- 묶음 기록 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
392 lines
13 KiB
TypeScript
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);
|
|
}
|
|
};
|
|
}
|