- 조정창 십자 키패드 키보드 방향키 조작(집수정 9키는 십자 축만) - 배관 기슭막이 높이 = 순수 높이(바닥~상단), 하한·기본 = 관경+1.0m (고정 2.0m 폐기) - 매몰 시 전면 발끝 지반 추적 바닥 심화 삭제 — 바닥 = invert-0.5 고정 - 상하(d) 0점 = 성토선 0 지점(벽 상단=노견), 기본 자리 = 성토사면 경사길이 4m 지점 (d=null 자동, 저장·표시·정수맞춤 pinned 판정 연동, 구세션 d=0 호환) - 유입 접속 절토선: 지반 교차 없으면 1m 공간 확보에서 끝 - 자동 집수정 기본 형상 L형 -> I형 (선택지 밖 구조물 그려지던 불일치) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
406 lines
17 KiB
TypeScript
406 lines
17 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 { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
|
import type { BasinAdjust, 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" | "basinadjust" | "extrawall",
|
|
) => string | null;
|
|
refreshCard: (chainageM: number) => void;
|
|
detail: () => SectionDetailResponse | null;
|
|
crossHalfWidth: () => number | undefined;
|
|
sampledHalfWidth: () => number;
|
|
/**
|
|
* 요청 반폭까지 지반 샘플을 넓힌다(백엔드 재생성). 이미 충분하면 즉시 true,
|
|
* 재생성에 실패하면 false. 페이지가 구현한다(2026-08-23 사용자 지시).
|
|
*/
|
|
ensureSampledWidth: (widthM: number) => Promise<boolean>;
|
|
}
|
|
|
|
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
|
|
export interface StationControls {
|
|
stationWidth: StationWidthControl;
|
|
revetOffset: RevetOffsetControl;
|
|
inletStructure: InletStructureControl;
|
|
extraWalls: ExtraWallControl;
|
|
widths: Map<string, number>;
|
|
inletStructures: Map<string, InletStructureChoice>;
|
|
basinAdjustments: Map<string, BasinAdjust>;
|
|
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. **상한은 두지 않는다**(2026-08-23 개편) — 계산 반폭(20m)을
|
|
* 넘는 값은 `deps.ensureSampledWidth`가 백엔드 재생성으로 샘플을 넓힌 뒤 적용된다.
|
|
*/
|
|
const clampStationWidth = (value: number): number => Math.max(Math.round(value), 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;
|
|
},
|
|
request: (chainageM, widthM) => {
|
|
const width = clampStationWidth(widthM);
|
|
const apply = (): void => {
|
|
stationWidths.set(widthKey(chainageM), width);
|
|
persistStationWidths();
|
|
deps.refreshCard(chainageM);
|
|
};
|
|
// 보유 샘플 안이면 표시만 바꾸면 된다 — 캐시(계산 20m)를 그대로 쓴다.
|
|
if (width <= deps.sampledHalfWidth() + 1e-6) {
|
|
apply();
|
|
return;
|
|
}
|
|
// 샘플 밖 = 지반이 없는 폭. 백엔드에 그 폭으로 재계산을 맡기고, 끝나면 적용한다.
|
|
void deps.ensureSampledWidth(width).then((ready) => {
|
|
if (ready) apply();
|
|
});
|
|
},
|
|
};
|
|
|
|
/* ── 기슭막이 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") {
|
|
// 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로
|
|
// 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다.
|
|
revetShifts.set(entry, { ...ZERO_ADJUST, ...value, d: value.d ? value.d : null });
|
|
}
|
|
});
|
|
} 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 && value.d == null && 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 axisEq = (a: number | null, b: number | null): boolean =>
|
|
a == null || b == null ? a === b : Math.abs(a - b) < 1e-9;
|
|
const sameAdjust = (a: WallAdjust, b: WallAdjust): boolean =>
|
|
Math.abs(a.x - b.x) < 1e-9 &&
|
|
axisEq(a.d, b.d) &&
|
|
(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: null = 기본 자리(배관 벽 — 경사길이 4m 지점) 유지.
|
|
d: patch.d === undefined ? current.d : patch.d === null ? null : clampMove(patch.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 basinAdjustments = new Map<string, BasinAdjust>();
|
|
const structSessionKey = (): string | null => deps.sessionKey("inletstruct");
|
|
const basinSessionKey = (): string | null => deps.sessionKey("basinadjust");
|
|
|
|
function loadBasinAdjustments(): void {
|
|
basinAdjustments.clear();
|
|
const key = basinSessionKey();
|
|
if (!key) return;
|
|
try {
|
|
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
|
|
string,
|
|
BasinAdjust
|
|
>;
|
|
Object.entries(parsed).forEach(([chainage, value]) =>
|
|
basinAdjustments.set(chainage, { ...DEFAULT_BASIN_ADJUST, ...value }),
|
|
);
|
|
} catch {
|
|
/* 손상된 세션 값은 기본값으로 대체. */
|
|
}
|
|
}
|
|
|
|
function persistBasinAdjustments(): void {
|
|
const key = basinSessionKey();
|
|
if (key)
|
|
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(basinAdjustments)));
|
|
}
|
|
|
|
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>();
|
|
|
|
// 다단은 유출 성토부(outlet)·집수정 계류측(basin) 두 갈래라 키에 쪽을 담는다.
|
|
const extraKey = (chainageM: number, side: "outlet" | "basin"): string =>
|
|
side === "basin" ? `b${chainageM.toFixed(2)}` : chainageM.toFixed(2);
|
|
const extraPrefix = (side: "outlet" | "basin"): string => (side === "basin" ? "bextra" : "extra");
|
|
|
|
const extraWallControl: ExtraWallControl = {
|
|
countFor: (section, side = "outlet") =>
|
|
extraCounts.get(extraKey(section.chainage_m, side)) ?? 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, side = "outlet") => {
|
|
const key = extraKey(chainageM, side);
|
|
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, `${extraPrefix(side)}${i}` as RevetKey));
|
|
}
|
|
persistRevetShifts();
|
|
persistExtraCounts();
|
|
deps.refreshCard(chainageM);
|
|
},
|
|
syncCount: (chainageM, built, side = "outlet") => {
|
|
const key = extraKey(chainageM, side);
|
|
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, `${extraPrefix(side)}${i}` as RevetKey));
|
|
}
|
|
persistRevetShifts();
|
|
persistExtraCounts();
|
|
},
|
|
};
|
|
|
|
const inletStructureControl: InletStructureControl = {
|
|
valueFor: (section) =>
|
|
inletStructures.get(section.chainage_m.toFixed(2)) ??
|
|
section.design?.inlet_structure ??
|
|
"auto",
|
|
adjustFor: (section) =>
|
|
basinAdjustments.get(section.chainage_m.toFixed(2)) ?? {
|
|
...DEFAULT_BASIN_ADJUST,
|
|
...(section.design?.basin_adjust ?? {}),
|
|
},
|
|
set: (chainageM, value) => {
|
|
inletStructures.set(chainageM.toFixed(2), value);
|
|
persistInletStructures();
|
|
deps.refreshCard(chainageM);
|
|
},
|
|
updateAdjust: (chainageM, patch) => {
|
|
const key = chainageM.toFixed(2);
|
|
const current = basinAdjustments.get(key) ?? { ...DEFAULT_BASIN_ADJUST };
|
|
const next = { ...current, ...patch };
|
|
basinAdjustments.set(key, {
|
|
innerWidthM: Math.min(2, Math.max(1, round1(next.innerWidthM))),
|
|
innerHeightM: Math.min(2, Math.max(1, round1(next.innerHeightM))),
|
|
lateralM: Math.max(0, clampMove(next.lateralM)),
|
|
slopeM: Math.max(0, clampMove(next.slopeM)),
|
|
});
|
|
persistBasinAdjustments();
|
|
deps.refreshCard(chainageM);
|
|
},
|
|
resetAdjust: (chainageM) => {
|
|
basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST });
|
|
persistBasinAdjustments();
|
|
deps.refreshCard(chainageM);
|
|
},
|
|
};
|
|
|
|
return {
|
|
stationWidth: stationWidthControl,
|
|
revetOffset: revetOffsetControl,
|
|
inletStructure: inletStructureControl,
|
|
extraWalls: extraWallControl,
|
|
widths: stationWidths,
|
|
inletStructures,
|
|
basinAdjustments,
|
|
load: () => {
|
|
loadStationWidths();
|
|
loadRevetShifts();
|
|
loadInletStructures();
|
|
loadBasinAdjustments();
|
|
loadExtraCounts();
|
|
},
|
|
applyGlobalWidth: (requested, chainages) => {
|
|
stationWidths.clear();
|
|
if (requested !== undefined) {
|
|
for (const chainageM of chainages) {
|
|
stationWidths.set(widthKey(chainageM), clampStationWidth(requested));
|
|
}
|
|
}
|
|
persistStationWidths();
|
|
},
|
|
};
|
|
}
|