diff options
author | bobzel <zzzman@gmail.com> | 2023-12-21 14:55:48 -0500 |
---|---|---|
committer | bobzel <zzzman@gmail.com> | 2023-12-21 14:55:48 -0500 |
commit | 1caba64ee0f32ee8af79263cd4ef2a8bc5d5146e (patch) | |
tree | 0fa0e957d1f342fdc6ed4a4b43f5dddfddb1298a /src/client/views/nodes/MapBox/MapBox.tsx | |
parent | 02eb7da95df283606d4275a22d9451cef371c3b5 (diff) | |
parent | 2691b951d96f2ce7652acbea9e340b61737b3b57 (diff) |
Merge branch 'moreUpgrading' into dataViz-annotations
Diffstat (limited to 'src/client/views/nodes/MapBox/MapBox.tsx')
-rw-r--r-- | src/client/views/nodes/MapBox/MapBox.tsx | 1369 |
1 files changed, 1155 insertions, 214 deletions
diff --git a/src/client/views/nodes/MapBox/MapBox.tsx b/src/client/views/nodes/MapBox/MapBox.tsx index 9b75ca7e3..7db139d74 100644 --- a/src/client/views/nodes/MapBox/MapBox.tsx +++ b/src/client/views/nodes/MapBox/MapBox.tsx @@ -1,32 +1,44 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import BingMapsReact from 'bingmaps-react'; -import { Button, EditableText, IconButton, Type } from 'browndash-components'; -import { action, computed, IReactionDisposer, observable, ObservableMap, reaction, runInAction } from 'mobx'; +import { IconButton, Size, Type } from 'browndash-components'; +import { IReactionDisposer, ObservableMap, action, autorun, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; +import { Utils, emptyFunction, setupMoveUpEvents } from '../../../../Utils'; import { Doc, DocListCast, Field, LinkedTo, Opt } from '../../../../fields/Doc'; import { DocCss, Highlight } from '../../../../fields/DocSymbols'; -import { Id } from '../../../../fields/FieldSymbols'; import { DocCast, NumCast, StrCast } from '../../../../fields/Types'; -import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnOne, setupMoveUpEvents, Utils } from '../../../../Utils'; -import { Docs, DocUtils } from '../../../documents/Documents'; import { DocumentType } from '../../../documents/DocumentTypes'; +import { DocUtils, Docs } from '../../../documents/Documents'; import { DocumentManager } from '../../../util/DocumentManager'; import { DragManager } from '../../../util/DragManager'; import { LinkManager } from '../../../util/LinkManager'; import { SnappingManager } from '../../../util/SnappingManager'; -import { Transform } from '../../../util/Transform'; -import { undoable, UndoManager } from '../../../util/UndoManager'; -import { MarqueeOptionsMenu } from '../../collections/collectionFreeForm'; +import { UndoManager, undoable } from '../../../util/UndoManager'; import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from '../../DocComponent'; -import { Colors } from '../../global/globalEnums'; import { SidebarAnnos } from '../../SidebarAnnos'; +import { MarqueeOptionsMenu } from '../../collections/collectionFreeForm'; +import { Colors } from '../../global/globalEnums'; import { DocumentView } from '../DocumentView'; import { FieldView, FieldViewProps } from '../FieldView'; import { FormattedTextBox } from '../formattedText/FormattedTextBox'; import { PinProps, PresBox } from '../trails'; import { MapAnchorMenu } from './MapAnchorMenu'; +import { ControlPosition, Layer, MapProvider, MapRef, Map as MapboxMap, Marker, MarkerProps, Source, ViewState, ViewStateChangeEvent } from 'react-map-gl'; import './MapBox.scss'; +// import { GeocoderControl } from './GeocoderControl'; +import { IconLookup, faCircleXmark, faGear, faPause, faPlay, faRotate } from '@fortawesome/free-solid-svg-icons'; +import { Checkbox, FormControlLabel, TextField } from '@mui/material'; +import * as turf from '@turf/turf'; +import * as d3 from 'd3'; +import { Feature, FeatureCollection, GeoJsonProperties, Geometry, LineString, Position } from 'geojson'; +import mapboxgl, { LngLat, LngLatBoundsLike, MapLayerMouseEvent } from 'mapbox-gl'; +import { CirclePicker, ColorResult } from 'react-color'; +import { MarkerEvent } from 'react-map-gl/dist/esm/types'; +import { fastSpeedIcon, mediumSpeedIcon, slowSpeedIcon } from './AnimationSpeedIcons'; +import { AnimationSpeed, AnimationStatus, AnimationUtility } from './AnimationUtility'; +import { MapboxApiUtility, TransportationType } from './MapboxApiUtility'; +import { MarkerIcons } from './MarkerIcons'; + // amongus /** * MapBox architecture: @@ -42,6 +54,30 @@ import './MapBox.scss'; */ const bingApiKey = process.env.BING_MAPS; // if you're running local, get a Bing Maps api key here: https://www.bingmapsportal.com/ and then add it to the .env file in the Dash-Web root directory as: _CLIENT_BING_MAPS=<your apikey> +const MAPBOX_ACCESS_TOKEN = 'pk.eyJ1IjoiemF1bHRhdmFuZ2FyIiwiYSI6ImNscHgwNDd1MDA3MXIydm92ODdianp6cGYifQ.WFAqbhwxtMHOWSPtu0l2uQ'; +const MAPBOX_FORWARD_GEOCODE_BASE_URL = 'https://api.mapbox.com/geocoding/v5/mapbox.places/'; + +const MAPBOX_REVERSE_GEOCODE_BASE_URL = 'https://api.mapbox.com/geocoding/v5/mapbox.places/'; + +type PopupInfo = { + longitude: number; + latitude: number; + title: string; + description: string; +}; + +// export type GeocoderControlProps = Omit<GeocoderOptions, 'accessToken' | 'mapboxgl' | 'marker'> & { +// mapboxAccessToken: string; +// marker?: Omit<MarkerProps, 'longitude' | 'latitude'>; +// position: ControlPosition; + +// onResult: (...args: any[]) => void; +// }; + +type MapMarker = { + longitude: number; + latitude: number; +}; /** * Consider integrating later: allows for drawing, circling, making shapes on map @@ -67,9 +103,15 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps private _dragRef = React.createRef<HTMLDivElement>(); private _sidebarRef = React.createRef<SidebarAnnos>(); private _ref: React.RefObject<HTMLDivElement> = React.createRef(); + private _mapRef: React.RefObject<MapRef> = React.createRef(); private _disposers: { [key: string]: IReactionDisposer } = {}; private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean, hide: boolean, doc: Opt<Doc>) => void); + constructor(props: any) { + super(props); + makeObservable(this); + } + @observable private _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); @@ -81,6 +123,116 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps @computed get allPushpins() { return this.allAnnotations.filter(anno => anno.type === DocumentType.PUSHPIN); } + @computed get allRoutes() { + return this.allAnnotations.filter(anno => anno.type === DocumentType.MAPROUTE); + } + @computed get updatedRouteCoordinates(): Feature<Geometry, GeoJsonProperties> { + 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<Geometry, GeoJsonProperties>; + + 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<LineString, turf.Properties> = { + 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[] { + let coordinates: Position[] = []; + if (this.routeToAnimate?.routeCoordinates) { + coordinates = JSON.parse(StrCast(this.routeToAnimate.routeCoordinates)); + } + return coordinates; + } + + @computed get allRoutesGeoJson(): FeatureCollection { + const features: Feature<Geometry, GeoJsonProperties>[] = 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, + }; + } + @computed get SidebarShown() { return this.layoutDoc._layout_showSidebar ? true : false; } @@ -88,7 +240,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps return StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); } @computed get sidebarColor() { - return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this.props.fieldKey + '_backgroundColor'], '#e4e4e4')); + return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this._props.fieldKey + '_backgroundColor'], '#e4e4e4')); } @computed get SidebarKey() { return this.fieldKey + '_sidebar'; @@ -96,13 +248,13 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps componentDidMount() { this._unmounting = false; - this.props.setContentView?.(this); + this._props.setContentView?.(this); } _unmounting = false; componentWillUnmount(): void { this._unmounting = true; - this.deselectPin(); + this.deselectPinOrRoute(); this._rerenderTimeout && clearTimeout(this._rerenderTimeout); Object.keys(this._disposers).forEach(key => this._disposers[key]?.()); } @@ -117,7 +269,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps if (!this.layoutDoc._layout_showSidebar) this.toggleSidebar(); const docs = doc instanceof Doc ? [doc] : doc; docs.forEach(doc => { - let existingPin = this.allPushpins.find(pin => pin.latitude === doc.latitude && pin.longitude === doc.longitude) ?? this.selectedPin; + 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)); } @@ -161,9 +313,9 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps e, (e, down, delta) => runInAction(() => { - const localDelta = this.props + const localDelta = this._props .ScreenToLocalTransform() - .scale(this.props.NativeDimScaling?.() || 1) + .scale(this._props.NativeDimScaling?.() || 1) .transformDirection(delta[0], delta[1]); const fullWidth = NumCast(this.layoutDoc._width); const mapWidth = fullWidth - this.sidebarWidth(); @@ -182,7 +334,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps () => UndoManager.RunInBatch(this.toggleSidebar, 'toggle sidebar map') ); }; - sidebarWidth = () => (Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100) * this.props.PanelWidth(); + sidebarWidth = () => (Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100) * this._props.PanelWidth(); /** * Handles toggle of sidebar on click the little comment button @@ -194,8 +346,8 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps key="sidebar" title="Toggle Sidebar" style={{ - display: !this.props.isContentActive() ? 'none' : undefined, - top: StrCast(this.rootDoc._layout_showTitle) === 'title' ? 20 : 5, + display: !this._props.isContentActive() ? 'none' : undefined, + top: StrCast(this.Document._layout_showTitle) === 'title' ? 20 : 5, backgroundColor: this.SidebarShown ? Colors.MEDIUM_BLUE : Colors.BLACK, }} onPointerDown={this.sidebarBtnDown}> @@ -218,25 +370,25 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps const sourceAnchorCreator = action(() => { const note = this.getAnchor(true); - if (note && this.selectedPin) { - note.latitude = this.selectedPin.latitude; - note.longitude = this.selectedPin.longitude; - note.map = this.selectedPin.map; + 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.rootDoc.title, 0, 0, 100, 100, undefined, annotationOn, undefined, 'yellow'); - FormattedTextBox.SelectOnLoad = target[Id]; + const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, undefined, annotationOn, undefined, 'yellow'); + FormattedTextBox.SetSelectOnLoad(target); return target; }; - const docView = this.props.DocumentView?.(); + const docView = this._props.DocumentView?.(); docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: e => { if (!e.aborted && e.annoDragData && e.annoDragData.linkSourceDoc && e.annoDragData.dropDocument && e.linkDocument) { - e.annoDragData.linkSourceDoc.followLinkToggle = e.annoDragData.dropDocument.annotationOn === this.props.Document; + e.annoDragData.linkSourceDoc.followLinkToggle = e.annoDragData.dropDocument.annotationOn === this._props.Document; e.annoDragData.linkSourceDoc.followLinkZoom = false; } }, @@ -247,10 +399,10 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps const createFunc = undoable( action(() => { const note = this._sidebarRef.current?.anchorMenuClick(this.getAnchor(true), ['latitude', 'longitude', LinkedTo]); - if (note && this.selectedPin) { - note.latitude = this.selectedPin.latitude; - note.longitude = this.selectedPin.longitude; - note.map = this.selectedPin.map; + if (note && this.selectedPinOrRoute) { + note.latitude = this.selectedPinOrRoute.latitude; + note.longitude = this.selectedPinOrRoute.longitude; + note.map = this.selectedPinOrRoute.map; } }), 'create note annotation' @@ -275,15 +427,15 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps addDocumentWrapper = (doc: Doc | Doc[], annotationKey?: string) => this.addDocument(doc, annotationKey); - pointerEvents = () => (this.props.isContentActive() && !MarqueeOptionsMenu.Instance.isShown() ? 'all' : 'none'); + 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.props.ScreenToLocalTransform().translate(0, NumCast(this.layoutDoc._layout_scrollTop)); - transparentFilter = () => [...this.props.childFilters(), Utils.TransparentBackgroundFilter]; - opaqueFilter = () => [...this.props.childFilters(), Utils.OpaqueBackgroundFilter]; - infoWidth = () => this.props.PanelWidth() / 5; - infoHeight = () => this.props.PanelHeight() / 5; + panelWidth = () => this._props.PanelWidth() / (this._props.NativeDimScaling?.() || 1) - this.sidebarWidth(); + panelHeight = () => this._props.PanelHeight() / (this._props.NativeDimScaling?.() || 1); + scrollXf = () => this._props.ScreenToLocalTransform().translate(0, NumCast(this.layoutDoc._layout_scrollTop)); + transparentFilter = () => [...this._props.childFilters(), Utils.TransparentBackgroundFilter]; + opaqueFilter = () => [...this._props.childFilters(), Utils.OpaqueBackgroundFilter]; + infoWidth = () => this._props.PanelWidth() / 5; + infoHeight = () => this._props.PanelHeight() / 5; anchorMenuClick = () => this._sidebarRef.current?.anchorMenuClick; savedAnnotations = () => this._savedAnnotations; @@ -321,55 +473,34 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps }; @observable - bingSearchBarContents: any = this.rootDoc.map; // For Bing Maps: The contents of the Bing search bar (string) + bingSearchBarContents: any = this.Document.map; // For Bing Maps: The contents of the Bing search bar (string) geoDataRequestOptions = { entityType: 'PopulatedPlace', }; - // incrementer: number = 0; - /* - * Creates Pushpin doc and adds it to the list of annotations - */ - @action - createPushpin = undoable((latitude: number, longitude: number, map?: string) => { - // Stores the pushpin as a MapMarkerDocument - const pushpin = Docs.Create.PushpinDocument( - NumCast(latitude), - NumCast(longitude), - false, - [], - { title: map ?? `lat=${latitude},lng=${longitude}`, map: map } - // ,'pushpinIDamongus'+ this.incrementer++ - ); - this.addDocument(pushpin, this.annotationKey); - return pushpin; - // mapMarker.infoWindowOpen = true; - }, 'createpin'); - // The pin that is selected - @observable selectedPin: Doc | undefined; + @observable selectedPinOrRoute: Doc | undefined; @action - deselectPin = () => { - if (this.selectedPin) { - // Removes filter - Doc.setDocFilter(this.rootDoc, 'latitude', this.selectedPin.latitude, 'remove'); - Doc.setDocFilter(this.rootDoc, 'longitude', this.selectedPin.longitude, 'remove'); - Doc.setDocFilter(this.rootDoc, 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.rootDoc.map; + 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; } }; @@ -382,25 +513,25 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps */ @action pushpinClicked = (pinDoc: Doc) => { - this.deselectPin(); - this.selectedPin = pinDoc; + this.deselectPinOrRoute(); + this.selectedPinOrRoute = pinDoc; this.bingSearchBarContents = pinDoc.map; - // Doc.setDocFilter(this.rootDoc, 'latitude', this.selectedPin.latitude, 'match'); - // Doc.setDocFilter(this.rootDoc, 'longitude', this.selectedPin.longitude, 'match'); - Doc.setDocFilter(this.rootDoc, LinkedTo, `mapPin=${Field.toScriptString(this.selectedPin)}`, 'check'); + // 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.selectedPin, 'green'); + this.recolorPin(this.selectedPinOrRoute, 'green'); - MapAnchorMenu.Instance.Delete = this.deleteSelectedPin; + MapAnchorMenu.Instance.Delete = this.deleteSelectedPinOrRoute; MapAnchorMenu.Instance.Center = this.centerOnSelectedPin; MapAnchorMenu.Instance.OnClick = this.createNoteAnnotation; MapAnchorMenu.Instance.StartDrag = this.startAnchorDrag; - const point = this._bingMap.current.tryLocationToPixel(new this.MicrosoftMaps.Location(this.selectedPin.latitude, this.selectedPin.longitude)); - const x = point.x + (this.props.PanelWidth() - this.sidebarWidth()) / 2; - const y = point.y + this.props.PanelHeight() / 2 + 32; - const cpt = this.props.ScreenToLocalTransform().inverse().transformPoint(x, y); + const point = this._bingMap.current.tryLocationToPixel(new this.MicrosoftMaps.Location(this.selectedPinOrRoute.latitude, this.selectedPinOrRoute.longitude)); + const x = point.x + (this._props.PanelWidth() - this.sidebarWidth()) / 2; + const y = point.y + this._props.PanelHeight() / 2 + 32; + const cpt = this._props.ScreenToLocalTransform().inverse().transformPoint(x, y); MapAnchorMenu.Instance.jumpTo(cpt[0], cpt[1], true); document.addEventListener('pointerdown', this.tryHideMapAnchorMenu, true); @@ -411,8 +542,8 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps */ @action mapOnClick = (e: { location: { latitude: any; longitude: any } }) => { - this.props.select(false); - this.deselectPin(); + this._props.select(false); + this.deselectPinOrRoute(); }; /* * Updates values of layout doc to match the current map @@ -457,24 +588,24 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps 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.rootDoc.title, - text: StrCast(this.selectedPin?.map) || StrCast(this.rootDoc.map) || 'map location', - config_latitude: NumCast((existingPin ?? this.selectedPin)?.latitude ?? this.dataDoc.latitude), - config_longitude: NumCast((existingPin ?? this.selectedPin)?.longitude ?? this.dataDoc.longitude), + title: 'MapAnchor:' + this.Document.title, + text: StrCast(this.selectedPinOrRoute?.map) || StrCast(this.Document.map) || 'map location', + 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.selectedPin)?.map) || StrCast(this.dataDoc.map), + // 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.selectedPin, - annotationOn: this.rootDoc, + mapPin: existingPin ?? this.selectedPinOrRoute, + annotationOn: this.Document, }); if (anchor) { if (!addAsAnnotation) anchor.backgroundColor = 'transparent'; addAsAnnotation && this.addDocument(anchor); - PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), map: true } }, this.rootDoc); + PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), map: true } }, this.Document); return anchor; } - return this.rootDoc; + return this.Document; }; map_docToPinMap = new Map<Doc, any>(); @@ -504,7 +635,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps * Removes pin from annotations */ @action - removePushpin = (pinDoc: Doc) => this.removeMapDocument(pinDoc, this.annotationKey); + removePushpinOrRoute = (pinOrRouteDoc: Doc) => this.removeMapDocument(pinOrRouteDoc, this.annotationKey); /* * Removes pushpin from map render @@ -514,18 +645,19 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps this._bingMap.current.entities.remove(this.map_docToPinMap.get(pinDoc)); } this.map_docToPinMap.delete(pinDoc); - this.selectedPin = undefined; + this.selectedPinOrRoute = undefined; }; @action - deleteSelectedPin = undoable(() => { - if (this.selectedPin) { + deleteSelectedPinOrRoute = undoable(() => { + console.log('deleting') + if (this.selectedPinOrRoute) { // Removes filter - Doc.setDocFilter(this.rootDoc, 'latitude', this.selectedPin.latitude, 'remove'); - Doc.setDocFilter(this.rootDoc, 'longitude', this.selectedPin.longitude, 'remove'); - Doc.setDocFilter(this.rootDoc, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this.selectedPin))}`, 'remove'); + 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.removePushpin(this.selectedPin); + this.removePushpinOrRoute(this.selectedPinOrRoute); } MapAnchorMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu, true); @@ -534,23 +666,39 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps 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.selectedPin) { - this.dataDoc.latitude = this.selectedPin.latitude; - this.dataDoc.longitude = this.selectedPin.longitude; - this.dataDoc.map = this.selectedPin.map ?? ''; - this.bingSearchBarContents = this.selectedPin.map; + 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); }; @@ -582,16 +730,13 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps }, }; - @action - searchbarOnEdit = (newText: string) => (this.bingSearchBarContents = newText); - 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); + // 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); }; /* @@ -611,7 +756,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps this.MicrosoftMaps.Events.addHandler(this._bingMap.current, 'maptypechanged', undoable(this.updateMapType, 'Map ViewType Change')); this._disposers.mapLocation = reaction( - () => this.rootDoc.map, + () => this.Document.map, mapLoc => (this.bingSearchBarContents = mapLoc), { fireImmediately: true } ); @@ -636,7 +781,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps ); this._disposers.location = reaction( - () => ({ lat: this.rootDoc.latitude, lng: this.rootDoc.longitude, zoom: this.rootDoc.map_zoom, mapType: this.rootDoc.map_type }), + () => ({ lat: this.Document.latitude, lng: this.Document.longitude, zoom: this.Document.map_zoom, mapType: this.Document.map_type }), locationObject => { // if (this._bingMap.current) try { @@ -661,24 +806,26 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps e, e, e => { + // move event if (!dragClone) { - dragClone = this._dragRef.current?.cloneNode(true) as HTMLDivElement; + dragClone = this._dragRef.current?.cloneNode(true) as HTMLDivElement; // copy draggable pin dragClone.style.position = 'absolute'; dragClone.style.zIndex = '10000'; - DragManager.Root().appendChild(dragClone); + DragManager.Root().appendChild(dragClone); // add clone to root } dragClone.style.transform = `translate(${e.clientX - 15}px, ${e.clientY - 15}px)`; return false; }, e => { + // up event if (!dragClone) return; DragManager.Root().removeChild(dragClone); - let target = document.elementFromPoint(e.x, e.y); + let target = document.elementFromPoint(e.x, e.y); // element for specified x and y coordinates while (target) { if (target === this._ref.current) { - const cpt = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY); - const x = cpt[0] - (this.props.PanelWidth() - this.sidebarWidth()) / 2; - const y = cpt[1] - 32 /* height of search bar */ - this.props.PanelHeight() / 2; + const cpt = this._props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY); + const x = cpt[0] - (this._props.PanelWidth() - this.sidebarWidth()) / 2; + const y = cpt[1] - 20 /* height of search bar */ - this._props.PanelHeight() / 2; const location = this._bingMap.current.tryPixelToLocation(new this.MicrosoftMaps.Point(x, y)); this.createPushpin(location.latitude, location.longitude); break; @@ -687,7 +834,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps } }, e => { - const createPin = () => this.createPushpin(this.rootDoc.latitude, this.rootDoc.longitude, this.rootDoc.map); + const createPin = () => this.createPushpin(this.Document.latitude, this.Document.longitude, this.Document.map); if (this.bingSearchBarContents) { this.bingSearch().then(createPin); } else createPin(); @@ -695,15 +842,712 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps ); }; + // 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; + } + // TODO: Display error that can't create route to same location + }, 'createmaproute'); + searchbarKeyDown = (e: any) => e.key === 'Enter' && this.bingSearch(); + @observable + featuresFromGeocodeResults: any[] = []; + + @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 geometry = features[0].geometry as LineString; + const routeTitle: string = features[0].properties['routeTitle']; + const routeDoc: Doc | undefined = this.allRoutes.find(routeDoc => routeDoc.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: LngLat = e.lngLat; + 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); + // } + }; + + @observable + currentPopup: PopupInfo | undefined = undefined; + + @action + handleMarkerClick = (e: MarkerEvent<mapboxgl.Marker, MouseEvent>, 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] + // }) + }; + + @observable + temporaryRouteSource: FeatureCollection = { + type: 'FeatureCollection', + features: [], + }; + + @action + displayRoute = (routeInfoMap: Record<TransportationType, any> | 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; + } + }; + + @observable + isAnimating: boolean = false; + + @observable + routeToAnimate: Doc | undefined = undefined; + + @observable + animationPhase: number = 0; + + @observable + finishedFlyTo: boolean = false; + + @action + setAnimationPhase = (newValue: number) => { + this.animationPhase = newValue; + }; + + @observable + frameId: number | null = null; + + @action + setFrameId = (frameId: number) => { + this.frameId = frameId; + }; + + @observable + animationUtility: AnimationUtility | null = null; + + @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; + } + } + + @observable + isStreetViewAnimation: boolean = false; + + @observable + animationSpeed: AnimationSpeed = AnimationSpeed.MEDIUM; + + @observable + animationLineColor: string = '#ffff00'; + + @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'; + } + } + @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; + } + } + + @action + toggleIsStreetViewAnimation = () => { + const newVal = !this.isStreetViewAnimation; + this.isStreetViewAnimation = newVal; + if (this.animationUtility) { + this.animationUtility.updateIsStreetViewAnimation(newVal); + } + }; + + @observable + dynamicRouteFeature: Feature<Geometry, GeoJsonProperties> = { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [], + }, + }; + + @observable + path: turf.helpers.Feature<turf.helpers.LineString, turf.helpers.Properties> = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [], + }, + properties: {}, + }; + + getFeatureFromRouteDoc = (routeDoc: Doc): Feature<Geometry, GeoJsonProperties> => { + 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(() => { + return new Promise<void>(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 => { + return ( + <> + <IconButton + tooltip={this.isAnimating && this.finishedFlyTo ? 'Pause Animation' : 'Play Animation'} + onPointerDown={() => { + 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 ? <FontAwesomeIcon icon={faPause as IconLookup} /> : <FontAwesomeIcon icon={faPlay as IconLookup} />} + color="black" + size={Size.MEDIUM} + /> + {this.isAnimating && this.finishedFlyTo && ( + <IconButton + tooltip="Restart animation" + onPointerDown={() => { + this.stopAnimation(false); + this.playAnimation(AnimationStatus.START); + }} + icon={<FontAwesomeIcon icon={faRotate as IconLookup} />} + color="black" + size={Size.MEDIUM} + /> + )} + <IconButton style={{ marginRight: '10px' }} tooltip="Stop and close animation" onPointerDown={() => this.stopAnimation(true)} icon={<FontAwesomeIcon icon={faCircleXmark as IconLookup} />} color="black" size={Size.MEDIUM} /> + <> + <div className="animation-suboptions"> + <div>|</div> + <FormControlLabel + className="first-person-label" + label="1st person animation:" + labelPlacement="start" + control={<Checkbox color="success" checked={this.isStreetViewAnimation} onChange={this.toggleIsStreetViewAnimation} />} + /> + <div id="divider">|</div> + <IconButton tooltip={this.animationSpeedTooltipText} onPointerDown={this.updateAnimationSpeed} icon={this.animationSpeedIcon} size={Size.MEDIUM} /> + <div id="divider">|</div> + <div style={{display: 'flex', alignItems: 'center'}}> + <div>Select Line Color: </div> + <CirclePicker circleSize={12} circleSpacing={5} width="100%" colors={['#ffff00', '#03a9f4', '#ff0000', '#ff5722', '#000000', '#673ab7']} onChange={(color:any) => this.setAnimationLineColor(color)} /> + </div> + + </div> + </> + </> + ); + }; + + @action + hideRoute = () => { + this.temporaryRouteSource = { + type: 'FeatureCollection', + features: [], + }; + }; + + @observable + settingsOpen: boolean = false; + + @observable + mapStyle: string = 'mapbox://styles/mapbox/standard'; + + @observable + showTerrain: boolean = true; + + @action + toggleSettings = () => { + if (!this.isAnimating && this.animationPhase == 0) { + this.featuresFromGeocodeResults = []; + this.settingsOpen = !this.settingsOpen; + } + }; + + @action + changeMapStyle = (e: React.ChangeEvent<HTMLSelectElement>) => { + this.dataDoc.map_style = e.target.value; + // this.mapStyle = `mapbox://styles/mapbox/${e.target.value}` + }; + + @action + onBearingChange = (e: React.ChangeEvent<HTMLInputElement>) => { + 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<HTMLInputElement>) => { + 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<HTMLInputElement>) => { + 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) { + let newZoom: number; + if (increment) { + console.log('inc'); + newZoom = Math.min(16, this.mapboxMapViewState.zoom + 1); + } else { + console.log('dec'); + newZoom = 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; + }; + static _firstRender = true; static _rerenderDelay = 500; _rerenderTimeout: any; render() { // bcz: no idea what's going on here, but bings maps have some kind of bug // such that we need to delay rendering a second map on startup until the first map is rendered. - this.rootDoc[DocCss]; + this.Document[DocCss]; if (MapBox._rerenderDelay) { // prettier-ignore this._rerenderTimeout = this._rerenderTimeout ?? @@ -712,10 +1556,12 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps MapBox._rerenderDelay = 0; } this._rerenderTimeout = undefined; - this.rootDoc[DocCss] = this.rootDoc[DocCss] + 1; + this.Document[DocCss] = this.Document[DocCss] + 1; }), MapBox._rerenderDelay); return null; } + const scale = this._props.NativeDimScaling?.() || 1; + const parscale = scale === 1 ? 1 : this._props.ScreenToLocalTransform().Scale ?? 1; const renderAnnotations = (childFilters?: () => string[]) => null; return ( @@ -726,93 +1572,188 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps onPointerDown={async e => { e.button === 0 && !e.ctrlKey && e.stopPropagation(); }} - style={{ width: `calc(100% - ${this.sidebarWidthPercent})`, pointerEvents: this.pointerEvents() }}> + style={{ transformOrigin: 'top left', transform: `scale(${scale})`, width: `calc(100% - ${this.sidebarWidthPercent})`, pointerEvents: this.pointerEvents() }}> <div style={{ mixBlendMode: 'multiply' }}>{renderAnnotations(this.transparentFilter)}</div> {renderAnnotations(this.opaqueFilter)} - {SnappingManager.GetIsDragging() ? null : renderAnnotations()} - - <div className="mapBox-searchbar"> - <EditableText - // editing - setVal={(newText: string | number) => typeof newText === 'string' && this.searchbarOnEdit(newText)} - onEnter={e => this.bingSearch()} - placeholder={this.bingSearchBarContents || 'enter city/zip/...'} - textAlign="center" - /> - <IconButton - icon={ - <svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="magnifying-glass" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" color="#DFDFDF"> - <path - fill="currentColor" - d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"></path> - </svg> - } - onClick={this.bingSearch} - type={Type.TERT} - /> - <div style={{ width: 30, height: 30 }} ref={this._dragRef} onPointerDown={this.dragToggle}> - <Button tooltip="drag to place a pushpin" icon={<FontAwesomeIcon size={'lg'} icon={'bullseye'} />} /> + {SnappingManager.IsDragging ? null : renderAnnotations()} + {!this.routeToAnimate && ( + <div className="mapBox-searchbar" style={{ width: `${100 / scale}%`, zIndex: 1, position: 'relative', background: 'lightGray' }}> + <TextField fullWidth placeholder="Enter a location" onChange={(e: any) => this.handleSearchChange(e.target.value)} /> + <IconButton icon={<FontAwesomeIcon icon={faGear as IconLookup} size="1x" />} type={Type.TERT} onClick={e => this.toggleSettings()} /> </div> - </div> + )} + {this.settingsOpen && !this.routeToAnimate && ( + <div className="mapbox-settings-panel" style={{ right: `${0 + this.sidebarWidth()}px` }}> + <div className="mapbox-style-select"> + <div>Map Style:</div> + <div> + <select onChange={this.changeMapStyle} value={StrCast(this.dataDoc.map_style)}> + <option value="mapbox://styles/mapbox/standard">Standard</option> + <option value="mapbox://styles/mapbox/streets-v11">Streets</option> + <option value="mapbox://styles/mapbox/outdoors-v12">Outdoors</option> + <option value="mapbox://styles/mapbox/light-v11">Light</option> + <option value="mapbox://styles/mapbox/dark-v11">Dark</option> + <option value="mapbox://styles/mapbox/satellite-v9">Satellite</option> + <option value="mapbox://styles/mapbox/satellite-streets-v12">Satellite Streets</option> + <option value="mapbox://styles/mapbox/navigation-day-v1">Navigation Day</option> + <option value="mapbox://styles/mapbox/navigation-night-v1">Navigation Night</option> + </select> + </div> + </div> + <div className="mapbox-bearing-selection"> + <div>Bearing: </div> + <input value={NumCast(this.mapboxMapViewState.bearing).toFixed(0)} type="number" onChange={this.onBearingChange} /> + </div> + <div className="mapbox-pitch-selection"> + <div>Pitch: </div> + <input value={NumCast(this.mapboxMapViewState.pitch).toFixed(0)} type="number" onChange={this.onPitchChange} /> + </div> + <div className="mapbox-pitch-selection"> + <div>Zoom: </div> + <input value={NumCast(this.mapboxMapViewState.zoom).toFixed(0)} type="number" onChange={this.onZoomChange} /> + </div> + <div className="mapbox-terrain-selection"> + <div>Show terrain: </div> + <input type="checkbox" checked={this.showTerrain} onChange={this.toggleShowTerrain} /> + </div> + </div> + )} + {this.routeToAnimate && ( + <div className="animation-panel" style={{ width: this.sidebarWidth() === 0 ? '100%' : `calc(100% - ${this.sidebarWidth()}px)` }}> + <div id="route-to-animate-title">{StrCast(this.routeToAnimate.title)}</div> + <div className="route-animation-options">{this.getRouteAnimationOptions()}</div> + </div> + )} + {this.featuresFromGeocodeResults.length > 0 && ( + <div className="mapbox-geocoding-search-results"> + <React.Fragment> + <h4>Choose a location for your pin: </h4> + {this.featuresFromGeocodeResults + .filter(feature => feature.place_name) + .map((feature, idx) => ( + <div + key={idx} + className="search-result-container" + onClick={() => { + this.handleSearchChange(''); + this.addMarkerForFeature(feature); + }}> + <div className="search-result-place-name">{feature.place_name}</div> + </div> + ))} + </React.Fragment> + </div> + )} + <MapProvider> + <MapboxMap + ref={this._mapRef} + mapboxAccessToken={MAPBOX_ACCESS_TOKEN} + viewState={(this.isAnimating || this.routeToAnimate) ? undefined : { ...this.mapboxMapViewState, width: NumCast(this.layoutDoc._width), height: NumCast(this.layoutDoc._height) }} + mapStyle={this.dataDoc.map_style ? StrCast(this.dataDoc.map_style) : 'mapbox://styles/mapbox/streets-v11'} + style={{ + position: 'absolute', + top: 0, + left: 0, + zIndex: '0', + width: NumCast(this.layoutDoc._width) * parscale, + height: NumCast(this.layoutDoc._height) * parscale, + }} + initialViewState={this.isAnimating ? undefined : this.mapboxMapViewState} + onZoom={this.onMapZoom} + onMove={this.onMapMove} + onClick={this.handleMapClick} + onDblClick={this.handleMapDblClick} + terrain={this.showTerrain ? { source: 'mapbox-dem', exaggeration: 2.0 } : undefined}> + <Source id="mapbox-dem" type="raster-dem" url="mapbox://mapbox.mapbox-terrain-dem-v1" tileSize={512} maxzoom={14} /> + <Source id="temporary-route" type="geojson" data={this.temporaryRouteSource} /> + <Source id="map-routes" type="geojson" data={this.allRoutesGeoJson} /> + <Layer id="temporary-route-layer" type="line" source="temporary-route" layout={{ 'line-join': 'round', 'line-cap': 'round' }} paint={{ 'line-color': '#36454F', 'line-width': 4, 'line-dasharray': [1, 1] }} /> + {!this.isAnimating && this.animationPhase == 0 && <Layer id="map-routes-layer" type="line" source="map-routes" layout={{ 'line-join': 'round', 'line-cap': 'round' }} paint={{ 'line-color': '#FF0000', 'line-width': 4 }} />} + {this.routeToAnimate && (this.isAnimating || this.animationPhase > 0) && ( + <> + {!this.isStreetViewAnimation && ( + <> + <Source id="animated-route" type="geojson" data={this.updatedRouteCoordinates} /> + <Layer + id="dynamic-animation-line" + type="line" + source="animated-route" + paint={{ + 'line-color': this.animationLineColor, + 'line-width': 5, + }} + /> + </> + )} + <Source id="start-pin-base" type="geojson" data={AnimationUtility.createGeoJSONCircle(this.selectedRouteCoordinates[0], 0.04)} /> + <Source id="start-pin-top" type="geojson" data={AnimationUtility.createGeoJSONCircle(this.selectedRouteCoordinates[0], 0.25)} /> + <Source id="end-pin-base" type="geojson" data={AnimationUtility.createGeoJSONCircle(this.selectedRouteCoordinates.slice(-1)[0], 0.04)} /> + <Source id="end-pin-top" type="geojson" data={AnimationUtility.createGeoJSONCircle(this.selectedRouteCoordinates.slice(-1)[0], 0.25)} /> + <Layer + id="start-fill-pin-base" + type="fill-extrusion" + source="start-pin-base" + paint={{ + 'fill-extrusion-color': '#0bfc03', + 'fill-extrusion-height': 1000, + }} + /> + <Layer + id="start-fill-pin-top" + type="fill-extrusion" + source="start-pin-top" + paint={{ + 'fill-extrusion-color': '#0bfc03', + 'fill-extrusion-base': 1000, + 'fill-extrusion-height': 1200, + }} + /> + <Layer + id="end-fill-pin-base" + type="fill-extrusion" + source="end-pin-base" + paint={{ + 'fill-extrusion-color': '#eb1c1c', + 'fill-extrusion-height': 1000, + }} + /> + <Layer + id="end-fill-pin-top" + type="fill-extrusion" + source="end-pin-top" + paint={{ + 'fill-extrusion-color': '#eb1c1c', + 'fill-extrusion-base': 1000, + 'fill-extrusion-height': 1200, + }} + /> + </> + )} - <BingMapsReact - onMapReady={this.bingMapReady} // - bingMapsKey={bingApiKey} - height="100%" - mapOptions={this.bingMapOptions} - width="100%" - viewOptions={this.bingViewOptions} - /> - <div> - {!this._mapReady - ? null - : this.allAnnotations - .filter(anno => !anno.layout_unrendered) - .map((pushpin, i) => ( - <DocumentView - key={i} - {...this.props} - renderDepth={this.props.renderDepth + 1} - Document={pushpin} - DataDoc={undefined} - PanelWidth={returnOne} - PanelHeight={returnOne} - NativeWidth={returnOne} - NativeHeight={returnOne} - onKey={undefined} - onDoubleClick={undefined} - onBrowseClick={undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - isDocumentActive={returnFalse} - isContentActive={returnFalse} - addDocTab={returnFalse} - ScreenToLocalTransform={Transform.Identity} - fitContentsToBox={undefined} - focus={returnOne} - /> - ))} - </div> - {/* <MapBoxInfoWindow - key={Docs.Create.MapMarkerDocument(NumCast(40), NumCast(40), false, [], {})[Id]} - {...OmitKeys(this.props, ['NativeWidth', 'NativeHeight', 'setContentView']).omit} - place={Docs.Create.MapMarkerDocument(NumCast(40), NumCast(40), false, [], {})} - markerMap={this.markerMap} - PanelWidth={this.infoWidth} - PanelHeight={this.infoHeight} - moveDocument={this.moveDocument} - isAnyChildContentActive={this.isAnyChildContentActive} - whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} - /> */} + <> + {!this.isAnimating && + this.animationPhase == 0 && + this.allPushpins + // .filter(anno => !anno.layout_unrendered) + .map((pushpin, idx) => ( + <Marker key={idx} longitude={NumCast(pushpin.longitude)} latitude={NumCast(pushpin.latitude)} anchor="bottom" onClick={(e: MarkerEvent<mapboxgl.Marker, MouseEvent>) => this.handleMarkerClick(e, pushpin)}> + {this.getMarkerIcon(pushpin)} + </Marker> + ))} + </> + + {/* {this.mapMarkers.length > 0 && this.mapMarkers.map((marker, idx) => ( + <Marker key={idx} longitude={marker.longitude} latitude={marker.latitude}/> + ))} */} + </MapboxMap> + </MapProvider> </div> - {/* </LoadScript > */} <div className="mapBox-sidebar" style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${this.sidebarColor}` }}> <SidebarAnnos ref={this._sidebarRef} - {...this.props} + {...this._props} fieldKey={this.fieldKey} - rootDoc={this.rootDoc} + Document={this.Document} layoutDoc={this.layoutDoc} dataDoc={this.dataDoc} usePanelWidth={true} |