Created
May 5, 2026 11:52
-
-
Save iamgeoknight/2813898d05d4e219e1ee95e0ef012e73 to your computer and use it in GitHub Desktop.
OpenLayers Clustering Tutorial: 100000 Points Without Browser Freeze (HTML + JavaScript)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>OpenLayers 50,000 Point Clustering Without Freeze</title> | |
| <!-- OpenLayers CSS --> | |
| <link | |
| rel="stylesheet" | |
| href="https://cdn.jsdelivr.net/npm/ol@v10.9.0/ol.css" | |
| /> | |
| <!-- OpenLayers JS full build --> | |
| <script src="https://cdn.jsdelivr.net/npm/ol@v10.9.0/dist/ol.js"></script> | |
| <style> | |
| html, | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%; | |
| height: 100%; | |
| font-family: Arial, sans-serif; | |
| } | |
| #map { | |
| width: 100%; | |
| height: 100vh; | |
| } | |
| #infoPanel { | |
| position: absolute; | |
| top: 12px; | |
| left: 12px; | |
| z-index: 1000; | |
| background: white; | |
| padding: 14px; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); | |
| font-size: 14px; | |
| min-width: 280px; | |
| } | |
| #infoPanel h3 { | |
| margin: 0 0 10px 0; | |
| font-size: 16px; | |
| } | |
| .row { | |
| margin-bottom: 8px; | |
| } | |
| label { | |
| display: block; | |
| margin-top: 10px; | |
| } | |
| input[type="range"] { | |
| width: 100%; | |
| } | |
| .small { | |
| font-size: 12px; | |
| color: #555; | |
| line-height: 1.4; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="map"></div> | |
| <div id="infoPanel"> | |
| <h3>OpenLayers 100,000 Point Cluster</h3> | |
| <div class="row"> | |
| Raw points: | |
| <b id="rawCount">0</b> | |
| </div> | |
| <div class="row"> | |
| Current cluster features: | |
| <b id="clusterCount">0</b> | |
| </div> | |
| <div class="row"> | |
| Current zoom: | |
| <b id="zoomValue">0</b> | |
| </div> | |
| <label> | |
| Cluster distance: | |
| <b id="distanceValue">40</b> px | |
| <input | |
| id="distanceSlider" | |
| type="range" | |
| min="10" | |
| max="120" | |
| value="40" | |
| /> | |
| </label> | |
| <p class="small"> | |
| Larger distance = fewer visible cluster symbols.<br /> | |
| Smaller distance = more detailed clustering. | |
| </p> | |
| </div> | |
| <script> | |
| /************************************************************ | |
| * 1. Basic configuration | |
| ************************************************************/ | |
| const TOTAL_POINTS = 100000; | |
| const rawCountEl = document.getElementById("rawCount"); | |
| const clusterCountEl = document.getElementById("clusterCount"); | |
| const zoomValueEl = document.getElementById("zoomValue"); | |
| const distanceSlider = document.getElementById("distanceSlider"); | |
| const distanceValueEl = document.getElementById("distanceValue"); | |
| rawCountEl.textContent = TOTAL_POINTS.toLocaleString(); | |
| /************************************************************ | |
| * 2. Create random points | |
| * | |
| * For demo, we generate random points inside India-like bounds. | |
| * In real projects, these points may come from GeoJSON, | |
| * PostGIS, API, CSV, or database. | |
| ************************************************************/ | |
| const minLon = 68.0; | |
| const maxLon = 98.0; | |
| const minLat = 6.0; | |
| const maxLat = 36.0; | |
| function randomBetween(min, max) { | |
| return min + Math.random() * (max - min); | |
| } | |
| function createRandomFeatures(count) { | |
| const features = new Array(count); | |
| for (let i = 0; i < count; i++) { | |
| const lon = randomBetween(minLon, maxLon); | |
| const lat = randomBetween(minLat, maxLat); | |
| const coordinate3857 = ol.proj.fromLonLat([lon, lat]); | |
| const feature = new ol.Feature({ | |
| geometry: new ol.geom.Point(coordinate3857), | |
| pointId: i | |
| }); | |
| features[i] = feature; | |
| } | |
| return features; | |
| } | |
| /* | |
| Important performance idea: | |
| Create all features first. | |
| Then give the whole feature array to VectorSource. | |
| Avoid doing source.addFeature(feature) 50,000 times. | |
| */ | |
| const pointFeatures = createRandomFeatures(TOTAL_POINTS); | |
| /************************************************************ | |
| * 3. Raw point source | |
| ************************************************************/ | |
| const pointSource = new ol.source.Vector({ | |
| features: pointFeatures | |
| }); | |
| /************************************************************ | |
| * 4. Cluster source | |
| * | |
| * distance: | |
| * Pixel distance within which points are grouped. | |
| * | |
| * minDistance: | |
| * Minimum pixel distance between clusters. | |
| * OpenLayers says minDistance is capped by distance. | |
| ************************************************************/ | |
| const clusterSource = new ol.source.Cluster({ | |
| distance: Number(distanceSlider.value), | |
| minDistance: 10, | |
| source: pointSource | |
| }); | |
| /************************************************************ | |
| * 5. Cached cluster styles | |
| * | |
| * This is very important. | |
| * | |
| * Do NOT create a new style object every time OpenLayers renders. | |
| * Cache style objects by cluster size group. | |
| ************************************************************/ | |
| const styleCache = {}; | |
| function getClusterStyle(clusterFeature) { | |
| const children = clusterFeature.get("features"); | |
| const size = children.length; | |
| let radius; | |
| let fontSize; | |
| if (size >= 1000) { | |
| radius = 26; | |
| fontSize = 13; | |
| } else if (size >= 500) { | |
| radius = 23; | |
| fontSize = 13; | |
| } else if (size >= 100) { | |
| radius = 20; | |
| fontSize = 12; | |
| } else if (size >= 10) { | |
| radius = 16; | |
| fontSize = 12; | |
| } else { | |
| radius = 10; | |
| fontSize = 11; | |
| } | |
| /* | |
| We cache by visual bucket, not by exact size only. | |
| This reduces repeated Style object creation. | |
| */ | |
| const cacheKey = `${radius}-${fontSize}-${size}`; | |
| if (!styleCache[cacheKey]) { | |
| styleCache[cacheKey] = new ol.style.Style({ | |
| image: new ol.style.Circle({ | |
| radius: radius, | |
| fill: new ol.style.Fill({ | |
| color: "rgba(0, 136, 255, 0.75)" | |
| }), | |
| stroke: new ol.style.Stroke({ | |
| color: "rgba(255, 255, 255, 0.95)", | |
| width: 3 | |
| }) | |
| }), | |
| text: new ol.style.Text({ | |
| text: size.toString(), | |
| font: `bold ${fontSize}px Arial`, | |
| fill: new ol.style.Fill({ | |
| color: "#ffffff" | |
| }), | |
| stroke: new ol.style.Stroke({ | |
| color: "rgba(0, 0, 0, 0.45)", | |
| width: 3 | |
| }) | |
| }) | |
| }); | |
| } | |
| return styleCache[cacheKey]; | |
| } | |
| /************************************************************ | |
| * 6. Cluster layer | |
| * | |
| * updateWhileAnimating and updateWhileInteracting are kept false. | |
| * | |
| * OpenLayers documents that enabling updateWhileAnimating | |
| * recreates feature batches during animations and has a | |
| * performance impact for large vector datasets. | |
| ************************************************************/ | |
| const clusterLayer = new ol.layer.Vector({ | |
| source: clusterSource, | |
| style: getClusterStyle, | |
| updateWhileAnimating: false, | |
| updateWhileInteracting: false | |
| }); | |
| /************************************************************ | |
| * 7. Base map layer | |
| ************************************************************/ | |
| const osmLayer = new ol.layer.Tile({ | |
| source: new ol.source.OSM() | |
| }); | |
| /************************************************************ | |
| * 8. Create map | |
| ************************************************************/ | |
| const map = new ol.Map({ | |
| target: "map", | |
| layers: [ | |
| osmLayer, | |
| clusterLayer | |
| ], | |
| view: new ol.View({ | |
| center: ol.proj.fromLonLat([78.9629, 20.5937]), | |
| zoom: 5 | |
| }) | |
| }); | |
| /************************************************************ | |
| * 9. Update panel statistics | |
| ************************************************************/ | |
| function updateStats() { | |
| const zoom = map.getView().getZoom(); | |
| const clusterFeatures = clusterSource.getFeatures(); | |
| zoomValueEl.textContent = zoom.toFixed(2); | |
| clusterCountEl.textContent = clusterFeatures.length.toLocaleString(); | |
| } | |
| map.on("moveend", updateStats); | |
| clusterSource.on("change", updateStats); | |
| /************************************************************ | |
| * 10. Change cluster distance dynamically | |
| ************************************************************/ | |
| distanceSlider.addEventListener("input", function () { | |
| const distance = Number(distanceSlider.value); | |
| distanceValueEl.textContent = distance; | |
| /* | |
| OpenLayers Cluster source supports setDistance(). | |
| */ | |
| clusterSource.setDistance(distance); | |
| updateStats(); | |
| }); | |
| /************************************************************ | |
| * 11. Click cluster and zoom to its children | |
| ************************************************************/ | |
| map.on("click", function (event) { | |
| clusterLayer.getFeatures(event.pixel).then(function (clickedFeatures) { | |
| if (!clickedFeatures.length) { | |
| return; | |
| } | |
| const clusterFeature = clickedFeatures[0]; | |
| const children = clusterFeature.get("features"); | |
| if (!children || children.length === 0) { | |
| return; | |
| } | |
| /* | |
| If this cluster contains only one point, show its info. | |
| */ | |
| if (children.length === 1) { | |
| const originalFeature = children[0]; | |
| console.log("Single point clicked:", originalFeature.get("pointId")); | |
| return; | |
| } | |
| /* | |
| If cluster contains many points, zoom to their extent. | |
| */ | |
| const coordinates = children.map(function (feature) { | |
| return feature.getGeometry().getCoordinates(); | |
| }); | |
| const extent = ol.extent.boundingExtent(coordinates); | |
| map.getView().fit(extent, { | |
| duration: 400, | |
| padding: [80, 80, 80, 80], | |
| maxZoom: 16 | |
| }); | |
| }); | |
| }); | |
| updateStats(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment