hyperprop-charting-library 0.1.120 → 0.1.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -189,8 +189,13 @@ 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 },
198
+ timeStepMs: 0,
194
199
  pinOutOfRangeLines: false,
195
200
  doubleClickEnabled: true,
196
201
  doubleClickAction: "reset",
@@ -589,6 +594,26 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
589
594
  }
590
595
  ctx.restore();
591
596
  };
597
+ var rangeOfSeries = (seriesList, startIndex, endIndex, skipIndex) => {
598
+ let min = Number.POSITIVE_INFINITY;
599
+ let max = Number.NEGATIVE_INFINITY;
600
+ for (const series of seriesList) {
601
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
602
+ if (idx === skipIndex) continue;
603
+ const value = series[idx];
604
+ if (value == null || !Number.isFinite(value)) continue;
605
+ if (value < min) min = value;
606
+ if (value > max) max = value;
607
+ }
608
+ }
609
+ return min <= max ? { min, max } : null;
610
+ };
611
+ var maAutoscaleRange = (cacheKeyPrefix, compute, defaultLength) => (data, startIndex, endIndex, inputs, skipIndex) => {
612
+ const length = clampIndicatorLength(inputs.length, defaultLength);
613
+ const source = inputs.source ?? "close";
614
+ const series = withCachedSeries(`${cacheKeyPrefix}|${length}|${source}`, data, () => compute(data, length, source));
615
+ return rangeOfSeries([series], startIndex, endIndex, skipIndex);
616
+ };
592
617
  var fillBetweenSeries = (ctx, renderContext, upper, lower, color, opacity) => {
593
618
  if (!renderContext.yFromPrice || opacity <= 0) return;
594
619
  const yFromPrice = renderContext.yFromPrice;
@@ -806,7 +831,8 @@ var BUILTIN_SMA_INDICATOR = {
806
831
  () => computeSmaSeries(renderContext.data, length, inputs.source ?? "close")
807
832
  );
808
833
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#60a5fa", Number(inputs.width) || 2);
809
- }
834
+ },
835
+ getAutoscaleRange: maAutoscaleRange("sma", computeSmaSeries, 20)
810
836
  };
811
837
  var BUILTIN_EMA_INDICATOR = {
812
838
  id: "ema",
@@ -821,7 +847,8 @@ var BUILTIN_EMA_INDICATOR = {
821
847
  () => computeEmaSeries(renderContext.data, length, inputs.source ?? "close")
822
848
  );
823
849
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
824
- }
850
+ },
851
+ getAutoscaleRange: maAutoscaleRange("ema", computeEmaSeries, 20)
825
852
  };
826
853
  var BUILTIN_WMA_INDICATOR = {
827
854
  id: "wma",
@@ -836,7 +863,8 @@ var BUILTIN_WMA_INDICATOR = {
836
863
  () => computeWmaSeries(renderContext.data, length, inputs.source ?? "close")
837
864
  );
838
865
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#a78bfa", Number(inputs.width) || 2);
839
- }
866
+ },
867
+ getAutoscaleRange: maAutoscaleRange("wma", computeWmaSeries, 20)
840
868
  };
841
869
  var BUILTIN_VWMA_INDICATOR = {
842
870
  id: "vwma",
@@ -851,7 +879,8 @@ var BUILTIN_VWMA_INDICATOR = {
851
879
  () => computeVwmaSeries(renderContext.data, length, inputs.source ?? "close")
852
880
  );
853
881
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#ef4444", Number(inputs.width) || 2);
854
- }
882
+ },
883
+ getAutoscaleRange: maAutoscaleRange("vwma", computeVwmaSeries, 20)
855
884
  };
856
885
  var BUILTIN_RMA_INDICATOR = {
857
886
  id: "rma",
@@ -866,7 +895,8 @@ var BUILTIN_RMA_INDICATOR = {
866
895
  () => computeRmaSeries(renderContext.data, length, inputs.source ?? "close")
867
896
  );
868
897
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#22c55e", Number(inputs.width) || 2);
869
- }
898
+ },
899
+ getAutoscaleRange: maAutoscaleRange("rma", computeRmaSeries, 14)
870
900
  };
871
901
  var BUILTIN_HMA_INDICATOR = {
872
902
  id: "hma",
@@ -881,7 +911,8 @@ var BUILTIN_HMA_INDICATOR = {
881
911
  () => computeHmaSeries(renderContext.data, length, inputs.source ?? "close")
882
912
  );
883
913
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
884
- }
914
+ },
915
+ getAutoscaleRange: maAutoscaleRange("hma", computeHmaSeries, 21)
885
916
  };
886
917
  var BUILTIN_VWAP_INDICATOR = {
887
918
  id: "vwap",
@@ -922,6 +953,27 @@ var BUILTIN_VWAP_INDICATOR = {
922
953
  drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
923
954
  }
924
955
  drawOverlaySeries(ctx, renderContext, vwap, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
956
+ },
957
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
958
+ const vwap = withCachedSeries("vwap|line", data, () => computeVwapSeries(data, "vwap"));
959
+ if (!inputs.showBands) {
960
+ return rangeOfSeries([vwap], startIndex, endIndex, skipIndex);
961
+ }
962
+ const sigma = withCachedSeries("vwap|sigma", data, () => computeVwapSeries(data, "sigma"));
963
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
964
+ let min = Number.POSITIVE_INFINITY;
965
+ let max = Number.NEGATIVE_INFINITY;
966
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
967
+ if (idx === skipIndex) continue;
968
+ const value = vwap[idx];
969
+ const s = sigma[idx];
970
+ if (value == null || s == null) continue;
971
+ const lo = value - multiplier * s;
972
+ const hi = value + multiplier * s;
973
+ if (lo < min) min = lo;
974
+ if (hi > max) max = hi;
975
+ }
976
+ return min <= max ? { min, max } : null;
925
977
  }
926
978
  };
927
979
  var BUILTIN_BOLLINGER_INDICATOR = {
@@ -963,7 +1015,23 @@ var BUILTIN_BOLLINGER_INDICATOR = {
963
1015
  fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.fillOpacity) || 0.05);
964
1016
  drawOverlaySeries(ctx, renderContext, upper, bandColor, width);
965
1017
  drawOverlaySeries(ctx, renderContext, lower, bandColor, width);
966
- drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#f59e0b", width);
1018
+ drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#ec4899", width);
1019
+ },
1020
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
1021
+ const length = clampIndicatorLength(inputs.length, 20);
1022
+ const multiplier = Math.max(0.1, Number(inputs.multiplier) || 2);
1023
+ const source = inputs.source ?? "close";
1024
+ const upper = withCachedSeries(
1025
+ `bb|upper|${length}|${multiplier}|${source}`,
1026
+ data,
1027
+ () => computeBollingerSeries(data, length, multiplier, source, "upper")
1028
+ );
1029
+ const lower = withCachedSeries(
1030
+ `bb|lower|${length}|${multiplier}|${source}`,
1031
+ data,
1032
+ () => computeBollingerSeries(data, length, multiplier, source, "lower")
1033
+ );
1034
+ return rangeOfSeries([upper, lower], startIndex, endIndex, skipIndex);
967
1035
  }
968
1036
  };
969
1037
  var BUILTIN_STDDEV_INDICATOR = {
@@ -1056,6 +1124,20 @@ var BUILTIN_INDICATORS = [
1056
1124
  ];
1057
1125
  function createChart(element, options = {}) {
1058
1126
  let mergedOptions = mergeChartOptions(DEFAULT_OPTIONS, options);
1127
+ let resolvedAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1128
+ let resolvedXAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
1129
+ let resolvedYAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
1130
+ let resolvedLabels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1131
+ let resolvedGrid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
1132
+ let resolvedCrosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1133
+ const refreshResolvedOptions = () => {
1134
+ resolvedAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1135
+ resolvedXAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.xAxis ?? {} };
1136
+ resolvedYAxis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.yAxis ?? {} };
1137
+ resolvedLabels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1138
+ resolvedGrid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
1139
+ resolvedCrosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
1140
+ };
1059
1141
  let width = mergedOptions.width;
1060
1142
  let height = mergedOptions.height;
1061
1143
  let data = [];
@@ -1092,8 +1174,11 @@ function createChart(element, options = {}) {
1092
1174
  pane: indicator.pane ?? plugin?.pane ?? "overlay",
1093
1175
  ...indicator.paneHeightRatio === void 0 ? {} : { paneHeightRatio: indicator.paneHeightRatio },
1094
1176
  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)),
1177
+ // Overlay series participate in autoscale by default (lightweight-charts
1178
+ // semantics): bands and MAs expand the price range instead of clipping
1179
+ // at the chart edge. Hosts can still opt out per indicator.
1180
+ excludeFromAutoscale: indicator.excludeFromAutoscale ?? false,
1181
+ overlayScaleWeight: indicator.overlayScaleWeight === void 0 ? 1 : Math.min(1, Math.max(0, Number(indicator.overlayScaleWeight) || 0)),
1097
1182
  inputs: {
1098
1183
  ...defaults,
1099
1184
  ...indicator.inputs ?? {}
@@ -1295,6 +1380,9 @@ function createChart(element, options = {}) {
1295
1380
  canvas.setAttribute("draggable", "false");
1296
1381
  element.innerHTML = "";
1297
1382
  element.appendChild(canvas);
1383
+ const baseCanvas = document.createElement("canvas");
1384
+ const baseCtx = baseCanvas.getContext("2d");
1385
+ let overlayLayout = null;
1298
1386
  const margin = { top: 16, right: 72, bottom: 26, left: 12 };
1299
1387
  let cachedRightMargin = margin.right;
1300
1388
  const minVisibleBars = Math.max(1, Math.floor(mergedOptions.minVisibleBars));
@@ -1334,6 +1422,20 @@ function createChart(element, options = {}) {
1334
1422
  const clamp = (value, min, max) => {
1335
1423
  return Math.min(max, Math.max(min, value));
1336
1424
  };
1425
+ const textWidthCache = /* @__PURE__ */ new Map();
1426
+ const measureTextWidth = (text) => {
1427
+ const key = `${ctx.font}\0${text}`;
1428
+ const cached = textWidthCache.get(key);
1429
+ if (cached !== void 0) {
1430
+ return cached;
1431
+ }
1432
+ const width2 = ctx.measureText(text).width;
1433
+ if (textWidthCache.size >= 4096) {
1434
+ textWidthCache.clear();
1435
+ }
1436
+ textWidthCache.set(key, width2);
1437
+ return width2;
1438
+ };
1337
1439
  const resetLabelSlots = (enabled) => {
1338
1440
  noOverlappingLineLabels = enabled;
1339
1441
  rightAxisLabelSlots = [];
@@ -1536,7 +1638,7 @@ function createChart(element, options = {}) {
1536
1638
  return `${integerPart}${decimalPart}`;
1537
1639
  };
1538
1640
  const getMeasuredLabelWidth = (text, paddingX) => {
1539
- return Math.ceil(ctx.measureText(text).width) + paddingX * 2;
1641
+ return Math.ceil(measureTextWidth(text)) + paddingX * 2;
1540
1642
  };
1541
1643
  const getStabilizedNumericLabelWidth = (text, paddingX) => {
1542
1644
  const measured = getMeasuredLabelWidth(text, paddingX);
@@ -1593,11 +1695,16 @@ function createChart(element, options = {}) {
1593
1695
  return Array.from(dedupedByTime.values()).sort((a, b) => a.time.getTime() - b.time.getTime());
1594
1696
  };
1595
1697
  const getTimeStepMs = () => {
1698
+ const configured = Number(mergedOptions.timeStepMs);
1699
+ if (Number.isFinite(configured) && configured > 0) {
1700
+ return configured;
1701
+ }
1596
1702
  if (data.length < 2) {
1597
1703
  return 24 * 60 * 60 * 1e3;
1598
1704
  }
1599
1705
  const deltas = [];
1600
- for (let index = 1; index < Math.min(data.length, 40); index += 1) {
1706
+ const first = Math.max(1, data.length - 40);
1707
+ for (let index = first; index < data.length; index += 1) {
1601
1708
  const previous = data[index - 1];
1602
1709
  const current = data[index];
1603
1710
  if (!previous || !current) {
@@ -1777,11 +1884,11 @@ function createChart(element, options = {}) {
1777
1884
  return;
1778
1885
  }
1779
1886
  const labelText = mergedLine.label ?? formatPrice(mergedLine.price);
1780
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1887
+ const axis = resolvedAxis;
1781
1888
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1782
1889
  const labelPaddingX = 8;
1783
1890
  const labelHeight = 20;
1784
- const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
1891
+ const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(measureTextWidth(labelText)) + labelPaddingX * 2;
1785
1892
  const labelWidth = getRightAxisLabelWidth(chartRight, measuredLabelWidth);
1786
1893
  const labelX = getRightAxisLabelX(chartRight);
1787
1894
  const labelY = placeRightAxisLabel(lineY - labelHeight / 2, labelHeight, chartTop, chartBottom - labelHeight);
@@ -1876,7 +1983,7 @@ function createChart(element, options = {}) {
1876
1983
  const closeAction = mergedLine.type === "market" ? "close" : "cancel";
1877
1984
  const showCloseButton = mergedLine.showCloseButton;
1878
1985
  const actionButtonText = mergedLine.actionButtonText;
1879
- const axis = { ...DEFAULT_AXIS_OPTIONS, ...mergedOptions.axis ?? {} };
1986
+ const axis = resolvedAxis;
1880
1987
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1881
1988
  const legacyButton = actionButtonText ? [
1882
1989
  {
@@ -1898,7 +2005,7 @@ function createChart(element, options = {}) {
1898
2005
  const actionButtonMetrics = actionButtons.map((button) => {
1899
2006
  const paddingX = Math.max(2, button.paddingX ?? mergedLine.actionButtonPaddingX);
1900
2007
  const minWidth = Math.max(16, button.minWidth ?? mergedLine.actionButtonMinWidth);
1901
- const width2 = Math.max(minWidth, Math.ceil(ctx.measureText(button.text).width) + paddingX * 2);
2008
+ const width2 = Math.max(minWidth, Math.ceil(measureTextWidth(button.text)) + paddingX * 2);
1902
2009
  return { button, width: width2 };
1903
2010
  });
1904
2011
  const actionButtonInnerGap = actionButtonMetrics.length > 1 ? Math.max(0, mergedLine.actionButtonsInnerGap) : 0;
@@ -1906,8 +2013,8 @@ function createChart(element, options = {}) {
1906
2013
  const actionButtonsGap = actionButtonMetrics.length > 0 ? Math.max(0, mergedLine.actionButtonsGroupGap) : 0;
1907
2014
  const segmentPaddingX = 8;
1908
2015
  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;
2016
+ const qtyWidth = qtyText ? Math.ceil(measureTextWidth(qtyText)) + segmentPaddingX * 2 : 0;
2017
+ const centerMeasuredWidth = Math.ceil(measureTextWidth(centerText)) + segmentPaddingX * 2;
1911
2018
  const centerWidth = mergedLine.id === void 0 ? centerMeasuredWidth : Math.max(centerMeasuredWidth, orderWidgetWidthById.get(mergedLine.id) ?? 0);
1912
2019
  if (mergedLine.id) {
1913
2020
  orderWidgetWidthById.set(mergedLine.id, centerWidth);
@@ -1915,7 +2022,7 @@ function createChart(element, options = {}) {
1915
2022
  const closeWidth = showCloseButton ? 24 : 0;
1916
2023
  const mainWidgetWidth = qtyWidth + centerWidth + closeWidth;
1917
2024
  const totalWidth = mainWidgetWidth + actionButtonsGap + actionButtonsTotalWidth;
1918
- const crosshair = { ...DEFAULT_CROSSHAIR_OPTIONS, ...mergedOptions.crosshair ?? {} };
2025
+ const crosshair = resolvedCrosshair;
1919
2026
  const crosshairActionInset = crosshair.visible && crosshair.showPriceLabel && crosshair.showPriceActionButton ? Math.max(
1920
2027
  0,
1921
2028
  Math.round(crosshair.priceActionButtonGap) + (clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4)) + 10) - 4
@@ -2096,20 +2203,29 @@ function createChart(element, options = {}) {
2096
2203
  }
2097
2204
  }
2098
2205
  crosshairPoint = point;
2099
- scheduleDraw();
2206
+ scheduleDraw({ overlayOnly: true });
2100
2207
  };
2101
2208
  let drawRafId = null;
2102
2209
  let pendingDrawUpdateAutoScale = false;
2210
+ let pendingDrawOverlayOnly = true;
2103
2211
  const scheduleDraw = (options2 = {}) => {
2104
- pendingDrawUpdateAutoScale = pendingDrawUpdateAutoScale || (options2.updateAutoScale ?? true);
2212
+ const overlayOnly = options2.overlayOnly ?? false;
2213
+ pendingDrawOverlayOnly = pendingDrawOverlayOnly && overlayOnly;
2214
+ pendingDrawUpdateAutoScale = pendingDrawUpdateAutoScale || !overlayOnly && (options2.updateAutoScale ?? true);
2105
2215
  if (drawRafId !== null) {
2106
2216
  return;
2107
2217
  }
2108
2218
  drawRafId = requestAnimationFrame(() => {
2109
2219
  drawRafId = null;
2110
2220
  const updateAutoScale = pendingDrawUpdateAutoScale;
2221
+ const flushOverlayOnly = pendingDrawOverlayOnly;
2111
2222
  pendingDrawUpdateAutoScale = false;
2112
- draw({ updateAutoScale });
2223
+ pendingDrawOverlayOnly = true;
2224
+ if (flushOverlayOnly) {
2225
+ drawOverlayOnly();
2226
+ } else {
2227
+ draw({ updateAutoScale });
2228
+ }
2113
2229
  });
2114
2230
  };
2115
2231
  const draw = (options2 = {}) => {
@@ -2123,15 +2239,15 @@ function createChart(element, options = {}) {
2123
2239
  canvas.width = Math.floor(width * pixelRatio);
2124
2240
  canvas.height = Math.floor(height * pixelRatio);
2125
2241
  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 ?? {} };
2242
+ const axis = resolvedAxis;
2243
+ const xAxis = resolvedXAxis;
2244
+ const yAxis = resolvedYAxis;
2129
2245
  const xAxisFontSize = Math.max(8, xAxis.fontSize);
2130
2246
  const yAxisFontSize = Math.max(8, yAxis.fontSize);
2131
2247
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2132
2248
  ctx.fillStyle = mergedOptions.backgroundColor;
2133
2249
  ctx.fillRect(0, 0, width, height);
2134
- const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
2250
+ const labels = resolvedLabels;
2135
2251
  const estimateRightMargin = () => {
2136
2252
  const paddingX = Math.max(4, labels.labelPaddingX);
2137
2253
  let required = margin.right - 1;
@@ -2244,6 +2360,7 @@ function createChart(element, options = {}) {
2244
2360
  yMax: 0
2245
2361
  };
2246
2362
  drawText("Load OHLC data to render chart", chartLeft + 10, chartTop + 20, "left", "middle", "#64748b");
2363
+ overlayLayout = null;
2247
2364
  return;
2248
2365
  }
2249
2366
  clampXViewport();
@@ -2251,62 +2368,39 @@ function createChart(element, options = {}) {
2251
2368
  const xEnd = xStart + xSpan;
2252
2369
  const startIndex = Math.max(0, Math.floor(xStart));
2253
2370
  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
- }
2371
+ const skipLatestIndex = mergedOptions.autoScaleIgnoreLatestCandle && data.length > 1 ? data.length - 1 : -1;
2372
+ const scanCandleRange = (from, to) => {
2373
+ let min = Number.POSITIVE_INFINITY;
2374
+ let max = Number.NEGATIVE_INFINITY;
2375
+ for (let idx = from; idx <= to; idx += 1) {
2376
+ if (idx === skipLatestIndex) continue;
2377
+ const point = data[idx];
2378
+ if (!point) continue;
2379
+ if (point.l < min) min = point.l;
2380
+ if (point.h > max) max = point.h;
2268
2381
  }
2382
+ return min <= max ? { min, max } : null;
2383
+ };
2384
+ let candleRange = scanCandleRange(startIndex, endIndex);
2385
+ if (!candleRange) {
2386
+ const latestIndex = data.length - 1;
2387
+ candleRange = scanCandleRange(Math.max(0, latestIndex - 120), latestIndex - 1) ?? scanCandleRange(0, latestIndex) ?? { min: data[latestIndex].l, max: data[latestIndex].h };
2269
2388
  }
2270
- let minPrice = Math.min(...priceSource.map((point) => point.l));
2271
- let maxPrice = Math.max(...priceSource.map((point) => point.h));
2389
+ let minPrice = candleRange.min;
2390
+ let maxPrice = candleRange.max;
2272
2391
  if (overlayIndicatorsForScale.length > 0) {
2273
- for (const { indicator } of overlayIndicatorsForScale) {
2392
+ for (const { indicator, plugin } of overlayIndicatorsForScale) {
2274
2393
  if (indicator.excludeFromAutoscale) {
2275
2394
  continue;
2276
2395
  }
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) {
2396
+ const range = plugin.getAutoscaleRange?.(data, startIndex, endIndex, indicator.inputs, skipLatestIndex);
2397
+ if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) {
2289
2398
  continue;
2290
2399
  }
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) {
2302
- continue;
2303
- }
2304
- const seriesMin = Math.min(...visibleValues);
2305
- const seriesMax = Math.max(...visibleValues);
2306
2400
  const weight = Math.min(1, Math.max(0, indicator.overlayScaleWeight));
2307
2401
  const currentMid = (minPrice + maxPrice) / 2;
2308
- const weightedMin = currentMid + (seriesMin - currentMid) * weight;
2309
- const weightedMax = currentMid + (seriesMax - currentMid) * weight;
2402
+ const weightedMin = currentMid + (range.min - currentMid) * weight;
2403
+ const weightedMax = currentMid + (range.max - currentMid) * weight;
2310
2404
  minPrice = Math.min(minPrice, weightedMin);
2311
2405
  maxPrice = Math.max(maxPrice, weightedMax);
2312
2406
  }
@@ -2381,7 +2475,7 @@ function createChart(element, options = {}) {
2381
2475
  ctx.save();
2382
2476
  const prevFont = ctx.font;
2383
2477
  ctx.font = `500 12px ${mergedOptions.fontFamily}`;
2384
- const textWidth = ctx.measureText(labelText).width;
2478
+ const textWidth = measureTextWidth(labelText);
2385
2479
  const paddingX = 6;
2386
2480
  const bgWidth = textWidth + paddingX * 2;
2387
2481
  const bgHeight = 18;
@@ -2547,7 +2641,7 @@ function createChart(element, options = {}) {
2547
2641
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2548
2642
  levelLines.forEach((level, index) => {
2549
2643
  const labelText = `${level.ratio} (${formatPrice(level.price)})`;
2550
- const textWidth = ctx.measureText(labelText).width;
2644
+ const textWidth = measureTextWidth(labelText);
2551
2645
  const padding = 4;
2552
2646
  const bgX = lineLeft + 4;
2553
2647
  const bgY = level.y - 9;
@@ -2620,7 +2714,7 @@ function createChart(element, options = {}) {
2620
2714
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2621
2715
  levelLines.forEach((level, index) => {
2622
2716
  const labelText = `${level.ratio} (${formatPrice(level.price)})`;
2623
- const textWidth = ctx.measureText(labelText).width;
2717
+ const textWidth = measureTextWidth(labelText);
2624
2718
  const padding = 4;
2625
2719
  const bgX = chartLeft + 4;
2626
2720
  const bgY = level.y - 9;
@@ -2763,8 +2857,8 @@ function createChart(element, options = {}) {
2763
2857
  const padding = 6;
2764
2858
  const lineH = 14;
2765
2859
  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;
2860
+ const digitW = measureTextWidth("0");
2861
+ const charAdvance = (ch) => isDigit(ch) ? digitW : measureTextWidth(ch);
2768
2862
  const lineWidth = (line) => {
2769
2863
  let w = 0;
2770
2864
  for (const ch of line) w += charAdvance(ch);
@@ -2787,11 +2881,11 @@ function createChart(element, options = {}) {
2787
2881
  const y = startY + lineIndex * lineH;
2788
2882
  for (const ch of line) {
2789
2883
  if (isDigit(ch)) {
2790
- ctx.fillText(ch, x + (digitW - ctx.measureText(ch).width) / 2, y);
2884
+ ctx.fillText(ch, x + (digitW - measureTextWidth(ch)) / 2, y);
2791
2885
  x += digitW;
2792
2886
  } else {
2793
2887
  ctx.fillText(ch, x, y);
2794
- x += ctx.measureText(ch).width;
2888
+ x += measureTextWidth(ch);
2795
2889
  }
2796
2890
  }
2797
2891
  });
@@ -2923,7 +3017,7 @@ function createChart(element, options = {}) {
2923
3017
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2924
3018
  const padding = 6;
2925
3019
  const lineHeight = 15;
2926
- const textW = Math.max(...labelLines.map((line) => ctx.measureText(line).width));
3020
+ const textW = Math.max(...labelLines.map((line) => measureTextWidth(line)));
2927
3021
  const pillW = textW + padding * 2;
2928
3022
  const pillH = labelLines.length * lineHeight + padding;
2929
3023
  const pillX = midX - pillW / 2;
@@ -2959,7 +3053,7 @@ function createChart(element, options = {}) {
2959
3053
  ctx.textBaseline = "middle";
2960
3054
  const lines = (drawing.label ?? "").split("\n");
2961
3055
  const lineH = Math.round(fontSize * 1.35);
2962
- const textW = Math.max(1, ...lines.map((line) => ctx.measureText(line).width));
3056
+ const textW = Math.max(1, ...lines.map((line) => measureTextWidth(line)));
2963
3057
  const padX = isNote ? 8 : 2;
2964
3058
  const padY = isNote ? 6 : 1;
2965
3059
  const blockW = textW + padX * 2;
@@ -3041,47 +3135,114 @@ function createChart(element, options = {}) {
3041
3135
  Math.max(1, candleSpacing - 1),
3042
3136
  Math.max(candleMinWidth, Math.floor(candleSpacing * candleBodyWidthRatio))
3043
3137
  );
3044
- const grid = { ...DEFAULT_GRID_OPTIONS, ...mergedOptions.grid ?? {} };
3138
+ const grid = resolvedGrid;
3045
3139
  const gridOpacity = clamp(grid.opacity, 0, 1);
3046
3140
  const yTickCountInput = grid.yTickCount ?? grid.horizontalTickCount;
3047
3141
  const yTicks = Math.max(1, Math.floor(yTickCountInput));
3048
3142
  const gridColor = grid.color ?? mergedOptions.gridColor;
3143
+ const priceTicks = [];
3144
+ {
3145
+ const targetStep = yRange / Math.max(1, yTicks);
3146
+ let step;
3147
+ if (targetStep > 0 && Number.isFinite(targetStep)) {
3148
+ const base = Math.pow(10, Math.floor(Math.log10(targetStep)));
3149
+ const frac = targetStep / base;
3150
+ step = (frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 2.5 ? 2.5 : frac <= 5 ? 5 : 10) * base;
3151
+ const instrumentTick = getConfiguredTickSize();
3152
+ if (instrumentTick > 0 && step < instrumentTick * 1e6) {
3153
+ step = Math.max(instrumentTick, Math.round(step / instrumentTick) * instrumentTick);
3154
+ }
3155
+ } else {
3156
+ step = 1;
3157
+ }
3158
+ const firstTick = Math.ceil(yMin / step) * step;
3159
+ for (let i = 0; ; i += 1) {
3160
+ const price = firstTick + i * step;
3161
+ if (price > yMax + step * 1e-6) break;
3162
+ priceTicks.push(price);
3163
+ if (priceTicks.length > 200) break;
3164
+ }
3165
+ }
3049
3166
  if (grid.horizontalLines) {
3050
3167
  ctx.save();
3051
3168
  ctx.globalAlpha = gridOpacity;
3052
- for (let tick = 0; tick <= yTicks; tick += 1) {
3053
- const ratio = tick / yTicks;
3054
- const price = yMin + yRange * ratio;
3169
+ ctx.strokeStyle = gridColor;
3170
+ ctx.lineWidth = 1;
3171
+ ctx.beginPath();
3172
+ for (const price of priceTicks) {
3055
3173
  const y = yFromPrice(price);
3056
- ctx.strokeStyle = gridColor;
3057
- ctx.lineWidth = 1;
3058
- ctx.beginPath();
3059
3174
  ctx.moveTo(crisp(chartLeft), crisp(y));
3060
3175
  ctx.lineTo(crisp(chartRight), crisp(y));
3061
- ctx.stroke();
3062
3176
  }
3177
+ ctx.stroke();
3063
3178
  ctx.restore();
3064
3179
  }
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);
3180
+ const minLabelSpacingPx = Math.max(60, Math.min(120, chartWidth / Math.max(2, Math.floor(grid.xTickCount ?? 8))));
3181
+ const visibleTickStart = Math.max(0, Math.floor(xStart));
3072
3182
  const visibleTickEnd = Math.ceil(xEnd) - 1;
3073
- const tickStartIndex = Math.ceil(visibleTickStart / xStep) * xStep;
3074
- if (grid.verticalLines) {
3183
+ const timeTicks = [];
3184
+ {
3185
+ const weightBuckets = [[], [], [], [], [], [], [], [], [], []];
3186
+ let prevTime = visibleTickStart > 0 ? getTimeForIndex(visibleTickStart - 1) : null;
3187
+ for (let index = visibleTickStart; index <= visibleTickEnd; index += 1) {
3188
+ const time = getTimeForIndex(index);
3189
+ if (!time) {
3190
+ prevTime = null;
3191
+ continue;
3192
+ }
3193
+ let weight = 0;
3194
+ if (!prevTime) {
3195
+ weight = 7;
3196
+ } else {
3197
+ if (prevTime.getFullYear() !== time.getFullYear()) weight = 9;
3198
+ else if (prevTime.getMonth() !== time.getMonth()) weight = 8;
3199
+ else if (prevTime.getDate() !== time.getDate()) weight = 7;
3200
+ else {
3201
+ const prevMinutes = prevTime.getHours() * 60 + prevTime.getMinutes();
3202
+ const curMinutes = time.getHours() * 60 + time.getMinutes();
3203
+ const crossed = (span) => Math.floor(prevMinutes / span) !== Math.floor(curMinutes / span);
3204
+ if (crossed(360)) weight = 6;
3205
+ else if (crossed(60)) weight = 5;
3206
+ else if (crossed(30)) weight = 4;
3207
+ else if (crossed(15)) weight = 3;
3208
+ else if (crossed(5)) weight = 2;
3209
+ else weight = 1;
3210
+ }
3211
+ }
3212
+ weightBuckets[weight].push(index);
3213
+ prevTime = time;
3214
+ }
3215
+ const placedXs = [];
3216
+ const maxMarks = Math.floor(chartWidth / minLabelSpacingPx) + 2;
3217
+ for (let weight = 9; weight >= 1 && timeTicks.length < maxMarks; weight -= 1) {
3218
+ for (const index of weightBuckets[weight]) {
3219
+ const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3220
+ let fits = true;
3221
+ for (const placedX of placedXs) {
3222
+ if (Math.abs(placedX - x) < minLabelSpacingPx) {
3223
+ fits = false;
3224
+ break;
3225
+ }
3226
+ }
3227
+ if (!fits) continue;
3228
+ placedXs.push(x);
3229
+ timeTicks.push({ index, weight, x });
3230
+ if (timeTicks.length >= maxMarks) break;
3231
+ }
3232
+ }
3233
+ timeTicks.sort((a, b) => a.index - b.index);
3234
+ }
3235
+ if (grid.verticalLines && timeTicks.length > 0) {
3075
3236
  ctx.save();
3076
3237
  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();
3238
+ ctx.strokeStyle = gridColor;
3239
+ ctx.lineWidth = 1;
3240
+ ctx.beginPath();
3241
+ for (const tick of timeTicks) {
3242
+ ctx.moveTo(crisp(tick.x), crisp(chartTop));
3243
+ ctx.lineTo(crisp(tick.x), crisp(fullChartBottom));
3084
3244
  }
3245
+ ctx.stroke();
3085
3246
  ctx.restore();
3086
3247
  }
3087
3248
  if (grid.sessionSeparators && data.length > 1) {
@@ -3119,38 +3280,45 @@ function createChart(element, options = {}) {
3119
3280
  }
3120
3281
  return data[index]?.v;
3121
3282
  };
3122
- for (let index = startIndex; index <= endIndex; index += 1) {
3123
- const point = data[index];
3124
- if (!point) {
3125
- continue;
3283
+ {
3284
+ const upWicks = new Path2D();
3285
+ const downWicks = new Path2D();
3286
+ const upBodies = new Path2D();
3287
+ const downBodies = new Path2D();
3288
+ for (let index = startIndex; index <= endIndex; index += 1) {
3289
+ const point = data[index];
3290
+ if (!point) {
3291
+ continue;
3292
+ }
3293
+ const isLastCandle = useSmoothedCandle && index === lastDataIndex;
3294
+ const actualDirection = point.c >= point.o ? "up" : "down";
3295
+ const displayClose = isLastCandle ? actualDirection === "up" ? Math.max(point.o, smoothedTickerPrice) : Math.min(point.o, smoothedTickerPrice) : point.c;
3296
+ const displayHigh = isLastCandle ? actualDirection === "up" ? Math.max(point.h, displayClose) : point.h : point.h;
3297
+ const displayLow = isLastCandle ? actualDirection === "up" ? point.l : Math.min(point.l, displayClose) : point.l;
3298
+ const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
3299
+ const direction = isLastCandle ? actualDirection : getCandleDirectionByIndex(index);
3300
+ const isUp = direction === "up";
3301
+ const roundedCenterX = Math.round(centerX);
3302
+ const wicks = isUp ? upWicks : downWicks;
3303
+ wicks.moveTo(roundedCenterX + 0.5, crisp(yFromPrice(displayHigh)));
3304
+ wicks.lineTo(roundedCenterX + 0.5, crisp(yFromPrice(displayLow)));
3305
+ const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
3306
+ const openYPx = Math.round(yFromPrice(point.o));
3307
+ const closeYPx = Math.round(yFromPrice(displayClose));
3308
+ const bodyIsUp = displayClose >= point.o;
3309
+ const bodyTop = bodyIsUp ? Math.min(closeYPx, openYPx - 1) : openYPx;
3310
+ const bodyBottom = bodyIsUp ? openYPx : Math.max(closeYPx, openYPx + 1);
3311
+ (isUp ? upBodies : downBodies).rect(bodyLeft, bodyTop, bodyWidth, bodyBottom - bodyTop);
3126
3312
  }
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
3313
  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);
3314
+ ctx.strokeStyle = mergedOptions.upColor;
3315
+ ctx.stroke(upWicks);
3316
+ ctx.fillStyle = mergedOptions.upColor;
3317
+ ctx.fill(upBodies);
3318
+ ctx.strokeStyle = mergedOptions.downColor;
3319
+ ctx.stroke(downWicks);
3320
+ ctx.fillStyle = mergedOptions.downColor;
3321
+ ctx.fill(downBodies);
3154
3322
  }
3155
3323
  const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
3156
3324
  (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
@@ -3268,56 +3436,6 @@ function createChart(element, options = {}) {
3268
3436
  ctx.textAlign = prevAlign;
3269
3437
  ctx.textBaseline = prevBaseline;
3270
3438
  }
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
3439
  ctx.restore();
3322
3440
  const positionAxisGutter = Math.max(0, width - chartRight);
3323
3441
  if (positionAxisGutter > 0) {
@@ -3430,7 +3548,7 @@ function createChart(element, options = {}) {
3430
3548
  const text = label.text ?? formatPaneValue(label.value);
3431
3549
  const labelPaddingX = 7;
3432
3550
  const labelHeight = Math.max(16, yAxisFontSize + 8);
3433
- const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(ctx.measureText(text).width) + labelPaddingX * 2);
3551
+ const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(measureTextWidth(text)) + labelPaddingX * 2);
3434
3552
  const labelX = getRightAxisLabelX(chartRight);
3435
3553
  const labelY = clamp(yFromPaneValue(label.value) - labelHeight / 2, paneTop + 2, paneBottom - labelHeight - 2);
3436
3554
  ctx.fillStyle = label.backgroundColor ?? label.color ?? labels.backgroundColor;
@@ -3442,18 +3560,6 @@ function createChart(element, options = {}) {
3442
3560
  }
3443
3561
  });
3444
3562
  }
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
3563
  ctx.strokeStyle = axis.lineColor;
3458
3564
  ctx.lineWidth = Math.max(1, axis.lineWidth);
3459
3565
  ctx.beginPath();
@@ -3463,13 +3569,13 @@ function createChart(element, options = {}) {
3463
3569
  ctx.lineTo(crisp(chartRight), crisp(fullChartBottom));
3464
3570
  ctx.stroke();
3465
3571
  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);
3572
+ {
3470
3573
  const prevFont = ctx.font;
3471
3574
  ctx.font = `${yAxisFontSize}px ${mergedOptions.fontFamily}`;
3472
- drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
3575
+ for (const price of priceTicks) {
3576
+ const y = clamp(yFromPrice(price), chartTop + priceScaleTickLabelInset, chartBottom - priceScaleTickLabelInset);
3577
+ drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
3578
+ }
3473
3579
  ctx.font = prevFont;
3474
3580
  }
3475
3581
  resetLabelSlots(labels.noOverlapping);
@@ -3507,8 +3613,8 @@ function createChart(element, options = {}) {
3507
3613
  return null;
3508
3614
  }
3509
3615
  const stepMs = getTimeStepMs();
3510
- const rawRemainingMs = last.time.getTime() + stepMs - Date.now();
3511
- const countdownMs = rawRemainingMs >= 0 && rawRemainingMs <= stepMs * 2 ? rawRemainingMs : stepMs - Date.now() % stepMs;
3616
+ const elapsedMs = Date.now() - last.time.getTime();
3617
+ const countdownMs = elapsedMs >= 0 ? stepMs - elapsedMs % stepMs : Math.min(stepMs, stepMs - elapsedMs);
3512
3618
  return formatDuration(countdownMs);
3513
3619
  };
3514
3620
  const ticker = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
@@ -3578,9 +3684,15 @@ function createChart(element, options = {}) {
3578
3684
  });
3579
3685
  }
3580
3686
  }
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));
3687
+ if (labels.showHighLow && endIndex >= startIndex) {
3688
+ let visibleHigh = Number.NEGATIVE_INFINITY;
3689
+ let visibleLow = Number.POSITIVE_INFINITY;
3690
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
3691
+ const point = data[idx];
3692
+ if (!point) continue;
3693
+ if (point.h > visibleHigh) visibleHigh = point.h;
3694
+ if (point.l < visibleLow) visibleLow = point.l;
3695
+ }
3584
3696
  addPriceAxisLabel({
3585
3697
  text: `H ${formatPrice(visibleHigh)}`,
3586
3698
  price: visibleHigh,
@@ -3633,12 +3745,12 @@ function createChart(element, options = {}) {
3633
3745
  const subtextFontSize = label.subtextFontSize !== void 0 && label.subtextFontSize > 0 ? Math.max(8, Math.round(label.subtextFontSize)) : priceLabelFontSize;
3634
3746
  const subtextLineGap = 5;
3635
3747
  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;
3748
+ const primaryWidth = label.text === formatPrice(label.price) ? getPriceLabelWidth(label.text, labelPaddingX) : Math.ceil(measureTextWidth(label.text)) + labelPaddingX * 2;
3637
3749
  let subtextWidth = 0;
3638
3750
  if (subtexts.length > 0) {
3639
3751
  const baseFont = ctx.font;
3640
3752
  ctx.font = `${subtextFontSize}px ${mergedOptions.fontFamily}`;
3641
- subtextWidth = Math.max(...subtexts.map((subtext) => Math.ceil(ctx.measureText(subtext).width) + labelPaddingX * 2));
3753
+ subtextWidth = Math.max(...subtexts.map((subtext) => Math.ceil(measureTextWidth(subtext)) + labelPaddingX * 2));
3642
3754
  ctx.font = baseFont;
3643
3755
  }
3644
3756
  const labelTextWidth = Math.max(primaryWidth, subtextWidth);
@@ -3742,164 +3854,250 @@ function createChart(element, options = {}) {
3742
3854
  ctx.font = prevFont;
3743
3855
  }
3744
3856
  }
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;
3857
+ {
3757
3858
  const prevFont = ctx.font;
3758
3859
  ctx.font = `${xAxisFontSize}px ${mergedOptions.fontFamily}`;
3759
- drawText(timeLabel, x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
3860
+ for (const tick of timeTicks) {
3861
+ const tickTime = getTimeForIndex(tick.index);
3862
+ if (!tickTime) continue;
3863
+ 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 });
3864
+ drawText(timeLabel, tick.x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
3865
+ }
3760
3866
  ctx.font = prevFont;
3761
3867
  }
3762
3868
  if (labels.visible && labels.showCountdownToBarClose && lastPoint) {
3763
3869
  const stepMs = getTimeStepMs();
3764
- const rawRemainingMs = lastPoint.time.getTime() + stepMs - Date.now();
3765
- const countdownMs = rawRemainingMs >= 0 && rawRemainingMs <= stepMs * 2 ? rawRemainingMs : stepMs - Date.now() % stepMs;
3870
+ const elapsedMs = Date.now() - lastPoint.time.getTime();
3871
+ const countdownMs = elapsedMs >= 0 ? stepMs - elapsedMs % stepMs : Math.min(stepMs, stepMs - elapsedMs);
3766
3872
  const countdownText = formatDuration(countdownMs);
3767
3873
  const prevFont = ctx.font;
3768
3874
  ctx.font = `${xAxisFontSize}px ${mergedOptions.fontFamily}`;
3769
3875
  drawText(countdownText, chartRight + 6, (fullChartBottom + height) / 2, "left", "middle", xAxis.textColor);
3770
3876
  ctx.font = prevFont;
3771
3877
  }
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);
3878
+ if (baseCtx) {
3879
+ if (baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height) {
3880
+ baseCanvas.width = canvas.width;
3881
+ baseCanvas.height = canvas.height;
3882
+ }
3883
+ baseCtx.setTransform(1, 0, 0, 1, 0, 0);
3884
+ baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
3885
+ baseCtx.drawImage(canvas, 0, 0);
3886
+ }
3887
+ overlayLayout = {
3888
+ chartLeft,
3889
+ chartTop,
3890
+ chartRight,
3891
+ chartBottom,
3892
+ chartWidth,
3893
+ chartHeight,
3894
+ fullChartBottom,
3895
+ yMin,
3896
+ yRange,
3897
+ xStart,
3898
+ xSpan
3899
+ };
3900
+ paintCrosshairOverlay();
3901
+ };
3902
+ const paintCrosshairOverlay = () => {
3903
+ crosshairPriceActionRegion = null;
3904
+ const layout = overlayLayout;
3905
+ const crosshair = resolvedCrosshair;
3906
+ if (!layout || !crosshair.visible || !crosshairPoint) {
3907
+ return;
3908
+ }
3909
+ const { chartLeft, chartTop, chartRight, chartBottom, chartWidth, chartHeight, fullChartBottom, yMin, yRange, xStart, xSpan: xSpan2 } = layout;
3910
+ const cx = clamp(crosshairPoint.x, chartLeft, chartRight);
3911
+ const cy = clamp(crosshairPoint.y, chartTop, chartBottom);
3912
+ ctx.save();
3913
+ ctx.beginPath();
3914
+ ctx.rect(chartLeft, chartTop, chartWidth, Math.max(0, chartBottom - chartTop));
3915
+ ctx.clip();
3916
+ if (crosshair.mode === "dot") {
3917
+ ctx.fillStyle = crosshair.color;
3918
+ ctx.beginPath();
3919
+ ctx.arc(cx, cy, Math.max(1, crosshair.dotRadius), 0, Math.PI * 2);
3920
+ ctx.fill();
3921
+ } else {
3922
+ ctx.strokeStyle = crosshair.color;
3923
+ ctx.lineWidth = Math.max(1, crosshair.width);
3924
+ applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3925
+ if (crosshair.showHorizontal) {
3926
+ ctx.beginPath();
3927
+ ctx.moveTo(crisp(chartLeft), crisp(cy));
3928
+ ctx.lineTo(crisp(chartRight), crisp(cy));
3929
+ ctx.stroke();
3930
+ }
3931
+ }
3932
+ if (crosshair.sideHintLeft || crosshair.sideHintRight) {
3933
+ ctx.save();
3934
+ ctx.font = `600 11px ${mergedOptions.fontFamily}`;
3935
+ ctx.textBaseline = "middle";
3936
+ ctx.setLineDash([]);
3937
+ const hintY = clamp(cy - 14, chartTop + 8, chartBottom - 8);
3938
+ const hintGap = 8;
3939
+ if (crosshair.sideHintLeft) {
3940
+ ctx.fillStyle = crosshair.sideHintLeftColor;
3941
+ ctx.textAlign = "right";
3942
+ ctx.fillText(crosshair.sideHintLeft, cx - hintGap, hintY);
3943
+ }
3944
+ if (crosshair.sideHintRight) {
3945
+ ctx.fillStyle = crosshair.sideHintRightColor;
3946
+ ctx.textAlign = "left";
3947
+ ctx.fillText(crosshair.sideHintRight, cx + hintGap, hintY);
3948
+ }
3949
+ ctx.restore();
3950
+ }
3951
+ ctx.restore();
3952
+ if (crosshair.showVertical) {
3953
+ ctx.save();
3954
+ ctx.strokeStyle = crosshair.color;
3955
+ ctx.lineWidth = Math.max(1, crosshair.width);
3956
+ applyDashPattern(crosshair.style, dashPatterns.dotted, dashPatterns.dashed);
3957
+ ctx.beginPath();
3958
+ ctx.moveTo(crisp(cx), crisp(chartTop));
3959
+ ctx.lineTo(crisp(cx), crisp(fullChartBottom));
3960
+ ctx.stroke();
3961
+ ctx.restore();
3962
+ }
3963
+ const labelPaddingX = 8;
3964
+ const labelHeight = 20;
3965
+ const labelRadius = Math.max(0, crosshair.labelBorderRadius);
3966
+ const labelBackground = crosshair.labelBackgroundColor;
3967
+ const labelTextColor = crosshair.labelTextColor;
3968
+ const labelBorderColor = crosshair.labelBorderColor;
3969
+ const labelBorderWidth = Math.max(0, crosshair.labelBorderWidth);
3970
+ const labelBorderStyle = crosshair.labelBorderStyle;
3971
+ const strokeCrosshairLabel = (x, y, widthValue, heightValue = labelHeight) => {
3972
+ if (labelBorderWidth <= 0) {
3973
+ return;
3974
+ }
3975
+ ctx.save();
3976
+ ctx.strokeStyle = labelBorderColor;
3977
+ ctx.lineWidth = labelBorderWidth;
3978
+ applyDashPattern(
3979
+ labelBorderStyle,
3980
+ dashPatterns.borderDotted,
3981
+ dashPatterns.borderDashed
3982
+ );
3983
+ strokeRoundedRect(Math.round(x), Math.round(y), widthValue, heightValue, labelRadius);
3984
+ ctx.restore();
3985
+ };
3986
+ if (crosshair.showPriceLabel) {
3987
+ const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
3988
+ const priceText = formatPrice(hoverPrice);
3989
+ const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
3990
+ const priceX = getRightAxisLabelX(chartRight);
3991
+ const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
3992
+ ctx.fillStyle = labelBackground;
3993
+ fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
3994
+ strokeCrosshairLabel(priceX, priceY, priceWidth);
3995
+ drawText(priceText, priceX + labelPaddingX, priceY + labelHeight / 2, "left", "middle", labelTextColor);
3996
+ if (crosshair.showPriceActionButton) {
3997
+ const buttonSize = clamp(Math.round(crosshair.priceActionButtonSize), 12, Math.max(12, labelHeight - 4));
3998
+ const buttonGap = Math.max(0, Math.round(crosshair.priceActionButtonGap));
3999
+ const containerPaddingX = 5;
4000
+ const containerWidth = buttonSize + containerPaddingX * 2;
4001
+ const containerX = priceX - buttonGap - containerWidth;
4002
+ const containerY = priceY;
4003
+ const buttonX = containerX + containerPaddingX;
4004
+ const buttonY = priceY + (labelHeight - buttonSize) / 2;
4005
+ const buttonRadius = crosshair.priceActionButtonRounded ? clamp(Math.round(crosshair.priceActionButtonBorderRadius), 0, buttonSize / 2) : 0;
4006
+ const buttonBorderWidth = Math.max(1, Math.round(labelBorderWidth || 1));
4007
+ const buttonCenterX = buttonX + buttonSize / 2;
4008
+ const buttonCenterY = buttonY + buttonSize / 2;
4009
+ const containerRadius = labelRadius;
3804
4010
  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([]);
4011
+ fillRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
4012
+ if (labelBorderWidth > 0) {
4013
+ ctx.save();
4014
+ ctx.strokeStyle = labelBorderColor;
4015
+ ctx.lineWidth = labelBorderWidth;
4016
+ ctx.setLineDash([]);
4017
+ strokeRoundedRect(Math.round(containerX), Math.round(containerY), containerWidth, labelHeight, containerRadius);
4018
+ ctx.restore();
4019
+ }
4020
+ if (buttonBorderWidth > 0) {
4021
+ ctx.save();
4022
+ ctx.strokeStyle = labelBorderColor;
4023
+ ctx.lineWidth = buttonBorderWidth;
4024
+ ctx.setLineDash([]);
4025
+ if (crosshair.priceActionButtonRounded) {
3860
4026
  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,
4027
+ ctx.arc(
3870
4028
  buttonCenterX,
3871
4029
  buttonCenterY,
3872
- "center",
3873
- "middle",
3874
- labelTextColor
4030
+ Math.max(1, buttonSize / 2 - buttonBorderWidth / 2),
4031
+ 0,
4032
+ Math.PI * 2
3875
4033
  );
4034
+ ctx.stroke();
4035
+ } else {
4036
+ strokeRoundedRect(Math.round(buttonX), Math.round(buttonY), buttonSize, buttonSize, buttonRadius);
3876
4037
  }
3877
- crosshairPriceActionRegion = {
3878
- x: containerX,
3879
- y: containerY,
3880
- width: containerWidth,
3881
- height: labelHeight,
3882
- price: Number(hoverPrice.toFixed(mergedOptions.priceDecimals))
3883
- };
4038
+ ctx.restore();
3884
4039
  }
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);
4040
+ if (crosshair.priceActionButtonIcon !== "text" && crosshair.priceActionButtonText === "+") {
4041
+ const plusHalf = Math.max(3, Math.round(buttonSize * 0.22));
4042
+ const plusThickness = crosshair.priceActionButtonIcon === "plusThin" ? Math.max(1, Math.round(buttonSize * 0.07)) : Math.max(1, Math.round(buttonSize * 0.1));
4043
+ ctx.save();
4044
+ ctx.strokeStyle = labelTextColor;
4045
+ ctx.lineWidth = plusThickness;
4046
+ ctx.lineCap = "round";
4047
+ ctx.setLineDash([]);
4048
+ ctx.beginPath();
4049
+ ctx.moveTo(buttonCenterX - plusHalf, buttonCenterY);
4050
+ ctx.lineTo(buttonCenterX + plusHalf, buttonCenterY);
4051
+ ctx.moveTo(buttonCenterX, buttonCenterY - plusHalf);
4052
+ ctx.lineTo(buttonCenterX, buttonCenterY + plusHalf);
4053
+ ctx.stroke();
4054
+ ctx.restore();
4055
+ } else {
4056
+ drawText(
4057
+ crosshair.priceActionButtonText,
4058
+ buttonCenterX,
4059
+ buttonCenterY,
4060
+ "center",
4061
+ "middle",
4062
+ labelTextColor
4063
+ );
3900
4064
  }
4065
+ crosshairPriceActionRegion = {
4066
+ x: containerX,
4067
+ y: containerY,
4068
+ width: containerWidth,
4069
+ height: labelHeight,
4070
+ price: Number(hoverPrice.toFixed(mergedOptions.priceDecimals))
4071
+ };
3901
4072
  }
3902
4073
  }
4074
+ if (crosshair.showTimeLabel) {
4075
+ const ratio = clamp((cx - chartLeft) / chartWidth, 0, 1);
4076
+ const hoverIndex = Math.round(xStart + ratio * xSpan2 - 0.5);
4077
+ const hoverTime = getTimeForIndex(hoverIndex);
4078
+ if (hoverTime) {
4079
+ const timeText = formatHoverTimeLabel(hoverTime, crosshair.timeLabelFormat);
4080
+ const timeWidth = Math.ceil(measureTextWidth(timeText)) + labelPaddingX * 2;
4081
+ const timeX = clamp(cx - timeWidth / 2, chartLeft, chartRight - timeWidth);
4082
+ const timeY = fullChartBottom + 1;
4083
+ const timeHeight = Math.max(labelHeight, Math.floor(height - timeY));
4084
+ ctx.fillStyle = labelBackground;
4085
+ fillRoundedRect(Math.round(timeX), Math.round(timeY), timeWidth, timeHeight, labelRadius);
4086
+ strokeCrosshairLabel(timeX, timeY, timeWidth, timeHeight);
4087
+ drawText(timeText, timeX + labelPaddingX, timeY + timeHeight / 2, "left", "middle", labelTextColor);
4088
+ }
4089
+ }
4090
+ };
4091
+ const drawOverlayOnly = () => {
4092
+ if (!baseCtx || !overlayLayout || baseCanvas.width !== canvas.width || baseCanvas.height !== canvas.height || baseCanvas.width === 0) {
4093
+ draw({ updateAutoScale: false });
4094
+ return;
4095
+ }
4096
+ const pixelRatio = getPixelRatio();
4097
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4098
+ ctx.drawImage(baseCanvas, 0, 0);
4099
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
4100
+ paintCrosshairOverlay();
3903
4101
  };
3904
4102
  const zoomX = (factor, anchorX) => {
3905
4103
  if (!drawState || data.length === 0) {
@@ -3990,6 +4188,59 @@ function createChart(element, options = {}) {
3990
4188
  }
3991
4189
  scheduleDraw();
3992
4190
  };
4191
+ let kineticRafId = null;
4192
+ const panVelocitySamples = [];
4193
+ const cancelKineticPan = () => {
4194
+ if (kineticRafId !== null) {
4195
+ cancelAnimationFrame(kineticRafId);
4196
+ kineticRafId = null;
4197
+ }
4198
+ };
4199
+ const recordPanVelocitySample = (x) => {
4200
+ const now = performance.now();
4201
+ panVelocitySamples.push({ t: now, x });
4202
+ while (panVelocitySamples.length > 0 && now - panVelocitySamples[0].t > 120) {
4203
+ panVelocitySamples.shift();
4204
+ }
4205
+ };
4206
+ const startKineticPan = (initialVelocityPxPerMs) => {
4207
+ cancelKineticPan();
4208
+ let velocity = initialVelocityPxPerMs;
4209
+ let lastTime = performance.now();
4210
+ const stepFn = (now) => {
4211
+ kineticRafId = null;
4212
+ const dt = Math.min(64, Math.max(1, now - lastTime));
4213
+ lastTime = now;
4214
+ const centerBefore = xCenter;
4215
+ pan(velocity * dt, 0, true, false);
4216
+ velocity *= Math.exp(-dt / 325);
4217
+ if (Math.abs(velocity) < 0.02 || xCenter === centerBefore) {
4218
+ return;
4219
+ }
4220
+ kineticRafId = requestAnimationFrame(stepFn);
4221
+ };
4222
+ kineticRafId = requestAnimationFrame(stepFn);
4223
+ };
4224
+ const maybeStartKineticPan = (pointerType) => {
4225
+ const config = mergedOptions.kineticScroll ?? DEFAULT_OPTIONS.kineticScroll;
4226
+ const enabled = pointerType === "touch" || pointerType === "pen" ? config?.touch !== false : config?.mouse === true;
4227
+ if (!enabled || panVelocitySamples.length < 2) {
4228
+ panVelocitySamples.length = 0;
4229
+ return;
4230
+ }
4231
+ const first = panVelocitySamples[0];
4232
+ const last = panVelocitySamples[panVelocitySamples.length - 1];
4233
+ panVelocitySamples.length = 0;
4234
+ const elapsed = last.t - first.t;
4235
+ if (elapsed <= 0 || performance.now() - last.t > 100) {
4236
+ return;
4237
+ }
4238
+ const velocity = (last.x - first.x) / elapsed;
4239
+ if (Math.abs(velocity) < 0.1) {
4240
+ return;
4241
+ }
4242
+ startKineticPan(clamp(velocity, -4, 4));
4243
+ };
3993
4244
  const resetYViewport = () => {
3994
4245
  yMinOverride = null;
3995
4246
  yMaxOverride = null;
@@ -4049,6 +4300,7 @@ function createChart(element, options = {}) {
4049
4300
  scheduleDraw();
4050
4301
  };
4051
4302
  const resetViewport = () => {
4303
+ cancelKineticPan();
4052
4304
  fitXViewport();
4053
4305
  resetYViewport();
4054
4306
  updateFollowLatest(true);
@@ -4058,6 +4310,7 @@ function createChart(element, options = {}) {
4058
4310
  const isFollowingLatest = () => followLatest;
4059
4311
  const setFollowingLatest = (follow) => {
4060
4312
  if (follow && data.length > 0) {
4313
+ cancelKineticPan();
4061
4314
  xCenter = data.length - xSpan / 2 + rightEdgePaddingBars;
4062
4315
  clampXViewport();
4063
4316
  updateFollowLatest(true);
@@ -4427,7 +4680,7 @@ function createChart(element, options = {}) {
4427
4680
  const prevFont = ctx.font;
4428
4681
  ctx.font = `500 ${fontSize}px ${mergedOptions.fontFamily}`;
4429
4682
  const lines = (drawing.label ?? "").split("\n");
4430
- const textW = Math.max(1, ...lines.map((line) => ctx.measureText(line).width));
4683
+ const textW = Math.max(1, ...lines.map((line) => measureTextWidth(line)));
4431
4684
  ctx.font = prevFont;
4432
4685
  const isNote = drawing.type === "note";
4433
4686
  const padX = isNote ? 8 : 2;
@@ -4900,16 +5153,24 @@ function createChart(element, options = {}) {
4900
5153
  emitViewportChange();
4901
5154
  scheduleDraw();
4902
5155
  };
5156
+ const capturePointer = (pointerId) => {
5157
+ try {
5158
+ canvas.setPointerCapture(pointerId);
5159
+ } catch {
5160
+ }
5161
+ };
4903
5162
  const onPointerDown = (event) => {
4904
5163
  if (event.pointerType === "touch" || event.pointerType === "pen") {
4905
5164
  event.preventDefault();
4906
5165
  }
5166
+ cancelKineticPan();
5167
+ panVelocitySamples.length = 0;
4907
5168
  magnetModifierActive = event.metaKey || event.ctrlKey;
4908
5169
  shiftKeyActive = event.shiftKey;
4909
5170
  const point = getCanvasPoint(event);
4910
5171
  if (event.pointerType === "touch") {
4911
5172
  touchPointers.set(event.pointerId, point);
4912
- canvas.setPointerCapture(event.pointerId);
5173
+ capturePointer(event.pointerId);
4913
5174
  const touchPair = getTouchPair();
4914
5175
  if (touchPair) {
4915
5176
  beginPinchZoom(touchPair[0], touchPair[1]);
@@ -4939,7 +5200,7 @@ function createChart(element, options = {}) {
4939
5200
  lastPrice: startPrice,
4940
5201
  moved: false
4941
5202
  };
4942
- canvas.setPointerCapture(event.pointerId);
5203
+ capturePointer(event.pointerId);
4943
5204
  canvas.style.cursor = "ns-resize";
4944
5205
  setCrosshairPoint(null);
4945
5206
  return;
@@ -4960,7 +5221,7 @@ function createChart(element, options = {}) {
4960
5221
  startPrice: orderDragRegion.price,
4961
5222
  lastPrice: orderDragRegion.price
4962
5223
  };
4963
- canvas.setPointerCapture(event.pointerId);
5224
+ capturePointer(event.pointerId);
4964
5225
  canvas.style.cursor = "ns-resize";
4965
5226
  setCrosshairPoint(null);
4966
5227
  return;
@@ -4992,7 +5253,7 @@ function createChart(element, options = {}) {
4992
5253
  startPoints: drawingHit.drawing.points.map((drawingPoint) => ({ ...drawingPoint }))
4993
5254
  };
4994
5255
  activePointerId = event.pointerId;
4995
- canvas.setPointerCapture(event.pointerId);
5256
+ capturePointer(event.pointerId);
4996
5257
  }
4997
5258
  }
4998
5259
  setCrosshairPoint(null);
@@ -5020,7 +5281,7 @@ function createChart(element, options = {}) {
5020
5281
  };
5021
5282
  lastPointerX = point.x;
5022
5283
  lastPointerY = point.y;
5023
- canvas.setPointerCapture(event.pointerId);
5284
+ capturePointer(event.pointerId);
5024
5285
  if (crosshairDrag) {
5025
5286
  canvas.style.cursor = "crosshair";
5026
5287
  setCrosshairPoint(point);
@@ -5206,6 +5467,7 @@ function createChart(element, options = {}) {
5206
5467
  const deltaY = point.y - lastPointerY;
5207
5468
  if (dragMode === "plot") {
5208
5469
  canvas.style.cursor = "grabbing";
5470
+ recordPanVelocitySample(point.x);
5209
5471
  pan(deltaX, deltaY, true, true);
5210
5472
  setCrosshairPoint(null);
5211
5473
  } else if (dragMode === "x-axis") {
@@ -5279,10 +5541,16 @@ function createChart(element, options = {}) {
5279
5541
  drawingDragState = null;
5280
5542
  emitDrawingsChange();
5281
5543
  }
5544
+ const endedPlotDrag = isDragging && dragMode === "plot";
5282
5545
  isDragging = false;
5283
5546
  dragMode = null;
5284
5547
  activePointerId = null;
5285
5548
  canvas.style.cursor = "default";
5549
+ if (endedPlotDrag) {
5550
+ maybeStartKineticPan(event?.pointerType);
5551
+ } else {
5552
+ panVelocitySamples.length = 0;
5553
+ }
5286
5554
  if (event && pointerDownInfo && event.pointerId === pointerDownInfo.pointerId) {
5287
5555
  if (pointerDownInfo.crosshairDrag) {
5288
5556
  const point = getCanvasPoint(event);
@@ -5320,6 +5588,7 @@ function createChart(element, options = {}) {
5320
5588
  if (!drawState) {
5321
5589
  return;
5322
5590
  }
5591
+ cancelKineticPan();
5323
5592
  const point = getCanvasPoint(event);
5324
5593
  const region = getHitRegion(point.x, point.y);
5325
5594
  if (region === "outside") {
@@ -5419,6 +5688,7 @@ function createChart(element, options = {}) {
5419
5688
  width = nextOptions.width !== void 0 && nextOptions.width > 0 ? mergedOptions.width : previousWidth;
5420
5689
  height = nextOptions.height !== void 0 && nextOptions.height > 0 ? mergedOptions.height : previousHeight;
5421
5690
  mergedOptions = { ...mergedOptions, width, height };
5691
+ refreshResolvedOptions();
5422
5692
  resetRightMarginCache();
5423
5693
  doubleClickEnabled = mergedOptions.doubleClickEnabled;
5424
5694
  doubleClickAction = mergedOptions.doubleClickAction;
@@ -5763,6 +6033,7 @@ function createChart(element, options = {}) {
5763
6033
  drawingHoverHandler = handler;
5764
6034
  };
5765
6035
  const destroy = () => {
6036
+ cancelKineticPan();
5766
6037
  if (smoothingRafId !== null) {
5767
6038
  cancelAnimationFrame(smoothingRafId);
5768
6039
  smoothingRafId = null;