From 9254d0649b853914835c9a82b0a0fc7b4bad1127 Mon Sep 17 00:00:00 2001 From: EwdErna Date: Fri, 23 Oct 2026 07:54:53 +0900 Subject: [PATCH] =?UTF-8?q?mock:=20=E3=82=AD=E3=83=BC=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=82=92=E3=80=81=E9=81=B8=E6=8A=9E=E4=B8=AD=E3=81=AE=E3=82=AD?= =?UTF-8?q?=E3=83=BC=E3=81=AE=E8=A1=8C=E3=81=AE=E5=8F=B3=E7=AB=AF=E3=80=81?= =?UTF-8?q?=E3=81=BE=E3=81=9F=E3=81=AF=E6=96=B0=E3=81=97=E3=81=84=E8=A1=8C?= =?UTF-8?q?=E3=81=AE=E5=B7=A6=E7=AB=AF=E3=81=AB=E7=BD=AE=E3=81=8F(T-30=20?= =?UTF-8?q?=E5=8D=98=E4=BD=8D6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 行の判定は idx-editor-feedback.md の規則(そろっている軸は座標でまとめ、 ずれている軸は隣とのつながりで1対1にたどる)を domain/rows.ts に実装し、 IDX Editor でも使えるよう列の判定も合わせて置いた。 何も選んでいないときは、コンテナの全キーの下に新しい行を作り、その左端に置く。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XsamNKrWY3obJwR746oQRq --- mock/src/domain/rows.ts | 105 ++++++++++++++++++++++++++++++ mock/src/ui/phy/usePhyCommands.ts | 17 +++-- mock/tests/rows.test.ts | 78 ++++++++++++++++++++++ provisional/decisions.md | 6 +- 4 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 mock/src/domain/rows.ts create mode 100644 mock/tests/rows.test.ts diff --git a/mock/src/domain/rows.ts b/mock/src/domain/rows.ts new file mode 100644 index 0000000..8c64380 --- /dev/null +++ b/mock/src/domain/rows.ts @@ -0,0 +1,105 @@ +/** Axis-aligned key rectangle in one coordinate frame, in U. */ +export type KeyRect = { id: string; x: number; y: number; w: number; h: number }; + +const EPS = 1e-3; +/** Keys farther apart than this across adjacent lines are not linked. */ +const MAX_LINK = 1; + +type Point = { id: string; along: number; across: number }; + +/** + * Detects visual rows, top to bottom, each listing key ids left to right. + * The aligned axis is grouped by coordinate and the staggered axis is traced through + * adjacent lines (idx-editor-feedback.md). Keys are placed by their top-left corner, + * so tall keys belong to their top row and wide keys to their left column. + */ +export function detectRows(keys: KeyRect[]): string[][] { + return detectLines(keys, "row"); +} +/** Detects visual columns, left to right, each listing key ids top to bottom. */ +export function detectColumns(keys: KeyRect[]): string[][] { + return detectLines(keys, "column"); +} + +function detectLines(keys: KeyRect[], kind: "row" | "column"): string[][] { + const points: Point[] = keys.map((k) => + kind === "row" ? { id: k.id, along: k.x, across: k.y } : { id: k.id, along: k.y, across: k.x }, + ); + const rowsAligned = groupBy(keys, (k) => k.y).length <= groupBy(keys, (k) => k.x).length; + return (kind === "row") === rowsAligned ? groupLines(points) : traceLines(points); +} + +/** Clusters values within EPS, ordered by value. */ +function groupBy(items: T[], value: (item: T) => number): T[][] { + const sorted = [...items].sort((a, b) => value(a) - value(b)); + const groups: T[][] = []; + for (const item of sorted) { + const last = groups.at(-1); + if (last && value(item) - value(last.at(-1)!) <= EPS) last.push(item); + else groups.push([item]); + } + return groups; +} + +function groupLines(points: Point[]): string[][] { + return groupBy(points, (p) => p.across).map((line) => sortAlong(line).map((p) => p.id)); +} + +/** Links each pair of adjacent cross lines one-to-one by nearest `across`, then joins the links. */ +function traceLines(points: Point[]): string[][] { + const cross = groupBy(points, (p) => p.along); + const parent = new Map(points.map((p) => [p.id, p.id])); + const find = (id: string): string => { + const up = parent.get(id)!; + if (up === id) return id; + const root = find(up); + parent.set(id, root); + return root; + }; + for (let i = 0; i + 1 < cross.length; i++) { + const pairs = cross[i]!.flatMap((a) => + cross[i + 1]!.map((b) => ({ a, b, d: Math.abs(a.across - b.across) })), + ) + .filter((pair) => pair.d < MAX_LINK) + .sort((p, q) => p.d - q.d); + const used = new Set(); + for (const { a, b } of pairs) { + if (used.has(a.id) || used.has(b.id)) continue; + used.add(a.id); + used.add(b.id); + parent.set(find(b.id), find(a.id)); + } + } + const lines = new Map(); + for (const p of points) lines.set(find(p.id), [...(lines.get(find(p.id)) ?? []), p]); + return [...lines.values()] + .map(sortAlong) + .sort((a, b) => mean(a) - mean(b) || a[0]!.along - b[0]!.along) + .map((line) => line.map((p) => p.id)); +} + +function sortAlong(line: Point[]): Point[] { + return [...line].sort((a, b) => a.along - b.along); +} + +function mean(line: Point[]): number { + return line.reduce((sum, p) => sum + p.across, 0) / line.length; +} + +/** Position right after the last key of the row containing `keyId`. */ +export function rowEndPosition(keys: KeyRect[], keyId: string): { x: number; y: number } | undefined { + const row = detectRows(keys).find((line) => line.includes(keyId)); + if (!row) return undefined; + const byId = new Map(keys.map((k) => [k.id, k])); + const last = row.map((id) => byId.get(id)!).reduce((a, b) => (b.x + b.w > a.x + a.w ? b : a)); + return { x: last.x + last.w, y: last.y }; +} + +/** Leftmost position of a new row below every key. */ +export function newRowPosition(keys: KeyRect[]): { x: number; y: number } | undefined { + if (!keys.length) return undefined; + return { + x: Math.min(...keys.map((k) => k.x)), + y: Math.max(...keys.map((k) => k.y + k.h)), + }; +} diff --git a/mock/src/ui/phy/usePhyCommands.ts b/mock/src/ui/phy/usePhyCommands.ts index 2791573..98603b6 100644 --- a/mock/src/ui/phy/usePhyCommands.ts +++ b/mock/src/ui/phy/usePhyCommands.ts @@ -16,6 +16,7 @@ import { } from "../../domain/phyCommands"; import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade"; import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry"; +import { newRowPosition, rowEndPosition } from "../../domain/rows"; import { pasteLocalTransform, localBounds, treeIndex } from "./logic"; type Options = { @@ -122,13 +123,17 @@ export function usePhyCommands(options: Options) { function keyAdd() { const chosen = options.node(options.selected.value.at(-1) || ""); const parent = chosen ? options.selectedContainer(chosen.id) : insertionParent(); + const keys = nodesInContainer(parent).flatMap((node) => + node.type === "key" ? [{ id: node.id, x: node.transform.x, y: node.transform.y, ...node.size }] : [], + ); + const snap = (value: number) => Math.round(value / options.grid.value) * options.grid.value; const center = centerInParent(parent); - const x = - Math.round( - (chosen ? chosen.transform.x + (chosen.type === "key" ? chosen.size.w : 1) : center.x) / - options.grid.value, - ) * options.grid.value; - const y = Math.round((chosen ? chosen.transform.y : center.y) / options.grid.value) * options.grid.value; + const { x, y } = + chosen?.type === "key" + ? rowEndPosition(keys, chosen.id)! + : chosen + ? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.y) } + : (newRowPosition(keys) ?? { x: snap(center.x), y: snap(center.y) }); let id = ""; options.dispatch("キー追加", (project) => { const phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 }); diff --git a/mock/tests/rows.test.ts b/mock/tests/rows.test.ts new file mode 100644 index 0000000..9efc91c --- /dev/null +++ b/mock/tests/rows.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { detectColumns, detectRows, newRowPosition, rowEndPosition, type KeyRect } from "../src/domain/rows"; + +const key = (id: string, x: number, y: number, w = 1, h = 1): KeyRect => ({ id, x, y, w, h }); + +describe("row detection", () => { + it("groups row-staggered keys by y and traces columns", () => { + const keys = [ + key("tab", 0, 0, 1.5), + key("q", 1.5, 0), + key("w", 2.5, 0), + key("caps", 0, 1, 1.75), + key("a", 1.75, 1), + key("s", 2.75, 1), + key("space", 0.5, 2, 3), + ]; + expect(detectRows(keys)).toEqual([["tab", "q", "w"], ["caps", "a", "s"], ["space"]]); + expect(detectColumns(keys)[0]).toEqual(["tab", "caps", "space"]); + }); + + it("traces column-staggered rows through the nearest key of the next column", () => { + const offsets = [0.5, 0.25, 0, 0.25]; + const keys = offsets.flatMap((dy, col) => [0, 1, 2].map((row) => key(`c${col}r${row}`, col, row + dy))); + expect(detectRows(keys)).toEqual([0, 1, 2].map((row) => offsets.map((_, col) => `c${col}r${row}`))); + expect(detectColumns(keys)[2]).toEqual(["c2r0", "c2r1", "c2r2"]); + }); + + it("keeps rows apart when an outer column has fewer keys", () => { + const keys = [ + key("p1", 0, 1.5), + key("p2", 0, 2.5), + ...[0, 1, 2].map((row) => key(`r${row}`, 1, row + 1.25)), + ...[0, 1, 2, 3].map((row) => key(`i${row}`, 2, row + 1)), + ]; + expect(detectRows(keys)).toEqual([["p1", "r0", "i0"], ["p2", "r1", "i1"], ["r2", "i2"], ["i3"]]); + }); + + it("does not link keys a full row apart", () => { + const keys = [key("a0", 0, 0), key("a1", 0, 1), key("b1", 1, 1.1), key("b2", 1, 2.1)]; + expect(detectRows(keys)).toEqual([["a0"], ["a1", "b1"], ["b2"]]); + }); + + it("puts a tall key in its top row", () => { + const keys = [key("7", 0, 0), key("8", 1, 0), key("plus", 2, 0, 1, 2), key("4", 0, 1), key("5", 1, 1)]; + expect(detectRows(keys)).toEqual([ + ["7", "8", "plus"], + ["4", "5"], + ]); + }); + + it("links the inner columns across the gap between halves", () => { + const columns: [string, number, number][] = [ + ["l", 0, 0.5], + ["m", 1, 0.25], + ["n", 4, 0.25], + ["o", 5, 0.5], + ]; + const keys = columns.flatMap(([name, x, dy]) => + [0, 1, 2].map((row) => key(`${name}${row}`, x, row + dy)), + ); + expect(detectRows(keys)).toEqual([0, 1, 2].map((row) => columns.map(([name]) => `${name}${row}`))); + }); +}); + +describe("key insertion position", () => { + const keys = [key("a", 0, 0), key("b", 1, 0, 2), key("c", 0, 1)]; + + it("places a new key after the right end of the selected key's row", () => { + expect(rowEndPosition(keys, "a")).toEqual({ x: 3, y: 0 }); + expect(rowEndPosition(keys, "c")).toEqual({ x: 1, y: 1 }); + expect(rowEndPosition(keys, "missing")).toBeUndefined(); + }); + + it("starts a new row at the left below every key", () => { + expect(newRowPosition([...keys, key("tall", 3, 0.5, 1, 2)])).toEqual({ x: 0, y: 2.5 }); + expect(newRowPosition([])).toBeUndefined(); + }); +}); diff --git a/provisional/decisions.md b/provisional/decisions.md index 4a60978..60d5219 100644 --- a/provisional/decisions.md +++ b/provisional/decisions.md @@ -84,18 +84,16 @@ T-29 のコードを作り直さず、[phy-editor-feedback.md](phy-editor-feedba | 3 | バリアント | ポップオーバーの印を「既定」と分かる見た目にする。バッジには名前を書かず、表示中のバリアント名はツールチップで示す。クラスター内のキーから作ったバリアントグループは、そのクラスターの中に作る | `NodeSvg.vue`、`usePhyCommands.ts` | | 4 | ドラッグ中の自動スクロール | キーを画面の端へ寄せると、キャンバスがスクロールする | `usePhyCanvasPointer.ts`、`usePhyCanvasView.ts` | | 5 | クラスターの原点 | 選択中のクラスターにだけ、原点に丸を出す | 1の層 | -| 6 | キー追加の位置 | 選択中のキーの行の右端に追加する。行の判定は `domain/` に純粋関数として実装してテストし、のちにIDX Editorでも使う | `domain/`、`usePhyCommands.ts` | +| 6 | キー追加の位置 | 選択中のキー(複数なら最後に選んだキー)の行の右端に追加する。何も選んでいなければ、同じコンテナの全キーの下に新しい行を作り、その左端に置く。行の判定は `domain/` に純粋関数として実装してテストし、のちにIDX Editorでも使う。規則は [idx-editor-feedback.md](idx-editor-feedback.md)「行と列の判定」 | `domain/rows.ts`、`usePhyCommands.ts` | | 7a | 長押しの切り出し | 長押しの処理を composable に切り出す。挙動は変えない | `usePhyCanvasPointer.ts` | | 7b | 長押しの2方式 | ループ方式(間隔を延ばし、円形インジケータと選択先のリストを付ける)と、長押しでリストを出して選ぶ方式を入れ、切り替えて比べる | 7aの composable | | 8 | スイッチ・スタビライザーの表示 | 既定は非表示にし、トグルで色違いの十字を出す。プロパティ欄に `switchOffset` の入力欄を置く | 1の層、`PhyProperties.vue` | 順番の理由: 1で作る層を5と8が使う。2〜5は小さく独立しているので先に片付ける。 -6・7・8は、着手前に下の点を決める必要がある。7は最も大きく、試して比べる部分なので、挙動を変えない切り出しを先に入れる。 +7・8は、着手前に下の点を決める必要がある。7は最も大きく、試して比べる部分なので、挙動を変えない切り出しを先に入れる。 着手前に決めること: -- 6: 行の判定は idx-editor-feedback.md の規則(そろっている軸は座標でまとめ、ずれている軸は隣とのつながりでたどる)をそのまま使ってよいか。 - もう1つ、キーを選んでいない・複数選んでいるときにどこへ追加するか。 - 7b: 長押しの間隔の値。リスト方式でどう選ぶか(離した位置で決めるのか、リストを出したあとにクリックするのか)。 - 8: スタビライザーを付けるキー幅と、幅ごとの位置。これは事実なので、出典で裏取りしてから決める。推測で実装しない。