Skip to content

Commit

Permalink
feat(landing): store the maps bounds to provide a better bounding box…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
blacha authored Sep 25, 2024
1 parent 4bc33ff commit a420f57
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 82 deletions.
40 changes: 40 additions & 0 deletions packages/geo/src/stac/stac.attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,59 @@ import { StacCatalog, StacCollection, StacItem } from './index.js';
* A Single File STAC compliant collection with zoom and priority for calculating attribution of an extent
*/
export type AttributionCollection = StacCollection<{
/**
* Category of the layer
*
* @example "Urban Aerial Photos"
*/
'linz:category'?: string;

/**
* Zoom levels that the layer is visit
*/
'linz:zoom': { min: number; max: number };

/**
* Priority order for the layer
*
* The higher the number the higher the priority
*
* @example [1077]
*/
'linz:priority': [number];
}>;

/**
* A Single File STAC compliant feature for calculating attribution of an extent
*/
export type AttributionItem = StacItem<{
/**
* Human friendly title of the layer
*
* @example "Whanganui 0.075m Urban Aerial Photos (2017)"
*/
title: string;

/**
* Category of the layer
*
* @example "Urban Aerial Photos"
*/
category?: string;

/**
* datetime is null as per STAC requirement when {@link start_datetime} and {@link end_datetime} are set
*/
datetime?: null;

/**
* datetime of when the layer started being captured
*/
start_datetime?: string;

/**
* datetime of when the layer stopped being captured
*/
end_datetime?: string;
}>;

Expand Down
3 changes: 3 additions & 0 deletions packages/lambda-tiler/src/routes/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,13 @@ async function tileSetAttribution(
for (const layer of tileSet.layers) {
const imgId = layer[proj.epsg.code];
if (imgId == null) continue;

const im = imagery.get(imgId);
if (im == null) continue;

const title = im.title;
const years = extractYearRangeFromTitle(im.title) ?? extractYearRangeFromName(im.name);

if (years == null) continue;
const interval = yearRangeToInterval(years);

Expand Down
90 changes: 38 additions & 52 deletions packages/landing/src/attribution.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,53 @@
import { Attribution } from '@basemaps/attribution';
import { AttributionBounds } from '@basemaps/attribution/build/attribution.js';
import { GoogleTms, Stac, TileMatrixSet } from '@basemaps/geo';
import { BBox } from '@linzjs/geojson';
import { GoogleTms, Stac } from '@basemaps/geo';
import * as maplibre from 'maplibre-gl';

import { onMapLoaded } from './components/map.js';
import { Config } from './config.js';
import { locationTransform } from './tile.matrix.js';
import { mapToBoundingBox } from './tile.matrix.js';
import { MapOptionType } from './url.js';

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

export class MapAttributionState {
/** Cache the loading of attribution */
_attrs: Map<string, Promise<Attribution | null>> = new Map();
/** Rendering process needs synch access */
_attrsSync: Map<string, Attribution> = new Map();
attrs: Map<string, Promise<Attribution | null>> = new Map();
/** Rendering process needs sync access */
attrsSync: Map<string, Attribution> = new Map();

private getLayer(layerKey: string, url: string): Promise<Attribution | null> {
const layer = this.attrs.get(layerKey);
if (layer != null) return layer;
const loader = Attribution.load(url)
.catch(() => null)
.then((ret) => {
if (ret == null) {
this.attrs.delete(layerKey);
this.attrsSync.delete(layerKey);
} else {
this.attrsSync.set(layerKey, ret);
}
return ret;
});
this.attrs.set(layerKey, loader);
return loader;
}

/**
* Load the attribution fo all layers
* @returns
*/
getAll(): Promise<Attribution | null> {
return this.getLayer('all', Config.map.toTileUrl(MapOptionType.Attribution, GoogleTms, 'all', null));
}

/** Load a attribution from a url, return a cached copy if we have one */
getCurrentAttribution(): Promise<Attribution | null> {
const cacheKey = Config.map.layerKeyTms;
let attrs = this._attrs.get(cacheKey);
if (attrs == null) {
attrs = Attribution.load(Config.map.toTileUrl(MapOptionType.Attribution)).catch(() => null);
this._attrs.set(cacheKey, attrs);
void attrs.then((a) => {
if (a == null) return;
a.isIgnored = this.isIgnored;
this._attrsSync.set(Config.map.layerKeyTms, a);
});
}
return attrs;
return this.getLayer(Config.map.layerKeyTms, Config.map.toTileUrl(MapOptionType.Attribution)).then((ret) => {
if (ret != null) ret.isIgnored = this.isIgnored;
return ret;
});
}

/** Filter the attribution to the map bounding box */
Expand All @@ -39,39 +56,8 @@ export class MapAttributionState {
// Note that Mapbox rendering 512×512 image tiles are offset by one zoom level compared to 256×256 tiles.
// For example, 512×512 tiles at zoom level 4 are equivalent to 256×256 tiles at zoom level 5.
zoom += 1;
const extent = MapAttributionState.mapboxBoundToBbox(map.getBounds(), zoom, Config.map.tileMatrix);
return attr.filter({
extent,
zoom: zoom,
dateBefore: Config.map.filter.date.before,
});
}

getAttributionByYear(attribution: AttributionBounds[]): Map<number, AttributionBounds[]> {
const attrsByYear = new Map<number, AttributionBounds[]>();
for (const a of attribution) {
if (!a.startDate || !a.endDate) continue;
const startYear = Number(a.startDate.slice(0, 4));
const endYear = Number(a.endDate.slice(0, 4));
for (let year = startYear; year <= endYear; year++) {
const attrs = attrsByYear.get(year) ?? [];
attrs.push(a);
attrsByYear.set(year, attrs);
}
}
return attrsByYear;
}

/**
* Covert Mapbox Bounds to tileMatrix BBox
*/
static mapboxBoundToBbox(bounds: maplibre.LngLatBounds, zoom: number, tileMatrix: TileMatrixSet): BBox {
const swLocation = { lon: bounds.getWest(), lat: bounds.getSouth(), zoom: zoom };
const neLocation = { lon: bounds.getEast(), lat: bounds.getNorth(), zoom: zoom };
const swCoord = locationTransform(swLocation, GoogleTms, tileMatrix);
const neCoord = locationTransform(neLocation, GoogleTms, tileMatrix);
const bbox: BBox = [swCoord.lon, swCoord.lat, neCoord.lon, neCoord.lat];
return bbox;
const extent = mapToBoundingBox(map, zoom, Config.map.tileMatrix);
return attr.filter({ extent, zoom });
}

// Ignore DEMS from the attribution list
Expand Down Expand Up @@ -176,7 +162,7 @@ export class MapAttribution implements maplibre.IControl {
renderAttribution = (): void => {
if (this.map == null) return;
this._raf = 0;
const attr = MapAttrState._attrsSync.get(Config.map.layerKeyTms);
const attr = MapAttrState.attrsSync.get(Config.map.layerKeyTms);
if (attr == null) return this.setAttribution('');
const filtered = MapAttrState.filterAttributionToMap(attr, this.map);
const filteredLayerIds = filtered.map((x) => x.collection.id).join('_');
Expand Down
48 changes: 29 additions & 19 deletions packages/landing/src/components/layer.switcher.dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Bounds, GoogleTms, Projection } from '@basemaps/geo';
import { intersection, MultiPolygon, Wgs84 } from '@linzjs/geojson';
import { ChangeEventHandler, Component, ReactNode } from 'react';
import Select from 'react-select';

import { MapAttrState } from '../attribution.js';
import { Config, GaEvent, gaEvent } from '../config.js';
import { LayerInfo, MapConfig } from '../config.map.js';

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

void MapAttrState.getAll().then(() => this.forceUpdate());

void Config.map.layers.then((layers) => {
this.setState({ layers });
// This needs to run on next tick or the sate will not have updated
Expand Down Expand Up @@ -187,10 +190,9 @@ export class LayerSwitcherDropdown extends Component<unknown, LayerSwitcherDropd
const filterToExtent = this.state.filterToExtent;

const location = Config.map.location;
const loc3857 = Projection.get(GoogleTms).fromWgs84([location.lon, location.lat]);
const tileSize = GoogleTms.tileSize * GoogleTms.pixelScale(Math.floor(location.zoom)); // width of 1 tile
// 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
const bounds = new Bounds(loc3857[0], loc3857[1], 1, 1).scaleFromCenter(3 * tileSize, 3 * tileSize);
if (location == null || location.extent == null) return { options: [], current: null, hidden, total };

const mapExtent = Wgs84.bboxToMultiPolygon(location.extent);

let current: Option | null = null;

Expand All @@ -201,15 +203,15 @@ export class LayerSwitcherDropdown extends Component<unknown, LayerSwitcherDropd
// Always show the current layer
if (layer.id !== currentLayer) {
// Limit all other layers to the extent if requested
if (filterToExtent && !doesLayerIntersect(bounds, layer)) {
if (filterToExtent && !doesLayerIntersect(mapExtent, layer)) {
hidden++;
continue;
}
}

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

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

// It is somewhat easier to find intersections in EPSG:3857
const ul3857 = Projection.get(GoogleTms).fromWgs84(layer.upperLeft);
const lr3857 = Projection.get(GoogleTms).fromWgs84(layer.lowerRight);
const poly = Wgs84.bboxToMultiPolygon([
layer.lowerRight[0],
layer.upperLeft[1],
layer.upperLeft[0],
layer.lowerRight[1],
]);

const inter = intersection(bounds, poly);
if (inter == null || inter.length === 0) return false;

// No attribution state loaded, assume it intersects
const attrs = MapAttrState.attrsSync.get('all');
if (attrs == null) return true;

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

return bounds.intersects(layerBounds);
return attrLayer[0].intersection(bounds);
}
Loading

0 comments on commit a420f57

Please sign in to comment.