mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
11 Commits
24.04.0-de
...
choropleth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435787281a | ||
|
|
bc9dd814c9 | ||
|
|
c7b13459e8 | ||
|
|
813d97a62c | ||
|
|
b331c4c922 | ||
|
|
6187448e6a | ||
|
|
3f280b1f6e | ||
|
|
3b29f0c0a7 | ||
|
|
4911764663 | ||
|
|
6260601213 | ||
|
|
8f7d1d8281 |
39
client/app/lib/referenceCountingCache.js
Normal file
39
client/app/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,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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
45
client/app/visualizations/choropleth/hooks/useLoadGeoJson.js
Normal file
45
client/app/visualizations/choropleth/hooks/useLoadGeoJson.js
Normal 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];
|
||||||
|
}
|
||||||
19
client/app/visualizations/choropleth/maps/index.js
Normal file
19
client/app/visualizations/choropleth/maps/index.js
Normal 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
@@ -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
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user