import { Component } from 'vue-property-decorator' ; import _ from 'lodash' ; import $ from 'jquery' ; import L , { Map as LeafletMap , LeafletEvent , LeafletMouseEvent , Circle } from 'leaflet' ; import template from './map.html' ; import ColumnSelect from '@/components/column-select/column-select' ; import { Visualization , injectVisualizationTemplate , drawBrushBox , multiplyVisuals , getBrushBox , isPointInBox , } from '@/components/visualization' ; import { VisualProperties } from '@/data/visuals' ; import { SELECTED_COLOR } from '@/common/constants' ; import { isNumericalType } from '@/data/util' ; import * as history from './history' ; import { showSystemMessage } from '@/common/util' ; import { ValueType } from '@/data/parser' ; const MAP_ATTRIBUTION = `<a href="http://mapbox.com" target="_blank">© Mapbox</a> |
  <a href="http://openstreetmap.org" target="_blank">© OpenStreetMap</a> |
  <a href="https://www.mapbox.com/map-feedback/#" target="_blank">Improve this map</a>` ; const MAP_ACCESS_TOKEN = 'pk.eyJ1IjoieXVib3dlbm9rIiwiYSI6ImNqMGJlMjU0dDAzNmozMm12aHEwbjZ4MDAifQ.sh8WWNXW5eaeWSxkJNZ4TQ' ; const LEAFLET_LIGHT_URL = 'https://api.mapbox.com/styles/v1/mapbox/light-v9/tiles/256/{z}/{x}/{y}?access_token=' ; const LEAFLET_DARK_URL = 'https://api.mapbox.com/styles/v1/mapbox/dark-v9/tiles/256/{z}/{x}/{y}?access_token=' ; const LEAFLET_STREETS_URL = 'https://api.mapbox.com/styles/v1/mapbox/streets-v10/tiles/256/{z}/{x}/{y}?access_token=' ; const ZOOM_EXTENT = [ 1 , 18 ] ; const DEFAULT_ZOOM_LEVEL = 12 ; const DEFAULT_CENTER = [ 40.729 , - 73.988 ] ; const DEFAULT_ITEM_VISUALS = { color : '#333' , border : undefined , width : 1.5 , size : 50 , opacity : 1 } ; const SELECTED_ITEM_VISUALS = { color : 'white' , border : SELECTED_COLOR } ; interface MapSave { latitudeColumn : number | null ; longitudeColumn : number | null ; isNavigating : boolean ; center : [ number , number ] ; zoom : number ; } interface MapItemProps { index : number ; lat : number ; lon : number ; visuals : VisualProperties ; hasVisuals : boolean ; selected : boolean ; } @ Component ( { template : injectVisualizationTemplate ( template ) , components : { ColumnSelect } } ) export default class Map extends Visualization { protected NODE_TYPE = 'map' ; private latitudeColumn : number | null = null ; private longitudeColumn : number | null = null ; private isNavigating = true ; private zoom = DEFAULT_ZOOM_LEVEL ; private center : [ number , number ] = DEFAULT_CENTER ; private map : LeafletMap | null = null ; private itemProps : MapItemProps [ ] = [ ] ; private circles : { [ index ] : Circle ; } = { } ; private isMapCreated = false ; public onKeys ( keys ) { if ( keys === 'n' ) { this . toggleNavigating ( ) ; return true ; } return this . onKeysVisualization ( keys ) ; } public setLatitudeColumn ( column ) { if ( ! this . isValidColumn ( column ) ) { return ; } this . latitudeColumn = column ; this . draw ( ) ; } public setLongitudeColumn ( column ) { if ( ! this . isValidColumn ( column ) ) { return ; } this . longitudeColumn = column ; this . draw ( ) ; } public applyColumns ( columns ) { if ( columns . length !== 2 ) { showSystemMessage ( this . $store , 'a map visualization needs latitude and longitude columns' , 'warn' ) ; return ; } [ this . latitudeColumn , this . longitudeColumn ] = columns ; if ( this . hasDataset ( ) ) { this . draw ( ) ; } } public setNavigating ( value ) { this . isNavigating = value ; } /**
     * Finds two numerical columns, preferrably with "lat" and "lon" text in names as latitude and longitude columns.
     */ protected findDefaultColumns ( ) { if ( ! this . hasDataset ( ) ) { return ; } const dataset = this . getDataset ( ) ; dataset . getColumns ( ) . forEach ( column => { if ( isNumericalType ( column . type ) ) { if ( column . name . match ( /lat/i ) !== null ) { this . latitudeColumn = column . index ; } if ( column . name . match ( /lon/i ) !== null ) { this . longitudeColumn = column . index ; } } } ) ; } protected created ( ) { this . serializationChain . push ( ( ) : MapSave => ( { isNavigating : this . isNavigating , latitudeColumn : this . latitudeColumn , longitudeColumn : this . longitudeColumn , center : this . center , zoom : this . zoom } ) ) ; } protected draw ( ) { if ( this . latitudeColumn === null || this . longitudeColumn === null ) { this . coverText = 'Please select latitude/longitude columns' ; return ; } this . coverText = '' ; if ( ! this . isMapCreated ) { this . isMapCreated = true ; this . createMap ( ) ; } this . computeItemProps ( ) ; this . drawMap ( ) ; } protected onResize ( ) { if ( ! this . hasNoDataset ( ) && ! this . isAnimating && this . isExpanded ) { if ( this . map ) { this . map . invalidateSize ( ) ; } } } protected isDraggable ( evt , ui ? ) { if ( this . isContentVisible && this . isNavigating ) { return false ; } return this . isDraggableBase ( evt ) ; } protected isBrushable ( ) { return ! this . isNavigating ; } protected brushed ( brushPoints , isBrushStop ? ) { if ( isBrushStop ) { this . computeBrushedItems ( brushPoints ) ; this . computeSelection ( ) ; this . computeItemProps ( ) ; this . drawMap ( ) ; this . propagateSelection ( ) ; } drawBrushBox ( this . $refs . brush as SVGElement , ! isBrushStop ? brushPoints : [ ] ) ; } private computeBrushedItems ( brushPoints ) { if ( ! this . isShiftPressed || ! brushPoints . length ) { this . selection . clear ( ) ; if ( ! brushPoints . length ) { return ; } } const box = getBrushBox ( brushPoints ) ; this . itemProps . forEach ( props => { const point = ( this . map as LeafletMap ) . latLngToContainerPoint ( [ props . lat , props . lon ] ) ; if ( isPointInBox ( point , box ) ) { this . selection . addItem ( props . index ) ; } } ) ; } private computeItemProps ( ) { const dataset = this . getDataset ( ) ; const pkg = this . inputPortMap . in . getSubsetPackage ( ) ; _ . each ( this . circles , ( circle , itemIndex ) => { if ( ! pkg . hasItem ( + itemIndex ) ) { circle . removeFrom ( this . map as LeafletMap ) ; delete this . circles [ + itemIndex ] ; } } ) ; this . itemProps = pkg . getItems ( ) . map ( item => { const props = { index : item . index , lat : dataset . getCellForScale ( item . index , this . latitudeColumn as number ) as number , lon : dataset . getCellForScale ( item . index , this . longitudeColumn as number ) as number , visuals : _ . extend ( { } , DEFAULT_ITEM_VISUALS , item . visuals ) , hasVisuals : ! _ . isEmpty ( item . visuals ) , selected : this . selection . hasItem ( item . index ) } ; if ( props . selected ) { _ . extend ( props . visuals , SELECTED_ITEM_VISUALS ) ; multiplyVisuals ( props . visuals ) ; } return props ; } ) ; } private drawMap ( ) { this . drawPoints ( ) ; } private drawPoints ( ) { const map = this . map as LeafletMap ; const radiusRatio = Math . pow ( 2 , DEFAULT_ZOOM_LEVEL - map . getZoom ( ) ) ; this . itemProps . forEach ( props => { if ( ! ( props . index in this . circles ) ) { this . circles [ props . index ] = L . circle ( [ 0 , 0 ] ) . addTo ( map ) ; } const circle = this . circles [ props . index ] ; circle . setLatLng ( [ props . lat , props . lon ] ) ; circle . setRadius ( ( props . visuals . size as number ) * radiusRatio ) ; circle . setStyle ( { color : props . visuals . border , weight : props . visuals . width , fillColor : props . visuals . color , opacity : props . visuals . opacity } ) ; } ) ; } private createMap ( ) { const tileOptions = { minZoom : ZOOM_EXTENT [ 0 ] , maxZoom : ZOOM_EXTENT [ 1 ] , accessToken : MAP_ACCESS_TOKEN , attribution : MAP_ATTRIBUTION } ; const light = L . tileLayer ( LEAFLET_LIGHT_URL + MAP_ACCESS_TOKEN , tileOptions ) ; const dark = L . tileLayer ( LEAFLET_DARK_URL + MAP_ACCESS_TOKEN , tileOptions ) ; const streets = L . tileLayer ( LEAFLET_STREETS_URL + MAP_ACCESS_TOKEN , tileOptions ) ; this . map = L . map ( this . $refs . map as HTMLElement , { layers : [ light , dark , streets ] } ) . setView ( this . center , this . zoom ) ; ( this . map as any ) . attributionControl . setPrefix ( '' ) ; const baseMaps = { gray : light , dark , color : streets } ; L . control . layers ( baseMaps ) . addTo ( this . map ) ; $ ( this . $refs . map ) . find ( '.leaflet-control-layers-base input' ) . first ( ) . click ( ) ; this . map . on ( 'mousedown' , ( evt ) => this . onMapMousedown ( ( evt as LeafletMouseEvent ) . originalEvent ) ) . on ( 'mouseup' , ( evt ) => this . onMapMouseup ( ( evt as LeafletMouseEvent ) . originalEvent ) ) . on ( 'zoomend' , ( ) => { this . drawMap ( ) ; this . zoom = ( this . map as LeafletMap ) . getZoom ( ) ; const center = ( this . map as LeafletMap ) . getCenter ( ) ; this . center = [ center . lat , center . lng ] ; } ) . on ( 'zoomlevelchange' , this . drawMap ) ; this . $nextTick ( ( ) => ( this . map as LeafletMap ) . invalidateSize ( ) ) ; } private onMapMousedown ( evt ) { if ( ! this . isNavigating && this . map ) { this . map . dragging . disable ( ) ; this . map . scrollWheelZoom . disable ( ) ; this . map . boxZoom . disable ( ) ; } } private onMapMouseup ( evt ) { if ( ! this . isNavigating && this . map ) { this . map . dragging . enable ( ) ; this . map . scrollWheelZoom . enable ( ) ; this . map . boxZoom . enable ( ) ; } } private toggleNavigating ( ) { this . isNavigating = ! this . isNavigating ; } private onSelectLatitudeColumn ( column , prevColumn ) { this . commitHistory ( history . selectLatitudeColumnEvent ( this , column , prevColumn ) ) ; this . setLatitudeColumn ( column ) ; } private onSelectLongitudeColumn ( column , prevColumn ) { this . commitHistory ( history . selectLongitudeColumnEvent ( this , column , prevColumn ) ) ; this . setLongitudeColumn ( column ) ; } private onToggleNavigating ( value ) { this . commitHistory ( history . toggleNavigatingEvent ( this , value ) ) ; } private isValidColumn ( column ) { if ( ! this . dataset ) { return true ; } const columnType = this . dataset . getColumnType ( column ) ; if ( ! isNumericalType ( columnType ) ) { showSystemMessage ( this . $store , `column ${ this . dataset . getColumnName ( column ) } is not a numerical column` , 'warn' ) ; return false ; } return true ; } }