From b96b9dacc5ff7988ff66a9f1929d9d345e7a46a3 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Sun, 10 May 2020 23:39:35 -0500 Subject: [PATCH 01/33] replace Flot.js with uPlot for server graphs --- assets/css/main.css | 3 - assets/html/index.html | 2 + assets/js/scale.js | 53 + assets/js/servers.js | 115 +- assets/lib/uPlot.esm.js | 2640 ++++++++++++++++++++++++++++++++++++++ assets/lib/uPlot.min.css | 1 + 6 files changed, 2769 insertions(+), 45 deletions(-) create mode 100644 assets/js/scale.js create mode 100644 assets/lib/uPlot.esm.js create mode 100644 assets/lib/uPlot.min.css 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 From 8d211434df9d4abaad5adf0d7e41d2213627a627 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Sun, 10 May 2020 23:42:44 -0500 Subject: [PATCH 02/33] reference graphData instead of obscure self.data --- assets/js/servers.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/js/servers.js b/assets/js/servers.js index ed9f70c..e860741 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -112,8 +112,8 @@ export class ServerRegistration { stroke: '#333', width: 1 }, - split: (self) => { - const [min, max, scale] = RelativeScale.scale(self.data[1], tickCount) + split: () => { + const [min, max, scale] = RelativeScale.scale(this._graphData[1], tickCount) const ticks = RelativeScale.generateTicks(min, max, scale) return ticks } @@ -122,8 +122,8 @@ export class ServerRegistration { scales: { Players: { auto: false, - range: (self) => { - const [scaledMin, scaledMax] = RelativeScale.scale(self.data[1], tickCount) + range: () => { + const [scaledMin, scaledMax] = RelativeScale.scale(this._graphData[1], tickCount) return [scaledMin, scaledMax] } } From 1ff9478988b3aab30db69318de5db757e5345050 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 00:37:22 -0500 Subject: [PATCH 03/33] wire uPlot tooltips into the existing tooltip system --- assets/js/scale.js | 4 +--- assets/js/servers.js | 16 +++++++++++++--- assets/js/tooltip.js | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 assets/js/tooltip.js diff --git a/assets/js/scale.js b/assets/js/scale.js index 5281151..47ee868 100644 --- a/assets/js/scale.js +++ b/assets/js/scale.js @@ -1,4 +1,4 @@ -class RelativeScale { +export class RelativeScale { static scale (data, tickCount) { const [min, max] = RelativeScale.calculateBounds(data) @@ -49,5 +49,3 @@ class RelativeScale { } } } - -module.exports = RelativeScale diff --git a/assets/js/servers.js b/assets/js/servers.js index e860741..a50c76d 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -1,8 +1,9 @@ import uPlot from '../lib/uPlot.esm' -import RelativeScale from './scale' +import { RelativeScale } from './scale' import { formatNumber, formatTimestamp, formatDate, formatMinecraftServerAddress, formatMinecraftVersions } from './util' +import { uPlotTooltipPlugin } from './tooltip' import MISSING_FAVICON from '../images/missing_favicon.svg' @@ -78,6 +79,17 @@ export class ServerRegistration { // eslint-disable-next-line new-cap this._plotInstance = new uPlot({ + plugins: [ + uPlotTooltipPlugin((pos, point) => { + if (pos) { + const text = formatNumber(point.y) + ' Players
' + formatTimestamp(point.x * 1000) + + this._app.tooltip.set(pos.left, pos.top, 10, 10, text) + } else { + this._app.tooltip.hide() + } + }) + ], height: 100, width: 400, cursor: { @@ -290,8 +302,6 @@ export class ServerRegistration { } initEventListeners () { - $('#chart_' + this.serverId).bind('plothover', this._app.graphDisplayManager.handlePlotHover) - document.getElementById('favorite-toggle_' + this.serverId).addEventListener('click', () => { this._app.favoritesManager.handleFavoriteButtonClick(this) }, false) diff --git a/assets/js/tooltip.js b/assets/js/tooltip.js new file mode 100644 index 0000000..a4fc78f --- /dev/null +++ b/assets/js/tooltip.js @@ -0,0 +1,32 @@ +export function uPlotTooltipPlugin (onHover) { + let element + + return { + hooks: { + init: u => { + element = u.root.querySelector('.over') + + element.onmouseenter = () => onHover() + element.onmouseleave = () => onHover() + }, + setCursor: u => { + const { left, top, idx } = u.cursor + + if (idx === null) { + onHover() + } else { + const bounds = element.getBoundingClientRect() + + onHover({ + left: bounds.left + left + window.pageXOffset, + top: bounds.top + top + window.pageYOffset + }, { + idx, + x: u.data[0][idx], + y: u.data[1][idx] + }) + } + } + } + } +} From 9987434fea2ca8fa89fc3df28d1a617be7c8dfc1 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 00:42:02 -0500 Subject: [PATCH 04/33] make tooltipPlugin more generic for further use --- assets/js/servers.js | 4 ++-- assets/js/tooltip.js | 6 +----- config.json | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/assets/js/servers.js b/assets/js/servers.js index a50c76d..fba696a 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -80,9 +80,9 @@ export class ServerRegistration { // eslint-disable-next-line new-cap this._plotInstance = new uPlot({ plugins: [ - uPlotTooltipPlugin((pos, point) => { + uPlotTooltipPlugin((pos, id, plot) => { if (pos) { - const text = formatNumber(point.y) + ' Players
' + formatTimestamp(point.x * 1000) + const text = formatNumber(plot.data[1][id]) + ' Players
' + formatTimestamp(plot.data[0][id] * 1000) this._app.tooltip.set(pos.left, pos.top, 10, 10, text) } else { diff --git a/assets/js/tooltip.js b/assets/js/tooltip.js index a4fc78f..431ad9e 100644 --- a/assets/js/tooltip.js +++ b/assets/js/tooltip.js @@ -20,11 +20,7 @@ export function uPlotTooltipPlugin (onHover) { onHover({ left: bounds.left + left + window.pageXOffset, top: bounds.top + top + window.pageYOffset - }, { - idx, - x: u.data[0][idx], - y: u.data[1][idx] - }) + }, idx, u) } } } diff --git a/config.json b/config.json index 157ed92..24465b0 100644 --- a/config.json +++ b/config.json @@ -10,7 +10,7 @@ "connectTimeout": 2500 }, "performance": { - "skipUnfurlSrv": false, + "skipUnfurlSrv": true, "unfurlSrvCacheTtl": 120000 }, "logToDatabase": true, From 19a7ce7d919991c03f85cda2facd5c03e039fbd8 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 02:28:41 -0500 Subject: [PATCH 05/33] wip moving historical graph to uPlot, remove flot.js dependency --- assets/html/index.html | 1 - assets/js/graph.js | 208 ++++++++++++++++++++++++----------------- assets/js/scale.js | 20 ++++ assets/js/socket.js | 1 - lib/app.js | 2 +- 5 files changed, 145 insertions(+), 87 deletions(-) diff --git a/assets/html/index.html b/assets/html/index.html index 08bfa58..5e9162c 100644 --- a/assets/html/index.html +++ b/assets/html/index.html @@ -91,7 +91,6 @@ - diff --git a/assets/js/graph.js b/assets/js/graph.js index 9c88a11..f64fefa 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -1,38 +1,12 @@ +import uPlot from '../lib/uPlot.esm' + +import { RelativeScale } from './scale' + import { formatNumber, formatTimestamp, isMobileBrowser } from './util' +import { uPlotTooltipPlugin } from './tooltip' import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites' -export const HISTORY_GRAPH_OPTIONS = { - series: { - shadowSize: 0 - }, - xaxis: { - font: { - color: '#E3E3E3' - }, - show: false - }, - yaxis: { - show: true, - ticks: 20, - minTickSize: 10, - tickLength: 10, - tickFormatter: formatNumber, - font: { - color: '#E3E3E3' - }, - labelWidth: -5, - min: 0 - }, - grid: { - hoverable: true, - color: '#696969' - }, - legend: { - show: false - } -} - const HIDDEN_SERVERS_STORAGE_KEY = 'minetrack_hidden_servers' const SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites' @@ -43,6 +17,7 @@ export class GraphDisplayManager { constructor (app) { this._app = app this._graphData = [] + this._graphTimestamps = [] this._hasLoadedSettings = false this._initEventListenersOnce = false this._showOnlyFavorites = false @@ -57,15 +32,7 @@ export class GraphDisplayManager { return } - const graphData = this._graphData[serverId] - - // Push the new data from the method call request - graphData.push([timestamp, playerCount]) - - // Trim any outdated entries by filtering the array into a new array - if (graphData.length > this._app.publicConfig.graphMaxLength) { - graphData.shift() - } + // FIXME } loadLocalStorage () { @@ -126,20 +93,17 @@ export class GraphDisplayManager { } } - // Converts the backend data into the schema used by flot.js getVisibleGraphData () { - return Object.keys(this._graphData) - .map(Number) - .map(serverId => this._app.serverRegistry.getServerRegistration(serverId)) - .filter(serverRegistration => serverRegistration !== undefined && serverRegistration.isVisible) - .map(serverRegistration => { - return { - data: this._graphData[serverRegistration.serverId], - yaxis: 1, - label: serverRegistration.data.name, - color: serverRegistration.data.color - } - }) + return this._app.serverRegistry.getServerRegistrations() + .filter(serverRegistration => serverRegistration.isVisible) + .map(serverRegistration => this._graphData[serverRegistration.serverId]) + } + + getPlotSize () { + return { + width: Math.max(window.innerWidth, 800) * 0.9, + height: 400 + } } buildPlotInstance (graphData) { @@ -150,12 +114,107 @@ export class GraphDisplayManager { this.loadLocalStorage() } - this._graphData = graphData + // FIXME: timestamps are not shared! + this._graphTimestamps = graphData[0].map(val => Math.floor(val[0] / 1000)) + this._graphData = Object.values(graphData).map(val => { + return val.map(element => { + // Safely handle null data points, they represent gaps in the graph + return element === null ? null : element[1] + }) + }) - // Explicitly define a height so flot.js can rescale the Y axis - document.getElementById('big-graph').style.height = '400px' + const series = this._app.serverRegistry.getServerRegistrations().map(serverRegistration => { + return { + scale: 'Players', + stroke: serverRegistration.data.color, + width: 2, + value: (_, raw) => formatNumber(raw) + ' Players', + show: serverRegistration.isVisible + } + }) - this._plotInstance = $.plot('#big-graph', this.getVisibleGraphData(), HISTORY_GRAPH_OPTIONS) + const tickCount = 10 + + // eslint-disable-next-line new-cap + this._plotInstance = new uPlot({ + plugins: [ + uPlotTooltipPlugin((pos, id, plot) => { + if (pos) { + // FIXME + let text = '' + formatTimestamp(this._graphTimestamps[id]) + '

' + + for (let i = 1; i < plot.series.length; i++) { + const serverRegistration = this._app.serverRegistry.getServerRegistration(i - 1) + const serverGraphData = this._graphData[serverRegistration.serverId] + + let playerCount + + if (id >= serverGraphData.length) { + playerCount = '-' + } else { + playerCount = formatNumber(serverGraphData[id]) + } + + text += serverRegistration.data.name + ': ' + playerCount + '
' + } + + this._app.tooltip.set(pos.left, pos.top, 10, 10, text) + } else { + this._app.tooltip.hide() + } + }) + ], + ...this.getPlotSize(), + cursor: { + y: false + }, + series: [ + { + }, + ...series + ], + axes: [ + { + font: '14px "Open Sans", sans-serif', + stroke: '#FFF', + grid: { + show: false + }, + space: 60 + }, + { + font: '14px "Open Sans", sans-serif', + stroke: '#FFF', + size: 60, + grid: { + stroke: '#333', + width: 1 + }, + split: () => { + const visibleGraphData = this.getVisibleGraphData() + const [, max, scale] = RelativeScale.scaleMatrix(visibleGraphData, tickCount) + const ticks = RelativeScale.generateTicks(0, max, scale) + return ticks + } + } + ], + scales: { + Players: { + auto: false, + range: () => { + const visibleGraphData = this.getVisibleGraphData() + const [, scaledMax] = RelativeScale.scaleMatrix(visibleGraphData, tickCount) + return [0, scaledMax] + } + } + }, + legend: { + show: false + } + }, [ + this._graphTimestamps, + ...this._graphData + ], document.getElementById('big-graph')) // Show the settings-toggle element document.getElementById('settings-toggle').style.display = 'inline-block' @@ -166,11 +225,12 @@ export class GraphDisplayManager { // This may cause unnessecary localStorage updates, but its a rare and harmless outcome this.updateLocalStorage() - // Fire calls to the provided graph instance - // This allows flot.js to manage redrawing and creates a helper method to reduce code duplication - this._plotInstance.setData(this.getVisibleGraphData()) - this._plotInstance.setupGrid() - this._plotInstance.draw() + // Copy application state into the series data used by uPlot + for (const serverRegistration of this._app.serverRegistry.getServerRegistrations()) { + this._plotInstance.series[serverRegistration.serverId + 1].show = serverRegistration.isVisible + } + + this._plotInstance.redraw() } requestResize () { @@ -189,11 +249,7 @@ export class GraphDisplayManager { } resize = () => { - if (this._plotInstance) { - this._plotInstance.resize() - this._plotInstance.setupGrid() - this._plotInstance.draw() - } + this._plotInstance.setSize(this.getPlotSize()) // undefine value so #clearTimeout is not called // This is safe even if #resize is manually called since it removes the pending work @@ -204,21 +260,6 @@ export class GraphDisplayManager { this._resizeRequestTimeout = undefined } - // Called by flot.js when they hover over a data point. - handlePlotHover = (event, pos, item) => { - if (!item) { - this._app.tooltip.hide() - } else { - let text = formatNumber(item.datapoint[1]) + ' Players
' + formatTimestamp(item.datapoint[0]) - // Prefix text with the series label when possible - if (item.series && item.series.label) { - text = '' + item.series.label + '
' + text - } - - this._app.tooltip.set(item.pageX, item.pageY, 10, 10, text) - } - } - initEventListeners () { if (!this._initEventListenersOnce) { this._initEventListenersOnce = true @@ -231,8 +272,6 @@ export class GraphDisplayManager { }) } - $('#big-graph').bind('plothover', this.handlePlotHover) - // These listeners should be bound each #initEventListeners call since they are for newly created elements document.querySelectorAll('.graph-control').forEach((element) => { element.addEventListener('click', this.handleServerButtonClick, false) @@ -317,6 +356,7 @@ export class GraphDisplayManager { } reset () { + this._graphTimestamps = [] this._graphData = [] this._plotInstance = undefined this._hasLoadedSettings = false diff --git a/assets/js/scale.js b/assets/js/scale.js index 47ee868..4a727d6 100644 --- a/assets/js/scale.js +++ b/assets/js/scale.js @@ -21,6 +21,26 @@ export class RelativeScale { } } + static scaleMatrix (data, tickCount) { + let max = Number.MIN_VALUE + + for (const row of data) { + let testMax = Number.MIN_VALUE + + for (const point of row) { + if (point > testMax) { + testMax = point + } + } + + if (testMax > max) { + max = testMax + } + } + + return RelativeScale.scale([0, max], tickCount) + } + static generateTicks (min, max, step) { const ticks = [] for (let i = min; i <= max; i += step) { diff --git a/assets/js/socket.js b/assets/js/socket.js index 4fb764a..b60645d 100644 --- a/assets/js/socket.js +++ b/assets/js/socket.js @@ -54,7 +54,6 @@ export class SocketManager { // Display the main page component // Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn - // Otherwise flot.js will cause visual alignment bugs this._app.setPageReady(true) // Allow the graphDisplayManager to control whether or not the historical graph is loaded diff --git a/lib/app.js b/lib/app.js index 82c2b0a..36fdc90 100644 --- a/lib/app.js +++ b/lib/app.js @@ -50,7 +50,7 @@ class App { // Send graphData in object wrapper to avoid needing to explicity filter // any header data being appended by #MessageOf since the graph data is fed - // directly into the flot.js graphing system + // directly into the graphing system client.send(MessageOf('historyGraph', { graphData: graphData })) From 19190f8d7901f1f6bb51d6d5610ab92f32147116 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 02:35:11 -0500 Subject: [PATCH 06/33] fix timestamp, graph spacing --- assets/css/main.css | 4 ++++ assets/js/graph.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/assets/css/main.css b/assets/css/main.css index 6a1c874..9a9fb62 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -343,6 +343,10 @@ footer a:hover { color: var(--text-color); } +#big-graph { + padding-right: 60px; +} + #big-graph, #big-graph-controls, #big-graph-checkboxes { width: 90%; } diff --git a/assets/js/graph.js b/assets/js/graph.js index f64fefa..5449aa0 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -141,7 +141,7 @@ export class GraphDisplayManager { uPlotTooltipPlugin((pos, id, plot) => { if (pos) { // FIXME - let text = '' + formatTimestamp(this._graphTimestamps[id]) + '

' + let text = '' + formatTimestamp(this._graphTimestamps[id] * 1000) + '

' for (let i = 1; i < plot.series.length; i++) { const serverRegistration = this._app.serverRegistry.getServerRegistration(i - 1) From ef0c41ee1df4b7843fe0710cd7cb81c402ee2d15 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 03:10:23 -0500 Subject: [PATCH 07/33] remove graph smoothing behavior, add null playerCount support --- assets/js/graph.js | 2 +- assets/js/scale.js | 23 +++++++++++--- assets/js/servers.js | 73 ++++++++++++++++++++++++++++++++------------ lib/ping.js | 9 +++++- lib/servers.js | 33 ++++++-------------- 5 files changed, 89 insertions(+), 51 deletions(-) diff --git a/assets/js/graph.js b/assets/js/graph.js index 5449aa0..72a99ac 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -149,7 +149,7 @@ export class GraphDisplayManager { let playerCount - if (id >= serverGraphData.length) { + if (id >= serverGraphData.length || typeof serverGraphData[id] !== 'number') { playerCount = '-' } else { playerCount = formatNumber(serverGraphData[id]) diff --git a/assets/js/scale.js b/assets/js/scale.js index 4a727d6..4b877e1 100644 --- a/assets/js/scale.js +++ b/assets/js/scale.js @@ -38,6 +38,10 @@ export class RelativeScale { } } + if (max === Number.MAX_VALUE) { + max = 0 + } + return RelativeScale.scale([0, max], tickCount) } @@ -57,14 +61,23 @@ export class RelativeScale { let max = Number.MIN_VALUE for (const point of data) { - if (point > max) { - max = point - } - if (point < min) { - min = point + if (typeof point === 'number') { + if (point > max) { + max = point + } + if (point < min) { + min = point + } } } + if (min === Number.MAX_VALUE) { + min = 0 + } + if (max === Number.MIN_VALUE) { + max = 0 + } + return [min, max] } } diff --git a/assets/js/servers.js b/assets/js/servers.js index fba696a..68113f7 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -63,7 +63,7 @@ export class ServerRegistration { this._app = app this.serverId = serverId this.data = data - this._graphData = [] + this._graphData = [[], []] this._failedSequentialPings = 0 } @@ -82,7 +82,16 @@ export class ServerRegistration { plugins: [ uPlotTooltipPlugin((pos, id, plot) => { if (pos) { - const text = formatNumber(plot.data[1][id]) + ' Players
' + formatTimestamp(plot.data[0][id] * 1000) + const playerCount = plot.data[1][id] + + if (typeof playerCount !== 'number') { + this._app.tooltip.hide() + + return + } + + // FIXME: update timestamp schema + const text = formatNumber(playerCount) + ' Players
' + formatTimestamp(plot.data[0][id] * 1000) this._app.tooltip.set(pos.left, pos.top, 10, 10, text) } else { @@ -147,23 +156,9 @@ export class ServerRegistration { } handlePing (payload, timestamp) { - if (typeof payload.playerCount !== 'undefined') { + if (typeof payload.playerCount === 'number') { this.playerCount = payload.playerCount - // Only update graph for successful pings - // This intentionally pauses the server graph when pings begin to fail - 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 - for (const series of this._graphData) { - if (series.length > this._app.publicConfig.serverGraphMaxLength) { - series.shift() - } - } - - this.redraw() - // Reset failed ping counter to ensure the next connection error // doesn't instantly retrigger a layout change this._failedSequentialPings = 0 @@ -174,6 +169,34 @@ export class ServerRegistration { this.playerCount = 0 } } + + // Use payload.playerCount so nulls WILL be pushed into the graphing data + this._graphData[0].push(Math.floor(timestamp / 1000)) + this._graphData[1].push(payload.playerCount) + + // Trim graphData to within the max length by shifting out the leading elements + for (const series of this._graphData) { + if (series.length > this._app.publicConfig.serverGraphMaxLength) { + series.shift() + } + } + + this.redraw() + + if (typeof payload.playerCount !== 'undefined') { + this.playerCount = payload.playerCount || 0 + + // Use payload.playerCount so nulls WILL be pushed into the graphing data + this._graphData[0].push(Math.floor(timestamp / 1000)) + this._graphData[1].push(payload.playerCount) + + // Trim graphData to within the max length by shifting out the leading elements + for (const series of this._graphData) { + if (series.length > this._app.publicConfig.serverGraphMaxLength) { + series.shift() + } + } + } } redraw () { @@ -237,13 +260,23 @@ export class ServerRegistration { const playerCountLabelElement = document.getElementById('player-count_' + this.serverId) const errorElement = document.getElementById('error_' + this.serverId) - if (ping.error) { + if (ping.error || typeof ping.playerCount !== 'number') { // Hide any visible player-count and show the error element playerCountLabelElement.style.display = 'none' errorElement.style.display = 'block' - errorElement.innerText = ping.error.message - } else if (typeof ping.playerCount !== 'undefined') { + let errorMessage + + if (ping.error) { + errorMessage = ping.error.message + } else if (typeof ping.playerCount !== 'number') { + // If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object + // In this case playerCount will safely be null, so provide a generic error message instead + errorMessage = 'Failed to ping' + } + + errorElement.innerText = errorMessage + } else if (typeof ping.playerCount === 'number') { // Ensure the player-count element is visible and hide the error element playerCountLabelElement.style.display = 'block' errorElement.style.display = 'none' diff --git a/lib/ping.js b/lib/ping.js index 82d7e33..58ef9c8 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -92,8 +92,9 @@ class PingController { const result = results[serverRegistration.serverId] // Log to database if enabled + // Use null to represent a failed ping if (config.logToDatabase) { - const playerCount = result.resp ? result.resp.players.online : 0 + const playerCount = result.resp ? result.resp.players.online : null this._app.database.insertPing(serverRegistration.data.ip, timestamp, playerCount) } @@ -130,6 +131,12 @@ class PingController { const version = serverRegistration.getNextProtocolVersion() ping(serverRegistration, config.rates.connectTimeout, (err, resp) => { + if (Math.random() < 0.1) { + err = { + message: 'random fail' + } + resp = undefined + } if (err) { logger.log('error', 'Failed to ping %s: %s', serverRegistration.data.ip, err.message) } diff --git a/lib/servers.js b/lib/servers.js index 4f06507..a0ee646 100644 --- a/lib/servers.js +++ b/lib/servers.js @@ -21,7 +21,8 @@ class ServerRegistration { } handlePing (timestamp, resp, err, version) { - const playerCount = resp ? resp.players.online : 0 + // Use null to represent a failed ping + const playerCount = resp ? resp.players.online : null // Store into in-memory ping data this._pingHistory.push(playerCount) @@ -37,7 +38,7 @@ class ServerRegistration { let updateHistoryGraph = false if (config.logToDatabase) { - if (this.addGraphPoint(resp !== undefined, playerCount, timestamp)) { + if (this.addGraphPoint(playerCount, timestamp)) { updateHistoryGraph = true } } @@ -142,37 +143,21 @@ class ServerRegistration { for (const point of points) { // 0 is the index of the timestamp if (point[0] - lastTimestamp >= 60 * 1000) { - // This check tries to smooth out randomly dropped pings - // By default only filter pings that are online (playerCount > 0) - // This will keep looking forward until it finds a ping that is online - // If it can't find one within a reasonable timeframe, it will select a failed ping - if (point[0] - lastTimestamp >= 120 * 1000 || point[1] > 0) { - minutePoints.push(point) - lastTimestamp = point[0] - } + lastTimestamp = point[0] + + // FIXME: update schema, remove timestamp + minutePoints.push(point) } } if (minutePoints.length > 0) { this.graphData = minutePoints - - // Select the last entry to use for lastGraphDataPush - this._lastGraphDataPush = minutePoints[minutePoints.length - 1][0] } } - addGraphPoint (isSuccess, playerCount, timestamp) { - // If the ping failed, then to avoid destroying the graph, ignore it - // However if it's been too long since the last successful ping, push it anyways - if (this._lastGraphDataPush) { - const timeSince = timestamp - this._lastGraphDataPush - if ((isSuccess && timeSince < 60 * 1000) || (!isSuccess && timeSince < 70 * 1000)) { - return false - } - } - + addGraphPoint (playerCount, timestamp) { + // FIXME: update schema, remove timestamp this.graphData.push([timestamp, playerCount]) - this._lastGraphDataPush = timestamp // Trim old graphPoints according to #getMaxGraphDataLength if (this.graphData.length > TimeTracker.getMaxGraphDataLength()) { From 84004f22be7b9ea15d14568c309c1dce3daa7480 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 04:12:46 -0500 Subject: [PATCH 08/33] merge graph timestamps into single array, use seconds --- assets/js/graph.js | 24 +++++++++++------------- assets/js/servers.js | 28 ++++++---------------------- assets/js/socket.js | 30 +++++++++--------------------- assets/js/util.js | 8 ++++---- lib/app.js | 15 ++++++++------- lib/database.js | 4 ++-- lib/ping.js | 13 +++++++++++-- lib/servers.js | 38 +++++++++++--------------------------- lib/time.js | 4 ++-- 9 files changed, 64 insertions(+), 100 deletions(-) diff --git a/assets/js/graph.js b/assets/js/graph.js index 72a99ac..b3f48c6 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -2,7 +2,7 @@ import uPlot from '../lib/uPlot.esm' import { RelativeScale } from './scale' -import { formatNumber, formatTimestamp, isMobileBrowser } from './util' +import { formatNumber, formatTimestampSeconds, isMobileBrowser } from './util' import { uPlotTooltipPlugin } from './tooltip' import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites' @@ -23,7 +23,7 @@ export class GraphDisplayManager { this._showOnlyFavorites = false } - addGraphPoint (serverId, timestamp, playerCount) { + addGraphPoint (timestamp, playerCounts) { if (!this._hasLoadedSettings) { // _hasLoadedSettings is controlled by #setGraphData // It will only be true once the context has been loaded and initial payload received @@ -32,7 +32,11 @@ export class GraphDisplayManager { return } - // FIXME + this._graphTimestamps.push(timestamp) + + for (let i = 0; i < playerCounts.length; i++) { + this._graphData[i].push(playerCounts[i]) + } } loadLocalStorage () { @@ -106,7 +110,7 @@ export class GraphDisplayManager { } } - buildPlotInstance (graphData) { + buildPlotInstance (timestamps, data) { // Lazy load settings from localStorage, if any and if enabled if (!this._hasLoadedSettings) { this._hasLoadedSettings = true @@ -114,14 +118,8 @@ export class GraphDisplayManager { this.loadLocalStorage() } - // FIXME: timestamps are not shared! - this._graphTimestamps = graphData[0].map(val => Math.floor(val[0] / 1000)) - this._graphData = Object.values(graphData).map(val => { - return val.map(element => { - // Safely handle null data points, they represent gaps in the graph - return element === null ? null : element[1] - }) - }) + this._graphTimestamps = timestamps + this._graphData = data const series = this._app.serverRegistry.getServerRegistrations().map(serverRegistration => { return { @@ -141,7 +139,7 @@ export class GraphDisplayManager { uPlotTooltipPlugin((pos, id, plot) => { if (pos) { // FIXME - let text = '' + formatTimestamp(this._graphTimestamps[id] * 1000) + '

' + let text = '' + formatTimestampSeconds(this._graphTimestamps[id]) + '

' for (let i = 1; i < plot.series.length; i++) { const serverRegistration = this._app.serverRegistry.getServerRegistration(i - 1) diff --git a/assets/js/servers.js b/assets/js/servers.js index 68113f7..bca0d40 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -2,7 +2,7 @@ import uPlot from '../lib/uPlot.esm' import { RelativeScale } from './scale' -import { formatNumber, formatTimestamp, formatDate, formatMinecraftServerAddress, formatMinecraftVersions } from './util' +import { formatNumber, formatTimestampSeconds, formatDate, formatMinecraftServerAddress, formatMinecraftVersions } from './util' import { uPlotTooltipPlugin } from './tooltip' import MISSING_FAVICON from '../images/missing_favicon.svg' @@ -69,7 +69,7 @@ export class ServerRegistration { addGraphPoints (points, timestampPoints) { this._graphData = [ - timestampPoints.map(val => Math.floor(val / 1000)), + timestampPoints.slice(), points ] } @@ -90,8 +90,7 @@ export class ServerRegistration { return } - // FIXME: update timestamp schema - const text = formatNumber(playerCount) + ' Players
' + formatTimestamp(plot.data[0][id] * 1000) + const text = formatNumber(playerCount) + ' Players
' + formatTimestampSeconds(plot.data[0][id]) this._app.tooltip.set(pos.left, pos.top, 10, 10, text) } else { @@ -171,7 +170,7 @@ export class ServerRegistration { } // Use payload.playerCount so nulls WILL be pushed into the graphing data - this._graphData[0].push(Math.floor(timestamp / 1000)) + this._graphData[0].push(timestamp) this._graphData[1].push(payload.playerCount) // Trim graphData to within the max length by shifting out the leading elements @@ -182,21 +181,6 @@ export class ServerRegistration { } this.redraw() - - if (typeof payload.playerCount !== 'undefined') { - this.playerCount = payload.playerCount || 0 - - // Use payload.playerCount so nulls WILL be pushed into the graphing data - this._graphData[0].push(Math.floor(timestamp / 1000)) - this._graphData[1].push(payload.playerCount) - - // Trim graphData to within the max length by shifting out the leading elements - for (const series of this._graphData) { - if (series.length > this._app.publicConfig.serverGraphMaxLength) { - series.shift() - } - } - } } redraw () { @@ -219,7 +203,7 @@ export class ServerRegistration { const peakValueElement = document.getElementById('peak-value_' + this.serverId) peakValueElement.innerText = formatNumber(data.playerCount) - peakLabelElement.title = 'At ' + formatTimestamp(data.timestamp) + peakLabelElement.title = 'At ' + formatTimestampSeconds(data.timestamp) this.lastPeakData = data } @@ -245,7 +229,7 @@ export class ServerRegistration { // Safely handle legacy recordData that may not include the timestamp payload if (recordData.timestamp > 0) { recordValueElement.innerHTML = formatNumber(recordData.playerCount) + ' (' + formatDate(recordData.timestamp) + ')' - recordLabelElement.title = 'At ' + formatDate(recordData.timestamp) + ' ' + formatTimestamp(recordData.timestamp) + recordLabelElement.title = 'At ' + formatDate(recordData.timestamp) + ' ' + formatTimestampSeconds(recordData.timestamp) } else { recordValueElement.innerText = formatNumber(recordData.playerCount) } diff --git a/assets/js/socket.js b/assets/js/socket.js index b60645d..24e1049 100644 --- a/assets/js/socket.js +++ b/assets/js/socket.js @@ -81,8 +81,6 @@ export class SocketManager { break case 'updateServers': { - let requestGraphRedraw = false - for (let serverId = 0; serverId < payload.updates.length; serverId++) { // The backend may send "update" events prior to receiving all "add" events // A server has only been added once it's ServerRegistration is defined @@ -92,28 +90,18 @@ export class SocketManager { if (serverRegistration) { serverRegistration.handlePing(serverUpdate, payload.timestamp) - serverRegistration.updateServerStatus(serverUpdate, this._app.publicConfig.minecraftVersions) } - - // Use update payloads to conditionally append data to graph - // Skip any incoming updates if the graph is disabled - if (serverUpdate.updateHistoryGraph && this._app.graphDisplayManager.isVisible) { - // Update may not be successful, safely append 0 points - const playerCount = serverUpdate.playerCount || 0 - - this._app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, payload.timestamp, playerCount) - - // Only redraw the graph if not mutating hidden data - if (serverRegistration.isVisible) { - requestGraphRedraw = true - } - } } - // Run redraw tasks after handling bulk updates - if (requestGraphRedraw) { - this._app.graphDisplayManager.redraw() + // Bulk add playerCounts into graph during #updateHistoryGraph + if (payload.updateHistoryGraph) { + this._app.graphDisplayManager.addGraphPoint(payload.timestamp, Object.values(payload.updates).map(update => update.playerCount)) + + // Run redraw tasks after handling bulk updates + if (this._app.graphDisplayManager.isVisible) { + this._app.graphDisplayManager.redraw() + } } this._app.percentageBar.redraw() @@ -132,7 +120,7 @@ export class SocketManager { // This is used for the manual graph load request behavior this._app.graphDisplayManager.isVisible = true - this._app.graphDisplayManager.buildPlotInstance(payload.graphData) + this._app.graphDisplayManager.buildPlotInstance(payload.timestamps, payload.graphData) // Build checkbox elements for graph controls let lastRowCounter = 0 diff --git a/assets/js/util.js b/assets/js/util.js index 6e55e42..31f89b3 100644 --- a/assets/js/util.js +++ b/assets/js/util.js @@ -99,15 +99,15 @@ export function formatMinecraftVersions (versions, knownVersions) { }).join(', ') } -export function formatTimestamp (millis) { +export function formatTimestampSeconds (secs) { const date = new Date(0) - date.setUTCSeconds(millis / 1000) + date.setUTCSeconds(secs) return date.toLocaleTimeString() } -export function formatDate (millis) { +export function formatDate (secs) { const date = new Date(0) - date.setUTCSeconds(millis / 1000) + date.setUTCSeconds(secs) return date.toLocaleDateString() } diff --git a/lib/app.js b/lib/app.js index 36fdc90..9ca1ce4 100644 --- a/lib/app.js +++ b/lib/app.js @@ -41,18 +41,19 @@ class App { if (config.logToDatabase) { client.on('message', (message) => { if (message === 'requestHistoryGraph') { - // Send historical graphData built from all serverRegistrations - const graphData = {} + // FIXME: update schema, remove timestamp + // Since all history graph updates share timestamps, pull the timestamps from a single ServerRegistration + const timestamps = this.serverRegistrations[0].graphData.map(point => Math.floor(point[0] / 1000)) - this.serverRegistrations.forEach((serverRegistration) => { - graphData[serverRegistration.serverId] = serverRegistration.graphData - }) + // Send historical graphData built from all serverRegistrations + const graphData = this.serverRegistrations.map(serverRegistration => serverRegistration.graphData.map(point => point[1])) // Send graphData in object wrapper to avoid needing to explicity filter // any header data being appended by #MessageOf since the graph data is fed // directly into the graphing system client.send(MessageOf('historyGraph', { - graphData: graphData + timestamps, + graphData })) } }) @@ -77,7 +78,7 @@ class App { } })(), mojangServices: this.mojangUpdater.getLastUpdate(), - timestampPoints: this.timeTracker.getPoints(), + timestampPoints: this.timeTracker.getPointsSeconds(), servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory()) } diff --git a/lib/database.js b/lib/database.js index 60f795a..c27e30d 100644 --- a/lib/database.js +++ b/lib/database.js @@ -63,8 +63,8 @@ class Database { // When complete increment completeTasks to know when complete this.getRecord(serverRegistration.data.ip, (playerCount, timestamp) => { serverRegistration.recordData = { - playerCount: playerCount, - timestamp: timestamp + playerCount, + timestamp: Math.floor(timestamp / 1000) } // Check if completedTasks hit the finish value diff --git a/lib/ping.js b/lib/ping.js index 58ef9c8..dcdd549 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -85,6 +85,14 @@ class PingController { pingAll = () => { const timestamp = this._app.timeTracker.newTimestamp() + // Flag each group as history graph additions each minute + // This is sent to the frontend for graph updates + const updateHistoryGraph = config.logToDatabase && (!this._lastHistoryGraphUpdate || timestamp - this._lastHistoryGraphUpdate >= 60 * 1000) + + if (updateHistoryGraph) { + this._lastHistoryGraphUpdate = timestamp + } + this.startPingTasks(results => { const updates = [] @@ -102,7 +110,7 @@ class PingController { // Generate a combined update payload // This includes any modified fields and flags used by the frontend // This will not be cached and can contain live metadata - const update = serverRegistration.handlePing(timestamp, result.resp, result.err, result.version) + const update = serverRegistration.handlePing(timestamp, result.resp, result.err, result.version, updateHistoryGraph) updates[serverRegistration.serverId] = update } @@ -110,7 +118,8 @@ class PingController { // Send object since updates uses serverIds as keys // Send a single timestamp entry since it is shared this._app.server.broadcast(MessageOf('updateServers', { - timestamp, + timestamp: Math.floor(timestamp / 1000), + updateHistoryGraph, updates })) }) diff --git a/lib/servers.js b/lib/servers.js index a0ee646..6ef2839 100644 --- a/lib/servers.js +++ b/lib/servers.js @@ -20,7 +20,7 @@ class ServerRegistration { this._pingHistory = [] } - handlePing (timestamp, resp, err, version) { + handlePing (timestamp, resp, err, version, updateHistoryGraph) { // Use null to represent a failed ping const playerCount = resp ? resp.players.online : null @@ -35,19 +35,21 @@ class ServerRegistration { // Only notify the frontend to append to the historical graph // if both the graphing behavior is enabled and the backend agrees // that the ping is eligible for addition - let updateHistoryGraph = false + if (updateHistoryGraph) { + // FIXME: update schema, remove timestamp + this.graphData.push([timestamp, playerCount]) - if (config.logToDatabase) { - if (this.addGraphPoint(playerCount, timestamp)) { - updateHistoryGraph = true + // Trim old graphPoints according to #getMaxGraphDataLength + if (this.graphData.length > TimeTracker.getMaxGraphDataLength()) { + this.graphData.shift() } } // Delegate out update payload generation - return this.getUpdate(timestamp, resp, err, version, updateHistoryGraph) + return this.getUpdate(timestamp, resp, err, version) } - getUpdate (timestamp, resp, err, version, updateHistoryGraph) { + getUpdate (timestamp, resp, err, version) { const update = {} if (resp) { @@ -59,7 +61,7 @@ class ServerRegistration { if (config.logToDatabase && (!this.recordData || resp.players.online > this.recordData.playerCount)) { this.recordData = { playerCount: resp.players.online, - timestamp: timestamp + timestamp: Math.floor(timestamp / 1000) } // Append an updated recordData @@ -80,12 +82,6 @@ class ServerRegistration { if (this.findNewGraphPeak()) { update.graphPeakData = this.getGraphPeak() } - - // Handled inside logToDatabase to validate logic from #getUpdate call - // Only append when true since an undefined value == false - if (updateHistoryGraph) { - update.updateHistoryGraph = true - } } } else if (err) { // Append a filtered copy of err @@ -155,18 +151,6 @@ class ServerRegistration { } } - addGraphPoint (playerCount, timestamp) { - // FIXME: update schema, remove timestamp - this.graphData.push([timestamp, playerCount]) - - // Trim old graphPoints according to #getMaxGraphDataLength - if (this.graphData.length > TimeTracker.getMaxGraphDataLength()) { - this.graphData.shift() - } - - return true - } - findNewGraphPeak () { let index = -1 for (let i = 0; i < this.graphData.length; i++) { @@ -192,7 +176,7 @@ class ServerRegistration { const graphPeak = this.graphData[this._graphPeakIndex] return { playerCount: graphPeak[1], - timestamp: graphPeak[0] + timestamp: Math.floor(graphPeak[0] / 1000) } } diff --git a/lib/time.js b/lib/time.js index 966272a..3556dfe 100644 --- a/lib/time.js +++ b/lib/time.js @@ -18,8 +18,8 @@ class TimeTracker { return timestamp } - getPoints () { - return this._points + getPointsSeconds () { + return this._points.map(value => Math.floor(value / 1000)) } static getMaxServerGraphDataLength () { From e8b530bbb843c696f12ae4b3d6e43bc6d98a1e4d Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 04:15:23 -0500 Subject: [PATCH 09/33] remove random fail test --- lib/ping.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/ping.js b/lib/ping.js index dcdd549..1ebbedf 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -140,12 +140,6 @@ class PingController { const version = serverRegistration.getNextProtocolVersion() ping(serverRegistration, config.rates.connectTimeout, (err, resp) => { - if (Math.random() < 0.1) { - err = { - message: 'random fail' - } - resp = undefined - } if (err) { logger.log('error', 'Failed to ping %s: %s', serverRegistration.data.ip, err.message) } From 59ec7d151ff4b1e7a66c5ca6401ceeb47138bb27 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 04:25:30 -0500 Subject: [PATCH 10/33] inline #isArrayUtil, remove jQuery --- .eslintrc.json | 3 +-- assets/html/index.html | 2 -- assets/js/sort.js | 24 +++++++++++++++++++----- assets/js/util.js | 15 --------------- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 0ec52d3..4af0191 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,8 +1,7 @@ { "env": { "browser": true, - "es6": true, - "jquery": true + "es6": true }, "extends": [ "standard" diff --git a/assets/html/index.html b/assets/html/index.html index 5e9162c..70e0abb 100644 --- a/assets/html/index.html +++ b/assets/html/index.html @@ -90,8 +90,6 @@ Powered by open source software - make it your own! - - diff --git a/assets/js/sort.js b/assets/js/sort.js index ad6a25f..7c9209c 100644 --- a/assets/js/sort.js +++ b/assets/js/sort.js @@ -1,5 +1,3 @@ -import { isArrayEqual } from './util' - const SORT_OPTIONS = [ { getName: () => 'Players', @@ -164,8 +162,21 @@ export class SortController { // This avoids DOM updates and graphs being redrawn const sortedServerIds = sortedServers.map(server => server.serverId) - if (isArrayEqual(sortedServerIds, this._lastSortedServers)) { - return + if (this._lastSortedServers) { + let allMatch = true + + // Test if the arrays have actually changed + // No need to length check, they are the same source data each time + for (let i = 0; i < sortedServerIds.length; i++) { + if (sortedServerIds[i] !== this._lastSortedServers[i]) { + allMatch = false + break + } + } + + if (allMatch) { + return + } } this._lastSortedServers = sortedServerIds @@ -176,7 +187,10 @@ export class SortController { // Update the DOM structure sortedServers.forEach(function (serverRegistration) { - $('#container_' + serverRegistration.serverId).appendTo('#server-list') + const parentElement = document.getElementById('server-list') + const serverElement = document.getElementById('container_' + serverRegistration.serverId) + + parentElement.appendChild(serverElement) // Set the ServerRegistration's rankIndex to its indexOf the normal sort serverRegistration.updateServerRankIndex(rankIndexSort.indexOf(serverRegistration)) diff --git a/assets/js/util.js b/assets/js/util.js index 31f89b3..be1dc8f 100644 --- a/assets/js/util.js +++ b/assets/js/util.js @@ -120,21 +120,6 @@ export function formatNumber (x) { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') } -export function isArrayEqual (a, b) { - if (typeof a === 'undefined' || typeof a !== typeof b) { - return false - } - if (a.length !== b.length) { - return false - } - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - return false - } - } - return true -} - // From http://detectmobilebrowsers.com/ export function isMobileBrowser () { var check = false; From c2f6d04e72571c38e9e964b28028a76736722471 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 18:12:29 -0500 Subject: [PATCH 11/33] share a single timestamp array between all graphData objects --- assets/css/main.css | 2 +- assets/js/graph.js | 7 ++++++- assets/js/scale.js | 2 +- assets/js/servers.js | 2 +- lib/app.js | 10 +++------- lib/database.js | 46 ++++++++++++++++++++++++++++++++++++-------- lib/ping.js | 10 +--------- lib/servers.js | 34 ++++++++++++++------------------ lib/time.js | 37 ++++++++++++++++++++++++++++++++--- main.js | 2 +- 10 files changed, 100 insertions(+), 52 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index 9a9fb62..7a66d37 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -344,7 +344,7 @@ footer a:hover { } #big-graph { - padding-right: 60px; + padding-right: 65px; } #big-graph, #big-graph-controls, #big-graph-checkboxes { diff --git a/assets/js/graph.js b/assets/js/graph.js index b3f48c6..13343c9 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -37,6 +37,11 @@ export class GraphDisplayManager { for (let i = 0; i < playerCounts.length; i++) { this._graphData[i].push(playerCounts[i]) } + + this._plotInstance.setData([ + this._graphTimestamps, + ...this._graphData + ]) } loadLocalStorage () { @@ -183,7 +188,7 @@ export class GraphDisplayManager { { font: '14px "Open Sans", sans-serif', stroke: '#FFF', - size: 60, + size: 65, grid: { stroke: '#333', width: 1 diff --git a/assets/js/scale.js b/assets/js/scale.js index 4b877e1..7a82368 100644 --- a/assets/js/scale.js +++ b/assets/js/scale.js @@ -12,7 +12,7 @@ export class RelativeScale { const ticks = (scaledMax - scaledMin) / scale - if (ticks + 1 <= tickCount) { + if (ticks < tickCount + 1) { return [scaledMin, scaledMax, scale] } else { // Too many steps between min/max, increase factor and try again diff --git a/assets/js/servers.js b/assets/js/servers.js index bca0d40..b39623b 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -75,7 +75,7 @@ export class ServerRegistration { } buildPlotInstance () { - const tickCount = 5 + const tickCount = 4 // eslint-disable-next-line new-cap this._plotInstance = new uPlot({ diff --git a/lib/app.js b/lib/app.js index 9ca1ce4..de640cd 100644 --- a/lib/app.js +++ b/lib/app.js @@ -41,18 +41,14 @@ class App { if (config.logToDatabase) { client.on('message', (message) => { if (message === 'requestHistoryGraph') { - // FIXME: update schema, remove timestamp - // Since all history graph updates share timestamps, pull the timestamps from a single ServerRegistration - const timestamps = this.serverRegistrations[0].graphData.map(point => Math.floor(point[0] / 1000)) - // Send historical graphData built from all serverRegistrations - const graphData = this.serverRegistrations.map(serverRegistration => serverRegistration.graphData.map(point => point[1])) + const graphData = this.serverRegistrations.map(serverRegistration => serverRegistration.graphData) // Send graphData in object wrapper to avoid needing to explicity filter // any header data being appended by #MessageOf since the graph data is fed // directly into the graphing system client.send(MessageOf('historyGraph', { - timestamps, + timestamps: this.timeTracker.getHistoricalPointsSeconds(), graphData })) } @@ -78,7 +74,7 @@ class App { } })(), mojangServices: this.mojangUpdater.getLastUpdate(), - timestampPoints: this.timeTracker.getPointsSeconds(), + timestampPoints: this.timeTracker.getServerPointsSeconds(), servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory()) } diff --git a/lib/database.js b/lib/database.js index c27e30d..e3b3b49 100644 --- a/lib/database.js +++ b/lib/database.js @@ -20,33 +20,63 @@ class Database { const startTime = endTime - graphDuration this.getRecentPings(startTime, endTime, pingData => { - const graphPointsByIp = [] + const relativeGraphData = [] for (const row of pingData) { // Load into temporary array // This will be culled prior to being pushed to the serverRegistration - let graphPoints = graphPointsByIp[row.ip] - if (!graphPoints) { - graphPoints = graphPointsByIp[row.ip] = [] + let graphData = relativeGraphData[row.ip] + if (!graphData) { + relativeGraphData[row.ip] = graphData = [[], []] } - graphPoints.push([row.timestamp, row.playerCount]) + // DANGER! + // This will pull the timestamp from each row into memory + // This is built under the assumption that each round of pings shares the same timestamp + // This enables all timestamp arrays to have consistent point selection and graph correctly + graphData[0].push(row.timestamp) + graphData[1].push(row.playerCount) } - Object.keys(graphPointsByIp).forEach(ip => { + Object.keys(relativeGraphData).forEach(ip => { // Match IPs to serverRegistration object for (const serverRegistration of this._app.serverRegistrations) { if (serverRegistration.data.ip === ip) { - const graphPoints = graphPointsByIp[ip] + const graphData = relativeGraphData[ip] // Push the data into the instance and cull if needed - serverRegistration.loadGraphPoints(graphPoints) + serverRegistration.loadGraphPoints(startTime, graphData[0], graphData[1]) break } } }) + // Since all timestamps are shared, use the array from the first ServerRegistration + // This is very dangerous and can break if data is out of sync + if (Object.keys(relativeGraphData).length > 0) { + const serverIp = Object.keys(relativeGraphData)[0] + const timestamps = relativeGraphData[serverIp][0] + + // This is a copy of ServerRegistration#loadGraphPoints + // relativeGraphData contains original timestamp data and needs to be filtered into minutes + const sharedTimestamps = [] + + let lastTimestamp = startTime + + for (let i = 0; i < timestamps.length; i++) { + const timestamp = timestamps[i] + + if (timestamp - lastTimestamp >= 60 * 1000) { + lastTimestamp = timestamp + + sharedTimestamps.push(timestamp) + } + } + + this._app.timeTracker.loadHistoricalTimestamps(sharedTimestamps) + } + callback() }) } diff --git a/lib/ping.js b/lib/ping.js index 1ebbedf..00fca63 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -83,15 +83,7 @@ class PingController { } pingAll = () => { - const timestamp = this._app.timeTracker.newTimestamp() - - // Flag each group as history graph additions each minute - // This is sent to the frontend for graph updates - const updateHistoryGraph = config.logToDatabase && (!this._lastHistoryGraphUpdate || timestamp - this._lastHistoryGraphUpdate >= 60 * 1000) - - if (updateHistoryGraph) { - this._lastHistoryGraphUpdate = timestamp - } + const { timestamp, updateHistoryGraph } = this._app.timeTracker.newPingTimestamp() this.startPingTasks(results => { const updates = [] diff --git a/lib/servers.js b/lib/servers.js index 6ef2839..412983e 100644 --- a/lib/servers.js +++ b/lib/servers.js @@ -14,7 +14,8 @@ class ServerRegistration { recordData graphData = [] - constructor (serverId, data) { + constructor (app, serverId, data) { + this._app = app this.serverId = serverId this.data = data this._pingHistory = [] @@ -36,8 +37,7 @@ class ServerRegistration { // if both the graphing behavior is enabled and the backend agrees // that the ping is eligible for addition if (updateHistoryGraph) { - // FIXME: update schema, remove timestamp - this.graphData.push([timestamp, playerCount]) + this.graphData.push(playerCount) // Trim old graphPoints according to #getMaxGraphDataLength if (this.graphData.length > TimeTracker.getMaxGraphDataLength()) { @@ -131,31 +131,26 @@ class ServerRegistration { } } - loadGraphPoints (points) { + loadGraphPoints (startTime, timestamps, points) { // Filter pings so each result is a minute apart - const minutePoints = [] - let lastTimestamp = 0 + let lastTimestamp = startTime - for (const point of points) { - // 0 is the index of the timestamp - if (point[0] - lastTimestamp >= 60 * 1000) { - lastTimestamp = point[0] + for (let i = 0; i < timestamps.length; i++) { + const timestamp = timestamps[i] - // FIXME: update schema, remove timestamp - minutePoints.push(point) + if (timestamp - lastTimestamp >= 60 * 1000) { + lastTimestamp = timestamp + + this.graphData.push(points[i]) } } - - if (minutePoints.length > 0) { - this.graphData = minutePoints - } } findNewGraphPeak () { let index = -1 for (let i = 0; i < this.graphData.length; i++) { const point = this.graphData[i] - if (index === -1 || point[1] > this.graphData[index][1]) { + if (index === -1 || point > this.graphData[index]) { index = i } } @@ -173,10 +168,9 @@ class ServerRegistration { if (this._graphPeakIndex === undefined) { return } - const graphPeak = this.graphData[this._graphPeakIndex] return { - playerCount: graphPeak[1], - timestamp: Math.floor(graphPeak[0] / 1000) + playerCount: this.graphData[this._graphPeakIndex], + timestamp: this._app.timeTracker.getHistoricalPointSeconds(this._graphPeakIndex) } } diff --git a/lib/time.js b/lib/time.js index 3556dfe..1710644 100644 --- a/lib/time.js +++ b/lib/time.js @@ -4,9 +4,10 @@ class TimeTracker { constructor (app) { this._app = app this._points = [] + this._historicalTimestamps = [] } - newTimestamp () { + newPingTimestamp () { const timestamp = new Date().getTime() this._points.push(timestamp) @@ -15,10 +16,40 @@ class TimeTracker { this._points.shift() } - return timestamp + // Flag each group as history graph additions each minute + // This is sent to the frontend for graph updates + const updateHistoryGraph = config.logToDatabase && (!this._lastHistoryGraphUpdate || timestamp - this._lastHistoryGraphUpdate >= 60 * 1000) + + if (updateHistoryGraph) { + this._lastHistoryGraphUpdate = timestamp + + // Push into timestamps array to update backend state + this._historicalTimestamps.push(timestamp) + + if (this._historicalTimestamps.length > TimeTracker.getMaxGraphDataLength()) { + this._historicalTimestamps.shift() + } + } + + return { + timestamp, + updateHistoryGraph + } } - getPointsSeconds () { + loadHistoricalTimestamps (timestamps) { + this._historicalTimestamps = timestamps + } + + getHistoricalPointsSeconds () { + return this._historicalTimestamps.map(value => Math.floor(value / 1000)) + } + + getHistoricalPointSeconds (index) { + return Math.floor(this._historicalTimestamps[index] / 1000) + } + + getServerPointsSeconds () { return this._points.map(value => Math.floor(value / 1000)) } diff --git a/main.js b/main.js index e0518f7..fec55ae 100644 --- a/main.js +++ b/main.js @@ -22,7 +22,7 @@ servers.forEach((server, serverId) => { } // Init a ServerRegistration instance of each entry in servers.json - app.serverRegistrations.push(new ServerRegistration(serverId, server)) + app.serverRegistrations.push(new ServerRegistration(app, serverId, server)) }) if (!config.serverGraphDuration) { From 71368511236d592cb98baa0a4ac2ce33db61793e Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 18:16:41 -0500 Subject: [PATCH 12/33] centralize shift behavior into TimeTracker func --- lib/servers.js | 14 ++------------ lib/time.js | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/lib/servers.js b/lib/servers.js index 412983e..58e6b8d 100644 --- a/lib/servers.js +++ b/lib/servers.js @@ -26,23 +26,13 @@ class ServerRegistration { const playerCount = resp ? resp.players.online : null // Store into in-memory ping data - this._pingHistory.push(playerCount) - - // Trim pingHistory to avoid memory leaks - if (this._pingHistory.length > TimeTracker.getMaxServerGraphDataLength()) { - this._pingHistory.shift() - } + TimeTracker.pushAndShift(this._pingHistory, playerCount, TimeTracker.getMaxServerGraphDataLength()) // Only notify the frontend to append to the historical graph // if both the graphing behavior is enabled and the backend agrees // that the ping is eligible for addition if (updateHistoryGraph) { - this.graphData.push(playerCount) - - // Trim old graphPoints according to #getMaxGraphDataLength - if (this.graphData.length > TimeTracker.getMaxGraphDataLength()) { - this.graphData.shift() - } + TimeTracker.pushAndShift(this.graphData, playerCount, TimeTracker.getMaxGraphDataLength()) } // Delegate out update payload generation diff --git a/lib/time.js b/lib/time.js index 1710644..34c8afd 100644 --- a/lib/time.js +++ b/lib/time.js @@ -10,11 +10,7 @@ class TimeTracker { newPingTimestamp () { const timestamp = new Date().getTime() - this._points.push(timestamp) - - if (this._points.length > TimeTracker.getMaxServerGraphDataLength()) { - this._points.shift() - } + TimeTracker.pushAndShift(this._points, timestamp, TimeTracker.getMaxServerGraphDataLength()) // Flag each group as history graph additions each minute // This is sent to the frontend for graph updates @@ -24,11 +20,7 @@ class TimeTracker { this._lastHistoryGraphUpdate = timestamp // Push into timestamps array to update backend state - this._historicalTimestamps.push(timestamp) - - if (this._historicalTimestamps.length > TimeTracker.getMaxGraphDataLength()) { - this._historicalTimestamps.shift() - } + TimeTracker.pushAndShift(this._historicalTimestamps, timestamp, TimeTracker.getMaxGraphDataLength()) } return { @@ -60,6 +52,14 @@ class TimeTracker { static getMaxGraphDataLength () { return Math.ceil(config.graphDuration / config.rates.pingAll) } + + static pushAndShift (array, value, maxLength) { + array.push(value) + + if (array.length > maxLength) { + array.shift() + } + } } module.exports = TimeTracker From 903343fbdf197f52d373a9777ba0d605dffdc18a Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 18:29:26 -0500 Subject: [PATCH 13/33] unify TimeTracker code, explicitly mark nullable playerCounts as unsafe --- lib/app.js | 4 ++-- lib/database.js | 26 ++++++-------------------- lib/ping.js | 11 +++++++---- lib/servers.js | 12 +++++++----- lib/time.js | 42 +++++++++++++++++++++++++++++------------- lib/util.js | 11 +++++++++++ 6 files changed, 62 insertions(+), 44 deletions(-) create mode 100644 lib/util.js diff --git a/lib/app.js b/lib/app.js index de640cd..8f8b924 100644 --- a/lib/app.js +++ b/lib/app.js @@ -48,7 +48,7 @@ class App { // any header data being appended by #MessageOf since the graph data is fed // directly into the graphing system client.send(MessageOf('historyGraph', { - timestamps: this.timeTracker.getHistoricalPointsSeconds(), + timestamps: this.timeTracker.getGraphPoints(), graphData })) } @@ -74,7 +74,7 @@ class App { } })(), mojangServices: this.mojangUpdater.getLastUpdate(), - timestampPoints: this.timeTracker.getServerPointsSeconds(), + timestampPoints: this.timeTracker.getServerGraphPoints(), servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory()) } diff --git a/lib/database.js b/lib/database.js index e3b3b49..c54bfb7 100644 --- a/lib/database.js +++ b/lib/database.js @@ -1,5 +1,7 @@ const sqlite = require('sqlite3') +const TimeTracker = require('./time') + class Database { constructor (app) { this._app = app @@ -58,23 +60,7 @@ class Database { const serverIp = Object.keys(relativeGraphData)[0] const timestamps = relativeGraphData[serverIp][0] - // This is a copy of ServerRegistration#loadGraphPoints - // relativeGraphData contains original timestamp data and needs to be filtered into minutes - const sharedTimestamps = [] - - let lastTimestamp = startTime - - for (let i = 0; i < timestamps.length; i++) { - const timestamp = timestamps[i] - - if (timestamp - lastTimestamp >= 60 * 1000) { - lastTimestamp = timestamp - - sharedTimestamps.push(timestamp) - } - } - - this._app.timeTracker.loadHistoricalTimestamps(sharedTimestamps) + this._app.timeTracker.loadGraphPoints(startTime, timestamps) } callback() @@ -94,7 +80,7 @@ class Database { this.getRecord(serverRegistration.data.ip, (playerCount, timestamp) => { serverRegistration.recordData = { playerCount, - timestamp: Math.floor(timestamp / 1000) + timestamp: TimeTracker.toSeconds(timestamp) } // Check if completedTasks hit the finish value @@ -119,9 +105,9 @@ class Database { ], (_, data) => callback(data[0]['MAX(playerCount)'], data[0].timestamp)) } - insertPing (ip, timestamp, playerCount) { + insertPing (ip, timestamp, unsafePlayerCount) { const statement = this._sql.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)') - statement.run(timestamp, ip, playerCount) + statement.run(timestamp, ip, unsafePlayerCount) statement.finalize() } } diff --git a/lib/ping.js b/lib/ping.js index 00fca63..a91e92a 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -3,6 +3,9 @@ const minecraftBedrockPing = require('mcpe-ping-fixed') const logger = require('./logger') const MessageOf = require('./message') +const TimeTracker = require('./time') + +const { getPlayerCountOrNull } = require('./util') const config = require('../config') @@ -83,7 +86,7 @@ class PingController { } pingAll = () => { - const { timestamp, updateHistoryGraph } = this._app.timeTracker.newPingTimestamp() + const { timestamp, updateHistoryGraph } = this._app.timeTracker.newPointTimestamp() this.startPingTasks(results => { const updates = [] @@ -94,9 +97,9 @@ class PingController { // Log to database if enabled // Use null to represent a failed ping if (config.logToDatabase) { - const playerCount = result.resp ? result.resp.players.online : null + const unsafePlayerCount = getPlayerCountOrNull(result.resp) - this._app.database.insertPing(serverRegistration.data.ip, timestamp, playerCount) + this._app.database.insertPing(serverRegistration.data.ip, timestamp, unsafePlayerCount) } // Generate a combined update payload @@ -110,7 +113,7 @@ class PingController { // Send object since updates uses serverIds as keys // Send a single timestamp entry since it is shared this._app.server.broadcast(MessageOf('updateServers', { - timestamp: Math.floor(timestamp / 1000), + timestamp: TimeTracker.toSeconds(timestamp), updateHistoryGraph, updates })) diff --git a/lib/servers.js b/lib/servers.js index 58e6b8d..f400368 100644 --- a/lib/servers.js +++ b/lib/servers.js @@ -4,6 +4,8 @@ const dns = require('dns') const TimeTracker = require('./time') const Server = require('./server') +const { getPlayerCountOrNull } = require('./util') + const config = require('../config') const minecraftVersions = require('../minecraft_versions') @@ -23,16 +25,16 @@ class ServerRegistration { handlePing (timestamp, resp, err, version, updateHistoryGraph) { // Use null to represent a failed ping - const playerCount = resp ? resp.players.online : null + const unsafePlayerCount = getPlayerCountOrNull(resp) // Store into in-memory ping data - TimeTracker.pushAndShift(this._pingHistory, playerCount, TimeTracker.getMaxServerGraphDataLength()) + TimeTracker.pushAndShift(this._pingHistory, unsafePlayerCount, TimeTracker.getMaxServerGraphDataLength()) // Only notify the frontend to append to the historical graph // if both the graphing behavior is enabled and the backend agrees // that the ping is eligible for addition if (updateHistoryGraph) { - TimeTracker.pushAndShift(this.graphData, playerCount, TimeTracker.getMaxGraphDataLength()) + TimeTracker.pushAndShift(this.graphData, unsafePlayerCount, TimeTracker.getMaxGraphDataLength()) } // Delegate out update payload generation @@ -51,7 +53,7 @@ class ServerRegistration { if (config.logToDatabase && (!this.recordData || resp.players.online > this.recordData.playerCount)) { this.recordData = { playerCount: resp.players.online, - timestamp: Math.floor(timestamp / 1000) + timestamp: TimeTracker.toSeconds(timestamp) } // Append an updated recordData @@ -160,7 +162,7 @@ class ServerRegistration { } return { playerCount: this.graphData[this._graphPeakIndex], - timestamp: this._app.timeTracker.getHistoricalPointSeconds(this._graphPeakIndex) + timestamp: this._app.timeTracker.getGraphPointAt(this._graphPeakIndex) } } diff --git a/lib/time.js b/lib/time.js index 34c8afd..b515eca 100644 --- a/lib/time.js +++ b/lib/time.js @@ -3,14 +3,14 @@ const config = require('../config.json') class TimeTracker { constructor (app) { this._app = app - this._points = [] - this._historicalTimestamps = [] + this._serverGraphPoints = [] + this._graphPoints = [] } - newPingTimestamp () { + newPointTimestamp () { const timestamp = new Date().getTime() - TimeTracker.pushAndShift(this._points, timestamp, TimeTracker.getMaxServerGraphDataLength()) + TimeTracker.pushAndShift(this._serverGraphPoints, timestamp, TimeTracker.getMaxServerGraphDataLength()) // Flag each group as history graph additions each minute // This is sent to the frontend for graph updates @@ -20,7 +20,7 @@ class TimeTracker { this._lastHistoryGraphUpdate = timestamp // Push into timestamps array to update backend state - TimeTracker.pushAndShift(this._historicalTimestamps, timestamp, TimeTracker.getMaxGraphDataLength()) + TimeTracker.pushAndShift(this._graphPoints, timestamp, TimeTracker.getMaxGraphDataLength()) } return { @@ -29,20 +29,36 @@ class TimeTracker { } } - loadHistoricalTimestamps (timestamps) { - this._historicalTimestamps = timestamps + loadGraphPoints (startTime, timestamps) { + // This is a copy of ServerRegistration#loadGraphPoints + // relativeGraphData contains original timestamp data and needs to be filtered into minutes + let lastTimestamp = startTime + + for (let i = 0; i < timestamps.length; i++) { + const timestamp = timestamps[i] + + if (timestamp - lastTimestamp >= 60 * 1000) { + lastTimestamp = timestamp + + this._graphPoints.push(timestamp) + } + } } - getHistoricalPointsSeconds () { - return this._historicalTimestamps.map(value => Math.floor(value / 1000)) + getGraphPointAt (i) { + return TimeTracker.toSeconds(this._graphPoints[i]) } - getHistoricalPointSeconds (index) { - return Math.floor(this._historicalTimestamps[index] / 1000) + getServerGraphPoints () { + return this._serverGraphPoints.map(TimeTracker.toSeconds) } - getServerPointsSeconds () { - return this._points.map(value => Math.floor(value / 1000)) + getGraphPoints () { + return this._graphPoints.map(TimeTracker.toSeconds) + } + + static toSeconds = (timestamp) => { + return Math.floor(timestamp / 1000) } static getMaxServerGraphDataLength () { diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..d603481 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,11 @@ +function getPlayerCountOrNull (resp) { + if (resp) { + return resp.players.online + } else { + return null + } +} + +module.exports = { + getPlayerCountOrNull +} From 3998b688ab2749eae424e334fc5be4d166d16fa1 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 18:36:22 -0500 Subject: [PATCH 14/33] splice to ensure shifts never fall behind --- lib/time.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/time.js b/lib/time.js index b515eca..f810ac4 100644 --- a/lib/time.js +++ b/lib/time.js @@ -73,7 +73,7 @@ class TimeTracker { array.push(value) if (array.length > maxLength) { - array.shift() + array.splice(0, array.length - maxLength) } } } From 0c98930d4cb2aa7e5d4a14e6d2e411288a6b08b8 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 18:44:29 -0500 Subject: [PATCH 15/33] centralize timestamp/point filtering, move magic numbers into named constants --- lib/app.js | 2 +- lib/database.js | 2 +- lib/ping.js | 2 +- lib/servers.js | 15 ++------------- lib/time.js | 39 +++++++++++++++++++++++++-------------- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/app.js b/lib/app.js index 8f8b924..c4419b3 100644 --- a/lib/app.js +++ b/lib/app.js @@ -2,7 +2,7 @@ const Database = require('./database') const MojangUpdater = require('./mojang') const PingController = require('./ping') const Server = require('./server') -const TimeTracker = require('./time') +const { TimeTracker } = require('./time') const MessageOf = require('./message') const config = require('../config') diff --git a/lib/database.js b/lib/database.js index c54bfb7..3e22245 100644 --- a/lib/database.js +++ b/lib/database.js @@ -1,6 +1,6 @@ const sqlite = require('sqlite3') -const TimeTracker = require('./time') +const { TimeTracker } = require('./time') class Database { constructor (app) { diff --git a/lib/ping.js b/lib/ping.js index a91e92a..a830207 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -3,7 +3,7 @@ const minecraftBedrockPing = require('mcpe-ping-fixed') const logger = require('./logger') const MessageOf = require('./message') -const TimeTracker = require('./time') +const { TimeTracker } = require('./time') const { getPlayerCountOrNull } = require('./util') diff --git a/lib/servers.js b/lib/servers.js index f400368..fc28cdd 100644 --- a/lib/servers.js +++ b/lib/servers.js @@ -1,7 +1,7 @@ const crypto = require('crypto') const dns = require('dns') -const TimeTracker = require('./time') +const { GRAPH_UPDATE_TIME_GAP, TimeTracker } = require('./time') const Server = require('./server') const { getPlayerCountOrNull } = require('./util') @@ -124,18 +124,7 @@ class ServerRegistration { } loadGraphPoints (startTime, timestamps, points) { - // Filter pings so each result is a minute apart - let lastTimestamp = startTime - - for (let i = 0; i < timestamps.length; i++) { - const timestamp = timestamps[i] - - if (timestamp - lastTimestamp >= 60 * 1000) { - lastTimestamp = timestamp - - this.graphData.push(points[i]) - } - } + this.graphData = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => points[i]) } findNewGraphPeak () { diff --git a/lib/time.js b/lib/time.js index f810ac4..0165215 100644 --- a/lib/time.js +++ b/lib/time.js @@ -1,5 +1,7 @@ const config = require('../config.json') +const GRAPH_UPDATE_TIME_GAP = 60 * 1000 // 60 seconds + class TimeTracker { constructor (app) { this._app = app @@ -14,7 +16,7 @@ class TimeTracker { // Flag each group as history graph additions each minute // This is sent to the frontend for graph updates - const updateHistoryGraph = config.logToDatabase && (!this._lastHistoryGraphUpdate || timestamp - this._lastHistoryGraphUpdate >= 60 * 1000) + const updateHistoryGraph = config.logToDatabase && (!this._lastHistoryGraphUpdate || timestamp - this._lastHistoryGraphUpdate >= GRAPH_UPDATE_TIME_GAP) if (updateHistoryGraph) { this._lastHistoryGraphUpdate = timestamp @@ -31,18 +33,8 @@ class TimeTracker { loadGraphPoints (startTime, timestamps) { // This is a copy of ServerRegistration#loadGraphPoints - // relativeGraphData contains original timestamp data and needs to be filtered into minutes - let lastTimestamp = startTime - - for (let i = 0; i < timestamps.length; i++) { - const timestamp = timestamps[i] - - if (timestamp - lastTimestamp >= 60 * 1000) { - lastTimestamp = timestamp - - this._graphPoints.push(timestamp) - } - } + // timestamps contains original timestamp data and needs to be filtered into minutes + this._graphPoints = TimeTracker.everyN(timestamps, startTime, GRAPH_UPDATE_TIME_GAP, (i) => timestamps[i]) } getGraphPointAt (i) { @@ -69,6 +61,22 @@ class TimeTracker { return Math.ceil(config.graphDuration / config.rates.pingAll) } + static everyN (array, start, diff, adapter) { + const selected = [] + let lastPoint = start + + for (let i = 0; i < array.length; i++) { + const point = array[i] + + if (point - lastPoint >= diff) { + lastPoint = point + selected.push(adapter(i)) + } + } + + return selected + } + static pushAndShift (array, value, maxLength) { array.push(value) @@ -78,4 +86,7 @@ class TimeTracker { } } -module.exports = TimeTracker +module.exports = { + GRAPH_UPDATE_TIME_GAP, + TimeTracker +} From bbdbe7e5996add8160c479de31c03e757bb4b65a Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 18:58:47 -0500 Subject: [PATCH 16/33] simplify error rendering logic --- assets/js/scale.js | 2 +- assets/js/servers.js | 20 +++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/assets/js/scale.js b/assets/js/scale.js index 7a82368..ef1b9e0 100644 --- a/assets/js/scale.js +++ b/assets/js/scale.js @@ -38,7 +38,7 @@ export class RelativeScale { } } - if (max === Number.MAX_VALUE) { + if (max === Number.MIN_VALUE) { max = 0 } diff --git a/assets/js/servers.js b/assets/js/servers.js index b39623b..6779b56 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -244,22 +244,20 @@ export class ServerRegistration { const playerCountLabelElement = document.getElementById('player-count_' + this.serverId) const errorElement = document.getElementById('error_' + this.serverId) - if (ping.error || typeof ping.playerCount !== 'number') { + if (ping.error) { // Hide any visible player-count and show the error element playerCountLabelElement.style.display = 'none' errorElement.style.display = 'block' - let errorMessage + errorElement.innerText = ping.error.message + } else if (typeof ping.playerCount !== 'number') { + // Hide any visible player-count and show the error element + playerCountLabelElement.style.display = 'none' + errorElement.style.display = 'block' - if (ping.error) { - errorMessage = ping.error.message - } else if (typeof ping.playerCount !== 'number') { - // If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object - // In this case playerCount will safely be null, so provide a generic error message instead - errorMessage = 'Failed to ping' - } - - errorElement.innerText = errorMessage + // If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object + // In this case playerCount will safely be null, so provide a generic error message instead + errorElement.innerText = 'Failed to ping' } else if (typeof ping.playerCount === 'number') { // Ensure the player-count element is visible and hide the error element playerCountLabelElement.style.display = 'block' From 80c565a6eddfa84be8fc175f84e10b0f67b9b076 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 19:23:37 -0500 Subject: [PATCH 17/33] simplify server label rendering --- assets/js/servers.js | 86 ++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/assets/js/servers.js b/assets/js/servers.js index 6779b56..bacf702 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -194,76 +194,68 @@ export class ServerRegistration { document.getElementById('ranking_' + this.serverId).innerText = '#' + (rankIndex + 1) } - updateServerPeak (data) { - const peakLabelElement = document.getElementById('peak_' + this.serverId) + _renderValue (prefix, handler) { + const labelElement = document.getElementById(prefix + '_' + this.serverId) - // Always set label once any peak data has been received - peakLabelElement.style.display = 'block' + labelElement.style.display = 'block' - const peakValueElement = document.getElementById('peak-value_' + this.serverId) + const valueElement = document.getElementById(prefix + '-value_' + this.serverId) + const targetElement = valueElement || labelElement - peakValueElement.innerText = formatNumber(data.playerCount) - peakLabelElement.title = 'At ' + formatTimestampSeconds(data.timestamp) + if (targetElement) { + if (typeof handler === 'function') { + handler(targetElement) + } else { + targetElement.innerText = handler + } + } + } - this.lastPeakData = data + _hideValue (prefix) { + const element = document.getElementById(prefix + '_' + this.serverId) + + element.style.display = 'none' } updateServerStatus (ping, minecraftVersions) { if (ping.versions) { - const versionsElement = document.getElementById('version_' + this.serverId) - - versionsElement.style.display = 'block' - versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || '' + this._renderValue('version', formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || '') } if (ping.recordData) { - // Always set label once any record data has been received - const recordLabelElement = document.getElementById('record_' + this.serverId) + this._renderValue('record', (element) => { + if (ping.recordData.timestamp > 0) { + element.innerText = formatNumber(ping.recordData.playerCount) + ' (' + formatDate(ping.recordData.timestamp) + ')' + element.title = 'At ' + formatDate(ping.recordData.timestamp) + ' ' + formatTimestampSeconds(ping.recordData.timestamp) + } else { + element.innerText = formatNumber(ping.recordData.playerCount) + } + }) - recordLabelElement.style.display = 'block' - - const recordValueElement = document.getElementById('record-value_' + this.serverId) - - const recordData = ping.recordData - - // Safely handle legacy recordData that may not include the timestamp payload - if (recordData.timestamp > 0) { - recordValueElement.innerHTML = formatNumber(recordData.playerCount) + ' (' + formatDate(recordData.timestamp) + ')' - recordLabelElement.title = 'At ' + formatDate(recordData.timestamp) + ' ' + formatTimestampSeconds(recordData.timestamp) - } else { - recordValueElement.innerText = formatNumber(recordData.playerCount) - } - - this.lastRecordData = recordData + this.lastRecordData = ping.recordData } if (ping.graphPeakData) { - this.updateServerPeak(ping.graphPeakData) + this._renderValue('peak', (element) => { + element.innerText = formatNumber(ping.graphPeakData.playerCount) + element.title = 'At ' + formatTimestampSeconds(ping.graphPeakData.timestamp) + }) + + this.lastPeakData = ping.graphPeakData } - const playerCountLabelElement = document.getElementById('player-count_' + this.serverId) - const errorElement = document.getElementById('error_' + this.serverId) - if (ping.error) { - // Hide any visible player-count and show the error element - playerCountLabelElement.style.display = 'none' - errorElement.style.display = 'block' - - errorElement.innerText = ping.error.message + this._hideValue('player-count') + this._renderValue('error', ping.error.message) } else if (typeof ping.playerCount !== 'number') { - // Hide any visible player-count and show the error element - playerCountLabelElement.style.display = 'none' - errorElement.style.display = 'block' + this._hideValue('player-count') // If the frontend has freshly connection, and the server's last ping was in error, it may not contain an error object // In this case playerCount will safely be null, so provide a generic error message instead - errorElement.innerText = 'Failed to ping' + this._renderValue('error', 'Failed to ping') } else if (typeof ping.playerCount === 'number') { - // Ensure the player-count element is visible and hide the error element - playerCountLabelElement.style.display = 'block' - errorElement.style.display = 'none' - - document.getElementById('player-count-value_' + this.serverId).innerText = formatNumber(ping.playerCount) + this._hideValue('error') + this._renderValue('player-count', formatNumber(ping.playerCount)) } // An updated favicon has been sent, update the src From 9a8afe2798a25dd9a7a26aef73e23c802bced658 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 19:26:44 -0500 Subject: [PATCH 18/33] inline redraw function --- assets/js/servers.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/assets/js/servers.js b/assets/js/servers.js index bacf702..2fee655 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -180,10 +180,6 @@ export class ServerRegistration { } } - this.redraw() - } - - redraw () { // Redraw the plot instance this._plotInstance.setData(this._graphData) } From 9d8dce716ba49890df41f247fef897b493a754ff Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 19:29:35 -0500 Subject: [PATCH 19/33] remove legacy flot.js hacks --- assets/js/graph.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/assets/js/graph.js b/assets/js/graph.js index 13343c9..9b5219a 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -372,14 +372,10 @@ export class GraphDisplayManager { } // Reset modified DOM structures + document.getElementById('big-graph').innerHTML = '' document.getElementById('big-graph-checkboxes').innerHTML = '' document.getElementById('big-graph-controls').style.display = 'none' document.getElementById('settings-toggle').style.display = 'none' - - const graphElement = document.getElementById('big-graph') - - graphElement.innerHTML = '' - graphElement.removeAttribute('style') } } From 8177c43d15cf94384158d100209d6fc6d4edf75f Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 20:05:34 -0500 Subject: [PATCH 20/33] impl updated graph tooltips --- assets/css/main.css | 37 +++++++++++++++--------------- assets/js/graph.js | 54 ++++++++++++++++++++++++++++++++------------ assets/js/servers.js | 12 ++++------ assets/js/tooltip.js | 2 +- 4 files changed, 65 insertions(+), 40 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index 7a66d37..5b66c19 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -263,24 +263,6 @@ footer a:hover { display: inline-block; } -.server .column-status .server-is-favorite { - cursor: pointer; - color: var(--color-gold); -} - -.server .column-status .server-is-favorite:hover::before { - content: "\f006"; -} - -.server .column-status .server-is-not-favorite { - cursor: pointer; - color: var(--background-color); -} - -.server .column-status .server-is-not-favorite:hover { - color: var(--color-gold); -} - .server .column-status .server-error { display: none; color: #e74c3c; @@ -302,6 +284,25 @@ footer a:hover { width: 400px; } +/* Favorite icons */ +.server-is-favorite { + cursor: pointer; + color: var(--color-gold); +} + +.server-is-favorite:hover::before { + content: "\f006"; +} + +.server-is-not-favorite { + cursor: pointer; + color: var(--background-color); +} + +.server-is-not-favorite:hover { + color: var(--color-gold); +} + /* Highlighted values */ .server-highlighted-label { font-size: 18px; diff --git a/assets/js/graph.js b/assets/js/graph.js index 9b5219a..d89b650 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -115,6 +115,13 @@ export class GraphDisplayManager { } } + getGraphDataPoint (serverId, index) { + const graphData = this._graphData[serverId] + if (graphData && index < graphData.length && typeof graphData[index] === 'number') { + return graphData[index] + } + } + buildPlotInstance (timestamps, data) { // Lazy load settings from localStorage, if any and if enabled if (!this._hasLoadedSettings) { @@ -141,25 +148,44 @@ export class GraphDisplayManager { // eslint-disable-next-line new-cap this._plotInstance = new uPlot({ plugins: [ - uPlotTooltipPlugin((pos, id, plot) => { + uPlotTooltipPlugin((pos, id) => { if (pos) { - // FIXME - let text = '' + formatTimestampSeconds(this._graphTimestamps[id]) + '

' + let text = this._app.serverRegistry.getServerRegistrations() + .filter(serverRegistration => serverRegistration.isVisible) + .sort((a, b) => { + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1 + } - for (let i = 1; i < plot.series.length; i++) { - const serverRegistration = this._app.serverRegistry.getServerRegistration(i - 1) - const serverGraphData = this._graphData[serverRegistration.serverId] + const aPoint = this.getGraphDataPoint(a.serverId, id) + const bPoint = this.getGraphDataPoint(b.serverId, id) - let playerCount + if (typeof aPoint === typeof bPoint) { + if (typeof aPoint === 'undefined') { + return 0 + } + } else { + return typeof aPoint === 'number' ? -1 : 1 + } - if (id >= serverGraphData.length || typeof serverGraphData[id] !== 'number') { - playerCount = '-' - } else { - playerCount = formatNumber(serverGraphData[id]) - } + return bPoint - aPoint + }) + .map(serverRegistration => { + const point = this.getGraphDataPoint(serverRegistration.serverId, id) - text += serverRegistration.data.name + ': ' + playerCount + '
' - } + let serverName = serverRegistration.data.name + if (serverRegistration.isFavorite) { + serverName = ' ' + serverName + } + + if (typeof point === 'number') { + return serverName + ': ' + formatNumber(point) + } else { + return serverName + ': -' + } + }).join('
') + + text += '

' + formatTimestampSeconds(this._graphTimestamps[id]) + '' this._app.tooltip.set(pos.left, pos.top, 10, 10, text) } else { diff --git a/assets/js/servers.js b/assets/js/servers.js index 2fee655..e5387e6 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -80,19 +80,17 @@ export class ServerRegistration { // eslint-disable-next-line new-cap this._plotInstance = new uPlot({ plugins: [ - uPlotTooltipPlugin((pos, id, plot) => { + uPlotTooltipPlugin((pos, id) => { if (pos) { - const playerCount = plot.data[1][id] + const playerCount = this._graphData[1][id] if (typeof playerCount !== 'number') { this._app.tooltip.hide() + } else { + const text = formatNumber(playerCount) + ' Players
' + formatTimestampSeconds(this._graphData[0][id]) - return + this._app.tooltip.set(pos.left, pos.top, 10, 10, text) } - - const text = formatNumber(playerCount) + ' Players
' + formatTimestampSeconds(plot.data[0][id]) - - this._app.tooltip.set(pos.left, pos.top, 10, 10, text) } else { this._app.tooltip.hide() } diff --git a/assets/js/tooltip.js b/assets/js/tooltip.js index 431ad9e..0595d7d 100644 --- a/assets/js/tooltip.js +++ b/assets/js/tooltip.js @@ -20,7 +20,7 @@ export function uPlotTooltipPlugin (onHover) { onHover({ left: bounds.left + left + window.pageXOffset, top: bounds.top + top + window.pageYOffset - }, idx, u) + }, idx) } } } From 08415d74ddd7f10c0f9682e1ab07b9d91ee9a85e Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 20:16:48 -0500 Subject: [PATCH 21/33] 5.5.0 release preview --- docs/CHANGELOG.md | 15 +++++++++++++++ package-lock.json | 2 +- package.json | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1bf96f4..9e55fa1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,3 +1,18 @@ +**5.5.0** *(May 11 2020)* +**IMPORTANT** +This update moves ping timestamps to a shared timestamp per round. Meaning that when pinging servers, each will share the same timestamp for that series of pings. The legacy backend used a timestamp per ping per series of pings. This means after updating Minetrack, the historical graph may render slightly inaccurate for the first 24 hours (or whatever your config.json->graphDuration is), and will automatically correct itself as it receives new updates. Don't worry. + +- Replaces flot.js charts with uPlot charts. This new chart library renders much quicker and supports a reduced data format. This results in ~1/12th the bandwidth use when sending the historical graph. +- Removed jQuery (flot.js required this dependency). Between removing flot.js and jQuery, the page size has been reduced by 100KB (33%)! +- New historical graph tooltip design to better compare multiple servers. +- Historical graph now supports click dragging to zoom in to a custom time frame. Double click to reset. +- Historical graph now displays time markers along the bottom. +- All graphs now have horizontal ticks to improve readability. +- Graphs will now display gaps (null) when the ping fails. This removes legacy graph smoothing code and prevents 0 player count pings messing up graph scales. +- Graphs will now render the same on initial page load as they will after being open for a while. This fixes a long standing bug where the frontend ignored 0 player count pings in updates but not on initial load. + +Faster, smaller, more features. + **5.4.1** *(May 10 2020)* - Adds warnings when the system is pinging more frequently than it is getting replies. - Replaces the legacy mc-ping-updated dependency with a new library, mcping-js. This fixes some bugs that could cause "zombie" connections and cause stuttering in the ping loops. diff --git a/package-lock.json b/package-lock.json index 6d19366..b5c4568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "minetrack", - "version": "5.4.1", + "version": "5.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 633cb96..69a1bb4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minetrack", - "version": "5.4.1", + "version": "5.5.0", "description": "A Minecraft server tracker that lets you focus on the basics.", "main": "main.js", "dependencies": { From aa48a95bab91a1a0b485ffcb0cceba416fe9f1a7 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 20:17:38 -0500 Subject: [PATCH 22/33] update docs/CHANGELOG.md --- docs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9e55fa1..6d56d25 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,4 +1,5 @@ **5.5.0** *(May 11 2020)* + **IMPORTANT** This update moves ping timestamps to a shared timestamp per round. Meaning that when pinging servers, each will share the same timestamp for that series of pings. The legacy backend used a timestamp per ping per series of pings. This means after updating Minetrack, the historical graph may render slightly inaccurate for the first 24 hours (or whatever your config.json->graphDuration is), and will automatically correct itself as it receives new updates. Don't worry. From 5eb1aa05ddd0052fefef2807b376897511524d36 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 20:18:46 -0500 Subject: [PATCH 23/33] disable logToDatabase/skipUnfurlSrv by default --- config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.json b/config.json index 24465b0..e96bca1 100644 --- a/config.json +++ b/config.json @@ -10,10 +10,10 @@ "connectTimeout": 2500 }, "performance": { - "skipUnfurlSrv": true, + "skipUnfurlSrv": false, "unfurlSrvCacheTtl": 120000 }, - "logToDatabase": true, + "logToDatabase": false, "graphDuration": 86400000, "serverGraphDuration": 180000 } From 8bfd2970080e4e1bbaf8f192114a46fc63587a9f Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Mon, 11 May 2020 20:53:59 -0500 Subject: [PATCH 24/33] remove big-graph-mobile-load-request --- assets/css/main.css | 15 --------------- assets/html/index.html | 7 ------- assets/js/graph.js | 5 +---- assets/js/main.js | 8 -------- assets/js/socket.js | 17 ++--------------- assets/js/util.js | 8 -------- docs/CHANGELOG.md | 1 + 7 files changed, 4 insertions(+), 57 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index 5b66c19..4fbf6a7 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -329,21 +329,6 @@ footer a:hover { } /* Historical graph */ -#big-graph-mobile-load-request { - background: var(--background-color); - color: var(--text-color); - padding: 10px 0; - text-align: center; - display: none; - width: 100%; - margin-bottom: 10px; -} - -#big-graph-mobile-load-request a { - display: inline-block; - color: var(--text-color); -} - #big-graph { padding-right: 65px; } diff --git a/assets/html/index.html b/assets/html/index.html index 70e0abb..f2fa20b 100644 --- a/assets/html/index.html +++ b/assets/html/index.html @@ -61,13 +61,6 @@ -
- On a mobile device? -

Minetrack has skipped automatically loading the historical graph to help save data and power.

-
- Load Historical Graph -
-
diff --git a/assets/js/graph.js b/assets/js/graph.js index d89b650..70227a8 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -2,7 +2,7 @@ import uPlot from '../lib/uPlot.esm' import { RelativeScale } from './scale' -import { formatNumber, formatTimestampSeconds, isMobileBrowser } from './util' +import { formatNumber, formatTimestampSeconds } from './util' import { uPlotTooltipPlugin } from './tooltip' import { FAVORITE_SERVERS_STORAGE_KEY } from './favorites' @@ -11,9 +11,6 @@ const HIDDEN_SERVERS_STORAGE_KEY = 'minetrack_hidden_servers' const SHOW_FAVORITES_STORAGE_KEY = 'minetrack_show_favorites' export class GraphDisplayManager { - // Only emit graph data request if not on mobile due to graph data size - isVisible = !isMobileBrowser() - constructor (app) { this._app = app this._graphData = [] diff --git a/assets/js/main.js b/assets/js/main.js index c0ee1b5..cd7ee3e 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -11,12 +11,4 @@ document.addEventListener('DOMContentLoaded', () => { // Delegate to GraphDisplayManager which can check if the resize is necessary app.graphDisplayManager.requestResize() }, false) - - document.getElementById('big-graph-mobile-load-request-button').addEventListener('click', function () { - // Send a graph data request to the backend - app.socketManager.sendHistoryGraphRequest() - - // Hide the activation link to avoid multiple requests - document.getElementById('big-graph-mobile-load-request').style.display = 'none' - }, false) }, false) diff --git a/assets/js/socket.js b/assets/js/socket.js index 24e1049..25cdd01 100644 --- a/assets/js/socket.js +++ b/assets/js/socket.js @@ -38,9 +38,6 @@ export class SocketManager { this._app.caption.set('Disconnected due to error.') } - // Reset modified DOM structures - document.getElementById('big-graph-mobile-load-request').style.display = 'none' - // Schedule socket reconnection attempt this.scheduleReconnect() } @@ -59,11 +56,7 @@ export class SocketManager { // Allow the graphDisplayManager to control whether or not the historical graph is loaded // Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload if (this._app.publicConfig.isGraphVisible) { - if (this._app.graphDisplayManager.isVisible) { - this.sendHistoryGraphRequest() - } else { - document.getElementById('big-graph-mobile-load-request').style.display = 'block' - } + this.sendHistoryGraphRequest() } payload.servers.forEach((serverPayload, serverId) => { @@ -99,9 +92,7 @@ export class SocketManager { this._app.graphDisplayManager.addGraphPoint(payload.timestamp, Object.values(payload.updates).map(update => update.playerCount)) // Run redraw tasks after handling bulk updates - if (this._app.graphDisplayManager.isVisible) { - this._app.graphDisplayManager.redraw() - } + this._app.graphDisplayManager.redraw() } this._app.percentageBar.redraw() @@ -116,10 +107,6 @@ export class SocketManager { } case 'historyGraph': { - // Consider the graph visible since a payload has been received - // This is used for the manual graph load request behavior - this._app.graphDisplayManager.isVisible = true - this._app.graphDisplayManager.buildPlotInstance(payload.timestamps, payload.graphData) // Build checkbox elements for graph controls diff --git a/assets/js/util.js b/assets/js/util.js index be1dc8f..474cc76 100644 --- a/assets/js/util.js +++ b/assets/js/util.js @@ -119,11 +119,3 @@ export function formatPercent (x, over) { export function formatNumber (x) { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') } - -// From http://detectmobilebrowsers.com/ -export function isMobileBrowser () { - var check = false; - // eslint-disable-next-line no-useless-escape - (function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4)))check = true })(navigator.userAgent || navigator.vendor || window.opera) - return check -} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6d56d25..65ce7f0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,7 @@ This update moves ping timestamps to a shared timestamp per round. Meaning that - All graphs now have horizontal ticks to improve readability. - Graphs will now display gaps (null) when the ping fails. This removes legacy graph smoothing code and prevents 0 player count pings messing up graph scales. - Graphs will now render the same on initial page load as they will after being open for a while. This fixes a long standing bug where the frontend ignored 0 player count pings in updates but not on initial load. +- Removes the mobile browser detection/manual historical graph load request. It is now automatically loaded given its smaller size. Faster, smaller, more features. From 7fd0117f7438d19a5c7c95dccb1a75f7db721edb Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Tue, 12 May 2020 19:50:28 -0500 Subject: [PATCH 25/33] replace uplot lib with npm package --- assets/css/main.css | 3 + assets/html/index.html | 2 - assets/js/graph.js | 2 +- assets/js/servers.js | 2 +- assets/lib/uPlot.esm.js | 2640 -------------------------------------- assets/lib/uPlot.min.css | 1 - package-lock.json | 5 + package.json | 1 + 8 files changed, 11 insertions(+), 2645 deletions(-) delete mode 100644 assets/lib/uPlot.esm.js delete mode 100644 assets/lib/uPlot.min.css diff --git a/assets/css/main.css b/assets/css/main.css index 4fbf6a7..8b3ca8b 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -1,4 +1,7 @@ @import url(https://fonts.googleapis.com/css?family=Open+Sans:700,300); + +@import url(uplot/dist/uPlot.min.css); + @import url(../css/icons.css); * { diff --git a/assets/html/index.html b/assets/html/index.html index f2fa20b..ae2f675 100644 --- a/assets/html/index.html +++ b/assets/html/index.html @@ -6,8 +6,6 @@ - - diff --git a/assets/js/graph.js b/assets/js/graph.js index 70227a8..df1971d 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -1,4 +1,4 @@ -import uPlot from '../lib/uPlot.esm' +import uPlot from 'uplot' import { RelativeScale } from './scale' diff --git a/assets/js/servers.js b/assets/js/servers.js index e5387e6..4e3d056 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -1,4 +1,4 @@ -import uPlot from '../lib/uPlot.esm' +import uPlot from 'uplot' import { RelativeScale } from './scale' diff --git a/assets/lib/uPlot.esm.js b/assets/lib/uPlot.esm.js deleted file mode 100644 index 0c32cb9..0000000 --- a/assets/lib/uPlot.esm.js +++ /dev/null @@ -1,2640 +0,0 @@ -/** -* 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 deleted file mode 100644 index 82b70b5..0000000 --- a/assets/lib/uPlot.min.css +++ /dev/null @@ -1 +0,0 @@ -.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 diff --git a/package-lock.json b/package-lock.json index b5c4568..93a463a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8519,6 +8519,11 @@ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", "dev": true }, + "uplot": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.0.8.tgz", + "integrity": "sha512-oS0YVdq6iEU4B+BXSX1Ln3Dd8iVHk9vKL9elWlIEa7cYzlhqDmnnJQsXSaLjYWTQbnDLRJuuaO3oyGF2q7loiw==" + }, "uri-js": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", diff --git a/package.json b/package.json index 69a1bb4..6a0dabb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "request": "2.88.2", "serve-static": "^1.14.1", "sqlite3": "4.1.1", + "uplot": "^1.0.8", "winston": "^2.0.0", "ws": "^7.2.5" }, From a68e4d2460d156170b30a13ee0bd65c0ade5e892 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Tue, 12 May 2020 22:10:38 -0500 Subject: [PATCH 26/33] fix missing graphData culling behavior --- assets/js/graph.js | 15 +++++++++++++++ lib/time.js | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/assets/js/graph.js b/assets/js/graph.js index df1971d..12f109b 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -35,6 +35,21 @@ export class GraphDisplayManager { this._graphData[i].push(playerCounts[i]) } + // Trim all data arrays to only the relevant portion + // This keeps it in sync with backend data structures + const graphMaxLength = this._app.publicConfig.graphMaxLength + + if (this._graphTimestamps.length > graphMaxLength) { + this._graphTimestamps.splice(0, this._graphTimestamps.length - graphMaxLength) + } + + for (const series of this._graphData) { + if (series.length > graphMaxLength) { + series.splice(0, series.length - graphMaxLength) + } + } + + // Paint updated data structure this._plotInstance.setData([ this._graphTimestamps, ...this._graphData diff --git a/lib/time.js b/lib/time.js index 0165215..8736cfd 100644 --- a/lib/time.js +++ b/lib/time.js @@ -58,7 +58,7 @@ class TimeTracker { } static getMaxGraphDataLength () { - return Math.ceil(config.graphDuration / config.rates.pingAll) + return Math.ceil(config.graphDuration / GRAPH_UPDATE_TIME_GAP) } static everyN (array, start, diff, adapter) { From 99c271ebc45576503fa488fbd69c2fd256aea76a Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Tue, 12 May 2020 22:21:15 -0500 Subject: [PATCH 27/33] move script into --- assets/html/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/html/index.html b/assets/html/index.html index ae2f675..3be6101 100644 --- a/assets/html/index.html +++ b/assets/html/index.html @@ -10,6 +10,8 @@ + + Minetrack @@ -81,8 +83,6 @@ Powered by open source software - make it your own! - - \ No newline at end of file From f4dadd9805cb6b7f1e9babeb9059d7213ab94eb7 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Thu, 14 May 2020 21:03:14 -0500 Subject: [PATCH 28/33] fix remaining merge errors --- docs/CHANGELOG.md | 4 +--- lib/ping.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f1615c6..cd78a49 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,4 +1,3 @@ -<<<<<<< HEAD **5.5.0** *(May 11 2020)* **IMPORTANT** @@ -15,14 +14,13 @@ This update moves ping timestamps to a shared timestamp per round. Meaning that - Removes the mobile browser detection/manual historical graph load request. It is now automatically loaded given its smaller size. Faster, smaller, more features. -======= + **5.4.3** *(May 14 2020)* - Added support for the optional field `config->skipSrvTimeout` in `config.json`. If a configured server does not return a valid response when unfurling potential SRV records, it will avoid re-unfurling SRV records for this duration in milliseconds. Use a value of `0` to disable this feature altogether. - Removes support for the `config->performance->skipUnfurlSrv` and `config->performance->unfurlSrvCacheTtl` fields in `config.json **5.4.2** *(May 13 2020)* - Fixes a typo causing `_minecraft._tcp.*` SRV records to not resolve. ->>>>>>> 0e5859a82953b9d67ea759a3235080eaf7dbe74c **5.4.1** *(May 10 2020)* - Adds warnings when the system is pinging more frequently than it is getting replies. diff --git a/lib/ping.js b/lib/ping.js index 2919b17..27e9dab 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -15,7 +15,7 @@ function ping (serverRegistration, timeout, callback, version) { serverRegistration.dnsResolver.resolve((host, port, remainingTimeout) => { const server = new minecraftJavaPing.MinecraftServer(host, port || 25565) - server.resolve(remainingTimeout, version, (err, res) => { + server.ping(remainingTimeout, version, (err, res) => { if (err) { callback(err) } else { From 05df7081e4e998870dee8150018340124f6205d6 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Wed, 20 May 2020 19:56:10 -0500 Subject: [PATCH 29/33] fix potential null errors in recordData/graphPeakData --- lib/database.js | 26 +++++++++++++++++++++----- lib/servers.js | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/database.js b/lib/database.js index 21fb7b6..ee7481a 100644 --- a/lib/database.js +++ b/lib/database.js @@ -77,10 +77,12 @@ class Database { // Query recordData // When complete increment completeTasks to know when complete - this.getRecord(serverRegistration.data.ip, (playerCount, timestamp) => { - serverRegistration.recordData = { - playerCount, - timestamp: TimeTracker.toSeconds(timestamp) + this.getRecord(serverRegistration.data.ip, (hasRecord, playerCount, timestamp) => { + if (hasRecord) { + serverRegistration.recordData = { + playerCount, + timestamp: TimeTracker.toSeconds(timestamp) + } } // Check if completedTasks hit the finish value @@ -102,7 +104,21 @@ class Database { getRecord (ip, callback) { this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?', [ ip - ], (_, data) => callback(data[0]['MAX(playerCount)'], data[0].timestamp)) + ], (_, data) => { + // For empty results, data will be length 1 with [null, null] + const playerCount = data[0]['MAX(playerCount)'] + const timestamp = data[0].timestamp + + // Allow null timestamps, the frontend will safely handle them + // This allows insertion of free standing records without a known timestamp + if (playerCount !== null) { + // eslint-disable-next-line standard/no-callback-literal + callback(true, playerCount, timestamp) + } else { + // eslint-disable-next-line standard/no-callback-literal + callback(false) + } + }) } insertPing (ip, timestamp, unsafePlayerCount) { diff --git a/lib/servers.js b/lib/servers.js index 060bd73..4568324 100644 --- a/lib/servers.js +++ b/lib/servers.js @@ -132,7 +132,7 @@ class ServerRegistration { let index = -1 for (let i = 0; i < this.graphData.length; i++) { const point = this.graphData[i] - if (index === -1 || point > this.graphData[index]) { + if (point !== null && (index === -1 || point > this.graphData[index])) { index = i } } From 2ddc09c2d3bde277009787e9757cea45cd8cd386 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Wed, 20 May 2020 19:56:25 -0500 Subject: [PATCH 30/33] sync vertical tick line across all server graphs --- assets/js/servers.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/js/servers.js b/assets/js/servers.js index 4e3d056..85db243 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -104,6 +104,10 @@ export class ServerRegistration { setScale: false, x: false, y: false + }, + sync: { + key: 'minetrack-server', + setSeries: true } }, series: [ From a360cb4ce02917abbbc35ecb994ca7fd0fa5fcce Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Wed, 20 May 2020 22:25:37 -0500 Subject: [PATCH 31/33] span plot gaps, change selection color, hide points --- assets/css/main.css | 6 ++++++ assets/js/graph.js | 15 ++++++++++++--- assets/js/servers.js | 5 ++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index 8b3ca8b..4ac7f61 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -451,3 +451,9 @@ footer a:hover { margin-bottom: 20px; } } + +/* uPlot.css overrides */ +.uplot .select { + background: var(--color-blue); + opacity: 0.3; +} \ No newline at end of file diff --git a/assets/js/graph.js b/assets/js/graph.js index 12f109b..b00880b 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -151,7 +151,11 @@ export class GraphDisplayManager { stroke: serverRegistration.data.color, width: 2, value: (_, raw) => formatNumber(raw) + ' Players', - show: serverRegistration.isVisible + show: serverRegistration.isVisible, + spanGaps: true, + points: { + show: false + } } }) @@ -397,9 +401,15 @@ export class GraphDisplayManager { } reset () { + // Destroy graphs and unload references + // uPlot#destroy handles listener de-registration, DOM reset, etc + if (this._plotInstance) { + this._plotInstance.destroy() + this._plotInstance = undefined + } + this._graphTimestamps = [] this._graphData = [] - this._plotInstance = undefined this._hasLoadedSettings = false // Fire #clearTimeout if the timeout is currently defined @@ -410,7 +420,6 @@ export class GraphDisplayManager { } // Reset modified DOM structures - document.getElementById('big-graph').innerHTML = '' document.getElementById('big-graph-checkboxes').innerHTML = '' document.getElementById('big-graph-controls').style.display = 'none' diff --git a/assets/js/servers.js b/assets/js/servers.js index 85db243..b2f1776 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -116,7 +116,10 @@ export class ServerRegistration { scale: 'Players', stroke: '#E9E581', width: 2, - value: (_, raw) => formatNumber(raw) + ' Players' + value: (_, raw) => formatNumber(raw) + ' Players', + points: { + show: false + } } ], axes: [ From 2a693799235381f496acdebf57e456cb6d6b2adc Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Wed, 20 May 2020 22:30:34 -0500 Subject: [PATCH 32/33] enable spanGaps for individual server graphs --- assets/js/servers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/js/servers.js b/assets/js/servers.js index b2f1776..b79945e 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -117,6 +117,7 @@ export class ServerRegistration { stroke: '#E9E581', width: 2, value: (_, raw) => formatNumber(raw) + ' Players', + spanGaps: true, points: { show: false } From a6974e201301f8178d569e89da49b8e049a20e94 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Wed, 20 May 2020 23:10:27 -0500 Subject: [PATCH 33/33] update docs/CHANGELOG.md release date --- docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cd78a49..a3db297 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,4 +1,4 @@ -**5.5.0** *(May 11 2020)* +**5.5.0** *(May 20 2020)* **IMPORTANT** This update moves ping timestamps to a shared timestamp per round. Meaning that when pinging servers, each will share the same timestamp for that series of pings. The legacy backend used a timestamp per ping per series of pings. This means after updating Minetrack, the historical graph may render slightly inaccurate for the first 24 hours (or whatever your config.json->graphDuration is), and will automatically correct itself as it receives new updates. Don't worry.