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/7932_add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add support for `categoryorder` and `categoryarray` on `multicategory` axes [[#7932](https://github.com/plotly/plotly.js/pull/7932)]
129 changes: 111 additions & 18 deletions src/plots/cartesian/category_order_defaults.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,59 @@
'use strict';

var Lib = require('../../lib');
var isArrayOrTypedArray = require('../../lib/array').isArrayOrTypedArray;
var isTypedArraySpec = require('../../lib/array').isTypedArraySpec;

function findCategories(ax, opts) {
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 +62,45 @@ 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);
// prototype-less, so that e.g. a category named 'toString' does not
// resolve through Object.prototype
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)) {
// comma-joined keys match how `_categoriesMap` stringifies
// [parent, child] pairs in set_convert.js
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,17 +114,38 @@ 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 order;
if(isMultiCategory) {
// ordering by aggregated value ('total ascending', 'median descending', ...)
// is not implemented for multicategory axes - it would interleave
// children across different parents, breaking the parent grouping -
// so coerce against the modes that are
order = Lib.coerce(containerIn, containerOut, {
categoryorder: {
valType: 'enumerated',
values: ['trace', 'category ascending', 'category descending', 'array'],
dflt: 'trace'
}
}, 'categoryorder', orderDefault);
} else {
order = coerce('categoryorder', orderDefault);
}
var array;

// coerce 'categoryarray' only in array order case
Expand All @@ -75,9 +162,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; only *trace*, *category ascending*, *category descending* and *array*',
'are supported on these axes - other values fall back on the default.'
].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
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