Added left and right scales
This commit is contained in:
@@ -100,6 +100,10 @@ h1 {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
margin: 10px 0 10px 0;
|
margin: 10px 0 10px 0;
|
||||||
|
transition: background-color 100ms;
|
||||||
|
}
|
||||||
|
.display-mode-widget-mins .min-count:hover {
|
||||||
|
background-color: #eaeaea;
|
||||||
}
|
}
|
||||||
.display-mode-widget-mins input {
|
.display-mode-widget-mins input {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -128,6 +132,12 @@ h1 {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timezone-widget span:nth-child(2) {
|
||||||
|
transition: background-color 100ms;
|
||||||
|
}
|
||||||
|
.timezone-widget span:nth-child(2):hover {
|
||||||
|
background-color: #eaeaea;
|
||||||
|
}
|
||||||
.timezone-widget input {
|
.timezone-widget input {
|
||||||
border: none;
|
border: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -136,6 +146,3 @@ h1 {
|
|||||||
margin: 10px 0 10px 0;
|
margin: 10px 0 10px 0;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-canvas {
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
import Timeseries from "./Timeseries";
|
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 {
|
export default class ClimateChart {
|
||||||
private readonly ctx: CanvasRenderingContext2D;
|
private readonly ctx: CanvasRenderingContext2D;
|
||||||
private readonly timeseries: Timeseries[] = [];
|
private readonly leftScale: Scale;
|
||||||
|
private readonly rightScale: Scale;
|
||||||
private readonly lastMousePos = {x: 0, y: 0};
|
private readonly lastMousePos = {x: 0, y: 0};
|
||||||
private readonly indexRange = {start: 0, stop: 0};
|
private readonly indexRange = {start: 0, stop: 0};
|
||||||
private readonly valRange = {high: -Infinity, low: Infinity}
|
private readonly margins = {top: 20, bottom: 20};
|
||||||
private formatTimestamp = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString();
|
private formatTimestamp = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString();
|
||||||
private width = 0;
|
private width = 0;
|
||||||
private height = 0;
|
private height = 0;
|
||||||
|
private resolution = 1;
|
||||||
constructor(context: CanvasRenderingContext2D) {
|
constructor(context: CanvasRenderingContext2D) {
|
||||||
this.ctx = context;
|
this.ctx = context;
|
||||||
this.ctx.fillStyle = "rgb(255,255,255)";
|
this.ctx.fillStyle = "rgb(255,255,255)";
|
||||||
@@ -17,6 +32,16 @@ export default class ClimateChart {
|
|||||||
this.ctx.fill();
|
this.ctx.fill();
|
||||||
this.ctx.translate(0.5, 0.5);
|
this.ctx.translate(0.5, 0.5);
|
||||||
this.ctx.canvas.onmousemove = (e) => this.handleMouseMove(e);
|
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() {
|
private updateDimensions() {
|
||||||
@@ -24,8 +49,12 @@ export default class ClimateChart {
|
|||||||
this.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2));
|
this.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2));
|
||||||
}
|
}
|
||||||
|
|
||||||
addTimeseries(timeseries: Timeseries) {
|
addTimeseries(timeseries: Timeseries, scale?: ScaleId) {
|
||||||
this.timeseries.push(timeseries);
|
if (scale === ScaleId.Left) {
|
||||||
|
this.leftScale.timeseries.push(timeseries);
|
||||||
|
} else {
|
||||||
|
this.rightScale.timeseries.push(timeseries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setRange(range: {start: number, stop: number}) {
|
setRange(range: {start: number, stop: number}) {
|
||||||
@@ -33,7 +62,7 @@ export default class ClimateChart {
|
|||||||
this.indexRange.stop = range.stop;
|
this.indexRange.stop = range.stop;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove(event: MouseEvent) {
|
private handleMouseMove(event: MouseEvent) {
|
||||||
const {left: canvasX, top: canvasY} = this.ctx.canvas.getBoundingClientRect();
|
const {left: canvasX, top: canvasY} = this.ctx.canvas.getBoundingClientRect();
|
||||||
const x = event.clientX - canvasX;
|
const x = event.clientX - canvasX;
|
||||||
const y = event.clientY - canvasY;
|
const y = event.clientY - canvasY;
|
||||||
@@ -44,62 +73,112 @@ export default class ClimateChart {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.updateDimensions();
|
this.updateDimensions();
|
||||||
this.ctx.fillStyle = "rgb(255,255,255)";
|
this.clearCanvas();
|
||||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
this.updateResolution();
|
||||||
this.ctx.fill();
|
this.setDisplayRangeForScale(this.leftScale);
|
||||||
this.setDisplayRange();
|
this.setDisplayRangeForScale(this.rightScale);
|
||||||
this.renderScale();
|
this.renderRightScale();
|
||||||
for (const timeseries of this.timeseries) {
|
this.leftScale.timeseries.forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Left));
|
||||||
this.renderTimeseries(timeseries);
|
this.rightScale.timeseries.forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Right));
|
||||||
}
|
this.renderLeftScale();
|
||||||
this.renderTooltips();
|
this.renderTooltips();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderScale() {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.strokeStyle = "rgb(230,230,230)";
|
||||||
this.ctx.fillStyle = "black";
|
this.ctx.fillStyle = "black";
|
||||||
const ticks = 20;
|
const ticks = 20;
|
||||||
const tickHeight = (this.valRange.high - this.valRange.low) / ticks;
|
const tickHeight = (this.leftScale.valRange.high - this.leftScale.valRange.low) / ticks;
|
||||||
let currentTick = this.valRange.low;
|
let currentTick = this.leftScale.valRange.low - tickHeight;
|
||||||
for (let i = 0; i < ticks; i++) {
|
for (let i = 0; i <= ticks; i++) {
|
||||||
currentTick += tickHeight;
|
currentTick += tickHeight;
|
||||||
const pos = Math.round(this.getY(currentTick));
|
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.beginPath();
|
||||||
this.ctx.moveTo(40, pos);
|
this.ctx.moveTo(this.leftScale.width, pos);
|
||||||
this.ctx.lineTo(this.width, pos);
|
this.ctx.lineTo(this.width - textWidth - 5, pos);
|
||||||
this.ctx.stroke();
|
this.ctx.stroke();
|
||||||
this.ctx.fillText(currentTick.toFixed(2), 0, pos + 4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setDisplayRange() {
|
private setDisplayRangeForScale(scale: Scale) {
|
||||||
for (const timeseries of this.timeseries) {
|
for (const timeseries of scale.timeseries) {
|
||||||
const extrema = timeseries.getExtrema();
|
const extrema = timeseries.getExtrema();
|
||||||
if (extrema.maxVal > this.valRange.high) {
|
if (extrema.maxVal > scale.valRange.high) {
|
||||||
this.valRange.high = extrema.maxVal;
|
scale.valRange.high = extrema.maxVal;
|
||||||
}
|
}
|
||||||
if (extrema.minVal < this.valRange.low) {
|
if (extrema.minVal < scale.valRange.low) {
|
||||||
this.valRange.low = extrema.minVal;
|
scale.valRange.low = extrema.minVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderTooltips() {
|
private renderTooltips(radius = 20) {
|
||||||
let bestDist = 20;
|
let bestDist = radius;
|
||||||
let bestTimeseries = this.timeseries[0];
|
let bestTimeseries = this.rightScale.timeseries[0];
|
||||||
let bestIndex = 0;
|
let bestIndex = 0;
|
||||||
let bestVal = 0;
|
let bestVal = 0;
|
||||||
for (const timeseries of this.timeseries) {
|
let bestScale = ScaleId.Right;
|
||||||
const cache = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop);
|
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) {
|
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 x = this.getX(cache[i + 1]);
|
||||||
const y = this.getY(cache[i]);
|
|
||||||
const dist = Math.sqrt((y - this.lastMousePos.y) ** 2 + (x - this.lastMousePos.x) ** 2);
|
const dist = Math.sqrt((y - this.lastMousePos.y) ** 2 + (x - this.lastMousePos.x) ** 2);
|
||||||
if (dist < bestDist) {
|
if (dist < bestDist) {
|
||||||
bestDist = dist;
|
bestDist = dist;
|
||||||
bestTimeseries = timeseries;
|
bestTimeseries = timeseries;
|
||||||
bestIndex = cache[i + 1];
|
bestIndex = cache[i + 1];
|
||||||
bestVal = cache[i];
|
bestVal = cache[i];
|
||||||
|
bestScale = scaleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +186,7 @@ export default class ClimateChart {
|
|||||||
this.renderTooltip(
|
this.renderTooltip(
|
||||||
`${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`,
|
`${bestTimeseries.getName()} - (${bestVal.toFixed(2)}, ${this.formatTimestamp(bestIndex)})`,
|
||||||
this.getX(bestIndex),
|
this.getX(bestIndex),
|
||||||
this.getY(bestVal),
|
this.getY(bestVal, bestScale),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,36 +196,43 @@ export default class ClimateChart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getX(index: number) {
|
getX(index: number) {
|
||||||
return (index - this.indexRange.start) / (this.indexRange.stop - this.indexRange.start) * this.width;
|
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) {
|
getY(value: number, scale: ScaleId) {
|
||||||
return this.height - (value - this.valRange.low) / (this.valRange.high - this.valRange.low) * this.height;
|
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) {
|
getIndex(x: number) {
|
||||||
return (x / this.width) * this.indexRange.stop;
|
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) {
|
getValue(y: number, scale: ScaleId) {
|
||||||
return ((this.height - y) / this.height) * this.valRange.high;
|
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) {
|
private renderTimeseries(timeseries: Timeseries, scale: ScaleId) {
|
||||||
const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop);
|
const timeseriesPoints = timeseries.cachedBetween(this.indexRange.start, this.indexRange.stop);
|
||||||
this.ctx.strokeStyle = timeseries.getColour();
|
this.ctx.strokeStyle = timeseries.getColour();
|
||||||
const drawBubbles = this.getX(timeseriesPoints[3]) - this.getX(timeseriesPoints[1]) > 6;
|
let y = this.getY(timeseriesPoints[0], scale);
|
||||||
let y = this.getY(timeseriesPoints[0]);
|
|
||||||
let x = this.getX(timeseriesPoints[1]);
|
let x = this.getX(timeseriesPoints[1]);
|
||||||
for (let i = 0; i < timeseriesPoints.length; i += 2) {
|
for (let i = 0; i < timeseriesPoints.length; i += 2 * this.resolution) {
|
||||||
this.ctx.beginPath();
|
this.ctx.beginPath();
|
||||||
this.ctx.moveTo(Math.round(x), Math.round(y));
|
this.ctx.moveTo(Math.round(x), Math.round(y));
|
||||||
y = this.getY(timeseriesPoints[i]);
|
y = 0;
|
||||||
x = this.getX(timeseriesPoints[i + 1]);
|
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.lineTo(Math.round(x), Math.round(y));
|
||||||
this.ctx.stroke();
|
this.ctx.stroke();
|
||||||
if (drawBubbles) {
|
if (this.resolution === 1) {
|
||||||
this.ctx.beginPath();
|
this.ctx.beginPath();
|
||||||
this.ctx.ellipse(x, y, 2, 2, 0, 0, 2 * Math.PI);
|
this.ctx.ellipse(x, y, 2, 2, 0, 0, 2 * Math.PI);
|
||||||
this.ctx.stroke();
|
this.ctx.stroke();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Timeseries from "./Timeseries";
|
import Timeseries from "./Timeseries";
|
||||||
|
import {ScaleId} from "./ClimateChart";
|
||||||
|
|
||||||
export class AppStateError extends Error {
|
export class AppStateError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -11,7 +12,7 @@ export type DisplayMode = "window" | "pastMins";
|
|||||||
|
|
||||||
export interface EventCallback {
|
export interface EventCallback {
|
||||||
newTimeseries: (timeseries: Timeseries) => void;
|
newTimeseries: (timeseries: Timeseries) => void;
|
||||||
timeseriesUpdated: (timeseries: Timeseries) => void;
|
timeseriesUpdated: (timeseries: Timeseries, scale?: ScaleId) => void;
|
||||||
}
|
}
|
||||||
type EventCallbackListing<K extends keyof EventCallback> = Record<K, EventCallback[K][]>;
|
type EventCallbackListing<K extends keyof EventCallback> = Record<K, EventCallback[K][]>;
|
||||||
|
|
||||||
@@ -25,7 +26,8 @@ interface AppState {
|
|||||||
displayWindow: TimeWindow;
|
displayWindow: TimeWindow;
|
||||||
minutesDisplayed: number;
|
minutesDisplayed: number;
|
||||||
utcOffset: number;
|
utcOffset: number;
|
||||||
timeseries: Timeseries[],
|
leftTimeseries: Timeseries[],
|
||||||
|
rightTimeseries: Timeseries[],
|
||||||
overlayText: string;
|
overlayText: string;
|
||||||
dataEndpointBase: string;
|
dataEndpointBase: string;
|
||||||
updateIntervalSeconds: number;
|
updateIntervalSeconds: number;
|
||||||
@@ -63,13 +65,18 @@ class AppStateStore {
|
|||||||
await this.getNewTimeseriesData();
|
await this.getNewTimeseriesData();
|
||||||
}
|
}
|
||||||
|
|
||||||
addTimeseries(timeseries: Timeseries) {
|
addTimeseries(timeseries: Timeseries, scale?: ScaleId) {
|
||||||
if (this.state.timeseries.indexOf(timeseries) >= 0) {
|
const group = scale === ScaleId.Left ? this.state.leftTimeseries : this.state.rightTimeseries;
|
||||||
|
if (group.indexOf(timeseries) >= 0) {
|
||||||
throw new AppStateError("Timeseries has already been added!");
|
throw new AppStateError("Timeseries has already been added!");
|
||||||
}
|
}
|
||||||
this.state.timeseries.push(timeseries);
|
if (scale === ScaleId.Left) {
|
||||||
this.notifyStoreVal("timeseries");
|
group.push(timeseries);
|
||||||
this.eventCallbacks["newTimeseries"].forEach(cb => cb(timeseries));
|
} else {
|
||||||
|
group.push(timeseries);
|
||||||
|
}
|
||||||
|
this.notifyStoreVal(scale === ScaleId.Left ? "leftTimeseries" : "rightTimeseries");
|
||||||
|
this.eventCallbacks["newTimeseries"].forEach(cb => cb(timeseries, scale));
|
||||||
this.updateTimeseriesFromSettings();
|
this.updateTimeseriesFromSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,29 +97,39 @@ class AppStateStore {
|
|||||||
stop = this.state.lastUpdateTime;
|
stop = this.state.lastUpdateTime;
|
||||||
}
|
}
|
||||||
this.addLoad();
|
this.addLoad();
|
||||||
console.log(start, stop);
|
for (const timeseries of this.state.leftTimeseries) {
|
||||||
for (const timeseries of this.state.timeseries) {
|
await timeseries.updateFromWindow(start, stop);
|
||||||
|
}
|
||||||
|
for (const timeseries of this.state.rightTimeseries) {
|
||||||
await timeseries.updateFromWindow(start, stop);
|
await timeseries.updateFromWindow(start, stop);
|
||||||
}
|
}
|
||||||
this.finishLoad();
|
this.finishLoad();
|
||||||
for (const timeseries of this.state.timeseries) {
|
this.notifyAllTimeseriesUpdated();
|
||||||
this.notifyStoreVal("timeseries");
|
|
||||||
this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getNewTimeseriesData() {
|
private async getNewTimeseriesData() {
|
||||||
const updateTime = new Date().getTime() / 1000;
|
const updateTime = new Date().getTime() / 1000;
|
||||||
this.addLoad();
|
this.addLoad();
|
||||||
for (const timeseries of this.state.timeseries) {
|
for (const timeseries of this.state.leftTimeseries) {
|
||||||
|
await timeseries.getLatest();
|
||||||
|
}
|
||||||
|
for (const timeseries of this.state.rightTimeseries) {
|
||||||
await timeseries.getLatest();
|
await timeseries.getLatest();
|
||||||
}
|
}
|
||||||
this.finishLoad();
|
this.finishLoad();
|
||||||
for (const timeseries of this.state.timeseries) {
|
this.setLastUpdateTime(updateTime);
|
||||||
this.notifyStoreVal("timeseries");
|
this.notifyAllTimeseriesUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyAllTimeseriesUpdated() {
|
||||||
|
for (const timeseries of this.state.leftTimeseries) {
|
||||||
|
this.notifyStoreVal("leftTimeseries");
|
||||||
|
this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries));
|
||||||
|
}
|
||||||
|
for (const timeseries of this.state.rightTimeseries) {
|
||||||
|
this.notifyStoreVal("rightTimeseries");
|
||||||
this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries));
|
this.eventCallbacks["timeseriesUpdated"].forEach(cb => cb(timeseries));
|
||||||
}
|
}
|
||||||
this.setLastUpdateTime(updateTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(): AppState {
|
getState(): AppState {
|
||||||
@@ -134,11 +151,13 @@ class AppStateStore {
|
|||||||
|
|
||||||
setDisplayWindow(newWin: TimeWindow) {
|
setDisplayWindow(newWin: TimeWindow) {
|
||||||
if (newWin.start < newWin.stop) {
|
if (newWin.start < newWin.stop) {
|
||||||
|
if (newWin.stop < this.state.lastUpdateTime) {
|
||||||
this.state.displayWindow = {...newWin};
|
this.state.displayWindow = {...newWin};
|
||||||
this.notifyStoreVal("displayWindow");
|
this.notifyStoreVal("displayWindow");
|
||||||
this.updateTimeseriesFromSettings();
|
this.updateTimeseriesFromSettings();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new AppStateError(`Invalid display window from ${newWin.start} to ${newWin.stop}`);
|
console.warn(`Invalid display window from ${newWin.start} to ${newWin.stop}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class Timeseries {
|
|||||||
this.loader = loader;
|
this.loader = loader;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.tolerance = tolerance ?? 0;
|
this.tolerance = tolerance ?? 0;
|
||||||
this.colour = `rgb(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255})`;
|
this.colour = `rgb(${Math.random() * 150},${Math.random() * 150},${Math.random() * 150})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getExtrema(): Extrema {
|
getExtrema(): Extrema {
|
||||||
@@ -51,7 +51,7 @@ class Timeseries {
|
|||||||
return new Int32Array();
|
return new Int32Array();
|
||||||
} else {
|
} else {
|
||||||
return this.cache.slice(
|
return this.cache.slice(
|
||||||
this.findIndexInCache(start),
|
this.findIndexInCache(start) - 1,
|
||||||
this.findIndexInCache(stop)
|
this.findIndexInCache(stop)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -164,9 +164,13 @@ class Timeseries {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private findIndexInCache(soughtIndex: number) {
|
private findIndexInCache(soughtIndex: number) {
|
||||||
|
return this.findIndexInCacheBinary(soughtIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findIndexInCacheLinear(soughtIndex: number) {
|
||||||
for (let i = 1; i < this.cache.length; i += 2) {
|
for (let i = 1; i < this.cache.length; i += 2) {
|
||||||
if (soughtIndex < this.cache[i]) {
|
if (soughtIndex < this.cache[i]) {
|
||||||
return i - 1;
|
return i > 3 ? i - 3 : i - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.cache.length - 2;
|
return this.cache.length - 2;
|
||||||
@@ -174,7 +178,7 @@ class Timeseries {
|
|||||||
|
|
||||||
private findIndexInCacheBinary(soughtIndex: number, listStart = 0, listStop: number = (this.currentEndPointer / 2)): number {
|
private findIndexInCacheBinary(soughtIndex: number, listStart = 0, listStop: number = (this.currentEndPointer / 2)): number {
|
||||||
if (listStop - listStart === 1) {
|
if (listStop - listStart === 1) {
|
||||||
return listStart;
|
return listStart * 2 + 1;
|
||||||
} else {
|
} else {
|
||||||
const middle = Math.floor((listStop + listStart) / 2);
|
const middle = Math.floor((listStop + listStart) / 2);
|
||||||
const val = this.cache[middle * 2 + 1];
|
const val = this.cache[middle * 2 + 1];
|
||||||
@@ -183,7 +187,7 @@ class Timeseries {
|
|||||||
} else if (val < soughtIndex) {
|
} else if (val < soughtIndex) {
|
||||||
return this.findIndexInCacheBinary(soughtIndex, middle, listStop);
|
return this.findIndexInCacheBinary(soughtIndex, middle, listStop);
|
||||||
} else {
|
} else {
|
||||||
return middle;
|
return middle * 2 + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import {ChartConfiguration, ChartPoint, TimeUnit} from "chart.js";
|
|
||||||
|
|
||||||
interface ClimateChartSettings {
|
|
||||||
humidity?: ChartPoint[];
|
|
||||||
temp?: ChartPoint[];
|
|
||||||
co2?: ChartPoint[];
|
|
||||||
colors?: {
|
|
||||||
humidity?: string;
|
|
||||||
temp?: string;
|
|
||||||
co2?: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultHumidityColor = "rgb(196,107,107)";
|
|
||||||
const defaultTempColor = "rgb(173,136,68)";
|
|
||||||
const defaultCo2Color = "rgb(52,133,141)";
|
|
||||||
|
|
||||||
export function generateClimateChartConfig(settings: ClimateChartSettings): ChartConfiguration {
|
|
||||||
return {
|
|
||||||
type: "line",
|
|
||||||
data: {
|
|
||||||
datasets: [{
|
|
||||||
label: "Humidity",
|
|
||||||
data: settings.humidity,
|
|
||||||
borderColor: settings.colors?.humidity ?? defaultHumidityColor,
|
|
||||||
fill: false,
|
|
||||||
yAxisID: "y-axis-3",
|
|
||||||
}, {
|
|
||||||
label: "Temperature",
|
|
||||||
data: settings.temp,
|
|
||||||
borderColor: settings.colors?.temp ?? defaultTempColor,
|
|
||||||
fill: false,
|
|
||||||
yAxisID: "y-axis-2",
|
|
||||||
}, {
|
|
||||||
label: "Co2 Concentration",
|
|
||||||
data: settings.co2,
|
|
||||||
borderColor: settings.colors?.co2 ?? defaultCo2Color,
|
|
||||||
fill: false,
|
|
||||||
yAxisID: "y-axis-1",
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
animation: {animateRotate: false, duration: 0, animateScale: false},
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
legend: {
|
|
||||||
position: "top",
|
|
||||||
align: "end",
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
xAxes: [{
|
|
||||||
type: "time",
|
|
||||||
time: {
|
|
||||||
unit: "second" as TimeUnit
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
yAxes: [{
|
|
||||||
type: "linear",
|
|
||||||
display: true,
|
|
||||||
position: "right",
|
|
||||||
id: "y-axis-1",
|
|
||||||
ticks: {
|
|
||||||
fontColor: settings.colors?.co2 ?? defaultCo2Color,
|
|
||||||
suggestedMin: 400,
|
|
||||||
suggestedMax: 1100,
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
type: "linear",
|
|
||||||
display: true,
|
|
||||||
position: "left",
|
|
||||||
id: "y-axis-2",
|
|
||||||
ticks: {
|
|
||||||
fontColor: settings.colors?.temp ?? defaultTempColor,
|
|
||||||
suggestedMin: 10,
|
|
||||||
suggestedMax: 35,
|
|
||||||
},
|
|
||||||
gridLines: {
|
|
||||||
drawOnChartArea: false,
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
type: "linear",
|
|
||||||
display: true,
|
|
||||||
position: "left",
|
|
||||||
id: "y-axis-3",
|
|
||||||
ticks: {
|
|
||||||
fontColor: settings.colors?.humidity ?? defaultHumidityColor,
|
|
||||||
suggestedMin: 15,
|
|
||||||
suggestedMax: 85,
|
|
||||||
},
|
|
||||||
gridLines: {
|
|
||||||
drawOnChartArea: false,
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,8 @@ import {AppStore, getAppState, initStore} from "./StateStore";
|
|||||||
import AppUI from "./ui-components/AppUI";
|
import AppUI from "./ui-components/AppUI";
|
||||||
import Timeseries from "./Timeseries";
|
import Timeseries from "./Timeseries";
|
||||||
import {ClayPIDashboardError} from "./errors";
|
import {ClayPIDashboardError} from "./errors";
|
||||||
|
import {ScaleId} from "./ClimateChart";
|
||||||
|
|
||||||
export {config};
|
export {config};
|
||||||
|
|
||||||
function getDisplayedMinutes() {
|
function getDisplayedMinutes() {
|
||||||
@@ -35,23 +37,32 @@ async function init() {
|
|||||||
fatalError: null,
|
fatalError: null,
|
||||||
displayWindow: {start: now - getDisplayedMinutes() * 60, stop: now},
|
displayWindow: {start: now - getDisplayedMinutes() * 60, stop: now},
|
||||||
documentReady: false,
|
documentReady: false,
|
||||||
timeseries: [],
|
leftTimeseries: [],
|
||||||
|
rightTimeseries: [],
|
||||||
});
|
});
|
||||||
AppStore().addTimeseries(new Timeseries(
|
AppStore().addTimeseries(
|
||||||
|
new Timeseries(
|
||||||
"temp",
|
"temp",
|
||||||
(start, stop) => loadClimateTimeseriesData("temp", start, stop),
|
(start, stop) => loadClimateTimeseriesData("temp", start, stop),
|
||||||
getAppState().updateIntervalSeconds
|
getAppState().updateIntervalSeconds
|
||||||
));
|
),
|
||||||
AppStore().addTimeseries(new Timeseries(
|
ScaleId.Left);
|
||||||
|
AppStore().addTimeseries(
|
||||||
|
new Timeseries(
|
||||||
"humidity",
|
"humidity",
|
||||||
(start, stop) => loadClimateTimeseriesData("humidity", start, stop),
|
(start, stop) => loadClimateTimeseriesData("humidity", start, stop),
|
||||||
getAppState().updateIntervalSeconds
|
getAppState().updateIntervalSeconds
|
||||||
));
|
),
|
||||||
AppStore().addTimeseries(new Timeseries(
|
ScaleId.Left
|
||||||
|
);
|
||||||
|
AppStore().addTimeseries(
|
||||||
|
new Timeseries(
|
||||||
"co2",
|
"co2",
|
||||||
(start, stop) => loadClimateTimeseriesData("co2", start, stop),
|
(start, stop) => loadClimateTimeseriesData("co2", start, stop),
|
||||||
getAppState().updateIntervalSeconds
|
getAppState().updateIntervalSeconds
|
||||||
));
|
),
|
||||||
|
ScaleId.Right
|
||||||
|
);
|
||||||
const ui = new AppUI();
|
const ui = new AppUI();
|
||||||
ui.bootstrap("root");
|
ui.bootstrap("root");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 from "../ClimateChart";
|
import ClimateChart, {ScaleId} from "../ClimateChart";
|
||||||
|
|
||||||
class ClimateChartWidget extends UIComponent {
|
class ClimateChartWidget extends UIComponent {
|
||||||
private readonly skeleton: GridWidget;
|
private readonly skeleton: GridWidget;
|
||||||
@@ -54,9 +54,8 @@ class ClimateChartWidget extends UIComponent {
|
|||||||
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 ClimateChart(ctx);
|
||||||
for (const timeseries of getAppState().timeseries) {
|
getAppState().leftTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Left));
|
||||||
this.chart.addTimeseries(timeseries);
|
getAppState().rightTimeseries.forEach(timeseries => this.chart.addTimeseries(timeseries, ScaleId.Right));
|
||||||
}
|
|
||||||
await this.rerender();
|
await this.rerender();
|
||||||
this.initialised = true;
|
this.initialised = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user