From fb93c149438f89eff58582194a37afada4474ffd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:29:55 +0900 Subject: [PATCH 01/33] =?UTF-8?q?feat(B05):=20352-2=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20=C2=B7=20=EB=8B=A4=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=ED=98=B8=20=EB=8B=A8=EC=B6=94=20=C2=B7=20?= =?UTF-8?q?[=EC=A0=80=EC=9E=A5=20=EC=A0=84=EC=9C=BC=EB=A1=9C]=20=EC=99=80?= =?UTF-8?q?=20reset=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ↶ ↷ 글 없이 기호 + aria-label · title(단축키) - 저장 전으로 단추 · history.reset · 초안 다리 reset 지움 - 시험 chrome_modal · 346_4 · 346_1 고쳐 씀 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 7 ------- .../B05_Profile_UI_RouteEdit_Chrome.ts | 10 ++++------ B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts | 9 ++------- .../B05_Profile_UI_RouteEdit_History.ts | 13 +------------ .../tester/helper_346_1_pipeline_page.cjs | 13 +------------ resources/tester/helper_346_4_one_undo.cjs | 5 ----- resources/tester/test_346_1_pipeline_page.py | 2 +- resources/tester/test_346_4_one_undo.py | 5 ----- .../tester/test_b05_routeedit_chrome_modal.py | 19 ++++++++++++------- 9 files changed, 21 insertions(+), 62 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 224bbfb60..b971bc532 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -284,13 +284,6 @@ export async function mountRouteEditor( planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), }); - // [저장 전으로] — 마지막 저장(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림. - overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => { - const snapshot = history?.reset(); - if (!snapshot) return showToast("반영 뒤 고친 노선이 없습니다.", "info"); - initial.restoreTag(snapshot.rec); - restoreSnapshot(snapshot, "반영 전 노선으로 되돌렸습니다.", false); - }); function resize(): void { if (closed || !overlay.isConnected) return; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index cf375a5ed..bac5493ed 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -13,8 +13,8 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; * 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다. * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · - * 닫기 단추는 걷었다 · [취소] → [저장 전으로] · [확인] · [노선 반영]은 없앰(346-3 — 노선은 페이지 [저장]이 - * 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸. + * 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지 + * [저장]이 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸. * 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측 * 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`). * ========================================================================== */ @@ -84,11 +84,9 @@ export function createRouteEditChrome( title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">발자국 + title="되돌리기 (Ctrl+Z)" aria-label="되돌리기" disabled>↶ - + title="다시하기 (Ctrl+Y)" aria-label="다시하기" disabled>↷
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts index a9fdd9ebe..de49bffad 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts @@ -1,14 +1,14 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Draft.ts * 노선 편집기 ↔ 계산 묶음 다리(PLAN 346-1) — 노선을 한 번 고칠 때마다(놓기 · 더함 · 지움 · 곡선 · - * 되돌리기 · 다시하기 · [저장 전으로] · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음 + * 되돌리기 · 다시하기 · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음 * (사진첩에 쌓이는 때 = 손을 놓은 때). * * 편집기는 사진첩(`_History`)을 `watch` 로 감싸기만 함 — 고친 줄 하나. 저장 안 한 초안은 * 다리가 캐시 칸(`route-draft`)에 두고, 편집기가 자료를 읽은 뒤 `restore` 로 한 번 되살림 * (새로고침 · 보기 전환에도 남음). 계산 · 가게는 `A00_Common/design/design_pipeline_page.ts` 몫. * - * 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 · [저장 전으로] 뒤 사진첩에 한 걸음 · + * 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 · * [되돌리기] · [다시하기]는 사진첩이 함(평면 · 종단 · 횡단 · 관 · 구조물 한 줄) · 사진첩이 되쓴 노선 * 초안은 `fromPage` 로 편집기 새 걸음이 됨. 사진첩이 없으면 편집기 사진 그대로. * ========================================================================== */ @@ -99,11 +99,6 @@ export function watch( album.redo(); return null; }, - reset: () => { - const snapshot = tell(history.reset()); - if (snapshot) pageHistory()?.record(); - return snapshot; - }, canUndo: () => pageHistory()?.canUndo() ?? history.canUndo(), canRedo: () => pageHistory()?.canRedo() ?? history.canRedo(), fromPage, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index 1a2added5..4c2f2273b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -11,7 +11,7 @@ * * 되돌리기 줄은 페이지 사진첩 하나(`_Profile_History` · 346-4) — 계산 묶음 다리가 있으면(`_Draft.watch`) * [되돌리기] · [다시하기] · Ctrl+Z · Y 는 사진첩이 받고, 여기 사진은 고정 · 추천 꼬리표 · 고른 점을 - * 되살리는 기록(`match`)과 [저장 전으로](`reset`) · 고친 것 있음(`isDirty`)에만 씀. + * 되살리는 기록(`match`) · 고친 것 있음(`isDirty`)에만 씀. * ========================================================================== */ import { onPageHistory } from "./B05_Profile_UI_Profile_History"; @@ -44,8 +44,6 @@ export interface RouteEditHistory { undo: () => RouteEditSnapshot | null; /** 한 걸음 앞으로. 없으면 null. */ redo: () => RouteEditSnapshot | null; - /** 창을 연 상태로. 이미 그 상태면 null. */ - reset: () => RouteEditSnapshot | null; canUndo: () => boolean; canRedo: () => boolean; /** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */ @@ -115,15 +113,6 @@ export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHis at += 1; return clone(steps[at]); }, - reset() { - // 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다. - if (sameRoute(steps[at], steps[0])) return null; - // 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다. - steps.length = at + 1; - steps.push(clone(steps[0])); - at = steps.length - 1; - return clone(steps[at]); - }, canUndo: () => at > 0, canRedo: () => at < steps.length - 1, isDirty: () => !sameRoute(steps[at], steps[0]), diff --git a/resources/tester/helper_346_1_pipeline_page.cjs b/resources/tester/helper_346_1_pipeline_page.cjs index 7f177fc8b..03151b31f 100644 --- a/resources/tester/helper_346_1_pipeline_page.cjs +++ b/resources/tester/helper_346_1_pipeline_page.cjs @@ -243,7 +243,7 @@ async function run() { // ③ 편집기 다리 — 사진첩 감싸기 · 초안 되살림 { const told = []; - let dirty = true; + const dirty = true; const fake = { commit: () => undefined, undo: () => ({ @@ -259,15 +259,6 @@ async function run() { picked: -1, }), redo: () => null, - reset: () => ({ - planned: [[0, 0]], - curveOn: [true], - curveRadius: [null], - curveLock: [null], - curveArc: [null], - apexLock: [false], - picked: -1, - }), canUndo: () => true, canRedo: () => false, isDirty: () => dirty, @@ -287,8 +278,6 @@ async function run() { }); watched.undo(); watched.redo(); - dirty = false; - watched.reset(); const restored = []; draftLink.restore(bridge, (snap, message, record) => restored.push([snap.planned.length, record]), diff --git a/resources/tester/helper_346_4_one_undo.cjs b/resources/tester/helper_346_4_one_undo.cjs index 5471833af..963db4efd 100644 --- a/resources/tester/helper_346_4_one_undo.cjs +++ b/resources/tester/helper_346_4_one_undo.cjs @@ -151,11 +151,6 @@ const snap = (nodes, lock = false) => ({ out.redo3 = state(); out.editorAfterRedo = editor.slice(out.editorAfterUndo.length); out.profileReads = profileReads; - // [반영 전으로] — 사진첩 한 걸음 · 되돌리면 옮긴 노선 - history.reset(); - out.reset = state(); - pageAlbum.undo(); - out.resetUndo = state(); controls.dispose(); process.stdout.write(JSON.stringify(out)); })(); diff --git a/resources/tester/test_346_1_pipeline_page.py b/resources/tester/test_346_1_pipeline_page.py index f100e72c2..a10480a78 100644 --- a/resources/tester/test_346_1_pipeline_page.py +++ b/resources/tester/test_346_1_pipeline_page.py @@ -173,7 +173,7 @@ def test_늦은_응답_버림(r): def test_편집기_다리(r): d = r["draftLink"] - assert d["told"] == [3, 2, None] # 쌓기 · 되돌리기 · 열 때로(null) — 다시하기 없음은 안 알림 + assert d["told"] == [3, 2] # 쌓기 · 되돌리기 — 다시하기 없음은 안 알림 assert d["restored"] == [ [r["n"], True] ] # 저장 안 한 초안 = 새 걸음으로 되살림 · 없으면 안 부름 diff --git a/resources/tester/test_346_4_one_undo.py b/resources/tester/test_346_4_one_undo.py index 2fa120d9a..f2423b500 100644 --- a/resources/tester/test_346_4_one_undo.py +++ b/resources/tester/test_346_4_one_undo.py @@ -54,11 +54,6 @@ def test_다시하기는_앞으로(out) -> None: assert out["profileReads"] == 6 # 종단도도 걸음마다 다시 읽음 -def test_반영_전으로도_한_걸음(out) -> None: - assert out["reset"]["route"] is None and out["reset"]["cross"] # 노선만 열 때로 - assert out["resetUndo"]["route"] == 70 - - def test_단축키는_사진첩_한_곳(): tools = (ROOT / "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts").read_text(encoding="utf-8") assert "undoOwner" not in tools diff --git a/resources/tester/test_b05_routeedit_chrome_modal.py b/resources/tester/test_b05_routeedit_chrome_modal.py index d07655715..4d1234e5d 100644 --- a/resources/tester/test_b05_routeedit_chrome_modal.py +++ b/resources/tester/test_b05_routeedit_chrome_modal.py @@ -1,7 +1,7 @@ """계획노선 편집 겉틀 — 345-3 부터 모달이 아니라 종단 보기 가운데 위 본문(PLAN 127-1 의 공용 모달 조각은 걷음). · 덮개(`ui-modal__backdrop`) · 전체 화면 · 닫기 단추 없음 · 부모를 받아 붙음(`document.body` 아님) -· [취소] → [반영 전으로](이력 처음으로) · [확인] → [노선 반영] · 머리 끝 `tools` 자리([3D]) +· [저장 전으로] 없음(352-2) · ↶ ↷ 는 기호 단추 · 머리 끝 `tools` 자리([3D]) · 오른쪽 횡단 미리보기 = 접는 칸 · 재계산 대기 막은 화면 전체(fixed) · R 라벨은 칸 안 절대 위치 · 바깥 누름 · Esc 로 안 닫힘(모달 아님) """ @@ -33,13 +33,16 @@ def test_겉틀은_본문_칸(): assert 'overlay.className = "b05-routeedit";' in chrome assert "b05-routeedit__close" not in chrome and 'data-slot="close"' not in chrome assert '"tools"' in chrome and "replaceWith(...tools)" in chrome - # 346-3 — [노선 반영] 없앰(노선은 페이지 [저장]이 맨 앞에서 보냄) · [취소] = [저장 전으로] - assert ">저장 전으로" in chrome and 'data-act="apply"' not in chrome - assert "노선 반영" not in chrome + # 346-3 — [노선 반영] 없앰(노선은 페이지 [저장]이 맨 앞에서 보냄) · 352-2 — [저장 전으로] 없앰 + assert 'data-act="apply"' not in chrome and 'data-act="cancel"' not in chrome + assert ">저장 전으로" not in chrome and "노선 반영" not in chrome assert ">취소" not in chrome and ">확인" not in chrome + # 352-2 — 되돌리기 · 다시하기 = 글 없이 기호 + aria-label · title(단축키) + assert 'aria-label="되돌리기" disabled>↶' in chrome + assert 'aria-label="다시하기" disabled>↷' in chrome + assert 'title="되돌리기 (Ctrl+Z)"' in chrome and 'title="다시하기 (Ctrl+Y)"' in chrome # 시험 · 배선이 집는 자리는 남김 - for hook in ('data-act="cancel"', "b05-routeedit__measure-close"): - assert hook in chrome + assert "b05-routeedit__measure-close" in chrome assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome @@ -67,7 +70,9 @@ def test_페이지_수명_닫기_없음(): assert "new ResizeObserver(" in main assert "host.onDispose(dispose);" in main assert "historyControls.listen(host.visible());" in main - assert "history?.reset();" in main # [반영 전으로] + assert "history?.reset()" not in main # 352-2 — [저장 전으로] 지움 + hist = _read("B05_Profile_UI_RouteEdit_History.ts") + assert "reset" not in hist.split("export interface RouteEditHistory", 1)[1].split("}", 1)[0] assert len(main.splitlines()) <= 700 label = _read("B05_Profile_UI_RouteEdit_Label.ts") assert "document.body.append" not in label and "container.append(root);" in label From 789922cedbd7deabba8db5e40d124905d7176e6d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:30:02 +0900 Subject: [PATCH 02/33] =?UTF-8?q?fix(B06):=20356-1=20=EC=96=91=EC=B8=A1?= =?UTF-8?q?=EC=A0=88=ED=86=A0=20=EB=A7=B9=EC=95=94=EA=B1=B0=20=E2=80=94=20?= =?UTF-8?q?=EB=82=AE=EC=9D=80=20=EC=AA=BD=20=EB=8A=98=EC=9D=B8=20=EB=85=B8?= =?UTF-8?q?=EB=A9=B4=EC=9D=B4=20=EC=9B=90=EC=A7=80=EB=B0=98=EA=B3=BC=20?= =?UTF-8?q?=EB=A7=8C=EB=82=98=EB=8A=94=20=EC=A0=90(tilt=5Freach)=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=81=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reach 너머 설계선 점 거름 · 꼬리가 reach 에서 수직으로 옵셋선까지 닫음 - 시험 test_288 · helper_288 에 양측절토 + reach 형상 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- B06_Section/B06_Section_UI_Cross_Underdrain.ts | 8 +++++++- resources/tester/helper_288_underdrain.cjs | 12 ++++++++++++ resources/tester/test_288_underdrain_cross.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_UI_Cross_Underdrain.ts b/B06_Section/B06_Section_UI_Cross_Underdrain.ts index 743c564ef..cb41c0033 100644 --- a/B06_Section/B06_Section_UI_Cross_Underdrain.ts +++ b/B06_Section/B06_Section_UI_Cross_Underdrain.ts @@ -5,6 +5,7 @@ * · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음. * · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도. * · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남. + * · 양측절토(낮은 쪽 늘인 노면 `tilt_reach`) = 늘인 노면이 원지반과 만나는 점에서 수직으로 끊음(356-1). * · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 · * 기울기를 번갈아 서로 교차 · 그림 전용(누름 안 받음 · CSS `.b06-chart__underdrain`). * 높이가 비면 그리지 않음(수치를 지어내지 않음). @@ -93,7 +94,12 @@ export function underdrainOutline( const span = left.offset_m - right.offset_m; if (!(Math.abs(span) > 1e-9)) return null; const grade = (left.elevation_m - right.elevation_m) / span; - const line = design.design_line ?? []; + // 양측절토(356-1) — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음 · 너머 점 거름 → 꼬리가 수직으로 닫음 + const reach = design.tilt_reach; + const outward = reach?.side === "left" ? 1 : -1; + const line = (design.design_line ?? []).filter( + (p) => !reach || (p.offset_m - reach.offset_m) * outward <= 1e-6, + ); const leftPath = sidePath(left, 1, line, grade, heightM); const rightPath = sidePath(right, -1, line, grade, heightM); return [ diff --git a/resources/tester/helper_288_underdrain.cjs b/resources/tester/helper_288_underdrain.cjs index 5a81ab179..eaf9c50ed 100644 --- a/resources/tester/helper_288_underdrain.cjs +++ b/resources/tester/helper_288_underdrain.cjs @@ -65,6 +65,17 @@ const design = { { offset_m: -7, elevation_m: 97.2 }, ], }; +// 356-1 양측절토 — 우(낮은 쪽) 노면 끝도 절토 → 노면을 늘여 원지반과 만난 점(tilt_reach) · 그 너머 원지반 오름 +const bothCut = { + ...design, + design_line: [ + ...design.design_line.slice(0, 3), + { offset_m: -4, elevation_m: 99.8 }, + { offset_m: -4.5, elevation_m: 99.775 }, + { offset_m: -6, elevation_m: 100.5 }, + ], + tilt_reach: { side: "right", offset_m: -4.5, elevation_m: 99.775 }, +}; const svg = new Node("g"); const group = draw.appendUnderdrainOverlay( svg, @@ -96,6 +107,7 @@ process.stdout.write( attached, sections: sections.map((s) => s.underdrain ?? null), outline: draw.underdrainOutline(design, 1), + bothCut: draw.underdrainOutline(bothCut, 1), noHeight: draw.underdrainOutline(design, null), drew: !!group, bodyId: body?.attrs["data-structure-id"] ?? null, diff --git a/resources/tester/test_288_underdrain_cross.py b/resources/tester/test_288_underdrain_cross.py index 51107f608..50c5526c8 100644 --- a/resources/tester/test_288_underdrain_cross.py +++ b/resources/tester/test_288_underdrain_cross.py @@ -131,6 +131,22 @@ def test_형상_옵셋선_성토_절토와_만남(ts): assert ts["drew"] is True and ts["stones"] > 0 +def test_양측절토_늘인_노면_원지반_교점에서_끊음(ts): + """356-1 — 낮은 쪽 노면 끝도 절토(tilt_reach) = reach 에서 수직으로 끊음 · 너머 원지반 따라가지 않음.""" + outline = [(p["offset"], p["elevation"]) for p in ts["bothCut"]] + expected = [ + (3, 100.15), + (-3, 99.85), + (-4, 99.8), + (-4.5, 99.775), # 늘인 노면 ∩ 원지반(tilt_reach) + (-4.5, 98.775), # reach 아래 옵셋선 + (1.846154, 99.092308), + ] + assert len(outline) == len(expected) + for got, want in zip(outline, expected): + assert got == pytest.approx(want, abs=1e-5) + + def test_횡단도_B07_이_맹암거를_그림(): view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") assert ( From 55c5aab90ec6367074b960d641a6dd4c920c436f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:31:32 +0900 Subject: [PATCH 03/33] =?UTF-8?q?fix(B06):=20356-2=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=B4=88=EC=A0=90=20=EA=B1=B7=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EB=A7=88=EC=9A=B0=EC=8A=A4=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EB=92=A4=20=ED=82=A4=EB=A5=BC=20=EB=88=8C=EB=9F=AC?= =?UTF-8?q?=EB=8F=84=20=EB=8B=A4=EB=A5=B8=20=EC=83=89=20=ED=85=8C=EB=91=90?= =?UTF-8?q?=EB=A6=AC=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드 tabIndex · Enter/Space 고름 지움 · ▲▼ 계획고는 창 듣개 그대로(219-1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- B06_Section/B06_Section_UI_Cross_View.ts | 5 +---- resources/tester/test_356_b06_card_center.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_356_b06_card_center.py diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index cda48dec7..9cea10b17 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -183,11 +183,8 @@ export function createCrossSectionCard( const card: CrossCardElement = document.createElement("article"); card.id = `cross-${section.station_id}`; card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`; - card.tabIndex = 0; + // 초점 없음(356-2) — 마우스로 고른 뒤 키를 눌러도 초점 테두리 안 뜸 · ▲▼ 계획고는 창 듣개(219-1) card.addEventListener("click", () => onSelect(section.station_id)); - card.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") onSelect(section.station_id); - }); // 선택 표시·면적 강조는 **DOM만 갈아 끼운다**. 카드를 새로 만들면 휠 줌·팬(viewBox)이 // 초기화돼 사용자가 맞춰 둔 배율이 날아간다(2026-08-02 사용자 지적). diff --git a/resources/tester/test_356_b06_card_center.py b/resources/tester/test_356_b06_card_center.py new file mode 100644 index 000000000..4f7fd6684 --- /dev/null +++ b/resources/tester/test_356_b06_card_center.py @@ -0,0 +1,19 @@ +"""356 B06 횡단 카드 — 키 강조 · 중심점 좌우(요구 235-17 · 18). + +못박는 것 + - 356-2 카드 초점 없음 — 마우스로 고른 뒤 키를 눌러도 초점 테두리(다른 색) 안 뜸 · ▲▼ 계획고는 창 듣개. +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B06 = ROOT / "B06_Section" + + +def test_카드_초점_없음(): + view = (B06 / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + assert "card.tabIndex" not in view + assert 'card.addEventListener("keydown"' not in view + assert 'card.addEventListener("click", () => onSelect(section.station_id));' in view From ec498dd20066cb6bdf0b77c104357b44f28f3f53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:33:43 +0900 Subject: [PATCH 04/33] =?UTF-8?q?feat(B05):=20352-1=20=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=B9=B8=20=C2=B7=20=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=20=EC=A4=84=20=C2=B7=20=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=C2=B7=20=EB=8F=84=EA=B5=AC=20=EB=96=A0=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EC=9C=A0=EB=A6=AC=20=EB=8B=A8=EC=B6=94=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EC=9A=B0=EC=B8=A1=20=EB=8D=AE=EB=8A=94=20?= =?UTF-8?q?=ED=8C=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 머리 줄(제목 · 테두리)과 상자 삭제 · 도구줄 = 3D 와 같은 떠 있는 단추(☰ 비킴) - [?] 도구줄 아래 · 분석 지형 글에 유리 바탕 - 횡단 판 = 오른쪽 덮는 판(z 2 · 아래 종단 판 z 3 이 위) · 회전 단추 · 잰 값 창이 판을 비킴 - 시험 chrome_modal · initial 고쳐 씀 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- .../B05_Profile_UI_RouteEdit_Chrome.ts | 44 ++++--- .../B05_Profile_UI_Style_RouteEdit.css | 117 ++++++++---------- .../tester/test_b05_routeedit_chrome_modal.py | 30 +++++ .../tester/test_b05_routeedit_initial.py | 4 +- 4 files changed, 104 insertions(+), 91 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index bac5493ed..875da3db4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -8,9 +8,9 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고 * 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다. * - * **배치**(2026-09-12 사용자 지시 ⑩~⑬·⑱) — 아래 정보행을 없애고 지도 위 오버레이로 옮겼다. - * 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 - * 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다. + * **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추 + * (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 + * 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위). * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · * 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지 @@ -57,12 +57,11 @@ export function createRouteEditChrome( const overlay = document.createElement("div"); overlay.className = "b05-routeedit"; overlay.innerHTML = ` -
-
-

계획노선

- +
+ +
- + - - - - - - - - - -
-
- +
@@ -121,15 +117,16 @@ export function createRouteEditChrome(
-
-
+ `; parent.append(overlay); + // 부른 쪽 단추([3D])도 같은 유리 단추로 — 지도 위에 떠 있는 도구줄. + tools.forEach((tool) => tool.classList.replace("ui-btn--ghost", "ui-btn--glass")); overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools); // 횡단 미리보기 = 접는 칸 — 좁은 화면은 접힌 채 시작(지도가 먼저다). @@ -137,6 +134,7 @@ export function createRouteEditChrome( const sideToggle = overlay.querySelector('[data-act="side-toggle"]')!; const setSide = (open: boolean): void => { sideWrap.classList.toggle("is-collapsed", !open); + overlay.classList.toggle("is-side-open", open); // 회전 단추 · 잰 값 창이 판을 비킴 sideToggle.setAttribute("aria-expanded", String(open)); }; setSide(window.innerWidth >= 1500); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index a9cc78586..00e97dd68 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -1,59 +1,37 @@ /* 계획노선 편집(평면) — 종단 보기 가운데 위 본문(옛 3D 자리 · 345-3). 덮개 · 전체 화면은 걷음 — 재계산 대기 막만 화면을 덮는다(노선 재탐색 · CLAUDE.md 5장). 색은 전부 테마 토큰을 쓴다. */ +/* 평면 = 가운데 칸 전체(352-1) — 머리 줄 · 테두리 없음. 도구줄은 3D 와 같은 떠 있는 유리 단추 + (`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */ .b05-routeedit { + --b05-side-open: 0px; /* 횡단 판이 펴져 지도를 덮는 폭 */ + --b05-side-top: 64px; /* 접힌 [횡단] 단추가 위 오른쪽을 덮는 폭 */ position: relative; display: flex; flex: 1 1 auto; - gap: var(--spacing-8); min-height: 0; - padding: var(--spacing-8); } -/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱). - 좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */ -.b05-routeedit__box { - position: relative; - display: flex; - flex: 1 1 auto; - flex-direction: column; - min-width: 0; - height: 100%; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: var(--color-surface-raised); +.b05-routeedit.is-side-open { + --b05-side-open: 460px; + --b05-side-top: 460px; } -/* 머리 = 도구줄 — 왼쪽 이름 · 오른쪽 단추 묶음. */ -.b05-routeedit__head { - display: flex; - flex: none; - align-items: center; - gap: var(--spacing-12); - padding: var(--spacing-8) var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.b05-routeedit__head > h3 { - margin: 0; - font-size: var(--text-body); -} - -/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪). - 단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */ -.b05-routeedit__actions { - display: flex; - flex: 1 1 auto; +/* 도구줄 — 3D 도구줄 규격 그대로 · 오른쪽 회전 단추 · 횡단 판을 비켜 줄바꿈. */ +.b05-routeedit__tools { + right: calc(var(--spacing-12) + var(--b05-side-top) + 104px); flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: var(--spacing-8); + pointer-events: none; /* 빈 자리는 지도 조작을 통과 */ +} + +.b05-routeedit__tools > * { + pointer-events: auto; } .b05-routeedit__canvas-wrap { position: relative; flex: 1 1 auto; + min-width: 0; min-height: 0; overflow: hidden; background: var(--color-surface); @@ -86,10 +64,10 @@ z-index: 1; } -/* 조작 설명 — 지도 왼쪽 위 · 기본 접힘 · [?] 로 펼침(345-5 덧 3) · 펼치면 **2열**(사용자 지시 ⑩). */ +/* 조작 설명 — 도구줄 아래 왼쪽 · 기본 접힘 · [?] 로 펼침(345-5 덧 3 · 352-1) · 펼치면 **2열**(사용자 지시 ⑩). */ .b05-routeedit__hint { - top: var(--spacing-12); - left: var(--spacing-12); + top: calc(var(--spacing-16) + 34px + var(--spacing-8)); + left: var(--spacing-16); display: flex; align-items: flex-start; gap: var(--spacing-8); @@ -173,11 +151,11 @@ color: var(--color-text-body); } -/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */ +/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯) · 횡단 판 왼쪽에 앉음(352-1). 여기만 클릭을 받는다. */ .b05-routeedit__spin { position: absolute; - top: var(--spacing-12); - right: var(--spacing-12); + top: var(--spacing-16); + right: calc(var(--spacing-12) + var(--b05-side-top)); z-index: 1; display: flex; gap: var(--spacing-8); @@ -211,18 +189,34 @@ border-top: 2px solid var(--map-route, #f97316); } -/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. 접는 칸(345-3) — - 좁은 화면은 접힌 채 시작(`_Chrome`) · [횡단] 단추로 펴고 접는다. */ +/* 오른쪽 덮는 판 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다(352-1). 지도 위에 얹고 + 아래 종단 판(z 3)이 그 위를 덮는다. 접는 칸(345-3) — 좁은 화면은 접힌 채 시작(`_Chrome`) · + [횡단] 단추로 펴고 접는다. 빈 자리는 지도 조작을 통과. */ .b05-routeedit__side-wrap { + position: absolute; + z-index: 2; + top: var(--spacing-16); + right: var(--spacing-12); + bottom: var(--spacing-12); display: flex; - flex: none; flex-direction: column; gap: var(--spacing-8); min-height: 0; + pointer-events: none; +} + +.b05-routeedit__side-wrap.is-collapsed { + bottom: auto; +} + +.b05-routeedit__side-wrap > * { + pointer-events: auto; } .b05-routeedit__side-toggle { align-self: flex-end; + min-width: 56px; + height: 34px; } .b05-routeedit__side { @@ -323,21 +317,20 @@ line-height: 1.5; } -/* ㉓ 거리 재기와 되돌리기 사이 구분선. */ -.b05-routeedit__divider { - width: 1px; - height: 20px; - margin: 0 var(--spacing-4, 4px); - background: var(--color-border); -} - -/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임. */ +/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임 · 글에 유리 바탕(352-1). */ .b05-routeedit__terrain { display: inline-flex; align-items: center; gap: var(--spacing-4, 4px); + height: 34px; + padding: 0 var(--spacing-8); + border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent); + border-radius: var(--radius-8, 6px); + background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); font-size: 12px; - color: var(--color-text-muted, inherit); + color: var(--color-text-body); } .b05-routeedit__terrain[hidden] { @@ -347,7 +340,7 @@ /* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */ .b05-routeedit__measure { position: absolute; - right: var(--spacing-12); + right: calc(var(--spacing-12) + var(--b05-side-open)); bottom: var(--spacing-12); z-index: 1; display: flex; @@ -461,11 +454,3 @@ color: var(--color-text-secondary); line-height: 1.35; } - -/* 「거리 재기」가 켜진 동안 — 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ⑥). - `is-active` 는 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */ -.b05-routeedit__actions .ui-btn.is-active { - border-color: transparent; - background: var(--color-primary, #7c3aed); - color: #fff; -} diff --git a/resources/tester/test_b05_routeedit_chrome_modal.py b/resources/tester/test_b05_routeedit_chrome_modal.py index 4d1234e5d..3f16fd9ef 100644 --- a/resources/tester/test_b05_routeedit_chrome_modal.py +++ b/resources/tester/test_b05_routeedit_chrome_modal.py @@ -61,6 +61,36 @@ def test_CSS_덮개_걷음_대기막은_전체(): assert "@media (width < 1500px)" not in css # 접기는 단추로(처음만 폭으로) +def test_352_1_머리줄_상자_없음_도구는_뜬_단추_횡단은_우측_덮개(): + chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts") + for gone in ("b05-routeedit__box", "b05-routeedit__head", "b05-routeedit__actions", "

"): + assert gone not in chrome + # 도구줄 = 3D 와 같은 떠 있는 유리 단추 · 구분선도 3D 것 + assert 'class="b05-route__view-controls b05-routeedit__tools"' in chrome + assert "ui-btn--ghost" not in chrome.split(" chrome.index("b05-routeedit__busy") + css = _read("B05_Profile_UI_Style_RouteEdit.css") + for gone in (".b05-routeedit__box", ".b05-routeedit__head", ".b05-routeedit__actions"): + assert gone not in css + root = _block(css, ".b05-routeedit") + assert "padding" not in root and "gap" not in root + side = _block(css, ".b05-routeedit__side-wrap") + assert "position: absolute;" in side and "right: var(--spacing-12);" in side + z = int(re.search(r"z-index: (\d+);", side).group(1)) + assert z < 3 # 아래 종단 판(z 3)이 위 + style = _read("B05_Profile_UI_Style.css") + assert "z-index: 3;" in _block(style, ".b05-route-profile") + # 회전 단추 · 잰 값 창은 펴진 횡단 판을 비킴 + assert "var(--b05-side-top)" in _block(css, ".b05-routeedit__spin") + assert "var(--b05-side-open)" in _block(css, ".b05-routeedit__measure") + assert "is-side-open" in chrome + glass = _block(css, ".b05-routeedit__terrain") + assert "backdrop-filter: blur(6px);" in glass and "background:" in glass + + def test_페이지_수명_닫기_없음(): main = _read("B05_Profile_UI_RouteEdit.ts") assert "openModal" not in main and "Escape" not in main diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index ae7d1c855..7cce14368 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -210,9 +210,9 @@ def test_노선_추천_다듬기_23_3(): chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts") # 345-4 — [노선 추천]은 좌측 칸 · 머리 구분선은 [3D](tools) 뒤 · 되돌리기 앞 둘 그대로 tools = chrome.index('data-slot="tools"') - divider = chrome.index("b05-routeedit__divider", tools) + divider = chrome.index("b05-route__view-separator", tools) # 352-1 — 3D 도구줄 구분선 그대로 assert tools < divider < chrome.index('data-act="contour-minor"') - assert chrome.count("b05-routeedit__divider") == 2 + assert chrome.count("b05-route__view-separator") == 2 shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts") for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"): assert f"{name}(" in shared From f331f2094e119edb7ecd7b61b76dd573eee03bfd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:36:15 +0900 Subject: [PATCH 05/33] =?UTF-8?q?feat(=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0?= =?UTF-8?q?):=20354-2=20=EC=A2=85=EB=8B=A8=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=E2=80=94=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EA=B8=B0=EC=A4=80=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=9C=20=EC=A2=85=EB=8B=A8=20=EB=B2=94?= =?UTF-8?q?=EB=A1=80=EB=8A=94=20=ED=9A=A1=EB=8B=A8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=C2=B7=20=EA=B3=84=EC=82=B0=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- .../B05_Profile_UI_Profile_MassHaul.ts | 13 ++-- .../helper_354_2_masshaul_cross_only.cjs | 57 ++++++++++++++++++ .../tester/test_354_2_masshaul_cross_only.py | 59 +++++++++++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 resources/tester/helper_354_2_masshaul_cross_only.cjs create mode 100644 resources/tester/test_354_2_masshaul_cross_only.py diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index f7349e98a..68c8575e1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -6,7 +6,7 @@ * 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세 * 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로 * (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴 - * 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고, + * 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준만 · 종단 기준 숨김 354-2)를 쓰고, * 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다. * * 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라 @@ -173,8 +173,11 @@ function readVisible(): Set { if (!raw) return new Set(DEFAULT_VISIBLE); const parsed = JSON.parse(raw) as unknown; // 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙). + // 종단 기준은 안 그림(354-2) — 저장된 범례가 종단이면 횡단으로 맞춘다. return Array.isArray(parsed) - ? normalizeVisibleBasis(new Set(parsed.map(String))) + ? normalizeVisibleBasis( + new Set(parsed.map((key) => (key === "longitudinal" ? "cross" : String(key)))), + ) : new Set(DEFAULT_VISIBLE); } catch { return new Set(DEFAULT_VISIBLE); @@ -370,7 +373,7 @@ export function createRouteMassHaulPanel( overlay.hidden = !open; syncHandlePosition(); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. - // 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번). + // 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번). if (!open) { reportSummary(params); return; @@ -400,7 +403,8 @@ export function createRouteMassHaulPanel( .map((chainage) => `${chainage.toFixed(2)} m`) .join(" · ")}${stale.length > 5 ? " …" : ""}` : "다시 계산 중…"; - // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. + // B06과 같은 계산 — 횡단 기준(정식)만 낸다. 종단 기준은 안 셈 · 안 그림 · 범례 단추 없음 + // (354-2 · 계산 코드는 공용에 남김). // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). const series: MassHaulSeries[] = computeMassHaulSeries( @@ -408,6 +412,7 @@ export function createRouteMassHaulPanel( params.crossSections, context.conversion, context.naturalSpoilMinSlope, + ["cross"], ); // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. emitSummary(series); diff --git a/resources/tester/helper_354_2_masshaul_cross_only.cjs b/resources/tester/helper_354_2_masshaul_cross_only.cjs new file mode 100644 index 000000000..8a688c9a6 --- /dev/null +++ b/resources/tester/helper_354_2_masshaul_cross_only.cjs @@ -0,0 +1,57 @@ +/* 354-2 종단 기준 숨김 — 진짜 `readVisible`(B05 유토곡선) · `normalizeVisibleBasis` · + * `computeMassHaulSeries`(공용)를 잘라 돌려 JSON 으로 냄. + * · 저장된 범례 = 종단 → 횡단으로 맞춤 · 분배 켬은 그대로 + * · 기준 ["cross"] = 횡단 계열 하나 · 기본(묶음 단계) = 두 기준 그대로 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); +const transpile = (code) => + ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; +const block = (src, head) => { + const at = src.indexOf(head); + if (at < 0) throw new Error(`없음: ${head}`); + return src.slice(at, src.indexOf("\n}", at) + 2).replace(/^export /, ""); +}; + +const util = read("common_util", "common_util_mass_haul.ts"); +const mass = read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts"); +let stored = null; +const src = [ + 'const SERIES_BASES = ["cross", "longitudinal"];', + 'const DEFAULT_VISIBLE = ["cross"];', + block(util, "export function normalizeVisibleBasis("), + block(mass, "function readVisible(): Set {"), + block(util, "export function computeMassHaulSeries("), +].join("\n"); +const api = new Function( + "readStateRaw", + "designedSections", + "crossAreaSamples", + "longitudinalAreaSamples", + "integrate", + `${transpile(src)}; return { readVisible, computeMassHaulSeries };`, +)( + () => stored, + (sections) => sections, + () => [{ basis: "cross" }], + () => [{ basis: "longitudinal" }], + (samples) => ({ from: samples[0].basis }), +); + +const visible = (raw) => ((stored = raw), [...api.readVisible()].sort()); +process.stdout.write( + JSON.stringify({ + longitudinal: visible('["longitudinal"]'), + longitudinalBalance: visible('["longitudinal","balance"]'), + cross: visible('["cross"]'), + empty: visible(null), + broken: visible("{"), + crossOnly: api.computeMassHaulSeries({}, [], {}, undefined, ["cross"]).map((s) => s.key), + both: api.computeMassHaulSeries({}, [], {}).map((s) => s.key), + }), +); diff --git a/resources/tester/test_354_2_masshaul_cross_only.py b/resources/tester/test_354_2_masshaul_cross_only.py new file mode 100644 index 000000000..fe84be732 --- /dev/null +++ b/resources/tester/test_354_2_masshaul_cross_only.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""354-2 유토곡선 종단 기준 숨김 — 안 셈 · 안 그림 · 범례 단추 없음(코드 남김). + +- 그리기 = 횡단 기준만(`["cross"]`) · 범례는 낸 계열마다라 종단 단추가 안 섬. +- 저장된 범례가 종단(`longitudinal`)이면 횡단으로 맞춤 · 분배 켬은 그대로. +- 계산 묶음 단계(MassHaul_Stage)는 두 기준 그대로 · 공용 계산 코드 남김. + +`helper_354_2_masshaul_cross_only.cjs` 가 진짜 TS 를 잘라 돌림. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_354_2_masshaul_cross_only.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_저장된_종단_범례는_횡단으로(r): + assert r["longitudinal"] == ["cross"] + assert r["longitudinalBalance"] == ["balance", "cross"] + assert r["cross"] == ["cross"] + assert r["empty"] == ["cross"] and r["broken"] == ["cross"] + + +def test_기준_횡단만_계산(r): + assert r["crossOnly"] == ["cross"] + assert r["both"] == ["cross", "longitudinal"] # 공용 기본값 = 묶음 단계 그대로 + + +def test_그리기는_횡단만_부름(): + mass = (ROOT / "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") + calls = re.findall(r"computeMassHaulSeries\(([^;]*?)\)", mass, re.S) + assert calls and all('["cross"]' in call for call in calls) + + +def test_묶음_단계와_공용_코드는_그대로(): + stage = (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").read_text(encoding="utf-8") + assert '["cross"]' not in stage + util = (ROOT / "common_util/common_util_mass_haul.ts").read_text(encoding="utf-8") + assert 'const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];' in util + assert "longitudinalAreaSamples(longitudinal, sections)" in util From 3d24a741c745310e9805056b819be221004d4423 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:37:26 +0900 Subject: [PATCH 06/33] =?UTF-8?q?fix(B05):=20352-3=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=A7=80=ED=98=95=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=3D=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EC=97=B4=20=EB=95=8C=20pref=20=EC=93=B0=EA=B8=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=C2=B7=20=EB=B0=94=EA=BE=B8=EB=A9=B4=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=AC=B6=EC=9D=8C=EC=97=90=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고른 적 없으면 설계 지형(라이다가 있으면 라이다) · 사용자가 바꿀 때만 기억 - 하나뿐인 프로젝트는 있는 지형만 · 옛 값 무시 - 바꾸면 노선 초안으로 계산 묶음에 알림 · 설계 지형이 수치지형도면 확정 모델 등고선을 LAS 로 쓰지 않음 - 시험 test_352_3 추가 · 133_1 · 133_2 고쳐 씀 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B05_Profile/B05_Profile_Api_Replan.ts | 19 +++++- B05_Profile/B05_Profile_UI_RouteEdit.ts | 20 +++--- resources/tester/helper_352_3_terrain.cjs | 61 +++++++++++++++++++ .../tester/test_133_1_analysis_terrain.py | 2 +- resources/tester/test_133_2_design_terrain.py | 4 +- .../test_352_3_analysis_terrain_default.py | 54 ++++++++++++++++ 6 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 resources/tester/helper_352_3_terrain.cjs create mode 100644 resources/tester/test_352_3_analysis_terrain_default.py diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index d6626e02e..d8e10a59b 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -23,16 +23,29 @@ import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_type /** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */ export type AnalysisTerrain = "las" | "sheet"; -/** 고른 분석 지형 — 화면 상태 보관소 pref 통(노선 편집 고정과 같은 자리). 없으면 `fallback` - * (모달이 열 때 설계 지형으로 채움). [확인] 때 이 값이 설계 지형이 됨(PLAN 133-2). */ +/** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */ +const terrainContext = new Map(); + +export const setTerrainContext = ( + projectId: string, + choosable: boolean, + design: AnalysisTerrain, +): void => void terrainContext.set(projectId, { choosable, design }); + +/** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 · + * 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다). + * 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */ export const analysisTerrain = ( projectId: string, fallback: AnalysisTerrain = "las", ): AnalysisTerrain => { + const context = terrainContext.get(projectId); + if (context && !context.choosable) return context.design; const saved = readState("routeedit-terrain", projectId); - return saved === "sheet" || saved === "las" ? saved : fallback; + return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback); }; +/** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */ export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void => writeState("routeedit-terrain", terrain, projectId); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index b971bc532..3947302b3 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -34,6 +34,7 @@ import { fetchRoutePlan, fetchTerrainSources, setAnalysisTerrain, + setTerrainContext, type AnalysisTerrain, } from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; @@ -184,7 +185,7 @@ export async function mountRouteEditor( let designTerrain: AnalysisTerrain = "las"; /** [확인]에 실을 분석 지형 — 고르기가 안 보이면 null(설계 지형 그대로). */ const pickedTerrain = (): AnalysisTerrain | null => - terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId, designTerrain); + terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId); /** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */ let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ @@ -624,21 +625,21 @@ export async function mountRouteEditor( syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; - // 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 기본 라이다. - // 고른 값이 없으면 지금 설계 지형(133-2) · 못 고르는 프로젝트는 설계 지형 그대로. + // 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 열 때 안 씀(352-3). + // 고른 적 없으면 데이터대로(설계 지형 · 라이다가 있으면 라이다) · 사용자가 바꿀 때만 기억. designTerrain = sources?.design ?? "las"; const choosable = !!sources?.sources.includes("las") && !!sources?.sources.includes("sheet"); terrainSelect.closest("label")!.hidden = !choosable; - setAnalysisTerrain( - projectId, - choosable ? analysisTerrain(projectId, designTerrain) : designTerrain, - ); + setTerrainContext(projectId, choosable, designTerrain); terrainSelect.value = analysisTerrain(projectId); // 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. + // 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽). const loadBackground = () => loadRouteEditBackground(projectId, drainage, { surfaceModelId: - analysisTerrain(projectId) === "sheet" ? null : (options.surfaceModelId ?? null), + analysisTerrain(projectId) === "las" && designTerrain === "las" + ? (options.surfaceModelId ?? null) + : null, intervalM: options.contourIntervalM ?? 1, smooth: options.smooth ?? false, }); @@ -665,6 +666,9 @@ export async function mountRouteEditor( let terrainTicket = 0; terrainSelect.addEventListener("change", () => { setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las"); + // 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3). + const dirty = history?.isDirty() || pickedTerrain() !== designTerrain; + options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), stationSnap) : null); const mine = ++terrainTicket; void loadBackground().then((background) => { if (closed || mine !== terrainTicket) return; diff --git a/resources/tester/helper_352_3_terrain.cjs b/resources/tester/helper_352_3_terrain.cjs new file mode 100644 index 000000000..1bb86fc36 --- /dev/null +++ b/resources/tester/helper_352_3_terrain.cjs @@ -0,0 +1,61 @@ +/* PLAN 352-3 단위검증 헬퍼 — 분석 지형 기본값(고른 적 없으면 데이터대로) · 사용자가 바꿀 때만 기억. + * 진짜 `B05_Profile_Api_Replan.ts` 를 트랜스파일해 Node 로 돌림. 화면 상태 보관소만 가짜(쓰기 횟수 셈). + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_352_3_analysis_terrain_default.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 store = new Map(); +let writes = 0; +const STUBS = { + config_frontend: { API_BASE_URL: "" }, + b_page_state: { + readState: (key, scope) => store.get(`${key}|${scope}`) ?? null, + writeState: (key, value, scope) => (writes++, store.set(`${key}|${scope}`, value)), + }, + common_util_surface_sampler: { heightAt: () => 0 }, + B05_Profile_Api_TerrainGrid: { loadDesignTerrainGrid: async () => null }, +}; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const api = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Replan.ts")); + +const out = {}; +// ① 사정을 모를 때 — 라이다 +out.unknown = api.analysisTerrain("p0"); +// ② 둘 다 있고 고른 적 없음 — 데이터대로(설계 라이다 → 라이다) · 쓰기 0 +api.setTerrainContext("p1", true, "las"); +out.bothNoPick = api.analysisTerrain("p1"); +out.writesAfterOpen = writes; +// ③ 설계가 수치지형도인 프로젝트 · 고른 적 없음 — 설계대로(고르기 글 = 실제 쓰는 지형) +api.setTerrainContext("p2", true, "sheet"); +out.sheetDesignNoPick = api.analysisTerrain("p2"); +// ④ 사용자가 바꿈 — 그때만 기억 · 다음 접근(사정 다시 알림)에도 남음 +api.setAnalysisTerrain("p1", "sheet"); +out.afterPick = api.analysisTerrain("p1"); +out.writesAfterPick = writes; +api.setTerrainContext("p1", true, "las"); +out.nextAccess = api.analysisTerrain("p1"); +// ⑤ 하나뿐인 프로젝트 — 옛 값이 남아 있어도 있는 것만 씀 +store.set("routeedit-terrain|p3", "las"); +api.setTerrainContext("p3", false, "sheet"); +out.sheetOnlyStale = api.analysisTerrain("p3"); +store.set("routeedit-terrain|p4", "sheet"); +api.setTerrainContext("p4", false, "las"); +out.lasOnlyStale = api.analysisTerrain("p4"); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_133_1_analysis_terrain.py b/resources/tester/test_133_1_analysis_terrain.py index 3ec2db211..eba2b8ab9 100644 --- a/resources/tester/test_133_1_analysis_terrain.py +++ b/resources/tester/test_133_1_analysis_terrain.py @@ -166,7 +166,7 @@ def test_화면_고르기_배선(): assert '' in chrome assert '' in chrome assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main - assert 'saved === "sheet" || saved === "las" ? saved : fallback' in api + assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api # 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음 assert api.count("terrain: analysisTerrain(projectId)") == 2 assert "terrain: analysisTerrain(projectId)" in initial_api diff --git a/resources/tester/test_133_2_design_terrain.py b/resources/tester/test_133_2_design_terrain.py index 552358ff8..c8d0edf84 100644 --- a/resources/tester/test_133_2_design_terrain.py +++ b/resources/tester/test_133_2_design_terrain.py @@ -294,4 +294,6 @@ def test_화면_확인이_고른_지형을_실음(): # 346-3 — 노선 초안(draft.terrain)에 실림 · [저장]이 보냄 assert "terrain: params.terrain() })" in apply and "terrain: settings.terrain," in apply assert "pickedTerrain() !== designTerrain" in main # 지형만 바꿔도 [확인]이 다시 셈 - assert "analysisTerrain(projectId, designTerrain)" in main # 고른 값 없으면 설계 지형 + assert ( + "setTerrainContext(projectId, choosable, designTerrain)" in main + ) # 고른 값 없으면 설계 지형 diff --git a/resources/tester/test_352_3_analysis_terrain_default.py b/resources/tester/test_352_3_analysis_terrain_default.py new file mode 100644 index 000000000..00aa1b676 --- /dev/null +++ b/resources/tester/test_352_3_analysis_terrain_default.py @@ -0,0 +1,54 @@ +"""352-3 분석 지형 기본값 — 고른 적 없으면 데이터대로 · 열 때 안 씀 · 사용자가 바꿀 때만 기억 · 바꾸면 묶음에 알림. + +./venv/Scripts/python.exe -m pytest resources/tester/test_352_3_analysis_terrain_default.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_352_3_terrain.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_고른_적_없으면_데이터대로_열_때_안_씀(out) -> None: + assert out["unknown"] == "las" + assert out["bothNoPick"] == "las" # 라이다가 있는 프로젝트 = 라이다 + assert out["writesAfterOpen"] == 0 # 열 때 pref 에 쓰지 않음 + assert out["sheetDesignNoPick"] == "sheet" # 설계 지형대로 — 고르기 글 = 실제 쓰는 지형 + + +def test_사용자가_바꿀_때만_기억(out) -> None: + assert out["afterPick"] == "sheet" and out["writesAfterPick"] == 1 + assert out["nextAccess"] == "sheet" # 다음 접근에도 + + +def test_하나뿐이면_있는_것만(out) -> None: + assert out["sheetOnlyStale"] == "sheet" + assert out["lasOnlyStale"] == "las" + + +def test_편집기_배선() -> None: + main = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert main.count("setAnalysisTerrain(") == 1 # 바꿀 때 한 곳뿐 — 열 때 쓰기 없음 + handler = main.split('terrainSelect.addEventListener("change"', 1)[1][:900] + assert "setAnalysisTerrain(projectId" in handler + assert "options.draft?.edited(" in handler # 바꾸면 계산 묶음에 알림 + # 설계 지형이 수치지형도면 확정 모델(수치지형도 서피스) 등고선을 「LAS」로 쓰지 않음 + assert 'analysisTerrain(projectId) === "las" && designTerrain === "las"' in main From cadff2af9092f9b54908b43941d45a2d321ce892 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:38:46 +0900 Subject: [PATCH 07/33] =?UTF-8?q?feat(=EC=84=A4=EC=A0=95):=20357-1=C2=B72?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20[=EB=8B=AB?= =?UTF-8?q?=EA=B8=B0]=EC=99=80=20=EA=B0=99=EC=9D=80=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=A9=B4=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EB=8B=AB=ED=9E=98=20=C2=B7=20=EB=8B=A8=EC=B6=95?= =?UTF-8?q?=ED=82=A4=20=ED=83=AD=20=ED=95=9C=20=EC=A4=84=EC=94=A9(?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=3D=20=EB=8F=99=EC=9E=91)=20=C2=B7=20?= =?UTF-8?q?=ED=82=A4=20=EC=B9=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단추 → 버튼 (마우스 · 다른 곳 단축키 글) - test_app_settings 저장 뒤 닫힘으로 고침 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/app_settings.css | 33 +++++++++++++++++ A00_Common/app_settings.ts | 37 +++++++++++++------ resources/tester/test_app_settings.py | 21 +++++------ ui_template/ui_template_locale_common.ts | 45 ++++++++++++------------ 4 files changed, 93 insertions(+), 43 deletions(-) diff --git a/A00_Common/app_settings.css b/A00_Common/app_settings.css index 4ac71bdcf..b2841ec1a 100644 --- a/A00_Common/app_settings.css +++ b/A00_Common/app_settings.css @@ -51,3 +51,36 @@ .app-settings__error:empty { display: none; } + +.app-settings__rules { + margin: 0 0 var(--spacing-8); + padding: 0; + list-style: none; +} + +.app-settings__rules > li { + display: grid; + grid-template-columns: minmax(0, 15em) 1fr; + align-items: center; + gap: var(--spacing-12); + padding: var(--spacing-4) 0; + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +.app-settings__keys { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.app-settings__keys > kbd { + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + color: var(--color-text); + font-family: var(--font-mono); + font-size: 0.85em; + line-height: 1.7; +} diff --git a/A00_Common/app_settings.ts b/A00_Common/app_settings.ts index d5ab19607..8e8efc4d5 100644 --- a/A00_Common/app_settings.ts +++ b/A00_Common/app_settings.ts @@ -37,7 +37,7 @@ const MOUSE_RULES: LocaleKey[] = [ "App_Settings_Rule_Pinch", ]; -/** ③ 안내표 줄 — [자리, 키] */ +/** ③ 안내 — [자리, 키] */ const KEY_ROWS: [LocaleKey, LocaleKey][] = [ ["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"], ["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"], @@ -55,6 +55,26 @@ const normalize = (p: InputPrefs): InputPrefs => ({ }); const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p)); +/** 규칙 목록 — 한 줄 = 「입력 = 동작」 · 입력은 키 칩(` · ` 로 여럿) · 동작 없는 줄은 칩만 */ +function ruleList(keys: LocaleKey[]): HTMLElement { + const items = keys + .flatMap((key) => L(key).split("\n")) + .map((line) => { + const at = line.indexOf(" = "); + const input = at < 0 ? line : line.slice(0, at); + return el("li", { + children: [ + el("span", { + className: "app-settings__keys", + children: input.split(" · ").map((text) => el("kbd", { text })), + }), + ...(at < 0 ? [] : [el("span", { text: line.slice(at + 3) })]), + ], + }); + }); + return el("ul", { className: "app-settings__rules", children: items }); +} + function mouseTab(draft: InputPrefs): HTMLElement { const group = el("div", { className: "app-settings__radios", @@ -73,18 +93,14 @@ function mouseTab(draft: InputPrefs): HTMLElement { el("h4", { text: L("App_Settings_Wheel") }), group, el("h4", { text: L("App_Settings_Rules") }), - el("ul", { children: MOUSE_RULES.map((key) => el("li", { text: L(key) })) }), + ruleList(MOUSE_RULES), ], }); } function keysTab(): HTMLElement { - const rows = KEY_ROWS.map(([place, keys]) => - el("tr", { children: [el("th", { text: L(place) }), el("td", { text: L(keys) })] }), - ); - return el("table", { - className: "app-settings__table", - children: [el("tbody", { children: rows })], + return el("div", { + children: KEY_ROWS.flatMap(([place, keys]) => [el("h4", { text: L(place) }), ruleList([keys])]), }); } @@ -138,10 +154,9 @@ export function openAppSettings(): void { const saveButton = createButton({ label: L("Common_Btn_Save"), variant: "filled", - size: "sm", - onClick: () => void save(), + onClick: () => save() && modal.close(), }); - openModal({ + const modal = openModal({ title: L("App_Settings"), closeLabel: L("Common_Btn_Close"), dialogClass: "app-settings", diff --git a/resources/tester/test_app_settings.py b/resources/tester/test_app_settings.py index 9fde1be4c..9016b0b37 100644 --- a/resources/tester/test_app_settings.py +++ b/resources/tester/test_app_settings.py @@ -140,16 +140,16 @@ def test_설정_창_탭_저장_물음(): page.click(".ui-confirm button >> nth=0") # 취소 assert page.locator(".app-settings").count() == 1 and _writes(page) == [] - # [저장] = 한 번 씀 · 읽기 층 방향 뒤집힘 · 닫을 때 안 물음 + # [저장] = 한 번 씀 · 바로 닫힘(물음 없음) · 읽기 층 방향 뒤집힘 _save(page) + assert page.locator(".app-settings").count() == 0 + assert page.locator(".ui-confirm").count() == 0 assert len(_writes(page)) == 1 assert json.loads(_writes(page)[0]) == {"wheelZoom": "pull", "cadAliases": {}} assert page.evaluate(WHEEL_IN, 100) is True and page.evaluate(WHEEL_IN, -100) is False - _save(page) # 고친 것 없음 — 또 안 씀 - assert len(_writes(page)) == 1 - _close(page) - assert page.locator(".app-settings").count() == 0 - assert page.locator(".ui-confirm").count() == 0 + _open_settings(page) + _save(page) # 고친 것 없음 — 또 안 씀 · 닫힘 + assert len(_writes(page)) == 1 and page.locator(".app-settings").count() == 0 # CAD 별칭 — 잘못이면 안 씀 · 칸 표시 _open_settings(page) @@ -163,25 +163,26 @@ def test_설정_창_탭_저장_물음(): _save(page) assert page.inner_text(".app-settings__error").strip(), (line_v, circle_v) assert page.locator(".app-settings input[aria-invalid=true]").count() >= 1 - assert len(_writes(page)) == 1 + assert len(_writes(page)) == 1 and page.locator(".app-settings").count() == 1 # 창 남음 page.fill(line, "ll") page.fill(circle, "") _save(page) - assert page.inner_text(".app-settings__error").strip() == "" + assert page.locator(".app-settings").count() == 0 # 저장 성공 = 닫힘 assert len(_writes(page)) == 2 assert json.loads(_writes(page)[1]) == {"wheelZoom": "pull", "cadAliases": {"LINE": ["LL"]}} # 닫을 때 물음 — 버림 = 안 씀 · 저장 = 한 번 더 + _open_settings(page) page.click("[data-settings-tab=mouse]") page.check("input[name=app-settings-wheel][value=push]") _close(page) - page.click(".ui-confirm button >> nth=1") # 버리고 넘어감 + page.click(".ui-confirm button >> nth=1") # 폐기 assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 2 assert page.evaluate(WHEEL_IN, 100) is True _open_settings(page) page.check("input[name=app-settings-wheel][value=push]") _close(page) - page.click(".ui-confirm button >> nth=2") # 저장 후 넘어감 + page.click(".ui-confirm button >> nth=2") # 저장 assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 3 assert json.loads(_writes(page)[2])["wheelZoom"] == "push" assert page.evaluate(WHEEL_IN, 100) is False diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index b2035a21e..7a5e3a990 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -40,7 +40,7 @@ export const ui_locales_common = { * ------------------------------------------------------------------------ */ Common_Status_Loading: ["불러오는 중...", "Loading..."], Common_Load_Failed: ["불러오지 못함", "Failed to load"], - Common_Reload: ["다시 불러오기", "Reload"], + Common_Reload: ["새로고침", "Reload"], Common_Status_Saving: ["저장 중...", "Saving..."], Common_Status_Processing: ["처리 중...", "Processing..."], Common_Status_Success: ["완료되었습니다", "Completed"], @@ -117,16 +117,17 @@ export const ui_locales_common = { App_Settings_Wheel: ["휠 확대 방향(모든 화면)", "Wheel zoom-in direction (all views)"], App_Settings_Wheel_Pull: ["당기면 확대(휠을 몸 쪽으로)", "Pull to zoom in (wheel toward you)"], App_Settings_Wheel_Push: ["밀면 확대(휠을 앞으로)", "Push to zoom in (wheel away)"], - App_Settings_Rules: ["마우스 단추", "Mouse buttons"], - App_Settings_Rule_Pan: ["화면 옮기기 = 가운데 단추 끌기", "Pan = drag with the middle button"], - App_Settings_Rule_Pick: ["고르기 = 왼쪽 단추", "Select = left button"], + /* 아래 규칙 값 = 한 줄에 「입력 = 동작」 · 줄바꿈(\n)으로 줄 나눔 · ko · en 줄 수 같게 */ + App_Settings_Rules: ["마우스 버튼", "Mouse buttons"], + App_Settings_Rule_Pan: ["가운데 버튼 끌기 = 화면 옮김", "Middle button drag = pan"], + App_Settings_Rule_Pick: ["왼쪽 버튼 = 고르기", "Left button = select"], App_Settings_Rule_Right: [ - "오른쪽 단추 = 메뉴 · CAD 에서는 명령 중 Enter · 쉴 때 반복 · Shift = 스냅 메뉴 · CAD 휠 두 번 = 범위", - "Right button = menu · in CAD Enter during a command · repeat when idle · Shift = snap menu · CAD wheel double-click = extents", + "오른쪽 버튼 = 메뉴\n오른쪽 버튼(CAD 명령 중) = Enter\n오른쪽 버튼(CAD 쉴 때) = 반복\nShift+오른쪽 버튼(CAD) = 스냅 메뉴\n휠 두 번 누름(CAD) = 범위", + "Right button = menu\nRight button (CAD, during a command) = Enter\nRight button (CAD, idle) = repeat\nShift+right button (CAD) = snap menu\nWheel double-click (CAD) = extents", ], App_Settings_Rule_Pinch: [ - "트랙패드 · 손가락 벌림 = 늘 확대(휠 방향과 상관없음)", - "Trackpad · finger spread = always zoom in (whatever the wheel direction)", + "트랙패드 손가락 벌림 = 늘 확대(휠 방향 무관)", + "Trackpad finger spread = always zoom in (any wheel direction)", ], App_Settings_Cad_Help: [ "CAD 는 명령 이름 > 내 별칭 > 기본 별칭 차례로 찾음 · 영문 · 숫자만 · 여러 개는 쉼표로", @@ -144,38 +145,38 @@ export const ui_locales_common = { App_Settings_Leave: ["저장 안 한 설정이 있음", "You have unsaved settings"], App_Settings_Keys_All: ["모든 화면", "All views"], App_Settings_Keys_All_Keys: [ - "Esc = ☰ · 창 닫기 · 고름 풀기 · Z 누른 채 또는 오른쪽 0.4초 누름 = 돋보기(휠 = 배율)", - "Esc = ☰ · close window · clear selection · hold Z or right button 0.4 s = loupe (wheel = zoom)", + "Esc = ☰ · 창 닫기 · 고름 풀기\nZ 누른 채 = 돋보기\n오른쪽 버튼 0.4초 누름 = 돋보기\n돋보기 중 휠 = 배율", + "Esc = ☰ · close window · clear selection\nHold Z = loupe\nRight button 0.4 s = loupe\nWheel in loupe = zoom", ], App_Settings_Keys_Profile: ["B05 종단", "B05 profile"], App_Settings_Keys_Profile_Keys: [ - "Ctrl+Z 되돌림 · Ctrl+Shift+Z 다시 · 방향키 = 옮김 · 자유지정 Enter = 끝 · Esc · Backspace", - "Ctrl+Z undo · Ctrl+Shift+Z redo · arrows = move · free mode Enter = finish · Esc · Backspace", + "Ctrl+Z = 되돌림\nCtrl+Shift+Z = 다시\n방향키 = 옮김\nEnter(자유지정) = 끝\nEsc · Backspace", + "Ctrl+Z = undo\nCtrl+Shift+Z = redo\nArrows = move\nEnter (free mode) = finish\nEsc · Backspace", ], App_Settings_Keys_RouteEdit: ["B05 노선 편집", "B05 route edit"], App_Settings_Keys_RouteEdit_Keys: [ - "Ctrl+Z 되돌림 · Ctrl+Y · Ctrl+Shift+Z 다시", - "Ctrl+Z undo · Ctrl+Y · Ctrl+Shift+Z redo", + "Ctrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+Shift+Z = 다시", + "Ctrl+Z = undo\nCtrl+Y = redo\nCtrl+Shift+Z = redo", ], App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"], App_Settings_Keys_Section_Keys: [ - "방향키 · ▲▼ = 측점 옮김 · Enter · Space = 고름 · Esc", - "Arrows · ▲▼ = move station · Enter · Space = pick · Esc", + "방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc", + "Arrows = move station\n▲▼ = move station\nEnter = pick\nSpace = pick\nEsc", ], App_Settings_Keys_Cad: ["CAD", "CAD"], App_Settings_Keys_Cad_Keys: [ - "F3 스냅 · F7 그리드 · F8 직교 · F10 45° · Ctrl+Z · Ctrl+Y · Ctrl+A · Ctrl+S(M02) · Delete · Esc · Enter · Space = Enter · ↑↓ = 기록 · 최근 값 · Ctrl+C · X · 1 · ← → · Shift+↑↓ = 화면 옮김 · 글자 = 명령", - "F3 snap · F7 grid · F8 ortho · F10 45° · Ctrl+Z · Ctrl+Y · Ctrl+A · Ctrl+S (M02) · Delete · Esc · Enter · Space = Enter · ↑↓ = history · recent values · Ctrl+C · X · 1 · ← → · Shift+↑↓ = pan · letters = command", + "F3 = 스냅\nF7 = 그리드\nF8 = 직교\nF10 = 45°\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+S(M02) = 저장\nSpace = Enter\n↑↓ = 기록 · 최근 값\nShift+↑↓ = 화면 옮김\n글자 = 명령\nCtrl+A · Delete · Esc · Enter · Ctrl+C · X · 1 · ← →", + "F3 = snap\nF7 = grid\nF8 = ortho\nF10 = 45°\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+S (M02) = save\nSpace = Enter\n↑↓ = history · recent values\nShift+↑↓ = pan\nLetters = command\nCtrl+A · Delete · Esc · Enter · Ctrl+C · X · 1 · ← →", ], App_Settings_Keys_Table: ["B08 · B09 표", "B08 · B09 tables"], App_Settings_Keys_Table_Keys: [ - "Enter · F2 = 입력 · 방향키 = 칸 옮김 · Esc = 그만", - "Enter · F2 = edit · arrows = move cell · Esc = cancel", + "Enter = 입력\nF2 = 입력\n방향키 = 칸 옮김\nEsc = 그만", + "Enter = edit\nF2 = edit\nArrows = move cell\nEsc = cancel", ], App_Settings_Keys_M02: ["M02 양식", "M02 templates"], App_Settings_Keys_M02_Keys: [ - "Ctrl+S 저장 · Ctrl+C · Ctrl+V · Delete · Esc · Space+끌기 = 화면 옮김 · Ctrl+휠 = 배율 · 시트 = 엑셀 키", - "Ctrl+S save · Ctrl+C · Ctrl+V · Delete · Esc · Space+drag = pan · Ctrl+wheel = zoom · sheet = Excel keys", + "Ctrl+S = 저장\nSpace+끌기 = 화면 옮김\nCtrl+휠 = 배율\n시트 안 = 엑셀 키 그대로\nCtrl+C · Ctrl+V · Delete · Esc", + "Ctrl+S = save\nSpace+drag = pan\nCtrl+wheel = zoom\nInside a sheet = Excel keys\nCtrl+C · Ctrl+V · Delete · Esc", ], App_Footer_Copyright: [ "© 2026 임도설계 및 견적 자동화. All rights reserved.", From 2a378eb5c84c11dc2fe53857972a9d5eb3a203d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:38:55 +0900 Subject: [PATCH 08/33] =?UTF-8?q?fix(=EB=B2=84=ED=8A=BC):=20357-3=20?= =?UTF-8?q?=EC=84=9C=EC=88=A0=ED=98=95=20=EB=B2=84=ED=8A=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=E2=86=92=20=EB=AA=85=EC=82=AC=ED=98=95=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EB=8B=A8=EC=B6=94=E3=80=8D=20=E2=86=92=20=E3=80=8C?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=E3=80=8D=20(A06=20=C2=B7=20A07=20=C2=B7=20B0?= =?UTF-8?q?1=20=C2=B7=20B03=20=C2=B7=20B09=20=C2=B7=20B11=20=C2=B7=20M01?= =?UTF-8?q?=20=C2=B7=20=EC=97=B4=EB=A6=B0=20=EB=AC=BC=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=8A=A4=ED=94=84=EB=A0=88=EB=93=9C=EC=8B=9C=ED=8A=B8=20=C2=B7?= =?UTF-8?q?=20=EB=8F=84=EC=9B=80=EB=A7=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 후 넘어감 → 저장 · 버리고 넘어감 → 폐기 · 다시 불러오기 → 새로고침 · 지우기 → 삭제 등 - test_303 · test_ui_loading 글 맞춤 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/design/design_leave.ts | 6 +++--- A00_Common/spreadsheet/spreadsheet_actions.ts | 2 +- A00_Common/spreadsheet/spreadsheet_condfmt.ts | 2 +- A00_Common/spreadsheet/spreadsheet_condition.ts | 2 +- .../spreadsheet/spreadsheet_format_dialog.ts | 2 +- A00_Common/spreadsheet/spreadsheet_names.ts | 2 +- A00_Common/spreadsheet/spreadsheet_sheet_extra.ts | 2 +- A00_Common/spreadsheet/spreadsheet_text.ts | 8 ++++---- A00_Common/spreadsheet/spreadsheet_validation.ts | 2 +- B01_Dashboard/B01_Dashboard_UI_AddressField.ts | 2 +- B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts | 6 +++--- B01_Dashboard/B01_Dashboard_UI_Modals.ts | 6 +++--- .../openwebcad/src/commands/commands.view.ts | 2 +- B09_Estimation/B09_Estimation_UI_Popup.ts | 2 +- .../B09_Estimation_UI_Tabs_Base_Quote.ts | 2 +- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 2 +- B09_Estimation/draft/B09_Draft_Print_Page.ts | 6 +++--- M01_MasterData/M01_MasterData_UI_Logic_Text.ts | 4 ++-- .../DataFlow/M02_DF_Diagram_Parts.ts | 2 +- M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts | 2 +- .../M02_MasterTemplete_Labor_Diagram.ts | 2 +- resources/help/B02/00_설명서.md | 2 +- resources/help/B03/00_설명서.md | 2 +- resources/help/B04/00_설명서.md | 2 +- resources/help/B05/00_설명서.md | 2 +- resources/help/B06/00_설명서.md | 2 +- resources/help/B07/00_설명서.md | 2 +- resources/help/B08/00_설명서.md | 2 +- resources/help/B09/00_설명서.md | 2 +- resources/tester/test_303_b09_row_popup.py | 4 ++-- resources/tester/test_m02_leave_dialog.py | 6 +++--- resources/tester/test_ui_loading.py | 4 ++-- ui_template/cad_host/cad_host_frame_edit.ts | 2 +- ui_template/cad_host/cad_host_tabs.ts | 4 ++-- ui_template/sheet/ui_template_sheet_text.ts | 2 +- ui_template/ui_template_leave_dialog.ts | 4 ++-- ui_template/ui_template_locale_a.ts | 6 +++--- ui_template/ui_template_locale_b1.ts | 8 ++++---- ui_template/ui_template_locale_b2.ts | 6 +++--- ui_template/ui_template_locale_b3.ts | 4 ++-- ui_template/ui_template_locale_m1.ts | 14 +++++++------- ui_template/ui_template_locale_m2.ts | 6 +++--- 42 files changed, 76 insertions(+), 76 deletions(-) diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts index 345ba4168..ddb73bd98 100644 --- a/A00_Common/design/design_leave.ts +++ b/A00_Common/design/design_leave.ts @@ -1,7 +1,7 @@ /* ============================================================================= * A00_Common/design/design_leave.ts * B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때 - * 공용 물음(`ui_template_leave_dialog` · [취소] [버리고 넘어감] [저장 후 넘어감]). + * 공용 물음(`ui_template_leave_dialog` · [취소] [폐기] [저장]). * * 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) · * 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것. @@ -24,7 +24,7 @@ import { } from "../b_page_state"; import { currentRoute, setLeaveGuard } from "../router"; -/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [버리고 넘어감]에도 안 지움. */ +/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [폐기]에도 안 지움. */ const NOT_EDITS = new Set([ "selection", "pipe-ids", @@ -81,7 +81,7 @@ export function designDirty(projectId: string): boolean { return cleanMarks.get(projectId) !== digest(signature); } -/** [버리고 넘어감] — 고침 칸만 지움(모든 노선). */ +/** [폐기] — 고침 칸만 지움(모든 노선). */ export function discardDesignEdits(projectId: string): void { editDraftNames().forEach((name) => clearState(name, projectId)); } diff --git a/A00_Common/spreadsheet/spreadsheet_actions.ts b/A00_Common/spreadsheet/spreadsheet_actions.ts index bf5dc6c13..a29be7fdc 100644 --- a/A00_Common/spreadsheet/spreadsheet_actions.ts +++ b/A00_Common/spreadsheet/spreadsheet_actions.ts @@ -23,7 +23,7 @@ const TEXT = { freezeAt: "지금 칸 기준 고정", unfreeze: "틀 고정 풀기", insert: "칸 넣기…(Ctrl+Shift++)", - remove: "칸 지우기…(Ctrl+-)", + remove: "칸 삭제…(Ctrl+-)", split: "텍스트 나누기…", dedupe: "중복된 항목 제거…", series: "연속 데이터 채우기…", diff --git a/A00_Common/spreadsheet/spreadsheet_condfmt.ts b/A00_Common/spreadsheet/spreadsheet_condfmt.ts index 625b7d7f6..a92a02de4 100644 --- a/A00_Common/spreadsheet/spreadsheet_condfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_condfmt.ts @@ -249,7 +249,7 @@ export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle { moveCondFmtRule(ctx, rule.id, 1); render(); }); - const del = createButton({ label: "지움", variant: "danger" }); + const del = createButton({ label: "삭제", variant: "danger" }); del.addEventListener("click", () => { removeCondFmtRule(ctx, rule.id); render(); diff --git a/A00_Common/spreadsheet/spreadsheet_condition.ts b/A00_Common/spreadsheet/spreadsheet_condition.ts index f53abb816..dd59f3476 100644 --- a/A00_Common/spreadsheet/spreadsheet_condition.ts +++ b/A00_Common/spreadsheet/spreadsheet_condition.ts @@ -44,7 +44,7 @@ const TEXT = { Then: ["→", "→"], Otherwise: ["아니면 →", "Otherwise →"], AddElseIf: ["+ 아니면 만약", "+ Else if"], - Remove: ["지움", "Remove"], + Remove: ["삭제", "Remove"], Make: ["만들기", "Make"], Cancel: ["취소", "Cancel"], Close: ["닫기", "Close"], diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts index 66d199ee7..ce97d678d 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts @@ -66,7 +66,7 @@ const TEXT = { left: "왼", right: "오른", fillColor: "채움색", - clear: "지움", + clear: "삭제", protectNote: "칸 잠금 · 숨김은 자리만 둠(시트 보호 자체가 아직 없음)", lock: "잠금", hidden: "수식 숨김", diff --git a/A00_Common/spreadsheet/spreadsheet_names.ts b/A00_Common/spreadsheet/spreadsheet_names.ts index 7bd63780d..0849b2f9a 100644 --- a/A00_Common/spreadsheet/spreadsheet_names.ts +++ b/A00_Common/spreadsheet/spreadsheet_names.ts @@ -190,7 +190,7 @@ export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle { input.focus(); input.select(); }); - const del = createButton({ label: "지움", variant: "danger" }); + const del = createButton({ label: "삭제", variant: "danger" }); del.addEventListener("click", () => { deleteName(ctx, 이름); render(); diff --git a/A00_Common/spreadsheet/spreadsheet_sheet_extra.ts b/A00_Common/spreadsheet/spreadsheet_sheet_extra.ts index a254ed7dc..76aa7a80d 100644 --- a/A00_Common/spreadsheet/spreadsheet_sheet_extra.ts +++ b/A00_Common/spreadsheet/spreadsheet_sheet_extra.ts @@ -76,7 +76,7 @@ export function createSheetExtraPanel(ctx: SpreadsheetContext): PartHandle { colorInput.addEventListener("change", () => setSheetTabColor(ctx, sheet.id, colorInput.value), ); - const colorClear = createButton({ label: "색 지움", variant: "ghost" }); + const colorClear = createButton({ label: "색 삭제", variant: "ghost" }); colorClear.addEventListener("click", () => { setSheetTabColor(ctx, sheet.id, null); render(); diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index f5cad79f0..548238146 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -25,7 +25,7 @@ const TEXT = { Strike: ["취소선", "Strikethrough"], TextColor: ["글자색", "Text color"], FillColor: ["채움색", "Fill color"], - ColorClear: ["지움", "Clear"], + ColorClear: ["색 삭제", "Clear color"], HAlign: ["가로 정렬", "Horizontal align"], VAlign: ["세로 정렬", "Vertical align"], Align_general: ["일반", "General"], @@ -70,7 +70,7 @@ const TEXT = { ], SheetAdd: ["시트 추가", "Add sheet"], SheetRename: ["이름 바꾸기", "Rename"], - SheetDelete: ["지우기", "Delete"], + SheetDelete: ["삭제", "Delete"], SheetDuplicate: ["복사", "Duplicate"], SheetRenamePrompt: ["새 시트 이름", "New sheet name"], SheetDeleteConfirm: [ @@ -84,9 +84,9 @@ const TEXT = { MenuCopy: ["복사", "Copy"], MenuPasteHint: ["Ctrl+V 를 쓸 것", "Use Ctrl+V"], MenuInsertRow: ["행 넣기", "Insert row"], - MenuDeleteRow: ["행 지우기", "Delete row"], + MenuDeleteRow: ["행 삭제", "Delete row"], MenuInsertCol: ["열 넣기", "Insert column"], - MenuDeleteCol: ["열 지우기", "Delete column"], + MenuDeleteCol: ["열 삭제", "Delete column"], MenuHide: ["숨기기", "Hide"], MenuUnhide: ["숨김 취소", "Unhide"], MenuClearContent: ["내용 지우기", "Clear content"], diff --git a/A00_Common/spreadsheet/spreadsheet_validation.ts b/A00_Common/spreadsheet/spreadsheet_validation.ts index 51b376306..23a7137d1 100644 --- a/A00_Common/spreadsheet/spreadsheet_validation.ts +++ b/A00_Common/spreadsheet/spreadsheet_validation.ts @@ -183,7 +183,7 @@ export function createValidationPanel(ctx: SpreadsheetContext): PartHandle { const label = el("span", { text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`, }); - const del = createButton({ label: "지움", variant: "danger" }); + const del = createButton({ label: "삭제", variant: "danger" }); del.addEventListener("click", () => { removeValidationRule(ctx, rule.id); render(); diff --git a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts index 595a03116..a9850245d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts @@ -58,7 +58,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { placeholder: "도로명·지번·건물명 일부 (예: 판교역로 235)", }); const findBtn = createButton({ - label: "주소 찾기", + label: "주소 검색", variant: "ghost", onClick: () => void runSearch(), }); diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index 780028bec..3a1a36ced 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -70,7 +70,7 @@ export function createAssetField( const fallback = options.fallback ?? null; const reset = createButton({ - label: "기본으로", + label: "기본값 복원", variant: "ghost", onClick: () => { selected = null; @@ -173,7 +173,7 @@ function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise { context?.clearRect(0, 0, canvas.width, canvas.height); @@ -312,7 +312,7 @@ function openAssetPickerModal( ); const pad = kind === "SIGNATURE" ? createSignaturePad() : null; const add = createButton({ - label: "파일 올리고 이 프로젝트에 쓰기", + label: "파일 업로드", onClick: async () => { if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요."); const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null; diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 6a7766388..7ec8e65cf 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -461,7 +461,7 @@ export function openCreateCompanyModal(): void { const matches = document.createElement("div"); matches.className = "b01-dashboard__actions"; const findBtn = createButton({ - label: "이미 있는 회사인지 찾기", + label: "회사 검색", variant: "ghost", onClick: async function onB01_Company_Duplicate_Click() { matches.innerHTML = ""; @@ -589,7 +589,7 @@ export function openAddMemberModal( companyId?: number | null, ): void { const query = createInputField({ - label: "이름 또는 이메일로 찾기", + label: "이름 · 이메일 검색", placeholder: "두 글자 이상", }); const results = document.createElement("div"); @@ -632,7 +632,7 @@ export function openAddMemberModal( }); const invite = createButton({ - label: "가입 안내 메일 보내기", + label: "초대 메일 발송", variant: "ghost", onClick: async function onB01_Member_Invite_Click() { const text = query.input.value.trim(); diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts index 044259c38..a0b287178 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -43,7 +43,7 @@ export const VIEW_COMMANDS: CadCommand[] = [ label: '범위 줌', aliases: ['Z'], glyph: '⛶', - hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 단추 = 바로 범위)', + hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 버튼 = 바로 범위)', run: () => { startZoomPrompt(); // 덧물음 — 명령 중 'Z 도 같은 길 return ''; diff --git a/B09_Estimation/B09_Estimation_UI_Popup.ts b/B09_Estimation/B09_Estimation_UI_Popup.ts index eb45a731f..23a9e791c 100644 --- a/B09_Estimation/B09_Estimation_UI_Popup.ts +++ b/B09_Estimation/B09_Estimation_UI_Popup.ts @@ -144,7 +144,7 @@ export function openRowPopup(o: PopupOpen): void { title.className = "b09-popup__title"; title.textContent = tabName(tab); const goButton = createButton({ - label: "탭으로", + label: "탭 이동", variant: "ghost", size: "sm", onClick: () => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts index 6525253d6..8a28db2ef 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts @@ -59,7 +59,7 @@ export function openQuotes(key: string, label: string, onChange: (names: string[ onClick: () => void download(key, f.name).catch(fail), }), createButton({ - label: "지우기", + label: "삭제", variant: "danger", size: "sm", onClick: () => diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index aff2f53c6..f70f0cb37 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -28,7 +28,7 @@ const ACTS: [string, string][] = [ ["↓ 아래로", "아래로 ↓"], ]; -const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력"; +const GUIDE = "줄 누르고 버튼 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력"; let picked: string | null = null; /** 242-1 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */ diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts index 913f74859..1a818890f 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -178,7 +178,7 @@ export function renderB09Print(root: HTMLElement): MountedPage { }; const pdfBtn = createButton({ - label: "PDF 내려받기", + label: "PDF 다운로드", onClick: () => { if (!shown) return; const name = `${label()}.pdf`; @@ -190,7 +190,7 @@ export function renderB09Print(root: HTMLElement): MountedPage { }); pdfBtn.dataset.b09PrintPdf = ""; const xlsxBtn = createButton({ - label: "엑셀 내려받기", + label: "엑셀 다운로드", variant: "ghost", onClick: () => { const ids = picked(); @@ -205,7 +205,7 @@ export function renderB09Print(root: HTMLElement): MountedPage { }); xlsxBtn.dataset.b09PrintXlsx = ""; const back = createButton({ - label: "B09 로 돌아가기", + label: "B09 복귀", variant: "ghost", onClick: () => navigateTo(ROUTES.B09_ESTIMATION), }); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index 6a7f7010a..9c69ff747 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -18,7 +18,7 @@ const TEXT = { List_New: ["새것", "New"], List_Deleted: ["지움", "Deleted"], Bar_Back: ["← 로직 목록", "← Logics"], - Bar_Delete: ["로직 지우기", "Delete logic"], + Bar_Delete: ["로직 삭제", "Delete logic"], Bar_Save: ["저장", "Save"], Bar_Discard: ["캐시 삭제", "Clear cache"], Bar_Pending: ["저장 안 한 로직 {n} 개", "{n} unsaved logics"], @@ -57,7 +57,7 @@ const TEXT = { Rounding: ["끝수", "Rounding"], Formula: ["식", "Formula"], AddRow: ["줄 추가", "Add line"], - DeleteRow: ["줄 지우기", "Delete line"], + DeleteRow: ["줄 삭제", "Delete line"], Calc_Title: ["시험 계산", "Test calculation"], Calc_Run: ["계산", "Calculate"], Calc_NoInputs: ["받을 값 없음", "No inputs"], diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index a81da8fbc..cbf1c78f3 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -60,7 +60,7 @@ export const TEXT = { ], LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], Hint: [ - "상자 머리를 끌어 옮김 · 가운데 단추(또는 Space + 끌기)로 판 옮김 · 휠로 확대", + "상자 머리를 끌어 옮김 · 가운데 버튼(또는 Space + 끌기)로 판 옮김 · 휠로 확대", "Drag a head to move · middle button (or Space + drag) to pan · wheel to zoom", ], Break: ["끊긴 곳", "Break"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts b/M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts index 9f34e5722..7e9821948 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts @@ -65,7 +65,7 @@ export const CARD_HELP: Record<"structure" | "labor" | "area", CardHelp> = { ["로직 줄 누름", "넣을 값 창 — 그 로직의 넣을 값 고침"], ["수량산출 칸", "수기 입력 · 면적산출 칸 고르기"], ["별칭 칸", "줄에 따로 이름 붙임"], - ["줄 끝 단추", "그 줄을 뺌"], + ["줄 끝 버튼", "그 줄을 뺌"], ["자재 추가", "자재를 골라 자재목록에 넣음"], FOLD, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 1d653f7a2..7dab24291 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -23,7 +23,7 @@ const TEXT = { Zoom_Out: ["작게", "Zoom out"], Fit: ["한눈에", "Fit"], Hint: [ - "가운데 단추 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대", + "가운데 버튼 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대", "Middle button or Space + drag to move · Ctrl + wheel to zoom", ], None: ["단가산출 로직이 없음", "No logics yet"], diff --git a/resources/help/B02/00_설명서.md b/resources/help/B02/00_설명서.md index ce3f51ea9..6b093ee97 100644 --- a/resources/help/B02/00_설명서.md +++ b/resources/help/B02/00_설명서.md @@ -4,6 +4,6 @@ ## 작업 순서 -## 단추 +## 버튼 ## 주의할 점 diff --git a/resources/help/B03/00_설명서.md b/resources/help/B03/00_설명서.md index bf1fc7923..2d7ae7e01 100644 --- a/resources/help/B03/00_설명서.md +++ b/resources/help/B03/00_설명서.md @@ -4,6 +4,6 @@ ## 작업 순서 -## 단추 +## 버튼 ## 주의할 점 diff --git a/resources/help/B04/00_설명서.md b/resources/help/B04/00_설명서.md index bcb9a433f..399e4ea5f 100644 --- a/resources/help/B04/00_설명서.md +++ b/resources/help/B04/00_설명서.md @@ -4,6 +4,6 @@ ## 작업 순서 -## 단추 +## 버튼 ## 주의할 점 diff --git a/resources/help/B05/00_설명서.md b/resources/help/B05/00_설명서.md index 52d74d9b4..89349f545 100644 --- a/resources/help/B05/00_설명서.md +++ b/resources/help/B05/00_설명서.md @@ -4,6 +4,6 @@ ## 작업 순서 -## 단추 +## 버튼 ## 주의할 점 diff --git a/resources/help/B06/00_설명서.md b/resources/help/B06/00_설명서.md index 0308a208e..e19d73653 100644 --- a/resources/help/B06/00_설명서.md +++ b/resources/help/B06/00_설명서.md @@ -4,6 +4,6 @@ ## 작업 순서 -## 단추 +## 버튼 ## 주의할 점 diff --git a/resources/help/B07/00_설명서.md b/resources/help/B07/00_설명서.md index 6fc3ddd3c..1a82be777 100644 --- a/resources/help/B07/00_설명서.md +++ b/resources/help/B07/00_설명서.md @@ -4,6 +4,6 @@ ## 작업 순서 -## 단추 +## 버튼 ## 주의할 점 diff --git a/resources/help/B08/00_설명서.md b/resources/help/B08/00_설명서.md index 291ac50b7..5b7df54e0 100644 --- a/resources/help/B08/00_설명서.md +++ b/resources/help/B08/00_설명서.md @@ -4,6 +4,6 @@ ## 작업 순서 -## 단추 +## 버튼 ## 주의할 점 diff --git a/resources/help/B09/00_설명서.md b/resources/help/B09/00_설명서.md index 4297e096b..6f6f854a6 100644 --- a/resources/help/B09/00_설명서.md +++ b/resources/help/B09/00_설명서.md @@ -4,6 +4,6 @@ ## 작업 순서 -## 단추 +## 버튼 ## 주의할 점 diff --git a/resources/tester/test_303_b09_row_popup.py b/resources/tester/test_303_b09_row_popup.py index 2012e4161..a6f1d1eb8 100644 --- a/resources/tester/test_303_b09_row_popup.py +++ b/resources/tester/test_303_b09_row_popup.py @@ -249,7 +249,7 @@ def test_창_열_제목_강조_줄_자리(): ) assert out["boxes"] == ["bill"] and out["n"] == 1 assert out["cls"] == "b09-estimation b09-popup ui-popup" # 창 안 = b09 표 꼴(빨강 테두리) - assert out["head"] == ["설계내역서", "탭으로", "×"] + assert out["head"] == ["설계내역서", "탭 이동", "×"] assert out["rows"] == ["b1", "b2", "b3"] # 그 줄 + 자손 assert out["rowCls"] == ["is-title is-pick is-pick-top", "is-pick", "is-pick is-pick-end"] assert out["columns"] is True and out["caption"] == "설계내역서" # 열 제목(머리 여러 줄) 그대로 @@ -291,7 +291,7 @@ def test_닫기_탭으로_새_누름_주소(): "\nconst a = boxes();" "\nopen(); tables[2].onCellClick(0, 'name'); open('b4');" # 화면 새 누름 = 있던 창 모두 닫고 새로 "\nconst b = [boxes(), rows(4)];" - "\nhead(0)[1].click();" # [탭으로] = 옮기고 모두 닫음 + "\nhead(0)[1].click();" # [탭 이동] = 옮기고 모두 닫음 "\nconst c = [boxes(), [...gone]];" "\nopen(); hashFns.forEach((f) => f());" "\nconst d = boxes();" diff --git a/resources/tester/test_m02_leave_dialog.py b/resources/tester/test_m02_leave_dialog.py index 594b1fc7e..2b8017c54 100644 --- a/resources/tester/test_m02_leave_dialog.py +++ b/resources/tester/test_m02_leave_dialog.py @@ -1,9 +1,9 @@ """저장 안 한 고침 3단추 물음(PLAN 86-1 · `ui_template_leave_dialog.ts`) — Node 로 TS 를 돌려 판정. 못박는 것 - - 단추 순서 [취소] [버리고 넘어감] [저장 후 넘어감] · 기본 초점 = 취소 · Esc = 취소. - - 저장 후 넘어감: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾). - - 버리고 넘어감: discard 갈고리 호출 · 취소: 아무것도 안 부름. + - 단추 순서 [취소] [폐기] [저장] · 기본 초점 = 취소 · Esc = 취소. + - 저장: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾). + - 폐기: discard 갈고리 호출 · 취소: 아무것도 안 부름. - M02 자리(UI_Main · Structure · ItemCards)가 이 물음을 씀. """ diff --git a/resources/tester/test_ui_loading.py b/resources/tester/test_ui_loading.py index f065511eb..f29688c0f 100644 --- a/resources/tester/test_ui_loading.py +++ b/resources/tester/test_ui_loading.py @@ -2,7 +2,7 @@ ① `delayedShow` 를 Node 로 가짜 시계로 돌림 — 0.3초 전에 끝나면 안 보임(깜빡임 없음) · 넘으면 보이고 끝나면 걷힘 · 끝낸 뒤 시계가 와도 안 보임. -② 실패 자리 = 까닭 글(「불러오지 못함 — 까닭」) + [다시 불러오기] · 화면 전체 잠금 안 씀. +② 실패 자리 = 까닭 글(「불러오지 못함 — 까닭」) + [새로고침] · 화면 전체 잠금 안 씀. ③ M02 나무 · 상세(표 · 도면) · 조합 목록 · 조합 상세 · 항목 카드가 이 부품을 씀. """ @@ -70,7 +70,7 @@ def test_실패_글과_다시_불러오기_M02_자리(): assert "clear: (id) => clearTimeout(id)," in src locale = _text(ROOT / "ui_template" / "ui_template_locale_common.ts") assert 'Common_Load_Failed: ["불러오지 못함"' in locale - assert 'Common_Reload: ["다시 불러오기"' in locale + assert 'Common_Reload: ["새로고침"' in locale tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "const stop = showLoading(body);" in tree assert "loadingError(why(error), () => void refresh())" in tree diff --git a/ui_template/cad_host/cad_host_frame_edit.ts b/ui_template/cad_host/cad_host_frame_edit.ts index f82c54c09..ec2f12eba 100644 --- a/ui_template/cad_host/cad_host_frame_edit.ts +++ b/ui_template/cad_host/cad_host_frame_edit.ts @@ -93,7 +93,7 @@ export function createFrameTemplateEditor( * 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. */ const resetButton = createButton({ - label: "기본 도각으로", + label: "기본 도각 복원", variant: "ghost", onClick: () => void resetToDefault(), }); diff --git a/ui_template/cad_host/cad_host_tabs.ts b/ui_template/cad_host/cad_host_tabs.ts index 5e9ef481f..9140d49fc 100644 --- a/ui_template/cad_host/cad_host_tabs.ts +++ b/ui_template/cad_host/cad_host_tabs.ts @@ -72,10 +72,10 @@ export interface CadTabs { const TEXT = { add: "새 장", rename: "두 번 눌러 이름 바꾸기", - remove: "장 지우기", + remove: "장 삭제", close: "닫기", removeAsk: "「{name}」 장을 지울까요? 저장하면 되돌릴 수 없음", - removeOk: "지우기", + removeOk: "삭제", closeAsk: "「{name}」 에 저장 안 한 고침이 있음 — 버리고 닫을까요?", closeOk: "버리고 닫기", emptyName: "장 이름을 넣을 것", diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts index b53aba30f..1c6966dea 100644 --- a/ui_template/sheet/ui_template_sheet_text.ts +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -7,7 +7,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { Col_Add: ["열 추가", "Add column"], - Col_Delete: ["열 지우기", "Delete column"], + Col_Delete: ["열 삭제", "Delete column"], Col_New: ["새 열", "New column"], Row_Unit: ["단위", "Unit"], Row_Unit_Price: ["일위대가", "Unit price"], diff --git a/ui_template/ui_template_leave_dialog.ts b/ui_template/ui_template_leave_dialog.ts index cf0a72ccb..d3fecf6c8 100644 --- a/ui_template/ui_template_leave_dialog.ts +++ b/ui_template/ui_template_leave_dialog.ts @@ -1,6 +1,6 @@ /* ============================================================================= * ui_template_leave_dialog.ts - * 저장 안 한 고침이 있을 때 넘어가기 전 물음 — 단추 셋: [취소] [버리고 넘어감] [저장 후 넘어감](주 단추). + * 저장 안 한 고침이 있을 때 넘어가기 전 물음 — 버튼 셋: [취소] [폐기] [저장](주 버튼). * 기본 초점 = 취소 · Esc = 취소 · 저장이 실패하거나 고침이 남으면 머묾. * 몸은 공용 확인 창(`showConfirmDialog`)과 같은 틀(.ui-confirm). * ========================================================================== */ @@ -57,7 +57,7 @@ export interface LeaveHooks { save: () => Promise; /** 저장 뒤에도 고침이 남았나 — 남으면 머묾 */ stillDirty: () => boolean; - /** [버리고 넘어감] 때 */ + /** [폐기] 때 */ discard?: () => void | Promise; } diff --git a/ui_template/ui_template_locale_a.ts b/ui_template/ui_template_locale_a.ts index 1768a1835..59ac938b8 100644 --- a/ui_template/ui_template_locale_a.ts +++ b/ui_template/ui_template_locale_a.ts @@ -181,11 +181,11 @@ export const ui_locales_a = { A06_Login_Error_Email: ["이메일 형식이 올바르지 않습니다.", "Invalid email format."], A06_Login_Field_Otp: ["이메일 인증 코드", "Email verification code"], A06_Login_Field_Otp_Placeholder: ["6자리 코드를 입력하세요", "Enter the 6-digit code"], - A06_Login_Verify: ["인증하고 로그인", "Verify and sign in"], + A06_Login_Verify: ["인증", "Verify"], A06_Login_OtpSent: ["인증 코드를 이메일로 발송했습니다.", "A verification code was emailed."], A06_Login_OtpResend: ["인증 코드 재발송", "Resend verification code"], A06_Login_OtpResendCountdown: ["{seconds}초 후 재발송", "Resend in {seconds}s"], - A06_Login_OtpBack: ["이메일/비밀번호 다시 입력", "Re-enter email/password"], + A06_Login_OtpBack: ["재입력", "Re-enter"], A06_Login_Success: ["로그인되었습니다.", "Signed in."], A06_Login_Error_Request: ["로그인 요청에 실패했습니다.", "Sign-in request failed."], @@ -209,7 +209,7 @@ export const ui_locales_a = { A07_Register_Field_Phone: ["전화번호", "Phone number"], A07_Register_Field_Phone_Placeholder: ["010-1234-5678", "010-1234-5678"], A07_Register_Submit: ["회원가입", "Sign up"], - A07_Register_ToLogin: ["이미 계정이 있으신가요? 로그인", "Already have an account? Sign in"], + A07_Register_ToLogin: ["로그인", "Sign in"], A07_Register_Error_Required: [ "모든 필수 항목을 입력하세요.", "Please fill in all required fields.", diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index bd455c923..e87f4d8d6 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -21,7 +21,7 @@ export const ui_locales_b1 = { "프로젝트가 없어졌습니다 · 대시보드에서 다시 고르세요.", "This project no longer exists. Choose a project again on the dashboard.", ], - B_Project_Gone_ToDashboard: ["대시보드로", "Go to dashboard"], + B_Project_Gone_ToDashboard: ["대시보드", "Dashboard"], /* --- B01_Dashboard 계정 관리 공통 문구 --- */ B01_Account_Title: ["내 계정", "My Account"], @@ -62,7 +62,7 @@ export const ui_locales_b1 = { B01_Dashboard_Company: ["회사 정보", "Company"], B01_Dashboard_NoCompany: ["회사 미연결", "No company linked"], B01_Dashboard_CreateCompany: ["회사 생성", "Create company"], - B01_Dashboard_FindCompany: ["회사 찾기", "Find company"], + B01_Dashboard_FindCompany: ["회사 검색", "Find company"], B01_Dashboard_JoinCompany: ["가입 신청", "Request to join"], B01_Dashboard_Members: ["팀원 관리", "Team members"], B01_Dashboard_AddMember: ["팀원 추가", "Add member"], @@ -352,7 +352,7 @@ export const ui_locales_b1 = { B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"], B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"], B03_File_Resume_Button: ["업로드 재개", "Resume upload"], - B03_File_New_Button: ["새 파일로 시작", "Start new file"], + B03_File_New_Button: ["새 파일", "New file"], B03_File_Overview_Complete: [ "필수 파일 업로드와 지표면 분석이 모두 완료된 프로젝트입니다. 파일을 다시 올리면 기존 결과가 교체됩니다.", "All required files are uploaded and surface analysis is complete. Re-uploading replaces existing results.", @@ -366,7 +366,7 @@ export const ui_locales_b1 = { "이 슬롯에는 이미 업로드된 파일이 있습니다. 새 파일을 올리면 기존 파일과 그에 따른 분석 결과(지표면 모델 등)가 교체됩니다. 계속하시겠습니까?", "This slot already has an uploaded file. Uploading a new one replaces the existing file and derived analysis results (surface model, etc.). Continue?", ], - B03_File_Replace_Confirm: ["교체하고 계속", "Replace and continue"], + B03_File_Replace_Confirm: ["교체", "Replace"], B03_File_Replace_Cancel: ["취소", "Cancel"], B03_File_ServiceWorker_Ready: [ "백그라운드 업로드 준비가 완료되었습니다.", diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index d03d9a579..69df77e87 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -1031,9 +1031,9 @@ export const ui_locales_b2 = { "아직 확정된 지표면이 없습니다. 필수 파일을 올리면 지표면 분석이 자동으로 진행되고, 끝나면 이 화면이 자료를 준비합니다.", "No confirmed surface yet. Upload the required files — the surface analysis runs automatically, and this screen prepares the data once it finishes.", ], - B11_Loading_Btn_FileInput: ["파일 입력 화면으로", "Go to file input"], - B11_Loading_Btn_Continue: ["그래도 화면으로 이동", "Continue anyway"], - B11_Loading_Btn_Dashboard: ["대시보드로", "Back to dashboard"], + B11_Loading_Btn_FileInput: ["파일 입력", "File input"], + B11_Loading_Btn_Continue: ["계속", "Continue"], + B11_Loading_Btn_Dashboard: ["대시보드", "Dashboard"], B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"], B11_Status_Step_Request: ["발행 요청", "Invoice Requested"], B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"], diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 42ffcf80e..2c4bb7c53 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -32,7 +32,7 @@ export const ui_locales_b3 = { "사유는 아래 단계(수량·단가)가 낸 것을 그대로 옮김 — 여기서 새로 짓지 않음", "Reasons are passed through from the quantity/price step as-is", ], - B09_Sheet_Reload: ["다시 불러오기", "Reload"], + B09_Sheet_Reload: ["새로고침", "Reload"], B09_Sheet_Level: ["보이는 레벨", "Show levels"], B09_Sheet_Level_All: ["모두", "All"], B09_Sheet_Level_Upto: ["레벨까지", " level(s)"], @@ -145,7 +145,7 @@ export const ui_locales_b3 = { B01_Dashboard_PermCompanyRead: ["회사 읽기", "Company read"], B01_Dashboard_PermCompanyWrite: ["회사 수정", "Company edit"], B01_Dashboard_PermPersonalWrite: ["개인 수정", "Personal edit"], - B01_Dashboard_PickCompany: ["회사 고르기", "Pick company"], + B01_Dashboard_PickCompany: ["회사 선택", "Pick company"], B01_Dashboard_PickCompanyHint: ["회사 이름 · 사업자번호", "Company name · business number"], B01_Dashboard_PickCompanyCount: ["{count}곳", "{count} companies"], B01_Dashboard_PickCompanyMore: [ diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index e2be52e3e..6f6e4c761 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -10,7 +10,7 @@ export const ui_locales_m1 = { M01_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], M01_LoadFailed: ["마스터 요소를 못 읽음", "Failed to load master elements"], M01_PickFile: ["왼쪽에서 그룹과 파일을 고를 것", "Pick a group and a file on the left"], - M01_Search: ["이름 찾기", "Find by name"], + M01_Search: ["이름 검색", "Find by name"], M01_WideHint: ["표가 넓음 — 옆으로 밀어서 볼 것", "Wide table — swipe sideways"], M01_Prev: ["이전", "Prev"], M01_Next: ["다음", "Next"], @@ -18,7 +18,7 @@ export const ui_locales_m1 = { M01_Rows: ["줄", "rows"], M01_NoRows: ["줄 없음", "No rows"], M01_RowAdd: ["줄 추가", "Add row"], - M01_RowDelete: ["지우기", "Delete"], + M01_RowDelete: ["삭제", "Delete"], M01_RowRestore: ["되살리기", "Restore"], M01_RowRemove: ["빼기", "Remove"], M01_BackToTables: ["← 표 목록", "← Tables"], @@ -35,25 +35,25 @@ export const ui_locales_m1 = { "그 사이 파일이 바뀜({value}) — 고친 것을 버리고 다시 읽은 뒤 다시 고칠 것", "Files changed meanwhile ({value}) — discard and reload, then edit again", ], - M01_StaleReload: ["버리고 다시 읽기", "Discard and reload"], + M01_StaleReload: ["새로고침", "Reload"], M01_FileStale: [ "고치기 시작한 뒤 이 파일이 바뀜 — 저장하면 막힘", "This file changed after you started editing — saving will be refused", ], M01_CheckErrors: ["검사에 걸림", "Validation failed"], - M01_ProcureSearch: ["이름·규격 찾기", "Find by name or spec"], + M01_ProcureSearch: ["이름·규격 검색", "Find by name or spec"], M01_ProcureCut: ["연결 끊기", "Unlink"], M01_ProcureClose: ["닫기", "Close"], M01_PriceFind: ["가격 연결", "Link price"], M01_PriceTitle: ["가격 연결 고르기", "Pick price source"], M01_PriceCol: ["가격 연결", "Price link"], - M01_PriceCond: ["후보 조건으로 연결 (지역은 입력)", "Link by condition (region as input)"], + M01_PriceCond: ["조건 연결 (지역은 입력)", "Link by condition (region as input)"], M01_PriceCondName: ["이름", "Name"], M01_PriceCondSpec: ["규격", "Spec"], M01_PriceCondUsed: ["후보 조건", "Condition"], M01_Govt: ["관급", "Govt"], - M01_OnlyUnlinked: ["못 이은 줄만 보기", "Unlinked only"], - M01_JobFind: ["직종 고르기", "Pick job"], + M01_OnlyUnlinked: ["미연결만", "Unlinked only"], + M01_JobFind: ["직종 선택", "Pick job"], M01_JobTitle: ["준용 직종 고르기", "Pick substitute job"], M01_JobCol: ["준용", "Substitute"], M01_All: ["전체", "All"], diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index c568a2181..c0af92511 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -101,7 +101,7 @@ export const ui_locales_m2 = { M02_SourcePractice: ["실무문서", "Practice documents"], M02_PeopleEmpty: ["비어 있음", "empty"], M02_TreeEmptyEdit: [ - "항목이 없음 — 빈 자리 오른쪽 클릭 또는 층 단추 옆 「+」 로 폴더 · 항목을 더함", + "항목이 없음 — 빈 자리 오른쪽 클릭 또는 층 버튼 옆 「+」 로 폴더 · 항목을 더함", "No items — right-click an empty area or use + beside the layer buttons to add", ], M02_TreeAddTop: ["맨 위에 폴더 · 항목 추가", "Add folder or item at top"], @@ -155,8 +155,8 @@ export const ui_locales_m2 = { "저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리", "Unsaved table edits — settle them before changing the tree", ], - M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"], - M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"], + M02_TreeSaveGo: ["저장", "Save"], + M02_TreeDiscardGo: ["폐기", "Discard"], M02_TreeDone: ["나무를 고침", "Tree updated"], M02_TreeRenamed: [ "나무를 고침 — 같은 이름이 있어 「{value}」 로 저장", From e1b1c79249d0121557b40139df176dcb777ce5c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:40:01 +0900 Subject: [PATCH 09/33] =?UTF-8?q?feat(=ED=8F=89=EB=A9=B4):=20353-1=20[?= =?UTF-8?q?=EB=B0=9C=EC=9E=90=EA=B5=AD]=20=E2=86=92=20[=EC=A0=88/=EC=84=B1?= =?UTF-8?q?=ED=86=A0=EC=84=A0]=20=C2=B7=20=EC=84=A0=20=EB=A7=A4=EB=81=84?= =?UTF-8?q?=EB=9F=BD=EA=B2=8C=20=E2=80=94=20=EB=8B=A8=EC=A1=B0=203?= =?UTF-8?q?=EC=B0=A8=20=EB=B3=B4=EA=B0=84=20=C2=B7=20=C2=B11=20m=20?= =?UTF-8?q?=EB=B2=95=EC=84=A0=20=C2=B7=20=EA=B3=A1=EC=84=A0=20=EC=B4=98?= =?UTF-8?q?=EC=B4=98=20=C2=B7=20=EC=A0=91=ED=9E=98=20=EB=B2=84=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EB=B0=94=EB=80=9C=20=EC=9E=90=EB=A6=AC=20=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EB=81=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측점 오프셋 = 측점 값 그대로 지나는 단조 3차(PCHIP) · 비탈은 폭으로 보간 - 사이 점 법선 = 측점 횡단 틀과 같은 앞뒤 ±1 m 식(route_normal · routeNormal 새 함수 · route_frame 그대로) - 네 선 중 하나라도 가운데서 2° 넘게 꺾이는 사이를 반씩 나눔(깊이 4 · 2 m → 12.5 cm) - 진행 거스르는 점 버림(안쪽 비탈 끝 접힘) · 그 자리 눈금 뺌 · 눈금은 측점 · 2 m 자리만 - 깎기↔쌓기 바뀜 자리 = 폭 0(비탈 끝이 도로 끝으로 모임) · 바뀜 점은 앞뒤 조각이 나눠 가짐 - TS ↔ 파이썬(B07 계획평면도) 짝 · 거울 시험 test_353_1 · 옛 계산은 꺾임 · 접힘 · 바뀜 자리 시험 셋 모두 떨어짐 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../B05_Profile_UI_RouteEdit_Chrome.ts | 2 +- .../logic/B05_Profile_PlanFootprint.ts | 298 +++++++++++++++--- .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 261 ++++++++++++--- .../tester/test_346_2_footprint_stage.py | 12 +- .../tester/test_353_1_cut_fill_line_smooth.py | 199 ++++++++++++ 5 files changed, 676 insertions(+), 96 deletions(-) create mode 100644 resources/tester/test_353_1_cut_fill_line_smooth.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index cf375a5ed..c2d51167e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -81,7 +81,7 @@ export function createRouteEditChrome( aria-pressed="false" title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅 + title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선 diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index 62ca60b39..93d9c06c9 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -3,7 +3,8 @@ * 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2) * * ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py` - * (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면 + * (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래 + * 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면 * 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다. * `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄. * 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` @@ -105,18 +106,6 @@ export function routeFrame(route: Array<[number, number, number]>, chainage: num return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length]; } -function interp(records: Array>, chainage: number, key: string): number { - const chainages = records.map((record) => record.chainage_m); - const index = bisectLeft(chainages, chainage); - if (index <= 0) return records[0][key]; - if (index >= records.length) return records[records.length - 1][key]; - const before = records[index - 1]; - const after = records[index]; - const span = after.chainage_m - before.chainage_m; - const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0; - return before[key] + (after[key] - before[key]) * ratio; -} - function groundOf(samples: FootprintPoint[]): Array<[number, number]> { return samples .filter( @@ -216,13 +205,187 @@ function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] { return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)]; } +/** 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°). 짝 같은 이름. */ +export const SPLIT_TURN_DEG = 2.0; +/** 나눔 깊이 상한(2 m → 12.5 cm). */ +export const SPLIT_DEPTH = 4; +/** 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`stationFrameAt`)과 같은 값. */ +export const NORMAL_PROBE_M = 1.0; + +type Frame = [number, number, number, number]; + +/** 노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향. 짝: `route_normal`. */ +export function routeNormal(route: Array<[number, number, number]>, chainage: number): Frame { + const [x, y, nx, ny] = routeFrame(route, chainage); + const first = route[0][0]; + const last = route[route.length - 1][0]; + let before = Math.max(first, chainage - NORMAL_PROBE_M); + let after = Math.min(last, chainage + NORMAL_PROBE_M); + if (after - before <= 1e-9) { + before = Math.max(first, chainage - 1e-3); + after = Math.min(last, chainage + 1e-3); + } + const [x0, y0] = routeFrame(route, before); + const [x1, y1] = routeFrame(route, after); + const length = Math.hypot(x1 - x0, y1 - y0); + if (length <= 1e-9) return [x, y, nx, ny]; + return [x, y, -(y1 - y0) / length, (x1 - x0) / length]; +} + +function bisectRight(values: number[], target: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const middle = (low + high) >> 1; + if (target < values[middle]) high = middle; + else low = middle + 1; + } + return low; +} + +type Knots = [number[], number[], number[]]; + +/** 단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않음. 짝: `_pchip_slopes`. */ +function pchipSlopes(xs: number[], ys: number[]): number[] { + const n = xs.length; + if (n < 2) return xs.map(() => 0); + const h = xs.slice(1).map((x, i) => x - xs[i]); + const delta = h.map((span, i) => (ys[i + 1] - ys[i]) / span); + const slopes = [delta[0], ...new Array(n - 2).fill(0), delta[n - 2]]; + for (let k = 1; k < n - 1; k += 1) { + if (delta[k - 1] * delta[k] > 0) { + const w1 = 2 * h[k] + h[k - 1]; + const w2 = h[k] + 2 * h[k - 1]; + slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]); + } + } + return slopes; +} + +/** 꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값. 짝: `_pchip`. */ +function pchip([xs, ys, slopes]: Knots, x: number): number { + if (x <= xs[0]) return ys[0]; + if (x >= xs[xs.length - 1]) return ys[ys.length - 1]; + const i = bisectRight(xs, x) - 1; + const h = xs[i + 1] - xs[i]; + const t = (x - xs[i]) / h; + return ( + (1 + 2 * t) * (1 - t) ** 2 * ys[i] + + t * (1 - t) ** 2 * h * slopes[i] + + t * t * (3 - 2 * t) * ys[i + 1] + + t * t * (t - 1) * h * slopes[i + 1] + ); +} + +const knots = (xs: number[], ys: number[]): Knots => [xs, ys, pchipSlopes(xs, ys)]; + +interface SideCurves { + chainages: number[]; + flags: boolean[]; + switches: number[]; + edge: Knots; + width: Knots; +} + +/** 한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임). 짝: `_side_curves`. */ +function sideCurves(records: FootprintRecord[], side: Side): SideCurves { + const chainages = records.map((record) => record.chainage_m); + const edges = records.map((record) => record[`${side}_edge_m`]); + const widths = records.map((record) => record[`${side}_end_m`] - record[`${side}_edge_m`]); + const flags = records.map((record) => Boolean(record[`${side}_cut`])); + const switches: number[] = []; + const widthX = [chainages[0]]; + const widthY = [widths[0]]; + for (let i = 1; i < records.length; i += 1) { + const c0 = chainages[i - 1]; + const c1 = chainages[i]; + const w0 = Math.abs(widths[i - 1]); + const w1 = Math.abs(widths[i]); + const at = c0 + (c1 - c0) * (w0 + w1 > 1e-9 ? w0 / (w0 + w1) : 0.5); + switches.push(at); + if (flags[i - 1] !== flags[i] && c0 + 1e-9 < at && at < c1 - 1e-9) { + widthX.push(at); + widthY.push(0.0); + } + widthX.push(c1); + widthY.push(widths[i]); + } + return { + chainages, + flags, + switches, + edge: knots(chainages, edges), + width: knots(widthX, widthY), + }; +} + +/** 그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐). 짝: `_cut_at`. */ +function cutAt(curves: SideCurves, chainage: number): boolean { + const { flags } = curves; + const i = bisectRight(curves.chainages, chainage); + if (i === 0) return flags[0]; + if (i === flags.length) return flags[flags.length - 1]; + if (flags[i - 1] === flags[i]) return flags[i]; + return chainage < curves.switches[i - 1] ? flags[i - 1] : flags[i]; +} + +/** 진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호. 짝: `_forward_only`. */ +function forwardOnly(points: Line, frames: Frame[]): number[] { + const kept = points.length ? [0] : []; + for (let i = 1; i < points.length; i += 1) { + const last = points[kept[kept.length - 1]]; + const [, , nx, ny] = frames[i]; + if ((points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6) kept.push(i); + } + return kept; +} + +function turnDeg(a: number[], m: number[], b: number[]): number { + const u = [m[0] - a[0], m[1] - a[1]]; + const v = [b[0] - m[0], b[1] - m[1]]; + return Math.abs( + (Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]) * 180) / Math.PI, + ); +} + +/** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */ +function densify( + chainages: number[], + frameOf: (chainage: number) => Frame, + place: (chainage: number, frame: Frame) => Line, +): [number[], Frame[]] { + const split = (c0: number, p0: Line, c1: number, p1: Line, depth: number): number[] => { + if (depth >= SPLIT_DEPTH) return []; + const middle = (c0 + c1) / 2.0; + const pm = place(middle, frameOf(middle)); + // 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 + const bends = pm.some((m, k) => { + const turn = turnDeg(p0[k], m, p1[k]); + return SPLIT_TURN_DEG < turn && turn < 90.0; + }); + if (!bends) return []; + return [ + ...split(c0, p0, middle, pm, depth + 1), + middle, + ...split(middle, pm, c1, p1, depth + 1), + ]; + }; + const points = chainages.map((chainage) => place(chainage, frameOf(chainage))); + const out = chainages.slice(0, 1); + for (let i = 1; i < chainages.length; i += 1) { + out.push(...split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0)); + out.push(chainages[i]); + } + return [out, out.map(frameOf)]; +} + interface Sampled { lines: Record<(typeof KEYS)[number], Line>; cut: Record; ticks: Line[]; } -/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */ +/** 측점 줄 → 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */ function sampleLines( route: Array<[number, number, number]>, input: FootprintRecord[], @@ -230,49 +393,94 @@ function sampleLines( endM: number, stepM: number, ): Sampled | null { - const records = input + const sorted = input .map((record) => ({ ...record, - left_cut: Number(Boolean(record.left_cut)), - right_cut: Number(Boolean(record.right_cut)), + left_cut: Boolean(record.left_cut), + right_cut: Boolean(record.right_cut), })) - .sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array>; - if (route.length < 2 || !records.length) return null; - const byChainage = new Map>(); + .sort((a, b) => a.chainage_m - b.chainage_m); + if (route.length < 2 || !sorted.length) return null; + // 같은 누가거리 측점은 뒤 것 하나만 + const records: FootprintRecord[] = []; + for (const record of sorted) { + const last = records[records.length - 1]; + if (last && record.chainage_m - last.chainage_m <= 1e-9) records[records.length - 1] = record; + else records.push(record); + } + const curves = { left: sideCurves(records, "left"), right: sideCurves(records, "right") }; + const byChainage = new Map(); for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record); - const samples = new Set(); + const base = new Set(); for (const record of records) { if (startM <= record.chainage_m && record.chainage_m <= endM) - samples.add(roundTo(record.chainage_m, 3)); + base.add(roundTo(record.chainage_m, 3)); } - for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3)); - const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"]; - const cut: Record = { left: [], right: [] }; + for (let step = startM; step <= endM; step += stepM) base.add(roundTo(step, 3)); + const all = new Set(base); + for (const side of SIDES) { + const { flags, switches } = curves[side]; + switches.forEach((at, i) => { + if (flags[i] !== flags[i + 1] && startM <= at && at <= endM) all.add(at); + }); + } + const frameOf = (chainage: number): Frame => { + const record = byChainage.get(roundTo(chainage, 3)); + const { x, y, nx, ny } = record ?? {}; + if (x !== undefined && y !== undefined && nx !== undefined && ny !== undefined) + return [x, y, nx, ny]; + return routeNormal(route, chainage); + }; + const valuesAt = (chainage: number): Record => { + const values: Record = {}; + for (const side of SIDES) { + const edge = pchip(curves[side].edge, chainage); + values[`${side}_edge_m`] = edge; + values[`${side}_end_m`] = edge + pchip(curves[side].width, chainage); + } + return values; + }; + const place = (chainage: number, [x, y, nx, ny]: Frame): Line => { + const values = valuesAt(chainage); + return KEYS.map((key) => [x + nx * values[key], y + ny * values[key]]); + }; + const [chainages, frames] = densify( + [...all].sort((a, b) => a - b), + frameOf, + place, + ); + const offsets = chainages.map(valuesAt); + const raw = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"]; + frames.forEach(([x, y, nx, ny], i) => { + for (const key of KEYS) + raw[key].push([roundTo(x + nx * offsets[i][key], 4), roundTo(y + ny * offsets[i][key], 4)]); + }); + const kept = Object.fromEntries( + KEYS.map((key) => [key, forwardOnly(raw[key], frames)]), + ) as unknown as Record<(typeof KEYS)[number], number[]>; + const lines = Object.fromEntries( + KEYS.map((key) => [key, kept[key].map((i) => raw[key][i])]), + ) as unknown as Sampled["lines"]; + const cut: Record = { + left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])), + right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])), + }; + // 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품 + // 도면 관례) · 접혀 버린 자리는 뺌. const ticks: Line[] = []; - [...samples] + const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) }; + const position = new Map(chainages.map((chainage, i) => [chainage, i])); + [...base] .sort((a, b) => a - b) .forEach((chainage, index) => { - const record = byChainage.get(chainage); - const [x, y, nx, ny] = - record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined) - ? [record.x, record.y, record.nx, record.ny] - : routeFrame(route, chainage); - const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)])); - const points = Object.fromEntries( - KEYS.map((key) => [ - key, - [roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)], - ]), - ); - for (const key of KEYS) lines[key].push(points[key]); - for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5); - // 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례). + const i = position.get(chainage) ?? 0; + const [x, y, nx, ny] = frames[i]; for (const side of SIDES) { - const edge = offsets[`${side}_edge_m`]; - const end = offsets[`${side}_end_m`]; - if (Math.abs(end - edge) < 0.05) continue; + const edge = offsets[i][`${side}_edge_m`]; + const end = offsets[i][`${side}_end_m`]; + if (Math.abs(end - edge) < 0.05 || !keptSets[side].has(i)) continue; const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0; - ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]); + ticks.push([raw[`${side}_edge_m`][i], [x + nx * reach, y + ny * reach]]); } }); return { lines, cut, ticks }; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index d5c327e7f..ddf0b615a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -2,13 +2,14 @@ `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. -⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end · -slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. +⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut · +slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림) — 종단 · 횡단 편집 중 평면 +발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ import math -from bisect import bisect_left +from bisect import bisect_left, bisect_right from typing import Any from config.config_system import DRAWING_SCALE_PLAN @@ -39,19 +40,6 @@ def route_frame( return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) -def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: - chainages = [record["chainage_m"] for record in records] - index = bisect_left(chainages, chainage) - if index <= 0: - return records[0][key] - if index >= len(records): - return records[-1][key] - before, after = records[index - 1], records[index] - span = after["chainage_m"] - before["chainage_m"] - ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 - return before[key] + (after[key] - before[key]) * ratio - - def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: return sorted( (float(item["offset_m"]), float(item["elevation_m"])) @@ -120,6 +108,149 @@ def _split_runs( ) +#: 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°) +SPLIT_TURN_DEG = 2.0 +#: 나눔 깊이 상한(2 m → 12.5 cm) +SPLIT_DEPTH = 4 +#: 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`_tangent_at`)과 같은 값 +NORMAL_PROBE_M = 1.0 + + +def route_normal( + route: list[tuple[float, float, float]], chainage: float +) -> tuple[float, float, float, float]: + """노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향(측점 횡단 틀과 같은 식).""" + x, y, nx, ny = route_frame(route, chainage) + first, last = route[0][0], route[-1][0] + before, after = max(first, chainage - NORMAL_PROBE_M), min(last, chainage + NORMAL_PROBE_M) + if after - before <= 1e-9: + before, after = max(first, chainage - 1e-3), min(last, chainage + 1e-3) + x0, y0, _nx, _ny = route_frame(route, before) + x1, y1, _nx, _ny = route_frame(route, after) + length = math.hypot(x1 - x0, y1 - y0) + if length <= 1e-9: + return x, y, nx, ny + return x, y, -(y1 - y0) / length, (x1 - x0) / length + + +def _pchip_slopes(xs: list[float], ys: list[float]) -> list[float]: + """단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않는다.""" + n = len(xs) + if n < 2: + return [0.0] * n + h = [xs[i + 1] - xs[i] for i in range(n - 1)] + delta = [(ys[i + 1] - ys[i]) / h[i] for i in range(n - 1)] + slopes = [delta[0]] + [0.0] * (n - 2) + [delta[-1]] + for k in range(1, n - 1): + if delta[k - 1] * delta[k] > 0: + w1, w2 = 2 * h[k] + h[k - 1], h[k] + 2 * h[k - 1] + slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]) + return slopes + + +def _pchip(knots: tuple[list[float], list[float], list[float]], x: float) -> float: + """꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값.""" + xs, ys, slopes = knots + if x <= xs[0]: + return ys[0] + if x >= xs[-1]: + return ys[-1] + i = bisect_right(xs, x) - 1 + h = xs[i + 1] - xs[i] + t = (x - xs[i]) / h + return ( + (1 + 2 * t) * (1 - t) ** 2 * ys[i] + + t * (1 - t) ** 2 * h * slopes[i] + + t * t * (3 - 2 * t) * ys[i + 1] + + t * t * (t - 1) * h * slopes[i + 1] + ) + + +def _knots(xs: list[float], ys: list[float]) -> tuple[list[float], list[float], list[float]]: + return xs, ys, _pchip_slopes(xs, ys) + + +def _side_curves(records: list[dict[str, Any]], side: str) -> dict[str, Any]: + """한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임).""" + chainages = [record["chainage_m"] for record in records] + edges = [record[f"{side}_edge_m"] for record in records] + widths = [record[f"{side}_end_m"] - record[f"{side}_edge_m"] for record in records] + flags = [bool(record[f"{side}_cut"]) for record in records] + switches: list[float] = [] + width_x, width_y = [chainages[0]], [widths[0]] + for i in range(1, len(records)): + c0, c1 = chainages[i - 1], chainages[i] + w0, w1 = abs(widths[i - 1]), abs(widths[i]) + switch = c0 + (c1 - c0) * (w0 / (w0 + w1) if w0 + w1 > 1e-9 else 0.5) + switches.append(switch) + if flags[i - 1] != flags[i] and c0 + 1e-9 < switch < c1 - 1e-9: + width_x.append(switch) + width_y.append(0.0) + width_x.append(c1) + width_y.append(widths[i]) + return { + "chainages": chainages, + "flags": flags, + "switches": switches, + "edge": _knots(chainages, edges), + "width": _knots(width_x, width_y), + } + + +def _cut_at(curves: dict[str, Any], chainage: float) -> bool: + """그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐).""" + flags = curves["flags"] + i = bisect_right(curves["chainages"], chainage) + if i == 0: + return flags[0] + if i == len(flags): + return flags[-1] + if flags[i - 1] == flags[i]: + return flags[i] + return flags[i - 1] if chainage < curves["switches"][i - 1] else flags[i] + + +def _forward_only(points: list[list[float]], frames: list[tuple[float, ...]]) -> list[int]: + """진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호.""" + kept = [0] if points else [] + for i in range(1, len(points)): + last = points[kept[-1]] + _x, _y, nx, ny = frames[i] + if (points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6: + kept.append(i) + return kept + + +def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float: + u, v = (m[0] - a[0], m[1] - a[1]), (b[0] - m[0], b[1] - m[1]) + return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) + + +def _densify( + chainages: list[float], frame_of: Any, place: Any +) -> tuple[list[float], list[tuple[float, float, float, float]]]: + """곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔.""" + + def split(c0: float, p0: list, c1: float, p1: list, depth: int) -> list[float]: + if depth >= SPLIT_DEPTH: + return [] + middle = (c0 + c1) / 2.0 + pm = place(middle, frame_of(middle)) + # 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 + if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)): + return [] + return ( + split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1) + ) + + points = [place(chainage, frame_of(chainage)) for chainage in chainages] + out = chainages[:1] + for i in range(1, len(chainages)): + out += split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0) + out.append(chainages[i]) + return out, [frame_of(chainage) for chainage in out] + + def slope_pieces( route: list[tuple[float, float, float]], records: list[dict[str, Any]], @@ -131,60 +262,94 @@ def slope_pieces( `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 - 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 - 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. + 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차 + 보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는 + 점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). + 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ - records = sorted( + sorted_records = sorted( ( - { - **record, - **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, - } + {**record, **{f"{s}_cut": bool(record.get(f"{s}_cut")) for s in ("left", "right")}} for record in records ), key=lambda record: record["chainage_m"], ) - if len(route) < 2 or not records: + if len(route) < 2 or not sorted_records: return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} + # 같은 누가거리 측점은 뒤 것 하나만 + records = [] + for record in sorted_records: + if records and record["chainage_m"] - records[-1]["chainage_m"] <= 1e-9: + records[-1] = record + else: + records.append(record) + sides = ("left", "right") keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") + curves = {side: _side_curves(records, side) for side in sides} by_chainage = {round(record["chainage_m"], 3): record for record in records} - samples = { + base = { round(record["chainage_m"], 3) for record in records if start_m <= record["chainage_m"] <= end_m } step = start_m while step <= end_m: - samples.add(round(step, 3)) + base.add(round(step, 3)) step += step_m - lines: dict[str, list[list[float]]] = {key: [] for key in keys} - cut: dict[str, list[bool]] = {"left": [], "right": []} - ticks: list[list[list[float]]] = [] - for index, chainage in enumerate(sorted(samples)): - record = by_chainage.get(chainage) + switches = { + at + for side in sides + for i, at in enumerate(curves[side]["switches"]) + if curves[side]["flags"][i] != curves[side]["flags"][i + 1] and start_m <= at <= end_m + } + + def frame_of(chainage: float) -> tuple[float, float, float, float]: + record = by_chainage.get(round(chainage, 3)) if record and all(k in record for k in ("x", "y", "nx", "ny")): - x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] - else: - x, y, nx, ny = route_frame(route, chainage) - offsets = {key: _interp(records, chainage, key) for key in keys} - points = { - key: [round(x + nx * value, 4), round(y + ny * value, 4)] - for key, value in offsets.items() - } + return record["x"], record["y"], record["nx"], record["ny"] + return route_normal(route, chainage) + + def values_at(chainage: float) -> dict[str, float]: + values: dict[str, float] = {} + for side in sides: + edge = _pchip(curves[side]["edge"], chainage) + values[f"{side}_edge_m"] = edge + values[f"{side}_end_m"] = edge + _pchip(curves[side]["width"], chainage) + return values + + def place(chainage: float, frame: tuple[float, float, float, float]) -> list[list[float]]: + x, y, nx, ny = frame + values = values_at(chainage) + return [[x + nx * values[key], y + ny * values[key]] for key in keys] + + chainages, frames = _densify(sorted(base | switches), frame_of, place) + offsets = [values_at(chainage) for chainage in chainages] + raw: dict[str, list[list[float]]] = {key: [] for key in keys} + for (x, y, nx, ny), values in zip(frames, offsets): for key in keys: - lines[key].append(points[key]) - for side in cut: - cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) - # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). - for side in ("left", "right"): - edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] - if abs(end - edge) < 0.05: + raw[key].append([round(x + nx * values[key], 4), round(y + ny * values[key], 4)]) + kept = {key: _forward_only(raw[key], frames) for key in keys} + lines = {key: [raw[key][i] for i in kept[key]] for key in keys} + cut = { + side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides + } + # 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아 + # 선다(납품 도면 관례) · 접혀 버린 자리는 뺌. + ticks: list[list[list[float]]] = [] + kept_sets = {key: set(kept[key]) for key in keys} + position = {chainage: i for i, chainage in enumerate(chainages)} + for index, chainage in enumerate(sorted(base)): + i = position[chainage] + x, y, nx, ny = frames[i] + for side in sides: + edge, end = offsets[i][f"{side}_edge_m"], offsets[i][f"{side}_end_m"] + if abs(end - edge) < 0.05 or i not in kept_sets[f"{side}_end_m"]: continue reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 - ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) + ticks.append([raw[f"{side}_edge_m"][i], [x + nx * reach, y + ny * reach]]) cut_lines: list[list[list[float]]] = [] fill_lines: list[list[list[float]]] = [] - for side in cut: + for side in sides: cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) cut_lines += cut_runs fill_lines += fill_runs diff --git a/resources/tester/test_346_2_footprint_stage.py b/resources/tester/test_346_2_footprint_stage.py index 4691b4804..e2aad6a0a 100644 --- a/resources/tester/test_346_2_footprint_stage.py +++ b/resources/tester/test_346_2_footprint_stage.py @@ -165,11 +165,19 @@ def test_계약_꼴_선(result) -> None: first = result["first"] for name in ("roadLeft", "roadRight", "slopeLeft", "slopeRight"): line = first[name] - assert len(line) == 51 # 0 ~ 100 m · 2 m + # 0 ~ 100 m · 2 m + 깎기↔쌓기 바뀜 자리(353-1 · 비탈 끝이 도로 끝으로 모임) + assert {round(p[0], 3) for p in line} >= {float(2 * i) for i in range(51)} assert [p[0] for p in line] == sorted(p[0] for p in line) # 시점 → 종점 assert all(p[1] == pytest.approx(2.5) for p in first["roadLeft"]) assert all(p[1] == pytest.approx(-2.5) for p in first["roadRight"]) - assert len(first["slopeLeftCut"]) == 51 and len(first["slopeRightCut"]) == 51 + assert len(first["slopeLeftCut"]) == len(first["slopeLeft"]) + assert len(first["slopeRightCut"]) == len(first["slopeRight"]) + for side in ("slopeLeft", "slopeRight"): # 바뀜 자리 = 도로 끝 위 + flags, points = first[f"{side}Cut"], first[side] + edge = 2.5 if side == "slopeLeft" else -2.5 + for i in range(1, len(flags)): + if flags[i] != flags[i - 1]: + assert points[i][1] == pytest.approx(edge, abs=1e-3) assert any(first["slopeLeftCut"]) and not all(first["slopeLeftCut"]) # 깎기 · 쌓기 섞임 diff --git a/resources/tester/test_353_1_cut_fill_line_smooth.py b/resources/tester/test_353_1_cut_fill_line_smooth.py new file mode 100644 index 000000000..2be252fc6 --- /dev/null +++ b/resources/tester/test_353_1_cut_fill_line_smooth.py @@ -0,0 +1,199 @@ +"""353-1 절/성토선 매끄럽게 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬 +(`B07_DesignDetail_Engine_Cad_PlanSlope.py`) 거울 + 측점 폭 = 횡단 값 · 꺾임 각 상한 · 접힘 버림 · +깎기↔쌓기 바뀜 자리 = 도로 끝. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_353_1_cut_fill_line_smooth.py -q +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 + route_normal, + slope_pieces, +) + +TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +TOL = 2e-4 +RADIUS = 20.0 +#: 꺾임 각 상한(°) — 이웃 선분 사이 방향 차 +MAX_TURN_DEG = 10.0 + + +def _route() -> list[tuple[float, float, float]]: + """+x 직선 50 m → 좌회전 90° 원호(R 20 · 5° 편) → +y 직선 40 m.""" + points = [(0.0, 0.0)] + points += [ + (50.0 + RADIUS * math.sin(math.radians(a)), RADIUS - RADIUS * math.cos(math.radians(a))) + for a in range(0, 91, 5) + ] + points.append((70.0, 60.0)) + route, chain = [], 0.0 + for index, (x, y) in enumerate(points): + if index: + step = math.hypot(x - points[index - 1][0], y - points[index - 1][1]) + if step < 1e-9: + continue + chain += step + route.append((chain, x, y)) + return route + + +def _records() -> list[dict]: + """20 m 측점 — 좌(곡선 안쪽) 비탈 폭이 곡선에서 R 보다 커져 접힘 · 우는 깎기↔쌓기 번갈아.""" + route = _route() + total = route[-1][0] + records = [] + for i in range(int(total // 10) + 1): + chainage = 10.0 * i + x, y, nx, ny = route_normal(route, chainage) + inside_curve = 50.0 <= chainage <= 50.0 + RADIUS * math.pi / 2 + records.append( + { + "chainage_m": chainage, + "left_edge_m": 2.5, + "left_end_m": 2.5 + (22.0 if inside_curve else 3.0 + 2.0 * math.sin(chainage / 7)), + "right_edge_m": -2.5 - 0.3 * (i % 2), + "right_end_m": -6.0 - 2.0 * math.cos(chainage / 11), + "left_cut": True, + "right_cut": (i // 2) % 2 == 0, + "x": x, + "y": y, + "nx": nx, + "ny": ny, + } + ) + return records + + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { slopePieces } from "./B05_Profile_PlanFootprint.js"; +const c = JSON.parse(readFileSync(process.argv[2], "utf-8")); +writeFileSync(process.argv[3], JSON.stringify(slopePieces(c.route, c.records, c.start, c.end))); +""" + + +@pytest.fixture(scope="module") +def pieces() -> dict: + route = _route() + edge, fill, cut, ticks = slope_pieces(route, _records(), 0.0, route[-1][0]) + return { + "edge": edge["lines"], + "fill": fill["lines"], + "cut": cut["lines"], + "ticks": ticks["ticks"], + } + + +@pytest.fixture(scope="module") +def ts_pieces(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_353_1") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + route = _route() + payload = {"route": route, "records": _records(), "start": 0.0, "end": route[-1][0]} + (out / "in.json").write_text(json.dumps(payload), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def test_두_쪽이_같다(pieces, ts_pieces) -> None: + for name in ("edge", "fill", "cut", "ticks"): + want, got = pieces[name], ts_pieces[name] + assert len(want) == len(got), name + for line_w, line_g in zip(want, got): + assert len(line_w) == len(line_g), name + for pw, pg in zip(line_w, line_g): + assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, (name, pw, pg) + + +def _nearest(lines: list, point: tuple[float, float]) -> float: + return min(math.hypot(p[0] - point[0], p[1] - point[1]) for line in lines for p in line) + + +def test_측점_폭은_횡단_값_그대로(pieces) -> None: + left_edge, right_edge = pieces["edge"] + ends = pieces["fill"] + pieces["cut"] + for record in _records(): + x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] + for key, lines in (("left_edge_m", [left_edge]), ("right_edge_m", [right_edge]), + ("right_end_m", ends)): # fmt: skip + value = record[key] + assert _nearest(lines, (x + nx * value, y + ny * value)) < 1e-3, (record, key) + if not 50.0 <= record["chainage_m"] <= 50.0 + RADIUS * math.pi / 2: # 접히지 않는 자리 + value = record["left_end_m"] + assert _nearest(ends, (x + nx * value, y + ny * value)) < 1e-3, record + + +def _turns(line: list) -> list[float]: + turns = [] + for a, b, c in zip(line, line[1:], line[2:]): + u = (b[0] - a[0], b[1] - a[1]) + v = (c[0] - b[0], c[1] - b[1]) + turns.append( + abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) + ) + return turns + + +def _right_runs(pieces: dict) -> list: + """우측 비탈 끝 조각 — 좌측은 늘 깎기라 깎기 첫 조각이 좌측 한 줄.""" + return pieces["fill"] + pieces["cut"][1:] + + +def test_꺾임_각_상한(pieces) -> None: + # 도로 끝 · 곡선 바깥(우) 비탈 끝 — 원호 5° 편 위에서도 톱니 없이 고름 + for line in [*pieces["edge"], *_right_runs(pieces)]: + assert max(_turns(line), default=0.0) < MAX_TURN_DEG + + +def _crosses(p1, p2, p3, p4) -> bool: + def side(a, b, c) -> float: + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + + return side(p1, p2, p3) * side(p1, p2, p4) < 0 and side(p3, p4, p1) * side(p3, p4, p2) < 0 + + +def test_안쪽_접힘은_버림(pieces) -> None: + left_end = pieces["cut"][0] + segments = list(zip(left_end, left_end[1:])) + for i, (a, b) in enumerate(segments): # 스스로 꼬이지 않음 + for c, d in segments[i + 2 :]: + assert not _crosses(a, b, c, d), (a, b, c, d) + + +def test_깎기_쌓기_바뀜_자리는_도로_끝(pieces) -> None: + right_edge = pieces["edge"][1] + runs = _right_runs(pieces) + assert len(runs) >= 4 + ends = sorted((p for line in runs for p in (line[0], line[-1])), key=lambda p: p[0] + p[1]) + for point in ends[1:-1]: # 시점 · 종점 빼고 조각 끝 = 바뀜 자리 + assert _nearest([right_edge], point) < 0.05, point + + +def test_단추_이름은_절성토선() -> None: + chrome = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8") + at = chrome.index('data-act="footprint"') + assert ">절/성토선" in chrome[at : at + 200] and ">발자국<" not in chrome From 9920981e4254dce010082be34904a07811e970b8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:41:00 +0900 Subject: [PATCH 10/33] =?UTF-8?q?feat(M02):=20359-1=20=C2=B7=20359-2=20?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4=EC=9E=90=EB=A3=8C=20[=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C]=20=C2=B7=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A4=84=20=3D=20=EB=B0=94=EB=A1=9C=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=C2=B7=20[=EC=88=98=EC=A0=95]=20=C2=B7=20[?= =?UTF-8?q?=EC=A0=80=EC=9E=A5]=20=C2=B7=20=EB=8B=A4=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=85=B9=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 [엑셀 올리기] → [파일 업로드](구조물 조합 층 단추 꼴 · 보통 크기 · m02-layers__btn) - 파일 줄 누름 · 올린 직후 = 엑셀 보기 창 · 본 칸 호표 표(Hopyo_View) 지움 · 고름 표시 = 창 열린 동안 - 엑셀 보기 머리 막대 = 시트 · 호표 수 · 검산 · 제안 · 셈 중 · [삭제](옛 본 칸 표 몫) - 호표 목록 제N호표 오른쪽 [수정] = 그 호표 확인 창 · 펼침 표 · [확인 창 열기] 지움 - [고른 N개 확인하고 만들기] → [저장] = 물음 한 번 뒤 체크 줄로 하위 호표부터 생성 · 체크 줄 없음 건너뜀 · 409 안내 - 목록 숫자 다 연결(같고 0 넘음) = 녹색(var(--color-success)) - 별칭 장부 뜻 한 줄(LEDGER_HINT) · 갈래 글 로직없음 · 사용안함 · [변경 취소] · [로직 추가] · [내 층 복사] · [삭제] Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../M02_MasterTemplete_Hopyo.css | 9 + .../M02_MasterTemplete_Hopyo_Check.ts | 8 + .../M02_MasterTemplete_Hopyo_Ledger.ts | 30 ++- .../M02_MasterTemplete_Hopyo_Sheet.ts | 246 +++++++++++------- .../M02_MasterTemplete_Hopyo_Side.ts | 64 ++--- .../M02_MasterTemplete_Hopyo_View.ts | 200 -------------- resources/tester/helper_m02_hopyo_check.cjs | 6 + resources/tester/test_m02_hopyo_screen.py | 61 +++-- resources/tester/test_m02_hopyo_seam.py | 2 +- 9 files changed, 271 insertions(+), 355 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 21202d055..612aee372 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -290,6 +290,15 @@ white-space: nowrap; } +/* 목록 숫자 — 다 연결(359-2)이면 녹색 · [수정] 은 번호 바로 오른쪽 · 줄 높이 안 커짐 */ +.m02-hopyo-sheet__item .m02-hopyo__note.is-linked { + color: var(--color-success); +} + +.m02-hopyo-sheet__item > .ui-btn { + flex-shrink: 0; +} + .m02-hopyo-sheet__result { max-height: 40%; overflow: auto; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index af6672c92..05f10c7c9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -74,6 +74,14 @@ export function loadDraft(k: string): CheckDraft { return empty(); } } +/** 확인 창을 열어 만진 적 있음(캐시 있음) */ +export function hasDraft(k: string): boolean { + try { + return sessionStorage.getItem(k) !== null; + } catch { + return false; + } +} export function saveDraft(k: string, d: CheckDraft): void { try { sessionStorage.setItem(k, JSON.stringify(d)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts index f245bdb4d..b2d752cc0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -2,8 +2,9 @@ * M02_MasterTemplete_Hopyo_Ledger.ts * 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶. * · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만) - * · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] · - * 위 층 줄 [내 층으로 고쳐 씀] + * · 뜻(359-4 · 머리 한 줄 · 좌측 단추 title) = 엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안 + * · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 추가] · 배 · 몫 · [옵션] · [삭제] · + * 위 층 줄 [내 층 복사] · 갈래 글 = 확인 창 드롭다운과 같음(짝 · 로직없음 · 사용안함 · 저장 값 그대로) * · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사) * · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기. * ========================================================================== */ @@ -24,7 +25,16 @@ import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import "./M02_MasterTemplete_Hopyo.css"; export const LEDGER_TITLE = "별칭 장부"; +/** 별칭 장부 뜻 한 줄(359-4) — 좌측 단추 title · 화면 머리 */ +export const LEDGER_HINT = + "엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안"; const KINDS = ["짝", "우리에 없음", "안 씀"] as const; +/** 갈래 화면 글 — 확인 창 드롭다운과 같음(저장 값 그대로) */ +export const KIND_TEXT: Record = { + 짝: "짝", + "우리에 없음": "로직없음", + "안 씀": "사용안함", +}; const LAYER_TEXT: Record = { system: "마스터", company: "회사", personal: "개인" }; type Edits = Record; /** 화면 한 줄 — 서버 줄 위에 고친 것 */ @@ -136,11 +146,11 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { className: "ui-input ui-input--sm", children: [ el("option", { text: "갈래 전체", attrs: { value: "" } }), - ...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })), + ...KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })), ], }) as HTMLSelectElement; const save = createButton({ label: "저장", variant: "filled", size: "sm" }); - const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" }); + const drop = createButton({ label: "변경 취소", variant: "ghost", size: "sm" }); const body = el("tbody"); const keep = (): void => { saveEdits(k, edits); @@ -199,7 +209,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { if (mine && r.갈래 === "짝") pairs.push( createButton({ - label: "로직 더함", + label: "로직 추가", variant: "ghost", size: "sm", onClick: () => @@ -217,7 +227,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { ? (() => { const s = el("select", { className: "ui-input ui-input--sm", - children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })), + children: KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })), }) as HTMLSelectElement; s.value = r.갈래; s.addEventListener("change", () => @@ -225,12 +235,12 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { ); return el("td", { children: [s] }); })() - : td(r.갈래); + : td(KIND_TEXT[r.갈래] ?? r.갈래); const acts: HTMLElement[] = []; if (mine) acts.push( createButton({ - label: "지우기", + label: "삭제", variant: "danger", size: "sm", onClick: () => { @@ -242,7 +252,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠]) acts.push( createButton({ - label: "내 층으로 고쳐 씀", + label: "내 층 복사", variant: "ghost", size: "sm", onClick: () => { @@ -316,7 +326,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { className: "m02-hopyo__bar", children: [ el("strong", { text: LEDGER_TITLE }), - note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"), + note(`${LEDGER_HINT} · 사전조합 생성 때 남음 · 아래 층이 이김`), search, kind, ...(opt.canWrite ? [save, drop] : []), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index e9b7bef0a..d4902b1ca 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -1,14 +1,15 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Sheet.ts - * 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 - * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m — 뒤 셈 중이면 3 초마다 다시) - * + [고른 n개 확인하고 만들기]. + * 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3 · 359-1 · 359-2) — 큰 모달 · 머리 막대 = 시트 · 호표 수 · 검산 · + * 제안 n / 줄 m · 숫자 판정 셈 중 · [삭제](좌측 파일 줄 누름으로 바로 엶 · 옛 본 칸 표 몫) · + * 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 일위대가 · 단가산출) · + * 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장]. * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 - * · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 또는 맞음 · 다른 점 수 · - * [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) — - * 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · - * `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) - * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨 + * · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로) + * · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` — + * 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 · + * 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) + * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨 * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ @@ -16,14 +17,17 @@ import { createButton, el, hideLoadingOverlay, + showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; import { buildHopyo, + deleteHopyo, readHopyo, readHopyoGrid, readHopyoOne, @@ -38,8 +42,8 @@ import { import { bodyOf, draftKey, + hasDraft, HOPYO_BUILT, - leftOut, loadDraft, openHopyoCheck, partsOf, @@ -64,6 +68,10 @@ export interface HopyoSheetOptions { 단산행?: number | [number, number]; /** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */ onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void; + /** 창을 닫았음(좌측 파일 줄 고름 표시) */ + onClose?: () => void; + /** [삭제] 했음 — 좌측 목록 다시 · 없으면 [삭제] 단추 없음 */ + onDeleted?: () => void; } const ILWI = "일위대가"; @@ -74,6 +82,9 @@ const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); +/** 목록 숫자 「연결된 줄 / 연결할 줄」 — 다 연결(같고 0 넘음)이면 참 · 0/0 은 색 없음 */ +export const allLinked = (m: [number, number]): boolean => m[1] > 0 && m[0] === m[1]; + /** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */ export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null => list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null; @@ -119,7 +130,11 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { let current: number | null = opt.호표 ?? null; const checked = new Set(); const items = new Map(); + const target = opt.target ?? opt.scope; + const head = el("div", { className: "m02-hopyo__bar", children: [note("엑셀을 읽는 중…")] }); + const warn = el("p", { className: "m02-hopyo__warn" }); + warn.hidden = true; const left = el("div", { className: "m02-hopyo-sheet__grid", children: [note("엑셀을 읽는 중…")], @@ -130,8 +145,9 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { }) as HTMLInputElement; const list = el("div", { className: "m02-hopyo-sheet__list" }); const count = note(""); - const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" }); + const go = createButton({ label: "저장", variant: "filled", size: "sm" }); go.disabled = true; + go.setAttribute("data-hopyo-save", ""); const result = el("div", { className: "m02-hopyo-sheet__result" }); const right = el("div", { className: "m02-hopyo-sheet__side", @@ -143,25 +159,70 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { ], }); - openModal({ + const finish = (): void => { + if (closed) return; + closed = true; + sheet?.destroy(); + opt.onClose?.(); + }; + const modal = openModal({ title: `엑셀 보기 — ${opt.파일}`, closeLabel: "닫기", wide: true, dialogClass: "m02-modal m02-hopyo-sheet__dialog", beforeClose: async () => { - closed = true; - sheet?.destroy(); + finish(); return true; }, mount: (body) => - body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })), + body.append(head, warn, el("div", { className: "m02-hopyo-sheet", children: [left, right] })), }); const paintGo = (): void => { - go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`; + go.querySelector(".ui-btn__label")!.textContent = checked.size + ? `저장 (${checked.size})` + : "저장"; go.disabled = !checked.size; }; + /** 머리 막대 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 제안 · 셈 중 · [삭제]) */ + function paintHead(file: HopyoView): void { + const wrong = file.검산.어긋남; + const c = file.셈 && !file.셈.끝 ? file.셈 : null; + head.replaceChildren( + note(`시트 ${file.시트}`), + note(`호표 ${file.호표수}`), + note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), + note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`), + ...(c + ? [note(`숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`)] + : []), + ...(opt.canWrite && opt.onDeleted ? [dropButton()] : []), + ); + warn.textContent = wrong.join("\n"); + warn.hidden = !wrong.length; + } + + const dropButton = (): HTMLButtonElement => + createButton({ + label: "삭제", + variant: "danger", + size: "sm", + onClick: async () => { + const ask = `실무자료 「${opt.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`; + if (!(await showConfirmDialog(ask, "삭제"))) return; + try { + await deleteHopyo(opt.scope, opt.key); + showToast("지움", "success"); + finish(); + modal.close(); + opt.onDeleted?.(); + } catch (error) { + showToast(`실패 — ${why(error)}`, "error"); + } + }, + }); + /** 목록 강조 · 보이게(시트 고름은 그대로) */ function mark(no: number | null): void { items.get(current ?? NaN)?.classList.remove("is-current"); @@ -180,6 +241,28 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi); } + /** [수정] — 그 호표 확인 창(줄 누름과 따로) */ + const editButton = (h: HopyoBrief): HTMLElement => { + const b = createButton({ + label: "수정", + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); + openHopyoCheck({ + scope: opt.scope, + key: opt.key, + no: h.번, + target, + canWrite: opt.canWrite, + onBuilt: (got) => opt.onImport?.(got, h), + }); + }, + }); + b.setAttribute("data-hopyo-edit", String(h.번)); + return b; + }; + function paintList(): void { const q = search.value.trim().toLowerCase(); items.clear(); @@ -197,14 +280,17 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { else checked.delete(h.번); paintGo(); }); + const linked = note(`${h.맞춤[0]}/${h.맞춤[1]}`); + linked.classList.toggle("is-linked", allLinked(h.맞춤)); const row = el("div", { className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`, attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() }, children: [ box, el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }), + editButton(h), el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }), - note(`${h.맞춤[0]}/${h.맞춤[1]}`), + linked, ], }); row.addEventListener("click", () => focus(h.번)); @@ -243,13 +329,14 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { }); } - /** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */ + /** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 · 머리 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */ function poll(file: HopyoView): void { if (!file.셈 || file.셈.끝) return; window.setTimeout(async () => { const next = closed ? null : await readHopyo(opt.scope, opt.key).catch(() => null); if (!next || closed) return; briefs = next.호표; + paintHead(next); paintList(); poll(next); }, POLL_MS); @@ -264,6 +351,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { if (closed) return; briefs = file.호표; spans = grid instanceof Error ? [] : grid.호표; + paintHead(file); paintList(); poll(file); if (grid instanceof Error) return void left.replaceChildren(note(grid.message)); @@ -271,7 +359,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN); else if (current !== null) focus(current); } catch (error) { - if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`)); + if (!closed) { + head.replaceChildren(note(`못 읽음 — ${why(error)}`)); + left.replaceChildren(note(`못 읽음 — ${why(error)}`)); + } } } @@ -297,84 +388,50 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null; } + const headCell = (no: number, name: string): HTMLElement => + el("td", { text: `제${no}호표 ${name}`.trim() }); - /** ① 고른 호표마다 서버 보기 → 펼침 표 */ - async function review(): Promise { - const target = opt.target ?? opt.scope; + /** [저장] — 물음 한 번 → 고른 호표마다 서버 보기 → 체크 줄 있는 것만 만들기(체크 줄 없으면 건너뜀) */ + async function save(): Promise { const nos = [...checked].sort((a, b) => a - b); + const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length; + const ask = + `고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` + + (fresh ? ` — 확인 창을 안 연 ${fresh}개는 서버 제안 체크 줄` : ""); + if (!(await showConfirmDialog(ask, "저장"))) return; go.disabled = true; const ones: HopyoOne[] = []; - for (const no of nos) { - if (closed) return; - try { - ones.push(await readHopyoOne(opt.scope, opt.key, no, target)); - } catch (error) { - showToast(`제${no}호표 — ${why(error)}`, "error"); + const skipped: HTMLElement[] = []; + showLoadingOverlay(); + try { + for (const no of nos) { + if (closed) return; + try { + ones.push(await readHopyoOne(opt.scope, opt.key, no, target)); + } catch (error) { + skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] })); + } } + } finally { + hideLoadingOverlay(); + paintGo(); } - paintGo(); - const rows = ones.map((one) => { - const h = one.호표; - const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); - const out = Object.entries(leftOut(h.줄, d)); - const tally = (s: string): number => h.줄.filter((l) => l.맞춤.상태 === s).length; - const calc = - h.셈 && !h.셈.끝 - ? `셈 중 ${h.셈.한}/${h.셈.모두}` - : `맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`; - const brief = briefs.find((x) => x.번 === h.번) ?? h; - return el("tr", { - attrs: { "data-review": String(h.번) }, - children: [ - el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }), - el("td", { - text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length), - className: "m02-hopyo__num", - }), - el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }), - el("td", { text: calc }), - el("td", { + const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one)); + for (const one of ones) + if (!ready.includes(one)) + skipped.push( + el("tr", { children: [ - createButton({ - label: "확인 창 열기", - variant: "ghost", - size: "sm", - onClick: () => - openHopyoCheck({ - scope: opt.scope, - key: opt.key, - no: h.번, - target, - canWrite: opt.canWrite, - onBuilt: (got) => opt.onImport?.(got, brief), - }), - }), + headCell(one.호표.번, one.호표.이름), + el("td", { text: "건너뜀 — 체크 줄 없음" }), ], }), - ], - }); - }); - const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one)); - const all = createButton({ - label: `이대로 ${ready.length}개 만들기`, - variant: "filled", - size: "sm", - disabled: !ready.length, - onClick: () => void makeAll(ready), - }); - all.setAttribute("data-hopyo-make-all", ""); - result.replaceChildren( - table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows), - el("div", { - className: "m02-hopyo__bar", - children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")], - }), - ); + ); + await makeAll(ready, skipped); } - /** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */ - async function makeAll(ones: HopyoOne[]): Promise { - const target = opt.target ?? opt.scope; + /** 하위 호표부터 차례로 만들기 — 하위 확인 전(409)은 안내 */ + async function makeAll(ones: HopyoOne[], skipped: HTMLElement[]): Promise { const by = new Map(ones.map((one) => [one.호표.번, one])); const order = buildOrder( ones.map((one) => { @@ -392,7 +449,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { for (const no of order) { const one = by.get(no)!; const h = briefs.find((x) => x.번 === no) ?? one.호표; - const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() }); + const cell = headCell(no, h.이름); try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one)); @@ -402,7 +459,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { el("tr", { attrs: { "data-built": got.참조.키 }, children: [ - head, + cell, el("td", { text: `${got.참조.키} · ${builtWord(got)}` }), el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), @@ -410,17 +467,24 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { }), ); } catch (error) { - rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] })); + const text = + error instanceof StaleError + ? `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장` + : `실패 — ${why(error)}`; + rows.push(el("tr", { children: [cell, el("td", { text })] })); } } } finally { hideLoadingOverlay(); } if (ok) document.dispatchEvent(new Event(HOPYO_BUILT)); - result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows)); - showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning"); + result.replaceChildren( + table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]), + ); + const all = order.length + skipped.length; + showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning"); } - go.addEventListener("click", () => void review()); + go.addEventListener("click", () => void save()); void load(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index ef48f51ca..8e6585aa8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -1,21 +1,20 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Side.ts - * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · - * [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면 - * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 · - * 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 · + * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 359-1 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · + * [파일 업로드](구조물 조합 층 단추 꼴 `m02-layers__btn` · 숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + + * 파일 줄 → 누르면 바로 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴 · 지우기 · 검산 · 셈 중은 창 머리) · + * 고름 표시 = 그 파일 창이 열린 동안 · 올린 직후도 엑셀 보기 창 · * 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api"; -import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger"; -import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View"; +import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger"; export const HOPYO_TITLE = "일위대가 실무자료"; export interface HopyoSectionOptions { - /** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 본 화면 확정 · 만들기 없음 */ + /** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 엑셀 보기 창 보기만 */ canWrite: boolean; /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ confirmLeave: () => Promise; @@ -42,22 +41,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" }, }) as HTMLInputElement; input.hidden = true; + // 구조물 조합 층 단추(`TreePanel_Roots` [마스터])와 같은 꼴 — 보통 크기 · 같은 클래스 const upload = createButton({ - label: "엑셀 올리기", + label: "파일 업로드", variant: "filled", - size: "sm", disabled: !opt.canWrite, onClick: () => input.click(), }); + upload.classList.add("m02-layers__btn"); upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음"; const ledger = el("button", { className: "m02-side__item ui-sidebar-row", text: LEDGER_TITLE, - attrs: { - type: "button", - title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기", - "data-hopyo-ledger-open": "", - }, + attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" }, }); ledger.addEventListener("click", () => void showLedger()); const body = el("div", { @@ -66,6 +62,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { }); let files: HopyoFile[] = []; let open: { key: string; view: HTMLElement } | null = null; + let sheetKey: string | null = null; // 엑셀 보기 창이 열린 파일 let seq = 0; input.addEventListener("change", () => { @@ -78,9 +75,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : ""; showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success"); await refresh(); - await show(got); - const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet"); - sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite }); + await showSheet(got); }) .catch((error) => showToast(why(error), "error")) .finally(() => (upload.disabled = !opt.canWrite)); @@ -92,32 +87,37 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { ...(files.length ? files.map((f) => { const row = el("button", { - className: `m02-side__item ui-sidebar-row${open?.key === f.키 ? " is-active" : ""}`, + className: `m02-side__item ui-sidebar-row${sheetKey === f.키 ? " is-active" : ""}`, text: `${f.파일} · 호표 ${f.호표수}`, attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 }, }); - row.addEventListener("click", () => void show(f)); + row.addEventListener("click", () => void showSheet(f)); return row; }) : [el("p", { className: "m02-side__empty", text: "올린 실무자료 없음" })]), ); } - async function show(file: HopyoFile): Promise { - if (open?.key === file.키 && open.view.isConnected) return; - if (!(await opt.confirmLeave())) return; - const view = buildHopyoView(file.키, { - scope, - canWrite: opt.canWrite, - onDeleted: () => { - view.replaceChildren(el("p", { className: "m02-side__empty", text: "지운 실무자료" })); - void refresh(); - }, + /** 파일 줄 누름 · 올린 직후 = 바로 엑셀 보기 창(본 칸은 그대로 · 열 때 불러옴) */ + async function showSheet(file: HopyoFile): Promise { + const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet").catch((error: unknown) => { + showToast(why(error), "error"); + return null; }); - opt.show(`${HOPYO_TITLE} — ${file.파일}`, view); - opt.onOpen(); - open = { key: file.키, view }; + if (!sheet) return; + sheetKey = file.키; paintList(); + sheet.openHopyoSheet({ + scope, + key: file.키, + 파일: file.파일, + canWrite: opt.canWrite, + onClose: () => { + if (sheetKey === file.키) sheetKey = null; + paintList(); + }, + onDeleted: () => void refresh(), + }); } async function showLedger(): Promise { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts deleted file mode 100644 index 433c83713..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Hopyo_View.ts - * 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 · - * [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` · - * 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음. - * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. - * ========================================================================== */ - -import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { loadingError, showLoading } from "@ui/ui_template_loading"; -import { - deleteHopyo, - readHopyo, - type HopyoScope, - type HopyoView, -} from "./M02_MasterTemplete_Hopyo_Api"; -import { buildHopyoCheck, POLL_MS } from "./M02_MasterTemplete_Hopyo_Check"; -import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; -import "./M02_MasterTemplete_Hopyo.css"; - -export interface HopyoViewOptions { - scope: HopyoScope; - /** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */ - canWrite: boolean; - /** 파일을 지웠음 — 좌측 목록 다시 */ - onDeleted: () => void; -} - -const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -const won = (v: number | null | undefined): string => - v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); -const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); -/** 엑셀 보기 창(329) — 열 때 불러옴 */ -const openSheet = (o: HopyoSheetOptions): void => - void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); - -const grid = (heads: string[], body: HTMLElement): HTMLElement => - el("div", { - className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", - children: [ - el("table", { - className: "ui-general-block__table m02-hopyo__table", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - body, - ], - }), - ], - }); -const td = (text: string, cls = ""): HTMLElement => - el("td", { text, className: cls, attrs: { title: text } }); -const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); - -export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { - const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); - let nav = 0; // 늦게 온 응답 버림 - let query = ""; - - async function load(get: () => Promise, retry: () => void): Promise { - const my = ++nav; - const stop = showLoading(view); - try { - const got = await get(); - return my === nav ? got : null; - } catch (error) { - if (my === nav) view.replaceChildren(loadingError(why(error), retry)); - return null; - } finally { - stop(); - } - } - - /* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */ - async function drawFile(): Promise { - const file = await load( - () => readHopyo(opt.scope, key), - () => void drawFile(), - ); - if (!file) return; - let hopyos = file.호표; - const search = el("input", { - className: "ui-input ui-input--sm m02-hopyo__search", - attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query }, - }) as HTMLInputElement; - const body = el("tbody"); - const paint = (): void => { - const q = search.value.trim().toLowerCase(); - query = search.value; - body.replaceChildren( - ...hopyos - .filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q)) - .map((h) => { - const tr = el("tr", { - className: "m02-hopyo__row", - attrs: { "data-hopyo-no": String(h.번) }, - children: [ - td(`제${h.번}호표`), - td(h.이름), - td(h.규격), - td(h.단위), - td(won(h.금액.계), "m02-hopyo__num"), - td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"), - ], - }); - tr.addEventListener("click", () => void drawOne(h.번)); - return tr; - }), - ); - }; - search.addEventListener("input", paint); - paint(); - // 뒤 숫자 판정 셈 중이면 3 초마다 다시 읽어 「제안 n / 줄 m」 갱신 — 셈 끝 · 다른 화면 · 못 읽음이면 멈춤 - const ready = note(""); - const calc = note(""); - const show = (f: HopyoView): void => { - ready.textContent = `제안 ${f.맞춤[0]} / 줄 ${f.맞춤[1]}`; - const c = f.셈 && !f.셈.끝 ? f.셈 : null; - calc.textContent = c - ? `숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}` - : ""; - calc.hidden = !c; - }; - const my = nav; - const live = (): boolean => my === nav && view.isConnected; - const poll = (f: HopyoView): void => { - if (!f.셈 || f.셈.끝) return; - window.setTimeout(async () => { - const next = live() ? await readHopyo(opt.scope, key).catch(() => null) : null; - if (!next || !live()) return; - hopyos = next.호표; - show(next); - paint(); - poll(next); - }, POLL_MS); - }; - show(file); - poll(file); - const wrong = file.검산.어긋남; - view.replaceChildren( - el("div", { - className: "m02-hopyo__bar", - children: [ - el("strong", { text: file.파일 }), - note(`시트 ${file.시트}`), - note(`호표 ${file.호표수}`), - note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), - ready, - calc, - createButton({ - label: "엑셀로 보기", - size: "sm", - onClick: () => - openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }), - }), - ...(opt.canWrite ? [dropButton(file)] : []), - ], - }), - ...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []), - search, - grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body), - ); - } - - const dropButton = (file: HopyoView): HTMLButtonElement => - createButton({ - label: "지우기", - variant: "danger", - size: "sm", - onClick: async () => { - const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`; - if (!(await showConfirmDialog(ask, "지우기"))) return; - try { - await deleteHopyo(opt.scope, key); - showToast("지움", "success"); - opt.onDeleted(); - } catch (error) { - fail(error); - } - }, - }); - - /* ── 호표 하나 — 확인 창 ────────────────────────────────────── */ - function drawOne(no: number): void { - nav += 1; // 파일 표 늦은 응답 버림 - view.replaceChildren( - buildHopyoCheck({ - scope: opt.scope, - key, - no, - canWrite: opt.canWrite, - onBack: () => void drawFile(), - }), - ); - } - - void drawFile(); - return view; -} diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index dc55d7522..9e2f83b33 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -185,6 +185,12 @@ let out0 = null; { 번: 7, 아래: [8] }, { 번: 8, 아래: [7] }, ]), + linked: [ + [2, 2], + [1, 2], + [0, 0], + [3, 2], + ].map(sheet.allLinked), }, }; // 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 0a421e7fd..c3b7b89ff 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -24,7 +24,6 @@ NEW = ( "Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", - "Hopyo_View", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_Choose", @@ -192,7 +191,9 @@ def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창(): def test_엑셀_보기_창_하위_호표_먼저(): - assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례 + got = _check()["sheet"] + assert got["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례 + assert got["linked"] == [True, False, False, False] # 다 연결만 녹색 · 0/0 색 없음(359-2) def _pick() -> dict: @@ -279,18 +280,27 @@ def test_옛_즉시_확정과_바로_만들기_없음(): ): assert label not in every, label users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) - assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번 + assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤 check = texts["Hopyo_Check"] assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check assert "showLoadingOverlay()" in check and "dropDraft(k)" in check assert "m02-hopyo-check:" in check and "POLL_MS" in check sheet = texts["Hopyo_Sheet"] - assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet - assert "확인 창 열기" in sheet and "buildOrder(" in sheet + # 359-2 — 펼침 표 · [확인 창 열기] · [이대로 n개 만들기] 없음 · [저장] = 물음 한 번 뒤 체크 줄로 만들기 + for gone in ("확인 창 열기", "이대로", "review(", "확인하고 만들기"): + assert gone not in sheet, gone + save = sheet.split("async function save(")[1].split("async function makeAll(")[0] + assert 'showConfirmDialog(ask, "저장")' in save and "hasDraft(" in save + assert "건너뜀 — 체크 줄 없음" in save and "await makeAll(ready, skipped);" in save + assert "buildOrder(" in sheet and "error instanceof StaleError" in sheet # 하위 먼저 · 409 안내 + edit = sheet.split("const editButton =")[1].split("function paintList(")[0] + assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit + row = sheet.split("function paintList(")[1] + assert row.index("m02-hopyo-sheet__no") < row.index("editButton(h)") < row.index("__name") + assert 'linked.classList.toggle("is-linked", allLinked(h.맞춤));' in row pick = texts["Hopyo_Pick"] assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick - view = texts["Hopyo_View"] - assert "buildHopyoCheck(" in view and "choose(" not in view + assert not (M02 / "M02_MasterTemplete_Hopyo_View.ts").exists() # 본 칸 표 없음(359-1) ledger = texts["Hopyo_Ledger"] assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger for gone in ("발주처", "회사로 올리기", "쓰인 횟수"): @@ -306,6 +316,13 @@ def test_좌측_판_자리와_권한(): assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 assert "hopyo.paint();" in side.split("function paint(): void {")[1] hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") + # 359-1 — [파일 업로드] = 구조물 조합 층 단추 꼴(보통 크기 · m02-layers__btn) · 파일 줄 = 바로 엑셀 보기 + up = hopyo_side.split("const upload = createButton({")[1].split("upload.title")[0] + assert 'label: "파일 업로드"' in up and 'upload.classList.add("m02-layers__btn");' in up + assert "size:" not in up and "엑셀 올리기" not in hopyo_side + assert "buildHopyoView" not in hopyo_side + assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side + assert "onClose:" in hopyo_side and "onDeleted: () => void refresh()," in hopyo_side assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부] @@ -332,16 +349,15 @@ def test_로직_추가_한_곳과_파일_규칙(): def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' side = _text("M02_MasterTemplete_Hopyo_Side.ts") - assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동 - view = _text("M02_MasterTemplete_Hopyo_View.ts") - assert lazy in view and '"엑셀로 보기"' in view + assert "await showSheet(got);" in side # 올린 직후 자동 + assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0] check = _text("M02_MasterTemplete_Hopyo_Check.ts") assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check assert "단산행: 행" in check pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") assert lazy in pick static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"' - for text in (side, view, pick, check): + for text in (side, pick, check): assert not re.search(static, text, re.M) # 정적 import 없음(타입만) sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet @@ -400,15 +416,18 @@ def test_확인_창_손질_339(): sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") assert "poll(file);" in sheet and "poll(next);" in sheet and "POLL_MS" in sheet assert "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in sheet - assert '`맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`' in sheet -def test_본_칸_호표_목록_셈_끝까지_다시_읽음_339(): - """339 덧고침 2 — 본 칸 호표 표 「제안 n / 줄 m」 · 머리 「셈 중」 — 셈 중이면 3 초마다 다시 읽음.""" - view = _text("M02_MasterTemplete_Hopyo_View.ts") - assert "poll(file);" in view and "poll(next);" in view and "POLL_MS" in view - assert "hopyos = next.호표;" in view and "...hopyos" in view - assert ( - "const live = (): boolean => my === nav && view.isConnected;" in view - ) # 호표 하나로 가면 멈춤 - assert "숫자 판정 셈 중" in view +def test_엑셀_보기_머리_막대_셈_끝까지_다시_읽음_359(): + """339 덧고침 2 → 359-1 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 「제안 n / 줄 m」 · 「셈 중」 · [삭제])은 + 엑셀 보기 창 머리 · 셈 중이면 3 초마다 다시 읽음(머리 · 목록 함께).""" + sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") + head = sheet.split("function paintHead(")[1].split("const dropButton")[0] + for word in ("시트 ", "호표 ", "검산 어긋남", "검산 맞음", "제안 ", "숫자 판정 셈 중"): + assert word in head, word + assert "opt.canWrite && opt.onDeleted ? [dropButton()]" in head + drop = sheet.split("const dropButton =")[1].split("/** 목록 강조")[0] + assert 'label: "삭제"' in drop and 'showConfirmDialog(ask, "삭제")' in drop + assert drop.index("deleteHopyo(") < drop.index("modal.close();") < drop.index("opt.onDeleted") + poll = sheet.split("function poll(")[1].split("async function load(")[0] + assert "paintHead(next);" in poll and "paintList();" in poll and "poll(next);" in poll diff --git a/resources/tester/test_m02_hopyo_seam.py b/resources/tester/test_m02_hopyo_seam.py index 45d5af159..ad7c77fac 100644 --- a/resources/tester/test_m02_hopyo_seam.py +++ b/resources/tester/test_m02_hopyo_seam.py @@ -1,5 +1,5 @@ """일위대가 실무자료 이음매(PLAN 329 · 330) — 실제 서버 응답(북부청 격자 · 호표 맞춤)을 -화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임. +화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet)에 먹임. 행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 · 엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 도우미는 고르기 창 `Hopyo_Choose`. From 5417cde435af15e6f82d133467cf4ec5e31ac870 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:41:55 +0900 Subject: [PATCH 11/33] =?UTF-8?q?fix(=EC=A2=85=EB=8B=A8=EB=8F=84):=20354-3?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=81=8A=EA=B9=80=20=E2=80=94?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=A9=94=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EB=AC=B4=EC=8B=9C=20=EA=B3=B5=EC=9A=A9=20=EB=8F=84=EC=9A=B0?= =?UTF-8?q?=EB=AF=B8(=EC=9C=A0=ED=86=A0=20=C2=B7=20=ED=91=9C)=20=C2=B7=20?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=EB=8A=94=20=EC=97=B4=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EB=B6=84=EB=B0=B0=20=EB=A7=9E=EC=B6=A4=20=EC=95=84=EB=8B=90=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C=20=ED=95=9C=20=ED=8B=80=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=EC=97=86=EC=9D=B4?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A0=A4=EB=8F=84=20?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=80=EB=A1=9C=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- .../B05_Profile_UI_Profile_MassHaul.ts | 98 ++++++++------ B05_Profile/B05_Profile_UI_Profile_Render.ts | 9 +- .../B05_Profile_UI_Profile_TableOverlay.ts | 16 +-- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 29 +++++ resources/tester/helper_354_3_scroll_echo.cjs | 121 ++++++++++++++++++ resources/tester/test_354_3_scroll_echo.py | 87 +++++++++++++ 6 files changed, 308 insertions(+), 52 deletions(-) create mode 100644 resources/tester/helper_354_3_scroll_echo.cjs create mode 100644 resources/tester/test_354_3_scroll_echo.py diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 68c8575e1..9d3af662c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -56,7 +56,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; -import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel"; import { frameOf } from "../common_util/common_util_view_window"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ @@ -165,6 +165,8 @@ export interface RouteMassHaulPanel { attachScrollSync(main: HTMLElement): void; /** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */ draw(params: RouteMassHaulDrawParams): void; + /** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */ + drawWindow(params: RouteMassHaulDrawParams): void; } function readVisible(): Set { @@ -291,32 +293,29 @@ export function createRouteMassHaulPanel( onChanged(); } - // 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다. + // 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3). // 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 · // 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태. - let syncing = false; let fitted = false; - function mirror(from: HTMLElement, to: HTMLElement): void { - from.addEventListener("scroll", () => { - if (syncing || fitted) return; - syncing = true; - to.scrollLeft = from.scrollLeft; - syncing = false; - }); - } let syncedMain: HTMLElement | null = null; + let putScroll: ((to: HTMLElement, left: number) => void) | null = null; + /** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */ + let syncedIn: Document | null = null; function attachScrollSync(main: HTMLElement): void { if (syncedMain === main) return; syncedMain = main; - mirror(main, scroll); - mirror(scroll, main); + putScroll = linkScrollLeft(main, scroll, () => fitted); } /** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */ let drawnOnce = false; + /** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */ + let lastSeries: MassHaulSeries[] | null = null; function note(message: string): void { drawnOnce = false; + lastSeries = null; + syncedIn = null; pendingChip.hidden = true; bar.replaceChildren(); scroll.replaceChildren(); @@ -363,18 +362,34 @@ export function createRouteMassHaulPanel( ); } - /** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */ - function draw(params: RouteMassHaulDrawParams): void { - pipelineWork("유토곡선", () => drawNow(params)); + /** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */ + function draw(params: RouteMassHaulDrawParams, reuse = false): void { + pipelineWork("유토곡선", () => drawNow(params, reuse)); } - function drawNow(params: RouteMassHaulDrawParams): void { + /** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) · + * 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */ + let windowDirty = false; + function drawWindow(params: RouteMassHaulDrawParams): void { + if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return; lastParams = params; + // 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림 + if (windowDirty || windowState.frame) return; + windowDirty = true; + frameOf(overlay, () => { + if (windowDirty && lastParams) draw(lastParams, true); + }); + } + + function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void { + lastParams = params; + windowDirty = false; overlay.hidden = !open; syncHandlePosition(); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. // 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번). if (!open) { + syncedIn = null; reportSummary(params); return; } @@ -407,21 +422,25 @@ export function createRouteMassHaulPanel( // (354-2 · 계산 코드는 공용에 남김). // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). - const series: MassHaulSeries[] = computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ["cross"], - ); + // 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3). + const series: MassHaulSeries[] = + reuse && lastSeries + ? lastSeries + : computeMassHaulSeries( + params.longitudinal, + params.crossSections, + context.conversion, + context.naturalSpoilMinSlope, + ["cross"], + ); // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. - emitSummary(series); + if (series !== lastSeries) emitSummary(series); + lastSeries = series; if (!series.length) { note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); return; } bar.replaceChildren(); - legendLayer.replaceChildren(); const visible = readVisible(); // 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙. const banded = series.find((entry) => visible.has(entry.key)); @@ -491,24 +510,30 @@ export function createRouteMassHaulPanel( wrap.append(chart); // 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙). if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight)); + // 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이 + // 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다. + const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null; scroll.replaceChildren(wrap); - // 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다. // 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다. if (fitAll) scroll.scrollLeft = 0; - else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft; + else if (keepLeft !== null) scroll.scrollLeft = keepLeft; + else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft); + syncedIn = fitAll ? null : scroll.ownerDocument; - legendLayer.append( - createMassHaulLegend(series, visible, toggleSeries, () => { - resetBalloonOffsets(); - onChanged(); - }), - ); + // 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다. + if (!reuse || !legendLayer.firstChild) + legendLayer.replaceChildren( + createMassHaulLegend(series, visible, toggleSeries, () => { + resetBalloonOffsets(); + onChanged(); + }), + ); drawnOnce = true; // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. scheduleMassHaulSettle( windowState, () => { - if (lastParams) draw(lastParams); + if (lastParams) draw(lastParams, true); }, overlay, ); @@ -533,6 +558,7 @@ export function createRouteMassHaulPanel( context = next; }, attachScrollSync, - draw, + draw: (params) => draw(params), + drawWindow, }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index dc5f5ba8b..d0a4fad59 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -64,7 +64,10 @@ export interface ProfileRenderContext { * 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */ onStaleDesigns?: (key: string) => boolean; callbacks: RouteProfilePanelCallbacks | undefined; - massHaul: { draw: (params: RouteMassHaulDrawParams) => void }; + massHaul: { + draw: (params: RouteMassHaulDrawParams) => void; + drawWindow: (params: RouteMassHaulDrawParams) => void; + }; tableOverlay: { isOpen: () => boolean; contentHeight: () => number; @@ -452,9 +455,9 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 // 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이 // 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는 - // 값보다 크다(요소 수가 종단의 1/3). + // 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). massHaulUpdater = (fromM, toM) => { - massHaul.draw({ + massHaul.drawWindow({ ...massHaulParams, axis: { ...massHaulParams.axis, viewRange: { fromM, toM } }, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index e206a08f0..7c2d15b89 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -14,7 +14,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout"; -import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel"; import { frameOf } from "../common_util/common_util_view_window"; /* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ @@ -125,16 +125,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa overlay.hidden = !open; handleControl.root.addEventListener("click", () => applyOpen(!open)); - // 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일). - let syncing = false; - function mirror(from: HTMLElement, to: HTMLElement): void { - from.addEventListener("scroll", () => { - if (syncing) return; - syncing = true; - to.scrollLeft = from.scrollLeft; - syncing = false; - }); - } + // 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(유토곡선과 같음 · 354-3). let syncedMain: HTMLElement | null = null; return { @@ -161,8 +152,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa attachScrollSync(main) { if (syncedMain === main) return; syncedMain = main; - mirror(main, scroll); - mirror(scroll, main); + linkScrollLeft(main, scroll); }, setBottomOffset(px) { bottomOffset = px; diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index f1298db83..cdbfef24f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -39,6 +39,35 @@ const BASE_FRAME_MS = 1000 / 60; * 쓰면 한 번에 목표까지 튄다. */ const MAX_FRAME_MS = 64; +/** + * 두 스크롤러의 가로 자리를 서로 따라가게 잇는다 — 종단 그래프 ↔ 유토곡선 · 표(354-3). + * 써 넣은 값으로 **다음 틀에** 오는 scroll(메아리)은 무시한다. 동기 플래그로 막던 예전 길은 + * 메아리가 한 틀 전 값을 되써 앞서 간 쪽을 뒤로 튀게 했고, 그 튐이 휠 이동을 접어 끊겼다. + * `paused` 가 참이면 안 따라감. 돌려주는 `put` = 따라가는 쪽에 손으로 써 넣을 때(메아리 표시). + */ +export function linkScrollLeft( + a: HTMLElement, + b: HTMLElement, + paused: () => boolean = () => false, +): (to: HTMLElement, left: number) => void { + const echo = new Map(); + const put = (to: HTMLElement, left: number): void => { + const before = to.scrollLeft; + to.scrollLeft = left; + if (to.scrollLeft !== before) echo.set(to, to.scrollLeft); + }; + const follow = (from: HTMLElement, to: HTMLElement): void => + from.addEventListener("scroll", () => { + const wrote = echo.get(from); + echo.delete(from); + if (wrote !== undefined && Math.abs(from.scrollLeft - wrote) < 1) return; + if (!paused() && to.scrollLeft !== from.scrollLeft) put(to, from.scrollLeft); + }); + follow(a, b); + follow(b, a); + return put; +} + export function attachWheelHorizontalScroll(scroller: HTMLElement): void { let target = -1; /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ diff --git a/resources/tester/helper_354_3_scroll_echo.cjs b/resources/tester/helper_354_3_scroll_echo.cjs new file mode 100644 index 000000000..e781836b9 --- /dev/null +++ b/resources/tester/helper_354_3_scroll_echo.cjs @@ -0,0 +1,121 @@ +/* 354-3 스크롤 동기화 메아리 재현 — 진짜 휠 이동(`attachWheelHorizontalScroll`)과 진짜 잇기 + * (`linkScrollLeft`)를 가짜 창 · 가짜 스크롤러 위에서 돌려 JSON 으로 냄. + * 가짜 창은 브라우저 차례 그대로 — scrollLeft 가 바뀌면 scroll 은 **다음 틀**에 오고(틀 앞머리), + * 그 뒤 화면 틀 콜백이 돎. 옛 잇기(동기 플래그)와 새 잇기를 같은 휠로 견줌. + * 경우 넷 = 표 켬/끔 × 분배(유토 전체 맞춤) 켬/끔 · 휠을 굴리는 쪽 = 종단 · 유토 둘. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const js = ts.transpileModule( + fs.readFileSync(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }, +).outputText; + +function fakeWindow() { + const win = { now: 0, frames: [], seq: 0, pending: new Set() }; + win.requestAnimationFrame = (fn) => (win.frames.push({ id: ++win.seq, fn }), win.seq); + win.cancelAnimationFrame = (id) => (win.frames = win.frames.filter((f) => f.id !== id)); + win.getSelection = () => ({ removeAllRanges() {} }); + win.frame = () => { + win.now += 16; + const targets = [...win.pending]; + win.pending.clear(); + targets.forEach((el) => el.fire("scroll")); + const due = win.frames; + win.frames = []; + due.forEach((f) => f.fn(win.now)); + }; + return win; +} + +function scroller(win, name, { hidden = false, scrollWidth = 6000 } = {}) { + const calls = {}; + let left = 0; + const el = { + name, + ownerDocument: { defaultView: win }, + clientWidth: hidden ? 0 : 1000, + scrollWidth: hidden ? 0 : scrollWidth, + scrollTop: 0, + style: {}, + addEventListener: (type, fn) => (calls[type] ??= []).push(fn), + removeEventListener() {}, + hasPointerCapture: () => false, + fire: (type, ev = {}) => { + const e = { preventDefault() {}, ...ev }; + (calls[type] ?? []).forEach((fn) => fn(e)); + }, + }; + Object.defineProperty(el, "scrollLeft", { + get: () => left, + set: (v) => { + const next = Math.min(Math.max(0, v), Math.max(0, el.scrollWidth - el.clientWidth)); + if (next !== left) win.pending.add(el); + left = next; + }, + }); + return el; +} + +/** 옛 잇기 — 유토곡선 · 표가 각자 들고 있던 동기 플래그 꼴 그대로. */ +function oldLink(a, b, paused = () => false) { + let syncing = false; + const mirror = (from, to) => + from.addEventListener("scroll", () => { + if (syncing || paused()) return; + syncing = true; + to.scrollLeft = from.scrollLeft; + syncing = false; + }); + mirror(a, b); + mirror(b, a); +} + +function run(kind, { table, balance, driver }) { + const win = fakeWindow(); + const wheel = { exports: {} }; + new Function("exports", "module", "require", "window", js)( + wheel.exports, + wheel, + () => ({ viewOf: () => win }), + win, + ); + const body = scroller(win, "body"); + // 분배 켬 = 유토곡선 전체 맞춤(스크롤 없음) · 잇기는 멈춤 + const mass = scroller(win, "mass", { scrollWidth: balance ? 1000 : 6000 }); + const tableEl = scroller(win, "table", { hidden: !table }); + const link = kind === "old" ? oldLink : wheel.exports.linkScrollLeft; + link(body, mass, () => balance); + link(body, tableEl); + const lead = driver === "mass" && !balance ? mass : body; + [body, mass, tableEl].forEach((el) => wheel.exports.attachWheelHorizontalScroll(el)); + const trace = []; + for (let i = 0; i < 60; i += 1) { + if (i < 20 && i % 2 === 0) lead.fire("wheel", { deltaY: 120 }); + win.frame(); + trace.push(body.scrollLeft); + } + let back = 0; + for (let i = 1; i < trace.length; i += 1) if (trace[i] < trace[i - 1] - 0.01) back += 1; + return { + back, + end: Math.round(body.scrollLeft), + lead: Math.round(lead.scrollLeft), + mass: Math.round(mass.scrollLeft), + table: Math.round(tableEl.scrollLeft), + }; +} + +const out = {}; +for (const kind of ["old", "new"]) + for (const driver of ["body", "mass"]) + for (const table of [true, false]) + for (const balance of [false, true]) + out[`${kind}|${driver}|table=${table}|balance=${balance}`] = run(kind, { + table, + balance, + driver, + }); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_354_3_scroll_echo.py b/resources/tester/test_354_3_scroll_echo.py new file mode 100644 index 000000000..869f9df34 --- /dev/null +++ b/resources/tester/test_354_3_scroll_echo.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""354-3 종단 그래프 스크롤 끊김 — 동기화 메아리 · 스크롤마다 유토 다시 셈. + +- 메아리: scroll 은 다음 틀에 옴 → 동기 플래그로 못 막아 한 틀 전 값을 되씀 → 뒤로 튐 → + 휠 이동이 접힘. 공용 `linkScrollLeft`(Wheel.ts)가 써 넣은 값으로 온 scroll 을 무시. + 가짜 창(다음 틀 scroll) 재현 — 옛 잇기는 뒤로 튐 · 멈춤, 새 잇기는 4 경우 × 휠 쪽 둘 모두 튐 0. +- 유토 스크롤 그리기 = 열림 · 분배 맞춤 아님 · 붙음일 때만 · 한 틀 한 번 · 다시 셈 없음. + +`helper_354_3_scroll_echo.cjs` 가 진짜 휠 · 잇기 TS 를 가짜 창 위에서 돌림. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +B05 = Path(HERE).parents[1] / "B05_Profile" +CASES = [ + f"{driver}|table={table}|balance={balance}" + for driver in ("body", "mass") + for table in ("true", "false") + for balance in ("false", "true") +] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_354_3_scroll_echo.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_옛_잇기는_메아리로_뒤로_튐(r): + # 표 · 분배 끔(사용자 보고 경우)에서 휠 다섯 칸(1,200px) 중 210px 에서 멈춤 + assert r["old|body|table=false|balance=false"]["back"] > 0 + assert r["old|body|table=false|balance=false"]["end"] < 1200 + # 분배 켜고 표 끔 = 잇기 없음 — 사용자 관찰(분배 켜면 매끄러움)과 같음 + assert r["old|body|table=false|balance=true"] == { + "back": 0, + "end": 1200, + "lead": 1200, + "mass": 0, + "table": 0, + } + + +@pytest.mark.parametrize("case", CASES) +def test_새_잇기는_뒤로_튐_0_끝까지(r, case): + got = r[f"new|{case}"] + assert got["back"] == 0 + assert got["end"] == 1200 and got["lead"] == 1200 + table, balance = "table=true" in case, "balance=true" in case + assert got["table"] == (1200 if table else 0) + assert got["mass"] == (0 if balance else 1200) + + +def test_유토_표_같은_도우미(): + for name in ("MassHaul", "TableOverlay"): + src = (B05 / f"B05_Profile_UI_Profile_{name}.ts").read_text(encoding="utf-8") + assert "linkScrollLeft(main, scroll" in src + assert "syncing" not in src + + +def test_유토_스크롤_그리기는_창_따를_때만_한_틀_한_번(): + mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") + body = mass[mass.index("function drawWindow(") :] + body = body[: body.index("\n }\n")] + assert '!open || fitted || overlay.classList.contains("is-popout") || !lastSeries' in body + assert "windowState.frame" in body and "frameOf(overlay" in body + assert "draw(lastParams, true)" in body + # 다시 셈 없음 · 총괄값 · 범례는 계열이 바뀔 때만 + assert re.search(r"reuse && lastSeries\s*\? lastSeries", mass) + assert "if (series !== lastSeries) emitSummary(series);" in mass + # 다시 그려도 제 자리 — 종단 값으로 되쓰는 것은 처음 · 맞춤 뒤 · 창 옮김 뒤만 + assert "scroll.scrollLeft = syncedMain.scrollLeft" not in mass + render = (B05 / "B05_Profile_UI_Profile_Render.ts").read_text(encoding="utf-8") + assert "massHaul.drawWindow({" in render From 0d1d54242106c83661d9f6bfd9ad5d30398fa9e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:42:28 +0900 Subject: [PATCH 12/33] =?UTF-8?q?feat(3D):=20353-3=203D=20=EB=93=B1?= =?UTF-8?q?=EA=B3=A0=EC=84=A0=20=EC=A3=BC=EA=B3=A1=EC=84=A0=205=20m=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=201=20m=20=EC=98=85=EA=B2=8C=20?= =?UTF-8?q?=E2=80=94=20=ED=8F=89=EB=A9=B4=20=EC=83=89=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=C2=B7=20B05=20=C2=B7=20B04=20=EA=B0=99=EC=9D=80=20=EC=9E=AC?= =?UTF-8?q?=EC=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 공용 재질 ui_template_contour3d(isMajorContour · contourMaterials) — 주곡선 = 5 m 배수(간격 무관) - 색 = --map-contour-major(진하게 0.95) · --map-contour-minor(토큰 알파 0.45 · 깊이 안 씀) - B05 3D 뷰어 · B04 3D 지형 뷰어 둘 다 옛 주황 두 색 · 간격 × 5 갈래 걷음 · B04 높이 글도 5 m 선에만 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- ...04_PreProcess_UI_TerrainViewer_Contours.ts | 18 +++---- B05_Profile/B05_Profile_UI_Viewer.ts | 16 +++--- .../tester/test_353_3_contour3d_style.py | 30 ++++++++++++ ui_template/ui_template_contour3d.ts | 49 +++++++++++++++++++ 4 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_353_3_contour3d_style.py create mode 100644 ui_template/ui_template_contour3d.ts diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts index febb8b805..32c90eea2 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts @@ -10,6 +10,7 @@ import * as THREE from "three"; import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d"; /** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */ const MAX_CONTOUR_LABELS = 40; @@ -93,7 +94,7 @@ export async function loadContourLinesInto( const points = transform(c.coordinates); if (points.length < 2) return; - const isMajor = c.level % (interval * 5) === 0; + const isMajor = isMajorContour(c.level); const bucket = isMajor ? majorPoints : minorPoints; for (let i = 0; i < points.length - 1; i++) { bucket.push(points[i], points[i + 1]); @@ -156,18 +157,15 @@ export async function loadContourLinesInto( ctx.setLabelsDirty(true); } - // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다 — 주곡선 = 5 m 고정 · 1 m 는 + // 옅게(평면 그림과 같은 색 토큰 · 353-3). + const materials = contourMaterials(); [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { + { points: minorPoints, material: materials.minor }, + { points: majorPoints, material: materials.major }, + ].forEach(({ points, material }) => { if (points.length === 0) return; const geometry = new THREE.BufferGeometry().setFromPoints(points); - const material = new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity: 0.8, - }); ctx.contourGroup.add(new THREE.LineSegments(geometry, material)); }); diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 619242cc5..00eb84fa7 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { createTerrainCompass } from "@ui/ui_template_compass"; +import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; @@ -339,21 +340,20 @@ export function createRouteViewer(): RouteViewer { ([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)), ); if (points.length < 2) return; - const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints; + const bucket = isMajorContour(contour.level) ? majorPoints : minorPoints; for (let index = 0; index < points.length - 1; index += 1) { bucket.push(points[index], points[index + 1]); } }); + // 주곡선 = 5 m 고정 · 1 m 는 옅게(평면 그림과 같은 색 토큰 · 353-3) + const materials = contourMaterials(); [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { + { points: minorPoints, material: materials.minor }, + { points: majorPoints, material: materials.major }, + ].forEach(({ points, material }) => { if (points.length === 0) return; contours.add( - new THREE.LineSegments( - new THREE.BufferGeometry().setFromPoints(points), - new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }), - ), + new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material), ); }); } diff --git a/resources/tester/test_353_3_contour3d_style.py b/resources/tester/test_353_3_contour3d_style.py new file mode 100644 index 000000000..6164ac994 --- /dev/null +++ b/resources/tester/test_353_3_contour3d_style.py @@ -0,0 +1,30 @@ +"""353-3 3D 등고선 — 주곡선 5 m 고정 · 1 m 옅게 · 평면 색 토큰(B05 · B04 3D 같은 재질). + +./venv/Scripts/python.exe -m pytest resources/tester/test_353_3_contour3d_style.py -q +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +STYLE = ROOT / "ui_template" / "ui_template_contour3d.ts" +VIEWERS = [ + ROOT / "B05_Profile" / "B05_Profile_UI_Viewer.ts", + ROOT / "B04_PreProcess" / "B04_PreProcess_UI_TerrainViewer_Contours.ts", +] + + +def test_재질은_평면_색_토큰과_5m_고정() -> None: + src = STYLE.read_text("utf-8") + assert '"--map-contour-major"' in src and '"--map-contour-minor"' in src + assert "CONTOUR_MAJOR_STEP_M = 5;" in src + # 1 m 선 불투명 = 토큰 알파(0.45) · 주곡선은 진하게 + assert "opacity: minor.alpha" in src and "opacity: 0.95 * major.alpha" in src + + +def test_두_3D_뷰어가_같은_재질을_씀() -> None: + for path in VIEWERS: + src = path.read_text("utf-8") + assert 'from "@ui/ui_template_contour3d"' in src, path.name + assert "isMajorContour(" in src and "contourMaterials()" in src, path.name + # 옛 갈래(간격 × 5 · 주황 두 색) 없음 + assert "interval * 5" not in src and "0xf59e0b" not in src, path.name diff --git a/ui_template/ui_template_contour3d.ts b/ui_template/ui_template_contour3d.ts new file mode 100644 index 000000000..d8411d452 --- /dev/null +++ b/ui_template/ui_template_contour3d.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * ui_template_contour3d.ts + * 3D 등고선 선 재질 — 평면 그림(노선 편집)과 같은 갈래 · 같은 색 토큰(353-3). + * + * 주곡선 = 5 m 배수 고정(간격 무관 · 평면 `B05_Profile_UI_RouteEdit_Render` 와 같음) · + * 색 `--map-contour-major` 진하게 · 나머지(1 m) = `--map-contour-minor` 옅게 · 낮은 불투명. + * B05 3D(`B05_Profile_UI_Viewer`) · B04 3D(`B04_PreProcess_UI_TerrainViewer_Contours`) 가 함께 씀. + * ========================================================================== */ + +import * as THREE from "three"; +import { themeColor } from "./ui_template_palette"; + +/** 주곡선 간격(m) — 평면 그림과 같은 5 m. */ +export const CONTOUR_MAJOR_STEP_M = 5; + +/** 5 m 배수 높이면 주곡선. */ +export function isMajorContour(level: number): boolean { + return Math.abs(level - Math.round(level / CONTOUR_MAJOR_STEP_M) * CONTOUR_MAJOR_STEP_M) < 1e-6; +} + +/** CSS 색(#rrggbb · rgba(...)) → 색 + 불투명(알파 없으면 1). */ +function parseColor(value: string): { color: THREE.Color; alpha: number } { + const rgba = value.match(/rgba?\(([^)]+)\)/); + if (!rgba) return { color: new THREE.Color(value), alpha: 1 }; + const [r, g, b, a] = rgba[1].split(",").map((part) => Number(part.trim())); + return { color: new THREE.Color(r / 255, g / 255, b / 255), alpha: Number.isFinite(a) ? a : 1 }; +} + +/** 주곡선 · 1 m 선 재질 — 1 m 는 주곡선보다 옅게. */ +export function contourMaterials(): { + major: THREE.LineBasicMaterial; + minor: THREE.LineBasicMaterial; +} { + const major = parseColor(themeColor("--map-contour-major", "#818cf8")); + const minor = parseColor(themeColor("--map-contour-minor", "rgba(165, 180, 252, 0.45)")); + return { + major: new THREE.LineBasicMaterial({ + color: major.color, + transparent: true, + opacity: 0.95 * major.alpha, + }), + minor: new THREE.LineBasicMaterial({ + color: minor.color, + transparent: true, + opacity: minor.alpha, + depthWrite: false, + }), + }; +} From e72164d422681d636834110e68f54b80e69b545b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:44:51 +0900 Subject: [PATCH 13/33] =?UTF-8?q?fix(=EC=84=A4=EC=A0=95):=20357=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95=ED=82=A4=20=EC=B9=A9=20=EA=B8=80=EA=BC=B4=20?= =?UTF-8?q?=C2=B7=20=EC=B9=A9=EB=A7=8C=20=EC=9E=88=EB=8A=94=20=EC=A4=84=20?= =?UTF-8?q?=EA=B0=80=EB=A1=9C=20=ED=8E=BC=EC=B9=A8=20=C2=B7=20PLAN=20357?= =?UTF-8?q?=20=EC=B2=B4=ED=81=AC?= 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_013QyNXY2TUKXCFMwAca2APG --- A00_Common/app_settings.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/A00_Common/app_settings.css b/A00_Common/app_settings.css index b2841ec1a..c7d32a4d9 100644 --- a/A00_Common/app_settings.css +++ b/A00_Common/app_settings.css @@ -74,13 +74,17 @@ gap: var(--spacing-4); } +.app-settings__keys:only-child { + grid-column: 1 / -1; +} + .app-settings__keys > kbd { padding: 0 var(--spacing-8); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface-raised); color: var(--color-text); - font-family: var(--font-mono); + font-family: var(--font-body); font-size: 0.85em; line-height: 1.7; } From 25830f4a54524f0b67d894e93c9de62c851eb1be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:45:06 +0900 Subject: [PATCH 14/33] =?UTF-8?q?feat(=EA=B3=B5=ED=86=B5):=20352-4=20?= =?UTF-8?q?=E2=98=B0=20=EB=A5=BC=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EC=A0=9C=EB=AA=A9=20=EC=95=9E=20=EC=B9=B8=20=EB=AA=A8?= =?UTF-8?q?=EC=96=91=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EC=84=9C=EB=9E=8D=20?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=20=EC=9A=B0=EC=B8=A1=20=C2=B7=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EC=9A=B0?= =?UTF-8?q?=EC=B8=A1=20=EB=81=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 패널 펼침 동안 ☰ 테두리 · 바탕 · 그림자 없음(자리 8,8 · 36 그대로 · 레이아웃 불변) · 접으면 떠 있는 단추 - 서랍 첫 줄 AISLO 로고 · 이름 우측 맞춤 · 패널 제목 줄 프로젝트 이름 margin-left auto - 시험 test_device_drawer 글 시험 추가 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/app_shell.ts | 12 +++++++++++- resources/tester/test_device_drawer.py | 21 +++++++++++++++++++++ ui_template/ui_template_overlay.css | 1 + 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 0d95b131d..5ca816bc8 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -525,6 +525,16 @@ html.is-bm-route .app-burger { box-shadow: var(--shadow-lg); } html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; } +/* 좌측 패널이 펼쳐진 동안 ☰ 는 패널 제목 줄 앞 칸에 든 모양(테두리 · 바탕 · 그림자 없음 · 자리 그대로) — + 패널을 접으면 떠 있는 단추(위 규칙)로 돌아감(352-4) */ +html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger { + border-color: transparent; + background: transparent; + box-shadow: none; +} +html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger:hover { + background: color-mix(in srgb, var(--color-text) 10%, transparent); +} html.is-bm-route .app-header.is-drawer-open .app-drawer { left: 0; right: auto; @@ -535,7 +545,7 @@ html.is-bm-route .app-drawer__brand { min-height: 44px; padding-left: calc(var(--app-burger-inset) - var(--spacing-16)); } -html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; } +html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-end; } `; export function injectShellStyles(): void { diff --git a/resources/tester/test_device_drawer.py b/resources/tester/test_device_drawer.py index e7f454e3a..c791d30d0 100644 --- a/resources/tester/test_device_drawer.py +++ b/resources/tester/test_device_drawer.py @@ -6,6 +6,8 @@ ③ 휴대폰 · 공개 쪽(A01) = 위 줄 그대로(본문 위 끝 64) · ☰ 는 위 줄 오른쪽 ④ 데스크탑(1440 · 마우스) · 로그인 뒤 B 쪽 = 위 줄 걷힘 · ☰ 왼쪽 위 36×36 · 진행 판 없음 · 위 줄 항목이 모두 서랍에 · Esc 로 닫힘 ⑤ 데스크탑 · 공개 쪽(A01) = 위 줄 그대로 · ☰ 없음 +⑥ (352-4) 좌측 패널이 펼쳐진 동안 ☰ 는 제목 줄 앞 칸에 든 모양(테두리 · 바탕 · 그림자 없음 · 자리 8,8 그대로) + · 접으면 떠 있는 단추 · 서랍 첫 줄 로고 우측 · 프로젝트 이름 우측 끝 — 글로 봄(서버 없이) """ import json @@ -131,3 +133,22 @@ def test_drawer_holds_steps_and_header_folds(): assert desk.evaluate(OUTLET_TOP) == 64 assert not desk.evaluate("document.querySelector('.app-burger').getClientRects().length") browser.close() + + +def test_352_4_burger_in_panel_title_row_text(): + root = Path(__file__).resolve().parents[2] + shell = (root / "A00_Common/app_shell.ts").read_text(encoding="utf-8") + open_panel = ( + "html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger {" + ) + assert open_panel in shell # 패널 펼침 = 제목 줄에 든 모양 + body = shell.split(open_panel, 1)[1].split("}", 1)[0] + for line in ("border-color: transparent;", "background: transparent;", "box-shadow: none;"): + assert line in body + # 자리(8,8 · 36)는 그대로 — 접으면 아래 기본 규칙(떠 있는 단추)으로 돌아감 + base = shell.split("html.is-bm-route .app-burger {", 1)[1].split("}", 1)[0] + assert "position: fixed;" in base and "left: var(--spacing-8);" in base + assert "html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-end; }" in shell + overlay = (root / "ui_template/ui_template_overlay.css").read_text(encoding="utf-8") + project = overlay.split(".ui-workflow-overlay__project {", 1)[1].split("}", 1)[0] + assert "margin-left: auto;" in project diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index 7565877b4..2d672e9bb 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -131,6 +131,7 @@ .ui-workflow-overlay__project { flex: 0 1 auto; + margin-left: auto; max-width: 60%; min-width: 0; overflow: hidden; From d6ceb51feb7934fa11c122cc76ce51dfa388f0f5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:46:15 +0900 Subject: [PATCH 15/33] =?UTF-8?q?=EF=BB=BFfeat(B05):=20352-5=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EB=B2=84=ED=8A=BC=20=EC=9D=B4=EB=A6=84=20=EB=AA=85?= =?UTF-8?q?=EC=82=AC=ED=98=95=20=E2=80=94=20=EA=B3=A1=EC=84=A0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=C2=B7=20=EA=B3=A1=EC=84=A0=20=EC=B6=94=EA=B0=80?= 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_01VmC34fsxxbweEG3RUjs3Mn --- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 4 ++-- resources/tester/test_b05_routeedit_chrome_modal.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index b4bfdb3ca..de2c38274 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -197,7 +197,7 @@ export function createCurveLabel( lockApex.dataset.act = "lock-apex"; root.querySelector('[data-slot="lock-apex"]')!.replaceWith(lockApex); - /** 끝점에서는 숨길 곡선 칸 — 곡선 지우기 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */ + /** 끝점에서는 숨길 곡선 칸 — 곡선 삭제 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */ const curveOnly = [toggle, radiusField.wrapper, arcField.wrapper, info]; // 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. @@ -327,7 +327,7 @@ export function createCurveLabel( lockApex.title = end ? `${name} 고정 — 켜면 끌어 옮길 수 없습니다. 풀면 끌어 옮깁니다` : "교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다"; - toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; + toggle.textContent = state.curveOn ? "곡선 삭제" : "곡선 추가"; radius.disabled = !state.curveOn; arc.disabled = !state.curveOn; lockRadius.disabled = !state.curveOn; diff --git a/resources/tester/test_b05_routeedit_chrome_modal.py b/resources/tester/test_b05_routeedit_chrome_modal.py index 3f16fd9ef..2dfc14a68 100644 --- a/resources/tester/test_b05_routeedit_chrome_modal.py +++ b/resources/tester/test_b05_routeedit_chrome_modal.py @@ -106,6 +106,7 @@ def test_페이지_수명_닫기_없음(): assert len(main.splitlines()) <= 700 label = _read("B05_Profile_UI_RouteEdit_Label.ts") assert "document.body.append" not in label and "container.append(root);" in label + assert '"곡선 삭제" : "곡선 추가"' in label # 352-5 — 버튼 이름은 명사형 assert "container: overlay," in main apply = _read("B05_Profile_UI_RouteEdit_Apply.ts") assert "close" not in apply.split("export function bindRouteApply", 1)[1] From bf3aaaa41fb7a35d60e101e21889082f73e7ea93 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:50:03 +0900 Subject: [PATCH 16/33] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8=EB=8F=84):=20354-?= =?UTF-8?q?1=20=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20=EC=9E=90=EA=B8=B0?= =?UTF-8?q?=20=ED=8C=90=20=E2=80=94=20=EC=A2=85=EB=8B=A8=20=EC=97=B4=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=20=EC=B9=B8=20=C2=B7=20=EC=9E=90=EA=B8=B0=20?= =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A0=80=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=C2=B7=20=EC=A0=91=EC=9C=BC=EB=A9=B4=20=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=EC=A4=84([=EC=B0=BD=20=EB=B6=84=ED=95=A0]=20=C2=B7=20=EC=86=90?= =?UTF-8?q?=EC=9E=A1=EC=9D=B4)=20=C2=B7=20=EB=86=92=EC=9D=B4=20=EB=82=98?= =?UTF-8?q?=EB=88=94=20=C2=B7=20=ED=91=9C=20=EC=8C=93=EA=B8=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B9=A0=EC=A7=90=20=C2=B7=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?[=EC=B0=BD=20=EB=B6=84=ED=95=A0]=EC=97=90=EB=8F=84=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=C2=B7=20B05=20=C2=B7=20B06=20=EA=B3=B5=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/design/design_page.css | 10 +-- A00_Common/design/design_profile.ts | 11 ++- A00_Common/design/design_profile_overlays.ts | 18 ++-- A00_Common/design/design_profile_popout.ts | 19 ++--- B05_Profile/B05_Profile_UI_Profile_Drag.ts | 31 +++---- B05_Profile/B05_Profile_UI_Profile_Heights.ts | 59 ++++--------- .../B05_Profile_UI_Profile_MassHaul.ts | 76 ++++++----------- .../B05_Profile_UI_Profile_TableOverlay.ts | 19 +---- B05_Profile/B05_Profile_UI_Style_MassHaul.css | 74 ++++++++++++---- .../tester/helper_224_1b_masshaul_docked.cjs | 35 ++++---- .../tester/helper_224_1c_subpanel_popout.cjs | 31 ++++--- .../tester/test_224_1b_masshaul_docked.py | 32 +++---- .../tester/test_224_1c_subpanel_popout.py | 16 ++-- resources/tester/test_230_2_popout_frames.py | 3 +- .../tester/test_354_1_masshaul_own_panel.py | 85 +++++++++++++++++++ 15 files changed, 279 insertions(+), 240 deletions(-) create mode 100644 resources/tester/test_354_1_masshaul_own_panel.py diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index d4b2ff545..7813704b5 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -55,20 +55,13 @@ font-size: var(--btn-bar-font-size); } -/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */ +/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 두 판 머리줄 맨 앞. */ .b05-subpanel__popout { flex: none; padding: var(--btn-bar-padding); font-size: var(--btn-bar-font-size); } -.b05-profile__masshaul-overlay > .b05-subpanel__popout { - position: absolute; - top: 4px; - left: var(--spacing-8); - z-index: 4; -} - /* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */ .b05-profile__masshaul-overlay.is-popout, .b05-drainage.is-popout { @@ -79,6 +72,7 @@ } .b05-profile__masshaul-overlay.is-popout > .ui-resizer, +.b05-profile__masshaul-overlay.is-popout .b05-profile__masshaul-handle, .b05-drainage.is-popout > .ui-resizer, .b05-drainage.is-popout > .ui-workflow-overlay__handle { display: none; diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 18ac1dcf6..faa2750cb 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -145,8 +145,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도 // 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고). bodyWrap.append(balanceBar, body, progress.root); - // 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정). - // 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다. + // 유토곡선 — 종단 칸 아래 자기 판(354-1) · 테이블 — 종단 칸 바닥 겹침 판. + // 둘 다 위 경계 리사이저로 높이를 조절한다. // 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고 // (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은 // 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw. @@ -155,7 +155,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { else draw(); }; // 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계). - const { massHaul, tableOverlay } = createProfileOverlays({ + const { massHaul, tableOverlay, column } = createProfileOverlays({ projectId, routeId: () => routeId, bodyWrap, @@ -192,7 +192,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { callbacks?.onStructureTypeAdd?.(chainage, typeId), ), }) ?? null; - content.append(bodyWrap); + content.append(column); if (drainagePanel) content.append(drainagePanel.root); const drag = createProfileDrag({ root, @@ -385,7 +385,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 - * 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면 + * 줄고 → ② 테이블이 최소까지 줄고(유토곡선은 자기 판 · 354-1) → ③ 둘 다 최소면 * 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기 * 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리). * @@ -395,7 +395,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { const heights = createHeightCascade({ root, body, - massHaul, tableOverlay, isMainDragging: drag.isMainDragging, isSubDragging: drag.isSubDragging, diff --git a/A00_Common/design/design_profile_overlays.ts b/A00_Common/design/design_profile_overlays.ts index b4c2d503a..5926afe74 100644 --- a/A00_Common/design/design_profile_overlays.ts +++ b/A00_Common/design/design_profile_overlays.ts @@ -1,7 +1,8 @@ /* ============================================================================= * design_profile_overlays.ts - * 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌). - * 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. + * 종단도 겹침 판(테이블) · 종단 열 아래 유토곡선 판 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌). + * 만들어 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. + * 종단 열(`column`) = [종단 칸(`bodyWrap`), 유토곡선 판](354-1) — 종단도가 content 왼쪽에 붙인다. * ========================================================================== */ import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge"; @@ -16,7 +17,7 @@ import { export interface ProfileOverlaysDeps { projectId: string; routeId: () => number | null; - /** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */ + /** 종단 칸(요약 줄 · 본문 · 서클을 담은 칸) — 표 겹침 판이 그 위를 덮는다. */ bodyWrap: HTMLElement; /** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */ body: HTMLElement; @@ -54,10 +55,13 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { }, haulPrefetch, ); - bodyWrap.append(massHaul.overlay, massHaul.handle); - // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. + // 유토곡선 = 종단 칸 아래 자기 판(354-1 · 배수유역도처럼) — 종단 [창 분할]에도 함께 감(content 안). + const column = document.createElement("div"); + column.className = "b05-route-profile__column"; + column.append(bodyWrap, massHaul.overlay); + // 판의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. massHaul.attachScrollSync(body); - // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). + // 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시). const tableOverlay = createProfileTableOverlay(onChange); bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); tableOverlay.attachScrollSync(body); @@ -65,5 +69,5 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. body.addEventListener("contextmenu", (event) => event.preventDefault()); massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); - return { massHaul, tableOverlay }; + return { massHaul, tableOverlay, column }; } diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index 304bdb07f..311681235 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -131,7 +131,7 @@ export function attachProfilePopout( }); } -/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */ +/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 두 판 머리줄 맨 앞(계획서 325-3 · 354-1). */ export function attachSubPanelPopouts(profile: DesignProfile): void { const subButton = (title: string): HTMLButtonElement => { const button = document.createElement("button"); @@ -141,23 +141,20 @@ export function attachSubPanelPopouts(profile: DesignProfile): void { }; const massOverlay = profile.root.querySelector(".b05-profile__masshaul-overlay"); - const massHandle = profile.root.querySelector(".b05-profile__masshaul-handle"); - if (massOverlay && massHandle) { + const massHead = massOverlay?.querySelector(".b05-profile__masshaul-head"); + if (massOverlay && massHead) { const button = subButton("유토곡선 창 분할"); - massOverlay.append(button); + // 판 머리줄 맨 앞 — 접어도 남아 닿는다(354-1). + massHead.prepend(button); + const column = massOverlay.parentElement; attachPopout(profile, { piece: massOverlay, button, name: "aislo-masshaul-popout", features: "popup,width=1400,height=420", docTitle: "유토곡선 — Aislo", - // 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판). - home: () => massHandle.before(massOverlay), - onMove: (popped) => { - // 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀). - massHandle.hidden = popped; - massOverlay.style.height = ""; - }, + // 제자리 = 종단 열 끝(종단 칸 아래 자기 판). + home: () => column?.append(massOverlay), }); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index 4bb99cbf0..bb6fb193b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -10,12 +10,11 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights"; -import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { viewOf } from "../common_util/common_util_view_window"; const MIN_CHART_HEIGHT = 100; -/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. +/** 접힌 테이블 손잡이가 앉을 본문 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. * 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다 * (2026-08-17 사용자 지시 2). */ const MASSHAUL_HANDLE_GUTTER_PX = 13; @@ -93,7 +92,7 @@ export function createElevationHold( }; } -/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */ +/** 겹침 판(표) 중 끌기가 보는 것만. */ interface SubPanel { overlay: HTMLElement; commitHeight: (height: number) => void; @@ -102,7 +101,8 @@ interface SubPanel { export interface ProfileDragContext { root: HTMLElement; body: HTMLElement; - massHaul: SubPanel; + /** 유토곡선 판 — 그 리사이저 끌기 출처만 본다(자기 판이라 높이 나눔에 안 낌 · 354-1). */ + massHaul: { overlay: HTMLElement }; tableOverlay: SubPanel & { isOpen: () => boolean }; /** 손을 뗀 뒤 한 번 다시 세운다. */ draw: () => void; @@ -112,7 +112,7 @@ export interface ProfileDrag { resizerRoot: HTMLElement; isMainDragging: () => boolean; isSubDragging: () => boolean; - mainDragRef: () => { chart: number; mass: number; table: number } | null; + mainDragRef: () => { chart: number; table: number } | null; isMainDragCooldown: () => boolean; clearMainDragCooldown: () => void; /** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */ @@ -133,23 +133,21 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. - // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). - const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay); const heightResizer = createPanelResizer({ axis: "vertical", target: root, cssVar: "--b05-profile-height", direction: -1, - // 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정). + // 동적 하한: 종단 최소 + 열린 표 최소 높이(2026-08-05 사용자 확정) — 유토곡선 판 높이는 + // chrome(뿌리 − 본문)에 이미 들어 있다(354-1). // 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) → // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). min: () => { const chrome = Math.max(0, root.offsetHeight - body.clientHeight); - const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; return Math.max( MIN_PANEL_HEIGHT, - chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin, + chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + tableMin, ); }, // 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식 @@ -166,19 +164,14 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { * 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해 * 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */ let mainDragCooldown = false; - /** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준. + /** 메인 드래그 시작 시점의 두 영역(종단·테이블) 실제 높이 — 비례 연동 기준. * 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */ - let mainDragRef: { chart: number; mass: number; table: number } | null = null; + let mainDragRef: { chart: number; table: number } | null = null; heightResizer.root.addEventListener("pointerdown", () => { mainPanelDragging = true; - const massH = massDocked() ? massHaul.overlay.offsetHeight : 0; const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; mainDragRef = { - chart: Math.max( - MIN_CHART_HEIGHT, - body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX, - ), - mass: massH, + chart: Math.max(MIN_CHART_HEIGHT, body.clientHeight - tableH - MASSHAUL_HANDLE_GUTTER_PX), table: tableH, }; }); @@ -193,9 +186,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). - if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight); if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); - massHaul.overlay.style.height = ""; tableOverlay.overlay.style.height = ""; } mainDragRef = null; diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index fe6035427..3e299ea31 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile_UI_Profile_Heights.ts - * 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙. + * 종단 패널의 높이 배분 — 그래프·테이블이 한 본문 높이를 나눠 갖는 규칙. + * 유토곡선은 종단 열 아래 자기 판이라 본문 높이에서 이미 빠져 있다 — 나눔에 안 낀다(354-1). * * 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다. * 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면 @@ -8,14 +9,13 @@ * 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다. * ========================================================================== */ -import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; import { frameOf } from "../common_util/common_util_view_window"; /** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */ const MIN_CHART_HEIGHT = 100; -/** 접힌 유토곡선 손잡이가 차지하는 바닥 여백(px). */ +/** 접힌 테이블 손잡이가 차지하는 바닥 여백(px). */ const MASSHAUL_HANDLE_GUTTER_PX = 13; /** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */ const MAX_PANEL_HEIGHT_RATIO = 0.9; @@ -32,21 +32,15 @@ export interface HeightCascadeDeps { root: HTMLElement; /** 본문(그래프+서브패널이 들어가는 스크롤 영역). */ body: HTMLElement; - massHaul: { - overlay: HTMLElement; - desiredHeight: () => number; - syncHandle: () => void; - }; tableOverlay: { overlay: HTMLElement; isOpen: () => boolean; desiredHeight: () => number; - setBottomOffset: (value: number) => void; }; isMainDragging: () => boolean; isSubDragging: () => boolean; - /** 메인 드래그 시작 시점의 3영역 비율(없으면 null). */ - mainDragRef: () => { chart: number; mass: number; table: number } | null; + /** 메인 드래그 시작 시점의 두 영역 비율(없으면 null). */ + mainDragRef: () => { chart: number; table: number } | null; /** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */ isMainDragCooldown: () => boolean; /** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */ @@ -67,21 +61,17 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); - // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). - const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay); const tableOpen = deps.tableOverlay.isOpen(); // 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면 // 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이 // 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을 // 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면 // 몇 번을 호출해도 같은 답이 나온다(멱등). - const massDesired = massOpen ? deps.massHaul.desiredHeight() : 0; const tableDesired = tableOpen ? deps.tableOverlay.desiredHeight() : 0; - let massHeight = massDesired; let tableOverlayHeight = tableDesired; const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX); - // ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ───────────── - // 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·유토곡선·테이블을 같이 늘리고 + // ── 메인 드래그 중: 비례 연동(2026-08-06 사용자 확정) ───────────── + // 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·테이블을 같이 늘리고 // 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다. // 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다. const dragRef = deps.mainDragRef(); @@ -89,15 +79,12 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX); const items = [ { key: "chart", ref: Math.max(1, dragRef.chart), min: MIN_CHART_HEIGHT }, - ...(massOpen - ? [{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }] - : []), ...(tableOpen ? [{ key: "table", ref: Math.max(1, dragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }] : []), ]; // 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은 - // 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다. + // 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 2개라 최대 2회에 끝난다. const out: Record = {}; let pool = content; let active = items; @@ -116,50 +103,34 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { active = active.filter((item) => !pinned.includes(item)); } // 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수. - massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0; tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0; - if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`; if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`; - // 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다. - deps.massHaul.syncHandle(); - deps.tableOverlay.setBottomOffset(massHeight); - return { - chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight), - }; + return { chartHeight: Math.max(MIN_CHART_HEIGHT, content - tableOverlayHeight) }; } if (deps.isSubDragging()) { // 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를 // 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로 // 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다. - deps.massHaul.overlay.style.height = ""; deps.tableOverlay.overlay.style.height = ""; - } else if (massDesired + tableDesired > budget) { - // ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한. + } else if (tableDesired > budget) { + // ② 줄이기 — 줄일 수 있는 여유분만큼 축소, 최소 높이 하한. // 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다. - const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0; - const shrinkable = massDesired - massMin + (tableDesired - tableMin); - const over = massDesired + tableDesired - budget; + const shrinkable = tableDesired - tableMin; + const over = tableDesired - budget; const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1; - massHeight = Math.round(massDesired - (massDesired - massMin) * ratio); tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio); // 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아 // 패널을 다시 키우면 원래 높이로 돌아온다. - if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`; if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`; } else { // 공간 충분 — 임시 축소 해제(저장 높이로 복귀). - deps.massHaul.overlay.style.height = ""; deps.tableOverlay.overlay.style.height = ""; } - // 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는 - // 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition). - deps.tableOverlay.setBottomOffset(massHeight); // ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우) // 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대 // 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다. - const deficit = - MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available; + const deficit = MIN_CHART_HEIGHT + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available; if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) { // 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은 // 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은 @@ -174,7 +145,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { return { chartHeight: Math.max( MIN_CHART_HEIGHT, - available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX, + available - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX, ), }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 9d3af662c..201cfa586 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B05_Profile_UI_Profile_MassHaul.ts - * B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**. + * B05 유토곡선 — 종단 열 아래 **자기 판**(354-1 · 배수유역도처럼 손잡이 · 높이 · 접기 따로). * * ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ────────────── * 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세 @@ -14,12 +14,11 @@ * 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을 * 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다. * - * ── 배치 (2026-08-04 사용자 확정: 오버레이 전환) ───────────────────── - * 예전에는 펼치면 종단 그래프와 세로 1:1 분할 + 도면 테이블 숨김이었다. 이제는 종단도· - * 테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는 - * 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고 - * 세션 동안 유지한다. 곡선 SVG는 오버레이 안 자체 가로 스크롤러에 들어가며, 종단 - * 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다. + * ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ───────────────────── + * 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도 + * 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다. + * 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에 + * 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다. * ========================================================================== */ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; @@ -65,12 +64,10 @@ import { frameOf } from "../common_util/common_util_view_window"; const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? ""; /** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */ const OVERLAY_MIN_HEIGHT = 160; -/** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */ -export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT; const OVERLAY_DEFAULT_HEIGHT = 280; /** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */ const MASS_FIT_PAD_RIGHT = 12; -/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */ +/** 판이 종단 열을 다 차지하지 않게 남기는 상한 비율. */ const OVERLAY_MAX_RATIO = 0.8; /** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */ const HEIGHT_VAR = "--b05-masshaul-height"; @@ -147,23 +144,15 @@ export interface RouteMassHaulDrawParams { } export interface RouteMassHaulPanel { - /** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */ + /** 판 머리줄의 접기 손잡이(펼치기/접기) — 판 안에 있다. */ handle: HTMLElement; - /** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */ + /** 유토곡선 판 — 호출한 쪽이 종단 열 아래(`b05-route-profile__column` 끝)에 붙인다. */ overlay: HTMLElement; isOpen(): boolean; - /** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다. - * Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */ - desiredHeight(): number; - /** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이 - * 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */ - commitHeight(px: number): void; - /** 손잡이를 오버레이 위 경계로 다시 맞춘다 — 캐스케이드가 인라인 높이를 바꾼 직후 호출. */ - syncHandle(): void; setContext(next: RouteMassHaulContext | null): void; /** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */ attachScrollSync(main: HTMLElement): void; - /** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */ + /** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */ draw(params: RouteMassHaulDrawParams): void; /** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */ drawWindow(params: RouteMassHaulDrawParams): void; @@ -210,8 +199,12 @@ export function createRouteMassHaulPanel( handle.className = "b05-profile__masshaul-handle"; handle.append(handleControl.root); handleControl.root.setAttribute("aria-label", "유토곡선 패널"); + // 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1). + const head = document.createElement("div"); + head.className = "b05-profile__masshaul-head"; + head.append(handle); - // 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례. + // 판 뼈대 — 위 경계 리사이저 + 머리줄 + 안내 문구 자리 + 가로 스크롤러 + 범례. // (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.) const overlay = document.createElement("div"); overlay.className = "b05-profile__masshaul-overlay"; @@ -238,8 +231,7 @@ export function createRouteMassHaulPanel( max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO, storageKey: OVERLAY_HEIGHT_KEY, onResize: () => { - // 손잡이는 경계를 즉시 따라가고(가벼움), 전체 redraw는 프레임당 한 번만. - syncHandlePosition(); + // 전체 redraw는 프레임당 한 번만. if (resizeRedrawPending) return; resizeRedrawPending = true; frameOf(overlay, () => { @@ -254,20 +246,14 @@ export function createRouteMassHaulPanel( pendingChip.className = "b05-profile__masshaul-pending"; pendingChip.textContent = "다시 계산 중…"; pendingChip.hidden = true; - overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); + overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip); // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. attachWheelHorizontalScroll(scroll); - /** - * 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와 - * 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널 - * 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정). - */ - function syncHandlePosition(): void { - handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px"; - } + /** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */ + const shown = (): boolean => open || overlay.classList.contains("is-popout"); function applyOpen(next: boolean): void { open = next; @@ -276,14 +262,14 @@ export function createRouteMassHaulPanel( // 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다. handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", next); - overlay.hidden = !next; - syncHandlePosition(); + // 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음) + overlay.classList.toggle("is-collapsed", !next); onChanged(); } handleControl.setOpen(open); handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", open); - overlay.hidden = !open; + overlay.classList.toggle("is-collapsed", !open); handleControl.root.addEventListener("click", () => applyOpen(!open)); function toggleSeries(key: string): void { @@ -384,11 +370,10 @@ export function createRouteMassHaulPanel( function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void { lastParams = params; windowDirty = false; - overlay.hidden = !open; - syncHandlePosition(); + overlay.classList.toggle("is-collapsed", !open); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. // 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번). - if (!open) { + if (!shown()) { syncedIn = null; reportSummary(params); return; @@ -542,18 +527,7 @@ export function createRouteMassHaulPanel( return { handle, overlay, - isOpen: () => open, - desiredHeight() { - const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR)); - return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT; - }, - commitHeight(px) { - const next = Math.round(px); - overlay.style.setProperty(HEIGHT_VAR, `${next}px`); - writeStateRaw("masshaul-height", String(next)); - syncHandlePosition(); - }, - syncHandle: () => syncHandlePosition(), + isOpen: shown, setContext(next) { context = next; }, diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index 7c2d15b89..0ae4b90c5 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -4,10 +4,8 @@ * 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시). * * - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존. - * - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가 - * 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다. - * - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서 - * 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. + * - 종단 칸 바닥에 붙는다 — 유토곡선은 그 아래 자기 판이라 쌓지 않는다(354-1). + * - 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. * ========================================================================== */ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; @@ -54,8 +52,6 @@ export interface RouteProfileTableOverlay { setTable: (table: HTMLElement | null) => void; /** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */ attachScrollSync: (main: HTMLElement) => void; - /** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */ - setBottomOffset: (px: number) => void; } export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay { @@ -73,7 +69,6 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa // 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다 // (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인). let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지) - let bottomOffset = 0; let resizeRedrawPending = false; const resizer = createPanelResizer({ axis: "vertical", @@ -102,11 +97,9 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa // 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다). attachWheelHorizontalScroll(scroll); - /** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다 - * — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */ + /** 손잡이는 오버레이 위 경계를, 접히면 종단 칸 바닥을 따라간다(2026-08-05 사용자 지시). */ function syncHandlePosition(): void { - handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`; - overlay.style.bottom = `${bottomOffset}px`; + handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px"; } function applyOpen(next: boolean): void { @@ -154,9 +147,5 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa syncedMain = main; linkScrollLeft(main, scroll); }, - setBottomOffset(px) { - bottomOffset = px; - syncHandlePosition(); - }, }; } diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index 02b120e32..ec55e8f1b 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -43,26 +43,68 @@ } /* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다. - 오버레이의 절대배치 기준이기도 하다. */ + 테이블 겹침 판의 절대배치 기준이기도 하다. */ .b05-route-profile__body-wrap { position: relative; flex-direction: column; } -/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다 - (2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */ -.b05-profile__masshaul-overlay { - position: absolute; - right: 0; - bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */ - left: 0; - z-index: 5; +/* 종단 열 = 종단 칸 + 그 아래 유토곡선 판(354-1) — 배수유역도는 열 오른쪽. */ +.b05-route-profile__column { display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +/* 유토곡선 판 — 종단 칸 아래 자기 칸(354-1). 종단도 · 테이블을 덮지 않는다. + 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */ +.b05-profile__masshaul-overlay { + position: relative; + display: flex; + flex: none; flex-direction: column; height: var(--b05-masshaul-height, 280px); border-top: 1px solid var(--color-border); background: var(--color-surface-raised); - box-shadow: 0 -4px 12px rgb(0 0 0 / 18%); +} + +/* 머리줄 — 펼침에선 곡선 위 여백(padTop)에 떠 자리를 안 먹고, 접으면 판에 머리줄만 남는다. */ +.b05-profile__masshaul-head { + position: absolute; + top: 4px; + right: 0; + left: var(--spacing-8); + z-index: 4; + display: flex; + align-items: center; + min-height: 24px; + pointer-events: none; +} + +.b05-profile__masshaul-head > * { + pointer-events: auto; +} + +.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) { + height: auto; +} + +.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) { + display: none; +} + +.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head { + position: relative; + top: 0; + left: 0; + padding: 2px var(--spacing-8); +} + +/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */ +.b05-route-profile.is-collapsed .b05-profile__masshaul-overlay { + display: none; } /* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */ @@ -142,13 +184,12 @@ font-size: var(--text-caption); } -/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다 - (2026-08-04 사용자 지시). 패널 본문 바닥에 붙고, 오버레이(z-index 5) 위에 뜬다. - 접혔을 때 테이블 손잡이와 **일렬**로 놓이도록 중앙에서 우측으로 비켜 세운다 - (2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */ +/* 접기 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다 + (2026-08-04 사용자 지시). 판 머리줄에 앉아 접어도 닿는다(354-1). 테이블 손잡이와 + 겹치지 않게 중앙에서 우측으로 비켜 세운다(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */ .b05-profile__masshaul-handle { position: absolute; - bottom: 0; + top: 0; left: calc(50% + 44px); z-index: 6; transform: translateX(-50%); @@ -163,8 +204,7 @@ } /* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ────────────────────── - 유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절). - 위→아래 순서: 종단도 → 테이블 → 유토곡선. */ + 종단 칸 바닥에 붙어 종단도 위를 덮는다. 유토곡선은 그 아래 자기 판(354-1). */ .b05-profile__table-overlay { position: absolute; right: 0; diff --git a/resources/tester/helper_224_1b_masshaul_docked.cjs b/resources/tester/helper_224_1b_masshaul_docked.cjs index 2c07b616a..c63b502e7 100644 --- a/resources/tester/helper_224_1b_masshaul_docked.cjs +++ b/resources/tester/helper_224_1b_masshaul_docked.cjs @@ -1,6 +1,6 @@ -/* 224-1b 유토 판 「붙어 있음」 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정 +/* 224-1b 유토 판 높이 나눔 · 방향키 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정 * (`arrowsBelongToProfile` 본문)을 가짜 DOM 위에서 돌려 JSON 으로 냄. - * · 열림 && 뿌리 안 = 자리 차지 · 열림 && 새 창(뿌리 밖) = 자리 0 · 인라인 높이 안 씀 + * · 유토곡선 = 종단 열 아래 자기 판(354-1) — 본문 높이에서 이미 빠져 나눔에 안 낌(입력에도 없음) * · 방향키 = 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫 */ const fs = require("fs"); const path = require("path"); @@ -44,33 +44,27 @@ new Function( )( heightsExports, (id) => - id.includes("MassHaul") - ? { MASSHAUL_MIN_HEIGHT: 160 } - : id.includes("TableOverlay") - ? { TABLE_OVERLAY_MIN_HEIGHT: 120 } - : id.includes("Structures_Marks") - ? { STRUCTURE_LANE_HEIGHT_PX: 40 } - : { frameOf: () => 0 }, + id.includes("TableOverlay") + ? { TABLE_OVERLAY_MIN_HEIGHT: 120 } + : id.includes("Structures_Marks") + ? { STRUCTURE_LANE_HEIGHT_PX: 40 } + : { frameOf: () => 0 }, { innerHeight: 900 }, ); -function run(docked) { +function run(tableDesired) { const root = el("b05-route-profile"); root.offsetHeight = 500; const body = root.append(el()); body.clientHeight = 400; - const overlay = el("b05-profile__masshaul-overlay"); - if (docked) body.append(overlay); - else el("popup-body").append(overlay); // 새 창 body + const overlay = el("b05-profile__table-overlay"); const cascade = heightsExports.createHeightCascade({ root, body, - massHaul: { overlay, desiredHeight: () => 300, syncHandle() {} }, tableOverlay: { - overlay: el(), - isOpen: () => false, - desiredHeight: () => 0, - setBottomOffset() {}, + overlay, + isOpen: () => tableDesired > 0, + desiredHeight: () => tableDesired, }, isMainDragging: () => false, isSubDragging: () => false, @@ -99,8 +93,9 @@ const card = el("b06-cross-card"); process.stdout.write( JSON.stringify({ - docked: run(true), - popped: run(false), + tableClosed: run(0), + tableFits: run(200), + tableShrinks: run(300), arrows: { profile: arrows(profileChild), mass: arrows(massChild), diff --git a/resources/tester/helper_224_1c_subpanel_popout.cjs b/resources/tester/helper_224_1c_subpanel_popout.cjs index c48685322..62c799150 100644 --- a/resources/tester/helper_224_1c_subpanel_popout.cjs +++ b/resources/tester/helper_224_1c_subpanel_popout.cjs @@ -1,8 +1,8 @@ /* 224-1c 유토곡선 · 배수유역 [창 분할] — 진짜 `attachSubPanelPopouts` · `attachProfilePopout` * (design_profile_popout.ts)를 가짜 DOM · 가짜 창 위에서 돌려 JSON 으로 냄. * · 단추 자리 · 글 · 클래스 · 툴팁 · 창 이름 셋 다름 - * · 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 rehome - * · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(유토 손잡이 앞 · content 끝) + * · 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) + * · 뗌 = 새 창 body · 배수 rehome · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(종단 열 끝 · content 끝) * · 종단 새 창 안의 단추 = 그 창에서 엶 */ const fs = require("fs"); const path = require("path"); @@ -122,21 +122,25 @@ new Function("exports", "require", "window", "document", js)( mainDoc, ); -// ---- 종단 뿌리 = [content = [bodyWrap = [유토 판, 유토 손잡이], 배수 판 = [머리줄 = [제목]]]] ---- +// ---- 종단 뿌리 = [content = [열 = [종단 칸, 유토 판 = [머리줄 = [손잡이]]], 배수 판 = [머리줄 = [제목]]]] ---- const slot = el("div"); mainDoc.body.append(slot); const root = el("div", "b05-route-profile"); slot.append(root); const content = el("div", "b05-route-profile__content"); -const bodyWrap = el("div"); +const column = el("div", "b05-route-profile__column"); +const bodyWrap = el("div", "b05-route-profile__body-wrap"); const massOverlay = el("div", "b05-profile__masshaul-overlay"); +const massHead = el("div", "b05-profile__masshaul-head"); const massHandle = el("div", "b05-profile__masshaul-handle"); -bodyWrap.append(massOverlay, massHandle); +massHead.append(massHandle); +massOverlay.append(el("div", "ui-resizer"), massHead); +column.append(bodyWrap, massOverlay); const drainageRoot = el("aside", "b05-drainage"); const header = el("div", "b05-drainage__header"); header.append(el("h3")); drainageRoot.append(header); -content.append(bodyWrap, drainageRoot); +content.append(column, drainageRoot); root.append(content); let rehomes = 0; let disposed = 0; @@ -150,20 +154,18 @@ const profileButton = el("button"); mod.attachProfilePopout(profile, slot, profileButton); mod.attachSubPanelPopouts(profile); -const massButton = massOverlay.children.find((c) => c.tagName === "BUTTON"); +const massButton = massHead.children[0]; const drainButton = header.children[0]; const btn = (b) => ({ text: b.textContent, title: b.title, className: b.className, type: b.type }); const out = { mass: btn(massButton), drainage: btn(drainButton), profile: btn(profileButton) }; +out.massButtonInHead = massButton.tagName === "BUTTON" && massHead.children[1] === massHandle; // 유토 뗌 -massOverlay.style.height = "123px"; massButton.click(); const massPopup = massOverlay.parent?.doc?.defaultView; out.massOpened = { inPopupBody: massOverlay.parent === massPopup?.document.body, isPopout: massOverlay.classList.contains("is-popout"), - handleHidden: massHandle.hidden, - inline: massOverlay.style.height, text: massButton.textContent, docTitle: massPopup?.document.title, }; @@ -175,11 +177,10 @@ out.drainOpened = { rehomes, text: drainButton.textContent, }; -// 유토 새 창 닫힘 → 제자리(손잡이 바로 앞) +// 유토 새 창 닫힘 → 제자리(종단 열 끝) massPopup.fire("pagehide"); out.massClosed = { - order: bodyWrap.children.map((c) => c.className), - handleHidden: massHandle.hidden, + order: column.children.map((c) => c.className), isPopout: massOverlay.classList.contains("is-popout"), text: massButton.textContent, }; @@ -205,10 +206,8 @@ profile.dispose(); out.disposed = { closed: [profilePopup.closed, massPopup2.closed, drainPopup2.closed], profileInSlot: root.parent === slot, - massBeforeHandle: - bodyWrap.children.indexOf(massOverlay) === bodyWrap.children.indexOf(massHandle) - 1, + massInColumnEnd: column.children[column.children.length - 1] === massOverlay, drainInContent: drainageRoot.parent === content, - handleHidden: massHandle.hidden, original: disposed, }; out.opened = opened; diff --git a/resources/tester/test_224_1b_masshaul_docked.py b/resources/tester/test_224_1b_masshaul_docked.py index d537ad087..942ae8347 100644 --- a/resources/tester/test_224_1b_masshaul_docked.py +++ b/resources/tester/test_224_1b_masshaul_docked.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -"""224-1b(PLAN 325-2) 유토곡선 판 「붙어 있음」 판정 · 방향키. +"""224-1b(PLAN 325-2 · 354-1) 유토곡선 판 높이 나눔 · 방향키. -- 높이 나눔 · 끌기는 「열림 && 종단 뿌리 안」만 자리를 줌 — 새 창으로 뗀 유토 판은 메인 자리 0 · - 임시 축소 인라인 높이도 안 씀(뗀 창 높이가 굳지 않게). +- 유토곡선 = 종단 열 아래 자기 판(354-1) — 높이 나눔 · 끌기에 안 낌(본문 높이에서 이미 빠짐) · + 인라인 임시 축소도 안 씀. 나눔은 그래프 · 표 둘. - 뗀 유토 판(`is-popout`)은 메인 보이는 구간 세로 범위(viewRange)를 안 씀 — 노선 전체 세로. - 방향키 ←→ 판정은 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫. @@ -34,13 +34,12 @@ def r(): return json.loads(proc.stdout) -def test_붙은_유토는_자리를_차지(r): - """본문 400 − 알약 40 = 360 · 유토 300 은 예산 247 넘어 247 로 줄고 그래프 100.""" - assert r["docked"] == {"chartHeight": 100, "inline": "247px"} - - -def test_뗀_유토는_자리_0_인라인_없음(r): - assert r["popped"] == {"chartHeight": 347, "inline": ""} +def test_유토_판은_나눔에_안_낌(r): + """본문 400 − 알약 40 = 360 · 손잡이 띠 13 → 표 없음 그래프 347.""" + assert r["tableClosed"] == {"chartHeight": 347, "inline": ""} + assert r["tableFits"] == {"chartHeight": 147, "inline": ""} + # 표 300 은 예산 247 넘어 247 로 줄고 그래프 100 + assert r["tableShrinks"] == {"chartHeight": 100, "inline": "247px"} def test_방향키_판정(r): @@ -53,12 +52,15 @@ def test_방향키_판정(r): } -def test_끌기도_같은_판정(): +def test_끌기도_유토_판_안_셈(): drag = (B05 / "B05_Profile_UI_Profile_Drag.ts").read_text(encoding="utf-8") - assert "massHaul.overlay.hidden" in drag # 판정 함수 한 곳에만 - assert drag.count("massHaul.overlay.hidden") == 1 - assert drag.count("massDocked()") == 3 - assert "root.contains(massHaul.overlay)" in drag + heights = (B05 / "B05_Profile_UI_Profile_Heights.ts").read_text(encoding="utf-8") + for src in (drag, heights): + assert "massDocked" not in src and "MASSHAUL_MIN_HEIGHT" not in src + assert "mass:" not in src + # 유토 판 리사이저 끌기 출처만 봄 + assert drag.count("massHaul.overlay") == 1 + assert "massHaul" not in heights def test_뗀_유토는_노선_전체_세로(): diff --git a/resources/tester/test_224_1c_subpanel_popout.py b/resources/tester/test_224_1c_subpanel_popout.py index df926a112..eb8f2f684 100644 --- a/resources/tester/test_224_1c_subpanel_popout.py +++ b/resources/tester/test_224_1c_subpanel_popout.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- """224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창. -- 단추 = 유토 판 좌상단 · 배수 머리줄 맨 앞 · 글 [창 분할]/[창 결합] · 툴팁에 판 이름. +- 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름. - 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐). -- 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 지도 관찰 rehome. -- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 유토 손잡이 바로 앞 · 배수 content 끝. +- 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome. +- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝. - 종단 새 창 안에서 다시 떼면 그 창이 새 창을 엶(메인 창 팝업 차단 피함). `helper_224_1c_subpanel_popout.cjs` 가 진짜 TS 를 가짜 DOM · 가짜 창 위에서 돌림. @@ -51,14 +51,13 @@ def test_단추_글_툴팁_클래스(r): "type": "button", } assert "b05-route-profile__popout" in r["profile"]["className"] + assert r["massButtonInHead"] is True def test_유토_뗌(r): assert r["massOpened"] == { "inPopupBody": True, "isPopout": True, - "handleHidden": True, - "inline": "", "text": "창 결합", "docTitle": "유토곡선 — Aislo", } @@ -70,8 +69,7 @@ def test_배수_뗌은_rehome(r): def test_새_창_닫힘과_단추로_제자리(r): assert r["massClosed"] == { - "order": ["b05-profile__masshaul-overlay", "b05-profile__masshaul-handle"], - "handleHidden": False, + "order": ["b05-route-profile__body-wrap", "b05-profile__masshaul-overlay"], "isPopout": False, "text": "창 분할", } @@ -90,9 +88,8 @@ def test_페이지_떠나면_셋_다_닫고_제자리(r): assert r["disposed"] == { "closed": [True, True, True], "profileInSlot": True, - "massBeforeHandle": True, + "massInColumnEnd": True, "drainInContent": True, - "handleHidden": False, "original": 1, } @@ -104,4 +101,5 @@ def test_배선과_양식(): block = re.search(r"\n\.b05-subpanel__popout \{([^}]*)\}", css).group(1) assert "var(--btn-bar-padding)" in block and "var(--btn-bar-font-size)" in block assert ".b05-profile__masshaul-overlay.is-popout," in css + assert ".b05-profile__masshaul-overlay.is-popout .b05-profile__masshaul-handle," in css assert ".b05-drainage.is-popout {" in css diff --git a/resources/tester/test_230_2_popout_frames.py b/resources/tester/test_230_2_popout_frames.py index a0f51bd03..b75a147be 100644 --- a/resources/tester/test_230_2_popout_frames.py +++ b/resources/tester/test_230_2_popout_frames.py @@ -67,7 +67,8 @@ def test_높이_맞춤(r): def test_표_유토곡선_크기_끌기_다시_그림(r): assert r["table"] == {"changed": 1, "handles": 2, "mainTimers": 0} - assert r["massHaul"] == {"changed": 1, "handles": 2, "mainTimers": 0} + # 유토 판 손잡이는 머리줄에 앉아 끌기 때 따라 옮길 것이 없음(354-1) + assert r["massHaul"] == {"changed": 1, "handles": 0, "mainTimers": 0} def test_유토곡선_세로창_미끄러짐(r): diff --git a/resources/tester/test_354_1_masshaul_own_panel.py b/resources/tester/test_354_1_masshaul_own_panel.py new file mode 100644 index 000000000..b92159eb4 --- /dev/null +++ b/resources/tester/test_354_1_masshaul_own_panel.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +"""354-1 유토곡선 = 배수유역도처럼 자기 판(요구 235-7). + +- 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할] + (`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나. +- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 머리줄만(손잡이 · [창 분할] 닿음). +- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화는 그대로. +""" + +import os +import re +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def css_block(css: str, selector: str) -> str: + m = re.search(r"(?:^|\n)" + re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_종단_열_아래_자기_칸(): + overlays = read("A00_Common/design/design_profile_overlays.ts") + assert 'column.className = "b05-route-profile__column";' in overlays + assert "column.append(bodyWrap, massHaul.overlay);" in overlays + assert "bodyWrap.append(massHaul.overlay" not in overlays + assert "massHaul.attachScrollSync(body);" in overlays + profile = read("A00_Common/design/design_profile.ts") + assert "content.append(column);" in profile + assert sum(1 for _ in profile.splitlines()) <= 700 + + +def test_종단_창_분할에도_안_숨음(): + page = read("A00_Common/design/design_page.css") + assert ".b05-route-profile.is-popout > :not(.b05-route-profile__content)" in page + # 유토 판은 뿌리 바로 아래가 아니라 content > column 안 + overlays = read("A00_Common/design/design_profile_overlays.ts") + assert "root.append" not in overlays + + +def test_흐름_칸_자기_높이_접으면_머리줄(): + css = read("B05_Profile/B05_Profile_UI_Style_MassHaul.css") + panel = css_block(css, ".b05-profile__masshaul-overlay") + assert "position: relative;" in panel and "absolute" not in panel + assert "height: var(--b05-masshaul-height, 280px);" in panel + assert "flex: none;" in panel + column = css_block(css, ".b05-route-profile__column") + assert "flex-direction: column;" in column + hide = css_block( + css, + ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head)", + ) + assert "display: none;" in hide + assert "display: none;" in css_block( + css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay" + ) + mass = read("B05_Profile/B05_Profile_UI_Profile_MassHaul.ts") + assert "head.append(handle);" in mass + assert "overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);" in mass + assert 'overlay.classList.toggle("is-collapsed", !next);' in mass + assert "overlay.hidden" not in mass + # 새 창으로 뗀 판은 접힘이어도 그림 + assert 'const shown = (): boolean => open || overlay.classList.contains("is-popout");' in mass + + +def test_창_분할_단추는_머리줄_맨_앞(): + popout = read("A00_Common/design/design_profile_popout.ts") + assert "massHead.prepend(button);" in popout + assert "home: () => column?.append(massOverlay)," in popout + page = read("A00_Common/design/design_page.css") + assert ".b05-profile__masshaul-overlay > .b05-subpanel__popout" not in page + + +def test_높이_나눔_표_쌓기에서_빠짐(): + heights = read("B05_Profile/B05_Profile_UI_Profile_Heights.ts") + table = read("B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts") + assert "massHaul" not in heights + assert "setBottomOffset" not in heights and "setBottomOffset" not in table + assert "bottomOffset" not in table From fb94f3d6866e97fc0bf100fddd3ed08e0026e9f0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:50:13 +0900 Subject: [PATCH 17/33] =?UTF-8?q?feat(=ED=8F=89=EB=A9=B4):=20353-2=20?= =?UTF-8?q?=EB=93=B1=EA=B3=A0=EC=84=A0=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=90=EB=AC=BC=EC=87=A0=20=E2=80=94=20?= =?UTF-8?q?=ED=98=B8=EB=B2=84=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EC=A7=81?= =?UTF-8?q?=ED=9B=84=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EB=88=84=EB=A5=B4?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=20=EB=86=92=EC=9D=B4=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EB=88=84=EB=A5=B4=EB=A9=B4=20?= =?UTF-8?q?=ED=92=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등고선 위를 지나거나 고른 직후 커서 옆에 자물쇠 단추(캔버스 위 DOM · aria-pressed · 호버 판정 = 집기와 같은 hitPreparedLayer) - 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것 그대로 · 잠긴 높이 위에서만 눌린 단추가 떠 풂 - 높이값으로 쥠 — 분석 지형을 바꿔도 같은 높이 가닥을 다시 짚음 · 페이지 동안만(저장 안 함) - 새 파일 logic/B05_Profile_ContourLock(규칙) · UI_RouteEdit_ContourLock(단추) · 모양 css · RouteEdit.ts 는 고르기 · 지형 바꿈 두 줄 + 세우기만(700줄) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 11 +- .../B05_Profile_UI_RouteEdit_ContourLock.ts | 151 ++++++++++++++++++ ...Profile_UI_Style_RouteEdit_ContourLock.css | 28 ++++ B05_Profile/logic/B05_Profile_ContourLock.ts | 39 +++++ resources/tester/test_353_2_contour_lock.py | 81 ++++++++++ 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts create mode 100644 B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css create mode 100644 B05_Profile/logic/B05_Profile_ContourLock.ts create mode 100644 resources/tester/test_353_2_contour_lock.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 224bbfb60..50fb2d7fc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -28,6 +28,7 @@ import { loadRouteEditBackground, type RouteEditContours, } from "./B05_Profile_UI_RouteEdit_Contour"; +import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { analysisTerrain, @@ -189,6 +190,12 @@ export async function mountRouteEditor( let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ let pickedContour = -1; + /** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */ + const contourLock = createContourLock( + canvas, + () => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation }, + (index) => ((pickedContour = index), draw()), + ); /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ const crossPreview = createCrossPreview({ projectId, @@ -521,7 +528,7 @@ export async function mountRouteEditor( picked = index; }, setPickedContour: (index) => { - pickedContour = index; + if (contourLock.canPick()) pickedContour = index; }, toScreen, toMetric, @@ -652,7 +659,7 @@ export async function mountRouteEditor( const useBackground = (background: Awaited>): void => { otherSheets = background.otherSheets; contours = background.contours; - pickedContour = -1; + pickedContour = contourLock.reattach(contours?.layer ?? null); // 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다. minorContourButton.disabled = !contours?.minor; minorContourButton.title = contours?.minor diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts new file mode 100644 index 000000000..e8e52b173 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_ContourLock.ts + * 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추. + * + * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂. + * 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함). + * 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`. + * ========================================================================== */ + +import { pickPx } from "../A00_Common/touch_bridge"; +import { + hitPreparedLayer, + type PreparedLayer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { + canPickContour, + featureOfLevel, + lockButtonState, + toggleLock, +} from "./logic/B05_Profile_ContourLock"; +import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css"; + +/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */ +const HOVER_HIT_PX = 6; +/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */ +const HIDE_DELAY_MS = 700; +/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */ +const OFFSET_PX = 12; + +const LOCK_SVG = ``; + +/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */ +export interface ContourLockScene { + layer: PreparedLayer; + view: ViewState; + /** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */ + stepM: number; + rotation: { unrotate: (px: number, py: number) => [number, number] }; +} + +export interface ContourLock { + /** 고른 가닥을 바꿔도 되는지(잠긴 동안 false). */ + canPick: () => boolean; + /** 지형을 바꿔 새 가닥들이 왔다 — 잠근 높이의 가닥 번호(없으면 -1). */ + reattach: (layer: PreparedLayer | null) => number; +} + +/** 자물쇠 단추를 캔버스 칸 위에 세움 · `pick` = 잠글 때 그 가닥을 고른 것으로(다시 그리기까지). */ +export function createContourLock( + canvas: HTMLCanvasElement, + scene: () => ContourLockScene | null, + pick: (index: number) => void, +): ContourLock { + const wrap = canvas.parentElement ?? document.body; + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-btn ui-btn--glass b05-contourlock"; + button.dataset.act = "contour-lock"; + button.hidden = true; + button.innerHTML = LOCK_SVG; + wrap.append(button); + + let lockedLevel: number | null = null; + /** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */ + let target = -1; + let hideTimer = 0; + let frame = 0; + let last: PointerEvent | null = null; + + const levelOf = (index: number): number | null => + index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null; + + const paint = (): void => { + const pressed = lockedLevel !== null; + button.setAttribute("aria-pressed", String(pressed)); + const label = pressed + ? `등고선 ${lockedLevel}m 고정 풀기 — 풀면 다른 등고선을 고를 수 있음` + : `등고선 ${levelOf(target) ?? ""}m 고정 — 빈 곳 · 다른 등고선을 눌러도 안 바뀜`; + button.setAttribute("aria-label", label); + button.title = label; + }; + + const hideLater = (): void => { + if (hideTimer) return; + hideTimer = window.setTimeout(() => { + hideTimer = 0; + button.hidden = true; + }, HIDE_DELAY_MS); + }; + const keep = (): void => { + window.clearTimeout(hideTimer); + hideTimer = 0; + }; + + const hover = (event: PointerEvent): void => { + const now = scene(); + // 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄 + if (!now || event.buttons !== 0) return hideLater(); + const rect = canvas.getBoundingClientRect(); + const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); + const hitPx = pickPx(HOVER_HIT_PX); + const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM); + const state = lockButtonState(levelOf(index), lockedLevel); + if (!state.visible) return hideLater(); + keep(); + target = index; + const origin = wrap.getBoundingClientRect(); + button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`; + button.style.top = `${Math.round(event.clientY - origin.top - OFFSET_PX)}px`; + button.hidden = false; + paint(); + }; + + // 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥. + canvas.addEventListener("pointermove", (event) => { + last = event; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + if (last) hover(last); + }); + }); + // 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음. + canvas.addEventListener("pointerup", hover); + canvas.addEventListener("pointerleave", hideLater); + button.addEventListener("pointerenter", keep); + button.addEventListener("pointerleave", hideLater); + button.addEventListener("pointerdown", (event) => event.stopPropagation()); + button.addEventListener("click", (event) => { + event.stopPropagation(); + lockedLevel = toggleLock(lockedLevel, levelOf(target)); + if (lockedLevel !== null) pick(target); + paint(); + }); + + return { + canPick: () => canPickContour(lockedLevel), + reattach: (layer) => { + target = -1; + button.hidden = true; + return featureOfLevel( + layer?.features.map((feature) => feature.labelValue) ?? [], + lockedLevel, + ); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css new file mode 100644 index 000000000..6a2f493b0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css @@ -0,0 +1,28 @@ +/* 평면 등고선 자물쇠(353-2) — 커서 옆에 뜨는 작은 단추 · 자리는 `_ContourLock.ts` 가 잡음. */ +.b05-contourlock { + position: absolute; + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border-radius: 999px; +} + +.b05-contourlock[hidden] { + display: none; +} + +/* 풀림 = 고리가 들림 · 잠김 = 고른 등고선 색(보라)으로 채움 */ +.b05-contourlock[aria-pressed="false"] .b05-contourlock__shackle { + transform: translate(2.5px, -1px) rotate(18deg); + transform-origin: 11px 7px; +} + +.b05-contourlock[aria-pressed="true"] { + border-color: var(--map-flow-arrow); + background-color: var(--map-flow-arrow); + color: #fff; +} diff --git a/B05_Profile/logic/B05_Profile_ContourLock.ts b/B05_Profile/logic/B05_Profile_ContourLock.ts new file mode 100644 index 000000000..85fffd545 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_ContourLock.ts @@ -0,0 +1,39 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_ContourLock.ts + * 평면 등고선 고정(353-2) — 고른 높이 하나를 쥐고, 잠긴 동안 다른 지정을 막는 규칙. + * + * 높이값으로 쥔다(가닥 번호는 지형을 바꾸면 달라짐) · 페이지 동안만(저장 안 함). + * 순수 계산 — 화면 · three · fetch · sessionStorage 없음. 단추는 `B05_Profile_UI_RouteEdit_ContourLock`. + * ========================================================================== */ + +/** 가닥마다 높이(모르면 null). */ +export type ContourLevels = ReadonlyArray; + +/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */ +export interface LockButtonState { + visible: boolean; + pressed: boolean; +} + +/** 그 높이의 첫 가닥 번호 — 없으면 -1(지형을 바꾼 뒤 잠근 높이 다시 짚기). */ +export function featureOfLevel(levels: ContourLevels, level: number | null): number { + if (level === null) return -1; + return levels.findIndex((value) => value === level); +} + +/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */ +export function lockButtonState( + hoverLevel: number | null, + lockedLevel: number | null, +): LockButtonState { + if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true }; + return { visible: hoverLevel !== null, pressed: false }; +} + +/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */ +export function toggleLock(lockedLevel: number | null, targetLevel: number | null): number | null { + return lockedLevel !== null ? null : targetLevel; +} + +/** 잠긴 동안은 고른 가닥이 안 바뀜(다른 가닥 · 빈 곳 눌러도). */ +export const canPickContour = (lockedLevel: number | null): boolean => lockedLevel === null; diff --git a/resources/tester/test_353_2_contour_lock.py b/resources/tester/test_353_2_contour_lock.py new file mode 100644 index 000000000..9dc0a4c58 --- /dev/null +++ b/resources/tester/test_353_2_contour_lock.py @@ -0,0 +1,81 @@ +"""353-2 평면 등고선 자물쇠 — 규칙(`logic/B05_Profile_ContourLock.ts`)을 Node 로 돌려 판정 + +편집기 이음(고르기 막음 · 지형 바꿔도 같은 높이 · 호버 = 집기와 같은 hitPreparedLayer). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_353_2_contour_lock.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +LOGIC = B05 / "logic" / "B05_Profile_ContourLock.ts" +TS = ROOT / "config" / "node_modules" / "typescript" + +DRIVER = r""" +const fs = require("fs"); +const ts = require(process.argv[1]); +const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const m = { exports: {} }; +new Function("exports", "module", js)(m.exports, m); +const L = m.exports; +const levels = [101, 102, 105, 102, null]; +console.log(JSON.stringify({ + hoverFree: L.lockButtonState(102, null), + hoverNone: L.lockButtonState(null, null), + hoverLocked: L.lockButtonState(102, 102), + hoverOther: L.lockButtonState(105, 102), + lock: L.toggleLock(null, 102), + unlock: L.toggleLock(102, 105), + pickFree: L.canPickContour(null), + pickLocked: L.canPickContour(102), + again: L.featureOfLevel(levels, 102), + gone: L.featureOfLevel(levels, 110), + none: L.featureOfLevel(levels, null), +})); +""" + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "-e", DRIVER, str(TS), str(LOGIC)], capture_output=True, text=True, timeout=60 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_안_잠김_단추는_높이_있는_가닥_위에서(got) -> None: + assert got["hoverFree"] == {"visible": True, "pressed": False} + assert got["hoverNone"]["visible"] is False + + +def test_잠김_단추는_그_높이_위에서만_눌린_모양(got) -> None: + assert got["hoverLocked"] == {"visible": True, "pressed": True} + assert got["hoverOther"]["visible"] is False + + +def test_누르면_잠금_다시_누르면_풂(got) -> None: + assert got["lock"] == 102 and got["unlock"] is None + + +def test_잠긴_동안_고르기_막음(got) -> None: + assert got["pickFree"] is True and got["pickLocked"] is False + + +def test_지형_바꿔도_같은_높이를_다시_짚음(got) -> None: + assert got["again"] == 1 and got["gone"] == -1 and got["none"] == -1 + + +def test_편집기_이음() -> None: + edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") + assert "if (contourLock.canPick()) pickedContour = index;" in edit + assert "pickedContour = contourLock.reattach(contours?.layer ?? null);" in edit + lock = (B05 / "B05_Profile_UI_RouteEdit_ContourLock.ts").read_text("utf-8") + assert "hitPreparedLayer(" in lock and 'setAttribute("aria-pressed"' in lock + assert "sessionStorage" not in lock and "writeState" not in lock # 페이지 동안만 From dd49b554b4fb8e15745bbeedef4a6c955c4c0512 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:52:22 +0900 Subject: [PATCH 18/33] =?UTF-8?q?feat(M02):=20359-3=20=EC=8B=A4=EB=AC=B4?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=ED=99=95=EC=9D=B8=20=EC=B0=BD=20=ED=91=9C?= =?UTF-8?q?=20=3D=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EA=BC=B4=20=C2=B7=20=EA=B8=88=EC=95=A1=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=85=88=20=C2=B7=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EC=B6=94=EA=B0=80=20=C2=B7=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=97=86=EC=9D=8C=20=C2=B7=20=EC=82=AC=EC=9A=A9=EC=95=88?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줄 표 = 로직 표(차례 · 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 노무비 · 재료비 · 경비 · 합계) + 자재 표(세 칸 · 가격 다섯) + 품 비교 · 단추 - 금액 칸 = 위 엑셀 · 아래 우리(줄마다 서버 calc 미리 셈 · 저장 안 함 · 최신 응답만) - 미리 셈 문서 꼴 = logic/M02_MasterTemplete_Hopyo_Draft.ts(정본 = M02_Hopyo_Build · 수량 = 엑셀 × 배 · 별도계상) - 드롭다운 글 추가 · 로직없음 · 사용안함(저장 값 그대로) · [엑셀에서 보기] → [엑셀 보기] · [사전조합 생성] - Check.ts 쪼갬(표 그리기 = Hopyo_CheckRows.ts) · 쓰지 않는 [← 호표 목록] 지움 - 시험 test_m02_hopyo_draft_calc(북부청 실물 줄 → calc 200 · 반영합 · 저장 안 함) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../M02_MasterTemplete_Hopyo.css | 41 ++- .../M02_MasterTemplete_Hopyo_Check.ts | 287 +++++---------- .../M02_MasterTemplete_Hopyo_CheckRows.ts | 343 ++++++++++++++++++ .../logic/M02_MasterTemplete_Hopyo_Draft.ts | 56 +++ resources/tester/helper_m02_hopyo_check.cjs | 38 ++ resources/tester/test_m02_hopyo_draft_calc.py | 65 ++++ resources/tester/test_m02_hopyo_screen.py | 60 ++- 7 files changed, 667 insertions(+), 223 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts create mode 100644 M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts create mode 100644 resources/tester/test_m02_hopyo_draft_calc.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 612aee372..5a6bc6ea6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -55,34 +55,49 @@ overflow-wrap: break-word; } -/* 확인 창 줄 표(338 · 339) — 체크 · 갈래 · 수량 · 단위 · 상태 = 줄바꿈 없음(폭 = 글 폭) · 단추 = 세로로 쌓아 좁게 · - 품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ +/* 확인 창 줄 표(338 · 339 · 359-3) — 일위대가 상세 꼴(m02-labrow) · 체크 칸 · 수량 · 단위 = 줄바꿈 없음 · + 단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ .m02-hopyo__table.m02-hopyo__lines :is(th, td) { padding: var(--spacing-8); } -.m02-hopyo__table.m02-hopyo__lines - :is(th, td):is(:nth-child(1), :nth-child(2), :nth-child(5), :nth-child(6), :nth-child(7)) { +.m02-hopyo__table.m02-hopyo__lines td:first-child, +.m02-hopyo__lines :is(.m02-labrow__qty, .m02-labrow__unit) { white-space: nowrap; } -/* 품명 · 규격 — 낱말 두셋은 한 줄에 */ -.m02-hopyo__table.m02-hopyo__lines td:nth-child(3), +/* 이름 칸 대상 줄 — 키 · 이름 · 배 칸 · 몫 한 줄(상세 이름 칸의 한 줄 말줄임을 풂) */ +.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__part { + display: flex; + max-width: none; + overflow: visible; +} + +.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__state, +.m02-hopyo__lines .m02-labrow__kind > .m02-hopyo__state { + display: inline-block; + margin-left: var(--spacing-4); +} + +/* 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */ +.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__compare { + min-width: 9em; + white-space: pre-line; +} + +.m02-hopyo__subhead { + margin: var(--spacing-8) 0 0; + font-size: var(--text-body-sm); +} + .m02-hopyo-ledger__table td:nth-child(1) { min-width: 7em; } -.m02-hopyo__table.m02-hopyo__lines td:nth-child(4), .m02-hopyo-ledger__table td:nth-child(2) { min-width: 6em; } -/* 옵션 · 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */ -.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) { - min-width: 9em; - white-space: pre-line; -} - /* 단추 칸 — 단추 · 넣음/뺌을 세로로 쌓아 칸 폭 = 가장 긴 단추 */ .m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__acts { width: 1%; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index 05f10c7c9..4af902735 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -1,15 +1,15 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Check.ts - * 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 · - * 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄. - * · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기] + * 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1 · 359-3) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 · + * 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [사전조합 생성]으로 서버에 보냄. + * · 자리 = 엑셀 보기 창 호표 [수정] · [로직 추가] 「실무 호표」 모달 * · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로) - * · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) · - * 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭 + * · 줄 표 = 구조물 조합 일위대가 상세 꼴(로직 표 · 자재 표 · 품 비교 · 단추 — `M02_MasterTemplete_Hopyo_CheckRows`) + * · 금액 = 줄마다 서버 calc 미리 셈(`logic/M02_MasterTemplete_Hopyo_Draft` 문서 · 저장 안 함 · 줄마다 최신 응답만) * · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈 * · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음 - * · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만. - * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만. + * · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만. + * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만. * ========================================================================== */ import { @@ -36,22 +36,23 @@ import { type HopyoPart, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; +import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose"; import { - builtWord, - inputsText, - openHopyoChoose, - sourceText, - targetText, -} from "./M02_MasterTemplete_Hopyo_Choose"; + checkTables, + type LinePrice, + type RowsContext, +} from "./M02_MasterTemplete_Hopyo_CheckRows"; import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; -import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; +import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft"; import "./M02_MasterTemplete_Hopyo.css"; export const POLL_MS = 3000; /** 사전조합을 만들었음(document 알림) — 좌측 「일위대가 사전조합」 목록 다시 */ export const HOPYO_BUILT = "m02-hopyo-built"; -const OUT = ["우리에 없음", "안 씀"] as const; +/** 미리 셈 문서 열 이름(서버 calc 는 보낸 문서로 셈 · 저장 안 함) */ +const DRAFT_COLUMN = "hopyo-draft"; /** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */ export interface CheckDraft { @@ -204,13 +205,11 @@ export interface CheckOptions { target?: HopyoScope; /** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */ canWrite: boolean; - /** [← 호표 목록](본 칸) */ - onBack?: () => void; /** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */ onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void; } -/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */ +/** 확인 창 몸 — 모달(`openHopyoCheck`)에 담김 */ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { const root = el("div", { className: "m02-hopyo m02-hopyo-check", @@ -224,6 +223,9 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { let seq = 0; let timer = 0; const ask = newest(); // 줄마다 최신 미리 셈만 + const askPrice = newest(); // 줄마다 최신 금액 미리 셈만 + const priced = new Map(); + let pending = false; const keep = (): void => saveDraft(k, d); async function load(quiet = false): Promise { @@ -313,161 +315,76 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { }); } - function partCell(line: HopyoLine): HTMLElement { - const parts = partsOf(line, d); - return el("td", { - className: "m02-hopyo__parts", - children: parts.map((p, at) => { - const times = el("input", { - className: "ui-input ui-input--sm m02-hopyo__times", - attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) }, - }) as HTMLInputElement; - times.title = "배 — 우리 수량 = 엑셀 수량 × 배"; - times.disabled = !opt.canWrite; - times.addEventListener("change", () => { - const v = times.value === "" ? null : Number(times.value); - d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x)); - keep(); - draw(); - }); - const share = p.몫 != null ? ` · 몫 ${p.몫}` : ""; - return el("div", { - className: "m02-hopyo__part", - children: [ - el("span", { - text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(), - attrs: { title: p.어디 ?? "" }, - }), - times, - ...(p.로직 - ? [ - createButton({ - label: "옵션", - variant: "ghost", - size: "sm", - onClick: () => editOptions(line, at), - }), - ] - : []), - ], - }); - }), + /** 줄마다 금액 미리 셈(359-3) — 서버 calc(저장 안 함 · 줄마다 최신 응답만 · 문서가 같으면 다시 안 셈) */ + function price(line: HopyoLine): LinePrice | null { + const doc = draftDoc(line, partsOf(line, d)); + if (!doc) return null; + const sig = JSON.stringify(doc); + const have = priced.get(line.차례); + if (have?.sig === sig) return have.got; + const slot = { sig, got: {} as LinePrice }; + priced.set(line.차례, slot); + const mine = askPrice(line.차례); + calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner).then( + (calc) => mine() && ((slot.got = { calc }), soon()), + (error: unknown) => mine() && ((slot.got = { fail: why(error) }), soon()), + ); + return slot.got; + } + /** 응답 여럿 = 한 번 그림 */ + function soon(): void { + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + if (root.isConnected) draw(); }); } - function lineRow(line: HopyoLine): HTMLElement { - const m = line.맞춤; - const parts = partsOf(line, d); - const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } }); - const input = box as HTMLInputElement; - input.checked = isChecked(line, d) && checkable(line, d); - input.disabled = !opt.canWrite || !checkable(line, d); - input.addEventListener("change", () => { - d.체크[line.차례] = input.checked; + const ctx = (): RowsContext => ({ + canWrite: opt.canWrite, + d, + price, + check: (line, on) => { + d.체크[line.차례] = on; keep(); draw(); - }); - const out = el("select", { - className: "ui-input ui-input--sm", - attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" }, - children: [ - el("option", { text: "넣음", attrs: { value: "" } }), - ...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })), - ], - }) as HTMLSelectElement; - out.value = d.뺌[line.차례] ?? ""; - out.disabled = !opt.canWrite || m.상태 === "%"; - out.addEventListener("change", () => { - if (out.value) d.뺌[line.차례] = out.value; + }, + out: (line, value) => { + if (value) d.뺌[line.차례] = value; else delete d.뺌[line.차례]; - d.체크[line.차례] = !!out.value || d.체크[line.차례]; + d.체크[line.차례] = !!value || d.체크[line.차례]; keep(); draw(); - }); - const sheet = (행: [number, number]): void => - openSheet({ - scope: opt.scope, - key: opt.key, - 파일: got?.파일 ?? "", - canWrite: opt.canWrite, - target, - 단산행: 행, - }); - const acts = [ - ...(opt.canWrite && m.상태 !== "%" - ? [ - createButton({ - label: "고르기", - variant: "ghost", - size: "sm", - onClick: () => - openHopyoChoose({ - line, - openSheet: sheet, - done: (ts) => - setParts( - line, - ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })), - ), - }), - }), - ] - : []), - createButton({ - label: "원문 보기", - variant: "ghost", - size: "sm", - onClick: () => void showSource(line), + }, + times: (line, at, value) => { + d.대상[line.차례] = partsOf(line, d).map((x, i) => (i === at ? { ...x, 배: value } : x)); + keep(); + draw(); + }, + choose: (line) => + openHopyoChoose({ + line, + openSheet: sheetAt, + done: (ts) => + setParts( + line, + ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })), + ), }), - ...(line.근거 - ? [ - createButton({ - label: "엑셀에서 보기", - variant: "ghost", - size: "sm", - onClick: () => sheet(line.근거!.행), - }), - ] - : []), - out, - ]; - const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n"); - const opts = parts - .filter((p) => p.로직) - .flatMap((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처).split(/, (?=[^,=]+=)| · /)) - .filter(Boolean); - if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`); - const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : ""; - return el("tr", { - className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`, - attrs: { "data-line": String(line.차례), "data-state": m.상태 }, - children: [ - el("td", { children: [box] }), - td(line.갈래), - td(line.이름), - td(line.규격), - td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), - td(line.단위), - el("td", { - attrs: { title: tags }, - children: [ - el("span", { - className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`, - text: `${m.상태}${alias}`, - }), - ], - }), - partCell(line), - td(opts.join("\n")), // 옵션 · 품 비교 = 항목마다 한 줄 - td( - compareText(d.품[line.차례] ?? m.품) - .split(" · ") - .join("\n"), - ), - el("td", { className: "m02-hopyo__acts", children: acts }), - ], + source: (line) => void showSource(line), + sheet: sheetAt, + options: editOptions, + }); + const sheetAt = (행: [number, number]): void => + openSheet({ + scope: opt.scope, + key: opt.key, + 파일: got?.파일 ?? "", + canWrite: opt.canWrite, + target, + 단산행: 행, }); - } async function make(h: HopyoOne["호표"]): Promise { const whole = !!h.통째 && (d.통째 ?? h.통째.체크); @@ -483,8 +400,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { .map(([a, b]) => `${a} ${b}`) .join(" · ")})` : "") + - " — 확인하고 사전조합을 만듦"; - if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return; + " — 사전조합 생성"; + if (!(await showConfirmDialog(ask, "생성"))) return; const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no }; showLoadingOverlay(); try { @@ -575,19 +492,15 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { draw(); }); const whole = !!h.통째 && (d.통째 ?? h.통째.체크); - const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" }); + const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" }); go.disabled = !whole && checked < 1; go.setAttribute("data-hopyo-make", ""); go.addEventListener("click", () => void make(h)); tools.push(all, go); } - const back = opt.onBack - ? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })] - : []; const head = el("div", { className: "m02-hopyo__bar", children: [ - ...back, el("strong", { text: `제${h.번}호표 ${h.이름}` }), note(h.규격), note(h.단위), @@ -598,7 +511,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { : []), ...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []), createButton({ - label: "엑셀에서 보기", + label: "엑셀 보기", size: "sm", onClick: () => openSheet({ @@ -638,38 +551,10 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { ]; })() : []; - const heads = [ - "", - "갈래", - "품명", - "규격", - "수량", - "단위", - "상태", - "우리 로직 · 배", - "옵션", - "품 비교", - "", - ]; root.replaceChildren( head, ...whole, - h.줄.length - ? el("div", { - className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", - children: [ - el("table", { - className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - el("tbody", { children: h.줄.map(lineRow) }), - ], - }), - ], - }) - : note("구성 줄 없음"), + ...(h.줄.length ? checkTables(h.줄, ctx()) : [note("구성 줄 없음")]), ...(built ? [builtBlock(built)] : []), ); } @@ -678,7 +563,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { return root; } -/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */ +/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 호표 [수정] · 만들면 닫음 */ export function openHopyoCheck(opt: CheckOptions & { title?: string }): void { openModal({ title: opt.title ?? `확인 — 제${opt.no}호표`, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts new file mode 100644 index 000000000..e14bbd11d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -0,0 +1,343 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_CheckRows.ts + * 확인 창 줄 표(PLAN 359-3) — 구조물 조합 일위대가 상세 꼴(`Labor_Rows` logicTable · matTable)에 품 비교 · 단추만 더함. + * · 로직 표 = [✓] · 차례 · 구분(엑셀 갈래 + 상태) · 이름(우리 로직 · 배 · 옵션 글) · 별칭(엑셀 품명 = 만들 때 별칭) · + * 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배) · 결과 단위 · 노무비 · 재료비 · 경비 · 합계 · 품 비교 · 단추 + * · 자재 표 = [✓] · 차례 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(자재구분 · 별도계상 · 운반비 · 글만) · + * 가격 다섯 · 엑셀 / 우리 금액 · 단추 + * · 금액 칸 = 위 작게 엑셀 · 아래 우리(서버 calc 미리 셈 · 저장 안 함) · 셈 중 「…」 + * · 단추 = [고르기] · [원문 보기] · [엑셀 보기] · [옵션](로직 대상마다) · 드롭다운 추가 · 로직없음 · 사용안함 + * (화면 글만 · 저장 값 = 빈 값 · 「우리에 없음」 · 「안 씀」 그대로) + * 줄 표 = `tableOf`(대상이 모두 자재 · 대상 없는 자재 · 별도계상 줄 = 자재 표). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api"; +import { + checkable, + compareText, + isChecked, + partsOf, + type CheckDraft, +} from "./M02_MasterTemplete_Hopyo_Check"; +import { inputsText, sourceText } from "./M02_MasterTemplete_Hopyo_Choose"; +import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api"; +import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; +import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft"; + +/** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */ +export const OUT_TEXT: [string, string][] = [ + ["", "추가"], + ["우리에 없음", "로직없음"], + ["안 씀", "사용안함"], +]; +const COSTS = ["노무비", "재료비", "경비", "계"] as const; +const COST_HEAD = { + 노무비: "Prev_Labor", + 재료비: "Prev_Material", + 경비: "Prev_Expense", + 계: "Prev_Sum", +} as const; + +/** 줄 하나 미리 셈 — 셈 중 = 둘 다 없음 */ +export interface LinePrice { + calc?: LaborCalc; + fail?: string; +} + +export interface RowsContext { + canWrite: boolean; + d: CheckDraft; + price: (line: HopyoLine) => LinePrice | null; + check: (line: HopyoLine, on: boolean) => void; + out: (line: HopyoLine, value: string) => void; + times: (line: HopyoLine, at: number, value: number | null) => void; + choose: (line: HopyoLine) => void; + source: (line: HopyoLine) => void; + sheet: (행: [number, number]) => void; + options: (line: HopyoLine, at: number) => void; +} + +const num = (v: number): string => + Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +const small = (text: string, cls = "m02-labrow__note"): HTMLElement => + el("small", { className: cls, text, attrs: { title: text } }); + +/** 금액 칸 — 위 엑셀(작게) · 아래 우리 */ +function pairCell( + excel: number | null | undefined, + ours: number | null | undefined, + wait: boolean, + label: string, +): HTMLElement { + const td = moneyCell(excel, ours, wait, label); + td.querySelector(".m02-labrow__base")?.setAttribute("title", "엑셀"); + td.querySelector(".m02-labrow__applied")?.setAttribute("title", "우리(미리 셈)"); + return td; +} + +/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배 · 대상마다) */ +function qtyCell(line: HopyoLine, parts: HopyoPart[]): HTMLElement { + const ours = parts + .map((p) => (p.배 === null || line.수량 === null ? "—" : num(line.수량 * p.배))) + .join(" · "); + return el("td", { + className: "m02-labrow__qty", + attrs: { "data-label": tc("Labor_QtyCol") }, + children: [ + el("small", { + className: "m02-labrow__base", + text: line.수량 === null ? "—" : `${num(line.수량)} ${line.단위}`.trim(), + attrs: { title: "엑셀" }, + }), + el("span", { className: "m02-labrow__applied", text: ours || "—", attrs: { title: "우리" } }), + ], + }); +} + +const stateChip = (line: HopyoLine): HTMLElement => { + const m = line.맞춤; + const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : ""; + const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n"); + return el("span", { + className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`, + text: `${m.상태}${alias}`, + attrs: { title: tags }, + }); +}; + +/** 대상마다 한 줄 — 키 · 이름 · × 배 칸 · 몫 */ +function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement[] { + return parts.map((p, at) => { + const times = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) }, + }) as HTMLInputElement; + times.title = "배 — 우리 수량 = 엑셀 수량 × 배"; + times.disabled = !ctx.canWrite; + times.addEventListener("change", () => + ctx.times(line, at, times.value === "" ? null : Number(times.value)), + ); + const key = p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : ""); + return el("div", { + className: "m02-hopyo__part", + attrs: { title: p.어디 ?? "" }, + children: [ + el("span", { className: "m02-labrow__key", text: key }), + el("strong", { text: p.이름 ?? "" }), + times, + ...(p.몫 != null ? [small(`몫 ${p.몫}`)] : []), + ], + }); + }); +} + +/** 이름 칸 덧글 — 옵션(칸=값 · 출처) · 못 가름 · 셈 못 함 까닭 */ +function nameNotes(line: HopyoLine, parts: HopyoPart[], price: LinePrice | null): HTMLElement[] { + const opts = parts + .filter((p) => p.로직) + .map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처)) + .filter(Boolean); + if (line.맞춤.갈리는칸?.length) opts.push(`⚠ 못 가름: ${line.맞춤.갈리는칸.join(", ")}`); + const why = [ + price?.fail, + ...(price?.calc?.줄 ?? []).map((l) => l.까닭), + ...(price?.calc?.자재 ?? []).map((m) => m.까닭), + ].filter(Boolean) as string[]; + return [ + ...opts.map((t) => small(t, "m02-labrow__note m02-hopyo__opts")), + ...why.map((t) => small(`셈 못 함: ${t}`, "m02-labrow__empty")), + ...(parts.some((p) => p.호표 !== undefined) ? [small("하위 호표 = 만든 뒤 셈")] : []), + ]; +} + +function checkCell(line: HopyoLine, ctx: RowsContext): HTMLElement { + const box = el("input", { + attrs: { type: "checkbox", "data-check": String(line.차례) }, + }) as HTMLInputElement; + box.checked = isChecked(line, ctx.d) && checkable(line, ctx.d); + box.disabled = !ctx.canWrite || !checkable(line, ctx.d); + box.addEventListener("change", () => ctx.check(line, box.checked)); + return el("td", { children: [box] }); +} + +function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement { + const m = line.맞춤; + const ghost = (label: string, run: () => void): HTMLElement => + createButton({ label, variant: "ghost", size: "sm", onClick: run }); + const out = el("select", { + className: "ui-input ui-input--sm", + attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" }, + children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })), + }) as HTMLSelectElement; + out.value = ctx.d.뺌[line.차례] ?? ""; + out.disabled = !ctx.canWrite || m.상태 === "%"; + out.addEventListener("change", () => ctx.out(line, out.value)); + const options = parts.flatMap((p, at) => { + if (!p.로직) return []; + const b = ghost("옵션", () => ctx.options(line, at)); + b.title = `${p.로직} 옵션`; + return [b]; + }); + return el("td", { + className: "m02-hopyo__acts", + children: [ + ...(ctx.canWrite && m.상태 !== "%" ? [ghost("고르기", () => ctx.choose(line))] : []), + ghost("원문 보기", () => ctx.source(line)), + ...(line.근거 ? [ghost("엑셀 보기", () => ctx.sheet(line.근거!.행))] : []), + ...options, + out, + ], + }); +} + +const rowOf = (line: HopyoLine, ctx: RowsContext, cells: HTMLElement[]): HTMLElement => { + const on = isChecked(line, ctx.d) && checkable(line, ctx.d); + return el("tr", { + className: `m02-labrow__row m02-hopyo__line is-${on ? "in" : "out"}`, + attrs: { "data-line": String(line.차례), "data-state": line.맞춤.상태 }, + children: [checkCell(line, ctx), ...cells, actsCell(line, partsOf(line, ctx.d), ctx)], + }); +}; + +function logicRow(line: HopyoLine, ctx: RowsContext): HTMLElement { + const parts = partsOf(line, ctx.d); + const price = ctx.price(line); + const wait = !!price && !price.calc && !price.fail; + const ours = price?.calc?.반영합; + const unit = price?.calc?.줄[0]?.분모 ?? parts[0]?.단위 ?? line.단위; + const compare = compareText(ctx.d.품[line.차례] ?? line.맞춤.품); + return rowOf(line, ctx, [ + el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }), + el("td", { className: "m02-labrow__kind", children: [line.갈래, stateChip(line)] }), + el("td", { + className: "m02-labrow__name m02-hopyo__parts", + children: [ + ...(parts.length ? partLines(line, parts, ctx) : [small("—", "m02-labrow__muted")]), + ...nameNotes(line, parts, price), + ], + }), + el("td", { + className: "m02-labrow__alias", + attrs: { title: `${line.이름} ${line.규격}`.trim() }, + children: [line.이름, ...(line.규격 ? [small(line.규격)] : [])], + }), + qtyCell(line, parts), + el("td", { className: "m02-labrow__unit", text: unit ?? "" }), + ...COSTS.map((c) => + pairCell(line.금액[c], price?.calc ? (ours?.[c] ?? null) : null, wait, tc(COST_HEAD[c])), + ), + el("td", { className: "m02-hopyo__compare", text: compare.split(" · ").join("\n") }), + ]); +} + +function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement { + const parts = partsOf(line, ctx.d); + const price = ctx.price(line); + const wait = !!price && !price.calc && !price.fail; + const mats = price?.calc?.자재 ?? []; + const first = mats[0]; + const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null; + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : v.toLocaleString("ko-KR"); + const p = parts[0]; + return rowOf(line, ctx, [ + el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }), + el("td", { + className: "m02-labrow__code", + text: parts.map((x) => x.자재 ?? "").join(" · ") || "—", + }), + el("td", { + className: "m02-labrow__name m02-hopyo__parts", + children: [ + stateChip(line), + ...(parts.length ? partLines(line, parts, ctx) : []), + small(`엑셀 ${line.이름}`.trim()), + ...nameNotes(line, parts, price), + ], + }), + el("td", { + className: "m02-labrow__spec", + text: p?.규격 ?? line.규격, + attrs: { title: p?.규격 ?? line.규격 }, + }), + qtyCell(line, parts), + el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }), + ...markCells({ 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }), + ...PRICE_SLOTS.map((slot) => + el("td", { + className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`, + text: show(first?.가격?.[slot]), + attrs: { "data-label": slot, "data-price-slot": slot }, + }), + ), + pairCell(line.금액.계, price?.calc ? ours : null, wait, tc("Labor_AppliedSum")), + ]); +} + +const grid = (heads: string[], rows: HTMLElement[], kind: string): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines", + attrs: { "data-hopyo-table": kind }, + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }); + +/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */ +export function checkTables(lines: HopyoLine[], ctx: RowsContext): HTMLElement[] { + const logic = lines.filter((l) => tableOf(l, partsOf(l, ctx.d)) === "logic"); + const mat = lines.filter((l) => !logic.includes(l)); + const out: HTMLElement[] = []; + if (logic.length) + out.push( + grid( + [ + "", + tc("Col_Order"), + tc("Sub"), + tc("Col_Name"), + tc("Col_Alias"), + tc("Labor_QtyCol"), + tc("Col_Unit"), + ...COSTS.map((c) => tc(COST_HEAD[c])), + "품 비교", + "", + ], + logic.map((l) => logicRow(l, ctx)), + "logic", + ), + ); + if (mat.length) + out.push( + el("h4", { className: "m02-hopyo__subhead", text: "자재" }), + grid( + [ + "", + tc("Col_Order"), + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Labor_QtyCol"), + tc("Labor_Unit"), + ...markHeads(), + ...PRICE_SLOTS, + `엑셀 / 우리 ${tc("Labor_AppliedSum")}`, + "", + ], + mat.map((l) => matRow(l, ctx)), + "mat", + ), + ); + return out; +} diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts new file mode 100644 index 000000000..cb2051f00 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts @@ -0,0 +1,56 @@ +/* ============================================================================= + * M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts + * 확인 창 금액 미리 보기 꼴(PLAN 359-3) — 엑셀 줄 하나 + 지금 대상 → 일위대가 문서 한 벌(서버 `/labor/{열}/calc` 에 + * 보냄 · 저장 안 함 · 금액은 서버가 셈) · 줄이 들어갈 표(로직 표 · 자재 표). + * 정본 = 서버 `M02_Hopyo_Build.Builder.build` 의 `put`([사전조합 생성] 때 다시 셈) — 같은 규칙: + * 우리 수량 = 엑셀 수량 × 배 · 로직 = {로직, 입력값, 수량 {손}} · 자재 = {코드, 수량 {손}, 별도계상 = + * 갈래 별도계상이면 「별도계상」 아니면 「직접계산」} · 같은 자재 = 수량 더함. + * 미리 보기만 다른 것: 호표 대상(하위 사전조합 — 만든 뒤 셈) · 배 없는 대상은 뺌 · + * 같은 로직 둘째는 뺌(Build = 한 줄 사전조합으로 감쌈 · 한 문서에 같은 로직 둘 = 서버 400). + * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). + * ========================================================================== */ + +import type { HopyoLine, HopyoPart } from "../M02_MasterTemplete_Hopyo_Api"; +import type { LaborDoc, LaborMat, LaborRow } from "../M02_MasterTemplete_Labor_Api"; + +export type CheckTable = "logic" | "mat"; + +/** 엑셀 갈래 중 자재 표로 가는 것(대상 없을 때) */ +const MAT_KINDS = ["자재", "별도계상"]; + +/** 줄이 들어갈 표 — 대상이 모두 자재면 자재 표 · 대상 없으면 갈래(자재 · 별도계상) · 그 밖 로직 표 */ +export function tableOf(line: Pick, parts: HopyoPart[]): CheckTable { + if (parts.length) return parts.every((p) => !!p.자재) ? "mat" : "logic"; + return MAT_KINDS.includes(line.갈래) ? "mat" : "logic"; +} + +/** 엑셀 줄 + 대상 → 미리 셈 문서 · 셀 것이 없으면 null(호표 대상뿐 · 배 없음 · 대상 없음) */ +export function draftDoc( + line: Pick, + parts: HopyoPart[], +): LaborDoc | null { + const rows: LaborRow[] = []; + const mats = new Map(); + const apart = line.갈래 === "별도계상" ? "별도계상" : "직접계산"; + for (const p of parts) { + if (p.배 === null || p.호표 !== undefined) continue; + const qty = (line.수량 ?? 0) * p.배; + if (p.자재) { + const have = mats.get(p.자재)?.수량; + const hand = (have ? have.손 : 0) + qty; + mats.set(p.자재, { + 코드: p.자재, + 이름: null, + 규격: null, + 단위: null, + 수량: { 손: hand }, + 별도계상: apart, + }); + } else if (p.로직 && !rows.some((r) => r.로직 === p.로직)) { + const values = (p.입력값 ?? {}) as LaborRow["입력값"]; + rows.push({ 로직: p.로직, 별칭: null, 메모: null, 입력값: values, 수량: { 손: qty } }); + } + } + if (!rows.length && !mats.size) return null; + return { 종류: "일위대가", 열: "", 단위: null, 담은로직: rows, 자재: [...mats.values()] }; +} diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index 9e2f83b33..b3d3acdd3 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -14,6 +14,7 @@ const STUB_FILES = [ "M02_MasterTemplete_Labor_Api", "M02_MasterTemplete_Labor_Input", "M01_MasterData_UI_LogicLab_Source", + "M02_MasterTemplete_Hopyo_CheckRows", // 표 그리기(DOM · Labor_Rows) — 359-3 ]; const opened = []; const stub = new Proxy( @@ -74,6 +75,17 @@ let out0 = null; const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts")); + const draft = loadTs(path.join(M02, "logic", "M02_MasterTemplete_Hopyo_Draft.ts")); + if (input.drafts) { + // 359-3 — 실물 줄 → 미리 셈 문서 · 표(서버 calc 에 그대로 보냄) + const d = check.loadDraft("drafts"); + out0 = input.drafts.map((l) => { + const parts = check.partsOf(l, d); + return { 차례: l.차례, table: draft.tableOf(l, parts), doc: draft.draftDoc(l, parts) }; + }); + process.stdout.write(JSON.stringify(out0)); + return; + } if (input.real) { // 실물 맞춤 응답(338-4) — 서버 줄을 그대로 먹여 체크 · 몸 · 빠질 줄 · 품 비교 · 글 const lines = input.real; @@ -177,6 +189,32 @@ let out0 = null; entry: ledger.entryOf({ ...rows[0], 갈래: "우리에 없음" }), }; })(), + draft: (() => { + // 359-3 — 미리 셈 문서(우리 수량 = 엑셀 × 배 · 별도계상 · 같은 자재 더함 · 같은 로직 둘째 · 호표 · 배 없음 뺌) + const L = { 로직: "GC000198", 입력값: { 소량감률: 50 }, 배: 2 }; + const M = { 자재: "MT1", 입력값: {}, 배: 1 }; + return { + logic: draft.draftDoc({ 수량: 0.5, 갈래: "단가산출" }, [ + L, + { ...L, 배: 3 }, + { 호표: 46, 입력값: {}, 배: 1 }, + { 로직: "GF000101", 입력값: {}, 배: null }, + ]), + mat: draft.draftDoc({ 수량: 2, 갈래: "별도계상" }, [M, { ...M, 배: 0.5 }]), + direct: draft.draftDoc({ 수량: null, 갈래: "자재" }, [M]).자재[0], + none: [ + draft.draftDoc({ 수량: 1, 갈래: "단가산출" }, []), + draft.draftDoc({ 수량: 1, 갈래: "호표" }, [{ 호표: 3, 입력값: {}, 배: 1 }]), + ], + table: [ + draft.tableOf({ 갈래: "단가산출" }, [M]), + draft.tableOf({ 갈래: "자재" }, [L]), + draft.tableOf({ 갈래: "자재" }, [L, M]), + draft.tableOf({ 갈래: "별도계상" }, []), + draft.tableOf({ 갈래: "%" }, []), + ], + }; + })(), sheet: { order: sheet.buildOrder([ { 번: 45, 아래: [46] }, diff --git a/resources/tester/test_m02_hopyo_draft_calc.py b/resources/tester/test_m02_hopyo_draft_calc.py new file mode 100644 index 000000000..a6769e95a --- /dev/null +++ b/resources/tester/test_m02_hopyo_draft_calc.py @@ -0,0 +1,65 @@ +"""확인 창 금액 미리 셈 이음매(PLAN 359-3) — 북부청 실물 호표 줄 → 화면 문서 꼴 +(`logic/M02_MasterTemplete_Hopyo_Draft.ts` · Node) → 서버 `/labor/{열}/calc`(저장 안 함). + +체크 제안 로직 줄(제1~10호표) = 200 · 반영합 있음 · 우리 수량 = 엑셀 수량 × 배 · +자재 줄 = 자재 표 · 셈 뒤 층 폴더에 문서 안 생김. +""" + +import json +import os +import subprocess + +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import _post + +HERE = os.path.dirname(os.path.abspath(__file__)) +client = base.client # 공용 고정물(임시 층) +CALC = "/api/m02/layers/personal/labor/hopyo-draft/calc" + + +def _drafts(lines: list[dict]) -> list[dict]: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_hopyo_check.cjs")], + input=json.dumps({"drafts": lines}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_실물_줄_미리_셈(client): + assert _post(client).status_code == 200 + folder = layers.personal_dir(7, 42) + fit.run_job(folder, "HP-000001") # 셈 끝(338 숫자 판정) + before = sorted(p.name for p in folder.rglob("*") if p.is_file()) + lines = [ + {**line, "차례": no * 1000 + line["차례"]} # 호표 여럿 줄 한 번에(차례 = 열쇠만) + for no in range(1, 11) + for line in client.get(f"/api/m02/layers/personal/hopyo/HP-000001/{no}").json()["호표"][ + "줄" + ] + ] + drafts = {x["차례"]: x for x in _drafts(lines)} + sure = [x for x in lines if x["맞춤"]["체크"] and any("로직" in p for p in x["맞춤"]["대상"])] + assert len(sure) >= 5, [x["맞춤"]["상태"] for x in lines] + for line in sure: + draft = drafts[line["차례"]] + assert draft["doc"] is not None, line["이름"] + res = client.post(CALC, json={"doc": draft["doc"], "수량": {}}) + assert res.status_code == 200, (line["이름"], res.text) + got = res.json() + if draft["table"] == "logic": + row = draft["doc"]["담은로직"][0] + part = line["맞춤"]["대상"][0] + assert row["수량"]["손"] == line["수량"] * part["배"] + assert got["반영합"]["계"] > 0, line["이름"] + for line in lines: # 자재 · 별도계상 줄(대상 없음) = 자재 표 + if line["갈래"] in ("자재", "별도계상") and not line["맞춤"]["대상"]: + assert drafts[line["차례"]]["table"] == "mat" + after = sorted(p.name for p in folder.rglob("*") if p.is_file()) + assert after == before # 미리 셈 = 저장 안 함 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index c3b7b89ff..ca913fc01 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -26,6 +26,7 @@ NEW = ( "Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", + "Hopyo_CheckRows", "Hopyo_Choose", "Hopyo_Ledger", ) @@ -282,7 +283,8 @@ def test_옛_즉시_확정과_바로_만들기_없음(): users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤 check = texts["Hopyo_Check"] - assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check + assert '"사전조합 생성"' in check and "맞음 · 별칭 모두 체크" in check + assert "확인하고" not in check # 359-4 명사형 assert "showLoadingOverlay()" in check and "dropDraft(k)" in check assert "m02-hopyo-check:" in check and "POLL_MS" in check sheet = texts["Hopyo_Sheet"] @@ -352,7 +354,8 @@ def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): assert "await showSheet(got);" in side # 올린 직후 자동 assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0] check = _text("M02_MasterTemplete_Hopyo_Check.ts") - assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check + assert lazy in check and 'label: "엑셀 보기"' in check and "호표: opt.no" in check + assert "엑셀에서 보기" not in check assert "단산행: 행" in check pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") assert lazy in pick @@ -398,16 +401,14 @@ def test_확인_창_손질_339(): ② 만들기 뒤 좌측 사전조합 목록 다시 ③ 엑셀 보기 창 목록 셈 끝까지 다시 읽음 · 펼침 표 숫자 판정 칸.""" css = _text("M02_MasterTemplete_Hopyo.css") assert "word-break: keep-all;" in css and "white-space: pre-line;" in css - assert ( - ".m02-hopyo__lines .m02-hopyo__acts > *" in css - and "text-overflow" - not in css.split(".m02-hopyo__lines td:is(:nth-child(9)")[-1].split("}")[0] - ) + assert ".m02-hopyo__lines .m02-hopyo__acts > *" in css + assert "nth-child(9)" not in css # 359-3 — 칸 차례 대신 칸 이름 assert ".m02-hopyo-ledger__table td:nth-child(1)" in css assert "m02-hopyo-ledger__table" in _text("M02_MasterTemplete_Hopyo_Ledger.ts") + rows = _text("M02_MasterTemplete_Hopyo_CheckRows.ts") + assert r'text: compare.split(" · ").join("\n")' in rows # 품 비교 = 항목마다 한 줄 + assert "m02-hopyo__opts" in rows # 옵션 = 항목마다 한 줄 check = _text("M02_MasterTemplete_Hopyo_Check.ts") - assert r'td(opts.join("\n"))' in check and r'.split(" · ")' in check - assert r'.join("\n"),' in check # 품 비교 = 항목마다 한 줄(판정 글 함수는 그대로) make = check.split("async function make(")[1].split("function builtBlock(")[0] assert make.index("document.dispatchEvent(new Event(HOPYO_BUILT));") < make.index("opt.onBuilt") side = _text("M02_MasterTemplete_UI_Side.ts") @@ -431,3 +432,44 @@ def test_엑셀_보기_머리_막대_셈_끝까지_다시_읽음_359(): assert drop.index("deleteHopyo(") < drop.index("modal.close();") < drop.index("opt.onDeleted") poll = sheet.split("function poll(")[1].split("async function load(")[0] assert "paintHead(next);" in poll and "paintList();" in poll and "poll(next);" in poll + + +def test_확인_창_표_일위대가_상세_꼴_359(): + """359-3 — 로직 표 · 자재 표(일위대가 상세 꼴) + 품 비교 · 단추 · 드롭다운 글(저장 값 그대로) · + 금액 = 줄마다 서버 calc 미리 셈(`logic/` 문서 꼴 · Build 와 같은 수량 규칙).""" + got = _check()["draft"] + assert got["logic"] == { + "종류": "일위대가", + "열": "", + "단위": None, + "담은로직": [ # 같은 로직 둘째 · 호표 · 배 없음 = 뺌 + {"로직": "GC000198", "별칭": None, "메모": None, "입력값": {"소량감률": 50}, + "수량": {"손": 1}}, + ], + "자재": [], + } # fmt: skip + assert got["mat"]["자재"] == [ # 같은 자재 = 2 × 1 + 2 × 0.5 + {"코드": "MT1", "이름": None, "규격": None, "단위": None, "수량": {"손": 3}, + "별도계상": "별도계상"}, + ] # fmt: skip + assert got["direct"]["별도계상"] == "직접계산" and got["direct"]["수량"] == {"손": 0} + assert got["none"] == [None, None] + assert got["table"] == ["mat", "logic", "logic", "mat", "logic"] + rows = _text("M02_MasterTemplete_Hopyo_CheckRows.ts") + assert '["", "추가"],' in rows and '["우리에 없음", "로직없음"],' in rows + assert '["안 씀", "사용안함"],' in rows and "뺌 — " not in rows + for word in ('"고르기"', '"원문 보기"', '"엑셀 보기"', '"옵션"', '"품 비교"'): + assert word in rows, word + assert 'import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";' in rows + assert "...markCells({ 별도계상:" in rows and "...markHeads()," in rows + for head in ("Col_Order", "Sub", "Col_Name", "Col_Alias", "Labor_QtyCol", "Col_Unit"): + assert f'tc("{head}")' in rows, head + check = _text("M02_MasterTemplete_Hopyo_Check.ts") + assert "calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner)" in check + assert "checkTables(h.줄, ctx())" in check and "draftDoc(line, partsOf(line, d))" in check + assert "function lineRow(" not in check and "function partCell(" not in check + draft = _text("logic/M02_MasterTemplete_Hopyo_Draft.ts") + assert "M02_Hopyo_Build" in draft # 정본 짝 표시 + code = draft.split("*/", 1)[1] # 머리 글 밖 + for gone in ("document", "fetch(", "sessionStorage", "@ui/"): + assert gone not in code, gone From c67a39c266acc0eb29068bf02666db6626bd5b85 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:53:10 +0900 Subject: [PATCH 19/33] =?UTF-8?q?feat(B06):=20356-3=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=EC=A4=91=EC=8B=AC=EC=A0=90=20=EA=B3=A0=EB=A6=84=20=E2=80=94?= =?UTF-8?q?=20=E2=96=B2=E2=96=BC=20=EA=B3=84=ED=9A=8D=EA=B3=A0=20=C2=B7=20?= =?UTF-8?q?=E2=97=80=E2=96=B6=20=C2=B7=20=EA=B0=80=EB=A1=9C=20=EB=81=8C?= =?UTF-8?q?=EA=B8=B0=20=3D=20=EA=B7=B8=20=EC=B8=A1=EC=A0=90=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EC=A2=8C=EC=9A=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 중심점 = 보라 선 고름 끝 조각(판정 원 · 보라 선 위 · 다른 고름과 하나만) · 십자는 누름 통과 - 좌우 = 노선 편집기가 등록한 host(B05_Profile_UI_RouteEdit_Shift) — 0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김 - 끄는 동안 · 키를 잇는 동안 십자만 옮겨 보임 · 놓을 때 한 번 커밋 - 뒤 측점은 계산 묶음이 조용히 · 그 측점 계획고 = 옮기기 전 값(계획고 편집) · 노선 + 계획고 = 되돌리기 한 걸음 - B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움(design_page 등록) - 시험 test_356 · helper_356(노드 고름 · 뒤 측점 범위 · 높이 그대로 · 되돌리기 한 걸음 · 바로 열기) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_page.ts | 2 + B05_Profile/B05_Profile_UI_RouteEdit.ts | 2 + B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts | 99 ++++++++ B05_Profile/logic/B05_Profile_RouteShift.ts | 97 ++++++++ B06_Section/B06_Section_UI_Cross_Center.ts | 151 ++++++++++++ .../B06_Section_UI_Cross_Design_Pick.ts | 94 ++++++-- .../B06_Section_UI_Cross_Pick_Order.ts | 4 +- .../B06_Section_UI_Style_Cross_Areas.css | 18 ++ resources/tester/helper_356_center.cjs | 227 ++++++++++++++++++ resources/tester/test_356_b06_card_center.py | 66 +++++ 10 files changed, 733 insertions(+), 27 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts create mode 100644 B05_Profile/logic/B05_Profile_RouteShift.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Center.ts create mode 100644 resources/tester/helper_356_center.cjs diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 2d436080b..63fc84c16 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -31,6 +31,7 @@ import type { DesignView } from "./design_dock"; import { saveSharedDesign, type DesignPageShared } from "./design_shared"; import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; +import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; @@ -140,6 +141,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, }; guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4) + setRouteShiftOpener(() => shared.ensureView("profile"), showToast); // 중심점 좌우 = 편집기 숨긴 채(356-3) /** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */ const applyView = (view: DesignView): void => { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 224bbfb60..ffd9f085c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -89,6 +89,7 @@ import { } from "./B05_Profile_UI_RouteEdit_History"; import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; +import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -627,6 +628,7 @@ export async function mountRouteEditor( history = createRouteEditHistory(snapshotNow()); history = draftLink.watch(history, options.draft, () => stationSnap); draftLink.restore(options.draft, restoreSnapshot); + bindRouteShift(host, projectId, snapshotNow, applyEdit, () => [plannedLine, curveInfo]); // 356-3 historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts new file mode 100644 index 000000000..ff19d2ce2 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts @@ -0,0 +1,99 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Shift.ts + * 횡단 중심점 좌우 → 노선 편집(356-3) — B06 카드가 부르는 host(`setRouteShiftHost`)를 편집기가 등록. + * + * 한 번 = 노드 옮김 · 끼움 한 번(고름은 `logic/B05_Profile_RouteShift`) → 편집기 한 걸음(applyEdit) → + * 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 그 측점 계획고를 옮기기 전 값으로(바뀐 구간 첫 + * 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume). + * ========================================================================== */ + +import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import { pagePipeline, pipelineGrade } from "../A00_Common/design/design_pipeline_page"; +import { setRouteShiftHost } from "../B06_Section/B06_Section_UI_Cross_Center"; +import { pageHistory } from "./B05_Profile_UI_Profile_History"; +import type { RouteEditHost } from "./B05_Profile_UI_RouteEdit"; +import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; +import type { EditedCurve } from "./logic/B05_Profile_UI_RouteEdit_Curve"; +import { spliceNode } from "./logic/B05_Profile_UI_RouteEdit_Edits"; +import { + emptyEdits, + planElevationAt, + type AlignmentEdits, +} from "./logic/B05_Profile_UI_Profile_Alignment"; +import { pinStationHeight, planRouteShift } from "./logic/B05_Profile_RouteShift"; + +type Vertex = [number, number]; +/** 새 종단을 기다리는 한계(ms) — 넘으면 높이 보정 없이 한 걸음을 닫음. */ +const PROFILE_WAIT_MS = 10000; + +/** 다음 조작의 종단 단계(가장 늦은 차례)가 끝날 때 true · 한계를 넘으면 false. */ +function nextProfile(pipe: DesignPipeline): Promise { + return new Promise((resolve) => { + const done = (ok: boolean): void => { + clearTimeout(timer); + stop(); + resolve(ok); + }; + const timer = setTimeout(() => done(false), PROFILE_WAIT_MS); + const stop = pipe.subscribe((event) => { + if ( + event.kind === "stage" && + event.stage === "profile" && + event.generation === pipe.generation + ) + done(true); + }, "중심점 좌우"); + }); +} + +/** + * 편집기가 자료를 읽은 뒤 부름 — `columns` = 지금 편집값(산 배열 · 끼움 · 옮김이 그 자리를 고침) · + * `apply` = 편집기 한 걸음 · `drawn` = [그린 계획노선, 곡선표]. 편집기가 떠나면 host 를 뗌. + */ +export function bindRouteShift( + host: RouteEditHost, + projectId: string, + columns: () => RouteEditSnapshot, + apply: (message: string) => void, + drawn: () => [Vertex[], EditedCurve[]], +): void { + setRouteShiftHost(async (chainageM, deltaM) => { + const pipe = pagePipeline(projectId); + if (pipe && !pipe.output("profile")) await nextProfile(pipe); // 막 세운 묶음(B06 바로 열기) — 첫 셈 + const now = columns(); + const [line, curves] = drawn(); + const plan = planRouteShift({ + planned: now.planned, + line: line.length ? line : now.planned, + curves, + locked: now.apexLock, + chainageM, + deltaM, + }); + if (!plan) return "노선을 아직 못 읽었습니다."; + if (plan.kind === "locked") return "고정한 노드라 옮기지 않았습니다 — 고정을 먼저 푸세요."; + const profile = pipe?.output("profile"); + const zBefore = profile ? planElevationAt(profile, chainageM) : null; + const album = pageHistory(); + album?.pause(); + try { + const routed = pipe ? nextProfile(pipe) : null; + if (plan.kind === "move") now.planned[plan.index] = plan.to; + else spliceNode(now, plan.index, plan.at); + const side = deltaM > 0 ? "좌" : "우"; + apply(`횡단 중심점 — 측점 노선을 ${side}로 ${Math.abs(deltaM).toFixed(1)}m 옮겼습니다.`); + if (!pipe || zBefore === null || !(await routed)) return null; + const base = pipe.output("planline"); + const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits(); + const pinned = base && pinStationHeight(base, edits, chainageM, zBefore); + if (!pinned) return null; + const graded = nextProfile(pipe); + pipelineGrade(projectId, pinned); + await graded; + return null; + } finally { + album?.resume(); + } + }); + host.onDispose(() => setRouteShiftHost(null)); +} diff --git a/B05_Profile/logic/B05_Profile_RouteShift.ts b/B05_Profile/logic/B05_Profile_RouteShift.ts new file mode 100644 index 000000000..1b60f8505 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_RouteShift.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * B05_Profile_RouteShift.ts + * 횡단 중심점 좌우 이동(356-3) — 측점 자리 노선을 좌측 축으로 Δ m 옮길 노드 편집을 고르고 · 그 측점 + * 계획고를 옮기기 전 값으로 되돌릴 계획고 편집을 냄. + * + * · P = 측점 중심 · L = 좌측 단위 벡터(`stationFrameAt` · +offset = 좌) + * · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김) + * · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김) + * · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움 + * · 높이 = 옮기기 전 계획고 − 옮긴 뒤 계획고 만큼 그 측점 계획고 편집(`adjustStation`) + * + * 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음). + * ========================================================================== */ + +import { + adjustStation, + buildAlignment, + planElevationAt, + type AlignmentBase, + type AlignmentEdits, +} from "./B05_Profile_UI_Profile_Alignment"; +import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame"; + +type Vertex = [number, number]; + +/** 측점 중심에서 이 안의 노드는 끼우지 않고 옮김(m). */ +export const NODE_SNAP_M = 0.5; +/** 이보다 작은 높이 차는 보정 안 함(m). */ +export const PIN_TOLERANCE_M = 5e-4; + +export type RouteShiftPlan = + | { kind: "move"; index: number; to: Vertex } + | { kind: "insert"; index: number; at: Vertex } + | { kind: "locked"; index: number }; + +export interface RouteShiftInput { + /** 노드(꺾임점). */ + planned: ReadonlyArray; + /** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */ + line: ReadonlyArray; + /** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */ + curves: ReadonlyArray; + /** 노드마다 고정. */ + locked: ReadonlyArray; + chainageM: number; + /** 좌(+) · 우(−) 이동(m). */ + deltaM: number; +} + +/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */ +function nearestSegment(nodes: ReadonlyArray, point: Vertex): number { + let best = 0; + let bestGap = Infinity; + for (let i = 0; i < nodes.length - 1; i += 1) { + const [ax, ay] = nodes[i]; + const [vx, vy] = [nodes[i + 1][0] - ax, nodes[i + 1][1] - ay]; + const length2 = vx * vx + vy * vy || 1; + const t = Math.min(1, Math.max(0, ((point[0] - ax) * vx + (point[1] - ay) * vy) / length2)); + const gap = Math.hypot(ax + vx * t - point[0], ay + vy * t - point[1]); + if (gap < bestGap) [best, bestGap] = [i, gap]; + } + return best; +} + +/** 노드 편집 고름 — 노선이 없으면 null. */ +export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null { + const { planned, line, curves, locked, chainageM, deltaM } = input; + const frame = planned.length >= 2 ? stationFrameAt(line, chainageM) : null; + if (!frame) return null; + const { origin, left } = frame; + const shifted = ([x, y]: Vertex): Vertex => [x + left[0] * deltaM, y + left[1] * deltaM]; + const move = (index: number): RouteShiftPlan => + locked[index] + ? { kind: "locked", index } + : { kind: "move", index, to: shifted(planned[index]) }; + const near = planned.findIndex( + ([x, y]) => Math.hypot(x - origin[0], y - origin[1]) <= NODE_SNAP_M, + ); + if (near >= 0) return move(near); + const curve = curves.find((one) => { + const [span] = curveSpans(line, [one]); + return span && chainageM > span[0] && chainageM < span[1]; + }); + if (curve && planned[curve.node_first]) return move(curve.node_first); + return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) }; +} + +/** 그 측점 계획고를 `zBefore` 로 되돌린 계획고 편집 — 차가 없으면 null. */ +export function pinStationHeight( + base: AlignmentBase, + edits: AlignmentEdits, + chainageM: number, + zBefore: number, +): AlignmentEdits | null { + const drift = zBefore - planElevationAt(buildAlignment(base, edits), chainageM); + return Math.abs(drift) < PIN_TOLERANCE_M ? null : adjustStation(base, edits, chainageM, drift); +} diff --git a/B06_Section/B06_Section_UI_Cross_Center.ts b/B06_Section/B06_Section_UI_Cross_Center.ts new file mode 100644 index 000000000..e97f7a88c --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Center.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Center.ts + * 횡단 카드 중심점 고름(356-3) — 보라 선 고름(`_Cross_Design_Pick`)의 끝 조각(키 `center`). + * + * · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만) + * · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로 + * · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄 + * · 좌우 = 노선 편집기가 등록한 host(`setRouteShiftHost` · B05 `_RouteEdit_Shift`) — B06 을 바로 열어 + * 편집기가 없으면 종단 보기를 숨긴 채 세움(`setRouteShiftOpener` · 페이지가 닮 · 알림도 페이지 것) + * ========================================================================== */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 판정 원 클래스 — 고르기 차례가 보라 선 위로 올림. */ +export const CENTER_HIT_CLASS = "b06-chart__center-hit"; +/** 판정 원 반지름(px). */ +const HIT_RADIUS_PX = 8; +/** 좌우 걸음(m). */ +export const CENTER_STEP_M = 0.1; +/** 키를 멈추고 이만큼 지나면 보냄(ms) — 잇단 누름 = 한 번. */ +const KEY_SETTLE_MS = 450; +/** 편집기를 기다리는 한계(ms) — 종단 보기 · 노선 자료 읽기. */ +const HOST_WAIT_MS = 20000; + +/** 측점 · 좌(+)우(−) m → 못 옮긴 까닭(null = 옮김). */ +export type RouteShiftHost = (chainageM: number, deltaM: number) => Promise; + +let host: RouteShiftHost | null = null; +const waiters = new Set<(ready: RouteShiftHost | null) => void>(); +let opener: (() => Promise) | null = null; +type Notice = (text: string, kind: "info" | "error") => void; +let notice: Notice = () => undefined; + +/** 노선 편집기가 단다(떠나면 null). */ +export function setRouteShiftHost(next: RouteShiftHost | null): void { + host = next; + if (!next) return; + waiters.forEach((resolve) => resolve(next)); + waiters.clear(); +} + +/** 편집기를 세우는 길 · 알림 — 페이지가 단다(종단 보기 숨긴 채 세움 · 토스트). */ +export function setRouteShiftOpener(next: (() => Promise) | null, toast?: Notice): void { + opener = next; + if (toast) notice = toast; +} + +async function readyHost(): Promise { + if (host) return host; + const waiting = new Promise((resolve) => { + waiters.add(resolve); + setTimeout(() => (waiters.delete(resolve), resolve(null)), HOST_WAIT_MS); + }); + await opener?.().catch(() => undefined); + return host ?? waiting; +} + +/** 그 측점 노선을 좌우로 — 편집기가 없으면 세워서. 옮겼으면 true. */ +export async function shiftRouteAt(chainageM: number, deltaM: number): Promise { + const ready = await readyHost(); + if (!ready) { + notice("노선 편집기를 못 열어 좌우로 옮기지 못했습니다.", "error"); + return false; + } + const problem = await ready(chainageM, deltaM); + if (problem) notice(problem, "info"); + return !problem; +} + +/** 판정 원(중심 십자 자리). */ +export function centerHit(cx: number, cy: number): SVGCircleElement { + const hit = document.createElementNS(SVG_NS, "circle"); + hit.setAttribute("cx", String(cx)); + hit.setAttribute("cy", String(cy)); + hit.setAttribute("r", String(HIT_RADIUS_PX)); + hit.setAttribute("class", CENTER_HIT_CLASS); + return hit; +} + +const snapStep = (valueM: number): number => + Math.round(Math.round(valueM / CENTER_STEP_M) * CENTER_STEP_M * 10) / 10; + +export interface CenterShift { + /** 화면 ◀(−1) · ▶(+1) 한 걸음 — 멈추면 모아 한 번 보냄. */ + step: (screenDir: -1 | 1) => void; +} + +/** + * 좌우 이동 배선 — `kx` = 화면 x / offset(m) 기울기 · `active` = 중심점을 골랐나 · `preview` = 미리 볼 + * 이동(m · 0 = 지움). 끌기는 고른 뒤에만(안 고른 원을 끌면 카드 팬 그대로). + */ +export function bindCenterShift(input: { + hit: SVGElement; + layer: SVGGraphicsElement; + chainageM: number; + kx: number; + active: () => boolean; + preview: (deltaM: number) => void; +}): CenterShift { + const { hit, layer, chainageM, kx, active } = input; + const preview = (deltaM: number): void => { + const shift = deltaM ? `${deltaM * kx}px 0px` : ""; + for (const el of [hit, layer.querySelector("g.b06-chart__center")]) + el?.style.setProperty("translate", shift); + input.preview(deltaM); + }; + const send = (deltaM: number): void => { + if (!deltaM) return preview(0); + void shiftRouteAt(chainageM, deltaM).then((moved) => moved || preview(0)); + }; + hit.addEventListener("pointerdown", (event) => { + const ctm = layer.getScreenCTM(); + if (event.button !== 0 || !active() || !ctm) return; + event.preventDefault(); + event.stopPropagation(); // 카드 팬으로 안 번짐 + const startX = event.clientX; + let deltaM = 0; + const move = (next: PointerEvent): void => { + deltaM = snapStep((next.clientX - startX) / (ctm.a * kx)); + preview(deltaM); + }; + const up = (): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", up); + send(deltaM); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", up); + }); + let pending = 0; + let timer = 0; + return { + step: (screenDir) => { + pending = snapStep(pending + screenDir * Math.sign(kx) * CENTER_STEP_M); + preview(pending); + clearTimeout(timer); + timer = window.setTimeout(() => { + const deltaM = pending; + pending = 0; + send(deltaM); + }, KEY_SETTLE_MS); + }, + }; +} + +/** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */ +export function centerPreviewText(deltaM: number): string { + if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우"; + return `노선 ${deltaM > 0 ? "좌" : "우"} ${Math.abs(deltaM).toFixed(1)}m — 놓으면 반영`; +} diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 308d96a8c..217f576d1 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -26,6 +26,7 @@ import { dpadButton, makeRow, } from "./B06_Section_UI_Cross_Panel_Base"; +import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center"; const SVG_NS = "http://www.w3.org/2000/svg"; const DESIGN_CLASS = "b06-chart__design-cross"; @@ -111,8 +112,9 @@ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => * 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */ export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis => isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x"; -/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */ -export type MoveAxis = "x" | "y" | "depth"; +/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1) · + * center = 중심점(▲▼ 종단 계획고 · ◀▶ 노선 좌우 · 356-3). */ +export type MoveAxis = "x" | "y" | "depth" | "center"; /** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */ export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number => @@ -345,7 +347,7 @@ function tellBermHost(chainageM: number | null): void { export function designLineMoves( section: Pick, onDesignChange?: (chainageM: number, change: CrossDesignChange) => void, -): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } { +): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number } { const design = section.design; if (!onDesignChange || !design) return { stationId: section.station_id }; const send = (change: Partial) => @@ -355,6 +357,7 @@ export function designLineMoves( }); return { stationId: section.station_id, + center: section.chainage_m, // 중심점 좌우 = 이 측점 노선(356-3) shift: { valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0, set: (side, valueM) => send({ shoulder_extend: { side, valueM } }), @@ -395,6 +398,8 @@ export function attachDesignLinePick(input: { berm?: BermDepthControl; /** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */ stationId?: string; + /** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */ + center?: number; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; const x0 = x(0); @@ -417,25 +422,38 @@ export function attachDesignLinePick(input: { ) .map((el) => pointsOf(el).map(toM)); const pieces = visiblePieces(mergeStraightPieces(runs), occluders); - if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces)); + const edges = design.road_edges; + const centerZ = + input.center === undefined || !edges || edges.left.offset_m === edges.right.offset_m + ? null + : edges.right.elevation_m - + ((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) / + (edges.left.offset_m - edges.right.offset_m); + const keys = [...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"])]; + if (input.stationId) keysByStation.set(input.stationId, keys); // 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1). const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); - const hits = pieces.map((piece, index) => { + const hits: SVGElement[] = pieces.map((piece) => { const hit = document.createElementNS(SVG_NS, "polyline"); hit.setAttribute( "points", [piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), ); hit.setAttribute("class", HIT_CLASS); + return hit; + }); + // 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`) + const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ)); + if (center) hits.push(center); + hits.forEach((hit, index) => { hit.addEventListener("click", (event) => { event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음 input.onPick(index); }); layer.insertBefore(hit, firstStructureHit ?? null); - return hit; }); const label = document.createElement("span"); @@ -455,7 +473,19 @@ export function attachDesignLinePick(input: { readout.className = "b06-structure-panel__value"; const move = makeRow("이동"); move.controls.classList.add("b06-structure-panel__buttons"); + const centerShift = + center && input.center !== undefined + ? bindCenterShift({ + hit: center, + layer: layer as SVGGraphicsElement, + chainageM: input.center, + kx, + active: () => axis === "center", + preview: (deltaM) => (readout.textContent = centerPreviewText(deltaM)), + }) + : null; const step = (dir: -1 | 1) => () => { + if (axis === "center") return centerShift?.step(dir); if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx)); }; move.controls.append( @@ -521,36 +551,48 @@ export function attachDesignLinePick(input: { label, panel: shell.root, setActive: (index) => { + const onCenter = !!centerShift && index === pieces.length; const piece = index === null ? undefined : pieces[index]; - hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index)); - label.hidden = !piece; - active = piece ? index : null; + hits.forEach((hit, i) => + hit.classList.toggle("is-active", (!!piece || onCenter) && i === index), + ); + label.hidden = !piece && !onCenter; + active = piece || onCenter ? index : null; side = piece ? pieceZone(design, piece).side : null; - axis = piece ? pieceMoveAxis(design, piece) : "x"; + axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x"; const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0; - label.textContent = piece - ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") - : ""; - const open = !!(piece && (axis === "depth" ? berm : shift)); + label.textContent = onCenter + ? "중심점" + : piece + ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") + : ""; + const open = onCenter || !!(piece && (axis === "depth" ? berm : shift)); shell.root.classList.toggle("is-hidden", !open); // 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단) - move.row.classList.toggle("is-hidden", axis !== "x"); - move.controls.classList.toggle("is-hidden", axis !== "x"); + // 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3) + const sideways = axis === "x" || axis === "center"; + move.row.classList.toggle("is-hidden", !sideways); + move.controls.classList.toggle("is-hidden", !sideways); + move.controls.children[1]?.classList.toggle("is-hidden", axis === "center"); lift.row.classList.toggle("is-hidden", axis !== "depth"); lift.controls.classList.toggle("is-hidden", axis !== "depth"); const sideName = side === "left" ? "(좌)" : "(우)"; shell.title.textContent = - axis === "y" - ? "노면 높이" - : axis === "depth" - ? `소단 높이 ${sideName}` - : `사면 이동 ${sideName}`; + axis === "center" + ? "중심점" + : axis === "y" + ? "노면 높이" + : axis === "depth" + ? `소단 높이 ${sideName}` + : `사면 이동 ${sideName}`; readout.textContent = - axis === "y" - ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" - : axis === "depth" && side - ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` - : `이동 ${extend.toFixed(1)}m`; + axis === "center" + ? centerPreviewText(0) + : axis === "y" + ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" + : axis === "depth" && side + ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` + : `이동 ${extend.toFixed(1)}m`; berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2) arrows.detach(); document.removeEventListener("keydown", blockOffAxis); diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts index 63febf573..b688e670f 100644 --- a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { thickenDown } from "./B06_Section_UI_Cross_Shape_Pick"; +import { CENTER_HIT_CLASS } from "./B06_Section_UI_Cross_Center"; /** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */ const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; @@ -69,7 +70,8 @@ export function stackPickTargets(layer: SVGElement): void { .sort((a, b) => b.area - a.area) .map(({ el }) => el); const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); - layer.append(...surfaces, ...edges, ...lines); + const centers = kids.filter((el) => el.classList.contains(CENTER_HIT_CLASS)); // 중심점 = 맨 위(356-3) + layer.append(...surfaces, ...edges, ...lines, ...centers); let down: { x: number; y: number } | null = null; layer.addEventListener( "pointerdown", diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 0fc23a43b..b3fff596b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -175,6 +175,24 @@ stroke-width: 7; } +/* 중심점 고름(356-3) — 십자 자리 투명 원 · 고르면 보라 테두리 · 끌면 좌우. 십자는 누름을 원으로 넘김. */ +.b06-chart__center { + pointer-events: none; +} + +.b06-chart__center-hit { + fill: transparent; + stroke: transparent; + stroke-width: 2; + cursor: pointer; +} + +.b06-chart__center-hit.is-active { + fill: color-mix(in srgb, var(--color-royal-amethyst) 18%, transparent); + stroke: var(--color-royal-amethyst); + cursor: ew-resize; +} + /* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ .b06-chart__carriageway-tick { stroke: var(--color-royal-amethyst); diff --git a/resources/tester/helper_356_center.cjs b/resources/tester/helper_356_center.cjs new file mode 100644 index 000000000..4c36e2ef2 --- /dev/null +++ b/resources/tester/helper_356_center.cjs @@ -0,0 +1,227 @@ +/* PLAN 356-3 단위검증 헬퍼 — 횡단 중심점 좌우 = 그 측점 노선 좌우. + * 진짜 TS(`logic/B05_Profile_RouteShift` · `B05_Profile_UI_RouteEdit_Shift` · `B06_Section_UI_Cross_Center` · + * 페이지 사진첩 `B05_Profile_UI_Profile_History`)를 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 계산 묶음은 흉내(노선이 바뀌면 바뀐 구간 첫 계획선을 새로 맞춤 = 그 측점 계획고가 바뀜) · 짝 = `test_356_b06_card_center.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const session = new Map(); +globalThis.sessionStorage = { + get length() { + return session.size; + }, + key: (i) => [...session.keys()][i] ?? null, + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; + +const toasts = []; +let pipe = null; +const graded = []; +const STUBS = { + "../A00_Common/design/design_pipeline_page": { + pagePipeline: () => pipe, + pipelineGrade: (_id, edits) => (graded.push(edits), pipe.grade(edits)), + }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const near = (request) => + (CATALOG.isCatalogRequest(request) ? CATALOG : null) ?? + STUBS[request] ?? + (request.startsWith("@util/") + ? loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)) + : request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request)); + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const B05 = path.join(ROOT, "B05_Profile"); +const shift = loadTs(path.join(B05, "logic", "B05_Profile_RouteShift.ts")); +const align = loadTs(path.join(B05, "logic", "B05_Profile_UI_Profile_Alignment.ts")); +const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts")); +const grade = loadTs(path.join(B05, "logic", "B05_Profile_Pipeline_Grade.ts")); +const history = loadTs(path.join(B05, "B05_Profile_UI_Profile_History.ts")); +const editShift = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Shift.ts")); +const center = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Center.ts")); + +const out = {}; + +// ── 노드 고름 — 직선 끼움 · 0.5 m 안 노드 옮김 · 고정 · 곡선 안 = 교각점 ── +const straight = [ + [0, 0], + [100, 0], +]; +const plan = (planned, line, curves, locked, chainageM, deltaM) => + shift.planRouteShift({ planned, line, curves, locked, chainageM, deltaM }); +out.insert = plan(straight, straight, [], [true, true], 40, 1); +const kinked = [ + [0, 0], + [40.3, 0], + [100, 0], +]; +out.near = plan(kinked, kinked, [], [true, false, true], 40, -0.5); +out.nearLocked = plan(kinked, kinked, [], [true, true, true], 40, -0.5); +const bent = [ + [0, 0], + [60, 0], + [60, 60], +]; +const built = curve.buildEditedPolyline(bent, [true, true, true], [null, 30, null], 30); +const bc = built.curves[0].start; +const tangentIn = Math.hypot(bc[0], bc[1]); // 시점 ~ BC(첫 토막 위) +out.curveHit = plan(bent, built.vertices, built.curves, [true, false, true], tangentIn + 5, 1); +out.curveApex = bent[1]; + +// ── 뒤 측점 다시 셈 — 끼운 뒤 바뀐 범위 = 끼운 노드 앞 노드부터 끝까지 ── +const after = [straight[0], out.insert.at, straight[1]]; +out.range = grade.routeRange(straight, after); + +// ── 높이 그대로 · 되돌리기 한 걸음 · B06 바로 열기 — 편집기 host + 흉내 묶음 ── +const policy = { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 20, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, +}; +const stations = [0, 20, 40, 60, 80, 100]; +const baseOf = (pvi) => ({ + policy, + onlyExplicitCurves: true, + basePvi: pvi.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m })), + chainage: stations, + ground: stations.map(() => 100), + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}); +const savedBase = baseOf([ + [0, 100], + [100, 110], +]); +// 노선이 바뀌면 바뀐 구간 첫 계획선을 새 지반에 다시 맞춤 — 40 m 계획고가 104 → 102 쪽으로 쓸림 +const refitBase = baseOf([ + [0, 100], + [60, 102.5], + [100, 110], +]); +let generation = 0; +const listeners = new Set(); +const state = { base: savedBase, edits: align.emptyEdits() }; +const emit = () => { + const gen = generation; + setTimeout(() => { + for (const fn of [...listeners]) fn({ kind: "stage", stage: "profile", generation: gen }); + }, 5); +}; +pipe = { + get generation() { + return generation; + }, + subscribe: (fn) => (listeners.add(fn), () => listeners.delete(fn)), + output: (name) => + name === "planline" + ? state.base + : name === "profile" + ? align.buildAlignment(state.base, state.edits) + : null, + input: (key) => (key === "alignment" ? state.edits : null), + route(planned) { + state.base = refitBase; + state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림 + sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned)); + sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits)); + generation += 1; + emit(); + }, + grade(edits) { + state.edits = edits; + sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(edits)); + generation += 1; + emit(); + }, +}; + +const album = history.createProfileHistory(() => undefined); +// 편집기 흉내 — 사진(산 배열) · 한 걸음(사진첩에 쌓고 묶음에 노선을 넘김) +const editor = { + planned: straight.map((p) => [...p]), + curveOn: [true, true], + curveRadius: [null, null], + curveLock: [null, null], + curveArc: [null, null], + apexLock: [true, true], + picked: -1, +}; +const applied = []; +const disposers = []; +const host = { onDispose: (fn) => disposers.push(fn) }; +const mountEditor = () => + editShift.bindRouteShift( + host, + "p", + () => editor, + (message) => { + applied.push(message); + album.record(); // 사진첩이 멈춰 있으면 안 쌓임 — 끝에 한 번 + pipe.route(editor.planned); + }, + () => [editor.planned, []], + ); + +(async () => { + const zBefore = align.planElevationAt(pipe.output("profile"), 40); + const sessionBefore = JSON.stringify([...session.entries()]); + // B06 바로 열기 — host 없음 → 페이지가 단 길(종단 보기 숨긴 채 세움)이 편집기를 세움 + let opened = 0; + center.setRouteShiftOpener( + async () => { + opened += 1; + setTimeout(mountEditor, 20); + }, + (text, kind) => toasts.push([text, kind]), + ); + out.moved = await center.shiftRouteAt(40, 1); + out.opened = opened; + out.planned = editor.planned; + out.applied = applied; + out.graded = graded.length; + out.zBefore = zBefore; + out.zAfter = align.planElevationAt(pipe.output("profile"), 40); + out.zRefit = align.planElevationAt(align.buildAlignment(refitBase, align.emptyEdits()), 40); + out.canUndo = album.canUndo(); + album.undo(); + out.undoOne = JSON.stringify([...session.entries()]) === sessionBefore; + out.canUndoAfter = album.canUndo(); + // 고정 노드 — 안 옮기고 까닭 알림(되돌리기 안 쌓임) + editor.planned = kinked.map((p) => [...p]); + editor.apexLock = [true, true, true]; + const lockedMoved = await center.shiftRouteAt(40, -0.5); + out.locked = { moved: lockedMoved, toast: toasts.at(-1), planned: editor.planned }; + disposers.forEach((fn) => fn()); + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_356_b06_card_center.py b/resources/tester/test_356_b06_card_center.py index 4f7fd6684..9455940de 100644 --- a/resources/tester/test_356_b06_card_center.py +++ b/resources/tester/test_356_b06_card_center.py @@ -2,12 +2,23 @@ 못박는 것 - 356-2 카드 초점 없음 — 마우스로 고른 뒤 키를 눌러도 초점 테두리(다른 색) 안 뜸 · ▲▼ 계획고는 창 듣개. + - 356-3 중심점 = 보라 선 고름 끝 조각(판정 원 · 노면 판정보다 위 · 다른 고름과 하나만) · ▲▼ = 종단 계획고 · + ◀▶ · 가로 끌기 = 그 측점 노선 좌우(0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김) · + 놓을 때 한 번 · 뒤 측점은 묶음이 다시 셈(바뀐 범위 = 앞 노드 ~ 끝) · 그 측점 계획고 그대로 · 되돌리기 한 걸음 · + B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움. + +진짜 TS 는 `helper_356_center.cjs` 가 Node 로 돌림. """ from __future__ import annotations +import json +import subprocess from pathlib import Path +import pytest + +HERE = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parents[2] B06 = ROOT / "B06_Section" @@ -17,3 +28,58 @@ def test_카드_초점_없음(): assert "card.tabIndex" not in view assert 'card.addEventListener("keydown"' not in view assert 'card.addEventListener("click", () => onSelect(section.station_id));' in view + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_356_center.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_노드_고름_끼움_옮김_고정_교각점(ts): + assert ts["insert"] == { + "kind": "insert", + "index": 1, + "at": [40, 1], + } # 직선 = 측점 자리 + 좌 1 m 끼움 + assert ts["near"] == {"kind": "move", "index": 1, "to": [40.3, -0.5]} # 0.5 m 안 노드 = 옮김 + assert ts["nearLocked"] == {"kind": "locked", "index": 1} # 고정 노드 = 안 옮김 + hit = ts["curveHit"] + assert hit["kind"] == "move" and hit["index"] == 1 # 곡선 안 = 그 교각점 + ax, ay = ts["curveApex"] + assert ((hit["to"][0] - ax) ** 2 + (hit["to"][1] - ay) ** 2) ** 0.5 == pytest.approx(1.0) + + +def test_뒤_측점은_묶음이_다시_셈(ts): + assert ts["range"] == {"fromM": 0, "toM": None} # 앞 노드 ~ 끝(Infinity) + + +def test_높이_그대로_되돌리기_한_걸음_바로_열기(ts): + assert ts["opened"] == 1 and ts["moved"] is True # 편집기 없음 → 종단 보기 숨긴 채 세움 + assert ts["planned"] == [[0, 0], [40, 1], [100, 0]] + assert len(ts["applied"]) == 1 # 편집기 한 걸음 + assert ts["zRefit"] != pytest.approx(ts["zBefore"], abs=1e-3) # 묶음 다시 맞춤이면 쓸림 + assert ts["graded"] == 1 and ts["zAfter"] == pytest.approx(ts["zBefore"], abs=1e-3) + assert ts["canUndo"] and ts["undoOne"] and not ts["canUndoAfter"] # 노선 + 계획고 = 한 걸음 + locked = ts["locked"] + assert locked["moved"] is False and locked["toast"][1] == "info" + assert locked["planned"] == [[0, 0], [40.3, 0], [100, 0]] + + +def test_배선(): + pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8") + assert "center: section.chainage_m," in pick # designLineMoves → 카드 고름 끝 조각 + assert 'if (axis === "center") return centerShift?.step(dir);' in pick + order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8") + assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order + edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert "bindRouteShift(host, projectId, snapshotNow, applyEdit," in edit + page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8") + assert 'setRouteShiftOpener(() => shared.ensureView("profile"), showToast);' in page From 6312671ab800675bb4cc7140c401fa17363b2a27 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:55:21 +0900 Subject: [PATCH 20/33] =?UTF-8?q?feat(M02):=20359-4=20=EC=8B=A4=EB=AC=B4?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=EB=8B=A8=EC=B6=94=20=EB=AA=85=EC=82=AC?= =?UTF-8?q?=ED=98=95=20=C2=B7=20=EB=B3=84=EC=B9=AD=20=EC=9E=A5=EB=B6=80=20?= =?UTF-8?q?=ED=92=80=EC=9D=B4=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [맞음 · 별칭 모두 체크] → [맞음 · 별칭 일괄 체크] · 머리 글 [엑셀 보기] - 시험: 별칭 장부 뜻 한 줄 · 좌측 단추 title · 장부 단추 명사형 · 갈래 글 · 옛 서술형 글 없음(내 몫 다섯 파일) - 로케일 단추 글(M02_New · M02_Reload · M02_PracticeDrop 등)은 sub6 몫으로 손 안 댐 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../M02_MasterTemplete_Hopyo_Check.ts | 4 +-- .../M02_MasterTemplete_Hopyo_Sheet.ts | 4 +-- resources/tester/test_m02_hopyo_screen.py | 30 +++++++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index 4af902735..d6e2fa8a1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -182,7 +182,7 @@ export function newest(): (key: number) => () => boolean { }; } -/** [맞음 · 별칭 모두 체크] 대상 줄 */ +/** [맞음 · 별칭 일괄 체크] 대상 줄 */ export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] => lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d)); @@ -484,7 +484,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { const tools: HTMLElement[] = []; if (opt.canWrite) { const sure = sureLines(h.줄, d); - const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" }); + const all = createButton({ label: `맞음 · 별칭 일괄 체크 (${sure.length})`, size: "sm" }); all.disabled = !sure.length; all.addEventListener("click", () => { for (const l of sure) d.체크[l.차례] = true; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index d4902b1ca..892e71415 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -62,9 +62,9 @@ export interface HopyoSheetOptions { canWrite: boolean; /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ target?: HopyoScope; - /** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */ + /** 처음 고를 호표 번(확인 창 머리 [엑셀 보기]) */ 호표?: number; - /** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */ + /** 처음 보일 단가산출 행(330-3 근거 [엑셀 보기]) */ 단산행?: number | [number, number]; /** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */ onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void; diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index ca913fc01..ca7514377 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -126,7 +126,7 @@ def test_확인_창_체크한_줄만_몸(): ] assert got["leftOut"] == {"확인 안 함": 3, "뺌": 1, "% 줄": 1} assert got["checkable"] == [True, True, True, True, False, False, True, False] - assert got["sure"] == [1, 2] # [맞음 · 별칭 모두 체크] + assert got["sure"] == [1, 2] # [맞음 · 별칭 일괄 체크] assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠" assert got["compareMany"] == "굴착기 0.1594 → 0.0889 ≠" # 같은 장비 여러 줄 = 합쳐 한 줄 assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}] @@ -283,7 +283,7 @@ def test_옛_즉시_확정과_바로_만들기_없음(): users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤 check = texts["Hopyo_Check"] - assert '"사전조합 생성"' in check and "맞음 · 별칭 모두 체크" in check + assert '"사전조합 생성"' in check and "맞음 · 별칭 일괄 체크" in check assert "확인하고" not in check # 359-4 명사형 assert "showLoadingOverlay()" in check and "dropDraft(k)" in check assert "m02-hopyo-check:" in check and "POLL_MS" in check @@ -473,3 +473,29 @@ def test_확인_창_표_일위대가_상세_꼴_359(): code = draft.split("*/", 1)[1] # 머리 글 밖 for gone in ("document", "fetch(", "sessionStorage", "@ui/"): assert gone not in code, gone + + +def test_명사형_단추와_별칭_장부_풀이_359(): + """359-4 — 별칭 장부 뜻 한 줄(좌측 단추 title · 화면 머리) · M02 실무자료 단추 = 명사형 · + 갈래 글 = 확인 창 드롭다운과 같음(저장 값 그대로).""" + ledger = _text("M02_MasterTemplete_Hopyo_Ledger.ts") + hint = "엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안" + assert hint in ledger and "note(`${LEDGER_HINT}" in ledger + side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert "title: LEDGER_HINT" in side + for label in ('"변경 취소"', '"로직 추가"', '"내 층 복사"', '"삭제"'): + assert f"label: {label}" in ledger, label + assert '"우리에 없음": "로직없음"' in ledger and '"안 씀": "사용안함"' in ledger + mine = ("Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Ledger") + every = "\n".join(_text(f"M02_MasterTemplete_{n}.ts") for n in mine) + for gone in ( + "고친 것 버림", + "로직 더함", + "내 층으로 고쳐 씀", + "확인하고 사전조합 만들기", + "엑셀 올리기", + "엑셀에서 보기", + "모두 체크 (", + 'label: "지우기"', + ): + assert gone not in every, gone From 8c562812da0dc078bd8a2a67d93fcbda5b25b4b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:55:48 +0900 Subject: [PATCH 21/33] =?UTF-8?q?feat(M02=20=EC=8B=A4=EB=AC=B4=EC=9E=90?= =?UTF-8?q?=EB=A3=8C):=20358-1=20=ED=9B=84=EB=B3=B4=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=20=E2=80=94=20=EC=9D=B4=EB=A6=84=ED=9B=84=EB=B3=B4(=EC=98=9B?= =?UTF-8?q?=20324=20=EB=8B=A4=EC=84=AF)=20=C2=B7=20=EB=9C=BB=EA=B2=B9?= =?UTF-8?q?=EC=B9=A8=20=C2=B7=20=EC=B6=9C=EC=B2=98=20=EA=B0=99=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=B1=85=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=B9=B8=20=3D=20=ED=92=88=EB=AA=85=20?= =?UTF-8?q?=EB=82=B1=EB=A7=90=20=C2=B7=20=EC=9D=BC=EA=B4=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9(N)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 줄 맞춤에 이름후보(옛 데스크탑 판과 같음 · 600줄 대조 0 다름) · 후보마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 — 차례 · 무게 · 상태 그대로 - 고르기 창 = 1위 / 출처 같음 / 이름 닮은 로직 / 숫자만 닮음(접힘 · 뜻 안 겹치는 후보 회색 끝) · 같은 이름 책 표시 - 버튼 명사형 — 일괄 적용(N) · 엑셀 보기 · 다른 로직 검색 · 재료비 있는 줄은 다른 자재 검색도 - 시험: match 이름후보 다섯 · 되메우기 · 다웰바 · golden 다섯 안 ∪ 이름후보 · screen 묶음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Line.py | 28 +++- M02_MasterTemplete/M02_Hopyo_Match.py | 24 ++- .../M02_MasterTemplete_Hopyo.css | 11 ++ .../M02_MasterTemplete_Hopyo_Api.ts | 6 + .../M02_MasterTemplete_Hopyo_Choose.ts | 143 ++++++++++++++---- resources/tester/helper_m02_hopyo_check.cjs | 36 +++++ resources/tester/test_m02_hopyo_golden.py | 15 ++ resources/tester/test_m02_hopyo_match.py | 23 +++ resources/tester/test_m02_hopyo_screen.py | 11 +- 9 files changed, 263 insertions(+), 34 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 60a842428..651edd4b3 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -11,6 +11,8 @@ 장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보 끝 덤만. 자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보. +로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 · +책 — 차례 · 무게 · 상태는 그대로. 입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값. 체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독. 장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창). @@ -214,15 +216,35 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A if alias and alias.get("갈래") in SKIP: return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)} if alias and alias.get("갈래") == "짝" and alias.get("짝"): - return {**out, **_aliased(line, ctx, alias, entry)} - if kind in hm.MATERIAL_KINDS: + got = _aliased(line, ctx, alias, entry) + elif kind in hm.MATERIAL_KINDS: got = _material(line, ctx, full) elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"): got = _numeric(line, ctx, entry) if full else _brief(entry) else: why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER got = _names_only(line, ctx, why, full) - return {**out, **(_loose(got, loose, line, ctx) if loose and full else got)} + got = _loose(got, loose, line, ctx) if loose and full else got + return {**out, **(_grouped(got, line, ctx) if full and kind not in hm.MATERIAL_KINDS else got)} + + +def _grouped(got: dict, line: dict, ctx: Ctx) -> dict: + """묶음 표시(358-1) — 후보 · 이름후보(옛 324 다섯)마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 · + 차례 · 무게 · 상태 그대로(아무것도 숨기지 않음 · 묶음은 화면 몫).""" + mine = hm.words(line.get("이름")) + theirs = hm.fix_typo(((line.get("근거") or {}).get("출처")) or []) + table = hopyo_basis.ours() + + def mark(c: dict) -> dict: + one, our = ctx.names.get(c.get("로직")), table.get(c.get("로직")) + if one is None: + return c + same = bool(theirs and our) and hm.clause(theirs, our)[0] >= 0.9 + book = (our or {}).get("책", "") + return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book} + + named = [mark(c) for c in hm.name_cands(line, ctx.got)] + return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named} def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict: diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 3bc65074d..5f77d3b4e 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -292,10 +292,14 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st def candidates( - line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5 + line: dict[str, Any], + pool: str, + got: dict[str, Any] | None = None, + limit: int | None = 5, + fold: bool = False, ) -> list[dict[str, Any]]: """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit` - (None = 모두).""" + (None = 모두) · `fold` = 로직도 (이름, 규격, 단위)로 접음(옛 324 꼴).""" me = tidy(line.get("이름")) grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") @@ -313,7 +317,8 @@ def candidates( scored.sort(key=lambda x: -x[0]) out = [] for score, one in scored: - label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음 + label = (one["이름"], one.get("규격"), one["단위"]) + label = label if fold else one.get("로직") or label # 로직은 접지 않음(fold 아니면) if label in seen: continue seen.add(label) @@ -323,6 +328,19 @@ def candidates( return out +def name_cands(line: dict[str, Any], got: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """이름 닮은 로직 다섯(358-1 · 옛 324 데스크탑 판 그대로) — 이름 같음 · 씨앗(점수 9) 먼저 + + 이름 점수 후보(로직도 이름 · 규격 · 단위로 접음) · 숫자 판정 차례와 따로(표시 묶음만).""" + got = got or targets() + same = [_public(x) for x in _exact(line, "로직", got)] + rest = [ + c + for c in candidates(line, "로직", got, 5, fold=True) + if {k: v for k, v in c.items() if k != "점수"} not in same + ] + return ([{**x, "점수": 9} for x in same] + rest)[:5] + + # ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ───────────────────── _OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 21202d055..02306d1ff 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -212,6 +212,17 @@ gap: var(--spacing-4); } +/* 고르기 창 후보 묶음(358-1) — 뜻 낱말 안 겹치는 「후보」 = 회색 · 숫자만 닮음 = 접힘 */ +.m02-hopyo__cand.is-weak { + color: var(--color-text-muted); +} + +.m02-hopyo__group > summary { + cursor: pointer; + color: var(--color-text-secondary); + margin-bottom: var(--spacing-4); +} + /* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */ .m02-hopyo-ledger__row.is-upper { color: var(--color-text-muted); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 818aec359..2b1d01c28 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -92,6 +92,10 @@ export interface HopyoTarget { /** 후보 판정(338) — 맞음 · 다른 점 · 후보 */ 판정?: "맞음" | "다른 점" | "후보"; 글?: string; + /** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */ + 뜻겹침?: boolean; + 출처같음?: boolean; + 책?: string; } /** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */ @@ -124,6 +128,8 @@ export interface HopyoMatch { 체크: boolean; 대상: HopyoPart[]; 후보?: HopyoTarget[]; + /** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */ + 이름후보?: HopyoTarget[]; 글?: string; 꼬리표?: string[]; 품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts index 26046f7fc..cf8022f09 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -1,10 +1,11 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Choose.ts - * 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. - * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행) - * · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음 - * · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 - * · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말) + * 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. + * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행) + * · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 · + * 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록) + * · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 + * · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2) * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. * ========================================================================== */ @@ -15,6 +16,7 @@ import type { HopyoBasisHit, HopyoLine, HopyoLineBasis, + HopyoMatch, HopyoQty, HopyoTarget, } from "./M02_MasterTemplete_Hopyo_Api"; @@ -95,13 +97,71 @@ export const qtyText = (q: HopyoQty | undefined): string => ].join(" · ") : ""; -/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */ -export function searchHint(line: Pick): string { - const clause = line.근거?.출처?.find((x) => x[1])?.[1]; - if (clause) return clause; +/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */ +export function searchHint(line: Pick): string { return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? ""; } +/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */ +export interface CandGroup { + 제목: string; + 접힘: boolean; + 후보: HopyoTarget[]; +} + +/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */ +export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false; + +/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) / + * 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */ +export function candGroups( + m: Pick, + material = false, +): CandGroup[] { + const all = m.후보 ?? []; + const seen = new Set(); + const take = (list: HopyoTarget[]): HopyoTarget[] => + list.filter((c) => { + const id = c.로직 ?? c.자재 ?? c.이름 ?? ""; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + const top = take(all.slice(0, 1)); + if (material) + return [ + { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) }, + ].filter((g) => g.후보.length); + const named = m.이름후보 ?? []; + const source = take([...all.slice(1), ...named].filter((c) => c.출처같음)); + const name = take(named); + const rest = take(all.slice(1)); + rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b))); + return [ + { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "출처 같음", 접힘: false, 후보: source }, + { 제목: "이름 닮은 로직", 접힘: false, 후보: name }, + { 제목: "숫자만 닮음", 접힘: true, 후보: rest }, + ].filter((g) => g.후보.length); +} + +/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */ +export function sameNames(groups: CandGroup[]): Set { + const count = new Map(); + for (const c of groups.flatMap((g) => g.후보)) + count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1); + return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name)); +} + +/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */ +export const candText = (c: HopyoTarget, twins: Set): string => + `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`; + +/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */ +export const canPickMaterial = (line: Pick): boolean => + MATERIAL.has(line.갈래) || !!line.단가?.재료비; + /** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] => picked.map(({ c, 몫 }) => ({ @@ -113,12 +173,12 @@ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): export interface ChooseOptions { line: HopyoLine; - /** [엑셀에서 보기] — 단가산출 그 행 */ + /** [엑셀 보기] — 단가산출 그 행 */ openSheet?: (행: [number, number]) => void; done: (targets: BuildTarget[]) => void; } -/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */ +/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 묶음(하나 또는 여럿 · 몫) · 다른 로직 · 자재 검색 */ export function openHopyoChoose(opt: ChooseOptions): void { const { line } = opt; const material = MATERIAL.has(line.갈래); @@ -132,9 +192,9 @@ export function openHopyoChoose(opt: ChooseOptions): void { opt.done(targets); }; const picked = new Map(); - const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true }); + const together = createButton({ label: "일괄 적용(0)", size: "sm", disabled: true }); const paint = (): void => { - together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`; + together.querySelector(".ui-btn__label")!.textContent = `일괄 적용(${picked.size})`; together.disabled = picked.size < 2; }; together.addEventListener("click", () => @@ -144,7 +204,9 @@ export function openHopyoChoose(opt: ChooseOptions): void { ), ), ); - const list = (line.맞춤.후보 ?? []).map((c) => { + const groups = candGroups(line.맞춤, material); + const twins = sameNames(groups); + const row = (c: HopyoTarget): HTMLElement => { const share = el("input", { className: "ui-input ui-input--sm m02-hopyo__times", attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" }, @@ -157,12 +219,12 @@ export function openHopyoChoose(opt: ChooseOptions): void { }); const tag = basisTag(c.근거); return el("div", { - className: "m02-hopyo__cand", + className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`, attrs: { "data-cand": c.로직 ?? c.자재 ?? "" }, children: [ box, createButton({ - label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, + label: candText(c, twins), variant: "ghost", size: "sm", onClick: () => finish(pickedTargets([{ c, 몫: null }])), @@ -176,6 +238,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ] : []), ...(c.글 ? [note(c.글)] : []), + ...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []), ...(tag && c.근거 ? [ el("span", { @@ -188,6 +251,18 @@ export function openHopyoChoose(opt: ChooseOptions): void { share, ], }); + }; + const sections = groups.map((g) => { + const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) }); + const title = `${g.제목} (${g.후보.length})`; + if (!g.접힘) + return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] }); + const fold = el("details", { + className: "m02-hopyo__group", + attrs: { "data-group": g.제목 }, + children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list], + }); + return fold; }); const excel = qtyText(line.맞춤.품?.엑셀); const basis = line.근거; @@ -199,7 +274,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ...(basis && opt.openSheet ? [ createButton({ - label: "엑셀에서 보기", + label: "엑셀 보기", variant: "ghost", size: "sm", onClick: () => opt.openSheet?.(basis.행), @@ -208,23 +283,37 @@ export function openHopyoChoose(opt: ChooseOptions): void { : []), ], }); - const more = createButton({ - label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", + const findMaterial = createButton({ + label: "다른 자재 검색…", size: "sm", onClick: () => { close(); - if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); - else - openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { - query: searchHint(line), - }); + pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); }, }); + const findLogic = createButton({ + label: "다른 로직 검색…", + size: "sm", + onClick: () => { + close(); + openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { + query: searchHint(line), + }); + }, + }); + const more = [ + ...(material ? [] : [findLogic]), + ...(canPickMaterial(line) ? [findMaterial] : []), + ]; body.append( ...(head.childElementCount ? [head] : []), - note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"), - el("div", { className: "m02-hopyo__cands", children: list }), - el("div", { className: "m02-hopyo__bar", children: [together, more] }), + note( + groups.length + ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]" + : "가까운 것 없음 — 아래 검색", + ), + ...sections, + el("div", { className: "m02-hopyo__bar", children: [together, ...more] }), ); }, }); diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index dc55d7522..3bfc7ab22 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -159,6 +159,42 @@ let out0 = null; word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })], qty: choose.qtyText(lines[1].맞춤.품.엑셀), target: choose.targetText({ 호표: 46, 단위: "㎡" }), + // 358-1 후보 묶음 — 1위 / 출처 같음 / 이름 닮은 로직 / 숫자만 닮음(약한 후보 끝) · 같은 이름 책 + groups: (() => { + const m = { + 후보: [ + { 로직: "A", 이름: "되메우기", 판정: "맞음", 뜻겹침: true, 책: "산림품셈" }, + { 로직: "B", 이름: "야면석 채집", 판정: "후보", 뜻겹침: false }, + { + 로직: "C", + 이름: "되메우기", + 판정: "후보", + 뜻겹침: true, + 출처같음: true, + 책: "건설품셈", + }, + { 로직: "D", 이름: "표토펴기", 판정: "다른 점", 뜻겹침: false }, + ], + 이름후보: [ + { 로직: "A", 이름: "되메우기" }, + { 로직: "E", 이름: "되메우기(노상재)" }, + ], + }; + const g = choose.candGroups(m); + const twins = choose.sameNames(g); + return { + groups: g.map((x) => [x.제목, x.접힘, x.후보.map((c) => c.로직)]), + text: g.flatMap((x) => x.후보).map((c) => choose.candText(c, twins)), + material: choose + .candGroups({ 후보: [{ 자재: "M1" }, { 자재: "M2" }] }, true) + .map((x) => x.제목), + pick: [ + choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: 10 } }), + choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: null } }), + choose.canPickMaterial({ 갈래: "별도계상", 단가: {} }), + ], + }; + })(), }, ledger: (() => { const rows = input.ledger; diff --git a/resources/tester/test_m02_hopyo_golden.py b/resources/tester/test_m02_hopyo_golden.py index 1006a4aa4..76a4a915c 100644 --- a/resources/tester/test_m02_hopyo_golden.py +++ b/resources/tester/test_m02_hopyo_golden.py @@ -80,3 +80,18 @@ def test_씨앗_끔_1순위(no_seeds): def test_원본_그대로_셈_무게(run): assert master_hash() == run["원본"] assert run["결과"]["시간"]["북부청임도"] <= 300 # 북부청 임도 맞춤 셈 ≤ 5 분 + + +def test_이름후보_묶음_덧(run): + """358-1 — 이름후보(옛 324 다섯) = 묶음 덧 · 숫자 차례 그대로 → 다섯 안 ∪ 이름후보 ≥ 다섯 안.""" + have = [ + r for r in run["결과"]["줄"].values() if r["lab"]["무리"] == "사람" and r["lab"]["정답"] + ] + got = hm.targets() + five = [set(r["셈"]["차례"][:5]) for r in have] + named = [{c["로직"] for c in hm.name_cands(r["lab"], got)} for r in have] + near = sum(bool(f & set(r["lab"]["정답"])) for f, r in zip(five, have, strict=True)) + both = sum( + bool((f | n) & set(r["lab"]["정답"])) for f, n, r in zip(five, named, have, strict=True) + ) + assert both >= near >= 122 diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index e593dd4d1..adc1a1ba7 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -245,3 +245,26 @@ def test_뜻_낱말이_안_겹치면_후보_아님(): assert got["상태"] == "없음" and got["top"] == [] fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침 + + +# ── 이름 닮은 로직(358-1 · 옛 324 데스크탑 다섯 그대로) ───────────────────── +def test_이름후보_옛_데스크탑_다섯(): + fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + got = [c["로직"] for c in hm.name_cands(fill)] + assert got == ["GF000113", "GF000194", "GC000108", "GC000109", "GC000451"] # 되메우기 다섯 + dowel = {"이름": "다웰바설치", "규격": "Φ32×1000", "단위": "EA", "갈래": "단가산출"} + assert [c["로직"] for c in hm.name_cands(dowel)][:3] == ["GC000226", "GF000201", "GF000202"] + soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.name_cands(soil) == [] # 뜻 낱말 안 겹치면 후보 아님 그대로 + same = {"이름": "되메우기", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.name_cands(same)[0] == {**hm.name_cands(same)[0], "로직": "GF000113", "점수": 9} + + +def test_이름후보_뜻겹침_출처_표시(client): + _ready(client) + dig = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert dig["이름후보"] and all({"뜻겹침", "출처같음", "책"} <= set(c) for c in dig["이름후보"]) + assert dig["후보"][0]["출처같음"] is True and dig["후보"][0]["책"] == "건설품셈" # 3-2-1 + assert [c["로직"] for c in dig["후보"]][0] == "GC000083" # 차례 · 판정 그대로(표시만) + mat = _hopyo(client, 1)["레미콘(25-21-120)"] + assert "이름후보" not in mat # 자재 줄은 로직 이름후보 없음 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 0a421e7fd..fd729e0b4 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -136,7 +136,16 @@ def test_확인_창_체크한_줄만_몸(): def test_고르기_창_도우미(): got = _check()["choose"] - assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말 + assert got["hint"] == ["레미콘타설", "고임돌"] # 품명 첫 뜻 낱말(358-1 · 조항 번호 아님) + assert got["groups"]["groups"] == [ + ["1위", False, ["A"]], + ["출처 같음", False, ["C"]], + ["이름 닮은 로직", False, ["E"]], + ["숫자만 닮음", True, ["D", "B"]], # 뜻 낱말 안 겹치는 「후보」 = 끝 + ] + assert got["groups"]["text"][:2] == ["되메우기 · A · 산림", "되메우기 · C · 건설"] + assert got["groups"]["material"] == ["1위", "그 밖 후보"] + assert got["groups"]["pick"] == [True, False, True] # 재료비 있는 단가산출 줄도 자재 검색 assert got["picked"] == [ {"로직": "GC000206", "입력값": {}, "배": 1, "몫": None}, {"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1}, From 1ac1a7d9362847427f103c0727b8b49a2b2544d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:58:31 +0900 Subject: [PATCH 22/33] =?UTF-8?q?feat(B05):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=E2=96=B2=E2=96=BC=20=EA=BA=BE=EC=9E=84=20=EB=88=84=EB=A6=84=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=9B=83=20=EA=BA=BE=EC=9E=84=20=EC=B6=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9D=B4=20=EC=A7=81=EC=84=A0=20?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC=20(355-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adjustStation — 누른 점이 꺾임이면 pivotAxes 로 좌우 다음 꺾임(없으면 시점 · 종점)을 축으로 사이 측점을 새 직선 위로 · 사이 R 키 지움 · 직선 안쪽 · 처음 계획선은 옛 값 그대로 - 새 logic/B05_Profile_UI_Profile_Pivot.ts — 타입만 import(순환 없음) - 시험 test_355_1 · helper_355_1 세 경우 · test_213_1/2 tsc 산출 ESM import 에 .js 붙임 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../logic/B05_Profile_UI_Profile_Alignment.ts | 28 ++-- .../logic/B05_Profile_UI_Profile_Pivot.ts | 51 ++++++ resources/tester/helper_355_1_pivot_tilt.cjs | 150 ++++++++++++++++++ .../tester/test_213_1_hidden_station_pvi.py | 4 + .../tester/test_213_2_ghost_edit_drop.py | 4 + resources/tester/test_355_1_pivot_tilt.py | 72 +++++++++ 6 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts create mode 100644 resources/tester/helper_355_1_pivot_tilt.cjs create mode 100644 resources/tester/test_355_1_pivot_tilt.py diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index c835a47e8..eaee72c90 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -13,6 +13,8 @@ * - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다. * ========================================================================== */ +import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot"; + export interface AlignmentPolicy { station_interval_m: number; curve_radius_ratio: number; @@ -552,6 +554,7 @@ const FEEDBACK_TOLERANCE_M = 1e-4; * 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가) * 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼 * 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다. + * 누른 점이 꺾임이면 이웃 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1). */ export function adjustStation( base: AlignmentBase, @@ -562,17 +565,24 @@ export function adjustStation( const key = chainageKey(chainageM); const current = buildAlignment(base, edits); const target = planElevationAt(current, chainageM) + delta; - const baseElevation = interpolate( - base.basePvi.map((node) => node.chainage_m), - base.basePvi.map((node) => node.elevation_m), - chainageM, - ); + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const baseElevation = interpolate(baseS, baseZ, chainageM); let offset = (edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta; - const withOffset = (value: number): AlignmentEdits => ({ - ...edits, - station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }, - }); + const axes = pivotAxes(current, chainageM); + const withOffset = (value: number): AlignmentEdits => { + const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }; + const radii = { ...edits.curve_radii }; + axes?.inner.forEach((chainage) => { + const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ]; + const z = + axisZ + ((baseElevation + value - axisZ) * (chainage - axisM)) / (chainageM - axisM); + offsets[chainageKey(chainage)] = Number((z - interpolate(baseS, baseZ, chainage)).toFixed(6)); + delete radii[chainageKey(chainage)]; + }); + return { ...edits, station_offsets: offsets, curve_radii: radii }; + }; let next = withOffset(offset); for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) { const error = target - planElevationAt(buildAlignment(base, next), chainageM); diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts new file mode 100644 index 000000000..ccc84efc4 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts @@ -0,0 +1,51 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Pivot.ts + * 종단 ▲▼ 축 찾기(355-1 · 요구 235-15) — 누른 점이 꺾임(R 있는 점)이면 좌우로 같은 + * 기울기 마디를 건너 다음 꺾임(없으면 시점 · 종점)을 축으로 삼는다. 사이 측점은 + * 새 직선(축 → 누른 점 → 축) 위로 따라 돈다(`adjustStation`). + * + * 처음 계획선은 마디마다 꺾여 축 = 누른 점 ±1 → 사이 측점 없음(옛 동작 그대로). + * `B05_Profile_UI_Profile_Alignment` 를 타입으로만 읽는다(순환 import 없음). + * ========================================================================== */ + +import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; + +/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */ +const COLLINEAR_EPSILON = 1e-6; +const SAME_STATION_M = 1e-6; + +export interface PivotAxes { + fromM: number; + fromZ: number; + toM: number; + toZ: number; + /** 두 축 사이(끝 · 누른 점 제외) 변화점 chainage. */ + inner: number[]; +} + +/** 누른 점이 꺾임이면 두 축과 사이 변화점 — 직선 안쪽 · 시종점 · 변화점 아님이면 null. */ +export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null { + const { pvi, segments } = alignment; + const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M); + if (index <= 0 || index >= pvi.length - 1) return null; + const grade = (at: number): number => { + const segment = segments[at]; + return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0; + }; + const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON; + if (same(index - 1, index)) return null; + let from = index - 1; + while (from > 0 && same(from - 1, index - 1)) from -= 1; + let to = index + 1; + while (to < pvi.length - 1 && same(to, index)) to += 1; + return { + fromM: pvi[from].chainage_m, + fromZ: pvi[from].elevation_m, + toM: pvi[to].chainage_m, + toZ: pvi[to].elevation_m, + inner: pvi + .slice(from + 1, to) + .map((node) => node.chainage_m) + .filter((chainage) => Math.abs(chainage - chainageM) >= SAME_STATION_M), + }; +} diff --git a/resources/tester/helper_355_1_pivot_tilt.cjs b/resources/tester/helper_355_1_pivot_tilt.cjs new file mode 100644 index 000000000..9afd9741f --- /dev/null +++ b/resources/tester/helper_355_1_pivot_tilt.cjs @@ -0,0 +1,150 @@ +/* 355-1 종단 ▲▼ 이웃 꺾임 축 돌림 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` · + * `_Straighten.ts` 를 그 자리에서 트랜스파일해 세 경우를 Node 로 돌린다. + * ① 처음 계획선 ▲ = 옛 값 ② 직선화 뒤 꺾임 ▲ = 사이 측점 한 직선 · 축 표고 그대로 · +0.1 + * ③ 직선 안쪽 ▲ = 옛 값. 옛 값 = 355-1 앞 adjustStation(아래 oldAdjust) 그대로. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); +const straight = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts")); + +// 전체 측점 폴리라인(20m 간격 · 0~200m) — 서버와 같이 꺾임마다 기본 L 라운드. +const stations = Array.from({ length: 11 }, (_, i) => i * 20); +const ground = stations.map((s, i) => 100 + 0.3 * i + (i % 2 ? 0.4 : 0)); +const base = { + policy: { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 15, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, + }, + onlyExplicitCurves: false, + basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })), + chainage: stations, + ground, + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}; + +/** 355-1 앞 adjustStation — 누른 측점 하나만 옮김. */ +function oldAdjust(edits, chainageM, delta) { + const key = align.chainageKey(chainageM); + const current = align.buildAlignment(base, edits); + const target = align.planElevationAt(current, chainageM) + delta; + const baseElevation = straight.baseElevationAt(base, chainageM); + let offset = + (edits.station_offsets[key] ?? align.planElevationAt(current, chainageM) - baseElevation) + + delta; + const withOffset = (value) => ({ + ...edits, + station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }, + }); + let next = withOffset(offset); + for (let pass = 0; pass < 4; pass += 1) { + const error = target - align.planElevationAt(align.buildAlignment(base, next), chainageM); + if (Math.abs(error) < 1e-4) break; + offset += error; + next = withOffset(offset); + } + return next; +} + +const sorted = (record) => + Object.fromEntries( + Object.keys(record ?? {}) + .sort() + .map((key) => [key, record[key]]), + ); +const pviZ = (built, s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m; +const out = {}; + +// ① 처음 계획선 — 마디마다 꺾임 → 축 = 누른 점 ±1 · 옛 값과 같음(▲ · ▼). +{ + const empty = align.emptyEdits(); + const up = align.adjustStation(base, empty, 100, 0.1); + const down = align.adjustStation(base, empty, 60, -0.1); + out.initial = { + sameUp: + JSON.stringify(sorted(up.station_offsets)) === + JSON.stringify(sorted(oldAdjust(empty, 100, 0.1).station_offsets)), + sameDown: + JSON.stringify(sorted(down.station_offsets)) === + JSON.stringify(sorted(oldAdjust(empty, 60, -0.1).station_offsets)), + upKeys: Object.keys(up.station_offsets), + radiiKeys: Object.keys(up.curve_radii ?? {}), + }; +} + +// ② 40~100 · 100~160 직선화 → 100 = 꺾임 · 두 축 40 · 160 · ▲ 100. +{ + let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100); + edits = straight.straightenBetween(base, edits, 100, 160); + const before = align.buildAlignment(base, edits); + const next = align.adjustStation(base, edits, 100, 0.1); + const after = align.buildAlignment(base, next); + const p = pviZ(after, 100); + const line = (s, aM, aZ) => aZ + ((p - aZ) * (s - aM)) / (100 - aM); + const z40 = pviZ(before, 40); + const z160 = pviZ(before, 160); + out.kink = { + planRise: align.planElevationAt(after, 100) - align.planElevationAt(before, 100), + pivotRise: p - pviZ(before, 100), + axisMove: [pviZ(after, 40) - z40, pviZ(after, 160) - z160], + outsideMove: [20, 180].map((s) => pviZ(after, s) - pviZ(before, s)), + innerResidual: [60, 80] + .map((s) => pviZ(after, s) - line(s, 40, z40)) + .concat([120, 140].map((s) => pviZ(after, s) - line(s, 160, z160))), + innerMoved: [60, 80, 120, 140].map((s) => pviZ(after, s) - pviZ(before, s)), + curvesAt: after.curves.map((c) => c.chainage_m).sort((a, b) => a - b), + oldWouldBreak: (() => { + const old = align.buildAlignment(base, oldAdjust(edits, 100, 0.1)); + return [80, 120].map((s) => pviZ(old, s) - pviZ(before, s)); + })(), + }; +} + +// ③ 직선 안쪽(80 · 40~100 직선 위) — 꺾임 아님 → 옛 값. +{ + let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100); + edits = straight.straightenBetween(base, edits, 100, 160); + const next = align.adjustStation(base, edits, 80, 0.1); + out.inside = { + same: + JSON.stringify(sorted(next.station_offsets)) === + JSON.stringify(sorted(oldAdjust(edits, 80, 0.1).station_offsets)), + planRise: + align.planElevationAt(align.buildAlignment(base, next), 80) - + align.planElevationAt(align.buildAlignment(base, edits), 80), + }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_213_1_hidden_station_pvi.py b/resources/tester/test_213_1_hidden_station_pvi.py index b75c20104..1f2fc4833 100644 --- a/resources/tester/test_213_1_hidden_station_pvi.py +++ b/resources/tester/test_213_1_hidden_station_pvi.py @@ -15,6 +15,7 @@ from __future__ import annotations import copy import json +import re import shutil import subprocess import sys @@ -136,6 +137,9 @@ def ts_alignment(tmp_path_factory): capture_output=True, ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요 + text = js.read_text(encoding="utf-8") + js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8") data = _synthetic() base = { "policy": AlignmentPolicy.from_dict({}).as_dict(), diff --git a/resources/tester/test_213_2_ghost_edit_drop.py b/resources/tester/test_213_2_ghost_edit_drop.py index 2e84ee6e5..203185130 100644 --- a/resources/tester/test_213_2_ghost_edit_drop.py +++ b/resources/tester/test_213_2_ghost_edit_drop.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import re import subprocess import sys from pathlib import Path @@ -163,6 +164,9 @@ def test_followed_station_ts(tmp_path): capture_output=True, ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요 + text = js.read_text(encoding="utf-8") + js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8") src, dst = tmp_path / "in.json", tmp_path / "out.json" src.write_text(json.dumps([case for case, _ in CASES]), encoding="utf-8") subprocess.run( # noqa: S603 diff --git a/resources/tester/test_355_1_pivot_tilt.py b/resources/tester/test_355_1_pivot_tilt.py new file mode 100644 index 000000000..eb24bcbf5 --- /dev/null +++ b/resources/tester/test_355_1_pivot_tilt.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""355-1 B05 종단 ▲▼ — 누른 점이 꺾임이면 이웃 꺾임을 축으로 사이 직선이 따라 돎(요구 235-15). + +계산(`B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts` adjustStation · +`_Pivot.ts` pivotAxes)은 Node 헬퍼(`helper_355_1_pivot_tilt.cjs`)가 트랜스파일해 돌리고, +부르는 곳 셋(▲▼ 버튼 · 표 값 입력 · 방향키)이 같은 함수인지는 소스로 본다. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_355_1_pivot_tilt.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음_계획선은_옛_값(out): + initial = out["initial"] + assert initial["sameUp"] and initial["sameDown"] + assert initial["upKeys"] == ["100.000"] + assert initial["radiiKeys"] == [] + + +def test_꺾임_누르면_사이_측점이_한_직선(out): + kink = out["kink"] + assert kink["planRise"] == pytest.approx(0.1, abs=1e-4) + assert kink["axisMove"] == [0, 0] + assert kink["outsideMove"] == [0, 0] + assert all(abs(value) < 1e-6 for value in kink["innerResidual"]) + # 축에서 멀수록 더 오름(40 → 60 → 80 → 100 · 160 → 140 → 120 → 100) + moved = kink["innerMoved"] + assert 0 < moved[0] < moved[1] < kink["pivotRise"] + assert 0 < moved[3] < moved[2] < kink["pivotRise"] + # 옛 길은 사이 측점이 그대로라 P±1 에서 꺾였음 + assert kink["oldWouldBreak"] == [0, 0] + + +def test_직선_안쪽은_옛_값(out): + assert out["inside"]["same"] + assert out["inside"]["planRise"] == pytest.approx(0.1, abs=1e-4) + + +def test_부르는_곳_셋이_같은_함수(): + assert "adjustStation(base, store.edits(), chainage, delta)" in _read( + "B05_Profile/B05_Profile_UI_Profile_Render.ts" + ) + assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read( + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts" + ) + assert "pivotAxes(current, chainageM)" in _read( + "B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts" + ) From 19243a5afa9104f4f0e1c4d8fa5c02816e65b104 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:00:33 +0900 Subject: [PATCH 23/33] =?UTF-8?q?fix(B05):=20352-1=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=86=90=EB=B4=84=20-=20=ED=9A=8C?= =?UTF-8?q?=EC=A0=84=20=EB=8B=A8=EC=B6=94=EB=A5=BC=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=EC=A4=84=20=EC=A4=84=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EB=81=9D?= =?UTF-8?q?=EC=97=90=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20=ED=8C=90=EC=9D=80=20?= =?UTF-8?q?=EA=B7=B8=20=EC=A4=84=20=EC=95=84=EB=9E=98=EB=B6=80=ED=84=B0?= 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_01VmC34fsxxbweEG3RUjs3Mn --- B05_Profile/B05_Profile_UI_Style_RouteEdit.css | 8 +++----- resources/tester/test_b05_routeedit_chrome_modal.py | 3 ++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 00e97dd68..1e6c5420b 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -5,7 +5,6 @@ (`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */ .b05-routeedit { --b05-side-open: 0px; /* 횡단 판이 펴져 지도를 덮는 폭 */ - --b05-side-top: 64px; /* 접힌 [횡단] 단추가 위 오른쪽을 덮는 폭 */ position: relative; display: flex; flex: 1 1 auto; @@ -14,12 +13,11 @@ .b05-routeedit.is-side-open { --b05-side-open: 460px; - --b05-side-top: 460px; } /* 도구줄 — 3D 도구줄 규격 그대로 · 오른쪽 회전 단추 · 횡단 판을 비켜 줄바꿈. */ .b05-routeedit__tools { - right: calc(var(--spacing-12) + var(--b05-side-top) + 104px); + right: calc(var(--spacing-12) + 112px); /* 오른쪽 위 회전 단추 자리 */ flex-wrap: wrap; pointer-events: none; /* 빈 자리는 지도 조작을 통과 */ } @@ -155,7 +153,7 @@ .b05-routeedit__spin { position: absolute; top: var(--spacing-16); - right: calc(var(--spacing-12) + var(--b05-side-top)); + right: var(--spacing-12); z-index: 1; display: flex; gap: var(--spacing-8); @@ -195,7 +193,7 @@ .b05-routeedit__side-wrap { position: absolute; z-index: 2; - top: var(--spacing-16); + top: calc(var(--spacing-16) + 34px + var(--spacing-8)); /* 도구줄 · 회전 단추 줄 아래 */ right: var(--spacing-12); bottom: var(--spacing-12); display: flex; diff --git a/resources/tester/test_b05_routeedit_chrome_modal.py b/resources/tester/test_b05_routeedit_chrome_modal.py index 2dfc14a68..0214f08ef 100644 --- a/resources/tester/test_b05_routeedit_chrome_modal.py +++ b/resources/tester/test_b05_routeedit_chrome_modal.py @@ -84,7 +84,8 @@ def test_352_1_머리줄_상자_없음_도구는_뜬_단추_횡단은_우측_덮 style = _read("B05_Profile_UI_Style.css") assert "z-index: 3;" in _block(style, ".b05-route-profile") # 회전 단추 · 잰 값 창은 펴진 횡단 판을 비킴 - assert "var(--b05-side-top)" in _block(css, ".b05-routeedit__spin") + assert "right: var(--spacing-12);" in _block(css, ".b05-routeedit__spin") # 도구줄 줄 오른쪽 + assert "34px" in side # 횡단 판은 도구줄 줄 아래에서 시작 assert "var(--b05-side-open)" in _block(css, ".b05-routeedit__measure") assert "is-side-open" in chrome glass = _block(css, ".b05-routeedit__terrain") From f79a33ef305ef668cb530603ad9a1a0f68d0dff4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:04:10 +0900 Subject: [PATCH 24/33] =?UTF-8?q?fix(=EC=A2=85=EB=8B=A8=EB=8F=84):=20354-1?= =?UTF-8?q?=20=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20=ED=8C=90=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=EC=A4=84=EC=9D=84=20=EA=B3=A0=EC=A0=95=20Y=EC=B6=95?= =?UTF-8?q?=20=EC=9C=84=EB=A1=9C=20=E2=80=94=20[=EC=B0=BD=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0]=EC=9D=B4=20=EC=B6=95=20=EB=9D=A0=EC=97=90=20?= =?UTF-8?q?=EA=B0=80=EB=A0=A4=EC=A7=80=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B05_Profile/B05_Profile_UI_Style_MassHaul.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index ec55e8f1b..59cc32b50 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -76,7 +76,8 @@ top: 4px; right: 0; left: var(--spacing-8); - z-index: 4; + /* 고정 Y축(z5 · 불투명) 위 — 아래면 [창 분할]이 축 띠에 가려짐 */ + z-index: 6; display: flex; align-items: center; min-height: 24px; From 73b337f1529792e1b88640ec9920aca1ed290ba7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:07:12 +0900 Subject: [PATCH 25/33] =?UTF-8?q?=EF=BB=BFfix(B05):=20=EB=B0=B0=EC=88=98?= =?UTF-8?q?=EC=9C=A0=EC=97=AD=20=EB=B2=84=ED=8A=BC=20=EA=B8=80=20=EB=AA=85?= =?UTF-8?q?=EC=82=AC=ED=98=95=20=E2=80=94=20=EC=9C=A0=EC=97=AD=20=EC=9E=AC?= =?UTF-8?q?=EB=B6=84=ED=95=A0=20=C2=B7=20=EA=B4=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?(359-3=20=EB=8D=A4=20=C2=B7=20=EC=9A=94=EA=B5=AC=20235-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- .../tester/test_359_3_b05_drainage_btn_noun.py | 13 +++++++++++++ ui_template/ui_template_locale_b2.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_359_3_b05_drainage_btn_noun.py diff --git a/resources/tester/test_359_3_b05_drainage_btn_noun.py b/resources/tester/test_359_3_b05_drainage_btn_noun.py new file mode 100644 index 000000000..2f2e29e60 --- /dev/null +++ b/resources/tester/test_359_3_b05_drainage_btn_noun.py @@ -0,0 +1,13 @@ +"""359-3 덤(요구 235-27) — B05 배수유역 버튼 글 명사형.""" + +from pathlib import Path + +LOCALE = Path(__file__).resolve().parents[2] / "ui_template" / "ui_template_locale_b2.ts" + + +def test_drainage_buttons_are_nouns(): + text = LOCALE.read_text(encoding="utf-8") + assert 'B05_Drainage_Btn_Analyze: ["유역 재분할",' in text + assert 'B05_Drainage_Btn_DeleteSelected: ["관 삭제",' in text + assert "유역 다시 나누기" not in text + assert "선택한 관 삭제" not in text diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 69df77e87..677cdb4b9 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -32,12 +32,12 @@ export const ui_locales_b2 = { ], // 버튼 이름만으로 무엇이 바뀌는지 알기 어려워 동작을 이름·툴팁에 함께 적는다 // (2026-08-19 사용자 지시 4). - B05_Drainage_Btn_Analyze: ["유역 다시 나누기", "Recompute sub-basins"], + B05_Drainage_Btn_Analyze: ["유역 재분할", "Recompute sub-basins"], B05_Drainage_Btn_Analyze_Tip: [ "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.", ], - B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"], + B05_Drainage_Btn_DeleteSelected: ["관 삭제", "Delete selected culvert"], B05_Drainage_Btn_DeleteSelected_Tip: [ "지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.", "Removes the culvert selected on the map. Select a culvert marker first.", From 933fe26791f851549bfd492c9cdbe9386bcf5b7f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:12:05 +0900 Subject: [PATCH 26/33] =?UTF-8?q?feat(M02=20=EC=8B=A4=EB=AC=B4=EC=9E=90?= =?UTF-8?q?=EB=A3=8C):=20358-2=20=EC=9E=90=EC=9E=AC=20=C2=B7=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EA=B3=84=EC=83=81=20=EC=A4=84=20=EB=A7=9E=EC=B6=A4=20?= =?UTF-8?q?=E2=80=94=20=E3=80=8C=EB=B3=84=EB=8F=84=EA=B3=84=EC=83=81?= =?UTF-8?q?=E3=80=8D=20=EA=B8=80=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EB=B0=94?= =?UTF-8?q?=ED=83=95=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=EC=88=98=20=C2=B7=20=EC=A7=80=EC=97=AD=20=C2=B7=20=EA=B4=80?= =?UTF-8?q?=EA=B8=89/=EC=82=AC=EA=B8=89=20=EB=B3=80=ED=98=95=20=C2=B7=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=AC=20=EB=95=8C=20=EC=84=B8=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=9E=90=EC=9E=AC=20=EB=91=90?= =?UTF-8?q?=20=EB=B2=88=20=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_Material — 별도계상 글 떼기 · 괄호 · 지역 · 관급/사급 꼬리 뗀 바탕 이름 · 규격 수(단위 배 · 범위) · 같은 단위 먼저 · 변형 = 한 품목 + 「못 가림」 꼬리표 · 후보 변형 접음 - 갈래: 재료비만 있는 줄 = 자재(단산 참조여도 · STmate D 코드도) · STmate 별도계상 글 포함 - 만들기: 자재 세 칸(별도계상 · 자재구분 · 운반비) · 로직 자재와 같은 품목 = 자재표시로 옮기고 빠진 줄 까닭(400 없음) - 북부청 임도 자재 · 별도계상 30줄 맞음 2 → 8 · 자재 정답 표 · build · flow 시험 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Build.py | 38 +++- M02_MasterTemplete/M02_Hopyo_Excel.py | 4 +- M02_MasterTemplete/M02_Hopyo_Line.py | 36 ++-- M02_MasterTemplete/M02_Hopyo_Material.py | 251 +++++++++++++++++++++++ M02_MasterTemplete/M02_Hopyo_Stmate.py | 7 +- resources/tester/test_m02_hopyo_build.py | 23 +++ resources/tester/test_m02_hopyo_flow.py | 3 +- resources/tester/test_m02_hopyo_match.py | 65 +++++- 8 files changed, 402 insertions(+), 25 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Material.py diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 41309a762..aaf612349 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -12,7 +12,9 @@ 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값, 수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄 사전조합으로 감싸 참조(같은 로직 두 번 금지) · - 자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조. + 자재 = {코드, 수량 {손}, 세 칸}(같은 자재 = 수량 더함 · 세 칸 = `Material.marks` + 별도계상 · 자재구분 · 운반비 · 358-2) · 로직 자재와 같은 품목 = 사용자 자재에서 빼고 + 세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조. 같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 (`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). 빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고). @@ -29,6 +31,8 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match +from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -39,6 +43,7 @@ LINES_MAX = 500 UNCHECKED = "확인 안 함" PERCENT = "% 줄 — 우리 로직 덧줄이 셈" WHOLE = "호표 통째로 대신" +TWICE = "로직 자재와 같은 품목 — 세 칸만 로직 자재표시로(수량 = 로직 셈)" def mark(key: str, no: int, at: int | None = None) -> str: @@ -131,6 +136,7 @@ class Builder: self.source = refs.where(source) self.done = confirmed(folder, self.source) self.known = ledger._known() + self.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]} self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None) if self.hopyo is None: raise _bad(f"없는 호표 「제{no}호표」", 404) @@ -197,20 +203,26 @@ class Builder: def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict: stamp = datetime.now().isoformat(timespec="seconds") hopyo, file = self.hopyo, self.doc.get("파일") or "" - lines, mats, missing, used = [], {}, [], set() + lines, mats, missing, used, mat_lines = [], {}, [], set(), {} def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) memo = _memo(line, one) if "자재" in one: - apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산" have = mats.get(one["자재"]) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty + name = self.mat_names.get(one["자재"], "") mats[one["자재"]] = { "코드": one["자재"], "수량": {"손": _num(hand)}, - "별도계상": apart, + **( + have + and {k: have[k] for k in labor.MARKS if k in have} + or hmat.marks(line, name) + ), } + if not have: + mat_lines[one["자재"]] = line return if "호표" in one: ref = { @@ -261,6 +273,8 @@ class Builder: pairs = [p for p in got["대상"] if "호표" not in p] if pairs: book[ledger.key(line)] = self._row(line, "짝", pairs) + for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로 + missing.append(_missing(mat_lines[code], TWICE)) name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x) doc = { "이름": name, @@ -275,6 +289,22 @@ class Builder: wrote = ledger.write(self.folder, book, self.who, file or None) return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} + def _inside(self, lines: list[dict[str, Any]], mats: dict[str, dict[str, Any]]) -> list[str]: + """로직 자재와 같은 품목(Labor_Doc `no_twice` 400 막음) — 사용자 자재에서 빼고 + 세 칸을 그 로직 줄 `자재표시 {재료 줄 차례: 세 칸}` 으로(수량 = 로직 셈) → 옮긴 코드.""" + whole, moved = m01.loaded()[1], [] + for row in (r for r in lines if r.get("로직")): + got = labor.run_line(whole, row["로직"], row.get("입력값"), None, None) + for m in got.get("재료") or []: + code = (m.get("품목") or {}).get("키") + if code in mats and code not in moved: + marks = {k: mats[code][k] for k in labor.MARKS if k in mats[code]} + row.setdefault("자재표시", {})[str(m["차례"])] = marks + moved.append(code) + for code in moved: + mats.pop(code) + return moved + def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]: """장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면).""" origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")} diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index e103d1d07..f1c114f8c 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -151,6 +151,8 @@ def kind_of(line: dict[str, Any]) -> str: return "%" if ref.get("종류") == "일위": return "호표" + if price.get("재료비") and not price.get("노무비") and not price.get("경비"): + return "자재" # 재료비만 = 자재(「단산N참조」여도 · 358-2) if ref.get("종류") == "단산": return "단가산출" if unit in _MACHINE_UNITS: @@ -159,8 +161,6 @@ def kind_of(line: dict[str, Any]) -> str: return "노임" if not any(price.get(k) for k in ("계", *COSTS)): return "별도계상" - if price.get("재료비") and not price.get("노무비") and not price.get("경비"): - return "자재" return "기타" diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 651edd4b3..9cee76c6b 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -10,9 +10,9 @@ 없음」). 장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보 끝 덤만. -자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보. -로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 · -책 — 차례 · 무게 · 상태는 그대로. +자재 · 별도계상 = 이름 · 규격 같음 하나 · 바탕 이름 · 규격 수 같은 한 품목이면 맞음(358-2). +로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 +뜻겹침 · 출처같음 · 책 — 차례 · 무게 · 상태는 그대로. 입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값. 체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독. 장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창). @@ -29,6 +29,7 @@ from M02_MasterTemplete import M02_Hopyo_Finger as finger from M02_MasterTemplete import M02_Hopyo_Fit as fit from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_Option as option from M02_MasterTemplete import M02_Hopyo_Ours as ours from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store @@ -174,21 +175,30 @@ def _names_only(line: dict, ctx: Ctx, why: str, full: bool) -> dict: def _material(line: dict, ctx: Ctx, full: bool) -> dict: - found = hm._exact(line, "자재", ctx.got) - if len(found) == 1: + """자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) — 「별도계상」 글 = 꼬리표 · 맞음 = + 이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(변형 = 첫째 + 꼬리표).""" + me, apart = hmat.clean(line) + tags = [hmat.APART] if apart else [] + found = hm._exact(me, "자재", ctx.got) + text = "이름 · 규격 같음" + if len(found) != 1: + sure, more = hmat.sure(me) + found, text, tags = [r["one"] for r in sure], "바탕 이름 · 규격 같음", tags + more + if found: one = found[0] conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv} target.update(몫=None, 배=conv["배"]) ok = conv["배"] is not None and conv["갈래"] != "추정" - return {"상태": "맞음", "체크": ok, "대상": [target], "후보": [], "글": "이름 · 규격 같음"} - cands = [{**c, "판정": "후보"} for c in hm.candidates(line, "자재", ctx.got, 5)] if full else [] - return { - "상태": "후보" if cands or not full else "없음", - "체크": False, - "대상": [], - "후보": cands, - } + cands = [ + {**hm._public(x), "점수": None, "판정": "맞음" if not at else "후보", "글": text} + for at, x in enumerate(found[:6]) + ] + out = {"상태": "맞음", "체크": ok, "대상": [target], "후보": cands if full else []} + return {**out, "글": text, "꼬리표": tags} + cands = hmat.candidates(me) if full else [] + state = "후보" if cands or not full else "없음" + return {"상태": state, "체크": False, "대상": [], "후보": cands, "꼬리표": tags} def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Hopyo_Material.py b/M02_MasterTemplete/M02_Hopyo_Material.py new file mode 100644 index 000000000..a3d269972 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Material.py @@ -0,0 +1,251 @@ +"""M02 일위대가 실무자료 자재 · 별도계상 줄 맞춤(PLAN 358-2) — `Line._material` 이 부름. + +줄 다듬기 = 「별도계상」 글은 표시로 뗌(「조약돌(별도계상)」 · 규격 「별도계상」) · m/m = mm. +바탕 이름 = 괄호 · 지역 · 관급/사급 꼬리 뗀 이름(「레미콘(서울)-사급」 → 레미콘). +규격 맞음(0~1) = 정규 규격 같음 1 · 범위(「3.0≤T<4.5」)에 듦 1 · 그 밖 = 줄 규격 수가 +자재 규격 수에 있는 몫(단위 배 1 · 10 · 1000 · 2 % 허용 · 면목 0.03 m = 30 mm) · +줄 괄호 글 낱말(「몸통」)은 자재 이름에 들어야 함. +맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐 +(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 · 지역 먼저 · +없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급 · 지역 없는 것 · M01 차례 → 꼬리표 「못 가림」). +그 밖 = 후보 다섯(변형은 하나로 접고 글에 수). 곳 낱말 = M01 레미콘 이름에서(자료). +가져오기 세 칸(`marks`) = 별도계상(표시 → 별도계상 · 아니면 직접계산) · 자재구분(고른 +자재 이름 · 줄 글) · 운반비(「운반비 미포함 · 별도」 → 미포함). 로직 판정과 무관. +""" + +from __future__ import annotations + +import re +import unicodedata +from functools import lru_cache +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Match as hm + +mm = hm.mm +APART = "별도계상" +SUPPLY = ("관급", "사급") +NO_REGION = ("전국", "전지역") +REGIONS = ( + *"서울 인천 부산 대구 광주 대전 울산 세종 제주 경기 강원 충북 충남 전북 전남 경북 경남".split(), + *"충청 전라 경상 용인 포천 강릉 원주 창원 경주 구미 김천 안동 포항 광양 목포 전주 공주".split(), + *"보령 서산 아산 천안 제천 청주 충주 춘천 김해 양산 거제 통영 진주 밀양".split(), + *"여주 이천 가평 연천".split(), +) +SKIP_WORDS = frozenset({"조합공통품목"}) +_APART = re.compile(r"\(?\s*별도\s*계상\s*\)?") +_NUM = re.compile(r"\d+(?:\.\d+)?") +_RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*(<=|≤|<)\s*[A-Za-z]\s*(<=|≤|<)\s*(\d+(?:\.\d+)?)") +_TAIL = re.compile(r"[\s,\-]*([가-힣]+)$") +SCALES = (1, 10, 100, 1000, 0.1, 0.01, 0.001) + + +def _nfkc(text: Any) -> str: + return unicodedata.normalize("NFKC", str(text or "")).replace("m/m", "mm") + + +@lru_cache(maxsize=1) +def _places() -> frozenset[str]: + """곳 낱말 — M01 레미콘 이름의 지역 · 시군 낱말(자료에서 · 「홍천군」 · 「미원면」).""" + names = (one["이름"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘(")) + words = {w for n in names for w in re.findall(r"[가-힣]{2,}", _nfkc(n))} + return frozenset(words - {"레미콘", "조합공통품목", *SUPPLY}) + + +def _region(word: str) -> bool: + return word.startswith(REGIONS) or word in NO_REGION or word in _places() + + +def _paren_words(name: str) -> set[str]: + return {w for g in re.findall(r"\(([^()]*)\)", name) for w in re.findall(r"[가-힣]{2,}", g)} + + +def base(name: Any) -> str: + """바탕 이름 — 괄호 글 · 끝 지역 · 관급/사급 꼬리 뗌(정규화).""" + text = _nfkc(name) + while "(" in text and (cut := re.sub(r"\([^()]*\)", "", text)) != text: + text = cut + while (m := _TAIL.search(text)) and (m.group(1) in SUPPLY or _region(m.group(1))): + if m.start() == 0: + break + text = text[: m.start()] + for w in SUPPLY: + text = text[: -len(w)] if text.endswith(w) and len(text) > len(w) else text + return mm.plain(text) + + +def clean(line: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """(「별도계상」 글 뗀 줄, 별도계상 표시) — 갈래 별도계상 줄도 표시.""" + out, apart = dict(line), line.get("갈래") == APART + for k in ("이름", "규격"): + text = str(line.get(k) or "") + if _APART.search(text): + apart, text = True, _APART.sub("", text).strip(" ,") + out[k] = text + return out, apart + + +def _nums(text: str) -> list[float]: + return [float(x) for x in _NUM.findall(text)] + + +def _ranges(text: str) -> list[tuple[float, bool, float, bool]]: + return [(float(a), op1 != "<", float(b), op2 != "<") for a, op1, op2, b in _RANGE.findall(text)] + + +def _words(text: str) -> frozenset[str]: + return ( + frozenset(w for w in re.findall(r"[가-힣]{2,}", text) if not _region(w) and w not in SUPPLY) + - SKIP_WORDS + ) + + +@lru_cache(maxsize=2) +def _pool(got_id: int) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]: + """자재마다 바탕 이름 · 규격 수 · 범위 · 지역 · 관급/사급 — (목록, {바탕: [자재]}).""" + rows, index = [], {} + for at, one in enumerate(hm.targets()["자재"]): + name, spec = _nfkc(one["이름"]), _nfkc(one["규격"]) + words = set(re.findall(r"[가-힣]+", name)) + row = { + "one": one, + "at": at, + "base": base(name), + "nums": _nums(spec), + "ranges": _ranges(spec), + "spec": mm.plain_spec(spec), + "region": sorted(w for w in words if _region(w) and w not in NO_REGION), + "supply": next((w for w in SUPPLY if w in name), None), + "key": ( + tuple(sorted(_nums(spec))), + mm.plain_unit(one["단위"]), + _words(spec) | _words(" ".join(_paren_words(name))), + ), + } + row["grams"] = hm._grams(row["base"]) + rows.append(row) + index.setdefault(row["base"], []).append(row) + return rows, index + + +def pool() -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]: + return _pool(id(hm.targets())) + + +def _near(x: float, y: float) -> bool: + return any(abs(x * s - y) <= 0.02 * max(abs(y), 1e-9) for s in SCALES) + + +def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float: + """규격 맞음 0~1 — 같음 · 범위에 듦 · 줄 규격 수가 자재 규격 수에 있는 몫.""" + text = _nfkc(f"{line.get('규격') or ''} {' '.join(hm._PAREN.findall(str(line.get('이름'))))}") + if mm.plain_spec(line.get("규격")) == row["spec"]: + return 1.0 + mine = _nums(text) + if row["ranges"]: + inside = any( + (a < x or (lo and a == x)) and (x < b or (hi and x == b)) + for x in mine + for a, lo, b, hi in row["ranges"] + ) + return 1.0 if inside else 0.0 + if not mine or not row["nums"]: + return 0.0 + return sum(any(_near(x, y) for y in row["nums"]) for x in mine) / len(mine) + + +def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]: + """(관급/사급, 지역 낱말) — 줄 글 · 없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급.""" + text = _nfkc(f"{line.get('이름')} {line.get('규격')} {line.get('비고') or ''}") + words = set(re.findall(r"[가-힣]+", text)) + supply = next((w for w in SUPPLY if w in text), None) + if supply is None: + supply = ( + "관급" if clean(line)[1] else "사급" if (line.get("단가") or {}).get("재료비") else None + ) + return supply, [w for w in words if _region(w) and w not in NO_REGION] + + +def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]: + """변형 차례 — 줄 글의 관급/사급 · 지역 먼저 · 지역 없는 것 · 엑셀(M01) 차례.""" + supply, regions = _wants(line) + + def rank(r: dict[str, Any]) -> tuple: + hit = any(w[:2] == x[:2] for w in regions for x in r["region"]) + return ( + supply is not None and r["supply"] not in (None, supply), + bool(regions) and not hit, + bool(r["region"]), + r["at"], + ) + + return sorted(rows, key=rank) + + +def _paren_ok(line: dict[str, Any], row: dict[str, Any]) -> bool: + return _paren_words(_nfkc(line.get("이름"))) <= set(re.findall(r"[가-힣]+", row["one"]["이름"])) + + +def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: + """맞음 ② — 바탕 이름 같음 · 단위 · 괄호 낱말 · 규격 맞음 1 이 한 품목 → (변형 차례, 꼬리표) · + 아니면 ([], []).""" + _, index = pool() + rows = [ + r + for r in index.get(base(line.get("이름")), []) + if hm.fits(line.get("단위"), r["one"]["단위"]) and _paren_ok(line, r) + ] + full = [r for r in rows if spec_fit(line, r) == 1.0] + unit = mm.plain_unit(line.get("단위")) + full = [r for r in full if r["key"][1] == unit] or full # 같은 단위 먼저(kg ↔ Ton 둘 다면) + if not full or len({r["key"] for r in full}) > 1: + return [], [] + full = _prefer(full, line) + tags = [f"지역 · 관급/사급 못 가림 — {len(full)}개 중 첫째"] if len(full) > 1 else [] + return full, tags + + +def candidates(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]: + """자재 후보 — 바탕 이름 글자 묶음 + 바탕 같음 1 + 단위 0.15 + 규격 맞음 0.5 + 괄호 낱말 0.2 · + 뜻 낱말 · 규격 수 둘 다 안 닮으면 뺌 · 변형(지역 · 관급/사급)은 하나로 접음(글 = 변형 수).""" + rows, _ = pool() + me = base(line.get("이름")) + grams, mine = hm._grams(me), hm.words(line.get("이름")) + parens = _paren_words(_nfkc(line.get("이름"))) + scored: dict[tuple, list] = {} + for r in rows: + if not grams & r["grams"]: + continue + fit = spec_fit(line, r) + if not hm.near(mine, r["one"]["words"]) and fit < 0.5: + continue + score = 2 * len(grams & r["grams"]) / (len(grams) + len(r["grams"])) + score += (r["base"] == me) + 0.15 * hm.fits(line.get("단위"), r["one"]["단위"]) + score += 0.5 * fit + (0.2 if parens and _paren_ok(line, r) else 0) + fold = (r["base"], *r["key"]) + have = scored.setdefault(fold, [score, []]) + have[0] = max(have[0], score) + have[1].append(r) + best = sorted(scored.values(), key=lambda x: (-x[0], x[1][0]["at"]))[:limit] + out = [] + for score, group in best: + first = _prefer(group, line)[0] + one = {**hm._public(first["one"]), "점수": round(score, 3), "판정": "후보"} + out.append( + {**one, "글": f"변형 {len(group)}개 — 지역 · 관급/사급"} if len(group) > 1 else one + ) + return out + + +def marks(line: dict[str, Any], name: str = "") -> dict[str, str]: + """가져오기 세 칸(Labor_Doc `MARKS` 값) — 별도계상 · 자재구분(고른 자재 `name` → + 줄 글 → 줄 갈래) · 운반비(모르면 칸 없음 = 기본).""" + _, apart = clean(line) + out = {"별도계상": APART if apart else "직접계산"} + supply = next((w for w in SUPPLY if w in str(name)), None) or _wants(line)[0] + if supply: + out["자재구분"] = supply + text = re.sub(r"\s", "", _nfkc(f"{line.get('규격')} {line.get('비고') or ''}")) + if "운반비미포함" in text or "운반비별도" in text: + out["운반비"] = "미포함" + return out diff --git a/M02_MasterTemplete/M02_Hopyo_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py index 5d3a6cdd8..e81ea07ad 100644 --- a/M02_MasterTemplete/M02_Hopyo_Stmate.py +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -168,9 +168,12 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str def _kind(code: str, line: dict[str, Any]) -> str: if code.startswith("PR") or _flat(line["단위"]) == "%": return "%" + price = line["단가"] if code[0] == "M": - apart = line["비고"].startswith("별산") or line["규격"] == "별도계상" - return "별도계상" if apart or not any(line["단가"].values()) else "자재" + apart = line["비고"].startswith("별산") or "별도계상" in f"{line['규격']}{line['이름']}" + return "별도계상" if apart or not any(price.values()) else "자재" + if code[0] == "D" and price.get("재료비") and not price.get("노무비") and not price.get("경비"): + return "자재" # 재료비만 = 자재(358-2 · 엑셀꼴과 같음) return _KIND.get(code[0], "기타") diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index 06b15d287..132f61375 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -123,6 +123,29 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client): assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files +def test_자재_세_칸_로직_자재_두_번_막음(client): + """358-2 — 별도계상 줄 자재 = 세 칸(별도계상 · 관급) · 로직 자재와 같은 품목 = 400 아니고 + 사용자 자재에서 빼고 그 로직 `자재표시` 로 + 빠진 줄 까닭.""" + assert _post(client).status_code == 200 + apart = {"차례": 0, "대상": [{"자재": "MT004258", "배": 1}]} # 레미콘(25-21-120) 별도계상 줄 + got = _build(client, 1, [apart]) + mine = layers.personal_dir(7, 42) + mats = _doc(mine, got["참조"]["키"])["자재"] + assert [{k: m.get(k) for k in ("코드", "별도계상", "자재구분", "운반비")} for m in mats] == [ + {"코드": "MT004258", "별도계상": "별도계상", "자재구분": "사급", "운반비": None} + ] # 자재구분 = 고른 자재 이름(-사급) 먼저 + twice = [ + {"차례": 1, "대상": [{"로직": "GC000207", "배": 1}]}, # 철근 현장조립 = 로직 자재 결속선 + {"차례": 0, "대상": [{"자재": "MT000209", "배": 1}]}, # 같은 결속선을 별도계상 줄에 + ] + got = _build(client, 1, twice) + doc = _doc(mine, got["참조"]["키"]) + assert doc["자재"] == [] + line = next(x for x in doc["담은로직"] if x.get("로직") == "GC000207") + assert {"별도계상": "별도계상", "자재구분": "관급"} in line["자재표시"].values() + assert any(m["까닭"] == hb.TWICE and m["차례"] == 0 for m in got["빠진줄"]) + + def test_몸_없음_재검사_400(client): assert _post(client).status_code == 200 one = {"로직": "GF000101", "배": 1} diff --git a/resources/tester/test_m02_hopyo_flow.py b/resources/tester/test_m02_hopyo_flow.py index a65f991c9..fc0643847 100644 --- a/resources/tester/test_m02_hopyo_flow.py +++ b/resources/tester/test_m02_hopyo_flow.py @@ -82,7 +82,8 @@ def test_끝까지_확인한_줄만_별칭(client): assert "굴착기 0.29 → 0.2909 ✓" in remicon["compare"] assert "소량감률=50(블록)" in remicon["inputs"] and remicon["target"].endswith("[㎥]") states = {x["상태"] for x in screen["lines"]} - assert {"맞음", "다른 점", "후보"} <= states + assert {"맞음", "다른 점"} <= states # 358-2 — 레미콘 · 면목 자재 줄도 맞음(후보 남지 않음) + assert seen["면목"]["상태"] == "맞음" and seen["면목"]["checked"] body = screen["body"] # 화면이 만든 몸 = 서버 체크 제안 줄만 suggested = [x["차례"] for x in hopyo["줄"] if x["맞춤"]["체크"]] assert [x["차례"] for x in body] == suggested and screen["leftOut"]["확인 안 함"] > 0 diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index adc1a1ba7..761e25592 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -115,9 +115,11 @@ def test_새_상태_체크_제안(client): assert remicon["품"]["같음"]["콘크리트공"] and remicon["품"]["엑셀"]["인력"]["콘크리트공"] plywood = first["합판거푸집"] assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"} - mat = first["레미콘(25-21-120)"] - assert mat["상태"] == "후보" and mat["체크"] is False - assert all(c["규격"] == "25-21-120" for c in mat["후보"][:3]) # 규격 같은 자재 먼저 + mat = first["레미콘(25-21-120)"] # 358-2 — 「별도계상」 글 = 표시 · 지역 변형 = 첫째 + 꼬리표 + assert mat["상태"] == "맞음" and mat["체크"] is True and "별도계상" in mat["꼬리표"] + assert mat["대상"][0]["규격"].endswith("25-21-120") and "레미콘" in mat["대상"][0]["이름"] + assert any(t.startswith("지역 · 관급/사급 못 가림") for t in mat["꼬리표"]) + assert all(c["규격"].endswith("25-21-120") for c in mat["후보"]) # 변형 = 고를 수 있게 dig = _hopyo(client, 3)["구조물터파기(인력100%)"] assert dig["상태"] == "다른 점" and dig["체크"] is False assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5 @@ -268,3 +270,60 @@ def test_이름후보_뜻겹침_출처_표시(client): assert [c["로직"] for c in dig["후보"]][0] == "GC000083" # 차례 · 판정 그대로(표시만) mat = _hopyo(client, 1)["레미콘(25-21-120)"] assert "이름후보" not in mat # 자재 줄은 로직 이름후보 없음 + + +# ── 자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) ───────────────────────── +MATERIAL_ANSWER = { # 북부청 임도 자재 · 별도계상 줄 → 우리 자재(정답 표 · 사람 확인) + ("면목", "0.03×0.03×0.0424"): "MT007567", # 면 목 42, 30×30(0.03 m = 30 mm) + ("자물쇠", "50m/m"): "MT008011", + ("열연강판", "3.0mm"): "MT000884", # 3.0≤t<4.5 kg(같은 단위 먼저) + ("원형돌망태(몸통)", "#8,45×45"): "MT006349", + ("원형돌망태(뚜껑)", "#8,45×45"): "MT006350", +} + + +def test_자재_다듬기_규격_수(): + from M02_MasterTemplete import M02_Hopyo_Material as hmat + + line, apart = hmat.clean({"이름": "조약돌(별도계상)", "규격": "Ø100~200mm내외", "갈래": "자재"}) + assert apart and line["이름"] == "조약돌" + assert hmat.clean({"이름": "레미콘(25-21-120)", "규격": "별도계상"})[0]["규격"] == "" + assert hmat.base("레미콘(관급)-강원도(홍천군(내면 제외))") == hmat.base("레미콘(서울)-사급") + assert ( + hmat.base("와이어메쉬(용접철망)관급") + == hmat.base("와이어메쉬(용접철망),서울") + == "와이어메쉬" + ) + row = {"spec": "", "nums": [42.0, 30.0, 30.0], "ranges": []} + assert hmat.spec_fit({"이름": "면목", "규격": "0.03×0.03×0.0424"}, row) == 1.0 + rng = {"spec": "", "nums": [], "ranges": hmat._ranges("2.0≤T<3.0")} + assert hmat.spec_fit({"이름": "열연강판", "규격": "3.0mm"}, rng) == 0.0 # 끝 3.0 은 안 듦 + got = hmat.marks( + {"이름": "레미콘", "규격": "별도계상", "갈래": "별도계상", "비고": "운반비 별도"} + ) + assert got == {"별도계상": "별도계상", "자재구분": "관급", "운반비": "미포함"} + assert hmat.marks({"이름": "면목", "단가": {"재료비": 360}}, "면 목") == { + "별도계상": "직접계산", + "자재구분": "사급", + } + + +def test_북부청_자재_별도계상_맞음_늚(client): + _ready(client) + seen, sure = {}, 0 + for no in range(1, 69): + res = client.get(f"{URL.format('personal')}/HP-000001/{no}") + if res.status_code != 200: + continue + for one in res.json()["호표"]["줄"]: + key = (one["이름"], one["규격"]) + if one["갈래"] in ("자재", "별도계상") and key not in seen: + seen[key] = one["맞춤"] + sure = sum(m["상태"] == "맞음" for m in seen.values()) + assert len(seen) == 30 and sure >= 8 # 옛 2 → 8(면목 · 레미콘 둘 · 와이어메쉬 · 열연강판 …) + for key, code in MATERIAL_ANSWER.items(): + assert seen[key]["상태"] == "맞음" and seen[key]["대상"][0]["자재"] == code, key + pipe = seen[("탄소강관(백)", "D=125mm(외경139.8mm)")] # 이름 달라도 규격 수로 후보 + assert pipe["상태"] == "후보" and "139.8" in pipe["후보"][0]["규격"] + mortar = seen[("드라이모르터", "조적용레미탈")] # 이름 오타 · 규격 같음 = 1위 후보 + assert mortar["후보"][0]["자재"] == "MT004251" From 1f0c35dcdbf7b17f8d2e7506083f19ce061d8971 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:16:42 +0900 Subject: [PATCH 27/33] =?UTF-8?q?feat(M02):=20359-3=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=EC=B0=BD=20=EB=A8=B8=EB=A6=AC=20=EA=B8=88=EC=95=A1=20?= =?UTF-8?q?=EC=B9=B8=20=EB=9C=BB=20=ED=95=9C=20=EC=A4=84(=EC=9C=84=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=C2=B7=20=EC=95=84=EB=9E=98=20=EC=9A=B0?= =?UTF-8?q?=EB=A6=AC=20=EB=AF=B8=EB=A6=AC=20=EC=85=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index d6e2fa8a1..c6888dcc4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -505,6 +505,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { note(h.규격), note(h.단위), note(`엑셀 단가 ${won(h.금액.계)}`), + note("금액 칸 위 = 엑셀 · 아래 = 우리(미리 셈)"), note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`), ...(calc && !calc.끝 ? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)] From ba2785cc495bb00c72acda227fec94cc111d31ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:23:57 +0900 Subject: [PATCH 28/33] =?UTF-8?q?fix(M02=20=EC=8B=A4=EB=AC=B4=EC=9E=90?= =?UTF-8?q?=EB=A3=8C):=20358-3=20=EB=8B=A8=EA=B3=84=20=EB=8C=80=EC=A1=B0?= =?UTF-8?q?=20=EB=B3=B4=EC=9E=84=20=E2=80=94=20=EC=9E=A5=EB=B6=80=20?= =?UTF-8?q?=EC=A7=9D=EB=B3=B4=EB=8B=A4=20=EC=88=AB=EC=9E=90=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=EB=A7=9E=EC=9D=8C=20=EB=A8=BC=EC=A0=80=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=9E=A5=EB=B6=80=EC=99=80=20=EB=8B=A4=EB=A6=84?= =?UTF-8?q?=E3=80=8D=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20=EC=88=98=20=3D=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=B0=BD=20=EC=88=98=20=C2=B7=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=ED=95=A0=20=C2=B7=20=EC=97=B0=EA=B2=B0=EB=90=9C=20?= =?UTF-8?q?=EB=91=90=20=EC=B9=B8=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20=EC=97=B4?= =?UTF-8?q?=20=EB=95=8C=20=EB=92=A4=20=EC=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장부 짝(정확 열쇠)이 숫자 판정 맞음 1위를 안 담으면 판정 먼저 + 꼬리표 「장부와 다름」 · 짝은 후보 끝 덤(장부 줄 안 지움) - 체크 제안 한 규칙(_check) — 목록(_brief) · 확인 창(_numeric) 같음 · 응답 연결할(% · 노임 · 뺌 밖) · 연결된 - 목록 길(GET hopyo)이 지금 판 맞춤셈 없는 파일을 뒤 셈 예약 - 실물 북부청(사용자 장부 포함 · 읽기만) 체크 제안 113 → 204 / 415 · 장부와 다름 24줄 · 자재 체크 94 / 142 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Line.py | 76 ++++++++++++++----- M02_MasterTemplete/M02_Hopyo_Match.py | 5 +- M02_MasterTemplete/M02_Labor_Router.py | 6 +- .../M02_MasterTemplete_Hopyo_Api.ts | 6 ++ resources/tester/test_m02_hopyo_match.py | 35 ++++++++- 5 files changed, 102 insertions(+), 26 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 9cee76c6b..cacdc3c2d 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -4,7 +4,8 @@ 상태 차례 = ① % ② 호표 참조(만들 층에 확인된 사전조합이면 체크) ③ 노임(그릇 없음) ④ 장부 「우리에 없음 · -안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(근거 다름 · 숫자 판정 맞음이 다른 로직이면 체크 안 함) ⑥ +안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(근거 다름이면 체크 안 함 · 숫자 판정 맞음이 다른 로직이면 +⑥ 판정 먼저 + 꼬리표 「장부와 다름」 · 짝은 후보 끝 덤 · 358-3) ⑥ 맞춤셈 판정(맞음 = 체크 · 다른 점 · 후보) ⑦ 셈 전 · 숫자 없음 → 이름 · 근거 후보(「셈 중」 · 「숫자 판정 없음」). @@ -38,6 +39,9 @@ from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store SKIP = ("우리에 없음", "안 씀") +LOOSE = "별칭 · 규격 다름" +LEDGER_OFF = "장부와 다름" +OUTSIDE = ("%", "노임", "뺌") # 연결 셈 밖(358-3 ②) class Ctx: @@ -118,6 +122,26 @@ def _qty(entry: dict, found: dict | None) -> dict: return {"엑셀": ex, "우리": us, "같음": same} +def _picked(entry: dict) -> list[dict]: + """체크할 조합 — 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표).""" + combo = [c for c in entry.get("조합") or [] if c["체크"]] + return combo if len(combo) > 1 else [] + + +def _check(entry: dict, line: dict, ctx: Ctx) -> bool: + """체크 제안 한 규칙(358-3 · 목록 수 = 확인 창) — 판정 맞음 · 대상(조합 또는 1위) 있음 · + 대상마다 환산 배 있음(추정 아님).""" + codes = [c["로직"] for c in _picked(entry)] or (entry.get("차례") or [])[:1] + if entry["판정"]["상태"] != "맞음" or not codes: + return False + for code in codes: + unit = (ctx.names.get(code) or {}).get("단위", "") + conv = hm.factor(line.get("단위"), unit, line.get("규격")) + if conv["배"] is None or conv["갈래"] == "추정": + return False + return True + + def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: """⑥ 맞춤셈 판정 → 상태 · 대상 · 후보 · 품 · 글 · 꼬리표.""" judged = entry["판정"] @@ -125,8 +149,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: top = (entry.get("차례") or [None])[0] found = (entry.get("옵션") or {}).get(top) use = state in ("맞음", "다른 점") - combo = [c for c in entry.get("조합") or [] if c["체크"]] - combo = combo if len(combo) > 1 else [] # 체크할 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표) + combo = _picked(entry) if combo: targets = [ _target( @@ -147,8 +170,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: if any("블록" in t["출처"].values() for t in targets): tags.append("옵션 — 블록에서") tags += [f"근거 {b} {n}" for b, n in (entry.get("지문") or {}).get("출처") or []] - ok = all(t["배"] is not None and t["환산"]["갈래"] != "추정" for t in targets) - out = {"상태": state, "체크": state == "맞음" and bool(targets) and ok, "대상": targets} + out = {"상태": state, "체크": _check(entry, line, ctx), "대상": targets} out.update(후보=entry.get("후보") or [], 글=judged["글"], 꼬리표=tags, 품=_qty(entry, found)) if found and found.get("갈리는칸"): out["갈리는칸"] = found["갈리는칸"] @@ -225,12 +247,15 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A entry = ctx.cache.get(fit.unit_key(line)) if kind not in hm.MATERIAL_KINDS else None if alias and alias.get("갈래") in SKIP: return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)} + if alias and alias.get("갈래") == "짝" and alias.get("짝") and _ledger_off(entry, alias): + loose, alias = alias, None # 숫자 판정 맞음 = 장부 짝 아닌 로직 → 판정 먼저(358-3 ①) + loose = {**loose, "_까닭": LEDGER_OFF} if alias and alias.get("갈래") == "짝" and alias.get("짝"): got = _aliased(line, ctx, alias, entry) elif kind in hm.MATERIAL_KINDS: got = _material(line, ctx, full) elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"): - got = _numeric(line, ctx, entry) if full else _brief(entry) + got = _numeric(line, ctx, entry) if full else _brief(entry, line, ctx) else: why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER got = _names_only(line, ctx, why, full) @@ -257,10 +282,17 @@ def _grouped(got: dict, line: dict, ctx: Ctx) -> dict: return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named} +def _ledger_off(entry: dict | None, alias: dict) -> bool: + """장부 짝(정확 열쇠)이 숫자 판정 맞음 1위를 안 담음.""" + codes = {p.get("로직") for p in alias.get("짝") or []} + return bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes) + + def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict: - """규격 다른 별칭 — 꼬리표 「별칭 · 규격 다름」 + 짝 로직 · 자재가 후보에 없으면 끝에 덤(상태 · - 대상 · 옵션은 이 줄 숫자 그대로).""" - tags = [*(got.get("꼬리표") or []), "별칭 · 규격 다름"] + """규격 다른 별칭 · 숫자 판정과 다른 장부 짝 — 꼬리표(「별칭 · 규격 다름」 · 「장부와 다름」) + + 짝 로직 · 자재가 후보에 없으면 끝에 덤(상태 · 대상 · 옵션은 이 줄 숫자 그대로).""" + word = alias.get("_까닭") or LOOSE + tags = [*(got.get("꼬리표") or []), word] have = {c.get("로직") or c.get("자재") for c in got.get("후보") or []} have |= {t.get("로직") or t.get("자재") for t in got.get("대상") or []} extra = [] @@ -272,13 +304,17 @@ def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict: for k, v in _pair(p, line, ctx).items() if k in ("로직", "자재", "이름", "단위") } - extra.append({**one, "점수": None, "판정": "후보", "글": "별칭 · 규격 다름"}) + extra.append({**one, "점수": None, "판정": "후보", "글": word}) return {**got, "꼬리표": tags, "후보": [*(got.get("후보") or []), *extra]} -def _brief(entry: dict) -> dict: - state = entry["판정"]["상태"] - return {"상태": state, "체크": state == "맞음", "글": entry["판정"]["글"]} +def _brief(entry: dict, line: dict, ctx: Ctx) -> dict: + """목록 수(`full` 아님) — 체크는 확인 창과 같은 `_check`.""" + return { + "상태": entry["판정"]["상태"], + "체크": _check(entry, line, ctx), + "글": entry["판정"]["글"], + } def _alias_info(alias: dict, basis_off: bool) -> dict: @@ -287,7 +323,7 @@ def _alias_info(alias: dict, basis_off: bool) -> dict: def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict: - """⑤ 장부 짝(정확 열쇠) — 체크 = 근거 같음 · 숫자 판정 맞음이 다른 로직 아님.""" + """⑤ 장부 짝(정확 열쇠 · 숫자 판정 맞음 1위를 담음 또는 판정 맞음 아님) — 체크 = 근거 같음.""" targets = [_pair(p, line, ctx) for p in alias["짝"]] codes = {t["로직"] for t in targets if t.get("로직")} theirs = ((line.get("근거") or {}).get("출처")) or [] @@ -295,12 +331,9 @@ def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict: off = bool(theirs and codes) and all( hm.clause(theirs, table[c])[0] < 0.9 for c in codes if c in table ) - other = bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes) - check = not off and not other tags = ["별칭 — " + " · ".join(str(x) for x in (alias.get("층"), alias.get("때")) if x)] tags += ["근거 다름"] * off - tags += [f"숫자 판정 맞음 = {entry['차례'][0]}"] if other else [] - out = {"상태": "별칭", "체크": check, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags} + out = {"상태": "별칭", "체크": not off, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags} out["별칭"] = _alias_info(alias, off) if entry and entry.get("품"): out["품"] = _qty(entry, (entry.get("옵션") or {}).get(next(iter(codes), None))) @@ -327,10 +360,13 @@ def whole(hopyo: dict, ctx: Ctx) -> dict | None: def hopyo_view(hopyo: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]: - """호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 통째 · 셈 · 만든.""" + """호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 연결할(% · 노임 · 뺌 밖 줄 수) · + 연결된(그중 체크 제안 수 · 358-3) · 통째 · 셈 · 만든.""" lines = [{**one, "맞춤": match_line(one, ctx, full)} for one in hopyo.get("줄") or []] check = sum(1 for one in lines if one["맞춤"]["체크"]) - out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)]} + need = [one for one in lines if one["맞춤"]["상태"] not in OUTSIDE] + out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)], "연결할": len(need)} + out["연결된"] = sum(1 for one in need if one["맞춤"]["체크"]) if full: made = ctx.done.get(hopyo_build.mark(ctx.key, int(hopyo["번"]))) out.update(통째=whole(hopyo, ctx), 셈=ctx.calc) diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 5f77d3b4e..88c7fa902 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -455,11 +455,12 @@ def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) doc = ctx.doc if no is None: hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []] - keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행") + keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행", "연결할", "연결된") brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos] ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc} - return {**head, "호표": brief, "맞춤": ready} + links = {k: sum(h[k] for h in hopyos) for k in ("연결할", "연결된")} + return {**head, "호표": brief, "맞춤": ready, **links} hopyo = ctx.units.get(int(no)) if hopyo is None: raise store.StoreError(404, f"없는 호표 「제{no}호표」") diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 7480fb174..96ee36f62 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -215,7 +215,11 @@ def hopyo_list( ) -> dict: """실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`.""" folder = _root(session, layer, owner) - return {"줄": hopyo_store.rows(folder)} + rows = hopyo_store.rows(folder) + if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: + for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③) + ensure(folder, row["키"]) + return {"줄": rows} @router.get("/layers/{layer}/hopyo-picks") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 2b1d01c28..8f223012f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -36,6 +36,9 @@ export interface HopyoBrief { 금액: Money; /** [체크 제안 수, 줄 수] */ 맞춤: [number, number]; + /** 연결할 줄 수(% · 노임 · 뺌 밖 · 358-3) · 연결된 줄 수(그중 체크 제안 — 확인 창과 한 규칙) */ + 연결할?: number; + 연결된?: number; /** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */ 행?: [number, number]; } @@ -58,6 +61,9 @@ export interface HopyoView extends HopyoFile { 검산: { 어긋남: string[]; 총괄표: number }; 호표: HopyoBrief[]; 맞춤: [number, number]; + /** 파일 전체 연결할 · 연결된 줄 수(358-3) */ + 연결할?: number; + 연결된?: number; /** 뒤 숫자 판정 셈(끝나면 맞춤 수가 바뀜) */ 셈?: HopyoCalc; } diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 761e25592..55eb7c88c 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -159,9 +159,10 @@ def test_별칭_체크_규칙(client): assert got["상태"] == "별칭" and got["체크"] is True and got["대상"][0]["출처"] == {} assert got["별칭"]["층"] == "personal" and not got["별칭"]["근거다름"] first = _hopyo(client, 1) - other = first["레미콘타설(장비사용)"] # 숫자 판정 맞음 = GC000198 → 체크 안 함 - assert other["상태"] == "별칭" and other["체크"] is False - assert "숫자 판정 맞음 = GC000198" in other["꼬리표"] + other = first["레미콘타설(장비사용)"] # 숫자 판정 맞음 = GC000198 ≠ 장부 짝 → 판정 먼저(358-3) + assert other["상태"] == "맞음" and other["체크"] is True and "별칭" not in other + assert other["대상"][0]["로직"] == "GC000198" and "장부와 다름" in other["꼬리표"] + assert "GC000199" in {c["로직"] for c in other["후보"]} # 장부 짝 = 후보 끝 덤(안 지움) loose = first["합판거푸집"] # 규격 4회 ↔ 장부 6회 — 느슨 열쇠 = 꼬리표 · 후보 덤만(229-4 ①) assert loose["상태"] == "맞음" and "별칭" not in loose and "별칭 · 규격 다름" in loose["꼬리표"] assert all("별칭" not in t["출처"].values() for t in loose["대상"]) @@ -327,3 +328,31 @@ def test_북부청_자재_별도계상_맞음_늚(client): assert pipe["상태"] == "후보" and "139.8" in pipe["후보"][0]["규격"] mortar = seen[("드라이모르터", "조적용레미탈")] # 이름 오타 · 규격 같음 = 1위 후보 assert mortar["후보"][0]["자재"] == "MT004251" + + +# ── 단계 대조 보임(358-3) — 목록 수 = 확인 창 수 · 연결 두 칸 · 목록 열 때 뒤 셈 ───────────── +def test_목록_수_확인_창_수_한_규칙(client): + _ready(client) + whole = client.get(f"{URL.format('personal')}/HP-000001").json() + need = done = 0 + for brief in whole["호표"]: + one = client.get(f"{URL.format('personal')}/HP-000001/{brief['번']}").json()["호표"] + lines = one["줄"] + assert brief["맞춤"] == [sum(x["맞춤"]["체크"] for x in lines), len(lines)], brief["번"] + inside = [x for x in lines if x["맞춤"]["상태"] not in ("%", "노임", "뺌")] + assert (brief["연결할"], brief["연결된"]) == (one["연결할"], one["연결된"]) + assert one["연결할"] == len(inside) and one["연결된"] == sum( + x["맞춤"]["체크"] for x in inside + ) + need, done = need + one["연결할"], done + one["연결된"] + assert (whole["연결할"], whole["연결된"]) == (need, done) and 0 < done <= need < 415 + pct = next(h for h in whole["호표"] if h["번"] == 46) # 잡재료비 % 줄 = 셈 밖 + assert pct["연결할"] < pct["맞춤"][1] + + +def test_목록_열_때_뒤_셈_예약(client, monkeypatch): + assert _post(client).status_code == 200 + asked = [] + monkeypatch.setattr(fit, "ensure", lambda folder, key: asked.append(key) or {"끝": False}) + rows = client.get(URL.format("personal")).json()["줄"] + assert [r["키"] for r in rows] == asked == ["HP-000001"] From ce483395bba1d9e80fee84dea2e126d608630336 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:25:08 +0900 Subject: [PATCH 29/33] =?UTF-8?q?fix(M02):=20359=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=8D=A7=EA=B3=A0=EC=B9=A8=20=E2=80=94=20?= =?UTF-8?q?=EB=92=A4=20=ED=83=AD=20=EB=AF=B8=EB=A6=AC=20=EC=85=88=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20=C2=B7=20[=EC=88=98=EC=A0=95]=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20[=EC=A0=80=EC=9E=A5]?= =?UTF-8?q?=20=EB=AC=BC=EC=9D=8C=20=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 확인 창 금액 미리 셈 다시 그림 = setTimeout(뒤 탭에서 rAF 멈춤 → 「…」 남던 것) - 엑셀 보기 목록 번호 칸 폭 고정 — [수정] 줄마다 같은 자리 - [저장] 물음 「확인 창을 안 연」 → 「확인 창에서 안 고친」(캐시 = 만진 호표) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- M02_MasterTemplete/M02_MasterTemplete_Hopyo.css | 1 + M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts | 6 +++--- M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 5a6bc6ea6..909abab1b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -293,6 +293,7 @@ } .m02-hopyo-sheet__no { + min-width: 4.5em; /* [수정] 단추가 줄마다 같은 자리 */ white-space: nowrap; font-variant-numeric: tabular-nums; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index c6888dcc4..c07f6e4cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -331,14 +331,14 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { ); return slot.got; } - /** 응답 여럿 = 한 번 그림 */ + /** 응답 여럿 = 한 번 그림(뒤 탭에서도 — rAF 는 멈춤) */ function soon(): void { if (pending) return; pending = true; - requestAnimationFrame(() => { + window.setTimeout(() => { pending = false; if (root.isConnected) draw(); - }); + }, 0); } const ctx = (): RowsContext => ({ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 892e71415..c0a37d2b5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -397,7 +397,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length; const ask = `고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` + - (fresh ? ` — 확인 창을 안 연 ${fresh}개는 서버 제안 체크 줄` : ""); + (fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : ""); if (!(await showConfirmDialog(ask, "저장"))) return; go.disabled = true; const ones: HopyoOne[] = []; From 86d82a78b2a5b74115a7ba68cd0e1581bddc12cf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:26:51 +0900 Subject: [PATCH 30/33] =?UTF-8?q?feat(M02):=20359-2=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EB=85=B9=EC=83=89=20=3D=20358-3=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=ED=95=A0=20=C2=B7=20=EC=97=B0=EA=B2=B0=EB=90=9C=20=EB=91=90=20?= =?UTF-8?q?=EC=B9=B8=20=EB=A8=BC=EC=A0=80(=EC=97=86=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EC=98=9B=20=EB=A7=9E=EC=B6=A4=20=EC=88=AB=EC=9E=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../M02_MasterTemplete_Hopyo_Sheet.ts | 20 +++++++++++++++---- resources/tester/helper_m02_hopyo_check.cjs | 11 ++++++---- resources/tester/test_m02_hopyo_screen.py | 5 +++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index c0a37d2b5..687db5ab1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -82,8 +82,20 @@ const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); -/** 목록 숫자 「연결된 줄 / 연결할 줄」 — 다 연결(같고 0 넘음)이면 참 · 0/0 은 색 없음 */ -export const allLinked = (m: [number, number]): boolean => m[1] > 0 && m[0] === m[1]; +/** 목록 숫자 「연결된 줄 / 연결할 줄」(358-3 두 칸 · 없으면 옛 맞춤 [제안, 줄]) — 다 연결(같고 0 넘음)이면 참 · + * 0/0 은 색 없음 */ +export function allLinked(h: { + 맞춤: [number, number]; + 연결할?: number; + 연결된?: number; +}): boolean { + const [done, all] = h.연결할 === undefined ? h.맞춤 : [h.연결된 ?? 0, h.연결할]; + return all > 0 && done === all; +} + +/** 목록 숫자 글 — 358-3 두 칸이 있으면 그것 */ +const linkText = (h: { 맞춤: [number, number]; 연결할?: number; 연결된?: number }): string => + h.연결할 === undefined ? `${h.맞춤[0]}/${h.맞춤[1]}` : `${h.연결된 ?? 0}/${h.연결할}`; /** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */ export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null => @@ -280,8 +292,8 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { else checked.delete(h.번); paintGo(); }); - const linked = note(`${h.맞춤[0]}/${h.맞춤[1]}`); - linked.classList.toggle("is-linked", allLinked(h.맞춤)); + const linked = note(linkText(h)); + linked.classList.toggle("is-linked", allLinked(h)); const row = el("div", { className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`, attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() }, diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index b3d3acdd3..90af13bf0 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -224,10 +224,13 @@ let out0 = null; { 번: 8, 아래: [7] }, ]), linked: [ - [2, 2], - [1, 2], - [0, 0], - [3, 2], + { 맞춤: [2, 2] }, + { 맞춤: [1, 2] }, + { 맞춤: [0, 0] }, + { 맞춤: [3, 2] }, + { 맞춤: [1, 4], 연결할: 3, 연결된: 3 }, // 358-3 두 칸이 앞섬(% · 노임 줄 밖) + { 맞춤: [3, 3], 연결할: 3, 연결된: 2 }, + { 맞춤: [0, 1], 연결할: 0, 연결된: 0 }, ].map(sheet.allLinked), }, }; diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index ca7514377..b07eda4c2 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -194,7 +194,8 @@ def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창(): def test_엑셀_보기_창_하위_호표_먼저(): got = _check()["sheet"] assert got["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례 - assert got["linked"] == [True, False, False, False] # 다 연결만 녹색 · 0/0 색 없음(359-2) + # 다 연결만 녹색 · 0/0 색 없음(359-2) · 358-3 연결할 · 연결된 두 칸이 있으면 그것 + assert got["linked"] == [True, False, False, False, True, False, False] def _pick() -> dict: @@ -299,7 +300,7 @@ def test_옛_즉시_확정과_바로_만들기_없음(): assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit row = sheet.split("function paintList(")[1] assert row.index("m02-hopyo-sheet__no") < row.index("editButton(h)") < row.index("__name") - assert 'linked.classList.toggle("is-linked", allLinked(h.맞춤));' in row + assert 'linked.classList.toggle("is-linked", allLinked(h));' in row pick = texts["Hopyo_Pick"] assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick assert not (M02 / "M02_MasterTemplete_Hopyo_View.ts").exists() # 본 칸 표 없음(359-1) From 0576420d824a429e492e2e7a09cfdbbc3b7fdd88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:31:24 +0900 Subject: [PATCH 31/33] =?UTF-8?q?fix(M02):=20=EB=A1=9C=EC=BC=80=EC=9D=BC?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B8=80=20=EB=AA=85=EC=82=AC=ED=98=95?= =?UTF-8?q?=20=E2=80=94=20=EC=8B=A0=EA=B7=9C=20=C2=B7=20=EB=B3=B5=EC=A0=9C?= =?UTF-8?q?=20=C2=B7=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=C2=B7=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=82=AD=EC=A0=9C=20=C2=B7=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=ED=9A=8C=EC=82=AC=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EA=B0=9C=EC=9D=B8=EC=9A=A9=20=EB=B3=B5=EC=82=AC=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20(235-27)?= 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_014utrjfoW831Vc5wJUd13kL --- .../M02_MasterTemplete_DetailNav.ts | 2 +- .../M02_MasterTemplete_Layer.ts | 4 ++-- .../M02_MasterTemplete_Practice.ts | 2 +- .../M02_MasterTemplete_UI_Main.ts | 2 +- .../M02_MasterTemplete_UI_Page.ts | 2 +- resources/tester/test_m02_detail_nav.py | 2 +- .../tester/ui_checks/43-1_수량산출_모달.js | 2 +- .../tester/ui_checks/44-6_일위대가_표.js | 4 ++-- .../tester/ui_checks/52-10_노면공_기타.js | 2 +- .../tester/ui_checks/52-5_횡단수로_개거.js | 2 +- ui_template/ui_template_locale_m2.ts | 20 +++++++++---------- 11 files changed, 22 insertions(+), 22 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts b/M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts index 39ea53ed2..568cf5a4e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_DetailNav.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_DetailNav.ts * 상세 머리 제목과 [?] 사이 [도면][일위대가][면적산출] 묶음(105-4) — 같은 열의 다른 상세로 옮겨 감. - * 지금 상세 단추 = 숨김(113-2) · 단추 크기 = 머리 [다시 열기]와 같은 보통 · 그 열 문서가 없으면 그 단추 꺼짐(번호 목록에 번호 없음). + * 지금 상세 단추 = 숨김(113-2) · 단추 크기 = 머리 [새로고침]와 같은 보통 · 그 열 문서가 없으면 그 단추 꺼짐(번호 목록에 번호 없음). * 보임 조건 · 옮겨 가기(confirmLeave → open)는 부른 쪽(`UI_Main`) 몫. * ========================================================================== */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 77736b7d4..ce046f785 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -3,7 +3,7 @@ * M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시 * `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…` * (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · - * 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대. + * 회사 없음 안내 · 회사용 읽기만일 때 [개인용 복사] 막대. * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; @@ -201,7 +201,7 @@ export interface ReadOnlyBar { setColumn: (column: string | null) => void; } -/** 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사](고른 열 하나를 내 개인 층으로) */ +/** 회사용을 읽기만 볼 때 — 안내 + [개인용 복사](고른 열 하나를 내 개인 층으로) */ export function buildReadOnlyBar(user: SessionUser | null): ReadOnlyBar { let column: string | null = null; const copy = createButton({ label: L("M02_CopyToPersonal"), variant: "filled", size: "sm" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Practice.ts b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts index f3b72ceac..0649c592c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Practice.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts @@ -2,7 +2,7 @@ * M02_MasterTemplete_Practice.ts * 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) — * 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) · - * [통째 지우기](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인). + * [전체 삭제](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인). * 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫. * ========================================================================== */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index b4c77c65f..ed0af9ff2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -255,7 +255,7 @@ export function createMain( nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음 const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null; const help = kind && helpParts(HELP_TITLE[kind], detailHelp(kind)); // 열 때마다 새로 — 상자는 닫힘 - help?.button.classList.remove("ui-btn--sm"); // 머리 [?] = [다시 열기]와 같은 보통 크기(113-2 · 카드 [?]는 작게) + help?.button.classList.remove("ui-btn--sm"); // 머리 [?] = [새로고침]와 같은 보통 크기(113-2 · 카드 [?]는 작게) helpSlot.replaceChildren(...(help ? [help.button, help.box] : [])); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index c44e253e8..2f8f094bb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -72,7 +72,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise str: def test_단추_셋_묶음(run): assert run["labels"] == ["도면", "일위대가", "면적산출"] assert "ui-btn-group" in run["className"] - assert run["sizes"] == [None, None, None] # 113-2 — [다시 열기]와 같은 보통 크기 + assert run["sizes"] == [None, None, None] # 113-2 — [새로고침]와 같은 보통 크기 def test_지금_것_숨김_없는_문서_꺼짐(run): diff --git a/resources/tester/ui_checks/43-1_수량산출_모달.js b/resources/tester/ui_checks/43-1_수량산출_모달.js index ffab377bb..283aa48ba 100644 --- a/resources/tester/ui_checks/43-1_수량산출_모달.js +++ b/resources/tester/ui_checks/43-1_수량산출_모달.js @@ -124,7 +124,7 @@ btn(im, "취소")?.click(); await W(300); // ⑦ 버림 - btn($(".m02-main__head"), "다시 열기").click(); + btn($(".m02-main__head"), "새로고침").click(); await W(600); btn($(".ui-confirm"), "확인")?.click(); await W(4500); diff --git a/resources/tester/ui_checks/44-6_일위대가_표.js b/resources/tester/ui_checks/44-6_일위대가_표.js index 030a8102c..acabcb4b5 100644 --- a/resources/tester/ui_checks/44-6_일위대가_표.js +++ b/resources/tester/ui_checks/44-6_일위대가_표.js @@ -1,7 +1,7 @@ /* 44-6 일위대가 단가산출 로직 표 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌. * * 시작 화면: M02 마스터 템플릿(시스템 관리자) · 좌측 일위대가 줄 「SU-000003」 이 보이는 상태 · - * 첫 로직 줄(GF000219 돌쌓기)에 고르기 입력이 있음 · 저장은 안 함(끝에 [다시 열기] → 확인 → 버림). + * 첫 로직 줄(GF000219 돌쌓기)에 고르기 입력이 있음 · 저장은 안 함(끝에 [새로고침] → 확인 → 버림). * 기대 수치: * heads[2] = "별칭" · alias.inputs = 줄 수 · foot.totalBorder ≥ 1.5(2px · 화면 배율) · foot.totalBg ≠ 다른 금액 칸 배경 * opt.row = true · opt.colspan = 3(구분 ~ 별칭 · 나머지 칸은 위 줄 rowspan 2 · 113-1) · opt.nowrap = "nowrap" · opt.ellipsis = "ellipsis" · @@ -87,7 +87,7 @@ await W(3500); out.alias.kept = $("[data-labor-alias]", first())?.value; // 버림 - btn($(".m02-main__head"), "다시 열기").click(); + btn($(".m02-main__head"), "새로고침").click(); await W(600); btn($(".ui-confirm"), "확인")?.click(); await W(4500); diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js index 7bc7c221f..4484f84fb 100644 --- a/resources/tester/ui_checks/52-10_노면공_기타.js +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -115,7 +115,7 @@ await until(() => document.querySelector(".ui-item-picker")); await w(600); const modal = document.querySelector(".ui-item-picker"); - const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"]; + const ctl = ["닫기", "개인용 복사", "관리 화면 편집", "해제", "적용"]; const leaf = [...modal.querySelectorAll("button")].find((b) => { const t = b.textContent.trim(); return t && !/^[▸▾]/.test(t) && !ctl.includes(t); diff --git a/resources/tester/ui_checks/52-5_횡단수로_개거.js b/resources/tester/ui_checks/52-5_횡단수로_개거.js index 8b7b11211..f7f3f478c 100644 --- a/resources/tester/ui_checks/52-5_횡단수로_개거.js +++ b/resources/tester/ui_checks/52-5_횡단수로_개거.js @@ -68,7 +68,7 @@ await w(600); const modal = document.querySelector(".ui-item-picker"); // 끝 항목 = ▸ · ▾ 로 안 시작하고 모달 조작 단추가 아닌 첫 단추 - const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"]; + const ctl = ["닫기", "개인용 복사", "관리 화면 편집", "해제", "적용"]; const leaf = [...modal.querySelectorAll("button")].find((b) => { const t = b.textContent.trim(); return t && !/^[▸▾]/.test(t) && !ctl.includes(t); diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index c0af92511..aa695e38e 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -11,11 +11,11 @@ export const ui_locales_m2 = { M02_KindTable: ["구조물 조합", "Structure combos"], M02_KindDrawing: ["도면 양식", "Drawing templates"], M02_NoTemplates: ["양식 없음", "None"], - M02_New: ["새로", "New"], - M02_Copy: ["본떠 만들기", "Copy as new"], + M02_New: ["신규", "New"], + M02_Copy: ["복제", "Duplicate"], M02_Delete: ["지우기", "Delete"], M02_NewTitle: ["새 양식", "New template"], - M02_CopyTitle: ["본떠 만들기", "Copy as new"], + M02_CopyTitle: ["복제", "Duplicate"], M02_Kind: ["종류", "Kind"], M02_Name: ["이름", "Name"], M02_Create: ["만들기", "Create"], @@ -31,7 +31,7 @@ export const ui_locales_m2 = { M02_Created: ["만들었음", "Created"], M02_Saved: ["저장했음", "Saved"], M02_Save: ["저장", "Save"], - M02_Reload: ["다시 열기", "Reopen"], + M02_Reload: ["새로고침", "Refresh"], M02_PickTemplate: ["왼쪽에서 양식을 고를 것", "Pick a template on the left"], M02_ReadOnlyLayer: ["이 층은 여기서 고치지 못함", "This layer is read-only here"], @@ -61,7 +61,7 @@ export const ui_locales_m2 = { "회사용은 복사해서 씀 — 고치려면 고른 열을 개인용으로 복사", "Company templates are read-only — copy the selected column to your own to edit", ], - M02_CopyToPersonal: ["개인용으로 복사", "Copy to personal"], + M02_CopyToPersonal: ["개인용 복사", "Copy to personal"], M02_CopyPickFirst: ["복사할 열을 구조물 조합에서 먼저 고름", "Pick a column in the table first"], M02_CopyDone: ["개인용으로 복사함 — 「개인」 뿌리에서 고침", "Copied — edit it in your own"], M02_CopyFailed: ["복사 안 됨 — {value}", "Copy failed — {value}"], @@ -69,7 +69,7 @@ export const ui_locales_m2 = { "회사부터 등록해야 구조물 조합을 만들 수 있음", "Register a company first to manage structure templates", ], - M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"], + M02_NeedCompanyGo: ["회사 등록", "Company registration"], M02_PickerTitle: ["구조물 조합 선택", "Select structure combo"], M02_PickerSearch: ["항목 찾기", "Search items"], M02_PickerSystem: ["마스터", "Master"], @@ -88,8 +88,8 @@ export const ui_locales_m2 = { M02_PickerNone: ["없음", "None"], M02_PickerApply: ["적용", "Apply"], M02_PickerClear: ["해제", "Clear"], - M02_PickerCopy: ["개인용으로 복사", "Copy to personal"], - M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"], + M02_PickerCopy: ["개인용 복사", "Copy to personal"], + M02_PickerManage: ["관리 화면 편집", "Edit in manager"], M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"], M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"], M02_TreeTitle: ["구조물 라이브러리", "Structure library"], @@ -176,8 +176,8 @@ export const ui_locales_m2 = { M02_PracticeOrder: ["차례", "No."], M02_PracticeMemo: ["메모", "Memo"], M02_PracticeNoLogic: ["단가산출 로직 없음", "No logics"], - M02_PracticeBack: ["목록으로", "Back to list"], - M02_PracticeDrop: ["통째 지우기", "Delete all"], + M02_PracticeBack: ["목록", "List"], + M02_PracticeDrop: ["전체 삭제", "Delete all"], M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"], M02_PracticeDropAsk2: [ "되돌릴 수 없음 — 정말 통째로 지울까요?", From e671b7739a36ac30debbd47ca6a48b93f5f6fe77 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:37:29 +0900 Subject: [PATCH 32/33] =?UTF-8?q?fix(=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0)?= =?UTF-8?q?:=20356-4=20=EB=85=B8=EC=84=A0=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=8B=A4=EC=8B=9C=ED=95=98=EA=B8=B0=20=3D?= =?UTF-8?q?=20=EA=B7=B8=20=EA=B1=B8=EC=9D=8C=20=EC=82=AC=EC=A7=84=EC=9D=98?= =?UTF-8?q?=20=EA=B3=84=ED=9A=8D=EA=B3=A0=20=ED=8E=B8=EC=A7=91=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사진첩 되살림 표시(pageReplaying) — 되살림 중 바뀐 노선은 새 조작 아님 - 묶음 follow 가 그 차례엔 계획고 편집을 옮기지 않고 세션 노선 0 칸(사진) 그대로 · 관 · 구조물도 세션 그대로 - 바뀐 구간 첫 계획선 맞춤 · 계획고 옮김은 새 조작 때만 - 시험 test_356_4 · helper_356_4(끌기 · ▲ · 다시 끌기 · 중심점 보정 · 되돌리기 · 다시하기 · 옛 동작 대조) · test_349_0q 입력 만들기 함수로 뗌 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipeline_page.ts | 8 + B05_Profile/B05_Profile_UI_Profile_History.ts | 8 +- resources/tester/helper_356_4_undo_grade.cjs | 448 ++++++++++++++++++ resources/tester/test_349_0q_undo_echo.py | 10 +- resources/tester/test_356_4_undo_grade.py | 88 ++++ 5 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 resources/tester/helper_356_4_undo_grade.cjs create mode 100644 resources/tester/test_356_4_undo_grade.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 48413bc6c..3ec3d51f0 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -55,6 +55,7 @@ import { readAlignmentDraft, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; +import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History"; import { analysisTerrain, fetchRoutePlan, @@ -259,6 +260,9 @@ function createPagePipeline( /** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */ let written: Facilities | null = null; let echo = false; + /** 되돌리기 · 다시하기로 바뀐 노선(356-4) — follow 가 계획고 편집을 옮기지 않고 그 걸음 사진(세션 노선 0 칸) + * 그대로. 바뀐 구간 첫 계획선 맞춤 · 계획고 옮김은 새 조작 때만. */ + let replay = false; const savedLine = (): Vertex[] | null => plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null; @@ -320,6 +324,9 @@ function createPagePipeline( savedLine: () => plan && { line: savedLine()!, curves: savedCurves() }, }), follow: (ctx) => { + const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null; + replay = false; + if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로) const line = ctx.before.route?.line ?? savedLine() ?? []; const { pipes, structures } = ctx.inputs; const inputs = { @@ -567,6 +574,7 @@ function createPagePipeline( if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored; } if (draft && !wasDraft && !held) held = holdNow(); + replay = pageReplaying(); store.set("routeDraft", draft); if (!started) return start(); const before = pipe.output("route")?.line ?? null; diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 932521a9d..d7c21b5f7 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -89,6 +89,10 @@ export function setPageHistory(history: ProfileHistory): void { active = history; } +/** 되돌리기 · 다시하기가 세션을 되쓰고 화면을 되살리는 중인가 — 그 사이 바뀐 노선은 새 조작이 아님(356-4). */ +let replaying = false; +export const pageReplaying = (): boolean => replaying; + export type PageHistoryEvent = "undo" | "redo"; interface PageHistoryHooks { /** 세션을 되쓴 뒤 — 자기 화면 값을 다시 읽음(사진첩이 막고 있는 동안이라 쌓이지 않음). */ @@ -193,7 +197,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil if (index < 0 || index >= stack.length) return false; const kind: PageHistoryEvent = index < cursor ? "undo" : "redo"; cursor = index; - restoring = true; + restoring = replaying = true; restoreSnapshot(stack[cursor]); try { onRestore(); @@ -201,7 +205,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil // 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦. stack[cursor] = takeSnapshot(stack[cursor]); } finally { - restoring = false; + restoring = replaying = false; } changed(); return true; diff --git a/resources/tester/helper_356_4_undo_grade.cjs b/resources/tester/helper_356_4_undo_grade.cjs new file mode 100644 index 000000000..c72761132 --- /dev/null +++ b/resources/tester/helper_356_4_undo_grade.cjs @@ -0,0 +1,448 @@ +/* PLAN 356-4 sub7 헬퍼 — 되돌리기 · 다시하기는 그 걸음 사진(노선 + 계획고 편집)을 그대로 되살림. + * 판 = `helper_349_0q_undo_echo.cjs` 그대로(진짜 종단도 · 묶음 · 상세 가게 · 페이지 사진첩 · 서버 창구만 가짜). + * 편집기 흉내 = 사진첩 되살림 훅 안에서 세션 노선 초안을 다리에 넘김(`_RouteEdit_Draft` fromPage 꼴). + * 걸음 — 끌기 → 계획고 ▲(측점 C) → 다시 끌기(C 가 바뀐 구간 = 새 조작은 ▲ 버림) → 되돌리기 · 다시하기 → + * 중심점 좌우 꼴(끌기 + C 높이 보정 · 한 걸음) → 되돌리기 · 다시하기 → 열기까지 되돌리기. + * argv[4] = "off" 면 되살림 표시를 끔(옛 동작 대조). 결과 = 표준출력 JSON. 짝 = `test_356_4_undo_grade.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +const winListeners = {}; +const windowBase = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, + addEventListener: (type, fn) => (winListeners[type] ??= []).push(fn), + removeEventListener: () => undefined, +}; +globalThis.window = new Proxy(windowBase, { + get: (t, key) => (key in t ? t[key] : undefined), + set: (t, key, value) => ((t[key] = value), true), +}); + +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +globalThis.fetch = (url, init) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }), + 20, + ); + init.signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new Error("aborted")); + }); + }); + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@ui/")) return anyModule(); + if (request.endsWith(".css")) return {}; + if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const sampler = load("common_util", "common_util_surface_sampler.ts"); +const bytes = fs.readFileSync(process.argv[3]); +const grid = sampler.parseDtmGrid( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), +); +const local = { grid, design: "las" }; + +/** 아무 화면 부품 — 무엇을 읽든 · 부르든 또 그것(DOM · 오버레이 · 도구줄 흉내). */ +function anyNode() { + const box = { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() {} } }; + return new Proxy(function node() {}, { + get: (_t, key) => { + if (key in box) return box[key]; + if (key === "then" || typeof key === "symbol") return undefined; + return (box[key] = anyNode()); + }, + set: (_t, key, value) => ((box[key] = value), true), + apply: () => anyNode(), + }); +} +const anyModule = (own = {}) => + new Proxy(own, { + get: (t, key) => + key in t + ? t[key] + : key === "__esModule" + ? true + : typeof key === "symbol" + ? undefined + : anyNode(), + }); +globalThis.document = anyNode(); +globalThis.HTMLElement = function HTMLElement() {}; + +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ + planned: job.line, + curves: job.curves, + min_radius_m: job.minRadius, + station_snap: job.stationSnap, + }), +}; +STUBS["B05_Profile_Api_Structures"] = anyModule({ + fetchStructures: async () => ({ structures: job.structures }), + fetchStructureTypes: async () => job.types, + readPendingStructures: () => null, +}); +STUBS["B05_Profile_Api_TerrainGrid"] = { + loadDesignTerrainGrid: async () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => ({ + pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), + }), +}; +STUBS["B06_Section_UI_Standard_Panel"] = { + effectiveStandardCross: () => job.standard, + readRockBoundaryDefault: () => -0.5, +}; +// 구조물 초안 얹기(측점 계획 · 형상) — 이 시험 밖 · 아무 일 없음 +STUBS["B06_Section_Section_Drafts"] = { + withDraftStations: async () => undefined, + withDraftShapes: async () => undefined, + forgetDraftStations: () => undefined, +}; +STUBS["B06_Section_Api_Fetch"] = { + fetchSectionDetail: async () => JSON.parse(JSON.stringify(job.detail)), + saveSections: async () => undefined, +}; +// 횡단 다시 셈(브라우저 · B05 crossPreview · B06 카드 같은 창구) — 부른 횟수 · 면적은 서버와 달라짐(두 배) +let refreshCalls = 0; +const doubled = (detail) => + detail.cross_sections.forEach((section) => { + if (!section.design) return; + const design = { ...section.design }; + for (const [key, value] of Object.entries(design)) + if (key.endsWith("_m2") && typeof value === "number") design[key] = value * 2; + section.design = design; + }); +STUBS["B06_Section_Cross_Refresh"] = { + refreshCrossDesigns: async ({ detail }) => { + refreshCalls += 1; + doubled(detail); + return detail.cross_sections.map((s) => s.chainage_m); + }, + // 화면 선 맡김 · 낡음 판정 = 진짜 `staleAgainstScreen` 그대로(화면 선 → 없으면 상세 자기 선) + holdScreenAlignment: (line) => { + screenLine = line; + return () => undefined; + }, + staleAgainstScreen: (detail, routeId) => { + const screen = screenLine?.(routeId); + if (!screen) return common.hasStaleDesigns(detail); + return common.hasStaleDesigns({ + longitudinal: { + design_profiles: [ + profileData.toDesignProfile(screen, detail.longitudinal.design_profiles?.[0]), + ], + }, + cross_sections: detail.cross_sections, + }); + }, + USER_TOUCHED_KEYS: [], +}; +let screenLine = null; +STUBS["B06_Section_UI_Cross_Detail"] = anyModule(); +STUBS["B06_Section_UI_Page_Common"] = anyModule({ L: (key) => key }); +// 그리기 — 진짜 `renderProfile` 의 낡음 판정 줄만(같은 함수 · 같은 인자) · SVG 는 안 그림 +const draws = []; +let opened = null; +STUBS["B05_Profile_UI_Profile_Render"] = { + renderProfile: (ctx) => { + const detail = ctx.detail(); + if (!detail) return; + const alignment = ctx.alignment(); + const longitudinal = detail.longitudinal; + const designProfiles = alignment + ? [profileData.toDesignProfile(alignment, longitudinal.design_profiles?.[0])] + : (longitudinal.design_profiles ?? []); + const key = common.staleKey({ + longitudinal: { design_profiles: designProfiles }, + cross_sections: detail.cross_sections, + }); + // 저장 상세를 그림 = 묶음 새 노선 상세(preview)가 아님 · 그린 객체(가게 것인지 견줌) + draws.push({ key, saved: !detail.cross_sections.some((s) => s.preview), ref: detail }); + ctx.onStaleDesigns?.(key); + }, +}; +STUBS["B05_Profile_UI_Profile_Tools"] = anyModule(); +STUBS["design_profile_free_view"] = anyModule(); +STUBS["B06_Section_UI_Cross_Panel_Base"] = anyModule(); +globalThis.sessionStorage = window.sessionStorage; +STUBS["common_util_view_window"] = { frameOf: (_root, fn) => fn() }; +for (const name of [ + "B05_Profile_UI_Profile_Balance", + "B05_Profile_UI_Profile_Heights", + "B05_Profile_UI_Profile_Zoom", + "B05_Profile_UI_Profile_Drag", + "B05_Profile_UI_Profile_MassHaul", + "design_profile_overlays", + "design_profile_viewport", + "design_save", + "B05_Profile_Api_Fetch", + "common_util_mass_haul_balance_view", +]) + STUBS[name] = anyModule(); + +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const storeMod = load("A00_Common", "design", "design_store.ts"); +const sectionStore = load("B06_Section", "B06_Section_Section_Store.ts"); +const common = load("B06_Section", "B06_Section_UI_Section_Common.ts"); +const layouts = load("B06_Section", "B06_Section_Structure_Layouts.ts"); +const profileData = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Data.ts"); +const alignmentMod = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); +const baseMod = load("A00_Common", "design", "design_profile_base.ts"); +const pageState = load("A00_Common", "b_page_state.ts"); +const massHaul = load("common_util", "common_util_mass_haul.ts"); +const historyMod = load("B05_Profile", "B05_Profile_UI_Profile_History.ts"); +const shiftMod = load("B05_Profile", "logic", "B05_Profile_RouteShift.ts"); +const profileMod = load("A00_Common", "design", "design_profile.ts"); +const designSync = load("B06_Section", "B06_Section_UI_Page_Design_Sync.ts"); +const designSession = load("B06_Section", "B06_Section_Cross_Design_Session.ts"); +const stationKeys = load("B06_Section", "B06_Section_Station_Keys.ts"); +// 진짜 다시 셈 창구(묶음 새 노선 상세를 안 고치는지만 봄 · 다른 곳은 위 가짜를 부름) +const realRefresh = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Cross_Refresh.ts")); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const runs = () => globalThis.__aisloPipeline?.runs ?? []; +/** 묶음 · 상세 가게 · 다시 셈이 조용해질 때까지. */ +async function quiet() { + let seen = -1; + for (let i = 0; i < 400; i += 1) { + await tick(30); + const last = runs()[runs().length - 1]; + const count = runs().length + refreshCalls * 1000 + draws.length * 1e6; + if (count === seen && (!last || last.outcome !== "running")) return; + seen = count; + } + throw new Error("묶음이 조용해지지 않음"); +} +const n = job.nodes.length; +const draftOf = (nodes) => ({ + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: job.stationSnap, +}); +const shift = (index, dx, dy) => + job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); +const sliceRuns = (mark) => + runs() + .slice(mark) + .map((r) => ({ + sources: r.sources, + outcome: r.outcome, + restored: r.restored ?? null, + why: r.restoreWhy ?? null, + })); +const finalOf = (detail) => { + const [series] = massHaul.computeMassHaulSeries( + detail.longitudinal, + detail.cross_sections, + job.conversion, + undefined, + ["cross"], + ); + const points = series?.result.points ?? []; + return points.length ? points[points.length - 1].cumulative_volume_m3 : null; +}; +const crossOf = (detail) => + JSON.stringify(detail.cross_sections.map((s) => [s.chainage_m, s.design, s.samples])); +/** 서버 정본 흉내 — 저장 횡단 계획고 = 열 때 종단도 계획선(낡음 0 · 서버가 낸 상세와 같음). */ +function settleDesigns(detail) { + const pa = detail.longitudinal.profile_alignment; + const base = baseMod.listedBase(alignmentMod.toAlignmentBase(pa), detail, []); + const samples = alignmentMod.buildAlignment(base, pa.edits).samples; + const at = (c) => { + const i = samples.findIndex((s) => s.chainage_m >= c); + if (i < 0) return samples[samples.length - 1].elevation_m; + if (i === 0) return samples[0].elevation_m; + const a = samples[i - 1]; + const b = samples[i]; + return ( + a.elevation_m + + ((b.elevation_m - a.elevation_m) * (c - a.chainage_m)) / (b.chainage_m - a.chainage_m) + ); + }; + detail.cross_sections.forEach((s) => (s.design.design_elevation_m = at(s.chainage_m))); +} + +async function run() { + const P = "proj-3564"; + const R = 7; + window.localStorage.setItem("frd_current_project_id", P); + opened = await sectionStore.loadSectionDetail(P, R); + settleDesigns(opened); + stationKeys.useStationKeys(P, R, opened); + const profile = profileMod.createDesignProfile({ + projectId: P, + view: "profile", + onSelectStation: () => undefined, + }); + pageMod.routeDraftBridge(P, profile, { + stationIntervalM: job.interval, + structureAreas: ({ cross_sections: rows }) => + layouts.applyStructureAreaRows(rows, layouts.structureAreaRows(rows)), + holdDraft: (routeId, saved) => { + const hold = saved && sectionStore.holdSectionDetail(P, routeId, saved); + return async (stations) => { + if (!hold) { + await sectionStore.swapSectionStations(P, routeId, stations); + return "no-saved"; + } + const back = await sectionStore.restoreSectionDetail(P, routeId, hold, stations); + return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; + }; + }, + }); + const show = (detail) => { + profile.render(detail, job.interval, R); + profile.setIrregularStations([]); + }; + const stopListen = sectionStore.subscribeSectionDetail(P, R, (next) => show(next)); + const stopStore = storeMod.useDesignStore(P, null).subscribe("stations", (stations) => { + if (stations?.length) void sectionStore.swapSectionStations(P, R, stations); + }); + show(opened); + profile.setEarthworkContext({ conversion: job.conversion }); + await quiet(); + + if (process.argv[4] === "off") historyMod.pageReplaying = () => false; // 옛 동작 대조 + const bridge = pageMod.routeDraftBridge(P, profile); + const designStore = storeMod.useDesignStore(P, null); + // 편집기 흉내 — 되살림 훅 안에서 세션 노선 초안을 다리에 넘김(노선 편집기가 사진첩에 단 훅 꼴) + historyMod.onPageHistory({ + restore: () => { + const kept = designStore.get("routeDraft"); + bridge.edited(kept ? draftOf(kept.nodes) : null); + }, + }); + const album = () => historyMod.pageHistory(); + const pipe = () => pageMod.pagePipeline(P); + const gesture = () => (winListeners.keydown ?? []).forEach((fn) => fn({})); + /** 한 걸음 — 손길 · 사진첩 멈춤 · 조작 · 조용해짐 · 한 번 쌓음. */ + const step = async (fn) => { + gesture(); + album().pause(); + await fn(); + await quiet(); + album().resume(); + }; + const grade = async (edits) => { + pageMod.pipelineGrade(P, edits); + await quiet(); + }; + const stations = pipe().output("ground").stations; + const [nx, ny] = job.nodes[1]; + const C = stations.reduce((a, b) => + Math.hypot(b.x - nx, b.y - ny) < Math.hypot(a.x - nx, a.y - ny) ? b : a, + ).chainageM; + const state = () => ({ + route: designStore.get("routeDraft")?.nodes[1] ?? null, + z: alignmentMod.planElevationAt(pipe().output("profile"), C), + offs: JSON.stringify(pipe().input("alignment")?.station_offsets ?? {}), + session: window.sessionStorage.getItem(`aislo:draft:alignment-draft:${P}:0`), + }); + const out = { C, open: state() }; + await step(() => bridge.edited(draftOf(shift(1, 6, -4)))); + out.drag1 = state(); + await step(() => { + const p = pipe(); + return grade(alignmentMod.adjustStation(p.output("planline"), p.input("alignment"), C, 0.3)); + }); + out.lift = state(); + await step(() => bridge.edited(draftOf(shift(1, 9, -6)))); + out.drag2 = state(); + album().undo(); + await quiet(); + out.undo1 = state(); + album().redo(); + await quiet(); + out.redo1 = state(); + // 중심점 좌우 꼴 — 끌기 + C 계획고를 끌기 전 값으로(한 걸음) + const zBefore = state().z; + await step(async () => { + bridge.edited(draftOf(shift(1, 12, -8))); + await quiet(); + const p = pipe(); + const pinned = shiftMod.pinStationHeight( + p.output("planline"), + p.input("alignment"), + C, + zBefore, + ); + if (pinned) await grade(pinned); + }); + out.pinned = { ...state(), zBefore }; + album().undo(); + await quiet(); + out.undo2 = state(); + album().redo(); + await quiet(); + out.redo2 = state(); + for (let i = 0; i < 6 && album().canUndo(); i += 1) { + album().undo(); + await quiet(); + } + out.home = state(); + out.blocked = pipe().blockedWrites; + stopListen(); + stopStore(); + pageMod.disposePagePipeline(P); + return out; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_349_0q_undo_echo.py b/resources/tester/test_349_0q_undo_echo.py index 455486817..fad9cb771 100644 --- a/resources/tester/test_349_0q_undo_echo.py +++ b/resources/tester/test_349_0q_undo_echo.py @@ -60,8 +60,8 @@ def _between(sections: list[dict], index: int) -> dict: } -@pytest.fixture(scope="module") -def r() -> dict: +def build_job() -> dict: + """입력 한 벌 — 356-4 시험도 빌림.""" if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): pytest.skip("검증_sub7 없음") curves = _json(ROUTE / "planned_route_curves.json") @@ -120,6 +120,12 @@ def r() -> dict: "cross_sections": sections, }, } + return job + + +@pytest.fixture(scope="module") +def r() -> dict: + job = build_job() with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "job.json" path.write_text(json.dumps(job), encoding="utf-8") diff --git a/resources/tester/test_356_4_undo_grade.py b/resources/tester/test_356_4_undo_grade.py new file mode 100644 index 000000000..5e1d65f18 --- /dev/null +++ b/resources/tester/test_356_4_undo_grade.py @@ -0,0 +1,88 @@ +"""PLAN 356-4 sub7 — 되돌리기 · 다시하기 = 그 걸음 사진(노선 + 계획고 편집) 그대로. + +· 노선 바뀜 규칙(바뀐 구간 첫 계획선 맞춤 · 계획고 옮김)은 새 조작 때만 — 되살림 때는 안 탐 +· 끌기 → 계획고 ▲(측점 C) → 다시 끌기(새 조작 = ▲ 버림) → 되돌리기 = ▲ 그대로 · 다시하기 = 다시 끌기 값 +· 중심점 좌우 꼴(끌기 + C 높이 보정 한 걸음) → 되돌리기 = 조작 전 계획고 · 편집 · 다시하기 = 보정 값 +· 고침을 끈 판(옛 동작)은 되돌리기에서 ▲ · 보정이 사라짐 — 시험이 흠을 실제로 잡음 +짝: `helper_356_4_undo_grade.cjs`(판 = 349-0q · 실제 TS 를 Node 로 · 화면 부품 · 서버 창구만 가짜). +""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest + +import test_349_0q_undo_echo as base + +HERE = Path(__file__).resolve().parent + + +def _run(job: dict, mode: str) -> dict: + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + grid = Path(folder) / "grid.bin" + grid.write_bytes(base.dtm_grid_bytes(base.GRID)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_356_4_undo_grade.cjs"), str(path), str(grid), mode], + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def job() -> dict: + return base.build_job() # 349-0q 판 입력(검증_sub7) + + +@pytest.fixture(scope="module") +def r(job) -> dict: + return _run(job, "on") + + +@pytest.fixture(scope="module") +def off(job) -> dict: + return _run(job, "off") + + +def test_새_조작은_바뀐_구간_계획고_편집을_버림(r): + c = f"{r['C']:.3f}" + assert c in json.loads(r["lift"]["offs"]) # ▲ 가 C 에 + assert c not in json.loads(r["drag2"]["offs"]) # 다시 끌기(새 조작) = 버림 + assert r["drag2"]["route"] != r["lift"]["route"] + + +def test_되돌리기_다시하기_그_걸음_사진(r): + assert r["undo1"]["route"] == r["lift"]["route"] + assert r["undo1"]["offs"] == r["lift"]["offs"] # ▲ 그대로 + assert r["undo1"]["z"] == pytest.approx(r["lift"]["z"], abs=1e-4) + assert r["redo1"]["route"] == r["drag2"]["route"] + assert r["redo1"]["offs"] == r["drag2"]["offs"] + assert r["redo1"]["z"] == pytest.approx(r["drag2"]["z"], abs=1e-4) + + +def test_중심점_좌우_보정_되돌리기(r): + pinned = r["pinned"] + assert pinned["z"] == pytest.approx(pinned["zBefore"], abs=1e-3) # 높이 보정 + assert pinned["offs"] != r["redo1"]["offs"] + assert r["undo2"]["route"] == r["redo1"]["route"] + assert r["undo2"]["offs"] == r["redo1"]["offs"] # 조작 전 편집 + assert r["undo2"]["z"] == pytest.approx(pinned["zBefore"], abs=1e-4) # 조작 전 계획고 + assert r["redo2"]["route"] == pinned["route"] + assert r["redo2"]["offs"] == pinned["offs"] # 보정 값 + assert r["home"]["route"] is None and r["home"]["offs"] == r["open"]["offs"] + assert r["blocked"] == 0 + + +def test_옛_동작은_되돌리기에서_편집이_사라짐(off): + c = f"{off['C']:.3f}" + assert c not in json.loads(off["undo1"]["offs"]) # ▲ 사라짐(고친 흠) + assert off["redo2"]["offs"] != off["pinned"]["offs"] # 다시하기에서 보정 사라짐 From 47b28e4ed1c0bb44e1f504c25228a58de331ac11 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:46:33 +0900 Subject: [PATCH 33/33] =?UTF-8?q?fix(=EC=8B=9C=ED=97=98):=20=EC=83=88=20Pi?= =?UTF-8?q?vot=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EA=B0=80=EC=A7=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=AA=A9=EB=A1=9D=20=EB=8F=84=EC=9A=B0?= =?UTF-8?q?=EB=AF=B8=EA=B0=80=20=EC=8B=A3=EA=B2=8C=20(355-2)?= 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_013QyNXY2TUKXCFMwAca2APG --- resources/tester/helper_234_1_structure_station.cjs | 5 ++++- resources/tester/helper_344_2_route_pairs.cjs | 5 ++++- resources/tester/helper_349_0v_span.cjs | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs index f7ba635ec..1ee3da8d5 100644 --- a/resources/tester/helper_234_1_structure_station.cjs +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -159,7 +159,10 @@ Promise.resolve( out.second = { count: snap.snapOldStations(again), toasts: [...toasts] }; // ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘) - const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts"); + const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); + const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { + "./B05_Profile_UI_Profile_Pivot": pivot, + }); const gone = [ { id: "pipe-80.17", chainage_m: 80.17 }, { id: "pipe-263.31", chainage_m: 263.31 }, diff --git a/resources/tester/helper_344_2_route_pairs.cjs b/resources/tester/helper_344_2_route_pairs.cjs index b00f2b115..bad98ffc5 100644 --- a/resources/tester/helper_344_2_route_pairs.cjs +++ b/resources/tester/helper_344_2_route_pairs.cjs @@ -20,7 +20,10 @@ function loadTs(file, stubs = {}) { return box.exports; } -const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { + "./B05_Profile_UI_Profile_Pivot": pivot, +}); const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", { "./B05_Profile_UI_Profile_Alignment": alignment, }); diff --git a/resources/tester/helper_349_0v_span.cjs b/resources/tester/helper_349_0v_span.cjs index ab579bdd2..0ddfe65ed 100644 --- a/resources/tester/helper_349_0v_span.cjs +++ b/resources/tester/helper_349_0v_span.cjs @@ -21,7 +21,10 @@ function loadTs(file, stubs = {}) { } const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts"); -const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { + "./B05_Profile_UI_Profile_Pivot": pivot, +}); const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { "./B05_Profile_UI_Profile_Alignment": alignment, });