/* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ import { IconLookup, faCircleXmark, faGear, faPause, faPlay, faRotate } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Checkbox, FormControlLabel, TextField } from '@mui/material'; import * as turf from '@turf/turf'; import { IconButton, Size, Type } from 'browndash-components'; import * as d3 from 'd3'; import { Feature, FeatureCollection, GeoJsonProperties, Geometry, LineString, Position } from 'geojson'; import mapboxgl, { LngLatBoundsLike, MapLayerMouseEvent } from 'mapbox-gl'; import { IReactionDisposer, ObservableMap, action, autorun, computed, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { CirclePicker, ColorResult } from 'react-color'; import { Layer, MapProvider, MapRef, Map as MapboxMap, Marker, Source, ViewState, ViewStateChangeEvent } from 'react-map-gl'; import { MarkerEvent } from 'react-map-gl/dist/esm/types'; import { ClientUtils, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; import { Doc, DocListCast, Field, LinkedTo, Opt } from '../../../../fields/Doc'; import { DocCast, NumCast, StrCast, toList } from '../../../../fields/Types'; import { DocUtils } from '../../../documents/DocUtils'; import { DocumentType } from '../../../documents/DocumentTypes'; import { Docs } from '../../../documents/Documents'; import { DragManager } from '../../../util/DragManager'; import { UndoManager, undoable } from '../../../util/UndoManager'; import { ViewBoxAnnotatableComponent, ViewBoxInterface } from '../../DocComponent'; import { PinDocView, PinProps } from '../../PinFuncs'; import { SidebarAnnos } from '../../SidebarAnnos'; import { MarqueeOptionsMenu } from '../../collections/collectionFreeForm'; import { Colors } from '../../global/globalEnums'; import { DocumentView } from '../DocumentView'; import { FieldView, FieldViewProps } from '../FieldView'; import { FocusViewOptions } from '../FocusViewOptions'; import { fastSpeedIcon, mediumSpeedIcon, slowSpeedIcon } from './AnimationSpeedIcons'; import { AnimationSpeed, AnimationStatus, AnimationUtility } from './AnimationUtility'; import { MapAnchorMenu } from './MapAnchorMenu'; import './MapBox.scss'; import { MapboxApiUtility, TransportationType } from './MapboxApiUtility'; import { MarkerIcons } from './MarkerIcons'; // import { GeocoderControl } from './GeocoderControl'; // amongus /** * MapBox architecture: * Main component: MapBox.tsx * Supporting Components: SidebarAnnos, CollectionStackingView * * MapBox is a node that extends the ViewBoxAnnotatableComponent. Similar to PDFBox and WebBox, it supports interaction between sidebar content and document content. * The main body of MapBox uses Google Maps API to allow location retrieval, adding map markers, pan and zoom, and open street view. * Dash Document architecture is integrated with Maps API: When drag and dropping documents with ExifData (gps Latitude and Longitude information) available, * sidebarAddDocument function checks if the document contains lat & lng information, if it does, then the document is added to both the sidebar and the infowindow (a pop up corresponding to a map marker--pin on map). * The lat and lng field of the document is filled when importing (spec see ConvertDMSToDD method and processFileUpload method in Documents.ts). * A map marker is considered a document that contains a collection with stacking view of documents, it has a lat, lng location, which is passed to Maps API's custom marker (red pin) to be rendered on the google maps */ const MAPBOX_ACCESS_TOKEN = 'pk.eyJ1IjoiemF1bHRhdmFuZ2FyIiwiYSI6ImNscHgwNDd1MDA3MXIydm92ODdianp6cGYifQ.WFAqbhwxtMHOWSPtu0l2uQ'; type PopupInfo = { longitude: number; latitude: number; title: string; description: string; }; @observer export class MapBox extends ViewBoxAnnotatableComponent() implements ViewBoxInterface { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(MapBox, fieldKey); } private _unmounting = false; private _sidebarRef = React.createRef(); private _ref: React.RefObject = React.createRef(); private _mapRef: React.RefObject = React.createRef(); private _disposers: { [key: string]: IReactionDisposer } = {}; constructor(props: FieldViewProps) { super(props); makeObservable(this); } @observable _featuresFromGeocodeResults: any[] = []; @observable _savedAnnotations = new ObservableMap(); @observable _selectedPinOrRoute: Doc | undefined = undefined; // The pin that is selected @observable _mapReady = false; @observable _isAnimating: boolean = false; @observable _routeToAnimate: Doc | undefined = undefined; @observable _animationPhase: number = 0; @observable _finishedFlyTo: boolean = false; @observable _frameId: number | null = null; @observable _animationUtility: AnimationUtility | null = null; @observable _settingsOpen: boolean = false; @observable _mapStyle: string = 'mapbox://styles/mapbox/standard'; @observable _showTerrain: boolean = true; @observable _currentPopup: PopupInfo | undefined = undefined; @observable _isStreetViewAnimation: boolean = false; @observable _animationSpeed: AnimationSpeed = AnimationSpeed.MEDIUM; @observable _animationLineColor: string = '#ffff00'; @observable _temporaryRouteSource: FeatureCollection = { type: 'FeatureCollection', features: [] }; @observable _dynamicRouteFeature: Feature = { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [] }, }; @observable path: turf.helpers.Feature = { type: 'Feature', geometry: { type: 'LineString', coordinates: [] }, properties: {}, }; // this list contains pushpins and configs @computed get allAnnotations() { return DocListCast(this.dataDoc[this.annotationKey]); } // prettier-ignore @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); } // prettier-ignore @computed get allPushpins() { return this.allAnnotations.filter(anno => anno.type === DocumentType.PUSHPIN); } // prettier-ignore @computed get allRoutes() { return this.allAnnotations.filter(anno => anno.type === DocumentType.MAPROUTE); } // prettier-ignore @computed get SidebarShown() { return !!this.layoutDoc._layout_showSidebar; } // prettier-ignore @computed get sidebarWidthPercent() { return StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); } // prettier-ignore @computed get SidebarKey() { return this.fieldKey + '_sidebar'; } // prettier-ignore @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this._props.fieldKey + '_backgroundColor'], '#e4e4e4')); } @computed get updatedRouteCoordinates(): Feature { if (this._routeToAnimate?.routeCoordinates) { const originalCoordinates: Position[] = JSON.parse(StrCast(this._routeToAnimate.routeCoordinates)); // const index = Math.floor(this.animationPhase * originalCoordinates.length); const index = this._animationPhase * (originalCoordinates.length - 1); // Calculate the fractional index console.log('Animation phase', this._animationPhase); const startIndex = Math.floor(index); const endIndex = Math.ceil(index); let feature: Feature; let geometry: LineString; if (startIndex === endIndex) { // AnimationPhase is at a whole number (no interpolation needed) const coordinates = [originalCoordinates[startIndex]]; geometry = { type: 'LineString', coordinates, }; feature = { type: 'Feature', properties: { routeTitle: StrCast(this._routeToAnimate.title), }, geometry: geometry, }; } else { // Interpolate between two coordinates const startCoord = originalCoordinates[startIndex]; const endCoord = originalCoordinates[endIndex]; const fraction = index - startIndex; const interpolator = d3.interpolateArray(startCoord, endCoord); const interpolatedCoord = interpolator(fraction); const coordinates = originalCoordinates.slice(0, startIndex + 1).concat([interpolatedCoord]); geometry = { type: 'LineString', coordinates, }; feature = { type: 'Feature', properties: { routeTitle: StrCast(this._routeToAnimate.title), }, geometry: geometry, }; } autorun(() => { const animationUtil = this._animationUtility; const concattedCoordinates = geometry.coordinates.concat(originalCoordinates.slice(endIndex)); const newFeature: Feature = { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: concattedCoordinates, }, }; if (animationUtil) { animationUtil.setPath(newFeature); } }); return feature; } console.log('ERROR'); return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [], }, }; } @computed get selectedRouteCoordinates(): Position[] { return !this._routeToAnimate?.routeCoordinates ? [] : JSON.parse(StrCast(this._routeToAnimate.routeCoordinates)); } @computed get allRoutesGeoJson(): FeatureCollection { const features: Feature[] = this.allRoutes.map((routeDoc: Doc) => { console.log('Route coords: ', routeDoc.routeCoordinates); const geometry: LineString = { type: 'LineString', coordinates: JSON.parse(StrCast(routeDoc.routeCoordinates)), }; return { type: 'Feature', properties: { routeTitle: routeDoc.title, }, geometry: geometry, }; }); return { type: 'FeatureCollection', features: features, }; } componentDidMount() { this._unmounting = false; this._props.setContentViewBox?.(this); } componentWillUnmount() { this._unmounting = true; this.deselectPinOrRoute(); Object.keys(this._disposers).forEach(key => this._disposers[key]?.()); } /** * Called when dragging documents into map sidebar or directly into infowindow; to create a map marker, ref to MapMarkerDocument in Documents.ts * @param docs * @param sidebarKey * @returns */ sidebarAddDocument = (docs: Doc | Doc[], sidebarKey?: string) => { if (!this.layoutDoc._layout_showSidebar) this.toggleSidebar(); toList(docs).forEach(doc => { let existingPin = this.allPushpins.find(pin => pin.latitude === doc.latitude && pin.longitude === doc.longitude) ?? this._selectedPinOrRoute; if (doc.latitude !== undefined && doc.longitude !== undefined && !existingPin) { existingPin = this.createPushpin(NumCast(doc.latitude), NumCast(doc.longitude), StrCast(doc.map)); } if (existingPin) { setTimeout(() => { // we use a timeout in case this is called from the sidebar which may have just added a link that hasn't made its way into th elink manager yet if (!Doc.Links(doc).some(link => DocCast(link.link_anchor_1)?.mapPin === existingPin || DocCast(link.link_anchor_2)?.mapPin === existingPin)) { const anchor = this.getAnchor(true, undefined, existingPin); anchor && DocUtils.MakeLink(anchor, doc, { link_relationship: 'link to map location' }); doc.latitude = existingPin?.latitude; doc.longitude = existingPin?.longitude; } }); } }); // add to annotation list return this.addDocument(docs, sidebarKey); // add to sidebar list }; removeMapDocument = (doc: Doc | Doc[], annotationKey?: string) => { this.allAnnotations .filter(anno => toList(doc).includes(DocCast(anno.mapPin))) .forEach(anno => { anno.mapPin = undefined; }); return this.removeDocument(doc, annotationKey, undefined); }; /** * Removing documents from the sidebar * @param doc * @param sidebarKey * @returns */ sidebarRemoveDocument = (doc: Doc | Doc[], sidebarKey?: string) => this.removeMapDocument(doc, sidebarKey); /** * Toggle sidebar onclick the tiny comment button on the top right corner * @param e */ sidebarBtnDown = (e: React.PointerEvent) => { setupMoveUpEvents( this, e, (moveEv, down, delta) => runInAction(() => { const localDelta = this._props .ScreenToLocalTransform() .scale(this._props.NativeDimScaling?.() || 1) .transformDirection(delta[0], delta[1]); const fullWidth = NumCast(this.layoutDoc._width); const mapWidth = fullWidth - this.sidebarWidth(); if (this.sidebarWidth() + localDelta[0] > 0) { this.layoutDoc._layout_showSidebar = true; this.layoutDoc._width = fullWidth + localDelta[0]; this.layoutDoc._layout_sidebarWidthPercent = ((100 * (this.sidebarWidth() + localDelta[0])) / (fullWidth + localDelta[0])).toString() + '%'; } else { this.layoutDoc._layout_showSidebar = false; this.layoutDoc._width = mapWidth; this.layoutDoc._layout_sidebarWidthPercent = '0%'; } return false; }), emptyFunction, () => UndoManager.RunInBatch(this.toggleSidebar, 'toggle sidebar map') ); }; sidebarWidth = () => (Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100) * this._props.PanelWidth(); /** * Handles toggle of sidebar on click the little comment button */ @computed get sidebarHandle() { return (
); } // TODO: Adding highlight box layer to Maps @action toggleSidebar = () => { const prevWidth = this.sidebarWidth(); this.layoutDoc._layout_showSidebar = (this.layoutDoc._layout_sidebarWidthPercent = StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%') === '0%' ? `${(100 * 0.2) / 1.2}%` : '0%') !== '0%'; this.layoutDoc._width = this.layoutDoc._layout_showSidebar ? NumCast(this.layoutDoc._width) * 1.2 : Math.max(20, NumCast(this.layoutDoc._width) - prevWidth); }; startAnchorDrag = (e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); const sourceAnchorCreator = action(() => { const note = this.getAnchor(true); if (note && this._selectedPinOrRoute) { note.latitude = this._selectedPinOrRoute.latitude; note.longitude = this._selectedPinOrRoute.longitude; note.map = this._selectedPinOrRoute.map; } return note as Doc; }); const targetCreator = (annotationOn: Doc | undefined) => { const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); Doc.SetSelectOnLoad(target); return target; }; const docView = this.DocumentView?.(); docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: dragEv => { if (!dragEv.aborted && dragEv.annoDragData && dragEv.annoDragData.linkSourceDoc && dragEv.annoDragData.dropDocument && dragEv.linkDocument) { dragEv.annoDragData.linkSourceDoc.followLinkToggle = dragEv.annoDragData.dropDocument.annotationOn === this.Document; dragEv.annoDragData.linkSourceDoc.followLinkZoom = false; } }, }); }; createNoteAnnotation = () => { const createFunc = undoable( action(() => { const note = this._sidebarRef.current?.anchorMenuClick(this.getAnchor(true), ['latitude', 'longitude', LinkedTo]); if (note && this._selectedPinOrRoute) { note.latitude = this._selectedPinOrRoute.latitude; note.longitude = this._selectedPinOrRoute.longitude; note.map = this._selectedPinOrRoute.map; } }), 'create note annotation' ); if (!this.layoutDoc.layout_showSidebar) { this.toggleSidebar(); setTimeout(createFunc); } else createFunc(); }; sidebarDown = (e: React.PointerEvent) => { setupMoveUpEvents(this, e, this.sidebarMove, emptyFunction, () => setTimeout(this.toggleSidebar), true); }; sidebarMove = (e: PointerEvent) => { const bounds = this._ref.current!.getBoundingClientRect(); this.layoutDoc._layout_sidebarWidthPercent = '' + 100 * Math.max(0, 1 - (e.clientX - bounds.left) / bounds.width) + '%'; this.layoutDoc._layout_showSidebar = this.layoutDoc._layout_sidebarWidthPercent !== '0%'; e.preventDefault(); return false; }; pointerEvents = () => (this._props.isContentActive() && !MarqueeOptionsMenu.Instance.isShown() ? 'all' : 'none'); panelWidth = () => this._props.PanelWidth() / (this._props.NativeDimScaling?.() || 1) - this.sidebarWidth(); panelHeight = () => this._props.PanelHeight() / (this._props.NativeDimScaling?.() || 1); scrollXf = () => this.ScreenToLocalBoxXf().translate(0, NumCast(this.layoutDoc._layout_scrollTop)); transparentFilter = () => [...this._props.childFilters(), ClientUtils.TransparentBackgroundFilter]; opaqueFilter = () => [...this._props.childFilters(), ClientUtils.OpaqueBackgroundFilter]; infoWidth = () => this._props.PanelWidth() / 5; infoHeight = () => this._props.PanelHeight() / 5; anchorMenuClick = () => this._sidebarRef.current?.anchorMenuClick; savedAnnotations = () => this._savedAnnotations; @action deselectPinOrRoute = () => { if (this._selectedPinOrRoute) { // // Removes filter // Doc.setDocFilter(this.Document, 'latitude', this.selectedPin.latitude, 'remove'); // Doc.setDocFilter(this.Document, 'longitude', this.selectedPin.longitude, 'remove'); // Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this.selectedPin))}`, 'remove'); // const temp = this.selectedPin; // if (!this._unmounting) { // this._bingMap.current.entities.remove(this.map_docToPinMap.get(temp)); // } // const newpin = new this.MicrosoftMaps.Pushpin(new this.MicrosoftMaps.Location(temp.latitude, temp.longitude)); // this.MicrosoftMaps.Events.addHandler(newpin, 'click', (e: any) => this.pushpinClicked(temp as Doc)); // if (!this._unmounting) { // this._bingMap.current.entities.push(newpin); // } // this.map_docToPinMap.set(temp, newpin); // this.selectedPin = undefined; // this.bingSearchBarContents = this.Document.map; } }; getView = async (doc: Doc, options: FocusViewOptions) => { if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) { this.toggleSidebar(); options.didMove = true; } return new Promise>(res => { DocumentView.addViewRenderedCb(doc, dv => res(dv)); }); }; /* * Returns doc w/ relevant info */ getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps, existingPin?: Doc) => { /// this should use SELECTED pushpin for lat/long if there is a selection, otherwise CENTER const anchor = Docs.Create.ConfigDocument({ title: 'MapAnchor:' + this.Document.title, text: (StrCast(this._selectedPinOrRoute?.map) || StrCast(this.Document.map) || 'map location') as any, config_latitude: NumCast((existingPin ?? this._selectedPinOrRoute)?.latitude ?? this.dataDoc.latitude), config_longitude: NumCast((existingPin ?? this._selectedPinOrRoute)?.longitude ?? this.dataDoc.longitude), config_map_zoom: NumCast(this.dataDoc.map_zoom), // config_map_type: StrCast(this.dataDoc.map_type), config_map: StrCast((existingPin ?? this._selectedPinOrRoute)?.map) || StrCast(this.dataDoc.map), layout_unrendered: true, mapPin: existingPin ?? this._selectedPinOrRoute, annotationOn: this.Document, }); if (anchor) { if (!addAsAnnotation) anchor.backgroundColor = 'transparent'; addAsAnnotation && this.addDocument(anchor); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), map: true } }, this.Document); return anchor; } return this.Document; }; map_docToPinMap = new Map(); map_pinHighlighted = new Map(); /* * Input: pin doc * Removes pin from annotations */ @action removePushpinOrRoute = (pinOrRouteDoc: Doc) => this.removeMapDocument(pinOrRouteDoc, this.annotationKey); @action deleteSelectedPinOrRoute = undoable(() => { console.log('deleting'); if (this._selectedPinOrRoute) { // Removes filter Doc.setDocFilter(this.Document, 'latitude', this._selectedPinOrRoute.latitude, 'remove'); Doc.setDocFilter(this.Document, 'longitude', this._selectedPinOrRoute.longitude, 'remove'); Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this._selectedPinOrRoute))}`, 'remove'); this.removePushpinOrRoute(this._selectedPinOrRoute); } MapAnchorMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu, true); }, 'delete pin'); tryHideMapAnchorMenu = (e: PointerEvent) => { let target = document.elementFromPoint(e.x, e.y); while (target) { if (target.id === 'route-destination-searcher-listbox') return; if (target === MapAnchorMenu.top.current) return; target = target.parentElement; } e.stopPropagation(); e.preventDefault(); MapAnchorMenu.Instance.fadeOut(true); runInAction(() => { this._temporaryRouteSource = { type: 'FeatureCollection', features: [], }; }); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu, true); }; @action centerOnSelectedPin = () => { if (this._selectedPinOrRoute) { this._mapRef.current?.flyTo({ center: [NumCast(this._selectedPinOrRoute.longitude), NumCast(this._selectedPinOrRoute.latitude)], }); } // if (this.selectedPin) { // this.dataDoc.latitude = this.selectedPin.latitude; // this.dataDoc.longitude = this.selectedPin.longitude; // this.dataDoc.map = this.selectedPin.map ?? ''; // this.bingSearchBarContents = this.selectedPin.map; // } MapAnchorMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu); }; // eslint-disable-next-line @typescript-eslint/no-unused-vars recolorPin = (pin: Doc, color?: string) => { // this._bingMap.current.entities.remove(this.map_docToPinMap.get(pin)); // this.map_docToPinMap.delete(pin); // const newpin = new this.MicrosoftMaps.Pushpin(new this.MicrosoftMaps.Location(pin.latitude, pin.longitude), color ? { color } : {}); // this.MicrosoftMaps.Events.addHandler(newpin, 'click', (e: any) => this.pushpinClicked(pin)); // this._bingMap.current.entities.push(newpin); // this.map_docToPinMap.set(pin, newpin); }; // incrementer: number = 0; /* * Creates Pushpin doc and adds it to the list of annotations */ @action createPushpin = undoable((latitude: number, longitude: number, location?: string, wikiData?: string) => { // Stores the pushpin as a MapMarkerDocument const pushpin = Docs.Create.PushpinDocument( NumCast(latitude), NumCast(longitude), false, [], { title: location ?? `lat=${NumCast(latitude)},lng=${NumCast(longitude)}`, map: location, description: '', wikiData: wikiData, markerType: 'MAP_PIN', markerColor: '#ff5722', } // { title: map ?? `lat=${latitude},lng=${longitude}`, map: map }, // ,'pushpinIDamongus'+ this.incrementer++ ); this.addDocument(pushpin, this.annotationKey); console.log(pushpin); return pushpin; // mapMarker.infoWindowOpen = true; }, 'createpin'); @action createMapRoute = undoable((coordinates: Position[], originName: string, destination: any, createPinForDestination: boolean) => { if (originName !== destination.place_name) { const mapRoute = Docs.Create.MapRouteDocument(false, [], { title: `${originName} --> ${destination.place_name}`, routeCoordinates: JSON.stringify(coordinates) }); this.addDocument(mapRoute, this.annotationKey); if (createPinForDestination) { this.createPushpin(destination.center[1], destination.center[0], destination.place_name); } this._temporaryRouteSource = { type: 'FeatureCollection', features: [], }; MapAnchorMenu.Instance.fadeOut(true); return mapRoute; } return undefined; // TODO: Display error that can't create route to same location }, 'createmaproute'); @action searchbarKeyDown = (e: any) => { if (e.key === 'Enter' && this._featuresFromGeocodeResults) { const center = this._featuresFromGeocodeResults[0]?.center; this._featuresFromGeocodeResults = []; setTimeout(() => center && this._mapRef.current?.flyTo({ center })); } }; @action addMarkerForFeature = (feature: any) => { const location = feature.place_name; if (feature.center) { const longitude = feature.center[0]; const latitude = feature.center[1]; const wikiData = feature.properties?.wikiData; this.createPushpin(latitude, longitude, location, wikiData); if (this._mapRef.current) { this._mapRef.current.flyTo({ center: feature.center, }); } this._featuresFromGeocodeResults = []; } else { // TODO: handle error } }; /** * Makes a forward geocoding API call to Mapbox to retrieve locations based on the search input * @param searchText the search input (presumably a location) */ handleSearchChange = async (searchText: string) => { const features = await MapboxApiUtility.forwardGeocodeForFeatures(searchText); if (features && !this._isAnimating) { runInAction(() => { this._settingsOpen = false; this._featuresFromGeocodeResults = features; this._routeToAnimate = undefined; }); } // try { // const url = MAPBOX_FORWARD_GEOCODE_BASE_URL + encodeURI(searchText) +'.json' +`?access_token=${MAPBOX_ACCESS_TOKEN}`; // const response = await fetch(url); // const data = await response.json(); // runInAction(() => { // this.featuresFromGeocodeResults = data.features; // }) // } catch (error: any){ // // TODO: handle error in better way // console.log(error); // } }; // @action // debouncedCall = React.useCallback(debounce(this.debouncedOnSearchBarChange, 300), []); @action handleMapClick = (e: MapLayerMouseEvent) => { this._featuresFromGeocodeResults = []; this._settingsOpen = false; if (this._mapRef.current) { const features = this._mapRef.current.queryRenderedFeatures(e.point, { layers: ['map-routes-layer'], }); console.error(features); if (features && features.length > 0 && features[0].properties && features[0].geometry) { const { routeTitle } = features[0].properties; const routeDoc: Doc | undefined = this.allRoutes.find(rtDoc => rtDoc.title === routeTitle); this.deselectPinOrRoute(); // TODO: Also deselect route if selected if (routeDoc) { this._selectedPinOrRoute = routeDoc; Doc.setDocFilter(this.Document, LinkedTo, `mapRoute=${Field.toScriptString(this._selectedPinOrRoute)}`, 'check'); // TODO: Recolor route MapAnchorMenu.Instance.Delete = this.deleteSelectedPinOrRoute; MapAnchorMenu.Instance.Center = this.centerOnSelectedPin; MapAnchorMenu.Instance.OnClick = this.createNoteAnnotation; MapAnchorMenu.Instance.StartDrag = this.startAnchorDrag; MapAnchorMenu.Instance.Reset(); MapAnchorMenu.Instance.setRouteDoc(routeDoc); // TODO: Subject to change MapAnchorMenu.Instance.setAllMapboxPins(this.allAnnotations.filter(anno => !anno.layout_unrendered)); MapAnchorMenu.Instance.DisplayRoute = this.displayRoute; MapAnchorMenu.Instance.AddNewRouteToMap = this.createMapRoute; MapAnchorMenu.Instance.CreatePin = this.addMarkerForFeature; MapAnchorMenu.Instance.OpenAnimationPanel = this.openAnimationPanel; // this.selectedRouteCoordinates = geometry.coordinates; MapAnchorMenu.Instance.setMenuType('route'); MapAnchorMenu.Instance.jumpTo(e.originalEvent.clientX, e.originalEvent.clientY, true); document.addEventListener('pointerdown', this.tryHideMapAnchorMenu, true); } } } }; /** * Makes a reverse geocoding API call to retrieve features corresponding to a map click (based on longitude * and latitude). Sets the search results accordingly. * @param e */ handleMapDblClick = async (e: MapLayerMouseEvent) => { e.preventDefault(); const { lngLat } = e; const longitude: number = lngLat.lng; const latitude: number = lngLat.lat; const features = await MapboxApiUtility.reverseGeocodeForFeatures(longitude, latitude); if (features) { runInAction(() => { this._featuresFromGeocodeResults = features; }); } // // REVERSE GEOCODE TO GET LOCATION DETAILS // try { // const url = MAPBOX_REVERSE_GEOCODE_BASE_URL + encodeURI(longitude.toString() + "," + latitude.toString()) + '.json' + // `?access_token=${MAPBOX_ACCESS_TOKEN}`; // const response = await fetch(url); // const data = await response.json(); // console.log("REV GEOCODE DATA: ", data); // runInAction(() => { // this.featuresFromGeocodeResults = data.features; // }) // } catch (error: any){ // // TODO: handle error in better way // console.log(error); // } }; @action handleMarkerClick = (e: MarkerEvent, pinDoc: Doc) => { this._featuresFromGeocodeResults = []; this.deselectPinOrRoute(); // TODO: check this method this._selectedPinOrRoute = pinDoc; // this.bingSearchBarContents = pinDoc.map; // Doc.setDocFilter(this.Document, 'latitude', this.selectedPin.latitude, 'match'); // Doc.setDocFilter(this.Document, 'longitude', this.selectedPin.longitude, 'match'); Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(this._selectedPinOrRoute)}`, 'check'); this.recolorPin(this._selectedPinOrRoute, 'green'); // TODO: check this method MapAnchorMenu.Instance.Delete = this.deleteSelectedPinOrRoute; MapAnchorMenu.Instance.Center = this.centerOnSelectedPin; MapAnchorMenu.Instance.OnClick = this.createNoteAnnotation; MapAnchorMenu.Instance.StartDrag = this.startAnchorDrag; MapAnchorMenu.Instance.Reset(); // pass in the pinDoc MapAnchorMenu.Instance.setPinDoc(pinDoc); MapAnchorMenu.Instance.setAllMapboxPins(this.allAnnotations.filter(anno => !anno.layout_unrendered)); MapAnchorMenu.Instance.DisplayRoute = this.displayRoute; MapAnchorMenu.Instance.AddNewRouteToMap = this.createMapRoute; MapAnchorMenu.Instance.CreatePin = this.addMarkerForFeature; MapAnchorMenu.Instance.setMenuType('standard'); // MapAnchorMenu.Instance.jumpTo(NumCast(pinDoc.longitude), NumCast(pinDoc.latitude)-3, true); MapAnchorMenu.Instance.jumpTo(e.originalEvent.clientX, e.originalEvent.clientY, true); document.addEventListener('pointerdown', this.tryHideMapAnchorMenu, true); // this._mapRef.current.flyTo({ // center: [NumCast(pinDoc.longitude), NumCast(pinDoc.latitude)-3] // }) }; @action displayRoute = (routeInfoMap: Record | undefined, type: TransportationType) => { if (routeInfoMap) { const newTempRouteSource: FeatureCollection = { type: 'FeatureCollection', features: [ { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: routeInfoMap[type].coordinates, }, }, ], }; // TODO: Create pin for destination // TODO: Fly to point where full route will be shown this._temporaryRouteSource = newTempRouteSource; } }; @action setAnimationPhase = (newValue: number) => { this._animationPhase = newValue; }; @action setFrameId = (frameId: number) => { this._frameId = frameId; }; @action setAnimationUtility = (util: AnimationUtility) => { this._animationUtility = util; }; @action openAnimationPanel = (routeDoc: Doc | undefined) => { if (routeDoc) { MapAnchorMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu, true); this._featuresFromGeocodeResults = []; this._routeToAnimate = routeDoc; } }; @computed get mapboxMapViewState(): ViewState { return { zoom: NumCast(this.dataDoc.map_zoom, 8), longitude: NumCast(this.dataDoc.longitude, -71.4128), latitude: NumCast(this.dataDoc.latitude, 41.824), pitch: NumCast(this.dataDoc.map_pitch), bearing: NumCast(this.dataDoc.map_bearing), padding: { top: 0, bottom: 0, left: 0, right: 0, }, }; } @computed get preAnimationViewState() { if (!this._isAnimating) { return this.mapboxMapViewState; } return undefined; } @action setAnimationLineColor = (color: ColorResult) => { this._animationLineColor = color.hex; }; @action updateAnimationSpeed = () => { let newAnimationSpeed: AnimationSpeed; switch (this._animationSpeed) { case AnimationSpeed.SLOW: newAnimationSpeed = AnimationSpeed.MEDIUM; break; case AnimationSpeed.MEDIUM: newAnimationSpeed = AnimationSpeed.FAST; break; case AnimationSpeed.FAST: newAnimationSpeed = AnimationSpeed.SLOW; break; default: newAnimationSpeed = AnimationSpeed.MEDIUM; break; } this._animationSpeed = newAnimationSpeed; if (this._animationUtility) { this._animationUtility.updateAnimationSpeed(newAnimationSpeed); } }; @computed get animationSpeedTooltipText(): string { switch (this._animationSpeed) { case AnimationSpeed.SLOW: return '1x speed'; case AnimationSpeed.MEDIUM: return '2x speed'; case AnimationSpeed.FAST: return '3x speed'; default: return '2x speed'; } // prettier-ignore } @computed get animationSpeedIcon(): JSX.Element { switch (this._animationSpeed) { case AnimationSpeed.SLOW: return slowSpeedIcon; case AnimationSpeed.MEDIUM: return mediumSpeedIcon; case AnimationSpeed.FAST: return fastSpeedIcon; default: return mediumSpeedIcon; } // prettier-ignore } @action toggleIsStreetViewAnimation = () => { const newVal = !this._isStreetViewAnimation; this._isStreetViewAnimation = newVal; this._animationUtility?.updateIsStreetViewAnimation(newVal); }; getFeatureFromRouteDoc = (routeDoc: Doc): Feature => { const geometry: LineString = { type: 'LineString', coordinates: JSON.parse(StrCast(routeDoc.routeCoordinates)), }; return { type: 'Feature', properties: { routeTitle: routeDoc.title, }, geometry: geometry, }; }; @action playAnimation = (status: AnimationStatus) => { if (!this._mapRef.current || !this._routeToAnimate) { return; } this._animationPhase = status === AnimationStatus.RESUME ? this._animationPhase : 0; this._frameId = AnimationStatus.RESUME ? this._frameId : null; this._finishedFlyTo = AnimationStatus.RESUME ? this._finishedFlyTo : false; const path = turf.lineString(this.selectedRouteCoordinates); this._settingsOpen = false; this.path = path; this._isAnimating = true; runInAction( () => // eslint-disable-next-line no-async-promise-executor new Promise(async resolve => { const targetLngLat = { lng: this.selectedRouteCoordinates[0][0], lat: this.selectedRouteCoordinates[0][1], }; const animationUtil = new AnimationUtility(targetLngLat, this.selectedRouteCoordinates, this._isStreetViewAnimation, this._animationSpeed, this._showTerrain, this._mapRef.current); runInAction(() => this.setAnimationUtility(animationUtil)); const updateFrameId = (newFrameId: number) => this.setFrameId(newFrameId); const updateAnimationPhase = (newAnimationPhase: number) => this.setAnimationPhase(newAnimationPhase); if (status !== AnimationStatus.RESUME) { const result = await animationUtil.flyInAndRotate({ map: this._mapRef.current!, // targetLngLat, // duration 3000 // startAltitude: 3000000, // endAltitude: this.isStreetViewAnimation ? 80 : 12000, // startBearing: 0, // endBearing: -20, // startPitch: 40, // endPitch: this.isStreetViewAnimation ? 80 : 50, updateFrameId, }); console.log('Bearing: ', result.bearing); console.log('Altitude: ', result.altitude); } runInAction(() => { this._finishedFlyTo = true; }); // follow the path while slowly rotating the camera, passing in the camera bearing and altitude from the previous animation await animationUtil.animatePath({ map: this._mapRef.current!, // path: this.path, // startBearing: -20, // startAltitude: this.isStreetViewAnimation ? 80 : 12000, // pitch: this.isStreetViewAnimation ? 80: 50, currentAnimationPhase: this._animationPhase, updateAnimationPhase, updateFrameId, }); // get the bounds of the linestring, use fitBounds() to animate to a final view const bbox3d = turf.bbox(this.path); const bbox2d: LngLatBoundsLike = [bbox3d[0], bbox3d[1], bbox3d[2], bbox3d[3]]; this._mapRef.current!.fitBounds(bbox2d, { duration: 3000, pitch: 30, bearing: 0, padding: 120, }); setTimeout(() => { this._isStreetViewAnimation = false; resolve(); }, 10000); }) ); }; @action pauseAnimation = () => { if (this._frameId && this._animationPhase > 0) { window.cancelAnimationFrame(this._frameId); this._frameId = null; this._isAnimating = false; } }; @action stopAnimation = (close: boolean) => { if (this._frameId) { window.cancelAnimationFrame(this._frameId); } this._animationPhase = 0; this._frameId = null; this._finishedFlyTo = false; this._isAnimating = false; if (close) { this._animationSpeed = AnimationSpeed.MEDIUM; this._isStreetViewAnimation = false; this._routeToAnimate = undefined; this._animationUtility = null; } }; getRouteAnimationOptions = (): JSX.Element => ( <> { if (this._isAnimating && this._finishedFlyTo) { this.pauseAnimation(); } else if (this._animationPhase > 0) { this.playAnimation(AnimationStatus.RESUME); // Resume from the current phase } else { this.playAnimation(AnimationStatus.START); // Play from the beginning } }} icon={this._isAnimating && this._finishedFlyTo ? : } color="black" size={Size.MEDIUM} /> {this._isAnimating && this._finishedFlyTo && ( { this.stopAnimation(false); this.playAnimation(AnimationStatus.START); }} icon={} color="black" size={Size.MEDIUM} /> )} this.stopAnimation(true)} icon={} color="black" size={Size.MEDIUM} />
|
} />
|
|
Select Line Color:
this.setAnimationLineColor(color)} />
); @action hideRoute = () => { this._temporaryRouteSource = { type: 'FeatureCollection', features: [], }; }; @action toggleSettings = () => { if (!this._isAnimating && this._animationPhase === 0) { this._featuresFromGeocodeResults = []; this._settingsOpen = !this._settingsOpen; } }; @action changeMapStyle = (e: React.ChangeEvent) => { this.dataDoc.map_style = e.target.value; // this.mapStyle = `mapbox://styles/mapbox/${e.target.value}` }; @action onBearingChange = (e: React.ChangeEvent) => { const bearing = parseInt(e.target.value); if (!isNaN(bearing) && this._mapRef.current) { console.log('bearing change'); const fixedBearing = Math.max(0, Math.min(360, bearing)); this._mapRef.current.setBearing(fixedBearing); this.dataDoc.map_bearing = fixedBearing; } }; @action onPitchChange = (e: React.ChangeEvent) => { const pitch = parseInt(e.target.value); if (!isNaN(pitch) && this._mapRef.current) { console.log('pitch change'); const fixedPitch = Math.max(0, Math.min(85, pitch)); this._mapRef.current.setPitch(fixedPitch); this.dataDoc.map_pitch = fixedPitch; } }; @action onZoomChange = (e: React.ChangeEvent) => { const zoom = parseInt(e.target.value); if (!isNaN(zoom) && this._mapRef.current) { const fixedZoom = Math.max(0, Math.min(16, zoom)); this._mapRef.current.setZoom(fixedZoom); this.dataDoc.map_zoom = fixedZoom; } }; @action onStepZoomChange = (increment: boolean) => { if (this._mapRef.current) { const newZoom = increment // ? Math.min(16, this.mapboxMapViewState.zoom + 1) : Math.max(0, this.mapboxMapViewState.zoom - 1); this._mapRef.current.setZoom(newZoom); this.dataDoc.map_zoom = newZoom; } }; @action onMapZoom = (e: ViewStateChangeEvent) => { this.dataDoc.map_zoom = e.viewState.zoom; }; @action onMapMove = (e: ViewStateChangeEvent) => { this.dataDoc.longitude = e.viewState.longitude; this.dataDoc.latitude = e.viewState.latitude; }; @action toggleShowTerrain = () => { this._showTerrain = !this._showTerrain; }; getMarkerIcon = (pinDoc: Doc): JSX.Element | null => { const markerType = StrCast(pinDoc.markerType); const markerColor = StrCast(pinDoc.markerColor); return MarkerIcons.getFontAwesomeIcon(markerType, '2x', markerColor) ?? null; }; _textRef = React.createRef(); render() { const scale = this._props.NativeDimScaling?.() || 1; const parscale = scale === 1 ? 1 : this.ScreenToLocalBoxXf().Scale ?? 1; return (
e.stopPropagation()} onPointerDown={e => e.button === 0 && !e.ctrlKey && e.stopPropagation()} style={{ transformOrigin: 'top left', transform: `scale(${scale})`, width: `calc(100% - ${this.sidebarWidthPercent})`, pointerEvents: this.pointerEvents() }}> {!this._routeToAnimate && (
this.handleSearchChange(e.target.value)} /> } type={Type.TERT} onClick={() => this.toggleSettings()} />
} type={Type.TERT} onClick={() => this.toggleSettings()} />
)} {this._settingsOpen && !this._routeToAnimate && (
Map Style:
Bearing:
Pitch:
Zoom:
Show terrain:
)} {this._routeToAnimate && (
{StrCast(this._routeToAnimate.title)}
{this.getRouteAnimationOptions()}
)} {this._featuresFromGeocodeResults.length > 0 && (

Choose a location for your pin:

{this._featuresFromGeocodeResults .filter(feature => feature.place_name) .map((feature, idx) => (
{ this.handleSearchChange(''); this.addMarkerForFeature(feature); }}>
{feature.place_name}
))}
)} {!this._isAnimating && this._animationPhase === 0 && ( )} {this._routeToAnimate && (this._isAnimating || this._animationPhase > 0) && ( <> {!this._isStreetViewAnimation && ( <> )} )} {!this._isAnimating && this._animationPhase === 0 && this.allPushpins // .filter(anno => !anno.layout_unrendered) .map((pushpin, idx) => ( // eslint-disable-next-line react/no-array-index-key ) => this.handleMarkerClick(e, pushpin)}> {this.getMarkerIcon(pushpin)} ))} {/* {this.mapMarkers.length > 0 && this.mapMarkers.map((marker, idx) => ( ))} */}
{this.sidebarHandle}
); } } Docs.Prototypes.TemplateMap.set(DocumentType.MAP, { layout: { view: MapBox, dataField: 'data' }, options: { acl: '', map: '', _height: 600, _width: 800, _layout_reflowHorizontal: true, _layout_reflowVertical: true, _layout_nativeDimEditable: true, systemIcon: 'BsFillPinMapFill' }, });