Skip to content

packages/core/src/features/viz/lib/hover-card.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 {
2   Group,
3   Rectangle,
4   definePrimitive,
5   resolveSignalValue,
6   resolveBox,
7   resolveLength,
8   type BoxStyle,
9   type PibblNode,
10   type FillStyle,
11   type StrokeStyle,
12   type SignalValue,
13 } from "@pibbl/core";
14 import {
15   pushLayoutBox,
16   setCurrentComponentAfterRender,
17   type SignalStyle,
18 } from "@pibbl/core/internal";
19 import type { HoverPoint } from "./hover-types.js";
20 import {
21   placeHoverCard,
22   type HoverBounds,
23   type HoverPlacement,
24 } from "./hover-placement.js";
25 export type { HoverBounds, HoverPlacement } from "./hover-placement.js";
26 /**
27  * Supported geometry and presentation properties for HoverCard.
28  *
29  * @see {@link BoxStyle}
30  * @see {@link FillStyle}
31  * @see {@link StrokeStyle}
32  * @see {@link HoverCardProps}
33  */
34 export interface HoverCardStyle {
35   /** Horizontal extent in the units of the containing geometry or surface. See {@link BoxStyle}. */
36   readonly width: Exclude<BoxStyle["width"], undefined | "auto">;
37   /** Vertical extent in the units of the containing geometry or surface. See {@link BoxStyle}. */
38   readonly height: Exclude<BoxStyle["height"], undefined | "auto">;
39   /** Insets between the border box and content box. See {@link BoxStyle}. */
40   readonly padding?: BoxStyle["padding"];
41   /** Paint used for the interior. See {@link FillStyle}. */
42   readonly fill?: FillStyle;
43   /** Paint used for the outline. See {@link StrokeStyle}. */
44   readonly stroke?: StrokeStyle;
45   /** Width of the painted outline. See {@link HoverCardStyle}. */
46   readonly strokeWidth?: number;
47   /**
48    * Uniform corner radius used where no per-corner override is supplied. See
49    * {@link HoverCardStyle}.
50    */
51   readonly cornerRadius?: number;
52   /** Opacity of the painted result. See {@link HoverCardStyle}. */
53   readonly opacity?: number;
54   /**
55    * Not accepted in this variant; use the alternative fields instead. See {@link HoverCardStyle}.
56    */
57   readonly filter?: never;
58   /**
59    * Not accepted in this variant; use the alternative fields instead. See {@link HoverCardStyle}.
60    */
61   readonly custom?: never;
62 }
63 /**
64  * Authored inputs for HoverCard, including the declared data and presentation options.
65  *
66  * @see {@link SignalValue}
67  * @see {@link HoverPoint}
68  * @see {@link HoverBounds}
69  * @see {@link HoverPlacement}
70  * @see {@link SignalStyle}
71  * @see {@link HoverCardStyle}
72  * @see {@link PibblNode}
73  * @see {@link HoverCard}
74  */
75 export interface HoverCardProps {
76   /**
77    * Logical point to which the content or result is anchored. See {@link SignalValue},
78    * {@link HoverPoint}.
79    */
80   readonly anchor: SignalValue<HoverPoint>;
81   /** Clearance around the anchor before placing the item. See {@link SignalValue}. */
82   readonly anchorRadius?: SignalValue<number>;
83   /** Spacing between adjacent items. See {@link SignalValue}. */
84   readonly gap?: SignalValue<number>;
85   /**
86    * Logical region constraining the content or query. See {@link SignalValue},
87    * {@link HoverBounds}.
88    */
89   readonly bounds?: SignalValue<HoverBounds>;
90   /**
91    * Preferred placement relative to the anchor or containing geometry. See {@link HoverPlacement}
92    * .
93    */
94   readonly placement?: HoverPlacement;
95   /**
96    * Declared presentation and layout properties. See {@link SignalValue}, {@link SignalStyle},
97    * {@link HoverCardStyle}.
98    */
99   readonly style: SignalValue<SignalStyle<HoverCardStyle>>;
100   /** Descendant content or the callback that supplies it. See {@link PibblNode}. */
101   readonly children?: PibblNode;
102 }
103 type CardInputs = Omit<
104   HoverCardProps,
105   "style" | "anchor" | "anchorRadius" | "gap" | "bounds"
106 > & {
107   anchor: HoverPoint;
108   anchorRadius: number;
109   gap: number;
110   bounds?: HoverBounds;
111 };
112 interface CardResolved {
113   readonly custom?: never;
114   readonly box: ReturnType<typeof resolveBox>;
115   readonly bounds: HoverBounds;
116   readonly fill: FillStyle;
117   readonly stroke: StrokeStyle;
118   readonly strokeWidth: number;
119   readonly cornerRadius: number;
120   readonly opacity: number;
121 }
122 function Content({
123   width,
124   height,
125   children,
126 }: {
127   width: number;
128   height: number;
129   children?: PibblNode;
130 }) {
131   // Establish both allocation and percentage basis for arbitrary composed nodes;
132   // this is a content box, not a layout parent that requires child dimensions.
133   const box = { x: 0, y: 0, width, height };
134   const outer = pushLayoutBox(box);
135   let inner: (() => void) | undefined;
136   try {
137     inner = pushLayoutBox(box);
138     setCurrentComponentAfterRender(() => {
139       inner!();
140       outer();
141     });
142   } catch (error) {
143     inner?.();
144     outer();
145     throw error;
146   }
147   return children;
148 }
149 const Card = definePrimitive<
150   CardInputs,
151   HoverCardStyle,
152   HoverCardStyle,
153   CardResolved
154 >(
155   function renderHoverCard(props, style, context) {
156     context.globalAlpha *= style.opacity;
157     const { borderBox: box, contentBox: content } = style.box;
158     const at = placeHoverCard({
159       anchor: props.anchor,
160       anchorRadius: props.anchorRadius,
161       gap: props.gap,
162       bounds: props.bounds ?? style.bounds,
163       width: box.width,
164       height: box.height,
165       strokeWidth: style.stroke ? style.strokeWidth : 0,
166       placement: props.placement ?? "auto",
167     });
168     return (
169       <Group style={{ translateX: at.x, translateY: at.y }}>
170         <Rectangle
171           pointerEvents="none"
172           style={{
173             width: box.width,
174             height: box.height,
175             fill: style.fill,
176             stroke: style.stroke,
177             strokeWidth: style.strokeWidth,
178             cornerRadius: style.cornerRadius,
179           }}
180         />
181         <Group style={{ translateX: content.x, translateY: content.y }}>
182           <Content width={content.width} height={content.height}>
183             {props.children}
184           </Content>
185         </Group>
186       </Group>
187     );
188   },
189   {
190     resolveStyle(style, context) {
191       const width = resolveLength(
192           style.width,
193           context.allocation.width,
194           "width",
195         ),
196         height = resolveLength(
197           style.height,
198           context.allocation.height,
199           "height",
200         );
201       if (width === undefined || height === undefined)
202         throw new TypeError("HoverCard requires declared width and height.");
203       const box = resolveBox(
204         { width, height, padding: style.padding ?? 10 },
205         { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity },
206         { component: "HoverCard" },
207       );
208       const strokeWidth = style.strokeWidth ?? 1,
209         cornerRadius = style.cornerRadius ?? 7,
210         opacity = style.opacity ?? 1;
211       for (const value of [strokeWidth, cornerRadius, opacity])
212         if (!Number.isFinite(value) || value < 0)
213           throw new RangeError(
214             "HoverCard paint dimensions must be finite and nonnegative.",
215           );
216       if (opacity > 1)
217         throw new RangeError("HoverCard opacity must be in [0,1].");
218       return {
219         box,
220         bounds: {
221           x: 0,
222           y: 0,
223           width: context.allocation.width,
224           height: context.allocation.height,
225         },
226         fill: style.fill ?? "rgba(255,255,255,0.92)",
227         stroke: style.stroke ?? "rgba(98,117,139,0.35)",
228         strokeWidth,
229         cornerRadius,
230         opacity,
231       };
232     },
233   },
234 );
235 const fields = new Set([
236   "width",
237   "height",
238   "padding",
239   "fill",
240   "stroke",
241   "strokeWidth",
242   "cornerRadius",
243   "opacity",
244 ]);
245 /**
246  * Positions a declared card outside a protected anchor, allowing overflow if needed.
247  *
248  * @param props - Anchor, placement, style, and hover-card content. See {@link HoverCardProps}.
249  * @returns Pibbl content placed as a hover card. See {@link PibblNode}.
250  *
251  * @see {@link HoverCardProps}
252  * @see {@link PibblNode}
253  */
254 export function HoverCard(props: HoverCardProps): PibblNode {
255   const style = resolveSignalValue(props.style);
256   if (!style || typeof style !== "object")
257     throw new TypeError("HoverCard requires a style with width and height.");
258   for (const field of Object.keys(style))
259     if (!fields.has(field))
260       throw new TypeError(`HoverCard does not support style.${field}.`);
261   return (
262     <Card
263       anchor={resolveSignalValue(props.anchor)}
264       anchorRadius={resolveSignalValue(props.anchorRadius ?? 0)}
265       gap={resolveSignalValue(props.gap ?? 8)}
266       bounds={
267         props.bounds === undefined
268           ? undefined
269           : resolveSignalValue(props.bounds)
270       }
271       placement={props.placement}
272       style={style}
273     >
274       {props.children}
275     </Card>
276   );
277 }
278 

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.