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