| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export interface PanelRect { |
| top: number; |
| height: number; |
| } |
|
|
| export interface PanelGeometryDiff { |
| heightChangers: Array<{ key: string; delta: number }>; |
| movedOnly: string[]; |
| inserted: string[]; |
| |
| |
| removed: string[]; |
| } |
|
|
| export interface MoverRecord extends PanelGeometryDiff { |
| |
| t: number; |
| |
| value: number; |
| |
| entryCount?: number; |
| |
| |
| coldStart?: boolean; |
| } |
|
|
| |
| const GEOMETRY_JITTER_PX = 2; |
| |
| const RECORD_SHIFT_THRESHOLD = 0.05; |
| |
| const MAX_RECORDS = 6; |
| |
| const CACHE_REFRESH_MIN_MS = 500; |
|
|
| |
| export function diffPanelGeometry( |
| cache: Record<string, PanelRect>, |
| current: Record<string, PanelRect>, |
| ): PanelGeometryDiff { |
| const heightChangers: Array<{ key: string; delta: number }> = []; |
| const movedOnly: string[] = []; |
| const inserted: string[] = []; |
| const removed = Object.keys(cache).filter((key) => !(key in current)); |
| for (const [key, rect] of Object.entries(current)) { |
| const prev = cache[key]; |
| if (!prev) { |
| inserted.push(key); |
| continue; |
| } |
| const dH = rect.height - prev.height; |
| const dTop = rect.top - prev.top; |
| if (Math.abs(dH) > GEOMETRY_JITTER_PX) { |
| heightChangers.push({ key, delta: Math.round(dH) }); |
| } else if (Math.abs(dTop) > GEOMETRY_JITTER_PX) { |
| movedOnly.push(key); |
| } |
| } |
| return { heightChangers, movedOnly, inserted, removed }; |
| } |
|
|
| |
| |
| |
| |
| export function formatMoverRecords(records: MoverRecord[]): string[] { |
| return [...records] |
| .sort((a, b) => b.value - a.value) |
| .slice(0, 3) |
| .map((r) => { |
| const parts = [`t=${r.t} v=${r.value}`]; |
| if (r.heightChangers.length > 0) { |
| |
| |
| parts.push( |
| `sized:${r.heightChangers |
| .map((c) => `${c.key}${c.delta >= 0 ? '+' : ''}${c.delta}`) |
| .join(',')}`, |
| ); |
| } |
| if (r.inserted.length > 0) parts.push(`ins:${r.inserted.join(',')}`); |
| if (r.removed.length > 0) parts.push(`rem:${r.removed.join(',')}`); |
| if (r.movedOnly.length > 0) parts.push(`moved:${r.movedOnly.length}`); |
| if ((r.entryCount ?? 1) > 1) parts.push(`n=${r.entryCount}`); |
| if (r.coldStart) parts.push('cold'); |
| return parts.join(' '); |
| }); |
| } |
|
|
| let records: MoverRecord[] = []; |
| let cache: Record<string, PanelRect> | null = null; |
| let lastRefresh = 0; |
| let started = false; |
| let observer: PerformanceObserver | null = null; |
| let onPageShow: ((event: PageTransitionEvent) => void) | null = null; |
|
|
| function snapshotPanels(): Record<string, PanelRect> | null { |
| const grids = ['panelsGrid', 'mapBottomGrid'] |
| .map((id) => document.getElementById(id)) |
| .filter((grid): grid is HTMLElement => grid !== null); |
| if (grids.length === 0) return null; |
| const out: Record<string, PanelRect> = {}; |
| for (const grid of grids) { |
| for (const el of grid.querySelectorAll<HTMLElement>(':scope > [data-panel], :scope > [data-cls-mover]')) { |
| const key = el.dataset.panel ?? el.dataset.clsMover; |
| if (!key) continue; |
| const rect = el.getBoundingClientRect(); |
| out[key] = { top: Math.round(rect.top + window.scrollY), height: Math.round(rect.height) }; |
| } |
| } |
| return out; |
| } |
|
|
| function resetMoverState(): void { |
| records = []; |
| cache = snapshotPanels(); |
| lastRefresh = cache ? performance.now() : 0; |
| } |
|
|
| |
| export function getMoverRecordStrings(): string[] { |
| return formatMoverRecords(records); |
| } |
|
|
| |
| export function resetClsMoverTrackingForTesting(): void { |
| observer?.disconnect(); |
| observer = null; |
| if (onPageShow && typeof window !== 'undefined') window.removeEventListener('pageshow', onPageShow); |
| onPageShow = null; |
| records = []; |
| cache = null; |
| lastRefresh = 0; |
| started = false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function startClsMoverTracking(): void { |
| if (started || typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return; |
| started = true; |
| resetMoverState(); |
| try { |
| observer = new PerformanceObserver((list) => { |
| const entries = list.getEntries() as Array<PerformanceEntry & { value: number; hadRecentInput: boolean }>; |
| if (entries.length === 0) return; |
| const now = performance.now(); |
|
|
| |
| |
| |
| if (entries.some((entry) => entry.hadRecentInput)) { |
| const current = snapshotPanels(); |
| if (current) { |
| cache = current; |
| lastRefresh = now; |
| } |
| return; |
| } |
|
|
| const value = entries.reduce((sum, entry) => sum + entry.value, 0); |
| const latest = entries[entries.length - 1]!; |
| if (value >= RECORD_SHIFT_THRESHOLD) { |
| const current = snapshotPanels(); |
| if (!current) return; |
| const roundedValue = Math.round(value * 1000) / 1000; |
| const entryCount = entries.length > 1 ? entries.length : undefined; |
| if (!cache) { |
| records.push({ |
| t: Math.round(latest.startTime), value: roundedValue, entryCount, |
| heightChangers: [], movedOnly: [], inserted: [], removed: [], |
| coldStart: true, |
| }); |
| } else { |
| const diff = diffPanelGeometry(cache, current); |
| if (diff.heightChangers.length > 0 || diff.inserted.length > 0 || diff.removed.length > 0 || diff.movedOnly.length > 0) { |
| records.push({ t: Math.round(latest.startTime), value: roundedValue, entryCount, ...diff }); |
| } |
| } |
| if (records.length > MAX_RECORDS) records = records.slice(-MAX_RECORDS); |
| cache = current; |
| lastRefresh = now; |
| } else if (now - lastRefresh > CACHE_REFRESH_MIN_MS) { |
| const current = snapshotPanels(); |
| if (current) { |
| cache = current; |
| lastRefresh = now; |
| } |
| } |
| }); |
| observer.observe({ type: 'layout-shift', buffered: true }); |
| onPageShow = (event: PageTransitionEvent): void => { |
| if (event.persisted) resetMoverState(); |
| }; |
| window.addEventListener('pageshow', onPageShow); |
| } catch { |
| |
| } |
| } |
|
|