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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user