ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Angular+ArcGIS JS地图外置控制条:平移缩放与goTo像素换算

Angular+ArcGIS JS地图外置控制条:平移缩放与goTo像素换算 地图交互控制条放在地图外面看起来只是把几个按钮挪个位置实际上是把整个视图状态的读写权限从地图组件手里接了过来。这件事在 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的纬度共同决定。你改zoomresolution跟着变你改centerresolution也可能变因为纬度变了。这种互相耦合的关系靠 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!: ElementRefHTMLDivElement; 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] } }); } }这套写法的问题不在初始化而在后续。一旦控制条要挪到另一个组件比如右侧工具栏或者地图组件被放进路由复用RouteReuseStrategythis.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 BehaviorSubjectMapView | null(null); readonly view$: ObservableMapView | 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)的第二个参数决定了过渡手感常用的有三个参数类型说明与建议值animateboolean关掉后跳变适合做复位这类瞬时操作durationnumber动画时长毫秒短距离建议 250~400easingstring缓动函数linear匀速、ease-out起步快收尾缓maxDurationnumber上限兜底防止远距离跳转动画拖太久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每增加 1resolution减半。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期间把标记置为truezoom监听里看到标记就不回写滑块动画结束再置回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 毫秒刷新目标值但只在没有动画进行时发起新的goToprivate panTimer: ReturnTypetypeof 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/pointercancelpointercancel别忘了处理否则手指滑出按钮范围时定时器会一直跑下去。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 classmap-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!: ElementRefHTMLDivElement; 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 classnav-pad rolegroup aria-label地图导航 button typebutton aria-label向上平移 (pointerdown)startPan(0, 120) (pointerup)stopPan() (pointerleave)stopPan() (pointercancel)stopPan()↑/button button typebutton aria-label向下平移 (pointerdown)startPan(0, -120) (pointerup)stopPan() (pointerleave)stopPan()↓/button button typebutton aria-label向左平移 (pointerdown)startPan(120, 0) (pointerup)stopPan() (pointerleave)stopPan()←/button button typebutton aria-label向右平移 (pointerdown)startPan(-120, 0) (pointerup)stopPan() (pointerleave)stopPan()→/button button typebutton aria-label放大 (click)zoomByStep(1)/button button typebutton aria-label缩小 (click)zoomByStep(-1)/button output classzoom-readout{{ displayZoom | number: 1.0-1 }}/output /div注意方向键的符号点击向左平移地图内容要往左走意味着视野中心往右移所以dx是正的。这个符号我建议在按钮的aria-label上写清楚地图向哪个方向移动而不是视野向哪个方向移动因为用户的直觉是前者。滑块部分input typerange classzoom-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 模式zonelessNgZone的用法要换成信号view.watch里改成写signal再让模板读它逻辑本身不用动。
返回列表