fixed blurry graphics, improvements to loading and caching
This commit is contained in:
73
dashboard/src/ui-components/AppUI.ts
Normal file
73
dashboard/src/ui-components/AppUI.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
this.chartWidget.updateDimensions();
|
||||
}
|
||||
|
||||
current(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
}
|
||||
|
||||
export default AppUI;
|
||||
97
dashboard/src/ui-components/ClimateChartWidget.ts
Normal file
97
dashboard/src/ui-components/ClimateChartWidget.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {AppStore, DisplayMode, getAppState} from "../StateStore";
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import UIComponent from "./UIComponent";
|
||||
import ClimateChart from "../ClimateChart";
|
||||
|
||||
class ClimateChartWidget extends UIComponent {
|
||||
private readonly skeleton: GridWidget;
|
||||
private chart: ClimateChart | null = null;
|
||||
private initialised: boolean;
|
||||
private displayMode: DisplayMode = "pastMins";
|
||||
private latestSnapshotInChartTime: number;
|
||||
private readonly canvasElement: HTMLCanvasElement = document.createElement("canvas");
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.initialised = false;
|
||||
this.canvasElement.className = "chart-canvas";
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
body: this.canvasElement,
|
||||
});
|
||||
const now = new Date().getTime() / 1000;
|
||||
this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
updateDimensions() {
|
||||
const skelStyle = getComputedStyle(this.skeleton.current());
|
||||
this.canvasElement.height = this.skeleton.current().clientHeight
|
||||
- Number(skelStyle.paddingTop.slice(0, -2))
|
||||
- Number(skelStyle.paddingBottom.slice(0, -2));
|
||||
this.canvasElement.width = this.skeleton.current().clientWidth
|
||||
- Number(skelStyle.paddingLeft.slice(0, -2))
|
||||
- Number(skelStyle.paddingRight.slice(0, -2));
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
AppStore().subscribeStoreVal("displayMode", () => this.updateDisplayMode());
|
||||
AppStore().subscribeStoreVal("minutesDisplayed", () => this.rerender());
|
||||
AppStore().subscribeStoreVal("displayWindow", () => this.rerender());
|
||||
AppStore().on("timeseriesUpdated", () => this.rerender());
|
||||
AppStore().on("newTimeseries", (timeseries) => this.chart.addTimeseries(timeseries));
|
||||
AppStore().subscribeStoreVal("documentReady", () => this.initChart());
|
||||
AppStore().subscribeStoreVal("utcOffset", () => this.updateTimezone());
|
||||
}
|
||||
|
||||
private updateTimezone() {
|
||||
const offset = getAppState().utcOffset * 60 * 60 * 1000;
|
||||
this.chart.setTimestampFormatter((timestamp) => new Date(timestamp * 1000 + offset).toLocaleTimeString());
|
||||
}
|
||||
|
||||
private async initChart() {
|
||||
try {
|
||||
AppStore().addLoad();
|
||||
const ctx = this.canvasElement.getContext("2d", {alpha: false});
|
||||
this.chart = new ClimateChart(ctx);
|
||||
for (const timeseries of getAppState().timeseries) {
|
||||
this.chart.addTimeseries(timeseries);
|
||||
}
|
||||
await this.rerender();
|
||||
this.initialised = true;
|
||||
} catch (e) {
|
||||
AppStore().fatalError(e);
|
||||
} finally {
|
||||
AppStore().finishLoad();
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDisplayMode() {
|
||||
this.displayMode = getAppState().displayMode;
|
||||
await this.rerender();
|
||||
}
|
||||
|
||||
private async rerender() {
|
||||
if (!this.initialised) {
|
||||
return;
|
||||
}
|
||||
let start;
|
||||
let stop;
|
||||
if (this.displayMode === "window") {
|
||||
start = getAppState().displayWindow.start;
|
||||
stop = getAppState().displayWindow.stop;
|
||||
} else if (this.displayMode === "pastMins") {
|
||||
const mins = getAppState().minutesDisplayed;
|
||||
start = getAppState().lastUpdateTime - mins * 60;
|
||||
stop = getAppState().lastUpdateTime;
|
||||
}
|
||||
this.chart.setRange({start, stop});
|
||||
this.chart.render();
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default ClimateChartWidget;
|
||||
164
dashboard/src/ui-components/DisplayModeWidget.tsx
Normal file
164
dashboard/src/ui-components/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().subscribeStoreVal("minutesDisplayed", () => this.updateDisplay());
|
||||
AppStore().subscribeStoreVal("displayMode", () => this.updateDisplay());
|
||||
AppStore().subscribeStoreVal("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 - 60, stop: displayWindow.stop});
|
||||
}}/>
|
||||
<ctx.WindowStartTime ctx={ctx}/>
|
||||
<ctx.PlusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start + 60, stop: displayWindow.stop});
|
||||
}}/>
|
||||
<div>to</div>
|
||||
<ctx.MinusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop - 60});
|
||||
}}/>
|
||||
<ctx.WindowStopTime ctx={ctx}/>
|
||||
<ctx.PlusButton onclick={() => {
|
||||
const displayWindow = AppStore().getState().displayWindow;
|
||||
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop + 60});
|
||||
}}/>
|
||||
</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 * 1000).toLocaleString();
|
||||
this.fromRef(this.windowStopTimeRef).innerText = new Date(getAppState().displayWindow.stop * 1000).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/ui-components/GridWidget.ts
Normal file
66
dashboard/src/ui-components/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;
|
||||
63
dashboard/src/ui-components/MessageOverlay.ts
Normal file
63
dashboard/src/ui-components/MessageOverlay.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {AppStore, getAppState} from "../StateStore";
|
||||
import UIComponent from "./UIComponent";
|
||||
|
||||
class MessageOverlay extends UIComponent {
|
||||
private element: HTMLDivElement;
|
||||
private textElement: HTMLSpanElement;
|
||||
private showingError = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.build();
|
||||
AppStore().subscribeStoreVal("overlayText", () => this.update());
|
||||
AppStore().subscribeStoreVal("isLoading", () => this.update());
|
||||
AppStore().subscribeStoreVal("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/ui-components/SelectDisplayModeWidget.tsx
Normal file
61
dashboard/src/ui-components/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().subscribeStoreVal("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();
|
||||
}
|
||||
}
|
||||
62
dashboard/src/ui-components/TimerWidget.tsx
Normal file
62
dashboard/src/ui-components/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().subscribeStoreVal("lastUpdateTime", () => this.resetTimer());
|
||||
setInterval(() => this.refreshTimer(), 10);
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
private resetTimer() {
|
||||
this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds;
|
||||
this.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime * 1000).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() / 1000;
|
||||
if (now <= this.nextUpdateTime) {
|
||||
this.fromRef(this.timerRef).innerText = `${(this.nextUpdateTime - now).toFixed(2)}s`;
|
||||
} else {
|
||||
this.fromRef(this.timerRef).innerText = "0.00s";
|
||||
}
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default TimerWidget;
|
||||
69
dashboard/src/ui-components/TimezoneWidget.tsx
Normal file
69
dashboard/src/ui-components/TimezoneWidget.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import GridWidget, {GridProps} from "./GridWidget";
|
||||
import {AppStore, getAppState} from "../StateStore";
|
||||
import UIComponent from "./UIComponent";
|
||||
import * as JSX from "../JSXFactory";
|
||||
|
||||
class TimezoneWidget extends UIComponent {
|
||||
private skeleton: GridWidget;
|
||||
private display: HTMLSpanElement = document.createElement("span");
|
||||
private timezoneInputRef: number;
|
||||
private timezoneDisplayRef: number;
|
||||
|
||||
constructor(gridProps: GridProps) {
|
||||
super();
|
||||
this.display = <this.MainBody ctx={this}/>;
|
||||
this.skeleton = new GridWidget({
|
||||
...gridProps,
|
||||
title: "Displayed Timezone:",
|
||||
body: this.display,
|
||||
});
|
||||
AppStore().subscribeStoreVal("utcOffset", () => this.updateDisplay());
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay() {
|
||||
const offset = AppStore().getState().utcOffset;
|
||||
this.fromRef(this.timezoneDisplayRef).innerText = `${offset > 0 ? "+" : "−"} ${Math.abs(offset)}`;
|
||||
(this.fromRef(this.timezoneInputRef) as HTMLInputElement).value = `${offset > 0 ? "" : "-"}${Math.abs(offset)}`;
|
||||
}
|
||||
|
||||
private MainBody({ctx}: {ctx: TimezoneWidget}) {
|
||||
return <div
|
||||
className={"timezone-widget"}
|
||||
onclick={() => ctx.onTimezoneClick()}>
|
||||
<span>UTC </span>
|
||||
<ctx.TimezoneDisplay ctx={ctx} />
|
||||
<span>:00</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
private TimezoneDisplay({ctx}: {ctx: TimezoneWidget}) {
|
||||
ctx.timezoneDisplayRef = ctx.makeRef(<span/>);
|
||||
ctx.timezoneInputRef = ctx.makeRef(<input
|
||||
type={"text"}
|
||||
onblur={() => ctx.onTimezoneInputBlur()}/>);
|
||||
return ctx.fromRef(ctx.timezoneDisplayRef);
|
||||
}
|
||||
|
||||
private onTimezoneInputBlur() {
|
||||
const input = this.fromRef(this.timezoneInputRef) as HTMLInputElement;
|
||||
const display = this.fromRef(this.timezoneDisplayRef);
|
||||
AppStore().setUtcOffset(Number(input.value));
|
||||
input.replaceWith(display);
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private onTimezoneClick() {
|
||||
const input = this.fromRef(this.timezoneInputRef) as HTMLInputElement;
|
||||
this.fromRef(this.timezoneDisplayRef).replaceWith(input);
|
||||
input.focus();
|
||||
input.selectionStart = 0;
|
||||
input.selectionEnd = input.value.length;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.skeleton.current();
|
||||
}
|
||||
}
|
||||
|
||||
export default TimezoneWidget;
|
||||
22
dashboard/src/ui-components/UIComponent.ts
Normal file
22
dashboard/src/ui-components/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;
|
||||
}
|
||||
Reference in New Issue
Block a user