バッジは NodeSvg の再帰の中で各バリアントグループの <g> に描いていたため、 後から描かれる兄弟のキーに隠れていた。選択の描画層の後に PhyBadgeLayer を置き、 位置は純粋関数 variantBadges でワールド座標に直して正立で描く。 フォーカス中、フォーカス外のバッジは薄く描き、クリックを受けない。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
327 lines
12 KiB
TypeScript
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 };
|
|
}
|