Files
superset2/superset-frontend/plugins/plugin-chart-country-map/src/plugin/controlPanel.tsx
Superset Dev 610c435872 feat(plugin-chart-country-map): wire up the "Show flying islands" toggle end-to-end
The control was removed earlier because it was a documented no-op —
the build pipeline repositioned features per flying_islands.yaml but
didn't tag them, so the runtime drop logic had nothing to filter on.
Now actually functional:

- build.py: apply_flying_islands sets `_flying: true` on every feature
  it repositions (FRA's 5 DROMs, USA's Hawaii/Alaska/Puerto Rico, ESP
  Canary Islands, NOR Svalbard, PRT Madeira/Azores, etc.)
- controlPanel.tsx: re-add the `show_flying_islands` checkbox (default
  on, renderTrigger so the projection refits without re-querying)
- CountryMap.tsx: filterFeatures drops _flying-tagged features when the
  toggle is off; fit-to-selection projection automatically zooms to
  the remaining mainland
- types: declare optional `_flying` property
- Tests: assert control presence + default value; remove the
  intentionally-absent assertion
- Regenerated 5 admin1 files + the france_overseas composite to embed
  the new tags (only countries with flying_islands rules differ)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 23:32:34 -07:00

625 lines
20 KiB
TypeScript

/**
* 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 { validateNonEmpty } from '@superset-ui/core';
import {
ControlPanelConfig,
D3_FORMAT_DOCS,
D3_FORMAT_OPTIONS,
getStandardizedControls,
} from '@superset-ui/chart-controls';
import manifest from '../data/manifest.json';
import migrateFromLegacy from './migrateFromLegacy';
// ----------------------------------------------------------------------
// Choice tables — sourced from the build pipeline's manifest.json
//
// The manifest is regenerated by every `./scripts/build.sh` run; adding
// a new worldview or country to the YAML configs and re-running build
// makes the new option appear in the UI without touching this file.
// ----------------------------------------------------------------------
interface CountryMapManifest {
worldviews: string[];
admin_levels: number[];
countries_by_worldview: Record<string, string[]>;
regional_aggregations: Array<{
country: string;
set_id: string;
worldview: string;
size_bytes: number;
}>;
composites: Array<{
id: string;
worldview: string;
size_bytes: number;
/** Anchor country (ISO_A3); when set, the composite is only offered
* while that country is selected. Older manifests omitted this field. */
country?: string;
}>;
}
const M = manifest as CountryMapManifest;
// Display-name labels for worldviews. Anything not listed here renders
// as the worldview code itself in the dropdown. The list of *available*
// worldviews is driven by the manifest (= what the build pipeline
// produced), not this map — so a worldview added to scripts/build.py
// and re-built will appear in the dropdown automatically, just with a
// raw code label until you add an entry here.
const WORLDVIEW_LABELS: Record<string, string> = {
default: t('Natural Earth Default'),
arg: t('Argentina'),
bdg: t('Bangladesh'),
bra: t('Brazil'),
chn: t('China'),
deu: t('Germany'),
egy: t('Egypt'),
esp: t('Spain'),
fra: t('France'),
gbr: t('United Kingdom'),
grc: t('Greece'),
idn: t('Indonesia'),
ind: t('India'),
iso: t('ISO 3166 (UN-style)'),
isr: t('Israel'),
ita: t('Italy'),
jpn: t('Japan'),
kor: t('South Korea'),
mar: t('Morocco'),
nep: t('Nepal'),
nld: t('Netherlands'),
pak: t('Pakistan'),
pol: t('Poland'),
prt: t('Portugal'),
pse: t('Palestine'),
rus: t('Russia'),
sau: t('Saudi Arabia'),
swe: t('Sweden'),
tur: t('Türkiye'),
twn: t('Taiwan'),
ukr: t('Ukraine (Superset default — Crimea as Ukrainian)'),
usa: t('United States'),
vnm: t('Vietnam'),
};
const WORLDVIEW_CHOICES: Array<[string, string]> = M.worldviews.map(wv => [
wv,
WORLDVIEW_LABELS[wv] || wv,
]);
// Map scope choices. The underlying values stay 0/1/aggregated so saved
// charts and form_data don't break — only the labels change for clarity.
const ADMIN_LEVEL_CHOICES: Array<[string, string]> = [
[String(0), t('World')],
[String(1), t('Country')],
['aggregated', t('Aggregated regions')],
];
// English country names by ISO_A3, for friendly dropdown labels. Codes
// not listed here render as the raw ISO code (rare; missing entries
// here aren't a correctness problem).
const COUNTRY_LABELS: Record<string, string> = {
AFG: 'Afghanistan',
ARG: 'Argentina',
AUS: 'Australia',
AUT: 'Austria',
BEL: 'Belgium',
BGD: 'Bangladesh',
BGR: 'Bulgaria',
BIH: 'Bosnia and Herzegovina',
BLR: 'Belarus',
BOL: 'Bolivia',
BRA: 'Brazil',
CAN: 'Canada',
CHE: 'Switzerland',
CHL: 'Chile',
CHN: 'China',
COL: 'Colombia',
CRI: 'Costa Rica',
CUB: 'Cuba',
CYP: 'Cyprus',
CZE: 'Czechia',
DEU: 'Germany',
DNK: 'Denmark',
DOM: 'Dominican Republic',
ECU: 'Ecuador',
EGY: 'Egypt',
ESP: 'Spain',
EST: 'Estonia',
ETH: 'Ethiopia',
FIN: 'Finland',
FRA: 'France',
GBR: 'United Kingdom',
GHA: 'Ghana',
GRC: 'Greece',
GTM: 'Guatemala',
HND: 'Honduras',
HRV: 'Croatia',
HUN: 'Hungary',
IDN: 'Indonesia',
IND: 'India',
IRL: 'Ireland',
IRN: 'Iran',
IRQ: 'Iraq',
ISL: 'Iceland',
ISR: 'Israel',
ITA: 'Italy',
JPN: 'Japan',
KAZ: 'Kazakhstan',
KEN: 'Kenya',
KGZ: 'Kyrgyzstan',
KHM: 'Cambodia',
KOR: 'South Korea',
LAO: 'Laos',
LBN: 'Lebanon',
LTU: 'Lithuania',
LVA: 'Latvia',
MAR: 'Morocco',
MEX: 'Mexico',
MMR: 'Myanmar',
MNG: 'Mongolia',
MYS: 'Malaysia',
NGA: 'Nigeria',
NLD: 'Netherlands',
NOR: 'Norway',
NPL: 'Nepal',
NZL: 'New Zealand',
PAK: 'Pakistan',
PER: 'Peru',
PHL: 'Philippines',
POL: 'Poland',
PRT: 'Portugal',
PRY: 'Paraguay',
ROU: 'Romania',
RUS: 'Russia',
SAU: 'Saudi Arabia',
SDN: 'Sudan',
SRB: 'Serbia',
SVK: 'Slovakia',
SVN: 'Slovenia',
SWE: 'Sweden',
SYR: 'Syria',
THA: 'Thailand',
TUR: 'Türkiye',
TWN: 'Taiwan',
UKR: 'Ukraine',
URY: 'Uruguay',
USA: 'United States',
UZB: 'Uzbekistan',
VEN: 'Venezuela',
VNM: 'Vietnam',
YEM: 'Yemen',
ZAF: 'South Africa',
ZWE: 'Zimbabwe',
};
const formatCountry = (code: string): string =>
COUNTRY_LABELS[code] ? `${COUNTRY_LABELS[code]} (${code})` : code;
// Build country choices from the union of all worldview manifests
// (typically the same set since Admin 1 is one global file, but
// future per-worldview Admin 1 outputs would naturally differ).
const COUNTRY_CHOICES: Array<[string, string]> = (() => {
const all = new Set<string>();
Object.values(M.countries_by_worldview).forEach(codes =>
codes.forEach(c => all.add(c)),
);
return Array.from(all)
.sort()
.map<[string, string]>(c => [c, formatCountry(c)]);
})();
// Region-set labels keyed by `<set_id>` (TUR's `nuts_1` etc.).
const REGION_SET_LABELS: Record<string, string> = {
nuts_1: t('NUTS-1 statistical regions'),
regions: t('Administrative regions'),
};
// Build {country: [(set_id, label), ...]} from manifest.
const REGION_SET_CHOICES_BY_COUNTRY: Record<
string,
Array<[string, string]>
> = (() => {
const out: Record<string, Array<[string, string]>> = {};
M.regional_aggregations.forEach(r => {
out[r.country] = out[r.country] || [];
out[r.country].push([r.set_id, REGION_SET_LABELS[r.set_id] || r.set_id]);
});
return out;
})();
// Composite-map labels keyed by `<id>`.
const COMPOSITE_LABELS: Record<string, string> = {
france_overseas: t('France (with overseas territories)'),
};
// Composites grouped by anchor country (ISO_A3). Composites without a
// country in the manifest fall into a "global" bucket and remain
// available regardless of the country selection — keeps backward
// compatibility with older manifests that didn't carry the field.
const COMPOSITES_GLOBAL: Array<[string, string]> = M.composites
.filter(c => !c.country)
.map(c => [c.id, COMPOSITE_LABELS[c.id] || c.id]);
const COMPOSITES_BY_COUNTRY: Record<string, Array<[string, string]>> = (() => {
const out: Record<string, Array<[string, string]>> = {};
M.composites.forEach(c => {
if (!c.country) return;
out[c.country] = out[c.country] || [];
out[c.country].push([c.id, COMPOSITE_LABELS[c.id] || c.id]);
});
return out;
})();
const COUNTRIES_WITH_COMPOSITE = new Set(Object.keys(COMPOSITES_BY_COUNTRY));
const compositeChoicesFor = (
controls: Record<string, { value?: unknown }>,
): Array<[string, string]> => {
const country = String(controls.country?.value || '');
return [
...(country && COMPOSITES_BY_COUNTRY[country]
? COMPOSITES_BY_COUNTRY[country]
: []),
...COMPOSITES_GLOBAL,
];
};
// NE NAME_<lang> language codes available across most features.
const NAME_LANGUAGE_CHOICES: Array<[string, string]> = [
['en', t('English (en)')],
['fr', t('French (fr)')],
['de', t('German (de)')],
['es', t('Spanish (es)')],
['it', t('Italian (it)')],
['pt', t('Portuguese (pt)')],
['ru', t('Russian (ru)')],
['zh', t('Chinese (zh)')],
['ja', t('Japanese (ja)')],
['ko', t('Korean (ko)')],
['vi', t('Vietnamese (vi)')],
['ar', t('Arabic (ar)')],
['hi', t('Hindi (hi)')],
['fa', t('Persian (fa)')],
['tr', t('Turkish (tr)')],
['nl', t('Dutch (nl)')],
['pl', t('Polish (pl)')],
['sv', t('Swedish (sv)')],
['el', t('Greek (el)')],
['he', t('Hebrew (he)')],
];
// ----------------------------------------------------------------------
// Visibility helpers
// ----------------------------------------------------------------------
const isAdminCountry = (controls: Record<string, { value?: unknown }>) =>
controls.admin_level?.value === String(0) ||
controls.admin_level?.value === 0;
const isAdminAggregated = (controls: Record<string, { value?: unknown }>) =>
controls.admin_level?.value === 'aggregated';
const hasComposite = (controls: Record<string, { value?: unknown }>) =>
Boolean(controls.composite?.value);
// A country selection is only *required* when the user is rendering
// subdivisions or an aggregated layer AND has not picked a composite.
// At Admin 0 (world choropleth) or with a composite set, country is moot.
const needsCountry = (controls: Record<string, { value?: unknown }>) =>
!isAdminCountry(controls) && !hasComposite(controls);
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Map'),
expanded: true,
controlSetRows: [
[
{
name: 'worldview',
config: {
type: 'SelectControl',
label: t('Worldview'),
description: t(
'Cartographic perspective for disputed regions. ' +
'Defaults to Ukraine worldview (Crimea shown as Ukrainian); ' +
'override per-deployment in superset_config.COUNTRY_MAP.default_worldview.',
),
choices: WORLDVIEW_CHOICES,
default: 'ukr',
renderTrigger: true,
clearable: false,
},
},
],
[
{
name: 'admin_level',
config: {
type: 'SelectControl',
label: t('Map view'),
description: t(
'World shows all countries; Country shows subdivisions of ' +
'one country (states/provinces/departments); Aggregated ' +
"regions dissolves a country's subdivisions into coarser " +
'administrative regions (e.g. French regions, Turkish ' +
'NUTS-1 regions). Stored as admin_level (0 / 1 / aggregated).',
),
choices: ADMIN_LEVEL_CHOICES,
default: String(0),
renderTrigger: true,
clearable: false,
},
},
],
[
{
name: 'country',
config: {
type: 'SelectControl',
label: t('Country'),
description: t('Which country to plot subdivisions for.'),
choices: COUNTRY_CHOICES,
default: null,
renderTrigger: true,
clearable: false,
// Country is only required when the current admin_level /
// composite combination actually consumes it. Without this,
// a hidden empty country traps the user with a permanent
// "Country: cannot be empty" badge on the Data tab even
// though there's no Country control on that tab.
mapStateToProps: ({ controls }: any) => ({
validators: needsCountry(controls) ? [validateNonEmpty] : [],
}),
visibility: ({ controls }: any) => needsCountry(controls),
},
},
],
[
{
name: 'region_set',
config: {
type: 'SelectControl',
label: t('Aggregated region set'),
description: t(
'Which administrative region layer to dissolve into. ' +
'Available sets depend on the selected country.',
),
default: null,
renderTrigger: true,
clearable: true,
// SelectControl's `choices` must be a literal array, not a
// function. Use mapStateToProps to derive choices from the
// currently selected country at render time.
mapStateToProps: ({ controls }: any) => ({
choices:
REGION_SET_CHOICES_BY_COUNTRY[
String(controls.country?.value || '')
] || [],
}),
visibility: ({ controls }: any) => isAdminAggregated(controls),
},
},
],
[
{
name: 'composite',
config: {
type: 'SelectControl',
label: t('Composite map'),
description: t(
'Multi-country composite (e.g. France with overseas territories). ' +
'Only offered when the selected country has a composite ' +
'definition; overrides admin level + country when set.',
),
default: null,
renderTrigger: true,
clearable: true,
// Choices narrow to whatever composites are scoped to the
// currently selected country, plus any country-agnostic
// ("global") composites in the manifest.
mapStateToProps: ({ controls }: any) => ({
choices: compositeChoicesFor(controls),
}),
// Hide when nothing applies: not at the world-map view, not
// when there are simply no composites in the manifest, and
// not when the selected country has no scoped composite
// (and no global composite is available either).
visibility: ({ controls }: any) => {
if (isAdminCountry(controls)) return false;
if (COMPOSITES_GLOBAL.length > 0) return true;
const country = String(controls.country?.value || '');
return COUNTRIES_WITH_COMPOSITE.has(country);
},
},
},
],
[
{
name: 'region_includes',
config: {
type: 'SelectControl',
multi: true,
freeForm: true,
label: t('Include only regions'),
description: t(
'Comma-separated ISO codes (iso_3166_2 or adm0_a3). ' +
'When set, only these features are rendered. Projection ' +
'auto-fits to the included set.',
),
choices: [],
default: [],
renderTrigger: true,
},
},
],
[
{
name: 'region_excludes',
config: {
type: 'SelectControl',
multi: true,
freeForm: true,
label: t('Exclude regions'),
description: t(
'Comma-separated ISO codes to drop from the rendered map.',
),
choices: [],
default: [],
renderTrigger: true,
},
},
],
[
{
name: 'show_flying_islands',
config: {
type: 'CheckboxControl',
label: t('Show flying islands'),
description: t(
'When on (default), territories that the build pipeline ' +
'repositioned into insets near the mainland (e.g. ' +
'Hawaii/Alaska for US, French DROMs for France) are ' +
'visible. Turn off to drop those features entirely; the ' +
'projection auto-refits to the remaining mainland.',
),
default: true,
renderTrigger: true,
},
},
],
[
{
name: 'name_language',
config: {
type: 'SelectControl',
label: t('Name language'),
description: t(
'Which language to use for displayed region names. ' +
"Falls back to English when the requested language isn't " +
'available for a feature.',
),
choices: NAME_LANGUAGE_CHOICES,
default: 'en',
renderTrigger: true,
clearable: false,
},
},
],
],
},
{
label: t('Query'),
expanded: true,
controlSetRows: [
[
{
name: 'entity',
config: {
type: 'SelectControl',
label: t('ISO code column'),
description: t(
'Column in your dataset containing ISO codes that match ' +
'features in the chosen map (iso_3166_2 for subdivisions, ' +
'adm0_a3 for countries).',
),
mapStateToProps: (state: any) => ({
choices: (state.datasource?.columns ?? []).map((c: any) => [
c.column_name,
c.column_name,
]),
}),
validators: [validateNonEmpty],
},
},
],
['metric'],
['adhoc_filters'],
['row_limit'],
],
},
{
label: t('Chart Options'),
expanded: true,
tabOverride: 'customize',
controlSetRows: [
[
{
name: 'number_format',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Number format'),
renderTrigger: true,
default: 'SMART_NUMBER',
choices: D3_FORMAT_OPTIONS,
description: D3_FORMAT_DOCS,
},
},
],
['linear_color_scheme'],
],
},
],
controlOverrides: {
entity: {
label: t('ISO code column'),
description: t(
'Column containing ISO codes of region/province/department in your dataset.',
),
},
linear_color_scheme: {
renderTrigger: true,
},
// Choropleths key one row per region — even the densest country maps
// (e.g. France 101 departments, India 36 states, US 51 territories)
// are well under 10k rows. The shared row_limit default of 50k is
// both wasteful and exceeds many deployments' configured ROW_LIMIT
// ceiling, blocking Update Chart with no visible explanation.
row_limit: {
default: 10000,
},
},
// formDataOverrides runs when the user switches a chart's viz_type to
// this plugin. We use it for two jobs:
// 1. Standard control hand-off (entity, metric) via getStandardizedControls
// 2. Migration from the legacy country_map plugin — translate the
// legacy `select_country` value into admin_level / country /
// composite / region_set so the new chart lands pre-populated
// rather than dumping the user back to an empty Country dropdown.
formDataOverrides: formData => {
const fromLegacy =
typeof formData.select_country === 'string' && formData.select_country
? migrateFromLegacy(formData)
: {};
return {
...formData,
entity: getStandardizedControls().shiftColumn(),
metric: getStandardizedControls().shiftMetric(),
// Only fill fields the user has not already set on the new chart;
// explicit user edits on the new viz win over legacy migration.
...Object.fromEntries(
Object.entries(fromLegacy).filter(
([k]) => formData[k as keyof typeof formData] == null,
),
),
};
},
};
export default config;