const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); /** Separate SVG paths at unavailable periods, including isolated recorded points. */ export function metricPlayerPaths(values: readonly (number | null)[], ceiling: number) { const segments: { x: number; y: number }[][] = []; let segment: { x: number; y: number }[] = []; for (let index = 0; index < values.length; index++) { const value = values[index]; if (value === null) { segment = []; continue; } if (!segment.length) segments.push(segment); segment.push({ x: ((index + 0.5) / values.length) * 100, y: 100 - (value / ceiling) * 100 }); } const line = (points: typeof segment) => points.map(({ x, y }, index) => `${index ? 'L' : 'M'}${x},${y}`).join(' '); return { line: segments.map(line).join(' '), area: segments .map((points) => `${line(points)} L${points.at(-1)!.x},100 L${points[0].x},100 Z`) .join(' ') }; } /** Label the actual bucket start in the chosen timezone; never imply local calendar-day buckets. */ export function metricPeriodLabels( points: readonly { startAt: number }[], days: 1 | 7 | 30, timeZone: string ) { const stride = days === 7 ? 1 : Math.max(1, Math.ceil(points.length / 6)); const weekday = new Intl.DateTimeFormat('en-US', { timeZone, weekday: 'short' }); const date = new Intl.DateTimeFormat('en-US', { timeZone, day: 'numeric' }); const clock = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric' }); return points.flatMap((point, index) => index % stride ? [] : [ { index, position: ((index + 0.5) / points.length) * 100, label: days === 1 ? clock.format(point.startAt) : weekday.format(point.startAt), date: days === 30 ? date.format(point.startAt) : '' } ] ); } export function metricNumber(value: number): string { if (!Number.isFinite(value)) return '—'; if (value > 0 && value < 0.1) return '<0.1'; return numberFormat.format(value); } /** Normalize lobby-hours by captured wall time so partial buckets remain comparable. */ export function metricAverageLobbies(lobbyHours: number, capturedHours: number): number | null { if (!Number.isFinite(lobbyHours) || !Number.isFinite(capturedHours) || capturedHours <= 0) return null; return lobbyHours / capturedHours; } /** Use up to four whole-lobby intervals while retaining fractional averages in the data. */ export function metricLobbyAxis(maximumLobbies: number) { const maximum = Number.isFinite(maximumLobbies) ? Math.max(1, maximumLobbies) : 1; const targetStep = Math.max(1, maximum / 4); const magnitude = 10 ** Math.floor(Math.log10(targetStep)); const step = [1, 2, 5, 10] .map((multiple) => multiple * magnitude) .find((value) => value >= targetStep) ?? 10 * magnitude; const intervals = Math.ceil(maximum / step); return { ceiling: intervals * step, ticks: Array.from({ length: intervals + 1 }, (_, index) => (intervals - index) * step) }; } export function metricPercent(value: number, total: number): string { if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—'; const percent = (100 * value) / total; return percent > 0 && percent < 0.1 ? '<0.1%' : `${percent.toFixed(1)}%`; } /** Input is hours; retain short, real observations instead of rounding them to zero. */ export function metricDuration(hours: number): string { if (!Number.isFinite(hours) || hours < 0) return '—'; if (hours === 0) return '0 h'; const seconds = hours * 3600; if (seconds < 1) return '<1 s'; if (seconds < 60) return `${numberFormat.format(seconds)} s`; if (hours < 0.1) return `${numberFormat.format(seconds / 60)} min`; return `${numberFormat.format(hours)} h`; } /** Missing evidence always sorts last, in either direction. */ export function compareMetricValues( left: number | null, right: number | null, direction: 'asc' | 'desc' ): number { const a = left !== null && Number.isFinite(left) ? left : null; const b = right !== null && Number.isFinite(right) ? right : null; if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1; return (a - b) * (direction === 'asc' ? 1 : -1); } /** Clamp saved pages after filtering or polling changes the result count. */ export function metricPage(rows: T[], requested: string | null, size = 15) { const parsed = Number(requested); const pages = Math.max(1, Math.ceil(rows.length / size)); const page = Math.min(pages, Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1); const start = (page - 1) * size; return { page, pages, rows: rows.slice(start, start + size), start: rows.length ? start + 1 : 0, end: Math.min(rows.length, start + size), total: rows.length }; }