Some Choropleth improvements/refactoring (#5186)

* Directly map query results column to GeoJSON property

* Use cache for geoJson requests

* Don't handle bounds changes while loading geoJson data

* Choropleth: fix map "jumping" on load; don't save bounds if user didn't edit them; refine code a bit

* Improve cache

* Optimize Japan Perfectures map (remove irrelevant GeoJson properties)

* Improve getOptions for Choropleth; remove unused code

* Fix test

* Add US states map

* Convert USA map to Albers projection

* Allow to specify user-friendly field names for maps
This commit is contained in:
Levko Kravets 2020-09-24 14:39:09 +03:00 committed by GitHub
parent 210008c714
commit a473611cb0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 408 additions and 292 deletions

View File

@ -6,6 +6,7 @@ import { Renderer as VisRenderer, Editor as VisEditor, updateVisualizationsSetti
import { clientConfig } from "@/services/auth";
import countriesDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/countries.geo.json";
import usaDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/usa-albers.geo.json";
import subdivJapanDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/japan.prefectures.geo.json";
function wrapComponentWithSettings(WrappedComponent) {
@ -17,10 +18,40 @@ function wrapComponentWithSettings(WrappedComponent) {
countries: {
name: "Countries",
url: countriesDataUrl,
fieldNames: {
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)",
},
},
usa: {
name: "USA",
url: usaDataUrl,
fieldNames: {
name: "Name",
ns_code: "National Standard ANSI Code (8-character)",
geoid: "Geographic ID",
usps_abbrev: "USPS Abbreviation",
fips_code: "FIPS Code (2-character)",
},
},
subdiv_japan: {
name: "Japan/Prefectures",
url: subdivJapanDataUrl,
fieldNames: {
name: "Name",
name_alt: "Name (alternative)",
name_local: "Name (local)",
iso_3166_2: "ISO-3166-2",
postal: "Postal Code",
type: "Type",
type_en: "Type (EN)",
region: "Region",
region_code: "Region Code",
},
},
},
...pick(clientConfig, [

View File

@ -48,11 +48,11 @@ describe("Choropleth", () => {
cy.clickThrough(`
VisualizationEditor.Tabs.General
Choropleth.Editor.MapType
Choropleth.Editor.MapType.Countries
Choropleth.Editor.MapType.countries
Choropleth.Editor.KeyColumn
Choropleth.Editor.KeyColumn.name
Choropleth.Editor.KeyType
Choropleth.Editor.KeyType.name
Choropleth.Editor.TargetField
Choropleth.Editor.TargetField.name
Choropleth.Editor.ValueColumn
Choropleth.Editor.ValueColumn.value
`);

View File

@ -0,0 +1,39 @@
import { each, debounce } from "lodash";
export default function createReferenceCountingCache({ cleanupDelay = 2000 } = {}) {
const items = {};
const cleanup = debounce(() => {
each(items, (item, key) => {
if (item.refCount <= 0) {
delete items[key];
}
});
}, cleanupDelay);
function get(key, getter) {
if (!items[key]) {
items[key] = {
value: getter(),
refCount: 0,
};
}
const item = items[key];
item.refCount += 1;
return item.value;
}
function release(key) {
if (items[key]) {
const item = items[key];
if (item.refCount > 0) {
item.refCount -= 1;
if (item.refCount <= 0) {
cleanup();
}
}
}
}
return { get, release };
}

View File

@ -1,10 +1,13 @@
import { isFinite, cloneDeep } from "lodash";
import { isArray, isFinite, cloneDeep } from "lodash";
import React, { useState, useEffect, useCallback } from "react";
import { useDebouncedCallback } from "use-debounce";
import * as Grid from "antd/lib/grid";
import { Section, InputNumber, ControlLabel } from "@/components/visualizations/editor";
import { EditorPropTypes } from "@/visualizations/prop-types";
import useLoadGeoJson from "../hooks/useLoadGeoJson";
import { getGeoJsonBounds } from "./utils";
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),
@ -16,9 +19,20 @@ export default function BoundsSettings({ options, onOptionsChange }) {
const [bounds, setBounds] = useState(options.bounds);
const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
const [geoJson] = useLoadGeoJson(options.mapType);
// `options.bounds` could be empty only if user didn't edit bounds yet - through preview or in this editor.
// In this case we should keep empty bounds value because it tells renderer to fit map every time.
useEffect(() => {
setBounds(options.bounds);
}, [options.bounds]);
if (options.bounds) {
setBounds(options.bounds);
} else {
const defaultBounds = getGeoJsonBounds(geoJson);
if (defaultBounds) {
setBounds(defaultBounds);
}
}
}, [options.bounds, geoJson]);
const updateBounds = useCallback(
(i, j, v) => {
@ -33,29 +47,47 @@ export default function BoundsSettings({ options, onOptionsChange }) {
[bounds, onOptionsChangeDebounced]
);
const boundsAvailable = isArray(bounds);
return (
<React.Fragment>
<Section>
<ControlLabel label="North-East latitude and longitude">
<ControlLabel label="North-East Latitude and Longitude">
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<InputNumber value={bounds[1][0]} onChange={value => updateBounds(1, 0, value)} />
<InputNumber
disabled={!boundsAvailable}
value={boundsAvailable ? bounds[1][0] : undefined}
onChange={value => updateBounds(1, 0, value)}
/>
</Grid.Col>
<Grid.Col span={12}>
<InputNumber value={bounds[1][1]} onChange={value => updateBounds(1, 1, value)} />
<InputNumber
disabled={!boundsAvailable}
value={boundsAvailable ? bounds[1][1] : undefined}
onChange={value => updateBounds(1, 1, value)}
/>
</Grid.Col>
</Grid.Row>
</ControlLabel>
</Section>
<Section>
<ControlLabel label="South-West latitude and longitude">
<ControlLabel label="South-West Latitude and Longitude">
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<InputNumber value={bounds[0][0]} onChange={value => updateBounds(0, 0, value)} />
<InputNumber
disabled={!boundsAvailable}
value={boundsAvailable ? bounds[0][0] : undefined}
onChange={value => updateBounds(0, 0, value)}
/>
</Grid.Col>
<Grid.Col span={12}>
<InputNumber value={bounds[0][1]} onChange={value => updateBounds(0, 1, value)} />
<InputNumber
disabled={!boundsAvailable}
value={boundsAvailable ? bounds[0][1] : undefined}
onChange={value => updateBounds(0, 1, value)}
/>
</Grid.Col>
</Grid.Row>
</ControlLabel>

View File

@ -12,7 +12,7 @@ export default function ColorsSettings({ options, onOptionsChange }) {
<Section>
<Select
layout="horizontal"
label="Clustering mode"
label="Clustering Mode"
data-test="Choropleth.Editor.ClusteringMode"
defaultValue={options.clusteringMode}
onChange={clusteringMode => onOptionsChange({ clusteringMode })}>
@ -71,7 +71,7 @@ export default function ColorsSettings({ options, onOptionsChange }) {
<Section>
<ColorPicker
layout="horizontal"
label="No value color"
label="No Value Color"
interactive
presetColors={ColorPalette}
placement="topRight"
@ -85,7 +85,7 @@ export default function ColorsSettings({ options, onOptionsChange }) {
<Section>
<ColorPicker
layout="horizontal"
label="Background color"
label="Background Color"
interactive
presetColors={ColorPalette}
placement="topRight"
@ -99,7 +99,7 @@ export default function ColorsSettings({ options, onOptionsChange }) {
<Section>
<ColorPicker
layout="horizontal"
label="Borders color"
label="Borders Color"
interactive
presetColors={ColorPalette}
placement="topRight"

View File

@ -1,4 +1,6 @@
import React from "react";
import { map } from "lodash";
import React, { useMemo } from "react";
import PropTypes from "prop-types";
import { useDebouncedCallback } from "use-debounce";
import * as Grid from "antd/lib/grid";
import {
@ -12,49 +14,29 @@ import {
} from "@/components/visualizations/editor";
import { EditorPropTypes } from "@/visualizations/prop-types";
function TemplateFormatHint({ mapType }) {
// eslint-disable-line react/prop-types
import useLoadGeoJson from "../hooks/useLoadGeoJson";
import { getGeoJsonFields } from "./utils";
function TemplateFormatHint({ geoJsonProperties }) {
return (
<ContextHelp placement="topLeft" arrowPointAtCenter>
<div style={{ paddingBottom: 5 }}>
All query result columns can be referenced using <code>{"{{ column_name }}"}</code> syntax.
<div>
All query result columns can be referenced using <code>{"{{ column_name }}"}</code> syntax.
</div>
<div>
Use <code>{"{{ @@value }}"}</code> to access formatted value.
</div>
</div>
<div style={{ paddingBottom: 5 }}>Use special names to access additional properties:</div>
<div>
<code>{"{{ @@value }}"}</code> formatted value;
</div>
{mapType === "countries" && (
{geoJsonProperties.length > 0 && (
<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 className="p-b-5">GeoJSON properties could be accessed by these names:</div>
<div style={{ maxHeight: 300, overflow: "auto" }}>
{map(geoJsonProperties, property => (
<div key={property}>
<code>{`{{ @@${property}}}`}</code>
</div>
))}
</div>
</React.Fragment>
)}
@ -62,10 +44,20 @@ function TemplateFormatHint({ mapType }) {
);
}
TemplateFormatHint.propTypes = {
geoJsonProperties: PropTypes.arrayOf(PropTypes.string),
};
TemplateFormatHint.defaultProps = {
geoJsonProperties: [],
};
export default function GeneralSettings({ options, onOptionsChange }) {
const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
const [geoJson] = useLoadGeoJson(options.mapType);
const geoJsonFields = useMemo(() => getGeoJsonFields(geoJson), [geoJson]);
const templateFormatHint = <TemplateFormatHint mapType={options.mapType} />;
const templateFormatHint = <TemplateFormatHint geoJsonProperties={geoJsonFields} />;
return (
<div className="choropleth-visualization-editor-format-settings">
@ -75,7 +67,7 @@ export default function GeneralSettings({ options, onOptionsChange }) {
<Input
label={
<React.Fragment>
Value format
Value Format
<ContextHelp.NumberFormatSpecs />
</React.Fragment>
}
@ -86,7 +78,7 @@ export default function GeneralSettings({ options, onOptionsChange }) {
</Grid.Col>
<Grid.Col span={12}>
<Input
label="Value placeholder"
label="Value Placeholder"
data-test="Choropleth.Editor.ValuePlaceholder"
defaultValue={options.noValuePlaceholder}
onChange={event => onOptionsChangeDebounced({ noValuePlaceholder: event.target.value })}
@ -100,7 +92,7 @@ export default function GeneralSettings({ options, onOptionsChange }) {
data-test="Choropleth.Editor.LegendVisibility"
checked={options.legend.visible}
onChange={event => onOptionsChange({ legend: { visible: event.target.checked } })}>
Show legend
Show Legend
</Checkbox>
</Section>
@ -108,7 +100,7 @@ export default function GeneralSettings({ options, onOptionsChange }) {
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<Select
label="Legend position"
label="Legend Position"
data-test="Choropleth.Editor.LegendPosition"
disabled={!options.legend.visible}
defaultValue={options.legend.position}
@ -130,7 +122,7 @@ export default function GeneralSettings({ options, onOptionsChange }) {
<Grid.Col span={12}>
<TextAlignmentSelect
data-test="Choropleth.Editor.LegendTextAlignment"
label="Legend text alignment"
label="Legend Text Alignment"
disabled={!options.legend.visible}
defaultValue={options.legend.alignText}
onChange={event => onOptionsChange({ legend: { alignText: event.target.value } })}
@ -144,13 +136,13 @@ export default function GeneralSettings({ options, onOptionsChange }) {
data-test="Choropleth.Editor.TooltipEnabled"
checked={options.tooltip.enabled}
onChange={event => onOptionsChange({ tooltip: { enabled: event.target.checked } })}>
Show tooltip
Show Tooltip
</Checkbox>
</Section>
<Section>
<Input
label={<React.Fragment>Tooltip template {templateFormatHint}</React.Fragment>}
label={<React.Fragment>Tooltip Template {templateFormatHint}</React.Fragment>}
data-test="Choropleth.Editor.TooltipTemplate"
disabled={!options.tooltip.enabled}
defaultValue={options.tooltip.template}
@ -163,13 +155,13 @@ export default function GeneralSettings({ options, onOptionsChange }) {
data-test="Choropleth.Editor.PopupEnabled"
checked={options.popup.enabled}
onChange={event => onOptionsChange({ popup: { enabled: event.target.checked } })}>
Show popup
Show Popup
</Checkbox>
</Section>
<Section>
<TextArea
label={<React.Fragment>Popup template {templateFormatHint}</React.Fragment>}
label={<React.Fragment>Popup Template {templateFormatHint}</React.Fragment>}
data-test="Choropleth.Editor.PopupTemplate"
disabled={!options.popup.enabled}
rows={4}

View File

@ -1,91 +1,86 @@
import { map } from "lodash";
import React, { useMemo } from "react";
import { isString, map, filter, get } from "lodash";
import React, { useMemo, useCallback } from "react";
import * as Grid from "antd/lib/grid";
import { EditorPropTypes } from "@/visualizations/prop-types";
import { Section, Select } from "@/components/visualizations/editor";
import { inferCountryCodeType } from "./utils";
import { visualizationsSettings } from "@/visualizations/visualizationsSettings";
import useLoadGeoJson from "../hooks/useLoadGeoJson";
import { getGeoJsonFields } 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 [geoJson, isLoadingGeoJson] = useLoadGeoJson(options.mapType);
const geoJsonFields = useMemo(() => getGeoJsonFields(geoJson), [geoJson]);
const handleChangeAndInferType = newOptions => {
newOptions.countryCodeType =
inferCountryCodeType(
newOptions.mapType || options.mapType,
data ? data.rows : [],
newOptions.countryCodeColumn || options.countryCodeColumn
) || options.countryCodeType;
onOptionsChange(newOptions);
};
// While geoJson is loading - show last selected field in select
const targetFields = isLoadingGeoJson ? filter([options.targetField], isString) : geoJsonFields;
const fieldNames = get(visualizationsSettings, `choroplethAvailableMaps.${options.mapType}.fieldNames`, {});
const handleMapChange = useCallback(
mapType => {
onOptionsChange({ mapType: mapType || null });
},
[onOptionsChange]
);
return (
<React.Fragment>
<Section>
<Select
label="Map type"
label="Map"
data-test="Choropleth.Editor.MapType"
defaultValue={options.mapType}
onChange={mapType => handleChangeAndInferType({ mapType })}>
<Select.Option key="countries" data-test="Choropleth.Editor.MapType.Countries">
Countries
</Select.Option>
<Select.Option key="subdiv_japan" data-test="Choropleth.Editor.MapType.Japan">
Japan/Prefectures
</Select.Option>
</Select>
</Section>
<Section>
<Select
label="Key column"
data-test="Choropleth.Editor.KeyColumn"
defaultValue={options.countryCodeColumn}
onChange={countryCodeColumn => handleChangeAndInferType({ countryCodeColumn })}>
{map(data.columns, ({ name }) => (
<Select.Option key={name} data-test={`Choropleth.Editor.KeyColumn.${name}`}>
{name}
onChange={handleMapChange}>
{map(visualizationsSettings.choroplethAvailableMaps, (_, mapType) => (
<Select.Option key={mapType} data-test={`Choropleth.Editor.MapType.${mapType}`}>
{get(visualizationsSettings, `choroplethAvailableMaps.${mapType}.name`, mapType)}
</Select.Option>
))}
</Select>
</Section>
<Section>
<Select
label="Key type"
data-test="Choropleth.Editor.KeyType"
value={options.countryCodeType}
onChange={countryCodeType => onOptionsChange({ countryCodeType })}>
{map(countryCodeTypes, (name, type) => (
<Select.Option key={type} data-test={`Choropleth.Editor.KeyType.${type}`}>
{name}
</Select.Option>
))}
</Select>
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<Select
label="Key Column"
className="w-100"
data-test="Choropleth.Editor.KeyColumn"
disabled={data.columns.length === 0}
defaultValue={options.keyColumn}
onChange={keyColumn => onOptionsChange({ keyColumn })}>
{map(data.columns, ({ name }) => (
<Select.Option key={name} data-test={`Choropleth.Editor.KeyColumn.${name}`}>
{name}
</Select.Option>
))}
</Select>
</Grid.Col>
<Grid.Col span={12}>
<Select
label="Target Field"
className="w-100"
data-test="Choropleth.Editor.TargetField"
disabled={isLoadingGeoJson || targetFields.length === 0}
loading={isLoadingGeoJson}
value={options.targetField}
onChange={targetField => onOptionsChange({ targetField })}>
{map(targetFields, field => (
<Select.Option key={field} data-test={`Choropleth.Editor.TargetField.${field}`}>
{fieldNames[field] || field}
</Select.Option>
))}
</Select>
</Grid.Col>
</Grid.Row>
</Section>
<Section>
<Select
label="Value column"
label="Value Column"
data-test="Choropleth.Editor.ValueColumn"
disabled={data.columns.length === 0}
defaultValue={options.valueColumn}
onChange={valueColumn => onOptionsChange({ valueColumn })}>
{map(data.columns, ({ name }) => (

View File

@ -1,38 +1,28 @@
/* eslint-disable import/prefer-default-export */
import { isObject, isArray, reduce, keys, uniq } from "lodash";
import L from "leaflet";
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,
export function getGeoJsonFields(geoJson) {
const features = isObject(geoJson) && isArray(geoJson.features) ? geoJson.features : [];
return reduce(
features,
(result, feature) => {
const properties = isObject(feature) && isObject(feature.properties) ? feature.properties : {};
return uniq([...result, ...keys(properties)]);
},
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;
[]
);
}
export function getGeoJsonBounds(geoJson) {
if (isObject(geoJson)) {
const layer = L.geoJSON(geoJson);
const bounds = layer.getBounds();
if (bounds.isValid()) {
return [
[bounds._southWest.lat, bounds._southWest.lng],
[bounds._northEast.lat, bounds._northEast.lng],
];
}
}
return null;
}

View File

@ -1,43 +1,26 @@
import { omit, merge, get } from "lodash";
import axios from "axios";
import React, { useState, useEffect } from "react";
import { omit, noop } from "lodash";
import React, { useState, useEffect, useRef } from "react";
import { RendererPropTypes } from "@/visualizations/prop-types";
import useMemoWithDeepCompare from "@/lib/hooks/useMemoWithDeepCompare";
import { visualizationsSettings } from "@/visualizations/visualizationsSettings";
import useLoadGeoJson from "../hooks/useLoadGeoJson";
import initChoropleth from "./initChoropleth";
import { prepareData } from "./utils";
import "./renderer.less";
function getDataUrl(type) {
return get(visualizationsSettings, `choroplethAvailableMaps.${type}.url`, undefined);
}
export default function Renderer({ data, options, onOptionsChange }) {
const [container, setContainer] = useState(null);
const [geoJson, setGeoJson] = useState(null);
const [geoJson] = useLoadGeoJson(options.mapType);
const onBoundsChangeRef = useRef();
onBoundsChangeRef.current = onOptionsChange ? bounds => onOptionsChange({ ...options, bounds }) : noop;
const optionsWithoutBounds = useMemoWithDeepCompare(() => omit(options, ["bounds"]), [options]);
const [map, setMap] = useState(null);
useEffect(() => {
let cancelled = false;
axios.get(getDataUrl(options.mapType)).then(({ data }) => {
if (!cancelled) {
setGeoJson(data);
}
});
return () => {
cancelled = true;
};
}, [options.mapType]);
useEffect(() => {
if (container) {
const _map = initChoropleth(container);
const _map = initChoropleth(container, (...args) => onBoundsChangeRef.current(...args));
setMap(_map);
return () => {
_map.destroy();
@ -49,24 +32,17 @@ export default function Renderer({ data, options, onOptionsChange }) {
if (map) {
map.updateLayers(
geoJson,
prepareData(data.rows, optionsWithoutBounds.countryCodeColumn, optionsWithoutBounds.valueColumn),
prepareData(data.rows, optionsWithoutBounds.keyColumn, optionsWithoutBounds.valueColumn),
options // detect changes for all options except bounds, but pass them all!
);
}
}, [map, geoJson, data.rows, optionsWithoutBounds]); // eslint-disable-line react-hooks/exhaustive-deps
// This may come only from editor
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 (

View File

@ -35,9 +35,9 @@ const CustomControl = L.Control.extend({
});
function prepareLayer({ feature, layer, data, options, limits, colors, formatValue }) {
const value = getValueForFeature(feature, data, options.countryCodeType);
const value = getValueForFeature(feature, data, options.targetField);
const valueFormatted = formatValue(value);
const featureData = prepareFeatureProperties(feature, valueFormatted, data, options.countryCodeType);
const featureData = prepareFeatureProperties(feature, valueFormatted, data, options.targetField);
const color = getColorByValue(value, limits, colors, options.colors.noValue);
layer.setStyle({
@ -69,7 +69,20 @@ function prepareLayer({ feature, layer, data, options, limits, colors, formatVal
});
}
export default function initChoropleth(container) {
function validateBounds(bounds, fallbackBounds) {
if (bounds) {
bounds = L.latLngBounds(bounds[0], bounds[1]);
if (bounds.isValid()) {
return bounds;
}
}
if (fallbackBounds && fallbackBounds.isValid()) {
return fallbackBounds;
}
return null;
}
export default function initChoropleth(container, onBoundsChange) {
const _map = L.map(container, {
center: [0.0, 0.0],
zoom: 1,
@ -82,13 +95,14 @@ export default function initChoropleth(container) {
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],
]);
if (isFunction(onBoundsChange)) {
const bounds = _map.getBounds();
onBoundsChange([
[bounds._southWest.lat, bounds._southWest.lng],
[bounds._northEast.lat, bounds._northEast.lng],
]);
}
}
let boundsChangedFromMap = false;
@ -123,14 +137,13 @@ export default function initChoropleth(container) {
},
}).addTo(_map);
const bounds = _choropleth.getBounds();
_map.fitBounds(options.bounds || bounds, { animate: false, duration: 0 });
_map.setMaxBounds(bounds);
const mapBounds = _choropleth.getBounds();
const bounds = validateBounds(options.bounds, mapBounds);
_map.fitBounds(bounds, { animate: false, duration: 0 });
// send updated bounds to editor; delay this to avoid infinite update loop
setTimeout(() => {
handleMapBoundsChange();
}, 10);
// equivalent to `_map.setMaxBounds(mapBounds)` but without animation
_map.options.maxBounds = mapBounds;
_map.panInsideBounds(mapBounds, { animate: false, duration: 0 });
// update legend
if (options.legend.visible && legend.length > 0) {
@ -149,8 +162,8 @@ export default function initChoropleth(container) {
function updateBounds(bounds) {
if (!boundsChangedFromMap) {
const layerBounds = _choropleth ? _choropleth.getBounds() : _map.getBounds();
bounds = bounds ? L.latLngBounds(bounds[0], bounds[1]) : layerBounds;
if (bounds.isValid()) {
bounds = validateBounds(bounds, layerBounds);
if (bounds) {
_map.fitBounds(bounds, { animate: false, duration: 0 });
}
}
@ -161,12 +174,6 @@ export default function initChoropleth(container) {
});
return {
get onBoundsChange() {
return onBoundsChange;
},
set onBoundsChange(value) {
onBoundsChange = isFunction(value) ? value : () => {};
},
updateLayers,
updateBounds,
destroy() {

View File

@ -18,17 +18,17 @@ export function createNumberFormatter(format, placeholder) {
};
}
export function prepareData(data, countryCodeField, valueField) {
if (!countryCodeField || !valueField) {
export function prepareData(data, keyColumn, valueColumn) {
if (!keyColumn || !valueColumn) {
return {};
}
const result = {};
each(data, item => {
if (item[countryCodeField]) {
const value = parseFloat(item[valueField]);
result[item[countryCodeField]] = {
code: item[countryCodeField],
if (item[keyColumn]) {
const value = parseFloat(item[valueColumn]);
result[item[keyColumn]] = {
code: item[keyColumn],
value: isFinite(value) ? value : undefined,
item,
};
@ -37,18 +37,18 @@ export function prepareData(data, countryCodeField, valueField) {
return result;
}
export function prepareFeatureProperties(feature, valueFormatted, data, countryCodeType) {
export function prepareFeatureProperties(feature, valueFormatted, data, targetField) {
const result = {};
each(feature.properties, (value, key) => {
result["@@" + key] = value;
});
result["@@value"] = valueFormatted;
const datum = data[feature.properties[countryCodeType]] || {};
const datum = data[feature.properties[targetField]] || {};
return extend(result, datum.item);
}
export function getValueForFeature(feature, data, countryCodeType) {
const code = feature.properties[countryCodeType];
export function getValueForFeature(feature, data, targetField) {
const code = feature.properties[targetField];
if (isString(code) && isObject(data[code])) {
return data[code].value;
}
@ -70,7 +70,7 @@ export function createScale(features, data, options) {
// Calculate limits
const values = uniq(
filter(
map(features, feature => getValueForFeature(feature, data, options.countryCodeType)),
map(features, feature => getValueForFeature(feature, data, options.targetField)),
isFinite
)
);

View File

@ -1,11 +1,16 @@
import { merge } from "lodash";
import { isNil, merge, first, keys, get } from "lodash";
import { visualizationsSettings } from "@/visualizations/visualizationsSettings";
import ColorPalette from "./ColorPalette";
function getDefaultMap() {
return first(keys(visualizationsSettings.choroplethAvailableMaps)) || null;
}
const DEFAULT_OPTIONS = {
mapType: "countries",
countryCodeColumn: "",
countryCodeType: "iso_a3",
valueColumn: "",
keyColumn: null,
targetField: null,
valueColumn: null,
clusteringMode: "e",
steps: 5,
valueFormat: "0,0.00",
@ -33,5 +38,26 @@ const DEFAULT_OPTIONS = {
};
export default function getOptions(options) {
return merge({}, DEFAULT_OPTIONS, options);
const result = merge({}, DEFAULT_OPTIONS, options);
// Both renderer and editor always provide new `bounds` array, so no need to clone it here.
// Keeping original object also reduces amount of updates in components
result.bounds = get(options, "bounds");
if (isNil(visualizationsSettings.choroplethAvailableMaps[result.mapType])) {
result.mapType = getDefaultMap();
}
// backward compatibility
if (!isNil(result.countryCodeColumn)) {
result.keyColumn = result.countryCodeColumn;
}
delete result.countryCodeColumn;
if (!isNil(result.countryCodeType)) {
result.targetField = result.countryCodeType;
}
delete result.countryCodeType;
return result;
}

View File

@ -0,0 +1,39 @@
import { isString, isObject, get } from "lodash";
import { useState, useEffect } from "react";
import axios from "axios";
import { visualizationsSettings } from "@/visualizations/visualizationsSettings";
import createReferenceCountingCache from "@/lib/referenceCountingCache";
const cache = createReferenceCountingCache();
export default function useLoadGeoJson(mapType) {
const [geoJson, setGeoJson] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const mapUrl = get(visualizationsSettings, `choroplethAvailableMaps.${mapType}.url`, undefined);
if (isString(mapUrl)) {
setIsLoading(true);
let cancelled = false;
const promise = cache.get(mapUrl, () => axios.get(mapUrl).catch(() => null));
promise.then(({ data }) => {
if (!cancelled) {
setGeoJson(isObject(data) ? data : null);
setIsLoading(false);
}
});
return () => {
cancelled = true;
cache.release(mapUrl);
};
} else {
setGeoJson(null);
setIsLoading(false);
}
}, [mapType]);
return [geoJson, isLoading];
}

View File

@ -0,0 +1,40 @@
// This helper converts USA map from Mercator projection to Albers (USA)
// Usage: `node convert-projection.js > usa-albers.geo.json`
const { each, map, filter } = require("lodash");
const d3 = require("d3");
const albersUSA = d3.geo.albersUsa();
const mercator = d3.geo.mercator();
const geojson = require("./usa.geo.json");
function convertPoint(coord) {
const pt = albersUSA(coord);
return pt ? mercator.invert(pt) : null;
}
function convertLineString(points) {
return filter(map(points, convertPoint));
}
function convertPolygon(polygon) {
return map(polygon, convertLineString);
}
function convertMultiPolygon(multiPolygon) {
return map(multiPolygon, convertPolygon);
}
each(geojson.features, feature => {
switch (feature.geometry.type) {
case "Polygon":
feature.geometry.coordinates = convertPolygon(feature.geometry.coordinates);
break;
case "MultiPolygon":
feature.geometry.coordinates = convertMultiPolygon(feature.geometry.coordinates);
break;
}
});
console.log(JSON.stringify(geojson));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long