Revamping this whole thing
This commit is contained in:
29
webapp/dist/charts.html
vendored
29
webapp/dist/charts.html
vendored
@@ -6,32 +6,7 @@
|
|||||||
<link href="{{.}}/static/styles.css" rel="stylesheet" />
|
<link href="{{.}}/static/styles.css" rel="stylesheet" />
|
||||||
<script src="{{.}}/static/main.js"></script>
|
<script src="{{.}}/static/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body id="root">
|
<body>
|
||||||
<div class="center">
|
<div id="root"></div>
|
||||||
<h1>Ledda's Room Climate</h1>
|
|
||||||
<div class="main-content-grid">
|
|
||||||
<div id="chart-container" class="widget">
|
|
||||||
<canvas id="myChart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div id="timer-widget" class="widget">
|
|
||||||
<div>
|
|
||||||
<h2>Next update in:</h2>
|
|
||||||
<p id="timer">0.00</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="mins-widget" class="widget">
|
|
||||||
<div>
|
|
||||||
<h2>Minutes Displayed:</h2>
|
|
||||||
<p id="mins-displayed">60</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="timezone-widget" class="widget">
|
|
||||||
<div>
|
|
||||||
<h2>Displayed Timezone:</h2>
|
|
||||||
<p>UTC</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
18
webapp/dist/styles.css
vendored
18
webapp/dist/styles.css
vendored
@@ -14,8 +14,6 @@ html, body {
|
|||||||
height: 80%;
|
height: 80%;
|
||||||
width: 80%;
|
width: 80%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: repeat(5, 1fr);
|
|
||||||
grid-template-columns: repeat(12, 1fr);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.main-content-grid > * {
|
.main-content-grid > * {
|
||||||
@@ -73,19 +71,3 @@ h1 {
|
|||||||
.widget p {
|
.widget p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#timer-widget {
|
|
||||||
grid-area: 1 / 11 / span 1 / span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mins-widget {
|
|
||||||
grid-area: auto / 11 / span 2 / span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#timezone-widget {
|
|
||||||
grid-area: auto / 11 / span 2 / span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chart-container {
|
|
||||||
grid-area: 1 / 1 / span 5 / span 10;
|
|
||||||
}
|
|
||||||
65
webapp/src/AppUI.ts
Normal file
65
webapp/src/AppUI.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import TimezoneWidget from "./TimezoneWidget";
|
||||||
|
import MinutesDisplayedWidget from "./MinutesDisplayedWidget";
|
||||||
|
import TimerWidget from "./TimerWidget";
|
||||||
|
import ClimateChartWidget from "./ClimateChartWidget";
|
||||||
|
import {GridSize} from "./GridWidget";
|
||||||
|
import MessageOverlay from "./MessageOverlay";
|
||||||
|
|
||||||
|
export interface UIComponent {
|
||||||
|
current(): HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppUI {
|
||||||
|
private timezoneWidget: TimezoneWidget;
|
||||||
|
private minutesDisplayedWidget: MinutesDisplayedWidget;
|
||||||
|
private timerWidget: TimerWidget;
|
||||||
|
private chartWidget: ClimateChartWidget;
|
||||||
|
private element: HTMLDivElement = document.createElement("div");
|
||||||
|
private grid: HTMLDivElement = document.createElement("div");
|
||||||
|
private messageOverlay: MessageOverlay = new MessageOverlay();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.setupGrid({width: 5, height: 12});
|
||||||
|
this.element.append(
|
||||||
|
Object.assign(document.createElement("h1"), { innerText: "Ledda's Room Climate" }),
|
||||||
|
this.grid,
|
||||||
|
this.messageOverlay.current(),
|
||||||
|
);
|
||||||
|
this.element.className = 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGrid(size: GridSize) {
|
||||||
|
this.setupWidgets();
|
||||||
|
this.grid.append(
|
||||||
|
this.chartWidget.current(),
|
||||||
|
this.timezoneWidget.current(),
|
||||||
|
this.minutesDisplayedWidget.current(),
|
||||||
|
this.timerWidget.current()
|
||||||
|
);
|
||||||
|
this.grid.className = "main-content-grid";
|
||||||
|
this.grid.style.gridTemplateRows = `repeat(${size.width}, 1fr)`;
|
||||||
|
this.grid.style.gridTemplateColumns = `repeat(${size.height}, 1fr)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupWidgets() {
|
||||||
|
this.timerWidget = new TimerWidget({
|
||||||
|
row: 1, col: 11, width: 1, height: 2,
|
||||||
|
});
|
||||||
|
this.minutesDisplayedWidget = new MinutesDisplayedWidget({
|
||||||
|
row: "auto", col: 11, width: 2, height: 2,
|
||||||
|
});
|
||||||
|
this.timezoneWidget = new TimezoneWidget({
|
||||||
|
row: "auto", col: 11, width: 2, height: 2,
|
||||||
|
});
|
||||||
|
this.chartWidget = new ClimateChartWidget({
|
||||||
|
row: 1, col: 1, width: 5, height: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap(rootNode: string) {
|
||||||
|
document.getElementById(rootNode).append(this.element);
|
||||||
|
this.timerWidget.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppUI;
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import Chart from "chart.js/dist/Chart.bundle.min";
|
|
||||||
import {generateClimateChartConfig} from "./climateChartConfig";
|
|
||||||
import {config} from "./main";
|
|
||||||
|
|
||||||
interface Snapshot {
|
|
||||||
id: number,
|
|
||||||
temp: number,
|
|
||||||
humidity: number,
|
|
||||||
co2: number,
|
|
||||||
time: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SnapshotRecords {
|
|
||||||
snapshots: Snapshot[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClimatePoint {
|
|
||||||
x: string;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ClimateChart {
|
|
||||||
private chart: Chart | null;
|
|
||||||
private latestSnapshot: Snapshot | null;
|
|
||||||
private onLoadedCallback: () => void = () => {};
|
|
||||||
private onErrorCallback: (e: Error) => void = () => {};
|
|
||||||
private errorLog: string = "";
|
|
||||||
private readonly dataEndpointBase: string;
|
|
||||||
private readonly domId: string;
|
|
||||||
private readonly minutesDisplayed: number = 60;
|
|
||||||
|
|
||||||
constructor(domId: string, minutesDisplayed: number) {
|
|
||||||
this.domId = domId;
|
|
||||||
if (config.development) {
|
|
||||||
this.dataEndpointBase = "http://tortedda.local/climate/data";
|
|
||||||
} else {
|
|
||||||
this.dataEndpointBase = "data";
|
|
||||||
}
|
|
||||||
this.minutesDisplayed = Math.floor(minutesDisplayed);
|
|
||||||
if (minutesDisplayed < 0 || Math.floor(minutesDisplayed) !== minutesDisplayed) {
|
|
||||||
console.warn(`Minutes passed were ${ minutesDisplayed }, which is invalid. ${ this.minutesDisplayed } minutes are being shown instead.`);
|
|
||||||
}
|
|
||||||
this.initChart().catch((e) => {this.logError(e);});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initChart() {
|
|
||||||
const canvasElement = document.getElementById(this.domId);
|
|
||||||
let ctx: CanvasRenderingContext2D;
|
|
||||||
if (ClimateChart.isCanvas(canvasElement)) {
|
|
||||||
ctx = canvasElement.getContext('2d');
|
|
||||||
} else {
|
|
||||||
throw new Error(`improper HTML element passed, needed type canvas, got ${canvasElement.tagName}`);
|
|
||||||
}
|
|
||||||
this.chart = new Chart(ctx, generateClimateChartConfig({}));
|
|
||||||
await this.update();
|
|
||||||
this.onLoadedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
async update() {
|
|
||||||
return this.updateFromServer().catch(e => this.logError(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getNewSnapshots(): Promise<SnapshotRecords> {
|
|
||||||
const lastTimeInChart = this.latestSnapshot?.time ?? null;
|
|
||||||
if (!lastTimeInChart) {
|
|
||||||
const minutesAsDate = (new Date().getTime() - this.minutesDisplayed * 60000);
|
|
||||||
const dataEndpoint = `${ this.dataEndpointBase }?since=${ new Date(minutesAsDate).toISOString() }`;
|
|
||||||
return (await fetch(dataEndpoint)).json();
|
|
||||||
} else {
|
|
||||||
const url = `${ this.dataEndpointBase }?since=${ new Date(lastTimeInChart + "+00:00").toISOString() }`;
|
|
||||||
return (await fetch(url)).json();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async tryGetNewSnapshots(): Promise<SnapshotRecords> {
|
|
||||||
try {
|
|
||||||
return this.getNewSnapshots();
|
|
||||||
} catch (e) {
|
|
||||||
this.logError(`Server error: ${e}`);
|
|
||||||
return { snapshots: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateFromServer() {
|
|
||||||
const payload = await this.tryGetNewSnapshots();
|
|
||||||
if (payload.snapshots.length > 0) {
|
|
||||||
const oldLatestTime = new Date(this.latestSnapshot?.time ?? null).getTime();
|
|
||||||
const newLatestTime = new Date(payload.snapshots[0].time).getTime();
|
|
||||||
if (newLatestTime > oldLatestTime) {
|
|
||||||
this.removePointsOlderThan(newLatestTime - this.minutesDisplayed * 60000);
|
|
||||||
this.latestSnapshot = payload.snapshots[0];
|
|
||||||
this.insertSnapshots(...payload.snapshots);
|
|
||||||
this.rerender();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private rerender() {
|
|
||||||
this.chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
private insertSnapshots(...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});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private removePointsOlderThan(referenceTime: number) {
|
|
||||||
for (let i = 0; i < this.humidityPointList().length; i++) {
|
|
||||||
const timeOnPoint = this.humidityPointList()[i].x;
|
|
||||||
if (new Date(timeOnPoint).getTime() < referenceTime) {
|
|
||||||
this.humidityPointList().splice(i, 1);
|
|
||||||
this.tempPointList().splice(i, 1);
|
|
||||||
this.co2PointList().splice(i, 1);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoaded(callback: () => void) {
|
|
||||||
this.onLoadedCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
onErrored(callback: (e: Error) => void) {
|
|
||||||
this.onErrorCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static isCanvas(el: HTMLElement): el is HTMLCanvasElement {
|
|
||||||
return el.tagName === "CANVAS";
|
|
||||||
}
|
|
||||||
|
|
||||||
private logError(error: string) {
|
|
||||||
this.errorLog += `${new Date().toISOString()}: ${ error }\n`;
|
|
||||||
this.onErrorCallback(new Error(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ClimateChart;
|
|
||||||
146
webapp/src/ClimateChartWidget.ts
Normal file
146
webapp/src/ClimateChartWidget.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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 {UIComponent} from "./AppUI";
|
||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
|
||||||
|
interface ClimatePoint {
|
||||||
|
x: string;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClimateChartWidget implements UIComponent {
|
||||||
|
private readonly skeleton: GridWidget;
|
||||||
|
private chart: Chart | null;
|
||||||
|
private displayMode: DisplayMode = "pastMins";
|
||||||
|
private displayedWin: TimeWindow;
|
||||||
|
private latestSnapshotInChartTime: number;
|
||||||
|
private readonly canvasElement: HTMLCanvasElement = document.createElement("canvas");
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
this.skeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
body: this.canvasElement,
|
||||||
|
});
|
||||||
|
this.latestSnapshotInChartTime = new Date().getTime() - getAppState().minutesDisplayed * 60000;
|
||||||
|
this.setupListeners();
|
||||||
|
this.initChart().catch((e) => {
|
||||||
|
AppStore().setLoading(false);
|
||||||
|
AppStore().fatalError(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners() {
|
||||||
|
AppStore().subscribe("displayMode", () => this.update());
|
||||||
|
AppStore().subscribe("minutesDisplayed", () => this.update());
|
||||||
|
AppStore().subscribe("displayWindow", () => this.update());
|
||||||
|
AppStore().subscribe("snapshots", () => this.update());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initChart() {
|
||||||
|
let ctx = this.canvasElement.getContext('2d');
|
||||||
|
this.chart = new Chart(ctx, generateClimateChartConfig({}));
|
||||||
|
await this.update();
|
||||||
|
AppStore().setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async update() {
|
||||||
|
if (getAppState().displayMode === "window") {
|
||||||
|
await this.updateChartFromTimeWindow(getAppState().displayWindow);
|
||||||
|
this.displayedWin = getAppState().displayWindow;
|
||||||
|
} else if (getAppState().displayMode === "pastMins" && this.displayMode !== "pastMins") {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const newTimeWindow = { start: now - getAppState().minutesDisplayed * 60000, stop: now };
|
||||||
|
await this.updateChartFromTimeWindow(newTimeWindow);
|
||||||
|
} else {
|
||||||
|
await this.updateChartFromMinuteSpan(getAppState().minutesDisplayed);
|
||||||
|
}
|
||||||
|
this.chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateChartFromTimeWindow(newWin: TimeWindow) {
|
||||||
|
const oldWin = this.displayedWin;
|
||||||
|
if (newWin !== oldWin) {
|
||||||
|
if (newWin.start > oldWin.start) {
|
||||||
|
this.removePointsOlderThan(newWin.start);
|
||||||
|
} else if (newWin.start < oldWin.start) {
|
||||||
|
this.prependSnapshots(await AppStore().snapshotsBetween(newWin.start, oldWin.start));
|
||||||
|
}
|
||||||
|
if (newWin.stop < oldWin.stop) {
|
||||||
|
this.removePointsNewerThan(getAppState().displayWindow.stop);
|
||||||
|
} else if (newWin.stop > oldWin.stop) {
|
||||||
|
this.appendSnapshots(await AppStore().snapshotsBetween(oldWin.stop, newWin.stop));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateChartFromMinuteSpan(mins: number) {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
this.removePointsOlderThan(now - mins * 60000);
|
||||||
|
this.appendSnapshots(await AppStore().snapshotsBetween(this.latestSnapshotInChartTime, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = new Date(snapshots[snapshots.length - 1].time).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
private prependSnapshots(snapshots: Snapshot[]) {
|
||||||
|
for (const snapshot of snapshots.reverse()) {
|
||||||
|
this.humidityPointList().unshift({x: snapshot.time, y: snapshot.humidity});
|
||||||
|
this.tempPointList().unshift({x: snapshot.time, y: snapshot.temp});
|
||||||
|
this.co2PointList().unshift({x: snapshot.time, y: snapshot.co2});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removePointsOlderThan(referenceTime: number) {
|
||||||
|
for (let i = 0; i < this.humidityPointList().length; i++) {
|
||||||
|
const timeOnPoint = this.humidityPointList()[i].x;
|
||||||
|
if (new Date(timeOnPoint).getTime() < referenceTime) {
|
||||||
|
this.removePoint(i);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removePointsNewerThan(referenceTime: number) {
|
||||||
|
for (let i = this.humidityPointList().length - 1; i > -1; i--) {
|
||||||
|
const timeOnPoint = this.humidityPointList()[i].x;
|
||||||
|
if (new Date(timeOnPoint).getTime() > referenceTime) {
|
||||||
|
this.removePoint(i);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removePoint(index: number) {
|
||||||
|
this.humidityPointList().splice(index, 1);
|
||||||
|
this.tempPointList().splice(index, 1);
|
||||||
|
this.co2PointList().splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
return this.skeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClimateChartWidget;
|
||||||
78
webapp/src/ClimateDataStore.ts
Normal file
78
webapp/src/ClimateDataStore.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {AppStore, TimeWindow} from "./StateStore";
|
||||||
|
import Snapshot from "./Snapshot";
|
||||||
|
|
||||||
|
interface SnapshotRecords {
|
||||||
|
snapshots: Snapshot[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClimateDataStore {
|
||||||
|
private cachedSpan: TimeWindow;
|
||||||
|
private cache: Snapshot[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.cache = [];
|
||||||
|
this.cachedSpan = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCache() {
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFromWindow(start: number, stop: number) {
|
||||||
|
if (!this.cacheValidForWindow(start, stop)) {
|
||||||
|
await this.fetchMissingSnapshotsBetween(start, stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotsBetween(start: number, stop: number): Promise<Snapshot[]> {
|
||||||
|
if (this.cacheValidForWindow(start, stop)) {
|
||||||
|
return this.cachedBetween(start, stop);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.fetchMissingSnapshotsBetween(start, stop);
|
||||||
|
return this.cachedBetween(start, stop);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Server error: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheValidForWindow(start: number, stop: number) {
|
||||||
|
if (this.cachedSpan) {
|
||||||
|
return start >= this.cachedSpan.start && stop <= this.cachedSpan.stop;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchMissingSnapshotsBetween(start: number, stop: number) {
|
||||||
|
const dataEndpoint = `${ AppStore().getState().dataEndpointBase }?since=${ new Date(start).toISOString() }`;
|
||||||
|
const payload = await fetch(dataEndpoint);
|
||||||
|
this.cache = ((await payload.json()) as SnapshotRecords).snapshots.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cachedBetween(start: number, stop: number) {
|
||||||
|
console.log(this.cache.length);
|
||||||
|
const cacheStart = this.findInCacheListRange(start, 0, this.cache.length - 1);
|
||||||
|
const cacheStop = this.findInCacheListRange(stop, 1, this.cache.length);
|
||||||
|
console.log(cacheStart, cacheStop, start, stop, this.cache);
|
||||||
|
return this.cache.slice(cacheStart, cacheStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findInCacheListRange(soughtTime: number, listStart: number, listStop: number): number {
|
||||||
|
if (listStop - listStart === 1) {
|
||||||
|
return listStart;
|
||||||
|
} else {
|
||||||
|
const middle = Math.floor((listStop + listStart) / 2);
|
||||||
|
const middleTime = new Date(this.cache[middle].time).getTime();
|
||||||
|
if (middleTime > soughtTime) {
|
||||||
|
return this.findInCacheListRange(soughtTime, listStart, middle);
|
||||||
|
} else if (middleTime < soughtTime) {
|
||||||
|
return this.findInCacheListRange(soughtTime, middle, listStop);
|
||||||
|
} else {
|
||||||
|
return middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClimateDataStore;
|
||||||
62
webapp/src/GridWidget.ts
Normal file
62
webapp/src/GridWidget.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {UIComponent} from "./AppUI";
|
||||||
|
|
||||||
|
export interface GridPosition {
|
||||||
|
row: number | "auto";
|
||||||
|
col: number | "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridSize {
|
||||||
|
width: number | "auto";
|
||||||
|
height: number | "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridProps extends GridSize, GridPosition {}
|
||||||
|
|
||||||
|
interface GridWidgetProps extends GridProps {
|
||||||
|
title?: string;
|
||||||
|
body?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GridWidget implements UIComponent {
|
||||||
|
private container: HTMLDivElement = document.createElement("div");
|
||||||
|
private title: HTMLHeadingElement = document.createElement("h2");
|
||||||
|
private body: HTMLElement = document.createElement("div");
|
||||||
|
|
||||||
|
constructor(props: GridWidgetProps) {
|
||||||
|
this.container.className = "widget";
|
||||||
|
this.setTitle(props.title);
|
||||||
|
this.setPosition({ row: props.row, col: props.col });
|
||||||
|
this.setSize({ width: props.width, height: props.height });
|
||||||
|
if (props.title) {
|
||||||
|
this.container.append(this.title);
|
||||||
|
}
|
||||||
|
if (props.body) {
|
||||||
|
this.body = props.body;
|
||||||
|
this.container.append(this.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition(pos: GridPosition) {
|
||||||
|
this.container.style.gridRowStart = `${pos.row}`;
|
||||||
|
this.container.style.gridColumnStart = `${pos.col}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSize(size: GridSize) {
|
||||||
|
this.container.style.gridRowEnd = `span ${size.width}`;
|
||||||
|
this.container.style.gridColumnEnd = `span ${size.height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(newTitle: string) {
|
||||||
|
this.title.innerText = newTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceBody(newEl: HTMLElement) {
|
||||||
|
this.body.replaceWith(newEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GridWidget;
|
||||||
62
webapp/src/MessageOverlay.ts
Normal file
62
webapp/src/MessageOverlay.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {AppStore, getAppState} from "./StateStore";
|
||||||
|
import {UIComponent} from "./AppUI";
|
||||||
|
|
||||||
|
class MessageOverlay implements UIComponent {
|
||||||
|
private element: HTMLDivElement;
|
||||||
|
private textElement: HTMLSpanElement;
|
||||||
|
private showingError: boolean = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.build();
|
||||||
|
AppStore().subscribe("overlayText", () => this.update());
|
||||||
|
AppStore().subscribe("isLoading", () => this.update());
|
||||||
|
AppStore().subscribe("fatalError", () => this.showError())
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private build() {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
private hide() {
|
||||||
|
this.element.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
private showError() {
|
||||||
|
const err = getAppState().fatalError;
|
||||||
|
this.showingError = true;
|
||||||
|
this.element.innerText = `${err.name}: ${err.message}!`;
|
||||||
|
this.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.showingError) {
|
||||||
|
let text: string;
|
||||||
|
if (getAppState().isLoading) {
|
||||||
|
text = "Loading...";
|
||||||
|
} else if (getAppState().overlayText) {
|
||||||
|
text = getAppState().overlayText;
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
this.textElement.innerText = text;
|
||||||
|
this.show();
|
||||||
|
} else {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageOverlay;
|
||||||
28
webapp/src/MinutesDisplayedWidget.ts
Normal file
28
webapp/src/MinutesDisplayedWidget.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {UIComponent} from "./AppUI";
|
||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import {AppStore} from "./StateStore";
|
||||||
|
|
||||||
|
class MinutesDisplayedWidget implements UIComponent {
|
||||||
|
private skeleton: GridWidget;
|
||||||
|
private display: HTMLSpanElement = document.createElement("span");
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
this.skeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
title: "Minutes Displayed:",
|
||||||
|
body: this.display,
|
||||||
|
});
|
||||||
|
AppStore().subscribe("minutesDisplayed", () => this.updateDisplay());
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDisplay() {
|
||||||
|
this.display.innerText = `${AppStore().getState().minutesDisplayed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.skeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MinutesDisplayedWidget;
|
||||||
9
webapp/src/Snapshot.ts
Normal file
9
webapp/src/Snapshot.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
interface Snapshot {
|
||||||
|
id: number,
|
||||||
|
temp: number,
|
||||||
|
humidity: number,
|
||||||
|
co2: number,
|
||||||
|
time: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Snapshot;
|
||||||
178
webapp/src/StateStore.ts
Normal file
178
webapp/src/StateStore.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import Snapshot from "./Snapshot";
|
||||||
|
import ClimateDataStore from "./ClimateDataStore";
|
||||||
|
|
||||||
|
export class AppStateError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AppStateError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisplayMode = "window" | "pastMins";
|
||||||
|
|
||||||
|
export interface TimeWindow {
|
||||||
|
start: number;
|
||||||
|
stop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
lastUpdateTime: number;
|
||||||
|
displayWindow: TimeWindow;
|
||||||
|
minutesDisplayed: number;
|
||||||
|
utcOffset: number;
|
||||||
|
snapshots: Snapshot[];
|
||||||
|
overlayText: string;
|
||||||
|
dataEndpointBase: string;
|
||||||
|
updateIntervalSeconds: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
displayMode: DisplayMode;
|
||||||
|
fatalError: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppStateStore {
|
||||||
|
private readonly subscriptions: Record<keyof AppState, Function[]>;
|
||||||
|
private readonly state: AppState;
|
||||||
|
private readonly climateDataStore: ClimateDataStore = new ClimateDataStore();
|
||||||
|
private initialised: boolean = false;
|
||||||
|
|
||||||
|
constructor(initialState: AppState) {
|
||||||
|
this.state = initialState;
|
||||||
|
const subscriptions: Record<string, any[]> = {};
|
||||||
|
for (const key in this.state) {
|
||||||
|
subscriptions[key] = [];
|
||||||
|
}
|
||||||
|
this.subscriptions = subscriptions as Record<keyof AppState, Function[]>;
|
||||||
|
setInterval(() => this.updateClimateData(), this.state.updateIntervalSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (!this.initialised) {
|
||||||
|
await this.updateClimateData();
|
||||||
|
this.initialised = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(subscribedValue: keyof AppState) {
|
||||||
|
for (const subscriptionCallback of this.subscriptions[subscribedValue]) {
|
||||||
|
subscriptionCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateClimateData() {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
if (this.state.displayMode === "window") {
|
||||||
|
await this.climateDataStore.updateFromWindow(
|
||||||
|
this.state.displayWindow.start,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.climateDataStore.updateFromWindow(
|
||||||
|
now - this.state.minutesDisplayed * 60000,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.state.lastUpdateTime = now;
|
||||||
|
this.state.snapshots = this.climateDataStore.getCache();
|
||||||
|
this.notify("snapshots");
|
||||||
|
this.notify("lastUpdateTime");
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotsBetween(start: number, stop: number) {
|
||||||
|
return this.climateDataStore.snapshotsBetween(start, stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): AppState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(dataName: keyof AppState, callback: () => any) {
|
||||||
|
this.subscriptions[dataName].push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayMode(mode: DisplayMode) {
|
||||||
|
this.state.displayMode = mode;
|
||||||
|
this.notify("displayMode");
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayWindow(newWin: TimeWindow) {
|
||||||
|
if (newWin.start < newWin.stop) {
|
||||||
|
this.state.displayWindow = {...newWin};
|
||||||
|
this.notify("displayWindow");
|
||||||
|
} else {
|
||||||
|
throw new AppStateError(`Invalid display window from ${newWin.start} to ${newWin.stop}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMinutesDisplayed(mins: number) {
|
||||||
|
if (mins > 0) {
|
||||||
|
this.state.minutesDisplayed = Math.ceil(mins);
|
||||||
|
this.notify("minutesDisplayed");
|
||||||
|
} else {
|
||||||
|
throw new AppStateError(`Invalid minutes passed: ${mins}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addSnapshots(...snapshots: Snapshot[]) {
|
||||||
|
this.state.snapshots.push(...snapshots);
|
||||||
|
this.notify("snapshots");
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLastUpdateTime(time: number) {
|
||||||
|
if (this.state.lastUpdateTime < time) {
|
||||||
|
this.state.lastUpdateTime = time;
|
||||||
|
this.notify("lastUpdateTime");
|
||||||
|
} else {
|
||||||
|
throw new AppStateError(`Bad new update time was in the past: ${time}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverlayText(text: string) {
|
||||||
|
this.state.overlayText = text;
|
||||||
|
this.notify("overlayText");
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(isLoading: boolean) {
|
||||||
|
this.state.isLoading = isLoading;
|
||||||
|
this.notify("isLoading");
|
||||||
|
}
|
||||||
|
|
||||||
|
fatalError(err: Error) {
|
||||||
|
if (!this.state.fatalError) {
|
||||||
|
this.state.fatalError = err;
|
||||||
|
this.notify("fatalError");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: AppStateStore;
|
||||||
|
|
||||||
|
export async function initStore(initialState: AppState) {
|
||||||
|
store = new AppStateStore(initialState);
|
||||||
|
await store.init();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppStore() {
|
||||||
|
if (store) {
|
||||||
|
return store;
|
||||||
|
} else {
|
||||||
|
throw new AppStateError("Store not yet initialised!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppState() {
|
||||||
|
if (store) {
|
||||||
|
return store.getState();
|
||||||
|
} else {
|
||||||
|
throw new AppStateError("Store not yet initialised!");
|
||||||
|
}
|
||||||
|
}
|
||||||
36
webapp/src/TimerWidget.ts
Normal file
36
webapp/src/TimerWidget.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {AppStore, getAppState} from "./StateStore";
|
||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import {UIComponent} from "./AppUI";
|
||||||
|
|
||||||
|
class TimerWidget implements UIComponent {
|
||||||
|
private readonly display: HTMLSpanElement = document.createElement("span");
|
||||||
|
private skeleton: GridWidget;
|
||||||
|
private nextUpdateTime: number;
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
this.skeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
title: "Next update in:",
|
||||||
|
body: this.display,
|
||||||
|
});
|
||||||
|
AppStore().subscribe("lastUpdateTime", () => this.resetTimer());
|
||||||
|
setInterval(() => this.refreshDisplay(), 10);
|
||||||
|
this.resetTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTimer() {
|
||||||
|
this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds * 1000;
|
||||||
|
this.refreshDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshDisplay() {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
this.display.innerText = ((this.nextUpdateTime - now)/1000).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.skeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimerWidget;
|
||||||
29
webapp/src/TimezoneWidget.ts
Normal file
29
webapp/src/TimezoneWidget.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {UIComponent} from "./AppUI";
|
||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import {AppStore} from "./StateStore";
|
||||||
|
|
||||||
|
class TimezoneWidget implements UIComponent {
|
||||||
|
private skeleton: GridWidget;
|
||||||
|
private display: HTMLSpanElement = document.createElement("span");
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
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;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"development": false,
|
"development": false,
|
||||||
"defaultMinuteSpan": 60,
|
"defaultMinuteSpan": 60,
|
||||||
"reloadIntervalSec": 30
|
"reloadIntervalSec": 30,
|
||||||
|
"dataEndpoint": "http://tortedda.local/climate/data"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
import ClimateChart from "./ClimateChart";
|
|
||||||
import config from "./config.json";
|
import config from "./config.json";
|
||||||
|
import {initStore} from "./StateStore";
|
||||||
|
import AppUI from "./AppUI";
|
||||||
export {config};
|
export {config};
|
||||||
|
|
||||||
const CHART_DOM_ID: string = "myChart";
|
function getDisplayedMinutes() {
|
||||||
let climateChart: ClimateChart;
|
|
||||||
let timer = config.reloadIntervalSec * 1000;
|
|
||||||
let timerElement: HTMLElement;
|
|
||||||
let lastTimerUpdate = new Date().getTime();
|
|
||||||
let rootUrl: string = "";
|
|
||||||
|
|
||||||
function createClimateChart() {
|
|
||||||
const pathname = window.location.pathname;
|
|
||||||
if (pathname !== "/") {
|
|
||||||
rootUrl += pathname.match(/\/[^?\s]*/)[0];
|
|
||||||
}
|
|
||||||
let minutesDisplayed = config.defaultMinuteSpan;
|
let minutesDisplayed = config.defaultMinuteSpan;
|
||||||
const argsStart = window.location.search.search(/\?minute-span=/);
|
const argsStart = window.location.search.search(/\?minute-span=/);
|
||||||
if (argsStart !== -1) {
|
if (argsStart !== -1) {
|
||||||
@@ -22,38 +12,33 @@ function createClimateChart() {
|
|||||||
minutesDisplayed = parsedMins;
|
minutesDisplayed = parsedMins;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new ClimateChart(CHART_DOM_ID, minutesDisplayed);
|
return minutesDisplayed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateChart() {
|
function getUtcOffset() {
|
||||||
timer = config.reloadIntervalSec * 1000;
|
return 0;
|
||||||
climateChart.update();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTimer() {
|
async function init() {
|
||||||
timer -= new Date().getTime() - lastTimerUpdate;
|
const now = new Date().getTime();
|
||||||
timerElement.innerText = (timer / 1000).toFixed(2);
|
await initStore({
|
||||||
lastTimerUpdate = new Date().getTime();
|
overlayText: "",
|
||||||
|
lastUpdateTime: now,
|
||||||
|
minutesDisplayed: getDisplayedMinutes(),
|
||||||
|
utcOffset: getUtcOffset(),
|
||||||
|
snapshots: [],
|
||||||
|
dataEndpointBase: config.dataEndpoint,
|
||||||
|
isLoading: true,
|
||||||
|
updateIntervalSeconds: config.reloadIntervalSec,
|
||||||
|
displayMode: "pastMins",
|
||||||
|
fatalError: null,
|
||||||
|
displayWindow: {start: now - getDisplayedMinutes() * 60000, stop: now},
|
||||||
|
});
|
||||||
|
const ui = new AppUI();
|
||||||
|
ui.bootstrap("root");
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.classList.add('overlay', 'center');
|
|
||||||
const textContainer = document.createElement('span');
|
|
||||||
textContainer.innerText = 'Loading data...';
|
|
||||||
overlay.appendChild(textContainer);
|
|
||||||
|
|
||||||
document.onreadystatechange = (e) => {
|
document.onreadystatechange = (e) => {
|
||||||
timerElement = document.getElementById('timer');
|
init();
|
||||||
document.getElementById("root").appendChild(overlay);
|
|
||||||
climateChart = createClimateChart();
|
|
||||||
climateChart.onLoaded(() => {
|
|
||||||
overlay.classList.add('hidden');
|
|
||||||
setInterval(updateTimer, 10);
|
|
||||||
setInterval(updateChart, config.reloadIntervalSec * 1000);
|
|
||||||
});
|
|
||||||
climateChart.onErrored((e) => {
|
|
||||||
overlay.classList.remove('hidden');
|
|
||||||
textContainer.innerText = `An error occurred: ${e}\nTry reloading the page.`;
|
|
||||||
});
|
|
||||||
document.onreadystatechange = () => {};
|
document.onreadystatechange = () => {};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user