From b2ad4f7e406b10d08fb3fae5a7c53f4034ad8fd2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:33:24 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(B08):=20262=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=EC=B0=BD=20=EC=A0=9C=EB=AA=A9=20=EC=9D=B4=EB=A6=84=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EC=86=8C=EC=9E=AC=EC=A7=80=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20=ED=91=9C=EC=A7=80=20=EA=B3=A0=EC=A0=95?= 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 --- B08_Quantity/B08_Quantity_UI_Milestone.css | 13 ++++++ .../B08_Quantity_UI_Milestone_KakaoMap.ts | 18 ++++++++- .../B08_Quantity_UI_Milestone_Modal.ts | 6 +-- .../B08_Quantity_UI_Milestone_Place.ts | 34 ++++++++++------ .../test_254_3_b08_milestone_box_modal_pic.py | 4 +- ...est_262_1_b08_milestone_title_start_pin.py | 40 +++++++++++++++++++ 6 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 resources/tester/test_262_1_b08_milestone_title_start_pin.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 4d0df3b34..09b24921d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -204,6 +204,19 @@ white-space: nowrap; } +/* 262-2 고정 시점 표지 — 둥근 붉은 알약(고르는 표지 = 기본 핀 + 흰 이름표와 구별) */ +.b08-milestone__map-start { + padding: 3px 10px; + border: 2px solid #fff; + border-radius: 999px; + background: #e60046; + color: #fff; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + pointer-events: none; +} + /* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */ .ui-modal.b08-milestone__modal { width: min(960px, 94vw); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 956d534ff..a6c2af12f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -98,6 +98,8 @@ export interface MapPin { lon: number; lat: number; movable: boolean; + /** 262-2 고정 시점 표지 — 끌기 · 맞춤 · 보기 이동에 안 쓰임 · 고르는 표지와 모양이 다름 */ + fixed?: boolean; } export interface EditMap { @@ -127,8 +129,9 @@ export function editMap( loadKakaoMaps(projectId).then( (maps) => requestAnimationFrame(() => { + const moving = pins.filter((p) => !p.fixed); const points = [ - ...pins.map((p) => new maps.LatLng(p.lat, p.lon)), + ...moving.map((p) => new maps.LatLng(p.lat, p.lon)), ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), ]; const c = opts.center?.(); @@ -156,6 +159,17 @@ export function editMap( ), ...pins.flatMap((p) => { const at = new maps.LatLng(p.lat, p.lon); + if (p.fixed) + return [ + new maps.CustomOverlay({ + position: at, + content: el("span", { + className: "b08-milestone__map-start", + text: p.name, + }), + yAnchor: 0.5, + }), + ]; const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable }); if (p.movable) maps.event.addListener(marker, "dragend", () => { @@ -176,7 +190,7 @@ export function editMap( if (fitted) { // 보기 밖 표지만 그쪽으로 옮김(줌 그대로) const view = map.getBounds(); - const out = pins.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon))); + const out = moving.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon))); if (out) map.panTo(new maps.LatLng(out.lat, out.lon)); } else if (points.length > 1) { const bounds = new maps.LatLngBounds(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 0e3394a59..97c93a15f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -106,8 +106,8 @@ export function openPlaceModal( const title = target.add ? "요소 추가 — 기준점 · 거래처" : base - ? `기준점 — ${base.name || "기준점"}` - : `거래처 — ${route!.name || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음 + ? base.name || "기준점" + : route!.name || "거래처"; // 262-1 「—」 앞 글자 없음 openModal({ title, closeLabel: "닫기", @@ -259,7 +259,7 @@ export interface SiteAddress { /** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ - title: "공사지 — 시점 · 중점", + title: "시점 · 중점", closeLabel: "닫기", dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts index aa65196e9..f4f2af5be 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts @@ -12,7 +12,7 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { request } from "./B08_Quantity_UI_Hydro"; import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap"; -import { siteInfo } from "./B08_Quantity_UI_Milestone_State"; +import { liveMilestone, startPoint } from "./B08_Quantity_UI_Milestone_State"; export interface Place { address: string; @@ -52,27 +52,35 @@ export function placePicker(projectId: string, initial: Place, name: () => strin list.hidden = true; const where = el("p", { className: "b08-milestone__place" }); where.dataset.b08MilestonePlace = "value"; + // 262-2 시점 = 거리이정표가 쓰는 시점(끌어 옮긴 덮개 · 없으면 노선 시점) + const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); const map = editMap(projectId, { hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김", onClick: (lon, lat) => void moveTo(lon, lat), onMove: (_key, lon, lat) => void moveTo(lon, lat), - center: () => siteInfo()?.start_lonlat ?? null, + center: start, }); const draw = (): void => { where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`; + const s = start(); map.set( - place.lon != null && place.lat != null - ? [ - { - key: "place", - name: name() || "소재지", - lon: place.lon, - lat: place.lat, - movable: true, - }, - ] - : [], + [ + ...(s + ? [{ key: "start", name: "시점", lon: s[0], lat: s[1], movable: false, fixed: true }] + : []), + ...(place.lon != null && place.lat != null + ? [ + { + key: "place", + name: name() || "소재지", + lon: place.lon, + lat: place.lat, + movable: true, + }, + ] + : []), + ], [], ); }; diff --git a/resources/tester/test_254_3_b08_milestone_box_modal_pic.py b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py index aeec089ba..0a78098f0 100644 --- a/resources/tester/test_254_3_b08_milestone_box_modal_pic.py +++ b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py @@ -1,6 +1,6 @@ """254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8). -① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음) +① 상자 누름 모달 제목 = 이름만(262-1 · 「—」 앞 글자 없음 · 표 머리 글 「항목 정보」 없음) ② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로) """ @@ -67,7 +67,7 @@ def test_장소_그림_상자_안_글_아래(): def test_모달_제목_화면_그림(): modal = _read(B08 + "UI_Milestone_Modal.ts") - assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal + assert 'route!.name || "거래처"' in modal and 'base.name || "기준점"' in modal # 262-1 assert "항목 정보 —" not in modal ui = _read(B08 + "UI_Milestone_Diagram.ts") assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui diff --git a/resources/tester/test_262_1_b08_milestone_title_start_pin.py b/resources/tester/test_262_1_b08_milestone_title_start_pin.py new file mode 100644 index 000000000..c002f4175 --- /dev/null +++ b/resources/tester/test_262_1_b08_milestone_title_start_pin.py @@ -0,0 +1,40 @@ +"""262 B08 거리이정표 상자 창 제목 · 소재지 지도 시점 표지(사용자 164-1). + +262-1 거래처 · 기준점 창 제목 = 이름만(빈 이름 = 「거래처」 · 「기준점」) · 시점 창 = 「시점 · 중점」 · 요소 추가 창 그대로 +262-2 소재지 지도 = 시점 표지 고정(끌기 없음 · 맞춤에서 빠짐) · 시점 없으면 표지 없음 · 소재지 없으면 시점 중심 +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B08 = "B08_Quantity/B08_Quantity_UI_Milestone" + + +def _read(name): + return (ROOT / f"{B08}{name}").read_text(encoding="utf-8") + + +def test_제목_이름만(): + modal = _read("_Modal.ts") + title = modal[modal.index("const title = target.add") : modal.index("openModal({")] + assert '? base.name || "기준점"' in title and ': route!.name || "거래처"' in title + assert '"요소 추가 — 기준점 · 거래처"' in title + assert 'title: "시점 · 중점",' in modal and "공사지 —" not in modal + assert re.search(r"title: `?[^\n]*(거래처|기준점|공사지) —", modal) is None + + +def test_시점_표지_고정(): + place = _read("_Place.ts") + assert "startPoint(liveMilestone(projectId))" in place # 거리이정표가 쓰는 시점 + assert "center: start" in place # 소재지 없음 = 시점 중심 + assert "movable: false, fixed: true" in place and 'name: "시점"' in place + assert "...(s" in place and " : [])," in place # 시점 없음 = 표지 없음 + kakao = _read("_KakaoMap.ts") + assert "fixed?: boolean;" in kakao + assert "const moving = pins.filter((p) => !p.fixed);" in kakao + assert "...moving.map(" in kakao # 처음 보기 = 소재지 중심(시점은 맞춤에서 빠짐) + assert "moving.find(" in kakao # 보기 이동에서도 빠짐 + assert "if (p.fixed)" in kakao and "b08-milestone__map-start" in kakao + css = _read(".css") + assert ".b08-milestone__map-start {" in css From 5fdfc64d108cd3abb5179c6bace19ab49ea949e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:33:24 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix(B06):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=20=EA=B4=80=EB=A7=A4=EC=84=A4=20=C2=B7=20=EC=84=B8=EC=9B=94?= =?UTF-8?q?=EA=B5=90=20=EC=A0=95=EB=B3=B4=20=EA=B8=80=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=20=3D=20=EB=85=B8=ED=8F=AD=20=EB=9D=BC=EB=B2=A8=20=EA=B8=80=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0(267-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 --- .../B06_Section_UI_Style_Cross_Areas.css | 5 +++ resources/tester/test_267_1_pipe_info_font.py | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 resources/tester/test_267_1_pipe_info_font.py diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 0b21a02b5..a724bc1cc 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -360,6 +360,11 @@ fill: var(--color-text-secondary); } +/* 관 정보 글(관매설 · 세월교 · 267-1) — 노폭 라벨(`.b06-chart__carriageway-label`)과 같은 글 크기 */ +.b06-chart__culvert-label.b06-chart__pipe-info { + font-size: 11px; +} + /* 유입 보호공 공간 확보 절토선 — 말단에서 표준단면 절토경사로 지반까지 */ .b06-chart__culvert-cut { stroke: var(--color-warning); diff --git a/resources/tester/test_267_1_pipe_info_font.py b/resources/tester/test_267_1_pipe_info_font.py new file mode 100644 index 000000000..d819bb537 --- /dev/null +++ b/resources/tester/test_267_1_pipe_info_font.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""PLAN 267-1 — B06 횡단 카드 「관매설 …」 글 크기 = 「노폭 …」 글 크기 · 줄 겹침 없음. + +./venv/Scripts/python.exe -m pytest resources/tester/test_267_1_pipe_info_font.py -q -p no:cacheprovider --basetemp=tmp/pt +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text(encoding="utf-8") +TYPES = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Types.ts").read_text(encoding="utf-8") + + +def _font_px(selector: str) -> float: + """셀렉터 블록의 마지막 font-size(px) — 뒤에 오는 규칙이 이긴다.""" + blocks = re.findall(re.escape(selector) + r"\s*\{([^}]*)\}", CSS) + sizes = [float(m) for b in blocks for m in re.findall(r"font-size:\s*([\d.]+)px", b)] + assert sizes, selector + return sizes[-1] + + +def test_관_정보_글_크기가_노폭_라벨과_같다() -> None: + road = _font_px(".b06-chart__carriageway-label") + info = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info") + assert road == info == 11.0 + # 다른 culvert-label(집수정 · 박스 이름)은 그대로 9px + assert _font_px(".b06-chart__culvert-label") == 9.0 + + +def test_관_정보_글_줄이_노폭_라벨과_겹치지_않는다() -> None: + step = float(re.search(r"- (\d+) \* \(stacked \+ 1\)", TYPES).group(1)) + font = _font_px(".b06-chart__culvert-label.b06-chart__pipe-info") + road_font = _font_px(".b06-chart__carriageway-label") + asc, desc = 0.8, 0.2 # 글자 상자 근사 — 기준선 위 80% · 아래 20% + road_top = 0 - asc * road_font # 노폭 라벨 기준선 y=0 + for n in (1, 2, 3): # 관 정보 글이 쌓인 줄 수 + info_y = -step * n + info_bottom = info_y + desc * font + assert info_bottom < road_top, (n, info_bottom, road_top) + if n > 1: # 윗줄 바닥 < 아랫줄 윗면 + upper_bottom = -step * n + desc * font + lower_top = -step * (n - 1) - asc * font + assert upper_bottom < lower_top, n From c47e9f861537574b2bba2dda9026377e9b62f57c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:34:15 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix(B06):=20=EC=83=81=EC=84=B8=20CAD=20?= =?UTF-8?q?=EC=B9=B8=20=EC=9B=90=ED=98=95=20=EB=A7=88=ED=81=AC=20=EA=B4=80?= =?UTF-8?q?=20=EA=B8=B8=EC=9D=B4=20=EC=82=AC=EC=82=AC=EC=98=A4=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=EC=88=98(265-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원형 마크 L = pipeLengthWhole 정수(8.5 → 9 · 8.4 → 8) · B07 짝 같은 규칙 - 거울 시험 test_265_note_cad · test_260 기대값 L=22m Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B06_Section_UI_Cross_Detail_Cad.ts | 6 +- ...B07_DesignDetail_Engine_Cad_CulvertNote.py | 7 +- resources/tester/helper_260_note_cad.cjs | 4 ++ resources/tester/test_260_note_cad.py | 6 +- resources/tester/test_265_note_cad.py | 66 +++++++++++++++++++ 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_265_note_cad.py diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index 3cb8b04b4..cf3fff35c 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -19,6 +19,7 @@ import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch"; +import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const"; const noteKey = (chainageM: number): string => chainageM.toFixed(2); @@ -109,6 +110,9 @@ export function legacyNoteEntities(note: string | undefined): Record typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : ""; const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : ""); +/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */ +const wholeMetres = (value: number | null | undefined): string => + typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : ""; const trim = (value: number): string => String(Math.round(value * 100) / 100); /** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */ const bondOf = (form: string | null | undefined): string => @@ -183,7 +187,7 @@ export function culvertNoteEntities( const inside = [ "배수관", `Φ${Math.round(culvert.diameter_m * 1000)}m/m`, - `L=${metres(section.design?.pipe_length_m)}`, + `L=${wholeMetres(section.design?.pipe_length_m)}`, ]; const right = [ station, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py index 53dc81fa1..18c584202 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -40,6 +40,11 @@ def _metres(value: Any) -> str: return f"{_one(value)}m" if _one(value) else "" +def _whole_metres(value: Any) -> str: + """관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값).""" + return f"{_js_round(value)}m" if _one(value) else "" + + def _trim(value: float) -> str: rounded = _js_round(value * 100) / 100 return str(int(rounded)) if rounded == int(rounded) else str(rounded) @@ -159,7 +164,7 @@ def culvert_note_entities( inside = [ "배수관", f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m", - f"L={_metres(length)}", + f"L={_whole_metres(length)}", ] chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {} right = [ diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs index 4cd84f09e..bf9ff6244 100644 --- a/resources/tester/helper_260_note_cad.cjs +++ b/resources/tester/helper_260_note_cad.cjs @@ -28,6 +28,10 @@ function loadTs(file) { return box.exports; } +// 관 길이 정수(265-3) = 실제 `pipeLengthWhole`(타입 import 만 있는 파일) +STUBS["./B06_Section_UI_Cross_Culvert_Const"] = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Const.ts"), +); const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts")); const wall = (role, form, height, length) => ({ diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 5a85aed87..34aae9442 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -107,7 +107,7 @@ def test_관_유입_유출_도수로_한_벌(out): assert _labels(full) == [ "배수관", "Φ1200m/m", - "L=22.0m", + "L=22m", "NO.71+13", "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", @@ -136,7 +136,7 @@ def test_도수로_옵션이_없으면_세트_구조만(out): def test_보호공을_안_고르면_관과_측점만(out): - assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22.0m", "NO.71+13"] + assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] @pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"]) @@ -425,7 +425,7 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_ assert labels[2:] == [ "배수관", "Φ1200m/m", - "L=22.0m", + "L=22m", "NO.71+13", "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", diff --git a/resources/tester/test_265_note_cad.py b/resources/tester/test_265_note_cad.py new file mode 100644 index 000000000..c92db042e --- /dev/null +++ b/resources/tester/test_265_note_cad.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""PLAN 265 — B06 [상세] 표 위 CAD 칸 · 접기 · 차례 · 구조물 글 · 관 길이 정수. + +- 265-3 원형 마크 L = 사사오입 정수(TS `pipeLengthWhole` = 파이썬 짝 같은 값). + +./venv/Scripts/python.exe -m pytest resources/tester/test_265_note_cad.py -q +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section.B06_Section_Engine_Culvert import _culvert_set # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 + culvert_note_entities, +) + +HERE = Path(__file__).resolve().parent +_PIPE = {"pipe_diameter_mm": 800} + + +def _mirror(cases: list[dict]) -> list[dict]: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"], + input=json.dumps(cases, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _labels(entities: list[dict]) -> list[str]: + return [e["shapeData"]["label"] for e in entities if e["type"] == "Text"] + + +# ── 265-3 관 길이 정수 ───────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("length", "label"), + [(8.5, "L=9m"), (8.4, "L=8m"), (8.45, "L=8m"), (7.0, "L=7m"), (None, "L=")], +) +def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, label): + design = {} if length is None else {"pipe_length_m": length} + culvert = {**_culvert_set(_PIPE), "chainage_m": 40.0} + case = { + "section": {"chainage_m": 40.0, "culvert": culvert, "design": design}, + "station": "NO.2", + "options": _PIPE, + "chainage": 40.0, + "interval": 20.0, + } + [ts] = _mirror([case]) + py = culvert_note_entities(40.0, culvert, design, "NO.2", _PIPE) + assert _labels(ts["entities"])[2] == label + assert _labels(py)[2] == label From 3a08e4df9da916d8e476d10688510d2991085150 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:35:42 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(B06):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=B0=BD=20=EC=B2=98=EC=9D=8C=20=EA=B0=80=EB=A1=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=3D=20=ED=9A=A1=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=98=ED=8F=AD=20(266-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칸 맞춤이 넓힌 가로를 처음 배율(homeScale)로 되좁힘 · 개별 표시 반폭 측점은 그 값 - 공용 줌 · 팬 homeScale 칸 — 처음 · [⤢] · 더블클릭 = 그 배율 · 줄이면 칸 맞춤 전체까지 - 시험 test_266 · 164 · 173 기대값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/b_svg_zoom_pan.ts | 20 ++-- B06_Section/B06_Section_UI_Cross_Detail.ts | 21 +++- B06_Section/B06_Section_UI_Section_View.ts | 4 +- .../B06_Section_UI_Section_View_Types.ts | 4 +- resources/tester/helper_266_detail_home.cjs | 110 ++++++++++++++++++ resources/tester/test_164_detail_zoom.py | 7 +- resources/tester/test_173_areas_detail.py | 5 +- resources/tester/test_266_detail_home.py | 85 ++++++++++++++ 8 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 resources/tester/helper_266_detail_home.cjs create mode 100644 resources/tester/test_266_detail_home.py diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 951564e82..5dc6d9c80 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -61,6 +61,8 @@ export interface SvgZoomPanOptions { maxScale?: number; /** 이전 상태 — 다시 그려도 배율이 살아남게. */ initial?: ZoomPanState; + /** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */ + homeScale?: number; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ @@ -85,9 +87,15 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { height: Math.max(heightPx - pad.top - pad.bottom, 1), }; const maxScale = options.maxScale ?? 8; - let scale = initial?.scale ?? 1; - let tx = initial?.tx ?? 0; - let ty = initial?.ty ?? 0; + const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale); + const home: ZoomPanState = { + scale: homeScale, + tx: (plot.x + plot.width / 2) * (1 - homeScale), + ty: (plot.y + plot.height / 2) * (1 - homeScale), + }; + let scale = initial?.scale ?? home.scale; + let tx = initial?.tx ?? home.tx; + let ty = initial?.ty ?? home.ty; const applyTransform = (): void => { layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); onChange?.({ scale, tx, ty }); @@ -182,7 +190,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { /* 이미 해제됨 */ } }; - if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) { + if (scale !== 1 || tx !== 0 || ty !== 0) { clampPan(); applyTransform(); } @@ -209,9 +217,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { } const currentScale = (): number => scale; const reset = (): void => { - scale = 1; - tx = 0; - ty = 0; + ({ scale, tx, ty } = home); applyTransform(); }; // 더블클릭 원복. diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index b82ab5469..d73a204e5 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -45,12 +45,12 @@ export interface CrossDetailHost { projectId: string; routeId: () => number | null; detail: () => SectionDetailResponse | null; - /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */ + /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */ chart: ( stationId: string, widthPx: number, heightPx: number, - ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null; } let host: CrossDetailHost | null = null; @@ -187,12 +187,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ /** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · - * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */ + * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) · + * 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */ export function attachDetailZoom( clone: SVGSVGElement, axes: CrossAxesOptions | null, initial: ZoomPanState | undefined, onChange: (state: ZoomPanState) => void, + homeScale = 1, ): ZoomPanHandle | null { const layer = clone.querySelector("g.b06-chart__plot-layer"); if (!layer) return null; @@ -223,6 +225,7 @@ export function attachDetailZoom( content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, wheelZoom: true, initial, + homeScale, onChange: (state) => { follow(state); onChange(state); @@ -251,7 +254,7 @@ function drawChart( section: CrossSection, widthPx: number, heightPx: number, -): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null { +): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null { drawing = true; try { const drawn = host?.chart(section.station_id, widthPx, heightPx); @@ -264,7 +267,7 @@ function drawChart( .querySelectorAll(`[clip-path="url(#${id})"]`) .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); }); - return { clone, axes: drawn.axes }; + return { clone, axes: drawn.axes, homeScale: drawn.homeScale }; } finally { drawing = false; } @@ -359,7 +362,13 @@ export function openCrossDetail(section: CrossSection): void { else { const { clone } = drawn; axes = drawn.axes; - const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); + const handle = attachDetailZoom( + clone, + axes, + zoom, + (next) => (zoom = next), + drawn.homeScale, + ); cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); tablePick.redraw(); // 선 조각 고르기(209-1) } diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 88f3a9e64..dc327f46f 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -579,7 +579,9 @@ export function createSectionView( elevationMid: plot.elevationMid, exaggeration: Math.max(currentExaggeration, 0.1), }; - return { svg, axes }; + // 칸 맞춤이 넓힌 가로 범위를 처음 배율로 되좁힘 = 카드 반폭(266-1) + const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; + return { svg, axes, homeScale }; }, focusStation(stationId) { if (stationId === null) clearSelection(); diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 939e33f2b..a67c0d508 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -39,12 +39,12 @@ export interface SectionViewController { /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ refreshCards: (chainages: ReadonlyArray) => void; /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null) - * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */ + * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null) · 처음 배율(266-1 · 가로 = 카드 반폭). */ detailChart: ( stationId: string, widthPx: number, heightPx: number, - ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null; /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; diff --git a/resources/tester/helper_266_detail_home.cjs b/resources/tester/helper_266_detail_home.cjs new file mode 100644 index 000000000..a5dfdbdbf --- /dev/null +++ b/resources/tester/helper_266_detail_home.cjs @@ -0,0 +1,110 @@ +/* PLAN 266-1 단위검증 헬퍼 — [상세] 처음 가로 범위 = 카드 반폭. + * 실제 TS(`_Cross_View_Metrics` 칸 맞춤 · `b_svg_zoom_pan` homeScale)를 Node 로 돌림. + * 처음 배율 = 뷰 `detailChart` 와 같은 식(base.ppm / fitted.ppm · 짝 시험이 원본 줄을 확인). + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_266_detail_home.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 B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]); +const STUBS = { + "./B06_Section_UI_Section_Common": { + CROSS_PAD, + CROSS_HEIGHT, + validElevation: (s) => s.valid !== false && Number.isFinite(s.elevation_m), + }, +}; +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => STUBS[request] ?? {}; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts")); +const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); + +function fakeSvg(width, height) { + const listeners = {}; + const attrs = {}; + return { + attrs, + setAttribute: (name, value) => (attrs[name] = String(value)), + getAttribute: (name) => attrs[name] ?? null, + classList: { add() {}, remove() {} }, + addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), + fire: (type, event) => + (listeners[type] ?? []).forEach((fn) => + fn({ preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }), + ), + setPointerCapture() {}, + releasePointerCapture() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width, height }), + }; +} + +// 가파른 비탈 — ±30 m 샘플 · 1 m 마다 · 1 m 에 0.6 m 오름(칸 맞춤이 가로를 넓히게) +const section = { + samples: Array.from({ length: 61 }, (_, i) => ({ + offset_m: i - 30, + elevation_m: 100 + 0.6 * (i - 30), + })), +}; + +/** 반폭 · 칸 크기로 상세 창을 연 셈 → 처음 · 줌 · 팬 · 원복 뒤 보이는 가로 범위(m). */ +function open(halfWidth, W, H) { + const base = metrics.crossPlotBase(section, 1, W, halfWidth, 100); + const fitted = metrics.fitPlotBase(base, H); + const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; + const svg = fakeSvg(W, H); + const layer = fakeSvg(W, H); + let state = null; + const handle = attachSvgZoomPan({ + svg, + layer, + widthPx: W, + heightPx: H, + pad: CROSS_PAD, + maxScale: 16, + content: { x: -W, y: -H, width: 3 * W, height: 3 * H }, + wheelZoom: true, + homeScale, + onChange: (next) => (state = next), + }); + // 보이는 플롯 가로 끝(svg x) → 도형 층 x → 측점 거리(m) + const range = () => { + const s = state ?? { scale: 1, tx: 0, ty: 0 }; + const toM = (x) => + fitted.minOffset + ((x - s.tx) / s.scale - CROSS_PAD.left) / fitted.pixelsPerMeter; + return [toM(CROSS_PAD.left), toM(W - CROSS_PAD.right)].map((v) => Math.round(v * 1000) / 1000); + }; + const out = { homeScale, widened: fitted !== base, first: range() }; + svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: 100 }); + out.wheelScale = state.scale / homeScale; + svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 }); + svg.fire("pointermove", { clientX: 160, clientY: 100 }); + svg.fire("pointerup", {}); + out.panned = range()[0] !== out.first[0]; + handle.reset(); // [⤢] + out.reset = range(); + for (let i = 0; i < 40; i++) svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: -100 }); + out.minScale = state.scale; // 줄이면 칸 맞춤 전체(1배)까지 + svg.fire("dblclick", {}); + out.dblclick = range(); + return out; +} + +process.stdout.write( + JSON.stringify({ + global15: open(15, 1200, 300), + station10: open(10, 1200, 300), + tall15: open(15, 1200, 900), + }), +); diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 1b14444f5..96e1842e4 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -84,13 +84,16 @@ def test_모달_배선_단추_칸_비율(): css = _read("B06_Section_UI_Style_Cross_Detail.css") assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src - assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src + flat = " ".join(src.split()) + assert ( + "attachDetailZoom( clone, axes, zoom, (next) => (zoom = next), drawn.homeScale, )" in flat + ) assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3) assert 'clone.querySelector("g.b06-chart__plot-layer")' in src assert "createCrossAxes(axisLayer, axes)" in src view = _read("B06_Section_UI_Section_View.ts") assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view - assert "return { svg, axes };" in view + assert "return { svg, axes, homeScale };" in view assert "position: relative;" in css # 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득 assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css diff --git a/resources/tester/test_173_areas_detail.py b/resources/tester/test_173_areas_detail.py index 0ac4e246e..a772fc015 100644 --- a/resources/tester/test_173_areas_detail.py +++ b/resources/tester/test_173_areas_detail.py @@ -100,7 +100,10 @@ def test_상세_칸_크기로_새로_그림_배선(): assert "cardZoomStates.delete(stationId);" in view types = _read("B06_Section_UI_Section_View_Types.ts") # 179-3 — svg + 축 옵션 - assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types + assert ( + " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;" + in types + ) assert ( " detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number," in types diff --git a/resources/tester/test_266_detail_home.py b/resources/tester/test_266_detail_home.py new file mode 100644 index 000000000..31dcecc2f --- /dev/null +++ b/resources/tester/test_266_detail_home.py @@ -0,0 +1,85 @@ +"""PLAN 266-1 — B06 [상세] 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(개별 표시 반폭이면 그것). + +짝: `B06_Section/B06_Section_UI_Section_View.ts`(`detailChart` 처음 배율) · +`B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` homeScale) · `A00_Common/b_svg_zoom_pan.ts`. +실제 TS 를 `helper_266_detail_home.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_266_detail_home.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음_가로_반폭_15(out): + # 칸 맞춤이 가로를 넓혀도 처음 배율로 되좁힘 = −15 ~ +15 + case = out["global15"] + assert case["widened"] is True and case["homeScale"] > 1 + assert case["first"] == [-15, 15] + + +def test_개별_반폭_측점(out): + assert out["station10"]["first"] == [-10, 10] + + +def test_안_넓힌_칸_그대로(out): + case = out["tall15"] + assert case["widened"] is False and case["homeScale"] == 1 + assert case["first"] == [-15, 15] + + +@pytest.mark.parametrize("name", ["global15", "station10", "tall15"]) +def test_처음으로_같은_범위_줌_이동_그대로(out, name): + case = out[name] + assert case["reset"] == case["first"] # [⤢] + assert case["dblclick"] == case["first"] # 더블클릭 + assert case["wheelScale"] == pytest.approx(1.15) # 휠 줌 + assert case["panned"] is True # 가운데 단추 끌기 + assert case["minScale"] == 1 # 줄이면 칸 맞춤 전체까지 + + +def test_배선(): + view = _read("B06_Section_UI_Section_View.ts") + assert ( + "const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;" + in view + ) + assert "return { svg, axes, homeScale };" in view + # 반폭 = 개별 표시 반폭 › 좌측 「횡단 보기 설정」(카드와 같은 길) + assert ( + "effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth)" + in view + ) + src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split()) + assert "drawn.homeScale" in src and "homeScale, onChange" in src + assert "homeScale: drawn.homeScale" in src From fe2ed662680122cf6bc2f73db84e79109b7c67bc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:40:21 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(B09):=20264-2=20=EA=B3=B5=EA=B8=B0?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=85=88=20=EC=A7=9D=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=93=AC=EA=B8=B0=20=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 셈 짝(Py · TS) 새 옵션 — 착공일(그날부터 · A 날수 비례) · 기후 자료 고르기 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준(장비 시간 합) · 중복 C 끝수 · 마지막 달 비작업 - 원천 = 기후 셋(거창 25 · 울진 24 · 울진 소광 25 · 0에서 직접) · 단위당 장비 시간 합 - clean_schedule 새 칸(모르는 값 · 범위 밖 버림) - 시험 test_264_2 — 기본값 = 지금 결과 · 옵션마다 값 바뀜 · 거울 TS = Py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../draft/B09_Draft_Schedule_Calc.py | 127 ++++++++--- .../draft/B09_Draft_Schedule_Calc.ts | 140 +++++++++--- .../draft/B09_Draft_Schedule_Source.py | 76 ++++++- .../tester/test_264_2_b09_schedule_options.py | 210 ++++++++++++++++++ 4 files changed, 476 insertions(+), 77 deletions(-) create mode 100644 resources/tester/test_264_2_b09_schedule_options.py diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.py b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py index defcf5af0..7dd55204f 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Calc.py +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py @@ -1,6 +1,6 @@ """B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). -짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`). +짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · `test_264_2_b09_schedule_options.py`). 근거 = 실무 설계서 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) — 값 베끼기가 아니라 식만: 공기산출 설계내역서 항목 줄마다 작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) @@ -11,13 +11,19 @@ 단위당 자원 = 서버 역전개(`…_Source.unit_resources` · 호표 구성 1 단위) 반영률 = 다른 공종과 겹침(100 = 혼자 · 50 = 반 겹침 · 0 = 다 겹침) · 운반공 기본 0 소계 = ROUND(Σ 작업일수 + 기타, 0) · 총작업일수 = ROUND(준비 + 소계 + 정리, 0) - 공기집계 착공 연월부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) · + 공기집계 착공일부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) · 법정공휴일 B = 일요일 + 공휴일(대체 · 선거일 포함 · 사용자 덮개) · 중복 C = A × B ÷ 달력 · 비작업일수 = ROUNDDOWN(A + B − C, 0) · 작업가능일수 = 달력 − 비작업 준비 → 작업 → 정리 차례로 작업가능일수를 채움 · 다 쓴 달 = 그 달 비작업 전부 · 마지막 달(덜 쓴 달) = ROUNDUP(비작업 × 쓴 날 ÷ 작업가능일, 0) - 공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공(그 달 1일) + 공사기간 − 1 + 공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공일 + 공사기간 − 1 + 옵션(264 · user.schedule · 기본값 = 위 식 그대로) — 착공일(1일 아니면 첫 달 = 그날부터 · + A = 날수 비례) · 기후 자료(원천 climates 고르기 · 달마다 덮개가 이김) · + 쉬는 날(일 · 토일) · 공휴일 빼기(대체 · 선거일 · 노동절 · 제헌절 — 이름으로 가름) · + 투입 기본(장비 대 · 인력 인) · 반영률 기본(운반공 · 그 밖) · + 주 장비 기준(가장 긴 장비 · 장비 시간 합) · 중복 C 끝수(그대로 · 소수 1자리 반올림) · + 마지막 달 비작업(쓴 비율 올림 · 전부) """ from __future__ import annotations @@ -27,12 +33,26 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, round_half, rounddown, roundup -HOURS = 8 -CREW = 10 -PREP_DOCS = 2 -PREP_SETUP = 2 -TIDY = 4 -#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 0(운반 = 다른 공종과 겹침) +#: 사용자 값 기본(264 패널 · 표 칸 같은 값) +DEFAULTS = { + "hours": 8, + "units": 1, + "crew": 10, + "prep_docs": 2, + "prep_setup": 2, + "tidy": 4, + "etc": 0, + "rate_haul": 0, + "rate_base": 100, +} +#: 공휴일 빼기 갈래 — 이름에 든 글 +HOLIDAY_KINDS = { + "substitute": ("대체",), + "election": ("선거",), + "labor": ("노동절", "근로자"), + "constitution": ("제헌절",), +} +#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 운반공(= 다른 공종과 겹침) SKIP_TOP = ("4", "G") OVERLAP_TOP = ("3",) MAX_MONTHS = 120 @@ -42,18 +62,23 @@ def _top(section: Any) -> str: return str(section or "").split(".")[0] -def _cfg(user: dict[str, Any], key: str, default: float) -> float: +def _cfg(user: dict[str, Any], key: str) -> float: value = num(user.get(key)) - return default if value is None or value < 0 else value + return DEFAULTS[key] if value is None or value < 0 else value -def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hours: float) -> dict: +def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, base: dict) -> dict: + hours = base["hours"] qty = num(item.get("qty")) or 0.0 rate = num(cfg.get("rate")) if rate is None: - rate = 0.0 if _top(item.get("section")) in OVERLAP_TOP else 100.0 + rate = base["rate_haul"] if _top(item.get("section")) in OVERLAP_TOP else base["rate_base"] daily_in = num(cfg.get("daily")) machine = (unit or {}).get("machine") + total = (unit or {}).get("machine_sum") + if machine and base["sum"] and total and (num(total.get("hr")) or 0) > 0: + many = f" 외 {total['n'] - 1}종" if (total.get("n") or 1) > 1 else "" + machine = {**machine, "name": f"{machine.get('name') or ''}{many}", "hr": total["hr"]} labor = num((unit or {}).get("labor")) out = { "kind": "item", @@ -78,12 +103,12 @@ def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hour need = qty * rate / 100 / daily_in elif machine and (num(machine.get("hr")) or 0) > 0: hr = num(machine["hr"]) - units = num(cfg.get("units")) or 1.0 + units = num(cfg.get("units")) or base["units"] out.update(basis="장비", who=machine.get("name") or "", per=hr, put=units) out.update(q=1 / hr, daily=hours * units / hr) need = qty * rate / 100 * hr / (hours * units) elif labor is not None and labor > 0: - crew = num(cfg.get("crew")) or float(CREW) + crew = num(cfg.get("crew")) or base["crew"] out.update(basis="인력", who="인력", per=labor, put=crew, daily=crew / labor) need = qty * rate / 100 * labor / crew else: @@ -96,7 +121,15 @@ def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hour def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict: """공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리.""" - hours = _cfg(user, "hours", HOURS) or HOURS + hours = _cfg(user, "hours") or DEFAULTS["hours"] + base = { + "hours": hours, + "units": _cfg(user, "units") or DEFAULTS["units"], + "crew": _cfg(user, "crew") or DEFAULTS["crew"], + "rate_haul": min(_cfg(user, "rate_haul"), 100), + "rate_base": min(_cfg(user, "rate_base"), 100), + "sum": user.get("machine_basis") == "sum", + } units = src.get("units") or {} cfgs = user.get("rows") or {} rows: list[dict[str, Any]] = [] @@ -129,11 +162,11 @@ def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) "lv": head.get("level") or 1, } # fmt: skip ) - rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, hours)) - docs = _cfg(user, "prep_docs", PREP_DOCS) - setup = _cfg(user, "prep_setup", PREP_SETUP) - etc = _cfg(user, "etc", 0) - tidy = _cfg(user, "tidy", TIDY) + rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, base)) + docs = _cfg(user, "prep_docs") + setup = _cfg(user, "prep_setup") + etc = _cfg(user, "etc") + tidy = _cfg(user, "tidy") days = sum(r["days"] or 0 for r in rows if r["kind"] == "item") subtotal = round_half(days + etc, 0) missing = sum(1 for r in rows if r["kind"] == "item" and r["days"] is None) @@ -156,45 +189,70 @@ def _month_days(year: int, month: int) -> int: return (nxt - datetime.date(year, month, 1)).days -def _holidays(src: dict[str, Any], year: int, month: int) -> tuple[int, list[str]]: - """그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」).""" +def _counted(name: str, off: list[str]) -> bool: + """공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음.""" + for part in name.split(";"): + kinds = [k for k, words in HOLIDAY_KINDS.items() if any(w in part for w in words)] + if not any(k in off for k in kinds): + return True + return False + + +def _holidays( + src: dict[str, Any], year: int, month: int, first: int, user: dict[str, Any] +) -> tuple[int, list[str]]: + """그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」).""" head = f"{year:04d}-{month:02d}-" + off = user.get("holiday_off") or [] named = { - d: n for d, n in (src.get("holidays") or {}).get(str(year)) or [] if d.startswith(head) + d: n + for d, n in (src.get("holidays") or {}).get(str(year)) or [] + if d.startswith(head) and int(d[-2:]) >= first and _counted(n, off) } + rest = (5, 6) if user.get("rest") == "satsun" else (6,) count = 0 - for day in range(1, _month_days(year, month) + 1): + for day in range(first, _month_days(year, month) + 1): iso = f"{head}{day:02d}" - if datetime.date(year, month, day).weekday() == 6 or iso in named: + if datetime.date(year, month, day).weekday() in rest or iso in named: count += 1 return count, [f"{int(d[-2:])}일 {n}" for d, n in sorted(named.items())] +def climate_base(src: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: + """고른 기후 자료(원천 climates) · 없으면 원천 기본값.""" + return (src.get("climates") or {}).get(user.get("climate_set")) or src.get("climate") or {} + + def _climate(src: dict[str, Any], user: dict[str, Any], kind: str, month: int) -> float: mine = ((user.get("climate") or {}).get(kind) or [None] * 12)[month - 1] if num(mine) is not None: return num(mine) - return num(((src.get("climate") or {}).get(kind) or [0] * 12)[month - 1]) or 0.0 + return num((climate_base(src, user).get(kind) or [0] * 12)[month - 1]) or 0.0 def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -> dict[str, Any]: - """공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움.""" + """공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움.""" start = {**(src.get("start") or {}), **(user.get("start") or {})} year, month = int(start.get("year") or 2000), int(start.get("month") or 1) + first = min(max(int(start.get("day") or 1), 1), _month_days(year, month)) + begin = datetime.date(year, month, first) left = [max(p, 0) for p in parts] months: list[dict[str, Any]] = [] total = 0 while sum(left) > 0 or not months: if len(months) >= MAX_MONTHS: break - days = _month_days(year, month) + full = _month_days(year, month) + days = full - first + 1 rain = _climate(src, user, "rain", month) heat = _climate(src, user, "heat", month) - a = roundup(rain + heat, 1) - auto, names = _holidays(src, year, month) + a = roundup(rain + heat if first == 1 else (rain + heat) * days / full, 1) + auto, names = _holidays(src, year, month, first, user) mine = num((user.get("holiday") or {}).get(f"{year:04d}-{month:02d}")) b = mine if mine is not None else auto c = a * b / days + if user.get("c_round") == "round1": + c = round_half(c, 1) off = rounddown(a + b - c, 0) avail = days - off cap = max(avail, 0) @@ -208,7 +266,7 @@ def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) - used = sum(use) if cap <= 0: off_used = days - elif used >= cap: + elif used >= cap or user.get("last_off") == "all": off_used = off else: off_used = roundup(off * used / cap, 0) @@ -237,11 +295,10 @@ def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) - } # fmt: skip ) year, month = (year + 1, 1) if month == 12 else (year, month + 1) - first = months[0] - begin = datetime.date(first["year"], first["month"], 1) + first = 1 end = begin + datetime.timedelta(days=max(total, 1) - 1) return { - "start": {"year": first["year"], "month": first["month"]}, + "start": {"year": begin.year, "month": begin.month, "day": begin.day}, "months": months, "days": total, "months_n": round_half(total / 30, 1), diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts index 0ae43dce9..501a98915 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts @@ -1,35 +1,59 @@ /* ============================================================================= * B09_Draft_Schedule_Calc.ts * B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). - * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`) — 식 설명은 그쪽 머리. + * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · + * `test_264_2_b09_schedule_options.py`) — 식 설명은 그쪽 머리. * 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력 - * 공기집계 = 착공 연월부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 + * 공기집계 = 착공일부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 + * 옵션(264) = 착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준 · + * 중복 C 끝수 · 마지막 달 비작업(기본값 = 위 식 그대로) * ========================================================================== */ import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base"; -const HOURS = 8; -const CREW = 10; -const PREP_DOCS = 2; -const PREP_SETUP = 2; -const TIDY = 4; +/** 사용자 값 기본(264 패널 · 표 칸 같은 값) */ +export const DEFAULTS: Record = { + hours: 8, + units: 1, + crew: 10, + prep_docs: 2, + prep_setup: 2, + tidy: 4, + etc: 0, + rate_haul: 0, + rate_base: 100, +}; +/** 공휴일 빼기 갈래 — 이름에 든 글 */ +export const HOLIDAY_KINDS: Record = { + substitute: ["대체"], + election: ["선거"], + labor: ["노동절", "근로자"], + constitution: ["제헌절"], +}; const SKIP_TOP = ["4", "G"]; const OVERLAP_TOP = ["3"]; const MAX_MONTHS = 120; const top = (section: unknown): string => String(section ?? "").split(".")[0]; -function cfg(user: Row, key: string, fallback: number): number { +function cfg(user: Row, key: string): number { const value = num(user[key]); - return value === null || value < 0 ? fallback : value; + return value === null || value < 0 ? DEFAULTS[key] : value; } -function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { +function workRow(item: Row, unit: Row | undefined, c: Row, base: Row): Row { + const hours: number = base.hours; const qty = num(item.qty) ?? 0; let rate = num(c.rate); - if (rate === null) rate = OVERLAP_TOP.includes(top(item.section)) ? 0 : 100; + if (rate === null) + rate = OVERLAP_TOP.includes(top(item.section)) ? base.rate_haul : base.rate_base; const dailyIn = num(c.daily); - const machine = unit?.machine as Row | null | undefined; + let machine = unit?.machine as Row | null | undefined; + const total = unit?.machine_sum as Row | null | undefined; + if (machine && base.sum && total && (num(total.hr) ?? 0) > 0) { + const many = (total.n || 1) > 1 ? ` 외 ${total.n - 1}종` : ""; + machine = { ...machine, name: `${machine.name || ""}${many}`, hr: total.hr }; + } const labor = num(unit?.labor); const out: Row = { kind: "item", @@ -55,12 +79,12 @@ function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { need = (qty * rate) / 100 / dailyIn; } else if (machine && (num(machine.hr) ?? 0) > 0) { const hr = num(machine.hr) as number; - const units = num(c.units) || 1; + const units = num(c.units) || base.units; Object.assign(out, { basis: "장비", who: machine.name || "", per: hr, put: units }); Object.assign(out, { q: 1 / hr, daily: (hours * units) / hr }); need = (((qty * rate) / 100) * hr) / (hours * units); } else if (labor !== null && labor > 0) { - const crew = num(c.crew) || CREW; + const crew = num(c.crew) || base.crew; Object.assign(out, { basis: "인력", who: "인력", per: labor, put: crew, daily: crew / labor }); need = (((qty * rate) / 100) * labor) / crew; } else { @@ -74,7 +98,15 @@ function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { /** 공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리 */ export function workTable(src: Row, billRows: Row[], user: Row): Row { - const hours = cfg(user, "hours", HOURS) || HOURS; + const hours = cfg(user, "hours") || DEFAULTS.hours; + const base: Row = { + hours, + units: cfg(user, "units") || DEFAULTS.units, + crew: cfg(user, "crew") || DEFAULTS.crew, + rate_haul: Math.min(cfg(user, "rate_haul"), 100), + rate_base: Math.min(cfg(user, "rate_base"), 100), + sum: user.machine_basis === "sum", + }; const units: Row = src.units || {}; const cfgs: Row = user.rows || {}; const rows: Row[] = []; @@ -101,12 +133,12 @@ export function workTable(src: Row, billRows: Row[], user: Row): Row { p[1] = true; rows.push({ kind: "head", key: p[0].key, name: p[0].name || "", lv: p[0].level || 1 }); } - rows.push(workRow(row, units[key], cfgs[key] || {}, hours)); + rows.push(workRow(row, units[key], cfgs[key] || {}, base)); } - const docs = cfg(user, "prep_docs", PREP_DOCS); - const setup = cfg(user, "prep_setup", PREP_SETUP); - const etc = cfg(user, "etc", 0); - const tidy = cfg(user, "tidy", TIDY); + const docs = cfg(user, "prep_docs"); + const setup = cfg(user, "prep_setup"); + const etc = cfg(user, "etc"); + const tidy = cfg(user, "tidy"); const items = rows.filter((r) => r.kind === "item"); const days = items.reduce((s: number, r) => s + (r.days || 0), 0); const subtotal = roundHalf(days + etc, 0); @@ -129,16 +161,38 @@ const monthDays = (year: number, month: number): number => const pad = (n: number): string => String(n).padStart(2, "0"); -/** 그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ -function holidays(src: Row, year: number, month: number): [number, string[]] { +/** 공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음 */ +function counted(name: string, off: string[]): boolean { + for (const part of name.split(";")) { + const kinds = Object.keys(HOLIDAY_KINDS).filter((k) => + HOLIDAY_KINDS[k].some((w) => part.includes(w)), + ); + if (!kinds.some((k) => off.includes(k))) return true; + } + return false; +} + +/** 그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ +function holidays( + src: Row, + year: number, + month: number, + first: number, + user: Row, +): [number, string[]] { const head = `${String(year).padStart(4, "0")}-${pad(month)}-`; + const off: string[] = user.holiday_off || []; const named = new Map( - ((src.holidays || {})[String(year)] || []).filter(([d]: [string]) => d.startsWith(head)), + ((src.holidays || {})[String(year)] || []).filter( + ([d, n]: [string, string]) => + d.startsWith(head) && Number(d.slice(-2)) >= first && counted(n, off), + ), ); + const rest = user.rest === "satsun" ? [6, 0] : [0]; let count = 0; - for (let day = 1; day <= monthDays(year, month); day += 1) { - const sunday = new Date(Date.UTC(year, month - 1, day)).getUTCDay() === 0; - if (sunday || named.has(`${head}${pad(day)}`)) count += 1; + for (let day = first; day <= monthDays(year, month); day += 1) { + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + if (rest.includes(weekday) || named.has(`${head}${pad(day)}`)) count += 1; } const names = [...named.entries()] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) @@ -146,30 +200,38 @@ function holidays(src: Row, year: number, month: number): [number, string[]] { return [count, names]; } +/** 고른 기후 자료(원천 climates) · 없으면 원천 기본값 */ +export const climateBase = (src: Row, user: Row): Row => + (src.climates || {})[user.climate_set] || src.climate || {}; + function climate(src: Row, user: Row, kind: string, month: number): number { const mine = num(((user.climate || {})[kind] || [])[month - 1]); if (mine !== null) return mine; - return num(((src.climate || {})[kind] || [])[month - 1]) ?? 0; + return num((climateBase(src, user)[kind] || [])[month - 1]) ?? 0; } -/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움 */ +/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공일부터 채움 */ export function monthTable(src: Row, user: Row, parts: number[]): Row { const start: Row = { ...(src.start || {}), ...(user.start || {}) }; let year = Math.trunc(Number(start.year) || 2000); let month = Math.trunc(Number(start.month) || 1); + let first = Math.min(Math.max(Math.trunc(Number(start.day) || 1), 1), monthDays(year, month)); + const begin = Date.UTC(year, month - 1, first); const left = parts.map((p) => Math.max(p, 0)); const months: Row[] = []; let total = 0; while (left.reduce((s, v) => s + v, 0) > 0 || !months.length) { if (months.length >= MAX_MONTHS) break; - const days = monthDays(year, month); + const full = monthDays(year, month); + const days = full - first + 1; const rain = climate(src, user, "rain", month); const heat = climate(src, user, "heat", month); - const a = roundup(rain + heat, 1); - const [auto, names] = holidays(src, year, month); + const a = roundup(first === 1 ? rain + heat : ((rain + heat) * days) / full, 1); + const [auto, names] = holidays(src, year, month, first, user); const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]); const b = mine !== null ? mine : auto; - const c = (a * b) / days; + let c = (a * b) / days; + if (user.c_round === "round1") c = roundHalf(c, 1); const off = rounddown(a + b - c, 0); const avail = days - off; const cap = Math.max(avail, 0); @@ -182,7 +244,12 @@ export function monthTable(src: Row, user: Row, parts: number[]): Row { room -= take; } const used = use[0] + use[1] + use[2]; - const offUsed = cap <= 0 ? days : used >= cap ? off : roundup((off * used) / cap, 0); + const offUsed = + cap <= 0 + ? days + : used >= cap || user.last_off === "all" + ? off + : roundup((off * used) / cap, 0); const span = used + offUsed; total += span; months.push({ @@ -209,11 +276,12 @@ export function monthTable(src: Row, user: Row, parts: number[]): Row { year += 1; month = 1; } else month += 1; + first = 1; } - const first = months[0]; - const end = new Date(Date.UTC(first.year, first.month - 1, 1) + (Math.max(total, 1) - 1) * 864e5); + const from = new Date(begin); + const end = new Date(begin + (Math.max(total, 1) - 1) * 864e5); return { - start: { year: first.year, month: first.month }, + start: { year: from.getUTCFullYear(), month: from.getUTCMonth() + 1, day: from.getUTCDate() }, months, days: total, months_n: roundHalf(total / 30, 1), diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Source.py b/B09_Estimation/draft/B09_Draft_Schedule_Source.py index 3f9e7972c..ad206aee7 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Source.py +++ b/B09_Estimation/draft/B09_Draft_Schedule_Source.py @@ -9,12 +9,14 @@ 공휴일 = `B09_Draft_Schedule_Holidays.json`(2024~2040 · 법정 · 대체 · 확정 선거일 — 공개 `holidays` 목록 0.106 에서 뽑음 · 2027 이후 선거일은 날짜가 안 정해져 뺌 · 달마다 사용자 덮개) 기후 기본값 = 참고 설계서(25 거창) 월별 강수일(일강수량 5mm 이상) · 혹서일(33℃ 이상) — - 지역마다 사용자 고침 -사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수) + 고르기(264) = 참고 설계서 셋(거창 25 · 울진 24 · 울진 소광 25 「공사기간산정표」) · 0에서 직접 · + 달마다 사용자 덮개가 이김 +사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수 · 고르기 칸은 목록 안) """ from __future__ import annotations +import calendar import datetime import json from pathlib import Path @@ -22,7 +24,7 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines -from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule +from B09_Estimation.draft.B09_Draft_Schedule_Calc import HOLIDAY_KINDS, compute_schedule HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json") CLIMATE = { @@ -30,8 +32,50 @@ CLIMATE = { "rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2], "heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0], } +#: 기후 자료 고르기(264) — 첫 줄 = 기본 · rain_note · heat_note = 공기집계 비고 +CLIMATES = { + "geochang25": { + **CLIMATE, + "name": "거창 25 참고", + "rain_note": "일강수량 5mm 이상", + "heat_note": "33℃ 이상", + }, + "uljin24": { + "name": "울진 24 참고", + "label": "경북 울진(참고 설계서 24 · 강수 = 일강수량 5mm 이상 · 혹서 없음)", + "rain": [0.6, 1.3, 2.9, 4.3, 3.2, 3.9, 7.4, 8.3, 4, 2.8, 2.6, 1.4], + "heat": [0] * 12, + "rain_note": "일강수량 5mm 이상", + "heat_note": "혹서 없음(참고 설계서)", + }, + "sogwang25": { + "name": "울진 소광 25 참고", + "label": "경북 울진 소광(참고 설계서 25 · 강수 = 일강수량 10mm 이상 · 혹서 없음)", + "rain": [2.1, 2, 3.3, 4, 2.1, 3.7, 6.3, 7.9, 5, 4.2, 3.6, 1.6], + "heat": [0] * 12, + "rain_note": "일강수량 10mm 이상", + "heat_note": "혹서 없음(참고 설계서)", + }, + "zero": { + "name": "0에서 직접", + "label": "직접 입력(달마다 강수 · 혹서 칸)", + "rain": [0] * 12, + "heat": [0] * 12, + "rain_note": "직접 입력", + "heat_note": "직접 입력", + }, +} START_MONTH = 3 -NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy") +NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy", "rate_haul", "rate_base") +POSITIVE = ("units", "crew") +#: 고르기 칸 — 기본값이 첫째 +CHOICES = { + "climate_set": tuple(CLIMATES), + "rest": ("sun", "satsun"), + "machine_basis": ("max", "sum"), + "c_round": ("none", "round1"), + "last_off": ("ratio", "all"), +} def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]: @@ -54,7 +98,13 @@ def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]: name = " ".join(t for t in (src.get("name"), src.get("spec")) if t) or mkey machine = {"key": mkey, "name": name, "hr": hr} labor = sum((used.get("labor") or {}).values()) - out[row["key"]] = {"machine": machine, "labor": labor if labor > 0 else None} + # 264 주 장비 기준 「장비 시간 합」 = 차례로 일하는 장비 · n = 장비 수 + spent = [hr for hr in hours.values() if hr > 0] + out[row["key"]] = { + "machine": machine, + "labor": labor if labor > 0 else None, + "machine_sum": {"hr": sum(spent), "n": len(spent)} if machine else None, + } return out @@ -71,6 +121,7 @@ def schedule_inputs(sheets: dict[str, Any], today: datetime.date | None = None) "units": unit_resources(sheets), "holidays": holiday_table(), "climate": CLIMATE, + "climates": CLIMATES, "start": {"year": today.year + 1, "month": START_MONTH}, } @@ -100,12 +151,25 @@ def clean_schedule(raw: Any) -> dict[str, Any]: out: dict[str, Any] = {} for field in NUMBERS: v = num(raw.get(field)) - if v is not None and 0 <= v <= 1000: + if v is not None and 0 <= v <= (100 if field.startswith("rate") else 1000): out[field] = v + for field in POSITIVE: + if (v := _positive(raw.get(field))) is not None and v <= 1e4: + out[field] = v + for field, allowed in CHOICES.items(): + if raw.get(field) in allowed: + out[field] = raw[field] + off = raw.get("holiday_off") if isinstance(raw.get("holiday_off"), list) else [] + kinds = [k for k in HOLIDAY_KINDS if k in off] + if kinds: + out["holiday_off"] = kinds start = raw.get("start") if isinstance(raw.get("start"), dict) else {} year, month = num(start.get("year")), num(start.get("month")) if year and month and 2000 <= year <= 2100 and 1 <= month <= 12: out["start"] = {"year": int(year), "month": int(month)} + day = num(start.get("day")) + if day and 1 < day <= calendar.monthrange(int(year), int(month))[1]: + out["start"]["day"] = int(day) climate = raw.get("climate") if isinstance(raw.get("climate"), dict) else {} kept = {k: v for k in ("rain", "heat") if (v := _twelve(climate.get(k)))} if kept: diff --git a/resources/tester/test_264_2_b09_schedule_options.py b/resources/tester/test_264_2_b09_schedule_options.py new file mode 100644 index 000000000..2266968f5 --- /dev/null +++ b/resources/tester/test_264_2_b09_schedule_options.py @@ -0,0 +1,210 @@ +"""PLAN 264 — B09 공기 옵션(착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 · 준비 정리 · 반영률 · +주 장비 기준 · 중복 C 끝수 · 마지막 달) — 옵션마다 결과가 바뀜 · 다듬기 · 거울 TS = Py · 패널 훅.""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402 +from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402 + CLIMATES, + clean_schedule, + schedule_inputs, + unit_resources, +) +from test_252_1_b09_schedule import GEOCHANG, SRC, _bill, _ref_user, _ts_schedule # noqa: E402 + +SRC2 = {**SRC, "climates": CLIMATES} +UNITS = { + "a": { + "machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066}, + "labor": 0.01, + "machine_sum": {"hr": 0.0086, "n": 2}, + }, + "b": {"machine": None, "labor": 1.5}, +} +BILL = [ + {"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2}, + {"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2}, + {"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2}, +] +SRC3 = {**SRC2, "units": UNITS} +YEAR_2026 = {"start": {"year": 2026, "month": 1}, "tidy": 400} + + +def _plain(): + """거창 재현 사용자 값에서 법정공휴일 덮개만 뺌(자동 B 를 봄).""" + user = _ref_user() + del user["holiday"] + return user + + +def _cal(user, src=SRC2, bill=None): + return compute_schedule(src, _bill(GEOCHANG) if bill is None else bill, user)["calendar"] + + +def _rows(user): + return {r["key"]: r for r in compute_schedule(SRC3, BILL, user)["work"]["rows"]} + + +def test_기본값_그대로(): + base = compute_schedule(SRC, _bill(GEOCHANG), _ref_user()) + assert compute_schedule(SRC2, _bill(GEOCHANG), _ref_user()) == base + picked = {**_ref_user(), "climate_set": "geochang25", "rest": "sun", "machine_basis": "max", + "c_round": "none", "last_off": "ratio", "holiday_off": [], "units": 1, "crew": 10, + "rate_haul": 0, "rate_base": 100, "hours": 8, "prep_docs": 2, "prep_setup": 2, + "tidy": 4} # fmt: skip + assert compute_schedule(SRC2, _bill(GEOCHANG), picked) == base + assert (base["work"]["total"], base["calendar"]["days"]) == (95, 136) + + +def test_1_착공일_그날부터(): + user = {**_plain(), "start": {"year": 2026, "month": 3, "day": 15}} + cal = _cal(user) + first = cal["months"][0] + # 3.15 ~ 3.31 = 17일 · A = ROUNDUP(3.6 × 17 ÷ 31, 1) = 2.0 · 일요일 15 · 22 · 29 + assert (first["days"], first["a"], first["b_auto"]) == (17, 2.0, 3) + assert cal["start"] == {"year": 2026, "month": 3, "day": 15} + assert cal["end"] > _cal(_plain())["end"] + assert cal["months"][1]["days"] == 30 # 둘째 달부터 달 전체 + + +def test_2_기후_자료_고르기(): + m = _cal({**_plain(), "climate_set": "uljin24"})["months"][0] + assert (m["rain"], m["heat"]) == (2.9, 0) + m = _cal({**_plain(), "climate_set": "sogwang25"})["months"][5 - 3] + assert (m["rain"], m["heat"]) == (2.1, 0) # 5월 + assert all(m["a"] == 0 for m in _cal({**_plain(), "climate_set": "zero"})["months"]) + user = {**_plain(), "climate_set": "uljin24", "climate": {"rain": [9] * 12}} + assert _cal(user)["months"][0]["rain"] == 9 # 달마다 덮개가 이김 + + +def test_3_쉬는_날_토일(): + base = _cal(_plain())["months"][0]["b_auto"] + assert _cal({**_plain(), "rest": "satsun"})["months"][0]["b_auto"] == base + 4 # 2026.3 토 4번 + + +def test_4_공휴일_빼기(): + def auto(off): + cal = _cal({**YEAR_2026, "holiday_off": off}, bill=[]) + return [m["b_auto"] for m in cal["months"][:12]] + + full = auto([]) + assert full == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5] + assert auto(["labor"])[4] == 7 # 5.1 노동절 + assert auto(["substitute"])[4] == 7 # 5.25 부처님오신날 대체 + assert auto(["election"])[5] == 5 # 6.3 지방선거 + assert auto(["constitution"])[6] == 4 # 7.17 제헌절 + may = _cal({**YEAR_2026, "holiday_off": ["labor"]}, bill=[])["months"][4]["names"] + assert not any("노동절" in n for n in may) + + +def test_5_일_작업시간(): + assert _rows({})["a"]["days"] == 0.9 + assert _rows({"hours": 4})["a"]["days"] == 1.7 # 1000 × 0.0066 ÷ 4 = 1.65 + + +def test_6_투입_기본(): + rows = _rows({"units": 2, "crew": 5}) + assert (rows["a"]["put"], rows["a"]["days"]) == (2, 0.5) # 0.4125 → 0.5 + assert (rows["b"]["put"], rows["b"]["days"]) == (5, 24) + rows = _rows({"crew": 5, "rows": {"b": {"crew": 20}}}) + assert rows["b"]["days"] == 6 # 줄마다 덮개가 이김 + + +def test_7_준비_정리_기타(): + work = compute_schedule(SRC3, BILL, {"prep_docs": 3, "prep_setup": 1, "tidy": 6, "etc": 2}) + w = work["work"] + assert (w["prep"], w["tidy"], w["etc"]) == (4, 6, 2) + assert w["total"] == 4 + round(0.9 + 12 + 0 + 2) + 6 + + +def test_8_반영률_기본(): + rows = _rows({"rate_haul": 100, "rate_base": 50}) + assert (rows["h"]["rate"], rows["a"]["rate"], rows["b"]["rate"]) == (100, 50, 50) + assert rows["b"]["days"] == 6 + assert _rows({"rate_base": 50, "rows": {"b": {"rate": 100}}})["b"]["days"] == 12 + + +def test_9_주_장비_장비_시간_합(): + a = _rows({"machine_basis": "sum"})["a"] + assert (a["per"], a["who"], a["days"]) == (0.0086, "굴착기 0.7㎥ 외 1종", 1.1) # 8.6 ÷ 8 + assert _rows({})["a"]["per"] == 0.0066 + + +def test_10_중복일수_끝수(): + base = _cal(_plain())["months"] + got = _cal({**_plain(), "c_round": "round1"})["months"] + assert all(m["c"] == round(m["c"], 1) for m in got) + assert [m["c"] for m in got] != [m["c"] for m in base] + + +def test_11_마지막_달_비작업_전부(): + base = _cal(_ref_user())["months"][-1] + last = _cal({**_ref_user(), "last_off": "all"}) + assert (base["off_used"], base["off"]) == (7, 15) + assert last["months"][-1]["off_used"] == 15 and last["days"] == 136 + 8 + + +def test_다듬기_새_칸(): + raw = { + "units": 2, "crew": 0, "rate_haul": 50, "rate_base": 120, + "climate_set": "uljin24", "rest": "sat", "machine_basis": "sum", + "c_round": "round1", "last_off": "x", "holiday_off": ["labor", "junk", "election"], + "start": {"year": 2027, "month": 2, "day": 30}, + } # fmt: skip + assert clean_schedule(raw) == { + "rate_haul": 50, + "units": 2, + "climate_set": "uljin24", + "machine_basis": "sum", + "c_round": "round1", + "holiday_off": ["election", "labor"], + "start": {"year": 2027, "month": 2}, + } + got = clean_schedule({"start": {"year": 2027, "month": 3, "day": 15}, "holiday_off": "x"}) + assert got == {"start": {"year": 2027, "month": 3, "day": 15}} + + +def test_원천_기후_셋_장비_합(): + sheets = { + "bill": {"rows": [{"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}}]}, + "sg": [{"key": "S1", "lines": [ + {"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066}, + {"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002}, + ]}], + "machine": [], + } # fmt: skip + unit = unit_resources(sheets)["earth:4"] + assert unit["machine_sum"]["n"] == 2 and round(unit["machine_sum"]["hr"], 6) == 0.0086 + got = schedule_inputs(sheets) + assert list(got["climates"]) == ["geochang25", "uljin24", "sogwang25", "zero"] + assert got["climates"]["geochang25"]["rain"] == got["climate"]["rain"] + + +def test_거울_TS_옵션(): + cases = [ + (SRC2, _bill(GEOCHANG), {**_plain(), "start": {"year": 2026, "month": 3, "day": 15}, + "climate_set": "sogwang25", "rest": "satsun", + "holiday_off": ["substitute", "constitution"], + "c_round": "round1", "last_off": "all"}), + (SRC3, BILL, {"machine_basis": "sum", "units": 3, "crew": 7, "rate_haul": 30, + "rate_base": 80, "hours": 9, "prep_docs": 1, "tidy": 2, "etc": 1, + "climate_set": "zero", "start": {"year": 2027, "month": 12, "day": 31}}), + ] # fmt: skip + for src, bill, user in cases: + py = json.loads(json.dumps(compute_schedule(src, bill, user), ensure_ascii=False)) + assert _ts_schedule(src, bill, user) == py + + +def test_패널_훅_draft_없으면_빈칸(): + tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text("utf-8") + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + ui = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_UI.ts").read_text("utf-8") + assert "{ builders: {}, extend: [], panels: [] }" in tabs # draft 없으면 카드 없음 + assert "...DRAFT.panels.map((make) => make(() => user, setUser))" in shell + assert "DRAFT.panels.push(" in ui From 0db53a9032437ae69ead184e039d38ed7a6d9d61 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:40:29 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(B09):=20264-1=20=EC=99=BC=EC=AA=BD?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=E3=80=8C=EA=B3=B5=EA=B8=B0=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=E3=80=8D=20=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - draft 새 파일 B09_Draft_Schedule_Panel — 착공일 · 기후 자료 · 쉬는 날 · 공휴일 넣기 · 일 작업시간 · 투입 기본 · 준비 · 정리 · 기타 · 반영률 · 주 장비 기준 · 중복 C 끝수 · 마지막 달 - 값 = user.schedule(캐시 → [저장]) · 표 칸과 같은 값 · 셈마다 카드 맞춤 - 훅 = DRAFT.panels(기본 빈 목록) · Shell 「데이터 점검」 아래 한 줄 - 공기집계 비고 = 고른 옵션을 따름 · 착공 칸 「연.월.일」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B09_Estimation/B09_Estimation_UI_Shell.ts | 2 +- B09_Estimation/B09_Estimation_UI_Tabs.ts | 6 +- .../draft/B09_Draft_Schedule_Panel.ts | 215 ++++++++++++++++++ B09_Estimation/draft/B09_Draft_Schedule_UI.ts | 55 ++++- 4 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Panel.ts diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index d1f6797ea..474e7bcf1 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -370,7 +370,7 @@ export function renderB09Estimation(root: HTMLElement): void { actions.append(save, reset); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; - left.append(section, actions); + left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); attachCollapsible(left); const layout = createWorkflowLayout({ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index dbaa1c443..97d937b6e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -122,11 +122,13 @@ export interface TabTable { export type TabBuild = (ctx: TabContext) => TabTable | string; /** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — - * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) */ + * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) · + * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */ export const DRAFT: { builders: Record; extend: Array<(payload: Row, user: Row, sheets: Row) => void>; -} = { builders: {}, extend: [] }; + panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; +} = { builders: {}, extend: [], panels: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts new file mode 100644 index 000000000..379a59b63 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts @@ -0,0 +1,215 @@ +/* ============================================================================= + * B09_Draft_Schedule_Panel.ts + * B09 왼쪽 패널 「공기 옵션」 카드(PLAN 264-1 · 사용자 164-2 · 초안 — draft 폴더째 지우면 사라짐). + * 등록 = `B09_Draft_Schedule_UI` 가 DRAFT.panels(카드) · DRAFT.extend(값 맞춤)에 넣음 — + * 본 코드 훅 = `…_UI_Shell` 이 「데이터 점검」 아래 · [저장] 묶음 위에 붙이는 한 줄 + * 값 = user.schedule(캐시 → [저장] 한 번에) · 공기산출 · 공기집계 표 칸과 같은 값(어디서 고쳐도 한 벌) + * 꼴 = 좌측 패널 공용(B05/B06 「구조물 배치」 칸 — 라벨 위 · 입력 아래 · 두 칸 줄 · 체크) + * ========================================================================== */ + +import "../../B05_Profile/B05_Profile_UI_Style.css"; +import { num, type Row } from "../B09_Estimation_Calc_Base"; +import { parseNumber } from "../B09_Estimation_UI_Tabs"; +import { DEFAULTS } from "./B09_Draft_Schedule_Calc"; + +type Sync = (mine: Row, src: Row) => void; + +const HOLIDAY_LABELS: Array<[string, string]> = [ + ["substitute", "대체공휴일"], + ["election", "선거일"], + ["labor", "근로자의 날(노동절)"], + ["constitution", "제헌절"], +]; +const CHOICES: Record> = { + rest: [ + ["sun", "일요일만"], + ["satsun", "토 · 일"], + ], + machine_basis: [ + ["max", "단위당 시간이 가장 긴 장비"], + ["sum", "장비 시간 합(차례로 일하는 장비)"], + ], + c_round: [ + ["none", "그대로"], + ["round1", "소수 1자리 반올림"], + ], + last_off: [ + ["ratio", "쓴 비율만큼 올림"], + ["all", "그 달 비작업 전부"], + ], +}; + +/** 지금 화면의 카드 맞춤 — 셈마다 DRAFT.extend 가 부름(카드를 다시 만들면 새 카드로) */ +let syncNow: Sync | null = null; + +export function syncSchedulePanel(payload: Row, user: Row): void { + syncNow?.(user.schedule || {}, payload.inputs?.schedule || {}); +} + +function field(label: string, input: HTMLElement): HTMLLabelElement { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__field"; + const caption = document.createElement("span"); + caption.textContent = label; + wrapper.append(caption, input); + return wrapper; +} + +function pair(...items: HTMLElement[]): HTMLDivElement { + const row = document.createElement("div"); + row.className = "b05-route__field-row"; + row.append(...items); + return row; +} + +export function createSchedulePanel(user: () => Row, setUser: (next: Row) => void): HTMLElement { + const syncs: Sync[] = []; + const change = (fn: (s: Row) => void): void => { + const next: Row = structuredClone(user().schedule || {}); + fn(next); + setUser({ ...user(), schedule: next }); + }; + + /** 수 칸 — 빈칸 · 기본값 = 지움(기본) · 범위 밖 = 되돌림 */ + const number = (key: string, label: string, min: number, max: number): HTMLLabelElement => { + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = "any"; + input.dataset.b09Schedule = key; + let last: Row = {}; + syncs.push((mine) => { + last = mine; + input.value = String(num(mine[key]) ?? DEFAULTS[key]); + }); + input.addEventListener("change", () => { + const v = parseNumber(input.value); + if (v === undefined || (v !== null && (v < min || v > max))) { + input.value = String(num(last[key]) ?? DEFAULTS[key]); + return; + } + change((s) => { + if (v === null || v === DEFAULTS[key]) delete s[key]; + else s[key] = v; + }); + }); + return field(label, input); + }; + + /** 고르기 칸 — 첫째 = 기본(고르면 지움) · options 없으면 원천에서(기후 자료) */ + const choose = ( + key: string, + label: string, + options?: Array<[string, string]>, + ): HTMLLabelElement => { + const select = document.createElement("select"); + select.dataset.b09Schedule = key; + let values: string[] = []; + syncs.push((mine, src) => { + const list = + options ?? + Object.entries((src.climates || {}) as Row).map(([id, c]): [string, string] => [ + id, + c.name || id, + ]); + if (list.map(([v]) => v).join() !== values.join()) { + values = list.map(([v]) => v); + select.replaceChildren(...list.map(([v, text]) => new Option(text, v))); + } + select.value = values.includes(mine[key]) ? mine[key] : (values[0] ?? ""); + }); + select.addEventListener("change", () => + change((s) => { + if (select.value === values[0]) delete s[key]; + else s[key] = select.value; + }), + ); + return field(label, select); + }; + + const start = document.createElement("input"); + start.type = "date"; + start.dataset.b09Schedule = "start"; + syncs.push((mine, src) => { + const at: Row = { ...(src.start || {}), ...(mine.start || {}) }; + const pad = (n: unknown): string => String(Number(n) || 1).padStart(2, "0"); + start.value = at.year ? `${at.year}-${pad(at.month)}-${pad(at.day)}` : ""; + }); + start.addEventListener("change", () => { + const hit = /^(\d{4})-(\d{2})-(\d{2})$/.exec(start.value); + change((s) => { + if (!hit) return void delete s.start; + const day = Number(hit[3]); + s.start = { year: Number(hit[1]), month: Number(hit[2]), ...(day > 1 ? { day } : {}) }; + }); + }); + + const checks = HOLIDAY_LABELS.map(([kind, label]) => { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__check"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.dataset.b09Holiday = kind; + syncs.push((mine) => { + input.checked = !(mine.holiday_off || []).includes(kind); + }); + input.addEventListener("change", () => + change((s) => { + const off = HOLIDAY_LABELS.map(([k]) => k).filter((k) => + k === kind ? !input.checked : (s.holiday_off || []).includes(k), + ); + if (off.length) s.holiday_off = off; + else delete s.holiday_off; + }), + ); + wrapper.append(input, document.createTextNode(label)); + return wrapper; + }); + const holidayBox = document.createElement("div"); + holidayBox.className = "b05-route__field"; + const holidayCaption = document.createElement("span"); + holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)"; + holidayBox.append(holidayCaption, ...checks); + + const body = document.createElement("div"); + body.className = "b05-route__panel-body"; + body.append( + field("착공일", start), + choose("climate_set", "기후 자료(달마다 덮개는 공기집계 표)"), + choose("rest", "쉬는 날", CHOICES.rest), + holidayBox, + number("hours", "일 작업시간(시간)", 0.5, 24), + pair( + number("units", "장비 기본 대수", 0.001, 1e4), + number("crew", "인력 기본 인원", 0.001, 1e4), + ), + pair( + number("prep_docs", "서류 · 착수 준비(일)", 0, 1000), + number("prep_setup", "공종별 준비(일)", 0, 1000), + ), + pair(number("tidy", "정리(일)", 0, 1000), number("etc", "기타 · 현장여건(일)", 0, 1000)), + pair( + number("rate_haul", "반영률 운반공(%)", 0, 100), + number("rate_base", "반영률 그 밖(%)", 0, 100), + ), + choose("machine_basis", "주 장비 기준", CHOICES.machine_basis), + choose("c_round", "중복일수 C 끝수", CHOICES.c_round), + choose("last_off", "마지막 달 비작업일", CHOICES.last_off), + ); + const section = document.createElement("section"); + section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; + section.dataset.b09SchedulePanel = ""; + section.hidden = true; // 원천(서버 공기 자료)이 오면 보임 + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = "공기 옵션"; + section.append(heading, body); + + syncNow = (mine, src) => { + section.hidden = !src.climates; + for (const sync of syncs) sync(mine, src); + }; + syncNow(user().schedule || {}, {}); + return section; +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts index ad39247d6..45f700bdf 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts @@ -4,6 +4,7 @@ * 등록 = 이 파일을 `…_UI_Shell` 이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 TABS · TAB_GROUPS · * DRAFT(탭 짜기 · 셈 곁)에 넣음 — draft 폴더째 지우면 탭 · 셈이 함께 사라짐 * 셈 = `B09_Draft_Schedule_Calc`(TS 짝 · 조작 즉시) · 원천 = 서버 `inputs.schedule` · 값 = `user.schedule` + * 왼쪽 「공기 옵션」 카드 = `B09_Draft_Schedule_Panel`(264-1 · 같은 값 · 비고는 고른 옵션을 따름) * 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 / * 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동) * ========================================================================== */ @@ -21,7 +22,8 @@ import { type TabId, type TabTable, } from "../B09_Estimation_UI_Tabs"; -import { computeSchedule } from "./B09_Draft_Schedule_Calc"; +import { climateBase, computeSchedule } from "./B09_Draft_Schedule_Calc"; +import { createSchedulePanel, syncSchedulePanel } from "./B09_Draft_Schedule_Panel"; const SUM = "schedule_sum" as TabId; const CALC = "schedule_calc" as TabId; @@ -185,6 +187,35 @@ const LINES: Array<[string, string, string, string]> = [ ["span", "공사기간", "공사일수", "작업일수 계 + 비작업일수"], ]; +/** 264 고른 옵션을 따르는 비고 — 기본과 같으면 LINES 글 그대로(rain 은 늘 기후 자료 이름) */ +function optionNotes(src: Row, mine: Row, midMonth: boolean): Record { + const base = climateBase(src, mine); + const out: Record = {}; + if (base.label) + out.rain = `${base.rain_note ?? "일강수량 5mm 이상"}(달마다 · 해 무관) · 기본값 = ${base.label}`; + if (base.heat_note) out.heat = base.heat_note; + if (midMonth) { + out.days = "첫 달 = 착공일부터"; + out.a = "ROUNDUP(강수 + 혹서, 1) · 첫 달 = 날수 비례"; + } + const off: string[] = mine.holiday_off || []; + if (mine.rest === "satsun" || off.length) { + const rest = mine.rest === "satsun" ? "토 · 일" : "일요일"; + const cut = HOLIDAY_NAMES.filter(([k]) => off.includes(k)).map(([, n]) => n); + out.b = `${rest} + 공휴일${cut.length ? `(뺌 = ${cut.join(" · ")})` : ""} · 풍선 = 이름 · 고치면 그 달 덮개`; + } + if (mine.c_round === "round1") out.c = "A × B ÷ 달력일수 · 소수 1자리 반올림"; + if (mine.last_off === "all") out.off_used = "다 쓴 달 · 마지막 달 = 그 달 비작업 전부"; + return out; +} + +const HOLIDAY_NAMES: Array<[string, string]> = [ + ["substitute", "대체"], + ["election", "선거일"], + ["labor", "노동절"], + ["constitution", "제헌절"], +]; + export function buildScheduleSum(ctx: TabContext): TabTable | string { const sch: Row | undefined = ctx.sheets.schedule; if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; @@ -192,7 +223,9 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { const months: Row[] = cal.months; const src: Row = ctx.payload.inputs?.schedule || {}; const mine: Row = ctx.user.schedule || {}; - const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}`; + const day = cal.start.day > 1 ? `.${String(cal.start.day).padStart(2, "0")}` : ""; + const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}${day}`; + const notes = optionNotes(src, mine, Boolean(day)); const columns: DataColumn[] = [ text("label", "구분", 96), text("sub", "세부", 96), @@ -212,15 +245,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`, }, ...LINES.map(([key, label, sub, note]) => { - const row: DataRow = { key, label, sub, note }; + const row: DataRow = { key, label, sub, note: notes[key] ?? note }; let sum = 0; months.forEach((m, i) => { row[`m${i}`] = m[key]; sum += Number(m[key]) || 0; }); row.sum = sum; - if (key === "rain" && src.climate?.label) - row.note = `${note} · 기본값 = ${src.climate.label}`; return row; }), ]; @@ -232,7 +263,7 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { frozen: 2, mergeSame: ["label"], rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null), - hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 그 달 1일 · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", + hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공기 옵션」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", tip: (i, key) => { const m = months[monthOf(key)]; if (!m || rows[i].key !== "b") return null; @@ -266,10 +297,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { const k = String(rows[i].key); change(ctx, (s) => { if (k === "start") { - const hit = /(\d{4})\D+(\d{1,2})/.exec(value); + const hit = /(\d{4})\D+(\d{1,2})(?:\D+(\d{1,2}))?/.exec(value); if (!value.trim()) delete s.start; - else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) - s.start = { year: Number(hit[1]), month: Number(hit[2]) }; + else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) { + const [year, month, day] = [Number(hit[1]), Number(hit[2]), Number(hit[3] ?? 1)]; + const last = new Date(Date.UTC(year, month, 0)).getUTCDate(); + s.start = { year, month, ...(day > 1 && day <= last ? { day } : {}) }; + } return; } const m = months[monthOf(key)]; @@ -303,4 +337,7 @@ if (!TABS.some(([id]) => id === SUM)) { const src = payload.inputs?.schedule; if (src) sheets.schedule = computeSchedule(src, sheets.bill?.rows || [], user.schedule || {}); }); + // 264-1 왼쪽 「공기 옵션」 카드 — 셈마다 지금 값으로 맞춤 + DRAFT.panels.push(createSchedulePanel); + DRAFT.extend.push(syncSchedulePanel); } From 3939cd11f967196c0b1bdcd3b3158d0eab579dac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:42:57 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat(B07):=20269-1=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EC=96=91=EC=8B=9D(=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=B0=B0=EC=B9=98=20=EA=B8=B0=EC=A4=80)=20=C2=B7?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=20=EC=B9=B4=EB=93=9C=20=ED=95=A8=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template: .ui-panel-form(칸 26px · 라벨 틈 2px · 캡션 글자 · 단추 크기) · .ui-panel-btn · createPanelCard - 칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰 - B07 평면도 · 횡단도 묶음 = 공용 카드 · 도면 단추 = .ui-panel-btn · 페이지 덧칠 걷음 - 시험 test_269_panel_form · 212-3 기대값 · 130 · 153 헬퍼 공용 카드 연결 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- .../B05_Profile_UI_Style_Structures.css | 6 +- .../B07_DesignDetail_UI_Panels.ts | 16 ++-- .../B07_DesignDetail_UI_PlanKinds.ts | 8 +- .../B07_DesignDetail_UI_Style.css | 46 +-------- .../tester/helper_130_b07_plan_manage.cjs | 4 +- .../tester/helper_153_b07_cross_layout.cjs | 4 +- .../test_212_3_b07_cross_layout_buttons.py | 17 ++-- resources/tester/test_269_panel_form.py | 96 +++++++++++++++++++ ui_template/ui_template_collapsible.ts | 11 +++ ui_template/ui_template_overlay.css | 71 ++++++++++++++ ui_template/ui_template_theme.css | 3 + 11 files changed, 210 insertions(+), 72 deletions(-) create mode 100644 resources/tester/test_269_panel_form.py diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index c3f163646..8345e861e 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -357,11 +357,11 @@ /* ── 조작 컨트롤 높이 템플릿(2026-08-29 사용자 지시) ────────────────────── * 「구조물 배치」 섹션 안의 입력·셀렉트·스텝 묶음·이식 행이 **한 높이**를 쓴다. - * 값을 바꾸려면 이 변수 하나만 고치면 된다. 기준은 조정창에서 옮겨 온 행의 + * 값은 theme `--panel-control-h` 한 곳(좌측 패널 공용 양식이 함께 씀 · 269). 기준은 조정창에서 옮겨 온 행의 * 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */ .b05-structure-section, .b05-drainage__facility { - --b05-control-h: 26px; + --b05-control-h: var(--panel-control-h); } .b05-structure-section input:not([type="checkbox"]), @@ -409,7 +409,7 @@ /* 제목(라벨)과 항목 사이 간격도 한 변수로 묶는다(2026-08-29 사용자 지시) — 기준은 조정창에서 옮겨 온 행(2px·작은 글자). 측점 행은 가로 배치라 열 간격을 그대로 둔다. */ .b05-structure-section { - --b05-label-gap: 2px; + --b05-label-gap: var(--panel-label-gap); } .b05-structure-section .b05-route__field { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 68426a2fa..d19228411 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -6,7 +6,7 @@ * DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다. * ========================================================================== */ -import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; @@ -79,7 +79,7 @@ export function buildDrawingSidePanel( const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; - button.className = "b07-drawing-button"; + button.className = "b07-drawing-button ui-panel-btn"; button.dataset.drawingId = drawing.id; button.dataset.confirmed = String(drawing.confirmed); const name = document.createElement("span"); @@ -114,7 +114,7 @@ export function buildDrawingSidePanel( if (!drawing) { const placeholder = document.createElement("button"); placeholder.type = "button"; - placeholder.className = "b07-drawing-button"; + placeholder.className = "b07-drawing-button ui-panel-btn"; placeholder.disabled = true; placeholder.title = "준비 중"; placeholder.dataset.pending = "true"; @@ -132,14 +132,10 @@ export function buildDrawingSidePanel( panel.append(button); continue; } - const section = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시). - section.className = "b07-drawing-group ui-collapsible ui-sidebar-section"; + // 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식). + const section = createPanelCard(`${group.label} ${items.length}`); + section.classList.add("b07-drawing-group"); if (group.kind) section.dataset.kind = group.kind; - const sectionTitle = document.createElement("h3"); - sectionTitle.className = "ui-collapsible__title"; - sectionTitle.textContent = `${group.label} ${items.length}`; - section.append(sectionTitle); if (cross) { // 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만. for (const standard of drawings.filter((item) => item.kind === "cross_standard")) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 1b3145360..3d04ef741 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createPanelCard } from "@ui/ui_template_collapsible"; import { createSelectField, showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { @@ -257,9 +258,10 @@ export function buildPlanSection( makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, onManage?: () => void, ): HTMLElement { - const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); + // 컨테이너 = 좌측 패널 공용 카드(269) + const section = createPanelCard(`평면도 ${items.length}`); + section.classList.add("b07-drawing-group", "b07-plan"); section.dataset.kind = "plan"; - section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`)); for (const { kindId, name, sheets } of groupPlanSheets(items)) { const block = el("div", "b07-plan-kind"); @@ -273,7 +275,7 @@ export function buildPlanSection( if (onManage) { // 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1). - const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리"); + const manage = el("button", "ui-panel-btn b07-plan-manage-open", "평면도 관리"); manage.type = "button"; manage.addEventListener("click", onManage); section.append(manage); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 71c501fb3..9e7c36ded 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -40,15 +40,6 @@ font-size: var(--text-body-sm); } -.b07-drawing-group { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - /* 테두리 색·두께는 공통 ui-sidebar-section이 준다 — 여기선 형태(라운드·안쪽 여백)만. */ - border-radius: var(--radius-lg); - padding: var(--spacing-8); -} - /* 횡단도는 2열 배치 — 단, **칸이 좁아지면 1열로 접는다**(2026-09-08). 좌측 패널이 320 고정이던 동안은 2열이 늘 128.8px 라 문제가 없었으나, 공용 오버레이가 좁은 폭에서 패널을 280·227·182 로 물리면서 칸이 108.8 → 82.4 → 60px @@ -62,13 +53,6 @@ gap: var(--spacing-4); } -.b07-drawing-group h3 { - margin: var(--spacing-8) 0 var(--spacing-4); - color: var(--color-text-muted); - font-size: var(--text-caption); - font-weight: 600; -} - /* 아직 내용이 없는 도면 — 눌리기는 하고 도각만 나온다(2026-09-01). 옅게 두되 못 누르는 것처럼 보이지 않게 커서는 그대로 둔다. */ .b07-drawing-button[data-pending="true"] { @@ -94,22 +78,13 @@ /* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가 오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */ min-width: 0; - min-height: 34px; - padding: var(--spacing-4) var(--spacing-8); - /* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */ - border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent); - /* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */ + /* 크기 · 테두리 · 바탕 · 글자 = 공용 `.ui-panel-btn`(269) · 여기선 확정 여부 좌측 색 띠(미확정: 투명)만 */ border-left: 3px solid transparent; - border-radius: var(--radius-buttons); - background: color-mix(in srgb, var(--color-surface) 84%, #000); - color: var(--color-text); - cursor: pointer; text-align: center; } .b07-drawing-button__name { overflow: hidden; - font-size: var(--text-body-sm); white-space: nowrap; text-overflow: ellipsis; } @@ -227,7 +202,7 @@ /* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일: 배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기]. - 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */ + 단추 · 숫자 칸 크기 = 좌측 패널 공용 양식(`.ui-panel-form` · 269) · 한 줄 안 같은 너비. */ .b07-cross-layout-slot { grid-column: 1 / -1; } @@ -260,17 +235,8 @@ gap: var(--spacing-4); } -.b07-cross-layout .ui-btn, -.b07-cross-layout .ui-field .ui-input { - height: auto; - padding: var(--btn-bar-padding); - font-size: var(--btn-bar-font-size); - line-height: 1; -} - .b07-cross-layout .ui-field__label { margin: 0; - font-size: var(--btn-bar-font-size); white-space: nowrap; } @@ -279,10 +245,6 @@ min-width: 0; } -.b07-cross-layout .ui-field__error { - display: none; -} - /* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */ .b07-drawing-list__bypass { margin: var(--spacing-4) 0 0; @@ -315,10 +277,6 @@ font-size: var(--text-caption); } -.b07-plan-manage-open { - margin-top: var(--spacing-4); -} - /* [평면도 관리] 모달(130-1) — 왼쪽 종류 · 자리 · 장 목록 / 오른쪽 미리보기 · 공용 모달에 자기 class 로 큰 창 */ .ui-modal.b07-plan-manage { width: min(1320px, 96vw); diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 612ce5081..b3a1e2334 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -140,7 +140,6 @@ const STUBS = { return cad; }, }, - "@ui/ui_template_collapsible": { attachCollapsible: () => {} }, "@ui/ui_template_locale": { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), @@ -233,6 +232,9 @@ function loadTs(file) { cache.set(full, box.exports); const localRequire = (request) => { if (STUBS[request]) return STUBS[request]; + // 공용 카드(269) = 진짜 모듈(가짜 그릇 위에서 돎) + if (request === "@ui/ui_template_collapsible") + return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts")); if (request === "./B07_DesignDetail_Api_Fetch") return API; if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); throw new Error(`stub 없음: ${request}`); diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index c80f3c94c..147bce9d6 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -78,7 +78,9 @@ function loadTs(file) { ...loadTs(path.join(UI, "ui_template_elements.ts")), showToast: (message, kind) => toasts.push([kind, message]), }; - if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} }; + // 공용 카드(269) = 진짜 모듈 + if (request === "@ui/ui_template_collapsible") + return loadTs(path.join(UI, "ui_template_collapsible.ts")); if (request === "@ui/ui_template_locale") return { currentLanguageIndex: 0, diff --git a/resources/tester/test_212_3_b07_cross_layout_buttons.py b/resources/tester/test_212_3_b07_cross_layout_buttons.py index 3b51c0f18..6cc3f02fd 100644 --- a/resources/tester/test_212_3_b07_cross_layout_buttons.py +++ b/resources/tester/test_212_3_b07_cross_layout_buttons.py @@ -4,7 +4,7 @@ 못박는 것 - 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」 · [안내선 숨기기](부르는 쪽). - - 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(187 `--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비. + - 단추 · 숫자 칸 · 이름 = 좌측 패널 공용 양식(269 `.ui-panel-form`) · 한 줄 안 같은 너비. - 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음. - [안내선 숨기기] 뒤 남는 검은 선 = 인쇄 칸선(도각 층 도면 개체 · 회청 `#8ea0b5` · 밝은 바탕에서 짙게 그려짐) — 안내선(겹판)과 다른 것이라 지우지 않음 · 단추는 겹판만 끔. @@ -40,16 +40,13 @@ def _decl(block: str, prop: str) -> str: def test_단추_숫자_칸_한_규격_토큰(): - shared = _block(".b07-cross-layout .ui-btn,\n.b07-cross-layout .ui-field .ui-input") - assert _decl(shared, "padding") == "var(--btn-bar-padding)" - assert _decl(shared, "font-size") == "var(--btn-bar-font-size)" - assert _decl(shared, "line-height") == "1" - assert _decl(shared, "height") == "auto" # 공용 작은 칸 30px 를 풀어 단추 높이에 맞춤 + # 269 — 크기는 좌측 패널 공용 양식(`.ui-panel-form`)이 줌 · B07 덧칠(줄 위 작은 단추 토큰) 없음 + assert ".b07-cross-layout .ui-btn" not in CSS + assert ".b07-cross-layout .ui-field .ui-input {" in CSS # 폭만 + assert "height" not in _block(".b07-cross-layout .ui-field .ui-input") label = _block(".b07-cross-layout .ui-field__label") - assert _decl(label, "font-size") == "var(--btn-bar-font-size)" - # 토큰은 theme 한 곳(187) - assert "--btn-bar-padding: 1px var(--spacing-8);" in THEME - assert "--btn-bar-font-size: var(--text-caption);" in THEME + assert "font-size" not in label + assert "createPanelCard(" in PANELS # 배치 칸이 사는 횡단도 묶음 = 공용 카드 def test_한_줄에_한_일_같은_너비(): diff --git a/resources/tester/test_269_panel_form.py b/resources/tester/test_269_panel_form.py new file mode 100644 index 000000000..1bd68f6ec --- /dev/null +++ b/resources/tester/test_269_panel_form.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""269 좌측 패널 컨테이너 공용 양식 — 기준 = B05/B06 「구조물 배치」. + +못박는 것 + - 공용 양식 = ui_template 한 곳(`.ui-panel-form` · `.ui-panel-btn` · 카드 `createPanelCard`) · + 칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰. + - B07 컨테이너 = 공용 카드 · 도면 단추 = `.ui-panel-btn` · 페이지 덧칠(크기 · 글자 · 바탕) 없음. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +THEME = _read("ui_template/ui_template_theme.css") +OVERLAY = _read("ui_template/ui_template_overlay.css") +COLLAPSIBLE = _read("ui_template/ui_template_collapsible.ts") +B05_STRUCT_CSS = _read("B05_Profile/B05_Profile_UI_Style_Structures.css") +B07_CSS = _read("B07_DesignDetail/B07_DesignDetail_UI_Style.css") +B07_PANELS = _read("B07_DesignDetail/B07_DesignDetail_UI_Panels.ts") +B07_PLAN = _read("B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts") + + +def _block(css: str, selector: str) -> str: + m = re.search(r"(?:^|\n)" + re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_공용_양식_토큰은_theme_한_곳_구조물_배치도_같은_토큰(): + assert "--panel-control-h: 26px;" in THEME + assert "--panel-label-gap: 2px;" in THEME + assert "--b05-control-h: var(--panel-control-h);" in B05_STRUCT_CSS + assert "--b05-label-gap: var(--panel-label-gap);" in B05_STRUCT_CSS + + +def test_공용_양식_칸_라벨_단추_규칙(): + field = _block( + OVERLAY, + '.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]),\n' + ".ui-panel-form select", + ) + assert "height: var(--panel-control-h);" in field + assert "padding: 0 6px;" in field + assert "font-size: var(--text-caption);" in field + label = _block(OVERLAY, ".ui-panel-form .ui-field,\n.ui-panel-form .b05-route__field") + assert "gap: var(--panel-label-gap);" in label + assert "display: none;" in _block(OVERLAY, ".ui-panel-form .ui-field__error:not(.is-visible)") + button = _block(OVERLAY, ".ui-panel-form .ui-btn:not(.ui-help-btn)") + # 「구조물 배치」 단추(b05-route__irregular-btn)와 같은 크기 + assert "padding: var(--spacing-8);" in button + assert "font-size: var(--text-caption);" in button + assert "border-radius: var(--radius-inputs);" in button + raw = _block(OVERLAY, ":where(.ui-panel-btn)") + for decl in ( + "padding: var(--spacing-8);", + "border-radius: var(--radius-inputs);", + "background: var(--color-surface);", + "font-size: var(--text-caption);", + ): + assert decl in raw, decl + # 아래 단추 묶음은 컨테이너 밖 — 공용 양식이 `.ui-sidebar-actions` 를 겨누지 않음 + form_rules = OVERLAY[OVERLAY.index("좌측 패널 컨테이너 공용 양식(269)") :] + form_rules = form_rules[: form_rules.index("/* 카드 안에 다시 카드")] + assert "ui-sidebar-actions {" not in form_rules and "ui-sidebar-actions ." not in form_rules + + +def test_공용_카드_함수(): + assert "export function createPanelCard(" in COLLAPSIBLE + assert "ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form" in COLLAPSIBLE + + +def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴(): + assert "createPanelCard(`${group.label} ${items.length}`)" in B07_PANELS + assert "createPanelCard(`평면도 ${items.length}`)" in B07_PLAN + for src in (B07_PANELS, B07_PLAN): + assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음 + assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2 + assert '"ui-panel-btn b07-plan-manage-open"' in B07_PLAN + + +def test_B07_페이지_덧칠_없음(): + assert not re.search(r"(?:^|\n)\.b07-drawing-group\s*\{", B07_CSS) + assert ".b07-drawing-group h3 {" not in B07_CSS + assert ".b07-plan-manage-open {" not in B07_CSS + button = _block(B07_CSS, ".b07-drawing-button") + for prop in ("min-height", "padding", "border-radius", "background", "font-size"): + assert not re.search(r"(?:^|\s)" + prop + r"\s*:", button), prop + assert "font-size" not in _block(B07_CSS, ".b07-drawing-button__name") + assert ".b07-cross-layout .ui-btn" not in B07_CSS + assert ".b07-cross-layout .ui-field__error" not in B07_CSS diff --git a/ui_template/ui_template_collapsible.ts b/ui_template/ui_template_collapsible.ts index c179e6838..9a0e77b4d 100644 --- a/ui_template/ui_template_collapsible.ts +++ b/ui_template/ui_template_collapsible.ts @@ -11,6 +11,17 @@ * 스타일은 전역 `ui_template_theme.css`에 있어 어떤 페이지에서도 클래스만 붙이면 동작한다. * ========================================================================== */ +/** 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임. */ +export function createPanelCard(title: string, collapsed = false): HTMLElement { + const section = document.createElement("section"); + section.className = `ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form${collapsed ? " is-collapsed" : ""}`; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = title; + section.append(heading); + return section; +} + /** root 아래 `.ui-collapsible__title` 클릭을 위임 처리해 해당 컨테이너를 접거나 편다. */ export function attachCollapsible(root: HTMLElement): void { root.addEventListener("click", (event) => { diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index c9b6c76c5..09a1be164 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -415,6 +415,77 @@ font-size: var(--text-body-sm); } +/* 좌측 패널 컨테이너 공용 양식(269) — 기준 = B05/B06 「구조물 배치」(칸 높이 · 라벨 · 글자 · 단추). + B05~B09 좌측 컨테이너가 `.ui-panel-form`(카드는 `createPanelCard`)을 붙임 · 아래 단추 묶음 + (`.ui-sidebar-actions`)은 컨테이너 밖이라 안 먹음 · 날 단추는 `.ui-panel-btn`. */ +:where(.ui-sidebar-card.ui-panel-form) { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.ui-panel-form .ui-field, +.ui-panel-form .b05-route__field { + gap: var(--panel-label-gap); +} + +.ui-panel-form .ui-field__label, +.ui-panel-form .b05-route__field > span { + font-size: var(--text-caption); + line-height: 1.2; +} + +.ui-panel-form .ui-field__error:not(.is-visible) { + display: none; +} + +.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]), +.ui-panel-form select { + box-sizing: border-box; + height: var(--panel-control-h); + padding: 0 6px; + font-size: var(--text-caption); + line-height: normal; +} + +.ui-panel-form select.ui-select { + padding-right: 24px; + background-position: right 6px center; + background-size: 12px; +} + +/* 숫자 칸 증감 화살표 없음 — 「구조물 배치」와 같게 */ +.ui-panel-form input[type="number"] { + appearance: textfield; + -moz-appearance: textfield; +} + +.ui-panel-form input[type="number"]::-webkit-outer-spin-button, +.ui-panel-form input[type="number"]::-webkit-inner-spin-button { + margin: 0; + appearance: none; + -webkit-appearance: none; +} + +/* 단추 크기 = 「구조물 배치」 단추(`b05-route__irregular-btn`) · 색은 단추 갈래(filled · ghost) 그대로 */ +.ui-panel-form .ui-btn:not(.ui-help-btn) { + padding: var(--spacing-8); + border-radius: var(--radius-inputs); + font-size: var(--text-caption); + line-height: normal; +} + +/* 날