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
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node } from "../../domain/phy";
|
||||
import { localBounds } from "./logic";
|
||||
defineOptions({ name: "NodeSvg" });
|
||||
const props = defineProps<{
|
||||
nodes: Node[];
|
||||
selected: string[];
|
||||
variants: Record<string, string>;
|
||||
cut: string[];
|
||||
dim: boolean;
|
||||
zoom: number;
|
||||
angle: number;
|
||||
longCandidate?: string | null;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
down: [e: PointerEvent, n: Node, el: SVGGElement];
|
||||
badge: [g: Node, e: PointerEvent];
|
||||
focus: [id: string];
|
||||
}>();
|
||||
function bounds(n: Node) {
|
||||
return n.type === "cluster"
|
||||
? localBounds(n.children, props.variants)
|
||||
: n.type === "variantGroup"
|
||||
? localBounds(
|
||||
n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [],
|
||||
props.variants,
|
||||
)
|
||||
: { x: 0, y: 0, width: 0, height: 0 };
|
||||
}
|
||||
function badgeBox(n: Node) {
|
||||
const b = bounds(n);
|
||||
const size = 26 / props.zoom;
|
||||
return {
|
||||
x: b.x + b.width + 4 / props.zoom,
|
||||
y: b.y - size - 4 / props.zoom,
|
||||
size,
|
||||
angle: -(props.angle + n.transform.rotation),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<g
|
||||
v-for="n in nodes"
|
||||
:key="n.id"
|
||||
:data-node-type="n.type"
|
||||
:data-node-id="n.id"
|
||||
:transform="`translate(${n.transform.x} ${n.transform.y}) rotate(${n.transform.rotation})`"
|
||||
:class="{ dim: dim, cut: cut.includes(n.id) }"
|
||||
@dblclick.stop="emit('focus', n.id)"
|
||||
>
|
||||
<g
|
||||
v-if="n.type === 'key'"
|
||||
class="node"
|
||||
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||
><rect
|
||||
:width="n.size.w"
|
||||
:height="n.size.h"
|
||||
rx=".06"
|
||||
class="key-face"
|
||||
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }" /><text
|
||||
class="key-label"
|
||||
x=".08"
|
||||
y=".24"
|
||||
>{{ n.labels?.find(Boolean) }}</text
|
||||
><rect
|
||||
v-if="selected.includes(n.id)"
|
||||
:width="n.size.w"
|
||||
:height="n.size.h"
|
||||
class="sel"
|
||||
:class="{ candidate: longCandidate === n.id }"
|
||||
/></g>
|
||||
<g
|
||||
v-else-if="n.type === 'component'"
|
||||
class="node"
|
||||
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||
><rect
|
||||
v-if="n.partGroup === '@led'"
|
||||
x="-.14"
|
||||
y="-.14"
|
||||
width=".28"
|
||||
height=".28"
|
||||
transform="rotate(45)"
|
||||
class="part led"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
/><g v-else-if="n.partGroup === '@encoder'" class="part" :class="{ chosen: selected.includes(n.id) }"
|
||||
><circle r=".16" /><path d="M -.1 0 H .1 M 0 -.1 V .1" /></g
|
||||
><path
|
||||
v-else-if="n.partGroup === '@screw'"
|
||||
d="M -.14 -.14 L .14 .14 M .14 -.14 L -.14 .14"
|
||||
class="part screw"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
/><rect
|
||||
v-else-if="n.partGroup === '@oled'"
|
||||
x="-.2"
|
||||
y="-.12"
|
||||
width=".4"
|
||||
height=".24"
|
||||
rx=".025"
|
||||
class="part oled"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
/><circle v-else r=".14" class="part" :class="{ chosen: selected.includes(n.id) }" /><text
|
||||
x=".2"
|
||||
y=".04"
|
||||
class="part-label"
|
||||
>{{ n.partGroup || "part" }}</text
|
||||
></g
|
||||
>
|
||||
<g
|
||||
v-else-if="n.type === 'cluster'"
|
||||
class="group"
|
||||
@pointerdown.self="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||
><NodeSvg
|
||||
:nodes="n.children"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:cut="cut"
|
||||
:dim="dim"
|
||||
:zoom="zoom"
|
||||
:angle="angle + n.transform.rotation"
|
||||
:long-candidate="longCandidate"
|
||||
@down="(e, c, el) => emit('down', e, c, el)"
|
||||
@badge="(g, e) => emit('badge', g, e)"
|
||||
@focus="(id) => emit('focus', id)"
|
||||
/><rect
|
||||
v-if="selected.includes(n.id) || longCandidate === n.id"
|
||||
:x="bounds(n).x"
|
||||
:y="bounds(n).y"
|
||||
:width="bounds(n).width"
|
||||
:height="bounds(n).height"
|
||||
class="group-sel"
|
||||
:class="{ candidate: longCandidate === n.id }"
|
||||
/><text x="0" y="-.12" class="group-label">{{ n.name }}</text></g
|
||||
>
|
||||
<g v-else class="group"
|
||||
><NodeSvg
|
||||
v-for="v in n.children.filter((v) => v.id === (variants[n.id] || n.default))"
|
||||
:key="v.id"
|
||||
:nodes="v.children"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:cut="cut"
|
||||
:dim="dim"
|
||||
:zoom="zoom"
|
||||
:angle="angle + n.transform.rotation"
|
||||
:long-candidate="longCandidate"
|
||||
@down="(e, c, el) => emit('down', e, c, el)"
|
||||
@badge="(g, e) => emit('badge', g, e)"
|
||||
@focus="(id) => emit('focus', id)"
|
||||
/><rect
|
||||
v-if="selected.includes(n.id) || longCandidate === n.id"
|
||||
:x="bounds(n).x"
|
||||
:y="bounds(n).y"
|
||||
:width="bounds(n).width"
|
||||
:height="bounds(n).height"
|
||||
class="group-sel"
|
||||
:class="{ candidate: longCandidate === n.id }"
|
||||
/><g
|
||||
:transform="`rotate(${badgeBox(n).angle} ${badgeBox(n).x} ${badgeBox(n).y})`"
|
||||
class="badge"
|
||||
@click.stop="emit('badge', n, $event)"
|
||||
><rect
|
||||
:x="badgeBox(n).x"
|
||||
:y="badgeBox(n).y"
|
||||
:width="badgeBox(n).size"
|
||||
:height="badgeBox(n).size"
|
||||
rx="3"
|
||||
@click.stop="emit('badge', n, $event)"
|
||||
/><text
|
||||
:x="badgeBox(n).x + 3 / zoom"
|
||||
:y="badgeBox(n).y + 16 / zoom"
|
||||
:style="{ fontSize: `${12 / zoom}px` }"
|
||||
>{{ n.children.find((v) => v.id === (variants[n.id] || n.default))?.name
|
||||
}}{{ n.default === (variants[n.id] || n.default) ? " ✓" : "" }}</text
|
||||
></g
|
||||
></g
|
||||
>
|
||||
</g>
|
||||
</template>
|
||||
<style scoped>
|
||||
.node {
|
||||
cursor: grab;
|
||||
}
|
||||
.key-face {
|
||||
fill: #f8cc78;
|
||||
stroke: #5b4a2b;
|
||||
stroke-width: 0.03;
|
||||
}
|
||||
.key-face.ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.key-face.decal {
|
||||
fill: #fff;
|
||||
stroke-dasharray: 0.08 0.06;
|
||||
}
|
||||
.key-label,
|
||||
.part-label,
|
||||
.group-label {
|
||||
font-size: 0.14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.part {
|
||||
fill: #b4d5ef;
|
||||
stroke: #315d7a;
|
||||
stroke-width: 0.03;
|
||||
}
|
||||
.part.chosen {
|
||||
stroke: #1769d2;
|
||||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.part.screw {
|
||||
fill: none;
|
||||
}
|
||||
.part path {
|
||||
fill: none;
|
||||
stroke: #315d7a;
|
||||
stroke-width: 0.03;
|
||||
}
|
||||
.sel,
|
||||
.group-sel {
|
||||
fill: none;
|
||||
stroke: #1769d2;
|
||||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
}
|
||||
.group-sel {
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
.candidate {
|
||||
stroke: #e87912;
|
||||
stroke-width: 2.5px;
|
||||
}
|
||||
.group {
|
||||
cursor: default;
|
||||
}
|
||||
.dim {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.cut {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.group-label {
|
||||
fill: #222;
|
||||
}
|
||||
.badge {
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
.badge rect {
|
||||
fill: #fff3c2;
|
||||
stroke: #8a691b;
|
||||
stroke-width: 1px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.badge text {
|
||||
font-size: 12px;
|
||||
fill: #302709;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user