wickchart 1.3.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/README.md CHANGED
@@ -633,6 +633,60 @@ WickChart.registerIndicator('vwap', {
633
633
  chart.indicators = 'vwap';
634
634
  ```
635
635
 
636
+ ### Plugin layers — extend without forking
637
+
638
+ `addLayer()` is the whole extension surface: an external draw hook that paints
639
+ into the render pipeline (above chart content, under the crosshair) and can
640
+ claim pointer gestures so drags reach your code instead of panning the chart.
641
+ Four public coordinate transforms — `timeToX`/`xToTime` (extrapolating past
642
+ the last bar into future space) and `priceToY`/`yToPrice` — anchor your
643
+ content in data space so it rides along with zoom and pan:
644
+
645
+ ```js
646
+ chart.addLayer({
647
+ id: 'flags',
648
+ draw(api) {
649
+ const x = api.timeToX(t), y = api.priceToY(p); // anchors, not pixels
650
+ api.ctx.fillStyle = api.palette.accent;
651
+ // …paint in CSS pixels
652
+ },
653
+ onPointer(ev) {
654
+ if (ev.type === 'down' && hitsMyContent(ev)) return true; // claim the drag
655
+ },
656
+ });
657
+ chart.requestDraw(); // repaint hook for interactive layers
658
+ chart.removeLayer('flags'); // detach by handle or id
659
+ ```
660
+
661
+ A claimed gesture delivers `move`/`up` (and `cancel` on Escape) to the layer
662
+ while the chart suppresses pan/brush/measure. Markers, watermarks, signal
663
+ badges — or a whole drawing toolkit — plug in without the core growing a
664
+ single tool. The main entry is covered by a CI gzip budget (64 KB) so it
665
+ stays that way.
666
+
667
+ ### Drawings — the `wickchart-draw` plugin
668
+
669
+ The first official plugin: TradingView-style drawing tools as opt-in bytes
670
+ (~8 KB gz, own CI budget). Trendlines (segment/ray), horizontal levels,
671
+ rectangles, fibonacci retracements and text — all plain `{ time, price }`
672
+ data that rides zoom & pan, survives reloads, extrapolates into future
673
+ space, and serializes to JSON. Anchors magnet-snap to bar times and OHLC.
674
+
675
+ ```js
676
+ import { attachDrawings } from 'wickchart-draw';
677
+
678
+ const draw = attachDrawings(chart);
679
+ draw.setTool('trendline'); // drag to draw; setTool(null) = select/move mode
680
+ draw.getDrawings(); // → JSON array (save it); setDrawings(saved)
681
+ draw.undo(); draw.clear();
682
+ chart.addEventListener('wick:drawings', (e) => save(e.detail.drawings));
683
+ ```
684
+
685
+ Select mode: click a drawing to select it, drag to move, drag the square
686
+ handles to re-anchor, `Delete` removes, `Esc` cancels a gesture; clicks on
687
+ empty space fall through to the chart. Peer dependency: wickchart ≥ 1.4.
688
+ See the live playground in the docs (Drawings section).
689
+
636
690
  ## Methods
637
691
 
638
692
  | Method | Description |
@@ -647,6 +701,10 @@ chart.indicators = 'vwap';
647
701
  | `getDataWindow()` | → AI-ready summary of the visible window (see below) |
648
702
  | `getState()` | → serializable snapshot (type, indicators, view, positions, alerts) |
649
703
  | `setState(state)` | Apply a snapshot; a pending view applies after the next `setData()` |
704
+ | `addLayer(layer)` / `removeLayer(idOrHandle)` | Register/detach a plugin layer (draw hook + optional pointer claim) |
705
+ | `requestDraw()` | Repaint on the next frame (interactive layers) |
706
+ | `timeToX(t)` / `xToTime(x)` | Bar time ⇄ x-pixel; extrapolates into future space |
707
+ | `priceToY(p)` / `yToPrice(y)` | Price ⇄ y-pixel in the main pane (log-aware) |
650
708
 
651
709
  ### Infinite history (`loadMore`)
652
710
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wickchart",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "<wick-chart> — a modern, dependency-free financial charting web component. Candles, line & area charts, crosshair, zoom/pan, indicators (incl. a safe expression mini-language), live streaming via <wick-feed>, theming.",
5
5
  "type": "module",
6
6
  "main": "src/wick-chart.js",
@@ -42,10 +42,10 @@
42
42
  },
43
43
  "scripts": {
44
44
  "dev": "npx --yes serve . -l 5173",
45
- "test": "node --test \"tests/*.test.mjs\"",
45
+ "test": "node --test \"tests/*.test.mjs\" \"plugins/draw/tests/*.test.mjs\"",
46
46
  "build:types": "node -e \"require('fs').rmSync('types', { recursive: true, force: true });\" && tsc -p tsconfig.json",
47
47
  "prepack": "npm run build:types",
48
- "ci": "npm run build:types && npm test && node --check src/wick-chart.js && node --check src/wick-feed.js && node --check src/core.js && node --check src/react.js && node --check src/react-core.js && node --check demo/app.js"
48
+ "ci": "npm run build:types && npm test && node --check src/wick-chart.js && node --check src/wick-feed.js && node --check src/core.js && node --check src/react.js && node --check src/react-core.js && node --check demo/app.js && node --check plugins/draw/core.mjs && node --check plugins/draw/draw.mjs"
49
49
  },
50
50
  "keywords": [
51
51
  "chart",
package/src/wick-chart.js CHANGED
@@ -235,6 +235,11 @@ class WickChart extends HTMLElementBase {
235
235
  this._pan = null;
236
236
  this._pinch = null;
237
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
+
238
243
  // history backfill state (onloadmore declared as a class field above)
239
244
  this._loadingMore = false;
240
245
  this._noMore = false;
@@ -303,6 +308,7 @@ class WickChart extends HTMLElementBase {
303
308
  this._connected = false;
304
309
  this.stopWalk(true);
305
310
  this.stopStory(true);
311
+ this._layerClaim = null;
306
312
  if (this._coviewCh) {
307
313
  this._coviewSend({ type: 'bye' });
308
314
  try {
@@ -2532,6 +2538,9 @@ class WickChart extends HTMLElementBase {
2532
2538
  );
2533
2539
  }
2534
2540
 
2541
+ /* plugin layers — above chart content, under the pointer-following UI */
2542
+ if (this._layers.length) this._drawLayers(ctx, pal, ly, d);
2543
+
2535
2544
  /* crosshair */
2536
2545
  if (this._hover && this._hover.index < d.length) {
2537
2546
  const h = this._hover;
@@ -2912,6 +2921,207 @@ class WickChart extends HTMLElementBase {
2912
2921
  poss.innerHTML = html;
2913
2922
  }
2914
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
+
2915
3125
  /* ------------------------------------------------------------ *
2916
3126
  * Interaction
2917
3127
  * ------------------------------------------------------------ */
@@ -2927,6 +3137,19 @@ class WickChart extends HTMLElementBase {
2927
3137
  this._canvas.setPointerCapture(e.pointerId);
2928
3138
  const pt = this._localPoint(e);
2929
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
+ }
2930
3153
  if (this._pointers.size === 2) {
2931
3154
  const [a, b] = [...this._pointers.values()];
2932
3155
  const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
@@ -2967,6 +3190,12 @@ class WickChart extends HTMLElementBase {
2967
3190
  }
2968
3191
 
2969
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
+ }
2970
3199
  const pt = this._localPoint(e);
2971
3200
  if (this._pointers.has(e.pointerId)) this._pointers.set(e.pointerId, pt);
2972
3201
  const ly = this._ly;
@@ -3026,6 +3255,14 @@ class WickChart extends HTMLElementBase {
3026
3255
  }
3027
3256
 
3028
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
+ }
3029
3266
  const had = this._pointers.delete(e.pointerId);
3030
3267
  if (this._pointers.size < 2) this._pinch = null;
3031
3268
  if (this._pointers.size === 0) {
@@ -3113,6 +3350,13 @@ class WickChart extends HTMLElementBase {
3113
3350
  const ly = this._ly;
3114
3351
  if (!ly || !this._data.length) return;
3115
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
+ }
3116
3360
  if (e.key === 'Escape' && (this._brushSel || this._brushDrag)) {
3117
3361
  this.clearBrush();
3118
3362
  return;
@@ -172,6 +172,12 @@ declare class WickChart extends HTMLElementBase {
172
172
  idxAtMid: number;
173
173
  midX: number;
174
174
  };
175
+ _layers: any[];
176
+ _layerClaim: {
177
+ layer: any;
178
+ pointerId: any;
179
+ };
180
+ _layerSeq: number;
175
181
  _loadingMore: boolean;
176
182
  _noMore: boolean;
177
183
  _positions: any[];
@@ -623,6 +629,75 @@ declare class WickChart extends HTMLElementBase {
623
629
  _updateLegend(): void;
624
630
  /** Position P&L chips (top-right HTML overlay). */
625
631
  _updateHud(): void;
632
+ /**
633
+ * Register a plugin layer. `layer.draw(api)` runs on every render, above
634
+ * chart content and under the crosshair; `layer.onPointer(pev)` is asked
635
+ * first on pointerdown and claims the gesture by returning true — the
636
+ * layer then receives that pointer's move/up/cancel events (plus a
637
+ * 'cancel' on Escape) and the chart suppresses its own pan/measure/brush
638
+ * for the duration.
639
+ * @param {{id?: string, draw: Function, onPointer?: Function}} layer
640
+ * @returns {object|null} the normalized layer handle (with `id`), or null
641
+ * if the layer was rejected (no draw fn, or 16 layers already added)
642
+ */
643
+ addLayer(layer: {
644
+ id?: string;
645
+ draw: Function;
646
+ onPointer?: Function;
647
+ }): object | null;
648
+ /**
649
+ * Remove a layer added via addLayer (pass the returned handle or its id).
650
+ * @param {object|string} idOrLayer
651
+ * @returns {boolean} true if a layer was removed
652
+ */
653
+ removeLayer(idOrLayer: object | string): boolean;
654
+ /** Ask for a repaint on the next frame (interactive layers call this). */
655
+ requestDraw(): void;
656
+ /** Paint every registered layer. Called from _render with live state. */
657
+ _drawLayers(ctx: any, pal: any, ly: any, d: any): void;
658
+ /** Ask layers, in order, whether one claims this pointerdown. */
659
+ _layerHit(e: any, pt: any): any;
660
+ /** Deliver a pointer event to a claiming layer; never throws outward. */
661
+ _routeLayer(layer: any, e: any, pt: any, type: any): void;
662
+ _layerPointerEvent(e: any, pt: any, type: any): {
663
+ type: any;
664
+ x: any;
665
+ y: any;
666
+ pointerId: any;
667
+ button: any;
668
+ shiftKey: boolean;
669
+ ctrlKey: boolean;
670
+ altKey: boolean;
671
+ metaKey: boolean;
672
+ };
673
+ /**
674
+ * x-pixel for a bar time (ms or s, auto-detected). Extrapolates past the
675
+ * last bar into future space using the median bar interval, so layers can
676
+ * anchor trendlines to tomorrow, not just to history.
677
+ * @param {number} time
678
+ * @returns {number|null}
679
+ */
680
+ timeToX(time: number): number | null;
681
+ /**
682
+ * Bar time (ms) at an x-pixel — the inverse of timeToX, interpolating
683
+ * between bars and extrapolating beyond both data edges.
684
+ * @param {number} x
685
+ * @returns {number|null}
686
+ */
687
+ xToTime(x: number): number | null;
688
+ /**
689
+ * y-pixel for a price in the main pane (current scale; log-aware).
690
+ * Unclamped — values off-screen still map, layers decide how to clip.
691
+ * @param {number} price
692
+ * @returns {number|null}
693
+ */
694
+ priceToY(price: number): number | null;
695
+ /**
696
+ * Price at a y-pixel in the main pane — the inverse of priceToY.
697
+ * @param {number} y
698
+ * @returns {number|null}
699
+ */
700
+ yToPrice(y: number): number | null;
626
701
  _localPoint(e: any): {
627
702
  x: number;
628
703
  y: number;