mirror of
https://github.com/apache/superset.git
synced 2026-07-10 00:35:39 +00:00
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>
625 lines
20 KiB
TypeScript
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;
|