Files
Aislo/A00_Common/design/design_pipeline_page.ts
T
eomsangdonandClaude Opus 5.5 7efee22ab4 feat(365-2): 횡단 고침 → 묶음 cross 입력 · 입력 지문 고리(최대 3바퀴)
- 횡단 입력 = 부를 때마다 저장 상세 + 세션 고침(지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 측점 열쇠) + 표준 — 상세 객체마다 기억 걷음
- B06 고침(DesignSync 다시 셈 끝 · 암 경계) 뒤 pipelineCross — 노선 초안 동안 B06 고침이 카드(새 노선 상세)에 보임
- 고리 — 열 때 · 페이지 들어올 때 입력(세션 · 가게)을 묶음 입력(넘긴 값 · follow 뒤 값)과 견줌 · 다르면 한 바퀴 · 끝난 뒤 다시 · 3바퀴 넘으면 「계산 불안정」 한 줄
- 시험 test_365_page_pipeline(초안 동안 고침 카드 · 같은 입력 0바퀴 · 세션만 바뀜 1바퀴 · 불안정 3바퀴 알림 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-17 12:08:22 +09:00

682 lines
32 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` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) ·
* 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
* 고리(365-2) — 열 때 · 페이지 들어올 때(`reconcilePagePipeline`) 입력(세션 · 가게)을 묶음이 쥔 입력과 견줌 →
* 다르면 한 바퀴 → 끝난 뒤 다시 견줌 · 최대 `RECONCILE_ROUNDS` · 넘으면 `onUnstable`(「계산 불안정」 한 줄).
* 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 ·
* 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림.
* 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선).
* 노선 초안을 버리면(되돌리기 · [저장 전으로]) 열 때처럼 — 측점 · 종단을 안 내고 초안 전 상세 · 횡단 초안 칸
* 그대로(349-0k · 349-0o) · 그 뒤 같은 측점 목록은 가게에 다시 안 냄(상세 측점이 묶음과 달라도 · 349-0q).
* [저장](sub5)이 서버 정본 — 노선을 먼저 보내고 `pendingAlignment(DRAFT_ROUTE_ID)` 를 새 노선에 보냄.
* ========================================================================== */
import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline";
import { SOURCE_INPUT } from "./logic/design_pipeline_types";
export { pipelineWork } from "./logic/design_pipeline";
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
import { createStationsStage } from "./logic/design_pipeline_stations";
import type {
ChangeSource,
FollowPatch,
PipelineInputs,
PipelineOutputs,
PipelineRunRecord,
PipelineStation,
RangePart,
RouteDraft,
StageFn,
Vertex,
} from "./logic/design_pipeline_types";
import { useDesignStore, type DesignStoreValues } from "./design_store";
import {
followStructures,
placeOnLine,
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,
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 { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
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;
/** 입력 ↔ 묶음 입력 맞춤 상한(바퀴) — 넘으면 「계산 불안정」(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;
}
interface PagePipeline {
pipe: DesignPipeline;
bridge: RouteDraftBridge;
grade(edits: AlignmentEdits): void;
restart(): void;
earthwork(context: MassHaulStageContext | null): void;
cross(): void;
reconcile(): Promise<number>;
detail(): SectionDetailResponse | null;
dispose(): void;
}
/** 가게 관 · 구조물 초안(null = 저장분). */
type Facilities = Pick<DesignStoreValues, "pipes" | "structures">;
const pages = new Map<string, PagePipeline>();
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
function stationDiff(
detail: SectionDetailResponse,
stations: ReadonlyArray<PipelineStation>,
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
const saved = detail.cross_sections.map((section) => section.chainage_m);
const n = Math.max(saved.length, stations.length);
for (let i = 0; i < n; i += 1) {
const pipelineM = stations[i]?.chainageM ?? null;
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
continue;
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
}
return null;
}
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
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);
/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */
export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross();
/** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(365-2) — 돈 바퀴 수(0 = 같음 · -1 = 넘음 · 묶음 없음 = 0). */
export const reconcilePagePipeline = (projectId: string): Promise<number> =>
pages.get(projectId)?.reconcile() ?? Promise.resolve(0);
/** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 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: PipelineCrossSource | null = null;
/** 저장 정본(서버가 준 상세 · 노선 번호) — 측점 갈아끼움(swap)이 만든 새 상세는 지반 표본 배열을
* 그대로 둠 → 정본 아님(묶음 출력 → 상세 → 다시 묶음 고리를 끊음). */
let canon: { routeId: unknown; samples: unknown; detail: SectionDetailResponse | null } | null =
null;
let shown: SectionDetailResponse | null = null;
/** 이 묶음이 지은 새 노선 상세들 — 횡단 보기 카드가 그것을 종단도에 그려도 저장 정본이 아님. */
const drafted = new WeakSet<SectionDetailResponse>();
let lastSaved: ReturnType<ProfileLink["pipelineSource"]> | null = null;
/** 종단도가 내준 저장 기준 — 종단도가 묶음 새 노선 상세를 그리는 동안은 그 앞 저장 기준(고리 끊음 ·
* 새 노선 상세 → 정본으로 오인 → 처음부터 다시 → 새 상세 … 349-0g). */
const savedSource = (): ReturnType<ProfileLink["pipelineSource"]> => {
const now = link.pipelineSource();
if (now.detail && drafted.has(now.detail)) return lastSaved ?? { ...now, detail: null };
return (lastSaved = now);
};
let ready = false;
let started = false;
/** 앞 종단이 노선 초안으로 셈한 것인가 — 초안을 버리면 저장 기준으로 다시 그림. */
let drawnDraft = false;
let disposed = false;
/** 열 때 · 정본이 바뀐 차례(start) 번호 — 측점 · 종단을 가게에 안 냄(상세 = 저장 정본 그대로). */
let openGeneration = -1;
/** 측점 · 종단을 가게에 안 내는 차례 — 열 때 · 노선 초안을 버린 차례(상세 = 초안 전 상세로 되돌림). */
let quietGeneration = -1;
/** 노선 초안 전 상세(종단도가 그리던 객체 그대로 — 횡단 초안 측점 장부가 이 객체에 묶임 · 갈아끼움은 설계를
* 사본으로 써 안 고침) · 되돌리는 함수 — 초안을 버리면 이것으로(다시 셈 없음 · 349-0k · 349-0o · 349-0q). */
let held: {
detail: SectionDetailResponse | null;
restore: ((stations: PipelineStation[]) => Promise<string>) | null;
} | null = null;
/** 초안을 버린 차례가 끝나 되돌림까지 마치는 약속(종단도는 그 뒤 저장 기준으로 — 349-0l). */
let dropping: Promise<string> | null = null;
/** 노선 초안 전 가게 관 · 구조물 — 초안 동안 follow 가 새 측점을 가게에 씀 · 초안을 버리면 이것으로. */
let beforeDraft: Facilities | null = null;
/** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */
let written: Facilities | null = null;
let echo = false;
/** 되돌리기 · 다시하기로 바뀐 노선(356-4) — follow 가 계획고 편집을 옮기지 않고 그 걸음 사진(세션 노선 0 칸)
* 그대로. 바뀐 구간 첫 계획선 맞춤 · 계획고 옮김은 새 조작 때만. */
let replay = false;
const savedLine = (): Vertex[] | null =>
plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null;
const savedCurves = () =>
(plan?.curves ?? []).map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m }));
/** 저장 정본이 바뀌었나(새로 받은 상세 · 새 노선) — 바뀌었으면 잡아 둠. */
const canonChanged = (): boolean => {
const { detail = null, routeId } = savedSource();
const samples = detail?.longitudinal.samples;
if (canon && canon.routeId === routeId && canon.samples === samples) return false;
canon = { routeId, samples, detail };
return true;
};
/** 저장 노선 계획고 편집(세션 초안 → 저장분) — 노선 초안을 버리면 이것으로 돌아옴. */
const savedEdits = (): AlignmentEdits => {
const routeId = canon?.routeId;
const longitudinal = canon?.detail?.longitudinal;
return (
(typeof routeId === "number" ? readAlignmentDraft(routeId) : null) ??
(longitudinal && readAlignment(longitudinal)?.edits) ??
emptyEdits()
);
};
/** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */
const crossNow = (): PipelineCrossSource | null => {
const routeId = canon?.routeId;
const source = pipelineCrossSource(
projectId,
canon?.detail ?? null,
savedStations,
typeof routeId === "number" ? routeId : null,
);
if (source) crossSource = source;
return source;
};
const groundDeps = groundStageDeps(projectId);
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
const haulStage = createHaulStage(projectId, () => savedSource().routeId ?? undefined);
const followStage = createFollowStage({
savedLine,
saved: () => {
const before = beforeDraft ?? facilitiesNow();
return {
pipes: before.pipes ?? savedPipes,
structures: before.structures ?? savedStructures,
alignment: savedEdits(),
};
},
});
/** 좌표 없는 관 · 구조물(세션 초안 — 배수유역도 · 구조물 목록이 정본 PUT 꼴로 씀)은 안 옮긴 것 = 정본 좌표 ·
* 그 밖 = 앞 노선 위 그 누가거리 자리에서 따라감 — 안 붙이면 측점이 그대로 남음(349-0u). */
const placed = (list: unknown, line: Vertex[], saved: FollowingStructure[]): unknown =>
Array.isArray(list)
? placeOnLine(
list as FollowingStructure[],
line.map(([x, y]) => ({ x, y })),
saved,
)
: list;
const pipe = createDesignPipeline({
stages: {
route: createRouteStage({
minRadiusM: () => plan?.min_radius_m ?? 0,
savedLine: () => plan && { line: savedLine()!, curves: savedCurves() },
}),
follow: (ctx) => {
const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null;
replay = false;
if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로)
const line = ctx.before.route?.line ?? savedLine() ?? [];
const { pipes, structures } = ctx.inputs;
const inputs = {
...ctx.inputs,
pipes: placed(pipes, line, savedPipes as FollowingStructure[]),
structures: placed(structures, line, []),
};
return followStage({ ...ctx, inputs });
},
stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }),
ground: createGroundStage(groundDeps),
planline: createPlanlineStage({ savedBase: () => savedSource().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;
},
},
});
const publish = publishToStore(store);
/** 상세가 맞춘 측점 목록(열쇠 · 자리) — 열 때 · 되돌린 차례 측점 · 마지막으로 낸 측점. 같은 목록이면 가게에
* 다시 안 냄(되돌린 상세 측점이 묶음 측점과 달라도 · 관 · 구조물 메아리가 상세를 갈아끼우지 않게 · 349-0q). */
let stationsShown: string | null = null;
pipe.subscribe((event) => {
const saved = event.kind === "stage" && (event.stage === "ground" || event.stage === "profile");
const quiet = saved && event.generation === quietGeneration;
if (event.kind === "stage" && event.stage === "ground") {
const list = JSON.stringify(
(event.value as PipelineOutputs["ground"] | null)?.stations.map((station) => [
station.key,
station.chainageM,
station.x,
station.y,
]) ?? null,
);
if (!quiet && list === stationsShown) return;
stationsShown = list;
}
if (!quiet) publish(event);
}, "가게");
/** 노선 초안을 버린 차례 끝 — 초안 전 상세로 되돌림(열 때와 같은 상태 · 측점이 달라도 · 다른 자리는
* 기록에) · 그 상세가 저장 정본(다시 하기도 같은 기준). 차례 끝에서 판정 — ground 를 건너뛰어도 · 사진첩
* 되돌리기가 가게 초안을 먼저 비워도(349-0m). */
const finishDrop = async (record: PipelineRunRecord): Promise<string> => {
const routeId = canon?.routeId;
const stations = pipe.output("ground")?.stations ?? [];
let why: string = record.outcome; // 새 조작에 밀림 · 단계 실패 — 초안 전 상세는 그대로 둠
if (record.outcome === "done") {
why = "none";
const hold = held;
const restore =
typeof routeId === "number" && stations.length
? (hold?.restore ?? options.holdDraft?.(routeId, null))
: null;
if (restore) {
held = null;
const detail = hold?.detail;
if (detail) {
canon = { routeId, samples: detail.longitudinal.samples, detail };
const diff = stationDiff(detail, stations);
if (diff) record.restoreDiff = diff;
}
why = await restore(stations).catch(() => "error");
}
}
Object.assign(record, { restored: why.startsWith("saved"), restoreWhy: why });
return why;
};
/** 노선 초안 시작 — 지금 저장 상세 · 되돌리는 함수를 맡김(횡단 초안 칸 · 측점 열쇠는 맡은 쪽이 떠 둠). */
const holdNow = (): typeof held => {
const { detail = null, routeId } = savedSource();
if (typeof routeId !== "number") return null;
return { detail, restore: options.holdDraft?.(routeId, detail) ?? null };
};
// 사용자 노선 조작 뒤 follow 가 다시 매긴 관 · 구조물 = 가게(목록 · 알약 · 횡단 보기가 바로 새 측점) ·
// 초안을 버리면 초안 전 값 · 열 때(start 차례)는 안 씀 — 알림 밖(틱 뒤)에서 씀(쓰기 고리 막음과 안 부딪힘)
pipe.subscribe((event) => {
if (event.kind !== "stage" || event.stage !== "follow") return;
if (event.generation === openGeneration) return;
const patch = event.value as FollowPatch;
queueMicrotask(() => {
if (event.generation !== pipe.generation) return;
if (!pipe.input("route")) {
if (!beforeDraft) return;
writeFacilities(beforeDraft);
beforeDraft = null;
return options.onFacilitiesFollowed?.(0);
}
const now = facilitiesNow();
beforeDraft ??= now;
writeFacilities({
pipes: (patch.pipes as Facilities["pipes"]) ?? now.pipes,
structures: (patch.structures as Facilities["structures"]) ?? now.structures,
});
const line = pipe.output("route")?.line ?? [];
const far = Array.isArray(patch.structures)
? followStructures(
patch.structures as FollowingStructure[],
line.map(([x, y]) => ({ x, y })),
).filter((moved) => moved.far).length
: 0;
options.onFacilitiesFollowed?.(far);
});
}, "관 · 구조물");
pipe.subscribe((event) => {
if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) return;
const draft = pipe.input("route") !== null;
const saved = savedSource().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?.specs ?? new Map(),
options.stationIntervalM ?? profile.policy.station_interval_m,
)
: null;
if (shown) drafted.add(shown);
if (shown) options.structureAreas?.(shown); // 관 · 구조물이 선 측점 = 폐회로 면적(349-0j)
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 칸도 비움
// 초안을 버린 차례 — 상세를 되돌린 뒤 저장 기준으로(그 전에 그리면 초안 상세를 낡음으로 봄 · 349-0l)
const after = !draft && event.generation === quietGeneration ? dropping : null;
if ((draft || drawnDraft) && base) {
if (after)
void after.then(
() => void (!pipe.input("route") && link.adoptPipeline(base, edits, false)),
);
else link.adoptPipeline(base, edits, draft);
}
drawnDraft = draft;
}, "새 상세 · 종단도");
function facilitiesNow(): Facilities {
return { pipes: store.get("pipes"), structures: store.get("structures") };
}
/** 가게에 씀 — 앞에 쓴 것과 같으면 안 씀(되돌리기가 세션을 되쓴 뒤에도 듣는 쪽은 다시 부름). */
function writeFacilities(next: Facilities): void {
echo = true;
try {
if (!written || !same(written.pipes, next.pipes)) store.set("pipes", next.pipes);
if (!written || !same(written.structures, next.structures))
store.set("structures", next.structures);
} finally {
echo = false;
}
written = next;
}
const inputsNow = (): PipelineInputs => {
const route = store.get("routeDraft");
return {
route,
alignment: (route && readAlignmentDraft(DRAFT_ROUTE_ID)) || savedSource().edits,
cross: crossNow()?.input ?? null,
pipes: store.get("pipes") ?? savedPipes,
structures: store.get("structures") ?? savedStructures,
};
};
const start = (): void => {
if (disposed || !ready || !savedSource().base) return;
canonChanged();
started = true;
const inputs = inputsNow();
// 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄
held = inputs.route ? (held ?? holdNow()) : null;
dropping = null;
Object.assign(given, inputs);
const run = (lastRun = pipe.start(inputs));
openGeneration = pipe.generation;
quietGeneration = inputs.route ? -1 : pipe.generation;
void run.then(() => reconcile()); // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤
};
/** 묶음에 넘긴 입력(follow 가 고치기 전) · 마지막으로 띄운 차례 — 고리가 견줌 · 기다림. */
const given: Partial<PipelineInputs> = {};
let lastRun: Promise<unknown> = Promise.resolve();
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;
return run;
};
let reconciling: Promise<number> | null = null;
/** 입력 지문 대 묶음 입력 — 넘긴 값 · follow 뒤 값 둘 다와 다르면 한 바퀴(노선 = 다리 길) · 끝난 뒤 다시 견줌. */
function reconcile(): Promise<number> {
if (!started || disposed) return Promise.resolve(0);
return (reconciling ??= (async () => {
for (let round = 0; ; round += 1) {
let seen: Promise<unknown> | null = null;
while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지
await pipe.settled();
if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
const now = inputsNow();
const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
.filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key]))
.map(([source, key]): [ChangeSource, unknown] => [source, now[key]]);
if (!stale.length) return round;
if (round >= RECONCILE_ROUNDS) {
options.onUnstable?.();
return -1;
}
for (const [source, value] of stale)
if (source === "route") bridge.edited(value as RouteDraft | null);
else feed(source, value);
}
})().finally(() => (reconciling = null)));
}
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`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 && !echo && void feed("pipe", pipes ?? savedPipes)),
store.subscribe(
"structures",
(list) => started && !echo && void feed("structure", list ?? savedStructures),
),
];
const bridge: RouteDraftBridge = {
saved: () => store.get("routeDraft"),
edited(edit: RouteDraftEdit | null) {
const stored = store.get("routeDraft");
// 묶음이 쥔 노선 초안 — 가게 칸은 사진첩 되돌리기가 먼저 비울 수 있음(349-0m)
const wasDraft = pipe.input("route") !== null;
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;
}
if (draft && !wasDraft && !held) held = holdNow();
replay = pageReplaying();
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();
const run = feed("route", draft, routeRange(before, after));
if (run && !draft && wasDraft) {
quietGeneration = pipe.generation;
dropping = run.then(finishDrop);
}
},
};
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 feed("grade", edits, gradeRange(before, edits, nodes));
},
restart() {
if (!started) return start();
if (canonChanged()) void loadPlan().then(start); // 갈아끼움 · 같은 상세 다시 그림은 안 셈
},
earthwork(context) {
earthwork = context;
const cross = crossNow()?.input ?? null;
if (started && cross) void feed("cross", cross);
},
cross() {
const cross = started ? (crossNow()?.input ?? null) : null;
if (cross) void feed("cross", cross);
},
reconcile,
detail: () => shown,
dispose() {
disposed = true;
unsubscribe.forEach((stop) => stop());
pipe.dispose();
},
};
}