From 9cb7078e4fd8fcedfb41e09c67d5763033b3ffc7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:47:11 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(B06):=20=EB=B3=B4=EB=9D=BC=20=EC=84=A0?= =?UTF-8?q?=20=EA=B3=A0=EB=A6=84=20=EC=A0=95=EB=B3=B4=20=EA=B8=80=20?= =?UTF-8?q?=E3=80=8C=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EA=B2=BD=EC=82=AC?= =?UTF-8?q?=EB=B9=84=EC=9C=A8=20=C2=B7=20=EC=88=98=ED=8F=89=20=C2=B7=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=E3=80=8D=20=EC=B0=A8=EB=A1=80=20(195-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../B06_Section_UI_Cross_Design_Pick.ts | 8 ++++---- .../B06_Section_UI_Style_Cross_Controls.css | 2 +- resources/tester/test_137_design_pick.py | 8 ++++---- resources/tester/test_195_1_pick_label.py | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 resources/tester/test_195_1_pick_label.py diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index b53a16ec9..7c20a317b 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -201,7 +201,7 @@ export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): return out; } -/** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */ +/** 치수 — 길이 · 수평 · 높이(m) · 경사비율(사면 1:n · 노면 %). */ export function pieceMeasure( piece: DesignPiece, zone: CrossZone, @@ -219,13 +219,13 @@ export function pieceMeasure( return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade }; } -/** 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배(줄바꿈 글자로 가름). */ +/** 두 줄(151-1 · 195-1) — 1줄 = 길이 · 경사비율 · 2줄 = 수평 · 높이(줄바꿈 글자로 가름). */ export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string { const { zone } = pieceZone(design, piece); const m = pieceMeasure(piece, zone); return ( - `${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` + - `수평 ${m.horizontalM.toFixed(2)}m\n높이 ${m.heightM.toFixed(2)}m · ${m.grade}` + `길이 ${m.lengthM.toFixed(2)}m · 경사비율 ${m.grade}\n` + + `수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m` ); } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index f4fee5c15..778418876 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -223,7 +223,7 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } -/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */ +/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 경사비율 / 수평 · 높이). */ .b06-cross-card__line-dims { align-self: center; padding: 1px var(--spacing-4); diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py index 0e17f6d7e..af7412b9a 100644 --- a/resources/tester/test_137_design_pick.py +++ b/resources/tester/test_137_design_pick.py @@ -57,9 +57,9 @@ def test_치수_사면길이_수평_높이_구배(out) -> None: assert abs(fill["lengthM"] - 13**0.5) < 1e-9 assert out["measure"]["road"]["grade"] == "2.0%" # 노면 = % assert out["measure"]["flatSlope"] == "0.0%" - # 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배 - assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m\n높이 2.00m · 1:1.50" - assert out["label"]["road"] == "길이 2.50m · 수평 2.50m\n높이 0.05m · 2.0%" + # 두 줄(151-1 · 195-1) — 1줄 = 길이 · 경사비율 · 2줄 = 수평 · 높이 + assert out["label"]["fill"] == "길이 3.61m · 경사비율 1:1.50\n수평 3.00m · 높이 2.00m" + assert out["label"]["road"] == "길이 2.50m · 경사비율 2.0%\n수평 2.50m · 높이 0.05m" def test_구역_도로_절토_성토(out) -> None: @@ -84,7 +84,7 @@ def test_실제_배관_측점_조각과_판정_선(out) -> None: assert real["hitsBelowStructure"] is True # 구조물 판정면이 위 assert real["picked"] == [0] and real["stopped"] is True # 카드 고름으로 안 번짐 assert real["activeCount"] == 1 and real["activeIndex"] == 1 - assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("사면길이 ") + assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("길이 ") assert real["labelOff"] == {"hidden": True, "text": ""} assert set(real["zones"]) <= {"road", "cut", "fill"} diff --git a/resources/tester/test_195_1_pick_label.py b/resources/tester/test_195_1_pick_label.py new file mode 100644 index 000000000..428c06d64 --- /dev/null +++ b/resources/tester/test_195_1_pick_label.py @@ -0,0 +1,19 @@ +"""PLAN 195-1 — B06 보라 선 고름 정보 글: 「사면 길이」 → 「길이」 · 차례 = 길이 → 경사비율 → 수평 → 높이. + +짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts` `pieceLabel`. 실제 TS 를 `helper_137_design_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import re + +from test_137_design_pick import out # noqa: F401 — 같은 Node 도우미 결과를 씀 + + +def test_글_차례_넷_길이_경사비율_수평_높이(out) -> None: # noqa: F811 + for key in ("fill", "road"): + label = out["label"][key] + assert "사면" not in label + names = re.findall(r"(길이|경사비율|수평|높이)", label) + assert names == ["길이", "경사비율", "수평", "높이"], label + assert out["label"]["fill"].startswith("길이 ") From 6e8692d133c352b2d38710f4820b155eeb94e6c5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:54:45 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A4=84=20=EC=B9=A9=20=EA=BC=B4=20=C2=B7=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=B9=B8=20=ED=95=98=EB=82=98=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=C2=B7=20=EA=B8=B4=20=EC=84=A4=EB=AA=85=20=3D=20?= =?UTF-8?q?=ED=98=B8=EB=B2=84=20(196-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항목 = 짧은 이름 + 측점 수 칩(성토 5m↑ · 포장 권장 · 소단 검토) · 0 이면 숨김 - 펼침 = 줄 아래 내용 칸 하나(누른 칩 목록만 · 다시 누르면 닫힘 · 한 번에 하나) - 긴 법령 설명 · 측점 없는 구조물 줄 까닭 글 = 공용 호버 - sticky · 열 수 칸 · 카드 scroll-margin 그대로 · 새 시험 test_196_1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B06_Section_UI_Cross_FillSlope_Notice.ts | 107 ++++------ .../B06_Section_UI_Cross_Notice_Chips.ts | 95 +++++++++ .../B06_Section_UI_Missing_Stations.ts | 6 +- B06_Section/B06_Section_UI_Style_Cross.css | 49 +++-- .../tester/test_196_1_b06_notice_chips.py | 195 ++++++++++++++++++ ui_template/ui_template_locale_b2.ts | 2 + 6 files changed, 371 insertions(+), 83 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Notice_Chips.ts create mode 100644 resources/tester/test_196_1_b06_notice_chips.py diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index ce8a899b3..2ceacd67b 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -6,6 +6,7 @@ * ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③). * ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리). * ⭐ 138-3 — 줄 전체가 카드 칸 위에 고정(sticky) · 알림 없어도 줄 유지(오른쪽 끝 열 수 칸은 부르는 쪽이 붙임). + * ⭐ 196-1 — 항목 = 짧은 이름 + 수 칩 · 긴 설명 = 호버 · 내용 칸 하나 공유(`_Notice_Chips`). * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; @@ -18,83 +19,65 @@ import { } from "./B06_Section_UI_Cross_FillSlope_Warn"; import { L, stationLabel } from "./B06_Section_UI_Section_Common"; import { pavedBySpecs } from "./B06_Section_Cross_Refresh"; +import { createNoticeChips, type NoticeChipRow } from "./B06_Section_UI_Cross_Notice_Chips"; export interface FillSlopeNotice { root: HTMLElement; - /** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */ + /** 측점 설계가 바뀔 때마다 부른다 — 연 칩은 그대로 둔다. */ update: (sections: ReadonlyArray, stationInterval: number) => void; } -function noticeBlock(className: string): { - root: HTMLDetailsElement; - fill: (head: string, rows: FillSlopeWarning[], stationInterval: number) => void; -} { - const root = document.createElement("details"); - root.className = className; - root.hidden = true; - const summary = document.createElement("summary"); - const list = document.createElement("div"); - list.className = "b06-section__notice-list"; - root.append(summary, list); - return { - root, - fill(head, rows, stationInterval) { - root.hidden = !rows.length; - summary.textContent = `${head} · ${rows.length}측점`; - list.replaceChildren( - ...rows.map(({ section, sides }) => { - const button = document.createElement("button"); - button.type = "button"; - const parts = sides.map(({ side, lengthM, open }) => { - const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); - return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; - }); - button.textContent = - `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim(); - button.dataset.stationId = section.station_id; - return button; - }), - ); - }, - }; +/** 측점 단추 글 — 측점 + 쪽별 길이(포장 권장은 측점만) */ +function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChipRow[] { + return warnings.map(({ section, sides }) => { + const parts = sides.map(({ side, lengthM, open }) => { + const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); + return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; + }); + return { + stationId: section.station_id, + text: `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim(), + }; + }); } export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { const root = document.createElement("div"); root.className = "b06-section__notice-bar"; - const blocks = document.createElement("div"); - blocks.className = "b06-section__notice-blocks"; - root.append(blocks); - const warn = noticeBlock("b06-section__notice"); - const info = noticeBlock("b06-section__notice b06-section__notice--info"); - const pave = noticeBlock("b06-section__notice"); - pave.root.title = L("B06_Design_Paved_Suggested"); - blocks.append(warn.root, pave.root, info.root); - root.addEventListener("click", (event) => { - const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; - if (id) onPick(id); - }); + const chips = createNoticeChips(onPick); + root.append(chips.root); return { root, update(sections, stationInterval) { - warn.fill( - `⚠ ${FILL_SLOPE_WARN_TEXT}`, - fillSlopeWarnings(sections, fillSlopeLengths), - stationInterval, - ); - pave.fill( - `⚠ ${L("B06_Notice_PaveSuggested")}`, - sections - .filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section)) - .map((section) => ({ section, sides: [] })), - stationInterval, - ); - info.fill( - `ⓘ ${BERM_INFO_TEXT}`, - bermReviewStations(sections, slopeRunLengths), - stationInterval, - ); + chips.update([ + { + key: "fill", + label: L("B06_Notice_FillSlope"), + hint: FILL_SLOPE_WARN_TEXT, + tone: "warn", + rows: rows(fillSlopeWarnings(sections, fillSlopeLengths), stationInterval), + }, + { + key: "pave", + label: L("B06_Notice_PaveSuggested"), + hint: L("B06_Design_Paved_Suggested"), + tone: "warn", + rows: rows( + sections + .filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section)) + .map((section) => ({ section, sides: [] })), + stationInterval, + ), + }, + { + key: "berm", + label: L("B06_Notice_Berm"), + hint: BERM_INFO_TEXT, + tone: "info", + rows: rows(bermReviewStations(sections, slopeRunLengths), stationInterval), + }, + ]); }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts new file mode 100644 index 000000000..648236959 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts @@ -0,0 +1,95 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Notice_Chips.ts + * 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버. + * 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 · + * 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로). + * 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만. + * ========================================================================== */ + +import { attachHoverInfo } from "@ui/ui_template_hover_info"; + +export interface NoticeChipRow { + stationId: string; + text: string; +} + +export interface NoticeChipItem { + key: string; + /** 짧은 이름 — 칩 글 = 이름 + 측점 수 */ + label: string; + /** 긴 설명 — 호버 */ + hint: string; + tone: "warn" | "info"; + rows: NoticeChipRow[]; +} + +export interface NoticeChips { + root: HTMLElement; + /** 측점 설계가 바뀔 때마다 — 연 칩은 그대로(측점이 0 이 되면 닫힘) */ + update: (items: NoticeChipItem[]) => void; +} + +export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips { + const root = document.createElement("div"); + root.className = "b06-section__notice-blocks"; + const chips = document.createElement("div"); + chips.className = "b06-section__notice-chips"; + const panel = document.createElement("div"); + panel.className = "b06-section__notice-list"; + panel.hidden = true; + root.append(chips, panel); + let items: NoticeChipItem[] = []; + let openKey: string | null = null; + + const render = (): void => { + const open = items.find((item) => item.key === openKey); + if (!open) openKey = null; + chips.replaceChildren( + ...items.map((item) => { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = `b06-section__notice-chip b06-section__notice-chip--${item.tone}`; + chip.dataset.noticeKey = item.key; + chip.setAttribute("aria-expanded", String(item === open)); + chip.textContent = `${item.label} ${item.rows.length}`; + return chip; + }), + ); + panel.hidden = !open; + panel.classList.toggle("is-info", open?.tone === "info"); + panel.replaceChildren( + ...(open?.rows ?? []).map((row) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = row.text; + button.dataset.stationId = row.stationId; + return button; + }), + ); + }; + + chips.addEventListener("click", (event) => { + const key = (event.target as HTMLElement).closest(".b06-section__notice-chip") + ?.dataset.noticeKey; + if (!key) return; + openKey = openKey === key ? null : key; + render(); + }); + panel.addEventListener("click", (event) => { + const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; + if (id) onPick(id); + }); + attachHoverInfo( + chips, + (target) => items.find((item) => item.key === target.dataset.noticeKey)?.hint, + ".b06-section__notice-chip", + ); + + return { + root, + update(next) { + items = next.filter((item) => item.rows.length); + render(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Missing_Stations.ts b/B06_Section/B06_Section_UI_Missing_Stations.ts index 8031db761..ead81240e 100644 --- a/B06_Section/B06_Section_UI_Missing_Stations.ts +++ b/B06_Section/B06_Section_UI_Missing_Stations.ts @@ -14,6 +14,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, showToast } from "@ui/ui_template_elements"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; interface MissingStation { chainage_m: number; @@ -67,8 +68,9 @@ export async function mountMissingStationNotice( .join(" · "); text.textContent = `측점이 없는 구조물 ${missing.length}개 — ${where}` + - (missing.length > 6 ? ` 외 ${missing.length - 6}개` : "") + - ". 이 자리는 횡단도에도 안 서고 수량에서도 빠집니다."; + (missing.length > 6 ? ` 외 ${missing.length - 6}개` : ""); + // 긴 까닭 글 = 호버(196-1) + attachHoverInfo(text, "이 자리는 횡단도에도 안 서고 수량에서도 빠집니다."); box.append(text); if (data.can_create) { diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 0effa7db6..8a0d5fdd7 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -142,10 +142,6 @@ min-width: 0; } -.b06-section__notice-blocks > .b06-section__notice:last-of-type { - margin-bottom: 0; -} - /* 열 수 칸 — 라벨 왼쪽 · 한 줄(150-1) */ .ui-field.b06-section__columns { flex: none; @@ -168,33 +164,48 @@ display: none; } -/* 펼친 목록이 길면 줄 안에서 스크롤 — 고정 줄이 카드 칸을 다 덮지 않게 */ -.b06-section__notice-list { - max-height: 30vh; - overflow: auto; +/* 알림 칩 한 줄(196-1) — 짧은 이름 + 수 · 긴 설명 = 호버 · 누른 칩 = 눌림 꼴 */ +.b06-section__notice-chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); } -/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */ -.b06-section__notice { - margin-bottom: var(--spacing-8); +.b06-section__notice-chip { + padding: 0 var(--spacing-8); + border: 1px solid currentColor; + border-radius: var(--radius-sm); + background: transparent; color: var(--color-warning); font-size: var(--text-caption); -} - -/* 소단 검토 — 정보 한 줄(경고 색 아님 · 2026-09-15 브레인 ③). */ -.b06-section__notice--info { - color: var(--color-text-secondary); -} - -.b06-section__notice > summary { + white-space: nowrap; cursor: pointer; } +/* 소단 검토 — 정보(경고 색 아님 · 2026-09-15 브레인 ③). */ +.b06-section__notice-chip--info, +.b06-section__notice-list.is-info { + color: var(--color-text-secondary); +} + +.b06-section__notice-chip[aria-expanded="true"] { + background: var(--color-surface-raised); + font-weight: 600; +} + +/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */ .b06-section__notice-list { display: flex; flex-wrap: wrap; gap: var(--spacing-4); + max-height: 30vh; + overflow: auto; padding-top: var(--spacing-4); + color: var(--color-warning); +} + +.b06-section__notice-list[hidden] { + display: none; } .b06-section__notice-list > button { diff --git a/resources/tester/test_196_1_b06_notice_chips.py b/resources/tester/test_196_1_b06_notice_chips.py new file mode 100644 index 000000000..7027d4783 --- /dev/null +++ b/resources/tester/test_196_1_b06_notice_chips.py @@ -0,0 +1,195 @@ +"""196-1 B06 횡단 맨 위 알림 줄 — 칩 꼴 · 짧은 이름 + 수 · 긴 설명 = 호버 · 내용 칸 하나 공유. + +`B06_Section_UI_Cross_Notice_Chips.ts` 를 Node 로 그대로 돌림(작은 가짜 DOM · 호버는 받은 글만 기록). +줄 짜임(sticky · 열 수 칸 · 카드 scroll-margin)과 이름 · 설명 자리는 원문 대조. +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "config" / "node_modules" / "typescript" +B06 = ROOT / "B06_Section" +MOD = B06 / "B06_Section_UI_Cross_Notice_Chips.ts" + +PRELUDE = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(TS))}); +const js = ts.transpileModule(fs.readFileSync({json.dumps(str(MOD))}, "utf8"), {{ + compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, +}}).outputText; +class El {{ + constructor(tag) {{ + this.tag = tag; this.parentElement = null; this.children = []; this.on = {{}}; + this.attrs = {{}}; this.dataset = {{}}; this.hidden = false; this.textContent = ""; this.cls = new Set(); + const cls = this.cls; + this.classList = {{ toggle: (c, on) => (on ? cls.add(c) : cls.delete(c)), contains: (c) => cls.has(c) }}; + }} + set className(v) {{ this.cls.clear(); v.split(/\\s+/).filter(Boolean).forEach((c) => this.cls.add(c)); }} + get className() {{ return [...this.cls].join(" "); }} + append(...cs) {{ cs.forEach((c) => {{ c.parentElement = this; this.children.push(c); }}); }} + replaceChildren(...cs) {{ this.children = []; this.append(...cs); }} + setAttribute(k, v) {{ this.attrs[k] = String(v); }} + getAttribute(k) {{ return this.attrs[k] ?? null; }} + addEventListener(t, f) {{ (this.on[t] ||= []).push(f); }} + closest(sel) {{ + for (let x = this; x; x = x.parentElement) + if (sel.startsWith(".") ? x.cls.has(sel.slice(1)) : x.tag === sel) return x; + return null; + }} + click() {{ + const e = {{ type: "click", target: this }}; + for (let x = this; x; x = x.parentElement) (x.on.click || []).forEach((f) => f(e)); + }} +}} +const document = {{ createElement: (t) => new El(t) }}; +const hovers = []; +const fakeRequire = (name) => {{ + if (name !== "@ui/ui_template_hover_info") throw new Error("뜻밖의 import " + name); + return {{ attachHoverInfo: (root, text, sel) => hovers.push({{ root, text, sel }}) }}; +}}; +const mod = {{ exports: {{}} }}; +new Function("exports", "module", "require", "document", js)(mod.exports, mod, fakeRequire, document); +const picks = []; +const bar = mod.exports.createNoticeChips((id) => picks.push(id)); +const [chipsRow, panel] = bar.root.children; +const chip = (key) => chipsRow.children.find((c) => c.dataset.noticeKey === key); +const view = () => ({{ + chips: chipsRow.children.map((c) => [c.textContent, c.getAttribute("aria-expanded"), c.className]), + panelHidden: panel.hidden, + panelInfo: panel.classList.contains("is-info"), + rows: panel.children.map((b) => [b.textContent, b.dataset.stationId]), +}}); +const row = (id) => ({{ stationId: id, text: "STA " + id }}); +const items = (fill, pave, berm) => [ + {{ key: "fill", label: "성토 5m↑", hint: "긴 성토 설명", tone: "warn", rows: fill.map(row) }}, + {{ key: "pave", label: "포장 권장", hint: "긴 포장 설명", tone: "warn", rows: pave.map(row) }}, + {{ key: "berm", label: "소단 검토", hint: "긴 소단 설명", tone: "info", rows: berm.map(row) }}, +]; +const out = {{}}; +""" + + +def _node(body: str): + script = PRELUDE + body + "\nconsole.log(JSON.stringify(out));" + got = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def test_칩_이름은_짧은_이름_더하기_측점_수_0이면_안_보임(): + got = _node( + """ +bar.update(items(["a", "b", "c"], [], ["a", "d", "e", "f", "g"])); +out.v = view(); +out.bar = bar.root.className; +out.tags = chipsRow.children.map((c) => [c.tag, c.type]); +""" + ) + assert got["v"]["chips"] == [ + ["성토 5m↑ 3", "false", "b06-section__notice-chip b06-section__notice-chip--warn"], + ["소단 검토 5", "false", "b06-section__notice-chip b06-section__notice-chip--info"], + ] + assert got["tags"] == [["button", "button"], ["button", "button"]] + assert got["v"]["panelHidden"] is True and got["v"]["rows"] == [] + + +def test_긴_설명은_공용_호버로_칩마다(): + got = _node( + """ +bar.update(items(["a"], ["b"], ["c"])); +out.count = hovers.length; +out.root = hovers[0].root === chipsRow; +out.sel = hovers[0].sel; +out.hints = chipsRow.children.map((c) => hovers[0].text(c)); +out.other = hovers[0].text({ dataset: {} }) ?? null; +""" + ) + assert got["count"] == 1 and got["root"] and got["sel"] == ".b06-section__notice-chip" + assert got["hints"] == ["긴 성토 설명", "긴 포장 설명", "긴 소단 설명"] + assert got["other"] is None + + +def test_내용_칸_하나_한_번에_하나_다시_누르면_닫힘(): + got = _node( + """ +bar.update(items(["a", "b"], ["p"], ["c"])); +out.panels = bar.root.children.length; +chip("fill").click(); +out.open1 = view(); +chip("berm").click(); +out.open2 = view(); +chip("berm").click(); +out.closed = view(); +""" + ) + assert got["panels"] == 2 # 칩 줄 + 내용 칸 하나 + o1 = got["open1"] + assert o1["panelHidden"] is False and o1["panelInfo"] is False + assert o1["rows"] == [["STA a", "a"], ["STA b", "b"]] + assert [c[1] for c in o1["chips"]] == ["true", "false", "false"] + o2 = got["open2"] + assert o2["rows"] == [["STA c", "c"]] and o2["panelInfo"] is True + assert [c[1] for c in o2["chips"]] == ["false", "false", "true"] + c = got["closed"] + assert c["panelHidden"] is True and c["rows"] == [] + assert [x[1] for x in c["chips"]] == ["false", "false", "false"] + + +def test_측점_누르면_그_카드로_다시_그려도_연_칩_유지_0이면_닫힘(): + got = _node( + """ +bar.update(items(["a", "b"], [], [])); +chip("fill").click(); +panel.children[1].click(); +chipsRow.children[0].click(); chipsRow.children[0].click(); // 칩 누름은 측점 고름 아님 +out.picks = picks.slice(); +bar.update(items(["a", "b", "x"], [], [])); +out.kept = view(); +bar.update(items([], ["p"], [])); +out.gone = view(); +""" + ) + assert got["picks"] == ["b"] + assert got["kept"]["rows"] == [["STA a", "a"], ["STA b", "b"], ["STA x", "x"]] + assert got["kept"]["chips"][0][:2] == ["성토 5m↑ 3", "true"] + assert got["gone"]["panelHidden"] is True and got["gone"]["chips"][0][:2] == [ + "포장 권장 1", + "false", + ] + + +def test_알림_줄은_칩을_쓰고_긴_설명은_호버_짧은_이름은_사전(): + src = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8") + assert "5m"], B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], + B06_Notice_Berm: ["소단 검토", "Berm review"], B06_Cross_Columns: ["가로 개수", "Per row"], B06_Design_Detail: ["상세", "Detail"], B06_Design_Paved_Suggested: [ From cb7a140c10e78e1600d7fac9c0271e53142dc72d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:59:43 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(B06):=20=EC=86=8C=EB=8B=A8=20=3D=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=EA=B9=8A=EC=9D=B4=20=EC=B9=B8=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20=C2=B7=20=ED=9A=A1=EB=8B=A8=EB=8F=84=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=20=EA=B3=A0=EB=A6=84=20=E2=96=B2=E2=96=BC=20=EB=A7=8C?= =?UTF-8?q?=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=AA=BD=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EA=B9=8A=EC=9D=B4=20patch=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=EC=A1=B0=EA=B0=81=20=ED=82=A4=20=C2=B7=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EC=86=8C=EB=8B=A8=20=EC=83=81=EC=9E=90=20=EC=BC=AC?= =?UTF-8?q?(194-1=20=C2=B7=20194-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정의 「상세 내용」 = 폭만 · depth_m 표시 끔(옛 구간 값 = 기본값으로 읽음 · 새 기본 1 m) - 짝: BermSpec 쪽마다 깊이 · side_berm ↔ sideBerm · 설계 결과 berm_depth_{side}_m 되싣기(파이썬 ↔ TS) - 저장: patch berm_depth_{side}_m(≥ 0) · 재계산 살림 목록 짝(RD ↔ CR) · 세션 bermdepth - 서버 [저장] 다시 셈 = 구조물 정본 소단 구간(load_berm_spans) + 측점 깊이 · 구간 지우면 소단 없음 - 고름: 소단 수평 단 = 높이만(▲ 0 에서 멈춤 · ▼ 사면 끝 앞 멈춤 · ◀▶ 막힘) · 사면 = 가로 · 노면 = 종단 - 고름을 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각 - 새 시험 test_194_1_berm_depth + helper · test_178_2 · test_54_26 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/b_page_state.ts | 2 + B06_Section/B06_Section_Api_Types.ts | 6 + .../B06_Section_Cross_Design_Session.ts | 50 ++- B06_Section/B06_Section_Cross_Refresh.ts | 50 ++- B06_Section/B06_Section_Engine_Design.py | 4 + .../B06_Section_Engine_Design_Geometry.py | 5 +- .../B06_Section_Engine_Structures_Wall.py | 64 ++++ B06_Section/B06_Section_Router_Design.py | 38 +- B06_Section/B06_Section_Schema.py | 3 + B06_Section/B06_Section_UI_Cross_Design.ts | 2 + .../B06_Section_UI_Cross_Design_Pick.ts | 180 +++++++++- B06_Section/B06_Section_UI_Cross_View.ts | 15 +- .../B06_Section_UI_Page_Design_Sync.ts | 14 +- B06_Section/B06_Section_UI_Page_Persist.ts | 12 +- ...06_Section_UI_Page_Structures_Selection.ts | 24 ++ common_util/common_util_cross_berm.py | 11 + common_util/common_util_cross_berm.ts | 10 + common_util/common_util_cross_design.ts | 9 + .../common_util_cross_design_geometry.ts | 3 +- resources/master_template/구조물정의.json | 4 +- resources/tester/helper_194_1_berm_pick.cjs | 304 ++++++++++++++++ .../tester/test_178_2_berm_fill_mirror.py | 4 +- resources/tester/test_194_1_berm_depth.py | 339 ++++++++++++++++++ resources/tester/test_54_26_slope_table09.py | 6 +- 24 files changed, 1090 insertions(+), 69 deletions(-) create mode 100644 resources/tester/helper_194_1_berm_pick.cjs create mode 100644 resources/tester/test_194_1_berm_depth.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 0319b43d2..cc12ad8eb 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -177,6 +177,8 @@ export const STATE_REGISTRY = { crossdesign: { bucket: "draft", scope: "route" }, /** 설계선 쉬프트 = 쪽마다 노견 확장 `{측점: {left, right}}`(139-6) — [저장] 때 patch 로. */ shoulderext: { bucket: "draft", scope: "route" }, + /** 소단 쪽마다 옮긴 깊이 `{측점: {left, right}}`(194-1 · 횡단도 ▲▼) — [저장] 때 patch 로. */ + bermdepth: { bucket: "draft", scope: "route" }, /** B08 자재 집계표 셋 사용자 값 `{units, mix, surcharge}`(고친 칸만) — [저장] 때 프로젝트 설정으로(121). */ "b08-materials": { bucket: "draft", scope: "project" }, /** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */ diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 1666ba773..88f8e135f 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -455,6 +455,9 @@ export interface CrossDesign { /** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 사용자 값 · 노체 · 측구 · 사면 · 구조물 같이. */ shoulder_extend_left_m?: number; shoulder_extend_right_m?: number; + /** 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · 194-1) — 사용자 값 · 기하 입력. */ + berm_depth_left_m?: number; + berm_depth_right_m?: number; cross_slope_pct: number; ditch: | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } @@ -578,6 +581,9 @@ export interface CrossSectionPatch { /** 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림. */ shoulder_extend_left_m?: number; shoulder_extend_right_m?: number; + /** 소단 쪽마다 옮긴 깊이(m · 194-1 · 횡단도 ▲▼). */ + berm_depth_left_m?: number; + berm_depth_right_m?: number; /** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */ pipe_length_m?: number; /** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */ diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts index 2f38122b6..d351a1f6b 100644 --- a/B06_Section/B06_Section_Cross_Design_Session.ts +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -74,15 +74,14 @@ export function crossDesignChoices( export type ShoulderSide = "left" | "right"; export type ShoulderExtends = Map>>; -/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */ -export function readShoulderExtends( - projectId: string | null, - routeId: number | null, -): ShoulderExtends { +type SideState = "shoulderext" | "bermdepth"; + +/** 누가거리(숫자) → 쪽마다 값(세션에 있는 쪽만). */ +function readSides(name: SideState, projectId: string | null, routeId: number | null) { const out: ShoulderExtends = new Map(); if (!projectId || routeId === null) return out; const raw = readState>>>( - "shoulderext", + name, projectId, routeId, ); @@ -93,8 +92,8 @@ export function readShoulderExtends( return out; } -/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */ -export function writeShoulderExtend( +function writeSide( + name: SideState, projectId: string, routeId: number, chainageM: number, @@ -102,11 +101,34 @@ export function writeShoulderExtend( valueM: number, ): void { const all = - readState>>>( - "shoulderext", - projectId, - routeId, - ) ?? {}; + readState>>>(name, projectId, routeId) ?? + {}; all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) }; - writeState("shoulderext", all, projectId, routeId); + writeState(name, all, projectId, routeId); } + +/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */ +export const readShoulderExtends = (projectId: string | null, routeId: number | null) => + readSides("shoulderext", projectId, routeId); + +/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */ +export const writeShoulderExtend = ( + projectId: string, + routeId: number, + chainageM: number, + side: ShoulderSide, + valueM: number, +) => writeSide("shoulderext", projectId, routeId, chainageM, side, valueM); + +/* ── 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · ≥ 0 · 194-1 · 횡단도 ▲▼) — 노견 확장과 같은 꼴. + * [저장]·[확정] 이 patch 로 싣고(서버가 다시 셈), 브라우저 재계산이 입력으로 씀. */ +export const readBermDepths = (projectId: string | null, routeId: number | null) => + readSides("bermdepth", projectId, routeId); + +export const writeBermDepth = ( + projectId: string, + routeId: number, + chainageM: number, + side: ShoulderSide, + depthM: number, +) => writeSide("bermdepth", projectId, routeId, chainageM, side, depthM); diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index fd6f8548e..02bfa8a79 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -31,7 +31,11 @@ import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetc import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; -import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; +import { + crossDesignChoices, + readBermDepths, + readShoulderExtends, +} from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, readRockBoundaryDefault, @@ -95,6 +99,9 @@ export const USER_TOUCHED_KEYS = [ // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 "shoulder_extend_left_m", "shoulder_extend_right_m", + // 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김 + "berm_depth_left_m", + "berm_depth_right_m", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ @@ -192,17 +199,39 @@ function bermPayload( projectId: string, routeId: number, detail: SectionDetailResponse, -): Record { +): Record>> { const spans = readBermSpans(projectId, routeId); - const out: Record = {}; + const out: Record>> = {}; if (!spans.length) return out; + const depthAt = bermDepthReader(projectId, routeId); for (const section of detail.cross_sections) { const spec = bermSpecAt(spans, section.chainage_m); - if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec; + if (!spec) continue; + const design = (section.design ?? {}) as Record; + const sides: Partial> = {}; + for (const side of SIDES) { + const own = depthAt(section.chainage_m, design, side); + if (own !== null) sides[`berm_depth_${side}_m`] = own; + } + out[rockKey(section.chainage_m).toFixed(2)] = { ...spec, ...sides }; } return out; } +type Side = "left" | "right"; +const SIDES: Side[] = ["left", "right"]; + +/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */ +function bermDepthReader(projectId: string, routeId: number) { + const session = readBermDepths(projectId, routeId); + return (chainageM: number, design: Record, side: Side): number | null => { + const own = session.get(rockKey(chainageM))?.[side]; + const stored = design[`berm_depth_${side}_m`]; + const value = own ?? (typeof stored === "number" ? stored : null); + return value === null ? null : Math.max(value, 0); + }; +} + /** * 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다. * @@ -256,7 +285,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). const bermSpans = readBermSpans(projectId, input.routeId); - const bermAt = (chainageM: number): BermSpec | null => { + const bermDepthAt = bermDepthReader(projectId, input.routeId); + const bermAt = (chainageM: number, design: Record): BermSpec | null => { const spec = bermSpecAt(bermSpans, chainageM); return spec ? { @@ -264,6 +294,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { intervalM: spec.interval_m, slopeDeg: spec.slope_deg, depthM: spec.depth_m, + // 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 + depthLeftM: bermDepthAt(chainageM, design, "left"), + depthRightM: bermDepthAt(chainageM, design, "right"), } : null; }; @@ -325,7 +358,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: pavedBySpecs(section), standard, - berm: bermAt(section.chainage_m), + berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, cutSlopeRatio: cutSlopeAt(section.chainage_m, design), twoStageSlope: @@ -374,6 +407,11 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ); section.design.shoulder_extend_left_m = extendLeft; section.design.shoulder_extend_right_m = extendRight; + // 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) + for (const side of SIDES) { + const own = next[`berm_depth_${side}_m`]; + if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own; + } updated.push(section.chainage_m); } return updated; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 885293480..db44611ca 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -541,6 +541,10 @@ def compute_cross_design( "slope_deg": round(float(berm.slope_deg), 4), "depth_m": round(max(float(berm.depth_m), 0.0), 4), } + # 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력) + for side, own in (("left", berm.depth_left_m), ("right", berm.depth_right_m)): + if own is not None: + result[f"berm_depth_{side}_m"] = round(max(float(own), 0.0), 4) # 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력) for side, extend in ( ("left", geometry.shoulder_extend_left), diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index db48dd7c9..1bc4121df 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -21,6 +21,7 @@ from common_util.common_util_cross_berm import ( BermSpec, cut_profile_points, fill_profile_points, + side_berm, ) from common_util.common_util_cross_berm import elevation_at as berm_elevation_at @@ -236,7 +237,9 @@ class _SectionGeometry: edge_z = ( self.road_z(self.left_extent) if side == "left" else self.road_z(-self.right_extent) ) - points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm, top_z=edge_z) + # 쪽마다 옮긴 깊이(194-1) — `side_berm` + berm = side_berm(self.berm, side) + points = fill_profile_points(start_dist, start_z, self.fill_ratio, berm, top_z=edge_z) self._fill_points_cache[side] = points return points diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index af7ae6052..90649e6fc 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -120,6 +120,70 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: return [] +def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None: + """구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1). + + 짝: TS `bermSpansFromStructures`(같은 규칙 — 폭 0 = 없음 · 깊이 빈 값 = 기본 1.0 · 0 미만 = 0). + 못 읽으면 None(부르는 쪽이 저장분 `design.berm` 으로 물러섬). + """ + from common_util.common_util_cross_berm import ( + BERM_DEFAULT_DEPTH_M, + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, + ) + + def number(options: dict[str, Any], key: str, fallback: float) -> float: + try: + value = float(options.get(key)) + except (TypeError, ValueError): + return fallback + return value if math.isfinite(value) and value >= 0 else fallback + + try: + spans: list[tuple[float, float, dict[str, float]]] = [] + for item in load_structures(str(project_root))[1]: + if item.type_id != BERM_TYPE_ID: + continue + anchor = item.chainage_m if item.chainage_m is not None else item.start_m + start = item.start_m if item.start_m is not None else anchor + end = item.end_m if item.end_m is not None else anchor + if start is None or end is None: + continue + options = item.options or {} + width = number(options, "width_m", BERM_DEFAULT_WIDTH_M) + if width <= 0: + continue + raw = options.get("depth_m") + try: + depth = BERM_DEFAULT_DEPTH_M if raw in (None, "") else max(float(raw), 0.0) + except (TypeError, ValueError): + depth = BERM_DEFAULT_DEPTH_M + if not math.isfinite(depth): + depth = BERM_DEFAULT_DEPTH_M + spec = { + "width_m": width, + "interval_m": number(options, "interval_m", BERM_DEFAULT_INTERVAL_M), + "slope_deg": number(options, "slope_deg", BERM_DEFAULT_SLOPE_DEG), + "depth_m": depth, + } + spans.append((float(min(start, end)), float(max(start, end)), spec)) + return spans + except Exception: # noqa: BLE001 — 정본을 못 읽으면 저장분으로 물러섬 + logger.exception("B06 소단 구간을 읽지 못했습니다 (저장분 소단을 씀)") + return None + + +def berm_span_at( + spans: list[tuple[float, float, dict[str, float]]], chainage_m: float +) -> dict[str, float] | None: + """그 측점을 덮는 소단 제원 — 겹치면 먼저 놓은 것(TS `bermSpecAt` 짝).""" + for start, end, spec in spans: + if start - 1e-6 <= chainage_m <= end + 1e-6: + return spec + return None + + def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: """구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다. diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 6a06c5481..adab8a3c5 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -160,6 +160,9 @@ USER_TOUCHED_KEYS = ( # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) "shoulder_extend_left_m", "shoulder_extend_right_m", + # 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력(`stored_berm`) + "berm_depth_left_m", + "berm_depth_right_m", ) # 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 · @@ -256,14 +259,20 @@ def enforce_design_lines( 받아 적었다(CLAUDE.md 5장 「[저장]은 서버가 정본으로 다시 계산」 어긋남 · B07 이 저장된 선을 그림). 입력 = 저장분(지반유형 · 단면유형 · 측구 · 포장 · 암 경계 · 암 절토 경사 · 소단 · 노견 확장) + 지금 종단 계획고 · 세월교 월류 하강 · 곡선부 확폭. 사용자 조작값은 승계. + 소단 제원은 구조물 정본(`structures.json`)의 구간이 원천 — 쪽마다 옮긴 깊이는 측점 patch(194-1). """ + from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans + ford_drops = ford_surface_drops(project_root) + spans = load_berm_spans(project_root) changed = 0 for section in cross_sections: design = section.get("design") if not isinstance(design, dict): continue chainage = float(section.get("chainage_m", 0.0)) + if spans is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로 + design = {**design, "berm": berm_span_at(spans, chainage)} try: recomputed = compute_cross_design( section.get("samples", []), @@ -449,22 +458,34 @@ def stored_cut_slope(stored: dict[str, Any]) -> float | None: return None -def stored_berm(stored: dict[str, Any]) -> BermSpec | None: - """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" - spec = stored.get("berm") +def berm_from(spec: Any, sides: dict[str, Any]) -> BermSpec | None: + """소단 제원 dict + 쪽마다 옮긴 깊이(`berm_depth_{side}_m` · 194-1) → BermSpec.""" if not isinstance(spec, dict): return None + + def own(side: str) -> float | None: + value = sides.get(f"berm_depth_{side}_m") + ok = isinstance(value, (int, float)) and not isinstance(value, bool) + return max(float(value), 0.0) if ok else None + try: return BermSpec( width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0), + depth_left_m=own("left"), + depth_right_m=own("right"), ) except (TypeError, ValueError): return None +def stored_berm(stored: dict[str, Any]) -> BermSpec | None: + """저장분에 남은 소단 제원 + 쪽마다 옮긴 깊이 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" + return berm_from(stored.get("berm"), stored) + + def recompute_designs_for_alignment( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], @@ -500,13 +521,10 @@ def recompute_designs_for_alignment( session_berms: dict[float, BermSpec] = {} for raw_key, spec in (berms or {}).items(): try: - session_berms[round(float(raw_key), 3)] = BermSpec( - width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), - interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), - slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), - depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0), - ) - except (TypeError, ValueError, AttributeError): + parsed = berm_from(spec, spec if isinstance(spec, dict) else {}) # 쪽 깊이도(194-1) + if parsed is not None: + session_berms[round(float(raw_key), 3)] = parsed + except (TypeError, ValueError): continue for section in cross_sections: chainage = float(section.get("chainage_m", 0.0)) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index b2d086b59..d0ee0e92c 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -184,6 +184,9 @@ class CrossSectionPatch(BaseModel): # 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림(정본의 옛 값을 지움) shoulder_extend_left_m: float | None = Field(default=None, ge=0, le=20) shoulder_extend_right_m: float | None = Field(default=None, ge=0, le=20) + # 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · 194-1 · 횡단도 ▲▼) — 0 = 노면 끝 높이(도로 위 금지) + berm_depth_left_m: float | None = Field(default=None, ge=0, le=50) + berm_depth_right_m: float | None = Field(default=None, ge=0, le=50) inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None basin_adjust: BasinAdjustPatch | None = None # 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 5020be6bb..7bc478776 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -116,6 +116,8 @@ export interface CrossDesignChange { ditch_choice: boolean | null; /** 설계선 쉬프트(139-6) — 있으면 그 쪽 노견 확장만 바꾸는 변경(다른 칸은 안 씀). */ shoulder_extend?: { side: "left" | "right"; valueM: number }; + /** 소단 높이(194-1) — 있으면 그 쪽 소단 깊이(노면 끝 아래 m)만 바꾸는 변경. */ + berm_depth?: { side: "left" | "right"; valueM: number }; } /** diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index b53a16ec9..9eb85b419 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -10,11 +10,15 @@ * 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 · * 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 · * 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고). + * 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬. + * 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2). * ========================================================================== */ -import type { CrossDesign } from "./B06_Section_Api_Fetch"; +import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; +import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design"; import { clipSegment, type XY } from "../A00_Common/b_polygon_clip"; import { sideRole } from "@util/common_util_cross_design_geometry"; +import { BERM_DEFAULT_DEPTH_M } from "@util/common_util_cross_berm"; import { arrowsBelongToProfile, bindArrowKeys, @@ -61,7 +65,8 @@ export type CrossSide = "left" | "right"; type ZoneDesign = Pick< CrossDesign, "road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode" ->; +> & + Partial>; /** * offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + @@ -104,8 +109,46 @@ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => /** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) · * 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */ -export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): "x" | "y" => - pieceZone(design, piece).zone === "road" ? "y" : "x"; +export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis => + isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x"; +/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */ +export type MoveAxis = "x" | "y" | "depth"; + +/** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */ +export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number => + design[`berm_depth_${side}_m`] ?? design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M; + +/** 소단 수평 단인가 — 성토측 · 폭 = 소단 폭 · 안쪽 끝 표고 = 노면 끝 − 깊이(194-1). */ +export function isBermPiece(design: ZoneDesign, piece: DesignPiece): boolean { + const berm = design.berm; + if (!berm || piece.structure) return false; + const { zone, side } = pieceZone(design, piece); + const edge = design.road_edges?.[side]; + if (zone !== "fill" || !edge) return false; + const rise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m; + const low = Math.min(piece.from.elevation, piece.to.elevation); + const high = Math.max(piece.from.elevation, piece.to.elevation); + return ( + Math.abs(Math.abs(piece.to.offset - piece.from.offset) - berm.width_m) < 0.05 && + Math.abs(low - (edge.elevation_m - bermDepthOf(design, side))) < 0.02 && + high - low <= rise + 0.01 + ); +} + +/** 조각 키 = 갈래 + 쪽 + 차례(가운데에서 바깥으로) — 다시 그려도 안 바뀜(194-2). */ +export function pieceKeys(design: ZoneDesign, pieces: DesignPiece[]): string[] { + const mid = (p: DesignPiece) => Math.abs(p.from.offset + p.to.offset) / 2; + const kinds = pieces.map((p) => { + const { zone, side } = pieceZone(design, p); + return `${isBermPiece(design, p) ? "berm" : zone}${p.structure ? "+s" : ""}:${side}`; + }); + return pieces.map((p, i) => { + const rank = pieces.filter( + (q, j) => kinds[j] === kinds[i] && (mid(q) < mid(p) || (mid(q) === mid(p) && j < i)), + ).length; + return `${kinds[i]}:${rank}`; + }); +} const gap = (a: CrossPoint, b: CrossPoint): number => Math.hypot(a.offset - b.offset, a.elevation - b.elevation); @@ -229,13 +272,22 @@ export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string { ); } -/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */ -let picked: { stationId: string; index: number } | null = null; -export const pickedLineAt = (stationId: string): number | null => - picked?.stationId === stationId ? picked.index : null; +/* ── 페이지 한 칸 고름 — 측점 + 조각 키(카드를 다시 그려도 되살림 · 137-3) ─────────── + * 번호가 아니라 키(`pieceKeys`)로 기억 — 옮겨 다시 그리면 조각 차례가 바뀌어도 같은 조각(194-2). + * 키를 안 적은 측점(카드 밖 시험)은 번호 그대로. */ +let picked: { stationId: string; key: string } | null = null; +const keysByStation = new Map(); +export function pickedLineAt(stationId: string): number | null { + if (picked?.stationId !== stationId) return null; + const keys = keysByStation.get(stationId); + const index = keys ? keys.indexOf(picked.key) : Number(picked.key); + return index >= 0 ? index : null; +} export function setPickedLine(stationId: string, index: number | null): void { - if (index !== null) picked = { stationId, index }; - else if (picked?.stationId === stationId) picked = null; + if (index !== null) { + const keys = keysByStation.get(stationId); + picked = { stationId, key: keys ? (keys[index] ?? "") : String(index) }; + } else if (picked?.stationId === stationId) picked = null; } /* ── 카드 배선 ─────────────────────────────────────────────────────────── */ @@ -270,6 +322,54 @@ export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, k return Math.max(0, Math.round((now + outward) * 10) / 10); } +/** 소단 높이(194-1) — 쪽마다 깊이(노면 끝 아래 m · ≥ 0) · 고름 켬/끔을 좌측 소단 상자에 알림. */ +export interface BermDepthControl { + set: (side: CrossSide, depthM: number) => void; + focus: (on: boolean) => void; +} + +/** 소단 고름 → 좌측 패널 소단 상자 켬(194-2) — 페이지가 단다(측점 · null = 끔). */ +let bermHost: ((chainageM: number | null) => void) | null = null; +let bermHostAt: number | null = null; +export function setBermPickHost(host: ((chainageM: number | null) => void) | null): void { + bermHost = host; + bermHostAt = null; +} +function tellBermHost(chainageM: number | null): void { + if (chainageM === bermHostAt) return; // 다시 그린 카드의 되살림은 한 번만 + bermHostAt = chainageM; + bermHost?.(chainageM); +} + +/** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */ +export function designLineMoves( + section: Pick, + onDesignChange?: (chainageM: number, change: CrossDesignChange) => void, +): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } { + const design = section.design; + if (!onDesignChange || !design) return { stationId: section.station_id }; + const send = (change: Partial) => + onDesignChange(section.chainage_m, { + ...(design as unknown as CrossDesignChange), + ...change, + }); + return { + stationId: section.station_id, + shift: { + valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0, + set: (side, valueM) => send({ shoulder_extend: { side, valueM } }), + }, + berm: { + set: (side, valueM) => send({ berm_depth: { side, valueM } }), + // 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움 + focus: (on) => { + if (on) tellBermHost(section.chainage_m); + else if (bermHostAt === section.chainage_m) tellBermHost(null); + }, + }, + }; +} + export interface DesignLinePick { pieces: DesignPiece[]; /** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */ @@ -291,6 +391,10 @@ export function attachDesignLinePick(input: { onPick: (index: number) => void; /** 있으면 고른 조각 쪽 사면을 이동 패널로 옮김(139-6 · 151-3). */ shift?: ShoulderShift; + /** 있으면 고른 소단을 ▲▼ 로 옮김(194-1). */ + berm?: BermDepthControl; + /** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */ + stationId?: string; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; const x0 = x(0); @@ -313,6 +417,7 @@ export function attachDesignLinePick(input: { ) .map((el) => pointsOf(el).map(toM)); const pieces = visiblePieces(mergeStraightPieces(runs), occluders); + if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces)); // 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김. const firstStructureHit = kids.find((el) => @@ -337,9 +442,9 @@ export function attachDesignLinePick(input: { label.className = "b06-cross-card__line-dims"; label.hidden = true; let side: CrossSide | null = null; - let axis: "x" | "y" = "x"; + let axis: MoveAxis = "x"; let active: number | null = null; - const { shift } = input; + const { shift, berm } = input; // 사면 이동 패널(151-3) — B06 공용 조정창 꼴(머리 · 「이동」 줄 십자 · 방향키 ←→). // ✕ = 같은 조각 다시 고름(카드가 풂) · ↺ = 그 쪽 0. @@ -370,8 +475,32 @@ export function attachDesignLinePick(input: { step(1), ), ); - shell.root.append(readout, move.row); - const arrows = bindArrowKeys(shell.root, () => [move.controls]); + // 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤) + const lift = makeRow("높이"); + lift.controls.classList.add("b06-structure-panel__buttons"); + const toeZ = (at: CrossSide): number => + Math.min( + ...pieces + .filter((p) => !p.structure && pieceZone(design, p).side === at) + .flatMap((p) => [p.from.elevation, p.to.elevation]), + ); + const deepen = (dir: -1 | 1) => () => { + if (!side || !berm) return; + const now = bermDepthOf(design, side); + const next = Math.max(0, Math.round((now + dir * SHIFT_STEP_M) * 10) / 10); + const edgeZ = design.road_edges?.[side]?.elevation_m; + if (dir > 0 && edgeZ !== undefined && edgeZ - next < toeZ(side) + SHIFT_STEP_M) return; + if (next !== now) berm.set(side, next); + }; + lift.controls.append( + dpadButton("▲", "up", "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", deepen(-1)), + dpadButton("↺", "reset", "이 쪽 소단 = 구간 기본 깊이", () => { + if (side && berm) berm.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M); + }), + dpadButton("▼", "down", "고른 소단을 0.1 m 아래로", deepen(1)), + ); + shell.root.append(readout, move.row, lift.row); + const arrows = bindArrowKeys(shell.root, () => [move.controls, lift.controls]); // 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감. // 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김. const blockOffAxis = (event: KeyboardEvent): void => { @@ -402,14 +531,27 @@ export function attachDesignLinePick(input: { label.textContent = piece ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") : ""; - const open = !!(piece && shift); + const open = !!(piece && (axis === "depth" ? berm : shift)); shell.root.classList.toggle("is-hidden", !open); - move.row.classList.toggle("is-hidden", axis === "y"); - move.controls.classList.toggle("is-hidden", axis === "y"); + // 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단) + move.row.classList.toggle("is-hidden", axis !== "x"); + move.controls.classList.toggle("is-hidden", axis !== "x"); + lift.row.classList.toggle("is-hidden", axis !== "depth"); + lift.controls.classList.toggle("is-hidden", axis !== "depth"); + const sideName = side === "left" ? "(좌)" : "(우)"; shell.title.textContent = - axis === "y" ? "노면 높이" : side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)"; + axis === "y" + ? "노면 높이" + : axis === "depth" + ? `소단 높이 ${sideName}` + : `사면 이동 ${sideName}`; readout.textContent = - axis === "y" ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" : `이동 ${extend.toFixed(1)}m`; + axis === "y" + ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" + : axis === "depth" && side + ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` + : `이동 ${extend.toFixed(1)}m`; + berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2) arrows.detach(); document.removeEventListener("keydown", blockOffAxis); if (open) { diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index f148b57df..9571b8df4 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -29,7 +29,6 @@ import { appendCrossDesignOverlay, appendPavementOverlay, appendRockBoundaryOverlay, - type CrossDesignChange, type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome"; @@ -59,6 +58,7 @@ import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels"; import { attachDesignLinePick, + designLineMoves, DESIGN_LINE_ATTR, type DesignLinePick, pickedLineAt, @@ -593,17 +593,8 @@ export function createCrossSectionCard( x, toDisplayY, onPick: pickLine, - // 고른 선 쪽 노견 확장(139-6) — 카드 버튼과 같은 창구(세션 · 브라우저 재계산) - shift: onDesignChange - ? { - valueOf: (side) => lineDesign[`shoulder_extend_${side}_m`] ?? 0, - set: (side, valueM) => - onDesignChange(section.chainage_m, { - ...(lineDesign as unknown as CrossDesignChange), - shoulder_extend: { side, valueM }, - }), - } - : undefined, + // 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) — 카드 버튼과 같은 창구 + ...designLineMoves(section, onDesignChange), }); setLineActive = linePick.setActive; setLineActive(pickedLineAt(section.station_id)); diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 12be73986..18b92f1bd 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -9,7 +9,11 @@ * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] * ========================================================================== */ -import { writeCrossDesignChoice, writeShoulderExtend } from "./B06_Section_Cross_Design_Session"; +import { + writeBermDepth, + writeCrossDesignChoice, + writeShoulderExtend, +} from "./B06_Section_Cross_Design_Session"; import { showToast } from "@ui/ui_template_elements"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { @@ -74,6 +78,14 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { await reconcileStaleDesigns({ force: true }); return; } + // 소단 높이(194-1) — 그 쪽 깊이만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구) + const berm = change.berm_depth; + if (berm) { + writeBermDepth(projectId, currentRouteId, chainageM, berm.side, berm.valueM); + if (target.design) target.design[`berm_depth_${berm.side}_m`] = berm.valueM; + await reconcileStaleDesigns({ force: true }); + return; + } // (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체. if (target.design) { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 8959cf87b..0d99b5e12 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -29,7 +29,11 @@ import { STRUCTURE_ROW_KEYS, structureAreaRows, } from "./B06_Section_Structure_Layouts"; -import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; +import { + crossDesignChoices, + readBermDepths, + readShoulderExtends, +} from "./B06_Section_Cross_Design_Session"; import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; @@ -420,6 +424,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): { if (sides.left !== undefined) patch.shoulder_extend_left_m = sides.left; if (sides.right !== undefined) patch.shoulder_extend_right_m = sides.right; }); + // 소단 쪽마다 옮긴 깊이(194-1) — 서버가 [저장] 때 구조물 정본의 소단 구간 + 이 값으로 다시 냄 + readBermDepths(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => { + const patch = patchFor(chainage); + if (sides.left !== undefined) patch.berm_depth_left_m = sides.left; + if (sides.right !== undefined) patch.berm_depth_right_m = sides.right; + }); if (detail) { // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다 // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index 6ce5b40a9..fc8ef4475 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -9,6 +9,8 @@ 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"; +import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick"; +import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist"; /** * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 @@ -164,6 +166,28 @@ export function wireStructureSelection( pickBox(born); }); + // 횡단도 소단 수평 단 고름 → 좌측 폼에 그 소단 · 「상세 내용」 상자 켬(194-2 · 191 상자 고름 짝) + const BERM_BOX = "fieldset.b05-structure__optgroup"; + setBermPickHost((chainageM) => { + panel.root + .querySelectorAll(`${BERM_BOX}.is-active`) + .forEach((box) => box.classList.remove("is-active")); + if (chainageM === null) return; + const berm = panel.getStructures().find((item) => { + if (item.type_id !== BERM_TYPE_ID) return false; + const start = item.start_m ?? item.chainage_m ?? -1; + const end = item.end_m ?? item.chainage_m ?? -1; + return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6; + }); + if (!berm?.structure_id) return; + panel.selectStructure(berm.structure_id); + const box = [...panel.root.querySelectorAll(BERM_BOX)].find( + (el) => !el.closest("[hidden]"), + ); + box?.classList.add("is-active"); + box?.scrollIntoView({ block: "nearest" }); + }); + const origFord = stationControls.ford.select; stationControls.ford.select = (chainageM, role) => { const nested = wallSelecting; diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 29b3282e2..d69e9ad4d 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -50,6 +50,17 @@ class BermSpec(NamedTuple): interval_m: float = BERM_DEFAULT_INTERVAL_M slope_deg: float = BERM_DEFAULT_SLOPE_DEG depth_m: float = BERM_DEFAULT_DEPTH_M + # 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼ · 측점 patch) — None = `depth_m` + depth_left_m: float | None = None + depth_right_m: float | None = None + + +def side_berm(berm: BermSpec | None, side: str) -> BermSpec | None: + """그 쪽 소단 — 쪽마다 옮긴 깊이가 있으면 그 값을 `depth_m` 으로(194-1 · 0 미만 = 0).""" + if berm is None: + return None + own = berm.depth_left_m if side == "left" else berm.depth_right_m + return berm if own is None else berm._replace(depth_m=max(float(own), 0.0)) def cut_profile_points( diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index 8f1f69dc3..eb0ec8f14 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -39,6 +39,16 @@ export interface BermSpec { intervalM: number; slopeDeg: number; depthM?: number; + /** 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼ · 측점 patch) — 없으면 `depthM`. */ + depthLeftM?: number | null; + depthRightM?: number | null; +} + +/** 짝: `side_berm`. 그 쪽 소단 — 쪽마다 옮긴 깊이가 있으면 그 값을 `depthM` 으로(0 미만 = 0). */ +export function sideBerm(berm: BermSpec | null, side: string): BermSpec | null { + if (berm === null) return null; + const own = side === "left" ? berm.depthLeftM : berm.depthRightM; + return own === undefined || own === null ? berm : { ...berm, depthM: Math.max(own, 0) }; } export function bermSpec( diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index b568edabb..b9b99b1d8 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -143,6 +143,9 @@ export interface CrossDesignResult { /** 노견 확장(m · 139-6) — 넓힌 쪽만. */ shoulder_extend_left_m?: number; shoulder_extend_right_m?: number; + /** 소단 쪽마다 옮긴 깊이(m · 194-1) — 옮긴 쪽만. */ + berm_depth_left_m?: number; + berm_depth_right_m?: number; cross_slope_pct: number; ditch: Record; /** 결과 — **실제로 섰나**. */ @@ -588,6 +591,12 @@ export function computeCrossDesign( slope_deg: round4(options.berm.slopeDeg), depth_m: round4(Math.max(options.berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)), }; + // 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(짝: 파이썬) + const { depthLeftM, depthRightM } = options.berm; + if (depthLeftM !== undefined && depthLeftM !== null) + result.berm_depth_left_m = round4(Math.max(depthLeftM, 0)); + if (depthRightM !== undefined && depthRightM !== null) + result.berm_depth_right_m = round4(Math.max(depthRightM, 0)); } // 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬) if (geometry.shoulderExtendLeft > 0) diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index ec3d0f978..80630a777 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -11,6 +11,7 @@ import { cutProfilePoints, elevationAt, fillProfilePoints, + sideBerm, } from "./common_util_cross_berm"; /** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */ @@ -306,7 +307,7 @@ export class SectionGeometry { startDist, startZ, this.fillRatio, - this.berm, + sideBerm(this.berm, side), // 쪽마다 옮긴 깊이(194-1) undefined, edgeZ, ); diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index b52e8ccca..a87b28d50 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3573,6 +3573,7 @@ "weep_hole_area_m2": false }, "berm": { + "depth_m": false, "height_m": false, "length_m": false, "before_m": false, @@ -3807,8 +3808,7 @@ "id": "gbermdetail", "이름": "상세 내용", "옵션": [ - "width_m", - "depth_m" + "width_m" ], "형상": {} } diff --git a/resources/tester/helper_194_1_berm_pick.cjs b/resources/tester/helper_194_1_berm_pick.cjs new file mode 100644 index 000000000..96b25c34a --- /dev/null +++ b/resources/tester/helper_194_1_berm_pick.cjs @@ -0,0 +1,304 @@ +/* 194-1 · 194-2 소단 고름 헬퍼 — `_Cross_Design_Pick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림. + * 소단 = 높이만(▲▼ · ◀▶ 막힘 · 노면 끝 위 금지) · 조각 키로 다시 그려도 같은 조각 · 좌측 상자 알림. 짝 = `test_194_1_berm_depth.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +class Node { + constructor(tag) { + this.tagName = tag; + this.children = []; + this.attrs = {}; + this.listeners = {}; + this.hidden = false; + this.isConnected = true; + this._text = ""; + this._cls = new Set(); + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._cls.add(c)), + toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)), + contains: (c) => self._cls.has(c), + }; + } + get descendants() { + return this.children.flatMap((c) => [c, ...c.descendants]); + } + /** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */ + querySelector(sel) { + return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null; + } + getClientRects() { + return this._cls.has("is-hidden") ? [] : [{}]; + } + set className(v) { + this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + this.children.push(...kids); + } + insertBefore(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this.attrs[n] = String(v); + if (n === "class") this.className = v; + } + getAttribute(n) { + return this.attrs[n] ?? null; + } + hasAttribute(n) { + return n in this.attrs; + } + addEventListener(t, fn) { + (this.listeners[t] ??= []).push(fn); + } + click() { + (this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined })); + } +} +const docListeners = {}; +globalThis.document = { + createElement: (t) => new Node(t), + createElementNS: (_n, t) => new Node(t), + addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn), + removeEventListener: (t, fn) => { + docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn); + }, +}; +const key = (name) => + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => undefined }), + ); + +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 req = (r) => + r.startsWith("@util/") + ? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`)) + : loadTs(path.join(path.dirname(full), `${r}.ts`)); + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} +globalThis.Element = Node; + +const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts")); +const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽 +const y = (e) => 900 - (e - 100) * 12; +const line = (pts, structure = false) => { + const el = new Node("polyline"); + el.setAttribute("class", "b06-chart__design-cross"); + el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" ")); + if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, ""); + return el; +}; +/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */ +const press = (name) => { + let prevented = false; + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => (prevented = true) }), + ); + return prevented; +}; + +/** 양성토 단면(노면 끝 표고 100) — 좌 소단 깊이 `dl`(노면 끝 아래) · 우 1.0 · 사면 1:1.5 · 끝 표고 97. `order` = 그리는 차례. */ +function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], stationId } = {}) { + for (const k of Object.keys(docListeners)) if (k === "keydown") docListeners[k] = []; + const design = { + road_edges: { + left: { offset_m: 2, elevation_m: 100 }, + right: { offset_m: -2, elevation_m: 100 }, + }, + ditch: null, + ditch_side: null, + ditch_enabled: false, + section_mode: "both_fill", + berm: { width_m: 0.5, interval_m: 3, slope_deg: 0, depth_m: 1 }, + }; + if (stored !== undefined) design.berm_depth_left_m = stored; + const slope = (s, d) => { + const k = s === "left" ? 1 : -1; + const knee = 2 + 1.5 * d; + const toe = knee + 0.5 + 1.5 * (100 - d - 97); + return [ + [k * toe, 97], + [k * (knee + 0.5), 100 - d], + [k * knee, 100 - d], + [k * 2, 100], + ]; + }; + const lines = { + left: line(slope("left", dl)), + road: line([ + [2, 100], + [0, 100.04], // 노면 횡단경사 2 % — 깊이 0 소단이 노면과 한 직선으로 안 합쳐짐 + [-2, 100], + ]), + right: line(slope("right", 1)), + }; + const layer = new Node("g"); + layer.append(...order.map((name) => lines[name])); + const values = { left: 0, right: 0 }; + const sets = []; + const focus = []; + const handle = pick.attachDesignLinePick({ + layer, + design, + x, + toDisplayY: y, + onPick: () => undefined, + shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) }, + berm: { set: (s, v) => sets.push([s, v]), focus: (on) => focus.push(on) }, + stationId, + }); + const find = (test) => handle.pieces.findIndex(test); + const mid = (p) => (p.from.offset + p.to.offset) / 2; + const at = { + leftBerm: find((p) => pick.isBermPiece(design, p) && mid(p) > 0), + rightBerm: find((p) => pick.isBermPiece(design, p) && mid(p) < 0), + leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl), + road: find((p) => mid(p) > 0 && mid(p) < 2), + }; + const slot = (name) => + handle.panel.descendants.find( + (n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden, + ); + const rows = () => + handle.panel.descendants + .filter((n) => n._cls.has("b06-structure-panel__struct")) + .map((n) => n._cls.has("is-hidden")); + const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent; + return { design, handle, values, sets, focus, at, slot, rows, text }; +} + +const out = {}; + +// ① 갈래 · 축 — 소단 = depth · 사면 = x · 노면 = y · 키 +{ + const c = card(); + out.axis = Object.fromEntries( + Object.entries(c.at).map(([name, i]) => [ + name, + pick.pieceMoveAxis(c.design, c.handle.pieces[i]), + ]), + ); + out.keys = pick.pieceKeys(c.design, c.handle.pieces).sort(); +} + +// ② 소단 고름 — ▲▼ 만 · ◀▶ 막힘 · ↺ = 구간 기본 · 좌측 상자 켬 알림 +{ + const c = card(); + c.handle.setActive(c.at.leftBerm); + const [moveHidden, liftHidden] = c.rows(); + const leftBlocked = press("ArrowLeft"); + const rightBlocked = press("ArrowRight"); + const afterSide = { values: { ...c.values }, sets: c.sets.length }; + const upTaken = press("ArrowUp"); + press("ArrowDown"); + c.handle.panel.descendants + .filter((n) => n._cls.has("b06-structure-panel__btn--reset")) + .at(-1) + .click(); + out.berm = { + title: c.text("b06-structure-panel__title"), + readout: c.text("b06-structure-panel__value"), + moveHidden, + liftHidden, + leftBlocked, + rightBlocked, + afterSide, + upTaken, + sets: c.sets, + focus: c.focus, + }; + c.handle.setActive(c.at.leftUpper); // 사면으로 바꿈 — 소단 상자 끔 · ▲▼ 막힘 · 깊이 안 바뀜 + const before = c.sets.length; + out.berm.slopeUpBlocked = press("ArrowUp"); + out.berm.slopeSets = c.sets.length - before; + out.berm.slopeTitle = c.text("b06-structure-panel__title"); + out.berm.focusAfter = c.focus.at(-1); + // 노면 = ▲▼ 는 종단도로 넘김(안 막음) + c.handle.setActive(c.at.road); + out.berm.roadUpPassed = !press("ArrowUp"); + out.berm.roadSets = c.sets.length - before; +} + +// ③ 도로 위 금지 = 0 에서 멈춤 · 사면 끝 앞에서 멈춤 +{ + const top = card({ dl: 0, stored: 0 }); + top.handle.setActive(top.at.leftBerm); + press("ArrowUp"); + const deep = card({ dl: 2.9, stored: 2.9 }); + deep.handle.setActive(deep.at.leftBerm); + press("ArrowDown"); // 2.9 → 3.0 = 사면 끝(97) — 막힘 + press("ArrowUp"); + out.limit = { topSets: top.sets, topFound: top.at.leftBerm >= 0, deepSets: deep.sets }; +} + +// ④ 옮긴 뒤 다시 그려도 같은 조각(194-2) — 그리는 차례 · 깊이가 바뀌어도 +{ + const first = card({ stationId: "S1" }); + pick.setPickedLine("S1", first.at.leftBerm); + const again = card({ + dl: 1.1, + stored: 1.1, + order: ["right", "road", "left"], + stationId: "S1", + }); + const restored = pick.pickedLineAt("S1"); + pick.setPickedLine("S1", again.at.leftUpper); + const third = card({ dl: 1.2, stored: 1.2, stationId: "S1" }); + out.stable = { + firstIndex: first.at.leftBerm, + againIndex: again.at.leftBerm, + restored, + upperRestored: pick.pickedLineAt("S1") === third.at.leftUpper, + upperIndexMoved: again.at.leftUpper !== third.at.leftUpper, + }; + pick.setPickedLine("S1", null); +} + +// ⑤ 카드 창구 — 소단 깊이 변경 · 좌측 상자 알림(한 번만 · 이 측점이 켠 때만 끔) +{ + const changes = []; + const design = { ground_type: "soil", section_mode: "both_fill", shoulder_extend_left_m: 0.3 }; + const moves = pick.designLineMoves({ station_id: "S9", chainage_m: 40, design }, (ch, change) => + changes.push([ch, change]), + ); + moves.berm.set("left", 1.3); + const host = []; + pick.setBermPickHost((ch) => host.push(ch)); + moves.berm.focus(true); + moves.berm.focus(true); // 다시 그린 카드 — 안 부름 + pick + .designLineMoves({ station_id: "S8", chainage_m: 20, design }, () => undefined) + .berm.focus(false); + moves.berm.focus(false); + out.moves = { + stationId: moves.stationId, + shiftValue: moves.shift.valueOf("left"), + change: changes[0], + host, + none: pick.designLineMoves({ station_id: "S7", chainage_m: 0, design }), + }; + pick.setBermPickHost(null); +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_178_2_berm_fill_mirror.py b/resources/tester/test_178_2_berm_fill_mirror.py index 90cc7231d..1de2e3061 100644 --- a/resources/tester/test_178_2_berm_fill_mirror.py +++ b/resources/tester/test_178_2_berm_fill_mirror.py @@ -199,7 +199,9 @@ def test_저장분_깊이_읽기(): def test_좌측_패널_칸_구간_깊이_나름(): - """구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴).""" + """옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴). + + 좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`).""" source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_194_1_berm_depth.py b/resources/tester/test_194_1_berm_depth.py new file mode 100644 index 000000000..b524a947c --- /dev/null +++ b/resources/tester/test_194_1_berm_depth.py @@ -0,0 +1,339 @@ +"""194-1 · 194-2 소단 = 좌측 위치 칸 없앰 · 횡단도에서 고른 소단을 ▲▼ 로만 옮김. + +측점 · 쪽마다 깊이(`berm_depth_{side}_m`). + +- 좌측 칸 없음(정의 그룹 · 표시) · 기본 = 노면 끝 아래 1 m +- 짝 거울: `common_util_cross_berm` · 설계 기하(파이썬 ↔ TS) — 쪽마다 깊이(`berm_depth_{side}_m`) +- 서버 [저장] 다시 셈: 구조물 정본 소단 구간 + 측점 patch 깊이(옛 구간 depth_m = 기본값) +- 화면(흉내 DOM · `helper_194_1_berm_pick.cjs`): ▲▼ 만 · ◀▶ 막힘 · 도로 위 금지 · + 옮긴 뒤 같은 조각 · + 좌측 소단 상자 알림 +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Router_Design import ( + USER_TOUCHED_KEYS, + berm_from, + enforce_design_lines, + stored_berm, +) +from B06_Section.B06_Section_Schema import CrossSectionPatch +from common_util.common_util_cross_berm import BermSpec, side_berm +from config.config_system import STANDARD_CROSS_SECTION + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +HELPER = Path(__file__).parent / "helper_194_1_berm_pick.cjs" +DEF = ROOT / "resources" / "master_template" / "구조물정의.json" + +node_only = pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") + + +def _ground() -> list[dict]: + """양성토 — 지반이 노면 끝보다 3 m 이상 낮음.""" + return [ + {"offset_m": o / 2, "elevation_m": round(96.0 + 0.02 * o / 2, 4), "valid": True} + for o in range(-40, 41) + ] + + +def _berm(left=None, right=None, depth=1.0) -> BermSpec: + return BermSpec(0.5, 3.0, 0.0, depth, left, right) + + +CASES = [ + {"name": "양쪽 기본 1.0", "berm": _berm()}, + {"name": "좌 2.0 · 우 기본", "berm": _berm(left=2.0)}, + {"name": "좌 0(노면 끝) · 우 0.5", "berm": _berm(left=0.0, right=0.5)}, + {"name": "좌 음수 = 0", "berm": _berm(left=-1.0)}, +] + + +def _python(berm: BermSpec) -> dict: + return compute_cross_design( + _ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil", + section_mode="both_fill", berm=berm, + ) # fmt: skip + + +def _flat_z(design: dict, side: str) -> list[float]: + """그 쪽 노면 끝 밖 평탄 도막(소단) 표고 — 지반(≤ 96.4)에 닿기 전 사면만.""" + edge = design["road_edges"][side]["offset_m"] + sign = 1 if side == "left" else -1 + line = sorted( + ( + p + for p in design["design_line"] + if sign * p["offset_m"] >= sign * edge - 1e-6 and p["elevation_m"] > 96.5 + ), + key=lambda p: sign * p["offset_m"], + ) + flats = { + round(a["elevation_m"], 3) # 설계선 표본이 한 단을 여러 도막으로 끊어도 한 표고 + for a, b in zip(line, line[1:], strict=False) + if abs(b["offset_m"] - a["offset_m"]) > 1e-6 + and abs(b["elevation_m"] - a["elevation_m"]) / abs(b["offset_m"] - a["offset_m"]) < 0.05 + } + return sorted(flats) + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { computeCrossDesign } from "./common_util_cross_design.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const designs = input.cases.map((c) => computeCrossDesign(input.samples, 100, { + standard: input.standard, groundType: "soil", sectionMode: "both_fill", + berm: { widthM: c[0], intervalM: c[1], slopeDeg: c[2], depthM: c[3], + depthLeftM: c[4], depthRightM: c[5] }, +})); +writeFileSync(process.argv[3], JSON.stringify(designs)); +""" + + +@pytest.fixture(scope="module") +def ts_designs(tmp_path_factory) -> list[dict]: + tmp = tmp_path_factory.mktemp("berm194") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(ROOT / "common_util" / "common_util_cross_design.ts"), + "--outDir", str(out), "--module", "esnext", "--target", "es2022", + "--moduleResolution", "bundler", "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + for emitted in out.glob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + src, dst = tmp / "in.json", tmp / "out.json" + src.write_text( + json.dumps( + { + "cases": [list(case["berm"]) for case in CASES], + "samples": _ground(), + "standard": STANDARD_CROSS_SECTION, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True + ) + return json.loads(dst.read_text(encoding="utf-8")) + + +def _close(left, right, path=""): + if isinstance(left, dict): + assert set(left) == set(right), f"{path} 키 다름 {set(left) ^ set(right)}" + for key in left: + _close(left[key], right[key], f"{path}.{key}") + elif isinstance(left, list): + assert len(left) == len(right), f"{path} 길이" + for i, (a, b) in enumerate(zip(left, right, strict=True)): + _close(a, b, f"{path}[{i}]") + elif isinstance(left, (int, float)) and not isinstance(left, bool): + assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}" + else: + assert left == right, f"{path}: {left!r} vs {right!r}" + + +# ── 좌측 칸 없음 · 기본 1 m ──────────────────────────────────────────────── +def test_좌측_패널_깊이_칸_없음() -> None: + data = json.loads(DEF.read_text(encoding="utf-8")) + assert data["그룹"]["berm"][0]["옵션"] == ["width_m"] + assert data["표시"]["berm"]["depth_m"] is False + berm = next(t for t in data["types"] if t["type_id"] == "berm") + depth = next(o for o in berm["options"] if o["key"] == "depth_m") + assert depth["default"] == 1.0 # 옛 구간 값 · 새 구간 기본 = 노면 끝 아래 1 m + + +def test_기본_1m_수평_한_단() -> None: + design = _python(_berm()) + for side in ("left", "right"): + edge_z = design["road_edges"][side]["elevation_m"] + assert _flat_z(design, side) == [pytest.approx(edge_z - 1.0, abs=1e-3)] + assert "berm_depth_left_m" not in design and "berm_depth_right_m" not in design + + +# ── 짝 거울 · 쪽마다 깊이 ─────────────────────────────────────────────────── +def test_쪽마다_깊이_그_쪽만() -> None: + by = {case["name"]: _python(case["berm"]) for case in CASES} + two = by["좌 2.0 · 우 기본"] + assert _flat_z(two, "left") == [pytest.approx(two["road_edges"]["left"]["elevation_m"] - 2.0)] + assert _flat_z(two, "right") == [pytest.approx(two["road_edges"]["right"]["elevation_m"] - 1.0)] + assert two["berm_depth_left_m"] == 2.0 and "berm_depth_right_m" not in two + assert two["berm"]["depth_m"] == 1.0 # 구간 값은 그대로 + top = by["좌 0(노면 끝) · 우 0.5"] + assert _flat_z(top, "left") == [pytest.approx(top["road_edges"]["left"]["elevation_m"])] + neg = by["좌 음수 = 0"] + assert neg["berm_depth_left_m"] == 0.0 # 도로보다 높을 수 없음 + assert neg["design_line"] == _python(_berm(left=0.0))["design_line"] + assert side_berm(_berm(right=1.5), "right").depth_m == 1.5 + assert side_berm(_berm(right=1.5), "left").depth_m == 1.0 + + +@node_only +def test_파이썬_TS_거울(ts_designs: list[dict]) -> None: + for case, ts in zip(CASES, ts_designs, strict=True): + _close(_python(case["berm"]), ts, case["name"]) + + +def test_짝_머리_표시() -> None: + for name in ("common_util_cross_berm.py", "common_util_cross_berm.ts"): + text = (ROOT / "common_util" / name).read_text(encoding="utf-8") + assert "짝" in text and ("side_berm" in text or "sideBerm" in text) + + +# ── 측점 patch 저장 왕복 · 서버 다시 셈 ───────────────────────────────────── +def test_patch_칸_왕복() -> None: + patch = CrossSectionPatch(chainage_m=40, berm_depth_left_m=1.5, berm_depth_right_m=0) + dumped = {k: v for k, v in patch.model_dump().items() if v is not None} + assert dumped == {"chainage_m": 40, "berm_depth_left_m": 1.5, "berm_depth_right_m": 0} + with pytest.raises(ValidationError): + CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지 + assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS) + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert "patch.berm_depth_left_m = sides.left" in persist + + +def test_저장분_읽기_옛_구간값_기본() -> None: + old = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}}) + assert (old.depth_m, old.depth_left_m, old.depth_right_m) == (2.5, None, None) + moved = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}, "berm_depth_left_m": 1.2}) + assert (moved.depth_left_m, moved.depth_right_m) == (1.2, None) + # 프리뷰 payload(구간 제원 + 쪽 깊이) + spec = berm_from({"width_m": 0.5, "depth_m": 1.0, "berm_depth_right_m": -3}, { + "berm_depth_right_m": -3 + }) # fmt: skip + assert spec.depth_right_m == 0.0 + + +LONG = { + "design_profiles": [ + { + "samples": [ + {"chainage_m": 0, "elevation_m": 100}, + {"chainage_m": 200, "elevation_m": 100}, + ] + } + ] +} + + +def _project(tmp_path: Path, structures: list[dict]) -> Path: + route = tmp_path / "B05_Profile" / "route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), + encoding="utf-8", + ) + return tmp_path + + +BERM_SPAN = { + "type_id": "berm", + "placement": "interval", + "start_m": 0, + "end_m": 100, + "options": {"width_m": 0.5, "depth_m": 2.5}, # 옛 좌측 칸 값 = 기본값 +} + + +def test_저장_때_서버가_정본_구간_측점_깊이로_다시_셈(tmp_path: Path) -> None: + root = _project(tmp_path, [BERM_SPAN]) + browser = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="both_fill") + # 브라우저 patch = 사용자 값(좌 깊이)만 · 설계선은 소단 없는 옛 것 + stored = {**browser, "berm_depth_left_m": 1.5} + sections = [{"chainage_m": 40.0, "samples": _ground(), "design": stored}] + assert enforce_design_lines(LONG, sections, root) == 1 + design = sections[0]["design"] + assert design["berm"]["depth_m"] == 2.5 and design["berm_depth_left_m"] == 1.5 + left_z = design["road_edges"]["left"]["elevation_m"] + right_z = design["road_edges"]["right"]["elevation_m"] + assert _flat_z(design, "left") == [pytest.approx(left_z - 1.5, abs=1e-3)] + assert _flat_z(design, "right") == [pytest.approx(right_z - 2.5, abs=1e-3)] + # 구간 밖 측점 = 소단 없음 · 옮긴 값만 남음(다시 놓으면 그대로 씀) + outside = [{"chainage_m": 150.0, "samples": _ground(), "design": dict(stored)}] + enforce_design_lines(LONG, outside, root) + assert "berm" not in outside[0]["design"] and _flat_z(outside[0]["design"], "left") == [] + + +def test_구간_지우면_저장분_소단도_사라짐(tmp_path: Path) -> None: + root = _project(tmp_path, []) + old = _python(_berm(left=1.5)) + sections = [{"chainage_m": 40.0, "samples": _ground(), "design": old}] + enforce_design_lines(LONG, sections, root) + assert "berm" not in sections[0]["design"] + assert _flat_z(sections[0]["design"], "left") == [] + + +# ── 화면 고름(흉내 DOM) ───────────────────────────────────────────────────── +@pytest.fixture(scope="module") +def ui() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +@node_only +def test_이동_셋_분리(ui: dict) -> None: + assert ui["axis"] == {"leftBerm": "depth", "rightBerm": "depth", "leftUpper": "x", "road": "y"} + assert "berm:left:0" in ui["keys"] and "fill:left:1" in ui["keys"] + + +@node_only +def test_소단_고름_위아래만(ui: dict) -> None: + berm = ui["berm"] + assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m" + assert berm["moveHidden"] is True and berm["liftHidden"] is False + assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘 + assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감 + assert berm["upTaken"] + assert berm["sets"] == [["left", 0.9], ["left", 1.1], ["left", 1]] # ▲ · ▼ · ↺ 기본 + assert berm["slopeTitle"] == "사면 이동 (좌)" and berm["slopeUpBlocked"] + assert berm["slopeSets"] == 0 and berm["roadSets"] == 0 + assert berm["roadUpPassed"] # 노면 ▲▼ = 종단도 몫 + + +@node_only +def test_도로_위_금지_사면_끝_앞_멈춤(ui: dict) -> None: + assert ui["limit"]["topFound"] and ui["limit"]["topSets"] == [] # 깊이 0 에서 ▲ = 안 감 + assert ui["limit"]["deepSets"] == [["left", 2.8]] # ▼ 막힘 · ▲ 는 감 + + +@node_only +def test_옮긴_뒤_같은_조각(ui: dict) -> None: + stable = ui["stable"] + assert stable["firstIndex"] != stable["againIndex"] # 번호는 바뀜 + assert stable["restored"] == stable["againIndex"] # 키로 같은 소단 + assert stable["upperRestored"] and stable["upperIndexMoved"] + + +@node_only +def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None: + assert ui["berm"]["focus"][0] is True and ui["berm"]["focusAfter"] is False + moves = ui["moves"] + assert moves["stationId"] == "S9" and moves["shiftValue"] == 0.3 + chainage, change = moves["change"] + assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3} + assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음 + assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시 + assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음 + selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text( + encoding="utf-8" + ) + assert "setBermPickHost(" in selection and 'classList.add("is-active")' in selection diff --git a/resources/tester/test_54_26_slope_table09.py b/resources/tester/test_54_26_slope_table09.py index d39729a73..c848eb74d 100644 --- a/resources/tester/test_54_26_slope_table09.py +++ b/resources/tester/test_54_26_slope_table09.py @@ -28,7 +28,11 @@ TABLE = { "옹벽", ["wall_form", "length_m", "height_m", "before_m", "after_m"], ), - "berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2) + "berm": ( + "gbermdetail", + "상세 내용", + ["width_m"], + ), # 높이 · 깊이 칸 숨김(깊이 = 횡단도 ▲▼ · 194-1) } SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"] From ee7c7228b995378f2a321e0bd8ffd77ff0dcc60e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:01:37 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(M02):=20=EC=98=B5=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=ED=99=94=EB=A9=B4=20=EC=9C=84=EC=97=90=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=ED=91=9C=EA=B0=80=20=EB=8D=AE=EC=9D=B4=EB=8D=98=20?= =?UTF-8?q?=EB=82=A1=EC=9D=80=20mount=20=EB=A7=89=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=98=B5=EC=85=98=20=EC=B9=B4=EB=93=9C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20(197-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../M02_MasterTemplete_UI_Main.ts | 5 + .../test_197_1_dashboard_option_card.py | 139 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 resources/tester/test_197_1_dashboard_option_card.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index b39d8309b..c27d84018 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -352,18 +352,21 @@ export function createMain( async function mount(doc: unknown, at: Selection): Promise { const readOnly = !writable(at); + const mine = seq; // 모듈 · 집계표 번호를 기다리는 사이 다른 화면(옵션 정의 등)이 열렸으면 그만둠 host.replaceChildren(); if (at.kind === "table") { const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( SHEET_MODS, "createSheet", ); + if (mine !== seq) return; if (!create) return empty(L("M02_NoSheet")); // 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 줄에 번호 · 칸을 고르면 아래 컨테이너 · // 고칠 수 있으면 일위대가 · 도면 · 면적산출 칸은 조회 후 선택 const struct = at.layer !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null; if (struct) await struct.reload(); + if (mine !== seq) return struct?.destroy(); panel = struct; // ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이 // 헐린 뒤에도 안 떼인 이벤트가 이 줄을 부르면 sheet?.getDoc() 가 undefined 라 @@ -417,6 +420,7 @@ export function createMain( // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 · // 단가승격 · 사전조합(50-3) = 같은 상세 모드(도면 · 면적 · 자재 없음 · 이름 = 키) const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); + if (mine !== seq) return; const mode = isPrecombo(at.kind) ? at.kind : undefined; const labor = mountLaborDetail(host, doc, { layer: at.layer, @@ -447,6 +451,7 @@ export function createMain( DRAWING_MODS, at.kind === "structure" ? "mountStructureSheets" : "mountDrawingTemplate", ); + if (mine !== seq) return; if (mountDrawing) { editor = mountDrawing(host, doc, { onSave: () => void doSave(), diff --git a/resources/tester/test_197_1_dashboard_option_card.py b/resources/tester/test_197_1_dashboard_option_card.py new file mode 100644 index 000000000..44e89195e --- /dev/null +++ b/resources/tester/test_197_1_dashboard_option_card.py @@ -0,0 +1,139 @@ +"""197-1 대시보드 「구조물 옵션」 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 사용자. + +원인 — 진입 해시 `open=옵션` 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면(집계표)의 `mount` 가 +모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 집계표를 덮음(`mount` 에 낡음 검사 없었음). + +· 카드 셋 → 해시(open=구조물조합 · 옵션 · 도면) · 해시 → 진입(`entryFromHash`) Node 로 바로 돌림 +· `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함 +· `mount` 집계표 갈래(진짜 소스 조각)를 Node 로 돌림 — 기다리는 사이 다른 화면이 열리면 그리지 않음 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +sys.path.insert(0, str(Path(__file__).parent)) + +NODE = ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"] +needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _run(script: str, *args: str) -> object: + result = subprocess.run( + [*NODE, "-e", script, *args], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@needs_node +def test_카드_셋_해시(): + from test_b01_settings_cards import _cards + + cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) + assert [c[2].split("?")[1].split("&")[0] for c in cards[1:]] == [ + "open=구조물조합", + "open=옵션", + "open=도면", + ] + assert all("&root=master" in c[2] for c in cards[1:]) + + +@needs_node +def test_해시_진입_셋(): + layer = _text(M02 / "M02_MasterTemplete_Layer.ts") + fn = re.search(r"^export function entryFromHash\([\s\S]*?^}", layer, re.M) + assert fn, "entryFromHash 를 못 찾음" + opens = re.search(r"^const OPENS[^\n]*", layer, re.M) + roots = re.search(r"^const ROOTS[^\n]*", layer, re.M) + assert opens and roots + script = ( + "const currentQuery = () => new URLSearchParams(process.argv[1]);\n" + "const defaultRoot = () => 'master';\n" + "type Root = string; type OpenTarget = string; type Entry = any; type SessionUser = any;\n" + f"{opens.group(0)}\n{roots.group(0)}\n" + + fn.group(0).replace("export function", "function") + + "\nconsole.log(JSON.stringify(entryFromHash(null)));" + ) + for want in ("구조물조합", "옵션", "도면"): + got = _run(script, f"open={want}&root=master&company=3") + assert got["open"] == want and got["root"] == "master" and got["company"] == "3" + + +def test_reveal_옵션만_줄을_누름(): + side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") + body = side.split("const reveal = ")[1].split("\n };")[0] + assert "if (!sec) return;" in body # 시스템 층 아니면 옵션 · 도면 줄 없음 + assert 'sec.classList.remove("is-collapsed");' in body # 도면 = 펼치기만 + assert ( + 'if (target === "옵션") options.body.querySelector("button")?.click();' in body + ) + # 옵션 컨테이너 안 단추 = 「구조물 옵션 정의」 줄 하나뿐 + options = _text(M02 / "StructOptions" / "M02_SO_Options.ts") + assert options.count('el("button"') == 1 + assert "openDefScreen" in options + # 옵션 컨테이너 = 구조물 옵션 정의 줄 · 시스템 층에만 + assert "옵션: section(options.title, options.body)," in side + page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") + assert 'if (entry.open !== "구조물조합") side.reveal(entry.open);' in page + + +@needs_node +def test_집계표_갈래_기다리는_사이_다른_화면이_열리면_그리지_않음(): + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + start = main.index(" async function mount(doc: unknown, at: Selection)") + stop = main.index(' if (at.kind === "labor" || isPrecombo(at.kind)) {', start) + table = main[start:stop] + "\n}\n" + assert table.count("if (mine !== seq)") == 2 # 모듈 받은 뒤 · 번호 받은 뒤 + script = ( + "type Selection = any; type EditorHandle = any; type SheetEditorHandle = any;\n" + "type StructureColumn = any;\n" + "let seq = 0; let panel: any = null; let editor: any = null; let sheet: any = null;\n" + "let dirty = false; const log: string[] = [];\n" + "const STRUCTURE_TABLE = 'T'; const SHEET_MODS = {}; const L = (k: string) => k;\n" + "const loaded: any = null; const extras: any = {};\n" + "const host = { replaceChildren() {} }; const writable = () => true;\n" + "const empty = (m: string) => log.push('empty:' + m);\n" + "const comboView = (d: any) => d; const comboMerge = (_: any, d: any) => d;\n" + "const laborSumText = () => ''; const laborSumState = () => '';\n" + "const gate = (): [Promise, () => void] => {\n" + " let go = () => {}; return [new Promise((r) => (go = r)), () => go()];\n" + "};\n" + "let modGate = gate(); let numGate = gate();\n" + "const findExport = async () => {\n" + " await modGate[0]; return (_h: any, _d: any, _o: any) => (log.push('create')," + " { destroy() {}, getDoc: () => ({}) });\n" + "};\n" + "const buildPanel = () => ({ root: {}, numbers: () => ({}), async reload() {\n" + " await numGate[0]; }, dirty: () => false, destroy: () => log.push('panel.destroy') });\n" + + table + + "\nasync function run(bumpAt: string): Promise {\n" + " seq = 1; log.length = 0; panel = null; modGate = gate(); numGate = gate();\n" + " const going = mount({}, { layer: 'system', kind: 'table', name: STRUCTURE_TABLE," + " projectId: null });\n" + " if (bumpAt === 'mod') seq++;\n" + " modGate[1](); await new Promise((r) => setTimeout(r, 5));\n" + " if (bumpAt === 'num') seq++;\n" + " numGate[1](); await going; return [...log, 'panel:' + (panel ? 'set' : 'null')];\n" + "}\n" + "console.log(JSON.stringify({ none: await run('none'), mod: await run('mod')," + " num: await run('num') }));" + ) + got = _run(script) + assert got["none"] == ["create", "panel:set"] # 안 끼어들면 그대로 그림 + assert got["mod"] == ["panel:null"] # 모듈 기다리는 사이 다른 화면 — 그리지 않음 + assert got["num"] == ["panel.destroy", "panel:null"] # 번호 기다리는 사이 — 패널도 버림 From 4ee90060e166782fcf011c548ee0b6f19c5d1ab0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:03:32 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EF=BB=BFfeat(B08):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A7=91=EA=B3=84=ED=91=9C=20=EB=AF=B8=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=3D=20=EC=B8=A1=EC=A0=90=20=EC=B9=B8=20=EC=99=BC?= =?UTF-8?q?=EC=AA=BD=20=EB=85=B8=EB=9E=91=20=EB=9E=A8=ED=94=84=20=C2=B7=20?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=A0=95=20=EC=97=B4=20=EC=A7=80=EC=9B=80=20?= =?UTF-8?q?(198-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「미지정」 열 · 조합 칸 「미지정」 표기 · 측점 「· 미연결」 글 지움 - 미연결 줄 측점 칸 = 경고 노랑 램프 · 호버 = 구조물 조합 미연결 — 빈 조합 칸 - 서버 안내 글 「노랑 램프 줄을 눌러 조합 연결」 - 새 시험 test_198_1 · test_61_1 · 143_1 · 160_2 단정 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Router_Summary.py | 2 +- B08_Quantity/B08_Quantity_Summary_Data.ts | 17 +-- B08_Quantity/B08_Quantity_UI_Page.css | 12 ++ B08_Quantity/B08_Quantity_UI_Summary.ts | 50 ++++---- .../tester/test_143_1_b08_linked_snapshots.py | 4 +- resources/tester/test_160_2_b08_unassigned.py | 20 +-- .../tester/test_198_1_b08_summary_lamp.py | 114 ++++++++++++++++++ .../tester/test_61_1_b08_summary_table.py | 2 +- ui_template/ui_template_locale_b4.ts | 2 +- 9 files changed, 163 insertions(+), 60 deletions(-) create mode 100644 resources/tester/test_198_1_b08_summary_lamp.py diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 85f66877f..3ac853296 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -595,7 +595,7 @@ def pipe_summary( notes = [] missing = sheet.unlinked | {key for key, found in others.items() if not found} if missing: - notes.append(f"조합 사본 없는 시설 {len(missing)}곳 — 「미연결」 줄을 눌러 조합 연결") + notes.append(f"조합 사본 없는 시설 {len(missing)}곳 — 노랑 램프 줄을 눌러 조합 연결") if no_length: notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐") if no_basin: diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index 3784dd949..116aecc9b 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -5,8 +5,8 @@ * 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순) * 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열 * 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1) - * 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 「미연결」 · PLAN 154-1) - * 「미지정」 열 글 · 같은 조합 열 칸 = 빈 조합 칸(`unassignedText` · `unassignedCombos` · 160-2) + * 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 노랑 램프 · PLAN 154-1 · 198-1) + * 램프 호버 글 = 빈 조합 칸(`unassignedText` · 160-2) * * 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다. * 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림. @@ -114,7 +114,7 @@ export interface ComboColumn { export const comboId = (item: SummaryItem): string => `${item.layer}|${item.owner ?? ""}|${item.key}`; -/** 측점 「미지정」 칸 글 — 시설마다 「이름: 빈 칸 · 빈 칸」(칸 이름 = 그룹 · 본체 = `body` · 160-2). */ +/** 측점 램프 호버 글 —시설마다 「이름: 빈 칸 · 빈 칸」(칸 이름 = 그룹 · 본체 = `body` · 160-2). */ export const unassignedText = (facilities: readonly UnlinkedFacility[], body: string): string => facilities .map((f) => { @@ -123,17 +123,6 @@ export const unassignedText = (facilities: readonly UnlinkedFacility[], body: st }) .join(" / "); -/** 빈 칸에 고른 조합 id — 같은 조합 열이 있으면 그 칸에 「미지정」(160-2). */ -export function unassignedCombos(facilities: readonly UnlinkedFacility[]): Set { - const ids = new Set(); - for (const f of facilities) - for (const slot of f.slots) { - const item = slot.linked ? null : readSummaryItem(slot.item); - if (item) ids.add(comboId(item)); - } - return ids; -} - function stationOf(thing: PlacedItem): number | null { const value = thing.chainage_m ?? thing.start_m; return typeof value === "number" && Number.isFinite(value) ? value : null; diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index b419ee0de..f1c338629 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -44,6 +44,18 @@ overflow-wrap: anywhere; } +/* 구조물 집계표 미연결 램프(198-1) — 측점 칸 왼쪽 경고 노랑 점 하나(177-1 미연결 노랑 토큰) · 연결 줄은 없음 */ +[data-b08-summary="table"] td.b08-summary__lamp::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 50%; + background: var(--color-warning); + vertical-align: middle; +} + /* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */ .b08-options__row { display: grid; diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 5a25baa8f..21cc8febf 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -6,8 +6,8 @@ * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) * 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`) * 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 미연결 시설은 그 측점 - * 줄만 「· 미연결」(값 빈칸 · 서버 `unlinked` · PLAN 154-1) · 측점 옆 「미지정」 열 = 그 측점 빈 조합 칸 - * · 같은 조합 열이 있으면 그 칸에도 「미지정」(160-2 · 화면만) + * 줄만 측점 칸 왼쪽 노랑 램프(값 빈칸 · 서버 `unlinked` · PLAN 154-1 · 198-1) + * · 램프 호버(공용 `ui_template_hover_info`) = 그 측점 빈 조합 칸(160-2 · 화면만) * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 @@ -24,6 +24,7 @@ import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import { stationLabel } from "../common_util/common_util_station"; import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; @@ -39,7 +40,6 @@ import { type LinkedSnapshot, type PlacedItem, type UnlinkedFacility, - unassignedCombos, unassignedText, unlinkedAt, widthOf, @@ -98,6 +98,9 @@ async function fetchPipeSummary(projectId: string): Promise { } const PIPE_TOP = "관 공"; +/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ +const LAMP_CLASS = "b08-summary__lamp"; +const LAMP_W = 14; const stationKey = (station: number): number => Math.round(station * 100); /** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ const placesOf = (values: number[]): number => @@ -150,29 +153,14 @@ function buildTable( ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } : null; const labels = stations.map((station) => stationLabel(station, interval)); - // 미연결 시설 측점 = 「· 미연결」(값 빈칸 · 누르면 바로 연결 · 154) - const shown = stations.map((station, i) => - unlinkedAt(unlinked, station).length - ? `${labels[i]} · ${L("B08_Quantity_Summary_Unlinked")}` - : labels[i], - ); const stationHead = L("B08_Quantity_Summary_Station"); - // 160-2 — 빈 조합 칸 이름 · 같은 조합 열 칸에도 「미지정」(미연결 시설이 있을 때만 열) - const missingHead = L("B08_Quantity_Summary_Unassigned"); - const here = stations.map((station) => unlinkedAt(unlinked, station)); - const missing = here.map((at) => unassignedText(at, L("B08_Quantity_Link_Body"))); - const hasMissing = missing.some(Boolean); - const comboIds = new Set(columns.map((column) => column.id)); - const markOf = (at: readonly UnlinkedFacility[]): Record => - Object.fromEntries( - [...unassignedCombos(at)].filter((id) => comboIds.has(id)).map((id) => [id, missingHead]), - ); + // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) + const missing = stations.map((station) => + unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), + ); const table = createDataTable({ columns: [ - { key: "station", label: stationHead, width: widthOf(stationHead, ...shown) }, - ...(hasMissing - ? [{ key: "unassigned", label: missingHead, width: widthOf(missingHead, ...missing) }] - : []), + { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, ...columns.map((column) => ({ key: column.id, label: column.name, @@ -185,18 +173,26 @@ function buildTable( ], // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 rows: stations.map((station, i) => ({ - station: shown[i], - ...(hasMissing ? { unassigned: missing[i] } : {}), - ...markOf(here[i]), + station: labels[i], ...values.get(stationKey(station)), })), total, onRowClick: (i) => onRowClick(stations[i], labels[i]), - frozen: hasMissing ? 2 : 1, + cellClass: (i, key) => (key === "station" && missing[i] ? LAMP_CLASS : null), + frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), }); table.root.dataset.b08Summary = "table"; + // 줄을 다시 그려도(보이는 줄만) 떠 있는 root 하나에 걸어 둠 — 줄 번호 = 줄 `data-i` + attachHoverInfo( + table.root, + (cell) => { + const why = missing[Number(cell.closest("tr")?.dataset.i)]; + return why && `${L("B08_Quantity_Summary_LampTip")} — ${why}`; + }, + `td.${LAMP_CLASS}`, + ); return table.root; } diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py index 2f48bbcbf..6eb698a8f 100644 --- a/resources/tester/test_143_1_b08_linked_snapshots.py +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -154,7 +154,7 @@ def test_사본_없는_시설_알림_응답에_사본(root): out = _run(root) # 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳 assert not any("wing" in c["id"] for c in out["columns"]) - assert "조합 사본 없는 시설 7곳 — 「미연결」 줄을 눌러 조합 연결" in out["notes"] + assert "조합 사본 없는 시설 7곳 — 노랑 램프 줄을 눌러 조합 연결" in out["notes"] assert not any("관 연장 없음" in n for n in out["notes"]) assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"} @@ -187,7 +187,7 @@ def test_화면_조합_이름_구조물도는_사본에서(): ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") assert "loadPickerRows" not in ui # 층 목록 GET 안 씀 assert "linkedOnly(" in ui - assert "unlinkedAt(" in ui and "B08_Quantity_Summary_Unlinked" in ui # 154-1 미연결 줄 + assert "unlinkedAt(" in ui and "B08_Quantity_Summary_LampTip" in ui # 154-1 · 198-1 미연결 램프 drawing = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") assert "readTemplate" not in drawing # 층 폴백 없음 assert "B08_Quantity_Sheets_NoCopy" in drawing diff --git a/resources/tester/test_160_2_b08_unassigned.py b/resources/tester/test_160_2_b08_unassigned.py index 7b9a37433..e2e351912 100644 --- a/resources/tester/test_160_2_b08_unassigned.py +++ b/resources/tester/test_160_2_b08_unassigned.py @@ -1,4 +1,4 @@ -"""160-2 B08 구조물 집계표 「미지정」 — 측점 옆 열 글 · 같은 조합 열 칸 표기(화면만). +"""160-2 B08 구조물 집계표 빈 조합 칸 글(198-1 부터 미연결 램프 호버 글 · 「미지정」 열 · 칸 표기는 지움). `B08_Quantity_Summary_Data.ts` 를 `common_util_summary_item.ts` 와 이어 붙여 node 로 돌림(61-1 시험과 같은 꼴). """ @@ -47,31 +47,23 @@ FACILITIES = [ ] -def test_미지정_글과_조합_칸(): +def test_빈_조합_칸_글(): out = _run( f"\nconst f = {json.dumps(FACILITIES, ensure_ascii=False)};" "\nconst at = (s) => unlinkedAt(f, s);" "\nconsole.log(JSON.stringify({" " t100: unassignedText(at(100), '본체'), t160: unassignedText(at(160), '본체')," - " t0: unassignedText(at(0), '본체')," - " c100: [...unassignedCombos(at(100))], c160: [...unassignedCombos(at(160))] }));" + " t0: unassignedText(at(0), '본체') }));" ) assert out["t100"] == "배관: 본체 · 관 / 세월교: 유출 구조" assert out["t160"] == "BOX" and out["t0"] == "" - assert out["c100"] == ["system||SS2"] # 연결된 칸 · 고른 조합 없는 칸은 뺌 - assert out["c160"] == [] def test_화면_연결(): summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") assert ( - 'unassignedText(at, L("B08_Quantity_Link_Body"))' in summary + 'unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body"))' in summary ) # 칸 이름 = 모달과 같은 규칙 - assert ( - 'key: "unassigned", label: missingHead' in summary - and "frozen: hasMissing ? 2 : 1" in summary - ) - assert "...markOf(here[i])," in summary and "filter((id) => comboIds.has(id))" in summary - assert '${L("B08_Quantity_Summary_Unlinked")}' in summary # 「· 미연결」 측점 표기 그대로 + assert 'key: "unassigned"' not in summary and "unassignedCombos" not in summary # 198-1 locale = (ROOT / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8") - assert 'B08_Quantity_Summary_Unassigned: ["미지정", "Unassigned"]' in locale + assert "B08_Quantity_Summary_Unassigned" not in locale diff --git a/resources/tester/test_198_1_b08_summary_lamp.py b/resources/tester/test_198_1_b08_summary_lamp.py new file mode 100644 index 000000000..184e9396d --- /dev/null +++ b/resources/tester/test_198_1_b08_summary_lamp.py @@ -0,0 +1,114 @@ +"""198-1 B08 구조물 집계표 — 미연결 = 측점 칸 왼쪽 램프 하나 · 「미지정」 열 지움. + +- 칸 class: 화면 소스의 `cellClass` 를 떼어 공용 표 `renderDataTableHtml` 로 그림(Node) — 미연결 줄 측점 칸만 램프 +- 호버 글: 화면 소스의 호버 함수를 떼어 가짜 칸으로 부름 — 「구조물 조합 미연결 — 빈 칸 이름」 · 연결 줄은 없음 +- CSS: 램프 = 경고 노랑 토큰(177-1) · 밝음 · 어두움 둘 다 +- 줄 값 = 측점 + 서버 값뿐(「미지정」 · 「· 미연결」 글 없음 → 수량 · 합계 그대로) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "config" / "node_modules" / "typescript" +UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + +PRELUDE = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(TS))}); +const js = (code) => ts.transpileModule(code, {{ + compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, +}}).outputText; +""" + + +def _node(script: str): + out = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def _lamp_class() -> str: + m = re.search(r'const LAMP_CLASS = "([^"]+)";', UI) + assert m + return m.group(1) + + +def test_미연결_줄_측점_칸만_램프(): + cell = re.search(r"cellClass: (\(i, key\) => .*?),\n", UI) + assert cell + table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) + got = _node( + PRELUDE + + f""" +const box = {{ exports: {{}} }}; +new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8")))( + box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); +const LAMP_CLASS = {json.dumps(_lamp_class())}; +const missing = ["", "BOX", ""]; +const cellClass = {cell.group(1)}; +const html = box.exports.renderDataTableHtml({{ + columns: [{{ key: "station", label: "측점" }}, {{ key: "c1", label: "조합" }}], + rows: [{{ station: "0+000" }}, {{ station: "0+020" }}, {{ station: "0+040", c1: 1 }}], + frozen: 1, empty: "", cellClass }}); +const rows = html.split("")[1].split(""); +console.log(JSON.stringify({{ + count: html.split(LAMP_CLASS).length - 1, + row1: rows[1], + row0: rows[0], +}})); +""" + ) + assert got["count"] == 1 + assert f' \{.*?\n\s*\}),\n", UI, re.S + ) + assert hover + got = _node( + PRELUDE + + f""" +const L = (k) => ({{ B08_Quantity_Summary_LampTip: "구조물 조합 미연결" }})[k]; +const missing = ["", "배관: 본체 · 관 / 세월교: 유출 구조"]; +const text = eval(js("(" + {json.dumps(hover.group(1))} + ")")); +const cell = (i) => ({{ closest: () => ({{ dataset: {{ i: String(i) }} }}) }}); +console.log(JSON.stringify([text(cell(1)), text(cell(0)) || null])); +""" + ) + assert got == ["구조물 조합 미연결 — 배관: 본체 · 관 / 세월교: 유출 구조", None] + assert 'import { attachHoverInfo } from "@ui/ui_template_hover_info";' in UI + assert "`td.${LAMP_CLASS}`," in UI # 램프 칸에만 + locale = (ROOT / "ui_template" / "ui_template_locale_b4.ts").read_text(encoding="utf-8") + assert 'B08_Quantity_Summary_LampTip: ["구조물 조합 미연결",' in locale + + +def test_미지정_열_없음_줄_값_그대로(): + assert 'key: "unassigned"' not in UI and "B08_Quantity_Summary_Unassigned" not in UI + assert 'L("B08_Quantity_Summary_Unlinked")' not in UI # 측점 「· 미연결」 글 지움 + assert "frozen: 1," in UI + rows = re.search(r"rows: stations\.map\(\(station, i\) => \(\{(.*?)\}\)\),", UI, re.S) + assert rows and [s.strip() for s in rows.group(1).strip().split("\n")] == [ + "station: labels[i],", + "...values.get(stationKey(station)),", + ] + + +def test_램프_경고_노랑_토큰(): + css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text(encoding="utf-8") + m = re.search( + r'\[data-b08-summary="table"\] td\.' + re.escape(_lamp_class()) + r"::before\s*\{([^}]*)\}", + css, + ) + assert m + assert re.search(r"background:\s*var\(--color-warning\)", m.group(1)) + assert "border-radius: 50%" in m.group(1) + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8") + assert len(re.findall(r"--color-warning:\s*#", theme)) >= 2 # 밝음 · 어두움 diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py index 8922b2a81..c7454065a 100644 --- a/resources/tester/test_61_1_b08_summary_table.py +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -96,5 +96,5 @@ def test_집계표는_공용_표(): # PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님) ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui - assert "frozen: hasMissing ? 2 : 1" in ui and 'empty: ""' in ui and "onRowClick" in ui + assert "frozen: 1," in ui and 'empty: ""' in ui and "onRowClick" in ui assert 'document.createElement("table")' not in ui diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index bc300a4ee..4dd32bf14 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -63,7 +63,7 @@ export const ui_locales_b4 = { ], B08_Quantity_Summary_Total: ["계", "Total"], B08_Quantity_Summary_Unlinked: ["미연결", "Unlinked"], - B08_Quantity_Summary_Unassigned: ["미지정", "Unassigned"], + B08_Quantity_Summary_LampTip: ["구조물 조합 미연결", "Structure combination unlinked"], B08_Quantity_Link_Title: ["조합 연결", "Link combinations"], B08_Quantity_Link_Body: ["본체", "Body"], B08_Quantity_Link_Linked: ["연결됨", "Linked"], From 706906171b5e1fa339356c84752832dc95a7bca5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:07:15 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat(B08):=20=ED=91=9C=20=EC=B9=B8=20?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=20=EB=86=92=EC=9D=B4=20=3D=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=B0=BD=20=EC=95=84=EB=9E=98=20?= =?UTF-8?q?=EB=81=9D=EA=B9=8C=EC=A7=80=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=ED=91=9C=20fillViewport=20=EC=98=B5=EC=85=98=20(199-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 1 + .../B08_Quantity_UI_Earthwork_Sheets.ts | 10 +- B08_Quantity/B08_Quantity_UI_Material.ts | 1 + B08_Quantity/B08_Quantity_UI_Summary.ts | 1 + resources/tester/helper_ui_fill_viewport.cjs | 135 ++++++++++++++++++ resources/tester/test_199_1_fill_viewport.py | 89 ++++++++++++ ui_template/ui_template_data_table.ts | 61 ++++++++ 7 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 resources/tester/helper_ui_fill_viewport.cjs create mode 100644 resources/tester/test_199_1_fill_viewport.py diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 4345422f6..da26b1d54 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -234,6 +234,7 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) frozen: 1, total: totalRow(table), caption: "토적표", + fillViewport: true, }); grid.root.dataset.b08Earthwork = "table"; host.replaceChildren(tableCard(info, grid.root)); diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index ba97716f5..420100cc8 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -41,6 +41,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) })), frozen: 1, caption: "토공집계표", + fillViewport: true, }); host.replaceChildren(tableCard([info], grid.root)); } @@ -91,7 +92,14 @@ function haulSheet( `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, ), ); - const grid = createDataTable({ columns, rows, frozen: 1, total, caption: info }); + const grid = createDataTable({ + columns, + rows, + frozen: 1, + total, + caption: info, + fillViewport: true, + }); host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages)); } diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index f569f58be..5316e7f84 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -366,6 +366,7 @@ export async function renderMaterialSheet( frozen: built.frozen, empty: "", caption: BASIS, + fillViewport: true, editable: (i, key) => built.editable(i, key), onEdit: (i, key, text) => { const value = parse(text); diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 5a25baa8f..e298bf798 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -195,6 +195,7 @@ function buildTable( frozen: hasMissing ? 2 : 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), + fillViewport: true, }); table.root.dataset.b08Summary = "table"; return table.root; diff --git a/resources/tester/helper_ui_fill_viewport.cjs b/resources/tester/helper_ui_fill_viewport.cjs new file mode 100644 index 000000000..258c2999f --- /dev/null +++ b/resources/tester/helper_ui_fill_viewport.cjs @@ -0,0 +1,135 @@ +/* 창 아래 끝까지 칸(`ui_template/ui_template_data_table.ts` · PLAN 199-1) 단위검증 헬퍼 — + * 트랜스파일해 가짜 DOM(요소 트리 · 창 · ResizeObserver)에서 돌리고 판정할 값을 JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +// ── 가짜 DOM ── +const listeners = { resize: new Set() }; +const observers = []; +const win = { + innerHeight: 900, + scrollY: 0, + addEventListener: (t, f) => listeners[t]?.add(f), + removeEventListener: (t, f) => listeners[t]?.delete(f), +}; +class RO { + constructor(fn) { + this.fn = fn; + this.targets = new Set(); + observers.push(this); + } + observe(el) { + this.targets.add(el); + } + disconnect() { + this.targets.clear(); + } +} +function el(name, css = {}, height = 0) { + const node = { + name, + css, + height, + top: 0, + parentElement: null, + nextElementSibling: null, + isConnected: false, + style: {}, + getBoundingClientRect() { + return { top: this.top - win.scrollY, height: this.height }; + }, + }; + return node; +} +function link(parent, ...kids) { + kids.forEach((k, i) => { + k.parentElement = parent; + k.nextElementSibling = kids[i + 1] ?? null; + }); + parent.kids = kids; + return parent; +} +const sty = (n) => ({ + rowGap: n.css.rowGap ?? "normal", + paddingBottom: n.css.paddingBottom ?? "0px", + borderBottomWidth: n.css.borderBottomWidth ?? "0px", + marginBottom: n.css.marginBottom ?? "0px", +}); +const doc = { body: null }; +const box = { exports: {} }; +const sandbox = { + window: win, + document: doc, + getComputedStyle: sty, + ResizeObserver: RO, +}; +new Function("exports", "module", "require", ...Object.keys(sandbox), js)( + box.exports, + box, + (p) => (p.endsWith(".css") ? {} : require(p)), + ...Object.values(sandbox), +); +const m = box.exports; + +// 문서: html > body(padding-bottom 8) > page(padding-bottom 16 · gap 8) > [tabs 40] [card(padding-bottom 12) > [root][note 40]] +const html = el("html"); +const body = el("body", { paddingBottom: "8px" }); +const page = el("page", { paddingBottom: "16px", rowGap: "8px" }); +const tabs = el("tabs", {}, 40); +const card = el("card", { paddingBottom: "12px", marginBottom: "4px" }); +const root = el("root"); +const note = el("note", {}, 40); +link(html, body); +link(body, page); +link(page, tabs, card); +link(card, root, note); +doc.body = body; +root.top = 200; +const out = { clamp: m.fillHeight(900, 200, 68), small: m.fillHeight(300, 250, 100) }; + +// 아래 자리 = note(40 + 줄 사이 0) + card 여백(12) + card 바깥 여백(4) + page 아래(16) + body 아래(8) +out.below = m.spaceBelow(root); +out.belowExpected = 40 + 0 + 12 + 4 + 16 + 8; + +// 아직 문서에 안 붙음 — 높이 안 건드림 +let fits = 0; +const stop = m.fillViewport(root, () => (fits += 1)); +out.beforeAttach = root.style.maxHeight ?? null; +out.observed = + observers.some((o) => o.targets.has(root)) && observers.some((o) => o.targets.has(body)); + +// 붙음 → 관찰자가 부름 +[html, body, page, tabs, card, root, note].forEach((n) => (n.isConnected = true)); +observers.forEach((o) => o.fn()); +out.first = root.style.maxHeight; +out.fits = fits; + +// 창 크기 바뀜 → 다시 셈 +win.innerHeight = 600; +[...listeners.resize].forEach((f) => f()); +out.afterResize = root.style.maxHeight; + +// 스크롤해도 같은 값(문서 좌표 기준) +win.scrollY = 80; +[...listeners.resize].forEach((f) => f()); +out.afterScroll = root.style.maxHeight; +win.scrollY = 0; + +// 아래 형제가 늘어남 → 그만큼 줄어듦 +note.height = 100; +observers.forEach((o) => o.fn()); +out.afterNoteGrow = root.style.maxHeight; + +// 칸이 떨어지면 멈춤 +root.isConnected = false; +[...listeners.resize].forEach((f) => f()); +out.listenersAfterDetach = listeners.resize.size; +stop(); + +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_199_1_fill_viewport.py b/resources/tester/test_199_1_fill_viewport.py new file mode 100644 index 000000000..ede12aa32 --- /dev/null +++ b/resources/tester/test_199_1_fill_viewport.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +"""PLAN 199-1 — B08 표 칸 최대 높이 = 브라우저 창 아래 끝까지. + +Node 헬퍼(`helper_ui_fill_viewport.cjs`)가 `ui_template_data_table.ts` 의 창 끝까지 셈을 가짜 DOM 에서 돌리고, +여기서 옵션 · 셈 · 창 크기 바뀜 · 합계 줄 고정 · B08 적용을 판정함. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_ui_fill_viewport.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_최대_높이_창_기준_셈(r): + assert r["clamp"] == 900 - 200 - 68 + assert r["small"] == 120 # 아주 작은 창에서도 머리 + 몇 줄은 남김 + assert r["below"] == r["belowExpected"] == 80 # 뒤 형제 + 윗대 아래 여백 + + +def test_붙은_뒤에_셈(r): + assert r["beforeAttach"] is None # 문서에 붙기 전엔 안 건드림 + assert r["observed"] # 칸 + 문서 본문(body) 둘 다 관찰 + assert r["first"] == f"{900 - 200 - 80}px" # 620 + assert r["fits"] >= 1 + + +def test_창_크기_바뀌면_다시_셈(r): + assert r["afterResize"] == f"{600 - 200 - 80}px" # 320 + + +def test_스크롤_위치와_무관(r): + assert r["afterScroll"] == r["afterResize"] + + +def test_아래_요소가_늘면_그만큼_줄어듦(r): + assert r["afterNoteGrow"] == f"{600 - 200 - 140}px" # 260 + + +def test_칸이_떨어지면_창_청취_해제(r): + assert r["listenersAfterDetach"] == 0 + + +def test_공용_표_옵션과_연결(): + ts = _read("ui_template", "ui_template_data_table.ts") + assert re.search(r"fillViewport\?: boolean", ts) + assert re.search(r"if \(opts\.fillViewport\) fillViewport\(root, \(\) => draw\(false\)\)", ts) + + +def test_합계_줄_고정_그대로(): + css = _read("ui_template", "ui_template_data_table.css") + assert re.search(r"tfoot[^{]*\{[^}]*position:\s*sticky[^}]*bottom:\s*0", css, re.S) + assert "max-height: calc(100vh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로 + + +@pytest.mark.parametrize( + "name, count", + [ + ("B08_Quantity_UI_Summary.ts", 1), + ("B08_Quantity_UI_Earthwork.ts", 1), + ("B08_Quantity_UI_Earthwork_Sheets.ts", 2), + ("B08_Quantity_UI_Material.ts", 1), + ], +) +def test_B08_표_모두_적용(name, count): + src = _read("B08_Quantity", name) + assert src.count("fillViewport: true") == count == src.count("createDataTable({") diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 13e7a3b54..33656ca10 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -10,6 +10,7 @@ * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 + * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) * ========================================================================== */ import "./ui_template_data_table.css"; @@ -62,6 +63,8 @@ export interface DataTableOptions { rowClass?: (index: number) => string | null | undefined; /** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */ cellClass?: (index: number, key: string) => string | null | undefined; + /** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */ + fillViewport?: boolean; } export interface DataTable { @@ -240,6 +243,63 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin /** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */ const OVER = 40; +/* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ──────────── + * 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백. + * 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤. + * ------------------------------------------------------------------------------------------ */ + +/** 칸이 이보다 작아지지 않게(아주 작은 창에서 머리만 남는 것 방지) */ +const MIN_HEIGHT = 120; + +/** 창 기준 최대 높이(px) — `top` = 문서 맨 위에서 칸 위까지 · `below` = 칸 아래로 남겨야 할 자리 */ +export function fillHeight(viewport: number, top: number, below: number): number { + return Math.max(MIN_HEIGHT, Math.floor(viewport - top - below)); +} + +const px = (v: string): number => parseFloat(v) || 0; + +/** 칸 아래로 문서 끝까지 차지하는 자리 */ +export function spaceBelow(el: HTMLElement): number { + let sum = 0; + for (let node: HTMLElement = el; node.parentElement; node = node.parentElement) { + const parent = node.parentElement; + const own = getComputedStyle(node); + const around = getComputedStyle(parent); + const gap = px(around.rowGap); + for (let s = node.nextElementSibling; s; s = s.nextElementSibling) { + const h = s.getBoundingClientRect().height; + if (h) sum += h + gap; + } + sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth); + } + return sum; +} + +export function fillViewport(el: HTMLElement, onFit?: () => void): () => void { + let seen = false; // 한 번이라도 문서에 붙었는지 — 붙었다가 떨어지면 멈춤 + let observer: ResizeObserver | null = null; + const fit = (): void => { + if (!el.isConnected) { + if (seen) stop(); + return; + } + seen = true; + const top = el.getBoundingClientRect().top + window.scrollY; + el.style.maxHeight = `${fillHeight(window.innerHeight, top, spaceBelow(el))}px`; + onFit?.(); + }; + const stop = (): void => { + window.removeEventListener("resize", fit); + observer?.disconnect(); + }; + window.addEventListener("resize", fit); + // 칸이 붙을 때 · 줄이 늘 때 · 위아래 요소가 바뀔 때(문서 높이) 다시 셈 + observer = new ResizeObserver(fit); + observer.observe(el); + observer.observe(document.body); + return stop; +} + export function createDataTable(opts: DataTableOptions): DataTable { const root = document.createElement("div"); root.className = "ui-data-table"; @@ -287,6 +347,7 @@ export function createDataTable(opts: DataTableOptions): DataTable { state.onRowClick?.(Number(row.dataset.i)); }); draw(true); + if (opts.fillViewport) fillViewport(root, () => draw(false)); return { root, setRows(rows, total) { From 0ccaa0afa5e29b73f8d6256fd0961dd0ae24b6cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:08:32 +0900 Subject: [PATCH 7/7] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2046~52=20=C2=B7=20194~199=EC=9E=A5=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80?= 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_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-10_plan_194-199.md | 37 +++++++++++++++++++++++ docs/raw/요구_1010_2/사용자요구_1010_2.md | 7 +++++ 2 files changed, 44 insertions(+) create mode 100644 docs/raw/plans/2026-10-10_plan_194-199.md diff --git a/docs/raw/plans/2026-10-10_plan_194-199.md b/docs/raw/plans/2026-10-10_plan_194-199.md new file mode 100644 index 000000000..1ef26378e --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_194-199.md @@ -0,0 +1,37 @@ +# 2026-10-10 계획 194~199장 기록 — 소단 위아래 · 보라선 글 · 알림 줄 칩 · 대시보드 카드 · B08 램프 · B08 표 높이 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 46~52 · 메인 합침 d2f76e03b. + +## 194. 소단 = 좌측 위치 칸 없앰 · 횡단에서 위아래로만 옮김 (sub7) +정함(브레인) = 사용자 46번 · 178-2 의 좌측 「소단 위치(도로 아래)」 칸 지움 · 기본 = 노면 끝 아래 1 m · 사용자가 횡단도에서 소단 선을 골라 ▲▼ 로만 옮김(◀▶ 막음 · 성토 사면 무리 이동과 안 부딪힘 · 193 축 제약 꼴) · 옮긴 값 = 측점마다 횡단 설계 patch(정본 · [저장] 때 서버 다시 셈) · 도로보다 높을 수 없음 +- [x] 194-1 칸 지움 · 소단 선 고름 = ▲▼ 만 · 측점별 깊이 patch · 짝 거울 — test_178_2 고침 · 새 시험 + - 검증완료 — 새 test_194_1_berm_depth 14(칸 없음 · 기본 1 m · 쪽마다 깊이 거울 · patch 왕복 · 서버 = 정본 구간 + 측점 깊이 · 구간 지우면 소단 없음 · ▲▼ 만 · 0 · 사면 끝 멈춤) + test_178_2 · 54_26 · 193_1 · 139_6 · 137 · 사용자값 짝 등 합 86 통과 · typecheck 통과 + +## 195. B06 보라 선 고를 때 정보 글 = 길이 · 경사비율 · 수평 · 높이 (sub1) +정함(브레인) = 사용자 47번 · 「사면 길이」 → 「길이」 · 차례 = 길이 → 경사비율 → 수평 → 높이 · 값 · 단위 셈은 그대로 +- [x] 195-1 보라 선 고름 정보 글(선 길이 정보창 · 고름 패널 둘 다 같은 글이면 둘 다) — 새 시험 + - 검증완료 — `pieceLabel` 한 곳(정보창 글만 · 고름 패널 제목은 이동 글이라 무변) · 새 test_195_1 + test_137 · 179_1 · 193_1 · 139_6 합 24 통과 · typecheck 통과 +- [x] 194-2 (48번 보충) 소단 고름 = 횡단도 소단 수평선 강조 + 좌측 패널 · 소단 컨테이너 켬 · 이동 셋 분리(성 · 절토 = X · 도로 = Y · 소단 = 높이 Y) · 방향키로 옮긴 뒤 다시 그려도 같은 선 조각이 고른 채 남음(다른 조각으로 넘어가지 않음 · 조각 키 = 갈래 + 쪽 + 차례) — 새 시험 + - 검증완료 — test_194_1 흉내 DOM: 갈래 축(소단 depth · 사면 x · 노면 y) · 그리는 차례 · 깊이 바꿔 다시 그려도 같은 조각(번호 1 → 6 · 키로 되살림) · 좌측 소단 상자 알림 한 번(켬 40 → 끔) · 합 86 통과 + +## 196. B06 횡단 맨 위 알림 줄 — 군더더기 지움 · 이름 줄임 · 내용 칸 하나 공유 (sub8) +정함(브레인) = 사용자 49번 · 알림 줄(B06_Section_UI_Cross_FillSlope_Notice · 성토사면 5m 초과 · 소단 검토 · 포장 권장 · 열 수 칸)과 그 위 줄(측점 없는 구조물 등) · 항목 = 짧은 이름 + 수(예 「성토 5m↑ 3」 「소단 검토 5」 「포장 권장 2」) · 긴 설명 글은 지우고 공용 호버(185-3 ui_template_hover_info)로 · 펼침은 항목마다 따로가 아니라 줄 아래 내용 칸 하나를 같이 씀(누른 항목 목록만 · 다시 누르면 닫힘 · 한 번에 하나) · 지운 것 목록은 보고에 +- [x] 196-1 알림 줄 칩 꼴 · 내용 칸 하나 · 설명 = 호버 · sticky · 열 수 칸 그대로 — 새 시험 + - 검증완료 — 새 `_Cross_Notice_Chips`(칩 + 내용 칸 하나 · 호버) · 측점 없는 구조물 줄 까닭 글 = 호버 · 새 test_196_1(7) + fill_slope_warn · berm_review · 138 열 수 · 185_3 호버 합 19 통과 · typecheck 통과 + +## 197. 대시보드 「구조물 옵션」 카드 → M02 구조물 옵션 정의로 바로 (sub1) +정함(브레인) = 사용자 50번 · 카드가 M02 open=옵션 으로 감 → M02_UI_Side reveal("옵션") 이 옵션 묶음 첫 단추를 누름(구조물 옵션 정의 줄이 아닐 수 있음 · 추정) · 다른 카드(구조물조합 · 도면)도 같이 확인 +- [x] 197-1 대시보드 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 · 구조물조합 · 도면 카드도 제자리 — 새 시험 + - 원인 — 해시 open=옵션 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면 집계표의 `mount` 가 모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 덮음(`mount` 에 낡음 검사 없음) · 카드 → 해시 · reveal 길은 맞았음 + - 고침 — `M02_MasterTemplete_UI_Main.ts` `mount` 기다림 뒤마다 `mine !== seq` 면 그만둠(집계표 · 일위대가 상세 · 도면) + - 검증완료 — 새 test_197_1(4: 카드 셋 해시 · 해시 진입 셋 · reveal 옵션만 누름 · mount 낡음 검사 Node 3경우 · 고침 빼면 실패 확인) + b01_settings_cards · m02_layer_screen · card_shell · structure_def_screen 포함 38 통과 · M02_UI_Main 쓰는 시험 116 중 115 통과(test_m02_card_sheet_edit 1건 = spreadsheet.ts 문구 시험 · 이번 고침 밖) · typecheck 통과 + +## 198. B08 구조물 집계표 — 미연결 = 줄 왼쪽 램프 하나 · 미지정 열 지움 (sub8) +정함(브레인) = 사용자 51번 · 160 · 162 때 더한 「미지정」 열 지움 · 줄 맨 왼쪽에 작은 램프 하나(미연결 = 경고 노랑 · 연결 = 안 보임 또는 초록 · 177-1 · 170-3 상태 색 토큰 재사용) · 램프에 마우스 = 공용 호버로 까닭(어느 조합이 미연결인지) +- [x] 198-1 미지정 열 지움 · 줄 왼쪽 램프 · 호버 까닭 · 엑셀 꼴 · 합계 영향 없음 — test_160 · 162 등 고침 · 새 시험 + - 검증완료 — test_198_1 4건(미연결 줄 측점 칸만 램프 1개 · 호버 「구조물 조합 미연결 — 빈 칸」 · 경고 노랑 토큰 · 줄 값 = 측점 + 서버 값뿐) + B08 시험 12파일 57 통과 · typecheck 0 · 구조물 집계표는 엑셀 내보내기 없음 · 「· 미연결」 측점 글 · 조합 칸 「미지정」 표기도 지움 · 서버 안내 「노랑 램프 줄」 + +## 199. B08 표 높이 = 브라우저 창 아래 끝까지(페이지 스크롤 없음) (sub5) +정함(브레인) = 사용자 52번 · 합계 줄 고정 + 안쪽 스크롤은 그대로 · 표 칸 최대 높이 = 고정값 대신 창 높이에서 표 위쪽 자리를 뺀 만큼(창 크기 바뀌면 다시 셈) · 줄이 적으면 내용만큼 · 공용 표 양식에 옵션 하나(다른 페이지도 쓸 수 있게) · 이번 적용은 B08 표들 +- [x] 199-1 B08 표 칸 = 창 아래 끝까지 · 페이지 스크롤 0 · 합계 줄 고정 유지 · 창 크기 바뀜 대응 — 새 시험 + - 검증완료 — test_199_1 12건(옵션 fillViewport · 최대 높이 = 창 − 위 − 아래 자리 · 창 크기 바뀜 900→600 재셈 · 스크롤 무관 · 아래 요소 늘면 줄어듦 · 합계 줄 sticky 그대로 · B08 표 5곳 적용) + 관련 B08 시험 · test_ui_data_table 통과 · typecheck 0 · test_124 「화면 연결」 1건 실패는 이 변경 전부터(HEAD 에 해당 글 없음) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 18c1b1be2..9cd35f849 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -66,3 +66,10 @@ 43. B06 횡단도 좌측 패널에서 세부 컨테이너의 정보를 변경하면 해당 구조물 형상을 잘 선택되고 있는데 해당 패널의 컨테이너 영역을 선택해도 변경되게 해줘. 44. 랩탑 메인 프로젝트에서 38+9 측점 확인 종단의 선을 봤을때 중간에 안보이는 가상의 측점이 존재하는것처럼 보임. 데이터로 확인. (사진: 종단 계획선(보라 점선)이 고른 측점 세로선 근처에서 꺾임) 45. 지금 횡단도의 보라색선인 계획선과 관련인데 여기서 선을 찍으면 직선선 성분을 기준으로 이동을 할수 있게 만들어져 있임. 여기서 이동의 제약이 있어야함. 마음대로 변경되고 있음. 보라색 선은 선택은 크게는 성토측, 절토측, 도로측이 있음. 성토측 보라색 선을 선택할경우에는 도로 중심을 기준으로 성토방향으로 만 동작해야함. 만약 배관의 길이 영향으로 갈수 있는 방향은 절토측은 고정이 되고 성토측 방향의 구조물+성토선 전체가 이동이 되어야함. 반대로도 동일함. 이때 노면, 노견의 선을 선택한다면 Y방향으로 이동이되고 이때 종단도도 같이 동작해야함. 지금도 그렇게 구성되어 있음. 전반적으로 잘 구현되어 있으나 다듬을 필요가 있음. +46. 소단에서 소단위 위치값은 필요없음. 이동은 어차피 아래 위밖에 안되는 상태일테고 추가 기본지점을 도로하단 1m 지점에 추가해주면 사용자가 위치를 결정하게 해줘. 이때 성토사면 그룹의 이동과 충돌나는걸 피하기위해 좌우 이동이 없는거야. 결과적으로는 사용자가 지정한 요소들의 위치가 중요한거지. +47. 추가로 보라색 선 성분을 선택할때 나오는 텍스트는 사면 길이 > 길이로 변경 다음에 경사비율, 수평, 높이로 표현해줘. +48. (46번 보충) 소단을 선택됨을 활성화하는 영역은 횡단도의 소단 수평선 성분을 하이라이트 하면되고 패널과 컨테이너를 활성화하면 됨. 성절토의 X 이동과 와 도로 Y 이동, 소단의 높이 위치 이동을 명확학헤 분리해서 제어되어야하며 상하좌우 이동시 다른 선 성분으로 이동되는 경우가 있으니 조심 +49. B06 횡단도 제일산단의 쓸대없는 정보는 지우고 나오는 항목 이름을 줄여서 표현하고 전부 열림을 하면 공간을 많이 차지하니 내용을 보여주는 영역을 공유하는게 어떤지 +50. 대시보드의 구조물옵션 정의가 제대로 연결안됨. +51. B08의 구조물 집계표에서 미연결 상태표시는 행의 좌측에 하나의 램프로 표기, 미지정 열을 필요없어 보임. +52. B08의 페이지들을 보면 합계가 나오는 행들을 보면 초기에는 행들이 추가되다가 특정 컨테이너 높이가 되면 합계 행을 고정하고 내부를 스크롤 방식으로 변경하고 있음. 이방법 자체는 좋은데 그렇다면 컨테이너의 높이제약을 현재 브라우져가 스크롤이 안되는 지점을 기준으로 적용하는게 조작 편의성이 좋아보임. 그럼 행들이 추가됨에 따라 밀리더라도 스크롤은 페이지스크롤은 없을테니