Point clustering
Shared point clustering
The same source options and methods work in matched browser main/Worker SDKs, desktop scripts and native QuickJS scripts. Native C uses the existing sqsm_map_source_json channel and runtime flag SQSM_MAP_CLUSTERING (16777216). All source-handle reads return Promises across hosts, including getClusterOptions; setClusterOptions also returns Promise
Options: cluster defaults to false; clusterRadius defaults to 50 logical pixels per 512-pixel world tile (0..512); clusterMaxZoom defaults to 17 and rounds fractional input (0..23); clusterMinPoints defaults to 2 (2..4096). clusterProperties supports at most eight scalar statistics: numeric +, -, *, /, min and max, boolean all/any, string concat shorthands, or custom reduce expressions, as reduce, map_expression. Mapping and reduction may return finite numbers, booleans or strings; null/array/object results are unsupported and report INVALID_ARGUMENT. Shared case/match/coalesce/to-string and arithmetic expressions can be combined. accumulated is valid only during reduction; get reads a declared statistic from the incoming child, including other statistics. The first child initializes the accumulator; parents fold child results in stable identity order and single-child levels reuse values. Custom reductions may change scalar type, but subsequent numeric/boolean operators perform no implicit conversion; concat uses shared to-string conversion. Numeric text removes integer .0, formats negative zero as 0 and uses fixed/exponent thresholds at 1e-6 and 1e21. Non-associative reductions and floating-point order need not match another engine; associative operations are recommended. Each expression is limited to 32 levels, 1024 JSON nodes and an estimated 64 KiB; each source has an estimated eight-million-node evaluation budget. Strings are limited to 4096 UTF-8 bytes each; the mapping buffer and each working level are independently bounded to an estimated 8 MiB, separately from the persistent 32 MiB index. Failed updates preserve data, index, feature state and revision. zoom/feature-state dependencies still report CAPABILITY_UNAVAILABLE. SDK capability: clusterScalarProperties; C flag: SQSM_MAP_CLUSTER_SCALARS (67108864); the numeric reduction flag remains available. setClusterOptions accepts only cluster/clusterRadius/clusterMaxZoom, and normalized unchanged options preserve the existing index and IDs.
Only Feature or FeatureCollection containing non-null Points is accepted when clustering is enabled, up to 4096 points within Mercator latitude ±85.0511287798066. Stable unique explicit/promoted IDs are recommended; missing IDs use input positions. Geometry is grouped in integer zoom levels without dateline wrapping. Radius zero groups coincident points only. Above clusterMaxZoom the original features display. Registration creates no layer UI: users attach circle layers and supply their own panels and buttons.
querySourceFeatures and queryRenderedFeatures return the actual cluster at the current integer camera zoom. A synthetic point has cluster=true, numeric cluster_id, point_count, point_count_abbreviated and configured statistics in properties. Its feature.id is 'sqs:cluster:<cluster_id>', distinct from numeric original IDs. Input IDs with that prefix, or reserved cluster properties, are rejected. getData returns original GeoJSON. getClusterLeaves(id,limit=10,offset=0) returns stable original members with source-instance/data-revision provenance; pages are 1..1000, offset 0..4096 and output ≤8 MiB (script transport retains its stricter 64 KiB budget). getClusterChildren returns the first split's children and getClusterExpansionZoom the corresponding integer zoom.
Cluster IDs belong to one source/index revision and expire with STALE_HANDLE after data or configuration changes. When disabled, cluster inspection reports CAPABILITY_UNAVAILABLE. Retain the source handle but re-query clusters after writes. Config/data changes prepare both geometry and display before commit; failures retain old data, index and map. Synthetic feature state expires on rebuild; original point state remains unless generateId replacement resets all positional state. Source dataRevision advances on a compiled cluster configuration change; the standard sourcedata/sqs.sourceschange observers apply.
Public syntax references: GeoJSON options, GeoJSONSource, GetClusterOptions, SetClusterOptions. Limits and asynchronous reads above are explicit SqsMap differences. Mobile device integration, container statistics, live sources and full scene ownership remain pending.
import {map} from 'sqsmap';
map.addSource('points',{type:'geojson',cluster:true,clusterMaxZoom:8,
clusterProperties:{total:['+',['get','weight']]},data:{type:'FeatureCollection',features:[
{type:'Feature',id:'a',properties:{weight:3},geometry:{type:'Point',coordinates:[0,0]}},
{type:'Feature',id:'b',properties:{weight:4},geometry:{type:'Point',coordinates:[.01,0]}}
]}});
map.addLayer({id:'points',type:'circle',source:'points',paint:{'circle-radius':10}});
map.jumpTo({center:[0,0],zoom:0});await map.sqs.flush();
const source=await map.getSource('points');
const cluster=(await map.querySourceFeatures('points')).find(f=>f.properties.cluster);
if(cluster){
const id=cluster.properties.cluster_id;
const page=await source.getClusterLeaves(id,10,0);
const children=await source.getClusterChildren(id);
const zoom=await source.getClusterExpansionZoom(id);
map.jumpTo({center:cluster.geometry.coordinates.slice(0,2),zoom});await map.sqs.flush();
}
await source.setClusterOptions({clusterRadius:60});
const options=await source.getClusterOptions();
Complete HTML/CSS/script
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SqsMap point clustering</title><style>html,body{height:100%;margin:0;font-family:system-ui}#map{height:100%}.tools{position:absolute;top:12px;left:12px;background:white;padding:12px;border-radius:8px;max-width:330px}button{padding:6px;margin:2px}pre{white-space:pre-wrap}#credits{position:absolute;bottom:0;right:0;background:#fffd;padding:4px;font-size:12px}</style></head><body><div id="map"></div><section class="tools"><strong>Point clustering</strong><p>Click a blue cluster to expand. Red points are original features.</p><div><button id="overview" disabled>Overview</button><button id="expand" disabled>Expand</button><button id="members" disabled>Member page</button><button id="toggle" disabled>Toggle clustering</button></div><pre id="log" role="status">Loading</pre></section><div id="credits"></div><script type="module">
// Serve next to a matched standalone SqsMap SDK's dist/ and wasm/ directories.
import {MapView} from './dist/index.js';
const log=document.querySelector('#log'),buttons=[...document.querySelectorAll('button')];let map,source,enabled=true;const subscriptions=[];
async function run(action){buttons.forEach(b=>b.disabled=true);try{await action();}catch(error){log.textContent=String(error);}finally{if(map)buttons.forEach(b=>b.disabled=false);}}
async function group(){return(await map.querySourceFeatures('points')).find(f=>f.properties.cluster===true);}
async function expand(feature){if(!feature){log.textContent='No cluster at this zoom';return;}const zoom=await source.getClusterExpansionZoom(feature.properties.cluster_id);map.jumpTo({center:feature.geometry.coordinates.slice(0,2),zoom});await map.flush();log.textContent='Expanded to zoom '+zoom;}
try{
map=await MapView.create(document.querySelector('#map'),{worker:false});if(!map.capabilities.clusterScalarProperties)throw Error('Matched clustering SDK/WASM required');
subscriptions.push(map.on('sqs.attributionschange',event=>document.querySelector('#credits').textContent=event.attributions.text));
map.jumpTo({center:[.025,0],zoom:0});
map.addSource('points',{type:'geojson',cluster:true,clusterMaxZoom:8,attribution:'Example data © SqsMap',clusterProperties:{total:[['+',['accumulated'],['get','total']],['get','weight']],labels:['concat',['get','name']],allActive:['all',['get','active']],anyActive:['any',['get','active']]},data:{type:'FeatureCollection',features:Array.from({length:6},(_,i)=>({type:'Feature',id:'p'+i,properties:{weight:i+1,name:'p'+i,active:i!==1},geometry:{type:'Point',coordinates:[i*.01,0]}}))}});
map.addLayer({id:'points',type:'circle',source:'points',paint:{'circle-radius':['case',['has','point_count'],12,3],'circle-color':['case',['has','point_count'],'#0000ff','#ff0000']}});await map.flush();source=await map.getSource('points');
document.querySelector('#credits').textContent=(await map.getAttributions()).text;
subscriptions.push(map.on('click','points',event=>run(async()=>{const f=event.features[0];if(f?.properties.cluster)await expand(f);else if(f)log.textContent=JSON.stringify(f.properties);})));
document.querySelector('#overview').onclick=()=>run(async()=>{map.jumpTo({center:[.025,0],zoom:0});await map.flush();log.textContent='Overview';});
document.querySelector('#expand').onclick=()=>run(async()=>expand(await group()));
document.querySelector('#members').onclick=()=>run(async()=>{const f=await group();if(!f){log.textContent='No cluster at this zoom';return;}const members=await source.getClusterLeaves(f.properties.cluster_id,2,0);log.textContent=JSON.stringify({count:f.properties.point_count,total:f.properties.total,labels:f.properties.labels,allActive:f.properties.allActive,anyActive:f.properties.anyActive,firstPage:members.map(m=>({id:m.id,weight:m.properties.weight}))},null,2);});
document.querySelector('#toggle').onclick=()=>run(async()=>{await source.setClusterOptions({cluster:!enabled});enabled=!enabled;log.textContent='Clustering '+(enabled?'enabled':'disabled');});
buttons.forEach(b=>b.disabled=false);log.textContent='Ready';
}catch(error){log.textContent=String(error);}
window.addEventListener('pagehide',()=>{subscriptions.forEach(off=>off());map?.destroy();},{once:true});
</script></body></html>
Matched downloads
Complete HTML · Portable script · Source declarations · Native header
Custom reduction portable verification (27 checks in each of five hosts): cluster-reducers.js。
Scalar aggregation portable verification (55 checks per host): cluster-scalars.js。