feat(513-1 B05 평면 우클릭): 평면도 우클릭 = 종단 그래프와 같은 구조물 메뉴 — 항목 짓기 공용 structureMenuItemsAt(가까운 측점 = 배관 · 구조물 · 측점 삭제 · 빈 자리 = 군 → 종류 추가) · 재료 = 지금 선 종단 그래프(liveStructureMenu) · 자리 = 누른 곳을 노선에 내린 누가거리 · 노드 위 = 노드 삭제 그대로 · 노선 밖 = 메뉴 없음 · 시험 test_513_1 · 368_3 낡은 기대 고침 — 요구 327-1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 19:55:21 +09:00
co-authored by Claude Opus 5.5
parent 35a8ac9bac
commit f4390bdede
6 changed files with 293 additions and 34 deletions
@@ -72,16 +72,48 @@ export interface StructureLineOptions {
onAddStructureType?: (chainageM: number, typeId: string) => void;
}
/** 지금 선 종단 그래프의 메뉴 재료 — 평면 우클릭이 같은 것을 씀(513-1 · 종단도는 페이지에 한 벌). */
let liveMenu: { host: HTMLElement; options: StructureLineOptions } | null = null;
/** 종단 그래프 메뉴 재료 — 그래프가 아직 안 섰거나 떠났으면 null. */
export function liveStructureMenu(): StructureLineOptions | null {
return liveMenu?.host.isConnected ? liveMenu.options : null;
}
/** 우클릭 한 자리의 항목 — 가까운 측점이 있으면 삭제, 없으면 그 자리에 추가(종단 그래프 · 평면 공용 · 513-1). */
export function structureMenuItemsAt(
options: StructureLineOptions,
chainageM: number,
near: IrregularStation | null,
): MapContextMenuItem[] {
if (near) {
if (near.id.startsWith(PLUS_OWNER_PREFIX))
return [["측점 삭제", () => options.onRemovePlus?.(near.chainage_m)]];
return [[isPipeStation(near) ? "배관 삭제" : "구조물 삭제", () => options.onRemove(near)]];
}
const at = Number(Math.min(Math.max(chainageM, 0), options.maxChainageM).toFixed(2));
// 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18
// 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를
// 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다.
const groups = options.structureGroups?.() ?? [];
return groups.length
? structureGroupMenuItems(groups, (typeId) => options.onAddStructureType?.(at, typeId))
: [
["배관 추가", () => options.onAddPipe(at)],
...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
`${type === "기타" ? "기타 구조물" : type} 추가`,
() => options.onAddStructure?.(at, type),
]),
];
}
/**
* 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 매 그리기마다 새로 만들어지므로 이 함수도
* 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다.
* 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다(평면이 쓸 재료만 `liveMenu` 에 둠).
*/
export function mountStructureMenu(host: HTMLElement, options: StructureLineOptions): void {
const menu = createMapContextMenu("b05-profile-chart");
function clamp(chainageM: number): number {
return Math.min(Math.max(chainageM, 0), options.maxChainageM);
}
liveMenu = { host, options };
// 우클릭 — 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관을 넣는다.
host.addEventListener("contextmenu", (event) => {
@@ -91,40 +123,17 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti
}
const rect = host.getBoundingClientRect();
const localX = event.clientX - rect.left;
const chainage = clamp(options.chainageAt(localX));
// 선을 그리지 않으므로 화면 거리로 가장 가까운 구조물을 찾는다.
const near = options.stations
.map((station) => ({ station, distance: Math.abs(options.x(station.chainage_m) - localX) }))
.filter((entry) => entry.distance <= pickPx(GRAB_SLACK_PX))
.sort((left, right) => left.distance - right.distance)[0];
event.preventDefault();
const at = Number(chainage.toFixed(2));
// 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18
// 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를
// 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다.
const groups = options.structureGroups?.() ?? [];
const addItems: MapContextMenuItem[] = groups.length
? structureGroupMenuItems(groups, (typeId) => options.onAddStructureType?.(at, typeId))
: [
["배관 추가", () => options.onAddPipe(at)],
...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
`${type === "기타" ? "기타 구조물" : type} 추가`,
() => options.onAddStructure?.(at, type),
]),
];
const plus = near?.station.id.startsWith(PLUS_OWNER_PREFIX);
menu.open(localX, event.clientY - rect.top, [
...(near
? [
(plus
? ["측점 삭제", () => options.onRemovePlus?.(near.station.chainage_m)]
: [
isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제",
() => options.onRemove(near.station),
]) as [string, () => void],
]
: addItems),
]);
menu.open(
localX,
event.clientY - rect.top,
structureMenuItemsAt(options, options.chainageAt(localX), near?.station ?? null),
);
});
host.addEventListener("pointerdown", (event) => {
if (!menu.contains(event.target)) menu.close();
@@ -13,6 +13,7 @@ import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProce
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { bindRouteEditKeys } from "./B05_Profile_UI_RouteEdit_Keys";
import { bindStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks";
import { bindPlanStructureMenu } from "./B05_Profile_UI_RouteEdit_StructureMenu";
import {
dragHandleTo as curveDragTo,
type EditedCurve,
@@ -212,6 +213,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
let dragOff = 0;
/** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */
const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null);
bindPlanStructureMenu(params, nodeAt); // 우클릭 = 종단 그래프와 같은 구조물 메뉴(513-1)
/** 누름 · 맴돔 한 판정 — 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것(`pressWinner`). */
const chooseAt = (px: number, py: number) => {
const pick = pickAt(px, py);
@@ -0,0 +1,56 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_StructureMenu.ts
* 평면 우클릭 구조물 메뉴(513-1 · 요구 327-1).
*
* · 메뉴 = 종단 그래프 우클릭과 같은 항목 · 같은 동작(`structureMenuItemsAt` · 재료 = 지금 선 종단 그래프).
* · 자리 = 누른 곳을 노선에 내린 누가거리 · 가까운 구조물 · 배관 측점이면 삭제.
* · 노드 위 = 옛 동작 그대로(노드 삭제 · `bindNodeSplice`) · 노선에서 먼 곳 = 메뉴 없음.
* ========================================================================== */
import { pickPx } from "../A00_Common/touch_bridge";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { liveStructureMenu, structureMenuItemsAt } from "./B05_Profile_UI_Profile_Structures";
import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input";
import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer";
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
/** 노선 위로 볼 거리(px) — 이보다 멀면 구조물 메뉴를 안 띄움. */
const ROUTE_HIT_PX = 12;
/** 구조물 · 배관 측점을 잡았다고 볼 거리(px) — 평면 마커 집기와 같은 값. */
const STATION_HIT_PX = 10;
/** 평면 캔버스에 구조물 우클릭 메뉴를 붙임 — 캔버스와 수명이 같음. */
export function bindPlanStructureMenu(
params: RouteEditPointerParams,
nodeAt: (px: number, py: number) => number,
): void {
const { canvas, toScreen } = params;
const menu = createMapContextMenu("b05-profile-chart");
canvas.parentElement?.append(menu.element);
canvas.addEventListener("contextmenu", (event) => {
menu.close(); // 노선 밖 · 노드 위 우클릭이면 옛 메뉴도 닫힌 채
const options = liveStructureMenu();
const rect = canvas.getBoundingClientRect();
const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (!options || nodeAt(px, py) >= 0) return; // 노드 삭제 몫
const state = params.state();
const line = state.plannedLine.length ? state.plannedLine : state.planned;
const hit = routePointAtScreen(line, toScreen, px, py, pickPx(ROUTE_HIT_PX));
if (!hit) return;
const near = options.stations
.map((station) => {
const frame = stationFrameAt(line, station.chainage_m);
const [x, y] = frame ? toScreen(frame.origin) : [Infinity, Infinity];
return { station, distance: Math.hypot(x - px, y - py) };
})
.filter((entry) => entry.distance <= pickPx(STATION_HIT_PX))
.sort((left, right) => left.distance - right.distance)[0];
const host = menu.element.parentElement?.getBoundingClientRect() ?? rect;
menu.open(
event.clientX - host.left,
event.clientY - host.top,
structureMenuItemsAt(options, hit.chainageM, near?.station ?? null),
);
});
}
@@ -0,0 +1,129 @@
/* 513-1 B05 평면 우클릭 구조물 메뉴(요구 327-1) — `_Profile_Structures` · `_RouteEdit_StructureMenu` 를 Node 로
* 돌려 JSON 한 줄(`test_513_1_plan_structure_menu.py` 가 판정). 메뉴 판 · 측점 판정은 가짜 · 항목 짓기 · 노선 셈은 진짜.
* node helper_513_1_plan_structure_menu.cjs
*/
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
const opens = [];
const calls = [];
/** 가짜 메뉴 — 연 자리 · 항목만 적음. */
const ui = () => ({
createMapContextMenu: () => ({
element: { parentElement: null },
open: (x, y, items) => opens.push({ x, y, items }),
close() {},
contains: () => false,
}),
});
const STUBS = {
"../A00_Common/touch_bridge": { pickPx: (v) => v },
"./B05_Profile_UI_IrregularStations": {
irregularStationId: (id) => `irr:${id}`,
isPipeStation: (station) => station.origin === "pipe",
},
"./B05_Profile_Api_Structures": { structureAnchorM: (s) => s.chainage_m ?? 0 },
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 },
};
const { loadTs } = makeLoader({ root: path.join(__dirname, "..", ".."), stubs: STUBS, ui });
const shared = loadTs(path.join(B05, "B05_Profile_UI_Profile_Structures.ts"));
const plan = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMenu.ts"));
/** 듣기만 적는 가짜 요소. */
function fakeElement(connected = true) {
const listeners = {};
return {
isConnected: connected,
listeners,
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
getBoundingClientRect: () => ({ left: 0, top: 0 }),
append(child) {
child.parentElement = this;
},
parentElement: null,
fire(type, clientX, clientY) {
let prevented = false;
(listeners[type] || []).forEach((fn) =>
fn({ clientX, clientY, target: null, preventDefault: () => (prevented = true) }),
);
return prevented;
},
};
}
// 노선 0 → 100 m 직선 · 화면 2 px/m · 배관 측점 40 m · 추가 측점 70 m
const PLUS = loadTs(
path.join(__dirname, "..", "..", "common_util", "common_util_structure_station.ts"),
).PLUS_OWNER_PREFIX;
const stations = [
{ id: "pipe-1", chainage_m: 40, origin: "pipe" },
{ id: `${PLUS}x`, chainage_m: 70 },
];
const options = {
stations,
x: (m) => m * 2,
chainageAt: (px) => px / 2,
maxChainageM: 100,
onRemove: (station) => calls.push(["remove", station.id]),
onRemovePlus: (m) => calls.push(["removePlus", m]),
onAddPipe: (m) => calls.push(["addPipe", m]),
structureGroups: () => [{ label: "A군", types: [{ type: { name: "배수관", type_id: "pipe" } }] }],
onAddStructureType: (m, typeId) => calls.push(["addType", m, typeId]),
};
const labels = (items) =>
items.map((item) =>
Array.isArray(item) ? item[0] : [item.label, item.children.map((c) => c[0])],
);
const pickFirst = (items) => {
const first = items[0];
(Array.isArray(first) ? first[1] : first.children[0][1])();
};
const out = {};
out.liveBefore = shared.liveStructureMenu() === null;
// 종단 그래프 — 빈 자리 · 배관 위
const host = fakeElement();
shared.mountStructureMenu(host, options);
host.fire("contextmenu", 120, 5); // 60 m
out.profileEmpty = labels(opens.at(-1).items);
pickFirst(opens.at(-1).items);
host.fire("contextmenu", 81, 5); // 40.5 m — 배관
out.profilePipe = labels(opens.at(-1).items);
// 평면 — 같은 재료
const wrap = fakeElement();
const canvas = fakeElement();
canvas.parentElement = wrap;
const line = [
[0, 0],
[100, 0],
];
const params = {
canvas,
toScreen: (v) => [v[0] * 2, v[1] * 2],
unrotate: (x, y) => [x, y],
state: () => ({ plannedLine: line, planned: line }),
};
const nodeAt = (px, py) => (Math.hypot(px, py) <= 6 ? 0 : Math.hypot(px - 200, py) <= 6 ? 1 : -1);
plan.bindPlanStructureMenu(params, nodeAt);
const planAt = (x, y) => {
const before = opens.length;
canvas.fire("contextmenu", x, y);
return opens.length > before ? labels(opens.at(-1).items) : null;
};
out.planEmpty = planAt(120, 8); // 60 m · 노선에서 8 px
pickFirst(opens.at(-1).items);
out.planPipe = planAt(82, 6); // 40 m 배관 근처
pickFirst(opens.at(-1).items);
out.planPlus = planAt(141, -4); // 70 m 추가 측점
out.planMenuAt = [opens.at(-1).x, opens.at(-1).y]; // 누른 화면 자리(캔버스 그릇 기준)
pickFirst(opens.at(-1).items);
out.planFar = planAt(120, 40); // 노선에서 40 px
out.planNode = planAt(2, 1); // 시점 노드 위 = 노드 삭제 몫
host.isConnected = false; // 종단 그래프가 떠남
out.planGone = planAt(120, 8);
out.calls = calls;
process.stdout.write(JSON.stringify(out));
+1 -1
View File
@@ -27,7 +27,7 @@ def test_누름_작은_창_우클릭_삭제() -> None:
assert "crossPickFromEvent(chartWrap, event" in render and "openCrossModal(" in render
assert "onRemovePlus: removePlusStation" in render
menu = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts")
assert '"측점 삭제", () => options.onRemovePlus?.(near.station.chainage_m)' in menu
assert '"측점 삭제", () => options.onRemovePlus?.(near.chainage_m)' in menu
modal = _read("B05_Profile/B05_Profile_UI_Profile_CrossModal.ts")
assert "previewStructureSections(projectId(), [m])" in modal
assert "add.disabled = taken" in modal # 같은 m 막음
@@ -0,0 +1,63 @@
"""513-1 B05 평면 우클릭 구조물 메뉴(요구 327-1) — 종단 그래프 우클릭과 같은 항목 · 같은 동작(공용
`structureMenuItemsAt`) · 자리 = 노선에 내린 누가거리 · 가까운 배관 · 측점 = 삭제 · 노선 밖 · 노드 위 = 메뉴 없음.
TS 셈은 `helper_513_1_plan_structure_menu.cjs`(Node · 항목 짓기 · 노선 셈은 진짜).
./venv/Scripts/python.exe -m pytest resources/tester/test_513_1_plan_structure_menu.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_513_1_plan_structure_menu.cjs")
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_평면_메뉴는_종단_메뉴와_같음(got) -> None:
assert got["liveBefore"] is True # 종단 그래프가 서기 전 = 재료 없음
assert got["profileEmpty"] == got["planEmpty"] == [["A군", ["배수관"]]]
assert got["profilePipe"] == got["planPipe"] == ["배관 삭제"]
assert got["planPlus"] == ["측점 삭제"]
assert got["planMenuAt"] == [141, -4]
def test_같은_동작_같은_자리(got) -> None:
assert got["calls"] == [
["addType", 60, "pipe"], # 종단 60 m
["addType", 60, "pipe"], # 평면 60 m(노선에 내린 자리)
["remove", "pipe-1"],
["removePlus", 70],
]
def test_노선_밖_노드_위_떠난_종단은_메뉴_없음(got) -> None:
assert got["planFar"] is None
assert got["planNode"] is None # 노드 삭제(옛 동작) 몫
assert got["planGone"] is None
def test_배선() -> None:
pointer = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Pointer.ts").read_text(
encoding="utf-8"
)
assert "bindPlanStructureMenu(params, nodeAt);" in pointer
viewer = (ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Structures.ts").read_text(
encoding="utf-8"
)
assert "structureMenuItemsAt(options, options.chainageAt(localX)" in viewer