조정창에서 좌·우 끝을 따로 잡는다 — ◀▶는 구체 길이, ▲▼는 그 끝의 구체 표고를 0.1m씩 움직인다. 좌·우 표고가 달라지면 구체가 기울고, 날개벽 구간 바닥(에이프런)은 각 끝 표고에서 수평을 지킨다(2026-08-25 사용자). - `computeBoxLayout`이 좌·우를 따로 푼다: 끝 표고·길이·성토선·에이프런 - 성토선은 그쪽 표고 기준으로 물매 1:1.2를 지키므로 표고를 내리면 구체가 길어진다 - 조정창 `_Cross_Box_Panel.ts` + 제어 `createBoxControls` + `design.box_adjust` 저장 (세션 → 캐시 → 확정 payload, 세월교와 같은 체계) - 3D: 구체 밖(날개벽 구간)에 서 있던 벽을 없앴다 — 천장 없는 통로가 생기던 자리는 날개벽 몫이다. 이제 상판·구체 저판·측벽 2매·에이프런 2매 + 날개벽 4매로 나뉜다 - 카드 렌더러의 구체 배선(선택 토글·강조·조정창)을 `_Cross_View_Bodies.ts`로 분리, 연동 플래그 세션은 `_Page_Link_Session.ts`로 분리(700줄 제한) 검증(10+0.9): 구체 5.34m·수평 → ◀ 1회 5.44m → ▼ 2회 표고 −0.2m·물매 1:28.4, 에이프런 두 장 모두 수평 유지, ↺ 원복. 3D 솔리드 10개(측벽 링 0.2m). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
167 lines
6.5 KiB
TypeScript
167 lines
6.5 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_Box.ts
|
||
* BOX암거 측점 횡단 카드의 구체(에이프런·저판·상판·내공·성토선) **오버레이 그리기**.
|
||
*
|
||
* 기하 계산은 `B06_Section_UI_Cross_Box_Geom.ts`가 맡는다(700줄 제한 분리).
|
||
* 여기서는 계산 결과(`BoxLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지.
|
||
*
|
||
* 그리는 순서 = 시공 순서: ① 에이프런 → ② 저판 → ③ 상판 → ④ 내공 윤곽 → ⑤ 성토선.
|
||
* 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다.
|
||
* ========================================================================== */
|
||
|
||
import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
|
||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||
|
||
export {
|
||
computeBoxLayout,
|
||
DEFAULT_BOX_ADJUST,
|
||
DEFAULT_BOX_SIDE_ADJUST,
|
||
} from "./B06_Section_UI_Cross_Box_Geom";
|
||
export type {
|
||
BoxAdjust,
|
||
BoxLayout,
|
||
BoxSideAdjust,
|
||
BoxSideLayout,
|
||
BoxSideRole,
|
||
} from "./B06_Section_UI_Cross_Box_Geom";
|
||
|
||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||
|
||
/** 선택 강조를 카드 재생성 없이 갈아 끼우는 setter. */
|
||
export type BoxHighlightSetter = (role: BoxSideRole | null) => void;
|
||
|
||
/**
|
||
* BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다.
|
||
* `onSelectSide`가 오면 좌·우 절반이 선택 대상이 된다(조정창이 그 측을 잡는다).
|
||
*/
|
||
export function appendBoxOverlay(
|
||
layer: SVGElement,
|
||
layout: BoxLayout,
|
||
x: (offset: number) => number,
|
||
toDisplayY: (elevation: number) => number,
|
||
onSelectSide?: (role: BoxSideRole) => void,
|
||
): BoxHighlightSetter {
|
||
const { box } = layout;
|
||
const toXY = (point: OffsetPoint): [number, number] => [
|
||
x(point.offset),
|
||
toDisplayY(point.elevation),
|
||
];
|
||
const polygon = (
|
||
points: OffsetPoint[],
|
||
className: string,
|
||
tooltip: string,
|
||
): SVGPolygonElement => {
|
||
const shape = document.createElementNS(SVG_NS, "polygon");
|
||
shape.setAttribute("points", points.map((point) => toXY(point).join(",")).join(" "));
|
||
shape.setAttribute("class", className);
|
||
const title = document.createElementNS(SVG_NS, "title");
|
||
title.textContent = tooltip;
|
||
shape.append(title);
|
||
return shape;
|
||
};
|
||
const wingText = (wing: {
|
||
installed: boolean;
|
||
length_m: number | null;
|
||
angle_deg: number | null;
|
||
}): string => (wing.installed ? `${wing.length_m ?? 0}m·${wing.angle_deg ?? 0}°` : "없음");
|
||
const slopeText = layout.slopeRatio
|
||
? ` · 구체 물매 1:${layout.slopeRatio.toFixed(1)}`
|
||
: " · 구체 수평";
|
||
|
||
// ① 에이프런 — 날개벽 구간 바닥. 각 끝 표고에서 수평이다(2026-08-25 사용자).
|
||
for (const side of layout.sides) {
|
||
if (!side.apron.length) continue;
|
||
const mark = side.role === "left" ? "좌" : "우";
|
||
layer.append(
|
||
polygon(
|
||
side.apron,
|
||
"b06-chart__ford-slab",
|
||
`${mark}측 날개벽 구간 바닥 — 수평 · 두께 ${box.slab_thickness_m.toFixed(2)}m` +
|
||
` (날개벽 ${wingText(side.role === "left" ? box.wing_in : box.wing_out)} 투영 연장)`,
|
||
),
|
||
);
|
||
}
|
||
|
||
// ② 저판 · ③ 상판 — 좌·우 표고가 다르면 함께 기운다.
|
||
layer.append(
|
||
polygon(
|
||
layout.bottomSlab,
|
||
"b06-chart__ford-slab",
|
||
`BOX암거 저판 — 두께 ${box.slab_thickness_m.toFixed(2)}m${slopeText}` +
|
||
(layout.bedGapM < -0.05
|
||
? ` · 계류 하상보다 ${Math.abs(layout.bedGapM).toFixed(2)}m 낮음(터파기)`
|
||
: layout.bedGapM > 0.05
|
||
? ` · 계류 하상보다 ${layout.bedGapM.toFixed(2)}m 높음`
|
||
: ""),
|
||
),
|
||
polygon(
|
||
layout.topSlab,
|
||
"b06-chart__ford-slab",
|
||
`BOX암거 상판 — 두께 ${box.top_thickness_m.toFixed(2)}m · 윗면이 노면 −${box.cover_m.toFixed(2)}m(복토)${slopeText}`,
|
||
),
|
||
);
|
||
|
||
// ④ 내공(유로) — 면은 비우고 윤곽만 점선으로 두른다.
|
||
layer.append(
|
||
polygon(
|
||
layout.barrel,
|
||
"b06-chart__box-barrel",
|
||
`BOX암거 내공(유로) ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}m` +
|
||
` · 측벽 두께 ${box.wall_thickness_m.toFixed(2)}m · 구체 길이 ${layout.bodyLengthM.toFixed(2)}m`,
|
||
),
|
||
);
|
||
|
||
// ⑤ 성토선 — 노견에서 (연장 후) 구체 최상단 모서리까지, 물매 1:1.2.
|
||
for (const side of layout.sides) {
|
||
if (side.fillLine.length < 2) continue;
|
||
const polyline = document.createElementNS(SVG_NS, "polyline");
|
||
polyline.setAttribute("points", side.fillLine.map((point) => toXY(point).join(",")).join(" "));
|
||
polyline.setAttribute("class", "b06-chart__design-cross");
|
||
const title = document.createElementNS(SVG_NS, "title");
|
||
const drop = side.fillLine[0].elevation - side.fillLine[side.fillLine.length - 1].elevation;
|
||
title.textContent = `BOX암거 성토선 — 물매 1:1.2 · 성토고 ${drop.toFixed(2)}m (구체 최상단에서 끝)`;
|
||
polyline.append(title);
|
||
layer.append(polyline);
|
||
}
|
||
|
||
const label = document.createElementNS(SVG_NS, "text");
|
||
const [labelX, labelY] = toXY(layout.label.at);
|
||
label.setAttribute("x", String(labelX));
|
||
label.setAttribute("y", String(labelY));
|
||
label.setAttribute("text-anchor", "middle");
|
||
label.setAttribute("class", "b06-chart__culvert-label");
|
||
label.textContent = layout.label.text;
|
||
layer.append(label);
|
||
|
||
// 좌·우 절반을 덮는 투명 겹면 — 어느 끝을 조정할지 클릭으로 고른다.
|
||
const hits = new Map<BoxSideRole, SVGPolygonElement>();
|
||
if (onSelectSide) {
|
||
const midOffset = (layout.sides[0].offset + layout.sides[1].offset) / 2;
|
||
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
|
||
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
|
||
for (const side of layout.sides) {
|
||
const outer = side.apron.length ? side.apron[1].offset : side.offset;
|
||
const hit = polygon(
|
||
[
|
||
{ offset: midOffset, elevation: top },
|
||
{ offset: outer, elevation: top },
|
||
{ offset: outer, elevation: bottom },
|
||
{ offset: midOffset, elevation: bottom },
|
||
],
|
||
"b06-chart__culvert-revet-hit",
|
||
"",
|
||
);
|
||
hit.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
onSelectSide(side.role);
|
||
});
|
||
hits.set(side.role, hit);
|
||
layer.append(hit);
|
||
}
|
||
}
|
||
|
||
return (role: BoxSideRole | null): void => {
|
||
for (const [side, hit] of hits) hit.classList.toggle("is-active", side === role);
|
||
};
|
||
}
|