From 3f4fd188e8086cffc3d4f589bf8144b2f2fcd812 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 23:13:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(754-1):=20B04=20=EB=93=B1=EA=B3=A0?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EA=B2=A9=EC=9E=90=20=EC=99=B8=EA=B3=BD=20?= =?UTF-8?q?=3D=20=EC=A7=80=EA=B8=88=20=EC=A7=80=ED=91=9C=EB=A9=B4=20?= =?UTF-8?q?=EC=99=B8=EA=B3=BD=20=E2=80=94=20=EC=96=B4=EB=8A=90=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=EC=9D=84=20=EC=A7=80=EC=96=B4=EB=8F=84=20=EB=82=A1?= =?UTF-8?q?=EC=9D=80=20dtm=20=EA=B0=99=EC=9D=B4=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9D=8C=20=C2=B7=20=EB=93=B1=EA=B3=A0=EC=84=A0=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=ED=8C=90=EB=8B=A8=EC=97=90=20dtm=20=C2=B7?= =?UTF-8?q?=20=EA=B5=B3=ED=9E=8C=20=EA=B2=A9=EC=9E=90=20=EB=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지표면 짓기 차례에 dtm 을 늘 맨 앞 — manifest 항목 없음 · 지문 다름이면 같이 다시 지음(화면 [서피스 생성] = 표현 하나만 짓던 길) - 등고선 캐시 낡음 = 모델 · 같은 필터 dtm · implicit 굳힌 격자 중 하나라도 더 새것(짓기 차례 · 등고선 API 한 함수) - 등고선 추출이 모델 · dtm 파일을 닫음 — 실패 때 열린 채 남아 Windows 에서 다시 짓기(교체)가 막히던 흠 - ptd 처럼 dtm 없던 필터도 dtm · 굳힌 격자가 생겨 ptd · implicit 확정 길 B05 · B06 열림 - 새 시험 test_754_1_contour_outline.py Co-Authored-By: Claude Opus 5.5 --- .../B04_PreProcess_Engine_Contour.py | 41 ++++++- .../B04_PreProcess_Engine_Pipeline.py | 15 +-- .../B04_PreProcess_Router_Contour.py | 7 +- .../tester/test_754_1_contour_outline.py | 102 ++++++++++++++++++ 4 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_754_1_contour_outline.py diff --git a/B04_PreProcess/B04_PreProcess_Engine_Contour.py b/B04_PreProcess/B04_PreProcess_Engine_Contour.py index 30a6946a1..b89aa00b2 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Contour.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Contour.py @@ -122,6 +122,23 @@ def extract_contours_from_grid( return contours_geojson_list +def contour_cache_is_stale(contour_path: Path, model_path: Path) -> bool: + """등고선 캐시가 없거나 그 면을 낳은 파일보다 옛것이면 True(754-1). + + 낳은 파일 = 모델 npz · 같은 필터 `dtm_{필터}.npz`(외곽 자르기) · implicit 굳힌 격자. + """ + stem = Path(model_path).stem.removesuffix("_smooth") + folder = Path(model_path).parent + sources = [Path(model_path), folder / f"dtm_{stem.split('_', 1)[-1]}.npz"] + if stem.startswith("implicit_"): + sources.append(folder / f"{stem}_grid.npz") + try: + built = Path(contour_path).stat().st_mtime_ns + except OSError: + return True + return any(path.is_file() and path.stat().st_mtime_ns > built for path in sources) + + def _load_footprint_mask( model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray ) -> np.ndarray | None: @@ -137,10 +154,10 @@ def _load_footprint_mask( if not dtm_path.exists(): return None try: - d = np.load(dtm_path) - dtm_x = np.asarray(d["x"]).ravel() - dtm_y = np.asarray(d["y"]).ravel() - dtm_mask = np.asarray(d["valid_mask"], dtype=bool) + with np.load(dtm_path) as d: + dtm_x = np.asarray(d["x"]).ravel() + dtm_y = np.asarray(d["y"]).ravel() + dtm_mask = np.asarray(d["valid_mask"], dtype=bool) except Exception: return None if len(dtm_x) < 2 or len(dtm_y) < 2: @@ -360,7 +377,21 @@ def extract_contours( model_npz_path = Path(model_npz_path) if not model_npz_path.exists(): raise FileNotFoundError(f"모델 파일이 존재하지 않습니다: {model_npz_path}") - data = np.load(model_npz_path) + # 실패해도 모델 파일을 닫음 — 열린 채 남으면 Windows 에서 다시 짓기(교체)가 막힘(754-1) + with np.load(model_npz_path) as data: + return _extract_loaded( + data, model_npz_path, representation, interval, target_grid_m, scene_center + ) + + +def _extract_loaded( + data: Any, + model_npz_path: Path, + representation: str, + interval: float, + target_grid_m: float, + scene_center: tuple[float, float, float] | None, +) -> list[dict[str, Any]]: if representation == "regular_grid": x_coords, y_coords, z_grid, valid_mask = ( diff --git a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py index f2f8766bf..3fab1e129 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py @@ -16,6 +16,7 @@ import numpy as np from B04_PreProcess.B04_PreProcess_Engine_Contour import ( CONTOUR_EXTRACTOR_VERSION, + contour_cache_is_stale, extract_contours, ) from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import BUILDERS @@ -191,11 +192,9 @@ def _build_all_terrain_models( output_dir.mkdir(parents=True, exist_ok=True) manifest_path = output_dir / "manifest.json" filters = tuple(key for key in config["source_filters"] if key in ground_masks) - methods_list = [key for key in config["precompute"] if key in BUILDERS] - if "dtm" in methods_list: - methods_list.remove("dtm") - methods_list.insert(0, "dtm") - methods = tuple(methods_list) + # dtm 은 늘 맨 앞 — 같은 필터 외곽 · implicit 굳힌 격자 · 등고선 자르기의 바탕이라 + # 어느 표현을 지어도 낡았으면(manifest 항목 없음 · 지문 다름) 같이 다시 지음(754-1). + methods = ("dtm", *(key for key in config["precompute"] if key in BUILDERS and key != "dtm")) # 무거운 implicit 은 기본 필터(첫 필터)에만 — 분류 LAS 의 csf 까지 두 번 짓지 않음(요구 558). later_methods = tuple(method for method in methods if method != "implicit") signature = config_signature(config) @@ -244,12 +243,14 @@ def _build_all_terrain_models( if not force and _cache_is_valid(output_dir, stem, method, entry, config): interval = float(config.get("contour_interval_meters", 1.0)) contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json" - needs_contours = not contour_path.exists() + needs_contours = contour_cache_is_stale(contour_path, output_dir / f"{stem}.npz") if method in config.get("smoothing_methods", ("dtm", "tin")): smooth_contour_path = ( output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json" ) - needs_contours = needs_contours or not smooth_contour_path.exists() + needs_contours = needs_contours or contour_cache_is_stale( + smooth_contour_path, output_dir / f"{stem}_smooth.npz" + ) if needs_contours: _cache_contours_safely( output_dir, diff --git a/B04_PreProcess/B04_PreProcess_Router_Contour.py b/B04_PreProcess/B04_PreProcess_Router_Contour.py index 4c1ea20f9..50e1d4bea 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Contour.py +++ b/B04_PreProcess/B04_PreProcess_Router_Contour.py @@ -16,6 +16,7 @@ from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine_Contour import ( CONTOUR_EXTRACTOR_VERSION, + contour_cache_is_stale, extract_contours, ) from common_util.common_util_atomic import atomic_write_bytes @@ -37,10 +38,10 @@ MODEL_REPRESENTATIONS = { def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool: - """캐시가 현재 추출기 버전이고 기반 모델 npz보다 최신인지 검사한다.""" + """캐시가 현재 추출기 버전이고 모델 · 같은 필터 dtm · 굳힌 격자보다 최신인지 검사한다.""" + if contour_cache_is_stale(contour_path, model_path): + return False try: - if contour_path.stat().st_mtime < model_path.stat().st_mtime: - return False with contour_path.open("rb") as cache_file: head = cache_file.read(256).decode("utf-8", errors="ignore") except OSError: diff --git a/resources/tester/test_754_1_contour_outline.py b/resources/tester/test_754_1_contour_outline.py new file mode 100644 index 000000000..1e40bd179 --- /dev/null +++ b/resources/tester/test_754_1_contour_outline.py @@ -0,0 +1,102 @@ +"""754-1 등고선 · 격자 외곽 = 지금 지표면 외곽 (요구 589-11). + +implicit 하나만 지어도 같은 필터 dtm 이 낡았으면 같이 다시 지음 · 등고선 캐시는 dtm · 굳힌 격자가 +바뀌면 다시 셈 · ptd 처럼 dtm 없던 필터도 굳힌 격자가 생김. 서버 함수만 — 합성 점군 · 임시 폴더. +""" + +import json +import os +from pathlib import Path + +import numpy as np + +from B04_PreProcess.B04_PreProcess_Engine_Contour import contour_cache_is_stale +from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models +from common_util.common_util_surface_sampler import implicit_grid_npz +from config import config_system as cfg + + +def _banded_plane() -> tuple[dict, np.ndarray]: + axis = np.arange(0.0, 60.0, 0.5) + x, y = np.meshgrid(axis, axis) + points = np.column_stack([x.ravel(), y.ravel(), (100.0 + 0.2 * x).ravel()]) + points = points[~((points[:, 0] > 28) & (points[:, 0] < 33))] # 5 m 빈 띠(z 106 = x 30) + data = {"xyz": points, "bounds": np.array([points.min(axis=0), points.max(axis=0)]).T} + return data, np.ones(len(points), bool) + + +def _config(filter_key: str, precompute: list[str], close_m: float) -> dict: + config = cfg.build_surface_model_config() + config.update( + { + "source_filters": [filter_key], + "precompute": precompute, + "footprint_gap_close_meters": close_m, + "implicit_min_points": 500, + "implicit_max_points": 2_000, + } + ) + return config + + +def _valid_area(path: Path) -> float: + with np.load(path) as grid: + resolution = float(np.asarray(grid["resolution"]).ravel()[0]) + return float(np.count_nonzero(grid["valid_mask"])) * resolution**2 + + +def _band_contour(path: Path) -> bool: + lines = json.loads(path.read_text(encoding="utf-8"))["contours"] + return any(abs(line["level"] - 106.0) < 1e-6 for line in lines) + + +def test_implicit_만_지어도_낡은_dtm_같이_다시(tmp_path: Path) -> None: + data, mask = _banded_plane() + masks = {"csf": mask} + build_all_terrain_models(data, masks, tmp_path, _config("csf", ["dtm", "implicit"], 1.0)) + narrow = _valid_area(tmp_path / "dtm_csf.npz") + contour = tmp_path / "contour_csf_implicit_1.0m.json" + assert not _band_contour(contour) # 1 m 닫기 = 띠 빈칸 · 106 m 줄 잘림 + + # 화면 [서피스 생성] = 고른 표현 하나만 — 외곽 설정(지문)이 바뀌었으니 dtm 도 다시 + manifest = build_all_terrain_models(data, masks, tmp_path, _config("csf", ["implicit"], 3.0)) + methods = manifest["source_filters"]["csf"]["methods"] + assert methods["dtm"]["status"] == "completed" + wide = _valid_area(tmp_path / "dtm_csf.npz") + grid_area = _valid_area(implicit_grid_npz(tmp_path, "csf")) + glb_area = methods["implicit"]["footprint_area_m2"] + assert wide > 1.05 * narrow + assert grid_area == wide # 격자 외곽 = dtm 외곽 + assert abs(glb_area - wide) / wide < 0.05 # 3D 외곽 = 격자 외곽 + assert _band_contour(contour) # 등고선이 넓힌 띠까지 + assert not contour_cache_is_stale(contour, tmp_path / "implicit_csf.npz") + + +def test_dtm_이_바뀌면_등고선_캐시_낡음(tmp_path: Path) -> None: + model = tmp_path / "implicit_csf.npz" + dtm = tmp_path / "dtm_csf.npz" + grid = tmp_path / "implicit_csf_grid.npz" + contour = tmp_path / "contour_csf_implicit_1.0m.json" + assert contour_cache_is_stale(contour, model) # 없음 + for index, path in enumerate((model, dtm, grid, contour)): + path.write_bytes(b"x") + os.utime(path, ns=(10**18 + index, 10**18 + index)) + assert not contour_cache_is_stale(contour, model) + for newer in (dtm, grid): + os.utime(newer, ns=(2 * 10**18, 2 * 10**18)) + assert contour_cache_is_stale(contour, model) + os.utime(newer, ns=(10**18, 10**18)) + tin_smooth = tmp_path / "tin_csf_smooth.npz" + tin_smooth.write_bytes(b"x") + os.utime(tin_smooth, ns=(10**18, 10**18)) + os.utime(dtm, ns=(2 * 10**18, 2 * 10**18)) + assert contour_cache_is_stale(contour, tin_smooth) # 스무딩 면도 같은 필터 dtm 을 봄 + + +def test_ptd_implicit_만_지어도_굳힌_격자(tmp_path: Path) -> None: + data, mask = _banded_plane() + build_all_terrain_models(data, {"ptd": mask}, tmp_path, _config("ptd", ["implicit"], 3.0)) + assert (tmp_path / "dtm_ptd.npz").is_file() + grid = implicit_grid_npz(tmp_path, "ptd") # B05 · B06 표본이 읽는 파일 + with np.load(grid) as loaded: + assert np.isfinite(loaded["z"][loaded["valid_mask"]]).all() From da45997ebe43e2670c7236c7485dfc694fd9e36d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 23:14:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(752-1):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20=ED=8C=A8=EB=84=90=20=E2=80=94=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=EB=A7=88=EB=8B=A4=20=ED=83=AD=20=C2=B7=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A4=84=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=95=88=EC=9D=80=20=EB=81=94=20=C2=B7=20=EC=A2=81=EC=9D=80=20?= =?UTF-8?q?=ED=8F=AD=20=EC=A0=91=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `Sangeun_Side` — 일위대가 상세와 같은 서랍(`createSidePanels`) + 원문 보기(`createSourceView` · M02 길 `/m02/logic-lib/source` · 원문 다운로드) · 바탕 · 둘째 기초(더한줄 로직)마다 탭 · 기초 하나 = 탭 없음 · 바탕 없음 = 「원문 연결 없음」 · 넓은 화면(1500 이상)만 처음 열림 · 열림 · 폭 기억 `m02-sangeun-side` - Sangeun_Detail — 옵션 `source`(단가산출 화면 View 만 켬 · 줄 모달 RowSk 는 안 켬) · 「시험 계산 · 텍스트 수식 = 바탕 기초 + …」 안내 글 걷음(750-2 로 네 보기가 같은 답이라 낡음 · 키 M02_SangeunBaseViews 지움) - 낡은 시험 고침 — test_721_find_pick(안내 걷음) · 새 지킴 test_750_2 에 752-1 대조 Co-Authored-By: Claude Sonnet 5.5 --- .../M02_MasterTemplete_Sangeun_Detail.ts | 41 +++++---- .../M02_MasterTemplete_Sangeun_Side.ts | 83 +++++++++++++++++++ .../M02_MasterTemplete_Sangeun_View.ts | 2 +- resources/tester/test_721_find_pick.py | 2 +- .../tester/test_750_2_views_one_answer.py | 11 +++ ui_template/ui_template_locale_m2.ts | 5 +- 6 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index c9cb1e618..04549c6a8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -38,6 +38,7 @@ import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead"; import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; +import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side"; import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit"; import { dropBase, @@ -63,6 +64,8 @@ export interface SangeunDetailOptions { owner: string | null; readOnly: boolean; onChanged: (dirty: boolean) => void; + /** 752-1 오른쪽 원문 패널 — 단가산출 화면만 켬(일위대가 줄 모달 안 상세는 끔) */ + source?: boolean; } const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" }; @@ -90,6 +93,9 @@ export function mountSangeunDetail( let fail = ""; let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3) const inputsOf = new Map(); + const infoOf = new Map(); // 더한줄 로직 → 원문 탭(752-1) + let extraKeys: string[] = []; + const side = opt.source ? createSangeunSide() : null; let seq = 0; let alive = true; @@ -141,19 +147,17 @@ export function mountSangeunDetail( touch(false); void loadBase(true); } - const baseHint = createText(L("M02_SangeunBaseViews")); - host.replaceChildren( - el("div", { - className: "m02-sk", - attrs: { "data-sangeun-detail": doc.키 ?? "" }, - children: [ - head, - notes, - el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }), - editor, - ], - }), - ); + const main = el("div", { + className: "m02-sk", + attrs: { "data-sangeun-detail": doc.키 ?? "" }, + children: [ + head, + notes, + el("div", { className: "m02-sk__head", children: [views.root, addButton] }), + editor, + ], + }); + host.replaceChildren(side ? side.wrap(main) : main); const opened = (): boolean => (doc.열린입력 ?? []).length > 0; // 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) · 둘째 기초 지문(727-2) @@ -185,11 +189,15 @@ export function mountSangeunDetail( .filter((k) => !inputsOf.has(k)) .map((k) => fetchLogic(k).then( - (r) => void inputsOf.set(k, r.logic.입력 ?? []), + (r) => { + inputsOf.set(k, r.logic.입력 ?? []); + infoOf.set(k, tabOf(r.logic)); + }, () => void inputsOf.set(k, []), ), ), ); + extraKeys = keys; const seen = new Set((base?.logic.입력 ?? []).map((i) => i.이름)); extra = keys .flatMap((k) => inputsOf.get(k) ?? []) @@ -306,7 +314,6 @@ export function mountSangeunDetail( if (!alive) return; drawHead(); drawNotes(); - baseHint.hidden = !base; const rows = ans ? rowsOf(ans, bare, doc) : []; const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름])); const removed = [ @@ -382,6 +389,10 @@ export function mountSangeunDetail( cover: { ho, est, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달 }); drawCalc(); + side?.set([ + ...(base ? [tabOf(base.logic)] : []), + ...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])), + ]); } /** 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts new file mode 100644 index 000000000..f75ee3381 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Side.ts + * 단가산출(SK) 상세 오른쪽 원문 패널(PLAN 752-1 · 요구 588-3) — 일위대가 상세(47-4 · 85-4)와 같은 부품: + * 오른쪽 서랍(`createSidePanels`) + 원문 보기(`createSourceView` · M02 로그인 길 `/m02/logic-lib/source`) + * 기초마다 탭 — 바탕 로직 · 둘째 기초(더한줄 로직) 각각의 출처 · 기초가 하나면 탭 없음 · 없으면 안내 + * 처음 = 넓은 화면만 열림(좁으면 세로 띠로 접힘) · 열림 · 폭 기억 = `m02-sangeun-side` + * 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬). + * ========================================================================== */ + +import { createSegment } from "@ui/ui_template_segment"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { createText } from "@ui/ui_template_text"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +export interface SourceTab { + label: string; + src: string; +} + +/** 기초 로직 → 원문 탭(「원문번호 이름」 · 출처 글) */ +export const tabOf = (logic: { + 키: string; + 원문번호?: string; + 이름: string; + 출처?: string; +}): SourceTab => ({ + label: `${logic.원문번호 || logic.키} ${logic.이름}`, + src: logic.출처 ?? "", +}); + +/** 이 폭 이상일 때만 처음부터 열림 — 상세 표 여섯 칸이 좁아지지 않게 */ +const OPEN_FROM = 1500; + +export function createSangeunSide(): { + wrap: (main: HTMLElement) => HTMLElement; + set: (tabs: SourceTab[]) => void; +} { + const view = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); + const host = el("div", { attrs: { "data-sangeun-source": "" } }); + let tabs: SourceTab[] = []; + let at = 0; + const side = createSidePanels({ + prefKey: "m02-sangeun-side", + follow: false, // 일위대가와 같음 — 상세 고정 높이 칸을 채움 · 본문과 따로 스크롤 + defaultOpen: window.innerWidth >= OPEN_FROM ? "source" : null, + panels: [{ id: "source", title: tc("Labor_Source"), body: host }], + onChange: () => paint(), + }); + function paint(): void { + if (side.current() !== "source") return; // 접힘 = 찾지 않음 + if (!tabs.length) + return void host.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted")); + at = Math.min(at, tabs.length - 1); + view.show(tabs[at].src); + const bar = + tabs.length > 1 + ? [ + createSegment({ + options: tabs.map((x, i) => ({ value: String(i), label: x.label })), + value: String(at), + size: 28, + ariaLabel: tc("Labor_Source"), + onChange: (v) => ((at = Number(v)), paint()), + }).root, + ] + : []; + host.replaceChildren(...bar, view.root); + } + return { + wrap: (main) => el("div", { className: "ui-side-panels-row", children: [main, side.root] }), + set: (next) => { + if (JSON.stringify(next) === JSON.stringify(tabs)) return; + tabs = next; + paint(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts index e31ca48ec..4a3b91584 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -18,5 +18,5 @@ export function mountSangeunView( doc: unknown, opt: SangeunViewOptions, ): { getDoc: () => unknown; destroy: () => void } { - return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null }); + return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1 } diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index b81649914..d13084ff0 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -26,7 +26,7 @@ def _text(path: Path) -> str: def test_721_1_단추_이름과_자리(): detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") - assert "children: [views.root, addButton, baseHint]" in detail + assert "children: [views.root, addButton]" in detail # 750-2 「시험 계산 = 바탕」 안내 걷음 assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음 locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts") assert "M02_SangeunPickBase" not in locale diff --git a/resources/tester/test_750_2_views_one_answer.py b/resources/tester/test_750_2_views_one_answer.py index ca3836567..bd23618aa 100644 --- a/resources/tester/test_750_2_views_one_answer.py +++ b/resources/tester/test_750_2_views_one_answer.py @@ -30,3 +30,14 @@ def test_calc_view_answer_hook_skips_logic_lib(): ts = _ts("M01_MasterData/M01_MasterData_UI_Logic_Calc.ts") assert "ctx.onText && !ctx.answer" in ts # 미리보기 안 부름 assert "if (ctx.answer)" in ts and "runCalc" in ts # M01 옛 길 그대로 + + +def test_752_1_source_panel_only_on_sangeun_screen(): + """752-1 원문 패널 — 단가산출 화면만 켬 · 일위대가 줄 모달 안 상세는 끔 · 기초마다 탭 · 좁은 폭 접힘.""" + side = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts") + assert "/m02/logic-lib/source" in side and "createSidePanels" in side + assert "OPEN_FROM" in side and "createSegment" in side + assert "source: true" in _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts") + assert "source" not in _ts("M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts") + detail = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts") + assert "opt.source ? createSangeunSide()" in detail and "side?.set(" in detail diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index bbc6ebf11..7c9678d3c 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -207,10 +207,7 @@ export const ui_locales_m2 = { "Lines not priced — total uses the rest · drop the line or fill its input", ], M02_SangeunScaleOnly: ["열린 입력 — 품은 배율만", "Open inputs — quantity as multiplier only"], - M02_SangeunBaseViews: [ - "시험 계산 · 텍스트 수식 = 바탕 단가산출 기초 + 고친 품 · 단가(뺀 줄 · 더한 줄 빼고)", - "Test calc · formula = base M01 logic + edited qty · price (no dropped · added lines)", - ], + M02_SangeunSourceNone: ["원문 연결 없음 — 바탕 로직 없음", "No source — no base logic"], M02_SangeunWait: ["셈 기다리는 중", "Calculating…"], M02_SangeunNoInputs: [ "바탕 단가산출 기초 없음 — 시험 입력 없음",