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 { 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),
@@ -12,13 +16,23 @@ export default function BoundsSettings({ options, onOptionsChange }) {
// 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(() => {
if (options.bounds) {
setBounds(options.bounds);
}, [options.bounds]);
} else {
const defaultBounds = getGeoJsonBounds(geoJson);
if (defaultBounds) {
setBounds(defaultBounds);
}
}
}, [options.bounds, geoJson]);
const updateBounds = useCallback(
(i, j, v) => {
@@ -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>
@@ -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>

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,30 @@ 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">
<div>
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;
Use <code>{"{{ @@value }}"}</code> to access formatted value.
</div>
{mapType === "countries" && (
</div>
{geoJsonProperties.length > 0 && (
<React.Fragment>
<div>
<code>{"{{ @@name }}"}</code> short country name;
<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>
<code>{"{{ @@name_long }}"}</code> full country name;
</div>
<div>
<code>{"{{ @@abbrev }}"}</code> abbreviated country name;
</div>
<div>
<code>{"{{ @@iso_a2 }}"}</code> two-letter ISO country code;
</div>
<div>
<code>{"{{ @@iso_a3 }}"}</code> three-letter ISO country code;
</div>
<div>
<code>{"{{ @@iso_n3 }}"}</code> three-digit ISO country code.
</div>
</React.Fragment>
)}
{mapType === "subdiv_japan" && (
<React.Fragment>
<div>
<code>{"{{ @@name }}"}</code> Prefecture name in English;
</div>
<div>
<code>{"{{ @@name_local }}"}</code> Prefecture name in Kanji;
</div>
<div>
<code>{"{{ @@iso_3166_2 }}"}</code> five-letter ISO subdivision code (JP-xx);
))}
</div>
</React.Fragment>
)}
@@ -62,10 +45,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(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">

View File

@@ -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]);
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 [handleCustomMapUrlChange] = useDebouncedCallback(customMapUrl => {
onOptionsChange({ customMapUrl });
}, 200);
return (
<React.Fragment>
@@ -45,44 +28,63 @@ 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
onChange={mapType => onOptionsChange({ mapType })}>
{map(availableMaps, ({ name }, mapKey) => (
<Select.Option key={mapKey} data-test={`Choropleth.Editor.MapType.${mapKey}`}>
{name}
</Select.Option>
<Select.Option key="subdiv_japan" data-test="Choropleth.Editor.MapType.Japan">
Japan/Prefectures
))}
<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>
<Grid.Row gutter={15}>
<Grid.Col span={12}>
<Select
label="Key column"
className="w-100"
data-test="Choropleth.Editor.KeyColumn"
defaultValue={options.countryCodeColumn}
onChange={countryCodeColumn => handleChangeAndInferType({ countryCodeColumn })}>
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>
</Section>
<Section>
</Grid.Col>
<Grid.Col span={12}>
<Select
label="Key type"
label="Target field"
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}
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>
@@ -90,6 +92,7 @@ export default function GeneralSettings({ options, data, onOptionsChange }) {
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 }) => (

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,52 +1,27 @@
import { omit, merge } from "lodash";
import React, { useState, useEffect } from "react";
import { axios } from "@/services/axios";
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 useLoadGeoJson from "../hooks/useLoadGeoJson";
import { getMapUrl } from "../maps";
import initChoropleth from "./initChoropleth";
import { prepareData } from "./utils";
import "./renderer.less";
import countriesDataUrl from "../maps/countries.geo.json";
import subdivJapanDataUrl from "../maps/japan.prefectures.geo.json";
function getDataUrl(type) {
switch (type) {
case "countries":
return countriesDataUrl;
case "subdiv_japan":
return subdivJapanDataUrl;
default:
return null;
}
}
export default function Renderer({ data, options, onOptionsChange }) {
const [container, setContainer] = useState(null);
const [geoJson, setGeoJson] = useState(null);
const [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 [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();
@@ -58,26 +33,19 @@ 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, 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 (
<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 }) {
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,14 +95,15 @@ export default function initChoropleth(container) {
let _choropleth = null;
const _legend = new CustomControl();
let onBoundsChange = () => {};
function handleMapBoundsChange() {
if (isFunction(onBoundsChange)) {
const bounds = _map.getBounds();
onBoundsChange([
[bounds._southWest.lat, bounds._southWest.lng],
[bounds._northEast.lat, bounds._northEast.lng],
]);
}
}
let boundsChangedFromMap = false;
const onMapMoveEnd = () => {
@@ -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,15 @@
import { merge } from "lodash";
import { isNil, merge, first, keys, get } from "lodash";
import ColorPalette from "./ColorPalette";
import availableMaps from "./maps";
const defaultMap = first(keys(availableMaps));
const DEFAULT_OPTIONS = {
mapType: "countries",
countryCodeColumn: "",
countryCodeType: "iso_a3",
valueColumn: "",
mapType: defaultMap,
customMapUrl: null,
keyColumn: null,
targetField: null,
valueColumn: null,
clusteringMode: "e",
steps: 5,
valueFormat: "0,0.00",
@@ -33,5 +37,24 @@ 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(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(`
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

@@ -1,5 +1,6 @@
from flask import jsonify
from flask import jsonify, request, Response
from flask_login import login_required
import requests
from redash.handlers.api import api
from redash.handlers.base import routes
@@ -22,6 +23,14 @@ def status_api():
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):
from redash.handlers import (
embed,