feat(charts): hover dimming, end-of-line labels, and value-label toggle
Deploy HiHala Dashboard / deploy (push) Successful in 11s

- 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 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-04-30 11:13:05 +03:00
parent 131868a280
commit 2888936d54
3 changed files with 71 additions and 8 deletions
+12 -2
View File
@@ -78,6 +78,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
const [selMuseums, setSelMuseums] = useState<string[]>([]); const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue'); const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week'); const [gran, setGran] = useState('week');
const [showLabels, setShowLabels] = useState(false);
const perm = useMemo(() => { const perm = useMemo(() => {
if (!allowedMuseums || !allowedChannels) return []; if (!allowedMuseums || !allowedChannels) return [];
@@ -174,10 +175,12 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
fill: false, fill: false,
pointRadius: gran==='week' ? 3 : 1, pointRadius: gran==='week' ? 3 : 1,
pointBackgroundColor: chartPalette[idx % chartPalette.length], pointBackgroundColor: chartPalette[idx % chartPalette.length],
_isMuseumLine: true,
}; };
}); });
return { return {
tooltipLabels, tooltipLabels,
multiMuseum,
data: { data: {
labels, labels,
datasets: [ datasets: [
@@ -204,13 +207,18 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
}; };
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]); }, [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 } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } }; const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
return { chartOpts }; return { chartOpts };
}, [baseOpts]); }, [baseOpts]);
const trendOpts: any = useMemo(() => ({ const trendOpts: any = useMemo(() => ({
...chartOpts, ...chartOpts,
interaction: { mode: 'nearest', intersect: false },
layout: {
...chartOpts.layout,
padding: { ...(chartOpts.layout?.padding ?? {}), right: trendResult.multiMuseum ? 110 : 5 },
},
plugins: { plugins: {
...chartOpts.plugins, ...chartOpts.plugins,
tooltip: { tooltip: {
@@ -220,7 +228,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
} }
} }
} }
}), [chartOpts, trendResult.tooltipLabels]); }), [chartOpts, trendResult.tooltipLabels, trendResult.multiMuseum]);
const metricOpts = [ const metricOpts = [
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { 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 => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)} {granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div> <div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
+12 -2
View File
@@ -37,6 +37,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const [selMuseums, setSelMuseums] = useState<string[]>([]); const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue'); const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week'); const [gran, setGran] = useState('week');
const [showLabels, setShowLabels] = useState(false);
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar'); const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie'); const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
const [districtChartType, setDistrictChartType] = 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, fill: false,
pointRadius: gran==='week' ? 3 : 1, pointRadius: gran==='week' ? 3 : 1,
pointBackgroundColor: chartPalette[idx % chartPalette.length], pointBackgroundColor: chartPalette[idx % chartPalette.length],
_isMuseumLine: true,
}; };
}); });
return { return {
tooltipLabels, tooltipLabels,
multiMuseum,
data: { data: {
labels, labels,
datasets: [ datasets: [
@@ -201,7 +204,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null; const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*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, barHorizOpts, barNoLegend } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } }; 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 } } }; 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]); }, [baseOpts]);
const trendOpts: any = useMemo(() => ({ const trendOpts: any = useMemo(() => ({
...chartOpts, ...chartOpts,
interaction: { mode: 'nearest', intersect: false },
layout: {
...chartOpts.layout,
padding: { ...(chartOpts.layout?.padding ?? {}), right: trendResult.multiMuseum ? 110 : 5 },
},
plugins: { plugins: {
...chartOpts.plugins, ...chartOpts.plugins,
tooltip: { 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(() => ({ const pieOptions: any = useMemo(() => ({
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
@@ -297,6 +305,8 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)} {granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div> <div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div>
+43
View File
@@ -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 = { export const lineDatasetDefaults = {
borderWidth: 2, borderWidth: 2,
tension: 0.4, tension: 0.4,