mirror of
https://github.com/valitydev/redash.git
synced 2024-11-07 09:28:51 +00:00
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:
parent
ef56e4e920
commit
1a95904ffd
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
11
client/app/lib/hooks/useMemoWithDeepCompare.js
Normal file
11
client/app/lib/hooks/useMemoWithDeepCompare.js
Normal 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;
|
||||
}
|
8
client/app/visualizations/choropleth/ColorPalette.js
Normal file
8
client/app/visualizations/choropleth/ColorPalette.js
Normal 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);
|
@ -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;
|
132
client/app/visualizations/choropleth/Editor/ColorsSettings.jsx
Normal file
132
client/app/visualizations/choropleth/Editor/ColorsSettings.jsx
Normal 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;
|
191
client/app/visualizations/choropleth/Editor/FormatSettings.jsx
Normal file
191
client/app/visualizations/choropleth/Editor/FormatSettings.jsx
Normal 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
|
||||
<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;
|
@ -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;
|
15
client/app/visualizations/choropleth/Editor/editor.less
Normal file
15
client/app/visualizations/choropleth/Editor/editor.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
38
client/app/visualizations/choropleth/Editor/index.jsx
Normal file
38
client/app/visualizations/choropleth/Editor/index.jsx
Normal 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;
|
38
client/app/visualizations/choropleth/Editor/utils.js
Normal file
38
client/app/visualizations/choropleth/Editor/utils.js
Normal 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;
|
||||
}
|
30
client/app/visualizations/choropleth/Renderer/Legend.jsx
Normal file
30
client/app/visualizations/choropleth/Renderer/Legend.jsx
Normal 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',
|
||||
};
|
86
client/app/visualizations/choropleth/Renderer/index.jsx
Normal file
86
client/app/visualizations/choropleth/Renderer/index.jsx
Normal 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;
|
185
client/app/visualizations/choropleth/Renderer/initChoropleth.js
Normal file
185
client/app/visualizations/choropleth/Renderer/initChoropleth.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
.choropleth-visualization-legend {
|
||||
padding: 3px;
|
||||
cursor: default;
|
||||
|
||||
> div {
|
||||
line-height: 1;
|
||||
margin: 5px;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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="https://redash.io/help/user-guide/visualizations/formatting-numbers" target="_blank">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>
|
@ -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>
|
37
client/app/visualizations/choropleth/getOptions.js
Normal file
37
client/app/visualizations/choropleth/getOptions.js
Normal 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);
|
||||
}
|
@ -1,368 +1,21 @@
|
||||
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';
|
||||
|
||||
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) => {
|
||||
export default function init() {
|
||||
registerVisualization({
|
||||
type: 'CHOROPLETH',
|
||||
name: 'Map (Choropleth)',
|
||||
getOptions: options => _.merge({}, DEFAULT_OPTIONS, options),
|
||||
Renderer: angular2react('choroplethRenderer', ChoroplethRenderer, $injector),
|
||||
Editor: angular2react('choroplethEditor', ChoroplethEditor, $injector),
|
||||
getOptions,
|
||||
Renderer,
|
||||
Editor,
|
||||
|
||||
defaultColumns: 3,
|
||||
defaultRows: 8,
|
||||
minColumns: 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
Loading…
Reference in New Issue
Block a user