diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index f1a0381ac8..cdf1e3ecde 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -133,6 +133,7 @@ export default defineConfig({
{text: "Interval", link: "/transforms/interval"},
{text: "Map", link: "/transforms/map"},
{text: "Normalize", link: "/transforms/normalize"},
+ {text: "Repel", link: "/transforms/repel"},
{text: "Select", link: "/transforms/select"},
{text: "Shift", link: "/transforms/shift"},
{text: "Sort", link: "/transforms/sort"},
diff --git a/docs/data/cancer.data.ts b/docs/data/cancer.data.ts
new file mode 100644
index 0000000000..71e78f81a8
--- /dev/null
+++ b/docs/data/cancer.data.ts
@@ -0,0 +1,9 @@
+import fs from "node:fs";
+import {csvParse} from "d3";
+
+export default {
+ watch: ["../public/data/cancer.csv"],
+ load([file]) {
+ return csvParse(fs.readFileSync(file, "utf-8"));
+ }
+};
diff --git a/docs/data/cancer.ts b/docs/data/cancer.ts
new file mode 100644
index 0000000000..c8c2ec7d8b
--- /dev/null
+++ b/docs/data/cancer.ts
@@ -0,0 +1,4 @@
+import {data} from "./cancer.data";
+import {autoType} from "d3";
+
+export default data.map(({...d}) => autoType(d));
diff --git a/docs/public/data/cancer.csv b/docs/public/data/cancer.csv
new file mode 120000
index 0000000000..144cadd83b
--- /dev/null
+++ b/docs/public/data/cancer.csv
@@ -0,0 +1 @@
+../../../test/data/cancer.csv
\ No newline at end of file
diff --git a/docs/transforms/repel.md b/docs/transforms/repel.md
new file mode 100644
index 0000000000..08fde5ef6a
--- /dev/null
+++ b/docs/transforms/repel.md
@@ -0,0 +1,136 @@
+
+
+# Repel transform
+
+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 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
+Plot.plot({
+ width: 400,
+ height: 600,
+ marginRight: 60,
+ marginBottom: 20,
+ x: {
+ axis: "top",
+ domain: ["5 Year", "10 Year", "15 Year", "20 Year"],
+ label: null,
+ padding: 0
+ },
+ y: { axis: null, insetTop: 20 },
+ marks: [
+ Plot.line(cancer, {x: "year", y: "survival", z: "name", strokeWidth: 1}),
+ Plot.text(cancer, Plot.repelY(
+ Plot.group({
+ text:"first"
+ }, {
+ text: "survival",
+ x: "year",
+ y: "survival",
+ textAnchor: "end",
+ dx: 5,
+ fontVariant: "tabular-nums",
+ stroke: "var(--plot-background)",
+ strokeWidth: 7,
+ fill: "currentColor"
+ })
+ )),
+ Plot.text(cancer, Plot.repelY({
+ filter: d => d.year === "20 Year",
+ text: "name",
+ textAnchor: "start",
+ frameAnchor: "right",
+ dx: 10,
+ y: "survival"
+ }))
+ ],
+ caption: "Estimates of survival rate (%), per type of cancer"
+})
+```
+
+Without this transform, some of these labels would otherwise be masking each other. (Note the use of the [group](group.md) transform so that, when several labels share an identical position and text contents, only the first one is retained—and the others filtered out; for example, value 62 in the first column.)
+
+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 repelY transform at each step (horizontal axis). Adjust the range slider below to see how the positions change with the minimum distance option:
+
+
+
+
+
+:::plot
+```js
+Plot.plot({
+ y: {axis: null, inset: 25},
+ color: {type: "categorical"},
+ marks: [
+ Plot.line(points, Plot.repelY(minDistance, {
+ x: "step",
+ stroke: "node",
+ y: "y",
+ curve: "basis",
+ strokeWidth: 1
+ })),
+ Plot.dot(points, Plot.repelY(minDistance, {
+ x: "step",
+ fill: "node",
+ r: (d) => d.step === d.node,
+ y: "y"
+ })),
+ ]
+})
+```
+:::
+
+The repel transform differs from the [dodge transform](./dodge.md) in that it only adjusts the nodes’ existing positions.
+
+The repel transform can be used with any mark that supports **x** and **y** position.
+
+## Repel options
+
+The repel transforms accept the following option:
+
+* **minDistance** — the number of pixels separating the nodes’ positions
+
+## repelY(*repelOptions*, *options*) {#repelY}
+
+```js
+Plot.repelY(minDistance, {x: "date", y: "value"})
+```
+
+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.
+
+## repelX(*repelOptions*, *options*) {#repelX}
+
+```js
+Plot.repelX({x: "value"})
+```
+
+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.
diff --git a/src/index.d.ts b/src/index.d.ts
index a83f0f3715..aacc4a6395 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -53,6 +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/repel.js";
export * from "./transforms/select.js";
export * from "./transforms/shift.js";
export * from "./transforms/stack.js";
diff --git a/src/index.js b/src/index.js
index a95fdbc035..c9d98d66bc 100644
--- a/src/index.js
+++ b/src/index.js
@@ -48,6 +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 {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";
diff --git a/src/transforms/repel.d.ts b/src/transforms/repel.d.ts
new file mode 100644
index 0000000000..01b42be3b7
--- /dev/null
+++ b/src/transforms/repel.d.ts
@@ -0,0 +1,53 @@
+import type {ChannelValueSpec} from "../channel.js";
+import type {Initialized} from "./basic.js";
+
+/** 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 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 repelY transform. */
+export interface RepelYOptions extends RepelOptions {
+ /**
+ * The horizontal position. Nodes sharing the same horizontal position will be
+ * rearranged vertically together.
+ */
+ x?: ChannelValueSpec;
+}
+
+/**
+ * Given an **x** position channel, rearranges the values in such a way that the
+ * horizontal 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 *repelOptions* is a number, it is shorthand for the repel
+ * **minDistance**.
+ */
+export function repelX(options?: T & RepelXOptions): Initialized;
+export function repelX(repelOptions?: RepelXOptions | RepelXOptions["minDistance"], options?: T): Initialized;
+
+/**
+ * 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 *repelOptions* is a number, it is shorthand for the repel
+ * **minDistance**.
+ */
+export function repelY(options?: T & RepelYOptions): Initialized;
+export function repelY(dodgeOptions?: RepelYOptions | RepelYOptions["minDistance"], options?: T): Initialized;
diff --git a/src/transforms/repel.js b/src/transforms/repel.js
new file mode 100644
index 0000000000..2063552a85
--- /dev/null
+++ b/src/transforms/repel.js
@@ -0,0 +1,69 @@
+import {bisector, group} from "d3";
+import {valueof} from "../options.js";
+import {initializer} from "./basic.js";
+
+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 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) {
+ return typeof minDistance === "number" ? {minDistance} : minDistance;
+}
+function mergeOptions({minDistance, ...options}) {
+ return [{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;
+ return initializer(options, function (data, facets, {[k]: channel}, {[sk]: s}) {
+ const {value, scale} = channel ?? {};
+ if (value === undefined) throw new Error(`missing channel ${k}`);
+ const K = value.slice();
+ const H = valueof(data, options[h]);
+ const bisect = bisector((d) => d.lo).left;
+
+ for (const facet of facets) {
+ for (const index of H ? group(facet, (i) => H[i]).values() : [facet]) {
+ const groups = [];
+ for (const i of index) {
+ if (scale === sk) K[i] = s(K[i]);
+ let j = bisect(groups, K[i]);
+ groups.splice(j, 0, {lo: K[i], hi: K[i], items: [i]});
+
+ // Merge overlapping groups.
+ while (
+ groups[j + 1]?.lo < groups[j].hi + minDistance ||
+ (groups[j - 1]?.hi > groups[j].lo - minDistance && (--j, true))
+ ) {
+ const items = groups[j].items.concat(groups[j + 1].items);
+ const mid = (Math.min(groups[j].lo, groups[j + 1].lo) + Math.max(groups[j].hi, groups[j + 1].hi)) / 2;
+ const w = (minDistance * (items.length - 1)) / 2;
+ groups.splice(j, 2, {lo: mid - w, hi: mid + w, items});
+ }
+ }
+
+ // Reposition elements within each group.
+ for (const {lo, hi, items} of groups) {
+ if (items.length > 1) {
+ const dist = (hi - lo) / (items.length - 1);
+ items.sort((i, j) => K[i] - K[j]);
+ let p = lo;
+ for (const i of items) (K[i] = p), (p += dist);
+ }
+ }
+ }
+ }
+
+ return {data, facets, channels: {[k]: {value: K, source: null}}};
+ });
+}
diff --git a/test/data/README.md b/test/data/README.md
index 91eb29abbe..88a8aefc81 100644
--- a/test/data/README.md
+++ b/test/data/README.md
@@ -30,6 +30,10 @@ https://www.bls.gov/
Great Britain aeromagnetic survey
https://www.bgs.ac.uk/datasets/gb-aeromagnetic-survey/
+## cancer.csv
+Edward Tufte, Beautiful Evidence
+https://www.edwardtufte.com/bboard/q-and-a-fetch-msg?msg_id=0003nk
+
## cars.csv
1983 ASA Data Exposition
http://lib.stat.cmu.edu/datasets/
diff --git a/test/data/cancer.csv b/test/data/cancer.csv
new file mode 100644
index 0000000000..71cb9b1516
--- /dev/null
+++ b/test/data/cancer.csv
@@ -0,0 +1,97 @@
+name,year,survival
+Prostate,5 Year,99
+Thyroid,5 Year,96
+Testis,5 Year,95
+Melanomas,5 Year,89
+Breast,5 Year,86
+Hodgkin’s disease,5 Year,85
+"Corpus uteri, uterus",5 Year,84
+"Urinary, bladder",5 Year,82
+"Cervix, uteri",5 Year,71
+Larynx,5 Year,69
+Rectum,5 Year,63
+"Kidney, renal pelvis",5 Year,62
+Colon,5 Year,62
+Non-Hodgkin’s,5 Year,58
+"Oral cavity, pharynx",5 Year,57
+Ovary,5 Year,55
+Leukemia,5 Year,43
+"Brain, nervous system",5 Year,32
+Multiple myeloma,5 Year,30
+Stomach,5 Year,24
+Lung and bronchus,5 Year,15
+Esophagus,5 Year,14
+"Liver, bile duct",5 Year,8
+Pancreas,5 Year,4
+Prostate,10 Year,95
+Thyroid,10 Year,96
+Testis,10 Year,94
+Melanomas,10 Year,87
+Breast,10 Year,78
+Hodgkin’s disease,10 Year,80
+"Corpus uteri, uterus",10 Year,83
+"Urinary, bladder",10 Year,76
+"Cervix, uteri",10 Year,64
+Larynx,10 Year,57
+Rectum,10 Year,55
+"Kidney, renal pelvis",10 Year,54
+Colon,10 Year,55
+Non-Hodgkin’s,10 Year,46
+"Oral cavity, pharynx",10 Year,44
+Ovary,10 Year,49
+Leukemia,10 Year,32
+"Brain, nervous system",10 Year,29
+Multiple myeloma,10 Year,13
+Stomach,10 Year,19
+Lung and bronchus,10 Year,11
+Esophagus,10 Year,8
+"Liver, bile duct",10 Year,6
+Pancreas,10 Year,3
+Prostate,15 Year,87
+Thyroid,15 Year,94
+Testis,15 Year,91
+Melanomas,15 Year,84
+Breast,15 Year,71
+Hodgkin’s disease,15 Year,74
+"Corpus uteri, uterus",15 Year,81
+"Urinary, bladder",15 Year,70
+"Cervix, uteri",15 Year,63
+Larynx,15 Year,46
+Rectum,15 Year,52
+"Kidney, renal pelvis",15 Year,50
+Colon,15 Year,54
+Non-Hodgkin’s,15 Year,38
+"Oral cavity, pharynx",15 Year,38
+Ovary,15 Year,50
+Leukemia,15 Year,30
+"Brain, nervous system",15 Year,28
+Multiple myeloma,15 Year,7
+Stomach,15 Year,19
+Lung and bronchus,15 Year,8
+Esophagus,15 Year,8
+"Liver, bile duct",15 Year,6
+Pancreas,15 Year,3
+Prostate,20 Year,81
+Thyroid,20 Year,95
+Testis,20 Year,88
+Melanomas,20 Year,83
+Breast,20 Year,65
+Hodgkin’s disease,20 Year,67
+"Corpus uteri, uterus",20 Year,79
+"Urinary, bladder",20 Year,68
+"Cervix, uteri",20 Year,60
+Larynx,20 Year,38
+Rectum,20 Year,49
+"Kidney, renal pelvis",20 Year,47
+Colon,20 Year,52
+Non-Hodgkin’s,20 Year,34
+"Oral cavity, pharynx",20 Year,33
+Ovary,20 Year,50
+Leukemia,20 Year,26
+"Brain, nervous system",20 Year,26
+Multiple myeloma,20 Year,5
+Stomach,20 Year,15
+Lung and bronchus,20 Year,6
+Esophagus,20 Year,5
+"Liver, bile duct",20 Year,8
+Pancreas,20 Year,3
\ No newline at end of file
diff --git a/test/output/repelCancer.html b/test/output/repelCancer.html
new file mode 100644
index 0000000000..37f2cf7493
--- /dev/null
+++ b/test/output/repelCancer.html
@@ -0,0 +1,174 @@
+
+ Estimates of survival rate (%), per type of cancer
+
\ No newline at end of file
diff --git a/test/output/repelStocks.html b/test/output/repelStocks.html
new file mode 100644
index 0000000000..b15554a836
--- /dev/null
+++ b/test/output/repelStocks.html
@@ -0,0 +1,163 @@
+
+