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

-
-
- -
-
-
-

Next update in:

-

0.00

-
-
-
-
-

Minutes Displayed:

-

60

-
-
-
-
-

Displayed Timezone:

-

UTC

-
-
-
-
+ +
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 = () => {}; };