diff --git a/docs/features/interactions.md b/docs/features/interactions.md
index 4cd5a27b3a..5c2883b9e0 100644
--- a/docs/features/interactions.md
+++ b/docs/features/interactions.md
@@ -52,6 +52,18 @@ Plot.plot({
These values are displayed atop the axes on the edge of the frame; unlike the tip mark, the crosshair mark will not obscure other marks in the plot.
+When called without data, the crosshair tracks the raw pointer position and inverts the plot's scales directly. This is useful for reading coordinates from any plot, even without matching data.
+
+:::plot defer
+```js
+Plot.plot({
+ x: {type: "utc", domain: [new Date("2010-01-01"), new Date("2025-01-01")]},
+ y: {domain: [0, 100]},
+ marks: [Plot.frame(), Plot.gridX(), Plot.gridY(), Plot.crosshair()]
+})
+```
+:::
+
## Selecting
Support for selecting points within a plot through direct manipulation is under development. If you are interested in this feature, please upvote [#5](https://github.com/observablehq/plot/issues/5). See [#721](https://github.com/observablehq/plot/pull/721) for some early work on brushing.
diff --git a/docs/interactions/crosshair.md b/docs/interactions/crosshair.md
index 8c58bc03f2..40fd3eb88b 100644
--- a/docs/interactions/crosshair.md
+++ b/docs/interactions/crosshair.md
@@ -12,6 +12,7 @@ import penguins from "../data/penguins.ts";
The **crosshair mark** shows the *x* (horizontal↔︎ position) and *y* (vertical↕︎ position) value of the point closest to the [pointer](./pointer.md) on the bottom and left sides of the frame, respectively.
+
:::plot defer https://observablehq.com/@observablehq/plot-crosshair
```js
Plot.plot({
@@ -64,6 +65,46 @@ Plot.plot({
The crosshair mark does not currently support any format options; values are displayed with the default format. If you are interested in this feature, please upvote [#1596](https://github.com/observablehq/plot/issues/1596). In the meantime, you can implement a custom crosshair using the [pointer transform](./pointer.md) and a [text mark](../marks/text.md).
+## Dataless crosshair
+
+When called without data, the crosshair tracks the raw pointer position and inverts the plot's scales. This is useful when you want a crosshair on any plot — even one without data that matches the crosshair's position channels — or when you want to read coordinates directly from the scales.
+
+:::plot defer
+```js
+Plot.plot({
+ x: {type: "utc", domain: [new Date("2010-01-01"), new Date("2025-01-01")]},
+ y: {domain: [0, 100]},
+ marks: [
+ Plot.frame(),
+ Plot.gridX(),
+ Plot.gridY(),
+ Plot.crosshair()
+ ]
+})
+```
+:::
+
+Displayed values are automatically rounded to the optimal precision that distinguishes neighboring pixels.
+
+## Input events
+
+The crosshair dispatches [*input* events](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) when the pointer moves. The plot's value (`plot.value`) is set to an object with **x** and **y** properties (the scale-inverted values), or null when the pointer leaves the frame.
+
+```js
+const crosshair = Plot.crosshair();
+const plot = Plot.plot({
+ x: {domain: [0, 100]},
+ y: {domain: [0, 100]},
+ marks: [crosshair, Plot.frame()]
+});
+
+plot.addEventListener("input", () => {
+ console.log(plot.value); // {x: 42, y: 73} or null
+});
+```
+
+For faceted plots, the value also includes **fx** and **fy** when applicable.
+
## Crosshair options
The following options are supported:
@@ -92,13 +133,21 @@ Plot.crosshair(cars, {x: "economy (mpg)", y: "cylinders"})
Returns a new crosshair for the given *data* and *options*, drawing horizontal and vertical rules. The corresponding **x** and **y** values are also drawn just outside the bottom and left sides of the frame, respectively, typically on top of the axes. If either **x** or **y** is not specified, the crosshair will be one-dimensional.
+## crosshair(*options*) {#crosshair-dataless}
+
+```js
+Plot.crosshair()
+```
+
+When called without data, returns a dataless crosshair that tracks the raw pointer position and inverts the plot's scales. The returned mark has a [move](#crosshair-move) method for programmatic control.
+
## crosshairX(*data*, *options*) {#crosshairX}
```js
Plot.crosshairX(aapl, {x: "Date", y: "Close"})
```
-Like crosshair, but using [pointerX](./pointer.md#pointerX) when *x* is the dominant dimension, like time in a time-series chart.
+Like crosshair, but using [pointerX](./pointer.md#pointerX) when *x* is the dominant dimension, like time in a time-series chart. When called without data, returns a dataless crosshair restricted to the *x* dimension.
## crosshairY(*data*, *options*) {#crosshairY}
@@ -106,4 +155,22 @@ Like crosshair, but using [pointerX](./pointer.md#pointerX) when *x* is the domi
Plot.crosshairY(aapl, {x: "Date", y: "Close"})
```
-Like crosshair, but using [pointerY](./pointer.md#pointerY) when *y* is the dominant dimension.
+Like crosshair, but using [pointerY](./pointer.md#pointerY) when *y* is the dominant dimension. When called without data, returns a dataless crosshair restricted to the *y* dimension.
+
+## *crosshair*.move(*value*) {#crosshair-move}
+
+```js
+crosshair.move({x: new Date("2020-06-01"), y: 42})
+```
+
+Programmatically sets the crosshair position in data space. Pass an object with **x** and/or **y** values to show the crosshair at that position, or null to hide it. The plot dispatches an *input* event, just as if the user had moved the pointer.
+
+```js
+crosshair.move(null) // hide
+```
+
+For faceted plots, include **fx** or **fy** to target a specific facet:
+
+```js
+crosshair.move({x: 45, y: 17, fx: "Chinstrap"})
+```
diff --git a/src/marks/crosshair.d.ts b/src/marks/crosshair.d.ts
index 86e39d7ee0..7f49da1be4 100644
--- a/src/marks/crosshair.d.ts
+++ b/src/marks/crosshair.d.ts
@@ -1,5 +1,5 @@
import type {ChannelValueSpec} from "../channel.js";
-import type {CompoundMark, Data, MarkOptions} from "../mark.js";
+import type {CompoundMark, Data, MarkOptions, RenderableMark} from "../mark.js";
/** Options for the crosshair mark. */
export interface CrosshairOptions extends MarkOptions {
@@ -54,6 +54,20 @@ export interface CrosshairOptions extends MarkOptions {
textStrokeWidth?: MarkOptions["strokeWidth"];
}
+/**
+ * A dataless crosshair mark that tracks the pointer position and displays
+ * scale-inverted values. Unlike the data-driven crosshair, this does not
+ * require data — it works directly with the plot's scales.
+ */
+export class Crosshair extends RenderableMark {
+ /**
+ * Programmatically sets the crosshair position in data space. Pass an object
+ * with **x** and/or **y** values (and **fx**, **fy** for faceted plots) to
+ * show the crosshair, or null to hide it.
+ */
+ move(value: {x?: any; y?: any; fx?: any; fy?: any} | null): void;
+}
+
/**
* Returns a new crosshair mark for the given *data* and *options*, drawing
* horizontal and vertical rules centered at the point closest to the pointer.
@@ -61,8 +75,13 @@ export interface CrosshairOptions extends MarkOptions {
* bottom and left sides of the frame, respectively, typically on top of the
* axes. If either **x** or **y** is not specified, the crosshair will be
* one-dimensional.
+ *
+ * If called without data, returns a dataless crosshair that tracks the raw
+ * pointer position, inverting the scales with pixel-level precision. The
+ * returned mark has a **.move**() method for programmatic control.
*/
-export function crosshair(data?: Data, options?: CrosshairOptions): CompoundMark;
+export function crosshair(data: Data, options?: CrosshairOptions): CompoundMark;
+export function crosshair(options?: CrosshairOptions): Crosshair;
/**
* Like crosshair, but uses the pointerX transform: the determination of the
@@ -71,7 +90,8 @@ export function crosshair(data?: Data, options?: CrosshairOptions): CompoundMark
* as time in a time-series chart, or the aggregated dimension when grouping or
* binning.
*/
-export function crosshairX(data?: Data, options?: CrosshairOptions): CompoundMark;
+export function crosshairX(data: Data, options?: CrosshairOptions): CompoundMark;
+export function crosshairX(options?: CrosshairOptions): Crosshair;
/**
* Like crosshair, but uses the pointerY transform: the determination of the
@@ -80,4 +100,5 @@ export function crosshairX(data?: Data, options?: CrosshairOptions): CompoundMar
* as time in a time-series chart, or the aggregated dimension when grouping or
* binning.
*/
-export function crosshairY(data?: Data, options?: CrosshairOptions): CompoundMark;
+export function crosshairY(data: Data, options?: CrosshairOptions): CompoundMark;
+export function crosshairY(options?: CrosshairOptions): Crosshair;
diff --git a/src/marks/crosshair.js b/src/marks/crosshair.js
index 0f155cb81f..65551b377e 100644
--- a/src/marks/crosshair.js
+++ b/src/marks/crosshair.js
@@ -1,19 +1,50 @@
+import {create, pointer as d3pointer, select} from "d3";
import {getSource} from "../channel.js";
+import {formatIsoDate} from "../format.js";
import {pointer, pointerX, pointerY} from "../interactions/pointer.js";
-import {marks} from "../mark.js";
+import {Mark, marks} from "../mark.js";
+import {isIterable, keyword} from "../options.js";
+import {pixelRound} from "../precision.js";
import {initializer} from "../transforms/basic.js";
import {ruleX, ruleY} from "./rule.js";
import {text} from "./text.js";
+// Returns a function (px) → {value, px} that inverts a pixel position to a
+// domain value and a snapped pixel position. For continuous scales, the value
+// is precision-rounded; for band/point scales, the value is the nearest domain
+// element and the pixel snaps to the band center.
+function scaleInvert(scale) {
+ if (scale.bandwidth) {
+ const domain = scale.domain();
+ const step = scale.step();
+ const offset = scale.bandwidth() / 2;
+ const start = scale(domain[0]) + 0.5;
+ return (px) => {
+ const i = Math.max(0, Math.min(domain.length - 1, Math.round((px - start - offset) / step)));
+ const value = domain[i];
+ return {value, px: scale(value) + offset};
+ };
+ }
+ if (!scale.invert) return null;
+ const round = pixelRound(scale);
+ return (px) => {
+ const value = round(scale.invert(px));
+ return {value, px: scale(value)};
+ };
+}
+
export function crosshair(data, options) {
+ if (arguments.length < 2 && !isIterable(data)) return new Crosshair(data);
return crosshairK(pointer, data, options);
}
-export function crosshairX(data, options = {}) {
+export function crosshairX(data, options) {
+ if (arguments.length < 2 && !isIterable(data)) return new Crosshair({...data, dimension: "x"});
return crosshairK(pointerX, data, options);
}
-export function crosshairY(data, options = {}) {
+export function crosshairY(data, options) {
+ if (arguments.length < 2 && !isIterable(data)) return new Crosshair({...data, dimension: "y"});
return crosshairK(pointerY, data, options);
}
@@ -26,7 +57,7 @@ function crosshairK(pointer, data, options = {}) {
if (x != null) M.push(text(data, textOptions("x", {...p, dy: 9, frameAnchor: "bottom", lineAnchor: "top"}, options)));
if (y != null) M.push(text(data, textOptions("y", {...p, dx: -9, frameAnchor: "left", textAnchor: "end"}, options)));
for (const m of M) m.ariaLabel = `crosshair ${m.ariaLabel}`;
- return marks(...M);
+ return marks(...M); // TODO: add .move() (shared with Crosshair class)
}
function markOptions(
@@ -107,3 +138,178 @@ function textChannel(source, options) {
return {channels: {text: {value: getSource(channels, source)?.value}}};
});
}
+
+export class Crosshair extends Mark {
+ constructor({dimension = "xy", color = "currentColor", opacity = 0.2, ...options} = {}) {
+ super(undefined, {}, options, {});
+ this._dimension = keyword(dimension, "dimension", ["x", "y", "xy"]);
+ this._color = color;
+ this._opacity = opacity;
+ this._states = [];
+ }
+ render(index, scales, values, dimensions, context) {
+ const {x, y, fx, fy} = scales;
+ const dim = this._dimension;
+ const {marginLeft, marginTop, width, height, marginRight, marginBottom} = dimensions;
+ const right = width - marginRight;
+ const bottom = height - marginBottom;
+ const labelOffset = 9; // default axis tickSize + tickPadding
+
+ // Setup once (first facet); for dataless marks index is always null,
+ // so we check _states.length instead of index.fi.
+ if (!this._states.length) {
+ this._invertX = dim !== "y" && x && !context.projection ? scaleInvert(x) : null;
+ this._invertY = dim !== "x" && y && !context.projection ? scaleInvert(y) : null;
+ this._formatX = this._invertX && (x.type === "utc" || x.type === "time" ? formatIsoDate : String);
+ this._formatY = this._invertY && (y.type === "utc" || y.type === "time" ? formatIsoDate : String);
+ if (this._invertX) this._scaleX = x;
+ if (this._invertY) this._scaleY = y;
+ this._dispatch = context.dispatchValue;
+ this._fx = fx;
+ this._fy = fy;
+ context.dispatchValue(null);
+ }
+
+ const {_invertX: invertX, _invertY: invertY, _formatX: formatX, _formatY: formatY} = this;
+
+ const g = create("svg:g").attr("aria-label", "crosshair").attr("pointer-events", "none");
+
+ // Transparent rect for pointer capture
+ g.append("rect")
+ .attr("fill", "none")
+ .attr("pointer-events", "all")
+ .attr("x", marginLeft)
+ .attr("y", marginTop)
+ .attr("width", right - marginLeft)
+ .attr("height", bottom - marginTop);
+
+ // Vertical rule (for x)
+ const ruleXEl = invertX
+ ? g
+ .append("line")
+ .attr("stroke", this._color)
+ .attr("stroke-opacity", this._opacity)
+ .attr("y1", marginTop)
+ .attr("y2", bottom)
+ .attr("display", "none")
+ : null;
+
+ // Horizontal rule (for y)
+ const ruleYEl = invertY
+ ? g
+ .append("line")
+ .attr("stroke", this._color)
+ .attr("stroke-opacity", this._opacity)
+ .attr("x1", marginLeft)
+ .attr("x2", right)
+ .attr("display", "none")
+ : null;
+
+ // Text label for x (at bottom)
+ const textXEl = invertX
+ ? g
+ .append("text")
+ .attr("fill", this._color)
+ .attr("stroke", "var(--plot-background)")
+ .attr("stroke-width", 5)
+ .attr("stroke-linejoin", "round")
+ .attr("paint-order", "stroke")
+ .attr("text-anchor", "middle")
+ .attr("font-variant", "tabular-nums")
+ .attr("dy", "0.71em")
+ .attr("y", bottom + labelOffset + 0.5)
+ .attr("display", "none")
+ : null;
+
+ // Text label for y (at left)
+ const textYEl = invertY
+ ? g
+ .append("text")
+ .attr("fill", this._color)
+ .attr("stroke", "var(--plot-background)")
+ .attr("stroke-width", 5)
+ .attr("stroke-linejoin", "round")
+ .attr("paint-order", "stroke")
+ .attr("text-anchor", "end")
+ .attr("font-variant", "tabular-nums")
+ .attr("dy", "0.32em")
+ .attr("x", marginLeft - labelOffset + 0.5)
+ .attr("display", "none")
+ : null;
+
+ const node = g.node();
+ const self = this;
+ const stateIndex = this._states.length;
+
+ const show = (px, py) => {
+ if (ruleXEl) {
+ const ix = invertX(px);
+ ruleXEl.attr("x1", ix.px).attr("x2", ix.px).attr("display", null);
+ textXEl.attr("x", ix.px).attr("display", null).text(formatX(ix.value));
+ }
+ if (ruleYEl) {
+ const iy = invertY(py);
+ ruleYEl.attr("y1", iy.px).attr("y2", iy.px).attr("display", null);
+ textYEl.attr("y", iy.px).attr("display", null).text(formatY(iy.value));
+ }
+ };
+
+ const hide = () => {
+ ruleXEl?.attr("display", "none");
+ ruleYEl?.attr("display", "none");
+ textXEl?.attr("display", "none");
+ textYEl?.attr("display", "none");
+ };
+
+ this._states.push({node, show, hide});
+
+ select(node)
+ .select("rect")
+ .on("pointermove", function (event) {
+ const [px, py] = d3pointer(event, node);
+ for (let i = 0; i < self._states.length; i++) {
+ if (i !== stateIndex) self._states[i].hide();
+ }
+ show(px, py);
+ const facet = node.__data__;
+ self._dispatch?.({
+ ...(invertX && {x: invertX(px).value}),
+ ...(invertY && {y: invertY(py).value}),
+ ...(fx && facet && {fx: facet.x}),
+ ...(fy && facet && {fy: facet.y})
+ });
+ })
+ .on("pointerleave", function () {
+ hide();
+ self._dispatch?.(null);
+ });
+
+ return node;
+ }
+ move(value) {
+ if (value == null) {
+ for (const state of this._states) state.hide();
+ this._dispatch?.(null);
+ return;
+ }
+ const {x: vx, y: vy, fx, fy} = value;
+ const sx = this._scaleX;
+ const sy = this._scaleY;
+ const px = vx != null && sx ? sx(vx) + (sx.bandwidth ? sx.bandwidth() / 2 : 0) : undefined;
+ const py = vy != null && sy ? sy(vy) + (sy.bandwidth ? sy.bandwidth() / 2 : 0) : undefined;
+ const state = this._states.find(
+ (s) => (fx === undefined || s.node.__data__?.x === fx) && (fy === undefined || s.node.__data__?.y === fy)
+ );
+ if (!state) return;
+ for (const s of this._states) {
+ if (s !== state) s.hide();
+ }
+ state.show(px, py);
+ this._dispatch?.({
+ ...(this._invertX && vx != null && {x: vx}),
+ ...(this._invertY && vy != null && {y: vy}),
+ ...(this._fx && fx !== undefined && {fx}),
+ ...(this._fy && fy !== undefined && {fy})
+ });
+ }
+}
diff --git a/test/output/crosshairDataless.html b/test/output/crosshairDataless.html
new file mode 100644
index 0000000000..ad5ac316f3
--- /dev/null
+++ b/test/output/crosshairDataless.html
@@ -0,0 +1,103 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/crosshairDatalessFacet.html b/test/output/crosshairDatalessFacet.html
new file mode 100644
index 0000000000..aa5beac3b4
--- /dev/null
+++ b/test/output/crosshairDatalessFacet.html
@@ -0,0 +1,514 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/crosshairDatalessLog.html b/test/output/crosshairDatalessLog.html
new file mode 100644
index 0000000000..1c0dc124f6
--- /dev/null
+++ b/test/output/crosshairDatalessLog.html
@@ -0,0 +1,221 @@
+
+
+
\ No newline at end of file
diff --git a/test/output/crosshairDatalessOrdinal.html b/test/output/crosshairDatalessOrdinal.html
new file mode 100644
index 0000000000..2b7206a1af
--- /dev/null
+++ b/test/output/crosshairDatalessOrdinal.html
@@ -0,0 +1,117 @@
+
+
+
\ No newline at end of file
diff --git a/test/plots/crosshair.ts b/test/plots/crosshair.ts
index 7b17e29109..6f11d14e8d 100644
--- a/test/plots/crosshair.ts
+++ b/test/plots/crosshair.ts
@@ -1,5 +1,6 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
+import {html} from "htl";
export async function crosshairDodge() {
const penguins = await d3.csv("data/penguins.csv", d3.autoType);
@@ -72,3 +73,88 @@ export async function crosshairContinuousX() {
]
});
}
+
+function formatValue(v: any) {
+ if (v == null) return String(v);
+ return (
+ "{\n" +
+ Object.entries(v)
+ .map(([k, d]) => ` ${k}: ${d instanceof Date ? d.toISOString() : JSON.stringify(d)}`)
+ .join(",\n") +
+ "\n}"
+ );
+}
+
+export async function crosshairDataless() {
+ const crosshair = Plot.crosshair();
+ const plot = crosshair.plot({
+ x: {type: "utc", domain: [new Date("2010-01-01"), new Date("2025-01-01")]},
+ y: {domain: [0, 100]},
+ marks: [Plot.frame(), Plot.gridX({tickSpacing: 25}), Plot.gridY({tickSpacing: 25})]
+ });
+ const textarea = html`