packages/core/src/features/viz/lib/interval-diamond-series.tsx
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.