Files
Aislo/B06_Section/B06_Section_UI_Cross_View_Structure.ts
T

325 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B06_Section_UI_Cross_View_Structure.ts
* 횡단 카드 ↔ **구조물 조정창** 배선 — 카드 렌더러(`_UI_Cross_View.ts`)에서 700줄
* 제한으로 분리했다(2026-08-24). 조정창이 부르는 동작을 제어기(기슭막이 4축·유입
* 구조물·다단·구간값·연동)로 넘기는 얇은 층이고, 기하나 그리기는 하지 않는다.
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert";
import {
FILL_SLOPE_RATIO_MIN,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_FORM_DEFAULT,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { clampAxis, type AxisDragSpec } from "./B06_Section_UI_Cross_Panel_Base";
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
import { showToast } from "@ui/ui_template_elements";
import { L } from "./B06_Section_UI_Section_Common";
import type {
ExtraWallControl,
InletStructureControl,
RevetLinkControl,
RevetOffsetControl,
SpanRole,
StructureSpanControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
/** 다단(추가) 벽 키인가 — 유출측 `extra0…`·유입측 `bextra0…`. */
export const isTierKey = (key: RevetKey): boolean =>
key.startsWith("extra") || key.startsWith("bextra");
/**
* 선택된 벽이 **기준벽 구간값**을 갖는 요소인지 — 유입·유출 기슭막이와 유입 집수정.
* 다단(extra·bextra)은 자기 단별 구간값을 따로 갖는다(`isTierKey` — 2026-08-29).
*/
export function spanRoleOf(key: RevetKey, inletIsBasin = false): SpanRole | null {
if (key === "inlet") return inletIsBasin ? "basin" : "inlet";
if (key === "outlet") return "outlet";
return null;
}
/**
* 조정창 배선에 필요한 카드 상태·제어기 묶음. 마지막 계산 결과(높이·재질·단 수 등)는
* 카드가 그릴 때마다 바뀌므로 **값이 아니라 읽는 함수**로 받는다.
*/
export interface StructurePanelContext {
section: CrossSection;
/**
* 4축·다단 조작이 실제로 실릴 측점의 누가거리. 연동 중인 링크 카드에서는 **소유
* 측점**이다 — 연동은 "본 기슭막이를 조절하면 같이 움직인다"는 뜻이므로 조작값이
* 한 곳에만 있어야 한다(2026-08-24 사용자). 연동을 풀면 이 카드 자신이 된다.
*/
adjustChainage: () => number;
/** 이 카드가 옆 측점에서 연장돼 온 링크 카드인가. */
isLinked: boolean;
revetOffset?: RevetOffsetControl;
inletStructure?: InletStructureControl;
extraWalls?: ExtraWallControl;
structureSpan?: StructureSpanControl;
revetLink?: RevetLinkControl;
adjustOf: (key: RevetKey) => WallAdjust;
/** 화면 좌(◀) 방향을 벽 기준 부호로 환산한다. */
outwardOf: (key: RevetKey) => number;
heightOfWall: (key: RevetKey) => number;
/** 지금 이 카드가 그린 벽 목록 — 연동 해제 때 높이를 굳히는 데 쓴다(2026-09-06). */
drawnWallKeys: () => RevetKey[];
formOfWall: (key: RevetKey) => string;
/** 마지막 계산의 실제 적용 d(상하) — d 미지정 벽의 ▲▼ 시작값. */
appliedD: () => Map<RevetKey, number>;
inletIsBasin: () => boolean;
/** 기준벽이 독립 기슭막이(관 없는 숨김 세트)인가 — 조정창 걸음 분류에 쓴다. */
hiddenPipe: () => boolean;
/** 세월교 · BOX암거 흙막이 세트인가(링크 카드는 소유 측점 세트 · 79b). */
bodyGuard?: () => boolean;
/** 세월교 흙막이 — 안이 관이라 관 보호공처럼 1 m 걸음(80). */
fordGuard?: () => boolean;
inletOptions: () => { revetAllowed: boolean; basinLUAllowed: boolean };
extraState: () => { canAdd: boolean; count: number };
basinExtraState: () => { canAdd: boolean; count: number };
activeRevet: () => RevetPick | null;
setActiveRevet: (key: RevetPick | null) => void;
toggleRevet: (key: RevetPick) => void;
/** 창 스크롤 자리를 기억할 카드 키(측점 id). */
scrollKey: string;
}
export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDeps {
return {
scrollKey: ctx.scrollKey,
adjustFor: ctx.adjustOf,
nudge: (key, screenDeltaM) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
x: ctx.adjustOf(key).x + screenDeltaM * ctx.outwardOf(key),
}),
// d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점
// 기준 절대값이라 시작값이 필요하다).
nudgeSlope: (key, deltaM) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
d: (ctx.adjustOf(key).d ?? ctx.appliedD().get(key) ?? 0) + deltaM,
}),
nudgeHeight: (key, deltaM) => {
const current = ctx.adjustOf(key);
const base = current.h ?? ctx.heightOfWall(key);
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
h: Math.max(WALL_MIN_PURE_HEIGHT_M, Math.round((base + deltaM) * 10) / 10),
});
},
formFor: (key) => ctx.formOfWall(key),
setForm: (key, form) => ctx.revetOffset?.update(ctx.adjustChainage(), key, { m: form }),
heightFor: (key) => ctx.heightOfWall(key),
reset: (key) => ctx.revetOffset?.reset(ctx.adjustChainage(), key),
close: () => {
const active = ctx.activeRevet();
if (active) ctx.toggleRevet(active);
},
structureFor: () => ctx.inletStructure?.valueFor(ctx.section) ?? "auto",
basinAdjustFor: () =>
ctx.inletStructure?.adjustFor(ctx.section) ?? {
innerWidthM: 1,
innerHeightM: 1.2,
lateralM: 0,
slopeM: 0,
},
updateBasin: (patch) =>
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
...ctx.inletStructure.adjustFor(ctx.section),
...patch,
}),
// 집수정 좌우는 **화면 기준**으로 받는다(2026-08-22 사용자 확정) — 유입이 우측인
// 측점에서 ◀가 화면 오른쪽으로 가던 반전을 없앤다. 노견 안쪽(음수)은 금지라
// 0에서 멈추고, 멈춘 사실은 토스트로 알린다(기슭막이와 같은 규칙).
nudgeBasinLateral: (screenDeltaM) => {
const current = ctx.inletStructure?.adjustFor(ctx.section);
if (!current) return;
const next = current.lateralM + screenDeltaM * ctx.outwardOf("inlet");
if (next < -1e-9) {
showToast(L("B06_Cross_Revet_Limit_Inward"), "info");
}
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
...current,
lateralM: Math.max(0, Math.round(next * 10) / 10),
});
},
resetBasin: () => ctx.inletStructure?.resetAdjust(ctx.adjustChainage()),
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(),
// 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽·
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
// 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2).
fineMove: (key) =>
(key !== "inlet" && key !== "outlet") ||
(ctx.hiddenPipe() && !ctx.fordGuard?.()) ||
(ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false),
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
extraState: () => ctx.extraState(),
isLinkedCard: () => ctx.isLinked,
basinExtraState: () => ctx.basinExtraState(),
hiddenPipe: () => ctx.hiddenPipe(),
bodyGuard: () => ctx.bodyGuard?.() ?? false,
setExtraCount: (count) => {
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
const active = ctx.activeRevet() ?? "";
if (
active.startsWith("extra") &&
!active.startsWith("bextra") &&
Number(active.slice(5)) >= count
) {
ctx.setActiveRevet("outlet");
ctx.revetOffset?.select(ctx.section.chainage_m, "outlet");
}
ctx.extraWalls?.setCount(ctx.adjustChainage(), count, "outlet");
},
setBasinExtraCount: (count) => {
const activeBasin = ctx.activeRevet() ?? "";
if (activeBasin.startsWith("bextra") && Number(activeBasin.slice(6)) >= count) {
ctx.setActiveRevet("inlet");
ctx.revetOffset?.select(ctx.section.chainage_m, "inlet");
}
ctx.extraWalls?.setCount(ctx.adjustChainage(), count, "basin");
},
equalizeExtras: () => ctx.extraWalls?.equalize(ctx.adjustChainage()),
// 구간값(길이·전·후). 기준벽은 배관 옵션 정본, **다단은 단별 세션 값**이다
// (2026-08-29 사용자 — 계곡부·능선부에서 단마다 연장이 달라 수동으로 넣는다).
// 링크 카드에서 만져도 제어기가 소유 측점 값을 고친다(원천은 하나).
spanFor: (key) => {
if (isTierKey(key)) return ctx.structureSpan?.tierValuesFor(ctx.section, key) ?? null;
const role = spanRoleOf(key, ctx.inletIsBasin());
return role && ctx.structureSpan ? ctx.structureSpan.valuesFor(ctx.section, role) : null;
},
setSpan: (key, patch) => {
if (isTierKey(key)) {
ctx.structureSpan?.updateTier(ctx.section, key, patch);
return;
}
const role = spanRoleOf(key, ctx.inletIsBasin());
if (role) ctx.structureSpan?.update(ctx.section, role, patch);
},
// 벽을 **그리지 않는 카드에는 [연동]을 내지 않는다**(2026-09-06). 링크 카드는 소유
// 측점의 벽을 빌려 그리는데, 빌릴 벽이 없는 카드에도 버튼이 떠 눌러도 아무 일이
// 없었다 — `revetlink` 에 `detached` 만 쌓였다(보조 창 실측: 0·20·40·60·80m 카드).
linkState: () =>
ctx.isLinked && ctx.drawnWallKeys().length
? { linked: ctx.revetLink?.linkedFor(ctx.section) ?? true }
: null,
// 푸는 순간 4축(x · d · h · m) 굳힘 · 다시 연동 = 지움 — 제어기 `setLinked` 한 길(139-2 ·
// 좌측 관 보호공 [연동]과 같음). 단 수 · 형식은 소유 측점을 그대로 따름(2026-08-30).
setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked),
followGrade: () =>
ctx.revetLink?.followGradeFor(ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section) ?? true,
setFollowGrade: (follow) => {
const owner = ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section;
ctx.revetLink?.setFollowGrade(owner, follow);
},
};
}
/** 끌기 한계 ±10 m — 제어기 `clampMove`(`_Page_Station_Controls`)와 같다. */
const DRAG_LIMIT_M = 10;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/**
* 벽 · 집수정 그림 끌기 두 축(108-2) — 축 뜻 · 한계 · 한 번 반영. 한계는 저장 · 기하가 거는 것 중
* 미리 알 수 있는 것(±10 m · 안쪽 금지 · 노면 위 금지 · 다단 선반 1.2·d) — 넘으면 가장 가까운 허용 자리.
* · 배관 기준벽 · 집수정 = ▲▼ 1:1.2 사면(`placePipeWall` · `_Culvert_Basin` 사면 이동)
* · 다단 · 독립 벽 = ▲▼ 순수 수직(`_Culvert_Extra` · `_Revetment` 축)
*/
function wallDragAxes(
ctx: StructurePanelContext,
key: RevetKey,
basin: boolean,
): Omit<AxisDragSpec, "stepM"> | null {
if (!ctx.revetOffset) return null;
const outward = ctx.outwardOf(basin ? "inlet" : key);
if (basin) {
const from = ctx.inletStructure?.adjustFor(ctx.section);
if (!from) return null;
const lateralMove = (left: number): number =>
clampAxis(from.lateralM, left * outward, 0, DRAG_LIMIT_M);
return {
slopeOutward: outward,
clamp: (left, down) => [
lateralMove(left) * outward,
clampAxis(from.slopeM, down, 0, DRAG_LIMIT_M),
],
apply: (left, down) =>
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
...from,
lateralM: round1(from.lateralM + left * outward),
slopeM: round1(from.slopeM + down),
}),
};
}
const adjust = ctx.adjustOf(key);
const d0 = adjust.d ?? ctx.appliedD().get(key) ?? 0;
const diagonal = key === "inlet" || key === "outlet";
// x 하한 — 배관 기준벽 = 안쪽 금지(흙막이는 본체 끝이 0점이라 안 막음 · 73-4) · 다단 = 선반 ≥ 0 ·
// 독립 = 성토선 각도(1.2·d − 기본 run)
const xLow = (d: number): number =>
isTierKey(key)
? Math.max(0, FILL_SLOPE_RATIO_MIN * d)
: key === "own"
? FILL_SLOPE_RATIO_MIN * d - PIPE_WALL_DEFAULT_RUN_M
: ctx.bodyGuard?.()
? -DRAG_LIMIT_M
: 0;
return {
slopeOutward: diagonal ? outward : null,
clamp: (left, down) => {
const dMove = clampAxis(d0, down, 0, DRAG_LIMIT_M);
const low = Math.max(xLow(d0 + dMove), -DRAG_LIMIT_M);
return [clampAxis(adjust.x, left * outward, low, DRAG_LIMIT_M) * outward, dMove];
},
apply: (left, down) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
x: round1(adjust.x + left * outward),
d: round1(d0 + down),
}),
};
}
/**
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
*/
export function culvertCardState(layout: CulvertLayout): {
inletOptions: CulvertLayout["inletOptions"];
extraState: { canAdd: boolean; count: number };
basinExtraState: { canAdd: boolean; count: number };
wallSpecs: Map<RevetKey, { height: number; form: string }>;
appliedD: Map<RevetKey, number>;
} {
const wallSpecs = new Map<RevetKey, { height: number; form: string }>();
const specOf = (wall: CulvertLayout["walls"][number], key: RevetKey): void => {
wallSpecs.set(key, {
// 조정창 높이 = 순수 높이(바닥~상단 = 계산용 + 근입 0.5). 추가 기슭막이도
// 같은 기준이다(2026-08-23 배관 벽 → 2026-08-29 추가 벽).
height: wall.height + REVET_EMBED_DEPTH_M,
form: wall.form ?? REVET_FORM_DEFAULT,
});
};
for (const wall of layout.walls) specOf(wall, wall.role as RevetKey);
layout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey));
layout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey));
const appliedD = new Map<RevetKey, number>();
appliedD.set("inlet", layout.revetShift.inlet.d ?? 0);
appliedD.set("outlet", layout.revetShift.outlet.d ?? 0);
layout.revetShift.extras.forEach((applied, i) =>
appliedD.set(`extra${i}` as RevetKey, applied.d ?? 0),
);
layout.revetShift.basinExtras.forEach((applied, i) =>
appliedD.set(`bextra${i}` as RevetKey, applied.d ?? 0),
);
return {
inletOptions: layout.inletOptions,
extraState: { canAdd: layout.outletFill.addable, count: layout.extraWalls.length },
basinExtraState: { canAdd: layout.basinFill.addable, count: layout.basinExtras.length },
wallSpecs,
appliedD,
};
}