Files
edr-platform/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx
2026-08-13 12:44:06 +00:00

171 lines
4.5 KiB
TypeScript

import { Waypoints } from "lucide-react";
import { ResponsiveContainer, Sankey, Tooltip, type SankeyLinkProps } from "recharts";
import { Text } from "@mantine/core";
import type { IOverviewRevenueFlow } from "@/types/overview";
import { chartTooltipStyle } from "./chart-style";
import { SummaryCard } from "./SummaryCard";
import {
DIRECTION_COLORS,
FLOW_FALLBACK_COLOR,
FLOW_LABELS,
FREIGHT_TYPE_COLORS,
} from "./flow-colors";
function formatEtb(amount: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
}).format(amount);
}
interface SankeyNodeDatum {
name: string;
key: string;
color: string;
}
/** Build recharts Sankey data: direction nodes on the left, freight types on the right. */
function toSankeyData(flows: IOverviewRevenueFlow[]) {
const active = flows.filter((f) => f.amountEtb > 0);
const nodes: SankeyNodeDatum[] = [];
const indexByKey = new Map<string, number>();
const nodeIndex = (key: string, color: string) => {
const existing = indexByKey.get(key);
if (existing != null) return existing;
nodes.push({ name: FLOW_LABELS[key] ?? key, key, color });
indexByKey.set(key, nodes.length - 1);
return nodes.length - 1;
};
// Register directions first so they all land on the left column.
for (const flow of active) {
nodeIndex(flow.direction, DIRECTION_COLORS[flow.direction] ?? FLOW_FALLBACK_COLOR);
}
const links = active.map((flow) => ({
source: indexByKey.get(flow.direction)!,
target: nodeIndex(
flow.freightType,
FREIGHT_TYPE_COLORS[flow.freightType] ?? FLOW_FALLBACK_COLOR,
),
value: flow.amountEtb,
}));
return { nodes, links };
}
function FlowNode({
x,
y,
width,
height,
index,
payload,
}: {
x: number;
y: number;
width: number;
height: number;
index: number;
payload: { name?: string; value?: number; color?: string };
}) {
// Labels sit to the right of every bar: the right margin reserves room for
// the last column, and the pale ribbons stay readable under the left one.
return (
<g key={`node-${index}`}>
<rect x={x} y={y} width={width} height={height} fill={payload.color} rx={3} />
<text
x={x + width + 8}
y={y + height / 2 - 6}
textAnchor="start"
dominantBaseline="central"
fontSize={12}
fontWeight={600}
fill="#1f2937"
>
{payload.name}
</text>
<text
x={x + width + 8}
y={y + height / 2 + 9}
textAnchor="start"
dominantBaseline="central"
fontSize={11}
fill="var(--mantine-color-gray-6)"
>
{formatEtb(payload.value ?? 0)}
</text>
</g>
);
}
/** Ribbon tinted by its source direction — the corridor keeps its color across the chart. */
function FlowLink({
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
index,
payload,
}: SankeyLinkProps) {
// Custom node fields (color) ride along on the layout node recharts hands back.
const source = payload.source as { color?: string };
return (
<path
key={`link-${index}`}
d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}
fill="none"
stroke={source.color ?? FLOW_FALLBACK_COLOR}
strokeOpacity={0.3}
strokeWidth={linkWidth}
/>
);
}
interface OverviewSankeyFlowProps {
flows: IOverviewRevenueFlow[];
}
/**
* Where the money runs: ETB revenue as ribbons from trade direction to
* freight type. Ribbon thickness is proportional to revenue, so the biggest
* corridor is unmissable.
*/
export function OverviewSankeyFlow({ flows }: OverviewSankeyFlowProps) {
const data = toSankeyData(flows);
return (
<SummaryCard
icon={Waypoints}
accent="sky"
title="Revenue flow"
subtitle="Trade direction → freight type, sized by ETB revenue"
>
{data.links.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No revenue in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={252}>
<Sankey
data={data}
node={FlowNode}
link={FlowLink}
nodePadding={32}
margin={{ top: 20, right: 96, bottom: 12, left: 4 }}
>
<Tooltip
formatter={(value) => formatEtb(Number(value))}
contentStyle={chartTooltipStyle}
/>
</Sankey>
</ResponsiveContainer>
)}
</SummaryCard>
);
}