This commit is contained in:
Timothy Jaeryang Baek 2026-10-21 15:57:39 +09:00
parent a26dda3eaf
commit 06c17834ee
2 changed files with 1026 additions and 402 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,479 @@
<script context="module" lang="ts">
export type UsageBucket = { date: string; models: Record<string, number> };
export const usageShare = (value: number, total: number) => {
if (total <= 0 || value <= 0) return '0%';
const share = (value / total) * 100;
return share < 0.1 ? '<0.1%' : `${share.toFixed(1)}%`;
};
export const chartSeries = (data: UsageBucket[], modelId: string | null = null) =>
data
.map((bucket) => ({
date: bucket.date,
models: modelId ? { [modelId]: bucket.models[modelId] ?? 0 } : bucket.models,
// Calendar keys have no timezone. UTC is only a plotting coordinate, not a query timestamp.
time: Date.parse(
`${bucket.date.length === 10 ? `${bucket.date}T00:00` : bucket.date.replace(' ', 'T')}Z`
),
value: modelId
? (bucket.models[modelId] ?? 0)
: Object.values(bucket.models).reduce((total, value) => total + value, 0)
}))
.sort((a, b) => a.time - b.time);
export const chartCeiling = (max: number) => {
if (max <= 4) return 4;
const step = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / step) * step;
};
export const modelTotals = (data: UsageBucket[]) => {
const totals: Record<string, number> = Object.create(null);
for (const bucket of data) {
for (const [id, value] of Object.entries(bucket.models))
totals[id] = (totals[id] ?? 0) + value;
}
return totals;
};
// Identity-based colors remain stable when the range, metric, or ranking changes.
export const modelColor = (id: string) => {
let hash = 0;
for (const character of id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) | 0;
return `hsl(${((hash % 360) + 360) % 360} 65% 52%)`;
};
export const intervalMinutes = {
'10m': 10,
hourly: 60,
'3h': 180,
'6h': 360,
daily: 1440
} as const;
export type UsageInterval = keyof typeof intervalMinutes;
export const defaultInterval = (period: string): UsageInterval =>
period === '24h' ? '10m' : period === '7d' ? 'hourly' : period === '30d' ? '6h' : 'daily';
export const aggregateUsage = (data: UsageBucket[], interval: UsageInterval): UsageBucket[] => {
if (interval !== '3h' && interval !== '6h') return data;
const hours = intervalMinutes[interval] / 60;
const buckets = new Map<string, Record<string, number>>();
for (const point of data) {
// Aggregate the server's calendar keys without converting their timezone.
const hour = Math.floor(Number(point.date.slice(11, 13)) / hours) * hours;
const date = `${point.date.slice(0, 10)} ${String(hour).padStart(2, '0')}:00`;
const models = buckets.get(date) ?? Object.create(null);
for (const [id, value] of Object.entries(point.models))
models[id] = (models[id] ?? 0) + value;
buckets.set(date, models);
}
return [...buckets]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, models]) => ({ date, models }));
};
export const intervalBreakdown = (
models: Record<string, number>,
modelIds: string[],
modelId: string | null = null
) => (modelId === null ? modelIds : [modelId]).map((id) => ({ id, value: models[id] ?? 0 }));
</script>
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import tippy, { type Instance } from 'tippy.js';
import dayjs from 'dayjs';
export let data: UsageBucket[] = [];
export let granularity: UsageInterval = 'daily';
export let metricLabel = '';
export let modelNames: Record<string, string> = {};
export let selectedModel: string | null = null;
export let view: 'volume' | 'activity' = 'volume';
const i18n = getContext<any>('i18n');
let active: number | null = null;
let keyboardIndex = 0;
let plot: HTMLDivElement;
let chartWidth = 0;
let tooltip: Instance | undefined;
let tooltipContent: HTMLDivElement;
let tooltipId = '';
let anchor: HTMLElement | null = null;
let activeModel: string | null = null;
$: {
data;
selectedModel;
view;
tooltip?.hide();
active = null;
activeModel = null;
keyboardIndex = 0;
}
$: totals = modelTotals(data);
$: ids = Object.keys(totals).sort((a, b) => totals[b] - totals[a] || a.localeCompare(b));
$: visibleIds = selectedModel ? [selectedModel] : ids;
$: series = chartSeries(data, selectedModel);
$: ceiling = chartCeiling(series.reduce((max, point) => Math.max(max, point.value), 0));
$: step = intervalMinutes[granularity] * 60000;
$: start = series[0]?.time ?? 0;
$: span = Math.max(step, (series[series.length - 1]?.time ?? start) - start + step);
$: barWidth = Math.min((100 * step) / span, 12);
$: hovered = active !== null ? series[active] : null;
$: tooltipRows = hovered ? intervalBreakdown(hovered.models, visibleIds, activeModel) : [];
$: hoveredModels = hovered ? data.find((bucket) => bucket.date === hovered.date)?.models : null;
$: labels = [
...new Set(
chartWidth >= 480
? [0, Math.floor((series.length - 1) / 2), series.length - 1]
: [0, series.length - 1]
)
].filter((i) => i >= 0 && i < series.length);
$: lanes = visibleIds.map((id) => {
const values = series.map((point) => point.models[id] ?? 0);
return { id, peak: Math.max(0, ...values), values };
});
$: stacks = series.map((point) => visibleIds.filter((id) => (point.models[id] ?? 0) > 0));
const name = (id: string) => modelNames[id] || id;
const number = (value: number) =>
new Intl.NumberFormat($i18n.language, { notation: 'compact', maximumFractionDigits: 1 }).format(
value
);
const dateLabel = (date: string) =>
dayjs(date)
.locale($i18n.language)
.format(granularity === 'daily' ? 'MMM D' : 'MMM D, HH:mm');
const position = (time: number) => ((time - start + step / 2) / span) * 100;
function navigate(event: KeyboardEvent, index: number) {
const buttons = plot.querySelectorAll<HTMLButtonElement>('button[data-point]');
const destinations: Record<string, number> = {
ArrowRight: index + 1,
ArrowLeft: index - 1,
Home: 0,
End: buttons.length - 1
};
if (view === 'activity') {
destinations.ArrowDown = index + series.length;
destinations.ArrowUp = index - series.length;
}
const next = destinations[event.key];
if (next === undefined) return;
event.preventDefault();
keyboardIndex = Math.max(0, Math.min(buttons.length - 1, next));
buttons[keyboardIndex]?.focus();
}
onMount(() => {
tooltip = tippy(plot, {
content: tooltipContent,
trigger: 'manual',
theme: 'dark',
arrow: false,
placement: 'right-start',
offset: [0, 12],
popperOptions: {
modifiers: [{ name: 'flip', options: { fallbackPlacements: ['left-start'] } }]
},
duration: 0,
interactive: true,
maxWidth: 'min(20rem, calc(100vw - 1rem))',
appendTo: () => document.body,
aria: { content: null, expanded: false },
onHidden: () => {
active = null;
activeModel = null;
},
getReferenceClientRect: () => {
const rect = (anchor ?? plot).getBoundingClientRect();
const height = tooltip?.popper.offsetHeight || tooltipContent.offsetHeight;
return new DOMRect(
rect.x + rect.width / 2,
plot.getBoundingClientRect().top - height / 2,
0,
0
);
}
});
tooltipId = tooltip.popper.id;
return () => tooltip?.destroy();
});
async function showTooltip(
event: MouseEvent | FocusEvent,
index: number,
modelId: string | null = null
) {
anchor = event.currentTarget as HTMLElement;
activeModel = modelId;
active = index;
await tick();
if (
active !== index ||
activeModel !== modelId ||
!anchor?.isConnected ||
tooltip?.state.isDestroyed
)
return;
tooltip?.clearDelayTimeouts();
tooltip?.show();
tooltip?.popperInstance?.update();
}
function leaveTooltip(event: MouseEvent) {
tooltip?.hideWithInteractivity(event);
}
function hideTooltip() {
tooltip?.hide();
active = null;
activeModel = null;
}
const intervalLabel = (date: string) => {
const start = dayjs(date).locale($i18n.language);
if (granularity === 'daily') return start.format('MMM D, YYYY');
const end = start.add(intervalMinutes[granularity], 'minute');
return `${start.format('MMM D, YYYY · HH:mm')}–${end.format(start.isSame(end, 'day') ? 'HH:mm' : 'MMM D, HH:mm')}`;
};
function isolate(id: string | null) {
selectedModel = selectedModel === id ? null : id;
active = null;
keyboardIndex = 0;
}
</script>
<svelte:window
on:keydown|capture={(event) => {
if (event.key === 'Escape' && tooltip?.state.isVisible) {
event.stopPropagation();
hideTooltip();
}
}}
/>
<div
bind:clientWidth={chartWidth}
class="min-w-0 text-xs font-normal text-gray-500 dark:text-gray-400"
>
<div
class="h-5 mb-2 flex min-w-0 items-center justify-between gap-3 text-[0.6875rem] tabular-nums"
aria-live="polite"
aria-atomic="true"
>
<span
class="truncate"
title={hovered ? dateLabel(hovered.date) : selectedModel ? name(selectedModel) : undefined}
>{hovered
? dateLabel(hovered.date)
: selectedModel
? name(selectedModel)
: $i18n.t('All models')}</span
>
<span class="shrink-0 whitespace-nowrap"
>{hovered
? `${hovered.value.toLocaleString($i18n.language)} ${metricLabel.toLocaleLowerCase($i18n.language)}`
: metricLabel}</span
>
</div>
<div
bind:this={plot}
class="relative"
role="group"
aria-label={`${metricLabel} — ${$i18n.t('Use arrow keys to explore the chart')}`}
>
{#if view === 'volume'}
<div class="relative h-40 pl-12">
{#each [ceiling, ceiling * 0.75, ceiling / 2, ceiling * 0.25, 0] as tick}
<div
class="absolute inset-x-0 flex items-center gap-2 pointer-events-none translate-y-1/2"
style:bottom={`${(tick / ceiling) * 100}%`}
>
<span class="w-10 shrink-0 text-right text-[0.6875rem] tabular-nums"
>{number(tick)}</span
>
<span class="h-px flex-1 bg-gray-100 dark:bg-gray-850/50"></span>
</div>
{/each}
<div class="relative h-full">
{#each series as point, index (point.date)}
<button
type="button"
data-point
class="absolute bottom-0 top-0 flex justify-center items-end focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
style:left={`${position(point.time)}%`}
style:width={`${barWidth}%`}
style:transform="translateX(-50%)"
tabindex={keyboardIndex === index ? 0 : -1}
aria-describedby={active === index && activeModel === null ? tooltipId : undefined}
aria-label={`${dateLabel(point.date)}: ${point.value.toLocaleString($i18n.language)} ${metricLabel}. ${stacks[index].map((id) => `${name(id)}: ${point.models[id].toLocaleString($i18n.language)}`).join(', ')}`}
on:mouseenter={(event) => showTooltip(event, index)}
on:mouseleave={leaveTooltip}
on:focus={(event) => {
keyboardIndex = index;
showTooltip(event, index);
}}
on:blur={hideTooltip}
on:click={(event) => showTooltip(event, index)}
on:keydown={(event) => navigate(event, index)}
>
<span
class="w-3/4 max-w-12 h-full flex flex-col-reverse justify-start"
style:opacity={active === null || active === index ? 1 : 0.45}
>
{#each stacks[index] as id}
<span
class="block shrink-0 w-full"
style:height={`${(point.models[id] / ceiling) * 100}%`}
style:background-color={modelColor(id)}
></span>
{/each}
</span>
</button>
{/each}
</div>
</div>
{:else}
<div class="space-y-1">
{#each lanes as lane, row (lane.id)}
<div class="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 items-center">
<button
type="button"
class="text-left truncate py-1"
title={name(lane.id)}
aria-pressed={selectedModel === lane.id}
on:click={() => isolate(lane.id)}>{name(lane.id)}</button
>
<div class="relative h-6 bg-gray-100/70 dark:bg-gray-850/40">
{#each series as point, index (point.date)}
{@const cellIndex = row * series.length + index}
<button
type="button"
data-point
class="absolute inset-y-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
style:left={`${position(point.time)}%`}
style:width={`${(100 * step) / span}%`}
style:transform="translateX(-50%)"
tabindex={keyboardIndex === cellIndex ? 0 : -1}
aria-label={`${name(lane.id)}, ${dateLabel(point.date)}: ${lane.values[index].toLocaleString($i18n.language)} ${metricLabel}`}
aria-describedby={active === index && activeModel === lane.id
? tooltipId
: undefined}
on:mouseenter={(event) => showTooltip(event, index, lane.id)}
on:mouseleave={leaveTooltip}
on:focus={(event) => {
keyboardIndex = cellIndex;
showTooltip(event, index, lane.id);
}}
on:blur={hideTooltip}
on:click={(event) => showTooltip(event, index, lane.id)}
on:keydown={(event) => navigate(event, cellIndex)}
>
<span
class="block absolute inset-0"
style:background-color={modelColor(lane.id)}
style:opacity={lane.values[index] > 0
? 0.25 + (0.75 * lane.values[index]) / lane.peak
: 0}
></span>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
{#if hovered}
<div
class="pointer-events-none absolute inset-y-0 right-0 z-10"
style:left={view === 'volume' ? '3rem' : '7.5rem'}
aria-hidden="true"
>
<div
class="absolute inset-y-0 w-px bg-gray-400/70 dark:bg-gray-300/70"
style:left={`${position(hovered.time)}%`}
style:transform="translateX(-50%)"
></div>
</div>
{/if}
</div>
<div
class="relative mt-2 h-4 text-[0.625rem] tabular-nums"
style:margin-left={view === 'volume' ? '3rem' : '7.5rem'}
>
{#each labels as index}
<span
class="absolute whitespace-nowrap"
style:left={`${position(series[index].time)}%`}
style:transform={series.length === 1
? 'translateX(-50%)'
: index === 0
? 'none'
: index === series.length - 1
? 'translateX(-100%)'
: 'translateX(-50%)'}>{dateLabel(series[index].date)}</span
>
{/each}
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.6875rem]">
<button
type="button"
class="py-1"
class:text-gray-900={selectedModel === null}
class:dark:text-gray-200={selectedModel === null}
aria-pressed={selectedModel === null}
on:click={() => isolate(null)}>{$i18n.t('All models')}</button
>
{#each ids as id (id)}
<button
type="button"
class="flex min-w-0 max-w-full items-center gap-1.5 py-1 text-left hover:text-gray-900 dark:hover:text-gray-100"
style:opacity={selectedModel && selectedModel !== id ? 0.45 : 1}
aria-label={`${name(id)} — ${$i18n.t('Select to isolate model')}`}
aria-pressed={selectedModel === id}
on:click={() => isolate(id)}
>
<span class="size-2 shrink-0" style:background-color={modelColor(id)}></span>
<span class="max-w-40 truncate" title={name(id)}>{name(id)}</span>
<span
class="w-14 shrink-0 text-right tabular-nums text-gray-400 dark:text-gray-500"
title={(hovered ? (hoveredModels?.[id] ?? 0) : totals[id]).toLocaleString($i18n.language)}
>{number(hovered ? (hoveredModels?.[id] ?? 0) : totals[id])}</span
>
</button>
{/each}
</div>
<p class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-500">
{view === 'activity'
? $i18n.t('Intensity relative to each model’s peak. Blank means no recorded usage.')
: $i18n.t('Select a model to isolate it.')} · {$i18n.t('Server time')}
</p>
</div>
<div class="hidden">
<div
bind:this={tooltipContent}
class="w-64 max-w-full py-0.5 text-[0.625rem] leading-4 font-normal text-gray-100"
>
{#if hovered}
<div class="truncate text-gray-400" title={intervalLabel(hovered.date)}>
{intervalLabel(hovered.date)}
</div>
<div class="mt-1 max-h-48 overflow-y-auto scrollbar-hidden space-y-0.5">
{#each tooltipRows as row (row.id)}
<div
class="flex h-4 items-center justify-between gap-3"
class:text-gray-500={row.value === 0}
>
<span class="flex min-w-0 items-center gap-1.5"
><span class="size-1.5 shrink-0" style:background-color={modelColor(row.id)}
></span><span class="min-w-0 truncate" title={name(row.id)}>{name(row.id)}</span
></span
>
<span class="shrink-0 whitespace-nowrap tabular-nums"
>{row.value.toLocaleString($i18n.language)}
<span class="text-gray-400">({usageShare(row.value, hovered.value)})</span></span
>
</div>
{:else}<div class="text-gray-400">{$i18n.t('No activity in this period')}</div>{/each}
</div>
{/if}
</div>
</div>