UNPKG

mapillary-js

Version:

WebGL JavaScript library for displaying street level imagery from mapillary.com

690 lines (610 loc) 26.6 kB
import { concat as observableConcat, merge as observableMerge, of as observableOf, combineLatest as observableCombineLatest, Observable, Subscription, } from "rxjs"; import { startWith, withLatestFrom, skip, first, publishReplay, pairwise, switchMap, refCount, distinctUntilChanged, map, } from "rxjs/operators"; import * as THREE from "three"; import * as when from "when"; import {ILatLon} from "../../API"; import { IMarkerConfiguration, IMarkerEvent, Marker, MarkerScene, MarkerSet, ComponentService, Component, } from "../../Component"; import {IFrame} from "../../State"; import { Container, Navigator, } from "../../Viewer"; import { IGLRenderHash, GLRenderStage, RenderCamera, } from "../../Render"; import { GraphCalculator, Node, } from "../../Graph"; import { GeoCoords, ILatLonAlt, ViewportCoords, } from "../../Geo"; /** * @class MarkerComponent * * @classdesc Component for showing and editing 3D marker objects. * * The `add` method is used for adding new markers or replacing * markers already in the set. * * If a marker already in the set has the same * id as one of the markers added, the old marker will be removed and * the added marker will take its place. * * It is not possible to update markers in the set by updating any properties * directly on the marker object. Markers need to be replaced by * re-adding them for updates to geographic position or configuration * to be reflected. * * Markers added to the marker component can be either interactive * or non-interactive. Different marker types define their behavior. * Markers with interaction support can be configured with options * to respond to dragging inside the viewer and be detected when * retrieving markers from pixel points with the `getMarkerIdAt` method. * * To retrive and use the marker component * * @example * ``` * var viewer = new Mapillary.Viewer( * "<element-id>", * "<client-id>", * "<my key>", * { component: { marker: true } }); * * var markerComponent = viewer.getComponent("marker"); * ``` */ export class MarkerComponent extends Component<IMarkerConfiguration> { public static componentName: string = "marker"; /** * Fired when the position of a marker is changed. * @event * @type {IMarkerEvent} markerEvent - Marker event data. * @example * ``` * markerComponent.on("changed", function(e) { * console.log(e.marker.id, e.marker.latLon); * }); * ``` */ public static changed: string = "changed"; /** * Fired when a marker drag interaction starts. * @event * @type {IMarkerEvent} markerEvent - Marker event data. * @example * ``` * markerComponent.on("dragstart", function(e) { * console.log(e.marker.id, e.marker.latLon); * }); * ``` */ public static dragstart: string = "dragstart"; /** * Fired when a marker drag interaction ends. * @event * @type {IMarkerEvent} markerEvent - Marker event data. * @example * ``` * markerComponent.on("dragend", function(e) { * console.log(e.marker.id, e.marker.latLon); * }); * ``` */ public static dragend: string = "dragend"; private _relativeGroundAltitude: number; private _geoCoords: GeoCoords; private _graphCalculator: GraphCalculator; private _markerScene: MarkerScene; private _markerSet: MarkerSet; private _viewportCoords: ViewportCoords; private _adjustHeightSubscription: Subscription; private _dragEventSubscription: Subscription; private _markersUpdatedSubscription: Subscription; private _mouseClaimSubscription: Subscription; private _referenceSubscription: Subscription; private _renderSubscription: Subscription; private _setChangedSubscription: Subscription; private _updateMarkerSubscription: Subscription; /** @ignore */ constructor(name: string, container: Container, navigator: Navigator) { super(name, container, navigator); this._relativeGroundAltitude = -2; this._geoCoords = new GeoCoords(); this._graphCalculator = new GraphCalculator(); this._markerScene = new MarkerScene(); this._markerSet = new MarkerSet(); this._viewportCoords = new ViewportCoords(); } /** * Add markers to the marker set or replace markers in the marker set. * * @description If a marker already in the set has the same * id as one of the markers added, the old marker will be removed * the added marker will take its place. * * Any marker inside the visible bounding bbox * will be initialized and placed in the viewer. * * @param {Array<Marker>} markers - Markers to add. * * @example ```markerComponent.add([marker1, marker2]);``` */ public add(markers: Marker[]): void { this._markerSet.add(markers); } /** * Returns the marker in the marker set with the specified id, or * undefined if the id matches no marker. * * @param {string} markerId - Id of the marker. * * @example ```var marker = markerComponent.get("markerId");``` * */ public get(markerId: string): Marker { return this._markerSet.get(markerId); } /** * Returns an array of all markers. * * @example ```var markers = markerComponent.getAll();``` */ public getAll(): Marker[] { return this._markerSet.getAll(); } /** * Returns the id of the interactive marker closest to the current camera * position at the specified point. * * @description Notice that the pixelPoint argument requires x, y * coordinates from pixel space. * * With this function, you can use the coordinates provided by mouse * events to get information out of the marker component. * * If no interactive geometry of an interactive marker exist at the pixel * point, `null` will be returned. * * @param {Array<number>} pixelPoint - Pixel coordinates on the viewer element. * @returns {string} Id of the interactive marker closest to the camera. If no * interactive marker exist at the pixel point, `null` will be returned. * * @example * ``` * markerComponent.getMarkerIdAt([100, 100]) * .then((markerId) => { console.log(markerId); }); * ``` */ public getMarkerIdAt(pixelPoint: number[]): when.Promise<string> { return when.promise<string>((resolve: (value: string) => void, reject: (reason: Error) => void): void => { this._container.renderService.renderCamera$.pipe( first(), map( (render: RenderCamera): string => { const viewport: number[] = this._viewportCoords .canvasToViewport( pixelPoint[0], pixelPoint[1], this._container.element); const id: string = this._markerScene.intersectObjects(viewport, render.perspective); return id; })) .subscribe( (id: string): void => { resolve(id); }, (error: Error): void => { reject(error); }); }); } /** * Check if a marker exist in the marker set. * * @param {string} markerId - Id of the marker. * * @example ```var markerExists = markerComponent.has("markerId");``` */ public has(markerId: string): boolean { return this._markerSet.has(markerId); } /** * Remove markers with the specified ids from the marker set. * * @param {Array<string>} markerIds - Ids for markers to remove. * * @example ```markerComponent.remove(["id-1", "id-2"]);``` */ public remove(markerIds: string[]): void { this._markerSet.remove(markerIds); } /** * Remove all markers from the marker set. * * @example ```markerComponent.removeAll();``` */ public removeAll(): void { this._markerSet.removeAll(); } protected _activate(): void { const groundAltitude$: Observable<number> = this._navigator.stateService.currentState$.pipe( map( (frame: IFrame): number => { return frame.state.camera.position.z + this._relativeGroundAltitude; }), distinctUntilChanged( (a1: number, a2: number): boolean => { return Math.abs(a1 - a2) < 0.01; }), publishReplay(1), refCount()); const geoInitiated$: Observable<void> = observableCombineLatest( groundAltitude$, this._navigator.stateService.reference$).pipe( first(), map((): void => { /* noop */ }), publishReplay(1), refCount()); const clampedConfiguration$: Observable<IMarkerConfiguration> = this._configuration$.pipe( map( (configuration: IMarkerConfiguration): IMarkerConfiguration => { return { visibleBBoxSize: Math.max(1, Math.min(200, configuration.visibleBBoxSize)) }; })); const currentlatLon$: Observable<ILatLon> = this._navigator.stateService.currentNode$.pipe( map((node: Node): ILatLon => { return node.latLon; }), publishReplay(1), refCount()); const visibleBBox$: Observable<[ILatLon, ILatLon]> = observableCombineLatest( clampedConfiguration$, currentlatLon$).pipe( map( ([configuration, latLon]: [IMarkerConfiguration, ILatLon]): [ILatLon, ILatLon] => { return this._graphCalculator .boundingBoxCorners(latLon, configuration.visibleBBoxSize / 2); }), publishReplay(1), refCount()); const visibleMarkers$: Observable<Marker[]> = observableCombineLatest( observableConcat( observableOf<MarkerSet>(this._markerSet), this._markerSet.changed$), visibleBBox$).pipe( map( ([set, bbox]: [MarkerSet, [ILatLon, ILatLon]]): Marker[] => { return set.search(bbox); })); this._setChangedSubscription = geoInitiated$.pipe( switchMap( (): Observable<[Marker[], ILatLonAlt, number]> => { return visibleMarkers$.pipe( withLatestFrom( this._navigator.stateService.reference$, groundAltitude$)); })) .subscribe( ([markers, reference, alt]: [Marker[], ILatLonAlt, number]): void => { const geoCoords: GeoCoords = this._geoCoords; const markerScene: MarkerScene = this._markerScene; const sceneMarkers: { [id: string]: Marker } = markerScene.markers; const markersToRemove: { [id: string]: Marker } = Object.assign({}, sceneMarkers); for (const marker of markers) { if (marker.id in sceneMarkers) { delete markersToRemove[marker.id]; } else { const point3d: number[] = geoCoords .geodeticToEnu( marker.latLon.lat, marker.latLon.lon, reference.alt + alt, reference.lat, reference.lon, reference.alt); markerScene.add(marker, point3d); } } for (const id in markersToRemove) { if (!markersToRemove.hasOwnProperty(id)) { continue; } markerScene.remove(id); } }); this._markersUpdatedSubscription = geoInitiated$.pipe( switchMap( (): Observable<[Marker[], [ILatLon, ILatLon], ILatLonAlt, number]> => { return this._markerSet.updated$.pipe( withLatestFrom( visibleBBox$, this._navigator.stateService.reference$, groundAltitude$)); })) .subscribe( ([markers, [sw, ne], reference, alt]: [Marker[], [ILatLon, ILatLon], ILatLonAlt, number]): void => { const geoCoords: GeoCoords = this._geoCoords; const markerScene: MarkerScene = this._markerScene; for (const marker of markers) { const exists: boolean = markerScene.has(marker.id); const visible: boolean = marker.latLon.lat > sw.lat && marker.latLon.lat < ne.lat && marker.latLon.lon > sw.lon && marker.latLon.lon < ne.lon; if (visible) { const point3d: number[] = geoCoords .geodeticToEnu( marker.latLon.lat, marker.latLon.lon, reference.alt + alt, reference.lat, reference.lon, reference.alt); markerScene.add(marker, point3d); } else if (!visible && exists) { markerScene.remove(marker.id); } } }); this._referenceSubscription = this._navigator.stateService.reference$.pipe( skip(1), withLatestFrom(groundAltitude$)) .subscribe( ([reference, alt]: [ILatLonAlt, number]): void => { const geoCoords: GeoCoords = this._geoCoords; const markerScene: MarkerScene = this._markerScene; for (const marker of markerScene.getAll()) { const point3d: number[] = geoCoords .geodeticToEnu( marker.latLon.lat, marker.latLon.lon, reference.alt + alt, reference.lat, reference.lon, reference.alt); markerScene.update(marker.id, point3d); } }); this._adjustHeightSubscription = groundAltitude$.pipe( skip(1), withLatestFrom( this._navigator.stateService.reference$, currentlatLon$)) .subscribe( ([alt, reference, latLon]: [number, ILatLonAlt, ILatLon]): void => { const geoCoords: GeoCoords = this._geoCoords; const markerScene: MarkerScene = this._markerScene; const position: number[] = geoCoords .geodeticToEnu( latLon.lat, latLon.lon, reference.alt + alt, reference.lat, reference.lon, reference.alt); for (const marker of markerScene.getAll()) { const point3d: number[] = geoCoords .geodeticToEnu( marker.latLon.lat, marker.latLon.lon, reference.alt + alt, reference.lat, reference.lon, reference.alt); const distanceX: number = point3d[0] - position[0]; const distanceY: number = point3d[1] - position[1]; const groundDistance: number = Math.sqrt(distanceX * distanceX + distanceY * distanceY); if (groundDistance > 50) { continue; } markerScene.lerpAltitude(marker.id, alt, Math.min(1, Math.max(0, 1.2 - 1.2 * groundDistance / 50))); } }); this._renderSubscription = this._navigator.stateService.currentState$.pipe( map( (frame: IFrame): IGLRenderHash => { const scene: MarkerScene = this._markerScene; return { name: this._name, render: { frameId: frame.id, needsRender: scene.needsRender, render: scene.render.bind(scene), stage: GLRenderStage.Foreground, }, }; })) .subscribe(this._container.glRenderer.render$); const hoveredMarkerId$: Observable<string> = observableCombineLatest( this._container.renderService.renderCamera$, this._container.mouseService.mouseMove$).pipe( map( ([render, event]: [RenderCamera, MouseEvent]): string => { const element: HTMLElement = this._container.element; const [canvasX, canvasY]: number[] = this._viewportCoords.canvasPosition(event, element); const viewport: number[] = this._viewportCoords.canvasToViewport( canvasX, canvasY, element); const markerId: string = this._markerScene.intersectObjects(viewport, render.perspective); return markerId; }), publishReplay(1), refCount()); const draggingStarted$: Observable<boolean> = this._container.mouseService .filtered$(this._name, this._container.mouseService.mouseDragStart$).pipe( map( (event: MouseEvent): boolean => { return true; })); const draggingStopped$: Observable<boolean> = this._container.mouseService .filtered$(this._name, this._container.mouseService.mouseDragEnd$).pipe( map( (event: Event): boolean => { return false; })); const filteredDragging$: Observable<boolean> = observableMerge( draggingStarted$, draggingStopped$).pipe( startWith(false)); this._dragEventSubscription = observableMerge( draggingStarted$.pipe( withLatestFrom(hoveredMarkerId$)), observableCombineLatest( draggingStopped$, observableOf<string>(null))).pipe( startWith<[boolean, string]>([false, null]), pairwise()) .subscribe( ([previous, current]: [boolean, string][]): void => { const dragging: boolean = current[0]; const eventType: string = dragging ? MarkerComponent.dragstart : MarkerComponent.dragend; const id: string = dragging ? current[1] : previous[1]; const marker: Marker = this._markerScene.get(id); const markerEvent: IMarkerEvent = { marker: marker, target: this, type: eventType }; this.fire(eventType, markerEvent); }); const mouseDown$: Observable<boolean> = observableMerge( this._container.mouseService.mouseDown$.pipe( map((event: MouseEvent): boolean => { return true; })), this._container.mouseService.documentMouseUp$.pipe( map((event: MouseEvent): boolean => { return false; }))).pipe( startWith(false)); this._mouseClaimSubscription = observableCombineLatest( this._container.mouseService.active$, hoveredMarkerId$.pipe(distinctUntilChanged()), mouseDown$, filteredDragging$).pipe( map( ([active, markerId, mouseDown, filteredDragging]: [boolean, string, boolean, boolean]): boolean => { return (!active && markerId != null && mouseDown) || filteredDragging; }), distinctUntilChanged()) .subscribe( (claim: boolean): void => { if (claim) { this._container.mouseService.claimMouse(this._name, 1); this._container.mouseService.claimWheel(this._name, 1); } else { this._container.mouseService.unclaimMouse(this._name); this._container.mouseService.unclaimWheel(this._name); } }); const offset$: Observable<[Marker, number[], RenderCamera]> = this._container.mouseService .filtered$(this._name, this._container.mouseService.mouseDragStart$).pipe( withLatestFrom( hoveredMarkerId$, this._container.renderService.renderCamera$), map( ([e, id, r]: [MouseEvent, string, RenderCamera]): [Marker, number[], RenderCamera] => { const marker: Marker = this._markerScene.get(id); const element: HTMLElement = this._container.element; const [groundCanvasX, groundCanvasY]: number[] = this._viewportCoords.projectToCanvas( marker.geometry.position.toArray(), element, r.perspective); const [canvasX, canvasY]: number[] = this._viewportCoords.canvasPosition(e, element); const offset: number[] = [canvasX - groundCanvasX, canvasY - groundCanvasY]; return [marker, offset, r]; }), publishReplay(1), refCount()); this._updateMarkerSubscription = this._container.mouseService .filtered$(this._name, this._container.mouseService.mouseDrag$).pipe( withLatestFrom( offset$, this._navigator.stateService.reference$, clampedConfiguration$)) .subscribe( ([event, [marker, offset, render], reference, configuration]: [MouseEvent, [Marker, number[], RenderCamera], ILatLonAlt, IMarkerConfiguration]): void => { if (!this._markerScene.has(marker.id)) { return; } const element: HTMLElement = this._container.element; const [canvasX, canvasY]: number[] = this._viewportCoords.canvasPosition(event, element); const groundX: number = canvasX - offset[0]; const groundY: number = canvasY - offset[1]; const [viewportX, viewportY]: number[] = this._viewportCoords .canvasToViewport( groundX, groundY, element); const direction: THREE.Vector3 = new THREE.Vector3(viewportX, viewportY, 1) .unproject(render.perspective) .sub(render.perspective.position) .normalize(); const distance: number = Math.min( this._relativeGroundAltitude / direction.z, configuration.visibleBBoxSize / 2 - 0.1); if (distance < 0) { return; } const intersection: THREE.Vector3 = direction .clone() .multiplyScalar(distance) .add(render.perspective.position); intersection.z = render.perspective.position.z + this._relativeGroundAltitude; const [lat, lon]: number[] = this._geoCoords .enuToGeodetic( intersection.x, intersection.y, intersection.z, reference.lat, reference.lon, reference.alt); this._markerScene.update(marker.id, intersection.toArray(), { lat: lat, lon: lon }); this._markerSet.update(marker); const markerEvent: IMarkerEvent = { marker: marker, target: this, type: MarkerComponent.changed }; this.fire(MarkerComponent.changed, markerEvent); }); } protected _deactivate(): void { this._adjustHeightSubscription.unsubscribe(); this._dragEventSubscription.unsubscribe(); this._markersUpdatedSubscription.unsubscribe(); this._mouseClaimSubscription.unsubscribe(); this._referenceSubscription.unsubscribe(); this._renderSubscription.unsubscribe(); this._setChangedSubscription.unsubscribe(); this._updateMarkerSubscription.unsubscribe(); this._markerScene.clear(); } protected _getDefaultConfiguration(): IMarkerConfiguration { return { visibleBBoxSize: 100 }; } } ComponentService.register(MarkerComponent); export default MarkerComponent;