Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert.ts
T
eomsangdonandClaude Opus 5 cd8d14f105 feat(B05·B06): 기슭막이 구간값 조정창 · 연동 기슭막이 옵션 · 가림 구간 선 정리
기슭막이 범위(길이·기준측점 전/후)를 B06 횡단도 조정창에서 지정하게 하고, 그
범위가 덮는 옆 측점의 기슭막이를 "연동" 파생물로 다루도록 체계를 세웠다.
값의 원천은 배수관 옵션(pipe_points) 하나다 — 같은 숫자를 B06 정본에 따로 담으면
B05 배수관 카드와 갈려 어느 쪽이 참인지 알 수 없다.

① 집수정 전/후 필드 신설 — inlet_basin_before_m·after_m(기본 각 1m), 길이 2m 유지.
   종방향 구간 계산을 revetSpanOfSpec·basinSpanOfSpec 한 곳으로 모아 2D 링크 판정과
   3D 스윕이 같은 값을 쓰게 했다.
② B06 조정창에 길이·전·후 행 — 기슭막이(유입·유출)·집수정 모두. 캐시를 고쳐 즉시
   보여주고 저장은 조작이 멎으면 한 번만 나간다(세부유역 재계산이 매번 돌지 않게).
   길이를 바꾸면 늘어난 몫만 지금 비율대로 나눠 담는다 — 총길이에서 비율로 다시
   계산하면 0.1m 반올림이 쌓여 5.0/5.0이 5.6/5.4로 어긋난다.
③ 링크 카드 선택 허용 — "소유 측점 통째 복사"에서 "위치 4축만 물려받고 원지반
   접합은 이 측점 지형으로 재계산"으로 바꿨다. 구조물 형식은 언제나 소유 측점 값이다.
   손대기 전에도 어긋나지 않게 소유 측점이 실제로 적용한 4축(revetShift)을 명시값으로
   물려준다.
④ 연동(측점별)·경사(전체 공통) 토글 — 연동 조작은 소유 측점 키에 실리고 그 연장이
   덮는 측점을 함께 다시 그린다. 경사는 3D 전용 스위치다(끄면 스윕 프레임 dz=0).
   횡단도까지 -dz로 밀면 노견에서 출발하는 성토선까지 내려가 노견과 벌어진다.
⑤ 가림 구간 선 정리 — 유입부 접속선과 유출부 접지 후 절토선을 2D·3D 모두에서
   빼되 기하는 남긴다. 면적·수량 계산은 건드리지 않았다(전면 개편 대상).
⑥ 집수정은 소유 측점 횡단도 하나에만 — 링크 카드에서 집수정·계류측 다단을 뺀다.
⑦ 3D 구간 겹침 정리 — 측점 간격이 구조물 연장보다 좁으면 두 구간 제어점이 번갈아
   서서 짧은 쪽(집수정 2m)이 잘려 보였다. 두 소유 측점의 중간에서 끊는다.
⑧ 조정창 정리 — 십자·9키를 맨 아래로, 창을 위쪽 기준 오버레이로(낮은 횡단도에서
   제목이 잘리던 문제), 연동·경사를 1행 토글 버튼으로, 스크롤 자리 유지.

파일 분리: B06_Section_UI_Cross_View_Structure.ts(조정창 배선),
B06_Section_Api_Culvert_Options.ts(구간값 저장) — 700줄 제한.
BUILD_VERSION 14 → 17(저장 코리도 만료).
tsc/ruff/prettier 통과, pytest 196 passed(기존 실패 1건 유지).
작업이력·자체검증 기록: docs/raw/PLAN.md §4-9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:26:35 +09:00

409 lines
20 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Culvert.ts
* 배수관 측점 횡단 카드의 배수관 세트(배관·기슭막이·보호공) **오버레이 그리기**.
*
* 기하 계산은 `B06_Section_UI_Cross_Culvert_Geom.ts`가 맡는다(700줄 제한 분리).
* 여기서는 계산 결과(`CulvertLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지.
*
* 그리는 순서 = 시공·계산 순서(2026-08-20 사용자 확정):
* ① 유출구측 구조물(기슭막이·추가 기슭막이) → ② 유입구측 구조물(기슭막이 또는
* 집수정) → ③ 배관 → ④ 유출구측 성토부선(보호공 삭제 — 2026-08-22)
* ========================================================================== */
import {
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
pipeWallThicknessM,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Geom";
import type {
BasinShape,
CulvertLayout,
PipeEnd,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Geom";
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
// 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다.
export { MIN_PIPE_COVER_M, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert_Geom";
export type {
BasinLayout,
BasinShape,
CulvertDesignTrim,
CulvertLayout,
EndFace,
InletStructureChoice,
} from "./B06_Section_UI_Cross_Culvert_Geom";
const SVG_NS = "http://www.w3.org/2000/svg";
function polygon(
points: Array<[number, number]>,
className: string,
tooltip: string,
): SVGPolygonElement {
const shape = document.createElementNS(SVG_NS, "polygon");
shape.setAttribute("points", points.map(([px, py]) => `${px},${py}`).join(" "));
shape.setAttribute("class", className);
const title = document.createElementNS(SVG_NS, "title");
title.textContent = tooltip;
shape.append(title);
return shape;
}
/** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. 추가 벽은 `extra{n}`. */
export type RevetKey = "inlet" | "outlet" | `extra${number}` | `bextra${number}`;
/** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */
export type RevetHighlightSetter = (key: RevetKey | null) => void;
/**
* 배수관 세트 오버레이 — computeCulvertLayout 결과를 그린다.
*
* `onSelectRevet`이 오면 기슭막이 폴리곤이 **선택 대상**이 된다(2026-08-21 사용자 ①).
* 클릭은 여기서 멈춘다 — 밑에 깔린 절·성토 밴드와 카드 선택으로 번지면 강조가 다른
* 것으로 바뀌거나 카드가 다시 그려진다(**구조물 선택이 면적 선택보다 우선**).
*/
export function appendCulvertOverlay(
layer: SVGElement,
layout: CulvertLayout,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
onSelectRevet?: (key: RevetKey) => void,
/** 관을 그리지 않는다 — 관은 한 측점에만 있고, 연장이 넘어온 옆 측점 카드에는
* 기슭막이만 링크로 보인다(2026-08-24 사용자). */
hidePipe?: boolean,
): RevetHighlightSetter {
const { culvert, pipe, pipeCorners } = layout;
const diameter = culvert.diameter_m;
const roleLabel = (role: "inlet" | "outlet" | "extra") =>
role === "inlet" ? "유입" : role === "outlet" ? "유출" : "추가(성토부)";
// ④ 유출구측 성토부선(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로).
// 구간별 폴리라인: 관 하단 꼭짓점(또는 앞 추가 벽 전면 상단) → 다음 벽/원지반.
const planLine = (points: (typeof pipe.inlet)[], tooltip: string): void => {
if (points.length < 2) return;
const line = document.createElementNS(SVG_NS, "polyline");
line.setAttribute(
"points",
points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
);
// 성토·절토 접속선은 공사 계획선 — 설계선과 같은 보라 실선.
line.setAttribute("class", "b06-chart__design-cross");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = tooltip;
line.append(title);
layer.append(line);
};
const drawOutletFill = (): void => {
for (const segment of layout.outletFill.segments) {
// 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이
// 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은
// 건드리지 않는다(그림만 — 면적은 전면 개편 대상).
if (segment.kind === "cut") continue;
planLine(
segment.points,
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
: " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` +
(layout.fillSlope.roadWideningM > 0.01
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m`
: "") +
(layout.fillSlope.structureRequired
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
: ""),
);
}
// 집수정(ㄴ·ㄷ형) 계류측 성토부선 — 유출측과 같은 체계(2026-08-22 사용자 확정).
for (const segment of layout.basinFill.segments) {
planLine(
segment.points,
segment.kind === "cut"
? `집수정 계류측 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
: `집수정 계류측 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
: " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
);
}
// 유입 기슭막이 **이후**의 절·성토선(옛 관 시작 접속선 — 2026-08-22 ②)은 그리지
// 않는다(2026-08-24 사용자). 기하(`layout.inletFill`)는 남겨 둔다 — 3D 트림과
// 면적 개편이 그 값을 쓴다. 여기서는 도면에 얹지 않을 뿐이다.
};
// ①·② 구조물 — 유출측(추가 벽 포함)이 먼저 서고, 그 다음 유입측.
// 다단 벽은 유출측(extra)·집수정측(bextra) 두 갈래라 **출처를 키에 담아** 돈다.
const wallsInOrder: Array<{ wall: WallLayout; key: RevetKey }> = [
...layout.walls
.filter((w) => w.role === "outlet")
.map((wall) => ({ wall, key: "outlet" as RevetKey })),
...layout.extraWalls.map((wall) => ({
wall,
key: `extra${wall.extraIndex ?? 0}` as RevetKey,
})),
...layout.basinExtras.map((wall) => ({
wall,
key: `bextra${wall.extraIndex ?? 0}` as RevetKey,
})),
...layout.walls
.filter((w) => w.role === "inlet")
.map((wall) => ({ wall, key: "inlet" as RevetKey })),
];
const revetShapes = new Map<RevetKey, SVGPolygonElement>();
/** 집수정 부재 도형 — 유입 선택 강조에 쓴다(2026-08-22 사용자 ③). */
const basinShapes: SVGPolygonElement[] = [];
for (const { wall, key: wallKey } of wallsInOrder) {
// 합성 단면(하부 사다리꼴 + 상부 평행사변형) — 상단 배면이 사면선 접점(사용자 ①·②).
const revetShape = polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet",
// 배관 벽 높이 표기 = 순수 높이(바닥~상단, 관 아래 0.5 포함 — 2026-08-23 사용자).
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
wall.height + (wall.role === "extra" ? 0 : REVET_EMBED_DEPTH_M)
).toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` +
`, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` +
(wall.floatGapM > 0.01
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)`
: ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)`) +
(wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""),
);
if (onSelectRevet) revetShape.classList.add("is-selectable");
revetShapes.set(wallKey, revetShape);
layer.append(revetShape);
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선):
// 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다.
const joint = document.createElementNS(SVG_NS, "line");
// 하단선이 원지반을 따라 기울어 있으므로 이음선 바닥도 하단선 위에서 잡는다.
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset;
const bottomAtOffset = (offset: number): number =>
Math.abs(bottomSpan) > 1e-9
? wall.bottomBack.elevation +
(wall.bottomFront.elevation - wall.bottomBack.elevation) *
((offset - wall.bottomBack.offset) / bottomSpan)
: wall.bottomBack.elevation;
joint.setAttribute("x1", String(x(wall.topJoint.offset)));
joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation)));
joint.setAttribute("x2", String(x(jointBaseOffset)));
joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset))));
joint.setAttribute("class", "b06-chart__culvert-stone");
layer.append(joint);
// 돌 해칭 — 큰 돌이 한 줄로 쌓인 **계류측 기운 띠**(전면 평행사변형)를 따라 쌓는다.
// 좌·우 벽은 outward 부호로 자동 반전된다. 벽 높이는 지형마다 달라지므로
// ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려
// 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정).
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
const clipId = `b06-revet-clip-${wallKey}-${Math.round(wall.backOffset * 100)}`;
const clip = document.createElementNS(SVG_NS, "clipPath");
clip.setAttribute("id", clipId);
clip.append(
polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"",
"",
),
);
const stoneGroup = document.createElementNS(SVG_NS, "g");
stoneGroup.setAttribute("clip-path", `url(#${clipId})`);
layer.append(clip, stoneGroup);
const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
const embedBase = bottomAtOffset(centerBaseOffset);
const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase);
const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45));
const stoneHeight = stoneSpan / stoneCount;
// 돌 중심선 = 전면 기운 띠(이음선~전면)의 중심선. 바닥은 근입 바닥까지 연장한다.
const centerBase = centerBaseOffset;
const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2);
// 화면 좌표 기준 기움 각(rect의 세로축을 경사축에 맞춘다).
const axisX = x(centerTop) - x(centerBase);
const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase);
const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI;
for (let i = 0; i < stoneCount; i += 1) {
const fraction = (i + 0.5) / stoneCount;
const centerOffset = centerBase + (centerTop - centerBase) * fraction;
const centerElevation = embedBase + stoneSpan * fraction;
const cx = x(centerOffset);
const cy = toDisplayY(centerElevation);
const stone = document.createElementNS(SVG_NS, "rect");
const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9;
const heightPx = stoneHeight * pixelsPerMeter * 0.86;
stone.setAttribute("x", String(cx - widthPx / 2));
stone.setAttribute("y", String(cy - heightPx / 2));
stone.setAttribute("width", String(widthPx));
stone.setAttribute("height", String(heightPx));
stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3));
stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`);
stone.setAttribute("class", "b06-chart__culvert-stone");
stoneGroup.append(stone);
}
}
if (layout.basin) {
const basin = layout.basin;
const shapeName = ((shape: BasinShape) =>
shape === "L" ? "ㄴ형" : shape === "U" ? "ㄷ형" : "I형")(basin.shape);
const reasonText =
basin.reason === "cut"
? "절토측 유입 — 집수정 기본(성토사면 없음)"
: basin.reason === "manual"
? "사용자 선택 집수정(규칙상 기슭막이 자리)"
: "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본";
for (const part of basin.parts) {
const shape = polygon(
part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-basin",
`유입 집수정(${shapeName}) ${part.kind === "floor" ? "바닥" : "벽"}` +
` — ${reasonText} · 내공 1.0m(실무 돌집수정) · 부재 두께 0.2m`,
);
basinShapes.push(shape);
layer.append(shape);
}
if (basin.cutLine) {
const cut = document.createElementNS(SVG_NS, "line");
cut.setAttribute("x1", String(x(basin.cutLine.from.offset)));
cut.setAttribute("y1", String(toDisplayY(basin.cutLine.from.elevation)));
cut.setAttribute("x2", String(x(basin.cutLine.to.offset)));
cut.setAttribute("y2", String(toDisplayY(basin.cutLine.to.elevation)));
// 절토선은 **공사 계획선**이다 — 설계선과 같은 보라 실선으로 그린다
// (2026-08-20 사용자 ②).
cut.setAttribute("class", "b06-chart__design-cross");
const cutTitle = document.createElementNS(SVG_NS, "title");
cutTitle.textContent = `집수정(${shapeName}) 설치 절토선(계획선) — 구조물이 원지반 안쪽에 들어가 절토 필요`;
cut.append(cutTitle);
layer.append(cut);
}
if (basin.fillLine) {
// 구조물이 원지반 밖으로 나온 경우의 성토(되메움) 계획선 — 성토 비탈 1:1.2.
const fill = document.createElementNS(SVG_NS, "line");
fill.setAttribute("x1", String(x(basin.fillLine.from.offset)));
fill.setAttribute("y1", String(toDisplayY(basin.fillLine.from.elevation)));
fill.setAttribute("x2", String(x(basin.fillLine.to.offset)));
fill.setAttribute("y2", String(toDisplayY(basin.fillLine.to.elevation)));
fill.setAttribute("class", "b06-chart__design-cross");
const fillTitle = document.createElementNS(SVG_NS, "title");
fillTitle.textContent =
basin.shape === "I"
? "집수정(I형) 되메움(성토)선 — 관 하단에서 3도(계류 쪽 오름)로 원지반과 연결"
: `집수정(${shapeName}) 성토(되메움)선 — 구조물이 원지반 밖으로 나와 1:1.2로 채움`;
fill.append(fillTitle);
layer.append(fill);
}
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String(x(basin.label.offset)));
// 라벨은 I형 벽 **하단** 바깥에 붙인다(2026-08-20 사용자) — 글자 높이만큼 내린다.
label.setAttribute("y", String(toDisplayY(basin.label.elevation) + 11));
label.setAttribute("text-anchor", "middle");
label.setAttribute("class", "b06-chart__culvert-label");
label.textContent = `집수정(${shapeName})`;
layer.append(label);
}
// ③ 관 — 축 법선 두께의 직사각형. 끝단면은 구조물 계류측 변에서 잘려 온다(Geom).
const { axis, normal } = layout.pipeAxis;
// 미세한 틈 방지: 양 끝을 축 방향으로 아주 조금만 밀어 넣는다. 벽 전면이 1:0.3으로
// 기울어 있어 크게 밀면 관 하단이 벽 **바깥으로 삐져나온다**(2026-08-20 사용자 ③
// 위치 어긋남 원인) — 렌더링 틈만 덮을 만큼(2㎝)으로 줄였다.
const OVERLAP_M = 0.02;
const pushOut = (corner: PipeEnd, dirSign: number): PipeEnd => ({
bottom: {
offset: corner.bottom.offset + dirSign * axis.offset * OVERLAP_M,
elevation: corner.bottom.elevation + dirSign * axis.elevation * OVERLAP_M,
},
top: {
offset: corner.top.offset + dirSign * axis.offset * OVERLAP_M,
elevation: corner.top.elevation + dirSign * axis.elevation * OVERLAP_M,
},
});
const inletFinal = pushOut(pipeCorners.inlet, -1);
const outletFinal = pushOut(pipeCorners.outlet, 1);
const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : "";
const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter);
const tip =
`관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipe.lengthM}.0m` +
` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` +
` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` +
` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`;
// 외경(관 두께 바깥) → 내경 순으로 2겹. 도면처럼 관벽 두께가 보이게 한다(사용자 ③).
const shifted = (corner: PipeEnd, delta: number): PipeEnd => ({
bottom: {
offset: corner.bottom.offset - normal.offset * delta,
elevation: corner.bottom.elevation - normal.elevation * delta,
},
top: {
offset: corner.top.offset + normal.offset * delta,
elevation: corner.top.elevation + normal.elevation * delta,
},
});
const pipeLayers = hidePipe
? []
: ([
[wallThickness, "b06-chart__culvert-pipe b06-chart__culvert-pipe--outer"],
[0, "b06-chart__culvert-pipe"],
] as const);
for (const [delta, cls] of pipeLayers) {
const a = shifted(inletFinal, delta);
const b = shifted(outletFinal, delta);
layer.append(
polygon(
[
[x(a.bottom.offset), toDisplayY(a.bottom.elevation)],
[x(b.bottom.offset), toDisplayY(b.bottom.elevation)],
[x(b.top.offset), toDisplayY(b.top.elevation)],
[x(a.top.offset), toDisplayY(a.top.elevation)],
],
cls,
tip,
),
);
}
drawOutletFill();
// 클릭 판정용 투명 겹면 — **맨 마지막에** 얹는다. 관·보호공이 벽 위를 지나가 그리기
// 순서상 벽 가운데를 덮으므로, 벽 폴리곤에 직접 핸들러를 달면 가운데를 눌러도 관이
// 먼저 먹는다(2026-08-21 화면 확인). 그리기 순서(벽 → 관 → 보호공)는 그대로 두고
// 판정면만 위로 올린다. 관 자체의 툴팁은 벽 밖 구간에서 그대로 뜬다.
if (onSelectRevet) {
for (const { wall, key: wallKey } of wallsInOrder) {
const hit = polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet-hit",
`${roleLabel(wall.role)} 기슭막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
);
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectRevet(wallKey);
});
layer.append(hit);
}
// 집수정도 선택 대상(2026-08-22 사용자 — 형식 드롭다운). 자리 이동은 없고
// 조정창에서 구조물 형식만 고른다.
if (layout.basin) {
for (const part of layout.basin.parts) {
const hit = polygon(
part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet-hit",
"유입 집수정 선택 — 조정창에서 구조물 형식 선택",
);
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectRevet("inlet");
});
layer.append(hit);
}
}
}
// 강조는 클래스만 갈아 끼운다 — 카드를 다시 그리면 휠 줌·팬이 초기화된다.
return (key) => {
for (const [role, shape] of revetShapes) shape.classList.toggle("is-active", role === key);
// 유입이 집수정이면 부재 전체를 함께 강조한다(2026-08-22 사용자 ③).
for (const shape of basinShapes) shape.classList.toggle("is-active", key === "inlet");
};
}