From 06c382e5d223ba1b35dc0b1306c71da489c632b0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 25 Oct 2026 15:14:58 +0900 Subject: [PATCH] refac --- .../admin/Analytics/UsageChart.svelte | 179 +++++++++++++----- 1 file changed, 131 insertions(+), 48 deletions(-) diff --git a/src/lib/components/admin/Analytics/UsageChart.svelte b/src/lib/components/admin/Analytics/UsageChart.svelte index 858782de5a..f7bade1efb 100644 --- a/src/lib/components/admin/Analytics/UsageChart.svelte +++ b/src/lib/components/admin/Analytics/UsageChart.svelte @@ -89,6 +89,11 @@ let keyboardIndex = 0; let plot: HTMLDivElement; let chartWidth = 0; + const activityRowHeight = 28; + let activityViewport: HTMLDivElement; + let activityScrollTop = 0; + let activityHeight = 336; + let activityWidth = 0; let legendExpanded = false; $: legendColumns = Math.max(1, Math.floor((chartWidth + 16) / 256)); $: legendLimit = legendColumns * 2 - 1; @@ -107,6 +112,7 @@ active = null; activeModel = null; keyboardIndex = 0; + resetActivityScroll(); } $: totals = modelTotals(data); $: comparing = compare && view === 'volume'; @@ -149,10 +155,15 @@ : [0, series.length - 1] ) ].filter((i) => i >= 0 && i < series.length); - $: lanes = (view === 'activity' ? visibleIds : []).map((id) => { - const values = series.map((point) => point.models[id] ?? 0); - return { id, peak: Math.max(0, ...values), values }; - }); + $: activityStart = Math.max(0, Math.floor(activityScrollTop / activityRowHeight) - 2); + $: activityEnd = Math.min( + visibleIds.length, + Math.ceil((activityScrollTop + activityHeight) / activityRowHeight) + 2 + ); + $: getActivityLanes = activityLaneWindow(series); + $: lanes = getActivityLanes( + view === 'activity' ? visibleIds.slice(activityStart, activityEnd) : [] + ); $: stacks = view === 'volume' ? series.map((point) => visibleIds.filter((id) => (point.models[id] ?? 0) > 0)) @@ -169,6 +180,30 @@ .locale($i18n.language) .format(granularity === 'daily' ? 'MMM D' : 'MMM D, HH:mm'); const position = (time: number) => ((time - start + step / 2) / span) * 100; + function resetActivityScroll() { + activityScrollTop = 0; + if (activityViewport) activityViewport.scrollTop = 0; + } + + // Preserve overlapping rows so scrolling only prepares and draws newly visible models. + function activityLaneWindow(points: ReturnType) { + type Lane = { id: string; peak: number; values: number[] }; + let cache = new Map(); + return (ids: string[]) => { + const next = new Map(); + const lanes = ids.map((id) => { + let lane = cache.get(id); + if (!lane) { + const values = points.map((point) => point.models[id] ?? 0); + lane = { id, peak: values.reduce((peak, value) => Math.max(peak, value), 0), values }; + } + next.set(id, lane); + return lane; + }); + cache = next; + return lanes; + }; + } async function navigate(event: KeyboardEvent, index: number) { const buttons = plot.querySelectorAll('button[data-point]'); const destinations: Record = { @@ -180,20 +215,27 @@ if (view === 'activity') { destinations.ArrowDown = index + series.length; destinations.ArrowUp = index - series.length; - destinations.End = lanes.length * series.length - 1; + destinations.End = visibleIds.length * series.length - 1; } const next = destinations[event.key]; if (next === undefined) return; event.preventDefault(); keyboardIndex = Math.max( 0, - Math.min((view === 'activity' ? lanes.length * series.length : buttons.length) - 1, next) + Math.min((view === 'activity' ? visibleIds.length * series.length : buttons.length) - 1, next) ); if (view === 'activity') { const row = Math.floor(keyboardIndex / series.length); + const top = row * activityRowHeight; + activityScrollTop = Math.max( + top + activityRowHeight - activityHeight, + Math.min(activityScrollTop, top) + ); + activityViewport.scrollTop = activityScrollTop; await tick(); - buttons[row]?.focus(); - showTooltip({ currentTarget: buttons[row] }, keyboardIndex % series.length, lanes[row].id); + const button = plot.querySelector(`button[data-row="${row}"]`); + button?.focus(); + showTooltip({ currentTarget: button }, keyboardIndex % series.length, visibleIds[row]); } else buttons[keyboardIndex]?.focus(); } @@ -227,8 +269,19 @@ draw(); return { update(next: typeof options) { + if ( + next.lane === options.lane && + next.positions === options.positions && + next.cellWidth === options.cellWidth && + next.width === options.width + ) + return; options = next; draw(); + }, + destroy() { + canvas.width = 0; + canvas.height = 0; } }; } @@ -481,54 +534,83 @@ {:else} -
- {#each lanes as lane, row (lane.id)} - {@const index = - Math.floor(keyboardIndex / series.length) === row ? keyboardIndex % series.length : 0} -
- { + if (activityScrollTop !== activityViewport.scrollTop) { + activityScrollTop = activityViewport.scrollTop; + hideTooltip(); + } + }} + > +
+ {#each lanes as lane, offset (lane.id)} + {@const row = activityStart + offset} + {@const index = + Math.floor(keyboardIndex / series.length) === row ? keyboardIndex % series.length : 0} +
- + -
- {/each} + on:blur={hideTooltip} + on:click={(event) => hoverActivity(event, lane.id)} + on:keydown={(event) => navigate(event, row * series.length + index)} + > + + +
+ {/each} +
{/if} {#if hovered}