acttrader-charts 1.4.0-beta.3 → 1.4.0-beta.5

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/README.md CHANGED
@@ -1203,7 +1203,7 @@ Sub-pane heights are user-resizable by dragging the separator between panes.
1203
1203
  | `horizontalLine` | Horizontal Line | Single click |
1204
1204
  | `verticalLine` | Vertical Line | Single click |
1205
1205
  | `trendLine` | Trend Line | 2 clicks |
1206
- | `trendAngle` | Trend Angle | 2 clicks. The angle is the drawing's own property: measured when a point is placed or dragged, kept while zooming, and saved as `angle` on the serialized drawing — so a synced drawing shows the same degrees on web, Android and iOS. |
1206
+ | `trendAngle` | Trend Angle | 2 clicks. The angle is the drawing's own property: measured when a point is placed or dragged, kept while zooming, and saved as `angle` on the serialized drawing — so a synced drawing shows the same degrees on web, Android and iOS. The line is drawn from the first point at that angle, as far as the second point projects; the stored points are never rewritten by rendering (before 1.4.0-beta.4 the second point was re-anchored onto the line on every render, which lengthened the line on every zoom or tick until it ran off the pane). |
1207
1207
  | `crossLine` | Cross Line | Single click (H+V) |
1208
1208
  | `parallelChannel` | Parallel Channel | 3 clicks |
1209
1209
  | `flatChannel` | Flat Channel | 2 clicks |
@@ -2266,6 +2266,22 @@ Two details worth knowing:
2266
2266
  in hand. It deletes via select-then-delete, so it shares the undo history with
2267
2267
  every other delete.
2268
2268
 
2269
+ ### Touch gestures (iOS / Android)
2270
+
2271
+ | Gesture | Effect |
2272
+ | --- | --- |
2273
+ | One-finger drag on the chart | Scrolls through time (with momentum) |
2274
+ | Pinch | Zooms the time axis |
2275
+ | One-finger drag along the time (x) axis | Zooms the time axis: drag right to show more bars, left to show fewer |
2276
+ | One-finger drag on the price (y) axis | Zooms the price axis |
2277
+ | One-finger **double tap** on the chart or price axis | Zooms the price axis in one step around the tapped price |
2278
+ | One-finger **double tap, then drag** up/down | Zooms the price axis continuously: up zooms in, down zooms out (the same direction as the price-axis drag) |
2279
+ | Long press | Shows the crosshair |
2280
+
2281
+ Once the view has moved away from its default, a round **Reset view** button
2282
+ appears just above the time axis. It is shown even when `showUI: false`, so a
2283
+ bare chart with no toolbar always has a way back. No configuration is needed.
2284
+
2269
2285
  ### Value tooltip on long press
2270
2286
 
2271
2287
  ```ts
@@ -2337,6 +2353,13 @@ Three consequences worth knowing:
2337
2353
  different number of elements.
2338
2354
  - **Market price still comes off the raw feed.** Trade levels and P&L price
2339
2355
  against the real last close, never a brick top.
2356
+ - **Live ticks move the element count both ways.** The forming bar completes a
2357
+ brick, line or column on one tick and takes it back on the next. The view
2358
+ keeps tracking the live edge either way — same width, same room past the
2359
+ last element — and a view scrolled into history stays put. Box and reversal
2360
+ sizes are measured over **closed** bars, so a tick never re-grids the series;
2361
+ an ATR box changes once per completed bar. (Before 1.4.0-beta.4 the view only
2362
+ ever moved forward, so these types went blank after a while on a live feed.)
2340
2363
 
2341
2364
  ### Parameters
2342
2365
 
@@ -2362,12 +2385,12 @@ Renko leaves Kagi and P&F alone.
2362
2385
 
2363
2386
  | `BoxSizeMode` | Meaning |
2364
2387
  | --- | --- |
2365
- | `{ kind: 'atr', length }` | Wilder ATR over the last `length` bars. Default `14`. |
2388
+ | `{ kind: 'atr', length }` | Wilder ATR over the last `length` closed bars (the forming bar is excluded). Default `14`. |
2366
2389
  | `{ kind: 'fixed', size }` | A fixed price distance. |
2367
- | `{ kind: 'percent', percent }` | A percentage of the latest close. |
2390
+ | `{ kind: 'percent', percent }` | A percentage of the last closed bar's close. |
2368
2391
 
2369
- When a box cannot be sized — fewer than `length + 1` bars for the ATR, or a
2370
- non-positive size — the transform returns **no elements** rather than guessing. A
2392
+ When a box cannot be sized — fewer than `length + 1` closed bars for the ATR, or
2393
+ a non-positive size — the transform returns **no elements** rather than guessing. A
2371
2394
  wrong box size does not look wrong; it looks like a different market.
2372
2395
 
2373
2396
  ### The rules each type implements
package/dist/index.cjs CHANGED
@@ -159,6 +159,28 @@ var DataStore = class {
159
159
  }
160
160
  };
161
161
 
162
+ // src/core/viewportSync.ts
163
+ function maxViewportStart(total, width) {
164
+ return Math.max(0, total - Math.floor(width / 2));
165
+ }
166
+ function syncViewportToElementCount(viewport, before, after) {
167
+ if (after === before || after <= 0) return viewport;
168
+ const width = viewport.endIndex - viewport.startIndex;
169
+ if (viewport.endIndex >= before) {
170
+ const slack = viewport.endIndex - before;
171
+ const endIndex = after + slack;
172
+ return { ...viewport, startIndex: endIndex - width, endIndex };
173
+ }
174
+ return clampViewportToData(viewport, after);
175
+ }
176
+ function clampViewportToData(viewport, total) {
177
+ if (total <= 0) return viewport;
178
+ const width = viewport.endIndex - viewport.startIndex;
179
+ const maxStart = maxViewportStart(total, width);
180
+ if (viewport.startIndex <= maxStart) return viewport;
181
+ return { ...viewport, startIndex: maxStart, endIndex: maxStart + width };
182
+ }
183
+
162
184
  // src/core/ScaleManager.ts
163
185
  var _ScaleManager = class _ScaleManager {
164
186
  // ── X axis: bar index → canvas pixel ──────────────────────────────────────
@@ -15725,41 +15747,54 @@ var TrendAngle = class {
15725
15747
  onPointsEdited() {
15726
15748
  this.pendingMeasure = true;
15727
15749
  }
15750
+ /** Whether the angle is (re)measured from the points rather than applied to them. */
15751
+ get measuring() {
15752
+ return this.angle === void 0 || this.pendingMeasure || !this.complete;
15753
+ }
15728
15754
  /**
15729
- * Applies the angle rule and returns the pixel geometry to draw.
15755
+ * The pixel geometry of the line on the given scales — a pure projection
15756
+ * of the stored points, with no side effects, shared by rendering and
15757
+ * hit-testing so a tap lands on what was drawn.
15730
15758
  *
15731
15759
  * - no stored angle, the drawing is still being placed, or the user has
15732
- * just moved a point: measure the angle from the current pixels;
15733
- * - otherwise the points only moved because the scale or the data did:
15734
- * keep the angle and move the second point onto the line, so
15735
- * hit-testing, handles and persistence agree with what is drawn. Its
15736
- * length is whatever the stored point projects to on this screen.
15760
+ * just moved a point: the line joins the points as they are, and
15761
+ * `measured` is the angle that run makes on this screen;
15762
+ * - otherwise the line leaves the first point at the stored angle and
15763
+ * runs as far as the second point projects on this screen.
15737
15764
  */
15738
- resolve(scale, viewport, priceRange, chartW, chartH) {
15765
+ geometry(scale, viewport, priceRange, chartW, chartH) {
15739
15766
  const [p1, p2] = this.points;
15740
15767
  const x1 = scale.xToPixel(p1.barIndex, viewport, chartW);
15741
15768
  const y1 = scale.yToPixel(p1.price, priceRange, chartH);
15742
15769
  const px2 = scale.xToPixel(p2.barIndex, viewport, chartW);
15743
15770
  const py2 = scale.yToPixel(p2.price, priceRange, chartH);
15744
15771
  const measured = Math.atan2(-(py2 - y1), px2 - x1) * 180 / Math.PI;
15745
- if (this.angle === void 0 || this.pendingMeasure || !this.complete) {
15746
- if (Number.isFinite(measured) && chartW > 0 && chartH > 0) {
15747
- this.angle = measured;
15748
- this.pendingMeasure = false;
15749
- }
15750
- return { x1, y1, x2: px2, y2: py2, angleDeg: this.angle ?? measured };
15772
+ if (this.measuring || this.angle === void 0) {
15773
+ return { x1, y1, x2: px2, y2: py2, angleDeg: this.angle ?? measured, measured };
15751
15774
  }
15752
15775
  const len = Math.hypot(px2 - x1, py2 - y1);
15753
15776
  const rad = this.angle * Math.PI / 180;
15754
15777
  const x22 = x1 + len * Math.cos(rad);
15755
15778
  const y22 = y1 - len * Math.sin(rad);
15756
- if (Number.isFinite(x22) && Number.isFinite(y22) && (Math.abs(x22 - px2) > 0.01 || Math.abs(y22 - py2) > 0.01)) {
15757
- this.points[1] = {
15758
- barIndex: scale.pixelToBarIndex(x22, viewport, chartW),
15759
- price: scale.pixelToPrice(y22, priceRange, chartH)
15760
- };
15779
+ if (!Number.isFinite(x22) || !Number.isFinite(y22)) {
15780
+ return { x1, y1, x2: px2, y2: py2, angleDeg: this.angle, measured };
15781
+ }
15782
+ return { x1, y1, x2: x22, y2: y22, angleDeg: this.angle, measured };
15783
+ }
15784
+ /**
15785
+ * The geometry to draw, recording the angle when it is due to be measured.
15786
+ * The stored points are left exactly as the user placed them.
15787
+ */
15788
+ resolve(scale, viewport, priceRange, chartW, chartH) {
15789
+ const g = this.geometry(scale, viewport, priceRange, chartW, chartH);
15790
+ if (this.measuring) {
15791
+ if (Number.isFinite(g.measured) && chartW > 0 && chartH > 0) {
15792
+ this.angle = g.measured;
15793
+ this.pendingMeasure = false;
15794
+ }
15795
+ return { x1: g.x1, y1: g.y1, x2: g.x2, y2: g.y2, angleDeg: this.angle ?? g.measured };
15761
15796
  }
15762
- return { x1, y1, x2: x22, y2: y22, angleDeg: this.angle };
15797
+ return g;
15763
15798
  }
15764
15799
  render(ctx, scale, viewport, priceRange, chartW, chartH, isSelected, _pricePrecision, theme) {
15765
15800
  if (this.points.length < 2) return;
@@ -15803,18 +15838,15 @@ var TrendAngle = class {
15803
15838
  }
15804
15839
  hitTest(x, y, scale, viewport, priceRange, chartW, chartH) {
15805
15840
  if (this.points.length < 2) return false;
15806
- const [p1, p2] = this.points;
15807
- const x1 = scale.xToPixel(p1.barIndex, viewport, chartW);
15808
- const y1 = scale.yToPixel(p1.price, priceRange, chartH);
15809
- const x22 = scale.xToPixel(p2.barIndex, viewport, chartW);
15810
- const y22 = scale.yToPixel(p2.price, priceRange, chartH);
15841
+ const { x1, y1, x2: x22, y2: y22 } = this.geometry(scale, viewport, priceRange, chartW, chartH);
15811
15842
  return pointToSegmentDist3(x, y, x1, y1, x22, y22) <= HIT_TOLERANCE24;
15812
15843
  }
15813
15844
  getHandle(x, y, scale, viewport, priceRange, chartW, chartH) {
15814
15845
  if (this.points.length < 2) return null;
15846
+ const { x1, y1, x2: x22, y2: y22 } = this.geometry(scale, viewport, priceRange, chartW, chartH);
15815
15847
  const pts = [
15816
- { px: scale.xToPixel(this.points[0].barIndex, viewport, chartW), py: scale.yToPixel(this.points[0].price, priceRange, chartH) },
15817
- { px: scale.xToPixel(this.points[1].barIndex, viewport, chartW), py: scale.yToPixel(this.points[1].price, priceRange, chartH) }
15848
+ { px: x1, py: y1 },
15849
+ { px: x22, py: y22 }
15818
15850
  ];
15819
15851
  for (let i = 0; i < pts.length; i++) {
15820
15852
  if (Math.hypot(x - pts[i].px, y - pts[i].py) <= HIT_TOLERANCE24 + 2) {
@@ -27448,15 +27480,19 @@ function resolveBoxSize(bars, mode) {
27448
27480
  case "fixed":
27449
27481
  return mode.size > 0 ? mode.size : null;
27450
27482
  case "percent": {
27451
- const last = bars[bars.length - 1];
27483
+ const closed = closedBars(bars);
27484
+ const last = closed[closed.length - 1];
27452
27485
  if (!last || mode.percent <= 0) return null;
27453
27486
  const size = last.close * mode.percent / 100;
27454
27487
  return size > 0 ? size : null;
27455
27488
  }
27456
27489
  case "atr":
27457
- return atrValue(bars, mode.length);
27490
+ return atrValue(closedBars(bars), mode.length);
27458
27491
  }
27459
27492
  }
27493
+ function closedBars(bars) {
27494
+ return bars.length > 1 ? bars.slice(0, -1) : bars;
27495
+ }
27460
27496
  function enforceIncreasingTimes(bars) {
27461
27497
  for (let i = 1; i < bars.length; i++) {
27462
27498
  const prev = bars[i - 1];
@@ -37044,6 +37080,11 @@ function initialVisibleBars(mobileBarDivisor = 2, forceApply = false, defaultBar
37044
37080
  }
37045
37081
  var LOOKAHEAD = 4;
37046
37082
  var LONG_PRESS_CANCEL_ARM_MS = 300;
37083
+ var DOUBLE_TAP_MS = 300;
37084
+ var DOUBLE_TAP_SLOP_PX = 30;
37085
+ var TAP_MAX_MS = 250;
37086
+ var TAP_MAX_MOVE_PX = 10;
37087
+ var DOUBLE_TAP_ZOOM_STEP = 0.7;
37047
37088
  function lastIndexAtOrBefore(bars, time) {
37048
37089
  let lo = 0, hi = bars.length - 1, ans = -1;
37049
37090
  while (lo <= hi) {
@@ -37346,6 +37387,16 @@ var _ChartEngine = class _ChartEngine {
37346
37387
  */
37347
37388
  this._viewportPersistTimer = null;
37348
37389
  this.axisDrag = null;
37390
+ /**
37391
+ * One-finger double-tap zoom on the price scale (the TradingView app's
37392
+ * gesture). Second tap down arms it; dragging up/down zooms Y continuously,
37393
+ * and lifting without a drag steps one zoom-in around the tapped price.
37394
+ */
37395
+ this.tapZoom = null;
37396
+ /** Where and when the last quick one-finger tap ended — the first half of a double tap. */
37397
+ this._lastTap = null;
37398
+ /** Down position/time of the current one-finger touch, to tell a tap from a drag. */
37399
+ this._touchDown = null;
37349
37400
  /** Per-instance sub-pane Y-scale factors, keyed by ActiveIndicatorState.instanceId. */
37350
37401
  this.subPaneScaleFactors = /* @__PURE__ */ new Map();
37351
37402
  this._mobileCrosshairActive = false;
@@ -37963,6 +38014,16 @@ var _ChartEngine = class _ChartEngine {
37963
38014
  const y = touch.clientY - rect.top;
37964
38015
  const { chartW, priceH } = this.getChartLayout();
37965
38016
  const priceRange = this.getScaledPriceRange(this.viewport);
38017
+ const now = performance.now();
38018
+ const lastTap = this._lastTap;
38019
+ this._lastTap = null;
38020
+ this._touchDown = null;
38021
+ if (lastTap && now - lastTap.at < DOUBLE_TAP_MS && Math.hypot(x - lastTap.x, y - lastTap.y) < DOUBLE_TAP_SLOP_PX && y < priceH && !this._mobileCrosshairActive) {
38022
+ e.preventDefault();
38023
+ this.panHandler.cancel();
38024
+ this.startTapZoom(y, priceH);
38025
+ return;
38026
+ }
37966
38027
  const axisTapHit = hitTestAxisTagCluster(x, y, this.axisTagClusterAreas);
37967
38028
  if (axisTapHit) {
37968
38029
  e.preventDefault();
@@ -37979,6 +38040,7 @@ var _ChartEngine = class _ChartEngine {
37979
38040
  e.preventDefault();
37980
38041
  if (y <= priceH) {
37981
38042
  this.axisDrag = { axis: "price", startPos: y, startSize: this.priceScaleFactor };
38043
+ this._touchDown = { x, y, at: now };
37982
38044
  } else {
37983
38045
  const subPaneStates = this.activeIndicators.filter(
37984
38046
  (s) => s.indicator.pane === "sub"
@@ -38161,6 +38223,7 @@ var _ChartEngine = class _ChartEngine {
38161
38223
  }
38162
38224
  }
38163
38225
  }
38226
+ this._touchDown = { x, y, at: now };
38164
38227
  this.drawingManager.selectDrawing(null);
38165
38228
  this.hideMobileDrawingFlyout();
38166
38229
  if (this.expandedClusters.size > 0) {
@@ -38198,6 +38261,11 @@ var _ChartEngine = class _ChartEngine {
38198
38261
  const point = this.touchToDrawingPoint(e.touches[0]);
38199
38262
  const tp = this.touchToPixels(e.touches[0]);
38200
38263
  this._lastDrawingTouchPoint = point;
38264
+ if (this.tapZoom) {
38265
+ e.preventDefault();
38266
+ this.moveTapZoom(tp.y);
38267
+ return;
38268
+ }
38201
38269
  if (this.axisDrag) {
38202
38270
  e.preventDefault();
38203
38271
  const touch = e.touches[0];
@@ -38230,6 +38298,7 @@ var _ChartEngine = class _ChartEngine {
38230
38298
  startIndex: newStart,
38231
38299
  endIndex: newStart + newVisible
38232
38300
  };
38301
+ this._targetViewport = { ...this.viewport };
38233
38302
  }
38234
38303
  this.scheduleRender();
38235
38304
  return;
@@ -38373,6 +38442,9 @@ var _ChartEngine = class _ChartEngine {
38373
38442
  * back to its start; an in-progress price drag is also cancelled defensively.
38374
38443
  */
38375
38444
  this.onDrawingTouchCancel = (_e) => {
38445
+ this._touchDown = null;
38446
+ this._lastTap = null;
38447
+ if (this.tapZoom) this.endTapZoom();
38376
38448
  if (this._longPressTimer !== null) {
38377
38449
  clearTimeout(this._longPressTimer);
38378
38450
  this._longPressTimer = null;
@@ -38406,6 +38478,22 @@ var _ChartEngine = class _ChartEngine {
38406
38478
  this._longPressTimer = null;
38407
38479
  this._longPressPoint = null;
38408
38480
  }
38481
+ if (this.tapZoom) {
38482
+ this.endTapZoom();
38483
+ return;
38484
+ }
38485
+ const down = this._touchDown;
38486
+ this._touchDown = null;
38487
+ const lifted = _e.changedTouches[0];
38488
+ if (down && lifted && _e.touches.length === 0) {
38489
+ const rect = this._cachedDrawRect ?? this.drawCanvas.getBoundingClientRect();
38490
+ const ux = lifted.clientX - rect.left;
38491
+ const uy = lifted.clientY - rect.top;
38492
+ const now = performance.now();
38493
+ if (now - down.at < TAP_MAX_MS && Math.hypot(ux - down.x, uy - down.y) < TAP_MAX_MOVE_PX) {
38494
+ this._lastTap = { x: down.x, y: down.y, at: now };
38495
+ }
38496
+ }
38409
38497
  if (this._pendingTradeDrag) {
38410
38498
  const { level } = this._pendingTradeDrag;
38411
38499
  this._pendingTradeDrag = null;
@@ -38805,7 +38893,7 @@ var _ChartEngine = class _ChartEngine {
38805
38893
  };
38806
38894
  all[all.length - 1] = updated;
38807
38895
  this.dataStore.invalidate();
38808
- this.followLiveEdge(before);
38896
+ this.syncViewportToData(before);
38809
38897
  } else {
38810
38898
  const completedBar = last;
38811
38899
  const newBarTime = Math.floor(tick.time / intervalMs) * intervalMs;
@@ -38824,7 +38912,7 @@ var _ChartEngine = class _ChartEngine {
38824
38912
  );
38825
38913
  }
38826
38914
  const openingBar = all[all.length - 1];
38827
- this.followLiveEdge(before);
38915
+ this.syncViewportToData(before);
38828
38916
  this.emitter.emit("newBar", { completedBar, openingBar, intervalMs });
38829
38917
  this.correctCompletedBar(completedBar.time, intervalMs);
38830
38918
  if (newBarTime > completedBar.time + intervalMs) {
@@ -39660,12 +39748,12 @@ var _ChartEngine = class _ChartEngine {
39660
39748
  this.applyScrollToEndBtnStyles();
39661
39749
  this.scrollToEndBtn.addEventListener("click", () => this.scrollToEnd());
39662
39750
  this.canvasWrap.appendChild(this.scrollToEndBtn);
39663
- this.resetViewBtn = document.createElement("button");
39664
- this.resetViewBtn.title = "Reset view";
39665
- this.applyResetViewBtnStyles();
39666
- this.resetViewBtn.addEventListener("click", () => this.resetView());
39667
- this.canvasWrap.appendChild(this.resetViewBtn);
39668
39751
  }
39752
+ this.resetViewBtn = document.createElement("button");
39753
+ this.resetViewBtn.title = "Reset view";
39754
+ this.applyResetViewBtnStyles();
39755
+ this.resetViewBtn.addEventListener("click", () => this.resetView());
39756
+ this.canvasWrap.appendChild(this.resetViewBtn);
39669
39757
  this.tradeDragHandler = new TradeDragHandler();
39670
39758
  this.orderTimeDragHandler = new OrderLineTimeDragHandler();
39671
39759
  if (config.onLevelDragEnd) {
@@ -39939,7 +40027,7 @@ var _ChartEngine = class _ChartEngine {
39939
40027
  },
39940
40028
  () => this.dataStore.length,
39941
40029
  () => this.scheduleRender(),
39942
- () => this.drawingManager.getActiveTool() !== null || this.drawingManager.isDraggingDrawing() || this.drawingManager.isResizingDrawing() || this.separatorDrag !== null || this.axisDrag !== null || (this.tradeDragHandler?.active ?? false) || (this.orderTimeDragHandler?.active ?? false) || this._pendingTradeDrag !== null || this._mobileCrosshairActive,
40030
+ () => this.drawingManager.getActiveTool() !== null || this.drawingManager.isDraggingDrawing() || this.drawingManager.isResizingDrawing() || this.separatorDrag !== null || this.axisDrag !== null || this.tapZoom !== null || (this.tradeDragHandler?.active ?? false) || (this.orderTimeDragHandler?.active ?? false) || this._pendingTradeDrag !== null || this._mobileCrosshairActive,
39943
40031
  (deltaY) => {
39944
40032
  if (this.priceScaleFactor === 1) return;
39945
40033
  const { priceH } = this.getChartLayout();
@@ -40224,6 +40312,12 @@ var _ChartEngine = class _ChartEngine {
40224
40312
  this.updateCompareRange();
40225
40313
  this.scheduleRender();
40226
40314
  }
40315
+ const clamped = clampViewportToData(this._targetViewport, this.dataStore.length);
40316
+ if (clamped !== this._targetViewport) {
40317
+ this._targetViewport = clamped;
40318
+ this.viewport = { ...clamped };
40319
+ this.scheduleRender();
40320
+ }
40227
40321
  this.isFetching = false;
40228
40322
  return this;
40229
40323
  }
@@ -40410,15 +40504,8 @@ var _ChartEngine = class _ChartEngine {
40410
40504
  const before = this.dataStore.length;
40411
40505
  this.dataStore.raw.push({ ...bar });
40412
40506
  this.dataStore.invalidate();
40413
- const grew = this.dataStore.length - before;
40414
- if (grew > 0 && this._targetViewport.endIndex >= before) {
40415
- this.viewport = {
40416
- ...this.viewport,
40417
- startIndex: this.viewport.startIndex + grew,
40418
- endIndex: this.viewport.endIndex + grew
40419
- };
40420
- this._targetViewport = { ...this.viewport };
40421
- }
40507
+ this.syncViewportToData(before);
40508
+ this.viewport = { ...this._targetViewport };
40422
40509
  r.time = bar.time;
40423
40510
  this.recomputeIndicators();
40424
40511
  this.updateReplayBar();
@@ -45136,6 +45223,10 @@ var _ChartEngine = class _ChartEngine {
45136
45223
  }
45137
45224
  render() {
45138
45225
  if (this._isDestroyed) return;
45226
+ {
45227
+ const clamped = clampViewportToData(this._targetViewport, this.dataStore.length);
45228
+ if (clamped !== this._targetViewport) this._targetViewport = clamped;
45229
+ }
45139
45230
  const { ctx, canvas, theme, viewport } = this;
45140
45231
  const dpr = window.devicePixelRatio || 1;
45141
45232
  const totalW = canvas.width;
@@ -45150,6 +45241,11 @@ var _ChartEngine = class _ChartEngine {
45150
45241
  const absBars = this.dataStore.getVisible(viewport);
45151
45242
  if (absBars.length === 0) {
45152
45243
  this.drawCtx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height);
45244
+ const target = this._targetViewport;
45245
+ if (!this._isPanning && (viewport.startIndex !== target.startIndex || viewport.endIndex !== target.endIndex)) {
45246
+ this.viewport = { ...target };
45247
+ this.scheduleRender();
45248
+ }
45153
45249
  return;
45154
45250
  }
45155
45251
  let bars = absBars;
@@ -46595,20 +46691,31 @@ var _ChartEngine = class _ChartEngine {
46595
46691
  }, this.tickActivityMs);
46596
46692
  }
46597
46693
  /**
46598
- * Shifts the target viewport by however many displayed elements the last
46599
- * raw-bar mutation added, when the view was sitting at the live edge.
46600
- * `before` is `dataStore.length` captured before the mutation. Without a
46601
- * transform this is the old "+1 on a new bar" rule; with one it is the
46602
- * number of bricks / columns the tick completed.
46694
+ * Re-aligns the view after a raw-bar mutation changed the number of
46695
+ * displayed elements. `before` is `dataStore.length` captured before the
46696
+ * mutation. The rule lives in `core/viewportSync.ts`: a view tracking the
46697
+ * live edge keeps tracking it whether the count grew or shrank; a view
46698
+ * scrolled into history is only clamped so it cannot sit past the data.
46699
+ *
46700
+ * The old rule shifted the view forward by the growth and did nothing on
46701
+ * a shrink. A time-based series never shrinks, but under Renko / Line
46702
+ * Break / Kagi / P&F the forming bar completes an element on one tick and
46703
+ * takes it back on the next, so the view ratcheted ahead one element per
46704
+ * oscillation until it sat past the data — a blank chart "after a while".
46705
+ *
46706
+ * Under a price transform the elements are rebuilt rather than shifted,
46707
+ * so there is nothing for the per-frame lerp to animate between and the
46708
+ * view snaps. A time-based series keeps its one-bar slide.
46603
46709
  */
46604
- followLiveEdge(before) {
46605
- const grew = this.dataStore.length - before;
46606
- if (grew <= 0 || this._targetViewport.endIndex < before) return;
46607
- this._targetViewport = {
46608
- ...this._targetViewport,
46609
- startIndex: this._targetViewport.startIndex + grew,
46610
- endIndex: this._targetViewport.endIndex + grew
46611
- };
46710
+ syncViewportToData(before) {
46711
+ const synced = syncViewportToElementCount(
46712
+ this._targetViewport,
46713
+ before,
46714
+ this.dataStore.length
46715
+ );
46716
+ if (synced === this._targetViewport) return;
46717
+ this._targetViewport = synced;
46718
+ if (isPriceTransformSeries(this.series)) this.viewport = { ...synced };
46612
46719
  }
46613
46720
  /** Fetch bars that fall in a gap between the last historical bar and the first live bar.
46614
46721
  * This happens when the system clock was behind feed time during the initial data load. */
@@ -46629,12 +46736,14 @@ var _ChartEngine = class _ChartEngine {
46629
46736
  const liveBar = all[all.length - 1];
46630
46737
  const before = this.dataStore.length;
46631
46738
  let insertCount = 0;
46739
+ let replaceCount = 0;
46632
46740
  for (const bar of bars) {
46633
46741
  if (bar.time >= liveBar.time) continue;
46634
46742
  const existingIdx = all.findIndex((b) => b.time === bar.time);
46635
46743
  if (existingIdx !== -1) {
46636
46744
  all[existingIdx] = bar;
46637
46745
  this.dataStore.invalidate();
46746
+ replaceCount++;
46638
46747
  } else {
46639
46748
  const insertAt = all.findIndex((b) => b.time > bar.time);
46640
46749
  all.splice(insertAt === -1 ? all.length - 1 : insertAt, 0, bar);
@@ -46642,13 +46751,8 @@ var _ChartEngine = class _ChartEngine {
46642
46751
  insertCount++;
46643
46752
  }
46644
46753
  }
46645
- const grew = this.dataStore.length - before;
46646
- if (insertCount > 0 && grew > 0) {
46647
- this.viewport = {
46648
- ...this.viewport,
46649
- startIndex: this.viewport.startIndex + grew,
46650
- endIndex: this.viewport.endIndex + grew
46651
- };
46754
+ if (insertCount > 0 || replaceCount > 0) {
46755
+ this.syncViewportToData(before);
46652
46756
  this.reanchorDrawingsByTimestamp();
46653
46757
  this.recomputeIndicators();
46654
46758
  this.scheduleRender();
@@ -46669,6 +46773,7 @@ var _ChartEngine = class _ChartEngine {
46669
46773
  if (bars.length === 0) return;
46670
46774
  const all = this.dataStore.raw;
46671
46775
  const liveBarIndex = all.length - 1;
46776
+ const before = this.dataStore.length;
46672
46777
  let changed = false;
46673
46778
  for (const bar of bars) {
46674
46779
  const idx = all.findIndex((b) => b.time === bar.time);
@@ -46679,6 +46784,7 @@ var _ChartEngine = class _ChartEngine {
46679
46784
  }
46680
46785
  }
46681
46786
  if (changed) {
46787
+ this.syncViewportToData(before);
46682
46788
  this.recomputeIndicators();
46683
46789
  this.scheduleRender();
46684
46790
  }
@@ -46708,8 +46814,10 @@ var _ChartEngine = class _ChartEngine {
46708
46814
  const all = this.dataStore.raw;
46709
46815
  const idx = all.findIndex((b) => b.time === barTime);
46710
46816
  if (idx === -1) return;
46817
+ const before = this.dataStore.length;
46711
46818
  all[idx] = bar;
46712
46819
  this.dataStore.invalidate();
46820
+ this.syncViewportToData(before);
46713
46821
  this.recomputeIndicators();
46714
46822
  this.scheduleRender();
46715
46823
  }
@@ -46830,6 +46938,55 @@ var _ChartEngine = class _ChartEngine {
46830
46938
  const all = this.dataStore.raw;
46831
46939
  return all[all.length - 1]?.close ?? 0;
46832
46940
  }
46941
+ /**
46942
+ * Arms a one-finger double-tap zoom at pane-local `y`. Locks the current
46943
+ * auto-fit range as the manual base so the zoom is relative to what is on
46944
+ * screen, and anchors on the tapped price so it stays under the finger.
46945
+ */
46946
+ startTapZoom(y, priceH) {
46947
+ if (this._longPressTimer !== null) {
46948
+ clearTimeout(this._longPressTimer);
46949
+ this._longPressTimer = null;
46950
+ this._longPressPoint = null;
46951
+ }
46952
+ const displayed = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
46953
+ if (!this._lockedAutoFitBase) this._lockedAutoFitBase = { ...displayed };
46954
+ const t = Math.max(0, Math.min(1, y / Math.max(1, priceH)));
46955
+ this.tapZoom = {
46956
+ startY: y,
46957
+ startFactor: this.priceScaleFactor,
46958
+ anchorPrice: displayed.max - t * (displayed.max - displayed.min),
46959
+ moved: false
46960
+ };
46961
+ }
46962
+ /** Sets the price-scale factor, keeping the tap-zoom anchor price at the same pixel. */
46963
+ applyTapZoomFactor(factor) {
46964
+ const tz = this.tapZoom;
46965
+ const base = this._lockedAutoFitBase;
46966
+ if (!tz || !base) return;
46967
+ const clamped = Math.max(0.1, Math.min(10, factor));
46968
+ const baseMid = (base.min + base.max) / 2;
46969
+ const mid = baseMid + this.pricePanOffset;
46970
+ const newMid = tz.anchorPrice + (mid - tz.anchorPrice) * (clamped / this.priceScaleFactor);
46971
+ this.priceScaleFactor = clamped;
46972
+ this.pricePanOffset = newMid - baseMid;
46973
+ this.scheduleRender();
46974
+ }
46975
+ moveTapZoom(y) {
46976
+ const tz = this.tapZoom;
46977
+ if (!tz) return;
46978
+ const dy = y - tz.startY;
46979
+ if (!tz.moved && Math.abs(dy) < TAP_MAX_MOVE_PX) return;
46980
+ tz.moved = true;
46981
+ this.applyTapZoomFactor(tz.startFactor * Math.pow(1.01, dy));
46982
+ }
46983
+ endTapZoom() {
46984
+ const tz = this.tapZoom;
46985
+ if (!tz) return;
46986
+ if (!tz.moved) this.applyTapZoomFactor(tz.startFactor * DOUBLE_TAP_ZOOM_STEP);
46987
+ this.tapZoom = null;
46988
+ this.scheduleViewportPersist();
46989
+ }
46833
46990
  resetPriceAxis() {
46834
46991
  this.priceScaleFactor = 1;
46835
46992
  this.pricePanOffset = 0;