| 1 | /* |
| 2 | PlotKit Canvas |
| 3 | ============== |
| 4 | |
| 5 | Provides HTML Canvas Renderer. This is supported under: |
| 6 | |
| 7 | - Safari 2.0 |
| 8 | - Mozilla Firefox 1.5 |
| 9 | - Opera 9.0 preview 2 |
| 10 | - IE 6 (via VML Emulation) |
| 11 | |
| 12 | It uses DIVs for labels. |
| 13 | |
| 14 | Copyright |
| 15 | --------- |
| 16 | Copyright 2005,2006 (c) Alastair Tse <alastair^liquidx.net> |
| 17 | For use under the BSD license. <http://www.liquidx.net/plotkit> |
| 18 | |
| 19 | */ |
| 20 | // -------------------------------------------------------------------- |
| 21 | // Check required components |
| 22 | // -------------------------------------------------------------------- |
| 23 | |
| 24 | try { |
| 25 | if ((typeof(PlotKit.Base) == 'undefined') || |
| 26 | (typeof(PlotKit.Layout) == 'undefined')) |
| 27 | { |
| 28 | throw ""; |
| 29 | } |
| 30 | } |
| 31 | catch (e) { |
| 32 | throw "PlotKit.Layout depends on MochiKit.{Base,Color,DOM,Format} and PlotKit.{Base,Layout}" |
| 33 | } |
| 34 | |
| 35 | |
| 36 | // ------------------------------------------------------------------------ |
| 37 | // Defines the renderer class |
| 38 | // ------------------------------------------------------------------------ |
| 39 | |
| 40 | if (typeof(PlotKit.CanvasRenderer) == 'undefined') { |
| 41 | PlotKit.CanvasRenderer = {}; |
| 42 | } |
| 43 | |
| 44 | PlotKit.CanvasRenderer.NAME = "PlotKit.CanvasRenderer"; |
| 45 | PlotKit.CanvasRenderer.VERSION = PlotKit.VERSION; |
| 46 | |
| 47 | PlotKit.CanvasRenderer.__repr__ = function() { |
| 48 | return "[" + this.NAME + " " + this.VERSION + "]"; |
| 49 | }; |
| 50 | |
| 51 | PlotKit.CanvasRenderer.toString = function() { |
| 52 | return this.__repr__(); |
| 53 | } |
| 54 | |
| 55 | PlotKit.CanvasRenderer = function(element, layout, options) { |
| 56 | if (arguments.length > 0) |
| 57 | this.__init__(element, layout, options); |
| 58 | }; |
| 59 | |
| 60 | PlotKit.CanvasRenderer.prototype.__init__ = function(element, layout, options) { |
| 61 | var isNil = MochiKit.Base.isUndefinedOrNull; |
| 62 | var Color = MochiKit.Color.Color; |
| 63 | |
| 64 | // default options |
| 65 | this.options = { |
| 66 | "drawBackground": true, |
| 67 | "backgroundColor": Color.whiteColor(), |
| 68 | "colorScheme": PlotKit.Base.palette(PlotKit.Base.baseColors()[0]), |
| 69 | "strokeColor": Color.whiteColor(), |
| 70 | "strokeColorTransform": "asStrokeColor", |
| 71 | "strokeWidth": 0.5, |
| 72 | "shouldFill": true, |
| 73 | "shouldStroke": true, |
| 74 | "drawXAxis": true, |
| 75 | "drawYAxis": true, |
| 76 | "axisLineColor": Color.blackColor(), |
| 77 | "axisLineWidth": 0.5, |
| 78 | "axisTickSize": 3, |
| 79 | "axisLabelColor": Color.blackColor(), |
| 80 | "axisLabelFont": "Arial", |
| 81 | "axisLabelFontSize": 9, |
| 82 | "axisLabelWidth": 50, |
| 83 | "pieRadius": 0.4, |
| 84 | "enableEvents": true |
| 85 | }; |
| 86 | MochiKit.Base.update(this.options, options ? options : {}); |
| 87 | |
| 88 | this.layout = layout; |
| 89 | this.element = MochiKit.DOM.getElement(element); |
| 90 | this.container = this.element.parentNode; |
| 91 | |
| 92 | // Stuff relating to Canvas on IE support |
| 93 | this.isIE = PlotKit.Base.excanvasSupported(); |
| 94 | |
| 95 | if (this.isIE && !isNil(G_vmlCanvasManager)) { |
| 96 | this.IEDelay = 0.5; |
| 97 | this.maxTries = 5; |
| 98 | this.renderDelay = null; |
| 99 | this.clearDelay = null; |
| 100 | this.element = G_vmlCanvasManager.initElement(this.element); |
| 101 | } |
| 102 | |
| 103 | this.height = this.element.height; |
| 104 | this.width = this.element.width; |
| 105 | |
| 106 | // --- check whether everything is ok before we return |
| 107 | |
| 108 | if (isNil(this.element)) |
| 109 | throw "CanvasRenderer() - passed canvas is not found"; |
| 110 | |
| 111 | if (!this.isIE && !(PlotKit.CanvasRenderer.isSupported(this.element))) |
| 112 | throw "CanvasRenderer() - Canvas is not supported."; |
| 113 | |
| 114 | if (isNil(this.container) || (this.container.nodeName.toLowerCase() != "div")) |
| 115 | throw "CanvasRenderer() - <canvas> needs to be enclosed in <div>"; |
| 116 | |
| 117 | // internal state |
| 118 | this.xlabels = new Array(); |
| 119 | this.ylabels = new Array(); |
| 120 | this.isFirstRender = true; |
| 121 | |
| 122 | this.area = { |
| 123 | x: this.options.yAxisLabelWidth + 2 * this.options.axisTickSize, |
| 124 | y: 0 |
| 125 | }; |
| 126 | this.area.w = this.width - this.area.x - this.options.rightGap; |
| 127 | this.area.h = this.height - this.options.axisLabelFontSize - |
| 128 | 2 * this.options.axisTickSize; |
| 129 | |
| 130 | MochiKit.DOM.updateNodeAttributes(this.container, |
| 131 | {"style":{ "position": "relative", "width": this.width + "px"}}); |
| 132 | |
| 133 | // load event system if we have Signals |
| 134 | /* Disabled until we have a proper implementation |
| 135 | try { |
| 136 | this.event_isinside = null; |
| 137 | if (MochiKit.Signal && this.options.enableEvents) { |
| 138 | this._initialiseEvents(); |
| 139 | } |
| 140 | } |
| 141 | catch (e) { |
| 142 | // still experimental |
| 143 | } |
| 144 | */ |
| 145 | }; |
| 146 | |
| 147 | PlotKit.CanvasRenderer.prototype.render = function() { |
| 148 | if (this.isIE) { |
| 149 | // VML takes a while to start up, so we just poll every this.IEDelay |
| 150 | try { |
| 151 | if (this.renderDelay) { |
| 152 | this.renderDelay.cancel(); |
| 153 | this.renderDelay = null; |
| 154 | } |
| 155 | var context = this.element.getContext("2d"); |
| 156 | } |
| 157 | catch (e) { |
| 158 | this.isFirstRender = false; |
| 159 | if (this.maxTries-- > 0) { |
| 160 | this.renderDelay = MochiKit.Async.wait(this.IEDelay); |
| 161 | this.renderDelay.addCallback(bind(this.render, this)); |
| 162 | } |
| 163 | return; |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | if (this.options.drawBackground) |
| 168 | this._renderBackground(); |
| 169 | |
| 170 | if (this.layout.style == "line") { |
| 171 | this._renderLineChart(); |
| 172 | this._renderLineAxis(); |
| 173 | } |
| 174 | }; |
| 175 | |
| 176 | PlotKit.CanvasRenderer.prototype._renderLineChart = function() { |
| 177 | var context = this.element.getContext("2d"); |
| 178 | var colorCount = this.options.colorScheme.length; |
| 179 | var colorScheme = this.options.colorScheme; |
| 180 | var setNames = MochiKit.Base.keys(this.layout.datasets); |
| 181 | var setCount = setNames.length; |
| 182 | var bind = MochiKit.Base.bind; |
| 183 | var partial = MochiKit.Base.partial; |
| 184 | |
| 185 | for (var i = 0; i < setCount; i++) { |
| 186 | var setName = setNames[i]; |
| 187 | var color = colorScheme[i%colorCount]; |
| 188 | var strokeX = this.options.strokeColorTransform; |
| 189 | |
| 190 | // setup graphics context |
| 191 | context.save(); |
| 192 | context.fillStyle = color.toRGBString(); |
| 193 | if (this.options.strokeColor) |
| 194 | context.strokeStyle = this.options.strokeColor.toRGBString(); |
| 195 | else if (this.options.strokeColorTransform) |
| 196 | context.strokeStyle = color[strokeX]().toRGBString(); |
| 197 | |
| 198 | context.lineWidth = this.options.strokeWidth; |
| 199 | |
| 200 | // create paths |
| 201 | var makePath = function(ctx) { |
| 202 | ctx.beginPath(); |
| 203 | ctx.moveTo(this.area.x, this.area.y + this.area.h); |
| 204 | var addPoint = function(ctx_, point) { |
| 205 | if (point.name == setName) |
| 206 | ctx_.lineTo(this.area.w * point.x + this.area.x, |
| 207 | this.area.h * point.y + this.area.y); |
| 208 | }; |
| 209 | MochiKit.Iter.forEach(this.layout.points, partial(addPoint, ctx), this); |
| 210 | ctx.lineTo(this.area.w + this.area.x, |
| 211 | this.area.h + this.area.y); |
| 212 | ctx.lineTo(this.area.x, this.area.y + this.area.h); |
| 213 | ctx.closePath(); |
| 214 | }; |
| 215 | |
| 216 | if (this.options.shouldFill) { |
| 217 | bind(makePath, this)(context); |
| 218 | context.fill(); |
| 219 | } |
| 220 | if (this.options.shouldStroke) { |
| 221 | bind(makePath, this)(context); |
| 222 | context.stroke(); |
| 223 | } |
| 224 | |
| 225 | context.restore(); |
| 226 | } |
| 227 | }; |
| 228 | |
| 229 | |
| 230 | PlotKit.CanvasRenderer.prototype._renderLineAxis = function() { |
| 231 | this._renderAxis(); |
| 232 | }; |
| 233 | |
| 234 | |
| 235 | PlotKit.CanvasRenderer.prototype._renderAxis = function() { |
| 236 | if (!this.options.drawXAxis && !this.options.drawYAxis) |
| 237 | return; |
| 238 | |
| 239 | var context = this.element.getContext("2d"); |
| 240 | |
| 241 | var labelStyle = {"style": |
| 242 | {"position": "absolute", |
| 243 | "fontSize": this.options.axisLabelFontSize + "px", |
| 244 | "zIndex": 10, |
| 245 | "color": this.options.axisLabelColor.toRGBString(), |
| 246 | "width": this.options.axisLabelWidth + "px", |
| 247 | "overflow": "hidden" |
| 248 | } |
| 249 | }; |
| 250 | |
| 251 | // axis lines |
| 252 | context.save(); |
| 253 | context.strokeStyle = this.options.axisLineColor.toRGBString(); |
| 254 | context.lineWidth = this.options.axisLineWidth; |
| 255 | |
| 256 | |
| 257 | if (this.options.drawYAxis) { |
| 258 | if (this.layout.yticks) { |
| 259 | var drawTick = function(tick) { |
| 260 | if (typeof(tick) == "function") return; |
| 261 | var x = this.area.x; |
| 262 | var y = this.area.y + tick[0] * this.area.h; |
| 263 | context.beginPath(); |
| 264 | context.moveTo(x, y); |
| 265 | context.lineTo(x - this.options.axisTickSize, y); |
| 266 | context.closePath(); |
| 267 | context.stroke(); |
| 268 | |
| 269 | var label = DIV(labelStyle, tick[1]); |
| 270 | var top = (y - this.options.axisLabelFontSize / 2); |
| 271 | if (top < 0) top = 0; |
| 272 | |
| 273 | if (top + this.options.axisLabelFontSize + 3 > this.height) { |
| 274 | label.style.bottom = "0px"; |
| 275 | } else { |
| 276 | label.style.top = top + "px"; |
| 277 | } |
| 278 | label.style.left = "0px"; |
| 279 | label.style.textAlign = "right"; |
| 280 | label.style.width = this.options.yAxisLabelWidth + "px"; |
| 281 | MochiKit.DOM.appendChildNodes(this.container, label); |
| 282 | this.ylabels.push(label); |
| 283 | }; |
| 284 | |
| 285 | MochiKit.Iter.forEach(this.layout.yticks, bind(drawTick, this)); |
| 286 | |
| 287 | // The lowest tick on the y-axis often overlaps with the leftmost |
| 288 | // tick on the x-axis. Shift the bottom tick up a little bit to |
| 289 | // compensate if necessary. |
| 290 | var bottomTick = this.ylabels[0]; |
| 291 | var fontSize = this.options.axisLabelFontSize; |
| 292 | var bottom = parseInt(bottomTick.style.top) + fontSize; |
| 293 | if (bottom > this.height - fontSize) { |
| 294 | bottomTick.style.top = (parseInt(bottomTick.style.top) - |
| 295 | fontSize / 2) + "px"; |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | context.beginPath(); |
| 300 | context.moveTo(this.area.x, this.area.y); |
| 301 | context.lineTo(this.area.x, this.area.y + this.area.h); |
| 302 | context.closePath(); |
| 303 | context.stroke(); |
| 304 | } |
| 305 | |
| 306 | if (this.options.drawXAxis) { |
| 307 | if (this.layout.xticks) { |
| 308 | var drawTick = function(tick) { |
| 309 | if (typeof(dataset) == "function") return; |
| 310 | |
| 311 | var x = this.area.x + tick[0] * this.area.w; |
| 312 | var y = this.area.y + this.area.h; |
| 313 | context.beginPath(); |
| 314 | context.moveTo(x, y); |
| 315 | context.lineTo(x, y + this.options.axisTickSize); |
| 316 | context.closePath(); |
| 317 | context.stroke(); |
| 318 | |
| 319 | var label = DIV(labelStyle, tick[1]); |
| 320 | label.style.textAlign = "center"; |
| 321 | label.style.bottom = "0px"; |
| 322 | |
| 323 | var left = (x - this.options.axisLabelWidth/2); |
| 324 | if (left + this.options.axisLabelWidth > this.width) { |
| 325 | left = this.width - this.options.xAxisLabelWidth; |
| 326 | label.style.textAlign = "right"; |
| 327 | } |
| 328 | if (left < 0) { |
| 329 | left = 0; |
| 330 | label.style.textAlign = "left"; |
| 331 | } |
| 332 | |
| 333 | label.style.left = left + "px"; |
| 334 | label.style.width = this.options.xAxisLabelWidth + "px"; |
| 335 | MochiKit.DOM.appendChildNodes(this.container, label); |
| 336 | this.xlabels.push(label); |
| 337 | }; |
| 338 | |
| 339 | MochiKit.Iter.forEach(this.layout.xticks, bind(drawTick, this)); |
| 340 | } |
| 341 | |
| 342 | context.beginPath(); |
| 343 | context.moveTo(this.area.x, this.area.y + this.area.h); |
| 344 | context.lineTo(this.area.x + this.area.w, this.area.y + this.area.h); |
| 345 | context.closePath(); |
| 346 | context.stroke(); |
| 347 | } |
| 348 | |
| 349 | context.restore(); |
| 350 | |
| 351 | }; |
| 352 | |
| 353 | PlotKit.CanvasRenderer.prototype._renderBackground = function() { |
| 354 | var context = this.element.getContext("2d"); |
| 355 | context.save(); |
| 356 | context.fillStyle = this.options.backgroundColor.toRGBString(); |
| 357 | context.fillRect(0, 0, this.width, this.height); |
| 358 | context.restore(); |
| 359 | }; |
| 360 | |
| 361 | PlotKit.CanvasRenderer.prototype.clear = function() { |
| 362 | if (this.isIE) { |
| 363 | // VML takes a while to start up, so we just poll every this.IEDelay |
| 364 | try { |
| 365 | if (this.clearDelay) { |
| 366 | this.clearDelay.cancel(); |
| 367 | this.clearDelay = null; |
| 368 | } |
| 369 | var context = this.element.getContext("2d"); |
| 370 | } |
| 371 | catch (e) { |
| 372 | this.isFirstRender = false; |
| 373 | this.clearDelay = MochiKit.Async.wait(this.IEDelay); |
| 374 | this.clearDelay.addCallback(bind(this.clear, this)); |
| 375 | return; |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | var context = this.element.getContext("2d"); |
| 380 | context.clearRect(0, 0, this.width, this.height); |
| 381 | |
| 382 | MochiKit.Iter.forEach(this.xlabels, MochiKit.DOM.removeElement); |
| 383 | MochiKit.Iter.forEach(this.ylabels, MochiKit.DOM.removeElement); |
| 384 | this.xlabels = new Array(); |
| 385 | this.ylabels = new Array(); |
| 386 | }; |
| 387 | |
| 388 | // ---------------------------------------------------------------- |
| 389 | // Everything below here is experimental and undocumented. |
| 390 | // ---------------------------------------------------------------- |
| 391 | |
| 392 | PlotKit.CanvasRenderer.prototype._initialiseEvents = function() { |
| 393 | var connect = MochiKit.Signal.connect; |
| 394 | var bind = MochiKit.Base.bind; |
| 395 | //MochiKit.Signal.registerSignals(this, ['onmouseover', 'onclick', 'onmouseout', 'onmousemove']); |
| 396 | //connect(this.element, 'onmouseover', bind(this.onmouseover, this)); |
| 397 | //connect(this.element, 'onmouseout', bind(this.onmouseout, this)); |
| 398 | //connect(this.element, 'onmousemove', bind(this.onmousemove, this)); |
| 399 | connect(this.element, 'onclick', bind(this.onclick, this)); |
| 400 | }; |
| 401 | |
| 402 | PlotKit.CanvasRenderer.prototype._resolveObject = function(e) { |
| 403 | // does not work in firefox |
| 404 | //var x = (e.event().offsetX - this.area.x) / this.area.w; |
| 405 | //var y = (e.event().offsetY - this.area.y) / this.area.h; |
| 406 | |
| 407 | var x = (e.mouse().page.x - PlotKit.Base.findPosX(this.element) - this.area.x) / this.area.w; |
| 408 | var y = (e.mouse().page.y - PlotKit.Base.findPosY(this.element) - this.area.y) / this.area.h; |
| 409 | |
| 410 | //log(x, y); |
| 411 | |
| 412 | var isHit = this.layout.hitTest(x, y); |
| 413 | if (isHit) |
| 414 | return isHit; |
| 415 | return null; |
| 416 | }; |
| 417 | |
| 418 | PlotKit.CanvasRenderer.prototype._createEventObject = function(layoutObj, e) { |
| 419 | if (layoutObj == null) { |
| 420 | return null; |
| 421 | } |
| 422 | |
| 423 | e.chart = layoutObj |
| 424 | return e; |
| 425 | }; |
| 426 | |
| 427 | |
| 428 | PlotKit.CanvasRenderer.prototype.onclick = function(e) { |
| 429 | var layoutObject = this._resolveObject(e); |
| 430 | var eventObject = this._createEventObject(layoutObject, e); |
| 431 | if (eventObject != null) |
| 432 | MochiKit.Signal.signal(this, "onclick", eventObject); |
| 433 | }; |
| 434 | |
| 435 | PlotKit.CanvasRenderer.prototype.onmouseover = function(e) { |
| 436 | var layoutObject = this._resolveObject(e); |
| 437 | var eventObject = this._createEventObject(layoutObject, e); |
| 438 | if (eventObject != null) |
| 439 | signal(this, "onmouseover", eventObject); |
| 440 | }; |
| 441 | |
| 442 | PlotKit.CanvasRenderer.prototype.onmouseout = function(e) { |
| 443 | var layoutObject = this._resolveObject(e); |
| 444 | var eventObject = this._createEventObject(layoutObject, e); |
| 445 | if (eventObject == null) |
| 446 | signal(this, "onmouseout", e); |
| 447 | else |
| 448 | signal(this, "onmouseout", eventObject); |
| 449 | |
| 450 | }; |
| 451 | |
| 452 | PlotKit.CanvasRenderer.prototype.onmousemove = function(e) { |
| 453 | var layoutObject = this._resolveObject(e); |
| 454 | var eventObject = this._createEventObject(layoutObject, e); |
| 455 | |
| 456 | if ((layoutObject == null) && (this.event_isinside == null)) { |
| 457 | // TODO: should we emit an event anyway? |
| 458 | return; |
| 459 | } |
| 460 | |
| 461 | if ((layoutObject != null) && (this.event_isinside == null)) |
| 462 | signal(this, "onmouseover", eventObject); |
| 463 | |
| 464 | if ((layoutObject == null) && (this.event_isinside != null)) |
| 465 | signal(this, "onmouseout", eventObject); |
| 466 | |
| 467 | if ((layoutObject != null) && (this.event_isinside != null)) |
| 468 | signal(this, "onmousemove", eventObject); |
| 469 | |
| 470 | this.event_isinside = layoutObject; |
| 471 | //log("move", x, y); |
| 472 | }; |
| 473 | |
| 474 | PlotKit.CanvasRenderer.isSupported = function(canvasName) { |
| 475 | var canvas = null; |
| 476 | try { |
| 477 | if (MochiKit.Base.isUndefinedOrNull(canvasName)) |
| 478 | canvas = MochiKit.DOM.CANVAS({}); |
| 479 | else |
| 480 | canvas = MochiKit.DOM.getElement(canvasName); |
| 481 | var context = canvas.getContext("2d"); |
| 482 | } |
| 483 | catch (e) { |
| 484 | var ie = navigator.appVersion.match(/MSIE (\d\.\d)/); |
| 485 | var opera = (navigator.userAgent.toLowerCase().indexOf("opera") != -1); |
| 486 | if ((!ie) || (ie[1] < 6) || (opera)) |
| 487 | return false; |
| 488 | return true; |
| 489 | } |
| 490 | return true; |
| 491 | }; |
| 492 | |
| 493 | // Namespace Iniitialisation |
| 494 | |
| 495 | PlotKit.Canvas = {} |
| 496 | PlotKit.Canvas.CanvasRenderer = PlotKit.CanvasRenderer; |
| 497 | |
| 498 | PlotKit.Canvas.EXPORT = [ |
| 499 | "CanvasRenderer" |
| 500 | ]; |
| 501 | |
| 502 | PlotKit.Canvas.EXPORT_OK = [ |
| 503 | "CanvasRenderer" |
| 504 | ]; |
| 505 | |
| 506 | PlotKit.Canvas.__new__ = function() { |
| 507 | var m = MochiKit.Base; |
| 508 | |
| 509 | m.nameFunctions(this); |
| 510 | |
| 511 | this.EXPORT_TAGS = { |
| 512 | ":common": this.EXPORT, |
| 513 | ":all": m.concat(this.EXPORT, this.EXPORT_OK) |
| 514 | }; |
| 515 | }; |
| 516 | |
| 517 | PlotKit.Canvas.__new__(); |
| 518 | MochiKit.Base._exportSymbols(this, PlotKit.Canvas); |
| 519 | |