it's done i think

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -5,6 +5,7 @@
<title>Ledda's Room Climate</title> <title>Ledda's Room Climate</title>
<link type="text/css" href="/styles.css" rel="stylesheet" /> <link type="text/css" href="/styles.css" rel="stylesheet" />
<script type="application/javascript" src="/dashboard.js"></script> <script type="application/javascript" src="/dashboard.js"></script>
<link rel="shortcut icon" type="image/jpg" href="/favicon64.png"/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -11,8 +11,8 @@ html, body {
} }
.main-content-grid { .main-content-grid {
height: 80%; width: calc(100% - 12em);
width: 80%; height: calc(100% - 10em);
display: grid; display: grid;
text-align: center; text-align: center;
} }
@@ -200,3 +200,66 @@ h1 {
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
} }
.help-box {
position: relative;
background-color: #ffffff;
border: solid 1px #7b5b2f;
width: 40em;
padding: 1em;
z-index: 1;
}
.help-box img {
}
.help-box h1 {
margin: auto;
text-align: center;
}
.help-box .image-advice {
align-items: center;
margin-top: 1em;
margin-bottom: 1em;
display: flex;
}
.help-box .image-advice * {
display: inline-block;
padding: 0.5em;
}
.x-button:before {
content: "×";
font-size: 1em;
line-height: 1em;
}
.x-button {
cursor: pointer;
text-align: center;
background-color: white;
height: 1.2em;
width: 1.2em;
position: absolute;
right: 0;
top: 0;
}
.help-button {
cursor: pointer;
width: 2em;
height: 2em;
margin: 0.5em;
position: absolute;
right: 0;
top: 0;
}
.help-button:hover {
filter: brightness(1.1);
}
.help-button:active {
filter: brightness(0.9);
}
.button:hover {
filter: brightness(1.1);
}
.button:active {
filter: brightness(0.9);
}

View File

@@ -0,0 +1,266 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg3486"
inkscape:version="1.0.2 (394de47547, 2021-03-26)"
sodipodi:docname="favicon.svg"
inkscape:export-filename="/home/ledda/Documents/Projects/climate-server/dashboard/assets/favicon16.png"
inkscape:export-xdpi="24"
inkscape:export-ydpi="24">
<defs
id="defs3480">
<linearGradient
inkscape:collect="always"
id="linearGradient867">
<stop
style="stop-color:#e3e3e3;stop-opacity:1"
offset="0"
id="stop863" />
<stop
style="stop-color:#1c56f8;stop-opacity:1"
offset="0.57295728"
id="stop869" />
<stop
style="stop-color:#002ca7;stop-opacity:1"
offset="1"
id="stop865" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient4914">
<stop
style="stop-color:#cccccc;stop-opacity:1"
offset="0"
id="stop4910" />
<stop
style="stop-color:#070a02;stop-opacity:1"
offset="1"
id="stop4912" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient4894">
<stop
style="stop-color:#f6dbdb;stop-opacity:1"
offset="0"
id="stop4890" />
<stop
style="stop-color:#f14a4a;stop-opacity:1"
offset="0.5394209"
id="stop4908" />
<stop
style="stop-color:#bd0000;stop-opacity:1"
offset="1"
id="stop4892" />
</linearGradient>
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="13.033485 : 17.411423 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="-9.7154793 : 15.520856 : 1"
inkscape:persp3d-origin="8.4666665 : 5.6444447 : 1"
id="perspective4365" />
<linearGradient
inkscape:collect="always"
id="linearGradient4260-6">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop4256" />
<stop
style="stop-color:#000000;stop-opacity:0;"
offset="1"
id="stop4258" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4260-6"
id="radialGradient4262"
cx="4.7302384"
cy="6.0053997"
fx="4.7302384"
fy="6.0053997"
r="2.4687963"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
id="linearGradient4475">
<stop
style="stop-color:#d0ecac;stop-opacity:1"
offset="0"
id="stop4471" />
<stop
style="stop-color:#070a02;stop-opacity:1"
offset="1"
id="stop4473" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4475"
id="radialGradient4477"
cx="4.0612016"
cy="5.0891728"
fx="4.0612016"
fy="5.0891728"
r="2.4687963"
gradientUnits="userSpaceOnUse" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4914"
id="radialGradient4477-6"
cx="4.0612016"
cy="5.0891728"
fx="4.0612016"
fy="5.0891728"
r="2.4687963"
gradientUnits="userSpaceOnUse" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4894"
id="radialGradient4433"
cx="-5.0382247"
cy="10.337361"
fx="-5.0382247"
fy="10.337361"
r="2.4677463"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-1.2725204,0.33871684,-0.33871684,-1.2725204,-7.9480302,25.198396)" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4894"
id="radialGradient4441"
cx="5.30828"
cy="12.653597"
fx="5.30828"
fy="12.653597"
r="2.5259755"
gradientUnits="userSpaceOnUse" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient5215"
id="radialGradient4477-6-5"
cx="4.0612016"
cy="5.0891728"
fx="4.0612016"
fy="5.0891728"
r="2.4687963"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
id="linearGradient5215">
<stop
style="stop-color:#e3e3e3;stop-opacity:1"
offset="0"
id="stop5211" />
<stop
style="stop-color:#070a02;stop-opacity:1"
offset="1"
id="stop5213" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient867"
id="radialGradient4477-6-5-1"
cx="4.0612016"
cy="5.0891728"
fx="4.0612016"
fy="5.0891728"
r="2.4687963"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.0341543,1.0341543,-1.0341543,1.0341543,5.1242825,-4.3737261)" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="10.24"
inkscape:cx="8.3396846"
inkscape:cy="30.850988"
inkscape:document-units="mm"
inkscape:current-layer="layer3"
inkscape:document-rotation="0"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1043"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata3483">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Layer 2"
style="display:inline">
<g
id="g5377">
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 5.0438123,6.1176582 12.47978,3.1707767"
id="path4106-3" />
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 4.5225786,6.363151 0.2192101,7.995604"
id="path4106-6" />
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 4.2469752,6.0364594 11.682943,3.0895779"
id="path4106" />
<path
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 4.905109,6.4443498 5.1243191,14.439954"
id="path4106-6-4" />
<circle
style="fill:url(#radialGradient4433);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.63382;stroke-opacity:1;stop-color:#000000"
id="path4049-9-9"
cx="-6.2022243"
cy="10.932185"
transform="rotate(-105.09473)"
r="2.4677463" />
<circle
style="fill:url(#radialGradient4441);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.63382;stop-color:#000000"
id="path4049-9-9-3"
cx="6.1492538"
cy="13.494351"
transform="rotate(4.9532955)"
r="2.5259755" />
</g>
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Layer 1"
style="display:inline">
<circle
style="display:inline;mix-blend-mode:multiply;fill:url(#radialGradient4477-6-5-1);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.634;stroke-miterlimit:4;stroke-dasharray:none;stop-color:#000000"
id="path4049"
cy="6.0053997"
cx="4.7302384"
r="2.4687963" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="32"
height="32"
viewBox="0 0 8.4666667 8.4666667"
version="1.1"
id="svg8"
inkscape:export-filename="/home/ledda/Documents/Projects/climate-server/dashboard/assets/help-button.png"
inkscape:export-xdpi="192"
inkscape:export-ydpi="192"
inkscape:version="1.0.2 (394de47547, 2021-03-26)"
sodipodi:docname="help-button.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="8.4763136"
inkscape:cx="22.273863"
inkscape:cy="37.692587"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
units="px"
width="15in"
inkscape:window-width="1439"
inkscape:window-height="1016"
inkscape:window-x="2257"
inkscape:window-y="209"
inkscape:window-maximized="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<ellipse
style="fill:#fff1de;fill-opacity:1;fill-rule:evenodd;stroke:#c7ab82;stroke-width:0.573164;stroke-opacity:1;stop-color:#000000"
id="path2811"
cx="4.2333336"
cy="4.2333336"
rx="3.9467516"
ry="3.9467514" />
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:8.0888px;line-height:1.25;font-family:sans-serif;fill:#c7ab82;fill-opacity:1;stroke:none;stroke-width:0.20222;"
x="2.5377054"
y="6.9956398"
id="text2809"><tspan
sodipodi:role="line"
id="tspan2807"
x="2.5377054"
y="6.9956398"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Bitstream Charter';-inkscape-font-specification:'Bitstream Charter';stroke-width:0.20222;fill:#c7ab82;fill-opacity:1;">?</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -3139,6 +3139,16 @@
"escape-string-regexp": "^1.0.5" "escape-string-regexp": "^1.0.5"
} }
}, },
"file-loader": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
"integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
"dev": true,
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
}
},
"file-uri-to-path": { "file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",

View File

@@ -17,6 +17,7 @@
"babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-plugin-syntax-dynamic-import": "^6.18.0",
"css-loader": "^5.0.1", "css-loader": "^5.0.1",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",
"file-loader": "^6.0.0",
"terser-webpack-plugin": "^5.0.3", "terser-webpack-plugin": "^5.0.3",
"ts-loader": "^8.0.18", "ts-loader": "^8.0.18",
"uglifyjs-webpack-plugin": "^2.2.0", "uglifyjs-webpack-plugin": "^2.2.0",

View File

@@ -15,6 +15,7 @@ export interface EventCallback {
timeseriesUpdated: (timeseries: Timeseries) => void; timeseriesUpdated: (timeseries: Timeseries) => void;
newTimeseries: (timeseries: Timeseries, scale?: ScaleId) => void; newTimeseries: (timeseries: Timeseries, scale?: ScaleId) => void;
stateChange: StateChangeCallback<AppState, keyof AppState>; stateChange: StateChangeCallback<AppState, keyof AppState>;
ready: () => void;
} }
type StateChangeCallback<T, K extends keyof T> = (attrName?: K, oldVal?: T[K], newVal?: T[K]) => void; type StateChangeCallback<T, K extends keyof T> = (attrName?: K, oldVal?: T[K], newVal?: T[K]) => void;
type EventCallbackListing<K extends keyof EventCallback> = Record<K, EventCallback[K][]>; type EventCallbackListing<K extends keyof EventCallback> = Record<K, EventCallback[K][]>;
@@ -39,6 +40,7 @@ interface AppState {
fatalError: Error | null; fatalError: Error | null;
documentReady: boolean; documentReady: boolean;
highlightedTimeseries: string | null; highlightedTimeseries: string | null;
showingHelp: boolean;
} }
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void; type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
@@ -62,10 +64,11 @@ function newDefaultState(): AppState {
leftTimeseries: [], leftTimeseries: [],
rightTimeseries: [], rightTimeseries: [],
highlightedTimeseries: null, highlightedTimeseries: null,
showingHelp: false,
}; };
} }
class AppStateStore { class AppStateStore {
private readonly subscriptions: IAppStateSubscriptions; private readonly subscriptions: IAppStateSubscriptions;
private readonly eventCallbacks: EventCallbackListing<keyof EventCallback>; private readonly eventCallbacks: EventCallbackListing<keyof EventCallback>;
private readonly state: AppState; private readonly state: AppState;
@@ -77,7 +80,7 @@ class AppStateStore {
for (const key in this.state) { for (const key in this.state) {
subscriptions[key] = []; subscriptions[key] = [];
} }
this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: [], stateChange: []}; this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: [], stateChange: [], ready: []};
this.subscriptions = subscriptions as IAppStateSubscriptions; this.subscriptions = subscriptions as IAppStateSubscriptions;
this.init(); this.init();
setInterval(() => this.getNewTimeseriesData().catch(e => AppStore().fatalError(e)), this.state.updateIntervalSeconds * 1000); setInterval(() => this.getNewTimeseriesData().catch(e => AppStore().fatalError(e)), this.state.updateIntervalSeconds * 1000);
@@ -86,6 +89,7 @@ class AppStateStore {
async init() { async init() {
await this.updateTimeseriesFromSettings(); await this.updateTimeseriesFromSettings();
await this.getNewTimeseriesData(); await this.getNewTimeseriesData();
this.emit("ready");
} }
addTimeseriesToScale(timeseries: Timeseries, scale?: ScaleId) { addTimeseriesToScale(timeseries: Timeseries, scale?: ScaleId) {
@@ -191,7 +195,7 @@ class AppStateStore {
setDisplayWindow(newWin: TimeWindow) { setDisplayWindow(newWin: TimeWindow) {
if (newWin.start < newWin.stop) { if (newWin.start < newWin.stop) {
if (newWin.stop < this.state.lastUpdateTime) { if (newWin.stop <= this.state.lastUpdateTime) {
this.state.displayWindow = {...newWin}; this.state.displayWindow = {...newWin};
this.notifyStoreVal("displayWindow"); this.notifyStoreVal("displayWindow");
this.updateTimeseriesFromSettings(); this.updateTimeseriesFromSettings();
@@ -270,6 +274,24 @@ class AppStateStore {
this.notifyStoreVal("highlightedTimeseries", name); this.notifyStoreVal("highlightedTimeseries", name);
} }
emulateLastMinsWithWindow() {
this.setDisplayMode("window");
this.setDisplayWindow({
start: this.state.lastUpdateTime - getAppState().minutesDisplayed * 60 + getAppState().utcOffset * 60,
stop: this.state.lastUpdateTime
});
}
showHelp() {
this.state.showingHelp = true;
this.notifyStoreVal("showingHelp");
}
hideHelp() {
this.state.showingHelp = false;
this.notifyStoreVal("showingHelp");
}
serialiseState(): string { serialiseState(): string {
const stateStringParams = []; const stateStringParams = [];
if (this.state.displayMode === "pastMins") { if (this.state.displayMode === "pastMins") {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,23 +10,33 @@ const webpackConfig = {
plugins: [new webpack.ProgressPlugin()], plugins: [new webpack.ProgressPlugin()],
module: { module: {
rules: [{ rules: [
test: /\.(ts|tsx)$/, {
loader: "ts-loader", test: /\.(ts|tsx)$/,
include: [path.resolve(__dirname, "src")], loader: "ts-loader",
exclude: [/node_modules/] include: [path.resolve(__dirname, "src")],
}, { exclude: [/node_modules/]
test: /.css$/, },
{
test: /.css$/,
use: [{ use: [{
loader: "style-loader" loader: "style-loader"
}, { }, {
loader: "css-loader", loader: "css-loader",
options: { options: {
sourceMap: true sourceMap: true
} }
}]
},
{
test: /\.(png|jpe?g|gif|ttf|woff2?|eot|svg)$/i,
use: [
{
loader: "file-loader",
},
],
}] }]
}]
}, },
resolve: { resolve: {