diff --git a/docs/features/facets.md b/docs/features/facets.md index 31fd211820..ff8fd962c4 100644 --- a/docs/features/facets.md +++ b/docs/features/facets.md @@ -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"} }) ] }) @@ -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"} })) ] }) diff --git a/docs/features/scales.md b/docs/features/scales.md index 52845cf646..3f366fa39a 100644 --- a/docs/features/scales.md +++ b/docs/features/scales.md @@ -969,21 +969,35 @@ Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y"}}) The sort option is an object whose keys are ordinal scale names, such as *x* or *fx*, and whose values are mark channel names, such as **y**, **y1**, or **y2**. By specifying an existing channel rather than a new value, you avoid repeating the order definition and can refer to channels derived by [transforms](./transforms.md) (such as [stack](../transforms/stack.md) or [bin](../transforms/bin.md)). When sorting the *x* domain, if no **x** channel is defined, **x2** will be used instead if available, and similarly for *y* and **y2**; this is useful for marks that implicitly stack such as [area](../marks/area.md), [bar](../marks/bar.md), and [rect](../marks/rect.md). A sort value may also be specified as *width* or *height*, representing derived channels |*x2* - *x1*| and |*y2* - *y1*| respectively. -Note that there may be multiple associated values in the secondary dimension for a given value in the primary ordinal dimension. The secondary values are therefore grouped for each associated primary value, and each group is then aggregated by applying a reducer. Lastly the primary values are sorted based on the associated reduced value in natural ascending order to produce the domain. The default reducer is *max*, but may be changed by specifying the *reduce* option. The above code is shorthand for: +Note that there may be multiple associated values in the secondary dimension for a given value in the primary ordinal dimension. The secondary values are therefore grouped for each associated primary value, and each group is then aggregated by applying a reducer. The default reducer is *max*, but may be changed by specifying the **reduce** option. Lastly the primary values are by default sorted based on the associated reduced value in natural ascending order to produce the domain. The above code is shorthand for: ```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reduce: "max"}}) +Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reduce: "max", order: "ascending"}}) ``` 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, set the **order** option to *descending*: + +```js +Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", order: "descending"}}) +``` + +Alternatively, the *-channel* shorthand option, which changes the default **order** to *descending*: + +```js +Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "-y"}}) +``` + +Setting **order** to null will disable sorting, preserving the order of the data. (When an aggregating transform is used, such as [group](../transforms/group.md) or [bin](../transforms/bin.md), note that the data may already have been sorted and thus the order may differ from the input data.) + +Alternatively, set the **reverse** option to true. This produces a different result than descending order for null or unorderable values: descending order puts nulls last, whereas reversed ascending order puts nulls first. ```js Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", reverse: true}}) ``` -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.) +An additional **limit** option truncates the domain to the first *n* values after ordering. 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.) ```js Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", limit: 5}}) @@ -992,7 +1006,7 @@ Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: "y", limit: 5}}) If different sort options are needed for different ordinal scales, the channel name can be replaced with a *value* object with additional per-scale options. ```js -Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: {value: "y", reverse: true}}}) +Plot.barY(alphabet, {x: "letter", y: "frequency", sort: {x: {value: "y", order: "descending"}}}) ``` If the input channel is *data*, then the reducer is passed groups of the mark’s data; this is typically used in conjunction with a custom reducer function, as when the built-in single-channel reducers are insufficient. diff --git a/docs/features/shorthand.md b/docs/features/shorthand.md index 98a1cf44e2..539640ca5d 100644 --- a/docs/features/shorthand.md +++ b/docs/features/shorthand.md @@ -156,7 +156,7 @@ Plot.tickX(numbers).plot() ``` ::: -We could even use [Plot.vectorX](../marks/vector.md) here to draw little up-pointing arrows. (Typically the vector mark is used in conjunction with the *rotate* and *length* options to control the direction and magnitude of each vector.) +We could even use [Plot.vectorX](../marks/vector.md) here to draw little up-pointing arrows. (Typically the vector mark is used in conjunction with the **rotate** and **length** options to control the direction and magnitude of each vector.) :::plot https://observablehq.com/@observablehq/plot-shorthand-one-dimensional-vector ```js diff --git a/docs/marks/bar.md b/docs/marks/bar.md index 4b0397f73c..b00b722f92 100644 --- a/docs/marks/bar.md +++ b/docs/marks/bar.md @@ -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() ``` ::: diff --git a/docs/transforms/group.md b/docs/transforms/group.md index dd11175ceb..df85aea289 100644 --- a/docs/transforms/group.md +++ b/docs/transforms/group.md @@ -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]) ] }) diff --git a/docs/transforms/sort.md b/docs/transforms/sort.md index d015f0ff28..02032dd4fc 100644 --- a/docs/transforms/sort.md +++ b/docs/transforms/sort.md @@ -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 })) ] }) @@ -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. diff --git a/src/channel.d.ts b/src/channel.d.ts index 8694b40f81..5ffad5b4a7 100644 --- a/src/channel.d.ts +++ b/src/channel.d.ts @@ -162,6 +162,9 @@ export type ChannelValueBinSpec = ChannelValue | ({value: ChannelValue} & BinOpt */ export type ChannelValueDenseBinSpec = ChannelValue | ({value: ChannelValue; scale?: Channel["scale"]} & Omit); // 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: @@ -177,7 +180,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 { diff --git a/src/channel.js b/src/channel.js index 8889995368..38edcc3e2a 100644 --- a/src/channel.js +++ b/src/channel.js @@ -82,7 +82,9 @@ 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, order = defaultOrder, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]); // prettier-ignore - order = order === undefined ? y === "width" || y === "height" ? descendingGroup : ascendingGroup : maybeOrder(order); // prettier-ignore + const negate = y?.startsWith("-"); + if (negate) y = y.slice(1); + order = order === undefined ? negate !== (y === "width" || y === "height") ? descendingGroup : ascendingGroup : maybeOrder(order); // prettier-ignore 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}`); diff --git a/src/mark.d.ts b/src/mark.d.ts index 6061501e78..21921fdd79 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -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: diff --git a/src/marks/dot.js b/src/marks/dot.js index 1080e48f68..2d969444c6 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -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 { diff --git a/src/transforms/basic.d.ts b/src/transforms/basic.d.ts index cfc7c988b2..fddc507801 100644 --- a/src/transforms/basic.d.ts +++ b/src/transforms/basic.d.ts @@ -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 diff --git a/src/transforms/basic.js b/src/transforms/basic.js index 3b4fd032a5..573032fa47 100644 --- a/src/transforms/basic.js +++ b/src/transforms/basic.js @@ -108,7 +108,10 @@ function sortData(compare) { function sortValue(value) { let channel, order; - ({channel, value, order = ascendingDefined} = {...maybeValue(value)}); + ({channel, value, order} = {...maybeValue(value)}); + const negate = channel?.startsWith("-"); + if (negate) channel = channel.slice(1); + if (order === undefined) order = negate ? descendingDefined : ascendingDefined; if (typeof order !== "function") { switch (`${order}`.toLowerCase()) { case "ascending": diff --git a/src/transforms/dodge.js b/src/transforms/dodge.js index 3f9b6a267a..23c20bd807 100644 --- a/src/transforms/dodge.js +++ b/src/transforms/dodge.js @@ -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; diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 1550d20e81..cad3675c54 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -81,7 +81,7 @@ function stack(x, y = one, kx, ky, {offset, order, reverse}, options) { const [Y2, setY2] = column(y); Y1.hint = Y2.hint = lengthy; offset = maybeOffset(offset); - order = maybeOrder(order, offset, ky); + order = maybeOrder(order, offset, ky); // TODO shorthand -order with reverse? return [ basic(options, (data, facets, plotOptions) => { const X = x == null ? undefined : setX(maybeApplyInterval(valueof(data, x), plotOptions?.[kx])); diff --git a/test/output/channelDomainMinus.svg b/test/output/channelDomainMinus.svg new file mode 100644 index 0000000000..aeba256354 --- /dev/null +++ b/test/output/channelDomainMinus.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + USA + BRA + GER + AUS + FRA + CHN + GBR + JPN + CAN + ESP + ITA + RUS + NED + POL + ARG + KOR + NZL + UKR + SWE + COL + + + nationality + + + + + + + + + + + 0 + 100 + 200 + 300 + 400 + 500 + + + Frequency → + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/channelDomainMinusReverse.svg b/test/output/channelDomainMinusReverse.svg new file mode 100644 index 0000000000..8ed811bdc9 --- /dev/null +++ b/test/output/channelDomainMinusReverse.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + TUV + SWZ + SOM + NRU + MTN + LBR + GEQ + DMA + CHA + BHU + YEM + TLS + STP + SSD + SOL + MON + LIE + KIR + BRU + BIZ + + + nationality + + + + + + + + + + + 0 + 100 + 200 + 300 + 400 + 500 + + + Frequency → + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/ballot-status-race.ts b/test/plots/ballot-status-race.ts index d546871fc0..689dd22e25 100644 --- a/test/plots/ballot-status-race.ts +++ b/test/plots/ballot-status-race.ts @@ -82,7 +82,7 @@ export async function ballotStatusRace() { sort: { fy: "data", reduce: (data) => data.find((d) => d.status === "ACCEPTED").percent, - reverse: true + order: "descending" } }), Plot.ruleX([0]) diff --git a/test/plots/channel-domain.ts b/test/plots/channel-domain.ts index 31c4ec16d7..2904dfb89c 100644 --- a/test/plots/channel-domain.ts +++ b/test/plots/channel-domain.ts @@ -30,6 +30,14 @@ export async function channelDomainDescendingReverse() { return countNationality({y: "x", order: "descending", reverse: true, limit: 20}); } +export async function channelDomainMinus() { + return countNationality({y: "-x", limit: 20}); +} + +export async function channelDomainMinusReverse() { + return countNationality({y: "-x", reverse: true, limit: 20}); +} + export async function channelDomainComparator() { return countNationality({y: "x", order: ([, a], [, b]) => d3.descending(a, b), limit: 20}); } diff --git a/test/plots/d3-survey-2015.ts b/test/plots/d3-survey-2015.ts index 170d6c61a4..3525910c6c 100644 --- a/test/plots/d3-survey-2015.ts +++ b/test/plots/d3-survey-2015.ts @@ -43,7 +43,7 @@ function bars(groups, title) { y: ([key]) => key, fill: "steelblue", insetTop: 1, - sort: {y: "x", order: "descending"} + sort: {y: "-x"} }), Plot.ruleX([0]) ] diff --git a/test/plots/fruit-sales.ts b/test/plots/fruit-sales.ts index 0c3b0dd085..482ac61b5c 100644 --- a/test/plots/fruit-sales.ts +++ b/test/plots/fruit-sales.ts @@ -5,13 +5,7 @@ export async function fruitSales() { const sales = await d3.csv("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])] }); } diff --git a/test/plots/industry-unemployment-track.ts b/test/plots/industry-unemployment-track.ts index 6221f60832..0baa29c288 100644 --- a/test/plots/industry-unemployment-track.ts +++ b/test/plots/industry-unemployment-track.ts @@ -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( diff --git a/test/plots/learning-poverty.ts b/test/plots/learning-poverty.ts index 9d72ceab17..d3714f6ba7 100644 --- a/test/plots/learning-poverty.ts +++ b/test/plots/learning-poverty.ts @@ -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", order: "descending"} + sort: {y: "-x"} }), Plot.ruleX([0]) ] diff --git a/test/plots/metro-unemployment-ridgeline.ts b/test/plots/metro-unemployment-ridgeline.ts index 4aa85a3b7b..4eef1335be 100644 --- a/test/plots/metro-unemployment-ridgeline.ts +++ b/test/plots/metro-unemployment-ridgeline.ts @@ -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", order: "descending"}}), + Plot.line(data, {x: "date", y: "unemployment", sort: {fy: "-y"}}), Plot.ruleY([0]) ] }); diff --git a/test/plots/movies-profit-by-genre.ts b/test/plots/movies-profit-by-genre.ts index f407be6c3f..f403f9973f 100644 --- a/test/plots/movies-profit-by-genre.ts +++ b/test/plots/movies-profit-by-genre.ts @@ -40,7 +40,7 @@ export async function moviesProfitByGenre() { x: Profit, stroke: "red", strokeWidth: 2, - sort: {y: "x", reverse: true} + sort: {y: "-x"} } ) ) diff --git a/test/plots/movies-rating-by-genre.ts b/test/plots/movies-rating-by-genre.ts index 12040d2d89..4709bb5cb6 100644 --- a/test/plots/movies-rating-by-genre.ts +++ b/test/plots/movies-rating-by-genre.ts @@ -35,9 +35,8 @@ export async function moviesRatingByGenre() { stroke: "Major Genre", r: 2.5, sort: { - fy: "x", - reduce: "median", - order: "descending" + fy: "-x", + reduce: "median" } } ) diff --git a/test/plots/penguin-annotated.ts b/test/plots/penguin-annotated.ts index 3fa24ba587..2b06a872e6 100644 --- a/test/plots/penguin-annotated.ts +++ b/test/plots/penguin-annotated.ts @@ -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, diff --git a/test/plots/us-population-state-age-dots.ts b/test/plots/us-population-state-age-dots.ts index 978ce5d365..4ee923d675 100644 --- a/test/plots/us-population-state-age-dots.ts +++ b/test/plots/us-population-state-age-dots.ts @@ -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"} }) ) ] diff --git a/test/plots/us-population-state-age.ts b/test/plots/us-population-state-age.ts index 501b0a12a9..d9a3b91c5d 100644 --- a/test/plots/us-population-state-age.ts +++ b/test/plots/us-population-state-age.ts @@ -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]) ]