hyperprop-charting-library 0.1.120 → 0.1.122

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.
@@ -215,8 +215,13 @@ var DEFAULT_OPTIONS = {
215
215
  tickSize: 0,
216
216
  candleColorMode: "openClose",
217
217
  candleColorEpsilon: -1,
218
- autoScaleSmoothing: 0.16,
218
+ // 0 = deterministic scale (lightweight-charts behavior): the y-range is a
219
+ // pure function of the visible data, so it can never drift or "breathe".
220
+ // Values > 0 re-enable eased contraction for hosts that prefer it.
221
+ autoScaleSmoothing: 0,
219
222
  autoScaleIgnoreLatestCandle: true,
223
+ kineticScroll: { touch: true, mouse: false },
224
+ timeStepMs: 0,
220
225
  pinOutOfRangeLines: false,
221
226
  doubleClickEnabled: true,
222
227
  doubleClickAction: "reset",
@@ -615,6 +620,26 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
615
620
  }
616
621
  ctx.restore();
617
622
  };
623
+ var rangeOfSeries = (seriesList, startIndex, endIndex, skipIndex) => {
624
+ let min = Number.POSITIVE_INFINITY;
625
+ let max = Number.NEGATIVE_INFINITY;
626
+ for (const series of seriesList) {
627
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
628
+ if (idx === skipIndex) continue;
629
+ const value = series[idx];
630
+ if (value == null || !Number.isFinite(value)) continue;
631
+ if (value < min) min = value;
632
+ if (value > max) max = value;
633
+ }
634
+ }
635
+ return min <= max ? { min, max } : null;
636
+ };
637
+ var maAutoscaleRange = (cacheKeyPrefix, compute, defaultLength) => (data, startIndex, endIndex, inputs, skipIndex) => {
638
+ const length = clampIndicatorLength(inputs.length, defaultLength);
639
+ const source = inputs.source ?? "close";
640
+ const series = withCachedSeries(`${cacheKeyPrefix}|${length}|${source}`, data, () => compute(data, length, source));
641
+ return rangeOfSeries([series], startIndex, endIndex, skipIndex);
642
+ };
618
643
  var fillBetweenSeries = (ctx, renderContext, upper, lower, color, opacity) => {
619
644
  if (!renderContext.yFromPrice || opacity <= 0) return;
620
645
  const yFromPrice = renderContext.yFromPrice;
@@ -832,7 +857,8 @@ var BUILTIN_SMA_INDICATOR = {
832
857
  () => computeSmaSeries(renderContext.data, length, inputs.source ?? "close")
833
858
  );
834
859
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#60a5fa", Number(inputs.width) || 2);
835
- }
860
+ },
861
+ getAutoscaleRange: maAutoscaleRange("sma", computeSmaSeries, 20)
836
862
  };
837
863
  var BUILTIN_EMA_INDICATOR = {
838
864
  id: "ema",
@@ -847,7 +873,8 @@ var BUILTIN_EMA_INDICATOR = {
847
873
  () => computeEmaSeries(renderContext.data, length, inputs.source ?? "close")
848
874
  );
849
875
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
850
- }
876
+ },
877
+ getAutoscaleRange: maAutoscaleRange("ema", computeEmaSeries, 20)
851
878
  };
852
879
  var BUILTIN_WMA_INDICATOR = {
853
880
  id: "wma",
@@ -862,7 +889,8 @@ var BUILTIN_WMA_INDICATOR = {
862
889
  () => computeWmaSeries(renderContext.data, length, inputs.source ?? "close")
863
890
  );
864
891
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#a78bfa", Number(inputs.width) || 2);
865
- }
892
+ },
893
+ getAutoscaleRange: maAutoscaleRange("wma", computeWmaSeries, 20)
866
894
  };
867
895
  var BUILTIN_VWMA_INDICATOR = {
868
896
  id: "vwma",
@@ -877,7 +905,8 @@ var BUILTIN_VWMA_INDICATOR = {
877
905
  () => computeVwmaSeries(renderContext.data, length, inputs.source ?? "close")
878
906
  );
879
907
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#ef4444", Number(inputs.width) || 2);
880
- }
908
+ },
909
+ getAutoscaleRange: maAutoscaleRange("vwma", computeVwmaSeries, 20)
881
910
  };
882
911
  var BUILTIN_RMA_INDICATOR = {
883
912
  id: "rma",
@@ -892,7 +921,8 @@ var BUILTIN_RMA_INDICATOR = {
892
921
  () => computeRmaSeries(renderContext.data, length, inputs.source ?? "close")
893
922
  );
894
923
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#22c55e", Number(inputs.width) || 2);
895
- }
924
+ },
925
+ getAutoscaleRange: maAutoscaleRange("rma", computeRmaSeries, 14)
896
926
  };
897
927
  var BUILTIN_HMA_INDICATOR = {
898
928
  id: "hma",
@@ -907,7 +937,8 @@ var BUILTIN_HMA_INDICATOR = {
907
937
  () => computeHmaSeries(renderContext.data, length, inputs.source ?? "close")
908
938
  );
909
939
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
910
- }
940
+ },
941
+ getAutoscaleRange: maAutoscaleRange("hma", computeHmaSeries, 21)
911
942
  };
912
943
  var BUILTIN_VWAP_INDICATOR = {
913
944
  id: "vwap",
@@ -948,6 +979,27 @@ var BUILTIN_VWAP_INDICATOR = {
948
979
  drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
949
980
  }
950
981
  drawOverlaySeries(ctx, renderContext, vwap, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
982
+ },
983
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
984
+ const vwap = withCachedSeries("vwap|line", data, () => computeVwapSeries(data, "vwap"));
985
+ if (!inputs.showBands) {
986
+ return rangeOfSeries([vwap], startIndex, endIndex, skipIndex);
987
+ }
988
+ const sigma = withCachedSeries("vwap|sigma", data, () => computeVwapSeries(data, "sigma"));
989
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
990
+ let min = Number.POSITIVE_INFINITY;
991
+ let max = Number.NEGATIVE_INFINITY;
992
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
993
+ if (idx === skipIndex) continue;
994
+ const value = vwap[idx];
995
+ const s = sigma[idx];
996
+ if (value == null || s == null) continue;
997
+ const lo = value - multiplier * s;
998
+ const hi = value + multiplier * s;
999
+ if (lo < min) min = lo;
1000
+ if (hi > max) max = hi;
1001
+ }
1002
+ return min <= max ? { min, max } : null;
951
1003
  }
952
1004
  };
953
1005
  var BUILTIN_BOLLINGER_INDICATOR = {
@@ -989,7 +1041,23 @@ var BUILTIN_BOLLINGER_INDICATOR = {
989
1041
  fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.fillOpacity) || 0.05);
990
1042
  drawOverlaySeries(ctx, renderContext, upper, bandColor, width);
991
1043
  drawOverlaySeries(ctx, renderContext, lower, bandColor, width);
992
- drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#f59e0b", width);
1044
+ drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#ec4899", width);
1045
+ },
1046
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
1047
+ const length = clampIndicatorLength(inputs.length, 20);
1048
+ const multiplier = Math.max(0.1, Number(inputs.multiplier) || 2);
1049
+ const source = inputs.source ?? "close";
1050
+ const upper = withCachedSeries(
1051
+ `bb|upper|${length}|${multiplier}|${source}`,
1052
+ data,
1053
+ () => computeBollingerSeries(data, length, multiplier, source, "upper")
1054
+ );
1055
+ const lower = withCachedSeries(
1056
+ `bb|lower|${length}|${multiplier}|${source}`,
1057
+ data,
1058
+ () => computeBollingerSeries(data, length, multiplier, source, "lower")
1059
+ );
1060
+ return rangeOfSeries([upper, lower], startIndex, endIndex, skipIndex);
993
1061
  }
994
1062
  };
995
1063
  var BUILTIN_STDDEV_INDICATOR = {
@@ -1082,6 +1150,20 @@ var BUILTIN_INDICATORS = [
1082
1150
  ];
1083
1151
  function createChart(element, options = {}) {
1084
1152
  let mergedOptions = mergeChartOptions(DEFAULT_OPTIONS, options);
1153
+ let resolvedAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1154
+ let resolvedXAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
1155
+ let resolvedYAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
1156
+ let resolvedLabels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1157
+ let resolvedGrid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
1158
+ let resolvedCrosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1159
+ const refreshResolvedOptions = () => {
1160
+ resolvedAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1161
+ resolvedXAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
1162
+ resolvedYAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
1163
+ resolvedLabels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1164
+ resolvedGrid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
1165
+ resolvedCrosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1166
+ };
1085
1167
  let width = mergedOptions.width;
1086
1168
  let height = mergedOptions.height;
1087
1169
  let data = [];
@@ -1118,8 +1200,11 @@ function createChart(element, options = {}) {
1118
1200
  pane: indicator.pane ?? plugin?.pane ?? "overlay",
1119
1201
  ...indicator.paneHeightRatio === void 0 ? {} : { paneHeightRatio: indicator.paneHeightRatio },
1120
1202
  zIndex: Math.round(Number(indicator.zIndex) || 0),
1121
- excludeFromAutoscale: indicator.excludeFromAutoscale ?? true,
1122
- overlayScaleWeight: Math.min(1, Math.max(0, Number(indicator.overlayScaleWeight) || 0.25)),
1203
+ // Overlay series participate in autoscale by default (lightweight-charts
1204
+ // semantics): bands and MAs expand the price range instead of clipping
1205
+ // at the chart edge. Hosts can still opt out per indicator.
1206
+ excludeFromAutoscale: indicator.excludeFromAutoscale ?? false,
1207
+ overlayScaleWeight: indicator.overlayScaleWeight === void 0 ? 1 : Math.min(1, Math.max(0, Number(indicator.overlayScaleWeight) || 0)),
1123
1208
  inputs: {
1124
1209
  ...defaults,
1125
1210
  ...indicator.inputs ?? {}
@@ -1321,6 +1406,9 @@ function createChart(element, options = {}) {
1321
1406
  canvas.setAttribute("draggable", "false");
1322
1407
  element.innerHTML = "";
1323
1408
  element.appendChild(canvas);
1409
+ const baseCanvas = document.createElement("canvas");
1410
+ const baseCtx = baseCanvas.getContext("2d");
1411
+ let overlayLayout = null;
1324
1412
  const margin = { top: 16, right: 72, bottom: 26, left: 12 };
1325
1413
  let cachedRightMargin = margin.right;
1326
1414
  const minVisibleBars = Math.max(1, Math.floor(mergedOptions.minVisibleBars));
@@ -1360,6 +1448,20 @@ function createChart(element, options = {}) {
1360
1448
  const clamp = (value, min, max) => {
1361
1449
  return Math.min(max, Math.max(min, value));
1362
1450
  };
1451
+ const textWidthCache = /* @__PURE__ */ new Map();
1452
+ const measureTextWidth = (text) => {
1453
+ const key = `${ctx.font}\0${text}`;
1454
+ const cached = textWidthCache.get(key);
1455
+ if (cached !== void 0) {
1456
+ return cached;
1457
+ }
1458
+ const width2 = ctx.measureText(text).width;
1459
+ if (textWidthCache.size >= 4096) {
1460
+ textWidthCache.clear();
1461
+ }
1462
+ textWidthCache.set(key, width2);
1463
+ return width2;
1464
+ };
1363
1465
  const resetLabelSlots = (enabled) => {
1364
1466
  noOverlappingLineLabels = enabled;
1365
1467
  rightAxisLabelSlots = [];
@@ -1562,7 +1664,7 @@ function createChart(element, options = {}) {
1562
1664
  return `${integerPart}${decimalPart}`;
1563
1665
  };
1564
1666
  const getMeasuredLabelWidth = (text, paddingX) => {
1565
- return Math.ceil(ctx.measureText(text).width) + paddingX * 2;
1667
+ return Math.ceil(measureTextWidth(text)) + paddingX * 2;
1566
1668
  };
1567
1669
  const getStabilizedNumericLabelWidth = (text, paddingX) => {
1568
1670
  const measured = getMeasuredLabelWidth(text, paddingX);
@@ -1619,11 +1721,16 @@ function createChart(element, options = {}) {
1619
1721
  return Array.from(dedupedByTime.values()).sort((a, b) => a.time.getTime() - b.time.getTime());
1620
1722
  };
1621
1723
  const getTimeStepMs = () => {
1724
+ const configured = Number(mergedOptions.timeStepMs);
1725
+ if (Number.isFinite(configured) && configured > 0) {
1726
+ return configured;
1727
+ }
1622
1728
  if (data.length < 2) {
1623
1729
  return 24 * 60 * 60 * 1e3;
1624
1730
  }
1625
1731
  const deltas = [];
1626
- for (let index = 1; index < Math.min(data.length, 40); index += 1) {
1732
+ const first = Math.max(1, data.length - 40);
1733
+ for (let index = first; index < data.length; index += 1) {
1627
1734
  const previous = data[index - 1];
1628
1735
  const current = data[index];
1629
1736
  if (!previous || !current) {
@@ -1803,11 +1910,11 @@ function createChart(element, options = {}) {
1803
1910
  return;
1804
1911
  }
1805
1912
  const labelText = mergedLine.label ?? formatPrice(mergedLine.price);
1806
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1913
+ const axis = resolvedAxis;
1807
1914
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1808
1915
  const labelPaddingX = 8;
1809
1916
  const labelHeight = 20;
1810
- const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
1917
+ const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(measureTextWidth(labelText)) + labelPaddingX * 2;
1811
1918
  const labelWidth = getRightAxisLabelWidth(chartRight, measuredLabelWidth);
1812
1919
  const labelX = getRightAxisLabelX(chartRight);
1813
1920
  const labelY = placeRightAxisLabel(lineY - labelHeight / 2, labelHeight, chartTop, chartBottom - labelHeight);
@@ -1902,7 +2009,7 @@ function createChart(element, options = {}) {
1902
2009
  const closeAction = mergedLine.type === "market" ? "close" : "cancel";
1903
2010
  const showCloseButton = mergedLine.showCloseButton;
1904
2011
  const actionButtonText = mergedLine.actionButtonText;
1905
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
2012
+ const axis = resolvedAxis;
1906
2013
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1907
2014
  const legacyButton = actionButtonText ? [
1908
2015
  {
@@ -1924,7 +2031,7 @@ function createChart(element, options = {}) {
1924
2031
  const actionButtonMetrics = actionButtons.map((button) => {
1925
2032
  const paddingX = Math.max(2, button.paddingX ?? mergedLine.actionButtonPaddingX);
1926
2033
  const minWidth = Math.max(16, button.minWidth ?? mergedLine.actionButtonMinWidth);
1927
- const width2 = Math.max(minWidth, Math.ceil(ctx.measureText(button.text).width) + paddingX * 2);
2034
+ const width2 = Math.max(minWidth, Math.ceil(measureTextWidth(button.text)) + paddingX * 2);
1928
2035
  return { button, width: width2 };
1929
2036
  });
1930
2037
  const actionButtonInnerGap = actionButtonMetrics.length > 1 ? Math.max(0, mergedLine.actionButtonsInnerGap) : 0;
@@ -1932,8 +2039,8 @@ function createChart(element, options = {}) {
1932
2039
  const actionButtonsGap = actionButtonMetrics.length > 0 ? Math.max(0, mergedLine.actionButtonsGroupGap) : 0;
1933
2040
  const segmentPaddingX = 8;
1934
2041
  const labelHeight = 22;
1935
- const qtyWidth = qtyText ? Math.ceil(ctx.measureText(qtyText).width) + segmentPaddingX * 2 : 0;
1936
- const centerMeasuredWidth = Math.ceil(ctx.measureText(centerText).width) + segmentPaddingX * 2;
2042
+ const qtyWidth = qtyText ? Math.ceil(measureTextWidth(qtyText)) + segmentPaddingX * 2 : 0;
2043
+ const centerMeasuredWidth = Math.ceil(measureTextWidth(centerText)) + segmentPaddingX * 2;
1937
2044
  const centerWidth = mergedLine.id === void 0 ? centerMeasuredWidth : Math.max(centerMeasuredWidth, orderWidgetWidthById.get(mergedLine.id) ?? 0);
1938
2045
  if (mergedLine.id) {
1939
2046
  orderWidgetWidthById.set(mergedLine.id, centerWidth);
@@ -1941,7 +2048,7 @@ function createChart(element, options = {}) {
1941
2048
  const closeWidth = showCloseButton ? 24 : 0;
1942
2049
  const mainWidgetWidth = qtyWidth + centerWidth + closeWidth;
1943
2050
  const totalWidth = mainWidgetWidth + actionButtonsGap + actionButtonsTotalWidth;
1944
- const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
2051
+ const crosshair = resolvedCrosshair;
1945
2052
  const crosshairActionInset = crosshair.visible && crosshair.showPriceLabel && crosshair.showPriceActionButton ? Math.max(
1946
2053
  0,
1947
2054
  Math.round(crosshair.priceActionButtonGap) + (clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4)) + 10) - 4
@@ -2122,20 +2229,29 @@ function createChart(element, options = {}) {
2122
2229
  }
2123
2230
  }
2124
2231
  crosshairPoint = point;
2125
- scheduleDraw();
2232
+ scheduleDraw({ overlayOnly: true });
2126
2233
  };
2127
2234
  let drawRafId = null;
2128
2235
  let pendingDrawUpdateAutoScale = false;
2236
+ let pendingDrawOverlayOnly = true;
2129
2237
  const scheduleDraw = (options2 = {}) => {
2130
- pendingDrawUpdateAutoScale = pendingDrawUpdateAutoScale || (options2.updateAutoScale ?? true);
2238
+ const overlayOnly = options2.overlayOnly ?? false;
2239
+ pendingDrawOverlayOnly = pendingDrawOverlayOnly && overlayOnly;
2240
+ pendingDrawUpdateAutoScale = pendingDrawUpdateAutoScale || !overlayOnly && (options2.updateAutoScale ?? true);
2131
2241
  if (drawRafId !== null) {
2132
2242
  return;
2133
2243
  }
2134
2244
  drawRafId = requestAnimationFrame(() => {
2135
2245
  drawRafId = null;
2136
2246
  const updateAutoScale = pendingDrawUpdateAutoScale;
2247
+ const flushOverlayOnly = pendingDrawOverlayOnly;
2137
2248
  pendingDrawUpdateAutoScale = false;
2138
- draw({ updateAutoScale });
2249
+ pendingDrawOverlayOnly = true;
2250
+ if (flushOverlayOnly) {
2251
+ drawOverlayOnly();
2252
+ } else {
2253
+ draw({ updateAutoScale });
2254
+ }
2139
2255
  });
2140
2256
  };
2141
2257
  const draw = (options2 = {}) => {
@@ -2149,15 +2265,15 @@ function createChart(element, options = {}) {
2149
2265
  canvas.width = Math.floor(width * pixelRatio);
2150
2266
  canvas.height = Math.floor(height * pixelRatio);
2151
2267
  ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
2152
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
2153
- const xAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
2154
- const yAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
2268
+ const axis = resolvedAxis;
2269
+ const xAxis = resolvedXAxis;
2270
+ const yAxis = resolvedYAxis;
2155
2271
  const xAxisFontSize = Math.max(8, xAxis.fontSize);
2156
2272
  const yAxisFontSize = Math.max(8, yAxis.fontSize);
2157
2273
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2158
2274
  ctx.fillStyle = mergedOptions.backgroundColor;
2159
2275
  ctx.fillRect(0, 0, width, height);
2160
- const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
2276
+ const labels = resolvedLabels;
2161
2277
  const estimateRightMargin = () => {
2162
2278
  const paddingX = Math.max(4, labels.labelPaddingX);
2163
2279
  let required = margin.right - 1;
@@ -2270,6 +2386,7 @@ function createChart(element, options = {}) {
2270
2386
  yMax: 0
2271
2387
  };
2272
2388
  drawText("Load OHLC data to render chart", chartLeft + 10, chartTop + 20, "left", "middle", "#64748b");
2389
+ overlayLayout = null;
2273
2390
  return;
2274
2391
  }
2275
2392
  clampXViewport();
@@ -2277,62 +2394,39 @@ function createChart(element, options = {}) {
2277
2394
  const xEnd = xStart + xSpan;
2278
2395
  const startIndex = Math.max(0, Math.floor(xStart));
2279
2396
  const endIndex = Math.min(data.length - 1, Math.ceil(xEnd) - 1);
2280
- const visibleData = data.slice(startIndex, endIndex + 1);
2281
- let priceSource = visibleData.length > 0 ? visibleData : data;
2282
- if (mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1) {
2283
- const latestIndex = data.length - 1;
2284
- const filtered = priceSource.filter((_, offset) => startIndex + offset !== latestIndex);
2285
- if (filtered.length > 0) {
2286
- priceSource = filtered;
2287
- } else {
2288
- const fallbackWindow = 120;
2289
- const fallbackStart = Math.max(0, latestIndex - fallbackWindow);
2290
- const fallback = data.slice(fallbackStart, latestIndex);
2291
- if (fallback.length > 0) {
2292
- priceSource = fallback;
2293
- }
2397
+ const skipLatestIndex = mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1 ? data.length - 1 : -1;
2398
+ const scanCandleRange = (from, to) => {
2399
+ let min = Number.POSITIVE_INFINITY;
2400
+ let max = Number.NEGATIVE_INFINITY;
2401
+ for (let idx = from; idx <= to; idx += 1) {
2402
+ if (idx === skipLatestIndex) continue;
2403
+ const point = data[idx];
2404
+ if (!point) continue;
2405
+ if (point.l < min) min = point.l;
2406
+ if (point.h > max) max = point.h;
2294
2407
  }
2408
+ return min <= max ? { min, max } : null;
2409
+ };
2410
+ let candleRange = scanCandleRange(startIndex, endIndex);
2411
+ if (!candleRange) {
2412
+ const latestIndex = data.length - 1;
2413
+ candleRange = scanCandleRange(Math.max(0, latestIndex - 120), latestIndex - 1) ?? scanCandleRange(0, latestIndex) ?? { min: data[latestIndex].l, max: data[latestIndex].h };
2295
2414
  }
2296
- let minPrice = Math.min(...priceSource.map((point) => point.l));
2297
- let maxPrice = Math.max(...priceSource.map((point) => point.h));
2415
+ let minPrice = candleRange.min;
2416
+ let maxPrice = candleRange.max;
2298
2417
  if (overlayIndicatorsForScale.length > 0) {
2299
- for (const { indicator } of overlayIndicatorsForScale) {
2418
+ for (const { indicator, plugin } of overlayIndicatorsForScale) {
2300
2419
  if (indicator.excludeFromAutoscale) {
2301
2420
  continue;
2302
2421
  }
2303
- const type = indicator.type;
2304
- const inputs = indicator.inputs;
2305
- const source = inputs.source ?? "close";
2306
- const length = clampIndicatorLength(inputs.length ?? 14, 14);
2307
- let series = null;
2308
- if (type === "sma") series = withCachedSeries(`sma|${length}|${source}`, data, () => computeSmaSeries(data, length, source));
2309
- if (type === "ema") series = withCachedSeries(`ema|${length}|${source}`, data, () => computeEmaSeries(data, length, source));
2310
- if (type === "wma") series = withCachedSeries(`wma|${length}|${source}`, data, () => computeWmaSeries(data, length, source));
2311
- if (type === "vwma") series = withCachedSeries(`vwma|${length}|${source}`, data, () => computeVwmaSeries(data, length, source));
2312
- if (type === "rma") series = withCachedSeries(`rma|${length}|${source}`, data, () => computeRmaSeries(data, length, source));
2313
- if (type === "hma") series = withCachedSeries(`hma|${length}|${source}`, data, () => computeHmaSeries(data, length, source));
2314
- if (!series) {
2422
+ const range = plugin.getAutoscaleRange?.(data, startIndex, endIndex, indicator.inputs, skipLatestIndex);
2423
+ if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) {
2315
2424
  continue;
2316
2425
  }
2317
- const visibleValues = [];
2318
- for (let idx = startIndex; idx <= endIndex; idx += 1) {
2319
- if (mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1 && idx === data.length - 1) {
2320
- continue;
2321
- }
2322
- const value = series[idx];
2323
- if (Number.isFinite(value ?? Number.NaN)) {
2324
- visibleValues.push(value);
2325
- }
2326
- }
2327
- if (visibleValues.length === 0) {
2328
- continue;
2329
- }
2330
- const seriesMin = Math.min(...visibleValues);
2331
- const seriesMax = Math.max(...visibleValues);
2332
2426
  const weight = Math.min(1, Math.max(0, indicator.overlayScaleWeight));
2333
2427
  const currentMid = (minPrice + maxPrice) / 2;
2334
- const weightedMin = currentMid + (seriesMin - currentMid) * weight;
2335
- const weightedMax = currentMid + (seriesMax - currentMid) * weight;
2428
+ const weightedMin = currentMid + (range.min - currentMid) * weight;
2429
+ const weightedMax = currentMid + (range.max - currentMid) * weight;
2336
2430
  minPrice = Math.min(minPrice, weightedMin);
2337
2431
  maxPrice = Math.max(maxPrice, weightedMax);
2338
2432
  }
@@ -2407,7 +2501,7 @@ function createChart(element, options = {}) {
2407
2501
  ctx.save();
2408
2502
  const prevFont = ctx.font;
2409
2503
  ctx.font = `500 12px ${mergedOptions.fontFamily}`;
2410
- const textWidth = ctx.measureText(labelText).width;
2504
+ const textWidth = measureTextWidth(labelText);
2411
2505
  const paddingX = 6;
2412
2506
  const bgWidth = textWidth + paddingX * 2;
2413
2507
  const bgHeight = 18;
@@ -2573,7 +2667,7 @@ function createChart(element, options = {}) {
2573
2667
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2574
2668
  levelLines.forEach((level, index) => {
2575
2669
  const labelText = `${level.ratio} (${formatPrice(level.price)})`;
2576
- const textWidth = ctx.measureText(labelText).width;
2670
+ const textWidth = measureTextWidth(labelText);
2577
2671
  const padding = 4;
2578
2672
  const bgX = lineLeft + 4;
2579
2673
  const bgY = level.y - 9;
@@ -2646,7 +2740,7 @@ function createChart(element, options = {}) {
2646
2740
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2647
2741
  levelLines.forEach((level, index) => {
2648
2742
  const labelText = `${level.ratio} (${formatPrice(level.price)})`;
2649
- const textWidth = ctx.measureText(labelText).width;
2743
+ const textWidth = measureTextWidth(labelText);
2650
2744
  const padding = 4;
2651
2745
  const bgX = chartLeft + 4;
2652
2746
  const bgY = level.y - 9;
@@ -2789,8 +2883,8 @@ function createChart(element, options = {}) {
2789
2883
  const padding = 6;
2790
2884
  const lineH = 14;
2791
2885
  const isDigit = (ch) => ch >= "0" && ch <= "9";
2792
- const digitW = ctx.measureText("0").width;
2793
- const charAdvance = (ch) => isDigit(ch) ? digitW : ctx.measureText(ch).width;
2886
+ const digitW = measureTextWidth("0");
2887
+ const charAdvance = (ch) => isDigit(ch) ? digitW : measureTextWidth(ch);
2794
2888
  const lineWidth = (line) => {
2795
2889
  let w = 0;
2796
2890
  for (const ch of line) w += charAdvance(ch);
@@ -2813,11 +2907,11 @@ function createChart(element, options = {}) {
2813
2907
  const y = startY + lineIndex * lineH;
2814
2908
  for (const ch of line) {
2815
2909
  if (isDigit(ch)) {
2816
- ctx.fillText(ch, x + (digitW - ctx.measureText(ch).width) / 2, y);
2910
+ ctx.fillText(ch, x + (digitW - measureTextWidth(ch)) / 2, y);
2817
2911
  x += digitW;
2818
2912
  } else {
2819
2913
  ctx.fillText(ch, x, y);
2820
- x += ctx.measureText(ch).width;
2914
+ x += measureTextWidth(ch);
2821
2915
  }
2822
2916
  }
2823
2917
  });
@@ -2949,7 +3043,7 @@ function createChart(element, options = {}) {
2949
3043
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2950
3044
  const padding = 6;
2951
3045
  const lineHeight = 15;
2952
- const textW = Math.max(...labelLines.map((line) => ctx.measureText(line).width));
3046
+ const textW = Math.max(...labelLines.map((line) => measureTextWidth(line)));
2953
3047
  const pillW = textW + padding * 2;
2954
3048
  const pillH = labelLines.length * lineHeight + padding;
2955
3049
  const pillX = midX - pillW / 2;
@@ -2985,7 +3079,7 @@ function createChart(element, options = {}) {
2985
3079
  ctx.textBaseline = "middle";
2986
3080
  const lines = (drawing.label ?? "").split("\n");
2987
3081
  const lineH = Math.round(fontSize * 1.35);
2988
- const textW = Math.max(1, ...lines.map((line) => ctx.measureText(line).width));
3082
+ const textW = Math.max(1, ...lines.map((line) => measureTextWidth(line)));
2989
3083
  const padX = isNote ? 8 : 2;
2990
3084
  const padY = isNote ? 6 : 1;
2991
3085
  const blockW = textW + padX * 2;
@@ -3067,47 +3161,114 @@ function createChart(element, options = {}) {
3067
3161
  Math.max(1, candleSpacing - 1),
3068
3162
  Math.max(candleMinWidth, Math.floor(candleSpacing * candleBodyWidthRatio))
3069
3163
  );
3070
- const grid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
3164
+ const grid = resolvedGrid;
3071
3165
  const gridOpacity = clamp(grid.opacity, 0, 1);
3072
3166
  const yTickCountInput = grid.yTickCount ?? grid.horizontalTickCount;
3073
3167
  const yTicks = Math.max(1, Math.floor(yTickCountInput));
3074
3168
  const gridColor = grid.color ?? mergedOptions.gridColor;
3169
+ const priceTicks = [];
3170
+ {
3171
+ const targetStep = yRange / Math.max(1, yTicks);
3172
+ let step;
3173
+ if (targetStep > 0 && Number.isFinite(targetStep)) {
3174
+ const base = Math.pow(10, Math.floor(Math.log10(targetStep)));
3175
+ const frac = targetStep / base;
3176
+ step = (frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 2.5 ? 2.5 : frac <= 5 ? 5 : 10) * base;
3177
+ const instrumentTick = getConfiguredTickSize();
3178
+ if (instrumentTick > 0 && step < instrumentTick * 1e6) {
3179
+ step = Math.max(instrumentTick, Math.round(step / instrumentTick) * instrumentTick);
3180
+ }
3181
+ } else {
3182
+ step = 1;
3183
+ }
3184
+ const firstTick = Math.ceil(yMin / step) * step;
3185
+ for (let i = 0; ; i += 1) {
3186
+ const price = firstTick + i * step;
3187
+ if (price > yMax + step * 1e-6) break;
3188
+ priceTicks.push(price);
3189
+ if (priceTicks.length > 200) break;
3190
+ }
3191
+ }
3075
3192
  if (grid.horizontalLines) {
3076
3193
  ctx.save();
3077
3194
  ctx.globalAlpha = gridOpacity;
3078
- for (let tick = 0; tick <= yTicks; tick += 1) {
3079
- const ratio = tick / yTicks;
3080
- const price = yMin + yRange * ratio;
3195
+ ctx.strokeStyle = gridColor;
3196
+ ctx.lineWidth = 1;
3197
+ ctx.beginPath();
3198
+ for (const price of priceTicks) {
3081
3199
  const y = yFromPrice(price);
3082
- ctx.strokeStyle = gridColor;
3083
- ctx.lineWidth = 1;
3084
- ctx.beginPath();
3085
3200
  ctx.moveTo(crisp(chartLeft), crisp(y));
3086
3201
  ctx.lineTo(crisp(chartRight), crisp(y));
3087
- ctx.stroke();
3088
3202
  }
3203
+ ctx.stroke();
3089
3204
  ctx.restore();
3090
3205
  }
3091
- const minLabelSpacingPx = Math.max(72, candleSpacing * 6);
3092
- const autoLabelCount = Math.max(2, Math.floor(chartWidth / minLabelSpacingPx));
3093
- const xTickCountInput = grid.xTickCount ?? autoLabelCount;
3094
- const xTickCount = Math.max(2, Math.floor(xTickCountInput));
3095
- const rawStep = xSpan / xTickCount;
3096
- const xStep = Math.max(1, Math.ceil(rawStep));
3097
- const visibleTickStart = Math.floor(xStart);
3206
+ const minLabelSpacingPx = Math.max(60, Math.min(120, chartWidth / Math.max(2, Math.floor(grid.xTickCount ?? 8))));
3207
+ const visibleTickStart = Math.max(0, Math.floor(xStart));
3098
3208
  const visibleTickEnd = Math.ceil(xEnd) - 1;
3099
- const tickStartIndex = Math.ceil(visibleTickStart / xStep) * xStep;
3100
- if (grid.verticalLines) {
3209
+ const timeTicks = [];
3210
+ {
3211
+ const weightBuckets = [[], [], [], [], [], [], [], [], [], []];
3212
+ let prevTime = visibleTickStart > 0 ? getTimeForIndex(visibleTickStart - 1) : null;
3213
+ for (let index = visibleTickStart; index <= visibleTickEnd; index += 1) {
3214
+ const time = getTimeForIndex(index);
3215
+ if (!time) {
3216
+ prevTime = null;
3217
+ continue;
3218
+ }
3219
+ let weight = 0;
3220
+ if (!prevTime) {
3221
+ weight = 7;
3222
+ } else {
3223
+ if (prevTime.getFullYear() !== time.getFullYear()) weight = 9;
3224
+ else if (prevTime.getMonth() !== time.getMonth()) weight = 8;
3225
+ else if (prevTime.getDate() !== time.getDate()) weight = 7;
3226
+ else {
3227
+ const prevMinutes = prevTime.getHours() * 60 + prevTime.getMinutes();
3228
+ const curMinutes = time.getHours() * 60 + time.getMinutes();
3229
+ const crossed = (span) => Math.floor(prevMinutes / span) !== Math.floor(curMinutes / span);
3230
+ if (crossed(360)) weight = 6;
3231
+ else if (crossed(60)) weight = 5;
3232
+ else if (crossed(30)) weight = 4;
3233
+ else if (crossed(15)) weight = 3;
3234
+ else if (crossed(5)) weight = 2;
3235
+ else weight = 1;
3236
+ }
3237
+ }
3238
+ weightBuckets[weight].push(index);
3239
+ prevTime = time;
3240
+ }
3241
+ const placedXs = [];
3242
+ const maxMarks = Math.floor(chartWidth / minLabelSpacingPx) + 2;
3243
+ for (let weight = 9; weight >= 1 && timeTicks.length < maxMarks; weight -= 1) {
3244
+ for (const index of weightBuckets[weight]) {
3245
+ const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3246
+ let fits = true;
3247
+ for (const placedX of placedXs) {
3248
+ if (Math.abs(placedX - x) < minLabelSpacingPx) {
3249
+ fits = false;
3250
+ break;
3251
+ }
3252
+ }
3253
+ if (!fits) continue;
3254
+ placedXs.push(x);
3255
+ timeTicks.push({ index, weight, x });
3256
+ if (timeTicks.length >= maxMarks) break;
3257
+ }
3258
+ }
3259
+ timeTicks.sort((a, b) => a.index - b.index);
3260
+ }
3261
+ if (grid.verticalLines && timeTicks.length > 0) {
3101
3262
  ctx.save();
3102
3263
  ctx.globalAlpha = gridOpacity;
3103
- for (let index = tickStartIndex; index <= visibleTickEnd; index += xStep) {
3104
- const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3105
- ctx.strokeStyle = gridColor;
3106
- ctx.beginPath();
3107
- ctx.moveTo(crisp(x), crisp(chartTop));
3108
- ctx.lineTo(crisp(x), crisp(fullChartBottom));
3109
- ctx.stroke();
3264
+ ctx.strokeStyle = gridColor;
3265
+ ctx.lineWidth = 1;
3266
+ ctx.beginPath();
3267
+ for (const tick of timeTicks) {
3268
+ ctx.moveTo(crisp(tick.x), crisp(chartTop));
3269
+ ctx.lineTo(crisp(tick.x), crisp(fullChartBottom));
3110
3270
  }
3271
+ ctx.stroke();
3111
3272
  ctx.restore();
3112
3273
  }
3113
3274
  if (grid.sessionSeparators && data.length > 1) {
@@ -3145,38 +3306,45 @@ function createChart(element, options = {}) {
3145
3306
  }
3146
3307
  return data[index]?.v;
3147
3308
  };
3148
- for (let index = startIndex; index <= endIndex; index += 1) {
3149
- const point = data[index];
3150
- if (!point) {
3151
- continue;
3309
+ {
3310
+ const upWicks = new Path2D();
3311
+ const downWicks = new Path2D();
3312
+ const upBodies = new Path2D();
3313
+ const downBodies = new Path2D();
3314
+ for (let index = startIndex; index <= endIndex; index += 1) {
3315
+ const point = data[index];
3316
+ if (!point) {
3317
+ continue;
3318
+ }
3319
+ const isLastCandle = useSmoothedCandle && index === lastDataIndex;
3320
+ const actualDirection = point.c >= point.o ? "up" : "down";
3321
+ const displayClose = isLastCandle ? actualDirection === "up" ? Math.max(point.o, smoothedTickerPrice) : Math.min(point.o, smoothedTickerPrice) : point.c;
3322
+ const displayHigh = isLastCandle ? actualDirection === "up" ? Math.max(point.h, displayClose) : point.h : point.h;
3323
+ const displayLow = isLastCandle ? actualDirection === "up" ? point.l : Math.min(point.l, displayClose) : point.l;
3324
+ const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3325
+ const direction = isLastCandle ? actualDirection : getCandleDirectionByIndex(index);
3326
+ const isUp = direction === "up";
3327
+ const roundedCenterX = Math.round(centerX);
3328
+ const wicks = isUp ? upWicks : downWicks;
3329
+ wicks.moveTo(roundedCenterX + 0.5, crisp(yFromPrice(displayHigh)));
3330
+ wicks.lineTo(roundedCenterX + 0.5, crisp(yFromPrice(displayLow)));
3331
+ const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
3332
+ const openYPx = Math.round(yFromPrice(point.o));
3333
+ const closeYPx = Math.round(yFromPrice(displayClose));
3334
+ const bodyIsUp = displayClose >= point.o;
3335
+ const bodyTop = bodyIsUp ? Math.min(closeYPx, openYPx - 1) : openYPx;
3336
+ const bodyBottom = bodyIsUp ? openYPx : Math.max(closeYPx, openYPx + 1);
3337
+ (isUp ? upBodies : downBodies).rect(bodyLeft, bodyTop, bodyWidth, bodyBottom - bodyTop);
3152
3338
  }
3153
- const isLastCandle = useSmoothedCandle && index === lastDataIndex;
3154
- const actualDirection = point.c >= point.o ? "up" : "down";
3155
- const displayClose = isLastCandle ? actualDirection === "up" ? Math.max(point.o, smoothedTickerPrice) : Math.min(point.o, smoothedTickerPrice) : point.c;
3156
- const displayHigh = isLastCandle ? actualDirection === "up" ? Math.max(point.h, displayClose) : point.h : point.h;
3157
- const displayLow = isLastCandle ? actualDirection === "up" ? point.l : Math.min(point.l, displayClose) : point.l;
3158
- const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3159
- const openY = yFromPrice(point.o);
3160
- const closeY = yFromPrice(displayClose);
3161
- const highY = yFromPrice(displayHigh);
3162
- const lowY = yFromPrice(displayLow);
3163
- const direction = isLastCandle ? actualDirection : getCandleDirectionByIndex(index);
3164
- const candleColor = direction === "up" ? mergedOptions.upColor : mergedOptions.downColor;
3165
- const roundedCenterX = Math.round(centerX);
3166
- ctx.strokeStyle = candleColor;
3167
3339
  ctx.lineWidth = candleWickWidth;
3168
- ctx.beginPath();
3169
- ctx.moveTo(roundedCenterX + 0.5, crisp(highY));
3170
- ctx.lineTo(roundedCenterX + 0.5, crisp(lowY));
3171
- ctx.stroke();
3172
- const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
3173
- const openYPx = Math.round(openY);
3174
- const closeYPx = Math.round(closeY);
3175
- const bodyIsUp = displayClose >= point.o;
3176
- const bodyTop = bodyIsUp ? Math.min(closeYPx, openYPx - 1) : openYPx;
3177
- const bodyBottom = bodyIsUp ? openYPx : Math.max(closeYPx, openYPx + 1);
3178
- ctx.fillStyle = candleColor;
3179
- ctx.fillRect(bodyLeft, bodyTop, bodyWidth, bodyBottom - bodyTop);
3340
+ ctx.strokeStyle = mergedOptions.upColor;
3341
+ ctx.stroke(upWicks);
3342
+ ctx.fillStyle = mergedOptions.upColor;
3343
+ ctx.fill(upBodies);
3344
+ ctx.strokeStyle = mergedOptions.downColor;
3345
+ ctx.stroke(downWicks);
3346
+ ctx.fillStyle = mergedOptions.downColor;
3347
+ ctx.fill(downBodies);
3180
3348
  }
3181
3349
  const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
3182
3350
  (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
@@ -3294,56 +3462,6 @@ function createChart(element, options = {}) {
3294
3462
  ctx.textAlign = prevAlign;
3295
3463
  ctx.textBaseline = prevBaseline;
3296
3464
  }
3297
- const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
3298
- if (crosshair.visible && crosshairPoint) {
3299
- const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3300
- const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
3301
- if (crosshair.mode === "dot") {
3302
- ctx.save();
3303
- ctx.fillStyle = crosshair.color;
3304
- ctx.beginPath();
3305
- ctx.arc(cx, cy, Math.max(1, crosshair.dotRadius), 0, Math.PI * 2);
3306
- ctx.fill();
3307
- ctx.restore();
3308
- } else {
3309
- ctx.save();
3310
- ctx.strokeStyle = crosshair.color;
3311
- ctx.lineWidth = Math.max(1, crosshair.width);
3312
- applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3313
- if (crosshair.showVertical) {
3314
- ctx.beginPath();
3315
- ctx.moveTo(crisp(cx), crisp(chartTop));
3316
- ctx.lineTo(crisp(cx), crisp(chartBottom));
3317
- ctx.stroke();
3318
- }
3319
- if (crosshair.showHorizontal) {
3320
- ctx.beginPath();
3321
- ctx.moveTo(crisp(chartLeft), crisp(cy));
3322
- ctx.lineTo(crisp(chartRight), crisp(cy));
3323
- ctx.stroke();
3324
- }
3325
- ctx.restore();
3326
- }
3327
- if (crosshair.sideHintLeft || crosshair.sideHintRight) {
3328
- ctx.save();
3329
- ctx.font = `600 11px ${mergedOptions.fontFamily}`;
3330
- ctx.textBaseline = "middle";
3331
- ctx.setLineDash([]);
3332
- const hintY = clamp(cy - 14, chartTop + 8, chartBottom - 8);
3333
- const hintGap = 8;
3334
- if (crosshair.sideHintLeft) {
3335
- ctx.fillStyle = crosshair.sideHintLeftColor;
3336
- ctx.textAlign = "right";
3337
- ctx.fillText(crosshair.sideHintLeft, cx - hintGap, hintY);
3338
- }
3339
- if (crosshair.sideHintRight) {
3340
- ctx.fillStyle = crosshair.sideHintRightColor;
3341
- ctx.textAlign = "left";
3342
- ctx.fillText(crosshair.sideHintRight, cx + hintGap, hintY);
3343
- }
3344
- ctx.restore();
3345
- }
3346
- }
3347
3465
  ctx.restore();
3348
3466
  const positionAxisGutter = Math.max(0, width - chartRight);
3349
3467
  if (positionAxisGutter > 0) {
@@ -3456,7 +3574,7 @@ function createChart(element, options = {}) {
3456
3574
  const text = label.text ?? formatPaneValue(label.value);
3457
3575
  const labelPaddingX = 7;
3458
3576
  const labelHeight = Math.max(16, yAxisFontSize + 8);
3459
- const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(ctx.measureText(text).width) + labelPaddingX * 2);
3577
+ const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(measureTextWidth(text)) + labelPaddingX * 2);
3460
3578
  const labelX = getRightAxisLabelX(chartRight);
3461
3579
  const labelY = clamp(yFromPaneValue(label.value) - labelHeight / 2, paneTop + 2, paneBottom - labelHeight - 2);
3462
3580
  ctx.fillStyle = label.backgroundColor ?? label.color ?? labels.backgroundColor;
@@ -3468,18 +3586,6 @@ function createChart(element, options = {}) {
3468
3586
  }
3469
3587
  });
3470
3588
  }
3471
- if (crosshair.visible && crosshairPoint && crosshair.showVertical) {
3472
- const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3473
- ctx.save();
3474
- ctx.strokeStyle = crosshair.color;
3475
- ctx.lineWidth = Math.max(1, crosshair.width);
3476
- applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3477
- ctx.beginPath();
3478
- ctx.moveTo(crisp(cx), crisp(chartTop));
3479
- ctx.lineTo(crisp(cx), crisp(fullChartBottom));
3480
- ctx.stroke();
3481
- ctx.restore();
3482
- }
3483
3589
  ctx.strokeStyle = axis.lineColor;
3484
3590
  ctx.lineWidth = Math.max(1, axis.lineWidth);
3485
3591
  ctx.beginPath();
@@ -3489,13 +3595,13 @@ function createChart(element, options = {}) {
3489
3595
  ctx.lineTo(crisp(chartRight), crisp(fullChartBottom));
3490
3596
  ctx.stroke();
3491
3597
  const priceScaleTickLabelInset = yAxisFontSize / 2 + 3;
3492
- for (let tick = 0; tick <= yTicks; tick += 1) {
3493
- const ratio = tick / yTicks;
3494
- const price = yMin + yRange * ratio;
3495
- const y = clamp(yFromPrice(price), chartTop + priceScaleTickLabelInset, chartBottom - priceScaleTickLabelInset);
3598
+ {
3496
3599
  const prevFont = ctx.font;
3497
3600
  ctx.font = `${yAxisFontSize}px ${mergedOptions.fontFamily}`;
3498
- drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
3601
+ for (const price of priceTicks) {
3602
+ const y = clamp(yFromPrice(price), chartTop + priceScaleTickLabelInset, chartBottom - priceScaleTickLabelInset);
3603
+ drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
3604
+ }
3499
3605
  ctx.font = prevFont;
3500
3606
  }
3501
3607
  resetLabelSlots(labels.noOverlapping);
@@ -3533,8 +3639,8 @@ function createChart(element, options = {}) {
3533
3639
  return null;
3534
3640
  }
3535
3641
  const stepMs = getTimeStepMs();
3536
- const rawRemainingMs = last.time.getTime() + stepMs - Date.now();
3537
- const countdownMs = rawRemainingMs >= 0 && rawRemainingMs <= stepMs * 2 ? rawRemainingMs : stepMs - Date.now() % stepMs;
3642
+ const elapsedMs = Date.now() - last.time.getTime();
3643
+ const countdownMs = elapsedMs >= 0 ? stepMs - elapsedMs % stepMs : Math.min(stepMs, stepMs - elapsedMs);
3538
3644
  return formatDuration(countdownMs);
3539
3645
  };
3540
3646
  const ticker = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
@@ -3604,9 +3710,15 @@ function createChart(element, options = {}) {
3604
3710
  });
3605
3711
  }
3606
3712
  }
3607
- if (labels.showHighLow && visibleData.length > 0) {
3608
- const visibleHigh = Math.max(...visibleData.map((point) => point.h));
3609
- const visibleLow = Math.min(...visibleData.map((point) => point.l));
3713
+ if (labels.showHighLow && endIndex >= startIndex) {
3714
+ let visibleHigh = Number.NEGATIVE_INFINITY;
3715
+ let visibleLow = Number.POSITIVE_INFINITY;
3716
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
3717
+ const point = data[idx];
3718
+ if (!point) continue;
3719
+ if (point.h > visibleHigh) visibleHigh = point.h;
3720
+ if (point.l < visibleLow) visibleLow = point.l;
3721
+ }
3610
3722
  addPriceAxisLabel({
3611
3723
  text: `H ${formatPrice(visibleHigh)}`,
3612
3724
  price: visibleHigh,
@@ -3659,12 +3771,12 @@ function createChart(element, options = {}) {
3659
3771
  const subtextFontSize = label.subtextFontSize !== void 0 && label.subtextFontSize > 0 ? Math.max(8, Math.round(label.subtextFontSize)) : priceLabelFontSize;
3660
3772
  const subtextLineGap = 5;
3661
3773
  const labelHeight = baseLabelHeight + (subtexts.length > 0 ? subtexts.length * subtextFontSize + subtexts.length * subtextLineGap : 0);
3662
- const primaryWidth = label.text === formatPrice(label.price) ? getPriceLabelWidth(label.text, labelPaddingX) : Math.ceil(ctx.measureText(label.text).width) + labelPaddingX * 2;
3774
+ const primaryWidth = label.text === formatPrice(label.price) ? getPriceLabelWidth(label.text, labelPaddingX) : Math.ceil(measureTextWidth(label.text)) + labelPaddingX * 2;
3663
3775
  let subtextWidth = 0;
3664
3776
  if (subtexts.length > 0) {
3665
3777
  const baseFont = ctx.font;
3666
3778
  ctx.font = `${subtextFontSize}px ${mergedOptions.fontFamily}`;
3667
- subtextWidth = Math.max(...subtexts.map((subtext) => Math.ceil(ctx.measureText(subtext).width) + labelPaddingX * 2));
3779
+ subtextWidth = Math.max(...subtexts.map((subtext) => Math.ceil(measureTextWidth(subtext)) + labelPaddingX * 2));
3668
3780
  ctx.font = baseFont;
3669
3781
  }
3670
3782
  const labelTextWidth = Math.max(primaryWidth, subtextWidth);
@@ -3768,164 +3880,250 @@ function createChart(element, options = {}) {
3768
3880
  ctx.font = prevFont;
3769
3881
  }
3770
3882
  }
3771
- const axisStepMs = getTimeStepMs();
3772
- const axisIntraday = axisStepMs > 0 && axisStepMs < 24 * 60 * 60 * 1e3;
3773
- let prevAxisTickTime = null;
3774
- for (let index = tickStartIndex; index <= visibleTickEnd; index += xStep) {
3775
- const tickTime = getTimeForIndex(index);
3776
- if (!tickTime) {
3777
- continue;
3778
- }
3779
- const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3780
- const isNewDay = !prevAxisTickTime || prevAxisTickTime.getDate() !== tickTime.getDate() || prevAxisTickTime.getMonth() !== tickTime.getMonth() || prevAxisTickTime.getFullYear() !== tickTime.getFullYear();
3781
- const timeLabel = axisIntraday && !isNewDay ? tickTime.toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit", hour12: false }) : tickTime.toLocaleDateString(void 0, { month: "short", day: "numeric" });
3782
- prevAxisTickTime = tickTime;
3883
+ {
3783
3884
  const prevFont = ctx.font;
3784
3885
  ctx.font = `${xAxisFontSize}px ${mergedOptions.fontFamily}`;
3785
- drawText(timeLabel, x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
3886
+ for (const tick of timeTicks) {
3887
+ const tickTime = getTimeForIndex(tick.index);
3888
+ if (!tickTime) continue;
3889
+ const timeLabel = tick.weight >= 9 ? String(tickTime.getFullYear()) : tick.weight === 8 ? tickTime.toLocaleDateString(void 0, { month: "short" }) : tick.weight === 7 ? tickTime.toLocaleDateString(void 0, { month: "short", day: "numeric" }) : tickTime.toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit", hour12: false });
3890
+ drawText(timeLabel, tick.x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
3891
+ }
3786
3892
  ctx.font = prevFont;
3787
3893
  }
3788
3894
  if (labels.visible && labels.showCountdownToBarClose && lastPoint) {
3789
3895
  const stepMs = getTimeStepMs();
3790
- const rawRemainingMs = lastPoint.time.getTime() + stepMs - Date.now();
3791
- const countdownMs = rawRemainingMs >= 0 && rawRemainingMs <= stepMs * 2 ? rawRemainingMs : stepMs - Date.now() % stepMs;
3896
+ const elapsedMs = Date.now() - lastPoint.time.getTime();
3897
+ const countdownMs = elapsedMs >= 0 ? stepMs - elapsedMs % stepMs : Math.min(stepMs, stepMs - elapsedMs);
3792
3898
  const countdownText = formatDuration(countdownMs);
3793
3899
  const prevFont = ctx.font;
3794
3900
  ctx.font = `${xAxisFontSize}px ${mergedOptions.fontFamily}`;
3795
3901
  drawText(countdownText, chartRight + 6, (fullChartBottom + height) / 2, "left", "middle", xAxis.textColor);
3796
3902
  ctx.font = prevFont;
3797
3903
  }
3798
- if (crosshair.visible && crosshairPoint) {
3799
- const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3800
- const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
3801
- const labelPaddingX = 8;
3802
- const labelHeight = 20;
3803
- const labelRadius = Math.max(0, crosshair.labelBorderRadius);
3804
- const labelBackground = crosshair.labelBackgroundColor;
3805
- const labelTextColor = crosshair.labelTextColor;
3806
- const labelBorderColor = crosshair.labelBorderColor;
3807
- const labelBorderWidth = Math.max(0, crosshair.labelBorderWidth);
3808
- const labelBorderStyle = crosshair.labelBorderStyle;
3809
- const strokeCrosshairLabel = (x, y, widthValue, heightValue = labelHeight) => {
3810
- if (labelBorderWidth <= 0) {
3811
- return;
3812
- }
3813
- ctx.save();
3814
- ctx.strokeStyle = labelBorderColor;
3815
- ctx.lineWidth = labelBorderWidth;
3816
- applyDashPattern(
3817
- labelBorderStyle,
3818
- dashPatterns.borderDotted,
3819
- dashPatterns.borderDashed
3820
- );
3821
- strokeRoundedRect(Math.round(x), Math.round(y), widthValue, heightValue, labelRadius);
3822
- ctx.restore();
3823
- };
3824
- if (crosshair.showPriceLabel) {
3825
- const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
3826
- const priceText = formatPrice(hoverPrice);
3827
- const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
3828
- const priceX = getRightAxisLabelX(chartRight);
3829
- const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
3904
+ if (baseCtx) {
3905
+ if (baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height) {
3906
+ baseCanvas.width = canvas.width;
3907
+ baseCanvas.height = canvas.height;
3908
+ }
3909
+ baseCtx.setTransform(1, 0, 0, 1, 0, 0);
3910
+ baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
3911
+ baseCtx.drawImage(canvas, 0, 0);
3912
+ }
3913
+ overlayLayout = {
3914
+ chartLeft,
3915
+ chartTop,
3916
+ chartRight,
3917
+ chartBottom,
3918
+ chartWidth,
3919
+ chartHeight,
3920
+ fullChartBottom,
3921
+ yMin,
3922
+ yRange,
3923
+ xStart,
3924
+ xSpan
3925
+ };
3926
+ paintCrosshairOverlay();
3927
+ };
3928
+ const paintCrosshairOverlay = () => {
3929
+ crosshairPriceActionRegion = null;
3930
+ const layout = overlayLayout;
3931
+ const crosshair = resolvedCrosshair;
3932
+ if (!layout || !crosshair.visible || !crosshairPoint) {
3933
+ return;
3934
+ }
3935
+ const { chartLeft, chartTop, chartRight, chartBottom, chartWidth, chartHeight, fullChartBottom, yMin, yRange, xStart, xSpan: xSpan2 } = layout;
3936
+ const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3937
+ const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
3938
+ ctx.save();
3939
+ ctx.beginPath();
3940
+ ctx.rect(chartLeft, chartTop, chartWidth, Math.max(0, chartBottom - chartTop));
3941
+ ctx.clip();
3942
+ if (crosshair.mode === "dot") {
3943
+ ctx.fillStyle = crosshair.color;
3944
+ ctx.beginPath();
3945
+ ctx.arc(cx, cy, Math.max(1, crosshair.dotRadius), 0, Math.PI * 2);
3946
+ ctx.fill();
3947
+ } else {
3948
+ ctx.strokeStyle = crosshair.color;
3949
+ ctx.lineWidth = Math.max(1, crosshair.width);
3950
+ applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3951
+ if (crosshair.showHorizontal) {
3952
+ ctx.beginPath();
3953
+ ctx.moveTo(crisp(chartLeft), crisp(cy));
3954
+ ctx.lineTo(crisp(chartRight), crisp(cy));
3955
+ ctx.stroke();
3956
+ }
3957
+ }
3958
+ if (crosshair.sideHintLeft || crosshair.sideHintRight) {
3959
+ ctx.save();
3960
+ ctx.font = `600 11px ${mergedOptions.fontFamily}`;
3961
+ ctx.textBaseline = "middle";
3962
+ ctx.setLineDash([]);
3963
+ const hintY = clamp(cy - 14, chartTop + 8, chartBottom - 8);
3964
+ const hintGap = 8;
3965
+ if (crosshair.sideHintLeft) {
3966
+ ctx.fillStyle = crosshair.sideHintLeftColor;
3967
+ ctx.textAlign = "right";
3968
+ ctx.fillText(crosshair.sideHintLeft, cx - hintGap, hintY);
3969
+ }
3970
+ if (crosshair.sideHintRight) {
3971
+ ctx.fillStyle = crosshair.sideHintRightColor;
3972
+ ctx.textAlign = "left";
3973
+ ctx.fillText(crosshair.sideHintRight, cx + hintGap, hintY);
3974
+ }
3975
+ ctx.restore();
3976
+ }
3977
+ ctx.restore();
3978
+ if (crosshair.showVertical) {
3979
+ ctx.save();
3980
+ ctx.strokeStyle = crosshair.color;
3981
+ ctx.lineWidth = Math.max(1, crosshair.width);
3982
+ applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3983
+ ctx.beginPath();
3984
+ ctx.moveTo(crisp(cx), crisp(chartTop));
3985
+ ctx.lineTo(crisp(cx), crisp(fullChartBottom));
3986
+ ctx.stroke();
3987
+ ctx.restore();
3988
+ }
3989
+ const labelPaddingX = 8;
3990
+ const labelHeight = 20;
3991
+ const labelRadius = Math.max(0, crosshair.labelBorderRadius);
3992
+ const labelBackground = crosshair.labelBackgroundColor;
3993
+ const labelTextColor = crosshair.labelTextColor;
3994
+ const labelBorderColor = crosshair.labelBorderColor;
3995
+ const labelBorderWidth = Math.max(0, crosshair.labelBorderWidth);
3996
+ const labelBorderStyle = crosshair.labelBorderStyle;
3997
+ const strokeCrosshairLabel = (x, y, widthValue, heightValue = labelHeight) => {
3998
+ if (labelBorderWidth <= 0) {
3999
+ return;
4000
+ }
4001
+ ctx.save();
4002
+ ctx.strokeStyle = labelBorderColor;
4003
+ ctx.lineWidth = labelBorderWidth;
4004
+ applyDashPattern(
4005
+ labelBorderStyle,
4006
+ dashPatterns.borderDotted,
4007
+ dashPatterns.borderDashed
4008
+ );
4009
+ strokeRoundedRect(Math.round(x), Math.round(y), widthValue, heightValue, labelRadius);
4010
+ ctx.restore();
4011
+ };
4012
+ if (crosshair.showPriceLabel) {
4013
+ const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
4014
+ const priceText = formatPrice(hoverPrice);
4015
+ const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
4016
+ const priceX = getRightAxisLabelX(chartRight);
4017
+ const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
4018
+ ctx.fillStyle = labelBackground;
4019
+ fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
4020
+ strokeCrosshairLabel(priceX, priceY, priceWidth);
4021
+ drawText(priceText, priceX + labelPaddingX, priceY + labelHeight / 2, "left", "middle", labelTextColor);
4022
+ if (crosshair.showPriceActionButton) {
4023
+ const buttonSize = clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4));
4024
+ const buttonGap = Math.max(0, Math.round(crosshair.priceActionButtonGap));
4025
+ const containerPaddingX = 5;
4026
+ const containerWidth = buttonSize + containerPaddingX * 2;
4027
+ const containerX = priceX - buttonGap - containerWidth;
4028
+ const containerY = priceY;
4029
+ const buttonX = containerX + containerPaddingX;
4030
+ const buttonY = priceY + (labelHeight - buttonSize) / 2;
4031
+ const buttonRadius = crosshair.priceActionButtonRounded ? clamp(Math.round(crosshair.priceActionButtonBorderRadius), 0, buttonSize / 2) : 0;
4032
+ const buttonBorderWidth = Math.max(1, Math.round(labelBorderWidth || 1));
4033
+ const buttonCenterX = buttonX + buttonSize / 2;
4034
+ const buttonCenterY = buttonY + buttonSize / 2;
4035
+ const containerRadius = labelRadius;
3830
4036
  ctx.fillStyle = labelBackground;
3831
- fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
3832
- strokeCrosshairLabel(priceX, priceY, priceWidth);
3833
- drawText(priceText, priceX + labelPaddingX, priceY + labelHeight / 2, "left", "middle", labelTextColor);
3834
- if (crosshair.showPriceActionButton) {
3835
- const buttonSize = clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4));
3836
- const buttonGap = Math.max(0, Math.round(crosshair.priceActionButtonGap));
3837
- const containerPaddingX = 5;
3838
- const containerWidth = buttonSize + containerPaddingX * 2;
3839
- const containerX = priceX - buttonGap - containerWidth;
3840
- const containerY = priceY;
3841
- const buttonX = containerX + containerPaddingX;
3842
- const buttonY = priceY + (labelHeight - buttonSize) / 2;
3843
- const buttonRadius = crosshair.priceActionButtonRounded ? clamp(Math.round(crosshair.priceActionButtonBorderRadius), 0, buttonSize / 2) : 0;
3844
- const buttonBorderWidth = Math.max(1, Math.round(labelBorderWidth || 1));
3845
- const buttonCenterX = buttonX + buttonSize / 2;
3846
- const buttonCenterY = buttonY + buttonSize / 2;
3847
- const containerRadius = labelRadius;
3848
- ctx.fillStyle = labelBackground;
3849
- fillRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
3850
- if (labelBorderWidth > 0) {
3851
- ctx.save();
3852
- ctx.strokeStyle = labelBorderColor;
3853
- ctx.lineWidth = labelBorderWidth;
3854
- ctx.setLineDash([]);
3855
- strokeRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
3856
- ctx.restore();
3857
- }
3858
- if (buttonBorderWidth > 0) {
3859
- ctx.save();
3860
- ctx.strokeStyle = labelBorderColor;
3861
- ctx.lineWidth = buttonBorderWidth;
3862
- ctx.setLineDash([]);
3863
- if (crosshair.priceActionButtonRounded) {
3864
- ctx.beginPath();
3865
- ctx.arc(
3866
- buttonCenterX,
3867
- buttonCenterY,
3868
- Math.max(1, buttonSize / 2 - buttonBorderWidth / 2),
3869
- 0,
3870
- Math.PI * 2
3871
- );
3872
- ctx.stroke();
3873
- } else {
3874
- strokeRoundedRect(Math.round(buttonX), Math.round(buttonY), buttonSize, buttonSize, buttonRadius);
3875
- }
3876
- ctx.restore();
3877
- }
3878
- if (crosshair.priceActionButtonIcon !== "text" && crosshair.priceActionButtonText === "+") {
3879
- const plusHalf = Math.max(3, Math.round(buttonSize * 0.22));
3880
- const plusThickness = crosshair.priceActionButtonIcon === "plusThin" ? Math.max(1, Math.round(buttonSize * 0.07)) : Math.max(1, Math.round(buttonSize * 0.1));
3881
- ctx.save();
3882
- ctx.strokeStyle = labelTextColor;
3883
- ctx.lineWidth = plusThickness;
3884
- ctx.lineCap = "round";
3885
- ctx.setLineDash([]);
4037
+ fillRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
4038
+ if (labelBorderWidth > 0) {
4039
+ ctx.save();
4040
+ ctx.strokeStyle = labelBorderColor;
4041
+ ctx.lineWidth = labelBorderWidth;
4042
+ ctx.setLineDash([]);
4043
+ strokeRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
4044
+ ctx.restore();
4045
+ }
4046
+ if (buttonBorderWidth > 0) {
4047
+ ctx.save();
4048
+ ctx.strokeStyle = labelBorderColor;
4049
+ ctx.lineWidth = buttonBorderWidth;
4050
+ ctx.setLineDash([]);
4051
+ if (crosshair.priceActionButtonRounded) {
3886
4052
  ctx.beginPath();
3887
- ctx.moveTo(buttonCenterX - plusHalf, buttonCenterY);
3888
- ctx.lineTo(buttonCenterX + plusHalf, buttonCenterY);
3889
- ctx.moveTo(buttonCenterX, buttonCenterY - plusHalf);
3890
- ctx.lineTo(buttonCenterX, buttonCenterY + plusHalf);
3891
- ctx.stroke();
3892
- ctx.restore();
3893
- } else {
3894
- drawText(
3895
- crosshair.priceActionButtonText,
4053
+ ctx.arc(
3896
4054
  buttonCenterX,
3897
4055
  buttonCenterY,
3898
- "center",
3899
- "middle",
3900
- labelTextColor
4056
+ Math.max(1, buttonSize / 2 - buttonBorderWidth / 2),
4057
+ 0,
4058
+ Math.PI * 2
3901
4059
  );
4060
+ ctx.stroke();
4061
+ } else {
4062
+ strokeRoundedRect(Math.round(buttonX), Math.round(buttonY), buttonSize, buttonSize, buttonRadius);
3902
4063
  }
3903
- crosshairPriceActionRegion = {
3904
- x: containerX,
3905
- y: containerY,
3906
- width: containerWidth,
3907
- height: labelHeight,
3908
- price: Number(hoverPrice.toFixed(mergedOptions.priceDecimals))
3909
- };
4064
+ ctx.restore();
3910
4065
  }
3911
- }
3912
- if (crosshair.showTimeLabel) {
3913
- const ratio = clamp((cx - chartLeft) / chartWidth, 0, 1);
3914
- const hoverIndex = Math.round(xStart + ratio * xSpan - 0.5);
3915
- const hoverTime = getTimeForIndex(hoverIndex);
3916
- if (hoverTime) {
3917
- const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
3918
- const timeWidth = Math.ceil(ctx.measureText(timeText).width) + labelPaddingX * 2;
3919
- const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
3920
- const timeY = fullChartBottom + 1;
3921
- const timeHeight = Math.max(labelHeight, Math.floor(height - timeY));
3922
- ctx.fillStyle = labelBackground;
3923
- fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, timeHeight, labelRadius);
3924
- strokeCrosshairLabel(timeX, timeY, timeWidth, timeHeight);
3925
- drawText(timeText, timeX + labelPaddingX, timeY + timeHeight / 2, "left", "middle", labelTextColor);
4066
+ if (crosshair.priceActionButtonIcon !== "text" && crosshair.priceActionButtonText === "+") {
4067
+ const plusHalf = Math.max(3, Math.round(buttonSize * 0.22));
4068
+ const plusThickness = crosshair.priceActionButtonIcon === "plusThin" ? Math.max(1, Math.round(buttonSize * 0.07)) : Math.max(1, Math.round(buttonSize * 0.1));
4069
+ ctx.save();
4070
+ ctx.strokeStyle = labelTextColor;
4071
+ ctx.lineWidth = plusThickness;
4072
+ ctx.lineCap = "round";
4073
+ ctx.setLineDash([]);
4074
+ ctx.beginPath();
4075
+ ctx.moveTo(buttonCenterX - plusHalf, buttonCenterY);
4076
+ ctx.lineTo(buttonCenterX + plusHalf, buttonCenterY);
4077
+ ctx.moveTo(buttonCenterX, buttonCenterY - plusHalf);
4078
+ ctx.lineTo(buttonCenterX, buttonCenterY + plusHalf);
4079
+ ctx.stroke();
4080
+ ctx.restore();
4081
+ } else {
4082
+ drawText(
4083
+ crosshair.priceActionButtonText,
4084
+ buttonCenterX,
4085
+ buttonCenterY,
4086
+ "center",
4087
+ "middle",
4088
+ labelTextColor
4089
+ );
3926
4090
  }
4091
+ crosshairPriceActionRegion = {
4092
+ x: containerX,
4093
+ y: containerY,
4094
+ width: containerWidth,
4095
+ height: labelHeight,
4096
+ price: Number(hoverPrice.toFixed(mergedOptions.priceDecimals))
4097
+ };
3927
4098
  }
3928
4099
  }
4100
+ if (crosshair.showTimeLabel) {
4101
+ const ratio = clamp((cx - chartLeft) / chartWidth, 0, 1);
4102
+ const hoverIndex = Math.round(xStart + ratio * xSpan2 - 0.5);
4103
+ const hoverTime = getTimeForIndex(hoverIndex);
4104
+ if (hoverTime) {
4105
+ const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
4106
+ const timeWidth = Math.ceil(measureTextWidth(timeText)) + labelPaddingX * 2;
4107
+ const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
4108
+ const timeY = fullChartBottom + 1;
4109
+ const timeHeight = Math.max(labelHeight, Math.floor(height - timeY));
4110
+ ctx.fillStyle = labelBackground;
4111
+ fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, timeHeight, labelRadius);
4112
+ strokeCrosshairLabel(timeX, timeY, timeWidth, timeHeight);
4113
+ drawText(timeText, timeX + labelPaddingX, timeY + timeHeight / 2, "left", "middle", labelTextColor);
4114
+ }
4115
+ }
4116
+ };
4117
+ const drawOverlayOnly = () => {
4118
+ if (!baseCtx || !overlayLayout || baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height || baseCanvas.width === 0) {
4119
+ draw({ updateAutoScale: false });
4120
+ return;
4121
+ }
4122
+ const pixelRatio = getPixelRatio();
4123
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4124
+ ctx.drawImage(baseCanvas, 0, 0);
4125
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
4126
+ paintCrosshairOverlay();
3929
4127
  };
3930
4128
  const zoomX = (factor, anchorX) => {
3931
4129
  if (!drawState || data.length === 0) {
@@ -4016,6 +4214,59 @@ function createChart(element, options = {}) {
4016
4214
  }
4017
4215
  scheduleDraw();
4018
4216
  };
4217
+ let kineticRafId = null;
4218
+ const panVelocitySamples = [];
4219
+ const cancelKineticPan = () => {
4220
+ if (kineticRafId !== null) {
4221
+ cancelAnimationFrame(kineticRafId);
4222
+ kineticRafId = null;
4223
+ }
4224
+ };
4225
+ const recordPanVelocitySample = (x) => {
4226
+ const now = performance.now();
4227
+ panVelocitySamples.push({ t: now, x });
4228
+ while (panVelocitySamples.length > 0 && now - panVelocitySamples[0].t > 120) {
4229
+ panVelocitySamples.shift();
4230
+ }
4231
+ };
4232
+ const startKineticPan = (initialVelocityPxPerMs) => {
4233
+ cancelKineticPan();
4234
+ let velocity = initialVelocityPxPerMs;
4235
+ let lastTime = performance.now();
4236
+ const stepFn = (now) => {
4237
+ kineticRafId = null;
4238
+ const dt = Math.min(64, Math.max(1, now - lastTime));
4239
+ lastTime = now;
4240
+ const centerBefore = xCenter;
4241
+ pan(velocity * dt, 0, true, false);
4242
+ velocity *= Math.exp(-dt / 325);
4243
+ if (Math.abs(velocity) < 0.02 || xCenter === centerBefore) {
4244
+ return;
4245
+ }
4246
+ kineticRafId = requestAnimationFrame(stepFn);
4247
+ };
4248
+ kineticRafId = requestAnimationFrame(stepFn);
4249
+ };
4250
+ const maybeStartKineticPan = (pointerType) => {
4251
+ const config = mergedOptions.kineticScroll ?? DEFAULT_OPTIONS.kineticScroll;
4252
+ const enabled = pointerType === "touch" || pointerType === "pen" ? config?.touch !== false : config?.mouse === true;
4253
+ if (!enabled || panVelocitySamples.length < 2) {
4254
+ panVelocitySamples.length = 0;
4255
+ return;
4256
+ }
4257
+ const first = panVelocitySamples[0];
4258
+ const last = panVelocitySamples[panVelocitySamples.length - 1];
4259
+ panVelocitySamples.length = 0;
4260
+ const elapsed = last.t - first.t;
4261
+ if (elapsed <= 0 || performance.now() - last.t > 100) {
4262
+ return;
4263
+ }
4264
+ const velocity = (last.x - first.x) / elapsed;
4265
+ if (Math.abs(velocity) < 0.1) {
4266
+ return;
4267
+ }
4268
+ startKineticPan(clamp(velocity, -4, 4));
4269
+ };
4019
4270
  const resetYViewport = () => {
4020
4271
  yMinOverride = null;
4021
4272
  yMaxOverride = null;
@@ -4075,6 +4326,7 @@ function createChart(element, options = {}) {
4075
4326
  scheduleDraw();
4076
4327
  };
4077
4328
  const resetViewport = () => {
4329
+ cancelKineticPan();
4078
4330
  fitXViewport();
4079
4331
  resetYViewport();
4080
4332
  updateFollowLatest(true);
@@ -4084,6 +4336,7 @@ function createChart(element, options = {}) {
4084
4336
  const isFollowingLatest = () => followLatest;
4085
4337
  const setFollowingLatest = (follow) => {
4086
4338
  if (follow && data.length > 0) {
4339
+ cancelKineticPan();
4087
4340
  xCenter = data.length - xSpan / 2 + rightEdgePaddingBars;
4088
4341
  clampXViewport();
4089
4342
  updateFollowLatest(true);
@@ -4453,7 +4706,7 @@ function createChart(element, options = {}) {
4453
4706
  const prevFont = ctx.font;
4454
4707
  ctx.font = `500 ${fontSize}px ${mergedOptions.fontFamily}`;
4455
4708
  const lines = (drawing.label ?? "").split("\n");
4456
- const textW = Math.max(1, ...lines.map((line) => ctx.measureText(line).width));
4709
+ const textW = Math.max(1, ...lines.map((line) => measureTextWidth(line)));
4457
4710
  ctx.font = prevFont;
4458
4711
  const isNote = drawing.type === "note";
4459
4712
  const padX = isNote ? 8 : 2;
@@ -4926,16 +5179,24 @@ function createChart(element, options = {}) {
4926
5179
  emitViewportChange();
4927
5180
  scheduleDraw();
4928
5181
  };
5182
+ const capturePointer = (pointerId) => {
5183
+ try {
5184
+ canvas.setPointerCapture(pointerId);
5185
+ } catch {
5186
+ }
5187
+ };
4929
5188
  const onPointerDown = (event) => {
4930
5189
  if (event.pointerType === "touch" || event.pointerType === "pen") {
4931
5190
  event.preventDefault();
4932
5191
  }
5192
+ cancelKineticPan();
5193
+ panVelocitySamples.length = 0;
4933
5194
  magnetModifierActive = event.metaKey || event.ctrlKey;
4934
5195
  shiftKeyActive = event.shiftKey;
4935
5196
  const point = getCanvasPoint(event);
4936
5197
  if (event.pointerType === "touch") {
4937
5198
  touchPointers.set(event.pointerId, point);
4938
- canvas.setPointerCapture(event.pointerId);
5199
+ capturePointer(event.pointerId);
4939
5200
  const touchPair = getTouchPair();
4940
5201
  if (touchPair) {
4941
5202
  beginPinchZoom(touchPair[0], touchPair[1]);
@@ -4965,7 +5226,7 @@ function createChart(element, options = {}) {
4965
5226
  lastPrice: startPrice,
4966
5227
  moved: false
4967
5228
  };
4968
- canvas.setPointerCapture(event.pointerId);
5229
+ capturePointer(event.pointerId);
4969
5230
  canvas.style.cursor = "ns-resize";
4970
5231
  setCrosshairPoint(null);
4971
5232
  return;
@@ -4986,7 +5247,7 @@ function createChart(element, options = {}) {
4986
5247
  startPrice: orderDragRegion.price,
4987
5248
  lastPrice: orderDragRegion.price
4988
5249
  };
4989
- canvas.setPointerCapture(event.pointerId);
5250
+ capturePointer(event.pointerId);
4990
5251
  canvas.style.cursor = "ns-resize";
4991
5252
  setCrosshairPoint(null);
4992
5253
  return;
@@ -5018,7 +5279,7 @@ function createChart(element, options = {}) {
5018
5279
  startPoints: drawingHit.drawing.points.map((drawingPoint) => ({ ...drawingPoint }))
5019
5280
  };
5020
5281
  activePointerId = event.pointerId;
5021
- canvas.setPointerCapture(event.pointerId);
5282
+ capturePointer(event.pointerId);
5022
5283
  }
5023
5284
  }
5024
5285
  setCrosshairPoint(null);
@@ -5046,7 +5307,7 @@ function createChart(element, options = {}) {
5046
5307
  };
5047
5308
  lastPointerX = point.x;
5048
5309
  lastPointerY = point.y;
5049
- canvas.setPointerCapture(event.pointerId);
5310
+ capturePointer(event.pointerId);
5050
5311
  if (crosshairDrag) {
5051
5312
  canvas.style.cursor = "crosshair";
5052
5313
  setCrosshairPoint(point);
@@ -5232,6 +5493,7 @@ function createChart(element, options = {}) {
5232
5493
  const deltaY = point.y - lastPointerY;
5233
5494
  if (dragMode === "plot") {
5234
5495
  canvas.style.cursor = "grabbing";
5496
+ recordPanVelocitySample(point.x);
5235
5497
  pan(deltaX, deltaY, true, true);
5236
5498
  setCrosshairPoint(null);
5237
5499
  } else if (dragMode === "x-axis") {
@@ -5305,10 +5567,16 @@ function createChart(element, options = {}) {
5305
5567
  drawingDragState = null;
5306
5568
  emitDrawingsChange();
5307
5569
  }
5570
+ const endedPlotDrag = isDragging && dragMode === "plot";
5308
5571
  isDragging = false;
5309
5572
  dragMode = null;
5310
5573
  activePointerId = null;
5311
5574
  canvas.style.cursor = "default";
5575
+ if (endedPlotDrag) {
5576
+ maybeStartKineticPan(event?.pointerType);
5577
+ } else {
5578
+ panVelocitySamples.length = 0;
5579
+ }
5312
5580
  if (event && pointerDownInfo && event.pointerId === pointerDownInfo.pointerId) {
5313
5581
  if (pointerDownInfo.crosshairDrag) {
5314
5582
  const point = getCanvasPoint(event);
@@ -5346,6 +5614,7 @@ function createChart(element, options = {}) {
5346
5614
  if (!drawState) {
5347
5615
  return;
5348
5616
  }
5617
+ cancelKineticPan();
5349
5618
  const point = getCanvasPoint(event);
5350
5619
  const region = getHitRegion(point.x, point.y);
5351
5620
  if (region === "outside") {
@@ -5445,6 +5714,7 @@ function createChart(element, options = {}) {
5445
5714
  width = nextOptions.width !== void 0 && nextOptions.width > 0 ? mergedOptions.width : previousWidth;
5446
5715
  height = nextOptions.height !== void 0 && nextOptions.height > 0 ? mergedOptions.height : previousHeight;
5447
5716
  mergedOptions = { ...mergedOptions, width, height };
5717
+ refreshResolvedOptions();
5448
5718
  resetRightMarginCache();
5449
5719
  doubleClickEnabled = mergedOptions.doubleClickEnabled;
5450
5720
  doubleClickAction = mergedOptions.doubleClickAction;
@@ -5789,6 +6059,7 @@ function createChart(element, options = {}) {
5789
6059
  drawingHoverHandler = handler;
5790
6060
  };
5791
6061
  const destroy = () => {
6062
+ cancelKineticPan();
5792
6063
  if (smoothingRafId !== null) {
5793
6064
  cancelAnimationFrame(smoothingRafId);
5794
6065
  smoothingRafId = null;