packages/core/src/features/viz/lib/axis.tsx
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import { Path, Text, resolveSignalValue, useCanvasContext } from "@pibbl/core";
2 import type { AxisProps } from "./types.js";
3 import { lookupScale } from "./scale-context.js";
4 import { width, opacity } from "./paint.js";
5
6 import { fitAxisLabel } from "./axis-labels.js";
7
8 import { ticks, position } from "./scale-ticks.js";
9
10 /**
11 * Draws ticks and labels using a named scale and the configured orientation and style.
12 *
13 * @param props - Scale, orientation, tick, label, and styling options. See {@link AxisProps}.
14 * @returns Pibbl nodes drawing the axis.
15 *
16 * @see {@link AxisProps}
17 */
18 export function Axis(props: AxisProps) {
19 if (
20 props.position !== "bottom" &&
21 props.position !== "left" &&
22 props.position !== "top" &&
23 props.position !== "right"
24 )
25 throw new TypeError("Axis position must be bottom, left, top, or right.");
26 const source = lookupScale(props.scale, "Axis");
27 if (!source) throw new TypeError("Axis requires a scale ID.");
28 const scale = source.get();
29 const style = resolveSignalValue(props.style) ?? {};
30 const stroke = resolveSignalValue(style.stroke) ?? "#64748b";
31 const strokeWidth = width(resolveSignalValue(style.strokeWidth) ?? 1, "Axis");
32 const fill = resolveSignalValue(style.fill) ?? "#64748b";
33 const font = resolveSignalValue(style.font) ?? "12px sans-serif";
34 const alpha = opacity(resolveSignalValue(style.opacity) ?? 1, "Axis");
35 const horizontal = props.position === "bottom" || props.position === "top";
36 const outward = props.position === "bottom" || props.position === "right" ? 1 : -1;
37 const baseline = horizontal
38 ? props.position === "bottom" ? scale.frame.height : 0
39 : props.position === "right" ? scale.frame.width : 0;
40 const selected = ticks(scale, props);
41 const context = useCanvasContext();
42 const overlap = resolveSignalValue(props.labelOverlap ?? "allow");
43 const gap = resolveSignalValue(props.labelGap ?? 8);
44 if (overlap !== "allow" && overlap !== "skip") throw new TypeError("Axis labelOverlap must be allow or skip.");
45 if (!Number.isFinite(gap) || gap < 0) throw new RangeError("Axis labelGap must be finite and nonnegative.");
46 const overflow = resolveSignalValue(props.labelOverflow ?? "allow");
47 const maxWidth = props.labelMaxWidth === undefined ? undefined : resolveSignalValue(props.labelMaxWidth);
48 const maxLines = resolveSignalValue(props.labelMaxLines ?? 2);
49 const lineHeightInput = props.labelLineHeight === undefined ? undefined : resolveSignalValue(props.labelLineHeight);
50 if (!["allow", "wrap", "truncate"].includes(overflow)) throw new TypeError("Axis labelOverflow must be allow, wrap, or truncate.");
51 if ((overflow !== "allow" && maxWidth === undefined) || (maxWidth !== undefined && (!Number.isFinite(maxWidth) || maxWidth <= 0))) throw new RangeError("Axis labelMaxWidth must be positive and finite for wrap/truncate.");
52 if (!Number.isInteger(maxLines) || maxLines < 1 || maxLines > 20) throw new RangeError("Axis labelMaxLines must be an integer from 1 to 20.");
53 if (lineHeightInput !== undefined && (!Number.isFinite(lineHeightInput) || lineHeightInput <= 0)) throw new RangeError("Axis labelLineHeight must be positive and finite.");
54 let lineHeight = 0;
55 const lines: (readonly string[])[] = [];
56 const occupied: { start: number; end: number }[] = [];
57 const visible = new Set<number>();
58 const texts = selected.map((value, index) => {
59 const label = props.tickFormat ? props.tickFormat(value, index)
60 : scale.type === "utc" ? scale.formatTick(value as number)
61 : typeof value === "number" ? String(Number(value.toPrecision(6))) : value;
62 if (typeof label !== "string") throw new TypeError("Axis tickFormat must return a string.");
63 return label;
64 });
65 // Endpoints have priority; sort by physical position for reversed ranges too.
66 const order = selected.map((value, index) => ({ index, p: position(scale, value) })).sort((a, b) => a.p - b.p);
67 const priority = order.length > 1 ? [order[0], order[order.length - 1], ...order.slice(1, -1)] : order;
68 context.save();
69 try {
70 context.font = font;
71 const fontMetrics = context.measureText("Mg");
72 lineHeight = lineHeightInput ?? Math.ceil(fontMetrics.actualBoundingBoxAscent + fontMetrics.actualBoundingBoxDescent + 4);
73 for (const text of texts) lines.push(fitAxisLabel(context, text, maxWidth ?? Infinity, overflow, maxLines));
74 for (const { index, p } of priority) {
75 const metrics = lines[index].map(text => context.measureText(text));
76 const size = horizontal ? Math.max(...metrics.map(m => m.width)) : (lines[index].length - 1) * lineHeight + Math.max(...metrics.map(m => m.actualBoundingBoxAscent + m.actualBoundingBoxDescent));
77 const interval = { start: p - size / 2, end: p + size / 2 };
78 if (overlap === "allow" || occupied.every(other => interval.end + gap <= other.start || interval.start >= other.end + gap)) {
79 visible.add(index); occupied.push(interval);
80 }
81 }
82 } finally { context.restore(); }
83 const path = new Path2D();
84 if (horizontal) {
85 path.moveTo(scale.range[0], baseline);
86 path.lineTo(scale.range[1], baseline);
87 } else {
88 path.moveTo(baseline, scale.range[0]);
89 path.lineTo(baseline, scale.range[1]);
90 }
91 const labels = selected.map((value, index) => {
92 const p = position(scale, value);
93 if (horizontal) {
94 path.moveTo(p, baseline);
95 path.lineTo(p, baseline + outward * 6);
96 } else {
97 path.moveTo(baseline, p);
98 path.lineTo(baseline + outward * 6, p);
99 }
100 if (!visible.has(index)) return null;
101 return lines[index].map((label, lineIndex) => (
102 <Text
103 key={`${index}:${lineIndex}`}
104 pointerEvents="none"
105 style={{
106 left: horizontal ? p : baseline + outward * 10,
107 top: horizontal ? baseline + outward * 10 + (outward > 0 ? lineIndex : lineIndex - lines[index].length + 1) * lineHeight : p + (lineIndex - (lines[index].length - 1) / 2) * lineHeight,
108 fill,
109 font,
110 opacity: alpha,
111 textAlign: horizontal ? "center" : outward < 0 ? "right" : "left",
112 textBaseline: horizontal ? outward > 0 ? "top" : "bottom" : "middle",
113 }}
114 >
115 {label}
116 </Text>
117 ));
118 });
119 return [
120 <Path
121 key="axis"
122 pointerEvents="none"
123 style={{ d: path, stroke, strokeWidth, opacity: alpha }}
124 />,
125 labels,
126 ];
127 }
128
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.