131868a280
Deploy HiHala Dashboard / deploy (push) Successful in 11s
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>
141 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|