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