Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
occlusion → repel
  • Loading branch information
Fil committed Jun 6, 2025
commit df5062ec85f38972ae0bb640870db08cfd51a511
2 changes: 1 addition & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export default defineConfig({
{text: "Interval", link: "/transforms/interval"},
{text: "Map", link: "/transforms/map"},
{text: "Normalize", link: "/transforms/normalize"},
{text: "Occlusion", link: "/transforms/occlusion"},
{text: "Repel", link: "/transforms/repel"},
{text: "Select", link: "/transforms/select"},
{text: "Shift", link: "/transforms/shift"},
{text: "Sort", link: "/transforms/sort"},
Expand Down
38 changes: 19 additions & 19 deletions docs/transforms/occlusion.md → docs/transforms/repel.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import cancer from "../data/cancer.ts";

const minDistance = ref(11);

const points = (() => {
const points = (() => {
const random = d3.randomLcg(42);
const data = [];
const points = [];
Expand All @@ -21,11 +21,11 @@ const points = (() => {
})();
</script>

# Occlusion transform <VersionBadge pr="1957" />
# Repel transform <VersionBadge pr="1957" />

Given a position dimension (either **x** or **y**), the **occlusion** transform rearranges the values along that dimension in such a way that the distance between nodes is greater than or equal to the minimum distance, and their visual order preserved. The [occlusionX transform](#occlusionX) rearranges the **x** (horizontal) position of each series of nodes sharing a common **y** (vertical) position; likewise the [occlusionY transform](#occlusionY) rearranges nodes vertically.
Given a position dimension (either **x** or **y**), the **repel** transform rearranges the values along that dimension in such a way that the distance between nodes is greater than or equal to the minimum distance, and their visual order preserved. The [repelX transform](#repelX) rearranges the **x** (horizontal) position of each series of nodes sharing a common **y** (vertical) position; likewise the [repelY transform](#repelY) rearranges nodes vertically.

The occlusion transform is commonly used to prevent superposition of labels on line charts. The example below, per [Edward Tufte](https://www.edwardtufte.com/bboard/q-and-a-fetch-msg?msg_id=0003nk), represents estimates of survival rates per type of cancer after 5, 10, 15 and 20 years. Each data point is labelled with its actual value (rounded to the unit). Labels in the last column indicate the type.
The repel transform is commonly used to prevent superposition of labels on line charts. The example below, per [Edward Tufte](https://www.edwardtufte.com/bboard/q-and-a-fetch-msg?msg_id=0003nk), represents estimates of survival rates per type of cancer after 5, 10, 15 and 20 years. Each data point is labelled with its actual value (rounded to the unit). Labels in the last column indicate the type.

:::plot
```js
Expand All @@ -43,7 +43,7 @@ Plot.plot({
y: { axis: null, insetTop: 20 },
marks: [
Plot.line(cancer, {x: "year", y: "survival", z: "name", strokeWidth: 1}),
Plot.text(cancer, Plot.occlusionY(
Plot.text(cancer, Plot.repelY(
Plot.group({
text:"first"
}, {
Expand All @@ -58,7 +58,7 @@ Plot.plot({
fill: "currentColor"
})
)),
Plot.text(cancer, Plot.occlusionY({
Plot.text(cancer, Plot.repelY({
filter: d => d.year === "20 Year",
text: "name",
textAnchor: "start",
Expand All @@ -75,7 +75,7 @@ Without this transform, some of these labels would otherwise be masking each oth

The **minDistance** option is a constant indicating the minimum distance between nodes, in pixels. It defaults to 11, about the height of a line of text with the default font size. (If zero, the transform is not applied.)

The chart below shows how the positions are transformed as we repeatedly inject nodes into a collection at a random vertical position, and apply the occlusionY transform at each step (horizontal axis). Adjust the range slider below to see how the positions change with the minimum distance option:
The chart below shows how the positions are transformed as we repeatedly inject nodes into a collection at a random vertical position, and apply the repelY transform at each step (horizontal axis). Adjust the range slider below to see how the positions change with the minimum distance option:

<p>
<label class="label-input">
Expand All @@ -91,14 +91,14 @@ Plot.plot({
y: {axis: null, inset: 25},
color: {type: "categorical"},
marks: [
Plot.line(points, Plot.occlusionY(minDistance, {
Plot.line(points, Plot.repelY(minDistance, {
x: "step",
stroke: "node",
y: "y",
curve: "basis",
strokeWidth: 1
})),
Plot.dot(points, Plot.occlusionY(minDistance, {
Plot.dot(points, Plot.repelY(minDistance, {
x: "step",
fill: "node",
r: (d) => d.step === d.node,
Expand All @@ -109,28 +109,28 @@ Plot.plot({
```
:::

The occlusion transform differs from the [dodge transform](./dodge.md) in that it only adjusts the nodes’ existing positions.
The repel transform differs from the [dodge transform](./dodge.md) in that it only adjusts the nodes’ existing positions.

The occlusion transform can be used with any mark that supports **x** and **y** position.
The repel transform can be used with any mark that supports **x** and **y** position.

## Occlusion options
## Repel options

The occlusion transforms accept the following option:
The repel transforms accept the following option:

* **minDistance** — the number of pixels separating the nodes’ positions

## occlusionY(*occlusionOptions*, *options*) {#occlusionY}
## repelY(*repelOptions*, *options*) {#repelY}

```js
Plot.occlusionY(minDistance, {x: "date", y: "value"})
Plot.repelY(minDistance, {x: "date", y: "value"})
```

Given marks arranged along the *y* axis, the occlusionY transform adjusts their vertical positions in such a way that two nodes are separated by at least *minDistance* pixels, avoiding overlapping. The order of the nodes is preserved. The *x* position channel, if present, is used to determine series on which the transform is applied, and left unchanged.
Given marks arranged along the *y* axis, the repelY transform adjusts their vertical positions in such a way that two nodes are separated by at least *minDistance* pixels, avoiding overlapping. The order of the nodes is preserved. The *x* position channel, if present, is used to determine series on which the transform is applied, and left unchanged.

## occlusionX(*occlusionOptions*, *options*) {#occlusionX}
## repelX(*repelOptions*, *options*) {#repelX}

```js
Plot.occlusionX({x: "value"})
Plot.repelX({x: "value"})
```

Equivalent to Plot.occlusionY, but arranging the marks horizontally by returning an updated *x* position channel that avoids overlapping. The *y* position channel, if present, is used to determine series and left unchanged.
Equivalent to Plot.repelY, but arranging the marks horizontally by returning an updated *x* position channel that avoids overlapping. The *y* position channel, if present, is used to determine series and left unchanged.
2 changes: 1 addition & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export * from "./transforms/group.js";
export * from "./transforms/hexbin.js";
export * from "./transforms/map.js";
export * from "./transforms/normalize.js";
export * from "./transforms/occlusion.js";
export * from "./transforms/repel.js";
export * from "./transforms/select.js";
export * from "./transforms/shift.js";
export * from "./transforms/stack.js";
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export {find, group, groupX, groupY, groupZ} from "./transforms/group.js";
export {hexbin} from "./transforms/hexbin.js";
export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js";
export {map, mapX, mapY} from "./transforms/map.js";
export {occlusionX, occlusionY} from "./transforms/occlusion.js";
export {repelX, repelY} from "./transforms/repel.js";
export {shiftX, shiftY} from "./transforms/shift.js";
export {window, windowX, windowY} from "./transforms/window.js";
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
Expand Down
30 changes: 12 additions & 18 deletions src/transforms/occlusion.d.ts → src/transforms/repel.d.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import type {ChannelValueSpec} from "../channel.js";
import type {Initialized} from "./basic.js";

/** Options for the occlusion transform. */
export interface OcclusionOptions {
/** Options for the repel transform. */
export interface RepelOptions {
/**
* A constant in pixels describing the minimum distance between two nodes.
* Defaults to 11.
*/
minDistance?: number;
}

/** Options for the occlusionX transform. */
export interface OcclusionXOptions extends OcclusionOptions {
/** Options for the repelX transform. */
export interface RepelXOptions extends RepelOptions {
/**
* The vertical position. Nodes sharing the same vertical position will be
* rearranged horizontally together.
*/
y?: ChannelValueSpec;
}

/** Options for the occlusionY transform. */
export interface OcclusionYOptions extends OcclusionOptions {
/** Options for the repelY transform. */
export interface RepelYOptions extends RepelOptions {
/**
* The horizontal position. Nodes sharing the same horizontal position will be
* rearranged vertically together.
Expand All @@ -34,26 +34,20 @@ export interface OcclusionYOptions extends OcclusionOptions {
* distance, and their visual order preserved. Nodes that share the same
* position and text are fused together.
*
* If *occlusionOptions* is a number, it is shorthand for the occlusion
* If *repelOptions* is a number, it is shorthand for the repel
* **minDistance**.
*/
export function occlusionX<T>(options?: T & OcclusionXOptions): Initialized<T>;
export function occlusionX<T>(
occlusionOptions?: OcclusionXOptions | OcclusionXOptions["minDistance"],
options?: T
): Initialized<T>;
export function repelX<T>(options?: T & RepelXOptions): Initialized<T>;
export function repelX<T>(repelOptions?: RepelXOptions | RepelXOptions["minDistance"], options?: T): Initialized<T>;

/**
* Given a **y** position channel, rearranges the values in such a way that the
* vertical distance between nodes is greater than or equal to the minimum
* distance, and their visual order preserved. Nodes that share the same
* position and text are fused together.
*
* If *occlusionOptions* is a number, it is shorthand for the occlusion
* If *repelOptions* is a number, it is shorthand for the repel
* **minDistance**.
*/
export function occlusionY<T>(options?: T & OcclusionYOptions): Initialized<T>;
export function occlusionY<T>(
dodgeOptions?: OcclusionYOptions | OcclusionYOptions["minDistance"],
options?: T
): Initialized<T>;
export function repelY<T>(options?: T & RepelYOptions): Initialized<T>;
export function repelY<T>(dodgeOptions?: RepelYOptions | RepelYOptions["minDistance"], options?: T): Initialized<T>;
18 changes: 9 additions & 9 deletions src/transforms/occlusion.js → src/transforms/repel.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import {bisector, group} from "d3";
import {valueof} from "../options.js";
import {initializer} from "./basic.js";

export function occlusionX(occlusionOptions = {}, options = {}) {
if (arguments.length === 1) [occlusionOptions, options] = mergeOptions(occlusionOptions);
const {minDistance = 11} = maybeDistance(occlusionOptions);
return occlusion("x", "y", minDistance, options);
export function repelX(repelOptions = {}, options = {}) {
if (arguments.length === 1) [repelOptions, options] = mergeOptions(repelOptions);
const {minDistance = 11} = maybeDistance(repelOptions);
return repel("x", "y", minDistance, options);
}

export function occlusionY(occlusionOptions = {}, options = {}) {
if (arguments.length === 1) [occlusionOptions, options] = mergeOptions(occlusionOptions);
const {minDistance = 11} = maybeDistance(occlusionOptions);
return occlusion("y", "x", minDistance, options);
export function repelY(repelOptions = {}, options = {}) {
if (arguments.length === 1) [repelOptions, options] = mergeOptions(repelOptions);
const {minDistance = 11} = maybeDistance(repelOptions);
return repel("y", "x", minDistance, options);
}

function maybeDistance(minDistance) {
Expand All @@ -21,7 +21,7 @@ function mergeOptions({minDistance, ...options}) {
return [{minDistance}, options];
}

function occlusion(k, h, minDistance, options) {
function repel(k, h, minDistance, options) {
const sk = k[0]; // e.g., the scale for x1 is x
if (typeof minDistance !== "number" || !(minDistance >= 0)) throw new Error(`unsupported minDistance ${minDistance}`);
if (minDistance === 0) return options;
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes
File renamed without changes
2 changes: 1 addition & 1 deletion test/plots/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export * from "./multiplication-table.js";
export * from "./music-revenue.js";
export * from "./nested-facets.js";
export * from "./npm-versions.js";
export * from "./occlusion.js";
export * from "./repel.js";
export * from "./opacity.js";
export * from "./ordinal-bar.js";
export * from "./pairs.js";
Expand Down
24 changes: 12 additions & 12 deletions test/plots/occlusion.ts → test/plots/repel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";

export async function occlusionXPaths() {
export async function repelXPaths() {
const random = d3.randomNormal.source(d3.randomLcg(42))(5, 2);
const data = [];
const points = [];
Expand All @@ -12,10 +12,10 @@ export async function occlusionXPaths() {
return Plot.plot({
x: {domain: [0, 10]},
marks: [
Plot.line(points, Plot.occlusionX(6, {x: "x", z: "e", y: "y", strokeOpacity: 0.3, strokeWidth: 0.5})),
Plot.line(points, Plot.repelX(6, {x: "x", z: "e", y: "y", strokeOpacity: 0.3, strokeWidth: 0.5})),
Plot.dot(
points,
Plot.occlusionX(
Plot.repelX(
{minDistance: 6},
{x: "x", r: 2, fill: "currentColor", fillOpacity: (d) => (d.y === d.e ? 1 : 0), y: "y"}
)
Expand All @@ -24,7 +24,7 @@ export async function occlusionXPaths() {
});
}

export async function occlusionYPaths() {
export async function repelYPaths() {
const random = d3.randomLcg(42);
const data = [];
const points = [];
Expand All @@ -39,8 +39,8 @@ export async function occlusionYPaths() {
y: {inset: 25},
color: {scheme: "Observable10"},
marks: [
Plot.line(points, Plot.occlusionY({x: "x", stroke: "e", y: "y", curve: "basis", strokeWidth: 1})),
Plot.dot(points, Plot.occlusionY({x: "x", fill: "e", r: (d) => d.x === d.e, y: "y"}))
Plot.line(points, Plot.repelY({x: "x", stroke: "e", y: "y", curve: "basis", strokeWidth: 1})),
Plot.dot(points, Plot.repelY({x: "x", fill: "e", r: (d) => d.x === d.e, y: "y"}))
]
});
}
Expand All @@ -50,7 +50,7 @@ async function loadSymbol(name) {
return d3.csv(`data/${name}.csv`, (d) => ({Symbol, ...d3.autoType(d)}));
}

export async function occlusionStocks() {
export async function repelStocks() {
const stocks = (await Promise.all(["aapl", "amzn", "goog", "ibm"].map(loadSymbol))).flat();
return Plot.plot({
insetTop: 4,
Expand All @@ -62,7 +62,7 @@ export async function occlusionStocks() {
Plot.lineY(stocks, {x: "Date", y: "Close", stroke: "Symbol"}),
Plot.text(
stocks,
Plot.occlusionY(
Plot.repelY(
Plot.binX(
{
x: "first",
Expand All @@ -87,7 +87,7 @@ export async function occlusionStocks() {
),
Plot.text(
stocks,
Plot.occlusionY(
Plot.repelY(
Plot.selectMaxX({
dx: 4,
textAnchor: "start",
Expand All @@ -104,7 +104,7 @@ export async function occlusionStocks() {
});
}

export async function occlusionCancer() {
export async function repelCancer() {
const cancer = await d3.csv<any>("data/cancer.csv", d3.autoType);
return Plot.plot({
width: 460,
Expand All @@ -122,7 +122,7 @@ export async function occlusionCancer() {
Plot.line(cancer, {x: "year", y: "survival", z: "name", strokeWidth: 1}),
Plot.text(
cancer,
Plot.occlusionY(
Plot.repelY(
Plot.group(
{
text: "first"
Expand All @@ -144,7 +144,7 @@ export async function occlusionCancer() {
),
Plot.text(
cancer,
Plot.occlusionY({
Plot.repelY({
filter: (d) => d.year === "20 Year",
text: "name",
textAnchor: "start",
Expand Down