diff --git a/assets/css/main.css b/assets/css/main.css
index 9dcf5df..6a1c874 100644
--- a/assets/css/main.css
+++ b/assets/css/main.css
@@ -298,11 +298,8 @@ footer a:hover {
}
.server .column-graph {
- float: right;
height: 100px;
width: 400px;
- margin-right: -3px;
- margin-bottom: 5px;
}
/* Highlighted values */
diff --git a/assets/html/index.html b/assets/html/index.html
index 6ec540c..08bfa58 100644
--- a/assets/html/index.html
+++ b/assets/html/index.html
@@ -6,6 +6,8 @@
+
+
diff --git a/assets/js/scale.js b/assets/js/scale.js
new file mode 100644
index 0000000..5281151
--- /dev/null
+++ b/assets/js/scale.js
@@ -0,0 +1,53 @@
+class RelativeScale {
+ static scale (data, tickCount) {
+ const [min, max] = RelativeScale.calculateBounds(data)
+
+ let factor = 1
+
+ while (true) {
+ const scale = Math.pow(10, factor)
+
+ const scaledMin = min - (min % scale)
+ const scaledMax = max + (max % scale === 0 ? 0 : (scale - (max % scale)))
+
+ const ticks = (scaledMax - scaledMin) / scale
+
+ if (ticks + 1 <= tickCount) {
+ return [scaledMin, scaledMax, scale]
+ } else {
+ // Too many steps between min/max, increase factor and try again
+ factor++
+ }
+ }
+ }
+
+ static generateTicks (min, max, step) {
+ const ticks = []
+ for (let i = min; i <= max; i += step) {
+ ticks.push(i)
+ }
+ return ticks
+ }
+
+ static calculateBounds (data) {
+ if (data.length === 0) {
+ return [0, 0]
+ } else {
+ let min = Number.MAX_VALUE
+ let max = Number.MIN_VALUE
+
+ for (const point of data) {
+ if (point > max) {
+ max = point
+ }
+ if (point < min) {
+ min = point
+ }
+ }
+
+ return [min, max]
+ }
+ }
+}
+
+module.exports = RelativeScale
diff --git a/assets/js/servers.js b/assets/js/servers.js
index bffe350..ed9f70c 100644
--- a/assets/js/servers.js
+++ b/assets/js/servers.js
@@ -1,37 +1,11 @@
+import uPlot from '../lib/uPlot.esm'
+
+import RelativeScale from './scale'
+
import { formatNumber, formatTimestamp, formatDate, formatMinecraftServerAddress, formatMinecraftVersions } from './util'
import MISSING_FAVICON from '../images/missing_favicon.svg'
-export const SERVER_GRAPH_OPTIONS = {
- series: {
- shadowSize: 0
- },
- xaxis: {
- font: {
- color: '#E3E3E3'
- },
- show: false
- },
- yaxis: {
- minTickSize: 100,
- tickDecimals: 0,
- show: true,
- tickLength: 10,
- tickFormatter: formatNumber,
- font: {
- color: '#E3E3E3'
- },
- labelWidth: -10
- },
- grid: {
- hoverable: true,
- color: '#696969'
- },
- colors: [
- '#E9E581'
- ]
-}
-
export class ServerRegistry {
constructor (app) {
this._app = app
@@ -93,15 +67,71 @@ export class ServerRegistration {
}
addGraphPoints (points, timestampPoints) {
- for (let i = 0; i < points.length; i++) {
- const point = points[i]
- const timestamp = timestampPoints[i]
- this._graphData.push([timestamp, point])
- }
+ this._graphData = [
+ timestampPoints.map(val => Math.floor(val / 1000)),
+ points
+ ]
}
buildPlotInstance () {
- this._plotInstance = $.plot('#chart_' + this.serverId, [this._graphData], SERVER_GRAPH_OPTIONS)
+ const tickCount = 5
+
+ // eslint-disable-next-line new-cap
+ this._plotInstance = new uPlot({
+ height: 100,
+ width: 400,
+ cursor: {
+ y: false,
+ drag: {
+ setScale: false,
+ x: false,
+ y: false
+ }
+ },
+ series: [
+ {},
+ {
+ scale: 'Players',
+ stroke: '#E9E581',
+ width: 2,
+ value: (_, raw) => formatNumber(raw) + ' Players'
+ }
+ ],
+ axes: [
+ {
+ show: false
+ },
+ {
+ ticks: {
+ show: false
+ },
+ font: '14px "Open Sans", sans-serif',
+ stroke: '#A3A3A3',
+ size: 55,
+ grid: {
+ stroke: '#333',
+ width: 1
+ },
+ split: (self) => {
+ const [min, max, scale] = RelativeScale.scale(self.data[1], tickCount)
+ const ticks = RelativeScale.generateTicks(min, max, scale)
+ return ticks
+ }
+ }
+ ],
+ scales: {
+ Players: {
+ auto: false,
+ range: (self) => {
+ const [scaledMin, scaledMax] = RelativeScale.scale(self.data[1], tickCount)
+ return [scaledMin, scaledMax]
+ }
+ }
+ },
+ legend: {
+ show: false
+ }
+ }, this._graphData, document.getElementById('chart_' + this.serverId))
}
handlePing (payload, timestamp) {
@@ -110,11 +140,14 @@ export class ServerRegistration {
// Only update graph for successful pings
// This intentionally pauses the server graph when pings begin to fail
- this._graphData.push([timestamp, this.playerCount])
+ this._graphData[0].push(Math.floor(timestamp / 1000))
+ this._graphData[1].push(this.playerCount)
// Trim graphData to within the max length by shifting out the leading elements
- if (this._graphData.length > this._app.publicConfig.serverGraphMaxLength) {
- this._graphData.shift()
+ for (const series of this._graphData) {
+ if (series.length > this._app.publicConfig.serverGraphMaxLength) {
+ series.shift()
+ }
}
this.redraw()
@@ -133,9 +166,7 @@ export class ServerRegistration {
redraw () {
// Redraw the plot instance
- this._plotInstance.setData([this._graphData])
- this._plotInstance.setupGrid()
- this._plotInstance.draw()
+ this._plotInstance.setData(this._graphData)
}
updateServerRankIndex (rankIndex) {
diff --git a/assets/lib/uPlot.esm.js b/assets/lib/uPlot.esm.js
new file mode 100644
index 0000000..0c32cb9
--- /dev/null
+++ b/assets/lib/uPlot.esm.js
@@ -0,0 +1,2640 @@
+/**
+* Copyright (c) 2020, Leon Sorokin
+* All rights reserved. (MIT Licensed)
+*
+* uPlot.js (μPlot)
+* A small, fast chart for time series, lines, areas, ohlc & bars
+* https://github.com/leeoniya/uPlot (v1.0.8)
+*/
+
+function debounce(fn, time) {
+ let pending = null;
+
+ function run() {
+ pending = null;
+ fn();
+ }
+
+ return function() {
+ clearTimeout(pending);
+ pending = setTimeout(run, time);
+ }
+}
+
+// binary search for index of closest value
+function closestIdx(num, arr, lo, hi) {
+ let mid;
+ lo = lo || 0;
+ hi = hi || arr.length - 1;
+ let bitwise = hi <= 2147483647;
+
+ while (hi - lo > 1) {
+ mid = bitwise ? (lo + hi) >> 1 : floor((lo + hi) / 2);
+
+ if (arr[mid] < num)
+ lo = mid;
+ else
+ hi = mid;
+ }
+
+ if (num - arr[lo] <= arr[hi] - num)
+ return lo;
+
+ return hi;
+}
+
+function getMinMax(data, _i0, _i1) {
+// console.log("getMinMax()");
+
+ let _min = inf;
+ let _max = -inf;
+
+ for (let i = _i0; i <= _i1; i++) {
+ if (data[i] != null) {
+ _min = min(_min, data[i]);
+ _max = max(_max, data[i]);
+ }
+ }
+
+ return [_min, _max];
+}
+
+// this ensures that non-temporal/numeric y-axes get multiple-snapped padding added above/below
+// TODO: also account for incrs when snapping to ensure top of axis gets a tick & value
+function rangeNum(min, max, mult, extra) {
+ // auto-scale Y
+ const delta = max - min;
+ const mag = log10(delta || abs(max) || 1);
+ const exp = floor(mag);
+ const incr = pow(10, exp) * mult;
+ const buf = delta == 0 ? incr : 0;
+
+ let snappedMin = round6(incrRoundDn(min - buf, incr));
+ let snappedMax = round6(incrRoundUp(max + buf, incr));
+
+ if (extra) {
+ // for flat data, always use 0 as one chart extreme & place data in center
+ if (delta == 0) {
+ if (max > 0) {
+ snappedMin = 0;
+ snappedMax = max * 2;
+ }
+ else if (max < 0) {
+ snappedMax = 0;
+ snappedMin = min * 2;
+ }
+ }
+ else {
+ // if buffer is too small, increase it
+ if (snappedMax - max < incr)
+ snappedMax += incr;
+
+ if (min - snappedMin < incr)
+ snappedMin -= incr;
+
+ // if original data never crosses 0, use 0 as one chart extreme
+ if (min >= 0 && snappedMin < 0)
+ snappedMin = 0;
+
+ if (max <= 0 && snappedMax > 0)
+ snappedMax = 0;
+ }
+ }
+
+ return [snappedMin, snappedMax];
+}
+
+const M = Math;
+
+const abs = M.abs;
+const floor = M.floor;
+const round = M.round;
+const ceil = M.ceil;
+const min = M.min;
+const max = M.max;
+const pow = M.pow;
+const log10 = M.log10;
+const PI = M.PI;
+
+const inf = Infinity;
+
+function incrRound(num, incr) {
+ return round(num/incr)*incr;
+}
+
+function clamp(num, _min, _max) {
+ return min(max(num, _min), _max);
+}
+
+function fnOrSelf(v) {
+ return typeof v == "function" ? v : () => v;
+}
+
+function retArg2(a, b) {
+ return b;
+}
+
+function incrRoundUp(num, incr) {
+ return ceil(num/incr)*incr;
+}
+
+function incrRoundDn(num, incr) {
+ return floor(num/incr)*incr;
+}
+
+function round3(val) {
+ return round(val * 1e3) / 1e3;
+}
+
+function round6(val) {
+ return round(val * 1e6) / 1e6;
+}
+
+//export const assign = Object.assign;
+
+const isArr = Array.isArray;
+
+function isStr(v) {
+ return typeof v === 'string';
+}
+
+function isObj(v) {
+ return typeof v === 'object' && v !== null;
+}
+
+function copy(o) {
+ let out;
+
+ if (isArr(o))
+ out = o.map(copy);
+ else if (isObj(o)) {
+ out = {};
+ for (var k in o)
+ out[k] = copy(o[k]);
+ }
+ else
+ out = o;
+
+ return out;
+}
+
+function assign(targ) {
+ let args = arguments;
+
+ for (let i = 1; i < args.length; i++) {
+ let src = args[i];
+
+ for (let key in src) {
+ if (isObj(targ[key]))
+ assign(targ[key], copy(src[key]));
+ else
+ targ[key] = copy(src[key]);
+ }
+ }
+
+ return targ;
+}
+
+const WIDTH = "width";
+const HEIGHT = "height";
+const TOP = "top";
+const BOTTOM = "bottom";
+const LEFT = "left";
+const RIGHT = "right";
+const firstChild = "firstChild";
+const createElement = "createElement";
+const hexBlack = "#000";
+const classList = "classList";
+
+const mousemove = "mousemove";
+const mousedown = "mousedown";
+const mouseup = "mouseup";
+const mouseenter = "mouseenter";
+const mouseleave = "mouseleave";
+const dblclick = "dblclick";
+const resize = "resize";
+const scroll = "scroll";
+
+const rAF = requestAnimationFrame;
+const doc = document;
+const win = window;
+const pxRatio = devicePixelRatio;
+
+function addClass(el, c) {
+ c != null && el[classList].add(c);
+}
+
+function remClass(el, c) {
+ el[classList].remove(c);
+}
+
+function setStylePx(el, name, value) {
+ el.style[name] = value + "px";
+}
+
+function placeTag(tag, cls, targ, refEl) {
+ let el = doc[createElement](tag);
+
+ if (cls != null)
+ addClass(el, cls);
+
+ if (targ != null)
+ targ.insertBefore(el, refEl);
+
+ return el;
+}
+
+function placeDiv(cls, targ) {
+ return placeTag("div", cls, targ);
+}
+
+function trans(el, xPos, yPos) {
+ el.style.transform = "translate(" + xPos + "px," + yPos + "px)";
+}
+
+const evOpts = {passive: true};
+
+function on(ev, el, cb) {
+ el.addEventListener(ev, cb, evOpts);
+}
+
+function off(ev, el, cb) {
+ el.removeEventListener(ev, cb, evOpts);
+}
+
+const months = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+];
+
+const days = [
+ "Sunday",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+];
+
+function slice3(str) {
+ return str.slice(0, 3);
+}
+
+const days3 = days.map(slice3);
+
+const months3 = months.map(slice3);
+
+const engNames = {
+ MMMM: months,
+ MMM: months3,
+ WWWW: days,
+ WWW: days3,
+};
+
+function zeroPad2(int) {
+ return (int < 10 ? '0' : '') + int;
+}
+
+function zeroPad3(int) {
+ return (int < 10 ? '00' : int < 100 ? '0' : '') + int;
+}
+
+/*
+function suffix(int) {
+ let mod10 = int % 10;
+
+ return int + (
+ mod10 == 1 && int != 11 ? "st" :
+ mod10 == 2 && int != 12 ? "nd" :
+ mod10 == 3 && int != 13 ? "rd" : "th"
+ );
+}
+*/
+
+const getFullYear = 'getFullYear';
+const getMonth = 'getMonth';
+const getDate = 'getDate';
+const getDay = 'getDay';
+const getHours = 'getHours';
+const getMinutes = 'getMinutes';
+const getSeconds = 'getSeconds';
+const getMilliseconds = 'getMilliseconds';
+
+const subs = {
+ // 2019
+ YYYY: d => d[getFullYear](),
+ // 19
+ YY: d => (d[getFullYear]()+'').slice(2),
+ // July
+ MMMM: (d, names) => names.MMMM[d[getMonth]()],
+ // Jul
+ MMM: (d, names) => names.MMM[d[getMonth]()],
+ // 07
+ MM: d => zeroPad2(d[getMonth]()+1),
+ // 7
+ M: d => d[getMonth]()+1,
+ // 09
+ DD: d => zeroPad2(d[getDate]()),
+ // 9
+ D: d => d[getDate](),
+ // Monday
+ WWWW: (d, names) => names.WWWW[d[getDay]()],
+ // Mon
+ WWW: (d, names) => names.WWW[d[getDay]()],
+ // 03
+ HH: d => zeroPad2(d[getHours]()),
+ // 3
+ H: d => d[getHours](),
+ // 9 (12hr, unpadded)
+ h: d => {let h = d[getHours](); return h == 0 ? 12 : h > 12 ? h - 12 : h;},
+ // AM
+ AA: d => d[getHours]() >= 12 ? 'PM' : 'AM',
+ // am
+ aa: d => d[getHours]() >= 12 ? 'pm' : 'am',
+ // a
+ a: d => d[getHours]() >= 12 ? 'p' : 'a',
+ // 09
+ mm: d => zeroPad2(d[getMinutes]()),
+ // 9
+ m: d => d[getMinutes](),
+ // 09
+ ss: d => zeroPad2(d[getSeconds]()),
+ // 9
+ s: d => d[getSeconds](),
+ // 374
+ fff: d => zeroPad3(d[getMilliseconds]()),
+};
+
+function fmtDate(tpl, names) {
+ names = names || engNames;
+ let parts = [];
+
+ let R = /\{([a-z]+)\}|[^{]+/gi, m;
+
+ while (m = R.exec(tpl))
+ parts.push(m[0][0] == '{' ? subs[m[1]] : m[0]);
+
+ return d => {
+ let out = '';
+
+ for (let i = 0; i < parts.length; i++)
+ out += typeof parts[i] == "string" ? parts[i] : parts[i](d, names);
+
+ return out;
+ }
+}
+
+// https://stackoverflow.com/questions/15141762/how-to-initialize-a-javascript-date-to-a-particular-time-zone/53652131#53652131
+function tzDate(date, tz) {
+ let date2 = new Date(date.toLocaleString('en-US', {timeZone: tz}));
+ date2.setMilliseconds(date[getMilliseconds]());
+ return date2;
+}
+
+//export const series = [];
+
+// default formatters:
+
+function genIncrs(minExp, maxExp, mults) {
+ let incrs = [];
+
+ for (let exp = minExp; exp < maxExp; exp++) {
+ for (let i = 0; i < mults.length; i++) {
+ let incr = mults[i] * pow(10, exp);
+ incrs.push(+incr.toFixed(abs(exp)));
+ }
+ }
+
+ return incrs;
+}
+
+const incrMults = [1,2,5];
+
+const decIncrs = genIncrs(-12, 0, incrMults);
+
+const intIncrs = genIncrs(0, 12, incrMults);
+
+const numIncrs = decIncrs.concat(intIncrs);
+
+let s = 1,
+ m = 60,
+ h = m * m,
+ d = h * 24,
+ mo = d * 30,
+ y = d * 365;
+
+// starting below 1e-3 is a hack to allow the incr finder to choose & bail out at incr < 1ms
+const timeIncrs = [5e-4].concat(genIncrs(-3, 0, incrMults), [
+ // minute divisors (# of secs)
+ 1,
+ 5,
+ 10,
+ 15,
+ 30,
+ // hour divisors (# of mins)
+ m,
+ m * 5,
+ m * 10,
+ m * 15,
+ m * 30,
+ // day divisors (# of hrs)
+ h,
+ h * 2,
+ h * 3,
+ h * 4,
+ h * 6,
+ h * 8,
+ h * 12,
+ // month divisors TODO: need more?
+ d,
+ d * 2,
+ d * 3,
+ d * 4,
+ d * 5,
+ d * 6,
+ d * 7,
+ d * 8,
+ d * 9,
+ d * 10,
+ d * 15,
+ // year divisors (# months, approx)
+ mo,
+ mo * 2,
+ mo * 3,
+ mo * 4,
+ mo * 6,
+ // century divisors
+ y,
+ y * 2,
+ y * 5,
+ y * 10,
+ y * 25,
+ y * 50,
+ y * 100,
+]);
+
+function timeAxisStamps(stampCfg, fmtDate) {
+ return stampCfg.map(s => [
+ s[0],
+ fmtDate(s[1]),
+ s[2],
+ fmtDate(s[4] ? s[1] + s[3] : s[3]),
+ ]);
+}
+
+const yyyy = "{YYYY}";
+const NLyyyy = "\n" + yyyy;
+const md = "{M}/{D}";
+const NLmd = "\n" + md;
+
+const aa = "{aa}";
+const hmm = "{h}:{mm}";
+const hmmaa = hmm + aa;
+const ss = ":{ss}";
+
+// [0]: minimum num secs in the tick incr
+// [1]: normal tick format
+// [2]: when a differing is encountered - 1: sec, 2: min, 3: hour, 4: day, 5: week, 6: month, 7: year
+// [3]: use a longer more contextual format
+// [4]: modes: 0: replace [1] -> [3], 1: concat [1] + [3]
+const _timeAxisStamps = [
+ [y, yyyy, 7, "", 1],
+ [d * 28, "{MMM}", 7, NLyyyy, 1],
+ [d, md, 7, NLyyyy, 1],
+ [h, "{h}" + aa, 4, NLmd, 1],
+ [m, hmmaa, 4, NLmd, 1],
+ [s, ss, 2, NLmd + " " + hmmaa, 1],
+ [1e-3, ss + ".{fff}", 2, NLmd + " " + hmmaa, 1],
+];
+
+// TODO: will need to accept spaces[] and pull incr into the loop when grid will be non-uniform, eg for log scales.
+// currently we ignore this for months since they're *nearly* uniform and the added complexity is not worth it
+function timeAxisVals(tzDate, stamps) {
+ return (self, splits, space, incr) => {
+ let s = stamps.find(e => incr >= e[0]);
+
+ // these track boundaries when a full label is needed again
+ let prevYear = null;
+ let prevDate = null;
+ let prevMinu = null;
+
+ return splits.map((split, i) => {
+ let date = tzDate(split);
+
+ let newYear = date[getFullYear]();
+ let newDate = date[getDate]();
+ let newMinu = date[getMinutes]();
+
+ let diffYear = newYear != prevYear;
+ let diffDate = newDate != prevDate;
+ let diffMinu = newMinu != prevMinu;
+
+ let stamp = s[2] == 7 && diffYear || s[2] == 4 && diffDate || s[2] == 2 && diffMinu ? s[3] : s[1];
+
+ prevYear = newYear;
+ prevDate = newDate;
+ prevMinu = newMinu;
+
+ return stamp(date);
+ });
+ }
+}
+
+function mkDate(y, m, d) {
+ return new Date(y, m, d);
+}
+
+// the ensures that axis ticks, values & grid are aligned to logical temporal breakpoints and not an arbitrary timestamp
+// https://www.timeanddate.com/time/dst/
+// https://www.timeanddate.com/time/dst/2019.html
+// https://www.epochconverter.com/timezones
+function timeAxisSplits(tzDate) {
+ return (self, scaleMin, scaleMax, incr, pctSpace) => {
+ let splits = [];
+ let isMo = incr >= mo && incr < y;
+
+ // get the timezone-adjusted date
+ let minDate = tzDate(scaleMin);
+ let minDateTs = minDate / 1e3;
+
+ // get ts of 12am (this lands us at or before the original scaleMin)
+ let minMin = mkDate(minDate[getFullYear](), minDate[getMonth](), isMo ? 1 : minDate[getDate]());
+ let minMinTs = minMin / 1e3;
+
+ if (isMo) {
+ let moIncr = incr / mo;
+ // let tzOffset = scaleMin - minDateTs; // needed?
+ let split = minDateTs == minMinTs ? minDateTs : mkDate(minMin[getFullYear](), minMin[getMonth]() + moIncr, 1) / 1e3;
+ let splitDate = new Date(split * 1e3);
+ let baseYear = splitDate[getFullYear]();
+ let baseMonth = splitDate[getMonth]();
+
+ for (let i = 0; split <= scaleMax; i++) {
+ let next = mkDate(baseYear, baseMonth + moIncr * i, 1);
+ let offs = next - tzDate(next / 1e3);
+
+ split = (+next + offs) / 1e3;
+
+ if (split <= scaleMax)
+ splits.push(split);
+ }
+ }
+ else {
+ let incr0 = incr >= d ? d : incr;
+ let tzOffset = floor(scaleMin) - floor(minDateTs);
+ let split = minMinTs + tzOffset + incrRoundUp(minDateTs - minMinTs, incr0);
+ splits.push(split);
+
+ let date0 = tzDate(split);
+
+ let prevHour = date0[getHours]() + (date0[getMinutes]() / m) + (date0[getSeconds]() / h);
+ let incrHours = incr / h;
+
+ while (1) {
+ split = round3(split + incr);
+
+ let expectedHour = floor(round6(prevHour + incrHours)) % 24;
+ let splitDate = tzDate(split);
+ let actualHour = splitDate.getHours();
+
+ let dstShift = actualHour - expectedHour;
+
+ if (dstShift > 1)
+ dstShift = -1;
+
+ split -= dstShift * h;
+
+ if (split > scaleMax)
+ break;
+
+ prevHour = (prevHour + incrHours) % 24;
+
+ // add a tick only if it's further than 70% of the min allowed label spacing
+ let prevSplit = splits[splits.length - 1];
+ let pctIncr = round3((split - prevSplit) / incr);
+
+ if (pctIncr * pctSpace >= .7)
+ splits.push(split);
+ }
+ }
+
+ return splits;
+ }
+}
+
+function timeSeriesStamp(stampCfg, fmtDate) {
+ return fmtDate(stampCfg);
+}
+const _timeSeriesStamp = '{YYYY}-{MM}-{DD} {h}:{mm}{aa}';
+
+function timeSeriesVal(tzDate, stamp) {
+ return (self, val) => stamp(tzDate(val));
+}
+
+function cursorPoint(self, si) {
+ let s = self.series[si];
+
+ let pt = placeDiv();
+
+ pt.style.background = s.stroke || hexBlack;
+
+ let dia = ptDia(s.width, 1);
+ let mar = (dia - 1) / -2;
+
+ setStylePx(pt, WIDTH, dia);
+ setStylePx(pt, HEIGHT, dia);
+ setStylePx(pt, "marginLeft", mar);
+ setStylePx(pt, "marginTop", mar);
+
+ return pt;
+}
+
+const cursorOpts = {
+ show: true,
+ x: true,
+ y: true,
+ lock: false,
+ points: {
+ show: cursorPoint,
+ },
+
+ drag: {
+ setScale: true,
+ x: true,
+ y: false,
+ },
+
+ focus: {
+ prox: -1,
+ },
+
+ locked: false,
+ left: -10,
+ top: -10,
+ idx: null,
+};
+
+const grid = {
+ show: true,
+ stroke: "rgba(0,0,0,0.07)",
+ width: 2,
+// dash: [],
+};
+
+const ticks = assign({}, grid, {size: 10});
+
+const font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"';
+const labelFont = "bold " + font;
+const lineMult = 1.5; // font-size multiplier
+
+const xAxisOpts = {
+ type: "x",
+ show: true,
+ scale: "x",
+ space: 50,
+ gap: 5,
+ size: 50,
+ labelSize: 30,
+ labelFont,
+ side: 2,
+// class: "x-vals",
+// incrs: timeIncrs,
+// values: timeVals,
+ grid,
+ ticks,
+ font,
+ rotate: 0,
+};
+
+const numSeriesLabel = "Value";
+const timeSeriesLabel = "Time";
+
+const xSeriesOpts = {
+ show: true,
+ scale: "x",
+// label: "Time",
+// value: v => stamp(new Date(v * 1e3)),
+
+ // internal caches
+ min: inf,
+ max: -inf,
+ idxs: [],
+};
+
+// alternative: https://stackoverflow.com/a/2254896
+let fmtNum = new Intl.NumberFormat(navigator.language);
+
+function numAxisVals(self, splits, space, incr) {
+ return splits.map(fmtNum.format);
+}
+
+function numAxisSplits(self, scaleMin, scaleMax, incr, pctSpace, forceMin) {
+ scaleMin = forceMin ? scaleMin : +incrRoundUp(scaleMin, incr).toFixed(12);
+
+ let splits = [];
+
+ for (let val = scaleMin; val <= scaleMax; val = +(val + incr).toFixed(12))
+ splits.push(val);
+
+ return splits;
+}
+
+function numSeriesVal(self, val) {
+ return val;
+}
+
+const yAxisOpts = {
+ type: "y",
+ show: true,
+ scale: "y",
+ space: 40,
+ gap: 5,
+ size: 50,
+ labelSize: 30,
+ labelFont,
+ side: 3,
+// class: "y-vals",
+// incrs: numIncrs,
+// values: (vals, space) => vals,
+ grid,
+ ticks,
+ font,
+ rotate: 0,
+};
+
+// takes stroke width
+function ptDia(width, mult) {
+ return max(round3(5 * mult), round3(width * mult) * 2 - 1);
+}
+
+function seriesPoints(self, si) {
+ const dia = ptDia(self.series[si].width, pxRatio);
+ let maxPts = self.bbox.width / dia / 2;
+ let idxs = self.series[0].idxs;
+ return idxs[1] - idxs[0] <= maxPts;
+}
+
+const ySeriesOpts = {
+// type: "n",
+ scale: "y",
+ show: true,
+ band: false,
+ alpha: 1,
+ points: {
+ show: seriesPoints,
+ // stroke: "#000",
+ // fill: "#fff",
+ // width: 1,
+ // size: 10,
+ },
+// label: "Value",
+// value: v => v,
+ values: null,
+
+ // internal caches
+ min: inf,
+ max: -inf,
+ idxs: [],
+
+ path: null,
+ clip: null,
+};
+
+const xScaleOpts = {
+ time: true,
+ auto: false,
+ distr: 1,
+ min: inf,
+ max: -inf,
+};
+
+const yScaleOpts = assign({}, xScaleOpts, {
+ time: false,
+ auto: true,
+});
+
+const syncs = {};
+
+function _sync(opts) {
+ let clients = [];
+
+ return {
+ sub(client) {
+ clients.push(client);
+ },
+ unsub(client) {
+ clients = clients.filter(c => c != client);
+ },
+ pub(type, self, x, y, w, h, i) {
+ if (clients.length > 1) {
+ clients.forEach(client => {
+ client != self && client.pub(type, self, x, y, w, h, i);
+ });
+ }
+ }
+ };
+}
+
+function setDefaults(d, xo, yo) {
+ return [d[0], d[1]].concat(d.slice(2)).map((o, i) => setDefault(o, i, xo, yo));
+}
+
+function setDefault(o, i, xo, yo) {
+ return assign({}, (i == 0 || o && o.side % 2 == 0 ? xo : yo), o);
+}
+
+function getYPos(val, scale, hgt, top) {
+ let pctY = (val - scale.min) / (scale.max - scale.min);
+ return top + (1 - pctY) * hgt;
+}
+
+function getXPos(val, scale, wid, lft) {
+ let pctX = (val - scale.min) / (scale.max - scale.min);
+ return lft + pctX * wid;
+}
+
+function snapTimeX(self, dataMin, dataMax) {
+ return [dataMin, dataMax > dataMin ? dataMax : dataMax + 86400];
+}
+
+function snapNumX(self, dataMin, dataMax) {
+ const delta = dataMax - dataMin;
+
+ if (delta == 0) {
+ const mag = log10(delta || abs(dataMax) || 1);
+ const exp = floor(mag) + 1;
+ return [dataMin, incrRoundUp(dataMax, pow(10, exp))];
+ }
+ else
+ return [dataMin, dataMax];
+}
+
+// this ensures that non-temporal/numeric y-axes get multiple-snapped padding added above/below
+// TODO: also account for incrs when snapping to ensure top of axis gets a tick & value
+function snapNumY(self, dataMin, dataMax) {
+ return rangeNum(dataMin, dataMax, 0.2, true);
+}
+
+// dim is logical (getClientBoundingRect) pixels, not canvas pixels
+function findIncr(valDelta, incrs, dim, minSpace) {
+ let pxPerUnit = dim / valDelta;
+
+ for (var i = 0; i < incrs.length; i++) {
+ let space = incrs[i] * pxPerUnit;
+
+ if (space >= minSpace)
+ return [incrs[i], space];
+ }
+}
+
+function filtMouse(e) {
+ return e.button == 0;
+}
+
+function pxRatioFont(font) {
+ let fontSize;
+ font = font.replace(/\d+/, m => (fontSize = round(m * pxRatio)));
+ return [font, fontSize];
+}
+
+function uPlot(opts, data, then) {
+ const self = {};
+
+ const root = self.root = placeDiv("uplot");
+
+ if (opts.id != null)
+ root.id = opts.id;
+
+ addClass(root, opts.class);
+
+ if (opts.title) {
+ let title = placeDiv("title", root);
+ title.textContent = opts.title;
+ }
+
+ const can = placeTag("canvas");
+ const ctx = self.ctx = can.getContext("2d");
+
+ const wrap = placeDiv("wrap", root);
+ const under = placeDiv("under", wrap);
+ wrap.appendChild(can);
+ const over = placeDiv("over", wrap);
+
+ opts = copy(opts);
+
+ (opts.plugins || []).forEach(p => {
+ if (p.opts)
+ opts = p.opts(self, opts) || opts;
+ });
+
+ let ready = false;
+
+ const series = setDefaults(opts.series, xSeriesOpts, ySeriesOpts);
+ const axes = setDefaults(opts.axes || [], xAxisOpts, yAxisOpts);
+ const scales = (opts.scales = opts.scales || {});
+
+ const gutters = assign({
+ x: round(yAxisOpts.size / 2),
+ y: round(xAxisOpts.size / 3),
+ }, opts.gutters);
+
+// self.tz = opts.tz || Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const _tzDate = (opts.tzDate || (ts => new Date(ts * 1e3)));
+ const _fmtDate = (opts.fmtDate || fmtDate);
+
+ const _timeAxisSplits = timeAxisSplits(_tzDate);
+ const _timeAxisVals = timeAxisVals(_tzDate, timeAxisStamps(_timeAxisStamps, _fmtDate));
+ const _timeSeriesVal = timeSeriesVal(_tzDate, timeSeriesStamp(_timeSeriesStamp, _fmtDate));
+
+ self.series = series;
+ self.axes = axes;
+ self.scales = scales;
+
+ const pendScales = {};
+
+ // explicitly-set initial scales
+ for (let k in scales) {
+ let sc = scales[k];
+
+ if (sc.min != null || sc.max != null)
+ pendScales[k] = {min: sc.min, max: sc.max};
+ }
+
+ const legend = assign({show: true}, opts.legend);
+ const showLegend = legend.show;
+
+ let legendEl;
+ let legendRows = [];
+ let legendCols;
+ let multiValLegend = false;
+
+ if (showLegend) {
+ legendEl = placeTag("table", "legend", root);
+
+ const getMultiVals = series[1].values;
+ multiValLegend = getMultiVals != null;
+
+ if (multiValLegend) {
+ let head = placeTag("tr", "labels", legendEl);
+ placeTag("th", null, head);
+ legendCols = getMultiVals(self, 1, 0);
+
+ for (var key in legendCols)
+ placeTag("th", null, head).textContent = key;
+ }
+ else {
+ legendCols = {_: 0};
+ addClass(legendEl, "inline");
+ }
+ }
+
+ function initLegendRow(s, i) {
+ if (i == 0 && multiValLegend)
+ return null;
+
+ let _row = [];
+
+ let row = placeTag("tr", "series", legendEl, legendEl.childNodes[i]);
+
+ addClass(row, s.class);
+
+ if (!s.show)
+ addClass(row, "off");
+
+ let label = placeTag("th", null, row);
+
+ let indic = placeDiv("ident", label);
+ s.width && (indic.style.borderColor = s.stroke);
+ indic.style.backgroundColor = s.fill;
+
+ let text = placeDiv("text", label);
+ text.textContent = s.label;
+
+ if (i > 0) {
+ on("click", label, e => {
+ if ( cursor.locked)
+ return;
+
+ filtMouse(e) && setSeries(series.indexOf(s), {show: !s.show}, syncOpts.setSeries);
+ });
+
+ if (cursorFocus) {
+ on(mouseenter, label, e => {
+ if (cursor.locked)
+ return;
+
+ setSeries(series.indexOf(s), {focus: true}, syncOpts.setSeries);
+ });
+ }
+ }
+
+ for (var key in legendCols) {
+ let v = placeTag("td", null, row);
+ v.textContent = "--";
+ _row.push(v);
+ }
+
+ return _row;
+ }
+
+ const cursor = (self.cursor = assign({}, cursorOpts, opts.cursor));
+
+ (cursor.points.show = fnOrSelf(cursor.points.show));
+
+ const focus = self.focus = assign({}, opts.focus || {alpha: 0.3}, cursor.focus);
+ const cursorFocus = focus.prox >= 0;
+
+ // series-intersection markers
+ let cursorPts = [null];
+
+ function initCursorPt(s, si) {
+ if (si > 0) {
+ let pt = cursor.points.show(self, si);
+
+ if (pt) {
+ addClass(pt, "cursor-pt");
+ addClass(pt, s.class);
+ trans(pt, -10, -10);
+ over.insertBefore(pt, cursorPts[si]);
+
+ return pt;
+ }
+ }
+ }
+
+ function initSeries(s, i) {
+ // init scales & defaults
+ const scKey = s.scale;
+
+ const sc = scales[scKey] = assign({}, (i == 0 ? xScaleOpts : yScaleOpts), scales[scKey]);
+
+ let isTime = sc.time;
+
+ sc.range = fnOrSelf(sc.range || (isTime ? snapTimeX : i == 0 ? snapNumX : snapNumY));
+
+ s.spanGaps = s.spanGaps === true ? retArg2 : fnOrSelf(s.spanGaps || []);
+
+ let sv = s.value;
+ s.value = isTime ? (isStr(sv) ? timeSeriesVal(_tzDate, timeSeriesStamp(sv, _fmtDate)) : sv || _timeSeriesVal) : sv || numSeriesVal;
+ s.label = s.label || (isTime ? timeSeriesLabel : numSeriesLabel);
+
+ if (i > 0) {
+ s.width = s.width == null ? 1 : s.width;
+ s.paths = s.paths || ( buildPaths);
+ let _ptDia = ptDia(s.width, 1);
+ s.points = assign({}, {
+ size: _ptDia,
+ width: max(1, _ptDia * .2),
+ }, s.points);
+ s.points.show = fnOrSelf(s.points.show);
+ s._paths = null;
+ }
+
+ if (showLegend)
+ legendRows.splice(i, 0, initLegendRow(s, i));
+
+ if ( cursor.show) {
+ let pt = initCursorPt(s, i);
+ pt && cursorPts.splice(i, 0, pt);
+ }
+ }
+
+ function addSeries(opts, si) {
+ si = si == null ? series.length : si;
+
+ opts = setDefault(opts, si, xSeriesOpts, ySeriesOpts);
+ series.splice(si, 0, opts);
+ initSeries(series[si], si);
+ }
+
+ self.addSeries = addSeries;
+
+ function delSeries(i) {
+ series.splice(i, 1);
+ legendRows.splice(i, 1)[0][0].parentNode.remove();
+ cursorPts.splice(i, 1)[0].remove();
+
+ // TODO: de-init no-longer-needed scales?
+ }
+
+ self.delSeries = delSeries;
+
+ series.forEach(initSeries);
+
+ // dependent scales inherit
+ for (let k in scales) {
+ let sc = scales[k];
+
+ if (sc.from != null)
+ scales[k] = assign({}, scales[sc.from], sc);
+ }
+
+ const xScaleKey = series[0].scale;
+ const xScaleDistr = scales[xScaleKey].distr;
+
+ function initAxis(axis, i) {
+ if (axis.show) {
+ let isVt = axis.side % 2;
+
+ let sc = scales[axis.scale];
+
+ // this can occur if all series specify non-default scales
+ if (sc == null) {
+ axis.scale = isVt ? series[1].scale : xScaleKey;
+ sc = scales[axis.scale];
+ }
+
+ // also set defaults for incrs & values based on axis distr
+ let isTime = sc.time;
+
+ axis.space = fnOrSelf(axis.space);
+ axis.rotate = fnOrSelf(axis.rotate);
+ axis.incrs = fnOrSelf(axis.incrs || ( sc.distr == 2 ? intIncrs : (isTime ? timeIncrs : numIncrs)));
+ axis.split = fnOrSelf(axis.split || (isTime && sc.distr == 1 ? _timeAxisSplits : numAxisSplits));
+ let av = axis.values;
+ axis.values = isTime ? (isArr(av) ? timeAxisVals(_tzDate, timeAxisStamps(av, _fmtDate)) : av || _timeAxisVals) : av || numAxisVals;
+
+ axis.font = pxRatioFont(axis.font);
+ axis.labelFont = pxRatioFont(axis.labelFont);
+ }
+ }
+
+ // set axis defaults
+ axes.forEach(initAxis);
+
+ let dataLen;
+
+ // rendered data window
+ let i0 = null;
+ let i1 = null;
+ const idxs = series[0].idxs;
+
+ let data0 = null;
+
+ function setData(_data, _resetScales) {
+ self.data = _data;
+ data = _data.slice();
+ data0 = data[0];
+ dataLen = data0.length;
+
+ if (xScaleDistr == 2)
+ data[0] = data0.map((v, i) => i);
+
+ resetYSeries();
+
+ fire("setData");
+
+ _resetScales !== false && autoScaleX();
+ }
+
+ self.setData = setData;
+
+ function autoScaleX() {
+ i0 = idxs[0] = 0;
+ i1 = idxs[1] = dataLen - 1;
+
+ let _min = xScaleDistr == 2 ? i0 : data[0][i0],
+ _max = xScaleDistr == 2 ? i1 : data[0][i1];
+
+ _setScale(xScaleKey, _min, _max);
+ }
+
+ function setCtxStyle(stroke, width, dash, fill) {
+ ctx.strokeStyle = stroke || hexBlack;
+ ctx.lineWidth = width;
+ ctx.lineJoin = "round";
+ ctx.setLineDash(dash || []);
+ ctx.fillStyle = fill || hexBlack;
+ }
+
+ let fullWidCss;
+ let fullHgtCss;
+
+ let plotWidCss;
+ let plotHgtCss;
+
+ // plot margins to account for axes
+ let plotLftCss;
+ let plotTopCss;
+
+ let plotLft;
+ let plotTop;
+ let plotWid;
+ let plotHgt;
+
+ self.bbox = {};
+
+ function _setSize(width, height) {
+ self.width = fullWidCss = plotWidCss = width;
+ self.height = fullHgtCss = plotHgtCss = height;
+ plotLftCss = plotTopCss = 0;
+
+ calcPlotRect();
+ calcAxesRects();
+
+ let bb = self.bbox;
+
+ plotLft = bb[LEFT] = incrRound(plotLftCss * pxRatio, 0.5);
+ plotTop = bb[TOP] = incrRound(plotTopCss * pxRatio, 0.5);
+ plotWid = bb[WIDTH] = incrRound(plotWidCss * pxRatio, 0.5);
+ plotHgt = bb[HEIGHT] = incrRound(plotHgtCss * pxRatio, 0.5);
+
+ setStylePx(under, LEFT, plotLftCss);
+ setStylePx(under, TOP, plotTopCss);
+ setStylePx(under, WIDTH, plotWidCss);
+ setStylePx(under, HEIGHT, plotHgtCss);
+
+ setStylePx(over, LEFT, plotLftCss);
+ setStylePx(over, TOP, plotTopCss);
+ setStylePx(over, WIDTH, plotWidCss);
+ setStylePx(over, HEIGHT, plotHgtCss);
+
+ setStylePx(wrap, WIDTH, fullWidCss);
+ setStylePx(wrap, HEIGHT, fullHgtCss);
+
+ can[WIDTH] = round(fullWidCss * pxRatio);
+ can[HEIGHT] = round(fullHgtCss * pxRatio);
+
+ syncRect();
+
+ ready && _setScale(xScaleKey, scales[xScaleKey].min, scales[xScaleKey].max);
+
+ ready && fire("setSize");
+ }
+
+ function setSize({width, height}) {
+ _setSize(width, height);
+ }
+
+ self.setSize = setSize;
+
+ // accumulate axis offsets, reduce canvas width
+ function calcPlotRect() {
+ // easements for edge labels
+ let hasTopAxis = false;
+ let hasBtmAxis = false;
+ let hasRgtAxis = false;
+ let hasLftAxis = false;
+
+ axes.forEach((axis, i) => {
+ if (axis.show) {
+ let {side, size} = axis;
+ let isVt = side % 2;
+ let labelSize = axis.labelSize = (axis.label != null ? (axis.labelSize || 30) : 0);
+
+ let fullSize = size + labelSize;
+
+ if (fullSize > 0) {
+ if (isVt) {
+ plotWidCss -= fullSize;
+
+ if (side == 3) {
+ plotLftCss += fullSize;
+ hasLftAxis = true;
+ }
+ else
+ hasRgtAxis = true;
+ }
+ else {
+ plotHgtCss -= fullSize;
+
+ if (side == 0) {
+ plotTopCss += fullSize;
+ hasTopAxis = true;
+ }
+ else
+ hasBtmAxis = true;
+ }
+ }
+ }
+ });
+
+ // hz gutters
+ if (hasTopAxis || hasBtmAxis) {
+ if (!hasRgtAxis)
+ plotWidCss -= gutters.x;
+ if (!hasLftAxis) {
+ plotWidCss -= gutters.x;
+ plotLftCss += gutters.x;
+ }
+ }
+
+ // vt gutters
+ if (hasLftAxis || hasRgtAxis) {
+ if (!hasBtmAxis)
+ plotHgtCss -= gutters.y;
+ if (!hasTopAxis) {
+ plotHgtCss -= gutters.y;
+ plotTopCss += gutters.y;
+ }
+ }
+ }
+
+ function calcAxesRects() {
+ // will accum +
+ let off1 = plotLftCss + plotWidCss;
+ let off2 = plotTopCss + plotHgtCss;
+ // will accum -
+ let off3 = plotLftCss;
+ let off0 = plotTopCss;
+
+ function incrOffset(side, size) {
+
+ switch (side) {
+ case 1: off1 += size; return off1 - size;
+ case 2: off2 += size; return off2 - size;
+ case 3: off3 -= size; return off3 + size;
+ case 0: off0 -= size; return off0 + size;
+ }
+ }
+
+ axes.forEach((axis, i) => {
+ let side = axis.side;
+
+ axis._pos = incrOffset(side, axis.size);
+
+ if (axis.label != null)
+ axis._lpos = incrOffset(side, axis.labelSize);
+ });
+ }
+
+ function setScales() {
+ if (inBatch) {
+ shouldSetScales = true;
+ return;
+ }
+
+ // log("setScales()", arguments);
+
+ if (dataLen > 0) {
+ // wip scales
+ let wipScales = copy(scales);
+
+ for (let k in wipScales) {
+ let wsc = wipScales[k];
+ let psc = pendScales[k];
+
+ if (psc != null) {
+ assign(wsc, psc);
+
+ // explicitly setting the x-scale invalidates everything (acts as redraw)
+ if (k == xScaleKey)
+ resetYSeries();
+ }
+ else if (k != xScaleKey) {
+ wsc.min = inf;
+ wsc.max = -inf;
+ }
+ }
+
+ // pre-range y-scales from y series' data values
+ series.forEach((s, i) => {
+ let k = s.scale;
+ let wsc = wipScales[k];
+
+ // setting the x scale invalidates everything
+ if (i == 0) {
+ let minMax = wsc.range(self, wsc.min, wsc.max);
+
+ wsc.min = minMax[0];
+ wsc.max = minMax[1];
+
+ i0 = closestIdx(wsc.min, data[0]);
+ i1 = closestIdx(wsc.max, data[0]);
+
+ // closest indices can be outside of view
+ if (data[0][i0] < wsc.min)
+ i0++;
+ if (data[0][i1] > wsc.max)
+ i1--;
+
+ s.min = data0[i0];
+ s.max = data0[i1];
+ }
+ else if (s.show && pendScales[k] == null) {
+ // only run getMinMax() for invalidated series data, else reuse
+ let minMax = s.min == inf ? (wsc.auto ? getMinMax(data[i], i0, i1) : [0,100]) : [s.min, s.max];
+
+ // initial min/max
+ wsc.min = min(wsc.min, s.min = minMax[0]);
+ wsc.max = max(wsc.max, s.max = minMax[1]);
+ }
+
+ s.idxs[0] = i0;
+ s.idxs[1] = i1;
+ });
+
+ // range independent scales
+ for (let k in wipScales) {
+ let wsc = wipScales[k];
+
+ if (wsc.from == null && wsc.min != inf && pendScales[k] == null) {
+ let minMax = wsc.range(self, wsc.min, wsc.max);
+ wsc.min = minMax[0];
+ wsc.max = minMax[1];
+ }
+ }
+
+ // range dependent scales
+ for (let k in wipScales) {
+ let wsc = wipScales[k];
+
+ if (wsc.from != null) {
+ let base = wipScales[wsc.from];
+
+ if (base.min != inf) {
+ let minMax = wsc.range(self, base.min, base.max);
+ wsc.min = minMax[0];
+ wsc.max = minMax[1];
+ }
+ }
+ }
+
+ let changed = {};
+
+ for (let k in wipScales) {
+ let wsc = wipScales[k];
+ let sc = scales[k];
+
+ if (sc.min != wsc.min || sc.max != wsc.max) {
+ sc.min = wsc.min;
+ sc.max = wsc.max;
+ changed[k] = true;
+ }
+
+ pendScales[k] = null;
+ }
+
+ // invalidate paths of all series on changed scales
+ series.forEach(s => {
+ if (changed[s.scale])
+ s._paths = null;
+ });
+
+ for (let k in changed)
+ fire("setScale", k);
+ }
+
+ cursor.show && updateCursor();
+ }
+
+ // TODO: drawWrap(si, drawPoints) (save, restore, translate, clip)
+
+ function drawPoints(si) {
+ // log("drawPoints()", arguments);
+
+ let s = series[si];
+ let p = s.points;
+
+ const width = round3(s[WIDTH] * pxRatio);
+ const offset = (width % 2) / 2;
+ const isStroked = p.width > 0;
+
+ let rad = (p.size - p.width) / 2 * pxRatio;
+ let dia = round3(rad * 2);
+
+ ctx.translate(offset, offset);
+
+ ctx.save();
+
+ ctx.beginPath();
+ ctx.rect(
+ plotLft - dia,
+ plotTop - dia,
+ plotWid + dia * 2,
+ plotHgt + dia * 2,
+ );
+ ctx.clip();
+
+ ctx.globalAlpha = s.alpha;
+
+ const path = new Path2D();
+
+ for (let pi = i0; pi <= i1; pi++) {
+ if (data[si][pi] != null) {
+ let x = round(getXPos(data[0][pi], scales[xScaleKey], plotWid, plotLft));
+ let y = round(getYPos(data[si][pi], scales[s.scale], plotHgt, plotTop));
+
+ path.moveTo(x + rad, y);
+ path.arc(x, y, rad, 0, PI * 2);
+ }
+ }
+
+ setCtxStyle(
+ p.stroke || s.stroke || hexBlack,
+ width,
+ null,
+ p.fill || (isStroked ? "#fff" : s.stroke || hexBlack),
+ );
+
+ ctx.fill(path);
+ isStroked && ctx.stroke(path);
+
+ ctx.globalAlpha = 1;
+
+ ctx.restore();
+
+ ctx.translate(-offset, -offset);
+ }
+
+ // grabs the nearest indices with y data outside of x-scale limits
+ function getOuterIdxs(ydata) {
+ let _i0 = clamp(i0 - 1, 0, dataLen - 1);
+ let _i1 = clamp(i1 + 1, 0, dataLen - 1);
+
+ while (ydata[_i0] == null && _i0 > 0)
+ _i0--;
+
+ while (ydata[_i1] == null && _i1 < dataLen - 1)
+ _i1++;
+
+ return [_i0, _i1];
+ }
+
+ let dir = 1;
+
+ function drawSeries() {
+ // path building loop must be before draw loop to ensure that all bands are fully constructed
+ series.forEach((s, i) => {
+ if (i > 0 && s.show && s._paths == null) {
+ let _idxs = getOuterIdxs(data[i]);
+ s._paths = s.paths(self, i, _idxs[0], _idxs[1]);
+ }
+ });
+
+ series.forEach((s, i) => {
+ if (i > 0 && s.show) {
+ if (s._paths)
+ drawPath(i);
+
+ if (s.points.show(self, i, i0, i1))
+ drawPoints(i);
+
+ fire("drawSeries", i);
+ }
+ });
+ }
+
+ function drawPath(si) {
+ const s = series[si];
+
+ if (dir == 1) {
+ const { stroke, fill, clip } = s._paths;
+ const width = round3(s[WIDTH] * pxRatio);
+ const offset = (width % 2) / 2;
+
+ setCtxStyle(s.stroke, width, s.dash, s.fill);
+
+ ctx.globalAlpha = s.alpha;
+
+ ctx.translate(offset, offset);
+
+ ctx.save();
+
+ let lft = plotLft,
+ top = plotTop,
+ wid = plotWid,
+ hgt = plotHgt;
+
+ let halfWid = width * pxRatio / 2;
+
+ if (s.min == 0)
+ hgt += halfWid;
+
+ if (s.max == 0) {
+ top -= halfWid;
+ hgt += halfWid;
+ }
+
+ ctx.beginPath();
+ ctx.rect(lft, top, wid, hgt);
+ ctx.clip();
+
+ if (clip != null)
+ ctx.clip(clip);
+
+ if (s.band) {
+ ctx.fill(stroke);
+ width && ctx.stroke(stroke);
+ }
+ else {
+ width && ctx.stroke(stroke);
+
+ if (s.fill != null)
+ ctx.fill(fill);
+ }
+
+ ctx.restore();
+
+ ctx.translate(-offset, -offset);
+
+ ctx.globalAlpha = 1;
+ }
+
+ if (s.band)
+ dir *= -1;
+ }
+
+ function buildClip(is, gaps) {
+ let s = series[is];
+ let toSpan = new Set(s.spanGaps(self, gaps, is));
+ gaps = gaps.filter(g => !toSpan.has(g));
+
+ let clip = null;
+
+ // create clip path (invert gaps and non-gaps)
+ if (gaps.length > 0) {
+ clip = new Path2D();
+
+ let prevGapEnd = plotLft;
+
+ for (let i = 0; i < gaps.length; i++) {
+ let g = gaps[i];
+
+ clip.rect(prevGapEnd, plotTop, g[0] - prevGapEnd, plotTop + plotHgt);
+
+ prevGapEnd = g[1];
+ }
+
+ clip.rect(prevGapEnd, plotTop, plotLft + plotWid - prevGapEnd, plotTop + plotHgt);
+ }
+
+ return clip;
+ }
+
+ function buildPaths(self, is, _i0, _i1) {
+ const s = series[is];
+
+ const xdata = data[0];
+ const ydata = data[is];
+ const scaleX = scales[xScaleKey];
+ const scaleY = scales[s.scale];
+
+ const _paths = dir == 1 ? {stroke: new Path2D(), fill: null, clip: null} : series[is-1]._paths;
+ const stroke = _paths.stroke;
+ const width = round3(s[WIDTH] * pxRatio);
+
+ let minY = inf,
+ maxY = -inf,
+ outY, outX;
+
+ // todo: don't build gaps on dir = -1 pass
+ let gaps = [];
+
+ let accX = round(getXPos(xdata[dir == 1 ? _i0 : _i1], scaleX, plotWid, plotLft));
+
+ // the moves the shape edge outside the canvas so stroke doesnt bleed in
+ if (s.band && dir == 1 && _i0 == i0) {
+ if (width)
+ stroke.lineTo(-width, round(getYPos(ydata[_i0], scaleY, plotHgt, plotTop)));
+
+ if (scaleX.min < xdata[0])
+ gaps.push([plotLft, accX - 1]);
+ }
+
+ for (let i = dir == 1 ? _i0 : _i1; i >= _i0 && i <= _i1; i += dir) {
+ let x = round(getXPos(xdata[i], scaleX, plotWid, plotLft));
+
+ if (x == accX) {
+ if (ydata[i] != null) {
+ outY = round(getYPos(ydata[i], scaleY, plotHgt, plotTop));
+ minY = min(outY, minY);
+ maxY = max(outY, maxY);
+ }
+ }
+ else {
+ let addGap = false;
+
+ if (minY != inf) {
+ stroke.lineTo(accX, minY);
+ stroke.lineTo(accX, maxY);
+ stroke.lineTo(accX, outY);
+ outX = accX;
+ }
+ else
+ addGap = true;
+
+ if (ydata[i] != null) {
+ outY = round(getYPos(ydata[i], scaleY, plotHgt, plotTop));
+ stroke.lineTo(x, outY);
+ minY = maxY = outY;
+
+ // prior pixel can have data but still start a gap if ends with null
+ if (x - accX > 1 && ydata[i-1] == null)
+ addGap = true;
+ }
+ else {
+ minY = inf;
+ maxY = -inf;
+ }
+
+ if (addGap) {
+ let prevGap = gaps[gaps.length - 1];
+
+ if (prevGap && prevGap[0] == outX) // TODO: gaps must be encoded at stroke widths?
+ prevGap[1] = x;
+ else
+ gaps.push([outX, x]);
+ }
+
+ accX = x;
+ }
+ }
+
+ if (s.band) {
+ let overShoot = width * 100, _iy, _x;
+
+ // the moves the shape edge outside the canvas so stroke doesnt bleed in
+ if (dir == -1 && _i0 == i0) {
+ _x = plotLft - overShoot;
+ _iy = _i0;
+ }
+
+ if (dir == 1 && _i1 == i1) {
+ _x = plotLft + plotWid + overShoot;
+ _iy = _i1;
+
+ if (scaleX.max > xdata[dataLen - 1])
+ gaps.push([accX, plotLft + plotWid]);
+ }
+
+ stroke.lineTo(_x, round(getYPos(ydata[_iy], scaleY, plotHgt, plotTop)));
+ }
+
+ if (dir == 1) {
+ _paths.clip = buildClip(is, gaps);
+
+ if (s.fill != null) {
+ let fill = _paths.fill = new Path2D(stroke);
+
+ let zeroY = round(getYPos(0, scaleY, plotHgt, plotTop));
+ fill.lineTo(plotLft + plotWid, zeroY);
+ fill.lineTo(plotLft, zeroY);
+ }
+ }
+
+ if (s.band)
+ dir *= -1;
+
+ return _paths;
+ }
+
+ function getIncrSpace(axis, min, max, fullDim) {
+ let incrSpace;
+
+ if (fullDim <= 0)
+ incrSpace = [0, 0];
+ else {
+ let minSpace = axis.space(self, min, max, fullDim);
+ let incrs = axis.incrs(self, min, max, fullDim, minSpace);
+ incrSpace = findIncr(max - min, incrs, fullDim, minSpace);
+ incrSpace.push(incrSpace[1]/minSpace);
+ }
+
+ return incrSpace;
+ }
+
+ function drawOrthoLines(offs, ori, side, pos0, len, width, stroke, dash) {
+ let offset = (width % 2) / 2;
+
+ ctx.translate(offset, offset);
+
+ setCtxStyle(stroke, width, dash);
+
+ ctx.beginPath();
+
+ let x0, y0, x1, y1, pos1 = pos0 + (side == 0 || side == 3 ? -len : len);
+
+ if (ori == 0) {
+ y0 = pos0;
+ y1 = pos1;
+ }
+ else {
+ x0 = pos0;
+ x1 = pos1;
+ }
+
+ offs.forEach((off, i) => {
+ if (ori == 0)
+ x0 = x1 = off;
+ else
+ y0 = y1 = off;
+
+ ctx.moveTo(x0, y0);
+ ctx.lineTo(x1, y1);
+ });
+
+ ctx.stroke();
+
+ ctx.translate(-offset, -offset);
+ }
+
+ function drawAxesGrid() {
+ axes.forEach((axis, i) => {
+ if (!axis.show)
+ return;
+
+ let scale = scales[axis.scale];
+
+ // this will happen if all series using a specific scale are toggled off
+ if (scale.min == inf)
+ return;
+
+ let side = axis.side;
+ let ori = side % 2;
+
+ let {min, max} = scale;
+
+ let [incr, space, pctSpace] = getIncrSpace(axis, min, max, ori == 0 ? plotWidCss : plotHgtCss);
+
+ // if we're using index positions, force first tick to match passed index
+ let forceMin = scale.distr == 2;
+
+ let splits = axis.split(self, min, max, incr, pctSpace, forceMin);
+
+ let getPos = ori == 0 ? getXPos : getYPos;
+ let plotDim = ori == 0 ? plotWid : plotHgt;
+ let plotOff = ori == 0 ? plotLft : plotTop;
+
+ let canOffs = splits.map(val => round(getPos(val, scale, plotDim, plotOff)));
+
+ let axisGap = round(axis.gap * pxRatio);
+
+ let ticks = axis.ticks;
+ let tickSize = ticks.show ? round(ticks.size * pxRatio) : 0;
+
+ // tick labels
+ // BOO this assumes a specific data/series
+ let values = axis.values(
+ self,
+ scale.distr == 2 ? splits.map(i => data0[i]) : splits,
+ space,
+ scale.distr == 2 ? data0[splits[1]] - data0[splits[0]] : incr,
+ );
+
+ // rotating of labels only supported on bottom x axis
+ let angle = side == 2 ? axis.rotate(self, values, space) * -PI/180 : 0;
+
+ let basePos = round(axis._pos * pxRatio);
+ let shiftAmt = tickSize + axisGap;
+ let shiftDir = ori == 0 && side == 0 || ori == 1 && side == 3 ? -1 : 1;
+ let finalPos = basePos + shiftAmt * shiftDir;
+ let y = ori == 0 ? finalPos : 0;
+ let x = ori == 1 ? finalPos : 0;
+
+ ctx.font = axis.font[0];
+ ctx.fillStyle = axis.stroke || hexBlack; // rgba?
+ ctx.textAlign = angle > 0 ? LEFT :
+ angle < 0 ? RIGHT :
+ ori == 0 ? "center" : side == 3 ? RIGHT : LEFT;
+ ctx.textBaseline = angle ||
+ ori == 1 ? "middle" : side == 2 ? TOP : BOTTOM;
+
+ let lineHeight = axis.font[1] * lineMult;
+
+ values.forEach((val, i) => {
+ if (ori == 0)
+ x = canOffs[i];
+ else
+ y = canOffs[i];
+
+ (""+val).split(/\n/gm).forEach((text, j) => {
+ if (angle) {
+ ctx.save();
+ ctx.translate(x, y + j * lineHeight);
+ ctx.rotate(angle);
+ ctx.fillText(text, 0, 0);
+ ctx.restore();
+ }
+ else
+ ctx.fillText(text, x, y + j * lineHeight);
+ });
+ });
+
+ // axis label
+ if (axis.label) {
+ ctx.save();
+
+ let baseLpos = round(axis._lpos * pxRatio);
+
+ if (ori == 1) {
+ x = y = 0;
+
+ ctx.translate(
+ baseLpos,
+ round(plotTop + plotHgt / 2),
+ );
+ ctx.rotate((side == 3 ? -PI : PI) / 2);
+
+ }
+ else {
+ x = round(plotLft + plotWid / 2);
+ y = baseLpos;
+ }
+
+ ctx.font = axis.labelFont[0];
+ // ctx.fillStyle = axis.labelStroke || hexBlack; // rgba?
+ ctx.textAlign = "center";
+ ctx.textBaseline = side == 2 ? TOP : BOTTOM;
+
+ ctx.fillText(axis.label, x, y);
+
+ ctx.restore();
+ }
+
+ // ticks
+ if (ticks.show) {
+ drawOrthoLines(
+ canOffs,
+ ori,
+ side,
+ basePos,
+ tickSize,
+ round3(ticks[WIDTH] * pxRatio),
+ ticks.stroke,
+ );
+ }
+
+ // grid
+ let grid = axis.grid;
+
+ if (grid.show) {
+ drawOrthoLines(
+ canOffs,
+ ori,
+ ori == 0 ? 2 : 1,
+ ori == 0 ? plotTop : plotLft,
+ ori == 0 ? plotHgt : plotWid,
+ round3(grid[WIDTH] * pxRatio),
+ grid.stroke,
+ grid.dash,
+ );
+ }
+ });
+
+ fire("drawAxes");
+ }
+
+ function resetYSeries() {
+ // log("resetYSeries()", arguments);
+
+ series.forEach((s, i) => {
+ if (i > 0) {
+ s.min = inf;
+ s.max = -inf;
+ s._paths = null;
+ }
+ });
+ }
+
+ let didPaint;
+
+ function paint() {
+ if (inBatch) {
+ shouldPaint = true;
+ return;
+ }
+
+ // log("paint()", arguments);
+
+ ctx.clearRect(0, 0, can[WIDTH], can[HEIGHT]);
+ fire("drawClear");
+ drawAxesGrid();
+ drawSeries();
+ didPaint = true;
+ fire("draw");
+ }
+
+ self.redraw = rebuildPaths => {
+ if (rebuildPaths !== false)
+ _setScale(xScaleKey, scales[xScaleKey].min, scales[xScaleKey].max);
+ else
+ paint();
+ };
+
+ // redraw() => setScale('x', scales.x.min, scales.x.max);
+
+ // explicit, never re-ranged (is this actually true? for x and y)
+ function setScale(key, opts) {
+ let sc = scales[key];
+
+ if (sc.from == null) {
+ if (key == xScaleKey) {
+ if (sc.distr == 2) {
+ opts.min = closestIdx(opts.min, data[0]);
+ opts.max = closestIdx(opts.max, data[0]);
+ }
+
+ // prevent setting a temporal x scale too small since Date objects cannot advance ticks smaller than 1ms
+ if ( sc.time && axes[0].show && opts.max > opts.min) {
+ // since scales and axes are loosly coupled, we have to make some assumptions here :(
+ let incr = getIncrSpace(axes[0], opts.min, opts.max, plotWidCss)[0];
+
+ if (incr < 1e-3)
+ return;
+ }
+ }
+
+ // log("setScale()", arguments);
+
+ pendScales[key] = opts;
+
+ didPaint = false;
+ setScales();
+ !didPaint && paint();
+ didPaint = false;
+ }
+ }
+
+ self.setScale = setScale;
+
+// INTERACTION
+
+ let vt;
+ let hz;
+
+ // starting position
+ let mouseLeft0;
+ let mouseTop0;
+
+ // current position
+ let mouseLeft1;
+ let mouseTop1;
+
+ let dragging = false;
+
+ const drag = cursor.drag;
+
+ if ( cursor.show) {
+ let c = "cursor-";
+
+ if (cursor.x) {
+ mouseLeft1 = cursor.left;
+ vt = placeDiv(c + "x", over);
+ }
+
+ if (cursor.y) {
+ mouseTop1 = cursor.top;
+ hz = placeDiv(c + "y", over);
+ }
+ }
+
+ const select = self.select = assign({
+ show: true,
+ left: 0,
+ width: 0,
+ top: 0,
+ height: 0,
+ }, opts.select);
+
+ const selectDiv = select.show ? placeDiv("select", over) : null;
+
+ function setSelect(opts, _fire) {
+ if (select.show) {
+ for (let prop in opts)
+ setStylePx(selectDiv, prop, select[prop] = opts[prop]);
+
+ _fire !== false && fire("setSelect");
+ }
+ }
+
+ self.setSelect = setSelect;
+
+ function toggleDOM(i, onOff) {
+ let s = series[i];
+ let label = showLegend ? legendRows[i][0].parentNode : null;
+
+ if (s.show)
+ label && remClass(label, "off");
+ else {
+ label && addClass(label, "off");
+ cursorPts.length > 1 && trans(cursorPts[i], 0, -10);
+ }
+ }
+
+ function _setScale(key, min, max) {
+ setScale(key, {min, max});
+ }
+
+ function setSeries(i, opts, pub) {
+ // log("setSeries()", arguments);
+
+ let s = series[i];
+
+ // batch(() => {
+ // will this cause redundant paint() if both show and focus are set?
+ if (opts.focus != null)
+ setFocus(i);
+
+ if (opts.show != null) {
+ s.show = opts.show;
+ toggleDOM(i, opts.show);
+
+ if (s.band) {
+ // not super robust, will break if two bands are adjacent
+ let ip = series[i+1] && series[i+1].band ? i+1 : i-1;
+ series[ip].show = s.show;
+ toggleDOM(ip, opts.show);
+ }
+
+ _setScale(xScaleKey, scales[xScaleKey].min, scales[xScaleKey].max); // redraw
+ }
+ // });
+
+ // firing setSeries after setScale seems out of order, but provides access to the updated props
+ // could improve by predefining firing order and building a queue
+ fire("setSeries", i, opts);
+
+ pub && sync.pub("setSeries", self, i, opts);
+ }
+
+ self.setSeries = setSeries;
+
+ function _alpha(i, value) {
+ series[i].alpha = value;
+
+ if ( legendRows)
+ legendRows[i][0].parentNode.style.opacity = value;
+ }
+
+ function _setAlpha(i, value) {
+ let s = series[i];
+
+ _alpha(i, value);
+
+ if (s.band) {
+ // not super robust, will break if two bands are adjacent
+ let ip = series[i+1].band ? i+1 : i-1;
+ _alpha(ip, value);
+ }
+ }
+
+ // y-distance
+ const distsToCursor = Array(series.length);
+
+ let focused = null;
+
+ function setFocus(i) {
+ if (i != focused) {
+ // log("setFocus()", arguments);
+
+ series.forEach((s, i2) => {
+ _setAlpha(i2, i == null || i2 == 0 || i2 == i ? 1 : focus.alpha);
+ });
+
+ focused = i;
+ paint();
+ }
+ }
+
+ if (showLegend && cursorFocus) {
+ on(mouseleave, legendEl, e => {
+ if (cursor.locked)
+ return;
+ setSeries(null, {focus: false}, syncOpts.setSeries);
+ updateCursor();
+ });
+ }
+
+ function scaleValueAtPos(pos, scale) {
+ let dim = scale == xScaleKey ? plotWidCss : plotHgtCss;
+ let pct = clamp(pos / dim, 0, 1);
+
+ let sc = scales[scale];
+ let d = sc.max - sc.min;
+ return sc.min + pct * d;
+ }
+
+ function closestIdxFromXpos(pos) {
+ let v = scaleValueAtPos(pos, xScaleKey);
+ return closestIdx(v, data[0], i0, i1);
+ }
+
+ self.valToIdx = val => closestIdx(val, data[0]);
+ self.posToIdx = closestIdxFromXpos;
+ self.posToVal = (pos, scale) => scaleValueAtPos(scale == xScaleKey ? pos : plotHgtCss - pos, scale);
+ self.valToPos = (val, scale, can) => (
+ scale == xScaleKey ?
+ getXPos(val, scales[scale],
+ can ? plotWid : plotWidCss,
+ can ? plotLft : 0,
+ ) :
+ getYPos(val, scales[scale],
+ can ? plotHgt : plotHgtCss,
+ can ? plotTop : 0,
+ )
+ );
+
+ let inBatch = false;
+ let shouldPaint = false;
+ let shouldSetScales = false;
+ let shouldUpdateCursor = false;
+
+ // defers calling expensive functions
+ function batch(fn) {
+ inBatch = true;
+ fn(self);
+ inBatch = false;
+ shouldSetScales && setScales();
+ shouldUpdateCursor && updateCursor();
+ shouldPaint && !didPaint && paint();
+ shouldSetScales = shouldUpdateCursor = shouldPaint = didPaint = inBatch;
+ }
+
+ self.batch = batch;
+
+ (self.setCursor = opts => {
+ mouseLeft1 = opts.left;
+ mouseTop1 = opts.top;
+ // assign(cursor, opts);
+ updateCursor();
+ });
+
+ let cursorRaf = 0;
+
+ function updateCursor(ts) {
+ if (inBatch) {
+ shouldUpdateCursor = true;
+ return;
+ }
+
+ // ts == null && log("updateCursor()", arguments);
+
+ cursorRaf = 0;
+
+ if (cursor.show) {
+ cursor.x && trans(vt,round(mouseLeft1),0);
+ cursor.y && trans(hz,0,round(mouseTop1));
+ }
+
+ let idx;
+
+ // if cursor hidden, hide points & clear legend vals
+ if (mouseLeft1 < 0 || dataLen == 0) {
+ idx = null;
+
+ for (let i = 0; i < series.length; i++) {
+ if (i > 0) {
+ distsToCursor[i] = inf;
+ cursorPts.length > 1 && trans(cursorPts[i], -10, -10);
+ }
+
+ if (showLegend) {
+ if (i == 0 && multiValLegend)
+ continue;
+
+ for (let j = 0; j < legendRows[i].length; j++)
+ legendRows[i][j][firstChild].nodeValue = '--';
+ }
+ }
+
+ if (cursorFocus)
+ setSeries(null, {focus: true}, syncOpts.setSeries);
+ }
+ else {
+ // let pctY = 1 - (y / rect[HEIGHT]);
+
+ idx = closestIdxFromXpos(mouseLeft1);
+
+ let scX = scales[xScaleKey];
+
+ let xPos = round3(getXPos(data[0][idx], scX, plotWidCss, 0));
+
+ for (let i = 0; i < series.length; i++) {
+ let s = series[i];
+
+ if (i > 0 && s.show) {
+ let valAtIdx = data[i][idx];
+
+ let yPos = valAtIdx == null ? -10 : round3(getYPos(valAtIdx, scales[s.scale], plotHgtCss, 0));
+
+ distsToCursor[i] = yPos > 0 ? abs(yPos - mouseTop1) : inf;
+
+ cursorPts.length > 1 && trans(cursorPts[i], xPos, yPos);
+ }
+ else
+ distsToCursor[i] = inf;
+
+ if (showLegend) {
+ if (i == 0 && multiValLegend)
+ continue;
+
+ let src = i == 0 && xScaleDistr == 2 ? data0 : data[i];
+
+ let vals = multiValLegend ? s.values(self, i, idx) : {_: s.value(self, src[idx], i, idx)};
+
+ let j = 0;
+
+ for (let k in vals)
+ legendRows[i][j++][firstChild].nodeValue = vals[k];
+ }
+ }
+ }
+
+ // nit: cursor.drag.setSelect is assumed always true
+ if (mouseLeft1 >= 0 && select.show && dragging) {
+ // setSelect should not be triggered on move events
+ if (drag.x) {
+ let minX = min(mouseLeft0, mouseLeft1);
+ let maxX = max(mouseLeft0, mouseLeft1);
+ setStylePx(selectDiv, LEFT, select[LEFT] = minX);
+ setStylePx(selectDiv, WIDTH, select[WIDTH] = maxX - minX);
+ }
+
+ if (drag.y) {
+ let minY = min(mouseTop0, mouseTop1);
+ let maxY = max(mouseTop0, mouseTop1);
+ setStylePx(selectDiv, TOP, select[TOP] = minY);
+ setStylePx(selectDiv, HEIGHT, select[HEIGHT] = maxY - minY);
+ }
+ }
+
+ // if ts is present, means we're implicitly syncing own cursor as a result of debounced rAF
+ if (ts != null) {
+ // this is not technically a "mousemove" event, since it's debounced, rename to setCursor?
+ // since this is internal, we can tweak it later
+ sync.pub(mousemove, self, mouseLeft1, mouseTop1, plotWidCss, plotHgtCss, idx);
+
+ if (cursorFocus) {
+ let minDist = min.apply(null, distsToCursor);
+
+ let fi = null;
+
+ if (minDist <= focus.prox) {
+ distsToCursor.some((dist, i) => {
+ if (dist == minDist)
+ return fi = i;
+ });
+ }
+
+ setSeries(fi, {focus: true}, syncOpts.setSeries);
+ }
+ }
+
+ cursor.idx = idx;
+ cursor.left = mouseLeft1;
+ cursor.top = mouseTop1;
+
+ ready && fire("setCursor");
+ }
+
+ let rect = null;
+
+ function syncRect() {
+ rect = over.getBoundingClientRect();
+ }
+
+ function mouseMove(e, src, _x, _y, _w, _h, _i) {
+ if (cursor.locked)
+ return;
+
+ cacheMouse(e, src, _x, _y, _w, _h, _i, false, e != null);
+
+ if (e != null) {
+ if (cursorRaf == 0)
+ cursorRaf = rAF(updateCursor);
+ }
+ else
+ updateCursor();
+ }
+
+ function cacheMouse(e, src, _x, _y, _w, _h, _i, initial, snap) {
+ if (e != null) {
+ _x = e.clientX - rect.left;
+ _y = e.clientY - rect.top;
+ }
+ else {
+ _x = plotWidCss * (_x/_w);
+ _y = plotHgtCss * (_y/_h);
+ }
+
+ if (snap) {
+ if (_x <= 1 || _x >= plotWidCss - 1)
+ _x = incrRound(_x, plotWidCss);
+
+ if (_y <= 1 || _y >= plotHgtCss - 1)
+ _y = incrRound(_y, plotHgtCss);
+ }
+
+ if (initial) {
+ mouseLeft0 = _x;
+ mouseTop0 = _y;
+ }
+ else {
+ mouseLeft1 = _x;
+ mouseTop1 = _y;
+ }
+ }
+
+ function hideSelect() {
+ setSelect({
+ width: !drag.x ? plotWidCss : 0,
+ height: !drag.y ? plotHgtCss : 0,
+ }, false);
+ }
+
+ function mouseDown(e, src, _x, _y, _w, _h, _i) {
+ if (e == null || filtMouse(e)) {
+ dragging = true;
+
+ cacheMouse(e, src, _x, _y, _w, _h, _i, true, true);
+
+ if (select.show && (drag.x || drag.y))
+ hideSelect();
+
+ if (e != null) {
+ on(mouseup, doc, mouseUp);
+ sync.pub(mousedown, self, mouseLeft0, mouseTop0, plotWidCss, plotHgtCss, null);
+ }
+ }
+ }
+
+ function mouseUp(e, src, _x, _y, _w, _h, _i) {
+ if ((e == null || filtMouse(e))) {
+ dragging = false;
+
+ cacheMouse(e, src, _x, _y, _w, _h, _i, false, true);
+
+ if (mouseLeft1 != mouseLeft0 || mouseTop1 != mouseTop0) {
+ setSelect(select);
+
+ if (drag.setScale) {
+ batch(() => {
+ if (drag.x) {
+ _setScale(xScaleKey,
+ scaleValueAtPos(select[LEFT], xScaleKey),
+ scaleValueAtPos(select[LEFT] + select[WIDTH], xScaleKey),
+ );
+ }
+
+ if (drag.y) {
+ for (let k in scales) {
+ let sc = scales[k];
+
+ if (k != xScaleKey && sc.from == null) {
+ _setScale(k,
+ scaleValueAtPos(plotHgtCss - select[TOP] - select[HEIGHT], k),
+ scaleValueAtPos(plotHgtCss - select[TOP], k),
+ );
+ }
+ }
+ }
+ });
+
+ hideSelect();
+ }
+ }
+ else if (cursor.lock) {
+ cursor.locked = !cursor.locked;
+
+ if (!cursor.locked)
+ updateCursor();
+ }
+
+ if (e != null) {
+ off(mouseup, doc, mouseUp);
+ sync.pub(mouseup, self, mouseLeft1, mouseTop1, plotWidCss, plotHgtCss, null);
+ }
+ }
+ }
+
+ function mouseLeave(e, src, _x, _y, _w, _h, _i) {
+ if (!cursor.locked && !dragging) {
+ mouseLeft1 = -10;
+ mouseTop1 = -10;
+ // passing a non-null timestamp to force sync/mousemove event
+ updateCursor(1);
+ }
+ }
+
+ function dblClick(e, src, _x, _y, _w, _h, _i) {
+ autoScaleX();
+
+ if (e != null)
+ sync.pub(dblclick, self, mouseLeft1, mouseTop1, plotWidCss, plotHgtCss, null);
+ }
+
+ // internal pub/sub
+ const events = {};
+
+ events[mousedown] = mouseDown;
+ events[mousemove] = mouseMove;
+ events[mouseup] = mouseUp;
+ events[dblclick] = dblClick;
+ events["setSeries"] = (e, src, idx, opts) => {
+ setSeries(idx, opts);
+ };
+
+ let deb;
+
+ if ( cursor.show) {
+ on(mousedown, over, mouseDown);
+ on(mousemove, over, mouseMove);
+ on(mouseenter, over, syncRect);
+ on(mouseleave, over, mouseLeave);
+ drag.setScale && on(dblclick, over, dblClick);
+
+ deb = debounce(syncRect, 100);
+
+ on(resize, win, deb);
+ on(scroll, win, deb);
+
+ self.syncRect = syncRect;
+ }
+
+ // external on/off
+ const hooks = self.hooks = opts.hooks || {};
+
+ function fire(evName, a1, a2) {
+ if (evName in hooks) {
+ hooks[evName].forEach(fn => {
+ fn.call(null, self, a1, a2);
+ });
+ }
+ }
+
+ (opts.plugins || []).forEach(p => {
+ for (let evName in p.hooks)
+ hooks[evName] = (hooks[evName] || []).concat(p.hooks[evName]);
+ });
+
+ const syncOpts = assign({
+ key: null,
+ setSeries: false,
+ }, cursor.sync);
+
+ const syncKey = syncOpts.key;
+
+ const sync = (syncKey != null ? (syncs[syncKey] = syncs[syncKey] || _sync()) : _sync());
+
+ sync.sub(self);
+
+ function pub(type, src, x, y, w, h, i) {
+ events[type](null, src, x, y, w, h, i);
+ }
+
+ (self.pub = pub);
+
+ function destroy() {
+ sync.unsub(self);
+ off(resize, win, deb);
+ off(scroll, win, deb);
+ root.remove();
+ fire("destroy");
+ }
+
+ self.destroy = destroy;
+
+ function _init() {
+ _setSize(opts[WIDTH], opts[HEIGHT]);
+
+ fire("init", opts, data);
+
+ setData(data || opts.data, false);
+
+ if (pendScales[xScaleKey])
+ setScale(xScaleKey, pendScales[xScaleKey]);
+ else
+ autoScaleX();
+
+ setSelect(select, false);
+
+ ready = true;
+
+ fire("ready");
+ }
+
+ if (then) {
+ if (then instanceof HTMLElement) {
+ then.appendChild(root);
+ _init();
+ }
+ else
+ then(self, _init);
+ }
+ else
+ _init();
+
+ return self;
+}
+
+uPlot.assign = assign;
+uPlot.rangeNum = rangeNum;
+
+{
+ uPlot.fmtDate = fmtDate;
+ uPlot.tzDate = tzDate;
+}
+
+export default uPlot;
diff --git a/assets/lib/uPlot.min.css b/assets/lib/uPlot.min.css
new file mode 100644
index 0000000..82b70b5
--- /dev/null
+++ b/assets/lib/uPlot.min.css
@@ -0,0 +1 @@
+.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: max-content;}.uplot .title {text-align: center;font-size: 18px;font-weight: bold;}.uplot .wrap {position: relative;user-select: none;}.uplot .over, .uplot .under {position: absolute;overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.uplot .legend {font-size: 14px;margin: auto;text-align: center;}.uplot .legend.inline {display: block;}.uplot .legend.inline * {display: inline-block;}.uplot .legend.inline tr {margin-right: 16px;}.uplot .legend th {font-weight: 600;}.uplot .legend th > * {vertical-align: middle;display: inline-block;}.uplot .legend .ident {width: 1em;height: 1em;margin-right: 4px;border: 2px solid transparent;}.uplot .legend.inline th::after {content: ":";vertical-align: middle;}.uplot .legend .series > * {padding: 4px;}.uplot .legend .series th {cursor: pointer;}.uplot .legend .off > * {opacity: 0.3;}.uplot .select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.uplot .select.off {display: none;}.uplot .cursor-x, .uplot .cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;z-index: 100;}.uplot .cursor-x {height: 100%;border-right: 1px dashed #607D8B;}.uplot .cursor-y {width: 100%;border-bottom: 1px dashed #607D8B;}.uplot .cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;filter: brightness(85%);pointer-events: none;will-change: transform;z-index: 100;}
\ No newline at end of file