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/, + ); +});