Skip to content

packages/core/src/features/viz/lib/editable-chart-elements.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   Line,
3   Rectangle,
4   definePrimitive,
5   resolveSignalValue,
6   useRef,
7   useSignal,
8   type PibblNodeInput,
9   type PibblRef,
10   type SignalValue,
11 } from "@pibbl/core";
12 import {
13   pibblInternalCurrentWorldTransform,
14   type SignalStyle,
15 } from "@pibbl/core/internal";
16 import type {
17   PibblEventTarget,
18   PibblKeyboardEvent,
19   PibblPointerEvent,
20 } from "@pibbl/core";
21 import type { ScaleId } from "./types.js";
22 import { lookupScale } from "./scale-context.js";
23 
24 /** One numeric x/y domain anchor for {@link EditableTrendLine}. */
25 export interface ChartAnchor {
26   /** X-scale domain value; an exact supplied timestamp for a trading-time x scale. */
27   readonly x: number;
28   /** Y-scale numeric domain value. */
29   readonly y: number;
30 }
31 /** Paint and hit-area fields shared by {@link RangeNavigator} and editable annotations. */
32 export interface EditableChartStyle {
33   /** Fill for navigator and reference-band interiors. */
34   readonly fill?: string;
35   /** Stroke color for visible annotation geometry. */
36   readonly stroke?: string;
37   /** Positive width of visible strokes. */
38   readonly strokeWidth?: number;
39   /** Annotation opacity from zero through one; handles remain fully opaque. */
40   readonly opacity?: number;
41   /** Fill color for visible drag handles. */
42   readonly handleFill?: string;
43   /** Stroke color for visible drag handles. */
44   readonly handleStroke?: string;
45   /** Positive square size of the painted handle. */
46   readonly handleSize?: number;
47   /** Positive width and height of each invisible pointer hit region. */
48   readonly hitSize?: number;
49 }
50 /** {@link EditableChartStyle} accepted by {@link RangeNavigator}. */
51 export interface RangeNavigatorStyle extends EditableChartStyle {}
52 /** {@link EditableChartStyle} accepted by {@link EditableReferenceLine}. */
53 export interface EditableReferenceLineStyle extends EditableChartStyle {}
54 /** {@link EditableChartStyle} accepted by {@link EditableReferenceBand}. */
55 export interface EditableReferenceBandStyle extends EditableChartStyle {}
56 /** {@link EditableChartStyle} accepted by {@link EditableTrendLine}. */
57 export interface EditableTrendLineStyle extends EditableChartStyle {}
58 type Pair = readonly [number, number];
59 type StyleInput<S extends EditableChartStyle> = SignalValue<SignalStyle<S>>;
60 type Change<T> = (value: T) => void;
61 /** Controlled inputs and callbacks for {@link RangeNavigator}. */
62 export interface RangeNavigatorProps {
63   /** ID of an ancestor invertible numeric continuous scale. */
64   readonly scale: ScaleId;
65   /** Controlled numeric interval in the scale domain. */
66   readonly values: SignalValue<Pair>;
67   /** Perpendicular local-coordinate start and end for the navigator strip. */
68   readonly span: SignalValue<Pair>;
69   /** Minimum numeric distance between interval endpoints; defaults to zero. */
70   readonly minimumSpan?: number;
71   /** Receives each synchronous controlled drag or keyboard interval. */
72   readonly onChange: Change<Pair>;
73   /** Receives the final interval after a completed pointer or keyboard edit. */
74   readonly onCommit?: Change<Pair>;
75   /** Runs after a cancelled interaction restores its starting interval. */
76   readonly onCancel?: () => void;
77   /** Optional annotation and handle paint inputs. */
78   readonly style?: StyleInput<RangeNavigatorStyle>;
79 }
80 /** Controlled inputs and callbacks for {@link EditableReferenceLine}. */
81 export interface EditableReferenceLineProps {
82   /** ID of an ancestor invertible numeric continuous scale. */
83   readonly scale: ScaleId;
84   /** Axis perpendicular to the drawn line and edited value. */
85   readonly direction: "horizontal" | "vertical";
86   /** Controlled numeric value in the scale domain. */
87   readonly value: SignalValue<number>;
88   /** Local-coordinate extent of the line along its non-edited axis. */
89   readonly span: SignalValue<Pair>;
90   /** Whether endpoint-independent handle geometry is drawn; defaults to true. */
91   readonly selected?: SignalValue<boolean>;
92   /** Finite positive numeric domain increment for arrow-key edits. */
93   readonly keyboardStep: number;
94   /** Runs when a pointer or keyboard interaction selects the line. */
95   readonly onSelect?: () => void;
96   /** Receives each synchronous controlled drag or keyboard value. */
97   readonly onChange: Change<number>;
98   /** Receives the final value after a completed pointer or keyboard edit. */
99   readonly onCommit?: Change<number>;
100   /** Runs after a cancelled interaction restores its starting value. */
101   readonly onCancel?: () => void;
102   /** Optional line and handle paint inputs. */
103   readonly style?: StyleInput<EditableReferenceLineStyle>;
104 }
105 /** Controlled inputs and callbacks for {@link EditableReferenceBand}. */
106 export interface EditableReferenceBandProps {
107   /** ID of an ancestor invertible numeric continuous scale. */
108   readonly scale: ScaleId;
109   /** Axis perpendicular to the band and edited interval. */
110   readonly direction: "horizontal" | "vertical";
111   /** Controlled numeric interval in the scale domain. */
112   readonly values: SignalValue<Pair>;
113   /** Local-coordinate extent of the band along its non-edited axis. */
114   readonly span: SignalValue<Pair>;
115   /** Whether resize handles are drawn; defaults to true. */
116   readonly selected?: SignalValue<boolean>;
117   /** Finite positive numeric domain increment for arrow-key panning. */
118   readonly keyboardStep: number;
119   /** Minimum numeric distance between interval endpoints; defaults to zero. */
120   readonly minimumSpan?: number;
121   /** Runs when a pointer or keyboard interaction selects the band. */
122   readonly onSelect?: () => void;
123   /** Receives each synchronous controlled drag or keyboard interval. */
124   readonly onChange: Change<Pair>;
125   /** Receives the final interval after a completed pointer or keyboard edit. */
126   readonly onCommit?: Change<Pair>;
127   /** Runs after a cancelled interaction restores its starting interval. */
128   readonly onCancel?: () => void;
129   /** Optional band and handle paint inputs. */
130   readonly style?: StyleInput<EditableReferenceBandStyle>;
131 }
132 /** Controlled inputs and callbacks for {@link EditableTrendLine}. */
133 export interface EditableTrendLineProps {
134   /** ID of an ancestor numeric or TradingTimeScale x mapping. */
135   readonly xScale: ScaleId;
136   /** ID of an ancestor invertible numeric y mapping. */
137   readonly yScale: ScaleId;
138   /** Controlled pair of domain anchors forming the line. */
139   readonly anchors: SignalValue<readonly [ChartAnchor, ChartAnchor]>;
140   /** Whether endpoint handles are drawn; defaults to true. */
141   readonly selected?: SignalValue<boolean>;
142   /** Finite positive x and y increments for arrow-key translation. */
143   readonly keyboardStep: {
144     /** Numeric-domain or timestamp increment for x translation. */
145     readonly x: number;
146     /** Numeric-domain increment for y translation. */
147     readonly y: number;
148   };
149   /** Runs when a pointer or keyboard interaction selects the trend line. */
150   readonly onSelect?: () => void;
151   /** Receives each synchronous controlled drag or keyboard anchor pair. */
152   readonly onChange: Change<readonly [ChartAnchor, ChartAnchor]>;
153   /** Receives final anchors after a completed pointer or keyboard edit. */
154   readonly onCommit?: Change<readonly [ChartAnchor, ChartAnchor]>;
155   /** Runs after a cancelled interaction restores its starting anchors. */
156   readonly onCancel?: () => void;
157   /** Optional line and handle paint inputs. */
158   readonly style?: StyleInput<EditableTrendLineStyle>;
159 }
160 
161 type Scale = {
162   readonly type: string;
163   readonly domain: readonly number[];
164   readonly range: Pair;
165   map(value: number): number;
166   invert?(position: number): number;
167   nearestTimeAtPosition?(position: number): number | undefined;
168   slotAtTime?(time: number): number | undefined;
169   timeAtSlot?(slot: number): number | undefined;
170 };
171 type Point = Readonly<{ x: number; y: number }>;
172 type Draft<T, M extends string> = Readonly<{
173   pointerId: number;
174   target: PibblEventTarget;
175   inverse: DOMMatrix;
176   start: Point;
177   original: T;
178   last: T;
179   mode: M;
180   /** Resolved scale instances at pointer-down, stable for the transaction. */
181   scales: readonly Scale[];
182 }>;
183 type DraftSignal<T, M extends string> = ReturnType<
184   typeof useSignal<Draft<T, M> | null>
185 >;
186 interface TransformCaptureProps {
187   readonly capture: PibblRef<DOMMatrix>;
188   readonly children: PibblNodeInput;
189 }
190 const TransformCapture = definePrimitive<TransformCaptureProps>(
191   function TransformCapture(props) {
192     props.capture.current = pibblInternalCurrentWorldTransform();
193     return props.children;
194   },
195   { childInput: "structural" },
196 );
197 
198 function pair(value: unknown, name: string): Pair {
199   if (
200     !Array.isArray(value) ||
201     value.length !== 2 ||
202     !value.every(Number.isFinite)
203   )
204     throw new RangeError(`${name} requires two finite values.`);
205   return value as unknown as Pair;
206 }
207 function positive(value: number, name: string) {
208   if (!Number.isFinite(value) || value <= 0)
209     throw new RangeError(`${name} must be finite and positive.`);
210   return value;
211 }
212 function nonnegative(value: number, name: string) {
213   if (!Number.isFinite(value) || value < 0)
214     throw new RangeError(`${name} must be finite and non-negative.`);
215   return value;
216 }
217 function clamp(value: number, values: readonly number[]) {
218   return Math.max(Math.min(...values), Math.min(Math.max(...values), value));
219 }
220 function clip(value: number, scale: Scale) {
221   return clamp(value, scale.domain);
222 }
223 function range(
224   first: number,
225   second: number,
226   viewport: Pair,
227 ): Pair | undefined {
228   const low = Math.max(Math.min(first, second), Math.min(...viewport)),
229     high = Math.min(Math.max(first, second), Math.max(...viewport));
230   return high < low ? undefined : [low, high];
231 }
232 function style<S extends EditableChartStyle>(
233   input: StyleInput<S> | undefined,
234 ): Required<EditableChartStyle> {
235   const source = resolveSignalValue(input ?? {}) as SignalStyle<S>;
236   const opacity = resolveSignalValue(source.opacity) ?? 1,
237     strokeWidth = resolveSignalValue(source.strokeWidth) ?? 1.5,
238     handleSize = resolveSignalValue(source.handleSize) ?? 8,
239     hitSize = resolveSignalValue(source.hitSize) ?? 20;
240   if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1)
241     throw new RangeError(
242       "Editable chart style opacity must be finite from zero through one.",
243     );
244   positive(strokeWidth, "Editable chart style strokeWidth");
245   positive(handleSize, "Editable chart style handleSize");
246   positive(hitSize, "Editable chart style hitSize");
247   const fill = resolveSignalValue(source.fill) ?? "#2563eb",
248     stroke = resolveSignalValue(source.stroke) ?? "#2563eb",
249     handleFill = resolveSignalValue(source.handleFill) ?? fill,
250     handleStroke = resolveSignalValue(source.handleStroke) ?? stroke;
251   for (const [name, value] of Object.entries({
252     fill,
253     stroke,
254     handleFill,
255     handleStroke,
256   }))
257     if (typeof value !== "string")
258       throw new TypeError(`Editable chart style ${name} must be a string.`);
259   return {
260     fill,
261     stroke,
262     strokeWidth,
263     opacity,
264     handleFill,
265     handleStroke,
266     handleSize,
267     hitSize,
268   };
269 }
270 function scale(
271   id: ScaleId,
272   receiver: string,
273   options: Readonly<{ allowTradingTime?: boolean }> = {},
274 ): Scale {
275   const source = lookupScale(id, receiver);
276   if (!source)
277     throw new TypeError(`${receiver} requires an ancestor scale ID.`);
278   const value = source.get() as Scale;
279   if (value.type === "point" || value.type === "band")
280     throw new TypeError(`${receiver} requires a continuous scale.`);
281   if (value.type === "trading-time" && !options.allowTradingTime)
282     throw new TypeError(
283       `${receiver} requires an invertible numeric continuous scale, not TradingTimeScale.`,
284     );
285   if (
286     !Array.isArray(value.domain) ||
287     value.domain.length < 2 ||
288     !value.domain.every(Number.isFinite) ||
289     !Array.isArray(value.range) ||
290     value.range.length !== 2 ||
291     !value.range.every(Number.isFinite) ||
292     typeof value.map !== "function"
293   )
294     throw new TypeError(`${receiver} requires a finite numeric scale.`);
295   if (value.type !== "trading-time" && typeof value.invert !== "function")
296     throw new TypeError(`${receiver} requires an invertible continuous scale.`);
297   if (
298     value.type === "trading-time" &&
299     (!value.nearestTimeAtPosition || !value.slotAtTime || !value.timeAtSlot)
300   )
301     throw new TypeError(`${receiver} requires a TradingTimeScale mapping.`);
302   return value;
303 }
304 function local(
305   inverse: DOMMatrix,
306   event: Pick<PibblPointerEvent, "x" | "y">,
307 ): Point {
308   const point = inverse.transformPoint(new DOMPoint(event.x, event.y));
309   if (!Number.isFinite(point.x) || !Number.isFinite(point.y))
310     throw new RangeError(
311       "Editable chart interaction requires an invertible finite transform.",
312     );
313   return point;
314 }
315 function begin<T, M extends string>(
316   state: DraftSignal<T, M>,
317   event: PibblPointerEvent,
318   original: T,
319   mode: M,
320   world: DOMMatrix | undefined,
321   scales: readonly Scale[],
322 ) {
323   if (!world || state.get()) return;
324   const inverse = world.inverse(),
325     draft: Draft<T, M> = {
326       pointerId: event.pointerId,
327       target: event.currentTarget,
328       inverse,
329       start: local(inverse, event),
330       original,
331       last: original,
332       mode,
333       scales: [...scales],
334     };
335   state.set(draft);
336   event.currentTarget.setPointerCapture(event.pointerId);
337   event.stopPropagation();
338 }
339 function publish<T, M extends string>(
340   state: DraftSignal<T, M>,
341   active: Draft<T, M>,
342   next: T,
343   onChange: Change<T>,
344 ) {
345   onChange(next);
346   state.set({ ...active, last: next });
347 }
348 function release<T, M extends string>(
349   state: DraftSignal<T, M>,
350   active: Draft<T, M>,
351 ) {
352   // Native release may synchronously dispatch lostpointercapture. Clear the
353   // transaction first so an intentional release cannot roll itself back.
354   state.set(null);
355   if (active.target.hasPointerCapture(active.pointerId))
356     active.target.releasePointerCapture(active.pointerId);
357 }
358 function keys(
359   event: PibblKeyboardEvent,
360   cancel: () => void,
361   edit: (sign: number) => void,
362 ) {
363   if (event.key === "Escape") {
364     event.preventDefault();
365     cancel();
366     return;
367   }
368   const sign =
369     event.key === "ArrowLeft" || event.key === "ArrowDown"
370       ? -1
371       : event.key === "ArrowRight" || event.key === "ArrowUp"
372         ? 1
373         : 0;
374   if (sign) {
375     event.preventDefault();
376     edit(sign);
377   }
378 }
379 function edge(
380   values: Pair,
381   which: "first" | "second",
382   value: number,
383   minimum: number,
384   source: Scale,
385   bounded: boolean,
386 ): Pair {
387   const [first, second] = values;
388   const domainLow = Math.min(...source.domain),
389     domainHigh = Math.max(...source.domain);
390   if (which === "first") {
391     const limit = first <= second ? second - minimum : second + minimum;
392     return [
393       first <= second
394         ? bounded
395           ? clamp(value, [domainLow, limit])
396           : Math.min(value, limit)
397         : bounded
398           ? clamp(value, [limit, domainHigh])
399           : Math.max(value, limit),
400       second,
401     ];
402   }
403   const limit = first <= second ? first + minimum : first - minimum;
404   return [
405     first,
406     first <= second
407       ? bounded
408         ? clamp(value, [limit, domainHigh])
409         : Math.max(value, limit)
410       : bounded
411         ? clamp(value, [domainLow, limit])
412         : Math.min(value, limit),
413   ];
414 }
415 function pan(
416   values: Pair,
417   delta: number,
418   source: Scale,
419   bounded: boolean,
420 ): Pair {
421   if (!bounded) return [values[0] + delta, values[1] + delta];
422   const low = Math.min(...values),
423     high = Math.max(...values),
424     permitted = clamp(delta, [
425       Math.min(...source.domain) - low,
426       Math.max(...source.domain) - high,
427     ]);
428   return [values[0] + permitted, values[1] + permitted];
429 }
430 function minimumMappedDelta(
431   points: Pair,
432   requested: number,
433   source: Scale,
434   minimum: number,
435 ) {
436   if (!minimum) return requested;
437   const width = (delta: number) =>
438     Math.abs(
439       source.invert!(points[1] + delta) - source.invert!(points[0] + delta),
440     );
441   let previous = 0;
442   for (let index = 1; index <= 64; index++) {
443     const candidate = (requested * index) / 64;
444     if (width(candidate) >= minimum) {
445       previous = candidate;
446       continue;
447     }
448     let low = previous,
449       high = candidate;
450     for (let iteration = 0; iteration < 40; iteration++) {
451       const middle = (low + high) / 2;
452       if (width(middle) >= minimum) low = middle;
453       else high = middle;
454     }
455     return low;
456   }
457   return requested;
458 }
459 function panMapped(
460   values: Pair,
461   requested: number,
462   source: Scale,
463   bounded: boolean,
464   minimum: number,
465 ): Pair {
466   const positions: Pair = [source.map(values[0]), source.map(values[1])],
467     viewportDelta = bounded
468       ? sharedMappedDelta(positions, requested, source.range)
469       : requested,
470     delta = minimumMappedDelta(positions, viewportDelta, source, minimum);
471   if (bounded)
472     return [
473       clip(source.invert!(positions[0] + delta), source),
474       clip(source.invert!(positions[1] + delta), source),
475     ];
476   return [
477     source.invert!(positions[0] + delta),
478     source.invert!(positions[1] + delta),
479   ];
480 }
481 function navigatorInterval(
482   values: Pair,
483   minimum: number,
484   source: Scale,
485   receiver: string,
486 ) {
487   const domainLow = Math.min(...source.domain),
488     domainHigh = Math.max(...source.domain),
489     domainSpan = domainHigh - domainLow;
490   if (minimum > domainSpan)
491     throw new RangeError(
492       `${receiver} minimumSpan cannot exceed its scale domain span.`,
493     );
494   if (values.some((value) => value < domainLow || value > domainHigh))
495     throw new RangeError(
496       `${receiver} values must lie within its scale domain.`,
497     );
498   if (Math.abs(values[1] - values[0]) < minimum)
499     throw new RangeError(`${receiver} values must meet minimumSpan.`);
500 }
501 function annotationInterval(values: Pair, minimum: number, receiver: string) {
502   if (Math.abs(values[1] - values[0]) < minimum)
503     throw new RangeError(`${receiver} values must meet minimumSpan.`);
504 }
505 function handle(
506   x: number,
507   y: number,
508   paint: Required<EditableChartStyle>,
509   handlers: Record<string, unknown>,
510 ) {
511   const visual = {
512       left: x - paint.handleSize / 2,
513       top: y - paint.handleSize / 2,
514       width: paint.handleSize,
515       height: paint.handleSize,
516     },
517     hit = {
518       left: x - paint.hitSize / 2,
519       top: y - paint.hitSize / 2,
520       width: paint.hitSize,
521       height: paint.hitSize,
522     };
523   return (
524     <>
525       <Rectangle
526         pointerEvents="none"
527         style={{
528           ...visual,
529           fill: paint.handleFill,
530           stroke: paint.handleStroke,
531           strokeWidth: paint.strokeWidth,
532           opacity: 1,
533         }}
534       />
535       <Rectangle
536         {...handlers}
537         keyboardFocusable
538         style={{ ...hit, fill: "rgba(0,0,0,0)", cursor: "grab" }}
539       />
540     </>
541   );
542 }
543 function sharedMappedDelta(
544   points: readonly number[],
545   requested: number,
546   viewport: Pair,
547 ) {
548   return clamp(requested, [
549     Math.min(...viewport) - Math.min(...points),
550     Math.max(...viewport) - Math.max(...points),
551   ]);
552 }
553 
554 /**
555  * Renders a controlled overview interval with pan and edge-resize handles.
556  *
557  * @param props - Controlled range inputs and callbacks; see {@link RangeNavigatorProps}.
558  * @returns Navigator geometry, handles, or no geometry when outside the visible scale range.
559  * @see {@link EditableReferenceBand} for a directional plot interval.
560  */
561 export function RangeNavigator(props: RangeNavigatorProps) {
562   const values = pair(
563       resolveSignalValue(props.values),
564       "RangeNavigator values",
565     ),
566     span = pair(resolveSignalValue(props.span), "RangeNavigator span"),
567     source = scale(props.scale, "RangeNavigator"),
568     paint = style(props.style),
569     minimum = nonnegative(props.minimumSpan ?? 0, "RangeNavigator minimumSpan"),
570     draft = useSignal<Draft<Pair, "pan" | "first" | "second"> | null>(null),
571     transform = useRef<DOMMatrix>();
572   navigatorInterval(values, minimum, source, "RangeNavigator");
573   const first = source.map(values[0]),
574     second = source.map(values[1]),
575     visible = range(first, second, source.range);
576   const cancel = () => {
577     const active = draft.get();
578     if (!active) return;
579     props.onChange(active.original);
580     release(draft, active);
581     props.onCancel?.();
582   };
583   const down =
584     (mode: "pan" | "first" | "second") => (event: PibblPointerEvent) =>
585       begin(draft, event, values, mode, transform.current, [source]);
586   const move = (event: PibblPointerEvent) => {
587     const active = draft.get();
588     if (!active || active.pointerId !== event.pointerId) return;
589     const point = local(active.inverse, event),
590       captured = active.scales[0]!,
591       next =
592         active.mode === "pan"
593           ? panMapped(
594               active.original,
595               point.x - active.start.x,
596               captured,
597               true,
598               minimum,
599             )
600           : edge(
601               active.original,
602               active.mode,
603               captured.invert!(point.x),
604               minimum,
605               captured,
606               true,
607             );
608     publish(draft, active, next, props.onChange);
609     event.stopPropagation();
610   };
611   const up = (event: PibblPointerEvent) => {
612     const active = draft.get();
613     if (!active || active.pointerId !== event.pointerId) return;
614     release(draft, active);
615     props.onCommit?.(active.last);
616     event.stopPropagation();
617   };
618   if (!visible) return null;
619   const top = Math.min(...span),
620     height = Math.abs(span[1] - span[0]),
621     center = top + height / 2;
622   const edit = (sign: number) => {
623     const keyboardStep =
624         minimum || Math.abs(source.domain[1]! - source.domain[0]!) * 0.01,
625       next = pan(values, sign * keyboardStep, source, true);
626     props.onChange(next);
627     props.onCommit?.(next);
628   };
629   const handleKeys = (event: PibblKeyboardEvent) => keys(event, cancel, edit);
630   return (
631     <TransformCapture capture={transform}>
632       <Rectangle
633         keyboardFocusable
634         onPointerDown={down("pan")}
635         onPointerMove={move}
636         onPointerUp={up}
637         onPointerCancel={cancel}
638         onLostPointerCapture={cancel}
639         onKeyDown={handleKeys}
640         style={{
641           left: visible[0],
642           top,
643           width: visible[1] - visible[0],
644           height,
645           fill: paint.fill,
646           stroke: paint.stroke,
647           strokeWidth: paint.strokeWidth,
648           opacity: paint.opacity,
649           cursor: "grab",
650         }}
651       />
652       {handle(first, center, paint, {
653         onPointerDown: down("first"),
654         onPointerMove: move,
655         onPointerUp: up,
656         onPointerCancel: cancel,
657         onLostPointerCapture: cancel,
658         onKeyDown: handleKeys,
659       })}
660       {handle(second, center, paint, {
661         onPointerDown: down("second"),
662         onPointerMove: move,
663         onPointerUp: up,
664         onPointerCancel: cancel,
665         onLostPointerCapture: cancel,
666         onKeyDown: handleKeys,
667       })}
668     </TransformCapture>
669   );
670 }
671 
672 /**
673  * Renders one controlled numeric-domain reference line.
674  *
675  * @param props - Controlled line inputs and callbacks; see {@link EditableReferenceLineProps}.
676  * @returns The line and its selected handle, or no geometry when it is outside the visible range.
677  * @see {@link EditableReferenceBand} for an editable interval.
678  */
679 export function EditableReferenceLine(props: EditableReferenceLineProps) {
680   if (props.direction !== "horizontal" && props.direction !== "vertical")
681     throw new TypeError(
682       "EditableReferenceLine direction must be horizontal or vertical.",
683     );
684   const value = resolveSignalValue(props.value),
685     span = pair(resolveSignalValue(props.span), "EditableReferenceLine span"),
686     source = scale(props.scale, "EditableReferenceLine"),
687     step = positive(props.keyboardStep, "EditableReferenceLine keyboardStep"),
688     paint = style(props.style),
689     selected = resolveSignalValue(props.selected ?? true),
690     draft = useSignal<Draft<number, "line"> | null>(null),
691     transform = useRef<DOMMatrix>();
692   if (!Number.isFinite(value))
693     throw new RangeError("EditableReferenceLine value must be finite.");
694   const position = source.map(value),
695     cancel = () => {
696       const active = draft.get();
697       if (!active) return;
698       props.onChange(active.original);
699       release(draft, active);
700       props.onCancel?.();
701     },
702     down = (event: PibblPointerEvent) => {
703       props.onSelect?.();
704       begin(draft, event, value, "line", transform.current, [source]);
705     };
706   const move = (event: PibblPointerEvent) => {
707       const active = draft.get();
708       if (!active || active.pointerId !== event.pointerId) return;
709       const point = local(active.inverse, event),
710         captured = active.scales[0]!;
711       publish(
712         draft,
713         active,
714         captured.invert!(props.direction === "horizontal" ? point.y : point.x),
715         props.onChange,
716       );
717       event.stopPropagation();
718     },
719     up = (event: PibblPointerEvent) => {
720       const active = draft.get();
721       if (!active || active.pointerId !== event.pointerId) return;
722       release(draft, active);
723       props.onCommit?.(active.last);
724       event.stopPropagation();
725     };
726   if (!range(position, position, source.range)) return null;
727   const coords: [number, number][] =
728       props.direction === "horizontal"
729         ? [
730             [span[0], position],
731             [span[1], position],
732           ]
733         : [
734             [position, span[0]],
735             [position, span[1]],
736           ],
737     x = props.direction === "horizontal" ? (span[0] + span[1]) / 2 : position,
738     y = props.direction === "horizontal" ? position : (span[0] + span[1]) / 2;
739   const edit = (sign: number) => {
740     props.onSelect?.();
741     const next = value + sign * step;
742     props.onChange(next);
743     props.onCommit?.(next);
744   };
745   const handleKeys = (event: PibblKeyboardEvent) => keys(event, cancel, edit);
746   return (
747     <TransformCapture capture={transform}>
748       <Line
749         keyboardFocusable
750         onPointerDown={down}
751         onPointerMove={move}
752         onPointerUp={up}
753         onPointerCancel={cancel}
754         onLostPointerCapture={cancel}
755         onKeyDown={handleKeys}
756         style={{
757           coords,
758           stroke: paint.stroke,
759           strokeWidth: paint.strokeWidth,
760           opacity: paint.opacity,
761           lineInteractionWidth: paint.hitSize,
762           cursor: "grab",
763         }}
764       />
765       {selected
766         ? handle(x, y, paint, {
767             onPointerDown: down,
768             onPointerMove: move,
769             onPointerUp: up,
770             onPointerCancel: cancel,
771             onLostPointerCapture: cancel,
772             onKeyDown: handleKeys,
773           })
774         : null}
775     </TransformCapture>
776   );
777 }
778 
779 /**
780  * Renders a controlled numeric-domain interval with pan and edge-resize handles.
781  *
782  * @param props - Controlled band inputs and callbacks; see {@link EditableReferenceBandProps}.
783  * @returns Band geometry and selected handles, or no geometry when outside the visible range.
784  * @see {@link RangeNavigator} for an overview-strip interval.
785  */
786 export function EditableReferenceBand(props: EditableReferenceBandProps) {
787   if (props.direction !== "horizontal" && props.direction !== "vertical")
788     throw new TypeError(
789       "EditableReferenceBand direction must be horizontal or vertical.",
790     );
791   const values = pair(
792       resolveSignalValue(props.values),
793       "EditableReferenceBand values",
794     ),
795     span = pair(resolveSignalValue(props.span), "EditableReferenceBand span"),
796     source = scale(props.scale, "EditableReferenceBand"),
797     step = positive(props.keyboardStep, "EditableReferenceBand keyboardStep"),
798     minimum = nonnegative(
799       props.minimumSpan ?? 0,
800       "EditableReferenceBand minimumSpan",
801     ),
802     paint = style(props.style),
803     selected = resolveSignalValue(props.selected ?? true),
804     draft = useSignal<Draft<Pair, "pan" | "first" | "second"> | null>(null),
805     transform = useRef<DOMMatrix>();
806   annotationInterval(values, minimum, "EditableReferenceBand");
807   const first = source.map(values[0]),
808     second = source.map(values[1]),
809     visible = range(first, second, source.range);
810   const cancel = () => {
811     const active = draft.get();
812     if (!active) return;
813     props.onChange(active.original);
814     release(draft, active);
815     props.onCancel?.();
816   };
817   const down =
818     (mode: "pan" | "first" | "second") => (event: PibblPointerEvent) => {
819       props.onSelect?.();
820       begin(draft, event, values, mode, transform.current, [source]);
821     };
822   const move = (event: PibblPointerEvent) => {
823     const active = draft.get();
824     if (!active || active.pointerId !== event.pointerId) return;
825     const point = local(active.inverse, event),
826       coordinate = props.direction === "horizontal" ? point.y : point.x,
827       start =
828         props.direction === "horizontal" ? active.start.y : active.start.x,
829       captured = active.scales[0]!,
830       next =
831         active.mode === "pan"
832           ? panMapped(
833               active.original,
834               coordinate - start,
835               captured,
836               false,
837               minimum,
838             )
839           : edge(
840               active.original,
841               active.mode,
842               captured.invert!(coordinate),
843               minimum,
844               captured,
845               false,
846             );
847     publish(draft, active, next, props.onChange);
848     event.stopPropagation();
849   };
850   const up = (event: PibblPointerEvent) => {
851     const active = draft.get();
852     if (!active || active.pointerId !== event.pointerId) return;
853     release(draft, active);
854     props.onCommit?.(active.last);
855     event.stopPropagation();
856   };
857   const edit = (sign: number) => {
858     props.onSelect?.();
859     const next = pan(values, sign * step, source, false);
860     props.onChange(next);
861     props.onCommit?.(next);
862   };
863   if (!visible) return null;
864   const rect =
865     props.direction === "horizontal"
866       ? {
867           left: Math.min(...span),
868           top: visible[0],
869           width: Math.abs(span[1] - span[0]),
870           height: visible[1] - visible[0],
871         }
872       : {
873           left: visible[0],
874           top: Math.min(...span),
875           width: visible[1] - visible[0],
876           height: Math.abs(span[1] - span[0]),
877         };
878   const firstHandle: Pair =
879       props.direction === "horizontal"
880         ? [Math.min(...span), first]
881         : [first, Math.min(...span)],
882     secondHandle: Pair =
883       props.direction === "horizontal"
884         ? [Math.max(...span), second]
885         : [second, Math.max(...span)];
886   const handleKeys = (event: PibblKeyboardEvent) => keys(event, cancel, edit);
887   return (
888     <TransformCapture capture={transform}>
889       <Rectangle
890         keyboardFocusable
891         onPointerDown={down("pan")}
892         onPointerMove={move}
893         onPointerUp={up}
894         onPointerCancel={cancel}
895         onLostPointerCapture={cancel}
896         onKeyDown={handleKeys}
897         style={{
898           ...rect,
899           fill: paint.fill,
900           stroke: paint.stroke,
901           strokeWidth: paint.strokeWidth,
902           opacity: paint.opacity,
903           cursor: "grab",
904         }}
905       />
906       {selected ? (
907         <>
908           {handle(firstHandle[0], firstHandle[1], paint, {
909             onPointerDown: down("first"),
910             onPointerMove: move,
911             onPointerUp: up,
912             onPointerCancel: cancel,
913             onLostPointerCapture: cancel,
914             onKeyDown: handleKeys,
915           })}
916           {handle(secondHandle[0], secondHandle[1], paint, {
917             onPointerDown: down("second"),
918             onPointerMove: move,
919             onPointerUp: up,
920             onPointerCancel: cancel,
921             onLostPointerCapture: cancel,
922             onKeyDown: handleKeys,
923           })}
924         </>
925       ) : null}
926     </TransformCapture>
927   );
928 }
929 
930 function snap(source: Scale, position: number): number {
931   const time = source.nearestTimeAtPosition?.(position);
932   if (time === undefined)
933     throw new RangeError(
934       "EditableTrendLine cannot edit an empty TradingTimeScale.",
935     );
936   return time;
937 }
938 function mapped(source: Scale, position: number): number {
939   return source.type === "trading-time"
940     ? snap(source, position)
941     : source.invert!(position);
942 }
943 function advanceTradingTime(
944   source: Scale,
945   value: number,
946   sign: number,
947   step: number,
948 ) {
949   const target = value + sign * step,
950     candidates = source.domain.filter((candidate) =>
951       sign > 0 ? candidate >= target : candidate <= target,
952     );
953   return sign > 0
954     ? (candidates[0] ?? Math.max(...source.domain))
955     : (candidates[candidates.length - 1] ?? Math.min(...source.domain));
956 }
957 function translateTradingAnchors(
958   source: Scale,
959   anchors: readonly [ChartAnchor, ChartAnchor],
960   sign: number,
961   step: number,
962 ): readonly [ChartAnchor, ChartAnchor] {
963   const slots = anchors.map((anchor) => source.slotAtTime!(anchor.x)!);
964   const requested =
965     source.slotAtTime!(advanceTradingTime(source, anchors[0].x, sign, step))! -
966     slots[0]!;
967   const delta = clamp(requested, [
968     -Math.min(...slots),
969     source.domain.length - 1 - Math.max(...slots),
970   ]);
971   return [
972     { ...anchors[0], x: source.timeAtSlot!(slots[0]! + delta)! },
973     { ...anchors[1], x: source.timeAtSlot!(slots[1]! + delta)! },
974   ];
975 }
976 
977 /**
978  * Renders two controlled anchors as an editable trend line.
979  *
980  * Trading-time x values snap to supplied observations during pointer and keyboard edits.
981  *
982  * @param props - Controlled anchor inputs and callbacks; see {@link EditableTrendLineProps}.
983  * @returns Line geometry and selected endpoint handles.
984  * @see {@link ChartAnchor} for the controlled anchor shape.
985  */
986 export function EditableTrendLine(props: EditableTrendLineProps) {
987   const anchors = resolveSignalValue(props.anchors),
988     x = scale(props.xScale, "EditableTrendLine xScale", {
989       allowTradingTime: true,
990     }),
991     y = scale(props.yScale, "EditableTrendLine yScale"),
992     stepX = positive(props.keyboardStep.x, "EditableTrendLine keyboardStep.x"),
993     stepY = positive(props.keyboardStep.y, "EditableTrendLine keyboardStep.y"),
994     paint = style(props.style),
995     selected = resolveSignalValue(props.selected ?? true),
996     draft = useSignal<Draft<
997       readonly [ChartAnchor, ChartAnchor],
998       "line" | "first" | "second"
999     > | null>(null),
1000     transform = useRef<DOMMatrix>();
1001   if (
1002     !Array.isArray(anchors) ||
1003     anchors.length !== 2 ||
1004     !anchors.every(
1005       (anchor) => Number.isFinite(anchor.x) && Number.isFinite(anchor.y),
1006     )
1007   )
1008     throw new RangeError("EditableTrendLine requires two finite anchors.");
1009   const positions: readonly [Point, Point] = [
1010     { x: x.map(anchors[0].x), y: y.map(anchors[0].y) },
1011     { x: x.map(anchors[1].x), y: y.map(anchors[1].y) },
1012   ];
1013   const cancel = () => {
1014     const active = draft.get();
1015     if (!active) return;
1016     props.onChange(active.original);
1017     release(draft, active);
1018     props.onCancel?.();
1019   };
1020   const down =
1021     (mode: "line" | "first" | "second") => (event: PibblPointerEvent) => {
1022       props.onSelect?.();
1023       begin(
1024         draft,
1025         event,
1026         anchors as readonly [ChartAnchor, ChartAnchor],
1027         mode,
1028         transform.current,
1029         [x, y],
1030       );
1031     };
1032   const move = (event: PibblPointerEvent) => {
1033     const active = draft.get();
1034     if (!active || active.pointerId !== event.pointerId) return;
1035     const point = local(active.inverse, event),
1036       dragX = active.scales[0]!,
1037       dragY = active.scales[1]!;
1038     let next: readonly [ChartAnchor, ChartAnchor];
1039     if (active.mode === "line") {
1040       const originalPositions: readonly [Point, Point] = [
1041         {
1042           x: dragX.map(active.original[0].x),
1043           y: dragY.map(active.original[0].y),
1044         },
1045         {
1046           x: dragX.map(active.original[1].x),
1047           y: dragY.map(active.original[1].y),
1048         },
1049       ];
1050       const yDelta = point.y - active.start.y;
1051       const slotDelta =
1052         dragX.type === "trading-time"
1053           ? clamp(
1054               Math.round(
1055                 dragX.slotAtTime!(snap(dragX, point.x))! -
1056                   dragX.slotAtTime!(snap(dragX, active.start.x))!,
1057               ),
1058               [
1059                 -Math.min(
1060                   dragX.slotAtTime!(active.original[0].x)!,
1061                   dragX.slotAtTime!(active.original[1].x)!,
1062                 ),
1063                 dragX.domain.length -
1064                   1 -
1065                   Math.max(
1066                     dragX.slotAtTime!(active.original[0].x)!,
1067                     dragX.slotAtTime!(active.original[1].x)!,
1068                   ),
1069               ],
1070             )
1071           : 0;
1072       const xDelta =
1073         dragX.type === "trading-time" ? 0 : point.x - active.start.x;
1074       const translateX = (anchor: ChartAnchor, position: Point) =>
1075         dragX.type === "trading-time"
1076           ? dragX.timeAtSlot!(dragX.slotAtTime!(anchor.x)! + slotDelta)!
1077           : mapped(dragX, position.x + xDelta);
1078       next = [
1079         {
1080           x: translateX(active.original[0], originalPositions[0]),
1081           y: mapped(dragY, originalPositions[0].y + yDelta),
1082         },
1083         {
1084           x: translateX(active.original[1], originalPositions[1]),
1085           y: mapped(dragY, originalPositions[1].y + yDelta),
1086         },
1087       ];
1088     } else {
1089       const index = active.mode === "first" ? 0 : 1,
1090         changed = { x: mapped(dragX, point.x), y: mapped(dragY, point.y) };
1091       next =
1092         index === 0
1093           ? [changed, active.original[1]]
1094           : [active.original[0], changed];
1095     }
1096     publish(draft, active, next, props.onChange);
1097     event.stopPropagation();
1098   };
1099   const up = (event: PibblPointerEvent) => {
1100     const active = draft.get();
1101     if (!active || active.pointerId !== event.pointerId) return;
1102     release(draft, active);
1103     props.onCommit?.(active.last);
1104     event.stopPropagation();
1105   };
1106   const edit = (sign: number, axis: "x" | "y") => {
1107     props.onSelect?.();
1108     const translated =
1109       axis === "y"
1110         ? anchors
1111         : x.type === "trading-time"
1112           ? translateTradingAnchors(x, anchors, sign, stepX)
1113           : ([
1114               { ...anchors[0], x: anchors[0].x + sign * stepX },
1115               { ...anchors[1], x: anchors[1].x + sign * stepX },
1116             ] as const);
1117     const next: readonly [ChartAnchor, ChartAnchor] = [
1118       { ...translated[0], y: anchors[0].y + (axis === "y" ? sign * stepY : 0) },
1119       { ...translated[1], y: anchors[1].y + (axis === "y" ? sign * stepY : 0) },
1120     ];
1121     props.onChange(next);
1122     props.onCommit?.(next);
1123   };
1124   const coords: [number, number][] = positions.map((point) => [
1125     point.x,
1126     point.y,
1127   ]);
1128   const handleKeys = (event: PibblKeyboardEvent) =>
1129     keys(event, cancel, (sign) =>
1130       edit(
1131         sign,
1132         event.key === "ArrowLeft" || event.key === "ArrowRight" ? "x" : "y",
1133       ),
1134     );
1135   return (
1136     <TransformCapture capture={transform}>
1137       <Line
1138         keyboardFocusable
1139         onPointerDown={down("line")}
1140         onPointerMove={move}
1141         onPointerUp={up}
1142         onPointerCancel={cancel}
1143         onLostPointerCapture={cancel}
1144         onKeyDown={handleKeys}
1145         style={{
1146           coords,
1147           stroke: paint.stroke,
1148           strokeWidth: paint.strokeWidth,
1149           opacity: paint.opacity,
1150           lineInteractionWidth: paint.hitSize,
1151           cursor: "grab",
1152         }}
1153       />
1154       {selected ? (
1155         <>
1156           {handle(positions[0].x, positions[0].y, paint, {
1157             onPointerDown: down("first"),
1158             onPointerMove: move,
1159             onPointerUp: up,
1160             onPointerCancel: cancel,
1161             onLostPointerCapture: cancel,
1162             onKeyDown: handleKeys,
1163           })}
1164           {handle(positions[1].x, positions[1].y, paint, {
1165             onPointerDown: down("second"),
1166             onPointerMove: move,
1167             onPointerUp: up,
1168             onPointerCancel: cancel,
1169             onLostPointerCapture: cancel,
1170             onKeyDown: handleKeys,
1171           })}
1172         </>
1173       ) : null}
1174     </TransformCapture>
1175   );
1176 }
1177 

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