diff --git a/app-dist/static/styles.css b/app-dist/static/styles.css index 15a0c55..5b1219f 100644 --- a/app-dist/static/styles.css +++ b/app-dist/static/styles.css @@ -100,6 +100,10 @@ h1 { display: inline-block; font-size: 30px; margin: 10px 0 10px 0; + transition: background-color 100ms; +} +.display-mode-widget-mins .min-count:hover { + background-color: #eaeaea; } .display-mode-widget-mins input { border: none; @@ -128,6 +132,12 @@ h1 { font-size: 12px; } +.timezone-widget span:nth-child(2) { + transition: background-color 100ms; +} +.timezone-widget span:nth-child(2):hover { + background-color: #eaeaea; +} .timezone-widget input { border: none; display: inline-block; @@ -135,7 +145,4 @@ h1 { text-align: center; margin: 10px 0 10px 0; background-color: white; -} - -.chart-canvas { } \ No newline at end of file diff --git a/dashboard/src/ClimateChart.ts b/dashboard/src/ClimateChart.ts index 16fe34e..e29c677 100644 --- a/dashboard/src/ClimateChart.ts +++ b/dashboard/src/ClimateChart.ts @@ -1,14 +1,29 @@ 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 timeseries: Timeseries[] = []; + private readonly leftScale: Scale; + private readonly rightScale: Scale; private readonly lastMousePos = {x: 0, y: 0}; private readonly indexRange = {start: 0, stop: 0}; - private readonly valRange = {high: -Infinity, low: Infinity} + 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)"; @@ -17,6 +32,16 @@ export default class ClimateChart { 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() { @@ -24,8 +49,12 @@ export default class ClimateChart { this.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2)); } - addTimeseries(timeseries: Timeseries) { - this.timeseries.push(timeseries); + 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}) { @@ -33,7 +62,7 @@ export default class ClimateChart { this.indexRange.stop = range.stop; } - handleMouseMove(event: MouseEvent) { + private handleMouseMove(event: MouseEvent) { const {left: canvasX, top: canvasY} = this.ctx.canvas.getBoundingClientRect(); const x = event.clientX - canvasX; const y = event.clientY - canvasY; @@ -44,62 +73,112 @@ export default class ClimateChart { render() { this.updateDimensions(); - this.ctx.fillStyle = "rgb(255,255,255)"; - this.ctx.fillRect(0, 0, this.width, this.height); - this.ctx.fill(); - this.setDisplayRange(); - this.renderScale(); - for (const timeseries of this.timeseries) { - this.renderTimeseries(timeseries); - } + 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 renderScale() { + 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); + } + } + + 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.valRange.high - this.valRange.low) / ticks; - let currentTick = this.valRange.low; - for (let i = 0; i < ticks; i++) { + 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 pos = Math.round(this.getY(currentTick)); + 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(40, pos); - this.ctx.lineTo(this.width, pos); + this.ctx.moveTo(this.leftScale.width, pos); + this.ctx.lineTo(this.width - textWidth - 5, pos); this.ctx.stroke(); - this.ctx.fillText(currentTick.toFixed(2), 0, pos + 4); } } - private setDisplayRange() { - for (const timeseries of this.timeseries) { + private setDisplayRangeForScale(scale: Scale) { + for (const timeseries of scale.timeseries) { const extrema = timeseries.getExtrema(); - if (extrema.maxVal > this.valRange.high) { - this.valRange.high = extrema.maxVal; + if (extrema.maxVal > scale.valRange.high) { + scale.valRange.high = extrema.maxVal; } - if (extrema.minVal < this.valRange.low) { - this.valRange.low = extrema.minVal; + if (extrema.minVal < scale.valRange.low) { + scale.valRange.low = extrema.minVal; } } } - private renderTooltips() { - let bestDist = 20; - let bestTimeseries = this.timeseries[0]; + private renderTooltips(radius = 20) { + let bestDist = radius; + let bestTimeseries = this.rightScale.timeseries[0]; let bestIndex = 0; let bestVal = 0; - for (const timeseries of this.timeseries) { - const cache = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop); - for (let i = 0; i < cache.length; i += 2) { - const x = this.getX(cache[i + 1]); - const y = this.getY(cache[i]); - 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]; + 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; + } + } } } } @@ -107,7 +186,7 @@ export default class ClimateChart { this.renderTooltip( `${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`, this.getX(bestIndex), - this.getY(bestVal), + this.getY(bestVal, bestScale), ); } } @@ -117,36 +196,43 @@ export default class ClimateChart { } getX(index: number) { - return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * this.width; + 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) { - return this.height - (value - this.valRange.low) / (this.valRange.high - this.valRange.low) * this.height; + 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.width) * this.indexRange.stop; + 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) { - return ((this.height - y) / this.height) * this.valRange.high; + 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) { + private renderTimeseries(timeseries: Timeseries, scale: ScaleId) { const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop); this.ctx.strokeStyle = timeseries.getColour(); - const drawBubbles = this.getX(timeseriesPoints[3]) - this.getX(timeseriesPoints[1]) > 6; - let y = this.getY(timeseriesPoints[0]); + let y = this.getY(timeseriesPoints[0], scale); let x = this.getX(timeseriesPoints[1]); - for (let i = 0; i < timeseriesPoints.length; i += 2) { + for (let i = 0; i < timeseriesPoints.length; i += 2 * this.resolution) { this.ctx.beginPath(); this.ctx.moveTo(Math.round(x), Math.round(y)); - y = this.getY(timeseriesPoints[i]); - x = this.getX(timeseriesPoints[i + 1]); + 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 (drawBubbles) { + if (this.resolution === 1) { this.ctx.beginPath(); this.ctx.ellipse(x, y, 2, 2, 0, 0, 2 * Math.PI); this.ctx.stroke(); diff --git a/dashboard/src/StateStore.ts b/dashboard/src/StateStore.ts index dc94b24..385da2a 100644 --- a/dashboard/src/StateStore.ts +++ b/dashboard/src/StateStore.ts @@ -1,4 +1,5 @@ import Timeseries from "./Timeseries"; +import {ScaleId} from "./ClimateChart"; export class AppStateError extends Error { constructor(message: string) { @@ -11,7 +12,7 @@ export type DisplayMode = "window" | "pastMins"; export interface EventCallback { newTimeseries: (timeseries: Timeseries) => void; - timeseriesUpdated: (timeseries: Timeseries) => void; + timeseriesUpdated: (timeseries: Timeseries, scale?: ScaleId) => void; } type EventCallbackListing = Record; @@ -25,7 +26,8 @@ interface AppState { displayWindow: TimeWindow; minutesDisplayed: number; utcOffset: number; - timeseries: Timeseries[], + leftTimeseries: Timeseries[], + rightTimeseries: Timeseries[], overlayText: string; dataEndpointBase: string; updateIntervalSeconds: number; @@ -63,13 +65,18 @@ class AppStateStore { await this.getNewTimeseriesData(); } - addTimeseries(timeseries: Timeseries) { - if (this.state.timeseries.indexOf(timeseries) >= 0) { + addTimeseries(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!"); } - this.state.timeseries.push(timeseries); - this.notifyStoreVal("timeseries"); - this.eventCallbacks["newTimeseries"].forEach(cb => cb(timeseries)); + if (scale === ScaleId.Left) { + group.push(timeseries); + } else { + group.push(timeseries); + } + this.notifyStoreVal(scale === ScaleId.Left ? "leftTimeseries" : "rightTimeseries"); + this.eventCallbacks["newTimeseries"].forEach(cb => cb(timeseries, scale)); this.updateTimeseriesFromSettings(); } @@ -90,29 +97,39 @@ class AppStateStore { stop = this.state.lastUpdateTime; } this.addLoad(); - console.log(start, stop); - for (const timeseries of this.state.timeseries) { + for (const timeseries of this.state.leftTimeseries) { + await timeseries.updateFromWindow(start, stop); + } + for (const timeseries of this.state.rightTimeseries) { await timeseries.updateFromWindow(start, stop); } this.finishLoad(); - for (const timeseries of this.state.timeseries) { - this.notifyStoreVal("timeseries"); - this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries)); - } + this.notifyAllTimeseriesUpdated(); } private async getNewTimeseriesData() { const updateTime = new Date().getTime() / 1000; this.addLoad(); - for (const timeseries of this.state.timeseries) { + for (const timeseries of this.state.leftTimeseries) { + await timeseries.getLatest(); + } + for (const timeseries of this.state.rightTimeseries) { await timeseries.getLatest(); } this.finishLoad(); - for (const timeseries of this.state.timeseries) { - this.notifyStoreVal("timeseries"); + this.setLastUpdateTime(updateTime); + this.notifyAllTimeseriesUpdated(); + } + + private notifyAllTimeseriesUpdated() { + for (const timeseries of this.state.leftTimeseries) { + this.notifyStoreVal("leftTimeseries"); + this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries)); + } + for (const timeseries of this.state.rightTimeseries) { + this.notifyStoreVal("rightTimeseries"); this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries)); } - this.setLastUpdateTime(updateTime); } getState(): AppState { @@ -134,11 +151,13 @@ class AppStateStore { setDisplayWindow(newWin: TimeWindow) { if (newWin.start < newWin.stop) { - this.state.displayWindow = {...newWin}; - this.notifyStoreVal("displayWindow"); - this.updateTimeseriesFromSettings(); + if (newWin.stop < this.state.lastUpdateTime) { + this.state.displayWindow = {...newWin}; + this.notifyStoreVal("displayWindow"); + this.updateTimeseriesFromSettings(); + } } else { - throw new AppStateError(`Invalid display window from ${newWin.start} to ${newWin.stop}`); + console.warn(`Invalid display window from ${newWin.start} to ${newWin.stop}`); } } diff --git a/dashboard/src/Timeseries.ts b/dashboard/src/Timeseries.ts index b65181c..a2b9c07 100644 --- a/dashboard/src/Timeseries.ts +++ b/dashboard/src/Timeseries.ts @@ -27,7 +27,7 @@ class Timeseries { this.loader = loader; this.name = name; this.tolerance = tolerance ?? 0; - this.colour = `rgb(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255})`; + this.colour = `rgb(${Math.random() * 150},${Math.random() * 150},${Math.random() * 150})`; } getExtrema(): Extrema { @@ -51,7 +51,7 @@ class Timeseries { return new Int32Array(); } else { return this.cache.slice( - this.findIndexInCache(start), + this.findIndexInCache(start) - 1, this.findIndexInCache(stop) ); } @@ -164,9 +164,13 @@ class Timeseries { } private findIndexInCache(soughtIndex: number) { + return this.findIndexInCacheBinary(soughtIndex); + } + + private findIndexInCacheLinear(soughtIndex: number) { for (let i = 1; i < this.cache.length; i += 2) { if (soughtIndex < this.cache[i]) { - return i - 1; + return i > 3 ? i - 3 : i - 1; } } return this.cache.length - 2; @@ -174,7 +178,7 @@ class Timeseries { private findIndexInCacheBinary(soughtIndex: number, listStart = 0, listStop: number = (this.currentEndPointer / 2)): number { if (listStop - listStart === 1) { - return listStart; + return listStart * 2 + 1; } else { const middle = Math.floor((listStop + listStart) / 2); const val = this.cache[middle * 2 + 1]; @@ -183,7 +187,7 @@ class Timeseries { } else if (val < soughtIndex) { return this.findIndexInCacheBinary(soughtIndex, middle, listStop); } else { - return middle; + return middle * 2 + 1; } } } diff --git a/dashboard/src/climateChartConfig.ts b/dashboard/src/climateChartConfig.ts deleted file mode 100644 index bac6a04..0000000 --- a/dashboard/src/climateChartConfig.ts +++ /dev/null @@ -1,97 +0,0 @@ -import {ChartConfiguration, ChartPoint, TimeUnit} from "chart.js"; - -interface ClimateChartSettings { - humidity?: ChartPoint[]; - temp?: ChartPoint[]; - co2?: ChartPoint[]; - colors?: { - humidity?: string; - temp?: string; - co2?: string; - } -} - -const defaultHumidityColor = "rgb(196,107,107)"; -const defaultTempColor = "rgb(173,136,68)"; -const defaultCo2Color = "rgb(52,133,141)"; - -export function generateClimateChartConfig(settings: ClimateChartSettings): ChartConfiguration { - return { - type: "line", - data: { - datasets: [{ - label: "Humidity", - data: settings.humidity, - borderColor: settings.colors?.humidity ?? defaultHumidityColor, - fill: false, - yAxisID: "y-axis-3", - }, { - label: "Temperature", - data: settings.temp, - borderColor: settings.colors?.temp ?? defaultTempColor, - fill: false, - yAxisID: "y-axis-2", - }, { - label: "Co2 Concentration", - data: settings.co2, - borderColor: settings.colors?.co2 ?? defaultCo2Color, - fill: false, - yAxisID: "y-axis-1", - }] - }, - options: { - animation: {animateRotate: false, duration: 0, animateScale: false}, - responsive: true, - maintainAspectRatio: false, - legend: { - position: "top", - align: "end", - }, - scales: { - xAxes: [{ - type: "time", - time: { - unit: "second" as TimeUnit - } - }], - yAxes: [{ - type: "linear", - display: true, - position: "right", - id: "y-axis-1", - ticks: { - fontColor: settings.colors?.co2 ?? defaultCo2Color, - suggestedMin: 400, - suggestedMax: 1100, - }, - }, { - type: "linear", - display: true, - position: "left", - id: "y-axis-2", - ticks: { - fontColor: settings.colors?.temp ?? defaultTempColor, - suggestedMin: 10, - suggestedMax: 35, - }, - gridLines: { - drawOnChartArea: false, - }, - }, { - type: "linear", - display: true, - position: "left", - id: "y-axis-3", - ticks: { - fontColor: settings.colors?.humidity ?? defaultHumidityColor, - suggestedMin: 15, - suggestedMax: 85, - }, - gridLines: { - drawOnChartArea: false, - }, - }], - } - }, - }; -} \ No newline at end of file diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index 6889c62..7236c76 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -3,6 +3,8 @@ import {AppStore, getAppState, initStore} from "./StateStore"; import AppUI from "./ui-components/AppUI"; import Timeseries from "./Timeseries"; import {ClayPIDashboardError} from "./errors"; +import {ScaleId} from "./ClimateChart"; + export {config}; function getDisplayedMinutes() { @@ -35,23 +37,32 @@ async function init() { fatalError: null, displayWindow: {start: now - getDisplayedMinutes() * 60, stop: now}, documentReady: false, - timeseries: [], + leftTimeseries: [], + rightTimeseries: [], }); - AppStore().addTimeseries(new Timeseries( - "temp", - (start, stop) => loadClimateTimeseriesData("temp", start, stop), - getAppState().updateIntervalSeconds - )); - AppStore().addTimeseries(new Timeseries( - "humidity", - (start, stop) => loadClimateTimeseriesData("humidity", start, stop), - getAppState().updateIntervalSeconds - )); - AppStore().addTimeseries(new Timeseries( - "co2", - (start, stop) => loadClimateTimeseriesData("co2", start, stop), - getAppState().updateIntervalSeconds - )); + 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 + ); const ui = new AppUI(); ui.bootstrap("root"); } diff --git a/dashboard/src/ui-components/ClimateChartWidget.ts b/dashboard/src/ui-components/ClimateChartWidget.ts index 320f988..66487e0 100644 --- a/dashboard/src/ui-components/ClimateChartWidget.ts +++ b/dashboard/src/ui-components/ClimateChartWidget.ts @@ -1,7 +1,7 @@ import {AppStore, DisplayMode, getAppState} from "../StateStore"; import GridWidget, {GridProps} from "./GridWidget"; import UIComponent from "./UIComponent"; -import ClimateChart from "../ClimateChart"; +import ClimateChart, {ScaleId} from "../ClimateChart"; class ClimateChartWidget extends UIComponent { private readonly skeleton: GridWidget; @@ -54,9 +54,8 @@ class ClimateChartWidget extends UIComponent { AppStore().addLoad(); const ctx = this.canvasElement.getContext("2d", {alpha: false}); this.chart = new ClimateChart(ctx); - for (const timeseries of getAppState().timeseries) { - this.chart.addTimeseries(timeseries); - } + getAppState().leftTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Left)); + getAppState().rightTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Right)); await this.rerender(); this.initialised = true; } catch (e) {