Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/7929_fix.md
Original file line number Diff line number Diff line change
@@ -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)]
118 changes: 101 additions & 17 deletions src/plots/cartesian/category_order_defaults.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
Expand All @@ -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.
*
Expand All @@ -48,19 +114,31 @@ 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';

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');
Expand All @@ -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') {
Expand Down
9 changes: 7 additions & 2 deletions src/plots/cartesian/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand Down
25 changes: 16 additions & 9 deletions src/plots/cartesian/set_convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -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]++;
}
}
}
Expand All @@ -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++) {
Expand Down
84 changes: 84 additions & 0 deletions test/image/mocks/multicategory-categoryorder.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading