Caller-owned scale and map tools
You own the toolbar, scale bar and overlay UI. Use zoomIn/zoomOut/resetNorth, authoritative camera queries and getMinZoom/getMaxZoom; refresh on move. This increment delivers local planar scale. Animation, anchors, gesture switches, top-down/pitched views and attribution are connected within their documented guides. Globe sampling is connected within the globe-scale guide bounds; missing ground intersections still reject explicitly.
// @gis-api 0.5-session
// @permissions ["project.read", "map.write"]
import {map} from 'sqsmap';
const scale = await map.sqs.scaleAt({point:[64,48],maxWidth:80});
map.zoomIn();
await map.sqs.flush({render:true});
export default {scale,minZoom:await map.getMinZoom(),maxZoom:await map.getMaxZoom()};
Common scripts call map.sqs.scaleAt; the standalone SDK calls view.scaleAt. Both return promises. point accepts [x,y] or {x,y} in CSS/logical viewport pixels, defaults to the viewport center, and must lie inside it. maxWidth defaults to the lesser of viewport width and 100; it must be positive and no greater than the viewport width or 10000. Queries do not move the camera, submit writes or emit move. Re-read on move after camera, projection or viewport changes.
| Field | Meaning |
|---|---|
| point / position | Logical sampling point and its WGS84 longitude/latitude |
| metersPerPixel | Local ground meters per horizontal logical screen pixel at that point |
| distance / width / maxWidth | Suggested 1/2/5 decimal distance in meters, bar width and maximum width in logical pixels |
| projection | geographic, mercator or globe |
| unit / pixelUnit | m / css; the caller formats labels |
| model / earthRadius / approximate | sphere-local / 6371008.8 meters / true |
The shared camera uses a local spherical differential. Geographic projection considers latitude and bearing; Mercator uses the actual sampling latitude. Padding, off-center points and device pixel ratio use the same logical units. This is an approximation, not long-distance, ellipsoidal, terrain-slope or engineering surveying. Unsupported CRSs and missing ground intersections reject explicitly; globe uses the local tangent model and pole bounds documented in globe-scale; connected pitched views use a shared analytic screen-ray ground derivative. The common camera currently accepts newly set zooms from -32 to 32. These are not configurable user limits and do not guarantee map data at every zoom.
C callers first check SQSM_MAP_SCALE_TOOLS (256), then pass {"method":"sqs.scaleAt","args":[{"maxWidth":80}]} to sqsm_map_camera_json. Existing independent output allocation/free rules apply. An older library with only PLANAR_CAMERA does not promise scale support. Browsers inspect view.capabilities.scaleTools; disable unavailable controls and show the reason. Native one-shot/retained QuickJS, browser and application common scripts are connected. Mobile hosts and external-device acceptance remain pending.
Complete copyable HTML/CSS/JS
Save the following file inside the matching SDK distribution folder beside the dist directory, and serve it over HTTP. It initializes state, subscribes to move and discards obsolete asynchronous refreshes. Buttons use real camera values and limits; error handling retains command watermarks. Removal and page exit unsubscribe and destroy the map. This sample uses no basemap; attach your own data through the public source APIs with its required attribution.
Complete tools page · Common tools script · Complete C example. Public attachments require deployment; local acceptance uses the matching preview origin.
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SqsMap caller-owned map tools</title>
<style>body{margin:0;font:15px system-ui}#map{height:80vh;min-height:320px}.tools{display:flex;align-items:center;gap:12px;padding:12px;flex-wrap:wrap}button{padding:8px 12px}#scale-line{border:2px solid currentColor;border-top:0;height:7px;margin-top:4px}#error{color:#b42318;white-space:pre-wrap}#state{font-size:13px}</style>
<div id="map"></div>
<div class="tools"><button id="zoom-in" disabled>+</button><button id="zoom-out" disabled>−</button><button id="north" disabled>North</button><div><span id="scale-label">Loading…</span><div id="scale-line"></div></div><span id="state">Initializing</span><button id="dispose" disabled>Remove map and tools</button></div><pre id="error" role="status"></pre>
<script type="module">
import {MapView} from './dist/index.js';
const element=id=>document.getElementById(id),plus=element('zoom-in'),minus=element('zoom-out'),north=element('north'),state=element('state'),error=element('error');
let view,off,disposed=false,busy=false,refreshSequence=0,since=0;
async function refresh(){
const current=++refreshSequence;
try{
const [camera,scale,min,max]=await Promise.all([view.cameraState(),view.scaleAt({maxWidth:100}),view.getMinZoom(),view.getMaxZoom()]);
if(disposed||current!==refreshSequence)return;
plus.disabled=busy||camera.camera.zoom+1>max||camera.camera.zoom+1<min;minus.disabled=busy||camera.camera.zoom-1<min||camera.camera.zoom-1>max;north.disabled=busy||Math.abs(camera.camera.bearing)<1e-7;
element('scale-line').style.width=scale.width+'px';element('scale-label').textContent=scale.distance>=1000?(scale.distance/1000)+' km':scale.distance+' m';
state.textContent='Zoom '+camera.camera.zoom.toFixed(2)+' · Bearing '+camera.camera.bearing.toFixed(1)+'°';state.dataset.zoom=String(camera.camera.zoom);state.dataset.bearing=String(camera.camera.bearing);state.dataset.metersPerPixel=String(scale.metersPerPixel);state.dataset.ready='true';
}catch(cause){if(!disposed&¤t===refreshSequence){plus.disabled=minus.disabled=north.disabled=true;error.textContent=String(cause);}}
}
async function apply(action){
if(disposed||busy)return;busy=true;plus.disabled=minus.disabled=north.disabled=true;error.textContent='';
try{action();const receipt=await view.flush({render:true,since});since=receipt.submittedSequence;}
catch(cause){if(Number.isSafeInteger(cause.submittedSequence))since=cause.submittedSequence;error.textContent=String(cause);}
finally{busy=false;if(!disposed)await refresh();}
}
function dispose(){if(disposed)return;disposed=true;refreshSequence++;off?.();plus.onclick=minus.onclick=north.onclick=null;plus.disabled=minus.disabled=north.disabled=true;element('dispose').disabled=true;view?.destroy();state.textContent='Disposed';state.dataset.ready='false';state.dataset.disposed='true';}
plus.onclick=()=>apply(()=>view.zoomIn());minus.onclick=()=>apply(()=>view.zoomOut());north.onclick=()=>apply(()=>view.resetNorth());element('dispose').onclick=dispose;addEventListener('pagehide',dispose,{once:true});
try{
view=await MapView.create(element('map'));if(view.capabilities.scaleTools!==true)throw Error('CAPABILITY_UNAVAILABLE: matching scale tools SDK required');if(disposed){view.destroy();}else{
off=view.on('move',()=>{void refresh();});view.addSource('demo',{type:'geojson',data:{type:'Feature',properties:{},geometry:{type:'Point',coordinates:[0,60]}}});view.addLayer({id:'demo',source:'demo',type:'circle',paint:{'circle-radius':12,'circle-color':'#2563eb'}});view.jumpTo({center:[0,60],zoom:4,bearing:30});since=(await view.flush({render:true})).submittedSequence;await refresh();element('dispose').disabled=false;
}
}catch(cause){error.textContent=String(cause);}
</script></html>
MapLibre ScaleControl reference. This independently implemented shared extension does not depend on its control implementation.
Complete caller-owned layer panel, 2D/3D, projection and attribution controls. The local scale limitation remains; it does not prevent the documented camera/tool operations.
Globe scale uses actual local spherical distance.
Shared 2D/3D state and switching uses existing pitch/projection/terrain APIs with caller-owned UI.