big update - fully functional
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime
|
||||
import random
|
||||
|
||||
print(
|
||||
'Time:', str(datetime.isoformat(datetime.now())),
|
||||
'\nTemp:', 20,
|
||||
'\nHumidity:', 60,
|
||||
'\nCO2:', 500,
|
||||
'Time:', str(datetime.isoformat(datetime.utcnow())) + "Z",
|
||||
'\nTemp:', random.randint(0, 40),
|
||||
'\nHumidity:', random.randint(50, 80),
|
||||
'\nCO2:', random.randint(400, 1200),
|
||||
sep='\t',
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
@@ -53,10 +53,9 @@ h1 {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-direction: column;
|
||||
margin: 1em;
|
||||
margin: 0.5em;
|
||||
padding: 1em;
|
||||
border-radius: 1em;
|
||||
border: 0.2em #c7ab82 solid;
|
||||
border: 0.1em #c7ab82 solid;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@@ -128,3 +127,19 @@ h1 {
|
||||
color: gray;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timezone-widget input {
|
||||
border: none;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
margin: 10px 0 10px 0;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -62,6 +62,7 @@ class AppUI extends UIComponent {
|
||||
|
||||
bootstrap(rootNode: string) {
|
||||
document.getElementById(rootNode).append(this.element);
|
||||
this.chartWidget.updateDimensions();
|
||||
}
|
||||
|
||||
current(): HTMLElement {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import Snapshot from "./Snapshot";
|
||||
|
||||
export default class Chart {
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
constructor(context: CanvasRenderingContext2D) {
|
||||
this.ctx = context;
|
||||
}
|
||||
|
||||
render(snapshots: Snapshot[]) {
|
||||
const snapshotWidth = this.ctx.canvas.width;
|
||||
let minTemp = Infinity;
|
||||
let maxTemp = -Infinity;
|
||||
let minCo2 = Infinity;
|
||||
let maxCo2 = -Infinity;
|
||||
let minHumidity = Infinity;
|
||||
let maxHumidity = -Infinity;
|
||||
for (const snapshot of snapshots) {
|
||||
if (snapshot.temp < minTemp) {
|
||||
minTemp = snapshot.temp;
|
||||
}
|
||||
if (snapshot.temp > maxTemp) {
|
||||
maxTemp = snapshot.temp;
|
||||
}
|
||||
if (snapshot.co2 < minCo2) {
|
||||
minCo2 = snapshot.co2;
|
||||
}
|
||||
if (snapshot.co2 > maxCo2) {
|
||||
maxCo2 = snapshot.co2;
|
||||
}
|
||||
if (snapshot.humidity < minHumidity) {
|
||||
minHumidity = snapshot.humidity;
|
||||
}
|
||||
if (snapshot.humidity > maxHumidity) {
|
||||
maxHumidity = snapshot.humidity;
|
||||
}
|
||||
}
|
||||
const humidityRange = maxHumidity - minHumidity;
|
||||
|
||||
let x = snapshotWidth / 2;
|
||||
let y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
this.ctx.moveTo(x, y);
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
x += snapshotWidth;
|
||||
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.lineTo(x, y);
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
|
||||
const co2Range = maxCo2 - minCo2;
|
||||
x = snapshotWidth / 2;
|
||||
y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
this.ctx.moveTo(x, y);
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
x += snapshotWidth;
|
||||
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.lineTo(x, y);
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
|
||||
const tempRange = maxTemp - minTemp;
|
||||
x = snapshotWidth / 2;
|
||||
y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
this.ctx.moveTo(x, y);
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
x += snapshotWidth;
|
||||
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.lineTo(x, y);
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
179
dashboard/src/ClimateChart.ts
Normal file
179
dashboard/src/ClimateChart.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import Timeseries from "./Timeseries";
|
||||
|
||||
export default class ClimateChart {
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
private readonly timeseries: Timeseries[] = [];
|
||||
private readonly lastMousePos = {x: 0, y: 0};
|
||||
private readonly indexRange = {start: 0, stop: 0};
|
||||
private readonly valRange = {high: -Infinity, low: Infinity}
|
||||
private formatTimestamp = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString();
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
constructor(context: CanvasRenderingContext2D) {
|
||||
this.ctx = context;
|
||||
this.ctx.fillStyle = "rgb(255,255,255)";
|
||||
this.width = this.ctx.canvas.width;
|
||||
this.height = this.ctx.canvas.height;
|
||||
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);
|
||||
}
|
||||
|
||||
addTimeseries(timeseries: Timeseries) {
|
||||
this.timeseries.push(timeseries);
|
||||
}
|
||||
|
||||
setRange(range: {start: number, stop: number}) {
|
||||
this.indexRange.start = range.start;
|
||||
this.indexRange.stop = range.stop;
|
||||
}
|
||||
|
||||
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.width = this.ctx.canvas.width;
|
||||
this.height = this.ctx.canvas.height;
|
||||
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.renderTooltips();
|
||||
}
|
||||
|
||||
private renderScale() {
|
||||
this.ctx.strokeStyle = "rgb(230,230,230)";
|
||||
this.ctx.fillStyle = "black";
|
||||
this.ctx.beginPath();
|
||||
const bottom = this.getY(this.valRange.low);
|
||||
this.ctx.moveTo(40, bottom);
|
||||
this.ctx.lineTo(this.width, bottom);
|
||||
this.ctx.fillText(this.valRange.low.toString(), 0, bottom + 4);
|
||||
const top = this.getY(this.valRange.high);
|
||||
this.ctx.moveTo(40, top);
|
||||
this.ctx.lineTo(this.width, top);
|
||||
this.ctx.fillText(this.valRange.high.toString(), 0, top + 4);
|
||||
const ticks = 20;
|
||||
const tickHeight = this.height / ticks;
|
||||
for (let i = 1; i < ticks; i++) {
|
||||
const pos = Math.floor(tickHeight * i);
|
||||
this.ctx.moveTo(40, pos);
|
||||
this.ctx.lineTo(this.width, pos);
|
||||
this.ctx.fillText(this.getValue(pos).toFixed(2), 0, pos + 4);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
private setDisplayRange() {
|
||||
for (const timeseries of this.timeseries) {
|
||||
const extrema = timeseries.getExtrema();
|
||||
if (extrema.maxVal > this.valRange.high) {
|
||||
this.valRange.high = extrema.maxVal;
|
||||
}
|
||||
if (extrema.minVal < this.valRange.low) {
|
||||
this.valRange.low = extrema.minVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderTooltips() {
|
||||
let bestDist = 20;
|
||||
let bestTimeseries = this.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];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestDist < 20) {
|
||||
this.renderTooltip(
|
||||
`${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`,
|
||||
this.getX(bestIndex),
|
||||
this.getY(bestVal),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setTimestampFormatter(formatter: (timestamp: number) => string) {
|
||||
this.formatTimestamp = formatter;
|
||||
}
|
||||
|
||||
private getX(index: number) {
|
||||
return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * this.width;
|
||||
}
|
||||
|
||||
private getY(value: number) {
|
||||
return this.height - (value - this.valRange.low) / (this.valRange.high - this.valRange.low) * this.height;
|
||||
}
|
||||
|
||||
private getIndex(x: number) {
|
||||
return (x / this.width) * this.indexRange.stop;
|
||||
}
|
||||
|
||||
private getValue(y: number) {
|
||||
return ((this.height - y) / this.height) * this.valRange.high;
|
||||
}
|
||||
|
||||
|
||||
private renderTimeseries(timeseries: Timeseries) {
|
||||
const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop);
|
||||
this.ctx.strokeStyle = timeseries.getColour();
|
||||
let y = this.getY(timeseriesPoints[0]);
|
||||
let x = this.getX(timeseriesPoints[1]);
|
||||
this.ctx.moveTo(Math.floor(x), Math.floor(y));
|
||||
this.ctx.beginPath();
|
||||
this.ctx.lineTo(Math.floor(x), Math.floor(y));
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
for (let i = 2; i < timeseriesPoints.length; i += 2) {
|
||||
y = this.getY(timeseriesPoints[i]);
|
||||
x = this.getX(timeseriesPoints[i + 1]);
|
||||
this.ctx.lineTo(Math.floor(x), Math.floor(y));
|
||||
this.ctx.ellipse(x, y, 3, 3, 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(x, y, width, height);
|
||||
this.ctx.strokeRect(x, y, width, height);
|
||||
this.ctx.fillStyle = "rgb(0,0,0)";
|
||||
this.ctx.fillText(text, x + 5, y + textHeight + 5);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,92 @@
|
||||
import Chart from "chart.js/dist/Chart.bundle.min";
|
||||
import {generateClimateChartConfig} from "./climateChartConfig";
|
||||
import Snapshot from "./Snapshot";
|
||||
import {AppStore, DisplayMode, getAppState, TimeWindow} from "./StateStore";
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import UIComponent from "./UIComponent";
|
||||
|
||||
interface ClimatePoint {
|
||||
x: string;
|
||||
y: number;
|
||||
}
|
||||
import ClimateChart from "./ClimateChart";
|
||||
|
||||
class ClimateChartWidget extends UIComponent {
|
||||
private readonly skeleton: GridWidget;
|
||||
private chart: Chart | null;
|
||||
private chart: ClimateChart | null = null;
|
||||
private initialised: boolean;
|
||||
private displayMode: DisplayMode = "pastMins";
|
||||
private latestSnapshotInChartTime: number;
|
||||
private readonly canvasElement: HTMLCanvasElement = document.createElement("canvas");
|
||||
private body = document.createElement("div");
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.initialised = false;
|
||||
this.canvasElement.className = "chart-canvas";
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
body: this.body,
|
||||
body: this.canvasElement,
|
||||
});
|
||||
const now = new Date().getTime();
|
||||
this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60000;
|
||||
const now = new Date().getTime() / 1000;
|
||||
this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60;
|
||||
this.setupListeners();
|
||||
AppStore().subscribe("documentReady", async () => {
|
||||
try {
|
||||
AppStore().addLoad();
|
||||
await this.initChart();
|
||||
this.initialised = true;
|
||||
} catch (e) {
|
||||
AppStore().fatalError(e);
|
||||
} finally {
|
||||
AppStore().finishLoad();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateDimensions() {
|
||||
const skelStyle = getComputedStyle(this.skeleton.current());
|
||||
this.canvasElement.height = this.skeleton.current().clientHeight
|
||||
- Number(skelStyle.paddingTop.slice(0, -2))
|
||||
- Number(skelStyle.paddingBottom.slice(0, -2));
|
||||
this.canvasElement.width = this.skeleton.current().clientWidth
|
||||
- Number(skelStyle.paddingLeft.slice(0, -2))
|
||||
- Number(skelStyle.paddingRight.slice(0, -2));
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
AppStore().subscribe("displayMode", () => this.updateDisplayMode());
|
||||
AppStore().subscribe("minutesDisplayed", () => this.update());
|
||||
AppStore().subscribe("displayWindow", () => this.update());
|
||||
AppStore().subscribe("snapshots", () => this.update());
|
||||
AppStore().subscribeStoreVal("displayMode", () => this.updateDisplayMode());
|
||||
AppStore().subscribeStoreVal("minutesDisplayed", () => this.rerender());
|
||||
AppStore().subscribeStoreVal("displayWindow", () => this.rerender());
|
||||
AppStore().on("timeseriesUpdated", () => this.rerender());
|
||||
AppStore().on("newTimeseries", (timeseries) => this.chart.addTimeseries(timeseries));
|
||||
AppStore().subscribeStoreVal("documentReady", () => this.initChart());
|
||||
AppStore().subscribeStoreVal("utcOffset", () => this.updateTimezone());
|
||||
}
|
||||
|
||||
private updateTimezone() {
|
||||
const offset = getAppState().utcOffset * 60 * 60 * 1000;
|
||||
this.chart.setTimestampFormatter((timestamp) => new Date(timestamp * 1000 + offset).toLocaleTimeString());
|
||||
}
|
||||
|
||||
private async initChart() {
|
||||
const ctx = this.canvasElement.getContext("2d");
|
||||
this.chart = new Chart(ctx, generateClimateChartConfig({}));
|
||||
await this.update();
|
||||
try {
|
||||
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);
|
||||
}
|
||||
await this.rerender();
|
||||
this.initialised = true;
|
||||
} catch (e) {
|
||||
AppStore().fatalError(e);
|
||||
} finally {
|
||||
AppStore().finishLoad();
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDisplayMode() {
|
||||
this.displayMode = getAppState().displayMode;
|
||||
await this.update();
|
||||
await this.rerender();
|
||||
}
|
||||
|
||||
private async update() {
|
||||
if (this.initialised) {
|
||||
if (this.displayMode === "window") {
|
||||
await this.updateChartFromTimeWindow();
|
||||
} else if (this.displayMode === "pastMins") {
|
||||
await this.updateChartFromMinuteSpan();
|
||||
}
|
||||
private async rerender() {
|
||||
if (!this.initialised) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async updateChartFromTimeWindow() {
|
||||
this.clearChart();
|
||||
this.appendSnapshots(await AppStore().snapshotsBetween(
|
||||
getAppState().displayWindow.start, getAppState().displayWindow.stop));
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
private async updateChartFromMinuteSpan() {
|
||||
const mins = getAppState().minutesDisplayed;
|
||||
this.clearChart();
|
||||
this.appendSnapshots(await AppStore().snapshotsBetween(
|
||||
getAppState().lastUpdateTime - mins * 60000,
|
||||
getAppState().lastUpdateTime));
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
private appendSnapshots(snapshots: Snapshot[]) {
|
||||
for (const snapshot of snapshots.reverse()) {
|
||||
this.humidityPointList().push({x: snapshot.time, y: snapshot.humidity});
|
||||
this.tempPointList().push({x: snapshot.time, y: snapshot.temp});
|
||||
this.co2PointList().push({x: snapshot.time, y: snapshot.co2});
|
||||
let start;
|
||||
let stop;
|
||||
if (this.displayMode === "window") {
|
||||
start = getAppState().displayWindow.start;
|
||||
stop = getAppState().displayWindow.stop;
|
||||
} else if (this.displayMode === "pastMins") {
|
||||
const mins = getAppState().minutesDisplayed;
|
||||
start = getAppState().lastUpdateTime - mins * 60;
|
||||
stop = getAppState().lastUpdateTime;
|
||||
}
|
||||
this.latestSnapshotInChartTime = snapshots[0] && new Date(snapshots[0].time).getTime();
|
||||
}
|
||||
|
||||
private removePoint(index: number) {
|
||||
this.humidityPointList().splice(index, 1);
|
||||
this.tempPointList().splice(index, 1);
|
||||
this.co2PointList().splice(index, 1);
|
||||
}
|
||||
|
||||
private clearChart() {
|
||||
for (const dataset of this.chart.data.datasets) {
|
||||
dataset.data = [];
|
||||
}
|
||||
}
|
||||
|
||||
private humidityPointList(): ClimatePoint[] {
|
||||
return this.chart.data.datasets[0].data as ClimatePoint[];
|
||||
}
|
||||
|
||||
private tempPointList(): ClimatePoint[] {
|
||||
return this.chart.data.datasets[1].data as ClimatePoint[];
|
||||
}
|
||||
|
||||
private co2PointList(): ClimatePoint[] {
|
||||
return this.chart.data.datasets[2].data as ClimatePoint[];
|
||||
this.chart.setRange({start, stop});
|
||||
this.chart.render();
|
||||
}
|
||||
|
||||
current() {
|
||||
|
||||
@@ -21,9 +21,9 @@ class DisplayModeWidget extends UIComponent {
|
||||
title: "Displaying:",
|
||||
body: this.mainDisplay,
|
||||
});
|
||||
AppStore().subscribe("minutesDisplayed", () => this.updateDisplay());
|
||||
AppStore().subscribe("displayMode", () => this.updateDisplay());
|
||||
AppStore().subscribe("displayWindow", () => this.updateDisplay());
|
||||
AppStore().subscribeStoreVal("minutesDisplayed", () => this.updateDisplay());
|
||||
AppStore().subscribeStoreVal("displayMode", () => this.updateDisplay());
|
||||
AppStore().subscribeStoreVal("displayWindow", () => this.updateDisplay());
|
||||
}
|
||||
|
||||
private WindowStartTime({ ctx }: {ctx: DisplayModeWidget}) {
|
||||
@@ -109,22 +109,22 @@ class DisplayModeWidget extends UIComponent {
|
||||
<div>From</div>
|
||||
<ctx.MinusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start - 60000, stop: displayWindow.stop});
|
||||
AppStore().setDisplayWindow({start: displayWindow.start - 60, stop: displayWindow.stop});
|
||||
}}/>
|
||||
<ctx.WindowStartTime ctx={ctx}/>
|
||||
<ctx.PlusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start + 60000, stop: displayWindow.stop});
|
||||
AppStore().setDisplayWindow({start: displayWindow.start + 60, stop: displayWindow.stop});
|
||||
}}/>
|
||||
<div>to</div>
|
||||
<ctx.MinusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop - 60000});
|
||||
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop - 60});
|
||||
}}/>
|
||||
<ctx.WindowStopTime ctx={ctx}/>
|
||||
<ctx.PlusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop + 60000});
|
||||
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop + 60});
|
||||
}}/>
|
||||
</div>);
|
||||
}
|
||||
@@ -147,8 +147,8 @@ class DisplayModeWidget extends UIComponent {
|
||||
private updateDisplay() {
|
||||
if (getAppState().displayMode === "window") {
|
||||
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));
|
||||
this.fromRef(this.windowStartTimeRef).innerText = new Date(getAppState().displayWindow.start).toLocaleString();
|
||||
this.fromRef(this.windowStopTimeRef).innerText = new Date(getAppState().displayWindow.stop).toLocaleString();
|
||||
this.fromRef(this.windowStartTimeRef).innerText = new Date(getAppState().displayWindow.start * 1000).toLocaleString();
|
||||
this.fromRef(this.windowStopTimeRef).innerText = new Date(getAppState().displayWindow.stop * 1000).toLocaleString();
|
||||
} else {
|
||||
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef));
|
||||
this.fromRef(this.minsCounterRef).innerText = getAppState().minutesDisplayed.toString();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import {AppStore} from "./StateStore";
|
||||
|
||||
type ListCacheLoader<I, D> = (start: I, stop: I) => Promise<D[]>;
|
||||
type ListCacheComparator<I, D> = (data: D, index: I) => -1 | 1 | 0;
|
||||
|
||||
class ListCache<IndexType, DataType> {
|
||||
private cache: DataType[];
|
||||
private loader: ListCacheLoader<IndexType, DataType>;
|
||||
private comparator: ListCacheComparator<IndexType, DataType>;
|
||||
|
||||
constructor(loader: ListCacheLoader<IndexType, DataType>, comparator: ListCacheComparator<IndexType, DataType>) {
|
||||
this.cache = [];
|
||||
this.loader = loader;
|
||||
this.comparator = comparator;
|
||||
}
|
||||
|
||||
getCache() {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
async snapshotsBetween(start: IndexType, stop: IndexType): Promise<DataType[]> {
|
||||
return this.cachedBetween(start, stop);
|
||||
}
|
||||
|
||||
async updateFromWindow(start: IndexType, stop: IndexType) {
|
||||
if (!this.cacheValidForWindow(start, stop)) {
|
||||
await this.fetchMissingElementsBetween(start, stop);
|
||||
}
|
||||
}
|
||||
|
||||
private cacheValidForWindow(start: IndexType, stop: IndexType) {
|
||||
if (this.cache.length > 0) {
|
||||
return this.comparator(this.cache[0], start) !== 1
|
||||
&& this.comparator(this.cache[this.cache.length - 1], stop) !== -1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMissingElementsBetween(start: IndexType, stop: IndexType) {
|
||||
AppStore().addLoad();
|
||||
this.cache = await this.loader(start, stop);
|
||||
AppStore().finishLoad();
|
||||
}
|
||||
|
||||
private cachedBetween(start: IndexType, stop: IndexType): DataType[] {
|
||||
if (this.cache.length <= 0) {
|
||||
return [];
|
||||
} else {
|
||||
return this.cache.slice(
|
||||
this.findIndexInCache(start),
|
||||
this.findIndexInCache(stop)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private findIndexInCache(soughtVal: IndexType, listStart = 0, listStop: number = this.cache.length): number {
|
||||
if (listStop - listStart === 1) {
|
||||
return listStart;
|
||||
} else {
|
||||
const middle = Math.floor((listStop + listStart) / 2);
|
||||
const comparison = this.comparator(this.cache[middle], soughtVal);
|
||||
if (comparison === 1) {
|
||||
return this.findIndexInCache(soughtVal, listStart, middle);
|
||||
} else if (comparison === -1) {
|
||||
return this.findIndexInCache(soughtVal, middle, listStop);
|
||||
} else {
|
||||
return middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ListCache;
|
||||
@@ -1,33 +1,34 @@
|
||||
import {AppStore, getAppState} from "./StateStore";
|
||||
import {UIComponent} from "./AppUI";
|
||||
import UIComponent from "./UIComponent";
|
||||
|
||||
class MessageOverlay implements UIComponent {
|
||||
class MessageOverlay extends UIComponent {
|
||||
private element: HTMLDivElement;
|
||||
private textElement: HTMLSpanElement;
|
||||
private showingError: boolean = false;
|
||||
private showingError = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.build();
|
||||
AppStore().subscribe("overlayText", () => this.update());
|
||||
AppStore().subscribe("isLoading", () => this.update());
|
||||
AppStore().subscribe("fatalError", () => this.showError())
|
||||
AppStore().subscribeStoreVal("overlayText", () => this.update());
|
||||
AppStore().subscribeStoreVal("isLoading", () => this.update());
|
||||
AppStore().subscribeStoreVal("fatalError", () => this.showError());
|
||||
this.update();
|
||||
}
|
||||
|
||||
private build() {
|
||||
this.element = document.createElement('div');
|
||||
this.element.classList.add('overlay', 'center');
|
||||
this.textElement = document.createElement('span');
|
||||
this.element = document.createElement("div");
|
||||
this.element.classList.add("overlay", "center");
|
||||
this.textElement = document.createElement("span");
|
||||
this.textElement.innerText = "";
|
||||
this.element.appendChild(this.textElement);
|
||||
}
|
||||
|
||||
private show() {
|
||||
this.element.classList.remove('hidden');
|
||||
this.element.classList.remove("hidden");
|
||||
}
|
||||
|
||||
private hide() {
|
||||
this.element.classList.add('hidden');
|
||||
this.element.classList.add("hidden");
|
||||
}
|
||||
|
||||
private showError() {
|
||||
|
||||
@@ -16,7 +16,7 @@ export default class SelectDisplayModeWidget extends UIComponent {
|
||||
title: "Display Mode:",
|
||||
body: this.mainBody,
|
||||
});
|
||||
AppStore().subscribe("displayMode", () => this.update());
|
||||
AppStore().subscribeStoreVal("displayMode", () => this.update());
|
||||
}
|
||||
|
||||
private selectMode(mode: DisplayMode) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Snapshot from "./Snapshot";
|
||||
import ListCache from "./ListCache";
|
||||
import Timeseries from "./Timeseries";
|
||||
|
||||
export class AppStateError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -10,6 +9,12 @@ export class AppStateError extends Error {
|
||||
|
||||
export type DisplayMode = "window" | "pastMins";
|
||||
|
||||
export interface EventCallback {
|
||||
newTimeseries: (timeseries: Timeseries) => void;
|
||||
timeseriesUpdated: (timeseries: Timeseries) => void;
|
||||
}
|
||||
type EventCallbackListing<K extends keyof EventCallback> = Record<K, EventCallback[K][]>;
|
||||
|
||||
export interface TimeWindow {
|
||||
start: number;
|
||||
stop: number;
|
||||
@@ -20,7 +25,7 @@ interface AppState {
|
||||
displayWindow: TimeWindow;
|
||||
minutesDisplayed: number;
|
||||
utcOffset: number;
|
||||
snapshots: Snapshot[];
|
||||
timeseries: Timeseries[],
|
||||
overlayText: string;
|
||||
dataEndpointBase: string;
|
||||
updateIntervalSeconds: number;
|
||||
@@ -31,31 +36,15 @@ interface AppState {
|
||||
}
|
||||
|
||||
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
|
||||
type SubscriptionType<T, K extends keyof T> = Record<K, StoreUpdateCallback<T[K]>[]>;
|
||||
type IAppStateSubscriptions = SubscriptionType<AppState, keyof AppState>;
|
||||
type SubscriptionType<K extends keyof AppState> = Record<K, StoreUpdateCallback<AppState[K]>[]>;
|
||||
type IAppStateSubscriptions = SubscriptionType<keyof AppState>;
|
||||
|
||||
|
||||
class AppStateStore {
|
||||
private readonly subscriptions: IAppStateSubscriptions;
|
||||
private readonly eventCallbacks: EventCallbackListing<keyof EventCallback>;
|
||||
private readonly state: AppState;
|
||||
private initialised = false;
|
||||
private loaders = 0;
|
||||
private readonly climateDataStore: ListCache<number, Snapshot> = new ListCache<number, Snapshot>(
|
||||
async (start, stop) => {
|
||||
const dataEndpoint = `${ this.state.dataEndpointBase }/snapshots?from=${ new Date(start).toISOString() }${stop ? `&to=${new Date(stop).toISOString()}` : ""}`;
|
||||
const payload = await fetch(dataEndpoint);
|
||||
return ((await payload.json()) as any).snapshots.reverse();
|
||||
},
|
||||
(data, index) => {
|
||||
const time = new Date(data.time).getTime();
|
||||
if (time + getAppState().updateIntervalSeconds * 1000 > index) {
|
||||
return 1;
|
||||
} else if (time - getAppState().updateIntervalSeconds * 1000< index) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
constructor(initialState: AppState) {
|
||||
this.state = initialState;
|
||||
@@ -63,62 +52,89 @@ class AppStateStore {
|
||||
for (const key in this.state) {
|
||||
subscriptions[key] = [];
|
||||
}
|
||||
this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: []};
|
||||
this.subscriptions = subscriptions as IAppStateSubscriptions;
|
||||
setInterval(() => this.updateClimateData(), this.state.updateIntervalSeconds * 1000);
|
||||
this.init();
|
||||
setInterval(() => this.getNewTimeseriesData(), this.state.updateIntervalSeconds * 1000);
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialised) {
|
||||
await this.updateClimateData();
|
||||
this.initialised = true;
|
||||
}
|
||||
await this.updateTimeseriesFromSettings();
|
||||
await this.getNewTimeseriesData();
|
||||
}
|
||||
|
||||
private notify(subscribedValue: keyof AppState) {
|
||||
addTimeseries(timeseries: Timeseries) {
|
||||
if (this.state.timeseries.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));
|
||||
this.updateTimeseriesFromSettings();
|
||||
}
|
||||
|
||||
private notifyStoreVal<T extends keyof AppState>(subscribedValue: T, newValue?: AppState[T], oldValue?: AppState[T]) {
|
||||
for (const subscriptionCallback of this.subscriptions[subscribedValue]) {
|
||||
new Promise(() => subscriptionCallback());
|
||||
new Promise(() => subscriptionCallback(newValue, oldValue));
|
||||
}
|
||||
}
|
||||
|
||||
private async updateClimateData() {
|
||||
const now = new Date().getTime();
|
||||
private async updateTimeseriesFromSettings() {
|
||||
let start: number;
|
||||
let stop: number;
|
||||
if (this.state.displayMode === "window") {
|
||||
await this.climateDataStore.updateFromWindow(
|
||||
this.state.displayWindow.start,
|
||||
this.state.displayWindow.stop
|
||||
);
|
||||
start = this.state.displayWindow.start;
|
||||
stop = this.state.displayWindow.stop;
|
||||
} else {
|
||||
await this.climateDataStore.updateFromWindow(
|
||||
now - this.state.minutesDisplayed * 60000,
|
||||
now
|
||||
);
|
||||
start = this.state.lastUpdateTime - this.state.minutesDisplayed * 60;
|
||||
stop = this.state.lastUpdateTime;
|
||||
}
|
||||
this.addLoad();
|
||||
for (const timeseries of this.state.timeseries) {
|
||||
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.setLastUpdateTime(now);
|
||||
this.setSnapshots(this.climateDataStore.getCache());
|
||||
}
|
||||
|
||||
async snapshotsBetween(start: number, stop: number) {
|
||||
return this.climateDataStore.snapshotsBetween(start, stop);
|
||||
private async getNewTimeseriesData() {
|
||||
this.addLoad();
|
||||
for (const timeseries of this.state.timeseries) {
|
||||
await timeseries.getLatest();
|
||||
}
|
||||
this.finishLoad();
|
||||
for (const timeseries of this.state.timeseries) {
|
||||
this.notifyStoreVal("timeseries");
|
||||
this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries));
|
||||
}
|
||||
this.setLastUpdateTime(new Date().getTime() / 1000);
|
||||
}
|
||||
|
||||
getState(): AppState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
subscribe<T extends keyof AppState>(dataName: T, callback: StoreUpdateCallback<AppState[T]>) {
|
||||
subscribeStoreVal<T extends keyof AppState>(dataName: T, callback: StoreUpdateCallback<AppState[T]>) {
|
||||
this.subscriptions[dataName].push(callback);
|
||||
}
|
||||
|
||||
on<T extends keyof EventCallback>(event: T, callback: EventCallback[T]) {
|
||||
this.eventCallbacks[event].push(callback);
|
||||
}
|
||||
|
||||
setDisplayMode(mode: DisplayMode) {
|
||||
this.state.displayMode = mode;
|
||||
this.notify("displayMode");
|
||||
this.notifyStoreVal("displayMode");
|
||||
}
|
||||
|
||||
setDisplayWindow(newWin: TimeWindow) {
|
||||
if (newWin.start < newWin.stop) {
|
||||
this.state.displayWindow = {...newWin};
|
||||
this.notify("displayWindow");
|
||||
this.updateClimateData();
|
||||
this.notifyStoreVal("displayWindow");
|
||||
this.updateTimeseriesFromSettings();
|
||||
} else {
|
||||
throw new AppStateError(`Invalid display window from ${newWin.start} to ${newWin.stop}`);
|
||||
}
|
||||
@@ -127,8 +143,8 @@ class AppStateStore {
|
||||
setMinutesDisplayed(mins: number) {
|
||||
if (mins > 0) {
|
||||
this.state.minutesDisplayed = Math.ceil(mins);
|
||||
this.notify("minutesDisplayed");
|
||||
this.updateClimateData();
|
||||
this.notifyStoreVal("minutesDisplayed");
|
||||
this.updateTimeseriesFromSettings();
|
||||
} else {
|
||||
throw new AppStateError(`Invalid minutes passed: ${mins}`);
|
||||
}
|
||||
@@ -137,16 +153,23 @@ class AppStateStore {
|
||||
setUtcOffset(newOffset: number) {
|
||||
if (Math.floor(newOffset) === newOffset && newOffset <= 14 && newOffset >= -12) {
|
||||
this.state.utcOffset = newOffset;
|
||||
this.notify("snapshots");
|
||||
} else {
|
||||
throw new AppStateError(`Invalid UTC offset: ${newOffset}`);
|
||||
console.warn(`Invalid UTC offset: ${newOffset}`);
|
||||
if (newOffset > 14) {
|
||||
this.state.utcOffset = 14;
|
||||
} else if (newOffset < -12) {
|
||||
this.state.utcOffset = -12;
|
||||
} else {
|
||||
this.state.utcOffset = Math.floor(newOffset);
|
||||
}
|
||||
}
|
||||
this.notifyStoreVal("utcOffset");
|
||||
}
|
||||
|
||||
private setLastUpdateTime(newTime: number) {
|
||||
if (this.state.lastUpdateTime <= newTime) {
|
||||
this.state.lastUpdateTime = newTime;
|
||||
this.notify("lastUpdateTime");
|
||||
this.notifyStoreVal("lastUpdateTime");
|
||||
} else {
|
||||
throw new AppStateError(`Bad new update time was before last update time. Old: ${this.state.lastUpdateTime}, New: ${newTime}`);
|
||||
}
|
||||
@@ -154,36 +177,31 @@ class AppStateStore {
|
||||
|
||||
setOverlayText(text: string) {
|
||||
this.state.overlayText = text;
|
||||
this.notify("overlayText");
|
||||
this.notifyStoreVal("overlayText");
|
||||
}
|
||||
|
||||
addLoad() {
|
||||
this.loaders += 1;
|
||||
this.state.isLoading = this.loaders > 0;
|
||||
this.notify("isLoading");
|
||||
this.notifyStoreVal("isLoading");
|
||||
}
|
||||
|
||||
finishLoad() {
|
||||
this.loaders -= 1;
|
||||
this.state.isLoading = this.loaders > 0;
|
||||
this.notify("isLoading");
|
||||
this.notifyStoreVal("isLoading");
|
||||
}
|
||||
|
||||
fatalError(err: Error) {
|
||||
if (!this.state.fatalError) {
|
||||
this.state.fatalError = err;
|
||||
this.notify("fatalError");
|
||||
this.notifyStoreVal("fatalError");
|
||||
}
|
||||
}
|
||||
|
||||
setDocumentReady(isReady: boolean) {
|
||||
this.state.documentReady = isReady;
|
||||
this.notify("documentReady");
|
||||
}
|
||||
|
||||
private setSnapshots(snapshots: Snapshot[]) {
|
||||
this.state.snapshots = snapshots;
|
||||
this.notify("snapshots");
|
||||
this.notifyStoreVal("documentReady");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +209,6 @@ let store: AppStateStore;
|
||||
|
||||
export async function initStore(initialState: AppState) {
|
||||
store = new AppStateStore(initialState);
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ class TimerWidget extends UIComponent {
|
||||
title: "Next update in:",
|
||||
body: this.display,
|
||||
});
|
||||
AppStore().subscribe("lastUpdateTime", () => this.resetTimer());
|
||||
AppStore().subscribeStoreVal("lastUpdateTime", () => this.resetTimer());
|
||||
setInterval(() => this.refreshTimer(), 10);
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
private resetTimer() {
|
||||
this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds * 1000;
|
||||
this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds;
|
||||
this.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime).toLocaleString();
|
||||
this.refreshTimer();
|
||||
}
|
||||
@@ -46,9 +46,9 @@ class TimerWidget extends UIComponent {
|
||||
}
|
||||
|
||||
private refreshTimer() {
|
||||
const now = new Date().getTime();
|
||||
const now = new Date().getTime() / 1000;
|
||||
if (now <= this.nextUpdateTime) {
|
||||
this.fromRef(this.timerRef).innerText = `${((this.nextUpdateTime - now)/1000).toFixed(2)}s`;
|
||||
this.fromRef(this.timerRef).innerText = `${(this.nextUpdateTime - now).toFixed(2)}s`;
|
||||
} else {
|
||||
this.fromRef(this.timerRef).innerText = "0.00s";
|
||||
}
|
||||
|
||||
182
dashboard/src/Timeseries.ts
Normal file
182
dashboard/src/Timeseries.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
type TimeseriesLoader = (start: number, stop: number) => Promise<Int32Array>;
|
||||
|
||||
type Extrema = {
|
||||
minVal: number,
|
||||
maxVal: number,
|
||||
minIndex: number,
|
||||
maxIndex: number,
|
||||
};
|
||||
|
||||
class Timeseries {
|
||||
private cache: Int32Array;
|
||||
private loader: TimeseriesLoader;
|
||||
private currentEndPointer: number;
|
||||
private name: string;
|
||||
private fetching = false;
|
||||
private extrema: Extrema = {
|
||||
minVal: Infinity,
|
||||
maxVal: -Infinity,
|
||||
minIndex: Infinity,
|
||||
maxIndex: -Infinity,
|
||||
};
|
||||
private colour: string;
|
||||
|
||||
constructor(name: string, loader: TimeseriesLoader) {
|
||||
this.cache = new Int32Array();
|
||||
this.loader = loader;
|
||||
this.name = name;
|
||||
this.colour = `rgb(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255})`;
|
||||
}
|
||||
|
||||
getExtrema(): Extrema {
|
||||
return Object.assign(this.extrema);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
getCache() {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
getColour() {
|
||||
return this.colour;
|
||||
}
|
||||
|
||||
cachedBetween(start: number, stop: number): Int32Array {
|
||||
if (this.cache.length <= 0) {
|
||||
return new Int32Array();
|
||||
} else {
|
||||
return this.cache.slice(
|
||||
this.findIndexInCache(start),
|
||||
this.findIndexInCache(stop)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
append(value: number, index: number): void {
|
||||
if (this.cache.length < this.currentEndPointer + 2) {
|
||||
const newCache = new Int32Array(this.cache.length * 2);
|
||||
newCache.set(this.cache, 0);
|
||||
newCache.set([value, index], this.currentEndPointer);
|
||||
this.cache = newCache;
|
||||
}
|
||||
}
|
||||
|
||||
async updateFromWindow(start: number, stop: number) {
|
||||
if (!this.fetching) {
|
||||
try {
|
||||
if (this.cache.length === 0) {
|
||||
this.fetching = true;
|
||||
await this.fullFetch(start, stop);
|
||||
} else if (this.cache[1] > start) {
|
||||
this.fetching = true;
|
||||
await this.fetchPrior(start);
|
||||
} else if (this.cache[this.currentEndPointer - 1] < stop) {
|
||||
this.fetching = true;
|
||||
await this.fetchAnterior(stop);
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Error fetching timeseries data: ${e}`);
|
||||
}
|
||||
}
|
||||
this.fetching = false;
|
||||
}
|
||||
|
||||
async getLatest() {
|
||||
this.fetching = true;
|
||||
try {
|
||||
await this.fetchAnterior(this.cache[this.currentEndPointer - 1]);
|
||||
} catch (e) {
|
||||
throw new Error(`Error fetching timeseries data: ${e}`);
|
||||
}
|
||||
this.fetching = false;
|
||||
}
|
||||
|
||||
private async fullFetch(start: number, stop: number) {
|
||||
try {
|
||||
this.cache = await this.loader(start, stop);
|
||||
this.currentEndPointer = this.cache.length;
|
||||
this.updateExtremaFrom(this.cache);
|
||||
} catch (e) {
|
||||
throw new Error(`Error fully fetching data: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAnterior(after: number) {
|
||||
try {
|
||||
const doubleTimespan = 2 * (this.cache[this.currentEndPointer - 1] - this.cache[1]);
|
||||
const result = await this.loader(after, after + doubleTimespan);
|
||||
const newCache = new Int32Array(this.cache.length + result.length);
|
||||
newCache.set(this.cache, 0);
|
||||
newCache.set(result, this.currentEndPointer);
|
||||
this.cache = newCache;
|
||||
this.currentEndPointer += result.length;
|
||||
this.updateExtremaFrom(result);
|
||||
} catch (e) {
|
||||
throw new Error(`Error fetching anterior data: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPrior(before: number) {
|
||||
try {
|
||||
const doubleTimespan = 2 * (this.cache[this.currentEndPointer - 1] - this.cache[1]);
|
||||
const result = await this.loader(before - doubleTimespan, before);
|
||||
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);
|
||||
} catch (e) {
|
||||
throw new Error(`Error fetching anterior data: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateExtremaFrom(data: Int32Array) {
|
||||
for (let i = 0; i < data.length; i += 2) {
|
||||
if (data[i] < this.extrema.minVal) {
|
||||
this.extrema.minVal = data[i];
|
||||
}
|
||||
if (data[i] > this.extrema.maxVal) {
|
||||
this.extrema.maxVal = data[i];
|
||||
}
|
||||
}
|
||||
for (let i = 1; i < this.cache.length; i += 2) {
|
||||
if (data[i] < this.extrema.minIndex) {
|
||||
this.extrema.minIndex = data[i];
|
||||
}
|
||||
if (data[i] > this.extrema.maxIndex) {
|
||||
this.extrema.maxIndex = data[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findIndexInCache(soughtIndex: number) {
|
||||
for (let i = 1; i < this.cache.length; i += 2) {
|
||||
if (soughtIndex < this.cache[i]) {
|
||||
return i - 1;
|
||||
}
|
||||
}
|
||||
return this.cache.length - 2;
|
||||
}
|
||||
|
||||
private findIndexInCacheBinary(soughtIndex: number, listStart = 0, listStop: number = (this.currentEndPointer / 2)): number {
|
||||
if (listStop - listStart === 1) {
|
||||
return listStart;
|
||||
} else {
|
||||
const middle = Math.floor((listStop + listStart) / 2);
|
||||
const val = this.cache[middle * 2 + 1];
|
||||
if (val > soughtIndex) {
|
||||
return this.findIndexInCacheBinary(soughtIndex, listStart, middle);
|
||||
} else if (val < soughtIndex) {
|
||||
return this.findIndexInCacheBinary(soughtIndex, middle, listStop);
|
||||
} else {
|
||||
return middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Timeseries;
|
||||
@@ -1,30 +0,0 @@
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import {AppStore} from "./StateStore";
|
||||
import UIComponent from "./UIComponent";
|
||||
|
||||
class TimezoneWidget extends UIComponent {
|
||||
private skeleton: GridWidget;
|
||||
private display: HTMLSpanElement = document.createElement("span");
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
title: "Displayed Timezone:",
|
||||
body: this.display,
|
||||
});
|
||||
AppStore().subscribe("utcOffset", () => this.updateDisplay());
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay() {
|
||||
const offset = AppStore().getState().utcOffset;
|
||||
this.display.innerText = `UTC ${offset > 1 ? "+" : "-"} ${Math.abs(offset)}:00`;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default TimezoneWidget;
|
||||
69
dashboard/src/TimezoneWidget.tsx
Normal file
69
dashboard/src/TimezoneWidget.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import {AppStore, getAppState} from "./StateStore";
|
||||
import UIComponent from "./UIComponent";
|
||||
import * as JSX from "./JSXFactory";
|
||||
|
||||
class TimezoneWidget extends UIComponent {
|
||||
private skeleton: GridWidget;
|
||||
private display: HTMLSpanElement = document.createElement("span");
|
||||
private timezoneInputRef: number;
|
||||
private timezoneDisplayRef: number;
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.display = <this.MainBody ctx={this}/>;
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
title: "Displayed Timezone:",
|
||||
body: this.display,
|
||||
});
|
||||
AppStore().subscribeStoreVal("utcOffset", () => this.updateDisplay());
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay() {
|
||||
const offset = AppStore().getState().utcOffset;
|
||||
this.fromRef(this.timezoneDisplayRef).innerText = `${offset > 0 ? "+" : "−"} ${Math.abs(offset)}`;
|
||||
(this.fromRef(this.timezoneInputRef) as HTMLInputElement).value = `${offset > 0 ? "" : "-"}${Math.abs(offset)}`;
|
||||
}
|
||||
|
||||
private MainBody({ctx}: {ctx: TimezoneWidget}) {
|
||||
return <div
|
||||
className={"timezone-widget"}
|
||||
onclick={() => ctx.onTimezoneClick()}>
|
||||
<span>UTC </span>
|
||||
<ctx.TimezoneDisplay ctx={ctx} />
|
||||
<span>:00</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
private TimezoneDisplay({ctx}: {ctx: TimezoneWidget}) {
|
||||
ctx.timezoneDisplayRef = ctx.makeRef(<span/>);
|
||||
ctx.timezoneInputRef = ctx.makeRef(<input
|
||||
type={"text"}
|
||||
onblur={() => ctx.onTimezoneInputBlur()}/>);
|
||||
return ctx.fromRef(ctx.timezoneDisplayRef);
|
||||
}
|
||||
|
||||
private onTimezoneInputBlur() {
|
||||
const input = this.fromRef(this.timezoneInputRef) as HTMLInputElement;
|
||||
const display = this.fromRef(this.timezoneDisplayRef);
|
||||
AppStore().setUtcOffset(Number(input.value));
|
||||
input.replaceWith(display);
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private onTimezoneClick() {
|
||||
const input = this.fromRef(this.timezoneInputRef) as HTMLInputElement;
|
||||
this.fromRef(this.timezoneDisplayRef).replaceWith(input);
|
||||
input.focus();
|
||||
input.selectionStart = 0;
|
||||
input.selectionEnd = input.value.length;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default TimezoneWidget;
|
||||
8
dashboard/src/errors.ts
Normal file
8
dashboard/src/errors.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export class ClayPIDashboardError extends Error {
|
||||
displayMessage: string;
|
||||
constructor(message: string, displayMessage?: string) {
|
||||
super(message);
|
||||
this.name = "ClayPIError";
|
||||
this.displayMessage = displayMessage ?? message;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import config from "./config.json";
|
||||
import {AppStore, initStore} from "./StateStore";
|
||||
import {AppStore, getAppState, initStore} from "./StateStore";
|
||||
import AppUI from "./AppUI";
|
||||
import Timeseries from "./Timeseries";
|
||||
import {ClayPIDashboardError} from "./errors";
|
||||
export {config};
|
||||
|
||||
function getDisplayedMinutes() {
|
||||
@@ -16,29 +18,64 @@ function getDisplayedMinutes() {
|
||||
}
|
||||
|
||||
function getUtcOffset() {
|
||||
return 0;
|
||||
return -(new Date().getTimezoneOffset() / 60);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const now = new Date().getTime();
|
||||
const now = new Date().getTime() / 1000;
|
||||
await initStore({
|
||||
overlayText: "",
|
||||
lastUpdateTime: now,
|
||||
minutesDisplayed: getDisplayedMinutes(),
|
||||
utcOffset: getUtcOffset(),
|
||||
snapshots: [],
|
||||
dataEndpointBase: config.dataEndpoint,
|
||||
isLoading: false,
|
||||
updateIntervalSeconds: config.reloadIntervalSec,
|
||||
displayMode: "pastMins",
|
||||
fatalError: null,
|
||||
displayWindow: {start: now - getDisplayedMinutes() * 60000, stop: now},
|
||||
displayWindow: {start: now - getDisplayedMinutes() * 60, stop: now},
|
||||
documentReady: false,
|
||||
timeseries: [],
|
||||
});
|
||||
AppStore().addTimeseries(new Timeseries("temp", (start, stop) => loadClimateTimeseriesData("temp", start, stop)));
|
||||
AppStore().addTimeseries(new Timeseries("humidity", (start, stop) => loadClimateTimeseriesData("humidity", start, stop)));
|
||||
AppStore().addTimeseries(new Timeseries("co2", (start, stop) => loadClimateTimeseriesData("co2", start, stop)));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
document.onreadystatechange = async () => {
|
||||
await init();
|
||||
AppStore().setDocumentReady(true);
|
||||
|
||||
@@ -12,4 +12,8 @@ export interface ISOSnapshot extends Snapshot {
|
||||
|
||||
export interface UnixTimeSnapshot extends Snapshot {
|
||||
time: number,
|
||||
}
|
||||
}
|
||||
|
||||
export type SnapshotAttrTimeseries = Int32Array;
|
||||
|
||||
export type ClimateDataType = "temp" | "humidity" | "co2";
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Connection, ResultSetHeader, RowDataPacket} from "mysql2/promise";
|
||||
import {Snapshot, ISOSnapshot} from "./Snapshot";
|
||||
import {Snapshot, ISOSnapshot, SnapshotAttrTimeseries, ClimateDataType} from "./Snapshot";
|
||||
import {isValidDatetime, toISOTime, toMySQLDatetime, toUnixTime} from "./utils";
|
||||
import {DatabaseConnection, tryQuery} from "./database";
|
||||
|
||||
@@ -52,6 +52,25 @@ class SnapshotCollection {
|
||||
});
|
||||
}
|
||||
|
||||
async getTimeseriesBytestreamSince(dataType: ClimateDataType, timeSince: number | string): Promise<SnapshotAttrTimeseries> {
|
||||
timeSince = toMySQLDatetime(timeSince);
|
||||
return tryQuery(async () => {
|
||||
const query = `SELECT \`id\`, DATE_FORMAT(\`time\`, '%Y-%m-%dT%TZ') \`time\`, \`${dataType}\` FROM \`snapshots\` WHERE TIMESTAMPDIFF(SECOND, \`time\`, ?) < 0 ORDER BY \`id\` ASC;`;
|
||||
const result = await this.db.query(query, [timeSince]);
|
||||
return SnapshotCollection.rowsToTimeseries(dataType, ...result[0] as RowDataPacket[]);
|
||||
});
|
||||
}
|
||||
|
||||
async getTimeseriesBytestreamInRange(dataType: ClimateDataType, start: number | string, stop: number | string): Promise<SnapshotAttrTimeseries> {
|
||||
start = toMySQLDatetime(start);
|
||||
stop = toMySQLDatetime(stop);
|
||||
return tryQuery(async () => {
|
||||
const query = `SELECT \`id\`, DATE_FORMAT(\`time\`, '%Y-%m-%dT%TZ') \`time\`, \`${dataType}\` FROM \`snapshots\` WHERE \`time\` BETWEEN ? AND ? ORDER BY \`id\` ASC;`;
|
||||
const result = await this.db.query(query, [start, stop]);
|
||||
return SnapshotCollection.rowsToTimeseries(dataType, ...result[0] as RowDataPacket[]);
|
||||
});
|
||||
}
|
||||
|
||||
static toUnixTime<T extends {time: string | number}>(...snapshots: T[]): (T & {time: number})[] {
|
||||
return snapshots.map(s => ({...s, time: toUnixTime(s.time)}));
|
||||
}
|
||||
@@ -61,7 +80,7 @@ class SnapshotCollection {
|
||||
}
|
||||
|
||||
private static toMySQLRows(...snapshots: Omit<Snapshot, "id">[]): (number | string | Date)[][] {
|
||||
return snapshots.map(s => [new Date(s.time), s.co2, s.humidity, s.temp]);
|
||||
return snapshots.map(s => [toMySQLDatetime(s.time), s.co2, s.humidity, s.temp]);
|
||||
}
|
||||
|
||||
static isSubmissibleSnapshot(potentialSnapshot: Record<string, unknown>): potentialSnapshot is Omit<Snapshot, "id"> {
|
||||
@@ -72,6 +91,15 @@ class SnapshotCollection {
|
||||
|| typeof potentialSnapshot.time === "string" && isValidDatetime(potentialSnapshot.time));
|
||||
}
|
||||
|
||||
private static rowsToTimeseries(dataType: ClimateDataType, ...rows: RowDataPacket[]): SnapshotAttrTimeseries {
|
||||
const timeseries = new Int32Array(rows.length * 2);
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
timeseries[i * 2] = Number(rows[i][dataType]);
|
||||
timeseries[i * 2 + 1] = toUnixTime(rows[i].time) / 1000;
|
||||
}
|
||||
return timeseries;
|
||||
}
|
||||
|
||||
private static rowsToSnapshots(...rows: RowDataPacket[]): ISOSnapshot[] {
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
|
||||
52
server/src/byteSeriesRouter.ts
Normal file
52
server/src/byteSeriesRouter.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {Router} from "express";
|
||||
import {CollectionRegistry} from "./Collections";
|
||||
import {ClayPIError} from "./errors";
|
||||
import {ClimateDataType, SnapshotAttrTimeseries} from "./Snapshot";
|
||||
|
||||
function newByteSeriesRouter(collections: CollectionRegistry) {
|
||||
const router = Router();
|
||||
|
||||
router.get("/:dataType", async (req, res) => {
|
||||
const query = req.query as Record<string, string>;
|
||||
const isMinutesQuery = typeof query["last-minutes"] !== "undefined" && !query.from && !query.to;
|
||||
const isFromToQuery = typeof query.from !== "undefined";
|
||||
const dataType = req.params.dataType;
|
||||
if (!isValidDataType(dataType)) {
|
||||
throw new ClayPIError(`The parameter 'data-type' must be one of the following: 'temp', 'humidity', 'co2'. Got: ${dataType}`);
|
||||
}
|
||||
let timeseries: SnapshotAttrTimeseries;
|
||||
if (!isMinutesQuery && !isFromToQuery) {
|
||||
if (query.to) {
|
||||
throw new ClayPIError("The parameter 'to' must always be accompanied by a 'from'.");
|
||||
}
|
||||
timeseries = await collections.snapshots.getTimeseriesBytestreamSince(dataType, new Date().getTime() - 60 * 60000);
|
||||
} else if (isMinutesQuery) {
|
||||
const lastMinutes = Math.floor(Number(query["last-minutes"]));
|
||||
if (isNaN(lastMinutes)) {
|
||||
throw new ClayPIError("The parameter 'last-minutes' must be a number.");
|
||||
} else {
|
||||
timeseries = await collections.snapshots.getTimeseriesBytestreamSince(dataType, new Date().getTime() - lastMinutes * 60000);
|
||||
}
|
||||
} else if (isFromToQuery) {
|
||||
const timeFrom = isNaN(Number(query.from)) ? query.from : Number(query.from);
|
||||
const timeTo = isNaN(Number(query.to)) ? query.to : Number(query.to);
|
||||
if (timeTo) {
|
||||
timeseries = await collections.snapshots.getTimeseriesBytestreamInRange(dataType, timeFrom, timeTo);
|
||||
} else {
|
||||
timeseries = await collections.snapshots.getTimeseriesBytestreamSince(dataType, timeFrom);
|
||||
}
|
||||
} else {
|
||||
throw new ClayPIError("Malformed request.");
|
||||
}
|
||||
res.type("application/octet-stream");
|
||||
res.end(Buffer.from(timeseries.buffer), "binary");
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function isValidDataType(dataType: string | undefined): dataType is ClimateDataType {
|
||||
return typeof dataType !== "undefined" && (dataType === "temp" || dataType === "humidity" || dataType === "co2");
|
||||
}
|
||||
|
||||
export default newByteSeriesRouter;
|
||||
@@ -2,15 +2,18 @@ import express from "express";
|
||||
import {ClayPIError, GenericPersistenceError} from "./errors";
|
||||
import newSnapshotRouter from "./snapshotRouter";
|
||||
import {CollectionRegistry} from "./Collections";
|
||||
import newByteSeriesRouter from "./byteSeriesRouter";
|
||||
|
||||
export function newMainRouter(collections: CollectionRegistry) {
|
||||
const router = express.Router();
|
||||
const snapshotRouter = newSnapshotRouter(collections);
|
||||
const byteSeriesRouter = newByteSeriesRouter(collections);
|
||||
|
||||
router.get("/dashboard", (req, res) => {
|
||||
res.render("index.ejs", { rootUrl: req.app.locals.rootUrl });
|
||||
});
|
||||
router.use("/api/snapshots", snapshotRouter);
|
||||
router.use("/api/timeseries", byteSeriesRouter);
|
||||
router.use(topLevelErrorHandler);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {ClayPIError} from "./errors";
|
||||
|
||||
async function pingSensors(): Promise<Omit<ISOSnapshot, "id">> {
|
||||
try {
|
||||
const process = await exec(`python3 ${path.resolve(__dirname + "/../scripts/pinger-test.py")}`);
|
||||
const process = await exec(`python3 ${path.resolve(__dirname + "/../scripts/climate-pinger.py")}`);
|
||||
const result = process.stdout;
|
||||
const snapshotArray = result.split("\t").map(piece => piece.trim());
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import SnapshotCollection from "./SnapshotCollection";
|
||||
import express, {Router} from "express";
|
||||
import {CollectionRegistry} from "./Collections";
|
||||
import {ClayPIError} from "./errors";
|
||||
import {toMySQLDatetime} from "./utils";
|
||||
import {unixTimeParamMiddleware} from "./utils";
|
||||
|
||||
function newSnapshotRouter(collections: CollectionRegistry) {
|
||||
const router = Router();
|
||||
@@ -80,14 +80,4 @@ function newSnapshotRouter(collections: CollectionRegistry) {
|
||||
return router;
|
||||
}
|
||||
|
||||
const unixTimeParamMiddleware: express.Handler = (req, res, next) => {
|
||||
const timeFormat = req.query.timeFormat;
|
||||
if (typeof timeFormat !== "undefined" && timeFormat !== "iso" && timeFormat !== "unix") {
|
||||
throw new ClayPIError("Parameter 'timeFormat' must be either 'iso' or 'unix'");
|
||||
} else {
|
||||
res.locals.timeFormat = timeFormat;
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
export default newSnapshotRouter;
|
||||
@@ -1,4 +1,5 @@
|
||||
import {DataValidationError} from "./errors";
|
||||
import {ClayPIError, DataValidationError} from "./errors";
|
||||
import express from "express";
|
||||
|
||||
export function toMySQLDatetime(datetime: number | string) {
|
||||
try {
|
||||
@@ -23,4 +24,14 @@ export function toUnixTime(datetime: string | number) {
|
||||
|
||||
export function toISOTime(datetime: string | number) {
|
||||
return new Date(datetime).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export const unixTimeParamMiddleware: express.Handler = (req, res, next) => {
|
||||
const timeFormat = req.query.timeFormat;
|
||||
if (typeof timeFormat !== "undefined" && timeFormat !== "iso" && timeFormat !== "unix") {
|
||||
throw new ClayPIError("Parameter 'timeFormat' must be either 'iso' or 'unix'");
|
||||
} else {
|
||||
res.locals.timeFormat = timeFormat;
|
||||
next();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user