Files
Aislo/A00_Common/design/design_pipeline.ts
T
eomsangdonandClaude Opus 5.5 aeca9616cc feat(계산묶음): 346-1 묶음을 B05 페이지 · 노선 편집에 붙임 — 노선 초안 캐시 칸 · 노선 고칠 때마다 종단 따라옴 · ▲▼ = grade · 단계 붙임
- 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
2026-10-16 11:59:45 +09:00

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);
}
};
}