mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
11 Commits
23.09.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 { 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(() => {
|
||||
setBounds(options.bounds);
|
||||
}, [options.bounds]);
|
||||
if (options.bounds) {
|
||||
setBounds(options.bounds);
|
||||
} else {
|
||||
const defaultBounds = getGeoJsonBounds(geoJson);
|
||||
if (defaultBounds) {
|
||||
setBounds(defaultBounds);
|
||||
}
|
||||
}
|
||||
}, [options.bounds, geoJson]);
|
||||
|
||||
const updateBounds = useCallback(
|
||||
(i, j, v) => {
|
||||
@@ -33,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>
|
||||
|
||||
@@ -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">
|
||||
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 className="p-b-5">Use special names to access additional properties:</div>
|
||||
<div>
|
||||
<code>{"{{ @@value }}"}</code> formatted value;
|
||||
</div>
|
||||
{mapType === "countries" && (
|
||||
{geoJsonProperties.length > 0 && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<code>{"{{ @@name }}"}</code> short country name;
|
||||
</div>
|
||||
<div>
|
||||
<code>{"{{ @@name_long }}"}</code> full country name;
|
||||
</div>
|
||||
<div>
|
||||
<code>{"{{ @@abbrev }}"}</code> abbreviated country name;
|
||||
</div>
|
||||
<div>
|
||||
<code>{"{{ @@iso_a2 }}"}</code> two-letter ISO country code;
|
||||
</div>
|
||||
<div>
|
||||
<code>{"{{ @@iso_a3 }}"}</code> three-letter ISO country code;
|
||||
</div>
|
||||
<div>
|
||||
<code>{"{{ @@iso_n3 }}"}</code> three-digit ISO country code.
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{mapType === "subdiv_japan" && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<code>{"{{ @@name }}"}</code> Prefecture name in English;
|
||||
</div>
|
||||
<div>
|
||||
<code>{"{{ @@name_local }}"}</code> Prefecture name in Kanji;
|
||||
</div>
|
||||
<div>
|
||||
<code>{"{{ @@iso_3166_2 }}"}</code> five-letter ISO subdivision code (JP-xx);
|
||||
<div className="p-b-5">GeoJSON properties could be accessed by these names:</div>
|
||||
<div style={{ maxHeight: 300, overflow: "auto" }}>
|
||||
{map(geoJsonProperties, property => (
|
||||
<div key={property}>
|
||||
<code>{`{{ @@${property}}}`}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
@@ -62,10 +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">
|
||||
|
||||
@@ -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
|
||||
</Select.Option>
|
||||
<Select.Option key="subdiv_japan" data-test="Choropleth.Editor.MapType.Japan">
|
||||
Japan/Prefectures
|
||||
onChange={mapType => onOptionsChange({ mapType })}>
|
||||
{map(availableMaps, ({ name }, mapKey) => (
|
||||
<Select.Option key={mapKey} data-test={`Choropleth.Editor.MapType.${mapKey}`}>
|
||||
{name}
|
||||
</Select.Option>
|
||||
))}
|
||||
<Select.Option key="custom" data-test="Choropleth.Editor.MapType.custom">
|
||||
Custom...
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Select
|
||||
label="Key column"
|
||||
className="w-100"
|
||||
data-test="Choropleth.Editor.KeyColumn"
|
||||
defaultValue={options.countryCodeColumn}
|
||||
onChange={countryCodeColumn => handleChangeAndInferType({ countryCodeColumn })}>
|
||||
{map(data.columns, ({ name }) => (
|
||||
<Select.Option key={name} data-test={`Choropleth.Editor.KeyColumn.${name}`}>
|
||||
{name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Section>
|
||||
{options.mapType === "custom" && (
|
||||
<Section>
|
||||
<Input
|
||||
data-test="Choropleth.Editor.CustomMapUrl"
|
||||
placeholder="Custom map URL..."
|
||||
defaultValue={options.customMapUrl}
|
||||
onChange={event => handleCustomMapUrlChange(event.target.value)}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<Select
|
||||
label="Key type"
|
||||
className="w-100"
|
||||
data-test="Choropleth.Editor.KeyType"
|
||||
value={options.countryCodeType}
|
||||
onChange={countryCodeType => onOptionsChange({ countryCodeType })}>
|
||||
{map(countryCodeTypes, (name, type) => (
|
||||
<Select.Option key={type} data-test={`Choropleth.Editor.KeyType.${type}`}>
|
||||
{name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Grid.Row gutter={15}>
|
||||
<Grid.Col span={12}>
|
||||
<Select
|
||||
label="Key column"
|
||||
className="w-100"
|
||||
data-test="Choropleth.Editor.KeyColumn"
|
||||
disabled={data.columns.length === 0}
|
||||
defaultValue={options.keyColumn}
|
||||
onChange={keyColumn => onOptionsChange({ keyColumn })}>
|
||||
{map(data.columns, ({ name }) => (
|
||||
<Select.Option key={name} data-test={`Choropleth.Editor.KeyColumn.${name}`}>
|
||||
{name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Select
|
||||
label="Target field"
|
||||
className="w-100"
|
||||
data-test="Choropleth.Editor.TargetField"
|
||||
disabled={isLoadingGeoJson || targetFields.length === 0}
|
||||
loading={isLoadingGeoJson}
|
||||
value={options.targetField}
|
||||
onChange={targetField => onOptionsChange({ targetField })}>
|
||||
{map(targetFields, field => (
|
||||
<Select.Option key={field} data-test={`Choropleth.Editor.TargetField.${field}`}>
|
||||
{field}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Grid.Col>
|
||||
</Grid.Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -1,38 +1,28 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { isObject, isArray, reduce, keys, uniq } from "lodash";
|
||||
import L from "leaflet";
|
||||
|
||||
import _ from "lodash";
|
||||
|
||||
export function inferCountryCodeType(mapType, data, countryCodeField) {
|
||||
const regexMap = {
|
||||
countries: {
|
||||
iso_a2: /^[a-z]{2}$/i,
|
||||
iso_a3: /^[a-z]{3}$/i,
|
||||
iso_n3: /^[0-9]{3}$/i,
|
||||
export function getGeoJsonFields(geoJson) {
|
||||
const features = isObject(geoJson) && isArray(geoJson.features) ? geoJson.features : [];
|
||||
return reduce(
|
||||
features,
|
||||
(result, feature) => {
|
||||
const properties = isObject(feature) && isObject(feature.properties) ? feature.properties : {};
|
||||
return uniq([...result, ...keys(properties)]);
|
||||
},
|
||||
subdiv_japan: {
|
||||
name: /^[a-z]+$/i,
|
||||
name_local: /^[\u3400-\u9FFF\uF900-\uFAFF]|[\uD840-\uD87F][\uDC00-\uDFFF]+$/i,
|
||||
iso_3166_2: /^JP-[0-9]{2}$/i,
|
||||
},
|
||||
};
|
||||
|
||||
const regex = regexMap[mapType];
|
||||
|
||||
const initState = _.mapValues(regex, () => 0);
|
||||
|
||||
const result = _.chain(data)
|
||||
.reduce((memo, item) => {
|
||||
const value = item[countryCodeField];
|
||||
if (_.isString(value)) {
|
||||
_.each(regex, (r, k) => {
|
||||
memo[k] += r.test(value) ? 1 : 0;
|
||||
});
|
||||
}
|
||||
return memo;
|
||||
}, initState)
|
||||
.toPairs()
|
||||
.reduce((memo, item) => (item[1] > memo[1] ? item : memo))
|
||||
.value();
|
||||
|
||||
return result[1] / data.length >= 0.9 ? result[0] : null;
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
export function getGeoJsonBounds(geoJson) {
|
||||
if (isObject(geoJson)) {
|
||||
const layer = L.geoJSON(geoJson);
|
||||
const bounds = layer.getBounds();
|
||||
if (bounds.isValid()) {
|
||||
return [
|
||||
[bounds._southWest.lat, bounds._southWest.lng],
|
||||
[bounds._northEast.lat, bounds._northEast.lng],
|
||||
];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,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} />
|
||||
);
|
||||
|
||||
@@ -35,9 +35,9 @@ const CustomControl = L.Control.extend({
|
||||
});
|
||||
|
||||
function prepareLayer({ feature, layer, data, options, limits, colors, formatValue }) {
|
||||
const value = getValueForFeature(feature, data, options.countryCodeType);
|
||||
const value = getValueForFeature(feature, data, options.targetField);
|
||||
const valueFormatted = formatValue(value);
|
||||
const featureData = prepareFeatureProperties(feature, valueFormatted, data, options.countryCodeType);
|
||||
const featureData = prepareFeatureProperties(feature, valueFormatted, data, options.targetField);
|
||||
const color = getColorByValue(value, limits, colors, options.colors.noValue);
|
||||
|
||||
layer.setStyle({
|
||||
@@ -69,7 +69,20 @@ function prepareLayer({ feature, layer, data, options, limits, colors, formatVal
|
||||
});
|
||||
}
|
||||
|
||||
export default function initChoropleth(container) {
|
||||
function validateBounds(bounds, fallbackBounds) {
|
||||
if (bounds) {
|
||||
bounds = L.latLngBounds(bounds[0], bounds[1]);
|
||||
if (bounds.isValid()) {
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
if (fallbackBounds && fallbackBounds.isValid()) {
|
||||
return fallbackBounds;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function initChoropleth(container, onBoundsChange) {
|
||||
const _map = L.map(container, {
|
||||
center: [0.0, 0.0],
|
||||
zoom: 1,
|
||||
@@ -82,13 +95,14 @@ export default function initChoropleth(container) {
|
||||
let _choropleth = null;
|
||||
const _legend = new CustomControl();
|
||||
|
||||
let onBoundsChange = () => {};
|
||||
function handleMapBoundsChange() {
|
||||
const bounds = _map.getBounds();
|
||||
onBoundsChange([
|
||||
[bounds._southWest.lat, bounds._southWest.lng],
|
||||
[bounds._northEast.lat, bounds._northEast.lng],
|
||||
]);
|
||||
if (isFunction(onBoundsChange)) {
|
||||
const bounds = _map.getBounds();
|
||||
onBoundsChange([
|
||||
[bounds._southWest.lat, bounds._southWest.lng],
|
||||
[bounds._northEast.lat, bounds._northEast.lng],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
let boundsChangedFromMap = false;
|
||||
@@ -123,14 +137,13 @@ export default function initChoropleth(container) {
|
||||
},
|
||||
}).addTo(_map);
|
||||
|
||||
const bounds = _choropleth.getBounds();
|
||||
_map.fitBounds(options.bounds || bounds, { animate: false, duration: 0 });
|
||||
_map.setMaxBounds(bounds);
|
||||
const mapBounds = _choropleth.getBounds();
|
||||
const bounds = validateBounds(options.bounds, mapBounds);
|
||||
_map.fitBounds(bounds, { animate: false, duration: 0 });
|
||||
|
||||
// send updated bounds to editor; delay this to avoid infinite update loop
|
||||
setTimeout(() => {
|
||||
handleMapBoundsChange();
|
||||
}, 10);
|
||||
// equivalent to `_map.setMaxBounds(mapBounds)` but without animation
|
||||
_map.options.maxBounds = mapBounds;
|
||||
_map.panInsideBounds(mapBounds, { animate: false, duration: 0 });
|
||||
|
||||
// update legend
|
||||
if (options.legend.visible && legend.length > 0) {
|
||||
@@ -149,8 +162,8 @@ export default function initChoropleth(container) {
|
||||
function updateBounds(bounds) {
|
||||
if (!boundsChangedFromMap) {
|
||||
const layerBounds = _choropleth ? _choropleth.getBounds() : _map.getBounds();
|
||||
bounds = bounds ? L.latLngBounds(bounds[0], bounds[1]) : layerBounds;
|
||||
if (bounds.isValid()) {
|
||||
bounds = validateBounds(bounds, layerBounds);
|
||||
if (bounds) {
|
||||
_map.fitBounds(bounds, { animate: false, duration: 0 });
|
||||
}
|
||||
}
|
||||
@@ -161,12 +174,6 @@ export default function initChoropleth(container) {
|
||||
});
|
||||
|
||||
return {
|
||||
get onBoundsChange() {
|
||||
return onBoundsChange;
|
||||
},
|
||||
set onBoundsChange(value) {
|
||||
onBoundsChange = isFunction(value) ? value : () => {};
|
||||
},
|
||||
updateLayers,
|
||||
updateBounds,
|
||||
destroy() {
|
||||
|
||||
@@ -18,17 +18,17 @@ export function createNumberFormatter(format, placeholder) {
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareData(data, countryCodeField, valueField) {
|
||||
if (!countryCodeField || !valueField) {
|
||||
export function prepareData(data, keyColumn, valueColumn) {
|
||||
if (!keyColumn || !valueColumn) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result = {};
|
||||
each(data, item => {
|
||||
if (item[countryCodeField]) {
|
||||
const value = parseFloat(item[valueField]);
|
||||
result[item[countryCodeField]] = {
|
||||
code: item[countryCodeField],
|
||||
if (item[keyColumn]) {
|
||||
const value = parseFloat(item[valueColumn]);
|
||||
result[item[keyColumn]] = {
|
||||
code: item[keyColumn],
|
||||
value: isFinite(value) ? value : undefined,
|
||||
item,
|
||||
};
|
||||
@@ -37,18 +37,18 @@ export function prepareData(data, countryCodeField, valueField) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function prepareFeatureProperties(feature, valueFormatted, data, countryCodeType) {
|
||||
export function prepareFeatureProperties(feature, valueFormatted, data, targetField) {
|
||||
const result = {};
|
||||
each(feature.properties, (value, key) => {
|
||||
result["@@" + key] = value;
|
||||
});
|
||||
result["@@value"] = valueFormatted;
|
||||
const datum = data[feature.properties[countryCodeType]] || {};
|
||||
const datum = data[feature.properties[targetField]] || {};
|
||||
return extend(result, datum.item);
|
||||
}
|
||||
|
||||
export function getValueForFeature(feature, data, countryCodeType) {
|
||||
const code = feature.properties[countryCodeType];
|
||||
export function getValueForFeature(feature, data, targetField) {
|
||||
const code = feature.properties[targetField];
|
||||
if (isString(code) && isObject(data[code])) {
|
||||
return data[code].value;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function createScale(features, data, options) {
|
||||
// Calculate limits
|
||||
const values = uniq(
|
||||
filter(
|
||||
map(features, feature => getValueForFeature(feature, data, options.countryCodeType)),
|
||||
map(features, feature => getValueForFeature(feature, data, options.targetField)),
|
||||
isFinite
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,11 +1,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;
|
||||
}
|
||||
|
||||
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(`
|
||||
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
|
||||
`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user