Skip to content

packages/core/src/features/viz/lib/axis.tsx

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

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 built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.