/* ============================================================================= * A00_Common/design/logic/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", ]; /** 뒤 — 브라우저 단계가 끝나면 함께 띄우고 기다리지 않음. */ export const AFTER_STAGES: StageName[] = ["drainage", "corridor"]; type Dep = StageName | `input.${InputKey}`; /** 단계가 기대는 값 — 이 판들이 그대로면 다시 안 셈(계약 표와 같음). */ export const STAGE_DEPS: Record = { route: ["input.route"], follow: ["route"], stations: ["route", "input.pipes", "input.structures", "input.plusStations"], ground: ["stations"], planline: ["ground"], profile: ["planline", "input.alignment"], cross: ["profile", "ground", "input.cross", "input.pipes", "input.structures"], footprint: ["cross", "ground"], drainage: ["route", "input.pipes"], corridor: ["profile", "cross"], }; /** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */ const FOLLOW_KEYS: ReadonlyArray = [ "pipes", "structures", "alignment", "plusStations", ]; /** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단이 그다음. */ const YIELD_AFTER = new Set(["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; /** 조작 하나 — 값이 같으면 안 셈(null). part 없으면 전 구간. */ update( source: ChangeSource, value: unknown, part?: RangePart | null, ): Promise | null; /** 입력 그대로 그 출처부터 다시 셈 — 늦게 온 서버 값(계산 자리 follow 「서버」 · 511-B B-5). */ refresh(source: ChangeSource): Promise | null; /** `name` = 기록 `work` 칸 이름(알림 받는 쪽 몫 ms · 349-0s). */ subscribe(fn: (event: PipelineEvent) => void, name?: string): () => void; output(name: K): PipelineOutputs[K] | null; /** 지금 입력(follow 가 고친 뒤 값 포함 · 읽기만). */ input(key: K): PipelineInputs[K]; readonly generation: number; /** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */ readonly blockedWrites: number; /** 떠 있는 뒤 단계가 다 끝날 때까지. */ settled(): Promise; /** 페이지를 떠남 — 떠 있는 계산 · 요청을 버림. */ dispose(): void; } type Outs = { [S in StageName]: PipelineOutputs[S] | null }; function emptyOuts(): Outs { const out = {} as Record; [...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; } const addWork = (record: PipelineRunRecord | undefined, name: string, ms: number): void => { if (!record) return; const work = (record.work ??= {}); work[name] = Math.round(((work[name] ?? 0) + ms) * 100) / 100; }; /** 그리기 몫 재기(349-0s) — 마지막 차례 기록 `work[name]` 에 ms 를 더함(차례 뒤 늦은 그리기도 그 차례 몫). */ export function pipelineWork(name: string, fn: () => T): T { const t0 = now(); try { return fn(); } finally { const runs = runLog().runs; addWork(runs[runs.length - 1], name, now() - t0); } } export function createDesignPipeline(options: { stages: PipelineStages }): DesignPipeline { const stages = options.stages; const inputs: PipelineInputs = { route: null, alignment: null, cross: null, pipes: null, structures: null, plusStations: null, }; const inputVer: Record = { route: 0, alignment: 0, cross: 0, pipes: 0, structures: 0, plusStations: 0, }; const out = emptyOuts(); const outVer = {} as Record; const lastSig = {} as Partial>; /** 마지막으로 끝난 뒤 쌓인 범위 — 없음(undefined) · 전 구간(null) · 범위. */ const pending = {} as Partial>; [...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => { outVer[name] = 0; pending[name] = null; }); /** 알림 받는 쪽 → 기록 이름. */ const listeners = new Map<(event: PipelineEvent) => void, string>(); const inFlight = new Set>(); let generation = 0; let controller = new AbortController(); /** 단계 함수 · 알림을 부르는 동안(동기) — 이때 들어온 입력 쓰기는 고리라 막음. */ let busy = false; let blockedWrites = 0; const emit = (event: PipelineEvent, record?: PipelineRunRecord): void => { busy = true; try { listeners.forEach((name, fn) => { const t0 = now(); fn(event); addWork(record, name, now() - t0); }); } 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) { 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) => unknown) | undefined; if (!fn || sig === lastSig[name]) { record.skipped.push(name); if (fn) delete pending[name]; return "ok"; } const ctx: StageContext = { 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)[name] = value; outVer[name] += 1; lastSig[name] = sig; delete pending[name]; emit({ kind: "stage", stage: name, value, generation: gen }, record); return "ok"; } async function run(): Promise { 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) { const t1 = now(); await new Promise((resolve) => setTimeout(resolve, 0)); addWork(record, "쉼", now() - t1); // 그 사이 늦은 그리기 · 다른 일 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 { 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); }, 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); }, 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); } }; }