Files
Aislo/B06_Section/B06_Section_UI_Page_Station_Controls.ts
T
eomsangdonandClaude Opus 5 de78feabde feat(B06): 다단 등간격 배치 버튼 + 유입 0도 접속선 + 유출 매몰 절토선·이동 금지
① 십자 우측 하단 ≡ 버튼 — 벽 사이 사면 구간을 같게 재배치(끝 성토부는 지형
   결정값이라 제외). 다단 연쇄(아랫단 바닥 하강) 때문에 1회 분할이 아니라
   구간 오차를 d로 되먹임하는 수렴 반복(≤8회, 허용 0.05m)으로 푼다.
② 유입 기슭막이 관 시작 접속선: 관 하단점이 원지반보다 위면 0도 성토선
   (지반 교차에서 정지), 아래면 0도 1m 후 표준 절토 경사(1:n)로 지반까지.
③ 유출 마지막 구조물 시작점이 원지반에 묻히면 0도 절토선을 지반 교차까지
   연장, 교차가 없는 자리는 이동 금지(배관 벽·추가 벽 공통 클램프).
- 배관 벽 4축 배치를 placePipeWall(Solve)로 추출 — Geom 700줄 유지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:29:43 +09:00

335 lines
14 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 { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
StationWidthControl,
} from "./B06_Section_UI_Cross_View";
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
export interface StationControlDeps {
sessionKey: (kind: "crossw" | "revetx" | "inletstruct" | "extrawall") => string | null;
refreshCard: (chainageM: number) => void;
detail: () => SectionDetailResponse | null;
crossHalfWidth: () => number | undefined;
sampledHalfWidth: () => number;
}
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
export interface StationControls {
stationWidth: StationWidthControl;
revetOffset: RevetOffsetControl;
inletStructure: InletStructureControl;
extraWalls: ExtraWallControl;
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);
},
};
/* ── 기슭막이 4축 조작값(2026-08-22 확정: 좌우 x·상하 d·높이 h·재질 m) ──
* 값은 세션에만 담는다 — 자동 자리가 지형·계획고를 따라 다시 풀리므로, 손으로
* 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`.
* 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */
const revetShifts = new Map<string, WallAdjust>();
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 | Partial<WallAdjust>>;
Object.entries(parsed).forEach(([entry, value]) => {
if (typeof value === "number" && Number.isFinite(value)) {
revetShifts.set(entry, { ...ZERO_ADJUST, x: value });
} else if (value && typeof value === "object") {
revetShifts.set(entry, { ...ZERO_ADJUST, ...value });
}
});
} 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 round1 = (value: number): number => Math.round(value * 10) / 10;
const clampMove = (value: number): number => Math.min(Math.max(round1(value), -10), 10);
const isDefaultAdjust = (value: WallAdjust): boolean =>
Math.abs(value.x) < 1e-9 && Math.abs(value.d) < 1e-9 && value.h == null && value.m == null;
const storeAdjust = (key: string, value: WallAdjust): void => {
if (isDefaultAdjust(value)) revetShifts.delete(key);
else revetShifts.set(key, value);
persistRevetShifts();
};
const sameAdjust = (a: WallAdjust, b: WallAdjust): boolean =>
Math.abs(a.x - b.x) < 1e-9 &&
Math.abs(a.d - b.d) < 1e-9 &&
(a.h ?? null) === (b.h ?? null) &&
(a.m ?? null) === (b.m ?? null);
const revetOffsetControl: RevetOffsetControl = {
adjustFor: (section, role) =>
revetShifts.get(revetKey(section.chainage_m, role)) ?? { ...ZERO_ADJUST },
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));
},
syncApplied: (chainageM, role, applied) => {
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지.
const key = revetKey(chainageM, role);
const stored = revetShifts.get(key) ?? { ...ZERO_ADJUST };
if (sameAdjust(stored, applied)) return;
storeAdjust(key, { ...applied });
},
update: (chainageM, role, patch) => {
const key = revetKey(chainageM, role);
const current = revetShifts.get(key) ?? { ...ZERO_ADJUST };
const next: WallAdjust = {
x: clampMove(patch.x ?? current.x),
d: clampMove(patch.d ?? current.d),
// 높이는 0.1 눈금 반올림만 — 재질 한계 절삭은 기하가 하고 되받는다.
h: patch.h === undefined ? current.h : patch.h === null ? null : round1(patch.h),
m: patch.m === undefined ? current.m : patch.m,
};
storeAdjust(key, next);
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 {
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
}
}
/* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ──
* 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */
const extraCounts = new Map<string, number>();
const extraSessionKey = (): string | null => deps.sessionKey("extrawall");
function loadExtraCounts(): void {
extraCounts.clear();
const key = extraSessionKey();
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, count]) => {
if (Number.isInteger(count) && count > 0) extraCounts.set(chainage, count);
});
} catch {
/* 손상된 세션 값은 무시 — 추가 벽 없음으로 재시작. */
}
}
function persistExtraCounts(): void {
const key = extraSessionKey();
if (!key) return;
try {
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraCounts)));
} catch {
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
}
}
/** 등간격 배치 1회성 요청(2026-08-22 ①) — 다음 카드 계산에서 소비된다. */
const pendingEqualize = new Set<string>();
const extraWallControl: ExtraWallControl = {
countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0,
equalize: (chainageM) => {
if ((extraCounts.get(chainageM.toFixed(2)) ?? 0) <= 0) return; // 단이 없으면 무의미
pendingEqualize.add(chainageM.toFixed(2));
deps.refreshCard(chainageM);
},
consumeEqualize: (section) => pendingEqualize.delete(section.chainage_m.toFixed(2)),
setCount: (chainageM, requested) => {
const key = chainageM.toFixed(2);
const previous = extraCounts.get(key) ?? 0;
const count = Math.max(0, Math.min(9, Math.round(requested)));
if (count === previous) return;
if (count === 0) extraCounts.delete(key);
else extraCounts.set(key, count);
// 줄어든 단의 이동량은 지운다 — 다시 늘리면 자동 자리에서 시작한다.
for (let i = count; i < previous; i += 1) {
revetShifts.delete(revetKey(chainageM, `extra${i}`));
}
persistRevetShifts();
persistExtraCounts();
deps.refreshCard(chainageM);
},
syncCount: (chainageM, built) => {
const key = chainageM.toFixed(2);
const count = extraCounts.get(key) ?? 0;
if (built >= count) return;
// 성토부가 없어져 못 세운 벽 — 개수·이동량을 세워진 만큼으로 자른다(고아 방지).
if (built <= 0) extraCounts.delete(key);
else extraCounts.set(key, built);
for (let i = built; i < count; i += 1) revetShifts.delete(revetKey(chainageM, `extra${i}`));
persistRevetShifts();
persistExtraCounts();
},
};
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,
extraWalls: extraWallControl,
widths: stationWidths,
load: () => {
loadStationWidths();
loadRevetShifts();
loadInletStructures();
loadExtraCounts();
},
applyGlobalWidth: (requested, chainages) => {
stationWidths.clear();
if (requested !== undefined) {
for (const chainageM of chainages) {
stationWidths.set(widthKey(chainageM), clampStationWidth(requested));
}
}
persistStationWidths();
},
};
}