fixed blurry graphics, improvements to loading and caching

This commit is contained in:
Daniel Ledda
2021-03-23 10:41:17 +01:00
parent a810c5c71d
commit 942db5c18f
14 changed files with 86 additions and 66 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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();
}
}

View 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;

View 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;

View 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;
}