import { LMap, LTileLayer, LMarker, LControl, LIcon, LFeatureGroup, LMarkerClusterGroup, LGeoJson } from "@vue-leaflet/vue-leaflet"; import Vue from "vue"; Vue.component("l-map", LMap); Vue.component("l-tile-layer", LTileLayer); Vue.component("l-marker", LMarker); Vue.component("l-control", LControl); const buildMarkerIcon = (color, number) => { const iconUrl = number !== undefined ? `/maps/marker/${color}/${number}/` : `/maps/marker/${color}/`; return new Licon({ iconUrl, shadowUrl: `/maps/marker-shadow/`, iconSize: [44, 64], iconAnchor: [22, 64], shadowSize: [48, 68], shadowAnchor: [22, 68], }); }; const layerUrl = (tileServerConfig, baseLayerName, gridName) => { return `${tileServerConfig.url}/tiles/${baseLayerName}/${gridName || 'webmercator'}/{z}/{x}/{y}.png`; } const retinaAwareLayerUrl = (tileServerConfig, baseLayerName, gridName, gridNameHq) => { const isRetina = false; // TODO const ln = isRetina ? baseLayerName + '_hq' : baseLayerName; const gn = isRetina ? (gridNameHq || "webmercator") : (gridName || "webmercator"); return layerUrl(tileServerConfig, ln, gn); } const mapboxStyle = (tileServerConfig, id) => ({ url: retinaAwareLayerUrl(tileServerConfig, id, "mapbox_webmercator", "mapbox_webmercator_hq"), attribution: 'Map data © <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>', // @see: https://github.com/Leaflet/Leaflet/issues/6059#issuecomment-367007392 mapMaxZoom: 20, maxZoom: 22, maxNativeZoom: 22, detectRetina: true, tileSize: false ? 1024 : 512, // TODO zoomOffset: false ? -2 : -1, // TODO }); const tileStyles = { "osm-mapnik": (tileServerConfig) => ({ url: layerUrl(tileServerConfig, "osm_mapnik"), attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors', maxZoom: 19, maxNativeZoom: 19, }), "stamen-toner": (tileServerConfig) => ({ url: layerUrl(tileServerConfig, "stamen_toner"), attribution: 'Map tiles by <a href="http://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.', ext: "png", maxZoom: 20, maxNativeZoom: 20, }), "stamen-terrain": (tileServerConfig) => ({ url: layerUrl(tileServerConfig, "stamen_terrain"), attribution: 'Map tiles by <a href="http://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.', ext: "png", maxZoom: 20, maxNativeZoom: 20, }), "stadia-osm-bright": (tileServerConfig) => ({ url: retinaAwareLayerUrl(tileServerConfig, "stadia_osm_bright"), attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a>, © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors', maxZoom: 20, maxNativeZoom: 20, }), "stadia-outdoors": (tileServerConfig) => ({ url: retinaAwareLayerUrl(tileServerConfig, "stadia_outdoors"), attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a>, © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors', maxZoom: 20, maxNativeZoom: 20, }), "mapbox-streets": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_streets"), "mapbox-outdoors": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_outdoors"), "mapbox-light": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_light"), "mapbox-dark": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_dark"), "mapbox-satellite": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_satellite"), "mapbox-pirate": (tileServerConfig) => mapboxStyle(tileServerConfig, "mapbox_pirate"), }; const GeoFeatureCollection = Vue.component( "GeoFeatureCollection", { props: { tileServerConfig: { type: Object, default: {}, }, geojson: { type: Array, required: true, }, categoryList: { type: Array, required: false, }, wrapperClass: { type: String, default: "", }, tileStyle: { type: String, default: "osm-mapnik", }, initialZoom: { type: Number, default: 13, }, displayLegend: { type: Boolean, default: true, }, displayZoomControl: { type: Boolean, default: true, }, displayPopups: { type: Boolean, default: true, }, handleClicks: { type: Boolean, default: true, }, height: { type: String, default: "50rem", }, }, data: function () { const tileOpts = tileStyles[this.tileStyle](this.tileServerConfig); return { // Future map reference map: null, categories: [], entityMap: {}, collections: [], featureGroup: LFeatureGroup(), zoom: this.initialZoom, currentItem: null, // See `mapboxStyle` implementation above. mapMaxZoom: tileOpts.mapMaxZoom || tileOpts.maxZoom, mapOptions: { dragging: true, // TODO tap: true, // TODO zoomSnap: 1, zoomControl: this.displayZoomControl, }, tileLayerOptions: { ...tileOpts }, }; }, computed: { categoryListExpanded: function () { return Object.values(this.categories).reduce((result, category) => { return result || category.expanded; }, false); }, categoryCount: function () { return Object.keys(this.categories).length; }, }, mounted() { this.map = this.$refs.map.mapObject; this.initialize(this.categoryList, this.geojson); window.addEventListener("keydown", (event) => { if (event.defaultPrevented) { return; // Should do nothing if the default action has been cancelled } let handled = false; let key; if (event.key !== undefined) { // Handle the event with KeyboardEvent.key and set handled true. key = event.key; } else if (event.keyCode !== undefined) { // Handle the event with KeyboardEvent.keyCode and set handled true. key = event.keyCode; } if (key === 27 || key === "Esc" || key === "Escape") { handled = true; this.closeItemInfo(); } if (handled) { // Suppress "double action" if event handled event.preventDefault(); } }); }, // Unbind event listener on component destroy. beforeDestroy() { window.removeEventListener("hashchange", this.onHashChange); }, methods: { initialize(categories, featureCollections) { const allFeatures = []; // Preprocess all the features and collections. // - prepare link to layer and feature collection // - prepare latlng // - prepare marker shats // - add `muted` to featureCollection to control opacity featureCollections.forEach((featureCollection) => { // Muted property to control opacity. featureCollection.properties.muted = false; featureCollection.features.forEach(f => { // Marker stash. f.properties.markers = []; // LatLng info (for points). f.properties.latlng = null; // Layer link (for other types). f.properties.layer = null; // Parent FeatureCollection link./ f.properties.collection = featureCollection; // Preload images. if (f.properties.image) { const i = new Image(); i.src = f.properties.image; } // Keep reference for easier searches. this.entityMap[f.properties.slug] = f; allFeatures.push(f); }) }); if (categories) { // Build category list. this.initCategories(categories, featureCollections); // Bind categories to features. allFeatures.forEach((f) => { if (f.properties.category && this.categories[f.properties.category]) { f.properties.categoryObj = this.categories[f.properties.category]; } }); // Bind categories to featureCollections. featureCollections.forEach(fc => { if (fc.properties.category && this.categories[fc.properties.category]) { fc.properties.categoryObj = this.categories[fc.properties.category]; } }); } this.collections = featureCollections; // Draw the map. this.initMap(featureCollections); }, /** * Traverse list of features a build list of categories * with features assigned. * * @param {GeoJSON features} features */ initCategories(inputCategories, featureCollections) { const categories = {}; inputCategories.forEach((cat, index) => { categories[cat.name] = { expanded: false, name: cat.name, color: cat.color, featureCollections: [], muted: false, }; }); featureCollections.forEach((featureCollection) => { if (featureCollection.properties.category) { categories[featureCollection.properties.category].featureCollections.push(featureCollection); } }); this.categories = categories; if (this.categories.length === 0) { this.displayLegend = false; } }, /** * Given a list of feature collection objects, add layers to map. * @param {GeoJSON featureCollections} featureCollections */ initMap(featureCollections) { featureCollections.forEach((featureCollection) => { const {geoJSONLayer, markersLayer} = this.createFeatureCollectionLayers(featureCollection); geoJSONLayer.addTo(this.featureGroup); markersLayer.addTo(this.featureGroup); // Keep links. featureCollection.properties.geoJSONLayer = geoJSONLayer; featureCollection.properties.markersLayer = markersLayer; this.entityMap[featureCollection.properties.slug] = featureCollection; }); this.map.addLayer(this.featureGroup); if (featureCollections.length == 1) { // Pan to single feature, likely a point. this.map.panTo(this.featureGroup.getBounds().getCenter()); } else { // Fit multiple features in a view. this.map.fitBounds(this.featureGroup.getBounds()); } // Initial check - open item detail if in URL. this.onUserNavigation(); // Listen to URL changes when user triggers browser navigation. window.addEventListener("popstate", this.onUserNavigation); }, /** * Creates two new layers for a given FeatureCollection object: GeoJSON and markers. * @param {Object} featureCollection * @returns */ createFeatureCollectionLayers(featureCollection) { // Markers are clustered for easier orientation. const markers = LMarkerClusterGroup({ showCoverageOnHover: false, maxClusterRadius: 48, }); // Get color for feature - either from category or fall back to default. const colorForFeature = (feature) => { if (feature.properties.color) { return "#" + feature.properties.color; } const cat = feature.properties.categoryObj; return cat ? "#" + cat.color : "#000000"; }; // Get style for given feature. const style = (feature) => ({ fillColor: colorForFeature(feature), weight: 3, opacity: 0.8, color: colorForFeature(feature), fillOpacity: 0.6, }); const markerIconForCategory = (categoryName, number) => categoryName && this.categories[categoryName] ? buildMarkerIcon( this.categories[categoryName].color, number ) : buildMarkerIcon("000000", number); const addMarker = (feature, markerPosLatLng, onClick) => { const tooltipTitle = [feature.properties.title, feature.properties.collectionTitle].filter(i => !!i).join(' - '); // add marker const featureMarker = new LMarker(markerPosLatLng, { icon: feature.properties.color ? buildMarkerIcon( feature.properties.color, feature.properties.index ) : markerIconForCategory( feature.properties.category, feature.properties.index ), }) .on("click", onClick) .bindTooltip(tooltipTitle, { className: "geo-feature-collection-tooltip", direction: "top", offset: [0, -64], }); // Add marker to feature marker list. feature.properties.markers.push(featureMarker); // Add item marker to the cluster. markers.addLayer(featureMarker); return featureMarker; }; /** * Process Point/MultiPoint type GeoJSON features. * Called for each such feature when building the map. */ const pointToLayer = (feature, latlng) => { const onClick = (evt) => { if (this.handleClicks) { this.zoomToPoint(evt.latlng, feature); } }; feature.properties.latlng = latlng; return addMarker(feature, latlng, onClick); }; /** * Process Polygon/MultiPolygon/LineString/MultiLineString type GeoJSON features. * Called for each such feature when building the map. */ const onEachFeature = (feature, layer) => { const markerPosLatLng = []; const markerForPolyCoords = (coords) => { // Find pole of inaccessibility (not centroid) for the polygon // @see: https://github.com/mapbox/polylabel const markerPos = polylabel(coords, 1); markerPosLatLng.push(L.latLng(markerPos[1], markerPos[0])); }; const markerForLineStringCoords = (coords) => { // Find a middle sector of LineString and set position to middle of it const sectorCount = coords.length; const sectorIndex = Math.floor((sectorCount - 1) / 2); markerPosLatLng.push( L.latLng( (coords[sectorIndex][1] + coords[sectorIndex + 1][1]) / 2, (coords[sectorIndex][0] + coords[sectorIndex + 1][0]) / 2 ) ); }; /** * Supported GeoJSON features: Polygon, MultiPolygon, LineString, MultiLineString. * Point/MultiPoint features are better handled by pointToLayer function. * It's better idea to convert Points to small Polygons (ask marek.forster@pirati.cz for conversion tool) as * bounds and zoom methods are not supported for those. **/ if (feature.geometry.type == "Polygon") { markerForPolyCoords(feature.geometry.coordinates); feature.properties.layer = layer; } else if (feature.geometry.type == "MultiPolygon") { feature.geometry.coordinates.forEach(markerForPolyCoords); feature.properties.layer = layer; } else if (feature.geometry.type == "LineString") { markerForLineStringCoords(feature.geometry.coordinates); feature.properties.layer = layer; } else if (feature.geometry.type == "MultiLineString") { feature.geometry.coordinates.forEach( markerForLineStringCoords ); feature.properties.layer = layer; } else if (feature.geometry.type == "MultiPoint" || feature.geometry.type == "Point") { // Supported via `pointToLayer`, noop here. } else { console.warn( `GeoJSON feature type unsupported: ${feature.geometry.type}` ); } if (markerPosLatLng.length) { const onMarkerClick = (evt) => { if (this.handleClicks) { this.zoomToFeature(feature, true, false); } } markerPosLatLng.forEach((pos) => { addMarker(feature, pos, onMarkerClick); }); if (this.handleClicks) { // Bind click event on the layer target item. layer.on({ click: (evt) => { this.zoomToFeature(feature, true, false); } }); } } }; return { geoJSONLayer: LGeoJson(featureCollection, { style, onEachFeature, pointToLayer, }), markersLayer: markers, }; }, /** * Stores slug in URL query param. * * @param {Object} item */ pushToUrl(slug) { const url = new URL(window.location); if (slug === null) { url.searchParams.delete("item"); history.pushState({}, "", url); } else { url.searchParams.set("item", slug); history.pushState({}, "", url); } }, /** * Called when user navigates. Will display detail * of corresponding item if such exist. * * @param {Event} evt */ onUserNavigation(evt) { const urlParams = new URLSearchParams(window.location.search); // If query is present when starting, locate the item and zoom to it. if (urlParams.has("item")) { this.focusOnEntity(urlParams.get("item"), false, true); } else if (this.currentItem) { this.closeItemInfo(false); } }, /** * Hide current item detail, drop it from URL. * @param {Boolean} updateUrl whether to push new state to history. */ closeItemInfo(updateUrl = true) { if (this.currentItem) { this.currentItem = null; if (updateUrl) { this.pushToUrl(null); } } }, /** * Expand category, show list of FeatureCollection objects belonging to it * @param {String} category */ toggleExpandCategory(category) { category.expanded = !category.expanded; }, /** * Set muted state for whole feature collection category. * @param {String} category */ setMutedCategory(category, muted) { category.featureCollections.forEach(featureCollection => this.setMuted(featureCollection, muted)); }, /** * Set muted state for collection. * When featureCollection is muted, it will be less opaque in the list. Used for * better orientation when there are lots of features. * @param {Object} featureCollection */ setMuted(featureCollection, muted) { featureCollection.properties.muted = muted; if (featureCollection.properties.categoryObj) { // Update muted flag on category this featureCollection belongs to. // Category is muted when all featureCollections in it are muted. featureCollection.properties.categoryObj.muted = featureCollection .properties .categoryObj .featureCollections .map(featureCollection => featureCollection.properties.muted) .reduce((a, b) => a && b, true); } if (featureCollection.properties.muted) { if (this.featureGroup.hasLayer(featureCollection.properties.geoJSONLayer)) { this.featureGroup.removeLayer(featureCollection.properties.geoJSONLayer); } if (this.featureGroup.hasLayer(featureCollection.properties.markersLayer)) { this.featureGroup.removeLayer(featureCollection.properties.markersLayer); } } else { this.featureGroup.addLayer(featureCollection.properties.geoJSONLayer); this.featureGroup.addLayer(featureCollection.properties.markersLayer); } }, /** * Toggle solo view for a FeatureCollection object. * If collection is in solo mode, all other collections are muted. When solo is toggled off, all collections * will get unmuted. * @param {Object} featureCollection */ toggleSolo(featureCollection) { const wasSolo = this.collections .filter(c => c !== featureCollection) .map(c => c.properties.muted) .reduce((a, b) => a && b, true); if (wasSolo) { // Unset solo - unmute all items. this.collections.forEach((c) => this.setMuted(c, false)); } else { // Set solo. this.collections.forEach((c) => this.setMuted(c, c !== featureCollection)); } }, /** * Zoom to a layer. * * @param {L.Layer} layer * @param {Boolean} zoom whether to zoom to the items */ zoomToLayer(layer, zoom = false) { if (zoom) { this.map.flyToBounds(layer.getBounds()); } else { this.map.panInsideBounds(layer.getBounds()); } }, /** * Zoom to a point. * * @param {L.Latlng} Latlng * @param {Object} feature * @param {Boolean} updateUrl whether to push new state to history. * @param {Boolean} zoom whether to zoom to the item */ zoomToPoint(latlng, feature, updateUrl = true, zoom = false) { if (zoom) { this.map.flyTo(latlng, 18); } else { this.map.panTo(latlng); } this.currentItem = feature.properties; if (updateUrl) { this.pushToUrl(this.currentItem.slug); } // Zooming always unmutes. this.setMuted(feature.properties.collection, false); }, /** * Zoom to a feature. * * @param {Object} feature * @param {Boolean} updateUrl whether to push new state to history. * @param {Boolean} zoom whether to zoom to the items */ zoomToFeature(feature, updateUrl = true, zoom = false) { this.zoomToLayer(feature.properties.layer, zoom); this.currentItem = feature.properties; if (updateUrl) { this.pushToUrl(this.currentItem.slug); } // Zooming always unmutes. this.setMuted(feature.properties.collection, false); }, /** * Zoom to a feature collection. * * @param {Object} featureCollection * @param {Boolean} updateUrl whether to push new state to history. * @param {Boolean} zoom whether to zoom to the items */ zoomToFeatureCollection(fetureCollection, updateUrl = true, zoom = false) { this.zoomToLayer(fetureCollection.properties.geoJSONLayer, zoom); this.currentItem = fetureCollection.properties; if (updateUrl) { this.pushToUrl(this.currentItem.slug); } // Zooming always unmutes. this.setMuted(fetureCollection, false); }, /** * Focus on a entity with corresponding slugified URL. * * @param {String} slugUrl entity slug * @param {Boolean} updateUrl whether to push new state to history. * @param {Boolean} zoom whether to zoom to the items */ focusOnEntity(slugUrl, updateUrl = true, zoom = false) { const entity = this.entityMap[slugUrl]; if (entity) { if (entity.properties.geoJSONLayer) { // Whole FeatureCollection. this.zoomToFeatureCollection(entity, zoom); } else if (entity.properties.layer) { // Feature - other than Point type. this.zoomToFeature(entity, updateUrl, zoom); } else if (entity.properties.latlng) { // Feature - Point type. this.zoomToPoint(entity.properties.latlng, entity, updateUrl, zoom); } } }, /** * Stop event propagation, utility fn. * @param {Event} evt */ stopPropagation(evt) { evt.stopPropagation(); }, }, template: ` <template> </template> `, } ); export { GeoFeatureCollection }