package map
import javafx.concurrent.Worker
import javafx.scene.layout.StackPane
import javafx.scene.paint.Color
import javafx.scene.shape.Polygon
import javafx.scene.web.WebEngine
import javafx.scene.web.WebView
import map.events.*
import netscape.javascript.JSObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.net.URL
import java.util.*
import java.util.concurrent.CompletableFuture
import javax.imageio.ImageIO
/**
* JavaFX component for displaying OpenStreetMap based maps by using the Leaflet.js JavaScript library inside a WebView
* browser component.
* This component can be embedded most easily by placing it inside a StackPane, the component uses then the size of the
* parent automatically.
*
* @author Stefan Saring
* @author Niklas Kellner
*/
class LeafletMapView : StackPane() {
private val webView = WebView()
private val webEngine: WebEngine = webView.engine
private var varNameSuffix: Int = 1
private val mapClickEvent = MapClickEventMaker()
private val markerClickEvent = MarkerClickEventMaker()
private val mapMoveEvent = MapMoveEventMaker()
internal val zoomLimitSmallMarker = 8
/**
* Creates the LeafletMapView component, it does not show any map yet.
*/
init {
this.children.add(webView)
}
/**
* Displays the initial map in the web view. Needs to be called and complete before adding any markers or tracks.
* The returned CompletableFuture will provide the final map load state, the map can be used when the load has
* completed with state SUCCEEDED (use CompletableFuture#whenComplete() for waiting to complete).
*
* @param mapConfig configuration of the map layers and controls
* @return the CompletableFuture which will provide the final map load state
*/
fun displayMap(mapConfig: MapConfig): CompletableFuture {
val finalMapLoadState = CompletableFuture()
webEngine.loadWorker.stateProperty().addListener { _, _, newValue ->
if (newValue == Worker.State.SUCCEEDED) {
executeMapSetupScripts(mapConfig)
}
if (newValue == Worker.State.SUCCEEDED || newValue == Worker.State.FAILED) {
finalMapLoadState.complete(newValue)
}
}
val localFileUrl: URL = LeafletMapView::class.java.getResource("/leafletmap/leafletmap.html")
webEngine.load(localFileUrl.toExternalForm())
return finalMapLoadState
}
private fun executeMapSetupScripts(mapConfig: MapConfig) {
// execute scripts for layer definition
mapConfig.layers.forEachIndexed { i, layer ->
execScript("var layer${i + 1} = ${layer.javaScriptCode};")
}
val jsLayers = mapConfig.layers
.mapIndexed { i, layer -> "'${layer.displayName}': layer${i + 1}" }
.joinToString(", ")
execScript("var baseMaps = { $jsLayers };")
// execute script for map view creation (Leaflet attribution must not be a clickable link)
execScript(
"""
|var myMap = L.map('map', {
| center: new L.LatLng(${mapConfig.initialCenter.latitude}, ${mapConfig.initialCenter.longitude}),
| zoom: 1,
| zoomControl: false,
| layers: [layer1]
|});
|L.control.scale().addTo(myMap);
|var myRenderer = L.canvas({ padding: 0.5 });
|var markerClusters = L.markerClusterGroup({spiderfyOnMaxZoom: false, disableClusteringAtZoom: 10});
|var heatLayer = L.heatLayer([]).addTo(myMap);""".trimMargin()
)
// eventZoomChangeIcon()
// execute script for layer control definition if there are multiple layers
if (mapConfig.layers.size > 1) {
execScript(
"""
|var overlayMaps = {};
|L.control.layers(baseMaps, overlayMaps).addTo(myMap);""".trimMargin()
)
}
// execute script for scale control definition
if (mapConfig.scaleControlConfig.show) {
execScript(
"L.control.scale({position: '${mapConfig.scaleControlConfig.position.positionName}', " +
"metric: ${mapConfig.scaleControlConfig.metric}, " +
"imperial: ${!mapConfig.scaleControlConfig.metric}})" +
".addTo(myMap);"
)
}
// execute script for zoom control definition
if (mapConfig.zoomControlConfig.show) {
execScript(
"L.control.zoom({position: '${mapConfig.zoomControlConfig.position.positionName}'})" +
".addTo(myMap);"
)
}
}
/**
* Sets the view of the map to the specified geographical center position and zoom level.
*
* @param position map center position
* @param zoomLevel zoom level (0 - 19 for OpenStreetMap)
*/
fun setView(position: LatLong, zoomLevel: Int) =
execScript("myMap.setView([${position.latitude}, ${position.longitude}], $zoomLevel);")
/**
* Pans the map to the specified geographical center position.
*
* @param position map center position
*/
fun panTo(position: LatLong) =
execScript("myMap.panTo([${position.latitude}, ${position.longitude}]);")
/**
* Sets the zoom of the map to the specified level.
*
* @param zoomLevel zoom level (0 - 19 for OpenStreetMap)
*/
fun setZoom(zoomLevel: Int) =
execScript("myMap.setZoom([$zoomLevel]);")
/**
* Adds a Marker Object to a map
*
* @param marker the Marker Object
*/
fun addMarker(marker: Marker) {
marker.addToMap(getNextMarkerName(), this)
}
fun addCircle(circle: Circle) {
circle.addToMap(this)
}
fun addZone(zone: Zone) {
zone.addToMap(this)
}
/**
* Removes an existing marker from the map
*
* @param marker the Marker object
*/
fun removeMarker(marker: Marker) {
execScript("myMap.removeLayer(${marker.getName()});")
}
fun removeCircle(circle: Circle) {
circle.removeCircle(this)
}
fun removeZone(zone: Zone) {
zone.removeZone()
}
fun removeZone(id: String) {
val idSanitized = id.replace("-", "")
execScript("myMap.removeLayer(polygon$idSanitized);")
}
fun uppdateCircle(circle: Circle, latLong: LatLong, radius: Double) {
circle.modifyCircle(latLong, radius)
circle.uppdateMap()
}
fun setEventMousePosition() {
execScript(
"var lat=0.0, lng=0.0;\n" +
"myMap.addEventListener('mousemove', function(ev) {\n" +
" lat = ev.latlng.lat;\n" +
" lng = ev.latlng.lng;\n" +
"});"
)
}
fun getMousePosition(): LatLong {
val lat = execScript("lat;") as Double
val lng = execScript("lng;") as Double
return LatLong(lat, lng)
}
/**
* Adds a custom marker type
*
* @param markerName the name of the marker type
* @param iconUrl the url if the marker icon
*/
fun addCustomMarker(markerName: String, iconUrl: String): String {
execScript(
"var $markerName = L.icon({\n" +
"iconUrl: '${createImage(iconUrl, "png")}',\n" +
"iconSize: [24, 24],\n" +
"iconAnchor: [12, 12],\n" +
"});"
)
return markerName
}
private fun createImage(path: String, type: String): String {
val image = ImageIO.read(File(path))
var imageString: String? = null
val bos = ByteArrayOutputStream()
try {
ImageIO.write(image, type, bos)
val imageBytes = bos.toByteArray()
val encoder = Base64.getEncoder()
imageString = encoder.encodeToString(imageBytes)
bos.close()
} catch (e: IOException) {
e.printStackTrace()
}
return "data:image/$type;base64,$imageString"
}
/**
* Sets the onMarkerClickListener
*
* @param listener the onMarerClickEventListener
*/
fun onMarkerClick(listener: MarkerClickEventListener) {
val win = execScript("document") as JSObject
win.setMember("java", this)
markerClickEvent.addListener(listener)
}
/**
* Handles the callback from the markerClickEvent
*/
fun markerClick(title: String) {
markerClickEvent.MarkerClickEvent(title)
}
/**
* Sets the onMapMoveListener
*
* @param listener the MapMoveEventListener
*/
fun onMapMove(listener: MapMoveEventListener) {
val win = execScript("document") as JSObject
win.setMember("java", this)
execScript("myMap.on('moveend', function(e){ document.java.mapMove(myMap.getCenter().lat, myMap.getCenter().lng);});")
mapMoveEvent.addListener(listener)
}
/**
* Handles the callback from the mapMoveEvent
*/
fun mapMove(lat: Double, lng: Double) {
val latlng = LatLong(lat, lng)
mapMoveEvent.MapMoveEvent(latlng)
}
/**
* Sets the onMapClickListener
*
* @param listener the onMapClickEventListener
*/
fun onMapClick(listener: MapClickEventListener) {
val win = execScript("document") as JSObject
win.setMember("java", this)
execScript("myMap.on('click', function(e){ document.java.mapClick(e.latlng.lat, e.latlng.lng);});")
mapClickEvent.addListener(listener)
}
/**
* Handles the callback from the mapClickEvent
*/
fun mapClick(lat: Double, lng: Double) {
val latlng = LatLong(lat, lng)
mapClickEvent.MapClickEvent(latlng)
}
/**
* Draws a track path along the specified positions.
*
* @param positions list of track positions
*/
fun addTrack(positions: List) {
val jsPositions = positions
.map { " [${it.latitude}, ${it.longitude}]" }
.joinToString(", \n")
execScript(
"""
|var latLngs = [
|$jsPositions
|];
|var polyline = L.polyline(latLngs, {color: 'red', weight: 2}).addTo(myMap);""".trimMargin()
)
}
fun clearAllLayer() {
execScript("""
myMap.eachLayer(function (layer) {
map.removeLayer(layer);
});
""".trimIndent())
}
fun addTrack(positions: List, id: String, color: Color, tooltip: String) {
val jsPositions = positions
.map { " [${it.latitude}, ${it.longitude}]" }
.joinToString(", \n")
val cleanTooltip = tooltip.replace("'", "'")
execScript(
"""
|var latLngs = [
|$jsPositions
|];
|var color = "rgb(${Math.floor(color.getRed() * 255).toInt()} ,${Math.floor(color.getGreen() * 255)
.toInt()},${Math.floor(color.getBlue() * 255).toInt()})";
|var polyline$id = L.polyline(latLngs, {color: color, weight: 2, zIndexOffset: 200}).bindTooltip('$cleanTooltip', {sticky: true}).addTo(trackGroup)""".trimMargin()
)
}
fun makeVesselTrackTransparent(id: String) {
execScript("polyline$id.setStyle({opacity: 0.5});")
}
fun highlightTrack(id: String) {
execScript("polyline$id.setStyle({weight: 4});")
}
fun normalizeVesselTrack(id: String) {
execScript("polyline$id.setStyle({opacity: 1,weight: 2});")
}
fun eventZoomChangeIcon() {
execScript(
"""
|myMap.on('zoomend', function() {
|var currentZoom = myMap.getZoom();
|if (currentZoom < $zoomLimitSmallMarker) {
|markersGroup.eachLayer(function(layer) {
return layer.setIcon(aircraftSmallIcon)
|});
|} else {
|markersGroup.eachLayer(function(layer) {
return layer.setIcon(aircraftIcon)
|});
|}
|});
""".trimMargin()
)
}
fun removeTrack(id: String) {
execScript("myMap.removeLayer(polyline$id);")
}
fun fitBoundsMarkers() {
execScript("setTimeout(() => {myMap.fitBounds(markersGroup.getBounds().pad(0.05));}, 500);")
}
fun addZone(polygon: Polygon, id: String, color: Color) {
val points = polygon.points
val latLongs = arrayListOf()
var lat: Double
var lon = 0.0
for (i in 0 until points.size) {
if (i % 2 == 0) {
lon = points[i]
} else {
lat = points[i]
latLongs.add(LatLong(lat, lon))
}
}
val jsPositions = latLongs
.map { " [${it.latitude}, ${it.longitude}]" }
.joinToString(", \n")
val idSanitized = id.replace("-", "")
execScript(
"""
|var latLngs = [
|$jsPositions
|];
|var color = "rgb(${Math.floor(color.getRed() * 255).toInt()} ,${Math.floor(color.getGreen() * 255)
.toInt()},${Math.floor(color.getBlue() * 255).toInt()})";
|var polygon$idSanitized = L.polygon(latLngs, {color: color}).addTo(myMap);""".trimMargin()
)
}
internal fun execScript(script: String) = webEngine.executeScript(script)
private fun getNextMarkerName(): String = "marker${varNameSuffix++}"
}