/* ============================================================================= * B06_Section_UI_Cross_Design_Pick.ts * 횡단 카드 **보라 요소 고름**(설계선 · 구조물 트림 선 · 137-2). * * 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을 * 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정 * 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다. * 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다. * 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다. * ========================================================================== */ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import { clipSegment, type XY } from "../A00_Common/b_polygon_clip"; import { sideRole } from "@util/common_util_cross_design_geometry"; const SVG_NS = "http://www.w3.org/2000/svg"; const DESIGN_CLASS = "b06-chart__design-cross"; const HIT_CLASS = "b06-chart__design-hit"; /** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */ export const DESIGN_LINE_ATTR = "data-design-line"; /** 보라 선을 덮는 벽 · 본체 폴리곤. */ const OCCLUDER_CLASSES = [ "b06-chart__culvert-revet", "b06-chart__culvert-basin", "b06-chart__ford-slab", "b06-chart__box-barrel", ]; /** 같은 직선으로 보는 꺾임 허용(m) — 설계선 표고는 0.1 mm 반올림이라 넉넉하다. */ const STRAIGHT_TOL_M = 0.005; /** 가리고 남은 조각이 이보다 짧으면 버린다(m) — 누를 수도 없다. */ const MIN_PIECE_M = 0.02; export interface CrossPoint { offset: number; elevation: number; } /** 그려진 보라 선 한 줄(m 좌표). `structure` = 구조물 트림 선(설계선 오버레이 밖). */ export interface DesignRun { points: CrossPoint[]; structure: boolean; } /** 직선 조각 — 두 끝(m) · 구조물 트림 선인가. */ export interface DesignPiece { from: CrossPoint; to: CrossPoint; structure: boolean; } export type CrossZone = "road" | "cut" | "fill"; export type CrossSide = "left" | "right"; type ZoneDesign = Pick< CrossDesign, "road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode" >; /** * offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + * 구조물 트림 선. 사면 역할은 단면유형(`sideRole`)으로 · 쪽 = +offset 좌. */ export function crossZoneAt( design: ZoneDesign, offsetM: number, structure = false, ): { zone: CrossZone; side: CrossSide } { const side: CrossSide = offsetM >= 0 ? "left" : "right"; const edges = design.road_edges; const hi = edges ? Math.max(edges.left.offset_m, edges.right.offset_m) : 0; const lo = edges ? Math.min(edges.left.offset_m, edges.right.offset_m) : 0; if (structure) return { zone: "fill", side }; if (offsetM >= lo - 1e-6 && offsetM <= hi + 1e-6) return { zone: "road", side }; const ditch = design.ditch; const ditchWidth = design.ditch_enabled === false || !ditch || ditch.type === "none" ? 0 : ditch.type === "standard" ? ditch.top_width_m : ditch.width_m; if (design.ditch_side === "left" && offsetM > hi && offsetM <= hi + ditchWidth + 1e-6) return { zone: "cut", side }; if (design.ditch_side === "right" && offsetM < lo && offsetM >= lo - ditchWidth - 1e-6) return { zone: "cut", side }; let roles: [string, string] = ["fill", "fill"]; try { roles = sideRole(design.section_mode); } catch { // 모르는 단면유형 = 성토로 봄 } return { zone: (side === "left" ? roles[0] : roles[1]) === "cut" ? "cut" : "fill", side }; } /** 조각 구역 = 가운데 자리의 구역. */ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure); const gap = (a: CrossPoint, b: CrossPoint): number => Math.hypot(a.offset - b.offset, a.elevation - b.elevation); /** 점열이 한 직선 위에서 한 방향으로만 가는가(되접힘 없음). */ function isStraight(points: CrossPoint[]): boolean { const a = points[0]; const b = points[points.length - 1]; const length = gap(a, b); if (length < 1e-9) return false; const ux = (b.offset - a.offset) / length; const uy = (b.elevation - a.elevation) / length; let along = -STRAIGHT_TOL_M; for (const p of points) { const dx = p.offset - a.offset; const dy = p.elevation - a.elevation; if (Math.abs(ux * dy - uy * dx) > STRAIGHT_TOL_M) return false; const t = ux * dx + uy * dy; if (t < along - STRAIGHT_TOL_M) return false; along = Math.max(along, t); } return true; } /** 그려진 선들 → 같은 방향 토막을 합친 직선 조각(설계선 · 구조물 선은 따로 합침). */ export function mergeStraightPieces(runs: DesignRun[]): DesignPiece[] { const chains: Array<{ points: CrossPoint[]; structure: boolean }> = []; for (const run of runs) { for (let i = 1; i < run.points.length; i += 1) { const start = run.points[i - 1]; const end = run.points[i]; if (gap(start, end) < 1e-6) continue; const last = chains[chains.length - 1]; if ( last && last.structure === run.structure && gap(last.points[last.points.length - 1], start) < 1e-3 && isStraight([...last.points, end]) ) last.points.push(end); else chains.push({ points: [start, end], structure: run.structure }); } } // 차례가 떨어진 토막도 끝이 닿고 한 직선이면 합친다(방향이 거꾸로 그려진 선 포함). const joined = (p: CrossPoint[], q: CrossPoint[]): CrossPoint[] | null => { for (const a of [p, [...p].reverse()]) for (const b of [q, [...q].reverse()]) if (gap(a[a.length - 1], b[0]) < 1e-3 && isStraight([...a, ...b.slice(1)])) return [...a, ...b.slice(1)]; return null; }; let merged = true; while (merged) { merged = false; for (let i = 0; i < chains.length && !merged; i += 1) for (let j = i + 1; j < chains.length && !merged; j += 1) { if (chains[i].structure !== chains[j].structure) continue; const points = joined(chains[i].points, chains[j].points); if (!points) continue; chains[i] = { points, structure: chains[i].structure }; chains.splice(j, 1); merged = true; } } return chains.map((chain) => ({ from: chain.points[0], to: chain.points[chain.points.length - 1], structure: chain.structure, })); } /** 벽 · 본체 폴리곤(m)에 가린 부분을 뺀 **보이는 조각**만. */ export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): DesignPiece[] { const polygons = occluders .filter((polygon) => polygon.length >= 3) .map((polygon) => polygon.map((p) => [p.offset, p.elevation] as XY)); const out: DesignPiece[] = []; for (const piece of pieces) { let runs: XY[][] = [ [ [piece.from.offset, piece.from.elevation], [piece.to.offset, piece.to.elevation], ], ]; for (const polygon of polygons) runs = runs.flatMap(([a, b]) => clipSegment(a, b, polygon, "outside")); for (const [a, b] of runs) { const from = { offset: a[0], elevation: a[1] }; const to = { offset: b[0], elevation: b[1] }; if (gap(from, to) >= MIN_PIECE_M) out.push({ from, to, structure: piece.structure }); } } return out; } /** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */ export function pieceMeasure( piece: DesignPiece, zone: CrossZone, ): { lengthM: number; horizontalM: number; heightM: number; grade: string } { const horizontalM = Math.abs(piece.to.offset - piece.from.offset); const heightM = Math.abs(piece.to.elevation - piece.from.elevation); const grade = zone === "road" ? horizontalM < 1e-6 ? "—" : `${((heightM / horizontalM) * 100).toFixed(1)}%` : heightM < 1e-4 ? "0.0%" : `1:${(horizontalM / heightM).toFixed(2)}`; return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade }; } export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string { const { zone } = pieceZone(design, piece); const m = pieceMeasure(piece, zone); return ( `${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` + `수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m · ${m.grade}` ); } /* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */ let picked: { stationId: string; index: number } | null = null; export const pickedLineAt = (stationId: string): number | null => picked?.stationId === stationId ? picked.index : null; export function setPickedLine(stationId: string, index: number | null): void { if (index !== null) picked = { stationId, index }; else if (picked?.stationId === stationId) picked = null; } /* ── 카드 배선 ─────────────────────────────────────────────────────────── */ const pointsOf = (element: Element): XY[] => { if (element.tagName.toLowerCase() === "line") { const n = (name: string) => Number(element.getAttribute(name)); return [ [n("x1"), n("y1")], [n("x2"), n("y2")], ]; } return (element.getAttribute("points") ?? "") .trim() .split(/\s+/) .map((pair) => pair.split(",").map(Number) as XY) .filter((p) => p.length === 2 && p.every(Number.isFinite)); }; export interface DesignLinePick { pieces: DesignPiece[]; /** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */ label: HTMLElement; setActive: (index: number | null) => void; } /** * 그려진 보라 선에 고름을 단다 — 벽 · 관 등을 다 그린 **뒤**(가림 · 판정면 차례) 부른다. * `x` · `toDisplayY` = 카드 좌표 함수(1차식 — 역산해 m 로 되돌림). */ export function attachDesignLinePick(input: { layer: SVGElement; design: ZoneDesign; x: (offset: number) => number; toDisplayY: (elevation: number) => number; onPick: (index: number) => void; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; const x0 = x(0); const kx = x(1) - x0; const y0 = toDisplayY(0); const ky = toDisplayY(1) - y0; const toM = ([px, py]: XY): CrossPoint => ({ offset: (px - x0) / kx, elevation: (py - y0) / ky }); const kids = [...layer.children]; const runs: DesignRun[] = kids .filter((el) => el.classList.contains(DESIGN_CLASS)) .map((el) => ({ points: pointsOf(el).map(toM), structure: !el.hasAttribute(DESIGN_LINE_ATTR), })); const occluders = kids .filter( (el) => el.tagName.toLowerCase() === "polygon" && OCCLUDER_CLASSES.some((name) => el.classList.contains(name)), ) .map((el) => pointsOf(el).map(toM)); const pieces = visiblePieces(mergeStraightPieces(runs), occluders); // 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김. const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); const hits = pieces.map((piece, index) => { const hit = document.createElementNS(SVG_NS, "polyline"); hit.setAttribute( "points", [piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), ); hit.setAttribute("class", HIT_CLASS); hit.addEventListener("click", (event) => { event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음 input.onPick(index); }); layer.insertBefore(hit, firstStructureHit ?? null); return hit; }); const label = document.createElement("span"); label.className = "b06-cross-card__line-dims"; label.hidden = true; return { pieces, label, setActive: (index) => { const piece = index === null ? undefined : pieces[index]; hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index)); label.hidden = !piece; label.textContent = piece ? pieceLabel(design, piece) : ""; }, }; }