Files
Aislo/B06_Section/B06_Section_Cross_Refresh.ts
eomsangdonandClaude Opus 5.5 dd80662750 refactor(B05·B06): 옛 B06 계획선 편집 껍데기 정리 · 유토 뱃지 한 계산(PLAN 24-2a 덧붙임)
- B06_Section_UI_Page_Grade_Edit.ts 삭제 · 페이지 배선 · Section_View setGradeEdit · LegacyGradeEdit 제거
  (▲▼ 는 종단도 한 벌 · 계획고 가게는 노선마다 한 벌 — useProfileEditStore 도 걷음)
- 구조물 폐회로 면적을 재계산 창구(refreshCrossDesigns) 한 자리에서 얹음 — B05 · B06 유토 뱃지 같은 값
  (실측 전 B05 −14,144.5 · B06 −12,533.2 → 후 두 보기 −12,993.7 · B06 편집 뒤 −12,952.9)
- 시험: test_masshaul_single_source 에 한 자리 잠금 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-29 21:02:02 +09:00

377 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
/** 계획선 편집 델타 — 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 updated = refreshLocally(input) ?? (await refreshFromServer(input));
// 구조물이 선 측점은 폐회로 면적이 정본이다 — 설계를 갈아 끼운 **이 자리에서 한 번** 다시
// 얹는다. 부르는 쪽마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의 유토 뱃지가
// 갈렸다(계획서 24-2a · 실측 B05 −14,144.5 · B06 −12,533.2㎥).
if (updated.length) {
applyStructureAreaRows(
input.detail.cross_sections,
structureAreaRows(input.detail.cross_sections),
);
}
return updated;
}
/**
* 암 경계 세션 오프셋을 **자릿수에 안 휘둘리게** 읽는다.
*
* 저장하는 쪽(`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: choice
? (choice.ditch_choice ?? null)
: 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;
}