big update - fully functional

This commit is contained in:
Daniel Ledda
2021-03-21 16:19:52 +01:00
parent 50362860ae
commit 5466b8d2bb
25 changed files with 824 additions and 461 deletions

View File

@@ -1,9 +1,10 @@
from datetime import datetime from datetime import datetime
import random
print( print(
'Time:', str(datetime.isoformat(datetime.now())), 'Time:', str(datetime.isoformat(datetime.utcnow())) + "Z",
'\nTemp:', 20, '\nTemp:', random.randint(0, 40),
'\nHumidity:', 60, '\nHumidity:', random.randint(50, 80),
'\nCO2:', 500, '\nCO2:', random.randint(400, 1200),
sep='\t', sep='\t',
) )

File diff suppressed because one or more lines are too long

View File

@@ -53,10 +53,9 @@ h1 {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
flex-direction: column; flex-direction: column;
margin: 1em; margin: 0.5em;
padding: 1em; padding: 1em;
border-radius: 1em; border: 0.1em #c7ab82 solid;
border: 0.2em #c7ab82 solid;
background-color: white; background-color: white;
} }
@@ -128,3 +127,19 @@ h1 {
color: gray; color: gray;
font-size: 12px; 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;
}

View File

@@ -62,6 +62,7 @@ class AppUI extends UIComponent {
bootstrap(rootNode: string) { bootstrap(rootNode: string) {
document.getElementById(rootNode).append(this.element); document.getElementById(rootNode).append(this.element);
this.chartWidget.updateDimensions();
} }
current(): HTMLElement { current(): HTMLElement {

View File

@@ -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();
}
}

View 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);
}
}

View File

@@ -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 {AppStore, DisplayMode, getAppState, TimeWindow} from "./StateStore";
import GridWidget, {GridProps} from "./GridWidget"; import GridWidget, {GridProps} from "./GridWidget";
import UIComponent from "./UIComponent"; import UIComponent from "./UIComponent";
import ClimateChart from "./ClimateChart";
interface ClimatePoint {
x: string;
y: number;
}
class ClimateChartWidget extends UIComponent { class ClimateChartWidget extends UIComponent {
private readonly skeleton: GridWidget; private readonly skeleton: GridWidget;
private chart: Chart | null; private chart: ClimateChart | null = null;
private initialised: boolean; private initialised: boolean;
private displayMode: DisplayMode = "pastMins"; private displayMode: DisplayMode = "pastMins";
private latestSnapshotInChartTime: number; private latestSnapshotInChartTime: number;
private readonly canvasElement: HTMLCanvasElement = document.createElement("canvas"); private readonly canvasElement: HTMLCanvasElement = document.createElement("canvas");
private body = document.createElement("div");
constructor(gridProps: GridProps) { constructor(gridProps: GridProps) {
super(); super();
this.initialised = false; this.initialised = false;
this.canvasElement.className = "chart-canvas";
this.skeleton = new GridWidget({ this.skeleton = new GridWidget({
...gridProps, ...gridProps,
body: this.body, body: this.canvasElement,
}); });
const now = new Date().getTime(); const now = new Date().getTime() / 1000;
this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60000; this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60;
this.setupListeners(); this.setupListeners();
AppStore().subscribe("documentReady", async () => { }
try {
AppStore().addLoad(); updateDimensions() {
await this.initChart(); const skelStyle = getComputedStyle(this.skeleton.current());
this.initialised = true; this.canvasElement.height = this.skeleton.current().clientHeight
} catch (e) { - Number(skelStyle.paddingTop.slice(0, -2))
AppStore().fatalError(e); - Number(skelStyle.paddingBottom.slice(0, -2));
} finally { this.canvasElement.width = this.skeleton.current().clientWidth
AppStore().finishLoad(); - Number(skelStyle.paddingLeft.slice(0, -2))
} - Number(skelStyle.paddingRight.slice(0, -2));
});
} }
private setupListeners() { private setupListeners() {
AppStore().subscribe("displayMode", () => this.updateDisplayMode()); AppStore().subscribeStoreVal("displayMode", () => this.updateDisplayMode());
AppStore().subscribe("minutesDisplayed", () => this.update()); AppStore().subscribeStoreVal("minutesDisplayed", () => this.rerender());
AppStore().subscribe("displayWindow", () => this.update()); AppStore().subscribeStoreVal("displayWindow", () => this.rerender());
AppStore().subscribe("snapshots", () => this.update()); 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() { private async initChart() {
const ctx = this.canvasElement.getContext("2d"); try {
this.chart = new Chart(ctx, generateClimateChartConfig({})); AppStore().addLoad();
await this.update(); 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() { private async updateDisplayMode() {
this.displayMode = getAppState().displayMode; this.displayMode = getAppState().displayMode;
await this.update(); await this.rerender();
} }
private async update() { private async rerender() {
if (this.initialised) { if (!this.initialised) {
if (this.displayMode === "window") { return;
await this.updateChartFromTimeWindow();
} else if (this.displayMode === "pastMins") {
await this.updateChartFromMinuteSpan();
}
} }
} let start;
let stop;
private async updateChartFromTimeWindow() { if (this.displayMode === "window") {
this.clearChart(); start = getAppState().displayWindow.start;
this.appendSnapshots(await AppStore().snapshotsBetween( stop = getAppState().displayWindow.stop;
getAppState().displayWindow.start, getAppState().displayWindow.stop)); } else if (this.displayMode === "pastMins") {
this.chart.update(); const mins = getAppState().minutesDisplayed;
} start = getAppState().lastUpdateTime - mins * 60;
stop = getAppState().lastUpdateTime;
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});
} }
this.latestSnapshotInChartTime = snapshots[0] && new Date(snapshots[0].time).getTime(); this.chart.setRange({start, stop});
} this.chart.render();
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[];
} }
current() { current() {

View File

@@ -21,9 +21,9 @@ class DisplayModeWidget extends UIComponent {
title: "Displaying:", title: "Displaying:",
body: this.mainDisplay, body: this.mainDisplay,
}); });
AppStore().subscribe("minutesDisplayed", () => this.updateDisplay()); AppStore().subscribeStoreVal("minutesDisplayed", () => this.updateDisplay());
AppStore().subscribe("displayMode", () => this.updateDisplay()); AppStore().subscribeStoreVal("displayMode", () => this.updateDisplay());
AppStore().subscribe("displayWindow", () => this.updateDisplay()); AppStore().subscribeStoreVal("displayWindow", () => this.updateDisplay());
} }
private WindowStartTime({ ctx }: {ctx: DisplayModeWidget}) { private WindowStartTime({ ctx }: {ctx: DisplayModeWidget}) {
@@ -109,22 +109,22 @@ class DisplayModeWidget extends UIComponent {
<div>From</div> <div>From</div>
<ctx.MinusButton onclick={() => { <ctx.MinusButton onclick={() => {
const displayWindow = AppStore().getState().displayWindow; 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.WindowStartTime ctx={ctx}/>
<ctx.PlusButton onclick={() => { <ctx.PlusButton onclick={() => {
const displayWindow = AppStore().getState().displayWindow; 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> <div>to</div>
<ctx.MinusButton onclick={() => { <ctx.MinusButton onclick={() => {
const displayWindow = AppStore().getState().displayWindow; 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.WindowStopTime ctx={ctx}/>
<ctx.PlusButton onclick={() => { <ctx.PlusButton onclick={() => {
const displayWindow = AppStore().getState().displayWindow; const displayWindow = AppStore().getState().displayWindow;
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop + 60000}); AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop + 60});
}}/> }}/>
</div>); </div>);
} }
@@ -147,8 +147,8 @@ class DisplayModeWidget extends UIComponent {
private updateDisplay() { private updateDisplay() {
if (getAppState().displayMode === "window") { if (getAppState().displayMode === "window") {
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef)); this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));
this.fromRef(this.windowStartTimeRef).innerText = new Date(getAppState().displayWindow.start).toLocaleString(); this.fromRef(this.windowStartTimeRef).innerText = new Date(getAppState().displayWindow.start * 1000).toLocaleString();
this.fromRef(this.windowStopTimeRef).innerText = new Date(getAppState().displayWindow.stop).toLocaleString(); this.fromRef(this.windowStopTimeRef).innerText = new Date(getAppState().displayWindow.stop * 1000).toLocaleString();
} else { } else {
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef)); this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef));
this.fromRef(this.minsCounterRef).innerText = getAppState().minutesDisplayed.toString(); this.fromRef(this.minsCounterRef).innerText = getAppState().minutesDisplayed.toString();

View File

@@ -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;

View File

@@ -1,33 +1,34 @@
import {AppStore, getAppState} from "./StateStore"; 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 element: HTMLDivElement;
private textElement: HTMLSpanElement; private textElement: HTMLSpanElement;
private showingError: boolean = false; private showingError = false;
constructor() { constructor() {
super();
this.build(); this.build();
AppStore().subscribe("overlayText", () => this.update()); AppStore().subscribeStoreVal("overlayText", () => this.update());
AppStore().subscribe("isLoading", () => this.update()); AppStore().subscribeStoreVal("isLoading", () => this.update());
AppStore().subscribe("fatalError", () => this.showError()) AppStore().subscribeStoreVal("fatalError", () => this.showError());
this.update(); this.update();
} }
private build() { private build() {
this.element = document.createElement('div'); this.element = document.createElement("div");
this.element.classList.add('overlay', 'center'); this.element.classList.add("overlay", "center");
this.textElement = document.createElement('span'); this.textElement = document.createElement("span");
this.textElement.innerText = ""; this.textElement.innerText = "";
this.element.appendChild(this.textElement); this.element.appendChild(this.textElement);
} }
private show() { private show() {
this.element.classList.remove('hidden'); this.element.classList.remove("hidden");
} }
private hide() { private hide() {
this.element.classList.add('hidden'); this.element.classList.add("hidden");
} }
private showError() { private showError() {

View File

@@ -16,7 +16,7 @@ export default class SelectDisplayModeWidget extends UIComponent {
title: "Display Mode:", title: "Display Mode:",
body: this.mainBody, body: this.mainBody,
}); });
AppStore().subscribe("displayMode", () => this.update()); AppStore().subscribeStoreVal("displayMode", () => this.update());
} }
private selectMode(mode: DisplayMode) { private selectMode(mode: DisplayMode) {

View File

@@ -1,5 +1,4 @@
import Snapshot from "./Snapshot"; import Timeseries from "./Timeseries";
import ListCache from "./ListCache";
export class AppStateError extends Error { export class AppStateError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -10,6 +9,12 @@ export class AppStateError extends Error {
export type DisplayMode = "window" | "pastMins"; 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 { export interface TimeWindow {
start: number; start: number;
stop: number; stop: number;
@@ -20,7 +25,7 @@ interface AppState {
displayWindow: TimeWindow; displayWindow: TimeWindow;
minutesDisplayed: number; minutesDisplayed: number;
utcOffset: number; utcOffset: number;
snapshots: Snapshot[]; timeseries: Timeseries[],
overlayText: string; overlayText: string;
dataEndpointBase: string; dataEndpointBase: string;
updateIntervalSeconds: number; updateIntervalSeconds: number;
@@ -31,31 +36,15 @@ interface AppState {
} }
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void; type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
type SubscriptionType<T, K extends keyof T> = Record<K, StoreUpdateCallback<T[K]>[]>; type SubscriptionType<K extends keyof AppState> = Record<K, StoreUpdateCallback<AppState[K]>[]>;
type IAppStateSubscriptions = SubscriptionType<AppState, keyof AppState>; type IAppStateSubscriptions = SubscriptionType<keyof AppState>;
class AppStateStore { class AppStateStore {
private readonly subscriptions: IAppStateSubscriptions; private readonly subscriptions: IAppStateSubscriptions;
private readonly eventCallbacks: EventCallbackListing<keyof EventCallback>;
private readonly state: AppState; private readonly state: AppState;
private initialised = false;
private loaders = 0; 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) { constructor(initialState: AppState) {
this.state = initialState; this.state = initialState;
@@ -63,62 +52,89 @@ class AppStateStore {
for (const key in this.state) { for (const key in this.state) {
subscriptions[key] = []; subscriptions[key] = [];
} }
this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: []};
this.subscriptions = subscriptions as IAppStateSubscriptions; this.subscriptions = subscriptions as IAppStateSubscriptions;
setInterval(() => this.updateClimateData(), this.state.updateIntervalSeconds * 1000); this.init();
setInterval(() => this.getNewTimeseriesData(), this.state.updateIntervalSeconds * 1000);
} }
async init() { async init() {
if (!this.initialised) { await this.updateTimeseriesFromSettings();
await this.updateClimateData(); await this.getNewTimeseriesData();
this.initialised = true;
}
} }
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]) { for (const subscriptionCallback of this.subscriptions[subscribedValue]) {
new Promise(() => subscriptionCallback()); new Promise(() => subscriptionCallback(newValue, oldValue));
} }
} }
private async updateClimateData() { private async updateTimeseriesFromSettings() {
const now = new Date().getTime(); let start: number;
let stop: number;
if (this.state.displayMode === "window") { if (this.state.displayMode === "window") {
await this.climateDataStore.updateFromWindow( start = this.state.displayWindow.start;
this.state.displayWindow.start, stop = this.state.displayWindow.stop;
this.state.displayWindow.stop
);
} else { } else {
await this.climateDataStore.updateFromWindow( start = this.state.lastUpdateTime - this.state.minutesDisplayed * 60;
now - this.state.minutesDisplayed * 60000, stop = this.state.lastUpdateTime;
now }
); 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) { private async getNewTimeseriesData() {
return this.climateDataStore.snapshotsBetween(start, stop); 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 { getState(): AppState {
return this.state; 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); this.subscriptions[dataName].push(callback);
} }
on<T extends keyof EventCallback>(event: T, callback: EventCallback[T]) {
this.eventCallbacks[event].push(callback);
}
setDisplayMode(mode: DisplayMode) { setDisplayMode(mode: DisplayMode) {
this.state.displayMode = mode; this.state.displayMode = mode;
this.notify("displayMode"); this.notifyStoreVal("displayMode");
} }
setDisplayWindow(newWin: TimeWindow) { setDisplayWindow(newWin: TimeWindow) {
if (newWin.start < newWin.stop) { if (newWin.start < newWin.stop) {
this.state.displayWindow = {...newWin}; this.state.displayWindow = {...newWin};
this.notify("displayWindow"); this.notifyStoreVal("displayWindow");
this.updateClimateData(); this.updateTimeseriesFromSettings();
} else { } else {
throw new AppStateError(`Invalid display window from ${newWin.start} to ${newWin.stop}`); throw new AppStateError(`Invalid display window from ${newWin.start} to ${newWin.stop}`);
} }
@@ -127,8 +143,8 @@ class AppStateStore {
setMinutesDisplayed(mins: number) { setMinutesDisplayed(mins: number) {
if (mins > 0) { if (mins > 0) {
this.state.minutesDisplayed = Math.ceil(mins); this.state.minutesDisplayed = Math.ceil(mins);
this.notify("minutesDisplayed"); this.notifyStoreVal("minutesDisplayed");
this.updateClimateData(); this.updateTimeseriesFromSettings();
} else { } else {
throw new AppStateError(`Invalid minutes passed: ${mins}`); throw new AppStateError(`Invalid minutes passed: ${mins}`);
} }
@@ -137,16 +153,23 @@ class AppStateStore {
setUtcOffset(newOffset: number) { setUtcOffset(newOffset: number) {
if (Math.floor(newOffset) === newOffset && newOffset <= 14 && newOffset >= -12) { if (Math.floor(newOffset) === newOffset && newOffset <= 14 && newOffset >= -12) {
this.state.utcOffset = newOffset; this.state.utcOffset = newOffset;
this.notify("snapshots");
} else { } 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) { private setLastUpdateTime(newTime: number) {
if (this.state.lastUpdateTime <= newTime) { if (this.state.lastUpdateTime <= newTime) {
this.state.lastUpdateTime = newTime; this.state.lastUpdateTime = newTime;
this.notify("lastUpdateTime"); this.notifyStoreVal("lastUpdateTime");
} else { } else {
throw new AppStateError(`Bad new update time was before last update time. Old: ${this.state.lastUpdateTime}, New: ${newTime}`); 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) { setOverlayText(text: string) {
this.state.overlayText = text; this.state.overlayText = text;
this.notify("overlayText"); this.notifyStoreVal("overlayText");
} }
addLoad() { addLoad() {
this.loaders += 1; this.loaders += 1;
this.state.isLoading = this.loaders > 0; this.state.isLoading = this.loaders > 0;
this.notify("isLoading"); this.notifyStoreVal("isLoading");
} }
finishLoad() { finishLoad() {
this.loaders -= 1; this.loaders -= 1;
this.state.isLoading = this.loaders > 0; this.state.isLoading = this.loaders > 0;
this.notify("isLoading"); this.notifyStoreVal("isLoading");
} }
fatalError(err: Error) { fatalError(err: Error) {
if (!this.state.fatalError) { if (!this.state.fatalError) {
this.state.fatalError = err; this.state.fatalError = err;
this.notify("fatalError"); this.notifyStoreVal("fatalError");
} }
} }
setDocumentReady(isReady: boolean) { setDocumentReady(isReady: boolean) {
this.state.documentReady = isReady; this.state.documentReady = isReady;
this.notify("documentReady"); this.notifyStoreVal("documentReady");
}
private setSnapshots(snapshots: Snapshot[]) {
this.state.snapshots = snapshots;
this.notify("snapshots");
} }
} }
@@ -191,7 +209,6 @@ let store: AppStateStore;
export async function initStore(initialState: AppState) { export async function initStore(initialState: AppState) {
store = new AppStateStore(initialState); store = new AppStateStore(initialState);
await store.init();
return store; return store;
} }

View File

@@ -19,13 +19,13 @@ class TimerWidget extends UIComponent {
title: "Next update in:", title: "Next update in:",
body: this.display, body: this.display,
}); });
AppStore().subscribe("lastUpdateTime", () => this.resetTimer()); AppStore().subscribeStoreVal("lastUpdateTime", () => this.resetTimer());
setInterval(() => this.refreshTimer(), 10); setInterval(() => this.refreshTimer(), 10);
this.resetTimer(); this.resetTimer();
} }
private 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.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime).toLocaleString();
this.refreshTimer(); this.refreshTimer();
} }
@@ -46,9 +46,9 @@ class TimerWidget extends UIComponent {
} }
private refreshTimer() { private refreshTimer() {
const now = new Date().getTime(); const now = new Date().getTime() / 1000;
if (now <= this.nextUpdateTime) { 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 { } else {
this.fromRef(this.timerRef).innerText = "0.00s"; this.fromRef(this.timerRef).innerText = "0.00s";
} }

182
dashboard/src/Timeseries.ts Normal file
View 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;

View File

@@ -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;

View 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
View 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;
}
}

View File

@@ -1,6 +1,8 @@
import config from "./config.json"; import config from "./config.json";
import {AppStore, initStore} from "./StateStore"; import {AppStore, getAppState, initStore} from "./StateStore";
import AppUI from "./AppUI"; import AppUI from "./AppUI";
import Timeseries from "./Timeseries";
import {ClayPIDashboardError} from "./errors";
export {config}; export {config};
function getDisplayedMinutes() { function getDisplayedMinutes() {
@@ -16,29 +18,64 @@ function getDisplayedMinutes() {
} }
function getUtcOffset() { function getUtcOffset() {
return 0; return -(new Date().getTimezoneOffset() / 60);
} }
async function init() { async function init() {
const now = new Date().getTime(); const now = new Date().getTime() / 1000;
await initStore({ await initStore({
overlayText: "", overlayText: "",
lastUpdateTime: now, lastUpdateTime: now,
minutesDisplayed: getDisplayedMinutes(), minutesDisplayed: getDisplayedMinutes(),
utcOffset: getUtcOffset(), utcOffset: getUtcOffset(),
snapshots: [],
dataEndpointBase: config.dataEndpoint, dataEndpointBase: config.dataEndpoint,
isLoading: false, isLoading: false,
updateIntervalSeconds: config.reloadIntervalSec, updateIntervalSeconds: config.reloadIntervalSec,
displayMode: "pastMins", displayMode: "pastMins",
fatalError: null, fatalError: null,
displayWindow: {start: now - getDisplayedMinutes() * 60000, stop: now}, displayWindow: {start: now - getDisplayedMinutes() * 60, stop: now},
documentReady: false, 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(); const ui = new AppUI();
ui.bootstrap("root"); 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 () => { document.onreadystatechange = async () => {
await init(); await init();
AppStore().setDocumentReady(true); AppStore().setDocumentReady(true);

View File

@@ -13,3 +13,7 @@ export interface ISOSnapshot extends Snapshot {
export interface UnixTimeSnapshot extends Snapshot { export interface UnixTimeSnapshot extends Snapshot {
time: number, time: number,
} }
export type SnapshotAttrTimeseries = Int32Array;
export type ClimateDataType = "temp" | "humidity" | "co2";

View File

@@ -1,5 +1,5 @@
import {Connection, ResultSetHeader, RowDataPacket} from "mysql2/promise"; 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 {isValidDatetime, toISOTime, toMySQLDatetime, toUnixTime} from "./utils";
import {DatabaseConnection, tryQuery} from "./database"; 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})[] { static toUnixTime<T extends {time: string | number}>(...snapshots: T[]): (T & {time: number})[] {
return snapshots.map(s => ({...s, time: toUnixTime(s.time)})); 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)[][] { 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"> { static isSubmissibleSnapshot(potentialSnapshot: Record<string, unknown>): potentialSnapshot is Omit<Snapshot, "id"> {
@@ -72,6 +91,15 @@ class SnapshotCollection {
|| typeof potentialSnapshot.time === "string" && isValidDatetime(potentialSnapshot.time)); || 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[] { private static rowsToSnapshots(...rows: RowDataPacket[]): ISOSnapshot[] {
return rows.map(row => ({ return rows.map(row => ({
id: row.id, id: row.id,

View 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;

View File

@@ -2,15 +2,18 @@ import express from "express";
import {ClayPIError, GenericPersistenceError} from "./errors"; import {ClayPIError, GenericPersistenceError} from "./errors";
import newSnapshotRouter from "./snapshotRouter"; import newSnapshotRouter from "./snapshotRouter";
import {CollectionRegistry} from "./Collections"; import {CollectionRegistry} from "./Collections";
import newByteSeriesRouter from "./byteSeriesRouter";
export function newMainRouter(collections: CollectionRegistry) { export function newMainRouter(collections: CollectionRegistry) {
const router = express.Router(); const router = express.Router();
const snapshotRouter = newSnapshotRouter(collections); const snapshotRouter = newSnapshotRouter(collections);
const byteSeriesRouter = newByteSeriesRouter(collections);
router.get("/dashboard", (req, res) => { router.get("/dashboard", (req, res) => {
res.render("index.ejs", { rootUrl: req.app.locals.rootUrl }); res.render("index.ejs", { rootUrl: req.app.locals.rootUrl });
}); });
router.use("/api/snapshots", snapshotRouter); router.use("/api/snapshots", snapshotRouter);
router.use("/api/timeseries", byteSeriesRouter);
router.use(topLevelErrorHandler); router.use(topLevelErrorHandler);
return router; return router;

View File

@@ -6,7 +6,7 @@ import {ClayPIError} from "./errors";
async function pingSensors(): Promise<Omit<ISOSnapshot, "id">> { async function pingSensors(): Promise<Omit<ISOSnapshot, "id">> {
try { 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 result = process.stdout;
const snapshotArray = result.split("\t").map(piece => piece.trim()); const snapshotArray = result.split("\t").map(piece => piece.trim());
return { return {

View File

@@ -3,7 +3,7 @@ import SnapshotCollection from "./SnapshotCollection";
import express, {Router} from "express"; import express, {Router} from "express";
import {CollectionRegistry} from "./Collections"; import {CollectionRegistry} from "./Collections";
import {ClayPIError} from "./errors"; import {ClayPIError} from "./errors";
import {toMySQLDatetime} from "./utils"; import {unixTimeParamMiddleware} from "./utils";
function newSnapshotRouter(collections: CollectionRegistry) { function newSnapshotRouter(collections: CollectionRegistry) {
const router = Router(); const router = Router();
@@ -80,14 +80,4 @@ function newSnapshotRouter(collections: CollectionRegistry) {
return router; 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; export default newSnapshotRouter;

View File

@@ -1,4 +1,5 @@
import {DataValidationError} from "./errors"; import {ClayPIError, DataValidationError} from "./errors";
import express from "express";
export function toMySQLDatetime(datetime: number | string) { export function toMySQLDatetime(datetime: number | string) {
try { try {
@@ -24,3 +25,13 @@ export function toUnixTime(datetime: string | number) {
export function toISOTime(datetime: string | number) { export function toISOTime(datetime: string | number) {
return new Date(datetime).toISOString(); 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();
}
};