|
|
|
|
@ -0,0 +1,731 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
/**
|
|
|
|
|
* Visual A/B: the in-house `$ethereal` positioning engine vs `@floating-ui/dom`.
|
|
|
|
|
* Three demonstrations on the user's own machine:
|
|
|
|
|
* 1. Correctness — both engines position the SAME anchor; the boxes overlap
|
|
|
|
|
* and a live Δ readout proves the output is pixel-identical.
|
|
|
|
|
* 2. Speed — a live micro-benchmark (sync runMiddleware vs async
|
|
|
|
|
* computePosition) reports ops/s + speedup.
|
|
|
|
|
* 3. Fluidity — N animated anchors with following boxes + an FPS meter; flip
|
|
|
|
|
* the engine at high N to feel the difference (own batches its reads into
|
|
|
|
|
* one dom.measure; floating-ui reads synchronously per element).
|
|
|
|
|
*
|
|
|
|
|
* Raw DOM in the rAF loop (no Svelte reactivity in the hot path) so the FPS
|
|
|
|
|
* meter reflects the engines, not the framework.
|
|
|
|
|
*/
|
|
|
|
|
import { onDestroy, onMount, tick } from 'svelte';
|
|
|
|
|
import { createActiveDom } from '$adom';
|
|
|
|
|
import {
|
|
|
|
|
computePosition as ownCompute,
|
|
|
|
|
runMiddleware,
|
|
|
|
|
offset as oOffset,
|
|
|
|
|
shift as oShift,
|
|
|
|
|
flip as oFlip,
|
|
|
|
|
type Placement,
|
|
|
|
|
type Middleware,
|
|
|
|
|
type ReadSnapshot,
|
|
|
|
|
type Rect
|
|
|
|
|
} from '$ethereal';
|
|
|
|
|
import {
|
|
|
|
|
computePosition as fuiCompute,
|
|
|
|
|
offset as fOffset,
|
|
|
|
|
shift as fShift,
|
|
|
|
|
flip as fFlip
|
|
|
|
|
} from '@floating-ui/dom';
|
|
|
|
|
|
|
|
|
|
const dom = createActiveDom();
|
|
|
|
|
|
|
|
|
|
const PLACEMENTS: Placement[] = ['top', 'right', 'bottom', 'left'];
|
|
|
|
|
|
|
|
|
|
let count = $state(60);
|
|
|
|
|
let engine = $state<'own' | 'fui' | 'both'>('both');
|
|
|
|
|
let placement = $state<Placement>('bottom');
|
|
|
|
|
let animate = $state(true);
|
|
|
|
|
|
|
|
|
|
let fps = $state(0);
|
|
|
|
|
let msFrame = $state(0);
|
|
|
|
|
let deltaMax = $state(0);
|
|
|
|
|
|
|
|
|
|
let benching = $state(false);
|
|
|
|
|
let bench = $state<{ ownOps: number; fuiOps: number; speedup: number } | null>(null);
|
|
|
|
|
|
|
|
|
|
// Fixed chain for both engines (offset + shift + flip — the soma default).
|
|
|
|
|
const oc: Middleware[] = [oOffset({ mainAxis: 10 }), oShift({ padding: 8 }), oFlip()];
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
const fc: any[] = [fOffset({ mainAxis: 10 }), fShift({ padding: 8 }), fFlip()];
|
|
|
|
|
|
|
|
|
|
let area: HTMLDivElement;
|
|
|
|
|
type Item = {
|
|
|
|
|
anchor: HTMLElement;
|
|
|
|
|
ownBox: HTMLElement;
|
|
|
|
|
fuiBox: HTMLElement;
|
|
|
|
|
ax: number;
|
|
|
|
|
ay: number;
|
|
|
|
|
phase: number;
|
|
|
|
|
r: number;
|
|
|
|
|
speed: number;
|
|
|
|
|
};
|
|
|
|
|
let items: Item[] = [];
|
|
|
|
|
|
|
|
|
|
function mk(cls: string): HTMLDivElement {
|
|
|
|
|
const d = document.createElement('div');
|
|
|
|
|
d.className = cls;
|
|
|
|
|
area.appendChild(d);
|
|
|
|
|
return d;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rebuild() {
|
|
|
|
|
if (!area) return;
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
it.anchor.remove();
|
|
|
|
|
it.ownBox.remove();
|
|
|
|
|
it.fuiBox.remove();
|
|
|
|
|
}
|
|
|
|
|
items = [];
|
|
|
|
|
const w = area.clientWidth || 900;
|
|
|
|
|
const h = area.clientHeight || 460;
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
const anchor = mk('anchor');
|
|
|
|
|
const ownBox = mk('box box-own');
|
|
|
|
|
const fuiBox = mk('box box-fui');
|
|
|
|
|
ownBox.textContent = 'ethereal';
|
|
|
|
|
fuiBox.textContent = '@floating-ui';
|
|
|
|
|
items.push({
|
|
|
|
|
anchor,
|
|
|
|
|
ownBox,
|
|
|
|
|
fuiBox,
|
|
|
|
|
ax: 70 + Math.random() * (w - 140),
|
|
|
|
|
ay: 70 + Math.random() * (h - 140),
|
|
|
|
|
phase: Math.random() * Math.PI * 2,
|
|
|
|
|
r: 24 + Math.random() * 70,
|
|
|
|
|
speed: 0.4 + Math.random() * 1.1
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
applyVisibility();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyVisibility() {
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
it.ownBox.style.display = engine !== 'fui' ? '' : 'none';
|
|
|
|
|
it.fuiBox.style.display = engine !== 'own' ? '' : 'none';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _maxD = 0;
|
|
|
|
|
function reposition() {
|
|
|
|
|
const doOwn = engine !== 'fui';
|
|
|
|
|
const doFui = engine !== 'own';
|
|
|
|
|
const round = (n: number) => Math.round(n);
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
if (doOwn && doFui) {
|
|
|
|
|
Promise.all([
|
|
|
|
|
ownCompute(it.anchor, it.ownBox, { placement, middleware: oc, dom }),
|
|
|
|
|
fuiCompute(it.anchor, it.fuiBox, { placement, middleware: fc })
|
|
|
|
|
]).then(([o, f]) => {
|
|
|
|
|
it.ownBox.style.transform = `translate(${round(o.x)}px,${round(o.y)}px)`;
|
|
|
|
|
it.fuiBox.style.transform = `translate(${round(f.x)}px,${round(f.y)}px)`;
|
|
|
|
|
const d = Math.max(Math.abs(o.x - f.x), Math.abs(o.y - f.y));
|
|
|
|
|
if (d > _maxD) _maxD = d;
|
|
|
|
|
});
|
|
|
|
|
} else if (doOwn) {
|
|
|
|
|
ownCompute(it.anchor, it.ownBox, { placement, middleware: oc, dom }).then((o) => {
|
|
|
|
|
it.ownBox.style.transform = `translate(${round(o.x)}px,${round(o.y)}px)`;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
fuiCompute(it.anchor, it.fuiBox, { middleware: fc, placement }).then((f) => {
|
|
|
|
|
it.fuiBox.style.transform = `translate(${round(f.x)}px,${round(f.y)}px)`;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let raf = 0;
|
|
|
|
|
let lastT = 0;
|
|
|
|
|
let frames = 0;
|
|
|
|
|
let accum = 0;
|
|
|
|
|
|
|
|
|
|
function settle() {
|
|
|
|
|
// position anchors at their base + reposition boxes once (paused state)
|
|
|
|
|
for (const it of items) it.anchor.style.transform = `translate(${it.ax}px,${it.ay}px)`;
|
|
|
|
|
reposition();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function frame(t: number) {
|
|
|
|
|
const dt = lastT ? t - lastT : 16;
|
|
|
|
|
lastT = t;
|
|
|
|
|
frames++;
|
|
|
|
|
accum += dt;
|
|
|
|
|
if (accum >= 300) {
|
|
|
|
|
fps = Math.round((1000 * frames) / accum);
|
|
|
|
|
msFrame = Math.round((accum / frames) * 10) / 10;
|
|
|
|
|
deltaMax = Math.round(_maxD * 100) / 100;
|
|
|
|
|
_maxD = 0;
|
|
|
|
|
frames = 0;
|
|
|
|
|
accum = 0;
|
|
|
|
|
}
|
|
|
|
|
const time = t / 1000;
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
const x = it.ax + Math.cos(time * it.speed + it.phase) * it.r;
|
|
|
|
|
const y = it.ay + Math.sin(time * it.speed + it.phase) * it.r;
|
|
|
|
|
it.anchor.style.transform = `translate(${x}px,${y}px)`;
|
|
|
|
|
}
|
|
|
|
|
reposition();
|
|
|
|
|
// Only keep the loop alive while animating → paused = renderer idle.
|
|
|
|
|
raf = animate ? requestAnimationFrame(frame) : 0;
|
|
|
|
|
}
|
|
|
|
|
function kick() {
|
|
|
|
|
if (!animate) return;
|
|
|
|
|
if (raf) cancelAnimationFrame(raf);
|
|
|
|
|
lastT = 0;
|
|
|
|
|
raf = requestAnimationFrame(frame);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onVisibility() {
|
|
|
|
|
// rAF is paused while the tab is in the background; resume cleanly on return.
|
|
|
|
|
if (!document.hidden && animate) kick();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMount(() => {
|
|
|
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
|
|
|
if (animate) kick();
|
|
|
|
|
else settle();
|
|
|
|
|
});
|
|
|
|
|
onDestroy(() => {
|
|
|
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
|
|
|
if (raf) cancelAnimationFrame(raf);
|
|
|
|
|
dom.dispose();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Rebuild reactively when N changes (runs once after mount too — area is bound).
|
|
|
|
|
$effect(() => {
|
|
|
|
|
void count;
|
|
|
|
|
rebuild();
|
|
|
|
|
if (!animate) settle();
|
|
|
|
|
});
|
|
|
|
|
$effect(() => {
|
|
|
|
|
void engine;
|
|
|
|
|
applyVisibility();
|
|
|
|
|
if (!animate) settle();
|
|
|
|
|
});
|
|
|
|
|
$effect(() => {
|
|
|
|
|
void placement;
|
|
|
|
|
if (!animate) settle();
|
|
|
|
|
});
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (animate) kick();
|
|
|
|
|
else {
|
|
|
|
|
fps = 0;
|
|
|
|
|
settle();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── live micro-benchmark (synthetic, CPU only) ────────────────────────────
|
|
|
|
|
async function runBench() {
|
|
|
|
|
benching = true;
|
|
|
|
|
bench = null;
|
|
|
|
|
await tick();
|
|
|
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
|
|
|
|
|
|
|
|
const reference: Rect = { x: 850, y: 700, width: 120, height: 36 };
|
|
|
|
|
const floatingDims = { width: 240, height: 160 };
|
|
|
|
|
const clip: Rect = { x: 0, y: 0, width: 1000, height: 760 };
|
|
|
|
|
const viewportDelta = { x: -40, y: -120 };
|
|
|
|
|
const snapshot: ReadSnapshot = {
|
|
|
|
|
rects: { reference, floating: { x: 0, y: 0, ...floatingDims } },
|
|
|
|
|
elements: { reference: {} as never, floating: {} as never },
|
|
|
|
|
clipping: { floating: clip, reference: clip },
|
|
|
|
|
arrowDimensions: undefined,
|
|
|
|
|
viewportDelta,
|
|
|
|
|
offsetScale: { x: 1, y: 1 },
|
|
|
|
|
rtl: false
|
|
|
|
|
};
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
const platform: any = {
|
|
|
|
|
getElementRects: async () => ({ reference, floating: { x: 0, y: 0, ...floatingDims } }),
|
|
|
|
|
getClippingRect: async () => ({ ...clip }),
|
|
|
|
|
getDimensions: async () => ({ ...floatingDims }),
|
|
|
|
|
getOffsetParent: async () => ({}),
|
|
|
|
|
getScale: async () => ({ x: 1, y: 1 }),
|
|
|
|
|
isElement: async () => true,
|
|
|
|
|
convertOffsetParentRelativeRectToViewportRelativeRect: async ({ rect }: { rect: Rect }) => ({
|
|
|
|
|
x: rect.x + viewportDelta.x,
|
|
|
|
|
y: rect.y + viewportDelta.y,
|
|
|
|
|
width: rect.width,
|
|
|
|
|
height: rect.height
|
|
|
|
|
}),
|
|
|
|
|
isRTL: async () => false,
|
|
|
|
|
getDocumentElement: async () => ({})
|
|
|
|
|
};
|
|
|
|
|
const N = 40000;
|
|
|
|
|
for (let i = 0; i < 4000; i++) runMiddleware('bottom', 'absolute', oc, snapshot);
|
|
|
|
|
for (let i = 0; i < 4000; i++)
|
|
|
|
|
await fuiCompute({} as never, {} as never, {
|
|
|
|
|
placement: 'bottom',
|
|
|
|
|
strategy: 'absolute',
|
|
|
|
|
middleware: fc,
|
|
|
|
|
platform
|
|
|
|
|
});
|
|
|
|
|
const t0 = performance.now();
|
|
|
|
|
for (let i = 0; i < N; i++) runMiddleware('bottom', 'absolute', oc, snapshot);
|
|
|
|
|
const ownMs = performance.now() - t0;
|
|
|
|
|
const t1 = performance.now();
|
|
|
|
|
for (let i = 0; i < N; i++)
|
|
|
|
|
await fuiCompute({} as never, {} as never, {
|
|
|
|
|
placement: 'bottom',
|
|
|
|
|
strategy: 'absolute',
|
|
|
|
|
middleware: fc,
|
|
|
|
|
platform
|
|
|
|
|
});
|
|
|
|
|
const fuiMs = performance.now() - t1;
|
|
|
|
|
bench = {
|
|
|
|
|
ownOps: Math.round((N / ownMs) * 1000),
|
|
|
|
|
fuiOps: Math.round((N / fuiMs) * 1000),
|
|
|
|
|
speedup: Math.round((fuiMs / ownMs) * 100) / 100
|
|
|
|
|
};
|
|
|
|
|
benching = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fmt = (n: number) => n.toLocaleString('es-ES');
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<svelte:head>
|
|
|
|
|
<title>ethereal vs @floating-ui — comparativa visual</title>
|
|
|
|
|
</svelte:head>
|
|
|
|
|
|
|
|
|
|
<main>
|
|
|
|
|
<header>
|
|
|
|
|
<h1><span class="brand">ethereal</span> <span class="vs">vs</span> @floating-ui</h1>
|
|
|
|
|
<p>
|
|
|
|
|
Comparativa en vivo sobre <strong>tu máquina</strong>. Verde = <code>ethereal</code> (motor
|
|
|
|
|
propio, síncrono). Magenta = <code>@floating-ui</code> (async). En modo
|
|
|
|
|
<em>Ambos</em> las cajas se superponen: si el output es idéntico, no se separan.
|
|
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<section class="controls">
|
|
|
|
|
<div class="ctl">
|
|
|
|
|
<span class="lbl">Motor</span>
|
|
|
|
|
<div class="seg">
|
|
|
|
|
<button class:on={engine === 'own'} onclick={() => (engine = 'own')}>ethereal</button>
|
|
|
|
|
<button class:on={engine === 'fui'} onclick={() => (engine = 'fui')}>@floating-ui</button>
|
|
|
|
|
<button class:on={engine === 'both'} onclick={() => (engine = 'both')}>Ambos</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ctl">
|
|
|
|
|
<span class="lbl">Elementos · {count}</span>
|
|
|
|
|
<input type="range" min="5" max="300" step="5" bind:value={count} />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ctl">
|
|
|
|
|
<span class="lbl">Lado</span>
|
|
|
|
|
<div class="seg">
|
|
|
|
|
{#each PLACEMENTS as p (p)}
|
|
|
|
|
<button class:on={placement === p} onclick={() => (placement = p)}>{p}</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ctl">
|
|
|
|
|
<span class="lbl">Animar</span>
|
|
|
|
|
<button class="toggle" class:on={animate} onclick={() => (animate = !animate)}
|
|
|
|
|
>{animate ? 'on' : 'off'}</button
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="metrics">
|
|
|
|
|
<div class="metric">
|
|
|
|
|
<div class="mv" class:warn={fps < 50} class:bad={fps < 30}>{fps}</div>
|
|
|
|
|
<div class="ml">FPS</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="metric">
|
|
|
|
|
<div class="mv">{msFrame}<small>ms</small></div>
|
|
|
|
|
<div class="ml">por frame</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="metric">
|
|
|
|
|
<div class="mv">{fmt(fps * count * (engine === 'both' ? 2 : 1))}</div>
|
|
|
|
|
<div class="ml">posiciones/s</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="metric">
|
|
|
|
|
<div class="mv" class:good={deltaMax < 0.6} class:bad={deltaMax >= 1}>
|
|
|
|
|
{engine === 'both' ? deltaMax.toFixed(2) : '—'}<small>px</small>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ml">Δ máx (corrección)</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<div class="area" bind:this={area}></div>
|
|
|
|
|
|
|
|
|
|
<section class="bench">
|
|
|
|
|
<div class="bench-head">
|
|
|
|
|
<div>
|
|
|
|
|
<h2>Benchmark de cómputo puro</h2>
|
|
|
|
|
<p>40 000 iteraciones de la cadena, math sola (CPU). Sin DOM. En tu CPU.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="run" onclick={runBench} disabled={benching}
|
|
|
|
|
>{benching ? 'Midiendo…' : 'Ejecutar benchmark'}</button
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
{#if bench}
|
|
|
|
|
<div class="bars">
|
|
|
|
|
<div class="bar-row">
|
|
|
|
|
<span class="bn own">ethereal</span>
|
|
|
|
|
<div class="bar"><div class="fill own" style:width="100%"></div></div>
|
|
|
|
|
<span class="bops">{fmt(bench.ownOps)} ops/s</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bar-row">
|
|
|
|
|
<span class="bn fui">@floating-ui</span>
|
|
|
|
|
<div class="bar">
|
|
|
|
|
<div class="fill fui" style:width="{(bench.fuiOps / bench.ownOps) * 100}%"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="bops">{fmt(bench.fuiOps)} ops/s</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="verdict">
|
|
|
|
|
<strong>{bench.speedup}×</strong> más rápido el motor propio (síncrono vs el async de
|
|
|
|
|
floating-ui).
|
|
|
|
|
</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="notes">
|
|
|
|
|
<h2>Cómo leerlo</h2>
|
|
|
|
|
<ul>
|
|
|
|
|
<li>
|
|
|
|
|
<strong>Corrección</strong> — en <em>Ambos</em>, Δ máx ≈ <code>0.00px</code>: el output es
|
|
|
|
|
idéntico bit a bit (verificado además por 487 tests de paridad).
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<strong>Velocidad</strong> — el motor propio es síncrono; floating-ui envuelve cada lectura
|
|
|
|
|
y middleware en un <code>await</code>. Esa maquinaria async cuesta: ~1.5–2.3× en CPU.
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<strong>Fluidez</strong> — sube los elementos y alterna el motor con <em>Animar</em> on. JS
|
|
|
|
|
es mono-hilo: ambos serializan (el async <em>no</em> paraleliza). Pero el motor propio
|
|
|
|
|
coalesce sus lecturas en un <code>dom.measure</code> → 1 reflow; floating-ui lee síncrono por
|
|
|
|
|
elemento → puede provocar <em>thrashing</em> de layout con muchos overlays.
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<strong>El precio honesto</strong> — el motor propio posiciona en el siguiente
|
|
|
|
|
<code>rAF</code> (+1 frame de latencia), deliberado para no forzar reflow. Imperceptible al
|
|
|
|
|
abrir; alineado al frame durante scroll.
|
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</section>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
:global(html) {
|
|
|
|
|
background: #0b0e14;
|
|
|
|
|
}
|
|
|
|
|
main {
|
|
|
|
|
max-width: 1100px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: 32px 24px 80px;
|
|
|
|
|
color: #d6dae3;
|
|
|
|
|
font:
|
|
|
|
|
14px/1.55 'Inter',
|
|
|
|
|
system-ui,
|
|
|
|
|
sans-serif;
|
|
|
|
|
}
|
|
|
|
|
header h1 {
|
|
|
|
|
font-size: 26px;
|
|
|
|
|
font-weight: 650;
|
|
|
|
|
margin: 0 0 8px;
|
|
|
|
|
color: #f2f4f8;
|
|
|
|
|
letter-spacing: -0.01em;
|
|
|
|
|
}
|
|
|
|
|
.brand {
|
|
|
|
|
background: linear-gradient(90deg, #22c55e, #60a5fa);
|
|
|
|
|
-webkit-background-clip: text;
|
|
|
|
|
background-clip: text;
|
|
|
|
|
color: transparent;
|
|
|
|
|
}
|
|
|
|
|
.vs {
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
}
|
|
|
|
|
header p {
|
|
|
|
|
margin: 0 0 22px;
|
|
|
|
|
max-width: 760px;
|
|
|
|
|
color: #9aa3b2;
|
|
|
|
|
}
|
|
|
|
|
code {
|
|
|
|
|
background: #1a1f2b;
|
|
|
|
|
padding: 1px 6px;
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
font-size: 0.92em;
|
|
|
|
|
color: #c7d0de;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.controls {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 22px;
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
padding: 16px 18px;
|
|
|
|
|
background: #11151e;
|
|
|
|
|
border: 1px solid #1e2430;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
margin-bottom: 14px;
|
|
|
|
|
}
|
|
|
|
|
.ctl {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 7px;
|
|
|
|
|
}
|
|
|
|
|
.lbl {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
color: #6b7484;
|
|
|
|
|
}
|
|
|
|
|
.seg {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
background: #0c0f16;
|
|
|
|
|
border: 1px solid #232a38;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.seg button {
|
|
|
|
|
background: none;
|
|
|
|
|
border: none;
|
|
|
|
|
color: #9aa3b2;
|
|
|
|
|
padding: 7px 13px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
border-right: 1px solid #1c2230;
|
|
|
|
|
}
|
|
|
|
|
.seg button:last-child {
|
|
|
|
|
border-right: none;
|
|
|
|
|
}
|
|
|
|
|
.seg button.on {
|
|
|
|
|
background: #2563eb;
|
|
|
|
|
color: #fff;
|
|
|
|
|
}
|
|
|
|
|
.toggle {
|
|
|
|
|
background: #0c0f16;
|
|
|
|
|
border: 1px solid #232a38;
|
|
|
|
|
color: #9aa3b2;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 7px 16px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
.toggle.on {
|
|
|
|
|
background: #16a34a;
|
|
|
|
|
color: #fff;
|
|
|
|
|
border-color: #16a34a;
|
|
|
|
|
}
|
|
|
|
|
input[type='range'] {
|
|
|
|
|
width: 200px;
|
|
|
|
|
accent-color: #2563eb;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.metrics {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-bottom: 14px;
|
|
|
|
|
}
|
|
|
|
|
.metric {
|
|
|
|
|
background: #11151e;
|
|
|
|
|
border: 1px solid #1e2430;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 16px 18px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
.mv {
|
|
|
|
|
font-size: 30px;
|
|
|
|
|
font-weight: 680;
|
|
|
|
|
color: #f2f4f8;
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
}
|
|
|
|
|
.mv small {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #6b7484;
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
margin-left: 1px;
|
|
|
|
|
}
|
|
|
|
|
.mv.warn {
|
|
|
|
|
color: #f59e0b;
|
|
|
|
|
}
|
|
|
|
|
.mv.bad {
|
|
|
|
|
color: #ef4444;
|
|
|
|
|
}
|
|
|
|
|
.mv.good {
|
|
|
|
|
color: #22c55e;
|
|
|
|
|
}
|
|
|
|
|
.ml {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
color: #6b7484;
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.area {
|
|
|
|
|
position: relative;
|
|
|
|
|
height: 460px;
|
|
|
|
|
background:
|
|
|
|
|
radial-gradient(circle at 20% 20%, #141926, #0b0e14 60%);
|
|
|
|
|
border: 1px solid #1e2430;
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
}
|
|
|
|
|
.area :global(.anchor) {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 7px;
|
|
|
|
|
height: 7px;
|
|
|
|
|
margin: -3.5px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
box-shadow: 0 0 8px rgba(255, 255, 255, 0.5);
|
|
|
|
|
will-change: transform;
|
|
|
|
|
}
|
|
|
|
|
.area :global(.box) {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 88px;
|
|
|
|
|
height: 30px;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
font:
|
|
|
|
|
9px/30px 'Inter',
|
|
|
|
|
sans-serif;
|
|
|
|
|
text-align: center;
|
|
|
|
|
color: rgba(255, 255, 255, 0.85);
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
will-change: transform;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.area :global(.box-own) {
|
|
|
|
|
background: rgba(22, 163, 74, 0.16);
|
|
|
|
|
border: 1.5px solid #22c55e;
|
|
|
|
|
}
|
|
|
|
|
.area :global(.box-fui) {
|
|
|
|
|
background: rgba(217, 70, 239, 0.12);
|
|
|
|
|
border: 1.5px dashed #d946ef;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.bench {
|
|
|
|
|
background: #11151e;
|
|
|
|
|
border: 1px solid #1e2430;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 20px 22px;
|
|
|
|
|
margin-bottom: 22px;
|
|
|
|
|
}
|
|
|
|
|
.bench-head {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 20px;
|
|
|
|
|
}
|
|
|
|
|
.bench h2 {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
margin: 0 0 4px;
|
|
|
|
|
color: #f2f4f8;
|
|
|
|
|
}
|
|
|
|
|
.bench p {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: #8b93a3;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
.run {
|
|
|
|
|
background: #2563eb;
|
|
|
|
|
color: #fff;
|
|
|
|
|
border: none;
|
|
|
|
|
border-radius: 9px;
|
|
|
|
|
padding: 11px 20px;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 560;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.run:disabled {
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
cursor: default;
|
|
|
|
|
}
|
|
|
|
|
.bars {
|
|
|
|
|
margin-top: 20px;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
.bar-row {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 110px 1fr 130px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
}
|
|
|
|
|
.bn {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 560;
|
|
|
|
|
}
|
|
|
|
|
.bn.own {
|
|
|
|
|
color: #22c55e;
|
|
|
|
|
}
|
|
|
|
|
.bn.fui {
|
|
|
|
|
color: #d946ef;
|
|
|
|
|
}
|
|
|
|
|
.bar {
|
|
|
|
|
height: 22px;
|
|
|
|
|
background: #0c0f16;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.fill {
|
|
|
|
|
height: 100%;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
transition: width 0.5s ease;
|
|
|
|
|
}
|
|
|
|
|
.fill.own {
|
|
|
|
|
background: linear-gradient(90deg, #16a34a, #22c55e);
|
|
|
|
|
}
|
|
|
|
|
.fill.fui {
|
|
|
|
|
background: linear-gradient(90deg, #a21caf, #d946ef);
|
|
|
|
|
}
|
|
|
|
|
.bops {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #c7d0de;
|
|
|
|
|
text-align: right;
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
}
|
|
|
|
|
.verdict {
|
|
|
|
|
margin: 16px 0 0;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
color: #d6dae3;
|
|
|
|
|
}
|
|
|
|
|
.verdict strong {
|
|
|
|
|
color: #22c55e;
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.notes {
|
|
|
|
|
background: #0e1219;
|
|
|
|
|
border: 1px solid #1a202c;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 20px 24px;
|
|
|
|
|
}
|
|
|
|
|
.notes h2 {
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
margin: 0 0 12px;
|
|
|
|
|
color: #cbd2de;
|
|
|
|
|
}
|
|
|
|
|
.notes ul {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding-left: 18px;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 9px;
|
|
|
|
|
}
|
|
|
|
|
.notes li {
|
|
|
|
|
color: #9aa3b2;
|
|
|
|
|
font-size: 13.5px;
|
|
|
|
|
}
|
|
|
|
|
.notes strong {
|
|
|
|
|
color: #d6dae3;
|
|
|
|
|
}
|
|
|
|
|
</style>
|