mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
171 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|