@vroomchart/react 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -76,13 +76,17 @@ function overlayToNumeric(o) {
76
76
  };
77
77
  }
78
78
  function drawingToSpec(d) {
79
+ const first = d.points[0];
80
+ const last = d.points[d.points.length - 1];
79
81
  return {
80
- aTime: d.points[0].timeMs,
81
- aPrice: d.points[0].price,
82
- bTime: d.points[1].timeMs,
83
- bPrice: d.points[1].price,
82
+ aTime: first.timeMs,
83
+ aPrice: first.price,
84
+ bTime: last.timeMs,
85
+ bPrice: last.price,
84
86
  color: (d.color != null ? parseColor(d.color) : null) ?? 4280902399,
85
- width: d.width ?? 2
87
+ width: d.width ?? 2,
88
+ kind: d.type === "box" ? 1 : d.type === "pencil" ? 2 : 0,
89
+ ...d.type === "pencil" ? { points: d.points.map((p) => ({ timeMs: p.timeMs, price: p.price })) } : {}
86
90
  };
87
91
  }
88
92
  var DEFAULT_BUY_COLOR = 4280723098;
@@ -122,6 +126,8 @@ function useChartCore(props, loadOpts) {
122
126
  height: heightProp,
123
127
  visibleRange,
124
128
  defaultCandleWidth,
129
+ chartType,
130
+ transitionMs,
125
131
  theme,
126
132
  rsi,
127
133
  macd,
@@ -257,11 +263,103 @@ function useChartCore(props, loadOpts) {
257
263
  );
258
264
  scheduleRender();
259
265
  }, [ready, width, height, candles, seriesKey, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, drawingsKey, liquidityKey, scheduleRender]);
266
+ const morphRafRef = useRef(null);
267
+ const morphFadeRef = useRef(null);
268
+ const morphHandleRef = useRef(null);
269
+ useEffect(() => {
270
+ if (!ready) return;
271
+ const h = handleRef.current;
272
+ if (!h) return;
273
+ const target = chartType === "line" ? 1 : 0;
274
+ if (morphHandleRef.current !== h || morphFadeRef.current == null) {
275
+ morphHandleRef.current = h;
276
+ morphFadeRef.current = target;
277
+ h.setChartType(target);
278
+ scheduleRender();
279
+ return;
280
+ }
281
+ if (morphFadeRef.current === target) return;
282
+ const reduce = !!(typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
283
+ const dur = Math.max(0, transitionMs ?? 300);
284
+ if (morphRafRef.current != null) {
285
+ cancelAnimationFrame(morphRafRef.current);
286
+ morphRafRef.current = null;
287
+ }
288
+ if (dur === 0) {
289
+ morphFadeRef.current = target;
290
+ h.setChartType(target);
291
+ scheduleRender();
292
+ return;
293
+ }
294
+ const from = morphFadeRef.current;
295
+ const start = performance.now();
296
+ const step = (now) => {
297
+ const p = Math.min(1, (now - start) / dur);
298
+ const e = p * p * (3 - 2 * p);
299
+ const fade = from + (target - from) * e;
300
+ morphFadeRef.current = fade;
301
+ h.setMorph(reduce ? 0 : fade, fade);
302
+ h.present();
303
+ if (p < 1) {
304
+ morphRafRef.current = requestAnimationFrame(step);
305
+ } else {
306
+ morphRafRef.current = null;
307
+ morphFadeRef.current = target;
308
+ h.setChartType(target);
309
+ h.present();
310
+ }
311
+ };
312
+ morphRafRef.current = requestAnimationFrame(step);
313
+ return () => {
314
+ if (morphRafRef.current != null) {
315
+ cancelAnimationFrame(morphRafRef.current);
316
+ morphRafRef.current = null;
317
+ }
318
+ };
319
+ }, [ready, chartType, transitionMs, scheduleRender]);
260
320
  return { containerRef, canvasRef, handleRef, scheduleRender, size: { width, height } };
261
321
  }
262
322
 
263
323
  // src/useGestures.ts
264
- import { useEffect as useEffect2, useRef as useRef2 } from "react";
324
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2 } from "react";
325
+
326
+ // src/simplify.ts
327
+ function perpendicularDistance(p, a, b) {
328
+ const dx = b.x - a.x;
329
+ const dy = b.y - a.y;
330
+ if (dx === 0 && dy === 0) return Math.hypot(p.x - a.x, p.y - a.y);
331
+ return Math.abs(dy * p.x - dx * p.y + b.x * a.y - b.y * a.x) / Math.hypot(dx, dy);
332
+ }
333
+ function simplifyIndices(points, epsilon) {
334
+ const n = points.length;
335
+ if (n <= 2) return points.map((_, i) => i);
336
+ if (epsilon <= 0) return points.map((_, i) => i);
337
+ const keep = new Uint8Array(n);
338
+ keep[0] = 1;
339
+ keep[n - 1] = 1;
340
+ const stack = [[0, n - 1]];
341
+ while (stack.length > 0) {
342
+ const [start, end] = stack.pop();
343
+ if (end <= start + 1) continue;
344
+ let farthest = -1;
345
+ let maxDist = -1;
346
+ for (let i = start + 1; i < end; i++) {
347
+ const d = perpendicularDistance(points[i], points[start], points[end]);
348
+ if (d > maxDist) {
349
+ maxDist = d;
350
+ farthest = i;
351
+ }
352
+ }
353
+ if (maxDist <= epsilon || farthest < 0) continue;
354
+ keep[farthest] = 1;
355
+ stack.push([start, farthest], [farthest, end]);
356
+ }
357
+ const out = [];
358
+ for (let i = 0; i < n; i++) if (keep[i]) out.push(i);
359
+ return out;
360
+ }
361
+
362
+ // src/useGestures.ts
265
363
  var MIN_SPAN = 24;
266
364
  var AXIS_RATIO = 0.5;
267
365
  var LONG_PRESS_MS = 350;
@@ -270,34 +368,197 @@ var WHEEL_K = 15e-4;
270
368
  var SEP_HIT = 4;
271
369
  var DRAW_COLOR = 4280902399;
272
370
  var DRAW_WIDTH = 2;
273
- var DRAW_HIT = 6;
371
+ var PENCIL_MIN_DIST = 2;
372
+ var PENCIL_EPSILON = 1;
274
373
  function drawingId() {
275
374
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
276
375
  return crypto.randomUUID();
277
376
  }
278
377
  return `draw-${Math.random().toString(36).slice(2)}`;
279
378
  }
280
- function distToSegment(px, py, ax, ay, bx, by) {
281
- const dx = bx - ax;
282
- const dy = by - ay;
283
- const len2 = dx * dx + dy * dy;
284
- let t = len2 > 0 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
285
- t = Math.max(0, Math.min(1, t));
286
- return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
379
+ function bodyPart(type) {
380
+ return type === "box" ? 4 : type === "pencil" ? 5 : 2;
381
+ }
382
+ function makeDrawing(base, type, pts) {
383
+ if (pts.length < 2) return null;
384
+ const style = {
385
+ ...base.color != null ? { color: base.color } : {},
386
+ ...base.width != null ? { width: base.width } : {}
387
+ };
388
+ if (type === "pencil") return { id: base.id, type, points: pts, ...style };
389
+ return { id: base.id, type, points: [pts[0], pts[pts.length - 1]], ...style };
390
+ }
391
+ function boxCorners(points) {
392
+ const [a, b] = points;
393
+ return [
394
+ { timeMs: a.timeMs, price: a.price },
395
+ { timeMs: b.timeMs, price: a.price },
396
+ { timeMs: b.timeMs, price: b.price },
397
+ { timeMs: a.timeMs, price: b.price }
398
+ ];
287
399
  }
288
400
  function useGestures(containerRef, handleRef, scheduleRender, opts) {
289
401
  const optsRef = useRef2(opts);
290
402
  optsRef.current = opts;
291
403
  const drawAnchorRef = useRef2(null);
292
- const drawAnchorPxRef = useRef2(null);
293
- const drawSelectedPxRef = useRef2(null);
404
+ const selectedIdRef = useRef2(null);
405
+ const grabRef = useRef2(null);
406
+ const dragLineRef = useRef2(null);
407
+ const pencilRef = useRef2(
408
+ null
409
+ );
410
+ const downHitRef = useRef2(null);
411
+ const lastCoordRef = useRef2(null);
412
+ const selectedTRef = useRef2(0.5);
413
+ const clipboardRef = useRef2(null);
414
+ const applySelection = useCallback2(() => {
415
+ const h = handleRef.current;
416
+ if (!h) return;
417
+ const id = selectedIdRef.current;
418
+ const list = optsRef.current.drawings ?? [];
419
+ let index = -1;
420
+ if (id != null) {
421
+ index = list.findIndex((d) => d.id === id);
422
+ if (index < 0) selectedIdRef.current = null;
423
+ }
424
+ h.setSelectedDrawing(index, index >= 0 ? grabRef.current?.endpoint ?? -1 : -1);
425
+ scheduleRender();
426
+ }, [handleRef, scheduleRender]);
427
+ useEffect2(() => {
428
+ applySelection();
429
+ }, [opts.drawings, applySelection]);
430
+ useEffect2(() => {
431
+ const isEditableTarget = () => {
432
+ const ae = document.activeElement;
433
+ const tag = ae?.tagName;
434
+ return tag === "INPUT" || tag === "TEXTAREA" || !!ae?.isContentEditable;
435
+ };
436
+ const findSelected = () => {
437
+ const id = selectedIdRef.current;
438
+ if (id == null) return null;
439
+ return (optsRef.current.drawings ?? []).find((x) => x.id === id) ?? null;
440
+ };
441
+ const placePaste = (clip) => {
442
+ const h = handleRef.current;
443
+ if (!h) return null;
444
+ const pts = clip.points;
445
+ if (pts.length < 2) return null;
446
+ const a = pts[0];
447
+ const b = pts[pts.length - 1];
448
+ const t = clip.t;
449
+ const pSel = {
450
+ timeMs: a.timeMs + t * (b.timeMs - a.timeMs),
451
+ price: a.price + t * (b.price - a.price)
452
+ };
453
+ const cur = h.getCrosshairInfo();
454
+ const moved = cur != null && (clip.crosshair == null || cur.timeMs !== clip.crosshair.timeMs || cur.price !== clip.crosshair.price);
455
+ if (moved && cur) {
456
+ const dt = cur.timeMs - pSel.timeMs;
457
+ const dp = cur.price - pSel.price;
458
+ return pts.map((q) => ({ timeMs: q.timeMs + dt, price: q.price + dp }));
459
+ }
460
+ const el = containerRef.current;
461
+ const shift = (dp) => pts.map((q) => ({ timeMs: q.timeMs, price: q.price + dp }));
462
+ const prices = pts.map((q) => q.price);
463
+ const span = Math.max(...prices) - Math.min(...prices);
464
+ if (!el) return shift(-(span || 1));
465
+ const rect = el.getBoundingClientRect();
466
+ const m = h.getAxisMetrics();
467
+ const priceBottom = rect.height - m.xAxisHeight - m.indicatorHeight;
468
+ const top = h.coordAt(rect.width / 2, 0);
469
+ const bot = h.coordAt(rect.width / 2, priceBottom);
470
+ if (!top || !bot || priceBottom <= 0 || top.price <= bot.price) return shift(-(span || 1));
471
+ const vTop = top.price;
472
+ const vBot = bot.price;
473
+ const pricePerPx = (vTop - vBot) / priceBottom;
474
+ const GAP_PX = 30;
475
+ const offset = (span / pricePerPx + GAP_PX) * pricePerPx;
476
+ const pMin = Math.min(...prices);
477
+ const pMax = Math.max(...prices);
478
+ const upFits = pMax + offset <= vTop;
479
+ const downFits = pMin - offset >= vBot;
480
+ let dir;
481
+ if (upFits && !downFits) dir = 1;
482
+ else if (downFits && !upFits) dir = -1;
483
+ else if (upFits && downFits) {
484
+ dir = vTop - (pMax + offset) >= pMin - offset - vBot ? 1 : -1;
485
+ } else {
486
+ const upVis = Math.min(pMax + offset, vTop) - Math.max(pMin + offset, vBot);
487
+ const dnVis = Math.min(pMax - offset, vTop) - Math.max(pMin - offset, vBot);
488
+ dir = upVis >= dnVis ? 1 : -1;
489
+ }
490
+ return shift(dir * offset);
491
+ };
492
+ const onKeyDown = (e) => {
493
+ if (isEditableTarget()) return;
494
+ if (drawAnchorRef.current && (e.key === "Escape" || e.key === "Backspace" || e.key === "Delete")) {
495
+ e.preventDefault();
496
+ drawAnchorRef.current = null;
497
+ const h = handleRef.current;
498
+ if (h) {
499
+ h.clearDraft();
500
+ scheduleRender();
501
+ }
502
+ return;
503
+ }
504
+ if (e.key === "Backspace" || e.key === "Delete") {
505
+ const id = selectedIdRef.current;
506
+ if (id == null) return;
507
+ e.preventDefault();
508
+ optsRef.current.onDrawingDelete?.(id);
509
+ selectedIdRef.current = null;
510
+ grabRef.current = null;
511
+ dragLineRef.current = null;
512
+ const h = handleRef.current;
513
+ if (h) {
514
+ h.setSelectedDrawing(-1, -1);
515
+ scheduleRender();
516
+ }
517
+ return;
518
+ }
519
+ if (!(e.metaKey || e.ctrlKey)) return;
520
+ const key = e.key.toLowerCase();
521
+ if (key === "c") {
522
+ if (window.getSelection()?.toString()) return;
523
+ const d = findSelected();
524
+ if (!d) return;
525
+ e.preventDefault();
526
+ const info = handleRef.current?.getCrosshairInfo() ?? null;
527
+ clipboardRef.current = {
528
+ type: d.type,
529
+ points: d.points.map((q) => ({ timeMs: q.timeMs, price: q.price })),
530
+ color: d.color,
531
+ width: d.width,
532
+ t: selectedTRef.current,
533
+ crosshair: info ? { timeMs: info.timeMs, price: info.price } : null
534
+ };
535
+ } else if (key === "v") {
536
+ const clip = clipboardRef.current;
537
+ if (!clip) return;
538
+ const pts = placePaste(clip);
539
+ if (!pts) return;
540
+ const id = drawingId();
541
+ const copy = makeDrawing(
542
+ { id, color: clip.color, width: clip.width },
543
+ clip.type,
544
+ pts
545
+ );
546
+ if (!copy) return;
547
+ e.preventDefault();
548
+ optsRef.current.onDrawingComplete?.(copy);
549
+ selectedIdRef.current = id;
550
+ selectedTRef.current = clip.t;
551
+ }
552
+ };
553
+ window.addEventListener("keydown", onKeyDown);
554
+ return () => window.removeEventListener("keydown", onKeyDown);
555
+ }, [containerRef, handleRef, scheduleRender]);
294
556
  const localCrosshairActiveRef = useRef2(false);
295
557
  useEffect2(() => {
296
- const active = opts.mode === "draw" && opts.tool === "line";
558
+ const active = opts.mode === "draw" && (opts.tool === "line" || opts.tool === "box" || opts.tool === "pencil");
297
559
  if (active) return;
298
560
  drawAnchorRef.current = null;
299
- drawAnchorPxRef.current = null;
300
- drawSelectedPxRef.current = null;
561
+ pencilRef.current = null;
301
562
  const h = handleRef.current;
302
563
  if (h) {
303
564
  h.clearDraft();
@@ -325,11 +586,46 @@ function useGestures(containerRef, handleRef, scheduleRender, opts) {
325
586
  let downX = 0;
326
587
  let downY = 0;
327
588
  let moved = false;
589
+ let shiftHeld = false;
590
+ let lastX = 0;
591
+ let lastY = 0;
328
592
  const pinch = { spanX: 1, spanY: 1, ratioX: 1, ratioY: 1, enableX: false, enableY: false, active: false };
329
593
  const rel = (e) => {
330
594
  const r = el.getBoundingClientRect();
331
595
  return { x: e.clientX - r.left, y: e.clientY - r.top };
332
596
  };
597
+ const snapToLine = (fixed, x, y) => {
598
+ const h = handleRef.current;
599
+ if (!h) return null;
600
+ const free = h.coordAt(x, y);
601
+ if (!shiftHeld || !free) return free;
602
+ const f = h.project(fixed.timeMs, fixed.price);
603
+ if (!f) return free;
604
+ const dx = x - f.x;
605
+ const dy = y - f.y;
606
+ if (dx === 0 && dy === 0) return free;
607
+ const step = Math.PI / 4;
608
+ const ang = Math.round(Math.atan2(dy, dx) / step) * step;
609
+ const ux = Math.cos(ang);
610
+ const uy = Math.sin(ang);
611
+ const len = dx * ux + dy * uy;
612
+ return h.coordAt(f.x + len * ux, f.y + len * uy) ?? free;
613
+ };
614
+ const snapToSquare = (fixed, x, y) => {
615
+ const h = handleRef.current;
616
+ if (!h) return null;
617
+ const free = h.coordAt(x, y);
618
+ if (!shiftHeld || !free) return free;
619
+ const f = h.project(fixed.timeMs, fixed.price);
620
+ if (!f) return free;
621
+ const dx = x - f.x;
622
+ const dy = y - f.y;
623
+ const side = Math.max(Math.abs(dx), Math.abs(dy));
624
+ if (side === 0) return free;
625
+ const nx = f.x + (dx < 0 ? -side : side);
626
+ const ny = f.y + (dy < 0 ? -side : side);
627
+ return h.coordAt(nx, ny) ?? free;
628
+ };
333
629
  const regionAt = (x, y) => {
334
630
  const h = handleRef.current;
335
631
  const r = el.getBoundingClientRect();
@@ -396,61 +692,128 @@ function useGestures(containerRef, handleRef, scheduleRender, opts) {
396
692
  scheduleRender();
397
693
  reportCrosshair("hide");
398
694
  };
399
- const drawActive = () => optsRef.current.mode === "draw" && optsRef.current.tool === "line";
695
+ const drawActive = () => optsRef.current.mode === "draw" && (optsRef.current.tool === "line" || optsRef.current.tool === "box" || optsRef.current.tool === "pencil");
696
+ const drawIsBox = () => optsRef.current.tool === "box";
697
+ const drawIsPencil = () => optsRef.current.tool === "pencil";
698
+ const snapMoving = (isBox, fixed, x, y) => isBox ? snapToSquare(fixed, x, y) : snapToLine(fixed, x, y);
400
699
  const updateGuideline = (x, y) => {
401
700
  const h = handleRef.current;
402
701
  if (!h) return;
403
- if (!drawAnchorRef.current || drawSelectedPxRef.current) return;
404
- const c = h.coordAt(x, y);
405
- if (!c) return;
406
702
  const a = drawAnchorRef.current;
407
- h.setDraft(a.timeMs, a.price, true, c.timeMs, c.price, true, DRAW_COLOR, DRAW_WIDTH);
703
+ if (!a) return;
704
+ const isBox = drawIsBox();
705
+ const c = snapMoving(isBox, a, x, y);
706
+ if (!c) return;
707
+ h.setDraft(a.timeMs, a.price, true, c.timeMs, c.price, true, DRAW_COLOR, DRAW_WIDTH, isBox ? 1 : 0);
408
708
  scheduleRender();
409
709
  };
410
710
  const handleDrawClick = (x, y) => {
411
711
  const h = handleRef.current;
412
712
  if (!h) return;
413
- const o = optsRef.current;
414
- const sel = drawSelectedPxRef.current;
415
- if (sel) {
416
- if (distToSegment(x, y, sel.ax, sel.ay, sel.bx, sel.by) <= DRAW_HIT) return;
417
- drawAnchorRef.current = null;
418
- drawAnchorPxRef.current = null;
419
- drawSelectedPxRef.current = null;
420
- h.clearDraft();
421
- scheduleRender();
422
- el.style.cursor = "";
423
- o.onRequestMode?.("pan");
424
- return;
425
- }
426
- const coord = h.coordAt(x, y);
427
- if (!coord) return;
713
+ const isBox = drawIsBox();
428
714
  if (!drawAnchorRef.current) {
715
+ const coord = h.coordAt(x, y);
716
+ if (!coord) return;
429
717
  drawAnchorRef.current = coord;
430
- drawAnchorPxRef.current = { x, y };
431
- h.setDraft(coord.timeMs, coord.price, false, 0, 0, true, DRAW_COLOR, DRAW_WIDTH);
718
+ h.setDraft(coord.timeMs, coord.price, false, 0, 0, true, DRAW_COLOR, DRAW_WIDTH, isBox ? 1 : 0);
432
719
  scheduleRender();
433
720
  } else {
434
721
  const a = drawAnchorRef.current;
435
- const apx = drawAnchorPxRef.current;
436
- o.onDrawingComplete?.({
722
+ const coord = snapMoving(isBox, a, x, y);
723
+ if (!coord) return;
724
+ optsRef.current.onDrawingComplete?.({
437
725
  id: drawingId(),
438
- type: "line",
726
+ type: isBox ? "box" : "line",
439
727
  points: [
440
728
  { timeMs: a.timeMs, price: a.price },
441
729
  { timeMs: coord.timeMs, price: coord.price }
442
730
  ]
443
731
  });
444
- drawSelectedPxRef.current = { ax: apx.x, ay: apx.y, bx: x, by: y };
445
- h.setDraft(a.timeMs, a.price, true, coord.timeMs, coord.price, false, DRAW_COLOR, DRAW_WIDTH);
732
+ drawAnchorRef.current = null;
733
+ h.clearDraft();
446
734
  scheduleRender();
447
735
  }
448
736
  };
737
+ const pencilStart = (x, y) => {
738
+ const h = handleRef.current;
739
+ if (!h) return;
740
+ const c = h.coordAt(x, y);
741
+ if (!c) return;
742
+ pencilRef.current = { pts: [c], px: [{ x, y }] };
743
+ h.startDraftStroke(DRAW_COLOR, DRAW_WIDTH);
744
+ h.appendDraftPoint(c.timeMs, c.price);
745
+ scheduleRender();
746
+ };
747
+ const pencilMove = (x, y) => {
748
+ const h = handleRef.current;
749
+ const s = pencilRef.current;
750
+ if (!h || !s) return;
751
+ const last = s.px[s.px.length - 1];
752
+ if (Math.hypot(x - last.x, y - last.y) < PENCIL_MIN_DIST) return;
753
+ const c = h.coordAt(x, y);
754
+ if (!c) return;
755
+ s.pts.push(c);
756
+ s.px.push({ x, y });
757
+ h.appendDraftPoint(c.timeMs, c.price);
758
+ scheduleRender();
759
+ };
760
+ const priceDecimals = () => {
761
+ const h = handleRef.current;
762
+ const el2 = containerRef.current;
763
+ if (!h || !el2) return 6;
764
+ const rect = el2.getBoundingClientRect();
765
+ const m = h.getAxisMetrics();
766
+ const priceBottom = rect.height - m.xAxisHeight - m.indicatorHeight;
767
+ if (priceBottom <= 0) return 6;
768
+ const top = h.coordAt(rect.width / 2, 0);
769
+ const bot = h.coordAt(rect.width / 2, priceBottom);
770
+ if (!top || !bot || top.price <= bot.price) return 6;
771
+ const pricePerPx = (top.price - bot.price) / priceBottom;
772
+ const d = Math.ceil(-Math.log10(pricePerPx / 100));
773
+ return Math.max(0, Math.min(12, Number.isFinite(d) ? d : 6));
774
+ };
775
+ const roundPoints = (pts) => {
776
+ const dp = priceDecimals();
777
+ return pts.map((p) => ({
778
+ timeMs: Math.round(p.timeMs),
779
+ price: Number(p.price.toFixed(dp))
780
+ }));
781
+ };
782
+ const pencilEnd = () => {
783
+ const h = handleRef.current;
784
+ const s = pencilRef.current;
785
+ pencilRef.current = null;
786
+ if (!h) return;
787
+ h.clearDraft();
788
+ scheduleRender();
789
+ if (!s || s.pts.length < 2) return;
790
+ const keep = simplifyIndices(s.px, PENCIL_EPSILON);
791
+ if (keep.length < 2) return;
792
+ const stroke = makeDrawing(
793
+ { id: drawingId() },
794
+ "pencil",
795
+ roundPoints(keep.map((i) => s.pts[i]))
796
+ );
797
+ if (stroke) optsRef.current.onDrawingComplete?.(stroke);
798
+ };
799
+ const moveGrab = (x, y) => {
800
+ const h = handleRef.current;
801
+ const g = grabRef.current;
802
+ if (!h || !g) return;
803
+ const c = snapMoving(g.isBox, g.fixed, x, y);
804
+ if (!c) return;
805
+ lastCoordRef.current = c;
806
+ h.moveDrawingEndpoint(g.index, g.endpoint, c.timeMs, c.price);
807
+ scheduleRender();
808
+ };
449
809
  const onPointerDown = (e) => {
450
810
  const h = handleRef.current;
451
811
  if (!h) return;
452
812
  if (e.pointerType === "mouse" && e.button !== 0) return;
453
813
  const { x, y } = rel(e);
814
+ shiftHeld = e.shiftKey;
815
+ lastX = x;
816
+ lastY = y;
454
817
  el.setPointerCapture(e.pointerId);
455
818
  pointers.set(e.pointerId, { x, y });
456
819
  if (pointers.size === 2) {
@@ -472,6 +835,66 @@ function useGestures(containerRef, handleRef, scheduleRender, opts) {
472
835
  downY = y;
473
836
  moved = false;
474
837
  panMode = regionAt(x, y);
838
+ if (drawIsPencil() && drawActive() && panMode === "chart") {
839
+ pencilStart(x, y);
840
+ return;
841
+ }
842
+ downHitRef.current = null;
843
+ if (!drawActive() && panMode === "chart") {
844
+ const hit = h.hitTestDrawing(x, y);
845
+ const gd = hit ? (optsRef.current.drawings ?? [])[hit.index] : void 0;
846
+ if (hit && gd && gd.type === "box" && hit.part <= 3) {
847
+ const corners = boxCorners(gd.points);
848
+ const grabbed = corners[hit.part];
849
+ const diagonal = corners[(hit.part + 2) % 4];
850
+ h.moveDrawingEndpoint(hit.index, 0, grabbed.timeMs, grabbed.price);
851
+ h.moveDrawingEndpoint(hit.index, 1, diagonal.timeMs, diagonal.price);
852
+ grabRef.current = { index: hit.index, endpoint: 0, fixed: diagonal, isBox: true };
853
+ lastCoordRef.current = grabbed;
854
+ applySelection();
855
+ return;
856
+ }
857
+ if (hit && gd && gd.type === "line" && hit.part <= 1) {
858
+ grabRef.current = {
859
+ index: hit.index,
860
+ endpoint: hit.part,
861
+ fixed: gd.points[hit.part === 0 ? 1 : 0],
862
+ // the other end stays put
863
+ isBox: false
864
+ };
865
+ lastCoordRef.current = null;
866
+ applySelection();
867
+ return;
868
+ }
869
+ const isBody = hit != null && gd != null && hit.part === bodyPart(gd.type);
870
+ if (hit && isBody) {
871
+ const list = optsRef.current.drawings ?? [];
872
+ const selIdx = selectedIdRef.current ? list.findIndex((d) => d.id === selectedIdRef.current) : -1;
873
+ if (hit.index === selIdx && selIdx >= 0) {
874
+ const start = h.coordAt(x, y);
875
+ const d = list[selIdx];
876
+ if (start && d) {
877
+ const first = d.points[0];
878
+ const last = d.points[d.points.length - 1];
879
+ dragLineRef.current = {
880
+ index: selIdx,
881
+ startTime: start.timeMs,
882
+ startPrice: start.price,
883
+ a0: first,
884
+ b0: last,
885
+ lastA: first,
886
+ lastB: last,
887
+ // A stroke can't be restated cheaply each frame, so it keeps its
888
+ // original points and is nudged with relative translate calls.
889
+ ...d.type === "pencil" ? { points0: d.points, appliedTime: 0, appliedPrice: 0 } : {}
890
+ };
891
+ el.style.cursor = "move";
892
+ return;
893
+ }
894
+ }
895
+ }
896
+ downHitRef.current = hit;
897
+ }
475
898
  if (e.pointerType !== "mouse" && panMode === "chart" && !drawActive()) {
476
899
  longPressTimer = setTimeout(() => {
477
900
  longPressTimer = null;
@@ -483,6 +906,9 @@ function useGestures(containerRef, handleRef, scheduleRender, opts) {
483
906
  const h = handleRef.current;
484
907
  if (!h) return;
485
908
  const { x, y } = rel(e);
909
+ shiftHeld = e.shiftKey;
910
+ lastX = x;
911
+ lastY = y;
486
912
  if (!pointers.has(e.pointerId)) {
487
913
  if (e.pointerType === "mouse" && pointers.size === 0) {
488
914
  if (drawActive()) {
@@ -504,6 +930,35 @@ function useGestures(containerRef, handleRef, scheduleRender, opts) {
504
930
  const dx = x - prev.x;
505
931
  const dy = y - prev.y;
506
932
  pointers.set(e.pointerId, { x, y });
933
+ if (pencilRef.current) {
934
+ pencilMove(x, y);
935
+ return;
936
+ }
937
+ if (grabRef.current) {
938
+ moveGrab(x, y);
939
+ return;
940
+ }
941
+ if (dragLineRef.current) {
942
+ const g = dragLineRef.current;
943
+ const c = h.coordAt(x, y);
944
+ if (c) {
945
+ const dT = c.timeMs - g.startTime;
946
+ const dP = c.price - g.startPrice;
947
+ if (g.points0) {
948
+ h.translateDrawing(g.index, dT - (g.appliedTime ?? 0), dP - (g.appliedPrice ?? 0));
949
+ g.appliedTime = dT;
950
+ g.appliedPrice = dP;
951
+ } else {
952
+ g.lastA = { timeMs: g.a0.timeMs + dT, price: g.a0.price + dP };
953
+ g.lastB = { timeMs: g.b0.timeMs + dT, price: g.b0.price + dP };
954
+ h.moveDrawingEndpoint(g.index, 0, g.lastA.timeMs, g.lastA.price);
955
+ h.moveDrawingEndpoint(g.index, 1, g.lastB.timeMs, g.lastB.price);
956
+ }
957
+ if (!moved && Math.hypot(x - downX, y - downY) > MOVE_THRESH) moved = true;
958
+ scheduleRender();
959
+ }
960
+ return;
961
+ }
507
962
  if (pointers.size >= 2 && pinch.active && !drawActive()) {
508
963
  const pts = [...pointers.values()];
509
964
  const focalX = (pts[0].x + pts[1].x) / 2;
@@ -552,15 +1007,75 @@ function useGestures(containerRef, handleRef, scheduleRender, opts) {
552
1007
  scheduleRender();
553
1008
  };
554
1009
  const endPointer = (e) => {
1010
+ shiftHeld = e.shiftKey;
555
1011
  const had = pointers.delete(e.pointerId);
556
1012
  clearLongPress();
557
1013
  if (pointers.size < 2) pinch.active = false;
558
1014
  if (!had) return;
1015
+ if (pencilRef.current && pointers.size === 0) {
1016
+ pencilEnd();
1017
+ el.style.cursor = "crosshair";
1018
+ return;
1019
+ }
1020
+ if (grabRef.current && pointers.size === 0) {
1021
+ const g = grabRef.current;
1022
+ grabRef.current = null;
1023
+ const list = optsRef.current.drawings ?? [];
1024
+ const d = list[g.index];
1025
+ const c = lastCoordRef.current;
1026
+ if (d && c) {
1027
+ const movedPt = { timeMs: c.timeMs, price: c.price };
1028
+ const pts = g.isBox ? [movedPt, g.fixed] : [d.points[0], d.points[1]];
1029
+ if (!g.isBox) pts[g.endpoint] = movedPt;
1030
+ const next = makeDrawing({ id: d.id, color: d.color, width: d.width }, d.type, pts);
1031
+ if (next) optsRef.current.onDrawingChange?.(next);
1032
+ }
1033
+ applySelection();
1034
+ el.style.cursor = "";
1035
+ return;
1036
+ }
1037
+ if (dragLineRef.current && pointers.size === 0) {
1038
+ const g = dragLineRef.current;
1039
+ dragLineRef.current = null;
1040
+ if (moved) {
1041
+ const list = optsRef.current.drawings ?? [];
1042
+ const d = list[g.index];
1043
+ if (d) {
1044
+ const pts = g.points0 ? roundPoints(
1045
+ g.points0.map((p) => ({
1046
+ timeMs: p.timeMs + (g.appliedTime ?? 0),
1047
+ price: p.price + (g.appliedPrice ?? 0)
1048
+ }))
1049
+ ) : [g.lastA, g.lastB];
1050
+ const next = makeDrawing({ id: d.id, color: d.color, width: d.width }, d.type, pts);
1051
+ if (next) optsRef.current.onDrawingChange?.(next);
1052
+ }
1053
+ }
1054
+ applySelection();
1055
+ el.style.cursor = "";
1056
+ return;
1057
+ }
559
1058
  if (drawActive() && pointers.size === 0) {
560
1059
  if (!moved && panMode === "chart") handleDrawClick(downX, downY);
561
1060
  else el.style.cursor = "crosshair";
562
1061
  return;
563
1062
  }
1063
+ if (pointers.size === 0 && !moved && panMode === "chart" && crosshairSource !== "press") {
1064
+ const hit = downHitRef.current;
1065
+ const list = optsRef.current.drawings ?? [];
1066
+ const hitDrawing = hit ? list[hit.index] : void 0;
1067
+ const isBodyHit = hit != null && hitDrawing != null && hit.part === bodyPart(hitDrawing.type);
1068
+ if (hit && isBodyHit && hitDrawing) {
1069
+ selectedTRef.current = hit.t;
1070
+ if (selectedIdRef.current !== hitDrawing.id) {
1071
+ selectedIdRef.current = hitDrawing.id;
1072
+ applySelection();
1073
+ }
1074
+ } else if (!hit && selectedIdRef.current != null) {
1075
+ selectedIdRef.current = null;
1076
+ applySelection();
1077
+ }
1078
+ }
564
1079
  if (crosshairSource === "press" && pointers.size === 0) {
565
1080
  hideCrosshair();
566
1081
  } else if (moved && (panMode === "chart" || panMode === "indicator")) {
@@ -591,12 +1106,22 @@ function useGestures(containerRef, handleRef, scheduleRender, opts) {
591
1106
  }
592
1107
  scheduleRender();
593
1108
  };
1109
+ const onShiftKey = (e) => {
1110
+ if (e.key !== "Shift") return;
1111
+ const held = e.type === "keydown";
1112
+ if (held === shiftHeld) return;
1113
+ shiftHeld = held;
1114
+ if (drawAnchorRef.current) updateGuideline(lastX, lastY);
1115
+ else if (grabRef.current) moveGrab(lastX, lastY);
1116
+ };
594
1117
  el.addEventListener("pointerdown", onPointerDown);
595
1118
  el.addEventListener("pointermove", onPointerMove);
596
1119
  el.addEventListener("pointerup", endPointer);
597
1120
  el.addEventListener("pointercancel", endPointer);
598
1121
  el.addEventListener("pointerleave", onPointerLeave);
599
1122
  el.addEventListener("wheel", onWheel, { passive: false });
1123
+ window.addEventListener("keydown", onShiftKey);
1124
+ window.addEventListener("keyup", onShiftKey);
600
1125
  return () => {
601
1126
  clearLongPress();
602
1127
  el.removeEventListener("pointerdown", onPointerDown);
@@ -605,10 +1130,187 @@ function useGestures(containerRef, handleRef, scheduleRender, opts) {
605
1130
  el.removeEventListener("pointercancel", endPointer);
606
1131
  el.removeEventListener("pointerleave", onPointerLeave);
607
1132
  el.removeEventListener("wheel", onWheel);
1133
+ window.removeEventListener("keydown", onShiftKey);
1134
+ window.removeEventListener("keyup", onShiftKey);
608
1135
  };
609
1136
  }, [containerRef, handleRef, scheduleRender]);
610
1137
  }
611
1138
 
1139
+ // src/useManagedDrawings.ts
1140
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef3, useState as useState2 } from "react";
1141
+
1142
+ // src/drawingStorage.ts
1143
+ var DRAWINGS_VERSION = 1;
1144
+ var KNOWN_TYPES = /* @__PURE__ */ new Set(["line", "box", "pencil"]);
1145
+ var FIXED_POINT_TYPES = /* @__PURE__ */ new Set(["line", "box"]);
1146
+ function isPoint(p) {
1147
+ if (p == null || typeof p !== "object") return false;
1148
+ const { timeMs, price } = p;
1149
+ return typeof timeMs === "number" && Number.isFinite(timeMs) && typeof price === "number" && Number.isFinite(price);
1150
+ }
1151
+ function isValidDrawing(d) {
1152
+ if (d == null || typeof d !== "object") return false;
1153
+ const { type, points } = d;
1154
+ if (typeof type !== "string" || !KNOWN_TYPES.has(type)) {
1155
+ return false;
1156
+ }
1157
+ if (!Array.isArray(points)) return false;
1158
+ if (FIXED_POINT_TYPES.has(type) ? points.length !== 2 : points.length < 2) {
1159
+ return false;
1160
+ }
1161
+ return points.every(isPoint);
1162
+ }
1163
+ var MIGRATIONS = {};
1164
+ function serializeDrawings(drawings) {
1165
+ const env = { v: DRAWINGS_VERSION, drawings };
1166
+ return JSON.stringify(env);
1167
+ }
1168
+ function deserializeDrawings(raw) {
1169
+ if (raw == null || raw === "") return [];
1170
+ let parsed;
1171
+ try {
1172
+ parsed = JSON.parse(raw);
1173
+ } catch {
1174
+ return [];
1175
+ }
1176
+ let env;
1177
+ if (Array.isArray(parsed)) {
1178
+ env = { v: 0, drawings: parsed };
1179
+ } else if (parsed != null && typeof parsed === "object") {
1180
+ const obj = parsed;
1181
+ env = {
1182
+ v: typeof obj.v === "number" ? obj.v : 0,
1183
+ drawings: Array.isArray(obj.drawings) ? obj.drawings : []
1184
+ };
1185
+ } else {
1186
+ return [];
1187
+ }
1188
+ let guard = 0;
1189
+ while (env.v < DRAWINGS_VERSION && guard++ < 100) {
1190
+ const up = MIGRATIONS[env.v];
1191
+ if (!up) break;
1192
+ env = up(env);
1193
+ }
1194
+ return (Array.isArray(env.drawings) ? env.drawings : []).filter(isValidDrawing);
1195
+ }
1196
+
1197
+ // src/useManagedDrawings.ts
1198
+ var SAVE_DEBOUNCE_MS = 400;
1199
+ function useManagedDrawings(seriesKey, store) {
1200
+ const [drawings, setDrawings] = useState2([]);
1201
+ const drawingsRef = useRef3([]);
1202
+ const storeRef = useRef3(store);
1203
+ storeRef.current = store;
1204
+ const loadedKeyRef = useRef3(void 0);
1205
+ const loadTokenRef = useRef3(0);
1206
+ const saveTimerRef = useRef3(null);
1207
+ const warnedRef = useRef3(false);
1208
+ const setBoth = (list) => {
1209
+ drawingsRef.current = list;
1210
+ setDrawings(list);
1211
+ };
1212
+ const flushSave = useCallback3((key, list) => {
1213
+ if (saveTimerRef.current != null) {
1214
+ clearTimeout(saveTimerRef.current);
1215
+ saveTimerRef.current = null;
1216
+ }
1217
+ const s = storeRef.current;
1218
+ if (!s || key == null) return;
1219
+ try {
1220
+ const r = s.save(key, serializeDrawings(list));
1221
+ if (r && typeof r.catch === "function") {
1222
+ r.catch(() => {
1223
+ });
1224
+ }
1225
+ } catch {
1226
+ }
1227
+ }, []);
1228
+ const scheduleSave = useCallback3(() => {
1229
+ const key = seriesKey;
1230
+ if (!storeRef.current || key == null) return;
1231
+ if (saveTimerRef.current != null) clearTimeout(saveTimerRef.current);
1232
+ saveTimerRef.current = setTimeout(() => {
1233
+ saveTimerRef.current = null;
1234
+ flushSave(key, drawingsRef.current);
1235
+ }, SAVE_DEBOUNCE_MS);
1236
+ }, [seriesKey, flushSave]);
1237
+ useEffect3(() => {
1238
+ const s = storeRef.current;
1239
+ if (!s) return;
1240
+ if (seriesKey == null) {
1241
+ if (!warnedRef.current && typeof console !== "undefined") {
1242
+ console.warn(
1243
+ "[vroom] drawingStore is set but seriesKey is missing \u2014 drawings will render but not persist."
1244
+ );
1245
+ warnedRef.current = true;
1246
+ }
1247
+ setBoth([]);
1248
+ loadedKeyRef.current = void 0;
1249
+ return;
1250
+ }
1251
+ const token = ++loadTokenRef.current;
1252
+ const result = s.load(seriesKey);
1253
+ if (result && typeof result.then === "function") {
1254
+ setBoth([]);
1255
+ loadedKeyRef.current = void 0;
1256
+ result.then((raw) => {
1257
+ if (token !== loadTokenRef.current) return;
1258
+ loadedKeyRef.current = seriesKey;
1259
+ setBoth(deserializeDrawings(raw));
1260
+ }).catch(() => {
1261
+ if (token !== loadTokenRef.current) return;
1262
+ loadedKeyRef.current = seriesKey;
1263
+ setBoth([]);
1264
+ });
1265
+ } else {
1266
+ loadedKeyRef.current = seriesKey;
1267
+ setBoth(deserializeDrawings(result));
1268
+ }
1269
+ return () => {
1270
+ if (loadedKeyRef.current === seriesKey) {
1271
+ flushSave(seriesKey, drawingsRef.current);
1272
+ } else if (saveTimerRef.current != null) {
1273
+ clearTimeout(saveTimerRef.current);
1274
+ saveTimerRef.current = null;
1275
+ }
1276
+ };
1277
+ }, [seriesKey, flushSave]);
1278
+ const onDrawingComplete = useCallback3(
1279
+ (d) => {
1280
+ setDrawings((p) => {
1281
+ const next = [...p, d];
1282
+ drawingsRef.current = next;
1283
+ return next;
1284
+ });
1285
+ scheduleSave();
1286
+ },
1287
+ [scheduleSave]
1288
+ );
1289
+ const onDrawingChange = useCallback3(
1290
+ (d) => {
1291
+ setDrawings((p) => {
1292
+ const next = p.map((x) => x.id === d.id ? d : x);
1293
+ drawingsRef.current = next;
1294
+ return next;
1295
+ });
1296
+ scheduleSave();
1297
+ },
1298
+ [scheduleSave]
1299
+ );
1300
+ const onDrawingDelete = useCallback3(
1301
+ (id) => {
1302
+ setDrawings((p) => {
1303
+ const next = p.filter((x) => x.id !== id);
1304
+ drawingsRef.current = next;
1305
+ return next;
1306
+ });
1307
+ scheduleSave();
1308
+ },
1309
+ [scheduleSave]
1310
+ );
1311
+ return { drawings, onDrawingComplete, onDrawingChange, onDrawingDelete };
1312
+ }
1313
+
612
1314
  // src/VroomChart.tsx
613
1315
  import { jsx } from "react/jsx-runtime";
614
1316
  var FILL = { position: "relative", width: "100%", height: "100%" };
@@ -628,13 +1330,21 @@ function VroomChart(props) {
628
1330
  crosshairOverride,
629
1331
  mode,
630
1332
  tool,
1333
+ drawings,
631
1334
  onCrosshair,
632
1335
  onViewportChange,
633
1336
  onDrawingComplete,
634
- onModeChange
1337
+ onDrawingChange,
1338
+ onDrawingDelete,
1339
+ onModeChange,
1340
+ drawingStore,
1341
+ seriesKey
635
1342
  } = props;
1343
+ const managed = useManagedDrawings(seriesKey, drawingStore);
1344
+ const stored = drawingStore != null;
1345
+ const effectiveDrawings = stored ? managed.drawings : drawings;
636
1346
  const { containerRef, canvasRef, handleRef, scheduleRender } = useChartCore(
637
- props,
1347
+ stored ? { ...props, drawings: effectiveDrawings } : props,
638
1348
  wasm ? { wasm } : void 0
639
1349
  );
640
1350
  useGestures(containerRef, handleRef, scheduleRender, {
@@ -642,9 +1352,12 @@ function VroomChart(props) {
642
1352
  crosshairOverride,
643
1353
  mode,
644
1354
  tool,
1355
+ drawings: effectiveDrawings,
645
1356
  onCrosshair,
646
1357
  onViewportChange,
647
- onDrawingComplete,
1358
+ onDrawingComplete: stored ? managed.onDrawingComplete : onDrawingComplete,
1359
+ onDrawingChange: stored ? managed.onDrawingChange : onDrawingChange,
1360
+ onDrawingDelete: stored ? managed.onDrawingDelete : onDrawingDelete,
648
1361
  onRequestMode: onModeChange
649
1362
  });
650
1363
  const rootStyle = {
@@ -656,9 +1369,12 @@ function VroomChart(props) {
656
1369
  return /* @__PURE__ */ jsx("div", { ref: containerRef, className, style: rootStyle, children: /* @__PURE__ */ jsx("canvas", { ref: canvasRef, style: CANVAS_STYLE }) });
657
1370
  }
658
1371
  export {
1372
+ DRAWINGS_VERSION,
659
1373
  VroomChart,
660
1374
  classifyTransition,
1375
+ deserializeDrawings,
661
1376
  inferStepMs,
1377
+ serializeDrawings,
662
1378
  timeframeWindow
663
1379
  };
664
1380
  //# sourceMappingURL=index.js.map