From a6e7a14217ea8a25eee81b5283d447bfa8464f2b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:01:18 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor(b06):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=ED=8C=A8=EB=84=90=20=EC=84=A0=ED=83=9D=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=20=EB=96=BC=EA=B8=B0=20=E2=80=94=20wireStructureSelec?= =?UTF-8?q?tion=20=EC=83=88=20=ED=8C=8C=EC=9D=BC(152-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B06_Section_UI_Page_Structures_Panel.ts 699 → 540줄 · 동작 그대로 - 선택 배선 = B06_Section_UI_Page_Structures_Selection.ts - 문자열 시험 3개 새 파일 읽기로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_view_cross.ts | 4 +- .../B06_Section_UI_Page_Structures_Panel.ts | 159 ----------------- ...06_Section_UI_Page_Structures_Selection.ts | 168 ++++++++++++++++++ resources/tester/test_118_pipe_pick.py | 2 +- .../tester/test_54_29_pick_same_panel.py | 2 +- .../tester/test_b05_b06_selection_single.py | 5 +- 6 files changed, 176 insertions(+), 164 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Page_Structures_Selection.ts diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index d84a5f93b..37bcf7a04 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -53,11 +53,11 @@ import { rememberRockBoundaryDefault, type StandardPanelController, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; +import { createB06StructuresPanel } from "../../B06_Section/B06_Section_UI_Page_Structures_Panel"; import { - createB06StructuresPanel, isWallSelecting, wireStructureSelection, -} from "../../B06_Section/B06_Section_UI_Page_Structures_Panel"; +} from "../../B06_Section/B06_Section_UI_Page_Structures_Selection"; import "../../B06_Section/B06_Section_UI_Style.css"; import "../../B06_Section/B06_Section_UI_Style_Cross.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 73332a1c9..f5901eb1d 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -54,8 +54,6 @@ import { setAdjustSideSlots, } from "./B06_Section_UI_Adjust_Dock"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; -import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; @@ -540,160 +538,3 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }, }; } - -/** - * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 - * 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계). - * 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다. - */ -/** 지금 처리 중인 선택이 **도면에서 벽·구체를 찍은 것**인가 — 카드 선택 리스너가 - * 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이 - * 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */ -let wallSelecting = false; - -export function isWallSelecting(): boolean { - return wallSelecting; -} - -function markWallSelecting(): void { - wallSelecting = true; - queueMicrotask(() => { - wallSelecting = false; - }); -} - -export function wireStructureSelection( - stationControls: StationControls, - detail: () => SectionDetailResponse | null, - panel: B06StructuresPanel, - /** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가 - * 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */ - remember?: (chainageM: number, key: string) => void, - /** 폼에서 고른 벽을 카드 조정창에도 세움(54-30 · 목록 넘김 `applyStructurePick` 과 같은 차례). */ - refreshCard?: (chainageM: number) => void, - /** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */ - reveal?: (chainageM: number) => void, -): { syncInletStructure: () => void } { - const ownerChainageOf = (chainageM: number): number => { - const sectionAt = detail()?.cross_sections.find( - (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, - ); - return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM; - }; - - // 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에 - // 올릴 때는 조정값을 폼으로 되읽는다(2026-08-29 지시 5 병합). - const sectionAt = (chainageM: number) => - detail()?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01); - panel.facility.onInletStructureChange((value) => { - const chainageM = panel.currentChainage(); - if (chainageM === null) return; - stationControls.inletStructure.set(chainageM, value); - }); - /** 폼에 올라온 시설의 유입측 형식을 폼 드롭다운에 되싣는다. */ - const syncInletStructure = (): void => { - const chainageM = panel.currentChainage(); - const target = chainageM === null ? undefined : sectionAt(chainageM); - if (target) panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target)); - }; - - const origRevet = stationControls.revetOffset.select; - stationControls.revetOffset.select = (chainageM, key) => { - // 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1) - const nested = wallSelecting; - // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) - if (key) markWallSelecting(); - origRevet(chainageM, key); - if (key) remember?.(ownerChainageOf(chainageM), key); - if (key) panel.showAtChainage(ownerChainageOf(chainageM)); - else if (!nested) { - remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 - panel.releasePipe(); - } - if (key) syncInletStructure(); - // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. - refreshAdjustSlots(); - }; - - // 관 상자 칸을 고치거나 고르면 그 부분을 고름 — 횡단에서 누른 것과 같은 길(54-30) - const keyOf = (box: HTMLElement): RevetPick | null => { - const id = box.dataset.specContainer; - if (id === "inlet" || id === "outlet" || id === "pipe") return id; - return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null; - }; - const pickBox = (box: HTMLElement): void => { - const chainageM = panel.currentChainage(); - const target = chainageM === null ? undefined : sectionAt(chainageM); - const key = keyOf(box); - if (!key || !target?.culvert || stationControls.revetOffset.highlightFor(target) === key) - return; - stationControls.revetOffset.select(target.chainage_m, key); - refreshCard?.(target.chainage_m); - }; - // 그 측점 카드 · 알약으로 감(목록에서 고른 길 · 54-47) — 같은 칸을 잇달아 고치면 한 번만(다시 그려도 칸 이름으로) - const fieldOf = (el: HTMLElement): string => { - const box = el.closest("fieldset[data-spec-container]"); - const item = el.closest("[data-spec-item]")?.dataset.specItem; - return `${box?.dataset.specContainer}:${box?.dataset.specIndex}:${item}`; - }; - let revealed: string | null = null; - const revealAt = (chainageM: number, field: string | null): void => { - if (field && field === revealed) return; - revealed = field; - reveal?.(chainageM); - }; - panel.root.addEventListener("focusin", (event) => { - if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null; - }); - panel.root.addEventListener("change", ({ target: el }) => { - const box = (el as HTMLElement).closest("fieldset[data-spec-container]"); - const chainageM = panel.currentChainage(); - if (!box || chainageM === null) return; - revealAt(chainageM, fieldOf(el as HTMLElement)); - // 단 수 칸은 고른 단을 지킴(단 수 바꿈 뒤 강조 유지) — 새 단은 다시 그린 뒤 아래에서 고름 - if (!(el as HTMLElement).closest('[data-spec-item="outlet.tiers"]')) pickBox(box); - }); - // 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만 - let painted: { chainageM: number | null; keys: Set } | null = null; - panel.root.addEventListener("specpaint", () => { - const chainageM = panel.currentChainage(); - const boxes = [...panel.root.querySelectorAll("fieldset[data-spec-container]")]; - const before = painted?.chainageM === chainageM ? painted.keys : null; - painted = { chainageM, keys: new Set(boxes.map(fieldOf)) }; - const born = before ? boxes.filter((box) => !before.has(fieldOf(box))).at(-1) : undefined; - if (!born || chainageM === null) return; - revealAt(chainageM, null); - pickBox(born); - }); - - const origFord = stationControls.ford.select; - stationControls.ford.select = (chainageM, role) => { - const nested = wallSelecting; - if (role) markWallSelecting(); - origFord(chainageM, role); - if (role) remember?.(chainageM, role); - if (role) panel.showAtChainage(chainageM); - else if (!nested) { - remember?.(chainageM, ""); - panel.releasePipe(); - } - // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 - // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). - refreshAdjustSlots(); - }; - const origBox = stationControls.box.select; - stationControls.box.select = (chainageM, role) => { - const nested = wallSelecting; - if (role) markWallSelecting(); - origBox(chainageM, role); - if (role) remember?.(chainageM, role); - if (role) panel.showAtChainage(chainageM); - else if (!nested) { - remember?.(chainageM, ""); - panel.releasePipe(); - } - // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. - refreshAdjustSlots(); - }; - return { syncInletStructure }; -} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts new file mode 100644 index 000000000..bfdb42d82 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -0,0 +1,168 @@ +/* ============================================================================= + * B06_Section_UI_Page_Structures_Selection.ts + * 횡단도 선택 → 좌측 폼 연동 배선(`wireStructureSelection`) — `_Page_Structures_Panel.ts` + * 에서 떼어 냄(700줄). 폼 · 목록 패널 자체는 그 파일 몫. + * ========================================================================== */ + +import { refreshAdjustSlots } from "./B06_Section_UI_Adjust_Dock"; +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; +import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; +import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel"; + +/** + * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 + * 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계). + * 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다. + */ +/** 지금 처리 중인 선택이 **도면에서 벽·구체를 찍은 것**인가 — 카드 선택 리스너가 + * 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이 + * 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */ +let wallSelecting = false; + +export function isWallSelecting(): boolean { + return wallSelecting; +} + +function markWallSelecting(): void { + wallSelecting = true; + queueMicrotask(() => { + wallSelecting = false; + }); +} + +export function wireStructureSelection( + stationControls: StationControls, + detail: () => SectionDetailResponse | null, + panel: B06StructuresPanel, + /** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가 + * 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */ + remember?: (chainageM: number, key: string) => void, + /** 폼에서 고른 벽을 카드 조정창에도 세움(54-30 · 목록 넘김 `applyStructurePick` 과 같은 차례). */ + refreshCard?: (chainageM: number) => void, + /** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */ + reveal?: (chainageM: number) => void, +): { syncInletStructure: () => void } { + const ownerChainageOf = (chainageM: number): number => { + const sectionAt = detail()?.cross_sections.find( + (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, + ); + return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM; + }; + + // 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에 + // 올릴 때는 조정값을 폼으로 되읽는다(2026-08-29 지시 5 병합). + const sectionAt = (chainageM: number) => + detail()?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01); + panel.facility.onInletStructureChange((value) => { + const chainageM = panel.currentChainage(); + if (chainageM === null) return; + stationControls.inletStructure.set(chainageM, value); + }); + /** 폼에 올라온 시설의 유입측 형식을 폼 드롭다운에 되싣는다. */ + const syncInletStructure = (): void => { + const chainageM = panel.currentChainage(); + const target = chainageM === null ? undefined : sectionAt(chainageM); + if (target) panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target)); + }; + + const origRevet = stationControls.revetOffset.select; + stationControls.revetOffset.select = (chainageM, key) => { + // 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1) + const nested = wallSelecting; + // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) + if (key) markWallSelecting(); + origRevet(chainageM, key); + if (key) remember?.(ownerChainageOf(chainageM), key); + if (key) panel.showAtChainage(ownerChainageOf(chainageM)); + else if (!nested) { + remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 + panel.releasePipe(); + } + if (key) syncInletStructure(); + // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. + refreshAdjustSlots(); + }; + + // 관 상자 칸을 고치거나 고르면 그 부분을 고름 — 횡단에서 누른 것과 같은 길(54-30) + const keyOf = (box: HTMLElement): RevetPick | null => { + const id = box.dataset.specContainer; + if (id === "inlet" || id === "outlet" || id === "pipe") return id; + return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null; + }; + const pickBox = (box: HTMLElement): void => { + const chainageM = panel.currentChainage(); + const target = chainageM === null ? undefined : sectionAt(chainageM); + const key = keyOf(box); + if (!key || !target?.culvert || stationControls.revetOffset.highlightFor(target) === key) + return; + stationControls.revetOffset.select(target.chainage_m, key); + refreshCard?.(target.chainage_m); + }; + // 그 측점 카드 · 알약으로 감(목록에서 고른 길 · 54-47) — 같은 칸을 잇달아 고치면 한 번만(다시 그려도 칸 이름으로) + const fieldOf = (el: HTMLElement): string => { + const box = el.closest("fieldset[data-spec-container]"); + const item = el.closest("[data-spec-item]")?.dataset.specItem; + return `${box?.dataset.specContainer}:${box?.dataset.specIndex}:${item}`; + }; + let revealed: string | null = null; + const revealAt = (chainageM: number, field: string | null): void => { + if (field && field === revealed) return; + revealed = field; + reveal?.(chainageM); + }; + panel.root.addEventListener("focusin", (event) => { + if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null; + }); + panel.root.addEventListener("change", ({ target: el }) => { + const box = (el as HTMLElement).closest("fieldset[data-spec-container]"); + const chainageM = panel.currentChainage(); + if (!box || chainageM === null) return; + revealAt(chainageM, fieldOf(el as HTMLElement)); + // 단 수 칸은 고른 단을 지킴(단 수 바꿈 뒤 강조 유지) — 새 단은 다시 그린 뒤 아래에서 고름 + if (!(el as HTMLElement).closest('[data-spec-item="outlet.tiers"]')) pickBox(box); + }); + // 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만 + let painted: { chainageM: number | null; keys: Set } | null = null; + panel.root.addEventListener("specpaint", () => { + const chainageM = panel.currentChainage(); + const boxes = [...panel.root.querySelectorAll("fieldset[data-spec-container]")]; + const before = painted?.chainageM === chainageM ? painted.keys : null; + painted = { chainageM, keys: new Set(boxes.map(fieldOf)) }; + const born = before ? boxes.filter((box) => !before.has(fieldOf(box))).at(-1) : undefined; + if (!born || chainageM === null) return; + revealAt(chainageM, null); + pickBox(born); + }); + + const origFord = stationControls.ford.select; + stationControls.ford.select = (chainageM, role) => { + const nested = wallSelecting; + if (role) markWallSelecting(); + origFord(chainageM, role); + if (role) remember?.(chainageM, role); + if (role) panel.showAtChainage(chainageM); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } + // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 + // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). + refreshAdjustSlots(); + }; + const origBox = stationControls.box.select; + stationControls.box.select = (chainageM, role) => { + const nested = wallSelecting; + if (role) markWallSelecting(); + origBox(chainageM, role); + if (role) remember?.(chainageM, role); + if (role) panel.showAtChainage(chainageM); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } + // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. + refreshAdjustSlots(); + }; + return { syncInletStructure }; +} diff --git a/resources/tester/test_118_pipe_pick.py b/resources/tester/test_118_pipe_pick.py index 161d0e4f3..0bf896f9a 100644 --- a/resources/tester/test_118_pipe_pick.py +++ b/resources/tester/test_118_pipe_pick.py @@ -97,7 +97,7 @@ def test_좌측_관_상자_강조_스크롤() -> None: assert 'body.classList.add("is-active");' in dock assert 'if (scroll) body.scrollIntoView({ block: "nearest" });' in dock # 관 상자 칸을 고치면 관을 고름 — 횡단에서 누른 것과 같은 길 - page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") + page = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts") assert 'if (id === "inlet" || id === "outlet" || id === "pipe") return id;' in page css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") assert ".b06-chart__culvert-pipe.is-active {" in css diff --git a/resources/tester/test_54_29_pick_same_panel.py b/resources/tester/test_54_29_pick_same_panel.py index 00ffa046a..7622d62af 100644 --- a/resources/tester/test_54_29_pick_same_panel.py +++ b/resources/tester/test_54_29_pick_same_panel.py @@ -49,7 +49,7 @@ def test_폼을_다시_그리면_강조_다시() -> None: def test_상자_칸을_고치면_그_부분을_고름() -> None: """54-30 — 칸 고침 = 횡단에서 그 벽을 누른 것과 같은 길 · 단 수 칸은 고른 단을 지킴.""" - page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") + page = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts") assert '[data-spec-item="outlet.tiers"]' in page assert "`extra${Number(box.dataset.specIndex)}`" in page assert "refreshCard?.(target.chainage_m);" in page diff --git a/resources/tester/test_b05_b06_selection_single.py b/resources/tester/test_b05_b06_selection_single.py index 33b63cf3e..e095972c5 100644 --- a/resources/tester/test_b05_b06_selection_single.py +++ b/resources/tester/test_b05_b06_selection_single.py @@ -61,6 +61,7 @@ def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다(): ("design_view_cross.ts", "A00_Common/design"), ("B05_Profile_UI_Selection.ts", "B05_Profile"), ("B06_Section_UI_Page_Structures_Panel.ts", "B06_Section"), + ("B06_Section_UI_Page_Structures_Selection.ts", "B06_Section"), ("B06_Section_UI_Page_Structure_Pick.ts", "B06_Section"), ("B05_Profile_UI_Structure_Pick_Session.ts", "B05_Profile"), ): @@ -187,7 +188,9 @@ def test_B05_되살리기_같은_측점_관보다_구조물_id가_먼저(out): # ── 배선 ─────────────────────────────────────────────────────────────── def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나(): - panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + _read( + "B06_Section", "B06_Section_UI_Page_Structures_Selection.ts" + ) assert "showPipeAt" not in panel # 관 전용 길을 없앰 assert panel.count("panel.showAtChainage(") == 3 # 기슭막이 · 세월교 · BOX assert panel.count("panel.releasePipe()") == 3 # 벽 선택을 풀 때는 관 폼만 비움 From 3eaa165cbc051b830ee7934cde95c16051e71ca9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:02:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(B07):=20=ED=9A=A1=EB=8B=A8=20=EC=9E=A5?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EC=B9=B8=EC=9D=84=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=EB=AC=B6=EC=9D=8C=20=EB=A7=A8=20=EC=95=84=EB=9E=98?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=20=EC=9E=85=EB=A0=A5=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EC=9E=91=EC=9D=80=20=EB=8B=A8=EC=B6=94=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99(153-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배치 열 · 행 = 공용 입력칸 · [이 장] [모든 장] = 작은 ghost 단추 - 장 아닌 도면 = 기본값 · [이 장] 꺼짐 - 정보 칸을 다시 그려도(장 확정 뒤) 칸이 남음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 37 ++-- .../B07_DesignDetail_UI_Panels.ts | 75 ++++---- .../B07_DesignDetail_UI_Style.css | 37 +++- .../tester/helper_153_b07_cross_layout.cjs | 165 ++++++++++++++++++ .../tester/test_153_1_b07_cross_layout.py | 63 +++++++ 5 files changed, 321 insertions(+), 56 deletions(-) create mode 100644 resources/tester/helper_153_b07_cross_layout.cjs create mode 100644 resources/tester/test_153_1_b07_cross_layout.py diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index a85e64072..003fd85d3 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -137,6 +137,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④). const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => { + void fillCrossLayout(drawing); if (drawing.kind !== "cross") { infoPanelHost.replaceChildren(); return; @@ -144,28 +145,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은 // 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다. const scope = isCrossSheet(drawing) ? "sheet" : "station"; - const panel = buildDesignInfoPanel(drawing.label, response.design ?? null, scope); - infoPanelHost.replaceChildren(panel); - if (scope === "sheet") void appendCrossLayout(drawing, panel); + infoPanelHost.replaceChildren( + buildDesignInfoPanel(drawing.label, response.design ?? null, scope), + ); }; - /** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. */ - const appendCrossLayout = async (drawing: DesignDrawingItem, panel: HTMLElement) => { + /** 장 배치 칸 자리(153-1) — 횡단도 묶음 맨 아래 · 정보 칸을 다시 그려도 남음. */ + const crossLayoutSlot = document.createElement("div"); + crossLayoutSlot.className = "b07-cross-layout-slot"; + let crossLayoutRequest: Promise | undefined; + + /** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. 장 아니면 기본값 · [이 장] 꺼짐. */ + const fillCrossLayout = async (drawing: DesignDrawingItem) => { if (!projectId) return; - const number = drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1; let layout: CrossLayout; try { - layout = await fetchCrossLayout(projectId); + layout = await (crossLayoutRequest ??= fetchCrossLayout(projectId)); } catch { + crossLayoutRequest = undefined; return; // 배치 칸만 빠짐 — 도면 보기는 그대로 } - if (currentDrawing?.id !== drawing.id || !panel.isConnected) return; + if (currentDrawing?.id !== drawing.id) return; + const number = isCrossSheet(drawing) + ? drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1 + : 0; const save = async (next: CrossLayout) => { if (!mayDiscardEdits()) return; showLoadingOverlay(); try { await saveCrossLayout(projectId, next); - reopenCrossSheet = number; + reopenCrossSheet = number || undefined; + reopenDrawingId = number ? undefined : currentDrawing?.id; cad.destroy(); void renderB07DesignDetail(root); } catch (error) { @@ -174,10 +184,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { hideLoadingOverlay(); } }; - panel.append( + crossLayoutSlot.replaceChildren( buildCrossLayoutControls( - layout.sheets[String(number)] ?? layout.default, - (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }), + (number && layout.sheets[String(number)]) || layout.default, + number + ? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }) + : null, (grid) => void save({ default: grid, sheets: {} }), ), ); @@ -517,6 +529,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }, } : undefined, + crossLayoutSlot, ); drawingListEl = drawingPanel; const confirmActions = document.createElement("div"); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 9fcda741d..83bfd7725 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -7,7 +7,7 @@ * ========================================================================== */ import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { showToast } from "@ui/ui_template_elements"; +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds"; @@ -46,6 +46,8 @@ export function buildDrawingSidePanel( errorMessage?: string, devBypass = false, plan?: PlanManageControls, + /** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */ + crossTools?: HTMLElement, ): HTMLDivElement { const panel = document.createElement("div"); panel.className = "b07-drawing-list"; @@ -147,6 +149,7 @@ export function buildDrawingSidePanel( // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. section.append(drawingButton(drawing, drawing.label)); } + if (cross && crossTools) section.append(crossTools); panel.append(section); } attachCollapsible(panel); @@ -172,54 +175,52 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); } -/** 횡단 장 배치 칸(140-3) — 「배치 열 [4] × 행 [4] [적용]」 + [모든 장 기본]. */ +/** + * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」. + * `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. + */ export function buildCrossLayoutControls( grid: [number, number], - onApply: (grid: [number, number]) => void, + onApply: ((grid: [number, number]) => void) | null, onAllDefault: (grid: [number, number]) => void, ): HTMLElement { const box = document.createElement("div"); - box.className = "b07-info__layout"; - const number = (value: number, label: string): HTMLInputElement => { - const input = document.createElement("input"); - input.type = "number"; - input.min = "1"; - input.max = "10"; - input.step = "1"; - input.value = String(value); - input.ariaLabel = label; - return input; - }; - const cols = number(grid[0], "열"); + box.className = "b07-cross-layout"; + const number = (value: number, label: string) => + createInputField({ + label, + type: "number", + value: String(value), + min: 1, + max: 10, + compact: true, + }); + const cols = number(grid[0], "배치 열"); const rows = number(grid[1], "행"); const read = (): [number, number] | null => { - const value: [number, number] = [Number(cols.value), Number(rows.value)]; + const value: [number, number] = [Number(cols.input.value), Number(rows.input.value)]; return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null; }; - const action = (text: string, title: string, run: (grid: [number, number]) => void) => { - const node = document.createElement("button"); - node.type = "button"; - node.className = "b07-plan-tools__button"; - node.textContent = text; - node.title = title; - node.addEventListener("click", () => { - const value = read(); - if (value) run(value); - else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error"); + const action = (label: string, title: string, run: ((grid: [number, number]) => void) | null) => { + const button = createButton({ + label, + variant: "ghost", + size: "sm", + disabled: !run, + onClick: () => { + const value = read(); + if (value) run?.(value); + else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error"); + }, }); - return node; + button.title = title; + return button; }; - const label = document.createElement("span"); - label.textContent = "배치 열"; - const times = document.createElement("span"); - times.textContent = "× 행"; box.append( - label, - cols, - times, - rows, - action("적용", "이 장만 이 배치로", onApply), - action("모든 장 기본", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), + cols.root, + rows.root, + action("이 장", "이 장만 이 배치로", onApply), + action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), ); return box; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 3835e57aa..e99f64b41 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -225,18 +225,41 @@ color: var(--color-text-muted); } -/* 횡단 장 배치(140-3) — 배치 열 [4] × 행 [4] [적용] [모든 장 기본] */ -.b07-info__layout { +/* 횡단 장 배치(153-1) — 횡단도 묶음 맨 아래 전체 폭: 배치 열 [4] 행 [4] [이 장] [모든 장]. + 좌측이 182px 까지 줄어드니 줄바꿈 허용 · 공용 칸의 오류 자리는 접음. */ +.b07-cross-layout-slot { + grid-column: 1 / -1; +} + +.b07-cross-layout-slot:empty { + display: none; +} + +.b07-cross-layout { display: flex; flex-wrap: wrap; align-items: center; - gap: var(--spacing-4); - color: var(--color-text-muted); - font-size: var(--text-caption); + gap: var(--spacing-4) var(--spacing-8); } -.b07-info__layout input { - width: 44px; +.b07-cross-layout > .ui-field { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-4); +} + +.b07-cross-layout .ui-field__label { + margin: 0; + white-space: nowrap; +} + +.b07-cross-layout .ui-input { + width: 48px; +} + +.b07-cross-layout .ui-field__error { + display: none; } /* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs new file mode 100644 index 000000000..dc221cc88 --- /dev/null +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -0,0 +1,165 @@ +/* 153-1 B07 횡단 장 배치 칸 헬퍼 — 좌측 「횡단도」 묶음 맨 아래 자리 · 공용 입력칸 · 작은 단추를 + * Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 · + * 공용 단추 · 입력칸은 진짜(`ui_template_elements`) · 알림만 기록용 가짜. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B07 = path.join(ROOT, "B07_DesignDetail"); +const UI = path.join(ROOT, "ui_template"); +const cache = new Map(); + +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.attrs = {}; + this.listeners = {}; + this._text = ""; + this._classes = new Set(); + this.value = ""; + this.disabled = false; + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + contains: (name) => self._classes.has(name), + }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + setAttribute(key, value) { + this.attrs[key] = String(value); + } + append(...kids) { + kids.forEach((k) => this.children.push(k)); + } + replaceChildren(...kids) { + this.children = [...kids]; + } + addEventListener(type, handler) { + (this.listeners[type] ||= []).push(handler); + } + click() { + if (!this.disabled) (this.listeners.click || []).forEach((handler) => handler({})); + } +} +const all = (node) => [node, ...node.children.flatMap(all)]; +const byClass = (node, name) => all(node).filter((n) => n.classList.contains(name)); + +globalThis.document = { createElement: (tag) => new FakeNode(tag) }; + +const toasts = []; +function loadTs(file) { + const full = path.resolve(file); + 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 (request === "@ui/ui_template_elements") + return { + ...loadTs(path.join(UI, "ui_template_elements.ts")), + showToast: (message, kind) => toasts.push([kind, message]), + }; + if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} }; + if (request === "@ui/ui_template_locale") + return { + currentLanguageIndex: 0, + ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), + }; + if (request.startsWith(".")) { + const target = path.join(path.dirname(full), `${request}.ts`); + return fs.existsSync(target) ? loadTs(target) : {}; + } + return {}; // 이 시험이 안 부르는 바깥 길(API · 설정 · CAD) + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { buildDrawingSidePanel, buildCrossLayoutControls } = loadTs( + path.join(B07, "B07_DesignDetail_UI_Panels.ts"), +); + +const out = {}; +const slot = new FakeNode("div"); +slot.className = "b07-cross-layout-slot"; +const panel = buildDrawingSidePanel( + [ + { id: "cross_standard", kind: "cross_standard", label: "표준 횡단면도", confirmed: false }, + { id: "cross_s1", kind: "cross", label: "1장", confirmed: false }, + { id: "cross_s2", kind: "cross", label: "2장", confirmed: true }, + ], + () => {}, + undefined, + false, + undefined, + slot, +); +const cross = all(panel).find((n) => n.dataset.kind === "cross"); +out.slotLast = cross.children.at(-1) === slot; +out.slotCount = all(panel).filter((n) => n === slot).length; + +// 장 — [이 장] 켜짐 · 값 바꿔 누름 +const applied = []; +const defaults = []; +slot.replaceChildren( + buildCrossLayoutControls( + [3, 4], + (grid) => applied.push(grid), + (grid) => defaults.push(grid), + ), +); +const box = slot.children[0]; +const buttons = () => all(slot).filter((n) => n.tagName === "BUTTON"); +const inputs = () => all(slot).filter((n) => n.tagName === "INPUT"); +out.sheet = { + boxClass: box.className, + labels: byClass(slot, "ui-field__label").map((n) => n.textContent), + inputs: inputs().map((n) => ({ + value: n.value, + type: n.attrs.type, + min: n.attrs.min, + max: n.attrs.max, + cls: n.className, + })), + buttons: buttons().map((n) => ({ text: n.textContent, cls: n.className, disabled: n.disabled })), +}; +inputs()[0].value = "2"; +inputs()[1].value = "5"; +buttons()[0].click(); +buttons()[1].click(); +inputs()[0].value = "11"; +buttons()[0].click(); +out.sheetClicks = { applied, defaults, toasts: [...toasts] }; + +// 장 아님 — [이 장] 꺼짐 · [모든 장]은 됨 +const defaults2 = []; +slot.replaceChildren(buildCrossLayoutControls([4, 4], null, (grid) => defaults2.push(grid))); +buttons()[0].click(); +buttons()[1].click(); +out.notSheet = { + disabled: buttons().map((n) => n.disabled), + defaults: defaults2, + values: inputs().map((n) => n.value), +}; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_153_1_b07_cross_layout.py b/resources/tester/test_153_1_b07_cross_layout.py new file mode 100644 index 000000000..58275b487 --- /dev/null +++ b/resources/tester/test_153_1_b07_cross_layout.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +"""153-1 B07 횡단 장 배치 칸. + +못박는 것 + - 배치 칸 자리 = 좌측 「횡단도」 묶음 맨 아래(한 번만) — 정보 칸을 다시 그려도 안 사라짐. + - 공용 입력칸(ui-field__label · ui-input--sm · 1~10) 둘 + 작은 ghost 단추 [이 장] [모든 장]. + - 장이 아니면 [이 장] 꺼짐 · [모든 장]은 됨 · 범위 밖 값 = 알림만. + +Node 헬퍼 `helper_153_b07_cross_layout.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_153_b07_cross_layout.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_횡단도_묶음_맨_아래(out): + assert out["slotLast"] is True + assert out["slotCount"] == 1 + + +def test_공용_입력칸과_작은_단추(out): + sheet = out["sheet"] + assert sheet["boxClass"] == "b07-cross-layout" + assert sheet["labels"] == ["배치 열", "행"] + assert [i["value"] for i in sheet["inputs"]] == ["3", "4"] + for field in sheet["inputs"]: + assert field["type"] == "number" and field["min"] == "1" and field["max"] == "10" + assert "ui-input--sm" in field["cls"] + assert [b["text"] for b in sheet["buttons"]] == ["이 장", "모든 장"] + for button in sheet["buttons"]: + assert button["cls"] == "ui-btn ui-btn--ghost ui-btn--sm" and not button["disabled"] + + +def test_이_장_모든_장_누름(out): + clicks = out["sheetClicks"] + assert clicks["applied"] == [[2, 5]] + assert clicks["defaults"] == [[2, 5]] + assert [t[0] for t in clicks["toasts"]] == ["error"] # 11 = 범위 밖 + + +def test_장_아니면_이_장_꺼짐(out): + not_sheet = out["notSheet"] + assert not_sheet["disabled"] == [True, False] + assert not_sheet["defaults"] == [[4, 4]] + assert not_sheet["values"] == ["4", "4"] From f70345e70405e05c998b6019cc1146a0ec41bdc2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:04:50 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84=20?= =?UTF-8?q?IP=20=EA=B8=80=20=C2=B7=20=EC=A7=80=EC=8B=9C=EC=84=A0=20?= =?UTF-8?q?=EC=83=89=EC=9D=84=20=EB=85=B8=EC=84=A0=20=EC=A4=91=EC=8B=AC?= =?UTF-8?q?=EC=84=A0=20=EC=83=89=EC=9C=BC=EB=A1=9C(153-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실측 = IP 글 #4d7dff 와 교각선 · 원 #4d7dff 이미 같음 → 정함대로 중심선 #ff4d4d - IP 기호 · R 글 색 그대로 · test_141 색 단정 추가 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 4 ++- resources/tester/test_141_b07_plan_labels.py | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index e3058d46a..25d6bbc54 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -146,6 +146,8 @@ _COLORS = { "emd": "#2f7d4f", "sgg": "#a63d3d", } +# 글 · 지시선만 조각 색과 다름 — IP 글 = 기준 형상(노선 중심선) 색(153-2) +_TEXT_COLORS = {"ip": _COLORS["centerline"]} # 행정 · 지적 경계 (선굵기, 파선) _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = { "parcel": (1, None), @@ -274,7 +276,7 @@ def _piece_entities( "label": label, "size": size, "layer": layer, - "color": color, + "color": _TEXT_COLORS.get(piece, color), "anchor": anchor, "candidates": spots, } diff --git a/resources/tester/test_141_b07_plan_labels.py b/resources/tester/test_141_b07_plan_labels.py index 6e9c426f1..c57df105f 100644 --- a/resources/tester/test_141_b07_plan_labels.py +++ b/resources/tester/test_141_b07_plan_labels.py @@ -152,3 +152,32 @@ def test_blocked_spots_push_farther_with_leader() -> None: entities = label_entities("d", placed) assert [e["type"] for e in entities] == ["Text", "Text", "PolyLine"] + + +def test_ip_text_takes_centerline_color() -> None: + """153-2 — IP 글 · 지시선 = 노선 중심선 색 · 교각선 · 원 · R 글은 조각 색 그대로.""" + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import _COLORS, _piece_entities + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities + + item = {"name": "IP1", "apex": [100.0, 100.0], "start": [90.0, 100.0], "end": [100.0, 110.0]} + pieces = { + "ip": {"items": [item]}, + "radius": {"items": [{"label": "R=12", "middle": [100.0, 98.0], "dir": [0.0, -1.0]}]}, + } + drawing = build_plan_kind_drawing("plan_x", "시험", (0.0, 0.0, 300.0, 200.0), pieces) + texts = {e["shapeData"]["label"]: e for e in _texts(drawing)} + ip_text = texts["IP1"] + assert ip_text["lineColor"] == _COLORS["centerline"] + assert ip_text["shapeData"]["options"]["textColor"] == _COLORS["centerline"] + assert texts["R=12"]["shapeData"]["options"]["textColor"] == _COLORS["radius"] + marks = [ + e for e in drawing["entities"] if e["layerId"] == "b07-plan-ip" and e["type"] == "PolyLine" + ] + assert len(marks) == 2 # 교각선 · 원 + assert {e["lineColor"] for e in marks} == {_COLORS["ip"]} + + labels: list = [] + _piece_entities("d", "ip", {"items": [item]}, lambda p: (p[0], p[1]), labels=labels) + far = [{**labels[0], "spot": {"x": 0.0, "y": 0.0, "far": True}, "rect": (0, 0, 1, 1)}] + leader = label_entities("d", far)[1] + assert leader["lineColor"] == _COLORS["centerline"] From 7cad25bb7f43985a108ff804290720bdecca8cb1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:06:37 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(CAD):=20=ED=9C=A0=20=3D=20=EB=8A=98=20?= =?UTF-8?q?=EC=A4=8C(=EC=BB=A4=EC=84=9C=20=EC=A4=91=EC=8B=AC)=20=C2=B7=20?= =?UTF-8?q?=ED=9C=A0=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20input-wheel.ts=20?= =?UTF-8?q?=EB=A1=9C=20=EB=96=BC=EA=B8=B0(153-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마우스 · 트랙패드 판정 지움 — 확대율 100 % 아닌 화면에서 휠이 팬으로 먹히던 것 - 트랙패드 두 손가락 팬 없어짐 · 팬 = 가운데 단추 끌기 - vitest input-wheel.test.ts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../src/inputController/input-controller.ts | 47 ++----------------- .../src/inputController/input-wheel.test.ts | 33 +++++++++++++ .../src/inputController/input-wheel.ts | 24 ++++++++++ 3 files changed, 61 insertions(+), 43 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-wheel.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-wheel.ts diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index dd3c6c21c..bc2957016 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -1,13 +1,7 @@ import { Point } from '@flatten-js/core'; import { compact, round } from 'es-toolkit'; import { Actor } from 'xstate'; -import { - PINCH_ZOOM_EXPONENT, - SNAP_POINT_DISTANCE, - WHEEL_LINE_PX, - WHEEL_PAGE_PX, - WHEEL_ZOOM_EXPONENT, -} from '../App.consts.ts'; +import { SNAP_POINT_DISTANCE } from '../App.consts.ts'; import { MouseButton } from '../App.types.ts'; import { describeCommand, @@ -66,6 +60,7 @@ import { isPlainSpace, } from './ime-sink.ts'; import { drawInputField } from './input-field-draw.ts'; +import { wheelZoom } from './input-wheel.ts'; import { ABSOLUTE_POINT_REGEXP, ABSOLUTE_POLAR_REGEXP, @@ -277,30 +272,8 @@ export class InputController { public handleMouseWheel(evt: WheelEvent) { // 확대는 도면만 — 트랙패드 핀치(ctrl+휠)가 브라우저 전체를 키우던 것을 막는다. evt.preventDefault(); - const drawController = getScreenCanvasDrawController(); - // deltaY 단위를 픽셀로 맞춘다 — 브라우저에 따라 줄(1)·쪽(2) 단위로 오기도 한다. - const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1; - const deltaX = evt.deltaX * unitPx; - const deltaY = evt.deltaY * unitPx; - - // 핀치(ctrl+휠)는 기기와 무관하게 확대. - if (evt.ctrlKey) { - if (deltaY !== 0) drawController.zoomScreen(-deltaY, PINCH_ZOOM_EXPONENT); - return; - } - - // 트랙패드 두 손가락 이동은 팬 — 문서를 스크롤하듯 화면이 손가락 반대로 간다. - if (!isMouseWheel(evt)) { - drawController.panScreen(-deltaX, deltaY); - return; - } - - if (deltaY === 0) { - return; // We can't zoom by zero delta - } - // Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing - // zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02). - drawController.zoomScreen(-deltaY, WHEEL_ZOOM_EXPONENT); + const zoom = wheelZoom(evt); + if (zoom) getScreenCanvasDrawController().zoomScreen(zoom.delta, zoom.exponent); } public handleMouseDown(evt: MouseEvent) { @@ -643,15 +616,3 @@ export class InputController { }); } } - -/** - * 마우스 휠인지 트랙패드인지 가른다. 마우스 휠은 한 칸이 정해진 크기(Chrome 100px, - * 일부 브라우저 120px)로 딱 떨어지고 가로 델타가 없다. 트랙패드는 손가락이 움직인 - * 만큼 잔 델타를 보내고 가로 델타도 함께 온다. 줄·쪽 단위(deltaMode≠0)는 휠뿐이다. - */ -function isMouseWheel(evt: WheelEvent): boolean { - if (evt.deltaMode !== 0) return true; - if (evt.deltaX !== 0) return false; - const step = Math.abs(evt.deltaY); - return step !== 0 && (step % 100 === 0 || step % 120 === 0); -} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-wheel.test.ts b/B07_DesignDetail/openwebcad/src/inputController/input-wheel.test.ts new file mode 100644 index 000000000..3bc6389ce --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-wheel.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + PINCH_ZOOM_EXPONENT, + WHEEL_LINE_PX, + WHEEL_PAGE_PX, + WHEEL_ZOOM_EXPONENT, +} from '../App.consts.ts'; +import { wheelZoom } from './input-wheel.ts'; + +const wheel = (deltaY: number, deltaMode = 0, ctrlKey = false) => + wheelZoom({ deltaY, deltaMode, ctrlKey }); + +describe('CAD 휠 = 늘 줌(153-3)', () => { + it('100 · 120 배수가 아닌 델타(확대율 90 % · 110 % · 트랙패드 잔 델타)도 줌', () => { + for (const deltaY of [100, 120, 111.1, 90.9, 4, -100, -111.1, -4]) { + expect(wheel(deltaY)).toEqual({ delta: -deltaY, exponent: WHEEL_ZOOM_EXPONENT }); + } + }); + + it('줄 · 쪽 단위는 픽셀로 환산', () => { + expect(wheel(3, 1)).toEqual({ delta: -3 * WHEEL_LINE_PX, exponent: WHEEL_ZOOM_EXPONENT }); + expect(wheel(-1, 2)).toEqual({ delta: WHEEL_PAGE_PX, exponent: WHEEL_ZOOM_EXPONENT }); + }); + + it('ctrl+휠(핀치)은 핀치 지수', () => { + expect(wheel(-7, 0, true)).toEqual({ delta: 7, exponent: PINCH_ZOOM_EXPONENT }); + }); + + it('델타 0 은 안 움직임', () => { + expect(wheel(0)).toBeNull(); + expect(wheel(0, 0, true)).toBeNull(); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-wheel.ts b/B07_DesignDetail/openwebcad/src/inputController/input-wheel.ts new file mode 100644 index 000000000..f6ea49e8b --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-wheel.ts @@ -0,0 +1,24 @@ +import { + PINCH_ZOOM_EXPONENT, + WHEEL_LINE_PX, + WHEEL_PAGE_PX, + WHEEL_ZOOM_EXPONENT, +} from '../App.consts.ts'; + +/** + * 휠 한 번 → 화면 확대(`zoomScreen` 인자). 휠은 늘 줌 · 커서 자리 고정(153-3) — 마우스 · 트랙패드를 + * 가르지 않는다(옛 판정은 확대율 ≠ 100 % 등에서 휠을 팬으로 오판). 팬 = 가운데 단추 끌기. + * null = 델타 0 이라 안 움직임. + */ +export function wheelZoom( + evt: Pick +): { delta: number; exponent: number } | null { + // deltaY 단위를 픽셀로 맞춘다 — 브라우저에 따라 줄(1)·쪽(2) 단위로 오기도 한다. + const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1; + const deltaY = evt.deltaY * unitPx; + if (deltaY === 0) return null; + // Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing + // zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02). + // 핀치(ctrl+휠)는 델타가 잘아 지수만 크게. + return { delta: -deltaY, exponent: evt.ctrlKey ? PINCH_ZOOM_EXPONENT : WHEEL_ZOOM_EXPONENT }; +} From 4ae7ee25cc915502e0e5bffe86a7b038d0991ecd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:13:08 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(B06):=20=EB=85=B8=EB=A9=B4=EA=B3=B5=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=3D=20=EB=B2=94=EC=9C=84=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=85=8C=EB=91=90=EB=A6=AC=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=ED=8F=AC=EC=9E=A5=EC=B8=B5=20=EA=B0=95=EC=A1=B0=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=88=84=EB=A6=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=85=B8=EB=A9=B4=EA=B3=B5=20=EC=97=B4=EA=B8=B0=20=EB=81=8A?= =?UTF-8?q?=EC=9D=8C(152-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 포장층 범위 표식(종류 · 시작 · 끝) · 고른 노면공 층만 is-active(겹침은 하나만) - 다른 구간형은 --span 그대로 - resolveSelection ③ 덮는 구조물에 거르개 · B06 은 G군 뺌 · 덮는 것 없으면 알약 · 강조 풂 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- A00_Common/design/design_selection.ts | 8 +- A00_Common/design/design_view_cross.ts | 2 +- .../B06_Section_UI_Cross_Design_Surface.ts | 26 +++++ .../B06_Section_UI_Page_Structures_Panel.ts | 14 ++- B06_Section/B06_Section_UI_Section_View.ts | 30 ++++-- .../B06_Section_UI_Style_Cross_Areas.css | 7 ++ .../tester/helper_152_5_pavement_pick.cjs | 102 ++++++++++++++++++ .../tester/test_106_span_highlight_wording.py | 13 ++- resources/tester/test_152_5_pavement_pick.py | 70 ++++++++++++ 9 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 resources/tester/helper_152_5_pavement_pick.cjs create mode 100644 resources/tester/test_152_5_pavement_pick.py diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index d4c7a3d17..de688e943 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -78,10 +78,12 @@ export function coversChainage(structure: SelectableStructure, chainageM: number * ① 구조물 id 가 있고 목록에 있으면 그 구조물 ② 측점 근처 관(계곡 통과 시설이 먼저 — * 같은 측점에 구조물이 겹쳐도 횡단도에 그려지는 것은 관이다) ③ 측점을 덮는 구조물. */ -export function resolveSelection( +export function resolveSelection( selection: DesignSelection, pipes: ReadonlyArray<{ chainage_m: number }>, - structures: ReadonlyArray, + structures: ReadonlyArray, + /** ③ 덮는 구조물로 열어도 되나 — B06 은 노면공을 뺌(152-5 · ① id 길은 그대로). */ + coverable: (entry: T) => boolean = () => true, ): ResolvedSelection { if (selection.structureId) { const byId = structures.find((entry) => entry.structure_id === selection.structureId); @@ -91,7 +93,7 @@ export function resolveSelection( if (chainageM === null) return { pipeChainageM: null, structureId: null }; const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m); if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null }; - const hit = structures.find((entry) => coversChainage(entry, chainageM)); + const hit = structures.find((entry) => coverable(entry) && coversChainage(entry, chainageM)); return { pipeChainageM: null, structureId: hit?.structure_id ?? null }; } diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 37bcf7a04..775dcc8fd 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -399,7 +399,7 @@ export async function mountCrossView( // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) const pipeAt = structuresPanel.currentChainage(); const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); - if (mark) sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 목록 길은 뒤에 여럿 + sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5) structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. diff --git a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts index 4355fab5c..275afb5e2 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts @@ -64,6 +64,26 @@ function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign) }; } +/** + * 고른 노면공의 포장층만 `is-active`(152-5) — 범위 표식(종류 · 시작 · 끝)이 맞는 층. 겹친 노면공은 하나만. + * `mark` 없음 = 모두 풂. + */ +export function highlightPavement( + root: ParentNode, + mark?: { type_id: string; start_m?: number | null; end_m?: number | null }, +): void { + const [low, high] = [mark?.start_m ?? 0, mark?.end_m ?? 0].sort((a, b) => a - b); + for (const layer of root.querySelectorAll(".b06-chart__pavement")) { + const { paveType, paveStart, paveEnd } = layer.dataset; + const same = + !!mark && + paveType === mark.type_id && + Math.abs(Number(paveStart) - low) < 0.01 && + Math.abs(Number(paveEnd) - high) < 0.01; + layer.classList.toggle("is-active", same); + } +} + /** * 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 · 무늬 · * 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께 채움만. @@ -92,6 +112,12 @@ export function appendPavementOverlay( const polygon = document.createElementNS(SVG_NS, "polygon"); polygon.setAttribute("points", points); polygon.setAttribute("class", "b06-chart__pavement"); + if (entry) { + // 범위 표식 — 고른 노면공의 포장층만 강조(152-5 · 겹친 노면공 가름) + polygon.dataset.paveType = entry.type_id; + polygon.dataset.paveStart = String(entry.start_m); + polygon.dataset.paveEnd = String(entry.end_m); + } const spec = entry?.spec; const mark = spec ? PAVE_MARKS[spec] : undefined; if (spec && mark) { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index f5901eb1d..e0f8b98a1 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -159,6 +159,9 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ); let markTypes: StructureType[] = []; + /** 카드 누름이 덮어 여는 구조물 — 노면공(G군)은 뺌(152-5 · 목록 · 알약 고름은 id 길로 그대로). */ + const coverable = (entry: StructureInstance): boolean => + markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G"; /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ let preferredId: string | null = null; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ @@ -516,7 +519,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }, selectionAt: (chainageM) => { const selection = { chainageM, structureId: null }; - return { chainageM, structureId: section.resolveAt(selection).structureId }; + return { + chainageM, + structureId: resolveSelection(selection, pipeFacilities, structures, coverable).structureId, + }; }, prefer: (structureId) => { preferredId = structureId; @@ -527,13 +533,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const selection: DesignSelection = { chainageM, structureId: preferredId }; // 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에 // 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳). - const hit = resolveSelection(selection, pipeFacilities, structures); + const hit = resolveSelection(selection, pipeFacilities, structures, coverable); if (hit.pipeChainageM !== null) { currentChainageM = hit.pipeChainageM; refreshSpecDefaults(hit.pipeChainageM); deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2) } - section.selectAt(selection); + // 폼은 위에서 푼 값으로 엶 — 공용 `selectAt` 은 노면공을 덮어 엶(152-5) + if (hit.pipeChainageM !== null) section.selectPipeByChainage(hit.pipeChainageM); + else section.selectById(hit.structureId); return { chainageM, structureId: hit.structureId }; }, }; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index c27103ecb..2a584f418 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -19,7 +19,7 @@ import type { HaulEquipmentLimit, SectionDetailResponse, } from "./B06_Section_Api_Fetch"; -import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; @@ -65,6 +65,7 @@ import { } from "./B06_Section_UI_Section_Common"; import type { SectionViewController } from "./B06_Section_UI_Section_View_Types"; import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice"; +import { highlightPavement } from "./B06_Section_UI_Cross_Design_Surface"; import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile"; import type { DesignPageShared } from "../A00_Common/design/design_shared"; @@ -231,23 +232,33 @@ export function createSectionView( if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights; /** - * 고른 구간형 구조물(노면공 등)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1). + * 고른 구간형 구조물(옹벽 · 독립 흙막이 · 사토장)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1). + * 노면공(G군)은 테두리 대신 범위 카드 안 그 노면공의 포장층만 강조(152-5) — 겹친 노면공은 범위 표식으로 하나만. * 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다. */ let spanMarks: ReadonlyArray = []; + let spanTypes: ReadonlyArray = []; let spanMarkId: string | null = null; - const inSpan = (section: CrossSection): boolean => { - const mark = spanMarkId ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : null; + const inSpan = (section: CrossSection, mark: StructureInstance | undefined): boolean => { const { start_m: start, end_m: end } = mark ?? {}; if (start == null || end == null || start === end) return false; const at = section.chainage_m; return at >= Math.min(start, end) - 0.01 && at <= Math.max(start, end) + 0.01; }; + const applySpan = (card: HTMLElement, section: CrossSection): void => { + const mark = spanMarkId + ? spanMarks.find((entry) => entry.structure_id === spanMarkId) + : undefined; + const span = inSpan(section, mark); + const pave = span && spanTypes.find((type) => type.type_id === mark?.type_id)?.group === "G"; + card.classList.toggle("b06-cross-card--span", span && !pave); + highlightPavement(card, pave ? mark : undefined); + }; const syncSpanHighlights = (): void => { - for (const section of currentDetail?.cross_sections ?? []) - document - .getElementById(`cross-${section.station_id}`) - ?.classList.toggle("b06-cross-card--span", inSpan(section)); + for (const section of currentDetail?.cross_sections ?? []) { + const card = document.getElementById(`cross-${section.station_id}`); + if (card) applySpan(card, section); + } }; /** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */ @@ -349,7 +360,7 @@ export function createSectionView( plotBase, cutSlope, ); - card.classList.toggle("b06-cross-card--span", inSpan(section)); + applySpan(card, section); return card; }; @@ -543,6 +554,7 @@ export function createSectionView( }, setStructureMarks(structures, types) { spanMarks = structures; + spanTypes = types; syncSpanHighlights(); profile.setMarks(structures, types); // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index de87ddbcc..de92d2214 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -200,6 +200,13 @@ stroke-width: 1; } +/* 고른 노면공의 포장층(152-5) — 범위 카드마다 · 무늬 채움은 inline 이라 무늬 없는 층만 채움이 바뀌고 테두리로 강조 */ +.b06-chart__pavement.is-active { + fill: color-mix(in srgb, var(--color-danger) 30%, transparent); + stroke: var(--color-danger); + stroke-width: 2.2; +} + /* 노면공 포장사양 무늬(88): 포장 박스 채움 = 바탕 + 무늬 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */ .b06-chart__pave-bg { fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent); diff --git a/resources/tester/helper_152_5_pavement_pick.cjs b/resources/tester/helper_152_5_pavement_pick.cjs new file mode 100644 index 000000000..8c8429b1a --- /dev/null +++ b/resources/tester/helper_152_5_pavement_pick.cjs @@ -0,0 +1,102 @@ +/* 152-5 노면공 강조 헬퍼 — 포장층 범위 표식 · 고른 노면공 포장층만 is-active · 카드 누름(③ 덮는 구조물)에서 노면공 뺌을 + * Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · SVG 그릇은 쓰는 만큼만 흉내. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +class FakeNode { + constructor(tag) { + this.tagName = tag; + this.children = []; + this.dataset = {}; + this.attrs = {}; + this.style = {}; + this._classes = new Set(); + const self = this; + this.classList = { + contains: (name) => self._classes.has(name), + toggle: (name, on) => (on ? self._classes.add(name) : self._classes.delete(name)), + }; + } + setAttribute(key, value) { + this.attrs[key] = String(value); + if (key === "class") this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + append(...kids) { + this.children.push(...kids); + } + querySelectorAll(selector) { + const name = selector.slice(1); + const all = (node) => node.children.flatMap((kid) => [kid, ...all(kid)]); + return all(this).filter((kid) => kid.classList.contains(name)); + } +} +globalThis.document = { createElementNS: (_, tag) => new FakeNode(tag) }; + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const surface = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Surface.ts")); +const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../b_page_state": { readState: () => null, writeState: () => {} }, +}); + +const out = {}; + +// 포장층 — 같은 측점 노면공 둘(겹침) · 범위 표식 +const svg = new FakeNode("svg"); +const edge = (offset) => ({ offset_m: offset, elevation_m: 100 }); +const design = { carriageway_edges: { left: edge(-2), right: edge(2) }, pavement_thickness_m: 0.2 }; +const entry = (start, end) => ({ + type_id: "pavement_concrete", + spec: null, + thickness_m: 0.2, + start_m: start, + end_m: end, + widening_on: true, +}); +surface.appendPavementOverlay( + svg, + design, + (v) => v, + (v) => v, + [entry(0, 40), entry(20, 60)], +); +const layers = () => svg.querySelectorAll(".b06-chart__pavement"); +const active = () => layers().map((layer) => layer.classList.contains("is-active")); +out.marks = layers().map((layer) => ({ ...layer.dataset })); +surface.highlightPavement(svg, { type_id: "pavement_concrete", start_m: 60, end_m: 20 }); +out.pickSecond = active(); +surface.highlightPavement(svg, { type_id: "gravel_surfacing", start_m: 20, end_m: 60 }); +out.otherType = active(); +surface.highlightPavement(svg, { type_id: "pavement_concrete", start_m: 0, end_m: 40 }); +out.pickFirst = active(); +surface.highlightPavement(svg, undefined); +out.cleared = active(); + +// 카드 누름 — ③ 덮는 구조물에서 노면공 뺌 · ① id 길은 그대로 +const pave = { structure_id: "pave", type_id: "pavement_concrete", start_m: 0, end_m: 40 }; +const wall = { structure_id: "wall", type_id: "retaining_wall", start_m: 10, end_m: 30 }; +const coverable = (s) => s.type_id !== "pavement_concrete"; +const resolve = (sel, list, fn) => selection.resolveSelection(sel, [], list, fn); +out.cover = { + default: resolve({ chainageM: 5, structureId: null }, [pave]).structureId, + skipped: resolve({ chainageM: 5, structureId: null }, [pave], coverable).structureId, + wallFirst: resolve({ chainageM: 20, structureId: null }, [pave, wall], coverable).structureId, + byId: resolve({ chainageM: 5, structureId: "pave" }, [pave], coverable).structureId, +}; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py index 3e9ca6238..4c92bdf6f 100644 --- a/resources/tester/test_106_span_highlight_wording.py +++ b/resources/tester/test_106_span_highlight_wording.py @@ -22,19 +22,24 @@ VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts") def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다(): - assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard + # applySpan 한 곳 — syncSpanHighlights · buildCrossCard 가 부름 + assert VIEW.count("b06-cross-card--span") == 1 assert re.search( r"selectMark: \(structureId, span = true\) => \{\s+(//[^\n]*\s+)?spanMarkId = span \? structureId : null;", VIEW, ) - assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW) - assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW + assert re.search( + r"spanMarks = structures;\s+spanTypes = types;\s+syncSpanHighlights\(\);", VIEW + ) + assert VIEW.count("applySpan(card, section);") == 2 + # 152-5 노면공(G군)은 테두리 대신 포장층 + assert 'card.classList.toggle("b06-cross-card--span", span && !pave);' in VIEW def test_측점_고름은_카드_하나_목록_고름은_여럿(): """137-4 — 측점 고름 리스너는 span 끔 · 목록(노면공) 길 · 알약은 기본(여럿) 그대로.""" cross = _read("A00_Common", "design", "design_view_cross.ts") - assert "if (mark) sectionView.selectMark(mark, false);" in cross + assert "sectionView.selectMark(mark, false);" in cross # 152-5 덮는 것 없으면 null = 풂 assert "markStructure: (structureId) => sectionView.selectMark(structureId)," in cross types = _read("B06_Section", "B06_Section_UI_Section_View_Types.ts") assert "selectMark: (structureId: string | null, span?: boolean) => void;" in types diff --git a/resources/tester/test_152_5_pavement_pick.py b/resources/tester/test_152_5_pavement_pick.py new file mode 100644 index 000000000..cb15c32f6 --- /dev/null +++ b/resources/tester/test_152_5_pavement_pick.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""152-5 B06 노면공 강조. + +못박는 것 + - 포장층마다 범위 표식(종류 · 시작 · 끝) — 고른 노면공의 층만 is-active · 겹친 노면공은 하나만 · 없음 = 모두 풂. + - 노면공(G군)은 범위 카드 테두리(--span) 대신 포장층 · 다른 구간형은 --span 그대로. + - 카드 누름(③ 덮는 구조물)은 노면공을 안 엶 · 목록 · 알약(① id 길)은 그대로 · 덮는 것 없으면 알약 · 강조 풂. + +Node 헬퍼 `helper_152_5_pavement_pick.cjs` 가 SVG 그릇 흉내로 돌림. 화면은 사용자 확인. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_152_5_pavement_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_포장층_범위_표식(out): + assert out["marks"] == [ + {"paveType": "pavement_concrete", "paveStart": "0", "paveEnd": "40"}, + {"paveType": "pavement_concrete", "paveStart": "20", "paveEnd": "60"}, + ] + + +def test_고른_노면공_층만_강조(out): + assert out["pickSecond"] == [False, True] # 시작 · 끝 거꾸로 와도 같은 범위 + assert out["otherType"] == [False, False] + assert out["pickFirst"] == [True, False] + assert out["cleared"] == [False, False] + + +def test_카드_누름은_노면공을_안_엶(out): + cover = out["cover"] + assert cover["default"] == "pave" # 거르개 없으면 옛 길 그대로(B05) + assert cover["skipped"] is None + assert cover["wallFirst"] == "wall" + assert cover["byId"] == "pave" # 목록 · 알약 고름 + + +def test_화면_배선(): + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + assert '?.group === "G"' in view + assert "highlightPavement(card, pave ? mark : undefined);" in view + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert panel.count("resolveSelection(selection, pipeFacilities, structures, coverable)") == 2 + assert "section.selectAt(selection)" not in panel # 공용 selectAt 은 노면공을 덮어 엶 + css = _read("B06_Section", "B06_Section_UI_Style_Cross_Areas.css") + assert ".b06-chart__pavement.is-active" in css