Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-10-17 12:59:58 +09:00
175 changed files with 4875 additions and 1444 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ export const STATE_REGISTRY = {
"masshaul-visible": {
bucket: "pref",
scope: "global",
version: 5,
version: 6,
legacy: () => "b06:masshaul-visible-v4",
},
"table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" },
+2
View File
@@ -32,6 +32,7 @@ import { saveSharedDesign, type DesignPageShared } from "./design_shared";
import { guardDesignLeave } from "./design_leave";
import { usePipeIds } from "./design_pipe_ids";
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
import { reconcilePagePipeline } from "./design_pipeline_page";
import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
@@ -244,6 +245,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
if (slot) {
slot.show();
applyView(view);
void reconcilePagePipeline(projectId); // 들어올 때 입력(세션 · 가게) 대 묶음 맞춤(365-2)
} else mountView(view, true);
// 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다.
const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } };
+42 -3
View File
@@ -8,9 +8,11 @@
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
* 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구
* 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계(세션 고침은 종단도 · 횡단 보기가 다시 셈하며 이미 얹음).
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 ·
* 암 경사) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2). 부를 때마다 모음.
* ========================================================================== */
import { readState } from "../b_page_state";
import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import {
effectiveStandardCross,
@@ -66,16 +68,50 @@ function carried(section: CrossSection): Record<string, unknown> {
: null,
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
surfaceDropM: drop > 0 ? drop : 0,
};
}
/** 저장 상세 + 저장 노선 측점(열쇠) → cross 입력 · 시설 제원 — 표준단면 · 상세가 없으면 null. */
/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */
function sessionCells(
name: "crossdesign" | "rockb" | "cutslope",
projectId: string,
routeId: number,
): Array<[string, unknown]> {
const raw = readState<Record<string, unknown>>(name, projectId, routeId);
if (!raw || typeof raw !== "object") return [];
return Object.entries(raw).filter(([key]) => key.includes(":") && !key.startsWith("at:"));
}
/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름). */
function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] {
const out: CrossInput["byKey"] = {};
for (const [key, value] of sessionCells("crossdesign", projectId, routeId)) {
const choice = (value ?? {}) as Record<string, unknown>;
const entry = (out[key] ??= {});
if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type;
if (typeof choice.ditch_type === "string") entry.ditchType = choice.ditch_type;
if ("ditch_choice" in choice)
entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null;
if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope);
}
for (const [key, value] of sessionCells("rockb", projectId, routeId))
if (finite(value) !== null) (out[key] ??= {}).rockBoundaryOffsetM = value;
for (const [key, value] of sessionCells("cutslope", projectId, routeId)) {
const ratio = finite(value);
if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null;
}
return out;
}
/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 ·
* 상세가 없으면 null. */
export function pipelineCrossSource(
projectId: string,
detail: SectionDetailResponse | null,
savedStations: ReadonlyArray<PipelineStation>,
routeId: number | null = null,
): PipelineCrossSource | null {
const standard = detail && effectiveStandardCross(projectId);
if (!detail || !standard) return null;
@@ -94,6 +130,9 @@ export function pipelineCrossSource(
for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name];
specs.set(best.key, own as SectionSpecs);
}
if (routeId !== null)
for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId)))
byKey[key] = { ...byKey[key], ...entry };
return {
// 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p)
input: { standard, byKey, rockBoundaryDefaultM: readRockBoundaryDefault(projectId) },
+122 -50
View File
@@ -12,7 +12,10 @@
*
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
* 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
* 입력 + 세션 고침(`design_pipeline_cross.ts` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) ·
* 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
* 고리(365-2) — 열 때 · 페이지 들어올 때(`reconcilePagePipeline`) 입력(세션 · 가게)을 묶음이 쥔 입력과 견줌 →
* 다르면 한 바퀴 → 끝난 뒤 다시 견줌 · 최대 `RECONCILE_ROUNDS` · 넘으면 `onUnstable`(「계산 불안정」 한 줄).
* 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 ·
* 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림.
* 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선).
@@ -22,15 +25,19 @@
* ========================================================================== */
import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline";
import { SOURCE_INPUT } from "./logic/design_pipeline_types";
export { pipelineWork } from "./logic/design_pipeline";
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
import { createStationsStage } from "./logic/design_pipeline_stations";
import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations";
import type {
ChangeSource,
CrossOutput,
FollowPatch,
PipelineInputs,
PipelineOutputs,
PipelineRunRecord,
PipelineStation,
RangePart,
RouteDraft,
StageFn,
Vertex,
@@ -102,6 +109,8 @@ import type {
/** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */
export const DRAFT_ROUTE_ID = 0;
/** 입력 ↔ 묶음 입력 맞춤 상한(바퀴) — 넘으면 「계산 불안정」(365-2). */
export const RECONCILE_ROUNDS = 3;
/** 종단도(`design_profile`)가 묶음에 내주는 것. */
export interface ProfileLink {
@@ -137,6 +146,15 @@ export interface PagePipelineOptions {
routeId: number,
saved: SectionDetailResponse | null,
) => (stations: PipelineStation[]) => Promise<string>;
/** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */
onUnstable?: () => void;
/** 저장 노선 cross = 다시 셈 창구 한 벌(365-4) — 상세 설계를 고치고 그 값을 냄 · 측점이 상세와 다르면 null
* (묶음 엔진). 주면 종단도가 계획고 편집 뒤 따로 다시 셈 안 함(`pipelineOwnsCross`). */
savedCross?: (
detail: SectionDetailResponse,
routeId: number,
stations: ReadonlyArray<PipelineStation>,
) => Promise<CrossOutput | null>;
}
interface PagePipeline {
@@ -145,6 +163,9 @@ interface PagePipeline {
grade(edits: AlignmentEdits): void;
restart(): void;
earthwork(context: MassHaulStageContext | null): void;
cross(): void;
reconcile(): Promise<number>;
ownsCross: boolean;
detail(): SectionDetailResponse | null;
dispose(): void;
}
@@ -155,22 +176,6 @@ type Facilities = Pick<DesignStoreValues, "pipes" | "structures">;
const pages = new Map<string, PagePipeline>();
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
function stationDiff(
detail: SectionDetailResponse,
stations: ReadonlyArray<PipelineStation>,
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
const saved = detail.cross_sections.map((section) => section.chainage_m);
const n = Math.max(saved.length, stations.length);
for (let i = 0; i < n; i += 1) {
const pipelineM = stations[i]?.chainageM ?? null;
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
continue;
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
}
return null;
}
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
export function routeDraftBridge(
projectId: string,
@@ -193,6 +198,17 @@ export const restartPagePipeline = (projectId: string): void => pages.get(projec
export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void =>
pages.get(projectId)?.earthwork(context);
/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */
export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross();
/** 저장 노선 횡단 다시 셈을 묶음 cross 가 맡는가(365-4) — 종단도는 계획고 편집 뒤 따로 안 셈. */
export const pipelineOwnsCross = (projectId: string): boolean =>
pages.get(projectId)?.ownsCross ?? false;
/** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(365-2) — 돈 바퀴 수(0 = 같음 · -1 = 넘음 · 묶음 없음 = 0). */
export const reconcilePagePipeline = (projectId: string): Promise<number> =>
pages.get(projectId)?.reconcile() ?? Promise.resolve(0);
/** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */
export const pipelineDetail = (projectId: string): SectionDetailResponse | null =>
pages.get(projectId)?.detail() ?? null;
@@ -219,10 +235,9 @@ function createPagePipeline(
let savedStructures: PlanStructure[] = [];
let savedPipes: PlanPipe[] = [];
let earthwork: MassHaulStageContext | null = null;
/** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */
/** 저장 노선 측점(열쇠) · 마지막으로 모은 횡단 입력(시설 제원 — 새 노선 상세가 씀). */
let savedStations: PipelineStation[] = [];
let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null =
null;
let crossSource: PipelineCrossSource | null = null;
/** 저장 정본(서버가 준 상세 · 노선 번호) — 측점 갈아끼움(swap)이 만든 새 상세는 지반 표본 배열을
* 그대로 둠 → 정본 아님(묶음 출력 → 상세 → 다시 묶음 고리를 끊음). */
let canon: { routeId: unknown; samples: unknown; detail: SectionDetailResponse | null } | null =
@@ -286,12 +301,17 @@ function createPagePipeline(
emptyEdits()
);
};
/** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */
const crossNow = (): PipelineCrossSource | null => {
const detail = canon?.detail ?? null;
if (!detail) return null;
if (crossSource?.detail !== detail || !crossSource.source)
crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) };
return crossSource.source;
const routeId = canon?.routeId;
const source = pipelineCrossSource(
projectId,
canon?.detail ?? null,
savedStations,
typeof routeId === "number" ? routeId : null,
);
if (source) crossSource = source;
return source;
};
const groundDeps = groundStageDeps(projectId);
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
@@ -340,18 +360,27 @@ function createPagePipeline(
ground: createGroundStage(groundDeps),
planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine }),
profile: profileStage,
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름
cross: (ctx) =>
crossStage.stage({
...ctx,
inputs: {
...ctx.inputs,
cross: crossWithUphill(
ctx.inputs.cross as CrossInput | null,
ctx.out.ground?.stations ?? [],
),
},
}),
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 · 저장 노선 = 다시 셈
// 창구 한 벌(카드 · 유토곡선이 보는 상세 설계 · 365-4)
cross: (ctx) => {
const engine = () =>
crossStage.stage({
...ctx,
inputs: {
...ctx.inputs,
cross: crossWithUphill(
ctx.inputs.cross as CrossInput | null,
ctx.out.ground?.stations ?? [],
),
},
});
const { detail = null, routeId } = savedSource();
const saved =
!ctx.inputs.route && detail && typeof routeId === "number"
? options.savedCross?.(detail, routeId, ctx.out.ground?.stations ?? [])
: null;
return saved ? saved.then((out) => out ?? engine()) : engine();
},
footprint: footprintStage,
masshaul: createMassHaulStage(() => earthwork),
haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)),
@@ -460,7 +489,7 @@ function createPagePipeline(
ground,
profile,
pipe.output("cross"),
crossSource?.source?.specs ?? new Map(),
crossSource?.specs ?? new Map(),
options.stationIntervalM ?? profile.policy.station_interval_m,
)
: null;
@@ -513,12 +542,52 @@ function createPagePipeline(
if (disposed || !ready || !savedSource().base) return;
canonChanged();
started = true;
held = null;
const inputs = inputsNow();
// 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄
held = inputs.route ? (held ?? holdNow()) : null;
dropping = null;
void pipe.start(inputsNow());
openGeneration = quietGeneration = pipe.generation;
Object.assign(given, inputs);
const run = (lastRun = pipe.start(inputs));
openGeneration = pipe.generation;
quietGeneration = inputs.route ? -1 : pipe.generation;
void run.then(() => reconcile()); // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤
};
/** 묶음에 넘긴 입력(follow 가 고치기 전) · 마지막으로 띄운 차례 — 고리가 견줌 · 기다림. */
const given: Partial<PipelineInputs> = {};
let lastRun: Promise<unknown> = Promise.resolve();
const feed = (source: ChangeSource, value: unknown, part: RangePart | null = null) => {
given[SOURCE_INPUT[source]] = value as never;
const run = pipe.update(source, value, part);
if (run) lastRun = run;
return run;
};
let reconciling: Promise<number> | null = null;
/** 입력 지문 대 묶음 입력 — 넘긴 값 · follow 뒤 값 둘 다와 다르면 한 바퀴(노선 = 다리 길) · 끝난 뒤 다시 견줌. */
function reconcile(): Promise<number> {
if (!started || disposed) return Promise.resolve(0);
return (reconciling ??= (async () => {
for (let round = 0; ; round += 1) {
let seen: Promise<unknown> | null = null;
while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지
await pipe.settled();
if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
const now = inputsNow();
const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
.filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key]))
.map(([source, key]): [ChangeSource, unknown] => [source, now[key]]);
if (!stale.length) return round;
if (round >= RECONCILE_ROUNDS) {
options.onUnstable?.();
return -1;
}
for (const [source, value] of stale)
if (source === "route") bridge.edited(value as RouteDraft | null);
else feed(source, value);
}
})().finally(() => (reconciling = null)));
}
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */
const loadPlan = (): Promise<void> =>
fetchRoutePlan(projectId)
@@ -552,13 +621,10 @@ function createPagePipeline(
.catch(() => undefined);
const unsubscribe = [
store.subscribe(
"pipes",
(pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes),
),
store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)),
store.subscribe(
"structures",
(list) => started && !echo && void pipe.update("structure", list ?? savedStructures),
(list) => started && !echo && void feed("structure", list ?? savedStructures),
),
];
@@ -579,7 +645,7 @@ function createPagePipeline(
if (!started) return start();
const before = pipe.output("route")?.line ?? null;
const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine();
const run = pipe.update("route", draft, routeRange(before, after));
const run = feed("route", draft, routeRange(before, after));
if (run && !draft && wasDraft) {
quietGeneration = pipe.generation;
dropping = run.then(finishDrop);
@@ -595,7 +661,7 @@ function createPagePipeline(
if (same(edits, pipe.input("alignment"))) return;
const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? [];
const before = pipe.input("alignment") as AlignmentEdits | null;
void pipe.update("grade", edits, gradeRange(before, edits, nodes));
void feed("grade", edits, gradeRange(before, edits, nodes));
},
restart() {
if (!started) return start();
@@ -604,8 +670,14 @@ function createPagePipeline(
earthwork(context) {
earthwork = context;
const cross = crossNow()?.input ?? null;
if (started && cross) void pipe.update("cross", cross);
if (started && cross) void feed("cross", cross);
},
cross() {
const cross = started ? (crossNow()?.input ?? null) : null;
if (cross) void feed("cross", cross);
},
reconcile,
ownsCross: Boolean(options.savedCross),
detail: () => shown,
dispose() {
disposed = true;
+8 -7
View File
@@ -324,9 +324,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
refresh: () => renderBalance(),
});
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
function applyEdits(next: AlignmentEdits): void {
if (!base || !alignment) return;
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */
function applyEdits(next: AlignmentEdits): boolean {
if (!base || !alignment) return false;
const candidate = buildAlignment(base, next);
if (
base.policy.grade_violation_policy === "block" &&
@@ -336,12 +336,13 @@ export function createDesignProfile(ctx: DesignProfileContext) {
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
"error",
);
return;
return false;
}
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false;
store.replace(next);
history.record();
return true;
}
/** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */
@@ -353,8 +354,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
pagePipeline.pipelineGrade(projectId, store.edits());
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
scheduleCrossPreview();
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). 묶음이 맡으면 그 cross 한 벌(365-4).
if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview();
drawSoon();
}
/** 다음 틀에 한 번 그림 — 같은 차례 알림 · 갈아끼움 · 설정이 여러 번 불러도 한 번(349-0s). */
@@ -59,8 +59,6 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
const column = document.createElement("div");
column.className = "b05-route-profile__column";
column.append(bodyWrap, massHaul.overlay);
// 판의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(onChange);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
+54
View File
@@ -29,6 +29,19 @@ import { saveDesign, type DesignSaveStep } from "./design_save";
import { markDesignClean } from "./design_leave";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page";
import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
import {
applyStructureAreaRows,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
import {
holdSectionDetail,
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh";
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -62,6 +75,8 @@ export interface DesignViewBinding {
onReset?: () => void;
/** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */
onPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
/** 계산 묶음 알림 — 묶음은 페이지가 세움(365-1) · 보기는 알림만 받음. */
pipeline?: Pick<PagePipelineOptions, "onCorridorStale" | "onFacilitiesFollowed">;
}
/** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */
@@ -182,9 +197,48 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
attachProfilePopout(profile, shared.profileSlot, popoutButton);
attachSubPanelPopouts(profile);
shared.profile = profile;
if (shared.projectId) startPagePipeline(shared, profile);
return profile;
}
/** 계산 묶음 한 벌(365-1) — 종단도와 함께 페이지가 세움 · B05 · B06 어느 쪽으로 들어와도(새로고침도) 노선
* 초안을 셈. 편집기는 `routeDraftBridge` 로 이 다리를 받아 씀 · 보기 몫 알림은 `pipeline` 바인딩으로. */
function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): void {
const projectId = shared.projectId;
const each = (fn: (hooks: NonNullable<DesignViewBinding["pipeline"]>) => void): void =>
Object.values(shared.bindings).forEach((binding) => binding?.pipeline && fn(binding.pipeline));
routeDraftBridge(projectId, profile, {
stationIntervalM: SECTION_STATION_INTERVAL_M,
onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()),
onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)),
onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"),
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을
// 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진
savedCross: async (detail, routeId, stations) => {
if (!detailCross(detail, stations)) return null;
const edits = profile.pipelineSource().edits;
const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits });
if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated);
profile.redraw();
return detailCross(detail, stations);
},
structureAreas: ({ cross_sections: rows }) =>
applyStructureAreaRows(rows, structureAreaRows(rows)),
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
holdDraft: (routeId, saved) => {
const hold = saved && holdSectionDetail(projectId, routeId, saved);
return async (stations) => {
if (!hold) {
await swapSectionStations(projectId, routeId, stations);
return "no-saved";
}
const back = await restoreSectionDetail(projectId, routeId, hold, stations);
return !back ? "no-cache" : back.same ? "saved" : "saved-diff";
};
},
});
}
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
export function sharedStructures(shared: DesignPageShared): StructuresSection {
if (shared.structures) return shared.structures;
+2 -2
View File
@@ -225,11 +225,11 @@ export async function mountCrossView(
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
try {
await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4)
const fresh = await loadSectionDetail(projectId, currentRouteId);
if (seq !== structuresRefreshSeq) return;
sectionDetail = fresh;
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측).
if (sectionDetail) {
applyStructureAreaRows(
sectionDetail.cross_sections,
+8 -31
View File
@@ -48,16 +48,7 @@ import {
rememberStandardDefaults,
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch";
import {
applyStructureAreaRows,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
import {
holdSectionDetail,
invalidateSectionDetail,
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import {
onStaleStructureTypes,
structureAnchorM,
@@ -419,6 +410,11 @@ export async function mountProfileView(
bindDesignView(shared, "profile", {
save: profileSaveParts(actionContext),
onReset: () => uphillOverrides.clear(),
// 계산 묶음(페이지 몫 · 365-1) 알림 중 종단 보기 몫 — 3D 「낡음」 · 관 · 구조물 따라감
pipeline: {
onCorridorStale: () => sections.setCorridorPending(true),
onFacilitiesFollowed: followFacilities,
},
});
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
@@ -539,27 +535,8 @@ export async function mountProfileView(
smooth: latestResponse.surface_params.smooth,
tools: [view3d.modal.button],
recommend: panel.recommend,
// 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
draft: routeDraftBridge(activeProjectId, profilePanel, {
stationIntervalM: panel.values().stationInterval ?? undefined,
onCorridorStale: () => sections.setCorridorPending(true),
onFacilitiesFollowed: followFacilities,
structureAreas: ({ cross_sections: rows }) =>
applyStructureAreaRows(rows, structureAreaRows(rows)),
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면(초안 둔 채
// 새로고침) 묶음 측점으로 갈아끼움
holdDraft: (routeId, saved) => {
const hold = saved && holdSectionDetail(activeProjectId, routeId, saved);
return async (stations) => {
if (!hold) {
await swapSectionStations(activeProjectId, routeId, stations);
return "no-saved";
}
const back = await restoreSectionDetail(activeProjectId, routeId, hold, stations);
return !back ? "no-cache" : back.same ? "saved" : "saved-diff";
};
},
}),
// 계산 묶음(346-1) — 페이지가 세운 다리(365-1) · 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
draft: routeDraftBridge(activeProjectId, profilePanel),
});
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
@@ -14,7 +14,13 @@ import type {
PlanStructure,
PlanType,
} from "../../../common_util/common_util_structure_station";
import type { PipelineStation, StageContext, StageFn } from "./design_pipeline_types";
import type {
PipelineRunRecord,
PipelineStation,
StageContext,
StageFn,
} from "./design_pipeline_types";
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
/** 범위 밖 측점은 앞 값(같은 열쇠 · 같은 자리)을 그대로 — 뒤 단계가 같은 객체로 「안 바뀜」을 앎. */
export function reuseOutside(
@@ -54,3 +60,19 @@ export function createStationsStage(
return reuseOutside(next, ctx.prev, ctx.range);
};
}
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
export function stationDiff(
detail: SectionDetailResponse,
stations: ReadonlyArray<PipelineStation>,
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
const saved = detail.cross_sections.map((section) => section.chainage_m);
const n = Math.max(saved.length, stations.length);
for (let i = 0; i < n; i += 1) {
const pipelineM = stations[i]?.chainageM ?? null;
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
continue;
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
}
return null;
}
+44 -13
View File
@@ -4,7 +4,8 @@
*
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) =
* 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음.
*
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
@@ -181,8 +182,8 @@ export interface FreeDrawDeps {
base: () => AlignmentBase | null;
alignment: () => ProfileAlignment | null;
edits: () => AlignmentEdits;
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
apply: (next: AlignmentEdits) => void;
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */
apply: (next: AlignmentEdits) => boolean | void;
/** 모드를 끈다(적용 뒤 · Esc). */
close: () => void;
/** 단계 · 안내가 바뀜. */
@@ -198,6 +199,11 @@ export interface FreeDraw {
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
/** Enter · Escape · Backspace — 먹었으면 true. */
key: (key: string) => boolean;
/** 자유지정 안 한 걸음 되돌림 · 다시함(367-3) — 큰 이력과 따로. */
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
marks: () => FreeMarks;
hint: () => string;
}
@@ -209,6 +215,25 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
let anchor: number | null = null;
let points: FreeVertex[] = [];
let target: FreeVertex | null = null;
/** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */
interface Snap {
phase: FreePhase;
pending: number | null;
range: [number, number] | null;
anchor: number | null;
points: FreeVertex[];
}
let past: Snap[] = [];
let future: Snap[] = [];
const snap = (): Snap => ({ phase, pending, range, anchor, points: [...points] });
function step(from: Snap[], to: Snap[]): void {
const state = from.pop();
if (!state) return;
to.push(snap());
({ phase, pending, range, anchor, points } = state);
target = null;
deps.changed();
}
function reset(): void {
phase = "range";
@@ -217,6 +242,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
anchor = null;
points = [];
target = null;
past = [];
future = [];
}
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
@@ -263,8 +290,9 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
if (!base || end === null || z === null) return;
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
const next = buildFreeEdits(base, deps.edits(), vertices);
// 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3)
if (deps.apply(next) === false) return;
reset();
deps.apply(next);
deps.close();
}
@@ -272,6 +300,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
phase: () => phase,
reset,
click(chainageM, elevationM, stations) {
const before = snap();
if (phase === "range") {
const station = snapStation(stations, chainageM);
if (station === null) return true;
@@ -297,6 +326,10 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
}
points.push(next);
}
if (JSON.stringify(snap()) !== JSON.stringify(before)) {
past.push(before);
future = [];
}
target = null;
deps.changed();
return true;
@@ -316,18 +349,16 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
return true;
}
if (key === "Backspace") {
if (phase !== "drawing") return false;
if (points.length) points.pop();
else {
anchor = null;
phase = "armed";
}
target = null;
deps.changed();
if (!past.length) return false;
step(past, future);
return true;
}
return false;
},
undo: () => step(past, future),
redo: () => step(future, past),
canUndo: () => past.length > 0,
canRedo: () => future.length > 0,
marks() {
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
const dots = path();
@@ -357,7 +388,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
}
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)";
},
};
}