Files
Aislo/B06_Section/B06_Section_UI_Page_Station_Controls.ts
T
eomsangdonandClaude Opus 5 81bb505cc4 feat(B05·B06): 세월교 횡단 구체·조정창·3D 예상형상
B05 세월교 옵션에 BOX암거 날개벽 한 벌(설치·짧은쪽 높이 1m·길이 2m·각도 45°)을
그대로 붙이고, B06 횡단도에 세월교 구체를 그린다. 구체는 양측 ㄴ형 집수정 측벽 +
그 사이를 잇는 바닥판 + 바닥판 상면에 안힌 관 + 관 위 성토(노체) 채움이다.

- 바닥판 편측 연장 = 날개벽 길이 × cos(각도) — 각도는 관축 기준 벌어짐각
- 측벽은 buildBasin 재사용이라 1:0.3 기움·좌우·상하 대각 이동·계류측 성토부선이
  집수정과 같은 규칙으로 따라온다 (기슭막이 다단·재질과는 연계하지 않음)
- 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후 절반까지 붙는다
- 조정창: 측벽 높이·좌우·상하, 관경·수량(정본 pipe_points 되쓰기).
  높이·좌우·상하는 세션 + design.ford_adjust에 저장
- 3D 예상형상: 구체 부재는 월류 폭 구간 스윕, 관은 련수만큼 폭 안 등간격
- 부재 두께: 바닥판 0.3m(교본 물넘이 물받이 최소 30cm), 측벽 0.2m(집수정 승계)

700줄 제한으로 카드 렌더러·측점 제어기·페이지에서 배선을 분리했다
(_Cross_View_Ford, _Page_Ford_Controls, _Page_Patches, Culvert_Const 구간값 헬퍼).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:05:42 +09:00

706 lines
31 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Page_Station_Controls.ts
* 측점 단위 표시 제어 두 가지 — **개별 표시 반폭**(2026-08-06)과 **기슭막이 X 자리**
* (2026-08-21). 둘 다 세션에만 담고 카드 재렌더로 반영한다. 페이지 본체
* (`_UI_Page.ts`)에서 700줄 제한으로 분리했다.
* ========================================================================== */
import type { CrossDesign, CrossSection, 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,
RevetLinkControl,
RevetOffsetControl,
StationWidthControl,
StructureSpanControl,
} from "./B06_Section_UI_Cross_View";
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
import { culvertOwnerFor } from "./B06_Section_UI_Cross_Culvert_Wire";
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
import { createFordControls } from "./B06_Section_UI_Page_Ford_Controls";
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
export interface StationControlDeps {
sessionKey: (
kind:
| "crossw"
| "revetx"
| "inletstruct"
| "basinadjust"
| "extrawall"
| "revetlink"
| "fordadjust",
) => string | null;
refreshCard: (chainageM: number) => void;
detail: () => SectionDetailResponse | null;
crossHalfWidth: () => number | undefined;
sampledHalfWidth: () => number;
/**
* 요청 반폭까지 지반 샘플을 넓힌다(백엔드 재생성). 이미 충분하면 즉시 true,
* 재생성에 실패하면 false. 페이지가 구현한다(2026-08-23 사용자 지시).
*/
ensureSampledWidth: (widthM: number) => Promise<boolean>;
/** 구간값(길이·전/후)을 되돌려 쓸 대상 프로젝트. 없으면 저장하지 않는다. */
projectId: () => string | null;
/** 구간값 저장 실패 알림 — 화면 토스트는 페이지가 띄운다. */
onSaveError?: (message: string) => void;
}
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
export interface StationControls {
stationWidth: StationWidthControl;
revetOffset: RevetOffsetControl;
inletStructure: InletStructureControl;
extraWalls: ExtraWallControl;
structureSpan: StructureSpanControl;
revetLink: RevetLinkControl;
/** 세월교 측벽 조작(2026-08-25). */
ford: FordControl;
/** 확정 payload용 — 측점별 세월교 조작값. */
fordAdjustsByChainage: () => Map<number, FordAdjust>;
widths: Map<string, number>;
inletStructures: Map<string, InletStructureChoice>;
basinAdjustments: Map<string, BasinAdjust>;
/** 확정 payload용 — 측점별 기슭막이 4축 조작값(역할 → 값). */
revetAdjustsByChainage: () => Map<number, Record<string, WallAdjust>>;
/** 확정 payload용 — 측점별 다단 단 수. */
extraCountsByChainage: () => Map<number, { outlet: number; basin: number }>;
/** 확정 payload용 — 측점별 연동 해제·종단경사 반영(2026-08-24). */
linkFlagsByChainage: () => Map<number, { detached?: boolean; followGrade?: boolean }>;
/** 예약된 구간값 저장을 즉시 내보낸다 — 확정·임시저장 직전에 부른다. */
flushCulvertOptions: () => Promise<void>;
load: () => void;
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
}
export function createStationControls(deps: StationControlDeps): StationControls {
/* ── 조작값 캐시 write-through(2026-08-24 사용자 확정 데이터 흐름) ────────
* 영구저장소 → 브라우저 캐시 → 3D는 **캐시로 플롯**. 편집하면 캐시를 갱신하고 그
* 캐시로 다시 그린다. 그래서 조정창 조작값은 세션에만 담지 않고 그 자리에서
* `section.design`(B05·B06 공유 객체)에 실어야 한다 — 세션은 리로드 복원용 사본이고,
* 코리도 해시가 design을 읽으므로 캐시가 바뀌면 3D가 다시 빌드된다. */
const sectionAt = (chainageM: number): CrossSection | undefined =>
deps
.detail()
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01);
/** design이 있는 측점에만 얹는다 — 없는 측점은 아직 설계 전이라 실을 자리가 없다. */
const patchCachedDesign = (chainageM: number, patch: Partial<CrossDesign>): void => {
const design = sectionAt(chainageM)?.design;
if (design) Object.assign(design, patch);
};
/* ── 측점 개별 표시 반폭(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();
// 같은 값을 캐시(design.revet_adjust)에도 실어 3D가 바로 따라오게 한다.
const [chainage, role] = key.split(":");
const at = Number(chainage);
if (!Number.isFinite(at) || !role) return;
const design = sectionAt(at)?.design;
if (!design) return;
const bucket = { ...(design.revet_adjust ?? {}) };
if (isDefaultAdjust(value)) delete bucket[role];
else bucket[role] = { ...value };
design.revet_adjust = bucket;
};
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);
/** 그 측점과 **그 구조물이 덮는 측점**을 함께 다시 그린다 — 연동된 옆 측점이
* 같이 움직여야 한다(2026-08-24 사용자). 구조물이 없는 측점이면 자기 카드만. */
const refreshWithLinked = (chainageM: number): void => {
deps.refreshCard(chainageM);
const sections = deps.detail()?.cross_sections ?? [];
const owner = sections.find((s) => Math.abs(s.chainage_m - chainageM) < 0.01);
const spec = owner?.culvert;
if (!spec) return;
const reach = Math.max(
...[spec.inlet, spec.outlet].flatMap((side) => {
const span =
side?.structure === "집수정"
? CulvertConst.basinSpanOfSpec(side)
: CulvertConst.revetSpanOfSpec(side);
return [span.beforeM, span.afterM];
}),
);
for (const other of sections) {
if (other === owner) continue;
if (Math.abs(other.chainage_m - chainageM) <= reach + 1e-9)
deps.refreshCard(other.chainage_m);
}
};
const revetOffsetControl: RevetOffsetControl = {
// 세션 → 정본(design.revet_adjust) → 자동. 확정된 조작값이 재접근·3D에서 살아난다
// (2026-08-24 사용자: 3D는 종단·횡단 확정 뒤의 최종 산출물).
adjustFor: (section, role) =>
revetShifts.get(revetKey(section.chainage_m, role)) ??
(section.design?.revet_adjust?.[role]
? { ...ZERO_ADJUST, ...section.design.revet_adjust[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);
refreshWithLinked(chainageM);
},
reset: (chainageM, role) => {
revetShifts.delete(revetKey(chainageM, role));
persistRevetShifts();
refreshWithLinked(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)) ??
section.design?.extra_wall_counts?.[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();
patchCachedDesign(chainageM, {
extra_wall_counts: {
outlet: extraCounts.get(extraKey(chainageM, "outlet")) ?? 0,
basin: extraCounts.get(extraKey(chainageM, "basin")) ?? 0,
},
});
refreshWithLinked(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();
patchCachedDesign(chainageM, { inlet_structure: value });
refreshWithLinked(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();
patchCachedDesign(chainageM, { basin_adjust: basinAdjustments.get(key) });
refreshWithLinked(chainageM);
},
resetAdjust: (chainageM) => {
basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST });
persistBasinAdjustments();
patchCachedDesign(chainageM, { basin_adjust: { ...DEFAULT_BASIN_ADJUST } });
refreshWithLinked(chainageM);
},
};
/* ── 구조물 구간값(길이·전/후)과 연동 옵션 ─────────────────────────────
* 2026-08-24 사용자. 구간값의 원천은 배수관 옵션(`pipe_points`) 하나다 — 여기서는
* 캐시(detail의 culvert 스펙)를 고쳐 즉시 보여주고, 저장은 페이지가 예약해 늦게
* 한 번 내보낸다. 링크 측점에서 만져도 바뀌는 것은 **소유 측점** 값이다.
* 연동은 측점별(세션 → 정본 → 기본 켬), 종단경사 반영은 소유 측점에 하나. */
const culvertOptions = createCulvertOptionWriter(deps.projectId, deps.onSaveError);
const linkDetached = new Map<string, boolean>();
const followGrades = new Map<string, boolean>();
const chainageKey = (chainageM: number): string => chainageM.toFixed(2);
/** 두 체크박스는 한 세션 항목에 같이 담는다 — 확정 전 리로드에도 살아남는다. */
function loadLinkFlags(): void {
linkDetached.clear();
followGrades.clear();
const key = deps.sessionKey("revetlink");
if (!key) return;
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return;
const parsed = JSON.parse(raw) as {
detached?: Record<string, boolean>;
grade?: Record<string, boolean>;
};
Object.entries(parsed.detached ?? {}).forEach(([at, value]) => linkDetached.set(at, !!value));
Object.entries(parsed.grade ?? {}).forEach(([at, value]) => followGrades.set(at, !!value));
} catch {
/* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */
}
}
function persistLinkFlags(): void {
const key = deps.sessionKey("revetlink");
if (!key) return;
window.sessionStorage.setItem(
key,
JSON.stringify({
detached: Object.fromEntries(linkDetached),
grade: Object.fromEntries(followGrades),
}),
);
}
/** 이 카드가 구간값을 만질 대상 — 링크면 소유 측점, 아니면 자기 자신. */
const ownerOf = (section: CrossSection): CrossSection | null => {
if (section.culvert) return section;
const sections = deps.detail()?.cross_sections ?? [];
return culvertOwnerFor(section, sections) ?? null;
};
const structureSpanControl: StructureSpanControl = {
ownerOf,
valuesFor: (section, role) => {
const owner = ownerOf(section);
return owner ? CulvertConst.spanValuesOf(owner, role) : null;
},
update: (section, role, patch) => {
const owner = ownerOf(section);
if (!owner?.culvert) return;
const current = CulvertConst.spanValuesOf(owner, role);
if (!current) return;
// 길이를 바꾸면 **늘어난 몫만** 지금 비율대로 나눠 담는다 — 매번 총길이에서
// 비율로 다시 계산하면 0.1m 반올림이 쌓여 5.0/5.0이 5.6/5.4로 어긋난다
// (2026-08-24 화면 실측). 전·후를 바꾸면 길이는 둘의 합이다.
let { beforeM, afterM } = current;
if (patch.lengthM !== undefined) {
const total = Math.max(patch.lengthM, 0);
const delta = total - current.lengthM;
const ratio = current.lengthM > 1e-9 ? current.beforeM / current.lengthM : 0.5;
beforeM = Math.max(Math.round((current.beforeM + delta * ratio) * 10) / 10, 0);
afterM = Math.max(Math.round((total - beforeM) * 10) / 10, 0);
}
if (patch.beforeM !== undefined) beforeM = Math.max(patch.beforeM, 0);
if (patch.afterM !== undefined) afterM = Math.max(patch.afterM, 0);
const lengthM = Math.round((beforeM + afterM) * 10) / 10;
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
const keys = CulvertConst.SPAN_OPTION_KEYS[role];
if (role === "basin") {
spec.basin_length_m = lengthM;
spec.basin_before_m = beforeM;
spec.basin_after_m = afterM;
} else {
spec.revet_length_m = lengthM;
spec.revet_before_m = beforeM;
spec.revet_after_m = afterM;
}
culvertOptions.queue(owner.chainage_m, {
[keys.length]: lengthM,
[keys.before]: beforeM,
[keys.after]: afterM,
});
// 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과
// 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번
// 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러
// 2번만 반영).
const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM);
for (const other of deps.detail()?.cross_sections ?? []) {
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
deps.refreshCard(other.chainage_m);
}
}
},
};
const revetLinkControl: RevetLinkControl = {
linkedFor: (section) => {
const session = linkDetached.get(chainageKey(section.chainage_m));
if (session !== undefined) return !session;
return section.design?.revet_link_detached !== true;
},
setLinked: (chainageM, linked) => {
linkDetached.set(chainageKey(chainageM), !linked);
persistLinkFlags();
patchCachedDesign(chainageM, { revet_link_detached: !linked });
deps.refreshCard(chainageM);
},
followGradeFor: (section) => {
const session = followGrades.get(chainageKey(section.chainage_m));
if (session !== undefined) return session;
return section.design?.revet_follow_grade !== false;
},
setFollowGrade: (section, follow) => {
followGrades.set(chainageKey(section.chainage_m), follow);
persistLinkFlags();
patchCachedDesign(section.chainage_m, { revet_follow_grade: follow });
// 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그
// 연장 안의 측점만 다시 그린다.
const spec = section.culvert;
const reach = spec
? Math.max(
...[spec.inlet, spec.outlet].flatMap((side) => {
const span =
side?.structure === "집수정"
? CulvertConst.basinSpanOfSpec(side)
: CulvertConst.revetSpanOfSpec(side);
return [span.beforeM, span.afterM];
}),
)
: 0;
for (const other of deps.detail()?.cross_sections ?? []) {
if (Math.abs(other.chainage_m - section.chainage_m) <= reach + 1e-9) {
deps.refreshCard(other.chainage_m);
}
}
},
};
// 세월교 측벽 조작은 별도 모듈(700줄 제한) — 축·저장 흐름은 집수정과 같다.
const fordControls = createFordControls({
sessionKey: () => deps.sessionKey("fordadjust"),
sectionAt,
patchCachedDesign,
refreshCard: deps.refreshCard,
queuePipeOptions: culvertOptions.queue,
round1,
clampMove,
});
return {
stationWidth: stationWidthControl,
revetOffset: revetOffsetControl,
inletStructure: inletStructureControl,
extraWalls: extraWallControl,
structureSpan: structureSpanControl,
revetLink: revetLinkControl,
ford: fordControls.control,
fordAdjustsByChainage: fordControls.byChainage,
widths: stationWidths,
inletStructures,
basinAdjustments,
// 확정·임시저장 payload용 — 세션 키(누가거리:역할)를 측점별로 되접는다.
revetAdjustsByChainage: () => {
const result = new Map<number, Record<string, WallAdjust>>();
revetShifts.forEach((adjust, key) => {
const [chainage, role] = key.split(":");
const value = Number(chainage);
if (!Number.isFinite(value) || !role) return;
const bucket = result.get(value) ?? {};
bucket[role] = adjust;
result.set(value, bucket);
});
return result;
},
// 확정 payload용 — 측점별 연동 해제·종단경사 반영 플래그.
linkFlagsByChainage: () => {
const result = new Map<number, { detached?: boolean; followGrade?: boolean }>();
const put = (key: string, patch: { detached?: boolean; followGrade?: boolean }): void => {
const value = Number(key);
if (!Number.isFinite(value)) return;
result.set(value, { ...(result.get(value) ?? {}), ...patch });
};
linkDetached.forEach((detached, key) => put(key, { detached }));
followGrades.forEach((followGrade, key) => put(key, { followGrade }));
return result;
},
extraCountsByChainage: () => {
const result = new Map<number, { outlet: number; basin: number }>();
extraCounts.forEach((count, key) => {
const basin = key.startsWith("b");
const value = Number(basin ? key.slice(1) : key);
if (!Number.isFinite(value)) return;
const bucket = result.get(value) ?? { outlet: 0, basin: 0 };
bucket[basin ? "basin" : "outlet"] = count;
result.set(value, bucket);
});
return result;
},
flushCulvertOptions: () => culvertOptions.flush(),
load: () => {
loadStationWidths();
loadRevetShifts();
loadInletStructures();
loadBasinAdjustments();
loadExtraCounts();
loadLinkFlags();
fordControls.load();
},
applyGlobalWidth: (requested, chainages) => {
stationWidths.clear();
if (requested !== undefined) {
for (const chainageM of chainages) {
stationWidths.set(widthKey(chainageM), clampStationWidth(requested));
}
}
persistStationWidths();
},
};
}