mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
style: overview revamp
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user