From c18ab481bcedab5ff509098ccfd8e265313cb7c9 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Fri, 2 Oct 2026 14:47:29 +0900 Subject: [PATCH] fix: the drag now tracks the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as: it should go where you click, but the handle drifts further out the further out you drag. That growth with distance is the diagnostic — a constant offset and a proportional one look identical at the centre and nothing alike at the rim. THE CONVERSION WAS WRONG TWICE, in the two ways that compound: · the pitch was `box.width / cols`, which is not the pitch. The field's box is `size` px and includes one dot of overhang at each end, so the centre-to-centre distance is `layout().pitchX`. The hand-rolled figure was about 3% out before anything else. · and the centre-to-cell step multiplied by the half-width a SECOND time. `cx` appears once to name the middle cell and again as a scale factor, so the whole thing was four times too sensitive. Measured on a 9×9 at 260px, the error against the true cell: 0 at the centre, 6 cells at col 6, and 12 cells — clamped — at the rim. That is the reported behaviour exactly: the handle reaches the edge while the cursor is halfway out, and dragging further does nothing, because there is nothing past the clamp. BOTH NUMBERS NOW COME FROM THE ENGINE'S `layout()`. That is the library's own rule — `field()` is the only place geometry is computed — applied to a control that has to agree with the thing it is pointing at. And `cellCentre` is now SHARED: the handle is drawn where the hit-test will look, so they cannot drift apart, which is a stronger guarantee than both being correct. THE HANDLE IS POSITIONED IN PX, not as a percentage of the box. A cell's centre is half a dot in from the edge, so `col / (cols - 1)` as a percentage is wrong by half a dot — 16px here at the rim — and wrong by a different amount everywhere else. The overlay also moved inside a wrapper sized by the field, so `inset: 0` IS the host box. THE REGRESSION WALKS ALL 81 CELLS and requires the identity, plus a test that the error does not grow outward. A hand-picked sample of cells passes the old code: it is only wrong past the middle, and every sample anyone would pick by hand is in the middle. 275 tests (was 269), oxlint 0, registry ok, both changelog ledgers. --- CHANGELOG.md | 4 + CHANGELOG.zh.md | 4 + apps/site/src/components/TraversalDemo.tsx | 96 +++++++++++++--------- apps/site/src/styles/site.css | 15 ++-- apps/site/src/traversals.ts | 56 +++++++++++-- apps/site/test/traversal.test.ts | 79 ++++++++++++++---- 6 files changed, 184 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 313b83c..2000d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,6 +146,10 @@ he copy button said "Your settings, as props — only what you changed", and the ### Fixed +- **The drag now tracks the cursor.** The handle drifted further out the further you dragged, which is the symptom of a conversion that was wrong twice over: the pitch was `box.width / cols`, which is not the pitch — the field's box is `size` px including one dot of overhang at each end — and the centre-to-cell step multiplied by the half-width a _second_ time, making the whole thing four times too sensitive. At the rim of a 9-wide field that is a twelve-cell error, clamped: the handle sat at the edge while the cursor was halfway out, and dragging further changed nothing. +- Both numbers now come from the engine's own `layout()`, and `cellCentre` is shared by the handle and the hit-test, so "the handle is under the cursor" is true by construction rather than by two approximations agreeing. The handle is positioned in px from the same geometry rather than as a percentage of the box, because a cell's centre is half a dot in from the edge — a percentage put it 16px out at this size. +- The regression is a test that walks **all 81 cells** and requires the mapping to be the identity, plus one that measures the error growing outward, since "the error grows with distance" is exactly what a hand-picked sample of cells misses. + - **Fixed: `Column Drop` still showed nothing, and this time the field was being clipped rather than missing.** The previous fix made the motion render, and it _was_ rendering — 25 cells, animating, one column lit at a time. The stage's controls column was an `auto` track, and `auto` is sized by max-content, so the refusal caption ("this motion is a highlight moving down the field…", shown only when the CSS renderer refuses the chosen motion) grew that track from **264px to 602px** and squeezed the field's own track to **zero**. The stage is `overflow: hidden`, so the field was not squashed — it was clipped out of existence. The tracks are capped now, and the caption wraps inside the cap instead of widening it. - **This one has no automated test, on purpose.** jsdom computes no layout, so no test in this repo can catch a collapsed track, and the first attempt at one was worse than nothing: it regexed the stylesheet, matched a **different** component's `repeat(auto-fill, …)`, and stayed green with the bug present. A test that asserts about the wrong rule is not a slow test, it is a wrong one. `AGENTS.md` now records that this class of check needs a real browser over CDP, with the `--user-data-dir` detail that cost the most time to work out. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index e7208ef..a9db8b0 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -124,6 +124,10 @@ Playground、双语文档与 registry 迁到 Cloudflare Pages 上的 Astro + Rea ### 修复 +- **拖拽现在跟手了。** 把手越往外拖偏得越多,这个症状来自一个错了两次的换算:pitch 用了 `box.width / cols`,而那不是 pitch —— 场的 box 是 `size` px,两端各含一个点的外伸;而中心到格的那一步又**第二次**乘了半宽,使整体灵敏度变成 4 倍。在 9 格宽场的边缘那是 12 格的误差,被 clamp 掉:光标还在半路把手就已在边缘,再往外拖毫无变化。 +- 两个数现在都取自引擎自己的 `layout()`,且把手与命中测试**共用** `cellCentre`,于是"把手就在光标下"由构造保证,而不是靠两处近似恰好一致。把手用同一套几何以 px 定位,而不是场的百分比 —— 因为格的中心是从边缘内半个点的位置,百分比在此尺寸下会偏 16px。 +- 回归测试**遍历全部 81 格**要求映射是恒等的,另有一条测量误差是否随距离增长,因为"误差随距离变大"正是手挑几个格子会漏掉的东西。 + - **修复:`Column Drop` 仍然什么都看不到,而这次场是被裁掉了而不是缺失。** 上一个修复让这个动效真的渲染出来了,而且它**确实**在渲染 —— 25 个格子、在动、每次点亮一列。舞台的控件列是 `auto` 轨道,而 `auto` 按 max-content 定尺寸,于是那条拒绝提示("这个动效是高亮沿着场移动…" —— 只在 CSS 渲染器拒绝当前动效时显示)把该轨道从 **264px 撑到 602px**,把场自己的轨道挤到 **0**。舞台是 `overflow: hidden`,所以场不是被压扁,而是被整个裁掉。轨道现在设了上限,提示在上限内换行,而不是把列撑宽。 - **这一条故意没有自动化测试。** jsdom 完全不算布局,所以本仓库里没有任何测试能抓住塌掉的轨道;而我写的第一版比没有更糟:它用正则去匹配样式表,匹配到了**另一个**组件的 `repeat(auto-fill, …)`,于是在 bug 存在时依然全绿。能断言到错误规则的测试不是慢的测试,是错的测试。`AGENTS.md` 现在记下了这类检查需要用 CDP 驱动真实浏览器,并写下了最耗时间才弄明白的 `--user-data-dir` 细节。 diff --git a/apps/site/src/components/TraversalDemo.tsx b/apps/site/src/components/TraversalDemo.tsx index a63beee..c8e0a0b 100644 --- a/apps/site/src/components/TraversalDemo.tsx +++ b/apps/site/src/components/TraversalDemo.tsx @@ -1,7 +1,8 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { Matrix } from "./Matrix.js"; import { m } from "../i18n.js"; -import { chevron, nearestCell, ringsFrom } from "../traversals.js"; +import { layout } from "@botharness/botui-core"; +import { cellCentre, 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. @@ -14,6 +15,26 @@ const COPYABLE = traversalSource.slice(traversalSource.indexOf("export")); const N = 9; +/** + * The field's geometry, from the ENGINE. + * + * The same numbers `` lays out with, so the handle can be placed on a cell and the + * hit-test can be taken against it with no approximation in between. This is the library's + * own rule — `field()` is the only place geometry is computed — applied to a control that + * has to agree with the thing it is pointing at. + */ +const FIELD = { + cols: N, + rows: N, + silhouette: "square", + dot: "circle", + dotSize: 0.8, + gapX: 0.12, + gapY: 0.12, + size: 260, +} as const; +const GEO = layout(FIELD); + /** * The `order` seam, on the page. * @@ -37,35 +58,37 @@ export function TraversalDemo() { // 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 + // the handle's position, from the same geometry the hit-test uses + const at = cellCentre(GEO, origin); + const order = useMemo( () => (variant === "chevron" ? chevron : ringsFrom(origin)), [variant, origin], ); /** - * A pointer position, in lattice cells. + * A pointer position, as a cell. * - * The field is drawn from its centre outward in the engine's own px geometry, so the - * click has to be measured from the middle rather than from the corner. + * Measured from the field's CENTRE and divided by the engine's pitch — the identity, not + * an approximation. An earlier version used `box.width / cols` as the pitch and scaled by + * the half-width a second time, which made the control four times too sensitive: at the + * rim of a 9-wide field that is a twelve-cell error, so the handle saturated at the edge + * while the cursor was halfway out and dragging further did nothing. */ const move = useCallback((e: { clientX: number; clientY: number }) => { - const box = fieldRef.current?.getBoundingClientRect(); - if (!box) return; - const pitch = box.width / N; + const host = hostRef.current?.getBoundingClientRect(); + if (!host) return; setOrigin( nearestCell( - { - x: e.clientX - box.left - box.width / 2, - y: e.clientY - box.top - box.height / 2, - }, - { x: pitch, y: pitch }, + { x: e.clientX - host.left - host.width / 2, y: e.clientY - host.top - host.height / 2 }, + GEO, N, N, ), ); }, []); - const fieldRef = useRef(null); + const hostRef = useRef(null); return (
@@ -93,7 +116,6 @@ export function TraversalDemo() {
- - {variant === "rings" && ( - // The handle, drawn rather than described: a ring with a filled centre, because - // a 7px dot on a field of 81 dots reads as one more dot. The ring is what says - // "this is a thing you move". -
diff --git a/apps/site/src/styles/site.css b/apps/site/src/styles/site.css index 286c6bb..6bd8779 100644 --- a/apps/site/src/styles/site.css +++ b/apps/site/src/styles/site.css @@ -256,13 +256,18 @@ h3 { /* The centre handle: a ring with a filled middle. A 7px dot on a field of 81 dots reads as one more dot, and the RING is what says "this is a thing you move" — a filled disc says "this is a pixel", which is what it looked like before. */ -/* The overlay is positioned over the FIELD'S CONTENT BOX, not its padding box. `inset: 0` - would span the padding too, so a handle placed at 0% would sit in the padding rather than - on the first row of dots — which is exactly what it did before this was measured. The - field's own padding is the same 6px, so subtracting it here keeps the two in step. */ +/* Inside a wrapper sized by the field itself, so `inset: 0` IS the host box and a handle + placed in px lands on a cell rather than near one. The field's padding lives on the + OUTER element, which is what keeps the corner handle from being clipped without + perturbing anything the geometry depends on. */ +.traversal-stage { + position: relative; + line-height: 0; +} + .traversal-origin { position: absolute; - inset: 6px; + inset: 0; pointer-events: none; } diff --git a/apps/site/src/traversals.ts b/apps/site/src/traversals.ts index 8f26336..b9e1572 100644 --- a/apps/site/src/traversals.ts +++ b/apps/site/src/traversals.ts @@ -66,19 +66,61 @@ export function ringsFrom(origin: { col: number; row: number }): OrderFn { }; } -/** the cell nearest a click, so a visitor can pick the origin with a pointer */ +/** + * The cell a pointer is over, and the pixel position of a cell's centre. + * + * Both come from the ENGINE's own `layout()` numbers, and that is the whole point: the + * handle is drawn where this function says and the hit-test is taken against the same + * function, so "the handle is under the cursor" is true by construction rather than by two + * approximations agreeing. + * + * Two mistakes are written into the old version and are worth naming, because both produce + * a handle that drifts further out the further out you drag — which is exactly the symptom + * that sent this back: + * + * · the pitch was `box.width / cols`, which is not the pitch. The field's box is `size` + * px including one dot of overhang, so the centre-to-centre distance is + * `layout().pitchX` and the box/n is about 3% out before anything else is wrong; + * · and the centre-to-cell conversion multiplied by `(cols - 1) / 2` a second time, making + * the whole thing FOUR TIMES as sensitive as it should be. At the edge of a 9-wide field + * that is a twelve-cell error, clamped — so the handle sat at the rim while the cursor + * was halfway out, and dragging further changed nothing at all. + * + * The fixed version is the identity: a dot's centre is `dotPx/2 + col * pitchX` from the + * field's left edge, and a pointer at offset `x` from the field's centre is + * `x / pitchX` cells from the middle one. + */ +export interface FieldGeometry { + /** centre-to-centre distance in px, per axis */ + pitchX: number; + pitchY: number; + /** one dot's width in px — the overhang at each end of the box */ + dotPx: number; +} + +/** where a cell's centre sits, relative to the field's box */ +export function cellCentre( + geo: FieldGeometry, + cell: { col: number; row: number }, +): { x: number; y: number } { + return { + x: geo.dotPx / 2 + cell.col * geo.pitchX, + y: geo.dotPx / 2 + cell.row * geo.pitchY, + }; +} + +/** the cell whose centre is nearest a point given as an offset from the field's centre */ export function nearestCell( at: { x: number; y: number }, - pitch: { x: number; y: number }, + geo: FieldGeometry, 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); + const col = Math.round((cols - 1) / 2 + at.x / geo.pitchX); + const row = Math.round((rows - 1) / 2 + at.y / geo.pitchY); return { + // clamped, because a drag that leaves the field should park the origin at the rim + // rather than index off the lattice col: Math.min(cols - 1, Math.max(0, col)), row: Math.min(rows - 1, Math.max(0, row)), }; diff --git a/apps/site/test/traversal.test.ts b/apps/site/test/traversal.test.ts index 9063c04..1e5ffe2 100644 --- a/apps/site/test/traversal.test.ts +++ b/apps/site/test/traversal.test.ts @@ -7,8 +7,8 @@ * 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"; +import { field, layout, type OrderFn } from "@botharness/botui-core"; +import { cellCentre, 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) => { @@ -176,26 +176,69 @@ describe("the code shown beside the field", () => { }); 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 }); + /** the same field the demo renders, so the numbers are the engine's real ones */ + const FIELD = { + cols: 9, + rows: 9, + silhouette: "square", + dot: "circle", + dotSize: 0.8, + gapX: 0.12, + gapY: 0.12, + size: 260, + } as const; + const GEO = layout(FIELD); + /** a pointer at a cell's centre, as an offset from the field's centre */ + const overCell = (col: number, row: number) => { + const c = cellCentre(GEO, { col, row }); + return { x: c.x - GEO.size / 2, y: c.y - GEO.size / 2 }; + }; + + it("is the IDENTITY — a pointer on a cell's centre selects that cell", () => { + // This is the whole complaint. The previous formula used `box.width / cols` as the + // pitch AND scaled by the half-width a second time, so it was four times too + // sensitive: at the rim of a 9-wide field that is a twelve-cell error, clamped, and the + // handle sat at the edge while the cursor was halfway out. `box.width / cols` is not + // even the pitch — the box is `size` px including a dot of overhang at each end. + for (let row = 0; row < 9; row++) { + for (let col = 0; col < 9; col++) { + expect(nearestCell(overCell(col, row), GEO, 9, 9), `pointer on ${col},${row}`).toEqual({ + col, + row, + }); + } + } }); - 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("does not drift as the pointer moves outward, which is what a drag reports", () => { + // The symptom, stated as the assertion: the error against the true cell must not grow + // with distance from the middle. + for (const col of [0, 1, 2, 3, 5, 6, 7, 8]) { + const got = nearestCell(overCell(col, 4), GEO, 9, 9); + expect(Math.abs(got.col - col), `pointer on col ${col}`).toBeLessThanOrEqual(1); + } + }); + + it("places the handle where the hit-test will look for it", () => { + // Both consume `cellCentre`, which is the point: the handle cannot be drawn somewhere + // the hit-test does not consider. + const c = cellCentre(GEO, { col: 7, row: 2 }); + expect(nearestCell({ x: c.x - GEO.size / 2, y: c.y - GEO.size / 2 }, GEO, 9, 9)).toEqual({ + col: 7, + row: 2, }); }); - 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 }); + it("puts the first cell's centre one half-dot from the box edge, not at it", () => { + // The overhang is half a dot at each end, so a cell centre is `dotPx/2` in. Placing the + // handle at 0% instead put it half a dot outside the first dot — 16px at this size. + expect(cellCentre(GEO, { col: 0, row: 0 }).x).toBeCloseTo(GEO.dotPx / 2, 6); + }); + + it("clamps a pointer past the edge rather than indexing off the lattice", () => { + // A drag that leaves the field should park the origin at the rim rather than select + // nothing — which is the first place anyone drags to. + expect(nearestCell({ x: 9999, y: 9999 }, GEO, 9, 9)).toEqual({ col: 8, row: 8 }); + expect(nearestCell({ x: -9999, y: -9999 }, GEO, 9, 9)).toEqual({ col: 0, row: 0 }); }); });