/* ============================================================================= * 2D 지도 오버레이 도형 (B04 지도 · B05 배수유역도 공용) * * 유역 채움·번호 배지·분수령 파선·상류 세류망처럼 **배경 레이어 위에 얹는** 도형만 모았다. * 좌표 변환과 레이어 렌더는 `B04_PreProcess_UI_MapRender.ts`가 맡는다 — 그 파일이 700줄 * 한계에 닿아 분리했다. * ========================================================================== */ import { themeColor } from "@ui/ui_template_palette"; import { stationLabel } from "@util/common_util_svg"; /** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */ const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)"); import { haloColor, lonLatToScreen, type Normalizer, type ViewState, } from "./B04_PreProcess_UI_MapRender"; export type FilledRing = { ring: ReadonlyArray; /** * 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** — * 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만. */ rings?: ReadonlyArray>; /** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */ label?: string; }; /** * lon/lat 폴리곤 링을 파스텔 채움 + 테두리 + 중심 번호로 그린다. * 사전 투영 캐시를 쓰지 않는 소량(유역 수 개) 오버레이 전용이라 매 프레임 변환해도 부담이 없다. */ export function drawFilledRing( context: CanvasRenderingContext2D, entry: FilledRing, normalizer: Normalizer, view: ViewState, color: string, ): void { if (entry.ring.length < 3) return; const rings = entry.rings?.length ? entry.rings : [entry.ring]; let sumX = 0; let sumY = 0; context.beginPath(); rings.forEach((ring) => { if (ring.length < 3) return; ring.forEach(([lon, lat], index) => { const [x, y] = lonLatToScreen(normalizer, view, lon, lat); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.closePath(); }); // 번호 자리는 바깥 링만 보고 잡는다 — 구멍까지 섞으면 중심이 유역 밖으로 밀린다. entry.ring.forEach(([lon, lat]) => { const [x, y] = lonLatToScreen(normalizer, view, lon, lat); sumX += x; sumY += y; }); context.fillStyle = color; context.fill("evenodd"); context.strokeStyle = color; context.lineWidth = 1.6; context.stroke(); if (!entry.label) return; drawRingBadge(context, [sumX / entry.ring.length, sumY / entry.ring.length], color, entry.label); } /** 유역 번호 배지 한 개(라운드 사각 + 숫자). 화면 좌표를 그대로 받는다. * * 채움과 따로 떼어 둔 이유: 번호는 **맨 위에** 있어야 한다. 채움과 함께 그리면 그 위에 * 얹히는 등고선·화살표·관 마커에 가려 읽을 수 없다(2026-08-01 사용자 지시). * * 원이 아니라 사각인 이유: 지도 위 다른 숫자들도 전부 동그라미라 크기로만 갈렸다. * 형태를 바꿔 한눈에 구분되게 했다 — 크기는 그대로다(2026-08-17 사용자 지시). */ export function drawRingBadge( context: CanvasRenderingContext2D, center: readonly [number, number], color: string, label: string, ): void { const [centerX, centerY] = center; const half = 11; context.save(); context.beginPath(); context.roundRect(centerX - half, centerY - half, half * 2, half * 2, 6); context.fillStyle = color; context.fill(); context.strokeStyle = haloColor(); context.lineWidth = 1.5; context.stroke(); context.font = "600 12px sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; context.fillStyle = themeColor("--map-label-text", "#1f2937"); context.fillText(label, centerX, centerY); context.restore(); } /** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */ /** * 점이 조각·구멍으로 이루어진 유역 안에 있는가 — 링마다 홀짝을 뒤집는 even-odd 판정. * 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않는다. */ export function pointInRings( rings: ReadonlyArray>, x: number, y: number, ): boolean { let inside = false; for (const ring of rings) { for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { const [xi, yi] = ring[index]; const [xj, yj] = ring[previous]; if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; } } return inside; } export function ringCenterOnScreen( ring: ReadonlyArray, normalizer: Normalizer, view: ViewState, ): [number, number] { let sumX = 0; let sumY = 0; ring.forEach(([lon, lat]) => { const [x, y] = lonLatToScreen(normalizer, view, lon, lat); sumX += x; sumY += y; }); return [sumX / ring.length, sumY / ring.length]; } /** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */ export function drawUpstreamLines( context: CanvasRenderingContext2D, lines: ReadonlyArray>, normalizer: Normalizer, view: ViewState, ): void { if (lines.length === 0) return; context.save(); context.setLineDash([]); context.lineWidth = 4; context.lineCap = "round"; context.lineJoin = "round"; context.strokeStyle = upstreamLineColor(); lines.forEach((line) => { if (line.length < 2) return; context.beginPath(); line.forEach(([lon, lat], index) => { const [x, y] = lonLatToScreen(normalizer, view, lon, lat); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); }); context.restore(); } /** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */ export function drawRidgeRing( context: CanvasRenderingContext2D, ring: ReadonlyArray, normalizer: Normalizer, view: ViewState, ): void { if (ring.length < 3) return; context.beginPath(); ring.forEach(([lon, lat], index) => { const [x, y] = lonLatToScreen(normalizer, view, lon, lat); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.closePath(); context.save(); context.strokeStyle = themeColor("--map-basin-outline", "#92400e"); context.lineWidth = 1.8; context.setLineDash([7, 4]); context.stroke(); context.restore(); } /* ----------------------------------------------------------------------------- * 계획선 위 측점 눈금·번호 (2026-09-04 사용자 지시) * * 종단·3D와 같은 `측점번호+잔여거리` 표기다. 배율이 낮으면 글자가 붙으므로 3D 라벨과 같은 * 단계 규칙으로 솎는다(5칸 → 2칸 → 전부). 관 마커가 있는 측점은 라벨을 계획선 **반대쪽** * 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다. * -------------------------------------------------------------------------- */ export interface StationTickOptions { /** 규칙 측점 간격(m). */ intervalM: number; /** 화면 1m 당 픽셀 — 라벨 솎기 단계를 여기서 정한다. */ pxPerMeter: number; toScreen: (x: number, y: number) => [number, number]; /** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */ avoidChainages?: ReadonlyArray; } export function drawStationTicks( context: CanvasRenderingContext2D, points: ReadonlyArray<{ x: number; y: number }>, options: StationTickOptions, ): void { if (points.length < 2) return; const interval = options.intervalM > 0 ? options.intervalM : 20; // 라벨 사이가 좁아지면 솎는다 — 화면에서 잰 간격(px)으로 정한다. const gapPx = interval * options.pxPerMeter; const step = gapPx >= 90 ? 1 : gapPx >= 40 ? 2 : 5; const avoid = options.avoidChainages ?? []; // 정점 누가거리 — 측점 자리는 정점 사이에 떨어지므로 보간해서 찍는다. const cumulative: number[] = [0]; for (let index = 1; index < points.length; index += 1) { cumulative.push( cumulative[index - 1] + Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y), ); } const total = cumulative[cumulative.length - 1]; if (total <= 0) return; context.save(); context.font = "11px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; // 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과 // 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다). const drawn: Array<{ x: number; y: number; half: number }> = []; let cursor = 1; for (let chainage = 0; chainage <= total; chainage += interval) { const stationNo = Math.round(chainage / interval); if (stationNo % step !== 0) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; const back = points[cursor - 1]; const front = points[cursor]; const segment = cumulative[cursor] - cumulative[cursor - 1] || 1; const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment)); const px = back.x + (front.x - back.x) * ratio; const py = back.y + (front.y - back.y) * ratio; const [sx, sy] = options.toScreen(px, py); const [bx, by] = options.toScreen(back.x, back.y); const [fx, fy] = options.toScreen(front.x, front.y); const dx = fx - bx; const dy = fy - by; const length = Math.hypot(dx, dy) || 1; // 계획선에 직각인 방향 — 눈금과 라벨을 이 방향으로 놓는다. const ux = -dy / length; const uy = dx / length; const nearPipe = avoid.some((pipe) => Math.abs(pipe - chainage) < interval / 2); const side = nearPipe ? -1 : 1; context.beginPath(); context.moveTo(sx - ux * 6, sy - uy * 6); context.lineTo(sx + ux * 6, sy + uy * 6); context.lineWidth = 1.2; context.strokeStyle = "rgba(40, 40, 40, 0.85)"; context.stroke(); const label = stationLabel(chainage, interval); const lx = sx + ux * side * 16; const ly = sy + uy * side * 16; const width = context.measureText(label).width + 6; const half = width / 2; const collides = drawn.some( (item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16, ); if (collides) continue; drawn.push({ x: lx, y: ly, half }); // 배경을 깔아 등고선 위에서도 읽히게 한다. context.fillStyle = "rgba(255, 255, 255, 0.78)"; context.fillRect(lx - half, ly - 8, width, 16); context.fillStyle = "#222222"; context.fillText(label, lx, ly); } context.restore(); }