feat(plugin-chart-choropleth-map): add package (#560)

* feat(plugin-chart-choropleth-map): scaffold and load map (#527)

* feat: add package

* feat: storybook working

* feat: load usa and world map

* refactor: clean up

* fix: remove test data

* refactor: utilize dynamic import

* build: remove unused dependencies

* fix: address pr comments

* fix: comment

* feat(plugin-chart-choropleth-map): add more country maps (#529)

* feat(plugin-chart-choropleth-map): add zooming (#528)

* feat: add zooming

* feat: can zoom in and out

* feat: add zoom controls

* refactor: extract controls

* fix: address comments

* feat(plugin-chart-choropleth-map): add encoding (#541)

* feat: add encoder

* feat: add encoding

* docs: add categorical

* fix: any

* docs: update storybook

* feat(plugin-chart-choropleth-map): add tooltip (#548)

* feat: support tooltip

* feat: support tooltip fields

* fix: default projection

* build: bump dependency

* build: update dependency

* build: mark private
This commit is contained in:
Krist Wongsuphasawat 2020-06-02 18:33:54 -07:00 committed by Yongjie Zhao
parent c966fc41a9
commit 72c2b7afc0
51 changed files with 205612 additions and 5 deletions

View File

@ -1,4 +1,3 @@
/* eslint-disable sort-keys */
export default [
{
country_id: 'FR-01',

View File

@ -0,0 +1,107 @@
import React from 'react';
import { SuperChart } from '@superset-ui/chart';
import {
maps,
ChoroplethMapChartPlugin,
} from '../../../../../../plugins/plugin-chart-choropleth-map/src';
import { withKnobs, select } from '@storybook/addon-knobs';
import useFakeMapData from './useFakeMapData';
new ChoroplethMapChartPlugin().configure({ key: 'choropleth-map' }).register();
export default {
title: 'Chart Plugins|plugin-chart-choropleth-map',
decorators: [withKnobs],
};
export const worldMap = () => {
const map = select(
'Map',
maps.map(m => m.key),
'world',
'map',
);
return (
<SuperChart
chartType="choropleth-map"
width={800}
height={450}
queryData={{ data: useFakeMapData(map) }}
formData={{
map,
encoding: {
key: {
field: 'key',
title: 'Location',
},
fill: {
type: 'quantitative',
field: 'numStudents',
scale: {
range: ['#cecee5', '#3f007d'],
},
},
},
}}
/>
);
};
export const usa = () => (
<SuperChart
chartType="choropleth-map"
width={800}
height={450}
queryData={{ data: useFakeMapData('usa') }}
formData={{
map: 'usa',
encoding: {
key: {
field: 'key',
title: 'State',
},
fill: {
type: 'quantitative',
field: 'numStudents',
title: 'No. of students',
scale: {
range: ['#fdc28c', '#7f2704'],
},
},
tooltip: [
{
field: 'favoriteFruit',
title: 'Fruit',
},
],
},
}}
/>
);
export const categoricalColor = () => (
<SuperChart
chartType="choropleth-map"
width={800}
height={450}
queryData={{ data: useFakeMapData('usa') }}
formData={{
map: 'usa',
encoding: {
key: {
field: 'key',
title: 'State',
},
fill: {
type: 'nominal',
field: 'favoriteFruit',
scale: {
domain: ['apple', 'banana', 'grape'],
range: ['#e74c3c', '#f1c40f', '#9b59b6'],
},
},
},
}}
/>
);

View File

@ -0,0 +1,26 @@
import loadMap from '../../../../../../plugins/plugin-chart-choropleth-map/src/chart/loadMap';
const FRUITS = ['apple', 'banana', 'grape'];
export type FakeMapData = {
key: string;
favoriteFruit: string;
numStudents: number;
}[];
/**
* Generate mock data for the given map
* Output is a promise of an array
* { key, favoriteFruit, numStudents }[]
* @param map map name
*/
export default async function generateFakeMapData(map: string) {
const { object, metadata } = await loadMap(map);
return object.features
.map(f => metadata.keyAccessor(f))
.map(key => ({
key,
favoriteFruit: FRUITS[Math.round(Math.random() * 2)],
numStudents: Math.round(Math.random() * 100),
}));
}

View File

@ -0,0 +1,14 @@
import { useState, useEffect } from 'react';
import generateFakeMapData, { FakeMapData } from './generateFakeMapData';
export default function useFakeMapData(map: string) {
const [mapData, setMapData] = useState<FakeMapData | undefined>(undefined);
useEffect(() => {
generateFakeMapData(map).then(mapData => {
setMapData(mapData);
});
}, [map]);
return mapData;
}

View File

@ -0,0 +1,32 @@
## @superset-ui/plugin-chart-choropleth-map
[![Version](https://img.shields.io/npm/v/@superset-ui/plugin-chart-choropleth-map.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/plugin-chart-choropleth-map.svg?style=flat-square)
[![David (path)](https://img.shields.io/david/apache-superset/superset-ui.svg?path=packages%2Fsuperset-ui-plugin-chart-choropleth-map&style=flat-square)](https://david-dm.org/apache-superset/superset-ui?path=packages/superset-ui-plugin-chart-choropleth-map)
This plugin provides Choropleth Map for Superset.
### Usage
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
```js
import ChoroplethMapChartPlugin from '@superset-ui/plugin-chart-choropleth-map';
new ChoroplethMapChartPlugin()
.configure({ key: 'choropleth-map' })
.register();
```
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui/?selectedKind=plugin-chart-choropleth-map) for more details.
```js
<SuperChart
chartType="choropleth-map"
width={600}
height={600}
formData={...}
queryData={{
data: {...},
}}
/>
```

View File

@ -0,0 +1,53 @@
{
"name": "@superset-ui/plugin-chart-choropleth-map",
"version": "0.0.0",
"description": "Superset Chart - Choropleth Map",
"sideEffects": false,
"private": true,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"repository": {
"type": "git",
"url": "git+https://github.com/apache-superset/superset-ui.git"
},
"keywords": [
"superset"
],
"author": "Superset",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/apache-superset/superset-ui/issues"
},
"homepage": "https://github.com/apache-superset/superset-ui#readme",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@types/react": "^16.9.35",
"@types/d3-geo": "^1.11.1",
"@types/geojson": "^7946.0.3",
"@types/topojson-client": "^3.0.0",
"@types/topojson-specification": "^1.0.0",
"@vx/clip-path": "^0.0.196",
"@vx/event": "^0.0.196",
"@vx/pattern": "^0.0.196",
"@vx/tooltip": "^0.0.196",
"@vx/zoom": "^0.0.196",
"encodable": "^0.3.7",
"geojson": "^0.5.0",
"lodash": "^4.17.15",
"topojson-client": "^3.1.0",
"d3-geo": "^1.12.0"
},
"peerDependencies": {
"react": "^16.13.1",
"@superset-ui/chart": "^0.13.3",
"@superset-ui/chart-composition": "^0.13.3",
"@superset-ui/translation": "^0.13.3",
"@superset-ui/style": "^0.13.3"
}
}

View File

@ -0,0 +1,322 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { t } from '@superset-ui/translation';
import { Zoom } from '@vx/zoom';
import { localPoint } from '@vx/event';
import { RectClipPath } from '@vx/clip-path';
import { withTooltip } from '@vx/tooltip';
import { keyBy } from 'lodash';
import { geoPath } from 'd3-geo';
import type { FeatureCollection } from 'geojson';
import { WithTooltipProvidedProps } from '@vx/tooltip/lib/enhancers/withTooltip';
import loadMap from './loadMap';
import MapMetadata from './MapMetadata';
import {
PADDING,
RelativeDiv,
IconButton,
TextButton,
ZoomControls,
MiniMapControl,
} from './components';
import {
ChoroplethMapEncoding,
choroplethMapEncoderFactory,
DefaultChannelOutputs,
} from './Encoder';
import MapTooltip, { MapDataPoint } from './MapTooltip';
const INITIAL_TRANSFORM = {
scaleX: 1,
scaleY: 1,
translateX: 0,
translateY: 0,
skewX: 0,
skewY: 0,
};
/**
* These props should be stored when saving the chart.
*/
export type ChoroplethMapVisualProps = {
encoding?: Partial<ChoroplethMapEncoding>;
map?: string;
};
export type ChoroplethMapProps = ChoroplethMapVisualProps &
WithTooltipProvidedProps<MapDataPoint> & {
data: Record<string, unknown>[];
height: number;
width: number;
};
const defaultProps = {
data: [],
encoding: {},
map: 'world',
};
const missingItem = DefaultChannelOutputs;
class ChoroplethMap extends React.PureComponent<
ChoroplethMapProps & typeof defaultProps,
{
mapShape?: {
metadata: MapMetadata;
object: FeatureCollection;
};
mapData: {
[key: string]: MapDataPoint;
};
showMiniMap: boolean;
}
> {
static defaultProps = defaultProps;
createEncoder = choroplethMapEncoderFactory.createSelector();
constructor(props: ChoroplethMapProps & typeof defaultProps) {
super(props);
this.state = {
mapData: {},
mapShape: undefined,
showMiniMap: true,
};
}
componentDidMount() {
this.loadMap();
this.processData();
}
componentDidUpdate(prevProps: ChoroplethMapProps) {
if (prevProps.map !== this.props.map) {
this.loadMap();
}
if (prevProps.data !== this.props.data || prevProps.encoding !== this.props.encoding) {
this.processData();
}
}
processData() {
const { data, encoding } = this.props;
const encoder = this.createEncoder(encoding);
const { key, fill, opacity, stroke, strokeWidth } = encoder.channels;
encoder.setDomainFromDataset(data);
const mapData = keyBy(
data.map(d => ({
key: key.getValueFromDatum<string>(d, DefaultChannelOutputs.key),
fill: fill.encodeDatum(d, DefaultChannelOutputs.fill),
opacity: opacity.encodeDatum(d, DefaultChannelOutputs.opacity),
stroke: stroke.encodeDatum(d, DefaultChannelOutputs.stroke),
strokeWidth: strokeWidth.encodeDatum(d, DefaultChannelOutputs.strokeWidth),
datum: d,
})),
d => d.key,
);
this.setState({ mapData });
}
loadMap() {
const { map } = this.props;
this.setState({ mapShape: undefined });
loadMap(map).then(mapShape => {
this.setState({ mapShape });
});
}
toggleMiniMap = () => {
const { showMiniMap } = this.state;
this.setState({
showMiniMap: !showMiniMap,
});
};
handleMouseOver = (event: React.MouseEvent<SVGPathElement>, datum?: MapDataPoint) => {
const coords = localPoint(event);
this.props.showTooltip({
tooltipLeft: coords?.x,
tooltipTop: coords?.y,
tooltipData: datum,
});
};
renderMap() {
const { height, width, hideTooltip } = this.props;
const { mapShape, mapData } = this.state;
if (typeof mapShape !== 'undefined') {
const { metadata, object } = mapShape;
const { keyAccessor } = metadata;
const projection = metadata.createProjection().fitExtent(
[
[PADDING, PADDING],
[width - PADDING * 2, height - PADDING * 2],
],
object,
);
const path = geoPath().projection(projection);
return object.features.map(f => {
const key = keyAccessor(f);
const encodedDatum = mapData[key] || missingItem;
const { stroke, fill, strokeWidth, opacity } = encodedDatum;
return (
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
<path
key={key}
vectorEffect="non-scaling-stroke"
stroke={stroke}
strokeWidth={strokeWidth}
fill={fill}
opacity={opacity}
d={path(f) || ''}
onMouseOver={event => this.handleMouseOver(event, encodedDatum)}
onMouseMove={event => this.handleMouseOver(event, encodedDatum)}
onMouseOut={hideTooltip}
onBlur={hideTooltip}
/>
);
});
}
return null;
}
render() {
const {
height,
width,
encoding,
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
} = this.props;
const { showMiniMap } = this.state;
const encoder = this.createEncoder(encoding);
const renderedMap = this.renderMap();
const miniMapTransform = `translate(${(width * 3) / 4 - PADDING}, ${
(height * 3) / 4 - PADDING
}) scale(0.25)`;
return (
<>
<Zoom
style={{ width, height }}
width={width}
height={height}
scaleXMin={0.75}
scaleXMax={8}
scaleYMin={0.75}
scaleYMax={8}
transformMatrix={INITIAL_TRANSFORM}
>
{zoom => (
<RelativeDiv>
<svg
width={width}
height={height}
style={{ cursor: zoom.isDragging ? 'grabbing' : 'grab' }}
>
<RectClipPath id="zoom-clip" width={width} height={height} />
<g
onWheel={zoom.handleWheel}
// eslint-disable-next-line react/jsx-handler-names
onMouseDown={zoom.dragStart}
// eslint-disable-next-line react/jsx-handler-names
onMouseMove={zoom.dragMove}
// eslint-disable-next-line react/jsx-handler-names
onMouseUp={zoom.dragEnd}
onMouseLeave={() => {
if (!zoom.isDragging) return;
zoom.dragEnd();
}}
onDoubleClick={event => {
const point = localPoint(event) || undefined;
zoom.scale({ scaleX: 1.1, scaleY: 1.1, point });
}}
>
<rect width={width} height={height} fill="transparent" />
<g transform={zoom.toString()}>{renderedMap}</g>
</g>
{showMiniMap && (
<g clipPath="url(#zoom-clip)" transform={miniMapTransform}>
<rect width={width} height={height} fill="#fff" stroke="#999" />
{renderedMap}
<rect
width={width}
height={height}
fill="white"
fillOpacity={0.2}
stroke="#999"
strokeWidth={4}
transform={zoom.toStringInvert()}
/>
</g>
)}
</svg>
<ZoomControls>
<IconButton type="button" onClick={() => zoom.scale({ scaleX: 1.2, scaleY: 1.2 })}>
+
</IconButton>
<IconButton type="button" onClick={() => zoom.scale({ scaleX: 0.8, scaleY: 0.8 })}>
-
</IconButton>
<TextButton
type="button"
// eslint-disable-next-line react/jsx-handler-names
onClick={zoom.clear}
>
Reset
</TextButton>
</ZoomControls>
<MiniMapControl>
<TextButton
type="button"
// eslint-disable-next-line react/jsx-handler-names
onClick={this.toggleMiniMap}
>
{showMiniMap ? t('Hide Mini Map') : t('Show Mini Map')}
</TextButton>
</MiniMapControl>
</RelativeDiv>
)}
</Zoom>
{tooltipOpen && (
<MapTooltip
encoder={encoder}
top={tooltipTop}
left={tooltipLeft}
tooltipData={tooltipData}
/>
)}
</>
);
}
}
export default withTooltip(ChoroplethMap);

View File

@ -0,0 +1,43 @@
import { createEncoderFactory, DeriveEncoding, Encoder, DeriveChannelOutputs } from 'encodable';
type ChoroplethMapEncodingConfig = {
key: ['Text', string];
fill: ['Color', string];
opacity: ['Numeric', number];
stroke: ['Color', string];
strokeWidth: ['Numeric', number];
tooltip: ['Text', string, 'multiple'];
};
export type ChoroplethMapEncoding = DeriveEncoding<ChoroplethMapEncodingConfig>;
export type ChoroplethMapEncoder = Encoder<ChoroplethMapEncodingConfig>;
export type ChoroplethMapChannelOutputs = DeriveChannelOutputs<ChoroplethMapEncodingConfig>;
export const DefaultChannelOutputs = {
key: '',
fill: '#f0f0f0',
opacity: 1,
stroke: '#ccc',
strokeWidth: 1,
};
export const choroplethMapEncoderFactory = createEncoderFactory<ChoroplethMapEncodingConfig>({
channelTypes: {
key: 'Text',
fill: 'Color',
opacity: 'Numeric',
stroke: 'Color',
strokeWidth: 'Numeric',
tooltip: 'Text',
},
defaultEncoding: {
key: { field: 'key', title: 'Location' },
fill: { value: DefaultChannelOutputs.fill },
opacity: { value: DefaultChannelOutputs.opacity },
stroke: { value: DefaultChannelOutputs.stroke },
strokeWidth: { value: DefaultChannelOutputs.strokeWidth },
tooltip: [],
},
});

View File

@ -0,0 +1,36 @@
import type { FeatureCollection } from 'geojson';
import { feature } from 'topojson-client';
import { get } from 'lodash/fp';
import { RawMapMetadata } from '../types';
import Projection from './Projection';
export default class MapMetadata {
config: RawMapMetadata;
keyAccessor: (...args: unknown[]) => string;
constructor(metadata: RawMapMetadata) {
const { keyField } = metadata;
this.config = metadata;
this.keyAccessor = get(keyField);
}
loadMap(): Promise<FeatureCollection> {
const { key } = this.config;
return this.config.type === 'topojson'
? this.config.load().then(map => feature(map, map.objects[key]) as FeatureCollection)
: this.config.load();
}
createProjection() {
const { projection = 'Mercator', rotate } = this.config;
const projectionFn = Projection[projection]();
if (rotate) {
projectionFn.rotate(rotate);
}
return projectionFn;
}
}

View File

@ -0,0 +1,57 @@
import React from 'react';
import { isCompleteFieldDef } from 'encodable';
import { uniqBy } from 'lodash';
import { Tooltip } from '@vx/tooltip';
import { TooltipFrame, TooltipTable } from '@superset-ui/chart-composition';
import { ChoroplethMapChannelOutputs, ChoroplethMapEncoder } from './Encoder';
export type MapDataPoint = Omit<ChoroplethMapChannelOutputs, 'tooltip'> & {
datum: Record<string, unknown>;
};
export type MapTooltipProps = {
top?: number;
left?: number;
encoder: ChoroplethMapEncoder;
tooltipData?: MapDataPoint;
};
export default function MapTooltip({ encoder, left, top, tooltipData }: MapTooltipProps) {
if (!tooltipData) {
return null;
}
const { channels } = encoder;
const { key, fill, stroke, strokeWidth, opacity, tooltip } = channels;
const { datum } = tooltipData;
const tooltipRows = [
{ key: 'key', keyColumn: key.getTitle(), valueColumn: key.formatDatum(datum) },
];
[fill, stroke, opacity, strokeWidth].forEach(channel => {
if (isCompleteFieldDef<string | number>(channel.definition)) {
tooltipRows.push({
key: channel.name as string,
keyColumn: channel.getTitle(),
valueColumn: channel.formatDatum(datum),
});
}
});
tooltip.forEach(g => {
tooltipRows.push({
key: `${g.name}`,
keyColumn: g.getTitle(),
valueColumn: g.formatDatum(datum),
});
});
return (
<Tooltip top={top} left={left}>
<TooltipFrame>
<TooltipTable data={uniqBy(tooltipRows, row => row.keyColumn)} />
</TooltipFrame>
</Tooltip>
);
}

View File

@ -0,0 +1,12 @@
import { geoMercator, geoEquirectangular, geoAlbers, geoAlbersUsa } from 'd3-geo';
const Projection = {
Mercator: geoMercator,
Equirectangular: geoEquirectangular,
Albers: geoAlbers,
AlbersUsa: geoAlbersUsa,
};
type Projection = keyof typeof Projection;
export default Projection;

View File

@ -0,0 +1,46 @@
import styled, { supersetTheme } from '@superset-ui/style';
export const PADDING = supersetTheme.gridUnit * 4;
export const RelativeDiv = styled.div`
position: relative;
`;
export const ZoomControls = styled.div`
position: absolute;
top: ${PADDING}px;
right: ${PADDING}px;
display: flex;
flex-direction: column;
align-items: flex-end;
`;
export const MiniMapControl = styled.div`
position: absolute;
bottom: ${PADDING + 6}px;
right: ${PADDING + 1}px;
`;
export const IconButton = styled.button`
width: ${({ theme }) => theme.gridUnit * 6}px;
font-size: ${({ theme }) => theme.typography.sizes.xl}px;
text-align: center;
color: #222;
margin: 0px;
margin-bottom: 2px;
background: #f5f8fb;
padding: 0px ${({ theme }) => theme.gridUnit}px;
border-radius: ${({ theme }) => theme.borderRadius}px;
border: none;
`;
export const TextButton = styled.button`
text-align: center;
font-size: ${({ theme }) => theme.typography.sizes.s}px;
color: #222;
margin: 0px;
background: #f5f8fb;
padding: ${({ theme }) => theme.gridUnit / 2}px ${({ theme }) => theme.gridUnit * 1.5}px;
border-radius: ${({ theme }) => theme.borderRadius}px;
border: none;
`;

View File

@ -0,0 +1,11 @@
import { mapsLookup } from '../maps';
import MapMetadata from './MapMetadata';
export default function loadMap(key: string) {
if (key in mapsLookup) {
const metadata = new MapMetadata(mapsLookup[key]);
return metadata.loadMap().then(object => ({ metadata, object }));
}
return Promise.reject(new Error(`Unknown map: ${key}`));
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,2 @@
export { default as ChoroplethMapChartPlugin } from './plugin';
export { maps, mapsLookup } from './maps';

View File

@ -0,0 +1,216 @@
import { keyBy } from 'lodash/fp';
import { RawMapMetadata } from '../types';
// Edit here if you are adding a new map
const mapsInfo: Record<string, Omit<RawMapMetadata, 'key'>> = {
belgium: {
name: 'Belgium',
type: 'topojson',
// @ts-ignore
load: () => import('./belgium-topo.json'),
keyField: 'properties.ISO',
},
brazil: {
name: 'Brazil',
type: 'topojson',
// @ts-ignore
load: () => import('./brazil-topo.json'),
keyField: 'properties.ISO',
},
bulgaria: {
name: 'Bulgaria',
type: 'topojson',
// @ts-ignore
load: () => import('./bulgaria-topo.json'),
keyField: 'properties.ISO',
},
canada: {
name: 'Canada',
type: 'topojson',
// @ts-ignore
load: () => import('./canada-topo.json'),
keyField: 'properties.NAME_1',
},
china: {
name: 'China',
type: 'topojson',
// @ts-ignore
load: () => import('./china-topo.json'),
keyField: 'properties.NAME_1',
},
france: {
name: 'France',
type: 'topojson',
// @ts-ignore
load: () => import('./france-topo.json'),
keyField: 'properties.ISO',
},
germany: {
name: 'Germany',
type: 'topojson',
// @ts-ignore
load: () => import('./germany-topo.json'),
keyField: 'properties.ISO',
},
india: {
name: 'India',
type: 'topojson',
// @ts-ignore
load: () => import('./india-topo.json'),
keyField: 'properties.ISO',
},
iran: {
name: 'Iran',
type: 'topojson',
// @ts-ignore
load: () => import('./iran-topo.json'),
keyField: 'properties.ISO',
},
italy: {
name: 'Italy',
type: 'topojson',
// @ts-ignore
load: () => import('./italy-topo.json'),
keyField: 'properties.ISO',
},
japan: {
name: 'Japan',
type: 'topojson',
// @ts-ignore
load: () => import('./japan-topo.json'),
keyField: 'properties.ISO',
},
korea: {
name: 'Korea',
type: 'topojson',
// @ts-ignore
load: () => import('./korea-topo.json'),
keyField: 'properties.ISO',
},
liechtenstein: {
name: 'Liechtenstein',
type: 'topojson',
// @ts-ignore
load: () => import('./liechtenstein-topo.json'),
keyField: 'properties.ISO',
},
morocco: {
name: 'Morocco',
type: 'topojson',
// @ts-ignore
load: () => import('./morocco-topo.json'),
keyField: 'properties.ISO',
},
myanmar: {
name: 'Myanmar',
type: 'topojson',
// @ts-ignore
load: () => import('./myanmar-topo.json'),
keyField: 'properties.ISO',
},
netherlands: {
name: 'Netherlands',
type: 'topojson',
// @ts-ignore
load: () => import('./netherlands-topo.json'),
keyField: 'properties.ISO',
},
portugal: {
name: 'Portugal',
type: 'topojson',
// @ts-ignore
load: () => import('./portugal-topo.json'),
keyField: 'properties.ISO',
},
russia: {
name: 'Russia',
type: 'topojson',
// @ts-ignore
load: () => import('./russia-topo.json'),
keyField: 'properties.ISO',
rotate: [-9, 0, 0],
},
singapore: {
name: 'Singapore',
type: 'topojson',
// @ts-ignore
load: () => import('./singapore-topo.json'),
keyField: 'properties.ISO',
},
spain: {
name: 'Spain',
type: 'topojson',
// @ts-ignore
load: () => import('./spain-topo.json'),
keyField: 'properties.ISO',
},
switzerland: {
name: 'Switzerland',
type: 'topojson',
// @ts-ignore
load: () => import('./switzerland-topo.json'),
keyField: 'properties.ISO',
},
thailand: {
name: 'Thailand',
type: 'topojson',
// @ts-ignore
load: () => import('./thailand-topo.json'),
keyField: 'properties.NAME_1',
},
timorleste: {
name: 'Timor-Leste',
type: 'topojson',
// @ts-ignore
load: () => import('./timorleste-topo.json'),
keyField: 'properties.ISO',
},
uk: {
name: 'United Kingdom',
type: 'topojson',
// @ts-ignore
load: () => import('./uk-topo.json'),
keyField: 'properties.ISO',
},
ukraine: {
name: 'Ukraine',
type: 'topojson',
// @ts-ignore
load: () => import('./ukraine-topo.json'),
keyField: 'properties.NAME_1',
},
usa: {
name: 'USA',
type: 'topojson',
// @ts-ignore
load: () => import('./usa-topo.json'),
keyField: 'properties.STATE',
projection: 'Albers',
},
world: {
name: 'World Map',
type: 'topojson',
// @ts-ignore
load: () => import('./world-topo.json'),
keyField: 'id',
projection: 'Equirectangular',
rotate: [-9, 0, 0],
},
zambia: {
name: 'Zambia',
type: 'topojson',
// @ts-ignore
load: () => import('./zambia-topo.json'),
keyField: 'properties.name',
},
};
/** List of available maps */
export const maps: RawMapMetadata[] = Object.entries(mapsInfo).map(
([key, metadata]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
({ ...metadata, key } as RawMapMetadata),
);
/** All maps indexed by map key */
export const mapsLookup = keyBy((m: RawMapMetadata) => m.key)(maps);

View File

@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/chart';
import transformProps from './transformProps';
import thumbnail from '../images/thumbnail.png';
const metadata = new ChartMetadata({
description: t('Choropleth Map'),
name: t('ChoroplethMap'),
thumbnail,
});
export default class ChoroplethMapChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('../chart/ChoroplethMap'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,33 @@
import { ChartProps } from '@superset-ui/chart';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, queryData } = chartProps;
const { map, encoding } = formData;
const { data } = queryData;
return {
width,
height,
map,
encoding,
data,
};
}

View File

@ -0,0 +1,24 @@
// eslint-disable-next-line import/no-unresolved
import type { Topology } from 'topojson-specification';
import type { FeatureCollection } from 'geojson';
import type Projection from './chart/Projection';
interface BaseMapMetadata {
key: string;
name: string;
keyField: string;
projection?: Projection;
rotate?: [number, number] | [number, number, number];
}
interface TopojsonMapMetadata extends BaseMapMetadata {
type: 'topojson';
load: () => Promise<Topology>;
}
interface GeojsonMapMetadata extends BaseMapMetadata {
type: 'geojson';
load: () => Promise<FeatureCollection>;
}
export type RawMapMetadata = TopojsonMapMetadata | GeojsonMapMetadata;

View File

@ -0,0 +1,7 @@
import { ChoroplethMapChartPlugin } from '../src';
describe('@superset-ui/plugin-chart-choropleth-map', () => {
it('exists', () => {
expect(ChoroplethMapChartPlugin).toBeDefined();
});
});

View File

@ -0,0 +1,4 @@
declare module '*.png' {
const value: any;
export default value;
}

View File

@ -33,7 +33,8 @@
"@types/react": "^16.3.0",
"d3-cloud": "^1.2.5",
"d3-scale": "^3.0.1",
"encodable": "^0.3.3"
"emotion-theming": "^10.0.27",
"encodable": "^0.3.7"
},
"peerDependencies": {
"@superset-ui/chart": "^0.13.0",

View File

@ -3705,6 +3705,13 @@
resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-1.3.1.tgz#35bf88264bd6bcda39251165bb827f67879c4384"
integrity sha512-KAWvReOKMDreaAwOjdfQMm0HjcUMlQG47GwqdVKgmm20vTd2pucj0a70c3gUSHrnsmo6H2AMrkBsZU2UhJLq8A==
"@types/d3-geo@^1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-1.11.1.tgz#e96ec91f16221d87507fec66b2cc889f52d2493e"
integrity sha512-Ox8WWOG3igDRoep/dNsGbOiSJYdUG3ew/6z0ETvHyAtXZVBjOE0S96zSSmzgl0gqQ3RdZjn2eeJOj9oRcMZPkQ==
dependencies:
"@types/geojson" "*"
"@types/d3-interpolate@^1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-1.3.1.tgz#1c280511f622de9b0b47d463fa55f9a4fd6f5fc8"
@ -3791,6 +3798,11 @@
resolved "https://registry.yarnpkg.com/@types/fetch-mock/-/fetch-mock-6.0.5.tgz#acbc6771d43d7ebc1f0a8b7e3d57147618f8eacb"
integrity sha512-rV8O2j/TIi0PtFCOlK55JnfKpE8Hm6PKFgrUZY/3FNHw4uBEMHnM+5ZickDO1duOyKxbpY3VES5T4NIwZXvodA==
"@types/geojson@*", "@types/geojson@^7946.0.3":
version "7946.0.7"
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.7.tgz#c8fa532b60a0042219cdf173ca21a975ef0666ad"
integrity sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==
"@types/glob@^7.1.1":
version "7.1.1"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"
@ -3945,7 +3957,7 @@
dependencies:
"@types/react" "*"
"@types/react-dom@^16.9.6":
"@types/react-dom@*", "@types/react-dom@^16.9.6":
version "16.9.8"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
@ -3988,7 +4000,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^16.3.0", "@types/react@^16.9.11", "@types/react@^16.9.34":
"@types/react@*", "@types/react@^16.3.0", "@types/react@^16.9.11", "@types/react@^16.9.34", "@types/react@^16.9.35":
version "16.9.35"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368"
integrity sha512-q0n0SsWcGc8nDqH2GJfWQWUOmZSJhXV64CjVN5SvcNti3TdEaA3AH0D8DwNmMdzjMAC/78tB8nAZIlV8yTz+zQ==
@ -4028,6 +4040,21 @@
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.5.tgz#9adbc12950582aa65ead76bffdf39fe0c27a3c02"
integrity sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ==
"@types/topojson-client@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/topojson-client/-/topojson-client-3.0.0.tgz#2517fae5abbdd3052eb191747c7d0bc268d69918"
integrity sha512-HZH6E8XMhjkDEkkpe3HuIg95COuvjdnyy0EKrh8rAi1f6o/V6P3ly1kGyU2E8bpAffXDd2r+Rk5ceMX4XkqHnA==
dependencies:
"@types/geojson" "*"
"@types/topojson-specification" "*"
"@types/topojson-specification@*", "@types/topojson-specification@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/topojson-specification/-/topojson-specification-1.0.1.tgz#a80cb294290b79f2d674d3f5938c544ed2bd9d80"
integrity sha512-ZZYZUgkmUls9Uhxx2WZNt9f/h2+H3abUUjOVmq+AaaDFckC5oAwd+MDp95kBirk+XCXrYj0hfpI6DSUiJMrpYQ==
dependencies:
"@types/geojson" "*"
"@types/tough-cookie@*":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d"
@ -4184,6 +4211,15 @@
dependencies:
prop-types "^15.5.10"
"@vx/bounds@0.0.196":
version "0.0.196"
resolved "https://registry.yarnpkg.com/@vx/bounds/-/bounds-0.0.196.tgz#1aa77d0ad71b35e4cea0f06d12eb880633eb0f1a"
integrity sha512-Az28AdWRheQJj1EefLUTSY3yzr3xPh+1IDgPxJMha1N2ZEa8YG+RA90icq2dtsaZ2PIDyvZx8hkPJkbN+SWgdQ==
dependencies:
"@types/react" "*"
"@types/react-dom" "*"
prop-types "^15.5.10"
"@vx/clip-path@0.0.140":
version "0.0.140"
resolved "https://registry.yarnpkg.com/@vx/clip-path/-/clip-path-0.0.140.tgz#b2623d004dd5c3c8a6afe8d060de59df51472d94"
@ -4194,6 +4230,14 @@
resolved "https://registry.yarnpkg.com/@vx/clip-path/-/clip-path-0.0.165.tgz#93cd65cc6a35319c7e403ce7b973ac1c8045b741"
integrity sha512-mBCbgguLMVyGvar5FbxqyyY4NQFlnXoSLF0TrhgWYkF/FCXdE1CzBC+Y4iXIJOY0ZTtluqL9XrNdIDpx49AmuA==
"@vx/clip-path@^0.0.196":
version "0.0.196"
resolved "https://registry.yarnpkg.com/@vx/clip-path/-/clip-path-0.0.196.tgz#a79e3fd5df0376a23a38b86a5eb7271a2a4fc4d6"
integrity sha512-yJmDaxEirHRUqJOGVaIUQQJ3HmV70JlT7TVXW//3LlNbAhR1gzBVnmi8wk02AvVog7h0NlR0jSHXCvGith8uLw==
dependencies:
"@types/react" "*"
prop-types "^15.5.10"
"@vx/curve@0.0.140":
version "0.0.140"
resolved "https://registry.yarnpkg.com/@vx/curve/-/curve-0.0.140.tgz#29ef388e8b3718213d66a896d569dc1ebc8edf89"
@ -4223,6 +4267,14 @@
dependencies:
"@vx/point" "0.0.136"
"@vx/event@0.0.196", "@vx/event@^0.0.196":
version "0.0.196"
resolved "https://registry.yarnpkg.com/@vx/event/-/event-0.0.196.tgz#be859997ef127819ef14a79e2447d737134b7dd2"
integrity sha512-Tv9AaXce3tuT5bSjvi26pHWVdRll44iYJVAmT4SPIK3brVxejngiL/Z+HCWeNjBg+YXexzKETJSbp8XjwFxohw==
dependencies:
"@types/react" "*"
"@vx/point" "0.0.196"
"@vx/event@^0.0.165":
version "0.0.165"
resolved "https://registry.yarnpkg.com/@vx/event/-/event-0.0.165.tgz#675d89fdfdc08d0c99c36ff1a381ea50fccfba2e"
@ -4378,6 +4430,16 @@
classnames "^2.2.5"
prop-types "^15.5.10"
"@vx/pattern@^0.0.196":
version "0.0.196"
resolved "https://registry.yarnpkg.com/@vx/pattern/-/pattern-0.0.196.tgz#f7caa7ce8a3445e14f532da593eb73cb8bad8f6c"
integrity sha512-doMzynNdIpwgXsTFQky3vv+wW9NcxBt8zjHSlseY/MUxAsmxOyi5YrEPPlzDyxhcFwDaA7+Xh1c5z+5Gokd7WA==
dependencies:
"@types/classnames" "^2.2.9"
"@types/react" "*"
classnames "^2.2.5"
prop-types "^15.5.10"
"@vx/point@0.0.136":
version "0.0.136"
resolved "https://registry.yarnpkg.com/@vx/point/-/point-0.0.136.tgz#93b325b4b95c9d5b96df740f4204017f57396559"
@ -4633,6 +4695,17 @@
classnames "^2.2.5"
prop-types "^15.5.10"
"@vx/tooltip@^0.0.196":
version "0.0.196"
resolved "https://registry.yarnpkg.com/@vx/tooltip/-/tooltip-0.0.196.tgz#1f35105b42064f593025594c0d8393ab3111bc1f"
integrity sha512-Lt45OA2dyIB+Fs+We++wL3bnsJurqugSwirmt+6xh4HtAcwnXUQXOqCdlFYfPKCqNukqw0yAPiaIuLxRRvhEvA==
dependencies:
"@types/classnames" "^2.2.9"
"@types/react" "*"
"@vx/bounds" "0.0.196"
classnames "^2.2.5"
prop-types "^15.5.10"
"@vx/voronoi@^0.0.165":
version "0.0.165"
resolved "https://registry.yarnpkg.com/@vx/voronoi/-/voronoi-0.0.165.tgz#11ab585199b0dccf403544a6ad378a505bfb913b"
@ -4643,6 +4716,15 @@
d3-voronoi "^1.1.2"
prop-types "^15.6.1"
"@vx/zoom@^0.0.196":
version "0.0.196"
resolved "https://registry.yarnpkg.com/@vx/zoom/-/zoom-0.0.196.tgz#22dcd91f1c058d2d5cd35f1d9a06a6b4d134a4e4"
integrity sha512-RGGREy4VcBx0z3miQQ8z/FZTuSptcdip2LiDKAnMlr/SJE8U5MGGAfxtkoLsbuIlJgatSHg0V86lCw7AY7EG9g==
dependencies:
"@types/react" "*"
"@vx/event" "0.0.196"
prop-types "^15.6.2"
"@webassemblyjs/ast@1.9.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964"
@ -7518,6 +7600,13 @@ d3-geo@^1.10.0, d3-geo@^1.11.9:
dependencies:
d3-array "1"
d3-geo@^1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.12.0.tgz#58ddbdf4d9db5f199db69d1b7c93dca6454a6f24"
integrity sha512-NalZVW+6/SpbKcnl+BCO67m8gX+nGeJdo6oGL9H6BRUGUL1e+AtPcP4vE4TwCQ/gl8y5KE7QvBzrLn+HsKIl+w==
dependencies:
d3-array "1"
d3-hierarchy@^1.1.8:
version "1.1.9"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
@ -8348,7 +8437,7 @@ emotion-theming@^10.0.19, emotion-theming@^10.0.27:
"@emotion/weak-memoize" "0.2.5"
hoist-non-react-statics "^3.3.0"
encodable@^0.3.3, encodable@^0.3.4:
encodable@^0.3.4:
version "0.3.5"
resolved "https://registry.yarnpkg.com/encodable/-/encodable-0.3.5.tgz#4d273623f86680939474b555183753c4976b3cc6"
integrity sha512-ofpVxFEYwFjgk94syrbdTc4nn6nxOO8rvJTieFz/Ko7V5oJMDpYwJYO87eL5xbc84XqD07vUJp50Zi54y+WAhw==
@ -8367,6 +8456,25 @@ encodable@^0.3.3, encodable@^0.3.4:
vega "^5.9.1"
vega-lite "~4.1.0"
encodable@^0.3.7:
version "0.3.7"
resolved "https://registry.yarnpkg.com/encodable/-/encodable-0.3.7.tgz#6906ab4b03be36108f70a4f3288882b601caec1c"
integrity sha512-aFbrhAsxPUN+OMZ28+N2tOqaeIFs4I1t/Li9+WmQwPFONrsu7InwMehLR3XTOQjmo+ucCBcSV9ABjQqMawgKNw==
dependencies:
"@types/d3-array" "^2.0.0"
"@types/d3-interpolate" "^1.3.1"
"@types/d3-scale" "^2.1.1"
"@types/d3-time" "^1.0.10"
"@types/lodash" "^4.14.149"
d3-array "^2.3.1"
d3-interpolate "^1.3.2"
d3-scale "^3.0.1"
d3-time "^1.0.11"
lodash "^4.17.15"
reselect "^4.0.0"
vega "^5.9.1"
vega-lite "~4.1.0"
encodeurl@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
@ -9761,6 +9869,11 @@ geojson-vt@^3.2.1:
resolved "https://registry.yarnpkg.com/geojson-vt/-/geojson-vt-3.2.1.tgz#f8adb614d2c1d3f6ee7c4265cad4bbf3ad60c8b7"
integrity sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==
geojson@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/geojson/-/geojson-0.5.0.tgz#3cd6c96399be65b56ee55596116fe9191ce701c0"
integrity sha1-PNbJY5m+ZbVu5VWWEW/pGRznAcA=
get-caller-file@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"