mirror of
https://github.com/valitydev/redash.git
synced 2024-11-06 09:05:17 +00:00
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:
parent
210008c714
commit
a473611cb0
@ -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, [
|
||||
|
@ -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
|
||||
`);
|
||||
|
39
viz-lib/src/lib/referenceCountingCache.js
Normal file
39
viz-lib/src/lib/referenceCountingCache.js
Normal 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 };
|
||||
}
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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 }) => (
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 (
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
)
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
@ -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
1
viz-lib/src/visualizations/choropleth/maps/usa.geo.json
Normal file
1
viz-lib/src/visualizations/choropleth/maps/usa.geo.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user