Skip to content

Commit dba8f55

Browse files
authored
Merge pull request #3864 from plotly/pr-sort-by-value
sort categorical Cartesian axes by value
2 parents 2bbf486 + d13542e commit dba8f55

18 files changed

+610
-13
lines changed

Diff for: src/lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ var statsModule = require('./stats');
7575
lib.aggNums = statsModule.aggNums;
7676
lib.len = statsModule.len;
7777
lib.mean = statsModule.mean;
78+
lib.median = statsModule.median;
7879
lib.midRange = statsModule.midRange;
7980
lib.variance = statsModule.variance;
8081
lib.stdev = statsModule.stdev;

Diff for: src/lib/stats.js

+9
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ exports.stdev = function(data, len, mean) {
7474
return Math.sqrt(exports.variance(data, len, mean));
7575
};
7676

77+
/**
78+
* median of a finite set of numbers
79+
* reference page: https://en.wikipedia.org/wiki/Median#Finite_set_of_numbers
80+
**/
81+
exports.median = function(data) {
82+
var b = data.slice().sort();
83+
return exports.interp(b, 0.5);
84+
};
85+
7786
/**
7887
* interp() computes a percentile (quantile) for a given distribution.
7988
* We interpolate the distribution (to compute quantiles, we follow method #10 here:

Diff for: src/plot_api/plot_schema.js

+2
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,8 @@ function getTraceAttributes(type) {
503503

504504
var out = {
505505
meta: _module.meta || {},
506+
categories: _module.categories || {},
507+
type: type,
506508
attributes: formatAttributes(attributes),
507509
};
508510

Diff for: src/plots/cartesian/layout_attributes.js

+11-5
Original file line numberDiff line numberDiff line change
@@ -817,8 +817,13 @@ module.exports = {
817817
categoryorder: {
818818
valType: 'enumerated',
819819
values: [
820-
'trace', 'category ascending', 'category descending', 'array'
821-
/* , 'value ascending', 'value descending'*/ // value ascending / descending to be implemented later
820+
'trace', 'category ascending', 'category descending', 'array',
821+
'total ascending', 'total descending',
822+
'min ascending', 'min descending',
823+
'max ascending', 'max descending',
824+
'sum ascending', 'sum descending',
825+
'mean ascending', 'mean descending',
826+
'median ascending', 'median descending'
822827
],
823828
dflt: 'trace',
824829
role: 'info',
@@ -828,11 +833,12 @@ module.exports = {
828833
'By default, plotly uses *trace*, which specifies the order that is present in the data supplied.',
829834
'Set `categoryorder` to *category ascending* or *category descending* if order should be determined by',
830835
'the alphanumerical order of the category names.',
831-
/* 'Set `categoryorder` to *value ascending* or *value descending* if order should be determined by the',
832-
'numerical order of the values.',*/ // // value ascending / descending to be implemented later
833836
'Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category',
834837
'is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to',
835-
'the *trace* mode. The unspecified categories will follow the categories in `categoryarray`.'
838+
'the *trace* mode. The unspecified categories will follow the categories in `categoryarray`.',
839+
'Set `categoryorder` to *total ascending* or *total descending* if order should be determined by the',
840+
'numerical order of the values.',
841+
'Similarly, the order can be determined by the min, max, sum, mean or median of all the values.'
836842
].join(' ')
837843
},
838844
categoryarray: {

Diff for: src/plots/cartesian/set_convert.js

+31
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,37 @@ module.exports = function setConvert(ax, fullLayout) {
612612
}
613613
};
614614

615+
// sort the axis (and all the matching ones) by _initialCategories
616+
// returns the indices of the traces affected by the reordering
617+
ax.sortByInitialCategories = function() {
618+
var affectedTraces = [];
619+
var emptyCategories = function() {
620+
ax._categories = [];
621+
ax._categoriesMap = {};
622+
};
623+
624+
emptyCategories();
625+
626+
if(ax._initialCategories) {
627+
for(var j = 0; j < ax._initialCategories.length; j++) {
628+
setCategoryIndex(ax._initialCategories[j]);
629+
}
630+
}
631+
632+
affectedTraces = affectedTraces.concat(ax._traceIndices);
633+
634+
// Propagate to matching axes
635+
var group = ax._matchGroup;
636+
for(var axId2 in group) {
637+
if(axId === axId2) continue;
638+
var ax2 = fullLayout[axisIds.id2name(axId2)];
639+
ax2._categories = ax._categories;
640+
ax2._categoriesMap = ax._categoriesMap;
641+
affectedTraces = affectedTraces.concat(ax2._traceIndices);
642+
}
643+
return affectedTraces;
644+
};
645+
615646
// Propagate localization into the axis so that
616647
// methods in Axes can use it w/o having to pass fullLayout
617648
// Default (non-d3) number formatting uses separators directly

Diff for: src/plots/cartesian/type_defaults.js

+2-8
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,8 @@ function setAutoType(ax, data) {
8080
ax.type = autoType(boxPositions, calendar, opts);
8181
} else if(d0.type === 'splom') {
8282
var dimensions = d0.dimensions;
83-
var diag = d0._diag;
84-
for(i = 0; i < dimensions.length; i++) {
85-
var dim = dimensions[i];
86-
if(dim.visible && (diag[i][0] === id || diag[i][1] === id)) {
87-
ax.type = autoType(dim.values, calendar, opts);
88-
break;
89-
}
90-
}
83+
var dim = dimensions[d0._axesDim[id]];
84+
if(dim.visible) ax.type = autoType(dim.values, calendar, opts);
9185
} else {
9286
ax.type = autoType(d0[axLetter] || [d0[axLetter + '0']], calendar, opts);
9387
}

Diff for: src/plots/plots.js

+186
Original file line numberDiff line numberDiff line change
@@ -2847,10 +2847,196 @@ plots.doCalcdata = function(gd, traces) {
28472847

28482848
doCrossTraceCalc(gd);
28492849

2850+
// Sort axis categories per value if specified
2851+
var sorted = sortAxisCategoriesByValue(axList, gd);
2852+
if(sorted.length) {
2853+
// If a sort operation was performed, run calc() again
2854+
for(i = 0; i < sorted.length; i++) calci(sorted[i], true);
2855+
for(i = 0; i < sorted.length; i++) calci(sorted[i], false);
2856+
doCrossTraceCalc(gd);
2857+
}
2858+
28502859
Registry.getComponentMethod('fx', 'calc')(gd);
28512860
Registry.getComponentMethod('errorbars', 'calc')(gd);
28522861
};
28532862

2863+
var sortAxisCategoriesByValueRegex = /(total|sum|min|max|mean|median) (ascending|descending)/;
2864+
2865+
function sortAxisCategoriesByValue(axList, gd) {
2866+
var affectedTraces = [];
2867+
var i, j, k, l, o;
2868+
2869+
function zMapCategory(type, ax, value) {
2870+
var axLetter = ax._id.charAt(0);
2871+
if(type === 'histogram2dcontour') {
2872+
var counterAxLetter = ax._counterAxes[0];
2873+
var counterAx = axisIDs.getFromId(gd, counterAxLetter);
2874+
2875+
var xCategorical = axLetter === 'x' || (counterAxLetter === 'x' && counterAx.type === 'category');
2876+
var yCategorical = axLetter === 'y' || (counterAxLetter === 'y' && counterAx.type === 'category');
2877+
2878+
return function(o, l) {
2879+
if(o === 0 || l === 0) return -1; // Skip first row and column
2880+
if(xCategorical && o === value[l].length - 1) return -1;
2881+
if(yCategorical && l === value.length - 1) return -1;
2882+
2883+
return (axLetter === 'y' ? l : o) - 1;
2884+
};
2885+
} else {
2886+
return function(o, l) {
2887+
return axLetter === 'y' ? l : o;
2888+
};
2889+
}
2890+
}
2891+
2892+
var aggFn = {
2893+
'min': function(values) {return Lib.aggNums(Math.min, null, values);},
2894+
'max': function(values) {return Lib.aggNums(Math.max, null, values);},
2895+
'sum': function(values) {return Lib.aggNums(function(a, b) { return a + b;}, null, values);},
2896+
'total': function(values) {return Lib.aggNums(function(a, b) { return a + b;}, null, values);},
2897+
'mean': function(values) {return Lib.mean(values);},
2898+
'median': function(values) {return Lib.median(values);}
2899+
};
2900+
2901+
for(i = 0; i < axList.length; i++) {
2902+
var ax = axList[i];
2903+
if(ax.type !== 'category') continue;
2904+
2905+
// Order by value
2906+
var match = ax.categoryorder.match(sortAxisCategoriesByValueRegex);
2907+
if(match) {
2908+
var aggregator = match[1];
2909+
var order = match[2];
2910+
2911+
// Store values associated with each category
2912+
var categoriesValue = [];
2913+
for(j = 0; j < ax._categories.length; j++) {
2914+
categoriesValue.push([ax._categories[j], []]);
2915+
}
2916+
2917+
// Collect values across traces
2918+
for(j = 0; j < ax._traceIndices.length; j++) {
2919+
var traceIndex = ax._traceIndices[j];
2920+
var fullTrace = gd._fullData[traceIndex];
2921+
var axLetter = ax._id.charAt(0);
2922+
2923+
// Skip over invisible traces
2924+
if(fullTrace.visible !== true) continue;
2925+
2926+
var type = fullTrace.type;
2927+
if(Registry.traceIs(fullTrace, 'histogram')) delete fullTrace._autoBinFinished;
2928+
2929+
var cd = gd.calcdata[traceIndex];
2930+
for(k = 0; k < cd.length; k++) {
2931+
var cdi = cd[k];
2932+
var cat, catIndex, value;
2933+
2934+
if(type === 'splom') {
2935+
// If `splom`, collect values across dimensions
2936+
// Find which dimension the current axis is representing
2937+
var currentDimensionIndex = fullTrace._axesDim[ax._id];
2938+
2939+
// Apply logic to associated x axis if it's defined
2940+
if(axLetter === 'y') {
2941+
var associatedXAxisID = fullTrace._diag[currentDimensionIndex][0];
2942+
if(associatedXAxisID) ax = gd._fullLayout[axisIDs.id2name(associatedXAxisID)];
2943+
}
2944+
2945+
var categories = cdi.trace.dimensions[currentDimensionIndex].values;
2946+
for(l = 0; l < categories.length; l++) {
2947+
cat = categories[l];
2948+
catIndex = ax._categoriesMap[cat];
2949+
2950+
// Collect associated values at index `l` over all other dimensions
2951+
for(o = 0; o < cdi.trace.dimensions.length; o++) {
2952+
if(o === currentDimensionIndex) continue;
2953+
var dimension = cdi.trace.dimensions[o];
2954+
categoriesValue[catIndex][1].push(dimension.values[l]);
2955+
}
2956+
}
2957+
} else if(type === 'scattergl') {
2958+
// If `scattergl`, collect all values stashed under cdi.t
2959+
for(l = 0; l < cdi.t.x.length; l++) {
2960+
if(axLetter === 'x') {
2961+
cat = cdi.t.x[l];
2962+
catIndex = cat;
2963+
value = cdi.t.y[l];
2964+
}
2965+
2966+
if(axLetter === 'y') {
2967+
cat = cdi.t.y[l];
2968+
catIndex = cat;
2969+
value = cdi.t.x[l];
2970+
}
2971+
categoriesValue[catIndex][1].push(value);
2972+
}
2973+
// must clear scene 'batches', so that 2nd
2974+
// _module.calc call starts from scratch
2975+
if(cdi.t && cdi.t._scene) {
2976+
delete cdi.t._scene.dirty;
2977+
}
2978+
} else if(cdi.hasOwnProperty('z')) {
2979+
// If 2dMap, collect values in `z`
2980+
value = cdi.z;
2981+
var mapping = zMapCategory(fullTrace.type, ax, value);
2982+
2983+
for(l = 0; l < value.length; l++) {
2984+
for(o = 0; o < value[l].length; o++) {
2985+
catIndex = mapping(o, l);
2986+
if(catIndex + 1) categoriesValue[catIndex][1].push(value[l][o]);
2987+
}
2988+
}
2989+
} else {
2990+
// For all other 2d cartesian traces
2991+
if(axLetter === 'x') {
2992+
cat = cdi.p + 1 ? cdi.p : cdi.x;
2993+
value = cdi.s || cdi.v || cdi.y;
2994+
} else if(axLetter === 'y') {
2995+
cat = cdi.p + 1 ? cdi.p : cdi.y;
2996+
value = cdi.s || cdi.v || cdi.x;
2997+
}
2998+
if(!Array.isArray(value)) value = [value];
2999+
for(l = 0; l < value.length; l++) {
3000+
categoriesValue[cat][1].push(value[l]);
3001+
}
3002+
}
3003+
}
3004+
}
3005+
3006+
ax._categoriesValue = categoriesValue;
3007+
3008+
var categoriesAggregatedValue = [];
3009+
for(j = 0; j < categoriesValue.length; j++) {
3010+
categoriesAggregatedValue.push([
3011+
categoriesValue[j][0],
3012+
aggFn[aggregator](categoriesValue[j][1])
3013+
]);
3014+
}
3015+
3016+
// Sort by aggregated value
3017+
categoriesAggregatedValue.sort(function(a, b) {
3018+
return a[1] - b[1];
3019+
});
3020+
3021+
ax._categoriesAggregatedValue = categoriesAggregatedValue;
3022+
3023+
// Set new category order
3024+
ax._initialCategories = categoriesAggregatedValue.map(function(c) {
3025+
return c[0];
3026+
});
3027+
3028+
// Reverse if descending
3029+
if(order === 'descending') {
3030+
ax._initialCategories.reverse();
3031+
}
3032+
3033+
// Sort all matching axes
3034+
affectedTraces = affectedTraces.concat(ax.sortByInitialCategories());
3035+
}
3036+
}
3037+
return affectedTraces;
3038+
}
3039+
28543040
function setupAxisCategories(axList, fullData) {
28553041
for(var i = 0; i < axList.length; i++) {
28563042
var ax = axList[i];

Diff for: src/traces/box/calc.js

+4
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ module.exports = function calc(gd, trace) {
8080
cdi.pos = posDistinct[i];
8181
cdi.pts = pts;
8282

83+
// Sort categories by values
84+
cdi[posLetter] = cdi.pos;
85+
cdi[valLetter] = cdi.pts.map(function(pt) { return pt.v; });
86+
8387
cdi.min = boxVals[0];
8488
cdi.max = boxVals[bvLen - 1];
8589
cdi.mean = Lib.mean(boxVals, bvLen);

Diff for: src/traces/ohlc/calc.js

+4
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ function calcCommon(gd, trace, x, ya, ptFunc) {
8686
pt.i = i;
8787
pt.dir = increasing ? 'increasing' : 'decreasing';
8888

89+
// For categoryorder, store low and high
90+
pt.x = pt.pos;
91+
pt.y = [li, hi];
92+
8993
if(hasTextArray) pt.tx = trace.text[i];
9094
if(hasHovertextArray) pt.htx = trace.hovertext[i];
9195

Diff for: src/traces/splom/defaults.js

+3
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ function handleAxisDefaults(traceIn, traceOut, layout, coerce) {
127127
var mustShiftX = !showDiag && !showLower;
128128
var mustShiftY = !showDiag && !showUpper;
129129

130+
traceOut._axesDim = {};
130131
for(i = 0; i < dimLength; i++) {
131132
var dim = dimensions[i];
132133
var i0 = i === 0;
@@ -143,6 +144,8 @@ function handleAxisDefaults(traceIn, traceOut, layout, coerce) {
143144
fillAxisStashes(xaId, yaId, dim, xList);
144145
fillAxisStashes(yaId, xaId, dim, yList);
145146
diag[i] = [xaId, yaId];
147+
traceOut._axesDim[xaId] = i;
148+
traceOut._axesDim[yaId] = i;
146149
}
147150

148151
// fill in splom subplot keys
19.3 KB
Loading
13.1 KB
Loading

Diff for: test/image/baselines/sort_by_total_matching_axes.png

18.5 KB
Loading

Diff for: test/image/mocks/hist_category_total_ascending.json

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"data": [{
3+
"x": ["a", "b", "c", "a", "b", "d", "b", "c", "b", "b"],
4+
"type": "histogram"
5+
},
6+
{
7+
"x": ["d", "c", "a", "e", "a"],
8+
"type": "histogram"
9+
},
10+
{
11+
"y": ["a", "b", "c", "a", "b", "d", "b", "c"],
12+
"type": "histogram",
13+
"xaxis": "x2",
14+
"yaxis": "y2"
15+
},
16+
{
17+
"y": ["d", "c", "b", "a", "e", "a", "b"],
18+
"type": "histogram",
19+
"xaxis": "x2",
20+
"yaxis": "y2"
21+
}],
22+
"layout": {
23+
"title": "categoryorder: \"total ascending\"",
24+
"height": 400,
25+
"width": 600,
26+
"barmode": "stack",
27+
"xaxis": {
28+
"domain": [0, 0.45],
29+
"categoryorder": "total ascending"
30+
},
31+
"xaxis2": {
32+
"domain": [0.55, 1]
33+
},
34+
"yaxis2": {
35+
"anchor": "x2",
36+
"categoryorder": "total ascending"
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)