Merge branch 'feat/33-frontend-plots'

Frontend plot

See merge request team-raclette/project-softweng!14
This commit is contained in:
Yann Sierro
2025-05-23 20:57:11 +00:00
11 changed files with 281 additions and 161 deletions

4
.gitignore vendored
View File

@@ -91,7 +91,11 @@ fabric.properties
# Android studio 3.1+ serialized cache file # Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser .idea/caches/build_file_checksums.ser
# Do not add specific ide files
.idea/*
config.json config.json
.env .env
# for local config
setup_env.sh setup_env.sh

View File

@@ -1,19 +1,65 @@
# web-app # web-app
This is a web application that uses Vue.js and Chart.js to display data from a database. The data is fetched from the database using an API and displayed in a chart.
## Project setup ## Usage
The web page contains two buttons:
- **New measurments**: fetch the data from the database and add it to the chart.
- **Fetch measurments**: ask for a new measurement at the currrent time.
It has too 3 selectors:
- **User**: select the user to fetch the data from.
- **Room**: select the room to fetch the data from.
- **Device**: select the device to fetch the data from.
The chart is updated when the web page is loaded with default values for the selectors.
The chart can be updated by clicking on the **New measurments** button after selecting the user, room and device. But, the application doesn't care if the combination of user, room and device is valid or not. It will just fetch the data from the database and plot it on the chart.
## Environment setup
Install Node.js version v22.15.0 from [Node.js](https://nodejs.org/en/download/releases/) (LTS version).
Verify the installation by running the following command in your terminal:
```terminal
node --version
npm --version
``` ```
With the following output:
```result
v22.15.0
10.9.2
```
Then install the project dependencies by running:
```terminal
npm install npm install
``` ```
### Compiles and hot-reloads for development Setup the environment:
linux:
```terminal
export VUE_APP_API_KEY=your_api_key
export VUE_APP_API_SECRET=your_api_secret
``` ```
windows:
```terminal
# use wsl ...
```
### Compile and run for development
```terminal
npm run serve npm run serve
``` ```
### Compiles and minifies for production ### Compile and minifie for production
``` ```terminal
npm run build npm run build
``` ```
### Customize configuration ### Links
See [Configuration Reference](https://cli.vuejs.org/config/). See [Configuration Reference](https://cli.vuejs.org/config/).\
See [Vue.js](https://vuejs.org/guide/introduction.html).\
See [chartjs](https://www.chartjs.org/docs/latest/).\
See [vue-chartjs](https://vue-chartjs.org/).

View File

@@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"axios": "^1.9.0", "axios": "^1.9.0",
"chartjs": "^0.3.24", "chart.js": "^4.4.9",
"chartjs-adapter-date-fns": "^3.0.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",
@@ -371,8 +372,7 @@
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@leichtgewicht/ip-codec": { "node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5", "version": "2.0.5",
@@ -2114,7 +2114,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -2122,11 +2121,15 @@
"pnpm": ">=8" "pnpm": ">=8"
} }
}, },
"node_modules/chartjs": { "node_modules/chartjs-adapter-date-fns": {
"version": "0.3.24", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs/-/chartjs-0.3.24.tgz", "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-h6G9qcDqmFYnSWqjWCzQMeOLiypS+pM6Fq2Rj7LPty8Kjx5yHonwwJ7oEHImZpQ2u9Pu36XGYfardvvBiQVrhg==", "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT" "license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
}, },
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
@@ -2904,6 +2907,17 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debounce": { "node_modules/debounce": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",

View File

@@ -8,7 +8,8 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.9.0", "axios": "^1.9.0",
"chartjs": "^0.3.24", "chart.js": "^4.4.9",
"chartjs-adapter-date-fns": "^3.0.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",

View File

@@ -9,24 +9,12 @@
<div class="main-content"> <div class="main-content">
<!-- Left Sidebar with Controls --> <!-- Left Sidebar with Controls -->
<div class="sidebar"> <div class="sidebar">
<ControlPanel <ControlPanel :manager="manager" />
:show-temperature.sync="showTemperature"
:show-humidity.sync="showHumidity"
@add-temperature="addTemperature"
@refresh="refreshData"
:manager="manager"
/>
</div> </div>
<!-- Right Side Chart Area --> <!-- Right Side Chart Area -->
<div class="chart-area"> <div class="chart-area">
<ChartComponent <ChartComponent :manager="manager" />
:series="series"
:show-temperature="showTemperature"
:show-humidity="showHumidity"
:loading="loading"
:error="error"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -38,7 +26,7 @@ import ChartComponent from "./components/ChartComponent.vue";
import ControlPanel from "./components/ControlPanel.vue"; import ControlPanel from "./components/ControlPanel.vue";
import { HttpClient } from "./Services/HttpClient"; import { HttpClient } from "./Services/HttpClient";
import { TimeSeriesManager } from "./Measures/TimeSeriesManager"; import { TimeSeriesManager } from "./Measures/TimeSeriesManager";
import { URL, USERNAME, PASSWORD, USER, ROOM, DEVICE, APP_NAME } from "./const"; import { URL, USERNAME, PASSWORD, APP_NAME } from "./const";
import { Serie } from "./Measures/Serie"; import { Serie } from "./Measures/Serie";
let httpClient = new HttpClient(URL, USERNAME, PASSWORD); let httpClient = new HttpClient(URL, USERNAME, PASSWORD);
@@ -53,53 +41,8 @@ export default defineComponent({
data() { data() {
return { return {
manager, manager,
series: [] as any[],
loading: true,
error: undefined as string | undefined,
showTemperature: true,
showHumidity: true,
}; };
}, },
methods: {
addTemperature() {
// Implement your functionality here
console.log("Add temperature clicked");
manager
.getNewValue(USER, ROOM, DEVICE)
.then((result) => {})
.catch((error) => {
console.error("Error asking data:", error);
this.error = "Failed to load data. Please try again later.";
this.loading = false;
});
},
refreshData() {
console.log("Refreshing data...");
this.fetchData();
},
fetchData() {
this.loading = true;
this.error = undefined;
manager
.getTimeSeriesData(USER, ROOM, DEVICE)
.then((result) => {
this.series = result;
this.loading = false;
})
.catch((error) => {
console.error("Error fetching data:", error);
this.error = "Failed to load data. Please try again later.";
this.loading = false;
});
},
},
mounted() {
// Fetch data when component is mounted
this.fetchData();
},
}); });
</script> </script>

View File

@@ -1,8 +1,10 @@
import { TEMPERATURE, HUMIDITY, TYPE, VALUE } from "../const"; import { TEMPERATURE, HUMIDITY, TYPE, VALUE } from "../const";
import { Colors } from "./Utils";
export class Serie { export class Serie {
private _type: string; private _type: string;
private _data: { time: number; value: number }[]; private _data: { time: number; value: Date }[];
private _user: string; private _user: string;
private _room: string; private _room: string;
@@ -10,7 +12,7 @@ export class Serie {
constructor( constructor(
type: string, type: string,
data: { time: number; value: number }[], data: { time: number; value: Date }[],
user: string, user: string,
room: string, room: string,
device: string device: string
@@ -22,8 +24,14 @@ export class Serie {
this._device = device; this._device = device;
} }
public getLabel(): string { public getLabel(): String {
return `${this._type} - ${this._user} - ${this._room} - ${this._device}`; if (this._type === TEMPERATURE) {
return "Temperature [°C]";
} else if (this._type === HUMIDITY) {
return "Humidity [%]";
} else {
return "Unknown";
}
} }
public getSerie(): any { public getSerie(): any {
@@ -33,9 +41,10 @@ export class Serie {
data: this._data.map((v: any) => { data: this._data.map((v: any) => {
return { x: v.time, y: v.value }; return { x: v.time, y: v.value };
}), }),
borderColor: "rgba(255, 99, 132, 1)", borderColor: Colors.BLUE,
backgroundColor: "rgba(255, 99, 132, 0.2)", backgroundColor: Colors.BLUE,
borderWidth: 1, borderWidth: 1,
yAxisID: TEMPERATURE,
}; };
} else if (this._type === HUMIDITY) { } else if (this._type === HUMIDITY) {
return { return {
@@ -43,9 +52,21 @@ export class Serie {
data: this._data.map((v: any) => { data: this._data.map((v: any) => {
return { x: v.time, y: v.value }; return { x: v.time, y: v.value };
}), }),
borderColor: "rgba(54, 162, 235, 1)", borderColor: Colors.GREEN,
backgroundColor: "rgba(54, 162, 235, 0.2)", backgroundColor: Colors.GREEN,
borderWidth: 1, borderWidth: 1,
yAxisID: HUMIDITY,
};
} else {
return {
label: "Unknown",
data: this._data.map((v: any) => {
return { x: v.time, y: v.value };
}),
borderColor: Colors.RED,
backgroundColor: Colors.RED,
borderWidth: 1,
yAxisID: TEMPERATURE,
}; };
} }
} }

View File

@@ -12,12 +12,12 @@ export class TimeSeriesManager {
tag: "remi", tag: "remi",
}; };
selected_room = { selected_room = {
room: "Bedroom", room: "Terrasse",
tag: "Bedroom", tag: "Terrasse",
}; };
selected_device = { selected_device = {
device: "Door sensor", device: "Shed",
tag: "DoorSensor", tag: "Shed",
}; };
user_options = [ user_options = [
@@ -57,16 +57,17 @@ export class TimeSeriesManager {
}, },
]; ];
error = ref(false);
loading = ref("");
series = ref<Serie[]>([]);
constructor(client: HttpClient) { constructor(client: HttpClient) {
this.client = client; this.client = client;
} }
async getTimeSeriesData( async getTimeSeriesData() {
user: string, this.client
room: string,
device: string
): Promise<Serie[]> {
return this.client
.getValues( .getValues(
this.selected_user.tag, this.selected_user.tag,
this.selected_room.tag, this.selected_room.tag,
@@ -112,20 +113,20 @@ export class TimeSeriesManager {
const temperatureSerie = new Serie( const temperatureSerie = new Serie(
TEMPERATURE, TEMPERATURE,
temperatureRecordsProcessed, temperatureRecordsProcessed,
user, this.selected_user.user,
room, this.selected_room.room,
device this.selected_device.device
); );
const humiditySerie = new Serie( const humiditySerie = new Serie(
HUMIDITY, HUMIDITY,
humidityRecordProcessed, humidityRecordProcessed,
user, this.selected_user.user,
room, this.selected_room.room,
device this.selected_device.device
); );
return [temperatureSerie, humiditySerie]; this.series.value = [temperatureSerie, humiditySerie];
}) })
.catch((error) => { .catch((error) => {
console.error("Error fetching time series data:", error); console.error("Error fetching time series data:", error);
@@ -133,10 +134,16 @@ export class TimeSeriesManager {
}); });
} }
async getNewValue(user: string, room: string, device: string) { async getNewValue() {
this.client.newValue(user, room, device).catch((error) => { this.client
console.error("Error asking new values:", error); .newValue(
throw error; this.selected_user.user,
}); this.selected_room.room,
this.selected_device.device
)
.catch((error) => {
console.error("Error asking new values:", this.selected_device.device);
throw error;
});
} }
} }

View File

@@ -0,0 +1,18 @@
export const Colors = {
RED: "rgb(255, 99, 132)", // Red
LIGHT_RED: "rgba(255, 99, 132, 0.2)",
BLUE: "rgb(54, 162, 235)", // Blue
LIGHT_BLUE: "rgba(54, 162, 235, 0.2)",
YELLOW: "rgb(255, 206, 86)", // Yellow
LIGHT_YELLOW: "rgba(255, 206, 86, 0.2)",
GREEN: "rgb(56, 193, 114)", // Green
DARK_GREEN: "rgb(45, 153, 91)",
ORANGE: "rgb(246, 153, 63)", // Orange
DARK_ORANGE: "rgb(230, 126, 34)",
DARK_BLUE: "rgb(39, 121, 189)", // Dark Blue
};

View File

@@ -1,8 +1,8 @@
<!-- src/components/ChartComponent.vue --> <!-- src/components/ChartComponent.vue -->
<template> <template>
<div class="chart-wrapper"> <div class="chart-wrapper">
<div v-if="loading" class="loading-state">Loading data...</div> <div v-if="manager.loading" class="loading-state">Loading data...</div>
<div v-else-if="error" class="error-state">{{ error }}</div> <div v-else-if="manager.error" class="error-state">{{ manager.error }}</div>
<div v-else class="chart-container"> <div v-else class="chart-container">
<Scatter <Scatter
v-if="chartData.datasets.length > 0" v-if="chartData.datasets.length > 0"
@@ -12,6 +12,7 @@
<div v-else class="no-data-state">No data to display</div> <div v-else class="no-data-state">No data to display</div>
</div> </div>
</div> </div>
<br />
</template> </template>
<script lang="ts"> <script lang="ts">
@@ -23,12 +24,25 @@ import {
LineElement, LineElement,
Tooltip, Tooltip,
Legend, Legend,
TimeScale,
ChartOptions,
ScatterDataPoint,
} from "chart.js"; } from "chart.js";
import "chartjs-adapter-date-fns"; // Import the date adapter
import { Scatter } from "vue-chartjs"; import { Scatter } from "vue-chartjs";
import { Serie } from "../Measures/Serie"; import { Serie } from "../Measures/Serie";
import { TimeSeriesManager } from "../Measures/TimeSeriesManager";
import { Colors } from "../Measures/Utils";
// Register Chart.js components // Register Chart.js components
ChartJS.register(LinearScale, PointElement, LineElement, Tooltip, Legend); ChartJS.register(
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
TimeScale
);
export default defineComponent({ export default defineComponent({
name: "ChartComponent", name: "ChartComponent",
@@ -36,41 +50,87 @@ export default defineComponent({
Scatter, Scatter,
}, },
props: { props: {
series: { manager: {
type: Array as PropType<Serie[]>, // Use PropType to specify array of Serie type: Object as PropType<TimeSeriesManager>,
default: () => [], required: true,
},
showTemperature: {
type: Boolean,
default: true,
},
showHumidity: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String as PropType<string | undefined>,
default: null,
}, },
}, },
setup(props) { setup(props) {
const chartData = computed(() => { const chartData = computed(() => {
let series_prepared = props.series.map((s: Serie) => { if ((props.manager as TimeSeriesManager).series.value as Serie[]) {
return s.getSerie(); let series_prepared = (props.manager.series.value as Serie[]).map(
}); (s: Serie) => {
return s.getSerie();
}
);
return { return {
datasets: series_prepared, datasets: series_prepared,
}; };
} else {
return {
datasets: [],
};
}
}); });
const chartOptions = { const chartOptions: ChartOptions<"scatter"> = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: true,
scales: {
x: {
type: "time",
position: "bottom",
title: {
text: "Time",
display: true,
},
time: {
unit: "minute", // Adjust the unit as needed (e.g., "minute", "hour", "day")
displayFormats: {
second: "HH:mm:ss", // Format for seconds
minute: "MMM-dd HH:mm", // Format for minutes
hour: "MMM dd HH:mm", // Format for hours
day: "MMM dd", // Format for days
},
},
grid: {
drawOnChartArea: true, // only want the grid lines for one axis to show up
},
},
temperature: {
type: "linear", // only linear but allow scale type registration. This allows extensions to exist solely for log scale for instance
position: "left",
title: {
text: "Temperature [°C]",
display: true,
},
ticks: {
color: Colors.BLUE,
},
grid: {
drawOnChartArea: true, // only want the grid lines for one axis to show up
},
min: -10,
max: 40,
},
humidity: {
type: "linear", // only linear but allow scale type registration. This allows extensions to exist solely for log scale for instance
position: "right",
title: {
text: "Humidity [%]",
display: true,
},
ticks: {
color: Colors.GREEN,
},
grid: {
drawOnChartArea: true, // only want the grid lines for one axis to show up
},
min: 0,
max: 100,
},
},
}; };
return { return {

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="control-panel"> <div class="control-panel">
<button @click="$emit('add-temperature')" class="action-button"> <button @click="newMeasurments" class="action-button">
Add Temperature New Measurments
</button> </button>
<button @click="$emit('refresh')" class="refresh-button"> <button @click="fetchData" class="refresh-button">
<span class="button-icon"></span> Refresh Data Fetch Measurments
</button> </button>
</div> </div>
@@ -57,40 +57,41 @@ import "vue-multiselect/dist/vue-multiselect.css"; // Add
import { ref } from "vue"; import { ref } from "vue";
import { TimeSeriesManager } from "@/Measures/TimeSeriesManager"; import { TimeSeriesManager } from "@/Measures/TimeSeriesManager";
let options = [{ code: "CA", country: "Canada" }];
export default defineComponent({ export default defineComponent({
name: "ControlPanel", name: "ControlPanel",
props: { props: {
showTemperature: {
type: Boolean,
required: true,
},
showHumidity: {
type: Boolean,
required: true,
},
manager: { manager: {
type: TimeSeriesManager, type: TimeSeriesManager,
required: true, required: true,
} },
}, },
emits: [
"add-temperature",
"update:showTemperature",
"update:showHumidity",
"refresh",
"simulate-data",
],
components: { components: {
Multiselect, Multiselect,
}, },
methods: { methods: {
setSelected(value: any) { newMeasurments() {
// trigger a mutation, or dispatch an action // Implement your functionality here
console.log("New measurments clicked");
this.manager
.getNewValue()
.then((result) => {})
.catch((error) => {
console.error("Error asking data:", error);
});
}, },
fetchData() {
console.log("Fetch measurments clicked");
this.manager
.getTimeSeriesData()
.then((result) => {})
.catch((error) => {
console.error("Error fetching data:", error);
});
},
},
mounted() {
this.fetchData();
}, },
}); });
</script> </script>

5
web-app/vue.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
devServer: {
allowedHosts: 'all'
}
}