diff --git a/client/app/components/ColorPicker/Label.jsx b/client/app/components/ColorPicker/Label.jsx new file mode 100644 index 0000000000..6d73af579f --- /dev/null +++ b/client/app/components/ColorPicker/Label.jsx @@ -0,0 +1,30 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +import { validateColor, getColorName } from './utils'; +import './label.less'; + +export default function Label({ className, color, presetColors, ...props }) { + const name = useMemo( + () => getColorName(validateColor(color), presetColors), + [color, presetColors], + ); + + return {name}; +} + +Label.propTypes = { + className: PropTypes.string, + color: PropTypes.string, + presetColors: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips) + PropTypes.objectOf(PropTypes.string), // color name => color value + ]), +}; + +Label.defaultProps = { + className: null, + color: '#FFFFFF', + presetColors: null, +}; diff --git a/client/app/components/ColorPicker/Swatch.jsx b/client/app/components/ColorPicker/Swatch.jsx index f0b510b612..17747b351f 100644 --- a/client/app/components/ColorPicker/Swatch.jsx +++ b/client/app/components/ColorPicker/Swatch.jsx @@ -1,6 +1,7 @@ import { isString } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; +import cx from 'classnames'; import Tooltip from 'antd/lib/tooltip'; import './swatch.less'; @@ -8,7 +9,7 @@ import './swatch.less'; export default function Swatch({ className, color, title, size, ...props }) { const result = ( @@ -30,7 +31,7 @@ Swatch.propTypes = { }; Swatch.defaultProps = { - className: '', + className: null, title: null, color: 'transparent', size: 12, diff --git a/client/app/components/ColorPicker/index.jsx b/client/app/components/ColorPicker/index.jsx index a067f811cc..16070573b4 100644 --- a/client/app/components/ColorPicker/index.jsx +++ b/client/app/components/ColorPicker/index.jsx @@ -1,5 +1,5 @@ import { toString } from 'lodash'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; import tinycolor from 'tinycolor2'; @@ -10,19 +10,16 @@ import Icon from 'antd/lib/icon'; import ColorInput from './Input'; import Swatch from './Swatch'; +import Label from './Label'; +import { validateColor } from './utils'; import './index.less'; -function validateColor(value, fallback = null) { - value = tinycolor(value); - return value.isValid() ? '#' + value.toHex().toUpperCase() : fallback; -} - export default function ColorPicker({ - color, placement, presetColors, presetColumns, triggerSize, interactive, children, onChange, - className, ...props + color, placement, presetColors, presetColumns, interactive, children, onChange, triggerProps, }) { const [visible, setVisible] = useState(false); + const validatedColor = useMemo(() => validateColor(color), [color]); const [currentColor, setCurrentColor] = useState(''); function handleApply() { @@ -59,12 +56,14 @@ export default function ColorPicker({ useEffect(() => { if (visible) { - setCurrentColor(validateColor(color)); + setCurrentColor(validatedColor); } - }, [color, visible]); + }, [validatedColor, visible]); return ( {children || ( )} @@ -117,11 +116,10 @@ ColorPicker.propTypes = { PropTypes.objectOf(PropTypes.string), // color name => color value ]), presetColumns: PropTypes.number, - triggerSize: PropTypes.number, interactive: PropTypes.bool, + triggerProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types children: PropTypes.node, onChange: PropTypes.func, - className: PropTypes.string, }; ColorPicker.defaultProps = { @@ -129,12 +127,12 @@ ColorPicker.defaultProps = { placement: 'top', presetColors: null, presetColumns: 8, - triggerSize: 30, interactive: false, + triggerProps: {}, children: null, onChange: () => {}, - className: null, }; ColorPicker.Input = ColorInput; ColorPicker.Swatch = Swatch; +ColorPicker.Label = Label; diff --git a/client/app/components/ColorPicker/label.less b/client/app/components/ColorPicker/label.less new file mode 100644 index 0000000000..e8aa009f30 --- /dev/null +++ b/client/app/components/ColorPicker/label.less @@ -0,0 +1,7 @@ +.color-label { + vertical-align: middle; + + .color-swatch + & { + margin-left: 7px; + } +} diff --git a/client/app/components/ColorPicker/utils.js b/client/app/components/ColorPicker/utils.js new file mode 100644 index 0000000000..b397e76c6d --- /dev/null +++ b/client/app/components/ColorPicker/utils.js @@ -0,0 +1,14 @@ +import { isArray, findKey } from 'lodash'; +import tinycolor from 'tinycolor2'; + +export function validateColor(value, fallback = null) { + value = tinycolor(value); + return value.isValid() ? '#' + value.toHex().toUpperCase() : fallback; +} + +export function getColorName(color, presetColors) { + if (isArray(presetColors)) { + return color; + } + return findKey(presetColors, v => validateColor(v) === color) || color; +} diff --git a/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx b/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx index 08ff86b8b8..fa637cf350 100644 --- a/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx +++ b/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx @@ -52,16 +52,17 @@ export default function ColorsSettings({ options, onOptionsChange }) { - + onOptionsChange({ colors: { min } })} /> + @@ -69,16 +70,17 @@ export default function ColorsSettings({ options, onOptionsChange }) { - + onOptionsChange({ colors: { max } })} /> + @@ -86,16 +88,17 @@ export default function ColorsSettings({ options, onOptionsChange }) { - + onOptionsChange({ colors: { noValue } })} /> + @@ -103,16 +106,17 @@ export default function ColorsSettings({ options, onOptionsChange }) { - + onOptionsChange({ colors: { background } })} /> + @@ -120,16 +124,17 @@ export default function ColorsSettings({ options, onOptionsChange }) { - + onOptionsChange({ colors: { borders } })} /> + diff --git a/client/app/visualizations/map/Editor/GeneralSettings.jsx b/client/app/visualizations/map/Editor/GeneralSettings.jsx new file mode 100644 index 0000000000..d24a1641df --- /dev/null +++ b/client/app/visualizations/map/Editor/GeneralSettings.jsx @@ -0,0 +1,71 @@ +import { isNil, map, filter, difference } from 'lodash'; +import React, { useMemo } from 'react'; +import Select from 'antd/lib/select'; +import { EditorPropTypes } from '@/visualizations'; + +function getColumns(column, unusedColumns) { + return filter( + [column, ...unusedColumns], + v => !isNil(v), + ); +} + +export default function GeneralSettings({ options, data, onOptionsChange }) { + const unusedColumns = useMemo( + () => difference(map(data.columns, c => c.name), [options.latColName, options.lonColName, options.classify]), + [data, options.latColName, options.lonColName, options.classify], + ); + + return ( + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +} + +GeneralSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/map/Editor/GroupsSettings.jsx b/client/app/visualizations/map/Editor/GroupsSettings.jsx new file mode 100644 index 0000000000..7e4c03f0fd --- /dev/null +++ b/client/app/visualizations/map/Editor/GroupsSettings.jsx @@ -0,0 +1,67 @@ +import { map } from 'lodash'; +import React, { useMemo, useCallback } from 'react'; +import Table from 'antd/lib/table'; +import ColorPicker from '@/components/ColorPicker'; +import { EditorPropTypes } from '@/visualizations'; +import ColorPalette from '@/visualizations/ColorPalette'; + +import prepareData from '../prepareData'; + +export default function GroupsSettings({ options, data, onOptionsChange }) { + const groups = useMemo(() => map( + prepareData(data, options), + ({ name }) => ({ name, color: (options.groups[name] || {}).color || null }), + ), [data, options]); + + const colors = useMemo(() => ({ + Automatic: null, + ...ColorPalette, + }), []); + + const updateGroupOption = useCallback((name, prop, value) => { + onOptionsChange({ + groups: { + [name]: { + [prop]: value, + }, + }, + }); + }, [onOptionsChange]); + + const columns = [ + { + title: 'Group', + dataIndex: 'name', + }, + { + title: 'Color', + dataIndex: 'color', + width: '1%', + render: (unused, item) => ( +
+ updateGroupOption(item.name, 'color', value)} + /> + +
+ ), + }, + ]; + + return ( + + ); +} + +GroupsSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/map/Editor/StyleSettings.jsx b/client/app/visualizations/map/Editor/StyleSettings.jsx new file mode 100644 index 0000000000..97aa6c797a --- /dev/null +++ b/client/app/visualizations/map/Editor/StyleSettings.jsx @@ -0,0 +1,277 @@ +import { isNil, map } from 'lodash'; +import React, { useMemo } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import Select from 'antd/lib/select'; +import Input from 'antd/lib/input'; +import Checkbox from 'antd/lib/checkbox'; +import Popover from 'antd/lib/popover'; +import Icon from 'antd/lib/icon'; +import Typography from 'antd/lib/typography'; +import * as Grid from 'antd/lib/grid'; +import ColorPicker from '@/components/ColorPicker'; +import { EditorPropTypes } from '@/visualizations'; +import ColorPalette from '@/visualizations/ColorPalette'; + +const mapTiles = [ + { + 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 CustomColorPalette = { + White: '#ffffff', + ...ColorPalette, +}; + +function getCustomIconOptionFields(iconShape) { + switch (iconShape) { + case 'doughnut': + return { showIcon: false, showBackgroundColor: true, showBorderColor: true }; + case 'circle-dot': + case 'rectangle-dot': + return { showIcon: false, showBackgroundColor: false, showBorderColor: true }; + default: + return { showIcon: true, showBackgroundColor: true, showBorderColor: true }; + } +} + +export default function StyleSettings({ options, onOptionsChange }) { + const [debouncedOnOptionsChange] = useDebouncedCallback(onOptionsChange, 200); + + const { showIcon, showBackgroundColor, showBorderColor } = useMemo( + () => getCustomIconOptionFields(options.iconShape), + [options.iconShape], + ); + + const isCustomMarkersStyleAllowed = isNil(options.classify); + + return ( + +
+ + +
+ +

Markers

+ +
+ +
+ +
+ +
+ + {isCustomMarkersStyleAllowed && options.customizeMarkers && ( + + + + + + + + + + + {showIcon && ( + + + + )} + > + + + + + + debouncedOnOptionsChange({ iconFont: event.target.value })} + /> + + + )} + + {showIcon && ( + + + + + + onOptionsChange({ foregroundColor })} + /> + + + + )} + + {showBackgroundColor && ( + + + + + + onOptionsChange({ backgroundColor })} + /> + + + + )} + + {showBorderColor && ( + + + + + + onOptionsChange({ borderColor })} + /> + + + + )} +
+ )} + + ); +} + +StyleSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/map/Editor/index.jsx b/client/app/visualizations/map/Editor/index.jsx new file mode 100644 index 0000000000..d1f97f8696 --- /dev/null +++ b/client/app/visualizations/map/Editor/index.jsx @@ -0,0 +1,32 @@ +import { merge } from 'lodash'; +import React from 'react'; +import Tabs from 'antd/lib/tabs'; +import { EditorPropTypes } from '@/visualizations'; + +import GeneralSettings from './GeneralSettings'; +import GroupsSettings from './GroupsSettings'; +import StyleSettings from './StyleSettings'; + +export default function Editor(props) { + const { options, onOptionsChange } = props; + + const optionsChanged = (newOptions) => { + onOptionsChange(merge({}, options, newOptions)); + }; + + return ( + + General}> + + + Groups}> + + + Style}> + + + + ); +} + +Editor.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/map/Renderer.jsx b/client/app/visualizations/map/Renderer.jsx new file mode 100644 index 0000000000..1d60b7b514 --- /dev/null +++ b/client/app/visualizations/map/Renderer.jsx @@ -0,0 +1,63 @@ +import { isEqual, omit, merge } from 'lodash'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { RendererPropTypes } from '@/visualizations'; + +import prepareData from './prepareData'; +import initMap from './initMap'; + +function useMemoWithDeepCompare(create, inputs) { + const valueRef = useRef(); + const value = useMemo(create, inputs); + if (!isEqual(value, valueRef.current)) { + valueRef.current = value; + } + return valueRef.current; +} + +export default function Renderer({ data, options, onOptionsChange }) { + const [container, setContainer] = useState(null); + + const optionsWithoutBounds = useMemoWithDeepCompare( + () => omit(options, ['bounds']), + [options], + ); + + const groups = useMemo( + () => prepareData(data, optionsWithoutBounds), + [data, optionsWithoutBounds], + ); + + const [map, setMap] = useState(null); + + useEffect(() => { + if (container) { + const _map = initMap(container); + setMap(_map); + return () => { _map.destroy(); }; + } + }, [container]); + + useEffect(() => { + if (map) { + map.updateLayers(groups, optionsWithoutBounds); + } + }, [map, groups, optionsWithoutBounds]); + + useEffect(() => { + if (map) { + map.updateBounds(options.bounds); + } + }, [map, options.bounds]); + + useEffect(() => { + if (map && onOptionsChange) { + map.onBoundsChange = (bounds) => { + onOptionsChange(merge({}, options, { bounds })); + }; + } + }, [map, options, onOptionsChange]); + + return (
); +} + +Renderer.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/map/getOptions.js b/client/app/visualizations/map/getOptions.js new file mode 100644 index 0000000000..a80e761a0e --- /dev/null +++ b/client/app/visualizations/map/getOptions.js @@ -0,0 +1,29 @@ +import { merge } from 'lodash'; + +const DEFAULT_OPTIONS = { + latColName: 'lat', + lonColName: 'lon', + classify: null, + groups: {}, + mapTileUrl: '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + clusterMarkers: true, + customizeMarkers: false, + iconShape: 'marker', + iconFont: 'circle', + foregroundColor: '#ffffff', + backgroundColor: '#356AFF', + borderColor: '#356AFF', + bounds: null, +}; + +export default function getOptions(options) { + options = merge({}, DEFAULT_OPTIONS, options); + options.mapTileUrl = options.mapTileUrl || DEFAULT_OPTIONS.mapTileUrl; + + // Backward compatibility + if (options.classify === 'none') { + options.classify = null; + } + + return options; +} diff --git a/client/app/visualizations/map/index.js b/client/app/visualizations/map/index.js index aa0ab4684d..79a2193ffc 100644 --- a/client/app/visualizations/map/index.js +++ b/client/app/visualizations/map/index.js @@ -1,393 +1,20 @@ -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 = '
    '; - description += `
  • ${lat}, ${lon}`; - - _.each(row, (v, k) => { - if (!(k === latCol || k === lonCol)) { - description += `
  • ${k}: ${v}
  • `; - } - }); - - 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: '© OpenStreetMap 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: `
    ${childCount}
    `, 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, - }); +import getOptions from './getOptions'; +import Renderer from './Renderer'; +import Editor from './Editor'; + +export default function init() { + registerVisualization({ + type: 'MAP', + name: 'Map (Markers)', + getOptions, + Renderer, + Editor, + + defaultColumns: 3, + defaultRows: 8, + minColumns: 2, }); } diff --git a/client/app/visualizations/map/initMap.js b/client/app/visualizations/map/initMap.js new file mode 100644 index 0000000000..905bd8d5e9 --- /dev/null +++ b/client/app/visualizations/map/initMap.js @@ -0,0 +1,201 @@ +import { isFunction, each, map, maxBy, toString } from 'lodash'; +import chroma from 'chroma-js'; +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 resizeObserver from '@/services/resizeObserver'; + +// 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 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 createHeatpointMarker = (lat, lon, color) => L.circleMarker( + [lat, lon], + { fillColor: color, fillOpacity: 0.9, stroke: false }, +); + +L.MarkerClusterIcon = L.DivIcon.extend({ + options: { + color: null, + className: 'marker-cluster', + iconSize: new L.Point(40, 40), + }, + createIcon(...args) { + const color = chroma(this.options.color); + const textColor = maxBy(['#ffffff', '#000000'], c => chroma.contrast(color, c)); + const borderColor = color.alpha(0.4).css(); + const backgroundColor = color.alpha(0.8).css(); + + const icon = L.DivIcon.prototype.createIcon.call(this, ...args); + icon.innerHTML = ` +
    + ${toString(this.options.html)} +
    + `; + icon.style.background = borderColor; + return icon; + }, +}); +L.markerClusterIcon = (...args) => new L.MarkerClusterIcon(...args); + +function createIconMarker(lat, lon, { iconShape, iconFont, foregroundColor, backgroundColor, borderColor }) { + const icon = L.BeautifyIcon.icon({ + iconShape, + icon: iconFont, + iconSize: iconShape === 'rectangle' ? [22, 22] : false, + iconAnchor: iconAnchors[iconShape], + popupAnchor: popupAnchors[iconShape], + prefix: 'fa', + textColor: foregroundColor, + backgroundColor, + borderColor, + }); + + return L.marker([lat, lon], { icon }); +} + +function createMarkerClusterGroup(color) { + return L.markerClusterGroup({ + iconCreateFunction(cluster) { + return L.markerClusterIcon({ color, html: cluster.getChildCount() }); + }, + }); +} + +function createMarkersLayer(options, { color, points }) { + const { classify, clusterMarkers, customizeMarkers } = options; + + const result = clusterMarkers ? createMarkerClusterGroup(color) : L.layerGroup(); + + // create markers + each(points, ({ lat, lon, row }) => { + let marker; + if (classify) { + marker = createHeatpointMarker(lat, lon, color); + } else { + if (customizeMarkers) { + marker = createIconMarker(lat, lon, options); + } else { + marker = L.marker([lat, lon]); + } + } + + marker.bindPopup(` +
      +
    • ${lat}, ${lon} + ${map(row, (v, k) => `
    • ${k}: ${v}
    • `).join('')} +
    + `); + result.addLayer(marker); + }); + + return result; +} + +export default function initMap(container) { + const _map = L.map(container, { + center: [0.0, 0.0], + zoom: 1, + scrollWheelZoom: false, + fullscreenControl: true, + }); + const _tileLayer = L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + }).addTo(_map); + const _markerLayers = L.featureGroup().addTo(_map); + const _layersControls = L.control.layers().addTo(_map); + + let onBoundsChange = () => {}; + + let boundsChangedFromMap = false; + const onMapMoveEnd = () => { + onBoundsChange(_map.getBounds()); + }; + _map.on('focus', () => { + boundsChangedFromMap = true; + _map.on('moveend', onMapMoveEnd); + }); + _map.on('blur', () => { + _map.off('moveend', onMapMoveEnd); + boundsChangedFromMap = false; + }); + + function updateLayers(groups, options) { + _tileLayer.setUrl(options.mapTileUrl); + + _markerLayers.eachLayer((layer) => { + _markerLayers.removeLayer(layer); + _layersControls.removeLayer(layer); + }); + + each(groups, (group) => { + const layer = createMarkersLayer(options, group); + _markerLayers.addLayer(layer); + _layersControls.addOverlay(layer, group.name); + }); + + // hide layers control if it is empty + if (groups.length > 0) { + _layersControls.addTo(_map); + } else { + _layersControls.remove(); + } + } + + function updateBounds(bounds) { + if (!boundsChangedFromMap) { + bounds = bounds ? L.latLngBounds( + [bounds._southWest.lat, bounds._southWest.lng], + [bounds._northEast.lat, bounds._northEast.lng], + ) : _markerLayers.getBounds(); + if (bounds.isValid()) { + _map.fitBounds(bounds, { animate: false, duration: 0 }); + } + } + } + + const unwatchResize = resizeObserver(container, () => { _map.invalidateSize(false); }); + + return { + get onBoundsChange() { + return onBoundsChange; + }, + set onBoundsChange(value) { + onBoundsChange = isFunction(value) ? value : () => {}; + }, + updateLayers, + updateBounds, + destroy() { + unwatchResize(); + _map.remove(); + }, + }; +} diff --git a/client/app/visualizations/map/map-editor.html b/client/app/visualizations/map/map-editor.html deleted file mode 100644 index b7b04b64f1..0000000000 --- a/client/app/visualizations/map/map-editor.html +++ /dev/null @@ -1,166 +0,0 @@ -
    - - -
    -
    - - - {{$select.selected}} - - - - - -
    - -
    - - - {{$select.selected}} - - - - - -
    - -
    - - - {{$select.selected}} - - - - - -
    -
    - -
    -
- - - - - - - - - - -
NameColor
{{name}} - -
- - -
-

Tile

- -
- -
- -
- - -
- -

Marker

- -
- -
- -
-
-
- - - {{$select.selected.value}} - - {{shapes.value}} - - -
-
-
-
- - -
-
-
- -
-
-
- - - - - - - - - - - -
-
-
-
- - - - - - - - - - - -
-
-
- -
-
-
- - - - - - - - - - - -
-
-
-
- diff --git a/client/app/visualizations/map/map.html b/client/app/visualizations/map/map.html deleted file mode 100644 index 056322c888..0000000000 --- a/client/app/visualizations/map/map.html +++ /dev/null @@ -1,3 +0,0 @@ -
-
-
diff --git a/client/app/visualizations/map/prepareData.js b/client/app/visualizations/map/prepareData.js new file mode 100644 index 0000000000..3ec3075ca9 --- /dev/null +++ b/client/app/visualizations/map/prepareData.js @@ -0,0 +1,31 @@ +import d3 from 'd3'; +import { isNil, extend, map, filter, groupBy, omit } from 'lodash'; + +export default function prepareData(data, options) { + const colorScale = d3.scale.category10(); + + const { classify, latColName, lonColName } = options; + + const pointGroups = classify ? groupBy(data.rows, classify) : { All: data.rows }; + + return filter(map(pointGroups, (rows, name) => { + const points = filter(map(rows, (row) => { + const lat = row[latColName]; + const lon = row[lonColName]; + if (isNil(lat) || isNil(lon)) { + return null; + } + return { lat, lon, row: omit(row, [latColName, lonColName]) }; + })); + if (points.length === 0) { + return null; + } + + const result = extend({}, options.groups[name], { name, points }); + if (isNil(result.color)) { + result.color = colorScale(name); + } + + return result; + })); +} diff --git a/client/cypress/integration/visualizations/map_spec.js b/client/cypress/integration/visualizations/map_spec.js new file mode 100644 index 0000000000..e29cf5c62c --- /dev/null +++ b/client/cypress/integration/visualizations/map_spec.js @@ -0,0 +1,97 @@ +/* global cy */ + +import { createQuery } from '../../support/redash-api'; + +const SQL = ` + SELECT 'Israel' AS country, 32.0808800 AS lat, 34.7805700 AS lng UNION ALL + SELECT 'Israel' AS country, 31.7690400 AS lat, 35.2163300 AS lng UNION ALL + SELECT 'Israel' AS country, 32.8184100 AS lat, 34.9885000 AS lng UNION ALL + + SELECT 'Ukraine' AS country, 50.4546600 AS lat, 30.5238000 AS lng UNION ALL + SELECT 'Ukraine' AS country, 49.8382600 AS lat, 24.0232400 AS lng UNION ALL + SELECT 'Ukraine' AS country, 49.9808100 AS lat, 36.2527200 AS lng UNION ALL + + SELECT 'Hungary' AS country, 47.4980100 AS lat, 19.0399100 AS lng +`; + +describe('Map (Markers)', () => { + const viewportWidth = Cypress.config('viewportWidth'); + + beforeEach(() => { + cy.login(); + createQuery({ query: SQL }).then(({ id }) => { + cy.visit(`queries/${id}/source`); + cy.getByTestId('ExecuteButton').click(); + }); + }); + + it('creates Map with groups', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.MAP + `); + + cy.clickThrough(` + Map.EditorTabs.General + Map.Editor.LatitudeColumnName + Map.Editor.LatitudeColumnName.lat + Map.Editor.LongitudeColumnName + Map.Editor.LongitudeColumnName.lng + Map.Editor.GroupBy + Map.Editor.GroupBy.country + `); + + cy.clickThrough('Map.EditorTabs.Groups'); + cy.clickThrough('Map.Editor.Groups.Israel.Color'); + cy.fillInputs({ 'ColorPicker.CustomColor': 'red{enter}' }); + cy.wait(100); // eslint-disable-line cypress/no-unnecessary-waiting + cy.clickThrough('Map.Editor.Groups.Ukraine.Color'); + cy.fillInputs({ 'ColorPicker.CustomColor': 'green{enter}' }); + cy.wait(100); // eslint-disable-line cypress/no-unnecessary-waiting + cy.clickThrough('Map.Editor.Groups.Hungary.Color'); + cy.fillInputs({ 'ColorPicker.CustomColor': 'blue{enter}' }); + + cy.getByTestId('VisualizationPreview').find('.leaflet-control-zoom-in').click(); + + // Wait for proper initialization of visualization + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Map (Markers) with groups', { widths: [viewportWidth] }); + }); + + it('creates Map with custom markers', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.MAP + `); + + cy.clickThrough(` + Map.EditorTabs.General + Map.Editor.LatitudeColumnName + Map.Editor.LatitudeColumnName.lat + Map.Editor.LongitudeColumnName + Map.Editor.LongitudeColumnName.lng + `); + + cy.clickThrough(` + Map.EditorTabs.Style + Map.Editor.ClusterMarkers + Map.Editor.CustomizeMarkers + `); + + cy.fillInputs({ 'Map.Editor.MarkerIcon': 'home' }, { wait: 250 }); // this input is debounced + + cy.clickThrough('Map.Editor.MarkerBackgroundColor'); + cy.fillInputs({ 'ColorPicker.CustomColor': 'red{enter}' }); + cy.wait(100); // eslint-disable-line cypress/no-unnecessary-waiting + cy.clickThrough('Map.Editor.MarkerBorderColor'); + cy.fillInputs({ 'ColorPicker.CustomColor': 'maroon{enter}' }); + + cy.getByTestId('VisualizationPreview').find('.leaflet-control-zoom-in').click(); + + // Wait for proper initialization of visualization + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Visualizations - Map (Markers) with custom markers', { widths: [viewportWidth] }); + }); +});