hyperprop-charting-library 0.1.142 → 0.1.143

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.
@@ -335,26 +335,45 @@ var getSeriesFingerprint = (data) => {
335
335
  var withCachedSeries = (key, data, compute) => {
336
336
  const fingerprint = getSeriesFingerprint(data);
337
337
  const existing = builtInSeriesCache.get(key);
338
- if (existing && existing.fingerprint === fingerprint) {
339
- return existing.values;
338
+ const shape = getSeriesShape(data);
339
+ if (existing) {
340
+ const unchanged = existing.fingerprint === fingerprint;
341
+ const liveTickWithinThrottle = !unchanged && indicatorLiveThrottleMs > 0 && existing.shape === shape && Date.now() - existing.computedAt < indicatorLiveThrottleMs;
342
+ if (unchanged || liveTickWithinThrottle) {
343
+ return existing.values;
344
+ }
340
345
  }
341
346
  const values = compute();
342
- builtInSeriesCache.set(key, { fingerprint, values });
347
+ builtInSeriesCache.set(key, { fingerprint, values, shape, computedAt: Date.now() });
343
348
  return values;
344
349
  };
345
350
  var builtInComputationCache = /* @__PURE__ */ new Map();
351
+ var indicatorLiveThrottleMs = 80;
352
+ var setIndicatorLiveThrottleMs = (ms) => {
353
+ indicatorLiveThrottleMs = Math.max(0, ms);
354
+ };
355
+ var getSeriesShape = (data) => {
356
+ const length = data.length;
357
+ const last = length > 0 ? data[length - 1] : void 0;
358
+ return last ? `${length}|${last.time.getTime()}` : "empty";
359
+ };
346
360
  var COMPUTATION_CACHE_MAX_ENTRIES = 128;
347
361
  var withCachedComputation = (key, data, compute) => {
348
362
  const fingerprint = getSeriesFingerprint(data);
349
363
  const existing = builtInComputationCache.get(key);
350
- if (existing && existing.fingerprint === fingerprint) {
351
- builtInComputationCache.delete(key);
352
- builtInComputationCache.set(key, existing);
353
- return existing.value;
364
+ const shape = getSeriesShape(data);
365
+ if (existing) {
366
+ const unchanged = existing.fingerprint === fingerprint;
367
+ const liveTickWithinThrottle = !unchanged && indicatorLiveThrottleMs > 0 && existing.shape === shape && Date.now() - existing.computedAt < indicatorLiveThrottleMs;
368
+ if (unchanged || liveTickWithinThrottle) {
369
+ builtInComputationCache.delete(key);
370
+ builtInComputationCache.set(key, existing);
371
+ return existing.value;
372
+ }
354
373
  }
355
374
  const value = compute();
356
375
  builtInComputationCache.delete(key);
357
- builtInComputationCache.set(key, { fingerprint, value });
376
+ builtInComputationCache.set(key, { fingerprint, value, shape, computedAt: Date.now() });
358
377
  while (builtInComputationCache.size > COMPUTATION_CACHE_MAX_ENTRIES) {
359
378
  const oldest = builtInComputationCache.keys().next().value;
360
379
  if (oldest === void 0) break;
@@ -2924,7 +2943,23 @@ var DEFAULT_OPTIONS = {
2924
2943
  accessibility: { label: "Price chart", description: "", focusable: true },
2925
2944
  downsampling: { enabled: true, thresholdPx: 1.5 },
2926
2945
  touch: { hitToleranceScale: 2.2, longPressTooltip: true, longPressMs: 400 },
2946
+ dataLine: {
2947
+ visible: false,
2948
+ symbol: "",
2949
+ interval: "",
2950
+ exchange: "",
2951
+ showOhlc: true,
2952
+ showChange: true,
2953
+ showVolume: false,
2954
+ details: [],
2955
+ statusColor: "",
2956
+ fontSize: 0,
2957
+ symbolColor: "",
2958
+ textColor: ""
2959
+ },
2927
2960
  datafeedOptions: { prefetchThresholdBars: 150, cooldownMs: 1200, chunkBars: 1500 },
2961
+ animation: { viewportTransitions: true, durationMs: 260 },
2962
+ indicatorUpdate: { liveThrottleMs: 80 },
2928
2963
  crosshair: DEFAULT_CROSSHAIR_OPTIONS,
2929
2964
  grid: DEFAULT_GRID_OPTIONS,
2930
2965
  watermark: DEFAULT_WATERMARK_OPTIONS,
@@ -2983,6 +3018,18 @@ var mergeChartOptions = (baseOptions, options = {}) => ({
2983
3018
  ...baseOptions.touch,
2984
3019
  ...options.touch ?? {}
2985
3020
  },
3021
+ dataLine: {
3022
+ ...baseOptions.dataLine,
3023
+ ...options.dataLine ?? {}
3024
+ },
3025
+ animation: {
3026
+ ...baseOptions.animation,
3027
+ ...options.animation ?? {}
3028
+ },
3029
+ indicatorUpdate: {
3030
+ ...baseOptions.indicatorUpdate,
3031
+ ...options.indicatorUpdate ?? {}
3032
+ },
2986
3033
  datafeedOptions: {
2987
3034
  ...baseOptions.datafeedOptions,
2988
3035
  ...options.datafeedOptions ?? {}
@@ -3242,6 +3289,7 @@ function createChart(element, options = {}) {
3242
3289
  let crosshairPoint = null;
3243
3290
  let doubleClickEnabled = mergedOptions.doubleClickEnabled;
3244
3291
  let doubleClickAction = mergedOptions.doubleClickAction;
3292
+ setIndicatorLiveThrottleMs(mergedOptions.indicatorUpdate?.liveThrottleMs ?? 80);
3245
3293
  let noOverlappingLineLabels = DEFAULT_LABELS_OPTIONS.noOverlapping;
3246
3294
  let rightAxisLabelSlots = [];
3247
3295
  let plotLabelSlots = [];
@@ -3483,21 +3531,69 @@ function createChart(element, options = {}) {
3483
3531
  Math.min(highCenter, count + maxPanBars)
3484
3532
  );
3485
3533
  };
3486
- const fitXViewport = () => {
3487
- const count = data.length;
3488
- if (count === 0) {
3489
- xCenter = 0;
3490
- xSpan = 60;
3491
- return;
3534
+ let viewportTweenRaf = null;
3535
+ const cancelViewportTween = () => {
3536
+ if (viewportTweenRaf !== null) {
3537
+ cancelAnimationFrame(viewportTweenRaf);
3538
+ viewportTweenRaf = null;
3539
+ }
3540
+ };
3541
+ const prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3542
+ const animateViewportTo = (targetCenter, targetSpan, onComplete) => {
3543
+ const animation = mergedOptions.animation ?? DEFAULT_OPTIONS.animation;
3544
+ const duration = Math.max(0, animation?.durationMs ?? 260);
3545
+ const startCenter = xCenter;
3546
+ const startSpan = xSpan;
3547
+ const centerDelta = targetCenter - startCenter;
3548
+ const spanRatio = startSpan > 0 ? targetSpan / startSpan : 1;
3549
+ const negligible = Math.abs(centerDelta) < 0.5 && Math.abs(spanRatio - 1) < 0.01;
3550
+ if (animation?.viewportTransitions === false || duration === 0 || negligible || prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
3551
+ xCenter = targetCenter;
3552
+ xSpan = targetSpan;
3553
+ clampXViewport();
3554
+ onComplete?.();
3555
+ emitViewportChange();
3556
+ scheduleDraw();
3557
+ return false;
3492
3558
  }
3559
+ cancelViewportTween();
3560
+ const startedAt = performance.now();
3561
+ const step = (now) => {
3562
+ const t = clamp((now - startedAt) / duration, 0, 1);
3563
+ const eased = 1 - Math.pow(1 - t, 3);
3564
+ xCenter = startCenter + centerDelta * eased;
3565
+ xSpan = startSpan * Math.pow(spanRatio, eased);
3566
+ clampXViewport();
3567
+ scheduleDraw();
3568
+ if (t < 1) {
3569
+ viewportTweenRaf = requestAnimationFrame(step);
3570
+ return;
3571
+ }
3572
+ viewportTweenRaf = null;
3573
+ xCenter = targetCenter;
3574
+ xSpan = targetSpan;
3575
+ clampXViewport();
3576
+ onComplete?.();
3577
+ emitViewportChange();
3578
+ scheduleDraw();
3579
+ };
3580
+ viewportTweenRaf = requestAnimationFrame(step);
3581
+ return true;
3582
+ };
3583
+ const getFitXTarget = () => {
3584
+ const count = data.length;
3585
+ if (count === 0) return { center: 0, span: 60 };
3493
3586
  const maxSpan = Math.min(maxVisibleBars, Math.max(minVisibleBars, count + maxPanBars * 2));
3494
- const requestedVisibleBars = clamp(Math.floor(mergedOptions.initialVisibleBars), minVisibleBars, maxSpan);
3495
- xSpan = requestedVisibleBars;
3496
- if (mergedOptions.initialViewport === "center") {
3497
- xCenter = count / 2;
3498
- } else {
3499
- xCenter = count - xSpan / 2 + rightEdgePaddingBars;
3500
- }
3587
+ const span = clamp(Math.floor(mergedOptions.initialVisibleBars), minVisibleBars, maxSpan);
3588
+ return {
3589
+ center: mergedOptions.initialViewport === "center" ? count / 2 : count - span / 2 + rightEdgePaddingBars,
3590
+ span
3591
+ };
3592
+ };
3593
+ const fitXViewport = () => {
3594
+ const target = getFitXTarget();
3595
+ xSpan = target.span;
3596
+ xCenter = target.center;
3501
3597
  clampXViewport();
3502
3598
  };
3503
3599
  const getYBounds = () => {
@@ -6894,6 +6990,73 @@ function createChart(element, options = {}) {
6894
6990
  for (const orderLine of orderLines) {
6895
6991
  drawOrderLine(orderLine, yFromPrice, chartLeft, chartTop, chartRight, chartBottom);
6896
6992
  }
6993
+ let dataLineBottom = chartTop;
6994
+ const dataLine = mergedOptions.dataLine;
6995
+ if (dataLine?.visible && data.length > 0) {
6996
+ const hoverIndex = crosshairPoint && getHitRegion(crosshairPoint.x, crosshairPoint.y) === "plot" ? indexFromCanvasX(crosshairPoint.x) : null;
6997
+ const barIndex = hoverIndex !== null && hoverIndex >= 0 && hoverIndex < data.length ? hoverIndex : data.length - 1;
6998
+ const bar = data[barIndex];
6999
+ if (bar) {
7000
+ const prevFont = ctx.font;
7001
+ const fontSize = Math.max(9, dataLine.fontSize || axis.fontSize);
7002
+ ctx.font = `${fontSize}px ${mergedOptions.fontFamily}`;
7003
+ ctx.textBaseline = "top";
7004
+ ctx.textAlign = "left";
7005
+ const symbolColor = dataLine.symbolColor || axis.textColor;
7006
+ const textColor = dataLine.textColor || labels.indicatorTextColor;
7007
+ const changeAbs = bar.c - bar.o;
7008
+ const changeColor = changeAbs >= 0 ? mergedOptions.upColor : mergedOptions.downColor;
7009
+ const rowY = chartTop + 6;
7010
+ let cursorX = chartLeft + 10;
7011
+ if (dataLine.statusColor) {
7012
+ const dotRadius = Math.max(2.5, fontSize / 4);
7013
+ ctx.fillStyle = dataLine.statusColor;
7014
+ ctx.beginPath();
7015
+ ctx.arc(cursorX + dotRadius, rowY + fontSize / 2, dotRadius, 0, Math.PI * 2);
7016
+ ctx.fill();
7017
+ cursorX += dotRadius * 2 + 6;
7018
+ }
7019
+ const headline = [dataLine.symbol, dataLine.interval, dataLine.exchange].filter((part) => part && part.length > 0).join(" \xB7 ");
7020
+ if (headline) {
7021
+ ctx.fillStyle = symbolColor;
7022
+ ctx.fillText(headline, cursorX, rowY);
7023
+ cursorX += measureTextWidth(headline) + 10;
7024
+ }
7025
+ if (dataLine.showOhlc !== false) {
7026
+ const ohlc = `O${formatPrice(bar.o)} H${formatPrice(bar.h)} L${formatPrice(bar.l)} C${formatPrice(bar.c)}`;
7027
+ ctx.fillStyle = changeColor;
7028
+ ctx.fillText(ohlc, cursorX, rowY);
7029
+ cursorX += measureTextWidth(ohlc) + 8;
7030
+ }
7031
+ if (dataLine.showChange !== false) {
7032
+ const pct = bar.o !== 0 ? changeAbs / bar.o * 100 : 0;
7033
+ const changeText = `${changeAbs >= 0 ? "+" : ""}${formatPrice(changeAbs)} (${changeAbs >= 0 ? "+" : ""}${pct.toFixed(2)}%)`;
7034
+ ctx.fillStyle = changeColor;
7035
+ ctx.fillText(changeText, cursorX, rowY);
7036
+ cursorX += measureTextWidth(changeText) + 10;
7037
+ }
7038
+ if (dataLine.showVolume && bar.v !== void 0) {
7039
+ const volumeText = `Vol ${bar.v >= 1e6 ? `${(bar.v / 1e6).toFixed(2)}M` : bar.v >= 1e3 ? `${(bar.v / 1e3).toFixed(1)}K` : String(Math.round(bar.v))}`;
7040
+ ctx.fillStyle = textColor;
7041
+ ctx.fillText(volumeText, cursorX, rowY);
7042
+ cursorX += measureTextWidth(volumeText) + 10;
7043
+ }
7044
+ for (const detail of dataLine.details ?? []) {
7045
+ const text = detail.label ? `${detail.label} ${detail.value}` : detail.value;
7046
+ const width2 = measureTextWidth(text);
7047
+ if (cursorX + width2 > chartRight - 4) break;
7048
+ if (detail.background) {
7049
+ ctx.fillStyle = detail.background;
7050
+ fillRoundedRect(cursorX - 4, rowY - 2, width2 + 8, fontSize + 4, 3);
7051
+ }
7052
+ ctx.fillStyle = detail.color || textColor;
7053
+ ctx.fillText(text, cursorX, rowY);
7054
+ cursorX += width2 + 12;
7055
+ }
7056
+ dataLineBottom = rowY + fontSize + 4;
7057
+ ctx.font = prevFont;
7058
+ }
7059
+ }
6897
7060
  if (labels.visible && (labels.showIndicatorNames || labels.showIndicatorValues)) {
6898
7061
  const isLegendInputValue = (value) => {
6899
7062
  if (typeof value === "number" || typeof value === "boolean") {
@@ -6922,7 +7085,7 @@ function createChart(element, options = {}) {
6922
7085
  const isRight = position === "top-right" || position === "bottom-right";
6923
7086
  const isBottom = position === "bottom-left" || position === "bottom-right";
6924
7087
  const legendX = isRight ? chartRight - offsetX : chartLeft + offsetX;
6925
- const legendY = isBottom ? chartBottom - offsetY : chartTop + offsetY;
7088
+ const legendY = isBottom ? chartBottom - offsetY : Math.max(chartTop + offsetY, dataLineBottom);
6926
7089
  drawText(legendText, legendX, legendY, isRight ? "right" : "left", isBottom ? "bottom" : "top", labels.indicatorTextColor);
6927
7090
  ctx.font = prevFont;
6928
7091
  }
@@ -7463,27 +7626,24 @@ function createChart(element, options = {}) {
7463
7626
  scheduleDraw();
7464
7627
  };
7465
7628
  const fitContent = () => {
7466
- fitXViewport();
7467
- updateFollowLatest(true);
7468
- emitViewportChange();
7469
- scheduleDraw();
7629
+ const target = getFitXTarget();
7630
+ animateViewportTo(target.center, target.span, () => updateFollowLatest(true));
7470
7631
  };
7471
7632
  const resetViewport = () => {
7472
7633
  cancelKineticPan();
7473
- fitXViewport();
7634
+ const target = getFitXTarget();
7474
7635
  resetYViewport();
7475
- updateFollowLatest(true);
7476
- emitViewportChange();
7477
- scheduleDraw();
7636
+ animateViewportTo(target.center, target.span, () => updateFollowLatest(true));
7478
7637
  };
7479
7638
  const isFollowingLatest = () => followLatest;
7480
7639
  const setFollowingLatest = (follow) => {
7481
7640
  if (follow && data.length > 0) {
7482
7641
  cancelKineticPan();
7483
- xCenter = data.length - xSpan / 2 + rightEdgePaddingBars;
7484
- clampXViewport();
7485
- updateFollowLatest(true);
7486
- scheduleDraw();
7642
+ animateViewportTo(
7643
+ data.length - xSpan / 2 + rightEdgePaddingBars,
7644
+ xSpan,
7645
+ () => updateFollowLatest(true)
7646
+ );
7487
7647
  } else {
7488
7648
  updateFollowLatest(follow);
7489
7649
  }
@@ -8766,6 +8926,7 @@ function createChart(element, options = {}) {
8766
8926
  canvas.focus({ preventScroll: true });
8767
8927
  }
8768
8928
  cancelKineticPan();
8929
+ cancelViewportTween();
8769
8930
  panVelocitySamples.length = 0;
8770
8931
  magnetModifierActive = event.metaKey || event.ctrlKey;
8771
8932
  shiftKeyActive = event.shiftKey;
@@ -9430,6 +9591,7 @@ function createChart(element, options = {}) {
9430
9591
  return;
9431
9592
  }
9432
9593
  cancelKineticPan();
9594
+ cancelViewportTween();
9433
9595
  const point = getCanvasPoint(event);
9434
9596
  const region = getHitRegion(point.x, point.y);
9435
9597
  if (region === "outside") {
@@ -9707,6 +9869,7 @@ function createChart(element, options = {}) {
9707
9869
  resetRightMarginCache();
9708
9870
  doubleClickEnabled = mergedOptions.doubleClickEnabled;
9709
9871
  doubleClickAction = mergedOptions.doubleClickAction;
9872
+ setIndicatorLiveThrottleMs(mergedOptions.indicatorUpdate?.liveThrottleMs ?? 80);
9710
9873
  applyAccessibilityAttributes();
9711
9874
  const isTickerSmoothingEnabled = mergedOptions.tickerLine?.smoothing ?? false;
9712
9875
  if (!isTickerSmoothingEnabled) {
@@ -10185,6 +10348,7 @@ function createChart(element, options = {}) {
10185
10348
  return canvas.toDataURL(options2.type ?? "image/png", options2.quality);
10186
10349
  };
10187
10350
  const destroy = () => {
10351
+ cancelViewportTween();
10188
10352
  cancelLongPressTimer();
10189
10353
  datafeedUnsubscribe?.();
10190
10354
  datafeedUnsubscribe = null;
package/dist/index.cjs CHANGED
@@ -369,26 +369,45 @@ var getSeriesFingerprint = (data) => {
369
369
  var withCachedSeries = (key, data, compute) => {
370
370
  const fingerprint = getSeriesFingerprint(data);
371
371
  const existing = builtInSeriesCache.get(key);
372
- if (existing && existing.fingerprint === fingerprint) {
373
- return existing.values;
372
+ const shape = getSeriesShape(data);
373
+ if (existing) {
374
+ const unchanged = existing.fingerprint === fingerprint;
375
+ const liveTickWithinThrottle = !unchanged && indicatorLiveThrottleMs > 0 && existing.shape === shape && Date.now() - existing.computedAt < indicatorLiveThrottleMs;
376
+ if (unchanged || liveTickWithinThrottle) {
377
+ return existing.values;
378
+ }
374
379
  }
375
380
  const values = compute();
376
- builtInSeriesCache.set(key, { fingerprint, values });
381
+ builtInSeriesCache.set(key, { fingerprint, values, shape, computedAt: Date.now() });
377
382
  return values;
378
383
  };
379
384
  var builtInComputationCache = /* @__PURE__ */ new Map();
385
+ var indicatorLiveThrottleMs = 80;
386
+ var setIndicatorLiveThrottleMs = (ms) => {
387
+ indicatorLiveThrottleMs = Math.max(0, ms);
388
+ };
389
+ var getSeriesShape = (data) => {
390
+ const length = data.length;
391
+ const last = length > 0 ? data[length - 1] : void 0;
392
+ return last ? `${length}|${last.time.getTime()}` : "empty";
393
+ };
380
394
  var COMPUTATION_CACHE_MAX_ENTRIES = 128;
381
395
  var withCachedComputation = (key, data, compute) => {
382
396
  const fingerprint = getSeriesFingerprint(data);
383
397
  const existing = builtInComputationCache.get(key);
384
- if (existing && existing.fingerprint === fingerprint) {
385
- builtInComputationCache.delete(key);
386
- builtInComputationCache.set(key, existing);
387
- return existing.value;
398
+ const shape = getSeriesShape(data);
399
+ if (existing) {
400
+ const unchanged = existing.fingerprint === fingerprint;
401
+ const liveTickWithinThrottle = !unchanged && indicatorLiveThrottleMs > 0 && existing.shape === shape && Date.now() - existing.computedAt < indicatorLiveThrottleMs;
402
+ if (unchanged || liveTickWithinThrottle) {
403
+ builtInComputationCache.delete(key);
404
+ builtInComputationCache.set(key, existing);
405
+ return existing.value;
406
+ }
388
407
  }
389
408
  const value = compute();
390
409
  builtInComputationCache.delete(key);
391
- builtInComputationCache.set(key, { fingerprint, value });
410
+ builtInComputationCache.set(key, { fingerprint, value, shape, computedAt: Date.now() });
392
411
  while (builtInComputationCache.size > COMPUTATION_CACHE_MAX_ENTRIES) {
393
412
  const oldest = builtInComputationCache.keys().next().value;
394
413
  if (oldest === void 0) break;
@@ -2958,7 +2977,23 @@ var DEFAULT_OPTIONS = {
2958
2977
  accessibility: { label: "Price chart", description: "", focusable: true },
2959
2978
  downsampling: { enabled: true, thresholdPx: 1.5 },
2960
2979
  touch: { hitToleranceScale: 2.2, longPressTooltip: true, longPressMs: 400 },
2980
+ dataLine: {
2981
+ visible: false,
2982
+ symbol: "",
2983
+ interval: "",
2984
+ exchange: "",
2985
+ showOhlc: true,
2986
+ showChange: true,
2987
+ showVolume: false,
2988
+ details: [],
2989
+ statusColor: "",
2990
+ fontSize: 0,
2991
+ symbolColor: "",
2992
+ textColor: ""
2993
+ },
2961
2994
  datafeedOptions: { prefetchThresholdBars: 150, cooldownMs: 1200, chunkBars: 1500 },
2995
+ animation: { viewportTransitions: true, durationMs: 260 },
2996
+ indicatorUpdate: { liveThrottleMs: 80 },
2962
2997
  crosshair: DEFAULT_CROSSHAIR_OPTIONS,
2963
2998
  grid: DEFAULT_GRID_OPTIONS,
2964
2999
  watermark: DEFAULT_WATERMARK_OPTIONS,
@@ -3017,6 +3052,18 @@ var mergeChartOptions = (baseOptions, options = {}) => ({
3017
3052
  ...baseOptions.touch,
3018
3053
  ...options.touch ?? {}
3019
3054
  },
3055
+ dataLine: {
3056
+ ...baseOptions.dataLine,
3057
+ ...options.dataLine ?? {}
3058
+ },
3059
+ animation: {
3060
+ ...baseOptions.animation,
3061
+ ...options.animation ?? {}
3062
+ },
3063
+ indicatorUpdate: {
3064
+ ...baseOptions.indicatorUpdate,
3065
+ ...options.indicatorUpdate ?? {}
3066
+ },
3020
3067
  datafeedOptions: {
3021
3068
  ...baseOptions.datafeedOptions,
3022
3069
  ...options.datafeedOptions ?? {}
@@ -3276,6 +3323,7 @@ function createChart(element, options = {}) {
3276
3323
  let crosshairPoint = null;
3277
3324
  let doubleClickEnabled = mergedOptions.doubleClickEnabled;
3278
3325
  let doubleClickAction = mergedOptions.doubleClickAction;
3326
+ setIndicatorLiveThrottleMs(mergedOptions.indicatorUpdate?.liveThrottleMs ?? 80);
3279
3327
  let noOverlappingLineLabels = DEFAULT_LABELS_OPTIONS.noOverlapping;
3280
3328
  let rightAxisLabelSlots = [];
3281
3329
  let plotLabelSlots = [];
@@ -3517,21 +3565,69 @@ function createChart(element, options = {}) {
3517
3565
  Math.min(highCenter, count + maxPanBars)
3518
3566
  );
3519
3567
  };
3520
- const fitXViewport = () => {
3521
- const count = data.length;
3522
- if (count === 0) {
3523
- xCenter = 0;
3524
- xSpan = 60;
3525
- return;
3568
+ let viewportTweenRaf = null;
3569
+ const cancelViewportTween = () => {
3570
+ if (viewportTweenRaf !== null) {
3571
+ cancelAnimationFrame(viewportTweenRaf);
3572
+ viewportTweenRaf = null;
3573
+ }
3574
+ };
3575
+ const prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3576
+ const animateViewportTo = (targetCenter, targetSpan, onComplete) => {
3577
+ const animation = mergedOptions.animation ?? DEFAULT_OPTIONS.animation;
3578
+ const duration = Math.max(0, animation?.durationMs ?? 260);
3579
+ const startCenter = xCenter;
3580
+ const startSpan = xSpan;
3581
+ const centerDelta = targetCenter - startCenter;
3582
+ const spanRatio = startSpan > 0 ? targetSpan / startSpan : 1;
3583
+ const negligible = Math.abs(centerDelta) < 0.5 && Math.abs(spanRatio - 1) < 0.01;
3584
+ if (animation?.viewportTransitions === false || duration === 0 || negligible || prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
3585
+ xCenter = targetCenter;
3586
+ xSpan = targetSpan;
3587
+ clampXViewport();
3588
+ onComplete?.();
3589
+ emitViewportChange();
3590
+ scheduleDraw();
3591
+ return false;
3526
3592
  }
3593
+ cancelViewportTween();
3594
+ const startedAt = performance.now();
3595
+ const step = (now) => {
3596
+ const t = clamp((now - startedAt) / duration, 0, 1);
3597
+ const eased = 1 - Math.pow(1 - t, 3);
3598
+ xCenter = startCenter + centerDelta * eased;
3599
+ xSpan = startSpan * Math.pow(spanRatio, eased);
3600
+ clampXViewport();
3601
+ scheduleDraw();
3602
+ if (t < 1) {
3603
+ viewportTweenRaf = requestAnimationFrame(step);
3604
+ return;
3605
+ }
3606
+ viewportTweenRaf = null;
3607
+ xCenter = targetCenter;
3608
+ xSpan = targetSpan;
3609
+ clampXViewport();
3610
+ onComplete?.();
3611
+ emitViewportChange();
3612
+ scheduleDraw();
3613
+ };
3614
+ viewportTweenRaf = requestAnimationFrame(step);
3615
+ return true;
3616
+ };
3617
+ const getFitXTarget = () => {
3618
+ const count = data.length;
3619
+ if (count === 0) return { center: 0, span: 60 };
3527
3620
  const maxSpan = Math.min(maxVisibleBars, Math.max(minVisibleBars, count + maxPanBars * 2));
3528
- const requestedVisibleBars = clamp(Math.floor(mergedOptions.initialVisibleBars), minVisibleBars, maxSpan);
3529
- xSpan = requestedVisibleBars;
3530
- if (mergedOptions.initialViewport === "center") {
3531
- xCenter = count / 2;
3532
- } else {
3533
- xCenter = count - xSpan / 2 + rightEdgePaddingBars;
3534
- }
3621
+ const span = clamp(Math.floor(mergedOptions.initialVisibleBars), minVisibleBars, maxSpan);
3622
+ return {
3623
+ center: mergedOptions.initialViewport === "center" ? count / 2 : count - span / 2 + rightEdgePaddingBars,
3624
+ span
3625
+ };
3626
+ };
3627
+ const fitXViewport = () => {
3628
+ const target = getFitXTarget();
3629
+ xSpan = target.span;
3630
+ xCenter = target.center;
3535
3631
  clampXViewport();
3536
3632
  };
3537
3633
  const getYBounds = () => {
@@ -6928,6 +7024,73 @@ function createChart(element, options = {}) {
6928
7024
  for (const orderLine of orderLines) {
6929
7025
  drawOrderLine(orderLine, yFromPrice, chartLeft, chartTop, chartRight, chartBottom);
6930
7026
  }
7027
+ let dataLineBottom = chartTop;
7028
+ const dataLine = mergedOptions.dataLine;
7029
+ if (dataLine?.visible && data.length > 0) {
7030
+ const hoverIndex = crosshairPoint && getHitRegion(crosshairPoint.x, crosshairPoint.y) === "plot" ? indexFromCanvasX(crosshairPoint.x) : null;
7031
+ const barIndex = hoverIndex !== null && hoverIndex >= 0 && hoverIndex < data.length ? hoverIndex : data.length - 1;
7032
+ const bar = data[barIndex];
7033
+ if (bar) {
7034
+ const prevFont = ctx.font;
7035
+ const fontSize = Math.max(9, dataLine.fontSize || axis.fontSize);
7036
+ ctx.font = `${fontSize}px ${mergedOptions.fontFamily}`;
7037
+ ctx.textBaseline = "top";
7038
+ ctx.textAlign = "left";
7039
+ const symbolColor = dataLine.symbolColor || axis.textColor;
7040
+ const textColor = dataLine.textColor || labels.indicatorTextColor;
7041
+ const changeAbs = bar.c - bar.o;
7042
+ const changeColor = changeAbs >= 0 ? mergedOptions.upColor : mergedOptions.downColor;
7043
+ const rowY = chartTop + 6;
7044
+ let cursorX = chartLeft + 10;
7045
+ if (dataLine.statusColor) {
7046
+ const dotRadius = Math.max(2.5, fontSize / 4);
7047
+ ctx.fillStyle = dataLine.statusColor;
7048
+ ctx.beginPath();
7049
+ ctx.arc(cursorX + dotRadius, rowY + fontSize / 2, dotRadius, 0, Math.PI * 2);
7050
+ ctx.fill();
7051
+ cursorX += dotRadius * 2 + 6;
7052
+ }
7053
+ const headline = [dataLine.symbol, dataLine.interval, dataLine.exchange].filter((part) => part && part.length > 0).join(" \xB7 ");
7054
+ if (headline) {
7055
+ ctx.fillStyle = symbolColor;
7056
+ ctx.fillText(headline, cursorX, rowY);
7057
+ cursorX += measureTextWidth(headline) + 10;
7058
+ }
7059
+ if (dataLine.showOhlc !== false) {
7060
+ const ohlc = `O${formatPrice(bar.o)} H${formatPrice(bar.h)} L${formatPrice(bar.l)} C${formatPrice(bar.c)}`;
7061
+ ctx.fillStyle = changeColor;
7062
+ ctx.fillText(ohlc, cursorX, rowY);
7063
+ cursorX += measureTextWidth(ohlc) + 8;
7064
+ }
7065
+ if (dataLine.showChange !== false) {
7066
+ const pct = bar.o !== 0 ? changeAbs / bar.o * 100 : 0;
7067
+ const changeText = `${changeAbs >= 0 ? "+" : ""}${formatPrice(changeAbs)} (${changeAbs >= 0 ? "+" : ""}${pct.toFixed(2)}%)`;
7068
+ ctx.fillStyle = changeColor;
7069
+ ctx.fillText(changeText, cursorX, rowY);
7070
+ cursorX += measureTextWidth(changeText) + 10;
7071
+ }
7072
+ if (dataLine.showVolume && bar.v !== void 0) {
7073
+ const volumeText = `Vol ${bar.v >= 1e6 ? `${(bar.v / 1e6).toFixed(2)}M` : bar.v >= 1e3 ? `${(bar.v / 1e3).toFixed(1)}K` : String(Math.round(bar.v))}`;
7074
+ ctx.fillStyle = textColor;
7075
+ ctx.fillText(volumeText, cursorX, rowY);
7076
+ cursorX += measureTextWidth(volumeText) + 10;
7077
+ }
7078
+ for (const detail of dataLine.details ?? []) {
7079
+ const text = detail.label ? `${detail.label} ${detail.value}` : detail.value;
7080
+ const width2 = measureTextWidth(text);
7081
+ if (cursorX + width2 > chartRight - 4) break;
7082
+ if (detail.background) {
7083
+ ctx.fillStyle = detail.background;
7084
+ fillRoundedRect(cursorX - 4, rowY - 2, width2 + 8, fontSize + 4, 3);
7085
+ }
7086
+ ctx.fillStyle = detail.color || textColor;
7087
+ ctx.fillText(text, cursorX, rowY);
7088
+ cursorX += width2 + 12;
7089
+ }
7090
+ dataLineBottom = rowY + fontSize + 4;
7091
+ ctx.font = prevFont;
7092
+ }
7093
+ }
6931
7094
  if (labels.visible && (labels.showIndicatorNames || labels.showIndicatorValues)) {
6932
7095
  const isLegendInputValue = (value) => {
6933
7096
  if (typeof value === "number" || typeof value === "boolean") {
@@ -6956,7 +7119,7 @@ function createChart(element, options = {}) {
6956
7119
  const isRight = position === "top-right" || position === "bottom-right";
6957
7120
  const isBottom = position === "bottom-left" || position === "bottom-right";
6958
7121
  const legendX = isRight ? chartRight - offsetX : chartLeft + offsetX;
6959
- const legendY = isBottom ? chartBottom - offsetY : chartTop + offsetY;
7122
+ const legendY = isBottom ? chartBottom - offsetY : Math.max(chartTop + offsetY, dataLineBottom);
6960
7123
  drawText(legendText, legendX, legendY, isRight ? "right" : "left", isBottom ? "bottom" : "top", labels.indicatorTextColor);
6961
7124
  ctx.font = prevFont;
6962
7125
  }
@@ -7497,27 +7660,24 @@ function createChart(element, options = {}) {
7497
7660
  scheduleDraw();
7498
7661
  };
7499
7662
  const fitContent = () => {
7500
- fitXViewport();
7501
- updateFollowLatest(true);
7502
- emitViewportChange();
7503
- scheduleDraw();
7663
+ const target = getFitXTarget();
7664
+ animateViewportTo(target.center, target.span, () => updateFollowLatest(true));
7504
7665
  };
7505
7666
  const resetViewport = () => {
7506
7667
  cancelKineticPan();
7507
- fitXViewport();
7668
+ const target = getFitXTarget();
7508
7669
  resetYViewport();
7509
- updateFollowLatest(true);
7510
- emitViewportChange();
7511
- scheduleDraw();
7670
+ animateViewportTo(target.center, target.span, () => updateFollowLatest(true));
7512
7671
  };
7513
7672
  const isFollowingLatest = () => followLatest;
7514
7673
  const setFollowingLatest = (follow) => {
7515
7674
  if (follow && data.length > 0) {
7516
7675
  cancelKineticPan();
7517
- xCenter = data.length - xSpan / 2 + rightEdgePaddingBars;
7518
- clampXViewport();
7519
- updateFollowLatest(true);
7520
- scheduleDraw();
7676
+ animateViewportTo(
7677
+ data.length - xSpan / 2 + rightEdgePaddingBars,
7678
+ xSpan,
7679
+ () => updateFollowLatest(true)
7680
+ );
7521
7681
  } else {
7522
7682
  updateFollowLatest(follow);
7523
7683
  }
@@ -8800,6 +8960,7 @@ function createChart(element, options = {}) {
8800
8960
  canvas.focus({ preventScroll: true });
8801
8961
  }
8802
8962
  cancelKineticPan();
8963
+ cancelViewportTween();
8803
8964
  panVelocitySamples.length = 0;
8804
8965
  magnetModifierActive = event.metaKey || event.ctrlKey;
8805
8966
  shiftKeyActive = event.shiftKey;
@@ -9464,6 +9625,7 @@ function createChart(element, options = {}) {
9464
9625
  return;
9465
9626
  }
9466
9627
  cancelKineticPan();
9628
+ cancelViewportTween();
9467
9629
  const point = getCanvasPoint(event);
9468
9630
  const region = getHitRegion(point.x, point.y);
9469
9631
  if (region === "outside") {
@@ -9741,6 +9903,7 @@ function createChart(element, options = {}) {
9741
9903
  resetRightMarginCache();
9742
9904
  doubleClickEnabled = mergedOptions.doubleClickEnabled;
9743
9905
  doubleClickAction = mergedOptions.doubleClickAction;
9906
+ setIndicatorLiveThrottleMs(mergedOptions.indicatorUpdate?.liveThrottleMs ?? 80);
9744
9907
  applyAccessibilityAttributes();
9745
9908
  const isTickerSmoothingEnabled = mergedOptions.tickerLine?.smoothing ?? false;
9746
9909
  if (!isTickerSmoothingEnabled) {
@@ -10219,6 +10382,7 @@ function createChart(element, options = {}) {
10219
10382
  return canvas.toDataURL(options2.type ?? "image/png", options2.quality);
10220
10383
  };
10221
10384
  const destroy = () => {
10385
+ cancelViewportTween();
10222
10386
  cancelLongPressTimer();
10223
10387
  datafeedUnsubscribe?.();
10224
10388
  datafeedUnsubscribe = null;