Skip to content

Fix multicategory axis ordering and support categoryorder / categoryarray - #7929

Draft
chriddyp wants to merge 2 commits into
masterfrom
claude/plotly-multicategory-sort-7bikc6
Draft

Fix multicategory axis ordering and support categoryorder / categoryarray#7929
chriddyp wants to merge 2 commits into
masterfrom
claude/plotly-multicategory-sort-7bikc6

Conversation

@chriddyp

@chriddyp chriddyp commented Aug 4, 2026

Copy link
Copy Markdown
Member

Written with Claude Code

Draft — the three baselines noted below still need regenerating, see Baselines.

The problem

On a multicategory axis the second-level categories share one global ordering, keyed on where each label first appears anywhere in the data. Every first-level category therefore renders the same child sequence, regardless of its own data order.

Minimal case — data order is P1/b, P1/a, P2/a, P2/b:

Plotly.newPlot('graph', [{
  type: 'bar',
  x: [['P1','P1','P2','P2'], ['b','a','a','b']],
  y: [1, 2, 3, 4]
}]);
order
expected P1/b P1/a P2/a P2/b
before P1/b P1/a P2/b P2/a

P2 is flipped: b was seen first under P1, so b precedes a under every parent.

Real-world shape — months under years, rows supplied in strict chronological order (2023 Jul–Dec, 2024 Jan–Dec, 2025 Jan–Jun). 2023 contributes Jul–Dec first, which pins Jul…Dec ahead of Jan…Jun for every year:

before   2023 -> Jul Aug Sep Oct Nov Dec
         2024 -> Jul Aug Sep Oct Nov Dec Jan Feb Mar Apr May Jun   <- supplied Jan..Dec
         2025 -> Jan Feb Mar Apr May Jun

after    2023 -> Jul Aug Sep Oct Nov Dec
         2024 -> Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
         2025 -> Jan Feb Mar Apr May Jun

2023 and 2025 looked correct before only because each happens to be a contiguous slice of that one global ordering.

Separately, categoryorder and categoryarray were never coerced on multicategory axes — handleCategoryOrderDefaults returned early for any non-category type — so setting them was a silent no-op with no way to work around the ordering above.

Reported downstream at plotly/dash-ai-analyst#171.

The fix

set_convert.jssetupMultiCategory. Track the child first-appearance index per parent instead of globally, and sort by (parent rank, child rank within that parent). The lookup objects are now prototype-less, so a category named toString no longer resolves through Object.prototype.

category_order_defaults.js. Let multicategory axes through, and handle the pair shape:

  • trace (default) — per-parent data order, as above
  • arraycategoryarray entries are [first-level, second-level] pairs. Malformed entries are dropped; an array holding no valid pair falls back to trace. Categories absent from categoryarray follow in trace order, matching how category axes already behave
  • category ascending / category descending — sort the pairs by label
  • ordering by aggregated value (total ascending, …) is not implemented for these axes — sortAxisCategoriesByValue skips non-category axes, and interleaving children across parents would break the grouping brackets anyway. Rather than accept it and silently do nothing, it now falls back to trace

No new attributes; categoryarray is already data_array. Descriptions updated for both, with test/plot-schema.json regenerated.

Tests

Visual — new mock test/image/mocks/multicategory-categoryorder.json, four panels over identical data so each ordering is distinguishable. Data is supplied as 2023 → Q4, Q3 and 2024 → Q2, Q1, Q4, Q3, so trace order is deliberately not alphabetical and all four panels differ:

trace (default)      2023/Q4 2023/Q3 2024/Q2 2024/Q1 2024/Q4 2024/Q3
array                2024/Q1 2024/Q2 2024/Q3 2024/Q4 2023/Q3 2023/Q4
category ascending   2023/Q3 2023/Q4 2024/Q1 2024/Q2 2024/Q3 2024/Q4
category descending  2024/Q4 2024/Q3 2024/Q2 2024/Q1 2023/Q4 2023/Q3

I verified this renders correctly in Chromium against a bundle built from this branch — year brackets intact, each panel distinct — but I can't attach screenshots through the API, so the baseline PNG will be the first rendering committed here.

Unit — 10 new specs in test/jasmine/tests/axes_test.js: per-parent ordering, prototype-name safety, and each categoryorder mode including both fallbacks.

npm run test-jasmine -- axes goes from 398 to 408 passing. The 2 insiderange failures in my sandbox are font-metric tolerances that fail identically on unmodified master.

Regression sweep

Rendered all 1067 non-gl3d/map/geo mocks under master and this branch and diffed each multicategory axis's resolved _categories. 1064 identical, 3 changed — all three corrections:

mock before after
multicategory2 2018/q1 2018/q3 2018/q2 2018/q1 2018/q2 2018/q3
multicategory-y 2018/q1 2018/q3 2018/q2 2018/q1 2018/q2 2018/q3
multicategory-sorting 4/1 4/2 … 6/1 6/2 4/2 4/1 … 6/2 6/1

multicategory2 is the clearest: the mock supplies 2018 q1, q2, q3 and the committed baseline shows q1, q3, q2 — the existing baseline encodes the bug.

multicategory-sorting subplot 2 draws 4/2 from the first trace before 4/1 from the second, so per-parent order is 4/2, 4/1.

Baselines — needs a maintainer

Four baselines need generating: the three above, plus the new mock. I did not commit them. My sandbox's kaleido rendering does not match CI's — regenerating the untouched multicategory baseline as a control produced 10910 differing pixels (max channel delta 205), i.e. font rendering differs, so any baseline I generated would be wrong in a way unrelated to this change.

npm run baseline -- multicategory2 multicategory-y multicategory-sorting multicategory-categoryorder

Happy to push them if a maintainer would rather paste the generated PNGs, or to split the three baseline updates into their own commit.

Notes for review

  • findCategoryPairs duplicates a little of setupMultiCategory's traversal. It runs at defaults time, before calc, where trace._length isn't available yet — hence Math.min on the two row lengths rather than Lib.minRowLength. Happy to factor it out if you'd prefer.
  • VALUE_ORDER_RE mirrors sortAxisCategoriesByValueRegex in plots.js. I kept them as separate literals to avoid a require cycle and left a comment on each; exporting one from a shared module would also work.
  • The _initialCategories seeding in clearCalc already handled array-valued categories — setCategoryIndex stringifies pairs to "parent,child" for _categoriesMap and pushes the array onto _categories. That mechanism needed no change; multicategory simply never reached it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XAGQnaXsViqVPvak39Y4qo

claude added 2 commits August 4, 2026 06:50
Second-level categories on a multicategory axis shared one global
ordering keyed on where each label first appeared anywhere in the data,
so every first-level category rendered the same child sequence
regardless of its own data order. With x = [['2023','2024'], ...] and
2023 contributing Jul-Dec first, 2024 rendered Jul-Dec then Jan-Jun even
though it was supplied Jan-Dec.

`setupMultiCategory` now tracks the child first-appearance index per
parent, so each group keeps the order found in its own data. The lookup
objects are prototype-less, so a category named 'toString' no longer
resolves through Object.prototype.

`categoryorder` and `categoryarray` were also never coerced on
multicategory axes - `handleCategoryOrderDefaults` returned early for any
non-category type - so setting them was a silent no-op. They are now
honoured:

- 'trace' (default) keeps the per-parent data order
- 'array' takes `categoryarray` as [first-level, second-level] pairs;
  malformed entries are dropped, and an array holding no valid pair
  falls back to 'trace'
- 'category ascending'/'descending' sort the pairs by label
- ordering by aggregated value ('total ascending', ...) is not
  implemented for these axes and falls back to 'trace' rather than being
  accepted and silently ignored

Three existing baselines encode the old order and need regenerating:
multicategory-sorting, multicategory-y and multicategory2. In
multicategory2 the data supplies 2018 q1, q2, q3 and the current
baseline shows q1, q3, q2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants