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/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/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/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/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_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_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_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 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 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