Migrate Choropleth visualization to React (#4313)

* Migrate Choropleth to React: skeleton

* Migrate Choropleth to React: Editor - skeleton

* Choropleth Editor: Bounds tab

* Choropleth Editor: Colors tab

* Choropleth Editor: Format tab

* Choropleth Editor: General tab

* Some refinements

* Migrate Choropleth to React: Renderer

* Refine code

* CR1
This commit is contained in:
Levko Kravets 2019-11-14 15:42:15 +02:00 committed by Arik Fraimovich
parent ef56e4e920
commit 1a95904ffd
21 changed files with 984 additions and 712 deletions

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
import { extend } from 'lodash';
import ColorPalette from '@/visualizations/ColorPalette';
export default extend({
White: '#ffffff',
Black: '#000000',
'Light Gray': '#dddddd',
}, ColorPalette);

View File

@ -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 (
<React.Fragment>
<div className="m-b-15">
<label htmlFor="choropleth-editor-bounds-ne">North-East latitude and longitude</label>
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<InputNumber
id="choropleth-editor-bounds-ne"
className="w-100"
value={bounds[1][0]}
onChange={value => updateBounds(1, 0, value)}
/>
</Grid.Col>
<Grid.Col span={12}>
<InputNumber
className="w-100"
value={bounds[1][1]}
onChange={value => updateBounds(1, 1, value)}
/>
</Grid.Col>
</Grid.Row>
</div>
<div className="m-b-15">
<label htmlFor="choropleth-editor-bounds-sw">South-West latitude and longitude</label>
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<InputNumber
id="choropleth-editor-bounds-sw"
className="w-100"
value={bounds[0][0]}
onChange={value => updateBounds(0, 0, value)}
/>
</Grid.Col>
<Grid.Col span={12}>
<InputNumber
className="w-100"
value={bounds[0][1]}
onChange={value => updateBounds(0, 1, value)}
/>
</Grid.Col>
</Grid.Row>
</div>
</React.Fragment>
);
}
BoundsSettings.propTypes = EditorPropTypes;

View File

@ -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 (
<React.Fragment>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-clustering-mode">Clustering mode</label>
</Grid.Col>
<Grid.Col span={12}>
<Select
id="choropleth-editor-clustering-mode"
className="w-100"
defaultValue={options.clusteringMode}
onChange={clusteringMode => onOptionsChange({ clusteringMode })}
>
<Select.Option value="q">quantile</Select.Option>
<Select.Option value="e">equidistant</Select.Option>
<Select.Option value="k">k-means</Select.Option>
</Select>
</Grid.Col>
</Grid.Row>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-color-steps">Steps</label>
</Grid.Col>
<Grid.Col span={12}>
<InputNumber
id="choropleth-editor-color-steps"
className="w-100"
min={3}
max={11}
defaultValue={options.steps}
onChange={steps => onOptionsChangeDebounced({ steps })}
/>
</Grid.Col>
</Grid.Row>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-color-min">Min Color</label>
</Grid.Col>
<Grid.Col span={12}>
<ColorPicker
id="choropleth-editor-color-min"
interactive
presetColors={ColorPalette}
placement="topRight"
color={options.colors.min}
onChange={min => onOptionsChange({ colors: { min } })}
/>
</Grid.Col>
</Grid.Row>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-color-max">Max Color</label>
</Grid.Col>
<Grid.Col span={12}>
<ColorPicker
id="choropleth-editor-color-max"
interactive
presetColors={ColorPalette}
placement="topRight"
color={options.colors.max}
onChange={max => onOptionsChange({ colors: { max } })}
/>
</Grid.Col>
</Grid.Row>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-color-no-value">No value color</label>
</Grid.Col>
<Grid.Col span={12}>
<ColorPicker
id="choropleth-editor-color-no-value"
interactive
presetColors={ColorPalette}
placement="topRight"
color={options.colors.noValue}
onChange={noValue => onOptionsChange({ colors: { noValue } })}
/>
</Grid.Col>
</Grid.Row>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-color-background">Background color</label>
</Grid.Col>
<Grid.Col span={12}>
<ColorPicker
id="choropleth-editor-color-background"
interactive
presetColors={ColorPalette}
placement="topRight"
color={options.colors.background}
onChange={background => onOptionsChange({ colors: { background } })}
/>
</Grid.Col>
</Grid.Row>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-color-borders">Borders color</label>
</Grid.Col>
<Grid.Col span={12}>
<ColorPicker
id="choropleth-editor-color-borders"
interactive
presetColors={ColorPalette}
placement="topRight"
color={options.colors.borders}
onChange={borders => onOptionsChange({ colors: { borders } })}
/>
</Grid.Col>
</Grid.Row>
</React.Fragment>
);
}
ColorsSettings.propTypes = EditorPropTypes;

View File

@ -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 (
<Popover
placement="topLeft"
arrowPointAtCenter
content={(
<React.Fragment>
<div className="p-b-5">All query result columns can be referenced using <code>{'{{ column_name }}'}</code> syntax.</div>
<div className="p-b-5">Use special names to access additional properties:</div>
<div><code>{'{{ @@value }}'}</code> formatted value;</div>
{mapType === 'countries' && (
<React.Fragment>
<div><code>{'{{ @@name }}'}</code> short country name;</div>
<div><code>{'{{ @@name_long }}'}</code> full country name;</div>
<div><code>{'{{ @@abbrev }}'}</code> abbreviated country name;</div>
<div><code>{'{{ @@iso_a2 }}'}</code> two-letter ISO country code;</div>
<div><code>{'{{ @@iso_a3 }}'}</code> three-letter ISO country code;</div>
<div><code>{'{{ @@iso_n3 }}'}</code> three-digit ISO country code.</div>
</React.Fragment>
)}
{mapType === 'subdiv_japan' && (
<React.Fragment>
<div><code>{'{{ @@name }}'}</code> Prefecture name in English;</div>
<div><code>{'{{ @@name_local }}'}</code> Prefecture name in Kanji;</div>
<div><code>{'{{ @@iso_3166_2 }}'}</code> five-letter ISO subdivision code (JP-xx);</div>
</React.Fragment>
)}
</React.Fragment>
)}
>
<Icon className="m-l-5" type="question-circle" theme="filled" />
</Popover>
);
}
export default function GeneralSettings({ options, onOptionsChange }) {
const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
const templateFormatHint = <TemplateFormatHint mapType={options.mapType} />;
return (
<div className="choropleth-visualization-editor-format-settings">
<Grid.Row gutter={15} className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-value-format">
Value format
<Popover
content={(
<React.Fragment>
Format&nbsp;
<a href="https://redash.io/help/user-guide/visualizations/formatting-numbers" target="_blank" rel="noopener noreferrer">specs.</a>
</React.Fragment>
)}
>
<Icon className="m-l-5" type="question-circle" theme="filled" />
</Popover>
</label>
<Input
id="choropleth-editor-value-format"
className="w-100"
defaultValue={options.valueFormat}
onChange={event => onOptionsChangeDebounced({ valueFormat: event.target.value })}
/>
</Grid.Col>
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-value-placeholder">Value placeholder</label>
<Input
id="choropleth-editor-value-placeholder"
className="w-100"
defaultValue={options.noValuePlaceholder}
onChange={event => onOptionsChangeDebounced({ noValuePlaceholder: event.target.value })}
/>
</Grid.Col>
</Grid.Row>
<div className="m-b-5">
<label htmlFor="choropleth-editor-show-legend">
<Checkbox
id="choropleth-editor-show-legend"
checked={options.legend.visible}
onChange={event => onOptionsChange({ legend: { visible: event.target.checked } })}
/>
<span>Show legend</span>
</label>
</div>
<Grid.Row gutter={15} className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-legend-position">Legend position</label>
<Select
id="choropleth-editor-legend-position"
className="w-100"
disabled={!options.legend.visible}
defaultValue={options.legend.position}
onChange={position => onOptionsChange({ legend: { position } })}
>
<Select.Option value="top-left">top / left</Select.Option>
<Select.Option value="top-right">top / right</Select.Option>
<Select.Option value="bottom-left">bottom / left</Select.Option>
<Select.Option value="bottom-right">bottom / right</Select.Option>
</Select>
</Grid.Col>
<Grid.Col span={12}>
<label htmlFor="choropleth-editor-legend-text-alignment">Legend text alignment</label>
<Radio.Group
id="choropleth-editor-legend-text-alignment"
className="choropleth-visualization-editor-legend-align-text"
disabled={!options.legend.visible}
defaultValue={options.legend.alignText}
onChange={event => onOptionsChange({ legend: { alignText: event.target.value } })}
>
<Tooltip title="Align left" mouseEnterDelay={0} mouseLeaveDelay={0}>
<Radio.Button value="left">
<Icon type="align-left" />
</Radio.Button>
</Tooltip>
<Tooltip title="Align center" mouseEnterDelay={0} mouseLeaveDelay={0}>
<Radio.Button value="center">
<Icon type="align-center" />
</Radio.Button>
</Tooltip>
<Tooltip title="Align right" mouseEnterDelay={0} mouseLeaveDelay={0}>
<Radio.Button value="right">
<Icon type="align-right" />
</Radio.Button>
</Tooltip>
</Radio.Group>
</Grid.Col>
</Grid.Row>
<div className="m-b-5">
<label htmlFor="choropleth-editor-show-tooltip">
<Checkbox
id="choropleth-editor-show-tooltip"
checked={options.tooltip.enabled}
onChange={event => onOptionsChange({ tooltip: { enabled: event.target.checked } })}
/>
<span>Show tooltip</span>
</label>
</div>
<div className="m-b-15">
<label htmlFor="choropleth-editor-tooltip-template">Tooltip template {templateFormatHint}</label>
<Input
id="choropleth-editor-tooltip-template"
className="w-100"
disabled={!options.tooltip.enabled}
defaultValue={options.tooltip.template}
onChange={event => onOptionsChangeDebounced({ tooltip: { template: event.target.value } })}
/>
</div>
<div className="m-b-5">
<label htmlFor="choropleth-editor-show-popup">
<Checkbox
id="choropleth-editor-show-popup"
checked={options.popup.enabled}
onChange={event => onOptionsChange({ popup: { enabled: event.target.checked } })}
/>
<span>Show popup</span>
</label>
</div>
<div className="m-b-15">
<label htmlFor="choropleth-editor-popup-template">Popup template {templateFormatHint}</label>
<Input.TextArea
id="choropleth-editor-popup-template"
className="w-100"
disabled={!options.popup.enabled}
rows={4}
defaultValue={options.popup.template}
onChange={event => onOptionsChangeDebounced({ popup: { template: event.target.value } })}
/>
</div>
</div>
);
}
GeneralSettings.propTypes = EditorPropTypes;

View File

@ -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 (
<React.Fragment>
<div className="m-b-15">
<label htmlFor="choropleth-editor-map-type">Map type</label>
<Select
id="choropleth-editor-map-type"
className="w-100"
defaultValue={options.mapType}
onChange={mapType => handleChangeAndInferType({ mapType })}
>
<Select.Option key="countries">Countries</Select.Option>
<Select.Option key="subdiv_japan">Japan/Prefectures</Select.Option>
</Select>
</div>
<div className="m-b-15">
<label htmlFor="choropleth-editor-key-column">Key column</label>
<Select
id="choropleth-editor-key-column"
className="w-100"
defaultValue={options.countryCodeColumn}
onChange={countryCodeColumn => handleChangeAndInferType({ countryCodeColumn })}
>
{map(data.columns, ({ name }) => (
<Select.Option key={name}>{name}</Select.Option>
))}
</Select>
</div>
<div className="m-b-15">
<label htmlFor="choropleth-editor-key-type">Key type</label>
<Select
id="choropleth-editor-key-type"
className="w-100"
value={options.countryCodeType}
onChange={countryCodeType => onOptionsChange({ countryCodeType })}
>
{map(countryCodeTypes, (name, type) => (
<Select.Option key={type}>{name}</Select.Option>
))}
</Select>
</div>
<div className="m-b-15">
<label htmlFor="choropleth-editor-value-column">Value column</label>
<Select
id="choropleth-editor-value-column"
className="w-100"
defaultValue={options.valueColumn}
onChange={valueColumn => onOptionsChange({ valueColumn })}
>
{map(data.columns, ({ name }) => (
<Select.Option key={name}>{name}</Select.Option>
))}
</Select>
</div>
</React.Fragment>
);
}
GeneralSettings.propTypes = EditorPropTypes;

View File

@ -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 <Input> height
height: 35px;
line-height: 33px;
}
}
}

View File

@ -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 (
<Tabs animated={false} tabBarGutter={0}>
<Tabs.TabPane key="general" tab={<span data-test="Choropleth.EditorTabs.General">General</span>}>
<GeneralSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
<Tabs.TabPane key="colors" tab={<span data-test="Choropleth.EditorTabs.Colors">Colors</span>}>
<ColorsSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
<Tabs.TabPane key="format" tab={<span data-test="Choropleth.EditorTabs.Format">Format</span>}>
<FormatSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
<Tabs.TabPane key="bounds" tab={<span data-test="Choropleth.EditorTabs.Bounds">Bounds</span>}>
<BoundsSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
</Tabs>
);
}
Editor.propTypes = EditorPropTypes;

View File

@ -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;
}

View File

@ -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 (
<div className="choropleth-visualization-legend">
{map(items, (item, index) => (
<div key={`legend${index}`} className="d-flex align-items-center">
<ColorPicker.Swatch color={item.color} className="m-r-5" />
<div className={`flex-fill text-${alignText}`}>{item.text}</div>
</div>
))}
</div>
);
}
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',
};

View File

@ -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 (
<div
className="map-visualization-container"
style={{ background: options.colors.background }}
ref={setContainer}
/>
);
}
Renderer.propTypes = RendererPropTypes;

View File

@ -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(
<Legend
items={map(legend, item => ({ ...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();
},
};
}

View File

@ -0,0 +1,9 @@
.choropleth-visualization-legend {
padding: 3px;
cursor: default;
> div {
line-height: 1;
margin: 5px;
}
}

View File

@ -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;
}

View File

@ -1,260 +0,0 @@
<div>
<ul class="tab-nav">
<li ng-class="{active: $ctrl.currentTab == 'general'}">
<a ng-click="$ctrl.setCurrentTab('general')">General</a>
</li>
<li ng-class="{active: $ctrl.currentTab == 'colors'}">
<a ng-click="$ctrl.setCurrentTab('colors')">Colors</a>
</li>
<li ng-class="{active: $ctrl.currentTab == 'bounds'}">
<a ng-click="$ctrl.setCurrentTab('bounds')">Bounds</a>
</li>
</ul>
<div ng-if="$ctrl.currentTab == 'general'" class="m-t-10 m-b-10">
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Map Type</label>
<select ng-options="key as value for (key, value) in $ctrl.mapTypes"
ng-model="$ctrl.options.mapType" class="form-control"></select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Key column</label>
<select ng-options="col.name as col.name for col in $ctrl.data.columns"
ng-model="$ctrl.options.countryCodeColumn" class="form-control"></select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Key type</label>
<select ng-options="key as value for (key, value) in $ctrl.countryCodeTypes"
ng-model="$ctrl.options.countryCodeType" class="form-control"></select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Value column</label>
<select ng-options="col.name as col.name for col in $ctrl.data.columns"
ng-model="$ctrl.options.valueColumn" class="form-control"></select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label for="legend-value-format">
Value format
<span class="m-l-5"
uib-popover-html="'Format <a href=&quot;https://redash.io/help/user-guide/visualizations/formatting-numbers&quot; target=&quot;_blank&quot;>specs.</a>'"
popover-trigger="'click outsideClick'"><i class="fa fa-question-circle"></i></span>
</label>
<input class="form-control" id="legend-value-format"
ng-model="$ctrl.options.valueFormat" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label for="legend-value-placeholder">Value placeholder</label>
<input class="form-control" id="legend-value-placeholder"
ng-model="$ctrl.options.noValuePlaceholder" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
</div>
<div class="form-group">
<label><input type="checkbox" ng-model="$ctrl.options.legend.visible"> Show legend</label>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label for="legend-position">Legend position</label>
<select class="form-control" id="legend-position"
ng-options="key as value for (key, value) in $ctrl.legendPositions"
ng-model="$ctrl.options.legend.position"
ng-disabled="!$ctrl.options.legend.visible"
></select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label for="legend-position">Legend text alignment</label>
<div class="btn-group d-flex">
<button type="button" class="btn btn-default btn-md flex-fill"
ng-click="$ctrl.options.legend.alignText = 'left'"
ng-class="{active: $ctrl.options.legend.alignText == 'left'}"><i class="fa fa-align-left"></i></button>
<button type="button" class="btn btn-default btn-md flex-fill"
ng-click="$ctrl.options.legend.alignText = 'center'"
ng-class="{active: $ctrl.options.legend.alignText == 'center'}"><i class="fa fa-align-center"></i></button>
<button type="button" class="btn btn-default btn-md flex-fill"
ng-click="$ctrl.options.legend.alignText = 'right'"
ng-class="{active: $ctrl.options.legend.alignText == 'right'}"><i class="fa fa-align-right"></i></button>
</div>
</div>
</div>
</div>
<label><input type="checkbox" ng-model="$ctrl.options.tooltip.enabled"> Show tooltip</label>
<div class="form-group">
<label for="tooltip-template">Tooltip template</label>
<input class="form-control" id="tooltip-template"
ng-model="$ctrl.options.tooltip.template" ng-model-options="{ allowInvalid: true, debounce: 200 }"
ng-disabled="!$ctrl.options.tooltip.enabled">
</div>
<label><input type="checkbox" ng-model="$ctrl.options.popup.enabled"> Show popup</label>
<div class="form-group">
<label for="popup-template">Popup template</label>
<textarea class="form-control resize-vertical" id="popup-template" rows="3"
ng-model="$ctrl.options.popup.template" ng-model-options="{ allowInvalid: true, debounce: 200 }"
ng-disabled="!$ctrl.options.popup.enabled"></textarea>
</div>
<div class="form-group">
<label class="ui-sortable-bypass text-muted" style="font-weight: normal; cursor: pointer;"
uib-popover-html="$ctrl.templateHint"
popover-trigger="'click outsideClick'" popover-placement="top-left">
Format specs <i class="fa fa-question-circle m-l-5"></i>
</label>
</div>
</div>
<div ng-if="$ctrl.currentTab == 'colors'" class="m-t-10 m-b-10">
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Steps</label>
<input type="number" min="3" max="11" class="form-control"
ng-model="$ctrl.options.steps">
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Clustering mode</label>
<select ng-options="key as value for (key, value) in $ctrl.clusteringModes"
ng-model="$ctrl.options.clusteringMode" class="form-control"></select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Min color</label>
<ui-select ng-model="$ctrl.options.colors.min">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Max color</label>
<ui-select ng-model="$ctrl.options.colors.max">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>No value color</label>
<ui-select ng-model="$ctrl.options.colors.noValue">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Background color</label>
<ui-select ng-model="$ctrl.options.colors.background">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Borders color</label>
<ui-select ng-model="$ctrl.options.colors.borders">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
</div>
<div ng-if="$ctrl.currentTab == 'bounds'" class="m-t-10 m-b-10">
<div class="form-group">
<label>North-East latitude and longitude</label>
<div class="row">
<div class="col-xs-6">
<input class="form-control" type="text"
ng-model="$ctrl.options.bounds[1][0]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
<div class="col-xs-6">
<input class="form-control" type="text"
ng-model="$ctrl.options.bounds[1][1]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
</div>
<div class="form-group">
<label>South-West latitude and longitude</label>
<div class="row">
<div class="col-xs-6">
<input class="form-control" type="text"
ng-model="$ctrl.options.bounds[0][0]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
<div class="col-xs-6">
<input class="form-control" type="text"
ng-model="$ctrl.options.bounds[0][1]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
</div>
</div>
</div>

View File

@ -1,11 +0,0 @@
<div class="map-visualization-container">
<div resize-event="handleResize()" ng-style="{ background: $ctrl.options.colors.background }"></div>
<div ng-if="$ctrl.options.legend.visible && ($ctrl.legendItems.length > 0)"
class="leaflet-bar map-custom-control" ng-class="$ctrl.options.legend.position"
>
<div ng-repeat="item in $ctrl.legendItems" class="d-flex align-items-center">
<color-box color="item.color" class="m-0" style="line-height: 1px"></color-box>
<div class="flex-fill text-{{ $ctrl.options.legend.alignText }}">{{ $ctrl.formatValue(item.limit) }}</div>
</div>
</div>
</div>

View File

@ -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: '<b>{{ @@name }}</b>: {{ @@value }}',
},
popup: {
enabled: true,
template: 'Country: <b>{{ @@name_long }} ({{ @@iso_a2 }})</b>\n<br>\nValue: <b>{{ @@value }}</b>',
},
};
export default function getOptions(options) {
return merge({}, DEFAULT_OPTIONS, options);
}

View File

@ -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 getOptions from './getOptions';
import Renderer from './Renderer';
import Editor from './Editor';
import template from './choropleth.html';
import editorTemplate from './choropleth-editor.html';
export default function init() {
registerVisualization({
type: 'CHOROPLETH',
name: 'Map (Choropleth)',
getOptions,
Renderer,
Editor,
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: '<b>{{ @@name }}</b>: {{ @@value }}',
},
popup: {
enabled: true,
template: 'Country: <b>{{ @@name_long }} ({{ @@iso_a2 }})</b>\n<br>\nValue: <b>{{ @@value }}</b>',
},
};
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 => `
<div class="p-b-5">All query result columns can be referenced using <code>{{ column_name }}</code> syntax.</div>
<div class="p-b-5">Use special names to access additional properties:</div>
<div><code>{{ @@value }}</code> formatted value;</div>
${propDescription}
<div class="p-t-5">This syntax is applicable to tooltip and popup templates.</div>
`;
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 = `
<div><code>{{ @@name }}</code> Prefecture name in English;</div>
<div><code>{{ @@name_local }}</code> Prefecture name in Kanji;</div>
<div><code>{{ @@iso_3166_2 }}</code> five-letter ISO subdivision code (JP-xx);</div>
`;
this.countryCodeTypes = {
name: 'Name',
name_local: 'Name (local)',
iso_3166_2: 'ISO-3166-2',
};
break;
case 'countries':
propDescription = `
<div><code>{{ @@name }}</code> short country name;</div>
<div><code>{{ @@name_long }}</code> full country name;</div>
<div><code>{{ @@abbrev }}</code> abbreviated country name;</div>
<div><code>{{ @@iso_a2 }}</code> two-letter ISO country code;</div>
<div><code>{{ @@iso_a3 }}</code> three-letter ISO country code;</div>
<div><code>{{ @@iso_n3 }}</code> three-digit ISO country code.</div>
`;
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,
});
defaultColumns: 3,
defaultRows: 8,
minColumns: 2,
});
}