Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -3,7 +3,11 @@
|
||||
import type { CapacityHistory, CapacityHistoryPoint } from '$lib/capacity.js';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
|
||||
let { history, scope, controls }: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
|
||||
let {
|
||||
history,
|
||||
scope,
|
||||
controls
|
||||
}: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
|
||||
let metric = $state<'cpu' | 'memory'>('cpu');
|
||||
let selectedAt = $state<number | null>(null);
|
||||
let tableOpen = $state(false);
|
||||
@@ -13,17 +17,22 @@
|
||||
if (selectedAt !== null && points.length) {
|
||||
let closest = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
if (Math.abs(points[index].at - selectedAt) < Math.abs(points[closest].at - selectedAt)) closest = index;
|
||||
if (
|
||||
Math.abs(points[index].at - selectedAt) <
|
||||
Math.abs(points[closest].at - selectedAt)
|
||||
)
|
||||
closest = index;
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
for (let index = points.length - 1; index >= 0; index--) if (value(points[index]) !== null) return index;
|
||||
for (let index = points.length - 1; index >= 0; index--)
|
||||
if (value(points[index]) !== null) return index;
|
||||
return Math.max(0, points.length - 1);
|
||||
});
|
||||
const selected = $derived(points[selectedIndex]);
|
||||
const measured = $derived(points.filter(point => value(point) !== null));
|
||||
const measured = $derived(points.filter((point) => value(point) !== null));
|
||||
const ceiling = $derived.by(() => {
|
||||
const max = Math.max(1, ...measured.map(point => value(point)!));
|
||||
const max = Math.max(1, ...measured.map((point) => value(point)!));
|
||||
const step = 10 ** Math.floor(Math.log10(max)) / 2;
|
||||
return Math.ceil(max / step) * step;
|
||||
});
|
||||
@@ -32,127 +41,508 @@
|
||||
let segment: { x: number; y: number }[] = [];
|
||||
for (const point of points) {
|
||||
const amount = value(point);
|
||||
if (amount === null) { if (segment.length) result.push(segment); segment = []; }
|
||||
else segment.push({ x: x(point.at), y: y(amount) });
|
||||
if (amount === null) {
|
||||
if (segment.length) result.push(segment);
|
||||
segment = [];
|
||||
} else segment.push({ x: x(point.at), y: y(amount) });
|
||||
}
|
||||
if (segment.length) result.push(segment);
|
||||
return result;
|
||||
});
|
||||
const ticks = [1, .75, .5, .25, 0];
|
||||
const ticks = [1, 0.75, 0.5, 0.25, 0];
|
||||
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||
function value(point: CapacityHistoryPoint): number | null {
|
||||
return metric === 'cpu' ? point.cpuCoresAvailable : point.memoryBytesAvailable === null ? null : point.memoryBytesAvailable / 1024 ** 3;
|
||||
return metric === 'cpu'
|
||||
? point.cpuCoresAvailable
|
||||
: point.memoryBytesAvailable === null
|
||||
? null
|
||||
: point.memoryBytesAvailable / 1024 ** 3;
|
||||
}
|
||||
function x(at: number) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1000,
|
||||
((at - history.startAt) / Math.max(1, history.endAt - history.startAt)) * 1000
|
||||
)
|
||||
);
|
||||
}
|
||||
function y(amount: number) {
|
||||
return 230 - Math.max(0, Math.min(1, amount / ceiling)) * 220;
|
||||
}
|
||||
function amount(value: number | null, unit: string) {
|
||||
return value === null ? 'No complete report' : `${numbers.format(value)} ${unit}`;
|
||||
}
|
||||
function x(at: number) { return Math.max(0, Math.min(1000, (at - history.startAt) / Math.max(1, history.endAt - history.startAt) * 1000)); }
|
||||
function y(amount: number) { return 230 - Math.max(0, Math.min(1, amount / ceiling)) * 220; }
|
||||
function amount(value: number | null, unit: string) { return value === null ? 'No complete report' : `${numbers.format(value)} ${unit}`; }
|
||||
function describe(point: CapacityHistoryPoint) {
|
||||
return `${$timeFormat.period(point.at)}. ${amount(value(point), metric === 'cpu' ? 'cores available' : 'GiB available')}. ${point.reportingHosts} of ${point.expectedHosts} hosts reporting.`;
|
||||
}
|
||||
function inspect(event: PointerEvent) {
|
||||
if (!points.length || (event.type === 'pointermove' && event.pointerType === 'touch')) return;
|
||||
if (!points.length || (event.type === 'pointermove' && event.pointerType === 'touch'))
|
||||
return;
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const at = history.startAt + Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * (history.endAt - history.startAt);
|
||||
const at =
|
||||
history.startAt +
|
||||
Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) *
|
||||
(history.endAt - history.startAt);
|
||||
let closest = points[0];
|
||||
for (const point of points) if (Math.abs(point.at - at) < Math.abs(closest.at - at)) closest = point;
|
||||
for (const point of points)
|
||||
if (Math.abs(point.at - at) < Math.abs(closest.at - at)) closest = point;
|
||||
selectedAt = closest.at;
|
||||
}
|
||||
function move(event: KeyboardEvent) {
|
||||
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
|
||||
return;
|
||||
event.preventDefault();
|
||||
const index = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1
|
||||
: Math.max(0, Math.min(points.length - 1, selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)));
|
||||
const index =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? points.length - 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
points.length - 1,
|
||||
selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)
|
||||
)
|
||||
);
|
||||
selectedAt = points[index].at;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="capacity-history-title">
|
||||
<div class="heading">
|
||||
<div><h2 id="capacity-history-title">Available resources over time</h2><p>{scope} · All workloads</p></div>
|
||||
<div class="controls">{@render controls?.()}<div class="mode" role="group" aria-label="Chart resource">
|
||||
<button type="button" aria-pressed={metric === 'cpu'} onclick={() => metric = 'cpu'}>CPU</button>
|
||||
<button type="button" aria-pressed={metric === 'memory'} onclick={() => metric = 'memory'}>Memory</button>
|
||||
</div></div>
|
||||
<div>
|
||||
<h2 id="capacity-history-title">Available resources over time</h2>
|
||||
<p>{scope} · All workloads</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
{@render controls?.()}
|
||||
<div class="mode" role="group" aria-label="Chart resource">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={metric === 'cpu'}
|
||||
onclick={() => (metric = 'cpu')}>CPU</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={metric === 'memory'}
|
||||
onclick={() => (metric = 'memory')}>Memory</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="scope-note">The game filter applies to current launch estimates only.</p>
|
||||
<div class="axis-title">{label} available</div>
|
||||
<div class="chart-grid">
|
||||
<div class="y-axis" aria-hidden="true">{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}>{numbers.format(ceiling * tick)}</span>{/each}</div>
|
||||
<div class="plot" role="slider" tabindex="0" aria-label={`${label} history. Use left and right arrows to inspect a period.`}
|
||||
aria-valuemin="0" aria-valuemax={Math.max(0, points.length - 1)} aria-valuenow={selectedIndex}
|
||||
<div class="y-axis" aria-hidden="true">
|
||||
{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}
|
||||
>{numbers.format(ceiling * tick)}</span
|
||||
>{/each}
|
||||
</div>
|
||||
<div
|
||||
class="plot"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label={`${label} history. Use left and right arrows to inspect a period.`}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={Math.max(0, points.length - 1)}
|
||||
aria-valuenow={selectedIndex}
|
||||
aria-valuetext={selected ? describe(selected) : 'No history available yet'}
|
||||
onpointermove={inspect} onpointerdown={inspect} onkeydown={move}>
|
||||
onpointermove={inspect}
|
||||
onpointerdown={inspect}
|
||||
onkeydown={move}
|
||||
>
|
||||
<svg viewBox="0 0 1000 240" preserveAspectRatio="none" aria-hidden="true">
|
||||
{#each ticks as tick}<line class="grid-line" x1="0" x2="1000" y1={y(ceiling * tick)} y2={y(ceiling * tick)} />{/each}
|
||||
{#each ticks as tick}<line
|
||||
class="grid-line"
|
||||
x1="0"
|
||||
x2="1000"
|
||||
y1={y(ceiling * tick)}
|
||||
y2={y(ceiling * tick)}
|
||||
/>{/each}
|
||||
{#each segments as segment}
|
||||
{#if segment.length > 1}<path class="area" d={`M${segment[0].x},230 ${segment.map(point => `L${point.x},${point.y}`).join(' ')} L${segment[segment.length - 1].x},230 Z`} />{/if}
|
||||
<path class="trend" d={segment.map((point, index) => `${index ? 'L' : 'M'}${point.x},${point.y}`).join(' ')} />
|
||||
{#if segment.length === 1}<circle class="point" cx={segment[0].x} cy={segment[0].y} r="3" />{/if}
|
||||
{#if segment.length > 1}<path
|
||||
class="area"
|
||||
d={`M${segment[0].x},230 ${segment.map((point) => `L${point.x},${point.y}`).join(' ')} L${segment[segment.length - 1].x},230 Z`}
|
||||
/>{/if}
|
||||
<path
|
||||
class="trend"
|
||||
d={segment
|
||||
.map((point, index) => `${index ? 'L' : 'M'}${point.x},${point.y}`)
|
||||
.join(' ')}
|
||||
/>
|
||||
{#if segment.length === 1}<circle
|
||||
class="point"
|
||||
cx={segment[0].x}
|
||||
cy={segment[0].y}
|
||||
r="3"
|
||||
/>{/if}
|
||||
{/each}
|
||||
{#if selected}<line class="cursor" x1={x(selected.at)} x2={x(selected.at)} y1="5" y2="230" />
|
||||
{#if value(selected) !== null}<circle class="selected-point" cx={x(selected.at)} cy={y(value(selected)!)} r="4" />{/if}
|
||||
{#if selected}<line
|
||||
class="cursor"
|
||||
x1={x(selected.at)}
|
||||
x2={x(selected.at)}
|
||||
y1="5"
|
||||
y2="230"
|
||||
/>
|
||||
{#if value(selected) !== null}<circle
|
||||
class="selected-point"
|
||||
cx={x(selected.at)}
|
||||
cy={y(value(selected)!)}
|
||||
r="4"
|
||||
/>{/if}
|
||||
{/if}
|
||||
</svg>
|
||||
{#if !measured.length}<div class="empty-chart"><strong>Waiting for capacity history</strong><span>Fresh host reports build this timeline automatically.</span></div>{/if}
|
||||
{#if !measured.length}<div class="empty-chart">
|
||||
<strong>Waiting for capacity history</strong><span
|
||||
>Fresh host reports build this timeline automatically.</span
|
||||
>
|
||||
</div>{/if}
|
||||
</div>
|
||||
<div class="x-axis" aria-hidden="true">
|
||||
<span>{$timeFormat.period(history.startAt)}</span><span
|
||||
>{$timeFormat.period(history.endAt)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="x-axis" aria-hidden="true"><span>{$timeFormat.period(history.startAt)}</span><span>{$timeFormat.period(history.endAt)}</span></div>
|
||||
</div>
|
||||
{#if selected}
|
||||
<div class="readout" aria-label="Selected history period">
|
||||
<div class="selected-time"><strong>{$timeFormat.period(selected.at)}</strong></div>
|
||||
<dl><div><dt>CPU available</dt><dd>{amount(selected.cpuCoresAvailable, 'cores')}</dd></div>
|
||||
<div><dt>Memory available</dt><dd>{amount(selected.memoryBytesAvailable === null ? null : selected.memoryBytesAvailable / 1024 ** 3, 'GiB')}</dd></div>
|
||||
<div><dt>Reporting hosts</dt><dd>{selected.reportingHosts} / {selected.expectedHosts}</dd></div></dl>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>CPU available</dt>
|
||||
<dd>{amount(selected.cpuCoresAvailable, 'cores')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Memory available</dt>
|
||||
<dd>
|
||||
{amount(
|
||||
selected.memoryBytesAvailable === null
|
||||
? null
|
||||
: selected.memoryBytesAvailable / 1024 ** 3,
|
||||
'GiB'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reporting hosts</dt>
|
||||
<dd>{selected.reportingHosts} / {selected.expectedHosts}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
<details class="about"><summary>About this chart</summary>
|
||||
<p class="note">Available host resources after the operating-system reserve, averaged per period. Gaps mean reporting is incomplete. Reporting-host coverage is shown for each selected period. This history includes all workloads on the selected hosts; it does not reconstruct historical launch eligibility, queued jobs, or port availability.</p>
|
||||
<details bind:open={tableOpen}><summary>View history data · {$timeZone.replaceAll('_', ' ')}</summary>
|
||||
{#if tableOpen}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Capacity history data" tabindex="0"><table>
|
||||
<caption>Measured headroom; a dash means a complete measurement is unavailable.</caption>
|
||||
<thead><tr><th scope="col">Period</th><th scope="col">CPU cores</th><th scope="col">Memory (GiB)</th><th scope="col">Reporting hosts</th></tr></thead>
|
||||
<tbody>{#each points as point (point.at)}<tr><th scope="row">{$timeFormat.period(point.at)}</th><td>{point.cpuCoresAvailable === null ? '—' : numbers.format(point.cpuCoresAvailable)}</td><td>{point.memoryBytesAvailable === null ? '—' : numbers.format(point.memoryBytesAvailable / 1024 ** 3)}</td><td>{point.reportingHosts} / {point.expectedHosts}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
{/if}
|
||||
</details>
|
||||
<details class="about">
|
||||
<summary>About this chart</summary>
|
||||
<p class="note">
|
||||
Available host resources after the operating-system reserve, averaged per period. Gaps
|
||||
mean reporting is incomplete. Reporting-host coverage is shown for each selected period.
|
||||
This history includes all workloads on the selected hosts; it does not reconstruct
|
||||
historical launch eligibility, queued jobs, or port availability.
|
||||
</p>
|
||||
<details bind:open={tableOpen}>
|
||||
<summary>View history data · {$timeZone.replaceAll('_', ' ')}</summary>
|
||||
{#if tableOpen}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
|
||||
<div
|
||||
class="table-wrap"
|
||||
role="region"
|
||||
aria-label="Capacity history data"
|
||||
tabindex="0"
|
||||
>
|
||||
<table>
|
||||
<caption
|
||||
>Measured headroom; a dash means a complete measurement is unavailable.</caption
|
||||
>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Period</th><th scope="col">CPU cores</th><th
|
||||
scope="col">Memory (GiB)</th
|
||||
><th scope="col">Reporting hosts</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each points as point (point.at)}<tr
|
||||
><th scope="row">{$timeFormat.period(point.at)}</th><td
|
||||
>{point.cpuCoresAvailable === null
|
||||
? '—'
|
||||
: numbers.format(point.cpuCoresAvailable)}</td
|
||||
><td
|
||||
>{point.memoryBytesAvailable === null
|
||||
? '—'
|
||||
: numbers.format(
|
||||
point.memoryBytesAvailable / 1024 ** 3
|
||||
)}</td
|
||||
><td>{point.reportingHosts} / {point.expectedHosts}</td></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.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; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.heading > div:first-child { flex:1 1 240px; min-width:0; }
|
||||
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.controls { display:flex; align-items:center; flex-wrap:wrap; gap:12px; min-width:0; max-width:100%; }
|
||||
.scope-note { margin:8px 0 0; font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
.mode { display: flex; gap: 3px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.mode button { padding: 7px 14px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
|
||||
.mode button[aria-pressed='true'] { color: var(--text-primary); background: var(--surface-raised); }
|
||||
.mode button:focus-visible, .plot:focus-visible, .table-wrap:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.axis-title { margin-top: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.chart-grid { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
|
||||
.y-axis { position: relative; height: 176px; margin-top: 8px; color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
|
||||
.plot { position: relative; height: 192px; border-radius: 3px; cursor: crosshair; touch-action: pan-y; }
|
||||
svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
svg path, svg line { vector-effect: non-scaling-stroke; }
|
||||
.grid-line { stroke: var(--border-subtle); stroke-width: 1; } .area { fill: color-mix(in oklab, var(--accent-primary) 10%, transparent); }
|
||||
.trend { fill: none; stroke: var(--accent-primary); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.point, .selected-point { fill: var(--accent-primary-hover); } .selected-point { stroke: var(--surface); stroke-width: 2; }
|
||||
.cursor { stroke: var(--text-tertiary); stroke-width: 1; stroke-dasharray: 4 5; }
|
||||
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.x-axis span:last-child { text-align: right; }
|
||||
.empty-chart { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; text-align: center; padding: var(--space-4); pointer-events: none; }
|
||||
.empty-chart strong { font-size: var(--text-sm); font-weight: var(--weight-medium); } .empty-chart span { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.readout { display:flex; flex-wrap:wrap; gap:12px 24px; align-items:center; margin:16px 0; font-size:var(--text-xs); }
|
||||
.selected-time { color:var(--text-secondary); } .selected-time strong { font-size:var(--text-xs); font-weight:400; }
|
||||
dl { display:flex; flex-wrap:wrap; gap:12px 24px; margin:0; } dl>div { display:flex; gap:6px; } dt { color:var(--text-secondary); } dd { margin:0; font-variant-numeric:tabular-nums; }
|
||||
.note { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.65; margin: var(--space-4) 0; max-width: 115ch; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); padding: 4px 0; }
|
||||
.table-wrap { position: relative; overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
|
||||
caption { text-align: left; color: var(--text-tertiary); margin-bottom: var(--space-3); } th, td { text-align: right; padding: 12px; white-space: nowrap; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
|
||||
th:first-child { text-align: left; } tbody th { font-weight: var(--weight-regular); } thead th { color: var(--text-secondary); }
|
||||
@media (max-width: 640px) { .panel { padding: var(--space-4); } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 8px; } .readout { gap: var(--space-4); } dl { gap: var(--space-4); } .x-axis { font-size: var(--text-xs); } }
|
||||
.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;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.heading > div:first-child {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
.heading p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.scope-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.mode button {
|
||||
padding: 7px 14px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode button[aria-pressed='true'] {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.mode button:focus-visible,
|
||||
.plot:focus-visible,
|
||||
.table-wrap:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.axis-title {
|
||||
margin-top: var(--space-4);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.y-axis {
|
||||
position: relative;
|
||||
height: 176px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.y-axis span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.plot {
|
||||
position: relative;
|
||||
height: 192px;
|
||||
border-radius: 3px;
|
||||
cursor: crosshair;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
svg path,
|
||||
svg line {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.grid-line {
|
||||
stroke: var(--border-subtle);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.area {
|
||||
fill: color-mix(in oklab, var(--accent-primary) 10%, transparent);
|
||||
}
|
||||
.trend {
|
||||
fill: none;
|
||||
stroke: var(--accent-primary);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.point,
|
||||
.selected-point {
|
||||
fill: var(--accent-primary-hover);
|
||||
}
|
||||
.selected-point {
|
||||
stroke: var(--surface);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.cursor {
|
||||
stroke: var(--text-tertiary);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 5;
|
||||
}
|
||||
.x-axis {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.x-axis span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
.empty-chart {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
pointer-events: none;
|
||||
}
|
||||
.empty-chart strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.empty-chart span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.readout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.selected-time {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.selected-time strong {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
margin: 0;
|
||||
}
|
||||
dl > div {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.65;
|
||||
margin: var(--space-4) 0;
|
||||
max-width: 115ch;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
caption {
|
||||
text-align: left;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 12px;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.chart-grid {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.readout {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
dl {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.x-axis {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user