From 7e39403b528955a96b67938bfaad0c6db88bbef5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 21:09:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(514-1=20=EC=95=8C=EC=95=BD=20=EC=9A=B0?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=82=AD=EC=A0=9C):=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=EC=84=A0=20=EC=97=86=EB=8A=94=20=EA=B5=AC=EC=A1=B0=EB=AC=BC(?= =?UTF-8?q?=EB=8C=80=ED=94=BC=EB=A1=9C=20=C2=B7=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=ED=98=95=20=ED=9A=A1=EB=8B=A8=EC=88=98=EB=A1=9C=20=EB=93=B1)?= =?UTF-8?q?=EB=8F=84=20=EC=A2=85=EB=8B=A8=20=EC=95=8C=EC=95=BD=20=C2=B7=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=20=EB=A7=88=EC=BB=A4=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=E3=80=8C=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=E3=80=8D=20=E2=80=94=20513-3=20=EC=A3=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=80=EB=A5=B4=EA=B8=B0=EB=A5=BC=20=EC=95=8C=EC=95=BD=20id?= =?UTF-8?q?=20=EB=A1=9C(removeMarkOwner=20=C2=B7=20=EA=B4=80=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=3D=20=EA=B4=80=20=EC=A7=80=EC=A0=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=20=C2=B7=20=EA=B7=B8=20=EB=B0=96=20=3D=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EC=B9=B8=20[=EC=82=AD=EC=A0=9C]=EC=99=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B8=B8)=20=C2=B7=20B05=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20B06=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=EA=B0=99=EC=9D=80=20=EA=B8=B8?= =?UTF-8?q?(onStructureMarkRemove)=20=C2=B7=20=EC=BA=90=EC=8B=9C=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=EB=95=8C=20?= =?UTF-8?q?=EB=82=98=EA=B0=90=20=C2=B7=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=80=20=3D=20=EA=B4=80=20=C2=B7=20?= =?UTF-8?q?=EC=84=B8=EC=9B=94=EA=B5=90=20=C2=B7=20BOX=20=EB=8F=84=20?= =?UTF-8?q?=E3=80=8C=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=E3=80=8D=20=ED=95=98=EB=82=98=20=C2=B7=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?test=5F514=5F1=20=C2=B7=20test=5F513=5F1=20=EA=B8=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=80=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20328-1=20=C2=B7=20328-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_pipe_ids.ts | 20 ++- A00_Common/design/design_shared.ts | 1 + A00_Common/design/design_view_profile.ts | 14 ++- .../B05_Profile_UI_Profile_Panel_Types.ts | 2 + B05_Profile/B05_Profile_UI_Profile_Render.ts | 2 + .../B05_Profile_UI_Profile_Structures.ts | 10 +- ...B05_Profile_UI_RouteEdit_StructureMarks.ts | 36 +++--- .../B05_Profile_UI_RouteEdit_StructureMenu.ts | 8 +- .../B05_Profile_UI_Structures_Marks.ts | 15 +++ .../B06_Section_UI_Section_View_Profile.ts | 10 +- .../helper_513_1_plan_structure_menu.cjs | 1 + resources/tester/helper_514_1_mark_remove.cjs | 119 ++++++++++++++++++ .../tester/test_513_1_plan_structure_menu.py | 2 +- resources/tester/test_514_1_mark_remove.py | 65 ++++++++++ 14 files changed, 270 insertions(+), 35 deletions(-) create mode 100644 resources/tester/helper_514_1_mark_remove.cjs create mode 100644 resources/tester/test_514_1_mark_remove.py diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts index 05228a613..7e17175c6 100644 --- a/A00_Common/design/design_pipe_ids.ts +++ b/A00_Common/design/design_pipe_ids.ts @@ -130,16 +130,26 @@ export function followMovedPipe(projectId: string | null, fromM: number, toM: nu ); } -/** 종단 측점 하나를 지움(513-3 · 우클릭 「배관 · 구조물 삭제」 종단 · 평면 공용) — 측점 id = 주인 · `주인:역할` +/** 우클릭 삭제의 두 길 — 관 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길. */ +export interface OwnerRemove { + pipe: (chainageM: number) => void; + structure: (structureId: string) => void; +} + +/** 종단 측점 하나를 지움(513-3 · 우클릭 「구조물 삭제」 종단 · 평면 공용) — 측점 id = 주인 · `주인:역할` * (`plannedIrregular`) · 관 기준 측점 = 그 자리 · 관 구간 끝 = 관 자리 · 그 밖 = 구조물 id(좌측 칸 [삭제]와 같은 길). */ export function removeStationOwner( station: { id: string; chainage_m: number }, pipe: boolean, - remove: { pipe: (chainageM: number) => void; structure: (structureId: string) => void }, + remove: OwnerRemove, ): void { if (pipe) return remove.pipe(station.chainage_m); - const owner = station.id.replace(/:(start|end)$/, ""); - const at = pipeChainageOf(owner); + removeMarkOwner(station.id.replace(/:(start|end)$/, ""), remove); +} + +/** 알약 하나를 지움(514-1 · 측점선 없는 구조물도) — 관 알약 = 관 세션 id 장부 자리 · 그 밖 = 구조물 id. */ +export function removeMarkOwner(structureId: string, remove: OwnerRemove): void { + const at = pipeChainageOf(structureId); if (at !== null) remove.pipe(at); - else remove.structure(owner); + else remove.structure(structureId); } diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index f19ec6448..fdcdab850 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -223,6 +223,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { onIrregularSelect: toView(shared, "onIrregularSelect"), onStructureSelect: toView(shared, "onStructureSelect"), onStructureMarkMove: toView(shared, "onStructureMarkMove"), + onStructureMarkRemove: toView(shared, "onStructureMarkRemove"), onStructureTypeAdd: toView(shared, "onStructureTypeAdd"), structureGroups: () => sharedStructures(shared).pickGroups(), onBasinSelected: toView(shared, "onBasinSelected"), diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 4aa105054..58a2342b9 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; -import { pipeIdAt, removeStationOwner } from "./design_pipe_ids"; +import { pipeIdAt, removeMarkOwner, removeStationOwner } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; import { pageSettingsPanelValues } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { setPlusStations } from "../../common_util/common_util_structure_station"; @@ -172,6 +172,10 @@ export async function mountProfileView( } panel.structures.moveById(structureId, toChainage); }; + const removeOwner = { + pipe: (chainage: number) => profilePanel.drainage.removePipe(chainage), + structure: (structureId: string) => void panel.structures.removeById(structureId), + }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( shared, @@ -194,12 +198,10 @@ export async function mountProfileView( onStructureMove: (from, to, station) => { if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); }, - // 우클릭 삭제 — 관 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길(513-3) + // 우클릭 삭제 — 관 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길(513-3 · 알약 514-1) onStructureRemove: (station) => - removeStationOwner(station, isPipeStation(station), { - pipe: (chainage) => profilePanel.drainage.removePipe(chainage), - structure: (structureId) => panel.structures.removeById(structureId), - }), + removeStationOwner(station, isPipeStation(station), removeOwner), + onStructureMarkRemove: (structureId) => removeMarkOwner(structureId, removeOwner), // 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18). onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"), onStructureAdd: (chainage, type) => diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts index 9c71a8d0d..36bfb2404 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -48,6 +48,8 @@ export interface RouteProfilePanelCallbacks { onStructureSelect?: (structureId: string | null) => void; /** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */ onStructureMarkMove?: (structureId: string, toChainageM: number) => void; + /** 알약 · 평면 마커 우클릭으로 그 구조물(관 포함)을 지움(514-1). */ + onStructureMarkRemove?: (structureId: string) => void; /** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */ onStructureTypeAdd?: (chainageM: number, typeId: string) => void; /** 우클릭 군 · 종류 목록 = 사이드 「구조물 배치」 드롭다운 그대로(129-1 · B05 · B06 한 패널). */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index ffa7410d0..b1a92e3d2 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -408,6 +408,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { chainageAt: chainageInverter(longitudinal, width, layout.originOffset), maxChainageM: maxChainageOf(longitudinal), onRemove: (station) => callbacks?.onStructureRemove?.(station), + onRemoveMark: (structureId) => callbacks?.onStructureMarkRemove?.(structureId), onRemovePlus: removePlusStation, onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage), onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), @@ -437,6 +438,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.redraw(); }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), + onRemove: (structureId) => callbacks?.onStructureMarkRemove?.(structureId), // 라벨 흙막이 상자 수 = 폼과 같은 유출 단 수(54-1 · 54-14) containerCount: (structure) => structure.type_id === "pipe" diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index bf338b58a..aeaf3c66f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -11,11 +11,7 @@ import { pickPx } from "../A00_Common/touch_bridge"; import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { - irregularStationId, - isPipeStation, - type IrregularStation, -} from "./B05_Profile_UI_IrregularStations"; +import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { structureAnchorM, type StructureInstance, @@ -60,6 +56,8 @@ export interface StructureLineOptions { maxChainageM: number; /** 선을 지울 때. */ onRemove: (station: IrregularStation) => void; + /** 알약(평면 마커)으로 구조물을 지울 때(514-1 · 측점선 없는 구조물도). */ + onRemoveMark?: (structureId: string) => void; /** 추가 측점을 지울 때(368-3 · 우클릭 「측점 삭제」). */ onRemovePlus?: (chainageM: number) => void; /** 빈 자리에서 우클릭해 배관을 넣을 때. */ @@ -89,7 +87,7 @@ export function structureMenuItemsAt( if (near) { if (near.id.startsWith(PLUS_OWNER_PREFIX)) return [["측점 삭제", () => options.onRemovePlus?.(near.chainage_m)]]; - return [[isPipeStation(near) ? "배관 삭제" : "구조물 삭제", () => options.onRemove(near)]]; + return [["구조물 삭제", () => options.onRemove(near)]]; // 관 · 세월교 · BOX 도 한 글(514-1) } const at = Number(Math.min(Math.max(chainageM, 0), options.maxChainageM).toFixed(2)); // 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts index 00a6f244a..06c66cbf8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -258,6 +258,26 @@ function showBalloon( : `translate(-50%, calc(-100% - ${gap}px))`; } +/** 화면 자리에서 가장 가까운 마커 — 집기 거리 밖이면 null(누름 · 우클릭 삭제 514-1 공용). */ +export function planMarkAt( + canvas: HTMLCanvasElement, + line: ReadonlyArray, + toScreen: (point: Vertex) => [number, number], + px: number, + py: number, +): { mark: PlanMark; distance: number } | null { + let best: PlanMark | null = null; + let bestDistance = pickPx(MARK_HIT_PX); + for (const mark of planMarks(canvas)?.marks ?? []) { + const frame = stationFrameAt(line, mark.chainageM); + if (!frame) continue; + const [x, y] = toScreen(frame.origin); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) [best, bestDistance] = [mark, distance]; + } + return best ? { mark: best, distance: bestDistance } : null; +} + export interface StructureMarkInput { /** 마커를 눌렀으면 고르고 끌기 시작 · true(재기 다음 맨 앞 · 409). */ press: (px: number, py: number) => boolean; @@ -288,20 +308,8 @@ export function bindStructureMarks( const state = params.state(); return state.plannedLine.length ? state.plannedLine : state.planned; }; - const markAt = (px: number, py: number): { mark: PlanMark; distance: number } | null => { - const found = planMarks(canvas); - const line = lineNow(); - let best: PlanMark | null = null; - let bestDistance = pickPx(MARK_HIT_PX); - for (const mark of found?.marks ?? []) { - const frame = stationFrameAt(line, mark.chainageM); - if (!frame) continue; - const [x, y] = params.toScreen(frame.origin); - const distance = Math.hypot(x - px, y - py); - if (distance <= bestDistance) [best, bestDistance] = [mark, distance]; - } - return best ? { mark: best, distance: bestDistance } : null; - }; + const markAt = (px: number, py: number): { mark: PlanMark; distance: number } | null => + planMarkAt(canvas, lineNow(), params.toScreen, px, py); const say = (moving: Moving, done: boolean): void => { const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM; const shift = moving.to - moving.from; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts index efcb298d3..a57aae7fa 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMenu.ts @@ -3,7 +3,7 @@ * 평면 우클릭 구조물 메뉴(513-1 · 요구 327-1). * * · 메뉴 = 종단 그래프 우클릭과 같은 항목 · 같은 동작(`structureMenuItemsAt` · 재료 = 지금 선 종단 그래프). - * · 자리 = 누른 곳을 노선에 내린 누가거리 · 가까운 구조물 · 배관 측점이면 삭제. + * · 자리 = 누른 곳을 노선에 내린 누가거리 · 가까운 구조물 · 배관 측점이면 삭제 · 측점선 없는 마커도(514-1). * · 노드 위 = 옛 동작 그대로(노드 삭제 · `bindNodeSplice`) · 노선에서 먼 곳 = 메뉴 없음. * ========================================================================== */ @@ -11,6 +11,7 @@ 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 { planMarkAt } from "./B05_Profile_UI_RouteEdit_StructureMarks"; import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer"; import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; @@ -47,10 +48,13 @@ export function bindPlanStructureMenu( .filter((entry) => entry.distance <= pickPx(STATION_HIT_PX)) .sort((left, right) => left.distance - right.distance)[0]; const host = menu.element.parentElement?.getBoundingClientRect() ?? rect; + const mark = near ? null : planMarkAt(canvas, line, toScreen, px, py)?.mark; menu.open( event.clientX - host.left, event.clientY - host.top, - structureMenuItemsAt(options, hit.chainageM, near?.station ?? null), + mark + ? [["구조물 삭제", () => options.onRemoveMark?.(mark.id)]] + : structureMenuItemsAt(options, hit.chainageM, near?.station ?? null), ); }); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 87b64fd38..1f711a65c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -16,6 +16,7 @@ * ========================================================================== */ import { pickPx } from "../A00_Common/touch_bridge"; +import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { structureAnchorM, type StructureInstance, @@ -73,6 +74,8 @@ export interface StructureMarksOptions { selectedId: string | null; onSelect: (structureId: string | null) => void; onMove: (structureId: string, toChainageM: number) => void; + /** 우클릭 「구조물 삭제」(514-1 · 측점선 없는 알약도) — 없으면 메뉴 없음. */ + onRemove?: (structureId: string) => void; /** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */ containerCount?: (structure: StructureInstance) => ContainerCount | undefined; } @@ -162,6 +165,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement const typeMap = new Map(options.types.map((type) => [type.type_id, type])); const structures = laneStructures(options.structures); + const menu = createMapContextMenu("b05-profile-chart"); // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이 // 모두 이 번호를 따른다. const texts = structurePillTexts(structures, typeMap); @@ -227,6 +231,16 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement options.onSelect(selected ? null : id); }); attachMarkDrag(mark, structure, options); + mark.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + const remove = options.onRemove; + if (!remove) return; + const rect = lane.getBoundingClientRect(); + menu.open(event.clientX - rect.left, event.clientY - rect.top, [ + ["구조물 삭제", () => remove(id)], + ]); + }); lane.append(mark); if (selected) @@ -257,6 +271,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement axisInner.textContent = "구조물"; axis.append(axisInner); lane.prepend(axis); + lane.append(menu.element); return lane; } diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 71b5e9c2b..7ad4a6587 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -24,7 +24,7 @@ import { } from "../A00_Common/design/design_selection"; import { paintMapPick } from "../A00_Common/design/design_pick_profile"; import { ESCAPE_FROM } from "../A00_Common/design/design_pick_escape"; -import { removeStationOwner } from "../A00_Common/design/design_pipe_ids"; +import { removeMarkOwner, removeStationOwner } from "../A00_Common/design/design_pipe_ids"; import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { fetchLatestRoute, @@ -164,6 +164,14 @@ export function createSectionProfile( structure: edit.removeStructure, }); }, + onStructureMarkRemove: (structureId) => { + const edit = host.edit(); + if (edit) + removeMarkOwner(structureId, { + pipe: edit.removePipe, + structure: edit.removeStructure, + }); + }, onPipeAdd: (chainage) => host.edit()?.addPipe(chainage), onStructureAdd: (chainage, type) => host.edit()?.addStructureType(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), diff --git a/resources/tester/helper_513_1_plan_structure_menu.cjs b/resources/tester/helper_513_1_plan_structure_menu.cjs index a81d4debb..b45259649 100644 --- a/resources/tester/helper_513_1_plan_structure_menu.cjs +++ b/resources/tester/helper_513_1_plan_structure_menu.cjs @@ -26,6 +26,7 @@ const STUBS = { }, "./B05_Profile_Api_Structures": { structureAnchorM: (s) => s.chainage_m ?? 0 }, "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "./B05_Profile_UI_RouteEdit_StructureMarks": { planMarkAt: () => null }, // 마커 없음(514-1 몫) }; const { loadTs } = makeLoader({ root: path.join(__dirname, "..", ".."), stubs: STUBS, ui }); const shared = loadTs(path.join(B05, "B05_Profile_UI_Profile_Structures.ts")); diff --git a/resources/tester/helper_514_1_mark_remove.cjs b/resources/tester/helper_514_1_mark_remove.cjs new file mode 100644 index 000000000..d1220e539 --- /dev/null +++ b/resources/tester/helper_514_1_mark_remove.cjs @@ -0,0 +1,119 @@ +/* 514-1 알약 · 평면 마커 우클릭 「구조물 삭제」(요구 328-1) — `design_pipe_ids.removeMarkOwner` · + * `_RouteEdit_StructureMenu` 를 Node 로 돌려 JSON 한 줄(`test_514_1_mark_remove.py` 가 판정). + * 관 장부 · 메뉴 판 · 마커 집기는 가짜 · 주인 가르기 · 평면 항목 짓기는 진짜. + * node helper_514_1_mark_remove.cjs + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const B05 = path.join(ROOT, "B05_Profile"); +const opens = []; +const calls = []; +const ui = () => ({ + createMapContextMenu: () => ({ + element: { parentElement: null }, + open: (x, y, items) => opens.push({ x, y, items }), + close() {}, + contains: () => false, + }), +}); +// 평면 마커 = 측점선 없는 대피로 20 m(화면 40 px) +const MARK = { id: "evac-1", chainageM: 20 }; +const STUBS = { + "../b_page_state": { + readState: () => ({ next: 4, ids: [{ id: "pipe-s3", at: 55 }] }), + writeState() {}, + }, + "./design_selection": { + PIPE_MATCH_M: 0.51, + isNear: () => false, + nearestWithin: () => null, + readSelection: () => null, + selectDesign() {}, + }, + common_util_pick_tag: { setPipeTagSource() {} }, + "../A00_Common/touch_bridge": { pickPx: (v) => v }, + "./B05_Profile_UI_IrregularStations": { irregularStationId: (id) => `irr:${id}` }, + "./B05_Profile_Api_Structures": { structureAnchorM: (s) => s.chainage_m ?? 0 }, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "./B05_Profile_UI_RouteEdit_StructureMarks": { + planMarkAt: (_c, _l, toScreen, px, py) => { + const [x, y] = toScreen([MARK.chainageM, 0]); + const distance = Math.hypot(x - px, y - py); + return distance <= 10 ? { mark: MARK, distance } : null; + }, + }, +}; +const { loadTs } = makeLoader({ root: ROOT, stubs: STUBS, ui }); +const ids = loadTs(path.join(ROOT, "A00_Common", "design", "design_pipe_ids.ts")); +ids.usePipeIds("p1"); +const shared = loadTs(path.join(B05, "B05_Profile_UI_Profile_Structures.ts")); +const plan = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMenu.ts")); + +const out = {}; +// 알약 주인 가르기 — 관 세션 id · 옛 자리 이름 관 = 관 지점 · 그 밖 = 구조물 id +const remove = { + pipe: (m) => calls.push(["pipe", m]), + structure: (id) => calls.push(["structure", id]), +}; +["pipe-s3", "pipe-12.50", "evac-1", "cross-2"].forEach((id) => ids.removeMarkOwner(id, remove)); +out.owners = calls.splice(0); + +function fakeElement() { + const listeners = {}; + return { + isConnected: true, + 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) { + (listeners[type] || []).forEach((fn) => + fn({ clientX, clientY, target: null, preventDefault() {} }), + ); + }, + }; +} +// 종단 그래프 = 재료(관 측점 40 m 하나) +const host = fakeElement(); +shared.mountStructureMenu(host, { + stations: [{ id: "pipe-1", chainage_m: 40, origin: "pipe" }], + x: (m) => m * 2, + chainageAt: (px) => px / 2, + maxChainageM: 100, + onRemove: (station) => calls.push(["remove", station.id]), + onRemoveMark: (id) => calls.push(["removeMark", id]), + onAddPipe: () => {}, + structureGroups: () => [{ label: "A군", types: [{ type: { name: "배수관", type_id: "pipe" } }] }], + onAddStructureType: () => {}, +}); +const canvas = fakeElement(); +canvas.parentElement = fakeElement(); +const line = [ + [0, 0], + [100, 0], +]; +plan.bindPlanStructureMenu( + { + canvas, + toScreen: (v) => [v[0] * 2, v[1] * 2], + unrotate: (x, y) => [x, y], + state: () => ({ plannedLine: line, planned: line }), + }, + () => -1, +); +const planAt = (x, y) => { + canvas.fire("contextmenu", x, y); + const items = opens.at(-1).items; + return items.map((item) => (Array.isArray(item) ? item[0] : item.label)); +}; +out.planMark = planAt(41, 3); // 대피로 마커 +opens.at(-1).items[0][1](); +out.planPipe = planAt(81, 2); // 관 측점선 = 측점 먼저 +opens.at(-1).items[0][1](); +out.planEmpty = planAt(140, 2); // 빈 자리 = 추가 메뉴 +out.calls = calls; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_513_1_plan_structure_menu.py b/resources/tester/test_513_1_plan_structure_menu.py index bd0081d5e..283b2471a 100644 --- a/resources/tester/test_513_1_plan_structure_menu.py +++ b/resources/tester/test_513_1_plan_structure_menu.py @@ -32,7 +32,7 @@ def got() -> dict: def test_평면_메뉴는_종단_메뉴와_같음(got) -> None: assert got["liveBefore"] is True # 종단 그래프가 서기 전 = 재료 없음 assert got["profileEmpty"] == got["planEmpty"] == [["A군", ["배수관"]]] - assert got["profilePipe"] == got["planPipe"] == ["배관 삭제"] + assert got["profilePipe"] == got["planPipe"] == ["구조물 삭제"] # 글 하나(514-1) assert got["planPlus"] == ["측점 삭제"] assert got["planMenuAt"] == [141, -4] diff --git a/resources/tester/test_514_1_mark_remove.py b/resources/tester/test_514_1_mark_remove.py new file mode 100644 index 000000000..49101a43d --- /dev/null +++ b/resources/tester/test_514_1_mark_remove.py @@ -0,0 +1,65 @@ +"""514-1 알약 · 평면 마커 우클릭 「구조물 삭제」(요구 328-1 · 328-2) — 측점선 없는 구조물(대피로 · 노출형 횡단수로)도 +알약 · 평면 마커 우클릭으로 지움 · 513-3 과 같은 주인 가르기(`removeMarkOwner`) · 삭제 글 = 「구조물 삭제」 하나. + +TS 셈은 `helper_514_1_mark_remove.cjs`(Node · 주인 가르기 · 평면 항목 짓기는 진짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_514_1_mark_remove.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_514_1_mark_remove.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["owners"] == [ + ["pipe", 55], # 관 세션 id = 장부 자리 + ["pipe", 12.5], # 옛 자리 이름 관 + ["structure", "evac-1"], + ["structure", "cross-2"], + ] + + +def test_평면_마커_우클릭(got) -> None: + assert got["planMark"] == ["구조물 삭제"] + assert got["planPipe"] == ["구조물 삭제"] # 관도 같은 글 + assert got["planEmpty"] == ["A군"] + assert got["calls"] == [["removeMark", "evac-1"], ["remove", "pipe-1"]] + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +def test_배선() -> None: + marks = _read("B05_Profile", "B05_Profile_UI_Structures_Marks.ts") + assert 'mark.addEventListener("contextmenu"' in marks + assert '["구조물 삭제", () => remove(id)]' in marks + render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") + assert render.count("callbacks?.onStructureMarkRemove?.(structureId)") == 2 # 알약 · 평면 + assert "배관 삭제" not in _read("B05_Profile", "B05_Profile_UI_Profile_Structures.ts") + assert 'toView(shared, "onStructureMarkRemove")' in _read( + "A00_Common", "design", "design_shared.ts" + ) + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "removeMarkOwner(structureId, removeOwner)" in view + cross = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts") + assert "removeMarkOwner(structureId, {" in cross