From 80cfc187c653068f2c9ee86481b5d212e136452e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 18:08:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=C2=B7B06=20=ED=95=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80):=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=A4=84=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=3D=20=EA=B0=80=EA=B2=8C=EB=A7=8C=20(386-2=20=C2=B7?= =?UTF-8?q?=20386-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_page_data 새로 — 저장분(구조물 · 관 · 정의 판 확인) 페이지에서 한 번 · 저장 정본 바뀜(invalidate) · 페이지 떠남에 비움 · 보기 전환 요청 0 - 묶음 · 두 보기 구조물 칸 · 초안 측점이 같은 줄 · 「계산 중」 점(도크 · 묶음 차례 동안) - 묶음 planline 저장 기준 = 입력(관 · 구조물)이 심는 측점(design_pipeline_base · 페이지가 옵션으로) · baseMoved 지움 · 종류 한 벌 - design_pipeline_page 700줄 앞 쪼갬(design_pipeline_page_types) - 시험 test_384_2 · test_384_3 · 엄격 흉내 helper 에 자료 줄 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_dock.css | 34 +++++ A00_Common/design/design_dock.ts | 15 ++- A00_Common/design/design_page.ts | 6 + A00_Common/design/design_page_data.ts | 75 +++++++++++ A00_Common/design/design_pipeline_base.ts | 36 +++++ A00_Common/design/design_pipeline_page.ts | 109 +++++---------- .../design/design_pipeline_page_types.ts | 71 ++++++++++ A00_Common/design/design_shared.ts | 2 + B05_Profile/B05_Profile_UI_Page_Structures.ts | 6 +- .../logic/B05_Profile_Pipeline_Grade.ts | 7 +- B06_Section/B06_Section_Section_Drafts.ts | 27 ++-- .../B06_Section_UI_Page_Structures_Panel.ts | 10 +- resources/tester/helper_165_pavement_flow.cjs | 6 + resources/tester/helper_231_1_pave_reload.cjs | 6 + .../tester/helper_273_2_preview_stations.cjs | 24 ++-- .../tester/helper_317_kind_change_station.cjs | 5 + resources/tester/helper_318_cross_card.cjs | 9 ++ resources/tester/helper_319_kind_swap.cjs | 9 ++ resources/tester/helper_365_page_pipeline.cjs | 42 ++++++ resources/tester/helper_384_2_page_data.cjs | 92 +++++++++++++ resources/tester/helper_384_3_entry.ts | 4 + resources/tester/helper_384_3_planted.mjs | 124 ++++++++++++++++++ .../tester/helper_design_view_parity.cjs | 1 + resources/tester/test_384_2_page_data.py | 98 ++++++++++++++ resources/tester/test_384_3_planted_base.py | 91 +++++++++++++ ui_template/ui_template_locale_b2.ts | 1 + 26 files changed, 793 insertions(+), 117 deletions(-) create mode 100644 A00_Common/design/design_page_data.ts create mode 100644 A00_Common/design/design_pipeline_base.ts create mode 100644 A00_Common/design/design_pipeline_page_types.ts create mode 100644 resources/tester/helper_384_2_page_data.cjs create mode 100644 resources/tester/helper_384_3_entry.ts create mode 100644 resources/tester/helper_384_3_planted.mjs create mode 100644 resources/tester/test_384_2_page_data.py create mode 100644 resources/tester/test_384_3_planted_base.py diff --git a/A00_Common/design/design_dock.css b/A00_Common/design/design_dock.css index e69de29bb..9cadb0b6f 100644 --- a/A00_Common/design/design_dock.css +++ b/A00_Common/design/design_dock.css @@ -0,0 +1,34 @@ +/* 「계산 중」 점(386-2) — 단추 묶음 위 경계선 오른쪽 끝 · 묶음 차례가 떠 있는 동안만. */ +.design-dock { + position: relative; +} + +.b05-route__dock.ui-sidebar-actions > .design-dock__busy { + position: absolute; + top: -4px; + right: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-primary); + animation: design-dock-busy 0.8s ease-in-out infinite alternate; +} + +.b05-route__dock.ui-sidebar-actions > .design-dock__busy[hidden] { + display: none; +} + +@keyframes design-dock-busy { + from { + opacity: 0.35; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .b05-route__dock.ui-sidebar-actions > .design-dock__busy { + animation: none; + } +} diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index 050c3c7db..8f29d14e0 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -36,6 +36,8 @@ export interface DesignDock { setActions: (actions: Partial) => void; /** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */ setView: (view: DesignView) => void; + /** 「계산 중」 점 — 묶음 차례가 떠 있는 동안 켬(386-2). */ + setBusy: (busy: boolean) => void; } function label(key: keyof typeof ui_locales): string { @@ -60,7 +62,13 @@ export function createDesignDock( let actions = { ...initial }; const root = document.createElement("div"); - root.className = "b05-route__dock ui-sidebar-actions"; + root.className = "b05-route__dock ui-sidebar-actions design-dock"; + const busy = document.createElement("span"); + busy.className = "design-dock__busy"; + busy.title = label("Design_Busy"); + busy.setAttribute("role", "status"); + busy.setAttribute("aria-label", label("Design_Busy")); + busy.hidden = true; const divider = document.createElement("hr"); divider.className = "b05-structure__divider"; const row = document.createElement("div"); @@ -114,7 +122,7 @@ export function createDesignDock( }; row.append(resetButton, saveButton, confirmButton, switchButton); - root.append(...above, divider, row); + root.append(...above, divider, row, busy); sync(); return { @@ -127,5 +135,8 @@ export function createDesignDock( current = next; sync(); }, + setBusy: (next) => { + busy.hidden = !next; + }, }; } diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index dbd6e2dcc..e8d3f4a57 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -33,6 +33,7 @@ import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center"; import { reconcilePagePipeline } from "./design_pipeline_page"; +import { forgetPageData, onPageBusy } from "./design_page_data"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; @@ -143,11 +144,14 @@ export function renderDesignPage(root: HTMLElement): MountedPage { }; guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4) setRouteShiftOpener(() => shared.ensureView("profile"), showToast); // 중심점 좌우 = 편집기 숨긴 채(356-3) + let busy = false; // 「계산 중」 점 — 좌측 패널이 늦게 서도 지금 상태로(386-2) + const stopBusy = onPageBusy(projectId, (now) => shared.panel?.dock.setBusy((busy = now))); /** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */ const applyView = (view: DesignView): void => { main.dataset.view = view; shared.panel?.setView(view); + shared.panel?.dock.setBusy(busy); shared.profile?.setView(view); layout?.setTitle(ui_locales[VIEW_TITLE[view]][currentLanguageIndex]); layout?.setActiveStep(VIEW_STEP[view]); @@ -267,6 +271,8 @@ export function renderDesignPage(root: HTMLElement): MountedPage { slots.forEach((slot) => slot.dispose()); slots.clear(); shared.profile?.dispose(); + stopBusy(); + forgetPageData(projectId); // 다시 열면 저장분 새로(386-2) delete (window as unknown as { __designPage?: unknown }).__designPage; }, }; diff --git a/A00_Common/design/design_page_data.ts b/A00_Common/design/design_page_data.ts new file mode 100644 index 000000000..6de98d569 --- /dev/null +++ b/A00_Common/design/design_page_data.ts @@ -0,0 +1,75 @@ +/* ============================================================================= + * A00_Common/design/design_page_data.ts + * B05 · B06 한 페이지 자료 줄(PLAN 386-2) — 저장분(구조물 · 관 · 정의 판 확인)을 페이지에서 한 번만 받음. + * + * 받는 쪽 = 묶음(`design_pipeline_page`) · 종단 보기 구조물 칸 · 횡단 보기 구조물 칸 · 초안 측점 + * (`B06_Section_Section_Drafts`) · [저장] 관 얹기(`design_save`) — 보기 전환 · 다시 보임 = 요청 0. + * 다시 받기 = 저장 정본이 바뀐 뒤 한 번 — `invalidateSectionDetail` → `forgetDraftStations` → 여기 비움 + * ([저장] · [확정] · [초기화] · 노선 [적용] · 파일 다시 올림) · 페이지를 떠나면 비움. + * 종류 목록은 `fetchStructureTypes` 가 탭 수명 동안 쥠 — 정의 판 확인만 페이지마다 한 번. + * 돌려줄 때마다 사본 — 받는 쪽이 고쳐도 다른 쪽 저장분이 안 바뀜. + * 「계산 중」 — 묶음 차례가 떠 있는 동안 좌측 아래 점(`onPageBusy`). + * ========================================================================== */ + +import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + dropStaleStructureTypes, + fetchStructures, +} from "../../B05_Profile/B05_Profile_Api_Structures"; + +const memo = new Map>(); + +function once(key: string, load: () => Promise): Promise { + let hit = memo.get(key) as Promise | undefined; + if (!hit) { + hit = load(); + memo.set(key, hit); + hit.catch(() => memo.get(key) === hit && memo.delete(key)); // 실패는 다음에 다시 물음 + } + return hit.then((value) => structuredClone(value)); +} + +/** 저장 구조물(structures.json · 판번호 · 정의 판). */ +export const pageStructures = (projectId: string): ReturnType => + once(`structures:${projectId}`, () => fetchStructures(projectId)); + +/** 저장 관(pipe_points.json · 유역). */ +export const pagePipes = (projectId: string): ReturnType => + once(`pipes:${projectId}`, () => fetchDetailPipePoints(projectId)); + +/** 정의 판이 바뀌었으면 종류 캐시 버림 — 페이지마다 한 번(탭이 다시 보일 때는 `onStaleStructureTypes`). */ +export const pageTypesChecked = (projectId: string): Promise => + once(`types:${projectId}`, () => dropStaleStructureTypes()); + +/** 저장 정본이 바뀜 · 페이지를 떠남 — 다음에 부르면 한 번 다시 받음. */ +export function forgetPageData(projectId: string): void { + for (const key of [...memo.keys()]) if (key.endsWith(`:${projectId}`)) memo.delete(key); +} + +const busy = new Map(); +const busyListeners = new Map void>>(); + +const notify = (projectId: string): void => { + const now = (busy.get(projectId) ?? 0) > 0; + busyListeners.get(projectId)?.forEach((fn) => fn(now)); +}; + +/** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */ +export function trackPageBusy(projectId: string, run: Promise | null): void { + if (!run) return; + busy.set(projectId, (busy.get(projectId) ?? 0) + 1); + notify(projectId); + const done = (): void => { + busy.set(projectId, Math.max(0, (busy.get(projectId) ?? 1) - 1)); + notify(projectId); + }; + run.then(done, done); +} + +/** 「계산 중」 켬 · 끔 듣기 — 떼는 함수를 돌려줌. */ +export function onPageBusy(projectId: string, fn: (busy: boolean) => void): () => void { + const set = busyListeners.get(projectId) ?? new Set(); + busyListeners.set(projectId, set.add(fn)); + fn((busy.get(projectId) ?? 0) > 0); + return () => set.delete(fn); +} diff --git a/A00_Common/design/design_pipeline_base.ts b/A00_Common/design/design_pipeline_base.ts new file mode 100644 index 000000000..bcf843930 --- /dev/null +++ b/A00_Common/design/design_pipeline_base.ts @@ -0,0 +1,36 @@ +/* ============================================================================= + * A00_Common/design/design_pipeline_base.ts + * 묶음 planline 의 저장 노선 종단 기준(PLAN 386-3) — 측점 한 벌을 묶음 입력(관 · 구조물)으로 셈. + * + * 종단도 `listed()`(design_profile)와 같은 셈 — 저장 상세 측점 + 구조물 · 관이 심는 측점(`listedBase`). + * 심는 측점 = 종단 보기 구조물 칸(`syncCrossDrainStations`) · 횡단 보기(`planMarkStations`)와 같은 + * `planStations` → `plannedIrregular` — 보기가 종단도에 늦게 심어도 묶음은 다시 안 돎(옛 `baseMoved` 지움). + * 종류 = 구조물 정의 전부(보기와 같음). 화면 모듈을 끌어 묶음 대신 페이지(`design_shared`)가 옵션으로 넘김. + * ========================================================================== */ + +import { listedBase } from "./design_profile_base"; +import { plannedIrregular } from "./design_structure_status"; +import { withPipeIds } from "./design_pipe_ids"; +import { planStations, type PlanType } from "../../common_util/common_util_structure_station"; +import type { PipelineInputs } from "./logic/design_pipeline_types"; +import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; +import { + toAlignmentBase, + type AlignmentBase, +} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; + +/** 저장 상세(종단도가 그리는 것) + 지금 입력이 심는 측점 = 저장 노선 종단 기준. 상세가 없으면 종단도 기준. */ +export function plantedBase( + source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null }, + inputs: Pick, + types: ReadonlyArray, +): AlignmentBase | null { + const detail = source.detail ?? null; + const stored = detail && readAlignment(detail.longitudinal); + if (!detail || !stored) return source.base; + const pipes = withPipeIds((inputs.pipes ?? []) as Array<{ chainage_m: number }>); + const structures = (inputs.structures ?? []) as Parameters[1]; + const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관 + return listedBase(toAlignmentBase(stored), detail, planted); +} diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index f7dd07874..9eba943e4 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -30,7 +30,6 @@ import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations"; import type { ChangeSource, - CrossOutput, FollowPatch, PipelineInputs, PipelineOutputs, @@ -38,7 +37,6 @@ import type { PipelineStation, RangePart, RouteDraft, - StageFn, Vertex, } from "./logic/design_pipeline_types"; import { useDesignStore, type DesignStoreValues } from "./design_store"; @@ -47,13 +45,11 @@ import { placedOnLine as placed, type FollowingStructure, } from "../../common_util/common_util_route_follow"; -import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage"; import { emptyEdits, toAlignmentBase, - type AlignmentBase, type AlignmentEdits, type ProfileAlignment, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; @@ -69,7 +65,10 @@ import { type RoutePlanResponse, } from "../../B05_Profile/B05_Profile_Api_Replan"; import { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; -import { fetchStructures, fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { pagePipes, pageStructures, trackPageBusy } from "./design_page_data"; +import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types"; +export type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types"; import { groundStageDeps, loadDesignTerrainGrid, @@ -90,7 +89,6 @@ import { crossWithBerm, crossWithUphill, draftSectionDetail, - type BermAt, type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; @@ -114,52 +112,6 @@ export const DRAFT_ROUTE_ID = 0; /** 입력 ↔ 묶음 입력 맞춤 상한(바퀴) — 넘으면 「계산 불안정」(365-2). */ export const RECONCILE_ROUNDS = 3; -/** 종단도(`design_profile`)가 묶음에 내주는 것. */ -export interface ProfileLink { - /** 저장 노선의 종단 기준(측점 한 벌) · 지금 계획고 편집 · 저장 상세 · 노선 번호. */ - pipelineSource(): { - base: AlignmentBase | null; - edits: AlignmentEdits; - detail?: SectionDetailResponse | null; - routeId?: number | null; - }; - /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림. */ - adoptPipeline(base: AlignmentBase, edits: AlignmentEdits, draft: boolean): void; - /** 새 노선 횡단이 왔음 — 다시 그림(유토곡선). */ - redraw?(): void; - /** 배수유역 판 — drainage 단계. */ - side?: { pipelineStage: StageFn<"drainage"> } | null; -} - -export interface PagePipelineOptions { - stationIntervalM?: number; - /** 노선 초안 동안 종단 · 횡단이 바뀜 — 3D 「낡음」. */ - onCorridorStale?: () => void; - /** 노선 조작 뒤 follow 가 가게 관 · 구조물을 다시 씀(목록 · 알약 다시) — `far` = 새 노선에서 - * `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 먼 구조물 수(초안을 버려 되돌렸으면 0). */ - onFacilitiesFollowed?: (far: number) => void; - /** 새 노선 상세에 관 · 구조물 측점 폐회로 면적을 얹음(상세 가게 · 서버와 같은 얹기 · 349-0j) — 화면 - * 부품 계산이라 페이지가 넘김(묶음은 안 끌어옴). */ - structureAreas?: (detail: SectionDetailResponse) => void; - /** 노선 초안 시작(349-0o) — 초안 전 상세 사본(`saved` · 없으면 null · 초안 둔 채 새로고침)을 맡김 · 돌려준 - * 함수 = 초안을 버린 차례 끝에 부름(`stations` = 묶음 저장 노선 측점) · 그 값 = 되돌림 길 한 단어(묶음 기록 - * `restoreWhy`). 끝난 뒤 종단도가 저장 기준으로 돌아옴. */ - holdDraft?: ( - routeId: number, - saved: SectionDetailResponse | null, - ) => (stations: PipelineStation[]) => Promise; - /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ - onUnstable?: () => void; - /** 소단 구간(지금 구조물 목록 → 누가거리마다 제원) — 초안 cross 가 따라간 구조물로 소단을 셈(365-5). */ - bermAt?: (structures: unknown) => BermAt; - /** 저장 노선 cross = 다시 셈 창구 한 벌(365-4 · 측점이 상세와 다르면 null = 엔진) · 주면 종단도는 따로 안 셈. */ - savedCross?: ( - d: SectionDetailResponse, - routeId: number, - s: PipelineStation[], - ) => Promise; -} - interface PagePipeline { pipe: DesignPipeline; bridge: RouteDraftBridge; @@ -234,7 +186,8 @@ function createPagePipeline( /** 노선 0 칸 — 종단도 편집 가게(`createProfileEditStore`)와 같은 자리(지금 프로젝트). */ const draftAlignment = useDesignStore(null, DRAFT_ROUTE_ID); let plan: RoutePlanResponse | null = null; - let types: PlanType[] = []; + let types: PlanType[] = []; // 정의 전부(심는 측점) · 측점 열쇠 단계 = 켜진 것만 + let enabled: PlanType[] = []; let savedStructures: PlanStructure[] = []; let savedPipes: PlanPipe[] = []; let earthwork: MassHaulStageContext | null = null; @@ -308,8 +261,6 @@ function createPagePipeline( const stored = canon?.detail && readAlignment(canon.detail.longitudinal); return stored ? toAlignmentBase(stored).basePvi : null; }; - const baseMoved = () => - !dropping && !pipe.input("route") && !same(savedSource().base, pipe.output("planline")); /** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */ const crossNow = (): PipelineCrossSource | null => { const routeId = canon?.routeId; @@ -357,9 +308,13 @@ function createPagePipeline( }; return followStage({ ...ctx, inputs }); }, - stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }), + stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => enabled }), ground: createGroundStage(groundDeps), - planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine, storedPvi }), + planline: createPlanlineStage({ + savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base, + savedLine, + storedPvi, + }), profile: profileStage, // 단면 유형 = 새 지형 상단측 · 소단 = 지금 구조물(365-5) · 저장 노선 = 다시 셈 창구 한 벌(365-4) · 엔진은 // 창구 출력을 앞 결과로 안 이어 씀(초안 = 새로고침) @@ -557,6 +512,7 @@ function createPagePipeline( dropping = null; Object.assign(given, inputs); const run = (lastRun = pipe.start(inputs)); + trackPageBusy(projectId, run); // 「계산 중」 점(386-2) openGeneration = pipe.generation; quietGeneration = inputs.route ? -1 : pipe.generation; void run.then(() => reconcile()); // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤 @@ -568,7 +524,7 @@ function createPagePipeline( const feed = (source: ChangeSource, value: unknown, part: RangePart | null = null) => { given[SOURCE_INPUT[source]] = value as never; const run = pipe.update(source, value, part); - if (run) lastRun = run; + if (run) trackPageBusy(projectId, (lastRun = run)); return run; }; let reconciling: Promise | null = null; @@ -597,35 +553,34 @@ function createPagePipeline( })().finally(() => (reconciling = null))); } - /** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */ + /** 저장분(자료 줄 한 번 · 386-2) · 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — 정본이 바뀌면 다시. */ const loadPlan = (): Promise => - fetchRoutePlan(projectId) - .then((routePlan) => { + Promise.all([ + fetchStructureTypes(true).catch(() => []), + pageStructures(projectId).catch(() => null), + pagePipes(projectId).catch(() => null), + fetchRoutePlan(projectId), + ]) + .then(([structureTypes, list, pipes, routePlan]) => { + types = structureTypes as PlanType[]; + enabled = types.filter((type) => (type as { enabled?: boolean }).enabled !== false); + savedStructures = (list?.structures ?? []) as PlanStructure[]; + savedPipes = (pipes?.pipe_points ?? []).map(({ lonlat: _lonlat, ...pipe }) => pipe); plan = routePlan; savedStations = buildStations(savedLine()!, savedCurves(), savedPipes, savedStructures, { intervalM: options.stationIntervalM, - types, + types: enabled, stationSnap: routePlan.station_snap ?? false, }); crossSource = null; }) .catch(() => undefined); - void Promise.all([ - fetchStructureTypes().catch(() => []), - fetchStructures(projectId).catch(() => null), - fetchDetailPipePoints(projectId).catch(() => null), - loadDesignTerrainGrid(projectId).catch(() => null), // 지형 격자(없으면 서버 길) - ]) - .then(([structureTypes, list, pipes]) => { - types = structureTypes as PlanType[]; - savedStructures = (list?.structures ?? []) as PlanStructure[]; - savedPipes = (pipes?.pipe_points ?? []).map(({ lonlat: _lonlat, ...pipe }) => pipe); - return loadPlan(); - }) + // 지형 격자(없으면 서버 길) · 열 때 한 번 — 발자국 · 저장 안 한 노선 초안이 있으면 그 노선으로 + void Promise.all([loadPlan(), loadDesignTerrainGrid(projectId).catch(() => null)]) .then(() => { ready = plan !== null; - start(); // 열 때 한 번 — 발자국 · 저장 안 한 노선 초안이 있으면 그 노선으로 + start(); }) .catch(() => undefined); @@ -674,8 +629,8 @@ function createPagePipeline( }, restart() { if (!started) return start(); - if (canonChanged()) return void loadPlan().then(start); // 갈아끼움 · 같은 상세 다시 그림은 안 셈 - if (baseMoved()) start(); // 저장 노선 기준이 바뀜(보기가 늦게 심은 측점 · 되돌린 뒤 · 초안 빼고 · 365-5) + // 갈아끼움 · 같은 상세 다시 그림은 안 셈 · 보기가 심은 측점은 묶음이 입력으로 셈(386-3) + if (canonChanged()) void loadPlan().then(start); }, earthwork(context) { earthwork = context; diff --git a/A00_Common/design/design_pipeline_page_types.ts b/A00_Common/design/design_pipeline_page_types.ts new file mode 100644 index 000000000..18010750d --- /dev/null +++ b/A00_Common/design/design_pipeline_page_types.ts @@ -0,0 +1,71 @@ +/* ============================================================================= + * A00_Common/design/design_pipeline_page_types.ts + * 페이지 계산 묶음(`design_pipeline_page`)이 받는 것 — 종단도 고리 · 페이지 옵션(700줄 쪼갬 · 386-3). + * ========================================================================== */ + +import type { PlanType } from "../../common_util/common_util_structure_station"; +import type { + CrossOutput, + PipelineInputs, + PipelineStation, + StageFn, +} from "./logic/design_pipeline_types"; +import type { + AlignmentBase, + AlignmentEdits, +} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; +import type { BermAt } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; + +/** 종단도(`design_profile`)가 묶음에 내주는 것. */ +export interface ProfileLink { + /** 저장 노선의 종단 기준(측점 한 벌) · 지금 계획고 편집 · 저장 상세 · 노선 번호. */ + pipelineSource(): { + base: AlignmentBase | null; + edits: AlignmentEdits; + detail?: SectionDetailResponse | null; + routeId?: number | null; + }; + /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림. */ + adoptPipeline(base: AlignmentBase, edits: AlignmentEdits, draft: boolean): void; + /** 새 노선 횡단이 왔음 — 다시 그림(유토곡선). */ + redraw?(): void; + /** 배수유역 판 — drainage 단계. */ + side?: { pipelineStage: StageFn<"drainage"> } | null; +} + +export interface PagePipelineOptions { + stationIntervalM?: number; + /** 노선 초안 동안 종단 · 횡단이 바뀜 — 3D 「낡음」. */ + onCorridorStale?: () => void; + /** 노선 조작 뒤 follow 가 가게 관 · 구조물을 다시 씀(목록 · 알약 다시) — `far` = 새 노선에서 + * `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 먼 구조물 수(초안을 버려 되돌렸으면 0). */ + onFacilitiesFollowed?: (far: number) => void; + /** 새 노선 상세에 관 · 구조물 측점 폐회로 면적을 얹음(상세 가게 · 서버와 같은 얹기 · 349-0j) — 화면 + * 부품 계산이라 페이지가 넘김(묶음은 안 끌어옴). */ + structureAreas?: (detail: SectionDetailResponse) => void; + /** 노선 초안 시작(349-0o) — 초안 전 상세 사본(`saved` · 없으면 null · 초안 둔 채 새로고침)을 맡김 · 돌려준 + * 함수 = 초안을 버린 차례 끝에 부름(`stations` = 묶음 저장 노선 측점) · 그 값 = 되돌림 길 한 단어(묶음 기록 + * `restoreWhy`). 끝난 뒤 종단도가 저장 기준으로 돌아옴. */ + holdDraft?: ( + routeId: number, + saved: SectionDetailResponse | null, + ) => (stations: PipelineStation[]) => Promise; + /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ + onUnstable?: () => void; + /** 소단 구간(지금 구조물 목록 → 누가거리마다 제원) — 초안 cross 가 따라간 구조물로 소단을 셈(365-5). */ + bermAt?: (structures: unknown) => BermAt; + /** 저장 노선 cross = 다시 셈 창구 한 벌(365-4 · 측점이 상세와 다르면 null = 엔진) · 주면 종단도는 따로 안 셈. */ + savedCross?: ( + d: SectionDetailResponse, + routeId: number, + s: PipelineStation[], + ) => Promise; + /** 저장 노선 종단 기준 = 저장 상세 + 지금 입력(관 · 구조물)이 심는 측점(386-3 · `design_pipeline_base`) — 보기가 + * 종단도에 늦게 심어도 묶음은 다시 안 돎 · 없으면 종단도 기준 그대로. `types` = 구조물 정의 전부. */ + plantedBase?: ( + source: ReturnType, + inputs: PipelineInputs, + types: ReadonlyArray, + ) => AlignmentBase | null; +} diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 41e1b0711..773a211eb 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -30,6 +30,7 @@ import { markDesignClean } from "./design_leave"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page"; +import { plantedBase } from "./design_pipeline_base"; import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { applyStructureAreaRows, @@ -228,6 +229,7 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo profile.redraw(); return detailCross(detail, stations); }, + plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관) structureAreas: ({ cross_sections: rows }) => applyStructureAreaRows(rows, structureAreaRows(rows)), // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 9445652df..64b0af6f0 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -16,7 +16,6 @@ import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchStructures, - dropStaleStructureTypes, fetchStructureTypes, staleDefinitionNotice, definitionStale, @@ -37,6 +36,7 @@ import { listPick } from "../common_util/common_util_structure_catalog"; import { planStations } from "../common_util/common_util_structure_station"; import { plannedIrregular } from "../A00_Common/design/design_structure_status"; import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids"; +import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -278,10 +278,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { // · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군). // · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 // 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다. - await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0) + await pageTypesChecked(deps.projectId); // 정의 판 확인 · 저장분 = 페이지 자료 줄 한 번(322-0 · 386-2) const [allTypes, stored] = await Promise.all([ fetchStructureTypes(true), - fetchStructures(deps.projectId), + pageStructures(deps.projectId), ]); deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1) deps.profilePanel().setStructureTypes(allTypes); diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index d0c57e1e7..1d7a5df27 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -14,6 +14,7 @@ import type { FollowPatch, + PipelineInputs, RangePart, StageFn, Vertex, @@ -109,8 +110,8 @@ export function createFollowStage(deps: { export const followStage = createFollowStage({ savedLine: () => null }); export interface PlanlineDeps { - /** 저장 노선의 종단 기준(측점 한 벌 · 종단 보기 `listed`) — 없으면 아직 못 셈. */ - savedBase(): AlignmentBase | null; + /** 저장 노선의 종단 기준(측점 한 벌 = 지금 입력이 심는 측점 · 386-3) — 없으면 아직 못 셈. */ + savedBase(inputs: PipelineInputs): AlignmentBase | null; /** 저장 노선(그린 선) — 바뀐 구간을 가름. */ savedLine(): Vertex[] | null; /** 노선 초안 첫 계획선 꼭짓점 = 서버 저장 종단 — 보기가 심은 측점(초안 자리 관 등)에 안 흔들림(365-5) · 없으면 `savedBase`. */ @@ -119,7 +120,7 @@ export interface PlanlineDeps { export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { return (ctx) => { - const saved = deps.savedBase(); + const saved = deps.savedBase(ctx.inputs); if (!saved) throw new Error("저장 종단 기준이 아직 없습니다."); const ground = ctx.out.ground; const route = ctx.out.route; diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 605d953b2..acf760890 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -12,9 +12,8 @@ * 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3). * ========================================================================== */ -import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { forgetPageData, pagePipes, pageStructures } from "../A00_Common/design/design_page_data"; import { - fetchStructures, fetchStructureTypes, readPendingStructures, type StructureInstance, @@ -44,8 +43,6 @@ const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) => a.chainage_m - b.chainage_m; -/** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀 · 저장 뒤(`forgetDraftStations`) 다시 받음. */ -const saved = new Map>(); /** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */ const previews = new Map(); /** 초안이 빼 둔 옛 자리 측점. */ @@ -53,9 +50,9 @@ const parked = new WeakMap(); /** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */ const turns = new WeakMap(); -/** 저장 · 노선 바뀜 뒤 — 쥔 저장분 · 미리 뜬 횡단을 버림. */ +/** 저장 · 노선 바뀜 뒤 — 페이지 저장분(자료 줄 · 386-2) · 미리 뜬 횡단을 버림. */ export function forgetDraftStations(projectId: string): void { - saved.delete(projectId); + forgetPageData(projectId); for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key); } @@ -77,18 +74,12 @@ export function forgetPreviewAdjusts( } } -function savedOf(projectId: string): Promise<[Pipes, StructureInstance[]]> { - let hit = saved.get(projectId); - if (!hit) { - hit = Promise.all([ - fetchDetailPipePoints(projectId).then((res) => res.pipe_points as Pipes), - fetchStructures(projectId).then((res) => res.structures), - ]); - hit.catch(() => saved.delete(projectId)); - saved.set(projectId, hit); - } - return hit; -} +/** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀(페이지 자료 줄 한 번 · 저장 뒤 다시). */ +const savedOf = (projectId: string): Promise<[Pipes, StructureInstance[]]> => + Promise.all([ + pagePipes(projectId).then((res) => res.pipe_points as Pipes), + pageStructures(projectId).then((res) => res.structures), + ]); async function previewOf( projectId: string, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index cfd085df3..ae31e6978 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -19,8 +19,6 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { - fetchStructures, - dropStaleStructureTypes, fetchStructureTypes, staleDefinitionNotice, readPendingStructures, @@ -33,7 +31,7 @@ import { import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { mergeFacilityOptions } from "../B05_Profile/logic/B05_Profile_UI_Drainage_Facility_Merge"; import { fordWaitsOf, waitsFordHeight } from "../B05_Profile/B05_Profile_UI_Drainage_Ford_Fill"; -import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { pagePipes, pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data"; import { linkFields, readGroupItems, @@ -375,14 +373,14 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (!deps.projectId) return; const projectId = deps.projectId; try { - await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0) + await pageTypesChecked(projectId); // 정의 판 확인 · 저장분 = 페이지 자료 줄 한 번(322-0 · 386-2) const [types, stored, pipeResponse] = await Promise.all([ // B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서 // 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이 // 「B06 개별 횡단도 옵션으로 재사용」이라 적어 둔 그 자리다. fetchStructureTypes(true), - fetchStructures(projectId), - fetchDetailPipePoints(projectId), + pageStructures(projectId), + pagePipes(projectId), ]); section.setTypes(types, listPick("b06")); // 표 목록 both + b06(322-1) markTypes = types; diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index c9beb6b73..2eb8288bd 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -150,6 +150,12 @@ const panelModule = loadTs( "../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }), }, + // 페이지 자료 줄(386-2) — 저장분 한 번 + "../A00_Common/design/design_page_data": { + pageTypesChecked: async () => false, + pageStructures: async () => ({ structures: [] }), + pagePipes: async () => ({ pipe_points: PIPES, basins: [] }), + }, "../common_util/common_util_summary_item": { linkFields: () => ({}), readGroupItems: (x) => x ?? {}, diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 474994deb..7a1c21f56 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -169,6 +169,12 @@ const panelModule = loadTs( "../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }), }, + // 페이지 자료 줄(386-2) — 저장분 한 번 + "../A00_Common/design/design_page_data": { + pageTypesChecked: async () => false, + pageStructures: async () => ({ structures: [] }), + pagePipes: async () => ({ pipe_points: PIPES, basins: [] }), + }, "../common_util/common_util_summary_item": { linkFields: () => ({}), readGroupItems: (x) => x ?? {}, diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 8dcd9faf3..88c4dd05d 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -35,15 +35,23 @@ const types = [ options: [], }, ]; +const serverFetch = { + fetchDetailPipePoints: async () => ({ pipe_points: [{ chainage_m: 95.38, facility: "pipe" }] }), +}; +const serverStructures = { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => types, + readPendingStructures: () => null, + dropStaleStructureTypes: async () => false, +}; const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { - "../B04_PreProcess/B04_PreProcess_Api_Fetch": { - fetchDetailPipePoints: async () => ({ pipe_points: [{ chainage_m: 95.38, facility: "pipe" }] }), - }, - "../B05_Profile/B05_Profile_Api_Structures": { - fetchStructures: async () => ({ structures: [] }), - fetchStructureTypes: async () => types, - readPendingStructures: () => null, - }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": serverFetch, + "../B05_Profile/B05_Profile_Api_Structures": serverStructures, + // 페이지 자료 줄(386-2) — 진짜 · 같은 서버 흉내 + "../A00_Common/design/design_page_data": loadTs("A00_Common/design/design_page_data.ts", { + "../../B04_PreProcess/B04_PreProcess_Api_Fetch": serverFetch, + "../../B05_Profile/B05_Profile_Api_Structures": serverStructures, + }), "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => draftPipes }, "../B05_Profile/B05_Profile_Api_PlusStations": { readPlusDraft: () => null }, "@util/common_util_culvert_sets": { diff --git a/resources/tester/helper_317_kind_change_station.cjs b/resources/tester/helper_317_kind_change_station.cjs index 8b17b7e77..558762648 100644 --- a/resources/tester/helper_317_kind_change_station.cjs +++ b/resources/tester/helper_317_kind_change_station.cjs @@ -24,6 +24,11 @@ const STUBS = { chainages.map((at) => ({ chainage_m: at, kind: "irregular", design: { fresh: true } })), }, }; +// 페이지 자료 줄(386-2) — 진짜 design_page_data 가 같은 서버 흉내를 부름 +STUBS["../../B04_PreProcess/B04_PreProcess_Api_Fetch"] = + STUBS["../B04_PreProcess/B04_PreProcess_Api_Fetch"]; +STUBS["../../B05_Profile/B05_Profile_Api_Structures"] = + STUBS["../B05_Profile/B05_Profile_Api_Structures"]; const cache = new Map(); const load = (file) => { if (cache.has(file)) return cache.get(file); diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index c5db0b8c8..7ab54a238 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -475,6 +475,7 @@ const state = { draftPipes: null, savedPipes: [] }; const loadDrafts = makeLoader( [ B06("B06_Section_Section_Drafts"), + path.join(ROOT, "A00_Common", "design", "design_page_data.ts"), // 자료 줄(386-2) path.join(ROOT, "common_util", "common_util_structure_station.ts"), path.join(ROOT, "common_util", "common_util_culvert_sets.ts"), ], @@ -487,6 +488,14 @@ const loadDrafts = makeLoader( fetchStructureTypes: async () => [], readPendingStructures: () => null, }, + "../../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }), + }, + "../../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => [], + readPendingStructures: () => null, + }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_p, chainages) => diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index 9ccbf496e..58c0a95a9 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.cjs @@ -313,6 +313,7 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt const load = makeLoader( [ B06("B06_Section_Section_Drafts"), + path.join(ROOT, "A00_Common", "design", "design_page_data.ts"), // 자료 줄(386-2) path.join(ROOT, "common_util", "common_util_culvert_sets.ts"), path.join(ROOT, "common_util", "common_util_structure_station.ts"), // 그림 자료 = 형상 얹기 한 벌(323-4) @@ -329,6 +330,14 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt fetchStructureTypes: async () => TYPES, readPendingStructures: () => state.draftStructures, }, + "../../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }), + }, + "../../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: state.savedStructures }), + fetchStructureTypes: async () => TYPES, + readPendingStructures: () => state.draftStructures, + }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_p, chainages) => diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index e49bd662a..ccc5a3d93 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -535,7 +535,49 @@ async function run() { savedFirst: firstPvi(savedBase), }; page.clearDrafts(C); + + // ⑬ 보기가 늦게 심은 측점(386-3) — 종단도 기준이 바뀌어도(restart) 묶음은 안 돎 · 기준 = 입력이 심는 측점(옵션) + const D = "proj-386"; + window.localStorage.setItem("frd_current_project_id", D); + let viewBase = savedBase; + const planted = []; + pageMod.routeDraftBridge( + D, + { ...link(), pipelineSource: () => ({ ...link().pipelineSource(), base: viewBase }) }, + { + stationIntervalM: job.interval, + plantedBase: (source, inputs) => { + planted.push(Array.isArray(inputs.pipes) ? inputs.pipes.length : null); + return source.base; + }, + }, + ); + pageMod.pipelineEarthwork(D, { conversion: job.conversion }); + // 기록 상한(RUN_LOG_SIZE) 뒤라 길이 대신 새 기록을 셈 + const since = (old) => runs().filter((record) => !old.has(record)); + let old = new Set(runs()); + for (let i = 0; i < 400; i += 1) { + const fresh = since(old); + if (fresh.length && fresh.every((record) => record.outcome !== "running")) break; + await tick(5); + } + await tick(20); + const openRuns = since(old).length; + old = new Set(runs()); + viewBase = { + ...savedBase, + stations: [...savedBase.stations, { station_id: "irregular:late", chainage_m: 33 }], + }; + pageMod.restartPagePipeline(D); // 종단도 setIrregularStations 꼴 + await tick(30); + const late = { openRuns, lateRuns: since(old).length, planted: planted[0] ?? null }; + old = new Set(runs()); + late.same = await pageMod.reconcilePagePipeline(D); + late.sameRuns = since(old).length; + pageMod.disposePagePipeline(D); + page.clearDrafts(D); return { + late, kept, b05, b06, diff --git a/resources/tester/helper_384_2_page_data.cjs b/resources/tester/helper_384_2_page_data.cjs new file mode 100644 index 000000000..0f9ef6f89 --- /dev/null +++ b/resources/tester/helper_384_2_page_data.cjs @@ -0,0 +1,92 @@ +/* PLAN 386-2 단위검증 헬퍼 — 진짜 `design_page_data.ts` 를 서버 흉내(받은 횟수 셈)로 돌림. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_384_2_page_data.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 calls = { structures: 0, pipes: 0, version: 0 }; +let failPipes = false; +const STUBS = { + "../../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => { + calls.pipes += 1; + if (failPipes) throw new Error("끊김"); + return { pipe_points: [{ chainage_m: 10, facility: "pipe" }], basins: [] }; + }, + }, + "../../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => { + calls.structures += 1; + return { structures: [{ structure_id: "s1", chainage_m: 20 }], revision: calls.structures }; + }, + dropStaleStructureTypes: async () => { + calls.version += 1; + return false; + }, + }, +}; +const js = ts.transpileModule( + fs.readFileSync(path.join(ROOT, "A00_Common", "design", "design_page_data.ts"), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }, +).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (name) => { + if (!STUBS[name]) throw new Error(`stub 없음: ${name}`); + return STUBS[name]; +}); +const data = box.exports; +const P = "p1"; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +(async () => { + const out = {}; + // 열 때 · 보기 전환 · 다시 보임 = 같은 저장분 — 서버는 한 번 + const [a, b] = await Promise.all([data.pageStructures(P), data.pageStructures(P)]); + await data.pageStructures(P); + await data.pagePipes(P); + await data.pagePipes(P); + await data.pageTypesChecked(P); + await data.pageTypesChecked(P); + out.open = { ...calls }; + // 사본 — 받는 쪽이 고쳐도 다음 받기는 그대로 + a.structures.push({ structure_id: "x" }); + out.copy = { same: a === b, after: (await data.pageStructures(P)).structures.length }; + // 다른 프로젝트는 따로 + await data.pageStructures("p2"); + out.other = calls.structures; + // 저장 정본이 바뀜 — 비우면 다음에 한 번 다시 + data.forgetPageData(P); + await data.pageStructures(P); + await data.pageStructures(P); + out.afterForget = { ...calls }; + // 실패는 기억 안 함 — 다음에 다시 물음 + data.forgetPageData(P); + failPipes = true; + out.failed = await data.pagePipes(P).then( + () => "ok", + () => "error", + ); + failPipes = false; + await data.pagePipes(P); + out.retry = calls.pipes; + // 「계산 중」 — 떠 있는 차례가 끝날 때까지 켬(밀림 · 실패도 끔) + const seen = []; + const stop = data.onPageBusy(P, (busy) => seen.push(busy)); + let finish; + data.trackPageBusy(P, new Promise((resolve) => (finish = resolve))); + data.trackPageBusy(P, Promise.reject(new Error("밀림"))); + data.trackPageBusy(P, null); + await tick(); + out.midBusy = seen[seen.length - 1]; + finish(); + await tick(); + stop(); + data.trackPageBusy(P, Promise.resolve()); + await tick(); + out.busy = seen; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/helper_384_3_entry.ts b/resources/tester/helper_384_3_entry.ts new file mode 100644 index 000000000..c6c26f11c --- /dev/null +++ b/resources/tester/helper_384_3_entry.ts @@ -0,0 +1,4 @@ +/* 386-3 시험 묶음 진입점 — 묶음 planline 저장 기준(진짜 모듈 · vite ssr). 짝 = test_384_3_planted_base.py. */ +export { plantedBase } from "../../A00_Common/design/design_pipeline_base"; +export { usePipeIds } from "../../A00_Common/design/design_pipe_ids"; +export { catalogRow, useCatalogDefinition } from "../../common_util/common_util_structure_catalog"; diff --git a/resources/tester/helper_384_3_planted.mjs b/resources/tester/helper_384_3_planted.mjs new file mode 100644 index 000000000..4b1bc6b95 --- /dev/null +++ b/resources/tester/helper_384_3_planted.mjs @@ -0,0 +1,124 @@ +/* 386-3 헬퍼 — 묶음 planline 저장 기준 = 저장 상세 + 지금 입력(관 · 구조물)이 심는 측점. + * node helper_384_3_planted.mjs <묶음.mjs> + * vite ssr 로 묶은 진짜 모듈(helper_384_3_entry.ts) · 표준출력 JSON 한 벌. 짝 = test_384_3_planted_base.py. + */ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p386"; +localStorage.setItem("frd_current_project_id", P); +m.usePipeIds(P); + +const st = (chainage_m, kind = "regular") => ({ + station_id: `station_${String(Math.round(chainage_m * 1000)).padStart(12, "0")}`, + chainage_m, + kind, +}); +const server = [st(0, "bp"), st(20), st(40), st(60, "ep")]; +const detail = { + longitudinal: { + stations: server, + length_m: 60, + samples: [ + { chainage_m: 0, elevation_m: 100 }, + { chainage_m: 60, elevation_m: 106 }, + ], + profile_alignment: { + policy: { default_curve_radius_m: 100, station_interval_m: 20 }, + base_pvi: [ + { chainage_m: 0, elevation_m: 100 }, + { chainage_m: 60, elevation_m: 106 }, + ], + samples: [ + { chainage_m: 0, ground_elevation_m: 100 }, + { chainage_m: 60, ground_elevation_m: 106 }, + ], + stations: server, + segments: [], + curves: [], + }, + }, + cross_sections: [], +}; +// 구조물 표 = 실제 정의(마스터 읽기만) · 측점을 심는 구조물 — 켜진 점형 하나 · 꺼진 것 하나(보기와 같이 둘 다 심음) +const definition = JSON.parse( + readFileSync(new URL("../master_template/구조물정의.json", import.meta.url), "utf8"), +); +m.useCatalogDefinition(definition); +const planting = (item) => { + const row = m.catalogRow(item.type_id); + return row && row.owner === "structures" && row.station !== "none"; +}; +const on = definition.types.find( + (item) => item.enabled !== false && item.placement === "point" && planting(item), +); +const off = definition.types.find((item) => item.enabled === false && planting(item)); +const types = [on, off].map(({ type_id, group, placement, enabled }) => ({ + type_id, + group, + placement, + enabled, +})); +const at = (id, type_id, chainage_m) => ({ + structure_id: id, + type_id, + chainage_m, + start_m: chainage_m - 3, + end_m: chainage_m + 3, + options: {}, +}); +const ids = (base) => base.stations.map((s) => [s.station_id.split(":")[0], s.chainage_m]); +const viewBase = { stations: [{ station_id: "view", chainage_m: 1 }] }; +const none = m.plantedBase({ base: viewBase, detail }, { pipes: [], structures: [] }, types); +const pipe = m.plantedBase( + { base: viewBase, detail }, + { pipes: [], structures: [at("b1", on.type_id, 33)] }, + types, +); +const both = m.plantedBase( + { base: viewBase, detail }, + { + pipes: [], + structures: [at("b1", on.type_id, 33), at("d1", off.type_id, 47)], + }, + types, +); +const noDetail = m.plantedBase( + { base: viewBase, detail: null }, + { pipes: [], structures: [] }, + types, +); +process.stdout.write( + JSON.stringify({ + none: ids(none), + pipe: ids(pipe), + both: ids(both), + noDetail: noDetail === viewBase, + pvi: both.basePvi.length, + kinds: [on.type_id, off.type_id], + }), +); diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index 17faa20ad..c00f77d53 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -126,6 +126,7 @@ const LOCALES = { B06_Profile_Btn_Confirm: ["확정", "Confirm"], Design_Btn_ToCross: ["횡단 보기", "Cross view"], Design_Btn_ToProfile: ["노선 보기", "Route view"], + Design_Busy: ["계산 중", "Calculating"], // 「계산 중」 점(386-2) }; const STUBS = { "@ui/ui_template_locale": { ui_locales: LOCALES, currentLanguageIndex: 0 }, diff --git a/resources/tester/test_384_2_page_data.py b/resources/tester/test_384_2_page_data.py new file mode 100644 index 000000000..10465962a --- /dev/null +++ b/resources/tester/test_384_2_page_data.py @@ -0,0 +1,98 @@ +"""PLAN 386-2 sub7 — B05 · B06 한 페이지 자료 줄(`design_page_data.ts`). + +못박는 것 + - 저장분(구조물 · 관 · 정의 판 확인) = 페이지에서 한 번 — 묶음 · 두 보기 구조물 칸 · 초안 측점이 같은 줄. + - 보기 전환 · 다시 보임 = 요청 0(구조물 칸 load 가 직접 안 받음). + - 저장 정본이 바뀌면(`invalidateSectionDetail` → `forgetDraftStations`) · 페이지를 떠나면 비움 → 한 번 다시. + - 돌려줄 때마다 사본 · 실패는 기억 안 함 · 「계산 중」 점 = 묶음 차례가 떠 있는 동안. + +짝: `helper_384_2_page_data.cjs`(진짜 TS · 서버 흉내). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_384_2_page_data.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["open"] == {"structures": 1, "pipes": 1, "version": 1} + assert r["other"] == 2 # 다른 프로젝트는 따로 + + +def test_돌려줄_때마다_사본(r): + assert r["copy"] == {"same": False, "after": 1} + + +def test_비우면_한_번_다시(r): + assert r["afterForget"]["structures"] == 3 # p1 · p2 · 비운 뒤 p1 한 번 + + +def test_실패는_기억_안_함(r): + assert r["failed"] == "error" + assert r["retry"] == 3 # 처음 · 실패 · 다시 + + +def test_계산_중_점(r): + assert r["midBusy"] is True + assert r["busy"][0] is False and r["busy"][-1] is False # 듣기 시작 = 지금 상태 · 끝나면 끔 + assert len(r["busy"]) <= 6 # 뗀 뒤 차례는 안 알림 + + +@pytest.mark.parametrize( + "rel", + [ + "A00_Common/design/design_pipeline_page.ts", + "B05_Profile/B05_Profile_UI_Page_Structures.ts", + "B06_Section/B06_Section_UI_Page_Structures_Panel.ts", + "B06_Section/B06_Section_Section_Drafts.ts", + ], +) +def test_보기_전환_길은_자료_줄만(rel): + src = _read(rel) + assert "dropStaleStructureTypes(" not in src + assert "fetchDetailPipePoints(" not in src + assert "pageStructures(" in src + # B05 구조물 칸은 [저장] 직전 판번호 · 저장 뒤 목록만 직접 받음 + if "Page_Structures.ts" not in rel: + assert "fetchStructures(" not in src + + +def test_비우는_자리(): + drafts = _read("B06_Section/B06_Section_Section_Drafts.ts") + page = _read("A00_Common/design/design_page.ts") + store = _read("B06_Section/B06_Section_Section_Store.ts") + assert "forgetPageData(projectId)" in drafts # 저장 정본 바뀜(invalidate → forgetDraftStations) + assert "forgetDraftStations(projectId)" in store + assert "forgetPageData(projectId)" in page # 페이지를 떠남 + assert "onPageBusy(projectId" in page + + +def test_계산_중_점은_묶음_차례(): + pipe = _read("A00_Common/design/design_pipeline_page.ts") + dock = _read("A00_Common/design/design_dock.ts") + assert pipe.count("trackPageBusy(projectId") == 2 # 열 때 start · 조작 feed + assert "setBusy" in dock and "Design_Busy" in dock diff --git a/resources/tester/test_384_3_planted_base.py b/resources/tester/test_384_3_planted_base.py new file mode 100644 index 000000000..ce67887a4 --- /dev/null +++ b/resources/tester/test_384_3_planted_base.py @@ -0,0 +1,91 @@ +"""PLAN 386-3 sub7 — 묶음 입력 = 가게만(보기 측점 심기 · baseMoved 지움). + +못박는 것 + - 묶음 planline 저장 기준 = 저장 상세 + 지금 입력(관 · 구조물)이 심는 측점(`plantedBase` · 보기와 같은 셈) — + 종류 = 정의 전부(꺼진 종류도 심음) · 상세가 없으면 종단도 기준. + - 보기가 종단도에 늦게 심어도(종단도 기준이 바뀌어 restart) 묶음은 안 돎 · 열 때 1 · 같은 입력 0. + - `baseMoved` 없음 · 페이지(design_shared)가 옵션으로 넘김(묶음은 화면 모듈을 안 끌어옴). + +짝: `helper_384_3_entry.ts` + `helper_384_3_planted.mjs`(진짜 모듈 · vite ssr) · `helper_365_page_pipeline.cjs` ⑬. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from test_365_page_pipeline import r # noqa: E402,F401 — 묶음 시험 한 벌(⑬ 늦게 심은 측점) + + +@pytest.fixture(scope="module") +def planted(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("b386") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_384_3_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_384_3_entry.js", bundle) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_384_3_planted.mjs"), str(bundle)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_저장_기준_입력이_심는_측점(planted): + assert [c for _, c in planted["none"]] == [0, 20, 40, 60] # 심을 것 없음 = 저장 상세 측점 + assert ["irregular", 33] in planted["pipe"] # 구조물 자리 + assert ["irregular", 33] in planted["both"] and ["irregular", 47] in planted[ + "both" + ] # 꺼진 종류도 + assert planted["noDetail"] is True # 상세 없음 = 종단도 기준 + assert planted["pvi"] == 2 + + +def test_늦게_심어도_안_돎(r): + late = r["late"] + assert late["openRuns"] == 1 # 열 때 1 + assert late["lateRuns"] == 0 # 종단도 기준이 바뀌어도(보기가 늦게 심음) 안 돎 + assert late["same"] == 0 and late["sameRuns"] == 0 # 같은 입력 0 + assert late["planted"] is not None # planline 이 입력(관)으로 기준을 셈 + + +def test_baseMoved_없음_옵션은_페이지가(): + pipe = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8") + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + assert "baseMoved" not in pipe + assert "design_pipeline_base" not in pipe # 묶음은 화면 모듈을 안 끌어옴 + assert "options.plantedBase?.(savedSource(), at, types)" in pipe + assert "plantedBase, //" in shared diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 796f13715..e33a35d5d 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -1057,4 +1057,5 @@ export const ui_locales_b2 = { "계산 불안정 — 입력과 결과가 세 바퀴 안에 안 맞음", "Calculation unstable — inputs and results did not settle in three rounds", ], + Design_Busy: ["계산 중", "Calculating"], } as const satisfies Record;