mirror of
https://github.com/KevinMidboe/brewPi.git
synced 2025-10-29 16:50:12 +00:00
621 lines
17 KiB
TypeScript
621 lines
17 KiB
TypeScript
import * as d3 from 'd3';
|
|
import { transition } from 'd3-transition';
|
|
|
|
var defaultColors = [
|
|
'#a6cee3',
|
|
'#ff7f00',
|
|
'#b2df8a',
|
|
'#1f78b4',
|
|
'#fdbf6f',
|
|
'#33a02c',
|
|
'#cab2d6',
|
|
'#6a3d9a',
|
|
'#fb9a99',
|
|
'#e31a1c',
|
|
'#ffff99',
|
|
'#b15928'
|
|
];
|
|
|
|
// utils
|
|
function functorkey(v) {
|
|
return typeof v === 'function'
|
|
? v
|
|
: function(d) {
|
|
return d[v];
|
|
};
|
|
}
|
|
|
|
function functorkeyscale(v, scale) {
|
|
var f =
|
|
typeof v === 'function'
|
|
? v
|
|
: function(d) {
|
|
return d[v];
|
|
};
|
|
return function(d) {
|
|
return scale(f(d));
|
|
};
|
|
}
|
|
|
|
function keyNotNull(k) {
|
|
return function(d) {
|
|
return d.hasOwnProperty(k) && d[k] !== null && !isNaN(d[k]);
|
|
};
|
|
}
|
|
|
|
function fk(v) {
|
|
return function(d) {
|
|
return d[v];
|
|
};
|
|
}
|
|
|
|
function main() {
|
|
// default
|
|
var height = 480;
|
|
var width = 600;
|
|
|
|
var drawerHeight = 80;
|
|
var drawerTopMargin = 10;
|
|
var margin = { top: 10, bottom: 20, left: 30, right: 10 };
|
|
|
|
var series = [];
|
|
|
|
var yscale = d3.scaleLinear();
|
|
var xscale = d3.scaleTime();
|
|
yscale.label = '';
|
|
xscale.label = '';
|
|
|
|
var brush = d3.brushX();
|
|
|
|
var svg, container, serieContainer, annotationsContainer, drawerContainer, mousevline;
|
|
var fullxscale, tooltipDiv;
|
|
|
|
yscale.setformat = function(n) {
|
|
return n.toLocaleString();
|
|
};
|
|
xscale.setformat = xscale.tickFormat();
|
|
|
|
// default tool tip function
|
|
var _tipFunction = function(date, series) {
|
|
var spans =
|
|
'<table style="border:none">' +
|
|
series
|
|
.filter(function(d) {
|
|
console.log('DDD:', d);
|
|
return d.item !== undefined && d.item !== null;
|
|
})
|
|
.map(function(d) {
|
|
return (
|
|
'<tr><td style="color:' +
|
|
d.options.color +
|
|
'">' +
|
|
d.options.label +
|
|
' </td>' +
|
|
'<td style="color:#333333;text-align:right">' +
|
|
yscale.setformat(d.item[d.aes.y]) +
|
|
'</td></tr>'
|
|
);
|
|
})
|
|
.join('') +
|
|
'</table>';
|
|
|
|
return '<h4>' + xscale.setformat(d3.timeDay(date)) + '</h4>' + spans;
|
|
};
|
|
|
|
function createLines(serie) {
|
|
// https://github.com/d3/d3-shape/blob/master/README.md#curves
|
|
var aes = serie.aes;
|
|
|
|
if (!serie.options.interpolate) {
|
|
serie.options.interpolate = 'linear';
|
|
} else {
|
|
// translate curvenames
|
|
serie.options.interpolate =
|
|
serie.options.interpolate == 'monotone'
|
|
? 'monotoneX'
|
|
: serie.options.interpolate == 'step-after'
|
|
? 'stepAfter'
|
|
: serie.options.interpolate == 'step-before'
|
|
? 'stepBefore'
|
|
: serie.options.interpolate;
|
|
}
|
|
// to uppercase for d3 curve name
|
|
var curveName =
|
|
'curve' + serie.options.interpolate[0].toUpperCase() + serie.options.interpolate.slice(1);
|
|
serie.interpolationFunction = d3[curveName] || d3.curveLinear;
|
|
|
|
var line = d3
|
|
.line()
|
|
.x(functorkeyscale(aes.x, xscale))
|
|
.y(functorkeyscale(aes.y, yscale))
|
|
.curve(serie.interpolationFunction)
|
|
.defined(keyNotNull(aes.y));
|
|
|
|
serie.line = line;
|
|
|
|
serie.options.label =
|
|
serie.options.label || serie.options.name || serie.aes.label || serie.aes.y;
|
|
|
|
if (aes.ci_up && aes.ci_down) {
|
|
var ciArea = d3
|
|
.area()
|
|
.x(functorkeyscale(aes.x, xscale))
|
|
.y0(functorkeyscale(aes.ci_down, yscale))
|
|
.y1(functorkeyscale(aes.ci_up, yscale))
|
|
.curve(serie.interpolationFunction);
|
|
serie.ciArea = ciArea;
|
|
}
|
|
|
|
if (aes.diff) {
|
|
serie.diffAreas = [
|
|
d3
|
|
.area()
|
|
.x(functorkeyscale(aes.x, xscale))
|
|
.y0(functorkeyscale(aes.y, yscale))
|
|
.y1(function(d) {
|
|
if (d[aes.y] > d[aes.diff]) return yscale(d[aes.diff]);
|
|
return yscale(d[aes.y]);
|
|
})
|
|
.curve(serie.interpolationFunction),
|
|
d3
|
|
.area()
|
|
.x(functorkeyscale(aes.x, xscale))
|
|
.y1(functorkeyscale(aes.y, yscale))
|
|
.y0(function(d) {
|
|
if (d[aes.y] < d[aes.diff]) return yscale(d[aes.diff]);
|
|
return yscale(d[aes.y]);
|
|
})
|
|
.curve(serie.interpolationFunction)
|
|
];
|
|
}
|
|
|
|
serie.find = function(date) {
|
|
var bisect = d3.bisector(fk(aes.x)).left;
|
|
var i = bisect(serie.data, date) - 1;
|
|
if (i == -1) {
|
|
return null;
|
|
}
|
|
|
|
// look to far after serie is defined
|
|
if (
|
|
i == serie.data.length - 1 &&
|
|
serie.data.length > 1 &&
|
|
Number(date) - Number(serie.data[i][aes.x]) >
|
|
Number(serie.data[i][aes.x]) - Number(serie.data[i - 1][aes.x])
|
|
) {
|
|
return null;
|
|
}
|
|
return serie.data[i];
|
|
};
|
|
}
|
|
|
|
function drawSerie(serie) {
|
|
if (!serie.linepath) {
|
|
console.log(series);
|
|
const sorted = [...series[0].data];
|
|
sorted.sort((a, b) => (a.n > b.n ? 1 : -1));
|
|
const min = sorted[0].n;
|
|
const max = sorted[sorted.length - 1].n;
|
|
console.log('max:', max);
|
|
console.log('min:', min);
|
|
const midean = (max + min) / 2;
|
|
console.log('midean:', midean);
|
|
|
|
var linepath = serieContainer
|
|
.append('path')
|
|
.datum(serie.data.filter((e) => e.n <= midean))
|
|
.attr('class', 'd3_timeseries line')
|
|
.attr('d', serie.line)
|
|
// .attr('stroke', serie.options.color)
|
|
.attr('stroke', 'red')
|
|
.attr('stroke-linecap', 'round')
|
|
.attr('stroke-width', serie.options.width || 1.5)
|
|
.attr('fill', 'none');
|
|
|
|
if (serie.options.dashed) {
|
|
if (serie.options.dashed == true || serie.options.dashed == 'dashed') {
|
|
serie['stroke-dasharray'] = '5,5';
|
|
} else if (serie.options.dashed == 'long') {
|
|
serie['stroke-dasharray'] = '10,10';
|
|
} else if (serie.options.dashed == 'dot') {
|
|
serie['stroke-dasharray'] = '2,4';
|
|
} else {
|
|
serie['stroke-dasharray'] = serie.options.dashed;
|
|
}
|
|
linepath.attr('stroke-dasharray', serie['stroke-dasharray']);
|
|
}
|
|
serie.linepath = linepath;
|
|
// serie.hotLine = hotLine;
|
|
|
|
if (serie.ciArea) {
|
|
serie.cipath = serieContainer
|
|
.insert('path', ':first-child')
|
|
.datum(serie.data)
|
|
.attr('class', 'd3_timeseries ci-area')
|
|
.attr('d', serie.ciArea)
|
|
.attr('stroke', 'none')
|
|
.attr('fill', serie.options.color)
|
|
.attr('opacity', serie.options.ci_opacity || 0.3);
|
|
}
|
|
if (serie.diffAreas) {
|
|
serie.diffpaths = serie.diffAreas.map(function(area, i) {
|
|
var c = (serie.options.diff_colors ? serie.options.diff_colors : ['green', 'red'])[i];
|
|
return serieContainer
|
|
.insert('path', function() {
|
|
return linepath.node();
|
|
})
|
|
.datum(serie.data)
|
|
.attr('class', 'd3_timeseries diff-area')
|
|
.attr('d', area)
|
|
.attr('stroke', 'none')
|
|
.attr('fill', c)
|
|
.attr('opacity', serie.options.diff_opacity || 0.5);
|
|
});
|
|
}
|
|
} else {
|
|
serie.linepath.attr('d', serie.line);
|
|
serie.linepath.attr('d', serie.hotLine);
|
|
if (serie.ciArea) {
|
|
serie.cipath.attr('d', serie.ciArea);
|
|
}
|
|
if (serie.diffAreas) {
|
|
serie.diffpaths[0].attr('d', serie.diffAreas[0]);
|
|
serie.diffpaths[1].attr('d', serie.diffAreas[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updatefocusRing(xdate) {
|
|
var s = annotationsContainer.selectAll('circle.d3_timeseries.focusring');
|
|
|
|
if (xdate == null) {
|
|
s = s.data([]);
|
|
} else {
|
|
s = s.data(
|
|
series
|
|
.map(function(s) {
|
|
return { x: xdate, item: s.find(xdate), aes: s.aes, color: s.options.color };
|
|
})
|
|
.filter(function(d) {
|
|
return (
|
|
d.item !== undefined &&
|
|
d.item !== null &&
|
|
d.item[d.aes.y] !== null &&
|
|
!isNaN(d.item[d.aes.y])
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
const t = transition().duration(50);
|
|
/*
|
|
.attr('cx', function(d) {
|
|
console.log('aDDD:', d);
|
|
|
|
return xscale(d.item[d.aes.n]);
|
|
})
|
|
.attr('cy', function(d) {
|
|
return yscale(d.item[d.aes.date]);
|
|
});
|
|
*/
|
|
|
|
s.transition(t);
|
|
|
|
s.enter()
|
|
.append('circle')
|
|
.attr('class', 'd3_timeseries focusring')
|
|
.attr('fill', 'none')
|
|
.attr('stroke-width', 2)
|
|
.attr('r', 5)
|
|
.attr('stroke', fk('color'));
|
|
|
|
s.exit().remove();
|
|
}
|
|
|
|
function updateTip(xdate) {
|
|
if (xdate == null) {
|
|
tooltipDiv.style('opacity', 0);
|
|
} else {
|
|
var s = series.map(function(s) {
|
|
return { item: s.find(xdate), aes: s.aes, options: s.options };
|
|
});
|
|
|
|
tooltipDiv
|
|
.style('opacity', 0.9)
|
|
.style('left', margin.left + 5 + xscale(xdate) + 'px')
|
|
.style('top', '0px')
|
|
.html(_tipFunction(xdate, s));
|
|
}
|
|
}
|
|
|
|
function drawMiniDrawer() {
|
|
var smallyscale = yscale.copy().range([drawerHeight - drawerTopMargin, 0]);
|
|
var serie = series[0];
|
|
var line = d3
|
|
.line()
|
|
.x(functorkeyscale(serie.aes.x, fullxscale))
|
|
.y(functorkeyscale(serie.aes.y, smallyscale))
|
|
.curve(serie.interpolationFunction)
|
|
.defined(keyNotNull(serie.aes.y));
|
|
var linepath = drawerContainer
|
|
.insert('path', ':first-child')
|
|
.datum(serie.data)
|
|
.attr('class', 'd3_timeseries.line')
|
|
.attr('transform', 'translate(0,' + drawerTopMargin + ')')
|
|
.attr('d', line)
|
|
.attr('stroke', serie.options.color)
|
|
.attr('stroke-width', serie.options.width || 1.5)
|
|
.attr('fill', 'none');
|
|
if (serie.hasOwnProperty('stroke-dasharray')) {
|
|
linepath.attr('stroke-dasharray', serie['stroke-dasharray']);
|
|
}
|
|
}
|
|
|
|
function mouseMove() {
|
|
var x = d3.pointer(container.node())[0];
|
|
x = xscale.invert(x);
|
|
mousevline.datum({ x: x, visible: true });
|
|
mousevline.update();
|
|
updatefocusRing(x);
|
|
updateTip(x);
|
|
}
|
|
function mouseOut() {
|
|
mousevline.datum({ x: null, visible: false });
|
|
mousevline.update();
|
|
updatefocusRing(null);
|
|
updateTip(null);
|
|
}
|
|
|
|
var chart = function(elem) {
|
|
// compute mins max on all series
|
|
series = series.map(function(s) {
|
|
var extent = d3.extent(s.data.map(functorkey(s.aes.y)));
|
|
s.min = extent[0];
|
|
s.max = extent[1];
|
|
extent = d3.extent(s.data.map(functorkey(s.aes.x)));
|
|
s.dateMin = extent[0];
|
|
s.dateMax = extent[1];
|
|
return s;
|
|
});
|
|
|
|
// set scales
|
|
|
|
yscale
|
|
.range([height - margin.top - margin.bottom - drawerHeight - drawerTopMargin, 0])
|
|
.domain([d3.min(series.map(fk('min'))), d3.max(series.map(fk('max')))])
|
|
.nice();
|
|
|
|
xscale
|
|
.range([0, width - margin.left - margin.right])
|
|
.domain([d3.min(series.map(fk('dateMin'))), d3.max(series.map(fk('dateMax')))])
|
|
.nice();
|
|
|
|
// if user specify domain
|
|
if (yscale.fixedomain) {
|
|
// for showing 0 :
|
|
// chart.addSerie(...)
|
|
// .yscale.domain([0])
|
|
if (yscale.fixedomain.length == 1) {
|
|
yscale.fixedomain.push(yscale.domain()[1]);
|
|
}
|
|
yscale.domain(yscale.fixedomain);
|
|
}
|
|
|
|
if (xscale.fixedomain) {
|
|
xscale.domain(yscale.fixedomain);
|
|
}
|
|
|
|
fullxscale = xscale.copy();
|
|
|
|
// create svg
|
|
svg = d3.select(elem).append('svg').attr('width', width).attr('height', height);
|
|
|
|
// clipping for scrolling in focus area
|
|
svg
|
|
.append('defs')
|
|
.append('clipPath')
|
|
.attr('id', 'clip')
|
|
.append('rect')
|
|
.attr('width', width - margin.left - margin.right)
|
|
.attr('height', height - margin.bottom - drawerHeight - drawerTopMargin)
|
|
.attr('y', -margin.top);
|
|
|
|
// container for focus area
|
|
container = svg
|
|
.insert('g', 'rect.mouse-catch')
|
|
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
|
|
.attr('clip-path', 'url(#clip)');
|
|
|
|
serieContainer = container.append('g');
|
|
annotationsContainer = container.append('g');
|
|
|
|
// mini container at the bottom
|
|
drawerContainer = svg
|
|
.append('g')
|
|
.attr(
|
|
'transform',
|
|
'translate(' + margin.left + ',' + (height - drawerHeight - margin.bottom) + ')'
|
|
);
|
|
|
|
// vertical line moving with mouse tip
|
|
mousevline = svg.append('g').datum({
|
|
x: new Date(),
|
|
visible: false
|
|
});
|
|
mousevline
|
|
.append('line')
|
|
.attr('x1', 0)
|
|
.attr('x2', 0)
|
|
.attr('y1', yscale.range()[0])
|
|
.attr('y2', yscale.range()[1])
|
|
.attr('class', 'd3_timeseries mousevline');
|
|
// update mouse vline
|
|
mousevline.update = function() {
|
|
this.attr('transform', function(d) {
|
|
return 'translate(' + (margin.left + xscale(d.x)) + ',' + margin.top + ')';
|
|
}).style('opacity', function(d) {
|
|
return d.visible ? 1 : 0;
|
|
});
|
|
};
|
|
mousevline.update();
|
|
|
|
var xAxis = d3.axisBottom().scale(xscale).tickFormat(xscale.setformat);
|
|
var yAxis = d3.axisLeft().scale(yscale).tickFormat(yscale.setformat);
|
|
|
|
brush
|
|
.extent([
|
|
[fullxscale.range()[0], 0],
|
|
[fullxscale.range()[1], drawerHeight - drawerTopMargin]
|
|
])
|
|
|
|
.on('brush', () => {
|
|
let selection = d3.event.selection;
|
|
|
|
xscale.domain(selection.map(fullxscale.invert, fullxscale));
|
|
|
|
series.forEach(drawSerie);
|
|
svg.select('.focus.x.axis').call(xAxis);
|
|
mousevline.update();
|
|
updatefocusRing();
|
|
})
|
|
|
|
.on('end', () => {
|
|
let selection = d3.event.selection;
|
|
if (selection === null) {
|
|
xscale.domain(fullxscale.domain());
|
|
|
|
series.forEach(drawSerie);
|
|
svg.select('.focus.x.axis').call(xAxis);
|
|
mousevline.update();
|
|
updatefocusRing();
|
|
}
|
|
});
|
|
|
|
svg
|
|
.append('g')
|
|
.attr('class', 'd3_timeseries focus x axis')
|
|
.attr(
|
|
'transform',
|
|
'translate(' +
|
|
margin.left +
|
|
',' +
|
|
(height - margin.bottom - drawerHeight - drawerTopMargin) +
|
|
')'
|
|
)
|
|
.call(xAxis);
|
|
|
|
drawerContainer
|
|
.append('g')
|
|
.attr('class', 'd3_timeseries x axis')
|
|
.attr('transform', 'translate(0,' + drawerHeight + ')')
|
|
.call(xAxis);
|
|
|
|
drawerContainer
|
|
.append('g')
|
|
.attr('class', 'd3_timeseries brush')
|
|
.call(brush)
|
|
.attr('transform', `translate(0, ${drawerTopMargin})`)
|
|
.attr('height', drawerHeight - drawerTopMargin);
|
|
|
|
svg
|
|
.append('g')
|
|
.attr('class', 'd3_timeseries y axis')
|
|
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
|
|
.call(yAxis)
|
|
.append('text')
|
|
.attr('transform', 'rotate(-90)')
|
|
.attr('x', -margin.top - d3.mean(yscale.range()))
|
|
.attr('dy', '.71em')
|
|
.attr('y', -margin.left + 5)
|
|
.style('text-anchor', 'middle')
|
|
.text(yscale.label);
|
|
|
|
// catch event for mouse tip
|
|
svg
|
|
.append('rect')
|
|
.attr('width', width)
|
|
.attr('class', 'd3_timeseries mouse-catch')
|
|
.attr('height', height - drawerHeight)
|
|
// .style('fill','green')
|
|
.style('opacity', 0)
|
|
.on('mousemove', mouseMove)
|
|
.on('mouseout', mouseOut);
|
|
|
|
tooltipDiv = d3
|
|
.select(elem)
|
|
.style('position', 'relative')
|
|
.append('div')
|
|
.attr('class', 'd3_timeseries tooltip')
|
|
.style('opacity', 0);
|
|
|
|
series.forEach(createLines);
|
|
series.forEach(drawSerie);
|
|
drawMiniDrawer();
|
|
};
|
|
|
|
chart.width = function(_) {
|
|
if (!arguments.length) return width;
|
|
width = _;
|
|
return chart;
|
|
};
|
|
|
|
chart.height = function(_) {
|
|
if (!arguments.length) return height;
|
|
height = _;
|
|
return chart;
|
|
};
|
|
|
|
chart.margin = function(_) {
|
|
if (!arguments.length) return margin;
|
|
margin = _;
|
|
return chart;
|
|
};
|
|
// accessors for margin.left(), margin.right(), margin.top(), margin.bottom()
|
|
// UNDEFINED
|
|
/*
|
|
d3.keys(margin).forEach(function(k) {
|
|
chart.margin[k] = function(_) {
|
|
if (!arguments.length) return margin[k];
|
|
margin[k] = _;
|
|
return chart;
|
|
};
|
|
});
|
|
*/
|
|
|
|
// scales accessors
|
|
var scaleGetSet = function(scale) {
|
|
return {
|
|
tickFormat: function(_) {
|
|
if (!arguments.length) return scale.setformat;
|
|
scale.setformat = _;
|
|
return chart;
|
|
},
|
|
label: function(_) {
|
|
if (!arguments.length) return scale.label;
|
|
scale.label = _;
|
|
return chart;
|
|
},
|
|
domain: function(_) {
|
|
if (!arguments.length && scale.fixedomain) return scale.fixedomain;
|
|
if (!arguments.length) return null;
|
|
scale.fixedomain = _;
|
|
return chart;
|
|
}
|
|
};
|
|
};
|
|
|
|
chart.yscale = scaleGetSet(yscale);
|
|
chart.xscale = scaleGetSet(xscale);
|
|
|
|
chart.addSerie = function(data, aes, options) {
|
|
if (!data && series.length > 0) data = series[0].data;
|
|
if (!options.color) options.color = defaultColors[series.length % defaultColors.length];
|
|
series.push({ data: data, aes: aes, options: options });
|
|
return chart;
|
|
};
|
|
|
|
return chart;
|
|
}
|
|
|
|
export default main;
|