1
0

Implemented (partially) Voronoi algorithm for zone-dividing in Seville map. Also refactored some things in frontend. Modified hardware firmware for conditional compilation for both SENSOR and ACTUATOR type boards.

This commit is contained in:
Jose
2025-05-16 23:05:46 +02:00
parent 51862cf0a8
commit cdff306ca1
30 changed files with 571 additions and 182 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,6 +7,8 @@ import { useTheme } from "@/hooks/useTheme";
import { DataProvider } from "@/context/DataContext.jsx";
import { useDataContext } from "@/hooks/useDataContext";
import { useConfig } from "@/hooks/useConfig";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler);
@@ -54,16 +56,31 @@ const HistoryChartsContent = () => {
carbonMonoxide: []
};
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
const isToday = (timestamp) => {
const date = new Date(timestamp * 1000);
return (
date.getUTCFullYear() >= threeDaysAgo.getUTCFullYear() &&
date.getUTCMonth() >= threeDaysAgo.getUTCMonth() &&
date.getUTCDate() >= threeDaysAgo.getUTCDate()
);
};
data?.forEach(sensor => {
if (sensor.value != null && grouped[sensor.valueType]) {
if (
sensor.value != null &&
grouped[sensor.valueType] &&
isToday(sensor.timestamp)
) {
grouped[sensor.valueType].push({
timestamp: sensor.timestamp * 1000,
value: sensor.value
});
}
});
const sortAndExtract = (entries) => {
const sorted = entries.sort((a, b) => a.timestamp - b.timestamp);
@@ -75,45 +92,50 @@ const HistoryChartsContent = () => {
})
);
const values = sorted.map(e => e.value);
return { labels, values };
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 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 timeLabels = temp.labels.length ? temp.labels : hum.labels.length ? hum.labels : co.labels.length ? co.labels : ["Sin datos"];
const historyData = [
{ title: "🌡️ Temperatura", data: temp.values, borderColor: "#00FF85", backgroundColor: "rgba(0, 255, 133, 0.2)" },
{ title: "💦 Humedad", data: hum.values, borderColor: "#00D4FF", backgroundColor: "rgba(0, 212, 255, 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)" }
];
const historyData = [
{ title: "🌡️ Temperatura", data: temp.values, borderColor: "#00FF85", backgroundColor: "rgba(0, 255, 133, 0.2)" },
{ title: "💦 Humedad", data: hum.values, borderColor: "#00D4FF", backgroundColor: "rgba(0, 212, 255, 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 (
<CardContainer
cards={historyData.map(({ title, data, borderColor, backgroundColor }) => ({
title,
content: (
<Line style={{ minHeight: "250px" }}
data={{
labels: timeLabels,
datasets: [{ data, borderColor, backgroundColor, fill: true, tension: 0.4 }]
}}
options={options}
/>
),
styleMode: "override",
className: "col-lg-6 col-xxs-12 d-flex flex-column align-items-center p-3 card-container",
style: { minHeight: "250px" }
}))}
className=""
/>
);
return (
<>
<CardContainer
cards={historyData.map(({ title, data, borderColor, backgroundColor }) => ({
title,
content: (
<Line style={{ minHeight: "250px" }}
data={{
labels: timeLabels,
datasets: [{ data, borderColor, backgroundColor, fill: true, tension: 0.4 }]
}}
options={options}
/>
),
styleMode: "override",
className: "col-lg-6 col-xxs-12 d-flex flex-column align-items-center",
style: { minHeight: "250px" }
}))}
/>
<span className="m-0 p-0 d-flex align-items-center justify-content-center">
<FontAwesomeIcon icon={faInfoCircle} className="me-2" />
<p className="m-0 p-0">El historial muestra datos de los últimos 3 días</p>
</span>
</>
);
};
HistoryCharts.propTypes = {

View File

@@ -72,9 +72,7 @@ const PollutionMapContent = () => {
if (!data) return <p>Datos no disponibles.</p>;
return (
<div className="p-3">
<div id="map" className='rounded-4' style={{ height: "60vh" }}></div>
</div>
<div id="map" className='rounded-4' style={{ height: "60vh" }}></div>
);
}

View File

@@ -39,12 +39,52 @@ const SummaryCardsContent = () => {
if (!data) return <p>Datos no disponibles.</p>;
const CardsData = [
{ id: 1, title: "Temperatura", content: "N/A", status: "Esperando datos...", titleIcon: '🌡 ' },
{ id: 2, title: "Humedad", content: "N/A", status: "Esperando datos...", titleIcon: '💦 ' },
{ id: 3, title: "Presión", content: "N/A", status: "Esperando datos...", titleIcon: '⏲ ' },
{ id: 4, title: "Nivel de CO", content: "N/A", status: "Esperando datos...", titleIcon: '☁ ' }
{
id: 1,
title: "Temperatura",
content: "N/A",
status: "Esperando datos...",
titleIcon: '🌡 ',
className: "col-12 col-md-6 col-lg-3",
link: false,
text: true
},
{
id: 2,
title: "Humedad",
content: "N/A",
status: "Esperando datos...",
titleIcon: '💦 ',
className: "col-12 col-md-6 col-lg-3",
link: false,
text: true
},
{
id: 3,
title: "Presión",
content: "N/A",
status: "Esperando datos...",
titleIcon: '⏲ ',
className: "col-12 col-md-6 col-lg-3",
link: false,
text: true
},
{
id: 4,
title: "Nivel de CO",
content: "N/A",
status: "Esperando datos...",
titleIcon: '☁ ',
className: "col-12 col-md-6 col-lg-3",
link: false,
text: true
}
];
if (data) {
let coData = data[2];
let tempData = data[1];
@@ -61,7 +101,7 @@ const SummaryCardsContent = () => {
}
return (
<CardContainer text cards={CardsData} />
<CardContainer cards={CardsData} />
);
}

View File

@@ -1,9 +1,22 @@
import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react";
import { Link } from "react-router-dom";
import "@/css/Card.css";
import { useTheme } from "@/hooks/useTheme";
const Card = ({ title, status, children, styleMode, className, titleIcon, style }) => {
const Card = ({
title,
status,
children,
styleMode,
className,
titleIcon,
style,
link,
to,
text,
marquee
}) => {
const cardRef = useRef(null);
const [shortTitle, setShortTitle] = useState(title);
const { theme } = useTheme();
@@ -25,37 +38,59 @@ const Card = ({ title, status, children, styleMode, className, titleIcon, style
return () => window.removeEventListener("resize", checkSize);
}, [title]);
return (
const cardContent = (
<div
ref={cardRef}
className={styleMode === "override" ? `${className}` :
`col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`}
className={`card p-3 w-100 ${theme} ${className ?? ""}`}
style={styleMode === "override" ? style : {}}
>
<div className={`card p-3 w-100 ${theme}`} style={styleMode === "override" ? style : {}}>
<h3 className="text-center">
{titleIcon}
{shortTitle}
</h3>
<div className="card-content">{children}</div>
{status ? <span className="status text-center mt-2">{status}</span> : null}
<h3 className="text-center">
{titleIcon}
{shortTitle}
</h3>
<div className="card-content">
{marquee ? (
<marquee>
<p className="card-text text-center">{children}</p>
</marquee>
) : text ? (
<p className="card-text text-center">{children}</p>
) : (
<div className="my-2">{children}</div>
)}
</div>
{status && <span className="status text-center mt-2">{status}</span>}
</div>
);
return link && to
? <Link to={to} style={{ textDecoration: "none" }}>{cardContent}</Link>
: cardContent;
};
Card.propTypes = {
title: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
styleMode: PropTypes.oneOf(["override", ""]),
className: PropTypes.string,
styleMode: PropTypes.oneOf(["override", ""]),
className: PropTypes.string,
titleIcon: PropTypes.node,
style: PropTypes.object,
link: PropTypes.bool,
to: PropTypes.string,
text: PropTypes.bool,
};
Card.defaultProps = {
styleMode: "",
className: "",
style: {},
link: false,
to: "",
text: false,
};
export default Card;

View File

@@ -1,41 +1,33 @@
import Card from "./Card.jsx";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
const CardContainer = ({ links, cards, className, text }) => {
const CardContainer = ({ cards, className }) => {
return (
<div className={`row justify-content-center g-0 ${className}`}>
<div className={`row justify-content-center g-3 ${className}`}>
{cards.map((card, index) => (
links ? (
<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} style={card.style}>
{text
? <p className="card-text text-center">{card.content}</p>
: <div className="my-2">{card.content}</div>
}
</Card>
</Link>
) : (
<Card key={index} title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon} style={card.style}>
{text
? <p className="card-text text-center">{card.content}</p>
: <div className="my-2">{card.content}</div>
}
<div key={index} className={card.className ?? "col-12 col-md-6 col-lg-3"}>
<Card {...card}>
{card.content}
</Card>
)
</div>
))}
</div>
);
};
CardContainer.propTypes = {
links: Boolean,
text: Boolean,
cards: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
className: PropTypes.string,
styleMode: PropTypes.string,
style: PropTypes.object,
titleIcon: PropTypes.node,
link: PropTypes.bool,
to: PropTypes.string,
text: PropTypes.bool,
})
).isRequired,
className: PropTypes.string,

View File

@@ -7,17 +7,13 @@ const Header = ({ subtitle }) => {
const { theme } = useTheme();
return (
<header className={`row justify-content-center text-center mb-4 ${theme}`}>
<header className={`animated-header row justify-content-center text-center mb-4 ${theme}`}>
<div className='col-xl-4 col-lg-6 col-8'>
<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>
);
}

View File

@@ -46,6 +46,14 @@
margin-right: 10px;
}
.card.led marquee > p.card-text {
font-family: "LEDBOARD" !important;
color: rgb(38, 60, 229) !important;
font-size: 2.5em !important;
text-transform: uppercase !important;
letter-spacing: 1px !important;
}
p.card-text {
font-size: 2.2em;
font-weight: 600;

View File

@@ -34,6 +34,21 @@ header > .subtitle {
animation: fadeIn 2s ease-in-out;
}
.animated-header {
animation: pulseHeader 6s ease-in-out infinite;
}
@keyframes pulseHeader {
0%, 100% {
opacity: 0.96;
transform: translateY(3px);
}
50% {
opacity: 1;
transform: translateY(-3px);
}
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }

View File

@@ -12,42 +12,52 @@
--card-gradient-secondary: #353535;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
font-family: "Open Sans";
src: url('/fonts/OpenSans.ttf');
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiEyp8kv8JHgFVrJJfecg.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;
font-family: "Product Sans";
src: url('/fonts/ProductSansRegular.ttf');
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
font-family: "Product Sans Italic";
src: url('/fonts/ProductSansItalic.ttf');
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
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;
font-family: "Product Sans Italic Bold";
src: url('/fonts/ProductSansBoldItalic.ttf');
}
@font-face {
font-family: "Product Sans Bold";
src: url('/fonts/ProductSansBold.ttf');
}
@font-face {
font-family: "LEDBOARD";
src: url('/fonts/LEDBOARD.ttf');
}
/* Tipografía global */
div,
label,
input,
p,
span,
a,
button {
font-family: "Open Sans", sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Product Sans", sans-serif;
}

View File

@@ -8,7 +8,7 @@ const Dashboard = () => {
const { groupId, deviceId } = useParams();
return (
<main className='container justify-content-center'>
<main className='container justify-content-center gap-3 d-flex flex-column'>
<SummaryCards groupId={groupId} deviceId={deviceId} />
<PollutionMap groupId={groupId} deviceId={deviceId} />
<HistoryCharts groupId={groupId} deviceId={deviceId} />

View File

@@ -9,8 +9,10 @@ import { useEffect, useState } from "react";
import { DataProvider } from "@/context/DataContext";
import { MapContainer, TileLayer, Marker } from 'react-leaflet';
import L from 'leaflet';
import L, { map } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import PropTypes from 'prop-types';
import { text } from "@fortawesome/fontawesome-svg-core";
// Icono de marcador por defecto (porque Leaflet no lo carga bien en algunos setups)
const markerIcon = new L.Icon({
@@ -19,6 +21,7 @@ const markerIcon = new L.Icon({
iconAnchor: [12, 41],
});
const MiniMap = ({ lat, lon }) => (
<MapContainer
center={[lat, lon]}
@@ -34,6 +37,11 @@ const MiniMap = ({ lat, lon }) => (
</MapContainer>
);
MiniMap.propTypes = {
lat: PropTypes.number.isRequired,
lon: PropTypes.number.isRequired,
};
const GroupView = () => {
const { groupId } = useParams();
const { config, configLoading } = useConfig();
@@ -91,21 +99,20 @@ const GroupViewContent = () => {
return (
<CardContainer
links
cards={data.map(device => {
const latest = latestData[device.deviceId];
const gpsSensor = latest?.data[0];
const mapPreview = gpsSensor?.lat && gpsSensor?.lon
? <MiniMap lat={gpsSensor.lat} lon={gpsSensor.lon} />
: "Sin posición";
const mapPreview = <MiniMap lat={gpsSensor?.lat} lon={gpsSensor?.lon} />;
return {
title: device.deviceName,
status: `ID: ${device.deviceId}`,
content: mapPreview,
link: gpsSensor != undefined,
text: gpsSensor == undefined,
marquee: gpsSensor == undefined,
content: gpsSensor == undefined ? "SOLO VEHICULOS ELECTRICOS" : mapPreview,
to: `/groups/${groupId}/devices/${device.deviceId}`,
styleMode: "override",
className: "col-12 col-md-6 col-lg-4"
className: `col-12 col-md-6 col-lg-4 ${gpsSensor == undefined ? "led" : ""}`,
};
})}

View File

@@ -60,17 +60,16 @@ const GroupsContent = ({ config }) => {
return (
<CardContainer
links
text
cards={data.map(group => {
const groupDevices = devices[group.groupId]?.data;
const deviceCount = groupDevices?.length;
return {
title: group.groupName,
link: true,
text: true,
status: `ID: ${group.groupId}`,
to: `/groups/${group.groupId}`,
styleMode: "override",
content: deviceCount != null
? (deviceCount === 1 ? "1 dispositivo" : `${deviceCount} dispositivos`)
: "Cargando dispositivos...",