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
178 lines
5.8 KiB
TypeScript
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,
|
|
};
|
|
}
|