Files
hihala-dashboard/src/components/Report/reportCharts.tsx
T
fahed 131868a280
Deploy HiHala Dashboard / deploy (push) Successful in 11s
feat(report): per-museum trend lines in PDF report chart
When multiple museums are present, the report trend chart now renders one
colored line per museum plus a bold Total line, mirroring dashboard behavior.
Legend is updated to list each museum with its corresponding color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:56:26 +03:00

141 lines
4.9 KiB
TypeScript

import React from 'react';
import { Svg, Line, Polyline, Rect, Text as SvgText, G } from '@react-pdf/renderer';
export const CHART_PALETTE = [
'#2563eb', '#0891b2', '#7c3aed', '#059669',
'#d97706', '#dc2626', '#db2777', '#f59e0b',
'#10b981', '#6366f1', '#0284c7', '#65a30d',
];
function fmtAxis(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 10_000) return `${Math.round(v / 1_000)}K`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
return String(Math.round(v));
}
interface TrendChartProps {
labels: string[];
current: number[];
previous: number[] | null;
color: string;
series?: Array<{ label: string; color: string; data: number[] }>;
width?: number;
height?: number;
}
export function PdfTrendChart({ labels, current, previous, color, series, width = 470, height = 155 }: TrendChartProps) {
const seriesValues = (series ?? []).flatMap(s => s.data);
const allValues = [...current, ...(previous ?? []), ...seriesValues].filter(v => v > 0);
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
// padL wide enough for y-axis labels like "1.2M"
const padL = 38, padR = 8, padT = 10, padB = 20;
const w = width - padL - padR;
const h = height - padT - padB;
const sx = (i: number) => padL + (labels.length > 1 ? (i / (labels.length - 1)) * w : w / 2);
const sy = (v: number) => padT + h - (v / max) * h;
const toPoints = (data: number[]) =>
data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`).join(' ');
const gridLines = [0.25, 0.5, 0.75, 1.0];
return (
<Svg width={width} height={height}>
{/* Baseline */}
<Line x1={padL} y1={(padT + h).toFixed(1)} x2={width - padR} y2={(padT + h).toFixed(1)}
stroke="#cbd5e1" strokeWidth={0.75} />
{/* Grid lines + Y-axis labels */}
{gridLines.map(f => {
const yPos = sy(max * f);
return (
<G key={f}>
<Line x1={padL} y1={yPos.toFixed(1)} x2={width - padR} y2={yPos.toFixed(1)}
stroke="#e2e8f0" strokeWidth={0.5} />
<SvgText x={(padL - 5).toFixed(1)} y={(yPos + 2.5).toFixed(1)}
fill="#94a3b8" style={{ fontSize: 6.5, textAnchor: 'end' }}>
{fmtAxis(max * f)}
</SvgText>
</G>
);
})}
{/* Comparison line (dashed) */}
{previous && previous.some(v => v > 0) && (
<Polyline points={toPoints(previous)}
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
)}
{/* Per-museum series */}
{(series ?? []).map(s => s.data.some(v => v > 0) && (
<Polyline key={s.label} points={toPoints(s.data)}
stroke={s.color} strokeWidth={1.5} fill="none" />
))}
{/* Current period total line */}
{current.some(v => v > 0) && (
<Polyline points={toPoints(current)}
stroke={color} strokeWidth={series && series.length >= 2 ? 2 : 2.5} fill="none" />
)}
{/* X-axis week labels */}
{labels
.filter((_, i) => labels.length <= 8 || i % Math.ceil(labels.length / 8) === 0)
.map((label) => {
const origIdx = labels.indexOf(label);
return (
<SvgText key={label}
x={sx(origIdx).toFixed(1)} y={height - 5}
style={{ fontSize: 7, fill: '#94a3b8', textAnchor: 'middle' }}>
{label}
</SvgText>
);
})}
</Svg>
);
}
interface HBarChartProps {
items: Array<{ name: string; value: number }>;
color: string;
usepalette?: boolean;
width?: number;
}
export function PdfHBarChart({ items, color, usepalette = false, width = 470 }: HBarChartProps) {
const barH = 17;
const gap = 10;
const labelW = 160;
const barAreaW = width - labelW - 20;
const max = Math.max(...items.map(i => i.value), 1);
const totalH = items.length * (barH + gap);
return (
<Svg width={width} height={totalH}>
{items.map((item, i) => {
const y = i * (barH + gap);
const bw = Math.max((item.value / max) * barAreaW, 2);
const shortName = item.name.length > 26 ? item.name.slice(0, 26) + '…' : item.name;
const valueStr = item.value.toLocaleString('en-SA', { maximumFractionDigits: 0 });
const barColor = usepalette ? CHART_PALETTE[i % CHART_PALETTE.length] : color;
const isShort = bw < 48;
return (
<G key={item.name + i}>
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8.5 }}>{shortName}</SvgText>
<Rect x={labelW} y={y} width={bw} height={barH} fill={barColor} rx={3} />
{isShort ? (
<SvgText x={labelW + bw + 6} y={y + barH - 4} fill="#334155"
style={{ fontSize: 8.5 }}>{valueStr}</SvgText>
) : (
<SvgText x={labelW + bw - 6} y={y + barH - 4} fill="#ffffff"
style={{ fontSize: 8.5, textAnchor: 'end' }}>{valueStr}</SvgText>
)}
</G>
);
})}
</Svg>
);
}