PageSourceSearch

https://www.simonwardjones.co.uk/js/lineChart.js

js simonwardjones.co.uk collected 2026-09-26 09:25:06 UTC 14,422 bytes, 433 lines download raw bytes

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.