From 79ca74fd94715d56c5964f84a44ffc915ada9493 Mon Sep 17 00:00:00 2001 From: Daniel Ledda Date: Tue, 30 Mar 2021 22:44:31 +0200 Subject: [PATCH] big update with better scales, massive ui additions, etc. --- app-dist/static/dashboard.js | 267 +++++++-------- app-dist/static/styles.css | 54 +++ dashboard/src/ClimateChart.ts | 267 --------------- dashboard/src/StateStore.ts | 141 ++++++-- dashboard/src/Timeseries.ts | 63 +++- dashboard/src/chart/Chart.ts | 308 ++++++++++++++++++ dashboard/src/chart/Scale.ts | 77 +++++ dashboard/src/climateTimeseries.ts | 56 ++++ dashboard/src/config.json | 4 +- dashboard/src/main.ts | 111 ++----- dashboard/src/ui-components/AppUI.ts | 10 +- .../src/ui-components/ClimateChartWidget.ts | 39 ++- .../src/ui-components/DisplayModeWidget.tsx | 93 +++++- dashboard/src/ui-components/LegendWidget.tsx | 65 ++++ .../ui-components/SelectDisplayModeWidget.tsx | 37 ++- dashboard/src/ui-components/TimerWidget.tsx | 7 +- 16 files changed, 1050 insertions(+), 549 deletions(-) delete mode 100644 dashboard/src/ClimateChart.ts create mode 100644 dashboard/src/chart/Chart.ts create mode 100644 dashboard/src/chart/Scale.ts create mode 100644 dashboard/src/climateTimeseries.ts create mode 100644 dashboard/src/ui-components/LegendWidget.tsx diff --git a/app-dist/static/dashboard.js b/app-dist/static/dashboard.js index 860e6eb..de7d6a5 100644 --- a/app-dist/static/dashboard.js +++ b/app-dist/static/dashboard.js @@ -10,73 +10,18 @@ /******/ "use strict"; /******/ var __webpack_modules__ = ({ -/***/ "./src/AppUI.ts": -/*!**********************!*\ - !*** ./src/AppUI.ts ***! - \**********************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _TimezoneWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./TimezoneWidget */ \"./src/TimezoneWidget.tsx\");\n/* harmony import */ var _DisplayModeWidget__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./DisplayModeWidget */ \"./src/DisplayModeWidget.tsx\");\n/* harmony import */ var _TimerWidget__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./TimerWidget */ \"./src/TimerWidget.tsx\");\n/* harmony import */ var _ClimateChartWidget__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./ClimateChartWidget */ \"./src/ClimateChartWidget.ts\");\n/* harmony import */ var _MessageOverlay__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./MessageOverlay */ \"./src/MessageOverlay.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./UIComponent */ \"./src/UIComponent.ts\");\n/* harmony import */ var _SelectDisplayModeWidget__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./SelectDisplayModeWidget */ \"./src/SelectDisplayModeWidget.tsx\");\n;\n\n\n\n\n\n\nclass AppUI extends _UIComponent__WEBPACK_IMPORTED_MODULE_5__.default {\n constructor() {\n super();\n this.element = document.createElement(\"div\");\n this.grid = document.createElement(\"div\");\n this.messageOverlay = new _MessageOverlay__WEBPACK_IMPORTED_MODULE_4__.default();\n this.setupGrid({ width: 5, height: 10 });\n this.element.append(Object.assign(document.createElement(\"h1\"), { innerText: \"Ledda's Room Climate\" }), this.grid, this.messageOverlay.current());\n this.element.className = \"center\";\n }\n setupGrid(size) {\n this.setupWidgets();\n this.grid.append(this.chartWidget.current(), this.displayModeSettingsWidget.current(), this.selectModeWidget.current(), this.timerWidget.current(), this.timezoneWidget.current());\n this.grid.className = \"main-content-grid\";\n this.grid.style.gridTemplateRows = `repeat(${size.height}, 1fr)`;\n this.grid.style.gridTemplateColumns = `repeat(${size.width}, 1fr)`;\n }\n setupWidgets() {\n this.displayModeSettingsWidget = new _DisplayModeWidget__WEBPACK_IMPORTED_MODULE_1__.default({\n row: \"auto\", col: 5, width: 1, height: 3,\n });\n this.selectModeWidget = new _SelectDisplayModeWidget__WEBPACK_IMPORTED_MODULE_6__.default({\n row: \"auto\", col: 5, width: 1, height: 2,\n });\n this.timezoneWidget = new _TimezoneWidget__WEBPACK_IMPORTED_MODULE_0__.default({\n row: \"auto\", col: 5, width: 1, height: 2,\n });\n this.timerWidget = new _TimerWidget__WEBPACK_IMPORTED_MODULE_2__.default({\n row: \"auto\", col: 5, width: 1, height: 3,\n });\n this.chartWidget = new _ClimateChartWidget__WEBPACK_IMPORTED_MODULE_3__.default({\n row: 1, col: 1, width: 4, height: 10,\n });\n }\n bootstrap(rootNode) {\n document.getElementById(rootNode).append(this.element);\n this.chartWidget.updateDimensions();\n }\n current() {\n return this.element;\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (AppUI);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/AppUI.ts?"); - -/***/ }), - /***/ "./src/ClimateChart.ts": /*!*****************************!*\ !*** ./src/ClimateChart.ts ***! \*****************************/ /*! namespace exports */ +/*! export ScaleId [provided] [no usage info] [missing usage info prevents renaming] */ /*! export default [provided] [no usage info] [missing usage info prevents renaming] */ /*! other exports [not provided] [no usage info] */ /*! runtime requirements: __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => /* binding */ ClimateChart\n/* harmony export */ });\nclass ClimateChart {\n constructor(context) {\n this.timeseries = [];\n this.lastMousePos = { x: 0, y: 0 };\n this.indexRange = { start: 0, stop: 0 };\n this.valRange = { high: -Infinity, low: Infinity };\n this.formatTimestamp = (timestamp) => new Date(timestamp * 1000).toLocaleTimeString();\n this.width = 0;\n this.height = 0;\n this.ctx = context;\n this.ctx.fillStyle = \"rgb(255,255,255)\";\n this.width = this.ctx.canvas.width;\n this.height = this.ctx.canvas.height;\n this.ctx.fillRect(0, 0, this.width, this.height);\n this.ctx.fill();\n this.ctx.translate(0.5, 0.5);\n this.ctx.canvas.onmousemove = (e) => this.handleMouseMove(e);\n }\n addTimeseries(timeseries) {\n this.timeseries.push(timeseries);\n }\n setRange(range) {\n this.indexRange.start = range.start;\n this.indexRange.stop = range.stop;\n }\n handleMouseMove(event) {\n const { left: canvasX, top: canvasY } = this.ctx.canvas.getBoundingClientRect();\n const x = event.clientX - canvasX;\n const y = event.clientY - canvasY;\n this.lastMousePos.x = x;\n this.lastMousePos.y = y;\n this.render();\n }\n render() {\n this.width = this.ctx.canvas.width;\n this.height = this.ctx.canvas.height;\n this.ctx.fillStyle = \"rgb(255,255,255)\";\n this.ctx.fillRect(0, 0, this.width, this.height);\n this.ctx.fill();\n this.setDisplayRange();\n this.renderScale();\n for (const timeseries of this.timeseries) {\n this.renderTimeseries(timeseries);\n }\n this.renderTooltips();\n }\n renderScale() {\n this.ctx.strokeStyle = \"rgb(230,230,230)\";\n this.ctx.fillStyle = \"black\";\n this.ctx.beginPath();\n const bottom = this.getY(this.valRange.low);\n this.ctx.moveTo(40, bottom);\n this.ctx.lineTo(this.width, bottom);\n this.ctx.fillText(this.valRange.low.toString(), 0, bottom + 4);\n const top = this.getY(this.valRange.high);\n this.ctx.moveTo(40, top);\n this.ctx.lineTo(this.width, top);\n this.ctx.fillText(this.valRange.high.toString(), 0, top + 4);\n const ticks = 20;\n const tickHeight = this.height / ticks;\n for (let i = 1; i < ticks; i++) {\n const pos = Math.floor(tickHeight * i);\n this.ctx.moveTo(40, pos);\n this.ctx.lineTo(this.width, pos);\n this.ctx.fillText(this.getValue(pos).toFixed(2), 0, pos + 4);\n }\n this.ctx.stroke();\n }\n setDisplayRange() {\n for (const timeseries of this.timeseries) {\n const extrema = timeseries.getExtrema();\n if (extrema.maxVal > this.valRange.high) {\n this.valRange.high = extrema.maxVal;\n }\n if (extrema.minVal < this.valRange.low) {\n this.valRange.low = extrema.minVal;\n }\n }\n }\n renderTooltips() {\n let bestDist = 20;\n let bestTimeseries = this.timeseries[0];\n let bestIndex = 0;\n let bestVal = 0;\n for (const timeseries of this.timeseries) {\n const cache = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop);\n for (let i = 0; i < cache.length; i += 2) {\n const x = this.getX(cache[i + 1]);\n const y = this.getY(cache[i]);\n const dist = Math.sqrt((y - this.lastMousePos.y) ** 2 + (x - this.lastMousePos.x) ** 2);\n if (dist < bestDist) {\n bestDist = dist;\n bestTimeseries = timeseries;\n bestIndex = cache[i + 1];\n bestVal = cache[i];\n }\n }\n }\n if (bestDist < 20) {\n this.renderTooltip(`${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`, this.getX(bestIndex), this.getY(bestVal));\n }\n }\n setTimestampFormatter(formatter) {\n this.formatTimestamp = formatter;\n }\n getX(index) {\n return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * this.width;\n }\n getY(value) {\n return this.height - (value - this.valRange.low) / (this.valRange.high - this.valRange.low) * this.height;\n }\n getIndex(x) {\n return (x / this.width) * this.indexRange.stop;\n }\n getValue(y) {\n return ((this.height - y) / this.height) * this.valRange.high;\n }\n renderTimeseries(timeseries) {\n const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop);\n this.ctx.strokeStyle = timeseries.getColour();\n let y = this.getY(timeseriesPoints[0]);\n let x = this.getX(timeseriesPoints[1]);\n this.ctx.moveTo(Math.floor(x), Math.floor(y));\n this.ctx.beginPath();\n this.ctx.lineTo(Math.floor(x), Math.floor(y));\n this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);\n for (let i = 2; i < timeseriesPoints.length; i += 2) {\n y = this.getY(timeseriesPoints[i]);\n x = this.getX(timeseriesPoints[i + 1]);\n this.ctx.lineTo(Math.floor(x), Math.floor(y));\n this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);\n }\n this.ctx.stroke();\n }\n renderTooltip(text, x, y) {\n this.ctx.strokeStyle = \"rgb(255,0,0)\";\n this.ctx.beginPath();\n this.ctx.ellipse(x, y, 5, 5, 0, 0, 2 * Math.PI);\n this.ctx.stroke();\n const measurements = this.ctx.measureText(text);\n const textHeight = measurements.actualBoundingBoxAscent + measurements.actualBoundingBoxDescent;\n const height = textHeight + 10;\n const width = measurements.width + 10;\n if (x + width > this.width) {\n x -= width;\n }\n if (y + height > this.height) {\n y -= height;\n }\n this.ctx.fillStyle = \"rgb(255,255,255)\";\n this.ctx.strokeStyle = \"rgb(0,0,0)\";\n this.ctx.fillRect(x, y, width, height);\n this.ctx.strokeRect(x, y, width, height);\n this.ctx.fillStyle = \"rgb(0,0,0)\";\n this.ctx.fillText(text, x + 5, y + textHeight + 5);\n }\n}\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ClimateChart.ts?"); - -/***/ }), - -/***/ "./src/ClimateChartWidget.ts": -/*!***********************************!*\ - !*** ./src/ClimateChartWidget.ts ***! - \***********************************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./GridWidget */ \"./src/GridWidget.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./UIComponent */ \"./src/UIComponent.ts\");\n/* harmony import */ var _ClimateChart__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./ClimateChart */ \"./src/ClimateChart.ts\");\n;\n\n\n\nclass ClimateChartWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_2__.default {\n constructor(gridProps) {\n super();\n this.chart = null;\n this.displayMode = \"pastMins\";\n this.canvasElement = document.createElement(\"canvas\");\n this.initialised = false;\n this.canvasElement.className = \"chart-canvas\";\n this.skeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_1__.default({\n ...gridProps,\n body: this.canvasElement,\n });\n const now = new Date().getTime() / 1000;\n this.latestSnapshotInChartTime = now - (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().minutesDisplayed * 60;\n this.setupListeners();\n }\n updateDimensions() {\n const skelStyle = getComputedStyle(this.skeleton.current());\n this.canvasElement.height = this.skeleton.current().clientHeight\n - Number(skelStyle.paddingTop.slice(0, -2))\n - Number(skelStyle.paddingBottom.slice(0, -2));\n this.canvasElement.width = this.skeleton.current().clientWidth\n - Number(skelStyle.paddingLeft.slice(0, -2))\n - Number(skelStyle.paddingRight.slice(0, -2));\n }\n setupListeners() {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"displayMode\", () => this.updateDisplayMode());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"minutesDisplayed\", () => this.rerender());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"displayWindow\", () => this.rerender());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().on(\"timeseriesUpdated\", () => this.rerender());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().on(\"newTimeseries\", (timeseries) => this.chart.addTimeseries(timeseries));\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"documentReady\", () => this.initChart());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"utcOffset\", () => this.updateTimezone());\n }\n updateTimezone() {\n const offset = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().utcOffset * 60 * 60 * 1000;\n this.chart.setTimestampFormatter((timestamp) => new Date(timestamp * 1000 + offset).toLocaleTimeString());\n }\n async initChart() {\n try {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().addLoad();\n const ctx = this.canvasElement.getContext(\"2d\", { alpha: false });\n this.chart = new _ClimateChart__WEBPACK_IMPORTED_MODULE_3__.default(ctx);\n for (const timeseries of (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().timeseries) {\n this.chart.addTimeseries(timeseries);\n }\n await this.rerender();\n this.initialised = true;\n }\n catch (e) {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().fatalError(e);\n }\n finally {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().finishLoad();\n }\n }\n async updateDisplayMode() {\n this.displayMode = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().displayMode;\n await this.rerender();\n }\n async rerender() {\n if (!this.initialised) {\n return;\n }\n let start;\n let stop;\n if (this.displayMode === \"window\") {\n start = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().displayWindow.start;\n stop = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().displayWindow.stop;\n }\n else if (this.displayMode === \"pastMins\") {\n const mins = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().minutesDisplayed;\n start = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime - mins * 60;\n stop = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime;\n }\n this.chart.setRange({ start, stop });\n this.chart.render();\n }\n current() {\n return this.skeleton.current();\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (ClimateChartWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ClimateChartWidget.ts?"); - -/***/ }), - -/***/ "./src/DisplayModeWidget.tsx": -/*!***********************************!*\ - !*** ./src/DisplayModeWidget.tsx ***! - \***********************************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./GridWidget */ \"./src/GridWidget.ts\");\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _JSXFactory__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./JSXFactory */ \"./src/JSXFactory.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./UIComponent */ \"./src/UIComponent.ts\");\n;\n\n\n\nclass DisplayModeWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_3__.default {\n constructor(gridProps) {\n super();\n this.mainDisplay = this.MainDisplay({ ctx: this });\n this.skeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_0__.default({\n ...gridProps,\n title: \"Displaying:\",\n body: this.mainDisplay,\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().subscribeStoreVal(\"minutesDisplayed\", () => this.updateDisplay());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().subscribeStoreVal(\"displayMode\", () => this.updateDisplay());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().subscribeStoreVal(\"displayWindow\", () => this.updateDisplay());\n }\n WindowStartTime({ ctx }) {\n ctx.windowStartTimeRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"display-mode-widget-date\" }, new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayWindow.start).toLocaleString()));\n return ctx.fromRef(ctx.windowStartTimeRef);\n }\n WindowStopTime({ ctx }) {\n ctx.windowStopTimeRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"display-mode-widget-date\" }, new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayWindow.stop).toLocaleString()));\n return ctx.fromRef(ctx.windowStopTimeRef);\n }\n MinutesCounter({ ctx, onclick }) {\n ctx.minsInputRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"input\", { value: (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString(), onblur: (e) => ctx.onMinutesCounterInputBlur(e) }));\n ctx.minsCounterRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"min-count\", onclick: onclick }, (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString()));\n return ctx.fromRef(ctx.minsCounterRef);\n }\n onMinutesCounterInputBlur(e) {\n const input = Number(e.target.value);\n if (!isNaN(input)) {\n if (input >= 1) {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setMinutesDisplayed(input);\n }\n }\n else {\n e.target.value = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString();\n }\n this.fromRef(this.minsInputRef).replaceWith(this.fromRef(this.minsCounterRef));\n }\n MinutesDisplay({ ctx }) {\n return (_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"display-mode-widget-mins\" },\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null, \"Last\"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinusButton, { onclick: () => {\n const mins = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().minutesDisplayed;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setMinutesDisplayed(mins - 1);\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinutesCounter, { ctx: ctx, onclick: () => ctx.onMinutesCounterClick() }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.PlusButton, { onclick: () => {\n const mins = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().minutesDisplayed;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setMinutesDisplayed(mins + 1);\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null, \"minutes\")));\n }\n onMinutesCounterClick() {\n const input = this.fromRef(this.minsInputRef);\n this.fromRef(this.minsCounterRef).replaceWith(input);\n input.focus();\n input.selectionStart = 0;\n input.selectionEnd = input.value.length;\n }\n MinusButton(props) {\n return _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"minus-button\", onclick: props.onclick });\n }\n PlusButton(props) {\n return _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"plus-button\", onclick: props.onclick });\n }\n WindowedDisplay({ ctx }) {\n return (_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null,\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null, \"From\"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinusButton, { onclick: () => {\n const displayWindow = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().displayWindow;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayWindow({ start: displayWindow.start - 60, stop: displayWindow.stop });\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.WindowStartTime, { ctx: ctx }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.PlusButton, { onclick: () => {\n const displayWindow = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().displayWindow;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayWindow({ start: displayWindow.start + 60, stop: displayWindow.stop });\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null, \"to\"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinusButton, { onclick: () => {\n const displayWindow = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().displayWindow;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayWindow({ start: displayWindow.start, stop: displayWindow.stop - 60 });\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.WindowStopTime, { ctx: ctx }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.PlusButton, { onclick: () => {\n const displayWindow = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().displayWindow;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayWindow({ start: displayWindow.start, stop: displayWindow.stop + 60 });\n } })));\n }\n MainDisplay({ ctx }) {\n const windowMode = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayMode === \"window\";\n ctx.windowedDisplayRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.WindowedDisplay, { ctx: ctx }));\n ctx.minsDisplayRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinutesDisplay, { ctx: ctx }));\n return _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"display-mode-widget\" }, windowMode\n ? ctx.fromRef(ctx.windowedDisplayRef)\n : ctx.fromRef(ctx.minsDisplayRef));\n }\n onSelectMode(mode) {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayMode(mode);\n }\n updateDisplay() {\n if ((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayMode === \"window\") {\n this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));\n this.fromRef(this.windowStartTimeRef).innerText = new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayWindow.start * 1000).toLocaleString();\n this.fromRef(this.windowStopTimeRef).innerText = new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayWindow.stop * 1000).toLocaleString();\n }\n else {\n this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef));\n this.fromRef(this.minsCounterRef).innerText = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString();\n this.fromRef(this.minsInputRef).value = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString();\n }\n }\n current() {\n return this.skeleton.current();\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (DisplayModeWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/DisplayModeWidget.tsx?"); - -/***/ }), - -/***/ "./src/GridWidget.ts": -/*!***************************!*\ - !*** ./src/GridWidget.ts ***! - \***************************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./UIComponent */ \"./src/UIComponent.ts\");\n;\nclass GridWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_0__.default {\n constructor(props) {\n super();\n this.container = document.createElement(\"div\");\n this.title = document.createElement(\"h2\");\n this.body = document.createElement(\"div\");\n this.container.className = `widget${props.className ? ` ${props.className}` : \"\"}`;\n this.title.className = \"widget-title\";\n this.body.className = \"widget-body\";\n this.setTitle(props.title);\n this.setPosition({ row: props.row, col: props.col });\n this.setSize({ width: props.width, height: props.height });\n if (props.title) {\n this.container.append(this.title);\n }\n if (props.body) {\n this.body.append(props.body);\n }\n this.container.append(this.body);\n }\n setPosition(pos) {\n this.container.style.gridRowStart = `${pos.row}`;\n this.container.style.gridColumnStart = `${pos.col}`;\n }\n setSize(size) {\n this.container.style.gridRowEnd = `span ${size.height}`;\n this.container.style.gridColumnEnd = `span ${size.width}`;\n }\n setTitle(newTitle) {\n this.title.innerText = newTitle;\n }\n replaceBody(newEl) {\n this.body.replaceWith(newEl);\n }\n current() {\n return this.container;\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (GridWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/GridWidget.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"ScaleId\": () => /* binding */ ScaleId,\n/* harmony export */ \"default\": () => /* binding */ ClimateChart\n/* harmony export */ });\nvar ScaleId;\n(function (ScaleId) {\n ScaleId[ScaleId[\"Left\"] = 0] = \"Left\";\n ScaleId[ScaleId[\"Right\"] = 1] = \"Right\";\n})(ScaleId || (ScaleId = {}));\nconst MIN_PIXELS_PER_POINT = 3;\nclass ClimateChart {\n constructor(context) {\n this.lastMousePos = { x: 0, y: 0 };\n this.indexRange = { start: 0, stop: 0 };\n this.margins = { top: 20, bottom: 20 };\n this.formatTimestamp = (timestamp) => new Date(timestamp * 1000).toLocaleTimeString();\n this.width = 0;\n this.height = 0;\n this.resolution = 1;\n this.ctx = context;\n this.ctx.fillStyle = \"rgb(255,255,255)\";\n this.updateDimensions();\n this.ctx.fillRect(0, 0, this.width, this.height);\n this.ctx.fill();\n this.ctx.translate(0.5, 0.5);\n this.ctx.canvas.onmousemove = (e) => this.handleMouseMove(e);\n this.leftScale = {\n timeseries: [],\n valRange: { high: -Infinity, low: Infinity },\n width: 0,\n };\n this.rightScale = {\n timeseries: [],\n valRange: { high: -Infinity, low: Infinity },\n width: 0,\n };\n }\n updateDimensions() {\n this.width = Number(getComputedStyle(this.ctx.canvas).width.slice(0, -2));\n this.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2));\n }\n addTimeseries(timeseries, scale) {\n if (scale === ScaleId.Left) {\n this.leftScale.timeseries.push(timeseries);\n }\n else {\n this.rightScale.timeseries.push(timeseries);\n }\n }\n setRange(range) {\n this.indexRange.start = range.start;\n this.indexRange.stop = range.stop;\n }\n handleMouseMove(event) {\n const { left: canvasX, top: canvasY } = this.ctx.canvas.getBoundingClientRect();\n this.lastMousePos.x = event.clientX - canvasX;\n this.lastMousePos.y = event.clientY - canvasY;\n this.render();\n }\n render() {\n this.updateDimensions();\n this.clearCanvas();\n this.updateResolution();\n this.setDisplayRangeForScale(this.leftScale);\n this.setDisplayRangeForScale(this.rightScale);\n this.renderRightScale();\n this.leftScale.timeseries.forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Left));\n this.rightScale.timeseries.forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Right));\n this.renderLeftScale();\n this.renderTooltips();\n }\n clearCanvas() {\n this.ctx.fillStyle = \"rgb(255,255,255)\";\n this.ctx.fillRect(0, 0, this.width, this.height);\n this.ctx.fill();\n }\n updateResolution() {\n const chartWidth = (this.width - this.rightScale.width - this.leftScale.width);\n const points = this.rightScale.timeseries[0].cachedBetween(this.indexRange.start, this.indexRange.stop).length / 2;\n const pixelsPerPoint = chartWidth / points;\n if (pixelsPerPoint < MIN_PIXELS_PER_POINT) {\n this.resolution = Math.ceil(MIN_PIXELS_PER_POINT / pixelsPerPoint);\n }\n else {\n this.resolution = 1;\n }\n }\n renderLeftScale() {\n this.ctx.fillStyle = \"rgb(255,255,255)\";\n this.ctx.fillRect(0, 0, this.leftScale.width, this.height);\n this.ctx.fill();\n this.ctx.strokeStyle = \"rgb(230,230,230)\";\n this.ctx.fillStyle = \"black\";\n const ticks = 20;\n const tickHeight = (this.leftScale.valRange.high - this.leftScale.valRange.low) / ticks;\n let currentTick = this.leftScale.valRange.low - tickHeight;\n for (let i = 0; i <= ticks; i++) {\n currentTick += tickHeight;\n const text = currentTick.toFixed(2);\n const textWidth = this.ctx.measureText(text).width;\n if (textWidth > this.leftScale.width) {\n this.leftScale.width = textWidth + 10;\n }\n const pos = Math.round(this.getY(currentTick, ScaleId.Left));\n this.ctx.fillText(text, 0, pos + 4);\n }\n }\n renderRightScale() {\n this.ctx.strokeStyle = \"rgb(230,230,230)\";\n this.ctx.fillStyle = \"black\";\n const ticks = 20;\n const tickHeight = (this.rightScale.valRange.high - this.rightScale.valRange.low) / ticks;\n let currentTick = this.rightScale.valRange.low - tickHeight;\n for (let i = 0; i <= ticks; i++) {\n currentTick += tickHeight;\n const pos = Math.round(this.getY(currentTick, ScaleId.Right));\n const text = currentTick.toFixed(2);\n const textWidth = this.ctx.measureText(text).width;\n if (textWidth > this.rightScale.width) {\n this.rightScale.width = textWidth;\n }\n this.ctx.fillText(text, this.width - textWidth, pos + 4);\n this.ctx.beginPath();\n this.ctx.moveTo(this.leftScale.width, pos);\n this.ctx.lineTo(this.width - textWidth - 5, pos);\n this.ctx.stroke();\n }\n }\n setDisplayRangeForScale(scale) {\n for (const timeseries of scale.timeseries) {\n const extrema = timeseries.getExtrema();\n if (extrema.maxVal > scale.valRange.high) {\n scale.valRange.high = extrema.maxVal;\n }\n if (extrema.minVal < scale.valRange.low) {\n scale.valRange.low = extrema.minVal;\n }\n }\n }\n renderTooltips(radius = 20) {\n let bestDist = radius;\n let bestTimeseries = this.rightScale.timeseries[0];\n let bestIndex = 0;\n let bestVal = 0;\n let bestScale = ScaleId.Right;\n for (const scaleId of [ScaleId.Left, ScaleId.Right]) {\n for (const timeseries of (scaleId === ScaleId.Right ? this.rightScale : this.leftScale).timeseries) {\n const cache = timeseries.cachedBetween(this.getIndex(this.lastMousePos.x - radius / 2), this.getIndex(this.lastMousePos.x + radius / 2));\n for (let i = 0; i < cache.length; i += 2) {\n const y = this.getY(cache[i], scaleId);\n if (y + radius / 2 >= this.lastMousePos.y && y - radius / 2 <= this.lastMousePos.y) {\n const x = this.getX(cache[i + 1]);\n const dist = Math.sqrt((y - this.lastMousePos.y) ** 2 + (x - this.lastMousePos.x) ** 2);\n if (dist < bestDist) {\n bestDist = dist;\n bestTimeseries = timeseries;\n bestIndex = cache[i + 1];\n bestVal = cache[i];\n bestScale = scaleId;\n }\n }\n }\n }\n }\n if (bestDist < 20) {\n this.renderTooltip(`${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`, this.getX(bestIndex), this.getY(bestVal, bestScale));\n }\n }\n setTimestampFormatter(formatter) {\n this.formatTimestamp = formatter;\n }\n getX(index) {\n return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * (this.width - this.rightScale.width - this.leftScale.width) + this.leftScale.width;\n }\n getY(value, scale) {\n const valRange = scale === ScaleId.Left ? this.leftScale.valRange : this.rightScale.valRange;\n return this.height - (value - valRange.low) / (valRange.high - valRange.low) * (this.height - this.margins.bottom - this.margins.top) - this.margins.top;\n }\n getIndex(x) {\n return ((x - this.leftScale.width) / (this.width - this.leftScale.width - this.rightScale.width)) * (this.indexRange.stop - this.indexRange.start) + this.indexRange.start;\n }\n getValue(y, scale) {\n const valRange = scale === ScaleId.Left ? this.leftScale.valRange : this.rightScale.valRange;\n return ((this.height - y) / this.height) * (valRange.high - valRange.low) + valRange.low;\n }\n renderTimeseries(timeseries, scale) {\n const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop);\n this.ctx.strokeStyle = timeseries.getColour();\n let y = this.getY(timeseriesPoints[0], scale);\n let x = this.getX(timeseriesPoints[1]);\n for (let i = 0; i < timeseriesPoints.length; i += 2 * this.resolution) {\n this.ctx.beginPath();\n this.ctx.moveTo(Math.round(x), Math.round(y));\n y = 0;\n x = 0;\n for (let j = 0; j < this.resolution * 2 && (j + 2 < timeseriesPoints.length); j += 2) {\n y += timeseriesPoints[i + j];\n x += timeseriesPoints[i + 1 + j];\n }\n y = this.getY(y / this.resolution, scale);\n x = this.getX(x / this.resolution);\n this.ctx.lineTo(Math.round(x), Math.round(y));\n this.ctx.stroke();\n if (this.resolution === 1) {\n this.ctx.beginPath();\n this.ctx.ellipse(x, y, 2, 2, 0, 0, 2 * Math.PI);\n this.ctx.stroke();\n }\n }\n }\n renderTooltip(text, x, y) {\n this.ctx.strokeStyle = \"rgb(255,0,0)\";\n this.ctx.beginPath();\n this.ctx.ellipse(x, y, 5, 5, 0, 0, 2 * Math.PI);\n this.ctx.stroke();\n const measurements = this.ctx.measureText(text);\n const textHeight = measurements.actualBoundingBoxAscent + measurements.actualBoundingBoxDescent;\n const height = textHeight + 10;\n const width = measurements.width + 10;\n if (x + width > this.width) {\n x -= width;\n }\n if (y + height > this.height) {\n y -= height;\n }\n this.ctx.fillStyle = \"rgb(255,255,255)\";\n this.ctx.strokeStyle = \"rgb(0,0,0)\";\n this.ctx.fillRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height));\n this.ctx.strokeRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height));\n this.ctx.fillStyle = \"rgb(0,0,0)\";\n this.ctx.fillText(text, Math.round(x + 5), Math.round(y + textHeight + 5));\n }\n}\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ClimateChart.ts?"); /***/ }), @@ -94,34 +39,6 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /***/ }), -/***/ "./src/MessageOverlay.ts": -/*!*******************************!*\ - !*** ./src/MessageOverlay.ts ***! - \*******************************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./UIComponent */ \"./src/UIComponent.ts\");\n;\n\nclass MessageOverlay extends _UIComponent__WEBPACK_IMPORTED_MODULE_1__.default {\n constructor() {\n super();\n this.showingError = false;\n this.build();\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"overlayText\", () => this.update());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"isLoading\", () => this.update());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"fatalError\", () => this.showError());\n this.update();\n }\n build() {\n this.element = document.createElement(\"div\");\n this.element.classList.add(\"overlay\", \"center\");\n this.textElement = document.createElement(\"span\");\n this.textElement.innerText = \"\";\n this.element.appendChild(this.textElement);\n }\n show() {\n this.element.classList.remove(\"hidden\");\n }\n hide() {\n this.element.classList.add(\"hidden\");\n }\n showError() {\n const err = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().fatalError;\n this.showingError = true;\n this.element.innerText = `${err.name}: ${err.message}!`;\n this.show();\n }\n update() {\n if (!this.showingError) {\n let text;\n if ((0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().isLoading) {\n text = \"Loading...\";\n }\n else if ((0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().overlayText) {\n text = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().overlayText;\n }\n if (text) {\n this.textElement.innerText = text;\n this.show();\n }\n else {\n this.hide();\n }\n }\n }\n current() {\n return this.element;\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (MessageOverlay);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/MessageOverlay.ts?"); - -/***/ }), - -/***/ "./src/SelectDisplayModeWidget.tsx": -/*!*****************************************!*\ - !*** ./src/SelectDisplayModeWidget.tsx ***! - \*****************************************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__, __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => /* binding */ SelectDisplayModeWidget\n/* harmony export */ });\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./UIComponent */ \"./src/UIComponent.ts\");\n/* harmony import */ var _JSXFactory__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./JSXFactory */ \"./src/JSXFactory.ts\");\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./GridWidget */ \"./src/GridWidget.ts\");\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./StateStore */ \"./src/StateStore.ts\");\n;\n\n\n\nclass SelectDisplayModeWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_0__.default {\n constructor(gridProps) {\n super();\n this.mainBody = this.MainBody({ ctx: this });\n this.gridWidgetSkeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_2__.default({\n ...gridProps,\n title: \"Display Mode:\",\n body: this.mainBody,\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_3__.AppStore)().subscribeStoreVal(\"displayMode\", () => this.update());\n }\n selectMode(mode) {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_3__.AppStore)().setDisplayMode(mode);\n }\n update() {\n const windowedMode = (0,_StateStore__WEBPACK_IMPORTED_MODULE_3__.getAppState)().displayMode === \"window\";\n this.fromRef(this.windowInputRef).checked = windowedMode;\n this.fromRef(this.minSpanInputRef).checked = !windowedMode;\n }\n MainBody({ ctx }) {\n const isInWindowMode = (0,_StateStore__WEBPACK_IMPORTED_MODULE_3__.getAppState)().displayMode === \"window\";\n ctx.windowInputRef = this.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"input\", { type: \"radio\", id: \"window\", name: \"display-mode\", checked: isInWindowMode, onclick: () => ctx.selectMode(\"window\") }));\n ctx.minSpanInputRef = this.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"input\", { type: \"radio\", id: \"min-span\", name: \"display-mode\", checked: !isInWindowMode, onclick: () => ctx.selectMode(\"pastMins\") }));\n return (_JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"div\", null,\n _JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"div\", null,\n this.fromRef(ctx.windowInputRef),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"label\", { htmlFor: \"window\" }, \"Time Window\")),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"div\", null,\n this.fromRef(ctx.minSpanInputRef),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"label\", { htmlFor: \"minSpan\" }, \"Rolling Minute Span\"))));\n }\n current() {\n return this.gridWidgetSkeleton.current();\n }\n}\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/SelectDisplayModeWidget.tsx?"); - -/***/ }), - /***/ "./src/StateStore.ts": /*!***************************!*\ !*** ./src/StateStore.ts ***! @@ -132,24 +49,10 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /*! export getAppState [provided] [no usage info] [missing usage info prevents renaming] */ /*! export initStore [provided] [no usage info] [missing usage info prevents renaming] */ /*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */ +/*! runtime requirements: __webpack_require__, __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"AppStateError\": () => /* binding */ AppStateError,\n/* harmony export */ \"initStore\": () => /* binding */ initStore,\n/* harmony export */ \"AppStore\": () => /* binding */ AppStore,\n/* harmony export */ \"getAppState\": () => /* binding */ getAppState\n/* harmony export */ });\nclass AppStateError extends Error {\n constructor(message) {\n super(message);\n this.name = \"AppStateError\";\n }\n}\nclass AppStateStore {\n constructor(initialState) {\n this.loaders = 0;\n this.state = initialState;\n const subscriptions = {};\n for (const key in this.state) {\n subscriptions[key] = [];\n }\n this.eventCallbacks = { newTimeseries: [], timeseriesUpdated: [] };\n this.subscriptions = subscriptions;\n this.init();\n setInterval(() => this.getNewTimeseriesData(), this.state.updateIntervalSeconds * 1000);\n }\n async init() {\n await this.updateTimeseriesFromSettings();\n await this.getNewTimeseriesData();\n }\n addTimeseries(timeseries) {\n if (this.state.timeseries.indexOf(timeseries) >= 0) {\n throw new AppStateError(\"Timeseries has already been added!\");\n }\n this.state.timeseries.push(timeseries);\n this.notifyStoreVal(\"timeseries\");\n this.eventCallbacks[\"newTimeseries\"].forEach(cb => cb(timeseries));\n this.updateTimeseriesFromSettings();\n }\n notifyStoreVal(subscribedValue, newValue, oldValue) {\n for (const subscriptionCallback of this.subscriptions[subscribedValue]) {\n new Promise(() => subscriptionCallback(newValue, oldValue));\n }\n }\n async updateTimeseriesFromSettings() {\n let start;\n let stop;\n if (this.state.displayMode === \"window\") {\n start = this.state.displayWindow.start;\n stop = this.state.displayWindow.stop;\n }\n else {\n start = this.state.lastUpdateTime - this.state.minutesDisplayed * 60;\n stop = this.state.lastUpdateTime;\n }\n this.addLoad();\n for (const timeseries of this.state.timeseries) {\n await timeseries.updateFromWindow(start, stop);\n }\n this.finishLoad();\n for (const timeseries of this.state.timeseries) {\n this.notifyStoreVal(\"timeseries\");\n this.eventCallbacks[\"timeseriesUpdated\"].forEach(cb => cb(timeseries));\n }\n }\n async getNewTimeseriesData() {\n this.addLoad();\n for (const timeseries of this.state.timeseries) {\n await timeseries.getLatest();\n }\n this.finishLoad();\n for (const timeseries of this.state.timeseries) {\n this.notifyStoreVal(\"timeseries\");\n this.eventCallbacks[\"timeseriesUpdated\"].forEach(cb => cb(timeseries));\n }\n this.setLastUpdateTime(new Date().getTime() / 1000);\n }\n getState() {\n return this.state;\n }\n subscribeStoreVal(dataName, callback) {\n this.subscriptions[dataName].push(callback);\n }\n on(event, callback) {\n this.eventCallbacks[event].push(callback);\n }\n setDisplayMode(mode) {\n this.state.displayMode = mode;\n this.notifyStoreVal(\"displayMode\");\n }\n setDisplayWindow(newWin) {\n if (newWin.start < newWin.stop) {\n this.state.displayWindow = { ...newWin };\n this.notifyStoreVal(\"displayWindow\");\n this.updateTimeseriesFromSettings();\n }\n else {\n throw new AppStateError(`Invalid display window from ${newWin.start} to ${newWin.stop}`);\n }\n }\n setMinutesDisplayed(mins) {\n if (mins > 0) {\n this.state.minutesDisplayed = Math.ceil(mins);\n this.notifyStoreVal(\"minutesDisplayed\");\n this.updateTimeseriesFromSettings();\n }\n else {\n throw new AppStateError(`Invalid minutes passed: ${mins}`);\n }\n }\n setUtcOffset(newOffset) {\n if (Math.floor(newOffset) === newOffset && newOffset <= 14 && newOffset >= -12) {\n this.state.utcOffset = newOffset;\n }\n else {\n console.warn(`Invalid UTC offset: ${newOffset}`);\n if (newOffset > 14) {\n this.state.utcOffset = 14;\n }\n else if (newOffset < -12) {\n this.state.utcOffset = -12;\n }\n else {\n this.state.utcOffset = Math.floor(newOffset);\n }\n }\n this.notifyStoreVal(\"utcOffset\");\n }\n setLastUpdateTime(newTime) {\n if (this.state.lastUpdateTime <= newTime) {\n this.state.lastUpdateTime = newTime;\n this.notifyStoreVal(\"lastUpdateTime\");\n }\n else {\n throw new AppStateError(`Bad new update time was before last update time. Old: ${this.state.lastUpdateTime}, New: ${newTime}`);\n }\n }\n setOverlayText(text) {\n this.state.overlayText = text;\n this.notifyStoreVal(\"overlayText\");\n }\n addLoad() {\n this.loaders += 1;\n this.state.isLoading = this.loaders > 0;\n this.notifyStoreVal(\"isLoading\");\n }\n finishLoad() {\n this.loaders -= 1;\n this.state.isLoading = this.loaders > 0;\n this.notifyStoreVal(\"isLoading\");\n }\n fatalError(err) {\n if (!this.state.fatalError) {\n this.state.fatalError = err;\n this.notifyStoreVal(\"fatalError\");\n }\n }\n setDocumentReady(isReady) {\n this.state.documentReady = isReady;\n this.notifyStoreVal(\"documentReady\");\n }\n}\nlet store;\nasync function initStore(initialState) {\n store = new AppStateStore(initialState);\n return store;\n}\nfunction AppStore() {\n if (store) {\n return store;\n }\n else {\n throw new AppStateError(\"Store not yet initialised!\");\n }\n}\nfunction getAppState() {\n if (store) {\n return store.getState();\n }\n else {\n throw new AppStateError(\"Store not yet initialised!\");\n }\n}\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/StateStore.ts?"); - -/***/ }), - -/***/ "./src/TimerWidget.tsx": -/*!*****************************!*\ - !*** ./src/TimerWidget.tsx ***! - \*****************************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./GridWidget */ \"./src/GridWidget.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./UIComponent */ \"./src/UIComponent.ts\");\n/* harmony import */ var _JSXFactory__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./JSXFactory */ \"./src/JSXFactory.ts\");\n;\n\n\n\nclass TimerWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_2__.default {\n constructor(gridProps) {\n super();\n this.display = _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(this.MainDisplay, { ctx: this });\n this.skeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_1__.default({\n ...gridProps,\n className: \"timer-widget\",\n title: \"Next update in:\",\n body: this.display,\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"lastUpdateTime\", () => this.resetTimer());\n setInterval(() => this.refreshTimer(), 10);\n this.resetTimer();\n }\n resetTimer() {\n this.nextUpdateTime = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime + (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().updateIntervalSeconds;\n this.fromRef(this.lastUpdateRef).innerText = new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime).toLocaleString();\n this.refreshTimer();\n }\n MainDisplay({ ctx }) {\n ctx.timerRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", { className: \"countdown\" }));\n ctx.lastUpdateRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"span\", { className: \"last-update\" }, new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime).toLocaleString()));\n return (_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", null,\n ctx.fromRef(ctx.timerRef),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", null,\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", { className: \"last-update\" }, \"Last update was at:\"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", null, ctx.fromRef(ctx.lastUpdateRef)))));\n }\n refreshTimer() {\n const now = new Date().getTime() / 1000;\n if (now <= this.nextUpdateTime) {\n this.fromRef(this.timerRef).innerText = `${(this.nextUpdateTime - now).toFixed(2)}s`;\n }\n else {\n this.fromRef(this.timerRef).innerText = \"0.00s\";\n }\n }\n current() {\n return this.skeleton.current();\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (TimerWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/TimerWidget.tsx?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"AppStateError\": () => /* binding */ AppStateError,\n/* harmony export */ \"initStore\": () => /* binding */ initStore,\n/* harmony export */ \"AppStore\": () => /* binding */ AppStore,\n/* harmony export */ \"getAppState\": () => /* binding */ getAppState\n/* harmony export */ });\n/* harmony import */ var _ClimateChart__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./ClimateChart */ \"./src/ClimateChart.ts\");\n;\nclass AppStateError extends Error {\n constructor(message) {\n super(message);\n this.name = \"AppStateError\";\n }\n}\nclass AppStateStore {\n constructor(initialState) {\n this.loaders = 0;\n this.state = initialState;\n const subscriptions = {};\n for (const key in this.state) {\n subscriptions[key] = [];\n }\n this.eventCallbacks = { newTimeseries: [], timeseriesUpdated: [] };\n this.subscriptions = subscriptions;\n this.init();\n setInterval(() => this.getNewTimeseriesData(), this.state.updateIntervalSeconds * 1000);\n }\n async init() {\n await this.updateTimeseriesFromSettings();\n await this.getNewTimeseriesData();\n }\n addTimeseries(timeseries, scale) {\n const group = scale === _ClimateChart__WEBPACK_IMPORTED_MODULE_0__.ScaleId.Left ? this.state.leftTimeseries : this.state.rightTimeseries;\n if (group.indexOf(timeseries) >= 0) {\n throw new AppStateError(\"Timeseries has already been added!\");\n }\n if (scale === _ClimateChart__WEBPACK_IMPORTED_MODULE_0__.ScaleId.Left) {\n group.push(timeseries);\n }\n else {\n group.push(timeseries);\n }\n this.notifyStoreVal(scale === _ClimateChart__WEBPACK_IMPORTED_MODULE_0__.ScaleId.Left ? \"leftTimeseries\" : \"rightTimeseries\");\n this.eventCallbacks[\"newTimeseries\"].forEach(cb => cb(timeseries, scale));\n this.updateTimeseriesFromSettings();\n }\n notifyStoreVal(subscribedValue, newValue, oldValue) {\n for (const subscriptionCallback of this.subscriptions[subscribedValue]) {\n new Promise(() => subscriptionCallback(newValue, oldValue));\n }\n }\n async updateTimeseriesFromSettings() {\n let start;\n let stop;\n if (this.state.displayMode === \"window\") {\n start = this.state.displayWindow.start;\n stop = this.state.displayWindow.stop;\n }\n else {\n start = this.state.lastUpdateTime - this.state.minutesDisplayed * 60;\n stop = this.state.lastUpdateTime;\n }\n this.addLoad();\n for (const timeseries of this.state.leftTimeseries) {\n await timeseries.updateFromWindow(start, stop);\n }\n for (const timeseries of this.state.rightTimeseries) {\n await timeseries.updateFromWindow(start, stop);\n }\n this.finishLoad();\n this.notifyAllTimeseriesUpdated();\n }\n async getNewTimeseriesData() {\n const updateTime = new Date().getTime() / 1000;\n this.addLoad();\n for (const timeseries of this.state.leftTimeseries) {\n await timeseries.getLatest();\n }\n for (const timeseries of this.state.rightTimeseries) {\n await timeseries.getLatest();\n }\n this.finishLoad();\n this.setLastUpdateTime(updateTime);\n this.notifyAllTimeseriesUpdated();\n }\n notifyAllTimeseriesUpdated() {\n for (const timeseries of this.state.leftTimeseries) {\n this.notifyStoreVal(\"leftTimeseries\");\n this.eventCallbacks[\"timeseriesUpdated\"].forEach(cb => cb(timeseries));\n }\n for (const timeseries of this.state.rightTimeseries) {\n this.notifyStoreVal(\"rightTimeseries\");\n this.eventCallbacks[\"timeseriesUpdated\"].forEach(cb => cb(timeseries));\n }\n }\n getState() {\n return this.state;\n }\n subscribeStoreVal(dataName, callback) {\n this.subscriptions[dataName].push(callback);\n }\n on(event, callback) {\n this.eventCallbacks[event].push(callback);\n }\n setDisplayMode(mode) {\n this.state.displayMode = mode;\n this.notifyStoreVal(\"displayMode\");\n }\n setDisplayWindow(newWin) {\n if (newWin.start < newWin.stop) {\n if (newWin.stop < this.state.lastUpdateTime) {\n this.state.displayWindow = { ...newWin };\n this.notifyStoreVal(\"displayWindow\");\n this.updateTimeseriesFromSettings();\n }\n }\n else {\n console.warn(`Invalid display window from ${newWin.start} to ${newWin.stop}`);\n }\n }\n setMinutesDisplayed(mins) {\n if (mins > 0) {\n this.state.minutesDisplayed = Math.ceil(mins);\n this.notifyStoreVal(\"minutesDisplayed\");\n this.updateTimeseriesFromSettings();\n }\n else {\n throw new AppStateError(`Invalid minutes passed: ${mins}`);\n }\n }\n setUtcOffset(newOffset) {\n if (Math.floor(newOffset) === newOffset && newOffset <= 14 && newOffset >= -12) {\n this.state.utcOffset = newOffset;\n }\n else {\n console.warn(`Invalid UTC offset: ${newOffset}`);\n if (newOffset > 14) {\n this.state.utcOffset = 14;\n }\n else if (newOffset < -12) {\n this.state.utcOffset = -12;\n }\n else {\n this.state.utcOffset = Math.floor(newOffset);\n }\n }\n this.notifyStoreVal(\"utcOffset\");\n }\n setLastUpdateTime(newTime) {\n if (this.state.lastUpdateTime <= newTime) {\n this.state.lastUpdateTime = newTime;\n this.notifyStoreVal(\"lastUpdateTime\");\n }\n else {\n throw new AppStateError(`Bad new update time was before last update time. Old: ${this.state.lastUpdateTime}, New: ${newTime}`);\n }\n }\n setOverlayText(text) {\n this.state.overlayText = text;\n this.notifyStoreVal(\"overlayText\");\n }\n addLoad() {\n this.loaders += 1;\n this.state.isLoading = this.loaders > 0;\n this.notifyStoreVal(\"isLoading\");\n }\n finishLoad() {\n this.loaders -= 1;\n this.state.isLoading = this.loaders > 0;\n this.notifyStoreVal(\"isLoading\");\n }\n fatalError(err) {\n if (!this.state.fatalError) {\n this.state.fatalError = err;\n this.notifyStoreVal(\"fatalError\");\n }\n }\n setDocumentReady(isReady) {\n this.state.documentReady = isReady;\n this.notifyStoreVal(\"documentReady\");\n }\n}\nlet store;\nasync function initStore(initialState) {\n store = new AppStateStore(initialState);\n return store;\n}\nfunction AppStore() {\n if (store) {\n return store;\n }\n else {\n throw new AppStateError(\"Store not yet initialised!\");\n }\n}\nfunction getAppState() {\n if (store) {\n return store.getState();\n }\n else {\n throw new AppStateError(\"Store not yet initialised!\");\n }\n}\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/StateStore.ts?"); /***/ }), @@ -163,35 +66,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /*! runtime requirements: __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\nclass Timeseries {\n constructor(name, loader) {\n this.fetching = false;\n this.extrema = {\n minVal: Infinity,\n maxVal: -Infinity,\n minIndex: Infinity,\n maxIndex: -Infinity,\n };\n this.cache = new Int32Array();\n this.loader = loader;\n this.name = name;\n this.colour = `rgb(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255})`;\n }\n getExtrema() {\n return Object.assign(this.extrema);\n }\n getName() {\n return this.name;\n }\n getCache() {\n return this.cache;\n }\n getColour() {\n return this.colour;\n }\n cachedBetween(start, stop) {\n if (this.cache.length <= 0) {\n return new Int32Array();\n }\n else {\n return this.cache.slice(this.findIndexInCache(start), this.findIndexInCache(stop));\n }\n }\n append(value, index) {\n if (this.cache.length < this.currentEndPointer + 2) {\n const newCache = new Int32Array(this.cache.length * 2);\n newCache.set(this.cache, 0);\n newCache.set([value, index], this.currentEndPointer);\n this.cache = newCache;\n }\n }\n async updateFromWindow(start, stop) {\n if (!this.fetching) {\n try {\n if (this.cache.length === 0) {\n this.fetching = true;\n await this.fullFetch(start, stop);\n }\n else if (this.cache[1] > start) {\n this.fetching = true;\n await this.fetchPrior(start);\n }\n else if (this.cache[this.currentEndPointer - 1] < stop) {\n this.fetching = true;\n await this.fetchAnterior(stop);\n }\n }\n catch (e) {\n throw new Error(`Error fetching timeseries data: ${e}`);\n }\n }\n this.fetching = false;\n }\n async getLatest() {\n this.fetching = true;\n try {\n await this.fetchAnterior(this.cache[this.currentEndPointer - 1]);\n }\n catch (e) {\n throw new Error(`Error fetching timeseries data: ${e}`);\n }\n this.fetching = false;\n }\n async fullFetch(start, stop) {\n try {\n this.cache = await this.loader(start, stop);\n this.currentEndPointer = this.cache.length;\n this.updateExtremaFrom(this.cache);\n }\n catch (e) {\n throw new Error(`Error fully fetching data: ${e}`);\n }\n }\n async fetchAnterior(after) {\n try {\n const doubleTimespan = 2 * (this.cache[this.currentEndPointer - 1] - this.cache[1]);\n const result = await this.loader(after, after + doubleTimespan);\n const newCache = new Int32Array(this.cache.length + result.length);\n newCache.set(this.cache, 0);\n newCache.set(result, this.currentEndPointer);\n this.cache = newCache;\n this.currentEndPointer += result.length;\n this.updateExtremaFrom(result);\n }\n catch (e) {\n throw new Error(`Error fetching anterior data: ${e}`);\n }\n }\n async fetchPrior(before) {\n try {\n const doubleTimespan = 2 * (this.cache[this.currentEndPointer - 1] - this.cache[1]);\n const result = await this.loader(before - doubleTimespan, before);\n const newCache = new Int32Array(this.cache.length + result.length);\n newCache.set(result, 0);\n newCache.set(this.cache, result.length);\n this.cache = newCache;\n this.currentEndPointer += result.length;\n this.updateExtremaFrom(result);\n }\n catch (e) {\n throw new Error(`Error fetching anterior data: ${e}`);\n }\n }\n updateExtremaFrom(data) {\n for (let i = 0; i < data.length; i += 2) {\n if (data[i] < this.extrema.minVal) {\n this.extrema.minVal = data[i];\n }\n if (data[i] > this.extrema.maxVal) {\n this.extrema.maxVal = data[i];\n }\n }\n for (let i = 1; i < this.cache.length; i += 2) {\n if (data[i] < this.extrema.minIndex) {\n this.extrema.minIndex = data[i];\n }\n if (data[i] > this.extrema.maxIndex) {\n this.extrema.maxIndex = data[i];\n }\n }\n }\n findIndexInCache(soughtIndex) {\n for (let i = 1; i < this.cache.length; i += 2) {\n if (soughtIndex < this.cache[i]) {\n return i - 1;\n }\n }\n return this.cache.length - 2;\n }\n findIndexInCacheBinary(soughtIndex, listStart = 0, listStop = (this.currentEndPointer / 2)) {\n if (listStop - listStart === 1) {\n return listStart;\n }\n else {\n const middle = Math.floor((listStop + listStart) / 2);\n const val = this.cache[middle * 2 + 1];\n if (val > soughtIndex) {\n return this.findIndexInCacheBinary(soughtIndex, listStart, middle);\n }\n else if (val < soughtIndex) {\n return this.findIndexInCacheBinary(soughtIndex, middle, listStop);\n }\n else {\n return middle;\n }\n }\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (Timeseries);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/Timeseries.ts?"); - -/***/ }), - -/***/ "./src/TimezoneWidget.tsx": -/*!********************************!*\ - !*** ./src/TimezoneWidget.tsx ***! - \********************************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./GridWidget */ \"./src/GridWidget.ts\");\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./UIComponent */ \"./src/UIComponent.ts\");\n/* harmony import */ var _JSXFactory__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./JSXFactory */ \"./src/JSXFactory.ts\");\n;\n\n\n\nclass TimezoneWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_2__.default {\n constructor(gridProps) {\n super();\n this.display = document.createElement(\"span\");\n this.display = _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(this.MainBody, { ctx: this });\n this.skeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_0__.default({\n ...gridProps,\n title: \"Displayed Timezone:\",\n body: this.display,\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().subscribeStoreVal(\"utcOffset\", () => this.updateDisplay());\n this.updateDisplay();\n }\n updateDisplay() {\n const offset = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().utcOffset;\n this.fromRef(this.timezoneDisplayRef).innerText = `${offset > 0 ? \"+\" : \"−\"} ${Math.abs(offset)}`;\n this.fromRef(this.timezoneInputRef).value = `${offset > 0 ? \"\" : \"-\"}${Math.abs(offset)}`;\n }\n MainBody({ ctx }) {\n return _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", { className: \"timezone-widget\", onclick: () => ctx.onTimezoneClick() },\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"span\", null, \"UTC \"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(ctx.TimezoneDisplay, { ctx: ctx }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"span\", null, \":00\"));\n }\n TimezoneDisplay({ ctx }) {\n ctx.timezoneDisplayRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"span\", null));\n ctx.timezoneInputRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"input\", { type: \"text\", onblur: () => ctx.onTimezoneInputBlur() }));\n return ctx.fromRef(ctx.timezoneDisplayRef);\n }\n onTimezoneInputBlur() {\n const input = this.fromRef(this.timezoneInputRef);\n const display = this.fromRef(this.timezoneDisplayRef);\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setUtcOffset(Number(input.value));\n input.replaceWith(display);\n this.updateDisplay();\n }\n onTimezoneClick() {\n const input = this.fromRef(this.timezoneInputRef);\n this.fromRef(this.timezoneDisplayRef).replaceWith(input);\n input.focus();\n input.selectionStart = 0;\n input.selectionEnd = input.value.length;\n }\n current() {\n return this.skeleton.current();\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (TimezoneWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/TimezoneWidget.tsx?"); - -/***/ }), - -/***/ "./src/UIComponent.ts": -/*!****************************!*\ - !*** ./src/UIComponent.ts ***! - \****************************/ -/*! namespace exports */ -/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ -/*! other exports [not provided] [no usage info] */ -/*! runtime requirements: __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => /* binding */ UIComponent\n/* harmony export */ });\nclass UIComponent {\n constructor() {\n this.id = UIComponent.componentCount;\n UIComponent.componentCount++;\n }\n makeRef(el) {\n UIComponent.reffedComponents.push(el);\n return UIComponent.reffedComponentCount++;\n }\n fromRef(ref) {\n return UIComponent.reffedComponents[ref] ?? null;\n }\n}\nUIComponent.componentCount = 0;\nUIComponent.reffedComponentCount = 0;\nUIComponent.reffedComponents = [];\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/UIComponent.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\nclass Timeseries {\n constructor(name, loader, tolerance) {\n this.fetching = false;\n this.extrema = {\n minVal: Infinity,\n maxVal: -Infinity,\n minIndex: Infinity,\n maxIndex: -Infinity,\n };\n this.cache = new Int32Array();\n this.loader = loader;\n this.name = name;\n this.tolerance = tolerance ?? 0;\n this.colour = `rgb(${Math.random() * 150},${Math.random() * 150},${Math.random() * 150})`;\n }\n getExtrema() {\n return Object.assign(this.extrema);\n }\n getName() {\n return this.name;\n }\n getCache() {\n return this.cache;\n }\n getColour() {\n return this.colour;\n }\n cachedBetween(start, stop) {\n if (this.cache.length <= 0) {\n return new Int32Array();\n }\n else {\n return this.cache.slice(this.findIndexInCache(start) - 1, this.findIndexInCache(stop));\n }\n }\n append(value, index) {\n if (this.cache.length < this.currentEndPointer + 2) {\n const newCache = new Int32Array(this.cache.length * 2);\n newCache.set(this.cache, 0);\n newCache.set([value, index], this.currentEndPointer);\n this.cache = newCache;\n }\n }\n async updateFromWindow(start, stop) {\n if (!this.fetching) {\n try {\n if (this.cache.length === 0) {\n this.fetching = true;\n await this.fullFetch(start, stop);\n }\n if (this.cache[1] > start + this.tolerance) {\n this.fetching = true;\n await this.fetchPrior(this.cache[1], start);\n }\n if (this.cache[this.currentEndPointer - 1] < stop - this.tolerance) {\n this.fetching = true;\n await this.fetchAfter(this.cache[this.currentEndPointer - 1], stop);\n }\n }\n catch (e) {\n throw new Error(`Error fetching timeseries data: ${e}`);\n }\n }\n this.fetching = false;\n }\n async getLatest() {\n this.fetching = true;\n try {\n await this.fetchAfter(this.cache[this.currentEndPointer - 1]);\n }\n catch (e) {\n throw new Error(`Error fetching timeseries data: ${e}`);\n }\n this.fetching = false;\n }\n async fullFetch(start, stop) {\n try {\n this.cache = await this.loader(start, stop);\n this.currentEndPointer = this.cache.length;\n this.updateExtremaFrom(this.cache);\n }\n catch (e) {\n throw new Error(`Error fully fetching data: ${e}`);\n }\n }\n async fetchAfter(after, atLeastUntil) {\n try {\n let forwardDist = 2 * (this.cache[this.currentEndPointer - 1] - this.cache[1]);\n if (atLeastUntil && (atLeastUntil > after + forwardDist)) {\n forwardDist = atLeastUntil - after;\n }\n const result = await this.loader(after, after + forwardDist);\n const newCache = new Int32Array(this.cache.length + result.length);\n newCache.set(this.cache, 0);\n newCache.set(result, this.currentEndPointer);\n this.cache = newCache;\n this.currentEndPointer += result.length;\n this.updateExtremaFrom(result);\n }\n catch (e) {\n throw new Error(`Error fetching anterior data: ${e}`);\n }\n }\n async fetchPrior(priorTo, atLeastUntil) {\n try {\n let backDist = 2 * (this.cache[this.currentEndPointer - 1] - this.cache[1]);\n if (atLeastUntil < priorTo - backDist) {\n backDist = priorTo - atLeastUntil;\n }\n const result = await this.loader(priorTo - backDist, priorTo);\n const newCache = new Int32Array(this.cache.length + result.length);\n newCache.set(result, 0);\n newCache.set(this.cache, result.length);\n this.cache = newCache;\n this.currentEndPointer += result.length;\n this.updateExtremaFrom(result);\n }\n catch (e) {\n throw new Error(`Error fetching anterior data: ${e}`);\n }\n }\n updateExtremaFrom(data) {\n for (let i = 0; i < data.length; i += 2) {\n if (data[i] < this.extrema.minVal) {\n this.extrema.minVal = data[i];\n }\n if (data[i] > this.extrema.maxVal) {\n this.extrema.maxVal = data[i];\n }\n }\n for (let i = 1; i < this.cache.length; i += 2) {\n if (data[i] < this.extrema.minIndex) {\n this.extrema.minIndex = data[i];\n }\n if (data[i] > this.extrema.maxIndex) {\n this.extrema.maxIndex = data[i];\n }\n }\n }\n findIndexInCache(soughtIndex) {\n return this.findIndexInCacheBinary(soughtIndex);\n }\n findIndexInCacheLinear(soughtIndex) {\n for (let i = 1; i < this.cache.length; i += 2) {\n if (soughtIndex < this.cache[i]) {\n return i > 3 ? i - 3 : i - 1;\n }\n }\n return this.cache.length - 2;\n }\n findIndexInCacheBinary(soughtIndex, listStart = 0, listStop = (this.currentEndPointer / 2)) {\n if (listStop - listStart === 1) {\n return listStart * 2 + 1;\n }\n else {\n const middle = Math.floor((listStop + listStart) / 2);\n const val = this.cache[middle * 2 + 1];\n if (val > soughtIndex) {\n return this.findIndexInCacheBinary(soughtIndex, listStart, middle);\n }\n else if (val < soughtIndex) {\n return this.findIndexInCacheBinary(soughtIndex, middle, listStop);\n }\n else {\n return middle * 2 + 1;\n }\n }\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (Timeseries);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/Timeseries.ts?"); /***/ }), @@ -219,7 +94,133 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.d, __webpack_require__.r, __webpack_require__.* */ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"config\": () => /* reexport default export from named module */ _config_json__WEBPACK_IMPORTED_MODULE_0__\n/* harmony export */ });\n/* harmony import */ var _config_json__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./config.json */ \"./src/config.json\");\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _AppUI__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./AppUI */ \"./src/AppUI.ts\");\n/* harmony import */ var _Timeseries__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./Timeseries */ \"./src/Timeseries.ts\");\n/* harmony import */ var _errors__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./errors */ \"./src/errors.ts\");\n;\n\n\n\n\n\nfunction getDisplayedMinutes() {\n let minutesDisplayed = _config_json__WEBPACK_IMPORTED_MODULE_0__.defaultMinuteSpan;\n const argsStart = window.location.search.search(/\\?minute-span=/);\n if (argsStart !== -1) {\n const parsedMins = Number(window.location.search.substring(13));\n if (!isNaN(parsedMins) && parsedMins > 0) {\n minutesDisplayed = parsedMins;\n }\n }\n return minutesDisplayed;\n}\nfunction getUtcOffset() {\n return -(new Date().getTimezoneOffset() / 60);\n}\nasync function init() {\n const now = new Date().getTime() / 1000;\n await (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.initStore)({\n overlayText: \"\",\n lastUpdateTime: now,\n minutesDisplayed: getDisplayedMinutes(),\n utcOffset: getUtcOffset(),\n dataEndpointBase: _config_json__WEBPACK_IMPORTED_MODULE_0__.dataEndpoint,\n isLoading: false,\n updateIntervalSeconds: _config_json__WEBPACK_IMPORTED_MODULE_0__.reloadIntervalSec,\n displayMode: \"pastMins\",\n fatalError: null,\n displayWindow: { start: now - getDisplayedMinutes() * 60, stop: now },\n documentReady: false,\n timeseries: [],\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().addTimeseries(new _Timeseries__WEBPACK_IMPORTED_MODULE_3__.default(\"temp\", (start, stop) => loadClimateTimeseriesData(\"temp\", start, stop)));\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().addTimeseries(new _Timeseries__WEBPACK_IMPORTED_MODULE_3__.default(\"humidity\", (start, stop) => loadClimateTimeseriesData(\"humidity\", start, stop)));\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().addTimeseries(new _Timeseries__WEBPACK_IMPORTED_MODULE_3__.default(\"co2\", (start, stop) => loadClimateTimeseriesData(\"co2\", start, stop)));\n const ui = new _AppUI__WEBPACK_IMPORTED_MODULE_2__.default();\n ui.bootstrap(\"root\");\n}\nasync function loadClimateTimeseriesData(dataType, start, stop) {\n const endpoint = `${(0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().dataEndpointBase}/timeseries/${dataType}${start && `?from=${start * 1000}`}${stop && `&to=${stop * 1000}`}`;\n try {\n const response = await fetch(endpoint, { headers: {\n \"Content-Type\": \"application/octet-stream\",\n } });\n const reader = await response.body.getReader();\n let receivedLength = 0;\n const chunks = [];\n let finishedReading = false;\n while (!finishedReading) {\n const chunk = await reader.read();\n finishedReading = chunk.done;\n if (!finishedReading) {\n const chunkBuffer = new Int32Array(chunk.value.buffer);\n chunks.push(chunkBuffer);\n receivedLength += chunkBuffer.length;\n }\n }\n const data = new Int32Array(receivedLength);\n let position = 0;\n for (const chunk of chunks) {\n data.set(chunk, position);\n position += chunk.length;\n }\n return data;\n }\n catch (e) {\n const message = \"Error fetching timerseries data from the server\";\n throw new _errors__WEBPACK_IMPORTED_MODULE_4__.ClayPIDashboardError(`${message}: ${e}`, message);\n }\n}\ndocument.onreadystatechange = async () => {\n await init();\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDocumentReady(true);\n // @ts-ignore\n window.store = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)();\n document.onreadystatechange = null;\n};\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/main.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"config\": () => /* reexport default export from named module */ _config_json__WEBPACK_IMPORTED_MODULE_0__\n/* harmony export */ });\n/* harmony import */ var _config_json__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./config.json */ \"./src/config.json\");\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _ui_components_AppUI__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./ui-components/AppUI */ \"./src/ui-components/AppUI.ts\");\n/* harmony import */ var _Timeseries__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./Timeseries */ \"./src/Timeseries.ts\");\n/* harmony import */ var _errors__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./errors */ \"./src/errors.ts\");\n/* harmony import */ var _ClimateChart__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./ClimateChart */ \"./src/ClimateChart.ts\");\n;\n\n\n\n\n\n\nfunction getDisplayedMinutes() {\n let minutesDisplayed = _config_json__WEBPACK_IMPORTED_MODULE_0__.defaultMinuteSpan;\n const argsStart = window.location.search.search(/\\?minute-span=/);\n if (argsStart !== -1) {\n const parsedMins = Number(window.location.search.substring(13));\n if (!isNaN(parsedMins) && parsedMins > 0) {\n minutesDisplayed = parsedMins;\n }\n }\n return minutesDisplayed;\n}\nfunction getUtcOffset() {\n return -(new Date().getTimezoneOffset() / 60);\n}\nasync function init() {\n const now = new Date().getTime() / 1000;\n await (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.initStore)({\n overlayText: \"\",\n lastUpdateTime: now,\n minutesDisplayed: getDisplayedMinutes(),\n utcOffset: getUtcOffset(),\n dataEndpointBase: _config_json__WEBPACK_IMPORTED_MODULE_0__.dataEndpoint,\n isLoading: false,\n updateIntervalSeconds: _config_json__WEBPACK_IMPORTED_MODULE_0__.reloadIntervalSec,\n displayMode: \"pastMins\",\n fatalError: null,\n displayWindow: { start: now - getDisplayedMinutes() * 60, stop: now },\n documentReady: false,\n leftTimeseries: [],\n rightTimeseries: [],\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().addTimeseries(new _Timeseries__WEBPACK_IMPORTED_MODULE_3__.default(\"temp\", (start, stop) => loadClimateTimeseriesData(\"temp\", start, stop), (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().updateIntervalSeconds), _ClimateChart__WEBPACK_IMPORTED_MODULE_5__.ScaleId.Left);\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().addTimeseries(new _Timeseries__WEBPACK_IMPORTED_MODULE_3__.default(\"humidity\", (start, stop) => loadClimateTimeseriesData(\"humidity\", start, stop), (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().updateIntervalSeconds), _ClimateChart__WEBPACK_IMPORTED_MODULE_5__.ScaleId.Left);\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().addTimeseries(new _Timeseries__WEBPACK_IMPORTED_MODULE_3__.default(\"co2\", (start, stop) => loadClimateTimeseriesData(\"co2\", start, stop), (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().updateIntervalSeconds), _ClimateChart__WEBPACK_IMPORTED_MODULE_5__.ScaleId.Right);\n const ui = new _ui_components_AppUI__WEBPACK_IMPORTED_MODULE_2__.default();\n ui.bootstrap(\"root\");\n}\nasync function loadClimateTimeseriesData(dataType, start, stop) {\n const endpoint = `${(0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().dataEndpointBase}/timeseries/${dataType}${start && `?from=${start * 1000}`}${stop && `&to=${stop * 1000}`}`;\n try {\n const response = await fetch(endpoint, { headers: {\n \"Content-Type\": \"application/octet-stream\",\n } });\n const reader = await response.body.getReader();\n let receivedLength = 0;\n const chunks = [];\n let finishedReading = false;\n while (!finishedReading) {\n const chunk = await reader.read();\n finishedReading = chunk.done;\n if (!finishedReading) {\n const chunkBuffer = new Int32Array(chunk.value.buffer);\n chunks.push(chunkBuffer);\n receivedLength += chunkBuffer.length;\n }\n }\n const data = new Int32Array(receivedLength);\n let position = 0;\n for (const chunk of chunks) {\n data.set(chunk, position);\n position += chunk.length;\n }\n return data;\n }\n catch (e) {\n const message = \"Error fetching timerseries data from the server\";\n throw new _errors__WEBPACK_IMPORTED_MODULE_4__.ClayPIDashboardError(`${message}: ${e}`, message);\n }\n}\ndocument.onreadystatechange = async () => {\n await init();\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDocumentReady(true);\n // @ts-ignore\n window.store = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)();\n document.onreadystatechange = null;\n};\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/main.ts?"); + +/***/ }), + +/***/ "./src/ui-components/AppUI.ts": +/*!************************************!*\ + !*** ./src/ui-components/AppUI.ts ***! + \************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _TimezoneWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./TimezoneWidget */ \"./src/ui-components/TimezoneWidget.tsx\");\n/* harmony import */ var _DisplayModeWidget__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./DisplayModeWidget */ \"./src/ui-components/DisplayModeWidget.tsx\");\n/* harmony import */ var _TimerWidget__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./TimerWidget */ \"./src/ui-components/TimerWidget.tsx\");\n/* harmony import */ var _ClimateChartWidget__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./ClimateChartWidget */ \"./src/ui-components/ClimateChartWidget.ts\");\n/* harmony import */ var _MessageOverlay__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./MessageOverlay */ \"./src/ui-components/MessageOverlay.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./UIComponent */ \"./src/ui-components/UIComponent.ts\");\n/* harmony import */ var _SelectDisplayModeWidget__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./SelectDisplayModeWidget */ \"./src/ui-components/SelectDisplayModeWidget.tsx\");\n;\n\n\n\n\n\n\nclass AppUI extends _UIComponent__WEBPACK_IMPORTED_MODULE_5__.default {\n constructor() {\n super();\n this.element = document.createElement(\"div\");\n this.grid = document.createElement(\"div\");\n this.messageOverlay = new _MessageOverlay__WEBPACK_IMPORTED_MODULE_4__.default();\n this.setupGrid({ width: 5, height: 10 });\n this.element.append(Object.assign(document.createElement(\"h1\"), { innerText: \"Ledda's Room Climate\" }), this.grid, this.messageOverlay.current());\n this.element.className = \"center\";\n }\n setupGrid(size) {\n this.setupWidgets();\n this.grid.append(this.chartWidget.current(), this.displayModeSettingsWidget.current(), this.selectModeWidget.current(), this.timerWidget.current(), this.timezoneWidget.current());\n this.grid.className = \"main-content-grid\";\n this.grid.style.gridTemplateRows = `repeat(${size.height}, 1fr)`;\n this.grid.style.gridTemplateColumns = `repeat(${size.width}, 1fr)`;\n }\n setupWidgets() {\n this.displayModeSettingsWidget = new _DisplayModeWidget__WEBPACK_IMPORTED_MODULE_1__.default({\n row: \"auto\", col: 5, width: 1, height: 3,\n });\n this.selectModeWidget = new _SelectDisplayModeWidget__WEBPACK_IMPORTED_MODULE_6__.default({\n row: \"auto\", col: 5, width: 1, height: 2,\n });\n this.timezoneWidget = new _TimezoneWidget__WEBPACK_IMPORTED_MODULE_0__.default({\n row: \"auto\", col: 5, width: 1, height: 2,\n });\n this.timerWidget = new _TimerWidget__WEBPACK_IMPORTED_MODULE_2__.default({\n row: \"auto\", col: 5, width: 1, height: 3,\n });\n this.chartWidget = new _ClimateChartWidget__WEBPACK_IMPORTED_MODULE_3__.default({\n row: 1, col: 1, width: 4, height: 10,\n });\n }\n bootstrap(rootNode) {\n document.getElementById(rootNode).append(this.element);\n this.chartWidget.updateDimensions();\n }\n current() {\n return this.element;\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (AppUI);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/AppUI.ts?"); + +/***/ }), + +/***/ "./src/ui-components/ClimateChartWidget.ts": +/*!*************************************************!*\ + !*** ./src/ui-components/ClimateChartWidget.ts ***! + \*************************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./GridWidget */ \"./src/ui-components/GridWidget.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./UIComponent */ \"./src/ui-components/UIComponent.ts\");\n/* harmony import */ var _ClimateChart__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../ClimateChart */ \"./src/ClimateChart.ts\");\n;\n\n\n\nclass ClimateChartWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_2__.default {\n constructor(gridProps) {\n super();\n this.chart = null;\n this.displayMode = \"pastMins\";\n this.canvasElement = document.createElement(\"canvas\");\n this.initialised = false;\n this.canvasElement.className = \"chart-canvas\";\n this.skeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_1__.default({\n ...gridProps,\n body: this.canvasElement,\n });\n const now = new Date().getTime() / 1000;\n this.latestSnapshotInChartTime = now - (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().minutesDisplayed * 60;\n this.setupListeners();\n }\n updateDimensions() {\n const skelStyle = getComputedStyle(this.skeleton.current());\n this.canvasElement.height = this.skeleton.current().clientHeight\n - Number(skelStyle.paddingTop.slice(0, -2))\n - Number(skelStyle.paddingBottom.slice(0, -2));\n this.canvasElement.width = this.skeleton.current().clientWidth\n - Number(skelStyle.paddingLeft.slice(0, -2))\n - Number(skelStyle.paddingRight.slice(0, -2));\n }\n setupListeners() {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"displayMode\", () => this.updateDisplayMode());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"minutesDisplayed\", () => this.rerender());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"displayWindow\", () => this.rerender());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().on(\"timeseriesUpdated\", () => this.rerender());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().on(\"newTimeseries\", (timeseries) => this.chart.addTimeseries(timeseries));\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"documentReady\", () => this.initChart());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"utcOffset\", () => this.updateTimezone());\n }\n updateTimezone() {\n const offset = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().utcOffset * 60 * 60 * 1000;\n this.chart.setTimestampFormatter((timestamp) => new Date(timestamp * 1000 + offset).toLocaleTimeString());\n }\n async initChart() {\n try {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().addLoad();\n const ctx = this.canvasElement.getContext(\"2d\", { alpha: false });\n this.chart = new _ClimateChart__WEBPACK_IMPORTED_MODULE_3__.default(ctx);\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().leftTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, _ClimateChart__WEBPACK_IMPORTED_MODULE_3__.ScaleId.Left));\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().rightTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, _ClimateChart__WEBPACK_IMPORTED_MODULE_3__.ScaleId.Right));\n await this.rerender();\n this.initialised = true;\n }\n catch (e) {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().fatalError(e);\n }\n finally {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().finishLoad();\n }\n }\n async updateDisplayMode() {\n this.displayMode = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().displayMode;\n await this.rerender();\n }\n async rerender() {\n if (!this.initialised) {\n return;\n }\n let start;\n let stop;\n if (this.displayMode === \"window\") {\n start = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().displayWindow.start;\n stop = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().displayWindow.stop;\n }\n else if (this.displayMode === \"pastMins\") {\n const mins = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().minutesDisplayed;\n start = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime - mins * 60;\n stop = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime;\n }\n this.chart.setRange({ start, stop });\n this.chart.render();\n }\n current() {\n return this.skeleton.current();\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (ClimateChartWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/ClimateChartWidget.ts?"); + +/***/ }), + +/***/ "./src/ui-components/DisplayModeWidget.tsx": +/*!*************************************************!*\ + !*** ./src/ui-components/DisplayModeWidget.tsx ***! + \*************************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./GridWidget */ \"./src/ui-components/GridWidget.ts\");\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _JSXFactory__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../JSXFactory */ \"./src/JSXFactory.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./UIComponent */ \"./src/ui-components/UIComponent.ts\");\n;\n\n\n\nclass DisplayModeWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_3__.default {\n constructor(gridProps) {\n super();\n this.mainDisplay = this.MainDisplay({ ctx: this });\n this.skeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_0__.default({\n ...gridProps,\n title: \"Displaying:\",\n body: this.mainDisplay,\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().subscribeStoreVal(\"minutesDisplayed\", () => this.updateDisplay());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().subscribeStoreVal(\"displayMode\", () => this.updateDisplay());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().subscribeStoreVal(\"displayWindow\", () => this.updateDisplay());\n }\n WindowStartTime({ ctx }) {\n ctx.windowStartTimeRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"display-mode-widget-date\" }, new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayWindow.start).toLocaleString()));\n return ctx.fromRef(ctx.windowStartTimeRef);\n }\n WindowStopTime({ ctx }) {\n ctx.windowStopTimeRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"display-mode-widget-date\" }, new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayWindow.stop).toLocaleString()));\n return ctx.fromRef(ctx.windowStopTimeRef);\n }\n MinutesCounter({ ctx, onclick }) {\n ctx.minsInputRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"input\", { value: (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString(), onblur: (e) => ctx.onMinutesCounterInputBlur(e) }));\n ctx.minsCounterRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"min-count\", onclick: onclick }, (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString()));\n return ctx.fromRef(ctx.minsCounterRef);\n }\n onMinutesCounterInputBlur(e) {\n const input = Number(e.target.value);\n if (!isNaN(input)) {\n if (input >= 1) {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setMinutesDisplayed(input);\n }\n }\n else {\n e.target.value = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString();\n }\n this.fromRef(this.minsInputRef).replaceWith(this.fromRef(this.minsCounterRef));\n }\n MinutesDisplay({ ctx }) {\n return (_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"display-mode-widget-mins\" },\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null, \"Last\"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinusButton, { onclick: () => {\n const mins = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().minutesDisplayed;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setMinutesDisplayed(mins - 1);\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinutesCounter, { ctx: ctx, onclick: () => ctx.onMinutesCounterClick() }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.PlusButton, { onclick: () => {\n const mins = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().minutesDisplayed;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setMinutesDisplayed(mins + 1);\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null, \"minutes\")));\n }\n onMinutesCounterClick() {\n const input = this.fromRef(this.minsInputRef);\n this.fromRef(this.minsCounterRef).replaceWith(input);\n input.focus();\n input.selectionStart = 0;\n input.selectionEnd = input.value.length;\n }\n MinusButton(props) {\n return _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"minus-button\", onclick: props.onclick });\n }\n PlusButton(props) {\n return _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"plus-button\", onclick: props.onclick });\n }\n WindowedDisplay({ ctx }) {\n return (_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null,\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null, \"From\"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinusButton, { onclick: () => {\n const displayWindow = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().displayWindow;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayWindow({ start: displayWindow.start - 60, stop: displayWindow.stop });\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.WindowStartTime, { ctx: ctx }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.PlusButton, { onclick: () => {\n const displayWindow = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().displayWindow;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayWindow({ start: displayWindow.start + 60, stop: displayWindow.stop });\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", null, \"to\"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinusButton, { onclick: () => {\n const displayWindow = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().displayWindow;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayWindow({ start: displayWindow.start, stop: displayWindow.stop - 60 });\n } }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.WindowStopTime, { ctx: ctx }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.PlusButton, { onclick: () => {\n const displayWindow = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().displayWindow;\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayWindow({ start: displayWindow.start, stop: displayWindow.stop + 60 });\n } })));\n }\n MainDisplay({ ctx }) {\n const windowMode = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayMode === \"window\";\n ctx.windowedDisplayRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.WindowedDisplay, { ctx: ctx }));\n ctx.minsDisplayRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(ctx.MinutesDisplay, { ctx: ctx }));\n return _JSXFactory__WEBPACK_IMPORTED_MODULE_2__.createElement(\"div\", { className: \"display-mode-widget\" }, windowMode\n ? ctx.fromRef(ctx.windowedDisplayRef)\n : ctx.fromRef(ctx.minsDisplayRef));\n }\n onSelectMode(mode) {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setDisplayMode(mode);\n }\n updateDisplay() {\n if ((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayMode === \"window\") {\n this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));\n this.fromRef(this.windowStartTimeRef).innerText = new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayWindow.start * 1000).toLocaleString();\n this.fromRef(this.windowStopTimeRef).innerText = new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().displayWindow.stop * 1000).toLocaleString();\n }\n else {\n this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef));\n this.fromRef(this.minsCounterRef).innerText = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString();\n this.fromRef(this.minsInputRef).value = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.getAppState)().minutesDisplayed.toString();\n }\n }\n current() {\n return this.skeleton.current();\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (DisplayModeWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/DisplayModeWidget.tsx?"); + +/***/ }), + +/***/ "./src/ui-components/GridWidget.ts": +/*!*****************************************!*\ + !*** ./src/ui-components/GridWidget.ts ***! + \*****************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./UIComponent */ \"./src/ui-components/UIComponent.ts\");\n;\nclass GridWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_0__.default {\n constructor(props) {\n super();\n this.container = document.createElement(\"div\");\n this.title = document.createElement(\"h2\");\n this.body = document.createElement(\"div\");\n this.container.className = `widget${props.className ? ` ${props.className}` : \"\"}`;\n this.title.className = \"widget-title\";\n this.body.className = \"widget-body\";\n this.setTitle(props.title);\n this.setPosition({ row: props.row, col: props.col });\n this.setSize({ width: props.width, height: props.height });\n if (props.title) {\n this.container.append(this.title);\n }\n if (props.body) {\n this.body.append(props.body);\n }\n this.container.append(this.body);\n }\n setPosition(pos) {\n this.container.style.gridRowStart = `${pos.row}`;\n this.container.style.gridColumnStart = `${pos.col}`;\n }\n setSize(size) {\n this.container.style.gridRowEnd = `span ${size.height}`;\n this.container.style.gridColumnEnd = `span ${size.width}`;\n }\n setTitle(newTitle) {\n this.title.innerText = newTitle;\n }\n replaceBody(newEl) {\n this.body.replaceWith(newEl);\n }\n current() {\n return this.container;\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (GridWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/GridWidget.ts?"); + +/***/ }), + +/***/ "./src/ui-components/MessageOverlay.ts": +/*!*********************************************!*\ + !*** ./src/ui-components/MessageOverlay.ts ***! + \*********************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./UIComponent */ \"./src/ui-components/UIComponent.ts\");\n;\n\nclass MessageOverlay extends _UIComponent__WEBPACK_IMPORTED_MODULE_1__.default {\n constructor() {\n super();\n this.showingError = false;\n this.build();\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"overlayText\", () => this.update());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"isLoading\", () => this.update());\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"fatalError\", () => this.showError());\n this.update();\n }\n build() {\n this.element = document.createElement(\"div\");\n this.element.classList.add(\"overlay\", \"center\");\n this.textElement = document.createElement(\"span\");\n this.textElement.innerText = \"\";\n this.element.appendChild(this.textElement);\n }\n show() {\n this.element.classList.remove(\"hidden\");\n }\n hide() {\n this.element.classList.add(\"hidden\");\n }\n showError() {\n const err = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().fatalError;\n this.showingError = true;\n this.element.innerText = `${err.name}: ${err.message}!`;\n this.show();\n }\n update() {\n if (!this.showingError) {\n let text;\n if ((0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().isLoading) {\n text = \"Loading...\";\n }\n else if ((0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().overlayText) {\n text = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().overlayText;\n }\n if (text) {\n this.textElement.innerText = text;\n this.show();\n }\n else {\n this.hide();\n }\n }\n }\n current() {\n return this.element;\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (MessageOverlay);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/MessageOverlay.ts?"); + +/***/ }), + +/***/ "./src/ui-components/SelectDisplayModeWidget.tsx": +/*!*******************************************************!*\ + !*** ./src/ui-components/SelectDisplayModeWidget.tsx ***! + \*******************************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__, __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => /* binding */ SelectDisplayModeWidget\n/* harmony export */ });\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./UIComponent */ \"./src/ui-components/UIComponent.ts\");\n/* harmony import */ var _JSXFactory__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../JSXFactory */ \"./src/JSXFactory.ts\");\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./GridWidget */ \"./src/ui-components/GridWidget.ts\");\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../StateStore */ \"./src/StateStore.ts\");\n;\n\n\n\nclass SelectDisplayModeWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_0__.default {\n constructor(gridProps) {\n super();\n this.mainBody = this.MainBody({ ctx: this });\n this.gridWidgetSkeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_2__.default({\n ...gridProps,\n title: \"Display Mode:\",\n body: this.mainBody,\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_3__.AppStore)().subscribeStoreVal(\"displayMode\", () => this.update());\n }\n selectMode(mode) {\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_3__.AppStore)().setDisplayMode(mode);\n }\n update() {\n const windowedMode = (0,_StateStore__WEBPACK_IMPORTED_MODULE_3__.getAppState)().displayMode === \"window\";\n this.fromRef(this.windowInputRef).checked = windowedMode;\n this.fromRef(this.minSpanInputRef).checked = !windowedMode;\n }\n MainBody({ ctx }) {\n const isInWindowMode = (0,_StateStore__WEBPACK_IMPORTED_MODULE_3__.getAppState)().displayMode === \"window\";\n ctx.windowInputRef = this.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"input\", { type: \"radio\", id: \"window\", name: \"display-mode\", checked: isInWindowMode, onclick: () => ctx.selectMode(\"window\") }));\n ctx.minSpanInputRef = this.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"input\", { type: \"radio\", id: \"min-span\", name: \"display-mode\", checked: !isInWindowMode, onclick: () => ctx.selectMode(\"pastMins\") }));\n return (_JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"div\", null,\n _JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"div\", null,\n this.fromRef(ctx.windowInputRef),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"label\", { htmlFor: \"window\" }, \"Time Window\")),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"div\", null,\n this.fromRef(ctx.minSpanInputRef),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_1__.createElement(\"label\", { htmlFor: \"minSpan\" }, \"Rolling Minute Span\"))));\n }\n current() {\n return this.gridWidgetSkeleton.current();\n }\n}\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/SelectDisplayModeWidget.tsx?"); + +/***/ }), + +/***/ "./src/ui-components/TimerWidget.tsx": +/*!*******************************************!*\ + !*** ./src/ui-components/TimerWidget.tsx ***! + \*******************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./GridWidget */ \"./src/ui-components/GridWidget.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./UIComponent */ \"./src/ui-components/UIComponent.ts\");\n/* harmony import */ var _JSXFactory__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../JSXFactory */ \"./src/JSXFactory.ts\");\n;\n\n\n\nclass TimerWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_2__.default {\n constructor(gridProps) {\n super();\n this.display = _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(this.MainDisplay, { ctx: this });\n this.skeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_1__.default({\n ...gridProps,\n className: \"timer-widget\",\n title: \"Next update in:\",\n body: this.display,\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.AppStore)().subscribeStoreVal(\"lastUpdateTime\", () => this.resetTimer());\n setInterval(() => this.refreshTimer(), 10);\n this.resetTimer();\n }\n resetTimer() {\n this.nextUpdateTime = (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime + (0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().updateIntervalSeconds;\n this.fromRef(this.lastUpdateRef).innerText = new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime * 1000).toLocaleString();\n this.refreshTimer();\n }\n MainDisplay({ ctx }) {\n ctx.timerRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", { className: \"countdown\" }));\n ctx.lastUpdateRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"span\", { className: \"last-update\" }, new Date((0,_StateStore__WEBPACK_IMPORTED_MODULE_0__.getAppState)().lastUpdateTime).toLocaleString()));\n return (_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", null,\n ctx.fromRef(ctx.timerRef),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", null,\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", { className: \"last-update\" }, \"Last update was at:\"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", null, ctx.fromRef(ctx.lastUpdateRef)))));\n }\n refreshTimer() {\n const now = new Date().getTime() / 1000;\n if (now <= this.nextUpdateTime) {\n this.fromRef(this.timerRef).innerText = `${(this.nextUpdateTime - now).toFixed(2)}s`;\n }\n else {\n this.fromRef(this.timerRef).innerText = \"0.00s\";\n }\n }\n current() {\n return this.skeleton.current();\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (TimerWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/TimerWidget.tsx?"); + +/***/ }), + +/***/ "./src/ui-components/TimezoneWidget.tsx": +/*!**********************************************!*\ + !*** ./src/ui-components/TimezoneWidget.tsx ***! + \**********************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__, __webpack_exports__, __webpack_require__.r, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => __WEBPACK_DEFAULT_EXPORT__\n/* harmony export */ });\n/* harmony import */ var _GridWidget__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./GridWidget */ \"./src/ui-components/GridWidget.ts\");\n/* harmony import */ var _StateStore__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ../StateStore */ \"./src/StateStore.ts\");\n/* harmony import */ var _UIComponent__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./UIComponent */ \"./src/ui-components/UIComponent.ts\");\n/* harmony import */ var _JSXFactory__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../JSXFactory */ \"./src/JSXFactory.ts\");\n;\n\n\n\nclass TimezoneWidget extends _UIComponent__WEBPACK_IMPORTED_MODULE_2__.default {\n constructor(gridProps) {\n super();\n this.display = document.createElement(\"span\");\n this.display = _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(this.MainBody, { ctx: this });\n this.skeleton = new _GridWidget__WEBPACK_IMPORTED_MODULE_0__.default({\n ...gridProps,\n title: \"Displayed Timezone:\",\n body: this.display,\n });\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().subscribeStoreVal(\"utcOffset\", () => this.updateDisplay());\n this.updateDisplay();\n }\n updateDisplay() {\n const offset = (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().getState().utcOffset;\n this.fromRef(this.timezoneDisplayRef).innerText = `${offset > 0 ? \"+\" : \"−\"} ${Math.abs(offset)}`;\n this.fromRef(this.timezoneInputRef).value = `${offset > 0 ? \"\" : \"-\"}${Math.abs(offset)}`;\n }\n MainBody({ ctx }) {\n return _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"div\", { className: \"timezone-widget\", onclick: () => ctx.onTimezoneClick() },\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"span\", null, \"UTC \"),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(ctx.TimezoneDisplay, { ctx: ctx }),\n _JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"span\", null, \":00\"));\n }\n TimezoneDisplay({ ctx }) {\n ctx.timezoneDisplayRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"span\", null));\n ctx.timezoneInputRef = ctx.makeRef(_JSXFactory__WEBPACK_IMPORTED_MODULE_3__.createElement(\"input\", { type: \"text\", onblur: () => ctx.onTimezoneInputBlur() }));\n return ctx.fromRef(ctx.timezoneDisplayRef);\n }\n onTimezoneInputBlur() {\n const input = this.fromRef(this.timezoneInputRef);\n const display = this.fromRef(this.timezoneDisplayRef);\n (0,_StateStore__WEBPACK_IMPORTED_MODULE_1__.AppStore)().setUtcOffset(Number(input.value));\n input.replaceWith(display);\n this.updateDisplay();\n }\n onTimezoneClick() {\n const input = this.fromRef(this.timezoneInputRef);\n this.fromRef(this.timezoneDisplayRef).replaceWith(input);\n input.focus();\n input.selectionStart = 0;\n input.selectionEnd = input.value.length;\n }\n current() {\n return this.skeleton.current();\n }\n}\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (TimezoneWidget);\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/TimezoneWidget.tsx?"); + +/***/ }), + +/***/ "./src/ui-components/UIComponent.ts": +/*!******************************************!*\ + !*** ./src/ui-components/UIComponent.ts ***! + \******************************************/ +/*! namespace exports */ +/*! export default [provided] [no usage info] [missing usage info prevents renaming] */ +/*! other exports [not provided] [no usage info] */ +/*! runtime requirements: __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => /* binding */ UIComponent\n/* harmony export */ });\nclass UIComponent {\n constructor() {\n this.id = UIComponent.componentCount;\n UIComponent.componentCount++;\n }\n makeRef(el) {\n UIComponent.reffedComponents.push(el);\n return UIComponent.reffedComponentCount++;\n }\n fromRef(ref) {\n return UIComponent.reffedComponents[ref] ?? null;\n }\n}\nUIComponent.componentCount = 0;\nUIComponent.reffedComponentCount = 0;\nUIComponent.reffedComponents = [];\n\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/ui-components/UIComponent.ts?"); /***/ }), @@ -237,7 +238,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac /*! runtime requirements: module */ /***/ ((module) => { -eval("module.exports = JSON.parse(\"{\\\"development\\\":true,\\\"defaultMinuteSpan\\\":60,\\\"reloadIntervalSec\\\":30,\\\"dataEndpoint\\\":\\\"/climate/api\\\"}\");\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/config.json?"); +eval("module.exports = JSON.parse(\"{\\\"development\\\":true,\\\"defaultMinuteSpan\\\":60,\\\"reloadIntervalSec\\\":30,\\\"dataEndpoint\\\":\\\"http://tortedda.local/climate/api\\\"}\");\n\n//# sourceURL=webpack://climate-ranger-frontend/./src/config.json?"); /***/ }) @@ -307,7 +308,7 @@ eval("module.exports = JSON.parse(\"{\\\"development\\\":true,\\\"defaultMinuteS /******/ /******/ /* webpack/runtime/getFullHash */ /******/ (() => { -/******/ __webpack_require__.h = () => "e6dd73ef8a516aebb679" +/******/ __webpack_require__.h = () => "747f0d1189db30276f63" /******/ })(); /******/ /******/ /* webpack/runtime/global */ diff --git a/app-dist/static/styles.css b/app-dist/static/styles.css index 5b1219f..5a99ec8 100644 --- a/app-dist/static/styles.css +++ b/app-dist/static/styles.css @@ -76,6 +76,7 @@ h1 { display: inline-block; opacity: 50%; font-size: 20px; + cursor: pointer; } .display-mode-widget-mins .minus-button, .display-mode-widget-mins .plus-button { @@ -114,12 +115,49 @@ h1 { margin: 10px 0 10px 0; background-color: white; } +.display-mode-widget-mins input { + border: none; + display: inline-block; + font-size: 30px; + width: 64px; + text-align: center; + margin: 10px 0 10px 0; + background-color: white; +} +.display-mode-widget input { + width: 12em; + text-align: center; +} .display-mode-widget-date { display: inline-block; font-size: 12px; line-height: 20px; margin: 5px 0 5px 0; + transition: background-color 100ms; +} +.display-mode-widget-date:hover { + background-color: #eaeaea; +} + +.display-mode-option * { + cursor: pointer; + transition: background-color 100ms; +} +.display-mode-option input { + display: none; +} +.display-mode-option.selected { + font-weight: bold; +} +.display-mode-option.selected:before { + content: "- "; +} +.display-mode-option.selected:after { + content: " -"; +} +.display-mode-option:hover { + background-color: #eaeaea; } .countdown { @@ -145,4 +183,20 @@ h1 { text-align: center; margin: 10px 0 10px 0; background-color: white; +} + +.legend-widget li:before { + content: "• "; + font-size: 12px; + font-weight: bold; +} +.legend-widget ul { + list-style: none; + margin: 0; + padding: 0; + text-align: center; +} +.legend-widget li.highlighted { + font-weight: bold; + cursor: pointer; } \ No newline at end of file diff --git a/dashboard/src/ClimateChart.ts b/dashboard/src/ClimateChart.ts deleted file mode 100644 index ed55fdb..0000000 --- a/dashboard/src/ClimateChart.ts +++ /dev/null @@ -1,267 +0,0 @@ -import Timeseries from "./Timeseries"; - -interface Scale { - timeseries: Timeseries[]; - valRange: {high: number, low: number}; - width: number; -} - -export enum ScaleId { - Left, - Right -} - -const MIN_PIXELS_PER_POINT = 3; - -export default class ClimateChart { - private readonly ctx: CanvasRenderingContext2D; - private readonly leftScale: Scale; - private readonly rightScale: Scale; - private readonly lastMousePos = {x: 0, y: 0}; - private readonly indexRange = {start: 0, stop: 0}; - private readonly margins = {top: 20, bottom: 20}; - private formatTimestamp = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString(); - private width = 0; - private height = 0; - private resolution = 1; - constructor(context: CanvasRenderingContext2D) { - this.ctx = context; - this.ctx.fillStyle = "rgb(255,255,255)"; - this.updateDimensions(); - this.ctx.fillRect(0, 0, this.width, this.height); - this.ctx.fill(); - this.ctx.translate(0.5, 0.5); - this.ctx.canvas.onmousemove = (e) => this.handleMouseMove(e); - this.leftScale = { - timeseries: [], - valRange: {high: -Infinity, low: Infinity}, - width: 0, - }; - this.rightScale = { - timeseries: [], - valRange: {high: -Infinity, low: Infinity}, - width: 0, - }; - } - - private updateDimensions() { - this.width = Number(getComputedStyle(this.ctx.canvas).width.slice(0, -2)); - this.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2)); - } - - addTimeseries(timeseries: Timeseries, scale?: ScaleId) { - if (scale === ScaleId.Left) { - this.leftScale.timeseries.push(timeseries); - } else { - this.rightScale.timeseries.push(timeseries); - } - } - - setRange(range: {start: number, stop: number}) { - this.indexRange.start = range.start; - this.indexRange.stop = range.stop; - } - - private handleMouseMove(event: MouseEvent) { - const {left: canvasX, top: canvasY} = this.ctx.canvas.getBoundingClientRect(); - const x = event.clientX - canvasX; - const y = event.clientY - canvasY; - this.lastMousePos.x = x; - this.lastMousePos.y = y; - this.render(); - } - - render() { - this.updateDimensions(); - this.clearCanvas(); - this.updateResolution(); - this.setDisplayRangeForScale(this.leftScale); - this.setDisplayRangeForScale(this.rightScale); - this.renderRightScale(); - this.leftScale.timeseries.forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Left)); - this.rightScale.timeseries.forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Right)); - this.renderLeftScale(); - this.renderTooltips(); - } - - private clearCanvas() { - this.ctx.fillStyle = "rgb(255,255,255)"; - this.ctx.fillRect(0, 0, this.width, this.height); - this.ctx.fill(); - } - - private updateResolution() { - const chartWidth = (this.width - this.rightScale.width - this.leftScale.width); - const points = this.rightScale.timeseries[0].cachedBetween(this.indexRange.start, this.indexRange.stop).length / 2; - const pixelsPerPoint = chartWidth / points; - if (pixelsPerPoint < MIN_PIXELS_PER_POINT) { - this.resolution = Math.ceil(MIN_PIXELS_PER_POINT / pixelsPerPoint); - } else { - this.resolution = 1; - } - } - - private renderLeftScale() { - this.ctx.fillStyle = "rgb(255,255,255)"; - this.ctx.fillRect(0, 0, this.leftScale.width, this.height); - this.ctx.fill(); - this.ctx.strokeStyle = "rgb(230,230,230)"; - this.ctx.fillStyle = "black"; - const ticks = 20; - const tickHeight = (this.leftScale.valRange.high - this.leftScale.valRange.low) / ticks; - let currentTick = this.leftScale.valRange.low - tickHeight; - for (let i = 0; i <= ticks; i++) { - currentTick += tickHeight; - const text = currentTick.toFixed(2); - const textWidth = this.ctx.measureText(text).width; - if (textWidth > this.leftScale.width) { - this.leftScale.width = textWidth + 10; - } - const pos = Math.round(this.getY(currentTick, ScaleId.Left)); - this.ctx.fillText(text, 0, pos + 4); - } - } - - private renderRightScale() { - this.ctx.strokeStyle = "rgb(230,230,230)"; - this.ctx.fillStyle = "black"; - const ticks = 20; - const tickHeight = (this.rightScale.valRange.high - this.rightScale.valRange.low) / ticks; - let currentTick = this.rightScale.valRange.low - tickHeight; - for (let i = 0; i <= ticks; i++) { - currentTick += tickHeight; - const pos = Math.round(this.getY(currentTick, ScaleId.Right)); - const text = currentTick.toFixed(2); - const textWidth = this.ctx.measureText(text).width; - if (textWidth > this.rightScale.width) { - this.rightScale.width = textWidth; - } - this.ctx.fillText(text, this.width - textWidth, pos + 4); - this.ctx.beginPath(); - this.ctx.moveTo(this.leftScale.width, pos); - this.ctx.lineTo(this.width - textWidth - 5, pos); - this.ctx.stroke(); - } - } - - private setDisplayRangeForScale(scale: Scale) { - for (const timeseries of scale.timeseries) { - const extrema = timeseries.getExtrema(); - if (extrema.maxVal > scale.valRange.high) { - scale.valRange.high = extrema.maxVal; - } - if (extrema.minVal < scale.valRange.low) { - scale.valRange.low = extrema.minVal; - } - } - } - - private renderTooltips(radius = 20) { - let bestDist = radius; - let bestTimeseries = this.rightScale.timeseries[0]; - let bestIndex = 0; - let bestVal = 0; - let bestScale = ScaleId.Right; - for (const scaleId of [ScaleId.Left, ScaleId.Right]) { - for (const timeseries of (scaleId === ScaleId.Right ? this.rightScale : this.leftScale).timeseries) { - const cache = timeseries.cachedBetween( - this.getIndex(this.lastMousePos.x - radius / 2), - this.getIndex(this.lastMousePos.x + radius / 2) - ); - for (let i = 0; i < cache.length; i += 2) { - const y = this.getY(cache[i], scaleId); - if (y + radius / 2 >= this.lastMousePos.y && y - radius / 2 <= this.lastMousePos.y) { - const x = this.getX(cache[i + 1]); - const dist = Math.sqrt((y - this.lastMousePos.y) ** 2 + (x - this.lastMousePos.x) ** 2); - if (dist < bestDist) { - bestDist = dist; - bestTimeseries = timeseries; - bestIndex = cache[i + 1]; - bestVal = cache[i]; - bestScale = scaleId; - } - } - } - } - } - if (bestDist < 20) { - this.renderTooltip( - `${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`, - this.getX(bestIndex), - this.getY(bestVal, bestScale), - ); - } - } - - setTimestampFormatter(formatter: (timestamp: number) => string) { - this.formatTimestamp = formatter; - } - - getX(index: number) { - return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * (this.width - this.rightScale.width - this.leftScale.width) + this.leftScale.width; - } - - getY(value: number, scale: ScaleId) { - const valRange = scale === ScaleId.Left ? this.leftScale.valRange : this.rightScale.valRange; - return this.height - (value - valRange.low) / (valRange.high - valRange.low) * (this.height - this.margins.bottom - this.margins.top) - this.margins.top; - } - - getIndex(x: number) { - return ((x - this.leftScale.width) / (this.width - this.leftScale.width - this.rightScale.width)) * (this.indexRange.stop - this.indexRange.start) + this.indexRange.start; - } - - getValue(y: number, scale: ScaleId) { - const valRange = scale === ScaleId.Left ? this.leftScale.valRange : this.rightScale.valRange; - return ((this.height - y) / this.height) * (valRange.high - valRange.low) + valRange.low; - } - - - private renderTimeseries(timeseries: Timeseries, scale: ScaleId) { - const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop); - this.ctx.strokeStyle = timeseries.getColour(); - let y = this.getY(timeseriesPoints[0], scale); - let x = this.getX(timeseriesPoints[1]); - for (let i = 0; i < timeseriesPoints.length; i += 2 * this.resolution) { - this.ctx.beginPath(); - this.ctx.moveTo(Math.round(x), Math.round(y)); - y = 0; - x = 0; - for (let j = 0; j < this.resolution * 2 && (j + 2 < timeseriesPoints.length); j += 2) { - y += timeseriesPoints[i + j]; - x += timeseriesPoints[i + 1 + j]; - } - y = this.getY(y / this.resolution, scale); - x = this.getX(x / this.resolution); - this.ctx.lineTo(Math.round(x), Math.round(y)); - this.ctx.stroke(); - if (this.resolution === 1) { - this.ctx.beginPath(); - this.ctx.ellipse(x, y, 2, 2, 0, 0, 2 * Math.PI); - this.ctx.stroke(); - } - } - } - - private renderTooltip(text: string, x: number, y: number) { - this.ctx.strokeStyle = "rgb(255,0,0)"; - this.ctx.beginPath(); - this.ctx.ellipse(x, y, 5, 5, 0, 0, 2 * Math.PI); - this.ctx.stroke(); - const measurements = this.ctx.measureText(text); - const textHeight = measurements.actualBoundingBoxAscent + measurements.actualBoundingBoxDescent; - const height = textHeight + 10; - const width = measurements.width + 10; - if (x + width > this.width) { - x -= width; - } - if (y + height > this.height) { - y -= height; - } - this.ctx.fillStyle = "rgb(255,255,255)"; - this.ctx.strokeStyle = "rgb(0,0,0)"; - this.ctx.fillRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height)); - this.ctx.strokeRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height)); - this.ctx.fillStyle = "rgb(0,0,0)"; - this.ctx.fillText(text, Math.round(x + 5), Math.round(y + textHeight + 5)); - } -} diff --git a/dashboard/src/StateStore.ts b/dashboard/src/StateStore.ts index 385da2a..bc2a849 100644 --- a/dashboard/src/StateStore.ts +++ b/dashboard/src/StateStore.ts @@ -1,5 +1,6 @@ import Timeseries from "./Timeseries"; -import {ScaleId} from "./ClimateChart"; +import {ScaleId} from "./chart/Chart"; +import config from "./config.json"; export class AppStateError extends Error { constructor(message: string) { @@ -11,9 +12,11 @@ export class AppStateError extends Error { export type DisplayMode = "window" | "pastMins"; export interface EventCallback { - newTimeseries: (timeseries: Timeseries) => void; - timeseriesUpdated: (timeseries: Timeseries, scale?: ScaleId) => void; + timeseriesUpdated: (timeseries: Timeseries) => void; + newTimeseries: (timeseries: Timeseries, scale?: ScaleId) => void; + stateChange: StateChangeCallback; } +type StateChangeCallback = (attrName?: K, oldVal?: T[K], newVal?: T[K]) => void; type EventCallbackListing = Record; export interface TimeWindow { @@ -35,12 +38,32 @@ interface AppState { displayMode: DisplayMode; fatalError: Error | null; documentReady: boolean; + highlightedTimeseries: string | null; } type StoreUpdateCallback = (newValue?: T, oldValue?: T) => void; type SubscriptionType = Record[]>; type IAppStateSubscriptions = SubscriptionType; +function newDefaultState(): AppState { + const now = new Date().getTime() / 1000; + return { + overlayText: "", + lastUpdateTime: now, + minutesDisplayed: config.defaultMinuteSpan, + utcOffset: -(new Date().getTimezoneOffset() / 60), + dataEndpointBase: config.dataEndpoint, + isLoading: false, + updateIntervalSeconds: config.reloadIntervalSec, + displayMode: "pastMins", + fatalError: null, + displayWindow: {start: now - config.defaultMinuteSpan * 60, stop: now}, + documentReady: false, + leftTimeseries: [], + rightTimeseries: [], + highlightedTimeseries: null, + }; +} class AppStateStore { private readonly subscriptions: IAppStateSubscriptions; @@ -48,16 +71,16 @@ class AppStateStore { private readonly state: AppState; private loaders = 0; - constructor(initialState: AppState) { - this.state = initialState; + constructor(initialState?: Partial) { + this.state = { ...newDefaultState(), ...initialState }; const subscriptions: Record unknown)[]> = {}; for (const key in this.state) { subscriptions[key] = []; } - this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: []}; + this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: [], stateChange: []}; this.subscriptions = subscriptions as IAppStateSubscriptions; this.init(); - setInterval(() => this.getNewTimeseriesData(), this.state.updateIntervalSeconds * 1000); + setInterval(() => this.getNewTimeseriesData().catch(e => AppStore().fatalError(e)), this.state.updateIntervalSeconds * 1000); } async init() { @@ -65,7 +88,7 @@ class AppStateStore { await this.getNewTimeseriesData(); } - addTimeseries(timeseries: Timeseries, scale?: ScaleId) { + addTimeseriesToScale(timeseries: Timeseries, scale?: ScaleId) { const group = scale === ScaleId.Left ? this.state.leftTimeseries : this.state.rightTimeseries; if (group.indexOf(timeseries) >= 0) { throw new AppStateError("Timeseries has already been added!"); @@ -76,13 +99,21 @@ class AppStateStore { group.push(timeseries); } this.notifyStoreVal(scale === ScaleId.Left ? "leftTimeseries" : "rightTimeseries"); - this.eventCallbacks["newTimeseries"].forEach(cb => cb(timeseries, scale)); + this.emit("newTimeseries", timeseries, scale); this.updateTimeseriesFromSettings(); } private notifyStoreVal(subscribedValue: T, newValue?: AppState[T], oldValue?: AppState[T]) { + this.emit("stateChange", subscribedValue, newValue, oldValue); for (const subscriptionCallback of this.subscriptions[subscribedValue]) { - new Promise(() => subscriptionCallback(newValue, oldValue)); + subscriptionCallback(newValue, oldValue); + } + } + + private emit(eventName: T, ...callbackArgs: Parameters) { + for (const sub of this.eventCallbacks[eventName]) { + // @ts-ignore + sub(...callbackArgs); } } @@ -97,11 +128,15 @@ class AppStateStore { stop = this.state.lastUpdateTime; } this.addLoad(); - for (const timeseries of this.state.leftTimeseries) { - await timeseries.updateFromWindow(start, stop); - } - for (const timeseries of this.state.rightTimeseries) { - await timeseries.updateFromWindow(start, stop); + try { + for (const timeseries of this.state.leftTimeseries) { + await timeseries.updateFromWindow(start, stop); + } + for (const timeseries of this.state.rightTimeseries) { + await timeseries.updateFromWindow(start, stop); + } + } catch (e) { + AppStore().fatalError(e); } this.finishLoad(); this.notifyAllTimeseriesUpdated(); @@ -110,11 +145,15 @@ class AppStateStore { private async getNewTimeseriesData() { const updateTime = new Date().getTime() / 1000; this.addLoad(); - for (const timeseries of this.state.leftTimeseries) { - await timeseries.getLatest(); - } - for (const timeseries of this.state.rightTimeseries) { - await timeseries.getLatest(); + try { + for (const timeseries of this.state.leftTimeseries) { + await timeseries.getLatest(); + } + for (const timeseries of this.state.rightTimeseries) { + await timeseries.getLatest(); + } + } catch (e) { + AppStore().fatalError(e); } this.finishLoad(); this.setLastUpdateTime(updateTime); @@ -124,11 +163,11 @@ class AppStateStore { private notifyAllTimeseriesUpdated() { for (const timeseries of this.state.leftTimeseries) { this.notifyStoreVal("leftTimeseries"); - this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries)); + this.emit("timeseriesUpdated", timeseries); } for (const timeseries of this.state.rightTimeseries) { this.notifyStoreVal("rightTimeseries"); - this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries)); + this.emit("timeseriesUpdated", timeseries); } } @@ -146,6 +185,7 @@ class AppStateStore { setDisplayMode(mode: DisplayMode) { this.state.displayMode = mode; + this.updateTimeseriesFromSettings(); this.notifyStoreVal("displayMode"); } @@ -224,12 +264,65 @@ class AppStateStore { this.state.documentReady = isReady; this.notifyStoreVal("documentReady"); } + + setHighlightedTimeseries(name: string | null) { + this.state.highlightedTimeseries = name; + this.notifyStoreVal("highlightedTimeseries", name); + } + + serialiseState(): string { + const stateStringParams = []; + if (this.state.displayMode === "pastMins") { + if (this.state.minutesDisplayed !== 60) { + stateStringParams.push( + `minutesDisplayed=${this.state.minutesDisplayed}`, + ); + } + } else { + stateStringParams.push( + `displayWindow=[${this.state.displayWindow.start},${this.state.displayWindow.stop}]`, + ); + } + if (this.state.utcOffset !== newDefaultState().utcOffset) { + stateStringParams.push( + `utcOffset=${this.state.utcOffset}`, + ); + } + return stateStringParams.join("&"); + } + + deserialise(serial: URLSearchParams) { + if (serial.get("minutesDisplayed") && serial.get("displayWindow")) { + console.warn("Options 'minutesDisplayed' and 'displayWindow' should not be used together. Defaulting to 'displayWindow'."); + } + if (serial.get("minutesDisplayed")) { + this.setDisplayMode("pastMins"); + this.setMinutesDisplayed(Number(serial.get("minutesDisplayed"))); + } + if (serial.get("utcOffset")) { + this.setUtcOffset(Number(serial.get("utcOffset"))); + } + if (serial.get("displayWindow")) { + const string = serial.get("displayWindow"); + const split = string.split(","); + if (split.length === 2) { + this.setDisplayMode("window"); + this.setDisplayWindow({ start: Number(split[0].slice(1)), stop: Number(split[1].slice(0, -1))}); + } + } + this.emit("stateChange"); + } } let store: AppStateStore; -export async function initStore(initialState: AppState) { - store = new AppStateStore(initialState); +export async function initStore(initialState?: Partial | URLSearchParams) { + if (initialState instanceof URLSearchParams) { + store = new AppStateStore(); + store.deserialise(initialState); + } else { + store = new AppStateStore(initialState); + } return store; } diff --git a/dashboard/src/Timeseries.ts b/dashboard/src/Timeseries.ts index a2b9c07..14fc668 100644 --- a/dashboard/src/Timeseries.ts +++ b/dashboard/src/Timeseries.ts @@ -1,5 +1,16 @@ type TimeseriesLoader = (start: number, stop: number) => Promise; +type TimeseriesOptions = { + name: string, + loader: TimeseriesLoader, + tolerance?: number, + valueRangeOverride?: { + high: number, + low: number, + }, + colour?: string, +}; + type Extrema = { minVal: number, maxVal: number, @@ -19,19 +30,48 @@ class Timeseries { minIndex: Infinity, maxIndex: -Infinity, }; + private valExtremaOverride?: { high: number, low: number }; private colour: string; private tolerance: number; - constructor(name: string, loader: TimeseriesLoader, tolerance?: number) { + constructor(options: TimeseriesOptions) { this.cache = new Int32Array(); - this.loader = loader; - this.name = name; - this.tolerance = tolerance ?? 0; - this.colour = `rgb(${Math.random() * 150},${Math.random() * 150},${Math.random() * 150})`; + this.loader = options.loader; + this.name = options.name; + this.tolerance = options.tolerance ?? 0; + let newColour: string | null; + if (options.colour) { + const style = new Option().style; + style.color = options.colour; + newColour = style.color === options.colour ? options.colour : null; + } + this.colour = newColour ?? `rgb(${Math.random() * 150},${Math.random() * 150},${Math.random() * 150})`; + if (options.valueRangeOverride) { + this.valExtremaOverride = { ...options.valueRangeOverride }; + } } getExtrema(): Extrema { - return Object.assign(this.extrema); + return Object.assign({}, this.extrema); + } + + getExtremaInRange(start: number, stop: number): Extrema { + let maxVal = -Infinity; + let minVal = Infinity; + for (let i = this.findIndexInCache(start) - 1; i < this.findIndexInCache(stop) - 1; i += 2) { + if (this.cache[i] < minVal) { + minVal = this.cache[i]; + } + if (this.cache[i] > maxVal) { + maxVal = this.cache[i]; + } + } + return { + minIndex: this.extrema.minIndex, + maxIndex: this.extrema.maxIndex, + maxVal: this.valExtremaOverride.high > maxVal ? this.valExtremaOverride.high : maxVal, + minVal: this.valExtremaOverride.low < minVal ? this.valExtremaOverride.low : minVal, + }; } getName() { @@ -46,13 +86,16 @@ class Timeseries { return this.colour; } - cachedBetween(start: number, stop: number): Int32Array { + cachedBetween(start: number, stop: number, blockSize = 1): Int32Array { if (this.cache.length <= 0) { return new Int32Array(); } else { + blockSize = Math.round(blockSize) * 2; + const cacheStartIndex = this.findIndexInCache(start); + const cacheStopIndex = this.findIndexInCache(stop); return this.cache.slice( - this.findIndexInCache(start) - 1, - this.findIndexInCache(stop) + (cacheStartIndex - (cacheStartIndex) % blockSize), + (cacheStopIndex + blockSize - (cacheStopIndex) % blockSize), ); } } @@ -140,7 +183,7 @@ class Timeseries { this.currentEndPointer += result.length; this.updateExtremaFrom(result); } catch (e) { - throw new Error(`Error fetching anterior data: ${e}`); + throw new Error(`Error fetching prior data: ${e}`); } } diff --git a/dashboard/src/chart/Chart.ts b/dashboard/src/chart/Chart.ts new file mode 100644 index 0000000..20ca2d5 --- /dev/null +++ b/dashboard/src/chart/Chart.ts @@ -0,0 +1,308 @@ +import Timeseries from "../Timeseries"; +import Scale from "./Scale"; + +export enum ScaleId { + Left, + Right +} + +export type Bounds = { + top: number; + left: number; + width: number; + height: number; +}; + +export interface ChartEventCallback { + scroll: (direction: -1 | 1, magnitude: number, index: number) => void; + mousemove: (coords: { x: number, y: number }) => void; + drag: (deltaX: number, deltaY: number, deltaIndex: number) => void; +} +type EventCallbackListing = Record; + +const MIN_PIXELS_PER_POINT = 5; + +export default class Chart { + private readonly ctx: CanvasRenderingContext2D; + private leftScale: Scale; + private rightScale: Scale; + private readonly lastMousePos = {x: 0, y: 0}; + private readonly indexRange = {start: 0, stop: 0}; + private readonly margins = {top: 20, bottom: 20, left: 10, right: 10}; + private readonly timeseries: Timeseries[] = []; + private chartBounds: Bounds; + private formatTimestamp = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString(); + private resolution = 1; + private subscriptions: EventCallbackListing; + private dragging = false; + private highlightedTimeseries: string | null = null; + + constructor(context: CanvasRenderingContext2D) { + this.subscriptions = {scroll: [], mousemove: [], drag: []}; + this.ctx = context; + this.initLayout(); + this.updateDimensions(); + this.ctx.fillStyle = "rgb(255,255,255)"; + this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); + this.ctx.fill(); + this.ctx.translate(0.5, 0.5); + this.ctx.canvas.onmousemove = (e) => this.handleMouseMove(e); + this.ctx.canvas.onmousedown = (e) => this.dragging = true; + this.ctx.canvas.onmouseup = (e) => this.dragging = false; + this.ctx.canvas.onmouseleave = (e) => this.dragging = false; + this.ctx.canvas.onmouseout = (e) => this.dragging = false; + this.ctx.canvas.onwheel = (e) => this.handleScroll(e); + } + + private initLayout() { + const leftScaleInitialWidth = 50; + const rightScaleInitialWidth = 50; + const verticalMargins = this.margins.bottom + this.margins.top; + const horizontalMargins = this.margins.left + this.margins.right; + this.leftScale = new Scale({ + top: this.margins.top, + left: this.margins.left, + height: this.ctx.canvas.height - verticalMargins, + width: leftScaleInitialWidth, + }); + this.chartBounds = { + top: this.margins.top, + left: this.margins.left + leftScaleInitialWidth, + height: this.ctx.canvas.height - verticalMargins, + width: this.ctx.canvas.width - (horizontalMargins + leftScaleInitialWidth + rightScaleInitialWidth), + }; + this.rightScale = new Scale({ + top: this.margins.top, + left: this.ctx.canvas.width - this.margins.right - rightScaleInitialWidth, + height: this.ctx.canvas.height - verticalMargins, + width: rightScaleInitialWidth, + }); + } + + private updateDimensions() { + this.chartBounds.width = Number(getComputedStyle(this.ctx.canvas).width.slice(0, -2)) - (this.margins.left + this.margins.right + this.rightScale.getBounds().width + this.leftScale.getBounds().width); + this.chartBounds.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2)) - (this.margins.bottom + this.margins.top); + } + + addTimeseries(timeseries: Timeseries, scale?: ScaleId) { + this.timeseries.push(timeseries); + if (scale === ScaleId.Left) { + this.leftScale.addTimeseries(timeseries); + } else { + this.rightScale.addTimeseries(timeseries); + } + } + + setRange(range: {start: number, stop: number}) { + this.indexRange.start = range.start; + this.indexRange.stop = range.stop; + } + + private handleMouseMove(event: MouseEvent) { + const {left: canvasX, top: canvasY} = this.ctx.canvas.getBoundingClientRect(); + const oldX = this.lastMousePos.x; + this.lastMousePos.x = event.clientX - canvasX; + this.lastMousePos.y = event.clientY - canvasY; + this.render(); + if (this.dragging) { + this.emit("drag", event.movementX, event.movementY, this.getIndex(oldX) - this.getIndex(this.lastMousePos.x)); + } + } + + private handleScroll(e: WheelEvent) { + this.emit("scroll", e.deltaY > 0 ? 1 : -1, Math.abs(e.deltaY), this.getIndex(this.lastMousePos.x)); + } + + private emit(eventName: T, ...callbackArgs: Parameters) { + for (const sub of this.subscriptions[eventName]) { + // @ts-ignore + sub(...callbackArgs); + } + } + + highlightTimeseries(name: string | null) { + if (!name) { + this.highlightedTimeseries = null; + this.render(); + return; + } + for (const timeseries of this.timeseries) { + if (timeseries.getName() === name) { + this.highlightedTimeseries = name; + this.render(); + return; + } + } + throw new Error(`The timeseries ${name} could not be highlighted because it doesn't exist on the chart!`); + } + + on(eventName: T, callback: ChartEventCallback[T]) { + this.subscriptions[eventName].push(callback); + } + + render() { + this.updateDimensions(); + this.clearCanvas(); + this.updateResolution(); + this.renderGuides(); + this.leftScale.updateIndexRange(this.indexRange); + this.rightScale.updateIndexRange(this.indexRange); + this.leftScale.listTimeseries().forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Left)); + this.rightScale.listTimeseries().forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Right)); + this.leftScale.render(this.ctx); + this.rightScale.render(this.ctx); + this.renderTooltips(); + } + + private clearCanvas() { + this.ctx.fillStyle = "rgb(255,255,255)"; + this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); + this.ctx.fill(); + } + + private updateResolution() { + const chartWidth = (this.chartBounds.width - this.rightScale.getBounds().width - this.leftScale.getBounds().width); + const points = this.timeseries[0]?.cachedBetween(this.indexRange.start, this.indexRange.stop, 1).length / 2 ?? 0; + const pixelsPerPoint = chartWidth / points; + if (pixelsPerPoint < MIN_PIXELS_PER_POINT) { + this.resolution = Math.ceil(MIN_PIXELS_PER_POINT / pixelsPerPoint); + } else { + this.resolution = 1; + } + } + + private renderGuides() { + this.ctx.strokeStyle = "rgb(230, 230, 230)"; + this.ctx.lineWidth = 1; + for (const tick of this.rightScale.getTicks()) { + const pos = this.rightScale.getY(tick); + this.ctx.beginPath(); + this.ctx.moveTo(this.chartBounds.left, pos); + this.ctx.lineTo(this.chartBounds.left + this.chartBounds.width, pos); + this.ctx.stroke(); + } + } + + private renderTooltips(radius = 20) { + let bestDist = radius; + let bestTimeseries = this.timeseries[0]; + let bestIndex = 0; + let bestVal = 0; + let bestScale = this.leftScale; + for (const scale of [this.leftScale, this.rightScale]) { + for (const timeseries of scale.listTimeseries()) { + const cache = timeseries.cachedBetween( + this.getIndex(this.lastMousePos.x - radius / 2), + this.getIndex(this.lastMousePos.x + radius / 2), + this.resolution + ); + for (let i = 0; i < cache.length; i += 2) { + const y = scale.getY(cache[i]); + if (y + radius / 2 >= this.lastMousePos.y && y - radius / 2 <= this.lastMousePos.y) { + const x = this.getX(cache[i + 1]); + const dist = Math.sqrt((y - this.lastMousePos.y) ** 2 + (x - this.lastMousePos.x) ** 2); + if (dist < bestDist) { + bestDist = dist; + bestTimeseries = timeseries; + bestIndex = cache[i + 1]; + bestVal = cache[i]; + bestScale = scale; + } + } + } + } + } + if (bestDist < 20) { + this.renderTooltip( + `${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`, + this.getX(bestIndex), + bestScale.getY(bestVal), + bestTimeseries.getColour() + ); + } + } + + setTimestampFormatter(formatter: (timestamp: number) => string) { + this.formatTimestamp = formatter; + } + + getX(index: number) { + return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * this.chartBounds.width + this.chartBounds.left; + } + + getY(value: number, scale: ScaleId) { + return (scale === ScaleId.Left ? this.leftScale : this.rightScale).getY(value); + } + + getIndex(x: number) { + return (x - this.leftScale.getBounds().width) / this.chartBounds.width * (this.indexRange.stop - this.indexRange.start) + this.indexRange.start; + } + + getValue(y: number, scale: ScaleId) { + return (scale === ScaleId.Left ? this.leftScale : this.rightScale).getValue(y); + } + + + private renderTimeseries(timeseries: Timeseries, scaleId: ScaleId) { + const scale = scaleId === ScaleId.Left ? this.leftScale : this.rightScale; + const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop, this.resolution); + this.ctx.strokeStyle = timeseries.getColour(); + this.ctx.lineWidth = timeseries.getName() === this.highlightedTimeseries ? 2 : 1; + let y = scale.getY(timeseriesPoints[0]); + let x = this.getX(timeseriesPoints[1]); + for (let i = 0; i < timeseriesPoints.length; i += 2 * this.resolution) { + this.ctx.beginPath(); + this.ctx.moveTo(Math.round(x), Math.round(y)); + y = 0; + x = 0; + for (let j = 0; j < this.resolution * 2 && (j + 2 < timeseriesPoints.length); j += 2) { + y += timeseriesPoints[i + j]; + x += timeseriesPoints[i + 1 + j]; + } + y = scale.getY(y / this.resolution); + x = this.getX(x / this.resolution); + this.ctx.lineTo(Math.round(x), Math.round(y)); + this.ctx.stroke(); + if (this.resolution === 1) { + this.ctx.beginPath(); + this.ctx.ellipse(x, y, 2, 2, 0, 0, 2 * Math.PI); + this.ctx.stroke(); + } + } + } + + private renderTooltip(text: string, x: number, y: number, markerColour: string) { + this.ctx.strokeStyle = "rgb(255,0,0)"; + this.ctx.beginPath(); + this.ctx.ellipse(x, y, 5, 5, 0, 0, 2 * Math.PI); + this.ctx.stroke(); + + const measurements = this.ctx.measureText(text); + const textHeight = measurements.actualBoundingBoxAscent + measurements.actualBoundingBoxDescent; + const height = textHeight + 10; + const width = measurements.width + 10 + 15; + y -= height + 2; + x += 2; + if (x + width > this.ctx.canvas.width) { + x -= width + 4; + } + if (y - height < 0) { + y += height + 4; + } + + this.ctx.fillStyle = "rgb(255,255,255)"; + this.ctx.strokeStyle = "rgb(0,0,0)"; + this.ctx.fillRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height)); + this.ctx.strokeRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height)); + + this.ctx.fillStyle = markerColour; + this.ctx.beginPath(); + this.ctx.arc(Math.round(x + 10), Math.round(y + height/2), 5, 0, Math.PI * 2); + this.ctx.fill(); + + this.ctx.fillStyle = "rgb(0,0,0)"; + this.ctx.textAlign = "left"; + this.ctx.fillText(text, Math.round(x + 20), Math.round(y + textHeight + 5)); + + } +} diff --git a/dashboard/src/chart/Scale.ts b/dashboard/src/chart/Scale.ts new file mode 100644 index 0000000..9e88ada --- /dev/null +++ b/dashboard/src/chart/Scale.ts @@ -0,0 +1,77 @@ +import Timeseries from "../Timeseries"; +import {Bounds} from "./Chart"; + +export default class Scale { + private readonly timeseries: Timeseries[] = []; + private valRange: {high: number, low: number} = {high: -Infinity, low: Infinity}; + private tickCache: number[] = []; + private tickCacheDirty = true; + private bounds: Bounds; + + constructor(bounds: Bounds) { + this.bounds = bounds; + } + + updateIndexRange(indexRange: {start: number, stop: number}) { + this.valRange.high = -Infinity; + this.valRange.low = Infinity; + for (const timeseries of this.timeseries) { + const extrema = timeseries.getExtremaInRange(indexRange.start, indexRange.stop); + if (extrema.maxVal > this.valRange.high) { + this.valRange.high = extrema.maxVal; + } + if (extrema.minVal < this.valRange.low) { + this.valRange.low = extrema.minVal; + } + } + this.tickCacheDirty = true; + } + + getBounds() { + return Object.assign({}, this.bounds); + } + + addTimeseries(timeseries: Timeseries) { + this.timeseries.push(timeseries); + } + + listTimeseries() { + return this.timeseries.slice(); + } + + render(ctx: CanvasRenderingContext2D) { + ctx.fillStyle = "rgb(255,255,255)"; + ctx.fillRect(this.bounds.left, this.bounds.top, this.bounds.width, this.bounds.height); + ctx.fillStyle = "black"; + ctx.textAlign = "center"; + for (const tick of this.getTicks()) { + const text = tick.toFixed(2); + const pos = Math.round(this.getY(tick)); + ctx.fillText(text, this.bounds.left + this.bounds.width/2, pos + 4); + } + } + + getTicks() { + if (this.tickCacheDirty) { + const ticks = []; + const tickCount = 20; + const tickHeight = (this.valRange.high - this.valRange.low) / tickCount; + let currentTick = this.valRange.low - tickHeight; + for (let i = 0; i <= tickCount; i++) { + currentTick += tickHeight; + ticks.push(currentTick); + } + this.tickCache = ticks; + this.tickCacheDirty = false; + } + return this.tickCache; + } + + getY(value: number) { + return this.bounds.top + this.bounds.height - (value - this.valRange.low) / (this.valRange.high - this.valRange.low) * this.bounds.height; + } + + getValue(y: number) { + return ((this.bounds.height + this.bounds.top - y) / this.bounds.height) * (this.valRange.high - this.valRange.low) + this.valRange.low; + } +} \ No newline at end of file diff --git a/dashboard/src/climateTimeseries.ts b/dashboard/src/climateTimeseries.ts new file mode 100644 index 0000000..087b98b --- /dev/null +++ b/dashboard/src/climateTimeseries.ts @@ -0,0 +1,56 @@ +import Timeseries from "./Timeseries"; +import {getAppState} from "./StateStore"; +import {ClayPIDashboardError} from "./errors"; + +export const newCo2Timeseries = (tolerance: number) => new Timeseries({ + name: "CO₂ (ppm)", + loader: (start, stop) => loadClimateTimeseriesData("co2", start, stop), + tolerance, + valueRangeOverride: { high: 800, low: 400 }, +}); + +export const newTempTimeseries = (tolerance: number) => new Timeseries({ + name: "Temperature (°C)", + loader: (start, stop) => loadClimateTimeseriesData("temp", start, stop), + tolerance, + valueRangeOverride: { high: 30, low: 10 }, +}); + +export const newHumidityTimeseries = (tolerance: number) => new Timeseries({ + name: "Humidity (%)", + loader: (start, stop) => loadClimateTimeseriesData("humidity", start, stop), + tolerance, + valueRangeOverride: { high: 75, low: 40 }, +}); + +async function loadClimateTimeseriesData(dataType: "temp" | "humidity" | "co2", start?: number, stop?: number) { + const endpoint = `${getAppState().dataEndpointBase}/timeseries/${dataType}${start && `?from=${start * 1000}`}${stop && `&to=${stop * 1000}`}`; + try { + const response = await fetch(endpoint, { headers: { + "Content-Type": "application/octet-stream", + }}); + const reader = await response.body.getReader(); + let receivedLength = 0; + const chunks = []; + let finishedReading = false; + while (!finishedReading) { + const chunk = await reader.read(); + finishedReading = chunk.done; + if (!finishedReading) { + chunks.push(chunk.value.buffer); + receivedLength += chunk.value.buffer.byteLength; + } + } + const data = new Uint8Array(receivedLength); + let position = 0; + for (const chunk of chunks) { + const chunkArray = new Uint8Array(chunk); + data.set(chunkArray, position); + position += chunkArray.length; + } + return new Int32Array(data.buffer); + } catch (e) { + const message = "timerseries data couldn't be loaded from the server"; + throw new ClayPIDashboardError(`${message}: ${e}`, message); + } +} \ No newline at end of file diff --git a/dashboard/src/config.json b/dashboard/src/config.json index aa37043..0bedc3f 100644 --- a/dashboard/src/config.json +++ b/dashboard/src/config.json @@ -1,6 +1,6 @@ { - "development": true, + "development": false, "defaultMinuteSpan": 60, "reloadIntervalSec": 30, - "dataEndpoint": "http://tortedda.local/climate/api" + "dataEndpoint": "/climate/api" } diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index 7236c76..88e115d 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -1,107 +1,42 @@ import config from "./config.json"; import {AppStore, getAppState, initStore} from "./StateStore"; import AppUI from "./ui-components/AppUI"; -import Timeseries from "./Timeseries"; -import {ClayPIDashboardError} from "./errors"; -import {ScaleId} from "./ClimateChart"; +import { + newCo2Timeseries, + newHumidityTimeseries, + newTempTimeseries, +} from "./climateTimeseries"; +import {ScaleId} from "./chart/Chart"; export {config}; -function getDisplayedMinutes() { - let minutesDisplayed = config.defaultMinuteSpan; - const argsStart = window.location.search.search(/\?minute-span=/); - if (argsStart !== -1) { - const parsedMins = Number(window.location.search.substring(13)); - if (!isNaN(parsedMins) && parsedMins > 0) { - minutesDisplayed = parsedMins; - } - } - return minutesDisplayed; -} - -function getUtcOffset() { - return -(new Date().getTimezoneOffset() / 60); -} - async function init() { - const now = new Date().getTime() / 1000; - await initStore({ - overlayText: "", - lastUpdateTime: now, - minutesDisplayed: getDisplayedMinutes(), - utcOffset: getUtcOffset(), - dataEndpointBase: config.dataEndpoint, - isLoading: false, - updateIntervalSeconds: config.reloadIntervalSec, - displayMode: "pastMins", - fatalError: null, - displayWindow: {start: now - getDisplayedMinutes() * 60, stop: now}, - documentReady: false, - leftTimeseries: [], - rightTimeseries: [], - }); - AppStore().addTimeseries( - new Timeseries( - "temp", - (start, stop) => loadClimateTimeseriesData("temp", start, stop), - getAppState().updateIntervalSeconds - ), - ScaleId.Left); - AppStore().addTimeseries( - new Timeseries( - "humidity", - (start, stop) => loadClimateTimeseriesData("humidity", start, stop), - getAppState().updateIntervalSeconds - ), - ScaleId.Left - ); - AppStore().addTimeseries( - new Timeseries( - "co2", - (start, stop) => loadClimateTimeseriesData("co2", start, stop), - getAppState().updateIntervalSeconds - ), - ScaleId.Right - ); + await initStore(new URLSearchParams(window.location.search)); + AppStore().addTimeseriesToScale(newCo2Timeseries(getAppState().updateIntervalSeconds), ScaleId.Right); + AppStore().addTimeseriesToScale(newTempTimeseries(getAppState().updateIntervalSeconds), ScaleId.Left); + AppStore().addTimeseriesToScale(newHumidityTimeseries(getAppState().updateIntervalSeconds), ScaleId.Left); const ui = new AppUI(); ui.bootstrap("root"); } -async function loadClimateTimeseriesData(dataType: "temp" | "humidity" | "co2", start?: number, stop?: number) { - const endpoint = `${getAppState().dataEndpointBase}/timeseries/${dataType}${start && `?from=${start * 1000}`}${stop && `&to=${stop * 1000}`}`; - try { - const response = await fetch(endpoint, { headers: { - "Content-Type": "application/octet-stream", - }}); - const reader = await response.body.getReader(); - let receivedLength = 0; - const chunks = []; - let finishedReading = false; - while (!finishedReading) { - const chunk = await reader.read(); - finishedReading = chunk.done; - if (!finishedReading) { - const chunkBuffer = new Int32Array(chunk.value.buffer); - chunks.push(chunkBuffer); - receivedLength += chunkBuffer.length; - } - } - const data = new Int32Array(receivedLength); - let position = 0; - for (const chunk of chunks) { - data.set(chunk, position); - position += chunk.length; - } - return data; - } catch (e) { - const message = "Error fetching timerseries data from the server"; - throw new ClayPIDashboardError(`${message}: ${e}`, message); - } +function updateUrlState() { + const appStateSerial = AppStore().serialiseState(); + const newUrl = `${window.location.pathname}${appStateSerial !== "" ? `?${appStateSerial}` : ""}`; + window.history.replaceState("", "", newUrl); +} + +let timer: ReturnType; +function debounce void>(func: F, timeout = 300){ + return (...args: Parameters[]) => { + clearTimeout(timer); + timer = setTimeout(() => { func.apply(this, args); }, timeout); + }; } document.onreadystatechange = async () => { await init(); AppStore().setDocumentReady(true); + AppStore().on("stateChange", () => debounce(() => updateUrlState())()); // @ts-ignore window.store = AppStore(); document.onreadystatechange = null; diff --git a/dashboard/src/ui-components/AppUI.ts b/dashboard/src/ui-components/AppUI.ts index 440f5ee..a36166c 100644 --- a/dashboard/src/ui-components/AppUI.ts +++ b/dashboard/src/ui-components/AppUI.ts @@ -6,12 +6,14 @@ import {GridSize} from "./GridWidget"; import MessageOverlay from "./MessageOverlay"; import UIComponent from "./UIComponent"; import SelectDisplayModeWidget from "./SelectDisplayModeWidget"; +import LegendWidget from "./LegendWidget"; class AppUI extends UIComponent { private timezoneWidget: TimezoneWidget; private selectModeWidget: SelectDisplayModeWidget; private displayModeSettingsWidget: DisplayModeWidget; private timerWidget: TimerWidget; + private legendWidget: LegendWidget; private chartWidget: ClimateChartWidget; private element: HTMLDivElement = document.createElement("div"); private grid: HTMLDivElement = document.createElement("div"); @@ -31,6 +33,7 @@ class AppUI extends UIComponent { private setupGrid(size: GridSize) { this.setupWidgets(); this.grid.append( + this.legendWidget.current(), this.chartWidget.current(), this.displayModeSettingsWidget.current(), this.selectModeWidget.current(), @@ -50,10 +53,13 @@ class AppUI extends UIComponent { row: "auto", col: 5, width: 1, height: 2, }); this.timezoneWidget = new TimezoneWidget({ - row: "auto", col: 5, width: 1, height: 2, + row: "auto", col: 5, width: 1, height: 1, }); this.timerWidget = new TimerWidget({ - row: "auto", col: 5, width: 1, height: 3, + row: "auto", col: 5, width: 1, height: 2, + }); + this.legendWidget = new LegendWidget({ + row: "auto", col: 5, width: 1, height: 2, }); this.chartWidget = new ClimateChartWidget({ row: 1, col: 1, width: 4, height: 10, diff --git a/dashboard/src/ui-components/ClimateChartWidget.ts b/dashboard/src/ui-components/ClimateChartWidget.ts index 66487e0..6b29faa 100644 --- a/dashboard/src/ui-components/ClimateChartWidget.ts +++ b/dashboard/src/ui-components/ClimateChartWidget.ts @@ -1,11 +1,11 @@ import {AppStore, DisplayMode, getAppState} from "../StateStore"; import GridWidget, {GridProps} from "./GridWidget"; import UIComponent from "./UIComponent"; -import ClimateChart, {ScaleId} from "../ClimateChart"; +import Chart, {ScaleId} from "../chart/Chart"; class ClimateChartWidget extends UIComponent { private readonly skeleton: GridWidget; - private chart: ClimateChart | null = null; + private chart: Chart | null = null; private initialised: boolean; private displayMode: DisplayMode = "pastMins"; private latestSnapshotInChartTime: number; @@ -22,6 +22,7 @@ class ClimateChartWidget extends UIComponent { const now = new Date().getTime() / 1000; this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60; this.setupListeners(); + this.updateDisplayMode(); } updateDimensions() { @@ -42,6 +43,36 @@ class ClimateChartWidget extends UIComponent { AppStore().on("newTimeseries", (timeseries) => this.chart.addTimeseries(timeseries)); AppStore().subscribeStoreVal("documentReady", () => this.initChart()); AppStore().subscribeStoreVal("utcOffset", () => this.updateTimezone()); + AppStore().subscribeStoreVal("highlightedTimeseries", (name) => this.chart.highlightTimeseries(name)); + } + + private handleScroll(direction: number, magnitude: number, index: number) { + let displayedWindow = getAppState().displayWindow; + if (getAppState().displayMode === "pastMins") { + AppStore().setDisplayMode("window"); + const now = new Date().getTime() / 1000; + displayedWindow = {start: now - getAppState().minutesDisplayed * 60, stop: now}; + } + const beforeIndex = index - displayedWindow.start; + const afterIndex = displayedWindow.stop - index; + const factor = direction === 1 ? 1.1 : 0.9; + const newBeforeIndex = factor * beforeIndex; + const newAfterIndex = factor * afterIndex; + AppStore().setDisplayWindow({ + start: index - newBeforeIndex, + stop: index + newAfterIndex, + }); + } + + private handleDrag(deltaX: number, deltaY: number, deltaIndex: number) { + if (getAppState().displayMode === "pastMins") { + AppStore().setDisplayMode("window"); + } + const displayWindow = getAppState().displayWindow; + AppStore().setDisplayWindow({ + start: displayWindow.start + deltaIndex, + stop: displayWindow.stop + deltaIndex, + }); } private updateTimezone() { @@ -53,9 +84,11 @@ class ClimateChartWidget extends UIComponent { try { AppStore().addLoad(); const ctx = this.canvasElement.getContext("2d", {alpha: false}); - this.chart = new ClimateChart(ctx); + this.chart = new Chart(ctx); getAppState().leftTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Left)); getAppState().rightTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Right)); + this.chart.on("scroll", (...args) => this.handleScroll(...args)); + this.chart.on("drag", (...args) => this.handleDrag(...args)); await this.rerender(); this.initialised = true; } catch (e) { diff --git a/dashboard/src/ui-components/DisplayModeWidget.tsx b/dashboard/src/ui-components/DisplayModeWidget.tsx index b158577..6edd5bb 100644 --- a/dashboard/src/ui-components/DisplayModeWidget.tsx +++ b/dashboard/src/ui-components/DisplayModeWidget.tsx @@ -7,7 +7,9 @@ class DisplayModeWidget extends UIComponent { private skeleton: GridWidget; private minsCounterRef: number; private windowStartTimeRef: number; + private windowStartTimeInputRef: number; private windowStopTimeRef: number; + private windowStopTimeInputRef: number; private windowedDisplayRef: number; private minsDisplayRef: number; private mainDisplay: HTMLElement; @@ -24,20 +26,35 @@ class DisplayModeWidget extends UIComponent { AppStore().subscribeStoreVal("minutesDisplayed", () => this.updateDisplay()); AppStore().subscribeStoreVal("displayMode", () => this.updateDisplay()); AppStore().subscribeStoreVal("displayWindow", () => this.updateDisplay()); + AppStore().subscribeStoreVal("utcOffset", () => this.updateDisplay()); + this.updateDisplay(); } private WindowStartTime({ ctx }: {ctx: DisplayModeWidget}) { + ctx.windowStartTimeInputRef = ctx.makeRef( ctx.onWindowStartInputBlur()} + />); ctx.windowStartTimeRef = ctx.makeRef(
- {new Date(getAppState().displayWindow.start).toLocaleString()} + className={"display-mode-widget-date"} + onwheel={(e: WheelEvent) => ctx.onStartTimeInputScroll(e)} + onclick={() => ctx.onWindowStartDisplayClick()}> + {new Date(getAppState().displayWindow.start + getAppState().utcOffset * 60 * 60 * 1000).toLocaleString()}
); return ctx.fromRef(ctx.windowStartTimeRef); } private WindowStopTime({ctx}: {ctx: DisplayModeWidget}) { + ctx.windowStopTimeInputRef = ctx.makeRef( ctx.onWindowStopInputBlur()} + />); ctx.windowStopTimeRef = ctx.makeRef(
- {new Date(getAppState().displayWindow.stop).toLocaleString()} + className={"display-mode-widget-date"} + onwheel={(e: WheelEvent) => ctx.onStopTimeInputScroll(e)} + onclick={() => ctx.onWindowStopDisplayClick()}> + {new Date(getAppState().displayWindow.stop + getAppState().utcOffset * 60 * 60 * 1000).toLocaleString()}
); return ctx.fromRef(ctx.windowStopTimeRef); } @@ -48,12 +65,26 @@ class DisplayModeWidget extends UIComponent { value={getAppState().minutesDisplayed.toString()} onblur={(e: FocusEvent) => ctx.onMinutesCounterInputBlur(e)}/>); ctx.minsCounterRef = ctx.makeRef( -
+
ctx.onMinutesCounterInputScroll(e)}> {getAppState().minutesDisplayed.toString()}
); return ctx.fromRef(ctx.minsCounterRef); } + private onMinutesCounterInputScroll(e: WheelEvent) { + AppStore().setMinutesDisplayed(getAppState().minutesDisplayed + e.deltaY); + } + + private onStopTimeInputScroll(e: WheelEvent) { + const oldWin = getAppState().displayWindow; + AppStore().setDisplayWindow({start: oldWin.start, stop: oldWin.stop - e.deltaY * 60}); + } + + private onStartTimeInputScroll(e: WheelEvent) { + const oldWin = getAppState().displayWindow; + AppStore().setDisplayWindow({start: oldWin.start - e.deltaY * 60, stop: oldWin.stop}); + } + private onMinutesCounterInputBlur(e: FocusEvent) { const input = Number((e.target as HTMLInputElement).value); if (!isNaN(input)) { @@ -90,6 +121,51 @@ class DisplayModeWidget extends UIComponent { input.selectionEnd = input.value.length; } + private onWindowStopDisplayClick() { + const stopTimeDisplay = this.fromRef(this.windowStopTimeRef); + (stopTimeDisplay as HTMLInputElement).valueAsDate = new Date(getAppState().displayWindow.stop); + const stopTimeInputDisplay = this.fromRef(this.windowStopTimeInputRef) as HTMLInputElement; + stopTimeDisplay.replaceWith(stopTimeInputDisplay); + const date = new Date(getAppState().displayWindow.stop * 1000 + getAppState().utcOffset * 60 * 60 * 1000); + stopTimeInputDisplay.value = `${date.toLocaleDateString()}, ${date.toLocaleTimeString()}`; + stopTimeInputDisplay.focus(); + } + + private onWindowStopInputBlur() { + const stopTimeInput = this.fromRef(this.windowStopTimeInputRef); + const val = new Date((stopTimeInput as HTMLInputElement).value).getTime() / 1000; + if (!isNaN(val)) { + AppStore().setDisplayWindow({ + start: getAppState().displayWindow.start, + stop: val + }); + } + stopTimeInput.replaceWith(this.fromRef(this.windowStopTimeRef)); + } + + private onWindowStartDisplayClick() { + const startTimeDisplay = this.fromRef(this.windowStartTimeRef); + (startTimeDisplay as HTMLInputElement).valueAsDate = new Date(getAppState().displayWindow.start); + const startTimeInputDisplay = this.fromRef(this.windowStartTimeInputRef) as HTMLInputElement; + startTimeDisplay.replaceWith(startTimeInputDisplay); + const date = new Date(getAppState().displayWindow.start * 1000 + getAppState().utcOffset * 60 * 60 * 1000); + startTimeInputDisplay.value = `${date.toLocaleDateString()}, ${date.toLocaleTimeString()}`; + startTimeInputDisplay.focus(); + + } + + private onWindowStartInputBlur() { + const startTimeInput = this.fromRef(this.windowStartTimeInputRef); + const val = new Date((startTimeInput as HTMLInputElement).value).getTime() / 1000; + if (!isNaN(val)) { + AppStore().setDisplayWindow({ + start: val, + stop: getAppState().displayWindow.stop, + }); + } + startTimeInput.replaceWith(this.fromRef(this.windowStartTimeRef)); + } + private MinusButton(props: {onclick: () => any}) { return
; + this.skeleton = new GridWidget({ + ...gridProps, + title: "Legend:", + className: "legend-widget", + body: this.display, + }); + AppStore().subscribeStoreVal("highlightedTimeseries", () => this.updateDisplay()); + this.updateDisplay(); + } + + private updateDisplay() { + this.fromRef(this.bodyRef).replaceWith(); + } + + private MainBody({ctx}: {ctx: LegendWidget}) { + ctx.bodyRef = ctx.makeRef(
); + return ctx.fromRef(ctx.bodyRef); + } + + private TimeseriesList({ctx}: { ctx: LegendWidget }) { + const highlightedTimeseries = getAppState().highlightedTimeseries; + return
    + { ...getAppState().rightTimeseries.map(timeseries => + ) } + { ...getAppState().leftTimeseries.map(timeseries => + ) } +
; + } + + private TimeseriesLegendEntry({timeseries, highlighted}: {timeseries: Timeseries, highlighted: boolean}) { + const option = new Option(); + option.style.color = timeseries.getColour(); + return
  • AppStore().setHighlightedTimeseries(timeseries.getName())} + onmouseout={() => AppStore().setHighlightedTimeseries(null)}> + {timeseries.getName()} +
  • ; + } + + current() { + return this.skeleton.current(); + } +} + +export default LegendWidget; \ No newline at end of file diff --git a/dashboard/src/ui-components/SelectDisplayModeWidget.tsx b/dashboard/src/ui-components/SelectDisplayModeWidget.tsx index 8186c91..70271f5 100644 --- a/dashboard/src/ui-components/SelectDisplayModeWidget.tsx +++ b/dashboard/src/ui-components/SelectDisplayModeWidget.tsx @@ -8,6 +8,8 @@ export default class SelectDisplayModeWidget extends UIComponent { private gridWidgetSkeleton: GridWidget; private windowInputRef: number; private minSpanInputRef: number; + private windowInputContainerRef: number; + private minSpanInputContainerRef: number; constructor(gridProps: GridProps) { super(); this.mainBody = this.MainBody({ctx: this}); @@ -27,6 +29,13 @@ export default class SelectDisplayModeWidget extends UIComponent { const windowedMode = getAppState().displayMode === "window"; (this.fromRef(this.windowInputRef) as HTMLInputElement).checked = windowedMode; (this.fromRef(this.minSpanInputRef) as HTMLInputElement).checked = !windowedMode; + if (!windowedMode) { + this.fromRef(this.minSpanInputContainerRef).classList.add("selected"); + this.fromRef(this.windowInputContainerRef).classList.remove("selected"); + } else { + this.fromRef(this.minSpanInputContainerRef).classList.remove("selected"); + this.fromRef(this.windowInputContainerRef).classList.add("selected"); + } } private MainBody({ ctx }: { ctx: SelectDisplayModeWidget }) { @@ -35,23 +44,27 @@ export default class SelectDisplayModeWidget extends UIComponent { type={"radio"} id={"window"} name={"display-mode"} - checked={isInWindowMode} - onclick={() => ctx.selectMode("window")}/>); + checked={isInWindowMode}/>); ctx.minSpanInputRef = this.makeRef( ctx.selectMode("pastMins")}/>); + checked={!isInWindowMode}/>); + ctx.windowInputContainerRef = this.makeRef(
    ctx.selectMode("window")}> + {this.fromRef(ctx.windowInputRef)} + +
    ); + ctx.minSpanInputContainerRef = this.makeRef(
    ctx.selectMode("pastMins")}> + {this.fromRef(ctx.minSpanInputRef)} + +
    ); return (
    -
    - {this.fromRef(ctx.windowInputRef)} - -
    -
    - {this.fromRef(ctx.minSpanInputRef)} - -
    + {this.fromRef(ctx.windowInputContainerRef)} + {this.fromRef(ctx.minSpanInputContainerRef)}
    ); } diff --git a/dashboard/src/ui-components/TimerWidget.tsx b/dashboard/src/ui-components/TimerWidget.tsx index fce6156..6cf32e5 100644 --- a/dashboard/src/ui-components/TimerWidget.tsx +++ b/dashboard/src/ui-components/TimerWidget.tsx @@ -20,16 +20,21 @@ class TimerWidget extends UIComponent { body: this.display, }); AppStore().subscribeStoreVal("lastUpdateTime", () => this.resetTimer()); + AppStore().subscribeStoreVal("utcOffset", () => this.resetTimer()); setInterval(() => this.refreshTimer(), 10); this.resetTimer(); } private resetTimer() { this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds; - this.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime * 1000).toLocaleString(); + this.updateUpdateText(); this.refreshTimer(); } + private updateUpdateText() { + this.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime * 1000 + getAppState().utcOffset * 60 * 60 * 1000).toLocaleString(); + } + private MainDisplay({ ctx }: { ctx: TimerWidget }) { ctx.timerRef = ctx.makeRef(
    ); ctx.lastUpdateRef = ctx.makeRef(