feat(charts): hover dimming, end-of-line labels, and value-label toggle
Deploy HiHala Dashboard / deploy (push) Successful in 11s
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:
@@ -76,8 +76,9 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
const [selDistricts, setSelDistricts] = useState<string[]>([]);
|
||||
const [selChannels, setSelChannels] = useState<string[]>([]);
|
||||
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
||||
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 => <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" />
|
||||
{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 className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
|
||||
|
||||
@@ -35,8 +35,9 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
const [selDistricts, setSelDistricts] = useState<string[]>([]);
|
||||
const [selChannels, setSelChannels] = useState<string[]>([]);
|
||||
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
||||
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 => <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" />
|
||||
{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 className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user