Pārlūkot izejas kodu

Create multiple graphs + refactor & improve data handling

- Process data into distinct time series.
  - Allows data to be processed per-measurement type, rather than as a whole.
- Refactored data cleaning to handle a standard `value` field, rather than
  needing to be told the data field.
- Generate distinct graphs per time series, based on a common config format
Jason Tarka 4 gadi atpakaļ
vecāks
revīzija
bf3eed8567

+ 11 - 4
web-view/src/index.html

@@ -3,12 +3,19 @@
 <head>
 	<meta charset="UTF-8">
 	<title>Weather Station</title>
+	<style>
+		canvas {
+			max-width: 800px;
+			max-height: 200px;
+			display: block;
+		}
+	</style>
 </head>
 <body>
+	<div id="guages"></div>
+	<div id="time-series-graphs"></div>
 
-<canvas id="temperature" width="200" height="100"></canvas>
-
-<!--suppress HtmlUnknownTarget -->
-<script src="js/main.js" async></script>
+	<!--suppress HtmlUnknownTarget -->
+	<script src="js/main.js" async></script>
 </body>
 </html>

+ 58 - 58
web-view/src/js/cleanData.test.ts

@@ -1,5 +1,5 @@
 import {alignToWindow, removeSpikes} from './cleanData';
-import {testTemperatureData} from './testData';
+import {testData} from './testData';
 import * as should from 'should';
 
 describe('Clean data', () => {
@@ -7,59 +7,59 @@ describe('Clean data', () => {
 		it('Aligns data to 15 minute increments', () => {
 			// Adapted from the data in testData.ts
 			const expected = [
-				{ time: 1640296800000, temperature: -4.2 },
-				{ time: 1640297700000, temperature: -4.3 },
-				{ time: 1640298600000, temperature: -4.4 },
-				{ time: 1640299500000, temperature: -4.2 },
-				{ time: 1640300400000, temperature: -4.2 },
-				{ time: 1640301300000, temperature: -4.2 },
-				{ time: 1640302200000, temperature: -0.2 },
-				{ time: 1640303100000, temperature: -2.9 },
-				{ time: 1640304000000, temperature: -3.9 },
-				{ time: 1640304900000, temperature: -4.0 },
-				{ time: 1640305800000, temperature: -3.9 },
-				{ time: 1640306700000, temperature: -3.8 },
-				{ time: 1640307600000, temperature: -3.8 },
+				{ time: 1640296800000, value: -4.2 },
+				{ time: 1640297700000, value: -4.3 },
+				{ time: 1640298600000, value: -4.4 },
+				{ time: 1640299500000, value: -4.2 },
+				{ time: 1640300400000, value: -4.2 },
+				{ time: 1640301300000, value: -4.2 },
+				{ time: 1640302200000, value: -0.2 },
+				{ time: 1640303100000, value: -2.9 },
+				{ time: 1640304000000, value: -3.9 },
+				{ time: 1640304900000, value: -4.0 },
+				{ time: 1640305800000, value: -3.9 },
+				{ time: 1640306700000, value: -3.8 },
+				{ time: 1640307600000, value: -3.8 },
 			];
 
-			const actual = alignToWindow(testTemperatureData, 'temperature', 15);
+			const actual = alignToWindow(testData, 15);
 			should(actual).deepEqual(expected);
 		});
 
 		it('Skips the first period when empty', () => {
 			const input = [
-				{ time: 1640296752000, temperature: -4.21 },
-				{ time: 1640297111475, temperature: -4.28 },
-				{ time: 1640297410320, temperature: -4.29 },
-				{ time: 1640297709214, temperature: -4.34 },
+				{ time: 1640296752000, value: -4.21 },
+				{ time: 1640297111475, value: -4.28 },
+				{ time: 1640297410320, value: -4.29 },
+				{ time: 1640297709214, value: -4.34 },
 			];
 
 			const expected = [
-				{ time: 1640296800000, temperature: -4.2 },
-				{ time: 1640297700000, temperature: -4.3 },
+				{ time: 1640296800000, value: -4.2 },
+				{ time: 1640297700000, value: -4.3 },
 			];
 
-			const actual = alignToWindow(input, 'temperature', 15);
+			const actual = alignToWindow(input, 15);
 			should(actual).deepEqual(expected);
 		});
 
 		it('Inserts NaN when a middle period is empty', () => {
 			const input = [
-				{ time: 1640296812283, temperature: -4.21 },
-				{ time: 1640297111475, temperature: -4.28 },
+				{ time: 1640296812283, value: -4.21 },
+				{ time: 1640297111475, value: -4.28 },
 				// Empty period here
-				{ time: 1640298307129, temperature: -4.42 },
-				{ time: 1640298606065, temperature: -4.4 },
-				{ time: 1640298905167, temperature: -4.27 },
+				{ time: 1640298307129, value: -4.42 },
+				{ time: 1640298606065, value: -4.4 },
+				{ time: 1640298905167, value: -4.27 },
 			];
 
 			const expected = [
-				{ time: 1640296800000, temperature: -4.2 },
-				{ time: 1640297700000, temperature: NaN },
-				{ time: 1640298600000, temperature: -4.4 },
+				{ time: 1640296800000, value: -4.2 },
+				{ time: 1640297700000, value: NaN },
+				{ time: 1640298600000, value: -4.4 },
 			];
 
-			const actual = alignToWindow(input, 'temperature', 15);
+			const actual = alignToWindow(input, 15);
 			should(actual).deepEqual(expected);
 		});
 	});
@@ -67,49 +67,49 @@ describe('Clean data', () => {
 	describe('Remove Spikes', () => {
 		it('Removes spiky measurements', () => {
 			// Testing that abnormally large spikes are removed. For example, if
-			// the chip heats up and causes a 2+ Celsius spike in temperature.
+			// the chip heats up and causes a 2+ Celsius spike in value.
 
 			const input = [
-				{ time: 1, temperature: -4.23 },
-				{ time: 2, temperature: -4.14 },
-				{ time: 3, temperature: -4.12 },
-				{ time: 4, temperature: 3.65 }, // Large spike
-				{ time: 5, temperature: -2.05 }, // Smaller, but still big
-				{ time: 6, temperature: -3.12 },
-				{ time: 7, temperature: -3.63 },
+				{ time: 1, value: -4.23 },
+				{ time: 2, value: -4.14 },
+				{ time: 3, value: -4.12 },
+				{ time: 4, value: 3.65 }, // Large spike
+				{ time: 5, value: -2.05 }, // Smaller, but still big
+				{ time: 6, value: -3.12 },
+				{ time: 7, value: -3.63 },
 			];
 			const expected = [
-				{ time: 1, temperature: -4.23 },
-				{ time: 2, temperature: -4.14 },
-				{ time: 3, temperature: -4.12 },
-				{ time: 6, temperature: -3.12 },
-				{ time: 7, temperature: -3.63 },
+				{ time: 1, value: -4.23 },
+				{ time: 2, value: -4.14 },
+				{ time: 3, value: -4.12 },
+				{ time: 6, value: -3.12 },
+				{ time: 7, value: -3.63 },
 			];
 
-			const actual = removeSpikes(input, 'temperature');
+			const actual = removeSpikes(input);
 			should(actual).deepEqual(expected);
 		});
 
 		it('Sorts data', () => {
 			// Same data as earlier test, but rearranged
 			const input = [
-				{ time: 4, temperature: 3.65 }, // Large spike
-				{ time: 1, temperature: -4.23 },
-				{ time: 7, temperature: -3.63 },
-				{ time: 2, temperature: -4.14 },
-				{ time: 6, temperature: -3.12 },
-				{ time: 5, temperature: -2.05 }, // Smaller, but still big
-				{ time: 3, temperature: -4.12 },
+				{ time: 4, value: 3.65 }, // Large spike
+				{ time: 1, value: -4.23 },
+				{ time: 7, value: -3.63 },
+				{ time: 2, value: -4.14 },
+				{ time: 6, value: -3.12 },
+				{ time: 5, value: -2.05 }, // Smaller, but still big
+				{ time: 3, value: -4.12 },
 			];
 			const expected = [
-				{ time: 1, temperature: -4.23 },
-				{ time: 2, temperature: -4.14 },
-				{ time: 3, temperature: -4.12 },
-				{ time: 6, temperature: -3.12 },
-				{ time: 7, temperature: -3.63 },
+				{ time: 1, value: -4.23 },
+				{ time: 2, value: -4.14 },
+				{ time: 3, value: -4.12 },
+				{ time: 6, value: -3.12 },
+				{ time: 7, value: -3.63 },
 			];
 
-			const actual = removeSpikes(input, 'temperature');
+			const actual = removeSpikes(input);
 			should(actual).deepEqual(expected);
 		});
 	});

+ 22 - 22
web-view/src/js/cleanData.ts

@@ -1,6 +1,5 @@
-interface TimeSeries {
-	time:number;
-}
+import {formatTime} from './utils';
+import {TimeSeries} from './types';
 
 /**
  * Align data to a window, using the average value within that period.
@@ -9,14 +8,12 @@ interface TimeSeries {
  *
  * @param data The data to align, with a `time` field as a unix timestamp, and a
  *             data field specified in `dataField`.
- * @param dataField The field containing the data to be averaged.
  * @param period The number of minutes to group on.
  */
-export function alignToWindow<T extends TimeSeries>(
-	data:T[],
-	dataField:string,
+export function alignToWindow(
+	data:TimeSeries[],
 	period:number = 15
-):T[] {
+):TimeSeries[] {
 	const sorted = data.sort((a,b) => a.time - b.time);
 
 	const firstTime = new Date(data[0].time),
@@ -47,8 +44,10 @@ export function alignToWindow<T extends TimeSeries>(
 			// Don't push the first window if it's empty
 			if(windows.length || currNums.length) {
 				// Push the average, or NaN if there are no elements
-				const w = { time: windowTime };
-				w[dataField] = average(currNums);
+				const w = {
+					time: windowTime,
+					value: average(currNums)
+				};
 				windows.push(w);
 			}
 
@@ -57,14 +56,16 @@ export function alignToWindow<T extends TimeSeries>(
 			windowEnd = windowTime + halfPeriodMs;
 			currNums = [];
 		} else {
-			currNums.push(d[dataField]);
+			currNums.push(d.value);
 		}
 	}
 
 	// Catch the ending period
 	if(currNums.length) {
-		const w = { time: windowTime };
-		w[dataField] = average(currNums);
+		const w = {
+			time: windowTime,
+			value: average(currNums)
+		};
 		windows.push(w);
 	}
 
@@ -84,7 +85,6 @@ function average(nums:number[], decimalPlaces:number = 1):number {
  *
  * @param data: The data to remove spikes from, where each object contains a
  *              data field specified in `dataField`.
- * @param dataField: The field containing the data to be compared.
  * @param minChange: The minimum absolute difference between elements before
  *                   it's considered a spike. eg: a difference of 0.1 is 10x an
  *                   earlier diff of 0.01, but isn't a spike. A `minChange` of
@@ -95,12 +95,11 @@ function average(nums:number[], decimalPlaces:number = 1):number {
  *                         is a 2.5x difference. A min multiplier of 3 would
  *                         ignore this.
  */
-export function removeSpikes<T extends TimeSeries>(
-	data:T[],
-	dataField:string,
+export function removeSpikes(
+	data:TimeSeries[],
 	minChange:number = 2,
 	minDiffMultiple:number = 3
-):T[] {
+):TimeSeries[] {
 	data = data.sort((a, b) => a.time - b.time);
 
 	const minLength = 5;
@@ -114,17 +113,18 @@ export function removeSpikes<T extends TimeSeries>(
 
 		// General case for most of an array
 		if(i > 2) {
-			prevDiff = Math.abs(data[i - 1][dataField] - data[i - 2][dataField]);
-			thisDiff = Math.abs(data[i][dataField] - data[i - 1][dataField]);
+			prevDiff = Math.abs(data[i - 1].value - data[i - 2].value);
+			thisDiff = Math.abs(data[i].value - data[i - 1].value);
 		} else {
 			// First couple of elements
-			prevDiff = Math.abs(data[i + 1][dataField] - data[i + 2][dataField]);
-			thisDiff = Math.abs(data[i][dataField] - data[i + 1][dataField]);
+			prevDiff = Math.abs(data[i + 1].value - data[i + 2].value);
+			thisDiff = Math.abs(data[i].value - data[i + 1].value);
 		}
 
 		const tooBig = (thisDiff - prevDiff) > minChange
 					&& (thisDiff / prevDiff) > minDiffMultiple;
 		if(tooBig) {
+			console.log(`Removing spike element ${formatTime(data[i].time)}: Value: ${data[i].value}; prevDiff: ${prevDiff}; thisDiff: ${thisDiff}`);
 			data = removeElement(data, i);
 			i--; // Removed the element, make sure we don't skip the next
 		}

+ 37 - 0
web-view/src/js/consts.ts

@@ -0,0 +1,37 @@
+export type FieldInfo = {
+	key: string,
+	label: string,
+	unit: string,
+	colour: string,
+	canSpike: boolean
+}
+
+export const FIELDS: FieldInfo[] = [
+	{
+		key: 'temperature',
+		label: 'Temperature',
+		unit: '*C',
+		colour: 'rgb(255, 0, 0)',
+		canSpike: true
+	}, {
+		key: 'humidity',
+		label: 'Relative Humidity',
+		unit: '%',
+		colour: 'rgb(0, 255, 255)',
+		canSpike: false // Affected by temperature, so can spike
+	}, {
+		key: 'pressure',
+		label: 'Air Pressure',
+		unit: 'hPa',
+		colour: 'rgb(0, 255, 0)',
+		canSpike: false
+	}, {
+		key: 'light',
+		label: 'Light',
+		unit: '',
+		colour: 'rgb(255, 255, 0)',
+		canSpike: false // Does spike, but it doesn't matter
+	},
+];
+
+export const FIELD_KEYS: string[] = FIELDS.map(f => f.key);

+ 69 - 0
web-view/src/js/graphs.ts

@@ -0,0 +1,69 @@
+import { Chart, registerables } from 'chart.js';
+import {TimeSeries, Values} from './types';
+import {FieldInfo, FIELDS} from './consts';
+import {formatTime} from './utils';
+Chart.register(...registerables);
+
+const CANVAS_HEIGHT = 100,
+	CANVAS_WIDTH = 200;
+
+export function createGraphs(
+	data:Values,
+	parentElement:HTMLElement
+) {
+	for(let field of FIELDS) {
+		const node = addCanvas(
+			data[field.key],
+			parentElement,
+			field
+		);
+		createGraph(
+			data[field.key],
+			node,
+			field
+		);
+	}
+}
+
+function addCanvas(
+	data:TimeSeries[],
+	parentElement:HTMLElement,
+	fieldInfo:FieldInfo
+):HTMLCanvasElement {
+	const div = document.createElement('div');
+	div.className = 'graph-container';
+
+	const header = document.createElement('h2');
+	header.textContent = fieldInfo.label;
+
+	const canvas = document.createElement('canvas');
+	canvas.height = CANVAS_HEIGHT;
+	canvas.width = CANVAS_WIDTH;
+	canvas.classList.add('time-series', fieldInfo.key);
+
+	div.append(header, canvas);
+
+	parentElement.appendChild(div);
+	return canvas;
+}
+
+function createGraph(
+	data:TimeSeries[],
+	canvas:HTMLCanvasElement,
+	fieldInfo:FieldInfo
+) {
+	let config:any = {
+		type: 'line',
+		data: {
+			labels: data.map(d => formatTime(d.time)),
+			datasets: [{
+				label: `${fieldInfo.label} (${fieldInfo.unit})`,
+				data: data.map(d => d.value),
+				backgroundColor: fieldInfo.colour, // Colour of the dots
+				borderColor: fieldInfo.colour // Colour of the line
+			}]
+		},
+		options: {}
+	};
+	new Chart(canvas, config);
+}

+ 17 - 66
web-view/src/js/main.ts

@@ -1,36 +1,30 @@
 import {firebaseConfig} from './config';
 import {initializeApp} from 'firebase/app';
 import {getDatabase, onValue, orderByChild, query, ref, startAt} from 'firebase/database';
-import { Chart, registerables } from 'chart.js';
-Chart.register(...registerables);
 
-import {testTemperatureData} from './testData';
-import {alignToWindow, removeSpikes} from './cleanData';
 
-type Reading = {
-	temperature: number,
-	humidity: number,
-	light: number,
-	pressure: number,
-	time: Date
-};
+import {Reading} from './types';
+import {createGraphs} from './graphs';
+import {processData} from './processData';
 
 const app = initializeApp(firebaseConfig);
 const rtdb = getDatabase(app);
-const data = new Map<Date, Reading>();
 
 document.body.onload = init;
 
-let canvas:HTMLCanvasElement;
-
 async function init() {
-	await getRecentData();
+	const readings = await getRecentData(12);
+	const values = processData(readings);
 
-	canvas = document.getElementById('temperature') as HTMLCanvasElement;
-	createChart();
+	createGraphs(
+		values,
+		document.getElementById('time-series-graphs')
+	);
 }
 
-async function getRecentData(hoursAgo:number = 2) {
+async function getRecentData(
+	hoursAgo:number = 2
+): Promise<Reading[]> {
 	const since = new Date().getTime() - (hoursAgo * 3600 * 1000);
 
 	const outdoors = ref(rtdb, 'outdoor');
@@ -40,60 +34,17 @@ async function getRecentData(hoursAgo:number = 2) {
 		startAt(since)
 	);
 
-	return new Promise<void>((res, err) => {
-		let values = [];
+	return new Promise<Reading[]>((res, err) => {
+		let values:Reading[] = [];
 
 		onValue(search, snapshot => {
 			snapshot.forEach(child => {
 				values.push(child.val());
 			});
 
-			console.log(values);
-
-			// TODO: Move this to its own function
-			values = removeSpikes(values, 'temperature');
-			values = alignToWindow(values, 'temperature');
-
-			values.forEach(v => {
-				const t = new Date(v.time);
-				v.time = t;
-				data.set(t, v);
-			});
-
-			console.log(Array.from(data.values())
-				.map(d => {
-					return {
-						time: d.time.getTime(),
-						temperature: d.temperature
-					}
-				}));
-			res();
+			res(values);
+		}, {
+			onlyOnce: true
 		});
 	});
 }
-
-function createChart() {
-	let config:any = {
-		type: 'line',
-		data: {
-			labels: Array.from(data.keys())
-				.sort((a,b) => a.getTime() - b.getTime())
-				.map(formatTime),
-			datasets: [{
-				data: Array.from(data.values()).map(d => d.temperature),
-				backgroundColor: 'rgb(255, 99, 132)', // Colour of the dots
-				borderColor: 'rgb(255, 99, 132)' // Colour of the line
-			}]
-		},
-		options: {}
-	};
-	console.log(config);
-	const chart = new Chart(canvas, config);
-}
-
-function formatTime(date:Date):string {
-	return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
-	function pad(num:number):string {
-		return num < 10 ? `0${num}` : num.toString();
-	}
-}

+ 52 - 0
web-view/src/js/processData.test.ts

@@ -0,0 +1,52 @@
+import {Reading, Values} from './types';
+import {splitData} from './processData';
+import should = require('should');
+
+describe('Process Data', () => {
+	it('Splits data into parts', () => {
+		const input:Reading[] = createTestData(1);
+
+		const expected:Values = {
+			temperature: [{
+				value: input[0].temperature,
+				time: input[0].time
+			}],
+			humidity: [{
+				value: input[0].humidity,
+				time: input[0].time
+			}],
+			pressure: [{
+				value: input[0].pressure,
+				time: input[0].time
+			}],
+			light: [{
+				value: input[0].light,
+				time: input[0].time
+			}],
+		};
+
+		const actual = splitData(input);
+		should(actual).deepEqual(expected);
+	});
+});
+
+function createTestData(
+	numElement:number,
+	temperature:number = 5.2,
+	humidity:number = 45.5,
+	pressure:number = 995.6,
+	light:number = 120,
+	timeIncrement:number = 900000 // 15 minutes
+) {
+	let testData:Reading[] = [];
+	for(let i = 0; i < numElement; i++) {
+		testData.push({
+			temperature,
+			humidity,
+			pressure,
+			light,
+			time: (i+1) * timeIncrement
+		});
+	}
+	return testData;
+}

+ 78 - 0
web-view/src/js/processData.ts

@@ -0,0 +1,78 @@
+import {Reading, TimeSeries, Values} from './types';
+import {alignToWindow, removeSpikes} from './cleanData';
+import {FIELD_KEYS, FIELDS} from './consts';
+
+export type ProcessingConfig = {
+	/** The number of minutes to group on. */
+	alignWindow: number,
+
+	/**
+	 * The minimum absolute difference between elements before
+	 * it's considered a spike. eg: a difference of 0.1 is 10x an
+	 * earlier diff of 0.01, but isn't a spike. A `minChange` of
+	 * 2 would ignore this.
+	 */
+	minSpikeChange: number,
+
+	/**
+	 * The minimum multiplier of differences between sibling
+	 * elements before it's considered a spike. eg: The
+	 * previous diff was 1.5*C, current diff is 3.8*C, this
+	 * is a 2.5x difference. A min multiplier of 3 would
+	 * ignore this.
+	 */
+	minSpikeMultiplier: number
+};
+
+const defaultConfig:ProcessingConfig = {
+	alignWindow: 15,
+	minSpikeChange: 2,
+	minSpikeMultiplier: 3
+};
+
+/**
+ * Fully process the data into a Values object, including removing spikes,
+ * and aligning to a window.
+ *
+ * @param data: The readings to process.
+ * @param config: Configuration to use.
+ */
+export function processData(
+	data:Reading[],
+	config?:ProcessingConfig
+):Values {
+	// Note: This is not tested, as everything it calls is tested, and setting up
+	// an expected output would be very cumbersome.
+
+	config = config || defaultConfig;
+
+	const values:Values = splitData(data);
+
+	for(let field of FIELDS) {
+		const key = field.key;
+		let series:TimeSeries[] = values[key];
+		series = field.canSpike
+			? removeSpikes(series, config.minSpikeChange, config.minSpikeMultiplier)
+			: series;
+		series = alignToWindow(series, config.alignWindow);
+		values[key] = series;
+	}
+
+	return values;
+}
+
+export function splitData(data:Reading[]): Values {
+	const ret = {};
+	for(let field of FIELD_KEYS) {
+		ret[field] = data.map(d => splitValue(d, field));
+	}
+
+	return ret as Values;
+}
+
+function splitValue(reading:Reading, field:string) {
+	return {
+		value: reading[field],
+		time: reading.time
+	}
+}

+ 36 - 36
web-view/src/js/testData.ts

@@ -1,144 +1,144 @@
-export const testTemperatureData = [
+export const testData = [
 	{
 		// 2021-12-23 17:00:12 EST
 		"time": 1640296812283,  // 2021-12-23 22:00:12 UTC
-		"temperature": -4.21
+		"value": -4.21
 	},
 	{
 		"time": 1640297111475,  // 2021-12-23 22:05:11 UTC
-		"temperature": -4.28
+		"value": -4.28
 	},
 	{
 		"time": 1640297410320,  // 2021-12-23 22:10:10 UTC
-		"temperature": -4.29
+		"value": -4.29
 	},
 	{
 		"time": 1640297709214,  // 2021-12-23 22:15:09 UTC
-		"temperature": -4.34
+		"value": -4.34
 	},
 	{
 		"time": 1640298008202,  // 2021-12-23 22:20:08 UTC
-		"temperature": -4.38
+		"value": -4.38
 	},
 	{
 		"time": 1640298307129,  // 2021-12-23 22:25:07 UTC
-		"temperature": -4.42
+		"value": -4.42
 	},
 	{
 		"time": 1640298606065,  // 2021-12-23 22:30:06 UTC
-		"temperature": -4.4
+		"value": -4.4
 	},
 	{
 		"time": 1640298905167,  // 2021-12-23 22:35:05 UTC
-		"temperature": -4.27
+		"value": -4.27
 	},
 	{
 		"time": 1640299204040,  // 2021-12-23 22:40:04 UTC
-		"temperature": -4.27
+		"value": -4.27
 	},
 	{
 		"time": 1640299502889,  // 2021-12-23 22:45:02 UTC
-		"temperature": -4.19
+		"value": -4.19
 	},
 	{
 		"time": 1640299801914,  // 2021-12-23 22:50:01 UTC
-		"temperature": -4.27
+		"value": -4.27
 	},
 	{
 		"time": 1640300101062,  // 2021-12-23 22:55:01 UTC
-		"temperature": -4.15
+		"value": -4.15
 	},
 	{
 		"time": 1640300400244,  // 2021-12-23 23:00:00 UTC
-		"temperature": -4.14
+		"value": -4.14
 	},
 	{
 		"time": 1640300699251,  // 2021-12-23 23:04:59 UTC
-		"temperature": -4.22
+		"value": -4.22
 	},
 	{
 		"time": 1640300998345,  // 2021-12-23 23:09:58 UTC
-		"temperature": -4.21
+		"value": -4.21
 	},
 	{
 		"time": 1640301297811,  // 2021-12-23 23:14:57 UTC
-		"temperature": -4.23
+		"value": -4.23
 	},
 	{
 		"time": 1640301596323,  // 2021-12-23 23:19:56 UTC
-		"temperature": -4.14
+		"value": -4.14
 	},
 	{
 		"time": 1640301917376,  // 2021-12-23 23:25:17 UTC
-		"temperature": -4.12
+		"value": -4.12
 	},
 	{
 		"time": 1640302550740,  // 2021-12-23 23:35:50 UTC
-		"temperature": 3.65
+		"value": 3.65
 	},
 	{
 		"time": 1640302829882,  // 2021-12-23 23:40:29 UTC
-		"temperature": -2.05
+		"value": -2.05
 	},
 	{
 		"time": 1640303128543,  // 2021-12-23 23:45:28 UTC
-		"temperature": -3.12
+		"value": -3.12
 	},
 	{
 		"time": 1640303427668,  // 2021-12-23 23:50:27 UTC
-		"temperature": -3.63
+		"value": -3.63
 	},
 	{
 		"time": 1640303726124,  // 2021-12-23 23:55:26 UTC
-		"temperature": -3.79
+		"value": -3.79
 	},
 	{
 		"time": 1640304024921,  // 2021-12-24 00:00:24 UTC
-		"temperature": -3.86
+		"value": -3.86
 	},
 	{
 		"time": 1640304324159,  // 2021-12-24 00:05:24 UTC
-		"temperature": -3.95
+		"value": -3.95
 	},
 	{
 		"time": 1640304622979,  // 2021-12-24 00:10:22 UTC
-		"temperature": -4
+		"value": -4
 	},
 	{
 		"time": 1640304921711,  // 2021-12-24 00:15:21 UTC
-		"temperature": -3.97
+		"value": -3.97
 	},
 	{
 		"time": 1640305220695,  // 2021-12-24 00:20:20 UTC
-		"temperature": -4.03
+		"value": -4.03
 	},
 	{
 		"time": 1640305519650,  // 2021-12-24 00:25:19 UTC
-		"temperature": -3.92
+		"value": -3.92
 	},
 	{
 		"time": 1640305818511,  // 2021-12-24 00:30:18 UTC
-		"temperature": -3.85
+		"value": -3.85
 	},
 	{
 		"time": 1640306117558,  // 2021-12-24 00:35:17 UTC
-		"temperature": -3.88
+		"value": -3.88
 	},
 	{
 		"time": 1640306418615,  // 2021-12-24 00:40:18 UTC
-		"temperature": -3.89
+		"value": -3.89
 	},
 	{
 		"time": 1640306715454,  // 2021-12-24 00:45:15 UTC
-		"temperature": -3.79
+		"value": -3.79
 	},
 	{
 		"time": 1640307014557,  // 2021-12-24 00:50:14 UTC
-		"temperature": -3.82
+		"value": -3.82
 	},
 	{
 		// 2021-12-23 19:55:13 EST
 		"time": 1640307313521,  // 2021-12-24 00:55:13 UTC
-		"temperature": -3.8
+		"value": -3.8
 	}
 ];

+ 33 - 0
web-view/src/js/types.d.ts

@@ -0,0 +1,33 @@
+/**
+ * Record of a reading from the weather station.
+ * Effectively, the raw data from the database.
+ */
+export type Reading = {
+	temperature: number,
+	humidity: number,
+	light: number,
+	pressure: number,
+
+	/** Unix timestamp of when this data was recorded. */
+	time: number
+};
+
+/**
+ * A single point of data within a time series.
+ */
+export type TimeSeries = {
+	value: number,
+	time: number
+};
+
+/**
+ * A collection of time series, for each measurement that is taken.
+ * Used to prevent processing (eg: spike removal) on one measurement from
+ * affecting another.
+ */
+export type Values = {
+	temperature: TimeSeries[],
+	humidity: TimeSeries[],
+	light: TimeSeries[],
+	pressure: TimeSeries[]
+}

+ 14 - 0
web-view/src/js/utils.ts

@@ -0,0 +1,14 @@
+/**
+ * Get the time (in localtime) for the date, padded properly with zeros.
+ * @param date
+ */
+export function formatTime(date:Date|number):string {
+	if(typeof date === 'number') {
+		date = new Date(date);
+	}
+
+	return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
+	function pad(num:number):string {
+		return num < 10 ? `0${num}` : num.toString();
+	}
+}