import './lib/d3.v7.min.js'; const x = d3.scaleLinear([0, 120]).nice(30); const y = d3.scaleLinear([0, 350]).nice(25); const line = d3.line(d => x(d[0]), d => y(d[1])); const marginx = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); const marginy = 2 * parseFloat(getComputedStyle(document.documentElement).fontSize); const svg = d3.select('#plot'); // Add the x-axis. const xa = svg.append('g'); // Add the y-axis. const ya = svg.append('g'); export const PLOTS = {}; export let ACTIVE = null; const sec = d3.format('02'); const min = d3.format(' '); const minsec = v => `${min(Math.floor(v / 60))}:${sec(Math.floor(v % 60))}`; const degc = v => `${Math.floor(v)}°C`; const crosshair = svg.append('g') .attr('display', 'none') .attr('color', '#00000080') .attr('font-size', 10) .attr('font-family', 'sans-serif') .attr('stroke-width', 0.5); crosshair.append('text') .attr('id', 'ctextx') .attr('y', marginy) .attr('dx', '0.32em') .attr('dy', '1em') .attr('fill', 'currentColor'); crosshair.append('text') .attr('id', 'ctexty') .attr('x', marginx) .attr('dx', '0.32em') .attr('dy', '-0.32em') .attr('fill', 'currentColor');; crosshair.append('line') .attr('id', 'clinex') .attr('stroke', 'currentColor'); crosshair.append('line') .attr('id', 'cliney') .attr('stroke', 'currentColor'); const updateCrosshair = ([px, py]) => { const [time, temp] = [x.invert(px), y.invert(py)]; const width = svg.node().clientWidth; const height = svg.node().clientHeight; const inRange = marginx < px && px < width - marginx && marginy < py && py < height - marginy; crosshair.attr('display', inRange ? null : 'none').lower(); crosshair.select('#ctextx').text(minsec(time)).attr('x', px); crosshair.select('#ctexty').text(degc(temp)).attr('y', py); crosshair.select('#clinex').attr('x1', px).attr('x2', px).attr('y1', marginy).attr('y2', height - marginy); crosshair.select('#cliney').attr('y1', py).attr('y2', py).attr('x1', marginx).attr('x2', width - marginx); }; export const addPlot = (name, options={}) => { const plot = Object.assign({ color: 'black', data: [], interactive: false, visible: true, }, options); plot.root = svg.append('g') .attr('fill', plot.color) .attr('stroke', plot.color) .on('click', (e) => { if (!plot.interactive) return; if (name === ACTIVE) return; e.preventDefault(); toggleActive(name); update(); }); plot.path = plot.root.append('path') .attr('fill', 'none') .attr('stroke-width', '2'); if (plot.interactive) { plot.shadow = plot.root.append('path') .attr('fill', 'none') .attr('stroke', 'transparent') .attr('stroke-width', '8'); plot.points = plot.root.append('g') .attr('stroke', 'none') } PLOTS[name] = plot; return plot; }; export const toggleActive = (name) => { if (ACTIVE === name) { ACTIVE = null; } else if (PLOTS[name]) { PLOTS[name].root.raise(); ACTIVE = name; } }; export const update = (transition=true) => { const times = d3.merge(Object.values(PLOTS).map(p => p.data.map(d => d[0]))); const temps = d3.merge(Object.values(PLOTS).map(p => p.data.map(d => d[1]))); x.domain([0, d3.max([...times, 120])]).nice(30); y.domain([0, d3.max([...temps, 200])]).nice(25); const dur = transition ? null : 0; xa.transition().duration(dur).call(d3.axisBottom(x).tickFormat(minsec)); ya.transition().duration(dur).call(d3.axisLeft(y).tickFormat(degc)); for (const name of Object.keys(PLOTS)) { const { root, path, shadow, points, data, color, interactive, visible } = PLOTS[name]; root .attr('cursor', interactive && name !== ACTIVE ? 'pointer' : 'default') .attr('display', visible ? null : 'none'); path.transition().duration(dur).attr('d', line(data)); shadow && shadow.transition().duration(dur).attr('d', line(data)); points && points.selectAll('circle') .data(data) .join('circle') .attr('cx', (d) => x(d[0])) .attr('cy', (d) => y(d[1])) .attr('r', name === ACTIVE ? 5 : 3) .attr('cursor', name === ACTIVE ? 'grab' : null) .on('dblclick', function (e, d) { e.stopPropagation(); if (d[0] === 0) return; const i = data.indexOf(d); data.splice(i, 1); update(false); }) .call( d3.drag() .filter((e) => !e.ctrlKey && !e.button && name === ACTIVE) .on('start', () => points.attr('cursor', 'grabbing')) .on('drag', function (e, d) { let [time, temp] = [x.invert(e.x), y.invert(e.y)]; time = Math.max(1, time); temp = Math.max(0, Math.min(temp, 400)); const ci = data.indexOf(d); if (data[ci-1] && data[ci-1][0] > time) { data.splice(ci-1, 0, ...data.splice(ci, 1)); } else if (data[ci+1] && data[ci+1][0] < time) { data.splice(ci+1, 0, ...data.splice(ci, 1)); } if (d[0] !== 0) d[0] = time; d[1] = temp; d3.select(this) .attr('cx', (d) => x(d[0])) .attr('cy', (d) => y(d[1])); update(false); updateCrosshair([e.x, e.y]); }) .on('end', () => points.attr('cursor', 'grab')) ); } }; const onresize = () => { const width = svg.node().clientWidth; const height = svg.node().clientHeight; svg .attr('width', width) .attr('height', height); x.range([marginx, width - marginx]); y.range([height - marginy, marginy]); xa.transition().call(d3.axisBottom(x).tickFormat(minsec)); ya.transition().call(d3.axisLeft(y).tickFormat(degc)); for (const { path, data, points } of Object.values(PLOTS)) { path.transition().attr('d', line(data)); points && points.transition().selectAll('circle') .attr('cx', (d) => x(d[0])) .attr('cy', (d) => y(d[1])); } xa.attr('transform', `translate(0, ${height - marginy})`); ya.attr('transform', `translate(${marginx}, 0)`); }; window.addEventListener('resize', onresize); svg .on('mousemove', (e) => { updateCrosshair(d3.pointer(e)); }) .on('dblclick', (e) => { const plot = ACTIVE && PLOTS[ACTIVE]; if (!plot) return; const [px, py] = d3.pointer(e); const [time, temp] = [x.invert(px), y.invert(py)]; const ni = plot.data.findLastIndex(([tt, tp]) => tt <= time); if (plot.data[ni][0] === time) { plot.data[ni][1] = temp; } else { plot.data.splice(ni + 1, 0, [time, temp]); } update(false); }); onresize(); update();