PageSourceSearch

https://degenai.dev/js/plugins/chart-plugins/measure/index.gd1f4p987v.js

js degenai.dev collected 2026-10-06 14:18:28 UTC 9,435 bytes, 251 lines download raw bytes

1/**
2 * Measure primitive for Lightweight Charts v5.
3 *
4 * Draws the TradingView-style price measurement: a translucent box between two anchors, a
5 * vertical arrow through its middle pointing at the destination price, and an info box with the
6 * move and the span it covered.
7 *
8 * Anchors are (time, price) pairs carrying SUB-BAR precision -- see measure-stats.js. They are
9 * mapped to x through the shared bar-anchored mapper rather than timeScale.timeToCoordinate(),
10 * whose exact-match lookup would silently snap every anchor to a bar centre; that snap is what
11 * made the previous measure tool jump sideways the instant a measurement was finished.
12 *
13 * This primitive implements attached()/detached(). That is not optional decoration: Lightweight
14 * Charts only hands a primitive its requestUpdate() callback if it exposes attached(), so a
15 * primitive without one cannot ask for the repaints that make a live-following overlay smooth.
16 */
17
18import { PluginBase } from '../shared/plugin-base.js';
19import { drawArrowhead } from '../shared/drawing-utils.js';
20import { makeTimeToX, getSeriesBars, medianCandleIntervalSec } from '../shared/time-mapping.js';
21import { measureStats, formatMeasureLines, placeInfoBox } from './measure-stats.js';
22
23export const DEFAULT_MEASURE_COLORS = {
24    upFill: 'rgba(184, 248, 78, 0.12)',
25    upBorder: 'rgba(184, 248, 78, 0.55)',
26    upText: '#B8F84E',
27    downFill: 'rgba(255, 107, 107, 0.12)',
28    downBorder: 'rgba(255, 107, 107, 0.55)',
29    downText: '#FF6B6B',
30    boxBackground: 'rgba(13, 18, 21, 0.92)',
31    subText: '#A2A2A2'
32};
33
34const FONT_SIZE = 11;
35const SUB_FONT_SIZE = 10;
36const LINE_GAP = 3;
37const BOX_PAD_X = 7;
38const BOX_PAD_Y = 5;
39const BOX_RADIUS = 3;
40const ANCHOR_DOT_RADIUS = 3;
41const ARROW_SIZE = 6;
42const FONT_FAMILY = "'Source Code Pro', monospace";
43
44function roundedRectPath(context, left, top, width, height, radius) {
45    const r = Math.min(radius, width / 2, height / 2);
46    context.beginPath();
47    context.moveTo(left + r, top);
48    context.lineTo(left + width - r, top);
49    context.arcTo(left + width, top, left + width, top + r, r);
50    context.lineTo(left + width, top + height - r);
51    context.arcTo(left + width, top + height, left + width - r, top + height, r);
52    context.lineTo(left + r, top + height);
53    context.arcTo(left, top + height, left, top + height - r, r);
54    context.lineTo(left, top + r);
55    context.arcTo(left, top, left + r, top, r);
56    context.closePath();
57}
58
59class MeasureRenderer {
60    constructor(data) {
61        this._data = data;
62    }
63
64    draw(target) {
65        const { x1, y1, x2, y2, lines, isPositive, colors } = this._data;
66
67        target.useBitmapCoordinateSpace(({ context, mediaSize, horizontalPixelRatio: hpr, verticalPixelRatio: vpr }) => {
68            const pr = Math.max(hpr, vpr);
69            const fill = isPositive ? colors.upFill : colors.downFill;
70            const border = isPositive ? colors.upBorder : colors.downBorder;
71            const accent = isPositive ? colors.upText : colors.downText;
72
73            const left = Math.min(x1, x2);
74            const right = Math.max(x1, x2);
75            const top = Math.min(y1, y2);
76            const bottom = Math.max(y1, y2);
77
78            context.save();
79
80            // --- Measured box ---
81            const bx = left * hpr;
82            const by = top * vpr;
83            const bw = Math.max(1, (right - left) * hpr);
84            const bh = Math.max(1, (bottom - top) * vpr);
85
86            context.fillStyle = fill;
87            context.fillRect(bx, by, bw, bh);
88            context.strokeStyle = border;
89            context.lineWidth = Math.max(1, pr);
90            context.strokeRect(bx, by, bw, bh);
91
92            // --- Vertical arrow through the middle, pointing at the destination price ---
93            const midX = ((x1 + x2) / 2) * hpr;
94            const startY = y1 * vpr;
95            const endY = y2 * vpr;
96            if (Math.abs(endY - startY) > ARROW_SIZE * pr) {
97                context.beginPath();
98                context.strokeStyle = accent;
99                context.lineWidth = Math.max(1, pr);
100                context.moveTo(midX, startY);
101                context.lineTo(midX, endY);
102                context.stroke();
103                // Canvas y grows downward, so a move to a HIGHER price has endY < startY.
104                const angle = endY > startY ? Math.PI / 2 : -Math.PI / 2;
105                drawArrowhead(context, midX, endY, angle, ARROW_SIZE * pr, accent);
106            }
107
108            // --- Anchor dots ---
109            context.fillStyle = accent;
110            for (const [dx, dy] of [[x1, y1], [x2, y2]]) {
111                context.beginPath();
112                context.arc(dx * hpr, dy * vpr, ANCHOR_DOT_RADIUS * pr, 0, Math.PI * 2);
113                context.fill();
114            }
115
116            // --- Info box ---
117            const [moveLine, spanLine] = lines;
118            context.font = `${FONT_SIZE * pr}px ${FONT_FAMILY}`;
119            const moveWidth = context.measureText(moveLine).width;
120            context.font = `${SUB_FONT_SIZE * pr}px ${FONT_FAMILY}`;
121            const spanWidth = context.measureText(spanLine).width;
122
123            // Layout happens in media pixels so the pane-clamping matches what the user sees.
124            const boxW = Math.max(moveWidth, spanWidth) / pr + BOX_PAD_X * 2;
125            const boxH = FONT_SIZE + SUB_FONT_SIZE + LINE_GAP + BOX_PAD_Y * 2;
126            const place = placeInfoBox({
127                x1, y1, x2, y2,
128                boxW, boxH,
129                paneW: mediaSize.width,
130                paneH: mediaSize.height
131            });
132
133            const ibx = place.left * hpr;
134            const iby = place.top * vpr;
135            const ibw = boxW * hpr;
136            const ibh = boxH * vpr;
137
138            roundedRectPath(context, ibx, iby, ibw, ibh, BOX_RADIUS * pr);
139            context.fillStyle = colors.boxBackground;
140            context.fill();
141            context.strokeStyle = border;
142            context.lineWidth = Math.max(1, pr);
143            context.stroke();
144
145            context.textAlign = 'center';
146            context.textBaseline = 'middle';
147            const centreX = ibx + ibw / 2;
148
149            context.font = `${FONT_SIZE * pr}px ${FONT_FAMILY}`;
150            context.fillStyle = accent;
151            context.fillText(moveLine, centreX, iby + (BOX_PAD_Y + FONT_SIZE / 2) * vpr);
152
153            context.font = `${SUB_FONT_SIZE * pr}px ${FONT_FAMILY}`;
154            context.fillStyle = colors.subText;
155            context.fillText(spanLine, centreX, iby + (BOX_PAD_Y + FONT_SIZE + LINE_GAP + SUB_FONT_SIZE / 2) * vpr);
156
157            context.restore();
158        });
159    }
160}
161
162class MeasurePaneView {
163    constructor(source) {
164        this._source = source;
165    }
166
167    renderer() {
168        const source = this._source;
169        const from = source._from;
170        const to = source._to;
171        const series = source._series;
172        const chart = source._chart;
173        if (!from || !to || !series || !chart) return null;
174
175        let timeScale = null;
176        try { timeScale = typeof chart.timeScale === 'function' ? chart.timeScale() : null; }
177        catch (e) { timeScale = null; }
178        if (!timeScale) return null;
179
180        const bars = getSeriesBars(series);
181        const intervalSec = medianCandleIntervalSec(bars) || 0;
182        const timeToX = makeTimeToX(chart, series, bars, intervalSec);
183
184        const x1 = timeToX(from.time);
185        const x2 = timeToX(to.time);
186        const y1 = series.priceToCoordinate(from.price);
187        const y2 = series.priceToCoordinate(to.price);
188        if (![x1, x2, y1, y2].every((v) => Number.isFinite(v))) return null;
189
190        const stats = measureStats(from, to, bars, intervalSec);
191        if (!stats) return null;
192
193        let priceFormat = null;
194        try { priceFormat = typeof series.options === 'function' ? series.options().priceFormat : null; }
195        catch (e) { priceFormat = null; }
196
197        return new MeasureRenderer({
198            x1, y1, x2, y2,
199            lines: formatMeasureLines(stats, priceFormat),
200            isPositive: stats.isPositive,
201            colors: source._colors
202        });
203    }
204}
205
206export class MeasurePrimitive extends PluginBase {
207    _from = null;
208    _to = null;
209    _requestUpdateFn = null;
210
211    constructor(options = {}) {
212        super();
213        this._colors = { ...DEFAULT_MEASURE_COLORS, ...(options.colors || {}) };
214        this._paneViews = [new MeasurePaneView(this)];
215    }
216
217    /**
218     * Lightweight Charts calls this on attachPrimitive and ONLY then supplies requestUpdate --
219     * a primitive without this hook can never trigger its own repaint.
220     */
221    attached(param) {
222        if (!param) return;
223        if (param.chart && param.series) this.setProviders(param.chart, param.series);
224        this._requestUpdateFn = typeof param.requestUpdate === 'function' ? param.requestUpdate : null;
225    }
226
227    detached() {
228        this._requestUpdateFn = null;
229    }
230
231    /** Ask the chart to repaint. Safe to call before attach and after detach. */
232    requestUpdate() {
233        if (this._requestUpdateFn) this._requestUpdateFn();
234    }
235
236    setPoints(from, to) {
237        this._from = from;
238        this._to = to;
239    }
240
241    paneViews() {
242        return this._paneViews;
243    }
244
245    updateAllViews() { /* pane view reads live state in renderer() */ }
246
247    /** A measurement must never influence the price scale it is measuring. */
248    autoscaleInfo() {
249        return null;
250    }
251}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.