import { PMTiles } from 'https://esm.sh/pmtiles@3.0.7'; const MAX_LEVEL = 20; const CLICK_HIGHLIGHT_OUTER_WIDTH = 10; // 바깥쪽 흰색 테두리 두께 const CLICK_HIGHLIGHT_INNER_WIDTH = 5; // 안쪽 선 두께 const HOVER_HIGHLIGHT_OUTER_WIDTH = 6; // 바깥쪽 흰색 테두리 두께 const HOVER_HIGHLIGHT_INNER_WIDTH = 2; // 안쪽 선 두께 // 선택/호버 상태 추적 let selectedFeature = null; let hoveredFeature = null; let selectLayer = null; let hoverLayer = null; // 현재 선택된 feature 저장 let currentSelectedFeature = null; // 선택된 feature의 레이어 키 추가 let currentSelectedLayerKey = null; // ---- OpenLayers VectorTile Layer ---- class OLVectorLayer { constructor(olMap, layerId) { this.olMap = olMap; this.layerId = layerId; this.type = null; this.templateUrl = null; this.pmtilesInstance = null; this.maxLevel = MAX_LEVEL; this.vectorLayer = null; } async setTileSource(source, type, zoomRange = null) { this.type = type; if (type === 'pbf') { this.templateUrl = source; try { const baseUrl = source.replace('/{z}/{x}/{y}.pbf', ''); const metadataUrl = `${baseUrl}/metadata.json`; const response = await fetch(metadataUrl); const metadata = await response.json(); if (metadata.maxzoom) { this.maxLevel = parseInt(metadata.maxzoom); } } catch (error) { console.warn(`[${this.layerId}] Could not read metadata.json`); } this.vectorLayer = new ol.layer.VectorTile({ source: new ol.source.VectorTile({ format: new ol.format.MVT(), url: source, maxZoom: this.maxLevel }), style: styleFunction, renderMode: 'vector', zIndex: 1, // 줌 범위 설정 minZoom: zoomRange ? zoomRange.min : undefined, maxZoom: zoomRange ? zoomRange.max : undefined }); this.olMap.addLayer(this.vectorLayer); const zoomInfo = zoomRange ? ` (zoom ${zoomRange.min}-${zoomRange.max})` : ''; console.log(`[${this.layerId}] PBF Vector layer initialized${zoomInfo}`); } else if (type === 'pmtiles') { this.pmtilesInstance = source; try { const metadata = await this.pmtilesInstance.getMetadata(); if (metadata.maxzoom) { this.maxLevel = parseInt(metadata.maxzoom); } // console.log(`[${this.layerId}] PMTiles metadata:`, metadata); } catch (error) { console.warn(`[${this.layerId}] Could not read PMTiles metadata`); } this.vectorLayer = new ol.layer.VectorTile({ source: new ol.source.VectorTile({ format: new ol.format.MVT(), tileUrlFunction: (tileCoord) => { const z = tileCoord[0]; const x = tileCoord[1]; const y = tileCoord[2]; return `pmtiles://${this.layerId}/${z}/${x}/${y}`; }, tileLoadFunction: async (tile, url) => { const urlParts = url.split('/'); const z = parseInt(urlParts[urlParts.length - 3]); const x = parseInt(urlParts[urlParts.length - 2]); const y = parseInt(urlParts[urlParts.length - 1]); // console.log(`Loading PMTiles tile: ${z}/${x}/${y}`); try { const tileData = await this.pmtilesInstance.getZxy(z, x, y); if (tileData && tileData.data) { // console.log(`PMTiles tile ${z}/${x}/${y} loaded, size: ${tileData.data.byteLength} bytes`); const format = new ol.format.MVT(); const tileGrid = ol.tilegrid.createXYZ({maxZoom: this.maxLevel}); const tileExtent = tileGrid.getTileCoordExtent([z, x, y]); const features = format.readFeatures(tileData.data, { extent: tileExtent, featureProjection: 'EPSG:3857' }); // console.log(`PMTiles tile ${z}/${x}/${y} features: ${features.length}`); if (tile.setFeatures) { tile.setFeatures(features); } else { tile.setLoader(() => Promise.resolve(features)); } } else { // console.log(`PMTiles tile ${z}/${x}/${y} - no data`); if (tile.setFeatures) { tile.setFeatures([]); } else { tile.setLoader(() => Promise.resolve([])); } } } catch (error) { // console.error(`Failed to load PMTiles tile ${z}/${x}/${y}:`, error); if (tile.setFeatures) { tile.setFeatures([]); } else { tile.setLoader(() => Promise.resolve([])); } } }, maxZoom: this.maxLevel }), style: styleFunction, zIndex: 1, updateWhileAnimating: false, updateWhileInteracting: false, renderBuffer: 50, declutter: false, // 줌 범위 설정 minZoom: zoomRange ? zoomRange.min : undefined, maxZoom: zoomRange ? zoomRange.max : undefined }); this.olMap.addLayer(this.vectorLayer); // this.pmtilesInstance = source; // try { // const metadata = await this.pmtilesInstance.getMetadata(); // if (metadata.maxzoom) this.maxLevel = parseInt(metadata.maxzoom, 10); // } catch (e) { // console.warn(`[${this.layerId}] Could not read PMTiles metadata`); // } // // PMTiles가 512 기준이면 512 권장 // const tileSize = 256; // const tileGrid = ol.tilegrid.createXYZ({ // maxZoom: this.maxLevel, // tileSize // }); // // 기존 styleFunction 그대로 사용 // const styleFn = styleFunction; // // 벡터 → 래스터 변환용 레이어 // this.rasterLayer = new ol.layer.Tile({ // source: new ol.source.TileImage({ // tileGrid, // tileUrlFunction: (tileCoord) => { // const [z, x, y] = tileCoord; // return `pmtiles://${this.layerId}/${z}/${x}/${y}`; // }, // tileLoadFunction: async (imageTile, url) => { // const parts = url.split('/'); // const z = parseInt(parts[parts.length - 3], 10); // const x = parseInt(parts[parts.length - 2], 10); // const y = parseInt(parts[parts.length - 1], 10); // // 타일 extent (EPSG:3857) // const tileExtent = tileGrid.getTileCoordExtent([z, x, y]); // const resolution = tileGrid.getResolution(z); // // 해상도/부하 트레이드오프 // const pixelRatio = 1; // 필요시 window.devicePixelRatio // const canvas = document.createElement('canvas'); // canvas.width = tileSize * pixelRatio; // canvas.height = tileSize * pixelRatio; // const ctx = canvas.getContext('2d'); // // ★ extent 반영하여 벡터 컨텍스트 생성 // const vectorCtx = ol.render.toContext(ctx, { // size: [tileSize * pixelRatio, tileSize * pixelRatio], // pixelRatio, // extent: tileExtent // ← 중요: 지도좌표 → 픽셀 변환 기준 // }); // try { // const tileData = await this.pmtilesInstance.getZxy(z, x, y); // if (tileData && tileData.data) { // // MVT 파싱 (extent는 featureProjection과 동일 좌표계여야 함) // const format = new ol.format.MVT(); // const features = format.readFeatures(tileData.data, { // extent: tileExtent, // EPSG:3857 // featureProjection: 'EPSG:3857' // }); // // zIndex 반영을 위해 (feature, style) 쌍 정렬 // const drawQueue = []; // for (const f of features) { // let s = styleFn ? styleFn(f, resolution) : null; // if (!s) continue; // const styles = Array.isArray(s) ? s : [s]; // for (const one of styles) { // if (one && typeof one.getZIndex === 'function') { // const zi = one.getZIndex() ?? 0; // drawQueue.push({ f, style: one, z: zi }); // } // } // } // drawQueue.sort((a, b) => a.z - b.z); // for (const { f, style } of drawQueue) { // vectorCtx.drawFeature(f, style); // } // } else { // // 데이터 없음 → 투명 타일 // ctx.clearRect(0, 0, canvas.width, canvas.height); // } // imageTile.getImage().src = canvas.toDataURL('image/png'); // } catch (err) { // console.warn(`PMTiles raster tile load failed ${z}/${x}/${y}`, err); // const empty = document.createElement('canvas'); // empty.width = tileSize; // empty.height = tileSize; // imageTile.getImage().src = empty.toDataURL(); // } // }, // // crossOrigin: 'anonymous', // }), // zIndex: 100000, // minZoom: zoomRange ? zoomRange.min : undefined, // maxZoom: zoomRange ? zoomRange.max : undefined // }); // this.olMap.addLayer(this.rasterLayer); const zoomInfo = zoomRange ? ` (zoom ${zoomRange.min}-${zoomRange.max})` : ''; console.log(`[${this.layerId}] PMTiles Vector layer initialized${zoomInfo}`); try { const metadata = await this.pmtilesInstance.getMetadata(); // PMTiles metadata에서 bounds 정보 확인 if (metadata && (metadata.bounds || metadata.antimeridian_adjusted_bounds || metadata.center)) { let extent; let bounds; if (metadata.bounds) bounds = metadata.bounds; if (metadata.antimeridian_adjusted_bounds) bounds = metadata.antimeridian_adjusted_bounds.split(','); if (bounds) { // bounds: [minLon, minLat, maxLon, maxLat] const [minLon, minLat, maxLon, maxLat] = bounds; const bottomLeft = ol.proj.fromLonLat([minLon, minLat]); const topRight = ol.proj.fromLonLat([maxLon, maxLat]); extent = [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]]; } else if (metadata.center) { // center와 zoom을 이용한 대략적인 영역 계산 const [centerLon, centerLat, centerZoom] = metadata.center; const centerPoint = ol.proj.fromLonLat([centerLon, centerLat]); const resolution = this.olMap.getView().getResolutionForZoom(centerZoom || 10); const size = 1000; // 대략적인 크기 extent = [ centerPoint[0] - size * resolution, centerPoint[1] - size * resolution, centerPoint[0] + size * resolution, centerPoint[1] + size * resolution ]; } let pmtilesFitBtn = document.querySelector('.map-container .control-btn-wrap .pmtiles-fit-btn'); if (extent) { if (ol.map.getOverlays().getLength() == 0) { this.olMap.getView().fit(extent, { padding: [50, 50, 50, 50], // duration: 500, // constrainResolution: true, maxZoom: 18 }); // console.log(`[${this.layerId}] PMTiles layer fitted to bounds`); } pmtilesFitBtn.style.display = 'flex'; pmtilesFitBtn.addEventListener('click', async() => { this.olMap.getView().fit(extent, { padding: [50, 50, 50, 50], // duration: 500, // constrainResolution: true, maxZoom: 18 }); }) } } } catch (error) { console.warn(`[${this.layerId}] Could not fit PMTiles bounds:`, error); } } } // GeoJSON/KML용 벡터 소스 설정 메서드 async setVectorSource(url, type, zoomRange = null) { this.type = type; if (type === 'geojson') { console.log(`[${this.layerId}] Loading GeoJSON from: ${url}`); this.vectorLayer = new ol.layer.Vector({ source: new ol.source.Vector({ url: url, format: new ol.format.GeoJSON() }), style: styleFunction, zIndex: 1, updateWhileAnimating: false, updateWhileInteracting: false, renderBuffer: 50, declutter: false, // 줌 범위 설정 minZoom: zoomRange ? zoomRange.min : undefined, maxZoom: zoomRange ? zoomRange.max : undefined }); this.olMap.addLayer(this.vectorLayer); const zoomInfo = zoomRange ? ` (zoom ${zoomRange.min}-${zoomRange.max})` : ''; console.log(`[${this.layerId}] GeoJSON Vector layer initialized${zoomInfo}`); // GeoJSON 로드 완료 이벤트 처리 const source = this.vectorLayer.getSource(); source.once('change', () => { if (source.getState() === 'ready') { const features = source.getFeatures(); const featureCount = features.length; console.log(`[${this.layerId}] GeoJSON loaded: ${featureCount} features`); // feature 수에 따른 경고 if (featureCount > 10000) { console.warn(`⚠️ Large dataset detected (${featureCount} features). Performance may be affected.`); } // 로드된 features가 있으면 해당 영역으로 자동 줌 (단독 레이어인 경우만) if (features.length > 0 && !zoomRange) { const extent = source.getExtent(); this.olMap.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 18 }); } } }); } else if (type === 'kml') { console.log(`[${this.layerId}] Loading KML from: ${url}`); this.vectorLayer = new ol.layer.Vector({ source: new ol.source.Vector({ url: url, format: new ol.format.KML({ extractStyles: false, showPointNames: false }) }), style: styleFunction, zIndex: 1, updateWhileAnimating: false, updateWhileInteracting: false, renderBuffer: 50, declutter: false, // 줌 범위 설정 minZoom: zoomRange ? zoomRange.min : undefined, maxZoom: zoomRange ? zoomRange.max : undefined }); this.olMap.addLayer(this.vectorLayer); const zoomInfo = zoomRange ? ` (zoom ${zoomRange.min}-${zoomRange.max})` : ''; console.log(`[${this.layerId}] KML Vector layer initialized${zoomInfo}`); // KML 로드 완료 이벤트 처리 const source = this.vectorLayer.getSource(); source.once('change', () => { if (source.getState() === 'ready') { const features = source.getFeatures(); console.log(`[${this.layerId}] KML loaded: ${features.length} features`); if (features.length > 0 && !zoomRange) { const extent = source.getExtent(); this.olMap.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 18 }); } } }); } } clear() { if (this.vectorLayer) { this.olMap.removeLayer(this.vectorLayer); this.vectorLayer = null; console.log(`[${this.layerId}] Layer cleared`); } } } // ---- 통합 멀티 레이어 관리자 (OpenLayers) ---- export class OLMultiLayerManager { constructor(olMap) { this.olMap = olMap; this.layerMap = new Map(); this.pmtilesInstances = new Map(); console.log('OLMultiLayerManager initialized'); } async toggleLayer(layerId, format) { const key = `${format}_${layerId}`; if (this.layerMap.has(key)) { // 레이어 제거 // PMTiles 제거 시 연결된 detail GeoJSON도 함께 제거 const layer = this.layerMap.get(key); layer.clear(); this.layerMap.delete(key); if (this.pmtilesInstances.has(key)) { this.pmtilesInstances.delete(key); } console.log(`[${key}] Layer removed`); return false; } else { // 레이어 추가 로직 if (format === 'pbf') { const layer = new OLVectorLayer(this.olMap, key); const templateUrl = `http://172.16.41.52:3003/vector_tile_pbf/${layerId}/{z}/{x}/{y}.pbf`; await layer.setTileSource(templateUrl, 'pbf'); this.layerMap.set(key, layer); } else if (format === 'pmtiles') { // console.log(`🚀 Creating hybrid PMTiles + GeoJSON layers for: ${layerId}`); // // 1. PMTiles 레이어 추가 (zoom 0-17만 표시) // const pmtilesLayer = new OLVectorLayer(this.olMap, key); // const pmtilesPath = `http://172.16.41.52:3003/vector_tile_pmtiles/${layerId}.pmtiles`; // const pmtiles = new PMTiles(pmtilesPath); // this.pmtilesInstances.set(key, pmtiles); // await pmtilesLayer.setTileSource(pmtiles, 'pmtiles', { min: 0, max: 18.1 }); // this.layerMap.set(key, pmtilesLayer); // console.log(`✅ [${key}] PMTiles layer added (zoom 0-17)`); // // 2. GeoJSON 레이어 추가 (zoom 18-24만 표시) // const detailGeojsonKey = `geojson_detail_${layerId}`; // const detailGeojsonLayer = new OLVectorLayer(this.olMap, detailGeojsonKey); // const geojsonUrl = `http://172.16.41.52:3003/vector_tile_geojson/${layerId}.geojson`; // await detailGeojsonLayer.setVectorSource(geojsonUrl, 'geojson', { min: 17.9, max: 24 }); // this.layerMap.set(detailGeojsonKey, detailGeojsonLayer); // console.log(`✅ [${detailGeojsonKey}] Detail GeoJSON layer added (zoom 18-24)`); // console.log(`🎯 Hybrid layer setup complete. PMTiles will show at zoom 0-17, GeoJSON at zoom 18+`); const pmtilesLayer = new OLVectorLayer(this.olMap, key); // const pmtilesPath = `http://172.16.41.52:3003/vector_tile_pmtiles/${layerId}.pmtiles`; let pmtilesPath = `https://gsim-model.digitalarchive.work/pmtiles/vector/${layerId}.pmtiles`; if (layerId == 'testbim') pmtilesPath = `https://gsim-model.digitalarchive.work/pmtiles/vector/dsdj2.pmtiles`; // let mapName = layerId; // if (layerId == 'testbim') mapName = 'dsdj2'; // let pmtilesPath = `https://gsim-model.digitalarchive.work/pmtiles/vector/${mapName}.pmtiles`; let checkUrlExistsResult = await checkUrlExists(pmtilesPath); if (checkUrlExistsResult == true) { const pmtiles = new PMTiles(pmtilesPath); this.pmtilesInstances.set(key, pmtiles); await pmtilesLayer.setTileSource(pmtiles, 'pmtiles'); this.layerMap.set(key, pmtilesLayer); } else { let pmtilesFitBtn = document.querySelector('.map-container .control-btn-wrap .pmtiles-fit-btn'); pmtilesFitBtn.style.display = 'none'; return; } } else if (format === 'geojson') { const layer = new OLVectorLayer(this.olMap, key); const geojsonUrl = `http://172.16.41.52:3003/vector_tile_geojson/${layerId}.geojson`; await layer.setVectorSource(geojsonUrl, 'geojson'); this.layerMap.set(key, layer); } else if (format === 'kml') { const layer = new OLVectorLayer(this.olMap, key); const kmlUrl = `http://172.16.41.52:3003/vector_tile_kml/${layerId}.kml`; await layer.setVectorSource(kmlUrl, 'kml'); this.layerMap.set(key, layer); } // console.log(`[${key}] Layer added`); return true; } } clearAll() { this.layerMap.forEach(layer => layer.clear()); this.layerMap.clear(); this.pmtilesInstances.clear(); console.log('All OpenLayers layers cleared'); } getActiveLayers() { return Array.from(this.layerMap.keys()); } } // ---- 피처별 스타일 함수 ---- function styleFunction(feature, resolution) { const props = feature.getProperties(); const entityType = props?.EntityType; let color = props.Color || '#ff0000'; const constWidth = props.constWidth || 0; const ltscale = props.ltscale || 1.0; // let dashPattern = props.dashPattern; // if (dashPattern && typeof dashPattern === 'string') { // try { // // "[0.5,-0.25]" → [0.5, -0.25] // dashPattern = JSON.parse(dashPattern); // } catch (e) { // console.warn('dashPattern 파싱 실패:', dashPattern, e); // dashPattern = null; // } // } // ⭐ constWidth에 따른 선 두께 결정 let strokeWidth = 1; // 기본값 // if (constWidth === 0) { // strokeWidth = 1; // } else if (constWidth === 1) { // strokeWidth = 5; // } else { // // 향후 다른 값 대비 // // strokeWidth = 2 + (constWidth * 2); // // strokeWidth = 1 + (constWidth * 5); // strokeWidth = constWidth * 5; // } // if (constWidth === 0) { // strokeWidth = 1; // } else { // // 향후 다른 값 대비 // // strokeWidth = 2 + (constWidth * 2); // // strokeWidth = 1 + (constWidth * 5); // strokeWidth = constWidth * 10; // } // if (constWidth === 0) { // strokeWidth = 1; // } else if (constWidth > 0 && constWidth <= 1) { // strokeWidth = constWidth * 10; // } else { // strokeWidth = constWidth * 5; // } // if (constWidth === 0) { // strokeWidth = 1; // } else { // console.log(constWidth); // strokeWidth = constWidth*2; // } // // ⭐ dashPattern을 OpenLayers lineDash로 변환 // let lineDash = null; // if (dashPattern && Array.isArray(dashPattern) && dashPattern.length > 0) { // // const zoomFactor = Math.min(1 / resolution, 1.0); // const scaleFactor = 10; // 조정 가능 (0.3 ~ 1.0 추천) // lineDash = dashPattern.map(value => // Math.abs(value) * ltscale * scaleFactor // ); // } // 투명도 처리 const hexToRgba = (hex, alpha = 1) => { if (!hex || hex.length < 7) return `rgba(255, 0, 0, ${alpha})`; const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; const geomType = feature.getGeometry().getType(); if (entityType) { if (entityType === 'TEXT_POLYGON' || entityType === 'LINETYPE_PATTERN_TEXT') { return new ol.style.Style({ fill: new ol.style.Fill({ color: hexToRgba(color, 1.0) }), zIndex: 40 }); } else if (entityType === 'LINETYPE_PATTERN_SHAPE') { return new ol.style.Style({ fill: new ol.style.Fill({ color: hexToRgba(color, 0.4) }), stroke: new ol.style.Stroke({ color: hexToRgba(color, 1.0), width: strokeWidth, // lineDash: lineDash, lineCap: 'butt', lineJoin: 'miter' }), zIndex: 20 }); } else if (entityType === 'HATCH') { return new ol.style.Style({ fill: new ol.style.Fill({ color: hexToRgba(color, 0.4) }), stroke: new ol.style.Stroke({ color: hexToRgba(color, 1.0), width: strokeWidth, // lineDash: lineDash, lineCap: 'butt', lineJoin: 'miter' }), zIndex: 10 }); } else if (entityType === 'SOLID_LINE_POLYGON' || entityType === 'DASHED_LINE_POLYGON') { return new ol.style.Style({ fill: new ol.style.Fill({ color: hexToRgba(color, 1) }), stroke: new ol.style.Stroke({ color: hexToRgba(color, 1.0), width: strokeWidth, // lineDash: lineDash, lineCap: 'butt', lineJoin: 'miter' }), zIndex: 10 }); } else if (entityType === 'LINETYPE_PATTERN_SHAPE_LINE') { return new ol.style.Style({ stroke: new ol.style.Stroke({ color: hexToRgba(color, 1.0), width: strokeWidth, // lineDash: lineDash, lineCap: 'butt', lineJoin: 'miter' }), zIndex: 30 }); } else if (entityType === 'LWPOLYLINE' || entityType === 'LINE') { return new ol.style.Style({ stroke: new ol.style.Stroke({ color: hexToRgba(color, 1.0), width: strokeWidth, // lineDash: lineDash, lineCap: 'butt', lineJoin: 'miter' }), zIndex: 30 }); } } else { if (geomType === 'Polygon' || geomType === 'MultiPolygon') { return new ol.style.Style({ fill: new ol.style.Fill({ color: hexToRgba(color, 0.4) }), stroke: new ol.style.Stroke({ color: hexToRgba(color, 1.0), width: strokeWidth, // lineDash: lineDash, lineCap: 'butt', lineJoin: 'miter' }), zIndex: 20 }); } else if (geomType === 'LineString' || geomType === 'MultiLineString') { // let strokeWidth, color; // if (props.DIVI == '주곡선') { // strokeWidth = 2; // color = '#cccccc'; // } else { // strokeWidth = 1; // color = '#777777'; // } return new ol.style.Style({ stroke: new ol.style.Stroke({ color: hexToRgba(color, 1.0), width: strokeWidth, // lineDash: lineDash, lineCap: 'butt', lineJoin: 'miter' }), zIndex: 30 }); } else if (geomType === 'Point' || geomType === 'MultiPoint') { return new ol.style.Style({ image: new ol.style.Circle({ radius: 4, fill: new ol.style.Fill({ color: hexToRgba(color, 1.0) }) }), zIndex: 30 }); } } } // async function checkUrlExists(url) { // try { // const res = await fetch(url, { method: "HEAD" }); // if (res.ok) { // return true; // 200~299 → 정상 URL // } // return false; // 404, 403 등 // } catch (e) { // return false; // 네트워크 오류, CORS 오류 포함 // } // } async function checkUrlExists(url) { try { const res = await fetch(url, { method: "HEAD" }); // 200~299 return res.ok; } catch (e) { // 여기서 swallow(삼키기) return false; } }