sample annotation
[dygraphs.git] / dygraph-canvas.js
1 // Copyright 2006 Dan Vanderkam (danvdk@gmail.com)
2 // All Rights Reserved.
3
4 /**
5 * @fileoverview Based on PlotKit, but modified to meet the needs of dygraphs.
6 * In particular, support for:
7 * - grid overlays
8 * - error bars
9 * - dygraphs attribute system
10 */
11
12 /**
13 * Creates a new DygraphLayout object.
14 * @param {Object} options Options for PlotKit.Layout
15 * @return {Object} The DygraphLayout object
16 */
17 DygraphLayout = function(dygraph, options) {
18 this.dygraph_ = dygraph;
19 this.options = {}; // TODO(danvk): remove, use attr_ instead.
20 Dygraph.update(this.options, options ? options : {});
21 this.datasets = new Array();
22 };
23
24 DygraphLayout.prototype.attr_ = function(name) {
25 return this.dygraph_.attr_(name);
26 };
27
28 DygraphLayout.prototype.addDataset = function(setname, set_xy) {
29 this.datasets[setname] = set_xy;
30 };
31
32 // TODO(danvk): CONTRACT remove
33 DygraphLayout.prototype.addAnnotation = function() {
34 // Add an annotation to one series.
35 this.annotations = [];
36 for (var x = 10; x < 30; x += 2) {
37 this.annotations.push( {
38 series: 'sine wave',
39 xval: this.attr_('xValueParser')("200610" + x),
40 shortText: x,
41 text: 'Stock Market Crash ' + x
42 } );
43 }
44 this.annotations.push( {
45 series: 'another line',
46 xval: this.attr_('xValueParser')("20061013"),
47 shortText: 'X',
48 text: 'Another one'
49 } );
50 };
51
52 DygraphLayout.prototype.evaluate = function() {
53 this._evaluateLimits();
54 this._evaluateLineCharts();
55 this._evaluateLineTicks();
56 this._evaluateAnnotations();
57 };
58
59 DygraphLayout.prototype._evaluateLimits = function() {
60 this.minxval = this.maxxval = null;
61 if (this.options.dateWindow) {
62 this.minxval = this.options.dateWindow[0];
63 this.maxxval = this.options.dateWindow[1];
64 } else {
65 for (var name in this.datasets) {
66 if (!this.datasets.hasOwnProperty(name)) continue;
67 var series = this.datasets[name];
68 var x1 = series[0][0];
69 if (!this.minxval || x1 < this.minxval) this.minxval = x1;
70
71 var x2 = series[series.length - 1][0];
72 if (!this.maxxval || x2 > this.maxxval) this.maxxval = x2;
73 }
74 }
75 this.xrange = this.maxxval - this.minxval;
76 this.xscale = (this.xrange != 0 ? 1/this.xrange : 1.0);
77
78 this.minyval = this.options.yAxis[0];
79 this.maxyval = this.options.yAxis[1];
80 this.yrange = this.maxyval - this.minyval;
81 this.yscale = (this.yrange != 0 ? 1/this.yrange : 1.0);
82 };
83
84 DygraphLayout.prototype._evaluateLineCharts = function() {
85 // add all the rects
86 this.points = new Array();
87 for (var setName in this.datasets) {
88 if (!this.datasets.hasOwnProperty(setName)) continue;
89
90 var dataset = this.datasets[setName];
91 for (var j = 0; j < dataset.length; j++) {
92 var item = dataset[j];
93 var point = {
94 // TODO(danvk): here
95 x: ((parseFloat(item[0]) - this.minxval) * this.xscale),
96 y: 1.0 - ((parseFloat(item[1]) - this.minyval) * this.yscale),
97 xval: parseFloat(item[0]),
98 yval: parseFloat(item[1]),
99 name: setName
100 };
101
102 // limit the x, y values so they do not overdraw
103 if (point.y <= 0.0) {
104 point.y = 0.0;
105 }
106 if (point.y >= 1.0) {
107 point.y = 1.0;
108 }
109 this.points.push(point);
110 }
111 }
112 };
113
114 DygraphLayout.prototype._evaluateLineTicks = function() {
115 this.xticks = new Array();
116 for (var i = 0; i < this.options.xTicks.length; i++) {
117 var tick = this.options.xTicks[i];
118 var label = tick.label;
119 var pos = this.xscale * (tick.v - this.minxval);
120 if ((pos >= 0.0) && (pos <= 1.0)) {
121 this.xticks.push([pos, label]);
122 }
123 }
124
125 this.yticks = new Array();
126 for (var i = 0; i < this.options.yTicks.length; i++) {
127 var tick = this.options.yTicks[i];
128 var label = tick.label;
129 var pos = 1.0 - (this.yscale * (tick.v - this.minyval));
130 if ((pos >= 0.0) && (pos <= 1.0)) {
131 this.yticks.push([pos, label]);
132 }
133 }
134 };
135
136
137 /**
138 * Behaves the same way as PlotKit.Layout, but also copies the errors
139 * @private
140 */
141 DygraphLayout.prototype.evaluateWithError = function() {
142 this.evaluate();
143 if (!this.options.errorBars) return;
144
145 // Copy over the error terms
146 var i = 0; // index in this.points
147 for (var setName in this.datasets) {
148 if (!this.datasets.hasOwnProperty(setName)) continue;
149 var j = 0;
150 var dataset = this.datasets[setName];
151 for (var j = 0; j < dataset.length; j++, i++) {
152 var item = dataset[j];
153 var xv = parseFloat(item[0]);
154 var yv = parseFloat(item[1]);
155
156 if (xv == this.points[i].xval &&
157 yv == this.points[i].yval) {
158 this.points[i].errorMinus = parseFloat(item[2]);
159 this.points[i].errorPlus = parseFloat(item[3]);
160 }
161 }
162 }
163 };
164
165 DygraphLayout.prototype._evaluateAnnotations = function() {
166 // Add the annotations to the point to which they belong.
167 // Make a map from (setName, xval) to annotation for quick lookups.
168 var annotations = {};
169 for (var i = 0; i < this.annotations.length; i++) {
170 var a = this.annotations[i];
171 annotations[a.xval + "," + a.series] = a;
172 }
173
174 this.annotated_points = [];
175 for (var i = 0; i < this.points.length; i++) {
176 var p = this.points[i];
177 var k = p.xval + "," + p.name;
178 if (k in annotations) {
179 p.annotation = annotations[k];
180 this.annotated_points.push(p);
181 }
182 }
183 };
184
185 /**
186 * Convenience function to remove all the data sets from a graph
187 */
188 DygraphLayout.prototype.removeAllDatasets = function() {
189 delete this.datasets;
190 this.datasets = new Array();
191 };
192
193 /**
194 * Change the values of various layout options
195 * @param {Object} new_options an associative array of new properties
196 */
197 DygraphLayout.prototype.updateOptions = function(new_options) {
198 Dygraph.update(this.options, new_options ? new_options : {});
199 };
200
201 // Subclass PlotKit.CanvasRenderer to add:
202 // 1. X/Y grid overlay
203 // 2. Ability to draw error bars (if required)
204
205 /**
206 * Sets some PlotKit.CanvasRenderer options
207 * @param {Object} element The canvas to attach to
208 * @param {Layout} layout The DygraphLayout object for this graph.
209 * @param {Object} options Options to pass on to CanvasRenderer
210 */
211 DygraphCanvasRenderer = function(dygraph, element, layout, options) {
212 // TODO(danvk): remove options, just use dygraph.attr_.
213 this.dygraph_ = dygraph;
214
215 // default options
216 this.options = {
217 "strokeWidth": 0.5,
218 "drawXAxis": true,
219 "drawYAxis": true,
220 "axisLineColor": "black",
221 "axisLineWidth": 0.5,
222 "axisTickSize": 3,
223 "axisLabelColor": "black",
224 "axisLabelFont": "Arial",
225 "axisLabelFontSize": 9,
226 "axisLabelWidth": 50,
227 "drawYGrid": true,
228 "drawXGrid": true,
229 "gridLineColor": "rgb(128,128,128)",
230 "fillAlpha": 0.15,
231 "underlayCallback": null
232 };
233 Dygraph.update(this.options, options);
234
235 this.layout = layout;
236 this.element = element;
237 this.container = this.element.parentNode;
238
239 this.height = this.element.height;
240 this.width = this.element.width;
241
242 // --- check whether everything is ok before we return
243 if (!this.isIE && !(DygraphCanvasRenderer.isSupported(this.element)))
244 throw "Canvas is not supported.";
245
246 // internal state
247 this.xlabels = new Array();
248 this.ylabels = new Array();
249 this.annotations = new Array();
250
251 this.area = {
252 x: this.options.yAxisLabelWidth + 2 * this.options.axisTickSize,
253 y: 0
254 };
255 this.area.w = this.width - this.area.x - this.options.rightGap;
256 this.area.h = this.height - this.options.axisLabelFontSize -
257 2 * this.options.axisTickSize;
258
259 this.container.style.position = "relative";
260 this.container.style.width = this.width + "px";
261 };
262
263 DygraphCanvasRenderer.prototype.clear = function() {
264 if (this.isIE) {
265 // VML takes a while to start up, so we just poll every this.IEDelay
266 try {
267 if (this.clearDelay) {
268 this.clearDelay.cancel();
269 this.clearDelay = null;
270 }
271 var context = this.element.getContext("2d");
272 }
273 catch (e) {
274 // TODO(danvk): this is broken, since MochiKit.Async is gone.
275 this.clearDelay = MochiKit.Async.wait(this.IEDelay);
276 this.clearDelay.addCallback(bind(this.clear, this));
277 return;
278 }
279 }
280
281 var context = this.element.getContext("2d");
282 context.clearRect(0, 0, this.width, this.height);
283
284 for (var i = 0; i < this.xlabels.length; i++) {
285 var el = this.xlabels[i];
286 el.parentNode.removeChild(el);
287 }
288 for (var i = 0; i < this.ylabels.length; i++) {
289 var el = this.ylabels[i];
290 el.parentNode.removeChild(el);
291 }
292 for (var i = 0; i < this.annotations.length; i++) {
293 var el = this.annotations[i];
294 el.parentNode.removeChild(el);
295 }
296 this.xlabels = new Array();
297 this.ylabels = new Array();
298 this.annotations = new Array();
299 };
300
301
302 DygraphCanvasRenderer.isSupported = function(canvasName) {
303 var canvas = null;
304 try {
305 if (typeof(canvasName) == 'undefined' || canvasName == null)
306 canvas = document.createElement("canvas");
307 else
308 canvas = canvasName;
309 var context = canvas.getContext("2d");
310 }
311 catch (e) {
312 var ie = navigator.appVersion.match(/MSIE (\d\.\d)/);
313 var opera = (navigator.userAgent.toLowerCase().indexOf("opera") != -1);
314 if ((!ie) || (ie[1] < 6) || (opera))
315 return false;
316 return true;
317 }
318 return true;
319 };
320
321 /**
322 * Draw an X/Y grid on top of the existing plot
323 */
324 DygraphCanvasRenderer.prototype.render = function() {
325 // Draw the new X/Y grid
326 var ctx = this.element.getContext("2d");
327
328 if (this.options.underlayCallback) {
329 this.options.underlayCallback(ctx, this.area, this.layout, this.dygraph_);
330 }
331
332 if (this.options.drawYGrid) {
333 var ticks = this.layout.yticks;
334 ctx.save();
335 ctx.strokeStyle = this.options.gridLineColor;
336 ctx.lineWidth = this.options.axisLineWidth;
337 for (var i = 0; i < ticks.length; i++) {
338 var x = this.area.x;
339 var y = this.area.y + ticks[i][0] * this.area.h;
340 ctx.beginPath();
341 ctx.moveTo(x, y);
342 ctx.lineTo(x + this.area.w, y);
343 ctx.closePath();
344 ctx.stroke();
345 }
346 }
347
348 if (this.options.drawXGrid) {
349 var ticks = this.layout.xticks;
350 ctx.save();
351 ctx.strokeStyle = this.options.gridLineColor;
352 ctx.lineWidth = this.options.axisLineWidth;
353 for (var i=0; i<ticks.length; i++) {
354 var x = this.area.x + ticks[i][0] * this.area.w;
355 var y = this.area.y + this.area.h;
356 ctx.beginPath();
357 ctx.moveTo(x, y);
358 ctx.lineTo(x, this.area.y);
359 ctx.closePath();
360 ctx.stroke();
361 }
362 }
363
364 // Do the ordinary rendering, as before
365 this._renderLineChart();
366 this._renderAxis();
367 this._renderAnnotations();
368 };
369
370
371 DygraphCanvasRenderer.prototype._renderAxis = function() {
372 if (!this.options.drawXAxis && !this.options.drawYAxis)
373 return;
374
375 var context = this.element.getContext("2d");
376
377 var labelStyle = {
378 "position": "absolute",
379 "fontSize": this.options.axisLabelFontSize + "px",
380 "zIndex": 10,
381 "color": this.options.axisLabelColor,
382 "width": this.options.axisLabelWidth + "px",
383 "overflow": "hidden"
384 };
385 var makeDiv = function(txt) {
386 var div = document.createElement("div");
387 for (var name in labelStyle) {
388 if (labelStyle.hasOwnProperty(name)) {
389 div.style[name] = labelStyle[name];
390 }
391 }
392 div.appendChild(document.createTextNode(txt));
393 return div;
394 };
395
396 // axis lines
397 context.save();
398 context.strokeStyle = this.options.axisLineColor;
399 context.lineWidth = this.options.axisLineWidth;
400
401 if (this.options.drawYAxis) {
402 if (this.layout.yticks && this.layout.yticks.length > 0) {
403 for (var i = 0; i < this.layout.yticks.length; i++) {
404 var tick = this.layout.yticks[i];
405 if (typeof(tick) == "function") return;
406 var x = this.area.x;
407 var y = this.area.y + tick[0] * this.area.h;
408 context.beginPath();
409 context.moveTo(x, y);
410 context.lineTo(x - this.options.axisTickSize, y);
411 context.closePath();
412 context.stroke();
413
414 var label = makeDiv(tick[1]);
415 var top = (y - this.options.axisLabelFontSize / 2);
416 if (top < 0) top = 0;
417
418 if (top + this.options.axisLabelFontSize + 3 > this.height) {
419 label.style.bottom = "0px";
420 } else {
421 label.style.top = top + "px";
422 }
423 label.style.left = "0px";
424 label.style.textAlign = "right";
425 label.style.width = this.options.yAxisLabelWidth + "px";
426 this.container.appendChild(label);
427 this.ylabels.push(label);
428 }
429
430 // The lowest tick on the y-axis often overlaps with the leftmost
431 // tick on the x-axis. Shift the bottom tick up a little bit to
432 // compensate if necessary.
433 var bottomTick = this.ylabels[0];
434 var fontSize = this.options.axisLabelFontSize;
435 var bottom = parseInt(bottomTick.style.top) + fontSize;
436 if (bottom > this.height - fontSize) {
437 bottomTick.style.top = (parseInt(bottomTick.style.top) -
438 fontSize / 2) + "px";
439 }
440 }
441
442 context.beginPath();
443 context.moveTo(this.area.x, this.area.y);
444 context.lineTo(this.area.x, this.area.y + this.area.h);
445 context.closePath();
446 context.stroke();
447 }
448
449 if (this.options.drawXAxis) {
450 if (this.layout.xticks) {
451 for (var i = 0; i < this.layout.xticks.length; i++) {
452 var tick = this.layout.xticks[i];
453 if (typeof(dataset) == "function") return;
454
455 var x = this.area.x + tick[0] * this.area.w;
456 var y = this.area.y + this.area.h;
457 context.beginPath();
458 context.moveTo(x, y);
459 context.lineTo(x, y + this.options.axisTickSize);
460 context.closePath();
461 context.stroke();
462
463 var label = makeDiv(tick[1]);
464 label.style.textAlign = "center";
465 label.style.bottom = "0px";
466
467 var left = (x - this.options.axisLabelWidth/2);
468 if (left + this.options.axisLabelWidth > this.width) {
469 left = this.width - this.options.xAxisLabelWidth;
470 label.style.textAlign = "right";
471 }
472 if (left < 0) {
473 left = 0;
474 label.style.textAlign = "left";
475 }
476
477 label.style.left = left + "px";
478 label.style.width = this.options.xAxisLabelWidth + "px";
479 this.container.appendChild(label);
480 this.xlabels.push(label);
481 }
482 }
483
484 context.beginPath();
485 context.moveTo(this.area.x, this.area.y + this.area.h);
486 context.lineTo(this.area.x + this.area.w, this.area.y + this.area.h);
487 context.closePath();
488 context.stroke();
489 }
490
491 context.restore();
492 };
493
494
495 DygraphCanvasRenderer.prototype._renderAnnotations = function() {
496 var annotationStyle = {
497 "position": "absolute",
498 "fontSize": this.options.axisLabelFontSize + "px",
499 "zIndex": 10,
500 "width": "20px",
501 "overflow": "hidden",
502 "border": "1px solid black",
503 "background-color": "white",
504 "text-align": "center"
505 };
506
507 // Get a list of point with annotations.
508 var points = this.layout.annotated_points;
509 for (var i = 0; i < points.length; i++) {
510 var p = points[i];
511 var div = document.createElement("div");
512 for (var name in annotationStyle) {
513 if (annotationStyle.hasOwnProperty(name)) {
514 div.style[name] = annotationStyle[name];
515 }
516 }
517 div.appendChild(document.createTextNode(p.annotation.shortText));
518 div.style.left = (p.canvasx - 10) + "px";
519 div.style.top = p.canvasy + "px";
520 div.title = p.annotation.text;
521 div.style.color = this.colors[p.name];
522 div.style.borderColor = this.colors[p.name];
523 this.container.appendChild(div);
524 this.annotations.push(div);
525 }
526 };
527
528
529 /**
530 * Overrides the CanvasRenderer method to draw error bars
531 */
532 DygraphCanvasRenderer.prototype._renderLineChart = function() {
533 var context = this.element.getContext("2d");
534 var colorCount = this.options.colorScheme.length;
535 var colorScheme = this.options.colorScheme;
536 var fillAlpha = this.options.fillAlpha;
537 var errorBars = this.layout.options.errorBars;
538 var fillGraph = this.layout.options.fillGraph;
539 var stackedGraph = this.layout.options.stackedGraph;
540 var stepPlot = this.layout.options.stepPlot;
541
542 var setNames = [];
543 for (var name in this.layout.datasets) {
544 if (this.layout.datasets.hasOwnProperty(name)) {
545 setNames.push(name);
546 }
547 }
548 var setCount = setNames.length;
549
550 this.colors = {}
551 for (var i = 0; i < setCount; i++) {
552 this.colors[setNames[i]] = colorScheme[i % colorCount];
553 }
554
555 // Update Points
556 // TODO(danvk): here
557 for (var i = 0; i < this.layout.points.length; i++) {
558 var point = this.layout.points[i];
559 point.canvasx = this.area.w * point.x + this.area.x;
560 point.canvasy = this.area.h * point.y + this.area.y;
561 }
562
563 // create paths
564 var isOK = function(x) { return x && !isNaN(x); };
565
566 var ctx = context;
567 if (errorBars) {
568 if (fillGraph) {
569 this.dygraph_.warn("Can't use fillGraph option with error bars");
570 }
571
572 for (var i = 0; i < setCount; i++) {
573 var setName = setNames[i];
574 var color = this.colors[setName];
575
576 // setup graphics context
577 ctx.save();
578 var prevX = NaN;
579 var prevY = NaN;
580 var prevYs = [-1, -1];
581 var yscale = this.layout.yscale;
582 // should be same color as the lines but only 15% opaque.
583 var rgb = new RGBColor(color);
584 var err_color = 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' +
585 fillAlpha + ')';
586 ctx.fillStyle = err_color;
587 ctx.beginPath();
588 for (var j = 0; j < this.layout.points.length; j++) {
589 var point = this.layout.points[j];
590 if (point.name == setName) {
591 if (!isOK(point.y)) {
592 prevX = NaN;
593 continue;
594 }
595
596 // TODO(danvk): here
597 if (stepPlot) {
598 var newYs = [ prevY - point.errorPlus * yscale,
599 prevY + point.errorMinus * yscale ];
600 prevY = point.y;
601 } else {
602 var newYs = [ point.y - point.errorPlus * yscale,
603 point.y + point.errorMinus * yscale ];
604 }
605 newYs[0] = this.area.h * newYs[0] + this.area.y;
606 newYs[1] = this.area.h * newYs[1] + this.area.y;
607 if (!isNaN(prevX)) {
608 if (stepPlot) {
609 ctx.moveTo(prevX, newYs[0]);
610 } else {
611 ctx.moveTo(prevX, prevYs[0]);
612 }
613 ctx.lineTo(point.canvasx, newYs[0]);
614 ctx.lineTo(point.canvasx, newYs[1]);
615 if (stepPlot) {
616 ctx.lineTo(prevX, newYs[1]);
617 } else {
618 ctx.lineTo(prevX, prevYs[1]);
619 }
620 ctx.closePath();
621 }
622 prevYs = newYs;
623 prevX = point.canvasx;
624 }
625 }
626 ctx.fill();
627 }
628 } else if (fillGraph) {
629 var axisY = 1.0 + this.layout.minyval * this.layout.yscale;
630 if (axisY < 0.0) axisY = 0.0;
631 else if (axisY > 1.0) axisY = 1.0;
632 axisY = this.area.h * axisY + this.area.y;
633
634 var baseline = [] // for stacked graphs: baseline for filling
635
636 // process sets in reverse order (needed for stacked graphs)
637 for (var i = setCount - 1; i >= 0; i--) {
638 var setName = setNames[i];
639 var color = this.colors[setName];
640
641 // setup graphics context
642 ctx.save();
643 var prevX = NaN;
644 var prevYs = [-1, -1];
645 var yscale = this.layout.yscale;
646 // should be same color as the lines but only 15% opaque.
647 var rgb = new RGBColor(color);
648 var err_color = 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' +
649 fillAlpha + ')';
650 ctx.fillStyle = err_color;
651 ctx.beginPath();
652 for (var j = 0; j < this.layout.points.length; j++) {
653 var point = this.layout.points[j];
654 if (point.name == setName) {
655 if (!isOK(point.y)) {
656 prevX = NaN;
657 continue;
658 }
659 var newYs;
660 if (stackedGraph) {
661 lastY = baseline[point.canvasx];
662 if (lastY === undefined) lastY = axisY;
663 baseline[point.canvasx] = point.canvasy;
664 newYs = [ point.canvasy, lastY ];
665 } else {
666 newYs = [ point.canvasy, axisY ];
667 }
668 if (!isNaN(prevX)) {
669 ctx.moveTo(prevX, prevYs[0]);
670 if (stepPlot) {
671 ctx.lineTo(point.canvasx, prevYs[0]);
672 } else {
673 ctx.lineTo(point.canvasx, newYs[0]);
674 }
675 ctx.lineTo(point.canvasx, newYs[1]);
676 ctx.lineTo(prevX, prevYs[1]);
677 ctx.closePath();
678 }
679 prevYs = newYs;
680 prevX = point.canvasx;
681 }
682 }
683 ctx.fill();
684 }
685 }
686
687 for (var i = 0; i < setCount; i++) {
688 var setName = setNames[i];
689 var color = this.colors[setName];
690
691 // setup graphics context
692 context.save();
693 var point = this.layout.points[0];
694 var pointSize = this.dygraph_.attr_("pointSize");
695 var prevX = null, prevY = null;
696 var drawPoints = this.dygraph_.attr_("drawPoints");
697 var points = this.layout.points;
698 for (var j = 0; j < points.length; j++) {
699 var point = points[j];
700 if (point.name == setName) {
701 if (!isOK(point.canvasy)) {
702 // this will make us move to the next point, not draw a line to it.
703 prevX = prevY = null;
704 } else {
705 // A point is "isolated" if it is non-null but both the previous
706 // and next points are null.
707 var isIsolated = (!prevX && (j == points.length - 1 ||
708 !isOK(points[j+1].canvasy)));
709
710 if (!prevX) {
711 prevX = point.canvasx;
712 prevY = point.canvasy;
713 } else {
714 ctx.beginPath();
715 ctx.strokeStyle = color;
716 ctx.lineWidth = this.options.strokeWidth;
717 ctx.moveTo(prevX, prevY);
718 if (stepPlot) {
719 ctx.lineTo(point.canvasx, prevY);
720 }
721 prevX = point.canvasx;
722 prevY = point.canvasy;
723 ctx.lineTo(prevX, prevY);
724 ctx.stroke();
725 }
726
727 if (drawPoints || isIsolated) {
728 ctx.beginPath();
729 ctx.fillStyle = color;
730 ctx.arc(point.canvasx, point.canvasy, pointSize,
731 0, 2 * Math.PI, false);
732 ctx.fill();
733 }
734 }
735 }
736 }
737 }
738
739 context.restore();
740 };