feat(web-app): plot humidity and temperature
This commit is contained in:
@@ -1,53 +1,146 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="text-align: center">
|
<div class="app-container">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<header class="header">
|
||||||
<h1>Home Monitor</h1>
|
<h1>Home Monitor</h1>
|
||||||
<br />
|
</header>
|
||||||
<!-- <Scatter :data="chartSeries" :options="options" /> -->
|
|
||||||
<button @click="addTemperature">Add Temperature</button>
|
<!-- Main Content Section -->
|
||||||
|
<div class="main-content">
|
||||||
|
<!-- Left Sidebar with Controls -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<ControlPanel
|
||||||
|
:show-temperature.sync="showTemperature"
|
||||||
|
:show-humidity.sync="showHumidity"
|
||||||
|
@add-temperature="addTemperature"
|
||||||
|
@refresh="refreshData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side Chart Area -->
|
||||||
|
<div class="chart-area">
|
||||||
|
<ChartComponent
|
||||||
|
:series="series"
|
||||||
|
:show-temperature="showTemperature"
|
||||||
|
:show-humidity="showHumidity"
|
||||||
|
:loading="loading"
|
||||||
|
:error="error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import {
|
import ChartComponent from "./components/ChartComponent.vue";
|
||||||
Chart as ChartJS,
|
import ControlPanel from "./components/ControlPanel.vue";
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
} from "chart.js";
|
|
||||||
import { Scatter } from "vue-chartjs";
|
|
||||||
|
|
||||||
import { HttpClient } from "./Services/HttpClient";
|
import { HttpClient } from "./Services/HttpClient";
|
||||||
import { TimeSeriesManager } from "./TimeSeriesManager";
|
import { TimeSeriesManager } from "./TimeSeriesManager";
|
||||||
import { URL, USERNAME, PASSWORD, USER, ROOM, DEVICE, APP_NAME } from "./const";
|
import { URL, USERNAME, PASSWORD, USER, ROOM, DEVICE, APP_NAME } from "./const";
|
||||||
|
import { Serie } from "./Measures/Serie";
|
||||||
|
|
||||||
const httpClient = new HttpClient(URL, USERNAME, PASSWORD);
|
let httpClient = new HttpClient(URL, USERNAME, PASSWORD);
|
||||||
const manager = new TimeSeriesManager(httpClient);
|
let manager = new TimeSeriesManager(httpClient);
|
||||||
console.log(manager.getTimeSeriesData(USER, ROOM, DEVICE));
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: APP_NAME,
|
name: APP_NAME,
|
||||||
components: {},
|
components: {
|
||||||
|
ChartComponent,
|
||||||
|
ControlPanel,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
manager,
|
manager,
|
||||||
|
series: [] as any[],
|
||||||
|
loading: true,
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
showTemperature: true,
|
||||||
|
showHumidity: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
addTemperature() {},
|
addTemperature() {
|
||||||
|
// Implement your functionality here
|
||||||
|
console.log("Add temperature clicked");
|
||||||
|
},
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#app {
|
.app-container {
|
||||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
display: grid;
|
||||||
-webkit-font-smoothing: antialiased;
|
grid-template-rows: auto 1fr;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #2c3e50;
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
margin-top: 60px;
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px 1fr;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-right: 1px solid #e9ecef;
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-area {
|
||||||
|
padding: 1.5rem;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,14 +23,16 @@ export class Serie {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getLabel(): string {
|
public getLabel(): string {
|
||||||
return `${this._user} - ${this._room} - ${this._device}`;
|
return `${this._type} - ${this._user} - ${this._room} - ${this._device}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSerie(): any {
|
public getSerie(): any {
|
||||||
if (this._type === TEMPERATURE) {
|
if (this._type === TEMPERATURE) {
|
||||||
return {
|
return {
|
||||||
label: this.getLabel(),
|
label: this.getLabel(),
|
||||||
data: this._data,
|
data: this._data.map((v: any) => {
|
||||||
|
return { x: v.time, y: v.value };
|
||||||
|
}),
|
||||||
borderColor: "rgba(255, 99, 132, 1)",
|
borderColor: "rgba(255, 99, 132, 1)",
|
||||||
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -38,7 +40,9 @@ export class Serie {
|
|||||||
} else if (this._type === HUMIDITY) {
|
} else if (this._type === HUMIDITY) {
|
||||||
return {
|
return {
|
||||||
label: this.getLabel(),
|
label: this.getLabel(),
|
||||||
data: this._data,
|
data: this._data.map((v: any) => {
|
||||||
|
return { x: v.time, y: v.value };
|
||||||
|
}),
|
||||||
borderColor: "rgba(54, 162, 235, 1)",
|
borderColor: "rgba(54, 162, 235, 1)",
|
||||||
backgroundColor: "rgba(54, 162, 235, 0.2)",
|
backgroundColor: "rgba(54, 162, 235, 0.2)",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|||||||
@@ -14,31 +14,63 @@ export class TimeSeriesManager {
|
|||||||
user: string,
|
user: string,
|
||||||
room: string,
|
room: string,
|
||||||
device: string
|
device: string
|
||||||
): Promise<{ temperature: Serie; humidity: Serie }> {
|
): Promise<Serie[]> {
|
||||||
return this.client
|
return this.client
|
||||||
.getValues(user, room, device)
|
.getValues(user, room, device)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
console.log(response);
|
// Filter temperature records
|
||||||
const serie_temperature = new Serie(
|
let temperatureRecords = response.data.filter(
|
||||||
|
(x: any) => x[TYPE] === TEMPERATURE
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a new array with type field removed and time converted to timestamp
|
||||||
|
let temperatureRecordsProcessed = temperatureRecords.map((x: any) => {
|
||||||
|
// Create a new object without the TYPE field
|
||||||
|
const { [TYPE]: removed, ...rest } = x;
|
||||||
|
|
||||||
|
// Convert the ISO time string to a timestamp (if it's a string)
|
||||||
|
if (typeof rest.time === "string") {
|
||||||
|
rest.time = new Date(rest.time).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
//filter humidity records
|
||||||
|
let humidityRecord = response.data.filter(
|
||||||
|
(x: any) => x[TYPE] === HUMIDITY
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a new array with type field removed and time converted to timestamp
|
||||||
|
let humidityRecordProcessed = humidityRecord.map((x: any) => {
|
||||||
|
// Create a new object without the TYPE field
|
||||||
|
const { [TYPE]: removed, ...rest } = x;
|
||||||
|
|
||||||
|
// Convert the ISO time string to a timestamp (if it's a string)
|
||||||
|
if (typeof rest.time === "string") {
|
||||||
|
rest.time = new Date(rest.time).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create actual Serie instances
|
||||||
|
const temperatureSerie = new Serie(
|
||||||
TEMPERATURE,
|
TEMPERATURE,
|
||||||
response.data.filter((x: any) => x[TYPE] === TEMPERATURE),
|
temperatureRecordsProcessed,
|
||||||
user,
|
user,
|
||||||
room,
|
room,
|
||||||
device
|
device
|
||||||
);
|
);
|
||||||
|
|
||||||
const serie_humidity = new Serie(
|
const humiditySerie = new Serie(
|
||||||
HUMIDITY,
|
HUMIDITY,
|
||||||
response.data.filter((x: any) => x[TYPE] === HUMIDITY),
|
humidityRecordProcessed,
|
||||||
user,
|
user,
|
||||||
room,
|
room,
|
||||||
device
|
device
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return [temperatureSerie, humiditySerie];
|
||||||
temperature: serie_temperature,
|
|
||||||
humidity: serie_humidity,
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error fetching time series data:", error);
|
console.error("Error fetching time series data:", error);
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<template></template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from "vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "ButtonInterface",
|
|
||||||
props: {
|
|
||||||
msg: String,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped>
|
|
||||||
h3 {
|
|
||||||
margin: 40px 0 0;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #42b983;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
111
web-app/src/components/ChartComponent.vue
Normal file
111
web-app/src/components/ChartComponent.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<!-- src/components/ChartComponent.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<div v-if="loading" class="loading-state">Loading data...</div>
|
||||||
|
<div v-else-if="error" class="error-state">{{ error }}</div>
|
||||||
|
<div v-else class="chart-container">
|
||||||
|
<Scatter
|
||||||
|
v-if="chartData.datasets.length > 0"
|
||||||
|
:data="chartData"
|
||||||
|
:options="chartOptions"
|
||||||
|
/>
|
||||||
|
<div v-else class="no-data-state">No data to display</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType, computed, proxyRefs } from "vue";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "chart.js";
|
||||||
|
import { Scatter } from "vue-chartjs";
|
||||||
|
import { Serie } from "../Measures/Serie";
|
||||||
|
|
||||||
|
// Register Chart.js components
|
||||||
|
ChartJS.register(LinearScale, PointElement, LineElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ChartComponent",
|
||||||
|
components: {
|
||||||
|
Scatter,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
series: {
|
||||||
|
type: Array as PropType<Serie[]>, // Use PropType to specify array of Serie
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
const chartData = computed(() => {
|
||||||
|
let series_prepared = props.series.map((s: Serie) => {
|
||||||
|
return s.getSerie();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
datasets: series_prepared,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
chartOptions,
|
||||||
|
chartData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state,
|
||||||
|
.no-data-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
web-app/src/components/ControlPanel.vue
Normal file
130
web-app/src/components/ControlPanel.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="control-panel">
|
||||||
|
<button @click="$emit('add-temperature')" class="action-button">
|
||||||
|
Add Temperature
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="sensor-selector">
|
||||||
|
<h3>Sensors</h3>
|
||||||
|
<div class="sensor-options">
|
||||||
|
<label class="sensor-option">
|
||||||
|
<input type="checkbox" :checked="showTemperature" />
|
||||||
|
<span>Temperature</span>
|
||||||
|
</label>
|
||||||
|
<label class="sensor-option">
|
||||||
|
<input type="checkbox" :checked="showHumidity" />
|
||||||
|
<span>Humidity</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="$emit('refresh')" class="refresh-button">
|
||||||
|
<span class="button-icon">↻</span> Refresh Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ControlPanel",
|
||||||
|
props: {
|
||||||
|
showTemperature: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showHumidity: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
"add-temperature",
|
||||||
|
"update:showTemperature",
|
||||||
|
"update:showHumidity",
|
||||||
|
"refresh",
|
||||||
|
"simulate-data",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.control-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button,
|
||||||
|
.refresh-button,
|
||||||
|
.demo-button {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
background-color: #3490dc;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background-color: #2779bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
background-color: #38c172;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button:hover {
|
||||||
|
background-color: #2d995b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button {
|
||||||
|
background-color: #f6993f;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button:hover {
|
||||||
|
background-color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-selector h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-option input {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user