From edcf5bd4079b07046163c14d1519f0c79426995c Mon Sep 17 00:00:00 2001 From: Yan <75355375+yancat160@users.noreply.github.com> Date: Fri, 8 May 2026 08:00:11 -0400 Subject: [PATCH 1/2] stabilize event chart colors per label Colors in the events-tab chart were assigned by the dataset index from Object.keys(map), so changing the date range or reloading the page reshuffled keys and produced a different color for the same event each time. Pick the palette slot deterministically from a hash of the label (hex6 / FNV-1a), and walk the palette greedily in hash-sorted order so the assignment is independent of the API response order. When two labels prefer the same slot, the later one steps to the next free slot, so the visible set of up to 12 events all get distinct colors. The right shift on the hash sidesteps the FNV-1a low-bit bias mod 12 (FNV prime is close to 2^24). --- src/components/metrics/EventsChart.tsx | 31 ++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx index db23dcc47..cbd868e34 100644 --- a/src/components/metrics/EventsChart.tsx +++ b/src/components/metrics/EventsChart.tsx @@ -9,6 +9,7 @@ import { useWebsiteEventsSeriesQuery, } from '@/components/hooks'; import { renderDateLabels } from '@/lib/charts'; +import { hex6 } from '@/lib/colors'; import { CHART_COLORS } from '@/lib/constants'; import { generateTimeSeries } from '@/lib/date'; @@ -51,9 +52,35 @@ export function EventsChart({ websiteId, focusLabel, limit }: EventsChartProps) ], }; } else { + // Each label has a preferred palette slot derived from a hash of the + // label, so the same event tends to get the same color across reloads + // and date-range changes. We walk labels in hash order and, when two + // labels prefer the same slot, the later one steps to the next free + // slot, so the visible set of <=12 events all get distinct colors. + // The right shift on hex6 sidesteps the FNV-1a low-bit bias mod 12 + // (the FNV prime is close to 2^24). + const colorByKey: Record = {}; + const used = new Set(); + const orderedKeys = [...Object.keys(map)].sort( + (a, b) => parseInt(hex6(a), 16) - parseInt(hex6(b), 16), + ); + for (const key of orderedKeys) { + const start = (parseInt(hex6(key), 16) >>> 4) % CHART_COLORS.length; + let chosen = CHART_COLORS[start]; + for (let i = 0; i < CHART_COLORS.length; i++) { + const candidate = CHART_COLORS[(start + i) % CHART_COLORS.length]; + if (!used.has(candidate)) { + chosen = candidate; + break; + } + } + used.add(chosen); + colorByKey[key] = chosen; + } + return { - datasets: Object.keys(map).map((key, index) => { - const color = colord(CHART_COLORS[index % CHART_COLORS.length]); + datasets: Object.keys(map).map(key => { + const color = colord(colorByKey[key]); return { label: key, data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale), From 46e1351aedf16749d34fb4eaf7aa046452f2a21f Mon Sep 17 00:00:00 2001 From: Yan <75355375+yancat160@users.noreply.github.com> Date: Fri, 8 May 2026 09:17:07 -0400 Subject: [PATCH 2/2] cache hex6 hash per label in color mapping Address Greptile review on PR #4257: hex6(key) was parsed twice per label, once in the sort comparator and once when deriving the preferred palette slot. Cache the parsed integer in a local hashOf map so the sort comparator and the slot lookup share one computation per label, no behaviour change. --- src/components/metrics/EventsChart.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx index cbd868e34..473b8d20c 100644 --- a/src/components/metrics/EventsChart.tsx +++ b/src/components/metrics/EventsChart.tsx @@ -61,11 +61,12 @@ export function EventsChart({ websiteId, focusLabel, limit }: EventsChartProps) // (the FNV prime is close to 2^24). const colorByKey: Record = {}; const used = new Set(); - const orderedKeys = [...Object.keys(map)].sort( - (a, b) => parseInt(hex6(a), 16) - parseInt(hex6(b), 16), + const hashOf = Object.fromEntries( + Object.keys(map).map(key => [key, parseInt(hex6(key), 16)]), ); + const orderedKeys = [...Object.keys(map)].sort((a, b) => hashOf[a] - hashOf[b]); for (const key of orderedKeys) { - const start = (parseInt(hex6(key), 16) >>> 4) % CHART_COLORS.length; + const start = (hashOf[key] >>> 4) % CHART_COLORS.length; let chosen = CHART_COLORS[start]; for (let i = 0; i < CHART_COLORS.length; i++) { const candidate = CHART_COLORS[(start + i) % CHART_COLORS.length];