B05 는 관 목록 전체를 세션 스냅샷으로, B06 은 바뀐 것만 따로 담고 있어 담는 꼴이 둘이었음. 저장 순서를 맞춰 결과는 같게 했으나 자리가 둘인 한 같은 사고가 다시 남. B06 의 추가·삭제·이동을 모두 B05 가 쓰는 스냅샷(pipes)에 쓰도록 바꾸고, B06 전용 예약 경로(culvertedit 세션·queueAdd·queueRemove·queueMove)를 걷어 냄. 옛 세션에 남아 있던 이동 예약만 저장 때 한 번 흘려보내게 읽기를 남김. 이제 관을 넣고 빼고 옮기는 길이 좌측 폼·목록·종단 우클릭·측점선 끌기 모두 한 함수이고, 담기는 자리도 한 곳임. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
640 lines
30 KiB
TypeScript
640 lines
30 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,
|
|
StoredWallSpan,
|
|
} 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,
|
|
SpanValues,
|
|
StationWidthControl,
|
|
StructureSpanControl,
|
|
} from "./B06_Section_UI_Cross_View";
|
|
import { culvertOwnerFor, culvertReach, wallStandsAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
|
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
|
|
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
|
|
import { createLinkFlagSession, createSessionMap } from "./B06_Section_UI_Page_Link_Session";
|
|
import { createSpanControl } from "./B06_Section_UI_Page_Span_Control";
|
|
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
|
|
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
|
import type { BoxAdjust } from "./B06_Section_UI_Cross_Box";
|
|
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
|
|
|
|
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
|
|
export interface StationControlDeps {
|
|
sessionKey: (
|
|
kind:
|
|
| "crossw"
|
|
| "revetx"
|
|
| "inletstruct"
|
|
| "basinadjust"
|
|
| "extrawall"
|
|
| "extraspan"
|
|
| "culvertopt"
|
|
| "culvertmove"
|
|
| "revetlink"
|
|
| "fordadjust"
|
|
| "boxadjust",
|
|
) => 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;
|
|
/** BOX암거 구체 조작(2026-08-25). */
|
|
box: BoxControl;
|
|
/** 확정 payload용 — 측점별 세월교·BOX암거 조작값. */
|
|
fordAdjustsByChainage: () => Map<number, FordAdjust>;
|
|
boxAdjustsByChainage: () => Map<number, BoxAdjust>;
|
|
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 }>;
|
|
/** 다단 단별 구간값(2026-08-29) — 확정·임시저장 payload용. */
|
|
extraSpansByChainage: () => Map<number, Record<string, StoredWallSpan>>;
|
|
/** 예약된 구간값 저장을 즉시 내보낸다 — 확정·임시저장 직전에 부른다. */
|
|
flushCulvertOptions: () => Promise<void>;
|
|
/** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */
|
|
queueCulvertOptions: (chainageM: number, patch: Record<string, number | string>) => 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 widthSession = createSessionMap<number>(
|
|
() => deps.sessionKey("crossw"),
|
|
(value) =>
|
|
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined,
|
|
);
|
|
const stationWidths = widthSession.values;
|
|
const loadStationWidths = widthSession.load;
|
|
const persistStationWidths = widthSession.persist;
|
|
const widthKey = (chainageM: number): string => chainageM.toFixed(2);
|
|
|
|
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 revetSession = createSessionMap<WallAdjust>(
|
|
() => deps.sessionKey("revetx"),
|
|
(value) => {
|
|
// 구 형식(숫자 = x 이동량)도 읽어 준다.
|
|
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
|
|
if (!value || typeof value !== "object") return undefined;
|
|
// 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로
|
|
// 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다.
|
|
const partial = value as Partial<WallAdjust>;
|
|
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
|
|
},
|
|
);
|
|
const revetShifts = revetSession.values;
|
|
/** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편:
|
|
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */
|
|
let revetSelection: { at: string; key: RevetKey } | null = null;
|
|
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
|
|
|
|
function loadRevetShifts(): void {
|
|
revetSession.load();
|
|
revetSelection = null;
|
|
}
|
|
const persistRevetShifts = revetSession.persist;
|
|
|
|
/** 손으로 미는 범위 한계(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);
|
|
if (!owner) return;
|
|
// 연장은 그리기·링크 판정과 **같은 함수**로 잰다 — 다단 단별 구간값까지 본다.
|
|
const span = owner.culvert ? culvertReach(owner) : null;
|
|
if (!span) return;
|
|
const reach = Math.max(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 }),
|
|
storedAdjustFor: (section, role) => {
|
|
const session = revetShifts.get(revetKey(section.chainage_m, role));
|
|
if (session) return session;
|
|
const stored = section.design?.revet_adjust?.[role];
|
|
return stored ? { ...ZERO_ADJUST, ...stored } : null;
|
|
},
|
|
selectedFor: (section) =>
|
|
revetSelection?.at === section.chainage_m.toFixed(2) ? revetSelection.key : null,
|
|
// 강조는 **같은 구조물**을 그리는 카드 전부. 고른 카드의 소유 측점과 이 카드의
|
|
// 소유 측점이 같고, 그 벽이 이 측점에도 서면 켠다(2026-08-30 사용자).
|
|
highlightFor: (section) => {
|
|
if (!revetSelection) return null;
|
|
if (revetSelection.at === section.chainage_m.toFixed(2)) return revetSelection.key;
|
|
const picked = sectionAt(Number(revetSelection.at));
|
|
if (!picked) return null;
|
|
const owner = picked.culvert ? picked : ownerOf(picked);
|
|
const here = section.culvert ? section : ownerOf(section);
|
|
if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null;
|
|
return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null;
|
|
},
|
|
select: (chainageM, key) => {
|
|
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
|
|
revetOffsetControl.onSelectionChange?.();
|
|
},
|
|
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 basinSession = createSessionMap<BasinAdjust>(
|
|
() => deps.sessionKey("basinadjust"),
|
|
(value) =>
|
|
value && typeof value === "object"
|
|
? { ...DEFAULT_BASIN_ADJUST, ...(value as Partial<BasinAdjust>) }
|
|
: undefined,
|
|
);
|
|
const structSession = createSessionMap<InletStructureChoice>(
|
|
() => deps.sessionKey("inletstruct"),
|
|
(value) =>
|
|
typeof value === "string" && ["auto", "revet", "I", "L", "U"].includes(value)
|
|
? (value as InletStructureChoice)
|
|
: undefined,
|
|
);
|
|
const inletStructures = structSession.values;
|
|
const basinAdjustments = basinSession.values;
|
|
const loadBasinAdjustments = basinSession.load;
|
|
const persistBasinAdjustments = basinSession.persist;
|
|
const loadInletStructures = structSession.load;
|
|
const persistInletStructures = structSession.persist;
|
|
|
|
/* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ──
|
|
* 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */
|
|
const extraSession = createSessionMap<number>(
|
|
() => deps.sessionKey("extrawall"),
|
|
(value) => (Number.isInteger(value) && (value as number) > 0 ? (value as number) : undefined),
|
|
);
|
|
const extraCounts = extraSession.values;
|
|
const loadExtraCounts = extraSession.load;
|
|
const persistExtraCounts = extraSession.persist;
|
|
|
|
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));
|
|
extraSpans.delete(spanKeyOf(chainageM, `${extraPrefix(side)}${i}`));
|
|
}
|
|
persistRevetShifts();
|
|
persistExtraSpans();
|
|
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));
|
|
extraSpans.delete(spanKeyOf(chainageM, `${extraPrefix(side)}${i}`));
|
|
}
|
|
persistRevetShifts();
|
|
persistExtraSpans();
|
|
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.sessionKey("culvertopt"),
|
|
deps.onSaveError,
|
|
() => deps.sessionKey("culvertmove"),
|
|
);
|
|
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
|
|
const linkDetached = linkSession.detached;
|
|
const followGrades = linkSession.followGrade;
|
|
const chainageKey = (chainageM: number): string => chainageM.toFixed(2);
|
|
const loadLinkFlags = linkSession.load;
|
|
const persistLinkFlags = linkSession.persist;
|
|
|
|
/** 이 카드가 구간값을 만질 대상 — 링크면 소유 측점, 아니면 자기 자신. */
|
|
const ownerOf = (section: CrossSection): CrossSection | null => {
|
|
if (section.culvert) return section;
|
|
const sections = deps.detail()?.cross_sections ?? [];
|
|
return culvertOwnerFor(section, sections) ?? null;
|
|
};
|
|
|
|
/* ── 다단(추가) 기슭막이 **단별** 구간값(2026-08-29 사용자) ───────────────
|
|
* 기준벽 연장에 종속시키지 않는다 — 계곡부·능선부에서 아래 단일수록 연장이 달라져
|
|
* 자동 규칙으로 못 잡는다. 제어는 세션이고 [저장]·[확정] 때 정본으로 간다
|
|
* (4축 조작값과 같은 경로). 키는 `누가거리:벽키`(예 `234.10:extra0`). */
|
|
const extraSpanSession = createSessionMap<SpanValues>(
|
|
() => deps.sessionKey("extraspan"),
|
|
(value) => {
|
|
const span = value as SpanValues | null;
|
|
return span && Number.isFinite(span.beforeM) && Number.isFinite(span.afterM)
|
|
? span
|
|
: undefined;
|
|
},
|
|
);
|
|
const extraSpans = extraSpanSession.values;
|
|
const loadExtraSpans = extraSpanSession.load;
|
|
const persistExtraSpans = extraSpanSession.persist;
|
|
|
|
const spanControl = createSpanControl({
|
|
detail: () => deps.detail(),
|
|
refreshCard: (chainageM) => deps.refreshCard(chainageM),
|
|
ownerOf,
|
|
extraSpans,
|
|
persistExtraSpans,
|
|
patchCachedDesign,
|
|
culvertOptions,
|
|
});
|
|
const structureSpanControl = spanControl.control;
|
|
const spanKeyOf = spanControl.spanKeyOf;
|
|
|
|
/** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */
|
|
const followGradeOf = (section: CrossSection): boolean => {
|
|
const session = followGrades.get(chainageKey(section.chainage_m));
|
|
if (session !== undefined) return session;
|
|
return section.design?.revet_follow_grade !== false;
|
|
};
|
|
|
|
/**
|
|
* 연동 해제로 **그 측점에만** 붙어 있던 4축 값을 지운다 — 경사 해제(수평)로 넘어갈 때
|
|
* 쓴다(2026-08-30 사용자: 해제해 둔 값은 되살리지 않는다). 옛 D경로 독립 벽(`own`)은
|
|
* 이 구조물 것이 아니라 남긴다.
|
|
*/
|
|
const dropLinkedAdjusts = (chainageM: number): void => {
|
|
const prefix = `${chainageM.toFixed(2)}:`;
|
|
for (const key of [...revetShifts.keys()]) {
|
|
if (!key.startsWith(prefix) || key.slice(prefix.length) === "own") continue;
|
|
revetShifts.delete(key);
|
|
}
|
|
persistRevetShifts();
|
|
const design = sectionAt(chainageM)?.design;
|
|
if (!design?.revet_adjust) return;
|
|
const own = design.revet_adjust.own;
|
|
design.revet_adjust = own ? { own } : {};
|
|
};
|
|
|
|
const revetLinkControl: RevetLinkControl = {
|
|
linkedFor: (section) => {
|
|
// 경사 해제(수평)면 연동은 잠긴다 — 측점마다 자리를 따로 잡으면 기슭막이 Z를
|
|
// 하나로 묶을 수 없다(2026-08-30 사용자).
|
|
if (!followGradeOf(ownerOf(section) ?? section)) return true;
|
|
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: followGradeOf,
|
|
setFollowGrade: (section, follow) => {
|
|
followGrades.set(chainageKey(section.chainage_m), follow);
|
|
patchCachedDesign(section.chainage_m, { revet_follow_grade: follow });
|
|
// 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그
|
|
// 연장 안의 측점만 다시 그린다.
|
|
const span = section.culvert ? culvertReach(section) : null;
|
|
const reach = span ? Math.max(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) continue;
|
|
// 수평으로 넘어가면 연동 해제를 되돌리고 그 측점만의 자리도 지운다
|
|
// (2026-08-30 사용자: 경사를 다시 켜도 되살아나지 않는다).
|
|
if (!follow && Math.abs(other.chainage_m - section.chainage_m) > 0.01) {
|
|
linkDetached.set(chainageKey(other.chainage_m), false);
|
|
patchCachedDesign(other.chainage_m, { revet_link_detached: false });
|
|
dropLinkedAdjusts(other.chainage_m);
|
|
}
|
|
deps.refreshCard(other.chainage_m);
|
|
}
|
|
persistLinkFlags();
|
|
},
|
|
};
|
|
|
|
// 세월교·BOX암거 조작은 별도 모듈(700줄 제한) — 저장 흐름은 집수정과 같다.
|
|
const bodyControls = createBodyControls(
|
|
{
|
|
sectionAt,
|
|
patchCachedDesign,
|
|
refreshCard: deps.refreshCard,
|
|
queuePipeOptions: culvertOptions.queue,
|
|
round1,
|
|
clampMove,
|
|
},
|
|
deps.sessionKey,
|
|
);
|
|
|
|
return {
|
|
stationWidth: stationWidthControl,
|
|
revetOffset: revetOffsetControl,
|
|
inletStructure: inletStructureControl,
|
|
extraWalls: extraWallControl,
|
|
structureSpan: structureSpanControl,
|
|
revetLink: revetLinkControl,
|
|
ford: bodyControls.ford.control,
|
|
box: bodyControls.box.control,
|
|
fordAdjustsByChainage: bodyControls.ford.byChainage,
|
|
boxAdjustsByChainage: bodyControls.box.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;
|
|
},
|
|
extraSpansByChainage: () => {
|
|
const result = new Map<number, Record<string, StoredWallSpan>>();
|
|
extraSpans.forEach((value, key) => {
|
|
const [chainage, wall] = key.split(":");
|
|
const chainageM = Number(chainage);
|
|
if (!Number.isFinite(chainageM) || !wall) return;
|
|
const bucket = result.get(chainageM) ?? {};
|
|
bucket[wall] = { length_m: value.lengthM, before_m: value.beforeM, after_m: value.afterM };
|
|
result.set(chainageM, bucket);
|
|
});
|
|
return result;
|
|
},
|
|
flushCulvertOptions: () => culvertOptions.flush(),
|
|
queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch),
|
|
load: () => {
|
|
loadStationWidths();
|
|
loadRevetShifts();
|
|
loadInletStructures();
|
|
loadBasinAdjustments();
|
|
loadExtraCounts();
|
|
loadExtraSpans();
|
|
loadLinkFlags();
|
|
bodyControls.ford.load();
|
|
bodyControls.box.load();
|
|
},
|
|
applyGlobalWidth: (requested, chainages) => {
|
|
stationWidths.clear();
|
|
if (requested !== undefined) {
|
|
for (const chainageM of chainages) {
|
|
stationWidths.set(widthKey(chainageM), clampStationWidth(requested));
|
|
}
|
|
}
|
|
persistStationWidths();
|
|
},
|
|
};
|
|
}
|