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
159 lines
6.5 KiB
TypeScript
159 lines
6.5 KiB
TypeScript
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];
|
|
}
|