Skip to content

Commit a420f57

Browse files
authored
feat(landing): store the maps bounds to provide a better bounding box intersection (#3346)
### Motivation The filter layer intersection logic is currently guessing the extent that the user is viewing ### Modifications Store the extent that the user is looking at when the map pans ### Verification tested locally
1 parent 4bc33ff commit a420f57

File tree

7 files changed

+196
-82
lines changed

7 files changed

+196
-82
lines changed

packages/geo/src/stac/stac.attribution.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,59 @@ import { StacCatalog, StacCollection, StacItem } from './index.js';
44
* A Single File STAC compliant collection with zoom and priority for calculating attribution of an extent
55
*/
66
export type AttributionCollection = StacCollection<{
7+
/**
8+
* Category of the layer
9+
*
10+
* @example "Urban Aerial Photos"
11+
*/
712
'linz:category'?: string;
13+
14+
/**
15+
* Zoom levels that the layer is visit
16+
*/
817
'linz:zoom': { min: number; max: number };
18+
19+
/**
20+
* Priority order for the layer
21+
*
22+
* The higher the number the higher the priority
23+
*
24+
* @example [1077]
25+
*/
926
'linz:priority': [number];
1027
}>;
1128

1229
/**
1330
* A Single File STAC compliant feature for calculating attribution of an extent
1431
*/
1532
export type AttributionItem = StacItem<{
33+
/**
34+
* Human friendly title of the layer
35+
*
36+
* @example "Whanganui 0.075m Urban Aerial Photos (2017)"
37+
*/
1638
title: string;
39+
40+
/**
41+
* Category of the layer
42+
*
43+
* @example "Urban Aerial Photos"
44+
*/
1745
category?: string;
46+
47+
/**
48+
* datetime is null as per STAC requirement when {@link start_datetime} and {@link end_datetime} are set
49+
*/
1850
datetime?: null;
51+
52+
/**
53+
* datetime of when the layer started being captured
54+
*/
1955
start_datetime?: string;
56+
57+
/**
58+
* datetime of when the layer stopped being captured
59+
*/
2060
end_datetime?: string;
2161
}>;
2262

packages/lambda-tiler/src/routes/attribution.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,13 @@ async function tileSetAttribution(
9999
for (const layer of tileSet.layers) {
100100
const imgId = layer[proj.epsg.code];
101101
if (imgId == null) continue;
102+
102103
const im = imagery.get(imgId);
103104
if (im == null) continue;
105+
104106
const title = im.title;
105107
const years = extractYearRangeFromTitle(im.title) ?? extractYearRangeFromName(im.name);
108+
106109
if (years == null) continue;
107110
const interval = yearRangeToInterval(years);
108111

packages/landing/src/attribution.ts

Lines changed: 38 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,53 @@
11
import { Attribution } from '@basemaps/attribution';
22
import { AttributionBounds } from '@basemaps/attribution/build/attribution.js';
3-
import { GoogleTms, Stac, TileMatrixSet } from '@basemaps/geo';
4-
import { BBox } from '@linzjs/geojson';
3+
import { GoogleTms, Stac } from '@basemaps/geo';
54
import * as maplibre from 'maplibre-gl';
65

76
import { onMapLoaded } from './components/map.js';
87
import { Config } from './config.js';
9-
import { locationTransform } from './tile.matrix.js';
8+
import { mapToBoundingBox } from './tile.matrix.js';
109
import { MapOptionType } from './url.js';
1110

1211
const Copyright = ${Stac.License} LINZ`;
1312

1413
export class MapAttributionState {
1514
/** Cache the loading of attribution */
16-
_attrs: Map<string, Promise<Attribution | null>> = new Map();
17-
/** Rendering process needs synch access */
18-
_attrsSync: Map<string, Attribution> = new Map();
15+
attrs: Map<string, Promise<Attribution | null>> = new Map();
16+
/** Rendering process needs sync access */
17+
attrsSync: Map<string, Attribution> = new Map();
18+
19+
private getLayer(layerKey: string, url: string): Promise<Attribution | null> {
20+
const layer = this.attrs.get(layerKey);
21+
if (layer != null) return layer;
22+
const loader = Attribution.load(url)
23+
.catch(() => null)
24+
.then((ret) => {
25+
if (ret == null) {
26+
this.attrs.delete(layerKey);
27+
this.attrsSync.delete(layerKey);
28+
} else {
29+
this.attrsSync.set(layerKey, ret);
30+
}
31+
return ret;
32+
});
33+
this.attrs.set(layerKey, loader);
34+
return loader;
35+
}
36+
37+
/**
38+
* Load the attribution fo all layers
39+
* @returns
40+
*/
41+
getAll(): Promise<Attribution | null> {
42+
return this.getLayer('all', Config.map.toTileUrl(MapOptionType.Attribution, GoogleTms, 'all', null));
43+
}
1944

2045
/** Load a attribution from a url, return a cached copy if we have one */
2146
getCurrentAttribution(): Promise<Attribution | null> {
22-
const cacheKey = Config.map.layerKeyTms;
23-
let attrs = this._attrs.get(cacheKey);
24-
if (attrs == null) {
25-
attrs = Attribution.load(Config.map.toTileUrl(MapOptionType.Attribution)).catch(() => null);
26-
this._attrs.set(cacheKey, attrs);
27-
void attrs.then((a) => {
28-
if (a == null) return;
29-
a.isIgnored = this.isIgnored;
30-
this._attrsSync.set(Config.map.layerKeyTms, a);
31-
});
32-
}
33-
return attrs;
47+
return this.getLayer(Config.map.layerKeyTms, Config.map.toTileUrl(MapOptionType.Attribution)).then((ret) => {
48+
if (ret != null) ret.isIgnored = this.isIgnored;
49+
return ret;
50+
});
3451
}
3552

3653
/** Filter the attribution to the map bounding box */
@@ -39,39 +56,8 @@ export class MapAttributionState {
3956
// Note that Mapbox rendering 512×512 image tiles are offset by one zoom level compared to 256×256 tiles.
4057
// For example, 512×512 tiles at zoom level 4 are equivalent to 256×256 tiles at zoom level 5.
4158
zoom += 1;
42-
const extent = MapAttributionState.mapboxBoundToBbox(map.getBounds(), zoom, Config.map.tileMatrix);
43-
return attr.filter({
44-
extent,
45-
zoom: zoom,
46-
dateBefore: Config.map.filter.date.before,
47-
});
48-
}
49-
50-
getAttributionByYear(attribution: AttributionBounds[]): Map<number, AttributionBounds[]> {
51-
const attrsByYear = new Map<number, AttributionBounds[]>();
52-
for (const a of attribution) {
53-
if (!a.startDate || !a.endDate) continue;
54-
const startYear = Number(a.startDate.slice(0, 4));
55-
const endYear = Number(a.endDate.slice(0, 4));
56-
for (let year = startYear; year <= endYear; year++) {
57-
const attrs = attrsByYear.get(year) ?? [];
58-
attrs.push(a);
59-
attrsByYear.set(year, attrs);
60-
}
61-
}
62-
return attrsByYear;
63-
}
64-
65-
/**
66-
* Covert Mapbox Bounds to tileMatrix BBox
67-
*/
68-
static mapboxBoundToBbox(bounds: maplibre.LngLatBounds, zoom: number, tileMatrix: TileMatrixSet): BBox {
69-
const swLocation = { lon: bounds.getWest(), lat: bounds.getSouth(), zoom: zoom };
70-
const neLocation = { lon: bounds.getEast(), lat: bounds.getNorth(), zoom: zoom };
71-
const swCoord = locationTransform(swLocation, GoogleTms, tileMatrix);
72-
const neCoord = locationTransform(neLocation, GoogleTms, tileMatrix);
73-
const bbox: BBox = [swCoord.lon, swCoord.lat, neCoord.lon, neCoord.lat];
74-
return bbox;
59+
const extent = mapToBoundingBox(map, zoom, Config.map.tileMatrix);
60+
return attr.filter({ extent, zoom });
7561
}
7662

7763
// Ignore DEMS from the attribution list
@@ -176,7 +162,7 @@ export class MapAttribution implements maplibre.IControl {
176162
renderAttribution = (): void => {
177163
if (this.map == null) return;
178164
this._raf = 0;
179-
const attr = MapAttrState._attrsSync.get(Config.map.layerKeyTms);
165+
const attr = MapAttrState.attrsSync.get(Config.map.layerKeyTms);
180166
if (attr == null) return this.setAttribution('');
181167
const filtered = MapAttrState.filterAttributionToMap(attr, this.map);
182168
const filteredLayerIds = filtered.map((x) => x.collection.id).join('_');

packages/landing/src/components/layer.switcher.dropdown.tsx

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Bounds, GoogleTms, Projection } from '@basemaps/geo';
1+
import { intersection, MultiPolygon, Wgs84 } from '@linzjs/geojson';
22
import { ChangeEventHandler, Component, ReactNode } from 'react';
33
import Select from 'react-select';
44

5+
import { MapAttrState } from '../attribution.js';
56
import { Config, GaEvent, gaEvent } from '../config.js';
67
import { LayerInfo, MapConfig } from '../config.map.js';
78

@@ -50,6 +51,8 @@ export class LayerSwitcherDropdown extends Component<unknown, LayerSwitcherDropd
5051
override componentDidMount(): void {
5152
this.setState({ zoomToExtent: true, currentLayer: Config.map.layerKey });
5253

54+
void MapAttrState.getAll().then(() => this.forceUpdate());
55+
5356
void Config.map.layers.then((layers) => {
5457
this.setState({ layers });
5558
// This needs to run on next tick or the sate will not have updated
@@ -187,10 +190,9 @@ export class LayerSwitcherDropdown extends Component<unknown, LayerSwitcherDropd
187190
const filterToExtent = this.state.filterToExtent;
188191

189192
const location = Config.map.location;
190-
const loc3857 = Projection.get(GoogleTms).fromWgs84([location.lon, location.lat]);
191-
const tileSize = GoogleTms.tileSize * GoogleTms.pixelScale(Math.floor(location.zoom)); // width of 1 tile
192-
// Assume the current bounds are 3x3 tiles, todo would be more correct to use the map's bounding box but we dont have access to it here
193-
const bounds = new Bounds(loc3857[0], loc3857[1], 1, 1).scaleFromCenter(3 * tileSize, 3 * tileSize);
193+
if (location == null || location.extent == null) return { options: [], current: null, hidden, total };
194+
195+
const mapExtent = Wgs84.bboxToMultiPolygon(location.extent);
194196

195197
let current: Option | null = null;
196198

@@ -201,15 +203,15 @@ export class LayerSwitcherDropdown extends Component<unknown, LayerSwitcherDropd
201203
// Always show the current layer
202204
if (layer.id !== currentLayer) {
203205
// Limit all other layers to the extent if requested
204-
if (filterToExtent && !doesLayerIntersect(bounds, layer)) {
206+
if (filterToExtent && !doesLayerIntersect(mapExtent, layer)) {
205207
hidden++;
206208
continue;
207209
}
208210
}
209211

210212
const layerId = layer.category ?? 'Unknown';
211213
const layerCategory = categories.get(layerId) ?? { label: layerId, options: [] };
212-
const opt = { value: layer.id, label: layer.name.replace(` ${layer.category}`, '') };
214+
const opt = { value: layer.id, label: layer.title.replace(` ${layer.category}`, '') };
213215
layerCategory.options.push(opt);
214216
categories.set(layerId, layerCategory);
215217
if (layer.id === currentLayer) current = opt;
@@ -228,25 +230,33 @@ export class LayerSwitcherDropdown extends Component<unknown, LayerSwitcherDropd
228230
}
229231

230232
/**
231-
* Determine if the bounds in EPSG:3857 intersects the provided layer
232-
*
233-
* TODO: It would be good to then use a more comprehensive intersection if the bounding box intersects,
234-
* there are complex polygons inside the attribution layer that could be used but they do not have all
235-
* the polygons
233+
* Determine if the polygon intersects the provided layer
236234
*
237-
* @param bounds Bounding box in EPSG:3857
235+
* @param bounds polygon to check
238236
* @param layer layer to check
239237
* @returns true if it intersects, false otherwise
240238
*/
241-
function doesLayerIntersect(bounds: Bounds, layer: LayerInfo): boolean {
239+
function doesLayerIntersect(bounds: MultiPolygon, layer: LayerInfo): boolean {
242240
// No layer information assume it intersects
243241
if (layer.lowerRight == null || layer.upperLeft == null) return true;
244242

245-
// It is somewhat easier to find intersections in EPSG:3857
246-
const ul3857 = Projection.get(GoogleTms).fromWgs84(layer.upperLeft);
247-
const lr3857 = Projection.get(GoogleTms).fromWgs84(layer.lowerRight);
243+
const poly = Wgs84.bboxToMultiPolygon([
244+
layer.lowerRight[0],
245+
layer.upperLeft[1],
246+
layer.upperLeft[0],
247+
layer.lowerRight[1],
248+
]);
249+
250+
const inter = intersection(bounds, poly);
251+
if (inter == null || inter.length === 0) return false;
252+
253+
// No attribution state loaded, assume it intersects
254+
const attrs = MapAttrState.attrsSync.get('all');
255+
if (attrs == null) return true;
248256

249-
const layerBounds = Bounds.fromBbox([ul3857[0], ul3857[1], lr3857[0], lr3857[1]]);
257+
const attrLayer = attrs.attributions.filter((f) => f.collection.title === layer.title);
258+
// Could not find a exact layer match in the attribution
259+
if (attrLayer.length !== 1) return true;
250260

251-
return bounds.intersects(layerBounds);
261+
return attrLayer[0].intersection(bounds);
252262
}

0 commit comments

Comments
 (0)