diff --git a/superset-frontend/plugins/plugin-chart-country-map/README.md b/superset-frontend/plugins/plugin-chart-country-map/README.md new file mode 100644 index 00000000000..5f47a0bb46c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/README.md @@ -0,0 +1,42 @@ +# `@superset-ui/plugin-chart-country-map` + +> Modern country/region choropleth chart for Apache Superset. Replaces `legacy-plugin-chart-country-map`. + +## Status + +🚧 **Work in progress** β€” see `SIP_DRAFT.md` in this directory for the full design rationale and implementation phases. This plugin lives in the same PR as the build pipeline that produces its data; both are currently scaffolded and being progressively fleshed out. + +## What it offers vs. the legacy plugin + +| | Legacy | New | +|---|---|---| +| Backend endpoint | `explore_json` | `chart/data` (modern) | +| Disputed-region handling | Hardcoded NE editorial | Configurable per-deployment + per-chart worldview (NE `_ukr` default) | +| Subdivisions level | Country-only | Country (Admin 0) **and** Subdivisions (Admin 1) **and** Aggregated regions | +| Data pipeline | Jupyter notebook | Reproducible script + YAML configs (see `scripts/`) | +| Per-deployment customization | Fork required | `superset_config.COUNTRY_MAP` block + chart-level controls | +| Composite maps (e.g. France-with-Overseas) | Hardcoded in notebook | Declarative in `composite_maps.yaml` | +| Regional aggregations (NUTS-1 etc.) | Hardcoded | Declarative in `regional_aggregations.yaml` | + +## Layout + +``` +src/ + index.ts β€” package entry; exports CountryMapChartPlugin and types + types.ts β€” TypeScript types for form data + transform props + CountryMap.tsx β€” React component; renders the SVG chart + plugin/ + index.ts β€” ChartPlugin class with metadata + buildQuery.ts β€” modern chart/data query builder + controlPanel.tsxβ€” form controls (worldview, admin level, country, ...) + transformProps.ts β€” form_data β†’ renderer props +scripts/ β€” build pipeline (NE shapefile β†’ simplified GeoJSON outputs) +SIP_DRAFT.md β€” design draft for the eventual SIP issue +``` + +## See also + +- `SIP_DRAFT.md` β€” design rationale, audit of legacy notebook, obsolescence findings, open questions +- `scripts/README.md` β€” build pipeline operating principles +- `scripts/config/*.yaml` β€” declarative transform configs (5 schemas for the 5 transform categories) +- `scripts/procedural/README.md` β€” escape hatch for edge cases YAML can't express diff --git a/superset-frontend/plugins/plugin-chart-country-map/package.json b/superset-frontend/plugins/plugin-chart-country-map/package.json new file mode 100644 index 00000000000..4f352644aaf --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/package.json @@ -0,0 +1,55 @@ +{ + "name": "@superset-ui/plugin-chart-country-map", + "version": "0.20.3", + "description": "Superset Chart - Configurable country/region choropleth map (replaces legacy-plugin-chart-country-map)", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "repository": { + "type": "git", + "url": "https://github.com/apache/superset.git", + "directory": "superset-frontend/plugins/plugin-chart-country-map" + }, + "keywords": [ + "superset", + "geo", + "choropleth", + "map" + ], + "author": "Superset", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/apache/superset/issues" + }, + "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-country-map#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-geo": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0" + }, + "peerDependencies": { + "@superset-ui/chart-controls": "*", + "@apache-superset/core": "*", + "@superset-ui/core": "*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/d3-array": "^3.2.1", + "@types/d3-color": "^3.1.3", + "@types/d3-geo": "^3.1.0", + "@types/d3-scale": "^4.0.9", + "@types/d3-selection": "^3.0.11", + "@types/jest": "^30.0.0", + "jest": "^30.3.0" + } +} diff --git a/superset-frontend/plugins/plugin-chart-country-map/src/CountryMap.tsx b/superset-frontend/plugins/plugin-chart-country-map/src/CountryMap.tsx new file mode 100644 index 00000000000..7137a95bdba --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/src/CountryMap.tsx @@ -0,0 +1,85 @@ +/** + * 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 { FC, useEffect, useRef, useState } from 'react'; +import { CountryMapTransformedProps } from './types'; + +/** + * Placeholder renderer. Real implementation in the next commit will + * port the legacy plugin's D3 rendering logic, then progressively + * incorporate: + * - region include/exclude client-side filtering + * - flying-islands toggle (drop matched features when off) + * - fit-to-selection projection refit + * - tooltip + cross-filter integration + * - composite projections (geoAlbersUsa etc.) when configured + */ +const CountryMap: FC = props => { + const { width, height, geoJsonUrl, data, metricName } = props; + const ref = useRef(null); + const [error, setError] = useState(null); + const [featureCount, setFeatureCount] = useState(null); + + useEffect(() => { + if (!geoJsonUrl) { + setError('No GeoJSON URL resolved (check worldview / admin_level / country).'); + return; + } + setError(null); + fetch(geoJsonUrl) + .then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status} fetching ${geoJsonUrl}`); + return r.json(); + }) + .then((geo: GeoJSON.FeatureCollection) => { + setFeatureCount(geo.features?.length ?? 0); + }) + .catch(e => setError(String(e))); + }, [geoJsonUrl]); + + return ( +
+
+ Country Map (scaffold) +
+
geoJsonUrl: {geoJsonUrl ?? '(none)'}
+
features loaded: {featureCount ?? '(loading)'}
+
data rows: {data.length}
+
metric: {metricName ?? '(none)'}
+ {error && ( +
error: {error}
+ )} +
+ ); +}; + +export default CountryMap; diff --git a/superset-frontend/plugins/plugin-chart-country-map/src/index.ts b/superset-frontend/plugins/plugin-chart-country-map/src/index.ts new file mode 100644 index 00000000000..4d1b2b8cc3c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/src/index.ts @@ -0,0 +1,21 @@ +/** + * 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. + */ +// eslint-disable-next-line import/prefer-default-export +export { default as CountryMapChartPlugin } from './plugin'; +export * from './types'; diff --git a/superset-frontend/plugins/plugin-chart-country-map/src/plugin/buildQuery.ts b/superset-frontend/plugins/plugin-chart-country-map/src/plugin/buildQuery.ts new file mode 100644 index 00000000000..3e1e0f3da5f --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/src/plugin/buildQuery.ts @@ -0,0 +1,41 @@ +/** + * 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 { + buildQueryContext, + normalizeOrderBy, + QueryFormData, +} from '@superset-ui/core'; + +/** + * The new country map uses the modern chart/data endpoint via + * buildQueryContext (the legacy plugin used explore_json directly). + * + * The data query itself is straightforward: one row per region + * (matched against the GeoJSON's iso_3166_2 / adm0_a3 properties), + * one metric column. The geographic data is loaded separately on the + * client from the build pipeline's GeoJSON outputs. + */ +export default function buildQuery(formData: QueryFormData) { + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + orderby: normalizeOrderBy(baseQueryObject).orderby, + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-country-map/src/plugin/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-country-map/src/plugin/controlPanel.tsx new file mode 100644 index 00000000000..12313f76950 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/src/plugin/controlPanel.tsx @@ -0,0 +1,85 @@ +/** + * 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 '@apache-superset/core/translation'; +import { + ControlPanelConfig, + sections, +} from '@superset-ui/chart-controls'; + +/** + * Minimal first-pass control panel. Subsequent commits will: + * - Populate the worldview + admin-level + country selectors with + * options derived from the build pipeline's manifest + * - Add region include/exclude multi-selects, flying-islands toggle, + * name-language picker, and the regional/composite layer pickers + * - Wire up dependent visibility (country only when admin_level=1, etc.) + */ +const config: ControlPanelConfig = { + controlPanelSections: [ + sections.legacyTimeseriesTime, + { + label: t('Query'), + expanded: true, + controlSetRows: [ + ['metric'], + ['adhoc_filters'], + ['row_limit'], + ], + }, + { + label: t('Map'), + expanded: true, + controlSetRows: [ + // TODO: worldview selector β€” pull options from the build manifest. + // TODO: admin_level segmented control (0 / 1 / Aggregated). + // TODO: country picker (visible when admin_level !== 0). + // TODO: region_set selector (when 'Aggregated' chosen). + // TODO: composite selector (when applicable). + // TODO: region_includes / region_excludes multi-selects. + // TODO: show_flying_islands toggle (default: true). + // TODO: name_language selector. + [ + { + name: 'select_country_placeholder', + config: { + type: 'TextControl', + label: t('Country (placeholder)'), + description: t( + 'Placeholder field β€” replaced in next commit with the full ' + + 'control set (worldview, admin level, country picker, etc.)', + ), + default: '', + renderTrigger: false, + }, + }, + ], + ], + }, + { + label: t('Chart Options'), + expanded: true, + controlSetRows: [ + ['linear_color_scheme'], + ['number_format'], + ], + }, + ], +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-country-map/src/plugin/index.ts b/superset-frontend/plugins/plugin-chart-country-map/src/plugin/index.ts new file mode 100644 index 00000000000..1a8bed43e70 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/src/plugin/index.ts @@ -0,0 +1,72 @@ +/** + * 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 '@apache-superset/core/translation'; +import { ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; + +/** + * Modern Country Map plugin. + * + * Replaces `legacy-plugin-chart-country-map`. Built against the + * `chart/data` endpoint with full async/caching/semantic-layer + * integration. Data driven by the build pipeline at + * `superset-frontend/plugins/plugin-chart-country-map/scripts/`. + * + * Default editorial position: ships Natural Earth's `_ukr` worldview, + * configurable via `superset_config.COUNTRY_MAP.default_worldview`. + * See `SIP_DRAFT.md` for design rationale and discussion of disputed + * regions. + */ +export default class CountryMapChartPlugin extends ChartPlugin { + constructor() { + const metadata = new ChartMetadata({ + category: t('Map'), + credits: ['Natural Earth (https://www.naturalearthdata.com/)'], + description: t( + "Visualizes a metric across a country's principal subdivisions " + + '(states, provinces, departments, etc.) on a choropleth map. ' + + 'Supports configurable worldview for disputed regions, multi-' + + 'country composites (e.g. France with overseas territories), ' + + 'and aggregated regional layers.', + ), + name: t('Country Map'), + tags: [ + t('2D'), + t('Comparison'), + t('Geo'), + t('Range'), + t('Report'), + ], + // TODO: thumbnail + example images come in a follow-up commit + // (need to render real outputs first). + thumbnail: '', + }); + + super({ + buildQuery, + controlPanel, + // Lazy-load the React renderer to keep the chart-type registry small. + loadChart: () => import('../CountryMap'), + metadata, + transformProps, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-country-map/src/plugin/transformProps.ts b/superset-frontend/plugins/plugin-chart-country-map/src/plugin/transformProps.ts new file mode 100644 index 00000000000..0f259cdf2da --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/src/plugin/transformProps.ts @@ -0,0 +1,72 @@ +/** + * 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 { + CountryMapChartProps, + CountryMapTransformedProps, +} from '../types'; + +/** + * Translate Superset's standard ChartProps into the shape the renderer + * needs. Notable: derive `geoJsonUrl` from the form data so the renderer + * can fetch the right output from the build pipeline. + * + * URL layout (matches the build script's output naming): + * _admin0.geo.json β€” world choropleth + * _admin1_.geo.json β€” country subdivisions + * regional___.geo.json β€” aggregated regions + * composite__.geo.json β€” composite maps + * + * The actual hosting path is wired up in a follow-up commit; for now + * the renderer prefixes URLs with a stubbed base. + */ +const GEOJSON_BASE = '/static/assets/country-maps'; + +export default function transformProps( + chartProps: CountryMapChartProps, +): CountryMapTransformedProps { + const { formData, queriesData, width, height } = chartProps; + const data = (queriesData?.[0]?.data as Record[]) ?? []; + + const worldview = formData.worldview || 'ukr'; + const adminLevel = formData.admin_level ?? 0; + + let geoJsonUrl: string | null = null; + if (formData.composite) { + geoJsonUrl = `${GEOJSON_BASE}/composite_${formData.composite}_${worldview}.geo.json`; + } else if (formData.region_set && formData.country) { + geoJsonUrl = + `${GEOJSON_BASE}/regional_${formData.country}_${formData.region_set}_${worldview}.geo.json`; + } else if (adminLevel === 1 && formData.country) { + geoJsonUrl = + `${GEOJSON_BASE}/${worldview}_admin1_${formData.country}.geo.json`; + } else if (adminLevel === 0) { + geoJsonUrl = `${GEOJSON_BASE}/${worldview}_admin0.geo.json`; + } + + return { + width, + height, + formData, + data, + geoJsonUrl, + metricName: typeof formData.metric === 'string' ? formData.metric : null, + numberFormat: formData.number_format, + linearColorScheme: formData.linear_color_scheme, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-country-map/src/types.ts b/superset-frontend/plugins/plugin-chart-country-map/src/types.ts new file mode 100644 index 00000000000..1aeefa41bd9 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/src/types.ts @@ -0,0 +1,85 @@ +/** + * 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 { ChartProps, QueryFormData } from '@superset-ui/core'; + +/** Admin levels supported by the plugin. */ +export type AdminLevel = 0 | 1; + +/** + * Identifier of one of the per-region-set dissolved outputs (e.g. `nuts_1` + * for TΓΌrkiye, `regions` for France/Italy/Philippines), shipped from the + * build pipeline's `regional_aggregations.yaml`. + */ +export type RegionSetId = string; + +/** + * Form data shape for the new country map. All fields are optional except + * the standard `viz_type`/`datasource` etc. inherited from QueryFormData. + */ +export interface CountryMapFormData extends QueryFormData { + /** NE worldview code (e.g. `ukr`, `default`, `ind`). Defaults to repo-configured value. */ + worldview?: string; + + /** 0 = countries, 1 = subdivisions (or aggregated regions when region_set is set). */ + admin_level?: AdminLevel; + + /** ISO_A3 country code; required when admin_level === 1 (and not a composite). */ + country?: string; + + /** Identifier from regional_aggregations.yaml; selects an aggregated region layer. */ + region_set?: RegionSetId; + + /** Identifier from composite_maps.yaml; selects a composite map (e.g. france_overseas). */ + composite?: string; + + /** ISO codes to keep; if non-empty, filter rendered features to these. */ + region_includes?: string[]; + /** ISO codes to drop; mutually exclusive with the above in normal use. */ + region_excludes?: string[]; + + /** When true (default), repositioned flying islands are visible; when false, they are dropped. */ + show_flying_islands?: boolean; + + /** NE NAME_ field code (e.g. `en`, `fr`, `de`, `vi`). */ + name_language?: string; + + // ---- Inherited / shared ---- // + /** Chosen metric to color the choropleth by. */ + metric?: string; + /** Color scheme name from @superset-ui/core. */ + linear_color_scheme?: string; + /** Number-format string for tooltip values. */ + number_format?: string; +} + +/** Props shape passed to the renderer after transformProps. */ +export interface CountryMapTransformedProps { + width: number; + height: number; + formData: CountryMapFormData; + data: Array>; + // The resolved GeoJSON URL the renderer should fetch β€” derived from + // formData fields by transformProps; null if unsatisfiable. + geoJsonUrl: string | null; + metricName: string | null; + numberFormat: string | undefined; + linearColorScheme: string | undefined; +} + +export type CountryMapChartProps = ChartProps; diff --git a/superset-frontend/plugins/plugin-chart-country-map/tsconfig.json b/superset-frontend/plugins/plugin-chart-country-map/tsconfig.json new file mode 100644 index 00000000000..8992e79cb1c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../..", + "outDir": "lib", + "rootDir": "src", + "declarationDir": "lib" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"], + "exclude": [ + "src/**/*.js", + "src/**/*.jsx", + "src/**/*.test.*", + "src/**/*.stories.*" + ], + "references": [ + { "path": "../../packages/superset-core" }, + { "path": "../../packages/superset-ui-core" }, + { "path": "../../packages/superset-ui-chart-controls" } + ] +} diff --git a/superset-frontend/plugins/plugin-chart-country-map/types/external.d.ts b/superset-frontend/plugins/plugin-chart-country-map/types/external.d.ts new file mode 100644 index 00000000000..245c30d8d1f --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-country-map/types/external.d.ts @@ -0,0 +1,30 @@ +/** + * 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. + */ +declare module '*.png' { + const value: string; + export default value; +} +declare module '*.jpg' { + const value: string; + export default value; +} +declare module '*.geojson' { + const value: GeoJSON.FeatureCollection; + export default value; +}