Skip to content

more descending shorthand #1606

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
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
4 changes: 2 additions & 2 deletions docs/features/facets.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Plot.plot({
y: "variety",
fy: "site",
stroke: "year",
sort: {y: "x", fy: "x", reduce: "median", reverse: true}
sort: {y: "-x", fy: "-x", reduce: "median"}
})
]
})
Expand Down Expand Up @@ -81,7 +81,7 @@ Plot.plot({
fy: "site",
stroke: "yield",
strokeWidth: 2,
sort: {y: "x1", fy: "x1", reduce: "median", reverse: true}
sort: {y: "-x1", fy: "-x1", reduce: "median"}
}))
]
})
Expand Down
4 changes: 2 additions & 2 deletions docs/features/scales.md
Original file line number Diff line number Diff line change
Expand Up @@ -977,10 +977,10 @@ Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reduce: "max"}}

Generally speaking, a reducer only needs to be specified when there are multiple secondary values for a given primary value. See the [group transform](../transforms/group.md) for the list of supported reducers.

For descending rather than ascending order, use the *reverse* option:
For descending rather than ascending order, use the *reverse* option, or *-name* when referring to a channel:

```js
Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reverse: true}})
Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "-y"}})
```

An additional *limit* option truncates the domain to the first *n* values after sorting. If *limit* is negative, the last *n* values are used instead. Hence, a positive *limit* with *reverse* = true will return the top *n* values in descending order. If *limit* is an array [*lo*, *hi*], the *i*th values with *lo* ≤ *i* < *hi* will be selected. (Note that like the [basic filter transform](../transforms/filter.md), limiting the *x* domain here does not affect the computation of the *y* domain, which is computed independently without respect to filtering.)
Expand Down
2 changes: 1 addition & 1 deletion docs/marks/bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Ordinal domains are sorted naturally (alphabetically) by default. Either set the

:::plot https://observablehq.com/@observablehq/plot-vertical-bars
```js
Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reverse: true}}).plot()
Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "-y"}}).plot()
```
:::

Expand Down
2 changes: 1 addition & 1 deletion docs/transforms/group.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Plot.plot({
x: {label: null, tickRotate: 90},
y: {grid: true},
marks: [
Plot.barY(olympians, Plot.groupX({y: "count"}, {x: "sport", sort: {x: "y", reverse: true}})),
Plot.barY(olympians, Plot.groupX({y: "count"}, {x: "sport", sort: {x: "-y"}})),
Plot.ruleY([0])
]
})
Expand Down
4 changes: 2 additions & 2 deletions docs/transforms/sort.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Plot.plot({
fill: "currentColor",
stroke: "var(--vp-c-bg)",
strokeWidth: 1,
sort: sorted ? {channel: "r", order: "descending"} : null
sort: sorted ? {channel: "-r"} : null
}))
]
})
Expand Down Expand Up @@ -134,7 +134,7 @@ Sorts the data by the specified *order*, which is one of:
- a field name
- a {*channel*, *order*} object

In the object case, the **channel** option specifies the name of the channel, while the **order** option specifies *ascending* (the default) or *descending* order. For example, `sort: {channel: "r", order: "descending"}` will sort by descending radius (**r**).
In the object case, the **channel** option specifies the name of the channel, while the **order** option specifies *ascending* (the default) or *descending* order. You can also use the shorthand *-name* to sort by descending order of the channel with the given *name*. For example, `sort: {channel: "-r"}` will sort by descending radius (**r**).

In the function case, if the sort function does not take exactly one argument, it is interpreted as a comparator function; otherwise it is interpreted as an accessor function.

Expand Down
5 changes: 4 additions & 1 deletion src/channel.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export type ChannelValueBinSpec = ChannelValue | ({value: ChannelValue} & BinOpt
*/
export type ChannelValueDenseBinSpec = ChannelValue | ({value: ChannelValue; scale?: Channel["scale"]} & Omit<BinOptions, "interval">); // prettier-ignore

/** A channel name, or an implied one for domain sorting. */
type ChannelDomainName = ChannelName | "data" | "width" | "height";

/**
* The available inputs for imputing scale domains. In addition to a named
* channel, an input may be specified as:
Expand All @@ -176,7 +179,7 @@ export type ChannelValueDenseBinSpec = ChannelValue | ({value: ChannelValue; sca
* custom **reduce** function, as when the built-in single-channel reducers are
* insufficient.
*/
export type ChannelDomainValue = ChannelName | "data" | "width" | "height" | null;
export type ChannelDomainValue = ChannelDomainName | `-${ChannelDomainName}` | null;

/** Options for imputing scale domains from channel values. */
export interface ChannelDomainOptions {
Expand Down
3 changes: 3 additions & 0 deletions src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ export function channelDomain(data, facets, channels, facetChannels, options) {
for (const x in options) {
if (!registry.has(x)) continue; // ignore unknown scale keys (including generic options)
let {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]);
const negate = y?.startsWith("-");
if (negate) y = y.slice(1);
if (reverse === undefined) reverse = y === "width" || y === "height"; // default to descending for lengths
if (negate) reverse = !reverse;
if (reduce == null || reduce === false) continue; // disabled reducer
const X = x === "fx" || x === "fy" ? reindexFacetChannel(facets, facetChannels[x]) : findScaleChannel(channels, x);
if (!X) throw new Error(`missing channel for scale: ${x}`);
Expand Down
2 changes: 1 addition & 1 deletion src/mark.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export interface MarkOptions {
* with a *value* object and per-scale options:
*
* ```js
* sort: {y: {value: "x", reverse: true}}
* sort: {y: {value: "-x"}}
* ```
*
* When sorting the mark’s index, the **sort** option is instead one of:
Expand Down
4 changes: 1 addition & 3 deletions src/marks/dot.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ const defaults = {
};

export function withDefaultSort(options) {
return options.sort === undefined && options.reverse === undefined
? sort({channel: "r", order: "descending"}, options)
: options;
return options.sort === undefined && options.reverse === undefined ? sort({channel: "-r"}, options) : options;
}

export class Dot extends Mark {
Expand Down
9 changes: 9 additions & 0 deletions src/reducer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,12 @@ export interface ReducerImplementation<S = any, T = S> {
* - an object that implements the *reduceIndex* method
*/
export type Reducer = ReducerName | ReducerFunction | ReducerImplementation;

/**
* How to reduce aggregated (binned or grouped) values for sorting; one of:
*
* - a named reducer implementation such as *count* or *sum*
* - a function that takes an array of values and returns the reduced value
* - an object that implements the *reduceIndex* method
*/
export type ReducerSort = `-${ReducerName}` | Reducer;
2 changes: 1 addition & 1 deletion src/transforms/basic.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export type SortOrder =
| CompareFunction
| ChannelValue
| {value?: ChannelValue; order?: CompareFunction | "ascending" | "descending"}
| {channel?: ChannelName; order?: CompareFunction | "ascending" | "descending"};
| {channel?: ChannelName | `-${ChannelName}`; order?: CompareFunction | "ascending" | "descending"};

/**
* Applies a transform to *options* to sort the mark’s index by the specified
Expand Down
16 changes: 16 additions & 0 deletions src/transforms/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ function sortValue(value) {
throw new Error(`invalid order: ${order}`);
}
}
if (channel?.startsWith("-")) {
channel = channel.slice(1);
order = reverseOrder(order);
}
if (typeof value === "string" && value?.startsWith("-")) {
value = value.slice(1);
order = reverseOrder(order);
}
Comment on lines +128 to +131
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If value is a string here, it is a field name rather than a reducer or a channel name. Because field names come from the user’s data, they are much more diverse than channel names and reducer names (which are generally fixed by Plot, though we do allow “extra” channels for the tip mark). This would break field names that start with a hyphen, which is admittedly unlikely and uncommon, but still possible. And since we aren’t going to allow x: "-foo" for field names in general, this is somewhat inconsistent.

I don’t think we should introduce sort: "-foo" shorthand for field names. There are already three ways to do this, and that seems reasonable enough:

  1. sort: "foo", reverse: true
  2. sort: {value: "foo", order: "descending"}
  3. sort: (d) => -d.foo (assuming foo is numeric)

We could introduce a value helper, perhaps: sort: Plot.negate("foo"). This would take the same arguments that Plot.valueof takes for value, but would return the negative value. It would only work for numbers, though, of course…

Or perhaps we could also introduce a sort helper: sort: Plot.descending("foo") which just returns {value: "foo", order: "descending"}.

return (data, facets, channels) => {
let V;
if (channel === undefined) {
Expand All @@ -135,3 +143,11 @@ function sortValue(value) {
return {data, facets: facets.map((I) => I.slice().sort(compareValue))};
};
}

function reverseOrder(order) {
return order === ascendingDefined
? descendingDefined
: order === descendingDefined
? ascendingDefined
: (i, j) => order(j, i);
}
4 changes: 4 additions & 0 deletions src/transforms/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ function binn(
// Compute the outputs.
outputs = maybeBinOutputs(outputs, inputs);
reduceData = maybeBinReduce(reduceData, identity);
if (typeof sort === "string" && sort.startsWith("-")) {
sort = sort.slice(1);
reverse = !reverse;
}
Comment on lines +101 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A subtle gotcha here: sort: "-x" doesn’t mean “sort by x in natural ascending order and then reverse the resulting order”; it means “sort by x in natural descending order”. The difference is that we want nulls/unordered values to be last when using -x, whereas reversing after sorting would put them first.

The ChannelDomainOptions interface is confusing in this regard because it calls the option reverse, but it really means whether to use ascending or descending order (per the documentation):

plot/src/channel.d.ts

Lines 195 to 196 in 0490ba6

/** If true, use descending instead of ascending order. */
reverse?: boolean;

I commented on this indirectly in #1460, and I’m going to add some context to that issue now to clarify the confusion.

In any case, here we ideally don’t want to flip the reverse option; we want to use descending rather than ascending natural order. That’s why I added the reverseOrder helper for the basic sort transform:

function reverseOrder(order) {
return order === ascendingDefined
? descendingDefined
: order === descendingDefined
? ascendingDefined
: (i, j) => order(j, i);
}

I think this might mean that the maybeSort helper should take an order argument, and that the bin and group transform should support order: "descending" and order: "ascending" as complements to the sort option, instead of only supporting a reverse option?

sort = sort == null ? undefined : maybeBinOutput("sort", sort, inputs);
filter = filter == null ? undefined : maybeBinEvaluator("filter", filter, inputs);

Expand Down
2 changes: 1 addition & 1 deletion src/transforms/dodge.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function dodge(y, x, anchor, padding, r, options) {
let {channels, sort, reverse} = options;
channels = maybeNamed(channels);
if (channels?.r === undefined) options = {...options, channels: {...channels, r: {value: r, scale: "r"}}};
if (sort === undefined && reverse === undefined) options.sort = {channel: "r", order: "descending"};
if (sort === undefined && reverse === undefined) options.sort = {channel: "-r"};
}
return initializer(options, function (data, facets, channels, scales, dimensions, context) {
let {[x]: X, r: R} = channels;
Expand Down
4 changes: 2 additions & 2 deletions src/transforms/group.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {ChannelReducers, ChannelValue} from "../channel.js";
import type {Reducer} from "../reducer.js";
import type {ReducerSort, Reducer} from "../reducer.js";
import type {Transformed} from "./basic.js";

/** Options for outputs of the group (and bin) transform. */
Expand Down Expand Up @@ -29,7 +29,7 @@ export interface GroupOutputOptions<T = Reducer> {
* Plot.groupX({y: "count", sort: "count"}, {fill: "sex", x: "sport"})
* ```
*/
sort?: T | null;
sort?: ReducerSort | null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is incorrectly narrowing the type of T (e.g., in the case that T is BinReducer instead of Reducer). We’ll need to find another way to type this.


/** If true, reverse the order of generated groups; defaults to false. */
reverse?: boolean;
Expand Down
4 changes: 4 additions & 0 deletions src/transforms/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ function groupn(
// Compute the outputs.
outputs = maybeOutputs(outputs, inputs);
reduceData = maybeReduce(reduceData, identity);
if (typeof sort === "string" && sort.startsWith("-")) {
sort = sort.slice(1);
reverse = !reverse;
}
Comment on lines +81 to +84
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here re. reverse.

sort = sort == null ? undefined : maybeOutput("sort", sort, inputs);
filter = filter == null ? undefined : maybeEvaluator("filter", filter, inputs);

Expand Down
4 changes: 4 additions & 0 deletions src/transforms/stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ function stack(x, y = one, kx, ky, {offset, order, reverse}, options) {
const [Y2, setY2] = column(y);
Y1.hint = Y2.hint = lengthy;
offset = maybeOffset(offset);
if (typeof order === "string" && order.startsWith("-")) {
order = order.slice(1);
reverse = !reverse;
}
Comment on lines +84 to +87
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here re. reverse. And also as commented above, order can be a field name, and I don’t think we should support -field syntax since it is more likely to conflict with a user-supplied column name.

order = maybeOrder(order, offset, ky);
return [
basic(options, (data, facets, plotOptions) => {
Expand Down
12 changes: 3 additions & 9 deletions test/plots/athletes-nationality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,8 @@ import * as d3 from "d3";
export async function athletesNationality() {
const athletes = await d3.csv<any>("data/athletes.csv", d3.autoType);
return Plot.plot({
x: {
grid: true
},
y: {
label: null
},
marks: [
Plot.barX(athletes, Plot.groupY({x: "count"}, {y: "nationality", sort: {y: "x", reverse: true, limit: 20}}))
]
x: {grid: true},
y: {label: null},
marks: [Plot.barX(athletes, Plot.groupY({x: "count"}, {y: "nationality", sort: {y: "-x", limit: 20}}))]
});
}
3 changes: 1 addition & 2 deletions test/plots/ballot-status-race.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ export async function ballotStatusRace() {
title: (d) => `${d.percent.toFixed(1)}%`,
sort: {
fy: "data",
reduce: (data) => data.find((d) => d.status === "ACCEPTED").percent,
reverse: true
reduce: (data) => -data.find((d) => d.status === "ACCEPTED").percent
}
}),
Plot.ruleX([0])
Expand Down
2 changes: 1 addition & 1 deletion test/plots/d3-survey-2015.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function bars(groups, title) {
y: ([key]) => key,
fill: "steelblue",
insetTop: 1,
sort: {y: "x", reverse: true}
sort: {y: "-x"}
}),
Plot.ruleX([0])
]
Expand Down
10 changes: 2 additions & 8 deletions test/plots/fruit-sales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,7 @@ export async function fruitSales() {
const sales = await d3.csv<any>("data/fruit-sales.csv", d3.autoType);
return Plot.plot({
marginLeft: 50,
y: {
label: null,
reverse: true
},
marks: [
Plot.barX(sales, Plot.groupY({x: "sum"}, {x: "units", y: "fruit", sort: {y: "x", reverse: true}})),
Plot.ruleX([0])
]
y: {label: null},
marks: [Plot.barX(sales, Plot.groupY({x: "sum"}, {x: "units", y: "fruit", sort: {y: "x"}})), Plot.ruleX([0])]
});
}
2 changes: 1 addition & 1 deletion test/plots/industry-unemployment-track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export async function industryUnemploymentTrack() {
interval: "month",
fill: "unemployed",
title: "unemployed",
sort: {fy: "fill", reverse: true},
sort: {fy: "-fill"},
inset: 0
}),
Plot.dotX(
Expand Down
2 changes: 1 addition & 1 deletion test/plots/learning-poverty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export async function learningPoverty() {
x: (d) => (d.type === "ok" ? -1 : 1) * d.share, // diverging bars
y: "Country Name",
fill: "type",
sort: {y: "x", reverse: true}
sort: {y: "-x"}
}),
Plot.ruleX([0])
]
Expand Down
2 changes: 1 addition & 1 deletion test/plots/metro-unemployment-ridgeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function metroUnemploymentRidgeline() {
},
marks: [
Plot.areaY(data, {x: "date", y: "unemployment", fill: "#eee"}),
Plot.line(data, {x: "date", y: "unemployment", sort: {fy: "y", reverse: true}}),
Plot.line(data, {x: "date", y: "unemployment", sort: {fy: "-y"}}),
Plot.ruleY([0])
]
});
Expand Down
2 changes: 1 addition & 1 deletion test/plots/movies-profit-by-genre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function moviesProfitByGenre() {
x: Profit,
stroke: "red",
strokeWidth: 2,
sort: {y: "x", reverse: true}
sort: {y: "-x"}
}
)
)
Expand Down
5 changes: 2 additions & 3 deletions test/plots/movies-rating-by-genre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ export async function moviesRatingByGenre() {
stroke: "Major Genre",
r: 2.5,
sort: {
fy: "x",
reduce: "median",
reverse: true
fy: "-x",
reduce: "median"
}
}
)
Expand Down
3 changes: 1 addition & 2 deletions test/plots/music-revenue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ export async function musicRevenue() {
x: "year",
y: "revenue",
z: "format",
order: "appearance",
reverse: true
order: "-appearance"
};
return Plot.plot({
y: {
Expand Down
5 changes: 1 addition & 4 deletions test/plots/penguin-annotated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ export async function penguinAnnotated() {
x: {insetRight: 10},
marks: [
Plot.frame(),
Plot.barX(
penguins,
Plot.groupY({x: "count"}, {y: "species", fill: "sex", title: "sex", sort: {y: "x", reverse: true}})
),
Plot.barX(penguins, Plot.groupY({x: "count"}, {y: "species", fill: "sex", title: "sex", sort: {y: "-x"}})),
Plot.text(["Count of penguins\ngrouped by species\n and colored by sex"], {
frameAnchor: "bottom-right",
dx: -3,
Expand Down
3 changes: 1 addition & 2 deletions test/plots/penguin-sex-mass-culmen-species.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ export async function penguinSexMassCulmenSpecies() {
Plot.bin(
{
r: "count",
sort: "count",
reverse: true
sort: "-count"
},
{
x: "body_mass_g",
Expand Down
2 changes: 1 addition & 1 deletion test/plots/us-population-state-age-dots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function usPopulationStateAgeDots() {
textAnchor: "end",
dx: -6,
text: "state",
sort: {y: "x", reduce: "min", reverse: true}
sort: {y: "-x", reduce: "min"}
})
)
]
Expand Down
2 changes: 1 addition & 1 deletion test/plots/us-population-state-age.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function usPopulationStateAgeGrouped() {
y: "population",
fill: "age",
title: "age",
sort: {fx: "y", reverse: true, limit: 6}
sort: {fx: "-y", limit: 6}
}),
Plot.ruleY([0])
]
Expand Down