Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f8401b4
feat(#55): add D3.js library to project dependencies
gustedeveloper Dec 9, 2025
1e8f57a
feat(#55): add custom CSS variable for total water graphic color
gustedeveloper Dec 9, 2025
4d49977
feat(#55): add reservoir data model and mock data
gustedeveloper Dec 9, 2025
45d9080
feat(#55): add gauge chart model with dimensions and arc configuration
gustedeveloper Dec 9, 2025
e609d99
feat(#55): implement gauge arcs component with D3.js for dynamic arc …
gustedeveloper Dec 9, 2025
bfe1756
feat(#55): add GaugeChart component with gauge information and arcs f…
gustedeveloper Dec 9, 2025
e9a3e62
feat(#55): integrate ReservoirGauge component into EmbalseDetallePage…
gustedeveloper Dec 9, 2025
795c173
fix(#55): normalize percentage input in calculateFilledAngle function…
gustedeveloper Dec 9, 2025
63d2073
style(#55): update ReservoirGauge component styling for improved layo…
gustedeveloper Dec 9, 2025
9ea1b8a
refactor(#55): replace spread operator with explicit props in Reservo…
gustedeveloper Dec 12, 2025
1e2dc79
Merge branch 'main' into Feature/#55-frontend-detail-of-the-reservoir…
manudous Jan 12, 2026
f16bea1
animacion gauge-Arc
reguer0 Jan 13, 2026
c9c577d
eliminacion funcion sin uso
reguer0 Jan 20, 2026
becf0cb
test + cards datos del embalse
reguer0 Jan 20, 2026
fce3677
refactor to pod structure
brauliodiez Jan 28, 2026
aaf1ac3
update
brauliodiez Jan 28, 2026
3aeca9e
implement EmbalsePod component and refactor EmbalseDetallePage to use it
manudous Jan 28, 2026
b917d2b
refactor: restructure reservoir data handling and update components t…
manudous Jan 28, 2026
ccc6d29
feat: add Embalse component and refactor EmbalsePod to utilize it
manudous Jan 28, 2026
81cee3d
refactor: remove unused percentage prop from ReservoirGauge component
manudous Jan 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions front/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dependencies": {
"@fontsource/nunito-sans": "^5.2.7",
"@tailwindcss/postcss": "^4.1.17",
"d3": "^7.9.0",
"next": "^15.4.1",
"postcss": "^8.5.6",
"react": "^19.1.0",
Expand Down
9 changes: 3 additions & 6 deletions front/src/app/embalse/[embalse]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import Link from "next/link";
import { EmbalsePod } from "@/pods/embalse";

interface Props {
params: Promise<{ embalse: string }>;
}

export default async function EmbalseDetallePage({ params }: Props) {
const { embalse } = await params;
return (
<div className="flex flex-col gap-8">
<h2>Detalle del embalse: {embalse}</h2>
</div>
);

return <EmbalsePod embalse={embalse} />;
}
3 changes: 3 additions & 0 deletions front/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
/* Title color */
--color-title: #051c1f;

/* Graphic total water */
--color-total-water: #26d6ed;

/* Accesible visited link color */
--color-visited-link: #257782;

Expand Down
2 changes: 1 addition & 1 deletion front/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import "./globals.css";
import { FooterComponent, HeaderComponent } from "./layouts";
import { FooterComponent, HeaderComponent } from "../layouts";

interface Props {
children: React.ReactNode;
Expand Down
File renamed without changes.
4 changes: 4 additions & 0 deletions front/src/pods/embalse/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./reservoir-card-detail";
export * from "./reservoir-card-gauge";
export * from "./reservoir-card-info.component";
export * from "./reservoir-gauge";
26 changes: 26 additions & 0 deletions front/src/pods/embalse/components/reservoir-card-detail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from "react";
import { DatosEmbalse } from "../embalse.vm";

interface Props {
datosEmbalse: DatosEmbalse;
}

export const ReservoirCardDetail: React.FC<Props> = (props) => {
const { datosEmbalse } = props;
return (
<div className="flex w-full flex-col items-start gap-4">
<h3>Datos del embalse</h3>
<ul>
<li>Cuenca: {datosEmbalse.cuenca}</li>
<li>Provincia: {datosEmbalse.provincia}</li>
<li>Municipio: {datosEmbalse.municipio}</li>
<li>Río: {datosEmbalse.rio}</li>
<li>Embalses Aguas Abajo: {datosEmbalse.embalsesAguasAbajo}</li>
<li>Tipo de Presa: {datosEmbalse.tipoDePresa}</li>
<li>Año de Construcción: {datosEmbalse.anioConstruccion}</li>
<li>Superficie: {datosEmbalse.superficie} </li>
<li>Localización: {datosEmbalse.localizacion}</li>
</ul>
</div>
);
};
26 changes: 26 additions & 0 deletions front/src/pods/embalse/components/reservoir-card-gauge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ReservoirData } from "../embalse.vm";
import { GaugeChart } from "./reservoir-gauge";
import { GaugeLegend } from "./reservoir-gauge/gauge-chart/components/gauge-legend.component";

interface Props {
name: string;
reservoirData: ReservoirData;
}

export const ReservoirCardGauge: React.FC<Props> = (props) => {
const { name, reservoirData } = props;
const { currentVolume, totalCapacity, measurementDate } = reservoirData;
// const percentage = currentVolume / totalCapacity;
// TODO: replace hardcoded % for real reservoir filled water percentage

return (
<div className="card bg-base-100 mx-auto w-full max-w-[400px] items-center gap-6 rounded-2xl p-4 shadow-lg">
<h2 className="text-center">Embalse de {name}</h2>
<GaugeChart percentage={0.67} measurementDate={measurementDate} />
<GaugeLegend
currentVolume={currentVolume}
totalCapacity={totalCapacity}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ReservoirInfo } from "../embalse.vm";

interface Props {
reservoirInfo: ReservoirInfo;
}

export const ReservoirCardInfo: React.FC<Props> = (props) => {
const { reservoirInfo } = props;
return (
<div className="flex w-full flex-col items-start gap-4">
<h2>Descubre el embalse</h2>
<p>{reservoirInfo?.Description}</p>
<img
className="mt-4 w-full rounded-xl sm:w-1/2 lg:w-1/3"
src="/images/embalse-generico.jpg"
alt="Mapa de embalses"
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { calculateFilledAngle } from "./gauge-arcs.business";
import { arcConfig } from "./model";

describe("calculateFilledAngle", () => {
it("should return start angle when percentage is 0", () => {
const result = calculateFilledAngle(0);
expect(result).toBe(arcConfig.startAngle);
});

it("should return end angle when percentage is 1", () => {
const result = calculateFilledAngle(1);
expect(result).toBe(arcConfig.endAngle);
});

it("should return middle angle when percentage is 0.5", () => {
const result = calculateFilledAngle(0.5);
const expectedMiddle = arcConfig.startAngle + (arcConfig.endAngle - arcConfig.startAngle) * 0.5;
expect(result).toBe(expectedMiddle);
});

it("should handle percentages between 0 and 1 correctly", () => {
const testCases = [
{ percentage: 0.25, expected: arcConfig.startAngle + (arcConfig.endAngle - arcConfig.startAngle) * 0.25 },
{ percentage: 0.75, expected: arcConfig.startAngle + (arcConfig.endAngle - arcConfig.startAngle) * 0.75 },
{ percentage: 0.1, expected: arcConfig.startAngle + (arcConfig.endAngle - arcConfig.startAngle) * 0.1 },
];

testCases.forEach(({ percentage, expected }) => {
const result = calculateFilledAngle(percentage);
expect(result).toBeCloseTo(expected, 10);
});
});

it("should clamp negative percentages to 0", () => {
const result = calculateFilledAngle(-0.5);
expect(result).toBe(arcConfig.startAngle);
});

it("should clamp percentages greater than 1 to 1", () => {
const result = calculateFilledAngle(1.5);
expect(result).toBe(arcConfig.endAngle);
});

it("should handle edge case percentages", () => {
expect(calculateFilledAngle(-Infinity)).toBe(arcConfig.startAngle);
expect(calculateFilledAngle(Infinity)).toBe(arcConfig.endAngle);
expect(isNaN(calculateFilledAngle(NaN))).toBe(true);
});

it("should handle very small positive percentages", () => {
const result = calculateFilledAngle(0.001);
const expected = arcConfig.startAngle + (arcConfig.endAngle - arcConfig.startAngle) * 0.001;
expect(result).toBeCloseTo(expected, 10);
});

it("should handle very large negative percentages", () => {
const result = calculateFilledAngle(-1000);
expect(result).toBe(arcConfig.startAngle);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as d3 from "d3";
import { arcConfig } from "./model";

type ArcGroup = d3.Selection<SVGGElement, unknown, null, undefined>;

interface DrawArcParams {
arcGroup: ArcGroup;
endAngle: number;
fillColor: string;
}

const createArcGenerator = (endAngle: number) => {
return d3
.arc()
.innerRadius(arcConfig.innerRadius)
.outerRadius(arcConfig.outerRadius)
.startAngle(arcConfig.startAngle)
.endAngle(endAngle)
.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));
// Total sweep of the arc (from start to end)
const totalAngle = arcConfig.endAngle - arcConfig.startAngle;
// Calculate where the filled arc should end based on percentage
return arcConfig.startAngle + normalized * totalAngle;
};

export const drawArc = ({ arcGroup, endAngle, fillColor }: DrawArcParams, animate: boolean = false) => {
const arcGenerator = createArcGenerator(endAngle);

if (animate) {
arcGroup
.append("path")
.attr("d", arcGenerator as any)
.style("fill", fillColor)
.attr("opacity", 0)
.transition()
.duration(1500)
.ease(d3.easeCubicInOut)
.attr("opacity", 1);
} else {
arcGroup
.append("path")
.attr("d", arcGenerator as any)
.style("fill", fillColor);
}
};


export const drawAnimatedArc = ({ arcGroup, endAngle, fillColor }: DrawArcParams) => {
const arcGeneratorStart = createArcGenerator(arcConfig.startAngle);


arcGroup
.append("path")
.attr("d", arcGeneratorStart as any)
.style("fill", fillColor)
.transition()
.duration(2000)
.ease(d3.easeCubicInOut)
.attrTween("d", function() {
const interpolate = d3.interpolate(arcConfig.startAngle, endAngle);
return function(t) {
const arcGenerator = createArcGenerator(interpolate(t));
return arcGenerator(this) || "";
};
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

import * as d3 from "d3";
import { useEffect, useRef } from "react";
import { calculateFilledAngle, drawAnimatedArc, drawArc } from "./gauge-arcs.business";
import { arcConfig, gaugeDimensions } from "./model";

interface GaugeArcsProps {
percentage: number;
}

export const GaugeArcs = ({ percentage }: GaugeArcsProps) => {
const svgRef = useRef<SVGSVGElement>(null);

useEffect(() => {
if (!svgRef.current) return;

const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();

// Center position
const centerX = gaugeDimensions.width / 2;
const centerY = arcConfig.outerRadius;

// Create centered group
const arcGroup = svg
.append("g")
.attr("transform", `translate(${centerX}, ${centerY})`);

// 1. Background arc (--color-total-water, full)
drawArc({
arcGroup,
endAngle: arcConfig.endAngle,
fillColor: "var(--color-total-water)",
});

// 2. Filled arc (primary color, based on percentage prop)
const filledEndAngle = calculateFilledAngle(percentage);
drawAnimatedArc({
arcGroup,
endAngle: filledEndAngle,
fillColor: "var(--color-primary)",
});
}, [percentage]);

return (
<svg
ref={svgRef}
width={gaugeDimensions.width}
height={gaugeDimensions.height}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { gaugeDimensions } from "./model";
import { GaugeInformation } from "./gauge-information.component";
import { GaugeArcs } from "./gauge-arcs.component";

interface Props {
percentage: number;
measurementDate: string;
}

export const GaugeChart = ({ percentage, measurementDate }: Props) => {
return (
<div
className="relative"
style={{
width: gaugeDimensions.width,
height: gaugeDimensions.height,
}}
>
{/* The SVG arc */}
<GaugeArcs percentage={percentage} />

{/* Center text */}
<div className="absolute inset-0 flex items-center justify-center pt-8">
<GaugeInformation
percentage={percentage}
measurementDate={measurementDate}
/>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
interface Props {
percentage: number;
measurementDate: string;
}

export const GaugeInformation = ({ percentage, measurementDate }: Props) => {
const displayPercentage = `${Math.round(percentage * 100)}`;

return (
<div className="flex flex-col items-center justify-center">
<span className="text-base-content text-5xl font-semibold">
{displayPercentage}
<span className="text-3xl">%</span>
</span>
<span className="text-base-content text-lg font-medium">
{measurementDate}
</span>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
interface Props {
currentVolume: number;
totalCapacity: number;
}

export const GaugeLegend = ({ currentVolume, totalCapacity }: Props) => {
return (
<div className="flex w-full flex-col items-start gap-3 pt-3">
{/* Embalsada (filled water) - uses primary color */}
<div className="flex items-center gap-2">
<span className="bg-primary h-4 w-4 rounded-full" />
<span className="text-base-content text-base">
Embalsada: {currentVolume}m³
</span>
</div>

{/* Total capacity - uses total-water color */}
<div className="flex items-center gap-2">
<span className="bg-total-water h-4 w-4 rounded-full" />
<span className="text-base-content text-base">
Total: {totalCapacity}m³
</span>
</div>
</div>
);
};
Loading