Files
climate-server/dashboard/src/StateStore.ts
2021-04-05 16:19:12 +02:00

390 lines
13 KiB
TypeScript

import Timeseries from "./Timeseries";
import {ScaleId} from "./chart/Chart";
import config from "./config.json";
export class AppStateError extends Error {
constructor(message: string) {
super(message);
this.name = "AppStateError";
}
}
export type DisplayMode = "window" | "pastMins";
export interface EventCallback {
timeseriesUpdated: (timeseries: Timeseries) => void;
newTimeseries: (timeseries: Timeseries, scale?: ScaleId) => void;
stateChange: StateChangeCallback<AppState, keyof AppState>;
ready: () => void;
}
type StateChangeCallback<T, K extends keyof T> = (attrName?: K, oldVal?: T[K], newVal?: T[K]) => void;
type EventCallbackListing<K extends keyof EventCallback> = Record<K, EventCallback[K][]>;
export interface TimeWindow {
start: number;
stop: number;
}
interface AppState {
lastUpdateTime: number;
displayWindow: TimeWindow;
minutesDisplayed: number;
utcOffset: number;
leftTimeseries: Timeseries[],
rightTimeseries: Timeseries[],
overlayText: string;
dataEndpointBase: string;
updateIntervalSeconds: number;
isLoading: boolean;
displayMode: DisplayMode;
fatalError: Error | null;
documentReady: boolean;
highlightedTimeseries: string | null;
showingHelp: boolean;
}
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
type SubscriptionType<K extends keyof AppState> = Record<K, StoreUpdateCallback<AppState[K]>[]>;
type IAppStateSubscriptions = SubscriptionType<keyof AppState>;
function newDefaultState(): AppState {
const now = new Date().getTime() / 1000;
return {
overlayText: "",
lastUpdateTime: now,
minutesDisplayed: config.defaultMinuteSpan,
utcOffset: -(new Date().getTimezoneOffset() / 60),
dataEndpointBase: config.dataEndpoint,
isLoading: false,
updateIntervalSeconds: config.reloadIntervalSec,
displayMode: "pastMins",
fatalError: null,
displayWindow: {start: now - config.defaultMinuteSpan * 60, stop: now},
documentReady: false,
leftTimeseries: [],
rightTimeseries: [],
highlightedTimeseries: null,
showingHelp: false,
};
}
class AppStateStore {
private readonly subscriptions: IAppStateSubscriptions;
private readonly eventCallbacks: EventCallbackListing<keyof EventCallback>;
private readonly state: AppState;
private loaders = 0;
constructor(initialState?: Partial<AppState>) {
this.state = { ...newDefaultState(), ...initialState };
const subscriptions: Record<string, (() => unknown)[]> = {};
for (const key in this.state) {
subscriptions[key] = [];
}
this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: [], stateChange: [], ready: []};
this.subscriptions = subscriptions as IAppStateSubscriptions;
this.init();
setInterval(() => this.getNewTimeseriesData().catch(e => AppStore().fatalError(e)), this.state.updateIntervalSeconds * 1000);
}
async init() {
await this.updateTimeseriesFromSettings();
await this.getNewTimeseriesData();
this.emit("ready");
}
addTimeseriesToScale(timeseries: Timeseries, scale?: ScaleId) {
const group = scale === ScaleId.Left ? this.state.leftTimeseries : this.state.rightTimeseries;
if (group.indexOf(timeseries) >= 0) {
throw new AppStateError("Timeseries has already been added!");
}
if (scale === ScaleId.Left) {
group.push(timeseries);
} else {
group.push(timeseries);
}
this.notifyStoreVal(scale === ScaleId.Left ? "leftTimeseries" : "rightTimeseries");
this.emit("newTimeseries", timeseries, scale);
this.updateTimeseriesFromSettings();
}
private notifyStoreVal<T extends keyof AppState>(subscribedValue: T, newValue?: AppState[T], oldValue?: AppState[T]) {
this.emit("stateChange", subscribedValue, newValue, oldValue);
for (const subscriptionCallback of this.subscriptions[subscribedValue]) {
subscriptionCallback(newValue, oldValue);
}
}
private emit<T extends keyof EventCallback>(eventName: T, ...callbackArgs: Parameters<EventCallback[T]>) {
for (const sub of this.eventCallbacks[eventName]) {
// @ts-ignore
sub(...callbackArgs);
}
}
private async updateTimeseriesFromSettings() {
let start: number;
let stop: number;
if (this.state.displayMode === "window") {
start = this.state.displayWindow.start;
stop = this.state.displayWindow.stop;
} else {
start = this.state.lastUpdateTime - this.state.minutesDisplayed * 60;
stop = this.state.lastUpdateTime;
}
this.addLoad();
try {
for (const timeseries of this.state.leftTimeseries) {
await timeseries.updateFromWindow(start, stop);
}
for (const timeseries of this.state.rightTimeseries) {
await timeseries.updateFromWindow(start, stop);
}
} catch (e) {
AppStore().fatalError(e);
}
this.finishLoad();
this.notifyAllTimeseriesUpdated();
}
private async getNewTimeseriesData() {
const updateTime = new Date().getTime() / 1000;
this.addLoad();
try {
for (const timeseries of this.state.leftTimeseries) {
await timeseries.getLatest();
}
for (const timeseries of this.state.rightTimeseries) {
await timeseries.getLatest();
}
} catch (e) {
AppStore().fatalError(e);
}
this.finishLoad();
this.setLastUpdateTime(updateTime);
this.notifyAllTimeseriesUpdated();
}
private notifyAllTimeseriesUpdated() {
for (const timeseries of this.state.leftTimeseries) {
this.notifyStoreVal("leftTimeseries");
this.emit("timeseriesUpdated", timeseries);
}
for (const timeseries of this.state.rightTimeseries) {
this.notifyStoreVal("rightTimeseries");
this.emit("timeseriesUpdated", timeseries);
}
}
getState(): AppState {
return this.state;
}
subscribeStoreVal<T extends keyof AppState>(dataName: T, callback: StoreUpdateCallback<AppState[T]>) {
this.subscriptions[dataName].push(callback);
}
on<T extends keyof EventCallback>(event: T, callback: EventCallback[T]) {
this.eventCallbacks[event].push(callback);
}
setDisplayMode(mode: DisplayMode) {
this.state.displayMode = mode;
this.updateTimeseriesFromSettings();
this.notifyStoreVal("displayMode");
}
setDisplayWindow(newWin: TimeWindow) {
if (newWin.start < newWin.stop) {
if (newWin.stop <= this.state.lastUpdateTime) {
this.state.displayWindow = {...newWin};
this.notifyStoreVal("displayWindow");
this.updateTimeseriesFromSettings();
}
} else {
console.warn(`Invalid display window from ${newWin.start} to ${newWin.stop}`);
}
}
setMinutesDisplayed(mins: number) {
if (mins > 0) {
this.state.minutesDisplayed = Math.ceil(mins);
this.notifyStoreVal("minutesDisplayed");
this.updateTimeseriesFromSettings();
} else {
throw new AppStateError(`Invalid minutes passed: ${mins}`);
}
}
setUtcOffset(newOffset: number) {
if (Math.floor(newOffset) === newOffset && newOffset <= 14 && newOffset >= -12) {
this.state.utcOffset = newOffset;
} else {
console.warn(`Invalid UTC offset: ${newOffset}`);
if (newOffset > 14) {
this.state.utcOffset = 14;
} else if (newOffset < -12) {
this.state.utcOffset = -12;
} else {
this.state.utcOffset = Math.floor(newOffset);
}
}
this.notifyStoreVal("utcOffset");
}
private setLastUpdateTime(newTime: number) {
if (this.state.lastUpdateTime <= newTime) {
this.state.lastUpdateTime = newTime;
this.notifyStoreVal("lastUpdateTime");
} else {
throw new AppStateError(`Bad new update time was before last update time. Old: ${this.state.lastUpdateTime}, New: ${newTime}`);
}
}
setOverlayText(text: string) {
this.state.overlayText = text;
this.notifyStoreVal("overlayText");
}
addLoad() {
this.loaders += 1;
this.state.isLoading = this.loaders > 0;
this.notifyStoreVal("isLoading");
}
finishLoad() {
this.loaders -= 1;
this.state.isLoading = this.loaders > 0;
this.notifyStoreVal("isLoading");
}
fatalError(err: Error) {
if (!this.state.fatalError) {
this.state.fatalError = err;
this.notifyStoreVal("fatalError");
}
}
setDocumentReady(isReady: boolean) {
this.state.documentReady = isReady;
this.notifyStoreVal("documentReady");
}
setHighlightedTimeseries(name: string | null) {
this.state.highlightedTimeseries = name;
this.notifyStoreVal("highlightedTimeseries", name);
}
emulateLastMinsWithWindow() {
this.setDisplayMode("window");
this.setDisplayWindow({
start: this.state.lastUpdateTime - getAppState().minutesDisplayed * 60 + getAppState().utcOffset * 60,
stop: this.state.lastUpdateTime
});
}
showHelp() {
this.state.showingHelp = true;
this.notifyStoreVal("showingHelp");
}
hideHelp() {
this.state.showingHelp = false;
this.notifyStoreVal("showingHelp");
}
serialiseState(): string {
const stateStringParams = [];
if (this.state.displayMode === "pastMins") {
if (this.state.minutesDisplayed !== 60) {
stateStringParams.push(
`last-minutes=${this.state.minutesDisplayed}`,
);
}
} else {
stateStringParams.push(
`from=${Math.floor(this.state.displayWindow.start)}&to=${Math.floor(this.state.displayWindow.stop)}`,
);
}
if (this.state.utcOffset !== newDefaultState().utcOffset) {
stateStringParams.push(
`utc-offset=${this.state.utcOffset}`,
);
}
return stateStringParams.join("&");
}
deserialise(serial: URLSearchParams) {
if (serial.get("last-minutes") && (serial.get("from") || serial.get("to"))) {
console.warn("Url param 'last-minutes' cannot be combined with 'from' or 'to'. Defaulting to 'last-minutes'.");
}
if (serial.get("utc-offset")) {
this.setUtcOffset(Number(serial.get("utc-offset")));
}
if (serial.get("to") && !serial.get("from")) {
console.warn("The url param 'to' must always be combined with 'from'. Ignoring.");
}
if (serial.get("from")) {
let from = Number(serial.get("from"));
if (isNaN(from)) {
const fromDate = new Date(from);
if (!isNaN(fromDate.valueOf())) {
from = fromDate.getTime();
} else {
console.warn("Param 'from' must be a date must be a number (unix timestamp in seconds) or an ISO string.");
return;
}
}
let to = Number(serial.get("to"));
if (isNaN(to)) {
const toDate = new Date(to);
if (!isNaN(toDate.valueOf())) {
to = toDate.getTime();
} else {
console.warn("Param 'to' must be a date must be a number (unix timestamp in seconds) or an ISO string.");
to = this.state.displayWindow.stop;
}
}
const displayWindow = {start: from, stop: to};
this.setDisplayWindow(displayWindow);
this.setDisplayMode("window");
}
if (serial.get("last-minutes")) {
const pastMins = Number(serial.get("last-minutes"));
if (!isNaN(pastMins)) {
this.setDisplayMode("pastMins");
this.setMinutesDisplayed(pastMins);
} else {
console.warn("Param 'last-minutes' must be a number");
}
}
this.emit("stateChange");
}
}
let store: AppStateStore;
export async function initStore(initialState?: Partial<AppState> | URLSearchParams) {
if (initialState instanceof URLSearchParams) {
store = new AppStateStore();
store.deserialise(initialState);
} else {
store = new AppStateStore(initialState);
}
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!");
}
}