共同线动画与样式曲线
共同 map.sqs.animations.line(options, {owner: scene}) 已接通浏览器主线程、地图 Worker、原生持续 QuickJS 和桌面脚本服务。地图持有控制器;脚本会话退出不会自动移除。UI、效果选择、播放按钮与布局由使用方实现。图源和图层使用 MapLibre 风格语法;场景和线动画是 SqsMap 扩展,不是 MapLibre API 实现。
当前开发 SDK 0.3.0-dev.1691a622、核心 1691a622810a8bd61c7b2bfaacc2855e2522e37f、脚本宿主 0.1.0-dev.15.6b2、契约 0.5-session、ABI 1。查询 sceneLinePlayback 和 sceneTracks 后使用;缺失/false 表示不可用。六种效果及曲线已通过软件 WebGL2/Vulkan 验收;移动设备、Windows/macOS 外机、硬件 WebGPU和正式发行需独立验收。
图源、归属和身份
使用已加载、静态、非聚合 GeoJSON 图源,创建时设置 lineMetrics:true,然后 scene.attachSource(source)。图源中的几何只能为 LineString/MultiLineString(或其 Feature/FeatureCollection 包装),图层必须为 line;坐标为二维经纬度,纬度绝对值不超过85.0511287798066,经度绝对值不超过1000000以容纳显式世界副本;当前只支持平面地图,不支持地球仪/三维线。layer 接受名称字符串,或包含 id/layerInstanceId 的身份。map.getLayer 返回样式规范;脚本 map.sqs.getLayers() / SDK map.getStyleLayers() 提供带身份的状态快照。名称在调用时绑定当前实例;删除再添加和跨地图身份不能复用旧控制器。构造函数在异步查找前捕获可变设置。
图层、图源、图片的实例与修订由核心校验。外部数据、状态、样式或图片变化会停止旧叠加并报告失效/冲突;旧控制器不会覆盖用户新值。普通时间采样只更新材质,保留几何缓存和原始样式。草稿场景准备控制器;scene.start() 后开始显示。
时间、效果与曲线
timing.duration 和 timing.delay 使用毫秒;范围分别为 1..86400000 / 0..86400000。loop 默认 false;direction 为 forward/reverse/alternate,alternate 的完整往返持续时间为两倍 duration。easing 为 linear/ease-in-out;fill 为 forwards/none,默认保留终点。
effects 包含 1..6 项,各类型最多一项:flow(spacing、trailLength、fade、phase)、dash(必需 on/gap)、reveal(start/end)、arrows(spacing/length)、glow(radius/intensity)、pattern(必需 image)。flow/dash/arrows/pattern 的 unit 为 progress/pixels。pattern 使用 map.addImage 注册的图片名,不能直接使用文件路径或样式 sprite 名,SDF 图片不支持。配置上的 cap 为 butt/round;stagger、phaseField、speedField 控制各路径相位/速度。
tracks.color 使用 #RRGGBB / #RRGGBBAA;opacity 范围 0..1;widthScale 范围 0..8。每条非空曲线有 2..32 个 {offset,value},offset 严格递增且从0覆盖到1。共同采样是线性插值,最终材质还应用图层自身颜色、宽度和透明度。
控制、事件与释放
控制器有 getState/play/pause/stop/seek/finished,通用查询使用 map.sqs.playbacks.get/list/remove。快照以 kind=route/line 区分,line 包含 frame、layer、sourceStateRevision、visibleEffect 和可选 pattern 身份;嵌套颜色数组、身份和错误均不可变。visibleEffect 表示保留的叠加效果,实际显示仍受图层/场景可见性约束。
手动暂停与场景暂停独立;场景恢复不会解除手动暂停。暂停 seek 到终点后恢复播放才完成。finished() 等待当前轮次真正接受终点,循环需显式取消;停止、重播、移除或地图销毁导致 CANCELLED,外部冲突导致对应错误。一次性脚本不能挂起等待未来通知,需持续脚本会话;已经完成的轮次可直接返回。注册 sqs.playbackchange 接收历史事件,不需常驻显示控件。
每地图最多64控制器、128通知/每轮;线材质最多32层/256MiB,每控制器4096路径/100000点。超限在分配与提交前拒绝。移除场景/控制器释放叠加,恢复调用方注册的原始图层;源、图层及图片继续由调用方拥有,需分别移除。图片更新使旧 pattern 捕获失效。
共同脚本
下列代码用于持续脚本会话/桌面调试板,浏览器脚本运行器使用相同代码。一次性动态库脚本可省略 finished(),由宿主渲染循环继续推进;地图需要持续存活。
// @gis-api 0.5-session
// @permissions ["project.read","map.write"]
import {map} from 'sqsmap';
map.addSource('line-demo',{type:'geojson',lineMetrics:true,data:{type:'LineString',coordinates:[[-.006,0],[0,.003],[.006,0]]}});
map.addLayer({id:'line-demo',type:'line',source:'line-demo',paint:{'line-color':'#ffffff','line-width':8}});
await map.sqs.flush();
const scene=await map.sqs.scenes.create({id:'line-demo'});await scene.attachSource(await map.getSource('line-demo'));
const playback=await map.sqs.animations.line({id:'line-demo',layer:'line-demo',timing:{duration:6000},effects:[{type:'flow'}],tracks:{color:[{offset:0,value:'#ff0000'},{offset:1,value:'#0000ff'}]}},{owner:scene});
map.on('sqs.playbackchange',event=>console.log(event.playback));await scene.start();
console.log(await playback.finished());
// Explicit cleanup: await map.sqs.scenes.remove(scene).
// Registered sources/layers remain caller-owned; remove them separately if needed.
完整 HTML
包含所有按钮、效果选择、事件、CSS 与销毁处理;使用匹配的开发 SDK 附件。生产地址需完成部署,本地验收使用 ?sdk=local。
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SqsMap line playback</title><style>body{margin:0;font:15px system-ui;color:#182435}#map{height:75vh;min-height:320px}nav{display:flex;flex-wrap:wrap;gap:8px;padding:12px}button,input,select{font:inherit}pre{padding:12px;white-space:pre-wrap}small{display:block;padding:0 12px}</style><div id="map"></div><nav><label>Effect <select id="effect"><option>flow</option><option>dash</option><option>reveal</option><option>arrows</option><option>glow</option><option>pattern</option></select></label><button id="play">Play / replay</button><button id="pause">Pause</button><button id="stop">Stop</button><label>Seek <input id="seek" type="range" min="0" max="6000" step="100" value="0"></label></nav><small>Synthetic example data · No external basemap or imagery is used.</small><pre id="log"></pre><script type="module">
const local=location.hostname==='127.0.0.1'&&new URLSearchParams(location.search).get('sdk')==='local';
const {MapView}=await import(local?'./dist/index.js':'https://www.sqsmap.com/sdk/sqsmap-core/dist/index.js');
const map=await MapView.create(document.querySelector('#map')),log=document.querySelector('#log');map.jumpTo({center:[0,0],zoom:12});
map.addSource('line',{type:'geojson',lineMetrics:true,attribution:'Synthetic example data',data:{type:'LineString',coordinates:[[-.006,0],[0,.003],[.006,0]]}}).addLayer({id:'line',type:'line',source:'line',paint:{'line-color':'#ffffff','line-width':8}});
map.addImage('pattern',{width:2,height:1,data:[255,255,255,255,255,255,255,255]});await map.flush();
const scene=await map.sqs.scenes.create({id:'line-example'});await scene.attachSource(await map.getSource('line'));
let playback,closing=false,tail=Promise.resolve();const effects={flow:{type:'flow'},dash:{type:'dash',on:.2,gap:.1},reveal:{type:'reveal'},arrows:{type:'arrows'},glow:{type:'glow'},pattern:{type:'pattern',image:'pattern'}};
const show=value=>{if(!closing)log.textContent=JSON.stringify(value,null,2);};
const wait=p=>p.finished().then(show,error=>{if(!closing)log.textContent=error.code+': '+error.message;});
const off=map.on('sqs.playbackchange',event=>{if(event.playback.playbackInstanceId===playback?.playbackInstanceId)show(event.playback);});
function action(run){tail=tail.then(async()=>{if(closing)return;try{await run();show(await playback.getState());}catch(error){if(!closing)log.textContent=error.code+': '+error.message;}});}
async function selectEffect(){if(playback)await map.sqs.playbacks.remove(playback);playback=await map.sqs.animations.line({id:'effect',layer:'line',timing:{duration:6000},effects:[effects[document.querySelector('#effect').value]],tracks:{color:[{offset:0,value:'#ff0000'},{offset:1,value:'#0000ff'}],widthScale:[{offset:0,value:1},{offset:1,value:2}]}},{owner:scene});await playback.play();wait(playback);}
document.querySelector('#effect').onchange=()=>action(selectEffect);
document.querySelector('#play').onclick=()=>action(async()=>{await playback.play();wait(playback);});
document.querySelector('#pause').onclick=()=>action(()=>playback.pause());document.querySelector('#stop').onclick=()=>action(()=>playback.stop());
document.querySelector('#seek').onchange=event=>{const time=Number(event.target.value);action(()=>playback.seek(time));};
await scene.start();await selectEffect();show(await playback.getState());
window.addEventListener('pagehide',()=>{closing=true;off();map.destroy();},{once:true});
</script></html>