From fc35579f28ad215c56dbfcff825d288d9365e87b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:37:08 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(B05=20=ED=8F=89=EB=A9=B4=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=B0=BD):=20=EC=82=BC=EA=B0=81=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=80=20=ED=95=9C=20=EC=A4=84=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=E3=80=8C=EB=82=B4=EA=B0=81=20N=C2=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EC=97=86=EC=9D=8C=E3=80=8D=20(403-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 3 ++- resources/tester/test_403_triangle_angle.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index f292f93cd..378add39f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -58,7 +58,8 @@ export function centerDirectionOf( * 칸이 막음. 155° 이상은 L 하한을 안 거는 까닭이 보이게. 곡선 없는 교각점(삼각)도 내각(403-1). */ export function curveInfoText(curveOn: boolean, inner: number | null, optional: boolean): string { const angle = inner ? `내각 ${Math.round(inner)}°` : ""; - if (!curveOn) return [angle, "곡선 없음 — 직선이 그대로 꺾입니다"].filter(Boolean).join(" · "); + // 한 줄에 들게 — 내각이 있으면 풀이 글은 뺌. + if (!curveOn) return angle ? `${angle} · 곡선 없음` : "곡선 없음 — 직선이 그대로 꺾입니다"; return angle && optional ? `${angle} · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능` : angle; diff --git a/resources/tester/test_403_triangle_angle.py b/resources/tester/test_403_triangle_angle.py index 21bcd1003..3943450cc 100644 --- a/resources/tester/test_403_triangle_angle.py +++ b/resources/tester/test_403_triangle_angle.py @@ -32,8 +32,8 @@ def test_곡선_있는_자리_그대로(texts): def test_삼각_자리도_내각(texts): - assert texts["triangle"] == "내각 133° · 곡선 없음 — 직선이 그대로 꺾입니다" - assert texts["triangleOptional"].startswith("내각 168° · ") + assert texts["triangle"] == "내각 133° · 곡선 없음" + assert texts["triangleOptional"] == "내각 168° · 곡선 없음" def test_내각_모르면_곡선_없음만(texts): From c3efa6dd7e2bed47f7ae393f4ae04cc65c04473d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:43:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EB=A7=88=EC=BB=A4):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=B8=A1=EC=A0=90=20=EB=A7=88=EC=BB=A4=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=EC=A0=95=EB=B3=B4=20=EC=B0=BD=20=C2=B7=20?= =?UTF-8?q?20=20m=20=EB=B2=95=EC=84=A0=20=C2=B7=20=EB=81=8C=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B0=A9=ED=96=A5=ED=82=A4=201=20m=20(399)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마커 = 종단 알약과 같은 목록(laneStructures) · 노선 위 알약 꼴(종류 색) · 스냅 · 간격과 무관 - 고름 = 알약 고름과 같은 길(onStructureSelect · 공용 고름) · 알약 벌룬 그대로 · 좌우 20 m 법선 - 끌기 · 방향키 = 노선 따라 정수 m 1 m 씩 · 시점 · 종점 밖 안 감 · 놓을 때 알약 끌기와 같은 길로 한 번 - 집기 차례 = 교각점 · 곡선 손잡이 다음 · 옛 마름모 지움 - 시험 test_399_plan_structure_marks · test_391 마름모 줄 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- A00_Common/design/design_view_profile.ts | 57 ++- B05_Profile/B05_Profile_UI_Page_Structures.ts | 5 + .../B05_Profile_UI_RouteEdit_Pointer.ts | 9 +- .../B05_Profile_UI_RouteEdit_Render.ts | 26 +- ...B05_Profile_UI_RouteEdit_StructureMarks.ts | 386 ++++++++++++++++++ .../B05_Profile_UI_Structures_Marks.ts | 32 +- .../B05_Profile_RouteEdit_StructureMarks.ts | 47 +++ .../helper_399_plan_structure_marks.cjs | 301 ++++++++++++++ .../tester/test_391_routeedit_plan_draw.py | 3 +- .../tester/test_399_plan_structure_marks.py | 128 ++++++ 10 files changed, 938 insertions(+), 56 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts create mode 100644 B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts create mode 100644 resources/tester/helper_399_plan_structure_marks.cjs create mode 100644 resources/tester/test_399_plan_structure_marks.py diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 47787bced..fd1dde7ec 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -84,6 +84,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections"; import { createProfile3d } from "./design_view_profile_3d"; +import { setPlanMarkSource } from "../../B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks"; type PipeReport = Parameters>[0]; @@ -159,6 +160,28 @@ export async function mountProfileView( profilePanel.setSelectedStation(target.stationId); if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId); }; + // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 + // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). 평면 구조물 마커(399)도 이 길. + const onStructureSelect = (structureId: string | null): void => { + const chainage = bridge.pipeMarkChainage(structureId); + // 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178) + const keepId = chainage === null || bridge.stationlessPipeMark(structureId); + rememberPick(bridge.markChainage(structureId), keepId ? structureId : null); + if (chainage !== null) { + panel.structures.selectPipeByChainage(chainage); + selectStationOfPipe(chainage); + return; + } + panel.structures.selectById(structureId); + }; + const onStructureMarkMove = (structureId: string, toChainage: number): void => { + const chainage = bridge.pipeMarkChainage(structureId); + if (chainage !== null) { + profilePanel.drainage.movePipe(chainage, toChainage); + return; + } + panel.structures.moveById(structureId, toChainage); + }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( shared, @@ -192,28 +215,8 @@ export async function mountProfileView( // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. onBasinSelected: (chainageM) => pickFromMap(chainageM), onPipeSelected: (chainageM) => pickFromMap(chainageM), - // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 - // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). - onStructureSelect: (structureId) => { - const chainage = bridge.pipeMarkChainage(structureId); - // 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178) - const keepId = chainage === null || bridge.stationlessPipeMark(structureId); - rememberPick(bridge.markChainage(structureId), keepId ? structureId : null); - if (chainage !== null) { - panel.structures.selectPipeByChainage(chainage); - selectStationOfPipe(chainage); - return; - } - panel.structures.selectById(structureId); - }, - onStructureMarkMove: (structureId, toChainage) => { - const chainage = bridge.pipeMarkChainage(structureId); - if (chainage !== null) { - profilePanel.drainage.movePipe(chainage, toChainage); - return; - } - panel.structures.moveById(structureId, toChainage); - }, + onStructureSelect, + onStructureMarkMove, onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId), }, ); @@ -376,6 +379,16 @@ export async function mountProfileView( renderStationLines: () => sections.redrawStationLines(), isRestoring: () => state.restoring, }); + // 평면 구조물 측점 마커(399) — 알약과 같은 목록 · 고름 · 옮김 + host.onDispose( + setPlanMarkSource(activeProjectId, { + marks: bridge.marks, + types: bridge.types, + intervalM: () => panel.values().stationInterval || 20, + select: onStructureSelect, + move: onStructureMarkMove, + }), + ); // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) const profilePick = bindProfilePick( activeProjectId, diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 64b0af6f0..7420aaaa1 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -37,6 +37,7 @@ import { planStations } from "../common_util/common_util_structure_station"; import { plannedIrregular } from "../A00_Common/design/design_structure_status"; import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids"; import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data"; +import { planMarksChanged } from "./B05_Profile_UI_RouteEdit_StructureMarks"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -155,6 +156,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { function syncGraphStructures(): void { deps.profilePanel().setStructures([...ownStructures, ...pipeMarks]); + planMarksChanged(deps.projectId); // 평면 구조물 마커 = 알약과 같은 목록(399) } /** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */ @@ -337,6 +339,9 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }, /** 측점 계획을 다시 맞춤 — 추가 측점이 바뀜(368-2). */ resyncStations: () => syncCrossDrainStations(), + /** 알약 레인 목록 · 종류 — 평면 구조물 마커가 같은 것을 씀(399). */ + marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks], + types: (): StructureType[] => planTypes, /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ irregularStations: () => irregularStations, /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index f7b350137..b9c89ec5e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -12,6 +12,7 @@ import { pickPx } from "../A00_Common/touch_bridge"; import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; 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 { dragHandleTo as curveDragTo, type EditedCurve, @@ -191,6 +192,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP let dragMoved = false; /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */ let dragRaw: Vertex[] | null = null; + /** 구조물 측점 마커(399) — 집기 차례 = 교각점 · 손잡이 다음 · 끌기 · 방향키 1 m. */ + const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; @@ -236,6 +239,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP })() ) { /* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */ + } else if (marks.press(px, py)) { + /* 구조물 마커를 골랐다 — 끌면 노선 따라 1 m 씩(399). */ } else if ( // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. (() => { @@ -288,6 +293,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const rect = canvas.getBoundingClientRect(); const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top); const state = params.state(); + if (marks.move(px, py)) return; if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). const node = dragHandle.node; @@ -361,7 +367,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP ? state.apexLock[overNode] ? "not-allowed" : "grab" - : handleAt(px, py) + : handleAt(px, py) || marks.over(px, py) ? "grab" : over ? "pointer" @@ -373,6 +379,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + marks.release(); // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 // 눌러야 한다. 놓는 순간에만 쌓는다. if (dragMoved) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 56716eec0..7c69b1794 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -29,12 +29,12 @@ import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdi import { drawPickHover, drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; import { drawPickedCurve, traceNodeTriangle } from "./B05_Profile_UI_RouteEdit_Curve_Pick"; import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross"; -import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock"; import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint"; import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; +import { drawStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks"; /** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */ const NODE_R = 6; @@ -255,6 +255,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저 // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다. drawContourLabels(context, scene, band, style); + drawStructureMarks(context, scene, line, style); // 구조물 측점 마커(399) — 라벨 위 · 교각점 아래 // 노드 · 손잡이는 라벨 뒤 맨 위(381-3) — 측점 · 기울기 · 높이 글자에 묻혀 안 집히던 자리. context.save(); context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; @@ -396,33 +397,14 @@ function drawMeasureMarks( } /** 고른 측점 — 맴돔과 같은 좌우 20 m 법선(391-1 · `drawStationNormal`) · 구조물 · 관 고름 = - * 그 자리 마름모(배수유역도 `markStation` 꼴)만. 절성토 색은 발자국 선이 보여 줌. */ + * 구조물 측점 마커가 법선 · 정보 창까지(399 · `drawStructureMarks`). 절성토 색은 발자국 선이 보여 줌. */ function drawCrossWidth( context: CanvasRenderingContext2D, scene: RouteEditScene, line: ReadonlyArray, ): void { const watch = scene.crossWidth; - if (!watch) return; - if (!watch.structure) { - drawStationNormal(context, scene, line, watch.chainageM); - return; - } - const frame = stationFrameAt(line, watch.chainageM); - if (!frame) return; - const [cx, cy] = scene.toScreen(frame.origin); - context.save(); - context.setLineDash([]); - context.translate(cx, cy); - context.rotate(Math.PI / 4); - context.beginPath(); - context.rect(-7, -7, 14, 14); - context.fillStyle = "rgba(255, 196, 0, 0.35)"; - context.fill(); - context.lineWidth = 2.5; - context.strokeStyle = "#ff9800"; - context.stroke(); - context.restore(); + if (watch && !watch.structure) drawStationNormal(context, scene, line, watch.chainageM); } /** diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts new file mode 100644 index 000000000..d3a47406e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -0,0 +1,386 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_StructureMarks.ts + * 평면 **구조물 측점 마커**(PLAN 399 · 요구 250-2). + * + * · 목록 = 종단 아래 알약과 같은 것(`laneStructures` · 관 · 구조물 · 물넘이 · 사토장 …) · 노선 위 그 측점에 + * 알약 꼴(종류 색) · 일반 측점 눈금 · 스냅 · 간격과 무관. + * · 고름 = 알약 고름과 같은 길(`select` · 공용 고름 382-2 — 평면 마커 고름 = 종단 알약 강조 · 반대도). + * 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391). + * · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 · + * 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본). + * · 집기 차례 = 교각점 · 곡선 손잡이 다음(381-3) — `_Pointer` 가 부름. + * 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`. + * ========================================================================== */ + +import { pickPx } from "../A00_Common/touch_bridge"; +import { draftProjectId } from "../A00_Common/design/design_store"; +import { PIPE_MATCH_M, readSelection } from "../A00_Common/design/design_selection"; +import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; +import { + balloonElement, + laneStructures, + positionText, + structurePillTexts, +} from "./B05_Profile_UI_Structures_Marks"; +import { balloonLines } from "./logic/B05_Profile_UI_Structures_Balloon"; +import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; +import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; +import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input"; +import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer"; +import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; +import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; +import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; +import { arrowStep, isArrowKey, type ArrowKey } from "./logic/B05_Profile_RouteEdit_KeyNudge"; +import { + arrowSign, + markChainage, + markStep, + pickedMarkId, +} from "./logic/B05_Profile_RouteEdit_StructureMarks"; +import { stationLabel as formatStation } from "../common_util/common_util_station"; + +/** 마커를 잡았다고 볼 거리(px). */ +const MARK_HIT_PX = 10; +/** 알약 마커 반 길이 · 굵기(px) — 고르면 조금 크게. */ +const MARK_HALF_PX = 5; +const MARK_WIDTH_PX = 8; +/** 고른 마커 바깥 테 — 법선과 같은 하늘색(391). */ +const PICKED_RING = "#0ea5e9"; + +/** 종단 보기가 건네는 것 — 알약 목록 · 종류 · 측점 간격 · 고름 · 옮김(알약과 같은 길). */ +export interface PlanMarkSource { + marks: () => ReadonlyArray; + types: () => ReadonlyArray; + intervalM: () => number; + select: (structureId: string) => void; + move: (structureId: string, toChainageM: number) => void; +} + +const sources = new Map(); +/** 목록이 바뀌면 다시 그릴 편집 창(프로젝트마다). */ +const redraws = new Map void>>(); + +/** 알약 목록이 바뀜(추가 · 옮김 · 노선 따라감) — 그 프로젝트 편집 창을 다시 그림. */ +export function planMarksChanged(projectId: string): void { + redraws.get(projectId)?.forEach((redraw) => redraw()); +} + +/** 프로젝트의 마커 재료를 건넴 — 돌려준 함수로 뗌(보기를 떠날 때). */ +export function setPlanMarkSource(projectId: string, source: PlanMarkSource): () => void { + sources.set(projectId, source); + return () => void (sources.get(projectId) === source && sources.delete(projectId)); +} + +interface PlanMark { + id: string; + chainageM: number; + structure: StructureInstance; + type: StructureType | undefined; + name: string; +} + +/** 옮기는 중(끌기 · 방향키 누름) — 놓을 때 · 뗄 때 `to` 로 한 번 옮김. 창(캔버스)마다 따로. */ +interface Moving { + id: string; + name: string; + from: number; + to: number; + totalM: number; +} +const movings = new WeakMap(); +const balloons = new WeakMap(); + +const lengthOf = (line: ReadonlyArray): number => + line.reduce( + (sum, vertex, index) => + index ? sum + Math.hypot(vertex[0] - line[index - 1][0], vertex[1] - line[index - 1][1]) : 0, + 0, + ); + +/** 지금 마커 목록(옮기는 것은 그 자리) · 고른 것 — 재료가 없으면 null. */ +function planMarks( + canvas: HTMLCanvasElement, +): { source: PlanMarkSource; marks: PlanMark[]; picked: string | null } | null { + const projectId = draftProjectId(); + const source = sources.get(projectId); + if (!source) return null; + const list = laneStructures(source.marks()); + const types = new Map(source.types().map((type) => [type.type_id, type])); + const texts = structurePillTexts(list, types); + const moving = movings.get(canvas) ?? null; + const marks = list + .filter((structure) => structure.structure_id) + .map((structure): PlanMark => { + const id = structure.structure_id as string; + return { + id, + chainageM: moving?.id === id ? moving.to : structureAnchorM(structure), + structure, + type: types.get(structure.type_id), + name: texts.get(id)?.name ?? structure.type_id, + }; + }); + // 옮기는 중엔 그것 — 관 고름은 누가거리만 들어 옮긴 자리로는 못 찾음 + const picked = moving?.id ?? pickedMarkId(marks, readSelection(projectId), PIPE_MATCH_M); + return { source, marks, picked }; +} + +/** 알약 꼴 마커 한 개 — 노선 방향으로 누운 둥근 막대(종류 색 · 흰 테 · 고르면 하늘 테). */ +function paintMark( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, + mark: PlanMark, + picked: boolean, + halo: string, +): void { + const frame = stationFrameAt(line, mark.chainageM); + if (!frame) return; + const [x, y] = scene.toScreen(frame.origin); + const [fx, fy] = scene.toScreen([ + frame.origin[0] + frame.left[1], + frame.origin[1] - frame.left[0], + ]); // 앞쪽 1 m(왼쪽 축을 시계로 90°) + const half = picked ? MARK_HALF_PX + 2 : MARK_HALF_PX; + context.save(); + context.translate(x, y); + context.rotate(Math.atan2(fy - y, fx - x)); + context.lineCap = "round"; + const stroke = (color: string, width: number): void => { + context.strokeStyle = color; + context.lineWidth = width; + context.beginPath(); + context.moveTo(-half, 0); + context.lineTo(half, 0); + context.stroke(); + }; + if (picked) stroke(PICKED_RING, MARK_WIDTH_PX + 7); + stroke(halo, MARK_WIDTH_PX + 3); + stroke(mark.type?.style?.color ?? "#888", MARK_WIDTH_PX); + context.restore(); +} + +/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 라벨 뒤 · 교각점 아래에 부름. */ +export function drawStructureMarks( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, + style: CSSStyleDeclaration, +): void { + const canvas = context.canvas; + const found = line.length >= 2 ? planMarks(canvas) : null; + const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + const picked = found?.marks.find((mark) => mark.id === found.picked) ?? null; + found?.marks.forEach( + (mark) => mark !== picked && paintMark(context, scene, line, mark, false, halo), + ); + if (picked) { + drawStationNormal(context, scene, line, picked.chainageM); + paintMark(context, scene, line, picked, true, halo); + } + showBalloon(canvas, scene, line, picked && found ? { mark: picked, source: found.source } : null); +} + +/** 고른 마커 위 정보 창 — 알약 벌룬 그대로 · 마커 위(위가 모자라면 아래) · 누름은 지나감. */ +function showBalloon( + canvas: HTMLCanvasElement, + scene: RouteEditScene, + line: ReadonlyArray, + pick: { mark: PlanMark; source: PlanMarkSource } | null, +): void { + const kept = balloons.get(canvas); + const frame = pick && stationFrameAt(line, pick.mark.chainageM); + if (!pick || !frame || !canvas.parentElement) { + kept?.element.remove(); + balloons.delete(canvas); + return; + } + const { mark } = pick; + const structure = { ...mark.structure, chainage_m: mark.chainageM }; + const position = positionText(structure, pick.source.intervalM()); + const lines = balloonLines(structure, mark.type, { name: mark.name, position }); + const key = JSON.stringify([lines, mark.type?.style?.color]); + let element = kept?.element; + if (!element || kept?.key !== key) { + const next = balloonElement(lines, mark.type); + next.classList.add("is-center"); + next.style.pointerEvents = "none"; + if (element) element.replaceWith(next); + else canvas.parentElement.append(next); + element = next; + balloons.set(canvas, { key, element }); + } + // 그림은 캔버스 가운데를 축으로 돎 — 창 자리도 같은 만큼 돌림 + const [sx, sy] = scene.toScreen(frame.origin); + const cx = scene.view.width / 2; + const cy = scene.view.height / 2; + const cos = Math.cos(scene.rotationRad); + const sin = Math.sin(scene.rotationRad); + const x = cx + (sx - cx) * cos - (sy - cy) * sin; + const y = cy + (sx - cx) * sin + (sy - cy) * cos; + element.style.left = `${x}px`; + element.style.top = `${y}px`; + element.style.bottom = "auto"; + element.style.transform = + y < element.offsetHeight + 24 ? "translate(-50%, 14px)" : "translate(-50%, calc(-100% - 14px))"; +} + +export interface StructureMarkInput { + /** 마커를 눌렀으면 고르고 끌기 시작 · true(교각점 · 손잡이 다음 차례). */ + press: (px: number, py: number) => boolean; + /** 끄는 중이면 자리 바꿈 · true. */ + move: (px: number, py: number) => boolean; + /** 끄는 중이었으면 놓음(옮겼으면 한 번 옮김) · true. */ + release: () => boolean; + /** 마커 위인가 — 커서 몫. */ + over: (px: number, py: number) => boolean; +} + +/** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */ +export function bindStructureMarks( + params: RouteEditPointerParams, + dragging: () => boolean, +): StructureMarkInput { + const { canvas, status } = params; + const projectId = draftProjectId(); + const list = redraws.get(projectId) ?? new Set<() => void>(); + redraws.set(projectId, list); + const redraw = (): void => void (canvas.isConnected ? params.draw() : list.delete(redraw)); + list.add(redraw); + const lineNow = (): Vertex[] => { + const state = params.state(); + return state.plannedLine.length ? state.plannedLine : state.planned; + }; + const markAt = (px: number, py: number): PlanMark | 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; + }; + const say = (moving: Moving, done: boolean): void => { + const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM; + const shift = moving.to - moving.from; + status.textContent = + `${params.routeHead()} — 구조물 ${moving.name} ${formatStation(moving.from, interval)} → ` + + `${formatStation(moving.to, interval)}(${shift > 0 ? "+" : ""}${shift} m)` + + (done ? " 옮김 — [저장]에서 정본." : " 옮기는 중."); + }; + /** 놓음 · 뗌 — 옮겼으면 알약 끌기와 같은 길로 한 번. */ + const finish = (): boolean => { + const moving = movings.get(canvas); + if (!moving) return false; + movings.set(canvas, null); + if (moving.to !== moving.from) { + sources.get(draftProjectId())?.move(moving.id, moving.to); + say(moving, true); + } + params.draw(); + return true; + }; + const start = (mark: PlanMark): Moving => { + const moving = { + id: mark.id, + name: mark.name, + from: mark.chainageM, + to: mark.chainageM, + totalM: lengthOf(lineNow()), + }; + movings.set(canvas, moving); + return moving; + }; + + // 방향키 — 마커를 골랐고 교각점을 안 골랐을 때 · 누름 ~ 뗌 한 번이 한 옮김(교각점 방향키와 같은 반복) + let holdKey: ArrowKey | null = null; + let timer = 0; + const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y)); + const step = (moving: Moving, key: ArrowKey): void => { + const frame = stationFrameAt(lineNow(), moving.to); + if (!frame) return; + const tangent: Vertex = [frame.left[1], -frame.left[0]]; + moving.to = markStep( + moving.to, + arrowSign(key, arrowStep(key, false, metricAt), tangent), + moving.totalM, + ); + say(moving, false); + params.draw(); + }; + const release = (): void => { + window.clearTimeout(timer); + window.clearInterval(timer); + if (holdKey === null) return; + holdKey = null; + finish(); + }; + const detach = (): void => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("keyup", onKeyUp, true); + window.removeEventListener("blur", release); + }; + function onKeyDown(event: KeyboardEvent): void { + if (!canvas.isConnected) return detach(); + if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return; + const target = event.target as HTMLElement | null; + if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return; + if (event.repeat || holdKey || dragging() || params.measureMode()) return; + if (params.state().picked >= 0 || movings.get(canvas)) return; // 교각점 방향키 몫 + const found = planMarks(canvas); + const mark = found?.marks.find((entry) => entry.id === found.picked); + if (!mark) return; + event.preventDefault(); + const moving = start(mark); + const key = (holdKey = event.key); + step(moving, key); + timer = window.setTimeout(() => { + timer = window.setInterval(() => holdKey === key && step(moving, key), HOLD_INTERVAL_MS); + }, HOLD_DELAY_MS); + } + function onKeyUp(event: KeyboardEvent): void { + if (!canvas.isConnected) return detach(); + if (event.key === holdKey) release(); + } + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("keyup", onKeyUp, true); + window.addEventListener("blur", release); + + return { + press(px, py) { + const mark = markAt(px, py); + if (!mark) return false; + release(); + planMarks(canvas)?.source.select(mark.id); // 알약 고름과 같은 길(공용 고름) + start(mark); + params.draw(); + return true; + }, + move(px, py) { + const moving = movings.get(canvas); + if (!moving || holdKey) return false; + const hit = routePointAtScreen(lineNow(), params.toScreen, px, py, Infinity); + const near = hit ? markChainage(hit.chainageM, moving.totalM) : moving.to; + // 옛 소수 자리(80.17)는 반올림 자리(80)에 머무는 동안 그대로 — 눌렀다 떼기만 해도 옮겨지지 않게 + const to = near === Math.round(moving.from) ? moving.from : near; + if (to !== moving.to) { + moving.to = to; + say(moving, false); + params.draw(); + } + return true; + }, + release: () => !holdKey && finish(), + over: (px, py) => markAt(px, py) !== null, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 6e831fe3d..87b64fd38 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -78,7 +78,7 @@ export interface StructureMarksOptions { } /** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */ -function positionText(structure: StructureInstance, intervalM: number): string { +export function positionText(structure: StructureInstance, intervalM: number): string { return structure.placement === "interval" ? `${formatStation(structure.start_m ?? 0, intervalM)} ~ ${formatStation(structure.end_m ?? 0, intervalM)}` : formatStation(structure.chainage_m ?? 0, intervalM); @@ -141,6 +141,16 @@ export function structurePillTexts( ); } +/** 알약 자리 목록 — 노선 초안 동안은 묶음 follow 뒤 자리(평면 구조물 마커 399 도 이 목록). */ +export function laneStructures( + structures: ReadonlyArray, +): ReadonlyArray { + const pipe = pagePipeline(draftProjectId()); + return pipe?.input("route") + ? followedMarks(structures, pipe.input("structures"), pipe.input("pipes")) + : structures; +} + /** * 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다). */ @@ -151,10 +161,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`; const typeMap = new Map(options.types.map((type) => [type.type_id, type])); - const pipe = pagePipeline(draftProjectId()); - const structures = pipe?.input("route") - ? followedMarks(options.structures, pipe.input("structures"), pipe.input("pipes")) - : options.structures; + const structures = laneStructures(options.structures); // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이 // 모두 이 번호를 따른다. const texts = structurePillTexts(structures, typeMap); @@ -266,13 +273,20 @@ function buildBalloon( anchorPx: number, row: number | null, ): HTMLElement { - const balloon = document.createElement("div"); - balloon.className = "b05-structure__balloon"; + const balloon = balloonElement(lines, type); balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center"); balloon.style.left = `${anchorPx}px`; balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`; - balloon.style.borderColor = type?.style?.color ?? "#888"; + // 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2). + queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon))); + return balloon; +} +/** 벌룬 몸(제목 + 줄 · 테 색) — 자리는 부르는 쪽(알약 레인 · 평면 구조물 마커 399). */ +export function balloonElement(lines: string[], type: StructureType | undefined): HTMLElement { + const balloon = document.createElement("div"); + balloon.className = "b05-structure__balloon"; + balloon.style.borderColor = type?.style?.color ?? "#888"; const [name, ...rest] = lines; const title = document.createElement("strong"); title.textContent = name; @@ -284,8 +298,6 @@ function buildBalloon( return span; }), ); - // 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2). - queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon))); return balloon; } diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts new file mode 100644 index 000000000..39165693a --- /dev/null +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts + * 평면 구조물 측점 마커(399 · 요구 250-2) — 노선 따라 정수 m 자리 · 방향키 부호 · 고른 마커. + * + * · 끌기 · 방향키 = 노선 위 정수 m(시점 0 ~ 종점 내림 · 밖은 끝에 붙음) · 방향키 한 번 1 m(Shift 없음). + * · 방향키 부호 = 누른 화면 방향이 노선 앞쪽과 같은 쪽이면 + · 거의 직각이면 → · ↑ = +. + * · 고른 마커 = 공용 고름 구조물 id 와 같은 것 · id 없으면(관 고름) 그 누가거리 가까운 것. + * 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_StructureMarks`. + * ========================================================================== */ + +import type { ArrowKey } from "./B05_Profile_RouteEdit_KeyNudge"; +import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; + +/** 노선 위 정수 m 자리 — 0 ~ 종점(내림) 밖은 끝에 붙임. */ +export function markChainage(rawM: number, totalM: number): number { + return Math.min(Math.max(Math.round(rawM), 0), Math.max(0, Math.floor(totalM))); +} + +/** 방향키 한 번 — 지금 자리(정수로) ± 1 m. */ +export const markStep = (chainageM: number, sign: 1 | -1, totalM: number): number => + markChainage(Math.round(chainageM) + sign, totalM); + +/** 방향키 부호 — `keyDir`(누른 화면 방향의 도면 벡터) · `tangent`(노선 앞쪽) 같은 쪽이면 +1. */ +export function arrowSign(key: ArrowKey, keyDir: Vertex, tangent: Vertex): 1 | -1 { + const dot = keyDir[0] * tangent[0] + keyDir[1] * tangent[1]; + const size = Math.hypot(keyDir[0], keyDir[1]) * Math.hypot(tangent[0], tangent[1]); + if (Math.abs(dot) > size * 0.2) return dot > 0 ? 1 : -1; + return key === "ArrowRight" || key === "ArrowUp" ? 1 : -1; +} + +/** 고른 마커 id — 고름 구조물 id 와 같은 것 · id 없으면 그 누가거리 `tolM` 안 가장 가까운 것 · 없으면 null. */ +export function pickedMarkId( + marks: ReadonlyArray<{ id: string; chainageM: number }>, + selection: { chainageM: number | null; structureId: string | null } | null, + tolM: number, +): string | null { + if (!selection || selection.chainageM === null) return null; + if (selection.structureId) + return marks.some((mark) => mark.id === selection.structureId) ? selection.structureId : null; + let best: string | null = null; + let bestGap = tolM; + for (const mark of marks) { + const gap = Math.abs(mark.chainageM - selection.chainageM); + if (gap < bestGap) [best, bestGap] = [mark.id, gap]; + } + return best; +} diff --git a/resources/tester/helper_399_plan_structure_marks.cjs b/resources/tester/helper_399_plan_structure_marks.cjs new file mode 100644 index 000000000..4233dd9be --- /dev/null +++ b/resources/tester/helper_399_plan_structure_marks.cjs @@ -0,0 +1,301 @@ +/* 399 B05 평면 구조물 측점 마커(요구 250-2) — `_RouteEdit_StructureMarks` 를 Node 로 돌려 JSON 한 줄 + * (`test_399_plan_structure_marks.py` 가 판정). 알약 목록 · 벌룬 · 고름 · 가게는 가짜 · 노선 셈 · 법선 · + * 걸음 · 방향키 부호는 진짜(`_Input` · `_Hover` · `logic/_Frame` · `logic/_KeyNudge` · `logic/_StructureMarks`). + * node helper_399_plan_structure_marks.cjs + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); +const out = {}; +let selection = null; +const laneInputs = []; +const balloonsMade = []; + +/* 가짜 DOM — 벌룬 요소 · 창 열쇠 듣기 */ +function fakeElement() { + const element = { + style: {}, + classList: { add: (...names) => element.classes.push(...names) }, + classes: [], + offsetHeight: 30, + removed: false, + remove: () => (element.removed = true), + replaceWith: (next) => ((element.removed = true), parent.children.push(next)), + }; + return element; +} +const parent = { children: [], append: (child) => parent.children.push(child) }; +const listeners = {}; +global.window = { + addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn), + removeEventListener: () => {}, + setTimeout: () => 0, + setInterval: () => 0, + clearTimeout: () => {}, + clearInterval: () => {}, +}; +const key = (type, name) => + (listeners[type] || []).forEach((fn) => + fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }), + ); + +const STUBS = { + "../A00_Common/touch_bridge": { + pickPx: (v) => v, + isPinchWheel: () => false, + wheelZoomsIn: () => true, + }, + "../A00_Common/input_prefs": { isTrackpadPinch: () => false }, + "../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 }, + "../A00_Common/design/design_store": { draftProjectId: () => "p1" }, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection }, + "../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true }, + "./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + }, + "./B05_Profile_UI_Structures_Marks": { + laneStructures: (list) => (laneInputs.push(list), list), + structurePillTexts: (list) => + new Map(list.map((s) => [s.structure_id, { name: `이름 ${s.type_id}`, pill: s.type_id }])), + positionText: (s) => `pos ${s.chainage_m}`, + balloonElement: (lines, type) => { + const element = fakeElement(); + element.lines = lines; + element.color = type?.style?.color; + balloonsMade.push(element); + return element; + }, + }, + "./logic/B05_Profile_UI_Structures_Balloon": { + balloonLines: (structure, type, head) => [ + `${head.name} ${head.position}`, + `옵션 ${type?.name}`, + ], + }, +}; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +/** 그린 것만 적는 가짜 캔버스 — 획 = 색 · 굵기 · 그때 옮긴 자리(translate) · 선 점. */ +function fakeContext(canvas) { + const log = []; + let at = [0, 0]; + let points = []; + const ctx = { + canvas, + save() {}, + restore: () => (at = [0, 0]), + setLineDash() {}, + translate: (x, y) => (at = [at[0] + x, at[1] + y]), + rotate() {}, + beginPath: () => (points = []), + moveTo: (x, y) => points.push([x, y]), + lineTo: (x, y) => points.push([x, y]), + arc() {}, + fill() {}, + stroke: () => log.push({ color: ctx.strokeStyle, width: ctx.lineWidth, at: [...at], points }), + }; + return { ctx, log }; +} + +const marks = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMarks.ts")); +const logic = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StructureMarks.ts")); + +// 노선 = 동서 200 m · 화면 2 px/m(위 = 북) · 구조물 A 40 m(색 #e11d48) · 관 80.17 m(옛 소수) +const EAST = [ + [0, 0], + [200, 0], +]; +const toScreen = ([x, y]) => [x * 2, -y * 2]; +const toMetric = (px, py) => [px / 2, -py / 2]; +const structures = [ + { structure_id: "A", type_id: "box", chainage_m: 40, start_m: null, end_m: null }, + { structure_id: "pipe-80.17", type_id: "pipe", chainage_m: 80.17, start_m: null, end_m: null }, +]; +const types = [ + { type_id: "box", name: "BOX", style: { color: "#e11d48" } }, + { type_id: "pipe", name: "배수관", style: { color: "#2563eb" } }, +]; +const selects = []; +const moves = []; +let redrawn = 0; +marks.setPlanMarkSource("p1", { + marks: () => structures, + types: () => types, + intervalM: () => 20, + select: (id) => selects.push(id), + move: (id, to) => moves.push([id, to]), +}); + +const canvas = { isConnected: true, parentElement: parent }; +let line = EAST; +let picked = -1; +const status = { textContent: "" }; +const scene = () => ({ toScreen, view: { width: 400, height: 300 }, rotationRad: 0 }); +const style = { getPropertyValue: () => "" }; +const draw = () => { + const fake = fakeContext(canvas); + marks.drawStructureMarks(fake.ctx, scene(), line, style); + return fake.log; +}; +const input = marks.bindStructureMarks( + { + canvas, + status, + stationIntervalM: 20, + state: () => ({ planned: line, plannedLine: line, picked }), + toScreen, + toMetric, + unrotate: (x, y) => [x, y], + draw: () => (redrawn += 1), + routeHead: () => "노선", + measureMode: () => false, + }, + () => false, +); + +// ① 그리기 — 알약 목록 그대로 · 종류 색 · 고름 없으면 법선 · 창 없음 +const plain = draw(); +out.laneGotSource = laneInputs[laneInputs.length - 1] === structures; +const fills = (log) => log.filter((s) => s.width === 8).map((s) => ({ color: s.color, at: s.at })); +out.plain = { marks: fills(plain), normals: plain.filter((s) => s.color === "#0ea5e9").length }; +out.balloonsAfterPlain = parent.children.filter((c) => !c.removed).length; + +// ② 공용 고름 = 관(누가거리만 · 382-2) → 그 마커 하늘 테 · 법선 · 창(알약 벌룬 줄) +selection = { chainageM: 80.17, structureId: null }; +const pickedPipe = draw(); +const ring = pickedPipe.filter((s) => s.color === "#0ea5e9" && s.width === 15); +const normal = pickedPipe.find((s) => s.color === "#0ea5e9" && s.width === 2.4); +const shown = parent.children.filter((c) => !c.removed); +out.pickedPipe = { + ringAt: ring.map((s) => s.at), + normal: normal && normal.points, + balloon: shown.length === 1 && { + lines: shown[0].lines, + left: shown[0].style.left, + top: shown[0].style.top, + color: shown[0].color, + }, +}; +selection = { chainageM: 40, structureId: "A" }; +draw(); +const shownA = parent.children.filter((c) => !c.removed); +out.pickedStructureBalloon = shownA.length === 1 && shownA[0].lines; +selection = null; +draw(); +out.balloonsAfterRelease = parent.children.filter((c) => !c.removed).length; + +// ③ 끌기 — A(화면 80, 0) 누름 = 고름 · 3 m 끌기(86 px) = 43 · 놓을 때 한 번 +out.pressMiss = input.press(300, 100); +out.pressA = input.press(80, 3); +out.selects = [...selects]; +input.move(83, 10); // 41.5 → 42 +input.move(86, -20); // 43 +out.dragStatus = status.textContent; +out.movesBeforeRelease = moves.length; +input.release(); +out.dragMove = moves[moves.length - 1]; +out.dragDoneStatus = status.textContent; +// 노선 밖 — 끝(200) · 시점(0)에 붙음 +input.press(80, 0); +input.move(9999, 0); +input.release(); +out.pastEnd = moves[moves.length - 1]; +input.press(80, 0); +input.move(-500, 0); +input.release(); +out.beforeStart = moves[moves.length - 1]; +// 옛 소수 관(80.17) — 눌렀다 반올림 자리에서 떼면 안 옮김 · 1 m 끌면 정수 +const count = moves.length; +input.press(160.34, 0); +input.move(160.5, 1); +input.release(); +out.oldDecimalStay = moves.length === count; +input.press(160.34, 0); +input.move(162.2, 0); +input.release(); +out.oldDecimalMoved = moves[moves.length - 1]; + +// ④ 방향키 — 고른 마커(A) · 교각점 안 고름 = 노선 따라 1 m · 뗄 때 한 번 +selection = { chainageM: 40, structureId: "A" }; +key("keydown", "ArrowRight"); +key("keyup", "ArrowRight"); +out.keyRight = moves[moves.length - 1]; +key("keydown", "ArrowLeft"); +key("keyup", "ArrowLeft"); +out.keyLeft = moves[moves.length - 1]; +key("keydown", "ArrowUp"); // 노선과 직각 = → · ↑ 는 + +key("keyup", "ArrowUp"); +out.keyUpPerpendicular = moves[moves.length - 1]; +// 서→동이 아니라 동→서 노선 — 오른쪽(동) = 뒤로 +line = [ + [200, 0], + [0, 0], +]; +key("keydown", "ArrowRight"); +key("keyup", "ArrowRight"); +out.keyRightWestward = moves[moves.length - 1]; +line = EAST; +// 교각점 고름 = 교각점 방향키 몫(마커 안 옮김) +picked = 2; +const before = moves.length; +key("keydown", "ArrowRight"); +key("keyup", "ArrowRight"); +out.keyWithNodePicked = moves.length - before; +picked = -1; +// 고름 없음 = 안 옮김 +selection = null; +key("keydown", "ArrowRight"); +key("keyup", "ArrowRight"); +out.keyNoPick = moves.length - before; + +// ⑤ 목록 바뀜 알림 = 그 편집 창 다시 그림 +const was = redrawn; +marks.planMarksChanged("p1"); +out.redrawnOnChange = redrawn - was; + +// ⑥ 순수 셈 +out.logic = { + round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)], + clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)], + step: [logic.markStep(80.17, 1, 200), logic.markStep(0, -1, 200), logic.markStep(199, 1, 199.6)], + pickId: logic.pickedMarkId( + [{ id: "A", chainageM: 40 }], + { chainageM: 1, structureId: "A" }, + 0.51, + ), + pickNear: logic.pickedMarkId( + [{ id: "A", chainageM: 40 }], + { chainageM: 40.3, structureId: null }, + 0.51, + ), + pickFar: logic.pickedMarkId( + [{ id: "A", chainageM: 40 }], + { chainageM: 41, structureId: null }, + 0.51, + ), + pickOther: logic.pickedMarkId( + [{ id: "A", chainageM: 40 }], + { chainageM: 40, structureId: "Z" }, + 0.51, + ), +}; +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_391_routeedit_plan_draw.py b/resources/tester/test_391_routeedit_plan_draw.py index fd66264de..bf2928ce2 100644 --- a/resources/tester/test_391_routeedit_plan_draw.py +++ b/resources/tester/test_391_routeedit_plan_draw.py @@ -46,7 +46,8 @@ def test_맴돔과_고름이_한_함수(): render = _read("B05_Profile_UI_RouteEdit_Render.ts") assert "drawStationNormal(context, scene, line, watch.chainageM);" in render assert "leftCut" not in render and "--b05-footprint-cut" not in render - assert 'context.strokeStyle = "#ff9800";' in render # 구조물 마름모 그대로 + assert 'context.strokeStyle = "#ff9800";' not in render # 구조물 고름 = 마커 법선 · 창(399) + assert "drawStructureMarks(context, scene, line, style);" in render def test_MC_는_호_가운데(results): diff --git a/resources/tester/test_399_plan_structure_marks.py b/resources/tester/test_399_plan_structure_marks.py new file mode 100644 index 000000000..f0a6d07df --- /dev/null +++ b/resources/tester/test_399_plan_structure_marks.py @@ -0,0 +1,128 @@ +"""399 B05 평면 구조물 측점 마커(요구 250-2) — 알약과 같은 목록 · 고름 = 공용 고름 · 정보 창(알약 벌룬) · +20 m 법선 · 끌기 · 방향키 = 노선 따라 정수 m 1 m 씩 · 시점 · 종점 밖 안 감 · 놓을 때 한 번 옮김(알약과 같은 길). + +TS 셈은 `helper_399_plan_structure_marks.cjs`(Node · 노선 셈 · 법선 · 걸음은 진짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_399_plan_structure_marks.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs") + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@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["laneGotSource"] is True # 알약 레인과 같은 `laneStructures` + assert got["plain"]["marks"] == [ + {"color": "#e11d48", "at": [80, 0]}, # 40 m × 2 px · 종류 색 + {"color": "#2563eb", "at": [160.34, 0]}, + ] + assert got["plain"]["normals"] == 0 and got["balloonsAfterPlain"] == 0 + lane = _read("B05_Profile", "B05_Profile_UI_Structures_Marks.ts") + assert "const structures = laneStructures(options.structures);" in lane + marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") + assert "laneStructures(source.marks())" in marks + bridge = _read("B05_Profile", "B05_Profile_UI_Page_Structures.ts") + assert "setStructures([...ownStructures, ...pipeMarks]);" in bridge + assert "marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks]," in bridge + assert "planMarksChanged(deps.projectId);" in bridge + + +def test_고름_정보_창_법선(got) -> None: + pipe = got["pickedPipe"] + assert pipe["ringAt"] == [[160.34, 0]] # 관 고름(누가거리만)도 그 마커 + assert pipe["normal"] == [[160.34, -40], [160.34, 40]] # 좌우 20 m + assert pipe["balloon"]["lines"] == ["이름 pipe pos 80.17", "옵션 배수관"] # 알약 벌룬 줄 + assert pipe["balloon"]["color"] == "#2563eb" + assert got["pickedStructureBalloon"] == ["이름 box pos 40", "옵션 BOX"] + assert got["balloonsAfterRelease"] == 0 + + +def test_공용_고름과_같은_길() -> None: + view = _read("A00_Common", "design", "design_view_profile.ts") + assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수 + assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view + render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts") + labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks") + nodes = render.index("scene.planned.forEach((vertex, index) => {") + assert labels < nodes # 라벨 위 · 교각점 아래 + + +def test_집기_차례는_교각점_손잡이_다음() -> None: + pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + node = pointer.index("if (hitNode >= 0) {") + handle = pointer.index("} else if (dragHandle) {") + mark = pointer.index("} else if (marks.press(px, py)) {") + tick = pointer.index('if (!pick || !("chainage" in pick)) return false;') + assert node < handle < mark < tick + + +def test_끌기_1m_정수_놓을_때_한_번(got) -> None: + assert got["pressMiss"] is False and got["pressA"] is True + assert got["selects"] == ["A"] + assert got["movesBeforeRelease"] == 0 # 끄는 동안은 미리보기만 + assert got["dragMove"] == ["A", 43] # 3 m + assert "2+0 → 2+3(+3 m) 옮김" in got["dragDoneStatus"] + assert got["oldDecimalStay"] is True # 옛 소수 자리 눌렀다 떼기 = 안 옮김 + assert got["oldDecimalMoved"] == ["pipe-80.17", 81] + + +def test_노선_밖_안_감(got) -> None: + assert got["pastEnd"] == ["A", 200] + assert got["beforeStart"] == ["A", 0] + assert got["logic"]["clamp"] == [0, 199] # 종점 199.6 → 199(정수 m 안쪽) + assert got["logic"]["step"] == [81, 0, 199] + + +def test_방향키_노선_따라_1m(got) -> None: + assert got["keyRight"] == ["A", 41] + assert got["keyLeft"] == ["A", 39] + assert got["keyUpPerpendicular"] == ["A", 41] # 직각 = → · ↑ 가 + + assert got["keyRightWestward"] == ["A", 39] # 노선이 서쪽으로 가면 → = 뒤로 + assert got["keyWithNodePicked"] == 0 # 교각점 고름 = 교각점 방향키 몫 + assert got["keyNoPick"] == 0 + + +def test_고른_마커_찾기(got) -> None: + logic = got["logic"] + assert logic["round"] == [41, 42] + assert (logic["pickId"], logic["pickNear"], logic["pickFar"], logic["pickOther"]) == ( + "A", + "A", + None, + None, + ) + assert got["redrawnOnChange"] == 1 + + +def test_편집기_줄_수_그대로() -> None: + edit = _read("B05_Profile", "B05_Profile_UI_RouteEdit.ts") + assert edit.count("\n") <= 695 + for name in ( + "B05_Profile_UI_RouteEdit_StructureMarks.ts", + "B05_Profile_UI_RouteEdit_Render.ts", + "B05_Profile_UI_RouteEdit_Pointer.ts", + ): + assert _read("B05_Profile", name).count("\n") <= 700