- 관 판정면(벽 판정면 아래 — 겹친 자리는 벽) · 누르면 관 두 겹 is-active - 고름 전용 키 RevetPick(RevetKey | "pipe") — 조정 함수는 RevetKey 그대로 - 조정창 없음(show "pipe" = 숨김 · side "pipe") · 좌측 fieldset[data-spec-container="pipe"] 강조 + 스크롤 - 좌측 「관」 상자 칸 고침 = 관 고름 · B05 3D pipe 키 → B06 관 고름 - 107-1 벽 · 본체 동시 고름 금지 그대로 · 관 숨긴 카드(링크 · 세월교 · 박스)엔 판정면 없음 - 시험 test_118_pipe_pick(helper_118_pipe_pick.cjs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
333 lines
16 KiB
TypeScript
333 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* 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).
|
||
fineMove: (key) =>
|
||
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
|
||
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,
|
||
setLinked: (linked) => {
|
||
// 연동을 **푸는 순간** 지금 그려진 높이를 이 측점 값으로 굳힌다(2026-09-06 사용자
|
||
// 지시). 예전에는 위치(4축)만 갈리고 높이는 소유 측점 값을 계속 따라가, 소유
|
||
// 측점 높이를 바꾸면 푼 측점까지 같이 움직였다. 굳혀 두면 값이 튀지 않으면서
|
||
// 이후에는 따로 논다. 단 수·형태는 소유 측점을 그대로 따른다(2026-08-30 확정 유지).
|
||
if (!linked) {
|
||
ctx.drawnWallKeys().forEach((key) => {
|
||
if (ctx.adjustOf(key).h !== null && ctx.adjustOf(key).h !== undefined) return;
|
||
const height = ctx.heightOfWall(key);
|
||
if (height > 0) ctx.revetOffset?.update(ctx.section.chainage_m, key, { h: height });
|
||
});
|
||
}
|
||
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,
|
||
};
|
||
}
|