feat(B05,B06): 계획선 편집 중 횡단 재계산을 브라우저에서 수행 — 서버 왕복 제거

사용자 조작 중의 계산은 브라우저 안에서 끝나야 함(2026-09-03 사용자 확정). 계획고가
바뀔 때마다 전 측점 횡단 계산이 `POST …/cross-design/preview` 로 나가 왕복이 조작
속도를 지배했음.

- `refreshCrossDesigns` 를 로컬 계산으로 교체. 계획고는 저장 선형(`base_pvi`)에 편집
  델타를 얹어 TS `buildAlignment` 로 여기서 풂 — 호출부(B05·B06) 시그니처는 그대로라
  단일 창구가 유지됨. 서버 프리뷰는 선형 저장분이 없는 옛 데이터 폴백으로만 남음.
- 표준단면 config 기본값·암 경계 기본 오프셋을 `sections/context` 응답에서 세션에 기억
  (`rememberStandardDefaults`·`rememberRockBoundaryDefault`). 프론트에 config 수치를
  복제하지 않으면서 패널 없는 B05 도 같은 값으로 계산함.
- 디바운스 250 → 60ms — 왕복이 사라져 손을 떼는 즉시 유토곡선이 따라옴.
- 낡음 판정을 `hasStaleDesigns` 로 바꿔 첫 건에서 중단하고, 계획선 유효 샘플을
  샘플 배열당 한 번만 걸러 이진탐색(그리기 프레임당 2.33ms → 0.07ms 실측).
- 파이썬 엔진 2파일·프리뷰 라우터에 「TS 짝 파일과 한 벌」 경고 블록 추가.

검증: `tmp/tests/test_b06_cross_design_mirror.py` 10건 — 토사/암 2단계/L형 측구/포장/
세월교 하강/사면 미닫힘 등에서 파이썬과 TS 결과가 면적·설계선까지 일치. 전체 183건 통과.
`tsc --noEmit` 통과. 실측: route 127(측점 128) 전 측점 로컬 재계산 7.5ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 19:58:09 +09:00
co-authored by Claude Opus 5
parent de41958446
commit a35c8ff356
13 changed files with 353 additions and 94 deletions
+9
View File
@@ -30,6 +30,10 @@ import { createSelectionSync } from "./B05_Profile_UI_Selection";
import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations";
import {
rememberRockBoundaryDefault,
rememberStandardDefaults,
} from "../B06_Section/B06_Section_UI_Standard_Panel";
import {
fetchSectionContext,
type SectionDetailResponse,
@@ -632,6 +636,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
haulLimits: sectionContext.haul_equipment_limits,
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
});
// 표준횡단 config 기본값도 같은 방식으로 기억한다 — B05에는 「표준 횡단면 설정」
// 패널이 없지만, 계획선 편집 중 횡단 재계산이 브라우저에서 돌아 이 값이 필요하다
// (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다.
rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section);
rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m);
restorePanel(latestResponse);
renderLatest(latestResponse);
latest = latestResponse;
@@ -127,7 +127,7 @@ export interface RouteMassHaulDrawParams {
/**
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
* 「새 값만 보여주기」). Panel이 `staleDesignChainages`로 판정해 넘긴다.
* 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다.
*/
pendingRecalc?: boolean;
}
+7 -5
View File
@@ -24,7 +24,7 @@ import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
import { hasStaleDesigns } from "../B06_Section/B06_Section_UI_Section_Common";
import {
blocksMinCover,
findMinCoverViolations,
@@ -80,9 +80,11 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13;
const MIN_PANEL_HEIGHT = 180;
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms).
* ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */
const CROSS_PREVIEW_DEBOUNCE_MS = 250;
/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms).
* ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다.
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */
const CROSS_PREVIEW_DEBOUNCE_MS = 60;
/**
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
@@ -582,7 +584,7 @@ export function createRouteProfilePanel(
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
if (staleDesignChainages(nextDetail).length) scheduleCrossPreview();
if (hasStaleDesigns(nextDetail)) scheduleCrossPreview();
},
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
+2 -2
View File
@@ -12,7 +12,7 @@ import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "../B06_Section/B06_Section_UI_Longitudinal";
import { LONG_PAD, staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
import { hasStaleDesigns, LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import {
@@ -328,7 +328,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
crossSections: detail.cross_sections,
// 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 —
// Panel이 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
pendingRecalc: staleDesignChainages(detail).length > 0,
pendingRecalc: hasStaleDesigns(detail),
axis: {
maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
+133 -26
View File
@@ -2,24 +2,44 @@
* B06_Section_Cross_Refresh.ts
* 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다.
*
* ── 왜 필요한가 (2026-09-03 사용자 지시) ─────────────────────────────
* 유토곡선은 두 화면이 같은 데이터를 보여 주는 기능인데, 재계산 호출이 두 벌이라 값이
* 갈렸다. 실측 — 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` ↔ B05 `4,526.8㎥`.
* 원인은 인자였다:
* · B06: `previewCrossDesigns(..., standardPanel.getValues(), { fullDesigns, rockBoundaryOffsets })`
* · B05: `previewCrossDesigns(..., undefined, { fullDesigns })`
* 표준 단면값과 암 경계 오프셋이 빠지면 서버가 다른 설계를 그려 단면적이 달라진다.
* 보존하는 사용자 부속값 목록도 서로 달라(B05는 2개, B06은 7개) B05를 거치면 기슭막이
* 조정 같은 값이 사라졌다.
* ── 계산은 브라우저 안에서 끝난다 (2026-09-03 사용자 확정) ───────────
* 사용자 조작 중의 계산은 서버로 나가지 않는다. 조작은 세션 캐시에 쌓이고 화면은 즉시
* 따라오며, 영구저장소는 [저장]·[확정]에서만 건드린다. 예전에는 계획고가 바뀔 때마다
* `POST …/cross-design/preview` 로 전 측점 횡단을 서버에 물어, 왕복이 조작 속도를
* 지배했다(2026-09-03 사용자 보고: 「종단을 바꾸면 업데이트가 느리다」).
*
* 그래서 **입력 수집·서버 호출·제자리 반영**을 여기 한 곳으로 모은다. 세션 편집값은
* 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 B06과 같은 값을 보낸다.
* 그래서 설계 계산은 `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 { readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel";
import {
effectiveStandardCross,
readRockBoundaryDefault,
readStandardCrossSession,
} from "./B06_Section_UI_Standard_Panel";
import {
buildAlignment,
planElevationAt,
toAlignmentBase,
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { readAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Data";
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
export interface CrossRefreshEdits {
@@ -34,28 +54,39 @@ export interface CrossRefreshInput {
detail: SectionDetailResponse;
edits: CrossRefreshEdits;
/**
* 응답이 도착한 시점에 **아직 이 결과를 써도 되는지** 묻는다(false면 반영하지 않는다).
* 끌기 중에는 요청이 겹치므로, 늦게 온 옛 응답이 새 설계를 덮지 않게 하는 문지기다.
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
*/
shouldApply?: () => boolean;
}
/** 서버 설계로 갈아 끼워도 **살려 두는 사용자 부속값** — 화면 조작으로만 생기는 값이다. */
/** 다시 계산해도 **살려 두는 값** — 화면 조작으로만 생기거나 상태를 나르는 필드다. */
const PRESERVED_KEYS = [
"status",
"pavement_suggested",
"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",
] as const;
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
previous: CrossSection["design"],
): NonNullable<CrossSection["design"]> {
if (!previous) return next;
return {
...next,
inlet_structure: previous.inlet_structure,
basin_adjust: previous.basin_adjust,
revet_adjust: previous.revet_adjust,
extra_wall_counts: previous.extra_wall_counts,
extra_spans: previous.extra_spans,
revet_link_detached: previous.revet_link_detached,
revet_follow_grade: previous.revet_follow_grade,
};
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"]>;
}
/**
@@ -63,8 +94,84 @@ function preserveUserFields(
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
const local = refreshLocally(input);
if (local !== null) return local;
return refreshFromServer(input);
}
/**
* 브라우저 안에서 전 측점을 다시 계산한다(정상 경로).
*
* 계획고는 저장된 자동 선형(`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 ?? {},
});
const rockOffsets = readRockBoundarySession(projectId, input.routeId) ?? {};
const rockDefault = readRockBoundaryDefault(projectId);
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[section.chainage_m.toFixed(3)];
const storedOffset = design.rock_boundary_offset_m;
const rockBoundaryOffsetM =
typeof sessionOffset === "number"
? sessionOffset
: typeof storedOffset === "number"
? storedOffset
: rockDefault;
let next;
try {
next = computeCrossDesign(
section.samples ?? [],
planElevationAt(alignment, section.chainage_m),
{
groundType: typeof design.ground_type === "string" ? design.ground_type : "ripping_rock",
sectionMode: typeof design.section_mode === "string" ? design.section_mode : "left_cut",
ditchSide: typeof design.ditch_side === "string" ? design.ditch_side : null,
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
ditchType: typeof design.ditch_type === "string" ? design.ditch_type : "standard",
paved: Boolean(design.paved),
standard,
rockBoundaryOffsetM,
twoStageSlope:
design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope),
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다.
surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0,
},
);
} 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;
// full_designs — 설계선 좌표까지 받아야 3D 코리도·횡단도가 편집 즉시 같은 형상이 된다.
const response = await previewCrossDesigns(
projectId,
routeId,
+5
View File
@@ -1,5 +1,10 @@
"""B06 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리.
⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠
짝: `common_util/common_util_cross_design_areas.ts`
회귀 테스트: `tmp/tests/test_b06_cross_design_mirror.py` (고칠 때 같이 돌릴 것).
두 벌인 이유는 `B06_Section_Engine_Design.py` 머리 참조.
`_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이
넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로
설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다.
+12
View File
@@ -1,5 +1,17 @@
"""B06 측점 표준횡단 설계 계산 엔진.
⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠
짝: `common_util/common_util_cross_design.ts` (`computeCrossDesign`)
면적 적분은 `B06_Section_Engine_Areas.py` ↔ `common_util_cross_design_areas.ts`.
회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다:
`tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
왜 두 벌인가: 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야
한다**(2026-09-03 사용자 확정). 계획고가 바뀔 때마다 서버에 전 측점 횡단을 물으면
왕복이 조작 속도를 지배했다. 이 파이썬 엔진은 자동설계 체인·[저장]·[확정]·도면
산출의 **정본**이고, TS 미러는 조작 중 화면용이다. 값이 갈리면 화면과 납품물이
달라지므로 새 필드·상수·판정을 더할 때 양쪽을 함께 고칠 것.
지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단
설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가
버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서
+6 -1
View File
@@ -516,7 +516,12 @@ async def preview_cross_designs(
) -> CrossDesignPreviewResponse | JSONResponse:
"""계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음).
B05에서 계획고를 끄는 동안 횡단 단면적·유토곡선이 함께 움직이게 하는 프리뷰 경로다.
**평상시 조작 경로가 아니다**(2026-09-03 이후). 계획선을 만지는 동안의 재계산은
브라우저가 직접 한다(`common_util/common_util_cross_design.ts`) 조작 계산이
서버로 나가면 왕복이 조작 속도를 지배하기 때문이다(사용자 확정). 엔드포인트는
선형 저장분이 없어 브라우저가 계획고를 없는 ** 데이터 폴백**으로만 남는다
(`B06_Section_Cross_Refresh.refreshCrossDesigns`).
영속화는 페이지의 임시저장·확정이 맡는다.
"""
pool = get_db_pool()
+10 -4
View File
@@ -31,13 +31,17 @@ import {
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View";
import { staleDesignChainages } from "./B06_Section_UI_Section_Common";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
type PipeOptionsContext,
} from "./B06_Section_UI_Page_Pipe_Options";
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
import {
createStandardPanel,
rememberRockBoundaryDefault,
type StandardPanelController,
} from "./B06_Section_UI_Standard_Panel";
import {
createB06StructuresPanel,
isWallSelecting,
@@ -285,9 +289,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
*/
async function reconcileStaleDesigns(): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) return;
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 staleDesignChainages — 계획고 어긋남 +
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
if (!staleDesignChainages(sectionDetail).length) return;
if (!hasStaleDesigns(sectionDetail)) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
@@ -588,6 +592,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
// 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다.
rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m);
standardPanelSlot.append(standardPanel.root);
if (context.route_id === null) {
+76 -41
View File
@@ -78,11 +78,7 @@ export function validElevation(
}
/**
* chainage .
* , undefined를 .
*/
/**
* ** ** (B05·B06 ).
* ** ** (B05·B06 ).
*
* (`design.design_elevation_m`)
* . ( ·,
@@ -90,58 +86,97 @@ export function validElevation(
* ·3D가 (2026-08-23 :
* 2.21m ).
* .
*
* ** **
* 2.33ms (2026-09-03 ).
*/
export function staleDesignChainages(
export function hasStaleDesigns(
detail: {
longitudinal: { design_profiles?: DesignProfile[] };
cross_sections: Array<{
chainage_m: number;
design?: {
design_elevation_m: number;
geometry_preset?: string;
two_stage_slope?: boolean;
} | null;
}>;
cross_sections: StaleCandidate[];
},
toleranceM = 1e-3,
): number[] {
): boolean {
const profiles = detail.longitudinal.design_profiles;
return detail.cross_sections
.filter((section) => {
const design = section.design;
if (!design) return false;
// 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다.
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
if (!profiles?.length) return false;
const planned = designElevationAt(profiles, section.chainage_m);
return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM;
})
.map((section) => section.chainage_m);
return detail.cross_sections.some((section) => isStaleSection(section, profiles, toleranceM));
}
interface StaleCandidate {
chainage_m: number;
design?: {
design_elevation_m: number;
geometry_preset?: string;
two_stage_slope?: boolean;
} | null;
}
function isStaleSection(
section: StaleCandidate,
profiles: DesignProfile[] | undefined,
toleranceM: number,
): boolean {
const design = section.design;
if (!design) return false;
// 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다.
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
if (!profiles?.length) return false;
const planned = designElevationAt(profiles, section.chainage_m);
return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM;
}
/**
* ** ** .
*
* ( 128 × 2,159 ). `filter()`
* (2026-09-03 실측: 프레임당
* 2.33ms, ). .
*/
const validSamplesCache = new WeakMap<object, Array<{ chainage_m: number; elevation_m: number }>>();
function validPlanSamples(
designProfiles: DesignProfile[] | undefined,
): Array<{ chainage_m: number; elevation_m: number }> | undefined {
const samples = designProfiles?.[0]?.samples;
if (!samples?.length) return undefined;
const cached = validSamplesCache.get(samples);
if (cached) return cached.length ? cached : undefined;
const filtered = samples.filter((sample) => Number.isFinite(sample.elevation_m)) as Array<{
chainage_m: number;
elevation_m: number;
}>;
validSamplesCache.set(samples, filtered);
return filtered.length ? filtered : undefined;
}
/**
* chainage .
* , undefined를 .
*/
export function designElevationAt(
designProfiles: DesignProfile[] | undefined,
chainageM: number,
): number | undefined {
const samples = designProfiles?.[0]?.samples?.filter((sample) =>
Number.isFinite(sample.elevation_m),
);
if (!samples?.length) return undefined;
const samples = validPlanSamples(designProfiles);
if (!samples) return undefined;
if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m;
const last = samples[samples.length - 1];
if (chainageM >= last.chainage_m) return last.elevation_m;
for (let index = 1; index < samples.length; index += 1) {
const previous = samples[index - 1];
const current = samples[index];
if (chainageM > current.chainage_m) continue;
const span = current.chainage_m - previous.chainage_m;
if (span <= 0) return current.elevation_m;
const ratio = (chainageM - previous.chainage_m) / span;
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
// 누가거리 순 배열이라 이진탐색으로 구간을 고른다(선형 훑기와 결과는 같다).
let low = 1;
let high = samples.length - 1;
while (low < high) {
const mid = (low + high) >> 1;
if (samples[mid].chainage_m < chainageM) low = mid + 1;
else high = mid;
}
return last.elevation_m;
const previous = samples[low - 1];
const current = samples[low];
const span = current.chainage_m - previous.chainage_m;
if (span <= 0) return current.elevation_m;
const ratio = (chainageM - previous.chainage_m) / span;
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
}
/**
@@ -6,7 +6,7 @@
* . ·
* (`common_util_mass_haul*`) ** ** .
*
* (`staleDesignChainages`) B05와
* (`hasStaleDesigns`) B05와
* (2026-09-03 :
* ).
* .
@@ -30,7 +30,7 @@ import {
L,
longitudinalMaxChainage,
LONG_PAD,
staleDesignChainages,
hasStaleDesigns,
} from "./B06_Section_UI_Section_Common";
/** 유토곡선 Y축 눈금 — 가로 스크롤 고정 오버레이가 그대로 받는다. */
@@ -76,7 +76,7 @@ export interface MassHaulPanelResult {
*/
export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult {
const { detail, visibleSeries } = input;
const pendingRecalc = staleDesignChainages(detail).length > 0;
const pendingRecalc = hasStaleDesigns(detail);
const series: MassHaulSeries[] =
input.conversion && !pendingRecalc
? computeMassHaulSeries(
@@ -33,11 +33,76 @@ const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [
];
const SESSION_PREFIX = "b06:std-cross:";
/** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */
const DEFAULTS_PREFIX = "b06:std-cross-default:";
function sessionKey(projectId: string): string {
return `${SESSION_PREFIX}${projectId}`;
}
function defaultsKey(projectId: string): string {
return `${DEFAULTS_PREFIX}${projectId}`;
}
/**
* **config ** (`sections/context` ).
*
* config ,
* . ** **.
* B05도 (2026-09-03 ).
*/
export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void {
try {
window.sessionStorage.setItem(defaultsKey(projectId), JSON.stringify(defaults));
} catch {
/* 세션 저장 실패는 무시 — 계산은 편집값·저장분으로 이어 간다. */
}
}
/** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */
export function readStandardDefaults(projectId: string): StandardCrossSection | null {
try {
const raw = window.sessionStorage.getItem(defaultsKey(projectId));
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
} catch {
return null;
}
}
const ROCK_DEFAULT_PREFIX = "b06:rock-boundary-default:";
/**
* (config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`) .
* ** **
* .
*/
export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void {
try {
window.sessionStorage.setItem(`${ROCK_DEFAULT_PREFIX}${projectId}`, String(offsetM));
} catch {
/* 세션 저장 실패는 무시. */
}
}
/** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */
export function readRockBoundaryDefault(projectId: string): number | null {
try {
const raw = window.sessionStorage.getItem(`${ROCK_DEFAULT_PREFIX}${projectId}`);
const parsed = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
}
/**
* ** , config **.
* `_resolve_group(preset, standard)` "요청값 → config" .
*/
export function effectiveStandardCross(projectId: string): StandardCrossSection | null {
return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId);
}
/** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */
export function clearStandardCrossSession(projectId: string): void {
try {
@@ -174,6 +239,8 @@ export function createStandardPanel(
defaults: StandardCrossSection,
onApplyAll?: () => void | Promise<void>,
): StandardPanelController {
// 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다.
rememberStandardDefaults(projectId, defaults);
// 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다.
const sessionValue = readStandardCrossSession(projectId);
const hadSession = sessionValue !== null;
+22 -11
View File
@@ -193,20 +193,30 @@ function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefi
return "left";
}
/** 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프). */
/**
* : `_ground_interpolator`. (offset, ) ( ).
*
* ·
*
* . ** **( , ).
*/
function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number {
const last = valid.length - 1;
return (offsetM: number): number => {
if (offsetM <= valid[0][0]) return valid[0][1];
if (offsetM >= valid[valid.length - 1][0]) return valid[valid.length - 1][1];
for (let index = 1; index < valid.length; index += 1) {
const [x1, z1] = valid[index];
if (offsetM > x1) continue;
const [x0, z0] = valid[index - 1];
const span = x1 - x0;
if (span <= 0) return z1;
return z0 + (z1 - z0) * ((offsetM - x0) / span);
if (offsetM >= valid[last][0]) return valid[last][1];
let low = 1;
let high = last;
while (low < high) {
const mid = (low + high) >> 1;
if (valid[mid][0] < offsetM) low = mid + 1;
else high = mid;
}
return valid[valid.length - 1][1];
const [x1, z1] = valid[low];
const [x0, z0] = valid[low - 1];
const span = x1 - x0;
if (span <= 0) return z1;
return z0 + (z1 - z0) * ((offsetM - x0) / span);
};
}
@@ -652,7 +662,8 @@ export function computeCrossDesign(
// 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2).
let resolvedMode: string;
if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut";
else if (geometry.leftRole === "fill" && geometry.rightRole === "fill") resolvedMode = "both_fill";
else if (geometry.leftRole === "fill" && geometry.rightRole === "fill")
resolvedMode = "both_fill";
else if (geometry.leftRole === "cut") resolvedMode = "left_cut";
else resolvedMode = "right_cut";