import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Autocomplete, GoogleMap, GoogleMapProps, Marker } from '@react-google-maps/api'; import { action, computed, IReactionDisposer, observable, ObservableMap } from 'mobx'; import { observer } from "mobx-react"; import * as React from "react"; import { Doc, DocListCast, Opt, WidthSym } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; import { InkTool } from '../../../../fields/InkField'; import { NumCast, StrCast } from '../../../../fields/Types'; import { TraceMobx } from '../../../../fields/util'; import { emptyFunction, OmitKeys, returnFalse, returnOne, setupMoveUpEvents, Utils } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; import { CurrentUserUtils } from '../../../util/CurrentUserUtils'; import { DragManager } from '../../../util/DragManager'; import { SnappingManager } from '../../../util/SnappingManager'; import { MarqueeOptionsMenu } from '../../collections/collectionFreeForm'; import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from '../../DocComponent'; import { Colors } from '../../global/globalEnums'; import { MarqueeAnnotator } from '../../MarqueeAnnotator'; import { AnchorMenu } from '../../pdf/AnchorMenu'; import { Annotation } from '../../pdf/Annotation'; import { SidebarAnnos } from '../../SidebarAnnos'; import { StyleProp } from '../../StyleProvider'; import { FieldView, FieldViewProps } from '../FieldView'; import "./MapBox.scss"; import { MapBoxInfoWindow } from './MapBoxInfoWindow'; /** * 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 _global = (window /* browser */ || global /* node */) as any; const mapContainerStyle = { height: '100%', }; const defaultCenter = { lat: 38.685, lng: -115.234, }; const mapOptions = { fullscreenControl: false, }; const apiKey = process.env.GOOGLE_MAPS; const script = document.createElement('script'); script.defer = true; script.async = true; script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places,drawing`; document.head.appendChild(script); /** * Consider integrating later: allows for drawing, circling, making shapes on map */ // const drawingManager = new window.google.maps.drawing.DrawingManager({ // drawingControl: true, // drawingControlOptions: { // position: google.maps.ControlPosition.TOP_RIGHT, // drawingModes: [ // google.maps.drawing.OverlayType.MARKER, // // currently we are not supporting the following drawing mode on map, a thought for future development // google.maps.drawing.OverlayType.CIRCLE, // google.maps.drawing.OverlayType.POLYLINE, // ], // }, // }); // options for searchbox in Google Maps Places Autocomplete API const options = { fields: ["formatted_address", "geometry", "name"], // note: level of details is charged by item per retrieval, not recommended to return all fields strictBounds: false, types: ["establishment"], // type pf places, subject of change according to user need } as google.maps.places.AutocompleteOptions; @observer export class MapBox extends ViewBoxAnnotatableComponent>() { private _dropDisposer?: DragManager.DragDropDisposer; private _disposers: { [name: string]: IReactionDisposer } = {}; private _annotationLayer: React.RefObject = React.createRef(); @observable private _overlayAnnoInfo: Opt; showInfo = action((anno: Opt) => this._overlayAnnoInfo = anno); public static LayoutString(fieldKey: string) { return FieldView.LayoutString(MapBox, fieldKey); } public get SidebarKey() { return this.fieldKey + "-sidebar"; } private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean, hide: boolean) => void); @computed get inlineTextAnnotations() { return this.allMapMarkers.filter(a => a.textInlineAnnotations); } @observable private _map: google.maps.Map = null as unknown as google.maps.Map; @observable private selectedPlace: Doc | undefined; @observable private markerMap: { [id: string]: google.maps.Marker } = {}; @observable private center = navigator.geolocation ? navigator.geolocation.getCurrentPosition : defaultCenter; @observable private _marqueeing: number[] | undefined; @observable private _isAnnotating = false; @observable private inputRef = React.createRef(); @observable private searchMarkers: google.maps.Marker[] = []; @observable private searchBox = new window.google.maps.places.Autocomplete(this.inputRef.current!, options); @observable private _savedAnnotations = new ObservableMap(); @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); } @computed get allMapMarkers() { return DocListCast(this.dataDoc[this.annotationKey]); } @observable private toggleAddMarker = false; private _mainCont: React.RefObject = React.createRef(); @observable _showSidebar = false; @computed get SidebarShown() { return this._showSidebar || this.layoutDoc._showSidebar ? true : false; } static _canAnnotate = true; static _hadSelection: boolean = false; private _sidebarRef = React.createRef(); private _ref: React.RefObject = React.createRef(); constructor(props: any) { super(props); } @action private setSearchBox = (searchBox: any) => { this.searchBox = searchBox; } // iterate allMarkers to size, center, and zoom map to contain all markers private fitBounds = (map: google.maps.Map) => { const curBounds = map.getBounds() ?? new window.google.maps.LatLngBounds(); const isFitting = this.allMapMarkers.reduce((fits, place) => fits && curBounds?.contains({ lat: NumCast(place.lat), lng: NumCast(place.lng) }), true as boolean); !isFitting && map.fitBounds(this.allMapMarkers.reduce((bounds, place) => bounds.extend({ lat: NumCast(place.lat), lng: NumCast(place.lng) }), new window.google.maps.LatLngBounds())); } /** * Custom control for add marker button * @param controlDiv * @param map */ private CenterControl = () => { const controlDiv = document.createElement("div"); controlDiv.className = "mapBox-addMarker"; // Set CSS for the control border. const controlUI = document.createElement("div"); controlUI.style.backgroundColor = "#fff"; controlUI.style.borderRadius = "3px"; controlUI.style.cursor = "pointer"; controlUI.style.marginTop = "10px"; controlUI.style.borderRadius = "4px"; controlUI.style.marginBottom = "22px"; controlUI.style.textAlign = "center"; controlUI.style.position = "absolute"; controlUI.style.width = "32px"; controlUI.style.height = "32px"; controlUI.title = "Click to toggle marker mode. In marker mode, click on map to place a marker."; const plIcon = document.createElement("img"); plIcon.src = "https://cdn4.iconfinder.com/data/icons/wirecons-free-vector-icons/32/add-256.png"; plIcon.style.color = "rgb(25,25,25)"; plIcon.style.fontFamily = "Roboto,Arial,sans-serif"; plIcon.style.fontSize = "16px"; plIcon.style.lineHeight = "32px"; plIcon.style.left = "18"; plIcon.style.top = "15"; plIcon.style.position = "absolute"; plIcon.width = 14; plIcon.height = 14; plIcon.innerHTML = "Add"; controlUI.appendChild(plIcon); // Set CSS for the control interior. const markerIcon = document.createElement("img"); markerIcon.src = "https://cdn0.iconfinder.com/data/icons/small-n-flat/24/678111-map-marker-1024.png"; markerIcon.style.color = "rgb(25,25,25)"; markerIcon.style.fontFamily = "Roboto,Arial,sans-serif"; markerIcon.style.fontSize = "16px"; markerIcon.style.lineHeight = "32px"; markerIcon.style.left = "-2"; markerIcon.style.top = "1"; markerIcon.width = 30; markerIcon.height = 30; markerIcon.style.position = "absolute"; markerIcon.innerHTML = "Add"; controlUI.appendChild(markerIcon); // Setup the click event listeners controlUI.addEventListener("click", () => { if (this.toggleAddMarker === true) { this.toggleAddMarker = false; console.log("add marker button status:" + this.toggleAddMarker); controlUI.style.backgroundColor = "#fff"; markerIcon.style.color = "rgb(25,25,25)"; } else { this.toggleAddMarker = true; console.log("add marker button status:" + this.toggleAddMarker); controlUI.style.backgroundColor = "#4476f7"; markerIcon.style.color = "rgb(255,255,255)"; } }); controlDiv.appendChild(controlUI); return controlDiv; } /** * Place the marker on google maps & store the empty marker as a MapMarker Document in allMarkers list * @param position - the LatLng position where the marker is placed * @param map */ @action private placeMarker = (position: google.maps.LatLng, map: google.maps.Map) => { const marker = new google.maps.Marker({ position: position, map: map }); map.panTo(position); const mapMarker = Docs.Create.MapMarkerDocument(NumCast(position.lat()), NumCast(position.lng()), false, [], {}); this.addDocument(mapMarker, this.annotationKey); } _loadPending = true; /** * store a reference to google map instance * setup the drawing manager on the top right corner of map * fit map bounds to contain all markers * @param map */ @action private loadHandler = (map: google.maps.Map) => { this._map = map; this._loadPending = true; const centerControlDiv = this.CenterControl(); map.controls[google.maps.ControlPosition.TOP_RIGHT].push(centerControlDiv); //drawingManager.setMap(map); // if (navigator.geolocation) { // navigator.geolocation.getCurrentPosition( // (position: Position) => { // const pos = { // lat: position.coords.latitude, // lng: position.coords.longitude, // }; // this._map.setCenter(pos); // } // ); // } else { // alert("Your geolocation is not supported by browser.") // }; map.setZoom(NumCast(this.dataDoc.mapZoom, 2.5)); map.setCenter(new google.maps.LatLng(NumCast(this.dataDoc.mapLat), NumCast(this.dataDoc.mapLng))); setTimeout(() => { if (this._loadPending && this._map.getBounds()) { this._loadPending = false; this.layoutDoc.fitToBox && this.fitBounds(this._map); } }, 250); // listener to addmarker event this._map.addListener('click', (e: MouseEvent) => { if (this.toggleAddMarker === true) { this.placeMarker((e as any).latLng, map); } }); } @action centered = () => { if (this._loadPending && this._map.getBounds()) { this._loadPending = false; this.layoutDoc.fitToBox && this.fitBounds(this._map); } this.dataDoc.mapLat = this._map.getCenter()?.lat(); this.dataDoc.mapLng = this._map.getCenter()?.lng(); } @action zoomChanged = () => { if (this._loadPending && this._map.getBounds()) { this._loadPending = false; this.layoutDoc.fitToBox && this.fitBounds(this._map); } this.dataDoc.mapZoom = this._map.getZoom(); } /** * Load and render all map markers * @param marker * @param place */ @action private markerLoadHandler = (marker: google.maps.Marker, place: Doc) => { place[Id] ? this.markerMap[place[Id]] = marker : null; } /** * on clicking the map marker, set the selected place to the marker document & set infowindowopen to be true * @param e * @param place */ @action private markerClickHandler = (e: google.maps.MapMouseEvent, place: Doc) => { // set which place was clicked this.selectedPlace = place; place.infoWindowOpen = true; } /** * Called when dragging documents into map sidebar or directly into infowindow; to create a map marker, ref to MapMarkerDocument in Documents.ts * @param doc * @param sidebarKey * @returns */ sidebarAddDocument = (doc: Doc | Doc[], sidebarKey?: string) => { console.log("print all sidebar Docs"); console.log(this.allSidebarDocs); if (!this.layoutDoc._showSidebar) this.toggleSidebar(); const docs = doc instanceof Doc ? [doc] : doc; docs.forEach(doc => { if (doc.lat !== undefined && doc.lng !== undefined) { const existingMarker = this.allMapMarkers.find(marker => marker.lat === doc.lat && marker.lng === doc.lng); doc.onClickBehavior = "enterPortal"; if (existingMarker) { Doc.AddDocToList(existingMarker, "data", doc); } else { const marker = Docs.Create.MapMarkerDocument(NumCast(doc.lat), NumCast(doc.lng), false, [doc], {}); this.addDocument(marker, this.annotationKey); } } }); //add to annotation list console.log("sidebaraddDocument"); console.log(doc); return this.addDocument(doc, sidebarKey); // add to sidebar list } /** * Removing documents from the sidebar * @param doc * @param sidebarKey * @returns */ sidebarRemoveDocument = (doc: Doc | Doc[], sidebarKey?: string) => { if (this.layoutDoc._showSidebar) this.toggleSidebar(); const docs = doc instanceof Doc ? [doc] : doc; docs.forEach(doc => { console.log(this.allMapMarkers); console.log(this.allSidebarDocs); }); return this.removeDocument(doc, sidebarKey); } /** * Toggle sidebar onclick the tiny comment button on the top right corner * @param e */ sidebarBtnDown = (e: React.PointerEvent) => { setupMoveUpEvents(this, e, (e, down, delta) => { const localDelta = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformDirection(delta[0], delta[1]); const nativeWidth = NumCast(this.layoutDoc[this.fieldKey + "-nativeWidth"]); const curNativeWidth = NumCast(this.layoutDoc.nativeWidth, nativeWidth); const ratio = (curNativeWidth + localDelta[0] / (this.props.scaling?.() || 1)) / nativeWidth; if (ratio >= 1) { this.layoutDoc.nativeWidth = nativeWidth * ratio; this.layoutDoc._width = this.layoutDoc[WidthSym]() + localDelta[0]; this.layoutDoc._showSidebar = nativeWidth !== this.layoutDoc._nativeWidth; } return false; }, emptyFunction, this.toggleSidebar); } sidebarWidth = () => Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100 * this.props.PanelWidth(); @computed get sidebarWidthPercent() { return StrCast(this.layoutDoc._sidebarWidthPercent, "0%"); } @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebarColor, StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], "#e4e4e4")); } /** * function that reads the place inputed from searchbox, then zoom in on the location that's been autocompleted; * add a customized temporary marker on the map */ @action private handlePlaceChanged = () => { console.log(this.searchBox); const place = this.searchBox.getPlace(); if (!place.geometry || !place.geometry.location) { // user entered the name of a place that wasn't suggested & pressed the enter key, or place details request failed window.alert("No details available for input: '" + place.name + "'"); return; } // zoom in on the location of the search result if (place.geometry.viewport) { console.log(this._map); this._map.fitBounds(place.geometry.viewport); } else { this._map.setCenter(place.geometry.location); this._map.setZoom(17); } // customize icon => customized icon for the nature of the location selected const icon = { url: place.icon as string, size: new google.maps.Size(71, 71), origin: new google.maps.Point(0, 0), anchor: new google.maps.Point(17, 34), scaledSize: new google.maps.Size(25, 25), }; // put temporary cutomized marker on searched location this.searchMarkers.forEach((marker) => { marker.setMap(null); }); this.searchMarkers = []; this.searchMarkers.push( new window.google.maps.Marker({ map: this._map, icon, title: place.name, position: place.geometry.location, }) ); } /** * Handles toggle of sidebar on click the little comment button */ @computed get sidebarHandle() { TraceMobx(); const annotated = DocListCast(this.dataDoc[this.SidebarKey]).filter(d => d?.author).length; const color = !annotated ? Colors.WHITE : Colors.BLACK; const backgroundColor = !annotated ? this.sidebarWidth() ? Colors.MEDIUM_BLUE : Colors.BLACK : this.props.styleProvider?.(this.rootDoc, this.props as any, StyleProp.WidgetColor + (annotated ? ":annotated" : "")); return (!annotated) ? (null) :
; } // TODO: Adding highlight box layer to Maps @action toggleSidebar = () => { const prevWidth = this.sidebarWidth(); this.layoutDoc._showSidebar = ((this.layoutDoc._sidebarWidthPercent = StrCast(this.layoutDoc._sidebarWidthPercent, "0%") === "0%" ? "50%" : "0%")) !== "0%"; this.layoutDoc._width = this.layoutDoc._showSidebar ? NumCast(this.layoutDoc._width) * 2 : Math.max(20, NumCast(this.layoutDoc._width) - prevWidth); } sidebarDown = (e: React.PointerEvent) => { setupMoveUpEvents(this, e, this.sidebarMove, emptyFunction, () => setTimeout(this.toggleSidebar), false); } sidebarMove = (e: PointerEvent, down: number[], delta: number[]) => { const bounds = this._ref.current!.getBoundingClientRect(); this.layoutDoc._sidebarWidthPercent = "" + 100 * Math.max(0, (1 - (e.clientX - bounds.left) / bounds.width)) + "%"; this.layoutDoc._showSidebar = this.layoutDoc._sidebarWidthPercent !== "0%"; e.preventDefault(); return false; } setPreviewCursor = (func?: (x: number, y: number, drag: boolean, hide: boolean) => void) => this._setPreviewCursor = func; @action onMarqueeDown = (e: React.PointerEvent) => { if (!e.altKey && e.button === 0 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen, InkTool.Write].includes(CurrentUserUtils.SelectedTool)) { setupMoveUpEvents(this, e, action(e => { MarqueeAnnotator.clearAnnotations(this._savedAnnotations); this._marqueeing = [e.clientX, e.clientY]; return true; }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false); } } @action finishMarquee = (x?: number, y?: number) => { this._marqueeing = undefined; this._isAnnotating = false; x !== undefined && y !== undefined && this._setPreviewCursor?.(x, y, false, false); } addDocumentWrapper = (doc: Doc | Doc[], annotationKey?: string) => { return this.addDocument(doc, annotationKey); } pointerEvents = () => { return this.props.isContentActive() && this.props.pointerEvents?.() !== "none" && !MarqueeOptionsMenu.Instance.isShown() ? "all" : SnappingManager.GetIsDragging() ? undefined : "none"; } @computed get annotationLayer() { return
{this.inlineTextAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y)).map(anno => )}
; } getAnchor = () => { const anchor = AnchorMenu.Instance?.GetAnchor(this._savedAnnotations) ?? this.rootDoc; return anchor; } /** * render contents in allMapMarkers (e.g. images with exifData) into google maps as map marker * @returns */ private renderMarkers = () => { return this.allMapMarkers.map(place => ( this.markerLoadHandler(marker, place)} onClick={(e: google.maps.MapMouseEvent) => this.markerClickHandler(e, place)} /> )); } // TODO: auto center on select a document in the sidebar private handleMapCenter = (map: google.maps.Map) => { // console.log("print the selected views in selectionManager:") // if (SelectionManager.Views().lastElement()) { // console.log(SelectionManager.Views().lastElement()); // } } panelWidth = () => this.props.PanelWidth() / (this.props.scaling?.() || 1) - this.sidebarWidth(); // (this.Document.scrollHeight || Doc.NativeHeight(this.Document) || 0); panelHeight = () => this.props.PanelHeight() / (this.props.scaling?.() || 1); // () => this._pageSizes.length && this._pageSizes[0] ? this._pageSizes[0].width : Doc.NativeWidth(this.Document); scrollXf = () => this.props.ScreenToLocalTransform().translate(0, NumCast(this.layoutDoc._scrollTop)); transparentFilter = () => [...this.props.docFilters(), Utils.IsTransparentFilter()]; opaqueFilter = () => [...this.props.docFilters(), Utils.IsOpaqueFilter()]; infoWidth = () => this.props.PanelWidth() / 5; infoHeight = () => this.props.PanelHeight() / 5; anchorMenuClick = () => this._sidebarRef.current?.anchorMenuClick; savedAnnotations = () => this._savedAnnotations; render() { const renderAnnotations = (docFilters?: () => string[]) => (null); // bcz: commmented this out. Otherwise, any documents that are rendered with an InfoWindow of a marker // will also be rendered as freeform annotations on the map. However, it doesn't seem that rendering // freeform documents on the map does anything anyway, so getting rid of it for now. Also, since documents // are rendered twice, adding a new note to the InfoWindow loses focus immediately on creation since it gets // shifted to the non-visible view of the document in this freeform view. // ; return
{/*console.log(apiKey)*/} {/* */}
e.stopPropagation()} onPointerDown={e => (e.button === 0 && !e.ctrlKey) && e.stopPropagation()} style={{ width: `calc(100% - ${this.sidebarWidthPercent})` }}>
{renderAnnotations(this.transparentFilter)}
{renderAnnotations(this.opaqueFilter)} {SnappingManager.GetIsDragging() ? (null) : renderAnnotations()} {this.annotationLayer} {this.renderMarkers()} {this.allMapMarkers.filter(marker => marker.infoWindowOpen).map(marker => )} {this.handleMapCenter(this._map)} {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? (null) : }
{/*
*/}
; } }