hyperprop-charting-library 0.1.120 → 0.1.121

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,12 @@ 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 },
220
224
  pinOutOfRangeLines: false,
221
225
  doubleClickEnabled: true,
222
226
  doubleClickAction: "reset",
@@ -615,6 +619,26 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
615
619
  }
616
620
  ctx.restore();
617
621
  };
622
+ var rangeOfSeries = (seriesList, startIndex, endIndex, skipIndex) => {
623
+ let min = Number.POSITIVE_INFINITY;
624
+ let max = Number.NEGATIVE_INFINITY;
625
+ for (const series of seriesList) {
626
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
627
+ if (idx === skipIndex) continue;
628
+ const value = series[idx];
629
+ if (value == null || !Number.isFinite(value)) continue;
630
+ if (value < min) min = value;
631
+ if (value > max) max = value;
632
+ }
633
+ }
634
+ return min <= max ? { min, max } : null;
635
+ };
636
+ var maAutoscaleRange = (cacheKeyPrefix, compute, defaultLength) => (data, startIndex, endIndex, inputs, skipIndex) => {
637
+ const length = clampIndicatorLength(inputs.length, defaultLength);
638
+ const source = inputs.source ?? "close";
639
+ const series = withCachedSeries(`${cacheKeyPrefix}|${length}|${source}`, data, () => compute(data, length, source));
640
+ return rangeOfSeries([series], startIndex, endIndex, skipIndex);
641
+ };
618
642
  var fillBetweenSeries = (ctx, renderContext, upper, lower, color, opacity) => {
619
643
  if (!renderContext.yFromPrice || opacity <= 0) return;
620
644
  const yFromPrice = renderContext.yFromPrice;
@@ -832,7 +856,8 @@ var BUILTIN_SMA_INDICATOR = {
832
856
  () => computeSmaSeries(renderContext.data, length, inputs.source ?? "close")
833
857
  );
834
858
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#60a5fa", Number(inputs.width) || 2);
835
- }
859
+ },
860
+ getAutoscaleRange: maAutoscaleRange("sma", computeSmaSeries, 20)
836
861
  };
837
862
  var BUILTIN_EMA_INDICATOR = {
838
863
  id: "ema",
@@ -847,7 +872,8 @@ var BUILTIN_EMA_INDICATOR = {
847
872
  () => computeEmaSeries(renderContext.data, length, inputs.source ?? "close")
848
873
  );
849
874
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
850
- }
875
+ },
876
+ getAutoscaleRange: maAutoscaleRange("ema", computeEmaSeries, 20)
851
877
  };
852
878
  var BUILTIN_WMA_INDICATOR = {
853
879
  id: "wma",
@@ -862,7 +888,8 @@ var BUILTIN_WMA_INDICATOR = {
862
888
  () => computeWmaSeries(renderContext.data, length, inputs.source ?? "close")
863
889
  );
864
890
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#a78bfa", Number(inputs.width) || 2);
865
- }
891
+ },
892
+ getAutoscaleRange: maAutoscaleRange("wma", computeWmaSeries, 20)
866
893
  };
867
894
  var BUILTIN_VWMA_INDICATOR = {
868
895
  id: "vwma",
@@ -877,7 +904,8 @@ var BUILTIN_VWMA_INDICATOR = {
877
904
  () => computeVwmaSeries(renderContext.data, length, inputs.source ?? "close")
878
905
  );
879
906
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#ef4444", Number(inputs.width) || 2);
880
- }
907
+ },
908
+ getAutoscaleRange: maAutoscaleRange("vwma", computeVwmaSeries, 20)
881
909
  };
882
910
  var BUILTIN_RMA_INDICATOR = {
883
911
  id: "rma",
@@ -892,7 +920,8 @@ var BUILTIN_RMA_INDICATOR = {
892
920
  () => computeRmaSeries(renderContext.data, length, inputs.source ?? "close")
893
921
  );
894
922
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#22c55e", Number(inputs.width) || 2);
895
- }
923
+ },
924
+ getAutoscaleRange: maAutoscaleRange("rma", computeRmaSeries, 14)
896
925
  };
897
926
  var BUILTIN_HMA_INDICATOR = {
898
927
  id: "hma",
@@ -907,7 +936,8 @@ var BUILTIN_HMA_INDICATOR = {
907
936
  () => computeHmaSeries(renderContext.data, length, inputs.source ?? "close")
908
937
  );
909
938
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
910
- }
939
+ },
940
+ getAutoscaleRange: maAutoscaleRange("hma", computeHmaSeries, 21)
911
941
  };
912
942
  var BUILTIN_VWAP_INDICATOR = {
913
943
  id: "vwap",
@@ -948,6 +978,27 @@ var BUILTIN_VWAP_INDICATOR = {
948
978
  drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
949
979
  }
950
980
  drawOverlaySeries(ctx, renderContext, vwap, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
981
+ },
982
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
983
+ const vwap = withCachedSeries("vwap|line", data, () => computeVwapSeries(data, "vwap"));
984
+ if (!inputs.showBands) {
985
+ return rangeOfSeries([vwap], startIndex, endIndex, skipIndex);
986
+ }
987
+ const sigma = withCachedSeries("vwap|sigma", data, () => computeVwapSeries(data, "sigma"));
988
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
989
+ let min = Number.POSITIVE_INFINITY;
990
+ let max = Number.NEGATIVE_INFINITY;
991
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
992
+ if (idx === skipIndex) continue;
993
+ const value = vwap[idx];
994
+ const s = sigma[idx];
995
+ if (value == null || s == null) continue;
996
+ const lo = value - multiplier * s;
997
+ const hi = value + multiplier * s;
998
+ if (lo < min) min = lo;
999
+ if (hi > max) max = hi;
1000
+ }
1001
+ return min <= max ? { min, max } : null;
951
1002
  }
952
1003
  };
953
1004
  var BUILTIN_BOLLINGER_INDICATOR = {
@@ -989,7 +1040,23 @@ var BUILTIN_BOLLINGER_INDICATOR = {
989
1040
  fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.fillOpacity) || 0.05);
990
1041
  drawOverlaySeries(ctx, renderContext, upper, bandColor, width);
991
1042
  drawOverlaySeries(ctx, renderContext, lower, bandColor, width);
992
- drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#f59e0b", width);
1043
+ drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#ec4899", width);
1044
+ },
1045
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
1046
+ const length = clampIndicatorLength(inputs.length, 20);
1047
+ const multiplier = Math.max(0.1, Number(inputs.multiplier) || 2);
1048
+ const source = inputs.source ?? "close";
1049
+ const upper = withCachedSeries(
1050
+ `bb|upper|${length}|${multiplier}|${source}`,
1051
+ data,
1052
+ () => computeBollingerSeries(data, length, multiplier, source, "upper")
1053
+ );
1054
+ const lower = withCachedSeries(
1055
+ `bb|lower|${length}|${multiplier}|${source}`,
1056
+ data,
1057
+ () => computeBollingerSeries(data, length, multiplier, source, "lower")
1058
+ );
1059
+ return rangeOfSeries([upper, lower], startIndex, endIndex, skipIndex);
993
1060
  }
994
1061
  };
995
1062
  var BUILTIN_STDDEV_INDICATOR = {
@@ -1082,6 +1149,20 @@ var BUILTIN_INDICATORS = [
1082
1149
  ];
1083
1150
  function createChart(element, options = {}) {
1084
1151
  let mergedOptions = mergeChartOptions(DEFAULT_OPTIONS, options);
1152
+ let resolvedAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1153
+ let resolvedXAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
1154
+ let resolvedYAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
1155
+ let resolvedLabels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1156
+ let resolvedGrid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
1157
+ let resolvedCrosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1158
+ const refreshResolvedOptions = () => {
1159
+ resolvedAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1160
+ resolvedXAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
1161
+ resolvedYAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
1162
+ resolvedLabels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1163
+ resolvedGrid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
1164
+ resolvedCrosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1165
+ };
1085
1166
  let width = mergedOptions.width;
1086
1167
  let height = mergedOptions.height;
1087
1168
  let data = [];
@@ -1118,8 +1199,11 @@ function createChart(element, options = {}) {
1118
1199
  pane: indicator.pane ?? plugin?.pane ?? "overlay",
1119
1200
  ...indicator.paneHeightRatio === void 0 ? {} : { paneHeightRatio: indicator.paneHeightRatio },
1120
1201
  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)),
1202
+ // Overlay series participate in autoscale by default (lightweight-charts
1203
+ // semantics): bands and MAs expand the price range instead of clipping
1204
+ // at the chart edge. Hosts can still opt out per indicator.
1205
+ excludeFromAutoscale: indicator.excludeFromAutoscale ?? false,
1206
+ overlayScaleWeight: indicator.overlayScaleWeight === void 0 ? 1 : Math.min(1, Math.max(0, Number(indicator.overlayScaleWeight) || 0)),
1123
1207
  inputs: {
1124
1208
  ...defaults,
1125
1209
  ...indicator.inputs ?? {}
@@ -1321,6 +1405,9 @@ function createChart(element, options = {}) {
1321
1405
  canvas.setAttribute("draggable", "false");
1322
1406
  element.innerHTML = "";
1323
1407
  element.appendChild(canvas);
1408
+ const baseCanvas = document.createElement("canvas");
1409
+ const baseCtx = baseCanvas.getContext("2d");
1410
+ let overlayLayout = null;
1324
1411
  const margin = { top: 16, right: 72, bottom: 26, left: 12 };
1325
1412
  let cachedRightMargin = margin.right;
1326
1413
  const minVisibleBars = Math.max(1, Math.floor(mergedOptions.minVisibleBars));
@@ -1360,6 +1447,20 @@ function createChart(element, options = {}) {
1360
1447
  const clamp = (value, min, max) => {
1361
1448
  return Math.min(max, Math.max(min, value));
1362
1449
  };
1450
+ const textWidthCache = /* @__PURE__ */ new Map();
1451
+ const measureTextWidth = (text) => {
1452
+ const key = `${ctx.font}\0${text}`;
1453
+ const cached = textWidthCache.get(key);
1454
+ if (cached !== void 0) {
1455
+ return cached;
1456
+ }
1457
+ const width2 = ctx.measureText(text).width;
1458
+ if (textWidthCache.size >= 4096) {
1459
+ textWidthCache.clear();
1460
+ }
1461
+ textWidthCache.set(key, width2);
1462
+ return width2;
1463
+ };
1363
1464
  const resetLabelSlots = (enabled) => {
1364
1465
  noOverlappingLineLabels = enabled;
1365
1466
  rightAxisLabelSlots = [];
@@ -1562,7 +1663,7 @@ function createChart(element, options = {}) {
1562
1663
  return `${integerPart}${decimalPart}`;
1563
1664
  };
1564
1665
  const getMeasuredLabelWidth = (text, paddingX) => {
1565
- return Math.ceil(ctx.measureText(text).width) + paddingX * 2;
1666
+ return Math.ceil(measureTextWidth(text)) + paddingX * 2;
1566
1667
  };
1567
1668
  const getStabilizedNumericLabelWidth = (text, paddingX) => {
1568
1669
  const measured = getMeasuredLabelWidth(text, paddingX);
@@ -1803,11 +1904,11 @@ function createChart(element, options = {}) {
1803
1904
  return;
1804
1905
  }
1805
1906
  const labelText = mergedLine.label ?? formatPrice(mergedLine.price);
1806
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1907
+ const axis = resolvedAxis;
1807
1908
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1808
1909
  const labelPaddingX = 8;
1809
1910
  const labelHeight = 20;
1810
- const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
1911
+ const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(measureTextWidth(labelText)) + labelPaddingX * 2;
1811
1912
  const labelWidth = getRightAxisLabelWidth(chartRight, measuredLabelWidth);
1812
1913
  const labelX = getRightAxisLabelX(chartRight);
1813
1914
  const labelY = placeRightAxisLabel(lineY - labelHeight / 2, labelHeight, chartTop, chartBottom - labelHeight);
@@ -1902,7 +2003,7 @@ function createChart(element, options = {}) {
1902
2003
  const closeAction = mergedLine.type === "market" ? "close" : "cancel";
1903
2004
  const showCloseButton = mergedLine.showCloseButton;
1904
2005
  const actionButtonText = mergedLine.actionButtonText;
1905
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
2006
+ const axis = resolvedAxis;
1906
2007
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1907
2008
  const legacyButton = actionButtonText ? [
1908
2009
  {
@@ -1924,7 +2025,7 @@ function createChart(element, options = {}) {
1924
2025
  const actionButtonMetrics = actionButtons.map((button) => {
1925
2026
  const paddingX = Math.max(2, button.paddingX ?? mergedLine.actionButtonPaddingX);
1926
2027
  const minWidth = Math.max(16, button.minWidth ?? mergedLine.actionButtonMinWidth);
1927
- const width2 = Math.max(minWidth, Math.ceil(ctx.measureText(button.text).width) + paddingX * 2);
2028
+ const width2 = Math.max(minWidth, Math.ceil(measureTextWidth(button.text)) + paddingX * 2);
1928
2029
  return { button, width: width2 };
1929
2030
  });
1930
2031
  const actionButtonInnerGap = actionButtonMetrics.length > 1 ? Math.max(0, mergedLine.actionButtonsInnerGap) : 0;
@@ -1932,8 +2033,8 @@ function createChart(element, options = {}) {
1932
2033
  const actionButtonsGap = actionButtonMetrics.length > 0 ? Math.max(0, mergedLine.actionButtonsGroupGap) : 0;
1933
2034
  const segmentPaddingX = 8;
1934
2035
  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;
2036
+ const qtyWidth = qtyText ? Math.ceil(measureTextWidth(qtyText)) + segmentPaddingX * 2 : 0;
2037
+ const centerMeasuredWidth = Math.ceil(measureTextWidth(centerText)) + segmentPaddingX * 2;
1937
2038
  const centerWidth = mergedLine.id === void 0 ? centerMeasuredWidth : Math.max(centerMeasuredWidth, orderWidgetWidthById.get(mergedLine.id) ?? 0);
1938
2039
  if (mergedLine.id) {
1939
2040
  orderWidgetWidthById.set(mergedLine.id, centerWidth);
@@ -1941,7 +2042,7 @@ function createChart(element, options = {}) {
1941
2042
  const closeWidth = showCloseButton ? 24 : 0;
1942
2043
  const mainWidgetWidth = qtyWidth + centerWidth + closeWidth;
1943
2044
  const totalWidth = mainWidgetWidth + actionButtonsGap + actionButtonsTotalWidth;
1944
- const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
2045
+ const crosshair = resolvedCrosshair;
1945
2046
  const crosshairActionInset = crosshair.visible && crosshair.showPriceLabel && crosshair.showPriceActionButton ? Math.max(
1946
2047
  0,
1947
2048
  Math.round(crosshair.priceActionButtonGap) + (clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4)) + 10) - 4
@@ -2122,20 +2223,29 @@ function createChart(element, options = {}) {
2122
2223
  }
2123
2224
  }
2124
2225
  crosshairPoint = point;
2125
- scheduleDraw();
2226
+ scheduleDraw({ overlayOnly: true });
2126
2227
  };
2127
2228
  let drawRafId = null;
2128
2229
  let pendingDrawUpdateAutoScale = false;
2230
+ let pendingDrawOverlayOnly = true;
2129
2231
  const scheduleDraw = (options2 = {}) => {
2130
- pendingDrawUpdateAutoScale = pendingDrawUpdateAutoScale || (options2.updateAutoScale ?? true);
2232
+ const overlayOnly = options2.overlayOnly ?? false;
2233
+ pendingDrawOverlayOnly = pendingDrawOverlayOnly && overlayOnly;
2234
+ pendingDrawUpdateAutoScale = pendingDrawUpdateAutoScale || !overlayOnly && (options2.updateAutoScale ?? true);
2131
2235
  if (drawRafId !== null) {
2132
2236
  return;
2133
2237
  }
2134
2238
  drawRafId = requestAnimationFrame(() => {
2135
2239
  drawRafId = null;
2136
2240
  const updateAutoScale = pendingDrawUpdateAutoScale;
2241
+ const flushOverlayOnly = pendingDrawOverlayOnly;
2137
2242
  pendingDrawUpdateAutoScale = false;
2138
- draw({ updateAutoScale });
2243
+ pendingDrawOverlayOnly = true;
2244
+ if (flushOverlayOnly) {
2245
+ drawOverlayOnly();
2246
+ } else {
2247
+ draw({ updateAutoScale });
2248
+ }
2139
2249
  });
2140
2250
  };
2141
2251
  const draw = (options2 = {}) => {
@@ -2149,15 +2259,15 @@ function createChart(element, options = {}) {
2149
2259
  canvas.width = Math.floor(width * pixelRatio);
2150
2260
  canvas.height = Math.floor(height * pixelRatio);
2151
2261
  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 ?? {} };
2262
+ const axis = resolvedAxis;
2263
+ const xAxis = resolvedXAxis;
2264
+ const yAxis = resolvedYAxis;
2155
2265
  const xAxisFontSize = Math.max(8, xAxis.fontSize);
2156
2266
  const yAxisFontSize = Math.max(8, yAxis.fontSize);
2157
2267
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2158
2268
  ctx.fillStyle = mergedOptions.backgroundColor;
2159
2269
  ctx.fillRect(0, 0, width, height);
2160
- const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
2270
+ const labels = resolvedLabels;
2161
2271
  const estimateRightMargin = () => {
2162
2272
  const paddingX = Math.max(4, labels.labelPaddingX);
2163
2273
  let required = margin.right - 1;
@@ -2270,6 +2380,7 @@ function createChart(element, options = {}) {
2270
2380
  yMax: 0
2271
2381
  };
2272
2382
  drawText("Load OHLC data to render chart", chartLeft + 10, chartTop + 20, "left", "middle", "#64748b");
2383
+ overlayLayout = null;
2273
2384
  return;
2274
2385
  }
2275
2386
  clampXViewport();
@@ -2277,62 +2388,39 @@ function createChart(element, options = {}) {
2277
2388
  const xEnd = xStart + xSpan;
2278
2389
  const startIndex = Math.max(0, Math.floor(xStart));
2279
2390
  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
- }
2391
+ const skipLatestIndex = mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1 ? data.length - 1 : -1;
2392
+ const scanCandleRange = (from, to) => {
2393
+ let min = Number.POSITIVE_INFINITY;
2394
+ let max = Number.NEGATIVE_INFINITY;
2395
+ for (let idx = from; idx <= to; idx += 1) {
2396
+ if (idx === skipLatestIndex) continue;
2397
+ const point = data[idx];
2398
+ if (!point) continue;
2399
+ if (point.l < min) min = point.l;
2400
+ if (point.h > max) max = point.h;
2294
2401
  }
2402
+ return min <= max ? { min, max } : null;
2403
+ };
2404
+ let candleRange = scanCandleRange(startIndex, endIndex);
2405
+ if (!candleRange) {
2406
+ const latestIndex = data.length - 1;
2407
+ candleRange = scanCandleRange(Math.max(0, latestIndex - 120), latestIndex - 1) ?? scanCandleRange(0, latestIndex) ?? { min: data[latestIndex].l, max: data[latestIndex].h };
2295
2408
  }
2296
- let minPrice = Math.min(...priceSource.map((point) => point.l));
2297
- let maxPrice = Math.max(...priceSource.map((point) => point.h));
2409
+ let minPrice = candleRange.min;
2410
+ let maxPrice = candleRange.max;
2298
2411
  if (overlayIndicatorsForScale.length > 0) {
2299
- for (const { indicator } of overlayIndicatorsForScale) {
2412
+ for (const { indicator, plugin } of overlayIndicatorsForScale) {
2300
2413
  if (indicator.excludeFromAutoscale) {
2301
2414
  continue;
2302
2415
  }
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) {
2315
- continue;
2316
- }
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) {
2416
+ const range = plugin.getAutoscaleRange?.(data, startIndex, endIndex, indicator.inputs, skipLatestIndex);
2417
+ if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) {
2328
2418
  continue;
2329
2419
  }
2330
- const seriesMin = Math.min(...visibleValues);
2331
- const seriesMax = Math.max(...visibleValues);
2332
2420
  const weight = Math.min(1, Math.max(0, indicator.overlayScaleWeight));
2333
2421
  const currentMid = (minPrice + maxPrice) / 2;
2334
- const weightedMin = currentMid + (seriesMin - currentMid) * weight;
2335
- const weightedMax = currentMid + (seriesMax - currentMid) * weight;
2422
+ const weightedMin = currentMid + (range.min - currentMid) * weight;
2423
+ const weightedMax = currentMid + (range.max - currentMid) * weight;
2336
2424
  minPrice = Math.min(minPrice, weightedMin);
2337
2425
  maxPrice = Math.max(maxPrice, weightedMax);
2338
2426
  }
@@ -2407,7 +2495,7 @@ function createChart(element, options = {}) {
2407
2495
  ctx.save();
2408
2496
  const prevFont = ctx.font;
2409
2497
  ctx.font = `500 12px ${mergedOptions.fontFamily}`;
2410
- const textWidth = ctx.measureText(labelText).width;
2498
+ const textWidth = measureTextWidth(labelText);
2411
2499
  const paddingX = 6;
2412
2500
  const bgWidth = textWidth + paddingX * 2;
2413
2501
  const bgHeight = 18;
@@ -2573,7 +2661,7 @@ function createChart(element, options = {}) {
2573
2661
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2574
2662
  levelLines.forEach((level, index) => {
2575
2663
  const labelText = `${level.ratio} (${formatPrice(level.price)})`;
2576
- const textWidth = ctx.measureText(labelText).width;
2664
+ const textWidth = measureTextWidth(labelText);
2577
2665
  const padding = 4;
2578
2666
  const bgX = lineLeft + 4;
2579
2667
  const bgY = level.y - 9;
@@ -2646,7 +2734,7 @@ function createChart(element, options = {}) {
2646
2734
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2647
2735
  levelLines.forEach((level, index) => {
2648
2736
  const labelText = `${level.ratio} (${formatPrice(level.price)})`;
2649
- const textWidth = ctx.measureText(labelText).width;
2737
+ const textWidth = measureTextWidth(labelText);
2650
2738
  const padding = 4;
2651
2739
  const bgX = chartLeft + 4;
2652
2740
  const bgY = level.y - 9;
@@ -2789,8 +2877,8 @@ function createChart(element, options = {}) {
2789
2877
  const padding = 6;
2790
2878
  const lineH = 14;
2791
2879
  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;
2880
+ const digitW = measureTextWidth("0");
2881
+ const charAdvance = (ch) => isDigit(ch) ? digitW : measureTextWidth(ch);
2794
2882
  const lineWidth = (line) => {
2795
2883
  let w = 0;
2796
2884
  for (const ch of line) w += charAdvance(ch);
@@ -2813,11 +2901,11 @@ function createChart(element, options = {}) {
2813
2901
  const y = startY + lineIndex * lineH;
2814
2902
  for (const ch of line) {
2815
2903
  if (isDigit(ch)) {
2816
- ctx.fillText(ch, x + (digitW - ctx.measureText(ch).width) / 2, y);
2904
+ ctx.fillText(ch, x + (digitW - measureTextWidth(ch)) / 2, y);
2817
2905
  x += digitW;
2818
2906
  } else {
2819
2907
  ctx.fillText(ch, x, y);
2820
- x += ctx.measureText(ch).width;
2908
+ x += measureTextWidth(ch);
2821
2909
  }
2822
2910
  }
2823
2911
  });
@@ -2949,7 +3037,7 @@ function createChart(element, options = {}) {
2949
3037
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2950
3038
  const padding = 6;
2951
3039
  const lineHeight = 15;
2952
- const textW = Math.max(...labelLines.map((line) => ctx.measureText(line).width));
3040
+ const textW = Math.max(...labelLines.map((line) => measureTextWidth(line)));
2953
3041
  const pillW = textW + padding * 2;
2954
3042
  const pillH = labelLines.length * lineHeight + padding;
2955
3043
  const pillX = midX - pillW / 2;
@@ -2985,7 +3073,7 @@ function createChart(element, options = {}) {
2985
3073
  ctx.textBaseline = "middle";
2986
3074
  const lines = (drawing.label ?? "").split("\n");
2987
3075
  const lineH = Math.round(fontSize * 1.35);
2988
- const textW = Math.max(1, ...lines.map((line) => ctx.measureText(line).width));
3076
+ const textW = Math.max(1, ...lines.map((line) => measureTextWidth(line)));
2989
3077
  const padX = isNote ? 8 : 2;
2990
3078
  const padY = isNote ? 6 : 1;
2991
3079
  const blockW = textW + padX * 2;
@@ -3067,47 +3155,114 @@ function createChart(element, options = {}) {
3067
3155
  Math.max(1, candleSpacing - 1),
3068
3156
  Math.max(candleMinWidth, Math.floor(candleSpacing * candleBodyWidthRatio))
3069
3157
  );
3070
- const grid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
3158
+ const grid = resolvedGrid;
3071
3159
  const gridOpacity = clamp(grid.opacity, 0, 1);
3072
3160
  const yTickCountInput = grid.yTickCount ?? grid.horizontalTickCount;
3073
3161
  const yTicks = Math.max(1, Math.floor(yTickCountInput));
3074
3162
  const gridColor = grid.color ?? mergedOptions.gridColor;
3163
+ const priceTicks = [];
3164
+ {
3165
+ const targetStep = yRange / Math.max(1, yTicks);
3166
+ let step;
3167
+ if (targetStep > 0 && Number.isFinite(targetStep)) {
3168
+ const base = Math.pow(10, Math.floor(Math.log10(targetStep)));
3169
+ const frac = targetStep / base;
3170
+ step = (frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 2.5 ? 2.5 : frac <= 5 ? 5 : 10) * base;
3171
+ const instrumentTick = getConfiguredTickSize();
3172
+ if (instrumentTick > 0 && step < instrumentTick * 1e6) {
3173
+ step = Math.max(instrumentTick, Math.round(step / instrumentTick) * instrumentTick);
3174
+ }
3175
+ } else {
3176
+ step = 1;
3177
+ }
3178
+ const firstTick = Math.ceil(yMin / step) * step;
3179
+ for (let i = 0; ; i += 1) {
3180
+ const price = firstTick + i * step;
3181
+ if (price > yMax + step * 1e-6) break;
3182
+ priceTicks.push(price);
3183
+ if (priceTicks.length > 200) break;
3184
+ }
3185
+ }
3075
3186
  if (grid.horizontalLines) {
3076
3187
  ctx.save();
3077
3188
  ctx.globalAlpha = gridOpacity;
3078
- for (let tick = 0; tick <= yTicks; tick += 1) {
3079
- const ratio = tick / yTicks;
3080
- const price = yMin + yRange * ratio;
3189
+ ctx.strokeStyle = gridColor;
3190
+ ctx.lineWidth = 1;
3191
+ ctx.beginPath();
3192
+ for (const price of priceTicks) {
3081
3193
  const y = yFromPrice(price);
3082
- ctx.strokeStyle = gridColor;
3083
- ctx.lineWidth = 1;
3084
- ctx.beginPath();
3085
3194
  ctx.moveTo(crisp(chartLeft), crisp(y));
3086
3195
  ctx.lineTo(crisp(chartRight), crisp(y));
3087
- ctx.stroke();
3088
3196
  }
3197
+ ctx.stroke();
3089
3198
  ctx.restore();
3090
3199
  }
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);
3200
+ const minLabelSpacingPx = Math.max(60, Math.min(120, chartWidth / Math.max(2, Math.floor(grid.xTickCount ?? 8))));
3201
+ const visibleTickStart = Math.max(0, Math.floor(xStart));
3098
3202
  const visibleTickEnd = Math.ceil(xEnd) - 1;
3099
- const tickStartIndex = Math.ceil(visibleTickStart / xStep) * xStep;
3100
- if (grid.verticalLines) {
3203
+ const timeTicks = [];
3204
+ {
3205
+ const weightBuckets = [[], [], [], [], [], [], [], [], [], []];
3206
+ let prevTime = visibleTickStart > 0 ? getTimeForIndex(visibleTickStart - 1) : null;
3207
+ for (let index = visibleTickStart; index <= visibleTickEnd; index += 1) {
3208
+ const time = getTimeForIndex(index);
3209
+ if (!time) {
3210
+ prevTime = null;
3211
+ continue;
3212
+ }
3213
+ let weight = 0;
3214
+ if (!prevTime) {
3215
+ weight = 7;
3216
+ } else {
3217
+ if (prevTime.getFullYear() !== time.getFullYear()) weight = 9;
3218
+ else if (prevTime.getMonth() !== time.getMonth()) weight = 8;
3219
+ else if (prevTime.getDate() !== time.getDate()) weight = 7;
3220
+ else {
3221
+ const prevMinutes = prevTime.getHours() * 60 + prevTime.getMinutes();
3222
+ const curMinutes = time.getHours() * 60 + time.getMinutes();
3223
+ const crossed = (span) => Math.floor(prevMinutes / span) !== Math.floor(curMinutes / span);
3224
+ if (crossed(360)) weight = 6;
3225
+ else if (crossed(60)) weight = 5;
3226
+ else if (crossed(30)) weight = 4;
3227
+ else if (crossed(15)) weight = 3;
3228
+ else if (crossed(5)) weight = 2;
3229
+ else weight = 1;
3230
+ }
3231
+ }
3232
+ weightBuckets[weight].push(index);
3233
+ prevTime = time;
3234
+ }
3235
+ const placedXs = [];
3236
+ const maxMarks = Math.floor(chartWidth / minLabelSpacingPx) + 2;
3237
+ for (let weight = 9; weight >= 1 && timeTicks.length < maxMarks; weight -= 1) {
3238
+ for (const index of weightBuckets[weight]) {
3239
+ const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3240
+ let fits = true;
3241
+ for (const placedX of placedXs) {
3242
+ if (Math.abs(placedX - x) < minLabelSpacingPx) {
3243
+ fits = false;
3244
+ break;
3245
+ }
3246
+ }
3247
+ if (!fits) continue;
3248
+ placedXs.push(x);
3249
+ timeTicks.push({ index, weight, x });
3250
+ if (timeTicks.length >= maxMarks) break;
3251
+ }
3252
+ }
3253
+ timeTicks.sort((a, b) => a.index - b.index);
3254
+ }
3255
+ if (grid.verticalLines && timeTicks.length > 0) {
3101
3256
  ctx.save();
3102
3257
  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();
3258
+ ctx.strokeStyle = gridColor;
3259
+ ctx.lineWidth = 1;
3260
+ ctx.beginPath();
3261
+ for (const tick of timeTicks) {
3262
+ ctx.moveTo(crisp(tick.x), crisp(chartTop));
3263
+ ctx.lineTo(crisp(tick.x), crisp(fullChartBottom));
3110
3264
  }
3265
+ ctx.stroke();
3111
3266
  ctx.restore();
3112
3267
  }
3113
3268
  if (grid.sessionSeparators && data.length > 1) {
@@ -3145,38 +3300,45 @@ function createChart(element, options = {}) {
3145
3300
  }
3146
3301
  return data[index]?.v;
3147
3302
  };
3148
- for (let index = startIndex; index <= endIndex; index += 1) {
3149
- const point = data[index];
3150
- if (!point) {
3151
- continue;
3303
+ {
3304
+ const upWicks = new Path2D();
3305
+ const downWicks = new Path2D();
3306
+ const upBodies = new Path2D();
3307
+ const downBodies = new Path2D();
3308
+ for (let index = startIndex; index <= endIndex; index += 1) {
3309
+ const point = data[index];
3310
+ if (!point) {
3311
+ continue;
3312
+ }
3313
+ const isLastCandle = useSmoothedCandle && index === lastDataIndex;
3314
+ const actualDirection = point.c >= point.o ? "up" : "down";
3315
+ const displayClose = isLastCandle ? actualDirection === "up" ? Math.max(point.o, smoothedTickerPrice) : Math.min(point.o, smoothedTickerPrice) : point.c;
3316
+ const displayHigh = isLastCandle ? actualDirection === "up" ? Math.max(point.h, displayClose) : point.h : point.h;
3317
+ const displayLow = isLastCandle ? actualDirection === "up" ? point.l : Math.min(point.l, displayClose) : point.l;
3318
+ const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3319
+ const direction = isLastCandle ? actualDirection : getCandleDirectionByIndex(index);
3320
+ const isUp = direction === "up";
3321
+ const roundedCenterX = Math.round(centerX);
3322
+ const wicks = isUp ? upWicks : downWicks;
3323
+ wicks.moveTo(roundedCenterX + 0.5, crisp(yFromPrice(displayHigh)));
3324
+ wicks.lineTo(roundedCenterX + 0.5, crisp(yFromPrice(displayLow)));
3325
+ const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
3326
+ const openYPx = Math.round(yFromPrice(point.o));
3327
+ const closeYPx = Math.round(yFromPrice(displayClose));
3328
+ const bodyIsUp = displayClose >= point.o;
3329
+ const bodyTop = bodyIsUp ? Math.min(closeYPx, openYPx - 1) : openYPx;
3330
+ const bodyBottom = bodyIsUp ? openYPx : Math.max(closeYPx, openYPx + 1);
3331
+ (isUp ? upBodies : downBodies).rect(bodyLeft, bodyTop, bodyWidth, bodyBottom - bodyTop);
3152
3332
  }
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
3333
  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);
3334
+ ctx.strokeStyle = mergedOptions.upColor;
3335
+ ctx.stroke(upWicks);
3336
+ ctx.fillStyle = mergedOptions.upColor;
3337
+ ctx.fill(upBodies);
3338
+ ctx.strokeStyle = mergedOptions.downColor;
3339
+ ctx.stroke(downWicks);
3340
+ ctx.fillStyle = mergedOptions.downColor;
3341
+ ctx.fill(downBodies);
3180
3342
  }
3181
3343
  const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
3182
3344
  (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
@@ -3294,56 +3456,6 @@ function createChart(element, options = {}) {
3294
3456
  ctx.textAlign = prevAlign;
3295
3457
  ctx.textBaseline = prevBaseline;
3296
3458
  }
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
3459
  ctx.restore();
3348
3460
  const positionAxisGutter = Math.max(0, width - chartRight);
3349
3461
  if (positionAxisGutter > 0) {
@@ -3456,7 +3568,7 @@ function createChart(element, options = {}) {
3456
3568
  const text = label.text ?? formatPaneValue(label.value);
3457
3569
  const labelPaddingX = 7;
3458
3570
  const labelHeight = Math.max(16, yAxisFontSize + 8);
3459
- const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(ctx.measureText(text).width) + labelPaddingX * 2);
3571
+ const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(measureTextWidth(text)) + labelPaddingX * 2);
3460
3572
  const labelX = getRightAxisLabelX(chartRight);
3461
3573
  const labelY = clamp(yFromPaneValue(label.value) - labelHeight / 2, paneTop + 2, paneBottom - labelHeight - 2);
3462
3574
  ctx.fillStyle = label.backgroundColor ?? label.color ?? labels.backgroundColor;
@@ -3468,18 +3580,6 @@ function createChart(element, options = {}) {
3468
3580
  }
3469
3581
  });
3470
3582
  }
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
3583
  ctx.strokeStyle = axis.lineColor;
3484
3584
  ctx.lineWidth = Math.max(1, axis.lineWidth);
3485
3585
  ctx.beginPath();
@@ -3489,13 +3589,13 @@ function createChart(element, options = {}) {
3489
3589
  ctx.lineTo(crisp(chartRight), crisp(fullChartBottom));
3490
3590
  ctx.stroke();
3491
3591
  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);
3592
+ {
3496
3593
  const prevFont = ctx.font;
3497
3594
  ctx.font = `${yAxisFontSize}px ${mergedOptions.fontFamily}`;
3498
- drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
3595
+ for (const price of priceTicks) {
3596
+ const y = clamp(yFromPrice(price), chartTop + priceScaleTickLabelInset, chartBottom - priceScaleTickLabelInset);
3597
+ drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
3598
+ }
3499
3599
  ctx.font = prevFont;
3500
3600
  }
3501
3601
  resetLabelSlots(labels.noOverlapping);
@@ -3604,9 +3704,15 @@ function createChart(element, options = {}) {
3604
3704
  });
3605
3705
  }
3606
3706
  }
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));
3707
+ if (labels.showHighLow && endIndex >= startIndex) {
3708
+ let visibleHigh = Number.NEGATIVE_INFINITY;
3709
+ let visibleLow = Number.POSITIVE_INFINITY;
3710
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
3711
+ const point = data[idx];
3712
+ if (!point) continue;
3713
+ if (point.h > visibleHigh) visibleHigh = point.h;
3714
+ if (point.l < visibleLow) visibleLow = point.l;
3715
+ }
3610
3716
  addPriceAxisLabel({
3611
3717
  text: `H ${formatPrice(visibleHigh)}`,
3612
3718
  price: visibleHigh,
@@ -3659,12 +3765,12 @@ function createChart(element, options = {}) {
3659
3765
  const subtextFontSize = label.subtextFontSize !== void 0 && label.subtextFontSize > 0 ? Math.max(8, Math.round(label.subtextFontSize)) : priceLabelFontSize;
3660
3766
  const subtextLineGap = 5;
3661
3767
  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;
3768
+ const primaryWidth = label.text === formatPrice(label.price) ? getPriceLabelWidth(label.text, labelPaddingX) : Math.ceil(measureTextWidth(label.text)) + labelPaddingX * 2;
3663
3769
  let subtextWidth = 0;
3664
3770
  if (subtexts.length > 0) {
3665
3771
  const baseFont = ctx.font;
3666
3772
  ctx.font = `${subtextFontSize}px ${mergedOptions.fontFamily}`;
3667
- subtextWidth = Math.max(...subtexts.map((subtext) => Math.ceil(ctx.measureText(subtext).width) + labelPaddingX * 2));
3773
+ subtextWidth = Math.max(...subtexts.map((subtext) => Math.ceil(measureTextWidth(subtext)) + labelPaddingX * 2));
3668
3774
  ctx.font = baseFont;
3669
3775
  }
3670
3776
  const labelTextWidth = Math.max(primaryWidth, subtextWidth);
@@ -3768,21 +3874,15 @@ function createChart(element, options = {}) {
3768
3874
  ctx.font = prevFont;
3769
3875
  }
3770
3876
  }
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;
3877
+ {
3783
3878
  const prevFont = ctx.font;
3784
3879
  ctx.font = `${xAxisFontSize}px ${mergedOptions.fontFamily}`;
3785
- drawText(timeLabel, x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
3880
+ for (const tick of timeTicks) {
3881
+ const tickTime = getTimeForIndex(tick.index);
3882
+ if (!tickTime) continue;
3883
+ 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 });
3884
+ drawText(timeLabel, tick.x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
3885
+ }
3786
3886
  ctx.font = prevFont;
3787
3887
  }
3788
3888
  if (labels.visible && labels.showCountdownToBarClose && lastPoint) {
@@ -3795,137 +3895,229 @@ function createChart(element, options = {}) {
3795
3895
  drawText(countdownText, chartRight + 6, (fullChartBottom + height) / 2, "left", "middle", xAxis.textColor);
3796
3896
  ctx.font = prevFont;
3797
3897
  }
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);
3898
+ if (baseCtx) {
3899
+ if (baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height) {
3900
+ baseCanvas.width = canvas.width;
3901
+ baseCanvas.height = canvas.height;
3902
+ }
3903
+ baseCtx.setTransform(1, 0, 0, 1, 0, 0);
3904
+ baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
3905
+ baseCtx.drawImage(canvas, 0, 0);
3906
+ }
3907
+ overlayLayout = {
3908
+ chartLeft,
3909
+ chartTop,
3910
+ chartRight,
3911
+ chartBottom,
3912
+ chartWidth,
3913
+ chartHeight,
3914
+ fullChartBottom,
3915
+ yMin,
3916
+ yRange,
3917
+ xStart,
3918
+ xSpan
3919
+ };
3920
+ paintCrosshairOverlay();
3921
+ };
3922
+ const paintCrosshairOverlay = () => {
3923
+ crosshairPriceActionRegion = null;
3924
+ const layout = overlayLayout;
3925
+ const crosshair = resolvedCrosshair;
3926
+ if (!layout || !crosshair.visible || !crosshairPoint) {
3927
+ return;
3928
+ }
3929
+ const { chartLeft, chartTop, chartRight, chartBottom, chartWidth, chartHeight, fullChartBottom, yMin, yRange, xStart, xSpan: xSpan2 } = layout;
3930
+ const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3931
+ const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
3932
+ ctx.save();
3933
+ ctx.beginPath();
3934
+ ctx.rect(chartLeft, chartTop, chartWidth, Math.max(0, chartBottom - chartTop));
3935
+ ctx.clip();
3936
+ if (crosshair.mode === "dot") {
3937
+ ctx.fillStyle = crosshair.color;
3938
+ ctx.beginPath();
3939
+ ctx.arc(cx, cy, Math.max(1, crosshair.dotRadius), 0, Math.PI * 2);
3940
+ ctx.fill();
3941
+ } else {
3942
+ ctx.strokeStyle = crosshair.color;
3943
+ ctx.lineWidth = Math.max(1, crosshair.width);
3944
+ applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3945
+ if (crosshair.showHorizontal) {
3946
+ ctx.beginPath();
3947
+ ctx.moveTo(crisp(chartLeft), crisp(cy));
3948
+ ctx.lineTo(crisp(chartRight), crisp(cy));
3949
+ ctx.stroke();
3950
+ }
3951
+ }
3952
+ if (crosshair.sideHintLeft || crosshair.sideHintRight) {
3953
+ ctx.save();
3954
+ ctx.font = `600 11px ${mergedOptions.fontFamily}`;
3955
+ ctx.textBaseline = "middle";
3956
+ ctx.setLineDash([]);
3957
+ const hintY = clamp(cy - 14, chartTop + 8, chartBottom - 8);
3958
+ const hintGap = 8;
3959
+ if (crosshair.sideHintLeft) {
3960
+ ctx.fillStyle = crosshair.sideHintLeftColor;
3961
+ ctx.textAlign = "right";
3962
+ ctx.fillText(crosshair.sideHintLeft, cx - hintGap, hintY);
3963
+ }
3964
+ if (crosshair.sideHintRight) {
3965
+ ctx.fillStyle = crosshair.sideHintRightColor;
3966
+ ctx.textAlign = "left";
3967
+ ctx.fillText(crosshair.sideHintRight, cx + hintGap, hintY);
3968
+ }
3969
+ ctx.restore();
3970
+ }
3971
+ ctx.restore();
3972
+ if (crosshair.showVertical) {
3973
+ ctx.save();
3974
+ ctx.strokeStyle = crosshair.color;
3975
+ ctx.lineWidth = Math.max(1, crosshair.width);
3976
+ applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3977
+ ctx.beginPath();
3978
+ ctx.moveTo(crisp(cx), crisp(chartTop));
3979
+ ctx.lineTo(crisp(cx), crisp(fullChartBottom));
3980
+ ctx.stroke();
3981
+ ctx.restore();
3982
+ }
3983
+ const labelPaddingX = 8;
3984
+ const labelHeight = 20;
3985
+ const labelRadius = Math.max(0, crosshair.labelBorderRadius);
3986
+ const labelBackground = crosshair.labelBackgroundColor;
3987
+ const labelTextColor = crosshair.labelTextColor;
3988
+ const labelBorderColor = crosshair.labelBorderColor;
3989
+ const labelBorderWidth = Math.max(0, crosshair.labelBorderWidth);
3990
+ const labelBorderStyle = crosshair.labelBorderStyle;
3991
+ const strokeCrosshairLabel = (x, y, widthValue, heightValue = labelHeight) => {
3992
+ if (labelBorderWidth <= 0) {
3993
+ return;
3994
+ }
3995
+ ctx.save();
3996
+ ctx.strokeStyle = labelBorderColor;
3997
+ ctx.lineWidth = labelBorderWidth;
3998
+ applyDashPattern(
3999
+ labelBorderStyle,
4000
+ dashPatterns.borderDotted,
4001
+ dashPatterns.borderDashed
4002
+ );
4003
+ strokeRoundedRect(Math.round(x), Math.round(y), widthValue, heightValue, labelRadius);
4004
+ ctx.restore();
4005
+ };
4006
+ if (crosshair.showPriceLabel) {
4007
+ const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
4008
+ const priceText = formatPrice(hoverPrice);
4009
+ const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
4010
+ const priceX = getRightAxisLabelX(chartRight);
4011
+ const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
4012
+ ctx.fillStyle = labelBackground;
4013
+ fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
4014
+ strokeCrosshairLabel(priceX, priceY, priceWidth);
4015
+ drawText(priceText, priceX + labelPaddingX, priceY + labelHeight / 2, "left", "middle", labelTextColor);
4016
+ if (crosshair.showPriceActionButton) {
4017
+ const buttonSize = clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4));
4018
+ const buttonGap = Math.max(0, Math.round(crosshair.priceActionButtonGap));
4019
+ const containerPaddingX = 5;
4020
+ const containerWidth = buttonSize + containerPaddingX * 2;
4021
+ const containerX = priceX - buttonGap - containerWidth;
4022
+ const containerY = priceY;
4023
+ const buttonX = containerX + containerPaddingX;
4024
+ const buttonY = priceY + (labelHeight - buttonSize) / 2;
4025
+ const buttonRadius = crosshair.priceActionButtonRounded ? clamp(Math.round(crosshair.priceActionButtonBorderRadius), 0, buttonSize / 2) : 0;
4026
+ const buttonBorderWidth = Math.max(1, Math.round(labelBorderWidth || 1));
4027
+ const buttonCenterX = buttonX + buttonSize / 2;
4028
+ const buttonCenterY = buttonY + buttonSize / 2;
4029
+ const containerRadius = labelRadius;
3830
4030
  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([]);
4031
+ fillRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
4032
+ if (labelBorderWidth > 0) {
4033
+ ctx.save();
4034
+ ctx.strokeStyle = labelBorderColor;
4035
+ ctx.lineWidth = labelBorderWidth;
4036
+ ctx.setLineDash([]);
4037
+ strokeRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
4038
+ ctx.restore();
4039
+ }
4040
+ if (buttonBorderWidth > 0) {
4041
+ ctx.save();
4042
+ ctx.strokeStyle = labelBorderColor;
4043
+ ctx.lineWidth = buttonBorderWidth;
4044
+ ctx.setLineDash([]);
4045
+ if (crosshair.priceActionButtonRounded) {
3886
4046
  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,
4047
+ ctx.arc(
3896
4048
  buttonCenterX,
3897
4049
  buttonCenterY,
3898
- "center",
3899
- "middle",
3900
- labelTextColor
4050
+ Math.max(1, buttonSize / 2 - buttonBorderWidth / 2),
4051
+ 0,
4052
+ Math.PI * 2
3901
4053
  );
4054
+ ctx.stroke();
4055
+ } else {
4056
+ strokeRoundedRect(Math.round(buttonX), Math.round(buttonY), buttonSize, buttonSize, buttonRadius);
3902
4057
  }
3903
- crosshairPriceActionRegion = {
3904
- x: containerX,
3905
- y: containerY,
3906
- width: containerWidth,
3907
- height: labelHeight,
3908
- price: Number(hoverPrice.toFixed(mergedOptions.priceDecimals))
3909
- };
4058
+ ctx.restore();
3910
4059
  }
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);
4060
+ if (crosshair.priceActionButtonIcon !== "text" && crosshair.priceActionButtonText === "+") {
4061
+ const plusHalf = Math.max(3, Math.round(buttonSize * 0.22));
4062
+ const plusThickness = crosshair.priceActionButtonIcon === "plusThin" ? Math.max(1, Math.round(buttonSize * 0.07)) : Math.max(1, Math.round(buttonSize * 0.1));
4063
+ ctx.save();
4064
+ ctx.strokeStyle = labelTextColor;
4065
+ ctx.lineWidth = plusThickness;
4066
+ ctx.lineCap = "round";
4067
+ ctx.setLineDash([]);
4068
+ ctx.beginPath();
4069
+ ctx.moveTo(buttonCenterX - plusHalf, buttonCenterY);
4070
+ ctx.lineTo(buttonCenterX + plusHalf, buttonCenterY);
4071
+ ctx.moveTo(buttonCenterX, buttonCenterY - plusHalf);
4072
+ ctx.lineTo(buttonCenterX, buttonCenterY + plusHalf);
4073
+ ctx.stroke();
4074
+ ctx.restore();
4075
+ } else {
4076
+ drawText(
4077
+ crosshair.priceActionButtonText,
4078
+ buttonCenterX,
4079
+ buttonCenterY,
4080
+ "center",
4081
+ "middle",
4082
+ labelTextColor
4083
+ );
3926
4084
  }
4085
+ crosshairPriceActionRegion = {
4086
+ x: containerX,
4087
+ y: containerY,
4088
+ width: containerWidth,
4089
+ height: labelHeight,
4090
+ price: Number(hoverPrice.toFixed(mergedOptions.priceDecimals))
4091
+ };
3927
4092
  }
3928
4093
  }
4094
+ if (crosshair.showTimeLabel) {
4095
+ const ratio = clamp((cx - chartLeft) / chartWidth, 0, 1);
4096
+ const hoverIndex = Math.round(xStart + ratio * xSpan2 - 0.5);
4097
+ const hoverTime = getTimeForIndex(hoverIndex);
4098
+ if (hoverTime) {
4099
+ const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
4100
+ const timeWidth = Math.ceil(measureTextWidth(timeText)) + labelPaddingX * 2;
4101
+ const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
4102
+ const timeY = fullChartBottom + 1;
4103
+ const timeHeight = Math.max(labelHeight, Math.floor(height - timeY));
4104
+ ctx.fillStyle = labelBackground;
4105
+ fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, timeHeight, labelRadius);
4106
+ strokeCrosshairLabel(timeX, timeY, timeWidth, timeHeight);
4107
+ drawText(timeText, timeX + labelPaddingX, timeY + timeHeight / 2, "left", "middle", labelTextColor);
4108
+ }
4109
+ }
4110
+ };
4111
+ const drawOverlayOnly = () => {
4112
+ if (!baseCtx || !overlayLayout || baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height || baseCanvas.width === 0) {
4113
+ draw({ updateAutoScale: false });
4114
+ return;
4115
+ }
4116
+ const pixelRatio = getPixelRatio();
4117
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4118
+ ctx.drawImage(baseCanvas, 0, 0);
4119
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
4120
+ paintCrosshairOverlay();
3929
4121
  };
3930
4122
  const zoomX = (factor, anchorX) => {
3931
4123
  if (!drawState || data.length === 0) {
@@ -4016,6 +4208,59 @@ function createChart(element, options = {}) {
4016
4208
  }
4017
4209
  scheduleDraw();
4018
4210
  };
4211
+ let kineticRafId = null;
4212
+ const panVelocitySamples = [];
4213
+ const cancelKineticPan = () => {
4214
+ if (kineticRafId !== null) {
4215
+ cancelAnimationFrame(kineticRafId);
4216
+ kineticRafId = null;
4217
+ }
4218
+ };
4219
+ const recordPanVelocitySample = (x) => {
4220
+ const now = performance.now();
4221
+ panVelocitySamples.push({ t: now, x });
4222
+ while (panVelocitySamples.length > 0 && now - panVelocitySamples[0].t > 120) {
4223
+ panVelocitySamples.shift();
4224
+ }
4225
+ };
4226
+ const startKineticPan = (initialVelocityPxPerMs) => {
4227
+ cancelKineticPan();
4228
+ let velocity = initialVelocityPxPerMs;
4229
+ let lastTime = performance.now();
4230
+ const stepFn = (now) => {
4231
+ kineticRafId = null;
4232
+ const dt = Math.min(64, Math.max(1, now - lastTime));
4233
+ lastTime = now;
4234
+ const centerBefore = xCenter;
4235
+ pan(velocity * dt, 0, true, false);
4236
+ velocity *= Math.exp(-dt / 325);
4237
+ if (Math.abs(velocity) < 0.02 || xCenter === centerBefore) {
4238
+ return;
4239
+ }
4240
+ kineticRafId = requestAnimationFrame(stepFn);
4241
+ };
4242
+ kineticRafId = requestAnimationFrame(stepFn);
4243
+ };
4244
+ const maybeStartKineticPan = (pointerType) => {
4245
+ const config = mergedOptions.kineticScroll ?? DEFAULT_OPTIONS.kineticScroll;
4246
+ const enabled = pointerType === "touch" || pointerType === "pen" ? config?.touch !== false : config?.mouse === true;
4247
+ if (!enabled || panVelocitySamples.length < 2) {
4248
+ panVelocitySamples.length = 0;
4249
+ return;
4250
+ }
4251
+ const first = panVelocitySamples[0];
4252
+ const last = panVelocitySamples[panVelocitySamples.length - 1];
4253
+ panVelocitySamples.length = 0;
4254
+ const elapsed = last.t - first.t;
4255
+ if (elapsed <= 0 || performance.now() - last.t > 100) {
4256
+ return;
4257
+ }
4258
+ const velocity = (last.x - first.x) / elapsed;
4259
+ if (Math.abs(velocity) < 0.1) {
4260
+ return;
4261
+ }
4262
+ startKineticPan(clamp(velocity, -4, 4));
4263
+ };
4019
4264
  const resetYViewport = () => {
4020
4265
  yMinOverride = null;
4021
4266
  yMaxOverride = null;
@@ -4075,6 +4320,7 @@ function createChart(element, options = {}) {
4075
4320
  scheduleDraw();
4076
4321
  };
4077
4322
  const resetViewport = () => {
4323
+ cancelKineticPan();
4078
4324
  fitXViewport();
4079
4325
  resetYViewport();
4080
4326
  updateFollowLatest(true);
@@ -4084,6 +4330,7 @@ function createChart(element, options = {}) {
4084
4330
  const isFollowingLatest = () => followLatest;
4085
4331
  const setFollowingLatest = (follow) => {
4086
4332
  if (follow && data.length > 0) {
4333
+ cancelKineticPan();
4087
4334
  xCenter = data.length - xSpan / 2 + rightEdgePaddingBars;
4088
4335
  clampXViewport();
4089
4336
  updateFollowLatest(true);
@@ -4453,7 +4700,7 @@ function createChart(element, options = {}) {
4453
4700
  const prevFont = ctx.font;
4454
4701
  ctx.font = `500 ${fontSize}px ${mergedOptions.fontFamily}`;
4455
4702
  const lines = (drawing.label ?? "").split("\n");
4456
- const textW = Math.max(1, ...lines.map((line) => ctx.measureText(line).width));
4703
+ const textW = Math.max(1, ...lines.map((line) => measureTextWidth(line)));
4457
4704
  ctx.font = prevFont;
4458
4705
  const isNote = drawing.type === "note";
4459
4706
  const padX = isNote ? 8 : 2;
@@ -4926,16 +5173,24 @@ function createChart(element, options = {}) {
4926
5173
  emitViewportChange();
4927
5174
  scheduleDraw();
4928
5175
  };
5176
+ const capturePointer = (pointerId) => {
5177
+ try {
5178
+ canvas.setPointerCapture(pointerId);
5179
+ } catch {
5180
+ }
5181
+ };
4929
5182
  const onPointerDown = (event) => {
4930
5183
  if (event.pointerType === "touch" || event.pointerType === "pen") {
4931
5184
  event.preventDefault();
4932
5185
  }
5186
+ cancelKineticPan();
5187
+ panVelocitySamples.length = 0;
4933
5188
  magnetModifierActive = event.metaKey || event.ctrlKey;
4934
5189
  shiftKeyActive = event.shiftKey;
4935
5190
  const point = getCanvasPoint(event);
4936
5191
  if (event.pointerType === "touch") {
4937
5192
  touchPointers.set(event.pointerId, point);
4938
- canvas.setPointerCapture(event.pointerId);
5193
+ capturePointer(event.pointerId);
4939
5194
  const touchPair = getTouchPair();
4940
5195
  if (touchPair) {
4941
5196
  beginPinchZoom(touchPair[0], touchPair[1]);
@@ -4965,7 +5220,7 @@ function createChart(element, options = {}) {
4965
5220
  lastPrice: startPrice,
4966
5221
  moved: false
4967
5222
  };
4968
- canvas.setPointerCapture(event.pointerId);
5223
+ capturePointer(event.pointerId);
4969
5224
  canvas.style.cursor = "ns-resize";
4970
5225
  setCrosshairPoint(null);
4971
5226
  return;
@@ -4986,7 +5241,7 @@ function createChart(element, options = {}) {
4986
5241
  startPrice: orderDragRegion.price,
4987
5242
  lastPrice: orderDragRegion.price
4988
5243
  };
4989
- canvas.setPointerCapture(event.pointerId);
5244
+ capturePointer(event.pointerId);
4990
5245
  canvas.style.cursor = "ns-resize";
4991
5246
  setCrosshairPoint(null);
4992
5247
  return;
@@ -5018,7 +5273,7 @@ function createChart(element, options = {}) {
5018
5273
  startPoints: drawingHit.drawing.points.map((drawingPoint) => ({ ...drawingPoint }))
5019
5274
  };
5020
5275
  activePointerId = event.pointerId;
5021
- canvas.setPointerCapture(event.pointerId);
5276
+ capturePointer(event.pointerId);
5022
5277
  }
5023
5278
  }
5024
5279
  setCrosshairPoint(null);
@@ -5046,7 +5301,7 @@ function createChart(element, options = {}) {
5046
5301
  };
5047
5302
  lastPointerX = point.x;
5048
5303
  lastPointerY = point.y;
5049
- canvas.setPointerCapture(event.pointerId);
5304
+ capturePointer(event.pointerId);
5050
5305
  if (crosshairDrag) {
5051
5306
  canvas.style.cursor = "crosshair";
5052
5307
  setCrosshairPoint(point);
@@ -5232,6 +5487,7 @@ function createChart(element, options = {}) {
5232
5487
  const deltaY = point.y - lastPointerY;
5233
5488
  if (dragMode === "plot") {
5234
5489
  canvas.style.cursor = "grabbing";
5490
+ recordPanVelocitySample(point.x);
5235
5491
  pan(deltaX, deltaY, true, true);
5236
5492
  setCrosshairPoint(null);
5237
5493
  } else if (dragMode === "x-axis") {
@@ -5305,10 +5561,16 @@ function createChart(element, options = {}) {
5305
5561
  drawingDragState = null;
5306
5562
  emitDrawingsChange();
5307
5563
  }
5564
+ const endedPlotDrag = isDragging && dragMode === "plot";
5308
5565
  isDragging = false;
5309
5566
  dragMode = null;
5310
5567
  activePointerId = null;
5311
5568
  canvas.style.cursor = "default";
5569
+ if (endedPlotDrag) {
5570
+ maybeStartKineticPan(event?.pointerType);
5571
+ } else {
5572
+ panVelocitySamples.length = 0;
5573
+ }
5312
5574
  if (event && pointerDownInfo && event.pointerId === pointerDownInfo.pointerId) {
5313
5575
  if (pointerDownInfo.crosshairDrag) {
5314
5576
  const point = getCanvasPoint(event);
@@ -5346,6 +5608,7 @@ function createChart(element, options = {}) {
5346
5608
  if (!drawState) {
5347
5609
  return;
5348
5610
  }
5611
+ cancelKineticPan();
5349
5612
  const point = getCanvasPoint(event);
5350
5613
  const region = getHitRegion(point.x, point.y);
5351
5614
  if (region === "outside") {
@@ -5445,6 +5708,7 @@ function createChart(element, options = {}) {
5445
5708
  width = nextOptions.width !== void 0 && nextOptions.width > 0 ? mergedOptions.width : previousWidth;
5446
5709
  height = nextOptions.height !== void 0 && nextOptions.height > 0 ? mergedOptions.height : previousHeight;
5447
5710
  mergedOptions = { ...mergedOptions, width, height };
5711
+ refreshResolvedOptions();
5448
5712
  resetRightMarginCache();
5449
5713
  doubleClickEnabled = mergedOptions.doubleClickEnabled;
5450
5714
  doubleClickAction = mergedOptions.doubleClickAction;
@@ -5789,6 +6053,7 @@ function createChart(element, options = {}) {
5789
6053
  drawingHoverHandler = handler;
5790
6054
  };
5791
6055
  const destroy = () => {
6056
+ cancelKineticPan();
5792
6057
  if (smoothingRafId !== null) {
5793
6058
  cancelAnimationFrame(smoothingRafId);
5794
6059
  smoothingRafId = null;