- design_pipeline_page.ts 새 — 프로젝트마다 묶음 하나(종단도 dispose 때 버림) · route · ground(sub8) · follow · planline · profile · stations · footprint(sub6) 붙임 · cross · masshaul · haul · drainage 는 붙으면 한 줄 · 첫 계산은 처음 고칠 때 · 가게 stations · profile · footprint · generation - B05_Profile_UI_RouteEdit_Draft.ts 새 — 사진첩 감싸기(쌓기 · 되돌리기 · 다시하기 · 열 때로 = 초안 넘김 · 같으면 null) · 저장 안 한 초안 되살림 · RouteEdit.ts 는 줄 셋(700줄 안) - logic/B05_Profile_Pipeline_Grade.ts 새 — follow(관 · 구조물 좌표 · 계획고 옮김) · planline(저장 기준 · 초안이면 바뀐 구간만 새 지반 첫 계획선) · profile(buildAlignment) · routeRange · gradeRange — sub1 짝 부르기만 - design_profile — listed 를 design_profile_base.ts 로 나눔 · rebuild = pipelineGrade · render · 구조물 측점 = 다시 셈 · adoptPipeline(초안 동안 편집은 메모리만 · [저장]이 안 보냄) · pipelineSource - design_pipeline — input() · dispose() - 시험 test_346_1_pipeline_page 12 건(실제 단계 · 노선 1,078 m 점 옮김 23 ms · 늦은 응답 버림 · 초안 캐시 칸 살아남음) · popout 시험 문맥에 pipelineGrade 자리 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* A00_Common/design/design_pipeline.ts
|
|
* 실시간 계산 묶음 하나(PLAN 344-4) — 조작마다 차례 번호(generation) +1 · 단계 사이마다 번호
|
|
* 확인 · 옛 번호 결과 버림 · 뒤 단계 요청은 AbortController 로 끊음. 계약(키 모양 · 단계 · deps)
|
|
* = `design_pipeline_types.ts` 머리 주석.
|
|
*
|
|
* const pipe = createDesignPipeline({ stages: { route, stations, ... } });
|
|
* pipe.subscribe(publishToStore(useDesignStore(projectId, null)));
|
|
* pipe.start(inputs); // 처음 · 전 구간
|
|
* pipe.update("grade", edits, { fromM: 40, toM: 60 }); // 조작 · 바뀐 범위
|
|
*
|
|
* 단계 시간 = `window.__aisloPipeline.runs`(마지막 RUN_LOG_SIZE 번 · 단계별 ms · 개발용).
|
|
* 화면 · fetch · 세션은 단계 함수(주입) 몫 — 이 파일은 차례 · 버림 · 범위 · 기록만.
|
|
* ========================================================================== */
|
|
|
|
import type { DesignStore } from "./design_store";
|
|
import type {
|
|
ChangeRange,
|
|
ChangeSource,
|
|
FollowPatch,
|
|
InputKey,
|
|
PipelineInputs,
|
|
PipelineOutputs,
|
|
PipelineRunRecord,
|
|
PipelineStages,
|
|
RangePart,
|
|
StageContext,
|
|
StageName,
|
|
} from "./design_pipeline_types";
|
|
import { SOURCE_INPUT } from "./design_pipeline_types";
|
|
|
|
/** 브라우저 즉시 — 차례대로 기다림. */
|
|
export const BROWSER_STAGES: StageName[] = [
|
|
"route",
|
|
"follow",
|
|
"stations",
|
|
"ground",
|
|
"planline",
|
|
"profile",
|
|
"cross",
|
|
"footprint",
|
|
"masshaul",
|
|
];
|
|
/** 뒤 — 브라우저 단계가 끝나면 함께 띄우고 기다리지 않음. */
|
|
export const AFTER_STAGES: StageName[] = ["haul", "drainage", "corridor"];
|
|
|
|
type Dep = StageName | `input.${InputKey}`;
|
|
|
|
/** 단계가 기대는 값 — 이 판들이 그대로면 다시 안 셈(계약 표와 같음). */
|
|
export const STAGE_DEPS: Record<StageName, Dep[]> = {
|
|
route: ["input.route"],
|
|
follow: ["route"],
|
|
stations: ["route", "input.pipes", "input.structures"],
|
|
ground: ["stations"],
|
|
planline: ["ground"],
|
|
profile: ["planline", "input.alignment"],
|
|
cross: ["profile", "ground", "input.cross", "input.pipes", "input.structures"],
|
|
footprint: ["cross", "ground"],
|
|
masshaul: ["cross"],
|
|
haul: ["masshaul"],
|
|
drainage: ["route", "input.pipes"],
|
|
corridor: ["profile", "cross"],
|
|
};
|
|
|
|
/** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */
|
|
const FOLLOW_KEYS: ReadonlyArray<keyof FollowPatch> = ["pipes", "structures", "alignment"];
|
|
/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단 · 유토곡선이 그다음. */
|
|
const YIELD_AFTER = new Set<StageName>(["profile"]);
|
|
export const RUN_LOG_SIZE = 20;
|
|
|
|
export type PipelineEvent =
|
|
| { kind: "generation"; generation: number }
|
|
| { kind: "stage"; stage: StageName; value: unknown; generation: number };
|
|
|
|
export interface DesignPipeline {
|
|
/** 처음 · 전 구간 — 입력 다섯을 한 번에. */
|
|
start(inputs: PipelineInputs): Promise<PipelineRunRecord>;
|
|
/** 조작 하나 — 값이 같으면 안 셈(null). part 없으면 전 구간. */
|
|
update(
|
|
source: ChangeSource,
|
|
value: unknown,
|
|
part?: RangePart | null,
|
|
): Promise<PipelineRunRecord> | null;
|
|
subscribe(fn: (event: PipelineEvent) => void): () => void;
|
|
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
|
|
/** 지금 입력(follow 가 고친 뒤 값 포함 · 읽기만). */
|
|
input<K extends InputKey>(key: K): PipelineInputs[K];
|
|
readonly generation: number;
|
|
/** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */
|
|
readonly blockedWrites: number;
|
|
/** 떠 있는 뒤 단계가 다 끝날 때까지. */
|
|
settled(): Promise<void>;
|
|
/** 페이지를 떠남 — 떠 있는 계산 · 요청을 버림. */
|
|
dispose(): void;
|
|
}
|
|
|
|
type Outs = { [S in StageName]: PipelineOutputs[S] | null };
|
|
|
|
function emptyOuts(): Outs {
|
|
const out = {} as Record<StageName, null>;
|
|
[...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => (out[name] = null));
|
|
return out as Outs;
|
|
}
|
|
|
|
/** 두 범위 합침 — null(전 구간)이 이김 · 거리는 앞 ~ 뒤. */
|
|
export function mergeRange(
|
|
a: ChangeRange | null | undefined,
|
|
b: ChangeRange | null,
|
|
): ChangeRange | null {
|
|
if (a === undefined) return b;
|
|
if (a === null || b === null) return null;
|
|
// ponytail: 떨어진 두 범위도 앞 ~ 뒤 하나로 — 사이까지 셈. 구간 목록은 느릴 때.
|
|
return {
|
|
sources: [...new Set([...a.sources, ...b.sources])],
|
|
fromM: Math.min(a.fromM, b.fromM),
|
|
toM: Math.max(a.toM, b.toM),
|
|
keys: [...new Set([...a.keys, ...b.keys])],
|
|
};
|
|
}
|
|
|
|
const now = (): number => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
|
|
function runLog(): { runs: PipelineRunRecord[] } {
|
|
const root = globalThis as { __aisloPipeline?: { runs: PipelineRunRecord[] } };
|
|
root.__aisloPipeline ??= { runs: [] };
|
|
return root.__aisloPipeline;
|
|
}
|
|
|
|
export function createDesignPipeline(options: { stages: PipelineStages }): DesignPipeline {
|
|
const stages = options.stages;
|
|
const inputs: PipelineInputs = {
|
|
route: null,
|
|
alignment: null,
|
|
cross: null,
|
|
pipes: null,
|
|
structures: null,
|
|
};
|
|
const inputVer: Record<InputKey, number> = {
|
|
route: 0,
|
|
alignment: 0,
|
|
cross: 0,
|
|
pipes: 0,
|
|
structures: 0,
|
|
};
|
|
const out = emptyOuts();
|
|
const outVer = {} as Record<StageName, number>;
|
|
const lastSig = {} as Partial<Record<StageName, string>>;
|
|
/** 마지막으로 끝난 뒤 쌓인 범위 — 없음(undefined) · 전 구간(null) · 범위. */
|
|
const pending = {} as Partial<Record<StageName, ChangeRange | null>>;
|
|
[...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => {
|
|
outVer[name] = 0;
|
|
pending[name] = null;
|
|
});
|
|
const listeners = new Set<(event: PipelineEvent) => void>();
|
|
const inFlight = new Set<Promise<unknown>>();
|
|
let generation = 0;
|
|
let controller = new AbortController();
|
|
/** 단계 함수 · 알림을 부르는 동안(동기) — 이때 들어온 입력 쓰기는 고리라 막음. */
|
|
let busy = false;
|
|
let blockedWrites = 0;
|
|
|
|
const emit = (event: PipelineEvent): void => {
|
|
busy = true;
|
|
try {
|
|
listeners.forEach((fn) => fn(event));
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
};
|
|
|
|
const sigOf = (name: StageName): string =>
|
|
STAGE_DEPS[name]
|
|
.map((dep) =>
|
|
dep.startsWith("input.") ? inputVer[dep.slice(6) as InputKey] : outVer[dep as StageName],
|
|
)
|
|
.join(",");
|
|
|
|
const markAll = (range: ChangeRange | null): void => {
|
|
[...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => {
|
|
pending[name] = mergeRange(pending[name], range);
|
|
});
|
|
};
|
|
|
|
const applyFollow = (patch: FollowPatch | null): void => {
|
|
if (!patch) return;
|
|
for (const key of Object.keys(patch) as Array<keyof FollowPatch>) {
|
|
if (!FOLLOW_KEYS.includes(key)) {
|
|
console.warn(`[pipeline] follow 가 고칠 수 없는 입력 — 버림: ${key}`);
|
|
continue;
|
|
}
|
|
inputs[key] = patch[key] as never;
|
|
inputVer[key] += 1;
|
|
}
|
|
};
|
|
|
|
/** 한 단계 — 같은 판이면 건너뜀 · 옛 번호면 버림 · 아니면 출력 내보냄. */
|
|
async function step(
|
|
name: StageName,
|
|
gen: number,
|
|
signal: AbortSignal,
|
|
record: PipelineRunRecord,
|
|
before: Outs,
|
|
): Promise<"ok" | "dropped" | "error"> {
|
|
const sig = sigOf(name);
|
|
const fn = stages[name] as ((ctx: StageContext<StageName>) => unknown) | undefined;
|
|
if (!fn || sig === lastSig[name]) {
|
|
record.skipped.push(name);
|
|
if (fn) delete pending[name];
|
|
return "ok";
|
|
}
|
|
const ctx: StageContext<StageName> = {
|
|
generation: gen,
|
|
range: pending[name] ?? null,
|
|
signal,
|
|
inputs: { ...inputs },
|
|
out: { ...out },
|
|
before,
|
|
prev: out[name],
|
|
};
|
|
const t0 = now();
|
|
let value: unknown;
|
|
try {
|
|
busy = true;
|
|
let pendingValue: unknown;
|
|
try {
|
|
pendingValue = fn(ctx);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
value = await pendingValue;
|
|
} catch (error) {
|
|
if (gen !== generation) return "dropped";
|
|
console.error(`[pipeline] ${name} 단계 실패`, error);
|
|
return "error";
|
|
}
|
|
if (gen !== generation) return "dropped";
|
|
record.stages[name] = Math.round((now() - t0) * 100) / 100;
|
|
if (name === "follow") applyFollow(value as FollowPatch | null);
|
|
(out as Record<StageName, unknown>)[name] = value;
|
|
outVer[name] += 1;
|
|
lastSig[name] = sig;
|
|
delete pending[name];
|
|
emit({ kind: "stage", stage: name, value, generation: gen });
|
|
return "ok";
|
|
}
|
|
|
|
async function run(): Promise<PipelineRunRecord> {
|
|
const gen = generation;
|
|
const signal = controller.signal;
|
|
const record: PipelineRunRecord = {
|
|
generation: gen,
|
|
sources: [],
|
|
stages: {},
|
|
skipped: [],
|
|
totalMs: 0,
|
|
outcome: "running",
|
|
};
|
|
// 같은 틱의 조작은 마지막 하나만 셈.
|
|
await Promise.resolve();
|
|
if (gen !== generation) {
|
|
record.outcome = "dropped";
|
|
return record;
|
|
}
|
|
record.sources = [...new Set(Object.values(pending).flatMap((range) => range?.sources ?? []))];
|
|
const log = runLog();
|
|
log.runs.push(record);
|
|
if (log.runs.length > RUN_LOG_SIZE) log.runs.splice(0, log.runs.length - RUN_LOG_SIZE);
|
|
const before = { ...out };
|
|
const t0 = now();
|
|
for (const name of BROWSER_STAGES) {
|
|
const result = await step(name, gen, signal, record, before);
|
|
if (result !== "ok") {
|
|
record.outcome = result;
|
|
return record;
|
|
}
|
|
if (YIELD_AFTER.has(name) && record.stages[name] !== undefined) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
if (gen !== generation) {
|
|
record.outcome = "dropped";
|
|
return record;
|
|
}
|
|
}
|
|
}
|
|
record.totalMs = Math.round((now() - t0) * 100) / 100;
|
|
record.outcome = "done";
|
|
for (const name of AFTER_STAGES) {
|
|
const task = step(name, gen, signal, record, before);
|
|
inFlight.add(task);
|
|
void task.finally(() => inFlight.delete(task));
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function begin(range: ChangeRange | null): Promise<PipelineRunRecord> {
|
|
generation += 1;
|
|
controller.abort();
|
|
controller = new AbortController();
|
|
markAll(range);
|
|
emit({ kind: "generation", generation });
|
|
return run();
|
|
}
|
|
|
|
return {
|
|
start(next) {
|
|
(Object.keys(inputs) as InputKey[]).forEach((key) => {
|
|
inputs[key] = next[key] as never;
|
|
inputVer[key] += 1;
|
|
});
|
|
return begin(null);
|
|
},
|
|
update(source, value, part = null) {
|
|
if (busy) {
|
|
// 파생(단계 · 알림)이 입력에 쓰는 길 — 고리라 막음. 예외는 follow 반환값뿐.
|
|
blockedWrites += 1;
|
|
console.warn(`[pipeline] 계산 · 알림 안에서 입력 쓰기 막음: ${source}`);
|
|
return null;
|
|
}
|
|
const key = SOURCE_INPUT[source];
|
|
if (JSON.stringify(inputs[key]) === JSON.stringify(value)) return null;
|
|
inputs[key] = value as never;
|
|
inputVer[key] += 1;
|
|
const range = part
|
|
? { sources: [source], fromM: part.fromM, toM: part.toM, keys: part.keys ?? [] }
|
|
: { sources: [source], fromM: 0, toM: Infinity, keys: [] };
|
|
return begin(range);
|
|
},
|
|
subscribe(fn) {
|
|
listeners.add(fn);
|
|
return () => listeners.delete(fn);
|
|
},
|
|
output(name) {
|
|
return out[name] as never;
|
|
},
|
|
input(key) {
|
|
return inputs[key];
|
|
},
|
|
get generation() {
|
|
return generation;
|
|
},
|
|
get blockedWrites() {
|
|
return blockedWrites;
|
|
},
|
|
async settled() {
|
|
while (inFlight.size) await Promise.allSettled([...inFlight]);
|
|
},
|
|
dispose() {
|
|
generation += 1;
|
|
controller.abort();
|
|
listeners.clear();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 가게 잇기 — 측점(ground 단계 · 지반 채움) · 종단 · 평면 발자국 · 차례 번호를 가게 키로. */
|
|
export function publishToStore(store: DesignStore): (event: PipelineEvent) => void {
|
|
return (event) => {
|
|
if (event.kind === "generation") return store.set("generation", event.generation);
|
|
if (event.stage === "ground") {
|
|
store.set("stations", (event.value as PipelineOutputs["ground"] | null)?.stations ?? null);
|
|
} else if (event.stage === "profile") {
|
|
store.set("profile", event.value as PipelineOutputs["profile"] | null);
|
|
} else if (event.stage === "footprint") {
|
|
store.set("footprint", event.value as PipelineOutputs["footprint"] | null);
|
|
}
|
|
};
|
|
}
|