From 4670307937c64c0c00d73824efa1d2b9eaa72840 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 06:50:55 +0000 Subject: [PATCH 1/2] Fix multicategory axis ordering and support categoryorder 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 --- .../cartesian/category_order_defaults.js | 118 ++++++++++++--- src/plots/cartesian/layout_attributes.js | 9 +- src/plots/cartesian/set_convert.js | 25 ++-- .../mocks/multicategory-categoryorder.json | 84 +++++++++++ test/jasmine/tests/axes_test.js | 135 ++++++++++++++++++ test/plot-schema.json | 28 ++-- 6 files changed, 357 insertions(+), 42 deletions(-) create mode 100644 test/image/mocks/multicategory-categoryorder.json diff --git a/src/plots/cartesian/category_order_defaults.js b/src/plots/cartesian/category_order_defaults.js index 76c1e5c261a..25995f4b93e 100644 --- a/src/plots/cartesian/category_order_defaults.js +++ b/src/plots/cartesian/category_order_defaults.js @@ -1,32 +1,63 @@ 'use strict'; +var isArrayOrTypedArray = require('../../lib/array').isArrayOrTypedArray; var isTypedArraySpec = require('../../lib/array').isTypedArraySpec; -function findCategories(ax, opts) { +// 'total ascending', 'median descending', ... - ordering by aggregated value, +// which `sortAxisCategoriesByValue` only implements for 'category' axes. +// Mirrors `sortAxisCategoriesByValueRegex` in plots.js +var VALUE_ORDER_RE = /(total|sum|min|max|mean|geometric mean|median) (ascending|descending)/; + +function isValidCategory(v) { + return v !== null && v !== undefined; +} + +// a multicategory entry is a [parent, child] pair +function isValidPair(v) { + return Array.isArray(v) && v.length === 2 && + isValidCategory(v[0]) && isValidCategory(v[1]); +} + +function compareAsString(a, b) { + a = String(a); + b = String(b); + return a < b ? -1 : (a > b ? 1 : 0); +} + +function comparePairs(a, b) { + return compareAsString(a[0], b[0]) || compareAsString(a[1], b[1]); +} + +function getAxData(ax, opts) { var dataAttr = opts.dataAttr || ax._id.charAt(0); - var lookup = {}; - var axData; - var i, j; if(opts.axData) { // non-x/y case - axData = opts.axData; - } else { - // x/y case - axData = []; - for(i = 0; i < opts.data.length; i++) { - var trace = opts.data[i]; - if(trace[dataAttr + 'axis'] === ax._id) { - axData.push(trace); - } + return opts.axData; + } + + // x/y case + var axData = []; + for(var i = 0; i < opts.data.length; i++) { + var trace = opts.data[i]; + if(trace[dataAttr + 'axis'] === ax._id) { + axData.push(trace); } } + return axData; +} + +function findCategories(ax, opts) { + var dataAttr = opts.dataAttr || ax._id.charAt(0); + var axData = getAxData(ax, opts); + var lookup = {}; + var i, j; for(i = 0; i < axData.length; i++) { var vals = axData[i][dataAttr]; for(j = 0; j < vals.length; j++) { var v = vals[j]; - if(v !== null && v !== undefined) { + if(isValidCategory(v)) { lookup[v] = 1; } } @@ -35,6 +66,41 @@ function findCategories(ax, opts) { return Object.keys(lookup); } +// multicategory variant: returns the unique [parent, child] pairs found in the +// data, which is what `_categories` holds for these axes +function findCategoryPairs(ax, opts) { + var dataAttr = opts.dataAttr || ax._id.charAt(0); + var axData = getAxData(ax, opts); + var lookup = Object.create(null); + var list = []; + var i, j; + + for(i = 0; i < axData.length; i++) { + var arrayIn = axData[i][dataAttr]; + if(!isArrayOrTypedArray(arrayIn) || + !isArrayOrTypedArray(arrayIn[0]) || + !isArrayOrTypedArray(arrayIn[1]) + ) continue; + + var len = Math.min(arrayIn[0].length, arrayIn[1].length); + + for(j = 0; j < len; j++) { + var v0 = arrayIn[0][j]; + var v1 = arrayIn[1][j]; + + if(isValidCategory(v0) && isValidCategory(v1)) { + var key = v0 + ',' + v1; + if(!(key in lookup)) { + lookup[key] = 1; + list.push([v0, v1]); + } + } + } + } + + return list; +} + /** * Fills in category* default and initial categories. * @@ -48,12 +114,18 @@ function findCategories(ax, opts) { * - dataAttr {string} : attribute name corresponding to coordinate array */ module.exports = function handleCategoryOrderDefaults(containerIn, containerOut, coerce, opts) { - if(containerOut.type !== 'category') return; + var isMultiCategory = containerOut.type === 'multicategory'; + if(containerOut.type !== 'category' && !isMultiCategory) return; var arrayIn = containerIn.categoryarray; var isValidArray = (Array.isArray(arrayIn) && arrayIn.length > 0) || isTypedArraySpec(arrayIn); + // on multicategory axes every entry must be a [parent, child] pair + if(isMultiCategory && isValidArray) { + isValidArray = Array.isArray(arrayIn) && arrayIn.some(isValidPair); + } + // override default 'categoryorder' value when non-empty array is supplied var orderDefault; if(isValidArray) orderDefault = 'array'; @@ -61,6 +133,12 @@ module.exports = function handleCategoryOrderDefaults(containerIn, containerOut, var order = coerce('categoryorder', orderDefault); var array; + // ordering by aggregated value is not implemented for multicategory axes - + // it would also interleave children across parents, breaking the grouping + if(isMultiCategory && VALUE_ORDER_RE.test(order)) { + order = containerOut.categoryorder = 'trace'; + } + // coerce 'categoryarray' only in array order case if(order === 'array') { array = coerce('categoryarray'); @@ -75,9 +153,15 @@ module.exports = function handleCategoryOrderDefaults(containerIn, containerOut, if(order === 'trace') { containerOut._initialCategories = []; } else if(order === 'array') { - containerOut._initialCategories = array.slice(); + array = array.slice(); + // drop malformed entries so they can't land in `_categories` + if(isMultiCategory) array = array.filter(isValidPair); + containerOut._initialCategories = array; } else { - array = findCategories(containerOut, opts).sort(); + array = isMultiCategory ? + findCategoryPairs(containerOut, opts).sort(comparePairs) : + findCategories(containerOut, opts).sort(); + if(order === 'category ascending') { containerOut._initialCategories = array; } else if(order === 'category descending') { diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 25b60cfa28e..14accf5c604 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -1265,7 +1265,10 @@ module.exports = { 'the *trace* mode. The unspecified categories will follow the categories in `categoryarray`.', 'Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the', 'numerical order of the values.', - 'Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.' + 'Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.', + 'On *multicategory* axes, *trace* orders the second-level categories by the order they appear in the data', + 'within each first-level category, and ordering by aggregated value is not supported', + '- those values fall back on *trace*.' ].join(' ') }, categoryarray: { @@ -1274,7 +1277,9 @@ module.exports = { description: [ 'Sets the order in which categories on this axis appear.', 'Only has an effect if `categoryorder` is set to *array*.', - 'Used with `categoryorder`.' + 'Used with `categoryorder`.', + 'On *multicategory* axes each entry is a [first-level, second-level] pair,', + 'e.g. `[[*2023*, *Q4*], [*2024*, *Q1*]]`; entries that are not such a pair are ignored.' ].join(' ') }, uirevision: { diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index cc3c6ccf7ee..f65e7383eeb 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -371,8 +371,12 @@ module.exports = function setConvert(ax, fullLayout) { } } - // [ [cnt, {$cat: index}], for 1,2 ] - var seen = [[0, {}], [0, {}]]; + // [cnt, {$cat: index}] for the first (parent) level + var seen0 = [0, Object.create(null)]; + // {$parentCat: [cnt, {$cat: index}]} for the second (child) level, + // tracked *per parent* so that each parent keeps the child order + // found in its own data rather than sharing one global order + var seen1 = Object.create(null); // [ [arrayIn[0][i], arrayIn[1][i]], for i .. N ] var list = []; @@ -391,11 +395,14 @@ module.exports = function setConvert(ax, fullLayout) { if(isValidCategory(v0) && isValidCategory(v1)) { list.push([v0, v1]); - if(!(v0 in seen[0][1])) { - seen[0][1][v0] = seen[0][0]++; + if(!(v0 in seen0[1])) { + seen0[1][v0] = seen0[0]++; + seen1[v0] = [0, Object.create(null)]; } - if(!(v1 in seen[1][1])) { - seen[1][1][v1] = seen[1][0]++; + + var seenUnder = seen1[v0]; + if(!(v1 in seenUnder[1])) { + seenUnder[1][v1] = seenUnder[0]++; } } } @@ -404,12 +411,12 @@ module.exports = function setConvert(ax, fullLayout) { } list.sort(function(a, b) { - var ind0 = seen[0][1]; + var ind0 = seen0[1]; var d = ind0[a[0]] - ind0[b[0]]; if(d) return d; - var ind1 = seen[1][1]; - return ind1[a[1]] - ind1[b[1]]; + // same parent, so the two rows share a child-index map + return seen1[a[0]][1][a[1]] - seen1[b[0]][1][b[1]]; }); for(i = 0; i < list.length; i++) { diff --git a/test/image/mocks/multicategory-categoryorder.json b/test/image/mocks/multicategory-categoryorder.json new file mode 100644 index 00000000000..46c6adbe09f --- /dev/null +++ b/test/image/mocks/multicategory-categoryorder.json @@ -0,0 +1,84 @@ +{ + "data": [ + { + "type": "bar", + "x": [ + ["2023", "2023", "2024", "2024", "2024", "2024"], + ["Q4", "Q3", "Q2", "Q1", "Q4", "Q3"] + ], + "y": [4, 3, 2, 1, 4, 3], + "marker": { "color": "#636efa" } + }, + + { + "type": "bar", + "x": [ + ["2023", "2023", "2024", "2024", "2024", "2024"], + ["Q4", "Q3", "Q2", "Q1", "Q4", "Q3"] + ], + "y": [4, 3, 2, 1, 4, 3], + "marker": { "color": "#ef553b" }, + "xaxis": "x2", + "yaxis": "y2" + }, + + { + "type": "bar", + "x": [ + ["2023", "2023", "2024", "2024", "2024", "2024"], + ["Q4", "Q3", "Q2", "Q1", "Q4", "Q3"] + ], + "y": [4, 3, 2, 1, 4, 3], + "marker": { "color": "#00cc96" }, + "xaxis": "x3", + "yaxis": "y3" + }, + + { + "type": "bar", + "x": [ + ["2023", "2023", "2024", "2024", "2024", "2024"], + ["Q4", "Q3", "Q2", "Q1", "Q4", "Q3"] + ], + "y": [4, 3, 2, 1, 4, 3], + "marker": { "color": "#ab63fa" }, + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "title": { "text": "multicategory categoryorder" }, + "grid": { + "rows": 2, + "columns": 2, + "pattern": "independent", + "xgap": 0.15, + "ygap": 0.4 + }, + "xaxis": { "title": { "text": "trace (default)" } }, + "xaxis2": { + "title": { "text": "array" }, + "categoryorder": "array", + "categoryarray": [ + ["2024", "Q1"], + ["2024", "Q2"], + ["2024", "Q3"], + ["2024", "Q4"], + ["2023", "Q3"], + ["2023", "Q4"] + ] + }, + "xaxis3": { + "title": { "text": "category ascending" }, + "categoryorder": "category ascending" + }, + "xaxis4": { + "title": { "text": "category descending" }, + "categoryorder": "category descending" + }, + "width": 700, + "height": 500, + "margin": { "l": 40, "b": 60, "t": 40, "r": 20 }, + "showlegend": false + } +} diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 15eca358dfe..da0d2059398 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -2386,6 +2386,120 @@ describe('Test axes', function() { .then(done, done.fail); }); }); + + describe('on multicategory axes', function() { + // '2023' contributes 'b' first, so under the previous behaviour + // '2024' rendered as b, a - one global second-level order was + // shared by every first-level category + var trace = { + type: 'bar', + x: [ + ['2023', '2023', '2024', '2024'], + ['b', 'c', 'a', 'b'] + ], + y: [1, 2, 3, 4] + }; + + function _plot(xaxis) { + return Plotly.newPlot(gd, [Lib.extendDeep({}, trace)], xaxis ? {xaxis: xaxis} : {}); + } + + function _categories() { + return gd._fullLayout.xaxis._categories; + } + + it('should follow the data order within each first-level category', function(done) { + _plot() + .then(function() { + expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); + expect(_categories()).toEqual([ + ['2023', 'b'], ['2023', 'c'], ['2024', 'a'], ['2024', 'b'] + ]); + }) + .then(done, done.fail); + }); + + it('should honour categoryorder "array" with [parent, child] pairs', function(done) { + _plot({ + categoryorder: 'array', + categoryarray: [['2024', 'b'], ['2024', 'a'], ['2023', 'c'], ['2023', 'b']] + }) + .then(function() { + expect(gd._fullLayout.xaxis.categoryorder).toBe('array'); + expect(_categories()).toEqual([ + ['2024', 'b'], ['2024', 'a'], ['2023', 'c'], ['2023', 'b'] + ]); + }) + .then(done, done.fail); + }); + + it('should switch categoryorder to "array" when only categoryarray is supplied', function(done) { + _plot({categoryarray: [['2024', 'b'], ['2024', 'a']]}) + .then(function() { + expect(gd._fullLayout.xaxis.categoryorder).toBe('array'); + // categories missing from categoryarray follow in trace order + expect(_categories()).toEqual([ + ['2024', 'b'], ['2024', 'a'], ['2023', 'b'], ['2023', 'c'] + ]); + }) + .then(done, done.fail); + }); + + it('should revert categoryorder to "trace" when categoryarray holds no valid pair', function(done) { + _plot({categoryorder: 'array', categoryarray: ['a', 'b']}) + .then(function() { + expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); + expect(_categories()).toEqual([ + ['2023', 'b'], ['2023', 'c'], ['2024', 'a'], ['2024', 'b'] + ]); + }) + .then(done, done.fail); + }); + + it('should drop malformed categoryarray entries', function(done) { + _plot({ + categoryorder: 'array', + categoryarray: ['2024', ['2024', 'b'], null, ['2023', 'c', 'extra']] + }) + .then(function() { + expect(_categories()).toEqual([ + ['2024', 'b'], ['2023', 'b'], ['2023', 'c'], ['2024', 'a'] + ]); + }) + .then(done, done.fail); + }); + + it('should honour categoryorder "category ascending"', function(done) { + _plot({categoryorder: 'category ascending'}) + .then(function() { + expect(_categories()).toEqual([ + ['2023', 'b'], ['2023', 'c'], ['2024', 'a'], ['2024', 'b'] + ]); + }) + .then(done, done.fail); + }); + + it('should honour categoryorder "category descending"', function(done) { + _plot({categoryorder: 'category descending'}) + .then(function() { + expect(_categories()).toEqual([ + ['2024', 'b'], ['2024', 'a'], ['2023', 'c'], ['2023', 'b'] + ]); + }) + .then(done, done.fail); + }); + + it('should revert value-based categoryorder to "trace"', function(done) { + _plot({categoryorder: 'total descending'}) + .then(function() { + expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); + expect(_categories()).toEqual([ + ['2023', 'b'], ['2023', 'c'], ['2024', 'a'], ['2024', 'b'] + ]); + }) + .then(done, done.fail); + }); + }); }); describe('bar category autorange', function() { @@ -4354,6 +4468,27 @@ describe('Test axes', function() { expect(ax._categoriesMap).toEqual({'1,a': 0, '1,b': 1, '2,a': 2, '2,b': 3}); }); + it('should order second-level categories per parent, not globally', function() { + var out = _makeCalcdata({ + x: [['1', '1', '2', '2'], ['b', 'a', 'a', 'b']] + }, 'x', 'multicategory'); + + // '2' keeps its own 'a' then 'b' order, even though 'b' is + // the first second-level category seen overall (under '1') + expect(out).toEqual([0, 1, 2, 3]); + expect(ax._categories).toEqual([['1', 'b'], ['1', 'a'], ['2', 'a'], ['2', 'b']]); + expect(ax._categoriesMap).toEqual({'1,b': 0, '1,a': 1, '2,a': 2, '2,b': 3}); + }); + + it('should not let second-level categories inherit the prototype chain', function() { + var out = _makeCalcdata({ + x: [['1', '1'], ['toString', 'a']] + }, 'x', 'multicategory'); + + expect(out).toEqual([0, 1]); + expect(ax._categories).toEqual([['1', 'toString'], ['1', 'a']]); + }); + it('case invalid in x[0]', function() { var out = _makeCalcdata({ x: [['1', '2', null, '2'], ['a', 'a', 'b', 'b']] diff --git a/test/plot-schema.json b/test/plot-schema.json index 78614b86693..09cf21dec5c 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -5117,7 +5117,7 @@ ] }, "categoryarray": { - "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", + "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`. On *multicategory* axes each entry is a [first-level, second-level] pair, e.g. `[[*2023*, *Q4*], [*2024*, *Q1*]]`; entries that are not such a pair are ignored.", "editType": "calc", "valType": "data_array" }, @@ -5127,7 +5127,7 @@ "valType": "string" }, "categoryorder": { - "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.", + "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values. On *multicategory* axes, *trace* orders the second-level categories by the order they appear in the data within each first-level category, and ordering by aggregated value is not supported - those values fall back on *trace*.", "dflt": "trace", "editType": "calc", "valType": "enumerated", @@ -5820,7 +5820,7 @@ ] }, "categoryarray": { - "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", + "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`. On *multicategory* axes each entry is a [first-level, second-level] pair, e.g. `[[*2023*, *Q4*], [*2024*, *Q1*]]`; entries that are not such a pair are ignored.", "editType": "calc", "valType": "data_array" }, @@ -5830,7 +5830,7 @@ "valType": "string" }, "categoryorder": { - "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.", + "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values. On *multicategory* axes, *trace* orders the second-level categories by the order they appear in the data within each first-level category, and ordering by aggregated value is not supported - those values fall back on *trace*.", "dflt": "trace", "editType": "calc", "valType": "enumerated", @@ -7249,7 +7249,7 @@ ] }, "categoryarray": { - "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", + "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`. On *multicategory* axes each entry is a [first-level, second-level] pair, e.g. `[[*2023*, *Q4*], [*2024*, *Q1*]]`; entries that are not such a pair are ignored.", "editType": "plot", "valType": "data_array" }, @@ -7259,7 +7259,7 @@ "valType": "string" }, "categoryorder": { - "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.", + "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values. On *multicategory* axes, *trace* orders the second-level categories by the order they appear in the data within each first-level category, and ordering by aggregated value is not supported - those values fall back on *trace*.", "dflt": "trace", "editType": "plot", "valType": "enumerated", @@ -7989,7 +7989,7 @@ ] }, "categoryarray": { - "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", + "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`. On *multicategory* axes each entry is a [first-level, second-level] pair, e.g. `[[*2023*, *Q4*], [*2024*, *Q1*]]`; entries that are not such a pair are ignored.", "editType": "plot", "valType": "data_array" }, @@ -7999,7 +7999,7 @@ "valType": "string" }, "categoryorder": { - "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.", + "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values. On *multicategory* axes, *trace* orders the second-level categories by the order they appear in the data within each first-level category, and ordering by aggregated value is not supported - those values fall back on *trace*.", "dflt": "trace", "editType": "plot", "valType": "enumerated", @@ -8729,7 +8729,7 @@ ] }, "categoryarray": { - "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", + "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`. On *multicategory* axes each entry is a [first-level, second-level] pair, e.g. `[[*2023*, *Q4*], [*2024*, *Q1*]]`; entries that are not such a pair are ignored.", "editType": "plot", "valType": "data_array" }, @@ -8739,7 +8739,7 @@ "valType": "string" }, "categoryorder": { - "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.", + "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values. On *multicategory* axes, *trace* orders the second-level categories by the order they appear in the data within each first-level category, and ordering by aggregated value is not supported - those values fall back on *trace*.", "dflt": "trace", "editType": "plot", "valType": "enumerated", @@ -13675,7 +13675,7 @@ ] }, "categoryarray": { - "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", + "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`. On *multicategory* axes each entry is a [first-level, second-level] pair, e.g. `[[*2023*, *Q4*], [*2024*, *Q1*]]`; entries that are not such a pair are ignored.", "editType": "calc", "valType": "data_array" }, @@ -13685,7 +13685,7 @@ "valType": "string" }, "categoryorder": { - "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.", + "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values. On *multicategory* axes, *trace* orders the second-level categories by the order they appear in the data within each first-level category, and ordering by aggregated value is not supported - those values fall back on *trace*.", "dflt": "trace", "editType": "calc", "valType": "enumerated", @@ -15271,7 +15271,7 @@ ] }, "categoryarray": { - "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`.", + "description": "Sets the order in which categories on this axis appear. Only has an effect if `categoryorder` is set to *array*. Used with `categoryorder`. On *multicategory* axes each entry is a [first-level, second-level] pair, e.g. `[[*2023*, *Q4*], [*2024*, *Q1*]]`; entries that are not such a pair are ignored.", "editType": "calc", "valType": "data_array" }, @@ -15281,7 +15281,7 @@ "valType": "string" }, "categoryorder": { - "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values.", + "description": "Specifies the ordering logic for the case of categorical variables. By default, plotly uses *trace*, which specifies the order that is present in the data supplied. Set `categoryorder` to *category ascending* or *category descending* if order should be determined by the alphanumerical order of the category names. Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to the *trace* mode. The unspecified categories will follow the categories in `categoryarray`. Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the numerical order of the values. Similarly, the order can be determined by the min, max, sum, mean, geometric mean or median of all the values. On *multicategory* axes, *trace* orders the second-level categories by the order they appear in the data within each first-level category, and ordering by aggregated value is not supported - those values fall back on *trace*.", "dflt": "trace", "editType": "calc", "valType": "enumerated", From a75f5933c4b15023cbab85597b4d00b707b5715d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 06:51:56 +0000 Subject: [PATCH 2/2] Add draftlog for #7929 Co-Authored-By: Claude Opus 5 --- draftlogs/7929_fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7929_fix.md diff --git a/draftlogs/7929_fix.md b/draftlogs/7929_fix.md new file mode 100644 index 00000000000..62f35c17308 --- /dev/null +++ b/draftlogs/7929_fix.md @@ -0,0 +1 @@ + - Fix `multicategory` axes ordering second-level categories by a single global order instead of the data order within each first-level category, and honour `categoryorder`/`categoryarray` on those axes [[#7929](https://github.com/plotly/plotly.js/pull/7929)]