'use strict' ; import type AnimationOptions from '../Core/Animation/AnimationOptions' ; import type BBoxObject from '../Core/Renderer/BBoxObject' ; import type { GeoJSON , Polygon , TopoJSON } from './GeoJSON' ; import type PositionObject from '../Core/Renderer/PositionObject' ; import type ProjectionOptions from './ProjectionOptions' ; import type { LonLatArray , MapBounds , MapViewInsetsOptions , MapViewOptions , MapViewPaddingType , ProjectedXY } from './MapViewOptions' ; import type SVGElement from '../Core/Renderer/SVG/SVGElement' ; import type SVGPath from '../Core/Renderer/SVG/SVGPath' ; import Chart from '../Core/Chart/Chart.js' ; import defaultOptions from './MapViewOptionsDefault.js' ; import defaultInsetsOptions from './MapViewInsetsOptionsDefault.js' ; import GeoJSONModule from '../Extensions/GeoJSON.js' ; const { topo2geo } = GeoJSONModule ; import MapChart from '../Core/Chart/MapChart.js' ; const { maps } = MapChart ; import MU from './MapUtilities.js' ; const { boundsFromPath , pointInPolygon } = MU ; import Projection from './Projection.js' ; import U from '../Core/Utilities.js' ; const { addEvent , clamp , fireEvent , isArray , isNumber , isObject , isString , merge , pick , relativeLength } = U ; type SVGTransformType = { scaleX : number ; scaleY : number ; translateX : number ; translateY : number ; } ; /**
 * The world size in terms of 10k meters in the Web Mercator projection, to
 * match a 256 square tile to zoom level 0
 */ const worldSize = 400.979322 ; const tileSize = 256 ; class name param { Highcharts . Chart } chart param { Highcharts . MapViewOptions } options class MapView { public center : LonLatArray ; public geoMap ? : GeoJSON ; public group ? : SVGElement ; public insets : MapViewInset [ ] = [ ] ; public minZoom ? : number ; public options : MapViewOptions ; public padding : [ number , number , number , number ] = [ 0 , 0 , 0 , 0 ] ; public playingField : BBoxObject ; public projection : Projection ; public userOptions : DeepPartial < MapViewOptions > ; public zoom : number ; public chart : Chart ; public static compositeBounds = ( arrayOfBounds ) : MapBounds | undefined => { if ( arrayOfBounds . length ) { return arrayOfBounds . slice ( 1 ) . reduce ( ( acc , cur ) : MapBounds => { acc . x1 = Math . min ( acc . x1 , cur . x1 ) ; acc . y1 = Math . min ( acc . y1 , cur . y1 ) ; acc . x2 = Math . max ( acc . x2 , cur . x2 ) ; acc . y2 = Math . max ( acc . y2 , cur . y2 ) ; return acc ; } , merge ( arrayOfBounds [ 0 ] ) ) ; } return ; } ; public constructor ( chart , options ? ) { let recommendedMapView ; let recommendedProjection ; if ( ! ( this instanceof MapViewInset ) ) { const geoMap = this . getGeoMap ( chart . options . chart . map ) ; if ( geoMap ) { recommendedMapView = geoMap [ 'hc-recommended-mapview' ] ; if ( geoMap . bbox ) { const [ x1 , y1 , x2 , y2 ] = geoMap . bbox ; if ( x2 - x1 > 180 && y2 - y1 > 90 ) { recommendedProjection = { name : 'EqualEarth' } ; } else { recommendedProjection = { name : 'LambertConformalConic' , parallels : [ y1 , y2 ] , rotation : [ - ( x1 + x2 ) / 2 ] } ; } } } this . geoMap = geoMap ; } this . userOptions = options || { } ; const o = merge ( defaultOptions , { projection : recommendedProjection } , recommendedMapView , options ) ; if ( recommendedMapView && recommendedMapView . insets && options && options . insets ) { type DeepInsetOptions = DeepPartial < MapViewInsetsOptions | undefined > ; const toObject = ( insets ) : Record < string , DeepInsetOptions > => { const ob = { } as Record < string , DeepInsetOptions > ; insets . forEach ( ( inset , i ) : void => { ob [ inset && inset . id || `i${ i }` ] = inset ; } ) ; return ob ; } ; const insetsObj = merge ( toObject ( recommendedMapView . insets ) , toObject ( options . insets ) ) , insets = Object . keys ( insetsObj ) . map ( ( key ) : DeepInsetOptions => insetsObj [ key ] ) ; ( o as any ) . insets = insets ; } this . chart = chart ; name readonly type { LonLatArray } this . center = o . center ; this . options = o ; this . projection = new Projection ( o . projection ) ; this . playingField = chart . plotBox ; name readonly type { number } this . zoom = o . zoom || 0 ; const insets = o . insets ; if ( insets ) { insets . forEach ( ( item ) : void => { const inset = new MapViewInset ( this , merge ( o . insetOptions , item ) ) ; this . insets . push ( inset ) ; } ) ; } addEvent ( chart , 'afterSetChartSize' , ( ) : void => { this . playingField = this . getField ( ) ; if ( this . minZoom === void 0 || this . minZoom === this . zoom ) { this . fitToBounds ( void 0 , void 0 , false ) ; if ( isNumber ( this . userOptions . zoom ) ) { this . zoom = this . userOptions . zoom ; } if ( this . userOptions . center ) { merge ( true , this . center , this . userOptions . center ) ; } } } ) ; this . setUpEvents ( ) ; } function param { Object } bounds param { number | string } padding param { boolean } redraw param { boolean | Partial < Highcharts . AnimationOptionsObject > } animation public fitToBounds ( bounds ? , padding ? , redraw = true , animation ? ) { const b = bounds || this . getProjectedBounds ( ) ; if ( b ) { const pad = pick ( padding , bounds ? 0 : this . options . padding ) , fullField = this . getField ( false ) , padArr = isArray ( pad ) ? pad : [ pad , pad , pad , pad ] ; this . padding = [ relativeLength ( padArr [ 0 ] , fullField . height ) , relativeLength ( padArr [ 1 ] , fullField . width ) , relativeLength ( padArr [ 2 ] , fullField . height ) , relativeLength ( padArr [ 3 ] , fullField . width ) ] ; this . playingField = this . getField ( ) ; const { width , height } = this . playingField ; const scaleToPlotArea = Math . max ( ( b . x2 - b . x1 ) / ( width / tileSize ) , ( b . y2 - b . y1 ) / ( height / tileSize ) ) ; const zoom = Math . log ( worldSize / scaleToPlotArea ) / Math . log ( 2 ) ; if ( ! bounds ) { this . minZoom = zoom ; } const center = this . projection . inverse ( [ ( b . x2 + b . x1 ) / 2 , ( b . y2 + b . y1 ) / 2 ] ) ; this . setView ( center , zoom , redraw , animation ) ; } } public getField ( padded = true ) { const padding = padded ? this . padding : [ 0 , 0 , 0 , 0 ] ; return { x : padding [ 3 ] , y : padding [ 0 ] , width : this . chart . plotWidth - padding [ 1 ] - padding [ 3 ] , height : this . chart . plotHeight - padding [ 0 ] - padding [ 2 ] } ; } public getGeoMap ( map ? ) { if ( isString ( map ) ) { return maps [ map ] ; } if ( isObject ( map ) ) { if ( map . type === 'FeatureCollection' ) { return map ; } if ( map . type === 'Topology' ) { return topo2geo ( map ) ; } } } public getMapBBox ( ) { const bounds = this . getProjectedBounds ( ) , scale = this . getScale ( ) ; if ( bounds ) { const padding = this . padding , p1 = this . projectedUnitsToPixels ( { x : bounds . x1 , y : bounds . y2 } ) , width = ( ( bounds . x2 - bounds . x1 ) * scale + padding [ 1 ] + padding [ 3 ] ) , height = ( ( bounds . y2 - bounds . y1 ) * scale + padding [ 0 ] + padding [ 2 ] ) ; return { width , height , x : p1 . x - padding [ 3 ] , y : p1 . y - padding [ 0 ] } ; } } public getProjectedBounds ( ) { const allBounds = this . chart . series . reduce ( ( acc , s ) : MapBounds [ ] => { const bounds = s . getProjectedBounds && s . getProjectedBounds ( ) ; if ( bounds ) { acc . push ( bounds ) ; } return acc ; } , [ ] as MapBounds [ ] ) ; return this . projection . bounds || MapView . compositeBounds ( allBounds ) ; } public getScale ( ) { return ( tileSize / worldSize ) * Math . pow ( 2 , this . zoom ) ; } public getSVGTransform ( ) { const { x , y , width , height } = this . playingField , projectedCenter = this . projection . forward ( this . center ) , flipFactor = this . projection . hasCoordinates ? - 1 : 1 , scaleX = this . getScale ( ) , scaleY = scaleX * flipFactor , translateX = x + width / 2 - projectedCenter [ 0 ] * scaleX , translateY = y + height / 2 - projectedCenter [ 1 ] * scaleY ; return { scaleX , scaleY , translateX , translateY } ; } public redraw ( animation ? ) { this . chart . series . forEach ( ( s ) : void => { if ( s . useMapGeometry ) { s . isDirty = true ; } } ) ; this . chart . redraw ( animation ) ; } function param { Highcharts . LonLatArray | undefined } center param { number } zoom param { boolean } redraw param { boolean | Partial < Highcharts . AnimationOptionsObject > } animation sample public setView ( center ? , zoom ? , redraw = true , animation ? ) { if ( center ) { this . center = center ; } if ( typeof zoom === 'number' ) { if ( typeof this . minZoom === 'number' ) { zoom = Math . max ( zoom , this . minZoom ) ; } if ( typeof this . options . maxZoom === 'number' ) { zoom = Math . min ( zoom , this . options . maxZoom ) ; } this . zoom = zoom ; } const bounds = this . getProjectedBounds ( ) ; if ( bounds ) { const projectedCenter = this . projection . forward ( this . center ) , { x , y , width , height } = this . playingField , scale = this . getScale ( ) , bottomLeft = this . projectedUnitsToPixels ( { x : bounds . x1 , y : bounds . y1 } ) , topRight = this . projectedUnitsToPixels ( { x : bounds . x2 , y : bounds . y2 } ) , boundsCenterProjected = [ ( bounds . x1 + bounds . x2 ) / 2 , ( bounds . y1 + bounds . y2 ) / 2 ] ; const x1 = bottomLeft . x , y1 = topRight . y , x2 = topRight . x , y2 = bottomLeft . y ; if ( x2 - x1 < width ) { projectedCenter [ 0 ] = boundsCenterProjected [ 0 ] ; } else if ( x1 < x && x2 < x + width ) { projectedCenter [ 0 ] += Math . max ( x1 - x , x2 - width - x ) / scale ; } else if ( x2 > x + width && x1 > x ) { projectedCenter [ 0 ] += Math . min ( x2 - width - x , x1 - x ) / scale ; } if ( y2 - y1 < height ) { projectedCenter [ 1 ] = boundsCenterProjected [ 1 ] ; } else if ( y1 < y && y2 < y + height ) { projectedCenter [ 1 ] -= Math . max ( y1 - y , y2 - height - y ) / scale ; } else if ( y2 > y + height && y1 > y ) { projectedCenter [ 1 ] -= Math . min ( y2 - height - y , y1 - y ) / scale ; } this . center = this . projection . inverse ( projectedCenter ) ; this . insets . forEach ( ( inset ) : void => { if ( inset . options . field ) { inset . hitZone = inset . getHitZone ( ) ; inset . playingField = inset . getField ( ) ; } } ) ; this . render ( ) ; } fireEvent ( this , 'afterSetView' ) ; if ( redraw ) { this . redraw ( animation ) ; } } function param { Highcharts . PositionObject } pos return { Highcharts . PositionObject } public projectedUnitsToPixels ( pos ) { const scale = this . getScale ( ) , projectedCenter = this . projection . forward ( this . center ) , field = this . playingField , centerPxX = field . x + field . width / 2 , centerPxY = field . y + field . height / 2 ; const x = centerPxX - scale * ( projectedCenter [ 0 ] - pos . x ) ; const y = centerPxY + scale * ( projectedCenter [ 1 ] - pos . y ) ; return { x , y } ; } function param { Highcharts . PositionObject } pos return { Highcharts . PositionObject } public pixelsToProjectedUnits ( pos ) { const { x , y } = pos , scale = this . getScale ( ) , projectedCenter = this . projection . forward ( this . center ) , field = this . playingField , centerPxX = field . x + field . width / 2 , centerPxY = field . y + field . height / 2 ; const projectedX = projectedCenter [ 0 ] + ( x - centerPxX ) / scale ; const projectedY = projectedCenter [ 1 ] - ( y - centerPxY ) / scale ; return { x : projectedX , y : projectedY } ; } public setUpEvents ( ) { const chart = this . chart ; let mouseDownCenterProjected ; let mouseDownKey ; let mouseDownRotation ; const onPan = ( e ) : void => { const pinchDown = chart . pointer . pinchDown ; let { mouseDownX , mouseDownY } = chart ; if ( pinchDown . length === 1 ) { mouseDownX = pinchDown [ 0 ] . chartX ; mouseDownY = pinchDown [ 0 ] . chartY ; } if ( typeof mouseDownX === 'number' && typeof mouseDownY === 'number' ) { const key = `${ mouseDownX },${ mouseDownY }` , { chartX , chartY } = ( e as any ) . originalEvent ; if ( key !== mouseDownKey ) { mouseDownKey = key ; mouseDownCenterProjected = this . projection . forward ( this . center ) ; mouseDownRotation = ( this . projection . options . rotation || [ 0 , 0 ] ) . slice ( ) ; } if ( this . projection . options . name === 'Orthographic' && ( this . minZoom || Infinity ) < 3 ) { const ratio = 440 / ( this . getScale ( ) * Math . min ( chart . plotWidth , chart . plotHeight ) ) ; if ( mouseDownRotation ) { const lon = ( mouseDownX - chartX ) * ratio - mouseDownRotation [ 0 ] ; const lat = clamp ( - mouseDownRotation [ 1 ] - ( mouseDownY - chartY ) * ratio , - 80 , 80 ) ; this . update ( { projection : { rotation : [ - lon , - lat ] } , zoom : this . zoom } , true , false ) ; } } else { const scale = this . getScale ( ) ; const newCenter = this . projection . inverse ( [ mouseDownCenterProjected [ 0 ] + ( mouseDownX - chartX ) / scale , mouseDownCenterProjected [ 1 ] - ( mouseDownY - chartY ) / scale ] ) ; this . setView ( newCenter , void 0 , true , false ) ; } e . preventDefault ( ) ; } } ; addEvent ( chart , 'pan' , onPan ) ; addEvent ( chart , 'touchpan' , onPan ) ; addEvent ( chart , 'selection' , ( evt ) : void => { if ( ! ( evt as any ) . resetSelection ) { const x = evt . x - chart . plotLeft ; const y = evt . y - chart . plotTop ; const { y : y1 , x : x1 } = this . pixelsToProjectedUnits ( { x , y } ) ; const { y : y2 , x : x2 } = this . pixelsToProjectedUnits ( { x : x + evt . width , y : y + evt . height } ) ; this . fitToBounds ( { x1 , y1 , x2 , y2 } , void 0 , true , ( evt as any ) . originalEvent . touches ? false : void 0 ) ; if ( ! /^touch/ . test ( ( ( evt as any ) . originalEvent . type ) ) ) { chart . showResetZoom ( ) ; } evt . preventDefault ( ) ; } else { this . zoomBy ( ) ; } } ) ; } public render ( ) { if ( ! this . group ) { this . group = this . chart . renderer . g ( 'map-view' ) . attr ( { zIndex : 4 } ) . add ( ) ; } } function param { Partial < Highcharts . MapViewOptions > } options param { boolean } redraw param { boolean | Partial < Highcharts . AnimationOptionsObject > } animation public update ( options , redraw = true , animation ? ) { const newProjection = options . projection ; const isDirtyProjection = newProjection && ( ( Projection . toString ( newProjection ) !== Projection . toString ( this . options . projection ) ) ) ; merge ( true , this . userOptions , options ) ; merge ( true , this . options , options ) ; if ( isDirtyProjection ) { this . chart . series . forEach ( ( series ) : void => { if ( series . clearBounds ) { series . clearBounds ( ) ; } series . isDirty = true ; series . isDirtyData = true ; } ) ; this . projection = new Projection ( this . options . projection ) ; if ( ! options . center && ! isNumber ( options . zoom ) ) { this . fitToBounds ( void 0 , void 0 , false ) ; } } if ( options . center || isNumber ( options . zoom ) ) { this . setView ( this . options . center , options . zoom , false ) ; } if ( redraw ) { this . chart . redraw ( animation ) ; } } function param { number | undefined } howMuch param { Highcharts . LonLatArray } coords param { Array < number > } chartCoords param { boolean | Partial < Highcharts . AnimationOptionsObject > } animation public zoomBy ( howMuch ? , coords ? , chartCoords ? , animation ? ) { const chart = this . chart ; const projectedCenter = this . projection . forward ( this . center ) ; let [ x , y ] = coords ? this . projection . forward ( coords ) : [ ] ; if ( typeof howMuch === 'number' ) { const zoom = this . zoom + howMuch ; let center ; if ( chartCoords ) { const [ chartX , chartY ] = chartCoords ; const scale = this . getScale ( ) ; const offsetX = chartX - chart . plotLeft - chart . plotWidth / 2 ; const offsetY = chartY - chart . plotTop - chart . plotHeight / 2 ; x = projectedCenter [ 0 ] + offsetX / scale ; y = projectedCenter [ 1 ] + offsetY / scale ; } if ( typeof x === 'number' && typeof y === 'number' ) { const scale = 1 - Math . pow ( 2 , this . zoom ) / Math . pow ( 2 , zoom ) ; const offsetX = projectedCenter [ 0 ] - x ; const offsetY = projectedCenter [ 1 ] - y ; projectedCenter [ 0 ] -= offsetX * scale ; projectedCenter [ 1 ] += offsetY * scale ; center = this . projection . inverse ( projectedCenter ) ; } this . setView ( center , zoom , void 0 , animation ) ; } else { this . fitToBounds ( void 0 , void 0 , void 0 , animation ) ; } } } class MapViewInset extends MapView { public allBounds : MapBounds [ ] ; public border ? : SVGElement ; public geoBoundsProjectedBox ? : MapBounds ; public geoBoundsProjectedPolygon ? : Array < Array < number > > ; public hitZone ? : Polygon ; public id ? : string ; public options : MapViewInsetsOptions ; public mapView : MapView ; public constructor ( mapView , options ) { super ( mapView . chart , options ) ; this . id = options . id ; this . mapView = mapView ; this . options = merge ( defaultInsetsOptions , options ) ; this . allBounds = [ ] ; if ( this . options . geoBounds ) { const path = mapView . projection . path ( this . options . geoBounds ) ; this . geoBoundsProjectedBox = boundsFromPath ( path ) ; this . geoBoundsProjectedPolygon = path . map ( ( segment ) : [ number , number ] => [ segment [ 1 ] || 0 , segment [ 2 ] || 0 ] ) ; } } getField ( padded = true ) { const hitZone = this . hitZone ; if ( hitZone ) { const padding = padded ? this . padding : [ 0 , 0 , 0 , 0 ] , polygon = hitZone . coordinates [ 0 ] , xs = polygon . map ( ( xy ) : number => xy [ 0 ] ) , ys = polygon . map ( ( xy ) : number => xy [ 1 ] ) , x = Math . min . apply ( 0 , xs ) + padding [ 3 ] , x2 = Math . max . apply ( 0 , xs ) - padding [ 1 ] , y = Math . min . apply ( 0 , ys ) + padding [ 0 ] , y2 = Math . max . apply ( 0 , ys ) - padding [ 2 ] ; if ( isNumber ( x ) && isNumber ( y ) ) { return { x , y , width : x2 - x , height : y2 - y } ; } } return super . getField . call ( this , padded ) ; } getHitZone ( ) { const { chart , mapView , options } = this , { coordinates } = options . field || { } ; if ( coordinates ) { let polygon = coordinates [ 0 ] ; if ( options . units === 'percent' ) { const relativeTo = options . relativeTo === 'mapBoundingBox' && mapView . getMapBBox ( ) || merge ( chart . plotBox , { x : 0 , y : 0 } ) ; polygon = polygon . map ( ( xy ) : [ number , number ] => [ relativeLength ( `${ xy [ 0 ] }%` , relativeTo . width , relativeTo . x ) , relativeLength ( `${ xy [ 1 ] }%` , relativeTo . height , relativeTo . y ) ] ) ; } return { type : 'Polygon' , coordinates : [ polygon ] } ; } } getProjectedBounds ( ) { return MapView . compositeBounds ( this . allBounds ) ; } isInside ( point ) { const { geoBoundsProjectedBox , geoBoundsProjectedPolygon } = this ; return Boolean ( geoBoundsProjectedBox && point . x >= geoBoundsProjectedBox . x1 && point . x <= geoBoundsProjectedBox . x2 && point . y >= geoBoundsProjectedBox . y1 && point . y <= geoBoundsProjectedBox . y2 && geoBoundsProjectedPolygon && pointInPolygon ( point , geoBoundsProjectedPolygon ) ) ; } render ( ) { const { chart , mapView , options } = this , borderPath = options . borderPath || options . field ; if ( borderPath && mapView . group ) { let animate = true ; if ( ! this . border ) { this . border = chart . renderer . path ( ) . addClass ( 'highcharts-mapview-inset-border' ) . add ( mapView . group ) ; animate = false ; } if ( ! chart . styledMode ) { this . border . attr ( { stroke : options . borderColor , 'stroke-width' : options . borderWidth } ) ; } const crisp = Math . round ( this . border . strokeWidth ( ) ) % 2 / 2 , field = ( options . relativeTo === 'mapBoundingBox' && mapView . getMapBBox ( ) ) || mapView . playingField ; const d = ( borderPath . coordinates || [ ] ) . reduce ( ( d , lineString ) : SVGPath => lineString . reduce ( ( d , point , i ) : SVGPath => { let [ x , y ] = point ; if ( options . units === 'percent' ) { x = chart . plotLeft + relativeLength ( `${ x }%` , field . width , field . x ) ; y = chart . plotTop + relativeLength ( `${ y }%` , field . height , field . y ) ; } x = Math . floor ( x ) + crisp ; y = Math . floor ( y ) + crisp ; d . push ( i === 0 ? [ 'M' , x , y ] : [ 'L' , x , y ] ) ; return d ; } , d ) , [ ] as SVGPath ) ; this . border [ animate ? 'animate' : 'attr' ] ( { d } ) ; } } setUpEvents ( ) { } } addEvent ( MapChart , 'afterInit' , function ( ) : void { this . mapView = new MapView ( this , this . options . mapView ) ; } ) ; export default MapView ;