import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { fetchGisGeoJson, fetchVWorldMeta, getVWorldMapUrl, type SurfaceBounds, type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; export interface SurfaceMapViewer { root: HTMLElement; render: (projectId: string, referenceBounds?: SurfaceBounds) => void; dispose: () => void; } type GeoJsonGeometry = { type: string; coordinates: unknown; }; type GeoJsonFeature = { geometry?: GeoJsonGeometry | null; }; type GeoJsonCollection = { features?: GeoJsonFeature[]; }; const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; const GIS_LAYERS = ["지적도", "수계망", "산사태", "행정구역_시군구", "행정구역_읍면동"] as const; type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number]; type GisLayer = (typeof GIS_LAYERS)[number]; const GIS_LAYER_COLORS: Record = { 지적도: "#f97316", 수계망: "#0ea5e9", 산사태: "#ef4444", 행정구역_시군구: "#7c3aed", 행정구역_읍면동: "#22c55e", }; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export function createSurfaceMapViewer(): SurfaceMapViewer { const root = document.createElement("section"); root.className = "b04-map"; const header = document.createElement("div"); header.className = "b04-map__header"; const title = document.createElement("h3"); title.textContent = L("B04_Surface_Map_Title"); const controls = document.createElement("div"); controls.className = "b04-map__controls"; const backgroundGroup = document.createElement("div"); backgroundGroup.className = "b04-map__control-group"; const backgroundTitle = document.createElement("span"); backgroundTitle.textContent = L("B04_Surface_Map_Background"); const backgroundButtons = document.createElement("div"); backgroundButtons.className = "b04-map__layer-buttons"; backgroundGroup.append(backgroundTitle, backgroundButtons); const gisGroup = document.createElement("div"); gisGroup.className = "b04-map__control-group"; const gisTitle = document.createElement("span"); gisTitle.textContent = L("B04_Surface_Map_GisLayer"); const gisButtons = document.createElement("div"); gisButtons.className = "b04-map__layer-buttons"; gisGroup.append(gisTitle, gisButtons); const resetButton = document.createElement("button"); resetButton.type = "button"; resetButton.textContent = L("B04_Surface_Map_Reset"); controls.append(backgroundGroup, gisGroup, resetButton); header.append(title, controls); const viewport = document.createElement("div"); viewport.className = "b04-map__viewport"; const backgroundImages = new Map(); BACKGROUND_LAYERS.forEach((layer) => { const image = document.createElement("img"); image.className = "b04-map__image"; image.alt = L("B04_Surface_Map_ImageAlt"); image.draggable = false; backgroundImages.set(layer, image); }); const canvas = document.createElement("canvas"); canvas.className = "b04-map__canvas"; const empty = document.createElement("p"); empty.className = "b04-map__empty"; empty.textContent = L("B04_Surface_Map_Empty"); const status = document.createElement("span"); status.className = "b04-map__status"; const scaleBar = document.createElement("div"); scaleBar.className = "b04-map__scale"; const scaleText = document.createElement("span"); scaleBar.append(scaleText); viewport.append( ...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!), canvas, empty, status, scaleBar, ); root.append(header, viewport); let currentProjectId: string | null = null; let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; const geoJsonLayers = new Map(); const activeBackgrounds = new Set(BACKGROUND_LAYERS); const activeGisLayers = new Set(GIS_LAYERS); let scale = 1; let offsetX = 0; let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; let loadSequence = 0; function makeLayerButton( label: string, activeLayers: Set, layer: T, color?: string, ): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "b04-map__layer-button is-active"; button.textContent = label; button.setAttribute("aria-pressed", "true"); if (color) { button.classList.add("b04-map__layer-button--gis"); button.style.setProperty("--b04-layer-color", color); } button.addEventListener("click", () => { if (activeLayers.has(layer)) activeLayers.delete(layer); else activeLayers.add(layer); const isActive = activeLayers.has(layer); button.classList.toggle("is-active", isActive); button.setAttribute("aria-pressed", String(isActive)); syncLayerVisibility(); }); return button; } const backgroundLabels: Record = { white: L("B04_Surface_Map_White"), satellite: L("B04_Surface_Map_Satellite"), hybrid: L("B04_Surface_Map_Hybrid"), }; BACKGROUND_LAYERS.forEach((layer) => { backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer)); }); const gisLabels: Record = { 지적도: L("B04_Surface_Map_Cadastral"), 수계망: L("B04_Surface_Map_Water"), 산사태: L("B04_Surface_Map_Landslide"), 행정구역_시군구: L("B04_Surface_Map_Sigungu"), 행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"), }; GIS_LAYERS.forEach((layer) => { gisButtons.append( makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]), ); }); function updateImageTransform(): void { backgroundImages.forEach((image) => { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; }); } function syncLayerVisibility(): void { backgroundImages.forEach((image, layer) => { image.hidden = !activeBackgrounds.has(layer); }); empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0; drawVectorLayer(); } function fitReferenceBounds(): void { if (!meta || !referenceBounds) return; const rect = viewport.getBoundingClientRect(); const width = Math.max(rect.width, 1); const height = Math.max(rect.height, 1); const mapRect = getMapRect(width, height); const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1); const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1); scale = Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9; const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2; const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2; const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; offsetX = -(baseX - width / 2) * scale; offsetY = -(baseY - height / 2) * scale; } function resetView(): void { scale = 1; offsetX = 0; offsetY = 0; fitReferenceBounds(); updateImageTransform(); drawVectorLayer(); } function getMapRect(width: number, height: number): DOMRect { if (!meta) return new DOMRect(0, 0, width, height); const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1); const viewportRatio = width / Math.max(height, 1); const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio; const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height; return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight); } function toCanvasPoint( lon: number, lat: number, width: number, height: number, ): [number, number] { if (!meta) return [0, 0]; const mapRect = getMapRect(width, height); const lonRange = meta.lon_max - meta.lon_min || 1; const latRange = meta.lat_max - meta.lat_min || 1; const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width; const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange); const centerX = width / 2; const centerY = height / 2; return [ centerX + (baseX - centerX) * scale + offsetX, centerY + (baseY - centerY) * scale + offsetY, ]; } function drawRing( context: CanvasRenderingContext2D, ring: unknown, width: number, height: number, closed: boolean, ): void { if (!Array.isArray(ring) || ring.length === 0) return; const points = ring.filter( (point): point is [number, number] => Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number", ); if (points.length === 0) return; context.beginPath(); points.forEach(([lon, lat], index) => { const [x, y] = toCanvasPoint(lon, lat, width, height); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); if (closed) context.closePath(); context.stroke(); } function drawGeometry( context: CanvasRenderingContext2D, geometry: GeoJsonGeometry, width: number, height: number, ): void { const coordinates = geometry.coordinates; if (!Array.isArray(coordinates)) return; if (geometry.type === "LineString") { drawRing(context, coordinates, width, height, false); } else if (geometry.type === "MultiLineString") { coordinates.forEach((line) => drawRing(context, line, width, height, false)); } else if (geometry.type === "Polygon") { coordinates.forEach((ring) => drawRing(context, ring, width, height, true)); } else if (geometry.type === "MultiPolygon") { coordinates.forEach((polygon) => { if (Array.isArray(polygon)) { polygon.forEach((ring) => drawRing(context, ring, width, height, true)); } }); } } function drawScaleBar(width: number, height: number): void { if (!meta || width <= 0) { scaleBar.hidden = true; return; } const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale; const meters = niceScaleDistance(100 * metersPerPixel); const pixels = meters / metersPerPixel; scaleBar.hidden = false; scaleBar.style.width = `${pixels}px`; scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; } function drawVectorLayer(): void { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; const context = canvas.getContext("2d"); if (!context) return; context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, height); context.lineWidth = 1.5; GIS_LAYERS.forEach((layer) => { if (!activeGisLayers.has(layer)) return; context.strokeStyle = GIS_LAYER_COLORS[layer]; geoJsonLayers.get(layer)?.features?.forEach((feature) => { if (feature.geometry) drawGeometry(context, feature.geometry, width, height); }); }); updateImageTransform(); drawScaleBar(width, height); } async function loadLayers(): Promise { if (!currentProjectId) return; const projectId = currentProjectId; const sequence = ++loadSequence; backgroundImages.forEach((image) => image.removeAttribute("src")); meta = null; geoJsonLayers.clear(); resetView(); status.textContent = L("B04_Surface_Map_Loading"); try { const nextMeta = await fetchVWorldMeta(projectId, "satellite"); const loadedLayers = await Promise.all( GIS_LAYERS.map(async (layer) => { try { const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection; return [layer, data] as const; } catch { return [layer, null] as const; } }), ); if (sequence !== loadSequence) return; meta = nextMeta; loadedLayers.forEach(([layer, data]) => { if (data) geoJsonLayers.set(layer, data); }); BACKGROUND_LAYERS.forEach((layer) => { backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; }); const featureCount = [...geoJsonLayers.values()].reduce( (sum, collection) => sum + (collection.features?.length ?? 0), 0, ); status.textContent = L("B04_Surface_Map_Features").replace( "{count}", featureCount.toLocaleString(), ); resetView(); syncLayerVisibility(); } catch (error) { if (sequence !== loadSequence) return; status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); } } resetButton.addEventListener("click", resetView); viewport.addEventListener( "wheel", (event) => { event.preventDefault(); scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); drawVectorLayer(); }, { passive: false }, ); viewport.addEventListener("pointerdown", (event) => { dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; viewport.setPointerCapture(event.pointerId); }); viewport.addEventListener("pointermove", (event) => { if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; drawVectorLayer(); }); const stopDragging = (): void => { dragStart = null; }; viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); const resizeObserver = new ResizeObserver(drawVectorLayer); resizeObserver.observe(viewport); return { root, render(projectId, nextReferenceBounds) { currentProjectId = projectId; referenceBounds = nextReferenceBounds ?? null; void loadLayers(); }, dispose() { loadSequence += 1; resizeObserver.disconnect(); }, }; }