Skip to main content

Keyboard navigation

When the map canvas has focus, arrow keys pan100 logical pixels. Plus/equal zooms in one level and minus zooms out one; Shift plus/minus changes two levels. Clicking the map focuses it and Tab can reach the canvas. Editable fields and code editors handle their own keys. Ctrl/Alt/Meta combinations, IME composition, already handled events and unknown keys stay with the host. Shared keyboard navigation currently supports planar maps. Shift-left/right rotates the bearing by15 degrees. Shift-up/down pitch and globe/3D keyboard input remain pending.

When the map canvas has focus, arrow keys pan100 logical pixels. Plus/equal zooms in one level and minus zooms out one; Shift plus/minus changes two levels. Clicking the map focuses it and Tab can reach the canvas. Editable fields and code editors handle their own keys. Ctrl/Alt/Meta combinations, IME composition, already handled events and unknown keys stay with the host. Shared keyboard navigation currently supports planar maps. Shift-left/right rotates the bearing by15 degrees. Shift-up/down pitch and globe/3D keyboard input remain pending.

await map.keyboard.enable(), disable(), isEnabled() and isActive() accept no arguments; independent SDK users call view.keyboard. Policy includes a separate keyboard flag; interactive:false disables all default navigation. Disabling keyboard preserves other handlers, pointer notifications and programmatic camera calls. Enabling does not create key events. Key operations currently complete immediately and use shared camera events. isActive is false afterwards; animation and invented settling timers do not represent physical key activity.

Subscribe to sqs.gestureschange, then query initial policy. Use historical event.gestures.keyboard to update caller-owned controls. Stopping scripts and destroying maps release listeners. Applications own control layout.

Native flag SQSM_MAP_KEYBOARD_NAVIGATION=262144, ABI1. sqsm_map_keyboard_plan_json recognizes key and optional boolean shiftKey/ctrlKey/altKey/metaKey without writing state. null means unhandled/disabled; other results contain pan offset, zoom delta or rotate delta. Requests≤1KiB, key≤32 bytes; unknown fields/types fail. The shell owns OS focus and applies panBy or current zoom + delta clamped to actual getMinZoom/getMaxZoom, then zoomTo with duration:0. Report keyboard activity around actual dispatch through sqsm_map_gesture_input_json. Reading a plan does not move the camera, change command watermark/activity or emit policy notices. Foreign output is independently owned, cleared on failure and freed even after destruction.

Identical cross-host script / Copyable complete HTML. Browser main/strict Worker and real native QuickJS are accepted separately. Actual egui input frames are not full desktop GUI acceptance. Windows/macOS, mobile, hardware and formal release remain pending.

MapLibre KeyboardHandler

import {map} from "sqsmap";
await map.keyboard.enable();
export default {enabled:await map.keyboard.isEnabled(),active:await map.keyboard.isActive()};

await map.keyboard.enableRotation()/disableRotation() accepts no arguments. The independent keyboardRotation policy controls all arrow panning and Shift-left/right bearing rotation; zoom keys remain available. Disabling or enabling the overall keyboard handler preserves this option. Its default is true, while the overall keyboard handler must also be enabled. Native rotation capability SQSM_MAP_KEYBOARD_ROTATION=524288 keeps ABI1. The read-only sqsm_map_keyboard_plan_json returns rotate delta -15/+15 for Shift-left/right; apply rotateTo with the current bearing plus delta and duration:0. Ordinary pan and zoom planning is unchanged.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SqsMap keyboard navigation</title>
<style>body{margin:0;font:16px system-ui;background:#f5f7fa;color:#182330}main{display:grid;grid-template-columns:1fr 260px;min-height:100vh}#map{min-height:70vh}aside{padding:20px}fieldset{margin:12px 0}button{padding:8px;margin:4px}pre{white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:650px){main{grid-template-columns:1fr}}</style>
<main><div id="map"></div><aside><h1>Keyboard pan / bearing navigation</h1><p>These controls change default navigation. Your click listeners keep working.</p><div id="controls"></div><button id="rotation">Keyboard pan / rotation</button><button id="sample">Query actual activity</button><button id="external">External writer: disable drag</button><button id="destroy">Destroy map</button><pre id="status">Starting</pre></aside></main>
<script type="module">
import {MapView} from './dist/index.js';
const controls=document.querySelector('#controls'),status=document.querySelector('#status');let view,closed=false,busy=false,off,offPolicy,offMove,offUp,offCapture;
const buttons=[];async function refresh(){if(!view||closed)return;let snapshot;try{snapshot=await view.gesturesState();}catch(error){if(!closed)throw error;return;}if(closed)return;for(const {name,button} of buttons){button.textContent=name+': '+(snapshot[name]?'enabled':'disabled');button.disabled=busy;button.setAttribute('aria-pressed',String(snapshot[name]));}const rotation=document.querySelector('#rotation');rotation.textContent='Keyboard pan / rotation: '+(snapshot.keyboardRotation?'enabled':'disabled');rotation.setAttribute('aria-pressed',String(snapshot.keyboardRotation));rotation.disabled=busy||view.capabilities.keyboardRotation!==true;}
function fail(error){if(!closed)status.textContent=String(error);}
document.querySelector('#destroy').addEventListener('click',destroy);window.addEventListener('pagehide',destroy,{once:true});
try{
 view=await MapView.create(document.querySelector('#map'),{worker:false});if(closed){view.destroy();throw Error('Initialization cancelled.');}view.jumpTo({center:[0,0],zoom:3});await view.flush({render:true});
 view.addSource('demo',{type:'geojson',data:{type:'FeatureCollection',features:[{type:'Feature',properties:{},geometry:{type:'Point',coordinates:[0,0]}}]}});view.addLayer({id:'demo',source:'demo',type:'circle',paint:{'circle-radius':30,'circle-color':'#2563eb'}});await view.flush({render:true});
 for(const name of ['dragPan','scrollZoom','doubleClickZoom','keyboard']){const button=document.createElement('button');button.type='button';controls.append(button);buttons.push({name,button});button.addEventListener('click',async()=>{if(busy||closed)return;busy=true;try{await refresh();if(await view[name].isEnabled())await view[name].disable();else await view[name].enable();status.textContent='Policy applied.';}catch(error){fail(error);}finally{busy=false;await refresh();}});}
 document.querySelector('#rotation').addEventListener('click',async()=>{if(busy||closed)return;busy=true;try{await refresh();if((await view.gesturesState()).keyboardRotation)await view.keyboard.disableRotation();else await view.keyboard.enableRotation();status.textContent='Keyboard pan / rotation policy applied.';}catch(error){fail(error);}finally{busy=false;await refresh();}});
 async function sample(){if(closed)return;try{const activity={};for(const name of ['dragPan','scrollZoom','doubleClickZoom','keyboard'])activity[name]=await view[name].isActive();if(!closed)status.textContent='Actual recognized activity: '+JSON.stringify(activity);}catch(error){fail(error);}}
 document.querySelector('#sample').addEventListener('click',sample);view.canvas.addEventListener('lostpointercapture',sample);offCapture=()=>view.canvas.removeEventListener('lostpointercapture',sample);offMove=view.on('mousemove',()=>sample());offUp=view.on('mouseup',()=>sample());
 offPolicy=view.onGesturesChange(()=>refresh().catch(fail));document.querySelector('#external').addEventListener('click',()=>{if(!closed)view.dragPan.disable().catch(fail);});
 off=view.on('click',event=>{status.textContent='Click events remain available: '+JSON.stringify(event.lngLat);});await refresh();status.textContent='Ready. Click the map, then use arrows or + / -. Shift plus/minus changes two levels; Shift left/right rotates 15 degrees.';
}catch(error){fail(error);view?.destroy();closed=true;}
function destroy(){if(closed)return;closed=true;off?.unsubscribe();offPolicy?.();offMove?.unsubscribe();offUp?.unsubscribe();offCapture?.();view?.destroy();for(const {button} of buttons)button.disabled=true;document.querySelector('#destroy').disabled=true;document.querySelector('#external').disabled=true;document.querySelector('#sample').disabled=true;document.querySelector('#rotation').disabled=true;status.textContent='Destroyed; subscriptions released.';}

</script></html>