/* ============================================================================= * B06_Section_UI_Cross_Shape_Pick.ts * 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2 · 요구 185). * * 그린 형상(`data-structure-id` 단 포장층 · 돌 무늬 둘레)마다 투명 판정면 — 누르면 그 구조물을 고름 * (다른 시설 형상 고름과 같은 창구 `onPick`). 지킬 것: * ① 보라 손잡이(설계선 판정 · 뒤에 붙음)가 위 — 판정면은 구조물 판정면 바로 앞(아래)에 꽂고 * 구조물 판정 클래스(`b06-chart__culvert-revet-hit`)를 안 씀(보라 판정이 그 앞에 꽂혀 형상 위로 옴). * ② 누름만 고름 — 눌렀다 3 px 넘게 움직이면(끌기) 고름 아님. * ③ 노면공 포장 고름과 안 섞임 — 노면공 범위 표식(`data-pave-type`) 없는 형상만 · 누름은 번지지 않음. * ④ 얇은 형상(물넘이 콘크리트 · 기본 배율 4 px)은 판정면을 아래쪽으로 `SHAPE_MIN_PX` 두께까지 늘림 — * 노면선 보라 판정 띠(굵기 10 · 위아래 5) 바깥 아래를 누르면 형상(294-7). * ========================================================================== */ import type { RevetHighlightSetter } from "./B06_Section_UI_Cross_Culvert"; const SVG_NS = "http://www.w3.org/2000/svg"; /** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */ export const SHAPE_PICK_EVENT = "b06-structure-shape-pick"; /** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */ export const SHAPE_DRAG_PX = 3; /** 형상 판정면 최소 두께(그림 px) — 보라 판정 띠 아래 반(5)보다 넉넉히(294-7). */ export const SHAPE_MIN_PX = 12; type XY = [number, number]; const parse = (points: string): XY[] => points .trim() .split(/\s+/) .map((pair) => pair.split(",").map(Number) as XY) .filter((p) => p.length === 2 && p.every(Number.isFinite)); const insideOf = (ring: XY[], [x, y]: XY): boolean => ring.reduce((inn, [ax, ay], i) => { const [bx, by] = ring[(i + 1) % ring.length]; return ay > y !== by > y && x < ax + ((y - ay) * (bx - ax)) / (by - ay) ? !inn : inn; }, false); /** 얇은 형상 → 판정면 꼭짓점 — 바로 아래가 형상 밖인 꼭짓점(아랫변)만 내려 두께를 `minPx` 로. * 두께 = 넓이 ÷ 가로 폭(기운 노면도 세로 두께로 잼). */ export function thickenDown(points: string, minPx = SHAPE_MIN_PX): string { const ring = parse(points); if (ring.length < 3) return points; const xs = ring.map(([x]) => x); const area = Math.abs( ring.reduce((sum, [x0, y0], i) => { const [x1, y1] = ring[(i + 1) % ring.length]; return sum + x0 * y1 - x1 * y0; }, 0) / 2, ); const drop = minPx - area / Math.max(Math.max(...xs) - Math.min(...xs), 1e-9); if (drop <= 0) return points; const midX = ring.reduce((sum, [x]) => sum + x, 0) / ring.length; const e = 1e-3; return ring .map(([x, y]): XY => { const probe: XY = [x + (midX > x ? e : midX < x ? -e : 0), y + e]; return insideOf(ring, probe) ? [x, y] : [x, y + drop]; }) .map((p) => p.join(",")) .join(" "); } /** 판정면을 꽂고 강조 setter 를 돌려줌 — 형상이 없으면 null. */ export function attachShapePick( layer: SVGElement, onPick?: (structureId: string) => void, ): ((active: boolean) => void) | null { const shapes = [...layer.querySelectorAll("[data-structure-id]")].filter( (el) => !el.hasAttribute("data-pave-type"), ); if (!shapes.length) return null; const kids = [...layer.children]; const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); for (const shape of onPick ? shapes : []) { const id = shape.getAttribute("data-structure-id") ?? ""; const hit = document.createElementNS(SVG_NS, "polygon"); hit.setAttribute("points", thickenDown(shape.getAttribute("points") ?? "")); hit.setAttribute("class", "b06-chart__shape-hit"); let down: { x: number; y: number } | null = null; hit.addEventListener("pointerdown", (event) => { down = { x: event.clientX, y: event.clientY }; }); hit.addEventListener("click", (event) => { const moved = down && Math.hypot(event.clientX - down.x, event.clientY - down.y) > SHAPE_DRAG_PX; down = null; if (moved) return; // 끌기 = 고름 아님(②) event.stopPropagation(); onPick?.(id); }); layer.insertBefore(hit, firstStructureHit ?? null); } return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active)); } /** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 물넘이 누가거리로 고름(카드의 다른 관 · * 연동 관 아님 · 294-7) · 그 밖(맹암거) = id 를 실은 알림을 위로 띄움(구조물 목록이 받음). */ export function shapePickRoute( from: Element, pickPipe: (chainageM: number) => void, ): (structureId: string) => void { return (structureId) => { if (structureId.startsWith("pipe-")) return pickPipe(Number(structureId.slice(5))); from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId })); }; } /** 카드 「관」 강조를 관 그림 · 물넘이 콘크리트로 가름(307) — 콘크리트를 고름(`concrete`) = 콘크리트만 · * 관을 고름 = 관만(관 카드에 걸친 물넘이포장 콘크리트를 눌러도 관 형상은 안 켬). */ export function splitPipePick( base: RevetHighlightSetter, setShape: (active: boolean) => void, concrete: () => boolean, ): RevetHighlightSetter { return (key) => { const shape = key === "pipe" && concrete(); base(shape ? null : key); setShape(shape); }; } /** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴 · 맹암거). 물넘이 * 콘크리트(`pipe-…`)는 형상을 직접 누른 「관」 고름만 켬 — 측점 길(측점선 · 알약 · 목록)은 안 켬(307). */ export function highlightShapes(root: ParentNode, structureId: string | null): void { for (const shape of root.querySelectorAll("[data-structure-id]")) { const id = shape.getAttribute("data-structure-id") ?? ""; if (id.startsWith("pipe-")) continue; shape.classList.toggle("is-active", !!structureId && id === structureId); } }