Moved the webapp to a webpack with some typescript, updated server wtih new endpoints
This commit is contained in:
1937
webapp/dist/main.js
vendored
Normal file
1937
webapp/dist/main.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8487
webapp/package-lock.json
generated
Normal file
8487
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
webapp/package.json
Normal file
30
webapp/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "climate-ranger-frontend",
|
||||
"version": "0.0.1",
|
||||
"description": "Frontend for displaying info about room climate",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "webpack",
|
||||
"dev": "webpack-dev-server"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@webpack-cli/init": "^1.0.3",
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"css-loader": "^5.0.1",
|
||||
"style-loader": "^2.0.0",
|
||||
"terser-webpack-plugin": "^5.0.3",
|
||||
"ts-loader": "^8.0.10",
|
||||
"typescript": "^4.0.5",
|
||||
"webpack": "^5.4.0",
|
||||
"webpack-cli": "^4.2.0",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"prettier": "^2.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/chart.js": "^2.9.27",
|
||||
"chart.js": "^2.9.4"
|
||||
}
|
||||
}
|
||||
110
webapp/src/ClimateChart.ts
Normal file
110
webapp/src/ClimateChart.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import Chart, {ChartPoint} from "chart.js";
|
||||
import {generateClimateChartConfig} from "./climateChartConfig";
|
||||
|
||||
interface Snapshot {
|
||||
id: number,
|
||||
temp: number,
|
||||
humidity: number,
|
||||
co2: number,
|
||||
time: string,
|
||||
}
|
||||
|
||||
interface SnapshotRecords {
|
||||
snapshots: Snapshot[]
|
||||
}
|
||||
|
||||
class ClimateChart {
|
||||
private readonly urlEndpoint: string;
|
||||
private chart: Chart | null;
|
||||
private latestSnapshot: Snapshot | null;
|
||||
private onLoadedCallback: () => void = () => {};
|
||||
constructor(
|
||||
private readonly rootUrl: string,
|
||||
private readonly canvasId: string,
|
||||
private readonly minutesDisplayed: number = 60
|
||||
) {
|
||||
if (minutesDisplayed > 0 && Math.floor(minutesDisplayed) == minutesDisplayed) {
|
||||
this.minutesDisplayed = minutesDisplayed;
|
||||
} else {
|
||||
throw new Error(`invalid minutes passed to display in chart: ${minutesDisplayed}`);
|
||||
}
|
||||
this.urlEndpoint = this.rootUrl + "data/";
|
||||
this.urlEndpoint += "since/" + this.minutesDisplayed;
|
||||
this.initChart()
|
||||
}
|
||||
|
||||
onLoaded(callback: () => void) {
|
||||
this.onLoadedCallback = callback;
|
||||
}
|
||||
|
||||
private static isCanvas(el: HTMLElement): el is HTMLCanvasElement {
|
||||
return el.tagName === "canvas";
|
||||
}
|
||||
|
||||
private async getInitialDataBlob(): Promise<SnapshotRecords> {
|
||||
const data = await fetch(this.urlEndpoint);
|
||||
return await data.json();
|
||||
}
|
||||
|
||||
private async initChart() {
|
||||
const canvasElement = document.getElementById(this.canvasId);
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
if (ClimateChart.isCanvas(canvasElement)) {
|
||||
ctx = canvasElement.getContext('2d');
|
||||
} else {
|
||||
throw new Error(`improper HTML element passed, needed type canvas, got ${canvasElement.tagName}`);
|
||||
}
|
||||
const payload = await this.getInitialDataBlob();
|
||||
this.latestSnapshot = payload.snapshots[payload.snapshots.length - 1];
|
||||
this.chart = new Chart(ctx, generateClimateChartConfig(this.jsonToChartPoints(payload)));
|
||||
setInterval(() => this.updateFromServer(), 30 * 1000);
|
||||
this.onLoadedCallback();
|
||||
}
|
||||
|
||||
private jsonToChartPoints(json: SnapshotRecords): {humidity: ChartPoint[], temp: ChartPoint[], co2: ChartPoint[]} {
|
||||
const humidity = [];
|
||||
const co2 = [];
|
||||
const temp = [];
|
||||
for (let i = 0; i < json.snapshots.length; i++) {
|
||||
const snapshot = json.snapshots[json.snapshots.length - i - 1];
|
||||
co2.push({x: snapshot.time, y: snapshot.co2});
|
||||
temp.push({x: snapshot.time, y: snapshot.temp});
|
||||
humidity.push({x: snapshot.time, y: snapshot.humidity});
|
||||
}
|
||||
return {humidity, co2, temp};
|
||||
}
|
||||
|
||||
private async updateFromServer() {
|
||||
const currentTime = (new Date(this.latestSnapshot.time)).toISOString();
|
||||
const url = "/" + this.rootUrl + "data?since=" + currentTime;
|
||||
const payload: SnapshotRecords = await (await fetch(url)).json();
|
||||
if (payload.snapshots.length > 0) {
|
||||
this.latestSnapshot = payload.snapshots[payload.snapshots.length - 1];
|
||||
this.removeExpiredData(currentTime);
|
||||
this.insertSnapshots(...payload.snapshots);
|
||||
this.chart.update();
|
||||
}
|
||||
}
|
||||
|
||||
private insertSnapshots(...snapshots: Snapshot[]) {
|
||||
for (const snapshot of snapshots) {
|
||||
this.chart.data.datasets[0].data.push(snapshot.humidity);
|
||||
this.chart.data.datasets[1].data.push(snapshot.temp);
|
||||
this.chart.data.datasets[2].data.push(snapshot.co2);
|
||||
}
|
||||
}
|
||||
|
||||
private removeExpiredData(latestTime: string) {
|
||||
for (let i = 0; i < this.chart.data.datasets[0].data.length; i++) {
|
||||
const timeOnPoint = (this.chart.data.datasets[0].data[i] as ChartPoint).x as string;
|
||||
const timeElapsedSincePoint = Date.parse(latestTime) - Date.parse(timeOnPoint);
|
||||
if (timeElapsedSincePoint > this.minutesDisplayed * 60000) {
|
||||
this.chart.data.datasets[0].data.splice(i, 1);
|
||||
this.chart.data.datasets[1].data.splice(i, 1);
|
||||
this.chart.data.datasets[2].data.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ClimateChart;
|
||||
94
webapp/src/climateChartConfig.ts
Normal file
94
webapp/src/climateChartConfig.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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(45,141,45)';
|
||||
const defaultTempColor = 'rgb(0,134,222)';
|
||||
const defaultCo2Color = 'rgb(194,30,30)';
|
||||
|
||||
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: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Ledda\'s Room Climate',
|
||||
},
|
||||
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,
|
||||
},
|
||||
}],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
27
webapp/src/main.ts
Normal file
27
webapp/src/main.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import ClimateChart from "./ClimateChart";
|
||||
|
||||
const ROOT_URL: string = "climate/";
|
||||
const CHART_DOM_ID: string = "myChart";
|
||||
|
||||
function createClimateChart() {
|
||||
const pathname = window.location.pathname;
|
||||
let minutesDisplayed = 60;
|
||||
const argsStart = pathname.search(/\?minute-span=/);
|
||||
if (argsStart !== -1) {
|
||||
const parsedMins = Number(pathname[12]);
|
||||
if (!isNaN(parsedMins) && parsedMins > 0) {
|
||||
minutesDisplayed = parsedMins;
|
||||
}
|
||||
}
|
||||
return new ClimateChart(ROOT_URL, CHART_DOM_ID, minutesDisplayed);
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.innerText = 'Loading data...';
|
||||
overlay.className = 'overlay';
|
||||
document.getRootNode().appendChild(overlay);
|
||||
|
||||
const climateChart = createClimateChart();
|
||||
climateChart.onLoaded(() => {
|
||||
overlay.classList.add('hidden');
|
||||
})
|
||||
14
webapp/static/charts.html
Normal file
14
webapp/static/charts.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Ledda's Room Climate</title>
|
||||
<link href="/climate/static/styles.css" rel="stylesheet" />
|
||||
<script src="/climate/static/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chart-container" style="position: relative; height:40vh; width:80vw; margin: auto">
|
||||
<canvas id="myChart"></canvas>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
15
webapp/static/styles.css
Normal file
15
webapp/static/styles.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
opacity: 50%;
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
10
webapp/tsconfig.json
Normal file
10
webapp/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noImplicitAny": true,
|
||||
"module": "es6",
|
||||
"target": "es5",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "Node",
|
||||
}
|
||||
}
|
||||
80
webapp/webpack.config.js
Normal file
80
webapp/webpack.config.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* SplitChunksPlugin is enabled by default and replaced
|
||||
* deprecated CommonsChunkPlugin. It automatically identifies modules which
|
||||
* should be splitted of chunk by heuristics using module duplication count and
|
||||
* module category (i. e. node_modules). And splits the chunks…
|
||||
*
|
||||
* It is safe to remove "splitChunks" from the generated configuration
|
||||
* and was added as an educational example.
|
||||
*
|
||||
* https://webpack.js.org/plugins/split-chunks-plugin/
|
||||
*
|
||||
*/
|
||||
|
||||
/*
|
||||
* We've enabled TerserPlugin for you! This minifies your app
|
||||
* in order to load faster and run less javascript.
|
||||
*
|
||||
* https://github.com/webpack-contrib/terser-webpack-plugin
|
||||
*
|
||||
*/
|
||||
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
mode: 'development',
|
||||
entry: './src/main.ts',
|
||||
plugins: [new webpack.ProgressPlugin()],
|
||||
|
||||
module: {
|
||||
rules: [{
|
||||
test: /\.(ts|tsx)$/,
|
||||
loader: 'ts-loader',
|
||||
include: [path.resolve(__dirname, 'src')],
|
||||
exclude: [/node_modules/]
|
||||
}, {
|
||||
test: /.css$/,
|
||||
|
||||
use: [{
|
||||
loader: "style-loader"
|
||||
}, {
|
||||
loader: "css-loader",
|
||||
|
||||
options: {
|
||||
sourceMap: true
|
||||
}
|
||||
}]
|
||||
}]
|
||||
},
|
||||
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js']
|
||||
},
|
||||
|
||||
optimization: {
|
||||
minimizer: [new TerserPlugin()],
|
||||
|
||||
splitChunks: {
|
||||
cacheGroups: {
|
||||
vendors: {
|
||||
priority: -10,
|
||||
test: /[\\/]node_modules[\\/]/
|
||||
}
|
||||
},
|
||||
|
||||
chunks: 'async',
|
||||
minChunks: 1,
|
||||
minSize: 30000,
|
||||
name: false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user