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 +35 -0
- package/core.mjs +56 -0
- package/navigator.mjs +201 -0
- package/package.json +37 -0
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
|
+
}
|