Files
Aislo/B06_Section/B06_Section_UI_Cross_Underdrain.ts
T
eomsangdonandClaude Opus 5.5 0a8008ce09 feat(B06): 306 횡단 맹암거 안 엇갈려 겹친 돌 무늬 · 그림 전용 · 고르기 차례 = 안쪽 판정면 맨 아래 · 테두리 판정 면 판정 맨 위
- 돌 무늬: 켜마다 반 칸 엇갈림 · 이웃 · 위아래와 겹침 · 기울기 ±22° 번갈아 교차 · 맹암거 색(카드 · [상세] 모달 같은 그림)
- 맹암거 무리 pointer-events none — 성토 띠 · 포장 · 넓은 관 판정면이 맹암거 안에서도 골라짐
- 차례: 보라 선 > 맹암거 테두리 > 구조물 판정면(작은 것 위) > 그림 · 띠 > 맹암거 안쪽
- 시험 test_306_underdrain_pick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-13 18:07:53 +09:00

159 lines
7.1 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_Underdrain.ts
* 맹암거 횡단 형상(288-1 · 요구 183-3).
*
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 ·
* 기울기를 번갈아 서로 교차 · 그림 전용(누름 안 받음 · CSS `.b06-chart__underdrain`).
* 높이가 비면 그리지 않음(수치를 지어내지 않음).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { cell, courses, type BandFrame } from "./B06_Section_UI_Cross_Wall_Hatch";
import type { UnderdrainSpec } from "@util/common_util_structure_underdrain";
const SVG_NS = "http://www.w3.org/2000/svg";
let clipSeq = 0;
/** 돌 한 칸(m · 보기용) · 이웃과 겹침 배수 · 번갈아 기울기(도). */
const STONE_M = 0.25;
const STONE_OVERLAP = 1.4;
const STONE_TILT_DEG = 22;
/** 엇갈려 겹친 돌 무늬(306-1) — 켜(가로줄)마다 반 칸 밀고 · 돌은 칸보다 커서 이웃 · 위아래와 겹침 ·
* 기울기를 번갈아 서로 교차. `frame` v = −0.5(노면) ~ 0.5(옵셋선). */
export function drawCrossedStones(group: SVGElement, frame: BandFrame, heightM: number): void {
const rows = courses(heightM, STONE_M);
const count = courses(frame.spanM, STONE_M);
const across = (heightM / rows) * STONE_OVERLAP;
const along = (frame.spanM / count) * STONE_OVERLAP;
for (let r = 0; r < rows; r += 1) {
const shift = r % 2 ? 0 : 0.5;
for (let i = 0; i + shift <= count; i += 1) {
const tilt = (i + r) % 2 ? STONE_TILT_DEG : -STONE_TILT_DEG;
const lean = { ...frame, leanDegrees: frame.leanDegrees + tilt };
group.append(cell(lean, (i + shift) / count, (r + 0.5) / rows - 0.5, across, along, 0.45));
}
}
}
type Edge = { offset_m: number; elevation_m: number };
/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */
function sidePath(
edge: Edge,
outward: 1 | -1,
line: readonly Edge[],
roadGrade: number,
heightM: number,
): OffsetPoint[] {
const start = { offset: edge.offset_m, elevation: edge.elevation_m };
const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM;
const outside = line
.filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6)
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
const first = outside[0];
if (!first) return [start, { offset: start.offset, elevation: offZ(start.offset) }];
if (first.elevation_m >= edge.elevation_m) {
// 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남
const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m);
const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade);
const offset = edge.offset_m + d;
return [start, { offset, elevation: offZ(offset) }];
}
// 성토 — 사면을 따라 내려가다 옵셋선을 만나는 자리
const path: OffsetPoint[] = [start];
let prev = edge;
for (const point of outside) {
const fa = prev.elevation_m - offZ(prev.offset_m);
const fb = point.elevation_m - offZ(point.offset_m);
if (fb <= 0) {
const t = fa / (fa - fb);
const offset = prev.offset_m + (point.offset_m - prev.offset_m) * t;
path.push({ offset, elevation: offZ(offset) });
return path;
}
path.push({ offset: point.offset_m, elevation: point.elevation_m });
prev = point;
}
path.push({ offset: prev.offset_m, elevation: offZ(prev.offset_m) });
return path;
}
/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */
export function underdrainOutline(
design: CrossDesign,
heightM: number | null,
): OffsetPoint[] | null {
const edges = design.road_edges;
if (!edges || !(heightM && heightM > 0)) return null;
const { left, right } = edges;
const span = left.offset_m - right.offset_m;
if (!(Math.abs(span) > 1e-9)) return null;
const grade = (left.elevation_m - right.elevation_m) / span;
const line = design.design_line ?? [];
const leftPath = sidePath(left, 1, line, grade, heightM);
const rightPath = sidePath(right, -1, line, grade, heightM);
return [
{ offset: left.offset_m, elevation: left.elevation_m },
...rightPath,
...leftPath.slice(1).reverse(),
];
}
/** 맹암거 형상을 그린다(돌 무늬 · 둘레 클립). 그렸으면 그 무리 — 둘레에 고름 표식(`data-structure-id` · 288-2). */
export function appendUnderdrainOverlay(
svg: SVGElement,
spec: UnderdrainSpec | undefined,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
): SVGGElement | null {
const outline = spec ? underdrainOutline(design, spec.height_m) : null;
if (!spec || !outline) return null;
const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)];
const points = outline.map((p) => px(p).join(",")).join(" ");
const group = document.createElementNS(SVG_NS, "g") as SVGGElement;
group.setAttribute("class", "b06-chart__underdrain");
const body = document.createElementNS(SVG_NS, "polygon");
body.setAttribute("points", points);
body.setAttribute("class", "b06-chart__underdrain-body");
if (spec.structure_id) body.setAttribute("data-structure-id", spec.structure_id);
clipSeq += 1;
const clipId = `b06-underdrain-clip-${clipSeq}`;
const clip = document.createElementNS(SVG_NS, "clipPath");
clip.setAttribute("id", clipId);
const clipShape = document.createElementNS(SVG_NS, "polygon");
clipShape.setAttribute("points", points);
clip.append(clipShape);
const stones = document.createElementNS(SVG_NS, "g");
stones.setAttribute("clip-path", `url(#${clipId})`);
const height = spec.height_m!;
const offsets = outline.map((p) => p.offset);
const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)];
const { left, right } = design.road_edges;
const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m);
const at = (u: number, v: number): OffsetPoint => {
const offset = lo + (hi - lo) * u;
return { offset, elevation: roadZ(offset) - height * (v + 0.5) };
};
const [ax, ay] = px(at(0, 0));
const [bx, by] = px(at(1, 0));
drawCrossedStones(
stones,
{
spanM: Math.hypot(hi - lo, grade * (hi - lo)),
pixelsPerMeter: Math.abs(x(1) - x(0)) || 1,
point: (u, v) => px(at(u, v)),
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
},
height,
);
group.append(body, clip, stones);
svg.append(group);
return group;
}