@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
222 lines (180 loc) • 7.67 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import { MathUtils } from 'three';
import type CoordinateSystem from '../core/geographic/CoordinateSystem';
import type { GetMemoryUsageContext } from '../core/MemoryUsage';
import type { GetImageOptions, ImageResponse, ImageResult } from './ImageSource';
import Extent from '../core/geographic/Extent';
import { nonEmpty, nonNull } from '../utils/tsutils';
import ImageSource from './ImageSource';
interface SourceProperties {
zIndex: number;
id: string;
visible: boolean;
}
/**
* An image source that aggregates several sub-sources.
* The extent of this source is the union of the extent of all sub-sources.
*
* Overlapping sources are stacked vertically with the sources toward the end of the array
* being drawn on top of sources at the beginning of the array.
*
* Constraints:
* - all sub-sources must have the same CRS.
* - all sub-sources must have the same color space
* - all sub-sources must have the same flip-Y parameter
* - all sub-sources must produce textures that have the same datatype (e.g either 8-bit or 32-bit textures, but not both)
*/
export default class AggregateImageSource extends ImageSource {
public readonly isAggregateImageSource = true as const;
public override readonly type = 'AggregateImageSource' as const;
private readonly _sources: Readonly<ImageSource[]>;
private readonly _sourceProperties: Map<ImageSource, SourceProperties> = new Map();
private _cachedExtent: Extent | null = null;
public constructor(options: {
/**
* The sub-sources. The order in which they appear in the array will set their z-index
* (i.e sources at the end of the array will be displayed on top).
*/
sources: ImageSource[];
}) {
super({
// Since we are stacking images, they must support transparency
transparent: true,
flipY: options.sources[0].flipY,
colorSpace: options.sources[0].colorSpace,
synchronous: options.sources.every(s => s.synchronous),
});
this._sources = Object.freeze(nonEmpty(options.sources, 'at least one source is expected'));
let zIndex = 0;
for (const source of this._sources) {
// Ensure that we bubble the events from the sub-sources
source.addEventListener('updated', e => this.update(e.extent));
this._sourceProperties.set(source, {
zIndex,
// We must assign a unique ID to each sub-source to avoid duplicate images,
id: MathUtils.generateUUID(),
visible: true,
});
zIndex++;
}
}
/**
* The sources in this source.
*/
public get sources(): Readonly<ImageSource[]> {
return this._sources;
}
public override async initialize(options: {
targetProjection: CoordinateSystem;
}): Promise<void> {
const promises = this._sources.map(source => source.initialize(options));
await Promise.allSettled(promises);
}
public override getCrs(): CoordinateSystem {
return this._sources[0].getCrs();
}
/**
* Sets the visibility of a sub-source. This will trigger a repaint of the source.
* @param source - The source to update.
* @param visible - The new visibility.
* @throws if the sub-source is not present in this source.
*/
public setSourceVisibility(source: ImageSource, visible: boolean): void {
const props = nonNull(this._sourceProperties.get(source), 'this source is not present');
if (props.visible !== visible) {
props.visible = visible;
this.update(this.getExtent() ?? undefined);
}
}
/**
* Returns the union of the extent of all the sub-sources, if possible.
* If at least one source does not have a known extent, then the entire aggregate extent is `undefined`.
*/
public override getExtent(): Extent | null {
if (this._cachedExtent == null) {
const allSources = [...this._sourceProperties.keys()];
const extents = allSources.map(source => source.getExtent()).filter(e => e != null);
// To avoid advertising an incorrect extent, all sub-sources must have a valid extent,
// otherwise we consider that we cannot compute the aggregate extent.
// If we considered only the sub-sources with a valid extent, then the aggregate
// extent would be partial and thus incorrect.
if (extents.length === allSources.length) {
const extent = Extent.unionMany(...extents);
this._cachedExtent = extent;
}
}
return this._cachedExtent ?? null;
}
public override getMemoryUsage(context: GetMemoryUsageContext): void {
this._sources.forEach(source => source.getMemoryUsage(context));
}
private patchRequest(
source: ImageSource,
request: ImageResponse['request'],
): ImageResponse['request'] {
const { zIndex, id } = nonNull(this._sourceProperties.get(source));
const patched = (): Promise<ImageResult> | ImageResult => {
const result = request();
if (result instanceof Promise) {
return result.then(img => {
img.zIndex = zIndex;
img.id = `${img.id}-${id}`;
return img;
});
} else {
result.zIndex = zIndex;
result.id = `${result.id}-${id}`;
return result;
}
};
// @ts-expect-error slightly different typing but it should be ok
return patched;
}
/**
* Returns true if the extent intersects with any sub-source's extent.
*/
public override contains(extent: Extent): boolean {
const convertedExtent = extent.clone().as(this.getCrs());
return this._sources.some(source => source.contains(convertedExtent));
}
/**
* Disposes all sub-sources.
*/
public override dispose(): void {
this._sources.forEach(source => source.dispose());
}
/**
* Patches the response provided by the sub-source with specific per-source properties.
*/
private patchResponse(source: ImageSource, response: ImageResponse): ImageResponse {
const { id } = nonNull(this._sourceProperties.get(source));
const result: ImageResponse = {
// Since each sub-source will possibly return the same ID, we have to deduplicate
// it so that the layer does not think that those are duplicate responses that should be eliminated.
id: `${response.id}-${id}`,
request: this.patchRequest(source, response.request),
};
return result;
}
public override getImages(options: GetImageOptions): ImageResponse[] {
const result: ImageResponse[] = [];
for (const source of this._sources) {
const { visible } = nonNull(this._sourceProperties.get(source));
if (!visible) {
continue;
}
const extent = source.getExtent();
if (extent == null || extent.intersectsExtent(options.extent) === true) {
const images = source
.getImages(options)
.map(response => this.patchResponse(source, response));
result.push(...images);
}
}
return result;
}
}