diff --git a/.gitignore b/.gitignore index a4df7c7..8e5c283 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,14 @@ dist/* *.map *.mjs *.cjs +# The patterns above are unanchored, so they swallow sources as well as build +# output — `!src/tileserver/tile-sw.js` above is the same problem, patched once +# before. The whole test suite was invisible to git because of it. +!test/** dist/*.d.ts dist/ dist/ + +# 適合テストが吐く自分の出力。突き合わせ用の中間物で、正本は projection-expected.txt。 +test/resources/projection-actual.*.txt +test/resources/geocell-actual.*.txt diff --git a/api/js-sdk-core.api.d.ts b/api/js-sdk-core.api.d.ts index 08a5ad8..dfe6ea2 100644 --- a/api/js-sdk-core.api.d.ts +++ b/api/js-sdk-core.api.d.ts @@ -531,164 +531,280 @@ interface OverlayController { /** 登録済みコントローラが {@link SlottedOverlayController} を満たすか。 */ declare function isSlottedOverlayController(controller: OverlayControllerLike): controller is OverlayControllerLike & SlottedOverlayController; -/** - * Configuration options for initializing a map - */ -interface MapConfig { - /** Container element for the map */ - container: HTMLElement | string; - /** API key or access token for the map provider */ - apiKey?: string; - /** - * Additional provider-specific options. Deliberately typed as `Record`: provider configs extend MapConfig and NARROW this to their own - * option interface (e.g. LeafletConfig -> Leaflet's `MapOptions`), which is - * only assignable when the base is permissive. `Record` would - * break that subtyping (TS2430), so `any` is intentional here. - */ - options?: Record; - initCameraPosition: MapCameraPosition; +declare enum TileScheme { + XYZ = "XYZ", + TMS = "TMS" } +type RasterAttributionRule = AttributionRule; +type RasterLayerSource = { + type: "UrlTemplate"; + template: string; + tileSize?: number; + minZoom?: number | null; + maxZoom?: number | null; + attributionRules?: AttributionRule[]; + scheme?: TileScheme; +} | { + type: "TileJson"; + url: string; +} | { + type: "ArcGisService"; + serviceUrl: string; +}; +declare const RasterLayerSource: { + DEFAULT_TILE_SIZE: number; + UrlTemplate(params: { + template: string; + tileSize?: number; + minZoom?: number | null; + maxZoom?: number | null; + attributionRules?: AttributionRule[]; + scheme?: TileScheme; + }): RasterLayerSource; + TileJson(url: string): RasterLayerSource; + ArcGisService(serviceUrl: string): RasterLayerSource; +}; +declare function resolveRasterAttributions(sources: RasterLayerSource[], camera: MapCameraPosition): string[]; + /** - * Abstract base class for map providers + * 明示指定が無いときの `userAgent`。 + * + * 「利用者が指定した値かどうか」を判定するために公開している。空ではないので、 + * 既定のままの状態まで「指定あり」として扱うと、ラスタレイヤを 1 枚置いただけで + * 非対応の警告が出てしまう。android-sdk の `RasterLayerState.DEFAULT_USER_AGENT` / + * ios-sdk の `RasterLayerState.defaultUserAgent` と同じ値・同じ用途。 */ -declare abstract class MapProvider { - protected controller: MapViewControllerInterface | null; - /** - * Initialize the map provider - */ - abstract initialize(config: MapConfig): Promise; - /** - * Get the current controller instance - */ - getController(): MapViewControllerInterface | null; - /** - * Check if the provider is initialized - */ - isInitialized(): boolean; - /** - * Clean up resources - */ - abstract destroy(): void; +declare const DEFAULT_RASTER_LAYER_USER_AGENT = "MapConductor/RasterLayerAgent(https://mapconductor.com)"; +interface RasterLayerFingerPrint { + id: number; + source: number; + opacity: number; + visible: number; + zIndex: number; + userAgent: number; + debug: number; + /** `extraHeaders` のハッシュ。中身が extraHeaders しか無いので名前を実態に合わせている。 */ + extraHeaders: number; } -/** - * Map provider types - */ -declare enum MapProviderType { - GOOGLE_MAPS = "google-maps", - MAPLIBRE = "maplibre", - MAPBOX = "mapbox", - HERE = "here", - ARCGIS = "arcgis" +interface RasterLayerEvent { + state: RasterLayerState; +} +type OnRasterLayerEventHandler = (event: RasterLayerEvent) => void; +interface RasterLayerState { + readonly id: string; + source: RasterLayerSource; + opacity: number; + visible: boolean; + zIndex: number; + userAgent: string; + debug: boolean; + extraHeaders: Record | null; + fingerPrint(): RasterLayerFingerPrint; + copy(opts?: RasterLayerStateCopyParams): RasterLayerState; + equals(other: unknown): boolean; + hashCode(): number; + asObservable(): { + subscribe: (fn: (fp: RasterLayerFingerPrint) => void) => () => void; + }; +} +interface RasterLayerStateCopyParams { + source?: RasterLayerSource; + opacity?: number; + visible?: boolean; + zIndex?: number; + userAgent?: string; + debug?: boolean; + extraHeaders?: Record | null; + id?: string | null; +} +declare function createRasterLayerState(params: { + source: RasterLayerSource; + opacity?: number; + visible?: boolean; + zIndex?: number; + userAgent?: string; + debug?: boolean; + extraHeaders?: Record | null; + id?: string | null; +}): RasterLayerState; + +interface TileRequest { + readonly x: number; + readonly y: number; + readonly z: number; +} + +interface TileProvider { + renderTile(request: TileRequest): Uint8Array | null | Promise; } -type OnMapLoadedHandler = (state: TState) => void; -type OnMapEventHandler = (point: GeoPoint) => void; -type OnCameraMoveHandler = (camera: MapCameraPosition) => void; /** - * Common props that every map-view component must accept. - * Extend this interface in SDK-specific packages to add platform-specific props - * (e.g. apiKey for Google Maps, style URL for MapLibre). + * What a tile request resolved to. + * + * The distinction that matters is **empty** versus **failed**, because a map + * library remembers them differently. A spot with nothing on it is a real + * answer — a transparent picture, cacheable, replaced when the data version + * moves the tile URL. A render that could not be completed right now is not: + * answering it with a transparent picture paints a hole and caches it, and the + * library never asks again, so the neighbouring tiles' overhang is cut off at + * its edge. + * + * ios-sdk's `LocalTileServer.TileOutcome` and android-sdk-core's + * `LocalTileServer.TileOutcome` are the same four cases. */ -interface MapViewBaseProps>> { - state: TState; - onMapLoaded?: OnMapLoadedHandler; - onMapClick?: OnMapEventHandler; - onMapLongClick?: OnMapEventHandler; - onCameraMoveStart?: OnCameraMoveHandler; - onCameraMove?: OnCameraMoveHandler; - onCameraMoveEnd?: OnCameraMoveHandler; - className?: string; - /** - * カメラの可動範囲(パン範囲・ズーム上下限)を制限する。 - * - * android-sdk の各 *MapView コンポーザブルが受け取る `cameraRestriction` 引数に対応する。 - * 個別の `restrictBounds` / `minZoom` / `maxZoom` prop も引き続き受け付けるが、 - * そちらはマップ生成時にしか反映されない。実行時に変更したい場合はこちらを使う。 - */ - cameraRestriction?: CameraRestriction | null; +type TileOutcome = { + readonly kind: 'tile'; + readonly bytes: Uint8Array; +} +/** Nothing to draw here. Answer with a transparent tile. */ + | { + readonly kind: 'empty'; +} +/** The provider could not draw it now. The caller should retry, not cache. */ + | { + readonly kind: 'failed'; } +/** Nothing of that name: an unknown route, or a path that is not a tile. */ + | { + readonly kind: 'notFound'; +}; /** - * Abstract base for map-view implementations. - * - * Mirrors Android's MapViewBase and enforces the three-step initialization - * contract that every new map SDK module must provide: - * 1. sdkInitialize — load the SDK (e.g. inject a script tag) - * 2. createHolder — wrap the native map in a MapViewHolder - * 3. createController — wrap the holder in a map view controller + * A 1x1 transparent PNG, which scales to whatever the map draws it into. * - * Android equivalent: MapViewBase.kt (sdkInitialize / holderProvider / controllerProvider) - * iOS equivalent: Each view's Coordinator.bind() in MapConductorCore + * Shared so the several places that answer "empty" all answer with the same + * bytes; the copies that used to live in each provider said the same thing in + * seven different arrays. */ -declare abstract class MapViewBase>, THolder extends MapViewHolder, TController extends MapViewControllerInterface> { - /** Load the underlying map SDK. Return false to abort initialization. */ - protected abstract sdkInitialize(): Promise; - /** Build the SDK-specific config from the view state (camera position, design type, etc.). */ - protected abstract buildConfig(state: TState, container: HTMLElement): MapConfig; - /** Create a holder that wraps the native map view instance. */ - protected abstract createHolder(container: HTMLElement): Promise; - /** Create the controller that exposes the MapConductor API. */ - protected abstract createController(holder: THolder): Promise; +declare const TRANSPARENT_TILE_PNG: Uint8Array; +/** + * Thrown by the helpers that hand tiles straight to a map library, where the + * only way to say "this one failed, ask again" is to reject. + */ +declare class TileRenderFailedError extends Error { + constructor(routeId: string, request: { + x: number; + y: number; + z: number; + }); } -type OnMapInitializedHandler = () => void; /** - * Core controller interface that all map SDK modules must implement. - * Mirrors Android MapViewControllerInterface / iOS MapViewControllerProtocol. + * Browser-side tile server implemented via a Service Worker interceptor. + * + * The Service Worker intercepts requests to `/__tiles/{routeId}/{tileSize}/{z}/{x}/{y}.png` + * and renders registered SW-side tile data with OffscreenCanvas when possible. + * If SW-side rendering is unavailable, it forwards requests back to the registered + * TileProvider on the main thread. + * + * For CPU-intensive tile rendering, attach a Web Worker via `attachRenderer()` so that + * `renderTile()` executes off the main thread. The worker should call + * `createTileWorkerHandler()` from `@mapconductor/js-sdk-core`. */ -interface MapViewControllerInterface { - readonly holder: MapViewHolder; - clearOverlays(): Promise; - setCameraMoveStartListener(listener: OnCameraMoveHandler | null): void; - setCameraMoveListener(listener: OnCameraMoveHandler | null): void; - setCameraMoveEndListener(listener: OnCameraMoveHandler | null): void; - setMapClickListener(listener: OnMapEventHandler | null): void; - setMapLongClickListener(listener: OnMapEventHandler | null): void; - setMapInitializedListener(listener: OnMapInitializedHandler | null): void; - moveCamera(position: MapCameraPosition): Promise; +declare class LocalTileServer { + readonly baseUrl: string; + private readonly providers; + private static instance; + private static swRegistered; + private workerRenderer; + private readonly pendingRequests; + private nextRequestId; + private constructor(); + static startServer(): LocalTileServer; /** - * カメラをアニメーションで移動する。 - * - * 引数は android-sdk の `animateCamera(position, duration: Long)` に揃えてある。 - * 以前は `CameraOptions` を受けていたが、実際に読まれていたのは `duration` だけで、 - * `easing` はどのプロバイダも見ておらず、`paddings` は `padding` の別名として - * フォールバックされるだけだった。 - * - * 戻り値が `Promise` なのは Kotlin の `Unit` / Swift の `Void` に対する TS 側の - * 言い方の違いで、`clearOverlays()` が `suspend fun` に対して `Promise` なのと同じ。 + * Attach a Web Worker that handles tile rendering via `createTileWorkerHandler()`. + * When attached, `handleFetch()` delegates `renderTile()` to the worker thread + * instead of running it on the main thread. */ - animateCamera(position: MapCameraPosition, durationMillis: number): Promise; + attachRenderer(worker: Worker): void; + /** Detach the renderer worker and fall back to main-thread rendering. */ + detachRenderer(): void; + register(routeId: string, provider: TileProvider): void; + unregister(routeId: string): void; /** - * Applies the gesture flags to the underlying map engine. + * Send provider data to the SW and await acknowledgment before returning. + * This guarantees the SW can render tiles with OffscreenCanvas before the + * caller adds the raster source (which triggers tile requests immediately). * - * Optional the same way Android's `applyUISettings` has an empty default body: - * a provider that has not been wired up yet simply leaves the map as it is. + * Falls back (resolves) after 500 ms if the SW does not respond — in that + * case the postMessage fallback path in the SW will handle tile requests + * using the provider already stored in `this.providers`. */ - applyUISettings?(settings: MapUISettings): void; + sendSWRegisterAndWait(routeId: string, data: { + items: { + lat: number; + lng: number; + iconIndex: number; + }[]; + icons: { + bitmap: ImageBitmap; + anchor: { + x: number; + y: number; + }; + size: { + width: number; + height: number; + }; + }[]; + zoomScales: number[]; + extraIconScale: number; + }): Promise; + private postToSW; + urlTemplate({ routeId, tileSize, cacheKey, }: { + routeId: string; + tileSize: number; + cacheKey?: string; + }): string; /** - * カメラの可動範囲(パン範囲・ズーム上下限)を制限する。null または空の - * `CameraRestriction` で制限解除。 + * The bytes of one tile, or null when there are none. * - * ネイティブに範囲制限 API を持つプロバイダは直接適用し、持たないプロバイダは - * `BaseMapViewController.cameraRestrictionCorrection()` によるクランプで実現する。 - * ズームは統一ズーム(Google 準拠)で指定し、各プロバイダが自身の体系へ変換する。 + * Null is two different things — nothing to draw, and a render that + * failed — and callers that can tell a map library "ask again" should use + * {@link handleFetchOutcome} or {@link fetchTileOrThrow} instead. + */ + handleFetch(routeId: string, request: TileRequest): Promise; + /** + * One tile, said precisely: drawn, empty, failed, or no such route. * - * `applyUISettings` と同じく optional: 未対応のプロバイダはマップをそのままにする。 + * A provider says "empty" by returning null and "failed" by throwing. The + * two must not be answered the same way: a transparent tile is cacheable + * and correct for an empty spot, and a lie for a failure — it paints a + * hole the library keeps, with the neighbouring tiles' overhang cut off at + * its edge. */ - setCameraRestriction?(restriction: CameraRestriction | null): void; + handleFetchOutcome(routeId: string, request: TileRequest): Promise; /** - * オーバーレイコントローラを登録する。登録先にはカメラ変更が伝播し、`destroy()` で - * 一括破棄される。`BaseMapViewController` が実装を提供する。 + * Tile bytes for a map library that has no way to express "empty": an + * empty spot comes back as a transparent tile, and a failure throws, which + * every library reads as a load it may retry. */ - registerOverlayController?(controller: OverlayControllerLike): void; - /** 登録を解除する。`BaseMapViewController` が実装を提供する。 */ - unregisterOverlayController?(controller: OverlayControllerLike): void; + fetchTileOrThrow(routeId: string, request: TileRequest): Promise; + handleFetchDataUrl(routeId: string, request: TileRequest): string | null; + private dispatchToWorker; /** - * 範囲が収まるようにカメラを合わせる。 - * 引数は android-sdk の `fitBounds(bounds, padding: Int)` に揃えてある。 + * Register a Service Worker that intercepts `/__tiles/` requests and routes them + * to `handleFetch()` on the main thread. + * + * Call once at app startup, before the map is rendered. The page URL template + * returned by `urlTemplate()` will then be resolvable by the browser. + * + * @param swPath Path to the tile service worker script (default: `/tile-sw.js`) */ - fitBounds(bounds: GeoRectBounds, padding: number): Promise; - destroy(): void; + startServiceWorker(swPath?: string): void; + hasProvider(routeId: string): boolean; + /** + * Returns true when Service Workers are available in the current context. + * SW requires a secure origin (HTTPS or localhost). On plain HTTP non-localhost + * origins (e.g. a local IP like 192.168.x.x), SW is unavailable and tile + * rendering must use a provider-specific fallback to avoid unresolvable tile URLs. + */ + static isServiceWorkerSupported(): boolean; + /** + * Resolves when the Service Worker is controlling the current page. + * Returns immediately if the SW already controls the page. + * Use this to avoid requesting tiles before the SW intercept is active. + */ + waitForController(): Promise; } /** @@ -864,972 +980,1436 @@ declare class MutableMapServiceRegistry implements MapServiceRegistry { } declare const EmptyMapServiceRegistry: MapServiceRegistry; -interface MapViewStateInterface> { - readonly id: string; - /** - * 現在のカメラ。**カメラを読む正規の経路はここ**で、表示範囲は - * `cameraPosition.visibleRegion.bounds` から取る。 - * - * プロバイダが地図 SDK のカメライベントごとに `updateCameraPosition()` で - * push する(初期化直後にも 1 回 push される)。変化を追いたい場合は - * `onCameraMove` / `onCameraMoveEnd`、拡張モジュールは登録した - * オーバーレイコントローラの `onCameraChanged` を使う。 - * - * コントローラ側に `getCameraPosition()` / `getBounds()` を足さないこと。 - * 理由は `MapViewControllerInterface` のコメントと /docs/reading-camera を参照。 - */ - readonly cameraPosition: MapCameraPosition; - mapDesignType: ActualMapDesignType; - /** - * このマップにスコープされたサービス(プラグイン)のレジストリ。 - * - * プロバイダが capability を登録し、拡張モジュール(marker-clustering など)が解決する。 - * これによりプロバイダはプラグインのインタフェースを実装せずに済み、プラグインは - * どのプロバイダ上で動いているかを知らずに済む。 - * ios-sdk の `MapViewState.serviceRegistry` と同じ位置づけで、React では - * `MapServiceRegistryProvider` が `useMapServiceRegistry()` へ供給する - * (android-sdk の `LocalMapServiceRegistry` CompositionLocal に相当)。 - */ - readonly serviceRegistry: MutableMapServiceRegistry; - /** Which map gestures the user may perform. See {@link MapUISettings}. */ - uiSettings: MapUISettings; - moveCameraTo(cameraPosition: MapCameraPosition, durationMillis?: number): void; - moveCameraTo(position: GeoPoint, durationMillis?: number): void; - fitBounds(bounds: GeoRectBounds, padding?: number): void; - getMapViewHolder(): MapViewHolder | null; -} /** - * プロバイダのビューと `js-sdk-react` だけが使う内部配線。**公開 API ではない。** + * How a map looks, as a property of the map rather than a layer inside it. * - * ここにある 4 つはアプリから呼ぶものではない(呼ぶと押し込んだ値と地図の実態がずれる)。 - * 公開インタフェース {@link MapViewStateInterface} には出さず、SDK 内部からは - * {@link mapViewStateInternal} を通して取り出す。 + * A vector style *is* the basemap, so passing one to `` + * says what it is rather than mounting something over it. What happens + * underneath depends on the backend and the app does not have to know: + * MapLibre, Mapbox and MapTiler are told the style and draw it themselves; + * Google Maps, Leaflet, Cesium, OpenLayers and the rest are handed raster + * tiles rendered from the same style in a worker. Either way the same + * adjustments produce the same map. * - * android-sdk / ios-sdk では同じものが具象クラスの internal / 非 public として隠れている - * (ビューが具象クラスを受け取るため)。React はビューがインタフェース越しに state を - * 受け取るので、こうして型を分けないと公開面に出てしまう。 + * An interface rather than a class, for two reasons. Core must not depend on + * the style compiler or the rasteriser — both are optional, and one of them + * is a wasm binary — so what core holds is the seam and the modules bring + * the implementation. And a provider's own styling, which is nothing like a + * MapLibre style, can be added later as another implementation without this + * changing. + * + * The concrete implementations live in the vector style module; an app does + * not implement this. + * + * android-sdk-core and ios-sdk-core carry the same seam. */ -interface MapViewStateInternal { +interface MapViewStyle { /** - * プロバイダのビューが、コントローラの準備完了時(アンマウント時は null)に呼ぶ。 - * MapConductor の React コンポーネント(LeafletMapView, GoogleMapView, ...)が - * 内部で使っている共通の接続点。 + * What identifies this style's content. + * + * The map re-installs a style whose key changed and leaves alone one + * whose key did not. Two objects describing the same thing must have the + * same key: a style built inline in JSX is a new object on every + * render — which is exactly what a colour picker produces — and keying + * off identity would reinstall the whole style on every unrelated state + * change. */ - setController(controller: MapViewControllerInterface | null): void; - /** プロバイダのビューが、地図 SDK のカメラ移動イベントごとに現在値を押し込む。 */ - updateCameraPosition(camera: MapCameraPosition): void; - /** カメラ変化で再レンダリングするためにプロバイダのビューが購読する。 */ - setCameraPositionChangeListener(listener: ((camera: MapCameraPosition) => void) | null): void; + readonly key: string; /** - * `uiSettings` への代入をエンジンへ伝えるために `useMapUISettings` が購読する。 + * Puts the style on the map. Called once the map has a style loaded, and + * again if it loads another one. * - * state を持たないコンポーネントはフィールド代入では再レンダリングできないため、 - * ポーリングではなく push する(Android の `mutableStateOf` / iOS の `@Published` が - * 暗黙にやっていることを手で書いている)。 + * @returns the ticket that takes it off again. Disposing must leave the + * map as it was found: a style that cannot be fully undone has no + * business being installed, because the app will switch away from it. */ - setUISettingsChangeListener(listener: ((settings: MapUISettings) => void) | null): void; + install(host: MapStyleHost): MapStyleInstallation; } /** - * 公開 state から内部配線({@link MapViewStateInternal})を取り出す。 + * A ticket to undo, and a chance to take a change without tearing down. * - * **プロバイダのビューと `js-sdk-react` の専用**。アプリから呼ばないこと。 - * 実装は必ず {@link MapViewState} を継承しているので、実行時は常に成立する。 + * Returned by anything that sets something up on a map. */ -declare function mapViewStateInternal(state: MapViewStateInterface>): MapViewStateInternal; -/** {@link MapViewState} のコンストラクタ引数。 */ -interface MapViewStateOptions { - /** state の識別子。省略するとランダム。 */ - id?: string; - /** - * コントローラが繋がるまでの間、保持しておくカメラ。 - * {@link MapViewState.attachController} の時点でこの位置へ移動する。 - */ - cameraPosition?: MapCameraPosition; - /** 初期のジェスチャ設定。 */ - uiSettings?: Partial; +interface MapStyleInstallation { + dispose(): void; /** - * {@link MapViewState.moveCameraTo} で、コントローラへ委譲したあとに要求された - * カメラを {@link MapViewState.cameraPosition} へ反映するか。 + * Takes `next` in place, when it is near enough to what is installed, + * and answers whether it did. * - * **web は既定が `true`。** android / iOS は `false`(地図が返してきた実際の値だけを - * 入れる)だが、web のエンジンはカメラ移動イベントが非同期で、しかも React には - * Compose の再コンポーズや SwiftUI の `@Published` に当たる push が無い。 - * 要求直後に `cameraPosition` を読むと古い値が返るため、要求値をそのまま保持する。 - * 全 web プロバイダが移行前からこの形だった。 + * Without this the whole point is lost. A style whose key changed is + * installed again, and installing a style means handing the map a + * document — which is the reload this design exists to avoid. Moving a + * colour slider changes the adjustments and nothing else, so the + * installed style answers true, applies the difference, and the map + * never reloads anything. + * + * Left out, or false, and the caller disposes and installs `next` from + * scratch. False is always correct, only slower. */ - optimisticCameraUpdate?: boolean; + update?(next: MapViewStyle): boolean; } /** - * 全プロバイダ共通の state 実装。 + * What a map offers to the style installed on it. * - * カメラの保持と、コントローラへの委譲({@link moveCameraTo} / {@link fitBounds} / - * {@link getMapViewHolder})はどのプロバイダでも同じなのでここに置く。 - * プロバイダ固有なのは `mapDesignType` の型と、`getMapViewHolder()` の戻り型を - * 絞るオーバーライドだけ。android-sdk / ios-sdk の `MapViewState` と同じ形。 + * The provider implements this; a {@link MapViewStyle} uses it and never + * touches a provider type. That is what keeps one style implementation + * working on every backend. */ -declare abstract class MapViewState> implements MapViewStateInterface, MapViewStateInternal { - protected readonly tag: string; - readonly id: string; - abstract mapDesignType: ActualMapDesignType; - /** @see MapViewStateInterface.serviceRegistry */ - readonly serviceRegistry: MutableMapServiceRegistry; - private readonly optimisticCameraUpdate; - private _cameraPosition; - private _cameraPositionChangeListener; +interface MapStyleHost { /** - * 接続済みのコントローラ。まだ地図が生成されていなければ null。 + * Where to look for what this map can do: `VectorStyleSupportKey` to + * hand over a style document, `VectorStyleMutationSupportKey` to change + * a loaded one in place, `RasterTilePreferenceKey` for the tile size it + * would rather have. + */ + readonly serviceRegistry: MapServiceRegistry; + /** For serving a style document, or tiles rendered from one, to the map. */ + readonly tileServer: LocalTileServer; + /** + * The vector style the map is drawing now, when it is drawing one. * - * 名前が `controller` でないのは、プロバイダが自分のコントローラ型で - * `controller` フィールドを持てるようにするため。 + * Undefined on every backend that has no such thing, which is most of + * them. It is how a rule set that adjusts "whatever is showing" finds + * something to adjust: the style has to be read before a rule can be + * matched against its layers, and only the map knows where it came from. */ - protected attachedMapController: MapViewControllerInterface | null; - private _uiSettings; - private _uiSettingsChangeListener; - constructor(options?: MapViewStateOptions); - get cameraPosition(): MapCameraPosition; - get uiSettings(): MapUISettings; - set uiSettings(value: MapUISettings); - setUISettingsChangeListener(listener: ((settings: MapUISettings) => void) | null): void; - moveCameraTo(cameraPosition: MapCameraPosition, durationMillis?: number): void; - moveCameraTo(position: GeoPoint, durationMillis?: number): void; - fitBounds(bounds: GeoRectBounds, padding?: number): void; + readonly vectorStyleUrl?: string; /** - * 各プロバイダは戻り型を自分のホルダー型へ絞るオーバーライドを 1 つだけ置くこと。 - * アプリが `state.getMapViewHolder()?.map` でネイティブの地図を取れる形を保つため。 + * Runs `block` whenever the map finishes loading a style, including the + * one it has now if it already has one. + * + * Everything a style installs on a live renderer is lost when that + * renderer loads a document again, for any reason. This is how it gets + * put back. */ - getMapViewHolder(): MapViewHolder | null; + onStyleLoaded(block: () => void): MapStyleInstallation; /** - * コントローラを接続する。プロバイダのビューが `setController` から呼ぶ。 + * Adds or updates a raster layer the app did not declare. * - * @param moveToInitialCamera 接続時に、保持していたカメラ位置へ移動するか。 - * 既定は `true`。地図の生成直後にカメラを動かすと初期位置が上書きされてしまう - * プロバイダは `false` を渡す。 + * The raster path needs somewhere to put its tiles, and that cannot be + * the children of the view: the style is a prop, so it is installed from + * outside the tree. Marker tiling already mounts its layer the same way. */ - protected attachController(controller: MapViewControllerInterface | null, moveToInitialCamera?: boolean): void; + upsertRaster(state: RasterLayerState): void; + removeRaster(id: string): void; /** - * 地図から通知された現在のカメラを保持する(地図を動かさない)。 + * Says what the app should know: a rule that matched nothing, an + * adjustment this backend cannot make, a style that could not be read. * - * カメラを**動かしたい**ときは {@link moveCameraTo} を使うこと。 + * Routed to whatever the host gave the style, so a module can report + * without knowing how the app logs. */ - protected setCameraPositionInternal(camera: MapCameraPosition): void; - setController(controller: MapViewControllerInterface | null): void; - updateCameraPosition(camera: MapCameraPosition): void; - setCameraPositionChangeListener(listener: ((camera: MapCameraPosition) => void) | null): void; -} - -interface FitBoundsCameraResult { - center: GeoPoint; - /** Unified (Google-style, 256px tile) zoom level. */ - zoom: number; -} -declare function computeFitBoundsCameraPosition({ bounds, viewportWidthPx, viewportHeightPx, padding, bearing, tileSize, }: { - bounds: GeoRectBounds; - viewportWidthPx: number; - viewportHeightPx: number; - /** Inset in pixels applied on every side. */ - padding?: number; - /** Map bearing in degrees; the fit accounts for a rotated viewport. */ - bearing?: number; - tileSize?: number; -}): FitBoundsCameraResult | null; - -interface MapOverlayInterface { - subscribe(fn: (data: ReadonlyMap) => void): () => void; - render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; -} -declare class MapOverlayRegistry { - private readonly overlays; - register(overlay: MapOverlayInterface): void; - getAll(): MapOverlayInterface[]; + report(diagnostics: readonly string[]): void; } - /** - * 同期の座標変換を要求する機能が、それを使えないプロバイダ上で動くときに - * 「黙って無反応」にならないようにするための入口。 - * - * ## なぜ要るか - * - * `MapViewHolder.toScreenOffset` / `fromScreenOffsetSync` が null を返す理由は 2 つある。 - * - * 1. その点が画面外・地表外(正常。毎フレーム起きる) - * 2. **そのプロバイダが同期変換を持たない**(恒久的) - * - * 呼び出し側は null からこの 2 つを区別できない。2 の場合、機能が一切動かないのに - * 何のログも出ないことになる。区別できるのは `screenProjectionSync` の宣言だけなので、 - * ここで見る。 + * ラスターレイヤを children の外から載せられるコントローラ。 * - * ## Unsupported は「機能が動かない」ときだけ宣言すること + * {@link RasterLayerController} が既に両方持っている。これは「どのプロバイダの + * コントローラか」を知らずにそこへ届くための名前。 + */ +interface RasterLayerMounting { + upsert(state: RasterLayerState): Promise; + removeById(id: string): Promise; +} +/** + * A style that can be carried to a native map SDK instead of installed here. * - * ホルダーの API が同期変換を持たないことと、機能が動かないことは**別**。 - * 独自経路でオーバーレイを配置できているなら `degraded` にすること。 - * `unsupported` にするとここが**動いているものを止める**。 + * React Native is the one host where {@link MapViewStyle.install} cannot do + * the work. The compiler is a wasm binary and Hermes has no wasm, so the + * style cannot be compiled in JavaScript on a device — and the map is not a + * JavaScript object anyway: it is the Android or iOS SDK, which already has + * the whole of this working natively. * - * ## Unknown を非対応と断定しない + * So on React Native the style is not installed; it is **described**, sent + * across the bridge as two strings, and rebuilt on the other side by the + * platform's own vector style module. The map a user sees is produced by + * exactly the code an Android or iOS app would run, which is why there is + * nothing here about how it is drawn. * - * 宣言が無い(`unknown`)=「まだ宣言していない」であって「使えない」ではない。 - * 地図の初期化途中もここに入る。**報告するのは `unsupported` と明示されているときだけ**。 + * Core declares the shape; `@mapconductor/react-vectorstyle` fills it in. */ -declare const ScreenProjectionRequirement: { +interface NativeStyleDescriptor { + /** The style document itself, when the app had it. */ + readonly documentJson?: string; + /** Where to fetch the document, when the app named a URL. */ + readonly documentUrl?: string; + /** Headers for that fetch — an API key, usually. */ + readonly documentHeaders?: Readonly>; /** - * 同期投影が使えるか。使えないと**分かっている**ときだけ 1 回報告して `false` を返す。 - * - * @param feature ログに出す機能名("InfoBubble" など)。何が動かないのかを - * 読み手に伝えるため、capability の id ではなくこちらを出す。 - * @returns 使える見込みがあれば `true`。`false` なら呼び出し側は機能を落とす。 + * Neither of the above: adjust whatever the map is already drawing. + * The native side reads the design's own style URL. */ - check(registry: MapServiceRegistry, provider: string, feature: string): boolean; -}; + readonly fromCurrentDesign?: boolean; + /** The rules document, in the form every platform's compiler reads. */ + readonly rulesJson: string; +} +/** + * A {@link MapViewStyle} that knows how to describe itself to a native SDK. + * + * Checked structurally rather than by class, so core stays free of the + * module that implements it. + */ +interface NativeDescribableStyle extends MapViewStyle { + toNativeDescriptor(): NativeStyleDescriptor; +} +/** Whether `style` can be sent to a native SDK rather than installed here. */ +declare function isNativeDescribableStyle(style: MapViewStyle | null | undefined): style is NativeDescribableStyle; /** - * 要求に応えられなかった度合い。{@link MapCapabilityStatus} と対応するが、 - * こちらは「いま起きた 1 回の出来事」を表す。 + * Configuration options for initializing a map */ -type MapDiagnosticLevel = -/** 出せない。 */ -'unsupported' -/** 出るが別物になる。 */ - | 'degraded' -/** 数値が近似になる。 */ - | 'approximated' -/** 要求を捨てた。 */ - | 'ignored'; -/** 報告の出力先。 */ -type MapDiagnosticsSink = (message: string) => void; +interface MapConfig { + /** Container element for the map */ + container: HTMLElement | string; + /** API key or access token for the map provider */ + apiKey?: string; + /** + * Additional provider-specific options. Deliberately typed as `Record`: provider configs extend MapConfig and NARROW this to their own + * option interface (e.g. LeafletConfig -> Leaflet's `MapOptions`), which is + * only assignable when the base is permissive. `Record` would + * break that subtyping (TS2430), so `any` is intentional here. + */ + options?: Record; + initCameraPosition: MapCameraPosition; +} /** - * プロバイダが応えられなかった要求を報告する。 - * - * {@link MapUISettingsDiagnostics.warnIfRequested} を全 capability へ一般化したもの。 - * 元の実装が持っていた 2 つの正しい判断をそのまま引き継いでいる: - * - * 1. **アプリが実際にその機能を要求したときだけ報告する。** 起動時に非対応一覧を - * 吐くとノイズになって読まれない。 - * 2. **provider + capability + level ごとに 1 回だけ。** 毎レンダーで呼ばれても - * コンソールが溢れない。 - * - * 出力先は {@link MapDiagnostics.setSink} で差し替えられる。 + * Abstract base class for map providers */ -declare const MapDiagnostics: { - /** 出力先を差し替える。テストや独自ロガー向け。 */ - setSink(next: MapDiagnosticsSink): void; +declare abstract class MapProvider { + protected controller: MapViewControllerInterface | null; /** - * 要求に応えられなかったことを 1 回だけ報告する。 - * - * @param subject ログに出す名前。既定は capability そのもの。要求と設定名が - * 食い違う場合(ジェスチャ設定など)に上書きする。 - * @returns 実際に報告したら true(同じ内容の 2 回目以降は false)。 + * Initialize the map provider */ - report(capability: MapCapability, level: MapDiagnosticLevel, provider: string, reason: string, subject?: string): boolean; + abstract initialize(config: MapConfig): Promise; /** - * `requested` が true のとき(=アプリがその機能を実際に要求したとき)だけ報告する。 - * - * 要求していない機能について警告しても行動につながらないので黙る。 + * Get the current controller instance */ - reportIfRequested(requested: boolean, capability: MapCapability, level: MapDiagnosticLevel, provider: string, reason: string, subject?: string): boolean; - /** テスト用フック — どの報告を済ませたかを忘れる。 */ - resetWarnings(): void; -}; - -/** Map initialization lifecycle states. Mirrors `InitState` from `MapViewState.kt`. */ -declare enum InitState { - NotStarted = "NotStarted", - Initializing = "Initializing", - SdkInitialized = "SdkInitialized", - MapViewCreated = "MapViewCreated", - MapCreating = "MapCreating", - MapCreated = "MapCreated", - MapLoaded = "MapLoaded", - Failed = "Failed" -} - -/** ビューポートの大きさ(px)。 */ -interface ViewportSize { - readonly width: number; - readonly height: number; + getController(): MapViewControllerInterface | null; + /** + * Check if the provider is initialized + */ + isInitialized(): boolean; + /** + * Clean up resources + */ + abstract destroy(): void; } /** - * ビューポートの 4 隅を逆投影して {@link VisibleRegion} を組み立てる。 - * - * react-for-maplibre / mapbox / maptiler / tomtom / longdo / leaflet / azuremaps / - * cesium が同じ 18 行を各自持っていたものの集約。android-sdk の - * `VisibleRegionBuilder.kt`、ios-sdk の `VisibleRegionBuilder.swift` と - * 同じ形・同じ隅の割り当て。 - * - * ## 使えないプロバイダがある - * - * すべてのプロバイダがこの形ではない。無理に寄せないこと。 - * - **googlemaps**: ネイティブの `projection.getVisibleRegion()` を使う。 - * SDK が返す値の方が正確なので 4 隅の逆投影に置き換えない。 - * - **here**: bounds はネイティブの `getBoundingBox()` から取る。 - * - **arcgis**: カメラから幾何的に算出する。 - * - **mapkit / openlayers**: ネイティブの extent から直接得られる。 - * - * @param holder 投影の注入点。同期の逆投影を持たないホルダーでは null を返す。 - * @param size ビューポートの大きさ。0 なら null。 - * @param inset 端から何 px 内側の点を使うか。0 なら端ちょうど。 - * @param requireAllCorners `true`(既定)なら 4 隅すべてが解けないと null を返す。 - * `false` なら解けた隅だけで bounds を作り、解けなかった隅は null のまま残す。 - * 傾けた地図や球体表示では隅の逆投影が地表に当たらないことがあり、そこで - * `VisibleRegion` ごと落とすと marker-clustering がビューポートを算出できず - * クラスタが一切描画されなくなる。それを避けたいプロバイダが `false` を使う。 - * @returns 隅が 1 つも解けなければ null。 - */ -declare function buildVisibleRegion(holder: Pick, 'fromScreenOffsetSync'>, size: ViewportSize, { inset, requireAllCorners }?: { - inset?: number; - requireAllCorners?: boolean; -}): VisibleRegion | null; -/** - * DOM 要素からビューポートの大きさを解決する。 - * - * GL 系は `map.getCanvas()`、その他は地図のコンテナ要素を渡す。 - * まだレイアウト前(幅か高さが 0)なら null。 + * Map provider types */ -declare function viewportSizeOf(element: HTMLElement | null | undefined): ViewportSize | null; +declare enum MapProviderType { + GOOGLE_MAPS = "google-maps", + MAPLIBRE = "maplibre", + MAPBOX = "mapbox", + HERE = "here", + ARCGIS = "arcgis" +} +type OnMapLoadedHandler = (state: TState) => void; +type OnMapEventHandler = (point: GeoPoint) => void; +type OnCameraMoveHandler = (camera: MapCameraPosition) => void; /** - * Marker animation types + * Common props that every map-view component must accept. + * Extend this interface in SDK-specific packages to add platform-specific props + * (e.g. apiKey for Google Maps, style URL for MapLibre). */ -declare enum MarkerAnimation { - /** Marker drops from the sky */ - Drop = "Drop", - /** Marker bounces */ - Bounce = "Bounce" -} - -interface MarkerEntity { - marker: ActualMarker | null; - state: MarkerState; - fingerPrint: MarkerFingerPrint; - visible: boolean; - isRendered: boolean; +interface MapViewBaseProps>> { + state: TState; + onMapLoaded?: OnMapLoadedHandler; + onMapClick?: OnMapEventHandler; + onMapLongClick?: OnMapEventHandler; + onCameraMoveStart?: OnCameraMoveHandler; + onCameraMove?: OnCameraMoveHandler; + onCameraMoveEnd?: OnCameraMoveHandler; + className?: string; /** - * タイル描画されるマーカーかどうか。 + * カメラの可動範囲(パン範囲・ズーム上下限)を制限する。 * - * android-sdk / ios-sdk の `MarkerEntity.tiling` に対応する。以前は「タイル済み」を - * `marker === null` で表現していたが、それだとプロバイダの `onAdd` が失敗して null を - * 返したエンティティまでタイル扱いになってしまうため、明示的なフラグを持つ。 + * android-sdk の各 *MapView コンポーザブルが受け取る `cameraRestriction` 引数に対応する。 + * 個別の `restrictBounds` / `minZoom` / `maxZoom` prop も引き続き受け付けるが、 + * そちらはマップ生成時にしか反映されない。実行時に変更したい場合はこちらを使う。 */ - tiling: boolean; + cameraRestriction?: CameraRestriction | null; + /** + * 地図の見た目。デザイン名を選ぶ代わりにアプリが直接述べるときに渡す。 + * + * ベクタースタイルは**ベースマップそのもの**なので、重ねるのではなく + * 「これが地図だ」と言う。`@mapconductor/vectorstyle` が作る。下で何が + * 起きるかはバックエンド次第で、アプリは知らなくてよい。 + * + * **android-sdk と ios-sdk ではこの引数名は `style`。** JS だけ違うのは + * `style` が React/React Native では要素自身の見た目を指す名前として + * 予約同然だから。RN の `MapViewBaseProps` は実際に `style` を + * コンテナのスタイルとして使っており、そこを奪うと React を書く人全員が + * 踏む。web と RN で名前を割るほうが悪いので、JS 側は両方 `mapStyle`。 + */ + mapStyle?: MapViewStyle | null; + /** スタイルが言ってきたこと。渡さなければ console に出る。 */ + onStyleDiagnostics?: (diagnostics: readonly string[]) => void; } -declare const createMarkerEntity: (params: { - marker: ActualMarker | null; - state: MarkerState; - visible?: boolean; - isRendered?: boolean; - tiling?: boolean; -}) => MarkerEntity; - /** - * A single marker animation request handed off to a screen-space overlay - * layer instead of being played by mutating the provider's native marker. + * Abstract base for map-view implementations. * - * Mirrors Android's `MarkerAnimationOverlayEntry` (compose/marker): - * the overlay animates `bitmapIcon` in screen space above the map view and - * below InfoBubbles, re-projecting `state.position` every frame so the - * animation tracks camera movement/tilt/rotation correctly. + * Mirrors Android's MapViewBase and enforces the three-step initialization + * contract that every new map SDK module must provide: + * 1. sdkInitialize — load the SDK (e.g. inject a script tag) + * 2. createHolder — wrap the native map in a MapViewHolder + * 3. createController — wrap the holder in a map view controller + * + * Android equivalent: MapViewBase.kt (sdkInitialize / holderProvider / controllerProvider) + * iOS equivalent: Each view's Coordinator.bind() in MapConductorCore */ -interface MarkerAnimationOverlayEntry { - id: string; - state: MarkerState; - bitmapIcon: BitmapIcon; - animation: MarkerAnimation; - durationMillis: number; - onFinished: () => void; +declare abstract class MapViewBase>, THolder extends MapViewHolder, TController extends MapViewControllerInterface> { + /** Load the underlying map SDK. Return false to abort initialization. */ + protected abstract sdkInitialize(): Promise; + /** Build the SDK-specific config from the view state (camera position, design type, etc.). */ + protected abstract buildConfig(state: TState, container: HTMLElement): MapConfig; + /** Create a holder that wraps the native map view instance. */ + protected abstract createHolder(container: HTMLElement): Promise; + /** Create the controller that exposes the MapConductor API. */ + protected abstract createController(holder: THolder): Promise; } + +type OnMapInitializedHandler = () => void; /** - * Implemented by the view layer (e.g. a React component) that owns the - * screen-space canvas/DOM overlay. Renderers call `host(entry)` to start - * an animation instead of interpolating geographic coordinates themselves. - */ -type MarkerAnimationOverlayHost = (entry: MarkerAnimationOverlayEntry) => void; -/** - * Ports Android's `BounceInterpolator` easing curve so overlay-driven and - * legacy geo-interpolated bounce animations look identical. + * Core controller interface that all map SDK modules must implement. + * Mirrors Android MapViewControllerInterface / iOS MapViewControllerProtocol. */ -declare const bounceInterpolation: (time: number) => number; - -type OnMarkerEventHandler = (state: MarkerState) => void; - -interface BitmapIcon { - url: string; - anchor: { - x: number; - y: number; - }; - size: { - width: number; - height: number; - }; -} -interface AddParams { - state: MarkerState; - bitmapIcon: BitmapIcon; -} -interface ChangeParams { - current: MarkerEntity; - bitmapIcon: BitmapIcon; - prev: MarkerEntity; -} -interface MarkerOverlayRenderer { - animateStartListener: OnMarkerEventHandler | null; - animateEndListener: OnMarkerEventHandler | null; +interface MapViewControllerInterface { + readonly holder: MapViewHolder; + clearOverlays(): Promise; + setCameraMoveStartListener(listener: OnCameraMoveHandler | null): void; + setCameraMoveListener(listener: OnCameraMoveHandler | null): void; + setCameraMoveEndListener(listener: OnCameraMoveHandler | null): void; + setMapClickListener(listener: OnMapEventHandler | null): void; + setMapLongClickListener(listener: OnMapEventHandler | null): void; + setMapInitializedListener(listener: OnMapInitializedHandler | null): void; + moveCamera(position: MapCameraPosition): Promise; /** - * View holder used for screen-space hit testing (see - * `StrategyMarkerController.find`). Mirrors the native - * `MarkerOverlayRendererInterface.holder`; optional because the interface - * predates it — every `AbstractMarkerOverlayRenderer` subclass provides it. + * カメラをアニメーションで移動する。 + * + * 引数は android-sdk の `animateCamera(position, duration: Long)` に揃えてある。 + * 以前は `CameraOptions` を受けていたが、実際に読まれていたのは `duration` だけで、 + * `easing` はどのプロバイダも見ておらず、`paddings` は `padding` の別名として + * フォールバックされるだけだった。 + * + * 戻り値が `Promise` なのは Kotlin の `Unit` / Swift の `Void` に対する TS 側の + * 言い方の違いで、`clearOverlays()` が `suspend fun` に対して `Promise` なのと同じ。 */ - readonly holder?: MapViewHolder; + animateCamera(position: MapCameraPosition, durationMillis: number): Promise; /** - * Set by the view layer to hand animation playback off to a screen-space - * overlay instead of interpolating geographic coordinates. `null` (the - * default) falls back to the legacy per-provider geo-interpolation. + * Applies the gesture flags to the underlying map engine. + * + * Optional the same way Android's `applyUISettings` has an empty default body: + * a provider that has not been wired up yet simply leaves the map as it is. */ - animationOverlayHost: MarkerAnimationOverlayHost | null; - onAdd(data: AddParams[]): Promise<(ActualMarker | null)[]>; - onChange(data: ChangeParams[]): Promise<(ActualMarker | null)[]>; - onRemove(data: MarkerEntity[]): Promise; - onAnimate(entity: MarkerEntity): Promise; - onPostProcess(): Promise; - /** Toggle native marker visibility while a screen-space overlay animation is playing. */ - setMarkerVisible(entity: MarkerEntity, visible: boolean): void; + applyUISettings?(settings: MapUISettings): void; + /** + * カメラの可動範囲(パン範囲・ズーム上下限)を制限する。null または空の + * `CameraRestriction` で制限解除。 + * + * ネイティブに範囲制限 API を持つプロバイダは直接適用し、持たないプロバイダは + * `BaseMapViewController.cameraRestrictionCorrection()` によるクランプで実現する。 + * ズームは統一ズーム(Google 準拠)で指定し、各プロバイダが自身の体系へ変換する。 + * + * `applyUISettings` と同じく optional: 未対応のプロバイダはマップをそのままにする。 + */ + setCameraRestriction?(restriction: CameraRestriction | null): void; + /** + * オーバーレイコントローラを登録する。登録先にはカメラ変更が伝播し、`destroy()` で + * 一括破棄される。`BaseMapViewController` が実装を提供する。 + */ + registerOverlayController?(controller: OverlayControllerLike): void; + /** 登録を解除する。`BaseMapViewController` が実装を提供する。 */ + unregisterOverlayController?(controller: OverlayControllerLike): void; + /** + * 範囲が収まるようにカメラを合わせる。 + * 引数は android-sdk の `fitBounds(bounds, padding: Int)` に揃えてある。 + */ + fitBounds(bounds: GeoRectBounds, padding: number): Promise; + destroy(): void; } -interface MarkerIcon { - scale: number; - anchor: Offset; - iconSize: number; - infoAnchor: Offset; - debug: boolean; - toBitmapIcon(): BitmapIcon; +interface MapViewStateInterface> { + readonly id: string; + /** + * 現在のカメラ。**カメラを読む正規の経路はここ**で、表示範囲は + * `cameraPosition.visibleRegion.bounds` から取る。 + * + * プロバイダが地図 SDK のカメライベントごとに `updateCameraPosition()` で + * push する(初期化直後にも 1 回 push される)。変化を追いたい場合は + * `onCameraMove` / `onCameraMoveEnd`、拡張モジュールは登録した + * オーバーレイコントローラの `onCameraChanged` を使う。 + * + * コントローラ側に `getCameraPosition()` / `getBounds()` を足さないこと。 + * 理由は `MapViewControllerInterface` のコメントと /docs/reading-camera を参照。 + */ + readonly cameraPosition: MapCameraPosition; + mapDesignType: ActualMapDesignType; + /** + * このマップにスコープされたサービス(プラグイン)のレジストリ。 + * + * プロバイダが capability を登録し、拡張モジュール(marker-clustering など)が解決する。 + * これによりプロバイダはプラグインのインタフェースを実装せずに済み、プラグインは + * どのプロバイダ上で動いているかを知らずに済む。 + * ios-sdk の `MapViewState.serviceRegistry` と同じ位置づけで、React では + * `MapServiceRegistryProvider` が `useMapServiceRegistry()` へ供給する + * (android-sdk の `LocalMapServiceRegistry` CompositionLocal に相当)。 + */ + readonly serviceRegistry: MutableMapServiceRegistry; + /** Which map gestures the user may perform. See {@link MapUISettings}. */ + uiSettings: MapUISettings; + moveCameraTo(cameraPosition: MapCameraPosition, durationMillis?: number): void; + moveCameraTo(position: GeoPoint, durationMillis?: number): void; + fitBounds(bounds: GeoRectBounds, padding?: number): void; + getMapViewHolder(): MapViewHolder | null; } -declare abstract class AbstractMarkerIcon implements MarkerIcon { - abstract scale: number; - abstract anchor: Offset; - abstract iconSize: number; - abstract infoAnchor: Offset; - abstract debug: boolean; - abstract toBitmapIcon(): BitmapIcon; +/** + * プロバイダのビューと `js-sdk-react` だけが使う内部配線。**公開 API ではない。** + * + * ここにある 4 つはアプリから呼ぶものではない(呼ぶと押し込んだ値と地図の実態がずれる)。 + * 公開インタフェース {@link MapViewStateInterface} には出さず、SDK 内部からは + * {@link mapViewStateInternal} を通して取り出す。 + * + * android-sdk / ios-sdk では同じものが具象クラスの internal / 非 public として隠れている + * (ビューが具象クラスを受け取るため)。React はビューがインタフェース越しに state を + * 受け取るので、こうして型を分けないと公開面に出てしまう。 + */ +interface MapViewStateInternal { /** - * デバッグ用の枠描画 + * プロバイダのビューが、コントローラの準備完了時(アンマウント時は null)に呼ぶ。 + * MapConductor の React コンポーネント(LeafletMapView, GoogleMapView, ...)が + * 内部で使っている共通の接続点。 */ - protected drawDebugFrame(canvas: HTMLCanvasElement | CanvasRenderingContext2D): void; + setController(controller: MapViewControllerInterface | null): void; + /** プロバイダのビューが、地図 SDK のカメラ移動イベントごとに現在値を押し込む。 */ + updateCameraPosition(camera: MapCameraPosition): void; + /** カメラ変化で再レンダリングするためにプロバイダのビューが購読する。 */ + setCameraPositionChangeListener(listener: ((camera: MapCameraPosition) => void) | null): void; + /** + * `uiSettings` への代入をエンジンへ伝えるために `useMapUISettings` が購読する。 + * + * state を持たないコンポーネントはフィールド代入では再レンダリングできないため、 + * ポーリングではなく push する(Android の `mutableStateOf` / iOS の `@Published` が + * 暗黙にやっていることを手で書いている)。 + */ + setUISettingsChangeListener(listener: ((settings: MapUISettings) => void) | null): void; } - -interface MarkerFingerPrint { - id: number; - icon: number; - clickable: number; - draggable: number; - latitude: number; - longitude: number; - animation: number; - zIndex: number; +/** + * 公開 state から内部配線({@link MapViewStateInternal})を取り出す。 + * + * **プロバイダのビューと `js-sdk-react` の専用**。アプリから呼ばないこと。 + * 実装は必ず {@link MapViewState} を継承しているので、実行時は常に成立する。 + */ +declare function mapViewStateInternal(state: MapViewStateInterface>): MapViewStateInternal; +/** {@link MapViewState} のコンストラクタ引数。 */ +interface MapViewStateOptions { + /** state の識別子。省略するとランダム。 */ + id?: string; + /** + * コントローラが繋がるまでの間、保持しておくカメラ。 + * {@link MapViewState.attachController} の時点でこの位置へ移動する。 + */ + cameraPosition?: MapCameraPosition; + /** 初期のジェスチャ設定。 */ + uiSettings?: Partial; + /** + * {@link MapViewState.moveCameraTo} で、コントローラへ委譲したあとに要求された + * カメラを {@link MapViewState.cameraPosition} へ反映するか。 + * + * **web は既定が `true`。** android / iOS は `false`(地図が返してきた実際の値だけを + * 入れる)だが、web のエンジンはカメラ移動イベントが非同期で、しかも React には + * Compose の再コンポーズや SwiftUI の `@Published` に当たる push が無い。 + * 要求直後に `cameraPosition` を読むと古い値が返るため、要求値をそのまま保持する。 + * 全 web プロバイダが移行前からこの形だった。 + */ + optimisticCameraUpdate?: boolean; } -type MarkerFingerPrintState = { - id: string; - icon: MarkerIcon | null; - clickable: boolean; - draggable: boolean; - position: GeoPoint; - /** null は「未指定」。MarkerState.zIndex と同じく android-sdk の `Int?` に対応する。 */ - zIndex: number | null; - getAnimation(): MarkerAnimation | null; -}; -declare function markerIconHashCode(icon: MarkerIcon | null): number; -declare const createFingerPrint: (state: MarkerFingerPrintState) => MarkerFingerPrint; -declare function createMarkerFingerPrint(state: MarkerFingerPrintState): MarkerFingerPrint; -declare function createMarkerFingerPrint(params: { - id: string; - icon: MarkerIcon | null; - clickable: boolean; - draggable: boolean; - position: GeoPoint; - animation: MarkerAnimation | null; - zIndex?: number | null; -}): MarkerFingerPrint; - -type Serializable = string | number | boolean | null | { - [key: string]: unknown; -} | unknown[]; -interface MarkerState { - position: GeoPoint; +/** + * 全プロバイダ共通の state 実装。 + * + * カメラの保持と、コントローラへの委譲({@link moveCameraTo} / {@link fitBounds} / + * {@link getMapViewHolder})はどのプロバイダでも同じなのでここに置く。 + * プロバイダ固有なのは `mapDesignType` の型と、`getMapViewHolder()` の戻り型を + * 絞るオーバーライドだけ。android-sdk / ios-sdk の `MapViewState` と同じ形。 + */ +declare abstract class MapViewState> implements MapViewStateInterface, MapViewStateInternal { + protected readonly tag: string; readonly id: string; - extra: Serializable | null; - icon: MarkerIcon | null; - animation: MarkerAnimation | null; - clickable: boolean; - draggable: boolean; + abstract mapDesignType: ActualMapDesignType; + /** @see MapViewStateInterface.serviceRegistry */ + readonly serviceRegistry: MutableMapServiceRegistry; + private readonly optimisticCameraUpdate; + private _cameraPosition; + private _cameraPositionChangeListener; /** - * 描画順。null は「未指定」で、0 とは意味が違う(android-sdk / ios-sdk は `Int?` 既定 null)。 - * 以前は number 固定で null を 0 に潰していたため、未指定と明示的な 0 を区別できなかった。 + * 接続済みのコントローラ。まだ地図が生成されていなければ null。 + * + * 名前が `controller` でないのは、プロバイダが自分のコントローラ型で + * `controller` フィールドを持てるようにするため。 */ - zIndex: number | null; - onClick: OnMarkerEventHandler | null; - onDragStart: OnMarkerEventHandler | null; - onDrag: OnMarkerEventHandler | null; - onDragEnd: OnMarkerEventHandler | null; - onAnimateStart: OnMarkerEventHandler | null; - onAnimateEnd: OnMarkerEventHandler | null; - animate(animation: MarkerAnimation | null): void; - getAnimation(): MarkerAnimation | null; - setPosition(position: GeoPoint): void; - setIcon(icon: MarkerIcon | null): void; - setClickable(clickable: boolean): void; - setDraggable(draggable: boolean): void; - setZIndex(zIndex: number | null): void; - copy(opts?: MarkerStateCopyParams): MarkerState; - equals(other: unknown): boolean; - hashCode(): number; - fingerPrint(): MarkerFingerPrint; - asObservable(): { - subscribe: (fn: (fingerPrint: MarkerFingerPrint) => void) => () => void; - }; -} -interface MarkerStateCopyParams { - id?: string | null; - position?: GeoPoint; - extra?: Serializable | null; - icon?: MarkerIcon | null; - animation?: MarkerAnimation | null; - zIndex?: number | null; - clickable?: boolean | null; - draggable?: boolean | null; - onClick?: OnMarkerEventHandler | null; - onDragStart?: OnMarkerEventHandler | null; - onDrag?: OnMarkerEventHandler | null; - onDragEnd?: OnMarkerEventHandler | null; - onAnimateStart?: OnMarkerEventHandler | null; - onAnimateEnd?: OnMarkerEventHandler | null; -} -interface CreateMarkerStateParams { - position: GeoPoint; - id?: string | null; - extra?: Serializable | null; - icon?: MarkerIcon | null; - animation?: MarkerAnimation | null; - zIndex?: number | null; - clickable?: boolean; - draggable?: boolean; - onClick?: OnMarkerEventHandler | null; - onDragStart?: OnMarkerEventHandler | null; - onDrag?: OnMarkerEventHandler | null; - onDragEnd?: OnMarkerEventHandler | null; - onAnimateStart?: OnMarkerEventHandler | null; - onAnimateEnd?: OnMarkerEventHandler | null; + protected attachedMapController: MapViewControllerInterface | null; + private _uiSettings; + private _uiSettingsChangeListener; + constructor(options?: MapViewStateOptions); + get cameraPosition(): MapCameraPosition; + get uiSettings(): MapUISettings; + set uiSettings(value: MapUISettings); + setUISettingsChangeListener(listener: ((settings: MapUISettings) => void) | null): void; + moveCameraTo(cameraPosition: MapCameraPosition, durationMillis?: number): void; + moveCameraTo(position: GeoPoint, durationMillis?: number): void; + fitBounds(bounds: GeoRectBounds, padding?: number): void; + /** + * 各プロバイダは戻り型を自分のホルダー型へ絞るオーバーライドを 1 つだけ置くこと。 + * アプリが `state.getMapViewHolder()?.map` でネイティブの地図を取れる形を保つため。 + */ + getMapViewHolder(): MapViewHolder | null; + /** + * コントローラを接続する。プロバイダのビューが `setController` から呼ぶ。 + * + * @param moveToInitialCamera 接続時に、保持していたカメラ位置へ移動するか。 + * 既定は `true`。地図の生成直後にカメラを動かすと初期位置が上書きされてしまう + * プロバイダは `false` を渡す。 + */ + protected attachController(controller: MapViewControllerInterface | null, moveToInitialCamera?: boolean): void; + /** + * 地図から通知された現在のカメラを保持する(地図を動かさない)。 + * + * カメラを**動かしたい**ときは {@link moveCameraTo} を使うこと。 + */ + protected setCameraPositionInternal(camera: MapCameraPosition): void; + setController(controller: MapViewControllerInterface | null): void; + updateCameraPosition(camera: MapCameraPosition): void; + setCameraPositionChangeListener(listener: ((camera: MapCameraPosition) => void) | null): void; } -declare function createMarkerState(params: CreateMarkerStateParams): MarkerState; -declare const fingerPrintEquals: (left: MarkerFingerPrint, right: MarkerFingerPrint) => boolean; -interface CircleFingerPrint { - id: number; - center: number; - radiusMeters: number; - clickable: number; - geodesic: number; - strokeColor: number; - strokeWidth: number; - fillColor: number; - zIndex: number; - extra: number; -} -interface CircleEvent { - state: CircleState; - clicked: GeoPoint; -} -type OnCircleEventHandler = (event: CircleEvent) => void; -interface CircleState { - readonly id: string; - center: GeoPoint; - radiusMeters: number; - geodesic: boolean; - clickable: boolean; - strokeColor: string; - strokeWidth: number; - fillColor: string; - extra: Serializable | null; - zIndex: number | null; - onClick: OnCircleEventHandler | null; - fingerPrint(): CircleFingerPrint; - copy(opts?: CircleStateCopyParams): CircleState; - equals(other: unknown): boolean; - hashCode(): number; - asObservable(): { - subscribe: (fn: (fp: CircleFingerPrint) => void) => () => void; - }; -} -interface CircleStateCopyParams { - center?: GeoPoint; - radiusMeters?: number; - geodesic?: boolean; - clickable?: boolean; - strokeColor?: string; - strokeWidth?: number; - fillColor?: string; - extra?: Serializable | null; - zIndex?: number | null; - onClick?: OnCircleEventHandler | null; - id?: string | null; -} -declare function createCircleState(params: { +interface FitBoundsCameraResult { center: GeoPoint; - radiusMeters: number; - geodesic?: boolean; - clickable?: boolean; - strokeColor?: string; - strokeWidth?: number; - fillColor?: string; - extra?: Serializable | null; - zIndex?: number | null; - onClick?: OnCircleEventHandler | null; - id?: string | null; -}): CircleState; - -interface CircleEntity { - circle: ActualCircle; - readonly state: CircleState; - readonly fingerPrint: CircleFingerPrint; -} -declare const createCircleEntity: (params: { - circle: ActualCircle; - state: CircleState; -}) => CircleEntity; - -interface CircleAddParams { - readonly state: CircleState; -} -interface CircleChangeParams { - readonly current: CircleEntity; - readonly prev: CircleEntity; -} -interface CircleOverlayRenderer { - onAdd(data: CircleAddParams[]): Promise<(ActualCircle | null)[]>; - onChange(data: CircleChangeParams[]): Promise<(ActualCircle | null)[]>; - onRemove(data: CircleEntity[]): Promise; - onPostProcess(): Promise; -} - -declare abstract class AbstractCircleOverlayRenderer, ActualCircle> implements CircleOverlayRenderer { - readonly holder: MapViewHolderType; - constructor(holder: MapViewHolderType); - abstract removeCircle(entity: CircleEntity): Promise; - abstract createCircle(state: CircleState): Promise; - abstract updateCircleProperties(params: { - circle: ActualCircle; - current: CircleEntity; - prev: CircleEntity; - }): Promise; - onAdd(data: CircleAddParams[]): Promise<(ActualCircle | null)[]>; - onChange(data: CircleChangeParams[]): Promise<(ActualCircle | null)[]>; - onRemove(data: CircleEntity[]): Promise; - onPostProcess(): Promise; -} - -interface CircleCapable { - compositionCircles(data: CircleState[]): Promise; - updateCircle(state: CircleState): Promise; - /** @deprecated Use CircleState.onClick instead. */ - setOnCircleClickListener(listener: OnCircleEventHandler | null): void; - hasCircle(state: CircleState): boolean; + /** Unified (Google-style, 256px tile) zoom level. */ + zoom: number; } +declare function computeFitBoundsCameraPosition({ bounds, viewportWidthPx, viewportHeightPx, padding, bearing, tileSize, }: { + bounds: GeoRectBounds; + viewportWidthPx: number; + viewportHeightPx: number; + /** Inset in pixels applied on every side. */ + padding?: number; + /** Map bearing in degrees; the fit accounts for a rotated viewport. */ + bearing?: number; + tileSize?: number; +}): FitBoundsCameraResult | null; -interface CircleManagerInterface { - registerEntity(entity: CircleEntity): void; - removeEntity(id: string): CircleEntity | null; - getEntity(id: string): CircleEntity | null; - hasEntity(id: string): boolean; - allEntities(): CircleEntity[]; - clear(): void; - find(position: GeoPoint): CircleEntity | null; - updateEntity(entity: CircleEntity): void; -} -declare class CircleManager implements CircleManagerInterface { - private readonly entities; - getEntity(id: string): CircleEntity | null; - hasEntity(id: string): boolean; - removeEntity(id: string): CircleEntity | null; - registerEntity(entity: CircleEntity): void; - updateEntity(entity: CircleEntity): void; - allEntities(): CircleEntity[]; - clear(): void; - find(position: GeoPoint): CircleEntity | null; +interface MapOverlayInterface { + subscribe(fn: (data: ReadonlyMap) => void): () => void; + render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; } - -declare abstract class CircleController implements SlottedOverlayController, OverlayController, CircleEvent> { - readonly zIndex: number; - readonly circleManager: CircleManagerInterface; - readonly renderer: CircleOverlayRenderer; - clickListener: OnCircleEventHandler | null; - private semaphore; - constructor({ circleManager, renderer, clickListener, }: { - circleManager: CircleManagerInterface; - renderer: CircleOverlayRenderer; - clickListener?: OnCircleEventHandler | null; - }); - dispatchClick(event: CircleEvent): void; - composition(data: CircleState[]): Promise; - has(state: CircleState): boolean; - setOnClickListener(listener: OnCircleEventHandler | null): void; - add(data: CircleState[]): Promise; - update(state: CircleState): Promise; - clear(): Promise; - find(position: GeoPoint): CircleEntity | null; - onCameraChanged(_mapCameraPosition: MapCameraPosition): Promise; - destroy(): void; - readonly kind: OverlayKind; - hasId(id: string): boolean; - compositionAny(data: unknown[]): Promise; - updateAny(state: unknown): Promise; - setClickListenerAny(listener: unknown): void; - resolveTap(position: GeoPointInterface): OverlayHit | null; +declare class MapOverlayRegistry { + private readonly overlays; + register(overlay: MapOverlayInterface): void; + getAll(): MapOverlayInterface[]; } /** - * Per-map, per-overlay-type source of truth for overlay states. + * 同期の座標変換を要求する機能が、それを使えないプロバイダ上で動くときに + * 「黙って無反応」にならないようにするための入口。 * - * 変更の届け方は 3 プラットフォームで揃えてある。 + * ## なぜ要るか * - * - **membership(add / remove)は debounce**。5ms の無入力窓で、イベントが来るたびに - * 窓を延長し、件数が閾値に達したら待たずに出す。android-sdk の - * `debounceBatch(5ms, 100/300)`、ios-sdk の `scheduleMembership()` と同じ。 - * - **in-place 変更は sample**。1 つの state につき 1 窓 1 回、最新の値だけを配る。 - * android-sdk の `sample(updateDebounce)`、ios-sdk の `scheduleUpdate()` と同じ。 + * `MapViewHolder.toScreenOffset` / `fromScreenOffsetSync` が null を返す理由は 2 つある。 * - * どちらの窓でも、コレクション自体の書き換えは同期のまま(`values()` / `get()` は常に - * 最新)。遅らせるのは購読者・ハンドラへの通知だけ。 + * 1. その点が画面外・地表外(正常。毎フレーム起きる) + * 2. **そのプロバイダが同期変換を持たない**(恒久的) + * + * 呼び出し側は null からこの 2 つを区別できない。2 の場合、機能が一切動かないのに + * 何のログも出ないことになる。区別できるのは `screenProjectionSync` の宣言だけなので、 + * ここで見る。 + * + * ## Unsupported は「機能が動かない」ときだけ宣言すること + * + * ホルダーの API が同期変換を持たないことと、機能が動かないことは**別**。 + * 独自経路でオーバーレイを配置できているなら `degraded` にすること。 + * `unsupported` にするとここが**動いているものを止める**。 + * + * ## Unknown を非対応と断定しない + * + * 宣言が無い(`unknown`)=「まだ宣言していない」であって「使えない」ではない。 + * 地図の初期化途中もここに入る。**報告するのは `unsupported` と明示されているときだけ**。 */ -declare class OverlayCollector { - private readonly map; - private readonly subs; - private updateHandler; - private readonly updateSubs; - private batchDepth; - private batchDirty; - private pendingAdds; - private pendingRemoves; - private flushTimer; - private readonly pendingUpdates; - private updateTimer; +declare const ScreenProjectionRequirement: { /** - * `` を1つマウントするたびに1回ずつ走っていた composition を、 - * android-sdk の OverlayCollector と同じ窓でまとめる。 + * 同期投影が使えるか。使えないと**分かっている**ときだけ 1 回報告して `false` を返す。 * - * コレクション自体の書き換えは同期のまま(`values()` / `get()` は常に最新)で、 - * 遅らせるのは購読者への通知だけ。`applyDiff` / `replaceAll` / `clear` は - * もともと1回しか通知しないバルク操作なので、android-sdk と同じくデバウンス対象外。 + * @param feature ログに出す機能名("InfoBubble" など)。何が動かないのかを + * 読み手に伝えるため、capability の id ではなくこちらを出す。 + * @returns 使える見込みがあれば `true`。`false` なら呼び出し側は機能を落とす。 */ - add(state: S): void; - remove(id: string): void; + check(registry: MapServiceRegistry, provider: string, feature: string): boolean; +}; + +/** + * 要求に応えられなかった度合い。{@link MapCapabilityStatus} と対応するが、 + * こちらは「いま起きた 1 回の出来事」を表す。 + */ +type MapDiagnosticLevel = +/** 出せない。 */ +'unsupported' +/** 出るが別物になる。 */ + | 'degraded' +/** 数値が近似になる。 */ + | 'approximated' +/** 要求を捨てた。 */ + | 'ignored'; +/** 報告の出力先。 */ +type MapDiagnosticsSink = (message: string) => void; +/** + * プロバイダが応えられなかった要求を報告する。 + * + * {@link MapUISettingsDiagnostics.warnIfRequested} を全 capability へ一般化したもの。 + * 元の実装が持っていた 2 つの正しい判断をそのまま引き継いでいる: + * + * 1. **アプリが実際にその機能を要求したときだけ報告する。** 起動時に非対応一覧を + * 吐くとノイズになって読まれない。 + * 2. **provider + capability + level ごとに 1 回だけ。** 毎レンダーで呼ばれても + * コンソールが溢れない。 + * + * 出力先は {@link MapDiagnostics.setSink} で差し替えられる。 + */ +declare const MapDiagnostics: { + /** 出力先を差し替える。テストや独自ロガー向け。 */ + setSink(next: MapDiagnosticsSink): void; /** - * Applies a batch of upserts and removals with a single notification. - * Unlike calling add()/remove() in a loop (which notifies subscribers once - * per call and can trigger an expensive downstream re-render each time), - * subscribers see the final state exactly once. + * 要求に応えられなかったことを 1 回だけ報告する。 + * + * @param subject ログに出す名前。既定は capability そのもの。要求と設定名が + * 食い違う場合(ジェスチャ設定など)に上書きする。 + * @returns 実際に報告したら true(同じ内容の 2 回目以降は false)。 */ - applyDiff(upserts: S[], removeIds: Iterable): void; + report(capability: MapCapability, level: MapDiagnosticLevel, provider: string, reason: string, subject?: string): boolean; /** - * Applies a group of collection and state mutations as one composition. - * - * `` は毎レンダーで「既存 state をその場で書き換える」と - * 「新しい id を追加し、消えた id を削除する」を同時にやる。素で走らせると: - * - * 1. `syncMarkerState` の代入1つごとに state の subject が発火 → `updateHandler` が - * 1件ずつ呼ばれる(1000件なら最大1000回) - * 2. そのあと `applyDiff` の membership 通知が1回 + * `requested` が true のとき(=アプリがその機能を実際に要求したとき)だけ報告する。 * - * つまり **membership より先に in-place 更新が飛ぶ**。まだコレクションに入っていない - * マーカーの更新を受け取ることになり、マーカークラスタリングのように「membership を - * 見てから作り直す」consumer が壊れる。 + * 要求していない機能について警告しても行動につながらないので黙る。 + */ + reportIfRequested(requested: boolean, capability: MapCapability, level: MapDiagnosticLevel, provider: string, reason: string, subject?: string): boolean; + /** テスト用フック — どの報告を済ませたかを忘れる。 */ + resetWarnings(): void; +}; + +/** Map initialization lifecycle states. Mirrors `InitState` from `MapViewState.kt`. */ +declare enum InitState { + NotStarted = "NotStarted", + Initializing = "Initializing", + SdkInitialized = "SdkInitialized", + MapViewCreated = "MapViewCreated", + MapCreating = "MapCreating", + MapCreated = "MapCreated", + MapLoaded = "MapLoaded", + Failed = "Failed" +} + +/** ビューポートの大きさ(px)。 */ +interface ViewportSize { + readonly width: number; + readonly height: number; +} +/** + * ビューポートの 4 隅を逆投影して {@link VisibleRegion} を組み立てる。 + * + * react-for-maplibre / mapbox / maptiler / tomtom / longdo / leaflet / azuremaps / + * cesium が同じ 18 行を各自持っていたものの集約。android-sdk の + * `VisibleRegionBuilder.kt`、ios-sdk の `VisibleRegionBuilder.swift` と + * 同じ形・同じ隅の割り当て。 + * + * ## 使えないプロバイダがある + * + * すべてのプロバイダがこの形ではない。無理に寄せないこと。 + * - **googlemaps**: ネイティブの `projection.getVisibleRegion()` を使う。 + * SDK が返す値の方が正確なので 4 隅の逆投影に置き換えない。 + * - **here**: bounds はネイティブの `getBoundingBox()` から取る。 + * - **arcgis**: カメラから幾何的に算出する。 + * - **mapkit / openlayers**: ネイティブの extent から直接得られる。 + * + * @param holder 投影の注入点。同期の逆投影を持たないホルダーでは null を返す。 + * @param size ビューポートの大きさ。0 なら null。 + * @param inset 端から何 px 内側の点を使うか。0 なら端ちょうど。 + * @param requireAllCorners `true`(既定)なら 4 隅すべてが解けないと null を返す。 + * `false` なら解けた隅だけで bounds を作り、解けなかった隅は null のまま残す。 + * 傾けた地図や球体表示では隅の逆投影が地表に当たらないことがあり、そこで + * `VisibleRegion` ごと落とすと marker-clustering がビューポートを算出できず + * クラスタが一切描画されなくなる。それを避けたいプロバイダが `false` を使う。 + * @returns 隅が 1 つも解けなければ null。 + */ +declare function buildVisibleRegion(holder: Pick, 'fromScreenOffsetSync'>, size: ViewportSize, { inset, requireAllCorners }?: { + inset?: number; + requireAllCorners?: boolean; +}): VisibleRegion | null; +/** + * DOM 要素からビューポートの大きさを解決する。 + * + * GL 系は `map.getCanvas()`、その他は地図のコンテナ要素を渡す。 + * まだレイアウト前(幅か高さが 0)なら null。 + */ +declare function viewportSizeOf(element: HTMLElement | null | undefined): ViewportSize | null; + +/** + * A MapLibre-style map with nothing in it but a background. + * + * What a provider shows for "no basemap" when it is built on MapLibre (or + * takes a MapLibre style): the same colour as the device-side blank styles in + * android-sdk-core / ios-sdk-core. A map that draws its whole viewport itself + * -- vector tiles rendered on the client, say -- fetches and paints the basemap + * under it for nobody; this is the style that fetches nothing. + */ +declare const BLANK_MAP_STYLE: { + version: 8; + name: string; + sources: {}; + layers: { + id: string; + type: "background"; + paint: { + 'background-color': string; + }; + }[]; +}; +/** [BLANK_MAP_STYLE] as a URL, for providers whose design carries a style URL. */ +declare const BLANK_MAP_STYLE_URL: string; + +/** + * A map that can draw a MapLibre style itself. + * + * MapLibre, Mapbox and MapTiler are vector renderers already: handing them a + * style is cheaper than rasterising it in a worker and feeding the result back + * as PNG tiles, which is what a backend that only takes raster (Google Maps, + * Leaflet, ArcGIS, Cesium...) has to be given. A provider that can take the + * style directly registers this under {@link VectorStyleSupportKey}; a layer + * that has a style to show looks the key up and, if it is there, skips the + * rasteriser altogether. + * + * The style *becomes the basemap*: on every provider that can do this, the + * style is the map, so showing one replaces the design the state names and + * clearing it puts that design back. A style drawn this way is not an overlay + * -- it has no opacity, and nothing of the previous basemap shows through it. + * Anything that needs the style on top of another basemap goes through the + * raster path. + * + * android-sdk-core and ios-sdk-core carry the same capability. There the + * style is a URL served by the local tile server; in the browser the map + * takes the parsed object directly, which is what `style` may be. + */ +interface VectorStyleSupport { + /** + * Draws `style` -- a `style.json` URL or the parsed object -- as the map's + * basemap. `attributionRules` are the credits the style's sources ask for, + * carried by the design so the map's attribution overlay shows them for as + * long as the style is up. + */ + showStyle(style: string | object, attributionRules?: readonly AttributionRule[]): void; + /** + * Restores the design that was showing before {@link showStyle}, unless + * the app has since chosen another one, in which case that choice is left + * alone. + */ + clearStyle(): void; + /** + * Where the style the map is drawing came from, when that is knowable. * - * batchChanges の間は: - * - in-place 変更は `updateHandler` を呼ばず `batchDirty` を立てるだけ(サンプリング窓 - * にも積まない) - * - `notify()` も同じく `batchDirty` を立てるだけ - * - 抜けるときに dirty なら `notify()` を **1回だけ** + * This is what lets a rule set adjust *whatever is showing* rather than + * a style the app had to name: the document has to be read before a rule + * can be matched against its layers, and only the provider knows where + * it came from. * - * 結果、N件の in-place 更新 + membership 変化が購読者への通知1回に畳まれる。捨てられた - * in-place 更新は失われない: 購読者は通知を受けて `values()` から最新の state を読み直す。 + * Undefined when it is not a URL anything can fetch — a provider's own + * named design, a style that needs a key this does not have. A caller + * that wanted it says so and leaves the map alone rather than showing + * something unstyled. */ - batchChanges(action: () => void): void; - replaceAll(states: S[]): void; - clear(): void; + readonly currentStyleUrl?: string; +} +/** + * {@link VectorStyleSupport} の登録キー。 + * + * 宣言しないプロバイダは「スタイルは受け取れない」。ベクタータイル層はその場合 + * ラスタータイルに描いて渡す。 + */ +declare const VectorStyleSupportKey: MapServiceKey; +/** + * What identifies a style: its URL, or a digest of its content. + * + * The design a style is shown as is keyed by this. A map only re-reads a style + * whose key changed, so a changed style must change the key -- and a remount + * with the same style must not. + */ +declare function vectorStyleKey(style: string | object): string; +/** + * {@link VectorStyleSupport} for a provider whose design type can carry a style. + * + * The MapLibre-based providers differ only in how a style is wrapped into + * their design type, which is what `designFor` supplies; the remember-and- + * restore dance is the same for all of them and lives here once. The "still + * ours" check on {@link clearStyle} is what keeps this from fighting an app + * that switched designs while the style was up: a layer unmounting must not + * undo a choice the app made after it. + * + * {@link clearStyle} takes effect a moment later rather than at once, and a + * {@link showStyle} of the same style in between cancels it. A view that + * rebuilds on a design change remounts the layer that asked for the style + * *by the change it caused* -- React's strict-mode double effects do the same + * -- and clearing on that unmount would restore the old design, which would + * rebuild again, without end. Waiting a beat lets the remount cancel it. + */ +declare class VectorStyleAsDesign> implements VectorStyleSupport { + private readonly state; + private readonly designFor; /** - * Mirrors Android's OverlayCollector.setUpdateHandler. - * When set, subscribes to each state's asObservable() and calls handler - * only when the fingerprint actually changes (distinctUntilChanged) — never - * for the value the subscription replays on registration. Membership - * changes are delivered to subscribe() listeners, not to this handler. + * Where this provider's design points, when it points at a plain + * style document. Only some providers and only some designs do. */ - setUpdateHandler(handler: ((state: S) => void) | null): void; - values(): S[]; - get(id: string): S | undefined; - subscribe(fn: (map: ReadonlyMap) => void): () => void; - private startUpdateSub; + private readonly urlOf?; + private installed; + private installedKey; + /** Set only when the style was handed over as a URL; a parsed object has none. */ + private installedUrl; + private previous; + private pendingClear; + constructor(state: MapViewStateInterface, designFor: (style: string | object, key: string, attributionRules: readonly AttributionRule[]) => D, /** - * 変更を 5ms 窓にためて、窓の終わりに id ごと最新の1件だけ配る。 - * - * debounce ではなく **sample**(最初の変更で窓を開き、以降は窓を延長しない)なのが - * 重要で、android-sdk が `sample(updateDebounce)` を使うのと同じ理由。ドラッグは - * 同じ state を毎フレーム書き換えるので、debounce だと指が止まるまで窓が延び続けて - * 1回も配信されない。sample なら変更が続いている間も1窓に1回は届く。 + * Where this provider's design points, when it points at a plain + * style document. Only some providers and only some designs do. + */ + urlOf?: ((design: D) => string | undefined) | undefined); + /** + * The URL this put up, or else what the design the app chose points at. * - * 以前はここが素通しで、`position` を1回書き換えるたびにプロバイダの - * `update(state)`(=ネイティブ SDK の再描画)が走っていた。 + * The first case is the one that matters: a style already installed here + * came from somewhere reachable, so a rule set asking for "whatever is + * showing" can fetch it too. */ - private scheduleUpdate; - private cancelPendingUpdates; - private stopUpdateSub; - private notify; - private notifyDebounced; - private cancelFlushTimer; - private notifySubscribers; + get currentStyleUrl(): string | undefined; + showStyle(style: string | object, attributionRules?: readonly AttributionRule[]): void; + clearStyle(): void; } /** - * Bridge between the React-side CircleState collector and a CircleCapable map controller. - * Mirrors `CircleOverlay.kt` in the Android SDK. + * One change to one layer of a vector style the map has already loaded. * - * Register an instance of this class with `MapOverlayRegistry` so that whenever - * the circle collection changes, the controller's `compositionCircles()` is called. + * This is what lets a style be adjusted without being reloaded. Handing a + * map a new style document is the obvious way to change how it looks, and on + * every provider it is the expensive way: the renderer drops its tiles, + * re-reads the document, re-tessellates, and the overlays the app added have + * to be put back. A paint property set in place costs none of that. + * + * Mutations are produced by the vector style compiler from the same + * evaluation that produces the adjusted document, so the two can never + * disagree about what the style should look like. A provider that can take + * them declares {@link VectorStyleMutationSupport}. + * + * ## Why the values are JSON text + * + * A style property's value is anything the style spec allows: a string, a + * number, or an expression tree. `map.setPaintProperty` here wants the + * parsed value, MapLibre on Android wants a Gson tree, MapLibre on iOS an + * `NSExpression` -- no shared typed representation saves anyone a + * conversion. Text is the one form all three start from, and it is what + * crossed the wasm boundary anyway. + * + * `"null"` means the property had no value, which is also how a renderer is + * told to go back to the spec default. + * + * android-sdk-core and ios-sdk-core carry the same type. A discriminated + * union here against a sealed class and an enum there: the same shape in + * each language's own grammar. */ -declare class CircleOverlay implements MapOverlayInterface { - private readonly collector; - constructor(collector: OverlayCollector); - subscribe(fn: (data: ReadonlyMap) => void): () => void; - render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; +type StyleMutation = { + readonly op: 'setPaint'; + readonly layerId: string; + readonly key: string; + readonly value: string; + readonly previous: string; +} | { + readonly op: 'setLayout'; + readonly layerId: string; + readonly key: string; + readonly value: string; + readonly previous: string; +} | { + readonly op: 'setFilter'; + readonly layerId: string; + readonly value: string; + readonly previous: string; +} | { + readonly op: 'setZoomRange'; + readonly layerId: string; + readonly minZoom: number | null; + readonly maxZoom: number | null; + readonly previousMinZoom: number | null; + readonly previousMaxZoom: number | null; } - -declare abstract class AbstractMarkerOverlayRenderer, ActualMarker> implements MarkerOverlayRenderer { - abstract onAdd(data: AddParams[]): Promise<(ActualMarker | null)[]>; - abstract onChange(data: ChangeParams[]): Promise<(ActualMarker | null)[]>; - abstract onRemove(data: MarkerEntity[]): Promise; - abstract onPostProcess(): Promise; - animateStartListener: OnMarkerEventHandler | null; - animateEndListener: OnMarkerEventHandler | null; - animationOverlayHost: MarkerAnimationOverlayHost | null; +/** + * Adds a layer the style did not have. + * + * Used for one thing: a background under a style that has none, so that + * "everything else muted" looks the same whether the style is drawn by + * the map or rasterised into tiles over another basemap. Without it the + * raster path leaves the gaps transparent and the two disagree. + */ + | { + readonly op: 'addLayer'; /** - * Set to `true` by a subclass constructor when the provider can hide its - * native marker (see `setMarkerVisible`) so animation can be delegated to - * a screen-space overlay instead of interpolating geo coordinates. - * Mirrors Android's `AbstractMarkerOverlayRenderer.supportsAnimationOverlay`. + * The new layer's id, carried rather than read back out of + * `layer`: the compiler knew it, every reader needs it, and three + * JSON parses of the same object to recover it are three chances + * to get it wrong. */ - protected supportsAnimationOverlay: boolean; - readonly holder: MapViewHolderType; - readonly dropAnimateDuration: number; - readonly bounceAnimateDuration: number; - constructor({ holder, dropAnimateDuration, bounceAnimateDuration, }: { - holder: MapViewHolderType; - dropAnimateDuration?: number; - bounceAnimateDuration?: number; - }); - abstract setMarkerPosition(markerEntity: MarkerEntity, position: GeoPoint): void; - /** Toggle native marker visibility. Overridden by providers that support the animation overlay. */ - setMarkerVisible(_markerEntity: MarkerEntity, _visible: boolean): void; - onAnimate(entity: MarkerEntity): Promise; + readonly layerId: string; + readonly layer: string; + readonly beforeId: string | null; +} | { + readonly op: 'removeLayer'; + readonly layerId: string; +}; +/** The layer a mutation acts on, or undefined for one that names no layer. */ +declare function styleMutationLayerId(mutation: StyleMutation): string; +/** + * What a mutation occupies, for working out which of two sets of mutations + * replace each other. Two mutations with the same target are the same change + * with different values. + */ +declare function styleMutationTarget(mutation: StyleMutation): string; +/** + * What a mutation changes, named the way the style spec names it. + * + * For telling an app *what* a renderer would not do. The layer is the wrong + * thing to name on its own: when a backend has no property for a spec key, + * the same key fails on every layer that uses it, so a list of layers is + * hundreds of names for one cause while the key is the cause. + */ +declare function styleMutationProperty(mutation: StyleMutation): string; +/** The mutation that puts back what was there, or null when there is nothing to put back. */ +declare function reverseStyleMutation(mutation: StyleMutation): StyleMutation | null; +/** + * Reads the compiler's `mutations` array. + * + * An entry this version does not know is skipped rather than fatal: the + * array is produced by the wasm this package shipped with, so an unknown op + * means the two went out of step, and dropping one change is better than + * losing every change. + */ +declare function parseStyleMutations(json: string): StyleMutation[]; + +/** + * A map that can be told to change a loaded style in place. + * + * Separate from {@link VectorStyleSupport}, which is about making a style + * *be* the basemap. This one is about changing the one that is already up, + * without the reload that would otherwise cost every tile on screen and + * every overlay the app added. A provider declares it under + * {@link VectorStyleMutationSupportKey}. + * + * Not every provider can take every change: MapLibre on iOS exposes its + * paint properties as typed expressions rather than by name, so a raw + * style-spec key it has no mapping for cannot be set. That is why + * {@link apply} answers with what it could not do instead of returning + * nothing -- a caller that silently assumed success would show a map that is + * half adjusted and say nothing about it. + * + * android-sdk-core and ios-sdk-core carry the same capability. + */ +interface VectorStyleMutationSupport { /** - * Instead of interpolating geographic coordinates (which produces wrong - * directions when the map is tilted, rotated, or rendered as a globe/3D - * scene), hand the animation to a screen-space overlay: hide the native - * marker, let the host animate a bitmap of its icon in screen space - * above the map (re-projecting every frame), then restore the marker. - * Mirrors Android's `AbstractMarkerOverlayRenderer.animateOnOverlay`. + * Applies `mutations` to the live style, replacing any set applied + * before: anything the previous set changed and this one does not is put + * back the way it was. + * + * @returns the mutations that could not be applied, empty when all were. */ - private animateOnOverlay; - zoomToMetersPerPixel(zoom: number, tileSize: number): number; - animateMarkerDrop(entity: MarkerEntity, duration: number): Promise; - animateMarkerBounce(entity: MarkerEntity, duration: number): Promise; - private animateFromScreenTop; - private fromScreenOffset; -} - -type ImageSource = HTMLImageElement | HTMLCanvasElement | ImageBitmap; -interface ImageIconOptions { - iconSize?: number; - scale?: number; - anchor?: Offset; - infoAnchor?: Offset; - debug?: boolean; + apply(mutations: readonly StyleMutation[]): StyleMutation[]; + /** Puts back everything {@link apply} changed. */ + clear(): void; } /** - * 任意の画像ソース(HTMLImageElement / HTMLCanvasElement / ImageBitmap)を - * マーカーアイコンとして使用するクラス。 + * {@link VectorStyleMutationSupport} の登録キー。 * - * Android SDK の ImageIcon に対応する実装。 + * 宣言しないプロバイダは「生きているスタイルは触れない」。スタイル調整は + * その場合、文書を差し替えるか(地図が読み直す)、ラスター経路で描き直す。 */ -declare class ImageIcon extends AbstractMarkerIcon { - readonly image: ImageSource; - readonly iconSize: number; - readonly scale: number; - readonly anchor: Offset; - readonly infoAnchor: Offset; - readonly debug: boolean; - constructor(image: ImageSource, options?: ImageIconOptions); - private getImageIdentity; - equals(other: unknown): boolean; - hashCode(): number; - toBitmapIcon(): BitmapIcon; +declare const VectorStyleMutationSupportKey: MapServiceKey; +/** + * The bookkeeping every provider's {@link VectorStyleMutationSupport} needs, + * so no provider has to get it right twice. + * + * Two things are easy to miss and both show up as "the adjustment worked, + * and then stopped": + * + * - **Replacing a set has to undo what it drops.** Moving a slider sends a + * whole new set each time. A property the new set no longer mentions must + * go back to the value the style gave it — and only the mutation knows + * that value, because by now the renderer's own idea of it is the one this + * put there. + * - **A style load wipes everything.** The app switching basemap, the + * provider reloading, a design change: whatever the reason, the renderer + * comes back with the document's own values and nothing this applied. The + * provider calls {@link onStyleLoaded} and the whole set goes on again. + */ +declare class StyleMutationStore implements VectorStyleMutationSupport { + private readonly applyNow; + private current; + /** + * @param applyNow puts mutations on the live style and returns the ones + * it could not. + */ + constructor(applyNow: (mutations: readonly StyleMutation[]) => StyleMutation[]); + apply(mutations: readonly StyleMutation[]): StyleMutation[]; + clear(): void; + /** + * Re-applies everything after the map has loaded a style. + * + * The provider calls this from whatever it has that fires on a style + * being ready (`map.on('styledata')` and friends). Without it an + * adjustment survives only until the next design change, which is the + * kind of bug that is found weeks later by someone switching basemap + * with a slider open. + */ + onStyleLoaded(): void; + /** What is applied right now. For tests and diagnostics. */ + get applied(): readonly StyleMutation[]; } -declare enum Direction6 { - Right = "Right", - RightUp = "RightUp", - LeftUp = "LeftUp", - Left = "Left", - LeftDown = "LeftDown", - RightDown = "RightDown" +/** + * Marker animation types + */ +declare enum MarkerAnimation { + /** Marker drops from the sky */ + Drop = "Drop", + /** Marker bounces */ + Bounce = "Bounce" } -declare const Direction6Delta: Record; - -declare const Earth: Readonly<{ - /** WGS84 semi-major axis (equatorial radius) in meters. */ - RADIUS_METERS: 6378137; - /** Equatorial circumference (2πa) in meters. */ - CIRCUMFERENCE_METERS: number; - /** WGS84 flattening f = 1 / 298.257223563. */ - FLATTENING: number; - /** WGS84 semi-minor axis (polar radius) b = a(1 - f) in meters. */ - SEMI_MINOR_AXIS_METERS: number; - /** WGS84 first eccentricity squared e² = f(2 - f). */ - ECCENTRICITY_SQUARED: number; -}>; -interface Projection { - project(position: GeoPointInterface): Offset; - unproject(point: Offset): GeoPoint; +interface MarkerEntity { + marker: ActualMarker | null; + state: MarkerState; + fingerPrint: MarkerFingerPrint; + visible: boolean; + isRendered: boolean; + /** + * タイル描画されるマーカーかどうか。 + * + * android-sdk / ios-sdk の `MarkerEntity.tiling` に対応する。以前は「タイル済み」を + * `marker === null` で表現していたが、それだとプロバイダの `onAdd` が失敗して null を + * 返したエンティティまでタイル扱いになってしまうため、明示的なフラグを持つ。 + */ + tiling: boolean; } +declare const createMarkerEntity: (params: { + marker: ActualMarker | null; + state: MarkerState; + visible?: boolean; + isRendered?: boolean; + tiling?: boolean; +}) => MarkerEntity; /** - * Maximum extent of the Web Mercator projection: half the equatorial - * circumference (πa ≈ 20037508.34 m). Projected x/y values fall within - * ±this value. + * A single marker animation request handed off to a screen-space overlay + * layer instead of being played by mutating the provider's native marker. + * + * Mirrors Android's `MarkerAnimationOverlayEntry` (compose/marker): + * the overlay animates `bitmapIcon` in screen space above the map view and + * below InfoBubbles, re-projecting `state.position` every frame so the + * animation tracks camera movement/tilt/rotation correctly. */ -declare const WEB_MERCATOR_MAX_EXTENT_METERS: number; -declare class WebMercatorClass implements Projection { - project(position: GeoPointInterface): Offset; - unproject(point: Offset): GeoPoint; +interface MarkerAnimationOverlayEntry { + id: string; + state: MarkerState; + bitmapIcon: BitmapIcon; + animation: MarkerAnimation; + durationMillis: number; + onFinished: () => void; } -declare const WebMercator: WebMercatorClass; +/** + * Implemented by the view layer (e.g. a React component) that owns the + * screen-space canvas/DOM overlay. Renderers call `host(entry)` to start + * an animation instead of interpolating geographic coordinates themselves. + */ +type MarkerAnimationOverlayHost = (entry: MarkerAnimationOverlayEntry) => void; +/** + * Ports Android's `BounceInterpolator` easing curve so overlay-driven and + * legacy geo-interpolated bounce animations look identical. + */ +declare const bounceInterpolation: (time: number) => number; -declare class WGS84class implements Projection { - project(position: GeoPointInterface): Offset; - unproject(point: Offset): GeoPoint; +type OnMarkerEventHandler = (state: MarkerState) => void; + +interface BitmapIcon { + url: string; + anchor: { + x: number; + y: number; + }; + size: { + width: number; + height: number; + }; +} +interface AddParams { + state: MarkerState; + bitmapIcon: BitmapIcon; +} +interface ChangeParams { + current: MarkerEntity; + bitmapIcon: BitmapIcon; + prev: MarkerEntity; +} +interface MarkerOverlayRenderer { + animateStartListener: OnMarkerEventHandler | null; + animateEndListener: OnMarkerEventHandler | null; + /** + * View holder used for screen-space hit testing (see + * `StrategyMarkerController.find`). Mirrors the native + * `MarkerOverlayRendererInterface.holder`; optional because the interface + * predates it — every `AbstractMarkerOverlayRenderer` subclass provides it. + */ + readonly holder?: MapViewHolder; + /** + * Set by the view layer to hand animation playback off to a screen-space + * overlay instead of interpolating geographic coordinates. `null` (the + * default) falls back to the legacy per-provider geo-interpolation. + */ + animationOverlayHost: MarkerAnimationOverlayHost | null; + onAdd(data: AddParams[]): Promise<(ActualMarker | null)[]>; + onChange(data: ChangeParams[]): Promise<(ActualMarker | null)[]>; + onRemove(data: MarkerEntity[]): Promise; + onAnimate(entity: MarkerEntity): Promise; + onPostProcess(): Promise; + /** Toggle native marker visibility while a screen-space overlay animation is playing. */ + setMarkerVisible(entity: MarkerEntity, visible: boolean): void; } -declare const WGS84: WGS84class; -interface IdentifiedHexCell { - id: string; - cell: HexCell; +interface MarkerIcon { + scale: number; + anchor: Offset; + iconSize: number; + infoAnchor: Offset; + debug: boolean; + toBitmapIcon(): BitmapIcon; +} +declare abstract class AbstractMarkerIcon implements MarkerIcon { + abstract scale: number; + abstract anchor: Offset; + abstract iconSize: number; + abstract infoAnchor: Offset; + abstract debug: boolean; + abstract toBitmapIcon(): BitmapIcon; + /** + * デバッグ用の枠描画 + */ + protected drawDebugFrame(canvas: HTMLCanvasElement | CanvasRenderingContext2D): void; } -declare function createIdentifiedHexCell(id: string, cell: HexCell): IdentifiedHexCell; -interface HexGeocell { - projection: Projection; - baseHexSideLength: number; - latLngToHexCoord(params: { +interface MarkerFingerPrint { + id: number; + icon: number; + clickable: number; + draggable: number; + latitude: number; + longitude: number; + animation: number; + zIndex: number; +} +type MarkerFingerPrintState = { + id: string; + icon: MarkerIcon | null; + clickable: boolean; + draggable: boolean; + position: GeoPoint; + /** null は「未指定」。MarkerState.zIndex と同じく android-sdk の `Int?` に対応する。 */ + zIndex: number | null; + getAnimation(): MarkerAnimation | null; +}; +declare function markerIconHashCode(icon: MarkerIcon | null): number; +declare const createFingerPrint: (state: MarkerFingerPrintState) => MarkerFingerPrint; +declare function createMarkerFingerPrint(state: MarkerFingerPrintState): MarkerFingerPrint; +declare function createMarkerFingerPrint(params: { + id: string; + icon: MarkerIcon | null; + clickable: boolean; + draggable: boolean; + position: GeoPoint; + animation: MarkerAnimation | null; + zIndex?: number | null; +}): MarkerFingerPrint; + +type Serializable = string | number | boolean | null | { + [key: string]: unknown; +} | unknown[]; +interface MarkerState { + position: GeoPoint; + readonly id: string; + extra: Serializable | null; + icon: MarkerIcon | null; + animation: MarkerAnimation | null; + clickable: boolean; + draggable: boolean; + /** + * 描画順。null は「未指定」で、0 とは意味が違う(android-sdk / ios-sdk は `Int?` 既定 null)。 + * 以前は number 固定で null を 0 に潰していたため、未指定と明示的な 0 を区別できなかった。 + */ + zIndex: number | null; + onClick: OnMarkerEventHandler | null; + onDragStart: OnMarkerEventHandler | null; + onDrag: OnMarkerEventHandler | null; + onDragEnd: OnMarkerEventHandler | null; + onAnimateStart: OnMarkerEventHandler | null; + onAnimateEnd: OnMarkerEventHandler | null; + animate(animation: MarkerAnimation | null): void; + getAnimation(): MarkerAnimation | null; + setPosition(position: GeoPoint): void; + setIcon(icon: MarkerIcon | null): void; + setClickable(clickable: boolean): void; + setDraggable(draggable: boolean): void; + setZIndex(zIndex: number | null): void; + copy(opts?: MarkerStateCopyParams): MarkerState; + equals(other: unknown): boolean; + hashCode(): number; + fingerPrint(): MarkerFingerPrint; + asObservable(): { + subscribe: (fn: (fingerPrint: MarkerFingerPrint) => void) => () => void; + }; +} +interface MarkerStateCopyParams { + id?: string | null; + position?: GeoPoint; + extra?: Serializable | null; + icon?: MarkerIcon | null; + animation?: MarkerAnimation | null; + zIndex?: number | null; + clickable?: boolean | null; + draggable?: boolean | null; + onClick?: OnMarkerEventHandler | null; + onDragStart?: OnMarkerEventHandler | null; + onDrag?: OnMarkerEventHandler | null; + onDragEnd?: OnMarkerEventHandler | null; + onAnimateStart?: OnMarkerEventHandler | null; + onAnimateEnd?: OnMarkerEventHandler | null; +} +interface CreateMarkerStateParams { + position: GeoPoint; + id?: string | null; + extra?: Serializable | null; + icon?: MarkerIcon | null; + animation?: MarkerAnimation | null; + zIndex?: number | null; + clickable?: boolean; + draggable?: boolean; + onClick?: OnMarkerEventHandler | null; + onDragStart?: OnMarkerEventHandler | null; + onDrag?: OnMarkerEventHandler | null; + onDragEnd?: OnMarkerEventHandler | null; + onAnimateStart?: OnMarkerEventHandler | null; + onAnimateEnd?: OnMarkerEventHandler | null; +} +declare function createMarkerState(params: CreateMarkerStateParams): MarkerState; +declare const fingerPrintEquals: (left: MarkerFingerPrint, right: MarkerFingerPrint) => boolean; + +interface PolylineFingerPrint { + id: number; + strokeColor: number; + strokeWidth: number; + geodesic: number; + zIndex: number; + points: number; + extra: number; +} +interface PolylineEvent { + state: PolylineState; + clicked: GeoPoint; +} +type OnPolylineEventHandler = (event: PolylineEvent) => void; +interface PolylineState { + readonly id: string; + points: GeoPoint[]; + strokeColor: string; + strokeWidth: number; + geodesic: boolean; + zIndex: number; + extra: Serializable | null; + onClick: OnPolylineEventHandler | null; + fingerPrint(): PolylineFingerPrint; + copy(opts?: PolylineStateCopyParams): PolylineState; + equals(other: unknown): boolean; + hashCode(): number; + asObservable(): { + subscribe: (fn: (fp: PolylineFingerPrint) => void) => () => void; + }; +} +interface PolylineStateCopyParams { + points?: GeoPoint[]; + id?: string | null; + strokeColor?: string; + strokeWidth?: number; + geodesic?: boolean; + zIndex?: number; + extra?: Serializable | null; + onClick?: OnPolylineEventHandler | null; +} +declare function createPolylineState(params: { + points: GeoPoint[]; + id?: string | null; + strokeColor?: string; + strokeWidth?: number; + geodesic?: boolean; + zIndex?: number; + extra?: Serializable | null; + onClick?: OnPolylineEventHandler | null; +}): PolylineState; + +declare abstract class AbstractMarkerOverlayRenderer, ActualMarker> implements MarkerOverlayRenderer { + abstract onAdd(data: AddParams[]): Promise<(ActualMarker | null)[]>; + abstract onChange(data: ChangeParams[]): Promise<(ActualMarker | null)[]>; + abstract onRemove(data: MarkerEntity[]): Promise; + abstract onPostProcess(): Promise; + animateStartListener: OnMarkerEventHandler | null; + animateEndListener: OnMarkerEventHandler | null; + animationOverlayHost: MarkerAnimationOverlayHost | null; + /** + * Set to `true` by a subclass constructor when the provider can hide its + * native marker (see `setMarkerVisible`) so animation can be delegated to + * a screen-space overlay instead of interpolating geo coordinates. + * Mirrors Android's `AbstractMarkerOverlayRenderer.supportsAnimationOverlay`. + */ + protected supportsAnimationOverlay: boolean; + readonly holder: MapViewHolderType; + readonly dropAnimateDuration: number; + readonly bounceAnimateDuration: number; + constructor({ holder, dropAnimateDuration, bounceAnimateDuration, }: { + holder: MapViewHolderType; + dropAnimateDuration?: number; + bounceAnimateDuration?: number; + }); + abstract setMarkerPosition(markerEntity: MarkerEntity, position: GeoPoint): void; + /** Toggle native marker visibility. Overridden by providers that support the animation overlay. */ + setMarkerVisible(_markerEntity: MarkerEntity, _visible: boolean): void; + onAnimate(entity: MarkerEntity): Promise; + /** + * Instead of interpolating geographic coordinates (which produces wrong + * directions when the map is tilted, rotated, or rendered as a globe/3D + * scene), hand the animation to a screen-space overlay: hide the native + * marker, let the host animate a bitmap of its icon in screen space + * above the map (re-projecting every frame), then restore the marker. + * Mirrors Android's `AbstractMarkerOverlayRenderer.animateOnOverlay`. + */ + private animateOnOverlay; + zoomToMetersPerPixel(zoom: number, tileSize: number): number; + animateMarkerDrop(entity: MarkerEntity, duration: number): Promise; + animateMarkerBounce(entity: MarkerEntity, duration: number): Promise; + private animateFromScreenTop; + private fromScreenOffset; +} + +type ImageSource = HTMLImageElement | HTMLCanvasElement | ImageBitmap; +interface ImageIconOptions { + iconSize?: number; + scale?: number; + anchor?: Offset; + infoAnchor?: Offset; + debug?: boolean; +} +/** + * 任意の画像ソース(HTMLImageElement / HTMLCanvasElement / ImageBitmap)を + * マーカーアイコンとして使用するクラス。 + * + * Android SDK の ImageIcon に対応する実装。 + */ +declare class ImageIcon extends AbstractMarkerIcon { + readonly image: ImageSource; + readonly iconSize: number; + readonly scale: number; + readonly anchor: Offset; + readonly infoAnchor: Offset; + readonly debug: boolean; + constructor(image: ImageSource, options?: ImageIconOptions); + private getImageIdentity; + equals(other: unknown): boolean; + hashCode(): number; + toBitmapIcon(): BitmapIcon; +} + +declare enum Direction6 { + Right = "Right", + RightUp = "RightUp", + LeftUp = "LeftUp", + Left = "Left", + LeftDown = "LeftDown", + RightDown = "RightDown" +} +declare const Direction6Delta: Record; + +declare const Earth: Readonly<{ + /** WGS84 semi-major axis (equatorial radius) in meters. */ + RADIUS_METERS: 6378137; + /** Equatorial circumference (2πa) in meters. */ + CIRCUMFERENCE_METERS: number; + /** WGS84 flattening f = 1 / 298.257223563. */ + FLATTENING: number; + /** WGS84 semi-minor axis (polar radius) b = a(1 - f) in meters. */ + SEMI_MINOR_AXIS_METERS: number; + /** WGS84 first eccentricity squared e² = f(2 - f). */ + ECCENTRICITY_SQUARED: number; +}>; + +interface Projection { + project(position: GeoPointInterface): Offset; + unproject(point: Offset): GeoPoint; +} + +/** + * Maximum extent of the Web Mercator projection: half the equatorial + * circumference (πa ≈ 20037508.34 m). Projected x/y values fall within + * ±this value. + */ +declare const WEB_MERCATOR_MAX_EXTENT_METERS: number; +/** + * 投影が定義される緯度の端。 + * + * Web Mercator は極で発散するので、正方形のワールドに収まる範囲で切る。 + * 世界中のタイルサーバが使っている値で、ここが `MAX_EXTENT` と一致する緯度。 + */ +declare const WEB_MERCATOR_MAX_LATITUDE = 85.05112878; +declare class WebMercatorClass implements Projection { + /** + * 緯度経度 -> EPSG:3857 メートル。 + * + * 入力は投影前に wrap とクランプを通す。通さないと `latitude = 90` で + * `Math.tan()` が発散し、呼び出し側に無限大が渡る。実測では web / android の + * 旧実装が `+90` で 238107693(有限のゴミ)を、`-90` で `-Infinity` を + * 返していた -- 同じ式が符号で違う壊れ方をしていた。ios-sdk は最初から + * クランプしていたので、そちらに合わせてある。 + */ + project(position: GeoPointInterface): Offset; + /** EPSG:3857 メートル -> 緯度経度。結果は wrap して返す。 */ + unproject(point: Offset): GeoPoint; +} +declare const WebMercator: WebMercatorClass; + +declare class WGS84class implements Projection { + project(position: GeoPointInterface): Offset; + unproject(point: Offset): GeoPoint; +} +declare const WGS84: WGS84class; + +interface IdentifiedHexCell { + id: string; + cell: HexCell; +} +declare function createIdentifiedHexCell(id: string, cell: HexCell): IdentifiedHexCell; + +interface HexGeocell { + projection: Projection; + baseHexSideLength: number; + latLngToHexCoord(params: { position: GeoPoint; zoom: number; }): HexCoord; @@ -1869,934 +2449,1659 @@ interface HexGeocell { radius: number; }): HexCoord[]; } -declare class HexGeocellImpl implements HexGeocell { - readonly projection: Projection; - /** ズーム0における六角形の「辺の長さ」(m)。半径ではありません。 */ - readonly baseHexSideLength: number; - constructor(params: { - projection: Projection; - baseHexSideLength?: number; +declare class HexGeocellImpl implements HexGeocell { + readonly projection: Projection; + /** ズーム0における六角形の「辺の長さ」(m)。半径ではありません。 */ + readonly baseHexSideLength: number; + constructor(params: { + projection: Projection; + baseHexSideLength?: number; + }); + /** 緯度経度 -> 六角グリッド座標 */ + latLngToHexCoord(params: { + position: GeoPoint; + zoom: number; + }): HexCoord; + /** 緯度経度 -> HexCell(ID/中心座標など含む) */ + latLngToHexCell(params: { + position: GeoPoint; + zoom: number; + }): HexCell; + /** 六角グリッド座標 -> 中心の緯度経度 */ + hexToLatLngCenter(params: { + coord: HexCoord; + latitude?: number; + latHint?: number; + zoom: number; + }): GeoPoint; + /** ズームを含むユニークなセルID */ + hexToCellId(params: { + coord: HexCoord; + zoom: number; + }): string; + /** 六角形ポリゴン(6頂点)を緯度経度で返す(フラットトップ) */ + hexToPolygonLatLng(params: { + coord: HexCoord; + latHint: number; + zoom: number; + }): GeoPoint[]; + /** 複数点の重心を囲むセル */ + enclosingCellOf(params: { + points: MarkerState[]; + zoom: number; + }): HexCell; + /** 複数点それぞれに対応する HexCell と元IDのセット */ + hexCellsForPointsWithId(params: { + points: MarkerState[]; + zoom: number; + }): Set; + /** 六角座標距離 */ + hexDistance(params: { + origin: HexCoord; + dst: HexCoord; + }): number; + /** 半径 r の六角範囲(axial) */ + hexRange(params: { + center: HexCoord; + radius: number; + }): HexCoord[]; + /** 緯度・ズームに応じて辺長を補正(ズームは 2^-zoom、緯度スケールは cos(lat) で補正) */ + private adjustedHexSideLength; + /** 六角中心の XY を返す(フラットトップ) */ + private hexCenterXY; + /** XY -> 六角座標(axial) */ + private pixelToHex; + /** 連続値のaxial座標(q,r)を最も近い格子点へ丸める */ + private cubeRound; + /** 地球曲率を考慮した重心(球面近似) */ + private computeGeographicCentroid; + static defaultGeocell(): HexGeocell; +} + +declare class HexCellRegistry { + private kdTree; + private readonly allCells; + private readonly entryIDsByCell; + private readonly allEntries; + private needsRebuild; + private readonly lock; + private readonly geocell; + private readonly zoom; + constructor(params: { + geocell: HexGeocell; + zoom: number; + }); + /** 対象エンティティが属する HexCell を(登録せず)計算して返す */ + getCell(entity: MarkerEntity): HexCell; + /** 登録/更新して属する HexCell を返す */ + setPoint(entity: MarkerEntity): HexCell; + /** HexCell が存在するか */ + contains(hexId: string): boolean; + /** エンティティをレジストリから削除 */ + removePoint(entity: MarkerEntity): boolean; + /** 特定セルからエンティティを外す */ + private removeFromCell; + /** 全消去 */ + clear(): void; + private markDirty; + /** インデックス再構築(必要時のみ) */ + private rebuildIfNeeded; + /** 最も近い HexCell を返す */ + findNearest(point: GeoPoint): HexCell | null; + /** 最も近い HexCell と距離 */ + findNearestWithDistance(point: GeoPoint): HexCellWithDistance | null; + /** k 近傍(距離付き) */ + findNearestKWithDistance(params: { + point: GeoPoint; + k: number; + }): HexCellWithDistance[]; + /** 半径内(メートル等)にある HexCell(距離付き) */ + findWithinRadiusWithDistance(params: { + position: GeoPoint; + radius: number; + }): HexCellWithDistance[]; + /** 全 HexCell */ + all(): HexCell[]; + /** 指定 HexCell に属するエンティティID一覧(コピーを返す) */ + getEntryIDsByHexCell(hexCell: HexCell): Set | null; + /** + * 指定位置/ズームでのピクセル数に相当する距離(m) + * ※投影がメートルを返す前提 + */ + metersPerPixel(params: { + position: GeoPoint; + zoom: number; + pixels: number; + tileSize?: number; + }): number; + /** ピクセル半径内の HexCell(距離付き) */ + findWithinPixelRadius(params: { + position: GeoPoint; + zoom: number; + pixels: number; + tileSize?: number; + }): HexCellWithDistance[]; + /** ID プレフィックス一致で検索(共通プレフィックス最適化想定) */ + findByIdPrefix(prefix: string): HexCell[]; + /** レジストリ状態の統計 */ + getStats(): RegistryStats; +} +interface RegistryStats { + totalCells: number; + totalEntries: number; + kdTreeBuilt: boolean; + needsRebuild: boolean; +} + +interface HexCellWithDistance { + cell: HexCell; + distanceMeters: number; +} +declare function createHexCellWithDistance(params: { + cell: HexCell; + distanceMeters: number; +}): HexCellWithDistance; + +interface KDTreeStats { + nodeCount: number; + maxDepth: number; + isEmpty: boolean; +} +declare class KDTree { + private readonly root; + constructor(points: HexCell[]); + /** 再帰的にK-D木を構築 */ + private build; + /** 最近傍セルを返す(見つからない場合 null) */ + nearest(query: Offset): HexCell | null; + /** 最近傍 + 距離 */ + nearestWithDistance(query: Offset): HexCellWithDistance | null; + /** k 近傍 + 距離(距離昇順) */ + nearestKWithDistance(params: { + query: Offset; + k: number; + }): HexCellWithDistance[]; + /** 半径内(メートルなど任意単位)にあるセルを距離付きで返す(距離昇順) */ + withinRadiusWithDistance(params: { + query: Offset; + radius: number; + }): HexCellWithDistance[]; + /** 構造統計 */ + getStats(): KDTreeStats; + private _nearest; + private _squaredDistance; + private _distance; + private _countNodes; + private _maxDepth; +} + +/** + * Memory usage statistics for MarkerManager optimization + */ +interface MarkerManagerStats { + entityCount: number; + hasSpatialIndex: boolean; + spatialIndexInitialized: boolean; + estimatedMemoryKB: number; +} +declare class MarkerManager { + readonly minMarkerCount: number; + protected readonly geocell: HexGeocell; + private readonly entities; + private cellRegistry; + private isDestroyed; + constructor(geocell: HexGeocell, minMarkerCount?: number); + lock(): void; + unlock(): void; + getEntity(id: string): MarkerEntity | null; + hasEntity(id: string): boolean; + removeEntity(id: string): MarkerEntity | null; + metersPerPixel({ position, zoom, pixels, tileSize, }: { + position: GeoPoint; + zoom: number; + pixels: number; + tileSize?: number; + }): number; + findNearest(position: GeoPoint): MarkerEntity | null; + private bruteForceNearest; + findByIdPrefix(prefix: string): HexCell[]; + registerEntity(entity: MarkerEntity): void; + /** 空間操作が必要になった時だけ遅延初期化 */ + private ensureCellRegistry; + updateEntity(entity: MarkerEntity): void; + allEntities(): Array>; + /** メモリ統計(概算) */ + getMemoryStats(): MarkerManagerStats; + private estimateMemoryUsage; + clear(): void; + findMarkersInBounds(bounds: GeoRectBounds): Array>; + /** マッププロバイダ切替時などに正しく破棄する */ + destroy(): void; + static defaultManager(geocell?: HexGeocell | null, minMarkerCount?: number): MarkerManager; +} + +/** + * Strategy interface for handling marker rendering during camera changes. + * Different map providers may have different optimal strategies for marker management. + */ +interface MarkerRenderingStrategy { + markerManager: MarkerManager; + clear(): void; + onAdd(params: { + data: MarkerState[]; + viewport: GeoRectBounds; + renderer: MarkerOverlayRenderer; + }): Promise; + onUpdate(params: { + state: MarkerState; + viewport: GeoRectBounds; + renderer: MarkerOverlayRenderer; + }): Promise; + /** + * Handle camera position changes and update marker rendering accordingly. + * + * @param cameraPosition The new camera position + * @param renderer The marker overlay renderer + */ + onCameraChanged(cameraPosition: MapCameraPosition, renderer: MarkerOverlayRenderer): Promise; +} + +declare abstract class AbstractMarkerRenderingStrategy implements MarkerRenderingStrategy { + /** + * MarkerManager instance provided by dependency injection. + * Each strategy can provide its own optimized MarkerManager implementation. + */ + readonly markerManager: MarkerManager; + protected readonly defaultMarkerIcon: BitmapIcon; + constructor( + /** + * MarkerManager instance provided by dependency injection. + * Each strategy can provide its own optimized MarkerManager implementation. + */ + markerManager: MarkerManager); + abstract onCameraChanged(cameraPosition: MapCameraPosition, renderer: MarkerOverlayRenderer): Promise; + clear(): void; + onAdd(_params: { + data: MarkerState[]; + viewport: GeoRectBounds; + renderer: MarkerOverlayRenderer; + }): Promise; + onUpdate(_params: { + state: MarkerState; + viewport: GeoRectBounds; + renderer: MarkerOverlayRenderer; + }): Promise; +} + +/** + * Android ColorDefaultIcon / iOS ColorDefaultIcon の名前付きコンストラクタ引数に対応する + * オプション群(fillColor を含む全パラメータを 1 つのオブジェクトで受ける)。 + * `labelTypeFace` は Android の Typeface / iOS の UIFont に対応する Web の font-family 文字列。 + */ +/** + * ラベルをピンに対してどこへ置くか。CSS の `background-position` と同じ書き方で、 + * 横(left / center / right)と縦(top / center / bottom)のキーワードを空白区切りで並べる。 + * 順序は問わず、片方だけなら残りは `center`。 + * + * - `left` / `right` はピンの外側に置き、縦はピンの上端・頭の中心・先端に揃える。 + * - `center` 列の `top` / `bottom` はピンの上・下の外側に置く。 + * - `"center center"`(既定)はピンの頭の中に重ねる(従来どおり)。 + */ +type MarkerLabelHorizontalPosition = "left" | "center" | "right"; +type MarkerLabelVerticalPosition = "top" | "center" | "bottom"; +type MarkerLabelPosition = MarkerLabelHorizontalPosition | MarkerLabelVerticalPosition | `${MarkerLabelHorizontalPosition} ${MarkerLabelVerticalPosition}` | `${MarkerLabelVerticalPosition} ${MarkerLabelHorizontalPosition}`; +/** + * 既定マーカーアイコンの見た目を決めるオプションと、その既定値。 + * + * `BaseIconProperties` は「ピンの形と共通の装飾」を表し、塗り方(単色か画像か)だけが + * サブクラスで変わる。オプションから内部表現へ落とすのが `createBaseProperties`。 + */ +interface DefaultMarkerIconOptions { + fillColor?: string; + strokeColor?: string; + strokeWidth?: number; + scale?: number; + label?: string | null; + labelTextColor?: string | null; + labelTextSize?: number; + labelTypeFace?: string; + labelStrokeColor?: string; + labelPosition?: MarkerLabelPosition; + infoAnchor?: Offset; + iconSize?: number; + debug?: boolean; +} +/** ImageDefaultIcon のコンストラクタ引数。backgroundImage は必須。 */ +interface ImageDefaultIconOptions extends DefaultMarkerIconOptions { + backgroundImage: string | HTMLImageElement; +} +interface BaseIconProperties { + strokeColor: string; + strokeWidth: number; + scale: number; + label: string | null; + labelTextColor: string | null; + labelTextSize: number; + labelTypeFace: string; + labelStrokeColor: string; + labelPosition: MarkerLabelPosition; + infoAnchor: Offset; + iconSize: number; + debug: boolean; +} + +/** + * Android ColorDefaultIcon / iOS ColorDefaultIcon の名前付きコンストラクタ引数に対応する + * オプション群(fillColor を含む全パラメータを 1 つのオブジェクトで受ける)。 + * `labelTypeFace` は Android の Typeface / iOS の UIFont に対応する Web の font-family 文字列。 + */ + +interface IconLayout { + /** マーカー本体の一辺(= iconSize * scale、Android の canvasSize)。 */ + markerSize: number; + /** ビットマップ幅(ラベルが広い場合はマーカーより横に広がる)。 */ + bitmapWidth: number; + /** ビットマップ高さ(= markerSize)。 */ + bitmapHeight: number; + /** ビットマップ内でのマーカー本体の左上。 */ + markerOffsetX: number; + markerOffsetY: number; + /** ラベルのアウトライン幅(Android: max(1*scale, 2))。 */ + outlineStroke: number; + /** ラベル文字列の中心(text-anchor="middle" / dominant-baseline="central" で描く)。 */ + labelCenterX: number; + labelCenterY: number; +} + +/** + * 既定マーカーアイコンの土台。ピン形の輪郭・ラベル・寸法の計算を受け持ち、 + * 中身の塗り方だけをサブクラスに任せる。 + */ +declare abstract class AbstractDefaultIcon extends AbstractMarkerIcon { + protected readonly baseProperties: BaseIconProperties; + private cachedLayout; + protected constructor(baseProperties: BaseIconProperties); + /** + * ピン先端の位置(ビットマップに対する割合)。ラベルをピンの横や上に置くと + * ビットマップが片側へ伸びるので、固定の (0.5, 1) ではなくレイアウトから求める。 + */ + get anchor(): Offset; + get strokeColor(): string; + get strokeWidth(): number; + get scale(): number; + get label(): string | null; + get labelTextColor(): string | null; + get labelTextSize(): number; + get labelTypeFace(): string; + get labelStrokeColor(): string; + get labelPosition(): MarkerLabelPosition; + get iconSize(): number; + /** + * 指定値はピン本体に対する割合として扱い、ビットマップに対する割合へ直して返す。 + * ラベルが中央にある限りは指定値と一致する(従来どおり)。 + */ + get infoAnchor(): Offset; + get debug(): boolean; + toBitmapIcon(): BitmapIcon; + hashCode(): number; + /** + * Android AbstractDefaultIcon.toBitmapIcon() / iOS makeIcon() と同じレイアウト計算。 + * すべてピクセル空間で行い、ラベルが広い場合はビットマップを横に広げてマーカーを + * 中央寄せする(正方形固定にしない)。ラベルの文字サイズはマーカー scale に追従せず + * 絶対値(Android/iOS と同じ)。 + * + * `labelPosition` が中央以外なら、ラベルをピンの輪郭の外側へ置き、ビットマップを + * その側へだけ伸ばす。アイコンは不変なので結果は 1 回だけ計算して使い回す。 + */ + protected computeLayout(): IconLayout; + protected createSvg(layout: IconLayout): string; + /** + * ラベル描画。既定では Android drawLabel() と同じく円形部分の中心(markerSize * 0.35)へ + * 通常ウェイト・絶対サイズで配置し(位置は computeLayout が決める)、アウトライン(stroke)を下に敷いてから塗りを重ねる。 + */ + protected createLabelSvg(layout: IconLayout): string; + protected abstract createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number, markerOffsetY: number): string; + protected abstract getUniqueProperties(): unknown; +} + +/** + * 単色で塗る既定アイコン。`DefaultMarkerIcon` はこれの別名。 + */ +declare class ColorDefaultIcon extends AbstractDefaultIcon { + readonly fillColor: string; + constructor(options?: DefaultMarkerIconOptions); + copy(options?: DefaultMarkerIconOptions): ColorDefaultIcon; + protected createFillSvg(markerPath: string): string; + protected getUniqueProperties(): unknown; +} +/** + * ピンの中に画像を敷く既定アイコン。 + */ +declare class ImageDefaultIcon extends AbstractDefaultIcon { + readonly backgroundImage: string | HTMLImageElement; + constructor(options: ImageDefaultIconOptions); + copy(options?: Partial): ImageDefaultIcon; + /** + * ロード済みの HTMLImageElement が渡された場合は Canvas で描画して PNG data URL を返す。 + * SVG data URL は「SVG as image」サンドボックスにより内部の が + * ブロックされるため、外部 URL を参照する場合は必ず Canvas パスを使用する。 + */ + toBitmapIcon(): BitmapIcon; + private toCanvasBitmapIcon; + protected createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number, markerOffsetY: number): string; + protected getUniqueProperties(): unknown; + private imageSourceUrl; +} +type DefaultMarkerIcon = ColorDefaultIcon; +declare const DefaultMarkerIcon: typeof ColorDefaultIcon; +declare const createDefaultIcon: () => DefaultMarkerIcon; +declare const hashDefaultMarkerIcon: (icon: DefaultMarkerIcon) => number; + +interface MarkerCapable { + compositionMarkers(data: MarkerState[]): Promise; + updateMarker(state: MarkerState): Promise; + setOnMarkerDragStart(listener: OnMarkerEventHandler | null): void; + setOnMarkerDrag(listener: OnMarkerEventHandler | null): void; + setOnMarkerDragEnd(listener: OnMarkerEventHandler | null): void; + setOnMarkerAnimateStart(listener: OnMarkerEventHandler | null): void; + setOnMarkerAnimateEnd(listener: OnMarkerEventHandler | null): void; + setOnMarkerClickListener(listener: OnMarkerEventHandler | null): void; + /** Route marker animations (Drop/Bounce) to a screen-space overlay instead of geo-interpolation. */ + setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost | null): void; + hasMarker(state: MarkerState): boolean; +} + +/** 1回の renderer 呼び出しで扱うマーカー数。android-sdk の MARKER_RENDER_BATCH_SIZE と同値。 */ +declare const MARKER_RENDER_BATCH_SIZE = 500; +interface MarkerIngestionResult { + /** タイル描画対象の集合が変化したか(タイルの再生成が要る)。 */ + tiledDataChanged: boolean; + /** 現在タイル描画中のマーカーが1件以上あるか。 */ + hasTiledMarkers: boolean; + /** + * アニメーションを開始すべきエンティティ。 + * + * android-sdk の engine は内部で `renderer.onAnimate` を直接呼ぶが、こちらは呼ばずに返す。 + * React 実装はロックの外で `Promise.all` して同時再生させる必要があるため + * (スクリーン空間オーバーレイの `onAnimate` はアニメーション終了時に解決するので、 + * ロック内で await すると他のマーカー操作をブロックし、アニメーション同士も直列化する)。 + */ + entitiesToAnimate: MarkerEntity[]; +} +/** + * マーカー取り込みの共有ロジック。 + * + * 受け取った `MarkerState` の一覧を `MarkerManager` の現在状態と差分比較し、 + * `MarkerOverlayRenderer` を駆動して `MarkerManager` を更新する。 + * + * プロバイダ固有のコントローラが差し込めるのは: + * - 「タイル描画にするか、プロバイダのネイティブマーカーにするか」の判定(`shouldTile`) + * - タイル済み ID の保持先(`tiledMarkerIds`) + * + * タイルオーバーレイそのもの(RasterLayer の state、キャッシュバスティング等)は + * 引き続きコントローラ側の責務。 + * + * android-sdk の `MarkerIngestionEngine.ingest` の移植。差分は上記 `entitiesToAnimate` と、 + * android-sdk が engine の外に置いているバッチ分割をこちらは engine 内に持つ点 + * (React 側は `AbstractMarkerController` に非タイル経路が別に無いため)。 + */ +declare function ingestMarkers(params: { + data: MarkerState[]; + markerManager: MarkerManager; + renderer: MarkerOverlayRenderer; + defaultMarkerIcon: BitmapIcon; + tilingEnabled: boolean; + tiledMarkerIds: Set; + shouldTile: (state: MarkerState) => boolean; + onMarkerAdded?: (entity: MarkerEntity) => void; +}): Promise>; + +/** + * Per-map, per-overlay-type source of truth for overlay states. + * + * 変更の届け方は 3 プラットフォームで揃えてある。 + * + * - **membership(add / remove)は debounce**。5ms の無入力窓で、イベントが来るたびに + * 窓を延長し、件数が閾値に達したら待たずに出す。android-sdk の + * `debounceBatch(5ms, 100/300)`、ios-sdk の `scheduleMembership()` と同じ。 + * - **in-place 変更は sample**。1 つの state につき 1 窓 1 回、最新の値だけを配る。 + * android-sdk の `sample(updateDebounce)`、ios-sdk の `scheduleUpdate()` と同じ。 + * + * どちらの窓でも、コレクション自体の書き換えは同期のまま(`values()` / `get()` は常に + * 最新)。遅らせるのは購読者・ハンドラへの通知だけ。 + */ +declare class OverlayCollector { + private readonly map; + private readonly subs; + private updateHandler; + private readonly updateSubs; + private batchDepth; + private batchDirty; + private pendingAdds; + private pendingRemoves; + private flushTimer; + private readonly pendingUpdates; + private updateTimer; + /** + * `` を1つマウントするたびに1回ずつ走っていた composition を、 + * android-sdk の OverlayCollector と同じ窓でまとめる。 + * + * コレクション自体の書き換えは同期のまま(`values()` / `get()` は常に最新)で、 + * 遅らせるのは購読者への通知だけ。`applyDiff` / `replaceAll` / `clear` は + * もともと1回しか通知しないバルク操作なので、android-sdk と同じくデバウンス対象外。 + */ + add(state: S): void; + remove(id: string): void; + /** + * Applies a batch of upserts and removals with a single notification. + * Unlike calling add()/remove() in a loop (which notifies subscribers once + * per call and can trigger an expensive downstream re-render each time), + * subscribers see the final state exactly once. + */ + applyDiff(upserts: S[], removeIds: Iterable): void; + /** + * Applies a group of collection and state mutations as one composition. + * + * `` は毎レンダーで「既存 state をその場で書き換える」と + * 「新しい id を追加し、消えた id を削除する」を同時にやる。素で走らせると: + * + * 1. `syncMarkerState` の代入1つごとに state の subject が発火 → `updateHandler` が + * 1件ずつ呼ばれる(1000件なら最大1000回) + * 2. そのあと `applyDiff` の membership 通知が1回 + * + * つまり **membership より先に in-place 更新が飛ぶ**。まだコレクションに入っていない + * マーカーの更新を受け取ることになり、マーカークラスタリングのように「membership を + * 見てから作り直す」consumer が壊れる。 + * + * batchChanges の間は: + * - in-place 変更は `updateHandler` を呼ばず `batchDirty` を立てるだけ(サンプリング窓 + * にも積まない) + * - `notify()` も同じく `batchDirty` を立てるだけ + * - 抜けるときに dirty なら `notify()` を **1回だけ** + * + * 結果、N件の in-place 更新 + membership 変化が購読者への通知1回に畳まれる。捨てられた + * in-place 更新は失われない: 購読者は通知を受けて `values()` から最新の state を読み直す。 + */ + batchChanges(action: () => void): void; + replaceAll(states: S[]): void; + clear(): void; + /** + * Mirrors Android's OverlayCollector.setUpdateHandler. + * When set, subscribes to each state's asObservable() and calls handler + * only when the fingerprint actually changes (distinctUntilChanged) — never + * for the value the subscription replays on registration. Membership + * changes are delivered to subscribe() listeners, not to this handler. + */ + setUpdateHandler(handler: ((state: S) => void) | null): void; + values(): S[]; + get(id: string): S | undefined; + subscribe(fn: (map: ReadonlyMap) => void): () => void; + private startUpdateSub; + /** + * 変更を 5ms 窓にためて、窓の終わりに id ごと最新の1件だけ配る。 + * + * debounce ではなく **sample**(最初の変更で窓を開き、以降は窓を延長しない)なのが + * 重要で、android-sdk が `sample(updateDebounce)` を使うのと同じ理由。ドラッグは + * 同じ state を毎フレーム書き換えるので、debounce だと指が止まるまで窓が延び続けて + * 1回も配信されない。sample なら変更が続いている間も1窓に1回は届く。 + * + * 以前はここが素通しで、`position` を1回書き換えるたびにプロバイダの + * `update(state)`(=ネイティブ SDK の再描画)が走っていた。 + */ + private scheduleUpdate; + private cancelPendingUpdates; + private stopUpdateSub; + private notify; + private notifyDebounced; + private cancelFlushTimer; + private notifySubscribers; +} + +/** + * Bridge between the React-side MarkerState collector and a MarkerCapable map controller. + * Mirrors `MarkerOverlay.kt` in the Android SDK. + * + * Register an instance of this class with `MapOverlayRegistry` so that whenever + * the marker collection changes, the controller's `compositionMarkers()` is called. + */ +declare class MarkerOverlay implements MapOverlayInterface { + private readonly collector; + constructor(collector: OverlayCollector); + subscribe(fn: (data: ReadonlyMap) => void): () => void; + render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; +} + +/** + * Marker renderer backed by a map's marker collector. + * + * `MarkerRenderingStrategy` implementations (marker clustering など) only ever talk + * to `MarkerOverlayRenderer` — the same contract `MarkerClusterStrategy.kt` uses on + * Android — and the platform decides how the resulting markers reach the map. + * Web では各 `react-for-*` がこのレンダラを `MarkerRenderingSupport.createMarkerRenderer` + * から返す。コレクタから先はプロバイダ通常のマーカー経路がそのまま処理するので、 + * プロバイダごとの実装差は要らない(android-sdk / ios-sdk はネイティブのマーカー型が + * プロバイダごとに違うため、そこで初めて実装が分かれる)。 + * + * Add / change / remove are staged and committed as a single `applyDiff()` in + * `onPostProcess()` — the same commit point Android uses. Notifying per marker + * instead would re-run the provider's entire marker composition once per marker, + * which reconstructs the tile renderer thousands of times on large datasets. + * + * `ActualMarker` is `MarkerState`: the collector is the "native" marker layer + * here, so a rendered marker is identified by the state that was written to it. + */ +declare class CollectorMarkerOverlayRenderer implements MarkerOverlayRenderer { + private readonly collector; + readonly holder: MapViewHolder | undefined; + animateStartListener: OnMarkerEventHandler | null; + animateEndListener: OnMarkerEventHandler | null; + animationOverlayHost: MarkerAnimationOverlayHost | null; + private readonly pendingUpserts; + private readonly pendingRemoveIds; + /** Ids currently written to the collector, so unmount can take them all back. */ + private readonly renderedIds; + constructor(collector: OverlayCollector, holder: MapViewHolder | undefined); + onAdd(data: AddParams[]): Promise<(MarkerState | null)[]>; + onChange(data: ChangeParams[]): Promise<(MarkerState | null)[]>; + onRemove(data: MarkerEntity[]): Promise; + /** + * Animation is driven by the strategy (it interpolates positions and calls + * `onChange()` per frame), exactly as on Android — there is nothing for the + * renderer to play back on its own. + */ + onAnimate(_entity: MarkerEntity): Promise; + onPostProcess(): Promise; + setMarkerVisible(_entity: MarkerEntity, _visible: boolean): void; + /** Drops every marker this renderer wrote. Used when the group unmounts. */ + reset(): void; + private stage; +} + +/** + * Strategy-driven marker controller. + * + * Mirrors `StrategyMarkerController.kt` (android-sdk-core) / + * `StrategyMarkerController.swift` (ios-sdk-core): camera bounds are remembered + * via `lastKnownBounds` so add/update keep working between camera events, states + * received before the first camera fix are queued in `pendingStates`, and + * `find()` hit-tests the tapped point against the nearest marker's icon bounds + * with `Settings.Default.tapTolerance`. + */ +declare class StrategyMarkerController implements SlottedOverlayController, OverlayController, MarkerState> { + readonly markerManager: MarkerManager; + readonly zIndex: number; + private mapCameraPosition; + private lastKnownBounds; + private readonly semaphore; + private pendingStates; + dragStartListener: OnMarkerEventHandler | null; + dragListener: OnMarkerEventHandler | null; + dragEndListener: OnMarkerEventHandler | null; + animateStartListener: OnMarkerEventHandler | null; + animateEndListener: OnMarkerEventHandler | null; + private readonly strategy; + private readonly renderer; + clickListener: OnMarkerEventHandler | null; + constructor({ strategy, renderer, clickListener, }: { + strategy: MarkerRenderingStrategy; + renderer: MarkerOverlayRenderer; + clickListener?: OnMarkerEventHandler | null; }); - /** 緯度経度 -> 六角グリッド座標 */ - latLngToHexCoord(params: { - position: GeoPoint; - zoom: number; - }): HexCoord; - /** 緯度経度 -> HexCell(ID/中心座標など含む) */ - latLngToHexCell(params: { - position: GeoPoint; - zoom: number; - }): HexCell; - /** 六角グリッド座標 -> 中心の緯度経度 */ - hexToLatLngCenter(params: { - coord: HexCoord; - latitude?: number; - latHint?: number; - zoom: number; - }): GeoPoint; - /** ズームを含むユニークなセルID */ - hexToCellId(params: { - coord: HexCoord; - zoom: number; - }): string; - /** 六角形ポリゴン(6頂点)を緯度経度で返す(フラットトップ) */ - hexToPolygonLatLng(params: { - coord: HexCoord; - latHint: number; - zoom: number; - }): GeoPoint[]; - /** 複数点の重心を囲むセル */ - enclosingCellOf(params: { - points: MarkerState[]; - zoom: number; - }): HexCell; - /** 複数点それぞれに対応する HexCell と元IDのセット */ - hexCellsForPointsWithId(params: { - points: MarkerState[]; - zoom: number; - }): Set; - /** 六角座標距離 */ - hexDistance(params: { - origin: HexCoord; - dst: HexCoord; - }): number; - /** 半径 r の六角範囲(axial) */ - hexRange(params: { - center: HexCoord; - radius: number; - }): HexCoord[]; - /** 緯度・ズームに応じて辺長を補正(ズームは 2^-zoom、緯度スケールは cos(lat) で補正) */ - private adjustedHexSideLength; - /** 六角中心の XY を返す(フラットトップ) */ - private hexCenterXY; - /** XY -> 六角座標(axial) */ - private pixelToHex; - /** 連続値のaxial座標(q,r)を最も近い格子点へ丸める */ - private cubeRound; - /** 地球曲率を考慮した重心(球面近似) */ - private computeGeographicCentroid; - static defaultGeocell(): HexGeocell; + dispatchClick(state: MarkerState): void; + dispatchDragStart(state: MarkerState): void; + dispatchDrag(state: MarkerState): void; + dispatchDragEnd(state: MarkerState): void; + dispatchAnimateStart(state: MarkerState): void; + dispatchAnimateEnd(state: MarkerState): void; + add(data: MarkerState[]): Promise; + update(state: MarkerState): Promise; + clear(): Promise; + getEntity(id: string): MarkerEntity | null; + find(position: GeoPoint): MarkerEntity | null; + /** `undefined` means the holder projects asynchronously (no sync value available). */ + private resolveSyncOffset; + onCameraChanged(mapCameraPosition: MapCameraPosition): Promise; + destroy(): void; + readonly kind: OverlayKind; + hasId(id: string): boolean; + compositionAny(data: unknown[]): Promise; + updateAny(state: unknown): Promise; + setClickListenerAny(listener: unknown): void; + /** マーカーは別経路(`dispatchMarkerTap`)。ここでは当たらない。 */ + resolveTap(_position: GeoPointInterface): OverlayHit | null; +} + +interface MarkerEventController { + readonly controller?: StrategyMarkerController; + readonly renderer?: MarkerOverlayRenderer; +} +/** + * マーカー描画プラグイン(marker-clustering など)が、マップコントローラ自身に + * プラグインのインタフェースを実装させずにレンダラ/コントローラを作るための + * map スコープの capability。 + * + * android-sdk の `MarkerRenderingSupport.kt` / ios-sdk の `MarkerRenderingSupport.swift` + * と同じ契約。プロバイダが {@link MarkerRenderingSupportKey} で + * {@link MutableMapServiceRegistry} に登録し、プラグインがそれを解決する。 + */ +interface MarkerRenderingSupport { + createMarkerRenderer(strategy: MarkerRenderingStrategy): MarkerOverlayRenderer; + createMarkerEventController(controller: StrategyMarkerController, renderer: MarkerOverlayRenderer): MarkerEventController; + registerMarkerEventController(controller: MarkerEventController): void; + mapLoadedState?: { + value: boolean; + } | null; + onMarkerRenderingReady?(): void; +} +/** + * 型引数を消した {@link MarkerRenderingSupport}。レジストリのキーは 1 つなので、 + * `ActualMarker` の異なる実装を同じキーで出し入れするためにこの別名を使う。 + * ios-sdk の `AnyMarkerRenderingSupport` と同じ役割。 + */ +type AnyMarkerRenderingSupport = MarkerRenderingSupport; +/** + * {@link MarkerRenderingSupport} をレジストリから引くためのキー。 + * android-sdk の `object MarkerRenderingSupportKey : MapServiceKey>`、 + * ios-sdk の `enum MarkerRenderingSupportKey: MapServiceKey` に対応する。 + */ +declare const MarkerRenderingSupportKey: MapServiceKey; +/** + * web の各 `react-for-*` が登録する既定の {@link MarkerRenderingSupport}。 + * + * android-sdk / ios-sdk ではプロバイダごとにネイティブのマーカー型が違うため実装も + * 分かれるが、web ではクラスタ結果が `MarkerState` のままマーカーコレクタへ入り、 + * そこから先はプロバイダ通常のマーカー経路が処理する。つまり全プロバイダで同じ実装で + * 足りるので、重複を避けてここに 1 つだけ置く。別の描画経路が要るプロバイダは、 + * 自前の {@link MarkerRenderingSupport} を登録すればよい。 + */ +declare function createCollectorMarkerRenderingSupport(params: { + collector: OverlayCollector; + holder: MapViewHolder | undefined; + /** 地図の準備完了状態。クラスタリング開始の合図に使う。 */ + mapLoadedState?: { + value: boolean; + } | null; + /** 描画準備が整った直後に呼ばれる。初期カメラをオーバーレイへ配る等に使う。 */ + onMarkerRenderingReady?: () => void; +}): MarkerRenderingSupport; + +/** + * Options for marker tiling optimization. + * + * When enabled, large sets of static markers can be rendered as tile overlays + * to avoid per-marker add/update cost in native map SDKs. + * Mirrors `MarkerTilingOptions` from `MarkerTilingOptions.kt`. + */ +interface MarkerTilingOptions { + readonly enabled: boolean; + readonly debugTileOverlay: boolean; + readonly minMarkerCount: number; + readonly cacheSize: number; + /** + * Per-marker icon scale multiplier applied during tile rendering. + * `(zoom: number, state: MarkerState) => number` + */ + readonly iconScaleCallback: ((state: MarkerState, zoom: number) => number) | null; + /** + * Keep one marker per cell of this many pixels, or 0 to keep them all. + * + * Markers a few pixels apart overlap almost completely, so thinning them is + * a judgement about the map rather than a free optimisation — hence opt-in. + * Tokyo's street trees are planted metres apart along a road: below street + * level most of them sit on top of one another, and one per icon width + * halves the tile bytes while showing the same map. + * + * Zero still drops markers that agree exactly — same rectangle, same icon — + * which cannot change the tile. + * + * android-sdk's MarkerTilingOptions carries the same field. + */ + readonly declutterPx: number; +} +declare namespace MarkerTilingOptions { + const Default: MarkerTilingOptions; + const Disabled: MarkerTilingOptions; } -declare class HexCellRegistry { - private kdTree; - private readonly allCells; - private readonly entryIDsByCell; - private readonly allEntries; - private needsRebuild; - private readonly lock; - private readonly geocell; - private readonly zoom; - constructor(params: { +/** + * Abstract base for marker rendering strategies that use viewport-based optimization. + * Only markers within the current camera viewport are rendered; out-of-viewport + * markers are tracked in the manager but not submitted to the renderer. + * Mirrors `AbstractViewportStrategy` from `AbstractViewportStrategy.kt`. + */ +declare abstract class AbstractViewportStrategy extends AbstractMarkerRenderingStrategy { + readonly markerManager: MarkerManager; + constructor({ geocell, minMarkerCount, }: { geocell: HexGeocell; - zoom: number; + minMarkerCount?: number; }); - /** 対象エンティティが属する HexCell を(登録せず)計算して返す */ - getCell(entity: MarkerEntity): HexCell; - /** 登録/更新して属する HexCell を返す */ - setPoint(entity: MarkerEntity): HexCell; - /** HexCell が存在するか */ - contains(hexId: string): boolean; - /** エンティティをレジストリから削除 */ - removePoint(entity: MarkerEntity): boolean; - /** 特定セルからエンティティを外す */ - private removeFromCell; - /** 全消去 */ + onAdd({ data, viewport, renderer, }: { + data: MarkerState[]; + viewport: GeoRectBounds; + renderer: MarkerOverlayRenderer; + }): Promise; + onUpdate({ state, viewport, renderer, }: { + state: MarkerState; + viewport: GeoRectBounds; + renderer: MarkerOverlayRenderer; + }): Promise; clear(): void; - private markDirty; - /** インデックス再構築(必要時のみ) */ - private rebuildIfNeeded; - /** 最も近い HexCell を返す */ - findNearest(point: GeoPoint): HexCell | null; - /** 最も近い HexCell と距離 */ - findNearestWithDistance(point: GeoPoint): HexCellWithDistance | null; - /** k 近傍(距離付き) */ - findNearestKWithDistance(params: { - point: GeoPoint; - k: number; - }): HexCellWithDistance[]; - /** 半径内(メートル等)にある HexCell(距離付き) */ - findWithinRadiusWithDistance(params: { - position: GeoPoint; - radius: number; - }): HexCellWithDistance[]; - /** 全 HexCell */ - all(): HexCell[]; - /** 指定 HexCell に属するエンティティID一覧(コピーを返す) */ - getEntryIDsByHexCell(hexCell: HexCell): Set | null; +} + +/** + * A hierarchical lat/lng grid used to cull the candidate set per tile without + * scanning every marker. + * + * The cells are the ones {@link MarkerGrid} describes, the same ones android-sdk + * and ios-sdk index on. What differs is the storage. Those two keep one sorted + * array of `(cellKey << 24) | position`, which works because a `Long` has 63 + * bits and the pair needs 60. A JavaScript `number` has 53, so the pair does + * not fit — and its bitwise operators would truncate to 32 in any case. Here + * the position lives in the bucket instead of in the key, which needs no packing + * at all. + * + * The consequence is that a level is a map of its own rather than a prefix of a + * shared array, so each one is built when first asked for. In practice a + * renderer asks for one or two: the level its tiles fall on, and the level its + * declutter separation asks for. + */ +declare class GeoGridIndex { + private readonly items; + /** Cells per level, built on demand. `level -> cellKey -> items`. */ + private readonly levels; /** - * 指定位置/ズームでのピクセル数に相当する距離(m) - * ※投影がメートルを返す前提 + * How many levels to keep built. + * + * A renderer settles on one or two and stays there; holding more would keep + * a second reference to every marker for a zoom the user has left. */ - metersPerPixel(params: { - position: GeoPoint; - zoom: number; - pixels: number; - tileSize?: number; - }): number; - /** ピクセル半径内の HexCell(距離付き) */ - findWithinPixelRadius(params: { - position: GeoPoint; - zoom: number; - pixels: number; - tileSize?: number; - }): HexCellWithDistance[]; - /** ID プレフィックス一致で検索(共通プレフィックス最適化想定) */ - findByIdPrefix(prefix: string): HexCell[]; - /** レジストリ状態の統計 */ - getStats(): RegistryStats; + private static readonly LEVEL_CACHE_MAX; + constructor(items: ReadonlyArray); + private cellsAt; + /** + * Items whose position falls within the given padded lat/lng bounds. + * + * `west` and `east` may run past ±180 or come in reversed: both callers pad + * a tile or a click by a few pixels, and near the antimeridian that padding + * walks off the end of the coordinate range. Comparing those numbers + * directly means a click at 179.99° never reaches a marker at -179.99°, and + * a reversed box iterates no cells at all and quietly returns nothing. + */ + queryBounds(south: number, north: number, west: number, east: number): T[]; + /** + * Walks the cells a box covers at `level`, folding columns onto the globe. + * + * The walk starts at the column holding `west` and runs eastward, which + * covers a reversed or past-±180 box without a second loop. + */ + private forEachCell; + private static spanOf; + private static insideTest; + /** Whether `lng` lies within `span` degrees east of `west`. */ + private static withinLongitude; +} + +/** Decodes BitmapIcon.url (a self-contained data: URL) into a drawable image, with dedup + caching. */ +declare class IconImageCache { + private readonly ready; + private readonly pending; + private readonly failed; + private static readonly MAX_READY_ENTRIES; + private static readonly MAX_FAILED_ENTRIES; + get(url: string): ImageBitmap | HTMLImageElement | undefined; + /** Decode (or return the in-flight/cached decode of) an icon image. Never throws. */ + ensure(url: string): Promise; + private evictReadyIfNeeded; + private addFailed; + private decode; +} + +declare const MARKER_HIT_RADIUS_TOUCH_PX = 20; +declare const MARKER_HIT_RADIUS_MOUSE_PX = 6; +/** A marker resolved to its decoded image and tile-local draw geometry, ready for drawImage(). */ +interface PreparedMarker { + image: ImageBitmap | HTMLImageElement; + centerNormX: number; + centerNormY: number; + drawW: number; + drawH: number; + anchorX: number; + anchorY: number; +} + +/** + * Canvas-based tile renderer for large marker datasets. + * + * Mirrors Android/iOS MarkerTileRenderer: renders each marker's actual icon + * (not a placeholder) onto Canvas tiles served via LocalTileServer, enabling + * tens of thousands of markers without creating individual DOM elements. + * A lat/lng grid index keeps per-tile queries proportional to the markers + * actually near that tile instead of scanning the whole dataset. + * + * Also provides `findNearest()` for hit-testing click/tap events against the same + * marker set, with an adaptive radius for touch vs. mouse input. + */ +declare class MarkerTileRenderer implements TileProvider { + private readonly items; + readonly tileSize: number; + readonly extraIconScale: number; + private readonly iconScale; + private readonly debugTileOverlay; + /** Keep one marker per cell of this many pixels, or 0 to keep them all. */ + private readonly declutterPx; + private readonly grid; + private readonly icons; + private readonly defaultBitmapIcon; + /** Per-instance tile output cache; each sync creates a fresh renderer instance, so no invalidation is needed. */ + private readonly tileCache; + /** Largest icon half-extent any tile has needed so far, in px. */ + private observedHalfExtentPx; + constructor(items: ReadonlyArray, tileSize?: number, iconScaleCallback?: (item: T, zoom: number) => number, extraIconScale?: number, debugTileOverlay?: boolean, declutterPx?: number); + private bitmapIconOf; + /** Decode every unique icon up front. Call before relying on the synchronous renderTileDataUrl() path. */ + preloadIcons(): Promise; + private tileKey; + private cacheTile; + private queryCandidates; + private prepareMarkers; + /** Kick off (but don't await) decoding for any candidate not yet in the icon cache. */ + private warmIcons; + private createCanvas; + private draw; + private drawDebugOverlay; + private canvasToBytes; + renderTile(req: TileRequest): Promise; + /** + * Synchronous best-effort render for APIs that require a data: URL + * immediately (e.g. Google Maps ImageMapType.getTileUrl). Markers whose + * icon hasn't finished decoding yet are skipped for this call and + * decoded in the background — call `preloadIcons()` first to avoid this + * gap on first paint. + */ + renderTileDataUrl(req: TileRequest): string | null; + /** + * Serialize items + deduped icon bitmaps for transfer to the Service + * Worker, so tiles can be rendered with OffscreenCanvas without a + * main-thread round-trip. Icons are decoded (async) and deduplicated by + * BitmapIcon.url so identical icons are only transferred once. + * + * `zoomScales` mirrors the original design: computed from a representative + * item, since `iconScaleCallback` is usually zoom-only in practice. Items + * whose icon could not be decoded to an ImageBitmap (SW contexts have no + * ) fall back to the default icon's index rather than being dropped, + * so they still render (as the default icon) instead of silently vanishing. + * + * The returned bitmaps belong to the caller, who must release them with + * `closeSWIcons()` once they have been posted. `sendSWRegisterAndWait()` + * already does this, so the usual one-line call site needs nothing extra. + */ + toSWData(): Promise<{ + items: { + lat: number; + lng: number; + iconIndex: number; + }[]; + icons: { + bitmap: ImageBitmap; + anchor: { + x: number; + y: number; + }; + size: { + width: number; + height: number; + }; + }[]; + zoomScales: number[]; + extraIconScale: number; + }>; + /** + * Find the item under `click` at the given zoom level, or the nearest one within `hitRadiusPx`. + * + * Use `MARKER_HIT_RADIUS_TOUCH_PX` for finger taps and `MARKER_HIT_RADIUS_MOUSE_PX` + * for mouse/pen, or supply a custom radius. Returns `null` when nothing is close enough. + */ + findNearest(click: GeoPoint, hitRadiusPx: number, zoom: number): T | null; } -interface RegistryStats { - totalCells: number; - totalEntries: number; - kdTreeBuilt: boolean; - needsRebuild: boolean; + +declare const MarkerGrid_GRID_DEPTH: typeof GRID_DEPTH; +declare const MarkerGrid_cellSize: typeof cellSize; +declare const MarkerGrid_columnWalk: typeof columnWalk; +declare const MarkerGrid_columns: typeof columns; +declare const MarkerGrid_eastwardSpan: typeof eastwardSpan; +declare const MarkerGrid_geocell: typeof geocell; +declare const MarkerGrid_latCell: typeof latCell; +declare const MarkerGrid_levelForSeparation: typeof levelForSeparation; +declare const MarkerGrid_lonCell: typeof lonCell; +declare const MarkerGrid_morton: typeof morton; +declare const MarkerGrid_mortonKeyFor: typeof mortonKeyFor; +declare const MarkerGrid_queryLevel: typeof queryLevel; +declare const MarkerGrid_rows: typeof rows; +declare const MarkerGrid_wrap: typeof wrap; +declare namespace MarkerGrid { + export { MarkerGrid_GRID_DEPTH as GRID_DEPTH, MarkerGrid_cellSize as cellSize, MarkerGrid_columnWalk as columnWalk, MarkerGrid_columns as columns, MarkerGrid_eastwardSpan as eastwardSpan, MarkerGrid_geocell as geocell, MarkerGrid_latCell as latCell, MarkerGrid_levelForSeparation as levelForSeparation, MarkerGrid_lonCell as lonCell, MarkerGrid_morton as morton, MarkerGrid_mortonKeyFor as mortonKeyFor, MarkerGrid_queryLevel as queryLevel, MarkerGrid_rows as rows, MarkerGrid_wrap as wrap }; } -interface HexCellWithDistance { - cell: HexCell; - distanceMeters: number; +interface PolygonFingerPrint { + id: number; + strokeColor: number; + strokeWidth: number; + fillColor: number; + geodesic: number; + zIndex: number; + points: number; + holes: number; + extra: number; } -declare function createHexCellWithDistance(params: { - cell: HexCell; - distanceMeters: number; -}): HexCellWithDistance; +interface PolygonEvent { + state: PolygonState; + clicked: GeoPoint; +} +type OnPolygonEventHandler = (event: PolygonEvent) => void; +interface PolygonState { + readonly id: string; + points: GeoPoint[]; + holes: GeoPoint[][]; + strokeColor: string; + strokeWidth: number; + fillColor: string; + geodesic: boolean; + zIndex: number; + extra: Serializable | null; + onClick: OnPolygonEventHandler | null; + fingerPrint(): PolygonFingerPrint; + copy(opts?: PolygonStateCopyParams): PolygonState; + equals(other: unknown): boolean; + hashCode(): number; + asObservable(): { + subscribe: (fn: (fp: PolygonFingerPrint) => void) => () => void; + }; +} +interface PolygonStateCopyParams { + points?: GeoPoint[]; + holes?: GeoPoint[][]; + id?: string | null; + strokeColor?: string; + strokeWidth?: number; + fillColor?: string; + geodesic?: boolean; + zIndex?: number; + extra?: Serializable | null; + onClick?: OnPolygonEventHandler | null; +} +declare function createPolygonState(params: { + points: GeoPoint[]; + holes?: GeoPoint[][]; + id?: string | null; + strokeColor?: string; + strokeWidth?: number; + fillColor?: string; + geodesic?: boolean; + zIndex?: number; + extra?: Serializable | null; + onClick?: OnPolygonEventHandler | null; +}): PolygonState; -interface KDTreeStats { - nodeCount: number; - maxDepth: number; - isEmpty: boolean; +interface CircleFingerPrint { + id: number; + center: number; + radiusMeters: number; + clickable: number; + geodesic: number; + strokeColor: number; + strokeWidth: number; + fillColor: number; + zIndex: number; + extra: number; } -declare class KDTree { - private readonly root; - constructor(points: HexCell[]); - /** 再帰的にK-D木を構築 */ - private build; - /** 最近傍セルを返す(見つからない場合 null) */ - nearest(query: Offset): HexCell | null; - /** 最近傍 + 距離 */ - nearestWithDistance(query: Offset): HexCellWithDistance | null; - /** k 近傍 + 距離(距離昇順) */ - nearestKWithDistance(params: { - query: Offset; - k: number; - }): HexCellWithDistance[]; - /** 半径内(メートルなど任意単位)にあるセルを距離付きで返す(距離昇順) */ - withinRadiusWithDistance(params: { - query: Offset; - radius: number; - }): HexCellWithDistance[]; - /** 構造統計 */ - getStats(): KDTreeStats; - private _nearest; - private _squaredDistance; - private _distance; - private _countNodes; - private _maxDepth; +interface CircleEvent { + state: CircleState; + clicked: GeoPoint; +} +type OnCircleEventHandler = (event: CircleEvent) => void; +interface CircleState { + readonly id: string; + center: GeoPoint; + radiusMeters: number; + geodesic: boolean; + clickable: boolean; + strokeColor: string; + strokeWidth: number; + fillColor: string; + extra: Serializable | null; + zIndex: number | null; + onClick: OnCircleEventHandler | null; + fingerPrint(): CircleFingerPrint; + copy(opts?: CircleStateCopyParams): CircleState; + equals(other: unknown): boolean; + hashCode(): number; + asObservable(): { + subscribe: (fn: (fp: CircleFingerPrint) => void) => () => void; + }; +} +interface CircleStateCopyParams { + center?: GeoPoint; + radiusMeters?: number; + geodesic?: boolean; + clickable?: boolean; + strokeColor?: string; + strokeWidth?: number; + fillColor?: string; + extra?: Serializable | null; + zIndex?: number | null; + onClick?: OnCircleEventHandler | null; + id?: string | null; } +declare function createCircleState(params: { + center: GeoPoint; + radiusMeters: number; + geodesic?: boolean; + clickable?: boolean; + strokeColor?: string; + strokeWidth?: number; + fillColor?: string; + extra?: Serializable | null; + zIndex?: number | null; + onClick?: OnCircleEventHandler | null; + id?: string | null; +}): CircleState; -/** - * Memory usage statistics for MarkerManager optimization - */ -interface MarkerManagerStats { - entityCount: number; - hasSpatialIndex: boolean; - spatialIndexInitialized: boolean; - estimatedMemoryKB: number; +interface GroundImageFingerPrint { + id: number; + bounds: number; + imageUrl: number; + opacity: number; + tileSize: number; + extra: number; } -declare class MarkerManager { - readonly minMarkerCount: number; - protected readonly geocell: HexGeocell; - private readonly entities; - private cellRegistry; - private isDestroyed; - constructor(geocell: HexGeocell, minMarkerCount?: number); - lock(): void; - unlock(): void; - getEntity(id: string): MarkerEntity | null; - hasEntity(id: string): boolean; - removeEntity(id: string): MarkerEntity | null; - metersPerPixel({ position, zoom, pixels, tileSize, }: { - position: GeoPoint; - zoom: number; - pixels: number; - tileSize?: number; - }): number; - findNearest(position: GeoPoint): MarkerEntity | null; - private bruteForceNearest; - findByIdPrefix(prefix: string): HexCell[]; - registerEntity(entity: MarkerEntity): void; - /** 空間操作が必要になった時だけ遅延初期化 */ - private ensureCellRegistry; - updateEntity(entity: MarkerEntity): void; - allEntities(): Array>; - /** メモリ統計(概算) */ - getMemoryStats(): MarkerManagerStats; - private estimateMemoryUsage; - clear(): void; - findMarkersInBounds(bounds: GeoRectBounds): Array>; - /** マッププロバイダ切替時などに正しく破棄する */ - destroy(): void; - static defaultManager(geocell?: HexGeocell | null, minMarkerCount?: number): MarkerManager; +interface GroundImageEvent { + state: GroundImageState; + clicked: GeoPoint | null; +} +type OnGroundImageEventHandler = (event: GroundImageEvent) => void; +interface GroundImageState { + readonly id: string; + bounds: GeoRectBounds; + imageUrl: string; + opacity: number; + tileSize: number; + extra: Serializable | null; + onClick: OnGroundImageEventHandler | null; + fingerPrint(): GroundImageFingerPrint; + copy(opts?: GroundImageStateCopyParams): GroundImageState; + equals(other: unknown): boolean; + hashCode(): number; + asObservable(): { + subscribe: (fn: (fp: GroundImageFingerPrint) => void) => () => void; + }; +} +interface GroundImageStateCopyParams { + bounds?: GeoRectBounds; + imageUrl?: string; + opacity?: number; + tileSize?: number; + extra?: Serializable | null; + onClick?: OnGroundImageEventHandler | null; + id?: string | null; } +declare const GROUND_IMAGE_DEFAULT_TILE_SIZE = 512; +declare function createGroundImageState(params: { + bounds: GeoRectBounds; + imageUrl: string; + opacity?: number; + tileSize?: number; + id?: string | null; + extra?: Serializable | null; + onClick?: OnGroundImageEventHandler | null; +}): GroundImageState; /** - * Strategy interface for handling marker rendering during camera changes. - * Different map providers may have different optimal strategies for marker management. + * Abstract base class implementing the listener-management contract of MapViewControllerInterface. + * Concrete SDK controllers extend this and call notify*() methods when native events fire. + * + * Mirrors Android BaseMapViewController / iOS MapViewController (base methods). */ -interface MarkerRenderingStrategy { - markerManager: MarkerManager; - clear(): void; - onAdd(params: { - data: MarkerState[]; - viewport: GeoRectBounds; - renderer: MarkerOverlayRenderer; - }): Promise; - onUpdate(params: { - state: MarkerState; - viewport: GeoRectBounds; - renderer: MarkerOverlayRenderer; - }): Promise; +declare abstract class BaseMapViewController { + protected cameraMoveStartCallback: OnCameraMoveHandler | null; + protected cameraMoveCallback: OnCameraMoveHandler | null; + protected cameraMoveEndCallback: OnCameraMoveHandler | null; + protected mapClickCallback: OnMapEventHandler | null; + protected mapLongClickCallback: OnMapEventHandler | null; + protected mapInitializedCallback: OnMapInitializedHandler | null; + private mapInitialized; + private mapInitializedCallbackDelivered; + private readonly overlayControllers; /** - * Handle camera position changes and update marker rendering accordingly. + * ネイティブ SDK に直接のカメラ範囲制限 API を持たないプロバイダ(HERE/ArcGIS/MapKit/Cesium)が + * クランプ方式で使う制限設定。ネイティブ API を持つプロバイダ(Mapbox/MapLibre/MapTiler/ + * TomTom/Google/Leaflet/OpenLayers/Azure Maps)は `setCameraRestriction` をオーバーライドして + * 直接適用するため、この値は使わない。 + */ + private cameraRestriction; + setCameraRestriction(restriction: CameraRestriction | null): void; + protected hasCameraRestriction(): boolean; + /** + * カメラ位置が `setCameraRestriction` の制限に違反していれば補正後の位置を返す。違反が無ければ null。 * - * @param cameraPosition The new camera position - * @param renderer The marker overlay renderer + * ズームは統一ズーム(Google 準拠)前提。範囲制限はネイティブの + * `setLatLngBoundsForCameraTarget` 相当(カメラ中心を矩形内へクランプ)のセマンティクスに揃える。 + * クランプ方式のプロバイダはカメラ停止時にこれを呼び、返り値があれば `moveCamera` で再適用する。 + * ε を用いて微小な誤差では補正しないことで、再適用 → イベント → 再補正の無限ループを防ぐ。 + * + * android-sdk の `BaseMapViewController.cameraRestrictionCorrection` の移植。ε の値も同一。 */ - onCameraChanged(cameraPosition: MapCameraPosition, renderer: MarkerOverlayRenderer): Promise; -} - -declare abstract class AbstractMarkerRenderingStrategy implements MarkerRenderingStrategy { + protected cameraRestrictionCorrection(current: MapCameraPosition): MapCameraPosition | null; + setCameraMoveStartListener(listener: OnCameraMoveHandler | null): void; + setCameraMoveListener(listener: OnCameraMoveHandler | null): void; + setCameraMoveEndListener(listener: OnCameraMoveHandler | null): void; + setMapClickListener(listener: OnMapEventHandler | null): void; + setMapLongClickListener(listener: OnMapEventHandler | null): void; + setMapInitializedListener(listener: OnMapInitializedHandler | null): void; /** - * MarkerManager instance provided by dependency injection. - * Each strategy can provide its own optimized MarkerManager implementation. + * オーバーレイコントローラを登録する。 + * + * 登録したコントローラには `notifyMapCameraPosition` からカメラ変更が伝播し、 + * `destroy()` で一括破棄される。android-sdk の + * `BaseMapViewController.registerOverlayController` に対応する。 */ - readonly markerManager: MarkerManager; - protected readonly defaultMarkerIcon: BitmapIcon; - constructor( + registerOverlayController(controller: OverlayControllerLike): void; + /** 登録を解除する。拡張コンポーネントのアンマウント時に呼ぶ。 */ + unregisterOverlayController(controller: OverlayControllerLike): void; /** - * MarkerManager instance provided by dependency injection. - * Each strategy can provide its own optimized MarkerManager implementation. + * ラスターレイヤを **children の外から**載せる/外す。 + * + * `compositionRasterLayers` は宣言された集合でまるごと置き換えるので、外から + * 足したものは次の再レンダーで消える。{@link RasterLayerController.upsert} は + * その掃除から id を外すので、外から載せたいものはここを通す。 + * + * 呼ぶのは 2 つだけ: マーカータイリングと、ベクタースタイルを描けない + * バックエンドに渡す「スタイルから描いたタイル」。 + * + * **プロバイダ非依存**。raster コントローラはどのプロバイダも + * `registerOverlayController` 済みなので、プロバイダごとの配線は要らない。 + * android-sdk の `BaseMapViewController.mountRasterLayer` と同じ。 */ - markerManager: MarkerManager); - abstract onCameraChanged(cameraPosition: MapCameraPosition, renderer: MarkerOverlayRenderer): Promise; - clear(): void; - onAdd(_params: { - data: MarkerState[]; - viewport: GeoRectBounds; - renderer: MarkerOverlayRenderer; - }): Promise; - onUpdate(_params: { - state: MarkerState; - viewport: GeoRectBounds; - renderer: MarkerOverlayRenderer; - }): Promise; -} - -/** - * Android ColorDefaultIcon / iOS ColorDefaultIcon の名前付きコンストラクタ引数に対応する - * オプション群(fillColor を含む全パラメータを 1 つのオブジェクトで受ける)。 - * `labelTypeFace` は Android の Typeface / iOS の UIFont に対応する Web の font-family 文字列。 - */ -/** - * ラベルをピンに対してどこへ置くか。CSS の `background-position` と同じ書き方で、 - * 横(left / center / right)と縦(top / center / bottom)のキーワードを空白区切りで並べる。 - * 順序は問わず、片方だけなら残りは `center`。 - * - * - `left` / `right` はピンの外側に置き、縦はピンの上端・頭の中心・先端に揃える。 - * - `center` 列の `top` / `bottom` はピンの上・下の外側に置く。 - * - `"center center"`(既定)はピンの頭の中に重ねる(従来どおり)。 - */ -type MarkerLabelHorizontalPosition = "left" | "center" | "right"; -type MarkerLabelVerticalPosition = "top" | "center" | "bottom"; -type MarkerLabelPosition = MarkerLabelHorizontalPosition | MarkerLabelVerticalPosition | `${MarkerLabelHorizontalPosition} ${MarkerLabelVerticalPosition}` | `${MarkerLabelVerticalPosition} ${MarkerLabelHorizontalPosition}`; -/** - * 既定マーカーアイコンの見た目を決めるオプションと、その既定値。 - * - * `BaseIconProperties` は「ピンの形と共通の装飾」を表し、塗り方(単色か画像か)だけが - * サブクラスで変わる。オプションから内部表現へ落とすのが `createBaseProperties`。 - */ -interface DefaultMarkerIconOptions { - fillColor?: string; - strokeColor?: string; - strokeWidth?: number; - scale?: number; - label?: string | null; - labelTextColor?: string | null; - labelTextSize?: number; - labelTypeFace?: string; - labelStrokeColor?: string; - labelPosition?: MarkerLabelPosition; - infoAnchor?: Offset; - iconSize?: number; - debug?: boolean; -} -/** ImageDefaultIcon のコンストラクタ引数。backgroundImage は必須。 */ -interface ImageDefaultIconOptions extends DefaultMarkerIconOptions { - backgroundImage: string | HTMLImageElement; -} -interface BaseIconProperties { - strokeColor: string; - strokeWidth: number; - scale: number; - label: string | null; - labelTextColor: string | null; - labelTextSize: number; - labelTypeFace: string; - labelStrokeColor: string; - labelPosition: MarkerLabelPosition; - infoAnchor: Offset; - iconSize: number; - debug: boolean; -} - -/** - * Android ColorDefaultIcon / iOS ColorDefaultIcon の名前付きコンストラクタ引数に対応する - * オプション群(fillColor を含む全パラメータを 1 つのオブジェクトで受ける)。 - * `labelTypeFace` は Android の Typeface / iOS の UIFont に対応する Web の font-family 文字列。 - */ - -interface IconLayout { - /** マーカー本体の一辺(= iconSize * scale、Android の canvasSize)。 */ - markerSize: number; - /** ビットマップ幅(ラベルが広い場合はマーカーより横に広がる)。 */ - bitmapWidth: number; - /** ビットマップ高さ(= markerSize)。 */ - bitmapHeight: number; - /** ビットマップ内でのマーカー本体の左上。 */ - markerOffsetX: number; - markerOffsetY: number; - /** ラベルのアウトライン幅(Android: max(1*scale, 2))。 */ - outlineStroke: number; - /** ラベル文字列の中心(text-anchor="middle" / dominant-baseline="central" で描く)。 */ - labelCenterX: number; - labelCenterY: number; -} - -/** - * 既定マーカーアイコンの土台。ピン形の輪郭・ラベル・寸法の計算を受け持ち、 - * 中身の塗り方だけをサブクラスに任せる。 - */ -declare abstract class AbstractDefaultIcon extends AbstractMarkerIcon { - protected readonly baseProperties: BaseIconProperties; - private cachedLayout; - protected constructor(baseProperties: BaseIconProperties); + mountRasterLayer(state: RasterLayerState): void; + /** {@link mountRasterLayer} で載せたものを外す。無いものを渡しても何も起きない。 */ + unmountRasterLayer(id: string): void; + private rasterLayerMounts; + private styleLoadedListeners; + /** スタイルが今載っているか。sticky: 聞かれる前に読み終わることがある。 */ + private styleLoaded; /** - * ピン先端の位置(ビットマップに対する割合)。ラベルをピンの横や上に置くと - * ビットマップが片側へ伸びるので、固定の (0.5, 1) ではなくレイアウトから求める。 + * スタイル文書を読み終わるたびに `block` を呼ぶ。既に読み終わっていれば今すぐ 1 回。 + * + * {@link MapViewStyle} が生きたレンダラへ入れたもの(paint プロパティ、足した + * レイヤ)は、レンダラが文書を読み直した時点で全部消える——アプリのベースマップ + * 切替、デザイン変更、プロバイダのビュー作り直し、理由は何であれ。ここが戻し場所。 + * 1 本のリスナーではなく登録簿なのは、ミューテーションストアと設置済みスタイルの + * **両方**が聞く必要があるため。 + * + * そういう概念を持たないプロバイダ(Google Maps、Leaflet…)は + * {@link notifyStyleLoaded} を呼ばない。そこに載ったスタイルはラスター化された + * もので、ベースマップ切替では消えない。 + * + * @returns 聞くのをやめる手形。 */ - get anchor(): Offset; - get strokeColor(): string; - get strokeWidth(): number; - get scale(): number; - get label(): string | null; - get labelTextColor(): string | null; - get labelTextSize(): number; - get labelTypeFace(): string; - get labelStrokeColor(): string; - get labelPosition(): MarkerLabelPosition; - get iconSize(): number; + addStyleLoadedListener(block: () => void): () => void; /** - * 指定値はピン本体に対する割合として扱い、ビットマップに対する割合へ直して返す。 - * ラベルが中央にある限りは指定値と一致する(従来どおり)。 + * スタイルが載り、**かつオーバーレイが戻った**時点でプロバイダが呼ぶ。 + * + * 読み込み開始時ではなく、プロバイダが自分のレイヤを戻す前でもない。作り直しの + * 途中でパッチを当てるリスナーは、これから置き換わるものにパッチを当てることになる。 + * + * android / iOS では protected。web だけ public なのは、GL 系 3 プロバイダで + * `styledata` を聞いているのがコントローラではなく + * `useVectorStyleMutationSupport`(共有フック)だから——呼ぶのは依然として + * 「プロバイダ側の 1 か所」で、その 1 か所がフックに寄っているだけ。 */ - get infoAnchor(): Offset; - get debug(): boolean; - toBitmapIcon(): BitmapIcon; - hashCode(): number; + notifyStyleLoaded(): void; /** - * Android AbstractDefaultIcon.toBitmapIcon() / iOS makeIcon() と同じレイアウト計算。 - * すべてピクセル空間で行い、ラベルが広い場合はビットマップを横に広げてマーカーを - * 中央寄せする(正方形固定にしない)。ラベルの文字サイズはマーカー scale に追従せず - * 絶対値(Android/iOS と同じ)。 + * オーバーレイの探索順。既定は {@link CANONICAL_ORDER}。 * - * `labelPosition` が中央以外なら、ラベルをピンの輪郭の外側へ置き、ビットマップを - * その側へだけ伸ばす。アイコンは不変なので結果は 1 回だけ計算して使い回す。 + * 種別を持たないプロバイダ(TomTom の円とグラウンドイメージはネイティブでは + * どちらも Polygon)でも順序は同じでよい。判定はコアの Manager が行うので、 + * ネイティブの実装がどうであれ結果は他プロバイダと揃う。 */ - protected computeLayout(): IconLayout; - protected createSvg(layout: IconLayout): string; + protected get overlayCascadeOrder(): readonly OverlayKind[]; /** - * ラベル描画。既定では Android drawLabel() と同じく円形部分の中心(markerSize * 0.35)へ - * 通常ウェイト・絶対サイズで配置し(位置は computeLayout が決める)、アウトライン(stroke)を下に敷いてから塗りを重ねる。 + * マーカーのヒットテストと配送。既定は「当たらない」。 + * + * マーカーだけコアが既定を持たないのは、判定に画面投影が要り、プロバイダごとに + * マーカーの実装方式(DOM 要素 / シンボルレイヤ / タイル)が違うため。 + * + * @returns マーカーがタップを消費したら true。 + */ + protected dispatchMarkerTap(_position: GeoPoint): boolean; + /** + * オーバーレイ(マーカー以外)のタップを、正準順に 1 つだけ配送する。 + * + * マーカーを含まないので、ネイティブのオーバーレイクリックリスナーから + * 呼ぶこともできる。 + * + * @returns 何かに当たって配送したら true。 + */ + dispatchOverlayTap(position: GeoPoint): boolean; + /** + * タップの入口。marker → オーバーレイ → 地図クリックの順に 1 つだけ配送する。 + * + * プロバイダは SDK のタップを地理座標に直してこれを呼ぶだけでよい。 + * + * **必ずどれか 1 つだけ**が配送される。オーバーレイに当たったのに地図クリックも + * 飛ぶ、という二重配送を防ぐのがこの関数の役目。 + * + * @returns 常に true(タップは必ずどこかで処理される)。 */ - protected createLabelSvg(layout: IconLayout): string; - protected abstract createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number, markerOffsetY: number): string; - protected abstract getUniqueProperties(): unknown; -} - -/** - * 単色で塗る既定アイコン。`DefaultMarkerIcon` はこれの別名。 - */ -declare class ColorDefaultIcon extends AbstractDefaultIcon { - readonly fillColor: string; - constructor(options?: DefaultMarkerIconOptions); - copy(options?: DefaultMarkerIconOptions): ColorDefaultIcon; - protected createFillSvg(markerPath: string): string; - protected getUniqueProperties(): unknown; -} -/** - * ピンの中に画像を敷く既定アイコン。 - */ -declare class ImageDefaultIcon extends AbstractDefaultIcon { - readonly backgroundImage: string | HTMLImageElement; - constructor(options: ImageDefaultIconOptions); - copy(options?: Partial): ImageDefaultIcon; + dispatchTap(position: GeoPoint): boolean; + /** 地図クリックをアプリへ通知する。 */ + emitMapClick(point: GeoPoint): void; + /** 地図の長押しをアプリへ通知する。 */ + emitMapLongClick(point: GeoPoint): void; /** - * ロード済みの HTMLImageElement が渡された場合は Canvas で描画して PNG data URL を返す。 - * SVG data URL は「SVG as image」サンドボックスにより内部の が - * ブロックされるため、外部 URL を参照する場合は必ず Canvas パスを使用する。 + * この種別の**主**コントローラ(最初に登録されたもの)。 + * + * `compositionXxx` / `updateXxx` はここへ流す。クラスタリングは同じ marker 種別で + * 追加のコントローラを登録するが、composition の受け口は最初の 1 つでよい + * (追加分はクラスタリング側が自分で駆動する)。 */ - toBitmapIcon(): BitmapIcon; - private toCanvasBitmapIcon; - protected createFillSvg(markerPath: string, markerSize: number, markerOffsetX: number, markerOffsetY: number): string; - protected getUniqueProperties(): unknown; - private imageSourceUrl; -} -type DefaultMarkerIcon = ColorDefaultIcon; -declare const DefaultMarkerIcon: typeof ColorDefaultIcon; -declare const createDefaultIcon: () => DefaultMarkerIcon; -declare const hashDefaultMarkerIcon: (icon: DefaultMarkerIcon) => number; - -interface MarkerCapable { + protected primaryOverlayController(kind: OverlayKind): SlottedOverlayController | null; + /** この種別に登録されたすべてのコントローラ。`hasXxx` は「どれかが持っていれば true」。 */ + protected overlayControllersOf(kind: OverlayKind): SlottedOverlayController[]; + /** `kind` のいずれかのコントローラがこの id を持っているか。`hasXxx` の既定実装。 */ + protected hasOverlay(kind: OverlayKind, id: string): boolean; + protected compositionOverlays(kind: OverlayKind, data: unknown[]): Promise; + protected updateOverlay(kind: OverlayKind, state: unknown): Promise; + protected setOverlayClickListener(kind: OverlayKind, listener: unknown): void; compositionMarkers(data: MarkerState[]): Promise; updateMarker(state: MarkerState): Promise; - setOnMarkerDragStart(listener: OnMarkerEventHandler | null): void; - setOnMarkerDrag(listener: OnMarkerEventHandler | null): void; - setOnMarkerDragEnd(listener: OnMarkerEventHandler | null): void; - setOnMarkerAnimateStart(listener: OnMarkerEventHandler | null): void; - setOnMarkerAnimateEnd(listener: OnMarkerEventHandler | null): void; - setOnMarkerClickListener(listener: OnMarkerEventHandler | null): void; - /** Route marker animations (Drop/Bounce) to a screen-space overlay instead of geo-interpolation. */ - setMarkerAnimationOverlayHost(host: MarkerAnimationOverlayHost | null): void; hasMarker(state: MarkerState): boolean; -} - -/** 1回の renderer 呼び出しで扱うマーカー数。android-sdk の MARKER_RENDER_BATCH_SIZE と同値。 */ -declare const MARKER_RENDER_BATCH_SIZE = 500; -interface MarkerIngestionResult { - /** タイル描画対象の集合が変化したか(タイルの再生成が要る)。 */ - tiledDataChanged: boolean; - /** 現在タイル描画中のマーカーが1件以上あるか。 */ - hasTiledMarkers: boolean; + compositionPolylines(data: PolylineState[]): Promise; + updatePolyline(state: PolylineState): Promise; + hasPolyline(state: PolylineState): boolean; + /** @deprecated Use PolylineState.onClick instead. */ + setOnPolylineClickListener(listener: OnPolylineEventHandler | null): void; + compositionPolygons(data: PolygonState[]): Promise; + updatePolygon(state: PolygonState): Promise; + hasPolygon(state: PolygonState): boolean; + /** @deprecated Use PolygonState.onClick instead. */ + setOnPolygonClickListener(listener: OnPolygonEventHandler | null): void; + compositionCircles(data: CircleState[]): Promise; + updateCircle(state: CircleState): Promise; + hasCircle(state: CircleState): boolean; + /** @deprecated Use CircleState.onClick instead. */ + setOnCircleClickListener(listener: OnCircleEventHandler | null): void; + compositionGroundImages(data: GroundImageState[]): Promise; + updateGroundImage(state: GroundImageState): Promise; + hasGroundImage(state: GroundImageState): boolean; + /** @deprecated Use GroundImageState.onClick instead. */ + setOnGroundImageClickListener(listener: OnGroundImageEventHandler | null): void; + compositionRasterLayers(data: RasterLayerState[]): Promise; + updateRasterLayer(state: RasterLayerState): Promise; + hasRasterLayer(state: RasterLayerState): boolean; + protected notifyCameraMoveStart(camera: MapCameraPosition): void; + protected notifyCameraMove(camera: MapCameraPosition): void; + protected notifyCameraMoveEnd(camera: MapCameraPosition): void; /** - * アニメーションを開始すべきエンティティ。 + * カメラ停止時に制限違反があれば補正位置へ移動し直す。 * - * android-sdk の engine は内部で `renderer.onAnimate` を直接呼ぶが、こちらは呼ばずに返す。 - * React 実装はロックの外で `Promise.all` して同時再生させる必要があるため - * (スクリーン空間オーバーレイの `onAnimate` はアニメーション終了時に解決するので、 - * ロック内で await すると他のマーカー操作をブロックし、アニメーション同士も直列化する)。 + * ネイティブの範囲制限 API を持たないプロバイダ(HERE / ArcGIS / MapKit / Cesium / + * OpenLayers / Longdo)はこの経路で制限が効く。ネイティブ API を持つプロバイダは + * `setCameraRestriction` をオーバーライドして `super` を呼ばないため、 + * ここでは何も起きない(android-sdk と同じ振り分け)。 + * + * 補正したら true。ε による不感帯があるので、再適用 → moveend → 再補正 のループにはならない。 */ - entitiesToAnimate: MarkerEntity[]; -} -/** - * マーカー取り込みの共有ロジック。 - * - * 受け取った `MarkerState` の一覧を `MarkerManager` の現在状態と差分比較し、 - * `MarkerOverlayRenderer` を駆動して `MarkerManager` を更新する。 - * - * プロバイダ固有のコントローラが差し込めるのは: - * - 「タイル描画にするか、プロバイダのネイティブマーカーにするか」の判定(`shouldTile`) - * - タイル済み ID の保持先(`tiledMarkerIds`) - * - * タイルオーバーレイそのもの(RasterLayer の state、キャッシュバスティング等)は - * 引き続きコントローラ側の責務。 - * - * android-sdk の `MarkerIngestionEngine.ingest` の移植。差分は上記 `entitiesToAnimate` と、 - * android-sdk が engine の外に置いているバッチ分割をこちらは engine 内に持つ点 - * (React 側は `AbstractMarkerController` に非タイル経路が別に無いため)。 - */ -declare function ingestMarkers(params: { - data: MarkerState[]; - markerManager: MarkerManager; - renderer: MarkerOverlayRenderer; - defaultMarkerIcon: BitmapIcon; - tilingEnabled: boolean; - tiledMarkerIds: Set; - shouldTile: (state: MarkerState) => boolean; - onMarkerAdded?: (entity: MarkerEntity) => void; -}): Promise>; - -/** - * Bridge between the React-side MarkerState collector and a MarkerCapable map controller. - * Mirrors `MarkerOverlay.kt` in the Android SDK. - * - * Register an instance of this class with `MapOverlayRegistry` so that whenever - * the marker collection changes, the controller's `compositionMarkers()` is called. - */ -declare class MarkerOverlay implements MapOverlayInterface { - private readonly collector; - constructor(collector: OverlayCollector); - subscribe(fn: (data: ReadonlyMap) => void): () => void; - render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; -} - -/** - * Marker renderer backed by a map's marker collector. - * - * `MarkerRenderingStrategy` implementations (marker clustering など) only ever talk - * to `MarkerOverlayRenderer` — the same contract `MarkerClusterStrategy.kt` uses on - * Android — and the platform decides how the resulting markers reach the map. - * Web では各 `react-for-*` がこのレンダラを `MarkerRenderingSupport.createMarkerRenderer` - * から返す。コレクタから先はプロバイダ通常のマーカー経路がそのまま処理するので、 - * プロバイダごとの実装差は要らない(android-sdk / ios-sdk はネイティブのマーカー型が - * プロバイダごとに違うため、そこで初めて実装が分かれる)。 - * - * Add / change / remove are staged and committed as a single `applyDiff()` in - * `onPostProcess()` — the same commit point Android uses. Notifying per marker - * instead would re-run the provider's entire marker composition once per marker, - * which reconstructs the tile renderer thousands of times on large datasets. - * - * `ActualMarker` is `MarkerState`: the collector is the "native" marker layer - * here, so a rendered marker is identified by the state that was written to it. - */ -declare class CollectorMarkerOverlayRenderer implements MarkerOverlayRenderer { - private readonly collector; - readonly holder: MapViewHolder | undefined; - animateStartListener: OnMarkerEventHandler | null; - animateEndListener: OnMarkerEventHandler | null; - animationOverlayHost: MarkerAnimationOverlayHost | null; - private readonly pendingUpserts; - private readonly pendingRemoveIds; - /** Ids currently written to the collector, so unmount can take them all back. */ - private readonly renderedIds; - constructor(collector: OverlayCollector, holder: MapViewHolder | undefined); - onAdd(data: AddParams[]): Promise<(MarkerState | null)[]>; - onChange(data: ChangeParams[]): Promise<(MarkerState | null)[]>; - onRemove(data: MarkerEntity[]): Promise; + protected applyCameraRestrictionOnIdle(camera: MapCameraPosition): boolean; + protected notifyMapClick(point: GeoPoint): void; + protected notifyMapLongClick(point: GeoPoint): void; /** - * Animation is driven by the strategy (it interpolates positions and calls - * `onChange()` per frame), exactly as on Android — there is nothing for the - * renderer to play back on its own. + * マップ初期化を記録し、リスナーが揃った時点で1回だけ配送する。 + * + * プロバイダのコントローラは、React 側の effect がリスナーを張り終える前に初期化を + * 完了させることがある(effect はマウント後に走るため)。sticky にしておくことで、 + * その隙間で初期化完了が取りこぼされるのを防ぐ。 + * android-sdk の `notifyMapInitialized` / `deliverMapInitializedCallbackIfReady` の移植。 */ - onAnimate(_entity: MarkerEntity): Promise; - onPostProcess(): Promise; - setMarkerVisible(_entity: MarkerEntity, _visible: boolean): void; - /** Drops every marker this renderer wrote. Used when the group unmounts. */ - reset(): void; - private stage; -} - -/** - * Strategy-driven marker controller. - * - * Mirrors `StrategyMarkerController.kt` (android-sdk-core) / - * `StrategyMarkerController.swift` (ios-sdk-core): camera bounds are remembered - * via `lastKnownBounds` so add/update keep working between camera events, states - * received before the first camera fix are queued in `pendingStates`, and - * `find()` hit-tests the tapped point against the nearest marker's icon bounds - * with `Settings.Default.tapTolerance`. - */ -declare class StrategyMarkerController implements SlottedOverlayController, OverlayController, MarkerState> { - readonly markerManager: MarkerManager; - readonly zIndex: number; - private mapCameraPosition; - private lastKnownBounds; - private readonly semaphore; - private pendingStates; - dragStartListener: OnMarkerEventHandler | null; - dragListener: OnMarkerEventHandler | null; - dragEndListener: OnMarkerEventHandler | null; - animateStartListener: OnMarkerEventHandler | null; - animateEndListener: OnMarkerEventHandler | null; - private readonly strategy; - private readonly renderer; - clickListener: OnMarkerEventHandler | null; - constructor({ strategy, renderer, clickListener, }: { - strategy: MarkerRenderingStrategy; - renderer: MarkerOverlayRenderer; - clickListener?: OnMarkerEventHandler | null; - }); - dispatchClick(state: MarkerState): void; - dispatchDragStart(state: MarkerState): void; - dispatchDrag(state: MarkerState): void; - dispatchDragEnd(state: MarkerState): void; - dispatchAnimateStart(state: MarkerState): void; - dispatchAnimateEnd(state: MarkerState): void; - add(data: MarkerState[]): Promise; - update(state: MarkerState): Promise; - clear(): Promise; - getEntity(id: string): MarkerEntity | null; - find(position: GeoPoint): MarkerEntity | null; - /** `undefined` means the holder projects asynchronously (no sync value available). */ - private resolveSyncOffset; - onCameraChanged(mapCameraPosition: MapCameraPosition): Promise; + protected notifyMapInitialized(): void; + private deliverMapInitializedCallbackIfReady; + /** + * カメラ変更を登録済みオーバーレイコントローラへ伝播し、続けて利用者コールバックを呼ぶ。 + * + * android-sdk の `notifyMapCameraPosition` に対応する。プロバイダが個別に + * `Promise.all([...onCameraChanged])` を書く必要がなくなり、拡張機能 + * (marker-clustering 等)も自前でリスナースロットを奪わずにカメラ変更を受け取れる。 + */ + protected notifyMapCameraPosition(camera: MapCameraPosition): Promise; destroy(): void; - readonly kind: OverlayKind; - hasId(id: string): boolean; - compositionAny(data: unknown[]): Promise; - updateAny(state: unknown): Promise; - setClickListenerAny(listener: unknown): void; - /** マーカーは別経路(`dispatchMarkerTap`)。ここでは当たらない。 */ - resolveTap(_position: GeoPointInterface): OverlayHit | null; } -interface MarkerEventController { - readonly controller?: StrategyMarkerController; - readonly renderer?: MarkerOverlayRenderer; -} /** - * マーカー描画プラグイン(marker-clustering など)が、マップコントローラ自身に - * プラグインのインタフェースを実装させずにレンダラ/コントローラを作るための - * map スコープの capability。 + * What a map offers the style installed on it, and the bookkeeping that + * keeps it installed. * - * android-sdk の `MarkerRenderingSupport.kt` / ios-sdk の `MarkerRenderingSupport.swift` - * と同じ契約。プロバイダが {@link MarkerRenderingSupportKey} で - * {@link MutableMapServiceRegistry} に登録し、プラグインがそれを解決する。 - */ -interface MarkerRenderingSupport { - createMarkerRenderer(strategy: MarkerRenderingStrategy): MarkerOverlayRenderer; - createMarkerEventController(controller: StrategyMarkerController, renderer: MarkerOverlayRenderer): MarkerEventController; - registerMarkerEventController(controller: MarkerEventController): void; - mapLoadedState?: { - value: boolean; - } | null; - onMarkerRenderingReady?(): void; -} -/** - * 型引数を消した {@link MarkerRenderingSupport}。レジストリのキーは 1 つなので、 - * `ActualMarker` の異なる実装を同じキーで出し入れするためにこの別名を使う。 - * ios-sdk の `AnyMarkerRenderingSupport` と同じ役割。 - */ -type AnyMarkerRenderingSupport = MarkerRenderingSupport; -/** - * {@link MarkerRenderingSupport} をレジストリから引くためのキー。 - * android-sdk の `object MarkerRenderingSupportKey : MapServiceKey>`、 - * ios-sdk の `enum MarkerRenderingSupportKey: MapServiceKey` に対応する。 - */ -declare const MarkerRenderingSupportKey: MapServiceKey; -/** - * web の各 `react-for-*` が登録する既定の {@link MarkerRenderingSupport}。 + * One class for every provider. Everything it needs that differs per + * backend is reached through seams every provider already fills in — the + * service registry for what the map can do, the controller's overlay + * controllers for mounting tiles, the controller's style-loaded listeners + * for putting an adjustment back after a reload. Adding a provider needs + * nothing here. * - * android-sdk / ios-sdk ではプロバイダごとにネイティブのマーカー型が違うため実装も - * 分かれるが、web ではクラスタ結果が `MarkerState` のままマーカーコレクタへ入り、 - * そこから先はプロバイダ通常のマーカー経路が処理する。つまり全プロバイダで同じ実装で - * 足りるので、重複を避けてここに 1 つだけ置く。別の描画経路が要るプロバイダは、 - * 自前の {@link MarkerRenderingSupport} を登録すればよい。 + * android-sdk's `MapViewStyleEffect` and ios-sdk's `MapViewStyleHost` do the + * same work; the shape differs because each framework owns its lifecycle + * differently. */ -declare function createCollectorMarkerRenderingSupport(params: { - collector: OverlayCollector; - holder: MapViewHolder | undefined; - /** 地図の準備完了状態。クラスタリング開始の合図に使う。 */ - mapLoadedState?: { - value: boolean; - } | null; - /** 描画準備が整った直後に呼ばれる。初期カメラをオーバーレイへ配る等に使う。 */ - onMarkerRenderingReady?: () => void; -}): MarkerRenderingSupport; +declare class MapViewStyleHost implements MapStyleHost { + readonly serviceRegistry: MapServiceRegistry; + private readonly controller; + private installation; + private installedKey; + private diagnosticsHandler; + /** How many updates in a row the installed style has refused. See {@link apply}. */ + private refusedUpdates; + constructor(serviceRegistry: MapServiceRegistry, controller: BaseMapViewController); + /** + * Keeps `style` installed on this map. + * + * Called on every render that reaches the hook, so most calls do + * nothing. Three things have to line up and none of them is the render: + * + * - **A style whose key did not change must be left alone.** A style + * built inline in JSX is a new object on every render — which is + * exactly what a colour picker produces — so comparing objects would + * reinstall on every unrelated state change. + * - **A changed style is usually an adjustment, not a new style.** + * Disposing and installing again hands the map a document and reloads + * it, which is the cost this whole design exists to avoid. What is + * installed is offered the new one first and only replaced if it + * refuses. + * - **Null means take it off.** An app that stops passing a style gets + * the map it had before, not the one the style left behind. + */ + apply(style: MapViewStyle | null | undefined, onDiagnostics?: ((diagnostics: readonly string[]) => void) | null): void; + /** Takes the style off. The hook calls this when the view goes. */ + dispose(): void; + get tileServer(): LocalTileServer; + get vectorStyleUrl(): string | undefined; + onStyleLoaded(block: () => void): MapStyleInstallation; + upsertRaster(state: RasterLayerState): void; + removeRaster(id: string): void; + report(diagnostics: readonly string[]): void; +} /** - * Options for marker tiling optimization. + * The part of a GL-JS map that a style mutation needs. * - * When enabled, large sets of static markers can be rendered as tile overlays - * to avoid per-marker add/update cost in native map SDKs. - * Mirrors `MarkerTilingOptions` from `MarkerTilingOptions.kt`. + * Structural on purpose. MapLibre GL JS, Mapbox GL JS and the MapTiler SDK + * (which wraps MapLibre) all expose these under the same names, so one + * applier serves three providers — and core stays free of all three, which + * it has to be: every package in the SDK depends on core and none of them + * should pull a renderer in with it. */ -interface MarkerTilingOptions { - readonly enabled: boolean; - readonly debugTileOverlay: boolean; - readonly minMarkerCount: number; - readonly cacheSize: number; +interface StylePatchableMap { /** - * Per-marker icon scale multiplier applied during tile rendering. - * `(zoom: number, state: MarkerState) => number` + * Whether the map has a style up to be changed. + * + * **False is not failure, and must not be reported as one.** A map with + * no style loaded yet answers `undefined` to every `getLayer`, so + * without this every mutation came back as one the map "cannot" make — + * which is how an app got told "N adjustments were not applied" about a + * map that was merely still loading, and, under the reloading policy, + * had the adjusted document handed to it for no reason. Nothing is lost + * by waiting: the store puts the whole set back when the style loads. + * + * Optional because it is read structurally off whatever map was handed + * in; all three GL-JS-family SDKs have it. + * + * `boolean | void` because MapLibre answers nothing at all before the + * style object exists — which is precisely the case this is here for, + * so the result is read as falsy rather than compared to `false`. */ - readonly iconScaleCallback: ((state: MarkerState, zoom: number) => number) | null; -} -declare namespace MarkerTilingOptions { - const Default: MarkerTilingOptions; - const Disabled: MarkerTilingOptions; + isStyleLoaded?(): boolean | void; + getLayer(id: string): unknown; + setPaintProperty(layerId: string, name: string, value: unknown): void; + setLayoutProperty(layerId: string, name: string, value: unknown): void; + setFilter(layerId: string, filter?: unknown): void; + setLayerZoomRange(layerId: string, minzoom: number, maxzoom: number): void; + addLayer(layer: unknown, beforeId?: string): void; + removeLayer(id: string): void; } - /** - * Abstract base for marker rendering strategies that use viewport-based optimization. - * Only markers within the current camera viewport are rendered; out-of-viewport - * markers are tracked in the manager but not submitted to the renderer. - * Mirrors `AbstractViewportStrategy` from `AbstractViewportStrategy.kt`. + * Applies style mutations to a live GL-JS map. + * + * The body of a provider's {@link VectorStyleMutationSupport}: a provider + * wires this into a `StyleMutationStore` and registers that, and the three + * MapLibre-family providers then behave identically by construction. + * + * @returns the mutations it could not apply. A layer the loaded style does + * not have, or a property the renderer rejected: the caller is told + * rather than left to assume the map looks the way the rules asked. + * Nothing is thrown — one bad property must not cost the other three + * hundred. Empty while the map has no style up, because then there is + * nothing it could not do — only something it has not done yet. */ -declare abstract class AbstractViewportStrategy extends AbstractMarkerRenderingStrategy { - readonly markerManager: MarkerManager; - constructor({ geocell, minMarkerCount, }: { - geocell: HexGeocell; - minMarkerCount?: number; - }); - onAdd({ data, viewport, renderer, }: { - data: MarkerState[]; - viewport: GeoRectBounds; - renderer: MarkerOverlayRenderer; - }): Promise; - onUpdate({ state, viewport, renderer, }: { - state: MarkerState; - viewport: GeoRectBounds; - renderer: MarkerOverlayRenderer; - }): Promise; - clear(): void; -} - -/** Uniform lat/lng grid used to cull the candidate set per tile without scanning every marker. */ -declare class GeoGridIndex { - private readonly items; - private readonly cells; - private static readonly CELL_DEG; - private static readonly MAX_CELLS_PER_QUERY; - constructor(items: ReadonlyArray); - private cellKey; - /** Items whose position falls within the given padded lat/lng bounds. */ - queryBounds(south: number, north: number, west: number, east: number): T[]; -} - -/** Decodes BitmapIcon.url (a self-contained data: URL) into a drawable image, with dedup + caching. */ -declare class IconImageCache { - private readonly ready; - private readonly pending; - private readonly failed; - private static readonly MAX_READY_ENTRIES; - private static readonly MAX_FAILED_ENTRIES; - get(url: string): ImageBitmap | HTMLImageElement | undefined; - /** Decode (or return the in-flight/cached decode of) an icon image. Never throws. */ - ensure(url: string): Promise; - private evictReadyIfNeeded; - private addFailed; - private decode; -} - -declare const MARKER_HIT_RADIUS_TOUCH_PX = 20; -declare const MARKER_HIT_RADIUS_MOUSE_PX = 6; -/** A marker resolved to its decoded image and tile-local draw geometry, ready for drawImage(). */ -interface PreparedMarker { - image: ImageBitmap | HTMLImageElement; - centerNormX: number; - centerNormY: number; - drawW: number; - drawH: number; - anchorX: number; - anchorY: number; -} +declare function applyStyleMutations(map: StylePatchableMap, mutations: readonly StyleMutation[]): StyleMutation[]; +/** For a message naming what would not apply. */ +declare function describeStyleMutations(mutations: readonly StyleMutation[]): string; -interface TileRequest { - readonly x: number; - readonly y: number; - readonly z: number; +interface CircleEntity { + circle: ActualCircle; + readonly state: CircleState; + readonly fingerPrint: CircleFingerPrint; } +declare const createCircleEntity: (params: { + circle: ActualCircle; + state: CircleState; +}) => CircleEntity; -interface TileProvider { - renderTile(request: TileRequest): Uint8Array | null | Promise; +interface CircleAddParams { + readonly state: CircleState; } - -/** - * Canvas-based tile renderer for large marker datasets. - * - * Mirrors Android/iOS MarkerTileRenderer: renders each marker's actual icon - * (not a placeholder) onto Canvas tiles served via LocalTileServer, enabling - * tens of thousands of markers without creating individual DOM elements. - * A lat/lng grid index keeps per-tile queries proportional to the markers - * actually near that tile instead of scanning the whole dataset. - * - * Also provides `findNearest()` for hit-testing click/tap events against the same - * marker set, with an adaptive radius for touch vs. mouse input. - */ -declare class MarkerTileRenderer implements TileProvider { - private readonly items; - readonly tileSize: number; - readonly extraIconScale: number; - private readonly iconScale; - private readonly debugTileOverlay; - private readonly grid; - private readonly icons; - private readonly defaultBitmapIcon; - /** Per-instance tile output cache; each sync creates a fresh renderer instance, so no invalidation is needed. */ - private readonly tileCache; - constructor(items: ReadonlyArray, tileSize?: number, iconScaleCallback?: (item: T, zoom: number) => number, extraIconScale?: number, debugTileOverlay?: boolean); - private bitmapIconOf; - /** Decode every unique icon up front. Call before relying on the synchronous renderTileDataUrl() path. */ - preloadIcons(): Promise; - private tileKey; - private cacheTile; - private queryCandidates; - private prepareMarkers; - /** Kick off (but don't await) decoding for any candidate not yet in the icon cache. */ - private warmIcons; - private createCanvas; - private draw; - private drawDebugOverlay; - private canvasToBytes; - renderTile(req: TileRequest): Promise; - /** - * Synchronous best-effort render for APIs that require a data: URL - * immediately (e.g. Google Maps ImageMapType.getTileUrl). Markers whose - * icon hasn't finished decoding yet are skipped for this call and - * decoded in the background — call `preloadIcons()` first to avoid this - * gap on first paint. - */ - renderTileDataUrl(req: TileRequest): string | null; - /** - * Serialize items + deduped icon bitmaps for transfer to the Service - * Worker, so tiles can be rendered with OffscreenCanvas without a - * main-thread round-trip. Icons are decoded (async) and deduplicated by - * BitmapIcon.url so identical icons are only transferred once. - * - * `zoomScales` mirrors the original design: computed from a representative - * item, since `iconScaleCallback` is usually zoom-only in practice. Items - * whose icon could not be decoded to an ImageBitmap (SW contexts have no - * ) fall back to the default icon's index rather than being dropped, - * so they still render (as the default icon) instead of silently vanishing. - * - * The returned bitmaps belong to the caller, who must release them with - * `closeSWIcons()` once they have been posted. `sendSWRegisterAndWait()` - * already does this, so the usual one-line call site needs nothing extra. - */ - toSWData(): Promise<{ - items: { - lat: number; - lng: number; - iconIndex: number; - }[]; - icons: { - bitmap: ImageBitmap; - anchor: { - x: number; - y: number; - }; - size: { - width: number; - height: number; - }; - }[]; - zoomScales: number[]; - extraIconScale: number; - }>; - /** - * Find the item under `click` at the given zoom level, or the nearest one within `hitRadiusPx`. - * - * Use `MARKER_HIT_RADIUS_TOUCH_PX` for finger taps and `MARKER_HIT_RADIUS_MOUSE_PX` - * for mouse/pen, or supply a custom radius. Returns `null` when nothing is close enough. - */ - findNearest(click: GeoPoint, hitRadiusPx: number, zoom: number): T | null; +interface CircleChangeParams { + readonly current: CircleEntity; + readonly prev: CircleEntity; +} +interface CircleOverlayRenderer { + onAdd(data: CircleAddParams[]): Promise<(ActualCircle | null)[]>; + onChange(data: CircleChangeParams[]): Promise<(ActualCircle | null)[]>; + onRemove(data: CircleEntity[]): Promise; + onPostProcess(): Promise; } -interface PolygonFingerPrint { - id: number; - strokeColor: number; - strokeWidth: number; - fillColor: number; - geodesic: number; - zIndex: number; - points: number; - holes: number; - extra: number; +declare abstract class AbstractCircleOverlayRenderer, ActualCircle> implements CircleOverlayRenderer { + readonly holder: MapViewHolderType; + constructor(holder: MapViewHolderType); + abstract removeCircle(entity: CircleEntity): Promise; + abstract createCircle(state: CircleState): Promise; + abstract updateCircleProperties(params: { + circle: ActualCircle; + current: CircleEntity; + prev: CircleEntity; + }): Promise; + onAdd(data: CircleAddParams[]): Promise<(ActualCircle | null)[]>; + onChange(data: CircleChangeParams[]): Promise<(ActualCircle | null)[]>; + onRemove(data: CircleEntity[]): Promise; + onPostProcess(): Promise; } -interface PolygonEvent { - state: PolygonState; - clicked: GeoPoint; + +interface CircleCapable { + compositionCircles(data: CircleState[]): Promise; + updateCircle(state: CircleState): Promise; + /** @deprecated Use CircleState.onClick instead. */ + setOnCircleClickListener(listener: OnCircleEventHandler | null): void; + hasCircle(state: CircleState): boolean; } -type OnPolygonEventHandler = (event: PolygonEvent) => void; -interface PolygonState { - readonly id: string; - points: GeoPoint[]; - holes: GeoPoint[][]; - strokeColor: string; - strokeWidth: number; - fillColor: string; - geodesic: boolean; - zIndex: number; - extra: Serializable | null; - onClick: OnPolygonEventHandler | null; - fingerPrint(): PolygonFingerPrint; - copy(opts?: PolygonStateCopyParams): PolygonState; - equals(other: unknown): boolean; - hashCode(): number; - asObservable(): { - subscribe: (fn: (fp: PolygonFingerPrint) => void) => () => void; - }; + +interface CircleManagerInterface { + registerEntity(entity: CircleEntity): void; + removeEntity(id: string): CircleEntity | null; + getEntity(id: string): CircleEntity | null; + hasEntity(id: string): boolean; + allEntities(): CircleEntity[]; + clear(): void; + find(position: GeoPoint): CircleEntity | null; + updateEntity(entity: CircleEntity): void; } -interface PolygonStateCopyParams { - points?: GeoPoint[]; - holes?: GeoPoint[][]; - id?: string | null; - strokeColor?: string; - strokeWidth?: number; - fillColor?: string; - geodesic?: boolean; - zIndex?: number; - extra?: Serializable | null; - onClick?: OnPolygonEventHandler | null; +declare class CircleManager implements CircleManagerInterface { + private readonly entities; + getEntity(id: string): CircleEntity | null; + hasEntity(id: string): boolean; + removeEntity(id: string): CircleEntity | null; + registerEntity(entity: CircleEntity): void; + updateEntity(entity: CircleEntity): void; + allEntities(): CircleEntity[]; + clear(): void; + find(position: GeoPoint): CircleEntity | null; +} + +declare abstract class CircleController implements SlottedOverlayController, OverlayController, CircleEvent> { + readonly zIndex: number; + readonly circleManager: CircleManagerInterface; + readonly renderer: CircleOverlayRenderer; + clickListener: OnCircleEventHandler | null; + private semaphore; + constructor({ circleManager, renderer, clickListener, }: { + circleManager: CircleManagerInterface; + renderer: CircleOverlayRenderer; + clickListener?: OnCircleEventHandler | null; + }); + dispatchClick(event: CircleEvent): void; + composition(data: CircleState[]): Promise; + has(state: CircleState): boolean; + setOnClickListener(listener: OnCircleEventHandler | null): void; + add(data: CircleState[]): Promise; + update(state: CircleState): Promise; + clear(): Promise; + find(position: GeoPoint): CircleEntity | null; + onCameraChanged(_mapCameraPosition: MapCameraPosition): Promise; + destroy(): void; + readonly kind: OverlayKind; + hasId(id: string): boolean; + compositionAny(data: unknown[]): Promise; + updateAny(state: unknown): Promise; + setClickListenerAny(listener: unknown): void; + resolveTap(position: GeoPointInterface): OverlayHit | null; +} + +/** + * Bridge between the React-side CircleState collector and a CircleCapable map controller. + * Mirrors `CircleOverlay.kt` in the Android SDK. + * + * Register an instance of this class with `MapOverlayRegistry` so that whenever + * the circle collection changes, the controller's `compositionCircles()` is called. + */ +declare class CircleOverlay implements MapOverlayInterface { + private readonly collector; + constructor(collector: OverlayCollector); + subscribe(fn: (data: ReadonlyMap) => void): () => void; + render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; } -declare function createPolygonState(params: { - points: GeoPoint[]; - holes?: GeoPoint[][]; - id?: string | null; - strokeColor?: string; - strokeWidth?: number; - fillColor?: string; - geodesic?: boolean; - zIndex?: number; - extra?: Serializable | null; - onClick?: OnPolygonEventHandler | null; -}): PolygonState; interface PolygonEntity { readonly polygon: ActualPolygon; @@ -2997,58 +4302,6 @@ declare function unionHolesInPlace(state: PolygonState): PolygonState; */ declare function resolveHoles(state: PolygonState): PolygonState; -interface PolylineFingerPrint { - id: number; - strokeColor: number; - strokeWidth: number; - geodesic: number; - zIndex: number; - points: number; - extra: number; -} -interface PolylineEvent { - state: PolylineState; - clicked: GeoPoint; -} -type OnPolylineEventHandler = (event: PolylineEvent) => void; -interface PolylineState { - readonly id: string; - points: GeoPoint[]; - strokeColor: string; - strokeWidth: number; - geodesic: boolean; - zIndex: number; - extra: Serializable | null; - onClick: OnPolylineEventHandler | null; - fingerPrint(): PolylineFingerPrint; - copy(opts?: PolylineStateCopyParams): PolylineState; - equals(other: unknown): boolean; - hashCode(): number; - asObservable(): { - subscribe: (fn: (fp: PolylineFingerPrint) => void) => () => void; - }; -} -interface PolylineStateCopyParams { - points?: GeoPoint[]; - id?: string | null; - strokeColor?: string; - strokeWidth?: number; - geodesic?: boolean; - zIndex?: number; - extra?: Serializable | null; - onClick?: OnPolylineEventHandler | null; -} -declare function createPolylineState(params: { - points: GeoPoint[]; - id?: string | null; - strokeColor?: string; - strokeWidth?: number; - geodesic?: boolean; - zIndex?: number; - extra?: Serializable | null; - onClick?: OnPolylineEventHandler | null; -}): PolylineState; - interface PolylineEntity { readonly polyline: ActualPolyline; readonly state: PolylineState; @@ -3154,74 +4407,25 @@ declare abstract class PolylineController implements SlottedOver setClickListenerAny(listener: unknown): void; /** * ポリラインだけ配送座標がタップ点ではない。**線上の最近傍点**を返す。 - * 線の上をきっかりタップすることはないので、タップ点をそのまま返すと - * 線から外れた座標がアプリへ渡る。3 プラットフォーム共通の既存契約。 - */ - resolveTap(position: GeoPointInterface): OverlayHit | null; -} - -/** - * Bridge between the React-side PolylineState collector and a PolylineCapable map controller. - * Mirrors `PolylineOverlay.kt` in the Android SDK. - * - * Register an instance of this class with `MapOverlayRegistry` so that whenever - * the polyline collection changes, the controller's `compositionPolylines()` is called. - */ -declare class PolylineOverlay implements MapOverlayInterface { - private readonly collector; - constructor(collector: OverlayCollector); - subscribe(fn: (data: ReadonlyMap) => void): () => void; - render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; -} - -interface GroundImageFingerPrint { - id: number; - bounds: number; - imageUrl: number; - opacity: number; - tileSize: number; - extra: number; -} -interface GroundImageEvent { - state: GroundImageState; - clicked: GeoPoint | null; -} -type OnGroundImageEventHandler = (event: GroundImageEvent) => void; -interface GroundImageState { - readonly id: string; - bounds: GeoRectBounds; - imageUrl: string; - opacity: number; - tileSize: number; - extra: Serializable | null; - onClick: OnGroundImageEventHandler | null; - fingerPrint(): GroundImageFingerPrint; - copy(opts?: GroundImageStateCopyParams): GroundImageState; - equals(other: unknown): boolean; - hashCode(): number; - asObservable(): { - subscribe: (fn: (fp: GroundImageFingerPrint) => void) => () => void; - }; -} -interface GroundImageStateCopyParams { - bounds?: GeoRectBounds; - imageUrl?: string; - opacity?: number; - tileSize?: number; - extra?: Serializable | null; - onClick?: OnGroundImageEventHandler | null; - id?: string | null; + * 線の上をきっかりタップすることはないので、タップ点をそのまま返すと + * 線から外れた座標がアプリへ渡る。3 プラットフォーム共通の既存契約。 + */ + resolveTap(position: GeoPointInterface): OverlayHit | null; +} + +/** + * Bridge between the React-side PolylineState collector and a PolylineCapable map controller. + * Mirrors `PolylineOverlay.kt` in the Android SDK. + * + * Register an instance of this class with `MapOverlayRegistry` so that whenever + * the polyline collection changes, the controller's `compositionPolylines()` is called. + */ +declare class PolylineOverlay implements MapOverlayInterface { + private readonly collector; + constructor(collector: OverlayCollector); + subscribe(fn: (data: ReadonlyMap) => void): () => void; + render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; } -declare const GROUND_IMAGE_DEFAULT_TILE_SIZE = 512; -declare function createGroundImageState(params: { - bounds: GeoRectBounds; - imageUrl: string; - opacity?: number; - tileSize?: number; - id?: string | null; - extra?: Serializable | null; - onClick?: OnGroundImageEventHandler | null; -}): GroundImageState; interface GroundImageEntity { readonly groundImage: ActualGroundImage; @@ -3336,103 +4540,6 @@ declare class GroundImageOverlay implements MapOverlayInterface, controller: MapViewControllerInterface): Promise; } -declare enum TileScheme { - XYZ = "XYZ", - TMS = "TMS" -} -type RasterAttributionRule = AttributionRule; -type RasterLayerSource = { - type: "UrlTemplate"; - template: string; - tileSize?: number; - minZoom?: number | null; - maxZoom?: number | null; - attributionRules?: AttributionRule[]; - scheme?: TileScheme; -} | { - type: "TileJson"; - url: string; -} | { - type: "ArcGisService"; - serviceUrl: string; -}; -declare const RasterLayerSource: { - DEFAULT_TILE_SIZE: number; - UrlTemplate(params: { - template: string; - tileSize?: number; - minZoom?: number | null; - maxZoom?: number | null; - attributionRules?: AttributionRule[]; - scheme?: TileScheme; - }): RasterLayerSource; - TileJson(url: string): RasterLayerSource; - ArcGisService(serviceUrl: string): RasterLayerSource; -}; -declare function resolveRasterAttributions(sources: RasterLayerSource[], camera: MapCameraPosition): string[]; - -/** - * 明示指定が無いときの `userAgent`。 - * - * 「利用者が指定した値かどうか」を判定するために公開している。空ではないので、 - * 既定のままの状態まで「指定あり」として扱うと、ラスタレイヤを 1 枚置いただけで - * 非対応の警告が出てしまう。android-sdk の `RasterLayerState.DEFAULT_USER_AGENT` / - * ios-sdk の `RasterLayerState.defaultUserAgent` と同じ値・同じ用途。 - */ -declare const DEFAULT_RASTER_LAYER_USER_AGENT = "MapConductor/RasterLayerAgent(https://mapconductor.com)"; -interface RasterLayerFingerPrint { - id: number; - source: number; - opacity: number; - visible: number; - zIndex: number; - userAgent: number; - debug: number; - /** `extraHeaders` のハッシュ。中身が extraHeaders しか無いので名前を実態に合わせている。 */ - extraHeaders: number; -} -interface RasterLayerEvent { - state: RasterLayerState; -} -type OnRasterLayerEventHandler = (event: RasterLayerEvent) => void; -interface RasterLayerState { - readonly id: string; - source: RasterLayerSource; - opacity: number; - visible: boolean; - zIndex: number; - userAgent: string; - debug: boolean; - extraHeaders: Record | null; - fingerPrint(): RasterLayerFingerPrint; - copy(opts?: RasterLayerStateCopyParams): RasterLayerState; - equals(other: unknown): boolean; - hashCode(): number; - asObservable(): { - subscribe: (fn: (fp: RasterLayerFingerPrint) => void) => () => void; - }; -} -interface RasterLayerStateCopyParams { - source?: RasterLayerSource; - opacity?: number; - visible?: boolean; - zIndex?: number; - userAgent?: string; - debug?: boolean; - extraHeaders?: Record | null; - id?: string | null; -} -declare function createRasterLayerState(params: { - source: RasterLayerSource; - opacity?: number; - visible?: boolean; - zIndex?: number; - userAgent?: string; - debug?: boolean; - extraHeaders?: Record | null; - id?: string | null; -}): RasterLayerState; - /** 「この宛先へのリクエストにはこのヘッダを載せる」という 1 件の規則。 */ interface RasterHeaderRule { /** 適用先ホスト(小文字化済み)。 */ @@ -3596,161 +4703,107 @@ interface RasterLayerOverlayRenderer { } declare abstract class RasterLayerController implements SlottedOverlayController, OverlayController, RasterLayerEvent> { - readonly zIndex: number; - readonly rasterLayerManager: RasterLayerManagerInterface; - readonly renderer: RasterLayerOverlayRenderer; - clickListener: OnRasterLayerEventHandler | null; - private semaphore; - private upsertedIds; - constructor({ rasterLayerManager, renderer, clickListener, }: { - rasterLayerManager: RasterLayerManagerInterface; - renderer: RasterLayerOverlayRenderer; - clickListener?: OnRasterLayerEventHandler | null; - }); - /** - * このプロバイダがタイル要求に何を載せられるか。 - * - * 既定は「載せられない」。宣言し忘れたプロバイダは**黙って無視するのではなく - * 警告が出る**側に倒しておく(逆にすると、対応していないのに対応しているように見える)。 - * 実際に載せられるプロバイダだけが override して `extraHeaders: true` を宣言する。 - */ - protected get headerSupport(): RasterHeaderSupport; - /** - * ヘッダ指定の反映と、載せられない指定の通知。 - * - * すべてのプロバイダがこの基底クラスを通るので、ここに置けば宣言([headerSupport]) - * だけで全プロバイダの挙動が決まる。renderer 側に散らすと、対応していないプロバイダが - * 「何も書かない」ことで黙って無視する形になり、実装漏れと区別できない。 - */ - private syncHeaders; - composition(data: RasterLayerState[]): Promise; - has(state: RasterLayerState): boolean; - setOnClickListener(listener: OnRasterLayerEventHandler | null): void; - add(data: RasterLayerState[]): Promise; - update(state: RasterLayerState): Promise; - upsert(state: RasterLayerState): Promise; - removeById(id: string): Promise; - clear(): Promise; - find(_position: GeoPoint): RasterLayerEntity | null; - onCameraChanged(mapCameraPosition: MapCameraPosition): Promise; - destroy(): void; - readonly kind: OverlayKind; - hasId(id: string): boolean; - compositionAny(data: unknown[]): Promise; - updateAny(state: unknown): Promise; - setClickListenerAny(listener: unknown): void; - /** ラスターレイヤはクリックを持たない。**明示的に null を返す**(実装忘れと区別するため)。 */ - resolveTap(_position: GeoPointInterface): OverlayHit | null; -} - -/** - * Bridge between the React-side RasterLayerState collector and a RasterLayerCapable map controller. - * Mirrors `RasterLayerOverlay.kt` in the Android SDK. - * - * Register an instance of this class with `MapOverlayRegistry` so that whenever - * the raster layer collection changes, the controller's `compositionRasterLayers()` is called. - */ -declare class RasterLayerOverlay implements MapOverlayInterface { - private readonly collector; - constructor(collector: OverlayCollector); - subscribe(fn: (data: ReadonlyMap) => void): () => void; - render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; -} - -/** - * Browser-side tile server implemented via a Service Worker interceptor. - * - * The Service Worker intercepts requests to `/__tiles/{routeId}/{tileSize}/{z}/{x}/{y}.png` - * and renders registered SW-side tile data with OffscreenCanvas when possible. - * If SW-side rendering is unavailable, it forwards requests back to the registered - * TileProvider on the main thread. - * - * For CPU-intensive tile rendering, attach a Web Worker via `attachRenderer()` so that - * `renderTile()` executes off the main thread. The worker should call - * `createTileWorkerHandler()` from `@mapconductor/js-sdk-core`. - */ -declare class LocalTileServer { - readonly baseUrl: string; - private readonly providers; - private static instance; - private static swRegistered; - private workerRenderer; - private readonly pendingRequests; - private nextRequestId; - private constructor(); - static startServer(): LocalTileServer; - /** - * Attach a Web Worker that handles tile rendering via `createTileWorkerHandler()`. - * When attached, `handleFetch()` delegates `renderTile()` to the worker thread - * instead of running it on the main thread. - */ - attachRenderer(worker: Worker): void; - /** Detach the renderer worker and fall back to main-thread rendering. */ - detachRenderer(): void; - register(routeId: string, provider: TileProvider): void; - unregister(routeId: string): void; - /** - * Send provider data to the SW and await acknowledgment before returning. - * This guarantees the SW can render tiles with OffscreenCanvas before the - * caller adds the raster source (which triggers tile requests immediately). - * - * Falls back (resolves) after 500 ms if the SW does not respond — in that - * case the postMessage fallback path in the SW will handle tile requests - * using the provider already stored in `this.providers`. - */ - sendSWRegisterAndWait(routeId: string, data: { - items: { - lat: number; - lng: number; - iconIndex: number; - }[]; - icons: { - bitmap: ImageBitmap; - anchor: { - x: number; - y: number; - }; - size: { - width: number; - height: number; - }; - }[]; - zoomScales: number[]; - extraIconScale: number; - }): Promise; - private postToSW; - urlTemplate({ routeId, tileSize, cacheKey, }: { - routeId: string; - tileSize: number; - cacheKey?: string; - }): string; - handleFetch(routeId: string, request: TileRequest): Promise; - handleFetchDataUrl(routeId: string, request: TileRequest): string | null; - private dispatchToWorker; + readonly zIndex: number; + readonly rasterLayerManager: RasterLayerManagerInterface; + readonly renderer: RasterLayerOverlayRenderer; + clickListener: OnRasterLayerEventHandler | null; + private semaphore; + private upsertedIds; + constructor({ rasterLayerManager, renderer, clickListener, }: { + rasterLayerManager: RasterLayerManagerInterface; + renderer: RasterLayerOverlayRenderer; + clickListener?: OnRasterLayerEventHandler | null; + }); /** - * Register a Service Worker that intercepts `/__tiles/` requests and routes them - * to `handleFetch()` on the main thread. - * - * Call once at app startup, before the map is rendered. The page URL template - * returned by `urlTemplate()` will then be resolvable by the browser. + * このプロバイダがタイル要求に何を載せられるか。 * - * @param swPath Path to the tile service worker script (default: `/tile-sw.js`) + * 既定は「載せられない」。宣言し忘れたプロバイダは**黙って無視するのではなく + * 警告が出る**側に倒しておく(逆にすると、対応していないのに対応しているように見える)。 + * 実際に載せられるプロバイダだけが override して `extraHeaders: true` を宣言する。 */ - startServiceWorker(swPath?: string): void; - hasProvider(routeId: string): boolean; + protected get headerSupport(): RasterHeaderSupport; /** - * Returns true when Service Workers are available in the current context. - * SW requires a secure origin (HTTPS or localhost). On plain HTTP non-localhost - * origins (e.g. a local IP like 192.168.x.x), SW is unavailable and tile - * rendering must use a provider-specific fallback to avoid unresolvable tile URLs. + * ヘッダ指定の反映と、載せられない指定の通知。 + * + * すべてのプロバイダがこの基底クラスを通るので、ここに置けば宣言([headerSupport]) + * だけで全プロバイダの挙動が決まる。renderer 側に散らすと、対応していないプロバイダが + * 「何も書かない」ことで黙って無視する形になり、実装漏れと区別できない。 */ - static isServiceWorkerSupported(): boolean; + private syncHeaders; + composition(data: RasterLayerState[]): Promise; + has(state: RasterLayerState): boolean; + setOnClickListener(listener: OnRasterLayerEventHandler | null): void; + add(data: RasterLayerState[]): Promise; + update(state: RasterLayerState): Promise; + upsert(state: RasterLayerState): Promise; + removeById(id: string): Promise; + clear(): Promise; + find(_position: GeoPoint): RasterLayerEntity | null; + onCameraChanged(mapCameraPosition: MapCameraPosition): Promise; + destroy(): void; + readonly kind: OverlayKind; + hasId(id: string): boolean; + compositionAny(data: unknown[]): Promise; + updateAny(state: unknown): Promise; + setClickListenerAny(listener: unknown): void; + /** ラスターレイヤはクリックを持たない。**明示的に null を返す**(実装忘れと区別するため)。 */ + resolveTap(_position: GeoPointInterface): OverlayHit | null; +} + +/** + * どの大きさのタイルなら、この地図 SDK がうまく扱えるか。 + * + * タイルの一辺は本来アプリの好みで決めてよく、既定は 512。1 枚あたりの固定費 + * (タイルサーバへの往復、PNG の符号化・復号、レンダラの立ち上げ)が枚数に比例 + * するので、同じ画面を覆うなら枚数は少ないほうが安い。 + * + * ところが SDK 側に都合があることがあり、その都合はタイルを供給する側からは + * 見えない。実例が ArcGIS の 3D SceneView で、**タイルは 256px** という前提で + * レベルを選ぶ。512px のタイルを渡すと 1 段深いレベルを 4 倍の枚数で取りに行く + * (Android 実機・統一ズーム 12 で、2D は z=11、3D は z=12)。絵は正しいので + * 気づきにくく、遅い端末ほど効く。 + * + * android-sdk の `RasterTilePreference.kt`、ios-sdk の `RasterTilePreference.swift` + * と同じもの。 + */ +interface RasterTilePreference { + /** この SDK に渡してほしいタイルの一辺(CSS px)。 */ + readonly preferredTileSize: number; /** - * Resolves when the Service Worker is controlling the current page. - * Returns immediately if the SW already controls the page. - * Use this to avoid requesting tiles before the SW intercept is active. + * この SDK がラスタータイルを **device px** でレイアウトするなら true。 + * + * 地図 SDK には 2 つの流儀がある。Google Maps や Leaflet は宣言された + * 一辺を CSS px として敷き、HERE は device px として敷く。2x の画面で + * 「512」と宣言すると、前者は 512 CSS px、後者は 256 CSS px しか覆わず、 + * HERE は 1 段深いズームを要求してスタイルをそこで読む — ラベルは半分、 + * 道路は倍の密度になる。供給側(ベクタータイルのラスタライザ)はこの + * 流儀を知りようがないので、プロバイダが言う。 + * + * true のとき、供給側は {@link preferredTileSize} × devicePixelRatio を + * 宣言し、描画は同じピクセル数で行い、スタイルには + * {@link preferredTileSize} CSS px として読ませる。省略時は false。 */ - waitForController(): Promise; + readonly layoutInDevicePixels?: boolean; +} +/** + * {@link RasterTilePreference} の登録キー。 + * + * 宣言しないプロバイダは「好みは無い」。その場合は供給側の既定が使われる。 + */ +declare const RasterTilePreferenceKey: MapServiceKey; + +/** + * Bridge between the React-side RasterLayerState collector and a RasterLayerCapable map controller. + * Mirrors `RasterLayerOverlay.kt` in the Android SDK. + * + * Register an instance of this class with `MapOverlayRegistry` so that whenever + * the raster layer collection changes, the controller's `compositionRasterLayers()` is called. + */ +declare class RasterLayerOverlay implements MapOverlayInterface { + private readonly collector; + constructor(collector: OverlayCollector); + subscribe(fn: (data: ReadonlyMap) => void): () => void; + render(data: ReadonlyMap, controller: MapViewControllerInterface): Promise; } declare const TileServerRegistry: { @@ -3792,6 +4845,12 @@ interface TileRenderResponse { readonly type: 'render'; readonly id: number; readonly result: Uint8Array | null; + /** + * Why there are no bytes. Optional because a worker built against an + * older version of this package does not send it; a missing outcome with + * no bytes is read as `empty`, which is what null meant before. + */ + readonly outcome?: TileOutcome['kind']; } declare abstract class AbstractZoomAltitudeConverter { @@ -3966,177 +5025,6 @@ declare abstract class AbstractMarkerController implements Slotted resolveTap(_position: GeoPointInterface): OverlayHit | null; } -/** - * Abstract base class implementing the listener-management contract of MapViewControllerInterface. - * Concrete SDK controllers extend this and call notify*() methods when native events fire. - * - * Mirrors Android BaseMapViewController / iOS MapViewController (base methods). - */ -declare abstract class BaseMapViewController { - protected cameraMoveStartCallback: OnCameraMoveHandler | null; - protected cameraMoveCallback: OnCameraMoveHandler | null; - protected cameraMoveEndCallback: OnCameraMoveHandler | null; - protected mapClickCallback: OnMapEventHandler | null; - protected mapLongClickCallback: OnMapEventHandler | null; - protected mapInitializedCallback: OnMapInitializedHandler | null; - private mapInitialized; - private mapInitializedCallbackDelivered; - private readonly overlayControllers; - /** - * ネイティブ SDK に直接のカメラ範囲制限 API を持たないプロバイダ(HERE/ArcGIS/MapKit/Cesium)が - * クランプ方式で使う制限設定。ネイティブ API を持つプロバイダ(Mapbox/MapLibre/MapTiler/ - * TomTom/Google/Leaflet/OpenLayers/Azure Maps)は `setCameraRestriction` をオーバーライドして - * 直接適用するため、この値は使わない。 - */ - private cameraRestriction; - setCameraRestriction(restriction: CameraRestriction | null): void; - protected hasCameraRestriction(): boolean; - /** - * カメラ位置が `setCameraRestriction` の制限に違反していれば補正後の位置を返す。違反が無ければ null。 - * - * ズームは統一ズーム(Google 準拠)前提。範囲制限はネイティブの - * `setLatLngBoundsForCameraTarget` 相当(カメラ中心を矩形内へクランプ)のセマンティクスに揃える。 - * クランプ方式のプロバイダはカメラ停止時にこれを呼び、返り値があれば `moveCamera` で再適用する。 - * ε を用いて微小な誤差では補正しないことで、再適用 → イベント → 再補正の無限ループを防ぐ。 - * - * android-sdk の `BaseMapViewController.cameraRestrictionCorrection` の移植。ε の値も同一。 - */ - protected cameraRestrictionCorrection(current: MapCameraPosition): MapCameraPosition | null; - setCameraMoveStartListener(listener: OnCameraMoveHandler | null): void; - setCameraMoveListener(listener: OnCameraMoveHandler | null): void; - setCameraMoveEndListener(listener: OnCameraMoveHandler | null): void; - setMapClickListener(listener: OnMapEventHandler | null): void; - setMapLongClickListener(listener: OnMapEventHandler | null): void; - setMapInitializedListener(listener: OnMapInitializedHandler | null): void; - /** - * オーバーレイコントローラを登録する。 - * - * 登録したコントローラには `notifyMapCameraPosition` からカメラ変更が伝播し、 - * `destroy()` で一括破棄される。android-sdk の - * `BaseMapViewController.registerOverlayController` に対応する。 - */ - registerOverlayController(controller: OverlayControllerLike): void; - /** 登録を解除する。拡張コンポーネントのアンマウント時に呼ぶ。 */ - unregisterOverlayController(controller: OverlayControllerLike): void; - /** - * オーバーレイの探索順。既定は {@link CANONICAL_ORDER}。 - * - * 種別を持たないプロバイダ(TomTom の円とグラウンドイメージはネイティブでは - * どちらも Polygon)でも順序は同じでよい。判定はコアの Manager が行うので、 - * ネイティブの実装がどうであれ結果は他プロバイダと揃う。 - */ - protected get overlayCascadeOrder(): readonly OverlayKind[]; - /** - * マーカーのヒットテストと配送。既定は「当たらない」。 - * - * マーカーだけコアが既定を持たないのは、判定に画面投影が要り、プロバイダごとに - * マーカーの実装方式(DOM 要素 / シンボルレイヤ / タイル)が違うため。 - * - * @returns マーカーがタップを消費したら true。 - */ - protected dispatchMarkerTap(_position: GeoPoint): boolean; - /** - * オーバーレイ(マーカー以外)のタップを、正準順に 1 つだけ配送する。 - * - * マーカーを含まないので、ネイティブのオーバーレイクリックリスナーから - * 呼ぶこともできる。 - * - * @returns 何かに当たって配送したら true。 - */ - dispatchOverlayTap(position: GeoPoint): boolean; - /** - * タップの入口。marker → オーバーレイ → 地図クリックの順に 1 つだけ配送する。 - * - * プロバイダは SDK のタップを地理座標に直してこれを呼ぶだけでよい。 - * - * **必ずどれか 1 つだけ**が配送される。オーバーレイに当たったのに地図クリックも - * 飛ぶ、という二重配送を防ぐのがこの関数の役目。 - * - * @returns 常に true(タップは必ずどこかで処理される)。 - */ - dispatchTap(position: GeoPoint): boolean; - /** 地図クリックをアプリへ通知する。 */ - emitMapClick(point: GeoPoint): void; - /** 地図の長押しをアプリへ通知する。 */ - emitMapLongClick(point: GeoPoint): void; - /** - * この種別の**主**コントローラ(最初に登録されたもの)。 - * - * `compositionXxx` / `updateXxx` はここへ流す。クラスタリングは同じ marker 種別で - * 追加のコントローラを登録するが、composition の受け口は最初の 1 つでよい - * (追加分はクラスタリング側が自分で駆動する)。 - */ - protected primaryOverlayController(kind: OverlayKind): SlottedOverlayController | null; - /** この種別に登録されたすべてのコントローラ。`hasXxx` は「どれかが持っていれば true」。 */ - protected overlayControllersOf(kind: OverlayKind): SlottedOverlayController[]; - /** `kind` のいずれかのコントローラがこの id を持っているか。`hasXxx` の既定実装。 */ - protected hasOverlay(kind: OverlayKind, id: string): boolean; - protected compositionOverlays(kind: OverlayKind, data: unknown[]): Promise; - protected updateOverlay(kind: OverlayKind, state: unknown): Promise; - protected setOverlayClickListener(kind: OverlayKind, listener: unknown): void; - compositionMarkers(data: MarkerState[]): Promise; - updateMarker(state: MarkerState): Promise; - hasMarker(state: MarkerState): boolean; - compositionPolylines(data: PolylineState[]): Promise; - updatePolyline(state: PolylineState): Promise; - hasPolyline(state: PolylineState): boolean; - /** @deprecated Use PolylineState.onClick instead. */ - setOnPolylineClickListener(listener: OnPolylineEventHandler | null): void; - compositionPolygons(data: PolygonState[]): Promise; - updatePolygon(state: PolygonState): Promise; - hasPolygon(state: PolygonState): boolean; - /** @deprecated Use PolygonState.onClick instead. */ - setOnPolygonClickListener(listener: OnPolygonEventHandler | null): void; - compositionCircles(data: CircleState[]): Promise; - updateCircle(state: CircleState): Promise; - hasCircle(state: CircleState): boolean; - /** @deprecated Use CircleState.onClick instead. */ - setOnCircleClickListener(listener: OnCircleEventHandler | null): void; - compositionGroundImages(data: GroundImageState[]): Promise; - updateGroundImage(state: GroundImageState): Promise; - hasGroundImage(state: GroundImageState): boolean; - /** @deprecated Use GroundImageState.onClick instead. */ - setOnGroundImageClickListener(listener: OnGroundImageEventHandler | null): void; - compositionRasterLayers(data: RasterLayerState[]): Promise; - updateRasterLayer(state: RasterLayerState): Promise; - hasRasterLayer(state: RasterLayerState): boolean; - protected notifyCameraMoveStart(camera: MapCameraPosition): void; - protected notifyCameraMove(camera: MapCameraPosition): void; - protected notifyCameraMoveEnd(camera: MapCameraPosition): void; - /** - * カメラ停止時に制限違反があれば補正位置へ移動し直す。 - * - * ネイティブの範囲制限 API を持たないプロバイダ(HERE / ArcGIS / MapKit / Cesium / - * OpenLayers / Longdo)はこの経路で制限が効く。ネイティブ API を持つプロバイダは - * `setCameraRestriction` をオーバーライドして `super` を呼ばないため、 - * ここでは何も起きない(android-sdk と同じ振り分け)。 - * - * 補正したら true。ε による不感帯があるので、再適用 → moveend → 再補正 のループにはならない。 - */ - protected applyCameraRestrictionOnIdle(camera: MapCameraPosition): boolean; - protected notifyMapClick(point: GeoPoint): void; - protected notifyMapLongClick(point: GeoPoint): void; - /** - * マップ初期化を記録し、リスナーが揃った時点で1回だけ配送する。 - * - * プロバイダのコントローラは、React 側の effect がリスナーを張り終える前に初期化を - * 完了させることがある(effect はマウント後に走るため)。sticky にしておくことで、 - * その隙間で初期化完了が取りこぼされるのを防ぐ。 - * android-sdk の `notifyMapInitialized` / `deliverMapInitializedCallbackIfReady` の移植。 - */ - protected notifyMapInitialized(): void; - private deliverMapInitializedCallbackIfReady; - /** - * カメラ変更を登録済みオーバーレイコントローラへ伝播し、続けて利用者コールバックを呼ぶ。 - * - * android-sdk の `notifyMapCameraPosition` に対応する。プロバイダが個別に - * `Promise.all([...onCameraChanged])` を書く必要がなくなり、拡張機能 - * (marker-clustering 等)も自前でリスナースロットを奪わずにカメラ変更を受け取れる。 - */ - protected notifyMapCameraPosition(camera: MapCameraPosition): Promise; - destroy(): void; -} - interface ChangeParamsInterface { readonly current: EntityType; readonly prev: EntityType; @@ -4519,4 +5407,4 @@ declare function segmentIntersectsRegion({ start, end, region, geodesic, }: { declare function splitByMeridian(points: GeoPoint[], geodesic: boolean): GeoPoint[][]; -export { AbstractCircleOverlayRenderer, AbstractGroundImageOverlayRenderer, AbstractMarkerController, AbstractMarkerIcon, AbstractMarkerOverlayRenderer, AbstractMarkerRenderingStrategy, AbstractPolygonOverlayRenderer, AbstractPolylineOverlayRenderer, AbstractViewportStrategy, AbstractZoomAltitudeConverter, type AddParams, type AnyMarkerRenderingSupport, type AttributionRule, BaseMapViewController, type BitmapIcon, CANONICAL_ORDER, type CameraOptions, type CameraRestriction, type ChangeParams, type ChangeParamsInterface, type CircleAddParams, type CircleCapable, type CircleChangeParams, CircleController, type CircleEntity, type CircleEvent, type CircleFingerPrint, CircleManager, type CircleManagerInterface, type CircleOptions, CircleOverlay, type CircleOverlayRenderer, type CircleState, type CircleStateCopyParams, CollectorMarkerOverlayRenderer, ColorDefaultIcon, type CreateMarkerStateParams, DEFAULT_CIRCLE_SEGMENTS, DEFAULT_RASTER_LAYER_USER_AGENT, DefaultMarkerEventController, DefaultMarkerIcon, type DefaultMarkerIconOptions, Direction6, Direction6Delta, DriverConformanceViolation, Earth, EmptyMapServiceRegistry, type FitBoundsCameraResult, GROUND_IMAGE_DEFAULT_TILE_SIZE, GeoGridIndex, GeoPoint, type GeoPointInterface, type GeoRectBounds, type GlGestureHandler, type GlGestureHandlers, type GlMarkerDragMap, type GlTouchZoomRotateHandler, type GroundImageAddParams, type GroundImageCapable, type GroundImageChangeParams, GroundImageController, type GroundImageEntity, type GroundImageEvent, type GroundImageFingerPrint, GroundImageManager, type GroundImageManagerInterface, GroundImageOverlay, type GroundImageOverlayRenderer, type GroundImageState, type GroundImageStateCopyParams, type GroundOverlayOptions, GroundScaleZoomAltitudeConverter, type HexCell, HexCellRegistry, type HexCellWithDistance, type HexCoord, type HexGeocell, HexGeocellImpl, type HexGeocell as HexGeocellInterface, IconImageCache, type IdentifiedHexCell, ImageDefaultIcon, type ImageDefaultIconOptions, ImageIcon, type ImageIconOptions, type ImageSource, type InfoBubbleEntry, InitState, KDTree, type KDTreeStats, LocalTileServer, MAP_CAPABILITIES, MARKER_HIT_RADIUS_MOUSE_PX, MARKER_HIT_RADIUS_TOUCH_PX, MARKER_HIT_SEARCH_RADIUS_PX, MARKER_RENDER_BATCH_SIZE, MapCameraPosition, type MapCameraPositionCopyParams, type MapCameraPositionInterface, type MapCapability, MapCapabilityStatus, type MapConfig, type MapDesignTypeInterface, type MapDiagnosticLevel, MapDiagnostics, type MapDiagnosticsSink, MapDriverConformance, type MapGesture, type MapOverlayInterface, MapOverlayRegistry, MapPaddings, type MapPaddingsInterface, MapProjection, MapProvider, MapProviderType, type MapServiceKey, type MapServiceRegistration, MapServiceRegistrations, type MapServiceRegistry, MapUISettings, MapUISettingsDiagnostics, MapViewBase, type MapViewBaseProps, type MapViewControllerInterface, type MapViewHolder, MapViewHolderBase, MapViewState, type MapViewStateInterface, type MapViewStateInternal, type MapViewStateOptions, MarkerAnimation, type MarkerAnimationOverlayEntry, type MarkerAnimationOverlayHost, type MarkerCapable, type MarkerEntity, type MarkerEventController, type MarkerEventHost, type MarkerFingerPrint, type MarkerHit, type MarkerHitRect, type MarkerIcon, MarkerIconSize, type MarkerIngestionResult, type MarkerLabelHorizontalPosition, type MarkerLabelPosition, type MarkerLabelVerticalPosition, MarkerManager, type MarkerManagerStats, type MarkerOptions, MarkerOverlay, type MarkerOverlayRenderer, type MarkerRenderingStrategy, type MarkerRenderingSupport, MarkerRenderingSupportKey, type MarkerState, type MarkerStateCopyParams, MarkerTileRenderer, MarkerTilingOptions, MutableMapServiceRegistry, Mutex, type NativeMapExtensionCapable, type NativeMapExtensionDescriptor, type NativeMapExtensionEvent, type NativeMapExtensionEventHandler, NoCameraRestriction, OVERLAY_KINDS, type Offset, type OnCameraMoveHandler, type OnCircleEventHandler, type OnGroundImageEventHandler, type OnMapEventHandler, type OnMapInitializedHandler, type OnMapLoadedHandler, type OnMarkerEventHandler, type OnPolygonEventHandler, type OnPolylineEventHandler, type OnRasterLayerEventHandler, OverlayCollector, type OverlayController, type OverlayControllerLike, OverlayGeoJson, type OverlayHit, OverlayHitResolver, type OverlayKind, type OverlayRendererInterface, Planar, type PolygonAddParams, type PolygonCapable, type PolygonChangeParams, PolygonController, type PolygonEntity, type PolygonEvent, type PolygonFingerPrint, PolygonManager, type PolygonManagerInterface, type PolygonOptions, PolygonOverlay, type PolygonOverlayRenderer, type PolygonRings, type PolygonState, type PolygonStateCopyParams, type PolylineAddParams, type PolylineCapable, type PolylineChangeParams, PolylineController, type PolylineEntity, type PolylineEvent, type PolylineFingerPrint, type PolylineHitResult, PolylineManager, type PolylineManagerInterface, type PolylineOptions, PolylineOverlay, type PolylineOverlayRenderer, type PolylineState, type PolylineStateCopyParams, type PreparedMarker, type Projection, type RasterAttributionRule, type RasterHeaderRule, RasterHeaderRuleSet, type RasterHeaderSupport, type RasterLayerAddParams, type RasterLayerCapable, type RasterLayerChangeParams, RasterLayerController, type RasterLayerEntity, type RasterLayerEvent, type RasterLayerFingerPrint, RasterLayerManager, type RasterLayerManagerInterface, RasterLayerOverlay, type RasterLayerOverlayRenderer, RasterLayerSource, type RasterLayerState, type RasterLayerStateCopyParams, type RasterRequestParameters, type RasterTransformRequest, type RegistryStats, type ScreenOffsetResult, ScreenProjectionRequirement, type Serializable, Settings, type SlottedOverlayController, type SpatialIndex, type SpatialIndexConfig, Spherical, StrategyMarkerController, type TileProvider, type TileRenderRequest, type TileRenderResponse, type TileRequest, TileScheme, TileServerRegistry, type ViewportSize, type VisibleRegion, WEB_MERCATOR_MAX_EXTENT_METERS, WGS84, WGS84Geodesic, WebMercator, WebMercatorZoomAltitudeConverter, applyGlMapUISettings, bearingFromNativeHeading, bearingFromNativeRotation, bounceInterpolation, bridgeHolesIntoSingleRing, bridgeHolesIntoSingleRingWrapAware, buildPolygonRings, buildPolylineSegments, buildUnwrappedPolygonRings, buildUnwrappedPolylinePath, buildVisibleRegion, calculateMetersPerPixel, capabilityOfGesture, circleToRing, closeRing, closestPointOnSegment, combineHash, computeArea$1 as computeArea, computeDistanceBetween$1 as computeDistanceBetween, computeFitBoundsCameraPosition, computeHeading$1 as computeHeading, computeLength$1 as computeLength, computeOffset$1 as computeOffset, computeOffsetOrigin$1 as computeOffsetOrigin, computeSignedArea$1 as computeSignedArea, computeDistanceBetween as computeWGS84DistanceBetween, createCircleEntity, createCircleState, createCollectorMarkerRenderingSupport, createDefaultIcon, createFingerPrint, createGeoPoint, createGeoRectBounds, createGroundImageEntity, createGroundImageState, createHexCell, createHexCellWithDistance, createHexCoord, createIdentifiedHexCell, createMapCameraPosition, createMapServiceKey, createMarkerEntity, createMarkerFingerPrint, createMarkerState, createOffset, createOppositeMeridianPoint, createOverlayHit, createPolygonEntity, createPolygonState, createPolylineEntity, createPolylineState, createRandomId, createRasterLayerEntity, createRasterLayerState, createSegmentBounds, createSubject, createTileWorkerHandler, densifyAndNormalize, expandBounds, fingerPrintEquals, fromGeoPoint, fromLatLng, fromLatLong, fromLngLat, fromLongLat, generateIdFromHashes, getNeighbors, hashBool, hashDefaultMarkerIcon, hashGeoPoint, hashNullable, hashNum, hashObj, hashStr, hexCellToIdPrefix, hexCoordToString, ingestMarkers, interpolate$1 as interpolate, interpolateAtMeridianGeodesic, interpolateAtMeridianLinear, interpolate as interpolateWGS84, isEmptyCameraRestriction, isFullySupported, isKnownUnsupported, isNativeMapExtensionCapable, isSlottedOverlayController, isUsable, longHashCode, mapCapabilityFromId, mapViewStateInternal, markerIconExtentPx, markerIconHashCode, markerSearchBounds, normalizeLngDegrees, pickMarkerHit, pickMarkerIndex, preferMarkerHit, resolveAttributionRules, resolveCameraRestriction, resolveHoles, resolveMapUISettings, resolveRasterAttributions, segmentIntersectsRegion, splitByMeridian, splitRingByMeridian, toDegrees, toInt, toNativeHeading, toNativeRotation, toRadians, unionHoleRings, unionHoles, unionHolesInPlace, viewportSizeOf, withRasterHeaderTransform, wrapClickedPoint }; +export { AbstractCircleOverlayRenderer, AbstractGroundImageOverlayRenderer, AbstractMarkerController, AbstractMarkerIcon, AbstractMarkerOverlayRenderer, AbstractMarkerRenderingStrategy, AbstractPolygonOverlayRenderer, AbstractPolylineOverlayRenderer, AbstractViewportStrategy, AbstractZoomAltitudeConverter, type AddParams, type AnyMarkerRenderingSupport, type AttributionRule, BLANK_MAP_STYLE, BLANK_MAP_STYLE_URL, BaseMapViewController, type BitmapIcon, CANONICAL_ORDER, type CameraOptions, type CameraRestriction, type ChangeParams, type ChangeParamsInterface, type CircleAddParams, type CircleCapable, type CircleChangeParams, CircleController, type CircleEntity, type CircleEvent, type CircleFingerPrint, CircleManager, type CircleManagerInterface, type CircleOptions, CircleOverlay, type CircleOverlayRenderer, type CircleState, type CircleStateCopyParams, CollectorMarkerOverlayRenderer, ColorDefaultIcon, type CreateMarkerStateParams, DEFAULT_CIRCLE_SEGMENTS, DEFAULT_RASTER_LAYER_USER_AGENT, DefaultMarkerEventController, DefaultMarkerIcon, type DefaultMarkerIconOptions, Direction6, Direction6Delta, DriverConformanceViolation, Earth, EmptyMapServiceRegistry, type FitBoundsCameraResult, GROUND_IMAGE_DEFAULT_TILE_SIZE, GeoGridIndex, GeoPoint, type GeoPointInterface, type GeoRectBounds, type GlGestureHandler, type GlGestureHandlers, type GlMarkerDragMap, type GlTouchZoomRotateHandler, type GroundImageAddParams, type GroundImageCapable, type GroundImageChangeParams, GroundImageController, type GroundImageEntity, type GroundImageEvent, type GroundImageFingerPrint, GroundImageManager, type GroundImageManagerInterface, GroundImageOverlay, type GroundImageOverlayRenderer, type GroundImageState, type GroundImageStateCopyParams, type GroundOverlayOptions, GroundScaleZoomAltitudeConverter, type HexCell, HexCellRegistry, type HexCellWithDistance, type HexCoord, type HexGeocell, HexGeocellImpl, type HexGeocell as HexGeocellInterface, IconImageCache, type IdentifiedHexCell, ImageDefaultIcon, type ImageDefaultIconOptions, ImageIcon, type ImageIconOptions, type ImageSource, type InfoBubbleEntry, InitState, KDTree, type KDTreeStats, LocalTileServer, MAP_CAPABILITIES, MARKER_HIT_RADIUS_MOUSE_PX, MARKER_HIT_RADIUS_TOUCH_PX, MARKER_HIT_SEARCH_RADIUS_PX, MARKER_RENDER_BATCH_SIZE, MapCameraPosition, type MapCameraPositionCopyParams, type MapCameraPositionInterface, type MapCapability, MapCapabilityStatus, type MapConfig, type MapDesignTypeInterface, type MapDiagnosticLevel, MapDiagnostics, type MapDiagnosticsSink, MapDriverConformance, type MapGesture, type MapOverlayInterface, MapOverlayRegistry, MapPaddings, type MapPaddingsInterface, MapProjection, MapProvider, MapProviderType, type MapServiceKey, type MapServiceRegistration, MapServiceRegistrations, type MapServiceRegistry, type MapStyleHost, type MapStyleInstallation, MapUISettings, MapUISettingsDiagnostics, MapViewBase, type MapViewBaseProps, type MapViewControllerInterface, type MapViewHolder, MapViewHolderBase, MapViewState, type MapViewStateInterface, type MapViewStateInternal, type MapViewStateOptions, type MapViewStyle, MapViewStyleHost, MarkerAnimation, type MarkerAnimationOverlayEntry, type MarkerAnimationOverlayHost, type MarkerCapable, type MarkerEntity, type MarkerEventController, type MarkerEventHost, type MarkerFingerPrint, MarkerGrid, type MarkerHit, type MarkerHitRect, type MarkerIcon, MarkerIconSize, type MarkerIngestionResult, type MarkerLabelHorizontalPosition, type MarkerLabelPosition, type MarkerLabelVerticalPosition, MarkerManager, type MarkerManagerStats, type MarkerOptions, MarkerOverlay, type MarkerOverlayRenderer, type MarkerRenderingStrategy, type MarkerRenderingSupport, MarkerRenderingSupportKey, type MarkerState, type MarkerStateCopyParams, MarkerTileRenderer, MarkerTilingOptions, MutableMapServiceRegistry, Mutex, type NativeDescribableStyle, type NativeMapExtensionCapable, type NativeMapExtensionDescriptor, type NativeMapExtensionEvent, type NativeMapExtensionEventHandler, type NativeStyleDescriptor, NoCameraRestriction, OVERLAY_KINDS, type Offset, type OnCameraMoveHandler, type OnCircleEventHandler, type OnGroundImageEventHandler, type OnMapEventHandler, type OnMapInitializedHandler, type OnMapLoadedHandler, type OnMarkerEventHandler, type OnPolygonEventHandler, type OnPolylineEventHandler, type OnRasterLayerEventHandler, OverlayCollector, type OverlayController, type OverlayControllerLike, OverlayGeoJson, type OverlayHit, OverlayHitResolver, type OverlayKind, type OverlayRendererInterface, Planar, type PolygonAddParams, type PolygonCapable, type PolygonChangeParams, PolygonController, type PolygonEntity, type PolygonEvent, type PolygonFingerPrint, PolygonManager, type PolygonManagerInterface, type PolygonOptions, PolygonOverlay, type PolygonOverlayRenderer, type PolygonRings, type PolygonState, type PolygonStateCopyParams, type PolylineAddParams, type PolylineCapable, type PolylineChangeParams, PolylineController, type PolylineEntity, type PolylineEvent, type PolylineFingerPrint, type PolylineHitResult, PolylineManager, type PolylineManagerInterface, type PolylineOptions, PolylineOverlay, type PolylineOverlayRenderer, type PolylineState, type PolylineStateCopyParams, type PreparedMarker, type Projection, type RasterAttributionRule, type RasterHeaderRule, RasterHeaderRuleSet, type RasterHeaderSupport, type RasterLayerAddParams, type RasterLayerCapable, type RasterLayerChangeParams, RasterLayerController, type RasterLayerEntity, type RasterLayerEvent, type RasterLayerFingerPrint, RasterLayerManager, type RasterLayerManagerInterface, type RasterLayerMounting, RasterLayerOverlay, type RasterLayerOverlayRenderer, RasterLayerSource, type RasterLayerState, type RasterLayerStateCopyParams, type RasterRequestParameters, type RasterTilePreference, RasterTilePreferenceKey, type RasterTransformRequest, type RegistryStats, type ScreenOffsetResult, ScreenProjectionRequirement, type Serializable, Settings, type SlottedOverlayController, type SpatialIndex, type SpatialIndexConfig, Spherical, StrategyMarkerController, type StyleMutation, StyleMutationStore, type StylePatchableMap, TRANSPARENT_TILE_PNG, type TileOutcome, type TileProvider, TileRenderFailedError, type TileRenderRequest, type TileRenderResponse, type TileRequest, TileScheme, TileServerRegistry, VectorStyleAsDesign, type VectorStyleMutationSupport, VectorStyleMutationSupportKey, type VectorStyleSupport, VectorStyleSupportKey, type ViewportSize, type VisibleRegion, WEB_MERCATOR_MAX_EXTENT_METERS, WEB_MERCATOR_MAX_LATITUDE, WGS84, WGS84Geodesic, WebMercator, WebMercatorZoomAltitudeConverter, applyGlMapUISettings, applyStyleMutations, bearingFromNativeHeading, bearingFromNativeRotation, bounceInterpolation, bridgeHolesIntoSingleRing, bridgeHolesIntoSingleRingWrapAware, buildPolygonRings, buildPolylineSegments, buildUnwrappedPolygonRings, buildUnwrappedPolylinePath, buildVisibleRegion, calculateMetersPerPixel, capabilityOfGesture, circleToRing, closeRing, closestPointOnSegment, combineHash, computeArea$1 as computeArea, computeDistanceBetween$1 as computeDistanceBetween, computeFitBoundsCameraPosition, computeHeading$1 as computeHeading, computeLength$1 as computeLength, computeOffset$1 as computeOffset, computeOffsetOrigin$1 as computeOffsetOrigin, computeSignedArea$1 as computeSignedArea, computeDistanceBetween as computeWGS84DistanceBetween, createCircleEntity, createCircleState, createCollectorMarkerRenderingSupport, createDefaultIcon, createFingerPrint, createGeoPoint, createGeoRectBounds, createGroundImageEntity, createGroundImageState, createHexCell, createHexCellWithDistance, createHexCoord, createIdentifiedHexCell, createMapCameraPosition, createMapServiceKey, createMarkerEntity, createMarkerFingerPrint, createMarkerState, createOffset, createOppositeMeridianPoint, createOverlayHit, createPolygonEntity, createPolygonState, createPolylineEntity, createPolylineState, createRandomId, createRasterLayerEntity, createRasterLayerState, createSegmentBounds, createSubject, createTileWorkerHandler, densifyAndNormalize, describeStyleMutations, expandBounds, fingerPrintEquals, fromGeoPoint, fromLatLng, fromLatLong, fromLngLat, fromLongLat, generateIdFromHashes, getNeighbors, hashBool, hashDefaultMarkerIcon, hashGeoPoint, hashNullable, hashNum, hashObj, hashStr, hexCellToIdPrefix, hexCoordToString, ingestMarkers, interpolate$1 as interpolate, interpolateAtMeridianGeodesic, interpolateAtMeridianLinear, interpolate as interpolateWGS84, isEmptyCameraRestriction, isFullySupported, isKnownUnsupported, isNativeDescribableStyle, isNativeMapExtensionCapable, isSlottedOverlayController, isUsable, longHashCode, mapCapabilityFromId, mapViewStateInternal, markerIconExtentPx, markerIconHashCode, markerSearchBounds, normalizeLngDegrees, parseStyleMutations, pickMarkerHit, pickMarkerIndex, preferMarkerHit, resolveAttributionRules, resolveCameraRestriction, resolveHoles, resolveMapUISettings, resolveRasterAttributions, reverseStyleMutation, segmentIntersectsRegion, splitByMeridian, splitRingByMeridian, styleMutationLayerId, styleMutationProperty, styleMutationTarget, toDegrees, toInt, toNativeHeading, toNativeRotation, toRadians, unionHoleRings, unionHoles, unionHolesInPlace, vectorStyleKey, viewportSizeOf, withRasterHeaderTransform, wrapClickedPoint }; diff --git a/src/controller/BaseMapViewController.ts b/src/controller/BaseMapViewController.ts index 850fdb8..155b45a 100644 --- a/src/controller/BaseMapViewController.ts +++ b/src/controller/BaseMapViewController.ts @@ -15,6 +15,7 @@ import type { OnPolylineEventHandler, PolylineState } from '../polyline/Polyline import type { OnPolygonEventHandler, PolygonState } from '../polygon/PolygonState'; import type { OnCircleEventHandler, CircleState } from '../circle/CircleState'; import type { OnGroundImageEventHandler, GroundImageState } from '../groundimage/GroundImageState'; +import type { RasterLayerMounting } from '../map/MapViewStyle'; import type { RasterLayerState } from '../raster/RasterLayerState'; // 統一ズーム(Google 準拠)での許容誤差。これ未満の差では補正しない。 @@ -155,6 +156,82 @@ export abstract class BaseMapViewController { if (index >= 0) this.overlayControllers.splice(index, 1); } + /** + * ラスターレイヤを **children の外から**載せる/外す。 + * + * `compositionRasterLayers` は宣言された集合でまるごと置き換えるので、外から + * 足したものは次の再レンダーで消える。{@link RasterLayerController.upsert} は + * その掃除から id を外すので、外から載せたいものはここを通す。 + * + * 呼ぶのは 2 つだけ: マーカータイリングと、ベクタースタイルを描けない + * バックエンドに渡す「スタイルから描いたタイル」。 + * + * **プロバイダ非依存**。raster コントローラはどのプロバイダも + * `registerOverlayController` 済みなので、プロバイダごとの配線は要らない。 + * android-sdk の `BaseMapViewController.mountRasterLayer` と同じ。 + */ + mountRasterLayer(state: RasterLayerState): void { + for (const controller of this.rasterLayerMounts()) void controller.upsert(state); + } + + /** {@link mountRasterLayer} で載せたものを外す。無いものを渡しても何も起きない。 */ + unmountRasterLayer(id: string): void { + for (const controller of this.rasterLayerMounts()) void controller.removeById(id); + } + + private rasterLayerMounts(): RasterLayerMounting[] { + return this.overlayControllers.filter( + (controller): controller is OverlayControllerLike & RasterLayerMounting => + isSlottedOverlayController(controller) && + controller.kind === 'rasterLayer' && + typeof (controller as Partial).upsert === 'function' && + typeof (controller as Partial).removeById === 'function', + ); + } + + // ── スタイル読み込みの通知 ──────────────────────────────────────────── + + private styleLoadedListeners = new Set<() => void>(); + /** スタイルが今載っているか。sticky: 聞かれる前に読み終わることがある。 */ + private styleLoaded = false; + + /** + * スタイル文書を読み終わるたびに `block` を呼ぶ。既に読み終わっていれば今すぐ 1 回。 + * + * {@link MapViewStyle} が生きたレンダラへ入れたもの(paint プロパティ、足した + * レイヤ)は、レンダラが文書を読み直した時点で全部消える——アプリのベースマップ + * 切替、デザイン変更、プロバイダのビュー作り直し、理由は何であれ。ここが戻し場所。 + * 1 本のリスナーではなく登録簿なのは、ミューテーションストアと設置済みスタイルの + * **両方**が聞く必要があるため。 + * + * そういう概念を持たないプロバイダ(Google Maps、Leaflet…)は + * {@link notifyStyleLoaded} を呼ばない。そこに載ったスタイルはラスター化された + * もので、ベースマップ切替では消えない。 + * + * @returns 聞くのをやめる手形。 + */ + addStyleLoadedListener(block: () => void): () => void { + this.styleLoadedListeners.add(block); + if (this.styleLoaded) block(); + return () => this.styleLoadedListeners.delete(block); + } + + /** + * スタイルが載り、**かつオーバーレイが戻った**時点でプロバイダが呼ぶ。 + * + * 読み込み開始時ではなく、プロバイダが自分のレイヤを戻す前でもない。作り直しの + * 途中でパッチを当てるリスナーは、これから置き換わるものにパッチを当てることになる。 + * + * android / iOS では protected。web だけ public なのは、GL 系 3 プロバイダで + * `styledata` を聞いているのがコントローラではなく + * `useVectorStyleMutationSupport`(共有フック)だから——呼ぶのは依然として + * 「プロバイダ側の 1 か所」で、その 1 か所がフックに寄っているだけ。 + */ + notifyStyleLoaded(): void { + this.styleLoaded = true; + for (const listener of [...this.styleLoadedListeners]) listener(); + } + // ── タップのカスケード ──────────────────────────────────────────────── // // marker → circle → groundImage → polyline → polygon → map の一本道。 diff --git a/src/map/BlankMapStyle.ts b/src/map/BlankMapStyle.ts new file mode 100644 index 0000000..2db0f60 --- /dev/null +++ b/src/map/BlankMapStyle.ts @@ -0,0 +1,25 @@ +/** + * A MapLibre-style map with nothing in it but a background. + * + * What a provider shows for "no basemap" when it is built on MapLibre (or + * takes a MapLibre style): the same colour as the device-side blank styles in + * android-sdk-core / ios-sdk-core. A map that draws its whole viewport itself + * -- vector tiles rendered on the client, say -- fetches and paints the basemap + * under it for nobody; this is the style that fetches nothing. + */ +export const BLANK_MAP_STYLE = { + version: 8 as const, + name: 'blank', + sources: {}, + layers: [ + { + id: 'background', + type: 'background' as const, + paint: { 'background-color': '#f2efe9' }, + }, + ], +}; + +/** [BLANK_MAP_STYLE] as a URL, for providers whose design carries a style URL. */ +export const BLANK_MAP_STYLE_URL = + 'data:application/json,' + encodeURIComponent(JSON.stringify(BLANK_MAP_STYLE)); diff --git a/src/map/MapViewBase.ts b/src/map/MapViewBase.ts index aad4270..0430fb8 100644 --- a/src/map/MapViewBase.ts +++ b/src/map/MapViewBase.ts @@ -1,4 +1,5 @@ import type { CameraRestriction } from './CameraRestriction'; +import type { MapViewStyle } from './MapViewStyle'; import type { GeoPoint } from '../features/GeoPoint'; import type { MapCameraPosition } from '../types/MapCamera'; import type { MapViewControllerInterface } from '../controller/MapViewControllerInterface'; @@ -34,6 +35,22 @@ export interface MapViewBaseProps void; } /** diff --git a/src/map/MapViewStyle.ts b/src/map/MapViewStyle.ts new file mode 100644 index 0000000..15229a5 --- /dev/null +++ b/src/map/MapViewStyle.ts @@ -0,0 +1,196 @@ +import type { RasterLayerState } from '../raster/RasterLayerState'; +import type { LocalTileServer } from '../tileserver/LocalTileServer'; +import type { MapServiceRegistry } from './MapServiceRegistry'; + +/** + * How a map looks, as a property of the map rather than a layer inside it. + * + * A vector style *is* the basemap, so passing one to `` + * says what it is rather than mounting something over it. What happens + * underneath depends on the backend and the app does not have to know: + * MapLibre, Mapbox and MapTiler are told the style and draw it themselves; + * Google Maps, Leaflet, Cesium, OpenLayers and the rest are handed raster + * tiles rendered from the same style in a worker. Either way the same + * adjustments produce the same map. + * + * An interface rather than a class, for two reasons. Core must not depend on + * the style compiler or the rasteriser — both are optional, and one of them + * is a wasm binary — so what core holds is the seam and the modules bring + * the implementation. And a provider's own styling, which is nothing like a + * MapLibre style, can be added later as another implementation without this + * changing. + * + * The concrete implementations live in the vector style module; an app does + * not implement this. + * + * android-sdk-core and ios-sdk-core carry the same seam. + */ +export interface MapViewStyle { + /** + * What identifies this style's content. + * + * The map re-installs a style whose key changed and leaves alone one + * whose key did not. Two objects describing the same thing must have the + * same key: a style built inline in JSX is a new object on every + * render — which is exactly what a colour picker produces — and keying + * off identity would reinstall the whole style on every unrelated state + * change. + */ + readonly key: string; + + /** + * Puts the style on the map. Called once the map has a style loaded, and + * again if it loads another one. + * + * @returns the ticket that takes it off again. Disposing must leave the + * map as it was found: a style that cannot be fully undone has no + * business being installed, because the app will switch away from it. + */ + install(host: MapStyleHost): MapStyleInstallation; +} + +/** + * A ticket to undo, and a chance to take a change without tearing down. + * + * Returned by anything that sets something up on a map. + */ +export interface MapStyleInstallation { + dispose(): void; + + /** + * Takes `next` in place, when it is near enough to what is installed, + * and answers whether it did. + * + * Without this the whole point is lost. A style whose key changed is + * installed again, and installing a style means handing the map a + * document — which is the reload this design exists to avoid. Moving a + * colour slider changes the adjustments and nothing else, so the + * installed style answers true, applies the difference, and the map + * never reloads anything. + * + * Left out, or false, and the caller disposes and installs `next` from + * scratch. False is always correct, only slower. + */ + update?(next: MapViewStyle): boolean; +} + +/** + * What a map offers to the style installed on it. + * + * The provider implements this; a {@link MapViewStyle} uses it and never + * touches a provider type. That is what keeps one style implementation + * working on every backend. + */ +export interface MapStyleHost { + /** + * Where to look for what this map can do: `VectorStyleSupportKey` to + * hand over a style document, `VectorStyleMutationSupportKey` to change + * a loaded one in place, `RasterTilePreferenceKey` for the tile size it + * would rather have. + */ + readonly serviceRegistry: MapServiceRegistry; + + /** For serving a style document, or tiles rendered from one, to the map. */ + readonly tileServer: LocalTileServer; + + /** + * The vector style the map is drawing now, when it is drawing one. + * + * Undefined on every backend that has no such thing, which is most of + * them. It is how a rule set that adjusts "whatever is showing" finds + * something to adjust: the style has to be read before a rule can be + * matched against its layers, and only the map knows where it came from. + */ + readonly vectorStyleUrl?: string; + + /** + * Runs `block` whenever the map finishes loading a style, including the + * one it has now if it already has one. + * + * Everything a style installs on a live renderer is lost when that + * renderer loads a document again, for any reason. This is how it gets + * put back. + */ + onStyleLoaded(block: () => void): MapStyleInstallation; + + /** + * Adds or updates a raster layer the app did not declare. + * + * The raster path needs somewhere to put its tiles, and that cannot be + * the children of the view: the style is a prop, so it is installed from + * outside the tree. Marker tiling already mounts its layer the same way. + */ + upsertRaster(state: RasterLayerState): void; + + removeRaster(id: string): void; + + /** + * Says what the app should know: a rule that matched nothing, an + * adjustment this backend cannot make, a style that could not be read. + * + * Routed to whatever the host gave the style, so a module can report + * without knowing how the app logs. + */ + report(diagnostics: readonly string[]): void; +} + +/** + * ラスターレイヤを children の外から載せられるコントローラ。 + * + * {@link RasterLayerController} が既に両方持っている。これは「どのプロバイダの + * コントローラか」を知らずにそこへ届くための名前。 + */ +export interface RasterLayerMounting { + upsert(state: RasterLayerState): Promise; + removeById(id: string): Promise; +} + +/** + * A style that can be carried to a native map SDK instead of installed here. + * + * React Native is the one host where {@link MapViewStyle.install} cannot do + * the work. The compiler is a wasm binary and Hermes has no wasm, so the + * style cannot be compiled in JavaScript on a device — and the map is not a + * JavaScript object anyway: it is the Android or iOS SDK, which already has + * the whole of this working natively. + * + * So on React Native the style is not installed; it is **described**, sent + * across the bridge as two strings, and rebuilt on the other side by the + * platform's own vector style module. The map a user sees is produced by + * exactly the code an Android or iOS app would run, which is why there is + * nothing here about how it is drawn. + * + * Core declares the shape; `@mapconductor/react-vectorstyle` fills it in. + */ +export interface NativeStyleDescriptor { + /** The style document itself, when the app had it. */ + readonly documentJson?: string; + /** Where to fetch the document, when the app named a URL. */ + readonly documentUrl?: string; + /** Headers for that fetch — an API key, usually. */ + readonly documentHeaders?: Readonly>; + /** + * Neither of the above: adjust whatever the map is already drawing. + * The native side reads the design's own style URL. + */ + readonly fromCurrentDesign?: boolean; + /** The rules document, in the form every platform's compiler reads. */ + readonly rulesJson: string; +} + +/** + * A {@link MapViewStyle} that knows how to describe itself to a native SDK. + * + * Checked structurally rather than by class, so core stays free of the + * module that implements it. + */ +export interface NativeDescribableStyle extends MapViewStyle { + toNativeDescriptor(): NativeStyleDescriptor; +} + +/** Whether `style` can be sent to a native SDK rather than installed here. */ +export function isNativeDescribableStyle( + style: MapViewStyle | null | undefined, +): style is NativeDescribableStyle { + return typeof (style as Partial | null)?.toNativeDescriptor === 'function'; +} diff --git a/src/map/MapViewStyleHost.ts b/src/map/MapViewStyleHost.ts new file mode 100644 index 0000000..d2a3a77 --- /dev/null +++ b/src/map/MapViewStyleHost.ts @@ -0,0 +1,144 @@ +import type { BaseMapViewController } from '../controller/BaseMapViewController'; +import type { RasterLayerState } from '../raster/RasterLayerState'; +import type { LocalTileServer } from '../tileserver/LocalTileServer'; +import { TileServerRegistry } from '../tileserver/TileServerRegistry'; +import type { MapServiceRegistry } from './MapServiceRegistry'; +import type { MapStyleHost, MapStyleInstallation, MapViewStyle } from './MapViewStyle'; +import { VectorStyleSupportKey } from './VectorStyleSupport'; + +/** + * What a map offers the style installed on it, and the bookkeeping that + * keeps it installed. + * + * One class for every provider. Everything it needs that differs per + * backend is reached through seams every provider already fills in — the + * service registry for what the map can do, the controller's overlay + * controllers for mounting tiles, the controller's style-loaded listeners + * for putting an adjustment back after a reload. Adding a provider needs + * nothing here. + * + * android-sdk's `MapViewStyleEffect` and ios-sdk's `MapViewStyleHost` do the + * same work; the shape differs because each framework owns its lifecycle + * differently. + */ +export class MapViewStyleHost implements MapStyleHost { + private installation: MapStyleInstallation | null = null; + private installedKey: string | null = null; + private diagnosticsHandler: ((diagnostics: readonly string[]) => void) | null = null; + + /** How many updates in a row the installed style has refused. See {@link apply}. */ + private refusedUpdates = 0; + + constructor( + readonly serviceRegistry: MapServiceRegistry, + private readonly controller: BaseMapViewController, + ) {} + + /** + * Keeps `style` installed on this map. + * + * Called on every render that reaches the hook, so most calls do + * nothing. Three things have to line up and none of them is the render: + * + * - **A style whose key did not change must be left alone.** A style + * built inline in JSX is a new object on every render — which is + * exactly what a colour picker produces — so comparing objects would + * reinstall on every unrelated state change. + * - **A changed style is usually an adjustment, not a new style.** + * Disposing and installing again hands the map a document and reloads + * it, which is the cost this whole design exists to avoid. What is + * installed is offered the new one first and only replaced if it + * refuses. + * - **Null means take it off.** An app that stops passing a style gets + * the map it had before, not the one the style left behind. + */ + apply( + style: MapViewStyle | null | undefined, + onDiagnostics?: ((diagnostics: readonly string[]) => void) | null, + ): void { + this.diagnosticsHandler = onDiagnostics ?? null; + if (!style) { + this.dispose(); + return; + } + if (this.installation === null) { + this.installation = style.install(this); + this.installedKey = style.key; + return; + } + // Nothing changed at all. The key covers the document *and* the + // rules, so an equal key means there is no work — and doing it + // anyway is not merely wasteful: recompiling re-reports the + // diagnostics, an app that renders them sets state, that renders + // again, and the two spin against each other. + if (this.installedKey === style.key) return; + + // The adjustments moved. Offer them to what is installed: taking + // them in place is what keeps a slider from reloading the map. + if (this.installation.update?.(style) === true) { + this.installedKey = style.key; + this.refusedUpdates = 0; + return; + } + + // Refused — correct, only slower. Refusing every time means the + // style cannot recognise its own successor and the map is rebuilt + // on each change; the usual cause is a value the style compares by + // identity being rebuilt too, a rasteriser constructed inline in + // the component rather than held in `useMemo`/`useRef`. + this.refusedUpdates += 1; + if (this.refusedUpdates === CHURN_THRESHOLD) { + console.warn( + `[MapConductor] style: the installed style has refused ${CHURN_THRESHOLD} ` + + 'updates in a row, so the map is being torn down and rebuilt on every ' + + 'change. Hold the rasteriser (and anything else the style compares by ' + + 'identity) in `useMemo` rather than rebuilding it.', + ); + } + this.installation.dispose(); + this.installation = style.install(this); + this.installedKey = style.key; + } + + /** Takes the style off. The hook calls this when the view goes. */ + dispose(): void { + this.installation?.dispose(); + this.installation = null; + this.installedKey = null; + } + + get tileServer(): LocalTileServer { + return TileServerRegistry.get(); + } + + get vectorStyleUrl(): string | undefined { + return this.serviceRegistry.get(VectorStyleSupportKey)?.currentStyleUrl; + } + + onStyleLoaded(block: () => void): MapStyleInstallation { + const stop = this.controller.addStyleLoadedListener(block); + return { dispose: stop }; + } + + upsertRaster(state: RasterLayerState): void { + this.controller.mountRasterLayer(state); + } + + removeRaster(id: string): void { + this.controller.unmountRasterLayer(id); + } + + report(diagnostics: readonly string[]): void { + if (diagnostics.length === 0) return; + if (this.diagnosticsHandler) { + this.diagnosticsHandler(diagnostics); + return; + } + // Never dropped. A rule that matched nothing, or an adjustment this + // backend could not make, is the kind of thing that otherwise shows + // up as "the style nearly worked" with no way to find out why. + for (const line of diagnostics) console.warn(`[MapConductor] style: ${line}`); + } +} + +const CHURN_THRESHOLD = 5; diff --git a/src/map/StyleMutation.ts b/src/map/StyleMutation.ts new file mode 100644 index 0000000..391c436 --- /dev/null +++ b/src/map/StyleMutation.ts @@ -0,0 +1,205 @@ +/** + * One change to one layer of a vector style the map has already loaded. + * + * This is what lets a style be adjusted without being reloaded. Handing a + * map a new style document is the obvious way to change how it looks, and on + * every provider it is the expensive way: the renderer drops its tiles, + * re-reads the document, re-tessellates, and the overlays the app added have + * to be put back. A paint property set in place costs none of that. + * + * Mutations are produced by the vector style compiler from the same + * evaluation that produces the adjusted document, so the two can never + * disagree about what the style should look like. A provider that can take + * them declares {@link VectorStyleMutationSupport}. + * + * ## Why the values are JSON text + * + * A style property's value is anything the style spec allows: a string, a + * number, or an expression tree. `map.setPaintProperty` here wants the + * parsed value, MapLibre on Android wants a Gson tree, MapLibre on iOS an + * `NSExpression` -- no shared typed representation saves anyone a + * conversion. Text is the one form all three start from, and it is what + * crossed the wasm boundary anyway. + * + * `"null"` means the property had no value, which is also how a renderer is + * told to go back to the spec default. + * + * android-sdk-core and ios-sdk-core carry the same type. A discriminated + * union here against a sealed class and an enum there: the same shape in + * each language's own grammar. + */ +export type StyleMutation = + | { readonly op: 'setPaint'; readonly layerId: string; readonly key: string; readonly value: string; readonly previous: string } + | { readonly op: 'setLayout'; readonly layerId: string; readonly key: string; readonly value: string; readonly previous: string } + | { readonly op: 'setFilter'; readonly layerId: string; readonly value: string; readonly previous: string } + | { + readonly op: 'setZoomRange'; + readonly layerId: string; + readonly minZoom: number | null; + readonly maxZoom: number | null; + readonly previousMinZoom: number | null; + readonly previousMaxZoom: number | null; + } + /** + * Adds a layer the style did not have. + * + * Used for one thing: a background under a style that has none, so that + * "everything else muted" looks the same whether the style is drawn by + * the map or rasterised into tiles over another basemap. Without it the + * raster path leaves the gaps transparent and the two disagree. + */ + | { + readonly op: 'addLayer'; + /** + * The new layer's id, carried rather than read back out of + * `layer`: the compiler knew it, every reader needs it, and three + * JSON parses of the same object to recover it are three chances + * to get it wrong. + */ + readonly layerId: string; + readonly layer: string; + readonly beforeId: string | null; + } + | { readonly op: 'removeLayer'; readonly layerId: string }; + +/** The layer a mutation acts on, or undefined for one that names no layer. */ +export function styleMutationLayerId(mutation: StyleMutation): string { + return mutation.layerId; +} + +/** + * What a mutation occupies, for working out which of two sets of mutations + * replace each other. Two mutations with the same target are the same change + * with different values. + */ +export function styleMutationTarget(mutation: StyleMutation): string { + switch (mutation.op) { + case 'setPaint': + return `paint|${mutation.layerId}|${mutation.key}`; + case 'setLayout': + return `layout|${mutation.layerId}|${mutation.key}`; + case 'setFilter': + return `filter|${mutation.layerId}`; + case 'setZoomRange': + return `zoom|${mutation.layerId}`; + case 'addLayer': + return `add|${mutation.layerId}`; + case 'removeLayer': + return `add|${mutation.layerId}`; + } +} + +/** + * What a mutation changes, named the way the style spec names it. + * + * For telling an app *what* a renderer would not do. The layer is the wrong + * thing to name on its own: when a backend has no property for a spec key, + * the same key fails on every layer that uses it, so a list of layers is + * hundreds of names for one cause while the key is the cause. + */ +export function styleMutationProperty(mutation: StyleMutation): string { + switch (mutation.op) { + case 'setPaint': + case 'setLayout': + return mutation.key; + case 'setFilter': + return 'filter'; + case 'setZoomRange': + return 'zoom range'; + default: + return 'layer'; + } +} + +/** The mutation that puts back what was there, or null when there is nothing to put back. */ +export function reverseStyleMutation(mutation: StyleMutation): StyleMutation | null { + switch (mutation.op) { + case 'setPaint': + case 'setLayout': + return { ...mutation, value: mutation.previous, previous: mutation.value }; + case 'setFilter': + return { ...mutation, value: mutation.previous, previous: mutation.value }; + case 'setZoomRange': + return { + ...mutation, + minZoom: mutation.previousMinZoom, + maxZoom: mutation.previousMaxZoom, + previousMinZoom: mutation.minZoom, + previousMaxZoom: mutation.maxZoom, + }; + case 'addLayer': + return { op: 'removeLayer', layerId: mutation.layerId }; + case 'removeLayer': + return null; + } +} + +/** + * Reads the compiler's `mutations` array. + * + * An entry this version does not know is skipped rather than fatal: the + * array is produced by the wasm this package shipped with, so an unknown op + * means the two went out of step, and dropping one change is better than + * losing every change. + */ +export function parseStyleMutations(json: string): StyleMutation[] { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.map(readOne).filter((m): m is StyleMutation => m !== null); +} + +function readOne(raw: unknown): StyleMutation | null { + if (typeof raw !== 'object' || raw === null) return null; + const entry = raw as Record; + const layerId = typeof entry.layerId === 'string' ? entry.layerId : undefined; + const key = typeof entry.key === 'string' ? entry.key : undefined; + switch (entry.op) { + case 'setPaint': + if (layerId === undefined || key === undefined) return null; + return { op: 'setPaint', layerId, key, value: jsonText(entry.value), previous: jsonText(entry.previous) }; + case 'setLayout': + if (layerId === undefined || key === undefined) return null; + return { op: 'setLayout', layerId, key, value: jsonText(entry.value), previous: jsonText(entry.previous) }; + case 'setFilter': + if (layerId === undefined) return null; + return { op: 'setFilter', layerId, value: jsonText(entry.value), previous: jsonText(entry.previous) }; + case 'setZoomRange': + if (layerId === undefined) return null; + return { + op: 'setZoomRange', + layerId, + minZoom: numberOrNull(entry.minZoom), + maxZoom: numberOrNull(entry.maxZoom), + previousMinZoom: numberOrNull(entry.previousMinZoom), + previousMaxZoom: numberOrNull(entry.previousMaxZoom), + }; + case 'addLayer': + if (layerId === undefined) return null; + if (typeof entry.layer !== 'object' || entry.layer === null) return null; + return { + op: 'addLayer', + layerId, + layer: JSON.stringify(entry.layer), + beforeId: typeof entry.beforeId === 'string' ? entry.beforeId : null, + }; + case 'removeLayer': + return layerId === undefined ? null : { op: 'removeLayer', layerId }; + default: + return null; + } +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function jsonText(value: unknown): string { + // `undefined` stringifies to `undefined`, which is not JSON. + return value === undefined ? 'null' : JSON.stringify(value); +} + diff --git a/src/map/VectorStyleMutationSupport.ts b/src/map/VectorStyleMutationSupport.ts new file mode 100644 index 0000000..3401e50 --- /dev/null +++ b/src/map/VectorStyleMutationSupport.ts @@ -0,0 +1,115 @@ +import { createMapServiceKey, type MapServiceKey } from './MapServiceRegistry'; +import { + reverseStyleMutation, + styleMutationTarget, + type StyleMutation, +} from './StyleMutation'; + +/** + * A map that can be told to change a loaded style in place. + * + * Separate from {@link VectorStyleSupport}, which is about making a style + * *be* the basemap. This one is about changing the one that is already up, + * without the reload that would otherwise cost every tile on screen and + * every overlay the app added. A provider declares it under + * {@link VectorStyleMutationSupportKey}. + * + * Not every provider can take every change: MapLibre on iOS exposes its + * paint properties as typed expressions rather than by name, so a raw + * style-spec key it has no mapping for cannot be set. That is why + * {@link apply} answers with what it could not do instead of returning + * nothing -- a caller that silently assumed success would show a map that is + * half adjusted and say nothing about it. + * + * android-sdk-core and ios-sdk-core carry the same capability. + */ +export interface VectorStyleMutationSupport { + /** + * Applies `mutations` to the live style, replacing any set applied + * before: anything the previous set changed and this one does not is put + * back the way it was. + * + * @returns the mutations that could not be applied, empty when all were. + */ + apply(mutations: readonly StyleMutation[]): StyleMutation[]; + /** Puts back everything {@link apply} changed. */ + clear(): void; +} + +/** + * {@link VectorStyleMutationSupport} の登録キー。 + * + * 宣言しないプロバイダは「生きているスタイルは触れない」。スタイル調整は + * その場合、文書を差し替えるか(地図が読み直す)、ラスター経路で描き直す。 + */ +export const VectorStyleMutationSupportKey: MapServiceKey = + createMapServiceKey(); + +/** + * The bookkeeping every provider's {@link VectorStyleMutationSupport} needs, + * so no provider has to get it right twice. + * + * Two things are easy to miss and both show up as "the adjustment worked, + * and then stopped": + * + * - **Replacing a set has to undo what it drops.** Moving a slider sends a + * whole new set each time. A property the new set no longer mentions must + * go back to the value the style gave it — and only the mutation knows + * that value, because by now the renderer's own idea of it is the one this + * put there. + * - **A style load wipes everything.** The app switching basemap, the + * provider reloading, a design change: whatever the reason, the renderer + * comes back with the document's own values and nothing this applied. The + * provider calls {@link onStyleLoaded} and the whole set goes on again. + */ +export class StyleMutationStore implements VectorStyleMutationSupport { + private current: readonly StyleMutation[] = []; + + /** + * @param applyNow puts mutations on the live style and returns the ones + * it could not. + */ + constructor(private readonly applyNow: (mutations: readonly StyleMutation[]) => StyleMutation[]) {} + + apply(mutations: readonly StyleMutation[]): StyleMutation[] { + const targets = new Set(mutations.map(styleMutationTarget)); + const restores = this.current + .filter((mutation) => !targets.has(styleMutationTarget(mutation))) + .map(reverseStyleMutation) + .filter((mutation): mutation is StyleMutation => mutation !== null); + this.current = mutations; + // Restores first: a property moving from one rule's value to no rule + // at all must pass through the style's own value, not stay on the old + // one because the new set happened to be applied first. + // + // Nothing is logged from here. What could not be applied is the + // caller's to report -- it has the app's diagnostics callback. + return this.applyNow([...restores, ...mutations]); + } + + clear(): void { + const restores = this.current + .map(reverseStyleMutation) + .filter((mutation): mutation is StyleMutation => mutation !== null); + this.current = []; + if (restores.length > 0) this.applyNow(restores); + } + + /** + * Re-applies everything after the map has loaded a style. + * + * The provider calls this from whatever it has that fires on a style + * being ready (`map.on('styledata')` and friends). Without it an + * adjustment survives only until the next design change, which is the + * kind of bug that is found weeks later by someone switching basemap + * with a slider open. + */ + onStyleLoaded(): void { + if (this.current.length > 0) this.applyNow(this.current); + } + + /** What is applied right now. For tests and diagnostics. */ + get applied(): readonly StyleMutation[] { + return this.current; + } +} diff --git a/src/map/VectorStyleSupport.ts b/src/map/VectorStyleSupport.ts new file mode 100644 index 0000000..d73c47e --- /dev/null +++ b/src/map/VectorStyleSupport.ts @@ -0,0 +1,179 @@ +import type { AttributionRule } from './AttributionRule'; +import type { MapDesignTypeInterface } from './MapDesignTypeInterface'; +import { createMapServiceKey, type MapServiceKey } from './MapServiceRegistry'; +import type { MapViewStateInterface } from './MapViewState'; + +/** + * A map that can draw a MapLibre style itself. + * + * MapLibre, Mapbox and MapTiler are vector renderers already: handing them a + * style is cheaper than rasterising it in a worker and feeding the result back + * as PNG tiles, which is what a backend that only takes raster (Google Maps, + * Leaflet, ArcGIS, Cesium...) has to be given. A provider that can take the + * style directly registers this under {@link VectorStyleSupportKey}; a layer + * that has a style to show looks the key up and, if it is there, skips the + * rasteriser altogether. + * + * The style *becomes the basemap*: on every provider that can do this, the + * style is the map, so showing one replaces the design the state names and + * clearing it puts that design back. A style drawn this way is not an overlay + * -- it has no opacity, and nothing of the previous basemap shows through it. + * Anything that needs the style on top of another basemap goes through the + * raster path. + * + * android-sdk-core and ios-sdk-core carry the same capability. There the + * style is a URL served by the local tile server; in the browser the map + * takes the parsed object directly, which is what `style` may be. + */ +export interface VectorStyleSupport { + /** + * Draws `style` -- a `style.json` URL or the parsed object -- as the map's + * basemap. `attributionRules` are the credits the style's sources ask for, + * carried by the design so the map's attribution overlay shows them for as + * long as the style is up. + */ + showStyle(style: string | object, attributionRules?: readonly AttributionRule[]): void; + /** + * Restores the design that was showing before {@link showStyle}, unless + * the app has since chosen another one, in which case that choice is left + * alone. + */ + clearStyle(): void; + + /** + * Where the style the map is drawing came from, when that is knowable. + * + * This is what lets a rule set adjust *whatever is showing* rather than + * a style the app had to name: the document has to be read before a rule + * can be matched against its layers, and only the provider knows where + * it came from. + * + * Undefined when it is not a URL anything can fetch — a provider's own + * named design, a style that needs a key this does not have. A caller + * that wanted it says so and leaves the map alone rather than showing + * something unstyled. + */ + readonly currentStyleUrl?: string; +} + +/** + * {@link VectorStyleSupport} の登録キー。 + * + * 宣言しないプロバイダは「スタイルは受け取れない」。ベクタータイル層はその場合 + * ラスタータイルに描いて渡す。 + */ +export const VectorStyleSupportKey: MapServiceKey = + createMapServiceKey(); + +/** + * What identifies a style: its URL, or a digest of its content. + * + * The design a style is shown as is keyed by this. A map only re-reads a style + * whose key changed, so a changed style must change the key -- and a remount + * with the same style must not. + */ +export function vectorStyleKey(style: string | object): string { + if (typeof style === 'string') return `vector-style:${style}`; + // FNV-1a over the JSON text. Stable, cheap, and only computed on a change. + const text = JSON.stringify(style); + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return `vector-style:${hash.toString(16)}`; +} + +/** + * {@link VectorStyleSupport} for a provider whose design type can carry a style. + * + * The MapLibre-based providers differ only in how a style is wrapped into + * their design type, which is what `designFor` supplies; the remember-and- + * restore dance is the same for all of them and lives here once. The "still + * ours" check on {@link clearStyle} is what keeps this from fighting an app + * that switched designs while the style was up: a layer unmounting must not + * undo a choice the app made after it. + * + * {@link clearStyle} takes effect a moment later rather than at once, and a + * {@link showStyle} of the same style in between cancels it. A view that + * rebuilds on a design change remounts the layer that asked for the style + * *by the change it caused* -- React's strict-mode double effects do the same + * -- and clearing on that unmount would restore the old design, which would + * rebuild again, without end. Waiting a beat lets the remount cancel it. + */ +export class VectorStyleAsDesign> implements VectorStyleSupport { + private installed: D | null = null; + private installedKey: string | null = null; + /** Set only when the style was handed over as a URL; a parsed object has none. */ + private installedUrl: string | null = null; + private previous: D | null = null; + private pendingClear: ReturnType | null = null; + + constructor( + private readonly state: MapViewStateInterface, + private readonly designFor: ( + style: string | object, + key: string, + attributionRules: readonly AttributionRule[], + ) => D, + /** + * Where this provider's design points, when it points at a plain + * style document. Only some providers and only some designs do. + */ + private readonly urlOf?: (design: D) => string | undefined, + ) {} + + /** + * The URL this put up, or else what the design the app chose points at. + * + * The first case is the one that matters: a style already installed here + * came from somewhere reachable, so a rule set asking for "whatever is + * showing" can fetch it too. + */ + get currentStyleUrl(): string | undefined { + if (this.installedUrl !== null) return this.installedUrl; + return this.urlOf?.(this.state.mapDesignType); + } + + showStyle(style: string | object, attributionRules: readonly AttributionRule[] = []): void { + if (this.pendingClear !== null) { + clearTimeout(this.pendingClear); + this.pendingClear = null; + } + const key = vectorStyleKey(style); + // Same style, still up: nothing to do, and writing the design again + // would rebuild the map for nothing. + if (this.installed && this.installedKey === key && this.state.mapDesignType.id === this.installed.id) { + return; + } + const design = this.designFor(style, key, attributionRules); + // Showing a second style over the first keeps the *original* design as + // the one to go back to; the intermediate style was never the app's + // basemap. + if (!this.installed) this.previous = this.state.mapDesignType; + this.installed = design; + this.installedKey = key; + this.installedUrl = typeof style === 'string' ? style : null; + this.state.mapDesignType = design; + } + + clearStyle(): void { + if (!this.installed) return; + if (this.pendingClear !== null) clearTimeout(this.pendingClear); + this.pendingClear = setTimeout(() => { + this.pendingClear = null; + const current = this.installed; + const restore = this.previous; + this.installed = null; + this.installedKey = null; + this.installedUrl = null; + this.previous = null; + if (current && restore && this.state.mapDesignType.id === current.id) { + this.state.mapDesignType = restore; + } + }, CLEAR_GRACE_MS); + } +} + +/** Longer than a frame, shorter than anyone notices. */ +const CLEAR_GRACE_MS = 100; diff --git a/src/map/applyStyleMutations.ts b/src/map/applyStyleMutations.ts new file mode 100644 index 0000000..169c1f8 --- /dev/null +++ b/src/map/applyStyleMutations.ts @@ -0,0 +1,138 @@ +import { styleMutationTarget, type StyleMutation } from './StyleMutation'; + +/** + * The part of a GL-JS map that a style mutation needs. + * + * Structural on purpose. MapLibre GL JS, Mapbox GL JS and the MapTiler SDK + * (which wraps MapLibre) all expose these under the same names, so one + * applier serves three providers — and core stays free of all three, which + * it has to be: every package in the SDK depends on core and none of them + * should pull a renderer in with it. + */ +export interface StylePatchableMap { + /** + * Whether the map has a style up to be changed. + * + * **False is not failure, and must not be reported as one.** A map with + * no style loaded yet answers `undefined` to every `getLayer`, so + * without this every mutation came back as one the map "cannot" make — + * which is how an app got told "N adjustments were not applied" about a + * map that was merely still loading, and, under the reloading policy, + * had the adjusted document handed to it for no reason. Nothing is lost + * by waiting: the store puts the whole set back when the style loads. + * + * Optional because it is read structurally off whatever map was handed + * in; all three GL-JS-family SDKs have it. + * + * `boolean | void` because MapLibre answers nothing at all before the + * style object exists — which is precisely the case this is here for, + * so the result is read as falsy rather than compared to `false`. + */ + isStyleLoaded?(): boolean | void; + getLayer(id: string): unknown; + setPaintProperty(layerId: string, name: string, value: unknown): void; + setLayoutProperty(layerId: string, name: string, value: unknown): void; + setFilter(layerId: string, filter?: unknown): void; + setLayerZoomRange(layerId: string, minzoom: number, maxzoom: number): void; + addLayer(layer: unknown, beforeId?: string): void; + removeLayer(id: string): void; +} + +/** + * Applies style mutations to a live GL-JS map. + * + * The body of a provider's {@link VectorStyleMutationSupport}: a provider + * wires this into a `StyleMutationStore` and registers that, and the three + * MapLibre-family providers then behave identically by construction. + * + * @returns the mutations it could not apply. A layer the loaded style does + * not have, or a property the renderer rejected: the caller is told + * rather than left to assume the map looks the way the rules asked. + * Nothing is thrown — one bad property must not cost the other three + * hundred. Empty while the map has no style up, because then there is + * nothing it could not do — only something it has not done yet. + */ +export function applyStyleMutations( + map: StylePatchableMap, + mutations: readonly StyleMutation[], +): StyleMutation[] { + // Not ready is not a refusal. See {@link StylePatchableMap.isStyleLoaded}. + // Tested for rather than called through `?.`: a map without the call at + // all is taken to be ready, which is what every map was before this + // existed, while one that answers `undefined` -- MapLibre, before the + // style object exists -- is not. Comparing to `false` would have missed + // exactly that case. + if (typeof map.isStyleLoaded === 'function' && !map.isStyleLoaded()) return []; + const unapplied: StyleMutation[] = []; + for (const mutation of mutations) { + try { + if (!applyOne(map, mutation)) unapplied.push(mutation); + } catch { + unapplied.push(mutation); + } + } + return unapplied; +} + +function applyOne(map: StylePatchableMap, mutation: StyleMutation): boolean { + switch (mutation.op) { + case 'setPaint': + if (!map.getLayer(mutation.layerId)) return false; + map.setPaintProperty(mutation.layerId, mutation.key, read(mutation.value)); + return true; + case 'setLayout': + if (!map.getLayer(mutation.layerId)) return false; + map.setLayoutProperty(mutation.layerId, mutation.key, read(mutation.value)); + return true; + case 'setFilter': + if (!map.getLayer(mutation.layerId)) return false; + // `undefined` removes the filter; `null` would be read as one. + map.setFilter(mutation.layerId, read(mutation.value) ?? undefined); + return true; + case 'setZoomRange': { + if (!map.getLayer(mutation.layerId)) return false; + // The spec's own defaults, for a layer that named neither. GL-JS + // takes both ends at once, so a mutation that changes one has to + // restate the other. + map.setLayerZoomRange(mutation.layerId, mutation.minZoom ?? 0, mutation.maxZoom ?? 24); + return true; + } + // Adding and removing guard themselves rather than going through the + // `getLayer` check above, because what "already there" means is the + // renderer's own question -- which is why on Android the guard sits + // in each provider's adapter. Here one applier *is* the adapter for + // all three GL-JS renderers, so it sits here. + case 'addLayer': { + const layer = read(mutation.layer); + if (typeof layer !== 'object' || layer === null) return false; + // The style was reloaded and the whole set is going back on; + // GL-JS throws on a duplicate id. + if (map.getLayer(mutation.layerId)) return true; + map.addLayer(layer, mutation.beforeId ?? undefined); + return true; + } + case 'removeLayer': + if (!map.getLayer(mutation.layerId)) return true; + map.removeLayer(mutation.layerId); + return true; + } +} + +/** + * A mutation's value, which travels as JSON text. + * + * Text because Android wants a Gson tree and iOS an `NSExpression`; here it + * is a `JSON.parse` away from what `setPaintProperty` wants. + */ +function read(json: string): unknown { + try { + return JSON.parse(json) as unknown; + } catch { + return undefined; + } +} + +/** For a message naming what would not apply. */ +export function describeStyleMutations(mutations: readonly StyleMutation[]): string { + return mutations.map(styleMutationTarget).join(', '); +} diff --git a/src/map/index.ts b/src/map/index.ts index 124ccee..2203185 100644 --- a/src/map/index.ts +++ b/src/map/index.ts @@ -14,3 +14,10 @@ export * from "./MapCapability"; export * from "./MapDiagnostics"; export * from "./InitState"; export * from './VisibleRegionBuilder'; +export * from './BlankMapStyle'; +export * from './VectorStyleSupport'; +export * from './StyleMutation'; +export * from './VectorStyleMutationSupport'; +export * from './MapViewStyleHost'; +export * from './MapViewStyle'; +export * from './applyStyleMutations'; diff --git a/src/marker/DefaultMarkerEventController.ts b/src/marker/DefaultMarkerEventController.ts index fd1663c..682020c 100644 --- a/src/marker/DefaultMarkerEventController.ts +++ b/src/marker/DefaultMarkerEventController.ts @@ -50,6 +50,7 @@ export interface MarkerEventHost { setSelectedMarker(entity: MarkerEntity | null): void | Promise; updateSelectedPosition(position: GeoPoint | null): void; + dispatchClick(state: MarkerState): void; dispatchDragStart(state: MarkerState): void; dispatchDrag(state: MarkerState): void; dispatchDragEnd(state: MarkerState): void; @@ -218,6 +219,16 @@ export class DefaultMarkerEventController { await this.host.setSelectedMarker(null); if (wasDragging) { this.host.dispatchDragEnd(selected.state); + } else if (updatePosition && selected.state.clickable) { + // A press and release that never passed the drag threshold is a + // click, and this is the only place that can report it: the + // pointerdown above called `preventDefault()` to take the gesture, + // which also suppresses the browser's click and with it the map's + // own tap cascade. Without this, a draggable marker was simply not + // clickable — `onClick` never fired, and the map did not get the + // tap either. Not dispatched for `pointercancel`, which is the + // gesture being taken away rather than completed. + this.host.dispatchClick(selected.state); } this.restoreMapInteraction(event.pointerId); } diff --git a/src/marker/GeoGridIndex.ts b/src/marker/GeoGridIndex.ts index f2c2b28..f187f30 100644 --- a/src/marker/GeoGridIndex.ts +++ b/src/marker/GeoGridIndex.ts @@ -1,58 +1,207 @@ import type { GeoPoint } from '../features'; +import { + columnWalk, + eastwardSpan, + latCell, + levelForSeparation, + lonCell, + morton, + queryLevel, + wrap, +} from './MarkerGrid'; -/** Uniform lat/lng grid used to cull the candidate set per tile without scanning every marker. */ +/** + * A hierarchical lat/lng grid used to cull the candidate set per tile without + * scanning every marker. + * + * The cells are the ones {@link MarkerGrid} describes, the same ones android-sdk + * and ios-sdk index on. What differs is the storage. Those two keep one sorted + * array of `(cellKey << 24) | position`, which works because a `Long` has 63 + * bits and the pair needs 60. A JavaScript `number` has 53, so the pair does + * not fit — and its bitwise operators would truncate to 32 in any case. Here + * the position lives in the bucket instead of in the key, which needs no packing + * at all. + * + * The consequence is that a level is a map of its own rather than a prefix of a + * shared array, so each one is built when first asked for. In practice a + * renderer asks for one or two: the level its tiles fall on, and the level its + * declutter separation asks for. + */ export class GeoGridIndex { - private readonly cells = new Map(); - private static readonly CELL_DEG = 0.02; - // If a bounds query would need to scan more cells than this, brute-force - // scanning `items` directly is comparably cheap and avoids pathological - // cell-iteration costs for very large (near-global) bounds. - private static readonly MAX_CELLS_PER_QUERY = 4000; - - constructor(private readonly items: ReadonlyArray) { - for (const item of items) { - const key = this.cellKey(item.position.latitude, item.position.longitude); - const bucket = this.cells.get(key); + /** Cells per level, built on demand. `level -> cellKey -> items`. */ + private readonly levels = new Map>(); + + /** + * How many levels to keep built. + * + * A renderer settles on one or two and stays there; holding more would keep + * a second reference to every marker for a zoom the user has left. + */ + private static readonly LEVEL_CACHE_MAX = 3; + + constructor(private readonly items: ReadonlyArray) {} + + private cellsAt(level: number): Map { + const existing = this.levels.get(level); + if (existing) return existing; + + const cells = new Map(); + for (const item of this.items) { + const key = morton( + latCell(item.position.latitude, level), + lonCell(item.position.longitude, level), + level, + ); + const bucket = cells.get(key); if (bucket) bucket.push(item); - else this.cells.set(key, [item]); + else cells.set(key, [item]); + } + this.levels.set(level, cells); + while (this.levels.size > GeoGridIndex.LEVEL_CACHE_MAX) { + const oldest = this.levels.keys().next().value; + if (oldest === undefined) break; + this.levels.delete(oldest); } + return cells; } - private cellKey(lat: number, lng: number): string { - const cx = Math.floor(lng / GeoGridIndex.CELL_DEG); - const cy = Math.floor(lat / GeoGridIndex.CELL_DEG); - return `${cx}:${cy}`; + /** + * Items whose position falls within the given padded lat/lng bounds. + * + * `west` and `east` may run past ±180 or come in reversed: both callers pad + * a tile or a click by a few pixels, and near the antimeridian that padding + * walks off the end of the coordinate range. Comparing those numbers + * directly means a click at 179.99° never reaches a marker at -179.99°, and + * a reversed box iterates no cells at all and quietly returns nothing. + */ + queryBounds(south: number, north: number, west: number, east: number): T[] { + const span = GeoGridIndex.spanOf(west, east); + const inside = GeoGridIndex.insideTest(south, north, west, span); + if (north < south) return []; + + const level = queryLevel(north - south, span.lonSpan); + const out: T[] = []; + this.forEachCell(south, north, west, span.lonSpan, level, (bucket) => { + for (const item of bucket) if (inside(item)) out.push(item); + }); + return out; } - /** Items whose position falls within the given padded lat/lng bounds. */ - queryBounds(south: number, north: number, west: number, east: number): T[] { - const cellDeg = GeoGridIndex.CELL_DEG; - const cx0 = Math.floor(west / cellDeg); - const cx1 = Math.floor(east / cellDeg); - const cy0 = Math.floor(south / cellDeg); - const cy1 = Math.floor(north / cellDeg); - const cellCount = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); - - if (!Number.isFinite(cellCount) || cellCount > GeoGridIndex.MAX_CELLS_PER_QUERY) { - return this.items.filter((item) => - item.position.latitude >= south && item.position.latitude <= north && - item.position.longitude >= west && item.position.longitude <= east, - ); - } + /** + * One item for each cell the bounds touch. + * + * The caller has said that items closer together than `minSeparationDegrees` + * are interchangeable, so the index is free to hand back whichever of a + * cell's items it likes — which is what lets it answer from its cells rather + * than reading every item. The level comes from the separation: the coarsest + * one whose cells are no wider than the caller asked for. + * + * ## Why the winner cannot depend on the bounds + * + * A cell's representative is the **last item in its bucket, always** — not + * the last one that falls inside the bounds. The difference is what a map + * made of tiles looks like at the seams. + * + * Tiles are rendered one at a time, each asking for its own box grown by the + * icon overhang. A cell straddling the boundary is asked about twice, by two + * different boxes. Choose the winner from what is inside the box and the two + * tiles choose **different items:** one item gets its left half drawn on the + * left tile and nothing on the right, so the icon is cut down the seam with + * no error anywhere. Measured on ios-sdk with Tokyo's street trees, 74 + * markers at zoom 9 and 84 at zoom 10 were drawn by one tile and not by its + * neighbour. + * + * Choosing without looking at the box removes the disagreement: an item + * whose icon reaches the next tile is inside that tile's grown box too, so + * that tile asks about the same cell and gets the same answer. + * + * A returned item may therefore lie just outside the bounds, by less than + * one cell. The renderer clips it; what it must not do is filter the list + * back down to the box, because that would put the disagreement back. + * + * Returns null when the index cannot help, meaning a separation finer than + * the bottom level, which would thin more than was asked for. The caller + * falls back to {@link queryBounds}. + * + * Mirrors `inBoundsThinned` in android-sdk and ios-sdk, where the index is + * internal to the module. Kept off the public surface here for the same + * reason: it is how the tile renderer talks to its index, not something an + * app calls. + * + * @internal + */ + queryBoundsThinned( + south: number, + north: number, + west: number, + east: number, + minSeparationDegrees: number, + ): T[] | null { + const level = levelForSeparation(minSeparationDegrees); + if (level === null) return null; + if (north < south) return []; const out: T[] = []; - for (let cx = cx0; cx <= cx1; cx++) { - for (let cy = cy0; cy <= cy1; cy++) { - const bucket = this.cells.get(`${cx}:${cy}`); - if (!bucket) continue; - for (const item of bucket) { - const { latitude, longitude } = item.position; - if (latitude >= south && latitude <= north && longitude >= west && longitude <= east) { - out.push(item); - } - } + this.forEachCell(south, north, west, eastwardSpan(west, east), level, (bucket) => { + // The last one, to land on the same item the other two SDKs pick: + // there the cell is a run in a sorted array ordered by position in + // the snapshot, so its last entry is the one added last — which is + // the end of this bucket. + out.push(bucket[bucket.length - 1]); + }); + return out; + } + + /** + * Walks the cells a box covers at `level`, folding columns onto the globe. + * + * The walk starts at the column holding `west` and runs eastward, which + * covers a reversed or past-±180 box without a second loop. + */ + private forEachCell( + south: number, + north: number, + west: number, + lonSpan: number, + level: number, + body: (bucket: T[]) => void, + ): void { + const cells = this.cellsAt(level); + const rowFrom = latCell(south, level); + const rowTo = latCell(north, level); + const { start, count } = columnWalk(west, lonSpan, level); + + for (let row = rowFrom; row <= rowTo; row++) { + for (let step = 0; step < count; step++) { + const bucket = cells.get(morton(row, wrap(start + step, level), level)); + if (bucket) body(bucket); } } - return out; + } + + private static spanOf(west: number, east: number): { fullGlobe: boolean; lonSpan: number } { + // A full turn or more covers everything; anything else folds into a + // single eastward sweep, whether the caller reversed the corners or ran + // past ±180. + return { fullGlobe: east - west >= 360, lonSpan: eastwardSpan(west, east) }; + } + + private static insideTest( + south: number, + north: number, + west: number, + span: { fullGlobe: boolean; lonSpan: number }, + ): (item: T) => boolean { + return (item: T): boolean => + item.position.latitude >= south && + item.position.latitude <= north && + (span.fullGlobe || + GeoGridIndex.withinLongitude(item.position.longitude, west, span.lonSpan)); + } + + /** Whether `lng` lies within `span` degrees east of `west`. */ + private static withinLongitude(lng: number, west: number, span: number): boolean { + const offset = (((lng - west) % 360) + 360) % 360; + return offset <= span; } } diff --git a/src/marker/MarkerGrid.ts b/src/marker/MarkerGrid.ts new file mode 100644 index 0000000..7a07a65 --- /dev/null +++ b/src/marker/MarkerGrid.ts @@ -0,0 +1,290 @@ +// The cell arithmetic behind {@link GeoGridIndex}, with no marker in sight. +// +// ## The scheme +// +// The world starts as **two square cells** — the western and eastern halves, +// 180 degrees on a side — and each is halved in latitude and in longitude, +// repeatedly. The key records the half chosen each time, latitude bit above +// longitude bit, under the bit that says which hemisphere it started in. That +// makes **every prefix of a key a cell:** dropping the low `2n` bits names the +// cell `n` levels up. A query picks the level that suits its box instead of +// paying the finest one, which is what the earlier single cell size of 0.005 +// degrees could not do. +// +// ## Why two root cells and not one +// +// One root cell covering the whole globe is what cordova-plugin-googlemaps' +// `geomodel.js` uses, and it is what this started as. It makes every cell +// **twice as wide as it is tall**, because the same number of divisions covers +// 360 degrees of longitude and 180 of latitude. +// +// That costs real time. A query asks for cells no wider than some separation; +// with a 2:1 cell, satisfying the width over-resolves the height by a factor of +// two, so the query walks twice the cells and returns twice the markers it +// asked for. Measured on ios-sdk with Tokyo's 144,183 street trees at zoom 11, +// a tile returned 9,715 markers of which the caller kept 2,156, and the work +// that scales with that count — the walk, positioning each marker, and grouping +// them — was 44 ms of the tile's 52 ms. +// +// Splitting the root into two square halves removes the factor of two. The +// string form keeps its shape: a hemisphere digit followed by geocell +// characters over `0123456789abcdef`, one per 4x4 subdivision, and a prefix is +// still a cell. What it is no longer is byte-identical to `geomodel.js`. +// +// ## Why none of this uses `<<` or `>>` +// +// A key is 37 bits. JavaScript's bitwise operators coerce their operands to +// **int32** first, so `2 ** 40 | 0` is 0 — a shift here would not overflow +// loudly, it would silently return the wrong cell. `number` itself is fine: +// doubles hold integers exactly to 2^53. So every shift below is written as a +// multiplication or a `Math.floor` division, which is exact at this size. +// +// Measured over Tokyo's 144,183 street trees: building the keys this way takes +// 12 ms, against 90 ms with `BigInt`. + +/** + * Levels of 2x2 subdivision below the two root cells. + * + * 18 matches the other two SDKs, where it is the largest depth that still + * leaves 24 bits of marker position under the key inside a 63-bit integer. It + * is also an even number of levels, so the string form packs into whole 4-bit + * characters. + * + * A cell at the bottom is 0.000687 degrees on a side, about 76 m at the + * equator. + * + * @internal + */ +export const GRID_DEPTH = 18; + +/** + * Roughly how many cells across a box a query aims to walk. + * + * The level is chosen so the box covers about this many cells squared, which + * bounds the walk no matter how large the box is. + */ +const CELLS_ACROSS = 32; + +/** + * The most cells a query walks along either axis. + * + * Only bites on a box so flat or so narrow that its area says nothing about how + * many cells it crosses. + */ +const MAX_CELLS_PER_AXIS = 64; + +const ALPHABET = '0123456789abcdef'; + +/** + * Degrees a cell spans, on either axis. Cells are square. + * + * @internal + */ +export function cellSize(level: number): number { + return 180 / 2 ** level; +} + +/** + * Rows of cells from pole to pole at `level`. + * + * @internal + */ +export function rows(level: number): number { + return 2 ** level; +} + +/** + * Columns of cells around the globe at `level`. Twice the rows, because the + * world is twice as wide as it is tall and the cells are square. + * + * @internal + */ +export function columns(level: number): number { + return 2 ** (level + 1); +} + +/** + * The coarsest level whose cells are no wider than `separation`, or null if + * even the bottom level is coarser than that. + * + * Counted up rather than solved with a logarithm, because the three SDKs have + * to agree and they do not have the same `log2` — Java has none, so Kotlin + * computes `ln(x) / ln(2)`, which is off by an ulp where the true answer is a + * whole number. Halving a double and comparing it is exact everywhere. + * + * @internal + */ +export function levelForSeparation(separation: number): number | null { + if (!(separation > 0)) return null; + for (let level = 0; level <= GRID_DEPTH; level++) { + if (cellSize(level) <= separation) return level; + } + return null; +} + +/** + * The level a bounds query walks, chosen so the box covers about + * `CELLS_ACROSS` squared cells. + * + * @internal + */ +export function queryLevel(latSpan: number, lonSpan: number): number { + const area = Math.max(lonSpan, 1e-12) * Math.max(latSpan, 1e-12); + // cells at a level = area / cellSize^2, and cellSize = 180 / 2^level + const budget = CELLS_ACROSS * CELLS_ACROSS * 180 * 180; + let level = 0; + while (level < GRID_DEPTH && area * 4 ** (level + 1) <= budget) level++; + // The area rule alone is not enough. A box with no height — a click box + // flattened by a degenerate projection, a bounds built from two points on + // the same parallel — has an area of nothing, which asks for the bottom + // level, which is a quarter of a million columns to walk. Step back up + // until neither axis is absurd. On a box of ordinary shape the area rule + // binds first and this does nothing. + while ( + level > 0 && + (lonSpan / cellSize(level) > MAX_CELLS_PER_AXIS || + latSpan / cellSize(level) > MAX_CELLS_PER_AXIS) + ) { + level--; + } + return level; +} + +/** + * How far east the box runs, from its west edge to its east one. + * + * A box crossing the antimeridian has its east corner west of its west one, and + * a padded box can run past ±180 outright. Normalising to a single eastward + * span removes both cases: everything downstream walks east from the west edge + * for this many degrees, and never compares two longitudes. + * + * @internal + */ +export function eastwardSpan(west: number, east: number): number { + if (east - west >= 360) return 360; + return (((east - west) % 360) + 360) % 360; +} + +/** + * The columns a box covers: where to start, and how many to walk. + * + * The end column is taken from `west + span`, not from the east corner. Taking + * it from the corner cannot work once column indices fold: the column holding + * 180 and the column holding -180 are the same one, so a box from -180.2 to + * 179.8 starts and ends on the same column and the walk covers one column out + * of the level's many. Nothing errors; the query simply answers for a thin + * slice of the world. + * + * @internal + */ +export function columnWalk( + west: number, + span: number, + level: number, +): { start: number; count: number } { + const total = columns(level); + const start = Math.floor(((west + 180) / 360) * total); + const end = Math.floor(((west + span + 180) / 360) * total); + // A full turn ends on the column it started on; walking both would return + // that column's items twice. + return { start, count: Math.min(end - start + 1, total) }; +} + +/** + * The row a latitude falls in, clamped at the poles. + * + * @internal + */ +export function latCell(latitude: number, level: number): number { + const count = rows(level); + const at = Math.floor(((latitude + 90) / 180) * count); + return Math.min(Math.max(at, 0), count - 1); +} + +/** + * The column a longitude falls in, folded back onto the globe. + * + * @internal + */ +export function lonCell(longitude: number, level: number): number { + const count = columns(level); + return wrap(Math.floor(((longitude + 180) / 360) * count), level); +} + +/** + * Folds a column index back onto the globe, so 180 and -180 share cells. + * + * @internal + */ +export function wrap(lonCell: number, level: number): number { + const count = columns(level); + return ((lonCell % count) + count) % count; +} + +/** + * Interleaves the two indices, latitude in the odd bits, under the bit that + * says which root cell they are in. + * + * Longitude carries one bit more than latitude — twice as many columns as rows + * — and that top bit is the hemisphere. It sits above the interleaved pairs + * rather than inside them, so dropping the low two bits still names the parent + * cell. + * + * @internal + */ +export function morton(latCell: number, lonCell: number, level: number): number { + const hemisphere = Math.floor(lonCell / 2 ** level); + const within = lonCell % 2 ** level; + return hemisphere * 4 ** level + spread(latCell) * 2 + spread(within); +} + +/** + * Spreads a value's bits apart, leaving a zero between each pair. + * + * The usual implementation is five masked shifts. Those are int32 operations, + * and the result here is up to 36 bits wide, so this walks the bits instead. It + * runs once per marker at build time and per cell at query time — not on any + * inner loop — and 18 iterations of exact arithmetic beat being subtly wrong. + */ +function spread(value: number): number { + let out = 0; + let scale = 1; + let rest = value; + for (let bit = 0; bit < GRID_DEPTH; bit++) { + if (rest % 2 === 1) out += scale; + rest = Math.floor(rest / 2); + scale *= 4; + } + return out; +} + +/** + * The key an item's position lands on, at the bottom level. + * + * @internal + */ +export function mortonKeyFor(latitude: number, longitude: number): number { + return morton(latCell(latitude, GRID_DEPTH), lonCell(longitude, GRID_DEPTH), GRID_DEPTH); +} + +/** + * The cell's name as a string: a hemisphere digit, then one character per 4x4 + * subdivision over `0123456789abcdef`. + * + * Each character after the first is two levels of this grid, so a string of + * `characters` names the cell at level `2 * (characters - 1)`. Not used by the + * index itself — the key is the same information, and faster — but it is the + * form the three SDKs' conformance fixtures compare, because it does not depend + * on how a platform stores an integer. + * + * @internal + */ +export function geocell(latitude: number, longitude: number, characters: number): string { + const level = Math.min(Math.max(characters - 1, 0) * 2, GRID_DEPTH); + const key = morton(latCell(latitude, level), lonCell(longitude, level), level); + let text = String(Math.floor(key / 4 ** level)); + for (let at = level * 2 - 4; at >= 0; at -= 4) { + text += ALPHABET[Math.floor(key / 2 ** at) % 16]; + } + return text; +} diff --git a/src/marker/MarkerTileRenderer.ts b/src/marker/MarkerTileRenderer.ts index 23640af..0f80995 100644 --- a/src/marker/MarkerTileRenderer.ts +++ b/src/marker/MarkerTileRenderer.ts @@ -63,6 +63,8 @@ export class MarkerTileRenderer number; private readonly debugTileOverlay: boolean; + /** Keep one marker per cell of this many pixels, or 0 to keep them all. */ + private readonly declutterPx: number; private readonly grid: GeoGridIndex; private readonly icons = new IconImageCache(); private readonly defaultBitmapIcon: BitmapIcon = createDefaultIcon().toBitmapIcon(); @@ -79,8 +81,10 @@ export class MarkerTileRenderer number, extraIconScale: number = 1.0, debugTileOverlay: boolean = false, + declutterPx: number = 0, ) { this.tileSize = tileSize; + this.declutterPx = declutterPx; this.iconScale = iconScaleCallback ?? ((_item, _zoom) => 1.0); this.extraIconScale = extraIconScale; this.debugTileOverlay = debugTileOverlay; @@ -112,7 +116,40 @@ export class MarkerTileRenderer 0 ? (Math.max(latSpan, lonSpan) * this.declutterPx) / this.tileSize : 0; + if (separationDegrees > 0) { + const thinned = this.grid.queryBoundsThinned(south, north, west, east, separationDegrees); + if (thinned) return thinned; + } return this.grid.queryBounds(south, north, west, east); } @@ -207,15 +244,97 @@ export class MarkerTileRenderer