Skip to content

occlusionY initializer #1957

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,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: "Select", link: "/transforms/select"},
{text: "Shift", link: "/transforms/shift"},
{text: "Sort", link: "/transforms/sort"},
Expand Down
9 changes: 9 additions & 0 deletions docs/data/cancer.data.ts
Original file line number Diff line number Diff line change
@@ -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"));
}
};
4 changes: 4 additions & 0 deletions docs/data/cancer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {data} from "./cancer.data";
import {autoType} from "d3";

export default data.map(({...d}) => autoType(d));
1 change: 1 addition & 0 deletions docs/public/data/cancer.csv
136 changes: 136 additions & 0 deletions docs/transforms/occlusion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<script setup>

import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {ref} from "vue";
import cancer from "../data/cancer.ts";

const minDistance = ref(11);

const points = (() => {
const random = d3.randomLcg(42);
const data = [];
const points = [];
let step;
for (step = 0; step < 31; ++step) {
data.push(random());
points.push(...data.map((y, node) => ({step, y, node})));
}
points.push(...data.map((y, node) => ({step, y, node})));
return points;
})();
</script>

# Occlusion 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.

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.

:::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.occlusionY(
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.occlusionY({
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 occlusionY 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">
minDistance:
<input type="range" v-model.number="minDistance" min="0" max="30" step="0.1">
<span style="font-variant-numeric: tabular-nums;">{{minDistance.toLocaleString("en-US")}}</span>
</label>
</p>

:::plot
```js
Plot.plot({
y: {axis: null, inset: 25},
color: {type: "categorical"},
marks: [
Plot.line(points, Plot.occlusionY(minDistance, {
x: "step",
stroke: "node",
y: "y",
curve: "basis",
strokeWidth: 1
})),
Plot.dot(points, Plot.occlusionY(minDistance, {
x: "step",
fill: "node",
r: (d) => d.step === d.node,
y: "y"
})),
]
})
```
:::

The occlusion 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.

## Occlusion options

The occlusion transforms accept the following option:

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

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

```js
Plot.occlusionY(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.

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

```js
Plot.occlusionX({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.
1 change: 1 addition & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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/select.js";
export * from "./transforms/shift.js";
export * from "./transforms/stack.js";
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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 {shiftX} 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
59 changes: 59 additions & 0 deletions src/transforms/occlusion.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type {ChannelValueSpec} from "../channel.js";
import type {Initialized} from "./basic.js";

/** Options for the occlusion transform. */
export interface OcclusionOptions {
/**
* 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 {
/**
* 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 {
/**
* 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 *occlusionOptions* is a number, it is shorthand for the occlusion
* **minDistance**.
*/
export function occlusionX<T>(options?: T & OcclusionXOptions): Initialized<T>;
export function occlusionX<T>(
occlusionOptions?: OcclusionXOptions | OcclusionXOptions["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
* **minDistance**.
*/
export function occlusionY<T>(options?: T & OcclusionYOptions): Initialized<T>;
export function occlusionY<T>(
dodgeOptions?: OcclusionYOptions | OcclusionYOptions["minDistance"],
options?: T
): Initialized<T>;
69 changes: 69 additions & 0 deletions src/transforms/occlusion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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 occlusionY(occlusionOptions = {}, options = {}) {
if (arguments.length === 1) [occlusionOptions, options] = mergeOptions(occlusionOptions);
const {minDistance = 11} = maybeDistance(occlusionOptions);
return occlusion("y", "x", minDistance, options);
}

function maybeDistance(minDistance) {
return typeof minDistance === "number" ? {minDistance} : minDistance;
}
function mergeOptions({minDistance, ...options}) {
return [{minDistance}, options];
}

function occlusion(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}}};
});
}
4 changes: 4 additions & 0 deletions test/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Loading