working frontend and backend
This commit is contained in:
80
README.md
80
README.md
@@ -173,48 +173,66 @@ WHERE s.sensorType = 'Temperature & Humidity';
|
|||||||
-- VISTAS AUXILIARES
|
-- VISTAS AUXILIARES
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW v_pollution_map AS
|
CREATE OR REPLACE VIEW v_pollution_map AS
|
||||||
SELECT
|
SELECT
|
||||||
|
d.deviceId AS deviceId,
|
||||||
|
d.deviceName AS deviceName,
|
||||||
|
g.lat AS lat,
|
||||||
|
g.lon AS lon,
|
||||||
|
c.carbonMonoxide AS carbonMonoxide,
|
||||||
|
c.timestamp AS timestamp
|
||||||
|
FROM
|
||||||
|
(dad.devices d
|
||||||
|
LEFT JOIN dad.v_co_by_device c ON d.deviceId = c.deviceId)
|
||||||
|
LEFT JOIN dad.v_gps_by_device g ON d.deviceId = g.deviceId AND (g.timestamp <= c.timestamp OR g.timestamp IS NULL)
|
||||||
|
WHERE
|
||||||
|
c.carbonMonoxide IS NOT NULL
|
||||||
|
ORDER BY
|
||||||
d.deviceId,
|
d.deviceId,
|
||||||
d.deviceName,
|
c.timestamp;
|
||||||
g.lat,
|
|
||||||
g.lon,
|
|
||||||
c.carbonMonoxide,
|
|
||||||
c.timestamp
|
|
||||||
FROM devices d
|
|
||||||
LEFT JOIN v_co_by_device c ON d.deviceId = c.deviceId
|
|
||||||
LEFT JOIN v_gps_by_device g ON d.deviceId = g.deviceId
|
|
||||||
AND (g.timestamp <= c.timestamp OR g.timestamp IS NULL)
|
|
||||||
WHERE c.carbonMonoxide IS NOT NULL
|
|
||||||
ORDER BY d.deviceId, c.timestamp;
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW v_sensor_history_by_device AS
|
CREATE OR REPLACE VIEW v_sensor_history_by_device AS
|
||||||
SELECT
|
SELECT
|
||||||
d.deviceId,
|
d.deviceId AS deviceId,
|
||||||
d.deviceName,
|
d.deviceName AS deviceName,
|
||||||
w.temperature AS value,
|
w.temperature AS value,
|
||||||
'temperature' AS valueType,
|
'temperature' AS valueType,
|
||||||
w.timestamp
|
w.timestamp AS timestamp
|
||||||
FROM devices d
|
FROM
|
||||||
JOIN v_weather_by_device w ON d.deviceId = w.deviceId
|
dad.devices d
|
||||||
|
JOIN dad.v_weather_by_device w ON d.deviceId = w.deviceId
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
d.deviceId,
|
d.deviceId AS deviceId,
|
||||||
d.deviceName,
|
d.deviceName AS deviceName,
|
||||||
w.humidity AS value,
|
w.humidity AS value,
|
||||||
'humidity' AS valueType,
|
'humidity' AS valueType,
|
||||||
w.timestamp
|
w.timestamp AS timestamp
|
||||||
FROM devices d
|
FROM
|
||||||
JOIN v_weather_by_device w ON d.deviceId = w.deviceId
|
dad.devices d
|
||||||
|
JOIN dad.v_weather_by_device w ON d.deviceId = w.deviceId
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
d.deviceId,
|
d.deviceId AS deviceId,
|
||||||
d.deviceName,
|
d.deviceName AS deviceName,
|
||||||
|
w.pressure AS value,
|
||||||
|
'pressure' AS valueType,
|
||||||
|
w.timestamp AS timestamp
|
||||||
|
FROM
|
||||||
|
dad.devices d
|
||||||
|
JOIN dad.v_weather_by_device w ON d.deviceId = w.deviceId
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
d.deviceId AS deviceId,
|
||||||
|
d.deviceName AS deviceName,
|
||||||
c.carbonMonoxide AS value,
|
c.carbonMonoxide AS value,
|
||||||
'carbonMonoxide' AS valueType,
|
'carbonMonoxide' AS valueType,
|
||||||
c.timestamp
|
c.timestamp AS timestamp
|
||||||
FROM devices d
|
FROM
|
||||||
JOIN v_co_by_device c ON d.deviceId = c.deviceId
|
dad.devices d
|
||||||
ORDER BY deviceId, timestamp;
|
JOIN dad.v_co_by_device c ON d.deviceId = c.deviceId
|
||||||
|
ORDER BY
|
||||||
|
deviceId,
|
||||||
|
timestamp;
|
||||||
|
|
||||||
-- Insertar grupos
|
-- Insertar grupos
|
||||||
INSERT INTO groups (groupName) VALUES ('Grupo 1');
|
INSERT INTO groups (groupName) VALUES ('Grupo 1');
|
||||||
|
|||||||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -3556,6 +3557,11 @@
|
|||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet.heat": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
BIN
frontend/public/images/logo-dark.png
Normal file
BIN
frontend/public/images/logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/images/logo-light.png
Normal file
BIN
frontend/public/images/logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
@@ -3,41 +3,28 @@ import 'leaflet/dist/leaflet.css'
|
|||||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
||||||
|
|
||||||
import Home from '@/pages/Home.jsx'
|
|
||||||
import Dashboard from '@/pages/Dashboard.jsx'
|
import Dashboard from '@/pages/Dashboard.jsx'
|
||||||
import MenuButton from '@/components/layout/MenuButton.jsx'
|
import Groups from '@/pages/Groups.jsx'
|
||||||
import SideMenu from '@/components/layout/SideMenu.jsx'
|
|
||||||
import ThemeButton from '@/components/layout/ThemeButton.jsx'
|
import ThemeButton from '@/components/layout/ThemeButton.jsx'
|
||||||
import Header from '@/components/layout/Header.jsx'
|
import Header from '@/components/layout/Header.jsx'
|
||||||
import GroupView from '@/pages/GroupView.jsx'
|
import GroupView from '@/pages/GroupView.jsx'
|
||||||
|
|
||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import ContentWrapper from './components/layout/ContentWrapper'
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [isSideMenuOpen, setIsSideMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const toggleSideMenu = () => {
|
|
||||||
setIsSideMenuOpen(!isSideMenuOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeSideMenu = () => {
|
|
||||||
setIsSideMenuOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MenuButton onClick={toggleSideMenu} />
|
|
||||||
<SideMenu isOpen={isSideMenuOpen} onClose={toggleSideMenu} />
|
|
||||||
<ThemeButton />
|
<ThemeButton />
|
||||||
<div className={isSideMenuOpen ? 'blur m-0 p-0' : 'm-0 p-0'} onClick={closeSideMenu}>
|
<Header subtitle='Midiendo la calidad del aire y las calles en Sevilla 🌿🚛' />
|
||||||
<Header subtitle='Midiendo la calidad del aire y las calles en Sevilla 🌿🚛' />
|
<ContentWrapper>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Groups />} />
|
||||||
<Route path="/groups/:groupId" element={<GroupView />} />
|
<Route path="/groups/:groupId" element={<GroupView />} />
|
||||||
<Route path="/groups/:groupId/devices/:deviceId" element={<Dashboard />} />
|
<Route path="/groups/:groupId/devices/:deviceId" element={<Dashboard />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</ContentWrapper>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Line } from "react-chartjs-2";
|
import { Line } from "react-chartjs-2";
|
||||||
import { Chart as ChartJS, LineElement, PointElement, LinearScale, CategoryScale, Filler } from "chart.js";
|
import { Chart as ChartJS, LineElement, PointElement, LinearScale, CategoryScale, Filler } from "chart.js";
|
||||||
import CardContainer from "./layout/CardContainer";
|
import CardContainer from "./layout/CardContainer";
|
||||||
import "@/css/HistoryCharts.css";
|
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
@@ -12,74 +11,91 @@ import { useConfig } from "@/hooks/useConfig";
|
|||||||
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler);
|
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler);
|
||||||
|
|
||||||
const HistoryCharts = ({ groupId, deviceId }) => {
|
const HistoryCharts = ({ groupId, deviceId }) => {
|
||||||
const { config, configLoading, configError } = useConfig();
|
const { config, configLoading, configError } = useConfig();
|
||||||
|
|
||||||
if (configLoading) return <p>Cargando configuración...</p>;
|
if (configLoading) return <p>Cargando configuración...</p>;
|
||||||
if (configError) return <p>Error al cargar configuración: {configError}</p>;
|
if (configError) return <p>Error al cargar configuración: {configError}</p>;
|
||||||
if (!config) return <p>Configuración no disponible.</p>;
|
if (!config) return <p>Configuración no disponible.</p>;
|
||||||
|
|
||||||
const BASE = config.appConfig.endpoints.LOGIC_URL;
|
const BASE = config.appConfig.endpoints.LOGIC_URL;
|
||||||
const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_HISTORY;
|
const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_HISTORY;
|
||||||
const endp = ENDPOINT
|
const endp = ENDPOINT
|
||||||
.replace(':groupId', groupId)
|
.replace(':groupId', groupId)
|
||||||
.replace(':deviceId', deviceId); // si tu endpoint lo necesita
|
.replace(':deviceId', deviceId); // si tu endpoint lo necesita
|
||||||
|
|
||||||
const reqConfig = {
|
const reqConfig = {
|
||||||
baseUrl: `${BASE}${endp}`,
|
baseUrl: `${BASE}${endp}`,
|
||||||
params: {}
|
params: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider config={reqConfig}>
|
<DataProvider config={reqConfig}>
|
||||||
<HistoryChartsContent />
|
<HistoryChartsContent />
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const HistoryChartsContent = () => {
|
const HistoryChartsContent = () => {
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
const { data, loading, error } = useDataContext();
|
const { data, loading, error } = useDataContext();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
const optionsDark = config?.appConfig?.historyChartConfig?.chartOptionsDark ?? {};
|
const optionsDark = config?.appConfig?.historyChartConfig?.chartOptionsDark ?? {};
|
||||||
const optionsLight = config?.appConfig?.historyChartConfig?.chartOptionsLight ?? {};
|
const optionsLight = config?.appConfig?.historyChartConfig?.chartOptionsLight ?? {};
|
||||||
const options = theme === "dark" ? optionsDark : optionsLight;
|
const options = theme === "dark" ? optionsDark : optionsLight;
|
||||||
|
|
||||||
const currentHour = new Date().getHours();
|
|
||||||
const timeLabels = [
|
|
||||||
`${currentHour - 3}:00`, `${currentHour - 2}:00`, `${currentHour - 1}:00`, `${currentHour}:00`, `${currentHour + 1}:00`, `${currentHour + 2}:00`, `${currentHour + 3}:00`
|
|
||||||
]
|
|
||||||
|
|
||||||
if (loading) return <p>Cargando datos...</p>;
|
if (loading) return <p>Cargando datos...</p>;
|
||||||
if (error) return <p>Datos no disponibles.</p>;
|
if (error) return <p>Datos no disponibles.</p>;
|
||||||
|
|
||||||
const temperatureData = [];
|
const grouped = {
|
||||||
const humidityData = [];
|
temperature: [],
|
||||||
const pollutionLevels = [];
|
humidity: [],
|
||||||
|
pressure: [],
|
||||||
|
carbonMonoxide: []
|
||||||
|
};
|
||||||
|
|
||||||
data?.forEach(sensor => {
|
data?.forEach(sensor => {
|
||||||
if (sensor.value != null) {
|
if (sensor.value != null && grouped[sensor.valueType]) {
|
||||||
if (sensor.sensor_type === "MQ-135") {
|
grouped[sensor.valueType].push({
|
||||||
pollutionLevels.push(sensor.value);
|
timestamp: new Date(sensor.timestamp),
|
||||||
} else if (sensor.sensor_type === "DHT-11") {
|
value: sensor.value
|
||||||
temperatureData.push(sensor.value);
|
});
|
||||||
humidityData.push(sensor.value);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sortAndExtract = (entries) => {
|
||||||
|
const sorted = entries.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
const labels = sorted.map(e =>
|
||||||
|
new Date(e.timestamp * 1000).toLocaleTimeString('es-ES', {
|
||||||
|
timeZone: 'Europe/Madrid',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const values = sorted.map(e => e.value);
|
||||||
|
return { labels, values };
|
||||||
|
};
|
||||||
|
|
||||||
|
const temp = sortAndExtract(grouped.temperature);
|
||||||
|
const hum = sortAndExtract(grouped.humidity);
|
||||||
|
const press = sortAndExtract(grouped.pressure);
|
||||||
|
const co = sortAndExtract(grouped.carbonMonoxide);
|
||||||
|
|
||||||
|
const timeLabels = temp.labels.length ? temp.labels : hum.labels.length ? hum.labels : co.labels.length ? co.labels : ["Sin datos"];
|
||||||
|
|
||||||
const historyData = [
|
const historyData = [
|
||||||
{ title: "🌡️ Temperatura", data: temperatureData.length ? temperatureData : [0], borderColor: "#00FF85", backgroundColor: "rgba(0, 255, 133, 0.2)" },
|
{ title: "🌡️ Temperatura", data: temp.values, borderColor: "#00FF85", backgroundColor: "rgba(0, 255, 133, 0.2)" },
|
||||||
{ title: "💧 Humedad", data: humidityData.length ? humidityData : [0], borderColor: "#00D4FF", backgroundColor: "rgba(0, 212, 255, 0.2)" },
|
{ title: "💦 Humedad", data: hum.values, borderColor: "#00D4FF", backgroundColor: "rgba(0, 212, 255, 0.2)" },
|
||||||
{ title: "☁️ Contaminación", data: pollutionLevels.length ? pollutionLevels : [0], borderColor: "#FFA500", backgroundColor: "rgba(255, 165, 0, 0.2)" }
|
{ title: "⏲ Presión", data: press.values, borderColor: "#B12424", backgroundColor: "rgba(255, 0, 0, 0.2)" },
|
||||||
];
|
{ title: "☁️ Contaminación", data: co.values, borderColor: "#FFA500", backgroundColor: "rgba(255, 165, 0, 0.2)" }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContainer
|
<CardContainer
|
||||||
cards={historyData.map(({ title, data, borderColor, backgroundColor }) => ({
|
cards={historyData.map(({ title, data, borderColor, backgroundColor }) => ({
|
||||||
title,
|
title,
|
||||||
content: (
|
content: (
|
||||||
<Line
|
<Line style= {{ minHeight: "250px" }}
|
||||||
data={{
|
data={{
|
||||||
labels: timeLabels,
|
labels: timeLabels,
|
||||||
datasets: [{ data, borderColor, backgroundColor, fill: true, tension: 0.4 }]
|
datasets: [{ data, borderColor, backgroundColor, fill: true, tension: 0.4 }]
|
||||||
@@ -88,7 +104,8 @@ const HistoryChartsContent = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
styleMode: "override",
|
styleMode: "override",
|
||||||
className: "col-lg-4 col-xxs-12 d-flex flex-column align-items-center p-3 card-container"
|
className: "col-lg-6 col-xxs-12 d-flex flex-column align-items-center p-3 card-container",
|
||||||
|
style: { minHeight: "250px" }
|
||||||
}))}
|
}))}
|
||||||
className=""
|
className=""
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { MapContainer, TileLayer, Circle, Popup } from 'react-leaflet';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useConfig } from '@/hooks/useConfig.js';
|
import { useConfig } from '@/hooks/useConfig.js';
|
||||||
@@ -6,38 +5,10 @@ import { useConfig } from '@/hooks/useConfig.js';
|
|||||||
import { DataProvider } from '@/context/DataContext.jsx';
|
import { DataProvider } from '@/context/DataContext.jsx';
|
||||||
import { useDataContext } from '@/hooks/useDataContext';
|
import { useDataContext } from '@/hooks/useDataContext';
|
||||||
|
|
||||||
const PollutionCircles = ({ data }) => {
|
import L from "leaflet";
|
||||||
return data.map(({ lat, lng, level }, index) => {
|
import "leaflet.heat";
|
||||||
const baseColor = level < 20 ? '#00FF85' : level < 60 ? '#FFA500' : '#FF0000';
|
|
||||||
const steps = 4;
|
|
||||||
const maxRadius = 400;
|
|
||||||
const stepSize = maxRadius / steps;
|
|
||||||
|
|
||||||
return (
|
import { useEffect } from 'react';
|
||||||
<div key={index}>
|
|
||||||
{[...Array(steps)].map((_, i) => {
|
|
||||||
const radius = stepSize * (i + 1);
|
|
||||||
const opacity = 0.6 * ((i + 1) / steps);
|
|
||||||
return (
|
|
||||||
<Circle
|
|
||||||
key={`${index}-${i}`}
|
|
||||||
center={[lat, lng]}
|
|
||||||
pathOptions={{ color: baseColor, fillColor: baseColor, fillOpacity: opacity, weight: 1 }}
|
|
||||||
radius={radius}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<Circle
|
|
||||||
center={[lat, lng]}
|
|
||||||
pathOptions={{ color: baseColor, fillColor: baseColor, fillOpacity: 0.8, weight: 2 }}
|
|
||||||
radius={50}
|
|
||||||
>
|
|
||||||
<Popup>Contaminación: {level} µg/m³</Popup>
|
|
||||||
</Circle>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const PollutionMap = ({ groupId, deviceId }) => {
|
const PollutionMap = ({ groupId, deviceId }) => {
|
||||||
const { config, configLoading, configError } = useConfig();
|
const { config, configLoading, configError } = useConfig();
|
||||||
@@ -64,10 +35,33 @@ const PollutionMap = ({ groupId, deviceId }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const PollutionMapContent = () => {
|
const PollutionMapContent = () => {
|
||||||
const { config, configLoading, configError } = useConfig();
|
const { config, configLoading, configError } = useConfig();
|
||||||
const { data, dataLoading, dataError } = useDataContext();
|
const { data, dataLoading, dataError } = useDataContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !config) return;
|
||||||
|
|
||||||
|
const mapContainer = document.getElementById("map");
|
||||||
|
if (!mapContainer) return;
|
||||||
|
|
||||||
|
const SEVILLA = config.userConfig.city;
|
||||||
|
|
||||||
|
const map = L.map(mapContainer).setView(SEVILLA, 12);
|
||||||
|
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const points = data.map((p) => [p.lat, p.lon, p.carbonMonoxide]);
|
||||||
|
|
||||||
|
L.heatLayer(points, { radius: 25 }).addTo(map);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
}, [data, config]);
|
||||||
|
|
||||||
if (configLoading) return <p>Cargando configuración...</p>;
|
if (configLoading) return <p>Cargando configuración...</p>;
|
||||||
if (configError) return <p>Error al cargar configuración: {configError}</p>;
|
if (configError) return <p>Error al cargar configuración: {configError}</p>;
|
||||||
@@ -77,33 +71,13 @@ const PollutionMapContent = () => {
|
|||||||
if (dataError) return <p>Error al cargar datos: {configError}</p>;
|
if (dataError) return <p>Error al cargar datos: {configError}</p>;
|
||||||
if (!data) return <p>Datos no disponibles.</p>;
|
if (!data) return <p>Datos no disponibles.</p>;
|
||||||
|
|
||||||
const SEVILLA = config?.userConfig.city;
|
|
||||||
|
|
||||||
const pollutionData = data.map((measure) => ({
|
|
||||||
lat: measure.lat,
|
|
||||||
lng: measure.lon,
|
|
||||||
level: measure.carbonMonoxide
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='p-3'>
|
<div className="p-3">
|
||||||
<MapContainer center={SEVILLA} zoom={13} scrollWheelZoom={false} style={mapStyles}>
|
<div id="map" className='rounded-4' style={{ height: "60vh" }}></div>
|
||||||
<TileLayer
|
|
||||||
attribution='© Contribuidores de <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
<PollutionCircles data={pollutionData} />
|
|
||||||
</MapContainer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStyles = {
|
|
||||||
height: '500px',
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: '20px'
|
|
||||||
};
|
|
||||||
|
|
||||||
PollutionMap.propTypes = {
|
PollutionMap.propTypes = {
|
||||||
groupId: PropTypes.number.isRequired,
|
groupId: PropTypes.number.isRequired,
|
||||||
deviceId: PropTypes.number.isRequired
|
deviceId: PropTypes.number.isRequired
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import CardContainer from './layout/CardContainer';
|
import CardContainer from './layout/CardContainer';
|
||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faCloud, faClock, faTemperature0, faWater } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
import { DataProvider } from '@/context/DataContext';
|
import { DataProvider } from '@/context/DataContext';
|
||||||
import { useDataContext } from '@/hooks/useDataContext';
|
import { useDataContext } from '@/hooks/useDataContext';
|
||||||
|
|
||||||
import { useConfig } from '@/hooks/useConfig.js';
|
import { useConfig } from '@/hooks/useConfig.js';
|
||||||
import { DateParser } from '@/util/dateParser';
|
|
||||||
|
|
||||||
const SummaryCards = ({ groupId, deviceId }) => {
|
const SummaryCards = ({ groupId, deviceId }) => {
|
||||||
const { config, configLoading, configError } = useConfig();
|
const { config, configLoading, configError } = useConfig();
|
||||||
@@ -21,7 +17,7 @@ const SummaryCards = ({ groupId, deviceId }) => {
|
|||||||
const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES;
|
const ENDPOINT = config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES;
|
||||||
const endp = ENDPOINT
|
const endp = ENDPOINT
|
||||||
.replace(':groupId', groupId)
|
.replace(':groupId', groupId)
|
||||||
.replace(':deviceId', deviceId); // solo si lo necesitas así
|
.replace(':deviceId', deviceId);
|
||||||
|
|
||||||
const reqConfig = {
|
const reqConfig = {
|
||||||
baseUrl: `${BASE}${endp}`,
|
baseUrl: `${BASE}${endp}`,
|
||||||
@@ -43,32 +39,29 @@ const SummaryCardsContent = () => {
|
|||||||
if (!data) return <p>Datos no disponibles.</p>;
|
if (!data) return <p>Datos no disponibles.</p>;
|
||||||
|
|
||||||
const CardsData = [
|
const CardsData = [
|
||||||
{ id: 1, title: "Temperatura", content: "N/A", status: "Esperando datos...", titleIcon: <FontAwesomeIcon icon={faTemperature0} /> },
|
{ id: 1, title: "Temperatura", content: "N/A", status: "Esperando datos...", titleIcon: '🌡 ' },
|
||||||
{ id: 2, title: "Humedad", content: "N/A", status: "Esperando datos...", titleIcon: <FontAwesomeIcon icon={faWater} /> },
|
{ id: 2, title: "Humedad", content: "N/A", status: "Esperando datos...", titleIcon: '💦 ' },
|
||||||
{ id: 3, title: "Nivel de CO", content: "N/A", status: "Esperando datos...", titleIcon: <FontAwesomeIcon icon={faCloud} /> },
|
{ id: 3, title: "Presión", content: "N/A", status: "Esperando datos...", titleIcon: '⏲ ' },
|
||||||
{ id: 4, title: "Actualizado a las", content: "N/A", status: "Esperando datos...", titleIcon: <FontAwesomeIcon icon={faClock} /> }
|
{ id: 4, title: "Nivel de CO", content: "N/A", status: "Esperando datos...", titleIcon: '☁ ' }
|
||||||
];
|
];
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
let coData = data[1];
|
let coData = data[2];
|
||||||
let tempData = data[2];
|
let tempData = data[1];
|
||||||
|
|
||||||
let lastTime = DateParser.timestampToString(coData.timestamp);
|
|
||||||
let lastDate = new Date(coData.timestamp);
|
|
||||||
|
|
||||||
CardsData[0].content = tempData.temperature + "°C";
|
CardsData[0].content = tempData.temperature + "°C";
|
||||||
CardsData[0].status = "Temperatura actual";
|
CardsData[0].status = "Temperatura actual";
|
||||||
CardsData[1].content = tempData.humidity + "%";
|
CardsData[1].content = tempData.humidity + "%";
|
||||||
CardsData[1].status = "Humedad actual";
|
CardsData[1].status = "Humedad actual";
|
||||||
CardsData[2].content = coData.carbonMonoxide + " ppm";
|
CardsData[3].content = coData.carbonMonoxide + " ppm";
|
||||||
CardsData[2].status = "Nivel de CO actual";
|
CardsData[3].status = "Nivel de CO actual";
|
||||||
CardsData[3].content = lastTime;
|
CardsData[2].content = tempData.pressure + " hPa";
|
||||||
CardsData[3].status = "Día " + lastDate.toLocaleDateString();
|
CardsData[2].status = "Presión actual";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContainer cards={CardsData} />
|
<CardContainer text cards={CardsData} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import "@/css/Card.css";
|
import "@/css/Card.css";
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
|
|
||||||
const Card = ({ title, status, children, styleMode, className, titleIcon }) => {
|
const Card = ({ title, status, children, styleMode, className, titleIcon, style }) => {
|
||||||
const cardRef = useRef(null);
|
const cardRef = useRef(null);
|
||||||
const [shortTitle, setShortTitle] = useState(title);
|
const [shortTitle, setShortTitle] = useState(title);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -30,8 +30,9 @@ const Card = ({ title, status, children, styleMode, className, titleIcon }) => {
|
|||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className={styleMode === "override" ? `${className}` :
|
className={styleMode === "override" ? `${className}` :
|
||||||
`col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`}
|
`col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`}
|
||||||
|
|
||||||
>
|
>
|
||||||
<div className={`card p-3 w-100 ${theme}`}>
|
<div className={`card p-3 w-100 ${theme}`} style={styleMode === "override" ? style : {}}>
|
||||||
<h3 className="text-center">
|
<h3 className="text-center">
|
||||||
{titleIcon}
|
{titleIcon}
|
||||||
{shortTitle}
|
{shortTitle}
|
||||||
@@ -50,6 +51,7 @@ Card.propTypes = {
|
|||||||
styleMode: PropTypes.oneOf(["override", ""]),
|
styleMode: PropTypes.oneOf(["override", ""]),
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
titleIcon: PropTypes.node,
|
titleIcon: PropTypes.node,
|
||||||
|
style: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
|
||||||
Card.defaultProps = {
|
Card.defaultProps = {
|
||||||
|
|||||||
@@ -2,20 +2,26 @@ import Card from "./Card.jsx";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
const CardContainer = ({ links, cards, className }) => {
|
const CardContainer = ({ links, cards, className, text }) => {
|
||||||
return (
|
return (
|
||||||
<div className={`row justify-content-center g-0 ${className}`}>
|
<div className={`row justify-content-center g-0 ${className}`}>
|
||||||
{cards.map((card, index) => (
|
{cards.map((card, index) => (
|
||||||
links ? (
|
links ? (
|
||||||
<Link to={card.to} key={index} style={{ textDecoration: 'none' }}>
|
<Link to={card.to} key={index} style={{ textDecoration: 'none' }}>
|
||||||
<Card title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon}>
|
<Card title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon} style={card.style}>
|
||||||
<p className="card-text text-center">{card.content}</p>
|
{text
|
||||||
|
? <p className="card-text text-center">{card.content}</p>
|
||||||
|
: <div className="my-2">{card.content}</div>
|
||||||
|
}
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Card key={index} title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon}>
|
<Card key={index} title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon} style={card.style}>
|
||||||
<p className="card-text text-center">{card.content}</p>
|
{text
|
||||||
</Card>
|
? <p className="card-text text-center">{card.content}</p>
|
||||||
|
: <div className="my-2">{card.content}</div>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -24,6 +30,7 @@ const CardContainer = ({ links, cards, className }) => {
|
|||||||
|
|
||||||
CardContainer.propTypes = {
|
CardContainer.propTypes = {
|
||||||
links: Boolean,
|
links: Boolean,
|
||||||
|
text: Boolean,
|
||||||
cards: PropTypes.arrayOf(
|
cards: PropTypes.arrayOf(
|
||||||
PropTypes.shape({
|
PropTypes.shape({
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
|
|||||||
16
frontend/src/components/layout/ContentWrapper.jsx
Normal file
16
frontend/src/components/layout/ContentWrapper.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
const ContentWrapper = ({ children, className }) => {
|
||||||
|
return (
|
||||||
|
<div className={`container-xl ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentWrapper.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
className: PropTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContentWrapper;
|
||||||
15
frontend/src/components/layout/CustomContainer.jsx
Normal file
15
frontend/src/components/layout/CustomContainer.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
const CustomContainer = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<main className="px-4 py-5">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomContainer.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomContainer;
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import '@/css/Header.css';
|
import '@/css/Header.css';
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const Header = ({ subtitle }) => {
|
const Header = ({ subtitle }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`justify-content-center text-center mb-4 ${theme}`}>
|
<header className={`row justify-content-center text-center mb-4 ${theme}`}>
|
||||||
<img src='/images/logo.png' width={500} />
|
<div className='col-xl-4 col-lg-6 col-8'>
|
||||||
<p className='subtitle'>{subtitle}</p>
|
<Link to="/" className="text-decoration-none">
|
||||||
|
<img src={`/images/logo-${theme}.png`} className='img-fluid' />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className='col-12 text-center my-3'>{subtitle}</p>
|
||||||
|
{/*<nav className='d-flex justify-content-center gap-4 my-3'>
|
||||||
|
<Link to="/" className="nav-link">Inicio</Link>
|
||||||
|
<Link to="/groups" className="nav-link">Grupos</Link>
|
||||||
|
</nav> */}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Header.propTypes = {
|
Header.propTypes = {
|
||||||
subtitle: PropTypes.string
|
subtitle: PropTypes.string,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import "@/css/MenuButton.css";
|
|
||||||
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faBars } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
|
|
||||||
const MenuButton = ({ onClick }) => {
|
|
||||||
return (
|
|
||||||
<button className="menuBtn" onClick={onClick}>
|
|
||||||
<FontAwesomeIcon icon={faBars} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
MenuButton.propTypes = {
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuButton;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import "@/css/SideMenu.css";
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faTimes, faHome } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
import { DataProvider } from '@/context/DataContext';
|
|
||||||
import { useDataContext } from "@/hooks/useDataContext";
|
|
||||||
|
|
||||||
import { useConfig } from '@/hooks/useConfig.js';
|
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Card from './Card';
|
|
||||||
|
|
||||||
const SideMenu = ({ isOpen, onClose }) => {
|
|
||||||
const { config, configLoading, configError } = useConfig();
|
|
||||||
|
|
||||||
if (configLoading) return <p>Cargando configuración...</p>;
|
|
||||||
if (configError) return <p>Error al cargar configuración: {configError}</p>;
|
|
||||||
if (!config) return <p>Configuración no disponible.</p>;
|
|
||||||
|
|
||||||
const BASE = config.appConfig.endpoints.DATA_URL;
|
|
||||||
const ENDPOINT = config.appConfig.endpoints.GET_GROUPS;
|
|
||||||
|
|
||||||
const reqConfig = {
|
|
||||||
baseUrl: `${BASE}${ENDPOINT}`,
|
|
||||||
params: {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataProvider config={reqConfig}>
|
|
||||||
<SideMenuContent isOpen={isOpen} onClose={onClose} />
|
|
||||||
</DataProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SideMenuContent = ({ isOpen, onClose }) => {
|
|
||||||
const { data, dataLoading, dataError } = useDataContext();
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
if (dataLoading) return <p>Cargando datos...</p>;
|
|
||||||
if (dataError) return <p>Error al cargar datos: {dataError}</p>;
|
|
||||||
if (!data) return <p>Datos no disponibles.</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`side-menu ${isOpen ? 'open' : ''} ${theme}`}>
|
|
||||||
<button className="home-btn" onClick={() => window.location.href = '/'}>
|
|
||||||
<FontAwesomeIcon icon={faHome} />
|
|
||||||
</button>
|
|
||||||
<button className="close-btn" onClick={onClose}>
|
|
||||||
<FontAwesomeIcon icon={faTimes} />
|
|
||||||
</button>
|
|
||||||
<hr className="separation w-100"></hr>
|
|
||||||
<div className="d-flex flex-column gap-3 mt-5">
|
|
||||||
{data.map(group => {
|
|
||||||
return (
|
|
||||||
<Link to={`/groups/${group.groupId}`} key={group.groupId} style={{ textDecoration: 'none' }}>
|
|
||||||
<Card
|
|
||||||
title={group.groupName}
|
|
||||||
status={`ID: ${group.groupId}`}
|
|
||||||
styleMode={"override"}
|
|
||||||
className={"col-12"}
|
|
||||||
>
|
|
||||||
{[]}
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SideMenu.propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
SideMenuContent.propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SideMenu;
|
|
||||||
@@ -13,7 +13,7 @@ export const ConfigProvider = ({ children }) => {
|
|||||||
try {
|
try {
|
||||||
const response = import.meta.env.MODE === 'production'
|
const response = import.meta.env.MODE === 'production'
|
||||||
? await fetch("/config/settings.prod.json")
|
? await fetch("/config/settings.prod.json")
|
||||||
: await fetch("/config/settings.dev.json");
|
: await fetch("/config/settings.prod.json");
|
||||||
if (!response.ok) throw new Error("Error al cargar settings.*.json");
|
if (!response.ok) throw new Error("Error al cargar settings.*.json");
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
setConfig(json);
|
setConfig(json);
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
.menuBtn {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
left: 20px;
|
|
||||||
z-index: 1000;
|
|
||||||
border: none;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
font-size: 24px; /* Aumenta el tamaño del icono */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--primary-color);
|
|
||||||
background-color: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s, transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuBtn .fa-bars {
|
|
||||||
width: 30px; /* Ajusta el ancho del icono */
|
|
||||||
height: 30px; /* Ajusta la altura del icono */
|
|
||||||
}
|
|
||||||
|
|
||||||
.menuBtn:hover {
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
.side-menu {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: -350px;
|
|
||||||
width: 350px;
|
|
||||||
height: 100%;
|
|
||||||
transition: left 0.3s ease;
|
|
||||||
padding: 30px;
|
|
||||||
box-shadow: 2px 0 5px rgba(0,0,0,0.5);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu.light {
|
|
||||||
background-color: white;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu.dark {
|
|
||||||
background-color: #333;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu.open {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blur {
|
|
||||||
filter: blur(5px);
|
|
||||||
transition: filter 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .close-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 30px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .close-btn.light {
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .close-btn.dark {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .close-btn .fa-times {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .close-btn:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .home-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
left: 20px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 30px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .home-btn .fa-home {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .home-btn.light {
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .home-btn.dark {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu .home-btn:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
hr.separation {
|
|
||||||
margin: 60px 0;
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
@@ -51,4 +51,3 @@
|
|||||||
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
|
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,25 +7,6 @@ import App from './App.jsx'
|
|||||||
import { ThemeProvider } from './context/ThemeContext.jsx'
|
import { ThemeProvider } from './context/ThemeContext.jsx'
|
||||||
import { ConfigProvider } from './context/ConfigContext.jsx'
|
import { ConfigProvider } from './context/ConfigContext.jsx'
|
||||||
|
|
||||||
/**
|
|
||||||
* main.jsx
|
|
||||||
*
|
|
||||||
* Este archivo es el punto de entrada principal de la aplicación React.
|
|
||||||
*
|
|
||||||
* Importaciones:
|
|
||||||
* - StrictMode: Un componente de React que ayuda a identificar problemas potenciales en la aplicación.
|
|
||||||
* - createRoot: Una función de ReactDOM que crea una raíz para renderizar la aplicación.
|
|
||||||
* - './css/index.css': Archivo CSS que contiene los estilos globales de la aplicación.
|
|
||||||
* - App: El componente principal de la aplicación.
|
|
||||||
* - ThemeProvider: Un proveedor de contexto que maneja el tema de la aplicación.
|
|
||||||
* - ConfigProvider: Un proveedor de contexto que maneja la configuración de la aplicación.
|
|
||||||
*
|
|
||||||
* Funcionalidad:
|
|
||||||
* - El archivo utiliza `createRoot` para obtener el elemento con el id 'root' del DOM y renderizar la aplicación React dentro de él.
|
|
||||||
* - La aplicación se envuelve en `StrictMode` para ayudar a detectar problemas potenciales.
|
|
||||||
* - `ThemeProvider` y `ConfigProvider` envuelven el componente `App` para proporcionar los contextos de tema y configuración a toda la aplicación.
|
|
||||||
*/
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
|||||||
@@ -4,9 +4,36 @@ import LoadingIcon from "@/components/LoadingIcon";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useConfig } from "@/hooks/useConfig";
|
import { useConfig } from "@/hooks/useConfig";
|
||||||
import { useDataContext } from "@/hooks/useDataContext";
|
import { useDataContext } from "@/hooks/useDataContext";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { DataProvider } from "@/context/DataContext";
|
import { DataProvider } from "@/context/DataContext";
|
||||||
|
|
||||||
|
import { MapContainer, TileLayer, Marker } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Icono de marcador por defecto (porque Leaflet no lo carga bien en algunos setups)
|
||||||
|
const markerIcon = new L.Icon({
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png',
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
|
||||||
|
const MiniMap = ({ lat, lon }) => (
|
||||||
|
<MapContainer
|
||||||
|
center={[lat, lon]}
|
||||||
|
zoom={15}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
dragging={false}
|
||||||
|
doubleClickZoom={false}
|
||||||
|
zoomControl={false}
|
||||||
|
style={{ height: '150px', width: '100%', borderRadius: '10px' }}
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
<Marker position={[lat, lon]} icon={markerIcon} />
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
|
||||||
const GroupView = () => {
|
const GroupView = () => {
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { config, configLoading } = useConfig();
|
const { config, configLoading } = useConfig();
|
||||||
@@ -16,18 +43,48 @@ const GroupView = () => {
|
|||||||
const replacedEndpoint = config.appConfig.endpoints.GET_GROUP_DEVICES.replace(':groupId', groupId);
|
const replacedEndpoint = config.appConfig.endpoints.GET_GROUP_DEVICES.replace(':groupId', groupId);
|
||||||
const reqConfig = {
|
const reqConfig = {
|
||||||
baseUrl: `${config.appConfig.endpoints.DATA_URL}${replacedEndpoint}`,
|
baseUrl: `${config.appConfig.endpoints.DATA_URL}${replacedEndpoint}`,
|
||||||
|
latestValuesUrl: `${config.appConfig.endpoints.LOGIC_URL}${config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider config={reqConfig}>
|
<DataProvider config={reqConfig}>
|
||||||
<GroupViewContent />
|
<GroupViewContent />
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupViewContent = () => {
|
const GroupViewContent = () => {
|
||||||
const { data, dataLoading, dataError } = useDataContext();
|
const { data, dataLoading, dataError, getData } = useDataContext();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
|
const [latestData, setLatestData] = useState({});
|
||||||
|
|
||||||
|
const { config } = useConfig(); // lo pillamos por si acaso
|
||||||
|
const latestValuesUrl = config.appConfig.endpoints.LOGIC_URL + config.appConfig.endpoints.GET_DEVICE_LATEST_VALUES;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const fetchLatestData = async () => {
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
await Promise.all(data.map(async device => {
|
||||||
|
const endpoint = latestValuesUrl
|
||||||
|
.replace(':groupId', groupId)
|
||||||
|
.replace(':deviceId', device.deviceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getData(endpoint);
|
||||||
|
results[device.deviceId] = res;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error al obtener latest values de ${device.deviceId}:`, err);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
setLatestData(results);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLatestData();
|
||||||
|
}, [data, groupId]);
|
||||||
|
|
||||||
if (dataLoading) return <p className="text-center my-5"><LoadingIcon /></p>;
|
if (dataLoading) return <p className="text-center my-5"><LoadingIcon /></p>;
|
||||||
if (dataError) return <p className="text-center my-5">Error al cargar datos: {dataError}</p>;
|
if (dataError) return <p className="text-center my-5">Error al cargar datos: {dataError}</p>;
|
||||||
@@ -35,15 +92,25 @@ const GroupViewContent = () => {
|
|||||||
return (
|
return (
|
||||||
<CardContainer
|
<CardContainer
|
||||||
links
|
links
|
||||||
cards={data.map(device => ({
|
cards={data.map(device => {
|
||||||
title: device.deviceName,
|
const latest = latestData[device.deviceId];
|
||||||
status: `ID: ${device.deviceId}`,
|
const gpsSensor = latest?.data[0];
|
||||||
to: `/groups/${groupId}/devices/${device.deviceId}`,
|
const mapPreview = gpsSensor?.lat && gpsSensor?.lon
|
||||||
styleMode: "override",
|
? <MiniMap lat={gpsSensor.lat} lon={gpsSensor.lon} />
|
||||||
className: "col-12 col-md-6 col-lg-4",
|
: "Sin posición";
|
||||||
}))}
|
|
||||||
|
return {
|
||||||
|
title: device.deviceName,
|
||||||
|
status: `ID: ${device.deviceId}`,
|
||||||
|
content: mapPreview,
|
||||||
|
to: `/groups/${groupId}/devices/${device.deviceId}`,
|
||||||
|
styleMode: "override",
|
||||||
|
className: "col-12 col-md-6 col-lg-4"
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default GroupView;
|
export default GroupView;
|
||||||
89
frontend/src/pages/Groups.jsx
Normal file
89
frontend/src/pages/Groups.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import CardContainer from "@/components/layout/CardContainer";
|
||||||
|
import LoadingIcon from "@/components/LoadingIcon";
|
||||||
|
|
||||||
|
import { useConfig } from "@/hooks/useConfig";
|
||||||
|
import { useDataContext } from "@/hooks/useDataContext";
|
||||||
|
|
||||||
|
import { DataProvider } from "@/context/DataContext";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
const Groups = () => {
|
||||||
|
const { config, configLoading } = useConfig();
|
||||||
|
|
||||||
|
if (configLoading || !config) return <p className="text-center my-5"><LoadingIcon /></p>;
|
||||||
|
|
||||||
|
const replacedEndpoint = config.appConfig.endpoints.GET_GROUPS;
|
||||||
|
const reqConfig = {
|
||||||
|
baseUrl: `${config.appConfig.endpoints.DATA_URL}${replacedEndpoint}`,
|
||||||
|
devicesUrl: `${config.appConfig.endpoints.DATA_URL}${config.appConfig.endpoints.GET_GROUP_DEVICES}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataProvider config={reqConfig}>
|
||||||
|
<GroupsContent config={reqConfig} />
|
||||||
|
</DataProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupsContent = ({ config }) => {
|
||||||
|
const { data, dataLoading, dataError, getData } = useDataContext();
|
||||||
|
const [devices, setDevices] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const fetchDevices = async () => {
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
await Promise.all(data.map(async group => {
|
||||||
|
const endpoint = config.devicesUrl
|
||||||
|
.replace(':groupId', group.groupId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getData(endpoint);
|
||||||
|
results[group.groupId] = res;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error al obtener dispositivos de ${group.groupId}:`, err);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
setDevices(results);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDevices();
|
||||||
|
}, [config.devicesUrl, data, getData]);
|
||||||
|
|
||||||
|
if (dataLoading) return <p className="text-center my-5"><LoadingIcon /></p>;
|
||||||
|
if (dataError) return <p className="text-center my-5">Error al cargar datos: {dataError}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContainer
|
||||||
|
links
|
||||||
|
text
|
||||||
|
cards={data.map(group => {
|
||||||
|
const groupDevices = devices[group.groupId]?.data;
|
||||||
|
const deviceCount = groupDevices?.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: group.groupName,
|
||||||
|
status: `ID: ${group.groupId}`,
|
||||||
|
to: `/groups/${group.groupId}`,
|
||||||
|
styleMode: "override",
|
||||||
|
content: deviceCount != null
|
||||||
|
? (deviceCount === 1 ? "1 dispositivo" : `${deviceCount} dispositivos`)
|
||||||
|
: "Cargando dispositivos...",
|
||||||
|
className: "col-12 col-md-6 col-lg-4",
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
GroupsContent.propTypes = {
|
||||||
|
config: PropTypes.shape({
|
||||||
|
devicesUrl: PropTypes.string.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Groups;
|
||||||
@@ -30,5 +30,5 @@ BME280Data_t BME280_Read()
|
|||||||
BME280::TempUnit tUnit(BME280::TempUnit_Celsius);
|
BME280::TempUnit tUnit(BME280::TempUnit_Celsius);
|
||||||
BME280::PresUnit pUnit(BME280::PresUnit_Pa);
|
BME280::PresUnit pUnit(BME280::PresUnit_Pa);
|
||||||
bme.read(p, t, h, tUnit, pUnit);
|
bme.read(p, t, h, tUnit, pUnit);
|
||||||
return {p, t, h};
|
return {p/100, t, h};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ void printAllData()
|
|||||||
Serial.println(DEVICE_ID, HEX);
|
Serial.println(DEVICE_ID, HEX);
|
||||||
|
|
||||||
Serial.print("Presión: ");
|
Serial.print("Presión: ");
|
||||||
Serial.print(bme280Data.pressure / 100);
|
Serial.print(bme280Data.pressure);
|
||||||
Serial.println(" hPa");
|
Serial.println(" hPa");
|
||||||
Serial.print("Temperatura: ");
|
Serial.print("Temperatura: ");
|
||||||
Serial.print(bme280Data.temperature);
|
Serial.print(bme280Data.temperature);
|
||||||
|
|||||||
Reference in New Issue
Block a user