add-gdp-scatter
SkillDatabases & dataAdd a scatter view (with GDP per capita on x) to existing OWID charts via the admin API, mirroring the admin UI's "Add scatter type" defaults, then retire the old standalone "X vs. GDP per capita" charts by redirecting their slugs to that scatter view. Trigger when the user pastes a table with columns `chart_admin_url`, `target_chart_admin_url`, `gdp_source` (part 1), or a list of `{grapher_url, target_chart_url}` pairs to redirect (part 2).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the add-gdp-scatter skill
What this skill tells your AI
The instructions your AI receives, as published by owid/etl in .claude/skills/add-gdp-scatter/SKILL.md and read by ahel’s review.
Bulk-add a scatter view to a set of OWID charts, with the same defaults the admin UI's applyDefaultsForScatter applies — plus extra parity checks against a reference scatter chart.
When to invoke
The user pastes (TSV or CSV) a table like:
chart_admin_url target_chart_admin_url gdp_source
https://admin.owid.io/admin/charts/1035/edit https://admin.owid.io/admin/charts/7842/edit Maddison Project Database
https://admin.owid.io/admin/charts/6305/edit https://admin.owid.io/admin/charts/6918/edit World Bank
-
chart_admin_url— the existing reference scatter chart used as the source of parity hints (yAxis log, ydisplay.name, color/size override, tolerance, exclusions). -
target_chart_admin_url— the chart the user wants to gain a scatter view. -
gdp_source— one of (case-insensitive, substring matches accepted):World Bank/WDI→ variableId1294305(WDI 2026-07-27)Maddison/Maddison Project Database→900793PWT/Penn World Table→1108541
GDP_SOURCESin the script is the authority on these ids; the WDI one goes stale on every WDI update — see the version check below.
The admin host that gets written to is OWID_ENV.admin_api, which auto-resolves to staging-site-<branch> on a feature branch. Confirm the branch before running.
Pre-flight: GDP version check
Before processing any rows, the script queries variables for the latest id matching each canonical GDP-per-capita catalogPath pattern (worldbank_wdi/.../ny_gdp_pcap_pp_kd, ggdc/.../maddison_project_database#gdp_per_capita, ggdc/.../penn_world_table#rgdpo_pc). If the latest id differs from the one hardcoded in GDP_SOURCES, a WARN is printed at the top of stdout with the newer id and catalogPath. The script does NOT auto-switch — update GDP_SOURCES (and the feedback_scatter_gdp_picker memory) deliberately if the new version is the one we want.
Read that WARN before the run, not after. These ids go stale on every WDI update, and the "target already has x → leave it" guard means a re-run will not repair a target that already received the stale variable — the x dimension has to be rewritten directly, chart by chart. Cross-check against the id the source scatters actually plot: if the sources are on a newer version than GDP_SOURCES, the newer one is what you want, and bumping first saves a repair pass. (2026-08-04: every source plotted WDI 2026-07-27 1294305 while the script was still pinned to 2026-02-27 1204826.)
What the script does (per row)
Mirrors the admin's applyDefaultsForScatter and the extra moves we agreed on:
-
Adds
ScatterPlottochartTypes, preserving existing tabs. Seeds the schema default[LineChart, DiscreteBar]whenchartTypesis unset. -
Appends x (the chosen GDP variable), color, size dimensions if absent.
- color: if the source uses a non-default color variable (e.g. World Bank income groups), mirror it; otherwise use
CONTINENTS_ID=900801. - size: the rule is always use the default
Populationindicator (POPULATION_ID=953899) for any population-type size. If the source sizes by any population variant (regular, historical, WPP, …), the target gets the default Population. A genuinely non-population size (e.g. GDP, area) is mirrored as-is but raises aWARNso the bubble sizing gets a manual review. If the source has nosizedim at all, the target also gets none — the script won't add sizing the curator deliberately omitted. Population variants are detected by the variable's name starting with "Population" or its catalogPath living under a/population/dataset; the action note records any normalization.
- color: if the source uses a non-default color variable (e.g. World Bank income groups), mirror it; otherwise use
-
Sets
matchingEntitiesOnly: true. -
Sets
xAxistoscaleType: log+canChangeScaleType: true. -
Y-axis log toggle (not forced): when the source scatter is
scaleType: log, only enable the toggle (canChangeScaleType: true) and leave the default linear.yAxisis shared across all views, so forcing log would flip the line/bar views too. Mirrors explicityAxismin/max bounds the source sets (each bound copied independently) — except a non-zerominis NOT mirrored when the target has aMarimekkoorStacked*view, because those draw from a baseline and a scatter-tuned non-zero min would make them start above zero (misleading).DiscreteBaris not in that set:DiscreteBarChart.yAxisConfighardcodesmin: undefinedand anchors at zero, so it ignoresyAxis.minoutright — withholding the min from a DiscreteBar target protects nothing and costs the scatter a well-fitted axis. A degeneratemin: 0+max: 0(collapsed axis) has itsmaxstripped. Note: y-axis bounds affect all views, not just scatter.On y-axis bounds, prefer removing to pinning.
Axis.updateDomainPreservingUserSettingstakesmin(config.min, data.min)andmax(config.max, data.max), so an authoredminis a hard floor across every view — which is why{min: 0, max: 0}renders identically to{min: 0}(the data max always wins) and is not the inert junk it looks like. When a reviewer says the scatter's axis is wasting space at zero, droppingminusually beats mirroring the source's: each view then fits its own data, and mirroring a non-zero min can clip a LineChart whose series run below it. (2026-08-04, chart 2201: source min was 5, but the line data reaches 0.92; droppingmingave the scatter 6.94–13.97 while the line chart moved only 0 → 0.92, and the DiscreteBar was unaffected either way.)
The scatter's single-year default needs no config (usually)
A recurring question: the scatter should show only the latest year, without affecting the other views — is that possible? Yes, and Grapher already does it — do not set minTime/maxTime for this.
minTime/maxTime are global. Only the map has its own time (map.time / map.startTime in MapConfigInterface); there is no per-chart-type time override, so pinning them to the latest year would collapse a LineChart to a single point.
Grapher handles it at runtime instead. checkSingleTimeSelectionPreferred returns true for the ScatterPlot tab whenever the scatter is not the primary chart type and the chart is not in relative mode, and adjustStateForTab → ensureTimeHandlesAreSensibleForTab then collapses both time handles onto the end (latest) time. That is runtime state, so the line/bar/map views keep their full range. Since the applier appends ScatterPlot, it is never chartTypes[0] and the condition holds by construction.
But it only fires when the reader CLICKS the tab — see the shared caveat below.
Also: hideTimeline: true breaks it even on a tab click. With a hidden timeline, timelineHandleTimeBounds reads the authored minTime/maxTime on every chart tab and ignores the runtime handles, so the collapse never takes effect — and the reader has no slider to fix it. Authored minTime == maxTime is then the only fix, and it is only safe when every other tab is single-time anyway (DiscreteBar/StackedDiscreteBar/Marimekko). With a LineChart, SlopeChart or single-indicator Dumbbell in the mix, one global time cannot serve both — un-hide the timeline or accept the range. The script emits a WARN for each case. (2026-08-04: chart 1253, DiscreteBar + hideTimeline, needed minTime/maxTime = latest; the other 16 targets in that batch needed nothing.)
adjustStateForTab fires on a tab CLICK only — not on a direct URL load
Both scatter adjustments — collapsing the time handles and clearing the entity selection — live in the same function behind the same guard:
if (!this.isEditor) {
this.ensureEntitySelectionIsSensibleForTab(tab)
this.ensureTimeHandlesAreSensibleForTab(tab)
}
So they always happen together, or not at all. adjustStateForTab has exactly one production caller, onTabChange, which in turn has exactly one: the ContentSwitchers tab control. A tab supplied in the URL takes a different path — populateFromQueryParams → setTab, which only assigns this.tab. Three consequences:
- Clicking the scatter tab: time collapses to the latest year and the selection is cleared. The scatter matches the old standalone chart.
- Landing directly on
?tab=scatter: neither happens. The scatter opens on the authored time range with the authored entities highlighted — unless the URL says otherwise. This is the path Part 2's redirect uses, which is why every part of its storedtab=scatter&time=latest&country=is load-bearing: each param hand-supplies one adjustment the click would have made.time=lateststands in forensureTimeHandlesAreSensibleForTab, andcountry=(present, empty) forensureEntitySelectionIsSensibleForTab—parseCountryParamreturnsvalid([])for an empty value andsetSelectedEntities([])clears, so the scatter shows every entity unhighlighted. Whenever a new tab-click adjustment is added upstream, a matching param has to be added here or the two paths drift apart again.country=has not yet been exercised through a live redirect — confirm in a browser on the first--applyrun (see "Verifying Part 2"). - The admin editor shows neither, because of the
isEditorguard — deliberate, so switching tabs cannot mutate the authored config on save (grapher #6794). A scatter that looks wrong in/admin/charts/<id>/editmay be fine for readers. Verify on the chart page.
The target's entity selection highlights the scatter, it does not filter it
Targets normally carry a selectedEntityNames list for their line/bar view (4–20 entities is typical) while the source scatters carry none — they show every country. That asymmetry does not hide data on the new scatter view:
ScatterPlotChartState.seriesNamesToHighlightuses the selection to highlight only; every entity is still plotted.- Axis domains narrow to the selection only via
pointsForAxisDomains, and only whenzoomToSelectionis set. Check that field — with it, a scatter's axes really would zoom to the highlighted subset. - On a tab click,
ensureEntitySelectionIsSensibleForTabclears the selection entirely (CHART_TYPES_THAT_SHOW_ALL_ENTITIESis[ScatterPlot, Marimekko]) so long as it is still the authored one — the scatter then looks exactly like the old standalone chart. On a direct URL load it does not, for the reason in the section above; the authored entities render highlighted.
That second case is what Part 2's redirect produces, so a reader arriving by a retired scatter's URL sees the same data and axes but with a few countries emphasized — visually unlike the chart they used to get, and unlike what a reader who clicks the tab gets. Decide per batch whether that is acceptable, and see the country= note above for the fix.
Cross-view safety (which fields are global)
yAxis (scaleType, min, max) is the only config the skill writes that meaningfully bleeds into the non-scatter views — hence the log-toggle and zero-baseline handling above. The others were checked and are safe: xAxis.scaleType: log is ignored by Line/DiscreteBar (they hardcode a linear time axis) and has no visible effect on Slope; the color dimension does not recolor line/bar (they color by entity); size is scatter-only (not even in the table tab); matchingEntitiesOnly is honored only by Scatter and Marimekko.
6. Mirrors source's manually-set y display.name when present.
6b. Mirrors source comparisonLines when the target has none. A scatter's reference line (e.g. yEquals: 1 on a ratio-to-a-benchmark indicator) is often the whole point of its framing, so dropping it makes the migrated view say less than the chart it replaces. Never overwrites an existing set.
7. Emits warnings (no action) for:
- Target has no
selectedEntityNames— line/bar/slope views will fall back to Grapher defaults. - Target
stackMode: relative— on scatter this is the "Display average annual change" mode; we want the toggle available but off by default, so a relative default is flagged for review. - Source
excludedEntityNames— never applied to the target (they would hide the entity from all views, not just the scatter), so each one reappears on the migrated scatter. Graded per entity byclassify_exclusionsintoy-OUTLIER/aggregate/high-GDP-material/unclear/ungradeable(a decision is needed) vshigh-GDP/no data(benign), with the numbers in the EXCLUDED ENTITIES table. Only the first group makes the note aWARN— the group isEXCLUSION_WARN_CLASSES, which the table's own footer prints, so the two cannot drift. Note that a high GDP per capita is benign only while it stays insideX_MATERIAL_DECADES; past that it gradeshigh-GDP-materialand warns like the rest. - Source y axis is log — the target's scatter tab opens linear, and only a URL carrying
yScale=logrestores it. See "A log y axis and an exclusion list are the two things the migration cannot carry". - GDP coverage mismatch — if y-indicator's earliest year predates the chosen GDP's coverage (WDI≈1990, PWT≈1950, Maddison≈year 1), suggest a deeper-history alternative.
- Few entities on default scatter view — counts entities with both a y- and an x-value within tolerance at the default time; if fewer than ~15 AND source uses higher tolerance, recommends bumping target's y
display.tolerance.
Push uses apps.chart_sync.admin_api.AdminAPI.update_chart(id, cfg).
A log y axis and an exclusion list are the two things the migration cannot carry
Everything else on the source is either mirrored onto the target or left behind for a reason that holds. These two are different — they are lost, and the only channel that gives either back is a query string:
- A log y axis stays behind because
yAxisis global (step 5). Part 2's redirect and a hand-updated article link carryyScale=log; a reader who clicks the scatter tab does not, and neither does any surface that has no URL of its own. excludedEntityNamesis never applied to the target (exclusions are global too, so they would hide the entity from its line/bar/map views), so every excluded entity reappears on the migrated scatter. Nothing, anywhere, puts it back.
The surfaces with no query string are what decide whether the retirement is worth doing, and there are three:
- a key-chart slot has nowhere to put one —
GdocPost.loadRelatedChartsselects onlychartId, slug, title, variantName, keyChartLevel, andRelatedChartsrenders<GrapherWithFallback slug={activeChartSlug}>; - a gdoc embed resolves the chart itself and renders its default tab
(
makeGrapherLinkedChartbuilds no query string); - a featured metric is worse still: it names a chart, an MDIM view or an explorer view and never a chart's tab, so the scatter view cannot be featured at all (see "Featured metrics").
On a featured or embedded source, a log axis is therefore gone for good and no amount of re-pointing recovers it. That is what makes "is this migration worth doing?" a real question rather than a formality, and why the answer depends on how the old chart is referenced.
Leaving the standalone chart alone is a legitimate outcome. The skill reports the loss and
the topic owner decides: the applier WARNs on a log source, the reviewer HTML asks the question
with both shapes side by side, and Part 2's audit prints a RECONSIDER block weighing the loss
against the blast radius. None of them blocks — see "RECONSIDER" in Part 2 for why not.
Exclusions are graded, not listed, because the two usual reasons for one have opposite
consequences here. The target's x axis is log, so a very high GDP per capita — the classic
Ireland / Luxembourg / Qatar exclusion — costs almost nothing: on chart 6305, Ireland's $131,338
against a pack topping out at $95,173 is +0.14 of a decade of extra axis width, i.e.
invisible — though "benign" there is a claim about the axis, and the note says so: a very high
GDP per capita can also be excluded because the figure itself is distorted (Ireland's profit
shifting, a Gulf state's expat denominator), which a log axis does not fix. A y outlier is the
opposite: on chart 5029, Australia's 3,243 ha average farm size
against a pack of 0.35–582.5 stretches the y axis 5.6x, and yAxis.max is global so the
scatter cannot cap it alone. That is why grade_exclusion measures each axis in the units it is
drawn in rather than testing for statistical outlierness — a symmetric IQR fence gets skewed
indicators badly wrong (chart 1131: Cape Verde's 40.3 kg/ha cereal yield sits well inside a
±3·IQR fence while being 14.3x below the lowest of the other 88 countries).
The two pack tests (Y_PACK_FACTOR, "N× above the highest / below the lowest") are ratios, so
they only run against a positive bound. On an indicator whose values are negative, hi_y × 2 sits
below the pack, which would make an ordinary in-range value read as an outlier, and a pack
topping out at exactly 0 would divide by zero. Those indicators are graded on the span stretch
alone, which is sign-agnostic and still catches a genuine outlier.
One case needs its own test rather than a ratio: an excluded value at or below zero while
every peer is positive. That is the limiting case of "below the lowest" — infinitely far below —
so no ratio expresses it, and the span stretch does not cover it either, because a broad pack
absorbs the extra width (y=0 against a pack of 1–100 stretches the axis only 1.01×). It is graded
y-OUTLIER on the sign alone. This matters because the pack tests, not the stretch, are what catch
this whole shape of case: chart 1131's Cape Verde is caught by "below the lowest" alone, its stretch
being just 1.04×.
Both sides of that comparison are read at the same year. An entity with no value at the target's default year is graded at its latest year with both indicators instead, and the peer pack is rebuilt at that year rather than held at the default one — otherwise a trending indicator has the point and the pack drifting apart, and the verdict measures the trend rather than the entity. The measured year is printed whenever it is not the default.
That fallback year honours the target's tolerance, and is not a raw-year intersection: at a
non-zero tolerance Grapher pairs a y value with a GDP value from a neighbouring year, so an entity
whose two observations never share a year can still be a point the reader meets by dragging the
timeline. Requiring the same year graded it a benign no data and dropped it out of the warning
altogether. The value is then read back at that same tolerance, since the observation itself may
sit a year or two off the timeline year it was found under.
The candidates for that year are every year the two variables cover, not just the entity's own
observation years, because the year that pairs them can be one where the entity has neither: y in
2000 and GDP in 2002 at tolerance 1 meet at 2001, a year on the timeline because other entities
have data there and a year the reader reaches by dragging the handle. Searching only the entity's
own years missed it and sent the entity back as a benign no data.
no data is also checked before the OWID_ code, so an excluded aggregate that has no
pairable year is no data rather than aggregate. The aggregate note claims the entity "renders
as one point among the countries"; an entity with no pair renders nowhere, since
matchingEntitiesOnly hides it. Testing the code first raised a warning — and a RECONSIDER row —
on the strength of a sentence that was not true of that entity.
(2026-08-19, production: of 22 published GDP scatters carrying excludedEntityNames, 8 exclude
World or another OWID aggregate — the single commonest case, which is why aggregate is its
own class, detected by the OWID_ prefix on entityCode rather than by a name list. Charts 1131
and 5029, both of which exclude a genuine y outlier, are also key charts on their topic pages
— the compound case where the loss lands where no query string reaches.)
Run this as a checklist in the chat
Create a TodoWrite list covering the WHOLE migration on the first step, before touching anything — not just the part being worked on now. This migration's failure mode is not getting a step wrong, it is losing a step: the work spans two scripts, a human review round, a merge, and a production run, with days between them. Anything not on the list from the start gets discovered later by a reader hitting a 404 or an article rendering the wrong tab.
So the reference sweep and Part 2 go on the list as pending from the very beginning, even when the request is only "add the scatter views". They are the two that get forgotten, and they are the two that break things for readers.
The canonical items, in order:
- Confirm the branch / which admin host
OWID_ENVresolves to (onmasterthat is production). - Pre-flight every row (
preflight_targets.py); report and drop the blocked ones. - Act on the GDP version
WARN— bumpGDP_SOURCESbefore applying if the sources plot a newer id. - Apply (
apply_scatter_defaults.py). - Verify every target:
ScatterPlotpresent, log x-axis, and the current GDP id onx. - Display-name follow-up — after the final applier run, or the next run re-mirrors it.
- Build the review HTML (
build_review.py) and hand it to the topic owner. - Apply the reviewer's flagged notes; regenerate the HTML and re-import their JSON.
- Chart-diff sign-off on staging, then merge.
- Confirm the scatter views actually reached production. A merged PR is not evidence that they did: chart-sync only carries chart edits whose diffs were approved in Chart Diff, so a PR can merge green with every row ✅ on staging and leave production untouched. An abandoned first attempt (PR #6173, merged 2026-06-24) left production untouched on all seven of its pairs — deliberately: the
target_query_paramneeded for Part 2 did not exist yet, so it was dropped and the migration restarted from scratch rather than left half-done. Whatever the reason, check production directly rather than inferring it from the merge. - Reference sweep on the old charts —
find-chart-referencesover each source slug and its aliases, thenscripts/build_reference_handoff.pyto turn it into the handoff (it keeps the sweep's 📄 doc / 👁 preview / 🔗 page links and its "Find in the doc" search string — see below). Re-point embeds and links at the target's scatter view before retiring anything: an embed is never fixed by a redirect, and a link that works only via a 301 outlives everyone's memory of why. Do not skip this because the Part 2 audit reports few references — it counts a narrower set; see the key-chart and featured-metric traps below. Settle the ⭐ featured-metric rows in the same pass: they are the only ones that cannot be repaired after the unpublish. - Narrative charts on the sources: replace where the parent is being retired (create → re-point articles → delete; never delete first).
- Part 2 audit —
redirect_to_scatter.pywith no--apply. Read every verdict, and resolve everyRECONSIDERrow with the topic owner before item 14. That block is the one verdict here that does not block on its own (a lossy retirement is an editorial call, not a broken page), so it is the one that gets applied past if nobody answers it. - Part 2
--applyon staging, then the browser checks in "Verifying Part 2". - Part 2
--apply --allow-productiononce the scatter views are live on production, then the same checks against the live site.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 156
- Forks
- 30
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
add-gdp-scatter- Source
- github.com/owid/etl