/* ============================================================================= * B01_Dashboard_UI_ProjectDetail_Map.ts * 상세 지도 미리보기(PLAN 548-2) — 주소 지도(`UI_MapPreview`)와 같은 배경 타일 길(서버가 VWorld 를 받아 넘김) * 위에 노선 선 · 시점 · 종점. 라이브러리 없이 SVG 한 장(타일 = image · 선 = path) — 칸 너비에 맞춰 줄어듦. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; const W = 640; const H = 260; const TILE = 256; const PAD = 28; const MAX_ZOOM = 17; const MIN_ZOOM = 6; const NS = "http://www.w3.org/2000/svg"; /** 위경도 → 그 배율의 전역 픽셀(웹 메르카토르) */ function toPixel(lat: number, lon: number, zoom: number): [number, number] { const size = TILE * 2 ** zoom; const rad = (lat * Math.PI) / 180; return [ ((lon + 180) / 360) * size, ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * size, ]; } /** 선이 판(여백 뺌)에 다 드는 가장 큰 배율 */ export function fitZoom(points: [number, number][]): number { for (let zoom = MAX_ZOOM; zoom > MIN_ZOOM; zoom -= 1) { const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom)); const xs = px.map((p) => p[0]); const ys = px.map((p) => p[1]); if ( Math.max(...xs) - Math.min(...xs) <= W - 2 * PAD && Math.max(...ys) - Math.min(...ys) <= H - 2 * PAD ) { return zoom; } } return MIN_ZOOM; } function node( tag: K, attrs: Record, ): SVGElementTagNameMap[K] { const element = document.createElementNS(NS, tag); for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, String(value)); return element; } /** 노선 지도 — points = [lat, lon] 두 점 넘게 */ export function routeMap(points: [number, number][]): SVGSVGElement { const zoom = fitZoom(points); const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom)); const xs = px.map((p) => p[0]); const ys = px.map((p) => p[1]); // 판 왼쪽 위 = 선 가운데 - 판 반 const left = (Math.min(...xs) + Math.max(...xs)) / 2 - W / 2; const top = (Math.min(...ys) + Math.max(...ys)) / 2 - H / 2; const svg = node("svg", { viewBox: `0 0 ${W} ${H}`, class: "b01-pd-map" }); const limit = 2 ** zoom; for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + W) / TILE); tx += 1) { for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + H) / TILE); ty += 1) { if (tx < 0 || ty < 0 || tx >= limit || ty >= limit) continue; svg.append( node("image", { href: `${API_BASE_URL}/dashboard/map/tile/${zoom}/${tx}/${ty}`, x: tx * TILE - left, y: ty * TILE - top, width: TILE, height: TILE, }), ); } } const local = px.map(([x, y]) => [x - left, y - top] as const); svg.append( node("path", { class: "b01-pd-map__line", d: local.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" "), }), ); const ends: [readonly [number, number], string][] = [ [local[0], "시점"], [local[local.length - 1], "종점"], ]; for (const [[x, y], label] of ends) { svg.append(node("circle", { class: "b01-pd-map__end", cx: x, cy: y, r: 5 })); const text = node("text", { class: "b01-pd-map__label", x: x + 8, y: y - 6 }); text.textContent = label; svg.append(text); } return svg; }