2026-08-22 사용자 요청 3건: - 관 끝선 = I형 벽의 안쪽(물받는 쪽) 변 복사(기슭막이와 동일 방식). 관 시작점(하단): ㄴ·ㄷ형은 측벽 안쪽 변×바닥 윗면 교차점, I형은 내공 1.0m 유지. - 집수정 부재(벽·바닥판) 두께 0.2m(BASIN_MEMBER_THICKNESS_M). - 유입 구조물 형식 드롭다운: 자동(규칙)/기슭막이+배관/집수정 I·ㄴ·ㄷ형. 집수정 클릭 선택 가능, 선택 시 조정창에 드롭다운(집수정은 ◀▶ 숨김). 세션 보관(inletstruct)·revet 선택 시 양측성토 로직 복귀·manual 사유. - 집수정 구성을 _Cross_Culvert_Basin.ts로 분리(700줄 제한 — Geom 690줄). I형 바닥판 제거(벽뿐 — 정의 정합). - 검증: 13+15.7 모듈(auto/revet/I/L/U 전부 기대 형상·관유입 자리 일치) + UI 실조작(집수정 클릭→드롭다운 revet/U/auto 전환, 부재 수 2→0→3→2, 세션 왕복) + 스크린샷 대조. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
236 lines
9.8 KiB
TypeScript
236 lines
9.8 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 { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
|
import type {
|
|
InletStructureControl,
|
|
RevetOffsetControl,
|
|
StationWidthControl,
|
|
} from "./B06_Section_UI_Cross_View";
|
|
|
|
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
|
|
export interface StationControlDeps {
|
|
sessionKey: (kind: "crossw" | "revetx" | "inletstruct") => string | null;
|
|
refreshCard: (chainageM: number) => void;
|
|
detail: () => SectionDetailResponse | null;
|
|
crossHalfWidth: () => number | undefined;
|
|
sampledHalfWidth: () => number;
|
|
}
|
|
|
|
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
|
|
export interface StationControls {
|
|
stationWidth: StationWidthControl;
|
|
revetOffset: RevetOffsetControl;
|
|
inletStructure: InletStructureControl;
|
|
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));
|
|
},
|
|
syncShift: (chainageM, role, appliedM) => {
|
|
const key = revetKey(chainageM, role);
|
|
const stored = revetShifts.get(key) ?? 0;
|
|
if (Math.abs(stored - appliedM) < 1e-9) return;
|
|
if (Math.abs(appliedM) < 1e-9) revetShifts.delete(key);
|
|
else revetShifts.set(key, appliedM);
|
|
persistRevetShifts();
|
|
},
|
|
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);
|
|
},
|
|
};
|
|
/* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ────────────────
|
|
* auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */
|
|
const inletStructures = new Map<string, InletStructureChoice>();
|
|
const structSessionKey = (): string | null => deps.sessionKey("inletstruct");
|
|
|
|
function loadInletStructures(): void {
|
|
inletStructures.clear();
|
|
const key = structSessionKey();
|
|
if (!key) return;
|
|
try {
|
|
const raw = window.sessionStorage.getItem(key);
|
|
if (!raw) return;
|
|
const parsed = JSON.parse(raw) as Record<string, InletStructureChoice>;
|
|
Object.entries(parsed).forEach(([chainage, value]) => {
|
|
if (["auto", "revet", "I", "L", "U"].includes(value)) inletStructures.set(chainage, value);
|
|
});
|
|
} catch {
|
|
/* 손상된 세션 값은 무시 — auto(규칙)로 재시작. */
|
|
}
|
|
}
|
|
|
|
function persistInletStructures(): void {
|
|
const key = structSessionKey();
|
|
if (!key) return;
|
|
try {
|
|
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(inletStructures)));
|
|
} catch {
|
|
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
|
}
|
|
}
|
|
|
|
const inletStructureControl: InletStructureControl = {
|
|
valueFor: (section) => inletStructures.get(section.chainage_m.toFixed(2)) ?? "auto",
|
|
set: (chainageM, value) => {
|
|
if (value === "auto") inletStructures.delete(chainageM.toFixed(2));
|
|
else inletStructures.set(chainageM.toFixed(2), value);
|
|
persistInletStructures();
|
|
deps.refreshCard(chainageM);
|
|
},
|
|
};
|
|
|
|
return {
|
|
stationWidth: stationWidthControl,
|
|
revetOffset: revetOffsetControl,
|
|
inletStructure: inletStructureControl,
|
|
widths: stationWidths,
|
|
load: () => {
|
|
loadStationWidths();
|
|
loadRevetShifts();
|
|
loadInletStructures();
|
|
},
|
|
applyGlobalWidth: (requested, chainages) => {
|
|
stationWidths.clear();
|
|
if (requested !== undefined) {
|
|
for (const chainageM of chainages) {
|
|
stationWidths.set(widthKey(chainageM), clampStationWidth(requested));
|
|
}
|
|
}
|
|
persistStationWidths();
|
|
},
|
|
};
|
|
}
|