Starting anew, before I changed to a custom chart
This commit is contained in:
72
dashboard/src/AppUI.ts
Normal file
72
dashboard/src/AppUI.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import TimezoneWidget from "./TimezoneWidget";
|
||||
import DisplayModeWidget from "./DisplayModeWidget";
|
||||
import TimerWidget from "./TimerWidget";
|
||||
import ClimateChartWidget from "./ClimateChartWidget";
|
||||
import {GridSize} from "./GridWidget";
|
||||
import MessageOverlay from "./MessageOverlay";
|
||||
import UIComponent from "./UIComponent";
|
||||
import SelectDisplayModeWidget from "./SelectDisplayModeWidget";
|
||||
|
||||
class AppUI extends UIComponent {
|
||||
private timezoneWidget: TimezoneWidget;
|
||||
private selectModeWidget: SelectDisplayModeWidget;
|
||||
private displayModeSettingsWidget: DisplayModeWidget;
|
||||
private timerWidget: TimerWidget;
|
||||
private chartWidget: ClimateChartWidget;
|
||||
private element: HTMLDivElement = document.createElement("div");
|
||||
private grid: HTMLDivElement = document.createElement("div");
|
||||
private messageOverlay: MessageOverlay = new MessageOverlay();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setupGrid({width: 5, height: 10});
|
||||
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.displayModeSettingsWidget.current(),
|
||||
this.selectModeWidget.current(),
|
||||
this.timerWidget.current(),
|
||||
this.timezoneWidget.current(),
|
||||
);
|
||||
this.grid.className = "main-content-grid";
|
||||
this.grid.style.gridTemplateRows = `repeat(${size.height}, 1fr)`;
|
||||
this.grid.style.gridTemplateColumns = `repeat(${size.width}, 1fr)`;
|
||||
}
|
||||
|
||||
private setupWidgets() {
|
||||
this.displayModeSettingsWidget = new DisplayModeWidget({
|
||||
row: "auto", col: 5, width: 1, height: 3,
|
||||
});
|
||||
this.selectModeWidget = new SelectDisplayModeWidget({
|
||||
row: "auto", col: 5, width: 1, height: 2,
|
||||
});
|
||||
this.timezoneWidget = new TimezoneWidget({
|
||||
row: "auto", col: 5, width: 1, height: 2,
|
||||
});
|
||||
this.timerWidget = new TimerWidget({
|
||||
row: "auto", col: 5, width: 1, height: 3,
|
||||
});
|
||||
this.chartWidget = new ClimateChartWidget({
|
||||
row: 1, col: 1, width: 4, height: 10,
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap(rootNode: string) {
|
||||
document.getElementById(rootNode).append(this.element);
|
||||
}
|
||||
|
||||
current(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
}
|
||||
|
||||
export default AppUI;
|
||||
77
dashboard/src/Chart.ts
Normal file
77
dashboard/src/Chart.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import Snapshot from "./Snapshot";
|
||||
|
||||
export default class Chart {
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
constructor(context: CanvasRenderingContext2D) {
|
||||
this.ctx = context;
|
||||
}
|
||||
|
||||
render(snapshots: Snapshot[]) {
|
||||
const snapshotWidth = this.ctx.canvas.width;
|
||||
let minTemp = Infinity;
|
||||
let maxTemp = -Infinity;
|
||||
let minCo2 = Infinity;
|
||||
let maxCo2 = -Infinity;
|
||||
let minHumidity = Infinity;
|
||||
let maxHumidity = -Infinity;
|
||||
for (const snapshot of snapshots) {
|
||||
if (snapshot.temp < minTemp) {
|
||||
minTemp = snapshot.temp;
|
||||
}
|
||||
if (snapshot.temp > maxTemp) {
|
||||
maxTemp = snapshot.temp;
|
||||
}
|
||||
if (snapshot.co2 < minCo2) {
|
||||
minCo2 = snapshot.co2;
|
||||
}
|
||||
if (snapshot.co2 > maxCo2) {
|
||||
maxCo2 = snapshot.co2;
|
||||
}
|
||||
if (snapshot.humidity < minHumidity) {
|
||||
minHumidity = snapshot.humidity;
|
||||
}
|
||||
if (snapshot.humidity > maxHumidity) {
|
||||
maxHumidity = snapshot.humidity;
|
||||
}
|
||||
}
|
||||
const humidityRange = maxHumidity - minHumidity;
|
||||
|
||||
let x = snapshotWidth / 2;
|
||||
let y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
this.ctx.moveTo(x, y);
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
x += snapshotWidth;
|
||||
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.lineTo(x, y);
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
|
||||
const co2Range = maxCo2 - minCo2;
|
||||
x = snapshotWidth / 2;
|
||||
y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
this.ctx.moveTo(x, y);
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
x += snapshotWidth;
|
||||
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.lineTo(x, y);
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
|
||||
const tempRange = maxTemp - minTemp;
|
||||
x = snapshotWidth / 2;
|
||||
y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
this.ctx.moveTo(x, y);
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
x += snapshotWidth;
|
||||
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||
this.ctx.lineTo(x, y);
|
||||
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
127
dashboard/src/ClimateChartWidget.ts
Normal file
127
dashboard/src/ClimateChartWidget.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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 GridWidget, {GridProps} from "./GridWidget";
|
||||
import UIComponent from "./UIComponent";
|
||||
|
||||
interface ClimatePoint {
|
||||
x: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
class ClimateChartWidget extends UIComponent {
|
||||
private readonly skeleton: GridWidget;
|
||||
private chart: Chart | null;
|
||||
private initialised: boolean;
|
||||
private displayMode: DisplayMode = "pastMins";
|
||||
private latestSnapshotInChartTime: number;
|
||||
private readonly canvasElement: HTMLCanvasElement = document.createElement("canvas");
|
||||
private body = document.createElement("div");
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.initialised = false;
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
body: this.body,
|
||||
});
|
||||
const now = new Date().getTime();
|
||||
this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60000;
|
||||
this.setupListeners();
|
||||
AppStore().subscribe("documentReady", async () => {
|
||||
try {
|
||||
AppStore().addLoad();
|
||||
await this.initChart();
|
||||
this.initialised = true;
|
||||
} catch (e) {
|
||||
AppStore().fatalError(e);
|
||||
} finally {
|
||||
AppStore().finishLoad();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
AppStore().subscribe("displayMode", () => this.updateDisplayMode());
|
||||
AppStore().subscribe("minutesDisplayed", () => this.update());
|
||||
AppStore().subscribe("displayWindow", () => this.update());
|
||||
AppStore().subscribe("snapshots", () => this.update());
|
||||
}
|
||||
|
||||
private async initChart() {
|
||||
const ctx = this.canvasElement.getContext("2d");
|
||||
this.chart = new Chart(ctx, generateClimateChartConfig({}));
|
||||
await this.update();
|
||||
}
|
||||
|
||||
private async updateDisplayMode() {
|
||||
this.displayMode = getAppState().displayMode;
|
||||
await this.update();
|
||||
}
|
||||
|
||||
private async update() {
|
||||
if (this.initialised) {
|
||||
if (this.displayMode === "window") {
|
||||
await this.updateChartFromTimeWindow();
|
||||
} else if (this.displayMode === "pastMins") {
|
||||
await this.updateChartFromMinuteSpan();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async updateChartFromTimeWindow() {
|
||||
this.clearChart();
|
||||
this.appendSnapshots(await AppStore().snapshotsBetween(
|
||||
getAppState().displayWindow.start, getAppState().displayWindow.stop));
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
private async updateChartFromMinuteSpan() {
|
||||
const mins = getAppState().minutesDisplayed;
|
||||
this.clearChart();
|
||||
this.appendSnapshots(await AppStore().snapshotsBetween(
|
||||
getAppState().lastUpdateTime - mins * 60000,
|
||||
getAppState().lastUpdateTime));
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
private appendSnapshots(snapshots: Snapshot[]) {
|
||||
for (const snapshot of snapshots.reverse()) {
|
||||
this.humidityPointList().push({x: snapshot.time, y: snapshot.humidity});
|
||||
this.tempPointList().push({x: snapshot.time, y: snapshot.temp});
|
||||
this.co2PointList().push({x: snapshot.time, y: snapshot.co2});
|
||||
}
|
||||
this.latestSnapshotInChartTime = snapshots[0] && new Date(snapshots[0].time).getTime();
|
||||
}
|
||||
|
||||
private removePoint(index: number) {
|
||||
this.humidityPointList().splice(index, 1);
|
||||
this.tempPointList().splice(index, 1);
|
||||
this.co2PointList().splice(index, 1);
|
||||
}
|
||||
|
||||
private clearChart() {
|
||||
for (const dataset of this.chart.data.datasets) {
|
||||
dataset.data = [];
|
||||
}
|
||||
}
|
||||
|
||||
private humidityPointList(): ClimatePoint[] {
|
||||
return this.chart.data.datasets[0].data as ClimatePoint[];
|
||||
}
|
||||
|
||||
private tempPointList(): ClimatePoint[] {
|
||||
return this.chart.data.datasets[1].data as ClimatePoint[];
|
||||
}
|
||||
|
||||
private co2PointList(): ClimatePoint[] {
|
||||
return this.chart.data.datasets[2].data as ClimatePoint[];
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default ClimateChartWidget;
|
||||
164
dashboard/src/DisplayModeWidget.tsx
Normal file
164
dashboard/src/DisplayModeWidget.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import {AppStore, DisplayMode, getAppState} from "./StateStore";
|
||||
import * as JSX from "./JSXFactory";
|
||||
import UIComponent from "./UIComponent";
|
||||
|
||||
class DisplayModeWidget extends UIComponent {
|
||||
private skeleton: GridWidget;
|
||||
private minsCounterRef: number;
|
||||
private windowStartTimeRef: number;
|
||||
private windowStopTimeRef: number;
|
||||
private windowedDisplayRef: number;
|
||||
private minsDisplayRef: number;
|
||||
private mainDisplay: HTMLElement;
|
||||
private minsInputRef: number;
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.mainDisplay = this.MainDisplay({ctx: this});
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
title: "Displaying:",
|
||||
body: this.mainDisplay,
|
||||
});
|
||||
AppStore().subscribe("minutesDisplayed", () => this.updateDisplay());
|
||||
AppStore().subscribe("displayMode", () => this.updateDisplay());
|
||||
AppStore().subscribe("displayWindow", () => this.updateDisplay());
|
||||
}
|
||||
|
||||
private WindowStartTime({ ctx }: {ctx: DisplayModeWidget}) {
|
||||
ctx.windowStartTimeRef = ctx.makeRef(<div
|
||||
className={"display-mode-widget-date"}>
|
||||
{new Date(getAppState().displayWindow.start).toLocaleString()}
|
||||
</div>);
|
||||
return ctx.fromRef(ctx.windowStartTimeRef);
|
||||
}
|
||||
|
||||
private WindowStopTime({ctx}: {ctx: DisplayModeWidget}) {
|
||||
ctx.windowStopTimeRef = ctx.makeRef(<div
|
||||
className={"display-mode-widget-date"}>
|
||||
{new Date(getAppState().displayWindow.stop).toLocaleString()}
|
||||
</div>);
|
||||
return ctx.fromRef(ctx.windowStopTimeRef);
|
||||
}
|
||||
|
||||
private MinutesCounter({ctx, onclick}: {ctx: DisplayModeWidget, onclick: () => any}) {
|
||||
ctx.minsInputRef = ctx.makeRef(
|
||||
<input
|
||||
value={getAppState().minutesDisplayed.toString()}
|
||||
onblur={(e: FocusEvent) => ctx.onMinutesCounterInputBlur(e)}/>);
|
||||
ctx.minsCounterRef = ctx.makeRef(
|
||||
<div className={"min-count"} onclick={onclick}>
|
||||
{getAppState().minutesDisplayed.toString()}
|
||||
</div>);
|
||||
return ctx.fromRef(ctx.minsCounterRef);
|
||||
}
|
||||
|
||||
private onMinutesCounterInputBlur(e: FocusEvent) {
|
||||
const input = Number((e.target as HTMLInputElement).value);
|
||||
if (!isNaN(input)) {
|
||||
if (input >= 1) {
|
||||
AppStore().setMinutesDisplayed(input);
|
||||
}
|
||||
} else {
|
||||
(e.target as HTMLInputElement).value = getAppState().minutesDisplayed.toString();
|
||||
}
|
||||
this.fromRef(this.minsInputRef).replaceWith(this.fromRef(this.minsCounterRef));
|
||||
}
|
||||
|
||||
private MinutesDisplay({ctx}: {ctx: DisplayModeWidget}) {
|
||||
return (<div className={"display-mode-widget-mins"}>
|
||||
<div>Last</div>
|
||||
<ctx.MinusButton onclick={() => {
|
||||
const mins = AppStore().getState().minutesDisplayed;
|
||||
AppStore().setMinutesDisplayed(mins - 1);
|
||||
}}/>
|
||||
<ctx.MinutesCounter ctx={ctx} onclick={() => ctx.onMinutesCounterClick()}/>
|
||||
<ctx.PlusButton onclick={() => {
|
||||
const mins = AppStore().getState().minutesDisplayed;
|
||||
AppStore().setMinutesDisplayed(mins + 1);
|
||||
}}/>
|
||||
<div>minutes</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
private onMinutesCounterClick() {
|
||||
const input = this.fromRef(this.minsInputRef) as HTMLInputElement;
|
||||
this.fromRef(this.minsCounterRef).replaceWith(input);
|
||||
input.focus();
|
||||
input.selectionStart = 0;
|
||||
input.selectionEnd = input.value.length;
|
||||
}
|
||||
|
||||
private MinusButton(props: {onclick: () => any}) {
|
||||
return <div
|
||||
className={"minus-button"}
|
||||
onclick={props.onclick}
|
||||
/>;
|
||||
}
|
||||
|
||||
private PlusButton(props: {onclick: () => any}) {
|
||||
return <div
|
||||
className={"plus-button"}
|
||||
onclick={props.onclick}
|
||||
/>;
|
||||
}
|
||||
|
||||
private WindowedDisplay({ctx}: {ctx: DisplayModeWidget}) {
|
||||
return (<div>
|
||||
<div>From</div>
|
||||
<ctx.MinusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start - 60000, stop: displayWindow.stop});
|
||||
}}/>
|
||||
<ctx.WindowStartTime ctx={ctx}/>
|
||||
<ctx.PlusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start + 60000, stop: displayWindow.stop});
|
||||
}}/>
|
||||
<div>to</div>
|
||||
<ctx.MinusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop - 60000});
|
||||
}}/>
|
||||
<ctx.WindowStopTime ctx={ctx}/>
|
||||
<ctx.PlusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop + 60000});
|
||||
}}/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
private MainDisplay({ ctx }: { ctx: DisplayModeWidget }) {
|
||||
const windowMode = getAppState().displayMode === "window";
|
||||
ctx.windowedDisplayRef = ctx.makeRef(<ctx.WindowedDisplay ctx={ctx}/>);
|
||||
ctx.minsDisplayRef = ctx.makeRef(<ctx.MinutesDisplay ctx={ctx}/>);
|
||||
return <div className={"display-mode-widget"}>
|
||||
{windowMode
|
||||
? ctx.fromRef(ctx.windowedDisplayRef)
|
||||
: ctx.fromRef(ctx.minsDisplayRef)}
|
||||
</div> as HTMLElement;
|
||||
}
|
||||
|
||||
private onSelectMode(mode: DisplayMode) {
|
||||
AppStore().setDisplayMode(mode);
|
||||
}
|
||||
|
||||
private updateDisplay() {
|
||||
if (getAppState().displayMode === "window") {
|
||||
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));
|
||||
this.fromRef(this.windowStartTimeRef).innerText = new Date(getAppState().displayWindow.start).toLocaleString();
|
||||
this.fromRef(this.windowStopTimeRef).innerText = new Date(getAppState().displayWindow.stop).toLocaleString();
|
||||
} else {
|
||||
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef));
|
||||
this.fromRef(this.minsCounterRef).innerText = getAppState().minutesDisplayed.toString();
|
||||
(this.fromRef(this.minsInputRef) as HTMLInputElement).value = getAppState().minutesDisplayed.toString();
|
||||
}
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default DisplayModeWidget;
|
||||
66
dashboard/src/GridWidget.ts
Normal file
66
dashboard/src/GridWidget.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import UIComponent from "./UIComponent";
|
||||
|
||||
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;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
class GridWidget extends UIComponent {
|
||||
private container: HTMLDivElement = document.createElement("div");
|
||||
private title: HTMLHeadingElement = document.createElement("h2");
|
||||
private body: HTMLElement = document.createElement("div");
|
||||
|
||||
constructor(props: GridWidgetProps) {
|
||||
super();
|
||||
this.container.className = `widget${props.className ? ` ${props.className}` : ""}`;
|
||||
this.title.className = "widget-title";
|
||||
this.body.className = "widget-body";
|
||||
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.append(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.height}`;
|
||||
this.container.style.gridColumnEnd = `span ${size.width}`;
|
||||
}
|
||||
|
||||
setTitle(newTitle: string) {
|
||||
this.title.innerText = newTitle;
|
||||
}
|
||||
|
||||
replaceBody(newEl: HTMLElement) {
|
||||
this.body.replaceWith(newEl);
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.container;
|
||||
}
|
||||
}
|
||||
|
||||
export default GridWidget;
|
||||
71
dashboard/src/JSXFactory.ts
Normal file
71
dashboard/src/JSXFactory.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export namespace JSX {
|
||||
export interface AttributeCollection {
|
||||
[name: string]: string | boolean | (() => any);
|
||||
className: string;
|
||||
}
|
||||
export type Element = HTMLElement;
|
||||
export interface ElementClass {
|
||||
render(): HTMLElement;
|
||||
}
|
||||
export interface IntrinsicElements {
|
||||
[key: string]: any;
|
||||
}
|
||||
}
|
||||
|
||||
export type FunctionComponent<PropsType> = (props: PropsType, children?: any[]) => HTMLElement;
|
||||
|
||||
export function createElement(tagName: string | FunctionComponent<JSX.AttributeCollection>, attributes: JSX.AttributeCollection | null, ...children: any[]): HTMLElement {
|
||||
if (typeof tagName === "function") {
|
||||
if (children.length >= 1) {
|
||||
return tagName({...attributes}, children);
|
||||
}
|
||||
else {
|
||||
return tagName({...attributes});
|
||||
}
|
||||
}
|
||||
else {
|
||||
return standardElement(tagName, attributes, ...children);
|
||||
}
|
||||
}
|
||||
|
||||
function standardElement(tagName: string, attributes: JSX.AttributeCollection | null, ...children: any[]) {
|
||||
const element = document.createElement(tagName);
|
||||
for (const key in attributes) {
|
||||
const attributeValue = attributes[key];
|
||||
if (key.startsWith("on") && typeof attributeValue === "function") {
|
||||
element.addEventListener(key.substring(2), attributeValue);
|
||||
}
|
||||
else if (typeof attributeValue === "boolean" && attributeValue === true) {
|
||||
element.setAttribute(key, "");
|
||||
}
|
||||
else if (typeof attributeValue === "string") {
|
||||
if (key === "className") {
|
||||
element.setAttribute("class", attributes[key]);
|
||||
}
|
||||
else {
|
||||
element.setAttribute(key, attributeValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
element.append(...createChildren(children));
|
||||
return element;
|
||||
}
|
||||
|
||||
function createChildren(children: any[]): Node[] {
|
||||
const childrenNodes: Node[] = [];
|
||||
for (const child of children) {
|
||||
if (typeof child === "undefined" || child === null || typeof child === "boolean") {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
childrenNodes.push(...createChildren(child));
|
||||
}
|
||||
else if (typeof child === "string") {
|
||||
childrenNodes.push(document.createTextNode(String(child)));
|
||||
}
|
||||
else if (child instanceof Node) {
|
||||
childrenNodes.push(child);
|
||||
}
|
||||
}
|
||||
return childrenNodes;
|
||||
}
|
||||
74
dashboard/src/ListCache.ts
Normal file
74
dashboard/src/ListCache.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {AppStore} from "./StateStore";
|
||||
|
||||
type ListCacheLoader<I, D> = (start: I, stop: I) => Promise<D[]>;
|
||||
type ListCacheComparator<I, D> = (data: D, index: I) => -1 | 1 | 0;
|
||||
|
||||
class ListCache<IndexType, DataType> {
|
||||
private cache: DataType[];
|
||||
private loader: ListCacheLoader<IndexType, DataType>;
|
||||
private comparator: ListCacheComparator<IndexType, DataType>;
|
||||
|
||||
constructor(loader: ListCacheLoader<IndexType, DataType>, comparator: ListCacheComparator<IndexType, DataType>) {
|
||||
this.cache = [];
|
||||
this.loader = loader;
|
||||
this.comparator = comparator;
|
||||
}
|
||||
|
||||
getCache() {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
async snapshotsBetween(start: IndexType, stop: IndexType): Promise<DataType[]> {
|
||||
return this.cachedBetween(start, stop);
|
||||
}
|
||||
|
||||
async updateFromWindow(start: IndexType, stop: IndexType) {
|
||||
if (!this.cacheValidForWindow(start, stop)) {
|
||||
await this.fetchMissingElementsBetween(start, stop);
|
||||
}
|
||||
}
|
||||
|
||||
private cacheValidForWindow(start: IndexType, stop: IndexType) {
|
||||
if (this.cache.length > 0) {
|
||||
return this.comparator(this.cache[0], start) !== 1
|
||||
&& this.comparator(this.cache[this.cache.length - 1], stop) !== -1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMissingElementsBetween(start: IndexType, stop: IndexType) {
|
||||
AppStore().addLoad();
|
||||
this.cache = await this.loader(start, stop);
|
||||
AppStore().finishLoad();
|
||||
}
|
||||
|
||||
private cachedBetween(start: IndexType, stop: IndexType): DataType[] {
|
||||
if (this.cache.length <= 0) {
|
||||
return [];
|
||||
} else {
|
||||
return this.cache.slice(
|
||||
this.findIndexInCache(start),
|
||||
this.findIndexInCache(stop)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private findIndexInCache(soughtVal: IndexType, listStart = 0, listStop: number = this.cache.length): number {
|
||||
if (listStop - listStart === 1) {
|
||||
return listStart;
|
||||
} else {
|
||||
const middle = Math.floor((listStop + listStart) / 2);
|
||||
const comparison = this.comparator(this.cache[middle], soughtVal);
|
||||
if (comparison === 1) {
|
||||
return this.findIndexInCache(soughtVal, listStart, middle);
|
||||
} else if (comparison === -1) {
|
||||
return this.findIndexInCache(soughtVal, middle, listStop);
|
||||
} else {
|
||||
return middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ListCache;
|
||||
62
dashboard/src/MessageOverlay.ts
Normal file
62
dashboard/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;
|
||||
61
dashboard/src/SelectDisplayModeWidget.tsx
Normal file
61
dashboard/src/SelectDisplayModeWidget.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import UIComponent from "./UIComponent";
|
||||
import * as JSX from "./JSXFactory";
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import {AppStore, DisplayMode, getAppState} from "./StateStore";
|
||||
|
||||
export default class SelectDisplayModeWidget extends UIComponent {
|
||||
private mainBody: HTMLElement;
|
||||
private gridWidgetSkeleton: GridWidget;
|
||||
private windowInputRef: number;
|
||||
private minSpanInputRef: number;
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.mainBody = this.MainBody({ctx: this});
|
||||
this.gridWidgetSkeleton = new GridWidget({
|
||||
...gridProps,
|
||||
title: "Display Mode:",
|
||||
body: this.mainBody,
|
||||
});
|
||||
AppStore().subscribe("displayMode", () => this.update());
|
||||
}
|
||||
|
||||
private selectMode(mode: DisplayMode) {
|
||||
AppStore().setDisplayMode(mode);
|
||||
}
|
||||
|
||||
private update() {
|
||||
const windowedMode = getAppState().displayMode === "window";
|
||||
(this.fromRef(this.windowInputRef) as HTMLInputElement).checked = windowedMode;
|
||||
(this.fromRef(this.minSpanInputRef) as HTMLInputElement).checked = !windowedMode;
|
||||
}
|
||||
|
||||
private MainBody({ ctx }: { ctx: SelectDisplayModeWidget }) {
|
||||
const isInWindowMode = getAppState().displayMode === "window";
|
||||
ctx.windowInputRef = this.makeRef(<input
|
||||
type={"radio"}
|
||||
id={"window"}
|
||||
name={"display-mode"}
|
||||
checked={isInWindowMode}
|
||||
onclick={() => ctx.selectMode("window")}/>);
|
||||
ctx.minSpanInputRef = this.makeRef(<input
|
||||
type={"radio"}
|
||||
id={"min-span"}
|
||||
name={"display-mode"}
|
||||
checked={!isInWindowMode}
|
||||
onclick={() => ctx.selectMode("pastMins")}/>);
|
||||
return (<div>
|
||||
<div>
|
||||
{this.fromRef(ctx.windowInputRef)}
|
||||
<label htmlFor={"window"}>Time Window</label>
|
||||
</div>
|
||||
<div>
|
||||
{this.fromRef(ctx.minSpanInputRef)}
|
||||
<label htmlFor={"minSpan"}>Rolling Minute Span</label>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.gridWidgetSkeleton.current();
|
||||
}
|
||||
}
|
||||
9
dashboard/src/Snapshot.ts
Normal file
9
dashboard/src/Snapshot.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Snapshot {
|
||||
id: number,
|
||||
temp: number,
|
||||
humidity: number,
|
||||
co2: number,
|
||||
time: string,
|
||||
}
|
||||
|
||||
export default Snapshot;
|
||||
212
dashboard/src/StateStore.ts
Normal file
212
dashboard/src/StateStore.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import Snapshot from "./Snapshot";
|
||||
import ListCache from "./ListCache";
|
||||
|
||||
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;
|
||||
documentReady: boolean;
|
||||
}
|
||||
|
||||
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
|
||||
type SubscriptionType<T, K extends keyof T> = Record<K, StoreUpdateCallback<T[K]>[]>;
|
||||
type IAppStateSubscriptions = SubscriptionType<AppState, keyof AppState>;
|
||||
|
||||
class AppStateStore {
|
||||
private readonly subscriptions: IAppStateSubscriptions;
|
||||
private readonly state: AppState;
|
||||
private initialised = false;
|
||||
private loaders = 0;
|
||||
private readonly climateDataStore: ListCache<number, Snapshot> = new ListCache<number, Snapshot>(
|
||||
async (start, stop) => {
|
||||
const dataEndpoint = `${ this.state.dataEndpointBase }/snapshots?from=${ new Date(start).toISOString() }${stop ? `&to=${new Date(stop).toISOString()}` : ""}`;
|
||||
const payload = await fetch(dataEndpoint);
|
||||
return ((await payload.json()) as any).snapshots.reverse();
|
||||
},
|
||||
(data, index) => {
|
||||
const time = new Date(data.time).getTime();
|
||||
if (time + getAppState().updateIntervalSeconds * 1000 > index) {
|
||||
return 1;
|
||||
} else if (time - getAppState().updateIntervalSeconds * 1000< index) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
constructor(initialState: AppState) {
|
||||
this.state = initialState;
|
||||
const subscriptions: Record<string, (() => unknown)[]> = {};
|
||||
for (const key in this.state) {
|
||||
subscriptions[key] = [];
|
||||
}
|
||||
this.subscriptions = subscriptions as IAppStateSubscriptions;
|
||||
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]) {
|
||||
new Promise(() => subscriptionCallback());
|
||||
}
|
||||
}
|
||||
|
||||
private async updateClimateData() {
|
||||
const now = new Date().getTime();
|
||||
if (this.state.displayMode === "window") {
|
||||
await this.climateDataStore.updateFromWindow(
|
||||
this.state.displayWindow.start,
|
||||
this.state.displayWindow.stop
|
||||
);
|
||||
} else {
|
||||
await this.climateDataStore.updateFromWindow(
|
||||
now - this.state.minutesDisplayed * 60000,
|
||||
now
|
||||
);
|
||||
}
|
||||
this.setLastUpdateTime(now);
|
||||
this.setSnapshots(this.climateDataStore.getCache());
|
||||
}
|
||||
|
||||
async snapshotsBetween(start: number, stop: number) {
|
||||
return this.climateDataStore.snapshotsBetween(start, stop);
|
||||
}
|
||||
|
||||
getState(): AppState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
subscribe<T extends keyof AppState>(dataName: T, callback: StoreUpdateCallback<AppState[T]>) {
|
||||
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");
|
||||
this.updateClimateData();
|
||||
} 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");
|
||||
this.updateClimateData();
|
||||
} else {
|
||||
throw new AppStateError(`Invalid minutes passed: ${mins}`);
|
||||
}
|
||||
}
|
||||
|
||||
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(newTime: number) {
|
||||
if (this.state.lastUpdateTime <= newTime) {
|
||||
this.state.lastUpdateTime = newTime;
|
||||
this.notify("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.notify("overlayText");
|
||||
}
|
||||
|
||||
addLoad() {
|
||||
this.loaders += 1;
|
||||
this.state.isLoading = this.loaders > 0;
|
||||
this.notify("isLoading");
|
||||
}
|
||||
|
||||
finishLoad() {
|
||||
this.loaders -= 1;
|
||||
this.state.isLoading = this.loaders > 0;
|
||||
this.notify("isLoading");
|
||||
}
|
||||
|
||||
fatalError(err: Error) {
|
||||
if (!this.state.fatalError) {
|
||||
this.state.fatalError = err;
|
||||
this.notify("fatalError");
|
||||
}
|
||||
}
|
||||
|
||||
setDocumentReady(isReady: boolean) {
|
||||
this.state.documentReady = isReady;
|
||||
this.notify("documentReady");
|
||||
}
|
||||
|
||||
private setSnapshots(snapshots: Snapshot[]) {
|
||||
this.state.snapshots = snapshots;
|
||||
this.notify("snapshots");
|
||||
}
|
||||
}
|
||||
|
||||
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!");
|
||||
}
|
||||
}
|
||||
62
dashboard/src/TimerWidget.tsx
Normal file
62
dashboard/src/TimerWidget.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {AppStore, getAppState} from "./StateStore";
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import UIComponent from "./UIComponent";
|
||||
import * as JSX from "./JSXFactory";
|
||||
|
||||
class TimerWidget extends UIComponent {
|
||||
private readonly display: HTMLElement;
|
||||
private skeleton: GridWidget;
|
||||
private nextUpdateTime: number;
|
||||
private timerRef: number;
|
||||
private lastUpdateRef: number;
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.display = <this.MainDisplay ctx={this}/>;
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
className: "timer-widget",
|
||||
title: "Next update in:",
|
||||
body: this.display,
|
||||
});
|
||||
AppStore().subscribe("lastUpdateTime", () => this.resetTimer());
|
||||
setInterval(() => this.refreshTimer(), 10);
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
private resetTimer() {
|
||||
this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds * 1000;
|
||||
this.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime).toLocaleString();
|
||||
this.refreshTimer();
|
||||
}
|
||||
|
||||
private MainDisplay({ ctx }: { ctx: TimerWidget }) {
|
||||
ctx.timerRef = ctx.makeRef(<div className={"countdown"}/>);
|
||||
ctx.lastUpdateRef = ctx.makeRef(
|
||||
<span className={"last-update"}>
|
||||
{new Date(getAppState().lastUpdateTime).toLocaleString()}
|
||||
</span>);
|
||||
return (<div>
|
||||
{ctx.fromRef(ctx.timerRef)}
|
||||
<div>
|
||||
<div className={"last-update"}>Last update was at:</div>
|
||||
<div>{ctx.fromRef(ctx.lastUpdateRef)}</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
private refreshTimer() {
|
||||
const now = new Date().getTime();
|
||||
if (now <= this.nextUpdateTime) {
|
||||
this.fromRef(this.timerRef).innerText = `${((this.nextUpdateTime - now)/1000).toFixed(2)}s`;
|
||||
} else {
|
||||
this.fromRef(this.timerRef).innerText = "0.00s";
|
||||
}
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default TimerWidget;
|
||||
30
dashboard/src/TimezoneWidget.ts
Normal file
30
dashboard/src/TimezoneWidget.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import {AppStore} from "./StateStore";
|
||||
import UIComponent from "./UIComponent";
|
||||
|
||||
class TimezoneWidget extends UIComponent {
|
||||
private skeleton: GridWidget;
|
||||
private display: HTMLSpanElement = document.createElement("span");
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
title: "Displayed Timezone:",
|
||||
body: this.display,
|
||||
});
|
||||
AppStore().subscribe("utcOffset", () => this.updateDisplay());
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay() {
|
||||
const offset = AppStore().getState().utcOffset;
|
||||
this.display.innerText = `UTC ${offset > 1 ? "+" : "-"} ${Math.abs(offset)}:00`;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default TimezoneWidget;
|
||||
22
dashboard/src/UIComponent.ts
Normal file
22
dashboard/src/UIComponent.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export default abstract class UIComponent {
|
||||
public readonly id: number;
|
||||
private static componentCount = 0;
|
||||
private static reffedComponentCount = 0;
|
||||
private static readonly reffedComponents: HTMLElement[] = [];
|
||||
|
||||
protected constructor() {
|
||||
this.id = UIComponent.componentCount;
|
||||
UIComponent.componentCount++;
|
||||
}
|
||||
|
||||
protected makeRef(el: HTMLElement | DocumentFragment): number {
|
||||
UIComponent.reffedComponents.push(el as HTMLElement);
|
||||
return UIComponent.reffedComponentCount++;
|
||||
}
|
||||
|
||||
protected fromRef(ref: number): HTMLElement | null {
|
||||
return UIComponent.reffedComponents[ref] ?? null;
|
||||
}
|
||||
|
||||
abstract current(): HTMLElement;
|
||||
}
|
||||
97
dashboard/src/climateChartConfig.ts
Normal file
97
dashboard/src/climateChartConfig.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {ChartConfiguration, ChartPoint, TimeUnit} from "chart.js";
|
||||
|
||||
interface ClimateChartSettings {
|
||||
humidity?: ChartPoint[];
|
||||
temp?: ChartPoint[];
|
||||
co2?: ChartPoint[];
|
||||
colors?: {
|
||||
humidity?: string;
|
||||
temp?: string;
|
||||
co2?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultHumidityColor = "rgb(196,107,107)";
|
||||
const defaultTempColor = "rgb(173,136,68)";
|
||||
const defaultCo2Color = "rgb(52,133,141)";
|
||||
|
||||
export function generateClimateChartConfig(settings: ClimateChartSettings): ChartConfiguration {
|
||||
return {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [{
|
||||
label: "Humidity",
|
||||
data: settings.humidity,
|
||||
borderColor: settings.colors?.humidity ?? defaultHumidityColor,
|
||||
fill: false,
|
||||
yAxisID: "y-axis-3",
|
||||
}, {
|
||||
label: "Temperature",
|
||||
data: settings.temp,
|
||||
borderColor: settings.colors?.temp ?? defaultTempColor,
|
||||
fill: false,
|
||||
yAxisID: "y-axis-2",
|
||||
}, {
|
||||
label: "Co2 Concentration",
|
||||
data: settings.co2,
|
||||
borderColor: settings.colors?.co2 ?? defaultCo2Color,
|
||||
fill: false,
|
||||
yAxisID: "y-axis-1",
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
animation: {animateRotate: false, duration: 0, animateScale: false},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
legend: {
|
||||
position: "top",
|
||||
align: "end",
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
type: "time",
|
||||
time: {
|
||||
unit: "second" as TimeUnit
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
type: "linear",
|
||||
display: true,
|
||||
position: "right",
|
||||
id: "y-axis-1",
|
||||
ticks: {
|
||||
fontColor: settings.colors?.co2 ?? defaultCo2Color,
|
||||
suggestedMin: 400,
|
||||
suggestedMax: 1100,
|
||||
},
|
||||
}, {
|
||||
type: "linear",
|
||||
display: true,
|
||||
position: "left",
|
||||
id: "y-axis-2",
|
||||
ticks: {
|
||||
fontColor: settings.colors?.temp ?? defaultTempColor,
|
||||
suggestedMin: 10,
|
||||
suggestedMax: 35,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
}, {
|
||||
type: "linear",
|
||||
display: true,
|
||||
position: "left",
|
||||
id: "y-axis-3",
|
||||
ticks: {
|
||||
fontColor: settings.colors?.humidity ?? defaultHumidityColor,
|
||||
suggestedMin: 15,
|
||||
suggestedMax: 85,
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
}],
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
6
dashboard/src/config.json
Normal file
6
dashboard/src/config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"development": true,
|
||||
"defaultMinuteSpan": 60,
|
||||
"reloadIntervalSec": 30,
|
||||
"dataEndpoint": "/climate/api"
|
||||
}
|
||||
48
dashboard/src/main.ts
Normal file
48
dashboard/src/main.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import config from "./config.json";
|
||||
import {AppStore, initStore} from "./StateStore";
|
||||
import AppUI from "./AppUI";
|
||||
export {config};
|
||||
|
||||
function getDisplayedMinutes() {
|
||||
let minutesDisplayed = config.defaultMinuteSpan;
|
||||
const argsStart = window.location.search.search(/\?minute-span=/);
|
||||
if (argsStart !== -1) {
|
||||
const parsedMins = Number(window.location.search.substring(13));
|
||||
if (!isNaN(parsedMins) && parsedMins > 0) {
|
||||
minutesDisplayed = parsedMins;
|
||||
}
|
||||
}
|
||||
return minutesDisplayed;
|
||||
}
|
||||
|
||||
function getUtcOffset() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const now = new Date().getTime();
|
||||
await initStore({
|
||||
overlayText: "",
|
||||
lastUpdateTime: now,
|
||||
minutesDisplayed: getDisplayedMinutes(),
|
||||
utcOffset: getUtcOffset(),
|
||||
snapshots: [],
|
||||
dataEndpointBase: config.dataEndpoint,
|
||||
isLoading: false,
|
||||
updateIntervalSeconds: config.reloadIntervalSec,
|
||||
displayMode: "pastMins",
|
||||
fatalError: null,
|
||||
displayWindow: {start: now - getDisplayedMinutes() * 60000, stop: now},
|
||||
documentReady: false,
|
||||
});
|
||||
const ui = new AppUI();
|
||||
ui.bootstrap("root");
|
||||
}
|
||||
|
||||
document.onreadystatechange = async () => {
|
||||
await init();
|
||||
AppStore().setDocumentReady(true);
|
||||
// @ts-ignore
|
||||
window.store = AppStore();
|
||||
document.onreadystatechange = null;
|
||||
};
|
||||
4
dashboard/src/types.d.ts
vendored
Normal file
4
dashboard/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "chart.js/dist/Chart.bundle.min" {
|
||||
import * as Charts from "chart.js";
|
||||
export default Charts;
|
||||
}
|
||||
Reference in New Issue
Block a user