wickchart 1.2.0 → 1.4.0

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/src/wick-chart.js CHANGED
@@ -31,7 +31,9 @@ import {
31
31
  windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
32
32
  compileScript, predicateTrueSeries, scriptAlertStep,
33
33
  AI_TOOLS, aiPromptText, applyChartOps,
34
- calcVolCone, normalizeScenario,
34
+ calcVolCone, normalizeScenario, normalizeRiskPlan, PresenceTracker,
35
+ narrateWindow, brushStats,
36
+ easeInOutCubic, sceneList,
35
37
  } from './core.js';
36
38
 
37
39
  /* ------------------------------------------------------------------ *
@@ -48,7 +50,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
48
50
 
49
51
  class WickChart extends HTMLElementBase {
50
52
  static get observedAttributes() {
51
- return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'sonify'];
53
+ return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'co-view-name', 'brush', 'sonify'];
52
54
  }
53
55
 
54
56
  constructor() {
@@ -205,6 +207,11 @@ class WickChart extends HTMLElementBase {
205
207
  this._coviewLast = 0;
206
208
  this._ghost = null;
207
209
  this._ghostTimer = 0;
210
+ // presence: peer viewports (who is looking where)
211
+ this._coviewLabel = null; // display name from the co-view-name attribute
212
+ this._presence = new PresenceTracker();
213
+ this._coviewBeat = 0;
214
+ this._coviewViewLast = 0;
208
215
 
209
216
  // sonification state
210
217
  this._sonify = false;
@@ -213,12 +220,26 @@ class WickChart extends HTMLElementBase {
213
220
  this._playToken = 0;
214
221
  this._measure = null; // { iA, pA, iB, pB, done }
215
222
  this._measuring = false;
223
+ // bar-walk narrator state
224
+ this._walkTimer = 0;
225
+ // story mode state: token cancels stale async runs
226
+ this._storyToken = 0;
227
+ this._story = null;
228
+ // delta brush state: mode flag + current/finished selection
229
+ this._brush = false;
230
+ this._brushSel = null; // { i0, i1, stats } — the committed selection
231
+ this._brushDrag = null; // { i0, i1 } — while the pointer is down
216
232
  this._ind = { overlays: [], panes: [], volume: true };
217
233
 
218
234
  this._pointers = new Map();
219
235
  this._pan = null;
220
236
  this._pinch = null;
221
237
 
238
+ // plugin layers: external draw hooks + pointer claims (see addLayer)
239
+ this._layers = [];
240
+ this._layerClaim = null; // { layer, pointerId } while a layer owns a drag
241
+ this._layerSeq = 0;
242
+
222
243
  // history backfill state (onloadmore declared as a class field above)
223
244
  this._loadingMore = false;
224
245
  this._noMore = false;
@@ -233,6 +254,8 @@ class WickChart extends HTMLElementBase {
233
254
 
234
255
  // scenario projection (ghost path + vol cone)
235
256
  this._scenario = null;
257
+ // risk plan (R-multiple grid)
258
+ this._riskPlan = null;
236
259
 
237
260
  this._onResize = () => this._invalidate();
238
261
  this._onPointerDown = (e) => this._pointerDown(e);
@@ -246,7 +269,10 @@ class WickChart extends HTMLElementBase {
246
269
  }
247
270
  };
248
271
  this._onWheel = (e) => this._wheel(e);
249
- this._onDbl = () => this.fit();
272
+ this._onDbl = () => {
273
+ this._stopPlayback();
274
+ this.fit();
275
+ };
250
276
  this._onKey = (e) => this._keydown(e);
251
277
  }
252
278
 
@@ -280,12 +306,23 @@ class WickChart extends HTMLElementBase {
280
306
 
281
307
  disconnectedCallback() {
282
308
  this._connected = false;
309
+ this.stopWalk(true);
310
+ this.stopStory(true);
311
+ this._layerClaim = null;
283
312
  if (this._coviewCh) {
313
+ this._coviewSend({ type: 'bye' });
284
314
  try {
285
315
  this._coviewCh.close();
286
316
  } catch (_) {}
287
317
  this._coviewCh = null;
288
318
  }
319
+ clearInterval(this._coviewBeat);
320
+ this._coviewBeat = 0;
321
+ if (this._presence && this._presence.peers.size) {
322
+ const left = this._presence.list();
323
+ this._presence = new PresenceTracker();
324
+ this._fire('peers', { peers: [], joined: [], left });
325
+ }
289
326
  clearTimeout(this._ghostTimer);
290
327
  if (this._ro) this._ro.disconnect();
291
328
  const cv = this._canvas;
@@ -355,6 +392,16 @@ class WickChart extends HTMLElementBase {
355
392
  this._coviewName = val || null;
356
393
  this._setupCoView();
357
394
  break;
395
+ case 'co-view-name':
396
+ // display name travels with every presence message; no re-render
397
+ this._coviewLabel = val || null;
398
+ break;
399
+ case 'brush':
400
+ this._brush = val != null && val !== 'false';
401
+ this._brushSel = null;
402
+ this._brushDrag = null;
403
+ this._invalidate();
404
+ break;
358
405
  case 'sonify':
359
406
  this._sonify = val != null && val !== 'false';
360
407
  this._lastToneIdx = -1;
@@ -424,6 +471,8 @@ class WickChart extends HTMLElementBase {
424
471
  this.clearData();
425
472
  return;
426
473
  }
474
+ // selection indices are data-bound; a replacement invalidates them
475
+ if (this._brushSel || this._brushDrag) this.clearBrush();
427
476
  const norm = [];
428
477
  for (const b of bars) {
429
478
  const nb = WickChart._normBar(b);
@@ -910,6 +959,41 @@ class WickChart extends HTMLElementBase {
910
959
  return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
911
960
  }
912
961
 
962
+ /**
963
+ * Risk plan: an R-multiple grid anchored at entry/stop. 1R = |entry −
964
+ * stop| (the risk unit); reward lines are drawn at kR beyond the entry
965
+ * with the risk/reward zones shaded, so sizing and take-profit choices
966
+ * read directly off the chart.
967
+ *
968
+ * chart.setRiskPlan({ entry: 64500, stop: 63800, multiples: [1, 2, 3] });
969
+ * chart.setRiskPlan({ entry: 64500, stop: 63800, targets: [65900, 67300] });
970
+ *
971
+ * Direction is derived (stop below entry ⇒ long). Targets convert to
972
+ * their R multiple; `multiples` win when both are given. Invalid specs
973
+ * clear the plan (replace semantics, like setScenario); excluded from
974
+ * getState/setState — it is app state, not chart state.
975
+ * @param {object} spec
976
+ * @returns {object|null} the normalized plan, or null when invalid
977
+ */
978
+ setRiskPlan(spec) {
979
+ this._riskPlan = normalizeRiskPlan(spec);
980
+ this._invalidate();
981
+ return this._riskPlan;
982
+ }
983
+
984
+ clearRiskPlan() {
985
+ if (this._riskPlan) {
986
+ this._riskPlan = null;
987
+ this._invalidate();
988
+ }
989
+ }
990
+
991
+ /** @returns {object|null} a copy of the active risk plan */
992
+ get riskPlan() {
993
+ if (!this._riskPlan) return null;
994
+ return { ...this._riskPlan, levels: this._riskPlan.levels.map((l) => ({ ...l })) };
995
+ }
996
+
913
997
  /** σ-cone for the active scenario, cached per data version. */
914
998
  _scenarioConeCache() {
915
999
  if (!this._scenario || !this._data.length) return null;
@@ -1799,6 +1883,67 @@ class WickChart extends HTMLElementBase {
1799
1883
  }
1800
1884
  }
1801
1885
 
1886
+ /* risk plan: R-multiple grid — risk/reward shading + kR lines */
1887
+ if (this._riskPlan && d.length) {
1888
+ const rp = this._riskPlan;
1889
+ const fP = numberFmt(this._prec(scale.rawHi || 1));
1890
+ const yE = yOf(rp.entry);
1891
+ const yS = yOf(rp.stop);
1892
+ ctx.fillStyle = hexToRgba(pal.down, 0.06);
1893
+ ctx.fillRect(0, Math.min(yE, yS), plotRight, Math.abs(yS - yE));
1894
+ const yTop = yOf(rp.levels[rp.levels.length - 1].price);
1895
+ ctx.fillStyle = hexToRgba(pal.up, 0.05);
1896
+ ctx.fillRect(0, Math.min(yE, yTop), plotRight, Math.abs(yTop - yE));
1897
+ const line = (p, col, dash) => {
1898
+ const y = yOf(p);
1899
+ if (y < main.y0 || y > main.y1) return null;
1900
+ ctx.strokeStyle = col;
1901
+ ctx.lineWidth = 1.5;
1902
+ if (dash) ctx.setLineDash([5, 4]);
1903
+ ctx.beginPath();
1904
+ ctx.moveTo(0, Math.round(y) + 0.5);
1905
+ ctx.lineTo(plotRight, Math.round(y) + 0.5);
1906
+ ctx.stroke();
1907
+ ctx.setLineDash([]);
1908
+ ctx.lineWidth = 1;
1909
+ return y;
1910
+ };
1911
+ const pills = [];
1912
+ for (let i = rp.levels.length - 1; i >= 0; i--) {
1913
+ const lv = rp.levels[i];
1914
+ const y = line(lv.price, pal.up, true);
1915
+ if (y != null) {
1916
+ const kk = lv.k % 1 === 0 ? lv.k : +lv.k.toFixed(2);
1917
+ pills.push({ y, text: `${kk}R ${fP.format(lv.price)}`, bg: pal.up });
1918
+ }
1919
+ }
1920
+ const yStop = line(rp.stop, pal.down, false);
1921
+ if (yStop != null) pills.push({ y: yStop, text: `STOP ${fP.format(rp.stop)}`, bg: pal.down });
1922
+ const yEnt = line(rp.entry, pal.accent, false);
1923
+ if (yEnt != null) {
1924
+ pills.push({ y: yEnt, text: `${rp.direction === 'long' ? 'LONG' : 'SHORT'} ${fP.format(rp.entry)}`, bg: pal.accent });
1925
+ }
1926
+ // stack right-edge pills instead of letting close lines overlap
1927
+ pills.sort((a, b) => a.y - b.y);
1928
+ let lastY = -Infinity;
1929
+ for (const p of pills) {
1930
+ const y = Math.max(p.y, lastY + 20);
1931
+ lastY = y;
1932
+ ctx.font = pillFont();
1933
+ const tw = ctx.measureText(p.text).width + 12;
1934
+ this._pill(plotRight - tw - 8, y, p.text, p.bg, pal.pillText, 'left', tw);
1935
+ }
1936
+ if (rp.label) {
1937
+ ctx.font = pillFont();
1938
+ ctx.fillStyle = pal.accent;
1939
+ ctx.globalAlpha = 0.9;
1940
+ ctx.textAlign = 'left';
1941
+ ctx.textBaseline = 'bottom';
1942
+ ctx.fillText(rp.label, 8, clamp(yE, main.y0 + 14, main.y1) - 3);
1943
+ ctx.globalAlpha = 1;
1944
+ }
1945
+ }
1946
+
1802
1947
  /* volume profile (behind the series) */
1803
1948
  if (this._profile) {
1804
1949
  const pkey = `${i0}:${i1}:${this._version}`;
@@ -2393,6 +2538,9 @@ class WickChart extends HTMLElementBase {
2393
2538
  );
2394
2539
  }
2395
2540
 
2541
+ /* plugin layers — above chart content, under the pointer-following UI */
2542
+ if (this._layers.length) this._drawLayers(ctx, pal, ly, d);
2543
+
2396
2544
  /* crosshair */
2397
2545
  if (this._hover && this._hover.index < d.length) {
2398
2546
  const h = this._hover;
@@ -2458,6 +2606,34 @@ class WickChart extends HTMLElementBase {
2458
2606
  );
2459
2607
  }
2460
2608
 
2609
+ /* co-view presence: peer viewport bands along the top of the plot */
2610
+ if (this._presence && this._presence.peers.size && d.length) {
2611
+ const peers = this._presence.list().slice(0, 4);
2612
+ ctx.save();
2613
+ ctx.font = pillFont();
2614
+ for (let row = 0; row < peers.length; row++) {
2615
+ const p = peers[row];
2616
+ if (!p.range) continue;
2617
+ const cols = pal.overlay || [];
2618
+ const col = cols[(row + 1) % Math.max(cols.length, 1)] || pal.accent;
2619
+ const i0 = WickChart._indexForTime(this._data, p.range.from);
2620
+ const i1 = WickChart._indexForTime(this._data, p.range.to);
2621
+ const x0 = clamp(this._xFor(i0), 0, plotRight);
2622
+ const x1 = clamp(this._xFor(i1), 0, plotRight);
2623
+ const y = main.y0 + 2 + row * 5;
2624
+ ctx.globalAlpha = 0.8;
2625
+ ctx.fillStyle = col;
2626
+ ctx.fillRect(x0, y, Math.max(x1 - x0, 3), 3);
2627
+ if (x1 - x0 > 44) {
2628
+ ctx.globalAlpha = 0.95;
2629
+ ctx.textAlign = 'left';
2630
+ ctx.textBaseline = 'top';
2631
+ ctx.fillText(p.name || p.id, x0 + 3, y + 4);
2632
+ }
2633
+ }
2634
+ ctx.restore();
2635
+ }
2636
+
2461
2637
  /* co-view ghost crosshair (peer pointer from another tab/chart) */
2462
2638
  if (this._ghost) {
2463
2639
  const g = this._ghost;
@@ -2544,6 +2720,50 @@ class WickChart extends HTMLElementBase {
2544
2720
  );
2545
2721
  }
2546
2722
 
2723
+ /* delta brush selection: band + live delta chip */
2724
+ {
2725
+ const sel = this._brushDrag || this._brushSel;
2726
+ if (sel && d.length) {
2727
+ const bi0 = Math.min(sel.i0, sel.i1);
2728
+ const bi1 = Math.max(sel.i0, sel.i1);
2729
+ const xa = this._xFor(bi0) - this._view.spacing / 2;
2730
+ const xb = this._xFor(bi1) + this._view.spacing / 2;
2731
+ const bx0 = clamp(Math.min(xa, xb), 0, plotRight);
2732
+ const bx1 = clamp(Math.max(xa, xb), 0, plotRight);
2733
+ if (bx1 - bx0 > 1) {
2734
+ const live = this._brushDrag ? brushStats(this._data, bi0, bi1) : sel.stats;
2735
+ ctx.save();
2736
+ ctx.fillStyle = hexToRgba(pal.accent, this._brushDrag ? 0.13 : 0.08);
2737
+ ctx.fillRect(bx0, main.y0, bx1 - bx0, main.h);
2738
+ ctx.globalAlpha = 0.55;
2739
+ ctx.strokeStyle = pal.accent;
2740
+ ctx.lineWidth = 1;
2741
+ if (!this._brushDrag) ctx.setLineDash([4, 3]);
2742
+ ctx.strokeRect(bx0 + 0.5, main.y0 + 0.5, bx1 - bx0 - 1, main.h - 1);
2743
+ ctx.setLineDash([]);
2744
+ ctx.restore();
2745
+ if (live) {
2746
+ const fP = numberFmt(this._prec(Math.abs(live.lastClose) || 1));
2747
+ const sign = live.delta >= 0 ? '+' : '';
2748
+ const txt =
2749
+ `${sign}${live.deltaPct.toFixed(2)}% · ${live.bars} bars · ` +
2750
+ `H ${fP.format(live.high)} · L ${fP.format(live.low)} · Σvol ${fmtCompact(live.volume)}`;
2751
+ ctx.font = pillFont();
2752
+ const tw = ctx.measureText(txt).width + 12;
2753
+ this._pill(
2754
+ clamp((bx0 + bx1) / 2 - tw / 2, 2, plotRight - tw - 2),
2755
+ main.y0 + 11,
2756
+ txt,
2757
+ live.delta >= 0 ? pal.up : pal.down,
2758
+ pal.pillText,
2759
+ 'left',
2760
+ tw
2761
+ );
2762
+ }
2763
+ }
2764
+ }
2765
+ }
2766
+
2547
2767
  /* visible-range stats chip */
2548
2768
  if (this._stats) {
2549
2769
  const st = computeStats(d, i0, i1, this._dt);
@@ -2701,6 +2921,207 @@ class WickChart extends HTMLElementBase {
2701
2921
  poss.innerHTML = html;
2702
2922
  }
2703
2923
 
2924
+ /* ------------------------------------------------------------ *
2925
+ * Plugin layers — external draw hooks + pointer claims
2926
+ * ------------------------------------------------------------ */
2927
+
2928
+ /**
2929
+ * Register a plugin layer. `layer.draw(api)` runs on every render, above
2930
+ * chart content and under the crosshair; `layer.onPointer(pev)` is asked
2931
+ * first on pointerdown and claims the gesture by returning true — the
2932
+ * layer then receives that pointer's move/up/cancel events (plus a
2933
+ * 'cancel' on Escape) and the chart suppresses its own pan/measure/brush
2934
+ * for the duration.
2935
+ * @param {{id?: string, draw: Function, onPointer?: Function}} layer
2936
+ * @returns {object|null} the normalized layer handle (with `id`), or null
2937
+ * if the layer was rejected (no draw fn, or 16 layers already added)
2938
+ */
2939
+ addLayer(layer) {
2940
+ if (!layer || typeof layer !== 'object' || typeof layer.draw !== 'function') return null;
2941
+ if (this._layers.length >= 16) return null;
2942
+ const id =
2943
+ layer.id != null && String(layer.id).trim()
2944
+ ? String(layer.id).slice(0, 64)
2945
+ : 'layer-' + ++this._layerSeq;
2946
+ const entry = {
2947
+ id,
2948
+ draw: layer.draw,
2949
+ onPointer: typeof layer.onPointer === 'function' ? layer.onPointer : null,
2950
+ };
2951
+ const at = this._layers.findIndex((l) => l.id === id);
2952
+ if (at >= 0) this._layers[at] = entry; // same id replaces
2953
+ else this._layers.push(entry);
2954
+ this._invalidate();
2955
+ return entry;
2956
+ }
2957
+
2958
+ /**
2959
+ * Remove a layer added via addLayer (pass the returned handle or its id).
2960
+ * @param {object|string} idOrLayer
2961
+ * @returns {boolean} true if a layer was removed
2962
+ */
2963
+ removeLayer(idOrLayer) {
2964
+ const id = idOrLayer != null && typeof idOrLayer === 'object' ? idOrLayer.id : idOrLayer;
2965
+ if (id == null) return false;
2966
+ const at = this._layers.findIndex((l) => l.id === String(id));
2967
+ if (at < 0) return false;
2968
+ const [gone] = this._layers.splice(at, 1);
2969
+ if (this._layerClaim && this._layerClaim.layer === gone) this._layerClaim = null;
2970
+ this._invalidate();
2971
+ return true;
2972
+ }
2973
+
2974
+ /** Ask for a repaint on the next frame (interactive layers call this). */
2975
+ requestDraw() {
2976
+ this._invalidate();
2977
+ }
2978
+
2979
+ /** Paint every registered layer. Called from _render with live state. */
2980
+ _drawLayers(ctx, pal, ly, d) {
2981
+ for (const layer of this._layers) {
2982
+ try {
2983
+ layer.draw({
2984
+ ctx, // 2D context, already DPR-scaled — draw in CSS pixels
2985
+ layout: ly,
2986
+ palette: pal,
2987
+ data: d,
2988
+ view: this._view,
2989
+ timeToX: (t) => this.timeToX(t),
2990
+ xToTime: (x) => this.xToTime(x),
2991
+ priceToY: (p) => this.priceToY(p),
2992
+ yToPrice: (y) => this.yToPrice(y),
2993
+ });
2994
+ } catch (err) {
2995
+ console.warn('wick-chart: layer "' + layer.id + '" threw in draw', err);
2996
+ }
2997
+ }
2998
+ }
2999
+
3000
+ /** Ask layers, in order, whether one claims this pointerdown. */
3001
+ _layerHit(e, pt) {
3002
+ for (const layer of this._layers) {
3003
+ if (!layer.onPointer) continue;
3004
+ let claimed = false;
3005
+ try {
3006
+ claimed = layer.onPointer(this._layerPointerEvent(e, pt, 'down')) === true;
3007
+ } catch (err) {
3008
+ console.warn('wick-chart: layer "' + layer.id + '" threw in onPointer', err);
3009
+ }
3010
+ if (claimed) return layer;
3011
+ }
3012
+ return null;
3013
+ }
3014
+
3015
+ /** Deliver a pointer event to a claiming layer; never throws outward. */
3016
+ _routeLayer(layer, e, pt, type) {
3017
+ const ev = pt
3018
+ ? this._layerPointerEvent(e, pt, type)
3019
+ : {
3020
+ type,
3021
+ x: null,
3022
+ y: null,
3023
+ pointerId: e.pointerId == null ? 0 : e.pointerId,
3024
+ button: 0,
3025
+ shiftKey: !!e.shiftKey,
3026
+ ctrlKey: !!e.ctrlKey,
3027
+ altKey: !!e.altKey,
3028
+ metaKey: !!e.metaKey,
3029
+ };
3030
+ try {
3031
+ if (layer.onPointer) layer.onPointer(ev);
3032
+ } catch (err) {
3033
+ console.warn('wick-chart: layer "' + layer.id + '" threw in onPointer', err);
3034
+ }
3035
+ }
3036
+
3037
+ _layerPointerEvent(e, pt, type) {
3038
+ return {
3039
+ type,
3040
+ x: pt.x,
3041
+ y: pt.y,
3042
+ pointerId: e.pointerId,
3043
+ button: e.button == null ? 0 : e.button,
3044
+ shiftKey: !!e.shiftKey,
3045
+ ctrlKey: !!e.ctrlKey,
3046
+ altKey: !!e.altKey,
3047
+ metaKey: !!e.metaKey,
3048
+ };
3049
+ }
3050
+
3051
+ /**
3052
+ * x-pixel for a bar time (ms or s, auto-detected). Extrapolates past the
3053
+ * last bar into future space using the median bar interval, so layers can
3054
+ * anchor trendlines to tomorrow, not just to history.
3055
+ * @param {number} time
3056
+ * @returns {number|null}
3057
+ */
3058
+ timeToX(time) {
3059
+ const ly = this._ly;
3060
+ const d = this._data;
3061
+ if (!ly || !d.length || !isNum(time)) return null;
3062
+ const t = time < 1e12 ? time * 1000 : time;
3063
+ const last = d.length - 1;
3064
+ if (t >= d[last].time) {
3065
+ return this._xFor(last + (t - d[last].time) / (this._dt || HOUR));
3066
+ }
3067
+ const i = WickChart._indexForTime(d, t);
3068
+ if (i === 0 && t < d[0].time) {
3069
+ return this._xFor((t - d[0].time) / (this._dt || HOUR));
3070
+ }
3071
+ if (i > 0 && t < d[i].time) {
3072
+ // between two bars: fractional index
3073
+ const a = d[i - 1];
3074
+ const b = d[i];
3075
+ return this._xFor(i - 1 + (t - a.time) / (b.time - a.time || 1));
3076
+ }
3077
+ return this._xFor(i);
3078
+ }
3079
+
3080
+ /**
3081
+ * Bar time (ms) at an x-pixel — the inverse of timeToX, interpolating
3082
+ * between bars and extrapolating beyond both data edges.
3083
+ * @param {number} x
3084
+ * @returns {number|null}
3085
+ */
3086
+ xToTime(x) {
3087
+ const ly = this._ly;
3088
+ const d = this._data;
3089
+ if (!ly || !d.length || !isNum(x)) return null;
3090
+ const idx = this._indexForX(x);
3091
+ const last = d.length - 1;
3092
+ if (idx >= last) return d[last].time + (idx - last) * (this._dt || HOUR);
3093
+ if (idx <= 0) return d[0].time + idx * (this._dt || HOUR);
3094
+ const i0 = Math.floor(idx);
3095
+ const a = d[i0];
3096
+ const b = d[Math.min(i0 + 1, last)];
3097
+ return a.time + (b.time - a.time) * (idx - i0);
3098
+ }
3099
+
3100
+ /**
3101
+ * y-pixel for a price in the main pane (current scale; log-aware).
3102
+ * Unclamped — values off-screen still map, layers decide how to clip.
3103
+ * @param {number} price
3104
+ * @returns {number|null}
3105
+ */
3106
+ priceToY(price) {
3107
+ const ly = this._ly;
3108
+ const scale = this._lastScale;
3109
+ if (!ly || !scale || !isNum(price)) return null;
3110
+ const { min, max, useLog } = scale;
3111
+ const v = useLog ? Math.log10(Math.max(price, 1e-12)) : price;
3112
+ return ly.main.y0 + ((max - v) / (max - min)) * ly.main.h;
3113
+ }
3114
+
3115
+ /**
3116
+ * Price at a y-pixel in the main pane — the inverse of priceToY.
3117
+ * @param {number} y
3118
+ * @returns {number|null}
3119
+ */
3120
+ yToPrice(y) {
3121
+ if (!isNum(y)) return null;
3122
+ return this._yToPrice(y);
3123
+ }
3124
+
2704
3125
  /* ------------------------------------------------------------ *
2705
3126
  * Interaction
2706
3127
  * ------------------------------------------------------------ */
@@ -2712,9 +3133,23 @@ class WickChart extends HTMLElementBase {
2712
3133
 
2713
3134
  _pointerDown(e) {
2714
3135
  if (e.button !== 0) return;
3136
+ this._stopPlayback(); // any touch interrupts the story
2715
3137
  this._canvas.setPointerCapture(e.pointerId);
2716
3138
  const pt = this._localPoint(e);
2717
3139
  this._pointers.set(e.pointerId, pt);
3140
+ if (this._layerClaim) return; // a layer owns a gesture: extra pointers are inert
3141
+ if (this._layers.length) {
3142
+ // layers get first claim on the pointer (hit-test their content);
3143
+ // a claim suppresses pinch/measure/brush/pan for this gesture
3144
+ const layer = this._layerHit(e, pt);
3145
+ if (layer) {
3146
+ this._pan = null;
3147
+ this._measuring = false;
3148
+ this._brushDrag = null;
3149
+ this._layerClaim = { layer, pointerId: e.pointerId };
3150
+ return;
3151
+ }
3152
+ }
2718
3153
  if (this._pointers.size === 2) {
2719
3154
  const [a, b] = [...this._pointers.values()];
2720
3155
  const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
@@ -2726,6 +3161,15 @@ class WickChart extends HTMLElementBase {
2726
3161
  };
2727
3162
  this._pan = null;
2728
3163
  this._measuring = false;
3164
+ this._brushDrag = null;
3165
+ } else if (this._brush && !e.shiftKey && this._data.length) {
3166
+ // brush mode: plain drag selects a bar range (shift still measures)
3167
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
3168
+ this._brushDrag = { i0: idx, i1: idx };
3169
+ this._brushSel = null;
3170
+ this._measuring = false;
3171
+ this._pan = null;
3172
+ this._invalidate();
2729
3173
  } else if (e.shiftKey && this._data.length) {
2730
3174
  // shift+drag → measure tool
2731
3175
  this._measuring = true;
@@ -2746,6 +3190,12 @@ class WickChart extends HTMLElementBase {
2746
3190
  }
2747
3191
 
2748
3192
  _pointerMove(e) {
3193
+ if (this._layerClaim) {
3194
+ if (this._layerClaim.pointerId === e.pointerId) {
3195
+ this._routeLayer(this._layerClaim.layer, e, this._localPoint(e), 'move');
3196
+ }
3197
+ return; // inert while a layer owns the gesture
3198
+ }
2749
3199
  const pt = this._localPoint(e);
2750
3200
  if (this._pointers.has(e.pointerId)) this._pointers.set(e.pointerId, pt);
2751
3201
  const ly = this._ly;
@@ -2777,6 +3227,13 @@ class WickChart extends HTMLElementBase {
2777
3227
  return;
2778
3228
  }
2779
3229
 
3230
+ if (this._brushDrag && this._pointers.has(e.pointerId) && this._data.length) {
3231
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
3232
+ this._brushDrag.i1 = idx;
3233
+ this._invalidate();
3234
+ return;
3235
+ }
3236
+
2780
3237
  if (this._pan && this._pointers.has(e.pointerId) && ly) {
2781
3238
  const dx = pt.x - this._pan.x;
2782
3239
  if (Math.abs(dx) > 3) this._pan.moved = true;
@@ -2798,11 +3255,23 @@ class WickChart extends HTMLElementBase {
2798
3255
  }
2799
3256
 
2800
3257
  _pointerUp(e) {
3258
+ const claim = this._layerClaim;
3259
+ if (claim && claim.pointerId === e.pointerId) {
3260
+ this._layerClaim = null;
3261
+ this._pointers.delete(e.pointerId);
3262
+ this._canvas.classList.remove('grabbing');
3263
+ this._routeLayer(claim.layer, e, this._localPoint(e), e.type === 'pointercancel' ? 'cancel' : 'up');
3264
+ return;
3265
+ }
2801
3266
  const had = this._pointers.delete(e.pointerId);
2802
3267
  if (this._pointers.size < 2) this._pinch = null;
2803
3268
  if (this._pointers.size === 0) {
2804
3269
  this._canvas.classList.remove('grabbing');
2805
- if (this._measuring) {
3270
+ if (this._brushDrag && had) {
3271
+ const b = this._brushDrag;
3272
+ this._brushDrag = null;
3273
+ this._brushFinish(Math.min(b.i0, b.i1), Math.max(b.i0, b.i1));
3274
+ } else if (this._measuring) {
2806
3275
  this._measuring = false;
2807
3276
  if (this._measure) {
2808
3277
  this._measure.done = true;
@@ -2846,6 +3315,7 @@ class WickChart extends HTMLElementBase {
2846
3315
  _wheel(e) {
2847
3316
  const ly = this._ly;
2848
3317
  if (!ly || !this._data.length) return;
3318
+ this._stopPlayback();
2849
3319
  e.preventDefault();
2850
3320
  const pt = this._localPoint(e);
2851
3321
  const dx = e.deltaX;
@@ -2879,6 +3349,18 @@ class WickChart extends HTMLElementBase {
2879
3349
  _keydown(e) {
2880
3350
  const ly = this._ly;
2881
3351
  if (!ly || !this._data.length) return;
3352
+ this._stopPlayback();
3353
+ if (e.key === 'Escape' && this._layerClaim) {
3354
+ const claim = this._layerClaim;
3355
+ this._layerClaim = null;
3356
+ this._routeLayer(claim.layer, { pointerId: claim.pointerId }, null, 'cancel');
3357
+ this._invalidate();
3358
+ return;
3359
+ }
3360
+ if (e.key === 'Escape' && (this._brushSel || this._brushDrag)) {
3361
+ this.clearBrush();
3362
+ return;
3363
+ }
2882
3364
  const d = this._data;
2883
3365
  const key = e.key;
2884
3366
  const step = e.shiftKey ? 10 : 1;
@@ -3067,11 +3549,18 @@ class WickChart extends HTMLElementBase {
3067
3549
  } catch (_) {}
3068
3550
  this._coviewCh = null;
3069
3551
  }
3552
+ clearInterval(this._coviewBeat);
3553
+ this._coviewBeat = 0;
3070
3554
  clearTimeout(this._ghostTimer);
3071
3555
  if (this._ghost) {
3072
3556
  this._ghost = null;
3073
3557
  this._invalidate();
3074
3558
  }
3559
+ if (this._presence && this._presence.peers.size) {
3560
+ const left = this._presence.list();
3561
+ this._presence = new PresenceTracker();
3562
+ this._fire('peers', { peers: [], joined: [], left });
3563
+ }
3075
3564
  const name = this._coviewName;
3076
3565
  if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
3077
3566
  if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
@@ -3080,6 +3569,33 @@ class WickChart extends HTMLElementBase {
3080
3569
  ch.onmessage = (ev) => this._onCoMessage(ev.data);
3081
3570
  this._coviewCh = ch;
3082
3571
  } catch (_) {}
3572
+ // presence: announce immediately, then heartbeat so idle peers stay
3573
+ // warm (and stale ones sweep) without waiting for a pan/zoom
3574
+ this._coviewSendView(true);
3575
+ this._coviewBeat = setInterval(() => {
3576
+ this._coviewSendView(true);
3577
+ const left = this._presence.sweep();
3578
+ if (left.length) {
3579
+ this._fire('peers', { peers: this._presence.list(), joined: [], left });
3580
+ this._invalidate();
3581
+ }
3582
+ }, 4000);
3583
+ }
3584
+
3585
+ /** Broadcast our visible range for presence; throttled unless forced. */
3586
+ _coviewSendView(force) {
3587
+ if (!this._coviewCh) return;
3588
+ const r = this.getVisibleRange();
3589
+ if (!r) return;
3590
+ const now = performance.now();
3591
+ if (!force && now - this._coviewViewLast < 120) return;
3592
+ this._coviewViewLast = now;
3593
+ this._coviewSend({
3594
+ type: 'view',
3595
+ from: r.from,
3596
+ to: r.to,
3597
+ name: this._coviewLabel || null,
3598
+ });
3083
3599
  }
3084
3600
 
3085
3601
  _coviewSend(msg) {
@@ -3090,7 +3606,30 @@ class WickChart extends HTMLElementBase {
3090
3606
  }
3091
3607
 
3092
3608
  _onCoMessage(m) {
3093
- if (!m || m.v !== 1 || m.peer === this._coviewPeer || m.type !== 'cross') return;
3609
+ if (!m || m.v !== 1 || m.peer === this._coviewPeer) return;
3610
+ if (m.type === 'view') {
3611
+ const joined = this._presence.track(m.peer, {
3612
+ range: { from: m.from, to: m.to },
3613
+ name: m.name,
3614
+ });
3615
+ this._invalidate();
3616
+ if (joined) {
3617
+ const p = this._presence.peers.get(m.peer);
3618
+ this._fire('peers', {
3619
+ peers: this._presence.list(),
3620
+ joined: [p ? { ...p, range: p.range && { ...p.range } } : { id: m.peer }],
3621
+ left: [],
3622
+ });
3623
+ }
3624
+ return;
3625
+ }
3626
+ if (m.type === 'bye') {
3627
+ const left = this._presence.drop(m.peer);
3628
+ if (left) this._fire('peers', { peers: this._presence.list(), joined: [], left: [left] });
3629
+ this._invalidate();
3630
+ return;
3631
+ }
3632
+ if (m.type !== 'cross') return;
3094
3633
  if (m.time == null) {
3095
3634
  if (this._ghost) {
3096
3635
  this._ghost = null;
@@ -3120,10 +3659,297 @@ class WickChart extends HTMLElementBase {
3120
3659
  this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
3121
3660
  }
3122
3661
 
3662
+ /**
3663
+ * Live co-view peers: who else is in the room and the time window each
3664
+ * one is looking at — [{ id, name, range: {from, to}, at }], oldest
3665
+ * sighting first. Peers fade out ~12 s after their last sighting.
3666
+ * @returns {object[]}
3667
+ */
3668
+ getPeers() {
3669
+ return this._presence ? this._presence.list() : [];
3670
+ }
3671
+
3672
+ /**
3673
+ * Narrated timeline for a window (default: the visible range) — pivot
3674
+ * highs/lows, volume spikes, gaps, RSI divergences plus derived legs
3675
+ * ("+12.4% over 38 bars"), sorted by index. Pure data, perfect for
3676
+ * caption UIs or the walk player.
3677
+ * chart.narrate(); // visible range
3678
+ * chart.narrate({ from, to }); // times in ms (s accepted)
3679
+ * @param {{from?: number, to?: number}} [range]
3680
+ * @returns {{i: number, time: number, type: string, side: string, note: string,
3681
+ * legPct?: number, legBars?: number}[]}
3682
+ */
3683
+ narrate(range) {
3684
+ const d = this._data;
3685
+ if (!d.length) return [];
3686
+ let i0 = 0;
3687
+ let i1 = d.length - 1;
3688
+ if (range && isNum(range.from) && isNum(range.to)) {
3689
+ i0 = WickChart._indexForTime(d, WickChart._timeToMs(range.from));
3690
+ i1 = WickChart._indexForTime(d, WickChart._timeToMs(range.to));
3691
+ if (i0 > i1) [i0, i1] = [i1, i0];
3692
+ }
3693
+ return narrateWindow(d, i0, i1);
3694
+ }
3695
+
3696
+ /**
3697
+ * Walk the chart through history like a story: the viewport slides
3698
+ * from `from` to `to` while `wick:walk` events announce every step and
3699
+ * the narrator's events (spikes, gaps, pivots, legs) as they're crossed.
3700
+ * Any user interaction — pointer, wheel, keys, double-click — stops it.
3701
+ * chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
3702
+ * chart.addEventListener('wick:walk', (e) => showCaption(e.detail));
3703
+ * // detail: { phase: 'step'|'end'|'stop', index, events: [...], from, to }
3704
+ * @param {{from?: number, to?: number, speed?: number, step?: number}} [opts]
3705
+ * from/to are bar indices (default: last ~500 bars → the end)
3706
+ * @returns {boolean} true when the walk started
3707
+ */
3708
+ walk(opts = {}) {
3709
+ this.stopWalk(true);
3710
+ const d = this._data;
3711
+ if (!d.length || !this._connected) return false;
3712
+ const to = clamp(Math.round(+opts.to || d.length - 1), 0, d.length - 1);
3713
+ const from = clamp(Math.round(opts.from != null ? +opts.from : Math.max(0, to - 500)), 0, to);
3714
+ const span = to - from + 1;
3715
+ // window width: the current viewport, but never more than ~⅓ of the
3716
+ // span (a fully zoomed-out chart would otherwise start at `to`)
3717
+ const widthBars = clamp(
3718
+ Math.min(
3719
+ this._ly ? Math.round(this._ly.plotRight / this._view.spacing) : 120,
3720
+ Math.max(10, Math.ceil(span / 3))
3721
+ ),
3722
+ 10,
3723
+ span
3724
+ );
3725
+ const events = narrateWindow(d, from, to, { pivot: 8 });
3726
+ const speed = clamp(Math.round(+opts.speed || 120), 16, 2000);
3727
+ const step = clamp(Math.round(+opts.step || Math.max(1, Math.round(widthBars / 12))), 1, 500);
3728
+ let cursor = Math.min(from + widthBars - 1, to);
3729
+ let ev = 0;
3730
+ let ended = false;
3731
+ const tick = () => {
3732
+ if (ended) return;
3733
+ this._auto = false;
3734
+ this._view.rightIndex = cursor;
3735
+ this._clampView();
3736
+ this._invalidate();
3737
+ this._emitRange();
3738
+ const hits = [];
3739
+ while (ev < events.length && events[ev].i <= cursor) hits.push(events[ev++]);
3740
+ this._fire('walk', { phase: 'step', index: cursor, events: hits, from, to });
3741
+ if (cursor >= to) {
3742
+ ended = true;
3743
+ clearInterval(this._walkTimer);
3744
+ this._walkTimer = 0;
3745
+ this._fire('walk', { phase: 'end', index: cursor, events: [], from, to });
3746
+ } else {
3747
+ cursor = Math.min(cursor + step, to);
3748
+ }
3749
+ };
3750
+ this._walkTimer = setInterval(tick, speed);
3751
+ tick(); // first step lands immediately
3752
+ return true;
3753
+ }
3754
+
3755
+ /**
3756
+ * Stop the running walk (if any). Fires a final `wick:walk`
3757
+ * { phase: 'stop' } unless called internally.
3758
+ */
3759
+ stopWalk(silent) {
3760
+ if (!this._walkTimer) return;
3761
+ clearInterval(this._walkTimer);
3762
+ this._walkTimer = 0;
3763
+ if (!silent) this._fire('walk', { phase: 'stop' });
3764
+ }
3765
+
3766
+ /**
3767
+ * Commit a brush selection over [i0, i1]: stores it (draws the band
3768
+ * and delta chip) and fires `wick:brush` with the range statistics.
3769
+ * @param {number} i0 first index
3770
+ * @param {number} i1 last index
3771
+ */
3772
+ _brushFinish(i0, i1) {
3773
+ if (!this._data.length) return;
3774
+ const stats = brushStats(this._data, i0, i1);
3775
+ if (!stats) {
3776
+ this._brushSel = null;
3777
+ this._invalidate();
3778
+ return;
3779
+ }
3780
+ this._brushSel = { i0, i1, stats };
3781
+ this._invalidate();
3782
+ this._fire('brush', stats);
3783
+ }
3784
+
3785
+ /** Clear the committed brush selection (if any). Escape does the same. */
3786
+ clearBrush() {
3787
+ if (this._brushSel || this._brushDrag) {
3788
+ this._brushSel = null;
3789
+ this._brushDrag = null;
3790
+ this._invalidate();
3791
+ }
3792
+ }
3793
+
3794
+ /** @returns {object|null} the committed selection { i0, i1, stats } */
3795
+ get brushSelection() {
3796
+ if (!this._brushSel) return null;
3797
+ const { i0, i1, stats } = this._brushSel;
3798
+ return { i0, i1, stats: { ...stats, from: { ...stats.from }, to: { ...stats.to } } };
3799
+ }
3800
+
3801
+ /**
3802
+ * Capture the current chart state as a story scene: view, series type,
3803
+ * indicators, overlays, scenario and risk plan, plus a title/note.
3804
+ * Build guided tours by capturing several and playing them back.
3805
+ * const story = [
3806
+ * chart.captureScene('Overview', 'The full picture'),
3807
+ * { title: 'The breakout', range: { from, to }, indicators: 'sma:20' },
3808
+ * ];
3809
+ * chart.playStory(story);
3810
+ * @param {string} [title]
3811
+ * @param {string} [note]
3812
+ * @returns {object} scene (plain data — snapshot of the moment)
3813
+ */
3814
+ captureScene(title, note) {
3815
+ const scene = {
3816
+ title: title != null ? String(title).slice(0, 60) : '',
3817
+ note: note != null ? String(note).slice(0, 200) : '',
3818
+ range: this.getVisibleRange() || undefined,
3819
+ type: this.getAttribute('type') || 'candles',
3820
+ indicators: this.getAttribute('indicators') || null,
3821
+ };
3822
+ const ovs = this.overlays;
3823
+ if (ovs.length) scene.overlays = ovs;
3824
+ const sc = this.scenario;
3825
+ if (sc) scene.scenario = sc;
3826
+ const rp = this.riskPlan;
3827
+ if (rp) scene.riskPlan = rp;
3828
+ return scene;
3829
+ }
3830
+
3831
+ /** @returns {object[]|null} a copy of the last played story */
3832
+ getStory() {
3833
+ return this._story ? this._story.map((s) => ({ ...s })) : null;
3834
+ }
3835
+
3836
+ /**
3837
+ * Play a story: each scene applies its state (type / indicators /
3838
+ * overlays / scenario / risk plan — set or clear), the camera eases
3839
+ * to its range, then holds for its dwell. `wick:story` events narrate:
3840
+ * { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
3841
+ * Any user interaction — pointer, wheel, keys, double-click — stops it.
3842
+ * @param {object[]} story scenes (invalid entries dropped, max 20)
3843
+ * @param {{dwell?: number, panMs?: number, loop?: boolean}} [opts]
3844
+ * panMs clamps 100–5000 (default 900); loop replays forever
3845
+ * @returns {boolean} true when playback started
3846
+ */
3847
+ playStory(story, opts = {}) {
3848
+ this.stopStory(true);
3849
+ const scenes = sceneList(story);
3850
+ if (!scenes.length || !this._data.length || !this._connected) return false;
3851
+ const token = ++this._storyToken;
3852
+ this._story = scenes;
3853
+ const panMs = clamp(Math.round(+opts.panMs || 900), 100, 5000);
3854
+ const loop = opts.loop === true;
3855
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
3856
+ const run = async () => {
3857
+ let idx = 0;
3858
+ while (token === this._storyToken) {
3859
+ const sc = scenes[idx];
3860
+ this._fire('story', {
3861
+ phase: 'scene', index: idx, total: scenes.length,
3862
+ scene: sc, title: sc.title, note: sc.note,
3863
+ });
3864
+ this._applyScene(sc);
3865
+ const target = this._sceneTarget(sc);
3866
+ if (target) await this._storyTween(target, panMs, token);
3867
+ if (token !== this._storyToken) return;
3868
+ await wait(sc.dwell);
3869
+ if (token !== this._storyToken) return;
3870
+ idx++;
3871
+ if (idx >= scenes.length) {
3872
+ if (loop) idx = 0;
3873
+ else {
3874
+ this._fire('story', { phase: 'end', index: idx - 1, total: scenes.length });
3875
+ return;
3876
+ }
3877
+ }
3878
+ }
3879
+ };
3880
+ run();
3881
+ return true;
3882
+ }
3883
+
3884
+ /**
3885
+ * Stop story playback (if running). Fires a final `wick:story`
3886
+ * { phase: 'stop' } unless called internally.
3887
+ */
3888
+ stopStory(silent) {
3889
+ if (!this._storyToken) return;
3890
+ this._storyToken = 0;
3891
+ if (!silent) this._fire('story', { phase: 'stop' });
3892
+ }
3893
+
3894
+ /** Apply a scene's state (only the fields it carries). */
3895
+ _applyScene(sc) {
3896
+ if (sc.type) this.setAttribute('type', sc.type);
3897
+ if (sc.indicators != null) this.setAttribute('indicators', sc.indicators);
3898
+ if (sc.overlays) this.setOverlays(sc.overlays);
3899
+ if (sc.scenario === 'clear') this.clearScenario();
3900
+ else if (sc.scenario) this.setScenario(sc.scenario);
3901
+ if (sc.riskPlan === 'clear') this.clearRiskPlan();
3902
+ else if (sc.riskPlan) this.setRiskPlan(sc.riskPlan);
3903
+ }
3904
+
3905
+ /** Map a scene's time range to bar indices (null when not applicable). */
3906
+ _sceneTarget(sc) {
3907
+ if (!sc.range || !this._data.length) return null;
3908
+ let i0 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.from));
3909
+ let i1 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.to));
3910
+ if (i0 > i1) [i0, i1] = [i1, i0];
3911
+ return i1 - i0 >= 2 ? { i0, i1 } : null;
3912
+ }
3913
+
3914
+ /** Ease the viewport to { i0, i1 } over `ms`; resolves early if the
3915
+ * token changes (superseded or stopped). rAF when available. */
3916
+ _storyTween(target, ms, token) {
3917
+ const ly = this._ly;
3918
+ const d = this._data;
3919
+ if (!ly || !d.length) return Promise.resolve();
3920
+ const sp1 = clamp(ly.plotRight / (target.i1 - target.i0), this._minSpacing(), WickChart._MAX_SP);
3921
+ const from = { right: this._view.rightIndex, sp: this._view.spacing };
3922
+ const to = { right: target.i1, sp: sp1 };
3923
+ const t0 = performance.now();
3924
+ this._auto = false;
3925
+ return new Promise((resolve) => {
3926
+ const step = () => {
3927
+ if (token !== this._storyToken) return resolve();
3928
+ const e = easeInOutCubic(Math.min(1, (performance.now() - t0) / ms));
3929
+ this._view.rightIndex = from.right + (to.right - from.right) * e;
3930
+ this._view.spacing = from.sp + (to.sp - from.sp) * e;
3931
+ this._clampView();
3932
+ this._invalidate();
3933
+ this._emitRange();
3934
+ if (e >= 1) resolve();
3935
+ else if (typeof requestAnimationFrame === 'function') requestAnimationFrame(step);
3936
+ else setTimeout(step, 16);
3937
+ };
3938
+ step();
3939
+ });
3940
+ }
3941
+
3942
+ /** Interrupt narrated playback (walk / story) on user input. */
3943
+ _stopPlayback() {
3944
+ if (this._walkTimer) this.stopWalk();
3945
+ if (this._storyToken) this.stopStory();
3946
+ }
3947
+
3123
3948
  _emitRange() {
3124
3949
  const r = this.getVisibleRange();
3125
3950
  if (!r) return;
3126
3951
  this._fire('range', r);
3952
+ if (this._coviewCh) this._coviewSendView();
3127
3953
  }
3128
3954
  }
3129
3955