Files
Aislo/B06_Section/B06_Section_UI_Cross_Box.ts
T
eomsangdonandClaude Opus 5 6d8fd46d87 fix(B06): BOX암거를 고르면 그 끝이 강조되게 한다
좌·우 끝을 골라도 횡단도에 아무 표시가 없었다. 강조 토글(`is-active`)은 이미
클릭 판정용 **투명 겹면**에 걸리고 있었는데, 그 클래스에는 강조 규칙이 없어
그대로 투명했다.

겹면에 `b06-chart__box-hit`를 하나 더 달고 그 클래스에만 강조를 준다 — 기슭막이·
집수정과 같은 색 계열이되 면이 넓어 채도를 낮췄다(danger 16% + 점선 테두리).
구체 부재(저판·상판·내공)는 좌·우가 한 몸이라 그쪽을 칠하면 어느 끝을 고른
것인지 되레 흐려진다.

자체검증(공용 브라우저 5174, 측점 10+0.9 BOX암거):
- tsc --noEmit 통과.
- 우측 끝 선택 → 그 겹면만 `is-active`, 계산 스타일 fill
  `color(srgb 0.909804 0.411765 0.352941 / 0.16)` · stroke `rgb(232, 105, 90)`.
  수정 전에는 `fill: transparent`라 아무것도 안 보였다.
- 스크린샷 `tmp/browser/shots/110_boxhl.png` — 구체 우측 절반이 붉게 칠해진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:41:30 +09:00

170 lines
6.8 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_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}${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 },
],
// 강조용 클래스를 하나 더 단다 — 고른 끝을 옅게 칠한다(2026-08-30 사용자:
// BOX암거는 선택해도 아무 표시가 없었다). 구체 부재는 좌·우가 한 몸이라
// 그쪽을 칠하면 어느 끝을 고른 것인지 되레 흐려진다.
"b06-chart__culvert-revet-hit b06-chart__box-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);
};
}