Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -3,19 +3,29 @@
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||
import { metricDuration, metricNumber, metricPercent, metricPeriodLabels } from '$ui/metrics.js';
|
||||
import {
|
||||
metricDuration,
|
||||
metricNumber,
|
||||
metricPercent,
|
||||
metricPeriodLabels
|
||||
} from '$ui/metrics.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
|
||||
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } = $props();
|
||||
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } =
|
||||
$props();
|
||||
let selectedAt = $state<number | null>(null);
|
||||
let announcement = $state('');
|
||||
const format = $derived(createTimeFormatter(timeZone));
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const mode = $derived(new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent');
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const mode = $derived(
|
||||
new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent'
|
||||
);
|
||||
const selectedIndex = $derived.by(() => {
|
||||
const existing = points.findIndex(point => point.at === selectedAt);
|
||||
const existing = points.findIndex((point) => point.at === selectedAt);
|
||||
if (existing >= 0) return existing;
|
||||
for (let i = points.length - 1; i >= 0; i--) if (!points[i].partial) return i;
|
||||
return Math.max(0, points.length - 1);
|
||||
@@ -24,32 +34,43 @@
|
||||
const selectedValue = $derived(selected ? String(selected.at) : '');
|
||||
const ceiling = $derived.by(() => {
|
||||
if (mode === 'percent') return 100;
|
||||
const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
|
||||
const max = Math.max(0, ...points.map((point) => point.observed + point.unknown));
|
||||
if (!max) return 1;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(max));
|
||||
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
|
||||
});
|
||||
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
|
||||
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` })));
|
||||
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
|
||||
const ticks = $derived(
|
||||
[1, 0.75, 0.5, 0.25, 0].map((fraction) => ({
|
||||
fraction,
|
||||
value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}`
|
||||
}))
|
||||
);
|
||||
const periodOptions = $derived(
|
||||
points.map((point) => ({
|
||||
value: String(point.at),
|
||||
label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}`
|
||||
}))
|
||||
);
|
||||
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
|
||||
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
|
||||
|
||||
function setMode(value: 'hours' | 'percent') {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
if (value === 'percent') url.searchParams.delete('chartMode'); else url.searchParams.set('chartMode', value);
|
||||
if (value === 'percent') url.searchParams.delete('chartMode');
|
||||
else url.searchParams.set('chartMode', value);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function selectPeriod(at: number) {
|
||||
selectedAt = at;
|
||||
const point = points.find(item => item.at === at);
|
||||
const point = points.find((item) => item.at === at);
|
||||
if (!point) return;
|
||||
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown.` : 'No captured time.'}`;
|
||||
}
|
||||
function segmentHeight(point: MetricPoint, value: number) {
|
||||
const denominator = mode === 'percent' ? point.observed + point.unknown : ceiling;
|
||||
return denominator > 0 ? value / denominator * 100 : 0;
|
||||
return denominator > 0 ? (value / denominator) * 100 : 0;
|
||||
}
|
||||
function move(direction: number) {
|
||||
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
|
||||
@@ -58,129 +79,600 @@
|
||||
function chartKey(event: KeyboardEvent, index: number) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? points.length - 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1))
|
||||
);
|
||||
selectPeriod(points[next].at);
|
||||
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
|
||||
(event.currentTarget as HTMLElement).parentElement
|
||||
?.querySelectorAll<HTMLButtonElement>('button')
|
||||
[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="metrics-trend-title">
|
||||
<div class="heading"><div><h2 id="metrics-trend-title">Server activity over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
|
||||
<div class="mode" role="group" aria-label="Timeline measure"><button type="button" aria-pressed={mode === 'percent'} onclick={() => setMode('percent')}>Percent</button><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}>Hours</button></div>
|
||||
<div class="heading">
|
||||
<div>
|
||||
<h2 id="metrics-trend-title">Server activity over time</h2>
|
||||
<p>
|
||||
{mode === 'percent'
|
||||
? 'Share of captured server time, including unknown status.'
|
||||
: `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain
|
||||
less elapsed time.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mode" role="group" aria-label="Timeline measure">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === 'percent'}
|
||||
onclick={() => setMode('percent')}>Percent</button
|
||||
><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}
|
||||
>Hours</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span
|
||||
><i class="unknown"></i>Unknown</span
|
||||
><span class="partial-key">* Partial period</span>
|
||||
</div>
|
||||
<div class="axis-title">
|
||||
{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}
|
||||
</div>
|
||||
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
|
||||
<div class="axis-title">{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}</div>
|
||||
<div class="chart-grid">
|
||||
<div class="y-axis" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}>{item.value}</span>{/each}</div>
|
||||
<div class="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
|
||||
<div class="grid-lines" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}></span>{/each}</div>
|
||||
<div class="y-axis" aria-hidden="true">
|
||||
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
|
||||
>{item.value}</span
|
||||
>{/each}
|
||||
</div>
|
||||
<div
|
||||
class="plot"
|
||||
role="group"
|
||||
aria-label="Server time by period. Select a bar or use the period selector below."
|
||||
>
|
||||
<div class="grid-lines" aria-hidden="true">
|
||||
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
|
||||
></span>{/each}
|
||||
</div>
|
||||
{#each points as point, index (point.at)}
|
||||
<button class="bar" class:selected={point.at === selected?.at} class:partial={point.partial} type="button" tabindex={index === selectedIndex ? 0 : -1}
|
||||
<button
|
||||
class="bar"
|
||||
class:selected={point.at === selected?.at}
|
||||
class:partial={point.partial}
|
||||
type="button"
|
||||
tabindex={index === selectedIndex ? 0 : -1}
|
||||
aria-pressed={point.at === selected?.at}
|
||||
aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown` : 'No captured time'}`}
|
||||
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
|
||||
onclick={() => selectPeriod(point.at)} onkeydown={event => chartKey(event, index)}>
|
||||
onclick={() => selectPeriod(point.at)}
|
||||
onkeydown={(event) => chartKey(event, index)}
|
||||
>
|
||||
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
|
||||
<span class="segment unknown" style:height={`${segmentHeight(point, point.unknown)}%`}></span>
|
||||
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}></span>
|
||||
<span class="segment occupied" style:height={`${segmentHeight(point, point.active)}%`}></span>
|
||||
{#if point.observed + point.unknown === 0}<span class="no-time" aria-hidden="true">·</span>{/if}
|
||||
<span
|
||||
class="segment unknown"
|
||||
style:height={`${segmentHeight(point, point.unknown)}%`}
|
||||
></span>
|
||||
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}
|
||||
></span>
|
||||
<span
|
||||
class="segment occupied"
|
||||
style:height={`${segmentHeight(point, point.active)}%`}
|
||||
></span>
|
||||
{#if point.observed + point.unknown === 0}<span
|
||||
class="no-time"
|
||||
aria-hidden="true">·</span
|
||||
>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">{#each periodLabels as item}<span style:left={`${item.position}%`} title={format.period(points[item.index].startAt)}>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span>{/each}</div>
|
||||
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">
|
||||
{#each periodLabels as item}<span
|
||||
style:left={`${item.position}%`}
|
||||
title={format.period(points[item.index].startAt)}
|
||||
>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span
|
||||
>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="period-note">{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll('_', ' ')}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}</p>
|
||||
<p class="period-note">
|
||||
{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}
|
||||
</p>
|
||||
{#if selected}
|
||||
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle servers'}</span></div>
|
||||
<details class="inspection"><summary>Inspect a period</summary>
|
||||
<div class="period-controls">
|
||||
<div class="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
|
||||
<div class="step-buttons"><Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}>← Previous</Button><Button size="sm" disabled={selectedIndex === points.length - 1} onclick={() => move(1)}>Next →</Button></div>
|
||||
</div>
|
||||
<div class="readout" aria-label="Selected period details">
|
||||
<p class="interval">{format.period(selected.startAt)} – {format.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
|
||||
{#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
|
||||
{:else}<dl>
|
||||
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, captured)} captured · {metricPercent(selected.active, selected.observed)} known</small></dd></div>
|
||||
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}<small>{metricPercent(selected.idle, captured)} of captured time</small></dd></div>
|
||||
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}<small>{metricPercent(selected.unknown, captured)} of captured time</small></dd></div>
|
||||
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
|
||||
</dl><p class="denominator">{metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.</p>{/if}
|
||||
<div class="selection-summary" aria-label="Selected period summary">
|
||||
<time
|
||||
>{format.period(selected.startAt)}{selected.partial
|
||||
? ' · Partial period'
|
||||
: ''}</time
|
||||
><span
|
||||
>{captured
|
||||
? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours`
|
||||
: 'No captured time · not evidence of idle servers'}</span
|
||||
>
|
||||
</div>
|
||||
<details class="inspection">
|
||||
<summary>Inspect a period</summary>
|
||||
<div class="period-controls">
|
||||
<div class="period-select">
|
||||
<Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}
|
||||
><Select
|
||||
value={selectedValue}
|
||||
options={periodOptions}
|
||||
onchange={(event) =>
|
||||
selectPeriod(
|
||||
Number((event.currentTarget as HTMLSelectElement).value)
|
||||
)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
<div class="step-buttons">
|
||||
<Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}
|
||||
>← Previous</Button
|
||||
><Button
|
||||
size="sm"
|
||||
disabled={selectedIndex === points.length - 1}
|
||||
onclick={() => move(1)}>Next →</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="readout" aria-label="Selected period details">
|
||||
<p class="interval">
|
||||
{format.period(selected.startAt)} – {format.period(selected.endAt)}
|
||||
{#if selected.partial}<span class="partial-label">Partial period</span>{/if}
|
||||
</p>
|
||||
{#if captured === 0}<p class="empty">
|
||||
No captured time in this period. Missing observations do not mean the fleet
|
||||
was idle.
|
||||
</p>
|
||||
{:else}<dl>
|
||||
<div>
|
||||
<dt>With players</dt>
|
||||
<dd>
|
||||
{metricDuration(selected.active)}<small
|
||||
>{metricPercent(selected.active, captured)} captured · {metricPercent(
|
||||
selected.active,
|
||||
selected.observed
|
||||
)} known</small
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Idle</dt>
|
||||
<dd>
|
||||
{metricDuration(selected.idle)}<small
|
||||
>{metricPercent(selected.idle, captured)} of captured time</small
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unknown</dt>
|
||||
<dd>
|
||||
{metricDuration(selected.unknown)}<small
|
||||
>{metricPercent(selected.unknown, captured)} of captured time</small
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Player-hours</dt>
|
||||
<dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="denominator">
|
||||
{metricDuration(captured)} captured server time · {metricDuration(
|
||||
selected.observed
|
||||
)} known. Percentage bars include unknown time; the occupancy KPI excludes it.
|
||||
</p>{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
<details class="time-data"><summary>View exact time data</summary>
|
||||
<details class="time-data">
|
||||
<summary>View exact time data</summary>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0"><table>
|
||||
<caption>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
|
||||
<thead><tr><th scope="col">Period</th><th scope="col">With players</th><th scope="col">Idle</th><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr></thead>
|
||||
<tbody>{#each points as point (point.at)}<tr><th scope="row">{format.period(point.startAt)}<small>to {format.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0">
|
||||
<table>
|
||||
<caption
|
||||
>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from
|
||||
captured observations.</caption
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Period</th><th scope="col">With players</th><th scope="col"
|
||||
>Idle</th
|
||||
><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each points as point (point.at)}<tr
|
||||
><th scope="row"
|
||||
>{format.period(point.startAt)}<small
|
||||
>to {format.period(point.endAt)}{point.partial
|
||||
? ' · Partial'
|
||||
: ''}</small
|
||||
></th
|
||||
><td>{metricDuration(point.active)}</td><td
|
||||
>{metricDuration(point.idle)}</td
|
||||
><td>{metricDuration(point.unknown)}</td><td
|
||||
>{point.observed ? metricNumber(point.playerHours) : '—'}</td
|
||||
></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selection-summary { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 16px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
|
||||
.mode { display: flex; border: 1px solid var(--border-subtle); padding: 3px; border-radius: var(--radius-md); }
|
||||
.mode button { border: 0; border-radius: var(--radius-sm); padding: 7px 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
|
||||
.mode button[aria-pressed='true'] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
.mode button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.legend { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; font-size: var(--text-xs); }
|
||||
.legend span { display: flex; align-items: center; gap: 6px; }
|
||||
.legend i { width: 10px; height: 10px; border-radius: 2px; }
|
||||
.occupied { background: var(--chart-active); }
|
||||
.idle { background: repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px), var(--chart-idle); }
|
||||
.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
|
||||
.partial-key { color: var(--text-secondary); }
|
||||
.axis-title { margin-top: var(--space-5); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.chart-grid { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 8px; margin-top: 12px; }
|
||||
.y-axis { position: relative; text-align: right; font-size: var(--text-xs); color: var(--text-tertiary); height: 210px; }
|
||||
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
|
||||
.plot { display: flex; position: relative; height: 210px; gap: 4px; isolation: isolate; border-bottom: 1px solid var(--border-strong); }
|
||||
.grid-lines { position: absolute; inset: 0; pointer-events: none; z-index: -1; }
|
||||
.grid-lines span { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border-subtle); }
|
||||
.bar { appearance: none; position: relative; flex: 1; min-width: 0; border: 0; margin: 0; padding: 0; background: transparent; display: flex; flex-direction: column; justify-content: flex-end; cursor: pointer; border-radius: 2px 2px 0 0; }
|
||||
.bar.selected { outline: 1px solid var(--text-secondary); outline-offset: 2px; }
|
||||
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
|
||||
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
|
||||
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
|
||||
.no-time { color: var(--text-tertiary); font-size: var(--text-lg); line-height: 1; }
|
||||
.bar.partial { border-inline: 1px dashed var(--border-strong); }
|
||||
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: var(--text-base); color: var(--text-secondary); }
|
||||
.x-axis { grid-column: 2; position: relative; min-width: 0; height: 1.6em; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 10px; }
|
||||
.x-axis.with-dates { height: 3em; }
|
||||
.x-axis > span { position: absolute; transform: translateX(-50%); text-align: center; white-space: nowrap; }
|
||||
.x-axis small { margin: 0; }
|
||||
.period-note { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); margin: var(--space-2) 0 0; }
|
||||
.period-controls { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-5); }
|
||||
.period-select { flex: 1 1 250px; max-width: 450px; }
|
||||
.step-buttons { display: flex; gap: var(--space-2); padding-bottom: 3px; }
|
||||
.readout { margin-top: var(--space-4); padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-subtle); }
|
||||
.interval { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; line-height: 1.6; }
|
||||
.partial-label { display: inline-block; margin-left: 4px; padding: 1px 6px; border: 1px dashed var(--border-strong); border-radius: 4px; }
|
||||
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-3) 0 0; }
|
||||
dt { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
|
||||
small { display: block; color: var(--text-tertiary); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; }
|
||||
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.denominator { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; margin: var(--space-3) 0 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
details { margin-top: var(--space-4); font-size: var(--text-sm); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
|
||||
.table-wrap { position: relative; max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
|
||||
caption { text-align: left; color: var(--text-tertiary); margin-bottom: 8px; }
|
||||
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
thead th { color: var(--text-secondary); }
|
||||
th:first-child { text-align: left; }
|
||||
tbody th { font-weight: var(--weight-regular); }
|
||||
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .step-buttons { flex-wrap: wrap; } }
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin-top: 16px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heading > div:first-child {
|
||||
flex: 1 1 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.mode button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode button[aria-pressed='true'] {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mode button:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.heading p {
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.legend span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.legend i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.occupied {
|
||||
background: var(--chart-active);
|
||||
}
|
||||
.idle {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent 0 5px,
|
||||
color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px
|
||||
),
|
||||
var(--chart-idle);
|
||||
}
|
||||
.unknown {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--chart-unknown-base) 0 3px,
|
||||
var(--chart-unknown-stripe) 3px 5px
|
||||
);
|
||||
}
|
||||
.partial-key {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.axis-title {
|
||||
margin-top: var(--space-5);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
column-gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.y-axis {
|
||||
position: relative;
|
||||
text-align: right;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
height: 210px;
|
||||
}
|
||||
.y-axis span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.plot {
|
||||
display: flex;
|
||||
position: relative;
|
||||
height: 210px;
|
||||
gap: 4px;
|
||||
isolation: isolate;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.grid-lines span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.bar {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
cursor: pointer;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.bar.selected {
|
||||
outline: 1px solid var(--text-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.bar:focus-visible {
|
||||
outline: 2px solid var(--accent-primary-hover);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.bar:hover {
|
||||
background: color-mix(in oklab, var(--accent-primary) 8%, transparent);
|
||||
}
|
||||
.segment {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.no-time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1;
|
||||
}
|
||||
.bar.partial {
|
||||
border-inline: 1px dashed var(--border-strong);
|
||||
}
|
||||
.partial-marker {
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.x-axis {
|
||||
grid-column: 2;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: 1.6em;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.x-axis.with-dates {
|
||||
height: 3em;
|
||||
}
|
||||
.x-axis > span {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.x-axis small {
|
||||
margin: 0;
|
||||
}
|
||||
.period-note {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-body);
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
.period-controls {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.period-select {
|
||||
flex: 1 1 250px;
|
||||
max-width: 450px;
|
||||
}
|
||||
.step-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.readout {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.interval {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.partial-label {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 4px;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.empty {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.denominator {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
details {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
caption {
|
||||
text-align: left;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.legend {
|
||||
gap: 10px;
|
||||
}
|
||||
.chart-grid {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.plot {
|
||||
gap: 3px;
|
||||
}
|
||||
dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.readout {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.step-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,25 +8,57 @@
|
||||
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
|
||||
let { report }: { report: MetricsData } = $props();
|
||||
const recommendations = $derived(metricRecommendations(report.maps));
|
||||
const highlighted = $derived(recommendations.filter(item => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')).slice(0, 3));
|
||||
const remaining = $derived(recommendations.filter(item => !highlighted.includes(item)));
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const highlighted = $derived(
|
||||
recommendations
|
||||
.filter(
|
||||
(item) => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')
|
||||
)
|
||||
.slice(0, 3)
|
||||
);
|
||||
const remaining = $derived(recommendations.filter((item) => !highlighted.includes(item)));
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const query = $derived((params.get('insightQ') ?? '').slice(0, 100));
|
||||
const sort = $derived(params.get('insightSort') === 'map' ? 'map' : 'priority');
|
||||
const visibleRemaining = $derived(remaining.filter(item => `${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0));
|
||||
const visibleRemaining = $derived(
|
||||
remaining
|
||||
.filter((item) =>
|
||||
`${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) =>
|
||||
sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0
|
||||
)
|
||||
);
|
||||
function updateContext(key: string, value: string) {
|
||||
const url = new URL(page.url); url.search = contextSearch;
|
||||
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
if (value) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
const limited = $derived(recommendations.filter(item => item.tentative).length);
|
||||
const limited = $derived(recommendations.filter((item) => item.tentative).length);
|
||||
</script>
|
||||
|
||||
{#snippet finding(suggestion: ReturnType<typeof metricRecommendations>[number])}
|
||||
<article class="recommendation">
|
||||
<div><strong>{suggestion.map}</strong><small>{suggestion.game.toUpperCase()}{suggestion.tentative ? ' · Limited evidence' : ''}</small></div>
|
||||
<div><strong>{suggestion.title}</strong><details><summary>Evidence and considerations</summary><p>{suggestion.evidence}</p><p>{suggestion.reason}</p><p>{suggestion.outcome}</p></details></div>
|
||||
<div>
|
||||
<strong>{suggestion.map}</strong><small
|
||||
>{suggestion.game.toUpperCase()}{suggestion.tentative
|
||||
? ' · Limited evidence'
|
||||
: ''}</small
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{suggestion.title}</strong>
|
||||
<details>
|
||||
<summary>Evidence and considerations</summary>
|
||||
<p>{suggestion.evidence}</p>
|
||||
<p>{suggestion.reason}</p>
|
||||
<p>{suggestion.outcome}</p>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
{/snippet}
|
||||
<section class="explore" aria-labelledby="metrics-explore-title">
|
||||
@@ -34,32 +66,133 @@
|
||||
<p>Historical signals for hosting trials. Check the current lineup before making a change.</p>
|
||||
{#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each}
|
||||
{:else}<p class="empty">No hosting trials meet the evidence threshold in this period.</p>{/if}
|
||||
{#if limited}<p class="notice">{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting, player limits, and observation duration before changing hosting.</p>{/if}
|
||||
{#if remaining.length}<details class="remaining"><summary>Other findings ({remaining.length})</summary>
|
||||
<div class="finding-controls"><Field label="Find a map"><Input type="search" value={query} placeholder="Map or game…" oninput={event => updateContext('insightQ', (event.currentTarget as HTMLInputElement).value)} /></Field><Field label="Sort findings"><Select value={sort} options={[{ value: 'priority', label: 'Priority' }, { value: 'map', label: 'Map A–Z' }]} onchange={event => updateContext('insightSort', (event.currentTarget as HTMLSelectElement).value)} /></Field></div>
|
||||
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>No other findings match this search.</p>{/each}</details>{/if}
|
||||
<details class="rules"><summary>How Rules v1 chooses a suggestion</summary>
|
||||
<ul>
|
||||
<li>Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion * 100}% of known-capacity time.</li>
|
||||
<li>When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}% occupied known time can suggest a tentative additional-lobby trial. Occupancy does not establish saturation.</li>
|
||||
<li>Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization * 100}%, and either measured pressure is below {HOSTING_RULES.quietPressure * 100}% or capacity is unavailable.</li>
|
||||
<li>Missing occupancy prompts a reporting check. Other cases suggest watching hosting or checking capacity reporting.</li>
|
||||
</ul>
|
||||
<p>Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity, or substantial unknown status marks advice as tentative. These are trial policies, not validated demand forecasts. Collection gaps are omitted from all rules.</p>
|
||||
</details>
|
||||
|
||||
{#if limited}<p class="notice">
|
||||
{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting,
|
||||
player limits, and observation duration before changing hosting.
|
||||
</p>{/if}
|
||||
{#if remaining.length}<details class="remaining">
|
||||
<summary>Other findings ({remaining.length})</summary>
|
||||
<div class="finding-controls">
|
||||
<Field label="Find a map"
|
||||
><Input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Map or game…"
|
||||
oninput={(event) =>
|
||||
updateContext(
|
||||
'insightQ',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
/></Field
|
||||
><Field label="Sort findings"
|
||||
><Select
|
||||
value={sort}
|
||||
options={[
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'map', label: 'Map A–Z' }
|
||||
]}
|
||||
onchange={(event) =>
|
||||
updateContext(
|
||||
'insightSort',
|
||||
(event.currentTarget as HTMLSelectElement).value
|
||||
)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>
|
||||
No other findings match this search.
|
||||
</p>{/each}
|
||||
</details>{/if}
|
||||
<details class="rules">
|
||||
<summary>How Rules v1 chooses a suggestion</summary>
|
||||
<ul>
|
||||
<li>
|
||||
Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion *
|
||||
100}% of known-capacity time.
|
||||
</li>
|
||||
<li>
|
||||
When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}%
|
||||
occupied known time can suggest a tentative additional-lobby trial. Occupancy does
|
||||
not establish saturation.
|
||||
</li>
|
||||
<li>
|
||||
Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization *
|
||||
100}%, and either measured pressure is below {HOSTING_RULES.quietPressure *
|
||||
100}% or capacity is unavailable.
|
||||
</li>
|
||||
<li>
|
||||
Missing occupancy prompts a reporting check. Other cases suggest watching hosting or
|
||||
checking capacity reporting.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity,
|
||||
or substantial unknown status marks advice as tentative. These are trial policies, not validated
|
||||
demand forecasts. Collection gaps are omitted from all rules.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.finding-controls { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.explore { padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||
.recommendation { display: grid; grid-template-columns: minmax(140px, .6fr) minmax(0, 2fr); gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
|
||||
small { display: block; color: var(--text-secondary); margin-top: 6px; font-size: var(--text-xs); }
|
||||
details { font-size: var(--text-xs); margin-top: 10px; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.remaining, .rules { margin-top: 20px; }
|
||||
.notice { border-left: 2px solid var(--state-warning); padding-left: 12px; }
|
||||
li { margin: 8px 0; color: var(--text-secondary); }
|
||||
@media (max-width: 540px) { .recommendation { grid-template-columns: 1fr; gap: 8px; } }
|
||||
.finding-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.explore {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.recommendation {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 0.6fr) minmax(0, 2fr);
|
||||
gap: 20px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
details {
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 10px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.remaining,
|
||||
.rules {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.notice {
|
||||
border-left: 2px solid var(--state-warning);
|
||||
padding-left: 12px;
|
||||
}
|
||||
li {
|
||||
margin: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.recommendation {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,100 +7,469 @@
|
||||
|
||||
type Heatmap = MetricsData['heatmap'];
|
||||
type Cell = Heatmap['cells'][number];
|
||||
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
|
||||
let {
|
||||
heatmap,
|
||||
disabled = false,
|
||||
onTimeZoneChange
|
||||
}: {
|
||||
heatmap: Heatmap;
|
||||
disabled?: boolean;
|
||||
onTimeZoneChange: (value: string) => void;
|
||||
} = $props();
|
||||
const id = $props.id();
|
||||
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const hours = Array.from({ length: 24 }, (_, hour) => hour);
|
||||
const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') })));
|
||||
const zones = $derived(
|
||||
[...new Set([...availableTimeZones(), heatmap.timeZone])]
|
||||
.sort()
|
||||
.map((zone) => ({ value: zone, label: zone.replaceAll('_', ' ') }))
|
||||
);
|
||||
const dayOptions = weekdays.map((label, day) => ({ value: String(day), label }));
|
||||
const hourOptions = hours.map(hour => ({ value: String(hour), label: hourLabel(hour) }));
|
||||
const hourOptions = hours.map((hour) => ({ value: String(hour), label: hourLabel(hour) }));
|
||||
let selectedKey = $state<number | null>(null);
|
||||
let announcement = $state('');
|
||||
const cells = $derived(new Map(heatmap.cells.map(cell => [cell.weekday * 24 + cell.hour, cell])));
|
||||
const selected = $derived(cells.get(selectedKey ?? -1) ?? heatmap.cells.find(cell => cell.observed + cell.unknown > 0) ?? heatmap.cells[0]);
|
||||
function hourLabel(hour: number) { return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`; }
|
||||
function cellAt(day: number, hour: number): Cell { return cells.get(day * 24 + hour) ?? { weekday: day, hour, active: 0, observed: 0, idle: 0, unknown: 0, playerHours: 0, distinctDates: 0 }; }
|
||||
const cells = $derived(
|
||||
new Map(heatmap.cells.map((cell) => [cell.weekday * 24 + cell.hour, cell]))
|
||||
);
|
||||
const selected = $derived(
|
||||
cells.get(selectedKey ?? -1) ??
|
||||
heatmap.cells.find((cell) => cell.observed + cell.unknown > 0) ??
|
||||
heatmap.cells[0]
|
||||
);
|
||||
function hourLabel(hour: number) {
|
||||
return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`;
|
||||
}
|
||||
function cellAt(day: number, hour: number): Cell {
|
||||
return (
|
||||
cells.get(day * 24 + hour) ?? {
|
||||
weekday: day,
|
||||
hour,
|
||||
active: 0,
|
||||
observed: 0,
|
||||
idle: 0,
|
||||
unknown: 0,
|
||||
playerHours: 0,
|
||||
distinctDates: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
function description(cell: Cell) {
|
||||
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
|
||||
}
|
||||
function select(day: number, hour: number) { selectedKey = day * 24 + hour; announcement = description(cellAt(day, hour)); }
|
||||
function select(day: number, hour: number) {
|
||||
selectedKey = day * 24 + hour;
|
||||
announcement = description(cellAt(day, hour));
|
||||
}
|
||||
function move(event: KeyboardEvent, cell: Cell) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
|
||||
return;
|
||||
event.preventDefault();
|
||||
const day = Math.max(0, Math.min(6, cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)));
|
||||
const hour = event.key === 'Home' ? 0 : event.key === 'End' ? 23 : Math.max(0, Math.min(23, cell.hour + (event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0)));
|
||||
const day = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
6,
|
||||
cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)
|
||||
)
|
||||
);
|
||||
const hour =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? 23
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
23,
|
||||
cell.hour +
|
||||
(event.key === 'ArrowLeft'
|
||||
? -1
|
||||
: event.key === 'ArrowRight'
|
||||
? 1
|
||||
: 0)
|
||||
)
|
||||
);
|
||||
const key = day * 24 + hour;
|
||||
select(day, hour);
|
||||
(event.currentTarget as HTMLElement).closest('table')?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)?.focus();
|
||||
(event.currentTarget as HTMLElement)
|
||||
.closest('table')
|
||||
?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)
|
||||
?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="heatmap" aria-labelledby={`${id}-heatmap-title`}>
|
||||
<div class="heading"><div><h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
|
||||
<div class="zone"><Field label="Report timezone"><Select value={heatmap.timeZone} options={zones} {disabled} onchange={event => onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)} /></Field></div>
|
||||
<div class="heading">
|
||||
<div>
|
||||
<h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2>
|
||||
<p>
|
||||
Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="zone">
|
||||
<Field label="Report timezone"
|
||||
><Select
|
||||
value={heatmap.timeZone}
|
||||
options={zones}
|
||||
{disabled}
|
||||
onchange={(event) =>
|
||||
onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span>0% <i class="scale"></i> 100% with players</span><span
|
||||
><i class="unknown-example"></i>Unknown only</span
|
||||
><span><i class="missing-example">·</i>No captured time</span>
|
||||
</div>
|
||||
<div class="legend"><span>0% <i class="scale"></i> 100% with players</span><span><i class="unknown-example"></i>Unknown only</span><span><i class="missing-example">·</i>No captured time</span></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The heatmap table must scroll with the keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Activity heatmap; use arrow keys between cells or the selectors below" tabindex="0"><table>
|
||||
<caption class="sr-only">Weekday and local hour activity. Cell details include known time and distinct recorded dates.</caption>
|
||||
<thead><tr><th scope="col">Day</th>{#each hours as hour}<th scope="col">{String(hour).padStart(2, '0')}</th>{/each}</tr></thead>
|
||||
<tbody>{#each weekdays as day, weekday}<tr><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell = cellAt(weekday, hour)}
|
||||
<td><button type="button" class="cell" class:known={cell.observed > 0} class:unknown={cell.observed === 0 && cell.unknown > 0} class:mixed={cell.observed > 0 && cell.unknown > 0} class:missing={cell.observed + cell.unknown === 0}
|
||||
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, 100 * cell.active / cell.observed)) : 0}%`}
|
||||
data-key={weekday * 24 + hour} tabindex={selected?.weekday === weekday && selected?.hour === hour ? 0 : -1}
|
||||
aria-pressed={selected?.weekday === weekday && selected?.hour === hour} aria-label={description(cell)} title={description(cell)}
|
||||
onclick={() => select(weekday, hour)} onkeydown={event => move(event, cell)}><span aria-hidden="true">{cell.observed > 0 ? '' : cell.unknown > 0 ? '?' : '·'}</span></button></td>
|
||||
{/each}</tr>{/each}</tbody>
|
||||
</table></div>
|
||||
<p class="note">A striped corner marks additional unknown time. Color uses known time only. Repeated daylight-saving clock hours share a cell; collection gaps stay uncaptured.</p>
|
||||
<div
|
||||
class="table-wrap"
|
||||
role="region"
|
||||
aria-label="Activity heatmap; use arrow keys between cells or the selectors below"
|
||||
tabindex="0"
|
||||
>
|
||||
<table>
|
||||
<caption class="sr-only"
|
||||
>Weekday and local hour activity. Cell details include known time and distinct
|
||||
recorded dates.</caption
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Day</th>{#each hours as hour}<th scope="col"
|
||||
>{String(hour).padStart(2, '0')}</th
|
||||
>{/each}</tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each weekdays as day, weekday}<tr
|
||||
><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell =
|
||||
cellAt(weekday, hour)}
|
||||
<td
|
||||
><button
|
||||
type="button"
|
||||
class="cell"
|
||||
class:known={cell.observed > 0}
|
||||
class:unknown={cell.observed === 0 && cell.unknown > 0}
|
||||
class:mixed={cell.observed > 0 && cell.unknown > 0}
|
||||
class:missing={cell.observed + cell.unknown === 0}
|
||||
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, (100 * cell.active) / cell.observed)) : 0}%`}
|
||||
data-key={weekday * 24 + hour}
|
||||
tabindex={selected?.weekday === weekday &&
|
||||
selected?.hour === hour
|
||||
? 0
|
||||
: -1}
|
||||
aria-pressed={selected?.weekday === weekday &&
|
||||
selected?.hour === hour}
|
||||
aria-label={description(cell)}
|
||||
title={description(cell)}
|
||||
onclick={() => select(weekday, hour)}
|
||||
onkeydown={(event) => move(event, cell)}
|
||||
><span aria-hidden="true"
|
||||
>{cell.observed > 0
|
||||
? ''
|
||||
: cell.unknown > 0
|
||||
? '?'
|
||||
: '·'}</span
|
||||
></button
|
||||
></td
|
||||
>
|
||||
{/each}</tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
<p class="note">
|
||||
A striped corner marks additional unknown time. Color uses known time only. Repeated
|
||||
daylight-saving clock hours share a cell; collection gaps stay uncaptured.
|
||||
</p>
|
||||
{#if selected}
|
||||
<div class="selectors"><Field label="Inspect weekday"><Select value={String(selected.weekday)} options={dayOptions} onchange={event => select(Number((event.currentTarget as HTMLSelectElement).value), selected.hour)} /></Field><Field label="Inspect hour"><Select value={String(selected.hour)} options={hourOptions} onchange={event => select(selected.weekday, Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
|
||||
<div class="readout"><strong>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll('_', ' ')}</strong>
|
||||
{#if selected.observed + selected.unknown === 0}<p>No captured time for this weekday and hour. This does not mean the fleet was idle.</p>
|
||||
<div class="selectors">
|
||||
<Field label="Inspect weekday"
|
||||
><Select
|
||||
value={String(selected.weekday)}
|
||||
options={dayOptions}
|
||||
onchange={(event) =>
|
||||
select(
|
||||
Number((event.currentTarget as HTMLSelectElement).value),
|
||||
selected.hour
|
||||
)}
|
||||
/></Field
|
||||
><Field label="Inspect hour"
|
||||
><Select
|
||||
value={String(selected.hour)}
|
||||
options={hourOptions}
|
||||
onchange={(event) =>
|
||||
select(
|
||||
selected.weekday,
|
||||
Number((event.currentTarget as HTMLSelectElement).value)
|
||||
)}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
<div class="readout">
|
||||
<strong
|
||||
>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)}</strong
|
||||
>
|
||||
{#if selected.observed + selected.unknown === 0}<p>
|
||||
No captured time for this weekday and hour. This does not mean the fleet was
|
||||
idle.
|
||||
</p>
|
||||
{:else}<dl>
|
||||
<div><dt>Time with players</dt><dd>{metricPercent(selected.active, selected.observed)}<small>{metricDuration(selected.active)} of {metricDuration(selected.observed)} known</small></dd></div>
|
||||
<div><dt>Unknown server time</dt><dd>{metricDuration(selected.unknown)}</dd></div>
|
||||
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
|
||||
<div><dt>Distinct recorded dates</dt><dd>{selected.distinctDates}</dd></div>
|
||||
</dl>{/if}
|
||||
<div>
|
||||
<dt>Time with players</dt>
|
||||
<dd>
|
||||
{metricPercent(selected.active, selected.observed)}<small
|
||||
>{metricDuration(selected.active)} of {metricDuration(
|
||||
selected.observed
|
||||
)} known</small
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unknown server time</dt>
|
||||
<dd>{metricDuration(selected.unknown)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Player-hours</dt>
|
||||
<dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Distinct recorded dates</dt>
|
||||
<dd>{selected.distinctDates}</dd>
|
||||
</div>
|
||||
</dl>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.heatmap { min-width: 0; padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
p { font-size: var(--text-xs); color: var(--text-secondary); margin: var(--space-2) 0 0; line-height: 1.6; }
|
||||
.zone { flex: 0 1 250px; min-width: 0; max-width: 100%; }
|
||||
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.legend span { display: flex; gap: 5px; align-items: center; }
|
||||
.scale { width: 64px; height: 9px; background: linear-gradient(to right, var(--surface-raised), var(--chart-active)); border-radius: 3px; }
|
||||
.unknown-example, .missing-example { width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-style: normal; }
|
||||
.unknown-example, .cell.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
|
||||
.missing-example { border: 1px dashed var(--border-control); }
|
||||
.table-wrap { position: relative; overflow-x: auto; max-width: 100%; padding: 3px; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
table { width: 100%; min-width: 750px; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
|
||||
th { color: var(--text-tertiary); font-weight: normal; font-size: var(--text-xs); text-align: center; padding: 0 0 3px; }
|
||||
th:first-child { width: 35px; position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; }
|
||||
td { padding: 0; }
|
||||
.cell { position: relative; appearance: none; display: block; width: 100%; height: 29px; border: 1px solid var(--border-subtle); border-radius: 3px; background: var(--surface-raised); color: var(--text-secondary); font: inherit; cursor: pointer; overflow: hidden; }
|
||||
.cell.known { background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised)); }
|
||||
.cell.missing { border-style: dashed; background: transparent; }
|
||||
.cell.mixed::after { content: ''; position: absolute; top: 0; right: 0; width: 7px; height: 7px; background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 2px, var(--chart-unknown-stripe) 2px 4px); }
|
||||
.cell[aria-pressed='true'] { outline: 1px solid var(--text-primary); outline-offset: 1px; }
|
||||
.cell:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 1px; }
|
||||
.note { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.selectors { display: grid; grid-template-columns: repeat(2, minmax(0, 190px)); gap: var(--space-3); margin-top: var(--space-4); }
|
||||
.readout { margin-top: var(--space-3); padding: var(--space-4); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.readout > strong { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-3) 0 0; }
|
||||
dt { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
dd { margin: 5px 0 0; font-size: var(--text-md); font-variant-numeric: tabular-nums; }
|
||||
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) { .heatmap { padding: var(--space-4); } .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.heatmap {
|
||||
min-width: 0;
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heading > div:first-child {
|
||||
flex: 1 1 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin: var(--space-2) 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.zone {
|
||||
flex: 0 1 250px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin: var(--space-4) 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.legend span {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.scale {
|
||||
width: 64px;
|
||||
height: 9px;
|
||||
background: linear-gradient(to right, var(--surface-raised), var(--chart-active));
|
||||
border-radius: 3px;
|
||||
}
|
||||
.unknown-example,
|
||||
.missing-example {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-style: normal;
|
||||
}
|
||||
.unknown-example,
|
||||
.cell.unknown {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--chart-unknown-base) 0 3px,
|
||||
var(--chart-unknown-stripe) 3px 5px
|
||||
);
|
||||
}
|
||||
.missing-example {
|
||||
border: 1px dashed var(--border-control);
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
padding: 3px;
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 750px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 3px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
th {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: normal;
|
||||
font-size: var(--text-xs);
|
||||
text-align: center;
|
||||
padding: 0 0 3px;
|
||||
}
|
||||
th:first-child {
|
||||
width: 35px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--surface);
|
||||
z-index: 2;
|
||||
text-align: left;
|
||||
}
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
.cell {
|
||||
position: relative;
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 29px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cell.known {
|
||||
background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised));
|
||||
}
|
||||
.cell.missing {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
}
|
||||
.cell.mixed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--chart-unknown-base) 0 2px,
|
||||
var(--chart-unknown-stripe) 2px 4px
|
||||
);
|
||||
}
|
||||
.cell[aria-pressed='true'] {
|
||||
outline: 1px solid var(--text-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.cell:focus-visible {
|
||||
outline: 2px solid var(--accent-primary-hover);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.selectors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 190px));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.readout {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.readout > strong {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
dt {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
dd {
|
||||
margin: 5px 0 0;
|
||||
font-size: var(--text-md);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.heatmap {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.zone {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.readout {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.selectors {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,36 +7,127 @@
|
||||
const prior = $derived(report.previous);
|
||||
const pressure = $derived(report.pooledPressure);
|
||||
const comparable = $derived(report.summary.observed > 0 && prior.summary.observed > 0);
|
||||
const occupancyDelta = $derived(comparable ? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
|
||||
const playerDelta = $derived(comparable && prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1) : comparable && report.summary.playerHours === 0 ? 0 : null);
|
||||
const previousLabel = $derived(`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`);
|
||||
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
|
||||
const occupancyDelta = $derived(
|
||||
comparable
|
||||
? 100 *
|
||||
(report.summary.active / report.summary.observed -
|
||||
prior.summary.active / prior.summary.observed)
|
||||
: null
|
||||
);
|
||||
const playerDelta = $derived(
|
||||
comparable && prior.summary.playerHours > 0
|
||||
? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
|
||||
: comparable && report.summary.playerHours === 0
|
||||
? 0
|
||||
: null
|
||||
);
|
||||
const previousLabel = $derived(
|
||||
`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`
|
||||
);
|
||||
function signed(value: number, unit: string) {
|
||||
return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="summary">
|
||||
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
|
||||
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}</p>
|
||||
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed server time</small>
|
||||
<Card
|
||||
><Stat
|
||||
label="Time with players"
|
||||
value={metricPercent(report.summary.active, report.summary.observed)}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{occupancyDelta === null
|
||||
? 'Comparison unavailable'
|
||||
: `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}
|
||||
</p>
|
||||
<small
|
||||
>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed
|
||||
server time</small
|
||||
>
|
||||
</Card>
|
||||
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
|
||||
<p>{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}</p>
|
||||
<details><summary>What are player-hours?</summary><p>Players × observed time. One player for one hour equals one player-hour.</p></details>
|
||||
<Card
|
||||
><Stat
|
||||
label="Player-hours"
|
||||
value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{playerDelta === null
|
||||
? comparable && report.summary.playerHours > 0
|
||||
? 'New · no previous player-hours'
|
||||
: 'Comparison unavailable'
|
||||
: `${signed(playerDelta, '%')} vs ${previousLabel}`}
|
||||
</p>
|
||||
<details>
|
||||
<summary>What are player-hours?</summary>
|
||||
<p>Players × observed time. One player for one hour equals one player-hour.</p>
|
||||
</details>
|
||||
</Card>
|
||||
<Card><Stat label="Time all lobbies were full" value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)} size="lg" tween={false} />
|
||||
<p>{pressure.knownMapHours > 0 ? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` : 'Capacity data unavailable'}</p>
|
||||
<Card
|
||||
><Stat
|
||||
label="Time all lobbies were full"
|
||||
value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)}
|
||||
size="lg"
|
||||
tween={false}
|
||||
/>
|
||||
<p>
|
||||
{pressure.knownMapHours > 0
|
||||
? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data`
|
||||
: 'Capacity data unavailable'}
|
||||
</p>
|
||||
<small>Combined map history · each map measured separately</small>
|
||||
</Card>
|
||||
</div>
|
||||
{#if prior.partialHistory}<p class="notice">Partial previous history. Comparisons use available observations without extrapolation.</p>{/if}
|
||||
{#if report.maps.some(map => map.capacityObserved <= 0)}<p class="notice">{report.maps.filter(map => map.capacityObserved <= 0).length} of {report.maps.length} maps lack capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.</p>{/if}
|
||||
{#if report.summary.unknown > 0}<p class="quality">{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time with players.” Collection gaps are separate.</p>{/if}
|
||||
{#if prior.partialHistory}<p class="notice">
|
||||
Partial previous history. Comparisons use available observations without extrapolation.
|
||||
</p>{/if}
|
||||
{#if report.maps.some((map) => map.capacityObserved <= 0)}<p class="notice">
|
||||
{report.maps.filter((map) => map.capacityObserved <= 0).length} of {report.maps.length} maps lack
|
||||
capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.
|
||||
</p>{/if}
|
||||
{#if report.summary.unknown > 0}<p class="quality">
|
||||
{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time
|
||||
with players.” Collection gaps are separate.
|
||||
</p>{/if}
|
||||
|
||||
<style>
|
||||
.summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
p { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
small, details { display: block; margin-top: 8px; font-size: var(--text-xs); color: var(--text-secondary); line-height: 1.5; }
|
||||
summary { cursor: pointer; }
|
||||
.notice { margin: 0; padding-left: 12px; border-left: 2px solid var(--state-warning); }
|
||||
.quality { margin: 0; font-size: var(--text-xs); }
|
||||
@media (max-width: 760px) { .summary { grid-template-columns: 1fr; gap: 8px; } }
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
small,
|
||||
details {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--state-warning);
|
||||
}
|
||||
.quality {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.summary {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
|
||||
import { metricPage, compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import {
|
||||
metricPage,
|
||||
compareMetricValues,
|
||||
metricDuration,
|
||||
metricNumber,
|
||||
metricPercent
|
||||
} from '$ui/metrics.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
@@ -10,45 +16,97 @@
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
let { maps, servers, view = 'maps' }: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
||||
let {
|
||||
maps,
|
||||
servers,
|
||||
view = 'maps'
|
||||
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
||||
const mapColumns = [
|
||||
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'map', label: 'Map' },
|
||||
{ key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' },
|
||||
{ key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
const serverColumns = [
|
||||
{ key: 'server', label: 'Instance' }, { key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'observed', label: 'Observed time' }, { key: 'unknown', label: 'Unknown time' }, { key: 'details', label: 'Details' }
|
||||
{ key: 'server', label: 'Instance' },
|
||||
{ key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'observed', label: 'Observed time' },
|
||||
{ key: 'unknown', label: 'Unknown time' },
|
||||
{ key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
type MapSort = typeof mapColumns[number]['key'];
|
||||
type ServerSort = typeof serverColumns[number]['key'];
|
||||
type MapSort = (typeof mapColumns)[number]['key'];
|
||||
type ServerSort = (typeof serverColumns)[number]['key'];
|
||||
type Direction = 'asc' | 'desc';
|
||||
// Shallow navigation changes page.state, not page.url. Store the working
|
||||
// search string there so filters react immediately and survive Back/Forward.
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
|
||||
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
|
||||
const mapSort = $derived(mapColumns.find(column => column.key !== 'details' && column.key === params.get('mapSort'))?.key ?? 'playerHours');
|
||||
const serverSort = $derived(serverColumns.find(column => column.key !== 'details' && column.key === params.get('serverSort'))?.key ?? 'utilization');
|
||||
const mapSort = $derived(
|
||||
mapColumns.find(
|
||||
(column) => column.key !== 'details' && column.key === params.get('mapSort')
|
||||
)?.key ?? 'playerHours'
|
||||
);
|
||||
const serverSort = $derived(
|
||||
serverColumns.find(
|
||||
(column) => column.key !== 'details' && column.key === params.get('serverSort')
|
||||
)?.key ?? 'utilization'
|
||||
);
|
||||
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
|
||||
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
|
||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
||||
}));
|
||||
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
}));
|
||||
const serverDirection = $derived<Direction>(
|
||||
params.get('serverDirection') === 'asc' ? 'asc' : 'desc'
|
||||
);
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map((map) => map.playerHours)));
|
||||
const mapRows = $derived(
|
||||
maps
|
||||
.filter((map) =>
|
||||
`${map.game} ${gameName(map.game)} ${map.map}`
|
||||
.toLowerCase()
|
||||
.includes(mapQuery.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const result =
|
||||
mapSort === 'map'
|
||||
? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(
|
||||
mapValue(a, mapSort),
|
||||
mapValue(b, mapSort),
|
||||
mapDirection
|
||||
);
|
||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
||||
})
|
||||
);
|
||||
const serverRows = $derived(
|
||||
servers
|
||||
.filter((server) =>
|
||||
`${server.server} ${serverTitle(server.server)}`
|
||||
.toLowerCase()
|
||||
.includes(serverQuery.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const result =
|
||||
serverSort === 'server'
|
||||
? serverTitle(a.server).localeCompare(serverTitle(b.server)) *
|
||||
(serverDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(
|
||||
serverValue(a, serverSort),
|
||||
serverValue(b, serverSort),
|
||||
serverDirection
|
||||
);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
})
|
||||
);
|
||||
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
|
||||
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
|
||||
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
|
||||
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
|
||||
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
|
||||
function gameName(game: string) {
|
||||
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
}
|
||||
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
|
||||
if (key === 'details') return null;
|
||||
if (key === 'full') return map.capacityObserved ? map.full / map.capacityObserved : null;
|
||||
@@ -56,87 +114,388 @@
|
||||
}
|
||||
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
|
||||
if (key === 'details') return null;
|
||||
if (key === 'utilization') return server.observed > 0 ? server.active / server.observed : null;
|
||||
if (key === 'utilization')
|
||||
return server.observed > 0 ? server.active / server.observed : null;
|
||||
return server[key];
|
||||
}
|
||||
function updateContext(changes: Record<string, string>) {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
|
||||
if ('serverQ' in changes || 'serverSort' in changes) url.searchParams.delete('serverPage');
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
|
||||
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
|
||||
function sortMap(key: MapSort) {
|
||||
updateContext({
|
||||
mapSort: key,
|
||||
mapDirection:
|
||||
key === mapSort
|
||||
? mapDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: key === 'map'
|
||||
? 'asc'
|
||||
: 'desc'
|
||||
});
|
||||
}
|
||||
function sortServer(key: ServerSort) {
|
||||
updateContext({
|
||||
serverSort: key,
|
||||
serverDirection:
|
||||
key === serverSort
|
||||
? serverDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: key === 'server'
|
||||
? 'asc'
|
||||
: 'desc'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
|
||||
<div class="heading">
|
||||
<h2>{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}</h2>
|
||||
<div class="search"><Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}><Input type="search" value={view === 'maps' ? mapQuery : serverQuery} placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'} oninput={event => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
<div class="search">
|
||||
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
|
||||
><Input
|
||||
type="search"
|
||||
value={view === 'maps' ? mapQuery : serverQuery}
|
||||
placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'}
|
||||
oninput={(event) =>
|
||||
updateContext({
|
||||
[view === 'maps' ? 'mapQ' : 'serverQ']: (
|
||||
event.currentTarget as HTMLInputElement
|
||||
).value
|
||||
})}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if currentPage.total}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'} tabindex="0"><table>
|
||||
<thead><tr>{#each view === 'maps' ? mapColumns : serverColumns as column}
|
||||
<th scope="col" aria-sort={column.key === 'details' ? undefined : (view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? 'ascending' : 'descending' : undefined}>
|
||||
{#if column.key === 'details'}Details{:else}<button type="button" onclick={() => view === 'maps' ? sortMap(column.key as MapSort) : sortServer(column.key as ServerSort)}>{column.label}<span aria-hidden="true">{(view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? '↑' : '↓' : '↕'}</span></button>{/if}
|
||||
</th>
|
||||
{/each}</tr></thead>
|
||||
<tbody>
|
||||
{#if view === 'maps'}
|
||||
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row">{map.map}<small>{gameName(map.game)}{map.observed < 1 || map.capacityObserved < 1 || map.unknown > map.observed / 4 ? ' · Limited evidence' : ''}</small></th>
|
||||
<td class="player-hours">{map.observed ? metricNumber(map.playerHours) : '—'}<div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
|
||||
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
|
||||
<td>{metricPercent(map.full, map.capacityObserved)}</td>
|
||||
<td><details><summary><span class="sr-only">{map.map} </span>Details</summary><dl>
|
||||
<dt>Observed instance time</dt><dd>{metricDuration(map.observed)}</dd><dt>Unknown status</dt><dd>{metricDuration(map.unknown)}</dd><dt>All lobbies full</dt><dd>{map.capacityObserved ? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time` : 'Capacity data unavailable'}</dd>
|
||||
</dl></details></td>
|
||||
</tr>{/each}
|
||||
{:else}
|
||||
{#each serverPage.rows as server (server.server)}<tr>
|
||||
<th scope="row">{#if server.currentRegistered}<a href={contextHref(`/servers/${encodeURIComponent(server.server)}`, `/metrics${contextSearch}`)} title="Open current instance with this name">{serverTitle(server.server)}</a>{:else}{serverTitle(server.server)}<small>Historical</small>{/if}</th>
|
||||
<td>{metricPercent(server.active, server.observed)}</td><td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
|
||||
<td><details><summary><span class="sr-only">{serverTitle(server.server)} </span>Details</summary><dl><dt>Recorded name</dt><dd><code>{server.server}</code></dd><dt>Time with players</dt><dd>{metricDuration(server.active)}</dd><dt>Idle</dt><dd>{metricPercent(server.idle, server.observed)} · {metricDuration(server.idle)}</dd></dl></details></td>
|
||||
</tr>{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}>{#snippet actions()}<Button size="sm" onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}>Clear search</Button>{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{currentPage.start}–{currentPage.end} of {currentPage.total} {view === 'maps' ? 'maps' : 'instances'}</span>
|
||||
{#if currentPage.pages > 1}<div class="paging"><Button size="sm" disabled={currentPage.page === 1} onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}>Previous</Button><span>Page {currentPage.page} of {currentPage.pages}</span><Button size="sm" disabled={currentPage.page === currentPage.pages} onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}>Next</Button></div>{/if}
|
||||
<div
|
||||
class="table-wrap"
|
||||
role="region"
|
||||
aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'}
|
||||
tabindex="0"
|
||||
>
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
>{#each view === 'maps' ? mapColumns : serverColumns as column}
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={column.key === 'details'
|
||||
? undefined
|
||||
: (view === 'maps' ? mapSort : serverSort) === column.key
|
||||
? (view === 'maps' ? mapDirection : serverDirection) === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined}
|
||||
>
|
||||
{#if column.key === 'details'}Details{:else}<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
view === 'maps'
|
||||
? sortMap(column.key as MapSort)
|
||||
: sortServer(column.key as ServerSort)}
|
||||
>{column.label}<span aria-hidden="true"
|
||||
>{(view === 'maps' ? mapSort : serverSort) ===
|
||||
column.key
|
||||
? (view === 'maps'
|
||||
? mapDirection
|
||||
: serverDirection) === 'asc'
|
||||
? '↑'
|
||||
: '↓'
|
||||
: '↕'}</span
|
||||
></button
|
||||
>{/if}
|
||||
</th>
|
||||
{/each}</tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#if view === 'maps'}
|
||||
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row"
|
||||
>{map.map}<small
|
||||
>{gameName(map.game)}{map.observed < 1 ||
|
||||
map.capacityObserved < 1 ||
|
||||
map.unknown > map.observed / 4
|
||||
? ' · Limited evidence'
|
||||
: ''}</small
|
||||
></th
|
||||
>
|
||||
<td class="player-hours"
|
||||
>{map.observed ? metricNumber(map.playerHours) : '—'}
|
||||
<div class="comparison" aria-hidden="true">
|
||||
<i
|
||||
style:width={`${maxPlayerHours ? (map.playerHours / maxPlayerHours) * 100 : 0}%`}
|
||||
></i>
|
||||
</div></td
|
||||
>
|
||||
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
|
||||
<td>{metricPercent(map.full, map.capacityObserved)}</td>
|
||||
<td
|
||||
><details>
|
||||
<summary
|
||||
><span class="sr-only">{map.map} </span>Details</summary
|
||||
>
|
||||
<dl>
|
||||
<dt>Observed instance time</dt>
|
||||
<dd>{metricDuration(map.observed)}</dd>
|
||||
<dt>Unknown status</dt>
|
||||
<dd>{metricDuration(map.unknown)}</dd>
|
||||
<dt>All lobbies full</dt>
|
||||
<dd>
|
||||
{map.capacityObserved
|
||||
? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time`
|
||||
: 'Capacity data unavailable'}
|
||||
</dd>
|
||||
</dl>
|
||||
</details></td
|
||||
>
|
||||
</tr>{/each}
|
||||
{:else}
|
||||
{#each serverPage.rows as server (server.server)}<tr>
|
||||
<th scope="row"
|
||||
>{#if server.currentRegistered}<a
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(server.server)}`,
|
||||
`/metrics${contextSearch}`
|
||||
)}
|
||||
title="Open current instance with this name"
|
||||
>{serverTitle(server.server)}</a
|
||||
>{:else}{serverTitle(server.server)}<small>Historical</small
|
||||
>{/if}</th
|
||||
>
|
||||
<td>{metricPercent(server.active, server.observed)}</td><td
|
||||
>{metricDuration(server.observed)}</td
|
||||
><td>{metricDuration(server.unknown)}</td>
|
||||
<td
|
||||
><details>
|
||||
<summary
|
||||
><span class="sr-only"
|
||||
>{serverTitle(server.server)}
|
||||
</span>Details</summary
|
||||
>
|
||||
<dl>
|
||||
<dt>Recorded name</dt>
|
||||
<dd><code>{server.server}</code></dd>
|
||||
<dt>Time with players</dt>
|
||||
<dd>{metricDuration(server.active)}</dd>
|
||||
<dt>Idle</dt>
|
||||
<dd>
|
||||
{metricPercent(server.idle, server.observed)} · {metricDuration(
|
||||
server.idle
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</details></td
|
||||
>
|
||||
</tr>{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}<EmptyState
|
||||
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
|
||||
>{#snippet actions()}<Button
|
||||
size="sm"
|
||||
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
|
||||
>Clear search</Button
|
||||
>{/snippet}</EmptyState
|
||||
>{/if}
|
||||
<div class="footer">
|
||||
<span
|
||||
>{currentPage.start}–{currentPage.end} of {currentPage.total}
|
||||
{view === 'maps' ? 'maps' : 'instances'}</span
|
||||
>
|
||||
{#if currentPage.pages > 1}<div class="paging">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={currentPage.page === 1}
|
||||
onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}
|
||||
>Previous</Button
|
||||
><span>Page {currentPage.page} of {currentPage.pages}</span><Button
|
||||
size="sm"
|
||||
disabled={currentPage.page === currentPage.pages}
|
||||
onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}
|
||||
>Next</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</div>
|
||||
{#if view === 'instances'}<p class="identity-note">Grouped by recorded name; links open the current instance, which may differ from the historical registration.</p>{/if}
|
||||
<details class="table-help"><summary>About these metrics</summary><p>{view === 'maps' ? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.' : 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}</p></details>
|
||||
{#if view === 'instances'}<p class="identity-note">
|
||||
Grouped by recorded name; links open the current instance, which may differ from the
|
||||
historical registration.
|
||||
</p>{/if}
|
||||
<details class="table-help">
|
||||
<summary>About these metrics</summary>
|
||||
<p>
|
||||
{view === 'maps'
|
||||
? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
|
||||
: 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
|
||||
.heading { padding: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 0; }
|
||||
.search { flex: 0 1 240px; }
|
||||
.table-wrap { position: relative; width: 100%; overflow-x: auto; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; vertical-align: top; }
|
||||
th:first-child { text-align: left; min-width: 160px; }
|
||||
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); white-space: nowrap; }
|
||||
thead button { display: inline-flex; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; white-space: nowrap; }
|
||||
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
tbody th { font-weight: var(--weight-medium); }
|
||||
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
|
||||
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; }
|
||||
.footer, .paging { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.footer { padding: 16px 20px; }
|
||||
details { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
summary { cursor: pointer; }
|
||||
dl { text-align: left; min-width: 170px; max-width: 300px; }
|
||||
dt { margin-top: 10px; } dd { margin: 4px 0; color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.identity-note { margin: 0; padding: 0 20px 12px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.table-help { padding: 0 20px 16px; } .table-help p { max-width: 90ch; line-height: 1.6; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 640px) { th, td { padding: 10px; } .search { flex: 1 1 100%; } }
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.heading {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
margin: 0;
|
||||
}
|
||||
.search {
|
||||
flex: 0 1 240px;
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
vertical-align: top;
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
min-width: 160px;
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-sunken);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
thead button {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
padding: 5px 0;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
thead button:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.comparison {
|
||||
height: 3px;
|
||||
width: 90px;
|
||||
margin: 8px 0 0 auto;
|
||||
background: var(--surface-raised);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.comparison i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--chart-active);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.footer,
|
||||
.paging {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
details {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
dl {
|
||||
text-align: left;
|
||||
min-width: 170px;
|
||||
max-width: 300px;
|
||||
}
|
||||
dt {
|
||||
margin-top: 10px;
|
||||
}
|
||||
dd {
|
||||
margin: 4px 0;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identity-note {
|
||||
margin: 0;
|
||||
padding: 0 20px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.table-help {
|
||||
padding: 0 20px 16px;
|
||||
}
|
||||
.table-help p {
|
||||
max-width: 90ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user