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 } })}
+ />
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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) => (
+
+ ))}
+
+ );
+}
+
+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(
+