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.
@@ -189,8 +189,12 @@ var DEFAULT_OPTIONS = {
189
189
  tickSize: 0,
190
190
  candleColorMode: "openClose",
191
191
  candleColorEpsilon: -1,
192
- autoScaleSmoothing: 0.16,
192
+ // 0 = deterministic scale (lightweight-charts behavior): the y-range is a
193
+ // pure function of the visible data, so it can never drift or "breathe".
194
+ // Values > 0 re-enable eased contraction for hosts that prefer it.
195
+ autoScaleSmoothing: 0,
193
196
  autoScaleIgnoreLatestCandle: true,
197
+ kineticScroll: { touch: true, mouse: false },
194
198
  pinOutOfRangeLines: false,
195
199
  doubleClickEnabled: true,
196
200
  doubleClickAction: "reset",
@@ -589,6 +593,26 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
589
593
  }
590
594
  ctx.restore();
591
595
  };
596
+ var rangeOfSeries = (seriesList, startIndex, endIndex, skipIndex) => {
597
+ let min = Number.POSITIVE_INFINITY;
598
+ let max = Number.NEGATIVE_INFINITY;
599
+ for (const series of seriesList) {
600
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
601
+ if (idx === skipIndex) continue;
602
+ const value = series[idx];
603
+ if (value == null || !Number.isFinite(value)) continue;
604
+ if (value < min) min = value;
605
+ if (value > max) max = value;
606
+ }
607
+ }
608
+ return min <= max ? { min, max } : null;
609
+ };
610
+ var maAutoscaleRange = (cacheKeyPrefix, compute, defaultLength) => (data, startIndex, endIndex, inputs, skipIndex) => {
611
+ const length = clampIndicatorLength(inputs.length, defaultLength);
612
+ const source = inputs.source ?? "close";
613
+ const series = withCachedSeries(`${cacheKeyPrefix}|${length}|${source}`, data, () => compute(data, length, source));
614
+ return rangeOfSeries([series], startIndex, endIndex, skipIndex);
615
+ };
592
616
  var fillBetweenSeries = (ctx, renderContext, upper, lower, color, opacity) => {
593
617
  if (!renderContext.yFromPrice || opacity <= 0) return;
594
618
  const yFromPrice = renderContext.yFromPrice;
@@ -806,7 +830,8 @@ var BUILTIN_SMA_INDICATOR = {
806
830
  () => computeSmaSeries(renderContext.data, length, inputs.source ?? "close")
807
831
  );
808
832
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#60a5fa", Number(inputs.width) || 2);
809
- }
833
+ },
834
+ getAutoscaleRange: maAutoscaleRange("sma", computeSmaSeries, 20)
810
835
  };
811
836
  var BUILTIN_EMA_INDICATOR = {
812
837
  id: "ema",
@@ -821,7 +846,8 @@ var BUILTIN_EMA_INDICATOR = {
821
846
  () => computeEmaSeries(renderContext.data, length, inputs.source ?? "close")
822
847
  );
823
848
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
824
- }
849
+ },
850
+ getAutoscaleRange: maAutoscaleRange("ema", computeEmaSeries, 20)
825
851
  };
826
852
  var BUILTIN_WMA_INDICATOR = {
827
853
  id: "wma",
@@ -836,7 +862,8 @@ var BUILTIN_WMA_INDICATOR = {
836
862
  () => computeWmaSeries(renderContext.data, length, inputs.source ?? "close")
837
863
  );
838
864
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#a78bfa", Number(inputs.width) || 2);
839
- }
865
+ },
866
+ getAutoscaleRange: maAutoscaleRange("wma", computeWmaSeries, 20)
840
867
  };
841
868
  var BUILTIN_VWMA_INDICATOR = {
842
869
  id: "vwma",
@@ -851,7 +878,8 @@ var BUILTIN_VWMA_INDICATOR = {
851
878
  () => computeVwmaSeries(renderContext.data, length, inputs.source ?? "close")
852
879
  );
853
880
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#ef4444", Number(inputs.width) || 2);
854
- }
881
+ },
882
+ getAutoscaleRange: maAutoscaleRange("vwma", computeVwmaSeries, 20)
855
883
  };
856
884
  var BUILTIN_RMA_INDICATOR = {
857
885
  id: "rma",
@@ -866,7 +894,8 @@ var BUILTIN_RMA_INDICATOR = {
866
894
  () => computeRmaSeries(renderContext.data, length, inputs.source ?? "close")
867
895
  );
868
896
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#22c55e", Number(inputs.width) || 2);
869
- }
897
+ },
898
+ getAutoscaleRange: maAutoscaleRange("rma", computeRmaSeries, 14)
870
899
  };
871
900
  var BUILTIN_HMA_INDICATOR = {
872
901
  id: "hma",
@@ -881,7 +910,8 @@ var BUILTIN_HMA_INDICATOR = {
881
910
  () => computeHmaSeries(renderContext.data, length, inputs.source ?? "close")
882
911
  );
883
912
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
884
- }
913
+ },
914
+ getAutoscaleRange: maAutoscaleRange("hma", computeHmaSeries, 21)
885
915
  };
886
916
  var BUILTIN_VWAP_INDICATOR = {
887
917
  id: "vwap",
@@ -922,6 +952,27 @@ var BUILTIN_VWAP_INDICATOR = {
922
952
  drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
923
953
  }
924
954
  drawOverlaySeries(ctx, renderContext, vwap, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
955
+ },
956
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
957
+ const vwap = withCachedSeries("vwap|line", data, () => computeVwapSeries(data, "vwap"));
958
+ if (!inputs.showBands) {
959
+ return rangeOfSeries([vwap], startIndex, endIndex, skipIndex);
960
+ }
961
+ const sigma = withCachedSeries("vwap|sigma", data, () => computeVwapSeries(data, "sigma"));
962
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
963
+ let min = Number.POSITIVE_INFINITY;
964
+ let max = Number.NEGATIVE_INFINITY;
965
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
966
+ if (idx === skipIndex) continue;
967
+ const value = vwap[idx];
968
+ const s = sigma[idx];
969
+ if (value == null || s == null) continue;
970
+ const lo = value - multiplier * s;
971
+ const hi = value + multiplier * s;
972
+ if (lo < min) min = lo;
973
+ if (hi > max) max = hi;
974
+ }
975
+ return min <= max ? { min, max } : null;
925
976
  }
926
977
  };
927
978
  var BUILTIN_BOLLINGER_INDICATOR = {
@@ -963,7 +1014,23 @@ var BUILTIN_BOLLINGER_INDICATOR = {
963
1014
  fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.fillOpacity) || 0.05);
964
1015
  drawOverlaySeries(ctx, renderContext, upper, bandColor, width);
965
1016
  drawOverlaySeries(ctx, renderContext, lower, bandColor, width);
966
- drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#f59e0b", width);
1017
+ drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#ec4899", width);
1018
+ },
1019
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
1020
+ const length = clampIndicatorLength(inputs.length, 20);
1021
+ const multiplier = Math.max(0.1, Number(inputs.multiplier) || 2);
1022
+ const source = inputs.source ?? "close";
1023
+ const upper = withCachedSeries(
1024
+ `bb|upper|${length}|${multiplier}|${source}`,
1025
+ data,
1026
+ () => computeBollingerSeries(data, length, multiplier, source, "upper")
1027
+ );
1028
+ const lower = withCachedSeries(
1029
+ `bb|lower|${length}|${multiplier}|${source}`,
1030
+ data,
1031
+ () => computeBollingerSeries(data, length, multiplier, source, "lower")
1032
+ );
1033
+ return rangeOfSeries([upper, lower], startIndex, endIndex, skipIndex);
967
1034
  }
968
1035
  };
969
1036
  var BUILTIN_STDDEV_INDICATOR = {
@@ -1056,6 +1123,20 @@ var BUILTIN_INDICATORS = [
1056
1123
  ];
1057
1124
  function createChart(element, options = {}) {
1058
1125
  let mergedOptions = mergeChartOptions(DEFAULT_OPTIONS, options);
1126
+ let resolvedAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1127
+ let resolvedXAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
1128
+ let resolvedYAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
1129
+ let resolvedLabels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1130
+ let resolvedGrid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
1131
+ let resolvedCrosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1132
+ const refreshResolvedOptions = () => {
1133
+ resolvedAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1134
+ resolvedXAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
1135
+ resolvedYAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
1136
+ resolvedLabels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1137
+ resolvedGrid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
1138
+ resolvedCrosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1139
+ };
1059
1140
  let width = mergedOptions.width;
1060
1141
  let height = mergedOptions.height;
1061
1142
  let data = [];
@@ -1092,8 +1173,11 @@ function createChart(element, options = {}) {
1092
1173
  pane: indicator.pane ?? plugin?.pane ?? "overlay",
1093
1174
  ...indicator.paneHeightRatio === void 0 ? {} : { paneHeightRatio: indicator.paneHeightRatio },
1094
1175
  zIndex: Math.round(Number(indicator.zIndex) || 0),
1095
- excludeFromAutoscale: indicator.excludeFromAutoscale ?? true,
1096
- overlayScaleWeight: Math.min(1, Math.max(0, Number(indicator.overlayScaleWeight) || 0.25)),
1176
+ // Overlay series participate in autoscale by default (lightweight-charts
1177
+ // semantics): bands and MAs expand the price range instead of clipping
1178
+ // at the chart edge. Hosts can still opt out per indicator.
1179
+ excludeFromAutoscale: indicator.excludeFromAutoscale ?? false,
1180
+ overlayScaleWeight: indicator.overlayScaleWeight === void 0 ? 1 : Math.min(1, Math.max(0, Number(indicator.overlayScaleWeight) || 0)),
1097
1181
  inputs: {
1098
1182
  ...defaults,
1099
1183
  ...indicator.inputs ?? {}
@@ -1295,6 +1379,9 @@ function createChart(element, options = {}) {
1295
1379
  canvas.setAttribute("draggable", "false");
1296
1380
  element.innerHTML = "";
1297
1381
  element.appendChild(canvas);
1382
+ const baseCanvas = document.createElement("canvas");
1383
+ const baseCtx = baseCanvas.getContext("2d");
1384
+ let overlayLayout = null;
1298
1385
  const margin = { top: 16, right: 72, bottom: 26, left: 12 };
1299
1386
  let cachedRightMargin = margin.right;
1300
1387
  const minVisibleBars = Math.max(1, Math.floor(mergedOptions.minVisibleBars));
@@ -1334,6 +1421,20 @@ function createChart(element, options = {}) {
1334
1421
  const clamp = (value, min, max) => {
1335
1422
  return Math.min(max, Math.max(min, value));
1336
1423
  };
1424
+ const textWidthCache = /* @__PURE__ */ new Map();
1425
+ const measureTextWidth = (text) => {
1426
+ const key = `${ctx.font}\0${text}`;
1427
+ const cached = textWidthCache.get(key);
1428
+ if (cached !== void 0) {
1429
+ return cached;
1430
+ }
1431
+ const width2 = ctx.measureText(text).width;
1432
+ if (textWidthCache.size >= 4096) {
1433
+ textWidthCache.clear();
1434
+ }
1435
+ textWidthCache.set(key, width2);
1436
+ return width2;
1437
+ };
1337
1438
  const resetLabelSlots = (enabled) => {
1338
1439
  noOverlappingLineLabels = enabled;
1339
1440
  rightAxisLabelSlots = [];
@@ -1536,7 +1637,7 @@ function createChart(element, options = {}) {
1536
1637
  return `${integerPart}${decimalPart}`;
1537
1638
  };
1538
1639
  const getMeasuredLabelWidth = (text, paddingX) => {
1539
- return Math.ceil(ctx.measureText(text).width) + paddingX * 2;
1640
+ return Math.ceil(measureTextWidth(text)) + paddingX * 2;
1540
1641
  };
1541
1642
  const getStabilizedNumericLabelWidth = (text, paddingX) => {
1542
1643
  const measured = getMeasuredLabelWidth(text, paddingX);
@@ -1777,11 +1878,11 @@ function createChart(element, options = {}) {
1777
1878
  return;
1778
1879
  }
1779
1880
  const labelText = mergedLine.label ?? formatPrice(mergedLine.price);
1780
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1881
+ const axis = resolvedAxis;
1781
1882
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1782
1883
  const labelPaddingX = 8;
1783
1884
  const labelHeight = 20;
1784
- const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
1885
+ const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(measureTextWidth(labelText)) + labelPaddingX * 2;
1785
1886
  const labelWidth = getRightAxisLabelWidth(chartRight, measuredLabelWidth);
1786
1887
  const labelX = getRightAxisLabelX(chartRight);
1787
1888
  const labelY = placeRightAxisLabel(lineY - labelHeight / 2, labelHeight, chartTop, chartBottom - labelHeight);
@@ -1876,7 +1977,7 @@ function createChart(element, options = {}) {
1876
1977
  const closeAction = mergedLine.type === "market" ? "close" : "cancel";
1877
1978
  const showCloseButton = mergedLine.showCloseButton;
1878
1979
  const actionButtonText = mergedLine.actionButtonText;
1879
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1980
+ const axis = resolvedAxis;
1880
1981
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1881
1982
  const legacyButton = actionButtonText ? [
1882
1983
  {
@@ -1898,7 +1999,7 @@ function createChart(element, options = {}) {
1898
1999
  const actionButtonMetrics = actionButtons.map((button) => {
1899
2000
  const paddingX = Math.max(2, button.paddingX ?? mergedLine.actionButtonPaddingX);
1900
2001
  const minWidth = Math.max(16, button.minWidth ?? mergedLine.actionButtonMinWidth);
1901
- const width2 = Math.max(minWidth, Math.ceil(ctx.measureText(button.text).width) + paddingX * 2);
2002
+ const width2 = Math.max(minWidth, Math.ceil(measureTextWidth(button.text)) + paddingX * 2);
1902
2003
  return { button, width: width2 };
1903
2004
  });
1904
2005
  const actionButtonInnerGap = actionButtonMetrics.length > 1 ? Math.max(0, mergedLine.actionButtonsInnerGap) : 0;
@@ -1906,8 +2007,8 @@ function createChart(element, options = {}) {
1906
2007
  const actionButtonsGap = actionButtonMetrics.length > 0 ? Math.max(0, mergedLine.actionButtonsGroupGap) : 0;
1907
2008
  const segmentPaddingX = 8;
1908
2009
  const labelHeight = 22;
1909
- const qtyWidth = qtyText ? Math.ceil(ctx.measureText(qtyText).width) + segmentPaddingX * 2 : 0;
1910
- const centerMeasuredWidth = Math.ceil(ctx.measureText(centerText).width) + segmentPaddingX * 2;
2010
+ const qtyWidth = qtyText ? Math.ceil(measureTextWidth(qtyText)) + segmentPaddingX * 2 : 0;
2011
+ const centerMeasuredWidth = Math.ceil(measureTextWidth(centerText)) + segmentPaddingX * 2;
1911
2012
  const centerWidth = mergedLine.id === void 0 ? centerMeasuredWidth : Math.max(centerMeasuredWidth, orderWidgetWidthById.get(mergedLine.id) ?? 0);
1912
2013
  if (mergedLine.id) {
1913
2014
  orderWidgetWidthById.set(mergedLine.id, centerWidth);
@@ -1915,7 +2016,7 @@ function createChart(element, options = {}) {
1915
2016
  const closeWidth = showCloseButton ? 24 : 0;
1916
2017
  const mainWidgetWidth = qtyWidth + centerWidth + closeWidth;
1917
2018
  const totalWidth = mainWidgetWidth + actionButtonsGap + actionButtonsTotalWidth;
1918
- const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
2019
+ const crosshair = resolvedCrosshair;
1919
2020
  const crosshairActionInset = crosshair.visible && crosshair.showPriceLabel && crosshair.showPriceActionButton ? Math.max(
1920
2021
  0,
1921
2022
  Math.round(crosshair.priceActionButtonGap) + (clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4)) + 10) - 4
@@ -2096,20 +2197,29 @@ function createChart(element, options = {}) {
2096
2197
  }
2097
2198
  }
2098
2199
  crosshairPoint = point;
2099
- scheduleDraw();
2200
+ scheduleDraw({ overlayOnly: true });
2100
2201
  };
2101
2202
  let drawRafId = null;
2102
2203
  let pendingDrawUpdateAutoScale = false;
2204
+ let pendingDrawOverlayOnly = true;
2103
2205
  const scheduleDraw = (options2 = {}) => {
2104
- pendingDrawUpdateAutoScale = pendingDrawUpdateAutoScale || (options2.updateAutoScale ?? true);
2206
+ const overlayOnly = options2.overlayOnly ?? false;
2207
+ pendingDrawOverlayOnly = pendingDrawOverlayOnly && overlayOnly;
2208
+ pendingDrawUpdateAutoScale = pendingDrawUpdateAutoScale || !overlayOnly && (options2.updateAutoScale ?? true);
2105
2209
  if (drawRafId !== null) {
2106
2210
  return;
2107
2211
  }
2108
2212
  drawRafId = requestAnimationFrame(() => {
2109
2213
  drawRafId = null;
2110
2214
  const updateAutoScale = pendingDrawUpdateAutoScale;
2215
+ const flushOverlayOnly = pendingDrawOverlayOnly;
2111
2216
  pendingDrawUpdateAutoScale = false;
2112
- draw({ updateAutoScale });
2217
+ pendingDrawOverlayOnly = true;
2218
+ if (flushOverlayOnly) {
2219
+ drawOverlayOnly();
2220
+ } else {
2221
+ draw({ updateAutoScale });
2222
+ }
2113
2223
  });
2114
2224
  };
2115
2225
  const draw = (options2 = {}) => {
@@ -2123,15 +2233,15 @@ function createChart(element, options = {}) {
2123
2233
  canvas.width = Math.floor(width * pixelRatio);
2124
2234
  canvas.height = Math.floor(height * pixelRatio);
2125
2235
  ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
2126
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
2127
- const xAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
2128
- const yAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
2236
+ const axis = resolvedAxis;
2237
+ const xAxis = resolvedXAxis;
2238
+ const yAxis = resolvedYAxis;
2129
2239
  const xAxisFontSize = Math.max(8, xAxis.fontSize);
2130
2240
  const yAxisFontSize = Math.max(8, yAxis.fontSize);
2131
2241
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2132
2242
  ctx.fillStyle = mergedOptions.backgroundColor;
2133
2243
  ctx.fillRect(0, 0, width, height);
2134
- const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
2244
+ const labels = resolvedLabels;
2135
2245
  const estimateRightMargin = () => {
2136
2246
  const paddingX = Math.max(4, labels.labelPaddingX);
2137
2247
  let required = margin.right - 1;
@@ -2244,6 +2354,7 @@ function createChart(element, options = {}) {
2244
2354
  yMax: 0
2245
2355
  };
2246
2356
  drawText("Load OHLC data to render chart", chartLeft + 10, chartTop + 20, "left", "middle", "#64748b");
2357
+ overlayLayout = null;
2247
2358
  return;
2248
2359
  }
2249
2360
  clampXViewport();
@@ -2251,62 +2362,39 @@ function createChart(element, options = {}) {
2251
2362
  const xEnd = xStart + xSpan;
2252
2363
  const startIndex = Math.max(0, Math.floor(xStart));
2253
2364
  const endIndex = Math.min(data.length - 1, Math.ceil(xEnd) - 1);
2254
- const visibleData = data.slice(startIndex, endIndex + 1);
2255
- let priceSource = visibleData.length > 0 ? visibleData : data;
2256
- if (mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1) {
2257
- const latestIndex = data.length - 1;
2258
- const filtered = priceSource.filter((_, offset) => startIndex + offset !== latestIndex);
2259
- if (filtered.length > 0) {
2260
- priceSource = filtered;
2261
- } else {
2262
- const fallbackWindow = 120;
2263
- const fallbackStart = Math.max(0, latestIndex - fallbackWindow);
2264
- const fallback = data.slice(fallbackStart, latestIndex);
2265
- if (fallback.length > 0) {
2266
- priceSource = fallback;
2267
- }
2365
+ const skipLatestIndex = mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1 ? data.length - 1 : -1;
2366
+ const scanCandleRange = (from, to) => {
2367
+ let min = Number.POSITIVE_INFINITY;
2368
+ let max = Number.NEGATIVE_INFINITY;
2369
+ for (let idx = from; idx <= to; idx += 1) {
2370
+ if (idx === skipLatestIndex) continue;
2371
+ const point = data[idx];
2372
+ if (!point) continue;
2373
+ if (point.l < min) min = point.l;
2374
+ if (point.h > max) max = point.h;
2268
2375
  }
2376
+ return min <= max ? { min, max } : null;
2377
+ };
2378
+ let candleRange = scanCandleRange(startIndex, endIndex);
2379
+ if (!candleRange) {
2380
+ const latestIndex = data.length - 1;
2381
+ candleRange = scanCandleRange(Math.max(0, latestIndex - 120), latestIndex - 1) ?? scanCandleRange(0, latestIndex) ?? { min: data[latestIndex].l, max: data[latestIndex].h };
2269
2382
  }
2270
- let minPrice = Math.min(...priceSource.map((point) => point.l));
2271
- let maxPrice = Math.max(...priceSource.map((point) => point.h));
2383
+ let minPrice = candleRange.min;
2384
+ let maxPrice = candleRange.max;
2272
2385
  if (overlayIndicatorsForScale.length > 0) {
2273
- for (const { indicator } of overlayIndicatorsForScale) {
2386
+ for (const { indicator, plugin } of overlayIndicatorsForScale) {
2274
2387
  if (indicator.excludeFromAutoscale) {
2275
2388
  continue;
2276
2389
  }
2277
- const type = indicator.type;
2278
- const inputs = indicator.inputs;
2279
- const source = inputs.source ?? "close";
2280
- const length = clampIndicatorLength(inputs.length ?? 14, 14);
2281
- let series = null;
2282
- if (type === "sma") series = withCachedSeries(`sma|${length}|${source}`, data, () => computeSmaSeries(data, length, source));
2283
- if (type === "ema") series = withCachedSeries(`ema|${length}|${source}`, data, () => computeEmaSeries(data, length, source));
2284
- if (type === "wma") series = withCachedSeries(`wma|${length}|${source}`, data, () => computeWmaSeries(data, length, source));
2285
- if (type === "vwma") series = withCachedSeries(`vwma|${length}|${source}`, data, () => computeVwmaSeries(data, length, source));
2286
- if (type === "rma") series = withCachedSeries(`rma|${length}|${source}`, data, () => computeRmaSeries(data, length, source));
2287
- if (type === "hma") series = withCachedSeries(`hma|${length}|${source}`, data, () => computeHmaSeries(data, length, source));
2288
- if (!series) {
2289
- continue;
2290
- }
2291
- const visibleValues = [];
2292
- for (let idx = startIndex; idx <= endIndex; idx += 1) {
2293
- if (mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1 && idx === data.length - 1) {
2294
- continue;
2295
- }
2296
- const value = series[idx];
2297
- if (Number.isFinite(value ?? Number.NaN)) {
2298
- visibleValues.push(value);
2299
- }
2300
- }
2301
- if (visibleValues.length === 0) {
2390
+ const range = plugin.getAutoscaleRange?.(data, startIndex, endIndex, indicator.inputs, skipLatestIndex);
2391
+ if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) {
2302
2392
  continue;
2303
2393
  }
2304
- const seriesMin = Math.min(...visibleValues);
2305
- const seriesMax = Math.max(...visibleValues);
2306
2394
  const weight = Math.min(1, Math.max(0, indicator.overlayScaleWeight));
2307
2395
  const currentMid = (minPrice + maxPrice) / 2;
2308
- const weightedMin = currentMid + (seriesMin - currentMid) * weight;
2309
- const weightedMax = currentMid + (seriesMax - currentMid) * weight;
2396
+ const weightedMin = currentMid + (range.min - currentMid) * weight;
2397
+ const weightedMax = currentMid + (range.max - currentMid) * weight;
2310
2398
  minPrice = Math.min(minPrice, weightedMin);
2311
2399
  maxPrice = Math.max(maxPrice, weightedMax);
2312
2400
  }
@@ -2381,7 +2469,7 @@ function createChart(element, options = {}) {
2381
2469
  ctx.save();
2382
2470
  const prevFont = ctx.font;
2383
2471
  ctx.font = `500 12px ${mergedOptions.fontFamily}`;
2384
- const textWidth = ctx.measureText(labelText).width;
2472
+ const textWidth = measureTextWidth(labelText);
2385
2473
  const paddingX = 6;
2386
2474
  const bgWidth = textWidth + paddingX * 2;
2387
2475
  const bgHeight = 18;
@@ -2547,7 +2635,7 @@ function createChart(element, options = {}) {
2547
2635
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2548
2636
  levelLines.forEach((level, index) => {
2549
2637
  const labelText = `${level.ratio} (${formatPrice(level.price)})`;
2550
- const textWidth = ctx.measureText(labelText).width;
2638
+ const textWidth = measureTextWidth(labelText);
2551
2639
  const padding = 4;
2552
2640
  const bgX = lineLeft + 4;
2553
2641
  const bgY = level.y - 9;
@@ -2620,7 +2708,7 @@ function createChart(element, options = {}) {
2620
2708
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2621
2709
  levelLines.forEach((level, index) => {
2622
2710
  const labelText = `${level.ratio} (${formatPrice(level.price)})`;
2623
- const textWidth = ctx.measureText(labelText).width;
2711
+ const textWidth = measureTextWidth(labelText);
2624
2712
  const padding = 4;
2625
2713
  const bgX = chartLeft + 4;
2626
2714
  const bgY = level.y - 9;
@@ -2763,8 +2851,8 @@ function createChart(element, options = {}) {
2763
2851
  const padding = 6;
2764
2852
  const lineH = 14;
2765
2853
  const isDigit = (ch) => ch >= "0" && ch <= "9";
2766
- const digitW = ctx.measureText("0").width;
2767
- const charAdvance = (ch) => isDigit(ch) ? digitW : ctx.measureText(ch).width;
2854
+ const digitW = measureTextWidth("0");
2855
+ const charAdvance = (ch) => isDigit(ch) ? digitW : measureTextWidth(ch);
2768
2856
  const lineWidth = (line) => {
2769
2857
  let w = 0;
2770
2858
  for (const ch of line) w += charAdvance(ch);
@@ -2787,11 +2875,11 @@ function createChart(element, options = {}) {
2787
2875
  const y = startY + lineIndex * lineH;
2788
2876
  for (const ch of line) {
2789
2877
  if (isDigit(ch)) {
2790
- ctx.fillText(ch, x + (digitW - ctx.measureText(ch).width) / 2, y);
2878
+ ctx.fillText(ch, x + (digitW - measureTextWidth(ch)) / 2, y);
2791
2879
  x += digitW;
2792
2880
  } else {
2793
2881
  ctx.fillText(ch, x, y);
2794
- x += ctx.measureText(ch).width;
2882
+ x += measureTextWidth(ch);
2795
2883
  }
2796
2884
  }
2797
2885
  });
@@ -2923,7 +3011,7 @@ function createChart(element, options = {}) {
2923
3011
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2924
3012
  const padding = 6;
2925
3013
  const lineHeight = 15;
2926
- const textW = Math.max(...labelLines.map((line) => ctx.measureText(line).width));
3014
+ const textW = Math.max(...labelLines.map((line) => measureTextWidth(line)));
2927
3015
  const pillW = textW + padding * 2;
2928
3016
  const pillH = labelLines.length * lineHeight + padding;
2929
3017
  const pillX = midX - pillW / 2;
@@ -2959,7 +3047,7 @@ function createChart(element, options = {}) {
2959
3047
  ctx.textBaseline = "middle";
2960
3048
  const lines = (drawing.label ?? "").split("\n");
2961
3049
  const lineH = Math.round(fontSize * 1.35);
2962
- const textW = Math.max(1, ...lines.map((line) => ctx.measureText(line).width));
3050
+ const textW = Math.max(1, ...lines.map((line) => measureTextWidth(line)));
2963
3051
  const padX = isNote ? 8 : 2;
2964
3052
  const padY = isNote ? 6 : 1;
2965
3053
  const blockW = textW + padX * 2;
@@ -3041,47 +3129,114 @@ function createChart(element, options = {}) {
3041
3129
  Math.max(1, candleSpacing - 1),
3042
3130
  Math.max(candleMinWidth, Math.floor(candleSpacing * candleBodyWidthRatio))
3043
3131
  );
3044
- const grid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
3132
+ const grid = resolvedGrid;
3045
3133
  const gridOpacity = clamp(grid.opacity, 0, 1);
3046
3134
  const yTickCountInput = grid.yTickCount ?? grid.horizontalTickCount;
3047
3135
  const yTicks = Math.max(1, Math.floor(yTickCountInput));
3048
3136
  const gridColor = grid.color ?? mergedOptions.gridColor;
3137
+ const priceTicks = [];
3138
+ {
3139
+ const targetStep = yRange / Math.max(1, yTicks);
3140
+ let step;
3141
+ if (targetStep > 0 && Number.isFinite(targetStep)) {
3142
+ const base = Math.pow(10, Math.floor(Math.log10(targetStep)));
3143
+ const frac = targetStep / base;
3144
+ step = (frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 2.5 ? 2.5 : frac <= 5 ? 5 : 10) * base;
3145
+ const instrumentTick = getConfiguredTickSize();
3146
+ if (instrumentTick > 0 && step < instrumentTick * 1e6) {
3147
+ step = Math.max(instrumentTick, Math.round(step / instrumentTick) * instrumentTick);
3148
+ }
3149
+ } else {
3150
+ step = 1;
3151
+ }
3152
+ const firstTick = Math.ceil(yMin / step) * step;
3153
+ for (let i = 0; ; i += 1) {
3154
+ const price = firstTick + i * step;
3155
+ if (price > yMax + step * 1e-6) break;
3156
+ priceTicks.push(price);
3157
+ if (priceTicks.length > 200) break;
3158
+ }
3159
+ }
3049
3160
  if (grid.horizontalLines) {
3050
3161
  ctx.save();
3051
3162
  ctx.globalAlpha = gridOpacity;
3052
- for (let tick = 0; tick <= yTicks; tick += 1) {
3053
- const ratio = tick / yTicks;
3054
- const price = yMin + yRange * ratio;
3163
+ ctx.strokeStyle = gridColor;
3164
+ ctx.lineWidth = 1;
3165
+ ctx.beginPath();
3166
+ for (const price of priceTicks) {
3055
3167
  const y = yFromPrice(price);
3056
- ctx.strokeStyle = gridColor;
3057
- ctx.lineWidth = 1;
3058
- ctx.beginPath();
3059
3168
  ctx.moveTo(crisp(chartLeft), crisp(y));
3060
3169
  ctx.lineTo(crisp(chartRight), crisp(y));
3061
- ctx.stroke();
3062
3170
  }
3171
+ ctx.stroke();
3063
3172
  ctx.restore();
3064
3173
  }
3065
- const minLabelSpacingPx = Math.max(72, candleSpacing * 6);
3066
- const autoLabelCount = Math.max(2, Math.floor(chartWidth / minLabelSpacingPx));
3067
- const xTickCountInput = grid.xTickCount ?? autoLabelCount;
3068
- const xTickCount = Math.max(2, Math.floor(xTickCountInput));
3069
- const rawStep = xSpan / xTickCount;
3070
- const xStep = Math.max(1, Math.ceil(rawStep));
3071
- const visibleTickStart = Math.floor(xStart);
3174
+ const minLabelSpacingPx = Math.max(60, Math.min(120, chartWidth / Math.max(2, Math.floor(grid.xTickCount ?? 8))));
3175
+ const visibleTickStart = Math.max(0, Math.floor(xStart));
3072
3176
  const visibleTickEnd = Math.ceil(xEnd) - 1;
3073
- const tickStartIndex = Math.ceil(visibleTickStart / xStep) * xStep;
3074
- if (grid.verticalLines) {
3177
+ const timeTicks = [];
3178
+ {
3179
+ const weightBuckets = [[], [], [], [], [], [], [], [], [], []];
3180
+ let prevTime = visibleTickStart > 0 ? getTimeForIndex(visibleTickStart - 1) : null;
3181
+ for (let index = visibleTickStart; index <= visibleTickEnd; index += 1) {
3182
+ const time = getTimeForIndex(index);
3183
+ if (!time) {
3184
+ prevTime = null;
3185
+ continue;
3186
+ }
3187
+ let weight = 0;
3188
+ if (!prevTime) {
3189
+ weight = 7;
3190
+ } else {
3191
+ if (prevTime.getFullYear() !== time.getFullYear()) weight = 9;
3192
+ else if (prevTime.getMonth() !== time.getMonth()) weight = 8;
3193
+ else if (prevTime.getDate() !== time.getDate()) weight = 7;
3194
+ else {
3195
+ const prevMinutes = prevTime.getHours() * 60 + prevTime.getMinutes();
3196
+ const curMinutes = time.getHours() * 60 + time.getMinutes();
3197
+ const crossed = (span) => Math.floor(prevMinutes / span) !== Math.floor(curMinutes / span);
3198
+ if (crossed(360)) weight = 6;
3199
+ else if (crossed(60)) weight = 5;
3200
+ else if (crossed(30)) weight = 4;
3201
+ else if (crossed(15)) weight = 3;
3202
+ else if (crossed(5)) weight = 2;
3203
+ else weight = 1;
3204
+ }
3205
+ }
3206
+ weightBuckets[weight].push(index);
3207
+ prevTime = time;
3208
+ }
3209
+ const placedXs = [];
3210
+ const maxMarks = Math.floor(chartWidth / minLabelSpacingPx) + 2;
3211
+ for (let weight = 9; weight >= 1 && timeTicks.length < maxMarks; weight -= 1) {
3212
+ for (const index of weightBuckets[weight]) {
3213
+ const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3214
+ let fits = true;
3215
+ for (const placedX of placedXs) {
3216
+ if (Math.abs(placedX - x) < minLabelSpacingPx) {
3217
+ fits = false;
3218
+ break;
3219
+ }
3220
+ }
3221
+ if (!fits) continue;
3222
+ placedXs.push(x);
3223
+ timeTicks.push({ index, weight, x });
3224
+ if (timeTicks.length >= maxMarks) break;
3225
+ }
3226
+ }
3227
+ timeTicks.sort((a, b) => a.index - b.index);
3228
+ }
3229
+ if (grid.verticalLines && timeTicks.length > 0) {
3075
3230
  ctx.save();
3076
3231
  ctx.globalAlpha = gridOpacity;
3077
- for (let index = tickStartIndex; index <= visibleTickEnd; index += xStep) {
3078
- const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3079
- ctx.strokeStyle = gridColor;
3080
- ctx.beginPath();
3081
- ctx.moveTo(crisp(x), crisp(chartTop));
3082
- ctx.lineTo(crisp(x), crisp(fullChartBottom));
3083
- ctx.stroke();
3232
+ ctx.strokeStyle = gridColor;
3233
+ ctx.lineWidth = 1;
3234
+ ctx.beginPath();
3235
+ for (const tick of timeTicks) {
3236
+ ctx.moveTo(crisp(tick.x), crisp(chartTop));
3237
+ ctx.lineTo(crisp(tick.x), crisp(fullChartBottom));
3084
3238
  }
3239
+ ctx.stroke();
3085
3240
  ctx.restore();
3086
3241
  }
3087
3242
  if (grid.sessionSeparators && data.length > 1) {
@@ -3119,38 +3274,45 @@ function createChart(element, options = {}) {
3119
3274
  }
3120
3275
  return data[index]?.v;
3121
3276
  };
3122
- for (let index = startIndex; index <= endIndex; index += 1) {
3123
- const point = data[index];
3124
- if (!point) {
3125
- continue;
3277
+ {
3278
+ const upWicks = new Path2D();
3279
+ const downWicks = new Path2D();
3280
+ const upBodies = new Path2D();
3281
+ const downBodies = new Path2D();
3282
+ for (let index = startIndex; index <= endIndex; index += 1) {
3283
+ const point = data[index];
3284
+ if (!point) {
3285
+ continue;
3286
+ }
3287
+ const isLastCandle = useSmoothedCandle && index === lastDataIndex;
3288
+ const actualDirection = point.c >= point.o ? "up" : "down";
3289
+ const displayClose = isLastCandle ? actualDirection === "up" ? Math.max(point.o, smoothedTickerPrice) : Math.min(point.o, smoothedTickerPrice) : point.c;
3290
+ const displayHigh = isLastCandle ? actualDirection === "up" ? Math.max(point.h, displayClose) : point.h : point.h;
3291
+ const displayLow = isLastCandle ? actualDirection === "up" ? point.l : Math.min(point.l, displayClose) : point.l;
3292
+ const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3293
+ const direction = isLastCandle ? actualDirection : getCandleDirectionByIndex(index);
3294
+ const isUp = direction === "up";
3295
+ const roundedCenterX = Math.round(centerX);
3296
+ const wicks = isUp ? upWicks : downWicks;
3297
+ wicks.moveTo(roundedCenterX + 0.5, crisp(yFromPrice(displayHigh)));
3298
+ wicks.lineTo(roundedCenterX + 0.5, crisp(yFromPrice(displayLow)));
3299
+ const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
3300
+ const openYPx = Math.round(yFromPrice(point.o));
3301
+ const closeYPx = Math.round(yFromPrice(displayClose));
3302
+ const bodyIsUp = displayClose >= point.o;
3303
+ const bodyTop = bodyIsUp ? Math.min(closeYPx, openYPx - 1) : openYPx;
3304
+ const bodyBottom = bodyIsUp ? openYPx : Math.max(closeYPx, openYPx + 1);
3305
+ (isUp ? upBodies : downBodies).rect(bodyLeft, bodyTop, bodyWidth, bodyBottom - bodyTop);
3126
3306
  }
3127
- const isLastCandle = useSmoothedCandle && index === lastDataIndex;
3128
- const actualDirection = point.c >= point.o ? "up" : "down";
3129
- const displayClose = isLastCandle ? actualDirection === "up" ? Math.max(point.o, smoothedTickerPrice) : Math.min(point.o, smoothedTickerPrice) : point.c;
3130
- const displayHigh = isLastCandle ? actualDirection === "up" ? Math.max(point.h, displayClose) : point.h : point.h;
3131
- const displayLow = isLastCandle ? actualDirection === "up" ? point.l : Math.min(point.l, displayClose) : point.l;
3132
- const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3133
- const openY = yFromPrice(point.o);
3134
- const closeY = yFromPrice(displayClose);
3135
- const highY = yFromPrice(displayHigh);
3136
- const lowY = yFromPrice(displayLow);
3137
- const direction = isLastCandle ? actualDirection : getCandleDirectionByIndex(index);
3138
- const candleColor = direction === "up" ? mergedOptions.upColor : mergedOptions.downColor;
3139
- const roundedCenterX = Math.round(centerX);
3140
- ctx.strokeStyle = candleColor;
3141
3307
  ctx.lineWidth = candleWickWidth;
3142
- ctx.beginPath();
3143
- ctx.moveTo(roundedCenterX + 0.5, crisp(highY));
3144
- ctx.lineTo(roundedCenterX + 0.5, crisp(lowY));
3145
- ctx.stroke();
3146
- const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
3147
- const openYPx = Math.round(openY);
3148
- const closeYPx = Math.round(closeY);
3149
- const bodyIsUp = displayClose >= point.o;
3150
- const bodyTop = bodyIsUp ? Math.min(closeYPx, openYPx - 1) : openYPx;
3151
- const bodyBottom = bodyIsUp ? openYPx : Math.max(closeYPx, openYPx + 1);
3152
- ctx.fillStyle = candleColor;
3153
- ctx.fillRect(bodyLeft, bodyTop, bodyWidth, bodyBottom - bodyTop);
3308
+ ctx.strokeStyle = mergedOptions.upColor;
3309
+ ctx.stroke(upWicks);
3310
+ ctx.fillStyle = mergedOptions.upColor;
3311
+ ctx.fill(upBodies);
3312
+ ctx.strokeStyle = mergedOptions.downColor;
3313
+ ctx.stroke(downWicks);
3314
+ ctx.fillStyle = mergedOptions.downColor;
3315
+ ctx.fill(downBodies);
3154
3316
  }
3155
3317
  const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
3156
3318
  (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
@@ -3268,56 +3430,6 @@ function createChart(element, options = {}) {
3268
3430
  ctx.textAlign = prevAlign;
3269
3431
  ctx.textBaseline = prevBaseline;
3270
3432
  }
3271
- const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
3272
- if (crosshair.visible && crosshairPoint) {
3273
- const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3274
- const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
3275
- if (crosshair.mode === "dot") {
3276
- ctx.save();
3277
- ctx.fillStyle = crosshair.color;
3278
- ctx.beginPath();
3279
- ctx.arc(cx, cy, Math.max(1, crosshair.dotRadius), 0, Math.PI * 2);
3280
- ctx.fill();
3281
- ctx.restore();
3282
- } else {
3283
- ctx.save();
3284
- ctx.strokeStyle = crosshair.color;
3285
- ctx.lineWidth = Math.max(1, crosshair.width);
3286
- applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3287
- if (crosshair.showVertical) {
3288
- ctx.beginPath();
3289
- ctx.moveTo(crisp(cx), crisp(chartTop));
3290
- ctx.lineTo(crisp(cx), crisp(chartBottom));
3291
- ctx.stroke();
3292
- }
3293
- if (crosshair.showHorizontal) {
3294
- ctx.beginPath();
3295
- ctx.moveTo(crisp(chartLeft), crisp(cy));
3296
- ctx.lineTo(crisp(chartRight), crisp(cy));
3297
- ctx.stroke();
3298
- }
3299
- ctx.restore();
3300
- }
3301
- if (crosshair.sideHintLeft || crosshair.sideHintRight) {
3302
- ctx.save();
3303
- ctx.font = `600 11px ${mergedOptions.fontFamily}`;
3304
- ctx.textBaseline = "middle";
3305
- ctx.setLineDash([]);
3306
- const hintY = clamp(cy - 14, chartTop + 8, chartBottom - 8);
3307
- const hintGap = 8;
3308
- if (crosshair.sideHintLeft) {
3309
- ctx.fillStyle = crosshair.sideHintLeftColor;
3310
- ctx.textAlign = "right";
3311
- ctx.fillText(crosshair.sideHintLeft, cx - hintGap, hintY);
3312
- }
3313
- if (crosshair.sideHintRight) {
3314
- ctx.fillStyle = crosshair.sideHintRightColor;
3315
- ctx.textAlign = "left";
3316
- ctx.fillText(crosshair.sideHintRight, cx + hintGap, hintY);
3317
- }
3318
- ctx.restore();
3319
- }
3320
- }
3321
3433
  ctx.restore();
3322
3434
  const positionAxisGutter = Math.max(0, width - chartRight);
3323
3435
  if (positionAxisGutter > 0) {
@@ -3430,7 +3542,7 @@ function createChart(element, options = {}) {
3430
3542
  const text = label.text ?? formatPaneValue(label.value);
3431
3543
  const labelPaddingX = 7;
3432
3544
  const labelHeight = Math.max(16, yAxisFontSize + 8);
3433
- const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(ctx.measureText(text).width) + labelPaddingX * 2);
3545
+ const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(measureTextWidth(text)) + labelPaddingX * 2);
3434
3546
  const labelX = getRightAxisLabelX(chartRight);
3435
3547
  const labelY = clamp(yFromPaneValue(label.value) - labelHeight / 2, paneTop + 2, paneBottom - labelHeight - 2);
3436
3548
  ctx.fillStyle = label.backgroundColor ?? label.color ?? labels.backgroundColor;
@@ -3442,18 +3554,6 @@ function createChart(element, options = {}) {
3442
3554
  }
3443
3555
  });
3444
3556
  }
3445
- if (crosshair.visible && crosshairPoint && crosshair.showVertical) {
3446
- const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3447
- ctx.save();
3448
- ctx.strokeStyle = crosshair.color;
3449
- ctx.lineWidth = Math.max(1, crosshair.width);
3450
- applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3451
- ctx.beginPath();
3452
- ctx.moveTo(crisp(cx), crisp(chartTop));
3453
- ctx.lineTo(crisp(cx), crisp(fullChartBottom));
3454
- ctx.stroke();
3455
- ctx.restore();
3456
- }
3457
3557
  ctx.strokeStyle = axis.lineColor;
3458
3558
  ctx.lineWidth = Math.max(1, axis.lineWidth);
3459
3559
  ctx.beginPath();
@@ -3463,13 +3563,13 @@ function createChart(element, options = {}) {
3463
3563
  ctx.lineTo(crisp(chartRight), crisp(fullChartBottom));
3464
3564
  ctx.stroke();
3465
3565
  const priceScaleTickLabelInset = yAxisFontSize / 2 + 3;
3466
- for (let tick = 0; tick <= yTicks; tick += 1) {
3467
- const ratio = tick / yTicks;
3468
- const price = yMin + yRange * ratio;
3469
- const y = clamp(yFromPrice(price), chartTop + priceScaleTickLabelInset, chartBottom - priceScaleTickLabelInset);
3566
+ {
3470
3567
  const prevFont = ctx.font;
3471
3568
  ctx.font = `${yAxisFontSize}px ${mergedOptions.fontFamily}`;
3472
- drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
3569
+ for (const price of priceTicks) {
3570
+ const y = clamp(yFromPrice(price), chartTop + priceScaleTickLabelInset, chartBottom - priceScaleTickLabelInset);
3571
+ drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
3572
+ }
3473
3573
  ctx.font = prevFont;
3474
3574
  }
3475
3575
  resetLabelSlots(labels.noOverlapping);
@@ -3578,9 +3678,15 @@ function createChart(element, options = {}) {
3578
3678
  });
3579
3679
  }
3580
3680
  }
3581
- if (labels.showHighLow && visibleData.length > 0) {
3582
- const visibleHigh = Math.max(...visibleData.map((point) => point.h));
3583
- const visibleLow = Math.min(...visibleData.map((point) => point.l));
3681
+ if (labels.showHighLow && endIndex >= startIndex) {
3682
+ let visibleHigh = Number.NEGATIVE_INFINITY;
3683
+ let visibleLow = Number.POSITIVE_INFINITY;
3684
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
3685
+ const point = data[idx];
3686
+ if (!point) continue;
3687
+ if (point.h > visibleHigh) visibleHigh = point.h;
3688
+ if (point.l < visibleLow) visibleLow = point.l;
3689
+ }
3584
3690
  addPriceAxisLabel({
3585
3691
  text: `H ${formatPrice(visibleHigh)}`,
3586
3692
  price: visibleHigh,
@@ -3633,12 +3739,12 @@ function createChart(element, options = {}) {
3633
3739
  const subtextFontSize = label.subtextFontSize !== void 0 && label.subtextFontSize > 0 ? Math.max(8, Math.round(label.subtextFontSize)) : priceLabelFontSize;
3634
3740
  const subtextLineGap = 5;
3635
3741
  const labelHeight = baseLabelHeight + (subtexts.length > 0 ? subtexts.length * subtextFontSize + subtexts.length * subtextLineGap : 0);
3636
- const primaryWidth = label.text === formatPrice(label.price) ? getPriceLabelWidth(label.text, labelPaddingX) : Math.ceil(ctx.measureText(label.text).width) + labelPaddingX * 2;
3742
+ const primaryWidth = label.text === formatPrice(label.price) ? getPriceLabelWidth(label.text, labelPaddingX) : Math.ceil(measureTextWidth(label.text)) + labelPaddingX * 2;
3637
3743
  let subtextWidth = 0;
3638
3744
  if (subtexts.length > 0) {
3639
3745
  const baseFont = ctx.font;
3640
3746
  ctx.font = `${subtextFontSize}px ${mergedOptions.fontFamily}`;
3641
- subtextWidth = Math.max(...subtexts.map((subtext) => Math.ceil(ctx.measureText(subtext).width) + labelPaddingX * 2));
3747
+ subtextWidth = Math.max(...subtexts.map((subtext) => Math.ceil(measureTextWidth(subtext)) + labelPaddingX * 2));
3642
3748
  ctx.font = baseFont;
3643
3749
  }
3644
3750
  const labelTextWidth = Math.max(primaryWidth, subtextWidth);
@@ -3742,21 +3848,15 @@ function createChart(element, options = {}) {
3742
3848
  ctx.font = prevFont;
3743
3849
  }
3744
3850
  }
3745
- const axisStepMs = getTimeStepMs();
3746
- const axisIntraday = axisStepMs > 0 && axisStepMs < 24 * 60 * 60 * 1e3;
3747
- let prevAxisTickTime = null;
3748
- for (let index = tickStartIndex; index <= visibleTickEnd; index += xStep) {
3749
- const tickTime = getTimeForIndex(index);
3750
- if (!tickTime) {
3751
- continue;
3752
- }
3753
- const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3754
- const isNewDay = !prevAxisTickTime || prevAxisTickTime.getDate() !== tickTime.getDate() || prevAxisTickTime.getMonth() !== tickTime.getMonth() || prevAxisTickTime.getFullYear() !== tickTime.getFullYear();
3755
- const timeLabel = axisIntraday && !isNewDay ? tickTime.toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit", hour12: false }) : tickTime.toLocaleDateString(void 0, { month: "short", day: "numeric" });
3756
- prevAxisTickTime = tickTime;
3851
+ {
3757
3852
  const prevFont = ctx.font;
3758
3853
  ctx.font = `${xAxisFontSize}px ${mergedOptions.fontFamily}`;
3759
- drawText(timeLabel, x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
3854
+ for (const tick of timeTicks) {
3855
+ const tickTime = getTimeForIndex(tick.index);
3856
+ if (!tickTime) continue;
3857
+ 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 });
3858
+ drawText(timeLabel, tick.x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
3859
+ }
3760
3860
  ctx.font = prevFont;
3761
3861
  }
3762
3862
  if (labels.visible && labels.showCountdownToBarClose && lastPoint) {
@@ -3769,137 +3869,229 @@ function createChart(element, options = {}) {
3769
3869
  drawText(countdownText, chartRight + 6, (fullChartBottom + height) / 2, "left", "middle", xAxis.textColor);
3770
3870
  ctx.font = prevFont;
3771
3871
  }
3772
- if (crosshair.visible && crosshairPoint) {
3773
- const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3774
- const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
3775
- const labelPaddingX = 8;
3776
- const labelHeight = 20;
3777
- const labelRadius = Math.max(0, crosshair.labelBorderRadius);
3778
- const labelBackground = crosshair.labelBackgroundColor;
3779
- const labelTextColor = crosshair.labelTextColor;
3780
- const labelBorderColor = crosshair.labelBorderColor;
3781
- const labelBorderWidth = Math.max(0, crosshair.labelBorderWidth);
3782
- const labelBorderStyle = crosshair.labelBorderStyle;
3783
- const strokeCrosshairLabel = (x, y, widthValue, heightValue = labelHeight) => {
3784
- if (labelBorderWidth <= 0) {
3785
- return;
3786
- }
3787
- ctx.save();
3788
- ctx.strokeStyle = labelBorderColor;
3789
- ctx.lineWidth = labelBorderWidth;
3790
- applyDashPattern(
3791
- labelBorderStyle,
3792
- dashPatterns.borderDotted,
3793
- dashPatterns.borderDashed
3794
- );
3795
- strokeRoundedRect(Math.round(x), Math.round(y), widthValue, heightValue, labelRadius);
3796
- ctx.restore();
3797
- };
3798
- if (crosshair.showPriceLabel) {
3799
- const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
3800
- const priceText = formatPrice(hoverPrice);
3801
- const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
3802
- const priceX = getRightAxisLabelX(chartRight);
3803
- const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
3872
+ if (baseCtx) {
3873
+ if (baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height) {
3874
+ baseCanvas.width = canvas.width;
3875
+ baseCanvas.height = canvas.height;
3876
+ }
3877
+ baseCtx.setTransform(1, 0, 0, 1, 0, 0);
3878
+ baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
3879
+ baseCtx.drawImage(canvas, 0, 0);
3880
+ }
3881
+ overlayLayout = {
3882
+ chartLeft,
3883
+ chartTop,
3884
+ chartRight,
3885
+ chartBottom,
3886
+ chartWidth,
3887
+ chartHeight,
3888
+ fullChartBottom,
3889
+ yMin,
3890
+ yRange,
3891
+ xStart,
3892
+ xSpan
3893
+ };
3894
+ paintCrosshairOverlay();
3895
+ };
3896
+ const paintCrosshairOverlay = () => {
3897
+ crosshairPriceActionRegion = null;
3898
+ const layout = overlayLayout;
3899
+ const crosshair = resolvedCrosshair;
3900
+ if (!layout || !crosshair.visible || !crosshairPoint) {
3901
+ return;
3902
+ }
3903
+ const { chartLeft, chartTop, chartRight, chartBottom, chartWidth, chartHeight, fullChartBottom, yMin, yRange, xStart, xSpan: xSpan2 } = layout;
3904
+ const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3905
+ const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
3906
+ ctx.save();
3907
+ ctx.beginPath();
3908
+ ctx.rect(chartLeft, chartTop, chartWidth, Math.max(0, chartBottom - chartTop));
3909
+ ctx.clip();
3910
+ if (crosshair.mode === "dot") {
3911
+ ctx.fillStyle = crosshair.color;
3912
+ ctx.beginPath();
3913
+ ctx.arc(cx, cy, Math.max(1, crosshair.dotRadius), 0, Math.PI * 2);
3914
+ ctx.fill();
3915
+ } else {
3916
+ ctx.strokeStyle = crosshair.color;
3917
+ ctx.lineWidth = Math.max(1, crosshair.width);
3918
+ applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3919
+ if (crosshair.showHorizontal) {
3920
+ ctx.beginPath();
3921
+ ctx.moveTo(crisp(chartLeft), crisp(cy));
3922
+ ctx.lineTo(crisp(chartRight), crisp(cy));
3923
+ ctx.stroke();
3924
+ }
3925
+ }
3926
+ if (crosshair.sideHintLeft || crosshair.sideHintRight) {
3927
+ ctx.save();
3928
+ ctx.font = `600 11px ${mergedOptions.fontFamily}`;
3929
+ ctx.textBaseline = "middle";
3930
+ ctx.setLineDash([]);
3931
+ const hintY = clamp(cy - 14, chartTop + 8, chartBottom - 8);
3932
+ const hintGap = 8;
3933
+ if (crosshair.sideHintLeft) {
3934
+ ctx.fillStyle = crosshair.sideHintLeftColor;
3935
+ ctx.textAlign = "right";
3936
+ ctx.fillText(crosshair.sideHintLeft, cx - hintGap, hintY);
3937
+ }
3938
+ if (crosshair.sideHintRight) {
3939
+ ctx.fillStyle = crosshair.sideHintRightColor;
3940
+ ctx.textAlign = "left";
3941
+ ctx.fillText(crosshair.sideHintRight, cx + hintGap, hintY);
3942
+ }
3943
+ ctx.restore();
3944
+ }
3945
+ ctx.restore();
3946
+ if (crosshair.showVertical) {
3947
+ ctx.save();
3948
+ ctx.strokeStyle = crosshair.color;
3949
+ ctx.lineWidth = Math.max(1, crosshair.width);
3950
+ applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3951
+ ctx.beginPath();
3952
+ ctx.moveTo(crisp(cx), crisp(chartTop));
3953
+ ctx.lineTo(crisp(cx), crisp(fullChartBottom));
3954
+ ctx.stroke();
3955
+ ctx.restore();
3956
+ }
3957
+ const labelPaddingX = 8;
3958
+ const labelHeight = 20;
3959
+ const labelRadius = Math.max(0, crosshair.labelBorderRadius);
3960
+ const labelBackground = crosshair.labelBackgroundColor;
3961
+ const labelTextColor = crosshair.labelTextColor;
3962
+ const labelBorderColor = crosshair.labelBorderColor;
3963
+ const labelBorderWidth = Math.max(0, crosshair.labelBorderWidth);
3964
+ const labelBorderStyle = crosshair.labelBorderStyle;
3965
+ const strokeCrosshairLabel = (x, y, widthValue, heightValue = labelHeight) => {
3966
+ if (labelBorderWidth <= 0) {
3967
+ return;
3968
+ }
3969
+ ctx.save();
3970
+ ctx.strokeStyle = labelBorderColor;
3971
+ ctx.lineWidth = labelBorderWidth;
3972
+ applyDashPattern(
3973
+ labelBorderStyle,
3974
+ dashPatterns.borderDotted,
3975
+ dashPatterns.borderDashed
3976
+ );
3977
+ strokeRoundedRect(Math.round(x), Math.round(y), widthValue, heightValue, labelRadius);
3978
+ ctx.restore();
3979
+ };
3980
+ if (crosshair.showPriceLabel) {
3981
+ const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
3982
+ const priceText = formatPrice(hoverPrice);
3983
+ const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
3984
+ const priceX = getRightAxisLabelX(chartRight);
3985
+ const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
3986
+ ctx.fillStyle = labelBackground;
3987
+ fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
3988
+ strokeCrosshairLabel(priceX, priceY, priceWidth);
3989
+ drawText(priceText, priceX + labelPaddingX, priceY + labelHeight / 2, "left", "middle", labelTextColor);
3990
+ if (crosshair.showPriceActionButton) {
3991
+ const buttonSize = clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4));
3992
+ const buttonGap = Math.max(0, Math.round(crosshair.priceActionButtonGap));
3993
+ const containerPaddingX = 5;
3994
+ const containerWidth = buttonSize + containerPaddingX * 2;
3995
+ const containerX = priceX - buttonGap - containerWidth;
3996
+ const containerY = priceY;
3997
+ const buttonX = containerX + containerPaddingX;
3998
+ const buttonY = priceY + (labelHeight - buttonSize) / 2;
3999
+ const buttonRadius = crosshair.priceActionButtonRounded ? clamp(Math.round(crosshair.priceActionButtonBorderRadius), 0, buttonSize / 2) : 0;
4000
+ const buttonBorderWidth = Math.max(1, Math.round(labelBorderWidth || 1));
4001
+ const buttonCenterX = buttonX + buttonSize / 2;
4002
+ const buttonCenterY = buttonY + buttonSize / 2;
4003
+ const containerRadius = labelRadius;
3804
4004
  ctx.fillStyle = labelBackground;
3805
- fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
3806
- strokeCrosshairLabel(priceX, priceY, priceWidth);
3807
- drawText(priceText, priceX + labelPaddingX, priceY + labelHeight / 2, "left", "middle", labelTextColor);
3808
- if (crosshair.showPriceActionButton) {
3809
- const buttonSize = clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4));
3810
- const buttonGap = Math.max(0, Math.round(crosshair.priceActionButtonGap));
3811
- const containerPaddingX = 5;
3812
- const containerWidth = buttonSize + containerPaddingX * 2;
3813
- const containerX = priceX - buttonGap - containerWidth;
3814
- const containerY = priceY;
3815
- const buttonX = containerX + containerPaddingX;
3816
- const buttonY = priceY + (labelHeight - buttonSize) / 2;
3817
- const buttonRadius = crosshair.priceActionButtonRounded ? clamp(Math.round(crosshair.priceActionButtonBorderRadius), 0, buttonSize / 2) : 0;
3818
- const buttonBorderWidth = Math.max(1, Math.round(labelBorderWidth || 1));
3819
- const buttonCenterX = buttonX + buttonSize / 2;
3820
- const buttonCenterY = buttonY + buttonSize / 2;
3821
- const containerRadius = labelRadius;
3822
- ctx.fillStyle = labelBackground;
3823
- fillRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
3824
- if (labelBorderWidth > 0) {
3825
- ctx.save();
3826
- ctx.strokeStyle = labelBorderColor;
3827
- ctx.lineWidth = labelBorderWidth;
3828
- ctx.setLineDash([]);
3829
- strokeRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
3830
- ctx.restore();
3831
- }
3832
- if (buttonBorderWidth > 0) {
3833
- ctx.save();
3834
- ctx.strokeStyle = labelBorderColor;
3835
- ctx.lineWidth = buttonBorderWidth;
3836
- ctx.setLineDash([]);
3837
- if (crosshair.priceActionButtonRounded) {
3838
- ctx.beginPath();
3839
- ctx.arc(
3840
- buttonCenterX,
3841
- buttonCenterY,
3842
- Math.max(1, buttonSize / 2 - buttonBorderWidth / 2),
3843
- 0,
3844
- Math.PI * 2
3845
- );
3846
- ctx.stroke();
3847
- } else {
3848
- strokeRoundedRect(Math.round(buttonX), Math.round(buttonY), buttonSize, buttonSize, buttonRadius);
3849
- }
3850
- ctx.restore();
3851
- }
3852
- if (crosshair.priceActionButtonIcon !== "text" && crosshair.priceActionButtonText === "+") {
3853
- const plusHalf = Math.max(3, Math.round(buttonSize * 0.22));
3854
- const plusThickness = crosshair.priceActionButtonIcon === "plusThin" ? Math.max(1, Math.round(buttonSize * 0.07)) : Math.max(1, Math.round(buttonSize * 0.1));
3855
- ctx.save();
3856
- ctx.strokeStyle = labelTextColor;
3857
- ctx.lineWidth = plusThickness;
3858
- ctx.lineCap = "round";
3859
- ctx.setLineDash([]);
4005
+ fillRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
4006
+ if (labelBorderWidth > 0) {
4007
+ ctx.save();
4008
+ ctx.strokeStyle = labelBorderColor;
4009
+ ctx.lineWidth = labelBorderWidth;
4010
+ ctx.setLineDash([]);
4011
+ strokeRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
4012
+ ctx.restore();
4013
+ }
4014
+ if (buttonBorderWidth > 0) {
4015
+ ctx.save();
4016
+ ctx.strokeStyle = labelBorderColor;
4017
+ ctx.lineWidth = buttonBorderWidth;
4018
+ ctx.setLineDash([]);
4019
+ if (crosshair.priceActionButtonRounded) {
3860
4020
  ctx.beginPath();
3861
- ctx.moveTo(buttonCenterX - plusHalf, buttonCenterY);
3862
- ctx.lineTo(buttonCenterX + plusHalf, buttonCenterY);
3863
- ctx.moveTo(buttonCenterX, buttonCenterY - plusHalf);
3864
- ctx.lineTo(buttonCenterX, buttonCenterY + plusHalf);
3865
- ctx.stroke();
3866
- ctx.restore();
3867
- } else {
3868
- drawText(
3869
- crosshair.priceActionButtonText,
4021
+ ctx.arc(
3870
4022
  buttonCenterX,
3871
4023
  buttonCenterY,
3872
- "center",
3873
- "middle",
3874
- labelTextColor
4024
+ Math.max(1, buttonSize / 2 - buttonBorderWidth / 2),
4025
+ 0,
4026
+ Math.PI * 2
3875
4027
  );
4028
+ ctx.stroke();
4029
+ } else {
4030
+ strokeRoundedRect(Math.round(buttonX), Math.round(buttonY), buttonSize, buttonSize, buttonRadius);
3876
4031
  }
3877
- crosshairPriceActionRegion = {
3878
- x: containerX,
3879
- y: containerY,
3880
- width: containerWidth,
3881
- height: labelHeight,
3882
- price: Number(hoverPrice.toFixed(mergedOptions.priceDecimals))
3883
- };
4032
+ ctx.restore();
3884
4033
  }
3885
- }
3886
- if (crosshair.showTimeLabel) {
3887
- const ratio = clamp((cx - chartLeft) / chartWidth, 0, 1);
3888
- const hoverIndex = Math.round(xStart + ratio * xSpan - 0.5);
3889
- const hoverTime = getTimeForIndex(hoverIndex);
3890
- if (hoverTime) {
3891
- const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
3892
- const timeWidth = Math.ceil(ctx.measureText(timeText).width) + labelPaddingX * 2;
3893
- const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
3894
- const timeY = fullChartBottom + 1;
3895
- const timeHeight = Math.max(labelHeight, Math.floor(height - timeY));
3896
- ctx.fillStyle = labelBackground;
3897
- fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, timeHeight, labelRadius);
3898
- strokeCrosshairLabel(timeX, timeY, timeWidth, timeHeight);
3899
- drawText(timeText, timeX + labelPaddingX, timeY + timeHeight / 2, "left", "middle", labelTextColor);
4034
+ if (crosshair.priceActionButtonIcon !== "text" && crosshair.priceActionButtonText === "+") {
4035
+ const plusHalf = Math.max(3, Math.round(buttonSize * 0.22));
4036
+ const plusThickness = crosshair.priceActionButtonIcon === "plusThin" ? Math.max(1, Math.round(buttonSize * 0.07)) : Math.max(1, Math.round(buttonSize * 0.1));
4037
+ ctx.save();
4038
+ ctx.strokeStyle = labelTextColor;
4039
+ ctx.lineWidth = plusThickness;
4040
+ ctx.lineCap = "round";
4041
+ ctx.setLineDash([]);
4042
+ ctx.beginPath();
4043
+ ctx.moveTo(buttonCenterX - plusHalf, buttonCenterY);
4044
+ ctx.lineTo(buttonCenterX + plusHalf, buttonCenterY);
4045
+ ctx.moveTo(buttonCenterX, buttonCenterY - plusHalf);
4046
+ ctx.lineTo(buttonCenterX, buttonCenterY + plusHalf);
4047
+ ctx.stroke();
4048
+ ctx.restore();
4049
+ } else {
4050
+ drawText(
4051
+ crosshair.priceActionButtonText,
4052
+ buttonCenterX,
4053
+ buttonCenterY,
4054
+ "center",
4055
+ "middle",
4056
+ labelTextColor
4057
+ );
3900
4058
  }
4059
+ crosshairPriceActionRegion = {
4060
+ x: containerX,
4061
+ y: containerY,
4062
+ width: containerWidth,
4063
+ height: labelHeight,
4064
+ price: Number(hoverPrice.toFixed(mergedOptions.priceDecimals))
4065
+ };
3901
4066
  }
3902
4067
  }
4068
+ if (crosshair.showTimeLabel) {
4069
+ const ratio = clamp((cx - chartLeft) / chartWidth, 0, 1);
4070
+ const hoverIndex = Math.round(xStart + ratio * xSpan2 - 0.5);
4071
+ const hoverTime = getTimeForIndex(hoverIndex);
4072
+ if (hoverTime) {
4073
+ const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
4074
+ const timeWidth = Math.ceil(measureTextWidth(timeText)) + labelPaddingX * 2;
4075
+ const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
4076
+ const timeY = fullChartBottom + 1;
4077
+ const timeHeight = Math.max(labelHeight, Math.floor(height - timeY));
4078
+ ctx.fillStyle = labelBackground;
4079
+ fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, timeHeight, labelRadius);
4080
+ strokeCrosshairLabel(timeX, timeY, timeWidth, timeHeight);
4081
+ drawText(timeText, timeX + labelPaddingX, timeY + timeHeight / 2, "left", "middle", labelTextColor);
4082
+ }
4083
+ }
4084
+ };
4085
+ const drawOverlayOnly = () => {
4086
+ if (!baseCtx || !overlayLayout || baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height || baseCanvas.width === 0) {
4087
+ draw({ updateAutoScale: false });
4088
+ return;
4089
+ }
4090
+ const pixelRatio = getPixelRatio();
4091
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4092
+ ctx.drawImage(baseCanvas, 0, 0);
4093
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
4094
+ paintCrosshairOverlay();
3903
4095
  };
3904
4096
  const zoomX = (factor, anchorX) => {
3905
4097
  if (!drawState || data.length === 0) {
@@ -3990,6 +4182,59 @@ function createChart(element, options = {}) {
3990
4182
  }
3991
4183
  scheduleDraw();
3992
4184
  };
4185
+ let kineticRafId = null;
4186
+ const panVelocitySamples = [];
4187
+ const cancelKineticPan = () => {
4188
+ if (kineticRafId !== null) {
4189
+ cancelAnimationFrame(kineticRafId);
4190
+ kineticRafId = null;
4191
+ }
4192
+ };
4193
+ const recordPanVelocitySample = (x) => {
4194
+ const now = performance.now();
4195
+ panVelocitySamples.push({ t: now, x });
4196
+ while (panVelocitySamples.length > 0 && now - panVelocitySamples[0].t > 120) {
4197
+ panVelocitySamples.shift();
4198
+ }
4199
+ };
4200
+ const startKineticPan = (initialVelocityPxPerMs) => {
4201
+ cancelKineticPan();
4202
+ let velocity = initialVelocityPxPerMs;
4203
+ let lastTime = performance.now();
4204
+ const stepFn = (now) => {
4205
+ kineticRafId = null;
4206
+ const dt = Math.min(64, Math.max(1, now - lastTime));
4207
+ lastTime = now;
4208
+ const centerBefore = xCenter;
4209
+ pan(velocity * dt, 0, true, false);
4210
+ velocity *= Math.exp(-dt / 325);
4211
+ if (Math.abs(velocity) < 0.02 || xCenter === centerBefore) {
4212
+ return;
4213
+ }
4214
+ kineticRafId = requestAnimationFrame(stepFn);
4215
+ };
4216
+ kineticRafId = requestAnimationFrame(stepFn);
4217
+ };
4218
+ const maybeStartKineticPan = (pointerType) => {
4219
+ const config = mergedOptions.kineticScroll ?? DEFAULT_OPTIONS.kineticScroll;
4220
+ const enabled = pointerType === "touch" || pointerType === "pen" ? config?.touch !== false : config?.mouse === true;
4221
+ if (!enabled || panVelocitySamples.length < 2) {
4222
+ panVelocitySamples.length = 0;
4223
+ return;
4224
+ }
4225
+ const first = panVelocitySamples[0];
4226
+ const last = panVelocitySamples[panVelocitySamples.length - 1];
4227
+ panVelocitySamples.length = 0;
4228
+ const elapsed = last.t - first.t;
4229
+ if (elapsed <= 0 || performance.now() - last.t > 100) {
4230
+ return;
4231
+ }
4232
+ const velocity = (last.x - first.x) / elapsed;
4233
+ if (Math.abs(velocity) < 0.1) {
4234
+ return;
4235
+ }
4236
+ startKineticPan(clamp(velocity, -4, 4));
4237
+ };
3993
4238
  const resetYViewport = () => {
3994
4239
  yMinOverride = null;
3995
4240
  yMaxOverride = null;
@@ -4049,6 +4294,7 @@ function createChart(element, options = {}) {
4049
4294
  scheduleDraw();
4050
4295
  };
4051
4296
  const resetViewport = () => {
4297
+ cancelKineticPan();
4052
4298
  fitXViewport();
4053
4299
  resetYViewport();
4054
4300
  updateFollowLatest(true);
@@ -4058,6 +4304,7 @@ function createChart(element, options = {}) {
4058
4304
  const isFollowingLatest = () => followLatest;
4059
4305
  const setFollowingLatest = (follow) => {
4060
4306
  if (follow && data.length > 0) {
4307
+ cancelKineticPan();
4061
4308
  xCenter = data.length - xSpan / 2 + rightEdgePaddingBars;
4062
4309
  clampXViewport();
4063
4310
  updateFollowLatest(true);
@@ -4427,7 +4674,7 @@ function createChart(element, options = {}) {
4427
4674
  const prevFont = ctx.font;
4428
4675
  ctx.font = `500 ${fontSize}px ${mergedOptions.fontFamily}`;
4429
4676
  const lines = (drawing.label ?? "").split("\n");
4430
- const textW = Math.max(1, ...lines.map((line) => ctx.measureText(line).width));
4677
+ const textW = Math.max(1, ...lines.map((line) => measureTextWidth(line)));
4431
4678
  ctx.font = prevFont;
4432
4679
  const isNote = drawing.type === "note";
4433
4680
  const padX = isNote ? 8 : 2;
@@ -4900,16 +5147,24 @@ function createChart(element, options = {}) {
4900
5147
  emitViewportChange();
4901
5148
  scheduleDraw();
4902
5149
  };
5150
+ const capturePointer = (pointerId) => {
5151
+ try {
5152
+ canvas.setPointerCapture(pointerId);
5153
+ } catch {
5154
+ }
5155
+ };
4903
5156
  const onPointerDown = (event) => {
4904
5157
  if (event.pointerType === "touch" || event.pointerType === "pen") {
4905
5158
  event.preventDefault();
4906
5159
  }
5160
+ cancelKineticPan();
5161
+ panVelocitySamples.length = 0;
4907
5162
  magnetModifierActive = event.metaKey || event.ctrlKey;
4908
5163
  shiftKeyActive = event.shiftKey;
4909
5164
  const point = getCanvasPoint(event);
4910
5165
  if (event.pointerType === "touch") {
4911
5166
  touchPointers.set(event.pointerId, point);
4912
- canvas.setPointerCapture(event.pointerId);
5167
+ capturePointer(event.pointerId);
4913
5168
  const touchPair = getTouchPair();
4914
5169
  if (touchPair) {
4915
5170
  beginPinchZoom(touchPair[0], touchPair[1]);
@@ -4939,7 +5194,7 @@ function createChart(element, options = {}) {
4939
5194
  lastPrice: startPrice,
4940
5195
  moved: false
4941
5196
  };
4942
- canvas.setPointerCapture(event.pointerId);
5197
+ capturePointer(event.pointerId);
4943
5198
  canvas.style.cursor = "ns-resize";
4944
5199
  setCrosshairPoint(null);
4945
5200
  return;
@@ -4960,7 +5215,7 @@ function createChart(element, options = {}) {
4960
5215
  startPrice: orderDragRegion.price,
4961
5216
  lastPrice: orderDragRegion.price
4962
5217
  };
4963
- canvas.setPointerCapture(event.pointerId);
5218
+ capturePointer(event.pointerId);
4964
5219
  canvas.style.cursor = "ns-resize";
4965
5220
  setCrosshairPoint(null);
4966
5221
  return;
@@ -4992,7 +5247,7 @@ function createChart(element, options = {}) {
4992
5247
  startPoints: drawingHit.drawing.points.map((drawingPoint) => ({ ...drawingPoint }))
4993
5248
  };
4994
5249
  activePointerId = event.pointerId;
4995
- canvas.setPointerCapture(event.pointerId);
5250
+ capturePointer(event.pointerId);
4996
5251
  }
4997
5252
  }
4998
5253
  setCrosshairPoint(null);
@@ -5020,7 +5275,7 @@ function createChart(element, options = {}) {
5020
5275
  };
5021
5276
  lastPointerX = point.x;
5022
5277
  lastPointerY = point.y;
5023
- canvas.setPointerCapture(event.pointerId);
5278
+ capturePointer(event.pointerId);
5024
5279
  if (crosshairDrag) {
5025
5280
  canvas.style.cursor = "crosshair";
5026
5281
  setCrosshairPoint(point);
@@ -5206,6 +5461,7 @@ function createChart(element, options = {}) {
5206
5461
  const deltaY = point.y - lastPointerY;
5207
5462
  if (dragMode === "plot") {
5208
5463
  canvas.style.cursor = "grabbing";
5464
+ recordPanVelocitySample(point.x);
5209
5465
  pan(deltaX, deltaY, true, true);
5210
5466
  setCrosshairPoint(null);
5211
5467
  } else if (dragMode === "x-axis") {
@@ -5279,10 +5535,16 @@ function createChart(element, options = {}) {
5279
5535
  drawingDragState = null;
5280
5536
  emitDrawingsChange();
5281
5537
  }
5538
+ const endedPlotDrag = isDragging && dragMode === "plot";
5282
5539
  isDragging = false;
5283
5540
  dragMode = null;
5284
5541
  activePointerId = null;
5285
5542
  canvas.style.cursor = "default";
5543
+ if (endedPlotDrag) {
5544
+ maybeStartKineticPan(event?.pointerType);
5545
+ } else {
5546
+ panVelocitySamples.length = 0;
5547
+ }
5286
5548
  if (event && pointerDownInfo && event.pointerId === pointerDownInfo.pointerId) {
5287
5549
  if (pointerDownInfo.crosshairDrag) {
5288
5550
  const point = getCanvasPoint(event);
@@ -5320,6 +5582,7 @@ function createChart(element, options = {}) {
5320
5582
  if (!drawState) {
5321
5583
  return;
5322
5584
  }
5585
+ cancelKineticPan();
5323
5586
  const point = getCanvasPoint(event);
5324
5587
  const region = getHitRegion(point.x, point.y);
5325
5588
  if (region === "outside") {
@@ -5419,6 +5682,7 @@ function createChart(element, options = {}) {
5419
5682
  width = nextOptions.width !== void 0 && nextOptions.width > 0 ? mergedOptions.width : previousWidth;
5420
5683
  height = nextOptions.height !== void 0 && nextOptions.height > 0 ? mergedOptions.height : previousHeight;
5421
5684
  mergedOptions = { ...mergedOptions, width, height };
5685
+ refreshResolvedOptions();
5422
5686
  resetRightMarginCache();
5423
5687
  doubleClickEnabled = mergedOptions.doubleClickEnabled;
5424
5688
  doubleClickAction = mergedOptions.doubleClickAction;
@@ -5763,6 +6027,7 @@ function createChart(element, options = {}) {
5763
6027
  drawingHoverHandler = handler;
5764
6028
  };
5765
6029
  const destroy = () => {
6030
+ cancelKineticPan();
5766
6031
  if (smoothingRafId !== null) {
5767
6032
  cancelAnimationFrame(smoothingRafId);
5768
6033
  smoothingRafId = null;