traderead-charts 1.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/CHANGELOG.md +35 -0
- package/LICENSE.md +30 -0
- package/README.md +107 -0
- package/package.json +35 -0
- package/src/traderead-charts.js +2243 -0
|
@@ -0,0 +1,2243 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* TradeRead Charts v1.0.0
|
|
3
|
+
* Copyright (c) 2026 Marcel Todea / TradeRead — traderead.ai
|
|
4
|
+
* Original work, written from first principles.
|
|
5
|
+
* TradeRead Community License 1.0 (see LICENSE.md): free to use, including
|
|
6
|
+
* commercially - the TradeRead mark stays visible.
|
|
7
|
+
*/
|
|
8
|
+
(function (global) {
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
var DEFAULTS = {
|
|
12
|
+
background: "#0d1117",
|
|
13
|
+
textColor: "#8b949e",
|
|
14
|
+
gridColor: "rgba(139,148,158,0.12)",
|
|
15
|
+
separatorColor: "rgba(139,148,158,0.25)",
|
|
16
|
+
upColor: "#00d97e",
|
|
17
|
+
downColor: "#f85149",
|
|
18
|
+
wickUp: "#00d97e",
|
|
19
|
+
wickDown: "#f85149",
|
|
20
|
+
volumeUp: "rgba(0,217,126,0.35)",
|
|
21
|
+
volumeDown: "rgba(248,81,73,0.35)",
|
|
22
|
+
crosshair: "rgba(139,148,158,0.45)",
|
|
23
|
+
tagBg: "#2a2e39",
|
|
24
|
+
tagText: "#e6edf3",
|
|
25
|
+
font: "11px -apple-system, 'Segoe UI', system-ui, sans-serif",
|
|
26
|
+
priceAxisWidth: 64,
|
|
27
|
+
timeAxisHeight: 24,
|
|
28
|
+
barSpacing: 8,
|
|
29
|
+
minBarSpacing: 1.5,
|
|
30
|
+
maxBarSpacing: 60,
|
|
31
|
+
rightPadBars: 5,
|
|
32
|
+
volumeHeightPct: 0.18,
|
|
33
|
+
autoScalePadPct: 0.08,
|
|
34
|
+
oscPaneHeight: 110, // default px height of an oscillator pane
|
|
35
|
+
minPricePaneFrac: 0.45, // price pane never shrinks below this share
|
|
36
|
+
onFullscreenToggle: null, // host override: an iframe cannot fullscreen itself on iOS
|
|
37
|
+
logoHref: null, // override the badge link (default carries utm_source=widget)
|
|
38
|
+
logo: true,
|
|
39
|
+
ui: true,
|
|
40
|
+
maxIndicators: 0, // 0 = unlimited (Pro); Standard builds pass 4 // the bundled rail + indicator panel + type switcher + camera
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
|
|
44
|
+
function isNum(v) { return typeof v === "number" && isFinite(v); }
|
|
45
|
+
|
|
46
|
+
function niceStep(span, n) {
|
|
47
|
+
var raw = span / Math.max(1, n);
|
|
48
|
+
var mag = Math.pow(10, Math.floor(Math.log(raw) / Math.LN10));
|
|
49
|
+
var norm = raw / mag;
|
|
50
|
+
var step = norm >= 5 ? 10 : norm >= 2.5 ? 5 : norm >= 2 ? 2.5 : norm >= 1 ? 2 : 1;
|
|
51
|
+
return step * mag;
|
|
52
|
+
}
|
|
53
|
+
function stepDecimals(step) {
|
|
54
|
+
if (step >= 1) return step % 1 === 0 ? 0 : 2;
|
|
55
|
+
var d = 0, s = step;
|
|
56
|
+
while (s < 1 && d < 10) { s *= 10; d++; }
|
|
57
|
+
return d + (Math.round(s) !== s ? 1 : 0);
|
|
58
|
+
}
|
|
59
|
+
function fmtPrice(v, dec) {
|
|
60
|
+
if (Math.abs(v) < Math.pow(10, -(dec + 3))) v = 0; // float dust must not read "-0"
|
|
61
|
+
return v.toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
|
62
|
+
}
|
|
63
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
64
|
+
function pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
|
65
|
+
|
|
66
|
+
// A pane owns a horizontal slice of the plot and its own value scale.
|
|
67
|
+
// Pane 0 is the price pane (candles, volume band, overlay lines); every
|
|
68
|
+
// further pane is an oscillator: lines, histograms and fixed guides.
|
|
69
|
+
function Pane(chart, kind) {
|
|
70
|
+
this.chart = chart;
|
|
71
|
+
this.kind = kind; // "price" | "osc"
|
|
72
|
+
this.hPx = kind === "osc" ? chart.opt.oscPaneHeight : null;
|
|
73
|
+
this.lines = {}; // id → {byTime, color, width}
|
|
74
|
+
this.hists = {}; // id → {byTime, pos, neg} (osc only)
|
|
75
|
+
this.guides = []; // {value, color} (osc only)
|
|
76
|
+
this.y0 = 0; this.h = 0; // set by layout
|
|
77
|
+
this.min = 0; this.max = 1; // set per paint
|
|
78
|
+
}
|
|
79
|
+
Pane.prototype.toY = function (v) {
|
|
80
|
+
var usable = this.kind === "price" ? this.h * (1 - this.chart.opt.volumeHeightPct * 0.35) : this.h - 8;
|
|
81
|
+
var off = this.kind === "price" ? 0 : 4;
|
|
82
|
+
return this.y0 + off + (this.max - v) / (this.max - this.min) * usable;
|
|
83
|
+
};
|
|
84
|
+
Pane.prototype.toValue = function (y) {
|
|
85
|
+
var usable = this.kind === "price" ? this.h * (1 - this.chart.opt.volumeHeightPct * 0.35) : this.h - 8;
|
|
86
|
+
var off = this.kind === "price" ? 0 : 4;
|
|
87
|
+
return this.max - ((y - this.y0 - off) / usable) * (this.max - this.min);
|
|
88
|
+
};
|
|
89
|
+
Pane.prototype.computeScale = function (lo, hi) {
|
|
90
|
+
var min = Infinity, max = -Infinity, i, b, v, id;
|
|
91
|
+
var bars = this.chart.bars;
|
|
92
|
+
if (this.kind === "price") {
|
|
93
|
+
for (i = lo; i <= hi; i++) {
|
|
94
|
+
b = bars[i]; if (!b) continue;
|
|
95
|
+
if (b.low < min) min = b.low;
|
|
96
|
+
if (b.high > max) max = b.high;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// The price pane scales from BARS alone: overlays ride the frame. An
|
|
100
|
+
// EMA200 far from price once pinned the candles to the top of the pane
|
|
101
|
+
// (owner, 28 Aug: "TV e central") — that lesson is now structural.
|
|
102
|
+
if (this.kind !== "price") for (id in this.lines) {
|
|
103
|
+
var ln = this.lines[id];
|
|
104
|
+
for (i = lo; i <= hi; i++) {
|
|
105
|
+
b = bars[i]; if (!b) continue;
|
|
106
|
+
v = ln.byTime.get(b.time);
|
|
107
|
+
if (isNum(v)) { if (v < min) min = v; if (v > max) max = v; }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (id in this.hists) {
|
|
111
|
+
var hs = this.hists[id];
|
|
112
|
+
for (i = lo; i <= hi; i++) {
|
|
113
|
+
b = bars[i]; if (!b) continue;
|
|
114
|
+
v = hs.byTime.get(b.time);
|
|
115
|
+
if (isNum(v)) { if (v < min) min = v; if (v > max) max = v; if (0 < min) min = 0; if (0 > max) max = 0; }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (i = 0; i < this.guides.length; i++) {
|
|
119
|
+
v = this.guides[i].value;
|
|
120
|
+
if (v < min) min = v;
|
|
121
|
+
if (v > max) max = v;
|
|
122
|
+
}
|
|
123
|
+
if (!isFinite(min) || !isFinite(max)) { min = 0; max = 1; }
|
|
124
|
+
if (min === max) { min -= 0.5; max += 0.5; }
|
|
125
|
+
var pad = (max - min) * this.chart.opt.autoScalePadPct;
|
|
126
|
+
min -= pad; max += pad;
|
|
127
|
+
if (this.kind === "price" && this.chart._vZoom !== 1) {
|
|
128
|
+
var mid = (min + max) / 2, halfR = (max - min) / 2 * this.chart._vZoom;
|
|
129
|
+
min = mid - halfR; max = mid + halfR;
|
|
130
|
+
}
|
|
131
|
+
if (this.kind === "price" && this.chart._vOff) {
|
|
132
|
+
var sh = (max - min) * this.chart._vOff;
|
|
133
|
+
min += sh; max += sh;
|
|
134
|
+
}
|
|
135
|
+
this.min = min; this.max = max;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function Chart(container, options) {
|
|
139
|
+
var self = this;
|
|
140
|
+
this.el = container;
|
|
141
|
+
this.opt = Object.assign({}, DEFAULTS, options || {});
|
|
142
|
+
this.bars = [];
|
|
143
|
+
this.panes = [new Pane(this, "price")];
|
|
144
|
+
this.priceLines = [];
|
|
145
|
+
this.drawings = []; // {id, type, t1,p1, t2,p2, text?, size?} in (time, price) space
|
|
146
|
+
this.tool = null; // null = cursor (select/move); tools mirror the production rail
|
|
147
|
+
this._draft = null;
|
|
148
|
+
this._selected = null;
|
|
149
|
+
this._dragDraw = null; // {d, handle:"p2"|null, t0, v0, orig}
|
|
150
|
+
this._persistKey = null;
|
|
151
|
+
this._saveT = null;
|
|
152
|
+
this._sepDrag = null; // {i, y0, h0} while a pane separator is dragged
|
|
153
|
+
this._vZoom = 1; // vertical stretch of the price pane (wheel on the axis)
|
|
154
|
+
this._vOff = 0; // vertical pan of the price pane, in range fractions
|
|
155
|
+
this._axisDrag = null; // {axis:"price"|"time", ...} while an axis is being dragged
|
|
156
|
+
this.indicators = []; // built-in indicator list (see addIndicator)
|
|
157
|
+
this._indColor = 0;
|
|
158
|
+
this.chartType = "candles"; // "candles" | "bars" | "line" | "area" | "baseline"
|
|
159
|
+
this.markers = []; // {time, position:"above"|"below", shape, color, text}
|
|
160
|
+
this.barSpacing = this.opt.barSpacing;
|
|
161
|
+
this.rightIndex = 0;
|
|
162
|
+
this.crosshairCb = null;
|
|
163
|
+
this._cross = null;
|
|
164
|
+
|
|
165
|
+
container.style.position = container.style.position || "relative";
|
|
166
|
+
container.style.background = this.opt.background;
|
|
167
|
+
container.style.overflow = "hidden";
|
|
168
|
+
|
|
169
|
+
this.canvas = document.createElement("canvas");
|
|
170
|
+
this.drawCanvas = document.createElement("canvas");
|
|
171
|
+
this.overlay = document.createElement("canvas");
|
|
172
|
+
[this.canvas, this.drawCanvas, this.overlay].forEach(function (c) {
|
|
173
|
+
c.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;display:block;";
|
|
174
|
+
container.appendChild(c);
|
|
175
|
+
});
|
|
176
|
+
this.ctx = this.canvas.getContext("2d");
|
|
177
|
+
this.dctx = this.drawCanvas.getContext("2d");
|
|
178
|
+
this.octx = this.overlay.getContext("2d");
|
|
179
|
+
|
|
180
|
+
if (this.opt.logo) this._mountLogo();
|
|
181
|
+
if (this.opt.ui) this._mountUI();
|
|
182
|
+
|
|
183
|
+
this._ro = new ResizeObserver(function () { self._resize(); });
|
|
184
|
+
this._ro.observe(container);
|
|
185
|
+
this._resize();
|
|
186
|
+
this._bind();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
// ── Bundled UI: the plus over the bare engine ────────────────────────
|
|
191
|
+
// ui: true (default) mounts the production-style rail: drawing tools,
|
|
192
|
+
// the TradingView-style indicator panel (many EMAs, SMA, add/remove,
|
|
193
|
+
// editable params), the chart-type switcher and a PNG camera.
|
|
194
|
+
var SVGI = function (inner) {
|
|
195
|
+
return '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">' + inner + "</svg>";
|
|
196
|
+
};
|
|
197
|
+
var ICONS = {
|
|
198
|
+
cursor: SVGI('<path d="M4 2 L12 8.5 L8.2 9.3 L6.2 13.5 Z" fill="currentColor" stroke="none"/>'),
|
|
199
|
+
trend: SVGI('<line x1="3" y1="13" x2="13" y2="3"/><circle cx="3" cy="13" r="1.4" fill="currentColor" stroke="none"/><circle cx="13" cy="3" r="1.4" fill="currentColor" stroke="none"/>'),
|
|
200
|
+
hline: SVGI('<line x1="2" y1="8" x2="14" y2="8"/><circle cx="8" cy="8" r="1.4" fill="currentColor" stroke="none"/>'),
|
|
201
|
+
rect: SVGI('<rect x="3" y="4.5" width="10" height="7" rx="1"/>'),
|
|
202
|
+
fib: SVGI('<line x1="2.5" y1="4" x2="13.5" y2="4"/><line x1="2.5" y1="8" x2="13.5" y2="8" opacity="0.55"/><line x1="2.5" y1="12" x2="13.5" y2="12"/>'),
|
|
203
|
+
text: SVGI('<path d="M4 4 H12 M8 4 V12.5" />'),
|
|
204
|
+
trash: SVGI('<line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/>'),
|
|
205
|
+
ind: SVGI('<path d="M2.5 11 L6 7 L9 9.5 L13.5 4"/><circle cx="13.5" cy="4" r="1.3" fill="currentColor" stroke="none"/>'),
|
|
206
|
+
camera: SVGI('<rect x="2.5" y="5" width="11" height="8" rx="1.5"/><path d="M6 5 L7 3.2 H9 L10 5"/><circle cx="8" cy="9" r="2.2"/>'),
|
|
207
|
+
candles: SVGI('<line x1="5" y1="2.5" x2="5" y2="13.5"/><rect x="3.4" y="5" width="3.2" height="5" fill="currentColor" stroke="none" rx="0.6"/><line x1="11" y1="2.5" x2="11" y2="13.5"/><rect x="9.4" y="4" width="3.2" height="6.5" fill="currentColor" stroke="none" rx="0.6"/>'),
|
|
208
|
+
bars: SVGI('<line x1="5" y1="3" x2="5" y2="13"/><line x1="3.5" y1="6" x2="5" y2="6"/><line x1="5" y1="10" x2="6.5" y2="10"/><line x1="11" y1="3" x2="11" y2="13"/><line x1="9.5" y1="7" x2="11" y2="7"/><line x1="11" y1="11" x2="12.5" y2="11"/>'),
|
|
209
|
+
line: SVGI('<path d="M2.5 11 L6 7 L9 9.5 L13.5 4"/>'),
|
|
210
|
+
area: SVGI('<path d="M2.5 11 L6 7 L9 9.5 L13.5 4 V13 H2.5 Z" fill="currentColor" opacity="0.35" stroke="none"/><path d="M2.5 11 L6 7 L9 9.5 L13.5 4"/>'),
|
|
211
|
+
baseline: SVGI('<line x1="2" y1="8" x2="14" y2="8" stroke-dasharray="2 2"/><path d="M2.5 10.5 L6 5.5 L9 9 L13.5 4"/>'),
|
|
212
|
+
hollow: SVGI('<line x1="5" y1="2.5" x2="5" y2="13.5"/><rect x="3.4" y="5" width="3.2" height="5" rx="0.6"/><line x1="11" y1="2.5" x2="11" y2="13.5"/><rect x="9.4" y="4" width="3.2" height="6.5" fill="currentColor" stroke="none" rx="0.6"/>'),
|
|
213
|
+
heikin: SVGI('<rect x="2.8" y="6" width="3.4" height="6" rx="1.6"/><rect x="9.8" y="3.5" width="3.4" height="6" rx="1.6" fill="currentColor" stroke="none"/>'),
|
|
214
|
+
share: SVGI('<path d="M8 10 V2.8 M5.5 5 L8 2.5 L10.5 5"/><path d="M4 8.5 H3 V13.5 H13 V8.5 H12"/>'),
|
|
215
|
+
fit: SVGI('<path d="M6 2.5 V6 H2.5 M13.5 6 H10 V2.5 M10 13.5 V10 H13.5 M2.5 10 H6 V13.5"/>'),
|
|
216
|
+
};
|
|
217
|
+
var TYPE_LIST = [
|
|
218
|
+
["candles", "Candles"], ["hollow", "Hollow candles"], ["heikin", "Heikin Ashi"],
|
|
219
|
+
["bars", "OHLC bars"], ["line", "Line"], ["area", "Area"], ["baseline", "Baseline"],
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
Chart.prototype._mountUI = function () {
|
|
223
|
+
var self = this, o = this.opt;
|
|
224
|
+
var rail = document.createElement("div");
|
|
225
|
+
rail.className = "trc-rail";
|
|
226
|
+
rail.style.cssText = "position:absolute;left:8px;top:10px;z-index:6;display:flex;flex-direction:column;gap:3px;" +
|
|
227
|
+
"background:" + o.background + ";border:1px solid " + o.separatorColor + ";border-radius:10px;padding:4px;" +
|
|
228
|
+
// a phone-height frame is shorter than the tool column: the rail
|
|
229
|
+
// scrolls so the bottom buttons (camera, share, fit) stay reachable
|
|
230
|
+
"overflow-y:auto;overflow-x:hidden;scrollbar-width:none;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;flex-shrink:0;";
|
|
231
|
+
if (!document.getElementById("trc-style")) {
|
|
232
|
+
var st = document.createElement("style");
|
|
233
|
+
st.id = "trc-style";
|
|
234
|
+
st.textContent = ".trc-rail::-webkit-scrollbar{display:none}";
|
|
235
|
+
document.head.appendChild(st);
|
|
236
|
+
}
|
|
237
|
+
function btn(icon, tip, fn) {
|
|
238
|
+
var b = document.createElement("button");
|
|
239
|
+
b.type = "button"; b.innerHTML = icon; b.title = tip;
|
|
240
|
+
b.style.cssText = "border:none;background:none;color:" + o.textColor + ";width:26px;height:26px;flex-shrink:0;" +
|
|
241
|
+
"border-radius:7px;cursor:pointer;font:700 13px -apple-system,'Segoe UI',sans-serif;line-height:1;";
|
|
242
|
+
b.addEventListener("click", function () { fn(b); });
|
|
243
|
+
rail.appendChild(b);
|
|
244
|
+
return b;
|
|
245
|
+
}
|
|
246
|
+
function mark(active) {
|
|
247
|
+
rail.querySelectorAll("button").forEach(function (b) {
|
|
248
|
+
b.style.background = b === active ? "rgba(99,102,241,0.3)" : "none";
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
var toolBtns = {};
|
|
252
|
+
[[ICONS.cursor, "Select / move", null], [ICONS.trend, "Trend line", "trend"], [ICONS.hline, "Horizontal line", "hline"],
|
|
253
|
+
[ICONS.rect, "Rectangle", "rect"], [ICONS.fib, "Fibonacci retracement", "fib"], [ICONS.text, "Text", "text"]].forEach(function (t) {
|
|
254
|
+
toolBtns[t[2] || "cursor"] = btn(t[0], t[1], function (b) { self.setTool(t[2]); mark(t[2] ? b : toolBtns.cursor); });
|
|
255
|
+
});
|
|
256
|
+
mark(toolBtns.cursor);
|
|
257
|
+
this.onToolDone(function () { mark(toolBtns.cursor); });
|
|
258
|
+
btn(ICONS.trash, "Delete selected (Del)", function () { self.deleteSelected(); });
|
|
259
|
+
var sep = document.createElement("div");
|
|
260
|
+
sep.style.cssText = "height:1px;background:" + o.separatorColor + ";margin:3px 2px;flex-shrink:0;";
|
|
261
|
+
rail.appendChild(sep);
|
|
262
|
+
this._indBtn = btn(ICONS.ind, "Indicators", function () { self._toggleIndPanel(); });
|
|
263
|
+
var typeBtn = btn(ICONS.candles, "Chart type", function () { self._toggleTypeMenu(typeBtn); });
|
|
264
|
+
this._typeBtn = typeBtn;
|
|
265
|
+
btn(ICONS.camera, "Screenshot (PNG)", function () { self.snapshot(); });
|
|
266
|
+
btn(ICONS.share, "Share chart", function (b) { self.share(b); });
|
|
267
|
+
btn(ICONS.fit, "Fit chart", function () { self.fit(); });
|
|
268
|
+
this.el.appendChild(rail);
|
|
269
|
+
this._rail = rail;
|
|
270
|
+
|
|
271
|
+
// fullscreen, bottom-right (owner, 7 Sep 2026)
|
|
272
|
+
var fsIcon = SVGI('<path d="M2.5 6 V2.5 H6 M10 2.5 H13.5 V6 M13.5 10 V13.5 H10 M6 13.5 H2.5 V10"/>');
|
|
273
|
+
var fsExit = SVGI('<path d="M6 2.5 V6 H2.5 M13.5 6 H10 V2.5 M10 13.5 V10 H13.5 M2.5 10 H6 V13.5"/>');
|
|
274
|
+
var fsBtn = document.createElement("button");
|
|
275
|
+
fsBtn.type = "button"; fsBtn.title = "Fullscreen"; fsBtn.innerHTML = fsIcon;
|
|
276
|
+
// visibly a button, not axis furniture (owner: nobody would find the
|
|
277
|
+
// faint one): accent tint, larger hit area, brightens on hover
|
|
278
|
+
fsBtn.style.cssText = "position:absolute;right:" + (o.priceAxisWidth + 10) + "px;bottom:" + (o.timeAxisHeight + 10) + "px;z-index:6;" +
|
|
279
|
+
"width:34px;height:34px;border:1px solid rgba(129,140,248,0.55);border-radius:10px;" +
|
|
280
|
+
"background:rgba(99,102,241,0.22);color:" + o.tagText + ";cursor:pointer;" +
|
|
281
|
+
"display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,0.25);";
|
|
282
|
+
fsBtn.addEventListener("mouseenter", function () { fsBtn.style.background = "rgba(99,102,241,0.4)"; });
|
|
283
|
+
fsBtn.addEventListener("mouseleave", function () { fsBtn.style.background = "rgba(99,102,241,0.22)"; });
|
|
284
|
+
this._fsIcons = { on: fsExit, off: fsIcon };
|
|
285
|
+
fsBtn.addEventListener("click", function () {
|
|
286
|
+
// a host override wins: inside an iframe the embedding page must
|
|
287
|
+
// maximize the frame (iOS has no element-fullscreen API at all)
|
|
288
|
+
if (o.onFullscreenToggle) { o.onFullscreenToggle(); return; }
|
|
289
|
+
var fsEl = document.fullscreenElement || document.webkitFullscreenElement;
|
|
290
|
+
if (fsEl === self.el) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
|
|
291
|
+
else if (self.el.requestFullscreen) self.el.requestFullscreen();
|
|
292
|
+
else if (self.el.webkitRequestFullscreen) self.el.webkitRequestFullscreen();
|
|
293
|
+
else self._toggleFakeFullscreen();
|
|
294
|
+
});
|
|
295
|
+
document.addEventListener("fullscreenchange", function () {
|
|
296
|
+
self.setMaximized(document.fullscreenElement === self.el);
|
|
297
|
+
});
|
|
298
|
+
this.el.appendChild(fsBtn);
|
|
299
|
+
this._fsBtn = fsBtn;
|
|
300
|
+
this._fitLogo();
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// The host (or a native fullscreenchange) reports the state; the button
|
|
304
|
+
// swaps its icon to match.
|
|
305
|
+
Chart.prototype.setMaximized = function (on) {
|
|
306
|
+
if (this._fsBtn && this._fsIcons) this._fsBtn.innerHTML = on ? this._fsIcons.on : this._fsIcons.off;
|
|
307
|
+
};
|
|
308
|
+
// iOS Safari fallback: pin the container to the viewport. Restoring puts
|
|
309
|
+
// back the exact inline style the element had.
|
|
310
|
+
Chart.prototype._toggleFakeFullscreen = function () {
|
|
311
|
+
if (this._fakeFS != null) {
|
|
312
|
+
this.el.style.cssText = this._fakeFS;
|
|
313
|
+
this._fakeFS = null;
|
|
314
|
+
} else {
|
|
315
|
+
this._fakeFS = this.el.style.cssText;
|
|
316
|
+
this.el.style.cssText += ";position:fixed;top:0;left:0;width:100vw;height:100vh;margin:0;z-index:2147483000;background:" + (this.opt.background || "#0d1117") + ";";
|
|
317
|
+
}
|
|
318
|
+
this.setMaximized(this._fakeFS != null);
|
|
319
|
+
this._resize();
|
|
320
|
+
};
|
|
321
|
+
// Menus close on any tap outside them. The anchor button is exempt — its
|
|
322
|
+
// own toggle handles that tap, otherwise close-then-reopen double-fires.
|
|
323
|
+
Chart.prototype._armOutsideClose = function (box, anchorBtn, close) {
|
|
324
|
+
var h = function (e) {
|
|
325
|
+
if (!box.isConnected) { document.removeEventListener("pointerdown", h, true); return; }
|
|
326
|
+
if (box.contains(e.target) || (anchorBtn && anchorBtn.contains(e.target))) return;
|
|
327
|
+
document.removeEventListener("pointerdown", h, true);
|
|
328
|
+
close();
|
|
329
|
+
};
|
|
330
|
+
document.addEventListener("pointerdown", h, true);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// A picker list, not a blind cycle: seven types, current one checked.
|
|
334
|
+
|
|
335
|
+
// ── Symbol search ────────────────────────────────────────────────────
|
|
336
|
+
// The selector ships with the widget; the DATA behind it is pluggable:
|
|
337
|
+
// symbolSearch: {
|
|
338
|
+
// search: function (query, cb) { cb([{symbol, display, name, exchange}]) },
|
|
339
|
+
// onSelect: function (item) { ... host loads bars ... },
|
|
340
|
+
// }
|
|
341
|
+
// Matches by ticker or by name; several results = the dropdown decides.
|
|
342
|
+
Chart.prototype._toggleSearch = function () {
|
|
343
|
+
var self = this, o = this.opt;
|
|
344
|
+
var cfg = o.symbolSearch;
|
|
345
|
+
if (!cfg || !cfg.search) return;
|
|
346
|
+
if (this._searchBox) { this._searchBox.remove(); this._searchBox = null; return; }
|
|
347
|
+
var wrap = document.createElement("div");
|
|
348
|
+
wrap.className = "trc-search";
|
|
349
|
+
wrap.style.cssText = "position:absolute;inset:0;z-index:22;display:flex;align-items:flex-start;justify-content:center;" +
|
|
350
|
+
"background:rgba(0,0,0,0.5);padding-top:48px;";
|
|
351
|
+
var box = document.createElement("div");
|
|
352
|
+
box.style.cssText = "width:min(420px,92%);background:" + o.background + ";border:1px solid " + o.separatorColor + ";" +
|
|
353
|
+
"border-radius:14px;padding:10px;box-shadow:0 12px 40px rgba(0,0,0,0.4);";
|
|
354
|
+
var inp = document.createElement("input");
|
|
355
|
+
inp.type = "text";
|
|
356
|
+
inp.placeholder = "Search ticker or name…";
|
|
357
|
+
inp.style.cssText = "width:100%;box-sizing:border-box;background:none;border:1px solid " + o.separatorColor + ";" +
|
|
358
|
+
"border-radius:9px;color:" + o.tagText + ";font:600 14px -apple-system,'Segoe UI',sans-serif;padding:9px 12px;outline:none;";
|
|
359
|
+
var list = document.createElement("div");
|
|
360
|
+
list.style.cssText = "margin-top:8px;max-height:300px;overflow:auto;";
|
|
361
|
+
box.appendChild(inp); box.appendChild(list);
|
|
362
|
+
wrap.appendChild(box);
|
|
363
|
+
wrap.addEventListener("click", function (e) { if (e.target === wrap) close(); });
|
|
364
|
+
this.el.appendChild(wrap);
|
|
365
|
+
this._searchBox = wrap;
|
|
366
|
+
inp.focus();
|
|
367
|
+
|
|
368
|
+
var items = [], active = -1, timer = null;
|
|
369
|
+
function close() { wrap.remove(); self._searchBox = null; }
|
|
370
|
+
function paint() {
|
|
371
|
+
list.innerHTML = "";
|
|
372
|
+
items.forEach(function (it, i) {
|
|
373
|
+
var row = document.createElement("button");
|
|
374
|
+
row.type = "button";
|
|
375
|
+
row.style.cssText = "display:flex;align-items:center;gap:10px;width:100%;text-align:left;border:none;" +
|
|
376
|
+
"background:" + (i === active ? "rgba(99,102,241,0.25)" : "none") + ";color:" + o.tagText + ";" +
|
|
377
|
+
"font:600 13px -apple-system,'Segoe UI',sans-serif;border-radius:8px;padding:8px 10px;cursor:pointer;";
|
|
378
|
+
row.innerHTML = '<span style="min-width:64px;font-weight:800;">' + (it.display || it.symbol) + "</span>" +
|
|
379
|
+
'<span style="flex:1;color:' + o.textColor + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + (it.name || "") + "</span>" +
|
|
380
|
+
(it.exchange ? '<span style="font-size:10px;font-weight:800;color:' + o.textColor + ';border:1px solid ' + o.separatorColor + ';border-radius:5px;padding:1px 5px;">' + it.exchange + "</span>" : "");
|
|
381
|
+
row.addEventListener("click", function () { pick(it); });
|
|
382
|
+
row.addEventListener("mouseenter", function () { active = i; paint(); });
|
|
383
|
+
list.appendChild(row);
|
|
384
|
+
});
|
|
385
|
+
if (!items.length && inp.value.trim()) {
|
|
386
|
+
list.innerHTML = '<div style="color:' + o.textColor + ';font:12px -apple-system,sans-serif;padding:10px;">No matches.</div>';
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function pick(it) {
|
|
390
|
+
close();
|
|
391
|
+
if (cfg.onSelect) cfg.onSelect(it);
|
|
392
|
+
}
|
|
393
|
+
inp.addEventListener("input", function () {
|
|
394
|
+
clearTimeout(timer);
|
|
395
|
+
timer = setTimeout(function () {
|
|
396
|
+
var q = inp.value.trim();
|
|
397
|
+
if (!q) { items = []; active = -1; paint(); return; }
|
|
398
|
+
cfg.search(q, function (res) { items = res || []; active = items.length ? 0 : -1; paint(); });
|
|
399
|
+
}, 160);
|
|
400
|
+
});
|
|
401
|
+
inp.addEventListener("keydown", function (e) {
|
|
402
|
+
if (e.key === "Escape") close();
|
|
403
|
+
else if (e.key === "ArrowDown") { active = Math.min(active + 1, items.length - 1); paint(); e.preventDefault(); }
|
|
404
|
+
else if (e.key === "ArrowUp") { active = Math.max(active - 1, 0); paint(); e.preventDefault(); }
|
|
405
|
+
else if (e.key === "Enter" && active >= 0 && items[active]) pick(items[active]);
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// A picker list, not a blind cycle: seven types, current one checked.
|
|
410
|
+
Chart.prototype._toggleTypeMenu = function (anchorBtn) {
|
|
411
|
+
var self = this, o = this.opt;
|
|
412
|
+
if (this._typeMenu) { this._typeMenu.remove(); this._typeMenu = null; return; }
|
|
413
|
+
var m = document.createElement("div");
|
|
414
|
+
m.className = "trc-type-menu";
|
|
415
|
+
m.style.cssText = "position:absolute;left:44px;top:" + (anchorBtn ? anchorBtn.offsetTop + 4 : 40) + "px;z-index:8;" +
|
|
416
|
+
"background:" + o.background + ";border:1px solid " + o.separatorColor + ";border-radius:12px;padding:6px;" +
|
|
417
|
+
"color:" + o.textColor + ";font:12px -apple-system,'Segoe UI',sans-serif;box-shadow:0 8px 30px rgba(0,0,0,0.35);";
|
|
418
|
+
var fitMenu = function () {
|
|
419
|
+
var h = m.offsetHeight || 0, maxTop = self._plotH() - h - 6;
|
|
420
|
+
var top = parseInt(m.style.top, 10) || 0;
|
|
421
|
+
if (top > maxTop) m.style.top = Math.max(6, maxTop) + "px";
|
|
422
|
+
};
|
|
423
|
+
TYPE_LIST.forEach(function (t) {
|
|
424
|
+
var row = document.createElement("button");
|
|
425
|
+
row.type = "button";
|
|
426
|
+
row.style.cssText = "display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:none;" +
|
|
427
|
+
"background:" + (self.chartType === t[0] ? "rgba(99,102,241,0.25)" : "none") + ";color:inherit;font:inherit;" +
|
|
428
|
+
"border-radius:8px;padding:6px 10px;cursor:pointer;white-space:nowrap;";
|
|
429
|
+
row.innerHTML = ICONS[t[0]] + "<span>" + t[1] + "</span>";
|
|
430
|
+
row.addEventListener("click", function () {
|
|
431
|
+
self.setChartType(t[0]);
|
|
432
|
+
if (self._typeBtn) { self._typeBtn.innerHTML = ICONS[t[0]]; self._typeBtn.title = "Chart type: " + t[1]; }
|
|
433
|
+
m.remove();
|
|
434
|
+
self._typeMenu = null;
|
|
435
|
+
});
|
|
436
|
+
m.appendChild(row);
|
|
437
|
+
});
|
|
438
|
+
this.el.appendChild(m);
|
|
439
|
+
fitMenu();
|
|
440
|
+
this._typeMenu = m;
|
|
441
|
+
this._armOutsideClose(m, this._typeBtn, function () { m.remove(); self._typeMenu = null; });
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
Chart.prototype._toggleIndPanel = function () {
|
|
445
|
+
var self = this, o = this.opt;
|
|
446
|
+
if (this._indPanel) { this._indPanel.remove(); this._indPanel = null; return; }
|
|
447
|
+
var p = document.createElement("div");
|
|
448
|
+
p.className = "trc-ind-panel";
|
|
449
|
+
p.style.cssText = "position:absolute;left:44px;top:10px;z-index:7;min-width:230px;max-height:70%;overflow:auto;" +
|
|
450
|
+
"background:" + o.background + ";border:1px solid " + o.separatorColor + ";border-radius:12px;padding:10px;" +
|
|
451
|
+
"color:" + o.textColor + ";font:12px -apple-system,'Segoe UI',sans-serif;box-shadow:0 8px 30px rgba(0,0,0,0.35);";
|
|
452
|
+
function repaint() {
|
|
453
|
+
var h = '<div style="font-weight:800;margin-bottom:8px;">Indicators</div>';
|
|
454
|
+
var list = self.getIndicators();
|
|
455
|
+
if (!list.length) h += '<div style="opacity:0.6;margin-bottom:8px;">None active.</div>';
|
|
456
|
+
list.forEach(function (ind) {
|
|
457
|
+
var def = IND_DEFS[ind.kind];
|
|
458
|
+
h += '<div style="display:flex;align-items:center;gap:6px;padding:4px 0;" data-row="' + ind.id + '">' +
|
|
459
|
+
'<input type="color" data-color="' + ind.id + '" value="' + (ind.color || "#8b949e") + '" title="Line color" ' +
|
|
460
|
+
'style="width:18px;height:18px;padding:0;border:none;border-radius:4px;background:none;cursor:pointer;">' +
|
|
461
|
+
'<input type="text" data-label="' + ind.id + '" value="' + (ind.label || "") + '" placeholder="' + def.label + '" title="Rename" ' +
|
|
462
|
+
'style="flex:1;min-width:60px;background:none;border:1px solid transparent;border-radius:5px;color:inherit;font:inherit;padding:1px 4px;" ' +
|
|
463
|
+
'onfocus="this.style.borderColor=\'rgba(139,148,158,0.4)\'" onblur="this.style.borderColor=\'transparent\'">';
|
|
464
|
+
Object.keys(ind.params).forEach(function (k) {
|
|
465
|
+
h += '<input data-ind="' + ind.id + '" data-k="' + k + '" type="number" step="any" value="' + ind.params[k] + '" ' +
|
|
466
|
+
'style="width:42px;background:none;border:1px solid ' + o.separatorColor + ';border-radius:5px;color:inherit;font:inherit;padding:1px 3px;">';
|
|
467
|
+
});
|
|
468
|
+
h += '<button data-del="' + ind.id + '" style="border:none;background:none;color:inherit;cursor:pointer;opacity:0.7;font:inherit;">✕</button></div>';
|
|
469
|
+
});
|
|
470
|
+
h += '<div style="border-top:1px solid ' + o.separatorColor + ';margin:8px 0;"></div>';
|
|
471
|
+
if (self.opt.maxIndicators > 0 && list.length >= self.opt.maxIndicators) {
|
|
472
|
+
h += '<div style="color:' + o.textColor + ';font-size:11px;margin:4px 0;">Standard: max ' + self.opt.maxIndicators + ' indicators — Pro is unlimited.</div>';
|
|
473
|
+
}
|
|
474
|
+
Object.keys(IND_DEFS).forEach(function (kind) {
|
|
475
|
+
h += '<button data-add="' + kind + '" style="border:1px solid ' + o.separatorColor + ';background:none;color:inherit;' +
|
|
476
|
+
'cursor:pointer;font:inherit;border-radius:7px;padding:3px 8px;margin:2px 3px 2px 0;">+ ' + IND_DEFS[kind].label + '</button>';
|
|
477
|
+
});
|
|
478
|
+
p.innerHTML = h;
|
|
479
|
+
p.querySelectorAll("[data-add]").forEach(function (b) {
|
|
480
|
+
b.addEventListener("click", function () { self.addIndicator(b.getAttribute("data-add")); repaint(); });
|
|
481
|
+
});
|
|
482
|
+
p.querySelectorAll("[data-del]").forEach(function (b) {
|
|
483
|
+
b.addEventListener("click", function () { self.removeIndicator(b.getAttribute("data-del")); repaint(); });
|
|
484
|
+
});
|
|
485
|
+
p.querySelectorAll("input[data-color]").forEach(function (inp) {
|
|
486
|
+
inp.addEventListener("change", function () {
|
|
487
|
+
self.setIndicatorStyle(inp.getAttribute("data-color"), { color: inp.value });
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
p.querySelectorAll("input[data-label]").forEach(function (inp) {
|
|
491
|
+
inp.addEventListener("change", function () {
|
|
492
|
+
self.setIndicatorStyle(inp.getAttribute("data-label"), { label: inp.value.trim() });
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
p.querySelectorAll("input[data-ind]").forEach(function (inp) {
|
|
496
|
+
inp.addEventListener("change", function () {
|
|
497
|
+
var patch = {};
|
|
498
|
+
patch[inp.getAttribute("data-k")] = parseFloat(inp.value);
|
|
499
|
+
self.updateIndicator(inp.getAttribute("data-ind"), patch);
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
repaint();
|
|
504
|
+
this.el.appendChild(p);
|
|
505
|
+
var hP = p.offsetHeight || 0, maxTopP = this._plotH() - hP - 6;
|
|
506
|
+
if (10 > maxTopP) p.style.top = Math.max(6, maxTopP) + "px";
|
|
507
|
+
this._indPanel = p;
|
|
508
|
+
this._armOutsideClose(p, this._indBtn, function () { p.remove(); self._indPanel = null; });
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
Chart.prototype._composite = function () {
|
|
512
|
+
var out = document.createElement("canvas");
|
|
513
|
+
out.width = this.canvas.width; out.height = this.canvas.height;
|
|
514
|
+
var c = out.getContext("2d");
|
|
515
|
+
var dpr = out.width / Math.max(1, this.w);
|
|
516
|
+
c.fillStyle = this.opt.background;
|
|
517
|
+
c.fillRect(0, 0, out.width, out.height);
|
|
518
|
+
c.drawImage(this.canvas, 0, 0);
|
|
519
|
+
c.drawImage(this.drawCanvas, 0, 0);
|
|
520
|
+
// header + mark are DOM, invisible to canvases — draw them into the file
|
|
521
|
+
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
522
|
+
var inf = this._symbolInfo || {};
|
|
523
|
+
var n = this.bars.length, last = n ? this.bars[n - 1] : null;
|
|
524
|
+
if (inf.ticker || last) {
|
|
525
|
+
c.font = "800 15px -apple-system, 'Segoe UI', sans-serif";
|
|
526
|
+
var x = 12;
|
|
527
|
+
if (inf.ticker) { c.fillStyle = this.opt.tagText; c.fillText(inf.ticker, x, 22); x += c.measureText(inf.ticker).width + 8; }
|
|
528
|
+
if (inf.tf) { c.font = "700 11px -apple-system, sans-serif"; c.fillStyle = this.opt.textColor; c.fillText(inf.tf, x, 22); x += c.measureText(inf.tf).width + 8; }
|
|
529
|
+
if (last) {
|
|
530
|
+
c.font = "800 15px -apple-system, sans-serif";
|
|
531
|
+
c.fillStyle = last.close >= last.open ? this.opt.upColor : this.opt.downColor;
|
|
532
|
+
c.fillText(fmtPrice(last.close, last.close >= 1000 ? 2 : 4), x, 22);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
c.font = "800 12px -apple-system, sans-serif";
|
|
536
|
+
c.fillStyle = this.opt.textColor;
|
|
537
|
+
c.save(); c.globalAlpha = 0.75;
|
|
538
|
+
c.fillText("TradeRead", 12, this._plotH() - 8);
|
|
539
|
+
c.restore();
|
|
540
|
+
return out;
|
|
541
|
+
};
|
|
542
|
+
// Share: the native sheet where it exists (phones), clipboard otherwise —
|
|
543
|
+
// same intent as the production widget's share button.
|
|
544
|
+
Chart.prototype.share = function (btnEl) {
|
|
545
|
+
var self = this;
|
|
546
|
+
this._composite().toBlob(function (blob) {
|
|
547
|
+
var file = new File([blob], "traderead-chart.png", { type: "image/png" });
|
|
548
|
+
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
|
549
|
+
navigator.share({ files: [file], title: "TradeRead chart" }).catch(function () {});
|
|
550
|
+
} else if (navigator.clipboard && window.ClipboardItem) {
|
|
551
|
+
navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]).then(function () {
|
|
552
|
+
if (btnEl) { var t = btnEl.title; btnEl.title = "Copied to clipboard"; setTimeout(function () { btnEl.title = t; }, 1500); }
|
|
553
|
+
}).catch(function () { self.snapshot(); });
|
|
554
|
+
} else self.snapshot();
|
|
555
|
+
});
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
// PNG snapshot. Nothing downloads by itself (owner, 7 Sep 2026): the
|
|
559
|
+
// camera opens a preview with explicit Download / Copy / Close actions.
|
|
560
|
+
Chart.prototype.snapshot = function (filename) {
|
|
561
|
+
var self = this, o = this.opt;
|
|
562
|
+
var out = this._composite();
|
|
563
|
+
if (this._snapModal) this._snapModal.remove();
|
|
564
|
+
var wrap = document.createElement("div");
|
|
565
|
+
wrap.className = "trc-snap";
|
|
566
|
+
wrap.style.cssText = "position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;" +
|
|
567
|
+
"background:rgba(0,0,0,0.55);";
|
|
568
|
+
var box = document.createElement("div");
|
|
569
|
+
box.style.cssText = "max-width:86%;max-height:86%;display:flex;flex-direction:column;gap:10px;" +
|
|
570
|
+
"background:" + o.background + ";border:1px solid " + o.separatorColor + ";border-radius:14px;padding:12px;";
|
|
571
|
+
var img = document.createElement("img");
|
|
572
|
+
img.src = out.toDataURL("image/png");
|
|
573
|
+
img.style.cssText = "max-width:100%;max-height:60vh;border-radius:8px;display:block;";
|
|
574
|
+
var row = document.createElement("div");
|
|
575
|
+
row.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
|
|
576
|
+
function act(label, fn) {
|
|
577
|
+
var b = document.createElement("button");
|
|
578
|
+
b.type = "button"; b.textContent = label;
|
|
579
|
+
b.style.cssText = "border:1px solid " + o.separatorColor + ";background:none;color:" + o.tagText + ";" +
|
|
580
|
+
"font:700 12px -apple-system,'Segoe UI',sans-serif;border-radius:8px;padding:7px 14px;cursor:pointer;";
|
|
581
|
+
b.addEventListener("click", fn);
|
|
582
|
+
row.appendChild(b);
|
|
583
|
+
return b;
|
|
584
|
+
}
|
|
585
|
+
act("Download", function () {
|
|
586
|
+
var a = document.createElement("a");
|
|
587
|
+
a.download = filename || "traderead-chart.png";
|
|
588
|
+
a.href = img.src;
|
|
589
|
+
a.click();
|
|
590
|
+
});
|
|
591
|
+
if (navigator.clipboard && window.ClipboardItem) {
|
|
592
|
+
var copyBtn = act("Copy", function () {
|
|
593
|
+
out.toBlob(function (blob) {
|
|
594
|
+
navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])
|
|
595
|
+
.then(function () { copyBtn.textContent = "Copied ✓"; setTimeout(function () { copyBtn.textContent = "Copy"; }, 1500); })
|
|
596
|
+
.catch(function () { copyBtn.textContent = "Copy failed"; });
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
act("Close", function () { wrap.remove(); self._snapModal = null; });
|
|
601
|
+
wrap.addEventListener("click", function (e) { if (e.target === wrap) { wrap.remove(); self._snapModal = null; } });
|
|
602
|
+
box.appendChild(img); box.appendChild(row); wrap.appendChild(box);
|
|
603
|
+
this.el.appendChild(wrap);
|
|
604
|
+
this._snapModal = wrap;
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
Chart.prototype._mountLogo = function () {
|
|
608
|
+
// The mark is a LINK to traderead.ai — that is the licence's attribution
|
|
609
|
+
// in its working form, so it stays clickable (owner, 7 Sep 2026).
|
|
610
|
+
var d = document.createElement("a");
|
|
611
|
+
d.className = "trc-logo";
|
|
612
|
+
// utm_source=widget: clicks from third-party embeds arrive referrer-less
|
|
613
|
+
// (or under the embedder's domain) — the tag is the only attribution.
|
|
614
|
+
d.href = this.opt.logoHref || "https://traderead.ai/?utm_source=widget";
|
|
615
|
+
d.target = "_blank";
|
|
616
|
+
d.rel = "noopener";
|
|
617
|
+
// A rounded plate separates the mark from whatever sits behind it — on
|
|
618
|
+
// the volume bars the candle icon camouflaged into the candles (owner,
|
|
619
|
+
// 7 Sep 2026). Plate tone follows the theme's background luminance.
|
|
620
|
+
var bg = String(this.opt.background || "#0d1117");
|
|
621
|
+
var lum = 0;
|
|
622
|
+
try {
|
|
623
|
+
var hx = bg.replace("#", "");
|
|
624
|
+
lum = (parseInt(hx.substr(0, 2), 16) * 0.299 + parseInt(hx.substr(2, 2), 16) * 0.587 + parseInt(hx.substr(4, 2), 16) * 0.114);
|
|
625
|
+
} catch (e) {}
|
|
626
|
+
var lightBg = lum > 128;
|
|
627
|
+
d.style.cssText = "position:absolute;left:8px;bottom:" + (this.opt.timeAxisHeight + 8) +
|
|
628
|
+
"px;z-index:3;display:flex;align-items:center;font:800 24px -apple-system,'Segoe UI',sans-serif;" +
|
|
629
|
+
"border-radius:12px;" +
|
|
630
|
+
"background:" + (lightBg ? "rgba(255,255,255,0.72)" : "rgba(13,17,23,0.66)") + ";" +
|
|
631
|
+
"-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);" +
|
|
632
|
+
"color:" + (lightBg ? "rgba(19,23,34,0.9)" : "rgba(205,214,223,0.95)") + ";" +
|
|
633
|
+
"text-decoration:none;cursor:pointer;user-select:none;";
|
|
634
|
+
d.innerHTML = '<svg width="30" height="30" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">' +
|
|
635
|
+
'<line x1="5" y1="3" x2="5" y2="7" stroke="#ef4444" stroke-width="1.5" stroke-linecap="round"/><rect x="2.5" y="7" width="5" height="8" rx="1" fill="#ef4444"/>' +
|
|
636
|
+
'<line x1="5" y1="15" x2="5" y2="20" stroke="#ef4444" stroke-width="1.5" stroke-linecap="round"/><line x1="14" y1="2" x2="14" y2="6" stroke="#22c55e" stroke-width="1.5" stroke-linecap="round"/>' +
|
|
637
|
+
'<rect x="11.5" y="6" width="5" height="13" rx="1" fill="#22c55e"/><line x1="14" y1="19" x2="14" y2="24" stroke="#22c55e" stroke-width="1.5" stroke-linecap="round"/>' +
|
|
638
|
+
'<line x1="23" y1="6" x2="23" y2="10" stroke="#ef4444" stroke-width="1.5" stroke-linecap="round"/><rect x="20.5" y="10" width="5" height="6" rx="1" fill="#ef4444"/>' +
|
|
639
|
+
'<line x1="23" y1="16" x2="23" y2="21" stroke="#ef4444" stroke-width="1.5" stroke-linecap="round"/></svg><span>TradeRead</span>';
|
|
640
|
+
this.el.appendChild(d);
|
|
641
|
+
this._logoEl = d;
|
|
642
|
+
this._fitLogo();
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
// Small embeds (the homepage widget is ~500px tall) get a compact mark:
|
|
646
|
+
// full size there overlapped the bottom oscillator pane (owner, 7 Sep 2026).
|
|
647
|
+
Chart.prototype._fitLogo = function () {
|
|
648
|
+
var d = this._logoEl;
|
|
649
|
+
if (!d) return;
|
|
650
|
+
var small = (this.h || this.el.clientHeight || 600) < 560;
|
|
651
|
+
d.style.fontSize = small ? "14px" : "24px";
|
|
652
|
+
d.style.gap = small ? "6px" : "10px";
|
|
653
|
+
d.style.padding = small ? "3px 9px 3px 7px" : "6px 14px 6px 10px";
|
|
654
|
+
d.style.borderRadius = small ? "9px" : "12px";
|
|
655
|
+
var svg = d.querySelector("svg");
|
|
656
|
+
if (svg) {
|
|
657
|
+
svg.setAttribute("width", small ? "18" : "30");
|
|
658
|
+
svg.setAttribute("height", small ? "18" : "30");
|
|
659
|
+
}
|
|
660
|
+
// the drawing rail shares the left column; in short frames it can reach
|
|
661
|
+
// the logo's row — slide the plate right of the rail instead of under it
|
|
662
|
+
var left = 8;
|
|
663
|
+
var rail = this.el.querySelector(".trc-rail");
|
|
664
|
+
if (rail) {
|
|
665
|
+
rail.style.maxHeight = Math.max(96, (this.h || this.el.clientHeight || 600) - this.opt.timeAxisHeight - 20) + "px";
|
|
666
|
+
var railBottom = rail.offsetTop + rail.offsetHeight;
|
|
667
|
+
var logoH = small ? 24 : 42;
|
|
668
|
+
var logoTop = (this.h || this.el.clientHeight || 600) - this.opt.timeAxisHeight - 8 - logoH;
|
|
669
|
+
if (railBottom + 4 > logoTop) left = rail.offsetLeft + rail.offsetWidth + 6;
|
|
670
|
+
}
|
|
671
|
+
d.style.left = left + "px";
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
Chart.prototype._resize = function () {
|
|
675
|
+
var dpr = window.devicePixelRatio || 1;
|
|
676
|
+
this.w = this.el.clientWidth;
|
|
677
|
+
this.h = this.el.clientHeight;
|
|
678
|
+
[this.canvas, this.drawCanvas, this.overlay].forEach(function (c) {
|
|
679
|
+
c.width = Math.round(this.w * dpr);
|
|
680
|
+
c.height = Math.round(this.h * dpr);
|
|
681
|
+
}, this);
|
|
682
|
+
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
683
|
+
this.dctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
684
|
+
this.octx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
685
|
+
this._fitLogo();
|
|
686
|
+
this._paint();
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// ── Layout: stack the panes inside the plot column ───────────────────
|
|
690
|
+
Chart.prototype._plotW = function () { return this.w - this.opt.priceAxisWidth; };
|
|
691
|
+
Chart.prototype._plotH = function () { return this.h - this.opt.timeAxisHeight; };
|
|
692
|
+
Chart.prototype._layout = function () {
|
|
693
|
+
var H = this._plotH();
|
|
694
|
+
var fixed = 0, i;
|
|
695
|
+
for (i = 1; i < this.panes.length; i++) fixed += this.panes[i].hPx;
|
|
696
|
+
// The price pane defends its share: squeeze osc panes proportionally
|
|
697
|
+
// when there are too many for the frame.
|
|
698
|
+
var minPrice = H * this.opt.minPricePaneFrac;
|
|
699
|
+
var scale = fixed > 0 && H - fixed < minPrice ? (H - minPrice) / fixed : 1;
|
|
700
|
+
var y = 0;
|
|
701
|
+
this.panes[0].y0 = 0;
|
|
702
|
+
this.panes[0].h = H - Math.round(fixed * scale);
|
|
703
|
+
y = this.panes[0].h;
|
|
704
|
+
for (i = 1; i < this.panes.length; i++) {
|
|
705
|
+
this.panes[i].y0 = y;
|
|
706
|
+
this.panes[i].h = Math.round(this.panes[i].hPx * scale);
|
|
707
|
+
y += this.panes[i].h;
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
// ±4px around an oscillator pane's top edge grabs the separator.
|
|
711
|
+
Chart.prototype._separatorAt = function (y) {
|
|
712
|
+
for (var i = 1; i < this.panes.length; i++) {
|
|
713
|
+
if (Math.abs(y - this.panes[i].y0) <= 4) return i;
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
};
|
|
717
|
+
Chart.prototype._paneAt = function (y) {
|
|
718
|
+
for (var i = 0; i < this.panes.length; i++) {
|
|
719
|
+
var pn = this.panes[i];
|
|
720
|
+
if (y >= pn.y0 && y < pn.y0 + pn.h) return pn;
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
// Drawings anchor to (time, price): the production widget's lazy history
|
|
726
|
+
// PREPENDS bars and shifts every index — a lesson already paid for once.
|
|
727
|
+
Chart.prototype.timeToIndex = function (t) {
|
|
728
|
+
var b = this.bars, n = b.length;
|
|
729
|
+
if (!n) return 0;
|
|
730
|
+
var sec = this._barSec || 3600;
|
|
731
|
+
if (t <= b[0].time) return (t - b[0].time) / sec;
|
|
732
|
+
if (t >= b[n - 1].time) return n - 1 + (t - b[n - 1].time) / sec;
|
|
733
|
+
var lo = 0, hi = n - 1;
|
|
734
|
+
while (hi - lo > 1) { var m = (lo + hi) >> 1; if (b[m].time <= t) lo = m; else hi = m; }
|
|
735
|
+
return lo + (t - b[lo].time) / Math.max(1, b[hi].time - b[lo].time);
|
|
736
|
+
};
|
|
737
|
+
Chart.prototype.indexToTime = function (i) {
|
|
738
|
+
var b = this.bars, n = b.length;
|
|
739
|
+
if (!n) return 0;
|
|
740
|
+
var sec = this._barSec || 3600;
|
|
741
|
+
var r = Math.round(i);
|
|
742
|
+
if (r >= 0 && r < n) return b[r].time + (i - r) * sec;
|
|
743
|
+
if (r < 0) return b[0].time + i * sec;
|
|
744
|
+
return b[n - 1].time + (i - (n - 1)) * sec;
|
|
745
|
+
};
|
|
746
|
+
Chart.prototype.timeToX = function (t) { return this.indexToX(this.timeToIndex(t)); };
|
|
747
|
+
|
|
748
|
+
Chart.prototype.indexToX = function (i) {
|
|
749
|
+
return this._plotW() - (this.rightIndex + this.opt.rightPadBars - i) * this.barSpacing;
|
|
750
|
+
};
|
|
751
|
+
Chart.prototype.xToIndex = function (x) {
|
|
752
|
+
return this.rightIndex + this.opt.rightPadBars - (this._plotW() - x) / this.barSpacing;
|
|
753
|
+
};
|
|
754
|
+
Chart.prototype._visibleRange = function () {
|
|
755
|
+
var lo = Math.floor(this.xToIndex(0)), hi = Math.ceil(this.xToIndex(this._plotW()));
|
|
756
|
+
return [clamp(lo, 0, this.bars.length - 1), clamp(hi, 0, this.bars.length - 1)];
|
|
757
|
+
};
|
|
758
|
+
// Back-compat shorthands for the price pane
|
|
759
|
+
Chart.prototype.priceToY = function (v) { return this.panes[0].toY(v); };
|
|
760
|
+
Chart.prototype.yToPrice = function (y) { return this.panes[0].toValue(y); };
|
|
761
|
+
|
|
762
|
+
// ── Data API ─────────────────────────────────────────────────────────
|
|
763
|
+
// An explicit bar length beats the inferred one wherever sessions have
|
|
764
|
+
// gaps (stocks after a weekend). "1M"/"1Y" pass their calendar strings.
|
|
765
|
+
Chart.prototype.setTimeframe = function (spec) {
|
|
766
|
+
this._tfSpec = spec;
|
|
767
|
+
if (typeof spec === "number") this._barSecFixed = spec;
|
|
768
|
+
else if (spec === "1M") this._barSecFixed = 2629800;
|
|
769
|
+
else if (spec === "1Y") this._barSecFixed = 31557600;
|
|
770
|
+
else this._barSecFixed = null;
|
|
771
|
+
};
|
|
772
|
+
Chart.prototype.setData = function (bars) {
|
|
773
|
+
this.bars = (bars || []).slice().sort(function (a, b) { return a.time - b.time; });
|
|
774
|
+
this.rightIndex = this.bars.length - 1;
|
|
775
|
+
// The label alphabet depends on the bar size: intraday alternates
|
|
776
|
+
// day-marks with clock times, daily and up never shows a clock.
|
|
777
|
+
var n = this.bars.length;
|
|
778
|
+
this._barSec = this._barSecFixed || (n > 1 ? Math.max(1, Math.round((this.bars[n - 1].time - this.bars[0].time) / (n - 1))) : 3600);
|
|
779
|
+
this._ha = null;
|
|
780
|
+
if (this.indicators.length) this._applyIndicators();
|
|
781
|
+
else this._paint();
|
|
782
|
+
};
|
|
783
|
+
Chart.prototype.update = function (bar) {
|
|
784
|
+
var n = this.bars.length;
|
|
785
|
+
if (n && this.bars[n - 1].time === bar.time) this.bars[n - 1] = bar;
|
|
786
|
+
else {
|
|
787
|
+
var atRight = Math.abs(this.rightIndex - (n - 1)) < 2;
|
|
788
|
+
this.bars.push(bar);
|
|
789
|
+
if (atRight) this.rightIndex = this.bars.length - 1;
|
|
790
|
+
}
|
|
791
|
+
this._ha = null;
|
|
792
|
+
this._paintHeader();
|
|
793
|
+
if (this.indicators.length) this._applyIndicators();
|
|
794
|
+
else this._paint();
|
|
795
|
+
};
|
|
796
|
+
// Live tick with the bucket roll built in: when the clock crosses the bar
|
|
797
|
+
// boundary a NEW candle opens at the last close. On the production site,
|
|
798
|
+
// candles once froze at their close because only "update the last bar"
|
|
799
|
+
// existed — that class of bug ends here, inside the library.
|
|
800
|
+
Chart.prototype.tick = function (price, volume, nowSec) {
|
|
801
|
+
var n = this.bars.length;
|
|
802
|
+
if (!n || !isNum(price)) return;
|
|
803
|
+
var sec = this._barSec || 3600;
|
|
804
|
+
var now = isNum(nowSec) ? nowSec : Math.floor(Date.now() / 1000);
|
|
805
|
+
var last = this.bars[n - 1];
|
|
806
|
+
var bucket = Math.floor(now / sec) * sec;
|
|
807
|
+
if (bucket > last.time) {
|
|
808
|
+
this.update({ time: bucket, open: last.close, high: Math.max(last.close, price),
|
|
809
|
+
low: Math.min(last.close, price), close: price, volume: volume || 0 });
|
|
810
|
+
} else {
|
|
811
|
+
this.update({ time: last.time, open: last.open, high: Math.max(last.high, price),
|
|
812
|
+
low: Math.min(last.low, price), close: price, volume: (last.volume || 0) + (volume || 0) });
|
|
813
|
+
}
|
|
814
|
+
this._paintHeader();
|
|
815
|
+
};
|
|
816
|
+
function toByTime(data) {
|
|
817
|
+
var m = new Map();
|
|
818
|
+
(data || []).forEach(function (d) { m.set(d.time, d.value); });
|
|
819
|
+
return m;
|
|
820
|
+
}
|
|
821
|
+
Chart.prototype.addLine = function (id, data, color, width, paneIndex) {
|
|
822
|
+
var pn = this._ensurePane(paneIndex || 0);
|
|
823
|
+
pn.lines[id] = { byTime: toByTime(data), color: color || "#58a6ff", width: width || 1.4 };
|
|
824
|
+
this._paint();
|
|
825
|
+
};
|
|
826
|
+
Chart.prototype.addHistogram = function (id, data, paneIndex, posColor, negColor) {
|
|
827
|
+
var pn = this._ensurePane(paneIndex);
|
|
828
|
+
pn.hists[id] = { byTime: toByTime(data), pos: posColor || "rgba(0,217,126,0.55)", neg: negColor || "rgba(248,81,73,0.55)" };
|
|
829
|
+
this._paint();
|
|
830
|
+
};
|
|
831
|
+
Chart.prototype.addGuide = function (paneIndex, value, color) {
|
|
832
|
+
this._ensurePane(paneIndex).guides.push({ value: value, color: color || "rgba(139,148,158,0.4)" });
|
|
833
|
+
this._paint();
|
|
834
|
+
};
|
|
835
|
+
Chart.prototype._ensurePane = function (i) {
|
|
836
|
+
if (!i) return this.panes[0];
|
|
837
|
+
while (this.panes.length <= i) this.panes.push(new Pane(this, "osc"));
|
|
838
|
+
return this.panes[i];
|
|
839
|
+
};
|
|
840
|
+
Chart.prototype.setPaneHeight = function (i, px) {
|
|
841
|
+
if (this.panes[i] && this.panes[i].kind === "osc") { this.panes[i].hPx = px; this._paint(); }
|
|
842
|
+
};
|
|
843
|
+
Chart.prototype.removeOscPanes = function () { this.panes = [this.panes[0]]; this._paint(); };
|
|
844
|
+
Chart.prototype.removeLine = function (id, paneIndex) {
|
|
845
|
+
var pn = this.panes[paneIndex || 0];
|
|
846
|
+
if (pn) { delete pn.lines[id]; delete pn.hists[id]; }
|
|
847
|
+
this._paint();
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// ── Built-in indicators ──────────────────────────────────────────────
|
|
851
|
+
// The library ships batteries included: the caller can still push raw
|
|
852
|
+
// series, but add/remove/configure of the standard set is one call —
|
|
853
|
+
// and the bundled panel (ui: true) gives end users the TradingView-style
|
|
854
|
+
// workflow the production widget has: many EMAs, SMA, delete, params.
|
|
855
|
+
var IND_PALETTE = ["#00d97e", "#f0b429", "#58a6ff", "#f85149", "#c084fc", "#22d3ee", "#fb923c", "#a3e635"];
|
|
856
|
+
var IND_DEFS = {
|
|
857
|
+
ema: { label: "EMA", overlay: true, params: { p: 20 } },
|
|
858
|
+
sma: { label: "SMA", overlay: true, params: { p: 50 } },
|
|
859
|
+
vwap: { label: "VWAP", overlay: true, params: {} },
|
|
860
|
+
bb: { label: "Bollinger", overlay: true, params: { p: 20, mult: 2 } },
|
|
861
|
+
rsi: { label: "RSI", overlay: false, params: { p: 14 } },
|
|
862
|
+
macd: { label: "MACD", overlay: false, params: { f: 12, s: 26, sig: 9 } },
|
|
863
|
+
stoch: { label: "Stochastic", overlay: false, params: { k: 14, d: 3, s: 3 } },
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
function calcEMA(bars, period, key) {
|
|
867
|
+
var k = 2 / (period + 1), out = [], prev = null;
|
|
868
|
+
for (var i = 0; i < bars.length; i++) {
|
|
869
|
+
var v = key ? bars[i][key] : bars[i].close;
|
|
870
|
+
prev = prev === null ? v : v * k + prev * (1 - k);
|
|
871
|
+
out.push({ time: bars[i].time, value: prev });
|
|
872
|
+
}
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
function calcSMA(bars, period) {
|
|
876
|
+
var out = [], sum = 0;
|
|
877
|
+
for (var i = 0; i < bars.length; i++) {
|
|
878
|
+
sum += bars[i].close;
|
|
879
|
+
if (i >= period) sum -= bars[i - period].close;
|
|
880
|
+
if (i >= period - 1) out.push({ time: bars[i].time, value: sum / period });
|
|
881
|
+
}
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
884
|
+
function calcVWAP(bars) {
|
|
885
|
+
var out = [], pv = 0, vol = 0, day = null;
|
|
886
|
+
for (var i = 0; i < bars.length; i++) {
|
|
887
|
+
var b = bars[i], d = Math.floor(b.time / 86400);
|
|
888
|
+
if (d !== day) { day = d; pv = 0; vol = 0; } // session reset at the UTC day open
|
|
889
|
+
var typ = (b.high + b.low + b.close) / 3;
|
|
890
|
+
pv += typ * (b.volume || 1); vol += (b.volume || 1);
|
|
891
|
+
out.push({ time: b.time, value: pv / vol });
|
|
892
|
+
}
|
|
893
|
+
return out;
|
|
894
|
+
}
|
|
895
|
+
function calcBB(bars, period, mult) {
|
|
896
|
+
var mid = [], up = [], lo = [], win = [];
|
|
897
|
+
for (var i = 0; i < bars.length; i++) {
|
|
898
|
+
win.push(bars[i].close);
|
|
899
|
+
if (win.length > period) win.shift();
|
|
900
|
+
if (win.length === period) {
|
|
901
|
+
var m = 0, j;
|
|
902
|
+
for (j = 0; j < period; j++) m += win[j];
|
|
903
|
+
m /= period;
|
|
904
|
+
var s2 = 0;
|
|
905
|
+
for (j = 0; j < period; j++) s2 += (win[j] - m) * (win[j] - m);
|
|
906
|
+
var sd = Math.sqrt(s2 / period);
|
|
907
|
+
mid.push({ time: bars[i].time, value: m });
|
|
908
|
+
up.push({ time: bars[i].time, value: m + mult * sd });
|
|
909
|
+
lo.push({ time: bars[i].time, value: m - mult * sd });
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return { mid: mid, up: up, lo: lo };
|
|
913
|
+
}
|
|
914
|
+
function calcRSI(bars, period) { // Wilder smoothing
|
|
915
|
+
var out = [], gain = 0, loss = 0;
|
|
916
|
+
for (var i = 1; i < bars.length; i++) {
|
|
917
|
+
var ch = bars[i].close - bars[i - 1].close;
|
|
918
|
+
if (i <= period) { gain += Math.max(ch, 0); loss += Math.max(-ch, 0); }
|
|
919
|
+
if (i === period) { gain /= period; loss /= period; }
|
|
920
|
+
else if (i > period) {
|
|
921
|
+
gain = (gain * (period - 1) + Math.max(ch, 0)) / period;
|
|
922
|
+
loss = (loss * (period - 1) + Math.max(-ch, 0)) / period;
|
|
923
|
+
}
|
|
924
|
+
if (i >= period) out.push({ time: bars[i].time, value: loss === 0 ? 100 : 100 - 100 / (1 + gain / loss) });
|
|
925
|
+
}
|
|
926
|
+
return out;
|
|
927
|
+
}
|
|
928
|
+
function emaOfSeries(values, period) {
|
|
929
|
+
var k = 2 / (period + 1), out = [], prev = null;
|
|
930
|
+
for (var i = 0; i < values.length; i++) {
|
|
931
|
+
prev = prev === null ? values[i].value : values[i].value * k + prev * (1 - k);
|
|
932
|
+
out.push({ time: values[i].time, value: prev });
|
|
933
|
+
}
|
|
934
|
+
return out;
|
|
935
|
+
}
|
|
936
|
+
function calcMACD(bars, f, sl, sig) {
|
|
937
|
+
var closes = [];
|
|
938
|
+
for (var i = 0; i < bars.length; i++) closes.push({ time: bars[i].time, value: bars[i].close });
|
|
939
|
+
var ef = emaOfSeries(closes, f), es = emaOfSeries(closes, sl);
|
|
940
|
+
var line = [];
|
|
941
|
+
for (i = sl - 1; i < closes.length; i++) line.push({ time: closes[i].time, value: ef[i].value - es[i].value });
|
|
942
|
+
var signal = emaOfSeries(line, sig);
|
|
943
|
+
var hist = [];
|
|
944
|
+
for (i = 0; i < line.length; i++) hist.push({ time: line[i].time, value: line[i].value - signal[i].value });
|
|
945
|
+
return { line: line, signal: signal, hist: hist };
|
|
946
|
+
}
|
|
947
|
+
function smaOfSeries(values, period) {
|
|
948
|
+
var out = [], sum = 0;
|
|
949
|
+
for (var i = 0; i < values.length; i++) {
|
|
950
|
+
sum += values[i].value;
|
|
951
|
+
if (i >= period) sum -= values[i - period].value;
|
|
952
|
+
if (i >= period - 1) out.push({ time: values[i].time, value: sum / period });
|
|
953
|
+
}
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
function calcStoch(bars, kP, dP, smooth) {
|
|
957
|
+
var raw = [];
|
|
958
|
+
for (var i = kP - 1; i < bars.length; i++) {
|
|
959
|
+
var hh = -Infinity, ll = Infinity;
|
|
960
|
+
for (var j = i - kP + 1; j <= i; j++) { if (bars[j].high > hh) hh = bars[j].high; if (bars[j].low < ll) ll = bars[j].low; }
|
|
961
|
+
raw.push({ time: bars[i].time, value: hh === ll ? 50 : (bars[i].close - ll) / (hh - ll) * 100 });
|
|
962
|
+
}
|
|
963
|
+
var k = smooth > 1 ? smaOfSeries(raw, smooth) : raw;
|
|
964
|
+
return { k: k, d: smaOfSeries(k, dP) };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
Chart.prototype.addIndicator = function (kind, params, color) {
|
|
968
|
+
var def = IND_DEFS[kind];
|
|
969
|
+
if (!def) return null;
|
|
970
|
+
if (this.opt.maxIndicators > 0 && this.indicators.length >= this.opt.maxIndicators) {
|
|
971
|
+
if (this.opt.onLimit) this.opt.onLimit("indicators", this.opt.maxIndicators);
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
var ind = {
|
|
975
|
+
id: "i" + Math.round(performance.now() * 1000) + "_" + this.indicators.length,
|
|
976
|
+
kind: kind,
|
|
977
|
+
params: Object.assign({}, def.params, params || {}),
|
|
978
|
+
color: color || IND_PALETTE[this._indColor++ % IND_PALETTE.length],
|
|
979
|
+
};
|
|
980
|
+
this.indicators.push(ind);
|
|
981
|
+
this._applyIndicators();
|
|
982
|
+
this._persistInd();
|
|
983
|
+
return ind.id;
|
|
984
|
+
};
|
|
985
|
+
Chart.prototype.removeIndicator = function (id) {
|
|
986
|
+
this.indicators = this.indicators.filter(function (x) { return x.id !== id; });
|
|
987
|
+
this._applyIndicators();
|
|
988
|
+
this._persistInd();
|
|
989
|
+
};
|
|
990
|
+
Chart.prototype.updateIndicator = function (id, params) {
|
|
991
|
+
for (var i = 0; i < this.indicators.length; i++) {
|
|
992
|
+
if (this.indicators[i].id === id) Object.assign(this.indicators[i].params, params || {});
|
|
993
|
+
}
|
|
994
|
+
this._applyIndicators();
|
|
995
|
+
this._persistInd();
|
|
996
|
+
};
|
|
997
|
+
Chart.prototype.setIndicatorStyle = function (id, style) {
|
|
998
|
+
for (var i = 0; i < this.indicators.length; i++) {
|
|
999
|
+
if (this.indicators[i].id === id) {
|
|
1000
|
+
if (style && style.color) this.indicators[i].color = style.color;
|
|
1001
|
+
if (style && "label" in style) this.indicators[i].label = style.label || undefined;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
this._applyIndicators();
|
|
1005
|
+
this._persistInd();
|
|
1006
|
+
};
|
|
1007
|
+
Chart.prototype.getIndicators = function () { return this.indicators.slice(); };
|
|
1008
|
+
// Per-bar markers: entries, exits, signals. Sorted once; drawn above the
|
|
1009
|
+
// bar's high or below its low with a small gap that scales with spacing.
|
|
1010
|
+
// "BTC · 1H · 79,822 −0.02%" — the render header the production widget
|
|
1011
|
+
// shows; price and change refresh with every setData/update.
|
|
1012
|
+
Chart.prototype.setSymbolInfo = function (info) {
|
|
1013
|
+
this._symbolInfo = info || null;
|
|
1014
|
+
if (!this._header) {
|
|
1015
|
+
var h = document.createElement("div");
|
|
1016
|
+
h.className = "trc-header";
|
|
1017
|
+
var clickable = !!(this.opt.symbolSearch && this.opt.symbolSearch.search);
|
|
1018
|
+
h.style.cssText = "position:absolute;left:" + (this.opt.ui ? 56 : 12) + "px;top:10px;z-index:4;display:flex;align-items:baseline;gap:8px;" +
|
|
1019
|
+
"font:800 15px -apple-system,'Segoe UI',sans-serif;user-select:none;" +
|
|
1020
|
+
(clickable ? "cursor:pointer;" : "pointer-events:none;");
|
|
1021
|
+
if (clickable) {
|
|
1022
|
+
var self2 = this;
|
|
1023
|
+
h.title = "Search ticker or name";
|
|
1024
|
+
h.addEventListener("click", function () { self2._toggleSearch(); });
|
|
1025
|
+
}
|
|
1026
|
+
this.el.appendChild(h);
|
|
1027
|
+
this._header = h;
|
|
1028
|
+
}
|
|
1029
|
+
this._paintHeader();
|
|
1030
|
+
};
|
|
1031
|
+
Chart.prototype._paintHeader = function () {
|
|
1032
|
+
if (!this._header) return;
|
|
1033
|
+
var o = this.opt, inf = this._symbolInfo || {};
|
|
1034
|
+
var n = this.bars.length;
|
|
1035
|
+
var last = n ? this.bars[n - 1] : null;
|
|
1036
|
+
var prev = n > 1 ? this.bars[n - 2] : null;
|
|
1037
|
+
var px = last ? fmtPrice(last.close, last.close >= 1000 ? 2 : 4) : "";
|
|
1038
|
+
// change is measured from the previous SESSION close when the host gives
|
|
1039
|
+
// us one (quote-screen semantics), else from the previous bar
|
|
1040
|
+
var base = isNum(this._prevCloseOverride) ? this._prevCloseOverride : (prev ? prev.close : null);
|
|
1041
|
+
var chg = last && base ? (last.close - base) / base * 100 : null;
|
|
1042
|
+
var up = chg !== null && chg >= 0;
|
|
1043
|
+
this._header.innerHTML =
|
|
1044
|
+
'<span style="color:' + (o.tagText || "#e6edf3") + ';font-size:17px;">' + (inf.ticker || "") + "</span>" +
|
|
1045
|
+
(inf.tf ? '<span style="color:' + o.textColor + ';font-weight:700;font-size:12px;">' + inf.tf + "</span>" : "") +
|
|
1046
|
+
(px ? '<span style="color:' + (up ? o.upColor : o.downColor) + ';">' + px + "</span>" : "") +
|
|
1047
|
+
(chg !== null ? '<span style="color:' + (up ? o.upColor : o.downColor) + ';font-weight:700;font-size:12px;">' +
|
|
1048
|
+
(up ? "+" : "") + chg.toFixed(2) + "%</span>" : "") +
|
|
1049
|
+
(inf.title ? '<span style="color:' + o.textColor + ';font-weight:600;font-size:12px;">' + inf.title + "</span>" : "");
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
// The indicator legend with LIVE numbers: label in the series color plus
|
|
1053
|
+
// the value under the crosshair (or the last bar when the cursor is away).
|
|
1054
|
+
Chart.prototype._paintLegend = function (barTime) {
|
|
1055
|
+
if (!this.indicators.length) { if (this._legend) this._legend.innerHTML = ""; return; }
|
|
1056
|
+
if (!this._legend) {
|
|
1057
|
+
var lg = document.createElement("div");
|
|
1058
|
+
lg.className = "trc-legend";
|
|
1059
|
+
lg.style.cssText = "position:absolute;left:" + (this.opt.ui ? 56 : 12) + "px;top:34px;right:80px;z-index:4;display:flex;flex-wrap:wrap;gap:2px 12px;" +
|
|
1060
|
+
"font:700 " + ((this.h || 600) < 560 ? 10 : 11) + "px -apple-system,'Segoe UI',sans-serif;pointer-events:none;user-select:none;";
|
|
1061
|
+
this.el.appendChild(lg);
|
|
1062
|
+
this._legend = lg;
|
|
1063
|
+
}
|
|
1064
|
+
var t = barTime;
|
|
1065
|
+
if (t === undefined || t === null) {
|
|
1066
|
+
var n = this.bars.length;
|
|
1067
|
+
t = n ? this.bars[n - 1].time : null;
|
|
1068
|
+
}
|
|
1069
|
+
var html = "";
|
|
1070
|
+
for (var i = 0; i < this.indicators.length; i++) {
|
|
1071
|
+
var ind = this.indicators[i], def = IND_DEFS[ind.kind];
|
|
1072
|
+
var label = ind.label || (def.label + (ind.params.p ? " " + ind.params.p : ind.kind === "macd" ? " " + ind.params.f + "/" + ind.params.s + "/" + ind.params.sig : ind.kind === "stoch" ? " " + ind.params.k + "/" + ind.params.d + "/" + ind.params.s : ""));
|
|
1073
|
+
var val = "";
|
|
1074
|
+
if (t !== null && ind._legendRef) {
|
|
1075
|
+
var v = ind._legendRef.byTime.get(t);
|
|
1076
|
+
if (isNum(v)) val = " " + fmtPrice(v, v >= 1000 ? 2 : v >= 10 ? 2 : 4);
|
|
1077
|
+
}
|
|
1078
|
+
html += '<span style="color:' + (ind.color || "#8b949e") + ';white-space:nowrap;">' + label + val + "</span>";
|
|
1079
|
+
}
|
|
1080
|
+
this._legend.innerHTML = html;
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
Chart.prototype.setMarkers = function (list) {
|
|
1084
|
+
this.markers = (list || []).slice().sort(function (a, b) { return a.time - b.time; });
|
|
1085
|
+
this._paint();
|
|
1086
|
+
};
|
|
1087
|
+
Chart.prototype._heikin = function () {
|
|
1088
|
+
if (this._ha) return this._ha;
|
|
1089
|
+
var out = [], po = null, pc = null;
|
|
1090
|
+
for (var i = 0; i < this.bars.length; i++) {
|
|
1091
|
+
var b = this.bars[i];
|
|
1092
|
+
var hc = (b.open + b.high + b.low + b.close) / 4;
|
|
1093
|
+
var ho = po === null ? (b.open + b.close) / 2 : (po + pc) / 2;
|
|
1094
|
+
out.push({ time: b.time, open: ho, close: hc,
|
|
1095
|
+
high: Math.max(b.high, ho, hc), low: Math.min(b.low, ho, hc), volume: b.volume });
|
|
1096
|
+
po = ho; pc = hc;
|
|
1097
|
+
}
|
|
1098
|
+
this._ha = out;
|
|
1099
|
+
return out;
|
|
1100
|
+
};
|
|
1101
|
+
Chart.prototype.setChartType = function (t) {
|
|
1102
|
+
if (["candles", "hollow", "heikin", "bars", "line", "area", "baseline"].indexOf(t) < 0) return;
|
|
1103
|
+
this.chartType = t;
|
|
1104
|
+
this._paint();
|
|
1105
|
+
};
|
|
1106
|
+
Chart.prototype._persistInd = function () {
|
|
1107
|
+
if (!this._persistKey) return;
|
|
1108
|
+
try { localStorage.setItem(this._persistKey + ":ind", JSON.stringify(this.indicators)); } catch (e) {}
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
// Recompute everything the indicator list implies: overlay lines on the
|
|
1112
|
+
// price pane, one oscillator pane per oscillator, guides included.
|
|
1113
|
+
Chart.prototype._applyIndicators = function () {
|
|
1114
|
+
var self = this;
|
|
1115
|
+
// wipe indicator-owned series (ids prefixed "@") and every osc pane
|
|
1116
|
+
for (var pi = 0; pi < this.panes.length; pi++) {
|
|
1117
|
+
var pn = this.panes[pi], id;
|
|
1118
|
+
for (id in pn.lines) if (id.charAt(0) === "@") delete pn.lines[id];
|
|
1119
|
+
for (id in pn.hists) if (id.charAt(0) === "@") delete pn.hists[id];
|
|
1120
|
+
}
|
|
1121
|
+
this.panes = [this.panes[0]];
|
|
1122
|
+
this.panes[0].guides = [];
|
|
1123
|
+
var bars = this.bars, pane = 1;
|
|
1124
|
+
this.indicators.forEach(function (ind) {
|
|
1125
|
+
var P = ind.params, key = "@" + ind.id;
|
|
1126
|
+
var defL = IND_DEFS[ind.kind];
|
|
1127
|
+
var indLabel = ind.label || (defL.label + (P.p ? " " + P.p
|
|
1128
|
+
: ind.kind === "macd" ? " " + (P.f || 12) + "/" + (P.s || 26) + "/" + (P.sig || 9)
|
|
1129
|
+
: ind.kind === "stoch" ? " " + (P.k || 14) + "/" + (P.d || 3) + "/" + (P.s || 3) : ""));
|
|
1130
|
+
if (ind.kind === "ema") self.addLine(key, calcEMA(bars, P.p || 20), ind.color, 1.4);
|
|
1131
|
+
else if (ind.kind === "sma") self.addLine(key, calcSMA(bars, P.p || 50), ind.color, 1.4);
|
|
1132
|
+
else if (ind.kind === "vwap") self.addLine(key, calcVWAP(bars), ind.color, 1.4);
|
|
1133
|
+
else if (ind.kind === "bb") {
|
|
1134
|
+
var bb = calcBB(bars, P.p || 20, P.mult || 2);
|
|
1135
|
+
self.addLine(key + "u", bb.up, ind.color, 1);
|
|
1136
|
+
self.addLine(key + "m", bb.mid, ind.color, 1);
|
|
1137
|
+
self.addLine(key + "l", bb.lo, ind.color, 1);
|
|
1138
|
+
} else if (ind.kind === "rsi") {
|
|
1139
|
+
self.addLine(key, calcRSI(bars, P.p || 14), ind.color, 1.4, pane);
|
|
1140
|
+
self.addGuide(pane, 70, "rgba(248,81,73,0.5)");
|
|
1141
|
+
self.addGuide(pane, 30, "rgba(0,217,126,0.5)");
|
|
1142
|
+
self.panes[pane].title = indLabel;
|
|
1143
|
+
self.panes[pane].titleColor = ind.color;
|
|
1144
|
+
pane++;
|
|
1145
|
+
} else if (ind.kind === "macd") {
|
|
1146
|
+
var m = calcMACD(bars, P.f || 12, P.s || 26, P.sig || 9);
|
|
1147
|
+
self.addHistogram(key + "h", m.hist, pane);
|
|
1148
|
+
self.addLine(key, m.line, "#58a6ff", 1.2, pane);
|
|
1149
|
+
self.addLine(key + "s", m.signal, "#f0b429", 1.2, pane);
|
|
1150
|
+
self.addGuide(pane, 0, "rgba(139,148,158,0.4)");
|
|
1151
|
+
self.panes[pane].title = indLabel;
|
|
1152
|
+
self.panes[pane].titleColor = ind.color;
|
|
1153
|
+
pane++;
|
|
1154
|
+
} else if (ind.kind === "stoch") {
|
|
1155
|
+
var st = calcStoch(bars, P.k || 14, P.d || 3, P.s || 3);
|
|
1156
|
+
self.addLine(key, st.k, "#58a6ff", 1.2, pane);
|
|
1157
|
+
self.addLine(key + "d", st.d, "#f0b429", 1.2, pane);
|
|
1158
|
+
self.addGuide(pane, 80, "rgba(248,81,73,0.5)");
|
|
1159
|
+
self.addGuide(pane, 20, "rgba(0,217,126,0.5)");
|
|
1160
|
+
self.panes[pane].title = indLabel;
|
|
1161
|
+
self.panes[pane].titleColor = ind.color;
|
|
1162
|
+
pane++;
|
|
1163
|
+
}
|
|
1164
|
+
// legend reference: the indicator's primary line, wherever it landed
|
|
1165
|
+
var refKey = "@" + ind.id;
|
|
1166
|
+
ind._legendRef = null;
|
|
1167
|
+
for (var pi3 = 0; pi3 < self.panes.length; pi3++) {
|
|
1168
|
+
if (self.panes[pi3].lines[refKey]) { ind._legendRef = self.panes[pi3].lines[refKey]; break; }
|
|
1169
|
+
if (self.panes[pi3].lines[refKey + "m"]) { ind._legendRef = self.panes[pi3].lines[refKey + "m"]; break; }
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
this._paintLegend(null);
|
|
1173
|
+
this._paint();
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
Chart.prototype.addPriceLine = function (price, color, label) {
|
|
1178
|
+
this.priceLines.push({ price: price, color: color || "#8b949e", label: label });
|
|
1179
|
+
this._paint();
|
|
1180
|
+
};
|
|
1181
|
+
Chart.prototype.clearPriceLines = function () { this.priceLines = []; this._paint(); };
|
|
1182
|
+
Chart.prototype.onCrosshair = function (cb) { this.crosshairCb = cb; };
|
|
1183
|
+
// cb({firstVisible, lastVisible, barsLeft}) after every pan/zoom; fires at
|
|
1184
|
+
// most once per paint. barsLeft small = time to prepend history.
|
|
1185
|
+
Chart.prototype.onVisibleRangeChange = function (cb) { this._rangeCb = cb; };
|
|
1186
|
+
// Older bars arrive in front; the visible frame must not move. Drawings
|
|
1187
|
+
// and markers anchor to TIME, so only rightIndex needs the shift — the
|
|
1188
|
+
// exact bug class the production lazy-loader taught us.
|
|
1189
|
+
Chart.prototype.prependBars = function (older) {
|
|
1190
|
+
if (!older || !older.length) return;
|
|
1191
|
+
var cut = this.bars.length ? this.bars[0].time : Infinity;
|
|
1192
|
+
var add = older.filter(function (b) { return b.time < cut; }).sort(function (a, b) { return a.time - b.time; });
|
|
1193
|
+
if (!add.length) return;
|
|
1194
|
+
this.bars = add.concat(this.bars);
|
|
1195
|
+
this.rightIndex += add.length;
|
|
1196
|
+
this._ha = null;
|
|
1197
|
+
if (this.indicators.length) this._applyIndicators();
|
|
1198
|
+
else this._paint();
|
|
1199
|
+
};
|
|
1200
|
+
// ── Drawings ─────────────────────────────────────────────────────────
|
|
1201
|
+
// The production widget's full set, ported (it is TradeRead code): trend,
|
|
1202
|
+
// hline, rect, fib retracement, text; cursor selects, moves and resizes;
|
|
1203
|
+
// Delete removes the selection; persistence is debounced and strips the
|
|
1204
|
+
// cached text width, and a persist key makes saving per-symbol.
|
|
1205
|
+
Chart.prototype.setTool = function (t) {
|
|
1206
|
+
this.tool = t || null;
|
|
1207
|
+
this.el.style.cursor = t ? "crosshair" : "";
|
|
1208
|
+
this._touchLock();
|
|
1209
|
+
};
|
|
1210
|
+
// While a tool is armed or a shape is being dragged, the chart owns the
|
|
1211
|
+
// touch: without touch-action none the browser steals the gesture, fires
|
|
1212
|
+
// a cancel and kills the line mid-draw (paid for once in production).
|
|
1213
|
+
Chart.prototype._touchLock = function () {
|
|
1214
|
+
this.overlay.style.touchAction = (this.tool || this._draft || this._dragDraw || this._sepDrag || this._axisDrag) ? "none" : "";
|
|
1215
|
+
};
|
|
1216
|
+
Chart.prototype.onToolDone = function (cb) { this._toolDoneCb = cb; };
|
|
1217
|
+
Chart.prototype.clearDrawings = function () { this.drawings = []; this._draft = null; this._selected = null; this._persist(); this._paintDrawings(); };
|
|
1218
|
+
// The style bar floats NEXT TO the selected drawing (the corner chip went
|
|
1219
|
+
// unseen — owner, 7 Sep 2026): color + delete, tracking the shape as it
|
|
1220
|
+
// moves, clamped inside the plot.
|
|
1221
|
+
Chart.prototype._syncStyleChip = function () {
|
|
1222
|
+
var self = this, o = this.opt;
|
|
1223
|
+
var d = null;
|
|
1224
|
+
for (var i = 0; i < this.drawings.length; i++) if (this.drawings[i].id === this._selected) d = this.drawings[i];
|
|
1225
|
+
if (!d) { if (this._styleChip) { this._styleChip.remove(); this._styleChip = null; } return; }
|
|
1226
|
+
if (!this._styleChip) {
|
|
1227
|
+
var chip = document.createElement("div");
|
|
1228
|
+
chip.className = "trc-style";
|
|
1229
|
+
chip.style.cssText = "position:absolute;z-index:9;display:flex;align-items:center;gap:6px;" +
|
|
1230
|
+
"background:" + o.background + ";border:1px solid " + o.separatorColor + ";" +
|
|
1231
|
+
"border-radius:10px;padding:5px 7px;box-shadow:0 6px 20px rgba(0,0,0,0.35);";
|
|
1232
|
+
chip.innerHTML =
|
|
1233
|
+
'<input type="color" title="Color" style="width:22px;height:22px;padding:0;border:none;border-radius:6px;background:none;cursor:pointer;">' +
|
|
1234
|
+
'<button type="button" data-act="del" title="Delete drawing (Del)" style="border:none;background:none;color:' + o.downColor + ';cursor:pointer;width:22px;height:22px;font:700 13px -apple-system,sans-serif;line-height:1;">' +
|
|
1235
|
+
SVGI('<path d="M3 4.5 H13 M6.5 4.5 V3 H9.5 V4.5 M4.5 4.5 L5.2 13 H10.8 L11.5 4.5 M7 7 V10.5 M9 7 V10.5"/>') + "</button>";
|
|
1236
|
+
chip.querySelector("input").addEventListener("input", function () {
|
|
1237
|
+
var sel2 = null;
|
|
1238
|
+
for (var j = 0; j < self.drawings.length; j++) if (self.drawings[j].id === self._selected) sel2 = self.drawings[j];
|
|
1239
|
+
if (sel2) { sel2.color = this.value; self._persist(); self._paintDrawings(); }
|
|
1240
|
+
});
|
|
1241
|
+
chip.querySelector('[data-act="del"]').addEventListener("click", function () { self.deleteSelected(); });
|
|
1242
|
+
this.el.appendChild(chip);
|
|
1243
|
+
this._styleChip = chip;
|
|
1244
|
+
}
|
|
1245
|
+
this._styleChip.querySelector("input").value = d.color || DRAW_COLOR;
|
|
1246
|
+
this._placeStyleChip(d);
|
|
1247
|
+
};
|
|
1248
|
+
Chart.prototype._placeStyleChip = function (d) {
|
|
1249
|
+
if (!this._styleChip) return;
|
|
1250
|
+
var pp = this.panes[0];
|
|
1251
|
+
var xs = [], ys = [];
|
|
1252
|
+
if (isNum(d.t1)) { xs.push(this.timeToX(d.t1)); }
|
|
1253
|
+
if (isNum(d.t2)) { xs.push(this.timeToX(d.t2)); }
|
|
1254
|
+
if (isNum(d.p1)) { ys.push(pp.toY(d.p1)); }
|
|
1255
|
+
if (isNum(d.p2)) { ys.push(pp.toY(d.p2)); }
|
|
1256
|
+
if (d.type === "hline") { xs = [this._plotW() / 2]; }
|
|
1257
|
+
var x = xs.length ? Math.min.apply(null, xs) : 20;
|
|
1258
|
+
var y = ys.length ? Math.min.apply(null, ys) : 20;
|
|
1259
|
+
var w = this._styleChip.offsetWidth || 64, h = this._styleChip.offsetHeight || 34;
|
|
1260
|
+
this._styleChip.style.left = clamp(x, 46, this._plotW() - w - 6) + "px";
|
|
1261
|
+
this._styleChip.style.top = clamp(y - h - 10, 4, this._plotH() - h - 4) + "px";
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
Chart.prototype.deleteSelected = function () {
|
|
1265
|
+
if (!this._selected) return;
|
|
1266
|
+
var id = this._selected;
|
|
1267
|
+
this.drawings = this.drawings.filter(function (d) { return d.id !== id; });
|
|
1268
|
+
this._selected = null;
|
|
1269
|
+
this._persist();
|
|
1270
|
+
this._syncStyleChip();
|
|
1271
|
+
this._paintDrawings();
|
|
1272
|
+
};
|
|
1273
|
+
Chart.prototype._toPoints = function (d) {
|
|
1274
|
+
// The site (server sanitizer + index.html opt-in row) speaks a
|
|
1275
|
+
// points-based shape; emit it alongside our t/p fields so both read it.
|
|
1276
|
+
var pts = [];
|
|
1277
|
+
if (isNum(d.t1)) pts.push({ time: d.t1, price: d.p1 });
|
|
1278
|
+
if (isNum(d.t2)) pts.push({ time: d.t2, price: d.p2 });
|
|
1279
|
+
if (d.type === "hline") pts = [{ time: d.t1 || null, price: d.p1 }];
|
|
1280
|
+
return pts;
|
|
1281
|
+
};
|
|
1282
|
+
Chart.prototype.serializeDrawings = function () {
|
|
1283
|
+
var self = this;
|
|
1284
|
+
var arr = this.drawings.map(function (d) {
|
|
1285
|
+
var o = { id: d.id, type: d.type, points: self._toPoints(d) };
|
|
1286
|
+
if (isNum(d.t1)) o.t1 = d.t1; if (isNum(d.p1)) o.p1 = d.p1;
|
|
1287
|
+
if (isNum(d.t2)) o.t2 = d.t2; if (isNum(d.p2)) o.p2 = d.p2;
|
|
1288
|
+
if (d.color) o.color = d.color;
|
|
1289
|
+
if (d.text) o.text = d.text;
|
|
1290
|
+
if (isNum(d.size)) o.size = d.size;
|
|
1291
|
+
return o;
|
|
1292
|
+
});
|
|
1293
|
+
return JSON.stringify(arr);
|
|
1294
|
+
};
|
|
1295
|
+
Chart.prototype.loadDrawings = function (json) {
|
|
1296
|
+
var list;
|
|
1297
|
+
try { list = (typeof json === "string" ? JSON.parse(json) : json) || []; } catch (e) { list = []; }
|
|
1298
|
+
// accept the site's points-based shape and hydrate our t/p anchors
|
|
1299
|
+
this.drawings = list.map(function (d) {
|
|
1300
|
+
if (!isNum(d.t1) && d.points && d.points.length) {
|
|
1301
|
+
d.t1 = d.points[0].time; d.p1 = d.points[0].price;
|
|
1302
|
+
if (d.points[1]) { d.t2 = d.points[1].time; d.p2 = d.points[1].price; }
|
|
1303
|
+
}
|
|
1304
|
+
return d;
|
|
1305
|
+
});
|
|
1306
|
+
this._selected = null;
|
|
1307
|
+
this._syncStyleChip();
|
|
1308
|
+
this._paintDrawings();
|
|
1309
|
+
};
|
|
1310
|
+
Chart.prototype.setPersistKey = function (key) {
|
|
1311
|
+
this._persistKey = key || null;
|
|
1312
|
+
if (key) {
|
|
1313
|
+
try { this.loadDrawings(localStorage.getItem(key)); } catch (e) { this.loadDrawings([]); }
|
|
1314
|
+
try {
|
|
1315
|
+
var ind = JSON.parse(localStorage.getItem(key + ":ind"));
|
|
1316
|
+
if (Array.isArray(ind)) { this.indicators = ind; this._applyIndicators(); }
|
|
1317
|
+
} catch (e) {}
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
Chart.prototype._persist = function () {
|
|
1321
|
+
var self = this;
|
|
1322
|
+
if (!this._persistKey) return;
|
|
1323
|
+
clearTimeout(this._saveT);
|
|
1324
|
+
this._saveT = setTimeout(function () {
|
|
1325
|
+
try { localStorage.setItem(self._persistKey, self.serializeDrawings()); } catch (e) {}
|
|
1326
|
+
}, 300);
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
var FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
|
1330
|
+
var DRAW_COLOR = "#818cf8";
|
|
1331
|
+
|
|
1332
|
+
Chart.prototype._paintDrawings = function () {
|
|
1333
|
+
var c = this.dctx, W = this._plotW(), pp = this.panes[0], self = this;
|
|
1334
|
+
if (this._selected && this._styleChip) {
|
|
1335
|
+
for (var si = 0; si < this.drawings.length; si++) {
|
|
1336
|
+
if (this.drawings[si].id === this._selected) { this._placeStyleChip(this.drawings[si]); break; }
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
c.clearRect(0, 0, this.w, this.h);
|
|
1340
|
+
var dec = stepDecimals(niceStep(pp.max - pp.min, 8));
|
|
1341
|
+
var list = this._draft ? this.drawings.concat([this._draft]) : this.drawings;
|
|
1342
|
+
for (var k = 0; k < list.length; k++) {
|
|
1343
|
+
var d = list[k];
|
|
1344
|
+
var sel = d.id && d.id === this._selected;
|
|
1345
|
+
var dc = d.color || DRAW_COLOR;
|
|
1346
|
+
c.strokeStyle = dc;
|
|
1347
|
+
c.fillStyle = dc;
|
|
1348
|
+
c.lineWidth = sel ? 2 : 1.4;
|
|
1349
|
+
var x1 = isNum(d.t1) ? this.timeToX(d.t1) : null, y1 = isNum(d.p1) ? pp.toY(d.p1) : null;
|
|
1350
|
+
var x2 = isNum(d.t2) ? this.timeToX(d.t2) : null, y2 = isNum(d.p2) ? pp.toY(d.p2) : null;
|
|
1351
|
+
if (d.type === "hline") {
|
|
1352
|
+
if (y1 === null || y1 < pp.y0 || y1 > pp.y0 + pp.h) continue;
|
|
1353
|
+
c.beginPath(); c.moveTo(0, Math.round(y1) + 0.5); c.lineTo(W, Math.round(y1) + 0.5); c.stroke();
|
|
1354
|
+
} else if (d.type === "trend") {
|
|
1355
|
+
c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke();
|
|
1356
|
+
if (sel) { c.fillRect(x1 - 3, y1 - 3, 6, 6); c.fillRect(x2 - 3, y2 - 3, 6, 6); }
|
|
1357
|
+
} else if (d.type === "rect") {
|
|
1358
|
+
c.save(); c.globalAlpha = 0.12;
|
|
1359
|
+
c.fillRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
|
|
1360
|
+
c.restore();
|
|
1361
|
+
c.strokeRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
|
|
1362
|
+
if (sel) c.fillRect(x2 - 3, y2 - 3, 6, 6);
|
|
1363
|
+
} else if (d.type === "fib") {
|
|
1364
|
+
var xa = Math.min(x1, x2), xb = Math.max(x1, x2);
|
|
1365
|
+
c.font = "10px -apple-system, sans-serif";
|
|
1366
|
+
for (var f = 0; f < FIB_LEVELS.length; f++) {
|
|
1367
|
+
var lv = FIB_LEVELS[f];
|
|
1368
|
+
var price = d.p2 - (d.p2 - d.p1) * lv;
|
|
1369
|
+
var fy = pp.toY(price);
|
|
1370
|
+
c.globalAlpha = lv === 0 || lv === 1 ? 0.9 : 0.55;
|
|
1371
|
+
c.beginPath(); c.moveTo(xa, Math.round(fy) + 0.5); c.lineTo(xb, Math.round(fy) + 0.5); c.stroke();
|
|
1372
|
+
c.fillText(lv.toFixed(3) + " " + fmtPrice(price, dec), xb + 5, fy + 3);
|
|
1373
|
+
c.globalAlpha = 1;
|
|
1374
|
+
}
|
|
1375
|
+
if (sel) c.fillRect(x2 - 3, y2 - 3, 6, 6);
|
|
1376
|
+
} else if (d.type === "text") {
|
|
1377
|
+
var fs = d.size || 12;
|
|
1378
|
+
c.font = "600 " + fs + "px -apple-system, sans-serif";
|
|
1379
|
+
d._tw = c.measureText(d.text || "").width; // cached for hit-testing, never persisted
|
|
1380
|
+
c.fillText(d.text || "", x1, y1);
|
|
1381
|
+
if (sel) {
|
|
1382
|
+
c.save(); c.globalAlpha = 0.35;
|
|
1383
|
+
c.strokeRect(x1 - 3, y1 - fs - 2, d._tw + 6, fs + 8);
|
|
1384
|
+
c.restore();
|
|
1385
|
+
c.fillRect(x1 + d._tw + 3, y1 - 3, 6, 6); // size grip: drag down = bigger
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
function distToSeg(x, y, a, b) {
|
|
1392
|
+
var dx = b.x - a.x, dy = b.y - a.y;
|
|
1393
|
+
var len2 = dx * dx + dy * dy;
|
|
1394
|
+
var t = len2 ? clamp(((x - a.x) * dx + (y - a.y) * dy) / len2, 0, 1) : 0;
|
|
1395
|
+
var px = a.x + t * dx, py = a.y + t * dy;
|
|
1396
|
+
return Math.sqrt((x - px) * (x - px) + (y - py) * (y - py));
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Topmost hit wins (reverse order); a selected shape's handle beats its body.
|
|
1400
|
+
Chart.prototype._hitTest = function (x, y) {
|
|
1401
|
+
var pp = this.panes[0];
|
|
1402
|
+
for (var k = this.drawings.length - 1; k >= 0; k--) {
|
|
1403
|
+
var d = this.drawings[k];
|
|
1404
|
+
var x1 = isNum(d.t1) ? this.timeToX(d.t1) : null, y1 = isNum(d.p1) ? pp.toY(d.p1) : null;
|
|
1405
|
+
var x2 = isNum(d.t2) ? this.timeToX(d.t2) : null, y2 = isNum(d.p2) ? pp.toY(d.p2) : null;
|
|
1406
|
+
if (d.id === this._selected && x2 !== null && Math.abs(x - x2) < 7 && Math.abs(y - y2) < 7 &&
|
|
1407
|
+
(d.type === "rect" || d.type === "fib" || d.type === "trend")) return { d: d, handle: "p2" };
|
|
1408
|
+
if (d.id === this._selected && d.type === "text") {
|
|
1409
|
+
var fsz = d.size || 12, tw2 = d._tw || (d.text || "").length * fsz * 0.62;
|
|
1410
|
+
if (Math.abs(x - (x1 + tw2 + 6)) < 8 && Math.abs(y - y1) < 8) return { d: d, handle: "size" };
|
|
1411
|
+
}
|
|
1412
|
+
if (d.id === this._selected && d.type === "trend" && Math.abs(x - x1) < 7 && Math.abs(y - y1) < 7) return { d: d, handle: "p1" };
|
|
1413
|
+
if (d.type === "hline") { if (Math.abs(y - y1) < 6) return { d: d, handle: null }; }
|
|
1414
|
+
else if (d.type === "trend") { if (distToSeg(x, y, { x: x1, y: y1 }, { x: x2, y: y2 }) < 6) return { d: d, handle: null }; }
|
|
1415
|
+
else if (d.type === "rect" || d.type === "fib") {
|
|
1416
|
+
if (x >= Math.min(x1, x2) - 4 && x <= Math.max(x1, x2) + 4 &&
|
|
1417
|
+
y >= Math.min(y1, y2) - 4 && y <= Math.max(y1, y2) + 4) return { d: d, handle: null };
|
|
1418
|
+
} else if (d.type === "text") {
|
|
1419
|
+
var fs = d.size || 12, tw = d._tw || (d.text || "").length * fs * 0.62;
|
|
1420
|
+
if (x >= x1 - 4 && x <= x1 + tw + 4 && y >= y1 - fs - 4 && y <= y1 + 6) return { d: d, handle: null };
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return null;
|
|
1424
|
+
};
|
|
1425
|
+
Chart.prototype.applyOptions = function (o) {
|
|
1426
|
+
Object.assign(this.opt, o || {});
|
|
1427
|
+
this.el.style.background = this.opt.background;
|
|
1428
|
+
// the mounted chrome carries colors from mount time — remount it in the
|
|
1429
|
+
// new palette (open panels close; their state is one click away)
|
|
1430
|
+
if (this._rail) {
|
|
1431
|
+
["_rail", "_indPanel", "_typeMenu", "_styleChip", "_searchBox", "_fsBtn"].forEach(function (k) {
|
|
1432
|
+
if (this[k]) { this[k].remove(); this[k] = null; }
|
|
1433
|
+
}, this);
|
|
1434
|
+
this._mountUI();
|
|
1435
|
+
this._syncStyleChip();
|
|
1436
|
+
}
|
|
1437
|
+
this._paintHeader();
|
|
1438
|
+
this._paint();
|
|
1439
|
+
};
|
|
1440
|
+
Chart.prototype.scrollToRealtime = function () { this.rightIndex = this.bars.length - 1; this._paint(); };
|
|
1441
|
+
// Fit: back to the default frame — bar spacing, right edge and vertical
|
|
1442
|
+
// zoom all reset (the production widget's refit, whole-chart edition).
|
|
1443
|
+
Chart.prototype.fit = function () {
|
|
1444
|
+
this.barSpacing = this.opt.barSpacing;
|
|
1445
|
+
this.rightIndex = this.bars.length - 1;
|
|
1446
|
+
this._vZoom = 1;
|
|
1447
|
+
this._vOff = 0;
|
|
1448
|
+
this._paint();
|
|
1449
|
+
};
|
|
1450
|
+
Chart.prototype.remove = function () { this._ro.disconnect(); this.el.innerHTML = ""; };
|
|
1451
|
+
|
|
1452
|
+
// ── Painting ─────────────────────────────────────────────────────────
|
|
1453
|
+
Chart.prototype._paint = function () {
|
|
1454
|
+
var c = this.ctx, o = this.opt, W = this._plotW(), H = this._plotH();
|
|
1455
|
+
if (!W || !H) return;
|
|
1456
|
+
this._layout();
|
|
1457
|
+
var rng = this._visibleRange(), lo = rng[0], hi = rng[1];
|
|
1458
|
+
for (var pi = 0; pi < this.panes.length; pi++) this.panes[pi].computeScale(lo, hi);
|
|
1459
|
+
|
|
1460
|
+
c.clearRect(0, 0, this.w, this.h);
|
|
1461
|
+
c.fillStyle = o.background;
|
|
1462
|
+
c.fillRect(0, 0, this.w, this.h);
|
|
1463
|
+
c.font = o.font;
|
|
1464
|
+
|
|
1465
|
+
var i, b, x, y, v;
|
|
1466
|
+
|
|
1467
|
+
// time grid first (spans all panes)
|
|
1468
|
+
var barStep = Math.max(1, Math.round(90 / this.barSpacing));
|
|
1469
|
+
var lastDay = null;
|
|
1470
|
+
c.textBaseline = "top";
|
|
1471
|
+
for (i = lo - (lo % barStep); i <= hi; i += barStep) {
|
|
1472
|
+
b = this.bars[i]; if (!b) continue;
|
|
1473
|
+
x = this.indexToX(i);
|
|
1474
|
+
if (x < 0 || x > W) continue;
|
|
1475
|
+
c.strokeStyle = o.gridColor;
|
|
1476
|
+
c.beginPath(); c.moveTo(Math.round(x) + 0.5, 0); c.lineTo(Math.round(x) + 0.5, H); c.stroke();
|
|
1477
|
+
var d = new Date(b.time * 1000);
|
|
1478
|
+
var daily = (this._barSec || 3600) >= 86400;
|
|
1479
|
+
var key = daily ? (d.getUTCFullYear() + "-" + d.getUTCMonth()) : (d.getUTCMonth() + "-" + d.getUTCDate());
|
|
1480
|
+
var label;
|
|
1481
|
+
if (daily) {
|
|
1482
|
+
label = key !== lastDay ? MONTHS[d.getUTCMonth()] + (d.getUTCMonth() === 0 ? " " + d.getUTCFullYear() : "") : "" + d.getUTCDate();
|
|
1483
|
+
} else {
|
|
1484
|
+
label = key !== lastDay
|
|
1485
|
+
? (d.getUTCDate() === 1 ? MONTHS[d.getUTCMonth()] : d.getUTCDate() + " " + MONTHS[d.getUTCMonth()])
|
|
1486
|
+
: pad2(d.getUTCHours()) + ":" + pad2(d.getUTCMinutes());
|
|
1487
|
+
}
|
|
1488
|
+
lastDay = key;
|
|
1489
|
+
c.fillStyle = o.textColor;
|
|
1490
|
+
c.fillText(label, x - c.measureText(label).width / 2, H + 7);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// ── price pane ──
|
|
1494
|
+
var pp = this.panes[0];
|
|
1495
|
+
var span = pp.max - pp.min;
|
|
1496
|
+
var step = niceStep(span, Math.max(3, Math.round(pp.h / 55)));
|
|
1497
|
+
var dec = stepDecimals(step);
|
|
1498
|
+
var first = Math.ceil(pp.min / step) * step;
|
|
1499
|
+
c.textBaseline = "middle";
|
|
1500
|
+
for (v = first; v <= pp.max; v += step) {
|
|
1501
|
+
y = pp.toY(v);
|
|
1502
|
+
if (y < pp.y0 + 4 || y > pp.y0 + pp.h - 4) continue;
|
|
1503
|
+
c.strokeStyle = o.gridColor; c.lineWidth = 1;
|
|
1504
|
+
c.beginPath(); c.moveTo(0, Math.round(y) + 0.5); c.lineTo(W, Math.round(y) + 0.5); c.stroke();
|
|
1505
|
+
c.fillStyle = o.textColor;
|
|
1506
|
+
c.fillText(fmtPrice(v, dec), W + 8, y);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
var volTop = pp.h * (1 - o.volumeHeightPct);
|
|
1510
|
+
var maxVol = 0;
|
|
1511
|
+
for (i = lo; i <= hi; i++) { b = this.bars[i]; if (b && b.volume > maxVol) maxVol = b.volume; }
|
|
1512
|
+
if (maxVol > 0) {
|
|
1513
|
+
var bw = Math.max(1, this.barSpacing * 0.7);
|
|
1514
|
+
for (i = lo; i <= hi; i++) {
|
|
1515
|
+
b = this.bars[i]; if (!b || !b.volume) continue;
|
|
1516
|
+
var vx = this.indexToX(i);
|
|
1517
|
+
var vh = (b.volume / maxVol) * (pp.h - volTop - 2);
|
|
1518
|
+
c.fillStyle = b.close >= b.open ? o.volumeUp : o.volumeDown;
|
|
1519
|
+
c.fillRect(vx - bw / 2, pp.y0 + pp.h - vh, bw, vh);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
var half = Math.max(0.5, this.barSpacing * 0.35);
|
|
1524
|
+
var ct = this.chartType;
|
|
1525
|
+
if (ct === "baseline") {
|
|
1526
|
+
// The base sits at the middle of the VISIBLE scale (the TradingView
|
|
1527
|
+
// default): half the frame is always up-territory and half down, so
|
|
1528
|
+
// baseline never degenerates into a plain area on a trending window
|
|
1529
|
+
// (owner's catch, 7 Sep 2026).
|
|
1530
|
+
var baseV = (pp.min + pp.max) / 2;
|
|
1531
|
+
var baseY = clamp(pp.toY(baseV), pp.y0, pp.y0 + pp.h);
|
|
1532
|
+
var seg = [];
|
|
1533
|
+
for (i = lo; i <= hi; i++) {
|
|
1534
|
+
b = this.bars[i]; if (!b) continue;
|
|
1535
|
+
seg.push([this.indexToX(i), pp.toY(b.close)]);
|
|
1536
|
+
}
|
|
1537
|
+
if (seg.length > 1) {
|
|
1538
|
+
var passes = [
|
|
1539
|
+
{ clipY0: pp.y0, clipH: baseY - pp.y0, stroke: o.upColor, fillA: "rgba(0,217,126,0.22)", fillB: "rgba(0,217,126,0)" },
|
|
1540
|
+
{ clipY0: baseY, clipH: pp.y0 + pp.h - baseY, stroke: o.downColor, fillA: "rgba(248,81,73,0)", fillB: "rgba(248,81,73,0.22)" },
|
|
1541
|
+
];
|
|
1542
|
+
for (var pi2 = 0; pi2 < passes.length; pi2++) {
|
|
1543
|
+
var ps = passes[pi2];
|
|
1544
|
+
if (ps.clipH <= 0) continue;
|
|
1545
|
+
c.save();
|
|
1546
|
+
c.beginPath(); c.rect(0, ps.clipY0, W, ps.clipH); c.clip();
|
|
1547
|
+
c.beginPath();
|
|
1548
|
+
c.moveTo(seg[0][0], seg[0][1]);
|
|
1549
|
+
for (i = 1; i < seg.length; i++) c.lineTo(seg[i][0], seg[i][1]);
|
|
1550
|
+
var grB = c.createLinearGradient(0, pp.y0, 0, pp.y0 + pp.h);
|
|
1551
|
+
grB.addColorStop(0, ps.fillA);
|
|
1552
|
+
grB.addColorStop(1, ps.fillB);
|
|
1553
|
+
c.lineTo(seg[seg.length - 1][0], baseY); c.lineTo(seg[0][0], baseY); c.closePath();
|
|
1554
|
+
c.fillStyle = grB;
|
|
1555
|
+
c.fill();
|
|
1556
|
+
c.beginPath();
|
|
1557
|
+
c.moveTo(seg[0][0], seg[0][1]);
|
|
1558
|
+
for (i = 1; i < seg.length; i++) c.lineTo(seg[i][0], seg[i][1]);
|
|
1559
|
+
c.strokeStyle = ps.stroke; c.lineWidth = 1.6;
|
|
1560
|
+
c.stroke();
|
|
1561
|
+
c.restore();
|
|
1562
|
+
}
|
|
1563
|
+
c.strokeStyle = o.textColor; c.lineWidth = 1; c.setLineDash([3, 3]);
|
|
1564
|
+
c.beginPath(); c.moveTo(0, Math.round(baseY) + 0.5); c.lineTo(W, Math.round(baseY) + 0.5); c.stroke();
|
|
1565
|
+
c.setLineDash([]);
|
|
1566
|
+
}
|
|
1567
|
+
} else if (ct === "line" || ct === "area") {
|
|
1568
|
+
c.save();
|
|
1569
|
+
c.beginPath(); c.rect(0, pp.y0, W, pp.h); c.clip();
|
|
1570
|
+
c.strokeStyle = o.upColor; c.lineWidth = 1.6;
|
|
1571
|
+
c.beginPath();
|
|
1572
|
+
var startedM = false, firstX = null, lastX = null;
|
|
1573
|
+
for (i = lo; i <= hi; i++) {
|
|
1574
|
+
b = this.bars[i]; if (!b) continue;
|
|
1575
|
+
var mx = this.indexToX(i), my = pp.toY(b.close);
|
|
1576
|
+
if (!startedM) { c.moveTo(mx, my); startedM = true; firstX = mx; } else c.lineTo(mx, my);
|
|
1577
|
+
lastX = mx;
|
|
1578
|
+
}
|
|
1579
|
+
c.stroke();
|
|
1580
|
+
if (ct === "area" && startedM) {
|
|
1581
|
+
var gr = c.createLinearGradient(0, pp.y0, 0, pp.y0 + pp.h);
|
|
1582
|
+
gr.addColorStop(0, "rgba(0,217,126,0.25)");
|
|
1583
|
+
gr.addColorStop(1, "rgba(0,217,126,0)");
|
|
1584
|
+
c.lineTo(lastX, pp.y0 + pp.h); c.lineTo(firstX, pp.y0 + pp.h); c.closePath();
|
|
1585
|
+
c.fillStyle = gr;
|
|
1586
|
+
c.fill();
|
|
1587
|
+
}
|
|
1588
|
+
c.restore();
|
|
1589
|
+
} else if (ct === "bars") {
|
|
1590
|
+
for (i = lo; i <= hi; i++) {
|
|
1591
|
+
b = this.bars[i]; if (!b) continue;
|
|
1592
|
+
var bx2 = this.indexToX(i);
|
|
1593
|
+
if (bx2 < -this.barSpacing || bx2 > W + this.barSpacing) continue;
|
|
1594
|
+
var bup = b.close >= b.open;
|
|
1595
|
+
c.strokeStyle = bup ? o.upColor : o.downColor;
|
|
1596
|
+
c.lineWidth = 1;
|
|
1597
|
+
var xR = Math.round(bx2) + 0.5;
|
|
1598
|
+
c.beginPath();
|
|
1599
|
+
c.moveTo(xR, pp.toY(b.high)); c.lineTo(xR, pp.toY(b.low));
|
|
1600
|
+
c.moveTo(xR - half, pp.toY(b.open)); c.lineTo(xR, pp.toY(b.open));
|
|
1601
|
+
c.moveTo(xR, pp.toY(b.close)); c.lineTo(xR + half, pp.toY(b.close));
|
|
1602
|
+
c.stroke();
|
|
1603
|
+
}
|
|
1604
|
+
} else {
|
|
1605
|
+
var hollow = ct === "hollow";
|
|
1606
|
+
var src = ct === "heikin" ? this._heikin() : this.bars;
|
|
1607
|
+
for (i = lo; i <= hi; i++) {
|
|
1608
|
+
b = src[i]; if (!b) continue;
|
|
1609
|
+
var cx = this.indexToX(i);
|
|
1610
|
+
if (cx < -this.barSpacing || cx > W + this.barSpacing) continue;
|
|
1611
|
+
var up = b.close >= b.open;
|
|
1612
|
+
var yO = pp.toY(b.open), yC = pp.toY(b.close);
|
|
1613
|
+
var yH = pp.toY(b.high), yL = pp.toY(b.low);
|
|
1614
|
+
c.strokeStyle = up ? o.wickUp : o.wickDown;
|
|
1615
|
+
c.lineWidth = 1;
|
|
1616
|
+
c.beginPath(); c.moveTo(Math.round(cx) + 0.5, yH); c.lineTo(Math.round(cx) + 0.5, yL); c.stroke();
|
|
1617
|
+
var bodyY = Math.min(yO, yC), bodyH = Math.max(1, Math.abs(yC - yO));
|
|
1618
|
+
if (hollow && up) {
|
|
1619
|
+
c.strokeStyle = o.upColor;
|
|
1620
|
+
c.strokeRect(cx - half + 0.5, bodyY + 0.5, half * 2 - 1, bodyH);
|
|
1621
|
+
} else {
|
|
1622
|
+
c.fillStyle = up ? o.upColor : o.downColor;
|
|
1623
|
+
c.fillRect(cx - half, bodyY, half * 2, bodyH);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// ── every pane: histograms, guides, lines (price pane has only lines) ──
|
|
1629
|
+
for (pi = 0; pi < this.panes.length; pi++) {
|
|
1630
|
+
var pn = this.panes[pi];
|
|
1631
|
+
c.save();
|
|
1632
|
+
c.beginPath(); c.rect(0, pn.y0, W, pn.h); c.clip();
|
|
1633
|
+
|
|
1634
|
+
var id;
|
|
1635
|
+
for (id in pn.hists) {
|
|
1636
|
+
var hs = pn.hists[id];
|
|
1637
|
+
var zeroY = pn.toY(0);
|
|
1638
|
+
var hw = Math.max(1, this.barSpacing * 0.55);
|
|
1639
|
+
for (i = lo; i <= hi; i++) {
|
|
1640
|
+
b = this.bars[i]; if (!b) continue;
|
|
1641
|
+
v = hs.byTime.get(b.time);
|
|
1642
|
+
if (!isNum(v)) continue;
|
|
1643
|
+
var hx = this.indexToX(i);
|
|
1644
|
+
var hy = pn.toY(v);
|
|
1645
|
+
c.fillStyle = v >= 0 ? hs.pos : hs.neg;
|
|
1646
|
+
c.fillRect(hx - hw / 2, Math.min(zeroY, hy), hw, Math.max(1, Math.abs(zeroY - hy)));
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
for (i = 0; i < pn.guides.length; i++) {
|
|
1650
|
+
var g = pn.guides[i];
|
|
1651
|
+
var gy = pn.toY(g.value);
|
|
1652
|
+
c.strokeStyle = g.color; c.lineWidth = 1; c.setLineDash([4, 3]);
|
|
1653
|
+
c.beginPath(); c.moveTo(0, Math.round(gy) + 0.5); c.lineTo(W, Math.round(gy) + 0.5); c.stroke();
|
|
1654
|
+
c.setLineDash([]);
|
|
1655
|
+
}
|
|
1656
|
+
for (id in pn.lines) {
|
|
1657
|
+
var ln = pn.lines[id];
|
|
1658
|
+
c.strokeStyle = ln.color; c.lineWidth = ln.width;
|
|
1659
|
+
c.beginPath();
|
|
1660
|
+
var started = false;
|
|
1661
|
+
for (i = lo; i <= hi; i++) {
|
|
1662
|
+
b = this.bars[i]; if (!b) continue;
|
|
1663
|
+
v = ln.byTime.get(b.time);
|
|
1664
|
+
if (!isNum(v)) { started = false; continue; }
|
|
1665
|
+
var lx = this.indexToX(i), ly = pn.toY(v);
|
|
1666
|
+
if (!started) { c.moveTo(lx, ly); started = true; } else c.lineTo(lx, ly);
|
|
1667
|
+
}
|
|
1668
|
+
c.stroke();
|
|
1669
|
+
}
|
|
1670
|
+
c.restore();
|
|
1671
|
+
|
|
1672
|
+
// last-value tags on the axis, one per oscillator line — the numbers
|
|
1673
|
+
// the production widget shows at the scale (owner, 7 Sep 2026)
|
|
1674
|
+
if (pn.kind === "osc") {
|
|
1675
|
+
c.textBaseline = "middle";
|
|
1676
|
+
for (id in pn.lines) {
|
|
1677
|
+
var lnT = pn.lines[id];
|
|
1678
|
+
var lastB = this.bars[hi];
|
|
1679
|
+
var lastV = lastB ? lnT.byTime.get(lastB.time) : undefined;
|
|
1680
|
+
if (!isNum(lastV)) continue;
|
|
1681
|
+
var lyT = pn.toY(lastV);
|
|
1682
|
+
if (lyT < pn.y0 + 2 || lyT > pn.y0 + pn.h - 2) continue;
|
|
1683
|
+
var decT = stepDecimals(niceStep(pn.max - pn.min, 8));
|
|
1684
|
+
var txtT = fmtPrice(lastV, decT);
|
|
1685
|
+
c.fillStyle = lnT.color;
|
|
1686
|
+
c.fillRect(W, lyT - 8, o.priceAxisWidth, 16);
|
|
1687
|
+
c.fillStyle = "#0d1117";
|
|
1688
|
+
c.font = o.font;
|
|
1689
|
+
c.fillText(txtT, W + 5, lyT);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
// osc pane: right-axis labels for its own scale (top & bottom values)
|
|
1693
|
+
if (pn.kind === "osc") {
|
|
1694
|
+
var oStep = niceStep(pn.max - pn.min, Math.max(2, Math.round(pn.h / 45)));
|
|
1695
|
+
var oDec = stepDecimals(oStep);
|
|
1696
|
+
var oFirst = Math.ceil(pn.min / oStep) * oStep;
|
|
1697
|
+
c.textBaseline = "middle"; c.fillStyle = o.textColor;
|
|
1698
|
+
for (v = oFirst; v <= pn.max; v += oStep) {
|
|
1699
|
+
y = pn.toY(v);
|
|
1700
|
+
if (y < pn.y0 + 8 || y > pn.y0 + pn.h - 8) continue;
|
|
1701
|
+
c.fillText(fmtPrice(v, oDec), W + 8, y);
|
|
1702
|
+
}
|
|
1703
|
+
// separator above the pane
|
|
1704
|
+
c.strokeStyle = o.separatorColor;
|
|
1705
|
+
c.beginPath(); c.moveTo(0, pn.y0 + 0.5); c.lineTo(this.w, pn.y0 + 0.5); c.stroke();
|
|
1706
|
+
// the pane says what it is (owner, 7 Sep 2026)
|
|
1707
|
+
if (pn.title) {
|
|
1708
|
+
c.font = "800 10.5px -apple-system, 'Segoe UI', sans-serif";
|
|
1709
|
+
c.fillStyle = pn.titleColor || o.textColor;
|
|
1710
|
+
c.save(); c.globalAlpha = 0.9;
|
|
1711
|
+
// the tool rail overlays the left edge; titles start clear of it
|
|
1712
|
+
c.fillText(pn.title, o.ui ? 52 : 8, pn.y0 + 13);
|
|
1713
|
+
c.restore();
|
|
1714
|
+
c.font = o.font;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// price lines + last-price tag (price pane only)
|
|
1720
|
+
var last = this.bars[this.bars.length - 1];
|
|
1721
|
+
var tags = this.priceLines.slice();
|
|
1722
|
+
if (last) tags.push({ price: last.close, color: last.close >= last.open ? o.upColor : o.downColor, _last: true });
|
|
1723
|
+
for (i = 0; i < tags.length; i++) {
|
|
1724
|
+
var t = tags[i];
|
|
1725
|
+
var ty = pp.toY(t.price);
|
|
1726
|
+
if (ty < pp.y0 || ty > pp.y0 + pp.h) continue;
|
|
1727
|
+
c.strokeStyle = t.color; c.lineWidth = 1; c.setLineDash([4, 3]);
|
|
1728
|
+
c.beginPath(); c.moveTo(0, Math.round(ty) + 0.5); c.lineTo(W, Math.round(ty) + 0.5); c.stroke();
|
|
1729
|
+
c.setLineDash([]);
|
|
1730
|
+
var txt = fmtPrice(t.price, dec);
|
|
1731
|
+
c.fillStyle = t._last ? t.color : o.tagBg;
|
|
1732
|
+
c.fillRect(W, ty - 9, o.priceAxisWidth, 18);
|
|
1733
|
+
c.fillStyle = t._last ? "#0d1117" : o.tagText;
|
|
1734
|
+
c.textBaseline = "middle";
|
|
1735
|
+
c.fillText(txt, W + 5, ty);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// markers
|
|
1739
|
+
if (this.markers.length) {
|
|
1740
|
+
c.save();
|
|
1741
|
+
c.beginPath(); c.rect(0, pp.y0, W, pp.h); c.clip();
|
|
1742
|
+
var msz = clamp(this.barSpacing * 0.5, 4, 9);
|
|
1743
|
+
c.font = "700 10px -apple-system, sans-serif";
|
|
1744
|
+
for (i = 0; i < this.markers.length; i++) {
|
|
1745
|
+
var mk = this.markers[i];
|
|
1746
|
+
var mi = Math.round(this.timeToIndex(mk.time));
|
|
1747
|
+
b = this.bars[mi];
|
|
1748
|
+
if (!b || mi < lo || mi > hi) continue;
|
|
1749
|
+
var mx2 = this.indexToX(mi);
|
|
1750
|
+
var above = mk.position !== "below";
|
|
1751
|
+
var my2 = above ? pp.toY(b.high) - 7 : pp.toY(b.low) + 7;
|
|
1752
|
+
c.fillStyle = mk.color || (above ? o.downColor : o.upColor);
|
|
1753
|
+
c.beginPath();
|
|
1754
|
+
if (mk.shape === "circle") {
|
|
1755
|
+
c.arc(mx2, my2, msz * 0.55, 0, Math.PI * 2);
|
|
1756
|
+
} else if (above) { // arrow pointing down at the bar
|
|
1757
|
+
c.moveTo(mx2, my2); c.lineTo(mx2 - msz * 0.6, my2 - msz); c.lineTo(mx2 + msz * 0.6, my2 - msz);
|
|
1758
|
+
} else { // arrow pointing up at the bar
|
|
1759
|
+
c.moveTo(mx2, my2); c.lineTo(mx2 - msz * 0.6, my2 + msz); c.lineTo(mx2 + msz * 0.6, my2 + msz);
|
|
1760
|
+
}
|
|
1761
|
+
c.closePath(); c.fill();
|
|
1762
|
+
if (mk.text) {
|
|
1763
|
+
var mtw = c.measureText(mk.text).width;
|
|
1764
|
+
c.fillText(mk.text, mx2 - mtw / 2, above ? my2 - msz - 4 : my2 + msz + 12);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
c.restore();
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
c.strokeStyle = o.gridColor;
|
|
1771
|
+
c.beginPath(); c.moveTo(W + 0.5, 0); c.lineTo(W + 0.5, this.h); c.stroke();
|
|
1772
|
+
c.beginPath(); c.moveTo(0, H + 0.5); c.lineTo(this.w, H + 0.5); c.stroke();
|
|
1773
|
+
|
|
1774
|
+
this._paintDrawings();
|
|
1775
|
+
this._paintCross();
|
|
1776
|
+
if (this._rangeCb) {
|
|
1777
|
+
var vr = this._visibleRange();
|
|
1778
|
+
this._rangeCb({ firstVisible: vr[0], lastVisible: vr[1], barsLeft: vr[0] });
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
Chart.prototype._paintCross = function () {
|
|
1783
|
+
var c = this.octx, o = this.opt, W = this._plotW(), H = this._plotH();
|
|
1784
|
+
c.clearRect(0, 0, this.w, this.h);
|
|
1785
|
+
if (!this._cross) return;
|
|
1786
|
+
var x = this._cross.x, y = this._cross.y;
|
|
1787
|
+
if (x > W || y > H) return;
|
|
1788
|
+
var i = clamp(Math.round(this.xToIndex(x)), 0, this.bars.length - 1);
|
|
1789
|
+
var b = this.bars[i];
|
|
1790
|
+
var bx = this.indexToX(i);
|
|
1791
|
+
c.strokeStyle = o.crosshair; c.lineWidth = 1; c.setLineDash([4, 3]);
|
|
1792
|
+
c.beginPath(); c.moveTo(Math.round(bx) + 0.5, 0); c.lineTo(Math.round(bx) + 0.5, H); c.stroke();
|
|
1793
|
+
c.beginPath(); c.moveTo(0, Math.round(y) + 0.5); c.lineTo(W, Math.round(y) + 0.5); c.stroke();
|
|
1794
|
+
c.setLineDash([]);
|
|
1795
|
+
c.font = o.font; c.textBaseline = "middle";
|
|
1796
|
+
// the y tag speaks the scale of the pane under the cursor
|
|
1797
|
+
var pn = this._paneAt(y) || this.panes[0];
|
|
1798
|
+
var val = pn.toValue(y);
|
|
1799
|
+
var dec = stepDecimals(niceStep(pn.max - pn.min, 8));
|
|
1800
|
+
var py = fmtPrice(val, dec);
|
|
1801
|
+
c.fillStyle = o.tagBg;
|
|
1802
|
+
c.fillRect(W, y - 9, o.priceAxisWidth, 18);
|
|
1803
|
+
c.fillStyle = o.tagText;
|
|
1804
|
+
c.fillText(py, W + 5, y);
|
|
1805
|
+
if (b) {
|
|
1806
|
+
var d = new Date(b.time * 1000);
|
|
1807
|
+
var xt = (this._barSec || 3600) >= 86400
|
|
1808
|
+
? d.getUTCDate() + " " + MONTHS[d.getUTCMonth()] + " " + d.getUTCFullYear()
|
|
1809
|
+
: d.getUTCDate() + " " + MONTHS[d.getUTCMonth()] + " " + pad2(d.getUTCHours()) + ":" + pad2(d.getUTCMinutes());
|
|
1810
|
+
var tw = c.measureText(xt).width + 12;
|
|
1811
|
+
c.fillStyle = o.tagBg;
|
|
1812
|
+
c.fillRect(clamp(bx - tw / 2, 0, W - tw), H, tw, o.timeAxisHeight - 2);
|
|
1813
|
+
c.fillStyle = o.tagText;
|
|
1814
|
+
c.fillText(xt, clamp(bx - tw / 2, 0, W - tw) + 6, H + o.timeAxisHeight / 2 - 1);
|
|
1815
|
+
}
|
|
1816
|
+
this._paintLegend(b ? b.time : null);
|
|
1817
|
+
if (this.crosshairCb) this.crosshairCb(b ? { bar: b, index: i, price: this.panes[0].toValue(y), paneValue: val } : null);
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
Chart.prototype._bind = function () {
|
|
1821
|
+
var self = this, el = this.overlay;
|
|
1822
|
+
var drag = null, pinch = null;
|
|
1823
|
+
// Momentum: the pan keeps gliding after release, decaying each frame —
|
|
1824
|
+
// the "thrown" feel every charting tool has. Any new input kills it.
|
|
1825
|
+
var glide = { v: 0, raf: 0, lastX: 0, lastT: 0 };
|
|
1826
|
+
function stopGlide() { if (glide.raf) cancelAnimationFrame(glide.raf); glide.raf = 0; glide.v = 0; }
|
|
1827
|
+
function startGlide() {
|
|
1828
|
+
if (Math.abs(glide.v) < 0.05) return;
|
|
1829
|
+
var last = performance.now();
|
|
1830
|
+
function step(now) {
|
|
1831
|
+
var dt = now - last; last = now;
|
|
1832
|
+
self.rightIndex = clamp(self.rightIndex - glide.v * dt / self.barSpacing,
|
|
1833
|
+
0, self.bars.length - 1 + self.opt.rightPadBars * 2);
|
|
1834
|
+
self._paint();
|
|
1835
|
+
glide.v *= Math.pow(0.94, dt / 16.7);
|
|
1836
|
+
if (Math.abs(glide.v) >= 0.05) glide.raf = requestAnimationFrame(step);
|
|
1837
|
+
else glide.raf = 0;
|
|
1838
|
+
}
|
|
1839
|
+
glide.raf = requestAnimationFrame(step);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
el.addEventListener("mousemove", function (e) {
|
|
1843
|
+
var r = el.getBoundingClientRect();
|
|
1844
|
+
self._cross = { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
1845
|
+
if (self._draft) {
|
|
1846
|
+
self._draft.t2 = self.indexToTime(self.xToIndex(self._cross.x));
|
|
1847
|
+
self._draft.p2 = self.panes[0].toValue(self._cross.y);
|
|
1848
|
+
self._paintDrawings();
|
|
1849
|
+
self._paintCross();
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
if (self._axisDrag) {
|
|
1853
|
+
var ad = self._axisDrag;
|
|
1854
|
+
if (ad.axis === "price") self._vZoom = clamp(ad.z0 * Math.exp((self._cross.y - ad.y0) * 0.004), 0.15, 8);
|
|
1855
|
+
else self.barSpacing = clamp(ad.s0 * Math.exp((self._cross.x - ad.x0) * 0.004), self.opt.minBarSpacing, self.opt.maxBarSpacing);
|
|
1856
|
+
self._paint();
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (self._sepDrag) {
|
|
1860
|
+
var sd = self._sepDrag;
|
|
1861
|
+
// dragging DOWN moves the pane's top edge down = the pane shrinks
|
|
1862
|
+
self.panes[sd.i].hPx = clamp(sd.h0 - (self._cross.y - sd.y0), 42, self._plotH() * 0.6);
|
|
1863
|
+
self._paint();
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
if (!self.tool && !drag && !self._draft && !self._dragDraw) {
|
|
1867
|
+
el.style.cursor = self._separatorAt(self._cross.y) !== null ? "ns-resize" : "";
|
|
1868
|
+
}
|
|
1869
|
+
if (self._dragDraw) {
|
|
1870
|
+
var dd = self._dragDraw, ppm = self.panes[0];
|
|
1871
|
+
var tNow = self.indexToTime(self.xToIndex(self._cross.x)), vNow = ppm.toValue(self._cross.y);
|
|
1872
|
+
if (dd.handle === "size") {
|
|
1873
|
+
if (dd._fs0 === undefined) { dd._fs0 = dd.d.size || 12; dd._ys = self._cross.y; }
|
|
1874
|
+
dd.d.size = clamp(Math.round(dd._fs0 + (self._cross.y - dd._ys) * 0.25), 8, 64);
|
|
1875
|
+
}
|
|
1876
|
+
else if (dd.handle === "p2") { dd.d.t2 = tNow; dd.d.p2 = vNow; }
|
|
1877
|
+
else if (dd.handle === "p1") { dd.d.t1 = tNow; dd.d.p1 = vNow; }
|
|
1878
|
+
else {
|
|
1879
|
+
var dt = tNow - dd.t0, dv = vNow - dd.v0;
|
|
1880
|
+
dd.t0 = tNow; dd.v0 = vNow;
|
|
1881
|
+
if (isNum(dd.d.t1)) dd.d.t1 += dt;
|
|
1882
|
+
if (isNum(dd.d.t2)) dd.d.t2 += dt;
|
|
1883
|
+
if (isNum(dd.d.p1)) dd.d.p1 += dv;
|
|
1884
|
+
if (isNum(dd.d.p2)) dd.d.p2 += dv;
|
|
1885
|
+
}
|
|
1886
|
+
self._paintDrawings();
|
|
1887
|
+
self._paintCross();
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
if (drag) {
|
|
1891
|
+
var nowT = performance.now(), nowX = e.clientX - r.left;
|
|
1892
|
+
if (glide.lastT) {
|
|
1893
|
+
var inst = (nowX - glide.lastX) / Math.max(1, nowT - glide.lastT);
|
|
1894
|
+
glide.v = glide.v * 0.75 + inst * 0.25; // smoothed px/ms
|
|
1895
|
+
}
|
|
1896
|
+
glide.lastX = nowX; glide.lastT = nowT;
|
|
1897
|
+
self.rightIndex = drag.right0 + (drag.x0 - nowX) / self.barSpacing;
|
|
1898
|
+
self.rightIndex = clamp(self.rightIndex, 0, self.bars.length - 1 + self.opt.rightPadBars * 2);
|
|
1899
|
+
if (drag.vpan) {
|
|
1900
|
+
var us = self.panes[0].h * (1 - self.opt.volumeHeightPct * 0.35) || 1;
|
|
1901
|
+
self._vOff = drag.off0 + (e.clientY - r.top - drag.y0) / us;
|
|
1902
|
+
}
|
|
1903
|
+
self._paint();
|
|
1904
|
+
} else self._paintCross();
|
|
1905
|
+
});
|
|
1906
|
+
el.addEventListener("mouseleave", function () { self._cross = null; drag = null; self._axisDrag = null; self._paintCross(); self._paintLegend(null); if (self.crosshairCb) self.crosshairCb(null); });
|
|
1907
|
+
function newId() { return "d" + Math.round(performance.now() * 1000) + "_" + self.drawings.length; }
|
|
1908
|
+
el.addEventListener("mousedown", function (e) {
|
|
1909
|
+
var r = el.getBoundingClientRect();
|
|
1910
|
+
var x = e.clientX - r.left, y = e.clientY - r.top;
|
|
1911
|
+
var pp = self.panes[0];
|
|
1912
|
+
// dragging the price axis stretches the scale, the time axis stretches
|
|
1913
|
+
// bar spacing — the same gestures TradingView trains everyone on
|
|
1914
|
+
if (x > self._plotW()) { stopGlide(); self._axisDrag = { axis: "price", y0: y, z0: self._vZoom }; e.preventDefault(); return; }
|
|
1915
|
+
if (y > self._plotH()) { stopGlide(); self._axisDrag = { axis: "time", x0: x, s0: self.barSpacing }; e.preventDefault(); return; }
|
|
1916
|
+
if (self.tool && self._paneAt(y) === pp) {
|
|
1917
|
+
var t0 = self.indexToTime(self.xToIndex(x)), v0 = pp.toValue(y);
|
|
1918
|
+
if (self.tool === "hline") {
|
|
1919
|
+
self.drawings.push({ id: newId(), type: "hline", t1: t0, p1: v0 });
|
|
1920
|
+
self.setTool(null);
|
|
1921
|
+
if (self._toolDoneCb) self._toolDoneCb();
|
|
1922
|
+
self._persist();
|
|
1923
|
+
self._paintDrawings();
|
|
1924
|
+
} else if (self.tool === "text") {
|
|
1925
|
+
var ask = self.opt.textPrompt || function (initial, cb) { cb(window.prompt("Text:", initial || "")); };
|
|
1926
|
+
ask("", function (txt) {
|
|
1927
|
+
if (txt) {
|
|
1928
|
+
self.drawings.push({ id: newId(), type: "text", t1: t0, p1: v0, text: txt, size: 12 });
|
|
1929
|
+
self._persist();
|
|
1930
|
+
self._paintDrawings();
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
self.setTool(null);
|
|
1934
|
+
if (self._toolDoneCb) self._toolDoneCb();
|
|
1935
|
+
} else if (self._draft) {
|
|
1936
|
+
// second click of the two-click flow: commit where the preview is
|
|
1937
|
+
self._draft.t2 = t0; self._draft.p2 = v0;
|
|
1938
|
+
self.drawings.push(self._draft);
|
|
1939
|
+
self._selected = self._draft.id;
|
|
1940
|
+
self._draft = null;
|
|
1941
|
+
self.setTool(null);
|
|
1942
|
+
if (self._toolDoneCb) self._toolDoneCb();
|
|
1943
|
+
self._persist();
|
|
1944
|
+
self._syncStyleChip();
|
|
1945
|
+
self._paintDrawings();
|
|
1946
|
+
} else {
|
|
1947
|
+
self._draft = { id: newId(), type: self.tool, t1: t0, p1: v0, t2: t0, p2: v0, _x0: x, _y0: y };
|
|
1948
|
+
}
|
|
1949
|
+
e.preventDefault();
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
// separator drag: resize the oscillator pane under it
|
|
1953
|
+
var sep = self._separatorAt(y);
|
|
1954
|
+
if (sep !== null) {
|
|
1955
|
+
self._sepDrag = { i: sep, y0: y, h0: self.panes[sep].hPx };
|
|
1956
|
+
e.preventDefault();
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
// cursor: select, grab a handle, or start moving the whole shape
|
|
1960
|
+
var hit = self._hitTest(x, y);
|
|
1961
|
+
if (hit) {
|
|
1962
|
+
self._selected = hit.d.id;
|
|
1963
|
+
self._dragDraw = { d: hit.d, handle: hit.handle,
|
|
1964
|
+
t0: self.indexToTime(self.xToIndex(x)), v0: pp.toValue(y) };
|
|
1965
|
+
self._syncStyleChip();
|
|
1966
|
+
self._paintDrawings();
|
|
1967
|
+
e.preventDefault();
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (self._selected) { self._selected = null; self._syncStyleChip(); self._paintDrawings(); }
|
|
1971
|
+
stopGlide();
|
|
1972
|
+
glide.lastX = x; glide.lastT = 0;
|
|
1973
|
+
drag = { x0: x, right0: self.rightIndex, y0: y, off0: self._vOff, vpan: self._paneAt(y) === pp };
|
|
1974
|
+
e.preventDefault();
|
|
1975
|
+
});
|
|
1976
|
+
window.addEventListener("mouseup", function () {
|
|
1977
|
+
if (drag) startGlide();
|
|
1978
|
+
drag = null;
|
|
1979
|
+
self._axisDrag = null;
|
|
1980
|
+
if (self._draft) {
|
|
1981
|
+
var f = self._draft;
|
|
1982
|
+
var movedPx = self._cross ? Math.abs(self._cross.x - f._x0) + Math.abs(self._cross.y - f._y0) : 0;
|
|
1983
|
+
// released after a real drag → classic drag-to-draw commit;
|
|
1984
|
+
// released in place → the draft stays armed for the second click
|
|
1985
|
+
if (movedPx > 6) {
|
|
1986
|
+
delete f._x0; delete f._y0;
|
|
1987
|
+
self.drawings.push(f);
|
|
1988
|
+
self._selected = f.id;
|
|
1989
|
+
self._draft = null;
|
|
1990
|
+
self.setTool(null);
|
|
1991
|
+
if (self._toolDoneCb) self._toolDoneCb();
|
|
1992
|
+
self._persist();
|
|
1993
|
+
self._syncStyleChip();
|
|
1994
|
+
self._paintDrawings();
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
if (self._dragDraw) { self._dragDraw = null; self._persist(); }
|
|
1998
|
+
self._sepDrag = null;
|
|
1999
|
+
});
|
|
2000
|
+
window.addEventListener("keydown", function (e) {
|
|
2001
|
+
if (e.key === "Escape" && self._draft) {
|
|
2002
|
+
self._draft = null;
|
|
2003
|
+
self.setTool(null);
|
|
2004
|
+
if (self._toolDoneCb) self._toolDoneCb();
|
|
2005
|
+
self._paintDrawings();
|
|
2006
|
+
}
|
|
2007
|
+
});
|
|
2008
|
+
// Delete removes the selection — but never while typing in a form field.
|
|
2009
|
+
window.addEventListener("keydown", function (e) {
|
|
2010
|
+
if ((e.key === "Delete" || e.key === "Backspace") && self._selected) {
|
|
2011
|
+
var tag = (document.activeElement && document.activeElement.tagName) || "";
|
|
2012
|
+
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
2013
|
+
e.preventDefault();
|
|
2014
|
+
self.deleteSelected();
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
el.addEventListener("dblclick", function (e) {
|
|
2018
|
+
var r = el.getBoundingClientRect();
|
|
2019
|
+
if (e.clientX - r.left > self._plotW()) { self._vZoom = 1; self._vOff = 0; self._paint(); return; }
|
|
2020
|
+
var hit = self._hitTest(e.clientX - r.left, e.clientY - r.top);
|
|
2021
|
+
if (hit && hit.d.type === "text") {
|
|
2022
|
+
var ask = self.opt.textPrompt || function (initial, cb) { cb(window.prompt("Text:", initial || "")); };
|
|
2023
|
+
ask(hit.d.text || "", function (txt) {
|
|
2024
|
+
if (txt !== null && txt !== undefined && txt !== "") { hit.d.text = txt; self._persist(); self._paintDrawings(); }
|
|
2025
|
+
});
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
self.scrollToRealtime();
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
el.addEventListener("wheel", function (e) {
|
|
2032
|
+
e.preventDefault();
|
|
2033
|
+
stopGlide();
|
|
2034
|
+
var r = el.getBoundingClientRect();
|
|
2035
|
+
var x = e.clientX - r.left;
|
|
2036
|
+
// over the price axis the wheel stretches the vertical scale — the
|
|
2037
|
+
// grid gets denser or sparser, TV-style; dblclick there resets
|
|
2038
|
+
if (x > self._plotW()) {
|
|
2039
|
+
self._vZoom = clamp(self._vZoom * Math.exp(e.deltaY * 0.0015), 0.15, 8);
|
|
2040
|
+
self._paint();
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
// A Mac trackpad swipes horizontally with deltaX: that is a PAN, the
|
|
2044
|
+
// way every charting tool treats it. Vertical wheel stays zoom.
|
|
2045
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
|
2046
|
+
self.rightIndex = clamp(self.rightIndex + e.deltaX / self.barSpacing,
|
|
2047
|
+
0, self.bars.length - 1 + self.opt.rightPadBars * 2);
|
|
2048
|
+
self._paint();
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
var anchor = self.xToIndex(x);
|
|
2052
|
+
var k = Math.exp(-e.deltaY * 0.0015);
|
|
2053
|
+
self.barSpacing = clamp(self.barSpacing * k, self.opt.minBarSpacing, self.opt.maxBarSpacing);
|
|
2054
|
+
self.rightIndex = anchor - (self.xToIndex(x) - self.rightIndex);
|
|
2055
|
+
self._paint();
|
|
2056
|
+
}, { passive: false });
|
|
2057
|
+
|
|
2058
|
+
el.addEventListener("touchstart", function (e) {
|
|
2059
|
+
var r = el.getBoundingClientRect();
|
|
2060
|
+
if (e.touches.length === 1) {
|
|
2061
|
+
var tx = e.touches[0].clientX - r.left, ty = e.touches[0].clientY - r.top;
|
|
2062
|
+
var pp = self.panes[0];
|
|
2063
|
+
if (tx > self._plotW()) { stopGlide(); self._axisDrag = { axis: "price", y0: ty, z0: self._vZoom }; self._touchLock(); return; }
|
|
2064
|
+
if (ty > self._plotH()) { stopGlide(); self._axisDrag = { axis: "time", x0: tx, s0: self.barSpacing }; self._touchLock(); return; }
|
|
2065
|
+
if (self.tool && self._paneAt(ty) === pp) {
|
|
2066
|
+
var t0 = self.indexToTime(self.xToIndex(tx)), v0 = pp.toValue(ty);
|
|
2067
|
+
if (self.tool === "hline") {
|
|
2068
|
+
self.drawings.push({ id: "d" + Math.round(performance.now() * 1000), type: "hline", t1: t0, p1: v0 });
|
|
2069
|
+
self.setTool(null);
|
|
2070
|
+
if (self._toolDoneCb) self._toolDoneCb();
|
|
2071
|
+
self._persist(); self._paintDrawings();
|
|
2072
|
+
} else if (self.tool === "text") {
|
|
2073
|
+
var ask = self.opt.textPrompt || function (initial, cb) { cb(window.prompt("Text:", initial || "")); };
|
|
2074
|
+
ask("", function (txt) {
|
|
2075
|
+
if (txt) { self.drawings.push({ id: "d" + Math.round(performance.now() * 1000), type: "text", t1: t0, p1: v0, text: txt, size: 12 }); self._persist(); self._paintDrawings(); }
|
|
2076
|
+
});
|
|
2077
|
+
self.setTool(null);
|
|
2078
|
+
if (self._toolDoneCb) self._toolDoneCb();
|
|
2079
|
+
} else {
|
|
2080
|
+
self._draft = { id: "d" + Math.round(performance.now() * 1000), type: self.tool, t1: t0, p1: v0, t2: t0, p2: v0 };
|
|
2081
|
+
self._touchLock();
|
|
2082
|
+
}
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
var sep = self._separatorAt(ty);
|
|
2086
|
+
if (sep !== null) { self._sepDrag = { i: sep, y0: ty, h0: self.panes[sep].hPx }; self._touchLock(); return; }
|
|
2087
|
+
var hit = self._hitTest(tx, ty);
|
|
2088
|
+
if (hit) {
|
|
2089
|
+
self._selected = hit.d.id;
|
|
2090
|
+
self._dragDraw = { d: hit.d, handle: hit.handle, t0: self.indexToTime(self.xToIndex(tx)), v0: pp.toValue(ty) };
|
|
2091
|
+
self._touchLock();
|
|
2092
|
+
self._syncStyleChip();
|
|
2093
|
+
self._paintDrawings();
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
stopGlide();
|
|
2097
|
+
glide.lastX = tx; glide.lastT = 0;
|
|
2098
|
+
drag = { x0: tx, right0: self.rightIndex, y0: ty, off0: self._vOff, vpan: self._paneAt(ty) === pp };
|
|
2099
|
+
} else if (e.touches.length === 2) {
|
|
2100
|
+
// a second finger means navigation: commit whatever was in flight
|
|
2101
|
+
if (self._draft) { self.drawings.push(self._draft); self._selected = self._draft.id; self._draft = null; self._persist(); self._paintDrawings(); }
|
|
2102
|
+
self._dragDraw = null; self._sepDrag = null; drag = null;
|
|
2103
|
+
self._touchLock();
|
|
2104
|
+
var dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
2105
|
+
pinch = { d0: Math.abs(dx) || 1, spacing0: self.barSpacing };
|
|
2106
|
+
}
|
|
2107
|
+
}, { passive: true });
|
|
2108
|
+
el.addEventListener("touchmove", function (e) {
|
|
2109
|
+
var r = el.getBoundingClientRect();
|
|
2110
|
+
if (self._axisDrag && e.touches.length === 1) {
|
|
2111
|
+
var aax = e.touches[0].clientX - r.left, aay = e.touches[0].clientY - r.top;
|
|
2112
|
+
var ad2 = self._axisDrag;
|
|
2113
|
+
if (ad2.axis === "price") self._vZoom = clamp(ad2.z0 * Math.exp((aay - ad2.y0) * 0.004), 0.15, 8);
|
|
2114
|
+
else self.barSpacing = clamp(ad2.s0 * Math.exp((aax - ad2.x0) * 0.004), self.opt.minBarSpacing, self.opt.maxBarSpacing);
|
|
2115
|
+
self._paint();
|
|
2116
|
+
e.preventDefault();
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
if (e.touches.length === 1 && (self._draft || self._dragDraw || self._sepDrag)) {
|
|
2120
|
+
var tx = e.touches[0].clientX - r.left, ty = e.touches[0].clientY - r.top;
|
|
2121
|
+
var pp = self.panes[0];
|
|
2122
|
+
if (self._draft) {
|
|
2123
|
+
self._draft.t2 = self.indexToTime(self.xToIndex(tx));
|
|
2124
|
+
self._draft.p2 = pp.toValue(ty);
|
|
2125
|
+
self._paintDrawings();
|
|
2126
|
+
} else if (self._sepDrag) {
|
|
2127
|
+
var sd = self._sepDrag;
|
|
2128
|
+
self.panes[sd.i].hPx = clamp(sd.h0 - (ty - sd.y0), 42, self._plotH() * 0.6);
|
|
2129
|
+
self._paint();
|
|
2130
|
+
} else {
|
|
2131
|
+
var dd = self._dragDraw;
|
|
2132
|
+
var tNow = self.indexToTime(self.xToIndex(tx)), vNow = pp.toValue(ty);
|
|
2133
|
+
if (dd.handle === "p2") { dd.d.t2 = tNow; dd.d.p2 = vNow; }
|
|
2134
|
+
else if (dd.handle === "p1") { dd.d.t1 = tNow; dd.d.p1 = vNow; }
|
|
2135
|
+
else {
|
|
2136
|
+
var dt = tNow - dd.t0, dv = vNow - dd.v0;
|
|
2137
|
+
dd.t0 = tNow; dd.v0 = vNow;
|
|
2138
|
+
if (isNum(dd.d.t1)) dd.d.t1 += dt;
|
|
2139
|
+
if (isNum(dd.d.t2)) dd.d.t2 += dt;
|
|
2140
|
+
if (isNum(dd.d.p1)) dd.d.p1 += dv;
|
|
2141
|
+
if (isNum(dd.d.p2)) dd.d.p2 += dv;
|
|
2142
|
+
}
|
|
2143
|
+
self._paintDrawings();
|
|
2144
|
+
}
|
|
2145
|
+
e.preventDefault();
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
if (pinch && e.touches.length === 2) {
|
|
2149
|
+
var d = Math.abs(e.touches[0].clientX - e.touches[1].clientX) || 1;
|
|
2150
|
+
self.barSpacing = clamp(pinch.spacing0 * d / pinch.d0, self.opt.minBarSpacing, self.opt.maxBarSpacing);
|
|
2151
|
+
self._paint();
|
|
2152
|
+
} else if (drag && e.touches.length === 1) {
|
|
2153
|
+
var tNowT = performance.now(), tNowX = e.touches[0].clientX - r.left;
|
|
2154
|
+
if (glide.lastT) {
|
|
2155
|
+
var tInst = (tNowX - glide.lastX) / Math.max(1, tNowT - glide.lastT);
|
|
2156
|
+
glide.v = glide.v * 0.75 + tInst * 0.25;
|
|
2157
|
+
}
|
|
2158
|
+
glide.lastX = tNowX; glide.lastT = tNowT;
|
|
2159
|
+
self.rightIndex = drag.right0 + (drag.x0 - tNowX) / self.barSpacing;
|
|
2160
|
+
self.rightIndex = clamp(self.rightIndex, 0, self.bars.length - 1 + self.opt.rightPadBars * 2);
|
|
2161
|
+
if (drag.vpan) {
|
|
2162
|
+
var us2 = self.panes[0].h * (1 - self.opt.volumeHeightPct * 0.35) || 1;
|
|
2163
|
+
self._vOff = drag.off0 + (e.touches[0].clientY - r.top - drag.y0) / us2;
|
|
2164
|
+
}
|
|
2165
|
+
self._paint();
|
|
2166
|
+
e.preventDefault();
|
|
2167
|
+
}
|
|
2168
|
+
}, { passive: false });
|
|
2169
|
+
function endTouch(e) {
|
|
2170
|
+
if (e.touches.length) return;
|
|
2171
|
+
if (drag) startGlide();
|
|
2172
|
+
drag = null; pinch = null; self._axisDrag = null;
|
|
2173
|
+
if (self._draft) {
|
|
2174
|
+
var f = self._draft;
|
|
2175
|
+
if (f.t1 !== f.t2 || f.p1 !== f.p2) { self.drawings.push(f); self._selected = f.id; self._persist(); }
|
|
2176
|
+
self._draft = null;
|
|
2177
|
+
self.setTool(null);
|
|
2178
|
+
if (self._toolDoneCb) self._toolDoneCb();
|
|
2179
|
+
self._paintDrawings();
|
|
2180
|
+
}
|
|
2181
|
+
if (self._dragDraw) { self._dragDraw = null; self._persist(); }
|
|
2182
|
+
self._sepDrag = null;
|
|
2183
|
+
self._touchLock();
|
|
2184
|
+
}
|
|
2185
|
+
el.addEventListener("touchend", endTouch, { passive: true });
|
|
2186
|
+
// a browser-fired cancel must COMMIT the in-flight shape, not destroy it
|
|
2187
|
+
el.addEventListener("touchcancel", endTouch, { passive: true });
|
|
2188
|
+
};
|
|
2189
|
+
|
|
2190
|
+
// Ready-made palettes; pass one to createChart or applyOptions. Colors are
|
|
2191
|
+
// read at paint time, so switching themes live is one applyOptions call.
|
|
2192
|
+
var THEMES = {
|
|
2193
|
+
dark: {}, // DEFAULTS already are the dark look
|
|
2194
|
+
light: {
|
|
2195
|
+
background: "#ffffff",
|
|
2196
|
+
textColor: "#5f6b7c",
|
|
2197
|
+
gridColor: "rgba(42,46,57,0.10)",
|
|
2198
|
+
separatorColor: "rgba(42,46,57,0.28)",
|
|
2199
|
+
crosshair: "rgba(95,107,124,0.45)",
|
|
2200
|
+
tagBg: "#e0e3eb",
|
|
2201
|
+
tagText: "#131722",
|
|
2202
|
+
volumeUp: "rgba(8,153,129,0.30)",
|
|
2203
|
+
volumeDown: "rgba(242,54,69,0.30)",
|
|
2204
|
+
upColor: "#089981", downColor: "#f23645",
|
|
2205
|
+
wickUp: "#089981", wickDown: "#f23645",
|
|
2206
|
+
},
|
|
2207
|
+
};
|
|
2208
|
+
|
|
2209
|
+
// Resample finer bars into a coarser frame: any fixed step in seconds, or
|
|
2210
|
+
// the calendar frames "1M"/"1Y" (months and years have no fixed length).
|
|
2211
|
+
// This is what makes custom timeframes provider-independent: a source that
|
|
2212
|
+
// only serves 1m can still feed a 2m/3m/1M/1Y chart.
|
|
2213
|
+
function resample(bars, spec) {
|
|
2214
|
+
if (!bars || !bars.length) return [];
|
|
2215
|
+
function bucketOf(t) {
|
|
2216
|
+
if (spec === "1M") { var d = new Date(t * 1000); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000; }
|
|
2217
|
+
if (spec === "1Y") { var y = new Date(t * 1000); return Date.UTC(y.getUTCFullYear(), 0, 1) / 1000; }
|
|
2218
|
+
return Math.floor(t / spec) * spec;
|
|
2219
|
+
}
|
|
2220
|
+
var out = [], cur = null;
|
|
2221
|
+
for (var i = 0; i < bars.length; i++) {
|
|
2222
|
+
var b = bars[i], bk = bucketOf(b.time);
|
|
2223
|
+
if (!cur || cur.time !== bk) {
|
|
2224
|
+
if (cur) out.push(cur);
|
|
2225
|
+
cur = { time: bk, open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0 };
|
|
2226
|
+
} else {
|
|
2227
|
+
if (b.high > cur.high) cur.high = b.high;
|
|
2228
|
+
if (b.low < cur.low) cur.low = b.low;
|
|
2229
|
+
cur.close = b.close;
|
|
2230
|
+
cur.volume += b.volume || 0;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
if (cur) out.push(cur);
|
|
2234
|
+
return out;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
global.TRCharts = {
|
|
2238
|
+
version: "1.0.0",
|
|
2239
|
+
themes: THEMES,
|
|
2240
|
+
resample: resample,
|
|
2241
|
+
createChart: function (el, options) { return new Chart(el, options); },
|
|
2242
|
+
};
|
|
2243
|
+
})(typeof window !== "undefined" ? window : this);
|