Files
Beyond-KLE/mock/src/ui/phy/logic.ts
T
ewdernaandClaude Opus 5.5 0697e7a3b4 mock: バリアントのバッジをノードより後に描き、キーの下に隠れないようにする
バッジは NodeSvg の再帰の中で各バリアントグループの <g> に描いていたため、
後から描かれる兄弟のキーに隠れていた。選択の描画層の後に PhyBadgeLayer を置き、
位置は純粋関数 variantBadges でワールド座標に直して正立で描く。
フォーカス中、フォーカス外のバッジは薄く描き、クリックを受けない。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
2026-10-22 14:24:49 +09:00

327 lines
12 KiB
TypeScript

import type { Node, PhyFile, Transform } from "../../domain/phy";
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
const parent = new Map<string, string | null>(),
nodes = new Map<string, Node>();
const walk = (items: Node[], p: string | null) =>
items.forEach((n) => {
parent.set(n.id, p);
nodes.set(n.id, n);
if (n.type === "cluster") walk(n.children, n.id);
if (n.type === "variantGroup")
n.children.forEach((v) => {
parent.set(v.id, n.id);
walk(v.children, v.id);
});
});
walk(file.root, null);
const out: string[] = [];
for (const id of ids) {
if (!nodes.has(id) || out.includes(id)) continue;
const ancestors = new Set<string>();
let p = parent.get(id) ?? null;
while (p) {
ancestors.add(p);
p = parent.get(p) ?? null;
}
const keep = out.filter((x) => !ancestors.has(x) && !isDescendant(parent, x, id));
out.splice(0, out.length, ...keep, id);
}
return out;
}
function isDescendant(parent: Map<string, string | null>, candidate: string, ancestor: string): boolean {
let p = parent.get(candidate) ?? null;
while (p) {
if (p === ancestor) return true;
p = parent.get(p) ?? null;
}
return false;
}
export function longPressColumn(keyId: string, ancestorsNearestFirst: string[], focusId?: string): string[] {
const list = [keyId, ...ancestorsNearestFirst];
if (focusId) {
const i = list.indexOf(focusId);
if (i >= 0) list.splice(i);
}
return list;
}
export function tabOrder<T extends { id: string; x: number; y: number }>(items: T[]): T[] {
const sorted = [...items].sort((a, b) => a.y - b.y),
rows: T[][] = [];
for (const item of sorted) {
const row = rows.at(-1);
if (!row || item.y - row[0]!.y >= 0.5) rows.push([item]);
else row.push(item);
}
return rows.flatMap((row) => row.sort((a, b) => a.x - b.x));
}
/** 1つの走査結果からノードの親と挿入先の器を引けるようにする。 */
export function treeIndex(roots: Node[]) {
const nodes = new Map<string, Node>();
const parents = new Map<string, string | null>();
const containers = new Map<string, string | null>();
const variants = new Map<string, string>();
const visit = (items: Node[], parent: string | null, container: string | null) => {
for (const n of items) {
nodes.set(n.id, n);
parents.set(n.id, parent);
containers.set(n.id, container);
if (n.type === "cluster") visit(n.children, n.id, n.id);
if (n.type === "variantGroup") {
for (const v of n.children) {
variants.set(v.id, n.id);
visit(v.children, n.id, v.id);
}
}
}
};
visit(roots, null, null);
return {
nodes,
parents,
containers,
variants,
selectableParent(id: string) {
return parents.get(id) ?? null;
},
insertionContainer(id: string) {
return containers.get(id) ?? null;
},
groupForVariant(id: string) {
return variants.get(id) ?? null;
},
};
}
export function treeNodes(roots: Node[]): Node[] {
return [...treeIndex(roots).nodes.values()];
}
export function structuralNodes(roots: Node[]): Extract<Node, { type: "cluster" | "variantGroup" }>[] {
return treeNodes(roots).filter(
(node): node is Extract<Node, { type: "cluster" | "variantGroup" }> =>
node.type === "cluster" || node.type === "variantGroup",
);
}
export function removeFocusedSubtree(nodes: Node[], focusedId: string): Node[] {
const result: Node[] = [];
for (const node of nodes) {
if (node.id === focusedId) continue;
if (node.type === "cluster") {
const children = removeFocusedSubtree(node.children, focusedId);
result.push({ ...node, children });
} else if (node.type === "variantGroup") {
result.push({
...node,
children: node.children.map((variant) => ({
...variant,
children: removeFocusedSubtree(variant.children, focusedId),
})),
});
} else {
result.push(node);
}
}
return result;
}
export function visibleNodeIds(root: Node | undefined, shownVariants: Record<string, string>): Set<string> {
const result = new Set<string>();
const visit = (node: Node) => {
result.add(node.id);
if (node.type === "cluster") node.children.forEach(visit);
if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (shownVariants[node.id] ?? node.default));
variant?.children.forEach(visit);
}
};
if (root) visit(root);
return result;
}
export function selectableParent(roots: Node[], id: string): string | null {
return treeIndex(roots).selectableParent(id);
}
export function insertionContainer(
roots: Node[],
parentId: string | null,
shownVariants: Record<string, string> = {},
): string | null {
if (parentId === null) return null;
const index = treeIndex(roots);
const node = index.nodes.get(parentId);
if (node?.type === "cluster") return node.id;
if (node?.type === "variantGroup") return shownVariants[node.id] ?? node.default;
return index.variants.get(parentId) === undefined ? null : parentId;
}
export function snap(value: number, step: number): number {
return Math.round(value / step) * step;
}
export const GRID_STEPS = [1, 0.25, 0.125, 1 / 12] as const;
export function nextGrid(step: number, direction: 1 | -1): number {
const i = GRID_STEPS.findIndex((x) => Math.abs(x - step) < 1e-9);
return GRID_STEPS[(i + direction + GRID_STEPS.length) % GRID_STEPS.length]!;
}
export function resolveKey(
e: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
): string | null {
const mod = e.ctrlKey || e.metaKey;
if (e.code === "Digit1" && e.shiftKey) return "fit";
if (e.code === "Digit2" && e.shiftKey) return "selection-fit";
if (mod && e.key.toLowerCase() === "z") return e.shiftKey ? "redo" : "undo";
if (mod && e.key.toLowerCase() === "y") return "redo";
if (mod && e.key.toLowerCase() === "s") return "save";
if (mod && e.key.toLowerCase() === "g") return e.shiftKey ? "ungroup" : "group";
if (mod && ["a", "c", "x", "v", "d"].includes(e.key.toLowerCase())) return e.key.toLowerCase();
return e.key;
}
export type Box = { x: number; y: number; width: number; height: number };
export function obbAabb(points: { x: number; y: number }[]): Box {
if (!points.length) return { x: 0, y: 0, width: 0, height: 0 };
const xs = points.map((p) => p.x),
ys = points.map((p) => p.y),
x = Math.min(...xs),
y = Math.min(...ys);
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
}
export function pasteParent(selectedParents: (string | null)[], focus: string | null): string | null {
return selectedParents.length ? selectedParents[0]! : focus;
}
/** 子のローカル形状を親座標でまとめる。回転親に追従する選択枠用。 */
export function localBounds(nodes: Node[], variants: Record<string, string> = {}): Box {
const points: { x: number; y: number }[] = [];
const visit = (items: Node[], parent: Affine) =>
items.forEach((n) => {
const w = compose(parent, matrix(n.transform));
if (n.type === "key") {
const corners = [
{ x: 0, y: 0 },
{ x: n.size.w, y: 0 },
{ x: n.size.w, y: n.size.h },
{ x: 0, y: n.size.h },
];
if (
n.size.x2 !== undefined ||
n.size.y2 !== undefined ||
n.size.w2 !== undefined ||
n.size.h2 !== undefined
) {
const x = n.size.x2 ?? 0,
y = n.size.y2 ?? 0,
ww = n.size.w2 ?? n.size.w,
hh = n.size.h2 ?? n.size.h;
corners.push({ x, y }, { x: x + ww, y }, { x: x + ww, y: y + hh }, { x, y: y + hh });
}
points.push(...corners.map((p) => apply(w, p)));
} else if (n.type === "component") {
const c = apply(w, { x: 0, y: 0 });
points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 });
} else if (n.type === "cluster") visit(n.children, w);
else {
const v = n.children.find((x) => x.id === (variants[n.id] ?? n.default));
if (v) visit(v.children, w);
}
});
visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
return obbAabb(points);
}
export type SelectionFrame = {
id: string;
type: Node["type"];
transform: Affine;
box: Box;
selected: boolean;
candidate: boolean;
};
/** 可視ノードの選択枠をワールド行列とローカル矩形で返す。 */
export function selectionFrames(
roots: Node[],
selected: string[],
variants: Record<string, string>,
candidate: string | null,
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
): SelectionFrame[] {
const frames: SelectionFrame[] = [];
const visit = (nodes: Node[], ancestor: Affine) => {
for (const node of nodes) {
const transform = compose(ancestor, matrix(node.transform));
if (node.type === "key" && (selected.includes(node.id) || candidate === node.id)) {
frames.push({
id: node.id,
type: node.type,
transform,
box: { x: 0, y: 0, width: node.size.w, height: node.size.h },
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
} else if (node.type === "cluster") {
if (selected.includes(node.id) || candidate === node.id)
frames.push({
id: node.id,
type: node.type,
transform,
box: localBounds(node.children, variants),
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
visit(node.children, transform);
} else if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (variants[node.id] ?? node.default));
if (selected.includes(node.id) || candidate === node.id)
frames.push({
id: node.id,
type: node.type,
transform,
box: localBounds(variant?.children ?? [], variants),
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
if (variant) visit(variant.children, transform);
}
}
};
visit(roots, parent);
return frames;
}
export type VariantBadge = {
group: Extract<Node, { type: "variantGroup" }>;
x: number;
y: number;
size: number;
};
/** 表示中のVariantGroupのバッジを、OBBの右上に正立で置く。大きさは画面上で固定する。 */
export function variantBadges(
roots: Node[],
variants: Record<string, string>,
zoom: number,
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
): VariantBadge[] {
const badges: VariantBadge[] = [];
const size = 20 / zoom;
const visit = (nodes: Node[], ancestor: Affine) => {
for (const node of nodes) {
const transform = compose(ancestor, matrix(node.transform));
if (node.type === "cluster") visit(node.children, transform);
else if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (variants[node.id] || node.default));
const box = localBounds(variant?.children ?? [], variants);
const corner = apply(transform, { x: box.x + box.width + 4 / zoom, y: box.y - size - 4 / zoom });
badges.push({ group: node, x: corner.x, y: corner.y, size });
if (variant) visit(variant.children, transform);
}
}
};
visit(roots, parent);
return badges;
}
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
const local = compose(inverse(parentWorld), sourceWorld);
return { x: local.e + step, y: local.f + step, rotation: (Math.atan2(local.b, local.a) * 180) / Math.PI };
}