diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 0a777853b..bf338b58a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -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(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index dae66c034..c5c0c2e11 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -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); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts new file mode 100644 index 000000000..efcb298d3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts @@ -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), + ); + }); +} diff --git a/resources/tester/helper_513_1_plan_structure_menu.cjs b/resources/tester/helper_513_1_plan_structure_menu.cjs new file mode 100644 index 000000000..a81d4debb --- /dev/null +++ b/resources/tester/helper_513_1_plan_structure_menu.cjs @@ -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)); diff --git a/resources/tester/test_368_3_cross_modal.py b/resources/tester/test_368_3_cross_modal.py index 3ea4faadc..2541545ee 100644 --- a/resources/tester/test_368_3_cross_modal.py +++ b/resources/tester/test_368_3_cross_modal.py @@ -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 막음 diff --git a/resources/tester/test_513_1_plan_structure_menu.py b/resources/tester/test_513_1_plan_structure_menu.py new file mode 100644 index 000000000..bd0081d5e --- /dev/null +++ b/resources/tester/test_513_1_plan_structure_menu.py @@ -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