hyperprop-charting-library 0.1.33 → 0.1.35

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.
@@ -821,10 +821,21 @@ function createChart(element, options = {}) {
821
821
  let xSpan = 60;
822
822
  let followLatest = true;
823
823
  let followingLatestChangeHandler = null;
824
+ let viewportChangeHandler = null;
825
+ let viewportEmitScheduled = false;
826
+ const emitViewportChange = () => {
827
+ if (!viewportChangeHandler || viewportEmitScheduled) return;
828
+ viewportEmitScheduled = true;
829
+ queueMicrotask(() => {
830
+ viewportEmitScheduled = false;
831
+ viewportChangeHandler?.(getViewport());
832
+ });
833
+ };
824
834
  const updateFollowLatest = (next) => {
825
835
  if (followLatest === next) return;
826
836
  followLatest = next;
827
837
  followingLatestChangeHandler?.(next);
838
+ emitViewportChange();
828
839
  };
829
840
  let yMinOverride = null;
830
841
  let yMaxOverride = null;
@@ -841,6 +852,41 @@ function createChart(element, options = {}) {
841
852
  let crosshairPoint = null;
842
853
  let doubleClickEnabled = mergedOptions.doubleClickEnabled;
843
854
  let doubleClickAction = mergedOptions.doubleClickAction;
855
+ let smoothedTickerPrice = null;
856
+ let tickerPriceTarget = null;
857
+ let smoothingRafId = null;
858
+ const tickerSmoothingLoop = () => {
859
+ smoothingRafId = null;
860
+ if (smoothedTickerPrice === null || tickerPriceTarget === null) return;
861
+ const diff = tickerPriceTarget - smoothedTickerPrice;
862
+ if (Math.abs(diff) < 1e-9) {
863
+ smoothedTickerPrice = tickerPriceTarget;
864
+ draw();
865
+ return;
866
+ }
867
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
868
+ const speed = clamp(tickerOpts.smoothingSpeed ?? 8, 1, 60);
869
+ const dt = 1 / 60;
870
+ const lerp = 1 - Math.exp(-speed * dt);
871
+ smoothedTickerPrice += diff * lerp;
872
+ draw();
873
+ smoothingRafId = requestAnimationFrame(tickerSmoothingLoop);
874
+ };
875
+ const pushSmoothedPrice = (target) => {
876
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
877
+ if (!tickerOpts.smoothing) {
878
+ smoothedTickerPrice = null;
879
+ tickerPriceTarget = null;
880
+ return;
881
+ }
882
+ tickerPriceTarget = target;
883
+ if (smoothedTickerPrice === null) {
884
+ smoothedTickerPrice = target;
885
+ }
886
+ if (smoothingRafId === null) {
887
+ smoothingRafId = requestAnimationFrame(tickerSmoothingLoop);
888
+ }
889
+ };
844
890
  const canvas = document.createElement("canvas");
845
891
  const ctx = canvas.getContext("2d");
846
892
  if (!ctx) {
@@ -1801,28 +1847,37 @@ function createChart(element, options = {}) {
1801
1847
  }
1802
1848
  ctx.restore();
1803
1849
  }
1850
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1851
+ const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
1852
+ const lastDataIndex = data.length - 1;
1804
1853
  for (let index = startIndex; index <= endIndex; index += 1) {
1805
1854
  const point = data[index];
1806
1855
  if (!point) {
1807
1856
  continue;
1808
1857
  }
1858
+ const isLastCandle = useSmoothedCandle && index === lastDataIndex;
1859
+ const displayClose = isLastCandle ? smoothedTickerPrice : point.c;
1860
+ const displayHigh = isLastCandle ? Math.max(point.h, smoothedTickerPrice) : point.h;
1861
+ const displayLow = isLastCandle ? Math.min(point.l, smoothedTickerPrice) : point.l;
1809
1862
  const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1810
1863
  const openY = yFromPrice(point.o);
1811
- const closeY = yFromPrice(point.c);
1812
- const highY = yFromPrice(point.h);
1813
- const lowY = yFromPrice(point.l);
1814
- const direction = getCandleDirectionByIndex(index);
1864
+ const closeY = yFromPrice(displayClose);
1865
+ const highY = yFromPrice(displayHigh);
1866
+ const lowY = yFromPrice(displayLow);
1867
+ const direction = isLastCandle ? displayClose >= point.o ? "up" : "down" : getCandleDirectionByIndex(index);
1815
1868
  const candleColor = direction === "up" ? mergedOptions.upColor : mergedOptions.downColor;
1869
+ const roundedCenterX = Math.round(centerX);
1816
1870
  ctx.strokeStyle = candleColor;
1817
1871
  ctx.lineWidth = candleWickWidth;
1818
1872
  ctx.beginPath();
1819
- ctx.moveTo(crisp(centerX), crisp(highY));
1820
- ctx.lineTo(crisp(centerX), crisp(lowY));
1873
+ ctx.moveTo(roundedCenterX + 0.5, crisp(highY));
1874
+ ctx.lineTo(roundedCenterX + 0.5, crisp(lowY));
1821
1875
  ctx.stroke();
1876
+ const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
1822
1877
  const bodyTop = Math.min(openY, closeY);
1823
1878
  const bodyHeight = Math.max(1, Math.abs(closeY - openY));
1824
1879
  ctx.fillStyle = candleColor;
1825
- ctx.fillRect(Math.round(centerX - bodyWidth / 2), Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1880
+ ctx.fillRect(bodyLeft, Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1826
1881
  }
1827
1882
  const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1828
1883
  (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
@@ -1956,10 +2011,10 @@ function createChart(element, options = {}) {
1956
2011
  const ticker = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1957
2012
  const lastPoint = data[data.length - 1];
1958
2013
  if ((ticker.visible ?? true) && lastPoint) {
1959
- const tickerPrice = lastPoint.c;
2014
+ const tickerPrice = ticker.smoothing && smoothedTickerPrice !== null ? smoothedTickerPrice : lastPoint.c;
1960
2015
  const tickerY = yFromPrice(tickerPrice);
1961
2016
  const lineY = clamp(tickerY, chartTop + 1, chartBottom - 1);
1962
- const lastDirection = getCandleDirectionByIndex(data.length - 1);
2017
+ const lastDirection = ticker.smoothing && smoothedTickerPrice !== null ? smoothedTickerPrice >= lastPoint.o ? "up" : "down" : getCandleDirectionByIndex(data.length - 1);
1963
2018
  const tickerColor = ticker.color ?? (lastDirection === "up" ? mergedOptions.upColor : mergedOptions.downColor);
1964
2019
  const tickerThickness = Math.max(1, ticker.thickness ?? 1);
1965
2020
  const tickerStyle = ticker.style ?? "solid";
@@ -2158,6 +2213,7 @@ function createChart(element, options = {}) {
2158
2213
  xCenter = nextStart + nextSpan / 2;
2159
2214
  clampXViewport();
2160
2215
  updateFollowLatest(false);
2216
+ emitViewportChange();
2161
2217
  draw();
2162
2218
  };
2163
2219
  const zoomXToLatest = (factor) => {
@@ -2172,6 +2228,7 @@ function createChart(element, options = {}) {
2172
2228
  xSpan = nextSpan;
2173
2229
  xCenter = nextStart + nextSpan / 2;
2174
2230
  clampXViewport();
2231
+ emitViewportChange();
2175
2232
  draw();
2176
2233
  };
2177
2234
  const zoomY = (factor, anchorY) => {
@@ -2192,6 +2249,7 @@ function createChart(element, options = {}) {
2192
2249
  const clamped = clampYRange(nextMin, nextMax);
2193
2250
  yMinOverride = clamped.min;
2194
2251
  yMaxOverride = clamped.max;
2252
+ emitViewportChange();
2195
2253
  draw();
2196
2254
  };
2197
2255
  const pan = (deltaX, deltaY, allowX, allowY) => {
@@ -2215,6 +2273,9 @@ function createChart(element, options = {}) {
2215
2273
  yMinOverride = clamped.min;
2216
2274
  yMaxOverride = clamped.max;
2217
2275
  }
2276
+ if (allowX || allowY) {
2277
+ emitViewportChange();
2278
+ }
2218
2279
  draw();
2219
2280
  };
2220
2281
  const resetYViewport = () => {
@@ -2254,6 +2315,7 @@ function createChart(element, options = {}) {
2254
2315
  xCenter += bars;
2255
2316
  clampXViewport();
2256
2317
  updateFollowLatest(false);
2318
+ emitViewportChange();
2257
2319
  draw();
2258
2320
  };
2259
2321
  const panY = (priceDelta) => {
@@ -2265,17 +2327,20 @@ function createChart(element, options = {}) {
2265
2327
  const clamped = clampYRange(currentMin + priceDelta, currentMax + priceDelta);
2266
2328
  yMinOverride = clamped.min;
2267
2329
  yMaxOverride = clamped.max;
2330
+ emitViewportChange();
2268
2331
  draw();
2269
2332
  };
2270
2333
  const fitContent = () => {
2271
2334
  fitXViewport();
2272
2335
  updateFollowLatest(true);
2336
+ emitViewportChange();
2273
2337
  draw();
2274
2338
  };
2275
2339
  const resetViewport = () => {
2276
2340
  fitXViewport();
2277
2341
  resetYViewport();
2278
2342
  updateFollowLatest(true);
2343
+ emitViewportChange();
2279
2344
  draw();
2280
2345
  };
2281
2346
  const isFollowingLatest = () => followLatest;
@@ -2292,6 +2357,64 @@ function createChart(element, options = {}) {
2292
2357
  const onFollowingLatestChange = (handler) => {
2293
2358
  followingLatestChangeHandler = handler;
2294
2359
  };
2360
+ const getViewport = () => {
2361
+ let centerTimeMs = null;
2362
+ if (data.length > 0) {
2363
+ const centerRounded = Math.round(xCenter);
2364
+ if (centerRounded >= 0 && centerRounded < data.length) {
2365
+ centerTimeMs = data[centerRounded]?.time.getTime() ?? null;
2366
+ }
2367
+ }
2368
+ return {
2369
+ xSpan,
2370
+ followingLatest: followLatest,
2371
+ centerTimeMs,
2372
+ yMin: yMinOverride,
2373
+ yMax: yMaxOverride
2374
+ };
2375
+ };
2376
+ const setViewport = (viewport) => {
2377
+ let changed = false;
2378
+ if (typeof viewport.xSpan === "number" && Number.isFinite(viewport.xSpan)) {
2379
+ const minSpan = minVisibleBars;
2380
+ const maxSpan = Math.min(maxVisibleBars, Math.max(minSpan, data.length + maxPanBars * 2));
2381
+ const nextSpan = clamp(viewport.xSpan, minSpan, maxSpan);
2382
+ if (nextSpan !== xSpan) {
2383
+ xSpan = nextSpan;
2384
+ changed = true;
2385
+ }
2386
+ }
2387
+ if (typeof viewport.centerTimeMs === "number" && data.length > 0) {
2388
+ const nextCenter = findNearestIndexForTimeMs(viewport.centerTimeMs);
2389
+ if (nextCenter !== null) {
2390
+ xCenter = nextCenter;
2391
+ changed = true;
2392
+ }
2393
+ }
2394
+ if (viewport.yMin !== void 0) {
2395
+ yMinOverride = viewport.yMin;
2396
+ changed = true;
2397
+ }
2398
+ if (viewport.yMax !== void 0) {
2399
+ yMaxOverride = viewport.yMax;
2400
+ changed = true;
2401
+ }
2402
+ if (typeof viewport.followingLatest === "boolean") {
2403
+ if (followLatest !== viewport.followingLatest) {
2404
+ followLatest = viewport.followingLatest;
2405
+ followingLatestChangeHandler?.(followLatest);
2406
+ changed = true;
2407
+ }
2408
+ }
2409
+ if (changed) {
2410
+ clampXViewport();
2411
+ draw();
2412
+ emitViewportChange();
2413
+ }
2414
+ };
2415
+ const onViewportChange = (handler) => {
2416
+ viewportChangeHandler = handler;
2417
+ };
2295
2418
  const getCanvasPoint = (event) => {
2296
2419
  const rect = canvas.getBoundingClientRect();
2297
2420
  return {
@@ -2723,6 +2846,10 @@ function createChart(element, options = {}) {
2723
2846
  fitXViewport();
2724
2847
  }
2725
2848
  }
2849
+ const lastClose = data.length > 0 ? data[data.length - 1].c : null;
2850
+ if (lastClose !== null) {
2851
+ pushSmoothedPrice(lastClose);
2852
+ }
2726
2853
  draw();
2727
2854
  };
2728
2855
  const setPriceLines = (lines) => {
@@ -2873,6 +3000,10 @@ function createChart(element, options = {}) {
2873
3000
  draw();
2874
3001
  };
2875
3002
  const destroy = () => {
3003
+ if (smoothingRafId !== null) {
3004
+ cancelAnimationFrame(smoothingRafId);
3005
+ smoothingRafId = null;
3006
+ }
2876
3007
  canvas.removeEventListener("pointerdown", onPointerDown);
2877
3008
  canvas.removeEventListener("pointermove", onPointerMove);
2878
3009
  canvas.removeEventListener("pointerup", endPointerDrag);
@@ -2907,6 +3038,9 @@ function createChart(element, options = {}) {
2907
3038
  isFollowingLatest,
2908
3039
  setFollowingLatest,
2909
3040
  onFollowingLatestChange,
3041
+ getViewport,
3042
+ setViewport,
3043
+ onViewportChange,
2910
3044
  setDoubleClickEnabled,
2911
3045
  setDoubleClickAction,
2912
3046
  registerIndicator,
@@ -259,6 +259,8 @@ interface TickerLineOptions {
259
259
  labelBackgroundColor?: string;
260
260
  labelTextColor?: string;
261
261
  labelBorderRadius?: number;
262
+ smoothing?: boolean;
263
+ smoothingSpeed?: number;
262
264
  }
263
265
  interface ChartInstance {
264
266
  setData: (data: OhlcDataPoint[]) => void;
@@ -284,6 +286,9 @@ interface ChartInstance {
284
286
  isFollowingLatest: () => boolean;
285
287
  setFollowingLatest: (follow: boolean) => void;
286
288
  onFollowingLatestChange: (handler: ((following: boolean) => void) | null) => void;
289
+ getViewport: () => ViewportState;
290
+ setViewport: (viewport: Partial<ViewportState>) => void;
291
+ onViewportChange: (handler: ((viewport: ViewportState) => void) | null) => void;
287
292
  setDoubleClickEnabled: (enabled: boolean) => void;
288
293
  setDoubleClickAction: (action: "reset" | "placeLimitOrder") => void;
289
294
  registerIndicator: (plugin: IndicatorPlugin<any>) => void;
@@ -305,6 +310,18 @@ interface OhlcDataPoint {
305
310
  c: number;
306
311
  v?: number;
307
312
  }
313
+ interface ViewportState {
314
+ /** Number of bars visible horizontally. */
315
+ xSpan: number;
316
+ /** Whether the chart is auto-following the latest candle. */
317
+ followingLatest: boolean;
318
+ /** Timestamp (ms) at the horizontal center of the viewport. null when data is empty. */
319
+ centerTimeMs: number | null;
320
+ /** Manual Y-axis min override (null = auto-scale). */
321
+ yMin: number | null;
322
+ /** Manual Y-axis max override (null = auto-scale). */
323
+ yMax: number | null;
324
+ }
308
325
  declare function createChart(element: HTMLElement, options?: ChartOptions): ChartInstance;
309
326
 
310
- export { type AxisOptions, type BuiltInIndicatorInfo, type ChartClickEvent, type ChartInstance, type ChartOptions, type CrosshairMoveEvent, type CrosshairOptions, type CrosshairPriceActionEvent, type DashPatternOptions, type GridOptions, type IndicatorInstanceOptions, type IndicatorPane, type IndicatorPlugin, type IndicatorRenderContext, type OhlcDataPoint, type OrderActionButton, type OrderActionEvent, type OrderLineOptions, type PriceLineOptions, type TickerLineOptions, type WatermarkOptions, createChart };
327
+ export { type AxisOptions, type BuiltInIndicatorInfo, type ChartClickEvent, type ChartInstance, type ChartOptions, type CrosshairMoveEvent, type CrosshairOptions, type CrosshairPriceActionEvent, type DashPatternOptions, type GridOptions, type IndicatorInstanceOptions, type IndicatorPane, type IndicatorPlugin, type IndicatorRenderContext, type OhlcDataPoint, type OrderActionButton, type OrderActionEvent, type OrderLineOptions, type PriceLineOptions, type TickerLineOptions, type ViewportState, type WatermarkOptions, createChart };
@@ -797,10 +797,21 @@ function createChart(element, options = {}) {
797
797
  let xSpan = 60;
798
798
  let followLatest = true;
799
799
  let followingLatestChangeHandler = null;
800
+ let viewportChangeHandler = null;
801
+ let viewportEmitScheduled = false;
802
+ const emitViewportChange = () => {
803
+ if (!viewportChangeHandler || viewportEmitScheduled) return;
804
+ viewportEmitScheduled = true;
805
+ queueMicrotask(() => {
806
+ viewportEmitScheduled = false;
807
+ viewportChangeHandler?.(getViewport());
808
+ });
809
+ };
800
810
  const updateFollowLatest = (next) => {
801
811
  if (followLatest === next) return;
802
812
  followLatest = next;
803
813
  followingLatestChangeHandler?.(next);
814
+ emitViewportChange();
804
815
  };
805
816
  let yMinOverride = null;
806
817
  let yMaxOverride = null;
@@ -817,6 +828,41 @@ function createChart(element, options = {}) {
817
828
  let crosshairPoint = null;
818
829
  let doubleClickEnabled = mergedOptions.doubleClickEnabled;
819
830
  let doubleClickAction = mergedOptions.doubleClickAction;
831
+ let smoothedTickerPrice = null;
832
+ let tickerPriceTarget = null;
833
+ let smoothingRafId = null;
834
+ const tickerSmoothingLoop = () => {
835
+ smoothingRafId = null;
836
+ if (smoothedTickerPrice === null || tickerPriceTarget === null) return;
837
+ const diff = tickerPriceTarget - smoothedTickerPrice;
838
+ if (Math.abs(diff) < 1e-9) {
839
+ smoothedTickerPrice = tickerPriceTarget;
840
+ draw();
841
+ return;
842
+ }
843
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
844
+ const speed = clamp(tickerOpts.smoothingSpeed ?? 8, 1, 60);
845
+ const dt = 1 / 60;
846
+ const lerp = 1 - Math.exp(-speed * dt);
847
+ smoothedTickerPrice += diff * lerp;
848
+ draw();
849
+ smoothingRafId = requestAnimationFrame(tickerSmoothingLoop);
850
+ };
851
+ const pushSmoothedPrice = (target) => {
852
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
853
+ if (!tickerOpts.smoothing) {
854
+ smoothedTickerPrice = null;
855
+ tickerPriceTarget = null;
856
+ return;
857
+ }
858
+ tickerPriceTarget = target;
859
+ if (smoothedTickerPrice === null) {
860
+ smoothedTickerPrice = target;
861
+ }
862
+ if (smoothingRafId === null) {
863
+ smoothingRafId = requestAnimationFrame(tickerSmoothingLoop);
864
+ }
865
+ };
820
866
  const canvas = document.createElement("canvas");
821
867
  const ctx = canvas.getContext("2d");
822
868
  if (!ctx) {
@@ -1777,28 +1823,37 @@ function createChart(element, options = {}) {
1777
1823
  }
1778
1824
  ctx.restore();
1779
1825
  }
1826
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1827
+ const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
1828
+ const lastDataIndex = data.length - 1;
1780
1829
  for (let index = startIndex; index <= endIndex; index += 1) {
1781
1830
  const point = data[index];
1782
1831
  if (!point) {
1783
1832
  continue;
1784
1833
  }
1834
+ const isLastCandle = useSmoothedCandle && index === lastDataIndex;
1835
+ const displayClose = isLastCandle ? smoothedTickerPrice : point.c;
1836
+ const displayHigh = isLastCandle ? Math.max(point.h, smoothedTickerPrice) : point.h;
1837
+ const displayLow = isLastCandle ? Math.min(point.l, smoothedTickerPrice) : point.l;
1785
1838
  const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1786
1839
  const openY = yFromPrice(point.o);
1787
- const closeY = yFromPrice(point.c);
1788
- const highY = yFromPrice(point.h);
1789
- const lowY = yFromPrice(point.l);
1790
- const direction = getCandleDirectionByIndex(index);
1840
+ const closeY = yFromPrice(displayClose);
1841
+ const highY = yFromPrice(displayHigh);
1842
+ const lowY = yFromPrice(displayLow);
1843
+ const direction = isLastCandle ? displayClose >= point.o ? "up" : "down" : getCandleDirectionByIndex(index);
1791
1844
  const candleColor = direction === "up" ? mergedOptions.upColor : mergedOptions.downColor;
1845
+ const roundedCenterX = Math.round(centerX);
1792
1846
  ctx.strokeStyle = candleColor;
1793
1847
  ctx.lineWidth = candleWickWidth;
1794
1848
  ctx.beginPath();
1795
- ctx.moveTo(crisp(centerX), crisp(highY));
1796
- ctx.lineTo(crisp(centerX), crisp(lowY));
1849
+ ctx.moveTo(roundedCenterX + 0.5, crisp(highY));
1850
+ ctx.lineTo(roundedCenterX + 0.5, crisp(lowY));
1797
1851
  ctx.stroke();
1852
+ const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
1798
1853
  const bodyTop = Math.min(openY, closeY);
1799
1854
  const bodyHeight = Math.max(1, Math.abs(closeY - openY));
1800
1855
  ctx.fillStyle = candleColor;
1801
- ctx.fillRect(Math.round(centerX - bodyWidth / 2), Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1856
+ ctx.fillRect(bodyLeft, Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1802
1857
  }
1803
1858
  const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1804
1859
  (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
@@ -1932,10 +1987,10 @@ function createChart(element, options = {}) {
1932
1987
  const ticker = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1933
1988
  const lastPoint = data[data.length - 1];
1934
1989
  if ((ticker.visible ?? true) && lastPoint) {
1935
- const tickerPrice = lastPoint.c;
1990
+ const tickerPrice = ticker.smoothing && smoothedTickerPrice !== null ? smoothedTickerPrice : lastPoint.c;
1936
1991
  const tickerY = yFromPrice(tickerPrice);
1937
1992
  const lineY = clamp(tickerY, chartTop + 1, chartBottom - 1);
1938
- const lastDirection = getCandleDirectionByIndex(data.length - 1);
1993
+ const lastDirection = ticker.smoothing && smoothedTickerPrice !== null ? smoothedTickerPrice >= lastPoint.o ? "up" : "down" : getCandleDirectionByIndex(data.length - 1);
1939
1994
  const tickerColor = ticker.color ?? (lastDirection === "up" ? mergedOptions.upColor : mergedOptions.downColor);
1940
1995
  const tickerThickness = Math.max(1, ticker.thickness ?? 1);
1941
1996
  const tickerStyle = ticker.style ?? "solid";
@@ -2134,6 +2189,7 @@ function createChart(element, options = {}) {
2134
2189
  xCenter = nextStart + nextSpan / 2;
2135
2190
  clampXViewport();
2136
2191
  updateFollowLatest(false);
2192
+ emitViewportChange();
2137
2193
  draw();
2138
2194
  };
2139
2195
  const zoomXToLatest = (factor) => {
@@ -2148,6 +2204,7 @@ function createChart(element, options = {}) {
2148
2204
  xSpan = nextSpan;
2149
2205
  xCenter = nextStart + nextSpan / 2;
2150
2206
  clampXViewport();
2207
+ emitViewportChange();
2151
2208
  draw();
2152
2209
  };
2153
2210
  const zoomY = (factor, anchorY) => {
@@ -2168,6 +2225,7 @@ function createChart(element, options = {}) {
2168
2225
  const clamped = clampYRange(nextMin, nextMax);
2169
2226
  yMinOverride = clamped.min;
2170
2227
  yMaxOverride = clamped.max;
2228
+ emitViewportChange();
2171
2229
  draw();
2172
2230
  };
2173
2231
  const pan = (deltaX, deltaY, allowX, allowY) => {
@@ -2191,6 +2249,9 @@ function createChart(element, options = {}) {
2191
2249
  yMinOverride = clamped.min;
2192
2250
  yMaxOverride = clamped.max;
2193
2251
  }
2252
+ if (allowX || allowY) {
2253
+ emitViewportChange();
2254
+ }
2194
2255
  draw();
2195
2256
  };
2196
2257
  const resetYViewport = () => {
@@ -2230,6 +2291,7 @@ function createChart(element, options = {}) {
2230
2291
  xCenter += bars;
2231
2292
  clampXViewport();
2232
2293
  updateFollowLatest(false);
2294
+ emitViewportChange();
2233
2295
  draw();
2234
2296
  };
2235
2297
  const panY = (priceDelta) => {
@@ -2241,17 +2303,20 @@ function createChart(element, options = {}) {
2241
2303
  const clamped = clampYRange(currentMin + priceDelta, currentMax + priceDelta);
2242
2304
  yMinOverride = clamped.min;
2243
2305
  yMaxOverride = clamped.max;
2306
+ emitViewportChange();
2244
2307
  draw();
2245
2308
  };
2246
2309
  const fitContent = () => {
2247
2310
  fitXViewport();
2248
2311
  updateFollowLatest(true);
2312
+ emitViewportChange();
2249
2313
  draw();
2250
2314
  };
2251
2315
  const resetViewport = () => {
2252
2316
  fitXViewport();
2253
2317
  resetYViewport();
2254
2318
  updateFollowLatest(true);
2319
+ emitViewportChange();
2255
2320
  draw();
2256
2321
  };
2257
2322
  const isFollowingLatest = () => followLatest;
@@ -2268,6 +2333,64 @@ function createChart(element, options = {}) {
2268
2333
  const onFollowingLatestChange = (handler) => {
2269
2334
  followingLatestChangeHandler = handler;
2270
2335
  };
2336
+ const getViewport = () => {
2337
+ let centerTimeMs = null;
2338
+ if (data.length > 0) {
2339
+ const centerRounded = Math.round(xCenter);
2340
+ if (centerRounded >= 0 && centerRounded < data.length) {
2341
+ centerTimeMs = data[centerRounded]?.time.getTime() ?? null;
2342
+ }
2343
+ }
2344
+ return {
2345
+ xSpan,
2346
+ followingLatest: followLatest,
2347
+ centerTimeMs,
2348
+ yMin: yMinOverride,
2349
+ yMax: yMaxOverride
2350
+ };
2351
+ };
2352
+ const setViewport = (viewport) => {
2353
+ let changed = false;
2354
+ if (typeof viewport.xSpan === "number" && Number.isFinite(viewport.xSpan)) {
2355
+ const minSpan = minVisibleBars;
2356
+ const maxSpan = Math.min(maxVisibleBars, Math.max(minSpan, data.length + maxPanBars * 2));
2357
+ const nextSpan = clamp(viewport.xSpan, minSpan, maxSpan);
2358
+ if (nextSpan !== xSpan) {
2359
+ xSpan = nextSpan;
2360
+ changed = true;
2361
+ }
2362
+ }
2363
+ if (typeof viewport.centerTimeMs === "number" && data.length > 0) {
2364
+ const nextCenter = findNearestIndexForTimeMs(viewport.centerTimeMs);
2365
+ if (nextCenter !== null) {
2366
+ xCenter = nextCenter;
2367
+ changed = true;
2368
+ }
2369
+ }
2370
+ if (viewport.yMin !== void 0) {
2371
+ yMinOverride = viewport.yMin;
2372
+ changed = true;
2373
+ }
2374
+ if (viewport.yMax !== void 0) {
2375
+ yMaxOverride = viewport.yMax;
2376
+ changed = true;
2377
+ }
2378
+ if (typeof viewport.followingLatest === "boolean") {
2379
+ if (followLatest !== viewport.followingLatest) {
2380
+ followLatest = viewport.followingLatest;
2381
+ followingLatestChangeHandler?.(followLatest);
2382
+ changed = true;
2383
+ }
2384
+ }
2385
+ if (changed) {
2386
+ clampXViewport();
2387
+ draw();
2388
+ emitViewportChange();
2389
+ }
2390
+ };
2391
+ const onViewportChange = (handler) => {
2392
+ viewportChangeHandler = handler;
2393
+ };
2271
2394
  const getCanvasPoint = (event) => {
2272
2395
  const rect = canvas.getBoundingClientRect();
2273
2396
  return {
@@ -2699,6 +2822,10 @@ function createChart(element, options = {}) {
2699
2822
  fitXViewport();
2700
2823
  }
2701
2824
  }
2825
+ const lastClose = data.length > 0 ? data[data.length - 1].c : null;
2826
+ if (lastClose !== null) {
2827
+ pushSmoothedPrice(lastClose);
2828
+ }
2702
2829
  draw();
2703
2830
  };
2704
2831
  const setPriceLines = (lines) => {
@@ -2849,6 +2976,10 @@ function createChart(element, options = {}) {
2849
2976
  draw();
2850
2977
  };
2851
2978
  const destroy = () => {
2979
+ if (smoothingRafId !== null) {
2980
+ cancelAnimationFrame(smoothingRafId);
2981
+ smoothingRafId = null;
2982
+ }
2852
2983
  canvas.removeEventListener("pointerdown", onPointerDown);
2853
2984
  canvas.removeEventListener("pointermove", onPointerMove);
2854
2985
  canvas.removeEventListener("pointerup", endPointerDrag);
@@ -2883,6 +3014,9 @@ function createChart(element, options = {}) {
2883
3014
  isFollowingLatest,
2884
3015
  setFollowingLatest,
2885
3016
  onFollowingLatestChange,
3017
+ getViewport,
3018
+ setViewport,
3019
+ onViewportChange,
2886
3020
  setDoubleClickEnabled,
2887
3021
  setDoubleClickAction,
2888
3022
  registerIndicator,
package/dist/index.cjs CHANGED
@@ -821,10 +821,21 @@ function createChart(element, options = {}) {
821
821
  let xSpan = 60;
822
822
  let followLatest = true;
823
823
  let followingLatestChangeHandler = null;
824
+ let viewportChangeHandler = null;
825
+ let viewportEmitScheduled = false;
826
+ const emitViewportChange = () => {
827
+ if (!viewportChangeHandler || viewportEmitScheduled) return;
828
+ viewportEmitScheduled = true;
829
+ queueMicrotask(() => {
830
+ viewportEmitScheduled = false;
831
+ viewportChangeHandler?.(getViewport());
832
+ });
833
+ };
824
834
  const updateFollowLatest = (next) => {
825
835
  if (followLatest === next) return;
826
836
  followLatest = next;
827
837
  followingLatestChangeHandler?.(next);
838
+ emitViewportChange();
828
839
  };
829
840
  let yMinOverride = null;
830
841
  let yMaxOverride = null;
@@ -841,6 +852,41 @@ function createChart(element, options = {}) {
841
852
  let crosshairPoint = null;
842
853
  let doubleClickEnabled = mergedOptions.doubleClickEnabled;
843
854
  let doubleClickAction = mergedOptions.doubleClickAction;
855
+ let smoothedTickerPrice = null;
856
+ let tickerPriceTarget = null;
857
+ let smoothingRafId = null;
858
+ const tickerSmoothingLoop = () => {
859
+ smoothingRafId = null;
860
+ if (smoothedTickerPrice === null || tickerPriceTarget === null) return;
861
+ const diff = tickerPriceTarget - smoothedTickerPrice;
862
+ if (Math.abs(diff) < 1e-9) {
863
+ smoothedTickerPrice = tickerPriceTarget;
864
+ draw();
865
+ return;
866
+ }
867
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
868
+ const speed = clamp(tickerOpts.smoothingSpeed ?? 8, 1, 60);
869
+ const dt = 1 / 60;
870
+ const lerp = 1 - Math.exp(-speed * dt);
871
+ smoothedTickerPrice += diff * lerp;
872
+ draw();
873
+ smoothingRafId = requestAnimationFrame(tickerSmoothingLoop);
874
+ };
875
+ const pushSmoothedPrice = (target) => {
876
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
877
+ if (!tickerOpts.smoothing) {
878
+ smoothedTickerPrice = null;
879
+ tickerPriceTarget = null;
880
+ return;
881
+ }
882
+ tickerPriceTarget = target;
883
+ if (smoothedTickerPrice === null) {
884
+ smoothedTickerPrice = target;
885
+ }
886
+ if (smoothingRafId === null) {
887
+ smoothingRafId = requestAnimationFrame(tickerSmoothingLoop);
888
+ }
889
+ };
844
890
  const canvas = document.createElement("canvas");
845
891
  const ctx = canvas.getContext("2d");
846
892
  if (!ctx) {
@@ -1801,28 +1847,37 @@ function createChart(element, options = {}) {
1801
1847
  }
1802
1848
  ctx.restore();
1803
1849
  }
1850
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1851
+ const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
1852
+ const lastDataIndex = data.length - 1;
1804
1853
  for (let index = startIndex; index <= endIndex; index += 1) {
1805
1854
  const point = data[index];
1806
1855
  if (!point) {
1807
1856
  continue;
1808
1857
  }
1858
+ const isLastCandle = useSmoothedCandle && index === lastDataIndex;
1859
+ const displayClose = isLastCandle ? smoothedTickerPrice : point.c;
1860
+ const displayHigh = isLastCandle ? Math.max(point.h, smoothedTickerPrice) : point.h;
1861
+ const displayLow = isLastCandle ? Math.min(point.l, smoothedTickerPrice) : point.l;
1809
1862
  const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1810
1863
  const openY = yFromPrice(point.o);
1811
- const closeY = yFromPrice(point.c);
1812
- const highY = yFromPrice(point.h);
1813
- const lowY = yFromPrice(point.l);
1814
- const direction = getCandleDirectionByIndex(index);
1864
+ const closeY = yFromPrice(displayClose);
1865
+ const highY = yFromPrice(displayHigh);
1866
+ const lowY = yFromPrice(displayLow);
1867
+ const direction = isLastCandle ? displayClose >= point.o ? "up" : "down" : getCandleDirectionByIndex(index);
1815
1868
  const candleColor = direction === "up" ? mergedOptions.upColor : mergedOptions.downColor;
1869
+ const roundedCenterX = Math.round(centerX);
1816
1870
  ctx.strokeStyle = candleColor;
1817
1871
  ctx.lineWidth = candleWickWidth;
1818
1872
  ctx.beginPath();
1819
- ctx.moveTo(crisp(centerX), crisp(highY));
1820
- ctx.lineTo(crisp(centerX), crisp(lowY));
1873
+ ctx.moveTo(roundedCenterX + 0.5, crisp(highY));
1874
+ ctx.lineTo(roundedCenterX + 0.5, crisp(lowY));
1821
1875
  ctx.stroke();
1876
+ const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
1822
1877
  const bodyTop = Math.min(openY, closeY);
1823
1878
  const bodyHeight = Math.max(1, Math.abs(closeY - openY));
1824
1879
  ctx.fillStyle = candleColor;
1825
- ctx.fillRect(Math.round(centerX - bodyWidth / 2), Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1880
+ ctx.fillRect(bodyLeft, Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1826
1881
  }
1827
1882
  const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1828
1883
  (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
@@ -1956,10 +2011,10 @@ function createChart(element, options = {}) {
1956
2011
  const ticker = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1957
2012
  const lastPoint = data[data.length - 1];
1958
2013
  if ((ticker.visible ?? true) && lastPoint) {
1959
- const tickerPrice = lastPoint.c;
2014
+ const tickerPrice = ticker.smoothing && smoothedTickerPrice !== null ? smoothedTickerPrice : lastPoint.c;
1960
2015
  const tickerY = yFromPrice(tickerPrice);
1961
2016
  const lineY = clamp(tickerY, chartTop + 1, chartBottom - 1);
1962
- const lastDirection = getCandleDirectionByIndex(data.length - 1);
2017
+ const lastDirection = ticker.smoothing && smoothedTickerPrice !== null ? smoothedTickerPrice >= lastPoint.o ? "up" : "down" : getCandleDirectionByIndex(data.length - 1);
1963
2018
  const tickerColor = ticker.color ?? (lastDirection === "up" ? mergedOptions.upColor : mergedOptions.downColor);
1964
2019
  const tickerThickness = Math.max(1, ticker.thickness ?? 1);
1965
2020
  const tickerStyle = ticker.style ?? "solid";
@@ -2158,6 +2213,7 @@ function createChart(element, options = {}) {
2158
2213
  xCenter = nextStart + nextSpan / 2;
2159
2214
  clampXViewport();
2160
2215
  updateFollowLatest(false);
2216
+ emitViewportChange();
2161
2217
  draw();
2162
2218
  };
2163
2219
  const zoomXToLatest = (factor) => {
@@ -2172,6 +2228,7 @@ function createChart(element, options = {}) {
2172
2228
  xSpan = nextSpan;
2173
2229
  xCenter = nextStart + nextSpan / 2;
2174
2230
  clampXViewport();
2231
+ emitViewportChange();
2175
2232
  draw();
2176
2233
  };
2177
2234
  const zoomY = (factor, anchorY) => {
@@ -2192,6 +2249,7 @@ function createChart(element, options = {}) {
2192
2249
  const clamped = clampYRange(nextMin, nextMax);
2193
2250
  yMinOverride = clamped.min;
2194
2251
  yMaxOverride = clamped.max;
2252
+ emitViewportChange();
2195
2253
  draw();
2196
2254
  };
2197
2255
  const pan = (deltaX, deltaY, allowX, allowY) => {
@@ -2215,6 +2273,9 @@ function createChart(element, options = {}) {
2215
2273
  yMinOverride = clamped.min;
2216
2274
  yMaxOverride = clamped.max;
2217
2275
  }
2276
+ if (allowX || allowY) {
2277
+ emitViewportChange();
2278
+ }
2218
2279
  draw();
2219
2280
  };
2220
2281
  const resetYViewport = () => {
@@ -2254,6 +2315,7 @@ function createChart(element, options = {}) {
2254
2315
  xCenter += bars;
2255
2316
  clampXViewport();
2256
2317
  updateFollowLatest(false);
2318
+ emitViewportChange();
2257
2319
  draw();
2258
2320
  };
2259
2321
  const panY = (priceDelta) => {
@@ -2265,17 +2327,20 @@ function createChart(element, options = {}) {
2265
2327
  const clamped = clampYRange(currentMin + priceDelta, currentMax + priceDelta);
2266
2328
  yMinOverride = clamped.min;
2267
2329
  yMaxOverride = clamped.max;
2330
+ emitViewportChange();
2268
2331
  draw();
2269
2332
  };
2270
2333
  const fitContent = () => {
2271
2334
  fitXViewport();
2272
2335
  updateFollowLatest(true);
2336
+ emitViewportChange();
2273
2337
  draw();
2274
2338
  };
2275
2339
  const resetViewport = () => {
2276
2340
  fitXViewport();
2277
2341
  resetYViewport();
2278
2342
  updateFollowLatest(true);
2343
+ emitViewportChange();
2279
2344
  draw();
2280
2345
  };
2281
2346
  const isFollowingLatest = () => followLatest;
@@ -2292,6 +2357,64 @@ function createChart(element, options = {}) {
2292
2357
  const onFollowingLatestChange = (handler) => {
2293
2358
  followingLatestChangeHandler = handler;
2294
2359
  };
2360
+ const getViewport = () => {
2361
+ let centerTimeMs = null;
2362
+ if (data.length > 0) {
2363
+ const centerRounded = Math.round(xCenter);
2364
+ if (centerRounded >= 0 && centerRounded < data.length) {
2365
+ centerTimeMs = data[centerRounded]?.time.getTime() ?? null;
2366
+ }
2367
+ }
2368
+ return {
2369
+ xSpan,
2370
+ followingLatest: followLatest,
2371
+ centerTimeMs,
2372
+ yMin: yMinOverride,
2373
+ yMax: yMaxOverride
2374
+ };
2375
+ };
2376
+ const setViewport = (viewport) => {
2377
+ let changed = false;
2378
+ if (typeof viewport.xSpan === "number" && Number.isFinite(viewport.xSpan)) {
2379
+ const minSpan = minVisibleBars;
2380
+ const maxSpan = Math.min(maxVisibleBars, Math.max(minSpan, data.length + maxPanBars * 2));
2381
+ const nextSpan = clamp(viewport.xSpan, minSpan, maxSpan);
2382
+ if (nextSpan !== xSpan) {
2383
+ xSpan = nextSpan;
2384
+ changed = true;
2385
+ }
2386
+ }
2387
+ if (typeof viewport.centerTimeMs === "number" && data.length > 0) {
2388
+ const nextCenter = findNearestIndexForTimeMs(viewport.centerTimeMs);
2389
+ if (nextCenter !== null) {
2390
+ xCenter = nextCenter;
2391
+ changed = true;
2392
+ }
2393
+ }
2394
+ if (viewport.yMin !== void 0) {
2395
+ yMinOverride = viewport.yMin;
2396
+ changed = true;
2397
+ }
2398
+ if (viewport.yMax !== void 0) {
2399
+ yMaxOverride = viewport.yMax;
2400
+ changed = true;
2401
+ }
2402
+ if (typeof viewport.followingLatest === "boolean") {
2403
+ if (followLatest !== viewport.followingLatest) {
2404
+ followLatest = viewport.followingLatest;
2405
+ followingLatestChangeHandler?.(followLatest);
2406
+ changed = true;
2407
+ }
2408
+ }
2409
+ if (changed) {
2410
+ clampXViewport();
2411
+ draw();
2412
+ emitViewportChange();
2413
+ }
2414
+ };
2415
+ const onViewportChange = (handler) => {
2416
+ viewportChangeHandler = handler;
2417
+ };
2295
2418
  const getCanvasPoint = (event) => {
2296
2419
  const rect = canvas.getBoundingClientRect();
2297
2420
  return {
@@ -2723,6 +2846,10 @@ function createChart(element, options = {}) {
2723
2846
  fitXViewport();
2724
2847
  }
2725
2848
  }
2849
+ const lastClose = data.length > 0 ? data[data.length - 1].c : null;
2850
+ if (lastClose !== null) {
2851
+ pushSmoothedPrice(lastClose);
2852
+ }
2726
2853
  draw();
2727
2854
  };
2728
2855
  const setPriceLines = (lines) => {
@@ -2873,6 +3000,10 @@ function createChart(element, options = {}) {
2873
3000
  draw();
2874
3001
  };
2875
3002
  const destroy = () => {
3003
+ if (smoothingRafId !== null) {
3004
+ cancelAnimationFrame(smoothingRafId);
3005
+ smoothingRafId = null;
3006
+ }
2876
3007
  canvas.removeEventListener("pointerdown", onPointerDown);
2877
3008
  canvas.removeEventListener("pointermove", onPointerMove);
2878
3009
  canvas.removeEventListener("pointerup", endPointerDrag);
@@ -2907,6 +3038,9 @@ function createChart(element, options = {}) {
2907
3038
  isFollowingLatest,
2908
3039
  setFollowingLatest,
2909
3040
  onFollowingLatestChange,
3041
+ getViewport,
3042
+ setViewport,
3043
+ onViewportChange,
2910
3044
  setDoubleClickEnabled,
2911
3045
  setDoubleClickAction,
2912
3046
  registerIndicator,
package/dist/index.d.cts CHANGED
@@ -259,6 +259,8 @@ interface TickerLineOptions {
259
259
  labelBackgroundColor?: string;
260
260
  labelTextColor?: string;
261
261
  labelBorderRadius?: number;
262
+ smoothing?: boolean;
263
+ smoothingSpeed?: number;
262
264
  }
263
265
  interface ChartInstance {
264
266
  setData: (data: OhlcDataPoint[]) => void;
@@ -284,6 +286,9 @@ interface ChartInstance {
284
286
  isFollowingLatest: () => boolean;
285
287
  setFollowingLatest: (follow: boolean) => void;
286
288
  onFollowingLatestChange: (handler: ((following: boolean) => void) | null) => void;
289
+ getViewport: () => ViewportState;
290
+ setViewport: (viewport: Partial<ViewportState>) => void;
291
+ onViewportChange: (handler: ((viewport: ViewportState) => void) | null) => void;
287
292
  setDoubleClickEnabled: (enabled: boolean) => void;
288
293
  setDoubleClickAction: (action: "reset" | "placeLimitOrder") => void;
289
294
  registerIndicator: (plugin: IndicatorPlugin<any>) => void;
@@ -305,6 +310,18 @@ interface OhlcDataPoint {
305
310
  c: number;
306
311
  v?: number;
307
312
  }
313
+ interface ViewportState {
314
+ /** Number of bars visible horizontally. */
315
+ xSpan: number;
316
+ /** Whether the chart is auto-following the latest candle. */
317
+ followingLatest: boolean;
318
+ /** Timestamp (ms) at the horizontal center of the viewport. null when data is empty. */
319
+ centerTimeMs: number | null;
320
+ /** Manual Y-axis min override (null = auto-scale). */
321
+ yMin: number | null;
322
+ /** Manual Y-axis max override (null = auto-scale). */
323
+ yMax: number | null;
324
+ }
308
325
  declare function createChart(element: HTMLElement, options?: ChartOptions): ChartInstance;
309
326
 
310
- export { type AxisOptions, type BuiltInIndicatorInfo, type ChartClickEvent, type ChartInstance, type ChartOptions, type CrosshairMoveEvent, type CrosshairOptions, type CrosshairPriceActionEvent, type DashPatternOptions, type GridOptions, type IndicatorInstanceOptions, type IndicatorPane, type IndicatorPlugin, type IndicatorRenderContext, type OhlcDataPoint, type OrderActionButton, type OrderActionEvent, type OrderLineOptions, type PriceLineOptions, type TickerLineOptions, type WatermarkOptions, createChart };
327
+ export { type AxisOptions, type BuiltInIndicatorInfo, type ChartClickEvent, type ChartInstance, type ChartOptions, type CrosshairMoveEvent, type CrosshairOptions, type CrosshairPriceActionEvent, type DashPatternOptions, type GridOptions, type IndicatorInstanceOptions, type IndicatorPane, type IndicatorPlugin, type IndicatorRenderContext, type OhlcDataPoint, type OrderActionButton, type OrderActionEvent, type OrderLineOptions, type PriceLineOptions, type TickerLineOptions, type ViewportState, type WatermarkOptions, createChart };
package/dist/index.d.ts CHANGED
@@ -259,6 +259,8 @@ interface TickerLineOptions {
259
259
  labelBackgroundColor?: string;
260
260
  labelTextColor?: string;
261
261
  labelBorderRadius?: number;
262
+ smoothing?: boolean;
263
+ smoothingSpeed?: number;
262
264
  }
263
265
  interface ChartInstance {
264
266
  setData: (data: OhlcDataPoint[]) => void;
@@ -284,6 +286,9 @@ interface ChartInstance {
284
286
  isFollowingLatest: () => boolean;
285
287
  setFollowingLatest: (follow: boolean) => void;
286
288
  onFollowingLatestChange: (handler: ((following: boolean) => void) | null) => void;
289
+ getViewport: () => ViewportState;
290
+ setViewport: (viewport: Partial<ViewportState>) => void;
291
+ onViewportChange: (handler: ((viewport: ViewportState) => void) | null) => void;
287
292
  setDoubleClickEnabled: (enabled: boolean) => void;
288
293
  setDoubleClickAction: (action: "reset" | "placeLimitOrder") => void;
289
294
  registerIndicator: (plugin: IndicatorPlugin<any>) => void;
@@ -305,6 +310,18 @@ interface OhlcDataPoint {
305
310
  c: number;
306
311
  v?: number;
307
312
  }
313
+ interface ViewportState {
314
+ /** Number of bars visible horizontally. */
315
+ xSpan: number;
316
+ /** Whether the chart is auto-following the latest candle. */
317
+ followingLatest: boolean;
318
+ /** Timestamp (ms) at the horizontal center of the viewport. null when data is empty. */
319
+ centerTimeMs: number | null;
320
+ /** Manual Y-axis min override (null = auto-scale). */
321
+ yMin: number | null;
322
+ /** Manual Y-axis max override (null = auto-scale). */
323
+ yMax: number | null;
324
+ }
308
325
  declare function createChart(element: HTMLElement, options?: ChartOptions): ChartInstance;
309
326
 
310
- export { type AxisOptions, type BuiltInIndicatorInfo, type ChartClickEvent, type ChartInstance, type ChartOptions, type CrosshairMoveEvent, type CrosshairOptions, type CrosshairPriceActionEvent, type DashPatternOptions, type GridOptions, type IndicatorInstanceOptions, type IndicatorPane, type IndicatorPlugin, type IndicatorRenderContext, type OhlcDataPoint, type OrderActionButton, type OrderActionEvent, type OrderLineOptions, type PriceLineOptions, type TickerLineOptions, type WatermarkOptions, createChart };
327
+ export { type AxisOptions, type BuiltInIndicatorInfo, type ChartClickEvent, type ChartInstance, type ChartOptions, type CrosshairMoveEvent, type CrosshairOptions, type CrosshairPriceActionEvent, type DashPatternOptions, type GridOptions, type IndicatorInstanceOptions, type IndicatorPane, type IndicatorPlugin, type IndicatorRenderContext, type OhlcDataPoint, type OrderActionButton, type OrderActionEvent, type OrderLineOptions, type PriceLineOptions, type TickerLineOptions, type ViewportState, type WatermarkOptions, createChart };
package/dist/index.js CHANGED
@@ -797,10 +797,21 @@ function createChart(element, options = {}) {
797
797
  let xSpan = 60;
798
798
  let followLatest = true;
799
799
  let followingLatestChangeHandler = null;
800
+ let viewportChangeHandler = null;
801
+ let viewportEmitScheduled = false;
802
+ const emitViewportChange = () => {
803
+ if (!viewportChangeHandler || viewportEmitScheduled) return;
804
+ viewportEmitScheduled = true;
805
+ queueMicrotask(() => {
806
+ viewportEmitScheduled = false;
807
+ viewportChangeHandler?.(getViewport());
808
+ });
809
+ };
800
810
  const updateFollowLatest = (next) => {
801
811
  if (followLatest === next) return;
802
812
  followLatest = next;
803
813
  followingLatestChangeHandler?.(next);
814
+ emitViewportChange();
804
815
  };
805
816
  let yMinOverride = null;
806
817
  let yMaxOverride = null;
@@ -817,6 +828,41 @@ function createChart(element, options = {}) {
817
828
  let crosshairPoint = null;
818
829
  let doubleClickEnabled = mergedOptions.doubleClickEnabled;
819
830
  let doubleClickAction = mergedOptions.doubleClickAction;
831
+ let smoothedTickerPrice = null;
832
+ let tickerPriceTarget = null;
833
+ let smoothingRafId = null;
834
+ const tickerSmoothingLoop = () => {
835
+ smoothingRafId = null;
836
+ if (smoothedTickerPrice === null || tickerPriceTarget === null) return;
837
+ const diff = tickerPriceTarget - smoothedTickerPrice;
838
+ if (Math.abs(diff) < 1e-9) {
839
+ smoothedTickerPrice = tickerPriceTarget;
840
+ draw();
841
+ return;
842
+ }
843
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
844
+ const speed = clamp(tickerOpts.smoothingSpeed ?? 8, 1, 60);
845
+ const dt = 1 / 60;
846
+ const lerp = 1 - Math.exp(-speed * dt);
847
+ smoothedTickerPrice += diff * lerp;
848
+ draw();
849
+ smoothingRafId = requestAnimationFrame(tickerSmoothingLoop);
850
+ };
851
+ const pushSmoothedPrice = (target) => {
852
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
853
+ if (!tickerOpts.smoothing) {
854
+ smoothedTickerPrice = null;
855
+ tickerPriceTarget = null;
856
+ return;
857
+ }
858
+ tickerPriceTarget = target;
859
+ if (smoothedTickerPrice === null) {
860
+ smoothedTickerPrice = target;
861
+ }
862
+ if (smoothingRafId === null) {
863
+ smoothingRafId = requestAnimationFrame(tickerSmoothingLoop);
864
+ }
865
+ };
820
866
  const canvas = document.createElement("canvas");
821
867
  const ctx = canvas.getContext("2d");
822
868
  if (!ctx) {
@@ -1777,28 +1823,37 @@ function createChart(element, options = {}) {
1777
1823
  }
1778
1824
  ctx.restore();
1779
1825
  }
1826
+ const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1827
+ const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
1828
+ const lastDataIndex = data.length - 1;
1780
1829
  for (let index = startIndex; index <= endIndex; index += 1) {
1781
1830
  const point = data[index];
1782
1831
  if (!point) {
1783
1832
  continue;
1784
1833
  }
1834
+ const isLastCandle = useSmoothedCandle && index === lastDataIndex;
1835
+ const displayClose = isLastCandle ? smoothedTickerPrice : point.c;
1836
+ const displayHigh = isLastCandle ? Math.max(point.h, smoothedTickerPrice) : point.h;
1837
+ const displayLow = isLastCandle ? Math.min(point.l, smoothedTickerPrice) : point.l;
1785
1838
  const centerX = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
1786
1839
  const openY = yFromPrice(point.o);
1787
- const closeY = yFromPrice(point.c);
1788
- const highY = yFromPrice(point.h);
1789
- const lowY = yFromPrice(point.l);
1790
- const direction = getCandleDirectionByIndex(index);
1840
+ const closeY = yFromPrice(displayClose);
1841
+ const highY = yFromPrice(displayHigh);
1842
+ const lowY = yFromPrice(displayLow);
1843
+ const direction = isLastCandle ? displayClose >= point.o ? "up" : "down" : getCandleDirectionByIndex(index);
1791
1844
  const candleColor = direction === "up" ? mergedOptions.upColor : mergedOptions.downColor;
1845
+ const roundedCenterX = Math.round(centerX);
1792
1846
  ctx.strokeStyle = candleColor;
1793
1847
  ctx.lineWidth = candleWickWidth;
1794
1848
  ctx.beginPath();
1795
- ctx.moveTo(crisp(centerX), crisp(highY));
1796
- ctx.lineTo(crisp(centerX), crisp(lowY));
1849
+ ctx.moveTo(roundedCenterX + 0.5, crisp(highY));
1850
+ ctx.lineTo(roundedCenterX + 0.5, crisp(lowY));
1797
1851
  ctx.stroke();
1852
+ const bodyLeft = roundedCenterX - Math.floor(bodyWidth / 2);
1798
1853
  const bodyTop = Math.min(openY, closeY);
1799
1854
  const bodyHeight = Math.max(1, Math.abs(closeY - openY));
1800
1855
  ctx.fillStyle = candleColor;
1801
- ctx.fillRect(Math.round(centerX - bodyWidth / 2), Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1856
+ ctx.fillRect(bodyLeft, Math.round(bodyTop), bodyWidth, Math.max(1, Math.round(bodyHeight)));
1802
1857
  }
1803
1858
  const activeOverlayIndicators = indicators.filter((indicator) => indicator.visible).map((indicator) => ({ indicator, plugin: indicatorRegistry.get(indicator.type) })).filter(
1804
1859
  (value) => value.plugin !== void 0 && (value.indicator.pane ?? value.plugin.pane ?? "overlay") === "overlay"
@@ -1932,10 +1987,10 @@ function createChart(element, options = {}) {
1932
1987
  const ticker = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1933
1988
  const lastPoint = data[data.length - 1];
1934
1989
  if ((ticker.visible ?? true) && lastPoint) {
1935
- const tickerPrice = lastPoint.c;
1990
+ const tickerPrice = ticker.smoothing && smoothedTickerPrice !== null ? smoothedTickerPrice : lastPoint.c;
1936
1991
  const tickerY = yFromPrice(tickerPrice);
1937
1992
  const lineY = clamp(tickerY, chartTop + 1, chartBottom - 1);
1938
- const lastDirection = getCandleDirectionByIndex(data.length - 1);
1993
+ const lastDirection = ticker.smoothing && smoothedTickerPrice !== null ? smoothedTickerPrice >= lastPoint.o ? "up" : "down" : getCandleDirectionByIndex(data.length - 1);
1939
1994
  const tickerColor = ticker.color ?? (lastDirection === "up" ? mergedOptions.upColor : mergedOptions.downColor);
1940
1995
  const tickerThickness = Math.max(1, ticker.thickness ?? 1);
1941
1996
  const tickerStyle = ticker.style ?? "solid";
@@ -2134,6 +2189,7 @@ function createChart(element, options = {}) {
2134
2189
  xCenter = nextStart + nextSpan / 2;
2135
2190
  clampXViewport();
2136
2191
  updateFollowLatest(false);
2192
+ emitViewportChange();
2137
2193
  draw();
2138
2194
  };
2139
2195
  const zoomXToLatest = (factor) => {
@@ -2148,6 +2204,7 @@ function createChart(element, options = {}) {
2148
2204
  xSpan = nextSpan;
2149
2205
  xCenter = nextStart + nextSpan / 2;
2150
2206
  clampXViewport();
2207
+ emitViewportChange();
2151
2208
  draw();
2152
2209
  };
2153
2210
  const zoomY = (factor, anchorY) => {
@@ -2168,6 +2225,7 @@ function createChart(element, options = {}) {
2168
2225
  const clamped = clampYRange(nextMin, nextMax);
2169
2226
  yMinOverride = clamped.min;
2170
2227
  yMaxOverride = clamped.max;
2228
+ emitViewportChange();
2171
2229
  draw();
2172
2230
  };
2173
2231
  const pan = (deltaX, deltaY, allowX, allowY) => {
@@ -2191,6 +2249,9 @@ function createChart(element, options = {}) {
2191
2249
  yMinOverride = clamped.min;
2192
2250
  yMaxOverride = clamped.max;
2193
2251
  }
2252
+ if (allowX || allowY) {
2253
+ emitViewportChange();
2254
+ }
2194
2255
  draw();
2195
2256
  };
2196
2257
  const resetYViewport = () => {
@@ -2230,6 +2291,7 @@ function createChart(element, options = {}) {
2230
2291
  xCenter += bars;
2231
2292
  clampXViewport();
2232
2293
  updateFollowLatest(false);
2294
+ emitViewportChange();
2233
2295
  draw();
2234
2296
  };
2235
2297
  const panY = (priceDelta) => {
@@ -2241,17 +2303,20 @@ function createChart(element, options = {}) {
2241
2303
  const clamped = clampYRange(currentMin + priceDelta, currentMax + priceDelta);
2242
2304
  yMinOverride = clamped.min;
2243
2305
  yMaxOverride = clamped.max;
2306
+ emitViewportChange();
2244
2307
  draw();
2245
2308
  };
2246
2309
  const fitContent = () => {
2247
2310
  fitXViewport();
2248
2311
  updateFollowLatest(true);
2312
+ emitViewportChange();
2249
2313
  draw();
2250
2314
  };
2251
2315
  const resetViewport = () => {
2252
2316
  fitXViewport();
2253
2317
  resetYViewport();
2254
2318
  updateFollowLatest(true);
2319
+ emitViewportChange();
2255
2320
  draw();
2256
2321
  };
2257
2322
  const isFollowingLatest = () => followLatest;
@@ -2268,6 +2333,64 @@ function createChart(element, options = {}) {
2268
2333
  const onFollowingLatestChange = (handler) => {
2269
2334
  followingLatestChangeHandler = handler;
2270
2335
  };
2336
+ const getViewport = () => {
2337
+ let centerTimeMs = null;
2338
+ if (data.length > 0) {
2339
+ const centerRounded = Math.round(xCenter);
2340
+ if (centerRounded >= 0 && centerRounded < data.length) {
2341
+ centerTimeMs = data[centerRounded]?.time.getTime() ?? null;
2342
+ }
2343
+ }
2344
+ return {
2345
+ xSpan,
2346
+ followingLatest: followLatest,
2347
+ centerTimeMs,
2348
+ yMin: yMinOverride,
2349
+ yMax: yMaxOverride
2350
+ };
2351
+ };
2352
+ const setViewport = (viewport) => {
2353
+ let changed = false;
2354
+ if (typeof viewport.xSpan === "number" && Number.isFinite(viewport.xSpan)) {
2355
+ const minSpan = minVisibleBars;
2356
+ const maxSpan = Math.min(maxVisibleBars, Math.max(minSpan, data.length + maxPanBars * 2));
2357
+ const nextSpan = clamp(viewport.xSpan, minSpan, maxSpan);
2358
+ if (nextSpan !== xSpan) {
2359
+ xSpan = nextSpan;
2360
+ changed = true;
2361
+ }
2362
+ }
2363
+ if (typeof viewport.centerTimeMs === "number" && data.length > 0) {
2364
+ const nextCenter = findNearestIndexForTimeMs(viewport.centerTimeMs);
2365
+ if (nextCenter !== null) {
2366
+ xCenter = nextCenter;
2367
+ changed = true;
2368
+ }
2369
+ }
2370
+ if (viewport.yMin !== void 0) {
2371
+ yMinOverride = viewport.yMin;
2372
+ changed = true;
2373
+ }
2374
+ if (viewport.yMax !== void 0) {
2375
+ yMaxOverride = viewport.yMax;
2376
+ changed = true;
2377
+ }
2378
+ if (typeof viewport.followingLatest === "boolean") {
2379
+ if (followLatest !== viewport.followingLatest) {
2380
+ followLatest = viewport.followingLatest;
2381
+ followingLatestChangeHandler?.(followLatest);
2382
+ changed = true;
2383
+ }
2384
+ }
2385
+ if (changed) {
2386
+ clampXViewport();
2387
+ draw();
2388
+ emitViewportChange();
2389
+ }
2390
+ };
2391
+ const onViewportChange = (handler) => {
2392
+ viewportChangeHandler = handler;
2393
+ };
2271
2394
  const getCanvasPoint = (event) => {
2272
2395
  const rect = canvas.getBoundingClientRect();
2273
2396
  return {
@@ -2699,6 +2822,10 @@ function createChart(element, options = {}) {
2699
2822
  fitXViewport();
2700
2823
  }
2701
2824
  }
2825
+ const lastClose = data.length > 0 ? data[data.length - 1].c : null;
2826
+ if (lastClose !== null) {
2827
+ pushSmoothedPrice(lastClose);
2828
+ }
2702
2829
  draw();
2703
2830
  };
2704
2831
  const setPriceLines = (lines) => {
@@ -2849,6 +2976,10 @@ function createChart(element, options = {}) {
2849
2976
  draw();
2850
2977
  };
2851
2978
  const destroy = () => {
2979
+ if (smoothingRafId !== null) {
2980
+ cancelAnimationFrame(smoothingRafId);
2981
+ smoothingRafId = null;
2982
+ }
2852
2983
  canvas.removeEventListener("pointerdown", onPointerDown);
2853
2984
  canvas.removeEventListener("pointermove", onPointerMove);
2854
2985
  canvas.removeEventListener("pointerup", endPointerDrag);
@@ -2883,6 +3014,9 @@ function createChart(element, options = {}) {
2883
3014
  isFollowingLatest,
2884
3015
  setFollowingLatest,
2885
3016
  onFollowingLatestChange,
3017
+ getViewport,
3018
+ setViewport,
3019
+ onViewportChange,
2886
3020
  setDoubleClickEnabled,
2887
3021
  setDoubleClickAction,
2888
3022
  registerIndicator,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperprop-charting-library",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Lightweight TypeScript charting core",
5
5
  "type": "module",
6
6
  "main": "./dist/hyperprop-charting-library.cjs",