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

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 |
@@ -2337,6 +2337,13 @@ Three consequences worth knowing:
2337
2337
  different number of elements.
2338
2338
  - **Market price still comes off the raw feed.** Trade levels and P&L price
2339
2339
  against the real last close, never a brick top.
2340
+ - **Live ticks move the element count both ways.** The forming bar completes a
2341
+ brick, line or column on one tick and takes it back on the next. The view
2342
+ keeps tracking the live edge either way — same width, same room past the
2343
+ last element — and a view scrolled into history stays put. Box and reversal
2344
+ sizes are measured over **closed** bars, so a tick never re-grids the series;
2345
+ an ATR box changes once per completed bar. (Before 1.4.0-beta.4 the view only
2346
+ ever moved forward, so these types went blank after a while on a live feed.)
2340
2347
 
2341
2348
  ### Parameters
2342
2349
 
@@ -2362,12 +2369,12 @@ Renko leaves Kagi and P&F alone.
2362
2369
 
2363
2370
  | `BoxSizeMode` | Meaning |
2364
2371
  | --- | --- |
2365
- | `{ kind: 'atr', length }` | Wilder ATR over the last `length` bars. Default `14`. |
2372
+ | `{ kind: 'atr', length }` | Wilder ATR over the last `length` closed bars (the forming bar is excluded). Default `14`. |
2366
2373
  | `{ kind: 'fixed', size }` | A fixed price distance. |
2367
- | `{ kind: 'percent', percent }` | A percentage of the latest close. |
2374
+ | `{ kind: 'percent', percent }` | A percentage of the last closed bar's close. |
2368
2375
 
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
2376
+ When a box cannot be sized — fewer than `length + 1` closed bars for the ATR, or
2377
+ a non-positive size — the transform returns **no elements** rather than guessing. A
2371
2378
  wrong box size does not look wrong; it looks like a different market.
2372
2379
 
2373
2380
  ### 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];
@@ -38805,7 +38841,7 @@ var _ChartEngine = class _ChartEngine {
38805
38841
  };
38806
38842
  all[all.length - 1] = updated;
38807
38843
  this.dataStore.invalidate();
38808
- this.followLiveEdge(before);
38844
+ this.syncViewportToData(before);
38809
38845
  } else {
38810
38846
  const completedBar = last;
38811
38847
  const newBarTime = Math.floor(tick.time / intervalMs) * intervalMs;
@@ -38824,7 +38860,7 @@ var _ChartEngine = class _ChartEngine {
38824
38860
  );
38825
38861
  }
38826
38862
  const openingBar = all[all.length - 1];
38827
- this.followLiveEdge(before);
38863
+ this.syncViewportToData(before);
38828
38864
  this.emitter.emit("newBar", { completedBar, openingBar, intervalMs });
38829
38865
  this.correctCompletedBar(completedBar.time, intervalMs);
38830
38866
  if (newBarTime > completedBar.time + intervalMs) {
@@ -40224,6 +40260,12 @@ var _ChartEngine = class _ChartEngine {
40224
40260
  this.updateCompareRange();
40225
40261
  this.scheduleRender();
40226
40262
  }
40263
+ const clamped = clampViewportToData(this._targetViewport, this.dataStore.length);
40264
+ if (clamped !== this._targetViewport) {
40265
+ this._targetViewport = clamped;
40266
+ this.viewport = { ...clamped };
40267
+ this.scheduleRender();
40268
+ }
40227
40269
  this.isFetching = false;
40228
40270
  return this;
40229
40271
  }
@@ -40410,15 +40452,8 @@ var _ChartEngine = class _ChartEngine {
40410
40452
  const before = this.dataStore.length;
40411
40453
  this.dataStore.raw.push({ ...bar });
40412
40454
  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
- }
40455
+ this.syncViewportToData(before);
40456
+ this.viewport = { ...this._targetViewport };
40422
40457
  r.time = bar.time;
40423
40458
  this.recomputeIndicators();
40424
40459
  this.updateReplayBar();
@@ -45136,6 +45171,10 @@ var _ChartEngine = class _ChartEngine {
45136
45171
  }
45137
45172
  render() {
45138
45173
  if (this._isDestroyed) return;
45174
+ {
45175
+ const clamped = clampViewportToData(this._targetViewport, this.dataStore.length);
45176
+ if (clamped !== this._targetViewport) this._targetViewport = clamped;
45177
+ }
45139
45178
  const { ctx, canvas, theme, viewport } = this;
45140
45179
  const dpr = window.devicePixelRatio || 1;
45141
45180
  const totalW = canvas.width;
@@ -45150,6 +45189,11 @@ var _ChartEngine = class _ChartEngine {
45150
45189
  const absBars = this.dataStore.getVisible(viewport);
45151
45190
  if (absBars.length === 0) {
45152
45191
  this.drawCtx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height);
45192
+ const target = this._targetViewport;
45193
+ if (!this._isPanning && (viewport.startIndex !== target.startIndex || viewport.endIndex !== target.endIndex)) {
45194
+ this.viewport = { ...target };
45195
+ this.scheduleRender();
45196
+ }
45153
45197
  return;
45154
45198
  }
45155
45199
  let bars = absBars;
@@ -46595,20 +46639,31 @@ var _ChartEngine = class _ChartEngine {
46595
46639
  }, this.tickActivityMs);
46596
46640
  }
46597
46641
  /**
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.
46642
+ * Re-aligns the view after a raw-bar mutation changed the number of
46643
+ * displayed elements. `before` is `dataStore.length` captured before the
46644
+ * mutation. The rule lives in `core/viewportSync.ts`: a view tracking the
46645
+ * live edge keeps tracking it whether the count grew or shrank; a view
46646
+ * scrolled into history is only clamped so it cannot sit past the data.
46647
+ *
46648
+ * The old rule shifted the view forward by the growth and did nothing on
46649
+ * a shrink. A time-based series never shrinks, but under Renko / Line
46650
+ * Break / Kagi / P&F the forming bar completes an element on one tick and
46651
+ * takes it back on the next, so the view ratcheted ahead one element per
46652
+ * oscillation until it sat past the data — a blank chart "after a while".
46653
+ *
46654
+ * Under a price transform the elements are rebuilt rather than shifted,
46655
+ * so there is nothing for the per-frame lerp to animate between and the
46656
+ * view snaps. A time-based series keeps its one-bar slide.
46603
46657
  */
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
- };
46658
+ syncViewportToData(before) {
46659
+ const synced = syncViewportToElementCount(
46660
+ this._targetViewport,
46661
+ before,
46662
+ this.dataStore.length
46663
+ );
46664
+ if (synced === this._targetViewport) return;
46665
+ this._targetViewport = synced;
46666
+ if (isPriceTransformSeries(this.series)) this.viewport = { ...synced };
46612
46667
  }
46613
46668
  /** Fetch bars that fall in a gap between the last historical bar and the first live bar.
46614
46669
  * This happens when the system clock was behind feed time during the initial data load. */
@@ -46629,12 +46684,14 @@ var _ChartEngine = class _ChartEngine {
46629
46684
  const liveBar = all[all.length - 1];
46630
46685
  const before = this.dataStore.length;
46631
46686
  let insertCount = 0;
46687
+ let replaceCount = 0;
46632
46688
  for (const bar of bars) {
46633
46689
  if (bar.time >= liveBar.time) continue;
46634
46690
  const existingIdx = all.findIndex((b) => b.time === bar.time);
46635
46691
  if (existingIdx !== -1) {
46636
46692
  all[existingIdx] = bar;
46637
46693
  this.dataStore.invalidate();
46694
+ replaceCount++;
46638
46695
  } else {
46639
46696
  const insertAt = all.findIndex((b) => b.time > bar.time);
46640
46697
  all.splice(insertAt === -1 ? all.length - 1 : insertAt, 0, bar);
@@ -46642,13 +46699,8 @@ var _ChartEngine = class _ChartEngine {
46642
46699
  insertCount++;
46643
46700
  }
46644
46701
  }
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
- };
46702
+ if (insertCount > 0 || replaceCount > 0) {
46703
+ this.syncViewportToData(before);
46652
46704
  this.reanchorDrawingsByTimestamp();
46653
46705
  this.recomputeIndicators();
46654
46706
  this.scheduleRender();
@@ -46669,6 +46721,7 @@ var _ChartEngine = class _ChartEngine {
46669
46721
  if (bars.length === 0) return;
46670
46722
  const all = this.dataStore.raw;
46671
46723
  const liveBarIndex = all.length - 1;
46724
+ const before = this.dataStore.length;
46672
46725
  let changed = false;
46673
46726
  for (const bar of bars) {
46674
46727
  const idx = all.findIndex((b) => b.time === bar.time);
@@ -46679,6 +46732,7 @@ var _ChartEngine = class _ChartEngine {
46679
46732
  }
46680
46733
  }
46681
46734
  if (changed) {
46735
+ this.syncViewportToData(before);
46682
46736
  this.recomputeIndicators();
46683
46737
  this.scheduleRender();
46684
46738
  }
@@ -46708,8 +46762,10 @@ var _ChartEngine = class _ChartEngine {
46708
46762
  const all = this.dataStore.raw;
46709
46763
  const idx = all.findIndex((b) => b.time === barTime);
46710
46764
  if (idx === -1) return;
46765
+ const before = this.dataStore.length;
46711
46766
  all[idx] = bar;
46712
46767
  this.dataStore.invalidate();
46768
+ this.syncViewportToData(before);
46713
46769
  this.recomputeIndicators();
46714
46770
  this.scheduleRender();
46715
46771
  }