trendkit 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/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/chunk-AEC4653J.js +1220 -0
- package/dist/index.d.ts +501 -0
- package/dist/index.js +1 -0
- package/dist/react/index.d.ts +35 -0
- package/dist/react/index.js +29 -0
- package/package.json +79 -0
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
import { LineSeries, HistogramSeries } from 'lightweight-charts';
|
|
2
|
+
|
|
3
|
+
// src/core/interaction.ts
|
|
4
|
+
var InteractionController = class {
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.options = options;
|
|
7
|
+
this.state = { name: "idle" };
|
|
8
|
+
this.activeTool = null;
|
|
9
|
+
this.savedScroll = true;
|
|
10
|
+
this.savedScale = true;
|
|
11
|
+
this.onPointerDown = (event) => {
|
|
12
|
+
if (event.button !== 0) return;
|
|
13
|
+
const at = this.local(event);
|
|
14
|
+
if (this.activeTool !== null) {
|
|
15
|
+
this.beginPlacing(this.activeTool, at);
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const hit = this.options.hits.hitTestDetailed(at);
|
|
20
|
+
if (hit.kind === "none") {
|
|
21
|
+
this.options.store.select(null);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const drawing = this.options.store.get(hit.id);
|
|
25
|
+
if (drawing === void 0) return;
|
|
26
|
+
this.options.store.select(hit.id);
|
|
27
|
+
this.suppressChartGestures();
|
|
28
|
+
this.state = {
|
|
29
|
+
name: "dragging",
|
|
30
|
+
id: hit.id,
|
|
31
|
+
handle: hit.kind === "handle" ? hit.index : null,
|
|
32
|
+
from: this.toPoint(at, false),
|
|
33
|
+
grabbed: drawing.points.map((p) => ({ ...p }))
|
|
34
|
+
};
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
};
|
|
37
|
+
this.onPointerMove = (event) => {
|
|
38
|
+
const at = this.local(event);
|
|
39
|
+
if (this.state.name === "idle") {
|
|
40
|
+
if (this.activeTool === null) {
|
|
41
|
+
const hit = this.options.hits.hitTestDetailed(at);
|
|
42
|
+
this.element.style.cursor = hit.kind === "handle" ? "grab" : hit.kind === "body" ? "move" : "";
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (this.state.name === "placing") {
|
|
47
|
+
const point = this.toPoint(at, event.shiftKey);
|
|
48
|
+
const drawing2 = this.options.store.get(this.state.id);
|
|
49
|
+
if (drawing2 === void 0) return;
|
|
50
|
+
const points2 = [...drawing2.points];
|
|
51
|
+
const moving = Math.min(this.state.placed, this.options.pointCount(this.state.tool) - 1);
|
|
52
|
+
points2[moving] = point;
|
|
53
|
+
this.options.store.update(this.state.id, { points: points2 }, { undoable: false });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const now = this.toPoint(at, event.shiftKey);
|
|
57
|
+
const drawing = this.options.store.get(this.state.id);
|
|
58
|
+
if (drawing === void 0) return;
|
|
59
|
+
if (this.state.handle !== null) {
|
|
60
|
+
const points2 = [...drawing.points];
|
|
61
|
+
points2[this.state.handle] = now;
|
|
62
|
+
this.options.store.update(this.state.id, { points: points2 }, { undoable: false });
|
|
63
|
+
} else {
|
|
64
|
+
const dPrice = now.price - this.state.from.price;
|
|
65
|
+
const dLogical = now.logical - this.state.from.logical;
|
|
66
|
+
const points2 = this.state.grabbed.map((p) => this.shift(p, dLogical, dPrice));
|
|
67
|
+
this.options.store.update(this.state.id, { points: points2 }, { undoable: false });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
this.onPointerUp = () => {
|
|
71
|
+
if (this.state.name === "placing") {
|
|
72
|
+
const needed = this.options.pointCount(this.state.tool);
|
|
73
|
+
const placed = this.state.placed + 1;
|
|
74
|
+
if (placed >= needed) {
|
|
75
|
+
if (this.options.isTransient(this.state.tool)) {
|
|
76
|
+
this.setActiveTool(null);
|
|
77
|
+
this.options.store.remove(this.state.id, { undoable: false });
|
|
78
|
+
} else {
|
|
79
|
+
this.options.store.update(this.state.id, { draft: false }, { undoable: false });
|
|
80
|
+
this.finishPlacing(this.state.id);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
this.state = { ...this.state, placed };
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
} else if (this.state.name === "dragging") {
|
|
87
|
+
this.state = { name: "idle" };
|
|
88
|
+
this.restoreChartGestures();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.state = { name: "idle" };
|
|
92
|
+
this.restoreChartGestures();
|
|
93
|
+
};
|
|
94
|
+
// -- keyboard -------------------------------------------------------------
|
|
95
|
+
this.onKeyDown = (event) => {
|
|
96
|
+
const target = event.target;
|
|
97
|
+
if (target !== null && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
101
|
+
if (event.key === "Escape") {
|
|
102
|
+
if (this.state.name === "placing") {
|
|
103
|
+
this.options.store.remove(this.state.id);
|
|
104
|
+
this.state = { name: "idle" };
|
|
105
|
+
this.restoreChartGestures();
|
|
106
|
+
}
|
|
107
|
+
this.setActiveTool(null);
|
|
108
|
+
this.options.store.select(null);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (mod && event.key.toLowerCase() === "z") {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
if (event.shiftKey) this.options.store.redo();
|
|
114
|
+
else this.options.store.undo();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (event.key === "Backspace" || event.key === "Delete") {
|
|
118
|
+
const selected = this.options.store.selectedId();
|
|
119
|
+
if (selected !== null) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
this.options.store.remove(selected);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
this.element = options.chart.chartElement();
|
|
126
|
+
this.element.addEventListener("pointerdown", this.onPointerDown);
|
|
127
|
+
this.element.addEventListener("pointermove", this.onPointerMove);
|
|
128
|
+
window.addEventListener("pointerup", this.onPointerUp);
|
|
129
|
+
window.addEventListener("keydown", this.onKeyDown);
|
|
130
|
+
}
|
|
131
|
+
destroy() {
|
|
132
|
+
this.element.removeEventListener("pointerdown", this.onPointerDown);
|
|
133
|
+
this.element.removeEventListener("pointermove", this.onPointerMove);
|
|
134
|
+
window.removeEventListener("pointerup", this.onPointerUp);
|
|
135
|
+
window.removeEventListener("keydown", this.onKeyDown);
|
|
136
|
+
this.restoreChartGestures();
|
|
137
|
+
}
|
|
138
|
+
/** Arm a tool. The next drag on the chart draws one, then disarms. */
|
|
139
|
+
setActiveTool(tool) {
|
|
140
|
+
this.activeTool = tool;
|
|
141
|
+
this.element.style.cursor = tool === null ? "" : "crosshair";
|
|
142
|
+
}
|
|
143
|
+
getActiveTool() {
|
|
144
|
+
return this.activeTool;
|
|
145
|
+
}
|
|
146
|
+
// -- pointer --------------------------------------------------------------
|
|
147
|
+
local(event) {
|
|
148
|
+
const rect = this.element.getBoundingClientRect();
|
|
149
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
150
|
+
}
|
|
151
|
+
beginPlacing(tool, at) {
|
|
152
|
+
const id = globalThis.crypto?.randomUUID?.() ?? `d${String(Date.now())}${String(Math.random())}`;
|
|
153
|
+
const point = this.toPoint(at, false);
|
|
154
|
+
const needed = this.options.pointCount(tool);
|
|
155
|
+
this.suppressChartGestures();
|
|
156
|
+
this.options.store.add({
|
|
157
|
+
id,
|
|
158
|
+
tool,
|
|
159
|
+
points: Array.from({ length: needed }, () => ({ ...point })),
|
|
160
|
+
draft: true
|
|
161
|
+
});
|
|
162
|
+
this.state = { name: "placing", id, tool, placed: 1 };
|
|
163
|
+
}
|
|
164
|
+
finishPlacing(id) {
|
|
165
|
+
this.setActiveTool(null);
|
|
166
|
+
this.options.store.select(id);
|
|
167
|
+
}
|
|
168
|
+
// -- coordinates ----------------------------------------------------------
|
|
169
|
+
/**
|
|
170
|
+
* Pixels to a data-space anchor.
|
|
171
|
+
*
|
|
172
|
+
* `magnet` snaps the price to the nearest of the bar's open/high/low/close
|
|
173
|
+
* when the pointer is close to one. Traders anchor to actual highs and lows
|
|
174
|
+
* constantly -- a trendline off by two paise looks wrong and misprices
|
|
175
|
+
* every projection off it -- and hitting an exact value by hand at this
|
|
176
|
+
* zoom is impossible. Holding shift turns it off for freehand placement.
|
|
177
|
+
*/
|
|
178
|
+
toPoint(at, disableMagnet) {
|
|
179
|
+
const timeScale = this.options.chart.timeScale();
|
|
180
|
+
const logical = timeScale.coordinateToLogical(at.x) ?? 0;
|
|
181
|
+
const time = timeScale.coordinateToTime(at.x);
|
|
182
|
+
const rawPrice = this.options.series.coordinateToPrice(at.y);
|
|
183
|
+
const price = rawPrice === null ? 0 : Number(rawPrice);
|
|
184
|
+
const magnetPx = this.options.magnetPx ?? 0;
|
|
185
|
+
if (disableMagnet || magnetPx <= 0) return { time, logical, price };
|
|
186
|
+
const snapped = this.snapToBar(logical, at.y, magnetPx);
|
|
187
|
+
return { time, logical, price: snapped ?? price };
|
|
188
|
+
}
|
|
189
|
+
/** Nearest OHLC of the bar under the cursor, if it is within `within` px. */
|
|
190
|
+
snapToBar(logical, y, within) {
|
|
191
|
+
const bar = this.options.series.dataByIndex(Math.round(logical));
|
|
192
|
+
if (bar === null || bar === void 0) return null;
|
|
193
|
+
const candle = bar;
|
|
194
|
+
const candidates = [candle.open, candle.high, candle.low, candle.close].filter(
|
|
195
|
+
(v) => typeof v === "number"
|
|
196
|
+
);
|
|
197
|
+
if (candidates.length === 0) return null;
|
|
198
|
+
let best = null;
|
|
199
|
+
let bestDistance = within;
|
|
200
|
+
for (const value of candidates) {
|
|
201
|
+
const coordinate = this.options.series.priceToCoordinate(value);
|
|
202
|
+
if (coordinate === null) continue;
|
|
203
|
+
const distance = Math.abs(coordinate - y);
|
|
204
|
+
if (distance < bestDistance) {
|
|
205
|
+
bestDistance = distance;
|
|
206
|
+
best = value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return best;
|
|
210
|
+
}
|
|
211
|
+
/** Move an anchor by a delta expressed in bars and price. */
|
|
212
|
+
shift(point, dLogical, dPrice) {
|
|
213
|
+
const logical = point.logical + dLogical;
|
|
214
|
+
const time = this.options.chart.timeScale().coordinateToTime(
|
|
215
|
+
this.options.chart.timeScale().logicalToCoordinate(logical) ?? 0
|
|
216
|
+
);
|
|
217
|
+
return { time, logical, price: point.price + dPrice };
|
|
218
|
+
}
|
|
219
|
+
// -- chart gesture suppression -------------------------------------------
|
|
220
|
+
suppressChartGestures() {
|
|
221
|
+
const current = this.options.chart.options();
|
|
222
|
+
this.savedScroll = current.handleScroll;
|
|
223
|
+
this.savedScale = current.handleScale;
|
|
224
|
+
this.options.chart.applyOptions({ handleScroll: false, handleScale: false });
|
|
225
|
+
}
|
|
226
|
+
restoreChartGestures() {
|
|
227
|
+
this.options.chart.applyOptions({
|
|
228
|
+
handleScroll: this.savedScroll,
|
|
229
|
+
handleScale: this.savedScale
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// src/core/serialise.ts
|
|
235
|
+
var SNAPSHOT_VERSION = 1;
|
|
236
|
+
function toSnapshot(drawings) {
|
|
237
|
+
return {
|
|
238
|
+
version: SNAPSHOT_VERSION,
|
|
239
|
+
drawings: drawings.filter((d) => d.draft !== true).map((d) => ({ ...d, points: d.points.map((p) => ({ ...p })) }))
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function fromSnapshot(snapshot) {
|
|
243
|
+
if (snapshot.version !== SNAPSHOT_VERSION) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`trendkit: snapshot version ${String(snapshot.version)} is not supported by this build (expected ${String(SNAPSHOT_VERSION)})`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return snapshot.drawings.map((d) => ({
|
|
249
|
+
...d,
|
|
250
|
+
points: d.points.map((p) => ({ ...p })),
|
|
251
|
+
// A stored drawing is never a draft, whatever the blob claims.
|
|
252
|
+
draft: false
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/core/store.ts
|
|
257
|
+
var DrawingStore = class {
|
|
258
|
+
constructor() {
|
|
259
|
+
this.items = [];
|
|
260
|
+
this.selected = null;
|
|
261
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
262
|
+
/** Undo history. Snapshots, not diffs -- drawings are small and few. */
|
|
263
|
+
this.past = [];
|
|
264
|
+
this.future = [];
|
|
265
|
+
}
|
|
266
|
+
subscribe(listener) {
|
|
267
|
+
this.listeners.add(listener);
|
|
268
|
+
return () => this.listeners.delete(listener);
|
|
269
|
+
}
|
|
270
|
+
notify() {
|
|
271
|
+
for (const listener of this.listeners) listener();
|
|
272
|
+
}
|
|
273
|
+
/** Snapshot for undo. Called before any mutation that should be undoable. */
|
|
274
|
+
checkpoint() {
|
|
275
|
+
this.past.push(structuredClone(this.items));
|
|
276
|
+
this.future.length = 0;
|
|
277
|
+
if (this.past.length > 100) this.past.shift();
|
|
278
|
+
}
|
|
279
|
+
all() {
|
|
280
|
+
return this.items;
|
|
281
|
+
}
|
|
282
|
+
selectedId() {
|
|
283
|
+
return this.selected;
|
|
284
|
+
}
|
|
285
|
+
get(id) {
|
|
286
|
+
return this.items.find((d) => d.id === id);
|
|
287
|
+
}
|
|
288
|
+
add(drawing, options = {}) {
|
|
289
|
+
if (options.undoable !== false) this.checkpoint();
|
|
290
|
+
this.items.push(drawing);
|
|
291
|
+
this.notify();
|
|
292
|
+
}
|
|
293
|
+
update(id, patch, options = {}) {
|
|
294
|
+
const index = this.items.findIndex((d) => d.id === id);
|
|
295
|
+
if (index === -1) return;
|
|
296
|
+
const current = this.items[index];
|
|
297
|
+
if (current === void 0) return;
|
|
298
|
+
if (options.undoable === true) this.checkpoint();
|
|
299
|
+
this.items[index] = { ...current, ...patch };
|
|
300
|
+
this.notify();
|
|
301
|
+
}
|
|
302
|
+
remove(id, options = {}) {
|
|
303
|
+
const index = this.items.findIndex((d) => d.id === id);
|
|
304
|
+
if (index === -1) return;
|
|
305
|
+
if (options.undoable !== false) this.checkpoint();
|
|
306
|
+
this.items.splice(index, 1);
|
|
307
|
+
if (this.selected === id) this.selected = null;
|
|
308
|
+
this.notify();
|
|
309
|
+
}
|
|
310
|
+
select(id) {
|
|
311
|
+
if (this.selected === id) return;
|
|
312
|
+
this.selected = id;
|
|
313
|
+
this.notify();
|
|
314
|
+
}
|
|
315
|
+
replaceAll(drawings) {
|
|
316
|
+
this.checkpoint();
|
|
317
|
+
this.items = drawings;
|
|
318
|
+
this.selected = null;
|
|
319
|
+
this.notify();
|
|
320
|
+
}
|
|
321
|
+
undo() {
|
|
322
|
+
const previous = this.past.pop();
|
|
323
|
+
if (previous === void 0) return;
|
|
324
|
+
this.future.push(structuredClone(this.items));
|
|
325
|
+
this.items = previous;
|
|
326
|
+
this.selected = null;
|
|
327
|
+
this.notify();
|
|
328
|
+
}
|
|
329
|
+
redo() {
|
|
330
|
+
const next = this.future.pop();
|
|
331
|
+
if (next === void 0) return;
|
|
332
|
+
this.past.push(structuredClone(this.items));
|
|
333
|
+
this.items = next;
|
|
334
|
+
this.selected = null;
|
|
335
|
+
this.notify();
|
|
336
|
+
}
|
|
337
|
+
canUndo() {
|
|
338
|
+
return this.past.length > 0;
|
|
339
|
+
}
|
|
340
|
+
canRedo() {
|
|
341
|
+
return this.future.length > 0;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// src/render/primitive.ts
|
|
346
|
+
var HIT_TOLERANCE = 6;
|
|
347
|
+
var HANDLE_RADIUS = 7;
|
|
348
|
+
var TrendkitPrimitive = class {
|
|
349
|
+
constructor(chart, series, source) {
|
|
350
|
+
this.chart = chart;
|
|
351
|
+
this.series = series;
|
|
352
|
+
this.source = source;
|
|
353
|
+
/** Cached axis views, and the signature they were built from. */
|
|
354
|
+
this.axisViews = [];
|
|
355
|
+
this.axisSignature = "";
|
|
356
|
+
/**
|
|
357
|
+
* Resolve a data-space anchor to pixels.
|
|
358
|
+
*
|
|
359
|
+
* Time first, logical as the fallback. `timeToCoordinate` returns null for
|
|
360
|
+
* anything past the last bar -- which is exactly where a user drags a
|
|
361
|
+
* trendline to project it forward -- and `logicalToCoordinate` keeps
|
|
362
|
+
* counting into that empty space.
|
|
363
|
+
*/
|
|
364
|
+
this.toScreen = (point) => {
|
|
365
|
+
const timeScale = this.chart.timeScale();
|
|
366
|
+
const x = (point.time !== null ? timeScale.timeToCoordinate(point.time) : null) ?? timeScale.logicalToCoordinate(point.logical);
|
|
367
|
+
const y = this.series.priceToCoordinate(point.price);
|
|
368
|
+
if (x === null || y === null) return null;
|
|
369
|
+
return { x, y };
|
|
370
|
+
};
|
|
371
|
+
this.toY = (price) => this.series.priceToCoordinate(price);
|
|
372
|
+
const renderer = {
|
|
373
|
+
draw: (target) => {
|
|
374
|
+
target.useBitmapCoordinateSpace((scope) => {
|
|
375
|
+
const context = this.context(
|
|
376
|
+
scope.context,
|
|
377
|
+
scope.horizontalPixelRatio,
|
|
378
|
+
scope.mediaSize.width,
|
|
379
|
+
scope.mediaSize.height
|
|
380
|
+
);
|
|
381
|
+
const selected = this.source.selectedId();
|
|
382
|
+
for (const drawing of this.source.drawings()) {
|
|
383
|
+
const tool = this.source.tool(drawing.tool);
|
|
384
|
+
tool?.draw(drawing, drawing.id === selected, context);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
this.view = {
|
|
390
|
+
// Above the series. A trendline drawn behind the candles it references
|
|
391
|
+
// is worse than useless -- the candles are exactly what it marks.
|
|
392
|
+
zOrder: () => "top",
|
|
393
|
+
renderer: () => renderer
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
// -- ISeriesPrimitive -----------------------------------------------------
|
|
397
|
+
attached(param) {
|
|
398
|
+
this.requestUpdate = param.requestUpdate;
|
|
399
|
+
}
|
|
400
|
+
detached() {
|
|
401
|
+
this.requestUpdate = void 0;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Called by the library before it re-reads the views.
|
|
405
|
+
*
|
|
406
|
+
* A no-op only because everything here is computed lazily inside the
|
|
407
|
+
* renderer and `priceAxisViews`. It still has to EXIST: without it the
|
|
408
|
+
* library has no signal that this primitive's views may have changed, and
|
|
409
|
+
* keeps the empty axis-view array it collected at attach time -- so axis
|
|
410
|
+
* labels never appear no matter how many drawings are added.
|
|
411
|
+
*/
|
|
412
|
+
updateAllViews() {
|
|
413
|
+
}
|
|
414
|
+
paneViews() {
|
|
415
|
+
return [this.view];
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Price-scale labels for every drawing that wants one.
|
|
419
|
+
*
|
|
420
|
+
* Rebuilt only when the set of labelled prices actually changes. The
|
|
421
|
+
* library caches on array IDENTITY, so returning a fresh array each call
|
|
422
|
+
* invalidates that cache on every frame of every pan -- the interface
|
|
423
|
+
* documentation asks for this explicitly.
|
|
424
|
+
*/
|
|
425
|
+
priceAxisViews() {
|
|
426
|
+
const wanted = [];
|
|
427
|
+
for (const drawing of this.source.drawings()) {
|
|
428
|
+
const tool = this.source.tool(drawing.tool);
|
|
429
|
+
for (const price of tool?.axisPrices?.(drawing) ?? []) {
|
|
430
|
+
wanted.push({
|
|
431
|
+
price,
|
|
432
|
+
colour: drawing.style?.colour ?? this.source.theme().accent
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const signature = wanted.map((w) => `${String(w.price)}:${w.colour}`).join("|");
|
|
437
|
+
if (signature === this.axisSignature) return this.axisViews;
|
|
438
|
+
this.axisSignature = signature;
|
|
439
|
+
this.axisViews = wanted.map(({ price, colour }) => ({
|
|
440
|
+
coordinate: () => this.series.priceToCoordinate(price) ?? -100,
|
|
441
|
+
text: () => this.formatPrice(price),
|
|
442
|
+
textColor: () => "#ffffff",
|
|
443
|
+
backColor: () => colour
|
|
444
|
+
}));
|
|
445
|
+
return this.axisViews;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* What the pointer is over, in the form Lightweight Charts wants.
|
|
449
|
+
*
|
|
450
|
+
* `externalId` encodes the target so the interaction controller can decode
|
|
451
|
+
* it without hit-testing a second time: `handle:<id>:<index>` or
|
|
452
|
+
* `body:<id>`. The library gives us one string, so it carries the lot.
|
|
453
|
+
*/
|
|
454
|
+
hitTest(x, y) {
|
|
455
|
+
const hit = this.hitTestDetailed({ x, y });
|
|
456
|
+
if (hit.kind === "none") return null;
|
|
457
|
+
return {
|
|
458
|
+
externalId: hit.kind === "handle" ? `handle:${hit.id}:${hit.index}` : `body:${hit.id}`,
|
|
459
|
+
zOrder: "top",
|
|
460
|
+
cursorStyle: hit.kind === "handle" ? "grab" : "move"
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
// -- Used by the interaction controller -----------------------------------
|
|
464
|
+
/** Ask the chart to repaint. Called whenever the store changes. */
|
|
465
|
+
update() {
|
|
466
|
+
this.requestUpdate?.();
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Full hit test, in priority order.
|
|
470
|
+
*
|
|
471
|
+
* Handles before bodies, and the selected drawing before the rest. Both
|
|
472
|
+
* matter: a handle sits on top of its own body, so testing bodies first
|
|
473
|
+
* would make endpoints ungrabbable; and once something is selected the user
|
|
474
|
+
* is working on it, so it should win ties against whatever it overlaps.
|
|
475
|
+
*/
|
|
476
|
+
hitTestDetailed(at) {
|
|
477
|
+
const context = this.contextForHitTest();
|
|
478
|
+
if (context === null) return { kind: "none" };
|
|
479
|
+
const drawings = this.source.drawings();
|
|
480
|
+
const selectedId = this.source.selectedId();
|
|
481
|
+
const ordered = [...drawings].sort((a, b) => {
|
|
482
|
+
if (a.id === selectedId) return 1;
|
|
483
|
+
if (b.id === selectedId) return -1;
|
|
484
|
+
return 0;
|
|
485
|
+
});
|
|
486
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
487
|
+
const drawing = ordered[i];
|
|
488
|
+
if (drawing === void 0) continue;
|
|
489
|
+
for (let index = 0; index < drawing.points.length; index++) {
|
|
490
|
+
const point = drawing.points[index];
|
|
491
|
+
if (point === void 0) continue;
|
|
492
|
+
const screen = context.toScreen(point);
|
|
493
|
+
if (screen === null) continue;
|
|
494
|
+
if (Math.hypot(at.x - screen.x, at.y - screen.y) <= HANDLE_RADIUS) {
|
|
495
|
+
return { kind: "handle", id: drawing.id, index };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
500
|
+
const drawing = ordered[i];
|
|
501
|
+
if (drawing === void 0) continue;
|
|
502
|
+
const tool = this.source.tool(drawing.tool);
|
|
503
|
+
const distance = tool?.distance(drawing, at, context);
|
|
504
|
+
if (distance !== null && distance !== void 0 && distance <= HIT_TOLERANCE) {
|
|
505
|
+
return { kind: "body", id: drawing.id };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { kind: "none" };
|
|
509
|
+
}
|
|
510
|
+
context(ctx, ratio, width, height) {
|
|
511
|
+
return {
|
|
512
|
+
ctx,
|
|
513
|
+
ratio,
|
|
514
|
+
width,
|
|
515
|
+
height,
|
|
516
|
+
theme: this.source.theme(),
|
|
517
|
+
toScreen: this.toScreen,
|
|
518
|
+
toY: this.toY
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Format a price for an axis label.
|
|
523
|
+
*
|
|
524
|
+
* Uses the series' own formatter where it has one, so a drawing's label
|
|
525
|
+
* matches the scale beside it -- a chart reading 1,240.50 on the axis and
|
|
526
|
+
* 1240.5 on a drawing looks like two different numbers at a glance.
|
|
527
|
+
*/
|
|
528
|
+
formatPrice(price) {
|
|
529
|
+
const format = this.series.priceFormatter;
|
|
530
|
+
if (typeof format === "function") {
|
|
531
|
+
try {
|
|
532
|
+
return format.call(this.series).format(price);
|
|
533
|
+
} catch {
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return price.toFixed(2);
|
|
537
|
+
}
|
|
538
|
+
/** The same context minus the canvas, for hit tests outside a paint. */
|
|
539
|
+
contextForHitTest() {
|
|
540
|
+
return {
|
|
541
|
+
ratio: 1,
|
|
542
|
+
width: this.chart.timeScale().width(),
|
|
543
|
+
height: 0,
|
|
544
|
+
theme: this.source.theme(),
|
|
545
|
+
toScreen: this.toScreen,
|
|
546
|
+
toY: this.toY
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// src/render/theme.ts
|
|
552
|
+
var LIGHT_THEME = {
|
|
553
|
+
accent: "#2962ff",
|
|
554
|
+
selection: "#2962ff",
|
|
555
|
+
text: "#131722",
|
|
556
|
+
labelBackground: "#ffffffe6",
|
|
557
|
+
fill: "#2962ff1f"
|
|
558
|
+
};
|
|
559
|
+
var DARK_THEME = {
|
|
560
|
+
accent: "#5b8def",
|
|
561
|
+
selection: "#5b8def",
|
|
562
|
+
text: "#d1d4dc",
|
|
563
|
+
labelBackground: "#1e222de6",
|
|
564
|
+
fill: "#5b8def24"
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
// src/core/geometry.ts
|
|
568
|
+
function distanceToSegment(p, a, b) {
|
|
569
|
+
const dx = b.x - a.x;
|
|
570
|
+
const dy = b.y - a.y;
|
|
571
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
572
|
+
if (lengthSquared === 0) return Math.hypot(p.x - a.x, p.y - a.y);
|
|
573
|
+
const t = Math.max(
|
|
574
|
+
0,
|
|
575
|
+
Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared)
|
|
576
|
+
);
|
|
577
|
+
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
578
|
+
}
|
|
579
|
+
function distanceToHorizontal(p, y) {
|
|
580
|
+
return Math.abs(p.y - y);
|
|
581
|
+
}
|
|
582
|
+
function distanceToRect(p, a, b) {
|
|
583
|
+
const left = Math.min(a.x, b.x);
|
|
584
|
+
const right = Math.max(a.x, b.x);
|
|
585
|
+
const top = Math.min(a.y, b.y);
|
|
586
|
+
const bottom = Math.max(a.y, b.y);
|
|
587
|
+
if (p.x >= left && p.x <= right && p.y >= top && p.y <= bottom) return 0;
|
|
588
|
+
const dx = Math.max(left - p.x, 0, p.x - right);
|
|
589
|
+
const dy = Math.max(top - p.y, 0, p.y - bottom);
|
|
590
|
+
return Math.hypot(dx, dy);
|
|
591
|
+
}
|
|
592
|
+
var FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
|
593
|
+
function fibonacciPrices(from, to) {
|
|
594
|
+
const span = to - from;
|
|
595
|
+
return FIB_LEVELS.map((level) => from + span * level);
|
|
596
|
+
}
|
|
597
|
+
function percentChange(from, to) {
|
|
598
|
+
if (from === 0) return 0;
|
|
599
|
+
return (to - from) / Math.abs(from) * 100;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/render/draw.ts
|
|
603
|
+
function toBitmap(value, ratio) {
|
|
604
|
+
return Math.round(value * ratio);
|
|
605
|
+
}
|
|
606
|
+
function snapLine(value, ratio, width) {
|
|
607
|
+
const scaled = Math.round(value * ratio);
|
|
608
|
+
return width % 2 === 1 ? scaled + 0.5 : scaled;
|
|
609
|
+
}
|
|
610
|
+
function dashPattern(style, ratio) {
|
|
611
|
+
switch (style) {
|
|
612
|
+
case "dashed":
|
|
613
|
+
return [6 * ratio, 4 * ratio];
|
|
614
|
+
case "dotted":
|
|
615
|
+
return [1 * ratio, 3 * ratio];
|
|
616
|
+
case "solid":
|
|
617
|
+
return [];
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function strokeSegment(ctx, a, b, options) {
|
|
621
|
+
const { colour, width, style = "solid", ratio } = options;
|
|
622
|
+
const deviceWidth = Math.max(1, Math.round(width * ratio));
|
|
623
|
+
ctx.save();
|
|
624
|
+
ctx.strokeStyle = colour;
|
|
625
|
+
ctx.lineWidth = deviceWidth;
|
|
626
|
+
ctx.setLineDash(dashPattern(style, ratio));
|
|
627
|
+
ctx.beginPath();
|
|
628
|
+
ctx.moveTo(snapLine(a.x, ratio, deviceWidth), snapLine(a.y, ratio, deviceWidth));
|
|
629
|
+
ctx.lineTo(snapLine(b.x, ratio, deviceWidth), snapLine(b.y, ratio, deviceWidth));
|
|
630
|
+
ctx.stroke();
|
|
631
|
+
ctx.restore();
|
|
632
|
+
}
|
|
633
|
+
function fillRect(ctx, a, b, colour, ratio) {
|
|
634
|
+
const x = toBitmap(Math.min(a.x, b.x), ratio);
|
|
635
|
+
const y = toBitmap(Math.min(a.y, b.y), ratio);
|
|
636
|
+
const w = toBitmap(Math.abs(b.x - a.x), ratio);
|
|
637
|
+
const h = toBitmap(Math.abs(b.y - a.y), ratio);
|
|
638
|
+
ctx.save();
|
|
639
|
+
ctx.fillStyle = colour;
|
|
640
|
+
ctx.fillRect(x, y, w, h);
|
|
641
|
+
ctx.restore();
|
|
642
|
+
}
|
|
643
|
+
function drawHandle(ctx, point, colour, ratio, radius = 4) {
|
|
644
|
+
const r = radius * ratio;
|
|
645
|
+
ctx.save();
|
|
646
|
+
ctx.beginPath();
|
|
647
|
+
ctx.arc(toBitmap(point.x, ratio), toBitmap(point.y, ratio), r, 0, Math.PI * 2);
|
|
648
|
+
ctx.fillStyle = "#ffffff";
|
|
649
|
+
ctx.fill();
|
|
650
|
+
ctx.lineWidth = Math.max(1, Math.round(1.5 * ratio));
|
|
651
|
+
ctx.strokeStyle = colour;
|
|
652
|
+
ctx.stroke();
|
|
653
|
+
ctx.restore();
|
|
654
|
+
}
|
|
655
|
+
function drawLabel(ctx, text, at, options) {
|
|
656
|
+
const { colour, background, ratio, align = "left" } = options;
|
|
657
|
+
const fontSize = Math.round(11 * ratio);
|
|
658
|
+
const padX = Math.round(4 * ratio);
|
|
659
|
+
const padY = Math.round(2 * ratio);
|
|
660
|
+
ctx.save();
|
|
661
|
+
ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
|
|
662
|
+
ctx.textBaseline = "middle";
|
|
663
|
+
const width = ctx.measureText(text).width;
|
|
664
|
+
const x = align === "right" ? toBitmap(at.x, ratio) - width - padX * 2 : toBitmap(at.x, ratio);
|
|
665
|
+
const y = toBitmap(at.y, ratio);
|
|
666
|
+
ctx.fillStyle = background;
|
|
667
|
+
ctx.fillRect(x, y - fontSize / 2 - padY, width + padX * 2, fontSize + padY * 2);
|
|
668
|
+
ctx.fillStyle = colour;
|
|
669
|
+
ctx.fillText(text, x + padX, y);
|
|
670
|
+
ctx.restore();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/tools/trendline.ts
|
|
674
|
+
var Trendline = {
|
|
675
|
+
id: "trendline",
|
|
676
|
+
pointCount: 2,
|
|
677
|
+
draw(drawing, selected, context) {
|
|
678
|
+
const [start, end] = resolve(drawing, context);
|
|
679
|
+
if (start === null || end === null) return;
|
|
680
|
+
const style = drawing.style ?? {};
|
|
681
|
+
strokeSegment(context.ctx, start, end, {
|
|
682
|
+
colour: style.colour ?? context.theme.accent,
|
|
683
|
+
width: style.lineWidth ?? 2,
|
|
684
|
+
style: style.lineStyle ?? "solid",
|
|
685
|
+
ratio: context.ratio
|
|
686
|
+
});
|
|
687
|
+
if (selected) {
|
|
688
|
+
const colour = context.theme.selection;
|
|
689
|
+
drawHandle(context.ctx, start, colour, context.ratio);
|
|
690
|
+
drawHandle(context.ctx, end, colour, context.ratio);
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
distance(drawing, at, context) {
|
|
694
|
+
const [start, end] = resolve(drawing, context);
|
|
695
|
+
if (start === null || end === null) return null;
|
|
696
|
+
return distanceToSegment(at, start, end);
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
function resolve(drawing, context) {
|
|
700
|
+
const a = drawing.points[0];
|
|
701
|
+
const b = drawing.points[1];
|
|
702
|
+
return [
|
|
703
|
+
a === void 0 ? null : context.toScreen(a),
|
|
704
|
+
b === void 0 ? null : context.toScreen(b)
|
|
705
|
+
];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// src/tools/horizontal-ray.ts
|
|
709
|
+
var HorizontalRay = {
|
|
710
|
+
id: "horizontal-ray",
|
|
711
|
+
pointCount: 1,
|
|
712
|
+
draw(drawing, selected, context) {
|
|
713
|
+
const anchor = drawing.points[0];
|
|
714
|
+
if (anchor === void 0) return;
|
|
715
|
+
const at = context.toScreen(anchor);
|
|
716
|
+
if (at === null) return;
|
|
717
|
+
const style = drawing.style ?? {};
|
|
718
|
+
const colour = style.colour ?? context.theme.accent;
|
|
719
|
+
strokeSegment(
|
|
720
|
+
context.ctx,
|
|
721
|
+
{ x: at.x, y: at.y },
|
|
722
|
+
// To the right edge, not to a second anchor.
|
|
723
|
+
{ x: context.width, y: at.y },
|
|
724
|
+
{
|
|
725
|
+
colour,
|
|
726
|
+
width: style.lineWidth ?? 2,
|
|
727
|
+
style: style.lineStyle ?? "solid",
|
|
728
|
+
ratio: context.ratio
|
|
729
|
+
}
|
|
730
|
+
);
|
|
731
|
+
if (selected) drawHandle(context.ctx, at, context.theme.selection, context.ratio);
|
|
732
|
+
},
|
|
733
|
+
axisPrices(drawing) {
|
|
734
|
+
const anchor = drawing.points[0];
|
|
735
|
+
return anchor === void 0 ? [] : [anchor.price];
|
|
736
|
+
},
|
|
737
|
+
distance(drawing, at, context) {
|
|
738
|
+
const anchor = drawing.points[0];
|
|
739
|
+
if (anchor === void 0) return null;
|
|
740
|
+
const screen = context.toScreen(anchor);
|
|
741
|
+
if (screen === null) return null;
|
|
742
|
+
if (at.x < screen.x) return null;
|
|
743
|
+
return distanceToHorizontal(at, screen.y);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// src/tools/fibonacci.ts
|
|
748
|
+
var BAND_ALPHA = "14";
|
|
749
|
+
var Fibonacci = {
|
|
750
|
+
id: "fibonacci",
|
|
751
|
+
pointCount: 2,
|
|
752
|
+
draw(drawing, selected, context) {
|
|
753
|
+
const a = drawing.points[0];
|
|
754
|
+
const b = drawing.points[1];
|
|
755
|
+
if (a === void 0 || b === void 0) return;
|
|
756
|
+
const pa = context.toScreen(a);
|
|
757
|
+
const pb = context.toScreen(b);
|
|
758
|
+
if (pa === null || pb === null) return;
|
|
759
|
+
const colour = drawing.style?.colour ?? context.theme.accent;
|
|
760
|
+
const left = Math.min(pa.x, pb.x);
|
|
761
|
+
const right = Math.max(pa.x, pb.x);
|
|
762
|
+
const prices = fibonacciPrices(a.price, b.price);
|
|
763
|
+
for (let i = 0; i < prices.length - 1; i++) {
|
|
764
|
+
const top = yOf(prices[i], context);
|
|
765
|
+
const bottom = yOf(prices[i + 1], context);
|
|
766
|
+
if (top === null || bottom === null) continue;
|
|
767
|
+
if (i % 2 === 0) continue;
|
|
768
|
+
fillRect(
|
|
769
|
+
context.ctx,
|
|
770
|
+
{ x: left, y: top },
|
|
771
|
+
{ x: right, y: bottom },
|
|
772
|
+
`${colour}${BAND_ALPHA}`,
|
|
773
|
+
context.ratio
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
prices.forEach((price, i) => {
|
|
777
|
+
const y = yOf(price, context);
|
|
778
|
+
if (y === null) return;
|
|
779
|
+
const level = FIB_LEVELS[i] ?? 0;
|
|
780
|
+
strokeSegment(
|
|
781
|
+
context.ctx,
|
|
782
|
+
{ x: left, y },
|
|
783
|
+
{ x: right, y },
|
|
784
|
+
{
|
|
785
|
+
colour,
|
|
786
|
+
// The 0.618 line drawn heavier: it is the one people act on, and a
|
|
787
|
+
// ladder of seven identical lines makes it hunt for.
|
|
788
|
+
width: level === 0.618 ? 2 : 1,
|
|
789
|
+
style: level === 0 || level === 1 ? "solid" : "dashed",
|
|
790
|
+
ratio: context.ratio
|
|
791
|
+
}
|
|
792
|
+
);
|
|
793
|
+
drawLabel(context.ctx, `${level.toFixed(3)} ${price.toFixed(2)}`, { x: left + 4, y }, {
|
|
794
|
+
colour: context.theme.text,
|
|
795
|
+
background: context.theme.labelBackground,
|
|
796
|
+
ratio: context.ratio
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
if (selected) {
|
|
800
|
+
drawHandle(context.ctx, pa, context.theme.selection, context.ratio);
|
|
801
|
+
drawHandle(context.ctx, pb, context.theme.selection, context.ratio);
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
distance(drawing, at, context) {
|
|
805
|
+
const a = drawing.points[0];
|
|
806
|
+
const b = drawing.points[1];
|
|
807
|
+
if (a === void 0 || b === void 0) return null;
|
|
808
|
+
const pa = context.toScreen(a);
|
|
809
|
+
const pb = context.toScreen(b);
|
|
810
|
+
if (pa === null || pb === null) return null;
|
|
811
|
+
return distanceToRect(at, pa, pb);
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
function yOf(price, context) {
|
|
815
|
+
return price === void 0 ? null : context.toY(price);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/tools/rectangle.ts
|
|
819
|
+
var Rectangle = {
|
|
820
|
+
id: "rectangle",
|
|
821
|
+
pointCount: 2,
|
|
822
|
+
draw(drawing, selected, context) {
|
|
823
|
+
const a = drawing.points[0];
|
|
824
|
+
const b = drawing.points[1];
|
|
825
|
+
if (a === void 0 || b === void 0) return;
|
|
826
|
+
const pa = context.toScreen(a);
|
|
827
|
+
const pb = context.toScreen(b);
|
|
828
|
+
if (pa === null || pb === null) return;
|
|
829
|
+
const style = drawing.style ?? {};
|
|
830
|
+
const colour = style.colour ?? context.theme.accent;
|
|
831
|
+
fillRect(context.ctx, pa, pb, style.fill ?? context.theme.fill, context.ratio);
|
|
832
|
+
const corners = [
|
|
833
|
+
[{ x: pa.x, y: pa.y }, { x: pb.x, y: pa.y }],
|
|
834
|
+
[{ x: pb.x, y: pa.y }, { x: pb.x, y: pb.y }],
|
|
835
|
+
[{ x: pb.x, y: pb.y }, { x: pa.x, y: pb.y }],
|
|
836
|
+
[{ x: pa.x, y: pb.y }, { x: pa.x, y: pa.y }]
|
|
837
|
+
];
|
|
838
|
+
for (const [from, to] of corners) {
|
|
839
|
+
strokeSegment(context.ctx, from, to, {
|
|
840
|
+
colour,
|
|
841
|
+
width: style.lineWidth ?? 1,
|
|
842
|
+
style: style.lineStyle ?? "solid",
|
|
843
|
+
ratio: context.ratio
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
if (selected) {
|
|
847
|
+
drawHandle(context.ctx, pa, context.theme.selection, context.ratio);
|
|
848
|
+
drawHandle(context.ctx, pb, context.theme.selection, context.ratio);
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
axisPrices(drawing) {
|
|
852
|
+
return drawing.points.map((p) => p.price);
|
|
853
|
+
},
|
|
854
|
+
distance(drawing, at, context) {
|
|
855
|
+
const a = drawing.points[0];
|
|
856
|
+
const b = drawing.points[1];
|
|
857
|
+
if (a === void 0 || b === void 0) return null;
|
|
858
|
+
const pa = context.toScreen(a);
|
|
859
|
+
const pb = context.toScreen(b);
|
|
860
|
+
if (pa === null || pb === null) return null;
|
|
861
|
+
return distanceToRect(at, pa, pb);
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// src/tools/measure.ts
|
|
866
|
+
var Measure = {
|
|
867
|
+
id: "measure",
|
|
868
|
+
pointCount: 2,
|
|
869
|
+
transient: true,
|
|
870
|
+
draw(drawing, _selected, context) {
|
|
871
|
+
const a = drawing.points[0];
|
|
872
|
+
const b = drawing.points[1];
|
|
873
|
+
if (a === void 0 || b === void 0) return;
|
|
874
|
+
const pa = context.toScreen(a);
|
|
875
|
+
const pb = context.toScreen(b);
|
|
876
|
+
if (pa === null || pb === null) return;
|
|
877
|
+
const up = b.price >= a.price;
|
|
878
|
+
const colour = up ? "#26a69a" : "#ef5350";
|
|
879
|
+
fillRect(context.ctx, pa, pb, `${colour}1f`, context.ratio);
|
|
880
|
+
strokeSegment(context.ctx, pa, pb, {
|
|
881
|
+
colour,
|
|
882
|
+
width: 1,
|
|
883
|
+
style: "dashed",
|
|
884
|
+
ratio: context.ratio
|
|
885
|
+
});
|
|
886
|
+
const change = percentChange(a.price, b.price);
|
|
887
|
+
const delta = b.price - a.price;
|
|
888
|
+
const bars = Math.abs(Math.round(b.logical - a.logical));
|
|
889
|
+
const text = `${delta >= 0 ? "+" : ""}${delta.toFixed(2)} (${change >= 0 ? "+" : ""}${change.toFixed(2)}%) ${String(bars)} ${bars === 1 ? "bar" : "bars"}`;
|
|
890
|
+
drawLabel(
|
|
891
|
+
context.ctx,
|
|
892
|
+
text,
|
|
893
|
+
{ x: Math.min(pa.x, pb.x) + 6, y: up ? Math.min(pa.y, pb.y) - 12 : Math.max(pa.y, pb.y) + 12 },
|
|
894
|
+
{
|
|
895
|
+
colour: "#ffffff",
|
|
896
|
+
background: colour,
|
|
897
|
+
ratio: context.ratio
|
|
898
|
+
}
|
|
899
|
+
);
|
|
900
|
+
},
|
|
901
|
+
distance() {
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
// src/indicators/math.ts
|
|
907
|
+
function sma(values, period) {
|
|
908
|
+
if (period <= 0) throw new Error("sma: period must be positive");
|
|
909
|
+
const out = [];
|
|
910
|
+
let sum = 0;
|
|
911
|
+
for (let i = 0; i < values.length; i++) {
|
|
912
|
+
sum += values[i] ?? 0;
|
|
913
|
+
if (i >= period) sum -= values[i - period] ?? 0;
|
|
914
|
+
out.push(i >= period - 1 ? sum / period : null);
|
|
915
|
+
}
|
|
916
|
+
return out;
|
|
917
|
+
}
|
|
918
|
+
function ema(values, period) {
|
|
919
|
+
if (period <= 0) throw new Error("ema: period must be positive");
|
|
920
|
+
const k = 2 / (period + 1);
|
|
921
|
+
const out = [];
|
|
922
|
+
let previous = null;
|
|
923
|
+
let seed = 0;
|
|
924
|
+
for (let i = 0; i < values.length; i++) {
|
|
925
|
+
const value = values[i] ?? 0;
|
|
926
|
+
if (previous === null) {
|
|
927
|
+
seed += value;
|
|
928
|
+
if (i === period - 1) {
|
|
929
|
+
previous = seed / period;
|
|
930
|
+
out.push(previous);
|
|
931
|
+
} else {
|
|
932
|
+
out.push(null);
|
|
933
|
+
}
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
previous = value * k + previous * (1 - k);
|
|
937
|
+
out.push(previous);
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
940
|
+
}
|
|
941
|
+
function wilder(values, period) {
|
|
942
|
+
if (period <= 0) throw new Error("wilder: period must be positive");
|
|
943
|
+
const out = [];
|
|
944
|
+
let previous = null;
|
|
945
|
+
let seed = 0;
|
|
946
|
+
for (let i = 0; i < values.length; i++) {
|
|
947
|
+
const value = values[i] ?? 0;
|
|
948
|
+
if (previous === null) {
|
|
949
|
+
seed += value;
|
|
950
|
+
if (i === period - 1) {
|
|
951
|
+
previous = seed / period;
|
|
952
|
+
out.push(previous);
|
|
953
|
+
} else {
|
|
954
|
+
out.push(null);
|
|
955
|
+
}
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
previous = (previous * (period - 1) + value) / period;
|
|
959
|
+
out.push(previous);
|
|
960
|
+
}
|
|
961
|
+
return out;
|
|
962
|
+
}
|
|
963
|
+
function stdev(values, period) {
|
|
964
|
+
if (period <= 0) throw new Error("stdev: period must be positive");
|
|
965
|
+
const out = [];
|
|
966
|
+
for (let i = 0; i < values.length; i++) {
|
|
967
|
+
if (i < period - 1) {
|
|
968
|
+
out.push(null);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
let sum = 0;
|
|
972
|
+
for (let j = i - period + 1; j <= i; j++) sum += values[j] ?? 0;
|
|
973
|
+
const mean = sum / period;
|
|
974
|
+
let variance = 0;
|
|
975
|
+
for (let j = i - period + 1; j <= i; j++) {
|
|
976
|
+
const d = (values[j] ?? 0) - mean;
|
|
977
|
+
variance += d * d;
|
|
978
|
+
}
|
|
979
|
+
out.push(Math.sqrt(Math.max(0, variance / period)));
|
|
980
|
+
}
|
|
981
|
+
return out;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// src/indicators/bollinger.ts
|
|
985
|
+
function bollinger(closes, period = 20, multiplier = 2) {
|
|
986
|
+
const middle = sma(closes, period);
|
|
987
|
+
const deviation = stdev(closes, period);
|
|
988
|
+
const upper = [];
|
|
989
|
+
const lower = [];
|
|
990
|
+
for (let i = 0; i < closes.length; i++) {
|
|
991
|
+
const m = middle[i];
|
|
992
|
+
const d = deviation[i];
|
|
993
|
+
if (m === null || d === null || m === void 0 || d === void 0) {
|
|
994
|
+
upper.push(null);
|
|
995
|
+
lower.push(null);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
upper.push(m + multiplier * d);
|
|
999
|
+
lower.push(m - multiplier * d);
|
|
1000
|
+
}
|
|
1001
|
+
return { upper, middle, lower };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// src/indicators/macd.ts
|
|
1005
|
+
function macd(closes, fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) {
|
|
1006
|
+
const fast = ema(closes, fastPeriod);
|
|
1007
|
+
const slow = ema(closes, slowPeriod);
|
|
1008
|
+
const line = closes.map((_, i) => {
|
|
1009
|
+
const f = fast[i];
|
|
1010
|
+
const s = slow[i];
|
|
1011
|
+
return f === null || s === null || f === void 0 || s === void 0 ? null : f - s;
|
|
1012
|
+
});
|
|
1013
|
+
const firstReal = line.findIndex((v) => v !== null);
|
|
1014
|
+
const signal = line.map(() => null);
|
|
1015
|
+
if (firstReal !== -1) {
|
|
1016
|
+
const dense = line.slice(firstReal);
|
|
1017
|
+
const smoothed = ema(dense, signalPeriod);
|
|
1018
|
+
for (let i = 0; i < smoothed.length; i++) signal[firstReal + i] = smoothed[i] ?? null;
|
|
1019
|
+
}
|
|
1020
|
+
const histogram = line.map((value, i) => {
|
|
1021
|
+
const s = signal[i];
|
|
1022
|
+
return value === null || s === null || s === void 0 ? null : value - s;
|
|
1023
|
+
});
|
|
1024
|
+
return { macd: line, signal, histogram };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// src/indicators/rsi.ts
|
|
1028
|
+
function rsi(closes, period = 14) {
|
|
1029
|
+
if (closes.length === 0) return [];
|
|
1030
|
+
const gains = [];
|
|
1031
|
+
const losses = [];
|
|
1032
|
+
for (let i = 1; i < closes.length; i++) {
|
|
1033
|
+
const change = (closes[i] ?? 0) - (closes[i - 1] ?? 0);
|
|
1034
|
+
gains.push(Math.max(0, change));
|
|
1035
|
+
losses.push(Math.max(0, -change));
|
|
1036
|
+
}
|
|
1037
|
+
const avgGain = wilder(gains, period);
|
|
1038
|
+
const avgLoss = wilder(losses, period);
|
|
1039
|
+
const out = [null];
|
|
1040
|
+
for (let i = 0; i < avgGain.length; i++) {
|
|
1041
|
+
const g = avgGain[i];
|
|
1042
|
+
const l = avgLoss[i];
|
|
1043
|
+
if (g === null || l === null || g === void 0 || l === void 0) {
|
|
1044
|
+
out.push(null);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
out.push(l === 0 ? 100 : 100 - 100 / (1 + g / l));
|
|
1048
|
+
}
|
|
1049
|
+
return out;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/indicators/attach.ts
|
|
1053
|
+
function points(bars, values) {
|
|
1054
|
+
const out = [];
|
|
1055
|
+
for (let i = 0; i < bars.length; i++) {
|
|
1056
|
+
const value = values[i];
|
|
1057
|
+
const bar = bars[i];
|
|
1058
|
+
if (value === null || value === void 0 || bar === void 0) continue;
|
|
1059
|
+
out.push({ time: bar.time, value });
|
|
1060
|
+
}
|
|
1061
|
+
return out;
|
|
1062
|
+
}
|
|
1063
|
+
function addIndicator(chart, spec, bars) {
|
|
1064
|
+
const created = [];
|
|
1065
|
+
let ownPaneIndex = null;
|
|
1066
|
+
function newPane() {
|
|
1067
|
+
const index = chart.panes().length;
|
|
1068
|
+
ownPaneIndex = index;
|
|
1069
|
+
return index;
|
|
1070
|
+
}
|
|
1071
|
+
if (spec.type === "bollinger") {
|
|
1072
|
+
const colour = spec.colour ?? "#2962ff";
|
|
1073
|
+
const band = { lineWidth: 1, priceLineVisible: false, lastValueVisible: false };
|
|
1074
|
+
const upper = chart.addSeries(LineSeries, { color: colour, ...band });
|
|
1075
|
+
const middle = chart.addSeries(LineSeries, {
|
|
1076
|
+
color: colour,
|
|
1077
|
+
lineWidth: 1,
|
|
1078
|
+
lineStyle: 2,
|
|
1079
|
+
priceLineVisible: false,
|
|
1080
|
+
lastValueVisible: false
|
|
1081
|
+
});
|
|
1082
|
+
const lower = chart.addSeries(LineSeries, { color: colour, ...band });
|
|
1083
|
+
created.push(upper, middle, lower);
|
|
1084
|
+
const apply2 = (data) => {
|
|
1085
|
+
const closes = data.map((b) => b.close);
|
|
1086
|
+
const result = bollinger(closes, spec.period ?? 20, spec.multiplier ?? 2);
|
|
1087
|
+
upper.setData(points(data, result.upper));
|
|
1088
|
+
middle.setData(points(data, result.middle));
|
|
1089
|
+
lower.setData(points(data, result.lower));
|
|
1090
|
+
};
|
|
1091
|
+
apply2(bars);
|
|
1092
|
+
return { setData: apply2, remove: () => removeAll(chart, created, ownPaneIndex) };
|
|
1093
|
+
}
|
|
1094
|
+
if (spec.type === "rsi") {
|
|
1095
|
+
const pane2 = newPane();
|
|
1096
|
+
const line2 = chart.addSeries(
|
|
1097
|
+
LineSeries,
|
|
1098
|
+
{
|
|
1099
|
+
color: spec.colour ?? "#7e57c2",
|
|
1100
|
+
lineWidth: 2,
|
|
1101
|
+
priceLineVisible: false,
|
|
1102
|
+
// 0-100 by definition, so pinning the scale stops it rescaling to
|
|
1103
|
+
// the visible range and making a move from 45 to 55 look violent.
|
|
1104
|
+
autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: 100 } })
|
|
1105
|
+
},
|
|
1106
|
+
pane2
|
|
1107
|
+
);
|
|
1108
|
+
for (const level of [70, 30]) {
|
|
1109
|
+
line2.createPriceLine({
|
|
1110
|
+
price: level,
|
|
1111
|
+
color: "#9598a1",
|
|
1112
|
+
lineWidth: 1,
|
|
1113
|
+
lineStyle: 2,
|
|
1114
|
+
axisLabelVisible: true,
|
|
1115
|
+
title: ""
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
created.push(line2);
|
|
1119
|
+
const apply2 = (data) => {
|
|
1120
|
+
line2.setData(points(data, rsi(data.map((b) => b.close), spec.period ?? 14)));
|
|
1121
|
+
};
|
|
1122
|
+
apply2(bars);
|
|
1123
|
+
chart.panes()[pane2]?.setHeight(110);
|
|
1124
|
+
return { setData: apply2, remove: () => removeAll(chart, created, ownPaneIndex) };
|
|
1125
|
+
}
|
|
1126
|
+
const pane = newPane();
|
|
1127
|
+
const histogram = chart.addSeries(
|
|
1128
|
+
HistogramSeries,
|
|
1129
|
+
{ priceLineVisible: false, lastValueVisible: false },
|
|
1130
|
+
pane
|
|
1131
|
+
);
|
|
1132
|
+
const line = chart.addSeries(
|
|
1133
|
+
LineSeries,
|
|
1134
|
+
{ color: "#2962ff", lineWidth: 2, priceLineVisible: false },
|
|
1135
|
+
pane
|
|
1136
|
+
);
|
|
1137
|
+
const signal = chart.addSeries(
|
|
1138
|
+
LineSeries,
|
|
1139
|
+
{ color: "#ff6d00", lineWidth: 2, priceLineVisible: false },
|
|
1140
|
+
pane
|
|
1141
|
+
);
|
|
1142
|
+
created.push(histogram, line, signal);
|
|
1143
|
+
const apply = (data) => {
|
|
1144
|
+
const result = macd(
|
|
1145
|
+
data.map((b) => b.close),
|
|
1146
|
+
spec.fastPeriod ?? 12,
|
|
1147
|
+
spec.slowPeriod ?? 26,
|
|
1148
|
+
spec.signalPeriod ?? 9
|
|
1149
|
+
);
|
|
1150
|
+
line.setData(points(data, result.macd));
|
|
1151
|
+
signal.setData(points(data, result.signal));
|
|
1152
|
+
histogram.setData(
|
|
1153
|
+
points(data, result.histogram).map((p) => ({
|
|
1154
|
+
...p,
|
|
1155
|
+
color: p.value >= 0 ? "#26a69a80" : "#ef535080"
|
|
1156
|
+
}))
|
|
1157
|
+
);
|
|
1158
|
+
};
|
|
1159
|
+
apply(bars);
|
|
1160
|
+
chart.panes()[pane]?.setHeight(110);
|
|
1161
|
+
return { setData: apply, remove: () => removeAll(chart, created, ownPaneIndex) };
|
|
1162
|
+
}
|
|
1163
|
+
function removeAll(chart, series, paneIndex) {
|
|
1164
|
+
for (const s of series) chart.removeSeries(s);
|
|
1165
|
+
series.length = 0;
|
|
1166
|
+
if (paneIndex !== null && chart.panes().length > paneIndex) {
|
|
1167
|
+
const pane = chart.panes()[paneIndex];
|
|
1168
|
+
if (pane !== void 0 && pane.getSeries().length === 0) chart.removePane(paneIndex);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/index.ts
|
|
1173
|
+
function createTrendkit(options) {
|
|
1174
|
+
const store = new DrawingStore();
|
|
1175
|
+
const tools = new Map(options.tools.map((tool) => [tool.id, tool]));
|
|
1176
|
+
let theme = options.theme ?? LIGHT_THEME;
|
|
1177
|
+
const primitive = new TrendkitPrimitive(options.chart, options.series, {
|
|
1178
|
+
drawings: () => store.all(),
|
|
1179
|
+
selectedId: () => store.selectedId(),
|
|
1180
|
+
tool: (id) => tools.get(id),
|
|
1181
|
+
theme: () => theme
|
|
1182
|
+
});
|
|
1183
|
+
options.series.attachPrimitive(primitive);
|
|
1184
|
+
const unsubscribe = store.subscribe(() => primitive.update());
|
|
1185
|
+
const interaction = new InteractionController({
|
|
1186
|
+
chart: options.chart,
|
|
1187
|
+
series: options.series,
|
|
1188
|
+
store,
|
|
1189
|
+
hits: primitive,
|
|
1190
|
+
pointCount: (id) => tools.get(id)?.pointCount ?? 2,
|
|
1191
|
+
isTransient: (id) => tools.get(id)?.transient === true,
|
|
1192
|
+
magnetPx: options.magnetPx ?? 6
|
|
1193
|
+
});
|
|
1194
|
+
return {
|
|
1195
|
+
store,
|
|
1196
|
+
setActiveTool(tool) {
|
|
1197
|
+
interaction.setActiveTool(tool);
|
|
1198
|
+
},
|
|
1199
|
+
activeTool() {
|
|
1200
|
+
return interaction.getActiveTool();
|
|
1201
|
+
},
|
|
1202
|
+
setTheme(next) {
|
|
1203
|
+
theme = next;
|
|
1204
|
+
primitive.update();
|
|
1205
|
+
},
|
|
1206
|
+
toJSON() {
|
|
1207
|
+
return toSnapshot(store.all());
|
|
1208
|
+
},
|
|
1209
|
+
fromJSON(snapshot) {
|
|
1210
|
+
store.replaceAll(fromSnapshot(snapshot));
|
|
1211
|
+
},
|
|
1212
|
+
destroy() {
|
|
1213
|
+
interaction.destroy();
|
|
1214
|
+
unsubscribe();
|
|
1215
|
+
options.series.detachPrimitive(primitive);
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
export { DARK_THEME, FIB_LEVELS, Fibonacci, HorizontalRay, LIGHT_THEME, Measure, Rectangle, Trendline, addIndicator, bollinger, createTrendkit, distanceToRect, distanceToSegment, ema, fibonacciPrices, macd, percentChange, rsi, sma, stdev, wilder };
|