wickchart-navigator 0.1.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 ADDED
@@ -0,0 +1,35 @@
1
+ # wickchart-navigator
2
+
3
+ The range-slider strip for [wickchart](https://github.com/benyblack/wickchart),
4
+ as an opt-in plugin layer — the most-missed TradingView affordance. A
5
+ silhouette of the whole dataset docks at the bottom of the canvas with a
6
+ draggable viewport window: drag the window to pan, grab an edge to resize,
7
+ click outside it to jump. Panning/zooming the chart moves the window and
8
+ vice versa — both stay in sync live.
9
+
10
+ Requires wickchart ≥ **1.6.0** with the `insetBottom` dock hook (added in
11
+ 1.6.0, the same release as this plugin): the largest `insetBottom` declared
12
+ by any layer reserves a strip at the bottom of the canvas — panes and the
13
+ time axis shrink above it, and the strip is handed to layers as
14
+ `api.layout.dock`. On older charts the plugin degrades silently (the layer
15
+ renders nothing).
16
+
17
+ ```js
18
+ npm install wickchart wickchart-navigator // the plugin is a separate package
19
+
20
+ import 'wickchart'; // the chart itself
21
+ import { attachNavigator } from 'wickchart-navigator';
22
+
23
+ const chart = document.querySelector('wick-chart');
24
+ const nav = attachNavigator(chart, { height: 46 }); // strip height, 24..120
25
+ nav.detach(); // remove the strip again
26
+ ```
27
+
28
+ - **Window interactions**: drag the body to pan, the 6px edges to resize
29
+ (min span: 5 bars), click outside the window to center it on the click.
30
+ - **Two-way sync**: the window follows chart pan/zoom every frame; drags
31
+ apply through the chart's public `setVisibleRange`.
32
+ - **Performance**: the silhouette is O(n) once per (dataset, strip width)
33
+ and cached; renders are pure repaints.
34
+
35
+ Peer dependency: wickchart ≥ 1.6.0 (the dock hook).
package/core.mjs ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * wickchart-navigator — pure navigator model: price-profile downsampling and
3
+ * viewport-window math. No DOM, no canvas; plain data in / data out.
4
+ */
5
+
6
+ /**
7
+ * Downsample `data` (sorted bars) into `count` buckets of {lo, hi} over the
8
+ * bar ranges — the silhouette behind the viewport window. Buckets without
9
+ * bars are null. This is O(n) per call; callers cache by (len, lastTime).
10
+ */
11
+ export function profile(data, count) {
12
+ const n = data.length;
13
+ const out = new Array(count > 0 ? Math.floor(count) : 0).fill(null);
14
+ if (n < 2 || !out.length) return out;
15
+ const t0 = data[0].time;
16
+ const span = data[n - 1].time - t0 || 1;
17
+ for (const b of data) {
18
+ const bi = Math.min(out.length - 1, Math.floor(((b.time - t0) / span) * out.length));
19
+ const cur = out[bi];
20
+ if (!cur) out[bi] = { lo: b.low, hi: b.high };
21
+ else {
22
+ if (b.low < cur.lo) cur.lo = b.low;
23
+ if (b.high > cur.hi) cur.hi = b.high;
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /**
30
+ * Viewport window as fractions of the full data time span: [f0, f1] clamped
31
+ * to [0, 1] (times outside the data edges — the future, or panned-off
32
+ * history — collapse to the edges).
33
+ */
34
+ export function windowFractions(tFirst, tLast, t0, t1) {
35
+ const span = tLast - tFirst || 1;
36
+ const clamp01 = (v) => Math.max(0, Math.min(1, v));
37
+ const f0 = clamp01((t0 - tFirst) / span);
38
+ const f1 = clamp01((t1 - tFirst) / span);
39
+ return f1 > f0 ? [f0, f1] : null;
40
+ }
41
+
42
+ /**
43
+ * Apply a drag to window fractions: `mode` 'move' shifts the window so the
44
+ * grab offset stays under the pointer; 'l'/'r' move one edge. The result
45
+ * keeps a minimum span (minFrac) and stays inside [0, 1].
46
+ */
47
+ export function dragWindow(f0, f1, mode, f, grab, minFrac = 0.01) {
48
+ const span = f1 - f0;
49
+ if (mode === 'move') {
50
+ const n0 = Math.max(0, Math.min(1 - span, f - grab));
51
+ return [n0, n0 + span];
52
+ }
53
+ if (mode === 'l') return [Math.max(0, Math.min(f1 - minFrac, f)), f1];
54
+ if (mode === 'r') return [f0, Math.min(1, Math.max(f0 + minFrac, f))];
55
+ return [f0, f1];
56
+ }
package/navigator.mjs ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * wickchart-navigator — the range-slider strip as a wickchart plugin layer:
3
+ * a silhouette of the full dataset docked at the bottom of the canvas with a
4
+ * draggable viewport window (drag to pan, grab an edge to resize, click
5
+ * outside the window to jump). Builds on the public layer API plus the
6
+ * `insetBottom` dock hook — the core chart shrinks above the strip.
7
+ *
8
+ * import { attachNavigator } from 'wickchart-navigator';
9
+ * const nav = attachNavigator(chart, { height: 46 });
10
+ * nav.detach();
11
+ *
12
+ * Nothing else to configure: panning/zooming the chart moves/resizes the
13
+ * window, dragging the window pans the chart — both stay in sync live.
14
+ */
15
+
16
+ import { profile, windowFractions, dragWindow } from './core.mjs';
17
+
18
+ const EDGE = 6; // px grab zone at each window edge
19
+ const MIN_SPAN_BARS = 5; // the window never collapses below this many bars
20
+
21
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
22
+
23
+ export function attachNavigator(chart, opts = {}) {
24
+ return new Navigator(chart, opts);
25
+ }
26
+
27
+ export class Navigator {
28
+ constructor(chart, opts = {}) {
29
+ if (!chart || typeof chart.addLayer !== 'function' || typeof chart.setVisibleRange !== 'function') {
30
+ throw new TypeError('attachNavigator(chart): the chart element is required');
31
+ }
32
+ this._chart = chart;
33
+ this._height = Math.max(24, Math.min(120, Math.round(opts.height || 46)));
34
+ this._drag = null; // { mode, grab }
35
+ this._cache = { key: null, prof: null, t0: 0, t1: 1 }; // silhouette cache
36
+ this._ly = null; // last render's layout (dock, W, plotRight)
37
+ this._layer = {
38
+ id: 'wick-navigator',
39
+ insetBottom: this._height,
40
+ draw: (api) => this._render(api),
41
+ onPointer: (ev) => this._onPointer(ev),
42
+ };
43
+ chart.addLayer(this._layer);
44
+ }
45
+
46
+ get height() {
47
+ return this._height;
48
+ }
49
+
50
+ detach() {
51
+ try {
52
+ this._chart.removeLayer('wick-navigator');
53
+ } catch (_) {}
54
+ this._chart = null;
55
+ }
56
+
57
+ /* ---------------- pointer ---------------- */
58
+
59
+ _onPointer(ev) {
60
+ if (!this._chart || ev.y == null) return false;
61
+ const dock = this._ly && this._ly.dock;
62
+ const W = this._ly && this._ly.W;
63
+ if (!dock || !W || ev.y < dock.y0) return false; // above the strip → chart keeps it
64
+
65
+ const f = Math.max(0, Math.min(1, ev.x / W));
66
+ const [f0, f1] = this._currentFractions();
67
+
68
+ if (ev.type === 'down') {
69
+ const ex = ev.x;
70
+ if (Math.abs(ex - f0 * W) <= EDGE) this._drag = { mode: 'l' };
71
+ else if (Math.abs(ex - f1 * W) <= EDGE) this._drag = { mode: 'r' };
72
+ else if (f > f0 && f < f1) this._drag = { mode: 'move', grab: f - f0 };
73
+ else {
74
+ // click outside the window: jump so it centers on the click
75
+ const half = (f1 - f0) / 2;
76
+ const [a, b] = dragWindow(f0, f1, 'move', f, half);
77
+ this._drag = { mode: 'move', grab: half };
78
+ this._apply(a, b);
79
+ return true;
80
+ }
81
+ return true;
82
+ }
83
+ if (!this._drag) return false; // stray move/up outside a gesture
84
+ if (ev.type === 'cancel') {
85
+ this._drag = null;
86
+ return true;
87
+ }
88
+ if (ev.type === 'move') {
89
+ const [a, b] = dragWindow(f0, f1, this._drag.mode, f, this._drag.grab, this._minFrac());
90
+ if (a !== f0 || b !== f1) this._apply(a, b);
91
+ return true;
92
+ }
93
+ if (ev.type === 'up') {
94
+ this._drag = null;
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ _currentFractions() {
101
+ const d = this._chart.data;
102
+ const t1 = d.length ? d[d.length - 1].time : 1;
103
+ const t0v = this._chart.xToTime(0);
104
+ const t1v = this._chart.xToTime(this._ly ? this._ly.plotRight : 0);
105
+ return windowFractions(d.length ? d[0].time : 0, t1, t0v, t1v) || [0, 1];
106
+ }
107
+
108
+ _minFrac() {
109
+ const d = this._chart.data;
110
+ return d.length ? Math.min(0.5, MIN_SPAN_BARS / d.length) : 0.01;
111
+ }
112
+
113
+ /** fractions → epoch window → the chart's public setVisibleRange. */
114
+ _apply(f0, f1) {
115
+ const d = this._chart.data;
116
+ if (!d.length) return;
117
+ const tFirst = d[0].time;
118
+ const span = d[d.length - 1].time - tFirst || 1;
119
+ this._chart.setVisibleRange({
120
+ from: tFirst + f0 * span,
121
+ to: tFirst + f1 * span,
122
+ });
123
+ }
124
+
125
+ /* ---------------- render ---------------- */
126
+
127
+ _render(api) {
128
+ const { ctx, palette: pal, data } = api;
129
+ const ly = api.layout;
130
+ this._ly = ly;
131
+ const dock = ly.dock;
132
+ if (!dock || !data.length) return;
133
+ const W = ly.W;
134
+ const tFirst = data[0].time;
135
+ const tLast = data[data.length - 1].time;
136
+
137
+ // silhouette: O(n) once per (dataset, strip width) — cached otherwise
138
+ const key = data.length + ':' + tLast + ':' + W;
139
+ if (this._cache.key !== key) {
140
+ this._cache = { key, prof: profile(data, W), t0: tFirst, t1: tLast };
141
+ }
142
+ const prof = this._cache.prof;
143
+
144
+ let lo = Infinity;
145
+ let hi = -Infinity;
146
+ for (const b of prof) {
147
+ if (b) {
148
+ if (b.lo < lo) lo = b.lo;
149
+ if (b.hi > hi) hi = b.hi;
150
+ }
151
+ }
152
+ if (!isNum(lo) || !isNum(hi)) return;
153
+
154
+ ctx.save();
155
+ ctx.beginPath();
156
+ ctx.rect(0, dock.y0, W, dock.h);
157
+ ctx.clip();
158
+
159
+ // opaque backing — the strip owns its pixels (crosshair et al. stay out)
160
+ ctx.fillStyle = pal.bg;
161
+ ctx.fillRect(0, dock.y0, W, dock.h);
162
+
163
+ // price separator + silhouette
164
+ ctx.strokeStyle = pal.grid;
165
+ ctx.lineWidth = 1;
166
+ ctx.beginPath();
167
+ ctx.moveTo(0, Math.round(dock.y0) + 0.5);
168
+ ctx.lineTo(W, Math.round(dock.y0) + 0.5);
169
+ ctx.stroke();
170
+ ctx.strokeStyle = pal.text;
171
+ ctx.globalAlpha = 0.35;
172
+ const pad = 4;
173
+ const yOf = (p) => dock.y0 + pad + ((hi - p) / (hi - lo || 1)) * (dock.h - 2 * pad);
174
+ ctx.beginPath();
175
+ for (let i = 0; i < prof.length; i++) {
176
+ const b = prof[i];
177
+ if (!b) continue;
178
+ ctx.moveTo(i + 0.5, Math.round(yOf(b.lo)) + 0.5);
179
+ ctx.lineTo(i + 0.5, Math.round(yOf(b.hi)) + 0.5);
180
+ }
181
+ ctx.stroke();
182
+ ctx.globalAlpha = 1;
183
+
184
+ // viewport window
185
+ const t0v = api.xToTime(0);
186
+ const t1v = api.xToTime(ly.plotRight);
187
+ const frac = windowFractions(tFirst, tLast, t0v, t1v);
188
+ if (frac) {
189
+ const x0 = frac[0] * W;
190
+ const x1 = frac[1] * W;
191
+ ctx.fillStyle = pal.accent;
192
+ ctx.globalAlpha = 0.14;
193
+ ctx.fillRect(x0, dock.y0 + 1, x1 - x0, dock.h - 2);
194
+ ctx.globalAlpha = 0.8;
195
+ ctx.fillRect(x0 - 1.5, dock.y0 + 1, 3, dock.h - 2);
196
+ ctx.fillRect(x1 - 1.5, dock.y0 + 1, 3, dock.h - 2);
197
+ ctx.globalAlpha = 1;
198
+ }
199
+ ctx.restore();
200
+ }
201
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "wickchart-navigator",
3
+ "version": "0.1.0",
4
+ "description": "Range-slider navigator for wickchart — a full-dataset silhouette docked below the chart with a draggable viewport window (drag to pan, edges to resize). Opt-in plugin, zero dependencies.",
5
+ "type": "module",
6
+ "main": "navigator.mjs",
7
+ "module": "navigator.mjs",
8
+ "exports": {
9
+ ".": "./navigator.mjs",
10
+ "./core": "./core.mjs"
11
+ },
12
+ "files": [
13
+ "navigator.mjs",
14
+ "core.mjs",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "chart",
19
+ "charting",
20
+ "navigator",
21
+ "range-slider",
22
+ "scrollbar",
23
+ "trading",
24
+ "web-component",
25
+ "wickchart",
26
+ "canvas"
27
+ ],
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "wickchart": ">=1.6.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "wickchart": {
34
+ "optional": true
35
+ }
36
+ }
37
+ }