feat(B05·B06 한 페이지): 페이지 자료 줄 · 묶음 입력 = 가게만 (386-2 · 386-3)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-17 18:08:17 +09:00
co-authored by Claude Opus 5.5
parent 1b327b3613
commit 80cfc187c6
26 changed files with 793 additions and 117 deletions
+34
View File
@@ -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;
}
}
+13 -2
View File
@@ -36,6 +36,8 @@ export interface DesignDock {
setActions: (actions: Partial<DesignDockActions>) => 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;
},
};
}
+6
View File
@@ -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;
},
};
+75
View File
@@ -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<string, Promise<unknown>>();
function once<T>(key: string, load: () => Promise<T>): Promise<T> {
let hit = memo.get(key) as Promise<T> | 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<typeof fetchStructures> =>
once(`structures:${projectId}`, () => fetchStructures(projectId));
/** 저장 관(pipe_points.json · 유역). */
export const pagePipes = (projectId: string): ReturnType<typeof fetchDetailPipePoints> =>
once(`pipes:${projectId}`, () => fetchDetailPipePoints(projectId));
/** 정의 판이 바뀌었으면 종류 캐시 버림 — 페이지마다 한 번(탭이 다시 보일 때는 `onStaleStructureTypes`). */
export const pageTypesChecked = (projectId: string): Promise<boolean> =>
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<string, number>();
const busyListeners = new Map<string, Set<(busy: boolean) => 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<unknown> | 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);
}
+36
View File
@@ -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<PipelineInputs, "pipes" | "structures">,
types: ReadonlyArray<PlanType>,
): 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<typeof planStations>[1];
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
return listedBase(toAlignmentBase(stored), detail, planted);
}
+32 -77
View File
@@ -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<string>;
/** 고리가 `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<CrossOutput | null>;
}
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<number> | null = null;
@@ -597,35 +553,34 @@ function createPagePipeline(
})().finally(() => (reconciling = null)));
}
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */
/** 저장분(자료 줄 한 번 · 386-2) · 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — 정본이 바뀌면 다시. */
const loadPlan = (): Promise<void> =>
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;
@@ -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<string>;
/** 고리가 `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<CrossOutput | null>;
/** 저장 노선 종단 기준 = 저장 상세 + 지금 입력(관 · 구조물)이 심는 측점(386-3 · `design_pipeline_base`) — 보기가
* 종단도에 늦게 심어도 묶음은 다시 안 돎 · 없으면 종단도 기준 그대로. `types` = 구조물 정의 전부. */
plantedBase?: (
source: ReturnType<ProfileLink["pipelineSource"]>,
inputs: PipelineInputs,
types: ReadonlyArray<PlanType>,
) => AlignmentBase | null;
}
+2
View File
@@ -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) · 상세가 없으면 묶음 측점으로 갈아끼움
@@ -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);
@@ -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;
+9 -18
View File
@@ -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<string, Promise<[Pipes, StructureInstance[]]>>();
/** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */
const previews = new Map<string, CrossSection | null>();
/** 초안이 빼 둔 옛 자리 측점. */
@@ -53,9 +50,9 @@ const parked = new WeakMap<SectionDetailResponse, CrossSection[]>();
/** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */
const turns = new WeakMap<SectionDetailResponse, number>();
/** 저장 · 노선 바뀜 뒤 — 쥔 저장분 · 미리 뜬 횡단을 버림. */
/** 저장 · 노선 바뀜 뒤 — 페이지 저장분(자료 줄 · 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,
@@ -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;
@@ -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 ?? {},
@@ -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 ?? {},
@@ -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": {
@@ -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);
@@ -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) =>
@@ -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) =>
@@ -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,
@@ -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);
});
+4
View File
@@ -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";
+124
View File
@@ -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],
}),
);
@@ -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 },
+98
View File
@@ -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
@@ -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
+1
View File
@@ -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<string, LocaleEntry>;