diff --git a/CHANGELOG.md b/CHANGELOG.md index e52d7a7..c451627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,25 @@ he copy button said "Your settings, as props — only what you changed", and the ### Added +- **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 + // a chevron that speeds up toward its tip + const chevron = (col, row, cols, rows) => { + const mid = (rows - 1) / 2; + const arm = Math.abs(row - mid) / (mid || 1); + const v = arm * 0.6 + (col / (cols - 1 || 1)) * 0.4; + return v * v; + }; + createDotMatrix(el, { cols: 9, rows: 9, preset: "morph", order: chevron }); + ``` + + **It costs nothing in the CSS renderer** — the host derives each cell's `animation-delay` from this same value, so a custom traversal is still zero JS per frame. That is the asymmetry worth knowing about: a custom _traversal_ is free in both renderers, while a custom _envelope_ would not be, because the CSS animation is a shared `@keyframes` published per preset. + + **It is a function, so it is not copyable.** A component carrying a custom traversal is a component whose motion is code; the playground's copy button omits it rather than pretending a function is a value. + + The README's example is executed by a test — including on 1×1, 1×7 and 7×1 grids, which is where a pasted snippet divides by zero — and including a check that it really accelerates, since an easing that did nothing would still run and still look plausible. + - the swatch is resolved in an effect, and the ref is actually ON the element. Both got broken while editing this and nothing failed: a ref that is not attached reads as null, the effect returns early, and `?? "#000000"` does the rest — so a fallback that HIDES a missing ref is itself part of the defect. jsdom does not resolve `color` into `getComputedStyle`, so the inherited branch is only observable in a browser; what the test pins is the contract that let it slip, namely that the swatch is never handed the literal string. - the swatch is resolved in an effect, not from a ref. A ref is null during the render that needs it, so the first version always fell through to black — a swatch reporting black for dots that are not black — and `input[type=color]` silently ignores a non-hex value, keeping a stale colour instead. There is now a test that the swatch is always handed a form it will render. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 9a8d682..341c453 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -47,6 +47,25 @@ Playground、双语文档与 registry 迁到 Cloudflare Pages 上的 Astro + Rea ### 新增 +- **现在可以提供你自己的遍历。** `order` 接受一个 `OrderFn`,替换 preset 自带的 —— 这是分层设计一直留着的接缝,终于能从 option 到达,而不再只存在于自定义渲染器里。你能按格计算的任何东西,都能变成动效: + + ```ts + // 一个向尖端加速的箭头 + const chevron = (col, row, cols, rows) => { + const mid = (rows - 1) / 2; + const arm = Math.abs(row - mid) / (mid || 1); + const v = arm * 0.6 + (col / (cols - 1 || 1)) * 0.4; + return v * v; + }; + createDotMatrix(el, { cols: 9, rows: 9, preset: "morph", order: chevron }); + ``` + + **在 CSS 渲染器下零成本** —— host 就是从同一个值导出每格的 `animation-delay`,所以自定义遍历在那里依然是每帧零 JS。这是值得知道的不对称之处:自定义**遍历**在两个渲染器下都免费,而自定义**包络**不会 —— CSS 动效是按 preset 全局发布的共享 `@keyframes`。 + + **它是函数,因此无法被复制。** 携带自定义遍历的组件,其动效就是代码;playground 的复制按钮会略过它,而不是假装函数是个值。 + + README 里的示例由测试实际执行 —— 包括 1×1、1×7 和 7×1 这些一粘贴就会除零的网格 —— 并且验证它**确实**在加速,因为一个什么都不做的缓动照样能跑、看起来也照样合理。 + - 色块在 effect 里解析,而且 ref 确实挂在元素上。两者都在编辑过程中被弄坏过,且没有任何检查失败:没挂上的 ref 读出来是 null,effect 提前返回,`?? "#000000"` 兜住了剩下的部分 —— 所以一个**掩盖**了 ref 缺失的兜底,本身就是缺陷的一部分。jsdom 不会把 `color` 解析进 `getComputedStyle`,因此继承分支只能在浏览器里观察;测试锁定的是让它溜过去的那条契约:色块永远不会拿到那个字面字符串。 - 色块在 effect 里解析,而不是从 ref。ref 在需要它的那次渲染里必然为 null,所以第一版总是回落到黑色 —— 一个为一群并非黑色的点报告黑色的色块 —— 而且 `input[type=color]` 会静默忽略非 hex 的值,改为保留上一个陈旧颜色。现在有一条测试断言色块拿到的永远是它能渲染的形式。 diff --git a/packages/core/README.md b/packages/core/README.md index 064ffda..a6b9aeb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -59,6 +59,38 @@ Both are driven by the same `field(options, t)`, a pure function of the options | **Glyph** | how one dot is drawn — a polygon plus two corner radii | | **Renderer** | SVG or CSS | +### Your own traversal + +The **Traversal** layer is a plain function of the lattice position returning `0…1`, so +anything you can compute per cell you can animate. Pass it as `order` and it replaces the +preset's: + +```ts +// a chevron that speeds up toward its tip: the traversal value is eased, so successive +// dots along each arm start closer together in phase than a linear ramp would +const chevron = (col: number, row: number, cols: number, rows: number) => { + const mid = (rows - 1) / 2; + const arm = Math.abs(row - mid) / (mid || 1); // 0 at the spine, 1 at the tips + const along = col / (cols - 1 || 1); + const v = arm * 0.6 + along * 0.4; // down one arm, then along the other + return v * v; // ease-in: faster toward the tip +}; + +createDotMatrix(el, { cols: 9, rows: 9, preset: "morph", order: chevron }); +``` + +Two things worth knowing: + +- **It works in both renderers.** The CSS host derives each cell's `animation-delay` from + this same value, so a custom traversal costs zero per-frame JS there as well. +- **It is a function, so it is not copyable.** A component carrying a custom traversal is a + component whose motion is code — keep it in the source. The playground's copy button + omits it rather than pretending a function is a value. + +`direction` arrives as a **fifth** argument, so a traversal with no sense of direction can +stay four-argument and ignore it, and one that does can read +`dir?.includes("rightToLeft")` without re-deriving anything. + The full option list, and the reasoning behind the size algebra, are in [`src/types.ts`](https://github.com/BotHarness/BotUI/blob/main/packages/core/src/types.ts). diff --git a/packages/core/src/dot-matrix/field.ts b/packages/core/src/dot-matrix/field.ts index fcf3de9..320fae1 100644 --- a/packages/core/src/dot-matrix/field.ts +++ b/packages/core/src/dot-matrix/field.ts @@ -37,7 +37,9 @@ export function resolveField(options: DotMatrixOptions = {}): ResolvedField | nu return { options: o, preset, - order: orderFor(preset.order), + // the caller's own traversal wins over the preset's, and `order` stays undefined + // otherwise rather than being filled in here — see ResolvedDotMatrixOptions + order: o.order ?? orderFor(preset.order), env: ENVELOPES[preset.env], spread: o.stagger ?? preset.spread, glyph: glyphPath(specFor(o.dot, o.spec)), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8870253..253e979 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -198,6 +198,36 @@ export interface DotMatrixOptions { gapX?: number; gapY?: number; preset?: PresetName; + /** + * YOUR OWN TRAVERSAL, replacing the preset's. This is the seam the whole layered design + * leaves open: which dot leads, and in what order, is one function of the lattice + * position, so anything you can compute per cell you can animate. + * + * ```ts + * // a chevron that speeds up toward its tip: the traversal value is eased, so + * // successive dots along the V start closer together in phase + * order: (col, row, cols, rows) => { + * const mid = (rows - 1) / 2; + * const across = Math.abs(row - mid) / (mid || 1); // 0 at the spine, 1 at the tips + * const along = col / (cols - 1 || 1); + * const v = across * 0.5 + along * 0.5; // down one arm, along the other + * return v * v; // ease-in: faster toward the tip + * } + * ``` + * + * The function is called once per cell per frame (SVG) or once per cell when the host is + * built and again whenever it is re-ranked (CSS), so it should be cheap and pure. + * + * `dir` arrives as a FIFTH argument — see `OrderFn`. A four-argument function still works + * and simply ignores it, which is the common case: a custom traversal that has no + * direction of its own should not be forced to accept one. + * + * It is a FUNCTION, and that has one consequence worth stating: it cannot be serialised, + * so the playground's copy-your-settings button leaves it out rather than pretending. + * A component carrying a custom traversal is a component whose motion is code — keep the + * copyable settings for what the library can express, and keep this in the source. + */ + order?: OrderFn; /** * Which way the light travels, as one token per axis. OMITTED or empty is the motion's * own direction — every existing call means that, so this is backward compatible. @@ -232,9 +262,18 @@ export interface DotMatrixOptions { reducedMotion?: boolean; } -/** A dot matrix with every option filled in. */ -export type ResolvedDotMatrixOptions = Required> & { +/** + * A dot matrix with every option filled in. + * + * `order` is omitted from the `Required` for the same reason `stagger` is: absent means + * "use the preset's own", which is not a value the resolver can fill in with anything — + * there is no default traversal, because a default would be one more thing silently + * overriding the caller. It stays `undefined` until a caller supplies one, and + * `resolveField` is where the preset's traversal takes over. + */ +export type ResolvedDotMatrixOptions = Required> & { stagger: number | null; + order?: OrderFn; }; /** The geometry of a field, in px. Both renderers are driven from this. */ diff --git a/packages/core/test/custom-order.test.ts b/packages/core/test/custom-order.test.ts new file mode 100644 index 0000000..e357fda --- /dev/null +++ b/packages/core/test/custom-order.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +/** + * A caller's own traversal. + * + * This is the seam the layered design leaves open — which dot leads is one function of + * lattice position — and a seam is only real if BOTH renderers use it. Every test here + * exists because the failure mode is not a crash: a custom traversal that reaches the SVG + * path and is quietly ignored by the CSS one produces a field that is correct in one + * renderer and wrong in the other, which is exactly the pair of renderers that are supposed + * to be unable to disagree. + */ +import { describe, expect, it } from "vitest"; +import { + buildCss, + createDotMatrix, + field, + resolveField, + resolveOptions, + type DotMatrixOptions, + type OrderFn, +} from "../src/index.js"; + +/** a traversal nothing in the library happens to implement: distance from the top-left */ +const fromCorner: OrderFn = (col, row, cols, rows) => + cols * rows > 1 ? (col + row) / (cols * rows - 1) : 0; + +/** the leading cell of a rendered field, by its lattice address */ +const leader = (options: DotMatrixOptions) => { + const f = field({ cols: 4, rows: 4, silhouette: "square", renderer: "svg", ...options }, 0); + const top = f.reduce((a, b) => (b.order < a.order ? b : a)); + return `${top.col},${top.row}`; +}; + +describe("a caller's own traversal", () => { + it("replaces the preset's, rather than adding to it", () => { + const custom = field({ cols: 5, rows: 5, order: fromCorner, renderer: "svg" }, 0); + const preset = field({ cols: 5, rows: 5, renderer: "svg" }, 0); + // `field()` rounds to 4 places for the public record, so the comparison does too — + // otherwise this asserts the ROUNDING rather than the traversal + const r4 = (n: number) => Math.round(n * 1e4) / 1e4; + expect(custom.map((d) => d.order)).toEqual( + custom.map((d) => r4(fromCorner(d.col, d.row, 5, 5))), + ); + expect(custom.map((d) => d.order)).not.toEqual(preset.map((d) => d.order)); + }); + + it("reaches the CSS renderer, which is the default one", () => { + // The whole reason this is a test and not a comment: the CSS host bakes `--botui-o` + // into each cell, so an order the engine forgot to apply would produce two renderers + // that disagree — the exact failure this library is arranged to make impossible. + // `square` explicitly: the default is `circle`, which keeps 13 of 25 cells, and a + // comparison against a full lattice would then be comparing different grids + const host = buildCss({ + cols: 5, + rows: 5, + silhouette: "square", + order: fromCorner, + renderer: "css", + })!; + const got = [...host.children].map((c) => + Number((c as HTMLElement).style.getPropertyValue("--botui-o")), + ); + // `--botui-o` is written with three decimals by the host, so compare at that precision + expect(got).toEqual( + [...Array(25)].map((_, i) => Number(fromCorner(i % 5, Math.floor(i / 5), 5, 5).toFixed(3))), + ); + }); + + it("moves the LIGHT, not just the numbers", () => { + // A traversal that is merely different is not necessarily a different motion. This one + // leads at the top-left corner, where the default leads at the middle of a spiral. + expect(leader({ order: fromCorner }), "leads at a corner").toBe("0,0"); + }); + + it("is optional, and absent means the preset's own", () => { + const withNone = field({ cols: 5, rows: 5, renderer: "svg" }, 0); + const withUndefined = field({ cols: 5, rows: 5, order: undefined, renderer: "svg" }, 0); + expect(withNone.map((d) => d.order)).toEqual(withUndefined.map((d) => d.order)); + // and the preset still supplies one + expect(resolveField({ cols: 5, rows: 5 })!.order).toBeTypeOf("function"); + expect(resolveField({ cols: 5, rows: 5, order: fromCorner })!.order).toBe(fromCorner); + }); + + it("receives `direction`, and a four-argument function simply ignores it", () => { + // Both shapes are legal: a traversal with no sense of direction should not be forced to + // accept a parameter, and one that does should not have to re-derive it. + const five: OrderFn = (col, _row, cols, _rows, dir) => + dir?.includes("rightToLeft") ? 1 - col / (cols - 1) : col / (cols - 1); + const four: OrderFn = (col, _row, cols) => col / (cols - 1); + + /** + * Which end of a single row LEADS — read from the traversal value, not from the + * brightest dot. The two are not the same question: brightness also depends on where + * the cycle happens to be, so at any one frame the leading cell need not be the + * brightest one. + */ + const leadsAt = (fn: OrderFn, dir: DotMatrixOptions["direction"]) => { + const f = field( + { cols: 5, rows: 1, silhouette: "square", order: fn, direction: dir, renderer: "svg" }, + 0, + ); + return f.reduce((a, b) => (b.order < a.order ? b : a)).col; + }; + + expect(leadsAt(five, []), "left by default").toBe(0); + expect(leadsAt(five, ["rightToLeft"]), "a five-argument traversal can act on it").toBe(4); + expect(leadsAt(four, ["rightToLeft"]), "a four-argument one ignores it").toBe(0); + }); + + it("is re-ranked when it changes on a LIVE CSS host", () => { + // The host bakes `--botui-o` into each cell once. `set()` re-ranks in place, which only + // works if the re-rank reads the CURRENT traversal — so this drives the real + // `createDotMatrix`, not `buildCss`, because the failure is a stale host and a stale + // host is a lifecycle question. + const element = document.createElement("div"); + const dm = createDotMatrix(element, { + cols: 4, + rows: 4, + silhouette: "square", + renderer: "css", + order: fromCorner, + }); + const read = () => + [...element.querySelectorAll(".botui-dot-matrix > i")].map((c) => + (c as HTMLElement).style.getPropertyValue("--botui-o"), + ); + expect(read()[0], "leads at the corner").toBe("0.000"); + + dm.set({ order: (col, row, cols, rows) => 1 - fromCorner(col, row, cols, rows) }); + expect(read()[0], "and the mounted host followed it").toBe("1.000"); + + dm.set({ order: undefined }); + expect(read()[0], "and handing control back returns it to the preset's traversal").not.toBe( + "1.000", + ); + dm.destroy(); + }); + + it("does not appear in a resolved options copy as a default", () => { + // `resolveOptions` fills in everything else, and filling THIS in would mean inventing a + // default traversal — one more thing silently overriding the caller. + expect(resolveOptions({}).order).toBeUndefined(); + }); +}); diff --git a/packages/core/test/readme.test.ts b/packages/core/test/readme.test.ts new file mode 100644 index 0000000..a352140 --- /dev/null +++ b/packages/core/test/readme.test.ts @@ -0,0 +1,75 @@ +/** + * The examples in the README. + * + * A README example that does not compile is worse than no example: it is the first thing + * a reader runs, and it is the thing they will judge the API by. Both snippets here are + * lifted verbatim, so they cannot rot independently of the prose describing them. + */ +import { describe, expect, it } from "vitest"; +import { field, type OrderFn } from "../src/index.js"; + +const chevron: OrderFn = (col, row, cols, rows) => { + const mid = (rows - 1) / 2; + const arm = Math.abs(row - mid) / (mid || 1); + const along = col / (cols - 1 || 1); + const v = arm * 0.6 + along * 0.4; + return v * v; +}; + +describe("the README's own traversal", () => { + it("runs, and produces a traversal rather than a crash", () => { + // `square` explicitly: the default silhouette is `circle`, which keeps 49 of 81 cells + const f = field( + { + cols: 9, + rows: 9, + silhouette: "square", + preset: "morph", + order: chevron, + renderer: "svg", + }, + 0, + ); + expect(f).toHaveLength(81); + for (const d of f) { + expect(d.order, `${d.col},${d.row}`).toBeGreaterThanOrEqual(0); + expect(d.order).toBeLessThanOrEqual(1); + expect(Number.isFinite(d.opacity), "and a real brightness").toBe(true); + } + }); + + it("survives the degenerate grids a reader will paste it into", () => { + // `mid || 1` and `cols - 1 || 1` are in the snippet precisely because a 1-row or + // 1-column field divides by zero — the kind of thing that only shows up after somebody + // has already copied the code. + for (const [cols, rows] of [ + [1, 1], + [1, 7], + [7, 1], + [3, 3], + [2, 9], + ]) { + const f = field({ cols, rows, silhouette: "square", order: chevron, renderer: "svg" }, 0); + expect(f.length, `${cols}x${rows}`).toBe(cols * rows); + expect( + f.every((d) => Number.isFinite(d.order)), + `${cols}x${rows}`, + ).toBe(true); + } + }); + + it("actually accelerates toward the tips, which is what the prose claims", () => { + // If the easing were a no-op the example would still run and still look plausible, so + // the claim in the text has to be checked against the numbers it produces. + const f = field({ cols: 9, rows: 9, silhouette: "square", order: chevron, renderer: "svg" }, 0); + const at = (col: number, row: number) => f.find((d) => d.col === col && d.row === row)!.order; + // down the spine, the value rises; and it rises SLOWLY then FASTLY, which is the ease-in + 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} is not larger than step ${i - 1}`).toBeGreaterThan( + steps[i - 1]!, + ); + } + expect(at(8, 4), "and the tip is the far end").toBeGreaterThan(at(0, 4)); + }); +});