A stacked heatmap layout for representing state of time-series over time. Time-series can be grouped into logical groups, represented as distinct sections. Explore the data by zooming (drag) or using the timeline brush at the bottom. Example is populated with randomly generated data.
Last active
March 27, 2026 15:01
-
-
Save vasturiano/ded69192b8269a78d2d97e24211e64e0 to your computer and use it in GitHub Desktop.
Timelines Chart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // D3 selections util funcs | |
| d3.selection.prototype.moveToFront = function() { | |
| return this.each(function(){ | |
| this.parentNode.appendChild(this); | |
| }); | |
| }; | |
| d3.selection.prototype.textFitToBox = function(w,h,passes) { | |
| passes = passes||3; | |
| var startSize = parseInt(this.style("font-size").split('px')[0]); | |
| var bbox = this.node().getBBox(); | |
| var newSize = Math.floor(startSize*Math.min(w/bbox.width, h/bbox.height)); | |
| if (newSize!=startSize) { | |
| this.style('font-size', newSize + 'px'); | |
| if(--passes) | |
| this.textFitToBox(w,h,passes); | |
| } | |
| return this; | |
| }; | |
| d3.selection.prototype.textAbbreviateToFit = function(maxW) { | |
| function abbreviateText(txt, maxChars) { | |
| return txt.length<=maxChars?txt:( | |
| txt.substring(0, maxChars*2/3) | |
| + '...' | |
| + txt.substring(txt.length - maxChars/3, txt.length) | |
| ); | |
| } | |
| var origTxt = this.text(); | |
| var nChars = Math.round(origTxt.length*maxW/this.node().getBBox().width*1.2); // Start above | |
| while(--nChars && maxW/this.node().getBBox().width<1){ | |
| this.text(abbreviateText(origTxt, nChars)); | |
| } | |
| return this; | |
| }; | |
| // colorScale: d3.scale.linear().domain([0, 1, 2]).range(['red', 'yellow', 'green']) | |
| // angle: 0 (left-right), 90 (down-up), ... | |
| d3.selection.prototype.addGradient = function(colorScale, angle) { | |
| angle = angle||0; // Horizontal | |
| var rad = Math.PI * angle/180; | |
| var gradId = "areaGradient" + Math.round(Math.random()*10000); | |
| var areaGradients = this.append("linearGradient") | |
| .attr("y1", Math.round(100*Math.max(0, Math.sin(rad))) + "%") | |
| .attr("y2", Math.round(100*Math.max(0, -Math.sin(rad))) + "%") | |
| .attr("x1", Math.round(100*Math.max(0, -Math.cos(rad))) + "%") | |
| .attr("x2", Math.round(100*Math.max(0, Math.cos(rad))) + "%") | |
| .attr("id", gradId); | |
| var threshVal = colorScale.domain()[0]; | |
| var normVal = colorScale.domain()[colorScale.domain().length-1] - threshVal; | |
| for (var i=0, len=colorScale.domain().length; i<len; i++) { | |
| areaGradients.append("stop") | |
| .attr("offset", (100*(colorScale.domain()[i] - threshVal)/normVal) + "%") | |
| .attr("stop-color", colorScale.range()[i]); | |
| } | |
| // Use with: .attr("fill", 'url(#<gradId>)'); | |
| return gradId; | |
| }; | |
| d3.selection.prototype.addDropShadow = function() { | |
| var shadowId = "areaGradient" + Math.round(Math.random()*10000); | |
| var filter = this.append('defs').append('filter') | |
| .attr('id', shadowId) | |
| .attr('height', '130%'); | |
| filter.append('feGaussianBlur') | |
| .attr('in', 'SourceAlpha') | |
| .attr('stdDeviation', 3); | |
| filter.append('feOffset') | |
| .attr('dx', 2) | |
| .attr('dy', 2) | |
| .attr('result', 'offsetblur'); | |
| var feMerge = filter.append('feMerge'); | |
| feMerge.append('feMergeNode'); | |
| feMerge.append('feMergeNode') | |
| .attr('in', 'SourceGraphic'); | |
| // Use with: .attr('filter', 'url(#<shadowId>)')) | |
| return shadowId; | |
| }; | |
| d3.selection.prototype.appendOrdinalColorLegend = function(w, h, scale, label) { | |
| var legend = this; | |
| var colorBinWidth = w / scale.domain().length; | |
| scale.domain().forEach(function(val, index) { | |
| var colorG = legend.append('g'); | |
| colorG.append("rect") | |
| .attr("width", colorBinWidth) | |
| .attr("height", h) | |
| .attr("x", colorBinWidth*index) | |
| .attr("y", 0) | |
| .attr("rx", 0) | |
| .attr("ry", 0) | |
| .attr("stroke-width", 0) | |
| .attr("fill", scale(val)); | |
| colorG.append("text") | |
| .text(val) | |
| .attr("x", colorBinWidth*(index+.5)) | |
| .attr("y", h*0.5) | |
| .style("text-anchor", "middle") | |
| .attr('dy','.4em') | |
| .style('fill', '#CCC' ) | |
| .style('font-family', 'Sans-Serif') | |
| .textFitToBox(colorBinWidth, h*0.8); | |
| colorG.append('title') | |
| .text(val + ' ' + label); | |
| }); | |
| legend.append("rect") | |
| .attr("width", w) | |
| .attr("height", h) | |
| .attr("x", 0) | |
| .attr("y", 0) | |
| .attr("rx", 3) | |
| .attr("ry", 3) | |
| .attr("stroke", "black") | |
| .attr("stroke-width", 0.5) | |
| .attr("fill-opacity", 0) | |
| .style("pointer-events", 'none'); | |
| return legend; | |
| }; | |
| d3.selection.prototype.appendLinearColorLegend = function(w, h, scale, label) { | |
| var gradId = this.addGradient(scale, 0); | |
| this.append("rect") | |
| .attr("width", w) | |
| .attr("height", h) | |
| .attr("x", 0) | |
| .attr("y", 0) | |
| .attr("rx", 3) | |
| .attr("ry", 3) | |
| .attr("stroke", "black") | |
| .attr("stroke-width", 0.5) | |
| .style("fill", 'url(#' + gradId + ')'); | |
| this.append("text") | |
| .attr("class", "legendText") | |
| .text(label) | |
| .attr("x", w*0.5) | |
| .attr("y", h*0.5) | |
| .style("text-anchor", "middle") | |
| .attr('dy','.35em') | |
| .style('font-family', 'Sans-Serif') | |
| .textFitToBox(w*0.8, h*0.9); | |
| this.append("text") | |
| .text(scale.domain()[0]) | |
| .attr("x", w*0.02) | |
| .attr("y", h*0.5) | |
| .style("text-anchor", "start") | |
| .attr('dy','.4em') | |
| .style('font', h*0.7 + 'px sans-serif') | |
| .style('fill', '#CCC' ) | |
| .style('font-family', 'Sans-Serif') | |
| .textFitToBox(w*0.3, h*0.7); | |
| this.append("text") | |
| .text(scale.domain()[scale.domain().length-1]) | |
| .attr("x", w*0.98) | |
| .attr("y", h*0.5) | |
| .style("text-anchor", "end") | |
| .attr('dy','.4em') | |
| .style('fill', '#CCC' ) | |
| .style('font-family', 'Sans-Serif') | |
| .textFitToBox(w*0.3, h*0.7); | |
| return this; | |
| }; | |
| d3.selection.prototype.appendColorLegend = function(x, y, w, h, scale, label) { | |
| var legendG = this.append("g") | |
| .attr("class", "legend"); | |
| legendG.attr("transform", "translate(" + x + "," + y + ")"); | |
| return (scale.copy().domain([1, 2]).range([1, 2])(1.5) === 1) | |
| ?legendG.appendOrdinalColorLegend(w, h, scale, label) | |
| :legendG.appendLinearColorLegend(w, h, scale, label); | |
| }; | |
| d3.selection.prototype.appendSvgThrobber = function(x, y, r, color, duration, angleFull) { | |
| function genDonutSlice(cx, cy, r, thickness, startAngle, endAngle) { | |
| startAngle = startAngle/180*Math.PI; | |
| endAngle = endAngle/180*Math.PI; | |
| var outerR=r; | |
| var innerR=r-thickness; | |
| p=[ | |
| [cx+outerR*Math.cos(startAngle), cy+outerR*Math.sin(startAngle)], | |
| [cx+outerR*Math.cos(endAngle), cy+outerR*Math.sin(endAngle)], | |
| [cx+innerR*Math.cos(endAngle), cy+innerR*Math.sin(endAngle)], | |
| [cx+innerR*Math.cos(startAngle), cy+innerR*Math.sin(startAngle)] | |
| ]; | |
| angleDiff = endAngle - startAngle; | |
| largeArc = ((angleDiff % (Math.PI * 2)) > Math.PI)?1:0; | |
| path = []; | |
| path.push("M" + p[0].join()); | |
| path.push("A" + [outerR,outerR,0,largeArc,1,p[1]].join()); | |
| path.push("L" + p[2].join()); | |
| path.push("A" + [innerR,innerR,0,largeArc,0,p[3]].join()); | |
| path.push("z"); | |
| return path.join(" "); | |
| } | |
| r = r||8; | |
| color = color||'darkblue'; | |
| duration = duration||0.7; | |
| angleFull = angleFull||120; | |
| var thickness = r/3; | |
| var path = this.append('path') | |
| .attr('d', genDonutSlice(x, y, r, thickness, 0, angleFull)) | |
| .attr('fill', color); | |
| path.append('animateTransform') | |
| .attr('attributeName', 'transform') | |
| .attr('attributeType', 'XML') | |
| .attr('type', 'rotate') | |
| .attr('from', '0 ' + x + ' ' + y) | |
| .attr('to', '360 ' + x + ' ' + y) | |
| .attr('begin', '0s') | |
| .attr('dur', duration + 's') | |
| .attr('fill', 'freeze') | |
| .attr('repeatCount', 'indefinite'); | |
| return path; | |
| }; | |
| d3.selection.prototype.appendImage = function(imgUrl, x, y, maxW, maxH, svgAlign) { | |
| svgAlign = svgAlign || "xMidYMid"; | |
| return new function(svgElem, imgUrl, x, y, maxW, maxH, svgAlign) { | |
| this.img = svgElem.append("image") | |
| .attr("xlink:href", imgUrl) | |
| .attr("x", x) | |
| .attr("y", y) | |
| .attr("width", maxW) | |
| .attr("height", maxH) | |
| .attr("preserveAspectRatio", svgAlign + " meet"); | |
| this.show = function() { | |
| this.img | |
| .attr("width", maxW) | |
| .attr("height", maxH); | |
| return this; | |
| }; | |
| this.hide = function() { | |
| this.img | |
| .attr("width", 0) | |
| .attr("height", 0); | |
| return this; | |
| }; | |
| }(this, imgUrl, x, y, maxW, maxH, svgAlign); | |
| }; | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <head> | |
| <script src="//code.jquery.com/jquery-2.2.3.min.js"></script> | |
| <script src="//cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script> | |
| <script src="//cdnjs.cloudflare.com/ajax/libs/d3-tip/0.6.7/d3-tip.min.js"></script> | |
| <script src="d3-utils.js"></script> | |
| <script src="time-overview.js"></script> | |
| <script src="stacked-heat-map.js"></script> | |
| <script src="mockup-data.js"></script> | |
| <link rel="stylesheet" type="text/css" href="stacked-heat-map.css"> | |
| <script> | |
| var myData = getMockupData(); | |
| $(function() { | |
| var myPlot = StackedTimeSeriesHeatMap() | |
| .width($(window).width()) | |
| .throbberImg('throbber.gif') | |
| .zScaleLabel("My Scale Units"); | |
| myPlot($('#myHeatMap'), myData); | |
| }); | |
| </script> | |
| </head> | |
| <body> | |
| <div id="myHeatMap"></div> | |
| </body> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| function getMockupData() { | |
| var NGROUPS = 6; | |
| var MAXLINES = 15; | |
| var MAXSEGMENTS = 20; | |
| var MINTIME = new Date(2013,2,21); | |
| function getGroupData() { | |
| function getSegmentsData() { | |
| var segData=[]; | |
| var nSegments = Math.ceil(Math.random()*MAXSEGMENTS); | |
| var segMaxLength = Math.round(((new Date())-MINTIME)/nSegments); | |
| var runLength = MINTIME; | |
| for (var i=0; i< nSegments; i++) { | |
| var tDivide = [Math.random(), Math.random()].sort(); | |
| var start = new Date(runLength.getTime() + tDivide[0]*segMaxLength); | |
| var end = new Date(runLength.getTime() + tDivide[1]*segMaxLength); | |
| runLength = new Date(runLength.getTime() + segMaxLength); | |
| segData.push({ | |
| 'timeRange': [start, end], | |
| 'val': Math.random() | |
| //'labelVal': is optional - only displayed in the labels | |
| }); | |
| } | |
| return segData; | |
| } | |
| var grpData = []; | |
| for (var i=0, nLines=Math.ceil(Math.random()*MAXLINES); i<nLines; i++) { | |
| grpData.push({ | |
| 'label': 'label' + (i+1), | |
| 'data': getSegmentsData() | |
| }); | |
| } | |
| return grpData; | |
| } | |
| var data = []; | |
| for (var i=0; i< NGROUPS; i++) { | |
| data.push({ | |
| 'group': 'group' + (i+1), | |
| 'data': getGroupData() | |
| }); | |
| } | |
| return data; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| .stacked-heat-map .axises { | |
| shape-rendering: crispedges; | |
| } | |
| .stacked-heat-map .axises line, .stacked-heat-map .axises path { | |
| fill: none; | |
| stroke: #808080; | |
| } | |
| .stacked-heat-map .y-axis line, .stacked-heat-map .y-axis path, .stacked-heat-map .grp-axis line, .stacked-heat-map .grp-axis path { | |
| stroke: none; | |
| } | |
| .stacked-heat-map .y-axis text, .stacked-heat-map .grp-axis text { | |
| fill: #2F4F4F; | |
| } | |
| .stacked-heat-map .x-grid line { | |
| stroke: #D3D3D3; | |
| } | |
| .stacked-heat-map rect.heatmap-group { | |
| fill-opacity: 0.6; | |
| stroke: #808080; | |
| stroke-opacity: 0.2; | |
| cursor: crosshair; | |
| } | |
| .stacked-heat-map rect.heatmap-segment { | |
| stroke: none; | |
| cursor: crosshair; | |
| } | |
| .legend .legendText { | |
| fill: #666; | |
| } | |
| .brusher .axis text { | |
| font: 11px sans-serif; | |
| } | |
| .brusher .axis path { | |
| display: none; | |
| } | |
| .brusher .axis line { | |
| fill: none; | |
| stroke: #000; | |
| shape-rendering: crispEdges; | |
| } | |
| .brusher .grid-background { | |
| fill: #C0C0C0; | |
| } | |
| .brusher .grid line, .brusher .grid path { | |
| fill: none; | |
| stroke: #fff; | |
| } | |
| .brusher .grid .minor.tick line { | |
| stroke-opacity: .5; | |
| } | |
| .brusher .brush .extent { | |
| stroke: blue; | |
| stroke-opacity: 0.6; | |
| fill: blue; | |
| fill-opacity: 0.3; | |
| shape-rendering: crispEdges; | |
| } | |
| /* | |
| * Cancel selection interaction | |
| */ | |
| .stat-noselect { | |
| -webkit-touch-callout: none; | |
| -webkit-user-select: none; | |
| -khtml-user-select: none; | |
| -moz-user-select: none; | |
| -ms-user-select: none; | |
| user-select: none; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* Stacked Time-Series Heat Map SVG Layout | |
| Exposed functions: | |
| .width(<px>) | |
| .leftMargin(<px>) | |
| .rightMargin(<px>) | |
| .topMargin(<px>) | |
| .bottomMargin(<px>) | |
| .maxHeight(<px>) | |
| .throbberImg(<image URI>) | |
| .dataDomain([<min>, <max>]) | |
| .dataScale(<d3 scale object>) | |
| .getNLines() | |
| .getTotalNLines() | |
| .zoomX([<start date>, <end date>], <force redraw (boolean). default: true>) | |
| .zoomY([<start row index, end row index], <force redraw (boolean). default: true>) | |
| .zoomYLabels([<(start) {group,label}>, <(end) {group,label}>], <force redraw (boolean). default: true>) | |
| .getVisibleStructure() | |
| .minSegmentDuration(<msecs>) | |
| .zDataLabel(<unit text on tooltips>) | |
| .zScaleLabel(<legend unit text>) | |
| .sort(<label compare function>, <group compare function>) | |
| .sortAlpha(<ascending (boolean)>) | |
| .sortChrono(<ascending (boolean)>) | |
| .replaceData(<new data>, <keep graph structure (boolean). default: false>) | |
| .enableOverview(<boolean>) | |
| .overviewDomain(<new time range for overview: [<start date>, <end date>]>) | |
| .animationsEnabled(<(boolean)>) | |
| .forceThrobber(<force throbber on (boolean>). default: false>) | |
| .axisClickURL(<URL to follow when clicking on Y axises>) | |
| .getSvg() | |
| .onZoom(<callback function for user initiated zoom>) | |
| .refresh() | |
| */ | |
| var StackedTimeSeriesHeatMap = function() { | |
| var env = { | |
| $elem: null, | |
| width : 720, // default width | |
| height : null, | |
| maxHeight : 640, // default maxHeight | |
| overviewHeight : 20, // Height of overview section in bottom | |
| lineMaxHeight : 12, | |
| minLabelFont : 2, | |
| margin : {top: 26, right: 100, bottom: 30, left: 90 }, | |
| groupBkgGradient : ['#FAFAFA', '#E0E0E0'], | |
| xScale : d3.time.scale(), | |
| yScale : d3.scale.ordinal(), | |
| grpScale : d3.scale.ordinal(), | |
| valScale : d3.scale.linear() | |
| .domain([0, 0.5, 1]) | |
| .range(["red", "yellow", "green"]) | |
| .clamp(false), | |
| zDataLabel: "", // Units of z data. Used in the tooltip descriptions | |
| zScaleLabel: "", // Units of valScale. Used in the legend label. | |
| xAxis : d3.svg.axis(), | |
| xGrid : d3.svg.axis(), | |
| yAxis : d3.svg.axis(), | |
| grpAxis : d3.svg.axis(), | |
| svg : null, | |
| graph : null, | |
| overviewArea: null, | |
| graphW : null, | |
| graphH : null, | |
| completeStructData : null, | |
| structData : null, | |
| completeFlatData : null, | |
| flatData : null, | |
| totalNLines : null, | |
| nLines : null, | |
| zoomX : [null, null], // Which time-range to show (null = min/max) | |
| zoomY : [null, null], // Which lines to show (null = min/max) [0 indexed] | |
| minSegmentDuration : 0, // ms | |
| transDuration : 700, // ms for transition duration | |
| throbber: null, | |
| throbberImg: null, | |
| throbberR: 23, | |
| forceThrobber: false, // Force the throbber to stay on | |
| enableOverview: true, | |
| axisClickURL: null, | |
| labelCmpFunction: alphaNumCmp, | |
| grpCmpFunction: alphaNumCmp, | |
| // Events callbacks | |
| onZoom: null // When user zooms in / resets zoom. Returns ([startX, endX], [startY, endY]) | |
| }; | |
| function chart($elem, data) { | |
| env.$elem = $elem; | |
| env.$elem.addClass('stacked-heat-map') | |
| .css('text-align', 'center'); | |
| env.svg = d3.select($elem[0]).append("svg"); | |
| initStatic(); | |
| drawNewData(data); | |
| return chart; | |
| } | |
| function parseData(rawData) { | |
| env.completeStructData = []; | |
| env.completeFlatData = []; | |
| env.totalNLines = 0; | |
| var dateObjs = rawData.length?rawData[0].data[0].data[0].timeRange[0] instanceof Date:false; | |
| for (var i= 0, ilen=rawData.length; i<ilen; i++) { | |
| var group = rawData[i].group; | |
| env.completeStructData.push({ | |
| group: group, | |
| lines: rawData[i].data.map(function(d) { return d.label; }) | |
| }); | |
| for (var j= 0, jlen=rawData[i].data.length; j<jlen; j++) { | |
| for (var k= 0, klen=rawData[i].data[j].data.length; k<klen; k++) { | |
| env.completeFlatData.push({ | |
| group: group, | |
| label: rawData[i].data[j].label, | |
| timeRange: (dateObjs | |
| ?rawData[i].data[j].data[k].timeRange | |
| :[new Date(rawData[i].data[j].data[k].timeRange[0]), new Date(rawData[i].data[j].data[k].timeRange[1])] | |
| ), | |
| val: rawData[i].data[j].data[k].val, | |
| labelVal: rawData[i].data[j].data[k][rawData[i].data[j].data[k].hasOwnProperty('labelVal')?'labelVal':'val'] | |
| }); | |
| } | |
| env.totalNLines++; | |
| } | |
| } | |
| } | |
| function initStatic() { | |
| buildDomStructure(); | |
| addTooltips(); | |
| addZoomSelection(); | |
| setEvents(); | |
| function buildDomStructure () { | |
| env.yScale.invert = invertOrdinal; | |
| env.grpScale.invert = invertOrdinal; | |
| env.groupGradId = env.svg.addGradient( | |
| d3.scale.linear() | |
| .domain([0, 1]) | |
| .range(env.groupBkgGradient), | |
| -90 | |
| ); | |
| env.graphW = env.width-env.margin.left-env.margin.right; | |
| env.xScale.range([0, env.graphW]) | |
| .clamp(true); | |
| env.svg.attr("width", env.width); | |
| var axises = env.svg.append('g'); | |
| env.graph = env.svg.append('g') | |
| .attr("transform", "translate(" + env.margin.left + "," + env.margin.top + ")"); | |
| axises.attr("class", "axises") | |
| .attr("transform", "translate(" + env.margin.left + "," + env.margin.top + ")"); | |
| axises.append("g") | |
| .attr("class", "x-axis") | |
| .style({ font: '12px sans-serif'}); | |
| axises.append("g") | |
| .attr("class", "x-grid"); | |
| axises.append("g") | |
| .attr("class", "y-axis") | |
| .attr("transform", "translate(" + env.graphW + ", 0)"); | |
| axises.append("g") | |
| .attr("class", "grp-axis"); | |
| env.xAxis.scale(env.xScale) | |
| .orient("bottom") | |
| .ticks(Math.round(env.graphW*0.011)); | |
| // Abbreviated month names | |
| var xAxisFormat = d3.time.format.multi([ | |
| [".%L", function(d) { return d.getMilliseconds(); }], | |
| [":%S", function(d) { return d.getSeconds(); }], | |
| ["%I:%M", function(d) { return d.getMinutes(); }], | |
| ["%I %p", function(d) { return d.getHours(); }], | |
| ["%a %d", function(d) { return d.getDay() && d.getDate() != 1; }], | |
| ["%b %d", function(d) { return d.getDate() != 1; }], | |
| ["%b", function(d) { return d.getMonth(); }], | |
| ["%Y", function() { return true; }] | |
| ]); | |
| env.xAxis.tickFormat(xAxisFormat); | |
| env.xGrid.scale(env.xScale) | |
| .orient("top") | |
| .tickFormat("") | |
| .ticks(env.xAxis.ticks()[0]); | |
| env.yAxis | |
| .scale(env.yScale) | |
| .orient("right") | |
| .tickSize(0); | |
| env.grpAxis | |
| .scale(env.grpScale) | |
| .orient("left") | |
| .tickSize(0); | |
| env.svg.appendColorLegend( | |
| (env.margin.left + env.graphW*0.05), | |
| 2, | |
| env.graphW/3, | |
| env.margin.top*.6, | |
| env.valScale, | |
| env.zScaleLabel | |
| ); | |
| if (env.enableOverview) { | |
| addOverviewArea(); | |
| } | |
| if (env.throbberImg) { | |
| env.throbber = env.svg.appendImage( | |
| env.throbberImg, | |
| env.margin.left + (env.graphW-env.throbberR)/2, | |
| env.margin.top + 5, | |
| env.throbberR, | |
| env.throbberR, | |
| 'xMidYMin' | |
| ).hide(); | |
| env.throbber.img | |
| .style('opacity', 0.85) | |
| .append('title').text('Loading data...'); | |
| } | |
| // Applies to ordinal scales (invert not supported in d3) | |
| function invertOrdinal(val, cmpFunc) { | |
| cmpFunc = cmpFunc || function(a, b) { | |
| return (a>=b); | |
| }; | |
| var bias = this.range()[0]; | |
| for (var i=0, len=this.range().length; i<len; i++) { | |
| if (cmpFunc(this.range()[i]+bias, val)) { | |
| return this.domain()[i]; | |
| } | |
| } | |
| return this.domain()[this.domain().length-1]; | |
| } | |
| function addOverviewArea() { | |
| var overviewMargins = { top: 1, right: 20, bottom: 20, left: 20 }; | |
| env.overviewArea = new TimeOverview( | |
| { | |
| margins: overviewMargins, | |
| granularityLevels: { | |
| "day": 43200 * 0.5, // 1 tick = 1 day if the total time window is within 0.5 month | |
| "week": 43200 * 5, // 1 tick = 1 week if the total time window is 5 month | |
| "month": (43200 * 12 * 1), // 1 tick = 1 month if the total time window is 1 year | |
| "year": (43200 * 12 * 20) | |
| }, | |
| width: env.width*0.8, | |
| height: env.overviewHeight + overviewMargins.top + overviewMargins.bottom, | |
| verticalLabels: false, | |
| format: xAxisFormat | |
| }, | |
| function(startTime, endTime) { | |
| env.$elem.trigger('zoom', [[startTime, endTime], null]); | |
| }, | |
| this | |
| ); | |
| env.overviewArea.init(($('<div>').appendTo(env.$elem))[0]); | |
| env.$elem.bind('zoomScent', function(event, zoomX, zoomY) { | |
| if (!env.overviewArea || !zoomX) return; | |
| // Out of overview bounds | |
| if (zoomX[0]<env.overviewArea.domainRange[0] || zoomX[1]>env.overviewArea.domainRange[1]) { | |
| env.overviewArea.update( | |
| [ | |
| new Date(Math.min(zoomX[0], env.overviewArea.domainRange[0])), | |
| new Date(Math.max(zoomX[1], env.overviewArea.domainRange[1])) | |
| ], | |
| env.zoomX | |
| ); | |
| } else { // Normal case | |
| env.overviewArea.updateSelection(zoomX); | |
| } | |
| /* | |
| var startLine = (zoomY&&zoomY[0]!=null)?zoomY[0]:0; | |
| var endLine = env.nLines?env.nLines+startLine:env.overviewArea.yDomain()[1]; | |
| */ | |
| }); | |
| } | |
| } | |
| function addTooltips() { | |
| env.groupTooltip = d3.tip() | |
| .direction('w') | |
| .offset([0, 0]) | |
| .style({ | |
| color: '#eee', | |
| background: "rgba(0,0,140,0.85)", | |
| padding: '5px', | |
| 'border-radius': '3px', | |
| font: '14px sans-serif', | |
| 'font-weight': 'bold', | |
| 'z-index': 4000 | |
| }) | |
| .html(function(d) { | |
| var leftPush = (d.hasOwnProperty("timeRange") | |
| ?env.xScale(d.timeRange[0]) | |
| :0 | |
| ); | |
| var topPush = (d.hasOwnProperty("label") | |
| ?env.grpScale(d.group)-env.yScale(d.group+"+&+"+d.label) | |
| :0 | |
| ); | |
| env.groupTooltip.offset([topPush, -leftPush]); | |
| return d.group; | |
| }); | |
| env.svg.call(env.groupTooltip); | |
| env.lineTooltip = d3.tip() | |
| .direction('e') | |
| .offset([0, 0]) | |
| .style({ | |
| color: '#eee', | |
| background: "rgba(0,0,140,0.85)", | |
| padding: '5px', | |
| 'border-radius': '3px', | |
| font: '13px sans-serif', | |
| 'font-weight': 'bold', | |
| 'z-index': 4000 | |
| }) | |
| .html(function(d) { | |
| var rightPush = (d.hasOwnProperty("timeRange")?env.xScale.range()[1]-env.xScale(d.timeRange[1]):0); | |
| env.lineTooltip.offset([0, rightPush]); | |
| return d.label; | |
| }); | |
| env.svg.call(env.lineTooltip); | |
| env.segmentTooltip = d3.tip() | |
| .direction('s') | |
| .offset([5, 0]) | |
| .style({ | |
| color: '#eee', | |
| background: "rgba(0,0,140,0.7)", | |
| padding: "5px", | |
| 'border-radius': "3px", | |
| font: '11px sans-serif', | |
| 'text-align': 'center', | |
| 'z-index': 4000 | |
| }) | |
| .html(function(d) { | |
| var normVal = env.valScale.domain()[env.valScale.domain().length-1] - env.valScale.domain()[0]; | |
| var dateFormat = d3.time.format("%Y-%m-%d %H:%M:%S"); | |
| return "<strong>" + d.labelVal + " </strong>" + env.zDataLabel | |
| + (normVal?" (<strong>" + d3.round((d.val-env.valScale.domain()[0])/normVal*100, 2) + "%</strong>)":"") + "<br>" | |
| + "<strong>From: </strong>" + dateFormat(d.timeRange[0]) + "<br>" | |
| + "<strong>To: </strong>" + dateFormat(d.timeRange[1]); | |
| }); | |
| env.svg.call(env.segmentTooltip); | |
| } | |
| function addZoomSelection() { | |
| env.graph.on("mousedown", function() { | |
| if (d3.select(window).on("mousemove.zoomRect")!=null) // Selection already active | |
| return; | |
| var e = this; | |
| if (d3.mouse(e)[0]<0 || d3.mouse(e)[0]>env.graphW || d3.mouse(e)[1]<0 || d3.mouse(e)[1]>env.graphH) | |
| return; | |
| env.disableHover=true; | |
| var rect = env.graph.append("rect") | |
| .style({ | |
| stroke: 'blue', | |
| 'stroke-opacity': .6, | |
| fill: 'blue', | |
| 'fill-opacity': .3 | |
| }); | |
| var startCoords = d3.mouse(e); | |
| d3.select("body").classed("stat-noselect", true); | |
| d3.select(window) | |
| .on("mousemove.zoomRect", function() { | |
| d3.event.stopPropagation(); | |
| var newCoords = [ | |
| Math.max(0, Math.min(env.graphW, d3.mouse(e)[0])), | |
| Math.max(0, Math.min(env.graphH, d3.mouse(e)[1])) | |
| ]; | |
| rect.attr("x", Math.min(startCoords[0], newCoords[0])) | |
| .attr("y", Math.min(startCoords[1], newCoords[1])) | |
| .attr("width", Math.abs(newCoords[0] - startCoords[0])) | |
| .attr("height", Math.abs(newCoords[1] - startCoords[1])); | |
| env.$elem.trigger('zoomScent', [ | |
| [startCoords[0], newCoords[0]].sort(d3.ascending).map(env.xScale.invert), | |
| [startCoords[1], newCoords[1]].sort(d3.ascending).map(function(d) { | |
| return env.yScale.domain().indexOf(env.yScale.invert(d)) | |
| + ((env.zoomY && env.zoomY[0])?env.zoomY[0]:0); | |
| }) | |
| ] | |
| ); | |
| }) | |
| .on("mouseup.zoomRect", function() { | |
| d3.select(window).on("mousemove.zoomRect", null).on("mouseup.zoomRect", null); | |
| d3.select("body").classed("stat-noselect", false); | |
| rect.remove(); | |
| env.disableHover=false; | |
| var endCoords = [ | |
| Math.max(0, Math.min(env.graphW, d3.mouse(e)[0])), | |
| Math.max(0, Math.min(env.graphH, d3.mouse(e)[1])) | |
| ]; | |
| if (startCoords[0]==endCoords[0] && startCoords[1]==endCoords[1]) | |
| return; | |
| var newDomainX = [startCoords[0], endCoords[0]].sort(d3.ascending).map(env.xScale.invert); | |
| var newDomainY = [startCoords[1], endCoords[1]].sort(d3.ascending).map(function(d) { | |
| return env.yScale.domain().indexOf(env.yScale.invert(d)) | |
| + ((env.zoomY && env.zoomY[0])?env.zoomY[0]:0); | |
| }); | |
| var changeX=((newDomainX[1] - newDomainX[0])>(60*1000)); // Zoom damper | |
| var changeY=(newDomainY[0]!=env.zoomY[0] || newDomainY[1]!=env.zoomY[1]); | |
| if (changeX || changeY) { | |
| env.$elem.trigger('zoom', [ | |
| changeX?newDomainX:null, | |
| changeY?newDomainY:null | |
| ]); | |
| } | |
| }, true); | |
| d3.event.stopPropagation(); | |
| }); | |
| env.svg.append('text') | |
| .text("Reset Zoom") | |
| .attr("x", env.margin.left + env.graphW*.99) | |
| .attr("y", env.margin.top *.8) | |
| .style("text-anchor", "end") | |
| .style({ | |
| 'font-family': 'sans-serif', | |
| fill: "blue", | |
| opacity: .6, | |
| cursor: 'pointer' | |
| }) | |
| .textFitToBox(env.graphW *.4, Math.min(13,env.margin.top *.8)) | |
| .on("mouseup" , function() { | |
| env.$elem.trigger('resetZoom'); | |
| }) | |
| .on("mouseover", function(){ | |
| d3.select(this).style('opacity', 1); | |
| }) | |
| .on("mouseout", function() { | |
| d3.select(this).style('opacity', .6); | |
| }); | |
| } | |
| function setEvents() { | |
| env.$elem.bind('zoom', function(event, zoomX, zoomY, redraw) { | |
| redraw = (redraw==null)?true:redraw; | |
| if (!zoomX && !zoomY) return; | |
| if (zoomX) env.zoomX=zoomX; | |
| if (zoomY) env.zoomY=zoomY; | |
| env.$elem.trigger('zoomScent', [zoomX, zoomY]); | |
| if (!redraw) return; | |
| draw(); | |
| if (env.onZoom) env.onZoom(env.zoomX, env.zoomY); | |
| }); | |
| env.$elem.bind('resetZoom', function() { | |
| var prevZoomX = env.zoomX; | |
| var prevZoomY = env.zoomY || [null, null]; | |
| var newZoomX = env.enableOverview | |
| ?env.overviewArea.domainRange | |
| :[ | |
| d3.min(env.flatData, function(d) { return d.timeRange[0]; }), | |
| d3.max(env.flatData, function(d) { return d.timeRange[1]; }) | |
| ]; | |
| newZoomY = [null, null]; | |
| if (prevZoomX[0]<newZoomX[0] || prevZoomX[1]>newZoomX[1] | |
| || prevZoomY[0]!=newZoomY[0] || prevZoomY[1]!=newZoomX[1]) { | |
| env.zoomX = [ | |
| new Date(Math.min(prevZoomX[0],newZoomX[0])), | |
| new Date(Math.max(prevZoomX[1],newZoomX[1])) | |
| ]; | |
| env.zoomY = newZoomY; | |
| env.$elem.trigger('zoomScent', [env.zoomX, env.zoomY]); | |
| draw(); | |
| } | |
| if (env.onZoom) env.onZoom(null, null); | |
| }); | |
| } | |
| } | |
| function drawNewData(data, keepGraphStructure) { | |
| keepGraphStructure = (keepGraphStructure==null?false:keepGraphStructure); | |
| var oldStructData = env.completeStructData; | |
| parseData(data); | |
| if (keepGraphStructure) { | |
| env.completeStructData = oldStructData; | |
| } else { | |
| env.zoomX = [ | |
| d3.min(env.completeFlatData, function(d) { return d.timeRange[0]; }), | |
| d3.max(env.completeFlatData, function(d) { return d.timeRange[1]; }) | |
| ]; | |
| env.zoomY = [null, null]; | |
| if (env.overviewArea) { | |
| env.overviewArea.update(env.zoomX, env.zoomX); | |
| //var yDomain = [0, env.totalNLines]; | |
| } | |
| } | |
| draw(); | |
| } | |
| function draw() { | |
| applyFilters(); | |
| setupHeights(); | |
| adjustXScale(); | |
| adjustYScale(); | |
| adjustGrpScale(); | |
| renderAxises(); | |
| renderGroups(); | |
| if (env.throbber) { env.throbber.show(); } | |
| renderTimelines(); | |
| if (env.throbber && !env.forceThrobber) { | |
| env.throbber.hide(); | |
| } | |
| function applyFilters() { | |
| // Flat data based on segment length | |
| env.flatData = (env.minSegmentDuration>0 | |
| ?env.completeFlatData.filter(function(d) { | |
| return (d.timeRange[1]-d.timeRange[0])>=env.minSegmentDuration; | |
| }) | |
| :env.completeFlatData | |
| ); | |
| // zoomY | |
| if (env.zoomY==null || env.zoomY==[null, null]) { | |
| env.structData = env.completeStructData; | |
| env.nLines=0; | |
| for (var i=0, len=env.structData.length; i<len; i++) { | |
| env.nLines += env.structData[i].lines.length; | |
| } | |
| return; | |
| } | |
| env.structData = []; | |
| var cntDwn = [env.zoomY[0]==null?0:env.zoomY[0]]; // Initial threshold | |
| cntDwn.push(Math.max(0, (env.zoomY[1]==null?env.totalNLines:env.zoomY[1]+1)-cntDwn[0])); // Number of lines | |
| env.nLines = cntDwn[1]; | |
| for (var i=0, len=env.completeStructData.length; i<len; i++) { | |
| var validLines = env.completeStructData[i].lines; | |
| if(env.minSegmentDuration>0) { // Use only non-filtered (due to segment length) groups/labels | |
| if (!env.flatData.some(function(d){ | |
| return d.group == env.completeStructData[i].group; | |
| })) { | |
| continue; // No data for this group | |
| } | |
| validLines = env.completeStructData[i].lines.filter( function(d) { | |
| return env.flatData.some( function (dd) { | |
| return (dd.group == env.completeStructData[i].group && dd.label == d); | |
| }) | |
| }); | |
| } | |
| if (cntDwn[0]>=validLines.length) { // Ignore whole group (before start) | |
| cntDwn[0]-=validLines.length; | |
| continue; | |
| } | |
| var groupData = { | |
| group: env.completeStructData[i].group, | |
| lines: null | |
| }; | |
| if (validLines.length-cntDwn[0]>=cntDwn[1]) { // Last (or first && last) group (partial) | |
| groupData.lines = validLines.slice(cntDwn[0],cntDwn[1]+cntDwn[0]); | |
| env.structData.push(groupData); | |
| cntDwn[1]=0; | |
| break; | |
| } | |
| if (cntDwn[0]>0) { // First group (partial) | |
| groupData.lines = validLines.slice(cntDwn[0]); | |
| cntDwn[0]=0; | |
| } else { // Middle group (full fit) | |
| groupData.lines = validLines; | |
| } | |
| env.structData.push(groupData); | |
| cntDwn[1]-=groupData.lines.length; | |
| } | |
| env.nLines-=cntDwn[1]; | |
| } | |
| function setupHeights() { | |
| env.graphH = d3.min([env.nLines*env.lineMaxHeight, env.maxHeight-env.margin.top-env.margin.bottom]); | |
| env.height = env.graphH + env.margin.top + env.margin.bottom; | |
| env.svg.transition().duration(env.transDuration) | |
| .attr("height", env.height); | |
| } | |
| function adjustXScale() { | |
| env.zoomX[0] = env.zoomX[0] || d3.min(env.flatData, function(d) { return d.timeRange[0]; }); | |
| env.zoomX[1] = env.zoomX[1] || d3.max(env.flatData, function(d) { return d.timeRange[1]; }); | |
| env.xScale.domain(env.zoomX); | |
| } | |
| function adjustYScale() { | |
| var labels = []; | |
| for (var i= 0, len=env.structData.length; i<len; i++) { | |
| labels = labels.concat(env.structData[i].lines.map(function (d) { | |
| return env.structData[i].group + "+&+" + d | |
| })); | |
| } | |
| env.yScale.domain(labels); | |
| env.yScale.rangePoints([env.graphH/labels.length*0.5, env.graphH*(1-0.5/labels.length)]); | |
| } | |
| function adjustGrpScale() { | |
| env.grpScale.domain(env.structData.map(function(d) { return d.group; })); | |
| var cntLines=0; | |
| env.grpScale.range(env.structData.map(function(d) { | |
| var pos = (cntLines+d.lines.length/2)/env.nLines*env.graphH; | |
| cntLines+=d.lines.length; | |
| return pos; | |
| })); | |
| } | |
| function renderAxises() { | |
| function reduceLabel(label, maxChars) { | |
| return label.length<=maxChars?label:( | |
| label.substring(0, maxChars*2/3) | |
| + '...' | |
| + label.substring(label.length - maxChars/3, label.length | |
| )); | |
| } | |
| // X | |
| env.svg.select('g.x-axis') | |
| .style({ | |
| 'stroke-opacity': 0, | |
| 'fill-opacity': 0 | |
| }) | |
| .attr("transform", "translate(0," + env.graphH + ")") | |
| .transition().duration(env.transDuration) | |
| .call(env.xAxis) | |
| .style({ | |
| 'stroke-opacity': 1, | |
| 'fill-opacity': 1 | |
| }); | |
| /* Angled x axis labels | |
| env.svg.select('g.x-axis').selectAll("text") | |
| .style("text-anchor", "end") | |
| .attr('transform', 'translate(-10, 3) rotate(-60)'); | |
| */ | |
| env.xGrid.tickSize(env.graphH); | |
| env.svg.select('g.x-grid') | |
| .attr("transform", "translate(0," + env.graphH + ")") | |
| .transition().duration(env.transDuration) | |
| .call(env.xGrid); | |
| // Y | |
| var fontVerticalMargin = 0.6; | |
| var labelDisplayRatio = Math.ceil(env.nLines*env.minLabelFont/Math.sqrt(2)/env.graphH/fontVerticalMargin); | |
| var tickVals = env.yScale.domain().filter(function(d, i) { return !(i % labelDisplayRatio); }); | |
| var fontSize = Math.min(12, env.graphH/tickVals.length*fontVerticalMargin*Math.sqrt(2)); | |
| var maxChars = Math.ceil(env.margin.right/(fontSize/Math.sqrt(2))); | |
| env.yAxis.tickValues(tickVals); | |
| env.yAxis.tickFormat(function(d) { | |
| return reduceLabel(d.split('+&+')[1], maxChars); | |
| }); | |
| env.svg.select('g.y-axis') | |
| .transition().duration(env.transDuration) | |
| .style({ font: fontSize + 'px sans-serif'}) | |
| .call(env.yAxis); | |
| // Grp | |
| var minHeight = d3.min(env.grpScale.range(), function (d,i) { | |
| return i>0?d-env.grpScale.range()[i-1]:d*2; | |
| }); | |
| fontSize = Math.min(14, minHeight*fontVerticalMargin*Math.sqrt(2)); | |
| maxChars = Math.floor(env.margin.left/(fontSize/Math.sqrt(2))); | |
| env.grpAxis.tickFormat(function(d) { | |
| return reduceLabel(d, maxChars); | |
| }); | |
| env.svg.select('g.grp-axis') | |
| .transition().duration(env.transDuration) | |
| .style({ font: fontSize + 'px sans-serif'}) | |
| .call(env.grpAxis); | |
| // Make Axises clickable | |
| if (env.axisClickURL) { | |
| env.svg.selectAll('g.y-axis,g.grp-axis').selectAll("text") | |
| .style("cursor", "pointer") | |
| .on("click", function(d){ | |
| var segms = d.split('+&+'); | |
| var lbl = segms[segms.length-1]; | |
| window.open(env.axisClickURL + lbl, '_blank'); | |
| }) | |
| .append('title') | |
| .text(function(d) { | |
| var segms = d.split('+&+'); | |
| var lbl = segms[segms.length-1]; | |
| return 'Open ' + lbl + ' on ' + env.axisClickURL; | |
| }); | |
| } | |
| } | |
| function renderGroups() { | |
| var groups = env.graph.selectAll('rect.heatmap-group').data(env.structData, function(d) { return d.group}); | |
| groups.exit() | |
| .transition().duration(env.transDuration) | |
| .style({ | |
| "stroke-opacity": 0, | |
| "fill-opacity": 0 | |
| }) | |
| .remove(); | |
| groups.enter() | |
| .append('rect').attr("class", "heatmap-group") | |
| .attr('width', env.graphW) | |
| .attr('x', 0) | |
| .attr('y', 0) | |
| .attr('height', 0) | |
| .style('fill', 'url(#' + env.groupGradId + ')') | |
| .on('mouseover', env.groupTooltip.show) | |
| .on('mouseout', env.groupTooltip.hide) | |
| .append('title') | |
| .text('click-drag to zoom in'); | |
| groups.transition().duration(env.transDuration) | |
| .attr('height', function (d) { | |
| return env.graphH*d.lines.length/env.nLines; | |
| }) | |
| .attr('y', function (d) { | |
| return env.grpScale(d.group)-env.graphH*d.lines.length/env.nLines/2; | |
| }); | |
| } | |
| function renderTimelines(maxElems) { | |
| if (maxElems<0) maxElems=null; | |
| var hoverEnlargeRatio = .4; | |
| var dataFilter = function(d, i) { | |
| return (maxElems==null || i<maxElems) && | |
| (env.grpScale.domain().indexOf(d.group)+1 && | |
| d.timeRange[1]>=env.xScale.domain()[0] && | |
| d.timeRange[0]<=env.xScale.domain()[1] && | |
| env.yScale.domain().indexOf(d.group+"+&+"+d.label)+1); | |
| }; | |
| env.lineHeight = env.graphH/env.nLines*0.8; | |
| var timelines = env.graph.selectAll('rect.heatmap-segment').data( | |
| env.flatData.filter(dataFilter), | |
| function(d) { return d.group + d.label + d.timeRange[0];} | |
| ); | |
| timelines.exit() | |
| .transition().duration(env.transDuration) | |
| .style({ | |
| "fill-opacity": 0 | |
| }) | |
| .remove(); | |
| var newSegments = timelines.enter() | |
| .append('rect').attr("class", "heatmap-segment") | |
| .attr('rx', 1) | |
| .attr('ry', 1) | |
| .attr('x', env.graphW/2) | |
| .attr('y', env.graphH/2) | |
| .attr('width', 0) | |
| .attr('height', 0) | |
| .style({ | |
| fill: function(d) { | |
| return env.valScale(d.val); | |
| }, | |
| 'fill-opacity': 0 | |
| }) | |
| .on('mouseover.groupTooltip', env.groupTooltip.show) | |
| .on('mouseout.groupTooltip', env.groupTooltip.hide) | |
| .on('mouseover.lineTooltip', env.lineTooltip.show) | |
| .on('mouseout.lineTooltip', env.lineTooltip.hide) | |
| .on('mouseover.segmentTooltip', env.segmentTooltip.show) | |
| .on('mouseout.segmentTooltip', env.segmentTooltip.hide); | |
| newSegments | |
| .on("mouseover", function() { | |
| if ('disableHover' in env && env.disableHover) | |
| return; | |
| var hoverEnlarge = env.lineHeight*hoverEnlargeRatio; | |
| d3.select(this) | |
| .moveToFront() | |
| .transition().duration(70) | |
| .attr('x', function (d) { | |
| return env.xScale(d.timeRange[0])-hoverEnlarge/2; | |
| }) | |
| .attr('width', function (d) { | |
| return d3.max([1, env.xScale(d.timeRange[1])-env.xScale(d.timeRange[0])])+hoverEnlarge; | |
| }) | |
| .attr('y', function (d) { | |
| return env.yScale(d.group+"+&+"+d.label)-(env.lineHeight+hoverEnlarge)/2; | |
| }) | |
| .attr('height', env.lineHeight+hoverEnlarge) | |
| .style({ | |
| "fill-opacity": 1 | |
| }); | |
| }) | |
| .on("mouseout", function() { | |
| d3.select(this) | |
| .transition().duration(250) | |
| .attr('x', function (d) { | |
| return env.xScale(d.timeRange[0]); | |
| }) | |
| .attr('width', function (d) { | |
| return d3.max([1, env.xScale(d.timeRange[1])-env.xScale(d.timeRange[0])]); | |
| }) | |
| .attr('y', function (d) { | |
| return env.yScale(d.group+"+&+"+d.label)-env.lineHeight/2; | |
| }) | |
| .attr('height', env.lineHeight) | |
| .style({ | |
| "fill-opacity": .8 | |
| }); | |
| }); | |
| timelines.transition().duration(env.transDuration) | |
| .attr('x', function (d) { | |
| return env.xScale(d.timeRange[0]); | |
| }) | |
| .attr('width', function (d) { | |
| return d3.max([1, env.xScale(d.timeRange[1])-env.xScale(d.timeRange[0])]); | |
| }) | |
| .attr('y', function (d) { | |
| return env.yScale(d.group+"+&+"+d.label)-env.lineHeight/2; | |
| }) | |
| .attr('height', env.lineHeight) | |
| .style({ 'fill-opacity': .8 }); | |
| } | |
| } | |
| function y2Label(y) { | |
| function getIdxLine(grpData, idx) { | |
| return { | |
| 'group': grpData.group, | |
| 'label': grpData.lines[idx] | |
| }; | |
| } | |
| if (y==null) return y; | |
| var cntDwn = y; | |
| for (var i=0, len=env.completeStructData.length; i<len; i++) { | |
| if (env.completeStructData[i].lines.length>cntDwn) | |
| return getIdxLine(env.completeStructData[i], cntDwn); | |
| cntDwn-=env.completeStructData[i].lines.length; | |
| } | |
| // y larger than all lines, return last | |
| return getIdxLine(env.completeStructData[env.completeStructData.length-1], env.completeStructData[env.completeStructData.length-1].lines.length-1); | |
| } | |
| function label2Y(label, useIdxAfterIfNotFound) { | |
| useIdxAfterIfNotFound = useIdxAfterIfNotFound || false; | |
| var subIdxNotFound = useIdxAfterIfNotFound?0:1; | |
| if (label==null) return label; | |
| var idx=0; | |
| for (var i=0, lenI=env.completeStructData.length; i<lenI; i++) { | |
| var grpCmp = env.grpCmpFunction(label.group, env.completeStructData[i].group); | |
| if (grpCmp<0) break; | |
| if (grpCmp==0 && label.group==env.completeStructData[i].group) { | |
| for (var j=0, lenJ=env.completeStructData[i].lines.length; j<lenJ; j++) { | |
| var cmpRes = env.labelCmpFunction(label.label, env.completeStructData[i].lines[j]); | |
| if (cmpRes<0) { | |
| return idx+j-subIdxNotFound; | |
| } | |
| if (cmpRes==0 && label.label==env.completeStructData[i].lines[j]) { | |
| return idx+j; | |
| } | |
| } | |
| return idx+env.completeStructData[i].lines.length-subIdxNotFound; | |
| } | |
| idx+=env.completeStructData[i].lines.length; | |
| } | |
| return idx-subIdxNotFound; | |
| } | |
| function alphaNumCmp(a,b){ | |
| var alist = a.split(/(\d+)/), | |
| blist = b.split(/(\d+)/); | |
| (alist.length && alist[alist.length-1] == '') ? alist.pop() : null; // remove the last element if empty | |
| (blist.length && blist[blist.length-1] == '') ? blist.pop() : null; // remove the last element if empty | |
| for (var i = 0, len = Math.max(alist.length, blist.length); i < len;i++){ | |
| if (alist.length==i || blist.length==i) { // Out of bounds for one of the sides | |
| return alist.length - blist.length; | |
| } | |
| if (alist[i] != blist[i]){ // find the first non-equal part | |
| if (alist[i].match(/\d/)) // if numeric | |
| { | |
| return (+alist[i])-(+blist[i]); // compare as number | |
| } else { | |
| return (alist[i].toLowerCase() > blist[i].toLowerCase())?1:-1; // compare as string | |
| } | |
| } | |
| } | |
| return 0; | |
| } | |
| // Exposed functions | |
| chart.width = function(_) { | |
| if (!arguments.length) { return env.width } | |
| env.width = _; | |
| return chart; | |
| }; | |
| chart.leftMargin = function(_) { | |
| if (!arguments.length) { return env.margin.left } | |
| env.margin.left = _; | |
| return chart; | |
| }; | |
| chart.rightMargin = function(_) { | |
| if (!arguments.length) { return env.margin.right } | |
| env.margin.right = _; | |
| return chart; | |
| }; | |
| chart.topMargin = function(_) { | |
| if (!arguments.length) { return env.margin.top } | |
| env.margin.top = _; | |
| return chart; | |
| }; | |
| chart.bottomMargin = function(_) { | |
| if (!arguments.length) { return env.margin.bottom } | |
| env.margin.bottom = _; | |
| return chart; | |
| }; | |
| chart.maxHeight = function(_) { | |
| if (!arguments.length) { return env.maxHeight; } | |
| env.maxHeight = _; | |
| return chart; | |
| }; | |
| chart.throbberImg = function(_) { | |
| if (!arguments.length) { return env.throbberImg; } | |
| env.throbberImg = _; | |
| return chart; | |
| }; | |
| chart.dataDomain = function(_) { | |
| if (!arguments.length) { | |
| return [env.valScale.domain()[0], env.valScale.domain()[env.valScale.domain.length-1]] | |
| } | |
| var midVal = _[0] + (_[1]-_[0])/2; | |
| env.valScale.domain([_[0], midVal, _[1]]); | |
| return chart; | |
| }; | |
| chart.dataScale = function(_) { | |
| if (!arguments.length) { return env.valScale; } | |
| env.valScale = _; | |
| return chart; | |
| }; | |
| chart.getNLines = function() { | |
| return env.nLines; | |
| }; | |
| chart.getTotalNLines = function() { | |
| return env.totalNLines; | |
| }; | |
| chart.zoomX = function(_, redraw) { | |
| if (!arguments.length) { return env.zoomX; } | |
| env.zoomX = _; | |
| if (env.$elem) | |
| env.$elem.trigger('zoom', [_, null, redraw]); | |
| return chart; | |
| }; | |
| chart.zoomY = function(_, redraw) { | |
| if (!arguments.length) { return env.zoomY; } | |
| env.zoomY = _; | |
| if (env.$elem) | |
| env.$elem.trigger('zoom', [null, _, redraw]); | |
| return chart; | |
| }; | |
| chart.zoomYLabels = function(_, redraw) { | |
| if (!arguments.length) { return [y2Label(env.zoomY[0]), y2Label(env.zoomY[1])]; } | |
| return chart.zoomY([label2Y(_[0], true), label2Y(_[1], false)], redraw); | |
| }; | |
| chart.getVisibleStructure = function() { | |
| return env.structData; | |
| }; | |
| chart.minSegmentDuration = function (_) { | |
| if (!arguments.length) { return env.minSegmentDuration; } | |
| env.minSegmentDuration = _; | |
| return chart; | |
| }; | |
| chart.zDataLabel = function (_) { | |
| if (!arguments.length) { return env.zDataLabel; } | |
| env.zDataLabel = _; | |
| return chart; | |
| }; | |
| chart.zScaleLabel = function (_) { | |
| if (!arguments.length) { return env.zScaleLabel; } | |
| env.zScaleLabel = _; | |
| return chart; | |
| }; | |
| chart.sort = function(labelCmpFunction, grpCmpFunction) { | |
| if (labelCmpFunction==null) { labelCmpFunction = env.labelCmpFunction } | |
| if (grpCmpFunction==null) { grpCmpFunction = env.grpCmpFunction } | |
| env.labelCmpFunction = labelCmpFunction; | |
| env.grpCmpFunction = grpCmpFunction; | |
| env.completeStructData.sort(function(a, b) { | |
| return grpCmpFunction(a.group, b.group); | |
| }); | |
| for (var i=0, len=env.completeStructData.length;i<len;i++) { | |
| env.completeStructData[i].lines.sort(labelCmpFunction); | |
| } | |
| draw(); | |
| return chart; | |
| }; | |
| chart.sortAlpha = function(asc) { | |
| if (asc==null) { asc=true } | |
| var alphaCmp = function (a, b) { return alphaNumCmp(asc?a:b, asc?b:a); }; | |
| chart.sort(alphaCmp, alphaCmp); | |
| return chart; | |
| }; | |
| chart.sortChrono = function(asc) { | |
| if (asc==null) { asc=true } | |
| function buildIdx(accessFunction) { | |
| var idx = {}; | |
| for (var i= 0, len=env.completeFlatData.length; i<len; i++ ) { | |
| var key = accessFunction(env.completeFlatData[i]); | |
| if (idx.hasOwnProperty(key)) { continue; } | |
| var itmList = env.completeFlatData.filter(function(d) { return key == accessFunction(d); }); | |
| idx[key] = [ | |
| d3.min(itmList, function(d) { return d.timeRange[0]}), | |
| d3.max(itmList, function(d) { return d.timeRange[1]}) | |
| ]; | |
| } | |
| return idx; | |
| } | |
| var timeCmp = function (a, b) { | |
| var aT = a[1], bT=b[1]; | |
| if (!aT || !bT) return null; // One of the two vals is null | |
| if (aT[1].getTime()==bT[1].getTime()) { | |
| if (aT[0].getTime()==bT[0].getTime()) { | |
| return alphaNumCmp(a[0],b[0]); // If first and last is same, use alphaNum | |
| } | |
| return aT[0]-bT[0]; // If last is same, earliest first wins | |
| } | |
| return bT[1]-aT[1]; // latest last wins | |
| }; | |
| function getCmpFunction(accessFunction, asc) { | |
| return function(a, b) { | |
| return timeCmp(accessFunction(asc?a:b), accessFunction(asc?b:a)); | |
| } | |
| } | |
| var grpIdx = buildIdx(function(d) { return d.group; }); | |
| var lblIdx = buildIdx(function(d) { return d.label; }); | |
| var grpCmp = getCmpFunction(function(d) { return [d, grpIdx[d] || null]; }, asc); | |
| var lblCmp = getCmpFunction(function(d) { return [d, lblIdx[d] || null]; }, asc); | |
| chart.sort(lblCmp, grpCmp); | |
| return chart; | |
| }; | |
| chart.replaceData =function(newData, keepGraphStructure) { | |
| keepGraphStructure = keepGraphStructure || false; | |
| drawNewData(newData, keepGraphStructure); | |
| return chart; | |
| }; | |
| // True/False | |
| chart.enableOverview = function(_) { | |
| if (!arguments.length) { return env.enableOverview; } | |
| env.enableOverview = _; | |
| return chart; | |
| }; | |
| chart.overviewDomain = function(_) { | |
| if (!env.enableOverview) { return null; } | |
| if (!arguments.length) { return env.overviewArea.domainRange; } | |
| env.overviewArea.update(_, env.overviewArea.currentSelection); | |
| return chart; | |
| }; | |
| // True/False | |
| chart.animationsEnabled = function(_) { | |
| if (!arguments.length) { return (env.transDuration !=0); } | |
| env.transDuration = (_?700:0); | |
| return chart; | |
| }; | |
| // True/false (true = shows throbber and leaves it on permanently. false = automatic internal management) | |
| chart.forceThrobber = function(_) { | |
| if (!arguments.length) { return env.forceThrobber; } | |
| env.forceThrobber=_; | |
| if (env.forceThrobber && env.throbber) { | |
| env.throbber.show(); | |
| } | |
| return chart; | |
| }; | |
| chart.axisClickURL = function(_) { | |
| if (!arguments.length) { return env.axisClickURL; } | |
| env.axisClickURL = _; | |
| return chart; | |
| }; | |
| chart.getSvg = function() { | |
| return d3.select(env.svg.node().parentNode).html(); | |
| }; | |
| chart.onZoom = function(_) { | |
| if (!arguments.length) { return env.onZoom; } | |
| env.onZoom = _; | |
| return chart; | |
| }; | |
| chart.refresh = function() { | |
| draw(); | |
| return chart; | |
| }; | |
| return chart; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Based on http://bl.ocks.org/mbostock/6232620 | |
| */ | |
| var TimeOverview = function(options, callback, context){ | |
| var timeMapper, timeTicker, brusherBucketLevelsMinutes, timeGrid, margins, width, hideIfLessThanSeconds, | |
| height, brush, xAxis, svg, groupOverview, timeUnitGrid, $this, margins, dom, labels, verticalLabels, | |
| format; | |
| $this = this; | |
| margins = options.margins; | |
| brusherBucketLevelsMinutes = options.granularityLevels; | |
| hideIfLessThanSeconds = options.hideIfLessThanSeconds; | |
| verticalLabels = (options.verticalLabels != null) ? options.verticalLabels : true; | |
| format = options.format || d3.time.format("%Y-%m-%d"); | |
| this.init = function(domElement, domainRange, currentSelection){ | |
| dom = domElement; | |
| if (domainRange && currentSelection){ | |
| this.render(domainRange, currentSelection); | |
| } | |
| }; | |
| this._afterInteraction = function(){ | |
| if (!d3.event.sourceEvent) return; | |
| var extent0, selectionPoints, boundedLeft, boundedRight, selectionPointsRounded, magneticEffect; | |
| extent0 = brush.extent(); | |
| boundedLeft = false; | |
| boundedRight = false; | |
| magneticEffect = 10 * 60 * 60 * 1000; | |
| // Magnetic effect | |
| selectionPoints = extent0; | |
| selectionPointsRounded = extent0.map(timeUnitGrid.round); | |
| if (selectionPoints[0].getTime() <= $this.domainRange[0].getTime() + magneticEffect){ | |
| selectionPoints[0] = $this.domainRange[0]; | |
| boundedLeft = true; | |
| } | |
| if (selectionPoints[1].getTime() >= $this.domainRange[1].getTime() - magneticEffect){ | |
| selectionPoints[1] = $this.domainRange[1]; | |
| boundedRight = true; | |
| } | |
| if (boundedLeft && !boundedRight){ | |
| selectionPoints[1] = selectionPointsRounded[1]; | |
| }else if (!boundedLeft && boundedRight){ | |
| selectionPoints[0] = selectionPointsRounded[0]; | |
| }else if (!boundedLeft && !boundedRight){ | |
| selectionPoints[0] = selectionPointsRounded[0]; | |
| selectionPoints[1] = selectionPointsRounded[1]; | |
| } | |
| if (selectionPoints[0] >= selectionPoints[1]) { | |
| selectionPoints[0] = timeUnitGrid.floor(extent0[0]); | |
| selectionPoints[1] = timeUnitGrid.ceil(extent0[1]); | |
| } | |
| // Apply magnetic feedback | |
| d3.select(this).transition() | |
| .call(brush.extent(selectionPoints)); | |
| callback.call(context, selectionPoints[0], selectionPoints[1]); | |
| }; | |
| this._duringInteraction = function(){ | |
| if (!d3.event.sourceEvent) return; | |
| var extent0, selectionPoints; | |
| extent0 = brush.extent(); | |
| // Magnetic effect | |
| selectionPoints = extent0.map(timeUnitGrid.round); | |
| if (selectionPoints[0] >= selectionPoints[1]) { | |
| selectionPoints[0] = timeUnitGrid.floor(extent0[0]); | |
| selectionPoints[1] = timeUnitGrid.ceil(extent0[1]); | |
| } | |
| // Apply magnetic feedback | |
| d3.select(this).transition() | |
| .call(brush.extent(selectionPoints)); | |
| }; | |
| this.render = function(domainRange, currentSelection){ | |
| var timeWindow; | |
| this.domainRange = domainRange; | |
| this.currentSelection = currentSelection; | |
| timeWindow = domainRange[1] - domainRange[0]; | |
| if (timeWindow < hideIfLessThanSeconds * 1000){ | |
| return false; | |
| } | |
| if (timeWindow < (brusherBucketLevelsMinutes.day * 60 * 1000)){ | |
| timeMapper = d3.time.day; | |
| timeTicker = d3.time.days; | |
| timeGrid = d3.time.hours; | |
| timeUnitGrid = d3.time.hour; | |
| }else if (timeWindow < (brusherBucketLevelsMinutes.week * 60 * 1000)){ | |
| timeMapper = d3.time.week; | |
| timeTicker = d3.time.weeks; | |
| timeGrid = d3.time.days; | |
| timeUnitGrid = d3.time.day; | |
| }else if (timeWindow < (brusherBucketLevelsMinutes.month * 60 * 1000)){ | |
| timeMapper = d3.time.month; | |
| timeTicker = d3.time.months; | |
| timeGrid = d3.time.weeks; | |
| timeUnitGrid = d3.time.week; | |
| }else{ | |
| timeMapper = d3.time.year; | |
| timeTicker = d3.time.years; | |
| timeGrid = d3.time.months; | |
| timeUnitGrid = d3.time.month; | |
| } | |
| width = options.width; | |
| height = options.height - margins.top - margins.bottom; | |
| xAxis = d3 | |
| .time | |
| .scale | |
| .utc() | |
| .domain(domainRange) | |
| .range([0, width]); | |
| brush = d3.svg.brush() | |
| .x(xAxis) | |
| .extent(currentSelection) | |
| //.on("brush", brushing) | |
| .on("brushend", $this._afterInteraction); | |
| svg = d3.select(dom) | |
| .append("svg") | |
| .attr("class", "brusher") | |
| .attr("width", width + margins.left + margins.right) | |
| .attr("height", height + margins.top + margins.bottom) | |
| .append("g") | |
| .attr("transform", "translate(" + margins.left + "," + margins.top + ")"); | |
| svg.append("rect") | |
| .attr("class", "grid-background") | |
| .attr("width", width) | |
| .attr("height", height); | |
| svg.append("g") | |
| .attr("class", "x grid") | |
| .attr("transform", "translate(0," + height + ")") | |
| .call(d3.svg.axis() | |
| .scale(xAxis) | |
| .orient("bottom") | |
| .ticks(timeGrid) | |
| .tickSize(-height) | |
| .tickFormat("")) | |
| .selectAll(".tick") | |
| .classed("minor", function(d) { return d.getHours(); }); | |
| svg.append("g") | |
| .attr("class", "x axis") | |
| .attr("transform", "translate(0," + height + ")") | |
| .call(d3.svg.axis() | |
| .scale(xAxis) | |
| .orient("bottom") | |
| .ticks(timeTicker) | |
| .tickFormat(format) | |
| .tickPadding(0)) | |
| .selectAll("text") | |
| .attr("x", 6) | |
| .style("text-anchor", null); | |
| groupOverview = svg.append("g") | |
| .attr("class", "brush") | |
| .call(brush); | |
| groupOverview.selectAll("rect") | |
| .attr("height", height); | |
| labels = svg.selectAll("text") | |
| .style("text-anchor", "end"); | |
| if (verticalLabels){ | |
| labels | |
| .attr("dx", "-1.2em") | |
| .attr("dy", ".15em") | |
| .attr('transform', 'rotate(-65)'); | |
| } | |
| return true; | |
| }; | |
| this.update = function(domainRange, currentSelection){ | |
| if (this.domainRange == domainRange){ | |
| return this.updateSelection(currentSelection); | |
| }else{ | |
| d3.select(dom) | |
| .select(".brusher") | |
| .remove(); | |
| return this.render(domainRange, currentSelection); | |
| } | |
| }; | |
| this.updateSelection = function(currentSelection){ | |
| if (this.currentSelection != currentSelection){ | |
| groupOverview | |
| .call(brush.extent(currentSelection)); | |
| return true; | |
| } | |
| return false; | |
| }; | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
