Skip to content

packages/core/src/features/viz/lib/interval-diamond-series.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   Path,
3   resolveSignalValue,
4   type FillStyle,
5   type PibblNode,
6   type SignalValue,
7   type StrokeStyle,
8 } from "@pibbl/core";
9 import { withHooksForbidden, type SignalStyle } from "@pibbl/core/internal";
10 import type { IntervalSeriesProps } from "./interval-series.js";
11 import type { BandCategory } from "./types.js";
12 import { lookupScale } from "./scale-context.js";
13 import { useContinuousScale } from "./use-continuous-scale.js";
14 import { finiteCoordinate, pointAccessor, pointData } from "./point-data.js";
15 import { opacity, width } from "./paint.js";
16 
17 /** Local-pixel height and shared paint for supplied summary diamonds.
18  * @see {@link IntervalDiamondSeries}
19  */
20 export interface IntervalDiamondSeriesStyle {
21   /** Full perpendicular height in local pixels; defaults to 12. Zero hides marks. */
22   readonly height?: number;
23   /** Diamond interior paint; defaults to #087f8c. */
24   readonly fill?: FillStyle;
25   /** Diamond outline paint; defaults to #087f8c. */
26   readonly stroke?: StrokeStyle;
27   /** Visible outline width in local pixels; defaults to 1.5. */
28   readonly strokeWidth?: number;
29   /** Outline targeting width in local pixels; defaults to strokeWidth. */
30   readonly lineInteractionWidth?: number;
31   /** Shared opacity from zero to one; defaults to 1. */
32   readonly opacity?: number;
33   /** Cursor shown over painted diamonds. */
34   readonly cursor?: string;
35 }
36 
37 /** Supplied summary estimates and bounds placed horizontally at categorical centers.
38  * @see {@link IntervalSeriesProps}
39  * @see {@link IntervalDiamondSeriesStyle}
40  */
41 export interface IntervalDiamondSeriesProps<D> extends Omit<
42   IntervalSeriesProps<D>,
43   "style"
44 > {
45   /** Whole-object and shallow field signals are observed; per-datum styling is not supported. */
46   readonly style?: SignalValue<SignalStyle<IntervalDiamondSeriesStyle>>;
47 }
48 
49 /**
50  * Draw supplied summary estimates as horizontal diamonds; no pooling or inference.
51  * Left/right tips map lower/upper, top/bottom map the estimate. Estimates must
52  * lie inside the supplied bounds. Missing/nonfinite rows and unknown categories
53  * are omitted; reversed bounds and invalid log values reject included rows.
54  * Diamonds share one ordinary Path target; handlers receive ordinary events.
55  * Mapping does not clamp or clip. Compose a Clip when required.
56  *
57  * @param props - Supplied rows, accessors, categorical/value scales, and shared paint.
58  * @returns One batched Pibbl Path containing the supplied diamonds in source order.
59  * @see {@link IntervalDiamondSeriesProps}
60  */
61 export function IntervalDiamondSeries<D>(
62   props: IntervalDiamondSeriesProps<D>,
63 ): PibblNode {
64   const receiver = "IntervalDiamondSeries";
65   const {
66     data: input,
67     category: categoryInput,
68     estimate: estimateInput,
69     lower: lowerInput,
70     upper: upperInput,
71     categoryScale,
72     valueScale,
73     defined,
74     style: styleInput,
75     ...events
76   } = props;
77   const categories = lookupScale(categoryScale, receiver)?.get();
78   const values = useContinuousScale(valueScale).get();
79   if (
80     !categories ||
81     (categories.type !== "point" && categories.type !== "band")
82   )
83     throw new TypeError(
84       `${receiver}: categoryScale requires PointScale or BarScale.`,
85     );
86   const data = pointData(resolveSignalValue(input), receiver);
87   const category =
88     typeof categoryInput === "function"
89       ? categoryInput
90       : (datum: D) => datum[categoryInput] as BandCategory | null | undefined;
91   const estimate = pointAccessor(estimateInput),
92     lower = pointAccessor(lowerInput),
93     upper = pointAccessor(upperInput);
94   const specified = resolveSignalValue(styleInput) ?? {};
95   if (typeof specified !== "object" || Array.isArray(specified))
96     throw new TypeError(`${receiver}: style must be an object.`);
97   const resolved: Record<string, unknown> = {};
98   for (const key of Object.keys(specified)) {
99     if (
100       ![
101         "height",
102         "fill",
103         "stroke",
104         "strokeWidth",
105         "lineInteractionWidth",
106         "opacity",
107         "cursor",
108       ].includes(key)
109     )
110       throw new TypeError(`${receiver}: unsupported style field ${key}.`);
111     resolved[key] = resolveSignalValue(
112       specified[key as keyof typeof specified],
113     );
114   }
115   const style = resolved as IntervalDiamondSeriesStyle;
116   const halfHeight = width(style.height ?? 12, `${receiver} height`) / 2;
117   const strokeWidth = width(style.strokeWidth ?? 1.5, receiver);
118   const lineInteractionWidth = width(
119     style.lineInteractionWidth ?? strokeWidth,
120     receiver,
121   );
122   const alpha = opacity(style.opacity ?? 1, receiver);
123   if (style.cursor !== undefined && typeof style.cursor !== "string")
124     throw new TypeError(`${receiver}: cursor must be a string.`);
125   const path = new Path2D();
126   for (let i = 0; i < data.length; i++) {
127     const datum = data[i];
128     const row = withHooksForbidden(
129       `${receiver}: accessors cannot call hooks.`,
130       () => {
131         if (defined && !defined(datum, i, data)) return undefined;
132         return {
133           category: category(datum, i, data),
134           estimate: estimate(datum, i, data),
135           lower: lower(datum, i, data),
136           upper: upper(datum, i, data),
137         };
138       },
139     );
140     if (
141       !row ||
142       row.category == null ||
143       !finiteCoordinate(row.estimate) ||
144       !finiteCoordinate(row.lower) ||
145       !finiteCoordinate(row.upper)
146     )
147       continue;
148     const start = categories.map(row.category);
149     if (start === undefined) continue;
150     if (row.lower > row.upper)
151       throw new RangeError(
152         `${receiver}: lower must not exceed upper (row ${i}).`,
153       );
154     if (row.estimate < row.lower || row.estimate > row.upper)
155       throw new RangeError(
156         `${receiver}: estimate must lie inside supplied bounds (row ${i}).`,
157       );
158     const center =
159       start + (categories.type === "band" ? categories.bandwidth / 2 : 0);
160     const lo = values.map(row.lower),
161       hi = values.map(row.upper),
162       point = values.map(row.estimate);
163     if (
164       ![center - halfHeight, center + halfHeight, lo, hi, point].every(
165         Number.isFinite,
166       )
167     )
168       throw new RangeError(
169         `${receiver}: mapped coordinates must be finite (row ${i}).`,
170       );
171     if (halfHeight === 0) continue;
172     path.moveTo(lo, center);
173     path.lineTo(point, center - halfHeight);
174     path.lineTo(hi, center);
175     path.lineTo(point, center + halfHeight);
176     path.closePath();
177   }
178   return (
179     <Path
180       {...events}
181       style={{
182         d: path,
183         fill: style.fill ?? "#087f8c",
184         stroke: style.stroke ?? "#087f8c",
185         strokeWidth,
186         lineInteractionWidth,
187         opacity: alpha,
188         cursor: style.cursor,
189       }}
190     />
191   );
192 }
193 

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