SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

charts: +30 outils de dessin (Fib extension/time zones/fan/arcs, Gann fan/box, pitchforks Andrews/Schiff/mSchiff, canal de régression, long/short avec R:R et P&L, plages prix/date/prix-date/verticale, hray, croix, chemin, ellipse, triangle, flèches, callout, étiquette prix, drapeau, Elliott 12345/ABC, XABCD, tête-épaules), primitives génériques + hit-test, sélection multiple Shift, Alt+drag duplique, flèches clavier, dessins sur panneaux d'indicateurs, styles/verrou/visibilité par timeframe, copier/coller, événements drawingSelect et textEdit, palette du harnais, tests (64)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent 743f231

11 changed files +1,095 −268

modified hfmarketdata/web/dev/charts-harness.html +3 −0
@@ -25,6 +25,8 @@
25 25 #legend .k { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
26 26 main { position: relative; flex: 1; min-height: 0; }
27 27 #chart { position: absolute; inset: 0; }
28 + #props { position: absolute; left: 50%; transform: translateX(-50%); top: 8px; z-index: 6; gap: 6px; align-items: center; background: color-mix(in srgb, var(--bg) 85%, transparent); border: 1px solid var(--line); border-radius: 8px; padding: 4px 8px; font-size: 12px; }
29 + #props input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: none; }
28 30 #status { position: absolute; right: 8px; bottom: 32px; z-index: 5; font-size: 11px; color: var(--fg-2); background: color-mix(in srgb, var(--bg) 75%, transparent); padding: 2px 6px; border-radius: 4px; pointer-events: none; }
29 31 @media (max-width: 640px) { header { gap: 4px; padding: 4px; } header .group { padding-right: 4px; } button, select { padding: 4px 6px; } .desktop-only { display: none; } }
30 32 </style>
@@ -33,6 +35,7 @@
33 35 <header id="toolbar"></header>
34 36 <main>
35 37 <div id="legend"></div>
38 + <div id="props" style="display:none"></div>
36 39 <div id="chart"></div>
37 40 <div id="status"></div>
38 41 </main>
modified hfmarketdata/web/dev/charts-harness.js +76 −3
@@ -4,7 +4,7 @@
4 4 // URL params: ?tf=1min|1day&n=50000&theme=dark|light&type=candles&ind=rsi,macd&mode=log|percent&compare=1
5 5 // &drawings=1&volume=0&watermark=…&reduced=1
6 6
7 −import { createChart, darkTheme, lightTheme, SERIES_TYPES, INDICATOR_TYPES, INDICATOR_CATEGORIES, listIndicators, DRAWING_TOOLS } from '../src/charts/engine/index.js'
7 +import { createChart, darkTheme, lightTheme, SERIES_TYPES, INDICATOR_TYPES, INDICATOR_CATEGORIES, listIndicators, DRAWING_TOOLS, DRAWING_TOOLS_V1, DRAWING_TOOL_LABELS as TOOL_LABELS } from '../src/charts/engine/index.js'
8 8
9 9 /* ───────────── synthetic data ───────────── */
10 10
@@ -160,10 +160,39 @@ const cmpBtn = btn(g, 'Compare', () => {
160 160 g = group()
161 161 const toolButtons = {}
162 162 const glyph = { trendline: '╱', ray: '→', extended: '↔', hline: '─', vline: '│', rect: '▭', fib: 'Fib', measure: '📏', text: 'T', arrow: '➚', channel: '∥', brush: '✎' }
163 −for (const t of DRAWING_TOOLS) toolButtons[t] = btn(g, glyph[t] || t, () => chart.setDrawingTool(chart.drawings.tool === t ? null : t), 'tool')
163 +for (const t of DRAWING_TOOLS_V1) toolButtons[t] = btn(g, glyph[t] || t, () => chart.setDrawingTool(chart.drawings.tool === t ? null : t), 'tool')
164 +// v2 tools: grouped select (30 tools).
165 +const toolSel = document.createElement('select')
166 +{
167 + const first = document.createElement('option'); first.value = ''; first.textContent = `+ tool (${DRAWING_TOOLS.length})`; toolSel.appendChild(first)
168 + const groups = { Fibonacci: ['fib-extension', 'fib-timezones', 'fib-fan', 'fib-arcs'], Gann: ['gann-fan', 'gann-box'], Pitchforks: ['pitchfork', 'schiff', 'mschiff', 'regression'], Positions: ['long', 'short'], Ranges: ['price-range', 'date-range', 'date-price-range', 'vrange'], Lines: ['hray', 'cross', 'path'], Shapes: ['ellipse', 'triangle'], Annotations: ['arrow-up', 'arrow-down', 'callout', 'price-label', 'flag'], Patterns: ['elliott-impulse', 'elliott-correction', 'xabcd', 'head-shoulders'] }
169 + for (const [name, list] of Object.entries(groups)) { const og = document.createElement('optgroup'); og.label = name; for (const t of list) { const op = document.createElement('option'); op.value = t; op.textContent = TOOL_LABELS[t] || t; og.appendChild(op) } toolSel.appendChild(og) }
170 + toolSel.addEventListener('change', () => { if (toolSel.value) chart.setDrawingTool(toolSel.value) })
171 + g.appendChild(toolSel)
172 +}
164 173 btn(g, 'Undo', () => chart.undo(), 'desktop-only'); btn(g, 'Redo', () => chart.redo(), 'desktop-only')
165 174 btn(g, 'Clear', () => chart.clearDrawings(), 'desktop-only')
166 −chart.on('toolChange', t => { for (const k of Object.keys(toolButtons)) toolButtons[k].classList.toggle('active', k === t) })
175 +chart.on('toolChange', t => { for (const k of Object.keys(toolButtons)) toolButtons[k].classList.toggle('active', k === t); toolSel.value = t && !toolButtons[t] ? t : '' })
176 +// Floating property bar driven by drawingSelect / textEdit (what the /charts page will do in HTML).
177 +const props = document.getElementById('props')
178 +chart.on('drawingSelect', (id, ids) => {
179 + if (!id) { props.style.display = 'none'; return }
180 + const st = chart.getDrawingStyle(id)
181 + props.style.display = 'flex'
182 + props.innerHTML = `<b>${ids.length > 1 ? ids.length + ' drawings' : chart.getDrawing(id).type}</b>`
183 + const color = document.createElement('input'); color.type = 'color'; color.value = st.color || '#3987e5'; color.addEventListener('input', () => ids.forEach(i => chart.setDrawingStyle(i, { color: color.value }))); props.appendChild(color)
184 + const width = document.createElement('select'); for (const w of [1, 2, 3, 4]) { const o = document.createElement('option'); o.value = w; o.textContent = `${w} px`; width.appendChild(o) } width.value = st.width || 1; width.addEventListener('change', () => ids.forEach(i => chart.setDrawingStyle(i, { width: Number(width.value) }))); props.appendChild(width)
185 + const dash = document.createElement('select'); for (const [v, l] of [['solid', 'solid'], ['dashed', 'dashed'], ['dotted', 'dotted']]) { const o = document.createElement('option'); o.value = v; o.textContent = l; dash.appendChild(o) } dash.value = st.dash ? (st.dash[0] <= 2 ? 'dotted' : 'dashed') : 'solid'; dash.addEventListener('change', () => ids.forEach(i => chart.setDrawingStyle(i, { dash: dash.value === 'solid' ? null : dash.value }))); props.appendChild(dash)
186 + const lock = document.createElement('button'); lock.textContent = st.locked ? '🔒' : '🔓'; lock.addEventListener('click', () => { ids.forEach(i => chart.setDrawingLocked(i, !st.locked)); chart.selectDrawing(ids) }); props.appendChild(lock)
187 + const dup = document.createElement('button'); dup.textContent = 'Duplicate'; dup.addEventListener('click', () => chart.duplicateDrawing(id)); props.appendChild(dup)
188 + const del = document.createElement('button'); del.textContent = 'Delete'; del.addEventListener('click', () => chart.deleteSelectedDrawing()); props.appendChild(del)
189 +})
190 +chart.on('textEdit', ({ id, text, x, y }) => {
191 + const input = document.createElement('input'); input.value = text; input.style.cssText = `position:absolute;left:${x}px;top:${y - 12}px;z-index:9;font:12px monospace;`
192 + container.appendChild(input); input.focus(); input.select()
193 + const done = () => { chart.setDrawingText(id, input.value); input.remove() }
194 + input.addEventListener('blur', done); input.addEventListener('keydown', e => { if (e.key === 'Enter') input.blur(); if (e.key === 'Escape') { input.value = text; input.blur() } })
195 +})
167 196
168 197 g = group()
169 198 btn(g, 'Fit', () => chart.fitContent(true)); btn(g, 'Latest', () => chart.scrollToLatest(true)); btn(g, 'Reset', () => chart.resetView())
@@ -259,6 +288,50 @@ if (q.get('drawings') === '1') {
259 288 chart.drawings.selectedId = 'tl'
260 289 chart.invalidate('overlay')
261 290 }
291 +if (q.get('drawings') === '2') {
292 + // v2 tool showcase.
293 + const data = chart.getData()
294 + const n = data.length
295 + const at = i => data[Math.max(0, Math.min(n - 1, i))]
296 + const P = (i, k = 'c') => ({ t: at(i).t, price: at(i)[k] })
297 + chart.setDrawings([
298 + { id: 'lp', type: 'long', points: [P(n - 40, 'c'), { t: at(n - 12).t, price: at(n - 40).c * 1.012 }, { t: at(n - 12).t, price: at(n - 40).c * 0.994 }], style: { qty: 100 } },
299 + { id: 'pf', type: 'pitchfork', points: [P(n - 150, 'l'), P(n - 120, 'h'), P(n - 100, 'l')] },
300 + { id: 'fe', type: 'fib-extension', points: [P(n - 95, 'l'), P(n - 75, 'h'), P(n - 62, 'l')] },
301 + { id: 'rg', type: 'regression', points: [P(n - 60), P(n - 42)] },
302 + { id: 'gf', type: 'gann-fan', points: [P(n - 135, 'l'), P(n - 110, 'h')], style: { color: '#c98500' } },
303 + { id: 'xa', type: 'xabcd', points: [P(n - 170, 'l'), P(n - 162, 'h'), P(n - 156, 'l'), P(n - 150, 'h'), P(n - 144, 'l')], style: { color: '#d55181' } },
304 + { id: 'co', type: 'callout', points: [P(n - 75, 'h'), { t: at(n - 60).t, price: at(n - 75).h * 1.004 }], text: 'Swing high\nwatch 0.618' },
305 + { id: 'pl', type: 'price-label', points: [P(n - 20, 'l')] },
306 + { id: 'au', type: 'arrow-up', points: [P(n - 100, 'l')], text: 'Buy' },
307 + { id: 'hr', type: 'hray', points: [P(n - 30, 'h')], style: { dash: [4, 3] } },
308 + { id: 'dr', type: 'date-range', points: [P(n - 190, 'l'), P(n - 172, 'l')] },
309 + { id: 'el', type: 'ellipse', points: [{ t: at(n - 130).t, price: at(n - 130).h * 1.002 }, { t: at(n - 115).t, price: at(n - 130).l * 0.998 }], style: { color: '#199e70' } },
310 + { id: 'ft', type: 'fib-timezones', points: [P(n - 190), P(n - 182)], style: { showLabels: true } },
311 + { id: 'fl', type: 'flag', points: [P(n - 8, 'h')], text: 'Close' },
312 + ])
313 + chart.selectDrawing('lp')
314 +}
315 +if (q.get('drawings') === '3') {
316 + const data = chart.getData(); const n = data.length; const at = i => data[Math.max(0, Math.min(n - 1, i))]
317 + const P = (i, k = 'c') => ({ t: at(i).t, price: at(i)[k] })
318 + chart.setDrawings([
319 + { id: 'ff', type: 'fib-fan', points: [P(n - 120, 'l'), P(n - 80, 'h')] },
320 + { id: 'fa', type: 'fib-arcs', points: [P(n - 180, 'l'), P(n - 150, 'h')] },
321 + { id: 'gb', type: 'gann-box', points: [P(n - 70, 'l'), P(n - 40, 'h')] },
322 + { id: 'sc', type: 'schiff', points: [P(n - 200, 'h'), P(n - 185, 'l'), P(n - 170, 'h')], style: { color: '#9085e9' } },
323 + { id: 'sp', type: 'short', points: [P(n - 30, 'c'), { t: at(n - 5).t, price: at(n - 30).c * 0.99 }, { t: at(n - 5).t, price: at(n - 30).c * 1.005 }] },
324 + { id: 'ei', type: 'elliott-impulse', points: [P(n - 165, 'l'), P(n - 158, 'h'), P(n - 152, 'l'), P(n - 140, 'h'), P(n - 134, 'l'), P(n - 126, 'h')], style: { color: '#e66767' } },
325 + { id: 'hs', type: 'head-shoulders', points: [P(n - 118, 'l'), P(n - 112, 'h'), P(n - 108, 'l'), P(n - 102, 'h'), P(n - 96, 'l'), P(n - 90, 'h'), P(n - 84, 'l')], style: { color: '#c98500' } },
326 + { id: 'pr', type: 'price-range', points: [P(n - 60, 'l'), P(n - 45, 'h')] },
327 + { id: 'dp', type: 'date-price-range', points: [P(n - 40, 'h'), P(n - 20, 'l')] },
328 + { id: 'vr', type: 'vrange', points: [P(n - 16), P(n - 10)], text: 'FOMC' },
329 + { id: 'cr', type: 'cross', points: [P(n - 75, 'c')] },
330 + { id: 'tr', type: 'triangle', points: [P(n - 200, 'l'), P(n - 190, 'h'), P(n - 180, 'l')], style: { color: '#199e70', fill: 0.2 } },
331 + { id: 'pa', type: 'path', points: [P(n - 12, 'l'), P(n - 9, 'h'), P(n - 6, 'l'), P(n - 3, 'h')], style: { color: '#d95926', width: 2 } },
332 + { id: 'ad', type: 'arrow-down', points: [P(n - 140, 'h')], text: 'Sell' },
333 + ])
334 +}
262 335 if (q.get('live') === '1') liveBtn.click()
263 336
264 337 /* ───────────── benchmark ───────────── */
modified hfmarketdata/web/scripts/charts-shots.mjs +1 −0
@@ -35,6 +35,7 @@ const SHOTS = [
35 35 { name: 'desktop-v2-oscillators', q: 'tf=1day&n=10000&ind=hma,lsma,stochrsi,ao,elder-ray' },
36 36 { name: 'desktop-v2-left-scale', q: 'tf=1day&n=10000&ind=obv@left,rsi,stoch@pane,cmf' },
37 37 { name: 'desktop-v2-drawings', q: 'tf=1min&n=50000&drawings=2' },
38 + { name: 'desktop-v2-drawings-b', q: 'tf=1min&n=50000&drawings=3' },
38 39 { name: 'desktop-v2-features', q: 'tf=1min&n=50000&features=1' },
39 40 { name: 'mobile-1min-candles', q: 'tf=1min&n=50000&ind=rsi', mobile: true },
40 41 { name: 'mobile-1day-light', q: 'tf=1day&n=10000&theme=light&type=area', mobile: true },
modified hfmarketdata/web/src/charts/engine/core/chart.js +16 −0
@@ -1063,6 +1063,22 @@ export class Chart {
1063 1063 deleteSelectedDrawing() { this.drawings.deleteSelected() }
1064 1064 undo() { this.drawings.undo() }
1065 1065 redo() { this.drawings.redo() }
1066 + // v2 additions
1067 + getDrawing(id) { return this.drawings.getDrawing(id) }
1068 + removeDrawing(id) { this.drawings.removeDrawing(id) }
1069 + getDrawingStyle(id) { return this.drawings.getStyle(id) }
1070 + setDrawingStyle(id, style) { this.drawings.setStyle(id, style) }
1071 + setDrawingText(id, text) { this.drawings.setText(id, text) }
1072 + setDrawingLocked(id, locked) { this.drawings.setLocked(id, locked) }
1073 + setDrawingVisible(id, visible) { this.drawings.setVisible(id, visible) }
1074 + setDrawingTimeframes(id, tfs) { this.drawings.setTimeframes(id, tfs) }
1075 + selectDrawing(ids) { this.drawings.select(ids) }
1076 + getSelectedDrawings() { return this.drawings.getSelected() }
1077 + duplicateDrawing(id, bars) { return this.drawings.duplicate(id, bars) }
1078 + copyDrawings() { return this.drawings.copy() }
1079 + pasteDrawings(json, bars) { return this.drawings.paste(json, bars) }
1080 + finishDrawing() { return this.drawings.finish() }
1081 + nudgeDrawings(dxBars, dyTicks) { return this.drawings.nudge(dxBars, dyTicks) }
1066 1082
1067 1083 /* ─── navigation ─── */
1068 1084
modified hfmarketdata/web/src/charts/engine/core/emitter.js +2 −2
@@ -10,11 +10,11 @@ export class Emitter {
10 10 return () => { set.delete(fn) }
11 11 }
12 12
13 − emit(event, payload) {
13 + emit(event, payload, ...rest) {
14 14 const set = this._h.get(event)
15 15 if (!set || set.size === 0) return
16 16 for (const fn of Array.from(set)) {
17 − try { fn(payload) } catch (e) { if (typeof console !== 'undefined') console.error(e) }
17 + try { fn(payload, ...rest) } catch (e) { if (typeof console !== 'undefined') console.error(e) }
18 18 }
19 19 }
20 20
modified hfmarketdata/web/src/charts/engine/drawings.test.js +79 −0
@@ -63,3 +63,82 @@ test('geometry: hit-testing prefers handles, then bodies, within 6 px', () => {
63 63 assert.deepEqual(hitDrawing('brush', brush, 15, 5, box), { part: 'body' })
64 64 assert.equal(hitDrawing('brush', brush, 2, 2, box).part, 'body') // brushes have no handles
65 65 })
66 +
67 +/* ───────────── v2 ───────────── */
68 +import { TOOLS_V2, TOOL_LABELS, CREATE_CLICKS, MULTI_MODE } from './drawings/model.js'
69 +import { primitives } from './drawings/geometry.js'
70 +
71 +test('model v2: 42 tools, every tool has a point count and a label; new fields round-trip', () => {
72 + assert.ok(TOOLS.length >= 42, `${TOOLS.length} tools`)
73 + assert.ok(TOOLS_V2.length >= 30)
74 + for (const t of TOOLS) { assert.ok(POINT_COUNT[t] != null, t); assert.ok(TOOL_LABELS[t], `label ${t}`) }
75 + assert.equal(CREATE_CLICKS.long, 1); assert.equal(MULTI_MODE.path, 'click'); assert.equal(MULTI_MODE.brush, 'drag')
76 + const d = newDrawing('long', [{ t: 1, price: 100 }, { t: 5, price: 104 }, { t: 5, price: 98 }], { style: { color: '#0f0', fill: 0.3, qty: 10 }, pane: 'pane-7', visible: false, timeframes: ['1min', '5min'], props: { note: 'x' } })
77 + const json = serialize([d])[0]
78 + assert.equal(json.pane, 'pane-7'); assert.equal(json.visible, false); assert.deepEqual(json.timeframes, ['1min', '5min']); assert.equal(json.style.qty, 10); assert.equal(json.style.fill, 0.3); assert.deepEqual(json.props, { note: 'x' })
79 + const back = deserialize(JSON.parse(JSON.stringify([json])))[0]
80 + assert.equal(back.pane, 'pane-7'); assert.equal(back.visible, false); assert.deepEqual(back.timeframes, ['1min', '5min']); assert.equal(back.style.fill, 0.3)
81 + // Main pane / visible are the defaults and are stripped.
82 + const plain = serialize([newDrawing('hline', [{ t: 1, price: 1 }])])[0]
83 + assert.equal(plain.pane, undefined); assert.equal(plain.visible, undefined)
84 + // Fill alpha is clamped, unknown style keys are dropped.
85 + const v = validateDrawing({ type: 'rect', points: [{ t: 1, price: 1 }, { t: 2, price: 2 }], style: { fill: 7, bogus: 1 } })
86 + assert.equal(v.style.fill, 1); assert.equal(v.style.bogus, undefined)
87 + // Elliott impulse needs 6 points.
88 + assert.equal(validateDrawing({ type: 'elliott-impulse', points: Array.from({ length: 5 }, (_, i) => ({ t: i, price: i })) }), null)
89 + assert.ok(validateDrawing({ type: 'elliott-impulse', points: Array.from({ length: 6 }, (_, i) => ({ t: i, price: i })) }))
90 +})
91 +
92 +test('geometry v2: primitives of the new tools', () => {
93 + const box = { w: 800, h: 400 }
94 + const A = { x: 100, y: 300 }, B = { x: 300, y: 100 }, C = { x: 400, y: 200 }
95 + // Fib extension: levels from C by (B − A) × ratio; 8 levels + 2 guides.
96 + const fe = primitives('fib-extension', [A, B, C], box)
97 + const lv = fe.filter(p => p.k === 'seg' && !p.guide)
98 + assert.equal(lv.length, 8); assert.equal(lv[0].y1, 200); near(lv.find(p => p.level === 1).y1, 200 + (100 - 300))
99 + // Fib time zones: vertical lines at A.x + d × fib numbers until they leave the box.
100 + const tz = primitives('fib-timezones', [{ x: 100, y: 0 }, { x: 150, y: 0 }], box).filter(p => p.k === 'line')
101 + assert.deepEqual(tz.slice(0, 5).map(p => p.x1), [100, 150, 200, 250, 350])
102 + assert.ok(tz.every(p => p.x1 <= 802))
103 + // Gann fan: 9 rays from A; the 1/1 ray passes through B.
104 + const gf = primitives('gann-fan', [A, B], box).filter(p => p.k === 'ray')
105 + assert.equal(gf.length, 9); const one = gf.find(p => p.main); assert.equal(one.x2, B.x); assert.equal(one.y2, B.y)
106 + // Pitchfork: median from A through the midpoint of BC, two parallel tines, a fill polygon.
107 + const pf = primitives('pitchfork', [A, B, C], box)
108 + const median = pf.find(p => p.median); near(median.x2, 350); near(median.y2, 150)
109 + assert.equal(pf.filter(p => p.k === 'ray').length, 3); assert.ok(pf.some(p => p.k === 'poly' && p.fill))
110 + // Schiff shifts the pivot to mid A-B in y; modified Schiff in x and y.
111 + near(primitives('schiff', [A, B, C], box).find(p => p.median).y1, 200)
112 + near(primitives('mschiff', [A, B, C], box).find(p => p.median).x1, 200)
113 + // Position tool: profit + loss rectangles and the entry line.
114 + const lp = primitives('long', [{ x: 100, y: 200 }, { x: 300, y: 100 }, { x: 300, y: 250 }], box)
115 + assert.equal(lp.filter(p => p.k === 'rect').length, 2); assert.equal(lp.find(p => p.zone === 'profit').h, 100); assert.equal(lp.find(p => p.zone === 'loss').h, 50)
116 + // Ellipse from a bounding box; head & shoulders has a neckline through points 2 and 4.
117 + const el = primitives('ellipse', [A, B], box)[0]; near(el.cx, 200); near(el.rx, 100)
118 + const hs = primitives('head-shoulders', [0, 1, 2, 3, 4, 5, 6].map(i => ({ x: 100 + i * 50, y: [300, 200, 250, 100, 250, 200, 300][i] })), box)
119 + assert.ok(hs.some(p => p.neckline))
120 + // Fib arcs face A (centered on B).
121 + const arcs = primitives('fib-arcs', [A, B], box).filter(p => p.k === 'arc')
122 + assert.equal(arcs.length, 4); near(arcs[3].r, Math.hypot(200, 200)); assert.equal(arcs[0].cx, B.x)
123 +})
124 +
125 +test('geometry v2: hit-testing the new tools', () => {
126 + const box = { w: 800, h: 400 }
127 + const A = { x: 100, y: 300 }, B = { x: 300, y: 100 }
128 + assert.deepEqual(hitDrawing('hray', [A], 700, 302, box), { part: 'body' }) // ray to the right
129 + assert.equal(hitDrawing('hray', [A], 50, 300, box), null) // nothing to the left
130 + assert.deepEqual(hitDrawing('cross', [A], 100, 20, box), { part: 'body' })
131 + assert.deepEqual(hitDrawing('ellipse', [A, B], 200, 102, box), { part: 'body' }) // on the ring (top)
132 + assert.deepEqual(hitDrawing('ellipse', [A, B], 200, 200, box), { part: 'body' }) // inside (filled)
133 + assert.equal(hitDrawing('ellipse', [A, B], 105, 105, box), null) // corner of the bbox is outside the ellipse
134 + assert.deepEqual(hitDrawing('triangle', [A, B, { x: 500, y: 300 }], 300, 250, box), { part: 'body' })
135 + assert.deepEqual(hitDrawing('gann-fan', [A, B], 500, -100 + 4, box), { part: 'body' }) // on the 1/1 ray extension
136 + assert.deepEqual(hitDrawing('fib-arcs', [A, B], B.x - Math.hypot(200, 200), B.y, box), { part: 'body' }) // on the 1.0 arc, facing A
137 + assert.equal(hitDrawing('fib-arcs', [A, B], B.x + Math.hypot(200, 200), B.y, box), null) // the far half is not drawn
138 + assert.deepEqual(hitDrawing('long', [{ x: 100, y: 200 }, { x: 300, y: 100 }, { x: 300, y: 250 }], 200, 150, box), { part: 'body' })
139 + assert.deepEqual(hitDrawing('long', [{ x: 100, y: 200 }, { x: 300, y: 100 }, { x: 300, y: 250 }], 302, 101, box), { part: 'handle', index: 1 })
140 + assert.deepEqual(hitDrawing('path', [A, B, { x: 500, y: 300 }], 400, 200, box), { part: 'body' })
141 + assert.deepEqual(hitDrawing('vrange', [{ x: 100, y: 0 }, { x: 200, y: 0 }], 150, 350, box), { part: 'body' })
142 + assert.deepEqual(hitDrawing('arrow-up', [A], 104, 305, box), { part: 'handle', index: 0 })
143 + assert.equal(hitDrawing('flag', [A], 400, 50, box), null)
144 +})
modified hfmarketdata/web/src/charts/engine/drawings/geometry.js +229 −48
@@ -1,4 +1,12 @@
1 −// Pixel-space geometry for drawings: distances and hit-testing. Pure functions (unit-tested).
1 +// Pixel-space geometry for drawings: distances, primitive decomposition of every tool and hit-testing.
2 +// Pure functions (unit-tested). A "primitive" is one of:
3 +// { k: 'seg' | 'ray' | 'line', x1, y1, x2, y2 } straight pieces (ray = from 1 through 2, line = infinite)
4 +// { k: 'poly', pts: [[x, y]…], fill?: alpha, stroke?: bool, closed?: bool }
5 +// { k: 'rect', x, y, w, h, fill?: alpha, stroke?: bool }
6 +// { k: 'ellipse', cx, cy, rx, ry, fill? } { k: 'arc', cx, cy, r, a0, a1 }
7 +// plus decoration hints the renderer understands ({ k: 'label', … }) that never participate in hit-testing.
8 +
9 +import { FIB_LEVELS, FIB_EXTENDED, FIB_EXTENSION_LEVELS, FIB_FAN_LEVELS, FIB_ARC_LEVELS, FIB_TIME_SEQ, GANN_RATIOS, GANN_BOX_LEVELS } from './model.js'
2 10
3 11 export const HANDLE_R = 6
4 12 export const HIT_TOLERANCE = 6
@@ -56,60 +64,233 @@ export function distToRectBorder(px, py, x1, y1, x2, y2) {
56 64 )
57 65 }
58 66
67 +export function pointInPoly(px, py, poly) {
68 + let inside = false
69 + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
70 + const [xi, yi] = poly[i], [xj, yj] = poly[j]
71 + if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside
72 + }
73 + return inside
74 +}
75 +
76 +/* ─────────────────────────── primitive decomposition ─────────────────────────── */
77 +
78 +const seg = (x1, y1, x2, y2, extra) => ({ k: 'seg', x1, y1, x2, y2, ...(extra || {}) })
79 +const ray = (x1, y1, x2, y2, extra) => ({ k: 'ray', x1, y1, x2, y2, ...(extra || {}) })
80 +const line = (x1, y1, x2, y2, extra) => ({ k: 'line', x1, y1, x2, y2, ...(extra || {}) })
81 +const label = (x, y, text, extra) => ({ k: 'label', x, y, text, ...(extra || {}) })
82 +const fmtRatio = r => String(Math.round(r * 1000) / 1000).replace(/^0\./, '.')
83 +
59 84 /**
60 − * Hit-test a drawing given its pixel anchor points. Returns { part: 'handle', index } when a handle is hit,
61 − * { part: 'body' } for the shape itself, or null. `box` = { w, h } of the plot.
85 + * Primitives of a drawing from its pixel anchor points. `box` = { w, h } of the plot; `o` = { style, text, fmt,
86 + * regression: { xs, ys, sd } (pre-computed by the manager for 'regression'), labelOf(idx) }.
62 87 */
63 −export function hitDrawing(type, pts, px, py, box, tol = HIT_TOLERANCE) {
64 − for (let i = 0; i < pts.length; i++) {
65 − if (type === 'brush') break
66 − if (Math.hypot(px - pts[i].x, py - pts[i].y) <= HANDLE_R + 2) return { part: 'handle', index: i }
67 − }
88 +export function primitives(type, pts, box, o = {}) {
68 89 const [a, b, c] = pts
90 + const W = box.w, H = box.h
91 + const st = o.style || {}
92 + const out = []
93 + const fill = st.fill != null ? st.fill : 0.12
94 + const levelsOf = def => (Array.isArray(st.levels) && st.levels.length ? st.levels : def)
69 95 switch (type) {
70 − case 'trendline': case 'arrow': case 'measure':
71 − return distToSegment(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null
72 − case 'ray':
73 − return distToRay(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null
74 − case 'extended':
75 − return distToLine(px, py, a.x, a.y, b.x, b.y) <= tol ? { part: 'body' } : null
76 − case 'hline':
77 − return Math.abs(py - a.y) <= tol ? { part: 'body' } : null
78 − case 'vline':
79 − return Math.abs(px - a.x) <= tol ? { part: 'body' } : null
80 − case 'rect': case 'fib':
81 − if (type === 'fib') {
82 − const l = Math.min(a.x, b.x), r = Math.max(a.x, b.x)
83 − if (px < l - tol || px > r + tol) return null
84 − const lo = Math.min(a.y, b.y), hi = Math.max(a.y, b.y)
85 − return py >= lo - tol && py <= hi + tol ? { part: 'body' } : null
86 − }
87 − return distToRectBorder(px, py, a.x, a.y, b.x, b.y) <= tol || pointInRect(px, py, a.x, a.y, b.x, b.y) ? { part: 'body' } : null
88 − case 'text':
89 − return pointInRect(px, py, a.x - 4, a.y - 10, a.x + Math.max(40, (box.textW || 40)), a.y + 10) ? { part: 'body' } : null
96 + case 'trendline': case 'arrow': out.push(seg(a.x, a.y, b.x, b.y)); break
97 + case 'ray': out.push(ray(a.x, a.y, b.x, b.y)); break
98 + case 'extended': out.push(line(a.x, a.y, b.x, b.y)); break
99 + case 'hline': out.push(line(0, a.y, W, a.y)); break
100 + case 'hray': out.push(ray(a.x, a.y, a.x + 1, a.y)); break
101 + case 'vline': out.push(line(a.x, 0, a.x, H)); break
102 + case 'cross': out.push(line(0, a.y, W, a.y)); out.push(line(a.x, 0, a.x, H)); break
103 + case 'rect': case 'measure': case 'price-range': case 'date-range': case 'date-price-range':
104 + out.push({ k: 'rect', x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y), fill, stroke: true }); break
105 + case 'vrange':
106 + out.push({ k: 'rect', x: Math.min(a.x, b.x), y: 0, w: Math.abs(b.x - a.x), h: H, fill: st.fill != null ? st.fill : 0.08, stroke: false })
107 + out.push(line(a.x, 0, a.x, H)); out.push(line(b.x, 0, b.x, H)); break
108 + case 'ellipse': {
109 + const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2
110 + out.push({ k: 'ellipse', cx, cy, rx: Math.abs(b.x - a.x) / 2, ry: Math.abs(b.y - a.y) / 2, fill }); break
111 + }
112 + case 'triangle': out.push({ k: 'poly', pts: [[a.x, a.y], [b.x, b.y], [c.x, c.y]], fill, stroke: true, closed: true }); break
113 + case 'brush': case 'path': out.push({ k: 'poly', pts: pts.map(p => [p.x, p.y]), stroke: true, closed: false }); break
90 114 case 'channel': {
91 − if (distToSegment(px, py, a.x, a.y, b.x, b.y) <= tol) return { part: 'body' }
92 − if (!c) return null
93 − const ox = c.x - a.x, oy = c.y - a.y
94 − if (distToSegment(px, py, a.x + ox, a.y + oy, b.x + ox, b.y + oy) <= tol) return { part: 'body' }
95 − // Inside the parallelogram.
96 − const inside = pointInPoly(px, py, [[a.x, a.y], [b.x, b.y], [b.x + ox, b.y + oy], [a.x + ox, a.y + oy]])
97 − return inside ? { part: 'body' } : null
98 − }
99 − case 'brush': {
100 − for (let i = 1; i < pts.length; i++) if (distToSegment(px, py, pts[i - 1].x, pts[i - 1].y, pts[i].x, pts[i].y) <= tol) return { part: 'body' }
101 − return null
102 − }
103 − default:
104 − return null
115 + out.push(seg(a.x, a.y, b.x, b.y))
116 + if (c) {
117 + const ox = c.x - a.x, oy = c.y - a.y
118 + out.push(seg(a.x + ox, a.y + oy, b.x + ox, b.y + oy))
119 + out.push({ k: 'poly', pts: [[a.x, a.y], [b.x, b.y], [b.x + ox, b.y + oy], [a.x + ox, a.y + oy]], fill: st.fill != null ? st.fill : 0.10, stroke: false, closed: true })
120 + out.push(seg(a.x + ox / 2, a.y + oy / 2, b.x + ox / 2, b.y + oy / 2, { dash: [3, 3], alpha: 0.6 }))
121 + }
122 + break
123 + }
124 + case 'fib': {
125 + const l = Math.min(a.x, b.x), r = st.extendRight ? W : Math.max(a.x, b.x)
126 + const lv = levelsOf(st.extended ? FIB_EXTENDED : FIB_LEVELS)
127 + lv.forEach((k, i) => { const y = a.y + (b.y - a.y) * k; out.push(seg(st.extendLeft ? 0 : l, y, r, y, { level: k, index: i })) })
128 + out.push(seg(a.x, a.y, b.x, b.y, { dash: [3, 3], alpha: 0.5, guide: true }))
129 + break
130 + }
131 + case 'fib-extension': {
132 + const l = Math.min(c.x, b.x), r = st.extendRight === false ? Math.max(a.x, b.x, c.x) : W
133 + levelsOf(FIB_EXTENSION_LEVELS).forEach((k, i) => { const y = c.y + (b.y - a.y) * k; out.push(seg(l, y, r, y, { level: k, index: i })) })
134 + out.push(seg(a.x, a.y, b.x, b.y, { dash: [3, 3], alpha: 0.5, guide: true })); out.push(seg(b.x, b.y, c.x, c.y, { dash: [3, 3], alpha: 0.5, guide: true }))
135 + break
136 + }
137 + case 'fib-timezones': {
138 + const d = b.x - a.x
139 + if (Math.abs(d) < 1) { out.push(line(a.x, 0, a.x, H)); break }
140 + for (let i = 0; i < FIB_TIME_SEQ.length; i++) {
141 + const x = a.x + d * FIB_TIME_SEQ[i]
142 + if ((d > 0 && x > W + 2) || (d < 0 && x < -2)) break
143 + out.push(line(x, 0, x, H, { index: i, level: FIB_TIME_SEQ[i] }))
144 + }
145 + break
146 + }
147 + case 'fib-fan': {
148 + out.push(ray(a.x, a.y, b.x, b.y, { level: 1, index: 0 }))
149 + levelsOf(FIB_FAN_LEVELS).forEach((k, i) => out.push(ray(a.x, a.y, b.x, a.y + (b.y - a.y) * k, { level: k, index: i + 1 })))
150 + break
151 + }
152 + case 'fib-arcs': {
153 + const R = Math.hypot(b.x - a.x, b.y - a.y)
154 + const base = Math.atan2(a.y - b.y, a.x - b.x) // direction from B toward A: arcs face A
155 + levelsOf(FIB_ARC_LEVELS).forEach((k, i) => out.push({ k: 'arc', cx: b.x, cy: b.y, r: R * k, a0: base - Math.PI / 2, a1: base + Math.PI / 2, level: k, index: i }))
156 + out.push(seg(a.x, a.y, b.x, b.y, { dash: [3, 3], alpha: 0.5, guide: true }))
157 + break
158 + }
159 + case 'gann-fan': {
160 + const dx = b.x - a.x, dy = b.y - a.y
161 + GANN_RATIOS.forEach((k, i) => out.push(ray(a.x, a.y, a.x + dx, a.y + dy * k, { level: k, index: i, main: k === 1 })))
162 + break
163 + }
164 + case 'gann-box': {
165 + const l = Math.min(a.x, b.x), r = Math.max(a.x, b.x), t = Math.min(a.y, b.y), bt = Math.max(a.y, b.y)
166 + out.push({ k: 'rect', x: l, y: t, w: r - l, h: bt - t, fill: st.fill != null ? st.fill : 0.05, stroke: true })
167 + GANN_BOX_LEVELS.forEach((k, i) => { out.push(seg(l, t + (bt - t) * k, r, t + (bt - t) * k, { alpha: 0.55, index: i, level: k })); out.push(seg(l + (r - l) * k, t, l + (r - l) * k, bt, { alpha: 0.55, index: i, level: k })) })
168 + out.push(seg(a.x, a.y, b.x, b.y, { alpha: 0.8, diag: true })); out.push(seg(a.x, b.y, b.x, a.y, { alpha: 0.8, diag: true }))
169 + break
170 + }
171 + case 'pitchfork': case 'schiff': case 'mschiff': {
172 + if (!c) { out.push(seg(a.x, a.y, b.x, b.y, { dash: [3, 3] })); break }
173 + let ax = a.x, ay = a.y
174 + if (type === 'schiff') ay = (a.y + b.y) / 2
175 + if (type === 'mschiff') { ax = (a.x + b.x) / 2; ay = (a.y + b.y) / 2 }
176 + const mx = (b.x + c.x) / 2, my = (b.y + c.y) / 2
177 + const dx = mx - ax, dy = my - ay
178 + out.push(ray(ax, ay, mx, my, { median: true }))
179 + out.push(ray(b.x, b.y, b.x + dx, b.y + dy)); out.push(ray(c.x, c.y, c.x + dx, c.y + dy))
180 + const far = (W + H) * 4, len = Math.hypot(dx, dy) || 1, ux = dx / len, uy = dy / len
181 + out.push({ k: 'poly', pts: [[b.x, b.y], [b.x + ux * far, b.y + uy * far], [c.x + ux * far, c.y + uy * far], [c.x, c.y]], fill: st.fill != null ? st.fill : 0.07, stroke: false, closed: true, noHit: true })
182 + out.push(seg(a.x, a.y, b.x, b.y, { dash: [3, 3], alpha: 0.4, guide: true })); out.push(seg(b.x, b.y, c.x, c.y, { dash: [3, 3], alpha: 0.4, guide: true }))
183 + break
184 + }
185 + case 'regression': {
186 + const rg = o.regression
187 + if (!rg) { out.push(seg(a.x, a.y, b.x, b.y, { dash: [3, 3] })); break }
188 + const { x0, x1, y0, y1, up0, up1, lo0, lo1 } = rg
189 + out.push(seg(x0, y0, x1, y1, { main: true }))
190 + out.push(seg(x0, up0, x1, up1, { dash: [4, 3] })); out.push(seg(x0, lo0, x1, lo1, { dash: [4, 3] }))
191 + out.push({ k: 'poly', pts: [[x0, up0], [x1, up1], [x1, lo1], [x0, lo0]], fill: st.fill != null ? st.fill : 0.08, stroke: false, closed: true })
192 + break
193 + }
194 + case 'long': case 'short': {
195 + // a = entry (t0, entry) · b = (t1, target) · c = (t1, stop)
196 + const x0 = Math.min(a.x, b.x), x1 = Math.max(a.x, b.x)
197 + out.push({ k: 'rect', x: x0, y: Math.min(a.y, b.y), w: x1 - x0, h: Math.abs(b.y - a.y), fill: st.fill != null ? st.fill : 0.18, stroke: true, zone: 'profit' })
198 + out.push({ k: 'rect', x: x0, y: Math.min(a.y, c.y), w: x1 - x0, h: Math.abs(c.y - a.y), fill: st.fill != null ? st.fill : 0.18, stroke: true, zone: 'loss' })
199 + out.push(seg(x0, a.y, x1, a.y, { entry: true }))
200 + break
201 + }
202 + case 'text': case 'price-label': case 'flag': case 'arrow-up': case 'arrow-down':
203 + out.push({ k: 'marker', x: a.x, y: a.y }); break
204 + case 'callout':
205 + out.push(seg(a.x, a.y, b.x, b.y, { alpha: 0.8 })); out.push({ k: 'marker', x: b.x, y: b.y }); break
206 + case 'elliott-impulse': case 'elliott-correction': case 'xabcd': case 'head-shoulders': {
207 + out.push({ k: 'poly', pts: pts.map(p => [p.x, p.y]), stroke: true, closed: false })
208 + if (type === 'xabcd' && pts.length >= 5) {
209 + out.push({ k: 'poly', pts: [[pts[0].x, pts[0].y], [pts[1].x, pts[1].y], [pts[2].x, pts[2].y]], fill: st.fill != null ? st.fill : 0.10, stroke: false, closed: true, noHit: true })
210 + out.push({ k: 'poly', pts: [[pts[2].x, pts[2].y], [pts[3].x, pts[3].y], [pts[4].x, pts[4].y]], fill: st.fill != null ? st.fill : 0.10, stroke: false, closed: true, noHit: true })
211 + }
212 + if (type === 'head-shoulders' && pts.length >= 7) {
213 + // Neckline through the two troughs (points 2 and 4), extended across the pattern.
214 + const p2 = pts[2], p4 = pts[4]
215 + const [nx1, ny1, nx2, ny2] = extendLine(p2.x, p2.y, p4.x, p4.y, W, H, 'extended')
216 + out.push(seg(nx1, ny1, nx2, ny2, { dash: [4, 3], alpha: 0.7, neckline: true }))
217 + out.push({ k: 'poly', pts: pts.map(p => [p.x, p.y]), fill: st.fill != null ? st.fill : 0.08, stroke: false, closed: true, noHit: true })
218 + }
219 + break
220 + }
221 + default: break
105 222 }
223 + return out
106 224 }
107 225
108 −export function pointInPoly(px, py, poly) {
109 − let inside = false
110 − for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
111 − const [xi, yi] = poly[i], [xj, yj] = poly[j]
112 − if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside
226 +/** Labels helper shared by fib-like tools: ratio label text. */
227 +export { fmtRatio }
228 +
229 +/* ─────────────────────────── hit-testing ─────────────────────────── */
230 +
231 +function hitPrimitive(pr, px, py, tol) {
232 + switch (pr.k) {
233 + case 'seg': return distToSegment(px, py, pr.x1, pr.y1, pr.x2, pr.y2) <= tol
234 + case 'ray': return distToRay(px, py, pr.x1, pr.y1, pr.x2, pr.y2) <= tol
235 + case 'line': return distToLine(px, py, pr.x1, pr.y1, pr.x2, pr.y2) <= tol
236 + case 'rect': return distToRectBorder(px, py, pr.x, pr.y, pr.x + pr.w, pr.y + pr.h) <= tol || pointInRect(px, py, pr.x, pr.y, pr.x + pr.w, pr.y + pr.h)
237 + case 'poly': {
238 + const p = pr.pts
239 + for (let i = 1; i < p.length; i++) if (distToSegment(px, py, p[i - 1][0], p[i - 1][1], p[i][0], p[i][1]) <= tol) return true
240 + if (pr.closed && p.length > 2) { if (distToSegment(px, py, p[p.length - 1][0], p[p.length - 1][1], p[0][0], p[0][1]) <= tol) return true; if (pr.fill && pointInPoly(px, py, p)) return true }
241 + return false
242 + }
243 + case 'ellipse': {
244 + if (pr.rx < 1 || pr.ry < 1) return Math.hypot(px - pr.cx, py - pr.cy) <= tol
245 + const v = Math.hypot((px - pr.cx) / pr.rx, (py - pr.cy) / pr.ry)
246 + const ring = Math.abs(v - 1) * Math.min(pr.rx, pr.ry)
247 + return ring <= tol || (pr.fill && v <= 1)
248 + }
249 + case 'arc': {
250 + const d = Math.hypot(px - pr.cx, py - pr.cy)
251 + if (Math.abs(d - pr.r) > tol) return false
252 + let ang = Math.atan2(py - pr.cy, px - pr.cx)
253 + const norm = t => ((t % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
254 + const a0 = norm(pr.a0), a1 = norm(pr.a1); ang = norm(ang)
255 + return a0 <= a1 ? ang >= a0 && ang <= a1 : ang >= a0 || ang <= a1
256 + }
257 + case 'marker': return Math.hypot(px - pr.x, py - pr.y) <= Math.max(tol, 10)
258 + default: return false
113 259 }
114 − return inside
260 +}
261 +
262 +/**
263 + * Hit-test a drawing given its pixel anchor points. Returns { part: 'handle', index } when a handle is hit,
264 + * { part: 'body' } for the shape itself, or null. `box` = { w, h, textW? } of the plot; `o` = extra primitive
265 + * options (style, regression) — see `primitives`.
266 + */
267 +export function hitDrawing(type, pts, px, py, box, tol = HIT_TOLERANCE, o = {}) {
268 + if (type !== 'brush' && type !== 'path') {
269 + for (let i = 0; i < pts.length; i++) if (Math.hypot(px - pts[i].x, py - pts[i].y) <= HANDLE_R + 2) return { part: 'handle', index: i }
270 + } else if (type === 'path') {
271 + for (let i = 0; i < pts.length; i++) if (Math.hypot(px - pts[i].x, py - pts[i].y) <= HANDLE_R) return { part: 'handle', index: i }
272 + }
273 + const [a] = pts
274 + if (type === 'text') {
275 + const w = Math.max(40, box.textW || 40)
276 + return pointInRect(px, py, a.x - 4, a.y - 10, a.x + w, a.y + 10) ? { part: 'body' } : null
277 + }
278 + if (type === 'fib' || type === 'fib-extension') {
279 + // The whole level block is grabbable (like terminals): between the outermost levels, within the x span.
280 + const prs = primitives(type, pts, box, o).filter(p => p.k === 'seg' && !p.guide)
281 + if (!prs.length) return null
282 + const l = Math.min(...prs.map(p => p.x1)), r = Math.max(...prs.map(p => p.x2))
283 + const lo = Math.min(...prs.map(p => p.y1)), hi = Math.max(...prs.map(p => p.y1))
284 + if (px < l - tol || px > r + tol) return null
285 + return py >= lo - tol && py <= hi + tol ? { part: 'body' } : null
286 + }
287 + if (type === 'callout') {
288 + const b = pts[1]
289 + if (b && pointInRect(px, py, b.x - 6, b.y - 12, b.x + Math.max(60, box.textW || 60), b.y + 12)) return { part: 'body' }
290 + }
291 + for (const pr of primitives(type, pts, box, o)) {
292 + if (pr.k === 'label' || pr.noHit) continue
293 + if (hitPrimitive(pr, px, py, tol)) return { part: 'body' }
294 + }
295 + return null
115 296 }
modified hfmarketdata/web/src/charts/engine/drawings/manager.js +602 −207
@@ -1,12 +1,14 @@
1 −// Drawing manager: tool state machine (click-click or drag to create), selection, handles, move/resize,
2 −// undo/redo (JSON snapshots), rendering on the main pane overlay.
3 −
4 −import { TOOLS, POINT_COUNT, FIB_LEVELS, newDrawing, serialize, deserialize } from './model.js'
5 −import { hitDrawing, extendLine, HANDLE_R } from './geometry.js'
6 −import { crisp, withAlpha, roundRect, alignFor, hair } from '../render/canvas.js'
7 −import { font, pill, measure, inkFor } from '../render/text.js'
1 +// Drawing manager: tool state machine (click-click, drag, or multi-click creation), selection (multi with Shift),
2 +// handles, move/resize, Alt+drag duplication, keyboard nudging, per-pane drawings, styles, text editing events,
3 +// undo/redo (JSON snapshots), copy/paste, rendering on each pane's overlay.
4 +
5 +import { TOOLS, POINT_COUNT, CREATE_CLICKS, MULTI_MODE, TEXT_TOOLS, PANE_TOOLS, newDrawing, serialize, deserialize, newId } from './model.js'
6 +import { hitDrawing, primitives, extendLine, HANDLE_R, fmtRatio } from './geometry.js'
7 +import { crisp, withAlpha, roundRect, alignFor, hair, snap } from '../render/canvas.js'
8 +import { font, pill, measure, inkFor, textY } from '../render/text.js'
8 9 import { formatPercent } from '../format/number.js'
9 10 import { fmtDuration, fmtFull } from '../format/time.js'
11 +import { rollingLinReg } from '../../indicators/util.js'
10 12
11 13 const MAX_HISTORY = 100
12 14
@@ -15,48 +17,66 @@ export class DrawingManager {
15 17 this.chart = chart
16 18 this.list = []
17 19 this.tool = null
18 − this.creating = null // { type, points: [{t, price}], preview: {t, price} | null }
19 − this.selectedId = null
20 + this.creating = null // { type, points: [{t, price}], preview, pane, downX, downY, dragging }
21 + this.selected = new Set()
20 22 this.hoverId = null
21 − this.drag = null // { id, part, index, startX, startY, orig }
23 + this.drag = null // { ids, part, index, hitId, startX, startY, orig: Map<id, points>, pane, snapshot, moved }
22 24 this.undoStack = []
23 25 this.redoStack = []
24 26 this._textW = 40
27 + this.clipboard = null
25 28 }
26 29
27 − /* ─── coordinates ─── */
30 + /** Back-compat: first selected id (or null). */
31 + get selectedId() { return this.selected.size ? this.selected.values().next().value : null }
32 + set selectedId(v) { this.selected = new Set(v ? [v] : []) }
33 +
34 + /* ─── panes & coordinates ─── */
35 +
36 + paneKey(pane) { return !pane || pane.kind === 'main' ? 'main' : pane.id }
37 + paneOf(d) { const c = this.chart; if (!d.pane) return c.mainPane; return c.panes.find(p => p.id === d.pane) || null }
38 + inPane(d, pane) { return (d.pane || 'main') === this.paneKey(pane) }
28 39
29 − toPixel(p) {
40 + toPixel(p, pane) {
30 41 const c = this.chart
31 42 const i = c.store.indexOfTime(p.t)
32 − return { x: c.ts.x(i), y: c.mainPane.scale.y(p.price) }
43 + return { x: c.ts.x(i), y: (pane || c.mainPane).scale.y(p.price) }
33 44 }
34 45
35 − fromPixel(x, y, snap = true) {
46 + fromPixel(x, y, pane, snapOn = true) {
36 47 const c = this.chart
37 48 const idx = c.ts.indexAt(x)
38 49 const t = c.store.timeAtIndex(idx)
39 − const price = snap ? c.snapPrice(x, y) : c.mainPane.scale.priceAt(y)
50 + const main = !pane || pane.kind === 'main'
51 + const price = main && snapOn ? c.snapPrice(x, y) : (pane || c.mainPane).scale.priceAt(y)
40 52 return { t: t == null ? 0 : t, price }
41 53 }
42 54
55 + isVisible(d) {
56 + if (d.visible === false) return false
57 + if (d.timeframes && d.timeframes.length && !d.timeframes.includes(this.chart.opts.timeframe)) return false
58 + return true
59 + }
60 +
43 61 /* ─── public API ─── */
44 62
45 63 setTool(tool) {
46 64 if (tool != null && !TOOLS.includes(tool)) throw new Error(`Unknown drawing tool: ${tool}`)
47 65 this.tool = tool
48 66 this.creating = null
49 − if (tool) this.selectedId = null
67 + if (tool) this._select([])
50 68 this.chart.emitter.emit('toolChange', tool)
51 69 this.chart.invalidate('overlay')
52 70 }
53 71
54 72 toJSON() { return serialize(this.list) }
73 + getById(id) { return this.list.find(d => d.id === id) || null }
74 + getDrawing(id) { const d = this.getById(id); return d ? serialize([d])[0] : null }
55 75
56 76 fromJSON(list) {
57 77 this._push()
58 78 this.list = deserialize(list)
59 − this.selectedId = null
79 + this._select([])
60 80 this._changed()
61 81 }
62 82
@@ -64,17 +84,24 @@ export class DrawingManager {
64 84 if (!this.list.length) return
65 85 this._push()
66 86 this.list = []
67 − this.selectedId = null
87 + this._select([])
68 88 this._changed()
69 89 }
70 90
71 91 deleteSelected() {
72 − if (!this.selectedId) return
73 − const d = this.list.find(x => x.id === this.selectedId)
74 − if (!d || d.locked) return
92 + const ids = Array.from(this.selected).filter(id => { const d = this.getById(id); return d && !d.locked })
93 + if (!ids.length) return
94 + this._push()
95 + this.list = this.list.filter(d => !ids.includes(d.id))
96 + this._select([])
97 + this._changed()
98 + }
99 +
100 + removeDrawing(id) {
101 + if (!this.getById(id)) return
75 102 this._push()
76 − this.list = this.list.filter(x => x.id !== this.selectedId)
77 − this.selectedId = null
103 + this.list = this.list.filter(d => d.id !== id)
104 + this.selected.delete(id)
78 105 this._changed()
79 106 }
80 107
@@ -82,7 +109,7 @@ export class DrawingManager {
82 109 if (!this.undoStack.length) return
83 110 this.redoStack.push(JSON.stringify(serialize(this.list)))
84 111 this.list = deserialize(JSON.parse(this.undoStack.pop()))
85 − this.selectedId = null
112 + this._select([])
86 113 this._changed()
87 114 }
88 115
@@ -90,18 +117,111 @@ export class DrawingManager {
90 117 if (!this.redoStack.length) return
91 118 this.undoStack.push(JSON.stringify(serialize(this.list)))
92 119 this.list = deserialize(JSON.parse(this.redoStack.pop()))
93 − this.selectedId = null
120 + this._select([])
94 121 this._changed()
95 122 }
96 123
97 124 escape() {
98 125 if (this.creating) { this.creating = null; this.chart.invalidate('overlay'); return }
99 126 if (this.tool) { this.setTool(null); return }
100 − if (this.selectedId) { this.selectedId = null; this.chart.invalidate('overlay') }
127 + if (this.selected.size) this._select([])
128 + }
129 +
130 + /** Finish a multi-click drawing (path) — also triggered by double-click and Enter. */
131 + finish() {
132 + const cr = this.creating
133 + if (!cr || MULTI_MODE[cr.type] !== 'click') return false
134 + if (cr.points.length >= 2) this._commitCreating(); else this.creating = null
135 + this.chart.invalidate('overlay')
136 + return true
137 + }
138 +
139 + select(ids) {
140 + const arr = ids == null ? [] : Array.isArray(ids) ? ids : [ids]
141 + this._select(arr.filter(id => this.getById(id)))
142 + }
143 + getSelected() { return Array.from(this.selected) }
144 +
145 + getStyle(id) { const d = this.getById(id); return d ? { ...d.style, text: d.text, locked: d.locked, visible: d.visible !== false, timeframes: d.timeframes ? d.timeframes.slice() : null, pane: d.pane || 'main' } : null }
146 +
147 + setStyle(id, style = {}) {
148 + const d = this.getById(id)
149 + if (!d) return
150 + this._push()
151 + const { text, locked, visible, timeframes, ...rest } = style
152 + d.style = { ...d.style, ...rest }
153 + if (d.style.dash != null && !Array.isArray(d.style.dash)) d.style.dash = d.style.dash === 'dashed' ? [6, 4] : d.style.dash === 'dotted' ? [2, 3] : null
154 + if (!(d.style.width > 0)) d.style.width = 1
155 + if (text !== undefined) d.text = text || undefined
156 + if (locked !== undefined) d.locked = !!locked
157 + if (visible !== undefined) d.visible = visible !== false
158 + if (timeframes !== undefined) d.timeframes = Array.isArray(timeframes) && timeframes.length ? timeframes.slice() : undefined
159 + this._changed()
160 + }
161 +
162 + setText(id, text) { const d = this.getById(id); if (!d) return; this._push(); d.text = text || undefined; this._changed() }
163 + setLocked(id, locked) { this.setStyle(id, { locked }) }
164 + setVisible(id, visible) { this.setStyle(id, { visible }) }
165 + setTimeframes(id, tfs) { this.setStyle(id, { timeframes: tfs }) }
166 +
167 + /** Duplicate a drawing (offset by `bars` bars). Returns the new id. */
168 + duplicate(id, bars = 5) {
169 + const d = this.getById(id)
170 + if (!d) return null
171 + this._push()
172 + const copy = this._clone(d, bars)
173 + this.list.push(copy)
174 + this._select([copy.id])
175 + this._changed()
176 + return copy.id
177 + }
178 +
179 + _clone(d, bars = 0) {
180 + const c = this.chart
181 + const copy = deserialize([serialize([d])[0]])[0]
182 + copy.id = newId()
183 + if (bars) copy.points = copy.points.map(p => { const t = c.store.timeAtIndex(c.store.indexOfTime(p.t) + bars); return { t: t == null ? p.t : t, price: p.price } })
184 + return copy
185 + }
186 +
187 + /** JSON of the selected drawings (also kept in an internal clipboard). */
188 + copy() {
189 + const ids = Array.from(this.selected)
190 + if (!ids.length) return null
191 + this.clipboard = serialize(this.list.filter(d => ids.includes(d.id)))
192 + return JSON.parse(JSON.stringify(this.clipboard))
193 + }
194 +
195 + /** Paste drawings from JSON (or the internal clipboard); new ids, shifted by `bars`. Returns the new ids. */
196 + paste(json, bars = 5) {
197 + const src = deserialize(json || this.clipboard || [])
198 + if (!src.length) return []
199 + this._push()
200 + const added = src.map(d => this._clone(d, bars))
201 + this.list.push(...added)
202 + this._select(added.map(d => d.id))
203 + this._changed()
204 + return added.map(d => d.id)
205 + }
206 +
207 + /** Move the selection by whole bars (dx) and price ticks (dy, one tick = 10^-decimals or minMove). */
208 + nudge(dxBars, dyTicks) {
209 + const ids = Array.from(this.selected).filter(id => { const d = this.getById(id); return d && !d.locked })
210 + if (!ids.length) return false
211 + const c = this.chart
212 + this._push()
213 + for (const id of ids) {
214 + const d = this.getById(id)
215 + const pane = this.paneOf(d) || c.mainPane
216 + const tick = pane.kind === 'main' ? (c.opts.priceFormat.minMove || Math.pow(10, -c.decimals)) : Math.pow(10, -(pane.decimals || 2))
217 + d.points = d.points.map(p => { const t = dxBars ? c.store.timeAtIndex(c.store.indexOfTime(p.t) + dxBars) : p.t; return { t: t == null ? p.t : t, price: p.price + dyTicks * tick } })
218 + }
219 + this._changed()
220 + return true
101 221 }
102 222
103 223 onDataChange() { this.chart.invalidate('overlay') }
104 − destroy() { this.list = []; this.undoStack = []; this.redoStack = [] }
224 + destroy() { this.list = []; this.undoStack = []; this.redoStack = []; this.selected.clear() }
105 225
106 226 _push() {
107 227 this.undoStack.push(JSON.stringify(serialize(this.list)))
@@ -114,117 +234,164 @@ export class DrawingManager {
114 234 this.chart.invalidate('overlay')
115 235 }
116 236
237 + _select(ids) {
238 + const next = new Set(ids)
239 + const same = next.size === this.selected.size && Array.from(next).every(id => this.selected.has(id))
240 + this.selected = next
241 + this.chart.invalidate('overlay')
242 + if (!same) this.chart.emitter.emit('drawingSelect', this.selectedId, Array.from(next))
243 + }
244 +
117 245 /* ─── hit testing ─── */
118 246
119 − _pixels(d) { return d.points.map(p => this.toPixel(p)) }
247 + _pixels(d, pane) { return d.points.map(p => this.toPixel(p, pane)) }
120 248
121 − hitTest(x, y) {
122 − const box = { w: this.chart.plotWidth, h: this.chart.mainPane.height, textW: this._textW }
123 − // Selected drawing first (its handles take priority), then top-most.
124 − const order = this.list.slice().sort((a, b) => (a.id === this.selectedId ? -1 : b.id === this.selectedId ? 1 : 0))
249 + _opts(d, pane) {
250 + const o = { style: d.style, text: d.text }
251 + if (d.type === 'regression') o.regression = this._regression(d, pane)
252 + return o
253 + }
254 +
255 + hitTest(x, y, pane) {
256 + const c = this.chart
257 + pane = pane || c.mainPane
258 + const box = { w: c.plotWidth, h: pane.height, textW: this._textW }
259 + // Selected drawings first (their handles take priority), then top-most.
260 + const order = this.list.filter(d => this.inPane(d, pane) && this.isVisible(d)).sort((a, b) => (this.selected.has(a.id) ? -1 : this.selected.has(b.id) ? 1 : 0))
125 261 for (const d of order) {
126 − const hit = hitDrawing(d.type, this._pixels(d), x, y, box)
127 − if (hit) { if (hit.part === 'handle' && d.id !== this.selectedId) return { id: d.id, part: 'body' }; return { id: d.id, ...hit } }
262 + const hit = hitDrawing(d.type, this._pixels(d, pane), x, y, box, undefined, this._opts(d, pane))
263 + if (hit) { if (hit.part === 'handle' && !this.selected.has(d.id)) return { id: d.id, part: 'body' }; return { id: d.id, ...hit } }
128 264 }
129 265 return null
130 266 }
131 267
132 268 cursorAt(x, y, pane) {
133 − if (pane && pane.kind !== 'main') return null
134 269 if (this.drag) return this.drag.part === 'handle' ? 'grabbing' : 'move'
135 − if (this.tool) return 'crosshair'
136 − const h = this.hitTest(x, y)
270 + if (this.tool) return pane && pane.kind !== 'main' && !PANE_TOOLS.has(this.tool) ? 'not-allowed' : 'crosshair'
271 + const h = this.hitTest(x, y, pane)
137 272 if (!h) return null
138 273 return h.part === 'handle' ? 'grab' : 'pointer'
139 274 }
140 275
141 − /* ─── pointer state machine (coordinates relative to the main pane plot) ─── */
142 −
143 − onDoubleClick(x, y, pane) { return (!pane || pane.kind === 'main') && !!this.hitTest(x, y) }
276 + /* ─── pointer state machine (coordinates relative to the pane's plot) ─── */
144 277
145 278 pointerDown(x, y, ev, pane) {
146 − if (pane && pane.kind !== 'main') return false
279 + const c = this.chart
280 + pane = pane || c.mainPane
147 281 if (this.tool) {
148 − const p = this.fromPixel(x, y)
282 + if (pane.kind !== 'main' && !PANE_TOOLS.has(this.tool)) return false
283 + const p = this.fromPixel(x, y, pane)
149 284 if (!this.creating) {
150 − this.creating = { type: this.tool, points: [p], preview: null, downX: x, downY: y, dragging: true }
151 − if (POINT_COUNT[this.tool] === 1) { this._commitCreating(); return true }
285 + this.creating = { type: this.tool, points: [p], preview: null, pane, downX: x, downY: y, dragging: true }
286 + const need = CREATE_CLICKS[this.tool] || POINT_COUNT[this.tool]
287 + if (need === 1) { this._commitCreating(); return true }
288 + return true
289 + }
290 + const cr = this.creating
291 + if (cr.pane !== pane) return true // a drawing lives in one pane
292 + if (MULTI_MODE[cr.type] === 'click') {
293 + // Clicking the last point again finishes the path.
294 + const last = this.toPixel(cr.points[cr.points.length - 1], pane)
295 + if (Math.hypot(last.x - x, last.y - y) < 6) { this.finish(); return true }
296 + cr.points.push(p); cr.preview = null
297 + c.invalidate('overlay')
152 298 return true
153 299 }
154 − // Second / third click.
155 − this.creating.points.push(p)
156 − this.creating.dragging = true
157 − this.creating.downX = x; this.creating.downY = y
158 − if (this.creating.points.length >= POINT_COUNT[this.creating.type]) this._commitCreating()
300 + cr.points.push(p)
301 + cr.dragging = true
302 + cr.downX = x; cr.downY = y
303 + if (cr.points.length >= (CREATE_CLICKS[cr.type] || POINT_COUNT[cr.type])) this._commitCreating()
159 304 return true
160 305 }
161 − const hit = this.hitTest(x, y)
306 + const hit = this.hitTest(x, y, pane)
162 307 if (!hit) {
163 − if (this.selectedId) { this.selectedId = null; this.chart.invalidate('overlay') }
308 + if (this.selected.size && !(ev && ev.shiftKey)) this._select([])
164 309 return false
165 310 }
166 − const d = this.list.find(z => z.id === hit.id)
167 − this.selectedId = hit.id
168 − if (!d.locked) this.drag = { id: hit.id, part: hit.part, index: hit.index, startX: x, startY: y, orig: d.points.map(p => ({ ...p })), moved: false, snapshot: JSON.stringify(serialize(this.list)) }
169 − this.chart.invalidate('overlay')
170 − void ev
311 + const d = this.getById(hit.id)
312 + const shift = !!(ev && ev.shiftKey), alt = !!(ev && ev.altKey)
313 + let ids
314 + if (shift && hit.part === 'body') {
315 + ids = Array.from(this.selected)
316 + if (ids.includes(hit.id)) ids = ids.filter(i => i !== hit.id); else ids.push(hit.id)
317 + } else if (this.selected.has(hit.id) && hit.part === 'body' && this.selected.size > 1) ids = Array.from(this.selected)
318 + else ids = [hit.id]
319 + const snapshot = JSON.stringify(serialize(this.list))
320 + let hitId = hit.id
321 + // Alt + drag = duplicate the hit drawing and drag the copy.
322 + if (alt && !d.locked) {
323 + const copy = this._clone(d, 0)
324 + this.list.push(copy)
325 + ids = [copy.id]; hitId = copy.id
326 + }
327 + const dragIds = ids.filter(id => { const z = this.getById(id); return z && !z.locked })
328 + this._select(ids)
329 + if (dragIds.length) {
330 + const orig = new Map()
331 + for (const id of dragIds) orig.set(id, this.getById(id).points.map(p => ({ ...p })))
332 + this.drag = { ids: dragIds, part: hit.part, index: hit.index, hitId, dup: alt, origId: hit.id, startX: x, startY: y, orig, pane, moved: false, snapshot }
333 + }
334 + c.invalidate('overlay')
171 335 return true
172 336 }
173 337
174 338 pointerMove(x, y, ev, pane) {
175 − if (pane && pane.kind !== 'main') return false
339 + const c = this.chart
340 + pane = pane || c.mainPane
176 341 if (this.creating) {
177 − const p = this.fromPixel(x, y)
178 342 const cr = this.creating
179 − if (cr.type === 'brush') { if (cr.dragging) cr.points.push(p) }
180 − else cr.preview = p
181 − this.chart.invalidate('overlay')
343 + if (cr.pane !== pane) return true
344 + const p = this.fromPixel(x, y, pane)
345 + if (cr.type === 'brush') { if (cr.dragging) cr.points.push(p) } else cr.preview = p
346 + c.invalidate('overlay')
182 347 return true
183 348 }
184 349 if (this.drag) {
185 350 const dg = this.drag
186 − const d = this.list.find(z => z.id === dg.id)
187 − if (!d) { this.drag = null; return false }
188 351 if (!dg.moved && Math.hypot(x - dg.startX, y - dg.startY) < 2) return true
189 352 dg.moved = true
353 + const ps = dg.pane.scale
190 354 if (dg.part === 'handle') {
191 − const p = this.fromPixel(x, y)
192 − d.points[dg.index] = p
355 + const d = this.getById(dg.hitId)
356 + if (!d) { this.drag = null; return false }
357 + d.points[dg.index] = this.fromPixel(x, y, dg.pane)
358 + this._normalize(d, dg.index)
193 359 } else {
194 − // Move: translate every point by the pixel delta (in index/price space).
195 − const c = this.chart
360 + // Move: translate every point of every dragged drawing by the pixel delta (index/price space).
196 361 const di = (x - dg.startX) / c.ts.barSpacing
197 − const ps = c.mainPane.scale
198 362 const dPix = y - dg.startY
199 − d.points = dg.orig.map(o => {
200 − const i0 = c.store.indexOfTime(o.t)
201 − const t = c.store.timeAtIndex(i0 + di)
202 − const yy = ps.y(o.price) + dPix
203 − return { t: t == null ? o.t : t, price: ps.priceAt(yy) }
204 − })
363 + for (const id of dg.ids) {
364 + const d = this.getById(id)
365 + if (!d) continue
366 + d.points = dg.orig.get(id).map(o => {
367 + const t = c.store.timeAtIndex(c.store.indexOfTime(o.t) + di)
368 + return { t: t == null ? o.t : t, price: ps.priceAt(ps.y(o.price) + dPix) }
369 + })
370 + }
205 371 }
206 − this.chart.invalidate('overlay')
372 + c.invalidate('overlay')
207 373 return true
208 374 }
209 − const h = this.hitTest(x, y)
375 + const h = this.hitTest(x, y, pane)
210 376 const id = h ? h.id : null
211 − if (id !== this.hoverId) { this.hoverId = id; this.chart.invalidate('overlay') }
377 + if (id !== this.hoverId) { this.hoverId = id; c.invalidate('overlay') }
212 378 void ev
213 379 return false
214 380 }
215 381
216 382 pointerUp(x, y, ev, pane) {
217 − if (pane && pane.kind !== 'main') return false
383 + pane = pane || this.chart.mainPane
218 384 if (this.creating) {
219 385 const cr = this.creating
220 386 if (cr.type === 'brush') { cr.dragging = false; if (cr.points.length >= 2) this._commitCreating(); else this.creating = null; return true }
387 + if (MULTI_MODE[cr.type] === 'click') return true
221 388 const moved = Math.hypot(x - cr.downX, y - cr.downY) > 4
222 389 if (moved && cr.dragging) {
223 − cr.points.push(this.fromPixel(x, y))
224 − if (cr.points.length >= POINT_COUNT[cr.type]) this._commitCreating()
390 + cr.points.push(this.fromPixel(x, y, cr.pane))
391 + if (cr.points.length >= (CREATE_CLICKS[cr.type] || POINT_COUNT[cr.type])) this._commitCreating()
225 392 else cr.preview = null
226 393 }
227 − cr.dragging = false
394 + if (this.creating) this.creating.dragging = false
228 395 return true
229 396 }
230 397 if (this.drag) {
@@ -235,24 +402,66 @@ export class DrawingManager {
235 402 if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift()
236 403 this.redoStack = []
237 404 this._changed()
405 + } else if (dg.dup) {
406 + // Alt+click without a move: drop the duplicate.
407 + this.list = deserialize(JSON.parse(dg.snapshot)); this._select([dg.origId]); this.chart.invalidate('overlay')
238 408 }
239 409 return true
240 410 }
241 411 return false
242 412 }
243 413
414 + /** Double-click: finishes a path, opens text editing on text-capable drawings. Returns true when handled. */
415 + onDoubleClick(x, y, pane) {
416 + pane = pane || this.chart.mainPane
417 + if (this.creating && MULTI_MODE[this.creating.type] === 'click') return this.finish()
418 + const hit = this.hitTest(x, y, pane)
419 + if (!hit) return false
420 + const d = this.getById(hit.id)
421 + if (d && TEXT_TOOLS.has(d.type) && !d.locked) this._emitTextEdit(d, pane)
422 + return true
423 + }
424 +
425 + _emitTextEdit(d, pane) {
426 + const c = this.chart
427 + const anchor = d.type === 'callout' ? d.points[1] : d.points[0]
428 + const px = this.toPixel(anchor, pane)
429 + c.emitter.emit('textEdit', { id: d.id, text: d.text || '', x: px.x + c.plotX0, y: px.y + pane.top, pane: this.paneKey(pane) })
430 + }
431 +
432 + /** Keep tool-specific invariants after a handle drag (position tools share t between target and stop). */
433 + _normalize(d, index) {
434 + if (d.type === 'long' || d.type === 'short') {
435 + if (index === 1) d.points[2].t = d.points[1].t
436 + else if (index === 2) d.points[1].t = d.points[2].t
437 + const e = d.points[0].price
438 + const isLong = d.type === 'long'
439 + if (isLong) { if (d.points[1].price < e) d.points[1].price = e; if (d.points[2].price > e) d.points[2].price = e }
440 + else { if (d.points[1].price > e) d.points[1].price = e; if (d.points[2].price < e) d.points[2].price = e }
441 + }
442 + }
443 +
244 444 _commitCreating() {
245 445 const cr = this.creating
246 446 this.creating = null
247 447 if (!cr) return
248 − const d = newDrawing(cr.type, cr.points, { text: cr.type === 'text' ? 'Text' : undefined })
448 + const c = this.chart
449 + let points = cr.points
450 + if ((cr.type === 'long' || cr.type === 'short') && points.length === 1) {
451 + // One click spawns entry + target (2 %) + stop (1 %) over ~20 bars.
452 + const e = points[0]
453 + const i0 = c.store.indexOfTime(e.t)
454 + const t1 = c.store.timeAtIndex(i0 + 20) ?? e.t
455 + const sgn = cr.type === 'long' ? 1 : -1
456 + points = [e, { t: t1, price: e.price * (1 + sgn * 0.02) }, { t: t1, price: e.price * (1 - sgn * 0.01) }]
457 + }
458 + const d = newDrawing(cr.type, points, { text: cr.type === 'text' ? 'Text' : cr.type === 'callout' ? 'Note' : undefined, pane: this.paneKey(cr.pane) })
249 459 this._push()
250 460 this.list.push(d)
251 − this.selectedId = cr.type === 'measure' ? null : d.id
252 − // Measure is transient: it is removed as soon as the tool is used again or deselected — keep it as a
253 − // normal drawing here (callers may filter type === 'measure' before persisting).
461 + this._select(cr.type === 'measure' ? [] : [d.id])
254 462 this.setToolAfterCreate()
255 463 this._changed()
464 + if (cr.type === 'text' || cr.type === 'callout') this._emitTextEdit(d, cr.pane)
256 465 }
257 466
258 467 setToolAfterCreate() {
@@ -260,192 +469,366 @@ export class DrawingManager {
260 469 if (this.tool !== 'brush') { this.tool = null; this.chart.emitter.emit('toolChange', null) }
261 470 }
262 471
472 + /* ─── regression helper (pixel-space channel from the bars between the two anchors) ─── */
473 +
474 + _regression(d, pane) {
475 + const c = this.chart
476 + const st = c.store
477 + if (!st.length) return null
478 + const i0 = Math.max(0, Math.min(st.length - 1, Math.round(st.indexOfTime(d.points[0].t))))
479 + const i1 = Math.max(0, Math.min(st.length - 1, Math.round(st.indexOfTime(d.points[1].t))))
480 + const a = Math.min(i0, i1), b = Math.max(i0, i1)
481 + const n = b - a + 1
482 + if (n < 2) return null
483 + const src = st.c.subarray(a, b + 1)
484 + const { slope, intercept } = rollingLinReg(src, n)
485 + const m = slope[n - 1], k = intercept[n - 1]
486 + let sq = 0
487 + for (let j = 0; j < n; j++) { const r = src[j] - (k + m * j); sq += r * r }
488 + const sd = Math.sqrt(sq / n)
489 + const mult = d.style && d.style.mult > 0 ? d.style.mult : 2
490 + const ps = (pane || c.mainPane).scale
491 + const x0 = c.ts.x(a), xb = c.ts.x(b)
492 + const x1 = d.style && d.style.extendRight ? c.plotWidth : xb
493 + const y0 = ps.y(k), yb = ps.y(k + m * (n - 1))
494 + const slopePx = (yb - y0) / Math.max(1e-9, xb - x0)
495 + const run = (x1 - x0) * slopePx
496 + return { x0, x1, y0, y1: y0 + run, up0: ps.y(k + mult * sd), up1: ps.y(k + mult * sd) + run, lo0: ps.y(k - mult * sd), lo1: ps.y(k - mult * sd) + run, sd, slope: m }
497 + }
498 +
263 499 /* ─── rendering ─── */
264 500
265 501 draw(ctx, g) {
266 502 const { theme } = g
267 − if (g.pane && g.pane.kind !== 'main') return
268 − for (const d of this.list) this._drawOne(ctx, g, d, d.id === this.selectedId, d.id === this.hoverId)
269 − if (this.creating) {
503 + const pane = g.pane || this.chart.mainPane
504 + for (const d of this.list) {
505 + if (!this.inPane(d, pane) || !this.isVisible(d)) continue
506 + this._drawOne(ctx, g, d, this.selected.has(d.id), d.id === this.hoverId, false, pane)
507 + }
508 + if (this.creating && this.creating.pane === pane) {
270 509 const cr = this.creating
271 510 const pts = cr.points.slice()
272 511 if (cr.preview && cr.type !== 'brush') pts.push(cr.preview)
273 512 if (pts.length >= 1) {
274 513 const need = POINT_COUNT[cr.type]
275 514 const tmp = { type: cr.type, points: pts, style: { width: 1 }, text: cr.type === 'text' ? 'Text' : undefined }
276 − if (need === Infinity || pts.length >= Math.min(need, 2) || need === 1) this._drawOne(ctx, g, tmp, true, false, true)
277 − else this._drawHandles(ctx, pts.map(p => this.toPixel(p)), theme)
515 + if (need === Infinity || pts.length >= Math.min(need, 2) || need === 1) this._drawOne(ctx, g, tmp, true, false, true, pane)
516 + else this._drawHandles(ctx, pts.map(p => this.toPixel(p, pane)), theme)
278 517 }
279 518 }
280 519 }
281 520
282 − _drawOne(ctx, g, d, selected, hovered, preview = false) {
521 + _drawOne(ctx, g, d, selected, hovered, preview, pane) {
283 522 const { theme, width: W, height: H } = g
284 − const pts = this._pixels(d)
523 + const c = this.chart
524 + const pts = this._pixels(d, pane)
285 525 const color = (d.style && d.style.color) || theme.drawing
286 − const lw = (d.style && d.style.width) || 1
526 + const lwCss = (d.style && d.style.width) || 1
527 + const box = { w: W, h: H, textW: this._textW }
528 + const o = this._opts(d, pane)
287 529 ctx.save()
288 530 ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
289 531 ctx.strokeStyle = color
290 532 ctx.fillStyle = color
291 − ctx.lineWidth = hovered && !selected ? lw + 1 : lw
533 + ctx.lineWidth = hovered && !selected ? lwCss + 1 : lwCss
292 534 ctx.lineJoin = 'round'; ctx.lineCap = 'round'
293 − if (d.style && d.style.dash) ctx.setLineDash(d.style.dash)
535 + const baseDash = (d.style && d.style.dash) || []
536 + ctx.setLineDash(baseDash)
294 537 if (preview) ctx.globalAlpha = 0.85
295 − const [a, b, c] = pts
296 − switch (d.type) {
297 − case 'trendline': case 'ray': case 'extended': {
298 − const mode = d.type === 'trendline' ? 'segment' : d.type
299 − const [x1, y1, x2, y2] = extendLine(a.x, a.y, b.x, b.y, W, H, mode)
300 − line(ctx, x1, y1, x2, y2)
301 − break
302 − }
303 − case 'arrow': {
304 − line(ctx, a.x, a.y, b.x, b.y)
305 − arrowHead(ctx, a.x, a.y, b.x, b.y, 9 + lw * 2)
306 − break
538 + const baseAlpha = ctx.globalAlpha
539 + const prims = primitives(d.type, pts, box, o)
540 + const fmt = v => c._formatPane(pane, v)
541 + // Level colors for fib-like tools: one palette color per level.
542 + const perLevel = d.type === 'fib' || d.type === 'fib-extension' || d.type === 'fib-fan' || d.type === 'fib-arcs' || d.type === 'fib-timezones'
543 + for (const pr of prims) {
544 + if (pr.k === 'label' || pr.k === 'marker') continue
545 + const col = perLevel && pr.index != null ? theme.series[pr.index % theme.series.length] : pr.zone ? (pr.zone === 'profit' ? theme.up : theme.down) : color
546 + ctx.strokeStyle = col; ctx.fillStyle = col
547 + ctx.globalAlpha = baseAlpha * (pr.alpha == null ? 1 : pr.alpha)
548 + if (pr.dash) ctx.setLineDash(pr.dash)
549 + switch (pr.k) {
550 + case 'seg': {
551 + if (pr.y1 === pr.y2) { const y = alignFor(lwCss, pr.y1); strokeLine(ctx, pr.x1, y, pr.x2, y) } else if (pr.x1 === pr.x2) { const x = alignFor(lwCss, pr.x1); strokeLine(ctx, x, pr.y1, x, pr.y2) } else strokeLine(ctx, pr.x1, pr.y1, pr.x2, pr.y2)
552 + break
553 + }
554 + case 'ray': { const [x1, y1, x2, y2] = extendLine(pr.x1, pr.y1, pr.x2, pr.y2, W, H, 'ray'); if (y1 === y2) { const y = alignFor(lwCss, y1); strokeLine(ctx, x1, y, x2, y) } else strokeLine(ctx, x1, y1, x2, y2); break }
555 + case 'line': {
556 + if (pr.y1 === pr.y2) { const y = alignFor(lwCss, pr.y1); strokeLine(ctx, 0, y, W, y) } else if (pr.x1 === pr.x2) { const x = alignFor(lwCss, pr.x1); strokeLine(ctx, x, 0, x, H) } else { const [x1, y1, x2, y2] = extendLine(pr.x1, pr.y1, pr.x2, pr.y2, W, H, 'extended'); strokeLine(ctx, x1, y1, x2, y2) }
557 + break
558 + }
559 + case 'rect': {
560 + if (pr.fill) { ctx.globalAlpha = baseAlpha * pr.fill; ctx.fillRect(pr.x, pr.y, pr.w, pr.h); ctx.globalAlpha = baseAlpha * (pr.alpha == null ? 1 : pr.alpha) }
561 + if (pr.stroke) { const x = alignFor(lwCss, pr.x), y = alignFor(lwCss, pr.y); ctx.strokeRect(x, y, snap(pr.w), snap(pr.h)) }
562 + break
563 + }
564 + case 'poly': {
565 + ctx.beginPath(); pr.pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)))
566 + if (pr.closed) ctx.closePath()
567 + if (pr.fill) { ctx.globalAlpha = baseAlpha * pr.fill; ctx.fill(); ctx.globalAlpha = baseAlpha }
568 + if (pr.stroke) { if (d.type === 'brush') ctx.lineWidth = Math.max(1.5, lwCss); ctx.stroke() }
569 + break
570 + }
571 + case 'ellipse': {
572 + ctx.beginPath(); ctx.ellipse(pr.cx, pr.cy, Math.max(0.5, pr.rx), Math.max(0.5, pr.ry), 0, 0, Math.PI * 2)
573 + if (pr.fill) { ctx.globalAlpha = baseAlpha * pr.fill; ctx.fill(); ctx.globalAlpha = baseAlpha }
574 + ctx.stroke(); break
575 + }
576 + case 'arc': { ctx.beginPath(); ctx.arc(pr.cx, pr.cy, Math.max(0.5, pr.r), pr.a0, pr.a1); ctx.stroke(); break }
577 + default: break
307 578 }
308 − case 'hline': {
309 − ctx.setLineDash(d.style && d.style.dash ? d.style.dash : [])
310 − line(ctx, 0, crisp(a.y), W, crisp(a.y))
311 − ctx.restore(); ctx.save() // axis label lives outside the plot clip
312 − ctx.globalAlpha = preview ? 0.85 : 1
313 − pill(ctx, this.chart._formatMain(d.points[0].price), W + 4, a.y, { bg: color, color: inkFor(color), fontStr: font(theme, { mono: true, size: 11 }), h: 18, padX: 4, clampTo: { x0: W + 2, x1: W + this.chart.axisWidth - 1, y0: 0, y1: H } })
579 + if (pr.dash) ctx.setLineDash(baseDash)
580 + }
581 + ctx.globalAlpha = baseAlpha
582 + ctx.setLineDash([])
583 + ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lwCss
584 + this._decorate(ctx, g, d, pts, prims, color, pane, fmt)
585 + ctx.restore()
586 + if (selected) {
587 + if (d.type === 'brush') { ctx.save(); ctx.strokeStyle = theme.selection; ctx.lineWidth = 6; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.stroke(); ctx.restore() }
588 + else this._drawHandles(ctx, pts, theme, d.locked)
589 + }
590 + }
591 +
592 + _drawHandles(ctx, pts, theme, locked = false) {
593 + for (const p of pts) {
594 + ctx.beginPath(); ctx.arc(p.x, p.y, HANDLE_R + 4, 0, Math.PI * 2)
595 + ctx.fillStyle = theme.selection; ctx.fill()
596 + ctx.beginPath(); ctx.arc(p.x, p.y, HANDLE_R / 2 + 0.5, 0, Math.PI * 2)
597 + ctx.fillStyle = locked ? theme.textMuted : theme.drawingHandle; ctx.fill()
598 + ctx.lineWidth = 1.5; ctx.strokeStyle = theme.drawing; ctx.stroke()
599 + }
600 + }
601 +
602 + /* ─── decorations: labels, boxes, markers ─── */
603 +
604 + _decorate(ctx, g, d, pts, prims, color, pane, fmt) {
605 + const { theme, width: W, height: H } = g
606 + const c = this.chart
607 + const [a, b] = pts
608 + const st = d.style || {}
609 + const showLabels = st.showLabels !== false
610 + const small = font(theme, { mono: true, size: 10 })
611 + const tag = (text, x, y, col, align = 'left') => {
612 + ctx.font = small; ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'
613 + const w = measure(ctx, text, small)
614 + let lx = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x
615 + lx = Math.max(2, Math.min(W - w - 2, lx))
616 + ctx.fillStyle = withAlpha(theme.bg, 0.8); ctx.fillRect(snap(lx) - 3, snap(y) - 7, Math.ceil(w) + 6, 14)
617 + ctx.fillStyle = col; ctx.fillText(text, snap(lx), textY(y, 10))
618 + }
619 + const axisPill = (price, col) => { ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W + c.axisWidth, H); ctx.clip(); pill(ctx, fmt(price), W + 4, pane.scale.y(price), { bg: col, color: inkFor(col), fontStr: font(theme, { mono: true, size: 11 }), h: 18, padX: 4, clampTo: { x0: W + 2, x1: W + c.axisWidth - 1, y0: 0, y1: H } }); ctx.restore() }
620 + const timeTag = (t, x) => { const txt = fmtFull(t, c.opts.timeframe, c.opts.sessionLabel); pill(ctx, txt, x + 6, 12, { bg: withAlpha(color, 0.9), color: inkFor(color), fontStr: small, h: 16, padX: 4, clampTo: { x0: 0, x1: W, y0: 0, y1: H } }) }
621 + switch (d.type) {
622 + case 'hline': case 'hray': { if (a.y >= -9 && a.y <= H + 9) axisPill(d.points[0].price, color); if (d.text) tag(d.text, d.type === 'hray' ? a.x + 6 : 6, a.y - 10, color); break }
623 + case 'cross': { axisPill(d.points[0].price, color); timeTag(d.points[0].t, a.x); break }
624 + case 'vline': timeTag(d.points[0].t, a.x); break
625 + case 'vrange': {
626 + const i0 = c.store.indexOfTime(d.points[0].t), i1 = c.store.indexOfTime(d.points[1].t)
627 + tag(`${Math.abs(Math.round(i1 - i0))} bars · ${fmtDuration(d.points[1].t - d.points[0].t)}`, (a.x + b.x) / 2, 14, color, 'center')
628 + if (d.text) tag(d.text, (a.x + b.x) / 2, 30, color, 'center')
314 629 break
315 630 }
316 − case 'vline': {
317 − line(ctx, crisp(a.x), 0, crisp(a.x), H)
318 − const t = d.points[0].t
319 − pill(ctx, fmtFull(t, this.chart.opts.timeframe, this.chart.opts.sessionLabel), a.x + 6, 12, { bg: withAlpha(color, 0.9), color: inkFor(color), fontStr: font(theme, { mono: true, size: 10 }), h: 16, padX: 4, clampTo: { x0: 0, x1: W, y0: 0, y1: H } })
631 + case 'trendline': case 'ray': case 'extended': case 'arrow': case 'channel': case 'path': case 'rect': case 'ellipse': case 'triangle':
632 + if (d.type === 'arrow') arrowHead(ctx, a.x, a.y, b.x, b.y, 9 + ((st.width || 1) * 2))
633 + if (d.text) tag(d.text, (a.x + (b ? b.x : a.x)) / 2, Math.min(a.y, b ? b.y : a.y) - 10, color, 'center')
320 634 break
321 − }
322 − case 'rect': {
323 − const l = Math.min(a.x, b.x), t = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y)
324 − ctx.fillStyle = withAlpha(color, 0.12); ctx.fillRect(l, t, w, h)
325 − ctx.strokeRect(crisp(l), crisp(t), Math.round(w), Math.round(h))
635 + case 'fib': case 'fib-extension': {
636 + const levels = prims.filter(p => p.k === 'seg' && !p.guide)
637 + const sorted = levels.slice().sort((p, q) => p.y1 - q.y1)
638 + for (let i = 1; i < sorted.length; i++) { const col = theme.series[sorted[i].index % theme.series.length]; ctx.fillStyle = withAlpha(col, 0.06); ctx.fillRect(sorted[i].x1, sorted[i - 1].y1, sorted[i].x2 - sorted[i].x1, sorted[i].y1 - sorted[i - 1].y1) }
639 + if (!showLabels) break
640 + for (const pr of levels) {
641 + const col = theme.series[pr.index % theme.series.length]
642 + const price = pane.scale.priceAt(pr.y1)
643 + tag(`${fmtRatio(pr.level)} ${fmt(price)}`, Math.min(pr.x2 + 6, W - 120), pr.y1, col)
644 + }
326 645 break
327 646 }
328 − case 'channel': {
329 − line(ctx, a.x, a.y, b.x, b.y)
330 − if (c) {
331 − const ox = c.x - a.x, oy = c.y - a.y
332 − line(ctx, a.x + ox, a.y + oy, b.x + ox, b.y + oy)
333 − ctx.fillStyle = withAlpha(color, 0.10)
334 − ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(b.x + ox, b.y + oy); ctx.lineTo(a.x + ox, a.y + oy); ctx.closePath(); ctx.fill()
335 − ctx.setLineDash([3, 3]); ctx.globalAlpha *= 0.6
336 − line(ctx, a.x + ox / 2, a.y + oy / 2, b.x + ox / 2, b.y + oy / 2)
337 − ctx.setLineDash([]); ctx.globalAlpha = preview ? 0.85 : 1
647 + case 'fib-timezones': { if (showLabels) for (const pr of prims) if (pr.k === 'line') tag(String(pr.level), pr.x1 + 3, 10, theme.series[pr.index % theme.series.length]); break }
648 + case 'fib-fan': case 'gann-fan': {
649 + if (!showLabels) break
650 + for (const pr of prims) {
651 + if (pr.k !== 'ray') continue
652 + const [x1, y1, x2, y2] = extendLine(pr.x1, pr.y1, pr.x2, pr.y2, W, H, 'ray')
653 + const hit = borderPoint(x1, y1, x2, y2, W, H)
654 + if (!hit) continue
655 + const col = d.type === 'fib-fan' ? theme.series[pr.index % theme.series.length] : color
656 + tag(d.type === 'gann-fan' ? gannLabel(pr.level) : fmtRatio(pr.level), hit.x - 4, Math.max(8, Math.min(H - 8, hit.y)), col, 'right')
338 657 }
339 658 break
340 659 }
341 − case 'brush': {
342 − ctx.lineWidth = Math.max(1.5, lw)
343 − ctx.beginPath()
344 − pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)))
345 − ctx.stroke()
660 + case 'fib-arcs': { if (showLabels) for (const pr of prims) if (pr.k === 'arc') tag(fmtRatio(pr.level), pr.cx + Math.cos((pr.a0 + pr.a1) / 2) * pr.r, pr.cy + Math.sin((pr.a0 + pr.a1) / 2) * pr.r, theme.series[pr.index % theme.series.length], 'center'); break }
661 + case 'regression': {
662 + const rg = this._opts(d, pane).regression
663 + if (rg && showLabels) tag(`σ ${fmt(rg.sd)} · slope ${rg.slope >= 0 ? '+' : ''}${fmt(rg.slope)}/bar`, rg.x1 - 4, rg.up1 - 10, color, 'right')
346 664 break
347 665 }
348 − case 'fib': this._drawFib(ctx, g, d, pts, color); break
349 − case 'measure': this._drawMeasure(ctx, g, d, pts, theme); break
666 + case 'measure': case 'price-range': case 'date-range': case 'date-price-range': this._drawRangeBox(ctx, g, d, pts, pane, fmt); break
667 + case 'long': case 'short': this._drawPosition(ctx, g, d, pts, pane, fmt); break
350 668 case 'text': {
351 669 const text = d.text || 'Text'
352 − const f = font(theme, { size: 12, weight: 500 })
670 + const f = font(theme, { size: st.fontSize || 12, weight: 500 })
353 671 this._textW = measure(ctx, text, f) + 8
354 672 ctx.font = f
355 673 ctx.fillStyle = withAlpha(theme.bg, 0.75)
356 674 roundRect(ctx, a.x - 4, a.y - 10, this._textW, 20, 3); ctx.fill()
357 − ctx.fillStyle = color
358 − ctx.textBaseline = 'middle'; ctx.textAlign = 'left'
359 − ctx.fillText(text, a.x, a.y + 0.5)
675 + ctx.fillStyle = st.textColor || color
676 + ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'
677 + ctx.fillText(text, snap(a.x), textY(a.y, st.fontSize || 12))
360 678 break
361 679 }
362 − default: break
363 − }
364 − ctx.restore()
365 − if (selected && d.type !== 'brush') this._drawHandles(ctx, pts, theme, d.locked)
366 − else if (selected && d.type === 'brush') { ctx.save(); ctx.strokeStyle = theme.selection; ctx.lineWidth = 6; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.stroke(); ctx.restore() }
367 − }
368 −
369 − _drawHandles(ctx, pts, theme, locked = false) {
370 − for (const p of pts) {
371 − ctx.beginPath(); ctx.arc(p.x, p.y, HANDLE_R + 4, 0, Math.PI * 2)
372 − ctx.fillStyle = theme.selection; ctx.fill()
373 − ctx.beginPath(); ctx.arc(p.x, p.y, HANDLE_R / 2 + 0.5, 0, Math.PI * 2)
374 − ctx.fillStyle = locked ? theme.textMuted : theme.drawingHandle; ctx.fill()
375 − ctx.lineWidth = 1.5; ctx.strokeStyle = theme.drawing; ctx.stroke()
680 + case 'callout': {
681 + const text = d.text || 'Note'
682 + const f = font(theme, { size: st.fontSize || 12, weight: 500 })
683 + const lines = text.split('\n')
684 + const tw = Math.max(...lines.map(l => measure(ctx, l, f))) + 16
685 + const th = lines.length * 16 + 8
686 + this._textW = tw
687 + ctx.fillStyle = withAlpha(color, 0.92)
688 + roundRect(ctx, b.x - 6, b.y - th / 2, tw, th, 4); ctx.fill()
689 + ctx.beginPath(); ctx.arc(a.x, a.y, 3, 0, Math.PI * 2); ctx.fill()
690 + ctx.font = f; ctx.fillStyle = st.textColor || inkFor(color); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'
691 + lines.forEach((l, i) => ctx.fillText(l, snap(b.x + 2), textY(b.y - th / 2 + 12 + i * 16, st.fontSize || 12)))
692 + break
693 + }
694 + case 'price-label': {
695 + const text = d.text ? `${d.text} ${fmt(d.points[0].price)}` : fmt(d.points[0].price)
696 + const f = font(theme, { mono: true, size: 11, weight: 600 })
697 + const bx = pill(ctx, text, a.x + 10, a.y - 14, { bg: color, color: inkFor(color), fontStr: f, h: 20, padX: 6, clampTo: { x0: 0, x1: W, y0: 0, y1: H } })
698 + ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(bx.x + 2, bx.y + bx.h); ctx.lineTo(bx.x + 12, bx.y + bx.h); ctx.closePath(); ctx.fill()
699 + break
700 + }
701 + case 'flag': {
702 + ctx.lineWidth = 1.5; ctx.strokeStyle = color; ctx.fillStyle = color
703 + strokeLine(ctx, alignFor(1.5, a.x), a.y, alignFor(1.5, a.x), a.y - 22)
704 + ctx.beginPath(); ctx.moveTo(a.x, a.y - 22); ctx.lineTo(a.x + 14, a.y - 17); ctx.lineTo(a.x, a.y - 12); ctx.closePath(); ctx.fill()
705 + if (d.text) tag(d.text, a.x + 18, a.y - 17, color)
706 + break
707 + }
708 + case 'arrow-up': case 'arrow-down': {
709 + const up = d.type === 'arrow-up'
710 + const col = st.color || (up ? theme.up : theme.down)
711 + ctx.fillStyle = col
712 + const s = 7, k = up ? 1 : -1
713 + ctx.beginPath()
714 + ctx.moveTo(a.x, a.y); ctx.lineTo(a.x - s, a.y + k * s * 1.5); ctx.lineTo(a.x - s / 2, a.y + k * s * 1.5); ctx.lineTo(a.x - s / 2, a.y + k * s * 2.6)
715 + ctx.lineTo(a.x + s / 2, a.y + k * s * 2.6); ctx.lineTo(a.x + s / 2, a.y + k * s * 1.5); ctx.lineTo(a.x + s, a.y + k * s * 1.5)
716 + ctx.closePath(); ctx.fill()
717 + if (d.text) tag(d.text, a.x, a.y + k * (s * 2.6 + 10), col, 'center')
718 + break
719 + }
720 + case 'elliott-impulse': case 'elliott-correction': {
721 + const names = d.type === 'elliott-impulse' ? ['0', '1', '2', '3', '4', '5'] : ['0', 'A', 'B', 'C']
722 + pts.forEach((p, i) => { if (i < names.length) this._pointLabel(ctx, theme, pts, i, names[i], color) })
723 + break
724 + }
725 + case 'xabcd': {
726 + const names = ['X', 'A', 'B', 'C', 'D']
727 + pts.forEach((p, i) => { if (i < 5) this._pointLabel(ctx, theme, pts, i, names[i], color) })
728 + if (pts.length >= 5 && showLabels) {
729 + const P = d.points.map(p => p.price)
730 + const r = (i, j, k, l) => { const den = Math.abs(P[k] - P[l]); return den ? (Math.abs(P[i] - P[j]) / den).toFixed(3) : '—' }
731 + tag(r(1, 2, 0, 1), (pts[0].x + pts[2].x) / 2, (pts[0].y + pts[2].y) / 2, color, 'center') // AB / XA
732 + tag(r(2, 3, 1, 2), (pts[1].x + pts[3].x) / 2, (pts[1].y + pts[3].y) / 2, color, 'center') // BC / AB
733 + tag(r(3, 4, 2, 3), (pts[2].x + pts[4].x) / 2, (pts[2].y + pts[4].y) / 2, color, 'center') // CD / BC
734 + tag(`AD/XA ${r(0, 4, 0, 1)}`, (pts[0].x + pts[4].x) / 2, Math.max(pts[0].y, pts[4].y) + 14, color, 'center')
735 + }
736 + break
737 + }
738 + case 'head-shoulders': {
739 + const names = ['', 'LS', '', 'H', '', 'RS', '']
740 + pts.forEach((p, i) => { if (names[i]) this._pointLabel(ctx, theme, pts, i, names[i], color) })
741 + break
742 + }
743 + default: if (st.text) tag(st.text, a.x + 4, a.y - 10, color); break
376 744 }
377 745 }
378 746
379 − _drawFib(ctx, g, d, pts, color) {
380 − const { theme, width: W } = g
381 − const [a, b] = pts
382 − const p0 = d.points[0].price, p1 = d.points[1].price
383 − const l = Math.min(a.x, b.x), r = Math.max(a.x, b.x)
384 − const ps = this.chart.mainPane.scale
385 − const f = font(theme, { mono: true, size: 10 })
386 − ctx.font = f; ctx.textBaseline = 'middle'
387 − const levels = d.style && d.style.extended ? [...FIB_LEVELS, 1.618] : FIB_LEVELS
388 − let prevY = null
389 − levels.forEach((lv, i) => {
390 − const price = p0 + (p1 - p0) * lv
391 − const y = ps.y(price)
392 − const c = theme.series[i % theme.series.length]
393 − if (prevY != null) { ctx.fillStyle = withAlpha(c, 0.06); ctx.fillRect(l, Math.min(prevY, y), r - l, Math.abs(y - prevY)) }
394 − ctx.strokeStyle = withAlpha(c, 0.9); ctx.lineWidth = 1
395 − line(ctx, l, crisp(y), r, crisp(y))
396 − const label = `${lv.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')} ${this.chart._formatMain(price)}`
397 − const lx = Math.min(r + 6, W - 120)
398 − const lw2 = measure(ctx, label, f)
399 − ctx.fillStyle = withAlpha(theme.bg, 0.8)
400 − ctx.fillRect(lx - 3, y - 7, lw2 + 6, 14)
401 − ctx.fillStyle = c
402 − ctx.textAlign = 'left'
403 − ctx.fillText(label, lx, y)
404 − prevY = y
405 − })
406 − ctx.strokeStyle = withAlpha(color, 0.5); ctx.setLineDash([3, 3])
407 − line(ctx, a.x, a.y, b.x, b.y)
408 − ctx.setLineDash([])
747 + /** Small label next to a polyline vertex, placed on the outer side of the local turn. */
748 + _pointLabel(ctx, theme, pts, i, text, color) {
749 + const p = pts[i]
750 + const prev = pts[i - 1] || pts[i + 1], next = pts[i + 1] || pts[i - 1]
751 + const above = prev && next ? p.y <= (prev.y + next.y) / 2 : true
752 + const f = font(theme, { size: 11, weight: 700 })
753 + ctx.font = f; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'
754 + const w = measure(ctx, text, f) + 8
755 + const y = above ? p.y - 18 : p.y + 8
756 + ctx.fillStyle = withAlpha(theme.bg, 0.85); roundRect(ctx, p.x - w / 2, y, w, 14, 3); ctx.fill()
757 + ctx.fillStyle = color; ctx.fillText(text, p.x, textY(y + 7, 11))
758 + ctx.textAlign = 'left'
409 759 }
410 760
411 − _drawMeasure(ctx, g, d, pts, theme) {
412 − const { width: W, height: H } = g
761 + _drawRangeBox(ctx, g, d, pts, pane, fmt) {
762 + const { theme, width: W, height: H } = g
763 + const c = this.chart
413 764 const [a, b] = pts
765 + const withPrice = d.type !== 'date-range', withTime = d.type !== 'price-range'
414 766 const up = d.points[1].price >= d.points[0].price
415 − const color = up ? theme.up : theme.down
767 + const color = (d.style && d.style.color) || (withPrice ? (up ? theme.up : theme.down) : theme.drawing)
416 768 const l = Math.min(a.x, b.x), t = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y)
417 − ctx.fillStyle = withAlpha(color, 0.12); ctx.fillRect(l, t, w, h)
418 − ctx.strokeStyle = withAlpha(color, 0.8); ctx.lineWidth = 1; ctx.setLineDash([3, 3])
419 − ctx.strokeRect(crisp(l), crisp(t), Math.round(w), Math.round(h))
769 + ctx.fillStyle = withAlpha(color, d.style && d.style.fill != null ? d.style.fill : 0.12); ctx.fillRect(l, t, w, h)
770 + ctx.strokeStyle = withAlpha(color, 0.8); ctx.lineWidth = hair(); ctx.setLineDash([3, 3])
771 + ctx.strokeRect(crisp(l), crisp(t), snap(w), snap(h))
420 772 ctx.setLineDash([])
421 − // Arrow along the vertical move.
422 − const mx = l + w / 2
423 − ctx.strokeStyle = color; ctx.lineWidth = 1.25
424 − line(ctx, mx, a.y, mx, b.y); arrowHead(ctx, mx, a.y, mx, b.y, 8)
425 − const c = this.chart
773 + ctx.strokeStyle = color; ctx.lineWidth = 1.25; ctx.fillStyle = color
774 + const mx = l + w / 2, my = t + h / 2
775 + if (withPrice) { strokeLine(ctx, mx, a.y, mx, b.y); arrowHead(ctx, mx, a.y, mx, b.y, 8) }
776 + if (withTime) { strokeLine(ctx, a.x, my, b.x, my); arrowHead(ctx, a.x, my, b.x, my, 8) }
426 777 const dp = d.points[1].price - d.points[0].price
427 778 const pct = d.points[0].price ? (dp / d.points[0].price) * 100 : 0
428 779 const i0 = c.store.indexOfTime(d.points[0].t), i1 = c.store.indexOfTime(d.points[1].t)
429 780 const bars = Math.round(i1 - i0)
430 − const lines = [
431 − `${dp >= 0 ? '+' : '−'}${c._formatMain(Math.abs(dp))} (${formatPercent(pct, 2, c.opts.locale)})`,
432 − `${bars} bars · ${fmtDuration(d.points[1].t - d.points[0].t)}`,
433 − ]
781 + const lines = []
782 + if (withPrice) lines.push(`${dp >= 0 ? '+' : '−'}${fmt(Math.abs(dp))} (${formatPercent(pct, 2, c.opts.locale)})`)
783 + if (withTime) lines.push(`${bars} bars · ${fmtDuration(d.points[1].t - d.points[0].t)}`)
784 + if (d.text) lines.push(d.text)
434 785 const f = font(theme, { mono: true, size: 11, weight: 500 })
435 786 ctx.font = f
436 787 const bw = Math.max(...lines.map(s => measure(ctx, s, f))) + 16
437 788 const bh = 18 * lines.length + 6
438 − let bx = mx - bw / 2, by = up ? t - bh - 8 : t + h + 8
789 + let bx = mx - bw / 2, by = withPrice ? (up ? t - bh - 8 : t + h + 8) : t + h + 8
439 790 bx = Math.max(2, Math.min(W - bw - 2, bx)); by = Math.max(2, Math.min(H - bh - 2, by))
440 791 ctx.fillStyle = color
441 − roundRect(ctx, bx, by, bw, bh, 4); ctx.fill()
442 − ctx.fillStyle = inkFor(color); ctx.textBaseline = 'middle'; ctx.textAlign = 'center'
443 − lines.forEach((s, i) => ctx.fillText(s, bx + bw / 2, by + 3 + 9 + i * 18))
792 + roundRect(ctx, snap(bx), snap(by), bw, bh, 4); ctx.fill()
793 + ctx.fillStyle = inkFor(color); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'center'
794 + lines.forEach((s, i) => ctx.fillText(s, snap(bx + bw / 2), textY(by + 3 + 9 + i * 18, 11)))
444 795 ctx.textAlign = 'left'
445 796 }
797 +
798 + /** Long / short position: entry line, profit & loss zones, and stats (target, stop, R:R, P/L in % and points, amount with qty). */
799 + _drawPosition(ctx, g, d, pts, pane, fmt) {
800 + const { theme, width: W } = g
801 + const c = this.chart
802 + const [a, b, s] = pts
803 + const entry = d.points[0].price, target = d.points[1].price, stop = d.points[2].price
804 + const isLong = d.type === 'long'
805 + const reward = Math.abs(target - entry), risk = Math.abs(entry - stop)
806 + const rr = risk > 0 ? reward / risk : Infinity
807 + const qty = d.style && d.style.qty > 0 ? d.style.qty : null
808 + const x0 = Math.min(a.x, b.x), x1 = Math.max(a.x, b.x)
809 + const small = font(theme, { mono: true, size: 10, weight: 600 })
810 + const box = (text, y, col, alignTop) => {
811 + ctx.font = small
812 + const w = measure(ctx, text, small) + 10
813 + const bx = Math.max(2, Math.min(W - w - 2, (x0 + x1) / 2 - w / 2))
814 + const by = alignTop ? y - 20 : y + 4
815 + ctx.fillStyle = col; roundRect(ctx, snap(bx), snap(by), w, 16, 3); ctx.fill()
816 + ctx.fillStyle = inkFor(col); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; ctx.fillText(text, snap(bx + 5), textY(by + 8, 10))
817 + }
818 + const pctT = entry ? (reward / entry) * 100 : 0, pctS = entry ? (risk / entry) * 100 : 0
819 + const amt = v => (qty ? ` · ${fmt(v * qty)}` : '')
820 + box(`Target ${fmt(target)} ${isLong ? '+' : '−'}${fmt(reward)} (${formatPercent(isLong ? pctT : -pctT, 2, c.opts.locale)})${amt(reward)}`, b.y, theme.up, isLong)
821 + box(`Stop ${fmt(stop)} ${isLong ? '−' : '+'}${fmt(risk)} (${formatPercent(isLong ? -pctS : pctS, 2, c.opts.locale)})${amt(-risk)}`, s.y, theme.down, !isLong)
822 + const entryText = `${isLong ? 'Long' : 'Short'} ${fmt(entry)} · R:R ${Number.isFinite(rr) ? rr.toFixed(2) : '∞'}${qty ? ` · qty ${qty}` : ''}`
823 + ctx.font = small
824 + const w = measure(ctx, entryText, small) + 10
825 + const bx = Math.max(2, Math.min(W - w - 2, x0)), by = a.y - 8
826 + ctx.fillStyle = theme.crosshairLabelBg; roundRect(ctx, snap(bx), snap(by), w, 16, 3); ctx.fill()
827 + ctx.fillStyle = theme.crosshairLabelText; ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; ctx.fillText(entryText, snap(bx + 5), textY(by + 8, 10))
828 + }
446 829 }
447 830
448 −function line(ctx, x1, y1, x2, y2) { ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke() }
831 +function strokeLine(ctx, x1, y1, x2, y2) { ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke() }
449 832
450 833 function arrowHead(ctx, x1, y1, x2, y2, size) {
451 834 const ang = Math.atan2(y2 - y1, x2 - x1)
@@ -456,3 +839,15 @@ function arrowHead(ctx, x1, y1, x2, y2, size) {
456 839 ctx.closePath()
457 840 ctx.fill()
458 841 }
842 +
843 +/** Point where the segment (x1,y1)→(x2,y2) leaves the [0,W]×[0,H] box (or null when it never does). */
844 +function borderPoint(x1, y1, x2, y2, W, H) {
845 + const dx = x2 - x1, dy = y2 - y1
846 + let best = null
847 + const cand = (t, x, y) => { if (t > 1e-9 && (best === null || t < best.t) && x >= -1 && x <= W + 1 && y >= -1 && y <= H + 1) best = { t, x, y } }
848 + if (dx !== 0) for (const X of [0, W]) { const t = (X - x1) / dx; cand(t, X, y1 + dy * t) }
849 + if (dy !== 0) for (const Y of [0, H]) { const t = (Y - y1) / dy; cand(t, x1 + dx * t, Y) }
850 + return best
851 +}
852 +
853 +function gannLabel(k) { return k >= 1 ? `${k}/1` : `1/${Math.round(1 / k)}` }
modified hfmarketdata/web/src/charts/engine/drawings/model.js +73 −7
@@ -1,21 +1,65 @@
1 1 // Drawing model: tool definitions, JSON (de)serialization and validation. Coordinates are { t, price }.
2 +//
3 +// v2 additions (all additive): 30 new tools, `pane` (main | indicator pane id), `visible`, `timeframes`
4 +// (visibility per timeframe), richer `style` (fill alpha, text, fontSize, extendLeft / extendRight, showLabels,
5 +// qty for position tools), `props` (tool-specific free-form data such as custom Fib levels).
2 6
3 −export const TOOLS = ['trendline', 'ray', 'extended', 'hline', 'vline', 'rect', 'fib', 'measure', 'text', 'arrow', 'channel', 'brush']
7 +export const TOOLS_V1 = ['trendline', 'ray', 'extended', 'hline', 'vline', 'rect', 'fib', 'measure', 'text', 'arrow', 'channel', 'brush']
8 +export const TOOLS_V2 = [
9 + 'fib-extension', 'fib-timezones', 'fib-fan', 'fib-arcs', 'gann-fan', 'gann-box',
10 + 'pitchfork', 'schiff', 'mschiff', 'regression',
11 + 'long', 'short', 'price-range', 'date-range', 'date-price-range', 'vrange',
12 + 'hray', 'cross', 'path', 'ellipse', 'triangle',
13 + 'arrow-up', 'arrow-down', 'callout', 'price-label', 'flag',
14 + 'elliott-impulse', 'elliott-correction', 'xabcd', 'head-shoulders',
15 +]
16 +export const TOOLS = [...TOOLS_V1, ...TOOLS_V2]
4 17
5 −/** Number of anchor points each tool needs (brush = free-hand, ends on pointer up). */
6 −export const POINT_COUNT = { trendline: 2, ray: 2, extended: 2, hline: 1, vline: 1, rect: 2, fib: 2, measure: 2, text: 1, arrow: 2, channel: 3, brush: Infinity }
18 +/** Number of anchor points each tool stores (brush / path = free-form, ends on pointer up / double-click). */
19 +export const POINT_COUNT = {
20 + trendline: 2, ray: 2, extended: 2, hline: 1, vline: 1, rect: 2, fib: 2, measure: 2, text: 1, arrow: 2, channel: 3, brush: Infinity,
21 + 'fib-extension': 3, 'fib-timezones': 2, 'fib-fan': 2, 'fib-arcs': 2, 'gann-fan': 2, 'gann-box': 2,
22 + pitchfork: 3, schiff: 3, mschiff: 3, regression: 2,
23 + long: 3, short: 3, 'price-range': 2, 'date-range': 2, 'date-price-range': 2, vrange: 2,
24 + hray: 1, cross: 1, path: Infinity, ellipse: 2, triangle: 3,
25 + 'arrow-up': 1, 'arrow-down': 1, callout: 2, 'price-label': 1, flag: 1,
26 + 'elliott-impulse': 6, 'elliott-correction': 4, xabcd: 5, 'head-shoulders': 7,
27 +}
28 +
29 +/** Clicks needed to CREATE a drawing when it differs from POINT_COUNT (position tools spawn their stop/target). */
30 +export const CREATE_CLICKS = { long: 1, short: 1 }
31 +
32 +/** Free-form tools: 'drag' ends on pointer up (brush), 'click' adds a point per click and ends on double-click / Enter. */
33 +export const MULTI_MODE = { brush: 'drag', path: 'click' }
34 +
35 +/** Tools whose text is editable (the engine emits `textEdit`, the page provides the input). */
36 +export const TEXT_TOOLS = new Set(['text', 'callout', 'price-label', 'flag', 'rect', 'ellipse', 'triangle', 'vrange', 'hline', 'hray', 'trendline', 'ray', 'extended', 'arrow', 'channel', 'path'])
37 +
38 +/** Tools that can live on indicator panes (their `price` is the pane's value). */
39 +export const PANE_TOOLS = new Set(['hline', 'rect', 'trendline', 'ray', 'extended', 'vline', 'text', 'arrow', 'hray', 'cross', 'brush', 'path', 'ellipse', 'triangle', 'channel', 'measure', 'price-range', 'date-range', 'date-price-range'])
7 40
8 41 export const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]
9 42 export const FIB_EXTENDED = [...FIB_LEVELS, 1.618]
43 +export const FIB_EXTENSION_LEVELS = [0, 0.382, 0.618, 1, 1.382, 1.618, 2, 2.618]
44 +export const FIB_FAN_LEVELS = [0.236, 0.382, 0.5, 0.618, 0.786]
45 +export const FIB_ARC_LEVELS = [0.382, 0.5, 0.618, 1]
46 +export const FIB_TIME_SEQ = [0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377]
47 +export const GANN_RATIOS = [1 / 8, 1 / 4, 1 / 3, 1 / 2, 1, 2, 3, 4, 8]
48 +export const GANN_BOX_LEVELS = [0.25, 0.382, 0.5, 0.618, 0.75]
10 49
11 50 let seq = 0
12 51 export const newId = () => `d${Date.now().toString(36)}${(++seq).toString(36)}`
13 52
14 −export function newDrawing(type, points, { style, text, locked } = {}) {
53 +const STYLE_KEYS = ['color', 'width', 'dash', 'fill', 'text', 'fontSize', 'textColor', 'extendLeft', 'extendRight', 'showLabels', 'qty', 'levels']
54 +
55 +export function newDrawing(type, points, { style, text, locked, pane, visible, timeframes, props } = {}) {
15 56 return {
16 57 id: newId(), type, points: points.map(p => ({ t: p.t, price: p.price })),
17 58 style: { color: undefined, width: 1, dash: null, ...(style || {}) },
18 59 text: text || undefined, locked: !!locked,
60 + pane: pane && pane !== 'main' ? pane : undefined,
61 + visible: visible !== false, timeframes: Array.isArray(timeframes) && timeframes.length ? timeframes.slice() : undefined,
62 + props: props && typeof props === 'object' ? { ...props } : undefined,
19 63 }
20 64 }
21 65
@@ -28,25 +72,35 @@ export function validateDrawing(d) {
28 72 const points = d.points.filter(p => p && Number.isFinite(p.t) && Number.isFinite(p.price)).map(p => ({ t: p.t, price: p.price }))
29 73 if (need !== Infinity && points.length < need) return null
30 74 if (need === Infinity && points.length < 2) return null
31 − const style = { width: 1, dash: null, ...(d.style && typeof d.style === 'object' ? d.style : {}) }
75 + const style = { width: 1, dash: null }
76 + if (d.style && typeof d.style === 'object') for (const k of STYLE_KEYS) if (d.style[k] != null) style[k] = d.style[k]
32 77 if (!(style.width > 0)) style.width = 1
33 78 if (style.dash != null && !Array.isArray(style.dash)) style.dash = null
79 + if (style.fill != null) style.fill = Math.min(1, Math.max(0, Number(style.fill) || 0))
34 80 return {
35 81 id: typeof d.id === 'string' && d.id ? d.id : newId(), type: d.type,
36 82 points: need === Infinity ? points : points.slice(0, need),
37 83 style, text: typeof d.text === 'string' ? d.text : undefined, locked: !!d.locked,
84 + pane: typeof d.pane === 'string' && d.pane !== 'main' ? d.pane : undefined,
85 + visible: d.visible !== false,
86 + timeframes: Array.isArray(d.timeframes) && d.timeframes.length ? d.timeframes.filter(x => typeof x === 'string') : undefined,
87 + props: d.props && typeof d.props === 'object' ? { ...d.props } : undefined,
38 88 }
39 89 }
40 90
41 −/** Plain JSON copy of a list of drawings (strips undefined). */
91 +/** Plain JSON copy of a list of drawings (strips undefined / defaults). */
42 92 export function serialize(list) {
43 93 return list.map(d => {
44 94 const o = { id: d.id, type: d.type, points: d.points.map(p => ({ t: p.t, price: p.price })) }
45 95 const style = {}
46 − if (d.style) for (const k of ['color', 'width', 'dash']) if (d.style[k] != null) style[k] = d.style[k]
96 + if (d.style) for (const k of STYLE_KEYS) if (d.style[k] != null) style[k] = d.style[k]
47 97 if (Object.keys(style).length) o.style = style
48 98 if (d.text != null) o.text = d.text
49 99 if (d.locked) o.locked = true
100 + if (d.pane) o.pane = d.pane
101 + if (d.visible === false) o.visible = false
102 + if (d.timeframes && d.timeframes.length) o.timeframes = d.timeframes.slice()
103 + if (d.props && Object.keys(d.props).length) o.props = { ...d.props }
50 104 return o
51 105 })
52 106 }
@@ -55,3 +109,15 @@ export function deserialize(list) {
55 109 if (!Array.isArray(list)) return []
56 110 return list.map(validateDrawing).filter(Boolean)
57 111 }
112 +
113 +/** Human labels for the tool palette. */
114 +export const TOOL_LABELS = {
115 + trendline: 'Trend line', ray: 'Ray', extended: 'Extended line', hline: 'Horizontal line', vline: 'Vertical line', rect: 'Rectangle', fib: 'Fib retracement',
116 + measure: 'Measure', text: 'Text', arrow: 'Arrow', channel: 'Parallel channel', brush: 'Brush',
117 + 'fib-extension': 'Trend-based Fib extension', 'fib-timezones': 'Fib time zones', 'fib-fan': 'Fib speed/resistance fan', 'fib-arcs': 'Fib arcs',
118 + 'gann-fan': 'Gann fan', 'gann-box': 'Gann box', pitchfork: 'Andrews pitchfork', schiff: 'Schiff pitchfork', mschiff: 'Modified Schiff pitchfork',
119 + regression: 'Regression trend', long: 'Long position', short: 'Short position', 'price-range': 'Price range', 'date-range': 'Date range',
120 + 'date-price-range': 'Date & price range', vrange: 'Vertical range', hray: 'Horizontal ray', cross: 'Cross line', path: 'Path', ellipse: 'Ellipse',
121 + triangle: 'Triangle', 'arrow-up': 'Arrow up marker', 'arrow-down': 'Arrow down marker', callout: 'Callout', 'price-label': 'Price label', flag: 'Flag',
122 + 'elliott-impulse': 'Elliott impulse (12345)', 'elliott-correction': 'Elliott correction (ABC)', xabcd: 'XABCD pattern', 'head-shoulders': 'Head & shoulders',
123 +}
modified hfmarketdata/web/src/charts/engine/index.js +1 −1
@@ -3,7 +3,7 @@
3 3 import { Chart } from './core/chart.js'
4 4
5 5 export { darkTheme, lightTheme, normalizeTheme } from './theme.js'
6 −export { TOOLS as DRAWING_TOOLS } from './drawings/model.js'
6 +export { TOOLS as DRAWING_TOOLS, TOOLS_V1 as DRAWING_TOOLS_V1, TOOLS_V2 as DRAWING_TOOLS_V2, TOOL_LABELS as DRAWING_TOOL_LABELS, POINT_COUNT as DRAWING_POINT_COUNT } from './drawings/model.js'
7 7 export { INDICATOR_TYPES, REGISTRY as INDICATORS, computeIndicator, indicatorParams, listIndicators, CATEGORIES as INDICATOR_CATEGORIES, SOURCES as INDICATOR_SOURCES } from '../indicators/index.js'
8 8
9 9 export const SERIES_TYPES = ['candles', 'hollow', 'ohlc', 'line', 'area', 'baseline', 'heikin', 'columns', 'hlc']
modified hfmarketdata/web/src/charts/engine/interactions/pointer.js +13 −0
@@ -284,9 +284,22 @@ export function attachInteractions(chart) {
284 284 function onKeyDown(ev) {
285 285 const meta = ev.metaKey || ev.ctrlKey
286 286 const step = chart.plotWidth * 0.1
287 + const dr = chart.drawings
288 + // With a drawing selected, arrows move it by one bar / one tick (Shift = 10×).
289 + if (dr.selected.size && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight' || ev.key === 'ArrowUp' || ev.key === 'ArrowDown')) {
290 + const k = ev.shiftKey ? 10 : 1
291 + dr.nudge(ev.key === 'ArrowLeft' ? -k : ev.key === 'ArrowRight' ? k : 0, ev.key === 'ArrowUp' ? k : ev.key === 'ArrowDown' ? -k : 0)
292 + ev.preventDefault(); return
293 + }
287 294 switch (ev.key) {
288 295 case 'ArrowLeft': chart.ts.scrollPx(ev.shiftKey ? chart.ts.barSpacing : step); chart.invalidate('data'); break
289 296 case 'ArrowRight': chart.ts.scrollPx(-(ev.shiftKey ? chart.ts.barSpacing : step)); chart.invalidate('data'); break
297 + case 'Enter': if (!dr.finish()) return; break
298 + case 'c': case 'C': if (meta) { if (!dr.copy()) return } else return; break
299 + case 'v': case 'V': if (meta) dr.paste(); else return; break
300 + case 'd': case 'D': if (meta && dr.selectedId) dr.duplicate(dr.selectedId); else return; break
301 + case 'l': case 'L': if (meta && dr.selectedId) { const d = dr.getById(dr.selectedId); dr.setLocked(d.id, !d.locked) } else return; break
302 + case 'a': case 'A': if (meta) dr.select(dr.list.filter(d => dr.isVisible(d)).map(d => d.id)); else return; break
290 303 case '+': case '=': chart.zoom(1.25, chart.pointer ? chart.pointer.x : undefined); break
291 304 case '-': case '_': chart.zoom(0.8, chart.pointer ? chart.pointer.x : undefined); break
292 305 case 'Home': chart.setVisibleRange({ fromIndex: 0, toIndex: Math.max(1, Math.min(chart.store.length - 1, Math.round(chart.ts.visibleBars))) }, true); break
293 306