diff --git a/packages/common/business_components/src/ui/index.ts b/packages/common/business_components/src/ui/index.ts index 8604f80d5..4686e3a99 100644 --- a/packages/common/business_components/src/ui/index.ts +++ b/packages/common/business_components/src/ui/index.ts @@ -23,4 +23,5 @@ export * from "./title"; export * from "./sdmInsertForm"; export * from "./popover_ui"; export * from "./date_month_picker"; -export * from "./date_picker_range" \ No newline at end of file +export * from "./date_picker_range" +export * from "./map/GisOverlay"; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/GisOverlay.js b/packages/common/business_components/src/ui/map/GisOverlay.js new file mode 100644 index 000000000..5e4fb8948 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisOverlay.js @@ -0,0 +1,216 @@ +// Overlay manager (no React here) +import Overlay from 'ol/Overlay'; +import { getFeatureLabelCoordinate } from '../map/lib/util'; +import { buildCoordinateKeyFactory } from '../map/lib/util'; + +// ---- main factory ---- +export default function createOverlayManager(map, vectorLayer, overlayFormatter, options = {}) { + const coordPrecision = Math.max(0, Math.min(12, Number.isFinite(options.coordPrecision) ? options.coordPrecision : 6)); + const maxOverlaysPerViewport = Number.isFinite(options.maxOverlaysPerViewport) ? options.maxOverlaysPerViewport : Infinity; + const getCoordinateKey = buildCoordinateKeyFactory(coordPrecision); + + let currentFormatter = overlayFormatter; + const overlayMap = new Map(); // coordKey -> Overlay + const overlayElementMap = new Map(); // coordKey -> HTMLElement + const vectorSource = vectorLayer?.getSource?.(); + + const SYNC_MIN_INTERVAL = 120; + let lastSyncAt = 0; + + let isMounted = false; + let isActive = false; // when false, event handlers no-op + let rafId = null; + let idleClearId = null; + + + + const clearAllOverlaysAsync = (batch = 150) => { + const keys = Array.from(overlayMap.keys()); + if (!keys.length) return; + + // 재귀로 사용하는 이유 한번에 다지운는ㄴ것이아닌 150개 이상 더있을경우를 대비해서 다시 호출하기 위해(지도가 계속 멈춤) + const step = () => { + let iNum = 0; + const end = Math.min(iNum + batch, keys.length); + for (; iNum < end; iNum++) { + const k = keys[iNum]; + const ov = overlayMap.get(k); + try { if (ov) map.removeOverlay(ov); } catch {} + overlayMap.delete(k); + const el = overlayElementMap.get(k); + if (el) { try { el.remove?.(); } catch {} overlayElementMap.delete(k); } + } + if (iNum < keys.length) { + (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : setTimeout)(step, 0); + } + }; + + step(); + }; + + const scheduleSync = () => { + if (!isActive) return; + const now = Date.now(); + const since = now - lastSyncAt; + const run = () => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + try { syncOverlays(); } finally { lastSyncAt = Date.now(); } + }); + }; + if (since >= SYNC_MIN_INTERVAL) run(); else run(); + }; + + const onVisibleChange = () => { if (!isActive) return; scheduleSync(); }; + const onAddFeature = () => { if (!isActive) return; scheduleSync(); }; + const onChangeFeature = () => { if (!isActive) return; scheduleSync(); }; + const onRemoveFeature = () => { if (!isActive) return; scheduleSync(); }; + + const clearAllOverlays = () => { + overlayMap.forEach((ov) => { try { map.removeOverlay(ov); } catch {} }); + overlayMap.clear(); + overlayElementMap.clear(); + }; + + const ensureOverlay = (feature, properties) => { + const coord = getFeatureLabelCoordinate(feature); + if (!Array.isArray(coord)) return null; + const key = getCoordinateKey(coord); + + let el = overlayElementMap.get(key); + if (!el) { + el = currentFormatter(feature,properties); + overlayElementMap.set(key, el); + } + + let isOverlay = overlayMap.get(key); + if (!isOverlay) { + isOverlay = new Overlay({ + element: el, + positioning: 'bottom-center', + offset: [0, -18], + stopEvent: true, + }); + overlayMap.set(key, isOverlay); + map.addOverlay(isOverlay); + } + + isOverlay.setPosition(coord); + if (el && el.style) el.style.display = 'block'; + return isOverlay; + }; + + const syncOverlays = () => { + if (!vectorLayer?.getVisible?.()) { clearAllOverlays(); return; } + const view = map.getView?.(); + const size = map.getSize?.(); + const viewExtent = view && size ? view.calculateExtent(size) : null; + + // gather features in viewport only and group by coordinate + const groups = new Map(); // coordKey -> feature[] + const addFeature = (f) => { + const c = getFeatureLabelCoordinate(f); + if (!Array.isArray(c)) return; + const k = getCoordinateKey(c); + const arr = groups.get(k); + if (arr) arr.push(f); else groups.set(k, [f]); + }; + + if (vectorSource?.forEachFeatureInExtent && viewExtent) { + vectorSource.forEachFeatureInExtent(viewExtent, addFeature); + } else { + vectorSource?.forEachFeature(addFeature); + } + + // cap overlays per viewport with simple uniform sampling + const keys = Array.from(groups.keys()); + let selectedKeys; + if (!isFinite(maxOverlaysPerViewport) || keys.length <= maxOverlaysPerViewport) { + selectedKeys = keys; + } else { + const step = Math.ceil(keys.length / maxOverlaysPerViewport); + selectedKeys = []; + for (let i = 0; i < keys.length && selectedKeys.length < maxOverlaysPerViewport; i += step) { + selectedKeys.push(keys[i]); + } + } + + // ensure selected overlays + for (const k of selectedKeys) { + const featuresAtKey = groups.get(k); + const rep = featuresAtKey[0]; + const props = rep.getProperties?.() ?? {}; + if (featuresAtKey.length > 1) props.__groupCount = featuresAtKey.length; + ensureOverlay(rep, props); + } + + // remove non-selected overlays + const keep = new Set(selectedKeys); + for (const [k, ov] of overlayMap.entries()) { + if (!keep.has(k)) { + try { map.removeOverlay(ov); } catch {} + overlayMap.delete(k); + const el = overlayElementMap.get(k); + if (el) { try { el.remove?.(); } catch {} overlayElementMap.delete(k); } + } + } + }; + + const mount = () => { + if (isMounted) return; + vectorLayer?.on?.('change:visible', onVisibleChange); + vectorSource?.on?.('addfeature', onAddFeature); + vectorSource?.on?.('changefeature', onChangeFeature); + vectorSource?.on?.('removefeature', onRemoveFeature); + isMounted = true; + }; + + // Backward-compat: make dispose lightweight so it doesn't freeze the map + const dispose = () => { + isActive = false; // pause reactions + setVisible(false); // hide our labels only + const run = () => { clearAllOverlaysAsync(150); idleClearId = null; }; + if (typeof requestIdleCallback === 'function') idleClearId = requestIdleCallback(run, { timeout: 250 }); + else idleClearId = setTimeout(run, 180); + }; + + // Explicit lifecycle controls + const enable = () => { + isActive = true; + setVisible(true); + scheduleSync(); + }; + + const disable = ({ clear = false, delayed = true } = {}) => { + isActive = false; + setVisible(false); + if (clear) { + const run = () => { clearAllOverlaysAsync(150); idleClearId = null; }; + if (delayed && typeof requestIdleCallback === 'function') idleClearId = requestIdleCallback(run, { timeout: 250 }); + else idleClearId = setTimeout(run, delayed ? 180 : 0); + } + }; + + const destroy = () => { + if (!isMounted) return; + vectorLayer?.un?.('change:visible', onVisibleChange); + vectorSource?.un?.('addfeature', onAddFeature); + vectorSource?.un?.('changefeature', onChangeFeature); + vectorSource?.un?.('removefeature', onRemoveFeature); + isMounted = false; + if (rafId) cancelAnimationFrame(rafId), rafId = null; + if (idleClearId) { (typeof cancelIdleCallback === 'function' ? cancelIdleCallback : clearTimeout)(idleClearId); idleClearId = null; } + clearAllOverlaysAsync(200); + }; + + const setFormatter = (nextFormatter) => { currentFormatter = nextFormatter; syncOverlays(); }; + + const setVisible = (visible) => { + const display = visible ? 'block' : 'none'; + for (const el of overlayElementMap.values()) { + try { if (el && el.style) el.style.display = display; } catch {} + } + }; + + return { mount, dispose, enable, disable, destroy, setFormatter, syncOverlays, setVisible }; +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/lib/util.js b/packages/common/business_components/src/ui/map/lib/util.js index 9fe55869b..7de8c2fc4 100644 --- a/packages/common/business_components/src/ui/map/lib/util.js +++ b/packages/common/business_components/src/ui/map/lib/util.js @@ -594,3 +594,50 @@ export const isMobile = () => { return width < GLOBAL.MOBILE_SIZE; }; + + +export const getFeatureLabelCoordinate = (feature) => { + const geometry = feature?.getGeometry?.(); + if (!geometry || typeof geometry.getType !== 'function') return null; + + const type = geometry.getType(); + + // 1) 기본 타입 + if (type === 'Point') return geometry.getCoordinates(); + if (type === 'Polygon') return geometry.getInteriorPoint().getCoordinates(); + if (type === 'LineString') return geometry.getCoordinateAt(0.5); + + // 2) 멀티 타입들: 가능한 한 대표 좌표를 추출 + if (type === 'MultiPoint') { + const coords = geometry.getCoordinates?.(); + return (coords && coords[0]) ? coords[0] : null; + } + if (type === 'MultiLineString') { + try { return geometry.getLineString?.(0)?.getCoordinateAt?.(0.5) ?? null; } catch {} + } + if (type === 'MultiPolygon') { + try { return geometry.getInteriorPoints?.()?.getFirstCoordinate?.() ?? null; } catch {} + } + + // 3) 기타: extent 중심점 + try { + const extent = geometry.getExtent?.(); + if (extent) return [ + (extent[0] + extent[2]) / 2, + (extent[1] + extent[3]) / 2, + ]; + } catch {} + + return null; +}; + + // 수는 “좌표를 문자열 키로 변환하는 함수”를 + //좌표 정밀도(소수점 자리수)를 조절해서 근사한 좌표를 같은 그룹으로 취급할 수 있게 함. + export const buildCoordinateKeyFactory =(precision)=> { + const p = Math.max(0, Math.min(12, Number.isFinite(precision) ? precision : 6)); + return function getCoordinateKey(coordinate) { + const x = coordinate[0]; + const y = coordinate[1]; + return `${x.toFixed(p)}_${y.toFixed(p)}`; + }; + } \ No newline at end of file