127 lines
5.1 KiB
TypeScript
127 lines
5.1 KiB
TypeScript
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<T>(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
|
|
};
|
|
}
|