Compare commits

...

11 Commits

Author SHA1 Message Date
Levko Kravets
435787281a Merge branch 'master' into choropleth-custom-map 2020-02-11 13:38:33 +02:00
Levko Kravets
bc9dd814c9 Optimize Japan Perfectures map (remove irrelevant GeoJson properties) 2020-02-11 13:27:29 +02:00
Levko Kravets
c7b13459e8 Load pre-defined maps directly; move proxy to /api namespace 2020-02-11 12:49:43 +02:00
Levko Kravets
813d97a62c Use proxy to load custom maps (to bypass CSP) 2020-02-11 12:36:19 +02:00
Levko Kravets
b331c4c922 Improve cache; fix typo 2020-01-30 00:02:19 +02:00
Levko Kravets
6187448e6a Choropleth: fix map "jumping" on load; don't save bounds if user didn't edit them; refine code a bit 2020-01-29 23:36:27 +02:00
Levko Kravets
3f280b1f6e Don't handle bounds changes while loading geoJson data 2020-01-29 13:40:03 +02:00
Levko Kravets
3b29f0c0a7 Use cache for geoJson requests 2020-01-29 13:05:54 +02:00
Levko Kravets
4911764663 Keep last custom map URL when selecting predefined map type 2020-01-29 13:05:10 +02:00
Levko Kravets
6260601213 Use separate input for custom map URL (pre-defined map URLs should not be saved in options, only keys) 2020-01-29 12:21:45 +02:00
Levko Kravets
8f7d1d8281 Choropleth: allow to use custom maps 2020-01-29 11:14:08 +02:00
14 changed files with 368 additions and 289 deletions

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,14 @@
import { isFinite, cloneDeep } from "lodash"; import { isArray, isFinite, cloneDeep } from "lodash";
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import * as Grid from "antd/lib/grid"; import * as Grid from "antd/lib/grid";
import { Section, InputNumber, ControlLabel } from "@/components/visualizations/editor"; import { Section, InputNumber, ControlLabel } from "@/components/visualizations/editor";
import { EditorPropTypes } from "@/visualizations/prop-types"; 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 }) { export default function BoundsSettings({ options, onOptionsChange }) {
// Bounds may be changed in editor or on preview (by drag/zoom map). // 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), // Changes from preview does not come frequently (only when user release mouse button),
@@ -12,13 +16,23 @@ export default function BoundsSettings({ options, onOptionsChange }) {
// Therefore this component has intermediate state to hold immediate user input, // Therefore this component has intermediate state to hold immediate user input,
// which is updated from `options.bounds` and by inputs immediately on 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. // but `onOptionsChange` event is debounced and uses last value from internal state.
const [bounds, setBounds] = useState(options.bounds); const [bounds, setBounds] = useState(options.bounds);
const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200); 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(() => { useEffect(() => {
setBounds(options.bounds); if (options.bounds) {
}, [options.bounds]); setBounds(options.bounds);
} else {
const defaultBounds = getGeoJsonBounds(geoJson);
if (defaultBounds) {
setBounds(defaultBounds);
}
}
}, [options.bounds, geoJson]);
const updateBounds = useCallback( const updateBounds = useCallback(
(i, j, v) => { (i, j, v) => {
@@ -33,16 +47,28 @@ export default function BoundsSettings({ options, onOptionsChange }) {
[bounds, onOptionsChangeDebounced] [bounds, onOptionsChangeDebounced]
); );
const boundsAvailable = isArray(bounds);
return ( return (
<React.Fragment> <React.Fragment>
<Section> <Section>
<ControlLabel label="North-East latitude and longitude"> <ControlLabel label="North-East latitude and longitude">
<Grid.Row gutter={15}> <Grid.Row gutter={15}>
<Grid.Col span={12}> <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>
<Grid.Col span={12}> <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.Col>
</Grid.Row> </Grid.Row>
</ControlLabel> </ControlLabel>
@@ -52,10 +78,20 @@ export default function BoundsSettings({ options, onOptionsChange }) {
<ControlLabel label="South-West latitude and longitude"> <ControlLabel label="South-West latitude and longitude">
<Grid.Row gutter={15}> <Grid.Row gutter={15}>
<Grid.Col span={12}> <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>
<Grid.Col span={12}> <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.Col>
</Grid.Row> </Grid.Row>
</ControlLabel> </ControlLabel>

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 { useDebouncedCallback } from "use-debounce";
import * as Grid from "antd/lib/grid"; import * as Grid from "antd/lib/grid";
import { import {
@@ -12,49 +14,30 @@ import {
} from "@/components/visualizations/editor"; } from "@/components/visualizations/editor";
import { EditorPropTypes } from "@/visualizations/prop-types"; import { EditorPropTypes } from "@/visualizations/prop-types";
function TemplateFormatHint({ mapType }) { import useLoadGeoJson from "../hooks/useLoadGeoJson";
// eslint-disable-line react/prop-types import { getMapUrl } from "../maps";
import { getGeoJsonFields } from "./utils";
function TemplateFormatHint({ geoJsonProperties }) {
return ( return (
<ContextHelp placement="topLeft" arrowPointAtCenter> <ContextHelp placement="topLeft" arrowPointAtCenter>
<div className="p-b-5"> <div className="p-b-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>
<div className="p-b-5">Use special names to access additional properties:</div> {geoJsonProperties.length > 0 && (
<div>
<code>{"{{ @@value }}"}</code> formatted value;
</div>
{mapType === "countries" && (
<React.Fragment> <React.Fragment>
<div> <div className="p-b-5">GeoJSON properties could be accessed by these names:</div>
<code>{"{{ @@name }}"}</code> short country name; <div style={{ maxHeight: 300, overflow: "auto" }}>
</div> {map(geoJsonProperties, property => (
<div> <div key={property}>
<code>{"{{ @@name_long }}"}</code> full country name; <code>{`{{ @@${property}}}`}</code>
</div> </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> </div>
</React.Fragment> </React.Fragment>
)} )}
@@ -62,10 +45,20 @@ function TemplateFormatHint({ mapType }) {
); );
} }
TemplateFormatHint.propTypes = {
geoJsonProperties: PropTypes.arrayOf(PropTypes.string),
};
TemplateFormatHint.defaultProps = {
geoJsonProperties: [],
};
export default function GeneralSettings({ options, onOptionsChange }) { export default function GeneralSettings({ options, onOptionsChange }) {
const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200); 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 ( return (
<div className="choropleth-visualization-editor-format-settings"> <div className="choropleth-visualization-editor-format-settings">

View File

@@ -1,41 +1,24 @@
import { map } from "lodash"; import { isString, map, filter } from "lodash";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { useDebouncedCallback } from "use-debounce";
import * as Grid from "antd/lib/grid";
import { EditorPropTypes } from "@/visualizations/prop-types"; import { EditorPropTypes } from "@/visualizations/prop-types";
import { Section, Select } from "@/components/visualizations/editor"; import { Section, Select, Input } from "@/components/visualizations/editor";
import { inferCountryCodeType } from "./utils";
import useLoadGeoJson from "../hooks/useLoadGeoJson";
import availableMaps, { getMapUrl } from "../maps";
import { getGeoJsonFields } from "./utils";
export default function GeneralSettings({ options, data, onOptionsChange }) { export default function GeneralSettings({ options, data, onOptionsChange }) {
const countryCodeTypes = useMemo(() => { const [geoJson, isLoadingGeoJson] = useLoadGeoJson(getMapUrl(options.mapType, options.customMapUrl));
switch (options.mapType) { const geoJsonFields = useMemo(() => getGeoJsonFields(geoJson), [geoJson]);
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 => { // While geoJson is loading - show last selected field in select
newOptions.countryCodeType = const targetFields = isLoadingGeoJson ? filter([options.targetField], isString) : geoJsonFields;
inferCountryCodeType(
newOptions.mapType || options.mapType, const [handleCustomMapUrlChange] = useDebouncedCallback(customMapUrl => {
data ? data.rows : [], onOptionsChange({ customMapUrl });
newOptions.countryCodeColumn || options.countryCodeColumn }, 200);
) || options.countryCodeType;
onOptionsChange(newOptions);
};
return ( return (
<React.Fragment> <React.Fragment>
@@ -45,44 +28,63 @@ export default function GeneralSettings({ options, data, onOptionsChange }) {
className="w-100" className="w-100"
data-test="Choropleth.Editor.MapType" data-test="Choropleth.Editor.MapType"
defaultValue={options.mapType} defaultValue={options.mapType}
onChange={mapType => handleChangeAndInferType({ mapType })}> onChange={mapType => onOptionsChange({ mapType })}>
<Select.Option key="countries" data-test="Choropleth.Editor.MapType.Countries"> {map(availableMaps, ({ name }, mapKey) => (
Countries <Select.Option key={mapKey} data-test={`Choropleth.Editor.MapType.${mapKey}`}>
</Select.Option> {name}
<Select.Option key="subdiv_japan" data-test="Choropleth.Editor.MapType.Japan"> </Select.Option>
Japan/Prefectures ))}
<Select.Option key="custom" data-test="Choropleth.Editor.MapType.custom">
Custom...
</Select.Option> </Select.Option>
</Select> </Select>
</Section> </Section>
<Section> {options.mapType === "custom" && (
<Select <Section>
label="Key column" <Input
className="w-100" data-test="Choropleth.Editor.CustomMapUrl"
data-test="Choropleth.Editor.KeyColumn" placeholder="Custom map URL..."
defaultValue={options.countryCodeColumn} defaultValue={options.customMapUrl}
onChange={countryCodeColumn => handleChangeAndInferType({ countryCodeColumn })}> onChange={event => handleCustomMapUrlChange(event.target.value)}
{map(data.columns, ({ name }) => ( />
<Select.Option key={name} data-test={`Choropleth.Editor.KeyColumn.${name}`}> </Section>
{name} )}
</Select.Option>
))}
</Select>
</Section>
<Section> <Section>
<Select <Grid.Row gutter={15}>
label="Key type" <Grid.Col span={12}>
className="w-100" <Select
data-test="Choropleth.Editor.KeyType" label="Key column"
value={options.countryCodeType} className="w-100"
onChange={countryCodeType => onOptionsChange({ countryCodeType })}> data-test="Choropleth.Editor.KeyColumn"
{map(countryCodeTypes, (name, type) => ( disabled={data.columns.length === 0}
<Select.Option key={type} data-test={`Choropleth.Editor.KeyType.${type}`}> defaultValue={options.keyColumn}
{name} onChange={keyColumn => onOptionsChange({ keyColumn })}>
</Select.Option> {map(data.columns, ({ name }) => (
))} <Select.Option key={name} data-test={`Choropleth.Editor.KeyColumn.${name}`}>
</Select> {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>
<Section> <Section>
@@ -90,6 +92,7 @@ export default function GeneralSettings({ options, data, onOptionsChange }) {
label="Value column" label="Value column"
className="w-100" className="w-100"
data-test="Choropleth.Editor.ValueColumn" data-test="Choropleth.Editor.ValueColumn"
disabled={data.columns.length === 0}
defaultValue={options.valueColumn} defaultValue={options.valueColumn}
onChange={valueColumn => onOptionsChange({ valueColumn })}> onChange={valueColumn => onOptionsChange({ valueColumn })}>
{map(data.columns, ({ name }) => ( {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 getGeoJsonFields(geoJson) {
const features = isObject(geoJson) && isArray(geoJson.features) ? geoJson.features : [];
export function inferCountryCodeType(mapType, data, countryCodeField) { return reduce(
const regexMap = { features,
countries: { (result, feature) => {
iso_a2: /^[a-z]{2}$/i, const properties = isObject(feature) && isObject(feature.properties) ? feature.properties : {};
iso_a3: /^[a-z]{3}$/i, return uniq([...result, ...keys(properties)]);
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,
}, export function getGeoJsonBounds(geoJson) {
}; if (isObject(geoJson)) {
const layer = L.geoJSON(geoJson);
const regex = regexMap[mapType]; const bounds = layer.getBounds();
if (bounds.isValid()) {
const initState = _.mapValues(regex, () => 0); return [
[bounds._southWest.lat, bounds._southWest.lng],
const result = _.chain(data) [bounds._northEast.lat, bounds._northEast.lng],
.reduce((memo, item) => { ];
const value = item[countryCodeField]; }
if (_.isString(value)) { }
_.each(regex, (r, k) => { return null;
memo[k] += r.test(value) ? 1 : 0;
});
}
return memo;
}, initState)
.toPairs()
.reduce((memo, item) => (item[1] > memo[1] ? item : memo))
.value();
return result[1] / data.length >= 0.9 ? result[0] : null;
} }

View File

@@ -1,52 +1,27 @@
import { omit, merge } from "lodash"; import { omit, noop } from "lodash";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { axios } from "@/services/axios";
import { RendererPropTypes } from "@/visualizations/prop-types"; import { RendererPropTypes } from "@/visualizations/prop-types";
import useMemoWithDeepCompare from "@/lib/hooks/useMemoWithDeepCompare"; import useMemoWithDeepCompare from "@/lib/hooks/useMemoWithDeepCompare";
import useLoadGeoJson from "../hooks/useLoadGeoJson";
import { getMapUrl } from "../maps";
import initChoropleth from "./initChoropleth"; import initChoropleth from "./initChoropleth";
import { prepareData } from "./utils"; import { prepareData } from "./utils";
import "./renderer.less"; 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 }) { export default function Renderer({ data, options, onOptionsChange }) {
const [container, setContainer] = useState(null); const [container, setContainer] = useState(null);
const [geoJson, setGeoJson] = useState(null); const [geoJson] = useLoadGeoJson(getMapUrl(options.mapType, options.customMapUrl));
const onBoundsChangeRef = useRef();
onBoundsChangeRef.current = onOptionsChange ? bounds => onOptionsChange({ ...options, bounds }) : noop;
const optionsWithoutBounds = useMemoWithDeepCompare(() => omit(options, ["bounds"]), [options]); const optionsWithoutBounds = useMemoWithDeepCompare(() => omit(options, ["bounds"]), [options]);
const [map, setMap] = useState(null); 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(() => { useEffect(() => {
if (container) { if (container) {
const _map = initChoropleth(container); const _map = initChoropleth(container, (...args) => onBoundsChangeRef.current(...args));
setMap(_map); setMap(_map);
return () => { return () => {
_map.destroy(); _map.destroy();
@@ -58,26 +33,19 @@ export default function Renderer({ data, options, onOptionsChange }) {
if (map) { if (map) {
map.updateLayers( map.updateLayers(
geoJson, 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! options // detect changes for all options except bounds, but pass them all!
); );
} }
}, [map, geoJson, data, optionsWithoutBounds]); // eslint-disable-line react-hooks/exhaustive-deps }, [map, geoJson, data, optionsWithoutBounds]); // eslint-disable-line react-hooks/exhaustive-deps
// This may come only from editor
useEffect(() => { useEffect(() => {
if (map) { if (map) {
map.updateBounds(options.bounds); map.updateBounds(options.bounds);
} }
}, [map, options.bounds]); }, [map, options.bounds]);
useEffect(() => {
if (map && onOptionsChange) {
map.onBoundsChange = bounds => {
onOptionsChange(merge({}, options, { bounds }));
};
}
}, [map, options, onOptionsChange]);
return ( return (
<div className="map-visualization-container" style={{ background: options.colors.background }} ref={setContainer} /> <div className="map-visualization-container" style={{ background: options.colors.background }} ref={setContainer} />
); );

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
import { merge } from "lodash"; import { isNil, merge, first, keys, get } from "lodash";
import ColorPalette from "./ColorPalette"; import ColorPalette from "./ColorPalette";
import availableMaps from "./maps";
const defaultMap = first(keys(availableMaps));
const DEFAULT_OPTIONS = { const DEFAULT_OPTIONS = {
mapType: "countries", mapType: defaultMap,
countryCodeColumn: "", customMapUrl: null,
countryCodeType: "iso_a3", keyColumn: null,
valueColumn: "", targetField: null,
valueColumn: null,
clusteringMode: "e", clusteringMode: "e",
steps: 5, steps: 5,
valueFormat: "0,0.00", valueFormat: "0,0.00",
@@ -33,5 +37,24 @@ const DEFAULT_OPTIONS = {
}; };
export default function getOptions(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(availableMaps[result.mapType]) && result.mapType !== "custom") {
result.mapType = defaultMap;
}
// 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,45 @@
import { isString, isObject, find } from "lodash";
import { useState, useEffect } from "react";
import { axios } from "@/services/axios";
import createReferenceCountingCache from "@/lib/referenceCountingCache";
import maps from "../maps";
const cache = createReferenceCountingCache();
function withProxy(url) {
// if it's one of predefined maps - use it directly
if (find(maps, map => map.url === url)) {
return url;
}
return `/api/resource-proxy?url=${encodeURIComponent(url)}`;
}
export default function useLoadGeoJson(url) {
const [geoJson, setGeoJson] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (isString(url)) {
setIsLoading(true);
let cancelled = false;
const promise = cache.get(url, () => axios.get(withProxy(url)).catch(() => null));
promise.then(data => {
if (!cancelled) {
setGeoJson(isObject(data) ? data : null);
setIsLoading(false);
}
});
return () => {
cancelled = true;
cache.release(url);
};
} else {
setGeoJson(null);
setIsLoading(false);
}
}, [url]);
return [geoJson, isLoading];
}

View File

@@ -0,0 +1,19 @@
import countriesDataUrl from "./countries.geo.json";
import subdivJapanDataUrl from "./japan.prefectures.geo.json";
const availableMaps = {
countries: {
name: "Countries",
url: countriesDataUrl,
},
subdiv_japan: {
name: "Japan/Prefectures",
url: subdivJapanDataUrl,
},
};
export function getMapUrl(mapType, defaultUrl) {
return availableMaps[mapType] ? availableMaps[mapType].url : defaultUrl;
}
export default availableMaps;

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,5 +1,6 @@
from flask import jsonify from flask import jsonify, request, Response
from flask_login import login_required from flask_login import login_required
import requests
from redash.handlers.api import api from redash.handlers.api import api
from redash.handlers.base import routes from redash.handlers.base import routes
@@ -22,6 +23,14 @@ def status_api():
return jsonify(status) return jsonify(status)
@routes.route("/api/resource-proxy", methods=["GET"])
def resource_proxy():
response = requests.get(request.args.get('url'))
allow_headers = ['content-type']
headers = [(name, value) for (name, value) in response.raw.headers.items() if name.lower() in allow_headers]
return Response(response.content, response.status_code, headers)
def init_app(app): def init_app(app):
from redash.handlers import ( from redash.handlers import (
embed, embed,