/** * 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; 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 = { 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 = { 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(); 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 `` (TUR's `nuts_1` etc.). const REGION_SET_LABELS: Record = { 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> = {}; 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 ``. const COMPOSITE_LABELS: Record = { 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> = (() => { const out: Record> = {}; 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, ): Array<[string, string]> => { const country = String(controls.country?.value || ''); return [ ...(country && COMPOSITES_BY_COUNTRY[country] ? COMPOSITES_BY_COUNTRY[country] : []), ...COMPOSITES_GLOBAL, ]; }; // NE NAME_ 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) => controls.admin_level?.value === String(0) || controls.admin_level?.value === 0; const isAdminAggregated = (controls: Record) => controls.admin_level?.value === 'aggregated'; const hasComposite = (controls: Record) => 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) => !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;