304 lines
16 KiB
TypeScript
304 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_Cross_Refresh_Calc.ts
|
|
* 저장 노선 횡단 다시 셈의 셈 몫(PLAN 511-B B-4) — `B06_Section_Cross_Refresh.ts` 가 세션 · 가게에서 모은
|
|
* 입력(`RefreshInputs` · JSON 꼴)으로 전 측점 설계를 제자리에서 다시 셈. 브라우저와 서버 Node
|
|
* (`cross.refresh` · 계산 자리 cross = server)가 이 함수 하나를 씀 — 같은 입력 = 같은 값.
|
|
* 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝) · 미교차 넓힘 = `extendUnclosed`.
|
|
* 순수 — 화면 · fetch · 세션 없음(세션 읽기는 부르는 쪽).
|
|
* ========================================================================== */
|
|
|
|
import { bermSpecAt, type BermSessionSpec, type BermSpan } from "@util/common_util_berm_spans";
|
|
import {
|
|
BERM_DEFAULT_DEPTH_M,
|
|
BERM_DEFAULT_INTERVAL_M,
|
|
BERM_DEFAULT_SLOPE_DEG,
|
|
BERM_DEFAULT_WIDTH_M,
|
|
type BermSpec,
|
|
} from "@util/common_util_cross_berm";
|
|
import { computeCrossDesign } from "@util/common_util_cross_design";
|
|
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
|
import type { DtmGrid } from "@util/common_util_surface_sampler";
|
|
import type { CrossSection, SectionDetailResponse } from "../B06_Section_Api_Fetch";
|
|
import type { CrossDesignChoice } from "../B06_Section_Cross_Design_Session";
|
|
import {
|
|
planElevationAt,
|
|
type ProfileAlignment,
|
|
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
|
import { toDesignProfile } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
|
import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
|
|
import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys";
|
|
|
|
type Side = "left" | "right";
|
|
const SIDES: Side[] = ["left", "right"];
|
|
type SideValues = Partial<Record<Side, number>>;
|
|
|
|
/** 모은 입력 — 열쇠 = 누가거리(0.01 m 숫자 · `rockKey`) · Map 은 목록(JSON 으로 서버에 감). */
|
|
export interface RefreshInputs {
|
|
/** 화면이 쥔 계획선(측점 한 벌 · 342-1). */
|
|
alignment: ProfileAlignment;
|
|
standard: StandardCrossSectionSpec;
|
|
/** 암 경계 세션 오프셋 · 기본 오프셋(config). */
|
|
rockOffsets: Array<[number, number]>;
|
|
rockDefault: number | null;
|
|
/** 측점별 암 절토 경사(0 = 표준값). */
|
|
cutSlopes: Array<[number, number]>;
|
|
/** 소단 사본(세션 `berm` · 없으면 null = 저장분 `design.berm`). */
|
|
bermSpans: BermSpan[] | null;
|
|
bermDepths: Array<[number, SideValues]>;
|
|
/** 카드 선택(지반 · 측구 · 2단 경사 …). */
|
|
choices: Array<[number, CrossDesignChoice]>;
|
|
shoulderExtends: Array<[number, SideValues]>;
|
|
/** 저장 정본 설계(세션 고름 ?? 정본). */
|
|
canon: Array<[number, unknown]>;
|
|
}
|
|
|
|
/** 측점별 앞 계산 — 브라우저만(측점 객체가 바뀌면 저절로 비워짐). */
|
|
export type RefreshMemo = WeakMap<
|
|
CrossSection,
|
|
{ samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] }
|
|
>;
|
|
|
|
/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
|
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
|
|
|
|
/** 누가거리 열쇠(0.01 m) — 자릿수에 안 휘둘리게 숫자로. */
|
|
export function rockKey(chainageM: number): number {
|
|
return Math.round(chainageM * 100) / 100;
|
|
}
|
|
|
|
/**
|
|
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
|
|
* 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다.
|
|
*/
|
|
export function pavedBySpecs(section: Pick<CrossSection, "pavement" | "ford_pavement">): boolean {
|
|
return Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
|
|
}
|
|
|
|
function preserveUserFields(
|
|
next: NonNullable<CrossSection["design"]>,
|
|
previous: CrossSection["design"],
|
|
): NonNullable<CrossSection["design"]> {
|
|
if (!previous) return next;
|
|
const merged = { ...next } as Record<string, unknown>;
|
|
const source = previous as unknown as Record<string, unknown>;
|
|
for (const key of PRESERVED_KEYS) {
|
|
if (source[key] !== undefined) merged[key] = source[key];
|
|
}
|
|
return merged as unknown as NonNullable<CrossSection["design"]>;
|
|
}
|
|
|
|
/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */
|
|
export function storedBermSpec(berm: unknown): BermSessionSpec | null {
|
|
if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null;
|
|
const spec = berm as Record<string, unknown>;
|
|
const value = (key: string, fallback: number): number => {
|
|
const raw = spec[key];
|
|
if (raw === undefined) return fallback;
|
|
return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw);
|
|
};
|
|
const out = {
|
|
width_m: value("width_m", BERM_DEFAULT_WIDTH_M),
|
|
interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M),
|
|
slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
|
depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0),
|
|
};
|
|
return Object.values(out).every(Number.isFinite) ? out : null;
|
|
}
|
|
|
|
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
|
|
function bermDepthReader(session: Map<number, SideValues>) {
|
|
return (chainageM: number, design: Record<string, unknown>, side: Side): number | null => {
|
|
const own = session.get(rockKey(chainageM))?.[side];
|
|
const stored = design[`berm_depth_${side}_m`];
|
|
const value = own ?? (typeof stored === "number" ? stored : null);
|
|
return value === null ? null : Math.max(value, 0);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 전 측점 설계를 모은 입력으로 다시 셈 · `detail` 제자리 고침 · 다시 센 측점 누가거리.
|
|
* 계획고는 화면 선(`inputs.alignment`)에서 — `design_profiles` 도 그 선으로 얹음(낡음 판정 기준 한 벌).
|
|
*/
|
|
export function refreshDesigns(
|
|
detail: SectionDetailResponse,
|
|
inputs: RefreshInputs,
|
|
grid: DtmGrid | null,
|
|
memos?: RefreshMemo,
|
|
): number[] {
|
|
const { alignment, standard } = inputs;
|
|
const standardKey = JSON.stringify(standard);
|
|
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
|
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
|
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
|
// 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다**
|
|
// — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다.
|
|
detail.longitudinal.design_profiles = [
|
|
toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]),
|
|
];
|
|
|
|
const rockOffsets = new Map(inputs.rockOffsets);
|
|
const rockDefault = inputs.rockDefault;
|
|
// 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로
|
|
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
|
|
const cutSlopeRatios = new Map(inputs.cutSlopes);
|
|
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
|
// 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`
|
|
// (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467).
|
|
const bermSpans = inputs.bermSpans;
|
|
const bermDepthAt = bermDepthReader(new Map(inputs.bermDepths));
|
|
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
|
|
const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm);
|
|
return spec
|
|
? {
|
|
widthM: spec.width_m,
|
|
intervalM: spec.interval_m,
|
|
slopeDeg: spec.slope_deg,
|
|
depthM: spec.depth_m,
|
|
// 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분
|
|
depthLeftM: bermDepthAt(chainageM, design, "left"),
|
|
depthRightM: bermDepthAt(chainageM, design, "right"),
|
|
}
|
|
: null;
|
|
};
|
|
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
|
|
// (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]).
|
|
/** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */
|
|
const cutSlopeAt = (chainageM: number, design: Record<string, unknown>): number | null => {
|
|
const session = cutSlopeRatios.get(rockKey(chainageM));
|
|
if (session !== undefined) return session > 0 ? session : null;
|
|
const stored = design.cut_slope_ratio_user;
|
|
return typeof stored === "number" && stored > 0 ? stored : null;
|
|
};
|
|
const choices = new Map(inputs.choices);
|
|
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
|
|
// 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로
|
|
const shoulderExtends = new Map(inputs.shoulderExtends);
|
|
const extendAt = (chainageM: number, design: Record<string, unknown>, side: "left" | "right") => {
|
|
const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side];
|
|
const stored = design[`shoulder_extend_${side}_m`];
|
|
return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0);
|
|
};
|
|
|
|
// 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음)
|
|
// 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로
|
|
// (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계
|
|
const canon = new Map(inputs.canon);
|
|
const updated: number[] = [];
|
|
for (const section of detail.cross_sections) {
|
|
const previous = section.design;
|
|
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
|
|
const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
// 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버
|
|
// `recompute_designs_for_alignment` 의 우선순위와 같다.
|
|
const sessionOffset = rockOffsets.get(rockKey(section.chainage_m));
|
|
const storedOffset = design.rock_boundary_offset_m;
|
|
const rockBoundaryOffsetM =
|
|
typeof sessionOffset === "number"
|
|
? sessionOffset
|
|
: typeof storedOffset === "number"
|
|
? storedOffset
|
|
: rockDefault;
|
|
const choice = choiceAt(section.chainage_m);
|
|
const extendLeft = extendAt(section.chainage_m, design, "left");
|
|
const extendRight = extendAt(section.chainage_m, design, "right");
|
|
const elevation = planElevationAt(alignment, section.chainage_m);
|
|
const options: CrossDesignOptions = {
|
|
groundType:
|
|
choice?.ground_type ??
|
|
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
|
|
sectionMode:
|
|
choice?.section_mode ??
|
|
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
|
|
ditchSide:
|
|
choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null),
|
|
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
|
|
ditchType:
|
|
choice?.ditch_type ??
|
|
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
|
|
paved: pavedBySpecs(section),
|
|
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
|
|
tilted: Boolean(section.ford_pavement || section.underdrain),
|
|
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
|
standard,
|
|
berm: bermAt(section.chainage_m, design),
|
|
rockBoundaryOffsetM,
|
|
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
|
|
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
|
|
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
|
|
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
|
|
ditchChoice: choice
|
|
? (choice.ditch_choice ?? null)
|
|
: typeof design.ditch_choice === "boolean"
|
|
? design.ditch_choice
|
|
: null,
|
|
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
|
|
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
|
|
surfaceDropM:
|
|
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
|
|
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
|
|
planRadiusM: section.plan_radius_m ?? null,
|
|
curveOuterSide:
|
|
section.curve_outer_side === "left" || section.curve_outer_side === "right"
|
|
? section.curve_outer_side
|
|
: null,
|
|
curveWideningM: section.curve_widening_m ?? null,
|
|
shoulderExtendLeftM: extendLeft,
|
|
shoulderExtendRightM: extendRight,
|
|
};
|
|
// 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고
|
|
// 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐.
|
|
const samples = section.samples ?? [];
|
|
const fingerprint = `${standardKey}|${elevation}|${samples.length}|${grid ? "grid" : ""}|${JSON.stringify({ ...options, standard: null })}`;
|
|
const memo = memos?.get(section);
|
|
if (
|
|
memo?.samples === section.samples &&
|
|
memo.fingerprint === fingerprint &&
|
|
memo.design === previous
|
|
) {
|
|
continue;
|
|
}
|
|
let next;
|
|
try {
|
|
next = computeCrossDesign(samples, elevation, options);
|
|
// 미교차 넓힘 — 서버 [저장] `extend_unclosed_sections` 짝 · 넓힌 표본을 측점에 남김(428 · 묶음 cross 와 같음)
|
|
const wide =
|
|
grid && next.slope_unclosed
|
|
? extendUnclosed(
|
|
samples,
|
|
[section.center_x, section.center_y],
|
|
section.frame.left_xy,
|
|
(more) => computeCrossDesign(more as typeof samples, elevation, options),
|
|
grid,
|
|
)
|
|
: null;
|
|
if (wide && (wide.info.left_m || wide.info.right_m)) {
|
|
section.samples = wide.samples as typeof samples;
|
|
next = computeCrossDesign(section.samples, elevation, options);
|
|
}
|
|
} catch {
|
|
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
|
}
|
|
section.design = preserveUserFields(
|
|
next as unknown as NonNullable<CrossSection["design"]>,
|
|
previous,
|
|
);
|
|
section.design.shoulder_extend_left_m = extendLeft;
|
|
section.design.shoulder_extend_right_m = extendRight;
|
|
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이
|
|
// 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음)
|
|
for (const side of SIDES) {
|
|
const own = next[`berm_depth_${side}_m`];
|
|
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
|
|
else delete section.design[`berm_depth_${side}_m`];
|
|
}
|
|
if (next.berm) section.design.berm = next.berm;
|
|
else delete section.design.berm;
|
|
memos?.set(section, { samples: section.samples, fingerprint, design: section.design });
|
|
updated.push(section.chainage_m);
|
|
}
|
|
return updated;
|
|
}
|