From 10894b5b34aa940154d3ad1c698fed765be57438 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 07:59:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(520-3):=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EB=84=98=EA=B9=80=20=EA=B3=B5=EC=9A=A9=20moveStage=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=A0=EA=B8=88=20+=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=9B=90(n/N=20=EB=8B=A8=EA=B3=84=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EB=82=9C=20=EC=B4=88)=20=C2=B7=20B07=20?= =?UTF-8?q?=C2=B7=20B08=20=C2=B7=20B09=20=C2=B7=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20[<]=20[>]=20=C2=B7=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=ED=8C=90=20=EB=88=84=EB=A6=84=EC=9D=84=20=ED=95=9C=20=EA=B8=B8?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template setLoadingOverlayProgress(ratio, label) — 잠금판 스피너 자리에 진행 원 · 참조가 모두 풀리면 함께 걷힘 - b_workflow_nav moveStage(projectId, 주소 또는 이동 함수, steps) — 단계 { 이름 · 실행 · 선택 서버 진행 묻기 } · 거짓 · 던짐 = 잠금 풀고 머묾(던짐은 까닭 토스트) · 넘기는 중 다시 누름 무시 - goToWorkflowStage 가 Promise 를 돌려줌 — 잠금이 이동 뒤에 풀림 - 낡은 시험 고침 — 298-2 · 481-2 가짜 의존에 moveStage · 290-1 · 280-5 글 약속을 새 길로 · 520-3 시험 새로 Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_workflow_nav.ts | 86 ++++++++++++++++++- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 37 +++----- B08_Quantity/B08_Quantity_UI_Page.ts | 24 +++--- B09_Estimation/B09_Estimation_UI_Shell.ts | 25 +++--- .../tester/helper_298_2_b08_b09_buttons.cjs | 14 ++- resources/tester/helper_481_2_save_move.cjs | 14 ++- resources/tester/helper_520_3_move_stage.cjs | 79 +++++++++++++++++ resources/tester/test_280_5_b07_settings.py | 6 +- resources/tester/test_290_1_project_purge.py | 2 +- resources/tester/test_520_3_move_stage.py | 71 +++++++++++++++ ui_template/ui_template_elements.ts | 19 +++- ui_template/ui_template_elements_styles.ts | 3 + 12 files changed, 320 insertions(+), 60 deletions(-) create mode 100644 resources/tester/helper_520_3_move_stage.cjs create mode 100644 resources/tester/test_520_3_move_stage.py diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 87f23a048..844447241 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -4,6 +4,12 @@ import { ROUTES, type RoutePath, } from "@config/config_frontend"; +import { + hideLoadingOverlay, + setLoadingOverlayProgress, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; import { fetchConfirmedSurface, @@ -93,9 +99,9 @@ export async function dropGoneProject(): Promise { */ const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE]; -export function goToWorkflowStage(projectId: string, route: RoutePath): void { +export function goToWorkflowStage(projectId: string, route: RoutePath): Promise { // 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1). - void enterProject( + return enterProject( projectId, async (id) => (await fetchWorkflowState(id)).project_name || id, ).then(() => { @@ -106,10 +112,84 @@ export function goToWorkflowStage(projectId: string, route: RoutePath): void { navigateTo(route); return; } - void routeAfterPreloadCheck(projectId, route); + return routeAfterPreloadCheck(projectId, route); }); } +/** 페이지 넘김 한 단계(520-3) — 잠금판에 「n/N 이름 · 지난 초」. */ +export interface StageStep { + name: string; + /** false = 머묾(까닭은 단계가 알림) · 던지면 까닭 토스트 뒤 머묾 */ + run: () => Promise; + /** 서버 진행 묻기(선택 · 1 s 마다) — 비율 0~1 · 글 */ + progress?: () => Promise<{ ratio: number | null; label?: string } | null>; +} + +const PROGRESS_POLL_MS = 1000; +let moving = false; + +/** 페이지 넘김 공용(520-3) — 잠금 → 단계 차례 → 실패면 잠금 풀고 머묾 → 성공이면 이동. + * target = 워크플로 단계 주소 또는 이동 함수(B09 → 인쇄 미리보기). 참 = 옮김. */ +export async function moveStage( + projectId: string, + target: RoutePath | (() => void), + steps: readonly StageStep[], +): Promise { + if (moving) return false; + moving = true; + const started = Date.now(); + let index = 0; + let server: { ratio: number | null; label?: string } | null = null; + const paint = (): void => { + const step = steps[index]; + const seconds = Math.floor((Date.now() - started) / 1000); + const head = step ? `${index + 1}/${steps.length} ${step.name}` : "페이지 여는 중"; + const tail = step && server?.label ? ` — ${server.label}` : ""; + const ratio = step ? (index + (server?.ratio ?? 0)) / steps.length : 1; + setLoadingOverlayProgress(ratio, `${head}${tail} · ${seconds}초`); + }; + showLoadingOverlay(); + let asking = false; + const timer = window.setInterval(() => { + const at = index; + const ask = steps[at]?.progress; + if (!ask) return paint(); + if (asking) return; + asking = true; + void ask() + .then( + (next) => { + if (at === index) server = next; + }, + () => undefined, + ) + .finally(() => { + asking = false; + paint(); + }); + }, PROGRESS_POLL_MS); + try { + for (; index < steps.length; index += 1) { + server = null; + paint(); + if ((await steps[index].run()) === false) return false; + } + paint(); + if (typeof target === "function") target(); + else await goToWorkflowStage(projectId, target); + return true; + } catch (error) { + const name = steps[index]?.name ?? "페이지 이동"; + const reason = error instanceof Error ? error.message : String(error); + showToast(`${name}에서 멈췄습니다 — ${reason}`, "error"); + return false; + } finally { + window.clearInterval(timer); + hideLoadingOverlay(); + moving = false; + } +} + /** * 담아 둔 자료가 지금 확정본과 같은지 확인하고 화면을 정한다. * diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index b7d7a6199..f131d079e 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -31,8 +31,9 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { clearWorkflowStateCache, fetchWorkflowState, - goToWorkflowStage, + moveStage, WORKFLOW_STEP_ROUTES, + type StageStep, type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { @@ -484,28 +485,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }); placeTabs(); - /** 페이지 이동(281) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */ - let moving = false; - const moveToStage = async (stepIndex: number): Promise => { - if (!projectId || moving) return; - moving = true; - try { - if (!(await saveEditedDrawings(store))) return; - if (stepIndex > 4 && drawings.length > 0) { - try { - await completeDesignStage(projectId); - } catch (error) { - showToast( - error instanceof Error ? error.message : "상세 설계 단계를 완료하지 못했습니다.", - "error", - ); - return; - } - } - goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); - } finally { - moving = false; - } + /** 페이지 이동(281 · 520-3) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */ + const moveToStage = (stepIndex: number): void => { + if (!projectId) return; + const steps: StageStep[] = [{ name: "도면 저장", run: () => saveEditedDrawings(store) }]; + if (stepIndex > 4 && drawings.length > 0) + steps.push({ + name: "상세 설계 단계 완료", + run: async () => void (await completeDesignStage(projectId)), + }); + void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], steps); }; const layout = createWorkflowLayout({ @@ -519,7 +508,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { currentStage: 5, routes: WORKFLOW_STEP_ROUTES, // ☰ · 진행 단계 판 · [<] [>] 한 길 — 저장 후 이동(옛 「모든 횡단 확정」 잠금 · 자동 B08 이동 없음) - onStepClick: (stepIndex) => void moveToStage(stepIndex), + onStepClick: (stepIndex) => moveToStage(stepIndex), }); layout.root.classList.add("b07-design-layout"); root.replaceChildren(layout.root); diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index fd8f4d8f1..40c520763 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -14,11 +14,11 @@ import "@ui/ui_template_general_layout.css"; import "./B08_Quantity_UI_Page.css"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { createButton, showToast } from "@ui/ui_template_elements"; +import { createButton } from "@ui/ui_template_elements"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { moveStage, WORKFLOW_STEP_ROUTES, type StageStep } from "../A00_Common/b_workflow_nav"; import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; import { renderMaterialSheet } from "./B08_Quantity_UI_Material"; import { renderHydro, request } from "./B08_Quantity_UI_Hydro"; @@ -166,17 +166,15 @@ export function renderB08Quantity(root: HTMLElement): void { routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (!projectId) return; - void (async () => { - if (!(await saveShown())) return; - // B09 쪽 = 단계 5 완료 뒤(요구 300 B1) · 실패면 머묾 - if (stepIndex > 5) - try { - await request(`/projects/${projectId}/quantity/complete`, { method: "POST" }); - } catch (error) { - return showToast(`단계를 마치지 못했습니다 — ${String(error)}`, "error"); - } - goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); - })(); + const steps: StageStep[] = [{ name: "수량 저장", run: saveShown }]; + // B09 쪽 = 단계 5 완료 뒤(요구 300 B1) · 실패면 머묾 + if (stepIndex > 5) + steps.push({ + name: "수량 산출 단계 완료", + run: async () => + void (await request(`/projects/${projectId}/quantity/complete`, { method: "POST" })), + }); + void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], steps); }, }); root.append(layout.root); diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index e9dc4b02d..e35d42b3d 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -27,7 +27,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { moveStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; @@ -357,9 +357,9 @@ export function renderB09Estimation(root: HTMLElement): void { // 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이 onStepClick: (stepIndex: number) => { if (!projectId) return; - void saveChanged().then( - (ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]), - ); + void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], [ + { name: "원가 저장", run: saveChanged }, + ]); }, }); // [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 저장 → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로 @@ -369,15 +369,14 @@ export function renderB09Estimation(root: HTMLElement): void { left.addEventListener("ui-step-nav", (event) => { if ((event as CustomEvent).detail !== 1 || !goNext || !projectId) return; event.stopPropagation(); - void (async () => { - if (!(await saveChanged())) return; - try { - await request(`/projects/${projectId}/estimation/complete`, { method: "POST" }); - } catch (error) { - return showToast(`단계를 마치지 못했습니다 — ${String(error)}`, "error"); - } - goNext(); - })(); + void moveStage(projectId, goNext, [ + { name: "원가 저장", run: saveChanged }, + { + name: "원가 계산 단계 완료", + run: async () => + void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" })), + }, + ]); }); root.append(layout.root); show(current); diff --git a/resources/tester/helper_298_2_b08_b09_buttons.cjs b/resources/tester/helper_298_2_b08_b09_buttons.cjs index 5a70069b4..5c1c6056e 100644 --- a/resources/tester/helper_298_2_b08_b09_buttons.cjs +++ b/resources/tester/helper_298_2_b08_b09_buttons.cjs @@ -113,7 +113,19 @@ const common = { "../A00_Common/b_page_scaffold": { workflowSteps: () => [] }, "../A00_Common/b_workflow_nav": { WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"], - goToWorkflowStage: (_p, route) => calls.push(`go:${route}`), + // 520-3 공용 페이지 넘김 — 단계 차례 · false · 던짐 = 머묾(잠금 · 진행 글은 test_520_3) + moveStage: async (_p, target, steps) => { + for (const step of steps) + try { + if ((await step.run()) === false) return false; + } catch { + calls.push("toast:error"); + return false; + } + if (typeof target === "function") target(); + else calls.push(`go:${target}`); + return true; + }, }, "../A00_Common/b_page_state": { readState: (name) => store.get(name) ?? null, diff --git a/resources/tester/helper_481_2_save_move.cjs b/resources/tester/helper_481_2_save_move.cjs index c7ee6cf34..5e35bef7c 100644 --- a/resources/tester/helper_481_2_save_move.cjs +++ b/resources/tester/helper_481_2_save_move.cjs @@ -101,7 +101,19 @@ const common = { "../A00_Common/b_page_scaffold": { workflowSteps: () => [] }, "../A00_Common/b_workflow_nav": { WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"], - goToWorkflowStage: (_p, route) => calls.push(`go:${route}`), + // 520-3 공용 페이지 넘김 — 단계 차례 · false · 던짐 = 머묾(잠금 · 진행 글은 test_520_3) + moveStage: async (_p, target, steps) => { + for (const step of steps) + try { + if ((await step.run()) === false) return false; + } catch { + calls.push("toast:error"); + return false; + } + if (typeof target === "function") target(); + else calls.push(`go:${target}`); + return true; + }, }, "../A00_Common/b_page_state": { readState: (name) => store.get(name) ?? null, diff --git a/resources/tester/helper_520_3_move_stage.cjs b/resources/tester/helper_520_3_move_stage.cjs new file mode 100644 index 000000000..491031661 --- /dev/null +++ b/resources/tester/helper_520_3_move_stage.cjs @@ -0,0 +1,79 @@ +/* 520-3 페이지 넘김 공용 `moveStage` — 진짜 `b_workflow_nav.ts` 를 트랜스파일해 가짜 잠금 · 토스트 · 이동 위에서 돌리고 + * 잠금 · 진행 글 · 머묾 · 이동을 JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const ROOT = path.join(__dirname, "..", ".."); + +const calls = []; +let lock = 0; +const stubs = { + "@config/config_frontend": { API_BASE_URL: "/api", CURRENT_PROJECT_ID_KEY: "pid", ROUTES: {} }, + "@ui/ui_template_elements": { + showLoadingOverlay: () => (lock += 1), + hideLoadingOverlay: () => (lock = Math.max(0, lock - 1)), + setLoadingOverlayProgress: (ratio, label) => calls.push(`p:${ratio.toFixed(2)}:${label}`), + showToast: (text, kind) => calls.push(`toast:${kind}:${text}`), + }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": {}, + "./b_asset_cache": {}, + "./b_project_switch": { enterProject: async () => calls.push("enter") }, + "./router": { navigateTo: (route) => calls.push(`go:${route}`) }, +}; +const js = ts.transpileModule( + fs.readFileSync(path.join(ROOT, "A00_Common/b_workflow_nav.ts"), "utf8"), + { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }, +).outputText; +const mod = { exports: {} }; +global.window = { setInterval, clearInterval }; +global.localStorage = { setItem() {} }; +new Function("exports", "module", "require", js)(mod.exports, mod, (id) => stubs[id] ?? {}); +const { moveStage } = mod.exports; + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const run = async (target, steps) => { + calls.length = 0; + const ok = await moveStage("P1", target, steps); + return { ok, lock, calls: calls.splice(0) }; +}; + +(async () => { + const out = {}; + let lockSeen = -1; + out.ok = await run("b08", [ + { name: "저장", run: async () => void (lockSeen = lock) }, + { name: "완료", run: async () => true }, + ]); + out.ok.lockSeen = lockSeen; + out.stop = await run("b08", [ + { name: "저장", run: async () => false }, + { name: "완료", run: async () => calls.push("완료 돎") }, + ]); + out.fail = await run("b08", [ + { + name: "저장", + run: async () => { + throw new Error("Failed to fetch"); + }, + }, + ]); + let asked = 0; + out.poll = await run( + () => calls.push("go:fn"), + [ + { + name: "서버 저장", + run: () => wait(1150), + progress: async () => ((asked += 1), { ratio: 0.5, label: "관 차례" }), + }, + ], + ); + out.poll.asked = asked; + // 넘기는 중 또 누름 = 무시 + const first = moveStage("P1", "b07", [{ name: "느림", run: () => wait(50) }]); + out.busy = await moveStage("P1", "b09", []); + await first; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_280_5_b07_settings.py b/resources/tester/test_280_5_b07_settings.py index 9fec1b557..1052ac77c 100644 --- a/resources/tester/test_280_5_b07_settings.py +++ b/resources/tester/test_280_5_b07_settings.py @@ -142,12 +142,12 @@ def test_페이지_확정_단추_잠금_자동이동_없음(): assert gone not in page, gone assert "confirmed: false, // 잠금 없음(요구 281)" in page assert "frameRow.append(frameEditor.button);" in page # 298-2 — [도각 편집] = 윗줄 - assert "onStepClick: (stepIndex) => void moveToStage(stepIndex)," in page + assert "onStepClick: (stepIndex) => moveToStage(stepIndex)," in page move = page[ - page.index("const moveToStage = async") : page.index("const layout = createWorkflowLayout") + page.index("const moveToStage = (") : page.index("const layout = createWorkflowLayout") ] assert move.index("saveEditedDrawings(store)") < move.index("completeDesignStage(projectId)") - assert move.index("completeDesignStage(projectId)") < move.index("goToWorkflowStage(") + assert move.index("completeDesignStage(projectId)") < move.index("moveStage(projectId") assert "if (stepIndex > 4 && drawings.length > 0)" in move assert "currentStage: 5," in page assert len(page.splitlines()) <= 700 diff --git a/resources/tester/test_290_1_project_purge.py b/resources/tester/test_290_1_project_purge.py index f401a5705..bc5e4311a 100644 --- a/resources/tester/test_290_1_project_purge.py +++ b/resources/tester/test_290_1_project_purge.py @@ -59,6 +59,6 @@ def test_빈_초안은_고침_아님_취향은_둠(out): def test_공용_이동_한_곳에서_부름(): nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8") - assert "void enterProject(" in nav and "./b_project_switch" in nav + assert "return enterProject(" in nav and "./b_project_switch" in nav ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8") assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui diff --git a/resources/tester/test_520_3_move_stage.py b/resources/tester/test_520_3_move_stage.py new file mode 100644 index 000000000..22a5fe00f --- /dev/null +++ b/resources/tester/test_520_3_move_stage.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""520-3 페이지 넘김 공용 `moveStage` — 잠금 → 「n/N 이름 · 지난 초」 → 실패면 잠금 풀고 머묾 + 까닭 → 성공이면 이동. + +`helper_520_3_move_stage.cjs` 가 진짜 `A00_Common/b_workflow_nav.ts` 를 가짜 잠금 · 토스트 · 이동 위에서 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_520_3_move_stage.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): + o = r["ok"] + assert o["ok"] is True and o["lock"] == 0 and o["lockSeen"] == 1 + assert o["calls"][0] == "p:0.00:1/2 저장 · 0초" + assert o["calls"][1] == "p:0.50:2/2 완료 · 0초" + assert o["calls"][2] == "p:1.00:페이지 여는 중 · 0초" + assert o["calls"][-2:] == ["enter", "go:b08"] + + +def test_단계가_거짓이면_머묾_뒤_단계_안_돎(r): + o = r["stop"] + assert o["ok"] is False and o["lock"] == 0 + assert not any(c.startswith("go:") or c == "완료 돎" for c in o["calls"]) + + +def test_던지면_까닭_토스트_풀림_머묾(r): + o = r["fail"] + assert o["ok"] is False and o["lock"] == 0 + assert "toast:error:저장에서 멈췄습니다 — Failed to fetch" in o["calls"] + assert not any(c.startswith("go:") for c in o["calls"]) + + +def test_서버_진행_묻기_글_비율_함수_이동(r): + o = r["poll"] + assert o["asked"] >= 1 and o["lock"] == 0 + assert any(c.startswith("p:0.50:1/1 서버 저장 — 관 차례 · 1초") for c in o["calls"]) + assert o["calls"][-1] == "go:fn" + + +def test_넘기는_중_또_누름_무시(r): + assert r["busy"] is False + + +def test_페이지_셋이_공용_길(): + for rel in ( + "B07_DesignDetail/B07_DesignDetail_UI_Page.ts", + "B08_Quantity/B08_Quantity_UI_Page.ts", + "B09_Estimation/B09_Estimation_UI_Shell.ts", + ): + page = (ROOT / rel).read_text(encoding="utf-8") + assert "void moveStage(projectId" in page and "goToWorkflowStage(" not in page, rel diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 6911020ba..43051d1c4 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -10,6 +10,7 @@ * ========================================================================== */ import { el } from "./ui_template_elements_base"; +import { createProgressCircle, type ProgressCircleHandle } from "./ui_template_progress"; // 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04). // 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다. @@ -225,6 +226,7 @@ export function createTag(label: string, variant: TagVariant = "neutral"): HTMLS let overlayEl: HTMLDivElement | null = null; let overlayCount = 0; +let overlayProgress: ProgressCircleHandle | null = null; function ensureOverlay(): HTMLDivElement { if (overlayEl) return overlayEl; @@ -248,10 +250,25 @@ export function showLoadingOverlay(): void { export function hideLoadingOverlay(): void { overlayCount = Math.max(0, overlayCount - 1); if (overlayCount === 0 && overlayEl) { - overlayEl.classList.remove("is-active"); + overlayEl.classList.remove("is-active", "ui-overlay--progress"); + overlayProgress?.remove(); + overlayProgress = null; } } +/** 띄운 잠금판에 진행 원 · 글(520-3) — 스피너 자리 · 모든 참조가 풀리면 함께 걷힘. + * ratio null = 진행률 미상(회전만). 잠금판이 안 떠 있으면 아무것도 안 함. */ +export function setLoadingOverlayProgress(ratio: number | null, label?: string): void { + if (overlayCount === 0) return; + const overlay = ensureOverlay(); + if (!overlayProgress) { + overlayProgress = createProgressCircle(); + overlay.append(overlayProgress.root); + overlay.classList.add("ui-overlay--progress"); + } + overlayProgress.set(ratio, label); +} + /* ----------------------------------------------------------------------------- * 6. 토스트 알림 (Toast) — 성공/에러 배너 * -------------------------------------------------------------------------- */ diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index 7ac8d386d..811bdeef2 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -204,6 +204,9 @@ const BASE_CSS = ` z-index: var(--z-overlay); } .ui-overlay.is-active { display: flex; } +/* 진행 잠금판(520-3) — 스피너 대신 진행 원 · 단계 글 */ +.ui-overlay--progress > .ui-spinner { display: none; } +.ui-overlay .ui-progress-circle__label { max-width: 40ch; } .ui-spinner { width: 40px; height: 40px; From df0e64ffe2ca36af2d8cb0c8a902e961d52345fb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 08:01:02 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(B07):=20=EC=A2=85=EB=8B=A8=EB=8F=84=20?= =?UTF-8?q?=EC=9E=A5=EB=A7=88=EB=8B=A4=20=EC=9A=B0=EC=B8=A1=20=EC=83=81?= =?UTF-8?q?=EB=8B=A8=20=ED=8F=89=EB=A9=B4=20=EC=9C=84=EC=B9=98=EB=8F=84=20?= =?UTF-8?q?=C2=B7=20=EC=A2=85=EB=8B=A8=20=E2=9A=99=20=E3=80=8C=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=E3=80=8D=20=EC=B2=99=EB=8F=84=20=EC=B9=B8=20?= =?UTF-8?q?(521-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20340-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Engine_Cad_LongPlan — 작도 영역 우상단 5 mm 안쪽 150×100 mm 칸 · 자동 = 전체 노선 맞춤(분모 500 올림) · 척도 정함 = 이 장 중심 확대 · 밖 노선 흐린 파선 · 칸 밖 잘림 · 이 장 굵게 · 시 · 종점 측점 · 방위표 · 「평면도 S=1/n」 · 「n/N 장」 · 좌표 없으면 안 그림 - 장 나눔 — 그래프가 칸을 덮으면 측점 줄여 장 더 나눔 · 장 목록에 number · total - 축척 — longitudinal.plan 기본 자동 · 범위 1/500~1/50,000 · ⚙ 칸 = 서버 기본값 열쇠 그대로(셋째 칸 + 자동) - 칸 앞 확정 종단 저장본 — 목록 받을 때 종단만 한 번 모두 풂(DRAWING_FORMAT 그대로) - 시험 — 칸 자리 · 안 겹침 · 장마다 강조 · 척도 확대 · 좌표 없음 · 한 번 풂 · plan 범위 · ⚙ 평면 칸 Co-Authored-By: Claude Opus 5.5 --- .../B07_DesignDetail_Api_Fetch.ts | 9 +- .../B07_DesignDetail_Engine_Cad_Long.py | 20 +- .../B07_DesignDetail_Engine_Cad_LongPlan.py | 224 ++++++++++++++++++ .../B07_DesignDetail_Engine_Template.py | 3 +- .../B07_DesignDetail_Router_Support.py | 2 + .../B07_DesignDetail_Router_Support_Io.py | 28 +++ .../B07_DesignDetail_Router_Support_Scale.py | 4 +- .../B07_DesignDetail_UI_Settings_Scale.ts | 27 ++- .../tester/helper_280_5_b07_settings.cjs | 18 +- .../tester/test_280_5_b07_drawing_scales.py | 11 +- resources/tester/test_280_5_b07_settings.py | 19 +- resources/tester/test_b07_long_sheet.py | 144 ++++++++++- 12 files changed, 472 insertions(+), 37 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_LongPlan.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 69fc10e89..bbce8b861 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -243,12 +243,15 @@ export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise /** 도면 축척 종류(485) — 표준 횡단 · 평면도 · 구조물은 뺌. */ export type DrawingScaleKind = "longitudinal" | "cross" | "mass_haul" | "development"; -/** 축척 분모 — null = 자동(토적 가로 · 전개도) · 범위 = [최소, 최대]. */ -export type DrawingScaleAxes = { x: number | null; y: number | null }; +/** 축척 분모 — null = 자동(토적 가로 · 전개도 · 종단 평면 위치도) · 범위 = [최소, 최대]. */ +export type DrawingScaleAxes = { x: number | null; y: number | null; plan?: number | null }; export interface DrawingScales { scales: Record; defaults: Record; - ranges: Record; + ranges: Record< + DrawingScaleKind, + { x: [number, number]; y: [number, number]; plan?: [number, number] } + >; /** 종류마다 저장본(확정) 장 수 — 바꾸면 풀리므로 화면이 먼저 물음. */ saved: Record; /** PUT 응답만 — 이번에 풀린 저장본 장 수. */ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py index 1a889d09a..7b5b33e76 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py @@ -42,6 +42,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( polyline_entity, station_no_label, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongPlan import ( + LONG_PLAN_LAYER_ID, + long_plan_entities, + plan_box_overlaps, +) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( drawing_scale, entities_bbox, @@ -93,12 +98,14 @@ def _span(stations: list[dict[str, Any]], index: int, take: int) -> dict[str, fl def _fits_sheet(longitudinal: dict[str, Any], span: dict[str, float]) -> bool: - """이 구간을 고정 축척으로 그렸을 때 A1 작도 영역에 드는가.""" - bbox = entities_bbox(_long_content(longitudinal, "probe", span)) + """이 구간을 고정 축척으로 그렸을 때 A1 작도 영역에 들고 평면 위치도 칸을 안 덮는가.""" + content = _long_content(longitudinal, "probe", span) + bbox = entities_bbox(content) if bbox is None: return True usable_w, usable_h = usable_area() - return bbox[2] - bbox[0] <= usable_w + 1e-6 and bbox[3] - bbox[1] <= usable_h + 1e-6 + fits = bbox[2] - bbox[0] <= usable_w + 1e-6 and bbox[3] - bbox[1] <= usable_h + 1e-6 + return fits and not plan_box_overlaps(longitudinal, content, bbox) def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]: @@ -124,9 +131,10 @@ def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]: break index += take - 1 # 경계 1측점 중복 if len(spans) == 1: - return [{"id": "longitudinal", "label": "종단도", **spans[0]}] + return [{"id": "longitudinal", "label": "종단도", "number": 1, "total": 1, **spans[0]}] return [ - {"id": f"longitudinal_{number}", "label": f"종단도({number})", **span} + {"id": f"longitudinal_{number}", "label": f"종단도({number})", "number": number, **span} + | {"total": len(spans)} for number, span in enumerate(spans, start=1) ] @@ -660,6 +668,7 @@ def build_longitudinal_drawing( # A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다. # 그림 좌하단 = 작도영역 좌하단 — 장마다 같은 자리에서 시작한다. bbox = entities_bbox(entities) + entities.extend(long_plan_entities(longitudinal, drawing_id, chunk, bbox)) # 521-2 if bbox: entities.extend( frame_entities( @@ -682,6 +691,7 @@ def build_longitudinal_drawing( _layer(DESIGN_LAYER_ID, "계획선"), _layer(LONG_GRID_LAYER_ID, "그래프 격자", locked=True), _layer(LONG_TABLE_LAYER_ID, "측점표"), + _layer(LONG_PLAN_LAYER_ID, "평면 위치도"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_LongPlan.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_LongPlan.py new file mode 100644 index 000000000..d8ba01233 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_LongPlan.py @@ -0,0 +1,224 @@ +"""B07 종단도 우측 상단 평면 위치도(521-2 · 요구 340-2) — 장마다 작도 영역 우상단 칸 하나. + +칸 = 작도 영역 우상단 5 mm 안쪽 150×100 mm(작도 영역 좌하단 = 그림 좌하단 · `frame_entities` +bottom_left). 척도 = 종단 축척 `plan` 칸(None = 자동). 방위표만 도각 층. + - 자동: 전체 노선을 칸에 맞춤(분모 500 단위 올림) · 전체 노선 가는 선 · 이 장 구간 굵게. + - 척도 정함: 이 장 중심 확대 · 이 장 굵게 · 밖 노선 흐린 파선 · 칸 밖은 잘림. +칸 내용 = 노선 · 이 장 시 · 종점 측점 글 · 방위표 · 「평면도 S=1/n」 · 「n/N 장」. +좌표 = 종단 측점 center_x · center_y — 없으면 칸 안 그림. 서버 단독(CLAUDE.md 5장 ③). +""" + +import math +from collections.abc import Iterator +from typing import Any + +from shapely.geometry import LineString, Point +from shapely.geometry import box as shapely_box + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DESIGN_COLOR, + TABLE_LABEL_COLOR, + TABLE_LINE_COLOR, + _stations, + _text_entity, + infer_station_interval, + polyline_entity, + station_no_label, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import text_rect +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + drawing_scale, + usable_area, +) + +LONG_PLAN_LAYER_ID = "b07-long-plan" +BOX_W, BOX_H = 150.0, 100.0 # 칸 크기(종이 mm) +_INSET = 5.0 # 작도 영역 테두리에서 안쪽 +_MAP_PAD = (4.0, 10.0, 4.0, 4.0) # 칸 안 노선 자리 여백(좌 · 하 · 우 · 상) — 아래는 글 줄 +_AUTO_STEP = 500 # 자동 분모 올림 단위 +_FONT = 2.5 +_COMPASS = 12.0 +_ROUTE_COLOR = "#7d8590" # 전체 · 밖 노선 — 흐리게 + + +def plan_box(content_bbox: tuple[float, float, float, float]) -> tuple[float, float, float, float]: + """칸 (x0, y0, x1, y1) — 그림 좌표(작도 영역 좌하단 = 그림 좌하단).""" + usable_w, usable_h = usable_area() + x1 = content_bbox[0] + usable_w - _INSET + y1 = content_bbox[1] + usable_h - _INSET + return (x1 - BOX_W, y1 - BOX_H, x1, y1) + + +def _plan_points(longitudinal: dict[str, Any]) -> list[tuple[float, float, float]]: + """종단 측점의 (누가거리 m, x, y) — `Router_Support_Basin.plan_stations` 와 같은 셈.""" + points: list[tuple[float, float, float]] = [] + for station in _stations(longitudinal): + x, y = station.get("center_x"), station.get("center_y") + if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (x, y)): + points.append((float(station["chainage_m"]), float(x), float(y))) + return sorted(points) + + +def _shapes(entities: list[dict[str, Any]]) -> Iterator[Any]: + """엔티티 → shapely 도형(선 · 글 사각 · 원) — 칸 겹침 판정용.""" + for entity in entities: + yield from _shapes(entity.get("children") or []) + shape = entity.get("shapeData") or {} + if "startPoint" in shape: + a, b = shape["startPoint"], shape["endPoint"] + yield LineString([(a["x"], a["y"]), (b["x"], b["y"])]) + elif "basePoint" in shape: + base, options = shape["basePoint"], shape.get("options") or {} + direction = options.get("textDirection") or {} + yield shapely_box( + *text_rect( + str(shape.get("label") or ""), + base["x"], + base["y"], + float(options.get("fontSize", _FONT)), + str(options.get("textAlign", "center")), + (float(direction.get("x", 1.0)), float(direction.get("y", 0.0))), + ) + ) + elif "center" in shape: + center = shape["center"] + yield Point(center["x"], center["y"]).buffer(float(shape.get("radius", 0.0))) + + +def plan_box_overlaps( + longitudinal: dict[str, Any], + entities: list[dict[str, Any]], + content_bbox: tuple[float, float, float, float] | None, +) -> bool: + """그래프 · 측점표가 칸을 덮는가 — 칸을 안 그리는 종단(좌표 없음)은 늘 거짓.""" + if content_bbox is None or len(_plan_points(longitudinal)) < 2: + return False + x0, y0, x1, y1 = plan_box(content_bbox) + if content_bbox[2] < x0 or content_bbox[3] < y0: + return False + region = shapely_box(x0, y0, x1, y1) + return any(region.intersects(shape) for shape in _shapes(entities)) + + +def _auto_denominator(points: list[tuple[float, float, float]], map_w: float, map_h: float) -> int: + width = max(p[1] for p in points) - min(p[1] for p in points) + height = max(p[2] for p in points) - min(p[2] for p in points) + need = max(width / map_w, height / map_h) * 1000.0 + return max(_AUTO_STEP, math.ceil(need / _AUTO_STEP) * _AUTO_STEP) + + +def _clipped( + drawing_id: str, + points: list[tuple[float, float]], + region: Any, + suffix: str, + color: str, + dash: list[int] | None = None, + width: int = 1, +) -> list[dict[str, Any]]: + """종이 점열을 칸 노선 자리로 잘라 PolyLine 들로.""" + if len(points) < 2: + return [] + clipped = LineString(points).intersection(region) + parts = getattr(clipped, "geoms", [clipped]) + out = [] + for index, part in enumerate(parts): + if isinstance(part, LineString) and not part.is_empty: + line = polyline_entity( + drawing_id, + list(part.coords), + LONG_PLAN_LAYER_ID, + color, + suffix=f"{suffix}:{index}", + dash=dash, + width=width, + ) + if line: + out.append(line) + return out + + +def long_plan_entities( + longitudinal: dict[str, Any], + drawing_id: str, + chunk: dict[str, Any], + content_bbox: tuple[float, float, float, float] | None, +) -> list[dict[str, Any]]: + """이 장의 평면 위치도 칸 — 좌표 없거나 그림이 비면 빈 목록.""" + points = _plan_points(longitudinal) + if content_bbox is None or len(points) < 2: + return [] + start_m, end_m = chunk.get("start_m"), chunk.get("end_m") + if start_m is None or end_m is None: + start_m, end_m = points[0][0], points[-1][0] + inside = [p for p in points if start_m - 1e-6 <= p[0] <= end_m + 1e-6] + x0, y0, x1, y1 = plan_box(content_bbox) + left, bottom, right, top = _MAP_PAD + map_box = (x0 + left, y0 + bottom, x1 - right, y1 - top) + map_w, map_h = map_box[2] - map_box[0], map_box[3] - map_box[1] + + fixed = drawing_scale("longitudinal").get("plan") + denominator = fixed or _auto_denominator(points, map_w, map_h) + focus = inside if fixed and inside else points + center_x = (min(p[1] for p in focus) + max(p[1] for p in focus)) / 2.0 + center_y = (min(p[2] for p in focus) + max(p[2] for p in focus)) / 2.0 + mm = 1000.0 / denominator + box_cx, box_cy = (map_box[0] + map_box[2]) / 2.0, (map_box[1] + map_box[3]) / 2.0 + + def paper(x: float, y: float) -> tuple[float, float]: + return (box_cx + (x - center_x) * mm, box_cy + (y - center_y) * mm) + + region = shapely_box(*map_box) + border = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + entities = [polyline_entity(drawing_id, border, LONG_PLAN_LAYER_ID, TABLE_LINE_COLOR, ":box")] + entities += _clipped( + drawing_id, + [paper(x, y) for _c, x, y in points], + region, + ":all", + _ROUTE_COLOR, + dash=[3, 3] if fixed else None, + ) + entities += _clipped( + drawing_id, [paper(x, y) for _c, x, y in inside], region, ":this", DESIGN_COLOR, width=3 + ) + interval_m = infer_station_interval(_stations(longitudinal)) + for name, (chainage, x, y) in (("start", inside[0]), ("end", inside[-1])) if inside else (): + px, py = paper(x, y) + if region.contains(Point(px, py)): + entities.append( + _text_entity( + f"{drawing_id}:lplan:{name}", + station_no_label(chainage, interval_m), + px, + py + _FONT, + LONG_PLAN_LAYER_ID, + _FONT, + TABLE_LABEL_COLOR, + ) + ) + number, total = int(chunk.get("number") or 1), int(chunk.get("total") or 1) + for name, label, x, align in ( + ("scale", f"평면도 S=1/{denominator:,}", x0 + left, "left"), + ("page", f"{number}/{total} 장", x1 - right, "right"), + ): + entities.append( + _text_entity( + f"{drawing_id}:lplan:{name}", + label, + x, + y0 + bottom / 2.0, + LONG_PLAN_LAYER_ID, + _FONT, + TABLE_LABEL_COLOR, + align=align, + ) + ) + # 방위표 = 다른 평면 도면처럼 도각 층(잠금) + compass = compass_entities( + f"{drawing_id}:lplan", + (x0 + left + _COMPASS / 2.0, y1 - top - _COMPASS / 2.0), + _COMPASS, + ) + return [entity for entity in entities if entity] + compass diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 0c2aea39b..ba274b87d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -97,7 +97,8 @@ def add_title_fields(extra: dict[str, str]) -> None: # 읽는 프로젝트 = 위 문맥 변수(라우터가 세움) — 엔진 서명을 고치지 않는다. DRAWING_SCALES_FILE = "drawing_scales.json" DRAWING_SCALE_DEFAULTS: dict[str, dict[str, int | None]] = { - "longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V}, + # plan = 종단 장 우측 상단 평면 위치도(521-2) · None = 자동(노선을 칸에 맞춤) + "longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V, "plan": None}, "cross": {"x": DRAWING_SCALE_CROSS, "y": DRAWING_SCALE_CROSS}, # 토적 세로 = 1 mm 당 50 ㎥ → 표기 1:50,000 (분모 / 1000 = 종이 1 mm 의 ㎥) "mass_haul": {"x": None, "y": int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000)}, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 17e9e38f8..7231becdb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -90,6 +90,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _read_manifest, _station_map, _write_manifest, + release_old_long_sheets, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( PLAN_KIND_ID, @@ -196,6 +197,7 @@ def write_cross_layout( def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]: longitudinal = _read_json(longitudinal_path) station_by_chainage = _station_map(longitudinal) + release_old_long_sheets(project_root) # 521-2 manifest_drawings = _read_manifest(project_root)["drawings"] # 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1). drawings = [ diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py index d1936097b..e6e3b7494 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py @@ -80,3 +80,31 @@ def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: temporary = path.with_suffix(".tmp") temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") temporary.replace(path) + + +def release_old_long_sheets(project_root: Path) -> None: + """평면 위치도(521-2) 앞 확정 종단 저장본 — 종단만 모두 한 번 풂(칸 · 장 나눔이 바뀜). + + 풀린 장의 저장본은 `_write_manifest` 가 걷으니 다음부터는 새 저장본(칸 층 있음)만 남음. + """ + # 엔진 → 양식 → 이 모듈 차례라 위에서 부르면 돌고 돎 + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongPlan import LONG_PLAN_LAYER_ID + + manifest = _read_manifest(project_root) + confirmed = [ + (drawing_id, entry) + for drawing_id, entry in manifest["drawings"].items() + if _LONG_ID.fullmatch(drawing_id) and entry.get("confirmed") + ] + if not confirmed: + return + # ponytail: 첫 장만 봄 — 옛 장은 한꺼번에 풀리므로 새 장과 섞이지 않음 + saved = _design_root(project_root) / "drawings" / f"{confirmed[0][0]}.json" + try: + if f'"{LONG_PLAN_LAYER_ID}"' in saved.read_text(encoding="utf-8"): + return + except OSError: + pass + for _drawing_id, entry in confirmed: + entry["confirmed"] = False + _write_manifest(project_root, manifest) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py index f2e971a90..006ecc1b2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Scale.py @@ -36,7 +36,7 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) # 분모 범위(정수) — 기본 · 자동 후보(토적 500~6,000 · 전개도 50~5,000)를 모두 담음. SCALE_RANGES: dict[str, dict[str, tuple[int, int]]] = { - "longitudinal": {"x": (100, 10000), "y": (10, 2000)}, + "longitudinal": {"x": (100, 10000), "y": (10, 2000), "plan": (500, 50000)}, "cross": {"x": (10, 1000), "y": (10, 1000)}, "mass_haul": {"x": (100, 20000), "y": (1000, 1000000)}, "development": {"x": (10, 10000), "y": (10, 10000)}, @@ -65,7 +65,7 @@ def checked_scales(scales: Any) -> dict[str, dict[str, int | None]]: if value is not None and ( isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high ): - name = "X(가로)" if axis == "x" else "Y(세로)" + name = {"x": "X(가로)", "y": "Y(세로)"}.get(axis, "평면도") raise ValueError( f"{_KIND_LABEL[kind]} {name} 축척은 1/{low:,}~1/{high:,} 사이 정수로 적으세요." ) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts b/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts index c868e41bb..5556e1c72 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B07_DesignDetail_UI_Settings_Scale.ts * ⚙ 모달 X · Y 축척 칸(485-2 · 요구 280-5) — 종단 · 횡단 · 토적 · 전개도만. + * 종단 = 셋째 칸 「평면도」(521-2 · 우측 상단 평면 위치도) — 칸은 서버 기본값 열쇠 그대로. * * 칸 = 분모 정수 · 범위 밖 = 칸 붉게 + [축척 저장] 꺼짐 · 기본이 자동인 칸(토적 X · 전개도) = 「자동」 * [축척 저장] = 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT → 화면 다시 세움(도면 다시 그림) @@ -32,10 +33,8 @@ const LABEL: Record = { mass_haul: "토적도", development: "전개도", }; -const AXES = [ - ["x", "X(가로) 1/"], - ["y", "Y(세로) 1/"], -] as const; +type Axis = keyof DrawingScaleAxes; +const AXIS_LABEL: Record = { x: "X(가로) 1/", y: "Y(세로) 1/", plan: "평면도 1/" }; const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL; @@ -86,9 +85,11 @@ function buildScaleFields( data: DrawingScales, save: (next: DrawingScaleAxes) => Promise, ): void { - const fields = AXES.map(([axis, label]) => { - const fallback = data.defaults[kind][axis]; - const range = data.ranges[kind][axis]; + const axes = Object.keys(data.defaults[kind]) as Axis[]; + const fields = axes.map((axis) => { + const label = AXIS_LABEL[axis]; + const fallback = data.defaults[kind][axis] ?? null; + const range = data.ranges[kind][axis] ?? [1, 1]; const [min, max] = range; const field = createInputField({ label, type: "number", compact: true, min, max }); field.root.classList.add("b07-scale__field"); @@ -99,7 +100,7 @@ function buildScaleFields( if (auto) auto.input.checked = value === null; field.input.disabled = value === null && !!auto; }; - set(data.scales[kind][axis]); + set(data.scales[kind][axis] ?? null); const read = () => readScaleAxis(field.input.value, !!auto?.input.checked, range); return { axis, field, auto, fallback, set, read }; }); @@ -107,13 +108,14 @@ function buildScaleFields( const saveButton = createButton({ label: "축척 저장", onClick: () => { - const [x, y] = fields.map((f) => f.read()) as [number | null, number | null]; + const read = fields.map((f) => [f, f.read() as number | null] as const); const now = data.scales[kind]; - if (x === now.x && y === now.y) { + if (read.every(([f, value]) => value === (now[f.axis] ?? null))) { showToast("바뀐 축척이 없습니다.", "info"); return; } - void save({ x: scaleToSend(x, fields[0].fallback), y: scaleToSend(y, fields[1].fallback) }); + const next = Object.fromEntries(read.map(([f, v]) => [f.axis, scaleToSend(v, f.fallback)])); + void save(next as DrawingScaleAxes); }, }); // 칸 밑 오류 글은 숨기고(틀린 칸만 길어짐) 줄 아래 한 줄로 알림 @@ -127,7 +129,8 @@ function buildScaleFields( const result = f.read(); const message = typeof result === "string" ? result : undefined; f.field.setError(message); - if (message) errors.set(message, [...(errors.get(message) ?? []), f.axis.toUpperCase()]); + const name = f.axis === "plan" ? "평면도" : f.axis.toUpperCase(); + if (message) errors.set(message, [...(errors.get(message) ?? []), name]); } alertLine.textContent = [...errors].map(([m, axes]) => `${axes.join(" · ")}: ${m}`).join(" / "); alertLine.hidden = errors.size === 0; diff --git a/resources/tester/helper_280_5_b07_settings.cjs b/resources/tester/helper_280_5_b07_settings.cjs index dc3a440fa..5c00e358d 100644 --- a/resources/tester/helper_280_5_b07_settings.cjs +++ b/resources/tester/helper_280_5_b07_settings.cjs @@ -97,19 +97,19 @@ const calls = []; let failConfirm = false; const SCALES = { scales: { - longitudinal: { x: 1000, y: 200 }, + longitudinal: { x: 1000, y: 200, plan: null }, cross: { x: 100, y: 100 }, mass_haul: { x: null, y: 50000 }, development: { x: null, y: null }, }, defaults: { - longitudinal: { x: 1000, y: 200 }, + longitudinal: { x: 1000, y: 200, plan: null }, cross: { x: 100, y: 100 }, mass_haul: { x: null, y: 50000 }, development: { x: null, y: null }, }, ranges: { - longitudinal: { x: [100, 10000], y: [10, 2000] }, + longitudinal: { x: [100, 10000], y: [10, 2000], plan: [500, 50000] }, cross: { x: [10, 1000], y: [10, 1000] }, mass_haul: { x: [100, 20000], y: [1000, 1000000] }, development: { x: [10, 10000], y: [10, 10000] }, @@ -413,6 +413,18 @@ const drawings = [ s.btn("축척 저장").click(); await tick(); scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) }; + // 평면 위치도 칸(521-2) — 셋째 칸 · 자동 끔 → 범위 밖 붉음 → 1/3,000 저장 + s = await scaleOf("longitudinal"); + scale.planStart = s.state(); + s.toggle(s.autos[0]); + s.type(s.inputs[2], "400"); + scale.planLow = s.state(); + s.type(s.inputs[2], "3000"); + calls.length = 0; + s.btn("축척 저장").click(); + await tick(); + await tick(); + scale.planSent = calls.find((c) => c[0] === "scales")?.[1]; s = await scaleOf("mass_haul"); scale.massStart = s.state(); diff --git a/resources/tester/test_280_5_b07_drawing_scales.py b/resources/tester/test_280_5_b07_drawing_scales.py index 98c74390b..33db4e526 100644 --- a/resources/tester/test_280_5_b07_drawing_scales.py +++ b/resources/tester/test_280_5_b07_drawing_scales.py @@ -244,7 +244,14 @@ def test_전개도_가로_세로(project: Path) -> None: def test_범위_검사() -> None: assert checked_scales({"cross": {"x": 100, "y": None}}) == {"cross": {"x": 100, "y": None}} + # 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 자동 + assert checked_scales({"longitudinal": {"plan": 3000}}) == { + "longitudinal": {"x": None, "y": None, "plan": 3000} + } + with pytest.raises(ValueError, match="평면도 축척은 1/500~1/50,000"): + checked_scales({"longitudinal": {"plan": 400}}) for bad in ( + {"longitudinal": {"plan": 50001}}, {"plan": {"x": 500}}, {"standard": {"x": 50}}, {"cross": {"x": 5}}, @@ -287,7 +294,7 @@ def test_바꾼_종류만_저장본_풂(project: Path) -> None: drawings = _read_manifest(project)["drawings"] assert not drawings["longitudinal_1"]["confirmed"] assert drawings["cross_00020m"]["confirmed"] and drawings["cover"]["confirmed"] - assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200} + assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": None} # 보낸 종류만 바뀜 — 앞서 쓴 종단은 남음 · 횡단 = 측점 설계 잠정 몫 invalidated, stale = write_drawing_scales( project, project / "none.json", {"cross": {"x": 100, "y": 50}} @@ -314,5 +321,5 @@ def test_GET_PUT_범위_밖_400(project: Path, monkeypatch: pytest.MonkeyPatch) assert not drawing_scales_path(project).exists() # 거절 = 안 씀 put = client.put(url, json={"scales": {"longitudinal": {"x": 500, "y": 200}}}) assert put.status_code == 200 - assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200} + assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": None} assert put.json()["invalidated"] == {} diff --git a/resources/tester/test_280_5_b07_settings.py b/resources/tester/test_280_5_b07_settings.py index 9fec1b557..274d72d7e 100644 --- a/resources/tester/test_280_5_b07_settings.py +++ b/resources/tester/test_280_5_b07_settings.py @@ -229,22 +229,27 @@ def test_485_2_축척_칸_판정(out): def test_485_2_종단_축척_저장(out): scale = out["scale"] - assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/"] - assert scale["longStart"]["values"] == ["1000", "200"] - assert scale["longStart"]["autos"] == [] # 종단 = 자동 없음 + assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/", "평면도 1/"] + assert scale["longStart"]["values"] == ["1000", "200", ""] + assert scale["longStart"]["autos"] == [True] # 종단 = 평면도 칸만 자동(521-2) for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐 - assert scale[bad]["errors"] == [RANGE, None] + assert scale[bad]["errors"] == [RANGE, None, None] assert scale[bad]["save"] is False - assert scale["longOk"]["errors"] == [None, None] and scale["longOk"]["save"] is True + assert scale["longOk"]["errors"] == [None, None, None] and scale["longOk"]["save"] is True # 저장본 2장 — 물음 [취소] = 요청 없음 · 모달 그대로 assert scale["cancel"] == {"calls": ["ask"], "open": True, "closed": 0} save = scale["save"] assert "저장본 2장" in save["ask"] - assert save["sent"] == {"longitudinal": {"x": 500, "y": None}} # Y = 기본 → null + assert save["sent"] == {"longitudinal": {"x": 500, "y": None, "plan": None}} # 기본 → null assert save["open"] is False assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면 - assert scale["reset"]["values"] == ["1000", "200"] # [기본값] = 칸만 + assert scale["reset"]["values"] == ["1000", "200", ""] # [기본값] = 칸만 assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]} + # 평면 위치도 칸(521-2) — 처음 자동(칸 꺼짐) · 1/500 밑 = 붉음 · 1/3,000 저장 + assert scale["planStart"]["disabled"] == [False, False, True] + assert scale["planLow"]["errors"] == [None, None, "1/500~1/50,000 사이 정수"] + assert scale["planLow"]["save"] is False + assert scale["planSent"] == {"longitudinal": {"x": None, "y": None, "plan": 3000}} def test_485_2_토적_전개도_자동(out): diff --git a/resources/tester/test_b07_long_sheet.py b/resources/tester/test_b07_long_sheet.py index 85002c341..032141a8f 100644 --- a/resources/tester/test_b07_long_sheet.py +++ b/resources/tester/test_b07_long_sheet.py @@ -1,4 +1,10 @@ -"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3).""" +"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3). + +우측 상단 평면 위치도(521-2) — 칸 자리 · 그래프와 안 겹침 · 장마다 강조 구간 · 척도. +""" + +import json +import math import pytest @@ -7,7 +13,21 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongPlan import ( + LONG_PLAN_LAYER_ID, + plan_box_overlaps, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + drawing_scales_path, + entities_bbox, + usable_area, + use_project_templates, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( + _read_manifest, + _write_manifest, + release_old_long_sheets, +) def _longitudinal(count: int, interval: float, slope: float) -> dict: @@ -57,3 +77,123 @@ def test_wide_interval_splits_more() -> None: # 40 m 간격 30측점 = 1,160 mm > 작도영역 폭 → 30개보다 적게 담는다 chunks = longitudinal_chunks(_longitudinal(40, 40.0, 0.0)) assert len(chunks) > 2 + + +# ── 우측 상단 평면 위치도(521-2 · 요구 340-2) ───────────────────────────────── +def _with_xy(longitudinal: dict) -> dict: + for station in longitudinal["stations"]: + chainage = station["chainage_m"] + station["center_x"] = 200000.0 + chainage + station["center_y"] = 500000.0 + 60.0 * math.sin(chainage / 150.0) + return longitudinal + + +def _plan_sheets(longitudinal: dict) -> list[dict]: + """장마다 그래프 bbox · 칸 엔티티 · 굵은(이 장) 선 꼭짓점 · 글.""" + sheets = [] + for chunk in longitudinal_chunks(longitudinal): + entities = build_longitudinal_drawing(longitudinal, chunk["id"], chunk)["entities"] + graph = [e for e in entities if e["layerId"] not in (FRAME_LAYER_ID, LONG_PLAN_LAYER_ID)] + plan = [e for e in entities if e["layerId"] == LONG_PLAN_LAYER_ID] + bold = [ + (child["shapeData"][key]["x"], child["shapeData"][key]["y"]) + for entity in plan + for child in entity.get("children") or [] + if child["lineWidth"] == 3 + for key in ("startPoint", "endPoint") + ] + texts = [e["shapeData"]["label"] for e in plan if e["type"] == "Text"] + sheets.append( + { + "graph": graph, + "bbox": entities_bbox(graph), + "plan": plan, + "bold": bold, + "texts": texts, + } + ) + return sheets + + +def test_평면_칸_작도영역_우상단_안_겹침_장마다_강조() -> None: + longitudinal = _with_xy(_longitudinal(66, 20.0, 0.05)) + sheets = _plan_sheets(longitudinal) + usable_w, usable_h = usable_area() + lows = [] + for number, sheet in enumerate(sheets, start=1): + x0, y0 = sheet["bbox"][0], sheet["bbox"][1] + box = entities_bbox(sheet["plan"]) + # 칸 = 작도 영역 우상단 5 mm 안쪽 150×100 mm + assert box == pytest.approx( + (x0 + usable_w - 155.0, y0 + usable_h - 105.0, x0 + usable_w - 5.0, y0 + usable_h - 5.0) + ) + assert not plan_box_overlaps(longitudinal, sheet["graph"], sheet["bbox"]) + assert f"{number}/{len(sheets)} 장" in sheet["texts"] + assert any(text.startswith("평면도 S=1/") for text in sheet["texts"]) + assert sheet["bold"] # 이 장 구간 굵게 + lows.append(min(x for x, _y in sheet["bold"]) - box[0]) + # 자동 = 모든 장이 같은 축척 · 같은 자리 — 굵은 구간만 장마다 오른쪽으로 옮겨 감 + assert lows == sorted(lows) and len(set(lows)) == len(lows) + + +def test_칸을_덮으면_측점_줄여_장_더_나눔() -> None: + # 25 m 간격 30 측점 = 725 mm(폭 안) · 오르막 그래프가 우상단 칸을 덮음 + plain = longitudinal_chunks(_longitudinal(60, 25.0, 0.1)) + with_box = _with_xy(_longitudinal(60, 25.0, 0.1)) + assert longitudinal_chunks(with_box)[0]["end_m"] < plain[0]["end_m"] + for sheet in _plan_sheets(with_box): + assert not plan_box_overlaps(with_box, sheet["graph"], sheet["bbox"]) + + +def test_좌표_없으면_칸_없음() -> None: + assert all(not sheet["plan"] for sheet in _plan_sheets(_longitudinal(40, 20.0, 0.0))) + + +def test_척도_정하면_이_장_확대_밖_흐린_파선(tmp_path) -> None: + longitudinal = _with_xy(_longitudinal(66, 20.0, 0.05)) + use_project_templates(tmp_path) + try: + path = drawing_scales_path(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"longitudinal": {"plan": 3000}}), encoding="utf-8") + sheets = _plan_sheets(longitudinal) + finally: + use_project_templates(None) + for sheet in sheets: + assert "평면도 S=1/3,000" in sheet["texts"] + assert any(entity.get("lineDash") for entity in sheet["plan"]) # 밖 노선 파선 + box = entities_bbox(sheet["plan"]) + xs = [x for x, _y in sheet["bold"]] + # 이 장 중심 = 칸 노선 자리 가운데 + assert (min(xs) + max(xs)) / 2.0 == pytest.approx((box[0] + box[2]) / 2.0, abs=1.0) + + +def test_칸_앞_확정_종단만_한_번_풂(tmp_path) -> None: + drawings = tmp_path / "B07_DesignDetail" / "drawings" + drawings.mkdir(parents=True) + old = {"kind": "longitudinal", "confirmed": True, "file": "x"} + _write_manifest( + tmp_path, + { + "drawings": { + "longitudinal_1": dict(old), + "longitudinal_2": dict(old), + "cross_00020m": {"kind": "cross", "confirmed": True, "file": "x"}, + } + }, + ) + for name in ("longitudinal_1", "longitudinal_2", "cross_00020m"): + (drawings / f"{name}.json").write_text('{"layers": []}', encoding="utf-8") + release_old_long_sheets(tmp_path) + after = _read_manifest(tmp_path)["drawings"] + assert not after["longitudinal_1"]["confirmed"] and not after["longitudinal_2"]["confirmed"] + assert after["cross_00020m"]["confirmed"] + assert not (drawings / "longitudinal_1.json").exists() # 옛 저장본 걷힘 + # 새 확정본(칸 층 있음)은 그대로 + after["longitudinal_1"] = dict(old) + _write_manifest(tmp_path, {"drawings": after}) + (drawings / "longitudinal_1.json").write_text( + json.dumps({"layers": [{"id": LONG_PLAN_LAYER_ID}]}), encoding="utf-8" + ) + release_old_long_sheets(tmp_path) + assert _read_manifest(tmp_path)["drawings"]["longitudinal_1"]["confirmed"] From 7e57a970f83df5f83a4f99ba66f39170f54020a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 08:03:55 +0900 Subject: [PATCH 3/3] =?UTF-8?q?perf(522-2=C2=B7522-3):=20B07=20=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=E2=9A=99=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=AB=20=ED=99=94=EB=A9=B4=EB=B6=80=ED=84=B0=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=EB=A7=8C=20=C2=B7=20=EB=B0=9B?= =?UTF-8?q?=EC=95=84=20=EB=91=94=20=EB=8F=84=EB=A9=B4=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cad_host bare 옵션 = iframe 주소 ?bare · openwebcad main.tsx 그리기 전 setBare(true) — 도면 오기 전에도 리본 · 패널 없음 - 평면도 관리 모달 · B06 표 위 CAD = bare - 평면도 관리 = 페이지 목록(deps.drawings) 그대로 · 장 받기 = Prefetch.openDrawing · 같은 목록 다시 열기 = 요청 없음 · 고친 뒤만 목록 새로 + syncDrawings - test_130 흉내 갱신(처음 = 목록 안 받음 · 고친 뒤 맞춤 · 다시 열기 요청 없음 · ?bare) - 첫 열기 1.23~1.34 s → 0.31 s · 다시 열기 0.14~0.16 s · 요청 3 → 0 Co-Authored-By: Claude Opus 5.5 --- .../B06_Section_UI_Cross_Detail_Cad.ts | 1 + .../B07_DesignDetail_UI_PlanManage.ts | 38 ++++++++++++------ .../B07_DesignDetail_UI_Settings.ts | 2 +- B07_DesignDetail/openwebcad/src/main.tsx | 3 ++ .../tester/helper_130_b07_plan_manage.cjs | 39 ++++++++++++++++--- resources/tester/test_130_b07_plan_manage.py | 17 ++++++++ ui_template/cad_host/cad_host.ts | 4 +- 7 files changed, 86 insertions(+), 18 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index c62aecddc..08865009c 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -333,6 +333,7 @@ export function mountNoteCad( let closed = false; const cad = createCadHost({ title: "표 위 CAD", + bare: true, onChanged: (dirty) => { if (!dirty || closed) return; cad diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts index b4dbe70bd..3b552d98d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts @@ -7,13 +7,13 @@ * 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기] * 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고, * 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음. + * 처음 열 때 = 페이지 목록 · 미리받은 장(`Prefetch.openDrawing`) 그대로 · 같은 목록으로 다시 열면 요청 없음(522-3). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { createCadHost } from "@ui/cad_host/cad_host"; import { - fetchDesignDrawing, fetchDesignDrawingList, fetchPlanKinds, reorderPlanKinds, @@ -21,6 +21,7 @@ import { type DesignDrawingItem, type PlanKindsState, } from "./B07_DesignDetail_Api_Fetch"; +import { openDrawing, syncDrawings } from "./B07_DesignDetail_Prefetch"; import { groupPlanSheets, kindForm, @@ -36,8 +37,15 @@ export interface PlanManageControls { onClosed: (changed: boolean, openId?: string) => void; /** 머리 단추 — ⚙ 로 열 때 [원본에서 다시 그리기](280-5). */ actions?: HTMLElement[]; + /** 페이지가 받은 도면 목록 — 있으면 처음엔 목록을 다시 안 받음. */ + drawings?: DesignDrawingItem[]; } +/** 마지막으로 세운 종류 · 목록 — 같은 페이지 목록(`source`)으로 다시 열면 그대로 씀. */ +let seen: + | { source: DesignDrawingItem[] | undefined; state: PlanKindsState; list: DesignDrawingItem[] } + | undefined; + function el( tag: K, className: string, @@ -66,7 +74,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo let pickedKind = ""; let form: KindForm | undefined; let sheets: DesignDrawingItem[] = []; - const preview = createCadHost({ title: "평면도 미리보기" }); + const preview = createCadHost({ title: "평면도 미리보기", bare: true }); const side = el("div", "b07-plan-manage__side"); const head = el("div", "b07-plan-manage__head"); const view = el("div", "b07-plan-manage__view"); @@ -77,7 +85,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo projectId, onChanged: () => { changed = true; - void refresh(); + void refresh(true); }, }; @@ -171,7 +179,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo head.replaceChildren(el("strong", "", label)); preview.beginLoading(); try { - const response = await fetchDesignDrawing(projectId, drawing.id); + const response = await openDrawing(drawing); if (closed || picked !== drawing.id) return; const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전"); badge.dataset.confirmed = String(response.confirmed); @@ -198,15 +206,23 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo } }; - /** 종류 · 장 목록을 서버에서 다시 받아 왼쪽을 세우고 보던 장(없으면 첫 장)을 다시 봄. */ - const refresh = async (): Promise => { + /** 종류 · 장 목록으로 왼쪽을 세우고 보던 장(없으면 첫 장)을 다시 봄 · + * edited = 고친 뒤 — 서버에서 새로 받고 지문 바뀐 장은 미리받기에서 버림. */ + const refresh = async (edited = false): Promise => { try { - const [state, list] = await Promise.all([ - fetchPlanKinds(projectId), - fetchDesignDrawingList(projectId), - ]); + const source = controls.drawings; + const fresh = edited || !source; + const known = !fresh && seen?.source === source ? seen : undefined; + const [state, list] = known + ? [known.state, known.list] + : await Promise.all([ + fetchPlanKinds(projectId), + fresh ? fetchDesignDrawingList(projectId).then((got) => got.drawings) : source, + ]); if (closed) return; - sheets = list.drawings.filter((item) => item.kind === "plan"); + if (fresh) syncDrawings(projectId, list); + seen = { source, state, list }; + sheets = list.filter((item) => item.kind === "plan"); const all = groupPlanSheets(sheets).flatMap((group) => group.sheets); const keep = all.find((sheet) => sheet.drawing.id === picked) ?? all[0]; if (keep) pickedKind = keep.drawing.plan_kind ?? ""; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts index c33e455d8..bcfa1243d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts @@ -68,7 +68,7 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void redraw.classList.add("b07-settings__redraw"); if (kind === "plan" && deps.plan) { - const manager = openPlanManager({ ...deps.plan, actions: [redraw] }); + const manager = openPlanManager({ ...deps.plan, drawings: deps.drawings, actions: [redraw] }); closeModal = manager.close; return; } diff --git a/B07_DesignDetail/openwebcad/src/main.tsx b/B07_DesignDetail/openwebcad/src/main.tsx index 9e4b392d7..38280e159 100644 --- a/B07_DesignDetail/openwebcad/src/main.tsx +++ b/B07_DesignDetail/openwebcad/src/main.tsx @@ -31,6 +31,7 @@ import { isViewOnly, setActiveLayerId, setActiveToolActor, + setBare, setCanvas, setEntities, setHighlightedEntityIds, @@ -46,6 +47,8 @@ import { ActorEvent, type DrawEvent } from './tools/tool.types'; // 호스트 앱의 화이트/블랙 모드를 먼저 붙인 뒤 렌더한다. syncThemeFromHost(); +// 부모가 `?bare` 로 띄우면 첫 그림부터 캔버스만 — 도면을 넘길 때 다시 정함(522-2) +if (new URLSearchParams(window.location.search).has('bare')) setBare(true); ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render( diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 84f24c7ce..7129fb2e8 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -187,8 +187,11 @@ const sheets = () => ); const copy = () => JSON.parse(JSON.stringify(state)); const API = { - fetchPlanKinds: async () => copy(), - fetchDesignDrawingList: async () => ({ drawings: [{ id: "cover", kind: "cover" }, ...sheets()] }), + fetchPlanKinds: async () => (calls.push(["kinds"]), copy()), + fetchDesignDrawingList: async () => ( + calls.push(["list"]), + { drawings: [{ id: "cover", kind: "cover" }, ...sheets()] } + ), fetchDesignDrawing: async (_, id) => ( calls.push(["fetchDrawing", id]), { id, confirmed: confirmed.has(id), drawing: { entities: [{ id }], layers: [] } } @@ -232,6 +235,12 @@ const { loadTs } = makeLoader({ if (request === "@ui/ui_template_collapsible") return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts")); if (request === "./B07_DesignDetail_Api_Fetch") return API; + // 522-3 미리받기 — 장 받기는 `openDrawing` · 고친 뒤 목록 맞춤은 `syncDrawings` + if (request === "./B07_DesignDetail_Prefetch") + return { + openDrawing: (item) => API.fetchDesignDrawing("p1", item.id), + syncDrawings: (_, list) => calls.push(["sync", list.length]), + }; if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); throw new Error(`stub 없음: ${request}`); }, @@ -245,9 +254,15 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag const out = {}; const closes = []; const selected = []; - const plan = { projectId: "p1", onClosed: (changed, openId) => closes.push([changed, openId]) }; + // 페이지가 받은 목록을 넘김(`openDrawingSettings` 와 같은 길 · 522-3) + const pageList = [{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()]; + const plan = { + projectId: "p1", + drawings: pageList, + onClosed: (changed, openId) => closes.push([changed, openId]), + }; const panel = buildDrawingSidePanel( - [{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()], + pageList, (drawing) => selected.push(drawing.id), undefined, false, @@ -288,6 +303,8 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag hasMarks: all(modal.body).some((n) => n.textContent === "대피소 · 사토장 자리"), preview: cad.loads.at(-1).drawing.entities[0].id, extra: cad.loads.at(-1).extra, + bare: cad.opts.bare, + calls: calls.map((c) => c[0]), }; // 종류 추가 — 「새 종류」 · 이름 · 조각 하나 @@ -297,7 +314,12 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag action().click(); await settle(); await settle(); - out.add = { kinds: kindRows(), sheets: sheetIds(), call: calls.find((c) => c[0] === "add") }; + out.add = { + kinds: kindRows(), + sheets: sheetIds(), + call: calls.find((c) => c[0] === "add"), + after: calls.slice(calls.findIndex((c) => c[0] === "add")).map((c) => c[0]), + }; // 업데이트 — 새 종류를 골라 이름 · 축척 바꿈 kindPick().change("k_new"); @@ -393,10 +415,17 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag out.close = { closes: [...closes], destroyed: cad.destroyed, open: modal.open }; // 다시 열어 고치지 않고 [CAD 에서 열기] → changed = false · 그 장 id + const callsBefore = calls.length; gear().click(); await settle(); await settle(); const modal2 = modals[1]; + out.reopen = { + calls: calls.slice(callsBefore).map((c) => c[0]), + kinds: modal2.body + .querySelectorAll(".b07-plan-manage__sheets .b07-plan-manage__row") + .map((row) => row.dataset.planKind), + }; modal2.body .querySelectorAll(".b07-plan-manage__sheet") .find((b) => b.dataset.drawingId === "plan_k_route") diff --git a/resources/tester/test_130_b07_plan_manage.py b/resources/tester/test_130_b07_plan_manage.py index 25d45793a..e635d36b7 100644 --- a/resources/tester/test_130_b07_plan_manage.py +++ b/resources/tester/test_130_b07_plan_manage.py @@ -9,6 +9,8 @@ - 장 단추 → 드롭다운 따라감 · 드롭다운 → 그 종류 첫 장 · 줄 끌어 놓기 = 종류 순서(놓는 즉시 · 140-2). - 미리보기 장 바꾸기 · 캔버스만(bare) · 확정 배지만(확정 풀기 없음 140-1) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id. + - 522 처음 = 페이지 목록 · 미리받기 장 · 고친 뒤만 목록 새로 + 미리받기 맞춤 · 같은 목록 다시 열기 = 요청 없음 · iframe `?bare`. + Node 헬퍼 `helper_130_b07_plan_manage.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인. """ @@ -59,6 +61,21 @@ def test_첫_장_미리보기는_보기_전용(out): assert extra["readOnly"] is True and extra["viewOnly"] is True assert extra["bare"] is True # 140-1 캔버스만(제목줄 · 리본 · 상태막대 없음) assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침 + assert out["open"]["bare"] is True # 522-2 iframe 을 `?bare` 로 — 도면 오기 전에도 리본 없음 + + +def test_처음_열기는_페이지_목록(out): + # 522-3 목록은 페이지가 받은 것 · 장은 미리받기(`openDrawing`) — 목록 다시 안 받음 + assert out["open"]["calls"] == ["kinds", "fetchDrawing"] + + +def test_고친_뒤엔_목록_새로_맞춤(out): + assert out["add"]["after"][:4] == ["add", "kinds", "list", "sync"] + + +def test_같은_목록으로_다시_열면_요청_없음(out): + assert out["reopen"]["calls"] == ["fetchDrawing"] # 진짜 길은 미리받기 캐시 + assert out["reopen"]["kinds"] == ["k_route", "k_terrain"] # 고친 뒤 받은 순서 그대로 def test_종류_추가(out): diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index d4b2a19f8..87f000e92 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -64,6 +64,8 @@ export interface CadSaveResult { export interface CadHostOptions { /** iframe 제목(접근성). */ title: string; + /** 처음부터 캔버스만(`?bare`) — 도면이 오기 전에도 리본 · 패널을 안 그림(522-2). */ + bare?: boolean; /** CAD 편집 통지 — dirty 는 미저장 편집이 있는가. */ onChanged?: (dirty: boolean) => void; /** CAD 리본의 DXF·DWG 내보내기 단추. */ @@ -142,7 +144,7 @@ export function createCadHost {