big update with better scales, massive ui additions, etc.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -76,6 +76,7 @@ h1 {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.display-mode-widget-mins .minus-button,
|
.display-mode-widget-mins .minus-button,
|
||||||
.display-mode-widget-mins .plus-button {
|
.display-mode-widget-mins .plus-button {
|
||||||
@@ -114,12 +115,49 @@ h1 {
|
|||||||
margin: 10px 0 10px 0;
|
margin: 10px 0 10px 0;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
.display-mode-widget-mins input {
|
||||||
|
border: none;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 30px;
|
||||||
|
width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-mode-widget input {
|
||||||
|
width: 12em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.display-mode-widget-date {
|
.display-mode-widget-date {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
margin: 5px 0 5px 0;
|
margin: 5px 0 5px 0;
|
||||||
|
transition: background-color 100ms;
|
||||||
|
}
|
||||||
|
.display-mode-widget-date:hover {
|
||||||
|
background-color: #eaeaea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-mode-option * {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 100ms;
|
||||||
|
}
|
||||||
|
.display-mode-option input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.display-mode-option.selected {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.display-mode-option.selected:before {
|
||||||
|
content: "- ";
|
||||||
|
}
|
||||||
|
.display-mode-option.selected:after {
|
||||||
|
content: " -";
|
||||||
|
}
|
||||||
|
.display-mode-option:hover {
|
||||||
|
background-color: #eaeaea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdown {
|
.countdown {
|
||||||
@@ -146,3 +184,19 @@ h1 {
|
|||||||
margin: 10px 0 10px 0;
|
margin: 10px 0 10px 0;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legend-widget li:before {
|
||||||
|
content: "• ";
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.legend-widget ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.legend-widget li.highlighted {
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import Timeseries from "./Timeseries";
|
|
||||||
|
|
||||||
interface Scale {
|
|
||||||
timeseries: Timeseries[];
|
|
||||||
valRange: {high: number, low: number};
|
|
||||||
width: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ScaleId {
|
|
||||||
Left,
|
|
||||||
Right
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_PIXELS_PER_POINT = 3;
|
|
||||||
|
|
||||||
export default class ClimateChart {
|
|
||||||
private readonly ctx: CanvasRenderingContext2D;
|
|
||||||
private readonly leftScale: Scale;
|
|
||||||
private readonly rightScale: Scale;
|
|
||||||
private readonly lastMousePos = {x: 0, y: 0};
|
|
||||||
private readonly indexRange = {start: 0, stop: 0};
|
|
||||||
private readonly margins = {top: 20, bottom: 20};
|
|
||||||
private formatTimestamp = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString();
|
|
||||||
private width = 0;
|
|
||||||
private height = 0;
|
|
||||||
private resolution = 1;
|
|
||||||
constructor(context: CanvasRenderingContext2D) {
|
|
||||||
this.ctx = context;
|
|
||||||
this.ctx.fillStyle = "rgb(255,255,255)";
|
|
||||||
this.updateDimensions();
|
|
||||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
|
||||||
this.ctx.fill();
|
|
||||||
this.ctx.translate(0.5, 0.5);
|
|
||||||
this.ctx.canvas.onmousemove = (e) => this.handleMouseMove(e);
|
|
||||||
this.leftScale = {
|
|
||||||
timeseries: [],
|
|
||||||
valRange: {high: -Infinity, low: Infinity},
|
|
||||||
width: 0,
|
|
||||||
};
|
|
||||||
this.rightScale = {
|
|
||||||
timeseries: [],
|
|
||||||
valRange: {high: -Infinity, low: Infinity},
|
|
||||||
width: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateDimensions() {
|
|
||||||
this.width = Number(getComputedStyle(this.ctx.canvas).width.slice(0, -2));
|
|
||||||
this.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2));
|
|
||||||
}
|
|
||||||
|
|
||||||
addTimeseries(timeseries: Timeseries, scale?: ScaleId) {
|
|
||||||
if (scale === ScaleId.Left) {
|
|
||||||
this.leftScale.timeseries.push(timeseries);
|
|
||||||
} else {
|
|
||||||
this.rightScale.timeseries.push(timeseries);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRange(range: {start: number, stop: number}) {
|
|
||||||
this.indexRange.start = range.start;
|
|
||||||
this.indexRange.stop = range.stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseMove(event: MouseEvent) {
|
|
||||||
const {left: canvasX, top: canvasY} = this.ctx.canvas.getBoundingClientRect();
|
|
||||||
const x = event.clientX - canvasX;
|
|
||||||
const y = event.clientY - canvasY;
|
|
||||||
this.lastMousePos.x = x;
|
|
||||||
this.lastMousePos.y = y;
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
this.updateDimensions();
|
|
||||||
this.clearCanvas();
|
|
||||||
this.updateResolution();
|
|
||||||
this.setDisplayRangeForScale(this.leftScale);
|
|
||||||
this.setDisplayRangeForScale(this.rightScale);
|
|
||||||
this.renderRightScale();
|
|
||||||
this.leftScale.timeseries.forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Left));
|
|
||||||
this.rightScale.timeseries.forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Right));
|
|
||||||
this.renderLeftScale();
|
|
||||||
this.renderTooltips();
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearCanvas() {
|
|
||||||
this.ctx.fillStyle = "rgb(255,255,255)";
|
|
||||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
|
||||||
this.ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateResolution() {
|
|
||||||
const chartWidth = (this.width - this.rightScale.width - this.leftScale.width);
|
|
||||||
const points = this.rightScale.timeseries[0].cachedBetween(this.indexRange.start, this.indexRange.stop).length / 2;
|
|
||||||
const pixelsPerPoint = chartWidth / points;
|
|
||||||
if (pixelsPerPoint < MIN_PIXELS_PER_POINT) {
|
|
||||||
this.resolution = Math.ceil(MIN_PIXELS_PER_POINT / pixelsPerPoint);
|
|
||||||
} else {
|
|
||||||
this.resolution = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderLeftScale() {
|
|
||||||
this.ctx.fillStyle = "rgb(255,255,255)";
|
|
||||||
this.ctx.fillRect(0, 0, this.leftScale.width, this.height);
|
|
||||||
this.ctx.fill();
|
|
||||||
this.ctx.strokeStyle = "rgb(230,230,230)";
|
|
||||||
this.ctx.fillStyle = "black";
|
|
||||||
const ticks = 20;
|
|
||||||
const tickHeight = (this.leftScale.valRange.high - this.leftScale.valRange.low) / ticks;
|
|
||||||
let currentTick = this.leftScale.valRange.low - tickHeight;
|
|
||||||
for (let i = 0; i <= ticks; i++) {
|
|
||||||
currentTick += tickHeight;
|
|
||||||
const text = currentTick.toFixed(2);
|
|
||||||
const textWidth = this.ctx.measureText(text).width;
|
|
||||||
if (textWidth > this.leftScale.width) {
|
|
||||||
this.leftScale.width = textWidth + 10;
|
|
||||||
}
|
|
||||||
const pos = Math.round(this.getY(currentTick, ScaleId.Left));
|
|
||||||
this.ctx.fillText(text, 0, pos + 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderRightScale() {
|
|
||||||
this.ctx.strokeStyle = "rgb(230,230,230)";
|
|
||||||
this.ctx.fillStyle = "black";
|
|
||||||
const ticks = 20;
|
|
||||||
const tickHeight = (this.rightScale.valRange.high - this.rightScale.valRange.low) / ticks;
|
|
||||||
let currentTick = this.rightScale.valRange.low - tickHeight;
|
|
||||||
for (let i = 0; i <= ticks; i++) {
|
|
||||||
currentTick += tickHeight;
|
|
||||||
const pos = Math.round(this.getY(currentTick, ScaleId.Right));
|
|
||||||
const text = currentTick.toFixed(2);
|
|
||||||
const textWidth = this.ctx.measureText(text).width;
|
|
||||||
if (textWidth > this.rightScale.width) {
|
|
||||||
this.rightScale.width = textWidth;
|
|
||||||
}
|
|
||||||
this.ctx.fillText(text, this.width - textWidth, pos + 4);
|
|
||||||
this.ctx.beginPath();
|
|
||||||
this.ctx.moveTo(this.leftScale.width, pos);
|
|
||||||
this.ctx.lineTo(this.width - textWidth - 5, pos);
|
|
||||||
this.ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setDisplayRangeForScale(scale: Scale) {
|
|
||||||
for (const timeseries of scale.timeseries) {
|
|
||||||
const extrema = timeseries.getExtrema();
|
|
||||||
if (extrema.maxVal > scale.valRange.high) {
|
|
||||||
scale.valRange.high = extrema.maxVal;
|
|
||||||
}
|
|
||||||
if (extrema.minVal < scale.valRange.low) {
|
|
||||||
scale.valRange.low = extrema.minVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderTooltips(radius = 20) {
|
|
||||||
let bestDist = radius;
|
|
||||||
let bestTimeseries = this.rightScale.timeseries[0];
|
|
||||||
let bestIndex = 0;
|
|
||||||
let bestVal = 0;
|
|
||||||
let bestScale = ScaleId.Right;
|
|
||||||
for (const scaleId of [ScaleId.Left, ScaleId.Right]) {
|
|
||||||
for (const timeseries of (scaleId === ScaleId.Right ? this.rightScale : this.leftScale).timeseries) {
|
|
||||||
const cache = timeseries.cachedBetween(
|
|
||||||
this.getIndex(this.lastMousePos.x - radius / 2),
|
|
||||||
this.getIndex(this.lastMousePos.x + radius / 2)
|
|
||||||
);
|
|
||||||
for (let i = 0; i < cache.length; i += 2) {
|
|
||||||
const y = this.getY(cache[i], scaleId);
|
|
||||||
if (y + radius / 2 >= this.lastMousePos.y && y - radius / 2 <= this.lastMousePos.y) {
|
|
||||||
const x = this.getX(cache[i + 1]);
|
|
||||||
const dist = Math.sqrt((y - this.lastMousePos.y) ** 2 + (x - this.lastMousePos.x) ** 2);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
bestDist = dist;
|
|
||||||
bestTimeseries = timeseries;
|
|
||||||
bestIndex = cache[i + 1];
|
|
||||||
bestVal = cache[i];
|
|
||||||
bestScale = scaleId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bestDist < 20) {
|
|
||||||
this.renderTooltip(
|
|
||||||
`${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`,
|
|
||||||
this.getX(bestIndex),
|
|
||||||
this.getY(bestVal, bestScale),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimestampFormatter(formatter: (timestamp: number) => string) {
|
|
||||||
this.formatTimestamp = formatter;
|
|
||||||
}
|
|
||||||
|
|
||||||
getX(index: number) {
|
|
||||||
return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * (this.width - this.rightScale.width - this.leftScale.width) + this.leftScale.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
getY(value: number, scale: ScaleId) {
|
|
||||||
const valRange = scale === ScaleId.Left ? this.leftScale.valRange : this.rightScale.valRange;
|
|
||||||
return this.height - (value - valRange.low) / (valRange.high - valRange.low) * (this.height - this.margins.bottom - this.margins.top) - this.margins.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIndex(x: number) {
|
|
||||||
return ((x - this.leftScale.width) / (this.width - this.leftScale.width - this.rightScale.width)) * (this.indexRange.stop - this.indexRange.start) + this.indexRange.start;
|
|
||||||
}
|
|
||||||
|
|
||||||
getValue(y: number, scale: ScaleId) {
|
|
||||||
const valRange = scale === ScaleId.Left ? this.leftScale.valRange : this.rightScale.valRange;
|
|
||||||
return ((this.height - y) / this.height) * (valRange.high - valRange.low) + valRange.low;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private renderTimeseries(timeseries: Timeseries, scale: ScaleId) {
|
|
||||||
const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop);
|
|
||||||
this.ctx.strokeStyle = timeseries.getColour();
|
|
||||||
let y = this.getY(timeseriesPoints[0], scale);
|
|
||||||
let x = this.getX(timeseriesPoints[1]);
|
|
||||||
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;
|
|
||||||
for (let j = 0; j < this.resolution * 2 && (j + 2 < timeseriesPoints.length); j += 2) {
|
|
||||||
y += timeseriesPoints[i + j];
|
|
||||||
x += timeseriesPoints[i + 1 + j];
|
|
||||||
}
|
|
||||||
y = this.getY(y / this.resolution, scale);
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderTooltip(text: string, x: number, y: number) {
|
|
||||||
this.ctx.strokeStyle = "rgb(255,0,0)";
|
|
||||||
this.ctx.beginPath();
|
|
||||||
this.ctx.ellipse(x, y, 5, 5, 0, 0, 2 * Math.PI);
|
|
||||||
this.ctx.stroke();
|
|
||||||
const measurements = this.ctx.measureText(text);
|
|
||||||
const textHeight = measurements.actualBoundingBoxAscent + measurements.actualBoundingBoxDescent;
|
|
||||||
const height = textHeight + 10;
|
|
||||||
const width = measurements.width + 10;
|
|
||||||
if (x + width > this.width) {
|
|
||||||
x -= width;
|
|
||||||
}
|
|
||||||
if (y + height > this.height) {
|
|
||||||
y -= height;
|
|
||||||
}
|
|
||||||
this.ctx.fillStyle = "rgb(255,255,255)";
|
|
||||||
this.ctx.strokeStyle = "rgb(0,0,0)";
|
|
||||||
this.ctx.fillRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height));
|
|
||||||
this.ctx.strokeRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height));
|
|
||||||
this.ctx.fillStyle = "rgb(0,0,0)";
|
|
||||||
this.ctx.fillText(text, Math.round(x + 5), Math.round(y + textHeight + 5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Timeseries from "./Timeseries";
|
import Timeseries from "./Timeseries";
|
||||||
import {ScaleId} from "./ClimateChart";
|
import {ScaleId} from "./chart/Chart";
|
||||||
|
import config from "./config.json";
|
||||||
|
|
||||||
export class AppStateError extends Error {
|
export class AppStateError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -11,9 +12,11 @@ export class AppStateError extends Error {
|
|||||||
export type DisplayMode = "window" | "pastMins";
|
export type DisplayMode = "window" | "pastMins";
|
||||||
|
|
||||||
export interface EventCallback {
|
export interface EventCallback {
|
||||||
newTimeseries: (timeseries: Timeseries) => void;
|
timeseriesUpdated: (timeseries: Timeseries) => void;
|
||||||
timeseriesUpdated: (timeseries: Timeseries, scale?: ScaleId) => void;
|
newTimeseries: (timeseries: Timeseries, scale?: ScaleId) => void;
|
||||||
|
stateChange: StateChangeCallback<AppState, keyof AppState>;
|
||||||
}
|
}
|
||||||
|
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][]>;
|
type EventCallbackListing<K extends keyof EventCallback> = Record<K, EventCallback[K][]>;
|
||||||
|
|
||||||
export interface TimeWindow {
|
export interface TimeWindow {
|
||||||
@@ -35,12 +38,32 @@ interface AppState {
|
|||||||
displayMode: DisplayMode;
|
displayMode: DisplayMode;
|
||||||
fatalError: Error | null;
|
fatalError: Error | null;
|
||||||
documentReady: boolean;
|
documentReady: boolean;
|
||||||
|
highlightedTimeseries: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
|
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
|
||||||
type SubscriptionType<K extends keyof AppState> = Record<K, StoreUpdateCallback<AppState[K]>[]>;
|
type SubscriptionType<K extends keyof AppState> = Record<K, StoreUpdateCallback<AppState[K]>[]>;
|
||||||
type IAppStateSubscriptions = SubscriptionType<keyof AppState>;
|
type IAppStateSubscriptions = SubscriptionType<keyof AppState>;
|
||||||
|
|
||||||
|
function newDefaultState(): AppState {
|
||||||
|
const now = new Date().getTime() / 1000;
|
||||||
|
return {
|
||||||
|
overlayText: "",
|
||||||
|
lastUpdateTime: now,
|
||||||
|
minutesDisplayed: config.defaultMinuteSpan,
|
||||||
|
utcOffset: -(new Date().getTimezoneOffset() / 60),
|
||||||
|
dataEndpointBase: config.dataEndpoint,
|
||||||
|
isLoading: false,
|
||||||
|
updateIntervalSeconds: config.reloadIntervalSec,
|
||||||
|
displayMode: "pastMins",
|
||||||
|
fatalError: null,
|
||||||
|
displayWindow: {start: now - config.defaultMinuteSpan * 60, stop: now},
|
||||||
|
documentReady: false,
|
||||||
|
leftTimeseries: [],
|
||||||
|
rightTimeseries: [],
|
||||||
|
highlightedTimeseries: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class AppStateStore {
|
class AppStateStore {
|
||||||
private readonly subscriptions: IAppStateSubscriptions;
|
private readonly subscriptions: IAppStateSubscriptions;
|
||||||
@@ -48,16 +71,16 @@ class AppStateStore {
|
|||||||
private readonly state: AppState;
|
private readonly state: AppState;
|
||||||
private loaders = 0;
|
private loaders = 0;
|
||||||
|
|
||||||
constructor(initialState: AppState) {
|
constructor(initialState?: Partial<AppState>) {
|
||||||
this.state = initialState;
|
this.state = { ...newDefaultState(), ...initialState };
|
||||||
const subscriptions: Record<string, (() => unknown)[]> = {};
|
const subscriptions: Record<string, (() => unknown)[]> = {};
|
||||||
for (const key in this.state) {
|
for (const key in this.state) {
|
||||||
subscriptions[key] = [];
|
subscriptions[key] = [];
|
||||||
}
|
}
|
||||||
this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: []};
|
this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: [], stateChange: []};
|
||||||
this.subscriptions = subscriptions as IAppStateSubscriptions;
|
this.subscriptions = subscriptions as IAppStateSubscriptions;
|
||||||
this.init();
|
this.init();
|
||||||
setInterval(() => this.getNewTimeseriesData(), this.state.updateIntervalSeconds * 1000);
|
setInterval(() => this.getNewTimeseriesData().catch(e => AppStore().fatalError(e)), this.state.updateIntervalSeconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@@ -65,7 +88,7 @@ class AppStateStore {
|
|||||||
await this.getNewTimeseriesData();
|
await this.getNewTimeseriesData();
|
||||||
}
|
}
|
||||||
|
|
||||||
addTimeseries(timeseries: Timeseries, scale?: ScaleId) {
|
addTimeseriesToScale(timeseries: Timeseries, scale?: ScaleId) {
|
||||||
const group = scale === ScaleId.Left ? this.state.leftTimeseries : this.state.rightTimeseries;
|
const group = scale === ScaleId.Left ? this.state.leftTimeseries : this.state.rightTimeseries;
|
||||||
if (group.indexOf(timeseries) >= 0) {
|
if (group.indexOf(timeseries) >= 0) {
|
||||||
throw new AppStateError("Timeseries has already been added!");
|
throw new AppStateError("Timeseries has already been added!");
|
||||||
@@ -76,13 +99,21 @@ class AppStateStore {
|
|||||||
group.push(timeseries);
|
group.push(timeseries);
|
||||||
}
|
}
|
||||||
this.notifyStoreVal(scale === ScaleId.Left ? "leftTimeseries" : "rightTimeseries");
|
this.notifyStoreVal(scale === ScaleId.Left ? "leftTimeseries" : "rightTimeseries");
|
||||||
this.eventCallbacks["newTimeseries"].forEach(cb => cb(timeseries, scale));
|
this.emit("newTimeseries", timeseries, scale);
|
||||||
this.updateTimeseriesFromSettings();
|
this.updateTimeseriesFromSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyStoreVal<T extends keyof AppState>(subscribedValue: T, newValue?: AppState[T], oldValue?: AppState[T]) {
|
private notifyStoreVal<T extends keyof AppState>(subscribedValue: T, newValue?: AppState[T], oldValue?: AppState[T]) {
|
||||||
|
this.emit("stateChange", subscribedValue, newValue, oldValue);
|
||||||
for (const subscriptionCallback of this.subscriptions[subscribedValue]) {
|
for (const subscriptionCallback of this.subscriptions[subscribedValue]) {
|
||||||
new Promise(() => subscriptionCallback(newValue, oldValue));
|
subscriptionCallback(newValue, oldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<T extends keyof EventCallback>(eventName: T, ...callbackArgs: Parameters<EventCallback[T]>) {
|
||||||
|
for (const sub of this.eventCallbacks[eventName]) {
|
||||||
|
// @ts-ignore
|
||||||
|
sub(...callbackArgs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +128,16 @@ class AppStateStore {
|
|||||||
stop = this.state.lastUpdateTime;
|
stop = this.state.lastUpdateTime;
|
||||||
}
|
}
|
||||||
this.addLoad();
|
this.addLoad();
|
||||||
|
try {
|
||||||
for (const timeseries of this.state.leftTimeseries) {
|
for (const timeseries of this.state.leftTimeseries) {
|
||||||
await timeseries.updateFromWindow(start, stop);
|
await timeseries.updateFromWindow(start, stop);
|
||||||
}
|
}
|
||||||
for (const timeseries of this.state.rightTimeseries) {
|
for (const timeseries of this.state.rightTimeseries) {
|
||||||
await timeseries.updateFromWindow(start, stop);
|
await timeseries.updateFromWindow(start, stop);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
AppStore().fatalError(e);
|
||||||
|
}
|
||||||
this.finishLoad();
|
this.finishLoad();
|
||||||
this.notifyAllTimeseriesUpdated();
|
this.notifyAllTimeseriesUpdated();
|
||||||
}
|
}
|
||||||
@@ -110,12 +145,16 @@ class AppStateStore {
|
|||||||
private async getNewTimeseriesData() {
|
private async getNewTimeseriesData() {
|
||||||
const updateTime = new Date().getTime() / 1000;
|
const updateTime = new Date().getTime() / 1000;
|
||||||
this.addLoad();
|
this.addLoad();
|
||||||
|
try {
|
||||||
for (const timeseries of this.state.leftTimeseries) {
|
for (const timeseries of this.state.leftTimeseries) {
|
||||||
await timeseries.getLatest();
|
await timeseries.getLatest();
|
||||||
}
|
}
|
||||||
for (const timeseries of this.state.rightTimeseries) {
|
for (const timeseries of this.state.rightTimeseries) {
|
||||||
await timeseries.getLatest();
|
await timeseries.getLatest();
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
AppStore().fatalError(e);
|
||||||
|
}
|
||||||
this.finishLoad();
|
this.finishLoad();
|
||||||
this.setLastUpdateTime(updateTime);
|
this.setLastUpdateTime(updateTime);
|
||||||
this.notifyAllTimeseriesUpdated();
|
this.notifyAllTimeseriesUpdated();
|
||||||
@@ -124,11 +163,11 @@ class AppStateStore {
|
|||||||
private notifyAllTimeseriesUpdated() {
|
private notifyAllTimeseriesUpdated() {
|
||||||
for (const timeseries of this.state.leftTimeseries) {
|
for (const timeseries of this.state.leftTimeseries) {
|
||||||
this.notifyStoreVal("leftTimeseries");
|
this.notifyStoreVal("leftTimeseries");
|
||||||
this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries));
|
this.emit("timeseriesUpdated", timeseries);
|
||||||
}
|
}
|
||||||
for (const timeseries of this.state.rightTimeseries) {
|
for (const timeseries of this.state.rightTimeseries) {
|
||||||
this.notifyStoreVal("rightTimeseries");
|
this.notifyStoreVal("rightTimeseries");
|
||||||
this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries));
|
this.emit("timeseriesUpdated", timeseries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +185,7 @@ class AppStateStore {
|
|||||||
|
|
||||||
setDisplayMode(mode: DisplayMode) {
|
setDisplayMode(mode: DisplayMode) {
|
||||||
this.state.displayMode = mode;
|
this.state.displayMode = mode;
|
||||||
|
this.updateTimeseriesFromSettings();
|
||||||
this.notifyStoreVal("displayMode");
|
this.notifyStoreVal("displayMode");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +264,65 @@ class AppStateStore {
|
|||||||
this.state.documentReady = isReady;
|
this.state.documentReady = isReady;
|
||||||
this.notifyStoreVal("documentReady");
|
this.notifyStoreVal("documentReady");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setHighlightedTimeseries(name: string | null) {
|
||||||
|
this.state.highlightedTimeseries = name;
|
||||||
|
this.notifyStoreVal("highlightedTimeseries", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
serialiseState(): string {
|
||||||
|
const stateStringParams = [];
|
||||||
|
if (this.state.displayMode === "pastMins") {
|
||||||
|
if (this.state.minutesDisplayed !== 60) {
|
||||||
|
stateStringParams.push(
|
||||||
|
`minutesDisplayed=${this.state.minutesDisplayed}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stateStringParams.push(
|
||||||
|
`displayWindow=[${this.state.displayWindow.start},${this.state.displayWindow.stop}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.state.utcOffset !== newDefaultState().utcOffset) {
|
||||||
|
stateStringParams.push(
|
||||||
|
`utcOffset=${this.state.utcOffset}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return stateStringParams.join("&");
|
||||||
|
}
|
||||||
|
|
||||||
|
deserialise(serial: URLSearchParams) {
|
||||||
|
if (serial.get("minutesDisplayed") && serial.get("displayWindow")) {
|
||||||
|
console.warn("Options 'minutesDisplayed' and 'displayWindow' should not be used together. Defaulting to 'displayWindow'.");
|
||||||
|
}
|
||||||
|
if (serial.get("minutesDisplayed")) {
|
||||||
|
this.setDisplayMode("pastMins");
|
||||||
|
this.setMinutesDisplayed(Number(serial.get("minutesDisplayed")));
|
||||||
|
}
|
||||||
|
if (serial.get("utcOffset")) {
|
||||||
|
this.setUtcOffset(Number(serial.get("utcOffset")));
|
||||||
|
}
|
||||||
|
if (serial.get("displayWindow")) {
|
||||||
|
const string = serial.get("displayWindow");
|
||||||
|
const split = string.split(",");
|
||||||
|
if (split.length === 2) {
|
||||||
|
this.setDisplayMode("window");
|
||||||
|
this.setDisplayWindow({ start: Number(split[0].slice(1)), stop: Number(split[1].slice(0, -1))});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let store: AppStateStore;
|
let store: AppStateStore;
|
||||||
|
|
||||||
export async function initStore(initialState: AppState) {
|
export async function initStore(initialState?: Partial<AppState> | URLSearchParams) {
|
||||||
|
if (initialState instanceof URLSearchParams) {
|
||||||
|
store = new AppStateStore();
|
||||||
|
store.deserialise(initialState);
|
||||||
|
} else {
|
||||||
store = new AppStateStore(initialState);
|
store = new AppStateStore(initialState);
|
||||||
|
}
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
type TimeseriesLoader = (start: number, stop: number) => Promise<Int32Array>;
|
type TimeseriesLoader = (start: number, stop: number) => Promise<Int32Array>;
|
||||||
|
|
||||||
|
type TimeseriesOptions = {
|
||||||
|
name: string,
|
||||||
|
loader: TimeseriesLoader,
|
||||||
|
tolerance?: number,
|
||||||
|
valueRangeOverride?: {
|
||||||
|
high: number,
|
||||||
|
low: number,
|
||||||
|
},
|
||||||
|
colour?: string,
|
||||||
|
};
|
||||||
|
|
||||||
type Extrema = {
|
type Extrema = {
|
||||||
minVal: number,
|
minVal: number,
|
||||||
maxVal: number,
|
maxVal: number,
|
||||||
@@ -19,19 +30,48 @@ class Timeseries {
|
|||||||
minIndex: Infinity,
|
minIndex: Infinity,
|
||||||
maxIndex: -Infinity,
|
maxIndex: -Infinity,
|
||||||
};
|
};
|
||||||
|
private valExtremaOverride?: { high: number, low: number };
|
||||||
private colour: string;
|
private colour: string;
|
||||||
private tolerance: number;
|
private tolerance: number;
|
||||||
|
|
||||||
constructor(name: string, loader: TimeseriesLoader, tolerance?: number) {
|
constructor(options: TimeseriesOptions) {
|
||||||
this.cache = new Int32Array();
|
this.cache = new Int32Array();
|
||||||
this.loader = loader;
|
this.loader = options.loader;
|
||||||
this.name = name;
|
this.name = options.name;
|
||||||
this.tolerance = tolerance ?? 0;
|
this.tolerance = options.tolerance ?? 0;
|
||||||
this.colour = `rgb(${Math.random() * 150},${Math.random() * 150},${Math.random() * 150})`;
|
let newColour: string | null;
|
||||||
|
if (options.colour) {
|
||||||
|
const style = new Option().style;
|
||||||
|
style.color = options.colour;
|
||||||
|
newColour = style.color === options.colour ? options.colour : null;
|
||||||
|
}
|
||||||
|
this.colour = newColour ?? `rgb(${Math.random() * 150},${Math.random() * 150},${Math.random() * 150})`;
|
||||||
|
if (options.valueRangeOverride) {
|
||||||
|
this.valExtremaOverride = { ...options.valueRangeOverride };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getExtrema(): Extrema {
|
getExtrema(): Extrema {
|
||||||
return Object.assign(this.extrema);
|
return Object.assign({}, this.extrema);
|
||||||
|
}
|
||||||
|
|
||||||
|
getExtremaInRange(start: number, stop: number): Extrema {
|
||||||
|
let maxVal = -Infinity;
|
||||||
|
let minVal = Infinity;
|
||||||
|
for (let i = this.findIndexInCache(start) - 1; i < this.findIndexInCache(stop) - 1; i += 2) {
|
||||||
|
if (this.cache[i] < minVal) {
|
||||||
|
minVal = this.cache[i];
|
||||||
|
}
|
||||||
|
if (this.cache[i] > maxVal) {
|
||||||
|
maxVal = this.cache[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
minIndex: this.extrema.minIndex,
|
||||||
|
maxIndex: this.extrema.maxIndex,
|
||||||
|
maxVal: this.valExtremaOverride.high > maxVal ? this.valExtremaOverride.high : maxVal,
|
||||||
|
minVal: this.valExtremaOverride.low < minVal ? this.valExtremaOverride.low : minVal,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getName() {
|
getName() {
|
||||||
@@ -46,13 +86,16 @@ class Timeseries {
|
|||||||
return this.colour;
|
return this.colour;
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedBetween(start: number, stop: number): Int32Array {
|
cachedBetween(start: number, stop: number, blockSize = 1): Int32Array {
|
||||||
if (this.cache.length <= 0) {
|
if (this.cache.length <= 0) {
|
||||||
return new Int32Array();
|
return new Int32Array();
|
||||||
} else {
|
} else {
|
||||||
|
blockSize = Math.round(blockSize) * 2;
|
||||||
|
const cacheStartIndex = this.findIndexInCache(start);
|
||||||
|
const cacheStopIndex = this.findIndexInCache(stop);
|
||||||
return this.cache.slice(
|
return this.cache.slice(
|
||||||
this.findIndexInCache(start) - 1,
|
(cacheStartIndex - (cacheStartIndex) % blockSize),
|
||||||
this.findIndexInCache(stop)
|
(cacheStopIndex + blockSize - (cacheStopIndex) % blockSize),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +183,7 @@ class Timeseries {
|
|||||||
this.currentEndPointer += result.length;
|
this.currentEndPointer += result.length;
|
||||||
this.updateExtremaFrom(result);
|
this.updateExtremaFrom(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Error fetching anterior data: ${e}`);
|
throw new Error(`Error fetching prior data: ${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
308
dashboard/src/chart/Chart.ts
Normal file
308
dashboard/src/chart/Chart.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import Timeseries from "../Timeseries";
|
||||||
|
import Scale from "./Scale";
|
||||||
|
|
||||||
|
export enum ScaleId {
|
||||||
|
Left,
|
||||||
|
Right
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Bounds = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChartEventCallback {
|
||||||
|
scroll: (direction: -1 | 1, magnitude: number, index: number) => void;
|
||||||
|
mousemove: (coords: { x: number, y: number }) => void;
|
||||||
|
drag: (deltaX: number, deltaY: number, deltaIndex: number) => void;
|
||||||
|
}
|
||||||
|
type EventCallbackListing<K extends keyof ChartEventCallback> = Record<K, ChartEventCallback[K][]>;
|
||||||
|
|
||||||
|
const MIN_PIXELS_PER_POINT = 5;
|
||||||
|
|
||||||
|
export default class Chart {
|
||||||
|
private readonly ctx: CanvasRenderingContext2D;
|
||||||
|
private leftScale: Scale;
|
||||||
|
private rightScale: Scale;
|
||||||
|
private readonly lastMousePos = {x: 0, y: 0};
|
||||||
|
private readonly indexRange = {start: 0, stop: 0};
|
||||||
|
private readonly margins = {top: 20, bottom: 20, left: 10, right: 10};
|
||||||
|
private readonly timeseries: Timeseries[] = [];
|
||||||
|
private chartBounds: Bounds;
|
||||||
|
private formatTimestamp = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString();
|
||||||
|
private resolution = 1;
|
||||||
|
private subscriptions: EventCallbackListing<keyof ChartEventCallback>;
|
||||||
|
private dragging = false;
|
||||||
|
private highlightedTimeseries: string | null = null;
|
||||||
|
|
||||||
|
constructor(context: CanvasRenderingContext2D) {
|
||||||
|
this.subscriptions = {scroll: [], mousemove: [], drag: []};
|
||||||
|
this.ctx = context;
|
||||||
|
this.initLayout();
|
||||||
|
this.updateDimensions();
|
||||||
|
this.ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
||||||
|
this.ctx.fill();
|
||||||
|
this.ctx.translate(0.5, 0.5);
|
||||||
|
this.ctx.canvas.onmousemove = (e) => this.handleMouseMove(e);
|
||||||
|
this.ctx.canvas.onmousedown = (e) => this.dragging = true;
|
||||||
|
this.ctx.canvas.onmouseup = (e) => this.dragging = false;
|
||||||
|
this.ctx.canvas.onmouseleave = (e) => this.dragging = false;
|
||||||
|
this.ctx.canvas.onmouseout = (e) => this.dragging = false;
|
||||||
|
this.ctx.canvas.onwheel = (e) => this.handleScroll(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initLayout() {
|
||||||
|
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({
|
||||||
|
top: this.margins.top,
|
||||||
|
left: this.margins.left,
|
||||||
|
height: this.ctx.canvas.height - verticalMargins,
|
||||||
|
width: leftScaleInitialWidth,
|
||||||
|
});
|
||||||
|
this.chartBounds = {
|
||||||
|
top: this.margins.top,
|
||||||
|
left: this.margins.left + leftScaleInitialWidth,
|
||||||
|
height: this.ctx.canvas.height - verticalMargins,
|
||||||
|
width: this.ctx.canvas.width - (horizontalMargins + leftScaleInitialWidth + rightScaleInitialWidth),
|
||||||
|
};
|
||||||
|
this.rightScale = new Scale({
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTimeseries(timeseries: Timeseries, scale?: ScaleId) {
|
||||||
|
this.timeseries.push(timeseries);
|
||||||
|
if (scale === ScaleId.Left) {
|
||||||
|
this.leftScale.addTimeseries(timeseries);
|
||||||
|
} else {
|
||||||
|
this.rightScale.addTimeseries(timeseries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRange(range: {start: number, stop: number}) {
|
||||||
|
this.indexRange.start = range.start;
|
||||||
|
this.indexRange.stop = range.stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseMove(event: MouseEvent) {
|
||||||
|
const {left: canvasX, top: canvasY} = this.ctx.canvas.getBoundingClientRect();
|
||||||
|
const oldX = this.lastMousePos.x;
|
||||||
|
this.lastMousePos.x = event.clientX - canvasX;
|
||||||
|
this.lastMousePos.y = event.clientY - canvasY;
|
||||||
|
this.render();
|
||||||
|
if (this.dragging) {
|
||||||
|
this.emit("drag", event.movementX, event.movementY, this.getIndex(oldX) - this.getIndex(this.lastMousePos.x));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleScroll(e: WheelEvent) {
|
||||||
|
this.emit("scroll", e.deltaY > 0 ? 1 : -1, Math.abs(e.deltaY), this.getIndex(this.lastMousePos.x));
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<T extends keyof ChartEventCallback>(eventName: T, ...callbackArgs: Parameters<ChartEventCallback[T]>) {
|
||||||
|
for (const sub of this.subscriptions[eventName]) {
|
||||||
|
// @ts-ignore
|
||||||
|
sub(...callbackArgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightTimeseries(name: string | null) {
|
||||||
|
if (!name) {
|
||||||
|
this.highlightedTimeseries = null;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const timeseries of this.timeseries) {
|
||||||
|
if (timeseries.getName() === name) {
|
||||||
|
this.highlightedTimeseries = name;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`The timeseries ${name} could not be highlighted because it doesn't exist on the chart!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
on<T extends keyof ChartEventCallback>(eventName: T, callback: ChartEventCallback[T]) {
|
||||||
|
this.subscriptions[eventName].push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.updateDimensions();
|
||||||
|
this.clearCanvas();
|
||||||
|
this.updateResolution();
|
||||||
|
this.renderGuides();
|
||||||
|
this.leftScale.updateIndexRange(this.indexRange);
|
||||||
|
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.leftScale.render(this.ctx);
|
||||||
|
this.rightScale.render(this.ctx);
|
||||||
|
this.renderTooltips();
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const chartWidth = (this.chartBounds.width - this.rightScale.getBounds().width - this.leftScale.getBounds().width);
|
||||||
|
const points = this.timeseries[0]?.cachedBetween(this.indexRange.start, this.indexRange.stop, 1).length / 2 ?? 0;
|
||||||
|
const pixelsPerPoint = chartWidth / points;
|
||||||
|
if (pixelsPerPoint < MIN_PIXELS_PER_POINT) {
|
||||||
|
this.resolution = Math.ceil(MIN_PIXELS_PER_POINT / pixelsPerPoint);
|
||||||
|
} else {
|
||||||
|
this.resolution = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGuides() {
|
||||||
|
this.ctx.strokeStyle = "rgb(230, 230, 230)";
|
||||||
|
this.ctx.lineWidth = 1;
|
||||||
|
for (const tick of this.rightScale.getTicks()) {
|
||||||
|
const pos = this.rightScale.getY(tick);
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(this.chartBounds.left, pos);
|
||||||
|
this.ctx.lineTo(this.chartBounds.left + this.chartBounds.width, pos);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTooltips(radius = 20) {
|
||||||
|
let bestDist = radius;
|
||||||
|
let bestTimeseries = this.timeseries[0];
|
||||||
|
let bestIndex = 0;
|
||||||
|
let bestVal = 0;
|
||||||
|
let bestScale = this.leftScale;
|
||||||
|
for (const scale of [this.leftScale, this.rightScale]) {
|
||||||
|
for (const timeseries of scale.listTimeseries()) {
|
||||||
|
const cache = timeseries.cachedBetween(
|
||||||
|
this.getIndex(this.lastMousePos.x - radius / 2),
|
||||||
|
this.getIndex(this.lastMousePos.x + radius / 2),
|
||||||
|
this.resolution
|
||||||
|
);
|
||||||
|
for (let i = 0; i < cache.length; i += 2) {
|
||||||
|
const y = scale.getY(cache[i]);
|
||||||
|
if (y + radius / 2 >= this.lastMousePos.y && y - radius / 2 <= this.lastMousePos.y) {
|
||||||
|
const x = this.getX(cache[i + 1]);
|
||||||
|
const dist = Math.sqrt((y - this.lastMousePos.y) ** 2 + (x - this.lastMousePos.x) ** 2);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestTimeseries = timeseries;
|
||||||
|
bestIndex = cache[i + 1];
|
||||||
|
bestVal = cache[i];
|
||||||
|
bestScale = scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestDist < 20) {
|
||||||
|
this.renderTooltip(
|
||||||
|
`${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`,
|
||||||
|
this.getX(bestIndex),
|
||||||
|
bestScale.getY(bestVal),
|
||||||
|
bestTimeseries.getColour()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimestampFormatter(formatter: (timestamp: number) => string) {
|
||||||
|
this.formatTimestamp = formatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
getX(index: number) {
|
||||||
|
return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * this.chartBounds.width + this.chartBounds.left;
|
||||||
|
}
|
||||||
|
|
||||||
|
getY(value: number, scale: ScaleId) {
|
||||||
|
return (scale === ScaleId.Left ? this.leftScale : this.rightScale).getY(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getIndex(x: number) {
|
||||||
|
return (x - this.leftScale.getBounds().width) / this.chartBounds.width * (this.indexRange.stop - this.indexRange.start) + this.indexRange.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue(y: number, scale: ScaleId) {
|
||||||
|
return (scale === ScaleId.Left ? this.leftScale : this.rightScale).getValue(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private renderTimeseries(timeseries: Timeseries, scaleId: ScaleId) {
|
||||||
|
const scale = scaleId === ScaleId.Left ? this.leftScale : this.rightScale;
|
||||||
|
const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop, this.resolution);
|
||||||
|
this.ctx.strokeStyle = timeseries.getColour();
|
||||||
|
this.ctx.lineWidth = timeseries.getName() === this.highlightedTimeseries ? 2 : 1;
|
||||||
|
let y = scale.getY(timeseriesPoints[0]);
|
||||||
|
let x = this.getX(timeseriesPoints[1]);
|
||||||
|
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;
|
||||||
|
for (let j = 0; j < this.resolution * 2 && (j + 2 < timeseriesPoints.length); j += 2) {
|
||||||
|
y += timeseriesPoints[i + j];
|
||||||
|
x += timeseriesPoints[i + 1 + j];
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTooltip(text: string, x: number, y: number, markerColour: string) {
|
||||||
|
this.ctx.strokeStyle = "rgb(255,0,0)";
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.ellipse(x, y, 5, 5, 0, 0, 2 * Math.PI);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
const measurements = this.ctx.measureText(text);
|
||||||
|
const textHeight = measurements.actualBoundingBoxAscent + measurements.actualBoundingBoxDescent;
|
||||||
|
const height = textHeight + 10;
|
||||||
|
const width = measurements.width + 10 + 15;
|
||||||
|
y -= height + 2;
|
||||||
|
x += 2;
|
||||||
|
if (x + width > this.ctx.canvas.width) {
|
||||||
|
x -= width + 4;
|
||||||
|
}
|
||||||
|
if (y - height < 0) {
|
||||||
|
y += height + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
this.ctx.strokeStyle = "rgb(0,0,0)";
|
||||||
|
this.ctx.fillRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height));
|
||||||
|
this.ctx.strokeRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height));
|
||||||
|
|
||||||
|
this.ctx.fillStyle = markerColour;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(Math.round(x + 10), Math.round(y + height/2), 5, 0, Math.PI * 2);
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
this.ctx.fillStyle = "rgb(0,0,0)";
|
||||||
|
this.ctx.textAlign = "left";
|
||||||
|
this.ctx.fillText(text, Math.round(x + 20), Math.round(y + textHeight + 5));
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
77
dashboard/src/chart/Scale.ts
Normal file
77
dashboard/src/chart/Scale.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Timeseries from "../Timeseries";
|
||||||
|
import {Bounds} from "./Chart";
|
||||||
|
|
||||||
|
export default class Scale {
|
||||||
|
private readonly timeseries: Timeseries[] = [];
|
||||||
|
private valRange: {high: number, low: number} = {high: -Infinity, low: Infinity};
|
||||||
|
private tickCache: number[] = [];
|
||||||
|
private tickCacheDirty = true;
|
||||||
|
private bounds: Bounds;
|
||||||
|
|
||||||
|
constructor(bounds: Bounds) {
|
||||||
|
this.bounds = bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIndexRange(indexRange: {start: number, stop: number}) {
|
||||||
|
this.valRange.high = -Infinity;
|
||||||
|
this.valRange.low = Infinity;
|
||||||
|
for (const timeseries of this.timeseries) {
|
||||||
|
const extrema = timeseries.getExtremaInRange(indexRange.start, indexRange.stop);
|
||||||
|
if (extrema.maxVal > this.valRange.high) {
|
||||||
|
this.valRange.high = extrema.maxVal;
|
||||||
|
}
|
||||||
|
if (extrema.minVal < this.valRange.low) {
|
||||||
|
this.valRange.low = extrema.minVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.tickCacheDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBounds() {
|
||||||
|
return Object.assign({}, this.bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTimeseries(timeseries: Timeseries) {
|
||||||
|
this.timeseries.push(timeseries);
|
||||||
|
}
|
||||||
|
|
||||||
|
listTimeseries() {
|
||||||
|
return this.timeseries.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx: CanvasRenderingContext2D) {
|
||||||
|
ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
ctx.fillRect(this.bounds.left, this.bounds.top, this.bounds.width, this.bounds.height);
|
||||||
|
ctx.fillStyle = "black";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
for (const tick of this.getTicks()) {
|
||||||
|
const text = tick.toFixed(2);
|
||||||
|
const pos = Math.round(this.getY(tick));
|
||||||
|
ctx.fillText(text, this.bounds.left + this.bounds.width/2, pos + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTicks() {
|
||||||
|
if (this.tickCacheDirty) {
|
||||||
|
const ticks = [];
|
||||||
|
const tickCount = 20;
|
||||||
|
const tickHeight = (this.valRange.high - this.valRange.low) / tickCount;
|
||||||
|
let currentTick = this.valRange.low - tickHeight;
|
||||||
|
for (let i = 0; i <= tickCount; i++) {
|
||||||
|
currentTick += tickHeight;
|
||||||
|
ticks.push(currentTick);
|
||||||
|
}
|
||||||
|
this.tickCache = ticks;
|
||||||
|
this.tickCacheDirty = false;
|
||||||
|
}
|
||||||
|
return this.tickCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
getY(value: number) {
|
||||||
|
return this.bounds.top + this.bounds.height - (value - this.valRange.low) / (this.valRange.high - this.valRange.low) * this.bounds.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue(y: number) {
|
||||||
|
return ((this.bounds.height + this.bounds.top - y) / this.bounds.height) * (this.valRange.high - this.valRange.low) + this.valRange.low;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
dashboard/src/climateTimeseries.ts
Normal file
56
dashboard/src/climateTimeseries.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import Timeseries from "./Timeseries";
|
||||||
|
import {getAppState} from "./StateStore";
|
||||||
|
import {ClayPIDashboardError} from "./errors";
|
||||||
|
|
||||||
|
export const newCo2Timeseries = (tolerance: number) => new Timeseries({
|
||||||
|
name: "CO₂ (ppm)",
|
||||||
|
loader: (start, stop) => loadClimateTimeseriesData("co2", start, stop),
|
||||||
|
tolerance,
|
||||||
|
valueRangeOverride: { high: 800, low: 400 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newTempTimeseries = (tolerance: number) => new Timeseries({
|
||||||
|
name: "Temperature (°C)",
|
||||||
|
loader: (start, stop) => loadClimateTimeseriesData("temp", start, stop),
|
||||||
|
tolerance,
|
||||||
|
valueRangeOverride: { high: 30, low: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const newHumidityTimeseries = (tolerance: number) => new Timeseries({
|
||||||
|
name: "Humidity (%)",
|
||||||
|
loader: (start, stop) => loadClimateTimeseriesData("humidity", start, stop),
|
||||||
|
tolerance,
|
||||||
|
valueRangeOverride: { high: 75, low: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadClimateTimeseriesData(dataType: "temp" | "humidity" | "co2", start?: number, stop?: number) {
|
||||||
|
const endpoint = `${getAppState().dataEndpointBase}/timeseries/${dataType}${start && `?from=${start * 1000}`}${stop && `&to=${stop * 1000}`}`;
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, { headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
}});
|
||||||
|
const reader = await response.body.getReader();
|
||||||
|
let receivedLength = 0;
|
||||||
|
const chunks = [];
|
||||||
|
let finishedReading = false;
|
||||||
|
while (!finishedReading) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
finishedReading = chunk.done;
|
||||||
|
if (!finishedReading) {
|
||||||
|
chunks.push(chunk.value.buffer);
|
||||||
|
receivedLength += chunk.value.buffer.byteLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = new Uint8Array(receivedLength);
|
||||||
|
let position = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const chunkArray = new Uint8Array(chunk);
|
||||||
|
data.set(chunkArray, position);
|
||||||
|
position += chunkArray.length;
|
||||||
|
}
|
||||||
|
return new Int32Array(data.buffer);
|
||||||
|
} catch (e) {
|
||||||
|
const message = "timerseries data couldn't be loaded from the server";
|
||||||
|
throw new ClayPIDashboardError(`${message}: ${e}`, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"development": true,
|
"development": false,
|
||||||
"defaultMinuteSpan": 60,
|
"defaultMinuteSpan": 60,
|
||||||
"reloadIntervalSec": 30,
|
"reloadIntervalSec": 30,
|
||||||
"dataEndpoint": "http://tortedda.local/climate/api"
|
"dataEndpoint": "/climate/api"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +1,42 @@
|
|||||||
import config from "./config.json";
|
import config from "./config.json";
|
||||||
import {AppStore, getAppState, initStore} from "./StateStore";
|
import {AppStore, getAppState, initStore} from "./StateStore";
|
||||||
import AppUI from "./ui-components/AppUI";
|
import AppUI from "./ui-components/AppUI";
|
||||||
import Timeseries from "./Timeseries";
|
import {
|
||||||
import {ClayPIDashboardError} from "./errors";
|
newCo2Timeseries,
|
||||||
import {ScaleId} from "./ClimateChart";
|
newHumidityTimeseries,
|
||||||
|
newTempTimeseries,
|
||||||
|
} from "./climateTimeseries";
|
||||||
|
import {ScaleId} from "./chart/Chart";
|
||||||
|
|
||||||
export {config};
|
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 -(new Date().getTimezoneOffset() / 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const now = new Date().getTime() / 1000;
|
await initStore(new URLSearchParams(window.location.search));
|
||||||
await initStore({
|
AppStore().addTimeseriesToScale(newCo2Timeseries(getAppState().updateIntervalSeconds), ScaleId.Right);
|
||||||
overlayText: "",
|
AppStore().addTimeseriesToScale(newTempTimeseries(getAppState().updateIntervalSeconds), ScaleId.Left);
|
||||||
lastUpdateTime: now,
|
AppStore().addTimeseriesToScale(newHumidityTimeseries(getAppState().updateIntervalSeconds), ScaleId.Left);
|
||||||
minutesDisplayed: getDisplayedMinutes(),
|
|
||||||
utcOffset: getUtcOffset(),
|
|
||||||
dataEndpointBase: config.dataEndpoint,
|
|
||||||
isLoading: false,
|
|
||||||
updateIntervalSeconds: config.reloadIntervalSec,
|
|
||||||
displayMode: "pastMins",
|
|
||||||
fatalError: null,
|
|
||||||
displayWindow: {start: now - getDisplayedMinutes() * 60, stop: now},
|
|
||||||
documentReady: false,
|
|
||||||
leftTimeseries: [],
|
|
||||||
rightTimeseries: [],
|
|
||||||
});
|
|
||||||
AppStore().addTimeseries(
|
|
||||||
new Timeseries(
|
|
||||||
"temp",
|
|
||||||
(start, stop) => loadClimateTimeseriesData("temp", start, stop),
|
|
||||||
getAppState().updateIntervalSeconds
|
|
||||||
),
|
|
||||||
ScaleId.Left);
|
|
||||||
AppStore().addTimeseries(
|
|
||||||
new Timeseries(
|
|
||||||
"humidity",
|
|
||||||
(start, stop) => loadClimateTimeseriesData("humidity", start, stop),
|
|
||||||
getAppState().updateIntervalSeconds
|
|
||||||
),
|
|
||||||
ScaleId.Left
|
|
||||||
);
|
|
||||||
AppStore().addTimeseries(
|
|
||||||
new Timeseries(
|
|
||||||
"co2",
|
|
||||||
(start, stop) => loadClimateTimeseriesData("co2", start, stop),
|
|
||||||
getAppState().updateIntervalSeconds
|
|
||||||
),
|
|
||||||
ScaleId.Right
|
|
||||||
);
|
|
||||||
const ui = new AppUI();
|
const ui = new AppUI();
|
||||||
ui.bootstrap("root");
|
ui.bootstrap("root");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadClimateTimeseriesData(dataType: "temp" | "humidity" | "co2", start?: number, stop?: number) {
|
function updateUrlState() {
|
||||||
const endpoint = `${getAppState().dataEndpointBase}/timeseries/${dataType}${start && `?from=${start * 1000}`}${stop && `&to=${stop * 1000}`}`;
|
const appStateSerial = AppStore().serialiseState();
|
||||||
try {
|
const newUrl = `${window.location.pathname}${appStateSerial !== "" ? `?${appStateSerial}` : ""}`;
|
||||||
const response = await fetch(endpoint, { headers: {
|
window.history.replaceState("", "", newUrl);
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
}});
|
|
||||||
const reader = await response.body.getReader();
|
|
||||||
let receivedLength = 0;
|
|
||||||
const chunks = [];
|
|
||||||
let finishedReading = false;
|
|
||||||
while (!finishedReading) {
|
|
||||||
const chunk = await reader.read();
|
|
||||||
finishedReading = chunk.done;
|
|
||||||
if (!finishedReading) {
|
|
||||||
const chunkBuffer = new Int32Array(chunk.value.buffer);
|
|
||||||
chunks.push(chunkBuffer);
|
|
||||||
receivedLength += chunkBuffer.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const data = new Int32Array(receivedLength);
|
|
||||||
let position = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
data.set(chunk, position);
|
|
||||||
position += chunk.length;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
} catch (e) {
|
|
||||||
const message = "Error fetching timerseries data from the server";
|
|
||||||
throw new ClayPIDashboardError(`${message}: ${e}`, message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
function debounce<F extends () => void>(func: F, timeout = 300){
|
||||||
|
return (...args: Parameters<F>[]) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => { func.apply(this, args); }, timeout);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
document.onreadystatechange = async () => {
|
document.onreadystatechange = async () => {
|
||||||
await init();
|
await init();
|
||||||
AppStore().setDocumentReady(true);
|
AppStore().setDocumentReady(true);
|
||||||
|
AppStore().on("stateChange", () => debounce(() => updateUrlState())());
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.store = AppStore();
|
window.store = AppStore();
|
||||||
document.onreadystatechange = null;
|
document.onreadystatechange = null;
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {GridSize} from "./GridWidget";
|
|||||||
import MessageOverlay from "./MessageOverlay";
|
import MessageOverlay from "./MessageOverlay";
|
||||||
import UIComponent from "./UIComponent";
|
import UIComponent from "./UIComponent";
|
||||||
import SelectDisplayModeWidget from "./SelectDisplayModeWidget";
|
import SelectDisplayModeWidget from "./SelectDisplayModeWidget";
|
||||||
|
import LegendWidget from "./LegendWidget";
|
||||||
|
|
||||||
class AppUI extends UIComponent {
|
class AppUI extends UIComponent {
|
||||||
private timezoneWidget: TimezoneWidget;
|
private timezoneWidget: TimezoneWidget;
|
||||||
private selectModeWidget: SelectDisplayModeWidget;
|
private selectModeWidget: SelectDisplayModeWidget;
|
||||||
private displayModeSettingsWidget: DisplayModeWidget;
|
private displayModeSettingsWidget: DisplayModeWidget;
|
||||||
private timerWidget: TimerWidget;
|
private timerWidget: TimerWidget;
|
||||||
|
private legendWidget: LegendWidget;
|
||||||
private chartWidget: ClimateChartWidget;
|
private chartWidget: ClimateChartWidget;
|
||||||
private element: HTMLDivElement = document.createElement("div");
|
private element: HTMLDivElement = document.createElement("div");
|
||||||
private grid: HTMLDivElement = document.createElement("div");
|
private grid: HTMLDivElement = document.createElement("div");
|
||||||
@@ -31,6 +33,7 @@ class AppUI extends UIComponent {
|
|||||||
private setupGrid(size: GridSize) {
|
private setupGrid(size: GridSize) {
|
||||||
this.setupWidgets();
|
this.setupWidgets();
|
||||||
this.grid.append(
|
this.grid.append(
|
||||||
|
this.legendWidget.current(),
|
||||||
this.chartWidget.current(),
|
this.chartWidget.current(),
|
||||||
this.displayModeSettingsWidget.current(),
|
this.displayModeSettingsWidget.current(),
|
||||||
this.selectModeWidget.current(),
|
this.selectModeWidget.current(),
|
||||||
@@ -50,10 +53,13 @@ class AppUI extends UIComponent {
|
|||||||
row: "auto", col: 5, width: 1, height: 2,
|
row: "auto", col: 5, width: 1, height: 2,
|
||||||
});
|
});
|
||||||
this.timezoneWidget = new TimezoneWidget({
|
this.timezoneWidget = new TimezoneWidget({
|
||||||
row: "auto", col: 5, width: 1, height: 2,
|
row: "auto", col: 5, width: 1, height: 1,
|
||||||
});
|
});
|
||||||
this.timerWidget = new TimerWidget({
|
this.timerWidget = new TimerWidget({
|
||||||
row: "auto", col: 5, width: 1, height: 3,
|
row: "auto", col: 5, width: 1, height: 2,
|
||||||
|
});
|
||||||
|
this.legendWidget = new LegendWidget({
|
||||||
|
row: "auto", col: 5, width: 1, height: 2,
|
||||||
});
|
});
|
||||||
this.chartWidget = new ClimateChartWidget({
|
this.chartWidget = new ClimateChartWidget({
|
||||||
row: 1, col: 1, width: 4, height: 10,
|
row: 1, col: 1, width: 4, height: 10,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {AppStore, DisplayMode, getAppState} from "../StateStore";
|
import {AppStore, DisplayMode, getAppState} from "../StateStore";
|
||||||
import GridWidget, {GridProps} from "./GridWidget";
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
import UIComponent from "./UIComponent";
|
import UIComponent from "./UIComponent";
|
||||||
import ClimateChart, {ScaleId} from "../ClimateChart";
|
import Chart, {ScaleId} from "../chart/Chart";
|
||||||
|
|
||||||
class ClimateChartWidget extends UIComponent {
|
class ClimateChartWidget extends UIComponent {
|
||||||
private readonly skeleton: GridWidget;
|
private readonly skeleton: GridWidget;
|
||||||
private chart: ClimateChart | null = null;
|
private chart: Chart | null = null;
|
||||||
private initialised: boolean;
|
private initialised: boolean;
|
||||||
private displayMode: DisplayMode = "pastMins";
|
private displayMode: DisplayMode = "pastMins";
|
||||||
private latestSnapshotInChartTime: number;
|
private latestSnapshotInChartTime: number;
|
||||||
@@ -22,6 +22,7 @@ class ClimateChartWidget extends UIComponent {
|
|||||||
const now = new Date().getTime() / 1000;
|
const now = new Date().getTime() / 1000;
|
||||||
this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60;
|
this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60;
|
||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
|
this.updateDisplayMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDimensions() {
|
updateDimensions() {
|
||||||
@@ -42,6 +43,36 @@ class ClimateChartWidget extends UIComponent {
|
|||||||
AppStore().on("newTimeseries", (timeseries) => this.chart.addTimeseries(timeseries));
|
AppStore().on("newTimeseries", (timeseries) => this.chart.addTimeseries(timeseries));
|
||||||
AppStore().subscribeStoreVal("documentReady", () => this.initChart());
|
AppStore().subscribeStoreVal("documentReady", () => this.initChart());
|
||||||
AppStore().subscribeStoreVal("utcOffset", () => this.updateTimezone());
|
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};
|
||||||
|
}
|
||||||
|
const beforeIndex = index - displayedWindow.start;
|
||||||
|
const afterIndex = displayedWindow.stop - index;
|
||||||
|
const factor = direction === 1 ? 1.1 : 0.9;
|
||||||
|
const newBeforeIndex = factor * beforeIndex;
|
||||||
|
const newAfterIndex = factor * afterIndex;
|
||||||
|
AppStore().setDisplayWindow({
|
||||||
|
start: index - newBeforeIndex,
|
||||||
|
stop: index + newAfterIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDrag(deltaX: number, deltaY: number, deltaIndex: number) {
|
||||||
|
if (getAppState().displayMode === "pastMins") {
|
||||||
|
AppStore().setDisplayMode("window");
|
||||||
|
}
|
||||||
|
const displayWindow = getAppState().displayWindow;
|
||||||
|
AppStore().setDisplayWindow({
|
||||||
|
start: displayWindow.start + deltaIndex,
|
||||||
|
stop: displayWindow.stop + deltaIndex,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateTimezone() {
|
private updateTimezone() {
|
||||||
@@ -53,9 +84,11 @@ class ClimateChartWidget extends UIComponent {
|
|||||||
try {
|
try {
|
||||||
AppStore().addLoad();
|
AppStore().addLoad();
|
||||||
const ctx = this.canvasElement.getContext("2d", {alpha: false});
|
const ctx = this.canvasElement.getContext("2d", {alpha: false});
|
||||||
this.chart = new ClimateChart(ctx);
|
this.chart = new Chart(ctx);
|
||||||
getAppState().leftTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Left));
|
getAppState().leftTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Left));
|
||||||
getAppState().rightTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Right));
|
getAppState().rightTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Right));
|
||||||
|
this.chart.on("scroll", (...args) => this.handleScroll(...args));
|
||||||
|
this.chart.on("drag", (...args) => this.handleDrag(...args));
|
||||||
await this.rerender();
|
await this.rerender();
|
||||||
this.initialised = true;
|
this.initialised = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ class DisplayModeWidget extends UIComponent {
|
|||||||
private skeleton: GridWidget;
|
private skeleton: GridWidget;
|
||||||
private minsCounterRef: number;
|
private minsCounterRef: number;
|
||||||
private windowStartTimeRef: number;
|
private windowStartTimeRef: number;
|
||||||
|
private windowStartTimeInputRef: number;
|
||||||
private windowStopTimeRef: number;
|
private windowStopTimeRef: number;
|
||||||
|
private windowStopTimeInputRef: number;
|
||||||
private windowedDisplayRef: number;
|
private windowedDisplayRef: number;
|
||||||
private minsDisplayRef: number;
|
private minsDisplayRef: number;
|
||||||
private mainDisplay: HTMLElement;
|
private mainDisplay: HTMLElement;
|
||||||
@@ -24,20 +26,35 @@ class DisplayModeWidget extends UIComponent {
|
|||||||
AppStore().subscribeStoreVal("minutesDisplayed", () => this.updateDisplay());
|
AppStore().subscribeStoreVal("minutesDisplayed", () => this.updateDisplay());
|
||||||
AppStore().subscribeStoreVal("displayMode", () => this.updateDisplay());
|
AppStore().subscribeStoreVal("displayMode", () => this.updateDisplay());
|
||||||
AppStore().subscribeStoreVal("displayWindow", () => this.updateDisplay());
|
AppStore().subscribeStoreVal("displayWindow", () => this.updateDisplay());
|
||||||
|
AppStore().subscribeStoreVal("utcOffset", () => this.updateDisplay());
|
||||||
|
this.updateDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
private WindowStartTime({ ctx }: {ctx: DisplayModeWidget}) {
|
private WindowStartTime({ ctx }: {ctx: DisplayModeWidget}) {
|
||||||
|
ctx.windowStartTimeInputRef = ctx.makeRef(<input
|
||||||
|
type={"datetime-local"}
|
||||||
|
onblur={() => ctx.onWindowStartInputBlur()}
|
||||||
|
/>);
|
||||||
ctx.windowStartTimeRef = ctx.makeRef(<div
|
ctx.windowStartTimeRef = ctx.makeRef(<div
|
||||||
className={"display-mode-widget-date"}>
|
className={"display-mode-widget-date"}
|
||||||
{new Date(getAppState().displayWindow.start).toLocaleString()}
|
onwheel={(e: WheelEvent) => ctx.onStartTimeInputScroll(e)}
|
||||||
|
onclick={() => ctx.onWindowStartDisplayClick()}>
|
||||||
|
{new Date(getAppState().displayWindow.start + getAppState().utcOffset * 60 * 60 * 1000).toLocaleString()}
|
||||||
</div>);
|
</div>);
|
||||||
return ctx.fromRef(ctx.windowStartTimeRef);
|
return ctx.fromRef(ctx.windowStartTimeRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
private WindowStopTime({ctx}: {ctx: DisplayModeWidget}) {
|
private WindowStopTime({ctx}: {ctx: DisplayModeWidget}) {
|
||||||
|
ctx.windowStopTimeInputRef = ctx.makeRef(<input
|
||||||
|
value={new Date()}
|
||||||
|
type={"datetime-local"}
|
||||||
|
onblur={() => ctx.onWindowStopInputBlur()}
|
||||||
|
/>);
|
||||||
ctx.windowStopTimeRef = ctx.makeRef(<div
|
ctx.windowStopTimeRef = ctx.makeRef(<div
|
||||||
className={"display-mode-widget-date"}>
|
className={"display-mode-widget-date"}
|
||||||
{new Date(getAppState().displayWindow.stop).toLocaleString()}
|
onwheel={(e: WheelEvent) => ctx.onStopTimeInputScroll(e)}
|
||||||
|
onclick={() => ctx.onWindowStopDisplayClick()}>
|
||||||
|
{new Date(getAppState().displayWindow.stop + getAppState().utcOffset * 60 * 60 * 1000).toLocaleString()}
|
||||||
</div>);
|
</div>);
|
||||||
return ctx.fromRef(ctx.windowStopTimeRef);
|
return ctx.fromRef(ctx.windowStopTimeRef);
|
||||||
}
|
}
|
||||||
@@ -48,12 +65,26 @@ class DisplayModeWidget extends UIComponent {
|
|||||||
value={getAppState().minutesDisplayed.toString()}
|
value={getAppState().minutesDisplayed.toString()}
|
||||||
onblur={(e: FocusEvent) => ctx.onMinutesCounterInputBlur(e)}/>);
|
onblur={(e: FocusEvent) => ctx.onMinutesCounterInputBlur(e)}/>);
|
||||||
ctx.minsCounterRef = ctx.makeRef(
|
ctx.minsCounterRef = ctx.makeRef(
|
||||||
<div className={"min-count"} onclick={onclick}>
|
<div className={"min-count"} onclick={onclick} onwheel={(e: WheelEvent) => ctx.onMinutesCounterInputScroll(e)}>
|
||||||
{getAppState().minutesDisplayed.toString()}
|
{getAppState().minutesDisplayed.toString()}
|
||||||
</div>);
|
</div>);
|
||||||
return ctx.fromRef(ctx.minsCounterRef);
|
return ctx.fromRef(ctx.minsCounterRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onMinutesCounterInputScroll(e: WheelEvent) {
|
||||||
|
AppStore().setMinutesDisplayed(getAppState().minutesDisplayed + e.deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStopTimeInputScroll(e: WheelEvent) {
|
||||||
|
const oldWin = getAppState().displayWindow;
|
||||||
|
AppStore().setDisplayWindow({start: oldWin.start, stop: oldWin.stop - e.deltaY * 60});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStartTimeInputScroll(e: WheelEvent) {
|
||||||
|
const oldWin = getAppState().displayWindow;
|
||||||
|
AppStore().setDisplayWindow({start: oldWin.start - e.deltaY * 60, stop: oldWin.stop});
|
||||||
|
}
|
||||||
|
|
||||||
private onMinutesCounterInputBlur(e: FocusEvent) {
|
private onMinutesCounterInputBlur(e: FocusEvent) {
|
||||||
const input = Number((e.target as HTMLInputElement).value);
|
const input = Number((e.target as HTMLInputElement).value);
|
||||||
if (!isNaN(input)) {
|
if (!isNaN(input)) {
|
||||||
@@ -90,6 +121,51 @@ class DisplayModeWidget extends UIComponent {
|
|||||||
input.selectionEnd = input.value.length;
|
input.selectionEnd = input.value.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onWindowStopDisplayClick() {
|
||||||
|
const stopTimeDisplay = this.fromRef(this.windowStopTimeRef);
|
||||||
|
(stopTimeDisplay as HTMLInputElement).valueAsDate = new Date(getAppState().displayWindow.stop);
|
||||||
|
const stopTimeInputDisplay = this.fromRef(this.windowStopTimeInputRef) as HTMLInputElement;
|
||||||
|
stopTimeDisplay.replaceWith(stopTimeInputDisplay);
|
||||||
|
const date = new Date(getAppState().displayWindow.stop * 1000 + getAppState().utcOffset * 60 * 60 * 1000);
|
||||||
|
stopTimeInputDisplay.value = `${date.toLocaleDateString()}, ${date.toLocaleTimeString()}`;
|
||||||
|
stopTimeInputDisplay.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWindowStopInputBlur() {
|
||||||
|
const stopTimeInput = this.fromRef(this.windowStopTimeInputRef);
|
||||||
|
const val = new Date((stopTimeInput as HTMLInputElement).value).getTime() / 1000;
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
AppStore().setDisplayWindow({
|
||||||
|
start: getAppState().displayWindow.start,
|
||||||
|
stop: val
|
||||||
|
});
|
||||||
|
}
|
||||||
|
stopTimeInput.replaceWith(this.fromRef(this.windowStopTimeRef));
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWindowStartDisplayClick() {
|
||||||
|
const startTimeDisplay = this.fromRef(this.windowStartTimeRef);
|
||||||
|
(startTimeDisplay as HTMLInputElement).valueAsDate = new Date(getAppState().displayWindow.start);
|
||||||
|
const startTimeInputDisplay = this.fromRef(this.windowStartTimeInputRef) as HTMLInputElement;
|
||||||
|
startTimeDisplay.replaceWith(startTimeInputDisplay);
|
||||||
|
const date = new Date(getAppState().displayWindow.start * 1000 + getAppState().utcOffset * 60 * 60 * 1000);
|
||||||
|
startTimeInputDisplay.value = `${date.toLocaleDateString()}, ${date.toLocaleTimeString()}`;
|
||||||
|
startTimeInputDisplay.focus();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWindowStartInputBlur() {
|
||||||
|
const startTimeInput = this.fromRef(this.windowStartTimeInputRef);
|
||||||
|
const val = new Date((startTimeInput as HTMLInputElement).value).getTime() / 1000;
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
AppStore().setDisplayWindow({
|
||||||
|
start: val,
|
||||||
|
stop: getAppState().displayWindow.stop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
startTimeInput.replaceWith(this.fromRef(this.windowStartTimeRef));
|
||||||
|
}
|
||||||
|
|
||||||
private MinusButton(props: {onclick: () => any}) {
|
private MinusButton(props: {onclick: () => any}) {
|
||||||
return <div
|
return <div
|
||||||
className={"minus-button"}
|
className={"minus-button"}
|
||||||
@@ -147,8 +223,11 @@ class DisplayModeWidget extends UIComponent {
|
|||||||
private updateDisplay() {
|
private updateDisplay() {
|
||||||
if (getAppState().displayMode === "window") {
|
if (getAppState().displayMode === "window") {
|
||||||
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));
|
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));
|
||||||
this.fromRef(this.windowStartTimeRef).innerText = new Date(getAppState().displayWindow.start * 1000).toLocaleString();
|
const offset = getAppState().utcOffset * 60 * 60;
|
||||||
this.fromRef(this.windowStopTimeRef).innerText = new Date(getAppState().displayWindow.stop * 1000).toLocaleString();
|
const startDate = new Date((getAppState().displayWindow.start + offset) * 1000);
|
||||||
|
const stopDate = new Date((getAppState().displayWindow.stop + offset) * 1000);
|
||||||
|
this.fromRef(this.windowStartTimeRef).innerText = startDate.toLocaleString();
|
||||||
|
this.fromRef(this.windowStopTimeRef).innerText = stopDate.toLocaleString();
|
||||||
} else {
|
} else {
|
||||||
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef));
|
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef));
|
||||||
this.fromRef(this.minsCounterRef).innerText = getAppState().minutesDisplayed.toString();
|
this.fromRef(this.minsCounterRef).innerText = getAppState().minutesDisplayed.toString();
|
||||||
|
|||||||
65
dashboard/src/ui-components/LegendWidget.tsx
Normal file
65
dashboard/src/ui-components/LegendWidget.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import {AppStore, getAppState} from "../StateStore";
|
||||||
|
import UIComponent from "./UIComponent";
|
||||||
|
import * as JSX from "../JSXFactory";
|
||||||
|
import Timeseries from "../Timeseries";
|
||||||
|
|
||||||
|
class LegendWidget extends UIComponent {
|
||||||
|
private skeleton: GridWidget;
|
||||||
|
private display: HTMLSpanElement = document.createElement("span");
|
||||||
|
private bodyRef: number;
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
super();
|
||||||
|
this.display = <this.MainBody ctx={this}/>;
|
||||||
|
this.skeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
title: "Legend:",
|
||||||
|
className: "legend-widget",
|
||||||
|
body: this.display,
|
||||||
|
});
|
||||||
|
AppStore().subscribeStoreVal("highlightedTimeseries", () => this.updateDisplay());
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDisplay() {
|
||||||
|
this.fromRef(this.bodyRef).replaceWith(<this.MainBody ctx={this}/>);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MainBody({ctx}: {ctx: LegendWidget}) {
|
||||||
|
ctx.bodyRef = ctx.makeRef(<div><ctx.TimeseriesList ctx={ctx}/></div>);
|
||||||
|
return ctx.fromRef(ctx.bodyRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeseriesList({ctx}: { ctx: LegendWidget }) {
|
||||||
|
const highlightedTimeseries = getAppState().highlightedTimeseries;
|
||||||
|
return <ul>
|
||||||
|
{ ...getAppState().rightTimeseries.map(timeseries =>
|
||||||
|
<ctx.TimeseriesLegendEntry
|
||||||
|
timeseries={timeseries}
|
||||||
|
highlighted={timeseries.getName() === highlightedTimeseries}/>) }
|
||||||
|
{ ...getAppState().leftTimeseries.map(timeseries =>
|
||||||
|
<ctx.TimeseriesLegendEntry
|
||||||
|
timeseries={timeseries}
|
||||||
|
highlighted={timeseries.getName() === highlightedTimeseries}/>) }
|
||||||
|
</ul>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeseriesLegendEntry({timeseries, highlighted}: {timeseries: Timeseries, highlighted: boolean}) {
|
||||||
|
const option = new Option();
|
||||||
|
option.style.color = timeseries.getColour();
|
||||||
|
return <li
|
||||||
|
style={`color: ${option.style.color}`}
|
||||||
|
className={highlighted ? "highlighted" : ""}
|
||||||
|
onmouseover={() => AppStore().setHighlightedTimeseries(timeseries.getName())}
|
||||||
|
onmouseout={() => AppStore().setHighlightedTimeseries(null)}>
|
||||||
|
{timeseries.getName()}
|
||||||
|
</li>;
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.skeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LegendWidget;
|
||||||
@@ -8,6 +8,8 @@ export default class SelectDisplayModeWidget extends UIComponent {
|
|||||||
private gridWidgetSkeleton: GridWidget;
|
private gridWidgetSkeleton: GridWidget;
|
||||||
private windowInputRef: number;
|
private windowInputRef: number;
|
||||||
private minSpanInputRef: number;
|
private minSpanInputRef: number;
|
||||||
|
private windowInputContainerRef: number;
|
||||||
|
private minSpanInputContainerRef: number;
|
||||||
constructor(gridProps: GridProps) {
|
constructor(gridProps: GridProps) {
|
||||||
super();
|
super();
|
||||||
this.mainBody = this.MainBody({ctx: this});
|
this.mainBody = this.MainBody({ctx: this});
|
||||||
@@ -27,6 +29,13 @@ export default class SelectDisplayModeWidget extends UIComponent {
|
|||||||
const windowedMode = getAppState().displayMode === "window";
|
const windowedMode = getAppState().displayMode === "window";
|
||||||
(this.fromRef(this.windowInputRef) as HTMLInputElement).checked = windowedMode;
|
(this.fromRef(this.windowInputRef) as HTMLInputElement).checked = windowedMode;
|
||||||
(this.fromRef(this.minSpanInputRef) as HTMLInputElement).checked = !windowedMode;
|
(this.fromRef(this.minSpanInputRef) as HTMLInputElement).checked = !windowedMode;
|
||||||
|
if (!windowedMode) {
|
||||||
|
this.fromRef(this.minSpanInputContainerRef).classList.add("selected");
|
||||||
|
this.fromRef(this.windowInputContainerRef).classList.remove("selected");
|
||||||
|
} else {
|
||||||
|
this.fromRef(this.minSpanInputContainerRef).classList.remove("selected");
|
||||||
|
this.fromRef(this.windowInputContainerRef).classList.add("selected");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private MainBody({ ctx }: { ctx: SelectDisplayModeWidget }) {
|
private MainBody({ ctx }: { ctx: SelectDisplayModeWidget }) {
|
||||||
@@ -35,23 +44,27 @@ export default class SelectDisplayModeWidget extends UIComponent {
|
|||||||
type={"radio"}
|
type={"radio"}
|
||||||
id={"window"}
|
id={"window"}
|
||||||
name={"display-mode"}
|
name={"display-mode"}
|
||||||
checked={isInWindowMode}
|
checked={isInWindowMode}/>);
|
||||||
onclick={() => ctx.selectMode("window")}/>);
|
|
||||||
ctx.minSpanInputRef = this.makeRef(<input
|
ctx.minSpanInputRef = this.makeRef(<input
|
||||||
type={"radio"}
|
type={"radio"}
|
||||||
id={"min-span"}
|
id={"min-span"}
|
||||||
name={"display-mode"}
|
name={"display-mode"}
|
||||||
checked={!isInWindowMode}
|
checked={!isInWindowMode}/>);
|
||||||
onclick={() => ctx.selectMode("pastMins")}/>);
|
ctx.windowInputContainerRef = this.makeRef(<div
|
||||||
return (<div>
|
className={`display-mode-option${isInWindowMode ? " selected" : ""}`}
|
||||||
<div>
|
onclick={() => ctx.selectMode("window")}>
|
||||||
{this.fromRef(ctx.windowInputRef)}
|
{this.fromRef(ctx.windowInputRef)}
|
||||||
<label htmlFor={"window"}>Time Window</label>
|
<label htmlFor={"window"}>Time Window</label>
|
||||||
</div>
|
</div>);
|
||||||
<div>
|
ctx.minSpanInputContainerRef = this.makeRef(<div
|
||||||
|
className={`display-mode-option${!isInWindowMode ? " selected" : ""}`}
|
||||||
|
onclick={() => ctx.selectMode("pastMins")}>
|
||||||
{this.fromRef(ctx.minSpanInputRef)}
|
{this.fromRef(ctx.minSpanInputRef)}
|
||||||
<label htmlFor={"minSpan"}>Rolling Minute Span</label>
|
<label htmlFor={"minSpan"}>Rolling Minute Span</label>
|
||||||
</div>
|
</div>);
|
||||||
|
return (<div>
|
||||||
|
{this.fromRef(ctx.windowInputContainerRef)}
|
||||||
|
{this.fromRef(ctx.minSpanInputContainerRef)}
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,21 @@ class TimerWidget extends UIComponent {
|
|||||||
body: this.display,
|
body: this.display,
|
||||||
});
|
});
|
||||||
AppStore().subscribeStoreVal("lastUpdateTime", () => this.resetTimer());
|
AppStore().subscribeStoreVal("lastUpdateTime", () => this.resetTimer());
|
||||||
|
AppStore().subscribeStoreVal("utcOffset", () => this.resetTimer());
|
||||||
setInterval(() => this.refreshTimer(), 10);
|
setInterval(() => this.refreshTimer(), 10);
|
||||||
this.resetTimer();
|
this.resetTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetTimer() {
|
private resetTimer() {
|
||||||
this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds;
|
this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds;
|
||||||
this.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime * 1000).toLocaleString();
|
this.updateUpdateText();
|
||||||
this.refreshTimer();
|
this.refreshTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateUpdateText() {
|
||||||
|
this.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime * 1000 + getAppState().utcOffset * 60 * 60 * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
private MainDisplay({ ctx }: { ctx: TimerWidget }) {
|
private MainDisplay({ ctx }: { ctx: TimerWidget }) {
|
||||||
ctx.timerRef = ctx.makeRef(<div className={"countdown"}/>);
|
ctx.timerRef = ctx.makeRef(<div className={"countdown"}/>);
|
||||||
ctx.lastUpdateRef = ctx.makeRef(
|
ctx.lastUpdateRef = ctx.makeRef(
|
||||||
|
|||||||
Reference in New Issue
Block a user