From 0800796998b3a802107efbe11df05756e0a35024 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 14:44:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(382-1):=20B05=20=EC=9A=B0=EC=B8=A1=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=ED=8C=90=20=3D=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D(=EC=86=90=EC=9E=A1=EC=9D=B4=20=C2=B7=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=C2=B7=20=ED=8F=AD=20=EB=81=8C=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=97=B4=EB=A6=BC=20=C2=B7=20=ED=8F=AD=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=C2=B7=20=EC=A7=80=EB=8F=84=20=EC=9C=84=20=EB=8D=AE?= =?UTF-8?q?=EA=B0=9C=20=EA=B7=B8=EB=8C=80=EB=A1=9C)?= 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_01TteK8mfYZetk5P3bEZkyVZ --- .../B05_Profile_UI_RouteEdit_Chrome.ts | 44 ++++++++++++------- .../B05_Profile_UI_Style_RouteEdit.css | 34 +++++--------- .../tester/test_b05_routeedit_chrome_modal.py | 11 ++--- 3 files changed, 45 insertions(+), 44 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 13021c9e8..388c3061c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -1,4 +1,5 @@ import { createButton } from "@ui/ui_template_elements"; +import { createSidePanels, type SidePanels } from "@ui/ui_template_side_panels"; import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; /* ============================================================================= @@ -46,6 +47,12 @@ export interface RouteEditChrome { terrainSelect: HTMLSelectElement; } +/** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */ +const SIDE_HANDLE_PX = 32; + +/** 횡단 판 몸 → 패널 묶음 — 고른 측점 횡단을 띄울 때 접힌 판을 폄(382-2 · `_Cross`). */ +export const sidePanelsOf = new WeakMap(); + // 글판 접기 기억(345-5 덧 3) — 페이지 동안만(모듈 값) · 다시 열어도 그대로. let hintOpen = false; let infoCollapsed = false; @@ -118,26 +125,33 @@ export function createRouteEditChrome( - `; + `; parent.append(overlay); // 부른 쪽 단추([3D])도 같은 유리 단추로 — 지도 위에 떠 있는 도구줄. tools.forEach((tool) => tool.classList.replace("ui-btn--ghost", "ui-btn--glass")); overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools); - // 횡단 미리보기 = 접는 칸 — 좁은 화면은 접힌 채 시작(지도가 먼저다). - const sideWrap = overlay.querySelector(".b05-routeedit__side-wrap")!; - const sideToggle = overlay.querySelector('[data-act="side-toggle"]')!; - const setSide = (open: boolean): void => { - sideWrap.classList.toggle("is-collapsed", !open); - overlay.classList.toggle("is-side-open", open); // 회전 단추 · 잰 값 창이 판을 비킴 - sideToggle.setAttribute("aria-expanded", String(open)); - }; - setSide(window.innerWidth >= 1500); - sideToggle.addEventListener("click", () => setSide(sideWrap.classList.contains("is-collapsed"))); + // 횡단 미리보기 = 패널 양식(382-1 · 손잡이 · 머리 · 폭 끌기 · 열림 · 폭 기억) · 자리는 지도 위 덮개 그대로 · + // 기억이 없으면 좁은 화면은 접힌 채 시작(지도가 먼저다) · 잰 값 창이 판을 비킴(--b05-side-open). + const side = document.createElement("div"); + side.className = "b05-routeedit__side"; + const setSideOpen = (): void => + overlay.style.setProperty( + "--b05-side-open", + `${(panels.current() ? panels.width() : 0) + SIDE_HANDLE_PX}px`, + ); + const panels = createSidePanels({ + prefKey: "b05-routeedit-cross", + panels: [{ id: "cross", title: "횡단", body: side }], + defaultOpen: window.innerWidth >= 1500 ? "cross" : null, + defaultWidth: 460, + follow: false, + onChange: setSideOpen, + onResize: setSideOpen, + }); + sidePanelsOf.set(side, panels); + overlay.querySelector(".b05-routeedit__side-wrap")!.append(panels.root); + setSideOpen(); // 도움말 = 기본 접힘 · [?] 한 번에 펼침(345-5 덧 3). const hint = overlay.querySelector(".b05-routeedit__hint")!; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 2698017ad..9a436bcc2 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -4,17 +4,13 @@ /* 평면 = 가운데 칸 전체(352-1) — 머리 줄 · 테두리 없음. 도구줄은 3D 와 같은 떠 있는 유리 단추 (`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */ .b05-routeedit { - --b05-side-open: 0px; /* 횡단 판이 펴져 지도를 덮는 폭 */ + --b05-side-open: 0px; /* 횡단 판이 지도를 덮는 폭(손잡이 포함 · `_Chrome` 이 적음) */ position: relative; display: flex; flex: 1 1 auto; min-height: 0; } -.b05-routeedit.is-side-open { - --b05-side-open: 460px; -} - /* 도구줄 — 3D 도구줄 규격 그대로 · 회전 단추도 이 줄(366-2) · 넘치면 줄바꿈. */ .b05-routeedit__tools { right: var(--spacing-12); @@ -187,9 +183,8 @@ border-top: 2px solid var(--map-route, #f97316); } -/* 오른쪽 덮는 판 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다(352-1). 지도 위에 얹고 - 아래 종단 판(z 3)이 그 위를 덮는다. 접는 칸(345-3) — 좁은 화면은 접힌 채 시작(`_Chrome`) · - [횡단] 단추로 펴고 접는다. 빈 자리는 지도 조작을 통과. */ +/* 오른쪽 덮는 판 — 패널 양식(382-1 · `ui_template_side_panels` · 손잡이 · 머리 · 폭 끌기) 을 지도 위에 얹음 · + 위아래 반씩 **지금 횡단** · **이전 횡단**(352-1) · 아래 종단 판(z 3)이 그 위를 덮음 · 빈 자리는 지도 조작을 통과. */ .b05-routeedit__side-wrap { position: absolute; z-index: 2; @@ -197,24 +192,20 @@ right: var(--spacing-12); bottom: var(--spacing-12); display: flex; - flex-direction: column; - gap: var(--spacing-8); min-height: 0; pointer-events: none; } -.b05-routeedit__side-wrap.is-collapsed { - bottom: auto; -} - .b05-routeedit__side-wrap > * { pointer-events: auto; } -.b05-routeedit__side-toggle { - align-self: flex-end; - min-width: 56px; - height: 34px; +.b05-routeedit__side-wrap .ui-side-panels__panel { + box-shadow: 0 12px 40px rgb(0 0 0 / 45%); +} + +.b05-routeedit__side-wrap .ui-side-panels__body { + display: flex; } .b05-routeedit__side { @@ -222,14 +213,10 @@ flex: 1 1 auto; flex-direction: column; gap: var(--spacing-12); - width: 452px; + min-width: 0; min-height: 0; } -.b05-routeedit__side-wrap.is-collapsed > .b05-routeedit__side { - display: none; -} - .b05-routeedit__cross { display: flex; flex: 1 1 0; @@ -241,7 +228,6 @@ border: 1px solid var(--color-border); border-radius: var(--radius-16, 12px); background: var(--color-surface-raised); - box-shadow: 0 12px 40px rgb(0 0 0 / 45%); } .b05-routeedit__cross-head { diff --git a/resources/tester/test_b05_routeedit_chrome_modal.py b/resources/tester/test_b05_routeedit_chrome_modal.py index 0214f08ef..edc64cac1 100644 --- a/resources/tester/test_b05_routeedit_chrome_modal.py +++ b/resources/tester/test_b05_routeedit_chrome_modal.py @@ -43,7 +43,9 @@ def test_겉틀은_본문_칸(): assert 'title="되돌리기 (Ctrl+Z)"' in chrome and 'title="다시하기 (Ctrl+Y)"' in chrome # 시험 · 배선이 집는 자리는 남김 assert "b05-routeedit__measure-close" in chrome - assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome + # 382-1 — 오른쪽 횡단 판 = 패널 양식(손잡이 · 머리 · 폭 끌기 · 열림 · 폭 기억) · 칸 높이를 채움 + assert "createSidePanels({" in chrome and 'prefKey: "b05-routeedit-cross"' in chrome + assert "follow: false" in chrome and 'data-act="side-toggle"' not in chrome def test_CSS_덮개_걷음_대기막은_전체(): @@ -55,9 +57,8 @@ def test_CSS_덮개_걷음_대기막은_전체(): assert "position: fixed;" in busy and "z-index: var(--z-modal, 1000);" in busy label = _block(css, ".b05-routeedit__label") assert "position: absolute;" in label and "position: fixed" not in label - assert "display: none;" in _block( - css, ".b05-routeedit__side-wrap.is-collapsed > .b05-routeedit__side" - ) + # 382-1 — 자체 [횡단] 단추 · 접기 걷음(접기는 양식 손잡이) + assert "b05-routeedit__side-toggle" not in css and "is-side-open" not in css assert "@media (width < 1500px)" not in css # 접기는 단추로(처음만 폭으로) @@ -87,7 +88,7 @@ def test_352_1_머리줄_상자_없음_도구는_뜬_단추_횡단은_우측_덮 assert "right: var(--spacing-12);" in _block(css, ".b05-routeedit__spin") # 도구줄 줄 오른쪽 assert "34px" in side # 횡단 판은 도구줄 줄 아래에서 시작 assert "var(--b05-side-open)" in _block(css, ".b05-routeedit__measure") - assert "is-side-open" in chrome + assert '"--b05-side-open"' in chrome # 판 폭(손잡이 포함)을 적음 glass = _block(css, ".b05-routeedit__terrain") assert "backdrop-filter: blur(6px);" in glass and "background:" in glass From 05d4d7dd36e5a53eb1cf19115d1c33d977901a0a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 14:50:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(382-2):=20B05=20=ED=8F=89=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EA=B3=A0=EB=A6=84=20=EC=97=B0=EB=8F=99(?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=EC=B8=A1=EC=A0=90=20=3D=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=ED=8C=90=20=C2=B7=20=EB=B2=95=EC=84=A0=20=C2=B7=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=3D=20=EB=A7=88=EB=A6=84=EB=AA=A8=20=C2=B7?= =?UTF-8?q?=20=EB=88=88=EA=B8=88=20=ED=81=B4=EB=A6=AD=20=3D=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=EC=A0=81=EC=9D=8C)=20=C2=B7=20=EB=B2=95=EC=84=A0?= =?UTF-8?q?=20=3D=20=EC=B8=A1=EC=A0=90=20=EC=97=B4=EC=87=A0=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9C=EC=9E=90=EA=B5=AD=20=EB=B9=84=ED=83=88=20=EB=81=9D=20?= =?UTF-8?q?=EC=A2=8C=EC=9A=B0=20=C2=B7=20=EC=A0=88=EC=84=B1=ED=86=A0=20?= =?UTF-8?q?=EC=83=89?= 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_01TteK8mfYZetk5P3bEZkyVZ --- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 155 +++++++++++++++--- .../B05_Profile_UI_RouteEdit_Render.ts | 44 ++++- resources/tester/helper_365_page_pipeline.cjs | 1 + .../helper_b05_routeedit_cross_width.cjs | 88 +++++++++- .../tester/test_b05_routeedit_cross_width.py | 51 +++++- 5 files changed, 296 insertions(+), 43 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index c93d4d2b9..742d132f4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -12,12 +12,27 @@ * 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계 * (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 · * 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. + * + * 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 · + * 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름). + * 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점). + * 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색). * ========================================================================== */ import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import type { + PipelineStation, + PlanFootprint, +} from "../A00_Common/design/logic/design_pipeline_types"; +import { + nearestWithin, + selectDesign, + subscribeSelection, + type DesignSelection, +} from "../A00_Common/design/design_selection"; import { useDesignStore } from "../A00_Common/design/design_store"; import { stationSection } from "./logic/B05_Profile_Pipeline_Detail"; import { @@ -27,6 +42,8 @@ import { } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; import { readByKey, stateKey } from "../A00_Common/b_page_state"; import { stationLabel as formatStation } from "../common_util/common_util_station"; +import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome"; +import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint"; export interface CrossPreviewParams { projectId: string; @@ -43,19 +60,31 @@ export interface CrossPreviewParams { onShown?: () => void; } -/** 위 판에 떠 있는 측점과 그 좌·우 횡단 반폭(m) — 평면도가 반폭 선을 그리는 재료. */ +/** 고른 측점과 그 좌·우 폭(m · 좌 + 방향 거리) — 평면도가 법선 · 마름모를 그리는 재료. */ export interface CrossWatch { chainageM: number; leftM: number; rightM: number; + /** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */ + leftCut?: boolean; + rightCut?: boolean; + /** 구조물 · 관 자리 — 법선 없이 마름모만. */ + structure?: true; } +/** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */ +export const PLAN_PICK_FROM = "b05-plan"; + +/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */ +const isGridStation = (station: PipelineStation): boolean => + station.kind === "grid" || station.kind === "end"; + export interface CrossPreviewWindow { /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */ open: (chainageM: number) => Promise; /** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; - /** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */ + /** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */ watched: () => CrossWatch | null; } @@ -193,18 +222,33 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind params.side.append(current.root, previous.root); const interval = (): number => params.request().station_interval_m; - /** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */ - let watching: number | null = null; + /** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 고름(마름모만). */ + let watching: { key: string | null; chainageM: number; structure: boolean } | null = null; /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; let drawnRoute: unknown = null; + const stationsNow = (): PipelineStation[] => pipe?.output("ground")?.stations ?? []; + /** 열쇠로 다시 짚은 지금 측점 — 열쇠가 없으면 들고 있던 누가거리 근처. */ + function located(): PipelineStation | null { + if (!watching) return null; + const list = stationsNow(); + const key = watching.key; + const at = watching.chainageM; + return ( + (key ? list.find((station) => station.key === key) : undefined) ?? + nearestWithin(list, at, (station) => station.chainageM) ?? + null + ); + } + function redraw(): void { - if (watching === null) return; + if (watching === null || watching.structure) return; const route = pipe?.input("route") ?? null; if (route !== drawnRoute && shown) previous.show(shown, interval()); drawnRoute = route; - shown = pipelineCrossAt(pipe, watching, interval(), appliedHalfWidth(params.projectId)); + const at = located()?.chainageM ?? watching.chainageM; + shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId)); if (shown) current.show(shown, interval()); else current.wait("계산 중…"); params.onShown?.(); @@ -230,33 +274,98 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind const stop = (): void => { stopStore(); stopPipe(); + stopPick(); }; + + /** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */ + function showStation(key: string | null, chainageM: number): void { + // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 + // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. + previous.show(null, interval()); + watching = { key, chainageM, structure: false }; + drawnRoute = pipe?.input("route") ?? null; + shown = null; + redraw(); + const panels = sidePanelsOf.get(params.side); + if (panels && panels.current() === null) panels.open("cross"); + } + + /** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */ + function follow(selection: DesignSelection): void { + attach(); + const at = selection.chainageM; + if (at === null) { + watching = null; + shown = null; + current.show(null, interval()); + previous.show(null, interval()); + params.onShown?.(); + return; + } + const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM); + if (!selection.structureId && (!station || isGridStation(station))) + return showStation(station?.key ?? null, station?.chainageM ?? at); + watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true }; + params.onShown?.(); + } + const stopPick = subscribeSelection(params.projectId, (selection, from) => { + if (from !== PLAN_PICK_FROM) follow(selection); + }); attach(); + /** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */ + const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => { + const footprint = useDesignStore(params.projectId, null).get("footprint") as + PlanFootprint | null | undefined; + const records = (footprint?.records ?? []) as FootprintRecord[]; + return ( + (key ? records.find((record) => record.key === key) : undefined) ?? + nearestWithin(records, chainageM, (record) => record.chainage_m) + ); + }; + return { async open(chainageM) { - // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 - // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. - previous.show(null, interval()); - watching = chainageM; attach(); - drawnRoute = pipe?.input("route") ?? null; - shown = null; - redraw(); + const list = stationsNow(); + const station = list.length + ? list.reduce((best, next) => + Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) + ? next + : best, + ) + : null; + const at = station?.chainageM ?? chainageM; + showStation(station?.key ?? null, at); + selectDesign( + params.projectId, + { chainageM: at, structureId: null }, + { from: PLAN_PICK_FROM }, + ); }, async refresh() { attach(); redraw(); }, - // 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. - // 반폭 = 적용해 둔 「횡단 반폭」 · 없으면 그린 원지반 선 범위 — 좌·우 같음. - watched: () => - watching === null || !shown - ? null - : { - chainageM: watching, - leftM: shown.cross_half_width_m, - rightM: shown.cross_half_width_m, - }, + // 자리 = 열쇠로 다시 짚은 측점 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. + // 폭 = 그 측점 발자국 비탈 끝 좌 · 우 · 기록이 없으면 「횡단 반폭」(없으면 원지반 선 범위) 양쪽. + watched: () => { + if (!watching) return null; + const station = located(); + const chainageM = station?.chainageM ?? watching.chainageM; + if (watching.structure || (station && !isGridStation(station))) + return { chainageM, leftM: 0, rightM: 0, structure: true }; + const record = footprintAt(station?.key ?? watching.key, chainageM); + if (record) + return { + chainageM, + leftM: record.left_end_m, + rightM: -record.right_end_m, + leftCut: record.left_cut, + rightCut: record.right_cut, + }; + if (!shown) return null; + return { chainageM, leftM: shown.cross_half_width_m, rightM: shown.cross_half_width_m }; + }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 22b7631ff..51041e40b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -390,8 +390,9 @@ function drawMeasureMarks( } /** - * 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 우측 패널 「횡단 반폭」 - * (평면 거리 · 횡단 미리보기의 원지반 선 범위와 같다). + * 고른 측점의 **좌·우 법선**(382-2) — 측점에서 노선에 직각, 길이 = 그 측점 발자국 비탈 끝 좌 · 우 · + * 색 = 깎기(절토) · 쌓기(성토) 토큰(`_Footprint` 와 같은 것) · 기록이 없으면 「횡단 반폭」 양쪽 파랑 · + * 측점 자리에 고리(눈금 강조). 구조물 · 관 고름 = 그 자리 마름모(배수유역도 `markStation` 꼴)만. * * 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버 * `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다. @@ -407,17 +408,37 @@ function drawCrossWidth( if (!frame) return; const [ox, oy] = frame.origin; const [lx, ly] = frame.left; - const ends: Vertex[] = [ - [ox + lx * watch.leftM, oy + ly * watch.leftM], - [ox - lx * watch.rightM, oy - ly * watch.rightM], - ]; const [cx, cy] = scene.toScreen(frame.origin); context.save(); - context.strokeStyle = CROSS_WIDTH_COLOR; - context.fillStyle = CROSS_WIDTH_COLOR; + context.setLineDash([]); + if (watch.structure) { + 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(); + return; + } + const style = getComputedStyle(document.documentElement); + const tone = (cut: boolean | undefined): string => + cut === undefined + ? CROSS_WIDTH_COLOR + : style.getPropertyValue(cut ? "--b05-footprint-cut" : "--b05-footprint-fill").trim() || + (cut ? "#dc2626" : "#2563eb"); + const ends: Array<[Vertex, string]> = [ + [[ox + lx * watch.leftM, oy + ly * watch.leftM], tone(watch.leftCut)], + [[ox - lx * watch.rightM, oy - ly * watch.rightM], tone(watch.rightCut)], + ]; context.lineWidth = 2.4; - ends.forEach((end) => { + ends.forEach(([end, color]) => { const [x, y] = scene.toScreen(end); + context.strokeStyle = color; + context.fillStyle = color; context.beginPath(); context.moveTo(cx, cy); context.lineTo(x, y); @@ -426,6 +447,11 @@ function drawCrossWidth( context.arc(x, y, 3, 0, Math.PI * 2); context.fill(); }); + context.strokeStyle = CROSS_WIDTH_COLOR; + context.lineWidth = 2; + context.beginPath(); + context.arc(cx, cy, 6, 0, Math.PI * 2); + context.stroke(); context.restore(); } diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index 970d5f9eb..e49bd662a 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -180,6 +180,7 @@ const store = load("A00_Common", "design", "design_store.ts"); // B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜 STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined }; STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" }; +STUBS["B05_Profile_UI_RouteEdit_Chrome"] = { sidePanelsOf: new WeakMap() }; // 판 양식(382-1) — 화면 밖 const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"); const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null); diff --git a/resources/tester/helper_b05_routeedit_cross_width.cjs b/resources/tester/helper_b05_routeedit_cross_width.cjs index baa25097f..b4e176a3e 100644 --- a/resources/tester/helper_b05_routeedit_cross_width.cjs +++ b/resources/tester/helper_b05_routeedit_cross_width.cjs @@ -1,7 +1,7 @@ -/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 — +/* 계획노선 편집 창 **측점 법선 · 고름 연동**(계획서 22-1 · 382-2) 단위검증 헬퍼 — * `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다. - * 계산 묶음 · 그림 조각은 가짜로 채운다 — 「무슨 반폭으로 선을 잡나」만 본다(365-3 · 서버 길 없음 · - * 묶음 그 측점 원지반 표본 = 좌우 20 m). + * 계산 묶음 · 가게 · 그림 조각은 가짜로 채운다 — 공용 고름(`design_selection`)은 진짜. + * 묶음 측점 = 일반측점 grid:2(40) · grid:3(60) · grid:5(100) · 관 pipe:a(80) · 원지반 표본 좌우 20 m. */ const fs = require("fs"); const path = require("path"); @@ -57,6 +57,19 @@ globalThis.ResizeObserver = class { disconnect() {} }; +const grid = (k, chainageM) => ({ key: `grid:${k}`, kind: "grid", chainageM }); +let stations = [ + grid(2, 40), + grid(3, 60), + { key: "pipe:a", kind: "pipe", chainageM: 80 }, + grid(5, 100), +]; +let footprint = null; +let route = { v: 1 }; +// 접힌 판 펴기 — 몸(side)에 묶인 패널 묶음 흉내 +const panels = { now: null, current: () => panels.now, open: (id) => (panels.now = id) }; +const side = { append() {} }; + const STUBS = { "../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} }, "../common_util/common_util_station": { stationLabel: (m) => String(m) }, @@ -65,17 +78,19 @@ const STUBS = { CROSS_PLOT_PAD: 0, summarizeCross: () => "", }, - // 계산 묶음 흉내 — 측점 하나(100 m) · 원지반 표본 좌우 20 m + "./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new WeakMap([[side, panels]]) }, "../A00_Common/design/design_pipeline_page": { pagePipeline: () => ({ - output: (name) => - name === "ground" ? { stations: [{ key: "grid:5", chainageM: 100 }] } : null, - input: () => null, + output: (name) => (name === "ground" ? { stations } : null), + input: () => route, subscribe: () => () => undefined, }), }, "../A00_Common/design/design_store": { - useDesignStore: () => ({ subscribe: () => () => undefined }), + useDesignStore: () => ({ + subscribe: () => () => undefined, + get: (name) => (name === "footprint" ? footprint : null), + }), }, "./logic/B05_Profile_Pipeline_Detail": { stationSection: (station) => ({ @@ -108,12 +123,26 @@ function loadTs(file) { const { createCrossPreview } = loadTs( path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"), ); +const pick = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts")); + +const record = (key, chainage_m, left, right, leftCut, rightCut) => ({ + key, + chainage_m, + left_edge_m: 2.5, + left_end_m: left, + right_edge_m: -2.5, + right_end_m: right, + left_cut: leftCut, + right_cut: rightCut, +}); async function run() { + let shownCount = 0; const cross = createCrossPreview({ projectId: "p-1", - side: { append() {} }, + side, request: () => ({ vertices: [], min_radius_m: 12, station_interval_m: 20 }), + onShown: () => (shownCount += 1), }); const out = {}; out.before = cross.watched(); @@ -129,6 +158,47 @@ async function run() { session.setItem("aislo:draft:cross-display:p-1:12", "abc"); await cross.refresh(); out.badValue = { watched: cross.watched() }; + + // 382-2 발자국 기록 = 비탈 끝 좌 · 우 · 깎기 여부 + footprint = { records: [record("grid:5", 100, 7.5, -6, true, false)] }; + out.footprint = cross.watched(); + // 노선 고침 — 같은 열쇠 측점이 102 로 밀리고 발자국도 새로(열쇠로 다시 짚음) + stations = [ + grid(2, 40), + grid(3, 60), + { key: "pipe:a", kind: "pipe", chainageM: 80 }, + grid(5, 102), + ]; + footprint = { records: [record("grid:5", 102, 9, -4, false, true)] }; + route = { v: 2 }; + await cross.refresh(); + out.afterEdit = cross.watched(); + + // 평면 눈금 클릭 = 공용 고름에 적음 · 판이 접혀 있으면 폄 + const heard = []; + pick.subscribeSelection("p-1", (selection, from) => + heard.push({ at: selection.chainageM, from }), + ); + panels.now = null; + await cross.open(41); + out.planClick = { + selection: pick.readSelection("p-1"), + heard: heard.slice(), + panel: panels.now, + watched: cross.watched(), + }; + + // 다른 보기 고름 — 일반측점 = 그 측점(판 폄) · 관 측점 · 구조물 id = 마름모 · 풂 = 없음 + panels.now = null; + pick.selectDesign("p-1", { chainageM: 60, structureId: null }, { from: "b06-card" }); + out.otherGrid = { watched: cross.watched(), panel: panels.now }; + pick.selectDesign("p-1", { chainageM: 80, structureId: null }, { from: "profile" }); + out.otherPipe = cross.watched(); + pick.selectDesign("p-1", { chainageM: 60, structureId: "s-1" }, { from: "list" }); + out.otherStructure = cross.watched(); + const before = shownCount; + pick.selectDesign("p-1", { chainageM: null, structureId: null }, { from: "escape" }); + out.cleared = { watched: cross.watched(), redrawn: shownCount > before }; return out; } run().then((out) => console.log(JSON.stringify(out))); diff --git a/resources/tester/test_b05_routeedit_cross_width.py b/resources/tester/test_b05_routeedit_cross_width.py index 4f7420a55..37aee859c 100644 --- a/resources/tester/test_b05_routeedit_cross_width.py +++ b/resources/tester/test_b05_routeedit_cross_width.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증. +"""계획노선 편집 창 — **측점 법선 길이 · 고름 연동**(계획서 22-1 · 382-2) 회귀검증. -파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 원지반 선 범위 +법선 = 그 측점 발자국 비탈 끝 좌 · 우(382-2) · 기록이 없으면 「횡단 반폭(m)」 적용값 양쪽 · 그것도 없으면 원지반 선 범위 (계산 묶음 그 측점 표본 · 365-3). Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다. """ @@ -45,3 +45,50 @@ def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시( def test_망가진_값은_기본값으로(results): assert results["badValue"]["watched"]["rightM"] == 20 + + +# ── 382-2 법선 = 발자국 비탈 끝 · 측점 열쇠 · 공용 고름 연동 ────────────────────── + + +def test_발자국_기록이면_비탈_끝_좌우와_깎기(results): + assert results["footprint"] == { + "chainageM": 100, + "leftM": 7.5, + "rightM": 6, + "leftCut": True, + "rightCut": False, + } + + +def test_노선_고친_뒤_같은_열쇠_측점의_새_발자국(results): + assert results["afterEdit"] == { + "chainageM": 102, + "leftM": 9, + "rightM": 4, + "leftCut": False, + "rightCut": True, + } + + +def test_평면_눈금_클릭은_공용_고름에_적고_판을_편다(results): + click = results["planClick"] + assert click["selection"] == {"chainageM": 40, "structureId": None} + assert click["heard"] == [{"at": 40, "from": "b05-plan"}] + assert click["panel"] == "cross" + assert click["watched"]["chainageM"] == 40 + + +def test_다른_보기_일반측점_고름은_평면_판(results): + assert results["otherGrid"]["watched"]["chainageM"] == 60 + assert "structure" not in results["otherGrid"]["watched"] + assert results["otherGrid"]["panel"] == "cross" + + +def test_관_구조물_고름은_마름모만(results): + assert results["otherPipe"] == {"chainageM": 80, "leftM": 0, "rightM": 0, "structure": True} + assert results["otherStructure"]["structure"] is True + assert results["otherStructure"]["chainageM"] == 60 + + +def test_고름을_풀면_법선도_없다(results): + assert results["cleared"] == {"watched": None, "redrawn": True}