Files
Aislo/B06_Section/B06_Section_UI_Page_Station_Controls.ts
T
eomsangdonandClaude Opus 5 8564e118d6 fix(B06): 조정 단위를 관 길이 1m로·벽 형상 불변·사면 역전 차단
지적 3건:
- 우측(유출) 기슭막이만 좌우로 밀 때 단면이 변했다. 0.1m씩 미니 관 길이 정수의
  끝수를 벽 폭이 흡수해 0.45→0.90까지 뚱뚱해졌다. 조정 단위를 **관 길이 1m**로
  바꾸고, 조정창으로 옮긴 벽은 폭을 절대 건드리지 않게 했다. 자리만 옮기면 정수
  길이가 성립하므로 형상이 그대로다(검증: 두께 0.450 고정, L 1m씩 증감).
- 성토 사면이 역전되는 자리가 있었다. 벽 상단이 노견보다 높아지면 사면선이 위로
  올라간다 — 최소 성토고 0.3m(FILL_MIN_RISE_M)를 두고 그 앞에서 멈춘다.
- 이동이 노견을 넘어 도로 반대편까지 갔다. 벽은 제 노견 바깥에만 서게 막았다.

조정창은 "관 길이 8m · 바깥 2.0m"처럼 관 길이를 같이 적는다.
700줄 유지를 위해 clampWallOffset·cutLength·outletSlopeFactory를 _Solve로 옮겼다.

검증: 이동 ±5m 20케이스 역전 0건·두께 고정, 275.71 유출 이동 11↔7m 정상,
계획고 스윕 24케이스 회귀 문제 0. tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:50:45 +09:00

180 lines
7.7 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Page_Station_Controls.ts
* 측점 단위 표시 제어 두 가지 — **개별 표시 반폭**(2026-08-06)과 **기슭막이 X 자리**
* (2026-08-21). 둘 다 세션에만 담고 카드 재렌더로 반영한다. 페이지 본체
* (`_UI_Page.ts`)에서 700줄 제한으로 분리했다.
* ========================================================================== */
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
import type { RevetOffsetControl, StationWidthControl } from "./B06_Section_UI_Cross_View";
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
export interface StationControlDeps {
sessionKey: (kind: "crossw" | "revetx") => string | null;
refreshCard: (chainageM: number) => void;
detail: () => SectionDetailResponse | null;
crossHalfWidth: () => number | undefined;
sampledHalfWidth: () => number;
}
/** 반폭·기슭막이 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
export interface StationControls {
stationWidth: StationWidthControl;
revetOffset: RevetOffsetControl;
widths: Map<string, number>;
load: () => void;
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
}
export function createStationControls(deps: StationControlDeps): StationControls {
/* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ──────────────────────
* 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때
* cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다.
* 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */
const stationWidths = new Map<string, number>();
const widthKey = (chainageM: number): string => chainageM.toFixed(2);
const widthSessionKey = (): string | null => deps.sessionKey("crossw");
function loadStationWidths(): void {
stationWidths.clear();
const key = widthSessionKey();
if (!key) return;
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, width]) => {
if (Number.isFinite(width) && width > 0) stationWidths.set(chainage, width);
});
} catch {
/* 손상된 세션 값은 무시 — 저장값·전역 반폭으로 재시작. */
}
}
function persistStationWidths(): void {
const key = widthSessionKey();
if (!key) return;
try {
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(stationWidths)));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
}
/** 개별 반폭 하한 2m·상한은 보유 샘플 폭 — 표시용이라 샘플 밖은 의미가 없다. */
const clampStationWidth = (value: number): number =>
Math.min(Math.max(value, 2), Math.max(deps.sampledHalfWidth(), 2));
const stationWidthControl: StationWidthControl = {
widthFor: (section) => {
const session = stationWidths.get(widthKey(section.chainage_m));
if (session !== undefined) return session;
const stored = section.design?.display_half_width_m;
return typeof stored === "number" && stored > 0 ? stored : undefined;
},
adjust: (chainageM, deltaM) => {
const key = widthKey(chainageM);
const section = deps
.detail()
?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01);
const stored = section?.design?.display_half_width_m;
const current =
stationWidths.get(key) ??
(typeof stored === "number" && stored > 0 ? stored : undefined) ??
deps.crossHalfWidth() ??
deps.sampledHalfWidth();
stationWidths.set(key, clampStationWidth(Math.round(current + deltaM)));
persistStationWidths();
deps.refreshCard(chainageM);
},
reset: (chainageM) => {
// 초기화 = 전역 반폭 복귀. 저장값(design)도 무시해야 하므로 세션에 전역값을 명시한다.
const globalWidth = deps.crossHalfWidth();
if (globalWidth === undefined) stationWidths.delete(widthKey(chainageM));
else stationWidths.set(widthKey(chainageM), clampStationWidth(globalWidth));
persistStationWidths();
deps.refreshCard(chainageM);
},
};
/* ── 기슭막이 X 자리(2026-08-21 사용자 ①) ────────────────────────────
* 벽을 눌러 고르고 카드 하단 ◀/▶로 0.1m씩 민다. 값은 세션에만 담는다 — 자동 자리가
* 지형·계획고를 따라 다시 풀리므로, 손으로 민 값은 그 세션의 표시 조정으로 본다.
* 키는 `누가거리:역할`. `select`는 다시 그리지 않고 값만 담는다(줌·팬 보존). */
const revetShifts = new Map<string, number>();
const revetSelected = new Map<string, RevetKey>();
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
const revetSessionKey = (): string | null => deps.sessionKey("revetx");
function loadRevetShifts(): void {
revetShifts.clear();
revetSelected.clear();
const key = revetSessionKey();
if (!key) return;
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([entry, shift]) => {
if (Number.isFinite(shift)) revetShifts.set(entry, shift);
});
} catch {
/* 손상된 세션 값은 무시 — 자동 자리로 재시작. */
}
}
function persistRevetShifts(): void {
const key = revetSessionKey();
if (!key) return;
try {
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(revetShifts)));
} catch {
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
}
}
/** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */
const clampRevetShift = (value: number): number =>
Math.min(Math.max(Math.round(value * 10) / 10, -10), 10);
const revetOffsetControl: RevetOffsetControl = {
shiftFor: (section, role) => revetShifts.get(revetKey(section.chainage_m, role)) ?? 0,
selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null,
select: (chainageM, key) => {
if (key) revetSelected.set(chainageM.toFixed(2), key);
else revetSelected.delete(chainageM.toFixed(2));
},
adjust: (chainageM, role, deltaM) => {
const key = revetKey(chainageM, role);
revetShifts.set(key, clampRevetShift((revetShifts.get(key) ?? 0) + deltaM));
persistRevetShifts();
deps.refreshCard(chainageM);
},
reset: (chainageM, role) => {
revetShifts.delete(revetKey(chainageM, role));
persistRevetShifts();
deps.refreshCard(chainageM);
},
};
return {
stationWidth: stationWidthControl,
revetOffset: revetOffsetControl,
widths: stationWidths,
load: () => {
loadStationWidths();
loadRevetShifts();
},
applyGlobalWidth: (requested, chainages) => {
stationWidths.clear();
if (requested !== undefined) {
for (const chainageM of chainages) {
stationWidths.set(widthKey(chainageM), clampStationWidth(requested));
}
}
persistStationWidths();
},
};
}