@funnest/charts 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/chart.d.ts CHANGED
@@ -141,6 +141,8 @@ export declare class Chart {
141
141
  private priceAxisWidth;
142
142
  private frame;
143
143
  private crosshair;
144
+ /** Time (UTC ms) of a crosshair mirrored from a linked chart; see setSyncedCrosshair(). */
145
+ private syncedTime;
144
146
  private lastCrosshairKey;
145
147
  private lastRangeKey;
146
148
  private lastLayoutKey;
@@ -155,6 +157,9 @@ export declare class Chart {
155
157
  private unsubscribeBars;
156
158
  private unsubscribeQuotes;
157
159
  private latestQuote;
160
+ /** Symbol the quote stream belongs to; kept across interval changes of the same symbol. */
161
+ private quoteSymbol;
162
+ private quoteToken;
158
163
  private connectionState;
159
164
  private readonly displayTimezone;
160
165
  private loadingHistory;
@@ -193,6 +198,30 @@ export declare class Chart {
193
198
  scrollBars(bars: number): void;
194
199
  /** True when the newest bar is visible. */
195
200
  isAtRealtime(): boolean;
201
+ /**
202
+ * Mirrors another chart's crosshair: a vertical line at `time` (UTC ms) on the bar that
203
+ * contains it, with the time badge but no price line. `crosshairMove` fires with
204
+ * `source: 'sync'`, so legends follow along. A pointer on this chart takes precedence.
205
+ * Pass null to clear.
206
+ */
207
+ setSyncedCrosshair(time: number | null): void;
208
+ /**
209
+ * The time span (UTC ms) between the plot's left and right edges. Edges past the data
210
+ * (the empty space right of the last bar) are extrapolated by the bar interval.
211
+ */
212
+ getVisibleTimeRange(): {
213
+ from: number;
214
+ to: number;
215
+ } | null;
216
+ /** Scrolls and zooms so the plot spans `from`…`to` (UTC ms), e.g. to match a linked chart. */
217
+ setVisibleTimeRange(range: {
218
+ from: number;
219
+ to: number;
220
+ }): void;
221
+ /** Fractional bar index → time, interpolating between bars and extrapolating past either end. */
222
+ private indexToTime;
223
+ /** Inverse of indexToTime(). */
224
+ private timeToIndex;
196
225
  /** Snapshot of the chart (without the crosshair) at device resolution. */
197
226
  takeScreenshot(): HTMLCanvasElement;
198
227
  /**
@@ -286,6 +315,8 @@ export declare class Chart {
286
315
  private afterFrame;
287
316
  private requestHistory;
288
317
  private resolveCrosshair;
318
+ /** The bar containing `time`, as a crosshair without a price (nothing when it's off the data). */
319
+ private resolveSyncedCrosshair;
289
320
  private paneAt;
290
321
  private setTooltip;
291
322
  private executionTooltip;
package/dist/index.js CHANGED
@@ -1681,6 +1681,7 @@ var DEFAULT_CHART_SETTINGS = {
1681
1681
  gridHorizontal: true,
1682
1682
  gridVertical: true,
1683
1683
  volume: true,
1684
+ navigationButtons: true,
1684
1685
  upColor: null,
1685
1686
  downColor: null
1686
1687
  };
@@ -2239,6 +2240,17 @@ function drawOverlay(layer, m, ch, tooltip = null) {
2239
2240
  if (tooltip) drawTooltip(p, f, tooltip);
2240
2241
  if (!ch) return;
2241
2242
  p.vLine(ch.x, 0, f.panesHeight, pal.crosshair, DASH);
2243
+ if (ch.source !== "sync") drawCrosshairValue(p, m, ch);
2244
+ const timeLabel = m.formatCrosshairTime(ch.time);
2245
+ const w = p.measure(timeLabel) + 16;
2246
+ const cx = Math.min(f.plotWidth - w / 2, Math.max(w / 2, ch.x));
2247
+ p.roundRect(cx - w / 2, f.panesHeight + 4, w, f.timeAxisHeight - 8, 4, pal.text);
2248
+ p.text(timeLabel, cx, f.panesHeight + f.timeAxisHeight / 2, pal.background, "center");
2249
+ }
2250
+ function drawCrosshairValue(p, m, ch) {
2251
+ const f = m.frame;
2252
+ const pal = f.palette;
2253
+ const ctx = p.ctx;
2242
2254
  p.hLine(ch.y, 0, f.plotWidth, pal.crosshair, DASH);
2243
2255
  if (ch.source === "touch") {
2244
2256
  ctx.beginPath();
@@ -2255,11 +2267,6 @@ function drawOverlay(layer, m, ch, tooltip = null) {
2255
2267
  const by = Math.min(f.panesHeight - BADGE_HEIGHT / 2, Math.max(BADGE_HEIGHT / 2, ch.y));
2256
2268
  p.roundRect(f.plotWidth + 2, by - BADGE_HEIGHT / 2, f.width - f.plotWidth - 4, BADGE_HEIGHT, 4, pal.text);
2257
2269
  p.text(valueLabel, f.plotWidth + 8, by, pal.background);
2258
- const timeLabel = m.formatCrosshairTime(ch.time);
2259
- const w = p.measure(timeLabel) + 16;
2260
- const cx = Math.min(f.plotWidth - w / 2, Math.max(w / 2, ch.x));
2261
- p.roundRect(cx - w / 2, f.panesHeight + 4, w, f.timeAxisHeight - 8, 4, pal.text);
2262
- p.text(timeLabel, cx, f.panesHeight + f.timeAxisHeight / 2, pal.background, "center");
2263
2270
  }
2264
2271
 
2265
2272
  // src/resolution.ts
@@ -2385,6 +2392,8 @@ var Chart = class {
2385
2392
  priceAxisWidth = MIN_PRICE_AXIS_WIDTH;
2386
2393
  frame = null;
2387
2394
  crosshair = null;
2395
+ /** Time (UTC ms) of a crosshair mirrored from a linked chart; see setSyncedCrosshair(). */
2396
+ syncedTime = null;
2388
2397
  lastCrosshairKey = "";
2389
2398
  lastRangeKey = "";
2390
2399
  lastLayoutKey = "";
@@ -2399,6 +2408,9 @@ var Chart = class {
2399
2408
  unsubscribeBars = null;
2400
2409
  unsubscribeQuotes = null;
2401
2410
  latestQuote = null;
2411
+ /** Symbol the quote stream belongs to; kept across interval changes of the same symbol. */
2412
+ quoteSymbol = null;
2413
+ quoteToken = 0;
2402
2414
  connectionState = null;
2403
2415
  displayTimezone;
2404
2416
  loadingHistory = false;
@@ -2501,9 +2513,14 @@ var Chart = class {
2501
2513
  const token = ++this.loadToken;
2502
2514
  this.unsubscribeBars?.();
2503
2515
  this.unsubscribeBars = null;
2504
- this.unsubscribeQuotes?.();
2505
- this.unsubscribeQuotes = null;
2506
- this.latestQuote = null;
2516
+ const keepQuotes = this.unsubscribeQuotes !== null && this.quoteSymbol === symbol;
2517
+ if (!keepQuotes) {
2518
+ this.quoteToken++;
2519
+ this.unsubscribeQuotes?.();
2520
+ this.unsubscribeQuotes = null;
2521
+ this.latestQuote = null;
2522
+ this.quoteSymbol = null;
2523
+ }
2507
2524
  this.emitter.emit("loadingChange", true);
2508
2525
  let info;
2509
2526
  let result;
@@ -2550,11 +2567,15 @@ var Chart = class {
2550
2567
  this.emitter.emit("connectionChange", state);
2551
2568
  }
2552
2569
  );
2553
- this.unsubscribeQuotes = feed.subscribeQuotes?.(info, (quote) => {
2554
- if (token !== this.loadToken) return;
2555
- this.latestQuote = { ...this.latestQuote, ...quote };
2556
- this.emitter.emit("quoteChange", this.latestQuote);
2557
- }) ?? null;
2570
+ if (!keepQuotes) {
2571
+ const quoteToken = ++this.quoteToken;
2572
+ this.quoteSymbol = symbol;
2573
+ this.unsubscribeQuotes = feed.subscribeQuotes?.(info, (quote) => {
2574
+ if (quoteToken !== this.quoteToken) return;
2575
+ this.latestQuote = { ...this.latestQuote, ...quote };
2576
+ this.emitter.emit("quoteChange", this.latestQuote);
2577
+ }) ?? null;
2578
+ }
2558
2579
  this.emitter.emit("symbolChange", info);
2559
2580
  this.stateChanged();
2560
2581
  }
@@ -2611,6 +2632,61 @@ var Chart = class {
2611
2632
  isAtRealtime() {
2612
2633
  return this.store.length === 0 || this.timeScale.visibleRange().to >= this.store.length - 1;
2613
2634
  }
2635
+ /**
2636
+ * Mirrors another chart's crosshair: a vertical line at `time` (UTC ms) on the bar that
2637
+ * contains it, with the time badge but no price line. `crosshairMove` fires with
2638
+ * `source: 'sync'`, so legends follow along. A pointer on this chart takes precedence.
2639
+ * Pass null to clear.
2640
+ */
2641
+ setSyncedCrosshair(time) {
2642
+ if (time === this.syncedTime) return;
2643
+ this.syncedTime = time;
2644
+ this.invalidate(false);
2645
+ }
2646
+ /**
2647
+ * The time span (UTC ms) between the plot's left and right edges. Edges past the data
2648
+ * (the empty space right of the last bar) are extrapolated by the bar interval.
2649
+ */
2650
+ getVisibleTimeRange() {
2651
+ const ts = this.timeScale;
2652
+ if (this.store.length === 0 || ts.width <= 0) return null;
2653
+ return { from: this.indexToTime(ts.xToIndexFloat(0)), to: this.indexToTime(ts.xToIndexFloat(ts.width)) };
2654
+ }
2655
+ /** Scrolls and zooms so the plot spans `from`…`to` (UTC ms), e.g. to match a linked chart. */
2656
+ setVisibleTimeRange(range) {
2657
+ const ts = this.timeScale;
2658
+ if (this.store.length === 0 || ts.width <= 0 || !(range.to > range.from)) return;
2659
+ const from = this.timeToIndex(range.from);
2660
+ const to = this.timeToIndex(range.to);
2661
+ if (!(to > from)) return;
2662
+ ts.barSpacing = ts.width / (to - from);
2663
+ ts.rightOffset = to - 0.5 - (this.store.length - 1);
2664
+ ts.clamp();
2665
+ this.invalidate();
2666
+ }
2667
+ /** Fractional bar index → time, interpolating between bars and extrapolating past either end. */
2668
+ indexToTime(index) {
2669
+ const bars = this.store.bars;
2670
+ const last = bars.length - 1;
2671
+ const step = this.barInterval();
2672
+ if (index <= 0) return bars[0].time + index * step;
2673
+ if (index >= last) return bars[last].time + (index - last) * step;
2674
+ const i = Math.floor(index);
2675
+ const a = bars[i].time;
2676
+ return a + (bars[i + 1].time - a) * (index - i);
2677
+ }
2678
+ /** Inverse of indexToTime(). */
2679
+ timeToIndex(time) {
2680
+ const bars = this.store.bars;
2681
+ const last = bars.length - 1;
2682
+ const step = this.barInterval();
2683
+ if (time <= bars[0].time) return (time - bars[0].time) / step;
2684
+ if (time >= bars[last].time) return last + (time - bars[last].time) / step;
2685
+ const i = this.store.indexAtOrBefore(time);
2686
+ const a = bars[i].time;
2687
+ const b = bars[i + 1].time;
2688
+ return i + (b > a ? (time - a) / (b - a) : 0);
2689
+ }
2614
2690
  /** Snapshot of the chart (without the crosshair) at device resolution. */
2615
2691
  takeScreenshot() {
2616
2692
  if (this.rafId || this.mainDirty) this.render();
@@ -3232,8 +3308,9 @@ var Chart = class {
3232
3308
  resolveCrosshair(frame) {
3233
3309
  const ch = this.crosshair;
3234
3310
  const bars = this.store.bars;
3235
- if (!ch || bars.length === 0) return null;
3236
- if (ch.source === "mouse" && (ch.x < 0 || ch.x > frame.plotWidth || ch.y < 0 || ch.y > frame.panesHeight)) return null;
3311
+ if (bars.length === 0) return null;
3312
+ const outside = ch?.source === "mouse" && (ch.x < 0 || ch.x > frame.plotWidth || ch.y < 0 || ch.y > frame.panesHeight);
3313
+ if (!ch || outside) return this.syncedTime === null ? null : this.resolveSyncedCrosshair(this.syncedTime);
3237
3314
  const index = Math.min(bars.length - 1, Math.max(0, this.timeScale.xToIndex(ch.x)));
3238
3315
  const bar = bars[index];
3239
3316
  const x = this.timeScale.indexToX(index);
@@ -3243,6 +3320,18 @@ var Chart = class {
3243
3320
  const value = pane.kind === "main" ? roundToTick(raw, this.tickSize) : raw;
3244
3321
  return { index, time: bar.time, bar, value, pane: pane.id, x, y, source: ch.source };
3245
3322
  }
3323
+ /** The bar containing `time`, as a crosshair without a price (nothing when it's off the data). */
3324
+ resolveSyncedCrosshair(time) {
3325
+ const bars = this.store.bars;
3326
+ const last = bars[bars.length - 1];
3327
+ if (time < bars[0].time || time >= last.time + this.barInterval()) return null;
3328
+ const index = this.store.indexAtOrBefore(time);
3329
+ const bar = bars[index];
3330
+ if (!bar) return null;
3331
+ const x = this.timeScale.indexToX(index);
3332
+ if (x < 0 || x > this.timeScale.width) return null;
3333
+ return { index, time: bar.time, bar, value: bar.close, pane: "main", x, y: -1, source: "sync" };
3334
+ }
3246
3335
  paneAt(frame, y) {
3247
3336
  return frame.panes.find((p) => y >= p.top && y < p.top + p.height) ?? frame.panes[frame.panes.length - 1];
3248
3337
  }
@@ -67,7 +67,8 @@ export interface CrosshairInfo {
67
67
  pane: PaneId;
68
68
  x: number;
69
69
  y: number;
70
- source: 'mouse' | 'touch';
70
+ /** 'sync': mirrored from a linked chart (setSyncedCrosshair) — time only, `y` is -1. */
71
+ source: 'mouse' | 'touch' | 'sync';
71
72
  }
72
73
  /** Fill details shown next to an execution marker. */
73
74
  export interface Tooltip {
@@ -12,6 +12,8 @@ export interface ChartSettings {
12
12
  gridVertical: boolean;
13
13
  /** Volume pane under the price pane. */
14
14
  volume: boolean;
15
+ /** Zoom/scroll buttons at the bottom center of the chart (@funnest/charts-ui). */
16
+ navigationButtons: boolean;
15
17
  /** Up/down candle colors; null follows the theme. */
16
18
  upColor: string | null;
17
19
  downColor: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnest/charts",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Canvas charting engine for financial charts",
5
5
  "keywords": [
6
6
  "chart",