diff --git a/app-dist/static/styles.css b/app-dist/static/styles.css index b404333..9a23d0a 100644 --- a/app-dist/static/styles.css +++ b/app-dist/static/styles.css @@ -1,7 +1,16 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@200;400;500&family=Roboto:wght@200;300;400&display=swap'); + +:root { + --accent-dark: #4f6fa0; + --accent-light: #fbfbff; +} + html, body { margin: 0; height: 100%; - background-color: #fff1de; + background-color: var(--accent-light); + font-family: 'Roboto', sans-serif; + font-weight: lighter; } .overlay { @@ -44,9 +53,9 @@ html, body { h1 { display: block; - font-family: "Georgia", serif; + font-family: 'Roboto Slab', serif; font-weight: normal; - color: #7b5b2f; + color: var(--accent-dark); } .widget { @@ -55,14 +64,15 @@ h1 { flex-direction: column; margin: 0.5em; padding: 1em; - border: 0.1em #c7ab82 solid; + border: 0.1em var(--accent-dark) solid; + border-radius: 0.4em; background-color: white; } .widget h2 { - font-family: "Georgia", serif; + font-family: 'Roboto', sans-serif; font-weight: normal; - color: #7b5b2f; + color: var(--accent-dark); font-size: 1em; display: block; } @@ -204,7 +214,7 @@ h1 { .help-box { position: relative; background-color: #ffffff; - border: solid 1px #7b5b2f; + border: solid 1px var(--accent-dark); width: 40em; padding: 1em; z-index: 1; diff --git a/dashboard/assets/help-button.svg b/dashboard/assets/help-button.svg index df07aeb..2e062c4 100644 --- a/dashboard/assets/help-button.svg +++ b/dashboard/assets/help-button.svg @@ -57,7 +57,7 @@ inkscape:groupmode="layer" id="layer1"> ? + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Bitstream Charter';-inkscape-font-specification:'Bitstream Charter';stroke-width:0.20222;fill:#4f6fa0;fill-opacity:1;">? diff --git a/dashboard/src/StateStore.ts b/dashboard/src/StateStore.ts index 9cf2767..b975939 100644 --- a/dashboard/src/StateStore.ts +++ b/dashboard/src/StateStore.ts @@ -131,37 +131,37 @@ class AppStateStore { start = this.state.lastUpdateTime - this.state.minutesDisplayed * 60; stop = this.state.lastUpdateTime; } - this.addLoad(); - 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); + const allTimeseries = this.state.leftTimeseries.concat(this.state.rightTimeseries); + const allHistoriesComplete = !allTimeseries.some(timeseries => !timeseries.historyIsComplete()); + if (start < this.getExtrema().minIndex && allHistoriesComplete) { + return; } - this.finishLoad(); - this.notifyAllTimeseriesUpdated(); + this.addLoad(); + const timeseriesFetches: Promise[] = []; + for (const timeseries of allTimeseries) { + timeseriesFetches.push(timeseries.updateFromWindow(start, stop)); + } + await Promise.allSettled(timeseriesFetches).then(() => { + this.finishLoad(); + this.notifyAllTimeseriesUpdated(); + }).catch((e) => this.fatalError(e)); } private async getNewTimeseriesData() { const updateTime = new Date().getTime() / 1000; this.addLoad(); - 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); + const timeseriesFetches: Promise[] = []; + for (const timeseries of this.state.leftTimeseries) { + timeseriesFetches.push(timeseries.getLatest()); } - this.finishLoad(); - this.setLastUpdateTime(updateTime); - this.notifyAllTimeseriesUpdated(); + for (const timeseries of this.state.rightTimeseries) { + timeseriesFetches.push(timeseries.getLatest()); + } + await Promise.allSettled(timeseriesFetches).then(() => { + this.finishLoad(); + this.setLastUpdateTime(updateTime); + this.notifyAllTimeseriesUpdated(); + }).catch((e) => this.fatalError(e)); } private notifyAllTimeseriesUpdated() { @@ -195,16 +195,53 @@ class AppStateStore { setDisplayWindow(newWin: TimeWindow) { if (newWin.start < newWin.stop) { - if (newWin.stop <= this.state.lastUpdateTime) { - this.state.displayWindow = {...newWin}; - this.notifyStoreVal("displayWindow"); - this.updateTimeseriesFromSettings(); + const extrema = this.getExtrema(); + if (newWin.stop >= extrema.maxIndex) { + newWin.stop = extrema.maxIndex; } + this.state.displayWindow = {...newWin}; + this.updateTimeseriesFromSettings().then(() => { + const newExtrema = this.getExtrema(); + this.state.displayWindow = { + start: Math.max(newExtrema.minIndex, this.state.displayWindow.start), + stop: Math.min(newExtrema.maxIndex, this.state.displayWindow.stop), + }; + this.notifyStoreVal("displayWindow"); + }); } else { console.warn(`Invalid display window from ${newWin.start} to ${newWin.stop}`); } } + shiftDisplayWindow(deltaX: number) { + const oldWin = {...this.state.displayWindow}; + this.state.displayWindow.start += deltaX; + this.state.displayWindow.stop += deltaX; + this.updateTimeseriesFromSettings().then(() => { + const newExtrema = this.getExtrema(); + const blockedBack = newExtrema.minIndex > this.state.displayWindow.start && deltaX < 0; + const blockedForward = newExtrema.maxIndex < this.state.displayWindow.stop && deltaX >= 0; + if (blockedBack || blockedForward) { + this.state.displayWindow = oldWin; + } + this.notifyStoreVal("displayWindow"); + }); + } + + getExtrema() { + let minIndex = Infinity; + let maxIndex = -Infinity; + let minVal = Infinity; + let maxVal = -Infinity; + for (const timeseries of this.state.leftTimeseries.concat(this.state.rightTimeseries)) { + minIndex = Math.min(minIndex, timeseries.getExtrema().minIndex); + maxIndex = Math.max(maxIndex, timeseries.getExtrema().maxIndex); + minVal = Math.min(minIndex, timeseries.getExtrema().minVal); + maxVal = Math.max(maxIndex, timeseries.getExtrema().maxVal); + } + return {minIndex, maxIndex, minVal, maxVal}; + } + setMinutesDisplayed(mins: number) { if (mins > 0) { this.state.minutesDisplayed = Math.ceil(mins); diff --git a/dashboard/src/Timeseries.ts b/dashboard/src/Timeseries.ts index 9afb914..b4a9815 100644 --- a/dashboard/src/Timeseries.ts +++ b/dashboard/src/Timeseries.ts @@ -33,6 +33,7 @@ class Timeseries { private valExtremaOverride?: { high: number, low: number }; private colour: string; private tolerance: number; + private historyComplete = false; constructor(options: TimeseriesOptions) { this.cache = new Int32Array(); @@ -58,7 +59,7 @@ class Timeseries { 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) { + for (let i = this.findIndexInCache(start) - 1; i < this.findIndexInCache(stop) + 1; i += 2) { if (this.cache[i] < minVal) { minVal = this.cache[i]; } @@ -69,8 +70,8 @@ class Timeseries { 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, + maxVal: this.valExtremaOverride ? Math.max(this.valExtremaOverride.high, maxVal) : maxVal, + minVal: this.valExtremaOverride ? Math.min(this.valExtremaOverride.low, minVal) : minVal, }; } @@ -95,7 +96,7 @@ class Timeseries { const cacheStopIndex = this.findIndexInCache(stop); return this.cache.slice( (cacheStartIndex - (cacheStartIndex) % blockSize), - (cacheStopIndex + blockSize - (cacheStopIndex) % blockSize) + blockSize * 2, + (cacheStopIndex + blockSize - (cacheStopIndex) % blockSize) + blockSize, ); } } @@ -175,13 +176,17 @@ class Timeseries { if (atLeastUntil < priorTo - backDist) { backDist = priorTo - atLeastUntil; } - const result = await this.loader(priorTo - backDist, priorTo); + const requestIndexStart = priorTo - backDist; + const result = await this.loader(requestIndexStart, priorTo); const newCache = new Int32Array(this.cache.length + result.length); newCache.set(result, 0); newCache.set(this.cache, result.length); this.cache = newCache; this.currentEndPointer += result.length; this.updateExtremaFrom(result); + if (this.extrema.minIndex === priorTo) { + this.historyComplete = true; + } } catch (e) { throw new Error(`Error fetching prior data: ${e}`); } @@ -234,6 +239,10 @@ class Timeseries { } } } + + historyIsComplete(): boolean { + return this.historyComplete; + } } export default Timeseries; \ No newline at end of file diff --git a/dashboard/src/chart/Chart.ts b/dashboard/src/chart/Chart.ts index de5511e..e96c218 100644 --- a/dashboard/src/chart/Chart.ts +++ b/dashboard/src/chart/Chart.ts @@ -46,7 +46,6 @@ export default class Chart { 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; @@ -78,7 +77,6 @@ export default class Chart { height: this.ctx.canvas.height - verticalMargins, width: rightScaleInitialWidth, }); - this.render(); } addTimeseries(timeseries: Timeseries, scale?: ScaleId) { @@ -138,16 +136,16 @@ export default class Chart { } render() { - this.clearCanvas(); + this.resetCanvas(); this.updateResolution(); - this.renderGuides(); this.leftScale.updateIndexRange(this.indexRange); this.rightScale.updateIndexRange(this.indexRange); + this.renderGuides(); this.leftScale.listTimeseries().forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Left)); this.rightScale.listTimeseries().forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Right)); - this.renderMargins(); this.leftScale.render(this.ctx); this.rightScale.render(this.ctx); + this.renderMargins(); this.renderTooltips(); } @@ -179,9 +177,11 @@ export default class Chart { ); } - private clearCanvas() { + private resetCanvas() { this.ctx.fillStyle = "rgb(255,255,255)"; this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); + this.ctx.resetTransform(); + this.ctx.translate(0.5, 0.5); } private updateResolution() { @@ -200,7 +200,7 @@ export default class Chart { this.ctx.lineWidth = 1; this.ctx.beginPath(); for (const tick of this.rightScale.getTicks()) { - const pos = Math.floor(this.rightScale.getY(tick)); + const pos = this.rightScale.getY(tick) | 0; this.ctx.moveTo(this.chartBounds.left, pos); this.ctx.lineTo(this.chartBounds.left + this.chartBounds.width, pos); } @@ -294,6 +294,7 @@ export default class Chart { } private renderTooltip(text: string, x: number, y: number, markerColour: string) { + this.ctx.lineWidth = 1; this.ctx.strokeStyle = "rgb(255,0,0)"; this.ctx.beginPath(); this.ctx.ellipse(x, y, 5, 5, 0, 0, 2 * Math.PI); diff --git a/dashboard/src/chart/Scale.ts b/dashboard/src/chart/Scale.ts index 214e180..62ba88a 100644 --- a/dashboard/src/chart/Scale.ts +++ b/dashboard/src/chart/Scale.ts @@ -7,6 +7,7 @@ export default class Scale { private tickCache: number[] = []; private tickCacheDirty = true; private bounds: Bounds; + private margins: {top: number, bottom: number} = {top: 10, bottom: 10}; constructor(bounds?: Bounds) { this.bounds = bounds ?? {height: 0, width: 0, top: 0, left: 0}; @@ -72,7 +73,8 @@ export default class Scale { } getY(value: number) { - return this.bounds.top + this.bounds.height - (value - this.valRange.low) / (this.valRange.high - this.valRange.low) * this.bounds.height; + const internalHeight = this.bounds.height - this.margins.top - this.margins.bottom; + return this.bounds.top + this.bounds.height - this.margins.bottom - (value - this.valRange.low) / (this.valRange.high - this.valRange.low) * internalHeight; } getValue(y: number) { diff --git a/dashboard/src/climateTimeseries.ts b/dashboard/src/climateTimeseries.ts index 087b98b..0fda09f 100644 --- a/dashboard/src/climateTimeseries.ts +++ b/dashboard/src/climateTimeseries.ts @@ -6,21 +6,18 @@ 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) { @@ -50,7 +47,7 @@ async function loadClimateTimeseriesData(dataType: "temp" | "humidity" | "co2", } return new Int32Array(data.buffer); } catch (e) { - const message = "timerseries data couldn't be loaded from the server"; + const message = "timeseries 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 0bedc3f..5624b06 100644 --- a/dashboard/src/config.json +++ b/dashboard/src/config.json @@ -1,6 +1,6 @@ { - "development": false, + "development": true, "defaultMinuteSpan": 60, "reloadIntervalSec": 30, - "dataEndpoint": "/climate/api" + "dataEndpoint": "http://192.168.178.21:4040/climate/api" } diff --git a/dashboard/src/types.d.ts b/dashboard/src/types.d.ts index 9a5934e..bc39be9 100644 --- a/dashboard/src/types.d.ts +++ b/dashboard/src/types.d.ts @@ -5,4 +5,8 @@ declare module "*.gif" { declare module "*.png" { const value: string; export = value; +} +declare module "*.svg" { + const value: string; + export = value; } \ No newline at end of file diff --git a/dashboard/src/ui-components/AppUI.tsx b/dashboard/src/ui-components/AppUI.tsx index 77c753b..d148aba 100644 --- a/dashboard/src/ui-components/AppUI.tsx +++ b/dashboard/src/ui-components/AppUI.tsx @@ -10,7 +10,7 @@ import LegendWidget from "./LegendWidget"; import HelpModal from "./HelpModal"; import * as JSX from "../JSXFactory"; import {AppStore} from "../StateStore"; -import HelpButtonImg from "../../assets/help-button.png"; +import HelpButtonImg from "../../assets/help-button.svg"; class AppUI extends UIComponent { private timezoneWidget: TimezoneWidget; diff --git a/dashboard/src/ui-components/ClimateChartWidget.ts b/dashboard/src/ui-components/ClimateChartWidget.ts index 779573c..02e9a37 100644 --- a/dashboard/src/ui-components/ClimateChartWidget.ts +++ b/dashboard/src/ui-components/ClimateChartWidget.ts @@ -75,11 +75,7 @@ class ClimateChartWidget extends UIComponent { if (getAppState().displayMode === "pastMins") { AppStore().emulateLastMinsWithWindow(); } - const displayedWindow = getAppState().displayWindow; - AppStore().setDisplayWindow({ - start: displayedWindow.start + deltaIndex, - stop: displayedWindow.stop + deltaIndex, - }); + AppStore().shiftDisplayWindow(deltaIndex); } private updateTimezone() { @@ -96,8 +92,8 @@ class ClimateChartWidget extends UIComponent { 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; + await this.rerender(); } catch (e) { AppStore().fatalError(e); } finally {