Hybrid View
-
30 Aug 2011 7:34 AM #1
Unanswered: Reducing number of labels on X axis of Stock example
Unanswered: Reducing number of labels on X axis of Stock example
Take your Stock line chart example: how would you greatly reduce the number of labels on the X (time) axis?
You have approx seven months worth of data, and the number of labels is excessive. I have a stock line chart based on your example, but with >2 years worth of data, with a data point available on virtually every weekday. The number of labels that gets rendered is massive, and the X axis is unreadable.
How to reduce the number of labels to have a label + dash every (say) three months?
Looking at the source, it would seem that the real problem is one of reducing the number of "inflections" on the X axis, since both the rendering of labels and grid lines is keyed off the number of inflections. (A per-label inflection is only hidden if it would intersect with the previous label; i.e. you're trying to render as many as you possibly can, which I don't think is a good idea - unless the behaviour is overridable.)
Any help / guidance would be welcome.
-
30 Aug 2011 3:04 PM #2
Hi - Thanks for your report. Unfortunately this is a feature we currently do not have. I just implemented the use of the filter function for the labels, that gets called just like a renderer but returns true or false to set the labels as displayed or hidden.
I had to change the source code for this, and you probably won't be able to use the filter function until the next release, but if you want to change the source code of the framework to add this, then you would have to change the drawVerticalLabels and drawHorizontalLabels methods with this code:
Then for the Stocks example, I only render labels that are modulo 5:Code:drawHorizontalLabels: function() { var me = this, labelConf = me.labelStyle.style, renderer = labelConf.renderer || function(v) { return v; }, filter = labelConf.filter || function() { return true; }, math = Math, floor = math.floor, max = math.max, axes = me.chart.axes, position = me.position, inflections = me.inflections, ln = inflections.length, labels = me.labels, skipTicks = me.skipTicks, maxHeight = 0, ratio, bbox, point, prevLabel, textLabel, text, last, x, y, i, firstLabel; if (!me.calcLabels && skipTicks) { labels = labels.slice(skipTicks); ln -= skipTicks; } last = ln - 1; //get a reference to the first text label dimensions point = inflections[0]; firstLabel = me.getOrCreateLabel(0, renderer(labels[0])); ratio = math.abs(math.sin(labelConf.rotate && (labelConf.rotate.degrees * math.PI / 180) || 0)) >> 0; for (i = 0; i < ln; i++) { point = inflections[i]; text = renderer(labels[i]); textLabel = me.getOrCreateLabel(i, text); bbox = textLabel._bbox; maxHeight = max(maxHeight, bbox.height + me.dashSize + (labelConf.padding || 0)); x = floor(point[0] - (ratio? bbox.height : bbox.width) / 2); if (me.chart.maxGutter[0] == 0) { if (i == 0 && axes.findIndex('position', 'left') == -1) { x = point[0]; } else if (i == last && axes.findIndex('position', 'right') == -1) { x = point[0] - bbox.width; } } if (position == 'top') { y = point[1] - (me.dashSize * 2) - labelConf.padding - (bbox.height / 2); } else { y = point[1] + (me.dashSize * 2) + labelConf.padding + (bbox.height / 2); } if (!me.isPannable()) { x += me.x; y += me.y; } textLabel.setAttributes({ hidden: false, x: x, y: y }, true); if (labelConf.rotate) { textLabel.setAttributes(labelConf, true); } // Skip label if there isn't available minimum space if (i != 0 && (((me.intersect(textLabel, prevLabel) || me.intersect(textLabel, firstLabel))) || !filter(textLabel, text, i))) { textLabel.hide(true); continue; } prevLabel = textLabel; } return maxHeight; }, drawVerticalLabels: function() { var me = this, labelConf = me.labelStyle.style, renderer = labelConf.renderer || function(v) { return v; }, filter = labelConf.filter || function() { return true; }, inflections = me.inflections, position = me.position, ln = inflections.length, labels = me.labels, skipTicks = me.skipTicks, maxWidth = 0, math = Math, max = math.max, floor = math.floor, ceil = math.ceil, axes = me.chart.axes, gutterY = me.chart.maxGutter[1], bbox, point, prevLabel, textLabel, text, last, x, y, i; if (!me.calcLabels && skipTicks) { labels = labels.slice(skipTicks); ln -= skipTicks; } last = ln; for (i = 0; i < last; i++) { point = inflections[i]; text = renderer(labels[i]); textLabel = me.getOrCreateLabel(i, text); bbox = textLabel._bbox; maxWidth = max(maxWidth, bbox.width + me.dashSize + (labelConf.padding || 0)); y = point[1]; if (gutterY < bbox.height / 2) { if (i == last - 1 && axes.findIndex('position', 'top') == -1) { y += ceil(bbox.height / 2); } else if (i == 0 && axes.findIndex('position', 'bottom') == -1) { y -= floor(bbox.height / 2); } } if (position == 'left') { x = point[0] - bbox.width - me.dashSize - (labelConf.padding || 0) - 2; } else { x = point[0] + me.dashSize + (labelConf.padding || 0) + 2; } if (!me.isPannable()) { x += me.x; y += me.y + me.panY; } textLabel.setAttributes(Ext.apply({ hidden: false, x: x, y: y }, labelConf), true); // Skip label if there isn't available minimum space if (i != 0 && (me.intersect(textLabel, prevLabel) || !filter(textLabel, text, i))) { textLabel.hide(true); continue; } prevLabel = textLabel; } return maxWidth; },
I hope this helps,Code:axes: [{ type: 'Numeric', position: 'left', fields: ['djia'], title: 'Dow Jones Average' }, { type: 'Numeric', position: 'right', fields: ['sp500'], title: 'S&P 500' }, { type: 'Time', position: 'bottom', fields: ['date'], dateFormat: ' M d ', label: { filter: function(textLabel, text, i) { return (+text.split(' ')[2] % 5) === 0; }, rotate: { degrees: 45 } } }],
-
31 Aug 2011 6:52 AM #3
Filtering of inflections: great results
Filtering of inflections: great results
Thanks philogb.
That's useful up to a point, and I would argue that you should include a boolean include-label filter function in future releases. However, you can and should go so much further. What we really want to do is to filter the X axis inflections and their associated paths (for the little dashes). Then we can draw a small number of vertical grid lines that divide up the line chart into (say) six sections, and just have a few, useful labels and dashes instead of information overload.
I've done this, and it looks great. Take a look at these two screenshots, showing the same line chart at different time periods...
screen1.jpg
screen2.jpg
Ok, how we do this? Well, we introduce a user-supplied filterInflection(index) function into 'drawAxis' (search for "AJW" to see all my modifications):-
The comments tell the story. Note that I've only made the mods for non-side axes. We end up with no unnecessary paths to render, and a sparse me.inflections array. Now we need to modify drawGrid() to ignore points (aka inflections) that are null:-Code:/** * Renders the axis into the screen and updates it's position. */ drawAxis: function (init) { var me = this, filterFnScope = me.filterFnScope || this, // AJW: scope for the filterInflection() function filterInflection = me.filterInflection || function() { return true; }, // AJW: filter inflection function zoomX = me.zoomX, zoomY = me.zoomY, x = me.startX * zoomX, y = me.startY * zoomY, gutterX = me.chart.maxGutter[0] * zoomX, gutterY = me.chart.maxGutter[1] * zoomY, dashSize = me.dashSize, subDashesX = me.minorTickSteps || 0, subDashesY = me.minorTickSteps || 0, isSide = me.isSide(), viewLength = me.length, bufferLength = viewLength * me.overflowBuffer, totalLength = viewLength * (isSide ? zoomY : zoomX), position = me.position, inflections = [], calcLabels = me.calcLabels, stepCalcs = me.applyData(), step = stepCalcs.step, from = stepCalcs.from, to = stepCalcs.to, math = Math, mfloor = math.floor, mmax = math.max, mmin = math.min, mround = math.round, trueLength, currentX, currentY, startX, startY, path, dashesX, dashesY, delta, skipTicks, i, index; // AJW: added index var me.updateSurfaceBox(); //If no steps are specified //then don't draw the axis. This generally happens //when an empty store. if (me.hidden || me.chart.store.getCount() < 1 || stepCalcs.steps <= 0) { me.getSurface().items.hide(true); if (me.displaySprite) { me.displaySprite.hide(true); } return; } me.from = stepCalcs.from; me.to = stepCalcs.to; if (isSide) { currentX = mfloor(x) + 0.5; path = ["M", currentX, y, "l", 0, -totalLength]; trueLength = totalLength - (gutterY * 2); } else { currentY = mfloor(y) + 0.5; path = ["M", x, currentY, "l", totalLength, 0]; trueLength = totalLength - (gutterX * 2); } delta = trueLength * step / (to - from); skipTicks = me.skipTicks = mfloor(mmax(0, (isSide ? totalLength + me.panY - viewLength - bufferLength : -me.panX - bufferLength)) / delta); dashesX = mmax(subDashesX +1, 0); dashesY = mmax(subDashesY +1, 0); if (calcLabels) { me.labels = [stepCalcs.from + skipTicks * step]; } if (isSide) { currentY = startY = y - gutterY - delta * skipTicks; currentX = x - ((position == 'left') * dashSize * 2); while (currentY >= startY - mmin(trueLength, viewLength + bufferLength * 2)) { path.push("M", currentX, mfloor(currentY) + 0.5, "l", dashSize * 2 + 1, 0); if (currentY != startY) { for (i = 1; i < dashesY; i++) { path.push("M", currentX + dashSize, mfloor(currentY + delta * i / dashesY) + 0.5, "l", dashSize + 1, 0); } } inflections.push([ mfloor(x), mfloor(currentY) ]); currentY -= delta; if (calcLabels) { // Cut everything that is after tenth digit after floating point. This is to get rid of // rounding errors, i.e. 12.00000000000121212. me.labels.push(+(me.labels[me.labels.length - 1] + step).toFixed(10)); } if (delta === 0) { break; } } if (mround(currentY + delta - (y - gutterY - trueLength))) { path.push("M", currentX, mfloor(y - totalLength + gutterY) + 0.5, "l", dashSize * 2 + 1, 0); for (i = 1; i < dashesY; i++) { path.push("M", currentX + dashSize, mfloor(y - totalLength + gutterY + delta * i / dashesY) + 0.5, "l", dashSize + 1, 0); } inflections.push([ mfloor(x), mfloor(currentY) ]); if (calcLabels) { // Cut everything that is after tenth digit after floating point. This is to get rid of // rounding errors, i.e. 12.00000000000121212. me.labels.push(+(me.labels[me.labels.length - 1] + step).toFixed(10)); } } } else { // AJW: initialize the index for the filterInflection function index = -1; currentX = startX = x + gutterX + delta * skipTicks; currentY = y - ((position == 'top') * dashSize * 2); while (currentX <= startX + mmin(trueLength, viewLength + bufferLength * 2)) { // AJW: increment the index for the filterInflection function index++; // AJW: if the filterInflection function returns false, then we don't want an inflection, label // and dash(es) for this data point. In this case we just push null onto the inflections array // to a) maintain a one-to-one correspondence between data points, inflections and labels and // b) indicate to the code that we don't want to render a grid line and label for this inflection. if (!filterInflection.call(filterFnScope, index)) { inflections.push(null); } else { path.push("M", mfloor(currentX) + 0.5, currentY, "l", 0, dashSize * 2 + 1); if (currentX != startX) { for (i = 1; i < dashesX; i++) { path.push("M", mfloor(currentX - delta * i / dashesX) + 0.5, currentY, "l", 0, dashSize + 1); } } inflections.push([ mfloor(currentX), mfloor(y) ]); } currentX += delta; if (calcLabels) { // Cut everything that is after tenth digit after floating point. This is to get rid of // rounding errors, i.e. 12.00000000000121212. me.labels.push(+(me.labels[me.labels.length - 1] + step).toFixed(10)); } if (delta === 0) { break; } } if (mround(currentX - delta - (x + gutterX + trueLength))) { path.push("M", mfloor(x + totalLength - gutterX) + 0.5, currentY, "l", 0, dashSize * 2 + 1); for (i = 1; i < dashesX; i++) { path.push("M", mfloor(x + totalLength - gutterX - delta * i / dashesX) + 0.5, currentY, "l", 0, dashSize + 1); } inflections.push([mfloor(currentX), mfloor(y) ]); if (calcLabels) { // Cut everything that is after tenth digit after floating point. This is to get rid of // rounding errors, i.e. 12.00000000000121212. me.labels.push(+(me.labels[me.labels.length - 1] + step).toFixed(10)); } } } if (!me.axis) { me.axis = me.getSurface().add(Ext.apply({ type: 'path', path: path }, me.style)); } me.axis.setAttributes({ path: path, hidden: false }, true); me.inflections = inflections; if (!init) { //if grids have been styled in some way if ( me.grid || me.gridStyle.style || me.gridStyle.oddStyle.style || me.gridStyle.evenStyle.style ) { me.drawGrid(); } } me.axisBBox = me.axis.getBBox(); me.drawLabel(); },
Code:/** * Renders an horizontal and/or vertical grid into the Surface. */ drawGrid: function() { var me = this, surface = me.getSurface(), grid = me.gridStyle.style || me.grid, odd = me.gridStyle.oddStyle.style || grid.odd, even = me.gridStyle.evenStyle.style || grid.even, inflections = me.inflections, ln = inflections.length - ((odd || even)? 0 : 1), position = me.position, gutter = me.chart.maxGutter, width = me.width - 2, point, prevPoint, i = 1, isSide = me.isSide(), path = [], styles, lineWidth, dlineWidth, oddPath = [], evenPath = []; if ((gutter[1] !== 0 && isSide) || (gutter[0] !== 0 && !isSide)) { i = 0; ln++; } for (; i < ln; i++) { point = inflections[i]; // AJW: if point is null, this means that a grid line is not wanted for this inflection. if (point === null) { continue; } prevPoint = inflections[i - 1]; if (odd || even) { path = (i % 2)? oddPath : evenPath; styles = ((i % 2)? odd : even) || {}; lineWidth = (styles.lineWidth || styles['stroke-width'] || 0) / 2; dlineWidth = 2 * lineWidth; if (position == 'left') { path.push("M", prevPoint[0] + 1 + lineWidth, prevPoint[1] + 0.5 - lineWidth, "L", prevPoint[0] + 1 + width - lineWidth, prevPoint[1] + 0.5 - lineWidth, "L", point[0] + 1 + width - lineWidth, point[1] + 0.5 + lineWidth, "L", point[0] + 1 + lineWidth, point[1] + 0.5 + lineWidth, "Z"); } else if (position == 'right') { path.push("M", prevPoint[0] - lineWidth, prevPoint[1] + 0.5 - lineWidth, "L", prevPoint[0] - width + lineWidth, prevPoint[1] + 0.5 - lineWidth, "L", point[0] - width + lineWidth, point[1] + 0.5 + lineWidth, "L", point[0] - lineWidth, point[1] + 0.5 + lineWidth, "Z"); } else if (position == 'top') { path.push("M", prevPoint[0] + 0.5 + lineWidth, prevPoint[1] + 1 + lineWidth, "L", prevPoint[0] + 0.5 + lineWidth, prevPoint[1] + 1 + width - lineWidth, "L", point[0] + 0.5 - lineWidth, point[1] + 1 + width - lineWidth, "L", point[0] + 0.5 - lineWidth, point[1] + 1 + lineWidth, "Z"); } else { path.push("M", prevPoint[0] + 0.5 + lineWidth, prevPoint[1] - lineWidth, "L", prevPoint[0] + 0.5 + lineWidth, prevPoint[1] - width + lineWidth, "L", point[0] + 0.5 - lineWidth, point[1] - width + lineWidth, "L", point[0] + 0.5 - lineWidth, point[1] - lineWidth, "Z"); } } else { if (position == 'left') { path = path.concat(["M", point[0] + 0.5, point[1] + 0.5, "l", width, 0]); } else if (position == 'right') { path = path.concat(["M", point[0] - 0.5, point[1] + 0.5, "l", -width, 0]); } else if (position == 'top') { path = path.concat(["M", point[0] + 0.5, point[1] + 0.5, "l", 0, width]); } else { path = path.concat(["M", point[0] + 0.5, point[1] - 0.5, "l", 0, -width]); } } } if (odd || even) { if (oddPath.length) { if (!me.gridOdd && oddPath.length) { me.gridOdd = surface.add({ type: 'path', path: oddPath }); } me.gridOdd.setAttributes(Ext.apply({ path: oddPath, hidden: false }, odd || {}), true); } if (evenPath.length) { if (!me.gridEven) { me.gridEven = surface.add({ type: 'path', path: evenPath }); } me.gridEven.setAttributes(Ext.apply({ path: evenPath, hidden: false }, even || {}), true); } } else { if (path.length) { if (!me.gridLines) { me.gridLines = me.getSurface().add({ type: 'path', path: path, "stroke-width": me.lineWidth || 1, stroke: me.gridColor || '#ccc' }); } me.gridLines.setAttributes({ hidden: false, path: path }, true); } else if (me.gridLines) { me.gridLines.hide(true); } } },
And we need to modify drawHorizontalLabels() similarly to ignore null points/inflections. Note the extra work I have to do to hide previously rendered labels:-
Now I specify the filterInflection function in my config:-Code:drawHorizontalLabels: function() { var me = this, labelConf = me.labelStyle.style, renderer = labelConf.renderer || function(v) { return v; }, math = Math, floor = math.floor, max = math.max, axes = me.chart.axes, position = me.position, inflections = me.inflections, ln = inflections.length, labels = me.labels, skipTicks = me.skipTicks, maxHeight = 0, ratio, bbox, point, prevLabel, textLabel, text, last, x, y, i, firstLabel; if (!me.calcLabels && skipTicks) { labels = labels.slice(skipTicks); ln -= skipTicks; } last = ln - 1; //get a reference to the first text label dimensions point = inflections[0]; firstLabel = me.getOrCreateLabel(0, renderer(labels[0])); ratio = math.abs(math.sin(labelConf.rotate && (labelConf.rotate.degrees * math.PI / 180) || 0)) >> 0; for (i = 0; i < ln; i++) { point = inflections[i]; // AJW: if point is null, this means that a label is not wanted for this inflection. if (point === null) { // AJW: not optimal, and I don't particularly like it, but I had to explicitly put in this // call to hide labels because otherwise changing the store data (e.g. changing time period // selection) won't clear the previously-rendered labels, and labels would overwrite each // other and look very messy. me.getOrCreateLabel(i, text).hide(true); continue; } text = renderer(labels[i]); textLabel = me.getOrCreateLabel(i, text); bbox = textLabel._bbox; maxHeight = max(maxHeight, bbox.height + me.dashSize + (labelConf.padding || 0)); x = floor(point[0] - (ratio? bbox.height : bbox.width) / 2); if (me.chart.maxGutter[0] == 0) { if (i == 0 && axes.findIndex('position', 'left') == -1) { x = point[0]; } else if (i == last && axes.findIndex('position', 'right') == -1) { x = point[0] - bbox.width; } } if (position == 'top') { y = point[1] - (me.dashSize * 2) - labelConf.padding - (bbox.height / 2); } else { y = point[1] + (me.dashSize * 2) + labelConf.padding + (bbox.height / 2); } if (!me.isPannable()) { x += me.x; y += me.y; } textLabel.setAttributes({ hidden: false, x: x, y: y }, true); if (labelConf.rotate) { textLabel.setAttributes(labelConf, true); } // Skip label if there isn't available minimum space if (i != 0 && (me.intersect(textLabel, prevLabel) || me.intersect(textLabel, firstLabel))) { textLabel.hide(true); continue; } prevLabel = textLabel; } return maxHeight; },
Note that this.keepEvery is a number that I set up before loading new data into the store. It's the numer of data points (= number of records in the store) divided by six (the number of sections I want) and rounded down to the nearest integer.Code:}, { type: 'Time', position: 'bottom', fields: ['date'], dateFormat: 'd M Y', grid: true, filterFnScope: this, filterInflection: function(index) { return ((index % this.keepEvery) === 0); } }],
This is pretty good! I cheated, of course, by only considering the X axis. But now I've got a stock line chart to be proud of.
It would be great to see something like this officially supported in the next version.
-
4 Sep 2011 4:21 PM #4
@awebb
I second your request to have the ability to customize the axis labels. Your example does indeed look much better.
I would go even further for a time scaled axis- have the ability to label at certain time points even if I don't have a point for that particular time. For example, if I am plotting over a range of say, 1 hour, I would like the ability to have the labels every x minutes ie. 1:00, 1:10, 1:20, 1:30 etc. even if I do not have a point at those particular times (example- data comes in at 1:03, 1:06, 1:11, 1:18, 1:25.....).
-
6 Sep 2011 8:41 AM #5
Hi - Thanks for your feedback. I just logged this and we will work on it for the next release.
-
24 Oct 2011 2:10 AM #6
Hi,
My problem is approximately the same. I have to reduce the number of labels + dash on the x axis...
I tried to modify the drawHorizontalLabels and [FONT=Menlo, 'Courier New', Courier, monospace][/FONT] drawVerticalLabels as philogb did but get the errors like me.labelStyle is not defined and me.issPannable() is not defined.
Am I missing something ?
Please if there's anyone that solved this problem I would really appreciate any help.
Thank you,
Luca
Thank you for reporting this bug. We will make it our priority to review this report.


Reply With Quote