I'd like to create a scatter or bubble chart with elliptical markers, with the ellipse center and axes determined by data values.
Is there any simple approach I can take, perhaps using a custom template ?
Here's an example of one way you could implement an elliptical marker template:
var data = [{ x: 2, y: 5, value1: 3, value2: 8 }, { x: 3, y: 3, value1: 5, value2: 2 }, { x: 1, y: 2, value1: 1, value2: 1 }, { x: 3, y: 2, value1: 4, value2: 3 }, { x: 5, y: 1, value1: 2, value2: 4 }, { x: 4, y: 3, value1: 2, value2: 2 }]; var minRx; var maxRx; var minRy; var maxRy; var minRadius = 5; var maxRadius = 20; var ellipticalMarker = { passStarting: function (passInfo) { maxRx = Number.MIN_VALUE; minRx = Number.MAX_VALUE; maxRy = Number.MIN_VALUE; minRy = Number.MAX_VALUE; for (var i = 0; i < data.length; i++) { maxRx = Math.max(maxRx, data[i].value1); minRx = Math.min(minRx, data[i].value1); maxRy = Math.max(maxRy, data[i].value2); minRy = Math.min(minRy, data[i].value2); } }, measure: function (measureInfo) { var item = measureInfo.data.item(), width = ((item.value1 - minRx) / (maxRx - minRx)) * (maxRadius - minRadius) + minRadius, height = ((item.value2 - minRy) / (maxRy - minRy)) * (maxRadius - minRadius) + minRadius; measureInfo.width = width; measureInfo.height = height; }, render: function (renderInfo) { var ctx = renderInfo.context, x = renderInfo.xPosition, y = renderInfo.yPosition, item = renderInfo.data.item(), width = renderInfo.availableWidth, height = renderInfo.availableHeight; ctx.fillStyle = renderInfo.data.actualItemBrush().fill(); ctx.strokeStyle = renderInfo.data.outline().fill(); ctx.lineWidth = 1.0 / Math.max(width, height); ctx.save(); ctx.translate(x, y); ctx.scale(width, height); ctx.translate(-x, -y); ctx.beginPath(); ctx.arc(x, y, 1, 0, 2.0 * Math.PI, false); ctx.fill(); ctx.stroke(); ctx.restore(); } }; $("#container").igDataChart({ width: "500px", height: "500px", dataSource: data, axes: [{ name: "xAxis", type: "numericX", minimumValue: 0, maximumValue: 8 }, { name: "yAxis", type: "numericY", minimumValue: 0, maximumValue: 8 }], series: [{ name: "series1", title: "Test Series", type: "scatter", xAxis: "xAxis", yAxis: "yAxis", xMemberPath: "x", yMemberPath: "y", markerTemplate: ellipticalMarker, showTooltip: true, tooltipTemplate: "tooltipTemplate" }], horizontalZoomable: true, verticalZoomable: true, windowResponse: "immediate", overviewPlusDetailPaneVisibility: "visible" }); });
Hope this helps! -Graham
Thanks Graham - this is almost what I need, and illustrates the fantastic flexibility of igDataChart !
However I'm getting a little lost in the scaling that's going on. For instance, the code sample above produces a marker at (2,5) with pixel radii independent of chart zoom. I would like this to be rendered as an ellipse extending from -1 to 5 on the x-axis (2-3 to 2+3) and -3 to 13 on the y-axis (5-8 to 5+8)
Can this be achieved with a simple modification ?
Here this may help. It determines the width of one unit by asking the axes to scale various values into the chart's pixel space, and then uses those are multipliers of the width and height. This should also make the shapes get larger as you zoom in. Please note thought, that as you start to make the markers this large you run into a few limitations in the current version of the chart. Marker visibility is determined based on whether the center is in view, at present, so the larger the marker, the odder it looks if it vanishes due to its center moving out of view. Having a different visibility mode here would need to be a feature request, unfortunately. Here is the updated logic:
$(function () { var data = [{ x: 2, y: 5, value1: 1.3, value2: .8 }, { x: 3, y: 3, value1: 1.5, value2: .8 }, { x: 1, y: 2, value1: 2, value2: 1 }, { x: 3, y: 2, value1: 1, value2: 1 }, { x: 5, y: 1, value1: 1.0, value2: 1.4 }, { x: 4, y: 3, value1: .2, value2: 1.2 }]; // var minRx; // var maxRx; // var minRy; // var maxRy; //var minRadius = 5; //var maxRadius = 20; var xDist = 0; var yDist = 0; var ellipticalMarker = { passStarting: function (passInfo) { // maxRx = Number.MIN_VALUE; // minRx = Number.MAX_VALUE; // maxRy = Number.MIN_VALUE; // minRy = Number.MAX_VALUE; // for (var i = 0; i < data.length; i++) { // maxRx = Math.max(maxRx, data[i].value1); // minRx = Math.min(minRx, data[i].value1); // maxRy = Math.max(maxRy, data[i].value2); // minRy = Math.min(minRy, data[i].value2); // } var x1 = $("#container").igDataChart("scaleValue", "xAxis", 0); var x2 = $("#container").igDataChart("scaleValue", "xAxis", 1); var y1 = $("#container").igDataChart("scaleValue", "yAxis", 0); var y2 = $("#container").igDataChart("scaleValue", "yAxis", 1); xDist = Math.abs(x1 - x2); yDist = Math.abs(y1 - y2); }, measure: function (measureInfo) { var item = measureInfo.data.item(), // width = ((item.value1 - minRx) / (maxRx - minRx)) * (maxRadius - minRadius) + minRadius, // height = ((item.value2 - minRy) / (maxRy - minRy)) * (maxRadius - minRadius) + minRadius; width = item.value1 * xDist; height = item.value2 * yDist; measureInfo.width = width; measureInfo.height = height; }, render: function (renderInfo) { var ctx = renderInfo.context, x = renderInfo.xPosition, y = renderInfo.yPosition, item = renderInfo.data.item(), width = renderInfo.availableWidth, height = renderInfo.availableHeight; ctx.fillStyle = renderInfo.data.actualItemBrush().fill(); ctx.strokeStyle = renderInfo.data.outline().fill(); ctx.lineWidth = 1.0 / Math.max(width, height); ctx.save(); ctx.translate(x, y); ctx.scale(width / 2.0, height / 2.0); ctx.translate(-x, -y); ctx.beginPath(); ctx.arc(x, y, 1, 0, 2.0 * Math.PI, false); ctx.fill(); ctx.stroke(); ctx.restore(); } }; $("#container").igDataChart({ width: "500px", height: "500px", dataSource: data, axes: [{ name: "xAxis", type: "numericX", minimumValue: 0, maximumValue: 8 }, { name: "yAxis", type: "numericY", minimumValue: 0, maximumValue: 8 }], series: [{ name: "series1", title: "Test Series", type: "scatter", xAxis: "xAxis", yAxis: "yAxis", xMemberPath: "x", yMemberPath: "y", markerTemplate: ellipticalMarker, showTooltip: true, tooltipTemplate: "tooltipTemplate" }], horizontalZoomable: true, verticalZoomable: true, windowResponse: "immediate" }); });
-Graham
Thanks Graham, this is just what I was looking for ! The behaviour when ellipse centers move outside the chart boundaries is quite OK
I'm now left with a few remaining questions:
The data I'm charting is scientfic and covers many orders of magnitude. I was using logarithmic x and y axes ( isLogarithmic: true ). When I use logarithmic axes with the ellipticalMarker template, the scaling goes awry. Can this be corrected ?
I have the overview pane displayed. When I zoom into the chart, the ellipses in the overview don't seem to behave correctly. Can this be corrected ?
Is there any documentation covering adding new marker types ? I'd like to be able to stop bugging you !
Nic,
For isLogarithmic, this will get the ellipses to display again:
var x1 = $("#container").igDataChart("scaleValue", "xAxis", 1);
var x2 = $("#container").igDataChart("scaleValue", "xAxis", 2);
var y1 = $("#container").igDataChart("scaleValue", "yAxis", 1);
var y2 = $("#container").igDataChart("scaleValue", "yAxis", 2);
Scaling 0 into the logarithmic axis is undefined so we need to pick a different two values in order to measure. But I don't think this will actually give you your correct behavior. As you are deciding on a static multiplier for all the ellipse widths or heights.
But if you want the "width" or "height" to be directly meaningful in terms of the values on the underlying axis, then you will basically need to scale the values in the context of the position of each ellipse. And ellipse of width 8 would have different apparent visual widths depending on where on the scale it is plotted, correct? And wouldn't it have a different radius on one side vs the other? Making it more of a blob than an ellipse?
In terms of documentation, our product guidance department hasn't gotten to writing any more detailed articles on how to configure marker templates. So the only current information is available in the API doc, and in some of the provided samples. I'm not sure there is a custom marker sample for the chart, but there is one for the Map CTP which uses the same API.
The general idea is that when the chart is rendering markers for a series, it will do it in two passes. It will call measure for each marker and each marker is allowed to express how much size it would like. It will then call render for each marker, passing in the underlying HTML5 Canvas context. It tells you how much size its decided you can have, and where you are to render in x/y coordinates. And you can render whatever you want at that position. If you want, you could even float DOM content over the chart at that position to represent your marker, but be warned, that DOM content will block mouse events from hitting the underlying chart if you are not careful, and DOM content will not be "clipped" by the plot area automatically.
We apologize for the lack of documentation, but certainly feel free to keep asking me questions, and we can document the answers here, for now ;-)
In addition, there is actually a completely seperate render phase that is used solely to generate a hit buffer, to aid in tooltips and mouse events over the markers. You are notified when this is occuring via the isHitTestRender property. When that is true, you can render some more simplified bounding box if you want, but you MUST use the color passed in by chart, as it helps the chart identify when the mouse is over the series.
Thanks Graham - that works. Many thanks for your excellent support
Here's an example of how you might try to correct the issues I describe above:
var data = [{ x: 2, y: 5, value1: 1.3, value2: .8 }, { x: 3, y: 3, value1: 1.5, value2: .8 }, { x: 1, y: 2, value1: 2, value2: 1 }, { x: 3, y: 2, value1: 1, value2: 1 }, { x: 5, y: 1, value1: 1.0, value2: 1.4 }, { x: 4, y: 3, value1: .2, value2: 1.2 }]; var xDist = 0; var yDist = 0; var chartWidget = $("#container"); var ellipticalMarker = { passStarting: function (passInfo) { }, measure: function (measureInfo) { //can't measure in this case, measure doesn't give you x,y position yet. }, render: function (renderInfo) { var ctx = renderInfo.context, x = renderInfo.xPosition, y = renderInfo.yPosition, item = renderInfo.data.item(), width = item.value1, height = item.value2, ux, uy; //get the axis values that equate to where this marker is being rendered. ux = chartWidget.igDataChart("unscaleValue", "xAxis", x); uy = chartWidget.igDataChart("unscaleValue", "yAxis", y); //see how wide the marker is in terms of pixels based on the item values being used as radius. width = (chartWidget.igDataChart("scaleValue", "xAxis", ux + width / 2.0) - x) * 2.0; height = (chartWidget.igDataChart("scaleValue", "yAxis", uy + height / 2.0) - y) * 2.0; //since we didn't measure, must update with how much size we actually used. renderInfo.availableWidth = width; renderInfo.availableHeight = height; //the chart is telling use what colors to use here ctx.fillStyle = renderInfo.data.actualItemBrush().fill(); ctx.strokeStyle = renderInfo.data.outline().fill(); ctx.lineWidth = 1.0 / Math.max(width, height); ctx.save(); //html5 canvas does support non circular arcs, so we either need to use a transform or rasterize it ourselves. ctx.translate(x, y); ctx.scale(width / 2.0, height / 2.0); ctx.translate(-x, -y); ctx.beginPath(); ctx.arc(x, y, 1, 0, 2.0 * Math.PI, false); ctx.fill(); ctx.stroke(); ctx.restore(); } }; $("#container").igDataChart({ width: "500px", height: "500px", dataSource: data, axes: [{ name: "xAxis", type: "numericX", minimumValue: 0, maximumValue: 8, isLogarithmic: true }, { name: "yAxis", type: "numericY", minimumValue: 0, maximumValue: 8, isLogarithmic: true }], series: [{ name: "series1", title: "Test Series", type: "scatter", xAxis: "xAxis", yAxis: "yAxis", xMemberPath: "x", yMemberPath: "y", markerTemplate: ellipticalMarker, showTooltip: true, tooltipTemplate: "tooltipTemplate" }], horizontalZoomable: true, verticalZoomable: true, windowResponse: "immediate" });
Note, though, that it is still drawing ellipses, and an ellipse that had radii that are defined in terms of the underlying scale would actually have a different radius in each direction, as far as I can tell. So this is not necessarily 100% correct. This leaves the issue of the OPD. Its a bit of an open issue at the moment that markers render at the same size in the OPD as in the normal chart. And is something we will be looking at adjusting in the future. There are some ways to work around this though, as the passStarting method will be notified of the size of the area the markers are being rendered in. But compensating for this gets a bit complex. The bubble series actually has a targetted correction here were it will scale the size of the markers being rendered in the OPD, but I'm not sure if the code we have above will play nicely with the bubble series. As the bubble series only has the one radius mapping it expects. -Graham