Files
redash/client/app/visualizations/map/index.js
2019-06-18 09:50:09 +03:00

395 lines
11 KiB
JavaScript

import _ from 'lodash';
import d3 from 'd3';
import L from 'leaflet';
import 'leaflet.markercluster';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import 'beautifymarker';
import 'beautifymarker/leaflet-beautify-marker-icon.css';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerIconRetina from 'leaflet/dist/images/marker-icon-2x.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import 'leaflet-fullscreen';
import 'leaflet-fullscreen/dist/leaflet.fullscreen.css';
import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations';
import ColorPalette from '@/visualizations/ColorPalette';
import template from './map.html';
import editorTemplate from './map-editor.html';
// This is a workaround for an issue with giving Leaflet load the icon on its own.
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIconRetina,
shadowUrl: markerShadow,
});
delete L.Icon.Default.prototype._getIconUrl;
const MAP_TILES = [
{
name: 'OpenStreetMap',
url: '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
},
{
name: 'OpenStreetMap BW',
url: '//{s}.tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
},
{
name: 'OpenStreetMap DE',
url: '//{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png',
},
{
name: 'OpenStreetMap FR',
url: '//{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png',
},
{
name: 'OpenStreetMap Hot',
url: '//{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
},
{
name: 'Thunderforest',
url: '//{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png',
},
{
name: 'Thunderforest Spinal',
url: '//{s}.tile.thunderforest.com/spinal-map/{z}/{x}/{y}.png',
},
{
name: 'OpenMapSurfer',
url: '//korona.geog.uni-heidelberg.de/tiles/roads/x={x}&y={y}&z={z}',
},
{
name: 'Stamen Toner',
url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}.png',
},
{
name: 'Stamen Toner Background',
url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-background/{z}/{x}/{y}.png',
},
{
name: 'Stamen Toner Lite',
url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png',
},
{
name: 'OpenTopoMap',
url: '//{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
},
];
const iconAnchors = {
marker: [14, 32],
circle: [10, 10],
rectangle: [11, 11],
'circle-dot': [1, 2],
'rectangle-dot': [1, 2],
doughnut: [8, 8],
};
const popupAnchors = {
rectangle: [0, -3],
circle: [1, -3],
};
const DEFAULT_OPTIONS = {
classify: 'none',
clusterMarkers: true,
iconShape: 'marker',
iconFont: 'circle',
foregroundColor: '#ffffff',
backgroundColor: '#356AFF',
borderColor: '#356AFF',
};
function heatpoint(lat, lon, color) {
const style = {
fillColor: color,
fillOpacity: 0.9,
stroke: false,
};
return L.circleMarker([lat, lon], style);
}
const createMarker = (lat, lon) => L.marker([lat, lon]);
const createIconMarker = (lat, lon, icn) => L.marker([lat, lon], { icon: icn });
function createDescription(latCol, lonCol, row) {
const lat = row[latCol];
const lon = row[lonCol];
let description = '<ul style="list-style-type: none;padding-left: 0">';
description += `<li><strong>${lat}, ${lon}</strong>`;
_.each(row, (v, k) => {
if (!(k === latCol || k === lonCol)) {
description += `<li>${k}: ${v}</li>`;
}
});
return description;
}
const MapRenderer = {
template,
bindings: {
data: '<',
options: '<',
onOptionsChange: '<',
},
controller($scope, $element) {
const colorScale = d3.scale.category10();
const map = L.map($element[0].children[0].children[0], {
scrollWheelZoom: false,
fullscreenControl: true,
});
const mapControls = L.control.layers().addTo(map);
const layers = {};
const tileLayer = L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
let mapMoveLock = false;
const onMapMoveStart = () => {
mapMoveLock = true;
};
const onMapMoveEnd = () => {
this.options.bounds = map.getBounds();
if (this.onOptionsChange) {
this.onOptionsChange(this.options);
}
};
const updateBounds = ({ disableAnimation = false } = {}) => {
if (mapMoveLock) {
return;
}
const b = this.options.bounds;
if (b) {
map.fitBounds([[b._southWest.lat, b._southWest.lng],
[b._northEast.lat, b._northEast.lng]]);
} else if (layers) {
const allMarkers = _.flatten(_.map(_.values(layers), l => l.getLayers()));
if (allMarkers.length > 0) {
// eslint-disable-next-line new-cap
const group = new L.featureGroup(allMarkers);
const options = disableAnimation ? {
animate: false,
duration: 0,
} : null;
map.fitBounds(group.getBounds(), options);
}
}
};
map.on('focus', () => {
map.on('movestart', onMapMoveStart);
map.on('moveend', onMapMoveEnd);
});
map.on('blur', () => {
map.off('movestart', onMapMoveStart);
map.off('moveend', onMapMoveEnd);
});
const removeLayer = (layer) => {
if (layer) {
mapControls.removeLayer(layer);
map.removeLayer(layer);
}
};
const addLayer = (name, points) => {
const latCol = this.options.latColName || 'lat';
const lonCol = this.options.lonColName || 'lon';
const classify = this.options.classify;
let markers;
if (this.options.clusterMarkers) {
const color = this.options.groups[name].color;
const options = {};
if (classify) {
options.iconCreateFunction = (cluster) => {
const childCount = cluster.getChildCount();
let c = ' marker-cluster-';
if (childCount < 10) {
c += 'small';
} else if (childCount < 100) {
c += 'medium';
} else {
c += 'large';
}
c = '';
const style = `color: white; background-color: ${color};`;
return L.divIcon({ html: `<div style="${style}"><span>${childCount}</span></div>`, className: `marker-cluster${c}`, iconSize: new L.Point(40, 40) });
};
}
markers = L.markerClusterGroup(options);
} else {
markers = L.layerGroup();
}
// create markers
_.each(points, (row) => {
let marker;
const lat = row[latCol];
const lon = row[lonCol];
if (lat === null || lon === null) return;
if (classify && classify !== 'none') {
const groupColor = this.options.groups[name].color;
marker = heatpoint(lat, lon, groupColor);
} else {
if (this.options.customizeMarkers) {
const icon = L.BeautifyIcon.icon({
iconShape: this.options.iconShape,
icon: this.options.iconFont,
iconSize: this.options.iconShape === 'rectangle' ? [22, 22] : false,
iconAnchor: iconAnchors[this.options.iconShape],
popupAnchor: popupAnchors[this.options.iconShape],
prefix: 'fa',
textColor: this.options.foregroundColor,
backgroundColor: this.options.backgroundColor,
borderColor: this.options.borderColor,
});
marker = createIconMarker(lat, lon, icon);
} else {
marker = createMarker(lat, lon);
}
}
marker.bindPopup(createDescription(latCol, lonCol, row));
markers.addLayer(marker);
});
markers.addTo(map);
layers[name] = markers;
mapControls.addOverlay(markers, name);
};
const render = () => {
const classify = this.options.classify;
tileLayer.setUrl(this.options.mapTileUrl || '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png');
if (this.options.clusterMarkers === undefined) {
this.options.clusterMarkers = true;
}
if (this.data) {
let pointGroups;
if (classify && classify !== 'none') {
pointGroups = _.groupBy(this.data.rows, classify);
} else {
pointGroups = { All: this.data.rows };
}
const groupNames = _.keys(pointGroups);
const options = _.map(groupNames, (group) => {
if (this.options.groups && this.options.groups[group]) {
return this.options.groups[group];
}
return { color: colorScale(group) };
});
this.options.groups = _.zipObject(groupNames, options);
_.each(layers, (v) => {
removeLayer(v);
});
_.each(pointGroups, (v, k) => {
addLayer(k, v);
});
updateBounds({ disableAnimation: true });
}
};
$scope.handleResize = () => {
if (!map) return;
map.invalidateSize(false);
updateBounds({ disableAnimation: true });
};
$scope.$watch('$ctrl.data', render);
$scope.$watch(() => _.omit(this.options, 'bounds'), render, true);
$scope.$watch('$ctrl.options.bounds', updateBounds, true);
},
};
const MapEditor = {
template: editorTemplate,
bindings: {
data: '<',
options: '<',
onOptionsChange: '<',
},
controller($scope) {
this.currentTab = 'general';
this.setCurrentTab = (tab) => {
this.currentTab = tab;
};
this.mapTiles = MAP_TILES;
this.iconShapes = {
marker: 'Marker + Icon',
doughnut: 'Circle',
'circle-dot': 'Circle Dot',
circle: 'Circle + Icon',
'rectangle-dot': 'Square Dot',
rectangle: 'Square + Icon',
};
this.colors = {
White: '#ffffff',
...ColorPalette,
};
$scope.$watch('$ctrl.data.columns', () => {
this.columns = this.data.columns;
this.columnNames = _.map(this.columns, c => c.name);
this.classifyColumns = [...this.columnNames, 'none'];
});
$scope.$watch('$ctrl.options', (options) => {
this.onOptionsChange(options);
}, true);
},
};
export default function init(ngModule) {
ngModule.component('mapRenderer', MapRenderer);
ngModule.component('mapEditor', MapEditor);
ngModule.run(($injector) => {
registerVisualization({
type: 'MAP',
name: 'Map (Markers)',
getOptions: options => _.merge({}, DEFAULT_OPTIONS, options),
Renderer: angular2react('mapRenderer', MapRenderer, $injector),
Editor: angular2react('mapEditor', MapEditor, $injector),
defaultColumns: 3,
defaultRows: 8,
minColumns: 2,
});
});
}
init.init = true;