From 9f7051d3489ca3391077d539d40444637db93e60 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Fri, 2 Oct 2026 06:29:44 +0900 Subject: [PATCH] feat: the site demonstrates `order` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capability nobody can see is a capability that quietly stops working, so the seam gets a section of its own with two traversals written the way a reader would write them: a chevron that accelerates toward its tips, and rings that expand from a dot the visitor CLICKS. Both answer questions the library's tables cannot — the shape of a traversal can be anything computable per cell, and its origin can be anywhere. SEPARATE FROM THE PLAYGROUND, deliberately. `order` is a function, so it cannot travel in the copied settings, and a visitor who reproduced the playground's snippet would silently get the preset's motion with nothing saying why. Its own field, its own code — and the code shown is the traversals file imported with `?raw`, so the source a visitor reads IS the source that is running. A hand-maintained copy of a function is a copy that lies the first time anyone edits one of them, and two tests pin that: the shown text is a real slice of the file, and it contains the body the running function has. The `?raw` import was checked in the real build rather than assumed — tsc accepts it and Vite might still not, which would have shipped a demo with an empty code block. FOUR REAL PROBLEMS FOUND BY LOOKING AT IT RENDERED, none of which any test had reported: - `ringsFrom` normalised by the distance to the nearest edge, so an off-centre origin shortened the range and the wave stopped short of 1 — by an amount that depended on where the visitor clicked, which is invisible in a single screenshot and obvious in the arithmetic. There is now a test that every origin reaches 1 at the far corner. - the same sentence appeared twice, once as the section lede and once above the code block. - the code block opened with the file's five-paragraph header comment, so the thing to copy was below the fold of a 320px panel. - the origin marker sat exactly on a corner cell and was half-clipped by the field's own edge — which is the first place anyone clicks. 273 tests (was 271), oxlint 0, registry ok, both changelog ledgers. --- CHANGELOG.md | 3 + CHANGELOG.zh.md | 3 + apps/site/messages/en.json | 8 +- apps/site/messages/zh.json | 8 +- apps/site/src/components/Home.astro | 7 + apps/site/src/components/TraversalDemo.tsx | 125 ++++++++++++++ apps/site/src/rawprobe.ts | 2 + apps/site/src/styles/site.css | 109 +++++++++++++ apps/site/src/traversals.ts | 82 ++++++++++ apps/site/test/i18n.test.ts | 1 + apps/site/test/traversal.test.ts | 179 +++++++++++++++++++++ 11 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 apps/site/src/components/TraversalDemo.tsx create mode 100644 apps/site/src/rawprobe.ts create mode 100644 apps/site/src/traversals.ts create mode 100644 apps/site/test/traversal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a9119..382efa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,9 @@ he copy button said "Your settings, as props — only what you changed", and the ### Added +- **The site demonstrates `order`.** A capability nobody can see is a capability that quietly stops working, so the seam now has a section of its own with two traversals written the way a reader would write them: a **chevron that accelerates** toward its tips, and **rings that expand from a dot the visitor clicks**. Both answer questions the library's own tables cannot: the shape of a traversal can be anything computable per cell, and its origin can be anywhere. +- **The demo is deliberately separate from the playground.** `order` is a function, so it cannot travel in the copied settings, and a visitor who reproduced the playground's snippet would silently get the preset's motion instead. Its own field, its own code — and the code shown is the traversals file **imported with `?raw`**, so the source a visitor reads is literally the source that is running and cannot drift from it. + - **You can now supply your own traversal.** `order` takes an `OrderFn` and replaces the preset's — which is the seam the layered design always left open, finally reachable from an option instead of only from a custom renderer. Anything you can compute per cell, you can animate: ```ts diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 5127610..5eeab0a 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -60,6 +60,9 @@ Playground、双语文档与 registry 迁到 Cloudflare Pages 上的 Astro + Rea ### 新增 +- **站点演示 `order`。** 没人能看见的能力,就是会悄悄坏掉的能力,所以这个接缝现在有了自己的版块,包含两个按读者会写的方式写的遍历:一个**向尖端加速的箭头**,以及**从访客点击的那个点扩散的圆环**。两者都回答了库自身表格无法回答的问题:遍历的形状可以是任何能按格计算的东西,而它的起点可以在任何位置。 +- **这个演示刻意与 playground 分开。** `order` 是函数,无法随复制的设置一起传递,而照着 playground 的片段重建组件的人会悄悄拿到 preset 的动效。它有自己的场、自己的代码 —— 而展示的代码是用 `?raw` 导入的遍历文件,所以读者读到的源码就是正在运行的源码,两者不可能漂移。 + - **现在可以提供你自己的遍历。** `order` 接受一个 `OrderFn`,替换 preset 自带的 —— 这是分层设计一直留着的接缝,终于能从 option 到达,而不再只存在于自定义渲染器里。你能按格计算的任何东西,都能变成动效: ```ts diff --git a/apps/site/messages/en.json b/apps/site/messages/en.json index 353f097..f143281 100644 --- a/apps/site/messages/en.json +++ b/apps/site/messages/en.json @@ -128,5 +128,11 @@ "ctl_color_swatch": "Pick a colour", "color_hex": "HEX", "color_rgb": "RGB", - "color_hsl": "HSL" + "color_hsl": "HSL", + "demo_traversal_title": "Your own traversal", + "demo_traversal_which": "Which traversal", + "demo_traversal_chevron": "Chevron", + "demo_traversal_rings": "Rings", + "demo_traversal_click": "Click the field to move the centre.", + "demo_traversal_lede": "A traversal is one function of lattice position, so anything you can compute per cell you can animate. These two are not presets — they are `order` functions, written here and running below." } diff --git a/apps/site/messages/zh.json b/apps/site/messages/zh.json index 4ff6d60..d490522 100644 --- a/apps/site/messages/zh.json +++ b/apps/site/messages/zh.json @@ -128,5 +128,11 @@ "ctl_color_swatch": "选择颜色", "color_hex": "HEX", "color_rgb": "RGB", - "color_hsl": "HSL" + "color_hsl": "HSL", + "demo_traversal_title": "你自己的遍历", + "demo_traversal_which": "选择遍历", + "demo_traversal_chevron": "箭头", + "demo_traversal_rings": "圆环", + "demo_traversal_click": "点击场来移动中心。", + "demo_traversal_lede": "遍历就是「格位置」的单一函数,所以你能按格计算的任何东西都能变成动效。下面这两个不是 preset — 它们是 `order` 函数,就在这里写着,并在下面运行。" } diff --git a/apps/site/src/components/Home.astro b/apps/site/src/components/Home.astro index 909f3c9..a78853f 100644 --- a/apps/site/src/components/Home.astro +++ b/apps/site/src/components/Home.astro @@ -3,6 +3,7 @@ import Base from '../layouts/Base.astro'; import { InstallCommand } from './InstallCommand'; import { Playground } from './Playground'; import { RegistryList } from './RegistryList'; +import { TraversalDemo } from './TraversalDemo'; import LocaleSwitch from './LocaleSwitch.astro'; import { m } from '../paraglide/messages.js'; import { localizeHref, locales } from '../paraglide/runtime.js'; @@ -51,6 +52,12 @@ const path = Astro.url.pathname; +
+

{m.demo_traversal_title()}

+

{m.demo_traversal_lede()}

+ +
+

{m.components_heading()}

{m.components_note()}

diff --git a/apps/site/src/components/TraversalDemo.tsx b/apps/site/src/components/TraversalDemo.tsx new file mode 100644 index 0000000..737e877 --- /dev/null +++ b/apps/site/src/components/TraversalDemo.tsx @@ -0,0 +1,125 @@ +import { useMemo, useState } from "react"; +import { Matrix } from "./Matrix.js"; +import { m } from "../i18n.js"; +import { chevron, nearestCell, ringsFrom } from "../traversals.js"; +// The source of the traversals file, at build time. Shown beside the field so the code a +// visitor reads IS the code that is running — a hand-kept copy of a function is a copy that +// lies the first time anyone edits one of them. +import traversalSource from "../traversals.ts?raw"; + +// The file, from the first export onward. The header comment explains WHY these exist, which +// is for us; a visitor's first question is what to copy, and scrolling past five paragraphs +// of rationale to reach it is the wrong first impression of a code sample. +const COPYABLE = traversalSource.slice(traversalSource.indexOf("export")); + +const N = 9; + +/** + * The `order` seam, on the page. + * + * A capability the page cannot demonstrate is a capability that quietly stops working, and + * this one has a specific hazard: `order` is a FUNCTION, so it cannot go in the copied + * snippet. A visitor who reproduced the settings from the copy button would get the preset's + * motion and no indication why. So this demo is deliberately separate from the playground — + * its own field, its own settings, its own copy button that copies the CODE — and the code is + * right there to paste. + * + * Two traversals, because they answer two different questions: + * + * chevron the SHAPE of the traversal can be anything you can compute per cell, including + * an easing along its own axis — which is what "accelerating" actually is. + * rings the ORIGIN can be anywhere, which the library's concentric preset pins to the + * middle. Click the field to move it. + */ +export function TraversalDemo() { + const [variant, setVariant] = useState<"chevron" | "rings">("chevron"); + const [origin, setOrigin] = useState({ col: 4, row: 4 }); + + // rebuilt whenever the origin moves, and the field below is keyed on it so the engine + // re-ranks rather than remounting — the same path a real caller takes + const order = useMemo( + () => (variant === "chevron" ? chevron : ringsFrom(origin)), + [variant, origin], + ); + + return ( +
+
+
+ + +
+ {variant === "rings" &&

{m.demo_traversal_click()}

} +
+ +
+
{ + if (variant !== "rings") return; + const box = e.currentTarget.getBoundingClientRect(); + // the field is drawn from its centre outward, in the engine's own px geometry + const pitch = box.width / N; + setOrigin( + nearestCell( + { + x: e.clientX - box.left - box.width / 2, + y: e.clientY - box.top - box.height / 2, + }, + { x: pitch, y: pitch }, + N, + N, + ), + ); + }} + > + + {variant === "rings" && ( +
+ +
+ {/* No caption here: the section's lede already says what these are, and a second + copy of the same sentence one inch away is the thing people stop reading. */} +
+            {COPYABLE}
+          
+
+
+
+ ); +} diff --git a/apps/site/src/rawprobe.ts b/apps/site/src/rawprobe.ts new file mode 100644 index 0000000..c00fb9a --- /dev/null +++ b/apps/site/src/rawprobe.ts @@ -0,0 +1,2 @@ +import src from "./traversals.ts?raw"; +export const hasRaw = typeof src === "string" && src.includes("chevron"); diff --git a/apps/site/src/styles/site.css b/apps/site/src/styles/site.css index 8093de3..3150c68 100644 --- a/apps/site/src/styles/site.css +++ b/apps/site/src/styles/site.css @@ -167,6 +167,115 @@ h3 { cursor: pointer; } +/* The `order` seam. Separate from the playground on purpose: `order` is a FUNCTION, so it + cannot travel in the copied settings, and a visitor who reproduced the playground's + snippet would silently get the preset's motion instead. Its own field, its own code. */ +.traversal-demo { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--card); + padding: 16px; +} + +.traversal-demo-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.traversal-choices { + display: flex; + gap: 2px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 2px; +} + +.traversal-choices button { + font: inherit; + font-size: 13px; + border: 0; + background: transparent; + color: var(--muted); + border-radius: 6px; + padding: 5px 12px; + cursor: pointer; +} + +.traversal-choices button.is-on { + background: var(--accent); + color: #fff; +} + +.traversal-hint { + margin: 0; + font-size: 12px; + color: var(--muted); +} + +.traversal-demo-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.traversal-field { + position: relative; + line-height: 0; + /* the origin marker sits ON a cell, so a cell at the very corner would have half of it + clipped by the field's own edge — which is exactly where a visitor clicks first */ + padding: 6px; +} + +/* only the rings variant is interactive, and saying so with a pointer is cheaper than + disabling it: the chevron has no origin to click */ +.traversal-field[role="button"], +.traversal-field:has(.traversal-origin) { + cursor: crosshair; +} + +/* where the rings currently expand from — the one thing the demo is about, so it is drawn + rather than described */ +.traversal-origin { + position: absolute; + width: 7px; + height: 7px; + margin: -3.5px 0 0 -3.5px; + border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); + pointer-events: none; + /* over the dots, which are drawn in `currentColor` at the page's ink */ + z-index: 1; +} + +.traversal-code p { + margin: 0 0 8px; + font-size: 12px; + color: var(--muted); +} + +.traversal-code pre { + margin: 0; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg); + font-size: 11px; + line-height: 1.6; + max-height: 320px; + overflow: auto; +} + +@media (max-width: 860px) { + .traversal-demo-body { + grid-template-columns: 1fr; + } +} + /* The dot colour. Full width like the copy button, and in the sticky pane beside the field, because a colour you have to look away from to check is not a colour you are choosing. */ diff --git a/apps/site/src/traversals.ts b/apps/site/src/traversals.ts new file mode 100644 index 0000000..f07f2f8 --- /dev/null +++ b/apps/site/src/traversals.ts @@ -0,0 +1,82 @@ +import type { OrderFn } from "@botharness/botui-core"; + +/** + * Two traversals the library does not ship, written the way a reader would write them. + * + * These exist to demonstrate the `order` seam on the page rather than only in a README: a + * capability nobody can see is a capability that quietly stops working. Both are ordinary + * `OrderFn`s — a function of lattice position returning `0…1`, where `0` leads. + * + * The source of this file is shown verbatim beside the field, imported with `?raw`, so the + * code a visitor reads cannot drift from the code that is running. A hand-maintained copy + * of a function is a copy that lies the first time someone edits one of them. + */ + +/** + * A CHEVRON that accelerates toward its tips. + * + * "Accelerating" is not a property of the traversal's SHAPE but of how the value is + * distributed along it: squaring compresses the early differences and stretches the late + * ones, so successive dots along each arm start closer together in phase near the tip. A + * linear chevron is a chevron; an eased one is a chevron that moves like it is going + * somewhere. + * + * The value mixes distance from the spine with progress along the field, which is what + * makes it fold rather than simply translate. + */ +export const chevron: OrderFn = (col, row, cols, rows) => { + const mid = (rows - 1) / 2; + // 0 at the spine, 1 at the outer tips. `|| 1` because a single row has no mid to divide by + const arm = Math.abs(row - mid) / (mid || 1); + const along = col / (cols - 1 || 1); + const v = arm * 0.6 + along * 0.4; + // ease-in + return v * v; +}; + +/** + * RINGS expanding from a dot the visitor chooses. + * + * Chebyshev distance — `max(|dc|, |dr|)` — rather than Euclidean, and that choice is the + * whole character: on an integer lattice the Chebyshev radius takes only a handful of + * distinct values, so every dot on a ring shares one phase and the field reads as discrete + * bands. Euclidean would give nearly every dot its own value and turn the same traversal + * into a smooth travelling wave. The concentric `ring` preset is this function with the + * origin pinned to the middle; here the origin is a parameter, which is the request the + * library's own table could not express. + * + * Normalised by the largest distance the lattice can hold, so the outermost dot leads at 1 + * whatever the grid size — otherwise a 9×9 and a 3×3 would animate at different rates for + * the same nominal shape. + */ +export function ringsFrom(origin: { col: number; row: number }): OrderFn { + return (col, row, cols, rows) => { + const d = Math.max(Math.abs(col - origin.col), Math.abs(row - origin.row)); + // The furthest cell in the lattice FROM THE ORIGIN, which is whichever corner is + // diagonally opposite it. Normalising by a fixed denominator — `cols`, or + // `max(cols, rows)` — is the tempting one-liner and it is wrong twice over: it makes + // the outermost dot's value depend on the GRID SIZE rather than on the origin, so a + // 9×9 and a 3×3 animate at different rates for the same nominal shape, and it leaves + // the last ring short of 1, so the wave never quite arrives. + const far = Math.max(origin.col, cols - 1 - origin.col, origin.row, rows - 1 - origin.row); + return far > 0 ? Math.min(1, d / far) : 0; + }; +} + +/** the cell nearest a click, so a visitor can pick the origin with a pointer */ +export function nearestCell( + at: { x: number; y: number }, + pitch: { x: number; y: number }, + cols: number, + rows: number, +): { col: number; row: number } { + // the field is centred, so a click at (0,0) in field space is the top-left cell + const cx = (cols - 1) / 2; + const cy = (rows - 1) / 2; + const col = Math.round(cx + (at.x / pitch.x) * cx); + const row = Math.round(cy + (at.y / pitch.y) * cy); + return { + col: Math.min(cols - 1, Math.max(0, col)), + row: Math.min(rows - 1, Math.max(0, row)), + }; +} diff --git a/apps/site/test/i18n.test.ts b/apps/site/test/i18n.test.ts index 01d7f73..f40ee7a 100644 --- a/apps/site/test/i18n.test.ts +++ b/apps/site/test/i18n.test.ts @@ -97,6 +97,7 @@ describe("the message catalogue", () => { // scan silently stops covering half the site "components/ShowcaseCopy.tsx", "components/ColorPicker.tsx", + "components/TraversalDemo.tsx", "i18n.ts", "components/Home.astro", "layouts/Base.astro", diff --git a/apps/site/test/traversal.test.ts b/apps/site/test/traversal.test.ts new file mode 100644 index 0000000..4d545bb --- /dev/null +++ b/apps/site/test/traversal.test.ts @@ -0,0 +1,179 @@ +/** + * The `order` examples on the page. + * + * These are the only place the `order` seam is visible to a visitor, so the failure this + * guards against is not a wrong number — it is a demo that renders nothing, or renders the + * PRESET's motion while claiming to render something else, which is precisely the failure + * that made `order` worth having in the first place. + */ +import { describe, expect, it } from "vitest"; +import { field, type OrderFn } from "@botharness/botui-core"; +import { chevron, nearestCell, ringsFrom } from "../src/traversals.js"; + +/** the leading cell of a rendered field — the one whose traversal value is lowest */ +const lead = (order: OrderFn, cols = 9, rows = 9) => { + const f = field({ cols, rows, silhouette: "square", renderer: "svg", order }, 0); + const t = f.reduce((a, b) => (b.order < a.order ? b : a)); + return { col: t.col, row: t.row, order: t.order }; +}; + +describe("the chevron example", () => { + it("leads from the spine, which is what makes it a chevron", () => { + const { row } = lead(chevron); + expect(row, "the fold, not a corner or a sweep").toBe(4); + }); + + it("accelerates — the claim the prose makes, checked against the numbers", () => { + const f = field({ cols: 9, rows: 9, silhouette: "square", renderer: "svg", order: chevron }, 0); + const at = (col: number, row: number) => f.find((d) => d.col === col && d.row === row)!.order; + const steps = [0, 1, 2, 3].map((c) => at(c, 4) - (c > 0 ? at(c - 1, 4) : 0)); + for (let i = 1; i < steps.length; i++) { + expect(steps[i]!, `step ${i} should exceed step ${i - 1}`).toBeGreaterThan(steps[i - 1]!); + } + }); + + it("survives a 1-row field, which a pasted traversal divides by zero on", () => { + const f = field({ cols: 5, rows: 1, silhouette: "square", renderer: "svg", order: chevron }, 0); + expect(f).toHaveLength(5); + expect(f.every((d) => Number.isFinite(d.order))).toBe(true); + }); +}); + +describe("the rings example", () => { + it("expands from the chosen cell, not from the middle", () => { + expect(lead(ringsFrom({ col: 4, row: 4 })).order, "the origin leads").toBe(0); + const corner = lead(ringsFrom({ col: 0, row: 0 })); + expect(corner.col, "move the origin and the light moves with it").toBe(0); + expect(corner.row).toBe(0); + }); + + it("is a ring, not a wave — Chebyshev gives every dot on a ring one phase", () => { + const f = field( + { + cols: 9, + rows: 9, + silhouette: "square", + renderer: "svg", + order: ringsFrom({ col: 4, row: 4 }), + }, + 0, + ); + // the diagonals: a Euclidean radius would give each of these its own value, a Chebyshev + // one gives them all the SAME value as their ring + const at = (col: number, row: number) => f.find((d) => d.col === col && d.row === row)!.order; + expect(at(4, 1), "(4,1) and (1,4) are on the same Chebyshev ring").toBe(at(1, 4)); + expect(at(4, 2)).toBe(at(2, 4)); + // 0 leads and 1 trails, so a FARTHER ring is a LARGER value + expect(at(4, 1), "ring 3 outranks ring 2").toBeGreaterThan(at(4, 2)); + expect(at(4, 2), "ring 2 outranks ring 1").toBeGreaterThan(at(4, 3)); + }); + + it("reaches 1 at the far corner for EVERY origin, not just the middle one", () => { + // The bug this catches: normalising by a fixed denominator, or by the distance to the + // nearest edge, leaves the last ring short of 1 whenever the origin is off-centre — so + // the wave never quite arrives, and by an amount that depends on where you clicked. + for (const origin of [ + { col: 0, row: 0 }, + { col: 8, row: 0 }, + { col: 0, row: 8 }, + { col: 4, row: 4 }, + { col: 2, row: 6 }, + ]) { + const f = field( + { + cols: 9, + rows: 9, + silhouette: "square", + renderer: "svg", + order: ringsFrom(origin), + }, + 0, + ); + expect(Math.max(...f.map((d) => d.order)), `origin ${origin.col},${origin.row}`).toBe(1); + } + }); + + it("normalises so the outermost dot leads at 1 whatever the grid", () => { + // otherwise a 9×9 and a 3×3 would animate at different rates for the same nominal shape + for (const n of [3, 5, 9, 16]) { + const f = field( + { + cols: n, + rows: n, + silhouette: "square", + renderer: "svg", + order: ringsFrom({ col: Math.floor(n / 2), row: Math.floor(n / 2) }), + }, + 0, + ); + const far = Math.max(...f.map((d) => d.order)); + expect(far, `${n}×${n}`).toBeCloseTo(1, 6); + } + }); + + it("does not divide by zero when the origin is the only cell", () => { + const f = field( + { + cols: 1, + rows: 1, + silhouette: "square", + renderer: "svg", + order: ringsFrom({ col: 0, row: 0 }), + }, + 0, + ); + expect(f[0]!.order).toBe(0); + }); +}); + +describe("the code shown beside the field", () => { + it("starts at the first export, not at the file's opening comment", async () => { + // A visitor's first question is what to copy. Five paragraphs of rationale about why + // these examples exist is for us, and scrolling past it to reach the function is the + // wrong first impression of a code sample. + const raw = (await import("../src/traversals.ts?raw")).default as string; + const shown = raw.slice(raw.indexOf("export")); + expect(shown.startsWith("export"), "and it is a real slice of this file").toBe(true); + expect(shown).toContain("export const chevron"); + expect(shown).toContain("export function ringsFrom"); + // the header comment is where it starts, so slicing must exclude it + expect(shown, "the file header is not shown").not.toContain( + "Two traversals the library does not ship", + ); + // and nothing was lost that a copy needs: the type import is the one thing that is + expect(shown).not.toContain("import type"); + }); + + it("cannot drift from the code that runs, because it IS that code", async () => { + // The whole reason for `?raw` rather than a hand-maintained string: a copied snippet of + // a function is a snippet that lies the first time someone edits the function. + const raw = (await import("../src/traversals.ts?raw")).default as string; + expect(raw, "the raw import is the file's own text").toContain("const arm = Math.abs"); + expect(raw).toContain("return v * v;"); + }); +}); + +describe("picking the origin by clicking", () => { + it("maps a click near the centre to the centre cell", () => { + const pitch = 260 / 9; + expect(nearestCell({ x: 0, y: 0 }, { x: pitch, y: pitch }, 9, 9)).toEqual({ col: 4, row: 4 }); + }); + + it("maps the top-left corner to cell 0,0", () => { + const pitch = 260 / 9; + const half = 260 / 2; + expect(nearestCell({ x: -half, y: -half }, { x: pitch, y: pitch }, 9, 9)).toEqual({ + col: 0, + row: 0, + }); + }); + + it("clamps a click past the edge rather than indexing out of the lattice", () => { + // The dot marker is drawn INSIDE the field box, so a click near the border can land a + // little beyond the last cell. An unclamped index would select nothing and the demo + // would silently stop responding at exactly the edge a visitor aims for. + const pitch = 260 / 9; + const far = { x: 9999, y: 9999 }; + expect(nearestCell(far, { x: pitch, y: pitch }, 9, 9)).toEqual({ col: 8, row: 8 }); + }); +});