Skip to content

Commit e92bf3e

Browse files
committed
Merge branch 'main' into fil/long-tip
# Conflicts: # test/plots/tip.ts
2 parents 6be54cf + 1c6b239 commit e92bf3e

39 files changed

Lines changed: 2791 additions & 81 deletions

docs/.vitepress/theme/VersionBadge.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
const {version, pr} = defineProps(["version", "pr"]);
44
55
</script>
6+
<style>
7+
8+
.VPBadge {
9+
margin: -2px 0;
10+
}
11+
12+
</style>
613
<template>
714
<Badge v-if="version || pr" :type="version ? `tip` : `warning`">
815
<a :href="version ? `https://github.com/observablehq/plot/releases/tag/v${version}` : `https://github.com/observablehq/plot/pull/${pr}`" :title="version ? `added in v${version}` : `added in #${pr}`" target="_blank" rel="external" style="color: inherit;">

docs/features/marks.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ All marks support the following style options:
492492
* **pointerEvents** - the [pointer events](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events) (*e.g.*, *none*)
493493
* **clip** - whether and how to clip the mark
494494
* **tip** - whether to generate an implicit [pointer](../interactions/pointer.md) [tip](../marks/tip.md) <VersionBadge version="0.6.7" />
495+
* **pool** - whether the [pointer transform](../interactions/pointer.md) is exclusive <VersionBadge pr="2382" />
495496

496497
If the **clip** option<a id="clip" href="#clip" aria-label="Permalink to &quot;clip&quot;"></a> is *frame* (or equivalently true), the mark is clipped to the frame’s dimensions. If the **clip** option is null (or equivalently false), the mark is not clipped. If the **clip** option is *sphere*, the mark will be clipped to the projected sphere (_e.g._, the front hemisphere when using the orthographic projection); a [geographic projection](./projections.md) is required in this case. Lastly if the **clip** option is a GeoJSON object <VersionBadge version="0.6.17" pr="2243" />, the mark will be clipped to the projected geometry.
497498

docs/interactions/pointer.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ The following options control the pointer transform:
178178
- **maxRadius** - the reach, or maximum distance, in pixels; defaults to 40
179179
- **frameAnchor** - how to position the target within the frame; defaults to *middle*
180180

181+
The **pool** mark option <VersionBadge pr="2382" /> determines whether the pointer transform is exclusive across marks. If false, pointer transforms operate independently, potentially allowing multiple marks to be visible simultaneously. If true, pointer transforms will coordinate such that at most one mark will be visible at a time. The **pool** option defaults to true for the [tip mark](../marks/tip.md). Regardless of this option, when faceting, the pointer transform is exclusive across facets.
182+
181183
To resolve the horizontal target position, the pointer transform applies the following order of precedence:
182184

183185
1. the **px** channel, if present;

docs/marks/tip.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ Plot.plot({
4646
})
4747
```
4848

49+
The tip mark defaults the [**pool** option](../interactions/pointer.md#pointer-options) <VersionBadge pr="2382" /> to true, such that if there are multiple tip marks and pointer transforms, at most one tip will be visible at a time. Setting the **pool** option to false allows multiple tips to be visible simultaneously; in this case, beware that tips may collide.
50+
4951
The tip mark can also be used for static annotations, say to draw attention to elements of interest or to add context. The tip text is supplied via the **title** channel. If the tip mark‘s data is an array of strings, the **title** channel defaults to [identity](../features/transforms.md#identity).
5052

5153
:::plot defer https://observablehq.com/@observablehq/plot-static-annotations

src/interactions/pointer.js

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {composeRender} from "../mark.js";
33
import {isArray} from "../options.js";
44
import {applyFrameAnchor} from "../style.js";
55

6-
const states = new WeakMap();
6+
const states = new WeakMap(); // ownerSVGElement → per-plot pointer state
77
const handledEvents = new WeakSet();
88

99
function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) {
@@ -29,8 +29,13 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op
2929

3030
// Isolate state per-pointer, per-plot; if the pointer is reused by
3131
// multiple marks, they will share the same state (e.g., sticky modality).
32+
// The pool maps renderIndex → {ii, ri, render} for marks competing for
33+
// the pointer (e.g., tips); only the closest point is shown.
3234
let state = states.get(svg);
33-
if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: []}));
35+
if (!state) {
36+
state = {sticky: false, roots: [], renders: [], pool: this.pool ? {map: new Map()} : null};
37+
states.set(svg, state);
38+
}
3439

3540
// This serves as a unique identifier of the rendered mark per-plot; it is
3641
// used to record the currently-rendered elements (state.roots) so that we
@@ -53,12 +58,12 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op
5358
// mark (!), since each facet has its own pointer event listeners; we only
5459
// want the closest point across facets to be visible.
5560
const faceted = index.fi != null;
56-
let facetState;
61+
let facetPool;
5762
if (faceted) {
58-
let facetStates = state.facetStates;
59-
if (!facetStates) state.facetStates = facetStates = new Map();
60-
facetState = facetStates.get(this);
61-
if (!facetState) facetStates.set(this, (facetState = new Map()));
63+
let facetPools = state.facetPools;
64+
if (!facetPools) state.facetPools = facetPools = new Map();
65+
facetPool = facetPools.get(this);
66+
if (!facetPool) facetPools.set(this, (facetPool = {map: new Map()}));
6267
}
6368

6469
// The order of precedence for the pointer position is: px & py; the
@@ -72,32 +77,23 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op
7277
let i; // currently focused index
7378
let g; // currently rendered mark
7479
let s; // currently rendered stickiness
75-
let f; // current animation frame
7680

77-
// When faceting, if more than one pointer would be visible, only show
78-
// this one if it is the closest. We defer rendering using an animation
79-
// frame to allow all pointer events to be received before deciding which
80-
// mark to render; although when hiding, we render immediately.
81+
// When pooling or faceting, if more than one pointer would be visible,
82+
// only show the closest. We defer rendering using an animation frame to
83+
// allow all pointer events to be received before deciding which mark to
84+
// render; although when hiding, we render immediately.
85+
const pool = state.pool ?? facetPool;
8186
function update(ii, ri) {
82-
if (faceted) {
83-
if (f) f = cancelAnimationFrame(f);
84-
if (ii == null) facetState.delete(index.fi);
85-
else {
86-
facetState.set(index.fi, ri);
87-
f = requestAnimationFrame(() => {
88-
f = null;
89-
for (const [fi, r] of facetState) {
90-
if (r < ri || (r === ri && fi < index.fi)) {
91-
ii = null;
92-
break;
93-
}
94-
}
95-
render(ii);
96-
});
97-
return;
98-
}
99-
}
100-
render(ii);
87+
if (!pool) return void render(ii);
88+
if (ii == null) render(ii);
89+
pool.map.set(renderIndex, {ii, ri, render});
90+
if (pool.frame !== undefined) cancelAnimationFrame(pool.frame);
91+
pool.frame = requestAnimationFrame(() => {
92+
pool.frame = undefined;
93+
let best = null;
94+
for (const c of pool.map.values()) if (!best || c.ri < best.ri) best = c;
95+
for (const c of pool.map.values()) c.render(c === best ? c.ii : null);
96+
});
10197
}
10298

10399
function render(ii) {
@@ -128,7 +124,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op
128124

129125
// Dispatch the value. When simultaneously exiting this facet and
130126
// entering a new one, prioritize the entering facet.
131-
if (!(i == null && facetState?.size > 1)) {
127+
if (!(i == null && facetPool?.map.size > 1)) {
132128
const value = i == null ? null : isArray(data) ? data[i] : data.get(i);
133129
context.dispatchValue(value);
134130
}

src/legends.js

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import {createContext} from "./context.js";
33
import {legendRamp} from "./legends/ramp.js";
44
import {isSymbolColorLegend, legendSwatches, legendSymbols} from "./legends/swatches.js";
55
import {inherit, isScaleOptions} from "./options.js";
6-
import {getFilterId} from "./style.js";
76
import {normalizeScale} from "./scales.js";
7+
import {getFilterId} from "./style.js";
88

99
const legendRegistry = new Map([
1010
["symbol", legendSymbols],
@@ -53,27 +53,30 @@ function legendColor(color, {legend = true, ...options}) {
5353
case "ramp":
5454
return legendRamp(color, options);
5555
default:
56-
throw new Error(`unknown legend type: ${legend}`);
56+
throw new Error(`unknown color legend type: ${legend}`);
5757
}
5858
}
5959

60-
function legendOpacity({type, interpolate, ...scale}, {legend = true, color = "currentColor", ...options}) {
61-
if (!interpolate) throw new Error(`${type} opacity scales are not supported`);
62-
if (legend === true) legend = "ramp";
63-
if (`${legend}`.toLowerCase() !== "ramp") throw new Error(`${legend} opacity legends are not supported`);
64-
const node = legendColor({type, ...scale, interpolate: interpolateOpacity}, {legend, ...options});
65-
if (!node) return;
66-
const fid = getFilterId();
67-
const svg = select(node);
68-
svg.select("image").attr("filter", `url(#${fid})`);
69-
const filter = svg.append("filter").attr("id", fid);
70-
filter.append("feFlood").attr("flood-color", color);
71-
filter.append("feComposite").attr("in2", "SourceGraphic").attr("operator", "in");
72-
return node;
73-
}
74-
75-
function interpolateOpacity(t) {
76-
return `rgba(0,0,0,${t})`;
60+
function legendOpacity(opacity, {legend = true, color = "currentColor", ...options}) {
61+
if (legend === true) legend = opacity.type === "ordinal" ? "swatches" : "ramp";
62+
opacity = {...opacity, color, key: "opacity"};
63+
switch (`${legend}`.toLowerCase()) {
64+
case "swatches": {
65+
return legendSwatches(opacity, options);
66+
}
67+
case "ramp": {
68+
const legend = legendRamp(opacity, options);
69+
const fid = getFilterId();
70+
const svg = select(legend);
71+
svg.select("image").attr("filter", `url(#${fid})`);
72+
const filter = svg.append("filter").attr("id", fid);
73+
filter.append("feFlood").attr("flood-color", color);
74+
filter.append("feComposite").attr("in2", "SourceGraphic").attr("operator", "in");
75+
return legend;
76+
}
77+
default:
78+
throw new Error(`unknown opacity legend type: ${legend}`);
79+
}
7780
}
7881

7982
export function createLegends(scales, context, options) {

src/legends/ramp.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@ export function legendRamp(color, options) {
9090
const canvas = context.document.createElement("canvas");
9191
canvas.width = n;
9292
canvas.height = 1;
93-
const context2 = canvas.getContext("2d");
93+
const context2 = canvas.getContext("2d", {colorSpace: "display-p3"}); // allow wide gamut
94+
const fillStyle = color.key === "opacity" ? "globalAlpha" : "fillStyle";
9495
for (let i = 0, j = n - 1; i < n; ++i) {
95-
context2.fillStyle = interpolator(i / j);
96+
context2[fillStyle] = interpolator(i / j);
9697
context2.fillRect(i, 0, 1, 1);
9798
}
9899

@@ -120,6 +121,7 @@ export function legendRamp(color, options) {
120121

121122
svg
122123
.append("g")
124+
.attr("fill", color.key === "opacity" ? color.color : null)
123125
.attr("fill-opacity", opacity)
124126
.selectAll()
125127
.data(range)
@@ -129,7 +131,7 @@ export function legendRamp(color, options) {
129131
.attr("y", marginTop)
130132
.attr("width", (d, i) => x(i) - x(i - 1))
131133
.attr("height", height - marginTop - marginBottom)
132-
.attr("fill", (d) => d);
134+
.attr(color.key === "opacity" ? "fill-opacity" : "fill", (d) => d);
133135

134136
ticks = map(thresholds, (_, i) => i);
135137
tickFormat = (i) => thresholdFormat(thresholds[i], i);
@@ -141,6 +143,7 @@ export function legendRamp(color, options) {
141143

142144
svg
143145
.append("g")
146+
.attr("fill", color.key === "opacity" ? color.color : null)
144147
.attr("fill-opacity", opacity)
145148
.selectAll()
146149
.data(domain)
@@ -150,7 +153,7 @@ export function legendRamp(color, options) {
150153
.attr("y", marginTop)
151154
.attr("width", Math.max(0, x.bandwidth() - 1))
152155
.attr("height", height - marginTop - marginBottom)
153-
.attr("fill", scale);
156+
.attr(color.key === "opacity" ? "fill-opacity" : "fill", scale);
154157

155158
tickAdjust = () => {};
156159
}

src/legends/swatches.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ export function legendSwatches(color, {opacity, ...options} = {}) {
2121
.append("svg")
2222
.attr("width", width)
2323
.attr("height", height)
24-
.attr("fill", scale.scale)
24+
.attr("fill", color.key === "opacity" ? color.color : null)
2525
.attr("fill-opacity", maybeNumberChannel(opacity)[1])
26+
.attr(color.key === "opacity" ? "fill-opacity" : "fill", scale.scale)
2627
.append("rect")
2728
.attr("width", "100%")
2829
.attr("height", "100%")

src/mark.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,13 @@ export interface MarkOptions {
292292
/** Whether to generate a tooltip for this mark, and any tip options. */
293293
tip?: boolean | TipPointer | (TipOptions & PointerOptions & {pointer?: TipPointer});
294294

295+
/**
296+
* Whether this mark participates in the pointer pool, which ensures that
297+
* only the closest point is shown when multiple pointer marks are present;
298+
* defaults to true for the tip mark.
299+
*/
300+
pool?: boolean;
301+
295302
/**
296303
* How to clip the mark; one of:
297304
*

src/mark.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class Mark {
2626
clip = defaults?.clip,
2727
channels: extraChannels,
2828
tip,
29+
pool = defaults?.pool,
2930
render
3031
} = options;
3132
this.data = data;
@@ -72,6 +73,7 @@ export class Mark {
7273
this.marginLeft = +marginLeft;
7374
this.clip = maybeClip(clip);
7475
this.tip = maybeTip(tip);
76+
this.pool = !!pool;
7577
this.className = string(className);
7678
// Super-faceting currently disallow position channels; in the future, we
7779
// could allow position to be specified in fx and fy in addition to (or

0 commit comments

Comments
 (0)