react-ol-choropleth
Version:
A React plugin for creating choropleth maps using OpenLayers
448 lines (447 loc) • 15.6 kB
JavaScript
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
};