/* ============================================================================= * 도로 유입 흐름 강도 오버레이 (B04 2D 지도) * * 계획선 위에 **1m 구간마다** 그 구간으로 모이는 상류 면적을 색으로 칠하고, 물이 특히 * 많이 모이는 자리(유입 집중점)를 마커로 찍는다. 마커를 고르면 그 지점으로 들어오는 * 셀들의 외곽선을 백엔드에서 받아 겹쳐 그린다. * * 계산은 전부 백엔드가 원본 격자(1m, 방향 지정 원본)에서 끝낸 값이다 — 여기서는 그리기만 * 한다. 색 단계는 로그 스케일이다. 계곡 한 지점이 사면보다 수백 배 크기 때문에 선형으로 * 칠하면 몇 점만 빨갛고 나머지는 전부 파랑으로 뭉친다(2026-08-01 사용자 지시). * ========================================================================== */ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; import { fetchRoadInflow, type VWorldMeta } from "./B04_wf1_Surface_Api_Fetch"; import { haloColor, lonLatToScreen, metricToScreen, type Normalizer, type ViewState, } from "./B04_wf1_Surface_UI_MapRender"; import { pointAtChainage, resampleRoute, type RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 강도 색띠 — 파랑(적음) → 빨강(많음). 정의처는 `ui_template_theme.css`. */ const RAMP_TOKENS: ReadonlyArray<[name: string, fallback: string]> = [ ["--map-flow-ramp-0", "#2563eb"], ["--map-flow-ramp-1", "#06b6d4"], ["--map-flow-ramp-2", "#22c55e"], ["--map-flow-ramp-3", "#eab308"], ["--map-flow-ramp-4", "#f97316"], ["--map-flow-ramp-5", "#dc2626"], ]; /** 강도 선 굵기(px). 계획선(2.4)보다 굵어야 그 위에 얹힌 것으로 읽힌다. */ const STRENGTH_LINE_WIDTH = 5; /** 마커 반지름(px) — 강도 최소~최대. 작게 둬서 지도를 가리지 않는다(사용자 지시). */ const MARKER_MIN_RADIUS = 4; const MARKER_MAX_RADIUS = 10; /** 마커를 눌렀다고 볼 여유(px). */ const MARKER_HIT_SLACK = 4; export type { RoutePoint }; export interface FlowStrengthOverlay { /** 지도 헤더 버튼 줄에 넣을 토글. */ button: HTMLButtonElement; visible: () => boolean; /** 선택 요약 문구(없으면 빈 문자열). */ status: () => string; /** 분석 결과를 받는다. 프로젝트가 바뀌면 `clear()`를 먼저 부른다. */ setData: ( profile: ReadonlyArray, hotspots: ReadonlyArray, ) => void; /** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */ setRoute: (points: ReadonlyArray, meta: VWorldMeta | null) => void; setProject: (projectId: string) => void; clear: () => void; /** 마커를 눌렀으면 true. 누른 것이 없으면 선택을 풀고 false. */ handleClick: (normalizer: Normalizer | null, view: ViewState, x: number, y: number) => boolean; draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void; dispose: () => void; } /** `#rrggbb` → [r,g,b]. 색띠는 hex로만 정의한다(보간을 위해). */ function parseHex(color: string): [number, number, number] { const hex = color.trim().replace("#", ""); const full = hex.length === 3 ? hex .split("") .map((c) => c + c) .join("") : hex; const value = Number.parseInt(full.slice(0, 6), 16); return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; } /** 0~1을 색띠 위에서 보간한다. */ function rampColor(t: number): string { const clamped = Math.min(1, Math.max(0, t)); const last = RAMP_TOKENS.length - 1; const position = clamped * last; const low = Math.min(last, Math.floor(position)); const high = Math.min(last, low + 1); const ratio = position - low; const a = parseHex(themeColor(RAMP_TOKENS[low][0], RAMP_TOKENS[low][1])); const b = parseHex(themeColor(RAMP_TOKENS[high][0], RAMP_TOKENS[high][1])); const mix = (index: number): number => Math.round(a[index] + (b[index] - a[index]) * ratio); return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`; } /** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */ function formatArea(areaM2: number): string { return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; } export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOverlay { const button = document.createElement("button"); button.type = "button"; // 기본 켜짐(2026-08-01 사용자 지시) — 분석 결과가 들어오면 바로 보이게 한다. let shown = true; button.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; button.textContent = L("B04_Surface_Flow_Strength"); button.style.setProperty("--b04-layer-color", themeColor("--map-flow-ramp-5", "#dc2626")); button.setAttribute("aria-pressed", "true"); button.title = L("B04_Surface_Flow_Strength_Tip"); button.addEventListener("click", () => { shown = !shown; button.classList.toggle("is-active", shown); button.setAttribute("aria-pressed", String(shown)); onChange(); }); let projectId: string | null = null; let meta: VWorldMeta | null = null; let routePoints: ReadonlyArray = []; /** 누가거리 1m 간격으로 다시 찍은 계획선 점 — 색칠·마커 위치의 기준. */ let samples: RoutePoint[] = []; /** 인덱스 = 누가거리(m), 값 = 그 1m 구간의 유입면적(㎡). */ let strength: Float64Array = new Float64Array(0); let maxStrength = 0; let hotspots: Array<{ chainage: number; area: number; zone: number; rank: number }> = []; let selected: number | null = null; let selectionRings: Array> = []; let statusText = ""; // 늦게 도착한 응답이 최신 선택을 덮어쓰지 않게 요청마다 번호를 매긴다. let requestSequence = 0; /** 로그 스케일 정규화 — 계곡 한 점이 사면보다 수백 배라 선형으로는 못 읽는다. */ function normalize(value: number): number { if (maxStrength <= 0 || value <= 0) return 0; return Math.log1p(value) / Math.log1p(maxStrength); } function drawStrengthLine(context: CanvasRenderingContext2D, view: ViewState): void { if (!meta || samples.length < 2 || strength.length === 0) return; context.save(); context.lineWidth = STRENGTH_LINE_WIDTH; context.lineCap = "round"; const limit = Math.min(strength.length, samples.length - 1); for (let i = 0; i < limit; i += 1) { const value = strength[i]; if (value <= 0) continue; // 유입 없는 구간은 계획선 원래 색을 그대로 둔다 const [x0, y0] = metricToScreen(meta, view, samples[i].x, samples[i].y); const [x1, y1] = metricToScreen(meta, view, samples[i + 1].x, samples[i + 1].y); context.strokeStyle = rampColor(normalize(value)); context.beginPath(); context.moveTo(x0, y0); context.lineTo(x1, y1); context.stroke(); } context.restore(); } function markerRadius(area: number): number { return MARKER_MIN_RADIUS + (MARKER_MAX_RADIUS - MARKER_MIN_RADIUS) * normalize(area); } function markerScreen(view: ViewState, chainage: number): [number, number] | null { if (!meta) return null; const point = pointAtChainage(samples, chainage); if (!point) return null; return metricToScreen(meta, view, point.x, point.y); } function drawMarkers(context: CanvasRenderingContext2D, view: ViewState): void { if (!meta || hotspots.length === 0) return; context.save(); context.textAlign = "center"; context.textBaseline = "middle"; hotspots.forEach((spot, index) => { const screen = markerScreen(view, spot.chainage); if (!screen) return; const [x, y] = screen; const radius = markerRadius(spot.area); context.beginPath(); context.arc(x, y, radius, 0, Math.PI * 2); context.fillStyle = rampColor(normalize(spot.area)); context.fill(); context.lineWidth = index === selected ? 3 : 1.5; context.strokeStyle = index === selected ? themeColor("--map-marker-text", "#111827") : haloColor(); context.stroke(); // 번호는 마커가 충분히 클 때만 안에 넣는다 — 작은 원에 글자를 넣으면 뭉갠다. context.font = "bold 9px sans-serif"; if (radius >= 8) { context.fillStyle = haloColor(); context.fillText(String(index + 1), x, y); } else { context.fillStyle = themeColor("--map-marker-text", "#111827"); context.strokeStyle = haloColor(); context.lineWidth = 2.5; context.strokeText(String(index + 1), x + radius + 6, y); context.fillText(String(index + 1), x + radius + 6, y); } }); context.restore(); } function drawSelection( context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState, ): void { if (!normalizer || selectionRings.length === 0) return; context.save(); context.lineWidth = 2.4; context.lineJoin = "round"; context.strokeStyle = themeColor("--map-inflow-outline", "#dc2626"); selectionRings.forEach((ring) => { 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.stroke(); }); context.restore(); } /** 선택한 마커의 기여 셀 외곽선을 받아 온다. */ async function loadSelection(index: number): Promise { const spot = hotspots[index]; if (!projectId || !spot) return; const sequence = ++requestSequence; statusText = L("B04_Surface_Flow_Inflow_Loading"); onChange(); try { const response = await fetchRoadInflow(projectId, spot.chainage); if (sequence !== requestSequence) return; // 더 최근 선택이 있다 selectionRings = response.rings_lonlat as Array>; statusText = L("B04_Surface_Flow_Inflow_Summary") .replace("{index}", String(index + 1)) .replace("{chainage}", spot.chainage.toFixed(1)) .replace("{area}", formatArea(response.area_m2)) .replace("{cells}", response.cell_count.toLocaleString()) .replace("{path}", response.max_path_length_m.toFixed(0)); } catch (error) { if (sequence !== requestSequence) return; selectionRings = []; statusText = error instanceof Error ? error.message : L("B04_Surface_Flow_Inflow_Failed"); } onChange(); } return { button, visible: () => shown, status: () => statusText, setProject(nextProjectId) { projectId = nextProjectId; }, setData(profile, spots) { const length = profile.reduce((max, [chainage]) => Math.max(max, chainage), 0); strength = new Float64Array(Math.floor(length) + 1); profile.forEach(([chainage, area]) => { const index = Math.round(chainage); if (index >= 0 && index < strength.length) strength[index] = area; }); // 노선이 길면 점이 수만 개가 되므로 spread(Math.max(...))로 최대를 구하지 않는다. maxStrength = strength.reduce((max, value) => (value > max ? value : max), 0); hotspots = spots.map(([chainage, area, zone, rank]) => ({ chainage, area, zone, rank })); selected = null; selectionRings = []; statusText = ""; // 진행 중인 유입 조회를 무효화한다 — 그러지 않으면 늦게 온 응답이 방금 지운 선택을 // 되살려 "고른 마커는 없는데 외곽선만 떠 있는" 상태가 된다. requestSequence += 1; }, setRoute(points, nextMeta) { routePoints = points; meta = nextMeta; samples = resampleRoute(routePoints); }, clear() { strength = new Float64Array(0); maxStrength = 0; hotspots = []; selected = null; selectionRings = []; statusText = ""; requestSequence += 1; }, handleClick(_normalizer, view, x, y) { if (!shown) return false; // 마커가 없어도 남은 외곽선은 지워 준다 — 안 그러면 지울 방법이 없다. if (hotspots.length === 0) { if (selected === null && selectionRings.length === 0) return false; selected = null; selectionRings = []; statusText = ""; requestSequence += 1; onChange(); return false; } let hit: number | null = null; let hitDistance = Number.POSITIVE_INFINITY; hotspots.forEach((spot, index) => { const screen = markerScreen(view, spot.chainage); if (!screen) return; const distance = Math.hypot(screen[0] - x, screen[1] - y); if (distance <= markerRadius(spot.area) + MARKER_HIT_SLACK && distance < hitDistance) { hit = index; hitDistance = distance; } }); if (hit === null) { // 마커 밖을 누르면 선택 해제. 도로 아무 데나 고를 수는 없다(사용자 지시). if (selected === null && selectionRings.length === 0) return false; selected = null; selectionRings = []; statusText = ""; requestSequence += 1; onChange(); return false; } if (selected === hit) { selected = null; selectionRings = []; statusText = ""; requestSequence += 1; onChange(); return true; } selected = hit; selectionRings = []; void loadSelection(hit); return true; }, draw(context, normalizer, view) { if (!shown) return; drawStrengthLine(context, view); drawSelection(context, normalizer, view); drawMarkers(context, view); }, dispose() { requestSequence += 1; }, }; }