- /calc 창구 차례 = 격자 경로 막기 → 단계 · 화면 길 서버 맥락(B-4) → follow 저장 정본 맥락(B-5 · stages 다시 짬) - 까닭 글 = cross 그대로 · pair_B5 걷음(follow 엶) - 시험 511_b1 · 309_2 · 309_2_browser = 잠긴 단계 줄 없음 · 잠금 예시 = form_default · 화면 열린 줄에 P5 P6 F1 F2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
690 lines
34 KiB
TypeScript
690 lines
34 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 `B06_Section_Cross_Stage` · drainage 배수유역 판(sub3) · corridor 3D 「낡음」(초안 동안만) · 유토(454).
|
|
*
|
|
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
|
|
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
|
|
* 입력 + 세션 고침(`design_pipeline_cross.ts` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) ·
|
|
* 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
|
|
* 고리(365-2) — 열 때 · 들어올 때 입력 대 묶음 입력 · 다르면 한 바퀴 · 최대 3 · 넘으면 `onUnstable`.
|
|
* 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 ·
|
|
* 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림.
|
|
* 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선) · 관 · 구조물 · 추가 측점
|
|
* 초안이 있으면 측점을 냄(새로고침 · 다시 열기 = 초안 측점 · 466).
|
|
* 노선 초안을 버리면(되돌리기 · [저장 전으로]) 초안 전 상세 · 횡단 초안 칸 그대로(349-0k · 349-0o) · 가게 측점 ·
|
|
* 종단 = 그 상세 측점 · 저장 기준(466) · 그 뒤 같은 측점 목록은 가게에 다시 안 냄(상세 측점이 묶음과 달라도 · 349-0q).
|
|
* [저장](sub5)이 서버 정본 — 노선을 먼저 보내고 `pendingAlignment(DRAFT_ROUTE_ID)` 를 새 노선에 보냄.
|
|
* ========================================================================== */
|
|
|
|
import { createDesignPipeline, type DesignPipeline } from "./logic/design_pipeline";
|
|
import { publishPagePipeline } from "./design_pipeline_page_publish";
|
|
import { SOURCE_INPUT } from "./logic/design_pipeline_types";
|
|
export { pipelineWork } from "./logic/design_pipeline";
|
|
import { createStationsStage, snapOptions, stationDiff } from "./logic/design_pipeline_stations";
|
|
import { stationsOnServer } from "./design_stations_server";
|
|
import { routeOnServer } from "./design_route_server";
|
|
import { followFromServer } from "./design_follow_server";
|
|
import type {
|
|
ChangeSource,
|
|
FollowPatch,
|
|
PipelineInputs,
|
|
PipelineRunRecord,
|
|
PipelineStation,
|
|
RangePart,
|
|
RouteDraft,
|
|
Vertex,
|
|
} from "./logic/design_pipeline_types";
|
|
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
|
import {
|
|
followStructures,
|
|
type FollowingStructure,
|
|
} from "../../common_util/common_util_route_follow";
|
|
import { contextCross, placedFollowInputs } from "./logic/design_pipeline_stage_inputs";
|
|
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
|
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
|
|
import {
|
|
emptyEdits,
|
|
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 {
|
|
followPlusDraft,
|
|
plusStationsOf,
|
|
readPlusDraft,
|
|
savedPlusRows,
|
|
} from "../../B05_Profile/B05_Profile_Api_PlusStations";
|
|
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
|
|
import { fetchRoutePlan, type RoutePlanResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
|
|
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
|
|
import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data";
|
|
import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
|
|
import { createPageSaved } from "./design_pipeline_page_saved";
|
|
import { staleInputs } from "./design_pipeline_cross";
|
|
import {
|
|
draftDetailOf,
|
|
heldSectionDetail,
|
|
setDraftSectionDetail,
|
|
} from "../../B06_Section/B06_Section_Section_Canon";
|
|
import { feedFacilities } from "./design_pipeline_facilities";
|
|
export type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
|
|
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,
|
|
routeRange,
|
|
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
|
import { groundPviAt, profileAt } from "./design_calc_profile";
|
|
import {
|
|
draftSectionDetail,
|
|
shapeStatus,
|
|
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
|
import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
|
import { footprintStageSited } from "../../B05_Profile/B05_Profile_Footprint_Server";
|
|
import { crossOnServer, type CrossStageContext } from "./design_pipeline_cross_server";
|
|
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;
|
|
|
|
interface PagePipeline {
|
|
pipe: DesignPipeline;
|
|
bridge: RouteDraftBridge;
|
|
grade(edits: AlignmentEdits): void;
|
|
restart(): void;
|
|
earthwork(): void;
|
|
cross(): void;
|
|
reconcile(): Promise<number>;
|
|
ownsCross: boolean;
|
|
detail(): SectionDetailResponse | null;
|
|
/** 노선 초안 버림 동안(그리기 멈춤 ~ 풂) — 저장 노선 낡음 다시 셈을 안 띄움(472-5 덧). */
|
|
holding(): boolean;
|
|
commit(): void;
|
|
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);
|
|
|
|
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
|
|
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();
|
|
|
|
/** 유토곡선 설정(표준단면 포함)이 막 옴 — 횡단 입력을 다시 먹임. */
|
|
export const pipelineEarthwork = (projectId: string): void => pages.get(projectId)?.earthwork();
|
|
|
|
/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */
|
|
export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross();
|
|
|
|
/** 저장 노선 횡단 다시 셈을 묶음 cross 가 맡는가(365-4) — 종단도는 계획고 편집 뒤 따로 안 셈. */
|
|
export const pipelineOwnsCross = (projectId: string): boolean =>
|
|
pages.get(projectId)?.ownsCross ?? false;
|
|
|
|
/** 노선 초안 버림 동안인가 — 되돌린 상세는 초안 전 그대로라 낡음 다시 셈이 필요 없음(시설 줄이 한 박자 늦어
|
|
* 낡아 보임 · 그 사이 셈하면 옛 관 자리 계획선으로 셈한 면적이 보였음 · 472-5 덧). */
|
|
export const pipelineHolding = (projectId: string): boolean =>
|
|
pages.get(projectId)?.holding() ?? false;
|
|
|
|
/** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(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;
|
|
|
|
/** 노선 초안을 저장함(472-6 6-B) — 초안 전 관 · 구조물을 버림(노선 초안이 비어도 「버림」 되돌림 안 함). */
|
|
export const commitRouteFacilities = (projectId: string): void => pages.get(projectId)?.commit();
|
|
|
|
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 enabled: PlanType[] = [];
|
|
let savedStructures: PlanStructure[] = [];
|
|
let savedPipes: PlanPipe[] = [];
|
|
/** 저장 노선 측점(열쇠) — 저장분을 받을 때 셈. */
|
|
let savedStations: PipelineStation[] = [];
|
|
/** 저장 기준(정본 지문 · 종단도 저장 기준 · 계획고 · PVI · 횡단 입력) — `design_pipeline_page_saved`. */
|
|
const pageSaved = createPageSaved(projectId, link, options, () => savedStations);
|
|
const { savedSource, canonChanged, savedEdits, storedPvi, crossNow, drafted } = pageSaved;
|
|
let ready = false;
|
|
let started = false;
|
|
/** 앞 종단이 노선 초안으로 셈한 것인가 — 초안을 버리면 저장 기준으로 다시 그림. */
|
|
let drawnDraft = false;
|
|
let disposed = false;
|
|
/** 열 때 · 정본이 바뀐 차례(start) 번호 — 측점 · 종단을 가게에 안 냄(상세 = 저장 정본 그대로). */
|
|
let openGeneration = -1;
|
|
/** 측점 · 종단을 가게에 안 내는 차례 — 열 때 · 노선 초안을 버린 차례(상세 = 초안 전 상세로 되돌림). */
|
|
let quietGeneration = -1;
|
|
/** 노선 초안을 버릴 때 되돌리는 함수 — 초안 전 상세는 상세 가게 칸(`heldSectionDetail` · 종단도가 그리던 객체
|
|
* 그대로 · 판이 바뀌면 새 판 · 472-5)(다시 셈 없음 · 349-0k · 349-0o · 349-0q). */
|
|
let held: { restore: ((stations: PipelineStation[]) => Promise<string>) | null } | null = null;
|
|
/** 초안을 버린 차례가 끝나 되돌림까지 마치는 약속(종단도는 그 뒤 저장 기준으로 — 349-0l). */
|
|
let dropping: Promise<string> | null = null;
|
|
/** 초안을 버린 차례가 되돌림까지 못 감(뒤 차례에 밀림) — 뒤 차례도 조용히 · 그 끝에서 되돌림(B06 cross · 451). */
|
|
let dropPending = false;
|
|
/** 버린 차례의 종단도 저장 기준 맞춤 — 되돌림 바로 뒤 한 번(되돌린 상세가 먼저 그려지면 초안 종단과 섞인 유토곡선이
|
|
* 한 틀 보였음 · 472-5 덧). */
|
|
let dropAdopt: (() => void) | null = null;
|
|
/** 버림 동안 종단도 그리기 멈춤 · 낡음 다시 셈 미룸(`pipelineHolding`). */
|
|
let holding = false;
|
|
const holdDraws = (on: boolean): void => {
|
|
holding = on;
|
|
link.holdDraw?.(on);
|
|
};
|
|
const adoptAfterDrop = (): void => {
|
|
const adopt = dropAdopt;
|
|
dropAdopt = null;
|
|
adopt?.();
|
|
};
|
|
/** 노선 초안 전 가게 관 · 구조물 — 초안 동안 follow 가 새 측점을 가게에 씀 · 초안을 버리면 이것으로. */
|
|
let beforeDraft: 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 statusOf = (pipes: unknown, structures: unknown) =>
|
|
shapeStatus(pipes ?? savedPipes, structures ?? savedStructures, types); // 새 측점 그림 · 맥락(457)
|
|
const groundDeps = groundStageDeps(projectId);
|
|
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
|
|
const followStage = createFollowStage({
|
|
savedLine,
|
|
savedPlus: () => savedPlusRows(projectId), // 추가 측점 정본 좌표에서 따라감(493-3)
|
|
saved: () => {
|
|
const before = beforeDraft ?? facilitiesNow();
|
|
return {
|
|
pipes: before.pipes ?? savedPipes,
|
|
structures: before.structures ?? savedStructures,
|
|
alignment: savedEdits(),
|
|
};
|
|
},
|
|
// 옮긴 편집이 놓일 새 측점 — stations 단계와 같은 셈(400)
|
|
stationsAt: (route, patch, inputs) =>
|
|
buildStations(
|
|
route.line,
|
|
route.curves,
|
|
(patch.pipes ?? inputs.pipes ?? []) as PlanPipe[],
|
|
(patch.structures ?? inputs.structures ?? []) as PlanStructure[],
|
|
{ intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) },
|
|
).map((station) => station.chainageM),
|
|
});
|
|
/** cross 서버 단계 맥락(511-B B-4) — 저장 노선 · 상세 · 시설 · 편집(`design_pipeline_cross_server`). */
|
|
const serverContext = (): CrossStageContext | null =>
|
|
plan && {
|
|
plan,
|
|
detail: savedSource().detail ?? null,
|
|
types,
|
|
saved_pipes: savedPipes,
|
|
saved_structures: savedStructures,
|
|
saved_edits: savedEdits(),
|
|
saved_plus: savedPlusRows(projectId),
|
|
before_draft: beforeDraft,
|
|
interval_m: options.stationIntervalM,
|
|
};
|
|
/** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */
|
|
const savedOut = new WeakSet<object>();
|
|
const pipe = createDesignPipeline({
|
|
stages: {
|
|
route: createRouteStage({
|
|
minRadiusM: () => plan?.min_radius_m ?? 0,
|
|
savedLine: () => plan && { line: savedLine()!, curves: savedCurves() },
|
|
remote: routeOnServer, // 계산 자리 route 「서버」(511-B B-5)
|
|
}),
|
|
follow: (ctx) => {
|
|
const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null;
|
|
replay = false;
|
|
// 관 · 구조물 · 추가 측점도 그 걸음 사진(사진첩이 되쓴 가게 값) — 묶음 입력에 넣어야 측점이 따라옴(280-3)
|
|
if (kept) {
|
|
const { pipes, structures, plusStations } = inputsNow();
|
|
return { alignment: kept, pipes, structures, plusStations };
|
|
}
|
|
// 계산 자리 follow 「서버」(511-B B-5) — 끌기 중 화면 셈 · 잠잠 뒤 서버 값으로 바꿈
|
|
const before = beforeDraft ?? facilitiesNow();
|
|
const remote = followFromServer(projectId, ctx.inputs, before, savedEdits(), () =>
|
|
pipe.refresh("route"),
|
|
);
|
|
const saved = { pipes: savedPipes, structures: savedStructures };
|
|
return (
|
|
remote ?? followStage({ ...ctx, inputs: placedFollowInputs(ctx, savedLine(), saved) })
|
|
);
|
|
},
|
|
stations: createStationsStage({
|
|
intervalM: options.stationIntervalM,
|
|
types: () => enabled,
|
|
saved: () => plan,
|
|
remote: stationsOnServer, // 계산 자리 stations 「서버」(511-B B-2)
|
|
}),
|
|
ground: createGroundStage(groundDeps),
|
|
planline: createPlanlineStage({
|
|
savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base,
|
|
savedLine,
|
|
storedPvi,
|
|
groundPvi: groundPviAt, // 계산 자리 「서버」 = 서버 Node(511-B B-3)
|
|
}),
|
|
profile: profileAt,
|
|
// 단면 유형 = 새 지형 상단측 · 소단 = 지금 구조물(365-5) · 저장 노선 = 다시 셈 창구 한 벌(365-4) · 엔진은
|
|
// 창구 출력을 앞 결과로 안 이어 씀(초안 = 새로고침)
|
|
cross: (ctx) => {
|
|
const stations = ctx.out.ground?.stations ?? [];
|
|
const status = statusOf(ctx.inputs.pipes, ctx.inputs.structures);
|
|
const engine = () =>
|
|
crossStage.stage({
|
|
...contextCross(ctx, status, options.bermAt?.(ctx.inputs.structures) ?? null),
|
|
prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev,
|
|
});
|
|
// 계산 자리 cross = server — 엔진 길도 서버 Node 단계(같은 TS · 511-B B-4) · 못 받음 = 브라우저
|
|
const remote = () =>
|
|
crossOnServer(projectId, ctx, serverContext)?.then((o) => o ?? engine());
|
|
const { detail = null, routeId } = savedSource();
|
|
const saved =
|
|
!ctx.inputs.route && detail && typeof routeId === "number"
|
|
? options.savedCross?.(detail, routeId, stations)
|
|
: null;
|
|
if (!saved) return remote() ?? engine();
|
|
return saved.then((out) => {
|
|
if (!out) return remote() ?? engine();
|
|
savedOut.add(out);
|
|
return out;
|
|
});
|
|
},
|
|
footprint: footprintStageSited, // 계산 자리 판(511-A)
|
|
drainage: link.side?.pipelineStage,
|
|
corridor: (ctx) => {
|
|
if (ctx.inputs.route) options.onCorridorStale?.();
|
|
return null;
|
|
},
|
|
},
|
|
});
|
|
const publisher = publishPagePipeline(pipe, store, () => quietGeneration);
|
|
/** 노선 초안을 버린 차례 끝 — 초안 전 상세로 되돌림(열 때와 같은 상태 · 측점이 달라도 · 다른 자리는
|
|
* 기록에) · 그 상세가 저장 정본(다시 하기도 같은 기준). 차례 끝에서 판정 — ground 를 건너뛰어도 · 사진첩
|
|
* 되돌리기가 가게 초안을 먼저 비워도(349-0m). */
|
|
const finishDrop = async (record: PipelineRunRecord): Promise<string> => {
|
|
// 뒤 차례(B06 cross 등)에 밀림 · 초안은 그대로 버려짐 — 마지막 차례 끝에서 되돌림(B05 와 같게)
|
|
const pushed = record.outcome === "dropped" && record.generation !== pipe.generation;
|
|
if (pushed && dropPending && !pipe.input("route")) {
|
|
const next = await (lastRun as Promise<PipelineRunRecord>);
|
|
if (next !== record) return finishDrop(next); // 같은 기록 = 끝난 차례 · 되받으면 끝없는 고리(298-3)
|
|
}
|
|
dropPending = false;
|
|
const routeId = pageSaved.canonRouteId();
|
|
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 ? heldSectionDetail(projectId, routeId as number) : null;
|
|
if (detail) {
|
|
pageSaved.adoptCanon(routeId, detail);
|
|
const diff = stationDiff(detail, stations);
|
|
if (diff) record.restoreDiff = diff;
|
|
}
|
|
why = await restore(stations).catch(() => "error");
|
|
adoptAfterDrop(); // 종단도 = 저장 기준 — 되돌린 상세 그림과 같은 틀(472-5 덧)
|
|
// 가게 측점 · 종단 = 되돌린 상세 · 저장 기준(초안 측점이 남지 않게 · 466)
|
|
if (why.startsWith("saved") || why === "no-saved")
|
|
await publisher.shown(projectId, routeId as number).catch(() => undefined);
|
|
}
|
|
}
|
|
Object.assign(record, { restored: why.startsWith("saved"), restoreWhy: why });
|
|
void releaseWhenQuiet();
|
|
return why;
|
|
};
|
|
/** 버림 뒤 그리기 풂 — 되돌림이 부른 뒤 차례(시설 줄 · 저장 노선 다시 셈)까지 끝나 묶음이 조용해진 뒤 한 번
|
|
* (시설 줄이 한 박자 늦어 그 사이 다시 셈이 옛 관 자리 계획선으로 셈한 면적이 한 틀 보였음 · 472-5 덧).
|
|
* ponytail: 50 ms 조용함 · 2 s 상한 — 차례 끝 알림이 생기면 그것으로. */
|
|
const releaseWhenQuiet = async (): Promise<void> => {
|
|
for (let i = 0, seen = -1; i < 40 && seen !== pipe.generation; i += 1) {
|
|
seen = pipe.generation;
|
|
await pipe.settled();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
holdDraws(false); // 낡음 확인은 시설 줄이 따라온 뒤 그쪽 다시 그림이 함(일찍 하면 옛 관 자리로 셈)
|
|
};
|
|
/** 노선 초안 시작 — 지금 저장 상세 · 되돌리는 함수를 맡김(횡단 초안 칸 · 측점 열쇠 · 관 · 구조물은 맡은 쪽이 떠 둠). */
|
|
const holdNow = (): typeof held => {
|
|
const { detail = null, routeId } = savedSource();
|
|
if (typeof routeId !== "number") return null;
|
|
const restore = options.holdDraft?.(routeId, detail, (f) => (beforeDraft ??= f)); // 440-4
|
|
return { restore: restore ?? 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")) {
|
|
followPlusDraft(projectId, null); // 추가 측점도 초안 전 값으로(459)
|
|
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,
|
|
});
|
|
followPlusDraft(projectId, patch); // 추가 측점 = 좌표 따라감 · 노선 밖 알림(459)
|
|
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;
|
|
const shown =
|
|
draft && saved && ground && profile
|
|
? draftSectionDetail(
|
|
saved,
|
|
ground,
|
|
profile,
|
|
pipe.output("cross"),
|
|
pageSaved.crossSpecs(),
|
|
options.stationIntervalM ?? profile.policy.station_interval_m,
|
|
statusOf(pipe.input("pipes"), pipe.input("structures")),
|
|
)
|
|
: null;
|
|
if (shown) drafted.add(shown);
|
|
if (shown) options.structureAreas?.(shown); // 관 · 구조물이 선 측점 = 폐회로 면적(349-0j)
|
|
setDraftSectionDetail(projectId, shown); // 노선 초안 상세 = 상세 가게 칸(472-5)
|
|
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) {
|
|
dropAdopt = () => void (!pipe.input("route") && link.adoptPipeline(base, edits, false));
|
|
void after.then(adoptAfterDrop); // 되돌림이 안 돈 차례(밀림 · 실패)도 끝에서
|
|
} else link.adoptPipeline(base, edits, draft);
|
|
}
|
|
drawnDraft = draft;
|
|
}, "새 상세 · 종단도");
|
|
|
|
function facilitiesNow(): Facilities {
|
|
return { pipes: store.get("pipes"), structures: store.get("structures") };
|
|
}
|
|
/** 가게에 씀 — 지금 가게 값과 같으면 안 씀(되돌리기가 가게를 되쓴 뒤 같은 조작도 씀 · 457). */
|
|
function writeFacilities(next: Facilities): void {
|
|
echo = true;
|
|
try {
|
|
if (!same(store.get("pipes"), next.pipes)) store.set("pipes", next.pipes);
|
|
if (!same(store.get("structures"), next.structures)) store.set("structures", next.structures);
|
|
} finally {
|
|
echo = false;
|
|
}
|
|
}
|
|
|
|
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,
|
|
plusStations: plusStationsOf(projectId),
|
|
};
|
|
};
|
|
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;
|
|
dropPending = false;
|
|
Object.assign(given, inputs);
|
|
const run = (lastRun = pipe.start(inputs));
|
|
trackPageBusy(projectId, run); // 「계산 중」 점(386-2)
|
|
openGeneration = pipe.generation;
|
|
// 관 · 구조물 · 추가 측점 초안이 있으면 측점을 냄 — 새로고침 · 다시 열기 = 초안 측점(갈아끼움 · 466)
|
|
const drafts = store.get("pipes") ?? store.get("structures") ?? readPlusDraft(projectId);
|
|
quietGeneration = inputs.route || drafts ? -1 : pipe.generation;
|
|
void run
|
|
.then(() => reconcile()) // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤
|
|
.then(() => publisher.opened(projectId, openGeneration, pageSaved.canonRouteId())); // 466
|
|
};
|
|
|
|
/** 묶음에 넘긴 입력(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) trackPageBusy(projectId, (lastRun = run));
|
|
if (run && dropPending && !pipe.input("route")) quietGeneration = pipe.generation; // 되돌림이 맡음
|
|
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 || pageSaved.canonRouteId() !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
|
|
if (facilities.saving()) return (facilities.afterSave(), round); // 저장 도중 = 옛 저장분 · 끝나면 다시
|
|
const stale = staleInputs(inputsNow(), given, pipe); // cross = 지금 측점 자리로 견줌(508-1c)
|
|
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)));
|
|
}
|
|
|
|
/** 저장분(자료 줄 한 번 · 386-2) · 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — 정본이 바뀌면 다시. */
|
|
const loadPlan = (): Promise<void> =>
|
|
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: enabled,
|
|
stationSnap: routePlan.station_snap ?? false,
|
|
stationSnapStep: routePlan.station_snap_step,
|
|
});
|
|
pageSaved.forgetCross();
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
// 지형 격자(없으면 서버 길) · 열 때 한 번 — 발자국 · 저장 안 한 노선 초안이 있으면 그 노선으로
|
|
void Promise.all([loadPlan(), loadDesignTerrainGrid(projectId).catch(() => null)])
|
|
.then(() => {
|
|
ready = plan !== null;
|
|
start();
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
/** 관 · 구조물 먹이기 — [저장] 도중 멈춤(끝나면 저장 목록 다시 읽고 맞춤) · 되돌리기 뒤 다시 견줌(415). */
|
|
const facilities = feedFacilities({
|
|
projectId,
|
|
store,
|
|
ready: () => started && !echo,
|
|
gone: () => disposed,
|
|
saved: (key) =>
|
|
key === "pipes"
|
|
? savedPipes
|
|
: key === "structures"
|
|
? savedStructures
|
|
: plusStationsOf(projectId),
|
|
input: (key) => pipe.input(key),
|
|
// 노선 초안 버림 중 = 버림 차례가 관 · 구조물도 초안 전 값으로 되돌림(follow) — 따로 먹이면 차례가 둘로
|
|
// 갈려 버림이 밀리고 측점 · 상세 · 유토곡선이 엇갈림(472-5 덧)
|
|
routed: () => pipe.input("route") !== null || store.get("routeDraft") !== null || dropPending,
|
|
feed: (source, value) => void feed(source, value),
|
|
resync: async () => (forgetPageData(projectId), await loadPlan(), reconcile()),
|
|
});
|
|
|
|
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: null }; // 설계 지형(509-5)
|
|
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;
|
|
holdDraws(true); // 앞 걸음(초안) 그림 그대로 — 되돌림 끝에서 한 번(472-5 덧)
|
|
dropPending = true;
|
|
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();
|
|
// 갈아끼움 · 같은 상세 다시 그림은 안 셈 · 보기가 심은 측점은 묶음이 입력으로 셈(386-3) · 화면 선만
|
|
// 바뀜(비정규 측점 도착) = 상세가 그 선 대비 낡았으면 cross 한 번 더(466)
|
|
if (canonChanged()) void loadPlan().then(start);
|
|
else if (typeof pageSaved.canonRouteId() === "number")
|
|
publisher.recheck(projectId, pageSaved.canonRouteId() as number);
|
|
},
|
|
earthwork() {
|
|
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,
|
|
ownsCross: Boolean(options.savedCross),
|
|
detail: () => draftDetailOf(projectId),
|
|
holding: () => holding,
|
|
commit: () => void (beforeDraft = null),
|
|
dispose() {
|
|
disposed = true;
|
|
setDraftSectionDetail(projectId, null);
|
|
facilities.stop();
|
|
pipe.dispose();
|
|
},
|
|
};
|
|
}
|