feat(511-B B-5 follow 줄 엶): 계산 자리 follow(P5 P6 F1 F2) 바꿀 수 있음 · 기본 both 그대로 · 「서버」 = 끌기 도구 꼴(끌기 중 화면 셈 · 잠잠 뒤 창구 /calc stage.follow until follow → 서버 Node route · follow 두 단계 · 답 오면 묶음을 노선부터 다시 돌려 서버 값으로 · 곁 자료 plusAt · followedAt 같이 돌려받아 다시 붙임 · 열쇠 = 노선 초안 + 저장 계획고) · 저장 정본 맥락(노선 계획 · 종류 · 정의 · 관 · 구조물 · 추가 측점 · 측점 간격 · 저장 종단) = 창구가 프로젝트 id 로 모음(B06_Section_Router_Calc_Context · 브라우저 정본 칸 버림 · 정본 쓰기 없음) · 묶음 refresh(입력 그대로 다시 돎) · 계획고 옮김 = 끌기 중 화면이 옮긴 입력 그대로(서버 follow 는 옮김 기준 없음) · 검증_sub3 끌기 네 걸음 뒤 서버 = 화면 0 차(관 · 입력 · 첫 계획선 · 종단 PVI 61 · 왕복 231 ms · Node 5 ms) · Node 두 단계 = 묶음 첫 바퀴 0 차(golden) · 까닭 글 follow · pair_B5 걷음 — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 15:56:39 +09:00
co-authored by Claude Opus 5.5
parent 29e1e4be6d
commit de6975363d
13 changed files with 488 additions and 53 deletions
+52
View File
@@ -0,0 +1,52 @@
/* =============================================================================
* A00_Common/design/design_follow_server.ts
* 계산 자리 follow 「서버」(PLAN 511-B B-5) — 끌기 도구 꼴: 조작 중 = 화면 셈(미리보기) · 잠잠 뒤 서버 Node
* route · follow 두 단계(`stage.follow` · until follow · 저장 정본 맥락은 창구가 프로젝트 id 로 붙임)를 묻고
* 답이 오면 묶음을 노선부터 다시 돌림 → 같은 노선 초안이면 서버 값(곁 자료 다시 붙임)으로 바꿈.
* 판이 both 면 null(화면 셈 그대로). 열쇠 = 노선 초안 + 저장 노선 계획고 편집(follow 는 노선에만 기댐).
* ========================================================================== */
import { readState } from "../b_page_state";
import {
withFollowSides,
type FollowSides,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
import { calcOnServerNow } from "./design_calc_sites";
import { afterQuiet, serverCalc } from "./design_calc_server";
import type { FollowPatch, PipelineInputs } from "./logic/design_pipeline_types";
const ID = "stage.follow";
let held: { key: string; patch: FollowPatch } | null = null;
let asked: string | null = null;
/** 서버 follow 값 — 같은 열쇠 답이 있으면 그 값 · 없으면 null(화면 셈) + 잠잠 뒤 묻고 `redraw`. */
export function followFromServer(
projectId: string,
inputs: Readonly<PipelineInputs>,
before: unknown,
savedEdits: unknown,
redraw: () => void,
): FollowPatch | null {
if (!calcOnServerNow("follow") || !inputs.route) return null;
const key = JSON.stringify([inputs.route, savedEdits]);
if (held?.key === key) return held.patch;
if (asked === key) return null;
asked = key;
const { route, alignment, pipes, structures, plusStations } = inputs;
const stages = {
until: "follow",
input: { route, alignment, pipes, structures, plusStations },
before_draft: before,
pipe_ids: readState("pipe-ids", projectId),
saved_edits: savedEdits,
};
const body = JSON.stringify({ run: [ID], inputs: { stages } });
void afterQuiet(ID, (signal) => serverCalc("follow", [ID], body, signal)).then((results) => {
if (asked === key) asked = null;
const got = results?.[ID] as ({ patch: FollowPatch } & FollowSides) | undefined;
if (!got) return;
held = { key, patch: withFollowSides(got.patch, got) };
redraw();
});
return null;
}
+9 -1
View File
@@ -29,6 +29,7 @@ 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,
@@ -289,8 +290,15 @@ function createPagePipeline(
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 followStage({ ...ctx, inputs: placedFollowInputs(ctx, savedLine(), saved) });
return (
remote ?? followStage({ ...ctx, inputs: placedFollowInputs(ctx, savedLine(), saved) })
);
},
stations: createStationsStage({
intervalM: options.stationIntervalM,
@@ -20,12 +20,14 @@
import { readFileSync, statSync } from "node:fs";
import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans";
import type { FollowingStructure } from "@util/common_util_route_follow";
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
import type { PlanPipe, PlanStructure, PlanType } from "@util/common_util_structure_station";
import { parseDtmGrid, type DtmGrid } from "@util/common_util_surface_sampler";
import type { RoutePlanResponse } from "../../../B05_Profile/B05_Profile_Api_Replan";
import {
createFollowStage,
createPlanlineStage,
followSides,
profileStage,
} from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
import { shapeStatus } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
@@ -40,14 +42,23 @@ import {
type AlignmentEdits,
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
import type {
LongitudinalSection,
SectionDetailResponse,
} from "../../../B06_Section/B06_Section_Api_Fetch";
import { createCrossStage } from "../../../B06_Section/logic/B06_Section_Cross_Stage";
import { pipeBook } from "../../../B06_Section/B06_Section_Route_Carry_Patch";
import { plantedBase } from "./design_planted_base";
import { createDesignPipeline } from "./design_pipeline";
import { AFTER_STAGES, BROWSER_STAGES, createDesignPipeline } from "./design_pipeline";
import { contextCross, placedFollowInputs } from "./design_pipeline_stage_inputs";
import { createStationsStage, snapOptions } from "./design_pipeline_stations";
import type { PipelineInputs, StageName, Vertex } from "./design_pipeline_types";
import type {
PipelineInputs,
PipelineStages,
StageContext,
StageName,
Vertex,
} from "./design_pipeline_types";
import { plain } from "./design_calc_node_trial";
export interface StagesInputs {
@@ -67,6 +78,13 @@ export interface StagesInputs {
interval_m?: number;
grid?: { path: string; design: "las" | "sheet" } | null;
input: Partial<PipelineInputs>;
/** 구조물 정의(서버 창구가 붙임 · 화면 `useCatalogDefinition` 꼴) — 측점 · 카드 이름 표. */
definition?: CatalogDefinition | null;
/** 저장 종단(계획고 편집 = `readAlignment(·).edits` · 세션 초안 `saved_edits` 없을 때 · 창구가 붙임). */
longitudinal?: LongitudinalSection | null;
/** "follow" = route · follow 두 단계만(격자 · 상세 안 씀 · 계산 자리 follow 「서버」 · 511-B B-5) —
* stage.follow = { patch · plus · followed }(곁 자료 = 페이지가 다시 붙임). */
until?: "follow";
};
}
@@ -107,6 +125,12 @@ async function runStages(
plusStations: stages.input.plusStations ?? null,
};
const stored = detail && readAlignment(detail.longitudinal);
if (stages.definition) useCatalogDefinition(stages.definition);
// 페이지 `savedEdits` 짝 — 세션 초안 없으면 저장 종단 편집 · 없으면 빈 편집(창구가 longitudinal 을 붙인 때)
const storedEdits = () =>
stages.longitudinal === undefined
? null
: ((stages.longitudinal && readAlignment(stages.longitudinal)?.edits) ?? emptyEdits());
const book = pipeBook(stages.pipe_ids ?? null);
// 따라감 기준(페이지 `beforeDraft ?? 가게`) — 초안 전 관 · 구조물(null = 저장분)
const before = stages.before_draft ?? stages.input;
@@ -116,7 +140,7 @@ async function runStages(
saved: () => ({
pipes: before.pipes ?? savedPipes,
structures: before.structures ?? savedStructures,
alignment: stages.saved_edits ?? inputs.alignment ?? emptyEdits(),
alignment: stages.saved_edits ?? storedEdits() ?? inputs.alignment ?? emptyEdits(),
}),
stationsAt: (route, patch, at) =>
buildStations(
@@ -128,50 +152,50 @@ async function runStages(
).map((station) => station.chainageM),
});
const crossStage = createCrossStage({ grid: () => grid });
const pipe = createDesignPipeline({
stages: {
route: createRouteStage({
minRadiusM: () => plan.min_radius_m ?? 0,
savedLine: () => ({ line: savedLine(), curves: savedCurves() }),
}),
follow: (ctx) =>
followStage({
...ctx,
inputs: placedFollowInputs(ctx, savedLine(), {
pipes: savedPipes,
structures: savedStructures,
}),
const table: PipelineStages = {
route: createRouteStage({
minRadiusM: () => plan.min_radius_m ?? 0,
savedLine: () => ({ line: savedLine(), curves: savedCurves() }),
}),
follow: (ctx) =>
followStage({
...ctx,
inputs: placedFollowInputs(ctx, savedLine(), {
pipes: savedPipes,
structures: savedStructures,
}),
stations: createStationsStage({
intervalM: stages.interval_m,
types: () => enabled,
saved: () => plan,
}),
ground: createGroundStage({
grid: () => grid,
remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")),
}),
planline: createPlanlineStage({
savedBase: (at) =>
plantedBase({ base: null, detail }, at, types, (pipes) =>
pipes.map((pipe) => ({ ...pipe, id: book.at(pipe.chainage_m) })),
),
savedLine,
storedPvi: () => (stored ? toAlignmentBase(stored).basePvi : null),
}),
profile: profileStage,
cross: (ctx) => {
const status = shapeStatus(
ctx.inputs.pipes ?? savedPipes,
ctx.inputs.structures ?? savedStructures,
types,
);
const list = Array.isArray(ctx.inputs.structures) ? ctx.inputs.structures : [];
const spans = bermSpansFromStructures(list);
return crossStage.stage(contextCross(ctx, status, (at) => bermSpecAt(spans, at)));
},
stations: createStationsStage({
intervalM: stages.interval_m,
types: () => enabled,
saved: () => plan,
}),
ground: createGroundStage({
grid: () => grid,
remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")),
}),
planline: createPlanlineStage({
savedBase: (at) =>
plantedBase({ base: null, detail }, at, types, (pipes) =>
pipes.map((pipe) => ({ ...pipe, id: book.at(pipe.chainage_m) })),
),
savedLine,
storedPvi: () => (stored ? toAlignmentBase(stored).basePvi : null),
}),
profile: profileStage,
cross: (ctx) => {
const status = shapeStatus(
ctx.inputs.pipes ?? savedPipes,
ctx.inputs.structures ?? savedStructures,
types,
);
const list = Array.isArray(ctx.inputs.structures) ? ctx.inputs.structures : [];
const spans = bermSpansFromStructures(list);
return crossStage.stage(contextCross(ctx, status, (at) => bermSpecAt(spans, at)));
},
});
};
if (stages.until === "follow") return followOnly(table, inputs);
const pipe = createDesignPipeline({ stages: table });
try {
const record = await pipe.start(inputs);
await pipe.settled();
@@ -184,6 +208,29 @@ async function runStages(
}
}
/** route · follow 두 단계만 — 묶음 첫 바퀴와 같은 맥락(앞 출력 없음 = 저장분에서 · 페이지 「다시 열기」 꼴). */
async function followOnly(
table: PipelineStages,
inputs: PipelineInputs,
): Promise<Record<StageName, unknown>> {
const none = Object.fromEntries(
[...BROWSER_STAGES, ...AFTER_STAGES].map((name) => [name, null]),
) as StageContext<"route">["out"];
const at = <K extends StageName>(out: StageContext<K>["out"]): StageContext<K> => ({
generation: 1,
range: null,
signal: new AbortController().signal,
inputs,
out,
before: none,
prev: null,
});
const route = await table.route!(at<"route">(none));
const follow = await table.follow!(at<"follow">({ ...none, route }));
const out = { route: plain(route), follow: { patch: plain(follow), ...followSides(follow) } };
return { stations: null, profile: null, cross: null, ...out } as Record<StageName, unknown>;
}
/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음) — 바퀴 하나를 id 들이 나눠 씀. */
export function stageRunners(inputs: StagesInputs): Record<string, () => Promise<unknown>> {
let run: Promise<Record<StageName, unknown>> | null = null;
@@ -83,6 +83,8 @@ export interface DesignPipeline {
value: unknown,
part?: RangePart | null,
): Promise<PipelineRunRecord> | null;
/** 입력 그대로 그 출처부터 다시 셈 — 늦게 온 서버 값(계산 자리 follow 「서버」 · 511-B B-5). */
refresh(source: ChangeSource): Promise<PipelineRunRecord> | null;
/** `name` = 기록 `work` 칸 이름(알림 받는 쪽 몫 ms · 349-0s). */
subscribe(fn: (event: PipelineEvent) => void, name?: string): () => void;
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
@@ -353,6 +355,11 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
: { sources: [source], fromM: 0, toM: Infinity, keys: [] };
return begin(range);
},
refresh(source) {
if (busy) return null;
inputVer[SOURCE_INPUT[source]] += 1;
return begin({ sources: [source], fromM: 0, toM: Infinity, keys: [] });
},
subscribe(fn, name = "알림") {
listeners.set(fn, name);
return () => listeners.delete(fn);