mock: IDX Editorを作り直す(T-31)
provisional/idx-editor-feedback.md の合意事項と T-31 の仮決定に沿って、IdxPanel を捨てて ui/idx/ に一から書き直した。 並びのリストは置かず、左にバリアントのツリー、中央のキャンバスでドラッグして並べ替え、右にプリセット・取り込み・未登録の登録を置いた。 - order の操作(隠れたキーのくっつき先、挿入、入れ替え、連番の並べ直し)を domain/idxOrder.ts に、 走査・半身の分割・行の割り当てのずらし・マトリクス順を domain/idxScan.ts に、純粋関数として置いた。行と列の判定は rows.ts をそのまま使う - QMK keyboard.json・VIA定義・KLE JSON の取り込みは、位置と回転で表示中のキーに照合する(io/layoutImport.ts、domain/idxImport.ts) - 回転したキーでも出力番号を正立させる - Codex の設定ファイルが壊れていて起動できなかったため、実装は Claude が直接行った - 検証: vue-tsc 0件、Vitest 101件(うち新規17件)、Playwright 48件(IDX 8件を書き直し)、prettier --check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GzX13A7HseQz9n6etGbFDg
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import type { FlatKey } from "./phy";
|
||||
|
||||
/** World position of a key origin and its rotation, as read from an existing definition. T-31. */
|
||||
export type Placement = { x: number; y: number; rotation: number };
|
||||
|
||||
const TOLERANCE = 0.01;
|
||||
|
||||
const angle = (deg: number) => ((deg % 360) + 360) % 360;
|
||||
|
||||
function sameRotation(a: number, b: number): boolean {
|
||||
const d = Math.abs(angle(a) - angle(b));
|
||||
return Math.min(d, 360 - d) <= TOLERANCE;
|
||||
}
|
||||
|
||||
/** Matches placements one-to-one to the shown keys by position and rotation, in placement order. */
|
||||
export function matchPlacements(
|
||||
placements: readonly Placement[],
|
||||
keys: readonly FlatKey[],
|
||||
): { ids: string[]; unmatched: number } {
|
||||
const used = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const p of placements) {
|
||||
let best: FlatKey | undefined,
|
||||
distance = Infinity;
|
||||
for (const k of keys) {
|
||||
if (used.has(k.key.id) || !sameRotation(p.rotation, k.rotation)) continue;
|
||||
const dx = Math.abs(p.x - k.x),
|
||||
dy = Math.abs(p.y - k.y);
|
||||
if (dx > TOLERANCE || dy > TOLERANCE || dx + dy >= distance) continue;
|
||||
best = k;
|
||||
distance = dx + dy;
|
||||
}
|
||||
if (best) {
|
||||
used.add(best.key.id);
|
||||
ids.push(best.key.id);
|
||||
}
|
||||
}
|
||||
return { ids, unmatched: placements.length - ids.length };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Node, PhyFile } from "./phy";
|
||||
|
||||
/** Keys shown in the edited configuration and the VariantGroups each key sits under. T-31. */
|
||||
export type OrderContext = {
|
||||
visible: ReadonlySet<string>;
|
||||
groups: ReadonlyMap<string, readonly string[]>;
|
||||
};
|
||||
|
||||
/** Lists the VariantGroup ancestors of every key, outermost first. Common keys get an empty list. */
|
||||
export function keyVariantGroups(phy: PhyFile): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
const visit = (nodes: Node[], groups: string[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "key") out.set(n.id, groups);
|
||||
else if (n.type === "cluster") visit(n.children, groups);
|
||||
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children, [...groups, n.id]));
|
||||
});
|
||||
visit(phy.root, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sharesGroup(ctx: OrderContext, a: string, b: string): boolean {
|
||||
const mine = ctx.groups.get(a) ?? [];
|
||||
return (ctx.groups.get(b) ?? []).some((g) => mine.includes(g));
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `order` into one segment per visible key, each holding the hidden keys attached to it.
|
||||
* A hidden key attaches to the nearer visible key that shares a VariantGroup with it,
|
||||
* preferring the one before it; once a hidden key attaches forward, the rest of that run does too.
|
||||
* Concatenating the segments gives `order` back. Without visible keys there are no segments.
|
||||
*/
|
||||
export function orderSegments(order: readonly string[], ctx: OrderContext): string[][] {
|
||||
const at = order.flatMap((id, i) => (ctx.visible.has(id) ? [i] : []));
|
||||
if (!at.length) return [];
|
||||
const segments = at.map((i) => [order[i]!]);
|
||||
segments[0]!.unshift(...order.slice(0, at[0]));
|
||||
segments.at(-1)!.push(...order.slice(at.at(-1)! + 1));
|
||||
for (let k = 0; k + 1 < at.length; k++) {
|
||||
const prev = order[at[k]!]!,
|
||||
next = order[at[k + 1]!]!,
|
||||
run = order.slice(at[k]! + 1, at[k + 1]);
|
||||
let split = run.findIndex((h) => sharesGroup(ctx, h, next) && !sharesGroup(ctx, h, prev));
|
||||
if (split < 0) split = run.length;
|
||||
segments[k]!.push(...run.slice(0, split));
|
||||
segments[k + 1]!.unshift(...run.slice(split));
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves `keyId` to gap `gap` (0..V) between the visible keys left after taking it out.
|
||||
* Hidden keys that were attached to it stay where they are. Unregistered keys are registered there.
|
||||
*/
|
||||
export function insertAtGap(
|
||||
order: readonly string[],
|
||||
ctx: OrderContext,
|
||||
keyId: string,
|
||||
gap: number,
|
||||
): string[] {
|
||||
const rest = order.filter((id) => id !== keyId);
|
||||
const segments = orderSegments(rest, ctx);
|
||||
if (gap >= segments.length) return [...rest, keyId];
|
||||
return [...segments.slice(0, gap).flat(), keyId, ...segments.slice(gap).flat()];
|
||||
}
|
||||
|
||||
/** Moves `keyId` right before (`after` false) or right after the visible key `target`. */
|
||||
export function insertNextTo(
|
||||
order: readonly string[],
|
||||
ctx: OrderContext,
|
||||
keyId: string,
|
||||
target: string,
|
||||
after: boolean,
|
||||
): string[] {
|
||||
if (keyId === target) return [...order];
|
||||
const visibleRest = order.filter((id) => id !== keyId && ctx.visible.has(id));
|
||||
const index = visibleRest.indexOf(target);
|
||||
if (index < 0) throw new Error(`Not a visible registered key: ${target}`);
|
||||
return insertAtGap(order, ctx, keyId, index + (after ? 1 : 0));
|
||||
}
|
||||
|
||||
/** Swaps two registered keys. Every other key keeps its position. */
|
||||
export function swapKeys(order: readonly string[], a: string, b: string): string[] {
|
||||
const i = order.indexOf(a),
|
||||
j = order.indexOf(b);
|
||||
if (i < 0 || j < 0) throw new Error("Both keys must be registered");
|
||||
const out = [...order];
|
||||
[out[i], out[j]] = [out[j]!, out[i]!];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the visible keys of `sequence` consecutively, starting where its first registered key was
|
||||
* among the other visible keys. Other visible keys keep their relative order, hidden keys move with
|
||||
* the visible key they are attached to, and unregistered keys in `sequence` are registered. T-31.
|
||||
*/
|
||||
export function renumber(order: readonly string[], ctx: OrderContext, sequence: readonly string[]): string[] {
|
||||
const registered = new Set(order);
|
||||
const seq = [...new Set(sequence)].filter((id) => ctx.visible.has(id) || !registered.has(id));
|
||||
const inSeq = new Set(seq);
|
||||
const anchor = seq.find((id) => registered.has(id));
|
||||
if (!anchor) return [...order, ...seq];
|
||||
const segments = orderSegments(order, ctx);
|
||||
const byKey = new Map(segments.map((s) => [s.find((id) => ctx.visible.has(id))!, s]));
|
||||
const others = segments.filter((s) => !s.some((id) => inSeq.has(id)));
|
||||
const anchorAt = order.indexOf(anchor);
|
||||
const gap = others.filter((s) => order.indexOf(s.find((id) => ctx.visible.has(id))!) < anchorAt).length;
|
||||
const block = seq.flatMap((id) => byKey.get(id) ?? [id]);
|
||||
return [...others.slice(0, gap).flat(), ...block, ...others.slice(gap).flat()];
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { FlatKey } from "./phy";
|
||||
import type { ProjectFile } from "./project";
|
||||
import { detectColumns, detectRows, type KeyRect } from "./rows";
|
||||
|
||||
/** Gaps narrower than this do not separate the two halves. T-31. */
|
||||
const MIN_HALF_GAP = 0.5;
|
||||
|
||||
export type ScanKind = "rows" | "columns" | "halves" | "alternating";
|
||||
export type Direction = "normal" | "reversed";
|
||||
export type ScanOptions = {
|
||||
kind: ScanKind;
|
||||
/** Whole-board direction for `rows` and `columns`. */
|
||||
direction: Direction;
|
||||
/** Per-half directions for `halves` and `alternating`. */
|
||||
left: Direction;
|
||||
right: Direction;
|
||||
/** Shift of the line assignment per cross line index (columns for row scans, rows for `columns`). */
|
||||
offsets: ReadonlyMap<number, number>;
|
||||
};
|
||||
export type ScanResult = {
|
||||
sequence: string[];
|
||||
/** Lines the scan walks, for the preview: rows, or columns for `columns`. */
|
||||
lines: string[][];
|
||||
/** Lines that can be shifted with `offsets`, in index order. */
|
||||
crossLines: string[][];
|
||||
/** x of the split between the halves, for scans that use halves. */
|
||||
boundary?: number;
|
||||
};
|
||||
|
||||
/** Axis-aligned bounding box of a key in world coordinates; rotated keys are judged by it. T-31. */
|
||||
export function keyRect(k: FlatKey): KeyRect {
|
||||
const r = (k.rotation * Math.PI) / 180,
|
||||
c = Math.cos(r),
|
||||
s = Math.sin(r);
|
||||
const { w, h } = k.key.size;
|
||||
const xs = [0, w * c, -h * s, w * c - h * s].map((dx) => k.x + dx);
|
||||
const ys = [0, w * s, h * c, w * s + h * c].map((dy) => k.y + dy);
|
||||
const x = Math.min(...xs),
|
||||
y = Math.min(...ys);
|
||||
return { id: k.key.id, x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits keys into left and right halves at the widest gap between detected columns.
|
||||
* When no gap reaches MIN_HALF_GAP, splits at the middle of the keys' x range by key center.
|
||||
*/
|
||||
export function splitHalves(rects: KeyRect[]): { left: Set<string>; right: Set<string>; boundary: number } {
|
||||
const columns = detectColumns(rects);
|
||||
const byId = new Map(rects.map((r) => [r.id, r]));
|
||||
const span = columns.map((col) => {
|
||||
const rs = col.map((id) => byId.get(id)!);
|
||||
return { min: Math.min(...rs.map((r) => r.x)), max: Math.max(...rs.map((r) => r.x + r.w)) };
|
||||
});
|
||||
let best = -1,
|
||||
width = -Infinity;
|
||||
for (let i = 0; i + 1 < span.length; i++) {
|
||||
const gap = span[i + 1]!.min - span[i]!.max;
|
||||
if (gap > width) [best, width] = [i, gap];
|
||||
}
|
||||
if (best >= 0 && width >= MIN_HALF_GAP) {
|
||||
const left = new Set(columns.slice(0, best + 1).flat());
|
||||
return {
|
||||
left,
|
||||
right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))),
|
||||
boundary: (span[best]!.max + span[best + 1]!.min) / 2,
|
||||
};
|
||||
}
|
||||
const boundary = rects.length
|
||||
? (Math.min(...rects.map((r) => r.x)) + Math.max(...rects.map((r) => r.x + r.w))) / 2
|
||||
: 0;
|
||||
const left = new Set(rects.filter((r) => r.x + r.w / 2 < boundary).map((r) => r.id));
|
||||
return { left, right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))), boundary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects rows (or columns), then moves every key of cross line `i` by `offsets.get(i)` lines.
|
||||
* Rows are rebuilt top to bottom and sorted by x; columns left to right and sorted by y.
|
||||
*/
|
||||
export function assignLines(
|
||||
rects: KeyRect[],
|
||||
kind: "row" | "column",
|
||||
offsets: ReadonlyMap<number, number>,
|
||||
): { lines: string[][]; crossLines: string[][] } {
|
||||
const base = kind === "row" ? detectRows(rects) : detectColumns(rects);
|
||||
const crossLines = kind === "row" ? detectColumns(rects) : detectRows(rects);
|
||||
const line = new Map(base.flatMap((ids, i) => ids.map((id) => [id, i] as const)));
|
||||
crossLines.forEach((ids, i) => ids.forEach((id) => line.set(id, line.get(id)! + (offsets.get(i) ?? 0))));
|
||||
const byId = new Map(rects.map((r) => [r.id, r]));
|
||||
const along = (id: string) => (kind === "row" ? byId.get(id)!.x : byId.get(id)!.y);
|
||||
const indexes = [...new Set(line.values())].sort((a, b) => a - b);
|
||||
const lines = indexes.map((i) =>
|
||||
[...line]
|
||||
.filter(([, at]) => at === i)
|
||||
.map(([id]) => id)
|
||||
.sort((a, b) => along(a) - along(b)),
|
||||
);
|
||||
return { lines, crossLines };
|
||||
}
|
||||
|
||||
const directed = <T>(items: T[], d: Direction) => (d === "reversed" ? [...items].reverse() : items);
|
||||
|
||||
/** Builds the key sequence of a preset scan over the target keys. T-31. */
|
||||
export function scanKeys(rects: KeyRect[], options: ScanOptions): ScanResult {
|
||||
if (options.kind === "columns") {
|
||||
const { lines, crossLines } = assignLines(rects, "column", options.offsets);
|
||||
return { sequence: directed(lines, options.direction).flat(), lines, crossLines };
|
||||
}
|
||||
const { lines, crossLines } = assignLines(rects, "row", options.offsets);
|
||||
if (options.kind === "rows")
|
||||
return { sequence: lines.flatMap((row) => directed(row, options.direction)), lines, crossLines };
|
||||
const { left, boundary } = splitHalves(rects);
|
||||
const leftPart = (row: string[]) =>
|
||||
directed(
|
||||
row.filter((id) => left.has(id)),
|
||||
options.left,
|
||||
);
|
||||
const rightPart = (row: string[]) =>
|
||||
directed(
|
||||
row.filter((id) => !left.has(id)),
|
||||
options.right,
|
||||
);
|
||||
const sequence =
|
||||
options.kind === "halves"
|
||||
? [...lines.flatMap(leftPart), ...lines.flatMap(rightPart)]
|
||||
: lines.flatMap((row) => [...leftPart(row), ...rightPart(row)]);
|
||||
return { sequence, lines, crossLines, boundary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds each contact key's (row, col) from the matrix pins, by the same rule as the Generator.
|
||||
* Returns undefined when the project has no matrix settings. T-19.
|
||||
*/
|
||||
export function matrixPositions(p: ProjectFile): Map<string, { row: number; col: number }> | undefined {
|
||||
const matrix = p.output.matrix;
|
||||
if (!matrix) return undefined;
|
||||
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
|
||||
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
|
||||
const out = new Map<string, { row: number; col: number }>();
|
||||
for (const w of p.elc.keys) {
|
||||
if (w.detect !== "contact") continue;
|
||||
const { a, b } = w.terminals;
|
||||
if (rowIndex.has(a) && colIndex.has(b)) out.set(w.key, { row: rowIndex.get(a)!, col: colIndex.get(b)! });
|
||||
else if (rowIndex.has(b) && colIndex.has(a))
|
||||
out.set(w.key, { row: rowIndex.get(b)!, col: colIndex.get(a)! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sorts keys by row then col. Keys without a matrix position keep their input order at the end. */
|
||||
export function matrixSequence(
|
||||
ids: readonly string[],
|
||||
positions: ReadonlyMap<string, { row: number; col: number }>,
|
||||
) {
|
||||
const placed = ids.filter((id) => positions.has(id));
|
||||
const rest = ids.filter((id) => !positions.has(id));
|
||||
const key = (id: string) => positions.get(id)!;
|
||||
return [...placed.sort((a, b) => key(a).row - key(b).row || key(a).col - key(b).col), ...rest];
|
||||
}
|
||||
Reference in New Issue
Block a user