- 암거 경사가 노면으로 가던 길 끊음(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
186 lines
8.9 KiB
TypeScript
186 lines
8.9 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|