1// Line chart in es6 2//import 'd3' 3 4class LineChart { 5 constructor(element, data, options = {}) { 6 this.element = element 7 this.data = data 8 this.options = { ...this.defaultOptions(), ...options } 9 10 this.chart_id = this.randomClass(); 11 this.hasData = false 12 this.colorIterator = this.colorIteratorGenerator() 13 this.setResizeEventListener() 14 this.update(data) 15 } 16 17 defaultOptions() { 18 return { 19 height: 400, 20 width: 400, 21 responsive: true, 22 23 /* set the following to undefined for no min/max 24 on the responsive behaviour */ 25 minHeight: 200, 26 maxHeight: 400, // change to 400 27 28 marginTop: 10, 29 marginBottom: 40, 30 marginLeft: 40, 31 marginRight: 10, 32 33 // svgBackground: "salmon", 34 startYAtZero: false, 35 minY: null, 36 maxY: null, 37 38 lineWidth: "0.2em", 39 curveLine: true, 40 lineColor: "blue", 41 42 duration: 2000, 43 debug: false, 44 } 45 } 46 47 update(data) { 48 // Called to update objects with data 49 this.data = data || this.data 50 if (this.data == undefined) { 51 this.log('No data yet') 52 this.updateSVG() 53 this.updateStyles() 54 return 55 } 56 this.hasData = true 57 this.updateDataColors() 58 this.updateSVG() 59 this.updateStyles() 60 this.updateScales() 61 this.updateConfidenceIntervals() 62 this.updateAxis() 63 this.updateLines() 64 this.updateLegend() 65 66 } 67 68 getWidthHeight() { 69 this.svg = d3.select(this.element) 70 .selectAll('svg') 71 .data(['svg']) 72 .join("svg") 73 .attr("class", `${this.chart_id}`) 74 .attr("id", d => d) 75 this.plot = this.svg 76 .selectAll("g#plot") 77 .data(['plot']) 78 .join("g") 79 .attr("class", `${this.chart_id}`) 80 .attr("id", d => d) 81 var width, height 82 if (this.options.responsive) { 83 width = this.element.offsetWidth; 84 height = d3.min([ 85 this.options.maxHeight, 86 d3.max([this.options.minHeight, width / 2])]) 87 } else { 88 width = this.options.width 89 height = this.options.height 90 } 91 return [width, height] 92 } 93 94 updateSVG() { 95 // Set chart height, width and margins 96 let [width, height] = this.getWidthHeight() 97 this.width = width 98 this.height = height 99 this.margin = { 100 top: this.options.marginTop, 101 bottom: this.options.marginBottom, 102 left: this.options.marginLeft, 103 right: this.options.marginRight, 104 }; 105 // now the sizes are correct resize 106 this.log(`Resizing: width ${width} and height ${height}`) 107 this.svg 108 .attr("width", this.width) 109 .attr("height", this.height) 110 this.plot 111 .attr("transform", `translate(${this.margin.left},${this.margin.top})`); 112 } 113 114 setResizeEventListener() { 115 if (this.options.responsive) { 116 d3.select(window).on(`resize.${this.chart_id}`, () => { 117 var duration = this.options.duration 118 this.options.duration = 0 119 this.update() 120 this.options.duration = duration 121 }); 122 } 123 } 124 125 updateStyles(styleOptions = {}) { 126 styleOptions = { ...this.options, ...styleOptions } 127 this.svg 128 .style("background", styleOptions.svgBackground) 129 } 130 131 updateScales() { 132 const { top, bottom, right, left } = this.margin; 133 134 // find extents for each line then find overall extent 135 const extents = [0, 1].map(i => { 136 let axisExtents = [] 137 this.data.forEach(line => { 138 d3.extent(line.values, d => d[i]).forEach(x => axisExtents.push(x)) 139 }) 140 return axisExtents 141 }).map(axis => d3.extent(axis)) 142 const [xExtent, yExtent] = extents 143 144 yExtent[1] *= 1.1 145 146 if (this.options.startYAtZero && yExtent[0] > 0) { 147 yExtent[0] = 0; 148 }; 149 150 if (this.options.minY !== null) { 151 yExtent[0] = this.options.minY 152 } 153 if (this.options.maxY !== null) { 154 yExtent[1] = this.options.maxY 155 } 156 157 this.xScale = d3.scaleLinear() 158 .range([0, this.width - (left + right)]) 159 .domain(xExtent); 160 161 this.yScale = d3.scaleLinear() 162 .range([this.height - (top + bottom), 0]) 163 .domain(yExtent); 164 } 165 166 getAxis() { 167 const xAxis = d3.axisBottom() 168 .scale(this.xScale) 169 .tickFormat(d3.format(".1f")); 170 171 const yAxis = d3.axisLeft() 172 .scale(this.yScale) 173 .tickFormat(d3.format(".2f")); 174 175 return [xAxis, yAxis] 176 } 177 178 updateAxis() { 179 const { top, bottom } = this.margin; 180 const [xAxis, yAxis] = this.getAxis() 181 this.plot 182 .selectAll("#x-axis") 183 .data(['x-axis']) 184 .join( 185 enter => enter.append("g") 186 .attr("class", `${this.chart_id}`) 187 .attr("id", d => d) 188 .attr("transform", `translate(0,${this.height - (bottom + top)})`) 189 .call(xAxis), 190 update => update 191 .attr("transform", `translate(0,${this.height - (bottom + top)})`) 192 .call(update => { 193 update.transition() 194 .duration(this.options.duration) 195 .call(xAxis) 196 }) 197 ) 198 this.plot 199 .selectAll("#y-axis") 200 .data(['y-axis']) 201 .join( 202 enter => enter.append("g") 203 .attr("class", `${this.chart_id}`) 204 .attr("id", d => d) 205 .call(yAxis), 206 update => update.call(update => 207 update.transition() 208 .duration(this.options.duration) 209 .call(yAxis)) 210 ) 211 } 212 213 updateLines() { 214 var line 215 if (this.options.curveLine) { 216 this.log('Using curve mode') 217 line = d3.line().curve(d3['curveNatural']) 218 .x(d => this.xScale(d[0])) 219 .y(d => this.yScale(d[1])) 220 } else { 221 line = d3.line() 222 .x(d => this.xScale(d[0])) 223 .y(d => this.yScale(d[1])) 224 } 225 this.plot.selectAll("path.series") 226 .data(this.data, d => { 227 return d.id 228 }) 229 .join( 230 enter => enter.append("path") 231 .attr("class", `series ${this.chart_id}`) 232 .attr("id", d => d.id) 233 .attr("d", d => line(d.values)) 234 .attr("stroke", d => this.getLineColor(d)) 235 .attr("fill", "none") 236 .attr("stroke-width", this.options.lineWidth) 237 .attr("stroke-dasharray", d => d.dashArray ? "5,5" : "0") 238 .attr("opacity", 0) 239 .call(enter => 240 enter.transition() 241 .duration(this.options.duration) 242 .attr("opacity", 1)) 243 , 244 update => update.call(update => 245 update.transition() 246 .duration(this.options.duration) 247 .attrTween("d", d => { 248 // Warning! selection.select propagates data so don't do 249 // this.plot.select ! 250 var previous = d3.select(`.${this.chart_id}#${d.id}`).attr("d") 251 var current = line(d.values) 252 return d3.interpolatePath(previous, current); 253 }) 254 ) 255 , 256 exit => exit 257 .call(exit => 258 exit.transition() 259 .duration(this.options.duration) 260 .attr("opacity", 0) 261 .remove(), 262 ) 263 ) 264 } 265 updateConfidenceIntervals() { 266 // if confidenceIntervals key in data then we plot confidence intervals 267 var area 268 if (this.options.curveLine) { 269 this.log('Using curve mode') 270 area = d3.area().curve(d3['curveNatural']) 271 .x(d => this.xScale(d[0])) 272 .y0(d => this.yScale(d[1])) 273 .y1(d => this.yScale(d[2])) 274 } else { 275 area = d3.area() 276 .x(d => this.xScale(d[0])) 277 .y0(d => this.yScale(d[1])) 278 .y1(d => this.yScale(d[2])) 279 } 280 this.plot.selectAll("path.confidence") 281 .data(this.data.filter(d => d.confidenceIntervals !== undefined), d => d.id) 282 .join( 283 enter => enter.append("path") 284 .attr("class", `confidence ${this.chart_id}`) 285 .attr("id", d => `confidence-${d.id}`) 286 .attr("fill", d => this.getLineColor(d, true)) 287 .attr("opacity", 0.3) 288 .attr("stroke", "none") 289 .attr("d", d => area(d.confidenceIntervals) 290 ) 291 , 292 update => update.call(update => 293 update.transition() 294 .duration(this.options.duration) 295 .attrTween("d", d => { 296 var previous = d3.select(`.${this.chart_id}#confidence-${d.id}`) 297 previous = previous.attr("d") 298 var current = area(d.confidenceIntervals) 299 return d3.interpolatePath(previous, current); 300 }) 301 ), 302 exit => exit 303 .call(exit => 304 exit.transition() 305 .duration(this.options.duration) 306 .attr("opacity", 0) 307 .remove(), 308 ) 309 310 ) 311 312 } 313 314 315 /* Create color wheel*/ 316 defaultColors() { 317 return { 318 blue: "#007bff", 319 red: "#dc3545", 320 green: "#28a745", 321 purple: "#6f42c1", 322 orange: "#fd7e14", 323 pink: "#e83e8c", 324 indigo: "#6610f2", 325 teal: "#20c997", 326 yellow: "#ffc107", 327 cyan: "#17a2b8", 328 } 329 } 330 331 colorIteratorGenerator() { 332 let keys = Object.keys(this.defaultColors()) 333 let n = keys.length 334 let iteration = 0 335 const iterator = { 336 next: () => { 337 let index = iteration % n 338 iteration += 1 339 return this.defaultColors()[keys[index]] 340 } 341 } 342 return iterator 343 } 344 345 updateDataColors() { 346 const dataColors = { ...this.dataColors } 347 this.data.forEach((d) => { 348 if (dataColors[d.id] === undefined) { 349 dataColors[d.id] = this.colorIterator.next() 350 } 351 this.dataColors = dataColors 352 }) 353 } 354 // lighten the color for interval 355 getLighterColor(color) { 356 return d3.color(color).brighter(1).hex() 357 } 358 359 getLineColor(d, interval = false) { 360 let color = d.lineColor || this.dataColors[d.id] || this.options.lineColor 361 if (interval) { 362 return this.getLighterColor(color) 363 } 364 return color 365 } 366 367 updateLegend() { 368 let legend = d3.select(this.element) 369 .selectAll('div#legend') 370 .data(['legend']) 371 .join("div") 372 .attr("id", 'legend') 373 .style("display", "flex") 374 .style("flex-flow", "row wrap") 375 .style("justify-content", "space-evenly")
376 .style("background", this.options.svgBackground) 377 378 // note we condense data to unique legend items based on legendGroup or id if no legendGroup 379 let legendData = this.data.reduce((acc, d) => { 380 let key = d.legendGroup || d.id 381 if (!acc.find(x => x.id === key)) { 382 acc.push({ id: key, legendGroup: d.legendGroup, lineColor: d.lineColor }) 383 } 384 return acc 385 }, []) 386 387 let legendItems = legend.selectAll("div.legendItem") 388 .data(legendData, d => d.id) 389 .join( 390 enter => enter.append("div") 391 .attr("class", `legendItem ${this.chart_id}`) 392 .style("display", "flex") 393 .style("padding", "4px") 394 .call(legendItem => { 395 legendItem.append("div") 396 .style("background", d => this.getLineColor(d)) 397 .style("height", "100%") 398 .attr('id', d => d.legendGroup || d.id) 399 .style("width", "1em") 400 .style("margin-right", "0.5em") 401 .style("clip-path", "circle(0)") 402 .transition() 403 .duration(this.options.duration) 404 .style("clip-path", "circle(0.5em)") 405 406 legendItem.append("div") 407 .style("opacity", "0") 408 .text(d => d.legendGroup || d.id) 409 .transition() 410 .duration(this.options.duration) 411 .style("opacity", "1") 412 }) 413 ) 414 } 415 416 /* helper functions below*/ 417 log(message = "") { 418 if (this.options.debug) { 419 console.log(`[${this.constructor.name}] `, message) 420 } 421 } 422 423 randomClass() { 424 return [...Array(5)] 425 .map(_ => Math.floor(Math.random() * 26)) 426 .map(x => 'abcdefghijklmnopqrstuvwxyz'[x]).join("") 427 } 428 429} 430 431// For a next version create base chart and 432// then extend to other chart types e.g. 433// class LineChart extends Chart { }
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.