it's done i think

This commit is contained in:
Daniel Ledda
2021-04-05 16:19:12 +02:00
parent 729db5ede1
commit 1fba1dbff1
24 changed files with 636 additions and 59 deletions

View File

@@ -15,6 +15,7 @@ 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][]>;
@@ -39,6 +40,7 @@ interface AppState {
fatalError: Error | null;
documentReady: boolean;
highlightedTimeseries: string | null;
showingHelp: boolean;
}
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
@@ -62,10 +64,11 @@ function newDefaultState(): AppState {
leftTimeseries: [],
rightTimeseries: [],
highlightedTimeseries: null,
showingHelp: false,
};
}
class AppStateStore {
class AppStateStore {
private readonly subscriptions: IAppStateSubscriptions;
private readonly eventCallbacks: EventCallbackListing<keyof EventCallback>;
private readonly state: AppState;
@@ -77,7 +80,7 @@ class AppStateStore {
for (const key in this.state) {
subscriptions[key] = [];
}
this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: [], stateChange: []};
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);
@@ -86,6 +89,7 @@ class AppStateStore {
async init() {
await this.updateTimeseriesFromSettings();
await this.getNewTimeseriesData();
this.emit("ready");
}
addTimeseriesToScale(timeseries: Timeseries, scale?: ScaleId) {
@@ -191,7 +195,7 @@ class AppStateStore {
setDisplayWindow(newWin: TimeWindow) {
if (newWin.start < newWin.stop) {
if (newWin.stop < this.state.lastUpdateTime) {
if (newWin.stop <= this.state.lastUpdateTime) {
this.state.displayWindow = {...newWin};
this.notifyStoreVal("displayWindow");
this.updateTimeseriesFromSettings();
@@ -270,6 +274,24 @@ class AppStateStore {
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") {

View File

@@ -95,7 +95,7 @@ class Timeseries {
const cacheStopIndex = this.findIndexInCache(stop);
return this.cache.slice(
(cacheStartIndex - (cacheStartIndex) % blockSize),
(cacheStopIndex + blockSize - (cacheStopIndex) % blockSize),
(cacheStopIndex + blockSize - (cacheStopIndex) % blockSize) + blockSize * 2,
);
}
}

View File

@@ -40,8 +40,9 @@ export default class Chart {
constructor(context: CanvasRenderingContext2D) {
this.subscriptions = {scroll: [], mousemove: [], drag: []};
this.ctx = context;
this.initLayout();
this.updateDimensions();
this.leftScale = new Scale();
this.rightScale = new Scale();
this.updateLayout();
this.ctx.fillStyle = "rgb(255,255,255)";
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
this.ctx.fill();
@@ -54,12 +55,12 @@ export default class Chart {
this.ctx.canvas.onwheel = (e) => this.handleScroll(e);
}
private initLayout() {
updateLayout() {
const leftScaleInitialWidth = 50;
const rightScaleInitialWidth = 50;
const verticalMargins = this.margins.bottom + this.margins.top;
const horizontalMargins = this.margins.left + this.margins.right;
this.leftScale = new Scale({
this.leftScale.updateBounds({
top: this.margins.top,
left: this.margins.left,
height: this.ctx.canvas.height - verticalMargins,
@@ -71,17 +72,13 @@ export default class Chart {
height: this.ctx.canvas.height - verticalMargins,
width: this.ctx.canvas.width - (horizontalMargins + leftScaleInitialWidth + rightScaleInitialWidth),
};
this.rightScale = new Scale({
this.rightScale.updateBounds({
top: this.margins.top,
left: this.ctx.canvas.width - this.margins.right - rightScaleInitialWidth,
height: this.ctx.canvas.height - verticalMargins,
width: rightScaleInitialWidth,
});
}
private updateDimensions() {
this.chartBounds.width = Number(getComputedStyle(this.ctx.canvas).width.slice(0, -2)) - (this.margins.left + this.margins.right + this.rightScale.getBounds().width + this.leftScale.getBounds().width);
this.chartBounds.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2)) - (this.margins.bottom + this.margins.top);
this.render();
}
addTimeseries(timeseries: Timeseries, scale?: ScaleId) {
@@ -141,7 +138,6 @@ export default class Chart {
}
render() {
this.updateDimensions();
this.clearCanvas();
this.updateResolution();
this.renderGuides();
@@ -149,15 +145,43 @@ export default class Chart {
this.rightScale.updateIndexRange(this.indexRange);
this.leftScale.listTimeseries().forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Left));
this.rightScale.listTimeseries().forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Right));
this.renderMargins();
this.leftScale.render(this.ctx);
this.rightScale.render(this.ctx);
this.renderTooltips();
}
private renderMargins() {
this.ctx.fillStyle = "rgb(255,255,255)";
this.ctx.fillRect(
this.ctx.canvas.clientLeft - 1,
this.ctx.canvas.clientTop - 1,
this.ctx.canvas.width + 1,
this.margins.top + 1,
);
this.ctx.fillRect(
this.ctx.canvas.clientLeft + this.ctx.canvas.width - this.margins.right - 1,
this.ctx.canvas.clientTop - 1,
this.margins.right + 1,
this.ctx.canvas.height + 1,
);
this.ctx.fillRect(
this.ctx.canvas.clientLeft - 1,
this.ctx.canvas.clientTop - 1,
this.margins.left + 1,
this.ctx.canvas.height + 1,
);
this.ctx.fillRect(
this.ctx.canvas.clientLeft,
this.ctx.canvas.clientTop + this.ctx.canvas.height - this.margins.bottom,
this.ctx.canvas.width,
this.margins.bottom,
);
}
private clearCanvas() {
this.ctx.fillStyle = "rgb(255,255,255)";
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
this.ctx.fill();
}
private updateResolution() {
@@ -174,13 +198,13 @@ export default class Chart {
private renderGuides() {
this.ctx.strokeStyle = "rgb(230, 230, 230)";
this.ctx.lineWidth = 1;
this.ctx.beginPath();
for (const tick of this.rightScale.getTicks()) {
const pos = this.rightScale.getY(tick);
this.ctx.beginPath();
const pos = Math.floor(this.rightScale.getY(tick));
this.ctx.moveTo(this.chartBounds.left, pos);
this.ctx.lineTo(this.chartBounds.left + this.chartBounds.width, pos);
this.ctx.stroke();
}
this.ctx.stroke();
}
private renderTooltips(radius = 20) {
@@ -250,8 +274,8 @@ export default class Chart {
this.ctx.lineWidth = timeseries.getName() === this.highlightedTimeseries ? 2 : 1;
let y = scale.getY(timeseriesPoints[0]);
let x = this.getX(timeseriesPoints[1]);
this.ctx.beginPath();
for (let i = 0; i < timeseriesPoints.length; i += 2 * this.resolution) {
this.ctx.beginPath();
this.ctx.moveTo(Math.round(x), Math.round(y));
y = 0;
x = 0;
@@ -262,13 +286,11 @@ export default class Chart {
y = scale.getY(y / this.resolution);
x = this.getX(x / this.resolution);
this.ctx.lineTo(Math.round(x), Math.round(y));
this.ctx.stroke();
if (this.resolution === 1) {
this.ctx.beginPath();
this.ctx.ellipse(x, y, 2, 2, 0, 0, 2 * Math.PI);
this.ctx.stroke();
}
}
this.ctx.stroke();
}
private renderTooltip(text: string, x: number, y: number, markerColour: string) {

View File

@@ -8,8 +8,8 @@ export default class Scale {
private tickCacheDirty = true;
private bounds: Bounds;
constructor(bounds: Bounds) {
this.bounds = bounds;
constructor(bounds?: Bounds) {
this.bounds = bounds ?? {height: 0, width: 0, top: 0, left: 0};
}
updateIndexRange(indexRange: {start: number, stop: number}) {
@@ -27,6 +27,10 @@ export default class Scale {
this.tickCacheDirty = true;
}
updateBounds(bounds: Bounds) {
Object.assign(this.bounds, bounds);
}
getBounds() {
return Object.assign({}, this.bounds);
}

View File

@@ -2,5 +2,5 @@
"development": false,
"defaultMinuteSpan": 60,
"reloadIntervalSec": 30,
"dataEndpoint": "/climate/api"
"dataEndpoint": "http://tortedda.local/climate/api"
}

View File

@@ -1,4 +1,8 @@
declare module "chart.js/dist/Chart.bundle.min" {
import * as Charts from "chart.js";
export default Charts;
declare module "*.gif" {
const value: string;
export = value;
}
declare module "*.png" {
const value: string;
export = value;
}

View File

@@ -7,6 +7,10 @@ import MessageOverlay from "./MessageOverlay";
import UIComponent from "./UIComponent";
import SelectDisplayModeWidget from "./SelectDisplayModeWidget";
import LegendWidget from "./LegendWidget";
import HelpModal from "./HelpModal";
import * as JSX from "../JSXFactory";
import {AppStore} from "../StateStore";
import HelpButtonImg from "../../assets/help-button.png";
class AppUI extends UIComponent {
private timezoneWidget: TimezoneWidget;
@@ -18,14 +22,21 @@ class AppUI extends UIComponent {
private element: HTMLDivElement = document.createElement("div");
private grid: HTMLDivElement = document.createElement("div");
private messageOverlay: MessageOverlay = new MessageOverlay();
private helpModal: HelpModal = new HelpModal();
constructor() {
super();
this.setupGrid({width: 5, height: 10});
this.element.append(
Object.assign(document.createElement("h1"), { innerText: "Ledda's Room Climate" }),
<img
alt={"Help"}
src={HelpButtonImg}
className={"help-button button"}
onclick={() => AppStore().showHelp()}/>,
<h1>Ledda's Room Climate</h1>,
this.grid,
this.messageOverlay.current(),
this.helpModal.current(),
);
this.element.className = "center";
}
@@ -33,8 +44,8 @@ class AppUI extends UIComponent {
private setupGrid(size: GridSize) {
this.setupWidgets();
this.grid.append(
this.legendWidget.current(),
this.chartWidget.current(),
this.legendWidget.current(),
this.displayModeSettingsWidget.current(),
this.selectModeWidget.current(),
this.timerWidget.current(),
@@ -68,7 +79,6 @@ class AppUI extends UIComponent {
bootstrap(rootNode: string) {
document.getElementById(rootNode).append(this.element);
this.chartWidget.updateDimensions();
}
current(): HTMLElement {

View File

@@ -26,6 +26,8 @@ class ClimateChartWidget extends UIComponent {
}
updateDimensions() {
this.canvasElement.width = 0;
this.canvasElement.height = 0;
const skelStyle = getComputedStyle(this.skeleton.current());
this.canvasElement.height = this.skeleton.current().clientHeight
- Number(skelStyle.paddingTop.slice(0, -2))
@@ -33,6 +35,7 @@ class ClimateChartWidget extends UIComponent {
this.canvasElement.width = this.skeleton.current().clientWidth
- Number(skelStyle.paddingLeft.slice(0, -2))
- Number(skelStyle.paddingRight.slice(0, -2));
this.chart.updateLayout();
}
private setupListeners() {
@@ -41,18 +44,22 @@ class ClimateChartWidget extends UIComponent {
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("documentReady", async () => {
await this.initChart();
this.updateDimensions();
window.addEventListener("resize", () => {
this.updateDimensions();
});
});
AppStore().subscribeStoreVal("utcOffset", () => this.updateTimezone());
AppStore().subscribeStoreVal("highlightedTimeseries", (name) => this.chart.highlightTimeseries(name));
}
private handleScroll(direction: number, magnitude: number, index: number) {
let displayedWindow = getAppState().displayWindow;
if (getAppState().displayMode === "pastMins") {
AppStore().setDisplayMode("window");
const now = new Date().getTime() / 1000;
displayedWindow = {start: now - getAppState().minutesDisplayed * 60, stop: now};
AppStore().emulateLastMinsWithWindow();
}
const displayedWindow = getAppState().displayWindow;
const beforeIndex = index - displayedWindow.start;
const afterIndex = displayedWindow.stop - index;
const factor = direction === 1 ? 1.1 : 0.9;
@@ -66,12 +73,12 @@ class ClimateChartWidget extends UIComponent {
private handleDrag(deltaX: number, deltaY: number, deltaIndex: number) {
if (getAppState().displayMode === "pastMins") {
AppStore().setDisplayMode("window");
AppStore().emulateLastMinsWithWindow();
}
const displayWindow = getAppState().displayWindow;
const displayedWindow = getAppState().displayWindow;
AppStore().setDisplayWindow({
start: displayWindow.start + deltaIndex,
stop: displayWindow.stop + deltaIndex,
start: displayedWindow.start + deltaIndex,
stop: displayedWindow.stop + deltaIndex,
});
}

View File

@@ -0,0 +1,80 @@
import {AppStore, getAppState} from "../StateStore";
import UIComponent from "./UIComponent";
import * as JSX from "../JSXFactory";
import MovePic from "../../assets/move-example.gif";
import ZoomPic from "../../assets/zoom-example.gif";
import ScrollPic from "../../assets/scroll-date-example.gif";
class HelpModal extends UIComponent {
private element: HTMLElement;
private visible = false;
constructor() {
super();
this.build();
AppStore().subscribeStoreVal("showingHelp", () => this.update());
this.update();
}
private build() {
this.element = (
<div className={"center"}>
<div
className={"overlay center"}
onclick={() => this.hide()}/>
<div className={"help-box"}>
<div
className={"x-button button"}
onclick={() => this.hide()}/>
<h1>Quick Help</h1>
<div className={"image-advice"}>
<img alt={"Animated example of scrolling over display timestamps"} src={ScrollPic}/>
<div>
Clicking the plus and minus buttons will adjust the time spans and minute spans by one minute.
Try scrolling over the numbers or clicking on them for direct editing as well!
</div>
</div>
<div className={"image-advice"}>
<img alt={"Animated example of dragging back and forth on the chart"} src={MovePic}/>
<div>
Dragging over the chart will switch to time window mode and allow you to pan back and forth.
</div>
</div>
<div className={"image-advice"}>
<img alt={"Animated example of zooming in and out on the chart"} src={ZoomPic}/>
<div>
Try scrolling whilst hovering over the chart to zoom in and out.
</div>
</div>
</div>
</div>
);
}
private show() {
this.visible = true;
this.element.classList.remove("hidden");
AppStore().showHelp();
}
private hide() {
this.visible = false;
this.element.classList.add("hidden");
AppStore().hideHelp();
}
update() {
this.visible = getAppState().showingHelp;
if (this.visible) {
this.element.classList.remove("hidden");
} else {
this.element.classList.add("hidden");
}
}
current() {
return this.element;
}
}
export default HelpModal;