- 물넘이 자리 노면 · 측구 설계선 걷음 → 옛 계획고 점선(바닥 + 월류 높이)이 드러남 · 측구 자리는 바닥 끝 ~ 절토 비탈 발 실선 - 노면 경사 = ford_slope_deg(°) → % · 없으면 옛 ford_slope_pct · 둘 다 없으면 기본 3° (파이썬 · TS 짝 + 거울 시험) - 방향 = 지금대로 절토측 높고 성토측 낮음 - 세월교 점선 = 노면 + 월류 높이(ford_height_m) · 패널에서 고치면 저장 전에도 바로 따라옴 - 물넘이 패널 월류 높이 · 경사각도 캐시 제원에 바로 얹음(폭 절반 걸친 옆 카드 포함) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Ford_Pavement.ts
|
|
* 물넘이포장 — 계획고를 파낸 노면을 횡단도에 그린다(2026-08-28 사용자 확정).
|
|
*
|
|
* 다른 시설은 구조물을 얹지만 물넘이는 **노면 자체가 내려앉는다**. 그래서 그림도 다르다.
|
|
* · 파임 범위 = 노견까지 **전폭**.
|
|
* · 깊이 = **노선 중심**에서 잰 월류 높이(`depth_m`).
|
|
* · 바닥은 **유입(상류)이 높고 유출이 낮게** 기운다. 경사를 비우면 그 측점의 노면
|
|
* 횡단경사(`cross_slope_pct`)를 쓴다 — 사용자가 폼에서 바꿀 수 있다.
|
|
* · 기존 계획고는 **점선**, 물넘이 바닥은 **실선**.
|
|
* · 포장 필수 — 일반 포장층과 구분되게 진한 회색 + 빗금으로 채운다.
|
|
*
|
|
* 유입측 판정은 `section_mode`(상단측 절토 = 등고가 높은 쪽 = 상류)를 따른다.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
|
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
/** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */
|
|
let hatchSeq = 0;
|
|
|
|
/** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */
|
|
export interface FordPavementSpec {
|
|
/** 관 지점 누가거리 — 폭 절반 걸친 옆 카드도 같은 값. */
|
|
chainage_m?: number;
|
|
span_m: number;
|
|
/** 노선 중심에서 잰 파임 깊이(m). 없으면 그리지 않는다 — 수치를 지어내지 않는다. */
|
|
depth_m: number | null;
|
|
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
|
|
slope_pct: number | null;
|
|
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
|
|
defaulted?: string[];
|
|
}
|
|
|
|
interface Edge {
|
|
offset_m: number;
|
|
elevation_m: number;
|
|
}
|
|
|
|
/** 유입측(상류) 부호 — 그 방향으로 갈수록 바닥이 높아진다. */
|
|
function inflowSign(design: CrossDesign, offsetM: number): number {
|
|
const inflowLeft = design.section_mode !== "right_cut";
|
|
const towardLeft = offsetM > 0;
|
|
return towardLeft === inflowLeft ? 1 : -1;
|
|
}
|
|
|
|
/**
|
|
* 파인 노면의 표고(m). 횡단도와 3D 코리도가 **같은 식**을 써야 두 그림이 어긋나지 않는다.
|
|
* 중심에서 깊이만큼 내리고, 유입측으로 갈수록 경사만큼 올린다.
|
|
*/
|
|
export function fordDeckElevationAt(
|
|
design: CrossDesign,
|
|
spec: FordPavementSpec,
|
|
offsetM: number,
|
|
fallbackElevationM = 0,
|
|
): number {
|
|
const depth = spec.depth_m ?? 0;
|
|
const slope = (spec.slope_pct ?? design.cross_slope_pct) / 100;
|
|
const center = design.design_elevation_m ?? fallbackElevationM;
|
|
return center - depth + slope * Math.abs(offsetM) * inflowSign(design, offsetM);
|
|
}
|
|
|
|
function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number {
|
|
return fordDeckElevationAt(design, spec, edge.offset_m, edge.elevation_m);
|
|
}
|
|
|
|
function line(points: string[], className: string): SVGPolylineElement {
|
|
const polyline = document.createElementNS(SVG_NS, "polyline");
|
|
polyline.setAttribute("points", points.join(" "));
|
|
polyline.setAttribute("class", className);
|
|
return polyline;
|
|
}
|
|
|
|
/**
|
|
* 세월교로 **내려 앉힌 노면** 위에 "월류가 없었다면" 노면을 점선으로 되그린다
|
|
* (2026-08-30 사용자 확정). 계획고는 이미 백엔드가 월류 높이만큼 내려 잡았으므로
|
|
* (`design.surface_drop_m`) 여기서는 그 양만큼 위로 올린 선을 얹는다.
|
|
* 물넘이포장의 「기존 계획고 점선」과 같은 표기라 사용자가 한 규칙으로 읽는다.
|
|
*
|
|
* 양 끝은 **측벽 상단의 계류측 꼭짓점까지 대각으로 내려 잇는다**(2026-08-30 사용자:
|
|
* 좌측 벽이면 좌상단, 우측 벽이면 우상단, 각도는 무시). 월류 전 노면이 구체 밖에서
|
|
* 어디로 떨어지는지가 그 선으로 읽힌다.
|
|
*/
|
|
export function appendFordSurfaceDropPlan(
|
|
svg: SVGElement,
|
|
design: CrossDesign,
|
|
x: (offset: number) => number,
|
|
y: (elevation: number) => number,
|
|
ford?: {
|
|
sides: ReadonlyArray<{ outward: number; wallTopOuter: OffsetPoint }>;
|
|
ford?: { overflow_depth_m?: number };
|
|
} | null,
|
|
): void {
|
|
// 점선 = 노면 + 월류 높이(66-3) — 패널 월류 높이(세트 제원)가 먼저 · 저장 뒤 내린 양과 같은 값
|
|
const drop = ford?.ford?.overflow_depth_m ?? design.surface_drop_m ?? 0;
|
|
if (!(drop > 0) || !design.road_edges) return;
|
|
const { left, right } = design.road_edges;
|
|
// +offset이 좌측이다 — 좌측 벽 상단 → 좌 노견 → 우 노견 → 우측 벽 상단 순.
|
|
const cornerAt = (side: 1 | -1): OffsetPoint | undefined =>
|
|
ford?.sides.find((entry) => Math.sign(entry.outward) === side)?.wallTopOuter;
|
|
const points: OffsetPoint[] = [
|
|
{ offset: left.offset_m, elevation: left.elevation_m + drop },
|
|
{ offset: right.offset_m, elevation: right.elevation_m + drop },
|
|
];
|
|
const leftCorner = cornerAt(1);
|
|
if (leftCorner) points.unshift(leftCorner);
|
|
const rightCorner = cornerAt(-1);
|
|
if (rightCorner) points.push(rightCorner);
|
|
svg.append(
|
|
line(
|
|
points.map((point) => `${x(point.offset)},${y(point.elevation)}`),
|
|
"b06-chart__ford-deck-plan",
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 물넘이 파임을 그린다. 그렸으면 true — 호출부는 일반 포장층 박스를 건너뛴다
|
|
* (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다).
|
|
*/
|
|
export function appendFordPavementOverlay(
|
|
svg: SVGElement,
|
|
spec: FordPavementSpec | undefined,
|
|
design: CrossDesign,
|
|
x: (offset: number) => number,
|
|
y: (elevation: number) => number,
|
|
): boolean {
|
|
if (!spec || !spec.depth_m || !design.road_edges) return false;
|
|
const { left, right } = design.road_edges;
|
|
const bottomLeft = bottomAt(design, spec, left);
|
|
const bottomRight = bottomAt(design, spec, right);
|
|
|
|
// ① 기존 계획고(점선) — 파기 전 노면이 어디였는지 남긴다.
|
|
svg.append(
|
|
line(
|
|
[
|
|
`${x(left.offset_m)},${y(left.elevation_m)}`,
|
|
`${x(right.offset_m)},${y(right.elevation_m)}`,
|
|
],
|
|
"b06-chart__ford-deck-plan",
|
|
),
|
|
);
|
|
|
|
// ② 포장층 — 바닥선에서 두께만큼 아래로. 일반 포장과 색·빗금으로 구분한다.
|
|
const thickness = design.pavement_thickness_m ?? 0.2;
|
|
const hatchId = `b06-ford-hatch-${(hatchSeq += 1)}`;
|
|
const defs = document.createElementNS(SVG_NS, "defs");
|
|
const pattern = document.createElementNS(SVG_NS, "pattern");
|
|
pattern.setAttribute("id", hatchId);
|
|
pattern.setAttribute("patternUnits", "userSpaceOnUse");
|
|
pattern.setAttribute("width", "6");
|
|
pattern.setAttribute("height", "6");
|
|
const stroke = document.createElementNS(SVG_NS, "path");
|
|
stroke.setAttribute("d", "M0,6 L6,0");
|
|
stroke.setAttribute("class", "b06-chart__ford-pavement-hatch");
|
|
pattern.append(stroke);
|
|
defs.append(pattern);
|
|
svg.append(defs);
|
|
|
|
const polygon = document.createElementNS(SVG_NS, "polygon");
|
|
polygon.setAttribute(
|
|
"points",
|
|
[
|
|
`${x(left.offset_m)},${y(bottomLeft)}`,
|
|
`${x(right.offset_m)},${y(bottomRight)}`,
|
|
`${x(right.offset_m)},${y(bottomRight - thickness)}`,
|
|
`${x(left.offset_m)},${y(bottomLeft - thickness)}`,
|
|
].join(" "),
|
|
);
|
|
polygon.setAttribute("class", "b06-chart__ford-pavement");
|
|
polygon.setAttribute("fill", `url(#${hatchId})`);
|
|
svg.append(polygon);
|
|
|
|
// ③ 물넘이 바닥(실선) — 횡단 기준선이라 가장 위에 올린다.
|
|
svg.append(
|
|
line(
|
|
[`${x(left.offset_m)},${y(bottomLeft)}`, `${x(right.offset_m)},${y(bottomRight)}`],
|
|
"b06-chart__ford-deck",
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가
|
|
* 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다).
|
|
* 측구 자리는 바닥 끝에서 절토 비탈 발까지 실선으로 잇는다. 면적 · 서버 설계선은 그대로(그림만).
|
|
*/
|
|
export function hideFordPavedRoad(
|
|
layer: SVGElement,
|
|
from: number,
|
|
design: CrossDesign,
|
|
spec: FordPavementSpec,
|
|
x: (offset: number) => number,
|
|
y: (elevation: number) => number,
|
|
): void {
|
|
const edges = design.road_edges;
|
|
const designLine = design.design_line;
|
|
if (!edges || !designLine?.length) return;
|
|
const ditch = design.ditch;
|
|
const ditchWidth =
|
|
design.ditch_enabled === false || ditch.type === "none"
|
|
? 0
|
|
: ditch.type === "standard"
|
|
? ditch.top_width_m
|
|
: ditch.width_m;
|
|
// +offset = 좌측 · 측구는 노견 바깥 그 쪽에 붙는다
|
|
const leftEdge = Math.max(edges.left.offset_m, edges.right.offset_m);
|
|
const rightEdge = Math.min(edges.left.offset_m, edges.right.offset_m);
|
|
const hi = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0);
|
|
const lo = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0);
|
|
const [xa, xb] = [x(lo), x(hi)];
|
|
const inside = (px: number): boolean =>
|
|
px >= Math.min(xa, xb) - 0.5 && px <= Math.max(xa, xb) + 0.5;
|
|
for (const node of [...layer.children].slice(from)) {
|
|
if (node.tagName !== "line" || !node.classList.contains("b06-chart__design-cross")) continue;
|
|
if (inside(Number(node.getAttribute("x1"))) && inside(Number(node.getAttribute("x2"))))
|
|
node.remove();
|
|
}
|
|
if (!(ditchWidth > 0) || !design.ditch_side) return;
|
|
const edge = design.ditch_side === "left" ? leftEdge : rightEdge;
|
|
const foot = design.ditch_side === "left" ? hi : lo;
|
|
const sorted = [...designLine].sort((a, b) => a.offset_m - b.offset_m);
|
|
const at = sorted.find((point) => Math.abs(point.offset_m - foot) < 1e-3);
|
|
if (!at) return;
|
|
const edgeElevation = design.ditch_side === "left" ? edges.left : edges.right;
|
|
layer.append(
|
|
line(
|
|
[
|
|
`${x(edge)},${y(fordDeckElevationAt(design, spec, edge, edgeElevation.elevation_m))}`,
|
|
`${x(foot)},${y(at.elevation_m)}`,
|
|
],
|
|
"b06-chart__ford-deck",
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 좌측 패널의 물넘이 칸을 캐시 제원에 얹는다(66-2 · 66-3) — 월류 높이 = 파임 깊이 · 경사각(°) → %.
|
|
* 서버 `_ford_pavement_set` 과 같은 환산(저장 뒤 서버가 정본으로 다시 냄). 바꿨으면 true.
|
|
*/
|
|
export function applyFordPavementOptions(
|
|
spec: FordPavementSpec,
|
|
patch: Record<string, number | string>,
|
|
): boolean {
|
|
const num = (key: string): number | undefined => {
|
|
const value = patch[key] === "" ? NaN : Number(patch[key]);
|
|
return Number.isFinite(value) ? value : undefined;
|
|
};
|
|
const height = num("ford_height_m");
|
|
const degree = num("ford_slope_deg");
|
|
if (height !== undefined) spec.depth_m = height > 0 ? height : null;
|
|
if (degree !== undefined) {
|
|
spec.slope_pct = Number((Math.tan((degree * Math.PI) / 180) * 100).toFixed(4));
|
|
spec.defaulted = spec.defaulted?.filter((item) => !item.startsWith("경사각 "));
|
|
}
|
|
return height !== undefined || degree !== undefined;
|
|
}
|