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
This commit is contained in:
2026-10-16 11:59:45 +09:00
co-authored by Claude Opus 5.5
parent 5a766a92e0
commit aeca9616cc
11 changed files with 1112 additions and 34 deletions
+4 -3
View File
@@ -84,6 +84,7 @@ import {
type RouteEditSnapshot,
} from "./B05_Profile_UI_RouteEdit_History";
import type { DesignPageHost } from "../A00_Common/design/design_page";
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
import "./B05_Profile_UI_Style_RouteEdit.css";
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
@@ -106,6 +107,7 @@ export interface RouteEditOptions {
tools?: HTMLElement[];
/** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */
recommend: InitialPanelParams["controls"];
draft?: draftLink.RouteDraftBridge; // 계산 묶음 다리(346-1 · `_Draft`)
}
/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */
@@ -326,14 +328,11 @@ export async function mountRouteEditor(
const toScreen = (vertex: Vertex): [number, number] =>
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
/** 화면 px → 사업지 좌표(m). 셈은 `_View` 몫. */
const toMetric = (px: number, py: number): Vertex =>
meta ? screenToMetric(meta, view, px, py) : [0, 0];
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */
const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1);
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
const contourStepM = (): number =>
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
@@ -631,6 +630,8 @@ export async function mountRouteEditor(
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
history = createRouteEditHistory(snapshotNow());
history = draftLink.watch(history, options.draft, () => stationSnap);
draftLink.restore(options.draft, restoreSnapshot);
historyControls.sync();
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
@@ -0,0 +1,82 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Draft.ts
* 노선 편집기 ↔ 계산 묶음 다리(PLAN 346-1) — 노선을 한 번 고칠 때마다(놓기 · 더함 · 지움 · 곡선 ·
* 되돌리기 · 다시하기 · [반영 전으로] · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음
* (사진첩에 쌓이는 때 = 손을 놓은 때).
*
* 편집기는 사진첩(`_History`)을 `watch` 로 감싸기만 함 — 고친 줄 하나. 저장 안 한 초안은
* 다리가 캐시 칸(`route-draft`)에 두고, 편집기가 자료를 읽은 뒤 `restore` 로 한 번 되살림
* (새로고침 · 보기 전환에도 남음). 계산 · 가게는 `A00_Common/design/design_pipeline_page.ts` 몫.
* ========================================================================== */
import type { RouteDraft } from "../A00_Common/design/design_pipeline_types";
import type { RouteEditHistory, RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
/** 편집기가 넘기는 노선 — 판 번호 · 지형은 다리가 붙임. */
export type RouteDraftEdit = Omit<RouteDraft, "rev" | "terrain">;
export interface RouteDraftBridge {
/** 노선이 바뀜 — 열 때(저장분)와 같으면 null. */
edited(draft: RouteDraftEdit | null): void;
/** 저장 안 한 초안(캐시 칸) — 없으면 null. */
saved(): RouteDraft | null;
}
/** 사진 → 노선 초안(고정 · 고른 점은 노선이 아님). */
export function draftOfSnapshot(snapshot: RouteEditSnapshot, stationSnap: boolean): RouteDraftEdit {
return {
nodes: snapshot.planned.map(([x, y]): [number, number] => [x, y]),
curveOn: [...snapshot.curveOn],
curveRadius: [...snapshot.curveRadius],
curveLock: [...snapshot.curveLock],
curveArc: [...snapshot.curveArc],
stationSnap,
};
}
/** 노선 초안 → 사진 — 고정은 편집기가 좌표로 다시 짚음(`restoreSnapshot` 새 걸음). */
export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot {
return {
planned: draft.nodes.map(([x, y]): [number, number] => [x, y]),
curveOn: [...draft.curveOn],
curveRadius: [...draft.curveRadius],
curveLock: [...draft.curveLock],
curveArc: [...draft.curveArc],
apexLock: draft.nodes.map(() => false),
picked: -1,
};
}
/** 사진첩을 감싸 노선이 바뀔 때마다 다리에 알림 — 쌓기 · 되돌리기 · 다시하기 · 열 때로. */
export function watch(
history: RouteEditHistory,
bridge: RouteDraftBridge | undefined,
stationSnap: () => boolean,
): RouteEditHistory {
if (!bridge) return history;
const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => {
if (snapshot)
bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, stationSnap()) : null);
return snapshot;
};
return {
...history,
commit: (snapshot) => {
history.commit(snapshot);
tell(snapshot);
},
undo: () => tell(history.undo()),
redo: () => tell(history.redo()),
reset: () => tell(history.reset()),
};
}
/** 저장 안 한 초안을 편집기에 한 번 되살림 — 사진첩에 새 걸음으로(되돌리면 저장분). */
export function restore(
bridge: RouteDraftBridge | undefined,
apply: (snapshot: RouteEditSnapshot, message: string, record: boolean) => void,
): void {
const kept = bridge?.saved();
if (kept?.nodes.length)
apply(snapshotOfDraft(kept), "저장 안 한 노선 초안을 되살렸습니다.", true);
}
@@ -0,0 +1,144 @@
/* =============================================================================
* B05_Profile_Pipeline_Grade.ts
* 계산 묶음(PLAN 346-1) **follow · planline · profile 단계** + 바뀐 범위 — 계약 =
* `A00_Common/design/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터):
*
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
* `carryProfileEdits` 의 edits 몫) · 옛 노선(`before.route`)이 있을 때만.
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 지반 첫 계획선(`Grade_Ground`)으로
* 바뀐 구간만 다시 맞추고 밖은 저장 꼭짓점을 밂(`carryProfileEdits` 의 basePvi 몫).
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
*
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
* ========================================================================== */
import type {
FollowPatch,
RangePart,
StageFn,
Vertex,
} from "../../A00_Common/design/design_pipeline_types";
import {
followStructures,
projectPipePoints,
type FollowingPoint,
type FollowingStructure,
} from "../../common_util/common_util_route_follow";
import {
buildAlignment,
emptyEdits,
type AlignmentBase,
type AlignmentEdits,
} from "../B05_Profile_UI_Profile_Alignment";
import { carryProfileEdits, changedSpan } from "./B05_Profile_Grade_Carry";
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
line.map(([x, y]) => ({ x, y }));
/** 서버 측점 id 꼴(`generate_sections`) — 종단 표 · 고르기가 이 id 로 측점을 짚음. */
export const stationIdOf = (chainageM: number): string =>
`station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`;
/** 노선 바뀐 범위 — 처음 바뀐 접선점부터 끝(같으면 전 구간). */
export function routeRange(
before: ReadonlyArray<Vertex> | null,
after: ReadonlyArray<Vertex> | null,
): RangePart {
const span = before && after ? changedSpan(xy(before), xy(after)) : null;
return { fromM: span ? span.start_m : 0, toM: Infinity };
}
/** 계획고 바뀐 범위 — 바뀐 편집 키 앞뒤 이웃 꼭짓점 사이(`nodes` = 계획선 꼭짓점 누가거리). 같으면 null. */
export function gradeRange(
before: AlignmentEdits | null,
after: AlignmentEdits | null,
nodes: ReadonlyArray<number>,
): RangePart | null {
const changed: number[] = [];
for (const field of ["station_offsets", "curve_radii"] as const) {
const a = before?.[field] ?? {};
const b = after?.[field] ?? {};
for (const key of new Set([...Object.keys(a), ...Object.keys(b)]))
if (a[key] !== b[key]) changed.push(Number(key));
}
if (!changed.length) return null;
const low = Math.min(...changed);
const high = Math.max(...changed);
const ahead = nodes.filter((node) => node > high + 1e-6);
return {
fromM: Math.max(0, ...nodes.filter((node) => node < low - 1e-6)),
toM: ahead.length ? Math.min(...ahead) : Infinity,
};
}
/** follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. */
export const followStage: StageFn<"follow"> = (ctx) => {
const before = ctx.before.route;
const now = ctx.out.route;
if (!before || !now) return {};
const line = xy(now.line);
const patch: FollowPatch = {};
const { pipes, structures, alignment } = ctx.inputs;
if (Array.isArray(pipes) && pipes.length)
patch.pipes = projectPipePoints(pipes as FollowingPoint[], line);
if (Array.isArray(structures) && structures.length)
patch.structures = followStructures(structures as FollowingStructure[], line).map(
(moved) => moved.item,
);
const span = changedSpan(xy(before.line), line);
if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits;
return patch;
};
export interface PlanlineDeps {
/** 저장 노선의 종단 기준(측점 한 벌 · 종단 보기 `listed`) — 없으면 아직 못 셈. */
savedBase(): AlignmentBase | null;
/** 저장 노선(그린 선) — 바뀐 구간을 가름. */
savedLine(): Vertex[] | null;
}
export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
return (ctx) => {
const saved = deps.savedBase();
if (!saved) throw new Error("저장 종단 기준이 아직 없습니다.");
const ground = ctx.out.ground;
const route = ctx.out.route;
if (!ctx.inputs.route || !ground || !route) return saved;
const { chainageM, z } = ground.longitudinal;
const samples = chainageM.map((chainage, index) => ({
chainage_m: chainage,
elevation_m: z[index],
}));
const profile = groundProfile(samples);
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
const groundPvi =
groundFollowingBasePvi({
samples,
stations: ground.stations.map((station) => station.chainageM),
policy: saved.policy,
}) ?? [];
const savedLine = deps.savedLine();
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
return {
policy: saved.policy,
onlyExplicitCurves: saved.onlyExplicitCurves,
basePvi: span
? carryProfileEdits(saved.basePvi, null, span, groundPvi).basePvi
: saved.basePvi,
chainage: profile.chainage,
ground: profile.ground,
stations: ground.stations.map((station) => ({
station_id: stationIdOf(station.chainageM),
chainage_m: station.chainageM,
})),
};
};
}
/** profile — 첫 계획선 + 계획고 편집 → 종단(종단 보기와 같은 `buildAlignment`). */
export const profileStage: StageFn<"profile"> = (ctx) => {
const base = ctx.out.planline;
if (!base) throw new Error("첫 계획선이 없습니다.");
return buildAlignment(base, ctx.inputs.alignment ?? emptyEdits());
};