UNPKG

react-ol-choropleth

Version:

A React plugin for creating choropleth maps using OpenLayers

448 lines (447 loc) 15.6 kB
import { jsx, jsxs } from "react/jsx-runtime"; import { useMemo, memo, useRef, useState, useCallback, useEffect } from "react"; import { createPortal } from "react-dom"; import Map from "ol/Map.js"; import View from "ol/View.js"; import TileLayer from "ol/layer/Tile.js"; import VectorLayer from "ol/layer/Vector.js"; import VectorSource from "ol/source/Vector.js"; import OSM from "ol/source/OSM.js"; import XYZ from "ol/source/XYZ.js"; import GeoJSON from "ol/format/GeoJSON.js"; import { Style, Stroke, Fill } from "ol/style.js"; import Polygon from "ol/geom/Polygon.js"; import Overlay from "ol/Overlay.js"; import chroma from "chroma-js"; const createColorScale = (colors) => { try { const validColors = colors.map((color) => chroma(color).hex()); return chroma.scale(validColors); } catch (e) { return chroma.scale(["#f7fbff", "#4292c6"]); } }; const useColorScale = ({ data, valueProperty, colorScale }) => { return useMemo(() => { if (!colorScale || !data.length) { return () => "#cccccc"; } const values = data.map((feature) => Number(feature.get(valueProperty))).filter((value) => !isNaN(value)).sort((a, b) => a - b); if (!values.length) { return () => "#cccccc"; } const min = values[0]; const max = values[values.length - 1]; const { type, colors } = colorScale; const scale = createColorScale(colors); switch (type) { case "sequential": { const numBreaks = colors.length; const breaks = Array.from({ length: numBreaks + 1 }, (_, i) => { const idx = Math.floor(i * (values.length - 1) / numBreaks); return values[idx]; }); return (value) => { if (isNaN(value)) return "#cccccc"; const breakIndex = breaks.findIndex( (_, i) => value >= breaks[i] && (i === breaks.length - 1 || value < breaks[i + 1]) ); if (breakIndex === -1) return colors[0]; if (breakIndex === breaks.length - 1) return colors[colors.length - 1]; const start = breaks[breakIndex]; const end = breaks[breakIndex + 1]; const normalizedWithinBreak = (value - start) / (end - start); return scale(breakIndex / (numBreaks - 1) + normalizedWithinBreak / numBreaks).hex(); }; } case "diverging": { const midpoint = values[Math.floor(values.length / 2)]; return (value) => { if (isNaN(value)) return "#cccccc"; let normalized; if (value <= midpoint) { normalized = (value - min) / (midpoint - min) * 0.5; } else { normalized = 0.5 + (value - midpoint) / (max - midpoint) * 0.5; } return scale(normalized).hex(); }; } case "categorical": { const uniqueValues = Array.from(new Set(values)); return (value) => { if (isNaN(value)) return "#cccccc"; const index = uniqueValues.indexOf(value); if (index === -1) return "#cccccc"; return colors[index % colors.length]; }; } default: return () => "#cccccc"; } }, [data, valueProperty, colorScale]); }; const Legend = ({ colorScale, position, values, className = "" }) => { const { type, colors } = colorScale; const [min, max] = [Math.min(...values), Math.max(...values)]; const getLabels = () => { if (type === "categorical") { return Array.from(new Set(values)).sort().map(String); } if (type === "diverging") { const mid = (max + min) / 2; return [min.toFixed(1), mid.toFixed(1), max.toFixed(1)]; } return colors.map((_, i) => { const value = min + i * (max - min) / (colors.length - 1); return value.toFixed(1); }); }; const labels = getLabels(); const legendColors = type === "categorical" ? colors.slice(0, labels.length) : colors; return /* @__PURE__ */ jsx( "div", { className: `react-ol-choropleth__legend ${className}`.trim(), "data-position": position, children: legendColors.map((color, i) => /* @__PURE__ */ jsxs("div", { className: "react-ol-choropleth__legend-item", children: [ /* @__PURE__ */ jsx( "div", { className: "react-ol-choropleth__legend-color", style: { backgroundColor: color } } ), /* @__PURE__ */ jsx("span", { className: "react-ol-choropleth__legend-label", children: labels[i] }) ] }, i)) } ); }; const generateOverlayContent = (feature) => { const properties = feature.getProperties(); const content = Object.entries(properties).filter(([key]) => key !== "geometry").map(([key, value]) => /* @__PURE__ */ jsxs("div", { className: "react-ol-choropleth__overlay-property", children: [ /* @__PURE__ */ jsxs("strong", { children: [ key, ":" ] }), " ", String(value) ] }, key)); return /* @__PURE__ */ jsx("div", { className: "react-ol-choropleth__overlay", children: content }); }; const debounce = (fn, ms = 300) => { let timeoutId; const debouncedFn = function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(this, args), ms); }; debouncedFn.cancel = () => clearTimeout(timeoutId); return debouncedFn; }; const ChoroplethMap = ({ data, valueProperty, colorScale, style, zoom = 2, baseMap = "osm", showLegend = true, legendPosition = "top-right", onFeatureClick, onFeatureHover, overlayOptions = { positioning: "bottom-center", offset: [0, -10], autoPan: true, trigger: "click" }, zoomToFeature = false, selectedFeatureBorderColor = "#0099ff", canZoomOutBoundaries = true, className = "", mapClassName = "", legendClassName = "" }) => { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const vectorSourceRef = useRef(null); const overlayRef = useRef(null); const overlayContainerRef = useRef(null); const [features, setFeatures] = useState([]); const selectedFeatureRef = useRef(null); const hoveredFeatureRef = useRef(null); const vectorLayerRef = useRef(null); const [overlayContent, setOverlayContent] = useState( null ); const vectorSource = useMemo(() => { const source = new VectorSource(); if (Array.isArray(data)) { source.addFeatures(data); } else { const geoJSON = new GeoJSON(); const features2 = geoJSON.readFeatures(data, { featureProjection: "EPSG:3857", dataProjection: "EPSG:4326" }); source.addFeatures(features2); } const loadedFeatures = source.getFeatures(); vectorSourceRef.current = source; setFeatures(loadedFeatures); return source; }, [data]); const getColor = useColorScale({ data: features, valueProperty, colorScale }); const styleFunction = useCallback( (feature) => { const value = feature.get(valueProperty); const color = getColor(Number(value)); const rgb = color === "#cccccc" ? [204, 204, 204] : chroma(color).rgb(); const isSelected = feature === selectedFeatureRef.current; const isHovered = feature === hoveredFeatureRef.current; const selectedColor = chroma(selectedFeatureBorderColor).rgb(); return new Style({ fill: new Fill({ color: [...rgb, 0.8] }), stroke: new Stroke({ color: isSelected || isHovered ? [...selectedColor, 1] : [61, 61, 61, 1], width: isSelected ? 3 : isHovered ? 2 : 1 }) }); }, [getColor, valueProperty, selectedFeatureBorderColor] ); const showOverlay = useCallback( (feature, coordinate) => { if (feature && overlayRef.current && overlayContainerRef.current && overlayOptions) { try { const content = overlayOptions.render ? overlayOptions.render(feature) : generateOverlayContent(feature); setOverlayContent(content); const geometry = feature.getGeometry(); if (geometry instanceof Polygon) { const extent = geometry.getExtent(); const position = coordinate || [ (extent[0] + extent[2]) / 2, extent[3] ]; requestAnimationFrame(() => { if (overlayRef.current) { overlayRef.current.setPosition(position); } }); } } catch (error) { console.error("Error updating overlay:", error); overlayRef.current.setPosition(void 0); setOverlayContent(null); } } else if (overlayRef.current) { overlayRef.current.setPosition(void 0); setOverlayContent(null); } }, [overlayOptions] ); const handleFeatureClick = useCallback( (event, map) => { const clickedFeature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature) || null; selectedFeatureRef.current = clickedFeature; if (overlayOptions && overlayOptions.trigger === "click") { showOverlay(clickedFeature); } if (clickedFeature) { if (onFeatureClick) { const coordinate = map.getCoordinateFromPixel(event.pixel); onFeatureClick(clickedFeature, coordinate); } if (zoomToFeature && mapInstanceRef.current) { const geometry = clickedFeature.getGeometry(); if (geometry instanceof Polygon) { const extent = geometry.getExtent(); const view = mapInstanceRef.current.getView(); const resolution = view.getResolutionForExtent( extent, mapInstanceRef.current.getSize() || void 0 ); const zoom2 = view.getZoomForResolution(resolution || 1); const position = [(extent[0] + extent[2]) / 2, extent[3]]; view.animate({ center: position, zoom: zoom2 ? Math.min(zoom2 + 0.5, 6) : 6, duration: 500 }); } } } else if (onFeatureClick) { onFeatureClick(null); } if (vectorLayerRef.current) { vectorLayerRef.current.changed(); } }, [onFeatureClick, zoomToFeature, overlayOptions, showOverlay] ); const handleFeatureHover = useCallback( (event, map) => { const hoveredFeature = event.type === "pointermove" ? map.forEachFeatureAtPixel(event.pixel, (feature) => feature) || null : null; hoveredFeatureRef.current = hoveredFeature; if (overlayOptions && overlayOptions.trigger === "hover") { const coordinate = hoveredFeature ? map.getCoordinateFromPixel(event.pixel) : void 0; showOverlay(hoveredFeature, coordinate); } if (onFeatureHover) { onFeatureHover(hoveredFeature); } if (vectorLayerRef.current) { vectorLayerRef.current.changed(); } }, [onFeatureHover, overlayOptions, showOverlay] ); useEffect(() => { if (!mapRef.current || !vectorSource) return; const vectorLayer = new VectorLayer({ source: vectorSource, style: style || styleFunction, updateWhileAnimating: true, updateWhileInteracting: true }); vectorLayerRef.current = vectorLayer; const baseLayers = []; if (baseMap !== "none") { if (baseMap === "satellite") { baseLayers.push( new TileLayer({ source: new XYZ({ url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", maxZoom: 19 }) }) ); } else { baseLayers.push(new TileLayer({ source: new OSM() })); } } const layers = [...baseLayers, vectorLayer]; const extent = vectorSource.getExtent(); const size = mapRef.current.getBoundingClientRect(); const minResolution = Math.max( (extent[2] - extent[0]) / size.width, (extent[3] - extent[1]) / size.height ); const minZoom = Math.floor( Math.log2(156543.03392804097) - Math.log2(minResolution) ); const view = new View({ projection: "EPSG:3857", ...canZoomOutBoundaries ? {} : { extent, minZoom: Math.max(minZoom - 1, 0), // Subtract 1 to give a little extra room constrainOnlyCenter: false } }); const map = new Map({ target: mapRef.current, layers, view }); view.fit(extent, { padding: [50, 50, 50, 50], maxZoom: zoom || void 0, duration: 0 }); mapInstanceRef.current = map; let updateOverlayPosition = null; if (overlayOptions) { try { const container = document.createElement("div"); container.className = "react-ol-choropleth__overlay-container"; overlayContainerRef.current = container; const overlayInstance = new Overlay({ element: container, positioning: overlayOptions.positioning || "bottom-center", offset: overlayOptions.offset || [0, -10], stopEvent: false, className: "react-ol-choropleth__overlay-wrapper", autoPan: overlayOptions.autoPan !== false }); overlayRef.current = overlayInstance; map.addOverlay(overlayInstance); updateOverlayPosition = debounce(() => { if (selectedFeatureRef.current && overlayRef.current) { const geometry = selectedFeatureRef.current.getGeometry(); if (geometry instanceof Polygon) { const extent2 = geometry.getExtent(); const position = [(extent2[0] + extent2[2]) / 2, extent2[3]]; overlayRef.current.setPosition(position); } } }, 150); map.on("moveend", updateOverlayPosition); } catch (error) { console.error("Error setting up overlay:", error); } } const clickListener = (event) => handleFeatureClick(event, map); map.on("click", clickListener); const hoverListener = (event) => handleFeatureHover(event, map); map.on("pointermove", hoverListener); return () => { if (updateOverlayPosition) { updateOverlayPosition.cancel(); map.un("moveend", updateOverlayPosition); } map.un("click", clickListener); map.un("pointermove", hoverListener); if (overlayRef.current) { map.removeOverlay(overlayRef.current); overlayRef.current = null; } if (overlayContainerRef.current) { overlayContainerRef.current.remove(); overlayContainerRef.current = null; } map.dispose(); }; }, [ vectorSource, style, styleFunction, baseMap, overlayOptions, handleFeatureClick, handleFeatureHover, zoom, canZoomOutBoundaries ]); const values = useMemo(() => { return features.map((feature) => Number(feature.get(valueProperty))).filter((value) => !isNaN(value)); }, [features, valueProperty]); return /* @__PURE__ */ jsxs("div", { className: `react-ol-choropleth ${className}`.trim(), children: [ /* @__PURE__ */ jsx( "div", { ref: mapRef, className: `react-ol-choropleth__map ${mapClassName}`.trim() } ), showLegend && colorScale && values.length > 0 && /* @__PURE__ */ jsx( Legend, { colorScale, position: legendPosition, values, className: legendClassName } ), overlayContent && overlayContainerRef.current && createPortal(overlayContent, overlayContainerRef.current) ] }); }; const ChoroplethMap$1 = memo(ChoroplethMap); export { ChoroplethMap$1 as ChoroplethMap, Legend, useColorScale };