UNPKG

mapillary-js

Version:

A WebGL interactive street imagery library

861 lines (760 loc) 37.5 kB
import * as THREE from "three"; import { combineLatest as observableCombineLatest, concat as observableConcat, empty as observableEmpty, zip as observableZip, Observable, Subscription, Subject, } from "rxjs"; import { catchError, debounceTime, distinctUntilChanged, filter, first, map, pairwise, publishReplay, refCount, scan, skipWhile, startWith, switchMap, withLatestFrom, } from "rxjs/operators"; import { SliderImages, SliderCombination, GLRendererOperation, PositionLookat, } from "./interfaces/SliderInterfaces"; import { Image } from "../../graph/Image"; import { Container } from "../../viewer/Container"; import { Navigator } from "../../viewer/Navigator"; import { Spatial } from "../../geo/Spatial"; import { ViewportCoords } from "../../geo/ViewportCoords"; import { RenderPass } from "../../render/RenderPass"; import { GLRenderHash } from "../../render/interfaces/IGLRenderHash"; import { ViewportSize } from "../../render/interfaces/ViewportSize"; import { VirtualNodeHash } from "../../render/interfaces/VirtualNodeHash"; import { RenderCamera } from "../../render/RenderCamera"; import { IAnimationState } from "../../state/interfaces/IAnimationState"; import { AnimationFrame } from "../../state/interfaces/AnimationFrame"; import { State } from "../../state/State"; import { TileLoader } from "../../tile/TileLoader"; import { TileStore } from "../../tile/TileStore"; import { TileBoundingBox } from "../../tile/interfaces/TileBoundingBox"; import { TileRegionOfInterest } from "../../tile/interfaces/TileRegionOfInterest"; import { RegionOfInterestCalculator } from "../../tile/RegionOfInterestCalculator"; import { TextureProvider } from "../../tile/TextureProvider"; import { Component } from "../Component"; import { SliderConfiguration, SliderConfigurationMode, } from "../interfaces/SliderConfiguration"; import { SliderGLRenderer } from "./SliderGLRenderer"; import { Transform } from "../../geo/Transform"; import { SliderDOMRenderer } from "./SliderDOMRenderer"; import { isSpherical } from "../../geo/Geo"; import { ComponentName } from "../ComponentName"; /** * @class SliderComponent * * @classdesc Component for comparing pairs of images. Renders * a slider for adjusting the curtain of the first image. * * Deactivate the sequence, direction and image plane * components when activating the slider component to avoid * interfering UI elements. * * To retrive and use the slider component * * @example * ```js * var viewer = new Viewer({ ... }); * * viewer.deactivateComponent("image"); * viewer.deactivateComponent("direction"); * viewer.deactivateComponent("sequence"); * * viewer.activateComponent("slider"); * * var sliderComponent = viewer.getComponent("slider"); * ``` */ export class SliderComponent extends Component<SliderConfiguration> { public static componentName: ComponentName = "slider"; private _viewportCoords: ViewportCoords; private _domRenderer: SliderDOMRenderer; private _imageTileLoader: TileLoader; private _roiCalculator: RegionOfInterestCalculator; private _spatial: Spatial; private _glRendererOperation$: Subject<GLRendererOperation>; private _glRenderer$: Observable<SliderGLRenderer>; private _glRendererCreator$: Subject<void>; private _glRendererDisposer$: Subject<void>; private _waitSubscription: Subscription; /** @ignore */ constructor( name: string, container: Container, navigator: Navigator, viewportCoords?: ViewportCoords) { super(name, container, navigator); this._viewportCoords = !!viewportCoords ? viewportCoords : new ViewportCoords(); this._domRenderer = new SliderDOMRenderer(container); this._imageTileLoader = new TileLoader(navigator.api); this._roiCalculator = new RegionOfInterestCalculator(); this._spatial = new Spatial(); this._glRendererOperation$ = new Subject<GLRendererOperation>(); this._glRendererCreator$ = new Subject<void>(); this._glRendererDisposer$ = new Subject<void>(); this._glRenderer$ = this._glRendererOperation$.pipe( scan( (glRenderer: SliderGLRenderer, operation: GLRendererOperation): SliderGLRenderer => { return operation(glRenderer); }, null), filter( (glRenderer: SliderGLRenderer): boolean => { return glRenderer != null; }), distinctUntilChanged( undefined, (glRenderer: SliderGLRenderer): number => { return glRenderer.frameId; })); this._glRendererCreator$.pipe( map( (): GLRendererOperation => { return (glRenderer: SliderGLRenderer): SliderGLRenderer => { if (glRenderer != null) { throw new Error("Multiple slider states can not be created at the same time"); } return new SliderGLRenderer(); }; })) .subscribe(this._glRendererOperation$); this._glRendererDisposer$.pipe( map( (): GLRendererOperation => { return (glRenderer: SliderGLRenderer): SliderGLRenderer => { glRenderer.dispose(); return null; }; })) .subscribe(this._glRendererOperation$); } protected _activate(): void { const subs = this._subscriptions; subs.push(this._domRenderer.mode$ .subscribe( (mode: SliderConfigurationMode): void => { this.configure({ mode }); })); subs.push(this._glRenderer$.pipe( map( (glRenderer: SliderGLRenderer): GLRenderHash => { let renderHash: GLRenderHash = { name: this._name, renderer: { frameId: glRenderer.frameId, needsRender: glRenderer.needsRender, render: glRenderer.render.bind(glRenderer), pass: RenderPass.Background, }, }; return renderHash; })) .subscribe(this._container.glRenderer.render$)); const position$ = observableConcat( this.configuration$.pipe( map( (configuration: SliderConfiguration): number => { return configuration.initialPosition != null ? configuration.initialPosition : 1; }), first()), this._domRenderer.position$); const mode$ = this.configuration$.pipe( map( (configuration: SliderConfiguration): SliderConfigurationMode => { return configuration.mode; }), distinctUntilChanged()); const motionless$ = this._navigator.stateService.currentState$.pipe( map( (frame: AnimationFrame): boolean => { return frame.state.motionless; }), distinctUntilChanged()); const spherical$ = this._navigator.stateService.currentState$.pipe( map( (frame: AnimationFrame): boolean => { return isSpherical(frame.state.currentImage.cameraType); }), distinctUntilChanged()); const sliderVisible$ = observableCombineLatest( this._configuration$.pipe( map( (configuration: SliderConfiguration): boolean => { return configuration.sliderVisible; })), this._navigator.stateService.currentState$.pipe( map( (frame: AnimationFrame): boolean => { return !(frame.state.currentImage == null || frame.state.previousImage == null || (isSpherical( frame.state.currentImage.cameraType) && !isSpherical( frame.state.previousImage.cameraType))); }), distinctUntilChanged())).pipe( map( ([sliderVisible, enabledState]: [boolean, boolean]): boolean => { return sliderVisible && enabledState; }), distinctUntilChanged()); this._waitSubscription = observableCombineLatest( mode$, motionless$, spherical$, sliderVisible$).pipe( withLatestFrom(this._navigator.stateService.state$)) .subscribe( ([[mode, motionless, spherical, sliderVisible], state]: [[SliderConfigurationMode, boolean, boolean, boolean], State]): void => { const interactive: boolean = sliderVisible && (motionless || mode === SliderConfigurationMode.Stationary || spherical); if (interactive && state !== State.WaitingInteractively) { this._navigator.stateService.waitInteractively(); } else if (!interactive && state !== State.Waiting) { this._navigator.stateService.wait(); } }); subs.push(observableCombineLatest( position$, mode$, motionless$, spherical$, sliderVisible$) .subscribe( ([position, mode, motionless, spherical]: [number, SliderConfigurationMode, boolean, boolean, boolean]): void => { if (motionless || mode === SliderConfigurationMode.Stationary || spherical) { this._navigator.stateService.moveTo(1); } else { this._navigator.stateService.moveTo(position); } })); subs.push(observableCombineLatest( position$, mode$, motionless$, spherical$, sliderVisible$, this._container.renderService.size$).pipe( map( ([position, mode, motionless, spherical, sliderVisible]: [number, SliderConfigurationMode, boolean, boolean, boolean, ViewportSize]): VirtualNodeHash => { return { name: this._name, vNode: this._domRenderer.render(position, mode, motionless, spherical, sliderVisible), }; })) .subscribe(this._container.domRenderer.render$)); this._glRendererCreator$.next(null); subs.push(observableCombineLatest( position$, spherical$, sliderVisible$, this._container.renderService.renderCamera$, this._navigator.stateService.currentTransform$).pipe( map( ([position, spherical, visible, render, transform]: [number, boolean, boolean, RenderCamera, Transform]): number => { if (!spherical) { return visible ? position : 1; } const basicMin: number[] = this._viewportCoords.viewportToBasic(-1.15, 0, transform, render.perspective); const basicMax: number[] = this._viewportCoords.viewportToBasic(1.15, 0, transform, render.perspective); const shiftedMax: number = basicMax[0] < basicMin[0] ? basicMax[0] + 1 : basicMax[0]; const basicPosition: number = basicMin[0] + position * (shiftedMax - basicMin[0]); return basicPosition > 1 ? basicPosition - 1 : basicPosition; }), map( (position: number): GLRendererOperation => { return (glRenderer: SliderGLRenderer): SliderGLRenderer => { glRenderer.updateCurtain(position); return glRenderer; }; })) .subscribe(this._glRendererOperation$)); subs.push(observableCombineLatest( this._navigator.stateService.currentState$, mode$).pipe( map( ([frame, mode]: [AnimationFrame, SliderConfigurationMode]): GLRendererOperation => { return (glRenderer: SliderGLRenderer): SliderGLRenderer => { glRenderer.update(frame, mode); return glRenderer; }; })) .subscribe(this._glRendererOperation$)); subs.push(this._configuration$.pipe( filter( (configuration: SliderConfiguration): boolean => { return configuration.ids != null; }), switchMap( (configuration: SliderConfiguration): Observable<SliderCombination> => { return observableZip( observableZip( this._catchCacheImage$( configuration.ids.background), this._catchCacheImage$( configuration.ids.foreground)).pipe( map( (images: [Image, Image]) : SliderImages => { return { background: images[0], foreground: images[1] }; })), this._navigator.stateService.currentState$.pipe(first())).pipe( map( (nf: [SliderImages, AnimationFrame]): SliderCombination => { return { images: nf[0], state: nf[1].state }; })); })) .subscribe( (co: SliderCombination): void => { if (co.state.currentImage != null && co.state.previousImage != null && co.state.currentImage.id === co.images.foreground.id && co.state.previousImage.id === co.images.background.id) { return; } if (co.state.currentImage.id === co.images.background.id) { this._navigator.stateService.setImages([co.images.foreground]); return; } if (co.state.currentImage.id === co.images.foreground.id && co.state.trajectory.length === 1) { this._navigator.stateService.prependImages([co.images.background]); return; } this._navigator.stateService.setImages([co.images.background]); this._navigator.stateService.setImages([co.images.foreground]); }, (e: Error): void => { console.error(e); })); const textureProvider$ = this._container.configurationService.imageTiling$.pipe( switchMap( (active): Observable<AnimationFrame> => { return active ? this._navigator.stateService.currentState$ : new Subject(); }), distinctUntilChanged( undefined, (frame: AnimationFrame): string => { return frame.state.currentImage.id; }), withLatestFrom( this._container.glRenderer.webGLRenderer$, this._container.renderService.size$), map( ([frame, renderer, size]: [AnimationFrame, THREE.WebGLRenderer, ViewportSize]): TextureProvider => { const state: IAnimationState = frame.state; const viewportSize: number = Math.max(size.width, size.height); const currentImage: Image = state.currentImage; const currentTransform: Transform = state.currentTransform; const tileSize: number = viewportSize > 2048 ? 2048 : viewportSize > 1024 ? 1024 : 512; return new TextureProvider( currentImage.id, currentTransform.basicWidth, currentTransform.basicHeight, currentImage.image, this._imageTileLoader, new TileStore(), renderer); }), publishReplay(1), refCount()); subs.push(textureProvider$.subscribe(() => { /*noop*/ })); subs.push(textureProvider$.pipe( map( (provider: TextureProvider): GLRendererOperation => { return (renderer: SliderGLRenderer): SliderGLRenderer => { renderer.setTextureProvider(provider.id, provider); return renderer; }; })) .subscribe(this._glRendererOperation$)); subs.push(textureProvider$.pipe( pairwise()) .subscribe( (pair: [TextureProvider, TextureProvider]): void => { let previous: TextureProvider = pair[0]; previous.abort(); })); const roiTrigger$ = this._container.configurationService.imageTiling$.pipe( switchMap( (active): Observable<[RenderCamera, ViewportSize]> => { return active ? observableCombineLatest( this._container.renderService.renderCameraFrame$, this._container.renderService.size$.pipe(debounceTime(250))) : new Subject(); }), map( ([camera, size]: [RenderCamera, ViewportSize]): PositionLookat => { return [ camera.camera.position.clone(), camera.camera.lookat.clone(), camera.zoom.valueOf(), size.height.valueOf(), size.width.valueOf()]; }), pairwise(), skipWhile( (pls: [PositionLookat, PositionLookat]): boolean => { return pls[1][2] - pls[0][2] < 0 || pls[1][2] === 0; }), map( (pls: [PositionLookat, PositionLookat]): boolean => { let samePosition: boolean = pls[0][0].equals(pls[1][0]); let sameLookat: boolean = pls[0][1].equals(pls[1][1]); let sameZoom: boolean = pls[0][2] === pls[1][2]; let sameHeight: boolean = pls[0][3] === pls[1][3]; let sameWidth: boolean = pls[0][4] === pls[1][4]; return samePosition && sameLookat && sameZoom && sameHeight && sameWidth; }), distinctUntilChanged(), filter( (stalled: boolean): boolean => { return stalled; }), switchMap( (): Observable<RenderCamera> => { return this._container.renderService.renderCameraFrame$.pipe( first()); }), withLatestFrom( this._container.renderService.size$, this._navigator.stateService.currentTransform$)); subs.push(textureProvider$.pipe( switchMap( (provider: TextureProvider): Observable<[TileRegionOfInterest, TextureProvider]> => { return roiTrigger$.pipe( map( ([camera, size, transform]: [RenderCamera, ViewportSize, Transform]): [TileRegionOfInterest, TextureProvider] => { return [ this._roiCalculator.computeRegionOfInterest(camera, size, transform), provider, ]; })); }), filter( (args: [TileRegionOfInterest, TextureProvider]): boolean => { return !args[1].disposed; })) .subscribe( (args: [TileRegionOfInterest, TextureProvider]): void => { let roi: TileRegionOfInterest = args[0]; let provider: TextureProvider = args[1]; provider.setRegionOfInterest(roi); })); const hasTexture$ = textureProvider$.pipe( switchMap( (provider: TextureProvider): Observable<boolean> => { return provider.hasTexture$; }), startWith(false), publishReplay(1), refCount()); subs.push(hasTexture$.subscribe(() => { /*noop*/ })); const textureProviderPrev$ = this._container.configurationService.imageTiling$.pipe( switchMap( (active): Observable<AnimationFrame> => { return active ? this._navigator.stateService.currentState$ : new Subject(); }), filter( (frame: AnimationFrame): boolean => { return !!frame.state.previousImage; }), distinctUntilChanged( undefined, (frame: AnimationFrame): string => { return frame.state.previousImage.id; }), withLatestFrom( this._container.glRenderer.webGLRenderer$, this._container.renderService.size$), map( ([frame, renderer, size]: [AnimationFrame, THREE.WebGLRenderer, ViewportSize]): TextureProvider => { const state = frame.state; const previousImage = state.previousImage; const previousTransform = state.previousTransform; return new TextureProvider( previousImage.id, previousTransform.basicWidth, previousTransform.basicHeight, previousImage.image, this._imageTileLoader, new TileStore(), renderer); }), publishReplay(1), refCount()); subs.push(textureProviderPrev$.subscribe(() => { /*noop*/ })); subs.push(textureProviderPrev$.pipe( map( (provider: TextureProvider): GLRendererOperation => { return (renderer: SliderGLRenderer): SliderGLRenderer => { renderer.setTextureProviderPrev(provider.id, provider); return renderer; }; })) .subscribe(this._glRendererOperation$)); subs.push(textureProviderPrev$.pipe( pairwise()) .subscribe( (pair: [TextureProvider, TextureProvider]): void => { let previous: TextureProvider = pair[0]; previous.abort(); })); const roiTriggerPrev$ = this._container.configurationService.imageTiling$.pipe( switchMap( (active): Observable<[RenderCamera, ViewportSize]> => { return active ? observableCombineLatest( this._container.renderService.renderCameraFrame$, this._container.renderService.size$.pipe(debounceTime(250))) : new Subject(); }), map( ([camera, size]: [RenderCamera, ViewportSize]): PositionLookat => { return [ camera.camera.position.clone(), camera.camera.lookat.clone(), camera.zoom.valueOf(), size.height.valueOf(), size.width.valueOf()]; }), pairwise(), skipWhile( (pls: [PositionLookat, PositionLookat]): boolean => { return pls[1][2] - pls[0][2] < 0 || pls[1][2] === 0; }), map( (pls: [PositionLookat, PositionLookat]): boolean => { let samePosition: boolean = pls[0][0].equals(pls[1][0]); let sameLookat: boolean = pls[0][1].equals(pls[1][1]); let sameZoom: boolean = pls[0][2] === pls[1][2]; let sameHeight: boolean = pls[0][3] === pls[1][3]; let sameWidth: boolean = pls[0][4] === pls[1][4]; return samePosition && sameLookat && sameZoom && sameHeight && sameWidth; }), distinctUntilChanged(), filter( (stalled: boolean): boolean => { return stalled; }), switchMap( (): Observable<RenderCamera> => { return this._container.renderService.renderCameraFrame$.pipe( first()); }), withLatestFrom( this._container.renderService.size$, this._navigator.stateService.currentTransform$)); subs.push(textureProviderPrev$.pipe( switchMap( (provider: TextureProvider): Observable<[TileRegionOfInterest, TextureProvider]> => { return roiTriggerPrev$.pipe( map( ([camera, size, transform]: [RenderCamera, ViewportSize, Transform]): [TileRegionOfInterest, TextureProvider] => { return [ this._roiCalculator.computeRegionOfInterest(camera, size, transform), provider, ]; })); }), filter( (args: [TileRegionOfInterest, TextureProvider]): boolean => { return !args[1].disposed; }), withLatestFrom(this._navigator.stateService.currentState$)) .subscribe( ([[roi, provider], frame]: [[TileRegionOfInterest, TextureProvider], AnimationFrame]): void => { let shiftedRoi: TileRegionOfInterest = null; if (isSpherical(frame.state.previousImage.cameraType)) { if (isSpherical(frame.state.currentImage.cameraType)) { const currentViewingDirection: THREE.Vector3 = this._spatial.viewingDirection(frame.state.currentImage.rotation); const previousViewingDirection: THREE.Vector3 = this._spatial.viewingDirection(frame.state.previousImage.rotation); const directionDiff: number = this._spatial.angleBetweenVector2( currentViewingDirection.x, currentViewingDirection.y, previousViewingDirection.x, previousViewingDirection.y); const shift: number = directionDiff / (2 * Math.PI); const bbox: TileBoundingBox = { maxX: this._spatial.wrap(roi.bbox.maxX + shift, 0, 1), maxY: roi.bbox.maxY, minX: this._spatial.wrap(roi.bbox.minX + shift, 0, 1), minY: roi.bbox.minY, }; shiftedRoi = { bbox: bbox, pixelHeight: roi.pixelHeight, pixelWidth: roi.pixelWidth, }; } else { const currentViewingDirection: THREE.Vector3 = this._spatial.viewingDirection(frame.state.currentImage.rotation); const previousViewingDirection: THREE.Vector3 = this._spatial.viewingDirection(frame.state.previousImage.rotation); const directionDiff: number = this._spatial.angleBetweenVector2( currentViewingDirection.x, currentViewingDirection.y, previousViewingDirection.x, previousViewingDirection.y); const shiftX: number = directionDiff / (2 * Math.PI); const a1: number = this._spatial.angleToPlane(currentViewingDirection.toArray(), [0, 0, 1]); const a2: number = this._spatial.angleToPlane(previousViewingDirection.toArray(), [0, 0, 1]); const shiftY: number = (a2 - a1) / (2 * Math.PI); const currentTransform: Transform = frame.state.currentTransform; const size: number = Math.max(currentTransform.basicWidth, currentTransform.basicHeight); const hFov: number = size > 0 ? 2 * Math.atan(0.5 * currentTransform.basicWidth / (size * currentTransform.focal)) : Math.PI / 3; const vFov: number = size > 0 ? 2 * Math.atan(0.5 * currentTransform.basicHeight / (size * currentTransform.focal)) : Math.PI / 3; const spanningWidth: number = hFov / (2 * Math.PI); const spanningHeight: number = vFov / Math.PI; const basicWidth: number = (roi.bbox.maxX - roi.bbox.minX) * spanningWidth; const basicHeight: number = (roi.bbox.maxY - roi.bbox.minY) * spanningHeight; const pixelWidth: number = roi.pixelWidth * spanningWidth; const pixelHeight: number = roi.pixelHeight * spanningHeight; const zoomShiftX: number = (roi.bbox.minX + roi.bbox.maxX) / 2 - 0.5; const zoomShiftY: number = (roi.bbox.minY + roi.bbox.maxY) / 2 - 0.5; const minX: number = 0.5 + shiftX + spanningWidth * zoomShiftX - basicWidth / 2; const maxX: number = 0.5 + shiftX + spanningWidth * zoomShiftX + basicWidth / 2; const minY: number = 0.5 + shiftY + spanningHeight * zoomShiftY - basicHeight / 2; const maxY: number = 0.5 + shiftY + spanningHeight * zoomShiftY + basicHeight / 2; const bbox: TileBoundingBox = { maxX: this._spatial.wrap(maxX, 0, 1), maxY: maxY, minX: this._spatial.wrap(minX, 0, 1), minY: minY, }; shiftedRoi = { bbox: bbox, pixelHeight: pixelHeight, pixelWidth: pixelWidth, }; } } else { const currentBasicAspect: number = frame.state.currentTransform.basicAspect; const previousBasicAspect: number = frame.state.previousTransform.basicAspect; const [[cornerMinX, cornerMinY], [cornerMaxX, cornerMaxY]]: number[][] = this._getBasicCorners(currentBasicAspect, previousBasicAspect); const basicWidth: number = cornerMaxX - cornerMinX; const basicHeight: number = cornerMaxY - cornerMinY; const pixelWidth: number = roi.pixelWidth / basicWidth; const pixelHeight: number = roi.pixelHeight / basicHeight; const minX: number = (basicWidth - 1) / (2 * basicWidth) + roi.bbox.minX / basicWidth; const maxX: number = (basicWidth - 1) / (2 * basicWidth) + roi.bbox.maxX / basicWidth; const minY: number = (basicHeight - 1) / (2 * basicHeight) + roi.bbox.minY / basicHeight; const maxY: number = (basicHeight - 1) / (2 * basicHeight) + roi.bbox.maxY / basicHeight; const bbox: TileBoundingBox = { maxX: maxX, maxY: maxY, minX: minX, minY: minY, }; this._clipBoundingBox(bbox); shiftedRoi = { bbox: bbox, pixelHeight: pixelHeight, pixelWidth: pixelWidth, }; } provider.setRegionOfInterest(shiftedRoi); })); const hasTexturePrev$ = textureProviderPrev$.pipe( switchMap( (provider: TextureProvider): Observable<boolean> => { return provider.hasTexture$; }), startWith(false), publishReplay(1), refCount()); subs.push(hasTexturePrev$.subscribe(() => { /*noop*/ })); } protected _deactivate(): void { this._waitSubscription.unsubscribe(); this._navigator.stateService.state$.pipe( first()) .subscribe( (state: State): void => { if (state !== State.Traversing) { this._navigator.stateService.traverse(); } }); this._glRendererDisposer$.next(null); this._domRenderer.deactivate(); this._subscriptions.unsubscribe(); this.configure({ ids: null }); } protected _getDefaultConfiguration(): SliderConfiguration { return { initialPosition: 1, mode: SliderConfigurationMode.Motion, sliderVisible: true, }; } private _catchCacheImage$(imageId: string): Observable<Image> { return this._navigator.graphService.cacheImage$(imageId).pipe( catchError( (error: Error): Observable<Image> => { console.error(`Failed to cache slider image (${imageId})`, error); return observableEmpty(); })); } private _getBasicCorners(currentAspect: number, previousAspect: number): number[][] { let offsetX: number; let offsetY: number; if (currentAspect > previousAspect) { offsetX = 0.5; offsetY = 0.5 * currentAspect / previousAspect; } else { offsetX = 0.5 * previousAspect / currentAspect; offsetY = 0.5; } return [[0.5 - offsetX, 0.5 - offsetY], [0.5 + offsetX, 0.5 + offsetY]]; } private _clipBoundingBox(bbox: TileBoundingBox): void { bbox.minX = Math.max(0, Math.min(1, bbox.minX)); bbox.maxX = Math.max(0, Math.min(1, bbox.maxX)); bbox.minY = Math.max(0, Math.min(1, bbox.minY)); bbox.maxY = Math.max(0, Math.min(1, bbox.maxY)); } }