Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 11 additions & 2 deletions front/src/app/embalse/[embalse]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import Link from "next/link";
import { mockData } from "../../model/reservoir-data";
import { ReservoirGauge } from "./reservoir-gauge";

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

export default async function EmbalseDetallePage({ params }: Props) {
const { embalse } = await params;

const reservoirData = mockData;

return (
<div className="flex flex-col gap-8">
<h2>Detalle del embalse: {embalse}</h2>
<ReservoirGauge
name={embalse}
currentVolume={reservoirData.currentVolume}
totalCapacity={reservoirData.totalCapacity}
measurementDate={reservoirData.measurementDate}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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);
};

// TODO: add unit tests for calculateFilledAngle

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) => {
const arcGenerator = createArcGenerator(endAngle);

arcGroup
.append("path")
.attr("d", arcGenerator as any)
.style("fill", fillColor);
};
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, 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);
drawArc({
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,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,2 @@
export * from "./gauge-arcs.component";
export * from "./gauge-information.component";
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
interface GaugeDimensions {
width: number;
height: number;
}

interface ArcConfig {
innerRadius: number;
outerRadius: number;
startAngle: number;
endAngle: number;
cornerRadius: number;
}

export const gaugeDimensions: GaugeDimensions = {
width: 220,
height: 184,
};

export const arcConfig: ArcConfig = {
innerRadius: 90,
outerRadius: 110,
startAngle: -Math.PI * 0.75,
endAngle: Math.PI * 0.75,
cornerRadius: 12,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { gaugeDimensions } from "./components/model";
import { GaugeInformation } from "./components";
import { GaugeArcs } from "./components/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 @@
export * from "./gauge-chart.component";
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>
);
};
1 change: 1 addition & 0 deletions front/src/app/embalse/[embalse]/reservoir-gauge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./reservoir-gauge";
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ReservoirData } from "../../../model/reservoir-data";
import { GaugeChart } from "./gauge-chart";
import { GaugeLegend } from "./gauge-legend.component";

interface Props extends ReservoirData {
name: string;
}

export const ReservoirGauge = ({
name,
currentVolume,
totalCapacity,
measurementDate,
}: Props) => {
// 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">
<h2 className="text-center">Embalse de {name}</h2>
<GaugeChart percentage={0.67} measurementDate={measurementDate} />
<GaugeLegend
currentVolume={currentVolume}
totalCapacity={totalCapacity}
/>
</div>
);
};
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
11 changes: 11 additions & 0 deletions front/src/app/model/reservoir-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface ReservoirData {
currentVolume: number;
totalCapacity: number;
measurementDate: string;
}

export const mockData: ReservoirData = {
currentVolume: 1500,
totalCapacity: 50000,
measurementDate: "25/12/2025",
};
Loading