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.