사용자 보고: 기슭막이 위치(본·연동)를 바꿔도, 추가(다단) 기슭막이를 세워도 3D가 바뀌지 않는다. 형상은 성토선 > 본 기슭막이 > 추가 성토선 > 추가 기슭막이 순이어야 한다. 원인이 둘이었다. ① 조작값이 캐시에 안 실렸다 — 사용자 확정 흐름은 "영구저장소 → 브라우저 캐시 → 3D는 캐시로 플롯, 편집하면 캐시를 갱신하고 그 캐시로 다시 플롯"이다. 그런데 조정창 조작값(4축·다단 단 수·유입 구조물·집수정·연동 플래그)은 sessionStorage에만 있고 확정·임시저장 때만 design에 실렸다. 이제 편집 즉시 `section.design`(B05·B06이 공유하는 같은 객체)에 함께 기록한다. 세션은 리로드 복원용 사본으로 남는다. ② 기하 캐시가 낡았다 — `culvertLayoutOf`의 WeakMap이 측점 객체만 키로 써서, 캐시를 제자리 고치면 옛 기하가 그대로 살아남는다. 입력 요약 해시를 함께 담아 무효화한다. ③ 비탈 실루엣이 순서를 잃었다 — 수집한 점을 편거리로 정렬한 뒤 재추출했다. 기슭막이 앞면이 거의 수직이라 편거리 정렬은 순서를 보장하지 못하고, 다단 계단은 안팎을 오가 뒤섞인다. 유출측은 수집 순서를 그대로 잇고, 추가 성토선과 추가 기슭막이를 번갈아 밀어 계단 순서를 맞춘다. 유입측(집수정·절토)은 범위 밖이라 종전 정렬 유지. ④ 성토 조각 해상도 20 → 48열 — 호길이 균등 재추출이 수직면을 뭉갠다. 열을 꼭짓점에 맞춰 배분하면 측점마다 열 수가 달라 리본이 꼬이므로, 파라미터화는 그대로 두고 해상도로 해결했다. 절토는 20 유지(다단이 붙지 않는다). 성토 물매 불변: 이번 수정은 trimSlope·outletFill 점을 만들지 않는다 — 잇는 순서와 재추출 열 수만 바꿨고, 물매 산출 경로(layout.fillSlope.ratio)는 손대지 않았다. BUILD_VERSION 17 → 18(저장 코리도 만료). tsc/prettier 통과, pytest 196 passed(기존 실패 1건 유지). 작업이력·자체검증 기록: docs/raw/PLAN.md §4-10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
706 lines
31 KiB
TypeScript
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,
|
|
SpanRole,
|
|
SpanValues,
|
|
StationWidthControl,
|
|
StructureSpanControl,
|
|
} from "./B06_Section_UI_Cross_View";
|
|
import { basinSpanOfSpec, revetSpanOfSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
|
import { culvertOwnerFor } from "./B06_Section_UI_Cross_Culvert_Wire";
|
|
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
|
|
|
|
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
|
|
export interface StationControlDeps {
|
|
sessionKey: (
|
|
kind: "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall" | "revetlink",
|
|
) => 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;
|
|
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 === "집수정" ? basinSpanOfSpec(side) : 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 OPTION_KEYS: Record<SpanRole, { length: string; before: string; after: string }> = {
|
|
inlet: {
|
|
length: "inlet_revet_length_m",
|
|
before: "inlet_revet_before_m",
|
|
after: "inlet_revet_after_m",
|
|
},
|
|
outlet: {
|
|
length: "outlet_revet_length_m",
|
|
before: "outlet_revet_before_m",
|
|
after: "outlet_revet_after_m",
|
|
},
|
|
basin: {
|
|
length: "inlet_basin_length_m",
|
|
before: "inlet_basin_before_m",
|
|
after: "inlet_basin_after_m",
|
|
},
|
|
};
|
|
|
|
const spanValuesOf = (owner: CrossSection, role: SpanRole): SpanValues | null => {
|
|
const spec = role === "outlet" ? owner.culvert?.outlet : owner.culvert?.inlet;
|
|
if (!spec) return null;
|
|
const span = role === "basin" ? basinSpanOfSpec(spec) : revetSpanOfSpec(spec);
|
|
return {
|
|
lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10,
|
|
beforeM: Math.round(span.beforeM * 10) / 10,
|
|
afterM: Math.round(span.afterM * 10) / 10,
|
|
};
|
|
};
|
|
|
|
const structureSpanControl: StructureSpanControl = {
|
|
ownerOf,
|
|
valuesFor: (section, role) => {
|
|
const owner = ownerOf(section);
|
|
return owner ? spanValuesOf(owner, role) : null;
|
|
},
|
|
update: (section, role, patch) => {
|
|
const owner = ownerOf(section);
|
|
if (!owner?.culvert) return;
|
|
const current = 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 = 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 === "집수정" ? basinSpanOfSpec(side) : 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);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
return {
|
|
stationWidth: stationWidthControl,
|
|
revetOffset: revetOffsetControl,
|
|
inletStructure: inletStructureControl,
|
|
extraWalls: extraWallControl,
|
|
structureSpan: structureSpanControl,
|
|
revetLink: revetLinkControl,
|
|
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();
|
|
},
|
|
applyGlobalWidth: (requested, chainages) => {
|
|
stationWidths.clear();
|
|
if (requested !== undefined) {
|
|
for (const chainageM of chainages) {
|
|
stationWidths.set(widthKey(chainageM), clampStationWidth(requested));
|
|
}
|
|
}
|
|
persistStationWidths();
|
|
},
|
|
};
|
|
}
|