mock: キー追加を、選択中のキーの行の右端、または新しい行の左端に置く(T-30 単位6)
行の判定は idx-editor-feedback.md の規則(そろっている軸は座標でまとめ、 ずれている軸は隣とのつながりで1対1にたどる)を domain/rows.ts に実装し、 IDX Editor でも使えるよう列の判定も合わせて置いた。 何も選んでいないときは、コンテナの全キーの下に新しい行を作り、その左端に置く。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsamNKrWY3obJwR746oQRq
This commit is contained in:
@@ -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<T>(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<string>();
|
||||
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<string, Point[]>();
|
||||
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)),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user