- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로 - 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판) - import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/ - 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob - design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄) - 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* A00_Common/design/design_pipeline_page.ts
|
|
* B05 · B06 한 페이지의 계산 묶음 하나(PLAN 346-1 · 346-2 붙임) — 프로젝트마다 하나 · 페이지를 떠나면
|
|
* 버림(종단도 `dispose`). 단계 붙임 · 입력 모음 · 가게 잇기만 — 셈은 단계 파일 몫.
|
|
*
|
|
* route · ground `B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts`(sub8)
|
|
* follow · planline · profile `B05_Profile/logic/B05_Profile_Pipeline_Grade.ts`
|
|
* stations `design_pipeline_stations.ts` · footprint `B05_Profile_Footprint_Stage.ts`(sub6)
|
|
* cross · masshaul · haul `B06_Section_Cross_Stage` · `logic/B05_Profile_MassHaul_Stage` ·
|
|
* `B05_Profile_Api_HaulPlan`(sub2) · drainage 배수유역 판 `pipelineStage`(sub3) · corridor 3D 「낡음」.
|
|
* haul · corridor 는 노선 초안 동안만(저장 노선은 유토곡선 판 · 횡단 다시 셈이 맡음).
|
|
*
|
|
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
|
|
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
|
|
* 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
|
|
* 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 ·
|
|
* 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림.
|
|
* 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선).
|
|
* [저장](sub5)이 서버 정본 — 노선을 먼저 보내고 `pendingAlignment(DRAFT_ROUTE_ID)` 를 새 노선에 보냄.
|
|
* ========================================================================== */
|
|
|
|
import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline";
|
|
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
|
|
import { createStationsStage } from "./logic/design_pipeline_stations";
|
|
import type {
|
|
PipelineInputs,
|
|
PipelineStation,
|
|
RouteDraft,
|
|
StageFn,
|
|
Vertex,
|
|
} from "./logic/design_pipeline_types";
|
|
import { useDesignStore } from "./design_store";
|
|
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,
|
|
type AlignmentBase,
|
|
type AlignmentEdits,
|
|
type ProfileAlignment,
|
|
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
|
import {
|
|
markAlignmentSaved,
|
|
readAlignmentDraft,
|
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
|
import {
|
|
analysisTerrain,
|
|
fetchRoutePlan,
|
|
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 {
|
|
groundStageDeps,
|
|
loadDesignTerrainGrid,
|
|
} from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
|
|
import {
|
|
createGroundStage,
|
|
createRouteStage,
|
|
draftLine,
|
|
} from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
|
import {
|
|
createFollowStage,
|
|
createPlanlineStage,
|
|
gradeRange,
|
|
profileStage,
|
|
routeRange,
|
|
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
|
import {
|
|
crossWithUphill,
|
|
draftSectionDetail,
|
|
type CrossInput,
|
|
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
|
import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
|
import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage";
|
|
import {
|
|
createMassHaulStage,
|
|
type MassHaulStageContext,
|
|
} from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage";
|
|
import type {
|
|
PlanPipe,
|
|
PlanStructure,
|
|
PlanType,
|
|
} from "../../common_util/common_util_structure_station";
|
|
import type {
|
|
RouteDraftBridge,
|
|
RouteDraftEdit,
|
|
} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Draft";
|
|
|
|
/** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */
|
|
export const DRAFT_ROUTE_ID = 0;
|
|
|
|
/** 종단도(`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;
|
|
}
|
|
|
|
interface PagePipeline {
|
|
pipe: DesignPipeline;
|
|
bridge: RouteDraftBridge;
|
|
grade(edits: AlignmentEdits): void;
|
|
restart(): void;
|
|
earthwork(context: MassHaulStageContext | null): void;
|
|
detail(): SectionDetailResponse | null;
|
|
dispose(): void;
|
|
}
|
|
|
|
const pages = new Map<string, PagePipeline>();
|
|
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
|
|
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
|
|
export function routeDraftBridge(
|
|
projectId: string,
|
|
link: ProfileLink,
|
|
options: PagePipelineOptions = {},
|
|
): RouteDraftBridge {
|
|
let page = pages.get(projectId);
|
|
if (!page) pages.set(projectId, (page = createPagePipeline(projectId, link, options)));
|
|
return page.bridge;
|
|
}
|
|
|
|
/** 종단도 계획고 편집(▲▼ · 끌기 · 되돌리기) — 묶음이 없으면(편집기 안 뜸) 아무 일 없음. */
|
|
export const pipelineGrade = (projectId: string, edits: AlignmentEdits): void =>
|
|
pages.get(projectId)?.grade(edits);
|
|
|
|
/** 저장 종단 기준이 바뀜(새 상세 · 구조물 측점) — 처음부터 다시. */
|
|
export const restartPagePipeline = (projectId: string): void => pages.get(projectId)?.restart();
|
|
|
|
/** 유토곡선 설정(토량환산계수 · 자연방토 경사) — masshaul 단계 재료 · 표준단면이 막 왔으면 횡단 입력도. */
|
|
export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void =>
|
|
pages.get(projectId)?.earthwork(context);
|
|
|
|
/** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */
|
|
export const pipelineDetail = (projectId: string): SectionDetailResponse | null =>
|
|
pages.get(projectId)?.detail() ?? null;
|
|
|
|
/** 지금 묶음(개발 · 시험) — 없으면 null. */
|
|
export const pagePipeline = (projectId: string): DesignPipeline | null =>
|
|
pages.get(projectId)?.pipe ?? null;
|
|
|
|
export function disposePagePipeline(projectId: string): void {
|
|
pages.get(projectId)?.dispose();
|
|
pages.delete(projectId);
|
|
}
|
|
|
|
function createPagePipeline(
|
|
projectId: string,
|
|
link: ProfileLink,
|
|
options: PagePipelineOptions,
|
|
): PagePipeline {
|
|
const store = useDesignStore(projectId, null);
|
|
/** 노선 0 칸 — 종단도 편집 가게(`createProfileEditStore`)와 같은 자리(지금 프로젝트). */
|
|
const draftAlignment = useDesignStore(null, DRAFT_ROUTE_ID);
|
|
let plan: RoutePlanResponse | null = null;
|
|
let types: PlanType[] = [];
|
|
let savedStructures: PlanStructure[] = [];
|
|
let savedPipes: PlanPipe[] = [];
|
|
let earthwork: MassHaulStageContext | null = null;
|
|
/** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */
|
|
let savedStations: PipelineStation[] = [];
|
|
let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null =
|
|
null;
|
|
let shown: SectionDetailResponse | null = null;
|
|
let ready = false;
|
|
let started = false;
|
|
/** 앞 종단이 노선 초안으로 셈한 것인가 — 초안을 버리면 저장 기준으로 다시 그림. */
|
|
let drawnDraft = false;
|
|
let disposed = false;
|
|
|
|
const savedLine = (): Vertex[] | null =>
|
|
plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null;
|
|
const savedCurves = () =>
|
|
(plan?.curves ?? []).map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m }));
|
|
const crossNow = (): PipelineCrossSource | null => {
|
|
const detail = link.pipelineSource().detail ?? null;
|
|
if (!detail) return null;
|
|
if (crossSource?.detail !== detail || !crossSource.source)
|
|
crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) };
|
|
return crossSource.source;
|
|
};
|
|
const crossStage = createCrossStage();
|
|
const haulStage = createHaulStage(projectId, () => link.pipelineSource().routeId ?? undefined);
|
|
const pipe = createDesignPipeline({
|
|
stages: {
|
|
route: createRouteStage({
|
|
minRadiusM: () => plan?.min_radius_m ?? 0,
|
|
savedLine: () => plan && { line: savedLine()!, curves: savedCurves() },
|
|
}),
|
|
follow: createFollowStage({ savedLine }),
|
|
stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }),
|
|
ground: createGroundStage(groundStageDeps(projectId)),
|
|
planline: createPlanlineStage({ savedBase: () => link.pipelineSource().base, savedLine }),
|
|
profile: profileStage,
|
|
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름
|
|
cross: (ctx) =>
|
|
crossStage.stage({
|
|
...ctx,
|
|
inputs: {
|
|
...ctx.inputs,
|
|
cross: crossWithUphill(
|
|
ctx.inputs.cross as CrossInput | null,
|
|
ctx.out.ground?.stations ?? [],
|
|
),
|
|
},
|
|
}),
|
|
footprint: footprintStage,
|
|
masshaul: createMassHaulStage(() => earthwork),
|
|
haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)),
|
|
drainage: link.side?.pipelineStage,
|
|
corridor: (ctx) => {
|
|
if (ctx.inputs.route) options.onCorridorStale?.();
|
|
return null;
|
|
},
|
|
},
|
|
});
|
|
pipe.subscribe(publishToStore(store));
|
|
pipe.subscribe((event) => {
|
|
if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) return;
|
|
const draft = pipe.input("route") !== null;
|
|
const saved = link.pipelineSource().detail ?? null;
|
|
const ground = pipe.output("ground");
|
|
const profile = pipe.output("profile") as ProfileAlignment | null;
|
|
shown =
|
|
draft && saved && ground && profile
|
|
? draftSectionDetail(
|
|
saved,
|
|
ground,
|
|
profile,
|
|
pipe.output("cross"),
|
|
crossSource?.source?.specs ?? new Map(),
|
|
options.stationIntervalM ?? profile.policy.station_interval_m,
|
|
)
|
|
: null;
|
|
if (event.stage === "cross") return void (draft && link.redraw?.());
|
|
const base = pipe.output("planline");
|
|
const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits();
|
|
if (draft) draftAlignment.set("alignmentDraft", edits);
|
|
else if (drawnDraft) markAlignmentSaved(DRAFT_ROUTE_ID); // 초안을 버림 — 노선 0 칸도 비움
|
|
if ((draft || drawnDraft) && base) link.adoptPipeline(base, edits, draft);
|
|
drawnDraft = draft;
|
|
});
|
|
|
|
const inputsNow = (): PipelineInputs => {
|
|
const route = store.get("routeDraft");
|
|
return {
|
|
route,
|
|
alignment: (route && readAlignmentDraft(DRAFT_ROUTE_ID)) || link.pipelineSource().edits,
|
|
cross: crossNow()?.input ?? null,
|
|
pipes: store.get("pipes") ?? savedPipes,
|
|
structures: store.get("structures") ?? savedStructures,
|
|
};
|
|
};
|
|
const start = (): void => {
|
|
if (disposed || !ready || !link.pipelineSource().base) return;
|
|
started = true;
|
|
void pipe.start(inputsNow());
|
|
};
|
|
|
|
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */
|
|
const loadPlan = (): Promise<void> =>
|
|
fetchRoutePlan(projectId)
|
|
.then((routePlan) => {
|
|
plan = routePlan;
|
|
savedStations = buildStations(savedLine()!, savedCurves(), savedPipes, savedStructures, {
|
|
intervalM: options.stationIntervalM,
|
|
types,
|
|
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();
|
|
})
|
|
.then(() => {
|
|
ready = plan !== null;
|
|
start(); // 열 때 한 번 — 발자국 · 저장 안 한 노선 초안이 있으면 그 노선으로
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
const unsubscribe = [
|
|
store.subscribe("pipes", (pipes) => started && void pipe.update("pipe", pipes ?? savedPipes)),
|
|
store.subscribe(
|
|
"structures",
|
|
(list) => started && void pipe.update("structure", list ?? savedStructures),
|
|
),
|
|
];
|
|
|
|
const bridge: RouteDraftBridge = {
|
|
saved: () => store.get("routeDraft"),
|
|
edited(edit: RouteDraftEdit | null) {
|
|
const stored = store.get("routeDraft");
|
|
let draft: RouteDraft | null = null;
|
|
if (edit) {
|
|
draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: analysisTerrain(projectId) };
|
|
if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored;
|
|
}
|
|
store.set("routeDraft", draft);
|
|
if (!started) return start();
|
|
const before = pipe.output("route")?.line ?? null;
|
|
const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine();
|
|
void pipe.update("route", draft, routeRange(before, after));
|
|
},
|
|
};
|
|
|
|
return {
|
|
pipe,
|
|
bridge,
|
|
grade(edits) {
|
|
if (!started) return start();
|
|
if (same(edits, pipe.input("alignment"))) return;
|
|
const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? [];
|
|
const before = pipe.input("alignment") as AlignmentEdits | null;
|
|
void pipe.update("grade", edits, gradeRange(before, edits, nodes));
|
|
},
|
|
restart() {
|
|
if (!started) return start();
|
|
void loadPlan().then(start);
|
|
},
|
|
earthwork(context) {
|
|
earthwork = context;
|
|
const cross = crossNow()?.input ?? null;
|
|
if (started && cross) void pipe.update("cross", cross);
|
|
},
|
|
detail: () => shown,
|
|
dispose() {
|
|
disposed = true;
|
|
unsubscribe.forEach((stop) => stop());
|
|
pipe.dispose();
|
|
},
|
|
};
|
|
}
|