mock: スイッチ・スタビの十字をトグルで出し、スタビ候補を選べるようにする(T-30 単位8)

スタビ候補はキーの長辺の長さで引き、キーごとに複数選べる。選択は ProjectFile.ui.phy.stabilizers に持つ。
プロパティ欄に switchOffset の入力欄を置く。異形キーは候補なし。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yoUu9Xd8dhwyQdCfKfaXG
This commit is contained in:
2026-10-23 22:13:07 +09:00
co-authored by Claude Opus 5.5
parent 2c388e3bdb
commit eacf8a1581
8 changed files with 416 additions and 3 deletions
+34
View File
@@ -706,3 +706,37 @@ test("キャンバスのviewBoxはツールバーを除いたSVGの大きさに
expect(Math.abs(w! - box.width)).toBeLessThan(1);
expect(Math.abs(h! - box.height)).toBeLessThan(1);
});
test("スイッチ・スタビの十字はトグルで出し、スタビ候補を複数選べる", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
await expect(page.locator(".mount-layer")).toHaveCount(0);
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
const mark = page.locator(`[data-mount-id="${id}"]`);
await expect(mark.locator(".switch")).toHaveCount(1);
await expect(mark.locator(".stab")).toHaveCount(0);
await key.click();
await expect(page.locator(".stabilizers")).toContainText("候補なし");
await page.getByLabel("Width").fill("6.25");
await page.getByLabel("Width").blur();
for (const label of ["±50mm", "±40mm", "±42.5mm"]) await expect(page.getByLabel(label)).not.toBeChecked();
await page.getByLabel("±50mm").check();
await page.getByLabel("±40mm").check();
await expect(mark.locator(".stab")).toHaveCount(4);
const switchX = async () => (await mark.locator(".switch").boundingBox())!.x;
const before = await switchX();
await page.getByLabel("switchOffset X").fill("0.625");
await page.getByLabel("switchOffset X").blur();
await expect.poll(switchX).toBeGreaterThan(before + 10);
await expect(mark.locator(".stab")).toHaveCount(4);
await page.getByRole("button", { name: "Undo" }).click();
await page.getByRole("button", { name: "Undo" }).click();
await expect(mark.locator(".stab")).toHaveCount(2);
await expect(page.getByLabel("±40mm")).not.toBeChecked();
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
await expect(page.locator(".mount-layer")).toHaveCount(0);
});
+73
View File
@@ -0,0 +1,73 @@
import type { Point, Size } from "./phy";
/** One candidate stabilizer layout: two mounts at ±offset along the key's long axis, in U. */
export type StabilizerCandidate = { id: string; label: string; offset: number };
const MM = 1 / 19.05;
/** Cherry MX spacings by key length. provisional/decisions.md「単位8のスタビ候補」. */
const TABLE: { lengths: number[]; candidates: StabilizerCandidate[] }[] = [
{
lengths: [2, 2.25, 2.5, 2.75],
candidates: [{ id: "11.938mm", label: "±11.938mm", offset: 11.938 * MM }],
},
{ lengths: [3], candidates: [{ id: "1u", label: "±1U", offset: 1 }] },
{ lengths: [4], candidates: [{ id: "1.5u", label: "±1.5U", offset: 1.5 }] },
{ lengths: [4.5], candidates: [{ id: "34.671mm", label: "±34.671mm", offset: 34.671 * MM }] },
{ lengths: [5.5], candidates: [{ id: "2.25u", label: "±2.25U", offset: 2.25 }] },
{
lengths: [6],
candidates: [
{ id: "2.5u", label: "±2.5U", offset: 2.5 },
{ id: "2u", label: "±2U", offset: 2 },
],
},
{
lengths: [6.25],
candidates: [
{ id: "50mm", label: "±50mm", offset: 50 * MM },
{ id: "40mm", label: "±40mm", offset: 40 * MM },
{ id: "42.5mm", label: "±42.5mm", offset: 42.5 * MM },
],
},
{ lengths: [6.5], candidates: [{ id: "2.75u", label: "±2.75U", offset: 2.75 }] },
{ lengths: [7], candidates: [{ id: "3u", label: "±3U", offset: 3 }] },
{ lengths: [8, 9, 9.75, 10], candidates: [{ id: "3.5u", label: "±3.5U", offset: 3.5 }] },
];
const EPS = 1e-9;
/** Non-rectangular keys (ISO Enter, BAE, stepped) have no candidates yet. */
function irregular(size: Size): boolean {
return [size.x2, size.y2, size.w2, size.h2].some((v) => v !== undefined);
}
export function stabilizerCandidates(size: Size): StabilizerCandidate[] {
if (irregular(size)) return [];
const length = Math.max(size.w, size.h);
return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? [];
}
/** A size with a single candidate shows it until chosen otherwise; several start unselected. */
export function defaultStabilizerSelection(size: Size): string[] {
const candidates = stabilizerCandidates(size);
return candidates.length === 1 ? [candidates[0]!.id] : [];
}
/** Mount points in key-local coordinates, around the (w, h) rectangle's center, not the switch. */
export function stabilizerPoints(size: Size, ids: string[]): Point[] {
const cx = size.w / 2,
cy = size.h / 2,
vertical = size.h > size.w;
return stabilizerCandidates(size)
.filter((c) => ids.includes(c.id))
.flatMap((c) =>
vertical
? [
{ x: cx, y: cy - c.offset },
{ x: cx, y: cy + c.offset },
]
: [
{ x: cx - c.offset, y: cy },
{ x: cx + c.offset, y: cy },
],
);
}
export function switchPoint(key: { size: Size; switchOffset?: Point }): Point {
return { x: key.size.w / 2 + (key.switchOffset?.x ?? 0), y: key.size.h / 2 + (key.switchOffset?.y ?? 0) };
}
+34 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import type { Dispatch } from "../types";
@@ -12,7 +12,18 @@ import PhyBadgeLayer from "./PhyBadgeLayer.vue";
import PhyToolbar from "./PhyToolbar.vue";
import PhyProperties from "./PhyProperties.vue";
import PhyHoldOverlay from "./PhyHoldOverlay.vue";
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
import PhyMountLayer from "./PhyMountLayer.vue";
import {
normalizeSelection,
nextGrid,
insertionContainer,
structuralNodes,
visibleNodeIds,
stabilizerSelections,
selectedStabilizers,
withStabilizers,
} from "./logic";
import { stabilizerCandidates } from "../../domain/stabilizers";
import { usePhyClipboard } from "./usePhyClipboard";
import { usePhyViewport } from "./usePhyViewport";
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
@@ -296,6 +307,15 @@ function openBadge(g: Node, e: PointerEvent | MouseEvent) {
const top = r.bottom + 150 < (paneRect?.bottom ?? Infinity) ? r.bottom + 4 : r.top - 150;
popoverPos.value = overlayPoint(left, top, 210, 150);
}
const showMounts = ref(false);
const stabilizerChoice = computed(() => {
const item = prop.value;
if (item?.type !== "key") return null;
return {
candidates: stabilizerCandidates(item.size),
selected: selectedStabilizers(stabilizerSelections(props.project), item),
};
});
function onFocus(_id: string) {
if (
pointer.priorSelection().length === 1 &&
@@ -334,6 +354,8 @@ function onFocus(_id: string) {
@focus="focus = $event"
:hold-mode="holdMode"
@hold-mode="holdMode = holdMode === 'loop' ? 'list' : 'loop'"
:show-mounts="showMounts"
@mounts="showMounts = !showMounts"
/>
<PhyHoldOverlay
v-if="hold.open.value"
@@ -394,6 +416,14 @@ function onFocus(_id: string) {
@down="down"
@focus="onFocus"
/>
<PhyMountLayer
v-if="showMounts"
:nodes="focus ? [node(focus)!] : project.phy.root"
:variants="variants"
:selections="stabilizerSelections(project)"
:zoom="zoom"
:parent="focus ? focusParentMatrix(focus) : undefined"
/>
<PhySelectionLayer
:nodes="focus ? [node(focus)!] : project.phy.root"
:selected="selected"
@@ -484,6 +514,8 @@ function onFocus(_id: string) {
@change-variant="chooseVariant"
@delete-node="(id) => askDelete(id)"
@add-variant="(groupId, name) => addVariantToGroup(groupId, name)"
:stabilizers="stabilizerChoice"
@stabilizers="(keyId, ids) => dispatch('スタビ位置', (p) => withStabilizers(p, keyId, ids))"
/>
</section>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Node } from "../../domain/phy";
import type { Affine } from "../../domain/geometry";
import { mountMarks, type StabilizerSelections } from "./logic";
const props = withDefaults(
defineProps<{
nodes: Node[];
variants: Record<string, string>;
selections: StabilizerSelections;
zoom: number;
parent?: Affine;
}>(),
{ parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
);
const marks = computed(() => mountMarks(props.nodes, props.variants, props.selections, props.parent));
// 十字の大きさは画面上で固定する。
const cross = (p: { x: number; y: number }) => {
const r = 6 / props.zoom;
return `M ${p.x - r} ${p.y} H ${p.x + r} M ${p.x} ${p.y - r} V ${p.y + r}`;
};
const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
</script>
<template>
<g class="mount-layer" pointer-events="none">
<g v-for="mark in marks" :key="mark.id" :data-mount-id="mark.id" :transform="affineText(mark.transform)">
<path class="switch" :d="cross(mark.switchAt)" />
<path v-for="(p, i) in mark.stabs" :key="i" class="stab" :d="cross(p)" />
</g>
</g>
</template>
<style scoped>
path {
fill: none;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.switch {
stroke: #c2185b;
}
.stab {
stroke: #00897b;
}
</style>
+57
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Node, Transform } from "../../domain/phy";
import type { StabilizerCandidate } from "../../domain/stabilizers";
const props = defineProps<{
selectedCount: number;
selectedId: string | null;
item: Node | null;
mismatch: boolean;
stabilizers: { candidates: StabilizerCandidate[]; selected: string[] } | null;
}>();
const emit = defineEmits<{
update: [id: string, change: (node: Node) => void, label?: string];
@@ -14,7 +16,24 @@ const emit = defineEmits<{
changeVariant: [group: Node, variantId: string];
deleteNode: [id: string];
addVariant: [groupId: string, name: string];
stabilizers: [keyId: string, ids: string[]];
}>();
function setSwitchOffset(axis: "x" | "y", value: number) {
emit("update", props.item!.id, (n) => {
if (n.type !== "key") return;
const offset = { x: n.switchOffset?.x ?? 0, y: n.switchOffset?.y ?? 0, [axis]: value };
if (offset.x === 0 && offset.y === 0) delete n.switchOffset;
else n.switchOffset = offset;
});
}
function toggleStabilizer(id: string, on: boolean) {
const current = props.stabilizers?.selected ?? [];
emit(
"stabilizers",
props.item!.id,
on ? [...current.filter((x) => x !== id), id] : current.filter((x) => x !== id),
);
}
const group = computed(() => (props.item?.type === "variantGroup" ? props.item : null));
const frameX = ref(0);
const frameY = ref(0);
@@ -94,6 +113,33 @@ const frameR = ref(0);
})
"
/></label>
<label
>switchOffset X
<input
type="number"
step="any"
:value="item.switchOffset?.x ?? 0"
@change="setSwitchOffset('x', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>switchOffset Y
<input
type="number"
step="any"
:value="item.switchOffset?.y ?? 0"
@change="setSwitchOffset('y', Number(($event.target as HTMLInputElement).value))"
/></label>
<div class="stabilizers">
スタビ
<span v-if="!stabilizers?.candidates.length" class="none">候補なし</span>
<label v-for="c in stabilizers?.candidates ?? []" :key="c.id" class="check"
><input
type="checkbox"
:checked="stabilizers!.selected.includes(c.id)"
@change="toggleStabilizer(c.id, ($event.target as HTMLInputElement).checked)"
/>{{ c.label }}</label
>
</div>
<label
>Label
<input
@@ -174,4 +220,15 @@ const frameR = ref(0);
width: 110px;
margin-left: auto;
}
.properties label.check {
margin: 4px 0 4px 12px;
}
.properties label.check input {
width: auto;
margin-left: 0;
}
.stabilizers .none {
margin-left: 8px;
color: #6b7380;
}
</style>
+15
View File
@@ -7,6 +7,7 @@ defineProps<{
focus: string | null;
crumbs: { id: string | null; label: string }[];
holdMode: "loop" | "list";
showMounts: boolean;
}>();
const emit = defineEmits<{
keyAdd: [];
@@ -25,6 +26,7 @@ const emit = defineEmits<{
help: [];
focus: [id: string | null];
holdMode: [];
mounts: [];
}>();
function addPart(e: Event) {
const select = e.target as HTMLSelectElement;
@@ -103,6 +105,15 @@ function addPart(e: Event) {
>
{{ holdMode === "loop" ? "⟳" : "☰" }}
</button>
<button
title="スイッチ・スタビライザーの位置"
aria-label="スイッチ・スタビライザーの位置"
:aria-pressed="showMounts"
:class="{ on: showMounts }"
@click="emit('mounts')"
>
✛
</button>
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
<span v-if="focus">
<template v-for="(crumb, i) in crumbs" :key="i">
@@ -123,6 +134,10 @@ function addPart(e: Event) {
.tools select {
font-size: 12px;
}
.tools button.on {
background: #dbe8fb;
border-color: #1769d2;
}
/* 候補の名前でセレクトの幅が変わると、ツールバーが折り返してキャンバスの大きさが変わる */
.tools select.move-to {
width: 6em;
+64 -1
View File
@@ -1,4 +1,11 @@
import type { Node, PhyFile, Transform } from "../../domain/phy";
import type { KeyNode, Node, PhyFile, Transform } from "../../domain/phy";
import type { ProjectFile } from "../../domain/project";
import {
defaultStabilizerSelection,
stabilizerCandidates,
stabilizerPoints,
switchPoint,
} from "../../domain/stabilizers";
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
@@ -324,3 +331,59 @@ export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, st
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 };
}
/** スタビ位置の選択は仕様の層に入れず、ProjectFile.ui.phy.stabilizers にキーIDごとに持つ。T-10/T-30。 */
export type StabilizerSelections = Record<string, string[]>;
export function stabilizerSelections(project: ProjectFile): StabilizerSelections {
const ui = project.ui as { phy?: { stabilizers?: StabilizerSelections } } | undefined;
return ui?.phy?.stabilizers ?? {};
}
/** 記録がなければサイズの既定。サイズが変わって候補にない記録は捨てて読む。 */
export function selectedStabilizers(selections: StabilizerSelections, key: KeyNode): string[] {
const ids = selections[key.id];
if (!ids) return defaultStabilizerSelection(key.size);
const known = new Set(stabilizerCandidates(key.size).map((c) => c.id));
return ids.filter((id) => known.has(id));
}
export function withStabilizers(project: ProjectFile, keyId: string, ids: string[]): ProjectFile {
const ui = (project.ui ?? {}) as Record<string, unknown>,
phy = (ui.phy ?? {}) as Record<string, unknown>;
return {
...project,
ui: { ...ui, phy: { ...phy, stabilizers: { ...stabilizerSelections(project), [keyId]: ids } } },
};
}
export type MountMark = {
id: string;
transform: Affine;
switchAt: { x: number; y: number };
stabs: { x: number; y: number }[];
};
/** 表示中のキーごとに、スイッチ位置とスタビ位置をキーのローカル座標で返す。 */
export function mountMarks(
roots: Node[],
variants: Record<string, string>,
selections: StabilizerSelections,
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
): MountMark[] {
const marks: MountMark[] = [];
const visit = (nodes: Node[], ancestor: Affine) => {
for (const node of nodes) {
const transform = compose(ancestor, matrix(node.transform));
if (node.type === "key") {
if (node.decal) continue;
marks.push({
id: node.id,
transform,
switchAt: switchPoint(node),
stabs: stabilizerPoints(node.size, selectedStabilizers(selections, node)),
});
} else 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));
if (variant) visit(variant.children, transform);
}
}
};
visit(roots, parent);
return marks;
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
defaultStabilizerSelection,
stabilizerCandidates,
stabilizerPoints,
switchPoint,
} from "../src/domain/stabilizers";
import { mountMarks, selectedStabilizers, stabilizerSelections, withStabilizers } from "../src/ui/phy/logic";
import type { KeyNode } from "../src/domain/phy";
import type { ProjectFile } from "../src/domain/project";
const key = (id: string, w: number, h = 1, extra: Partial<KeyNode> = {}): KeyNode => ({
type: "key",
id,
profile: "p",
transform: { x: 0, y: 0, rotation: 0 },
size: { w, h },
...extra,
});
const ids = (w: number, h = 1) => stabilizerCandidates({ w, h }).map((c) => c.id);
describe("stabilizer candidates", () => {
it("looks up candidates by the long side", () => {
expect(ids(1)).toEqual([]);
expect(ids(1.75)).toEqual([]);
expect(ids(2.25)).toEqual(["11.938mm"]);
expect(ids(1, 2)).toEqual(["11.938mm"]);
expect(ids(6)).toEqual(["2.5u", "2u"]);
expect(ids(6.25)).toEqual(["50mm", "40mm", "42.5mm"]);
expect(ids(10)).toEqual(["3.5u"]);
expect(ids(6.75)).toEqual([]);
});
it("gives no candidates to non-rectangular keys", () => {
expect(stabilizerCandidates({ w: 1.25, h: 2, w2: 1.5, h2: 1, x2: -0.25 })).toEqual([]);
expect(stabilizerCandidates({ w: 2, h: 1, w2: 2.25 })).toEqual([]);
});
it("selects a lone candidate by default and nothing when there are several", () => {
expect(defaultStabilizerSelection({ w: 2, h: 1 })).toEqual(["11.938mm"]);
expect(defaultStabilizerSelection({ w: 6.25, h: 1 })).toEqual([]);
});
it("places mounts around the rectangle center along the long axis", () => {
expect(stabilizerPoints({ w: 7, h: 1 }, ["3u"])).toEqual([
{ x: 0.5, y: 0.5 },
{ x: 6.5, y: 0.5 },
]);
const [top, bottom] = stabilizerPoints({ w: 1, h: 2 }, ["11.938mm"]);
expect(top!.x).toBe(0.5);
expect(bottom!.y - top!.y).toBeCloseTo(23.876 / 19.05, 12);
expect(stabilizerPoints({ w: 6, h: 1 }, ["2.5u", "2u"])).toHaveLength(4);
});
it("keeps stabilizers on the key center when the switch is offset", () => {
const k = key("k", 6, 1, { switchOffset: { x: 0.5, y: 0 } });
expect(switchPoint(k)).toEqual({ x: 3.5, y: 0.5 });
expect(stabilizerPoints(k.size, ["2.5u"]).map((p) => p.x)).toEqual([0.5, 5.5]);
});
});
describe("stabilizer selections in ProjectFile.ui", () => {
const project = { ui: { other: 1 } } as unknown as ProjectFile;
it("falls back to the size default and drops ids the size no longer offers", () => {
expect(selectedStabilizers({}, key("a", 2))).toEqual(["11.938mm"]);
expect(selectedStabilizers({ a: [] }, key("a", 2))).toEqual([]);
expect(selectedStabilizers({ a: ["2.5u", "3u"] }, key("a", 6))).toEqual(["2.5u"]);
});
it("writes per key without touching other ui state", () => {
const next = withStabilizers(withStabilizers(project, "a", ["50mm"]), "b", []);
expect(stabilizerSelections(next)).toEqual({ a: ["50mm"], b: [] });
expect((next.ui as { other: number }).other).toBe(1);
expect(stabilizerSelections(project)).toEqual({});
});
it("lists marks for visible keys only, skipping decals", () => {
const marks = mountMarks(
[
key("a", 2),
key("d", 2, 1, { decal: true }),
{
type: "variantGroup",
id: "g",
name: "g",
default: "v1",
transform: { x: 0, y: 1, rotation: 0 },
children: [
{ type: "variant", id: "v1", name: "1", children: [key("b", 1)] },
{ type: "variant", id: "v2", name: "2", children: [key("c", 1)] },
],
},
],
{ g: "v2" },
{},
);
expect(marks.map((m) => m.id)).toEqual(["a", "c"]);
expect(marks[1]!.transform.f).toBe(1);
expect(marks[0]!.stabs).toHaveLength(2);
});
});