Files
Aislo/B06_Section/B06_Section_UI_Cross_Underdrain.ts
T
eomsangdonandClaude Opus 5.5 486616aabd fix(B06 맹암거): 371-3 노면 = 표준 경사 · 암거 경사 = 형상 하단 경계(높은 쪽 노면 끝 − 높이에서 내림)
- 암거 경사가 노면으로 가던 길 끊음(tilt_ranges None ↔ facilityRanges null · Cross_Refresh · design_pipeline_cross) · 측구 없음 · 낮은 쪽 한 경사 그대로
- underdrainOutline 하단 = 높은 쪽 노면 끝 z − H + 암거 경사 · 절토 교점 · 성토 교점 · 돌 띠 따라감 · 경사 비면 옛 동작
- 저장분은 tilt_pct 달라 서버가 다시 셈 · B06 · B07 같은 함수(묶음 다시 지음)
- 시험 test_288 하단 경계 · 349_0n 맹암거 자리 경사 비움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-17 12:21:00 +09:00

186 lines
8.9 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 짝이 표준 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
* · 옵셋선(하단 경계) = 높은 쪽 노면 끝에서 암거 높이 아래 · 암거 경사로 낮은 쪽으로 내림(371-3) ·
* 경사가 비면 노면과 같은 각도.
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
* · 양측절토(낮은 쪽 늘인 노면 `tilt_reach`) = 늘인 노면이 원지반과 만나는 점에서 수직으로 끊음(356-1).
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(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 };
/** 하단 경계(옵셋선) — 표고 = `z0 + grade · (o − o0)`. */
type Bottom = { o0: number; z0: number; grade: number };
/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */
function sidePath(
edge: Edge,
outward: 1 | -1,
line: readonly Edge[],
bottom: Bottom,
): OffsetPoint[] {
const start = { offset: edge.offset_m, elevation: edge.elevation_m };
const offZ = (o: number) => bottom.z0 + bottom.grade * (o - bottom.o0);
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 g = bottom.grade;
const d = Math.abs(k - g) < 1e-9 ? 0 : (offZ(edge.offset_m) - edge.elevation_m) / (k - g);
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;
}
/** 하단 경계 — 높은 쪽(상류) 노면 끝 − 높이에서 암거 경사로 낮은 쪽으로 내림(371-3) · 경사가 비면 노면과 평행. */
function bottomOf(left: Edge, right: Edge, heightM: number, slopePct: number | null): Bottom {
const roadGrade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
const high = left.elevation_m >= right.elevation_m ? left : right;
const low = high === left ? right : left;
const grade =
slopePct && slopePct > 0
? (Math.sign(low.offset_m - high.offset_m) * -slopePct) / 100
: roadGrade;
return { o0: high.offset_m, z0: high.elevation_m - heightM, grade };
}
/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */
export function underdrainOutline(
design: CrossDesign,
heightM: number | null,
slopePct: number | null = 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 bottom = bottomOf(left, right, heightM, slopePct);
// 양측절토(356-1) — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음 · 너머 점 거름 → 꼬리가 수직으로 닫음
const reach = design.tilt_reach;
const outward = reach?.side === "left" ? 1 : -1;
const line = (design.design_line ?? []).filter(
(p) => !reach || (p.offset_m - reach.offset_m) * outward <= 1e-6,
);
const leftPath = sidePath(left, 1, line, bottom);
const rightPath = sidePath(right, -1, line, bottom);
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, spec.slope_pct) : 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 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 bottom = bottomOf(left, right, spec.height_m!, spec.slope_pct);
const bottomZ = (o: number) => bottom.z0 + bottom.grade * (o - bottom.o0);
// 돌 띠 = 노면 ~ 하단 경계(두께가 자리마다 다름 · 켜 수는 두꺼운 쪽)
const height = Math.max(roadZ(lo) - bottomZ(lo), roadZ(hi) - bottomZ(hi), spec.height_m!);
const at = (u: number, v: number): OffsetPoint => {
const offset = lo + (hi - lo) * u;
const top = roadZ(offset);
return { offset, elevation: top + (bottomZ(offset) - top) * (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;
}