Files
Beyond-KLE/mock/src/ui/phy/usePhyCanvasView.ts
T
ewdernaandClaude Opus 5.5 4d1eb23cf8 mock: PHY Editorを作り直す(T-29)
provisional/phy-editor-feedback.md の合意事項と phy-editor-keybindings.md の割り当て案に沿って、PHY 画面を一から書き直した。
App.vue は共通のシェル(入出力・履歴・タブ)だけにし、PHY は src/ui/phy/ に、コンポーネント・composable・純粋モジュール(logic.ts, viewport.ts)を分けて置いた。

- 画面確認で見つかった不具合(読み込み時の全体表示、Shift+クリック、削除の確認の大きさ、Alt+↑、フォーカス中の二重描画、長押し中の選択など)を直し、キャンバス上の実際の操作で確かめる e2e を足した
- 階層パネルでクラスターの子を余分な折りたたみで包まず、件数を配下のキー・部品の数にそろえた
- 実装は Codex(Luna)。検証: vue-tsc 0件、Vitest 64件、Playwright 30件、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
2026-10-07 20:54:37 +09:00

178 lines
5.8 KiB
TypeScript

import { computed, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node, VariantGroupNode } from "../../domain/phy";
import {
apply,
compose,
flattenKeys,
inverse,
matrix,
worldTransform,
type Affine,
} from "../../domain/geometry";
import { removeFocusedSubtree, structuralNodes } from "./logic";
type Options = {
project: () => ProjectFile;
size: Ref<{ w: number; h: number }>;
pan: Ref<{ x: number; y: number }>;
zoom: Ref<number>;
focus: Ref<string | null>;
variants: Ref<Record<string, string>>;
grid: Ref<number>;
selected: Ref<string[]>;
node: (id: string) => Node | undefined;
parentId: (id: string) => string | null;
parentLabel: (id: string | null) => string;
selectedContainer: (id: string) => string | null;
};
export function usePhyCanvasView(options: Options) {
const keys = computed(() => flattenKeys(options.project().phy, options.variants.value));
const groupsAll = computed(() => structuralNodes(options.project().phy.root));
const outsideNodes = computed(() =>
options.focus.value ? removeFocusedSubtree(options.project().phy.root, options.focus.value) : [],
);
const gridLines = computed(() => {
const step = options.grid.value;
const xMin = Math.floor(-options.pan.value.x / options.zoom.value / step);
const xMax = Math.ceil((options.size.value.w - options.pan.value.x) / options.zoom.value / step);
const yMin = Math.floor(-options.pan.value.y / options.zoom.value / step);
const yMax = Math.ceil((options.size.value.h - options.pan.value.y) / options.zoom.value / step);
const lines: { d: string; major: boolean }[] = [];
for (let index = xMin; index <= xMax; index++) {
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
for (let index = yMin; index <= yMax; index++) {
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
return lines;
});
const variantGroups = computed(() =>
groupsAll.value.filter((node): node is VariantGroupNode => node.type === "variantGroup"),
);
const focusGridLines = computed(() => {
const focusId = options.focus.value;
if (!focusId || !options.node(focusId)) return [];
const screen = compose(
{
a: options.zoom.value,
b: 0,
c: 0,
d: options.zoom.value,
e: options.pan.value.x,
f: options.pan.value.y,
},
worldTransform(options.project().phy, focusId),
);
const toLocal = inverse(screen);
const corners = [
{ x: 0, y: 0 },
{ x: options.size.value.w, y: 0 },
{ x: options.size.value.w, y: options.size.value.h },
{ x: 0, y: options.size.value.h },
].map((point) => apply(toLocal, point));
const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y);
const step = options.grid.value;
const lines: { d: string; major: boolean }[] = [];
for (
let index = Math.floor(Math.min(...xs) / step);
index <= Math.ceil(Math.max(...xs) / step);
index++
) {
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
for (
let index = Math.floor(Math.min(...ys) / step);
index <= Math.ceil(Math.max(...ys) / step);
index++
) {
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
return lines;
});
const moveTargets = computed(() => {
const excluded = new Set<string>();
const add = (node: Node) => {
excluded.add(node.id);
if (node.type === "cluster") node.children.forEach(add);
if (node.type === "variantGroup") {
node.children.forEach((variant) => {
excluded.add(variant.id);
variant.children.forEach(add);
});
}
};
options.selected.value.forEach((id) => {
const node = options.node(id);
if (node) add(node);
});
return [
{ id: "__root__", label: "root" },
...groupsAll.value
.filter((node) => node.type === "cluster" && !excluded.has(node.id))
.map((node) => ({ id: node.id, label: node.name || "クラスター" })),
...variantGroups.value.flatMap((group) =>
group.children
.filter((variant) => !excluded.has(variant.id))
.map((variant) => ({ id: variant.id, label: `${group.name} / ${variant.name}` })),
),
];
});
function parentMatrix(id: string): Affine {
const node = options.node(id);
return node
? compose(worldTransform(options.project().phy, id), inverse(matrix(node.transform)))
: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
}
function focusAngle(id: string) {
const transform = parentMatrix(id);
return (Math.atan2(transform.b, transform.a) * 180) / Math.PI;
}
function affineText(transform: Affine) {
return `matrix(${transform.a} ${transform.b} ${transform.c} ${transform.d} ${transform.e} ${transform.f})`;
}
function focusCrumbs(id: string): { id: string | null; label: string }[] {
const ids = [id];
let parent = options.parentId(id);
while (parent) {
ids.unshift(parent);
parent = options.parentId(parent);
}
return [
{ id: null, label: "root" },
...ids.map((item) => ({
id:
groupsAll.value.find(
(node) => node.type === "variantGroup" && node.children.some((variant) => variant.id === item),
)?.id || item,
label: options.parentLabel(item),
})),
];
}
const propertyNode = computed(() =>
options.selected.value.length === 1 ? options.node(options.selected.value[0]!) : null,
);
return {
keys,
groupsAll,
outsideNodes,
gridLines,
variantGroups,
focusGridLines,
moveTargets,
parentMatrix,
focusAngle,
affineText,
focusCrumbs,
propertyNode,
};
}