Files
Aislo/A00_Common/design/logic/design_pipeline.ts
T
eomsangdonandClaude Opus 5.5 25eea13ab2 perf(계산묶음): 349-0s 끌기 한 걸음 = 종단도 한 번 그림 — 다시 그림 한 틀 모음 · 초안 동안 저장 횡단 다시 셈 없음 · 그리기 몫 기록
- 묶음 기록 runs[].work — 알림 받는 쪽 이름별 ms(가게 · 관 · 구조물 · 새 상세 · 종단도 · 다시 셈 대기 · 횡단 카드) · 그리기 몫(종단도 · 유토곡선) · 쉼(profile 뒤 쉬는 동안)
- 종단도 다시 그림 = 다음 틀 한 번(drawSoon) — 갈아끼움 render · 정책 · 이어 공사 기준 · 비정규 측점 · 새 상세 알림 · 다시 셈 뒤가 각각 바로 그리던 것(끌기 한 걸음 6~8 번 → 1 번 · 되돌리기 5 → 1)
- 노선 초안 동안 종단도 횡단 다시 셈(crossPreview)이 갈아끼운 초안 상세를 안 셈(첫 끌기 한 번 통째 다시 셈 없앰)
- 시험 test_349_0s_draw_work 4(다섯 걸음 · 걸음마다 그리기 1 · work 칸 · 되돌리기 그대로 · 다시 셈 0) · 230-2 도움이 drawSoon 도 읽음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 17:42:54 +09:00

392 lines
13 KiB
TypeScript

/* =============================================================================
* 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",
"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;
/** `name` = 기록 `work` 칸 이름(알림 받는 쪽 몫 ms · 349-0s). */
subscribe(fn: (event: PipelineEvent) => void, name?: string): () => 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;
}
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<T>(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,
};
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 Map<(event: PipelineEvent) => void, string>();
const inFlight = new Set<Promise<unknown>>();
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<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 }, record);
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) {
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<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, 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);
}
};
}