Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Choropleth: allow to use custom maps #4599

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
39 changes: 39 additions & 0 deletions client/app/lib/referenceCountingCache.js
Original file line number Diff line number Diff line change
@@ -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 };
}
52 changes: 44 additions & 8 deletions client/app/visualizations/choropleth/Editor/BoundsSettings.jsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
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 { getMapUrl } from "../maps";
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),
// 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);

const [geoJson] = useLoadGeoJson(getMapUrl(options.mapType, options.customMapUrl));

// `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) => {
Expand All @@ -33,16 +47,28 @@ export default function BoundsSettings({ options, onOptionsChange }) {
[bounds, onOptionsChangeDebounced]
);

const boundsAvailable = isArray(bounds);

return (
<React.Fragment>
<Section>
<ControlLabel label="North-East latitude and longitude">
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<InputNumber className="w-100" value={bounds[1][0]} onChange={value => updateBounds(1, 0, value)} />
<InputNumber
className="w-100"
disabled={!boundsAvailable}
value={boundsAvailable ? bounds[1][0] : undefined}
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)} />
<InputNumber
className="w-100"
disabled={!boundsAvailable}
value={boundsAvailable ? bounds[1][1] : undefined}
onChange={value => updateBounds(1, 1, value)}
/>
</Grid.Col>
</Grid.Row>
</ControlLabel>
Expand All @@ -52,10 +78,20 @@ export default function BoundsSettings({ options, onOptionsChange }) {
<ControlLabel label="South-West latitude and longitude">
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<InputNumber className="w-100" value={bounds[0][0]} onChange={value => updateBounds(0, 0, value)} />
<InputNumber
className="w-100"
disabled={!boundsAvailable}
value={boundsAvailable ? bounds[0][0] : undefined}
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)} />
<InputNumber
className="w-100"
disabled={!boundsAvailable}
value={boundsAvailable ? bounds[0][1] : undefined}
onChange={value => updateBounds(0, 1, value)}
/>
</Grid.Col>
</Grid.Row>
</ControlLabel>
Expand Down
73 changes: 33 additions & 40 deletions client/app/visualizations/choropleth/Editor/FormatSettings.jsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,60 +14,51 @@ 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 { getMapUrl } from "../maps";
import { getGeoJsonFields } from "./utils";

function TemplateFormatHint({ geoJsonProperties }) {
return (
<ContextHelp placement="topLeft" arrowPointAtCenter>
<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>
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>
{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>
)}
</ContextHelp>
);
}

TemplateFormatHint.propTypes = {
geoJsonProperties: PropTypes.arrayOf(PropTypes.string),
};

TemplateFormatHint.defaultProps = {
geoJsonProperties: [],
};

export default function GeneralSettings({ options, onOptionsChange }) {
const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);
const [geoJson] = useLoadGeoJson(getMapUrl(options.mapType, options.customMapUrl));
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">
Expand Down
131 changes: 67 additions & 64 deletions client/app/visualizations/choropleth/Editor/GeneralSettings.jsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,24 @@
import { map } from "lodash";
import { isString, map, filter } from "lodash";
import React, { useMemo } from "react";
import { useDebouncedCallback } from "use-debounce";
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 { Section, Select, Input } from "@/components/visualizations/editor";

import useLoadGeoJson from "../hooks/useLoadGeoJson";
import availableMaps, { getMapUrl } from "../maps";
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(getMapUrl(options.mapType, options.customMapUrl));
const geoJsonFields = useMemo(() => getGeoJsonFields(geoJson), [geoJson]);

// While geoJson is loading - show last selected field in select
const targetFields = isLoadingGeoJson ? filter([options.targetField], isString) : geoJsonFields;

const handleChangeAndInferType = newOptions => {
newOptions.countryCodeType =
inferCountryCodeType(
newOptions.mapType || options.mapType,
data ? data.rows : [],
newOptions.countryCodeColumn || options.countryCodeColumn
) || options.countryCodeType;
onOptionsChange(newOptions);
};
const [handleCustomMapUrlChange] = useDebouncedCallback(customMapUrl => {
onOptionsChange({ customMapUrl });
}, 200);

return (
<React.Fragment>
Expand All @@ -45,51 +28,71 @@ export default function GeneralSettings({ options, data, onOptionsChange }) {
className="w-100"
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"
className="w-100"
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}`}>
onChange={mapType => onOptionsChange({ mapType })}>
{map(availableMaps, ({ name }, mapKey) => (
<Select.Option key={mapKey} data-test={`Choropleth.Editor.MapType.${mapKey}`}>
{name}
</Select.Option>
))}
<Select.Option key="custom" data-test="Choropleth.Editor.MapType.custom">
Custom...
</Select.Option>
</Select>
</Section>

{options.mapType === "custom" && (
<Section>
<Input
data-test="Choropleth.Editor.CustomMapUrl"
placeholder="Custom map URL..."
defaultValue={options.customMapUrl}
onChange={event => handleCustomMapUrlChange(event.target.value)}
/>
</Section>
)}

<Section>
<Select
label="Key type"
className="w-100"
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}`}>
{field}
</Select.Option>
))}
</Select>
</Grid.Col>
</Grid.Row>
</Section>

<Section>
<Select
label="Value column"
className="w-100"
data-test="Choropleth.Editor.ValueColumn"
disabled={data.columns.length === 0}
defaultValue={options.valueColumn}
onChange={valueColumn => onOptionsChange({ valueColumn })}>
{map(data.columns, ({ name }) => (
Expand Down
Loading