Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/react-for-tomtom.api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,19 @@ declare class TomTomRasterLayerOverlayRenderer implements RasterLayerOverlayRend
onCameraChanged(_mapCameraPosition: MapCameraPosition): Promise<void>;
onPostProcess(): Promise<void>;
private addLayer;
/**
* The style's first label layer, so a raster overlay can go under it.
*
* Appended at the top of the style instead, a raster overlay covers the
* place names, road names and shields the style draws -- a vector tile
* layer's own roads run straight through them, which is what "the labels
* are under the lines" looks like. Every raster overlay has the same
* problem, so the rule lives here rather than in each of them.
*
* Our own layers are skipped: markers are a symbol layer too, and anchoring
* to them would put the raster back above the labels.
*/
private firstBasemapLabelLayerId;
private updateLayer;
/**
* スタイル再読込中に頼まれた削除の保留分。
Expand Down
4 changes: 2 additions & 2 deletions src/circle/TomTomCircleLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
LayerSpecification,
} from 'maplibre-gl';
import { type CircleEntity } from '@mapconductor/js-sdk-core';
import { bringMarkerLayersToFront, type FeatureCollection, type PolygonFeature } from '../helpers';
import { bringMarkerLayersToFront, setSourceFeatures, type FeatureCollection, type PolygonFeature } from '../helpers';
import { TomTomMapViewHolder } from '../TomTomMapViewHolder';

export type TomTomActualCircle = PolygonFeature & { id?: string | number };
Expand Down Expand Up @@ -51,7 +51,7 @@ export class TomTomCircleLayer {
try {
const source = this.holder.map.getSource(this.sourceId) as GeoJSONSource | undefined;
if (!source) return false;
source.setData(data);
setSourceFeatures(source, data.features);
return true;
} catch {
return false;
Expand Down
72 changes: 71 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,84 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string): voi
if (map.getSource(sourceId)) map.removeSource(sourceId);
}

const MARKER_LAYER_IDS = ['mc-raster-lyr-mc-marker-tiles', 'mc-marker-layer', 'mc-marker-drag-layer'];

// The layer order this module last produced, per map. Lets bringMarkerLayersToFront tell
// "nothing moved since I arranged it" apart from "someone added a layer on top".
const arrangedOrders = new WeakMap<object, string>();

function currentLayerOrder(map: maplibregl.Map): string[] | null {
try {
const m = map as unknown as {
getLayersOrder?: () => string[];
style?: { order?: string[] };
getStyle?: () => { layers?: Array<{ id: string }> } | undefined;
};
if (typeof m.getLayersOrder === 'function') return m.getLayersOrder();
if (Array.isArray(m.style?.order)) return [...m.style!.order!];
const layers = m.getStyle?.()?.layers;
return layers ? layers.map((layer) => layer.id) : null;
} catch {
return null;
}
}

/**
* Keep the marker layers above every other layer.
*
* **Moves nothing when they already are.** `moveLayer` marks the style as changed even when
* the layer stays where it was, and every change fires `styledata`. The provider treats a
* `styledata` that arrives while sources are still loading as "style not ready", and the
* `idle` that follows resyncs every overlay — which called this again. The style flipped
* between ready and not ready about every 0.3 s, forever, with nothing on the map changing.
*/
export function bringMarkerLayersToFront(map: maplibregl.Map): void {
for (const layerId of ['mc-raster-lyr-mc-marker-tiles', 'mc-marker-layer', 'mc-marker-drag-layer']) {
const order = currentLayerOrder(map);
if (order) {
const present = MARKER_LAYER_IDS.filter((layerId) => order.includes(layerId));
if (present.length === 0) return;
const tail = order.slice(order.length - present.length);
if (tail.every((layerId, i) => layerId === present[i])) return;
if (arrangedOrders.get(map) === order.join('\n')) return;
}
for (const layerId of MARKER_LAYER_IDS) {
try {
if (map.getLayer(layerId)) map.moveLayer(layerId);
} catch {
continue;
}
}
const arranged = currentLayerOrder(map);
if (arranged) arrangedOrders.set(map, arranged.join('\n'));
}

// What each GeoJSON source was last given, keyed by the source object itself. A style swap
// replaces the source objects, so a lost source never matches and is written again.
const writtenFeatures = new WeakMap<object, readonly unknown[]>();

/**
* `source.setData` that skips the write when the features are the same objects, in the same
* order, as the last write to this very source.
*
* Renderers build a new feature object whenever an overlay changes, so identity is enough to
* say "nothing changed". Skipping matters for the same reason as in
* [bringMarkerLayersToFront]: a redundant setData puts the source back into "loading", the
* next `styledata` then reads as "style not ready", and the resync on `idle` wrote it again.
*/
export function setSourceFeatures(
source: { setData(data: GeoJSONSourceData): unknown },
features: FeatureCollection['features'],
): void {
const previous = writtenFeatures.get(source);
if (
previous &&
previous.length === features.length &&
previous.every((feature, i) => feature === features[i])
) {
return;
}
source.setData({ type: 'FeatureCollection', features } as GeoJSONSourceData);
writtenFeatures.set(source, [...features]);
}

export function metersToPixels(meters: number, latitude: number): number {
Expand Down
4 changes: 2 additions & 2 deletions src/marker/MarkerLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
LayerSpecification,
} from 'maplibre-gl';
import type { MarkerEntity } from '@mapconductor/js-sdk-core';
import { bringMarkerLayersToFront, type FeatureCollection, type PointFeature } from '../helpers';
import { bringMarkerLayersToFront, setSourceFeatures, type FeatureCollection, type PointFeature } from '../helpers';
import { TomTomMapViewHolder } from '../TomTomMapViewHolder';

export type TomTomActualMarker = PointFeature;
Expand Down Expand Up @@ -93,7 +93,7 @@ export class MarkerLayer {
try {
const source = this.holder.map.getSource(this.sourceId) as GeoJSONSource | undefined;
if (!source) return false;
source.setData(data);
setSourceFeatures(source, data.features);
return true;
} catch {
return false;
Expand Down
6 changes: 3 additions & 3 deletions src/polygon/TomTomPolygonLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
LineFeature,
PolygonFeature,
} from '../helpers';
import { bringMarkerLayersToFront } from '../helpers';
import { bringMarkerLayersToFront, setSourceFeatures } from '../helpers';
import { TomTomMapViewHolder } from '../TomTomMapViewHolder';

export interface TomTomActualPolygon {
Expand Down Expand Up @@ -69,8 +69,8 @@ export class TomTomPolygonLayer {
const outlineSource = this.holder.map.getSource(this.outlineSourceId) as GeoJSONSource | undefined;
if (!fillSource || !outlineSource) return false;

fillSource.setData(fillData);
outlineSource.setData(outlineData);
setSourceFeatures(fillSource, fillData.features);
setSourceFeatures(outlineSource, outlineData.features);
return true;
} catch {
return false;
Expand Down
4 changes: 2 additions & 2 deletions src/polyline/TomTomPolylineLayer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { GeoJSONSource, LayerSpecification } from 'maplibre-gl';
import type { PolylineEntity } from '@mapconductor/js-sdk-core';
import { bringMarkerLayersToFront, type FeatureCollection, type LineFeature } from '../helpers';
import { bringMarkerLayersToFront, setSourceFeatures, type FeatureCollection, type LineFeature } from '../helpers';
import { TomTomMapViewHolder } from '../TomTomMapViewHolder';

export type TomTomActualPolyline = LineFeature[];
Expand Down Expand Up @@ -45,7 +45,7 @@ export class TomTomPolylineLayer {
try {
const source = this.holder.map.getSource(this.sourceId) as GeoJSONSource | undefined;
if (!source) return false;
source.setData(data);
setSourceFeatures(source, data.features);
return true;
} catch {
return false;
Expand Down
39 changes: 33 additions & 6 deletions src/raster/TomTomRasterLayerOverlayRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,40 @@ export class TomTomRasterLayerOverlayRenderer
}
const opacity = state.visible ? state.opacity : 0;
if (!this.holder.map.getLayer(handle.layerId)) {
this.holder.map.addLayer({
id: handle.layerId,
type: 'raster',
source: handle.sourceId,
paint: { 'raster-opacity': opacity },
} as LayerSpecification);
this.holder.map.addLayer(
{
id: handle.layerId,
type: 'raster',
source: handle.sourceId,
paint: { 'raster-opacity': opacity },
} as LayerSpecification,
this.firstBasemapLabelLayerId(),
);
} else {
this.holder.map.setPaintProperty(handle.layerId, 'raster-opacity', opacity);
}
return handle;
}

/**
* The style's first label layer, so a raster overlay can go under it.
*
* Appended at the top of the style instead, a raster overlay covers the
* place names, road names and shields the style draws -- a vector tile
* layer's own roads run straight through them, which is what "the labels
* are under the lines" looks like. Every raster overlay has the same
* problem, so the rule lives here rather than in each of them.
*
* Our own layers are skipped: markers are a symbol layer too, and anchoring
* to them would put the raster back above the labels.
*/
private firstBasemapLabelLayerId(): string | undefined {
const layers = this.holder.map.getStyle?.()?.layers;
if (!layers) return undefined;
const anchor = layers.find((layer) => layer.type === 'symbol' && !isOurLayerId(layer.id));
return anchor?.id;
}

private updateLayer(handle: TomTomRasterLayerHandle, state: RasterLayerState): void {
if (!this.canEditStyle()) return;
if (!this.holder.map.getLayer(handle.layerId)) return;
Expand Down Expand Up @@ -137,3 +159,8 @@ export class TomTomRasterLayerOverlayRenderer
removeSourceIfExists(this.holder.map, handle.sourceId);
}
}

/** True for layers this SDK adds, as opposed to the style's own. */
function isOurLayerId(id: string): boolean {
return id.startsWith('mc-');
}
88 changes: 88 additions & 0 deletions test/RasterLayerOrder.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { after, test } from 'node:test';
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { build } from 'tsup';
import { createRasterLayerState, RasterLayerSource } from '@mapconductor/js-sdk-core';

// clearOverlays() leaves the polygon / polyline / circle layers in the style (their sources
// are just emptied). A raster added after that was appended at the top of the style, so
// sending the same overlays again drew the raster over the polygons. The same happened on any
// raster update that replaced its source. The other GL providers insert a raster below the
// basemap's first label layer; TomTom now does the same.
mkdirSync(new URL('../build/', import.meta.url), { recursive: true });
const outDir = mkdtempSync(new URL('../build/raster-order-tests-', import.meta.url));
after(() => rmSync(outDir, { recursive: true, force: true }));
await build({
entry: {
controller: 'src/raster/TomTomRasterLayerController.ts',
renderer: 'src/raster/TomTomRasterLayerOverlayRenderer.ts',
},
outDir, format: ['esm'], platform: 'node', splitting: false,
dts: false, sourcemap: false, config: false, silent: true,
});
const { TomTomRasterLayerController } = await import(pathToFileURL(resolve(outDir, 'controller.mjs')));
const { TomTomRasterLayerOverlayRenderer } = await import(pathToFileURL(resolve(outDir, 'renderer.mjs')));

function fixture() {
const sources = new Map();
const layers = new Map();
let order = [];
const add = (layer, before) => {
layers.set(layer.id, layer);
const at = before ? order.indexOf(before) : -1;
if (at >= 0) order.splice(at, 0, layer.id); else order.push(layer.id);
};
// Basemap: a fill, then its first label layer.
add({ id: 'land', type: 'fill' });
add({ id: 'place-label', type: 'symbol' });
// What clearOverlays() leaves behind: our vector overlay layers, still in the style.
add({ id: 'polygon-fill-layer', type: 'fill' });
add({ id: 'mc-marker-layer', type: 'symbol' });
const map = {
order: () => [...order],
getStyle: () => ({ layers: order.map((id) => layers.get(id)) }),
getLayersOrder: () => [...order],
getSource: (id) => sources.get(id),
getLayer: (id) => layers.get(id),
addSource: (id, source) => { sources.set(id, source); },
addLayer: (layer, before) => add(layer, before),
removeLayer: (id) => { layers.delete(id); order = order.filter((x) => x !== id); },
removeSource: (id) => { sources.delete(id); },
setPaintProperty: (id, property, value) => { layers.get(id).paint[property] = value; },
moveLayer: (id, before) => {
order = order.filter((x) => x !== id);
const at = before ? order.indexOf(before) : -1;
if (at >= 0) order.splice(at, 0, id); else order.push(id);
},
};
const renderer = new TomTomRasterLayerOverlayRenderer({ map }, () => true);
const controller = new TomTomRasterLayerController(renderer);
return { map, controller };
}

function relief(opacity) {
return createRasterLayerState({
id: 'RL',
opacity,
source: RasterLayerSource.UrlTemplate({ template: 'https://example.test/{z}/{x}/{y}.png', tileSize: 256 }),
});
}

test('a raster added after the vector overlays goes under them and under the basemap labels', async () => {
const f = fixture();
await f.controller.composition([relief(0.5)]);
const order = f.map.order();
assert.ok(order.indexOf('mc-raster-lyr-RL') < order.indexOf('place-label'), order.join(' > '));
assert.ok(order.indexOf('mc-raster-lyr-RL') < order.indexOf('polygon-fill-layer'), order.join(' > '));
});

test('replacing the raster source keeps it under the vector overlays', async () => {
const f = fixture();
await f.controller.composition([relief(0.5)]);
// A new source object (as a re-render creates) is a source replacement: remove + add.
await f.controller.composition([relief(0.3)]);
const order = f.map.order();
assert.ok(order.indexOf('mc-raster-lyr-RL') < order.indexOf('polygon-fill-layer'), order.join(' > '));
});
Loading