⚠⚠ 한 칸(`ditch_enabled`)에 **두 뜻**이 담겨 있었음. 결과(실제 섰나)를 그대로 다시 입력으로 넣어 읽었으므로 **한 번 저장되면 자동 판정이 영영 다시 안 돌았음.** 계획고를 내려 절토가 생겨도 측구가 안 서고 **아무 말도 안 나왔음.** - **선택** `ditch_choice` (없음 = 자동) · **결과** `ditch_enabled` (실제 섰나)로 가름 - 자동 판정을 **먼저** 하고 선택을 그 위에 얹음. 양성 단면은 선택보다 기하가 먼저 - ⚠ **옛 저장분은 자동값과 다를 때만 선택으로 살림** — 같으면 자동이 그렇게 냈던 것이고 다르면 사용자가 일부러 바꾼 것임. 설계 의도를 잃지 않으면서 굳은 값을 푼 것 (오늘 `length_m` 에서 겪은 「저장분이 걸린 자리」를 같은 방식으로 처리) - 화면 카드는 이제 **선택**을 읽어 상태를 세우고, 표시는 **결과**를 보임 - 파이썬·TS 짝 양쪽 + 호출부 여섯 곳(재계산·저장·확정·사토장·요청 스키마) 시험 다섯 추가 — 「결과를 다시 넣어도 자동이 계속 돎」이 그 잠금. 전체 616 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
364 lines
18 KiB
TypeScript
364 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_Cross_Refresh.ts
|
|
* 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다.
|
|
*
|
|
* ── 계산은 브라우저 안에서 끝난다 (2026-09-03 사용자 확정) ───────────
|
|
* 사용자 조작 중의 계산은 서버로 나가지 않는다. 조작은 세션 캐시에 쌓이고 화면은 즉시
|
|
* 따라오며, 영구저장소는 [저장]·[확정]에서만 건드린다. 예전에는 계획고가 바뀔 때마다
|
|
* `POST …/cross-design/preview` 로 전 측점 횡단을 서버에 물어, 왕복이 조작 속도를
|
|
* 지배했다(2026-09-03 사용자 보고: 「종단을 바꾸면 업데이트가 느리다」).
|
|
*
|
|
* 그래서 설계 계산은 `common_util_cross_design.ts`(파이썬 `B06_Section_Engine_Design.py`
|
|
* 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다.
|
|
* 서버 프리뷰는 선형 저장분이 없어 계획고를 못 푸는 **옛 데이터 폴백**으로만 남는다.
|
|
*
|
|
* ⚠ 두 벌 계산 주의 — TS 미러(`common_util_cross_design*.ts`)와 파이썬 엔진
|
|
* (`B06_Section_Engine_Design.py`·`B06_Section_Engine_Areas.py`)은 한 벌이다.
|
|
* 한쪽만 고치면 화면과 저장본이 갈린다. 회귀 테스트:
|
|
* `tmp/tests/test_b06_cross_design_mirror.py`
|
|
*
|
|
* ── 왜 창구가 하나여야 하는가 (2026-09-03 실측) ──────────────────────
|
|
* 재계산 호출이 두 벌이던 시절, 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥`
|
|
* ↔ B05 `4,526.8㎥` 로 갈렸다. 원인은 인자였다 — 표준 단면값과 암 경계 오프셋이 빠지면
|
|
* 서버가 다른 설계를 그린다. 입력 수집·계산·제자리 반영을 여기 한 곳에 모아 둔다. 세션
|
|
* 편집값은 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 같은 값을 쓴다.
|
|
* ========================================================================== */
|
|
|
|
import { computeCrossDesign } from "@util/common_util_cross_design";
|
|
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
|
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
|
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
|
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
|
import type { BermSpec } from "@util/common_util_cross_berm";
|
|
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
|
import {
|
|
effectiveStandardCross,
|
|
readRockBoundaryDefault,
|
|
readStandardCrossSession,
|
|
} from "./B06_Section_UI_Standard_Panel";
|
|
import {
|
|
buildAlignment,
|
|
planElevationAt,
|
|
toAlignmentBase,
|
|
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
|
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
|
import { readState } from "../A00_Common/b_page_state";
|
|
|
|
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
|
|
export interface CrossRefreshEdits {
|
|
station_offsets: Record<string, number>;
|
|
curve_radii: Record<string, number>;
|
|
}
|
|
|
|
export interface CrossRefreshInput {
|
|
projectId: string;
|
|
routeId: number;
|
|
/** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */
|
|
detail: SectionDetailResponse;
|
|
edits: CrossRefreshEdits;
|
|
/**
|
|
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
|
|
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
|
|
*/
|
|
shouldApply?: () => boolean;
|
|
}
|
|
|
|
/**
|
|
* 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로
|
|
* 실어 보낸다(`B06_Section_Section_Store`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS`
|
|
* 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`).
|
|
*
|
|
* 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을
|
|
* 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
|
|
*/
|
|
export const USER_TOUCHED_KEYS = [
|
|
"display_half_width_m",
|
|
"inlet_structure",
|
|
"basin_adjust",
|
|
"revet_adjust",
|
|
"ford_adjust",
|
|
"box_adjust",
|
|
"extra_wall_counts",
|
|
"extra_spans",
|
|
"revet_link_detached",
|
|
"revet_follow_grade",
|
|
// 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
|
|
"berm",
|
|
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
|
|
"cut_slope_ratio_user",
|
|
] as const;
|
|
|
|
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
|
// 사토장 구간값 — **브라우저가 못 만드는 값**이라 이어 붙인다. 구간 전체를 봐야 나오는
|
|
// 값이고(용량 배분) 정본은 [저장] 때 서버가 다시 낸다. 안 이으면 계획선을 만지는 순간
|
|
// 말풍선에서 「구간 용량 …」이 사라진다.
|
|
const SPOIL_KEYS = [
|
|
"spoil_fill_capacity_m3",
|
|
"spoil_fill_placed_m3",
|
|
"spoil_fill_unplaced_m3",
|
|
"spoil_fill_structure_id",
|
|
"spoil_fill_extra_distance_m",
|
|
] as const;
|
|
|
|
const PRESERVED_KEYS = [
|
|
"status",
|
|
"pavement_suggested",
|
|
...USER_TOUCHED_KEYS,
|
|
...SPOIL_KEYS,
|
|
] as const;
|
|
|
|
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"]>;
|
|
}
|
|
|
|
/**
|
|
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
|
|
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
|
|
*/
|
|
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
|
|
const local = refreshLocally(input);
|
|
if (local !== null) return local;
|
|
return refreshFromServer(input);
|
|
}
|
|
|
|
/**
|
|
* 암 경계 세션 오프셋을 **자릿수에 안 휘둘리게** 읽는다.
|
|
*
|
|
* 저장하는 쪽(`createRockBoundaryStore`)은 키를 `toFixed(2)` 로 쓰고, 서버는 받은 키를
|
|
* 숫자로 바꿔 비교했다. 로컬 계산이 문자열 키를 그대로 맞추려다 자릿수가 달라 세션값을
|
|
* 통째로 놓쳤고, 그래서 B06 을 다녀오기 전과 후의 절·성토가 달랐다(2026-09-03 실측
|
|
* 성토 16,715.5㎥ ↔ 16,690.7㎥). 키를 숫자로 되돌려 0.01m 단위로 맞춘다.
|
|
*/
|
|
function rockKey(chainageM: number): number {
|
|
return Math.round(chainageM * 100) / 100;
|
|
}
|
|
|
|
function readRockOffsets(projectId: string, routeId: number): Map<number, number> {
|
|
const raw = readRockBoundarySession(projectId, routeId) ?? {};
|
|
const offsets = new Map<number, number>();
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
const chainage = Number(key);
|
|
if (Number.isFinite(chainage) && typeof value === "number" && Number.isFinite(value)) {
|
|
offsets.set(rockKey(chainage), value);
|
|
}
|
|
}
|
|
return offsets;
|
|
}
|
|
|
|
/**
|
|
* 소단 구간을 **측점별 제원**으로 편다 — 서버는 측점키로 받기 때문이다.
|
|
* 구간 자체는 세션에 그대로 두어 「어디부터 어디까지 놓았나」를 잃지 않는다.
|
|
*/
|
|
function bermPayload(
|
|
projectId: string,
|
|
routeId: number,
|
|
detail: SectionDetailResponse,
|
|
): Record<string, { width_m: number; interval_m: number; slope_deg: number }> {
|
|
const spans = readBermSpans(projectId, routeId);
|
|
const out: Record<string, { width_m: number; interval_m: number; slope_deg: number }> = {};
|
|
if (!spans.length) return out;
|
|
for (const section of detail.cross_sections) {
|
|
const spec = bermSpecAt(spans, section.chainage_m);
|
|
if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다.
|
|
*
|
|
* **0 은 「표준값을 씀」**이다(되돌리기). 그래서 0 을 거르지 않고 그대로 싣는다 — 거르면
|
|
* 저장분에 남은 옛 사용자 값이 되살아난다.
|
|
*/
|
|
function readCutSlopeRatios(projectId: string, routeId: number): Map<number, number> {
|
|
const out = new Map<number, number>();
|
|
const raw = readState<Record<string, number>>("cutslope", projectId, routeId);
|
|
if (!raw) return out;
|
|
for (const [chainage, ratio] of Object.entries(raw)) {
|
|
const at = Number(chainage);
|
|
if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 브라우저 안에서 전 측점을 다시 계산한다(정상 경로).
|
|
*
|
|
* 계획고는 저장된 자동 선형(`profile_alignment.base_pvi`)에 편집 델타를 얹어 **여기서**
|
|
* 푼다 — B05 편집 중에는 `detail.longitudinal.design_profiles` 가 아직 옛 계획선이라
|
|
* 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면
|
|
* `null` 을 돌려 서버 폴백으로 넘긴다.
|
|
*/
|
|
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
|
const { projectId, detail, edits } = input;
|
|
const stored = readAlignment(detail.longitudinal);
|
|
if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
|
|
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
|
|
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
|
|
|
|
const alignment = buildAlignment(toAlignmentBase(stored), {
|
|
station_offsets: edits.station_offsets ?? {},
|
|
curve_radii: edits.curve_radii ?? {},
|
|
});
|
|
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
|
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
|
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
|
// 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다**
|
|
// — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다.
|
|
detail.longitudinal.design_profiles = [
|
|
toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]),
|
|
];
|
|
|
|
const rockOffsets = readRockOffsets(projectId, input.routeId);
|
|
const rockDefault = readRockBoundaryDefault(projectId);
|
|
// 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로
|
|
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
|
|
const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId);
|
|
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
|
// 순간 계단이 사라진다(계획서 3-9).
|
|
const bermSpans = readBermSpans(projectId, input.routeId);
|
|
const bermAt = (chainageM: number): BermSpec | null => {
|
|
const spec = bermSpecAt(bermSpans, chainageM);
|
|
return spec
|
|
? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg }
|
|
: 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 = crossDesignChoices(projectId, input.routeId);
|
|
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
|
|
|
|
const updated: number[] = [];
|
|
for (const section of detail.cross_sections) {
|
|
const previous = section.design;
|
|
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
|
|
const design = 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);
|
|
let next;
|
|
try {
|
|
next = computeCrossDesign(
|
|
section.samples ?? [],
|
|
planElevationAt(alignment, section.chainage_m),
|
|
{
|
|
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: choice?.paved ?? Boolean(design.paved),
|
|
standard,
|
|
berm: bermAt(section.chainage_m),
|
|
rockBoundaryOffsetM,
|
|
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
|
|
twoStageSlope:
|
|
choice?.two_stage_slope ??
|
|
(design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)),
|
|
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
|
|
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
|
|
ditchChoice: typeof design.ditch_choice === "boolean" ? design.ditch_choice : null,
|
|
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
|
|
// 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다.
|
|
surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_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,
|
|
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
|
|
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
|
|
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
|
|
spoilFill:
|
|
typeof design.spoil_fill_width_m === "number" &&
|
|
design.spoil_fill_width_m > 0 &&
|
|
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
|
|
? {
|
|
side: design.spoil_fill_side,
|
|
widthM: design.spoil_fill_width_m,
|
|
slopeRatioN: null,
|
|
}
|
|
: null,
|
|
},
|
|
);
|
|
} catch {
|
|
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
|
}
|
|
section.design = preserveUserFields(
|
|
next as unknown as NonNullable<CrossSection["design"]>,
|
|
previous,
|
|
);
|
|
updated.push(section.chainage_m);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
/** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */
|
|
async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
|
|
const { projectId, routeId, detail, edits, shouldApply } = input;
|
|
const response = await previewCrossDesigns(
|
|
projectId,
|
|
routeId,
|
|
edits,
|
|
readStandardCrossSession(projectId) ?? undefined,
|
|
{
|
|
fullDesigns: true,
|
|
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
|
|
berms: bermPayload(projectId, routeId, detail),
|
|
// 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만
|
|
// 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07).
|
|
cutSlopeRatios:
|
|
readState<Record<string, number>>("cutslope", projectId, routeId) ?? undefined,
|
|
},
|
|
);
|
|
if (shouldApply && !shouldApply()) return [];
|
|
const designByChainage = new Map(
|
|
response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
|
|
);
|
|
const updated: number[] = [];
|
|
for (const section of detail.cross_sections) {
|
|
const next = designByChainage.get(section.chainage_m.toFixed(3));
|
|
if (!next) continue;
|
|
section.design = preserveUserFields(
|
|
next as NonNullable<CrossSection["design"]>,
|
|
section.design,
|
|
);
|
|
updated.push(section.chainage_m);
|
|
}
|
|
return updated;
|
|
}
|