/** 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)), }; }