diff --git a/client/app/assets/less/inc/visualizations/map.less b/client/app/assets/less/inc/visualizations/map.less index 39d0bc54bb..85d6c49d32 100644 --- a/client/app/assets/less/inc/visualizations/map.less +++ b/client/app/assets/less/inc/visualizations/map.less @@ -6,32 +6,4 @@ height: 100%; z-index: 0; } - - .map-custom-control.leaflet-bar { - background: #fff; - padding: 10px; - margin: 10px; - position: absolute; - z-index: 1; - - &.top-left { - left: 0; - top: 0; - } - - &.top-right { - right: 0; - top: 0; - } - - &.bottom-left { - left: 0; - bottom: 0; - } - - &.bottom-right { - right: 0; - bottom: 0; - } - } } diff --git a/client/app/lib/hooks/useMemoWithDeepCompare.js b/client/app/lib/hooks/useMemoWithDeepCompare.js new file mode 100644 index 0000000000..d299596a33 --- /dev/null +++ b/client/app/lib/hooks/useMemoWithDeepCompare.js @@ -0,0 +1,11 @@ +import { isEqual } from 'lodash'; +import { useMemo, useRef } from 'react'; + +export default function useMemoWithDeepCompare(create, inputs) { + const valueRef = useRef(); + const value = useMemo(create, inputs); + if (!isEqual(value, valueRef.current)) { + valueRef.current = value; + } + return valueRef.current; +} diff --git a/client/app/visualizations/choropleth/ColorPalette.js b/client/app/visualizations/choropleth/ColorPalette.js new file mode 100644 index 0000000000..fd06852a7e --- /dev/null +++ b/client/app/visualizations/choropleth/ColorPalette.js @@ -0,0 +1,8 @@ +import { extend } from 'lodash'; +import ColorPalette from '@/visualizations/ColorPalette'; + +export default extend({ + White: '#ffffff', + Black: '#000000', + 'Light Gray': '#dddddd', +}, ColorPalette); diff --git a/client/app/visualizations/choropleth/Editor/BoundsSettings.jsx b/client/app/visualizations/choropleth/Editor/BoundsSettings.jsx new file mode 100644 index 0000000000..29b5f0c0af --- /dev/null +++ b/client/app/visualizations/choropleth/Editor/BoundsSettings.jsx @@ -0,0 +1,80 @@ +import { isFinite, cloneDeep } from 'lodash'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import InputNumber from 'antd/lib/input-number'; +import * as Grid from 'antd/lib/grid'; +import { EditorPropTypes } from '@/visualizations'; + +export default function BoundsSettings({ options, onOptionsChange }) { + // Bounds may be changed in editor or on preview (by drag/zoom map). + // Changes from preview does not come frequently (only when user release mouse button), + // but changes from editor should be debounced. + // Therefore this component has intermediate state to hold immediate user input, + // which is updated from `options.bounds` and by inputs immediately on user input, + // but `onOptionsChange` event is debounced and uses last value from internal state. + + const [bounds, setBounds] = useState(options.bounds); + const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200); + + useEffect(() => { + setBounds(options.bounds); + }, [options.bounds]); + + const updateBounds = useCallback((i, j, v) => { + v = parseFloat(v); // InputNumber may emit `null` and empty strings instead of numbers + if (isFinite(v)) { + const newBounds = cloneDeep(bounds); + newBounds[i][j] = v; + setBounds(newBounds); + onOptionsChangeDebounced({ bounds: newBounds }); + } + }, [bounds]); + + return ( + +
+ + + + updateBounds(1, 0, value)} + /> + + + updateBounds(1, 1, value)} + /> + + +
+ +
+ + + + updateBounds(0, 0, value)} + /> + + + updateBounds(0, 1, value)} + /> + + +
+
+ ); +} + +BoundsSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx b/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx new file mode 100644 index 0000000000..6dec6113b8 --- /dev/null +++ b/client/app/visualizations/choropleth/Editor/ColorsSettings.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import Select from 'antd/lib/select'; +import InputNumber from 'antd/lib/input-number'; +import * as Grid from 'antd/lib/grid'; +import ColorPicker from '@/components/ColorPicker'; +import { EditorPropTypes } from '@/visualizations'; +import ColorPalette from '../ColorPalette'; + +export default function ColorsSettings({ options, onOptionsChange }) { + const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200); + + return ( + + + + + + + + + + + + + + + + onOptionsChangeDebounced({ steps })} + /> + + + + + + + + + onOptionsChange({ colors: { min } })} + /> + + + + + + + + + onOptionsChange({ colors: { max } })} + /> + + + + + + + + + onOptionsChange({ colors: { noValue } })} + /> + + + + + + + + + onOptionsChange({ colors: { background } })} + /> + + + + + + + + + onOptionsChange({ colors: { borders } })} + /> + + + + ); +} + +ColorsSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/choropleth/Editor/FormatSettings.jsx b/client/app/visualizations/choropleth/Editor/FormatSettings.jsx new file mode 100644 index 0000000000..56731e0035 --- /dev/null +++ b/client/app/visualizations/choropleth/Editor/FormatSettings.jsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import Input from 'antd/lib/input'; +import Checkbox from 'antd/lib/checkbox'; +import Select from 'antd/lib/select'; +import Radio from 'antd/lib/radio'; +import Tooltip from 'antd/lib/tooltip'; +import Popover from 'antd/lib/popover'; +import Icon from 'antd/lib/icon'; +import * as Grid from 'antd/lib/grid'; +import { EditorPropTypes } from '@/visualizations'; + +function TemplateFormatHint({ mapType }) { // eslint-disable-line react/prop-types + return ( + +
All query result columns can be referenced using {'{{ column_name }}'} syntax.
+
Use special names to access additional properties:
+
{'{{ @@value }}'} formatted value;
+ {mapType === 'countries' && ( + +
{'{{ @@name }}'} short country name;
+
{'{{ @@name_long }}'} full country name;
+
{'{{ @@abbrev }}'} abbreviated country name;
+
{'{{ @@iso_a2 }}'} two-letter ISO country code;
+
{'{{ @@iso_a3 }}'} three-letter ISO country code;
+
{'{{ @@iso_n3 }}'} three-digit ISO country code.
+
+ )} + {mapType === 'subdiv_japan' && ( + +
{'{{ @@name }}'} Prefecture name in English;
+
{'{{ @@name_local }}'} Prefecture name in Kanji;
+
{'{{ @@iso_3166_2 }}'} five-letter ISO subdivision code (JP-xx);
+
+ )} + + )} + > + +
+ ); +} + +export default function GeneralSettings({ options, onOptionsChange }) { + const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200); + + const templateFormatHint = ; + + return ( +
+ + + + onOptionsChangeDebounced({ valueFormat: event.target.value })} + /> + + + + onOptionsChangeDebounced({ noValuePlaceholder: event.target.value })} + /> + + + +
+ +
+ + + + + + + + + onOptionsChange({ legend: { alignText: event.target.value } })} + > + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + onOptionsChangeDebounced({ tooltip: { template: event.target.value } })} + /> +
+ +
+ +
+ +
+ + onOptionsChangeDebounced({ popup: { template: event.target.value } })} + /> +
+
+ ); +} + +GeneralSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/choropleth/Editor/GeneralSettings.jsx b/client/app/visualizations/choropleth/Editor/GeneralSettings.jsx new file mode 100644 index 0000000000..c04bfb0f21 --- /dev/null +++ b/client/app/visualizations/choropleth/Editor/GeneralSettings.jsx @@ -0,0 +1,99 @@ +import { map } from 'lodash'; +import React, { useMemo } from 'react'; +import Select from 'antd/lib/select'; +import { EditorPropTypes } from '@/visualizations'; +import { inferCountryCodeType } from './utils'; + +export default function GeneralSettings({ options, data, onOptionsChange }) { + const countryCodeTypes = useMemo(() => { + switch (options.mapType) { + case 'countries': + return { + name: 'Short name', + name_long: 'Full name', + abbrev: 'Abbreviated name', + iso_a2: 'ISO code (2 letters)', + iso_a3: 'ISO code (3 letters)', + iso_n3: 'ISO code (3 digits)', + }; + case 'subdiv_japan': + return { + name: 'Name', + name_local: 'Name (local)', + iso_3166_2: 'ISO-3166-2', + }; + default: + return {}; + } + }, [options.mapType]); + + const handleChangeAndInferType = (newOptions) => { + newOptions.countryCodeType = inferCountryCodeType( + newOptions.mapType || options.mapType, + data ? data.rows : [], + newOptions.countryCodeColumn || options.countryCodeColumn, + ) || options.countryCodeType; + onOptionsChange(newOptions); + }; + + return ( + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +} + +GeneralSettings.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/choropleth/Editor/editor.less b/client/app/visualizations/choropleth/Editor/editor.less new file mode 100644 index 0000000000..27c315e84c --- /dev/null +++ b/client/app/visualizations/choropleth/Editor/editor.less @@ -0,0 +1,15 @@ +.choropleth-visualization-editor-format-settings { + .choropleth-visualization-editor-legend-align-text { + display: flex; + align-items: stretch; + justify-content: stretch; + + .ant-radio-button-wrapper { + flex-grow: 1; + text-align: center; + // fit height + height: 35px; + line-height: 33px; + } + } +} diff --git a/client/app/visualizations/choropleth/Editor/index.jsx b/client/app/visualizations/choropleth/Editor/index.jsx new file mode 100644 index 0000000000..ec18d09173 --- /dev/null +++ b/client/app/visualizations/choropleth/Editor/index.jsx @@ -0,0 +1,38 @@ +import { merge } from 'lodash'; +import React from 'react'; +import Tabs from 'antd/lib/tabs'; +import { EditorPropTypes } from '@/visualizations'; + +import GeneralSettings from './GeneralSettings'; +import ColorsSettings from './ColorsSettings'; +import FormatSettings from './FormatSettings'; +import BoundsSettings from './BoundsSettings'; + +import './editor.less'; + +export default function Editor(props) { + const { options, onOptionsChange } = props; + + const optionsChanged = (newOptions) => { + onOptionsChange(merge({}, options, newOptions)); + }; + + return ( + + General}> + + + Colors}> + + + Format}> + + + Bounds}> + + + + ); +} + +Editor.propTypes = EditorPropTypes; diff --git a/client/app/visualizations/choropleth/Editor/utils.js b/client/app/visualizations/choropleth/Editor/utils.js new file mode 100644 index 0000000000..9acb211ce7 --- /dev/null +++ b/client/app/visualizations/choropleth/Editor/utils.js @@ -0,0 +1,38 @@ +/* eslint-disable import/prefer-default-export */ + +import _ from 'lodash'; + +export function inferCountryCodeType(mapType, data, countryCodeField) { + const regexMap = { + countries: { + iso_a2: /^[a-z]{2}$/i, + iso_a3: /^[a-z]{3}$/i, + iso_n3: /^[0-9]{3}$/i, + }, + subdiv_japan: { + name: /^[a-z]+$/i, + name_local: /^[\u3400-\u9FFF\uF900-\uFAFF]|[\uD840-\uD87F][\uDC00-\uDFFF]+$/i, + iso_3166_2: /^JP-[0-9]{2}$/i, + }, + }; + + const regex = regexMap[mapType]; + + const initState = _.mapValues(regex, () => 0); + + const result = _.chain(data) + .reduce((memo, item) => { + const value = item[countryCodeField]; + if (_.isString(value)) { + _.each(regex, (r, k) => { + memo[k] += r.test(value) ? 1 : 0; + }); + } + return memo; + }, initState) + .toPairs() + .reduce((memo, item) => (item[1] > memo[1] ? item : memo)) + .value(); + + return (result[1] / data.length) >= 0.9 ? result[0] : null; +} diff --git a/client/app/visualizations/choropleth/Renderer/Legend.jsx b/client/app/visualizations/choropleth/Renderer/Legend.jsx new file mode 100644 index 0000000000..5bf76436e9 --- /dev/null +++ b/client/app/visualizations/choropleth/Renderer/Legend.jsx @@ -0,0 +1,30 @@ +import { map } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import ColorPicker from '@/components/ColorPicker'; + +export default function Legend({ items, alignText }) { + return ( +
+ {map(items, (item, index) => ( +
+ +
{item.text}
+
+ ))} +
+ ); +} + +Legend.propTypes = { + items: PropTypes.arrayOf(PropTypes.shape({ + color: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + })), + alignText: PropTypes.oneOf(['left', 'center', 'right']), +}; + +Legend.defaultProps = { + items: [], + alignText: 'left', +}; diff --git a/client/app/visualizations/choropleth/Renderer/index.jsx b/client/app/visualizations/choropleth/Renderer/index.jsx new file mode 100644 index 0000000000..d2bdf61f9e --- /dev/null +++ b/client/app/visualizations/choropleth/Renderer/index.jsx @@ -0,0 +1,86 @@ +import { omit, merge } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import { RendererPropTypes } from '@/visualizations'; +import { $http } from '@/services/ng'; +import useMemoWithDeepCompare from '@/lib/hooks/useMemoWithDeepCompare'; + +import initChoropleth from './initChoropleth'; +import { prepareData } from './utils'; +import './renderer.less'; + +import countriesDataUrl from '../maps/countries.geo.json'; +import subdivJapanDataUrl from '../maps/japan.prefectures.geo.json'; + +function getDataUrl(type) { + switch (type) { + case 'countries': return countriesDataUrl; + case 'subdiv_japan': return subdivJapanDataUrl; + default: return null; + } +} + +export default function Renderer({ data, options, onOptionsChange }) { + const [container, setContainer] = useState(null); + const [geoJson, setGeoJson] = useState(null); + + const optionsWithoutBounds = useMemoWithDeepCompare( + () => omit(options, ['bounds']), + [options], + ); + + const [map, setMap] = useState(null); + + useEffect(() => { + let cancelled = false; + + $http.get(getDataUrl(options.mapType)).then((response) => { + if (!cancelled) { + setGeoJson(response.data); + } + }); + + return () => { cancelled = true; }; + }, [options.mapType]); + + useEffect(() => { + if (container) { + const _map = initChoropleth(container); + setMap(_map); + return () => { _map.destroy(); }; + } + }, [container]); + + useEffect(() => { + if (map) { + map.updateLayers( + geoJson, + prepareData(data.rows, optionsWithoutBounds.countryCodeColumn, optionsWithoutBounds.valueColumn), + options, // detect changes for all options except bounds, but pass them all! + ); + } + }, [map, geoJson, data, 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/choropleth/Renderer/initChoropleth.js b/client/app/visualizations/choropleth/Renderer/initChoropleth.js new file mode 100644 index 0000000000..e029b1eb1d --- /dev/null +++ b/client/app/visualizations/choropleth/Renderer/initChoropleth.js @@ -0,0 +1,185 @@ +import { isFunction, isObject, isArray, map } from 'lodash'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import 'leaflet-fullscreen'; +import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; +import { formatSimpleTemplate } from '@/lib/value-format'; +import { $sanitize } from '@/services/ng'; +import resizeObserver from '@/services/resizeObserver'; +import { + createNumberFormatter, + createScale, + darkenColor, + getColorByValue, + getValueForFeature, + prepareFeatureProperties, +} from './utils'; +import Legend from './Legend'; + +const CustomControl = L.Control.extend({ + options: { + position: 'topright', + }, + onAdd() { + const div = document.createElement('div'); + div.className = 'leaflet-bar leaflet-custom-toolbar'; + div.style.background = '#fff'; + div.style.backgroundClip = 'padding-box'; + return div; + }, + onRemove() { + ReactDOM.unmountComponentAtNode(this.getContainer()); + }, +}); + +function prepareLayer({ feature, layer, data, options, limits, colors, formatValue }) { + const value = getValueForFeature(feature, data, options.countryCodeType); + const valueFormatted = formatValue(value); + const featureData = prepareFeatureProperties( + feature, + valueFormatted, + data, + options.countryCodeType, + ); + const color = getColorByValue(value, limits, colors, options.colors.noValue); + + layer.setStyle({ + color: options.colors.borders, + weight: 1, + fillColor: color, + fillOpacity: 1, + }); + + if (options.tooltip.enabled) { + layer.bindTooltip($sanitize(formatSimpleTemplate( + options.tooltip.template, + featureData, + )), { sticky: true }); + } + + if (options.popup.enabled) { + layer.bindPopup($sanitize(formatSimpleTemplate( + options.popup.template, + featureData, + ))); + } + + layer.on('mouseover', () => { + layer.setStyle({ + weight: 2, + fillColor: darkenColor(color), + }); + }); + layer.on('mouseout', () => { + layer.setStyle({ + weight: 1, + fillColor: color, + }); + }); +} + +export default function initChoropleth(container) { + const _map = L.map(container, { + center: [0.0, 0.0], + zoom: 1, + zoomSnap: 0, + scrollWheelZoom: false, + maxBoundsViscosity: 1, + attributionControl: false, + fullscreenControl: true, + }); + let _choropleth = null; + const _legend = new CustomControl(); + + let onBoundsChange = () => {}; + function handleMapBoundsChange() { + const bounds = _map.getBounds(); + onBoundsChange([ + [bounds._southWest.lat, bounds._southWest.lng], + [bounds._northEast.lat, bounds._northEast.lng], + ]); + } + + let boundsChangedFromMap = false; + const onMapMoveEnd = () => { handleMapBoundsChange(); }; + _map.on('focus', () => { + boundsChangedFromMap = true; + _map.on('moveend', onMapMoveEnd); + }); + _map.on('blur', () => { + _map.off('moveend', onMapMoveEnd); + boundsChangedFromMap = false; + }); + + function updateLayers(geoJson, data, options) { + _map.eachLayer(layer => _map.removeLayer(layer)); + _map.removeControl(_legend); + + if (!isObject(geoJson) || !isArray(geoJson.features)) { + _choropleth = null; + _map.setMaxBounds(null); + return; + } + + const { limits, colors, legend } = createScale(geoJson.features, data, options); + const formatValue = createNumberFormatter(options.valueFormat, options.noValuePlaceholder); + + _choropleth = L.geoJSON(geoJson, { + onEachFeature(feature, layer) { + prepareLayer({ feature, layer, data, options, limits, colors, formatValue }); + }, + }).addTo(_map); + + const bounds = _choropleth.getBounds(); + _map.fitBounds(options.bounds || bounds, { animate: false, duration: 0 }); + _map.setMaxBounds(bounds); + + // send updated bounds to editor; delay this to avoid infinite update loop + setTimeout(() => { + handleMapBoundsChange(); + }, 10); + + // update legend + if (options.legend.visible && (legend.length > 0)) { + _legend.setPosition(options.legend.position.replace('-', '')); + _map.addControl(_legend); + ReactDOM.render( + ({ ...item, text: formatValue(item.limit) }))} + alignText={options.legend.alignText} + />, + _legend.getContainer(), + ); + } + } + + function updateBounds(bounds) { + if (!boundsChangedFromMap) { + const layerBounds = _choropleth ? _choropleth.getBounds() : _map.getBounds(); + bounds = bounds ? L.latLngBounds(bounds[0], bounds[1]) : layerBounds; + 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.removeControl(_legend); // _map.remove() does not cleanup controls - bug in Leaflet? + _map.remove(); + }, + }; +} diff --git a/client/app/visualizations/choropleth/Renderer/renderer.less b/client/app/visualizations/choropleth/Renderer/renderer.less new file mode 100644 index 0000000000..46f680aeb5 --- /dev/null +++ b/client/app/visualizations/choropleth/Renderer/renderer.less @@ -0,0 +1,9 @@ +.choropleth-visualization-legend { + padding: 3px; + cursor: default; + + > div { + line-height: 1; + margin: 5px; + } +} diff --git a/client/app/visualizations/choropleth/utils.js b/client/app/visualizations/choropleth/Renderer/utils.js similarity index 59% rename from client/app/visualizations/choropleth/utils.js rename to client/app/visualizations/choropleth/Renderer/utils.js index 4b65171e59..1fc9b6ed55 100644 --- a/client/app/visualizations/choropleth/utils.js +++ b/client/app/visualizations/choropleth/Renderer/utils.js @@ -1,13 +1,7 @@ +import { isString, isObject, isFinite, each, map, extend, uniq, filter, first } from 'lodash'; import chroma from 'chroma-js'; -import _ from 'lodash'; import { createNumberFormatter as createFormatter } from '@/lib/value-format'; -export const AdditionalColors = { - White: '#ffffff', - Black: '#000000', - 'Light Gray': '#dddddd', -}; - export function darkenColor(color) { return chroma(color).darken().hex(); } @@ -15,7 +9,7 @@ export function darkenColor(color) { export function createNumberFormatter(format, placeholder) { const formatter = createFormatter(format); return (value) => { - if (_.isNumber(value) && isFinite(value)) { + if (isFinite(value)) { return formatter(value); } return placeholder; @@ -28,7 +22,7 @@ export function prepareData(data, countryCodeField, valueField) { } const result = {}; - _.each(data, (item) => { + each(data, (item) => { if (item[countryCodeField]) { const value = parseFloat(item[valueField]); result[item[countryCodeField]] = { @@ -43,24 +37,24 @@ export function prepareData(data, countryCodeField, valueField) { export function prepareFeatureProperties(feature, valueFormatted, data, countryCodeType) { const result = {}; - _.each(feature.properties, (value, key) => { + each(feature.properties, (value, key) => { result['@@' + key] = value; }); result['@@value'] = valueFormatted; const datum = data[feature.properties[countryCodeType]] || {}; - return _.extend(result, datum.item); + return extend(result, datum.item); } export function getValueForFeature(feature, data, countryCodeType) { const code = feature.properties[countryCodeType]; - if (_.isString(code) && _.isObject(data[code])) { + if (isString(code) && isObject(data[code])) { return data[code].value; } return undefined; } export function getColorByValue(value, limits, colors, defaultColor) { - if (_.isNumber(value) && isFinite(value)) { + if (isFinite(value)) { for (let i = 0; i < limits.length; i += 1) { if (value <= limits[i]) { return colors[i]; @@ -72,9 +66,9 @@ export function getColorByValue(value, limits, colors, defaultColor) { export function createScale(features, data, options) { // Calculate limits - const values = _.uniq(_.filter( - _.map(features, feature => getValueForFeature(feature, data, options.countryCodeType)), - _.isNumber, + const values = uniq(filter( + map(features, feature => getValueForFeature(feature, data, options.countryCodeType)), + isFinite, )); if (values.length === 0) { return { @@ -90,7 +84,7 @@ export function createScale(features, data, options) { colors: [options.colors.max], legend: [{ color: options.colors.max, - limit: _.first(values), + limit: first(values), }], }; } @@ -101,45 +95,10 @@ export function createScale(features, data, options) { .colors(limits.length); // Group values for legend - const legend = _.map(colors, (color, index) => ({ + const legend = map(colors, (color, index) => ({ color, limit: limits[index], })).reverse(); return { limits, colors, legend }; } - -export function inferCountryCodeType(mapType, data, countryCodeField) { - const regexMap = { - countries: { - iso_a2: /^[a-z]{2}$/i, - iso_a3: /^[a-z]{3}$/i, - iso_n3: /^[0-9]{3}$/i, - }, - subdiv_japan: { - name: /^[a-z]+$/i, - name_local: /^[\u3400-\u9FFF\uF900-\uFAFF]|[\uD840-\uD87F][\uDC00-\uDFFF]+$/i, - iso_3166_2: /^JP-[0-9]{2}$/i, - }, - }; - - const regex = regexMap[mapType]; - - const initState = _.mapValues(regex, () => 0); - - const result = _.chain(data) - .reduce((memo, item) => { - const value = item[countryCodeField]; - if (_.isString(value)) { - _.each(regex, (r, k) => { - memo[k] += r.test(value) ? 1 : 0; - }); - } - return memo; - }, initState) - .toPairs() - .reduce((memo, item) => (item[1] > memo[1] ? item : memo)) - .value(); - - return (result[1] / data.length) >= 0.9 ? result[0] : null; -} diff --git a/client/app/visualizations/choropleth/choropleth-editor.html b/client/app/visualizations/choropleth/choropleth-editor.html deleted file mode 100644 index 589bf7caf5..0000000000 --- a/client/app/visualizations/choropleth/choropleth-editor.html +++ /dev/null @@ -1,260 +0,0 @@ -
- -
-
-
-
- - -
-
-
-
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
-
- -
- -
-
-
-
- - -
-
-
-
- -
- - - -
-
-
-
- - -
- - -
- - -
- - -
- -
- -
-
- -
-
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
- - - - - - - - - - - -
-
- -
-
- - - - - - - - - - - -
-
- -
-
- - - - - - - - - - - -
-
-
- -
-
-
- - - - - - - - - - - -
-
- -
-
- - - - - - - - - - - -
-
-
-
- -
-
- -
-
- -
-
- -
-
-
- -
- -
-
- -
-
- -
-
-
-
-
diff --git a/client/app/visualizations/choropleth/choropleth.html b/client/app/visualizations/choropleth/choropleth.html deleted file mode 100644 index bd1c8093cd..0000000000 --- a/client/app/visualizations/choropleth/choropleth.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
-
-
- -
{{ $ctrl.formatValue(item.limit) }}
-
-
-
diff --git a/client/app/visualizations/choropleth/getOptions.js b/client/app/visualizations/choropleth/getOptions.js new file mode 100644 index 0000000000..9615647100 --- /dev/null +++ b/client/app/visualizations/choropleth/getOptions.js @@ -0,0 +1,37 @@ +import { merge } from 'lodash'; +import ColorPalette from './ColorPalette'; + +const DEFAULT_OPTIONS = { + mapType: 'countries', + countryCodeColumn: '', + countryCodeType: 'iso_a3', + valueColumn: '', + clusteringMode: 'e', + steps: 5, + valueFormat: '0,0.00', + noValuePlaceholder: 'N/A', + colors: { + min: ColorPalette['Light Blue'], + max: ColorPalette['Dark Blue'], + background: ColorPalette.White, + borders: ColorPalette.White, + noValue: ColorPalette['Light Gray'], + }, + legend: { + visible: true, + position: 'bottom-left', + alignText: 'right', + }, + tooltip: { + enabled: true, + template: '{{ @@name }}: {{ @@value }}', + }, + popup: { + enabled: true, + template: 'Country: {{ @@name_long }} ({{ @@iso_a2 }})\n
\nValue: {{ @@value }}', + }, +}; + +export default function getOptions(options) { + return merge({}, DEFAULT_OPTIONS, options); +} diff --git a/client/app/visualizations/choropleth/index.js b/client/app/visualizations/choropleth/index.js index 16751d4a9e..fbb5651c01 100644 --- a/client/app/visualizations/choropleth/index.js +++ b/client/app/visualizations/choropleth/index.js @@ -1,367 +1,20 @@ -import _ from 'lodash'; -import L from 'leaflet'; -import 'leaflet/dist/leaflet.css'; -import { formatSimpleTemplate } from '@/lib/value-format'; -import 'leaflet-fullscreen'; -import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; -import { angular2react } from 'angular2react'; import { registerVisualization } from '@/visualizations'; -import ColorPalette from '@/visualizations/ColorPalette'; -import { - AdditionalColors, - darkenColor, - createNumberFormatter, - prepareData, - getValueForFeature, - createScale, - prepareFeatureProperties, - getColorByValue, - inferCountryCodeType, -} from './utils'; - -import template from './choropleth.html'; -import editorTemplate from './choropleth-editor.html'; - -import countriesDataUrl from './countries.geo.json'; -import subdivJapanDataUrl from './japan.prefectures.geo.json'; - -export const ChoroplethPalette = _.extend({}, AdditionalColors, ColorPalette); - -const DEFAULT_OPTIONS = { - mapType: 'countries', - countryCodeColumn: '', - countryCodeType: 'iso_a3', - valueColumn: '', - clusteringMode: 'e', - steps: 5, - valueFormat: '0,0.00', - noValuePlaceholder: 'N/A', - colors: { - min: ChoroplethPalette['Light Blue'], - max: ChoroplethPalette['Dark Blue'], - background: ChoroplethPalette.White, - borders: ChoroplethPalette.White, - noValue: ChoroplethPalette['Light Gray'], - }, - legend: { - visible: true, - position: 'bottom-left', - alignText: 'right', - }, - tooltip: { - enabled: true, - template: '{{ @@name }}: {{ @@value }}', - }, - popup: { - enabled: true, - template: 'Country: {{ @@name_long }} ({{ @@iso_a2 }})\n
\nValue: {{ @@value }}', - }, -}; - -const loadCountriesData = _.bind(function loadCountriesData($http, url) { - if (!this[url]) { - this[url] = $http.get(url).then(response => response.data); - } - return this[url]; -}, {}); - -const ChoroplethRenderer = { - template, - bindings: { - data: '<', - options: '<', - onOptionsChange: '<', - }, - controller($scope, $element, $sanitize, $http) { - let countriesData = null; - let map = null; - let choropleth = null; - let mapMoveLock = false; - - const onMapMoveStart = () => { - mapMoveLock = true; - }; - - const onMapMoveEnd = () => { - const bounds = map.getBounds(); - this.options.bounds = [ - [bounds._southWest.lat, bounds._southWest.lng], - [bounds._northEast.lat, bounds._northEast.lng], - ]; - if (this.onOptionsChange) { - this.onOptionsChange(this.options); - } - $scope.$applyAsync(() => { - mapMoveLock = false; - }); - }; - - const updateBounds = ({ disableAnimation = false } = {}) => { - if (mapMoveLock) { - return; - } - if (map && choropleth) { - const bounds = this.options.bounds || choropleth.getBounds(); - const options = disableAnimation ? { - animate: false, - duration: 0, - } : null; - map.fitBounds(bounds, options); - } - }; - - const getDataUrl = (type) => { - switch (type) { - case 'countries': return countriesDataUrl; - case 'subdiv_japan': return subdivJapanDataUrl; - default: return ''; - } - }; - - let dataUrl = getDataUrl(this.options.mapType); - - const render = () => { - if (map) { - map.remove(); - map = null; - choropleth = null; - } - if (!countriesData) { - return; - } - - this.formatValue = createNumberFormatter( - this.options.valueFormat, - this.options.noValuePlaceholder, - ); - - const data = prepareData(this.data.rows, this.options.countryCodeColumn, this.options.valueColumn); - - const { limits, colors, legend } = createScale(countriesData.features, data, this.options); - - // Update data for legend block - this.legendItems = legend; - - choropleth = L.geoJson(countriesData, { - onEachFeature: (feature, layer) => { - const value = getValueForFeature(feature, data, this.options.countryCodeType); - const valueFormatted = this.formatValue(value); - const featureData = prepareFeatureProperties( - feature, - valueFormatted, - data, - this.options.countryCodeType, - ); - const color = getColorByValue(value, limits, colors, this.options.colors.noValue); - - layer.setStyle({ - color: this.options.colors.borders, - weight: 1, - fillColor: color, - fillOpacity: 1, - }); - - if (this.options.tooltip.enabled) { - layer.bindTooltip($sanitize(formatSimpleTemplate( - this.options.tooltip.template, - featureData, - )), { sticky: true }); - } - - if (this.options.popup.enabled) { - layer.bindPopup($sanitize(formatSimpleTemplate( - this.options.popup.template, - featureData, - ))); - } - - layer.on('mouseover', () => { - layer.setStyle({ - weight: 2, - fillColor: darkenColor(color), - }); - }); - layer.on('mouseout', () => { - layer.setStyle({ - weight: 1, - fillColor: color, - }); - }); - }, - }); - - const choroplethBounds = choropleth.getBounds(); - - map = L.map($element[0].children[0].children[0], { - center: choroplethBounds.getCenter(), - zoom: 1, - zoomSnap: 0, - layers: [choropleth], - scrollWheelZoom: false, - maxBounds: choroplethBounds, - maxBoundsViscosity: 1, - attributionControl: false, - fullscreenControl: true, - }); - - map.on('focus', () => { - map.on('movestart', onMapMoveStart); - map.on('moveend', onMapMoveEnd); - }); - map.on('blur', () => { - map.off('movestart', onMapMoveStart); - map.off('moveend', onMapMoveEnd); - }); - - updateBounds({ disableAnimation: true }); - }; - - const load = () => { - loadCountriesData($http, dataUrl).then((data) => { - if (_.isObject(data)) { - countriesData = data; - render(); - } - }); - }; - - load(); - - - $scope.handleResize = _.debounce(() => { - if (map) { - map.invalidateSize(false); - updateBounds({ disableAnimation: true }); - } - }, 50); - - $scope.$watch('$ctrl.data', render); - $scope.$watch(() => _.omit(this.options, 'bounds', 'mapType'), render, true); - $scope.$watch('$ctrl.options.bounds', updateBounds, true); - $scope.$watch('$ctrl.options.mapType', () => { - dataUrl = getDataUrl(this.options.mapType); - load(); - }, true); - }, -}; - -const ChoroplethEditor = { - template: editorTemplate, - bindings: { - data: '<', - options: '<', - onOptionsChange: '<', - }, - controller($scope) { - this.currentTab = 'general'; - this.setCurrentTab = (tab) => { - this.currentTab = tab; - }; - - this.colors = ChoroplethPalette; - - this.mapTypes = { - countries: 'Countries', - subdiv_japan: 'Japan/Prefectures', - }; - - this.clusteringModes = { - q: 'quantile', - e: 'equidistant', - k: 'k-means', - }; - - this.legendPositions = { - 'top-left': 'top / left', - 'top-right': 'top / right', - 'bottom-left': 'bottom / left', - 'bottom-right': 'bottom / right', - }; - - this.countryCodeTypes = {}; - - this.templateHintFormatter = propDescription => ` -
All query result columns can be referenced using {{ column_name }} syntax.
-
Use special names to access additional properties:
-
{{ @@value }} formatted value;
- ${propDescription} -
This syntax is applicable to tooltip and popup templates.
- `; - - const updateCountryCodeType = () => { - this.options.countryCodeType = inferCountryCodeType( - this.options.mapType, - this.data ? this.data.rows : [], - this.options.countryCodeColumn, - ) || this.options.countryCodeType; - }; - - const populateCountryCodeTypes = () => { - let propDescription = ''; - switch (this.options.mapType) { - case 'subdiv_japan': - propDescription = ` -
{{ @@name }} Prefecture name in English;
-
{{ @@name_local }} Prefecture name in Kanji;
-
{{ @@iso_3166_2 }} five-letter ISO subdivision code (JP-xx);
- `; - this.countryCodeTypes = { - name: 'Name', - name_local: 'Name (local)', - iso_3166_2: 'ISO-3166-2', - }; - break; - case 'countries': - propDescription = ` -
{{ @@name }} short country name;
-
{{ @@name_long }} full country name;
-
{{ @@abbrev }} abbreviated country name;
-
{{ @@iso_a2 }} two-letter ISO country code;
-
{{ @@iso_a3 }} three-letter ISO country code;
-
{{ @@iso_n3 }} three-digit ISO country code.
- `; - this.countryCodeTypes = { - name: 'Short name', - name_long: 'Full name', - abbrev: 'Abbreviated name', - iso_a2: 'ISO code (2 letters)', - iso_a3: 'ISO code (3 letters)', - iso_n3: 'ISO code (3 digits)', - }; - break; - default: - this.countryCodeTypes = {}; - } - this.templateHint = this.templateHintFormatter(propDescription); - }; - - $scope.$watch('$ctrl.options.mapType', populateCountryCodeTypes); - $scope.$watch('$ctrl.options.countryCodeColumn', updateCountryCodeType); - $scope.$watch('$ctrl.data', updateCountryCodeType); - - $scope.$watch('$ctrl.options', (options) => { - this.onOptionsChange(options); - }, true); - }, -}; - -export default function init(ngModule) { - ngModule.component('choroplethRenderer', ChoroplethRenderer); - ngModule.component('choroplethEditor', ChoroplethEditor); - - ngModule.run(($injector) => { - registerVisualization({ - type: 'CHOROPLETH', - name: 'Map (Choropleth)', - getOptions: options => _.merge({}, DEFAULT_OPTIONS, options), - Renderer: angular2react('choroplethRenderer', ChoroplethRenderer, $injector), - Editor: angular2react('choroplethEditor', ChoroplethEditor, $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: 'CHOROPLETH', + name: 'Map (Choropleth)', + getOptions, + Renderer, + Editor, + + defaultColumns: 3, + defaultRows: 8, + minColumns: 2, }); } diff --git a/client/app/visualizations/choropleth/countries.geo.json b/client/app/visualizations/choropleth/maps/countries.geo.json similarity index 100% rename from client/app/visualizations/choropleth/countries.geo.json rename to client/app/visualizations/choropleth/maps/countries.geo.json diff --git a/client/app/visualizations/choropleth/japan.prefectures.geo.json b/client/app/visualizations/choropleth/maps/japan.prefectures.geo.json similarity index 100% rename from client/app/visualizations/choropleth/japan.prefectures.geo.json rename to client/app/visualizations/choropleth/maps/japan.prefectures.geo.json