From 4b86235e7c54199fdeaeeecad0f6098bfdfe92fa Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:30:13 -0800 Subject: [PATCH] Let clearOverlays() clear the marker controllers extensions register Providers' clearOverlays() cleared their own six controllers only. The StrategyMarkerController that MarkerClusterGroup (and anything built on MarkerRenderingSupport) creates and registers with registerOverlayController() was never cleared, so clustered markers stayed on the map after clearOverlays(). BaseMapViewController.clearOverlayControllers(own) is the one place to call from clearOverlays(): it clears `own` in the order given (what the providers listed before), then every other registered controller that takes part in the overlay slots (has a `kind`), in registration order. Camera-only registrations such as the heatmap's are left alone. OverlayControllerLike gains an optional clear() for this. MapDriverConformance.checkClearOverlaysReachesExtensions() catches a provider that does not route through it, which would otherwise compile and silently keep the markers. ClearOverlaysStrategy.test.mjs fails on the previous core (no such helper; the old provider shape leaves the strategy's markers drawn) and passes now. Public API: additions only (optional clear?() on OverlayControllerLike); the helper and the check are @internal. --- api/js-sdk-core.api.d.ts | 5 + src/conformance/MapDriverConformance.ts | 47 ++++++++- src/controller/BaseMapViewController.ts | 30 ++++++ src/controller/OverlayController.ts | 5 + test/ClearOverlaysStrategy.test.mjs | 129 ++++++++++++++++++++++++ 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 test/ClearOverlaysStrategy.test.mjs diff --git a/api/js-sdk-core.api.d.ts b/api/js-sdk-core.api.d.ts index 206f03d..2feea94 100644 --- a/api/js-sdk-core.api.d.ts +++ b/api/js-sdk-core.api.d.ts @@ -449,6 +449,11 @@ declare const CANONICAL_ORDER: readonly OverlayKind[]; interface OverlayControllerLike { onCameraChanged?(mapCameraPosition: MapCameraPosition): Promise | void; destroy?(): void; + /** + * 載せているものを全部外す。`BaseMapViewController.clearOverlayControllers` が呼ぶ + * (プロバイダ自身のもの以外は、スロットに参加しているものだけ)。 + */ + clear?(): Promise | void; readonly kind?: OverlayKind; hasId?(id: string): boolean; compositionAny?(data: unknown[]): Promise; diff --git a/src/conformance/MapDriverConformance.ts b/src/conformance/MapDriverConformance.ts index c0cfe77..2c28d23 100644 --- a/src/conformance/MapDriverConformance.ts +++ b/src/conformance/MapDriverConformance.ts @@ -44,7 +44,8 @@ const COORD_TOLERANCE = 1e-5; * ``` * * android-sdk の `MapDriverConformance.kt`、ios-sdk の - * `MapDriverConformance.swift` と同じ 5 つのチェックを持つ。 + * `MapDriverConformance.swift` と同じ 5 つのチェックに、`clearOverlays()` が拡張の + * マーカーまで届くかの非同期チェック({@link MapDriverConformance.checkClearOverlaysReachesExtensions})を足したもの。 * * ## ここに入れられないもの * @@ -214,4 +215,48 @@ export const MapDriverConformance = { ); } }, + + /** + * `clearOverlays()` が、拡張が登録したマーカーのコントローラ(クラスタリングなどの + * `StrategyMarkerController`)まで clear するか。 + * + * プロバイダの `clearOverlays()` が自分の 6 つだけを clear していると、 + * クラスタリングのマーカーが `clearOverlays()` の後も残る。型でもビルドでも止まらない + * 「黙って何もしない」形なので、ここで確かめる。`clearOverlays()` は + * `BaseMapViewController.clearOverlayControllers` を通すこと。 + */ + async checkClearOverlaysReachesExtensions(controller: { + clearOverlays(): Promise; + registerOverlayController?(controller: OverlayControllerLike): void; + unregisterOverlayController?(controller: OverlayControllerLike): void; + }): Promise { + require_( + typeof controller.registerOverlayController === 'function', + () => 'the controller has no registerOverlayController; extensions cannot reach it', + ); + let cleared = 0; + const extension: OverlayControllerLike = { + kind: 'marker', + hasId: () => false, + compositionAny: async () => {}, + updateAny: async () => {}, + setClickListenerAny: () => {}, + resolveTap: () => null, + clear: async () => { + cleared++; + }, + }; + controller.registerOverlayController!(extension); + try { + await controller.clearOverlays(); + } finally { + controller.unregisterOverlayController?.(extension); + } + require_( + cleared === 1, + () => + `clearOverlays() cleared a registered extension marker controller ${cleared} times ` + + '(expected once); call clearOverlayControllers() from clearOverlays()', + ); + }, } as const; diff --git a/src/controller/BaseMapViewController.ts b/src/controller/BaseMapViewController.ts index 155b45a..43ce01d 100644 --- a/src/controller/BaseMapViewController.ts +++ b/src/controller/BaseMapViewController.ts @@ -150,6 +150,36 @@ export abstract class BaseMapViewController { this.overlayControllers.push(controller); } + /** + * `clearOverlays()` の中身。プロバイダは自分のコントローラを渡して呼ぶだけでよい。 + * + * 1. `own` を渡された順に 1 つずつ clear する(今までプロバイダが並べていたもの)。 + * 2. 続けて、登録済みのうち `own` に無く、スロットに参加している(`kind` を持つ) + * コントローラを登録順に clear する。拡張が足すストラテジ駆動のマーカー + * (MarkerClusterGroup や MarkerRenderingSupport で作った `StrategyMarkerController`)がここに入る。 + * + * 以前はプロバイダの `clearOverlays()` が自分の 6 つしか clear しておらず、 + * クラスタリングなどのマーカーは `clearOverlays()` の後も地図に残った。 + * カメラ購読だけのために登録するもの(heatmap など、スロットに参加しないもの)は + * オーバーレイを持たないので触らない。 + * + * android-sdk / ios-sdk の `clearOverlays()` も同じ範囲を clear する。 + * + * @internal ドライバー実装点。 + */ + protected async clearOverlayControllers( + own: readonly { clear(): Promise | void }[], + ): Promise { + for (const controller of own) await controller.clear(); + const ownSet = new Set(own); + for (const controller of [...this.overlayControllers]) { + if (ownSet.has(controller)) continue; + if (!isSlottedOverlayController(controller)) continue; + if (typeof controller.clear !== 'function') continue; + await controller.clear(); + } + } + /** 登録を解除する。拡張コンポーネントのアンマウント時に呼ぶ。 */ unregisterOverlayController(controller: OverlayControllerLike): void { const index = this.overlayControllers.indexOf(controller); diff --git a/src/controller/OverlayController.ts b/src/controller/OverlayController.ts index 4f51dd6..34dfa90 100644 --- a/src/controller/OverlayController.ts +++ b/src/controller/OverlayController.ts @@ -14,6 +14,11 @@ import type { OverlayKind } from "./OverlayKind"; export interface OverlayControllerLike { onCameraChanged?(mapCameraPosition: MapCameraPosition): Promise | void; destroy?(): void; + /** + * 載せているものを全部外す。`BaseMapViewController.clearOverlayControllers` が呼ぶ + * (プロバイダ自身のもの以外は、スロットに参加しているものだけ)。 + */ + clear?(): Promise | void; // ── スロット参加分({@link SlottedOverlayController} を参照) ────────── // diff --git a/test/ClearOverlaysStrategy.test.mjs b/test/ClearOverlaysStrategy.test.mjs new file mode 100644 index 0000000..e33e920 --- /dev/null +++ b/test/ClearOverlaysStrategy.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + BaseMapViewController, + MapDriverConformance, + MarkerManager, + StrategyMarkerController, + createGeoPoint, + createGeoRectBounds, + createMarkerEntity, + createMarkerState, +} from '../dist/index.mjs'; + +/** + * clearOverlays() must also clear the marker controllers that extensions register + * (MarkerClusterGroup / MarkerRenderingSupport build a StrategyMarkerController and + * registerOverlayController it). Providers used to clear only their own six + * controllers, so clustered markers stayed on the map after clearOverlays(). + * Providers now call clearOverlayControllers(own) from clearOverlays(). + */ + +const bounds = createGeoRectBounds({ + southWest: createGeoPoint({ latitude: 34, longitude: 138 }), + northEast: createGeoPoint({ latitude: 36, longitude: 140 }), +}); +const camera = { position: createGeoPoint({ latitude: 35, longitude: 139 }), zoom: 10, visibleRegion: { bounds } }; + +class GlRenderer { + constructor(manager) { this.manager = manager; this.drawn = []; this.holder = null; } + async onAdd(data) { return data.map((p) => p.state.id); } + async onChange(data) { return data.map((p) => p.current.state.id); } + async onRemove() {} + async onAnimate() {} + async onPostProcess() { this.drawn = this.manager.allEntities().map((e) => e.state.id).sort(); } +} + +class DrawAllStrategy { + constructor() { this.markerManager = MarkerManager.defaultManager(); } + clear() { this.markerManager.clear(); } + async onAdd({ data, renderer }) { + const markers = await renderer.onAdd(data.map((state) => ({ state, bitmapIcon: null }))); + data.forEach((state, i) => this.markerManager.registerEntity(createMarkerEntity({ state, marker: markers[i] }))); + await renderer.onPostProcess(); + return true; + } + async onUpdate() { return true; } + async onCameraChanged() {} +} + +/** A provider's own controller: counts clear() calls, in the order they happen. */ +class OwnController { + constructor(kind, log) { this.kind = kind; this.log = log; } + hasId() { return false; } + async compositionAny() {} + async updateAny() {} + setClickListenerAny() {} + resolveTap() { return null; } + async clear() { this.log.push(this.kind); } +} + +class Provider extends BaseMapViewController { + constructor() { + super(); + this.log = []; + this.own = ['marker', 'circle', 'polyline', 'polygon', 'groundImage', 'rasterLayer'].map((k) => new OwnController(k, this.log)); + this.own.forEach((c) => this.registerOverlayController(c)); + } + async clearOverlays() { await this.clearOverlayControllers(this.own); } +} + +/** The old shape: the provider clears its own controllers only. */ +class OldProvider extends Provider { + async clearOverlays() { for (const c of this.own) await c.clear(); } +} + +async function strategyOn(provider) { + const strategy = new DrawAllStrategy(); + const renderer = new GlRenderer(strategy.markerManager); + const smc = new StrategyMarkerController({ strategy, renderer }); + provider.registerOverlayController(smc); + await smc.onCameraChanged(camera); + await smc.add([ + createMarkerState({ id: 's1', position: createGeoPoint({ latitude: 35, longitude: 139 }) }), + createMarkerState({ id: 's2', position: createGeoPoint({ latitude: 35.1, longitude: 139.1 }) }), + ]); + return { strategy, renderer, smc }; +} + +test('clearOverlays() clears a registered StrategyMarkerController', async () => { + const provider = new Provider(); + const { strategy, renderer } = await strategyOn(provider); + assert.deepEqual(renderer.drawn, ['s1', 's2']); + await provider.clearOverlays(); + assert.deepEqual(renderer.drawn, []); + assert.equal(strategy.markerManager.allEntities().length, 0); +}); + +test('own controllers are cleared first, once each, in the order given', async () => { + const provider = new Provider(); + await strategyOn(provider); + await provider.clearOverlays(); + assert.deepEqual(provider.log, ['marker', 'circle', 'polyline', 'polygon', 'groundImage', 'rasterLayer']); +}); + +test('controllers that only listen to the camera are left alone', async () => { + const provider = new Provider(); + let cleared = 0; + // heatmap-style: registers for camera events, is not a slotted overlay controller + provider.registerOverlayController({ onCameraChanged() {}, clear() { cleared++; } }); + await provider.clearOverlays(); + assert.equal(cleared, 0); +}); + +test('an unregistered strategy controller is not cleared', async () => { + const provider = new Provider(); + const { renderer, smc } = await strategyOn(provider); + provider.unregisterOverlayController(smc); + await provider.clearOverlays(); + assert.deepEqual(renderer.drawn, ['s1', 's2']); +}); + +test('conformance check passes for clearOverlayControllers and fails for the old shape', async () => { + await MapDriverConformance.checkClearOverlaysReachesExtensions(new Provider()); + await assert.rejects( + MapDriverConformance.checkClearOverlaysReachesExtensions(new OldProvider()), + /clearOverlays\(\) cleared a registered extension marker controller 0 times/, + ); +});