UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

348 lines (296 loc) 11.2 kB
/* * Copyright (c) 2015-2018, IGN France. * Copyright (c) 2018-2026, Giro3D team. * SPDX-License-Identifier: MIT */ import type { Feature } from 'ol'; import type FeatureFormat from 'ol/format/Feature'; import type { Type } from 'ol/format/Feature'; import GeoJSON from 'ol/format/GeoJSON'; import type { Cache } from '../core/Cache'; import { GlobalCache } from '../core/Cache'; import CoordinateSystem from '../core/geographic/CoordinateSystem'; import Extent from '../core/geographic/Extent'; import Fetcher from '../utils/Fetcher'; import { nonNull } from '../utils/tsutils'; import { processFeatures } from './features/processor'; import { FeatureSourceBase, type GetFeatureRequest, type GetFeatureResult } from './FeatureSource'; /** * A function to build URLs used to query features from the remote source. * @returns The URL of the query, or `undefined`, if the query should not be made at all. */ export type StreamableFeatureSourceQueryBuilder = (params: { extent: Extent; sourceCoordinateSystem: CoordinateSystem; }) => URL | undefined; /** * A query builder to fetch data from an OGC API Features service. * @param serviceUrl - The base URL to the service. * @param collection - The name of the feature collection. * @param options - Optional parameters to customize the query. */ export const ogcApiFeaturesBuilder: ( serverUrl: string, collection: string, options?: { /** * The limit of features to retrieve with each query. * @defaultValue 1000 */ limit?: number; /** * Additional parameters to pass to the query, such as CQL filter, etc, * with the exception of the `limit` (passed with the `limit` option) * and `bbox` parameters (dynamically computed for each query). */ params?: Record<string, string>; }, ) => StreamableFeatureSourceQueryBuilder = (serviceUrl, collection, opts) => { return params => { const url = new URL(`/collections/${collection}/items.json`, serviceUrl); const bbox = params.extent.as(params.sourceCoordinateSystem); url.searchParams.set('bbox', `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`); const limit = opts?.limit ?? 1000; url.searchParams.set('limit', limit.toString()); if (opts?.params) { for (const [key, value] of Object.entries(opts.params)) { url.searchParams.set(key, value); } } return url; }; }; /** * A query builder to fetch data from an WFS service. * @param serviceUrl - The base URL to the service. * @param typename - The name of the feature collection. * @param options - Optional parameters to customize the query. */ export const wfsBuilder: ( serverUrl: string, typename: string, options?: { /** * Additional parameters to pass to the query, with the exception * of the `bbox` parameter (dynamically computed for each query). */ params?: Record<string, string>; }, ) => StreamableFeatureSourceQueryBuilder = (serviceUrl, typename, opts) => { return params => { const url = new URL(serviceUrl); url.searchParams.set('SERVICE', 'WFS'); url.searchParams.set('VERSION', '2.0.0'); url.searchParams.set('request', 'GetFeature'); url.searchParams.set('typename', typename); url.searchParams.set('outputFormat', 'application/json'); // url.searchParams.set('startIndex', '0'); url.searchParams.set('SRSNAME', params.sourceCoordinateSystem.id); const bbox = params.extent.as(params.sourceCoordinateSystem); url.searchParams.set( 'bbox', `${bbox.west},${bbox.south},${bbox.east},${bbox.north},${params.sourceCoordinateSystem.id}`, ); if (opts?.params) { for (const [key, value] of Object.entries(opts.params)) { url.searchParams.set(key, value); } } return url; }; }; export type StreamableFeatureSourceGetter = (url: string, type: Type) => Promise<unknown>; /** * Getter for JSON, text, XML and ArrayBuffer data. */ export const defaultGetter: StreamableFeatureSourceGetter = (url, type) => { switch (type) { case 'arraybuffer': return Fetcher.arrayBuffer(url); case 'json': return Fetcher.json(url); case 'text': return Fetcher.text(url); case 'xml': return Fetcher.xml(url); } }; export interface StreamableFeatureSourceOptions { /** * The query builder. */ queryBuilder: StreamableFeatureSourceQueryBuilder; /** * The format of the features. * @defaultValue {@link GeoJSON} */ format?: FeatureFormat; /** * The function to download and process the data. * @defaultValue {@link defaultGetter} */ getter?: StreamableFeatureSourceGetter; /** * Enable caching of downloaded features. * @defaultValue true */ enableCaching?: boolean; /** * The cache to use. * @defaultValue {@link GlobalCache} */ cache?: Cache; /** * The loading strategy. * @defaultValue {@link defaultLoadingStrategy} */ loadingStrategy?: StreamableFeatureSourceLoadingStrategy; /** * The source coordinate system. * @defaultValue EPSG:4326 */ sourceCoordinateSystem?: CoordinateSystem; /** * Limits the extent in which features are queried. If a feature request is * outside this extent, no query happens. * @defaultValue `null` */ extent?: Extent | null; } export type StreamableFeatureSourceLoadingStrategy = (request: GetFeatureRequest) => { requests: GetFeatureRequest[]; }; /** * A loading strategy that process the entire input request without any filtering or splitting. */ export const defaultLoadingStrategy: StreamableFeatureSourceLoadingStrategy = request => ({ requests: [request], }); /** * Splits the input request into a regular grid of requests to improves caching. */ export const tiledLoadingStrategy: (params?: { /** * The size of the tiles in the grid. Expressed in CRS units (typically meters). * @defaultValue 1000 */ tileSize?: number; }) => StreamableFeatureSourceLoadingStrategy = params => { const tileSize = params?.tileSize ?? 1000; return request => { const extent = request.extent; const xmin = Math.floor(extent.west / tileSize); const xmax = Math.ceil(extent.east / tileSize); const ymin = Math.floor(extent.south / tileSize); const ymax = Math.ceil(extent.north / tileSize); const tileRequests: GetFeatureRequest[] = []; for (let x = xmin; x < xmax; ++x) { for (let y = ymin; y < ymax; ++y) { const tileExtent = new Extent( extent.crs, x * tileSize, (x + 1) * tileSize, y * tileSize, (y + 1) * tileSize, ); tileRequests.push({ extent: tileExtent, signal: request.signal, }); } } return { requests: tileRequests }; }; }; /** * A feature source that supports streaming features from a * remote server (e.g OGC API Features, etc) */ export default class StreamableFeatureSource extends FeatureSourceBase { public readonly isStreamableFeatureSource = true as const; public readonly type = 'StreamableFeatureSource' as const; private readonly _options: Required<StreamableFeatureSourceOptions>; public constructor(params: StreamableFeatureSourceOptions) { super(); this._options = { queryBuilder: params.queryBuilder, format: params.format ?? new GeoJSON(), getter: params.getter ?? defaultGetter, loadingStrategy: params.loadingStrategy ?? defaultLoadingStrategy, extent: params.extent ?? null, cache: params.cache ?? GlobalCache, enableCaching: params.enableCaching ?? true, sourceCoordinateSystem: params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326, }; } private async processRequest(request: GetFeatureRequest): Promise<GetFeatureResult> { const url = this._options.queryBuilder({ extent: request.extent, sourceCoordinateSystem: this._options.sourceCoordinateSystem, }); if (!url) { return { features: [] }; } const urlString = url.toString(); if (this._options.enableCaching) { const cached = this._options.cache.get(urlString); if (cached != null) { return cached as GetFeatureResult; } } const { getter, format, sourceCoordinateSystem } = this._options; const targetCoordinateSystem = nonNull(this._targetCoordinateSystem); const data = await getter(urlString, format.getType()); const features = format.readFeatures(data) as Feature[]; const getFeatureId = (feature: Feature): number | string => { return ( feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') ); }; const processedFeatures = await processFeatures( features, sourceCoordinateSystem, targetCoordinateSystem, { getFeatureId, }, ); const result: GetFeatureResult = { features: processedFeatures }; if (this._options.enableCaching) { this._options.cache.set(urlString, result); } return result; } public async getFeatures(request: GetFeatureRequest): Promise<GetFeatureResult> { this.throwIfNotInitialized(); let west = request.extent.west; let east = request.extent.east; let south = request.extent.south; let north = request.extent.north; if (this._options.extent) { west = Math.max(west, this._options.extent.west); east = Math.min(east, this._options.extent.east); south = Math.max(south, this._options.extent.south); north = Math.min(north, this._options.extent.north); } if (west >= east || south >= north) { // Empty extent return { features: [] }; } const adjustedExtent = new Extent(request.extent.crs, { east, north, south, west }); const strategy = nonNull(this._options.loadingStrategy); const { requests } = strategy({ extent: adjustedExtent, signal: request.signal, }); if (requests.length === 0) { return { features: [] }; } const promises: Promise<GetFeatureResult>[] = []; for (const subRequest of requests) { promises.push(this.processRequest(subRequest)); } const results = await Promise.all(promises); return { features: results.flatMap(item => item.features) }; } }