From 2888936d54045ac9a435d13604dbee74558afc04 Mon Sep 17 00:00:00 2001 From: fahed Date: Thu, 30 Apr 2026 11:13:05 +0300 Subject: [PATCH] feat(charts): hover dimming, end-of-line labels, and value-label toggle - Hover: non-hovered lines fade to 15% opacity so active line pops out - End labels: museum name rendered at the tip of each line (always visible, stays full-opacity even when dimmed) with 110px right-padding for space - Labels toggle: button in chart controls shows/hides per-point value labels - interaction mode set to nearest/no-intersect for responsive hover Co-Authored-By: Claude Sonnet 4.6 --- src/components/Comparison.tsx | 18 +++++++++++---- src/components/Dashboard.tsx | 18 +++++++++++---- src/config/chartConfig.ts | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index 49ca3a6..2020b67 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -76,8 +76,9 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM const [selDistricts, setSelDistricts] = useState([]); const [selChannels, setSelChannels] = useState([]); const [selMuseums, setSelMuseums] = useState([]); - const [metric, setMetric] = useState('revenue'); - const [gran, setGran] = useState('week'); + const [metric, setMetric] = useState('revenue'); + const [gran, setGran] = useState('week'); + const [showLabels, setShowLabels] = useState(false); const perm = useMemo(() => { if (!allowedMuseums || !allowedChannels) return []; @@ -174,10 +175,12 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM fill: false, pointRadius: gran==='week' ? 3 : 1, pointBackgroundColor: chartPalette[idx % chartPalette.length], + _isMuseumLine: true, }; }); return { tooltipLabels, + multiMuseum, data: { labels, datasets: [ @@ -204,13 +207,18 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM }; }, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]); - const baseOpts = useMemo(() => createBaseOptions(false), []); + const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]); const { chartOpts } = useMemo(() => { const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } }; return { chartOpts }; }, [baseOpts]); const trendOpts: any = useMemo(() => ({ ...chartOpts, + interaction: { mode: 'nearest', intersect: false }, + layout: { + ...chartOpts.layout, + padding: { ...(chartOpts.layout?.padding ?? {}), right: trendResult.multiMuseum ? 110 : 5 }, + }, plugins: { ...chartOpts.plugins, tooltip: { @@ -220,7 +228,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM } } } - }), [chartOpts, trendResult.tooltipLabels]); + }), [chartOpts, trendResult.tooltipLabels, trendResult.multiMuseum]); const metricOpts = [ { value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, @@ -320,6 +328,8 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM {metricOpts.map(o => )}
{granOpts.map(o => )} +
+
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 0d5af05..f0b3165 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -35,8 +35,9 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set const [selDistricts, setSelDistricts] = useState([]); const [selChannels, setSelChannels] = useState([]); const [selMuseums, setSelMuseums] = useState([]); - const [metric, setMetric] = useState('revenue'); - const [gran, setGran] = useState('week'); + const [metric, setMetric] = useState('revenue'); + const [gran, setGran] = useState('week'); + const [showLabels, setShowLabels] = useState(false); const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar'); const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie'); const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie'); @@ -135,10 +136,12 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set fill: false, pointRadius: gran==='week' ? 3 : 1, pointBackgroundColor: chartPalette[idx % chartPalette.length], + _isMuseumLine: true, }; }); return { tooltipLabels, + multiMuseum, data: { labels, datasets: [ @@ -201,7 +204,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null; const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null; - const baseOpts = useMemo(() => createBaseOptions(false), []); + const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]); const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => { const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } }; const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } }; @@ -210,6 +213,11 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set }, [baseOpts]); const trendOpts: any = useMemo(() => ({ ...chartOpts, + interaction: { mode: 'nearest', intersect: false }, + layout: { + ...chartOpts.layout, + padding: { ...(chartOpts.layout?.padding ?? {}), right: trendResult.multiMuseum ? 110 : 5 }, + }, plugins: { ...chartOpts.plugins, tooltip: { @@ -219,7 +227,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set } } } - }), [chartOpts, trendResult.tooltipLabels]); + }), [chartOpts, trendResult.tooltipLabels, trendResult.multiMuseum]); const pieOptions: any = useMemo(() => ({ responsive: true, maintainAspectRatio: false, @@ -297,6 +305,8 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set {metricOpts.map(o => )}
{granOpts.map(o => )} +
+
diff --git a/src/config/chartConfig.ts b/src/config/chartConfig.ts index aa35881..97ee9f9 100644 --- a/src/config/chartConfig.ts +++ b/src/config/chartConfig.ts @@ -134,6 +134,49 @@ export const createBaseOptions = (showDataLabels: boolean): any => { }; }; +// Hover-dim + end-of-line name labels for multi-museum trend charts. +// Only activates for charts that have datasets marked with _isMuseumLine. +const trendLinePlugin = { + id: 'trendLineOverlay', + beforeDatasetDraw(chart: any, args: any) { + if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return; + const active = chart.getActiveElements(); + if (active.length === 0) return; + if (active[0].datasetIndex !== args.index) { + chart.ctx.save(); + chart.ctx.globalAlpha = 0.15; + } + }, + afterDatasetDraw(chart: any, args: any) { + if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return; + const ds = chart.data.datasets[args.index] as any; + const active = chart.getActiveElements(); + const isDimmed = active.length > 0 && active[0].datasetIndex !== args.index; + if (isDimmed) chart.ctx.restore(); + if (!ds._isMuseumLine) return; + const meta = chart.getDatasetMeta(args.index); + if (meta.hidden) return; + const vals = ds.data as number[]; + let lastIdx = vals.length - 1; + while (lastIdx > 0 && !vals[lastIdx]) lastIdx--; + if (!vals[lastIdx]) return; + const pt = meta.data[lastIdx] as any; + if (!pt) return; + const name: string = ds.label; + const label = name.length > 22 ? name.slice(0, 20) + '…' : name; + const ctx = chart.ctx; + ctx.save(); + ctx.font = '600 9px system-ui,-apple-system,sans-serif'; + ctx.fillStyle = ds.borderColor; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, pt.x + 6, pt.y); + ctx.restore(); + } +}; + +ChartJS.register(trendLinePlugin); + export const lineDatasetDefaults = { borderWidth: 2, tension: 0.4,