@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
296 lines (257 loc) • 10.3 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type { Texture } from 'three';
import { UnsignedByteType } from 'three';
import type CoordinateSystem from '../core/geographic/CoordinateSystem';
import type Extent from '../core/geographic/Extent';
import type ImageFormat from '../formats/ImageFormat';
import type { GetImageOptions, ImageResponse, ImageSourceOptions } from './ImageSource';
import EmptyTexture from '../renderer/EmptyTexture';
import MemoryTracker from '../renderer/MemoryTracker';
import { isHttpError } from '../utils/Fetcher';
import TextureGenerator from '../utils/TextureGenerator';
import { nonNull } from '../utils/tsutils';
import ConcurrentDownloader from './ConcurrentDownloader';
import { ImageResult } from './ImageSource';
import ImageSource from './ImageSource';
const DEFAULT_RETRIES = 3;
const DEFAULT_TIMEOUT = 5000;
/**
* Constructor options for {@link UrlImageSource}.
*/
export interface UrlImageSourceOptions extends ImageSourceOptions {
/**
* The URL template to use for image requests. Parameters to substitute must be enclosed in braces, e.g `{minx}`.
* Supported parameters:
* - `{minx}`: The min X (leftmost side) value of the requested extent.
* - `{maxx}`: The max X (rightmost side) value of the requested extent.
* - `{miny}`: The min Y (bottom side) value of the requested extent.
* - `{maxy}`: The max Y (top side) value of the requested extent.
* - `{width}`: The width, in pixels, of the requested image.
* - `{height}`: The height, in pixels, of the requested image.
* - `{epsgCode}`: The numerical code of the request coordinate system.
* For example, if the instance coordinate system is EPSG:3857, `{epsgCode}` will be substituted with the `3857` value.
* ```js
* // A typical GetMap WMS pattern
* const wmsTemplate: "http://example.com?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=MyLayer&STYLES=&SRS=EPSG:{epsgCode}&BBOX={minx},{miny},{maxx},{maxy}&WIDTH={width}&HEIGHT={height}&FORMAT=image/png"
* ```
*/
urlTemplate: string;
/**
* The Coordinate Reference System of the image. If unspecified, will assume that the desired coordinate system is the one of the entity that contains the layer.
*/
crs?: CoordinateSystem;
/**
* The image format decoder to use. Note: for jpeg, png and webp images, no format decoder is required.
*/
format?: ImageFormat;
/**
* The optional extent to use.
*/
extent?: Extent;
/**
* The optional HTTP request timeout, in milliseconds.
*
* @defaultValue 5000
*/
httpTimeout?: number;
/**
* How many retries to execute when an HTTP request ends up in error.
* @defaultValue 3
*/
retries?: number;
/**
* Enable web workers.
* @defaultValue true
*/
enableWorkers?: boolean;
/**
* The optional no-data value.
*/
noDataValue?: number;
}
/**
* Base class for URL-based image sources. Image requests are based on a provided URL
* template that contain parameters replaced with their actual values.
*
* Supported template tokens:
* |Parameter|Value|
* |---|---|
* |`{minx}`|The minimum X coordinate of the request bounding box|
* |`{miny}`|The minimum Y coordinate of the request bounding box|
* |`{maxx}`|The maximum X coordinate of the request bounding box|
* |`{maxy}`|The maximum Y coordinate of the request bounding box|
* |`{width}`|The width, in pixels of the requested image|
* |`{height}`|The height, in pixels of the requested image|
* |`{epsgCode}`|The numerical code of the coordinate system, e.g `3857`|
*
* @example
* ```js
* const source = new UrlImageSource({
* urlTemplate: "http://example.com?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=MyLayer&STYLES=&SRS=EPSG:4326&BBOX={minx},{miny},{maxx},{maxy}&WIDTH={width}&HEIGHT={height}&FORMAT=image/png"
* });
* ```
*/
export default class UrlImageSource extends ImageSource {
public readonly isUrlImageSource = true as const;
public override readonly type: string = 'UrlImageSource' as const;
private readonly _extent: Extent | undefined;
private readonly _downloader: ConcurrentDownloader;
private readonly _enableWorkers: boolean;
private readonly _format: ImageFormat | undefined;
private readonly _noDataValue: number | undefined;
private _urlTemplate: string;
private _crs: CoordinateSystem | undefined;
/** @internal */
public readonly info = {
requestedImages: 0,
loadedImages: 0,
};
/**
* Sets the URL template to a new value. This raises the `updated` event so that the layer can be repainted.
* @param template - The new URL template to use.
*/
public setUrlTemplate(template: string): void {
this._urlTemplate = template;
this.update();
}
public constructor(options: UrlImageSourceOptions) {
super({
...options,
flipY: options.flipY ?? options.format?.flipY ?? false,
is8bit:
options.is8bit ??
(options.format?.dataType ?? UnsignedByteType) === UnsignedByteType,
});
this._urlTemplate = options.urlTemplate;
this._crs = options.crs;
this._extent = options.extent;
this._downloader = new ConcurrentDownloader({
retry: options.retries ?? DEFAULT_RETRIES,
timeout: options.httpTimeout ?? DEFAULT_TIMEOUT,
});
this._enableWorkers = options.enableWorkers ?? true;
this._format = options.format;
this._noDataValue = options.noDataValue;
}
public override initialize(options: { targetProjection: CoordinateSystem }): Promise<void> {
this._crs = this._crs ?? options.targetProjection;
return Promise.resolve();
}
public override getCrs(): CoordinateSystem {
return nonNull(this._crs, 'this source is not yet initialized');
}
public override getExtent(): Extent | null {
return this._extent ?? null;
}
public override getImages(options: GetImageOptions): ImageResponse[] {
const response: ImageResponse = {
id: options.id,
request: () => this.requestImageFromSource(options),
};
return [response];
}
/** @internal */
public generateUrl(options: GetImageOptions): string {
const crs = nonNull(this._crs);
const { minX, maxX, minY, maxY } = options.extent.as(crs);
let url = this._urlTemplate
// Bounding box
.replace('{minx}', minX.toString())
.replace('{maxx}', maxX.toString())
.replace('{miny}', minY.toString())
.replace('{maxy}', maxY.toString())
// Image dimensions
.replace('{width}', options.width.toFixed(0))
.replace('{height}', options.height.toFixed(0));
if (this._urlTemplate.includes('{epsgCode}')) {
let epsgCode = crs.srid?.tryGetEpsgCode();
if (epsgCode == null && crs.id.startsWith('EPSG:')) {
epsgCode = Number.parseInt(crs.id.split(':')[1], 10);
}
if (epsgCode != null) {
// Coordinate system
url = url.replace('{epsgCode}', epsgCode.toString());
} else {
throw new Error(
`could not replace {epsgCode} value in URL because the coordinate system does not have any EPSG code (${crs.id})`,
);
}
}
return encodeURI(url);
}
private async requestImageFromSource(options: GetImageOptions): Promise<ImageResult> {
const { extent, id, signal, createReadableTextures } = options;
this.info.requestedImages++;
const url = this.generateUrl(options);
const blob = await this.fetchData(url, signal);
if (!blob) {
return new ImageResult({
texture: new EmptyTexture(),
extent,
id,
});
}
let texture: Texture;
let min;
let max;
if (this._format) {
const width = options.width;
const height = options.height;
const decoded = await this._format.decode(blob, {
noDataValue: this._noDataValue,
width,
height,
});
texture = decoded.texture;
min = decoded.min;
max = decoded.max;
} else {
texture = await TextureGenerator.decodeBlob(blob, {
createDataTexture: createReadableTextures,
flipY: true,
enableWorkers: this._enableWorkers,
});
texture.flipY = false;
}
texture.generateMipmaps = false;
texture.name = 'UrlImageSource - image';
MemoryTracker.track(texture, texture.name);
this.info.loadedImages++;
return new ImageResult({
texture,
extent,
id,
min,
max,
});
}
private async fetchData(url: string, signal: AbortSignal | undefined): Promise<Blob | null> {
try {
const response = await this._downloader.fetch(url, {
signal,
priority: this.priority,
});
// If the response is 204 No Content for example, we have nothing to do.
// This happens when a tile request is valid, but points to a region with no data.
// Note: we let the HTTP handler do the logging for us in case of 4XX errors.
if (response.status !== 200) {
return null;
}
const blob = await response.blob();
return blob;
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
throw e;
} else if (isHttpError(e)) {
// Do nothing as Fetcher already dispatches events when HTTP errors occur.
} else {
console.error(e);
}
return null;
}
}
}