Миллион строк
Цена трёх жестов на предельной книге — прокрутки, протяжки и масштаба: кадр p50 и p95 и обращения к ядру числами, а не обещанием.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.
Страница, которую открывает браузер.
<!doctype html><!-- Миллион строк — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ.
Не «страница витрины», а тот проект, который встраивающий копирует к себе целиком. Галерея примеров ЗАПУСКАЕТ эти файлы и ПОКАЗЫВАЕТ их же.
РАЗМЕТКА ПАНЕЛИ ОБЪЯВЛЕНА ЗДЕСЬ, А НЕ СОБИРАЕТСЯ КОДОМ. Меняются в ней ровно три места — числа, вердикт и род жеста, — и меняет их `src/main.tsx`. Панель, которую код перерисовывал бы целиком, теряла бы галочку «мерить цену жеста» на каждом кадре: она часть доказательства, а не украшение.
МАСШТАБ МЕНЯЕТСЯ ОРГАНОМ ПРОДУКТА, А НЕ СВОИМ. Внизу стоит НАСТОЯЩАЯ строка состояния из `@f1/sheets-ui` — тот же ползунок, те же «−»/«+» по остановкам Excel, та же команда `view.zoom.set:`. Второй ползунок «для примера» показывал бы цену не того, чем пользуются.--><html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Миллион строк — @f1</title> </head> <body> <div data-example="stress" class="flex h-screen w-screen flex-col bg-base-100 text-base-content"> <header class="flex flex-wrap items-center gap-3 border-b border-base-300 px-4 py-2" data-stress-panel data-stress-verdict="жеста ещё не было" > <div class="me-auto"> <div class="text-sm font-semibold" id="book"></div> <div class="text-xs opacity-70"> Три жеста, три цены: прокрутите лист колесом, протяните выделение мышью и измените масштаб — ползунком внизу или Ctrl+колесом. Ctrl+End уводит в конец книги. Числа объявляет сам лист (свойство onGesture), страница их только показывает. </div> <!-- Предел полосы называет сам лист, и только вместе с жестом: он важен ровно тогда, когда по полосе едут. --> <div id="reach"></div> </div>
<!-- Числа появляются ПОСЛЕ жеста, и у каждого рода они СВОИ: прокрутка, протяжка и масштаб стоят разного, а одна строка «последний жест» показывала бы цену того, о чём не спрашивали. До жеста в его строке стоит «мерить нечего», а не нули и прочерки: они читались бы как «быстро». --> <div id="readings"> <div class="text-sm opacity-70" data-stress-empty>жеста ещё не было — мерить нечего</div> </div>
<span class="badge badge-ghost" data-stress-badge>жеста ещё не было</span> <span id="kind"></span>
<label class="label cursor-pointer gap-2 text-xs"> <input type="checkbox" class="checkbox checkbox-sm" checked data-stress-meter /> мерить цену жеста </label> </header>
<div id="app" class="flex min-h-0 flex-1 flex-col"></div>
<!-- Строка состояния ПРОДУКТА: масштаб живёт здесь, как в Excel. Рисует её Preact — это компонент пакета `@f1/sheets-ui`, и монтирует его хозяин страницы в СВОЙ узел. --> <div id="status"></div> </div>
<script type="module" src="./src/main.tsx"></script> </body></html>Точка входа: здесь лист создаётся и сюда же приходят события.
import { render } from "preact";import { createSheetsCore, type SheetsCore } from "@f1/preset-sheets-core";import { GESTURE_BUDGET, type GridGesture, type GridGestureKind } from "@f1/grid-ui";import { DEFAULT_STATUS_INDICATORS, StatusBar } from "@f1/sheets-ui";import { ZOOM_DEFAULT } from "@f1/sheets";import { BOOK, transport } from "./book.js";import { hostAsks } from "./host.js";import "./styles.css";
/** * СТРЕСС: ПРЕДЕЛЬНАЯ КНИГА И ЧИСЛА РЯДОМ. * * Обычно на такое отвечают общими словами про эффективную работу с * большими данными. У нас цена жеста давно меряется в наших проверках, но * их читает тот, кто их запустил, а пример открывает любой — и до этого среза * ему предлагалось верить на слово. * * ЧЕСТНОСТЬ ЗДЕСЬ — ГЛАВНОЕ ТРЕБОВАНИЕ, а не украшение. Правила такие: * * · ЧИСЛА ИЗМЕРЯЮТСЯ, а не рисуются. Пока жеста не было, стоит «жеста ещё не * было» — не нули и не прочерки, которые читаются как «быстро». * · ПОРОГИ БЕРУТСЯ ИЗ ОДНОГО ОБЪЯВЛЕНИЯ (`GESTURE_BUDGET`), того же, по * которому краснеют наши проверки. Своя копия числа означала бы, что пример вправе * быть довольным тогда, когда проверка красная. * · ВЫХОД ЗА БЮДЖЕТ ПОКАЗЫВАЕТСЯ, а не прячется: вердикт «мимо бюджета» — * такая же законная надпись, как «в бюджете». Пример, который умеет только * зеленеть, — это реклама, а не замер. * * ТРИ ЖЕСТА, А НЕ ОДИН. Масштаб — такой же показатель, как прокрутка, и * предельная книга обязана показывать и его. Масштаба здесь не было ни * органом, ни числом, и это был не пропуск ПОКАЗА: собранный лист не передавал * сетке масштаба вовсе, поэтому Ctrl+колесо объявляло жест, который ничего не * менял. Теперь цена каждого рода живёт СВОЕЙ строкой и не затирается * следующим жестом: прокрутка, протяжка и масштаб стоят разного, и одна строка * «последний жест» отвечала бы не на тот вопрос. Рядом с ценой стоит МАСШТАБ, * при котором она снята, — потому что прокрутка при 25 % и при 100 % это * разная работа: в то же окно попадает вчетверо больше ячеек. * * ОРГАН МАСШТАБА — ПРОДУКТОВЫЙ. Внизу настоящая строка состояния из * `@f1/sheets-ui`: тот же ползунок, те же остановки Excel у «−»/«+», и * объявляет она ту же команду `view.zoom.set:`, которую исполняет собранный * лист. Свой ползунок рядом мерил бы не то, чем пользуются. * * ПРИМЕР ТОЛЬКО СЛУШАЕТ. Цена жеста — СВОЙСТВО виджета (`onGesture`), и о * внутренностях листа проект не знает ничего: он подписался и показывает * присланное. Ни одного обращения к `document` за пределами своих узлов здесь * нет — а раньше пример слушал прокрутку на захвате у документа и искал в нём * чужой узел, потому что сетка не объявляла наружу ни жеста, ни его цены. * * ЖЕСТ ДЕЛАЕТ ЧЕЛОВЕК, А НЕ КНОПКА. Прежние кнопки «прокрутить» и «в конец * книги» двигали полосу листа снаружи — то есть страница дёргала продукт за * его внутренние органы и называла это жестом. Настоящий жест — колесо, * Ctrl+End, протяжка мышью и ползунок масштаба. */
const { locale } = hostAsks();
const ru = new Intl.NumberFormat("ru-RU");const ms = (value: number) => `${value.toFixed(1)} мс`;
const panel = document.querySelector<HTMLElement>("[data-stress-panel]")!;const badge = document.querySelector<HTMLElement>("[data-stress-badge]")!;const meter = document.querySelector<HTMLInputElement>("[data-stress-meter]")!;const readings = document.getElementById("readings")!;const reachSlot = document.getElementById("reach")!;const kindSlot = document.getElementById("kind")!;const statusHost = document.getElementById("status")!;const mount = document.getElementById("app")!;
document.getElementById("book")!.textContent = `${ru.format(BOOK.rows)} × ${ru.format(BOOK.cols)} — предельный лист`;
const KIND_TITLE: Record<GridGestureKind, string> = { scroll: "прокрутка", drag: "протяжка", zoom: "масштаб",};/* Порядок строк постоянный: цена, переезжающая с места на место, не читается — глаз ищет её там же, где видел в прошлый раз. */const KINDS: readonly GridGestureKind[] = ["scroll", "drag", "zoom"];
interface Measured { readonly gesture: GridGesture; readonly atZoom: number;}
let measured = new Map<GridGestureKind, Measured>();let last: GridGestureKind | null = null;let zoom = ZOOM_DEFAULT;
function withinBudget(gesture: GridGesture): boolean { const cost = gesture.cost; return ( cost !== null && cost.p95Ms < GESTURE_BUDGET.frameP95Ms && cost.worstMs < GESTURE_BUDGET.worstFrameMs && gesture.windowRequests <= GESTURE_BUDGET.requestsPerScroll );}
function reading(anchor: string, title: string, value: string, limit: string, ok: boolean): string { return ` <div class="stat px-3 py-1" data-stress-reading="${anchor}"> <div class="stat-title text-xs">${title}</div> <div class="stat-value text-xl ${ok ? "" : "text-error"}" data-stress-value="${anchor}">${value}</div> <div class="stat-desc">бюджет ${limit}</div> </div>`;}
function row(kind: GridGestureKind): string { const seen = measured.get(kind); const head = `<div class="w-20 shrink-0 text-xs font-semibold">${KIND_TITLE[kind]}</div>`; const cost = seen?.gesture.cost ?? null; if (!seen || cost === null) { return `<div class="flex items-center gap-2" data-stress-gesture="${kind}"> ${head} <div class="text-xs opacity-60" data-stress-empty="${kind}">жеста ещё не было</div> </div>`; } return `<div class="flex items-center gap-2" data-stress-gesture="${kind}"> ${head} <div class="stats stats-horizontal shadow-none">${[ reading(`${kind}.p50`, "кадр p50", ms(cost.p50Ms), ms(GESTURE_BUDGET.frameP95Ms), cost.p50Ms < GESTURE_BUDGET.frameP95Ms), reading(`${kind}.p95`, "кадр p95", ms(cost.p95Ms), ms(GESTURE_BUDGET.frameP95Ms), cost.p95Ms < GESTURE_BUDGET.frameP95Ms), reading(`${kind}.worst`, "худший кадр", ms(cost.worstMs), ms(GESTURE_BUDGET.worstFrameMs), cost.worstMs < GESTURE_BUDGET.worstFrameMs), /* «Запросов к ядру» — про ШОВ КОНТРАКТА, а не про исполнителя: считаются обращения, ушедшие за шов. Кто за ним стоит — заглушка сегодня, wasm завтра, — на это число не влияет. */ reading( `${kind}.requests`, "запросов к ядру", String(seen.gesture.windowRequests), `${GESTURE_BUDGET.requestsPerScroll} за жест`, seen.gesture.windowRequests <= GESTURE_BUDGET.requestsPerScroll, ), ].join("")}</div> <!-- МАСШТАБ, ПРИ КОТОРОМ СНЯТА ЦЕНА: без него число несравнимо само с собой — при 25 % в то же окно попадает вчетверо больше ячеек. --> <div class="text-xs opacity-70 tabular-nums" data-stress-at="${kind}">при ${seen.atZoom} %</div> </div>`;}
/** * ПОКАЗАТЬ ВСЁ ИЗМЕРЕННОЕ, а вердиктом назвать ПОСЛЕДНИЙ жест. * * Разбирать событие на отдельные состояния незачем: оно приезжает одним * значением, и хранить его частями значило бы уметь показать половину одного * жеста рядом с половиной другого. */function show(gesture: GridGesture | null): void { if (gesture) { measured.set(gesture.kind, { gesture, atZoom: zoom }); last = gesture.kind; } const latest = last ? (measured.get(last) ?? null) : null; const cost = latest?.gesture.cost ?? null; const ok = latest !== null && withinBudget(latest.gesture); const verdict = !meter.checked ? "замер выключен" : cost === null ? "жеста ещё не было" : ok ? "в бюджете" : "мимо бюджета";
panel.setAttribute("data-stress-verdict", verdict); badge.textContent = verdict; badge.className = `badge ${cost === null ? "badge-ghost" : ok ? "badge-success" : "badge-error"}`;
readings.innerHTML = measured.size === 0 ? `<div class="text-sm opacity-70" data-stress-empty>${ meter.checked ? "жеста ещё не было — мерить нечего" : "замер выключен — лист едет без него" }</div>` : `<div class="flex flex-col gap-1">${KINDS.map(row).join("")}</div>`;
/* Род ПОСЛЕДНЕГО жеста — рядом с ценой: «прокрутка», «протяжка» и «масштаб» стоят разного, и число без имени жеста ни о чём не говорит. */ kindSlot.innerHTML = last ? `<span class="badge badge-ghost" data-stress-kind="${last}">${KIND_TITLE[last]}</span>` : "";
/* ЧЕСТНО О ПРЕДЕЛЕ БРАУЗЕРА: он отдаёт элементу не больше 2^24 пикселей высоты, и на предельном листе колесом достаётся не вся книга. Оба числа — от самого листа: что он попросил и что дал браузер. Подпись «миллион строк» над листом, который колесом кончается раньше, была бы обманом, и обнаруживать его не должен посетитель. С МАСШТАБОМ ЭТО ЧИСЛО ЕДЕТ: распорка живёт в экранных пикселях, то есть в координатах листа, умноженных на масштаб, — при 25 % книга подходит к пределу элемента вчетверо ближе, чем при 100 %. */ const reach = latest?.gesture.reach.rows ?? null; if (!reach) { reachSlot.innerHTML = ""; return; } reachSlot.innerHTML = reach.given < reach.declared * 0.999 ? `<div class="text-xs text-warning" data-stress-reach="clamped">колесом достижимо ${Math.round( (reach.given / reach.declared) * 100, )}% книги: лист просит ${ru.format(Math.round(reach.declared))} px, браузер отдаёт ${ru.format( reach.given, )} px (предел элемента)</div>` : `<div class="text-xs opacity-70" data-stress-reach="full">полоса получила всё, что просил лист (${ru.format( reach.given, )} px) — колесом достижима вся книга</div>`;}
/** * СТРОКА СОСТОЯНИЯ ПРОДУКТА — И ОНА ТОЖЕ НИЧЕГО НЕ ПРИМЕНЯЕТ. * * Ползунок ОБЪЯВЛЯЕТ команду `view.zoom.set:<процент>`, исполняет её собранный * лист, и новый масштаб приезжает обратно событием `onZoomChange` — только * после этого полоса показывает другое число. Короткого пути «сам показал, сам * применил» здесь нет намеренно: он и есть второе состояние масштаба. */const paintStatus = () => { render(<StatusBar visible={new Set(DEFAULT_STATUS_INDICATORS)} zoom={zoom} />, statusHost);};
/** * ЗАМЕР МОЖНО ВЫКЛЮЧИТЬ — И ЭТО ЧАСТЬ ДОКАЗАТЕЛЬСТВА, а не настройка. Цена * измерения обязана быть видимой: сняли галочку — виджет не получает * подписчика, не заводит ни цикла кадров, ни чтения раскладки, и лист едет * ровно так же, как у того, кому эти числа не нужны. */let core: SheetsCore | null = null;
function build(): void { core?.destroy(); measured = new Map(); last = null; show(null); core = createSheetsCore({ mount, transport, locale, rows: BOOK.rows, cols: BOOK.cols, /* Масштаб отдаётся сборке НАЧАЛЬНЫМ значением, а держит его она: органов у масштаба несколько, состояние обязано быть одно. */ zoom, onZoomChange: (percent) => { zoom = percent; paintStatus(); }, /* НЕ ПОДПИСАЛСЯ — НЕ ПЛАТИТ: при снятой галочке свойства нет вовсе. */ ...(meter.checked ? { onGesture: show } : {}), });}
meter.addEventListener("change", build);paintStatus();build();
import.meta.hot?.dispose(() => core?.destroy());Книга примера: данные, с которыми лист открывается.
import { createMockTransport } from "@f1/contract";import { SHEET_BOUNDS } from "@f1/sheets";
/** * КНИГА РАЗМЕРОМ С ПРЕДЕЛ ЛИСТА — тот самый, который мы заявляем. * * Транспорт контракта — единственное место, где лист разговаривает с ядром * (инвариант №1: считает ядро, оболочка только показывает). В продукте здесь * стоит `wasm` или мост хоста, и меняется РОВНО эта строка. */export const BOOK = SHEET_BOUNDS;
/** * Значение находится для ЛЮБОЙ строки, поэтому на дне миллиона строк лист не * окажется пустым — иначе стресс мерил бы рисование пустоты. */export const transport = createMockTransport({ cellAt: (row, col) => col === 0 ? `строка ${row + 1}` : col === 1 ? `${((row * 7 + col) % 97) / 4}` : `${(row + 1) * (col + 1)}`, /* Край книги ОБЪЯВЛЕН: книга-генератор края не имеет, а мерить его по загруженному окну значило бы выдать размер экрана за размер книги. */ usedRange: { rowEnd: BOOK.rows - 1, colEnd: BOOK.cols - 1 },});Сторона хозяина страницы: что приложение отвечает листу и что от него получает.
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";
/** * ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема и язык. * * У встраивающего это уже есть своё: тему он знает из настроек, язык — из * профиля пользователя. Здесь и то и другое читается из адреса * (`?theme=dark&locale=ar`) ровно затем, чтобы на любое состояние можно было * дать ссылку и чтобы его проверял обход: настройка, доступная только щелчком, * недоступна ни ссылке, ни автоматической проверке. * * ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не * лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает * СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном. * * НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром * страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от * арабского языка интерфейса не переворачивается. Это не пропуск, а решение * (`locale.ts`). */export function hostAsks(): { readonly theme: "light" | "dark"; readonly locale: Locale } { const params = new URLSearchParams(location.search); const theme = params.get("theme") === "dark" ? "dark" : "light"; const locale = resolveLocale(params.get("locale") ?? undefined, "ru");
document.documentElement.setAttribute("data-theme", theme); /* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */ setLocale(locale); applyLocaleAttributes(document.documentElement, locale);
return { theme, locale };}Оформление страницы вокруг листа.
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ. Собственное оформление лист приносит в теневые корни сам, но утилиты раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл замера у сетки без размеров). У потребителя вне репозитория это `@fastoffices/formula-kit` из его node_modules. */@import "@fastoffices/formula-kit"; /* см. примечание выше */html,body { height: 100%; margin: 0;}Из каких пакетов собран пример.
{ "name": "f1-example-stress", "private": true, "type": "module", "description": "Предельная книга и цена ТРЁХ жестов рядом с ней — прокрутки, протяжки и масштаба: кадр p50/p95 и запросы за шов контракта числами, а не обещанием.", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "@f1/preset-sheets-core": "*", "@f1/grid-ui": "*", "@f1/sheets": "*", "@f1/sheets-ui": "*", "@f1/contract": "*", "@f1/i18n": "*", "preact": "^10.0.0" }, "devDependencies": { "vite": "^7.0.0" }}- Остальные примеры — Примеры F1.
- Правила, на которые ссылается код, — пять правил.
- Из чего собирается лист — Архитектура.
- Что можно вызвать сегодня — справочник Compute Contract.