diff --git a/webapp/dist/charts.html b/webapp/dist/charts.html
index 8363087..126eadc 100644
--- a/webapp/dist/charts.html
+++ b/webapp/dist/charts.html
@@ -6,32 +6,7 @@
-
-
-
Ledda's Room Climate
-
-
+
+
diff --git a/webapp/dist/styles.css b/webapp/dist/styles.css
index caa32be..5d82c51 100644
--- a/webapp/dist/styles.css
+++ b/webapp/dist/styles.css
@@ -14,8 +14,6 @@ html, body {
height: 80%;
width: 80%;
display: grid;
- grid-template-rows: repeat(5, 1fr);
- grid-template-columns: repeat(12, 1fr);
text-align: center;
}
.main-content-grid > * {
@@ -73,19 +71,3 @@ h1 {
.widget p {
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;
-}
\ No newline at end of file
diff --git a/webapp/src/AppUI.ts b/webapp/src/AppUI.ts
new file mode 100644
index 0000000..c1b99c4
--- /dev/null
+++ b/webapp/src/AppUI.ts
@@ -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;
\ No newline at end of file
diff --git a/webapp/src/ClimateChart.ts b/webapp/src/ClimateChart.ts
deleted file mode 100644
index edc378f..0000000
--- a/webapp/src/ClimateChart.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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;
\ No newline at end of file
diff --git a/webapp/src/ClimateChartWidget.ts b/webapp/src/ClimateChartWidget.ts
new file mode 100644
index 0000000..420a76f
--- /dev/null
+++ b/webapp/src/ClimateChartWidget.ts
@@ -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;
\ No newline at end of file
diff --git a/webapp/src/ClimateDataStore.ts b/webapp/src/ClimateDataStore.ts
new file mode 100644
index 0000000..77593da
--- /dev/null
+++ b/webapp/src/ClimateDataStore.ts
@@ -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 {
+ 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;
\ No newline at end of file
diff --git a/webapp/src/GridWidget.ts b/webapp/src/GridWidget.ts
new file mode 100644
index 0000000..d1f69a6
--- /dev/null
+++ b/webapp/src/GridWidget.ts
@@ -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;
\ No newline at end of file
diff --git a/webapp/src/MessageOverlay.ts b/webapp/src/MessageOverlay.ts
new file mode 100644
index 0000000..4a22d46
--- /dev/null
+++ b/webapp/src/MessageOverlay.ts
@@ -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;
\ No newline at end of file
diff --git a/webapp/src/MinutesDisplayedWidget.ts b/webapp/src/MinutesDisplayedWidget.ts
new file mode 100644
index 0000000..897958f
--- /dev/null
+++ b/webapp/src/MinutesDisplayedWidget.ts
@@ -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;
\ No newline at end of file
diff --git a/webapp/src/Snapshot.ts b/webapp/src/Snapshot.ts
new file mode 100644
index 0000000..70539a9
--- /dev/null
+++ b/webapp/src/Snapshot.ts
@@ -0,0 +1,9 @@
+interface Snapshot {
+ id: number,
+ temp: number,
+ humidity: number,
+ co2: number,
+ time: string,
+}
+
+export default Snapshot;
\ No newline at end of file
diff --git a/webapp/src/StateStore.ts b/webapp/src/StateStore.ts
new file mode 100644
index 0000000..9debe02
--- /dev/null
+++ b/webapp/src/StateStore.ts
@@ -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;
+ private readonly state: AppState;
+ private readonly climateDataStore: ClimateDataStore = new ClimateDataStore();
+ private initialised: boolean = false;
+
+ constructor(initialState: AppState) {
+ this.state = initialState;
+ const subscriptions: Record = {};
+ for (const key in this.state) {
+ subscriptions[key] = [];
+ }
+ this.subscriptions = subscriptions as Record;
+ 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!");
+ }
+}
\ No newline at end of file
diff --git a/webapp/src/TimerWidget.ts b/webapp/src/TimerWidget.ts
new file mode 100644
index 0000000..800a9d1
--- /dev/null
+++ b/webapp/src/TimerWidget.ts
@@ -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;
\ No newline at end of file
diff --git a/webapp/src/TimezoneWidget.ts b/webapp/src/TimezoneWidget.ts
new file mode 100644
index 0000000..7510679
--- /dev/null
+++ b/webapp/src/TimezoneWidget.ts
@@ -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;
\ No newline at end of file
diff --git a/webapp/src/config.json b/webapp/src/config.json
index f27e0b8..c50b749 100644
--- a/webapp/src/config.json
+++ b/webapp/src/config.json
@@ -1,5 +1,6 @@
{
"development": false,
"defaultMinuteSpan": 60,
- "reloadIntervalSec": 30
+ "reloadIntervalSec": 30,
+ "dataEndpoint": "http://tortedda.local/climate/data"
}
diff --git a/webapp/src/main.ts b/webapp/src/main.ts
index a28ff28..4d56f70 100644
--- a/webapp/src/main.ts
+++ b/webapp/src/main.ts
@@ -1,19 +1,9 @@
-import ClimateChart from "./ClimateChart";
import config from "./config.json";
+import {initStore} from "./StateStore";
+import AppUI from "./AppUI";
export {config};
-const CHART_DOM_ID: string = "myChart";
-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];
- }
+function getDisplayedMinutes() {
let minutesDisplayed = config.defaultMinuteSpan;
const argsStart = window.location.search.search(/\?minute-span=/);
if (argsStart !== -1) {
@@ -22,38 +12,33 @@ function createClimateChart() {
minutesDisplayed = parsedMins;
}
}
- return new ClimateChart(CHART_DOM_ID, minutesDisplayed);
+ return minutesDisplayed;
}
-async function updateChart() {
- timer = config.reloadIntervalSec * 1000;
- climateChart.update();
+function getUtcOffset() {
+ return 0;
}
-function updateTimer() {
- timer -= new Date().getTime() - lastTimerUpdate;
- timerElement.innerText = (timer / 1000).toFixed(2);
- lastTimerUpdate = new Date().getTime();
+async function init() {
+ const now = new Date().getTime();
+ await initStore({
+ 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) => {
- timerElement = document.getElementById('timer');
- 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.`;
- });
+ init();
document.onreadystatechange = () => {};
};