地图交互控制条放在地图外面,看起来只是把几个按钮挪个位置,实际上是把整个视图状态的读写权限从地图组件手里接了过来。这件事在 Angular + ArcGIS JS 的组合里尤其容易翻车:ViewChild 拿不到实例、goTo 的 Promise 抛未捕获异常、连续点两次只放大一级、滑块拖完自己弹回去……这些坑我基本都踩过一轮。这篇就把外置平移缩放这件事从需求拆解、实例持有方式、像素换算原理、动画与约束配置,到完整的可复用代码和排查思路,一次性讲透。适合已经能在 Angular 里跑起 ArcGIS JS 地图、但被自定义交互卡住的同学,也适合想搞明白 goTo、resolution、toScreen 这几个 API 到底在干什么的人。
1. 为什么地图外的按钮不能靠 CSS 位移实现
1.1 一个几乎人人都会先试的错误直觉
需求很简单:地图右上角有个默认的缩放控件,但设计稿要求控制按钮统一放在页面右侧的工具栏里,跟其他面板对齐。第一反应往往是把地图容器(那个div)的transform改一改,让它往左偏一点,把右下角的空间腾出来。或者更极端的做法:给地图容器加position: relative; left: -200px,视觉上确实"平移"了。
这个做法在静态截图里看不出问题,但只要用户拖一下地图就崩了。原因是 ArcGIS 的MapView在初始化时会读取容器的尺寸和位置,计算出width、height以及一系列内部变换参数(屏幕坐标系到地图坐标系的映射矩阵)。你用 CSS 把容器挪走之后,DOM 上看到的画面变了,但视图内部的这些参数一个都没变。结果就是:点击地图上的某个点,落点跟你的预期差了一个200px;瓦片加载的可见范围判断错误,边缘出现空白;弹窗(popup)会出现在错误的位置。
更隐蔽的是,view.toScreen()和view.toMap()这两个换算函数依然按老的尺寸计算,于是所有基于它们的自定义逻辑全部偏移。这种 bug 排查起来极其痛苦,因为代码逻辑本身没错,错的是"视图状态和 DOM 状态不一致"。
记住一句话:地图的可见范围、中心点、分辨率这些状态由 ArcGIS 托管,任何绕过 API 直接改 DOM 的做法都会造成状态分裂。
1.2 视图状态该由谁托管:搞清楚哪些属性不能碰
要理解为什么必须走 API,得先知道MapView到底管了哪些东西。简单说,它维护了一套"三层映射":地图坐标系(通常是 Web Mercator,单位是米)→ 视图坐标系 → 屏幕像素坐标系。这三层之间的转换由view.resolution、view.rotation、view.center、view.extent以及容器尺寸共同决定。
真正需要强调的一点是:在这套模型里,resolution(每个屏幕像素对应多少地图单位)是一个派生值,它由view.zoom和view.center的纬度共同决定。你改zoom,resolution跟着变;你改center,resolution也可能变(因为纬度变了)。这种互相耦合的关系,靠 CSS 是完全没法模拟的。
所以外置控制条要做的事情其实只有一件:把用户的点击翻译成对view.center和view.zoom的写入操作,然后让 ArcGIS 自己去重算所有派生值。这也是为什么view.goTo()是这套方案的核心——它是官方提供的、会正确触发全部重算和动画的入口。
反过来说,那些看起来"更直接"的写法,比如直接赋值view.center.x += 0.01,也是错的。ArcGIS 的Point对象虽然有 setter,但view.center本身是个访问器属性,直接改它的字段不会触发视图更新,还会污染内部缓存。要么用view.center.clone()改完再赋回去,要么老老实实用goTo。
1.3 把"外置控制"拆成四个正交的能力
需求上说是"平移加缩放",落到实现层面其实是四个互相独立的能力,混在一起写就很容易乱:
- 定步长平移:点击方向键,地图朝某个方向移动固定的屏幕像素距离(比如 120px),而不是固定的地图距离。后者在缩放级别变化后手感会完全不一样。
- 定步长缩放:点击加号减号,缩放一级。关键在于"一级"的判定基准是什么,是当前视图的
zoom还是上一次点击的累积目标值。 - 连续平移:按住方向键不放时持续移动,松开停止。这里涉及定时器管理和动画打断,是坑最多的地方。
- 连续缩放:用滑块或滚轮,把
zoom当成连续量而不是离散量处理,需要关掉snapToZoom。
把这四件事分开实现、各自独立测试,后面排查问题时能省一半时间。我在项目里吃过亏:一开始为了省事把平移和缩放写在一个goTo调用里,结果用户同时点方向键和加号的时候,两个动画互相打断,地图开始抽搐。
2. Angular 中持有 MapView 的三种姿势与选型
2.1 ViewChild 直取:只在父子同树时成立
如果控制按钮和地图容器在同一个组件模板里,@ViewChild是最省事的。但要注意static的含义:因为地图容器是通过*ngIf或异步数据渲染出来的,必须用@ViewChild('mapContainer')配合ngAfterViewInit,而不能用static: true。用了static: true之后在ngAfterViewInit里拿到的可能是undefined,报错信息还特别难看。
@Component({ /* ... */ }) export class MapPanelComponent implements AfterViewInit { @ViewChild('mapContainer') mapContainer!: ElementRef<HTMLDivElement>; private view?: MapView; ngAfterViewInit(): void { // 容器此刻才真正存在,再创建视图 this.view = new MapView({ container: this.mapContainer.nativeElement, map: new Map({ basemap: 'topo-vector' }), center: [116.391, 39.907], zoom: 11, ui: { components: ['attribution'] } }); } }这套写法的问题不在初始化,而在后续。一旦控制条要挪到另一个组件(比如右侧工具栏),或者地图组件被放进路由复用(RouteReuseStrategy),this.view的生命周期就开始跟组件缠在一起。组件销毁了但地图还在,或者反过来,组件复用了但view是旧的。我建议只要控制条和地图不在同一个模板里,就别用这套。
2.2 服务加响应式容器:跨组件场景的通用解法
更稳的做法是让地图宿主组件只负责"创建视图并注册到服务",控制条组件只负责"从服务取视图并发号施令"。两边通过一个BehaviorSubject通信,还能顺便解决视图未就绪时调用 API 的问题。
| 维度 | ViewChild 直取 | 服务 + BehaviorSubject | 全局模块级单例 |
|---|---|---|---|
| 跨组件访问 | 需要层层传递 | 直接注入 | 直接调用 |
| 多地图实例 | 天然支持 | 需要按 key 分组 | 不支持 |
| 时序处理 | 靠ngAfterViewInit | 靠 Observable 订阅 | 容易拿到 null |
| 单元测试 | 需要 mock DOM | 可注入假视图 | 难隔离 |
| 内存泄漏风险 | 中 | 低(可显式注册/注销) | 高 |
服务本身写得越薄越好,别在里面塞业务逻辑:
import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import type MapView from '@arcgis/core/views/MapView'; @Injectable({ providedIn: 'root' }) export class MapViewRegistry { private readonly subject = new BehaviorSubject<MapView | null>(null); readonly view$: Observable<MapView | null> = this.subject.asObservable(); register(view: MapView): void { this.subject.next(view); } unregister(view: MapView): void { if (this.subject.value === view) { this.subject.next(null); } } get current(): MapView | null { return this.subject.value; } }注意unregister里那个相等判断。多个地图实例共存时(比如一个主图加一个鹰眼图),如果不判断就置空,关掉鹰眼会把主图的引用也清掉。这种 bug 现场很难复现,因为要看用户的关闭顺序。
2.3 销毁与多实例:destroy()不是可选项
MapView内部挂着 WebGL 上下文、瓦片请求队列、事件监听和一堆定时器。组件销毁时不调view.destroy(),轻则内存持续增长,重则浏览器报"too many WebGL contexts"然后整页白屏。我见过一个后台系统在路由之间来回切二十几次之后地图直接不显示了,就是这个原因。
ngOnDestroy(): void { if (this.view) { this.registry.unregister(this.view); this.view.destroy(); // 必须调用 this.view = undefined; } }顺序也有讲究:先注销,再销毁。反过来的话,订阅者可能在视图已经被拆掉之后拿到引用,一调用goTo就抛异常。另外如果你用了view.when()的异步回调,记得在里面也判断一下组件是否已销毁,否则回调回来的时候view.container已经是null了。
顺带提一个编译期的小坑:用 npm 方式安装的@arcgis/core,如果项目里用到需要静态资源的组件(比如Legend、BasemapGallery),得把node_modules/@arcgis/core/assets拷到输出目录,并配置esriConfig.assetsPath。Angular CLI 项目在angular.json的assets数组里加一条 glob 就行:
{ "glob": "**/*", "input": "node_modules/@arcgis/core/assets", "output": "/assets/arcgis" }3. 平移的像素换算是怎么推导出来的
3.1 resolution 与 scale:地图的"米每像素"到底怎么算
先建立直觉。view.resolution的含义是:屏幕上 1 个像素,对应地图坐标系里多少单位(Web Mercator 下就是米)。所以想让地图往右移动 120 个像素,理论上只要让中心点往右移120 * resolution米就够了。
问题在于,resolution不是常量。它和zoom的关系是每升高一级,resolution减半。你可以自己验证:view.resolution * Math.pow(2, view.zoom)在一张图内基本是个常数(在赤道附近约等于 156543.03)。这就是所谓"分辨率常量"的来源。
还有一个容易被忽略的点:在 Web Mercator 投影下,同一个zoom级别在不同纬度上的实际地表比例是不一样的。纬度高的时候,同样的resolution代表的地面距离更短。这个特性会导致两件事:一是如果你用"米"来定义平移步长,在高纬度地区地图会移动得特别快;二是用view.scale显示比例尺的时候,它会随纬度变化。
所以平移步长的单位一定要选像素,不能选米。这也是为什么下面的实现里全部基于屏幕坐标做换算。
3.2 用 toScreen 与 toMap 做双向换算
ArcGIS 提供了两个方向相反的换算函数。view.toScreen(mapPoint)把地图坐标转成屏幕像素坐标,返回一个{ x, y }对象,注意屏幕坐标的 y 轴是向下的;view.toMap(screenPoint)反过来,把屏幕像素坐标转成地图坐标,返回一个Point。两个函数在特殊情况下都可能返回null,比如点不在当前投影范围内、视图还没初始化完成、或者容器尺寸是 0。
用它们实现平移时,最直观的写法是"三步走":先取当前中心点的屏幕坐标,加上偏移量得到目标屏幕坐标,再转回地图坐标作为新中心点。
panByPixel(dx: number, dy: number): void { const view = this.registry.current; if (!view || !view.ready) { return; } const anchor = view.toScreen(view.center!); if (!anchor) { return; } const nextCenter = view.toMap({ x: anchor.x + dx, y: anchor.y + dy }); if (!nextCenter) { return; } view.goTo({ center: nextCenter }, { animate: true, duration: 380, easing: 'ease-out' }).catch(() => void 0); }这里有个可以简化的地方:view.center的屏幕坐标其实就等于视口中心,也就是(view.width / 2, view.height / 2)。所以直接写:
const nextCenter = view.toMap({ x: view.width / 2 + dx, y: view.height / 2 + dy });省掉一次toScreen调用。但要小心,如果视图设置了padding,中心点的屏幕位置会偏移,这时候用view.width / 2就不对了,还是老老实实走toScreen(view.center)更保险。
3.3 为什么必须走 toMap,而不是直接加减米
有人会问:既然resolution是"米每像素",那直接算center.x + dx * resolution不就行了?
单看水平方向,确实差不多成立。但垂直方向有个符号问题——屏幕坐标 y 向下增大,而地图坐标 y 向上增大。你得手动处理这个翻转。更麻烦的是view.rotation:一旦地图被旋转过,屏幕的"向右"就不再等于地图坐标系的"向东",dx * resolution这种算法会彻底失效。
toMap/toScreen内部封装了完整的仿射变换(含旋转和 y 轴翻转),所以它天然支持旋转视图。这也是我强烈建议用换算函数而不是手算的原因。
另外说说弧度:地图的东西向是循环的。如果你把center.x一直往东推,过了 180 度之后,Point的 x 可能会变成 200 多度也可能回到 -160 度,取决于view.spatialReference和内部处理。用toMap得到的点通常是规范化过的,一般不会出问题;但如果你自己拼Point,就得留意这个。
实操提醒:
toMap和toScreen都依赖容器尺寸。如果地图所在的容器在某个时刻被隐藏(比如display: none的 Tab),宽高会变成 0,这两个函数返回的值就完全不可信。切 Tab 回来之后记得调一次view.resize()。
4. 缩放控制的三个关键决策
4.1 goTo 的动画参数怎么配才不晕
view.goTo(target, options)的第二个参数决定了过渡手感,常用的有三个:
| 参数 | 类型 | 说明与建议值 |
|---|---|---|
animate | boolean | 关掉后跳变,适合做"复位"这类瞬时操作 |
duration | number | 动画时长(毫秒),短距离建议 250~400 |
easing | string | 缓动函数,linear匀速、ease-out起步快收尾缓 |
maxDuration | number | 上限兜底,防止远距离跳转动画拖太久 |
duration的默认值是根据距离自动算的,近距离跳转可能只有一两百毫秒,远距离可能两秒以上。用户连点缩放按钮时,长动画会变成累赘,所以定步长操作我一般都显式指定 300 到 400 毫秒。
easing选择上有个经验:平移用ease-out(起步快、停下缓),连续点击时手感更跟手;缩放用ease-in-out,视觉上不那么突兀。别用linear做长距离跳转,看起来像卡帧。
还有一点,goTo接受的目标对象可以是{ center, zoom }、{ center, scale }、{ target: geometry }、{ extent }等多种形式。不要混用zoom和scale,两个都给的话行为不确定。我的惯例是交互操作统一用zoom,跳转到某个要素才用scale或target。
4.2 步长累积:为什么连点两次经常只放大一级
这是外置缩放按钮最经典的问题。看这段代码:
zoomIn(): void { const view = this.registry.current; view?.goTo({ zoom: view.zoom + 1 }); }用户快速点两下,期望放两级。实际结果只放了一级多一点点,有时候甚至只放了一级。原因是第一次goTo启动动画之后,view.zoom是一个逐渐变化的值。第二次点击发生在动画中途,读到的view.zoom是中间值(比如 11.3),于是目标变成 12.3,而不是期望的 13。点得越快,累积误差越大。
正确做法是维护一个"待达成目标值",而不是每次都读实时值:
private pendingZoom: number | null = null; private navToken = 0; zoomByStep(step: number): void { const view = this.registry.current; if (!view) { return; } const min = view.constraints?.minZoom ?? 0; const max = view.constraints?.maxZoom ?? 24; const base = this.pendingZoom ?? view.zoom; const next = Math.min(max, Math.max(min, base + step)); this.pendingZoom = next; const token = ++this.navToken; view.goTo({ zoom: next }, { animate: true, duration: 320, easing: 'ease-in-out' }) .then(() => { // 只有当没有更新的导航指令时才清空 if (token === this.navToken) { this.pendingZoom = null; } }) .catch(() => { /* 被新动画打断,交给新指令接管 */ }); }navToken这个令牌机制是关键。goTo被新动画打断时 Promise 会 reject,如果我们无条件在 catch 里清空pendingZoom,那么被打断后的累积就断了。用令牌判断"我是不是最新那条指令",才能保证只有最后一条真正完成时重置状态。
顺便说,pendingZoom在动画结束后要设回null而不是设成view.zoom。因为snapToZoom为真时,实际落地的zoom可能是整数,而pendingZoom是逻辑值,两者不一致会造成下一次点击的步长跳变。
4.3 约束配置与锚点缩放:把缩放中心从视图中心挪走
默认情况下,goTo({ zoom })是以视图中心为锚点缩放的。绝大多数场景够用,但如果你想要"以鼠标指针位置为中心缩放"(类似地图滚轮的手感),就得自己算新的中心点。
推导过程不复杂。设当前中心为C,缩放锚点的地图坐标为A(通过view.toMap(anchorScreen)得到)。我们希望缩放完成后,A仍然落在同一个屏幕位置上。屏幕坐标和地图坐标之间是"旋转加均匀缩放"的关系,缩放比例由resolution决定。设缩放前后分辨率之比为k,那么:
新中心 = A - (A - C) * k其中k = 2^(当前zoom - 目标zoom),因为zoom每增加 1,resolution减半。
private zoomToWithAnchor(targetZoom: number, anchorScreen: { x: number; y: number }): void { const view = this.registry.current; if (!view) { return; } const min = view.constraints?.minZoom ?? 0; const max = view.constraints?.maxZoom ?? 24; const z = Math.min(max, Math.max(min, targetZoom)); const center = view.center; const anchorMap = view.toMap(anchorScreen); if (!center || !anchorMap) { return; } const k = Math.pow(2, view.zoom - z); const next = anchorMap.clone(); next.x = anchorMap.x - (anchorMap.x - center.x) * k; next.y = anchorMap.y - (anchorMap.y - center.y) * k; view.goTo({ center: next, zoom: z }, { animate: true, duration: 300, easing: 'ease-out' }).catch(() => void 0); }这个公式的好处是完全不依赖容器尺寸,也不受rotation影响,因为它是从"同一条变换关系"里消元得到的。推导里唯一近似的地方是view.resolution定义在视图中心纬度上,跨大纬度缩放时会有微小偏差,但视觉上完全看不出来。
约束这块还有两个参数值得改。snapToZoom默认为true,意思是goTo结束后会吸附到最近的 LOD 级别。做滑块连续缩放时,这个行为会让滑块和实际zoom对不上,所以要设成false。另外constraints.boundary可以限制可见范围,做"只让用户看本省"这类需求时比自己在extent变化事件里拦截要优雅得多。
5. 实测踩坑:Promise 拒绝、Zone 逃逸与滑块回环
5.1 goTo 返回的 Promise 在被打断时会 reject
先看控制台里最常见的那条报错:view:goto-interrupted。原因是goTo返回的 Promise 在动画未完成就被新的goTo或用户拖拽打断时,会用 reject 的形式结束。如果没写.catch(),控制台就会一直刷红字,Sentry 之类的监控还会把它当成线上异常上报,把真正的问题淹没掉。
处理方式很简单,但要注意别把 catch 当垃圾桶:
view.goTo({ zoom: next }, opts) .then(() => { /* 正常结束,做状态同步 */ }) .catch((err) => { if (err?.name === 'AbortError') { // 正常的打断,静默忽略 return; } console.warn('goTo 失败', err); });不同版本的 API 抛出的错误名不太一样,有的场景是AbortError,有的是带goto-interrupted的 message。稳妥的写法是判断一下错误内容里有没有 "interrupt" 关键字,其余错误照常打日志。千万别写一个空.catch(() => {})了事——真出了容器不可见之类的错误,你会一点线索都看不到。
5.2 ArcGIS 的回调不在 Angular 的 Zone 里
这是我踩得最久的一个坑。表现为:地图拖完之后,工具栏上的缩放百分比显示不更新,但点一下别的按钮它又刷新了。原因是 ArcGIS 的内部事件回调(view.watch、goTo().then、各种on()事件)都是在 Zone.js 之外执行的,Angular 感知不到数据变化,自然不会触发变更检测。
三种解法,按推荐度排序:
- 在创建视图时用
runOutsideAngular包起来,只在需要更新 UI 的地方zone.run()切回去。这样地图内部的高频事件(比如拖动时的extent变化)不会每帧都触发一次变更检测,性能最好。 - 订阅时手动
markForCheck(),配合ChangeDetectionStrategy.OnPush。写法略啰嗦,但比第一种更精细。 - 直接
zone.run()包住整个回调,简单粗暴。地图拖动时每秒几十次变更检测,大表格页面会明显卡顿。
constructor(private zone: NgZone) {} private setupSync(view: MapView): void { this.zone.runOutsideAngular(() => { view.watch('zoom', (z: number) => { this.zone.run(() => { this.displayZoom = Math.round(z * 10) / 10; }); }); }); }有个细节:this.zone.run()里的赋值操作越少越好。如果每次都要重算一堆派生值,就把它们提前算好,只把最终结果塞进去。
5.3 滑块与视图之间的回环该怎么断开
用一个range输入框做缩放滑块时,很容易写出"滑块变了推地图、地图变了推滑块"的双向绑定,然后它就自己抖起来了。因为goTo动画过程中zoom在持续变化,每次变化都去更新滑块,而滑块更新又可能触发input事件,形成回环。
断开的方式有两种。一种是加个"来源标记":由滑块驱动的goTo期间,把标记置为true,zoom监听里看到标记就不回写滑块,动画结束再置回false。
private sliderDriving = false; onSliderInput(value: number): void { this.sliderDriving = true; const view = this.registry.current; view?.goTo({ zoom: value }, { animate: false }) .catch(() => void 0) .finally(() => { this.sliderDriving = false; }); } // 视图侧 view.watch('zoom', (z: number) => { if (this.sliderDriving) { return; } this.zone.run(() => { this.sliderValue = z; }); });另一种更简洁:干脆不做双向绑定,滑块只作为"输入源",显示当前缩放值用另一个只读的文本。这样从结构上就不存在回环。我在实际项目里更倾向后者,因为用户对"滑块自己动"这件事的接受度其实不高,反而觉得怪。
滑块的animate一定要设成false。拖动滑块时每一帧都启动一个动画,会造成几十个goTo排队,松手之后地图还在自己动,体验极差。
5.4 长按连续平移的节流与收尾
按住方向键连续移动,直觉实现是setInterval每 50 毫秒调一次panByPixel。问题是这样会在半秒内发起十个goTo,互相打断,实际位移远小于预期,而且松手后还有动画残留。
我的做法是把"目标中心点"当成累积状态,用一个较慢的定时器(比如每 100 毫秒)刷新目标值,但只在没有动画进行时发起新的goTo:
private panTimer: ReturnType<typeof setInterval> | null = null; private panAccum = { x: 0, y: 0 }; startPan(dx: number, dy: number): void { this.stopPan(); this.panAccum = { x: dx, y: dy }; this.panTimer = setInterval(() => { this.flushPan(); }, 110); this.flushPan(); } private flushPan(): void { const view = this.registry.current; if (!view) { return; } if (view.animation && view.animation.state === 'running') { return; } const { x, y } = this.panAccum; const next = view.toMap({ x: view.width / 2 + x, y: view.height / 2 + y }); if (!next) { return; } view.goTo({ center: next }, { animate: true, duration: 160, easing: 'linear' }).catch(() => void 0); } stopPan(): void { if (this.panTimer) { clearInterval(this.panTimer); this.panTimer = null; } }view.animation.state判断是这段代码的核心。它让每一段动画能跑完再开下一段,视觉上是连续的匀速移动,而不是抽搐。另外触摸设备上要监听pointerdown/pointerup/pointercancel,pointercancel别忘了处理,否则手指滑出按钮范围时定时器会一直跑下去。
6. 一套可直接抄的控制组件代码
6.1 地图宿主组件:创建、注册、销毁
宿主组件只做三件事,不掺业务逻辑。注意runOutsideAngular和ui.components的配置。
import { AfterViewInit, Component, ElementRef, NgZone, OnDestroy, ViewChild } from '@angular/core'; import Map from '@arcgis/core/Map'; import MapView from '@arcgis/core/views/MapView'; import { MapViewRegistry } from './map-view.registry'; @Component({ selector: 'app-map-host', standalone: true, template: `<div class="map-root" #mapContainer></div>`, styles: [` :host { display: block; position: relative; width: 100%; height: 100%; } .map-root { width: 100%; height: 100%; } `] }) export class MapHostComponent implements AfterViewInit, OnDestroy { @ViewChild('mapContainer') mapContainer!: ElementRef<HTMLDivElement>; private view?: MapView; constructor(private zone: NgZone, private registry: MapViewRegistry) {} ngAfterViewInit(): void { this.zone.runOutsideAngular(() => { const view = new MapView({ container: this.mapContainer.nativeElement, map: new Map({ basemap: 'topo-vector' }), center: [116.391, 39.907], zoom: 11, constraints: { minZoom: 3, maxZoom: 19, snapToZoom: false }, ui: { components: ['attribution'] } }); view.when(() => { this.zone.run(() => { this.registry.register(view); }); }); this.view = view; }); } ngOnDestroy(): void { const view = this.view; if (view) { this.registry.unregister(view); view.destroy(); this.view = undefined; } } }ui.components里我保留了attribution。默认的那些控件(缩放、指北针、比例尺)如果全部清掉,记得确认底图服务的使用条款是否允许隐去版权信息,这个别图省事。
6.2 控制条组件:模板、样式与交互
控制条组件的模板就是几个按钮加一个滑块。样式上要注意按钮的pointer-events和焦点态。
<div class="nav-pad" role="group" aria-label="地图导航"> <button type="button" aria-label="向上平移" (pointerdown)="startPan(0, 120)" (pointerup)="stopPan()" (pointerleave)="stopPan()" (pointercancel)="stopPan()">↑</button> <button type="button" aria-label="向下平移" (pointerdown)="startPan(0, -120)" (pointerup)="stopPan()" (pointerleave)="stopPan()">↓</button> <button type="button" aria-label="向左平移" (pointerdown)="startPan(120, 0)" (pointerup)="stopPan()" (pointerleave)="stopPan()">←</button> <button type="button" aria-label="向右平移" (pointerdown)="startPan(-120, 0)" (pointerup)="stopPan()" (pointerleave)="stopPan()">→</button> <button type="button" aria-label="放大" (click)="zoomByStep(1)">+</button> <button type="button" aria-label="缩小" (click)="zoomByStep(-1)">-</button> <output class="zoom-readout">{{ displayZoom | number: '1.0-1' }}</output> </div>注意方向键的符号:点击"向左平移",地图内容要往左走,意味着视野中心往右移,所以dx是正的。这个符号我建议在按钮的aria-label上写清楚"地图向哪个方向移动",而不是"视野向哪个方向移动",因为用户的直觉是前者。
滑块部分:
<input type="range" class="zoom-range" [min]="minZoom" [max]="maxZoom" [step]="0.25" [value]="sliderValue" (input)="onSliderInput($event)" aria-label="缩放级别" />组件 TS 部分把前面几节的逻辑拼起来即可,重点是sliderValue的初始化要等视图注册之后再做:
ngOnInit(): void { this.sub = this.registry.view$.subscribe((view) => { if (!view) { return; } this.minZoom = view.constraints?.minZoom ?? 0; this.maxZoom = view.constraints?.maxZoom ?? 24; this.syncFromView(view); }); } private syncFromView(view: MapView): void { this.zone.runOutsideAngular(() => { view.watch('zoom', (z: number) => { if (this.sliderDriving) { return; } this.zone.run(() => { this.displayZoom = z; this.sliderValue = z; }); }); }); }用view$.subscribe而不是在ngAfterViewInit里直接读registry.current,是因为地图初始化的速度取决于网络和瓦片服务,控制条可能先就绪。订阅模式天然处理了这个时序。
6.3 键盘与无障碍:别让外置控制变成只支持鼠标
既然把控制条搬出了地图,键盘支持就得自己补上。做法很简单:在控制条的容器上监听keydown,方向键映射平移,+/-映射缩放,并且对按住时的重复触发(event.repeat为true)直接用同一套累积逻辑处理,不用另写一套。
@HostListener('keydown', ['$event']) onKeydown(e: KeyboardEvent): void { const step = 120; switch (e.key) { case 'ArrowUp': this.startPan(0, step); break; case 'ArrowDown': this.startPan(0, -step); break; case 'ArrowLeft': this.startPan(step, 0); break; case 'ArrowRight': this.startPan(-step, 0); break; case '+': case '=': this.zoomByStep(1); return; case '-': case '_': this.zoomByStep(-1); return; default: return; } e.preventDefault(); }preventDefault是必须的,否则方向键会去滚动页面。另外+键在多数键盘上需要配合 Shift,浏览器报的e.key是+,但小键盘上的加号报的也是+,所以=和+都要处理一遍。
最后贴一下按钮的基础样式,重点是touch-action:
.nav-pad { position: absolute; right: 16px; bottom: 16px; display: grid; grid-template-columns: repeat(3, 40px); gap: 6px; padding: 8px; border-radius: 10px; background: rgba(255, 255, 255, .92); box-shadow: 0 2px 10px rgba(0, 0, 0, .16); } .nav-pad button { width: 40px; height: 36px; border: 1px solid #d5d9e0; border-radius: 6px; background: #fff; cursor: pointer; touch-action: none; user-select: none; font-size: 15px; line-height: 1; } .nav-pad button:focus-visible { outline: 2px solid #2b6cb0; outline-offset: 1px; }touch-action: none是为了防止在触摸屏上长按时触发浏览器的滚动或双击缩放,少了这一行,平板上按住方向键会变成拖页面。
这套代码我在两个项目里复用下来基本没改过,唯一的调整是把duration从 380 改到 300 左右,因为大部分用户是在笔记本触摸板上操作,动画太长会觉得"黏"。另外提醒一句,如果你用的是 Angular 的迁移到无 Zone 模式(zoneless),NgZone的用法要换成信号,view.watch里改成写signal再让模板读它,逻辑本身不用动。