diff --git a/front/src/app/embalse/[embalse]/page.tsx b/front/src/app/embalse/[embalse]/page.tsx index 361c200..1bca99e 100644 --- a/front/src/app/embalse/[embalse]/page.tsx +++ b/front/src/app/embalse/[embalse]/page.tsx @@ -4,8 +4,14 @@ import { EmbalsePod, getReservoirInfoBySlugCached, getEmbalseBySlugCached, + getAverageLastYearByMonthCached, + getAverageHistoricalByMonthCached, } from "@/pods/embalse"; -import { mapEmbalseToReservoirData } from "@/pods/embalse/embalse.mapper"; +import { + mapReservoirLastYearToViewModel, + mapEmbalseToReservoirData, + mapHistoricalReservoirToViewModel, +} from "@/pods/embalse/embalse.mapper"; export const revalidate = 300; // ISR: regenerar cada 5 minutos @@ -30,10 +36,32 @@ export default async function EmbalseDetallePage({ params }: Props) { const { embalse } = await params; const embalseDoc = await getEmbalseBySlugCached(embalse); const embalseInfo = await getReservoirInfoBySlugCached(embalse); + const actualYear = new Date().getFullYear(); + const actualMonth = new Date().getMonth(); // return month 0-11 if (!embalseDoc) { notFound(); } const reservoirData = mapEmbalseToReservoirData(embalseDoc, embalseInfo); - return ; + + const dataMappedOneYearAgo = await getAverageLastYearByMonthCached( + embalseDoc.nombre, + actualMonth + 1, + ).then(mapReservoirLastYearToViewModel); + + const averageHistoricalData = await getAverageHistoricalByMonthCached( + embalseDoc.nombre, + actualMonth + 1, + actualYear - 10, // 10 years ago + ).then(mapHistoricalReservoirToViewModel); + + return ( + <> + + + ); } diff --git a/front/src/app/globals.css b/front/src/app/globals.css index f0275c1..3c11b57 100644 --- a/front/src/app/globals.css +++ b/front/src/app/globals.css @@ -24,8 +24,10 @@ /* Title color */ --color-title: #051c1f; - /* Graphic total water */ + /* Colors of reservoir graphics */ --color-total-water: #26d6ed; + --line-average-last-year: #6904bb; + --line-average-last-ten-years: #952e00; /* Accesible visited link color */ --color-visited-link: #257782; diff --git a/front/src/pods/embalse/api/embalse.api-model.ts b/front/src/pods/embalse/api/embalse.api-model.ts index eaf2219..0db2038 100644 --- a/front/src/pods/embalse/api/embalse.api-model.ts +++ b/front/src/pods/embalse/api/embalse.api-model.ts @@ -11,3 +11,15 @@ export interface ReservoirInfo { description: string; mapUrl?: string; } + +export interface ReservoirLastYearModel { + mes: number; + promedio_agua_actual: number; +} + +export interface HistoricalAverageReservoir { + embalse: string; + mes: number; + año: number; + promedio_agua_actual: number; +} diff --git a/front/src/pods/embalse/api/embalse.api.ts b/front/src/pods/embalse/api/embalse.api.ts index 4b9e539..03b09c1 100644 --- a/front/src/pods/embalse/api/embalse.api.ts +++ b/front/src/pods/embalse/api/embalse.api.ts @@ -1,8 +1,16 @@ import "server-only"; import { unstable_cache } from "next/cache"; -import type { ReservoirInfo } from "./embalse.api-model"; +import type { + ReservoirInfo, + ReservoirLastYearModel, + HistoricalAverageReservoir, +} from "./embalse.api-model"; import { contentIslandClient } from "@/lib"; -import { getEmbalseBySlug } from "../embalse.repository"; +import { + getEmbalseBySlug, + getAverageLastYearByMonth, + getAverageHistoricalByMonth, +} from "../embalse.repository"; import type { Embalse } from "db-model"; /** @@ -46,3 +54,62 @@ export const getEmbalseBySlugCached = unstable_cache( ["embalse-by-slug"], { revalidate: 60 }, ); + +/** + * Function for historical average. + * + * Cached version of getHistoricalAverageByMonths. + * Revalidates every 60 minutes. + **/ +export const getAverageLastYearByMonthCached = unstable_cache( + async (name: string, month: number): Promise => { + try { + const statisticsReservoir = await getAverageLastYearByMonth(name, month); + + if (!statisticsReservoir) { + throw new Error("Empty data last year by month - skip cache"); + } + + return statisticsReservoir; + } catch (error) { + console.warn( + "getAverageLastYearByMonthCached: MongoDB not available or empty, returning empty array.", + "Error:", + error instanceof Error ? error.message : error, + ); + return; + } + }, + ["reservoir-last-year"], + { revalidate: 3600 }, +); + +export const getAverageHistoricalByMonthCached = unstable_cache( + async ( + reservoirName: string, + month: number, + year: number, + ): Promise => { + try { + const historicalStatistics = await getAverageHistoricalByMonth( + reservoirName, + month, + year, + ); + + if (!historicalStatistics) { + throw new Error("Empty historical data by month and year"); + } + return historicalStatistics; + } catch (error) { + console.warn( + "getAverageHistoricalByMonthCached: MongoDB not available or empty, returning empty array.", + "Error:", + error instanceof Error ? error.message : error, + ); + return; + } + }, + ["reservoir-last-ten-year"], + { revalidate: 3600 }, +); diff --git a/front/src/pods/embalse/components/chart/chart-legend.tsx b/front/src/pods/embalse/components/chart/chart-legend.tsx new file mode 100644 index 0000000..a7cf76f --- /dev/null +++ b/front/src/pods/embalse/components/chart/chart-legend.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { monthsNames } from "./chart.constants"; +import { ReferenceLine } from "./chart.helpers"; + +interface Props { + currentLevel: number; + monthOneYearAgo: number; + yearOneYearAgo: number; + averageOneYearAgo: number; + monthTenYearsAgo: number; + yearTenYearsAgo: number; + averageTenYearsAgo: number; +} +export const ChartLegend: React.FC = (props) => { + const { + currentLevel, + monthOneYearAgo, + yearOneYearAgo, + averageOneYearAgo, + monthTenYearsAgo, + yearTenYearsAgo, + averageTenYearsAgo, + } = props; + + return ( +
+
+
+
+ Embalsada: + {currentLevel} Hm³ +
+
+ {averageOneYearAgo && ( +
+
+
+ + {monthsNames[monthOneYearAgo - 1]} de {yearOneYearAgo}: + + {averageOneYearAgo} Hm³ +
+
+ )} + {averageTenYearsAgo && ( +
+
+
+ + {monthsNames[monthTenYearsAgo - 1]} de {yearTenYearsAgo}: + + {averageTenYearsAgo} Hm³ +
+
+ )} +
+ ); +}; diff --git a/front/src/pods/embalse/components/chart/chart.constants.ts b/front/src/pods/embalse/components/chart/chart.constants.ts new file mode 100644 index 0000000..0ba2aac --- /dev/null +++ b/front/src/pods/embalse/components/chart/chart.constants.ts @@ -0,0 +1,23 @@ +// Declare the chart dimensions and margins. + +export const sizeChart = { + width: 200, + height: 180, + margin: { top: 0, right: 30, bottom: 0, left: 30 }, + radius: 10, +}; + +export const monthsNames = [ + "Enero", + "Febrero", + "Marzo", + "Abril", + "Mayo", + "Junio", + "Julio", + "Agosto", + "Septiembre", + "Octubre", + "Noviembre", + "Diciembre", +]; diff --git a/front/src/pods/embalse/components/chart/chart.helpers.tsx b/front/src/pods/embalse/components/chart/chart.helpers.tsx new file mode 100644 index 0000000..21e48d3 --- /dev/null +++ b/front/src/pods/embalse/components/chart/chart.helpers.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { sizeChart as s } from "./chart.constants"; + +interface barRoundedTopProps { + x: number; + y: number; + width: number; + height: number; + fill: string; +} +export const BarRoundedTop: React.FC = ({ + x, + y, + width, + height, + fill, +}): React.ReactNode => { + return ( + + {/* Barra según porcentaje con esquinas redondeadas */} + + {/* Barra inferior sin redondeo para aplanar la base */} + + + ); +}; + +export const ReferenceLine: React.FC<{ + yPos: number; + x1: number; + x2: number; + stroke: string; + dashArray: string; +}> = ({ yPos, x1, x2, stroke, dashArray }) => ( + +); diff --git a/front/src/pods/embalse/components/chart/chart.vm.ts b/front/src/pods/embalse/components/chart/chart.vm.ts new file mode 100644 index 0000000..42a03c4 --- /dev/null +++ b/front/src/pods/embalse/components/chart/chart.vm.ts @@ -0,0 +1,13 @@ +import { + DataLastYearModel, + HistoricalAverageReservoir, +} from "@/pods/embalse/embalse.vm"; + +export interface ChartModel { + titleChart?: string; + reservoirName: string; + currentLevel: number; + maxCapacity: number; + dataOneYearAgo?: DataLastYearModel; + dataTenYearsAgo?: HistoricalAverageReservoir; +} diff --git a/front/src/pods/embalse/components/chart/history-chart.tsx b/front/src/pods/embalse/components/chart/history-chart.tsx new file mode 100644 index 0000000..da3fc53 --- /dev/null +++ b/front/src/pods/embalse/components/chart/history-chart.tsx @@ -0,0 +1,129 @@ +import * as d3 from "d3"; +import { ChartModel } from "./chart.vm"; +import { sizeChart as s } from "./chart.constants"; +import { ChartLegend } from "./chart-legend"; +import { BarRoundedTop, ReferenceLine } from "./chart.helpers"; + +export const HistoryChart: React.FC = ({ + titleChart, + reservoirName, + currentLevel, + maxCapacity, + dataOneYearAgo, + dataTenYearsAgo, +}) => { + let percentageActual = (currentLevel * 100) / maxCapacity; + if (percentageActual > 100) { + percentageActual = 100; + } + const isOutside = percentageActual < 10; + // Cálculo de escalas + const x = d3 + .scaleBand() + .domain([reservoirName]) + .range([s.margin.left, s.width - s.margin.right]) + .padding(0.2); + + const y = d3 + .scaleLinear() + .domain([0, 105]) + .range([s.height - s.margin.bottom, s.margin.top]); + + const barX = x(reservoirName); + const barWidth = x.bandwidth(); + const barY = y(percentageActual); + const barHeight = y(0) - barY; + + // Extremos compartidos por las líneas de referencia + const refX1 = barX - s.margin.left / 2; + const refX2 = barX * 2 + s.margin.left + s.margin.right; + + // Etiqueta: encima de la barra si el nivel es muy bajo (<10%), dentro si no + const labelY = isOutside ? barY - 8 : barY + 20; + + return ( +
+

+ {titleChart} +

+ + + {/* Indicador de capacidad total (100%) */} + + + {/* Nivel actual */} + + + {/* Línea de referencia: mismo mes del año anterior */} + {dataOneYearAgo && ( + + )} + + {/* Línea de referencia: mismo mes hace 10 años */} + {dataTenYearsAgo && ( + + )} + {/* Etiqueta con el nivel actual en Hm³ */} + + {currentLevel} Hm³ + + + {/* Eje X */} + + + + +
+ ); +}; diff --git a/front/src/pods/embalse/components/chart/index.ts b/front/src/pods/embalse/components/chart/index.ts new file mode 100644 index 0000000..542fa0c --- /dev/null +++ b/front/src/pods/embalse/components/chart/index.ts @@ -0,0 +1,2 @@ +export * from "./history-chart"; +export * from "./chart.vm"; diff --git a/front/src/pods/embalse/components/index.ts b/front/src/pods/embalse/components/index.ts index 0843170..f62974a 100644 --- a/front/src/pods/embalse/components/index.ts +++ b/front/src/pods/embalse/components/index.ts @@ -2,3 +2,4 @@ export * from "./reservoir-card-detail"; export * from "./reservoir-card-gauge"; export * from "./reservoir-card-info.component"; export * from "./reservoir-gauge"; +export * from "./chart"; diff --git a/front/src/pods/embalse/components/reservoir-card-gauge.tsx b/front/src/pods/embalse/components/reservoir-card-gauge.tsx index 937bbb4..f24f131 100644 --- a/front/src/pods/embalse/components/reservoir-card-gauge.tsx +++ b/front/src/pods/embalse/components/reservoir-card-gauge.tsx @@ -1,28 +1,92 @@ -import { ReservoirData } from "../embalse.vm"; +"use client"; +import React from "react"; +import { + DataLastYearModel, + HistoricalAverageReservoir, + ReservoirData, +} from "../embalse.vm"; import { GaugeChart } from "./reservoir-gauge"; import { GaugeLegend } from "./reservoir-gauge/gauge-chart/components/gauge-legend.component"; +import { HistoryChart } from "./chart"; +import { useIsMobile } from "./useIsMobile"; interface Props { name: string; reservoirData: ReservoirData; + dataOneYearAgo?: DataLastYearModel; + dataTenYearsAgo?: HistoricalAverageReservoir; } export const ReservoirCardGauge: React.FC = (props) => { - const { name, reservoirData } = props; + const { name, reservoirData, dataOneYearAgo, dataTenYearsAgo } = props; const { currentVolume, totalCapacity, measurementDate } = reservoirData; const percentage = totalCapacity > 0 ? currentVolume / totalCapacity : 0; + const [cardGaugeSelected, setCardGaugeSelected] = + React.useState(true); + + const isMobile = useIsMobile(); + + const handleGraphicDisplay = (e: React.MouseEvent) => { + const action = e.currentTarget.name; + if (action === "currentStatus") { + setCardGaugeSelected(true); + } else if (action === "historicalStatus") { + setCardGaugeSelected(false); + } + }; return (

{name}

- 100 ? 100 : percentage} measurementDate= - {measurementDate} /> - + {isMobile && ( +
+ + +
+ )} + {cardGaugeSelected || !isMobile ? ( + <> + 100 ? 100 : percentage} + measurementDate={measurementDate} + /> + + + ) : ( + + )}
); }; diff --git a/front/src/pods/embalse/components/reservoir-gauge/gauge-chart/components/gauge-arcs.business.ts b/front/src/pods/embalse/components/reservoir-gauge/gauge-chart/components/gauge-arcs.business.ts index f96eb17..b80436f 100644 --- a/front/src/pods/embalse/components/reservoir-gauge/gauge-chart/components/gauge-arcs.business.ts +++ b/front/src/pods/embalse/components/reservoir-gauge/gauge-chart/components/gauge-arcs.business.ts @@ -19,8 +19,6 @@ const createArcGenerator = (endAngle: number) => { .cornerRadius(arcConfig.cornerRadius); }; - - export const calculateFilledAngle = (percentage: number): number => { // Ensure percentage is within valid range [0, 1] const normalized = Math.max(0, Math.min(1, percentage)); @@ -30,7 +28,10 @@ export const calculateFilledAngle = (percentage: number): number => { return arcConfig.startAngle + normalized * totalAngle; }; -export const drawArc = ({ arcGroup, endAngle, fillColor }: DrawArcParams, animate: boolean = false) => { +export const drawArc = ( + { arcGroup, endAngle, fillColor }: DrawArcParams, + animate: boolean = false, +) => { const arcGenerator = createArcGenerator(endAngle); if (animate) { @@ -51,10 +52,12 @@ export const drawArc = ({ arcGroup, endAngle, fillColor }: DrawArcParams, animat } }; - -export const drawAnimatedArc = ({ arcGroup, endAngle, fillColor }: DrawArcParams) => { +export const drawAnimatedArc = ({ + arcGroup, + endAngle, + fillColor, +}: DrawArcParams) => { const arcGeneratorStart = createArcGenerator(arcConfig.startAngle); - arcGroup .append("path") @@ -63,11 +66,11 @@ export const drawAnimatedArc = ({ arcGroup, endAngle, fillColor }: DrawArcParams .transition() .duration(2000) .ease(d3.easeCubicInOut) - .attrTween("d", function() { + .attrTween("d", function () { const interpolate = d3.interpolate(arcConfig.startAngle, endAngle); - return function(t) { + return function (t) { const arcGenerator = createArcGenerator(interpolate(t)); - return arcGenerator(this) || ""; + return arcGenerator(null) || ""; }; }); }; diff --git a/front/src/pods/embalse/components/useIsMobile.ts b/front/src/pods/embalse/components/useIsMobile.ts new file mode 100644 index 0000000..6bb8515 --- /dev/null +++ b/front/src/pods/embalse/components/useIsMobile.ts @@ -0,0 +1,16 @@ +import React from "react"; + +export const useIsMobile = () => { + const [isMobile, setIsMobile] = React.useState(false); + React.useEffect(() => { + const mediaQuery = window.matchMedia("(max-width: 768px)"); + setIsMobile(mediaQuery.matches); + + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mediaQuery.addEventListener("change", handler); + + return () => mediaQuery.removeEventListener("change", handler); + }, []); + + return isMobile; +}; diff --git a/front/src/pods/embalse/embalse.component.tsx b/front/src/pods/embalse/embalse.component.tsx index a1566af..78ed5fa 100644 --- a/front/src/pods/embalse/embalse.component.tsx +++ b/front/src/pods/embalse/embalse.component.tsx @@ -3,10 +3,17 @@ import { ReservoirCardDetail, ReservoirCardGauge, ReservoirCardInfo, + HistoryChart, } from "./components"; -import { ReservoirData } from "./embalse.vm"; +import { + ReservoirData, + DataLastYearModel, + HistoricalAverageReservoir, +} from "./embalse.vm"; interface Props { reservoirData: ReservoirData; + dataOneYearAgo: DataLastYearModel; + dataTenYearsAgo: HistoricalAverageReservoir; } /** La prop name de ReservoirCardGauge ahora recibe reservoirData.nombre (el nombre real del embalse desde la BD). @@ -14,24 +21,36 @@ interface Props { */ export const Embalse: React.FC = (props) => { - const { reservoirData } = props; + const { reservoirData, dataOneYearAgo, dataTenYearsAgo } = props; + return (
+
+
+
- {reservoirData.reservoirInfo && ( -
+
)} {reservoirData.datosEmbalse.mapUrl && ( -
+