web: stub temporaire du moteur de charts (contrat createChart) pour développer la page /charts en parallèle
1 changed file +311 −0
added
hfmarketdata/web/src/charts/engine/index.js
+311 −0
@@ -0,0 +1,311 @@ | ||
| 1 | +// TEMPORARY STUB — replaced by feat/engine at merge | |
| 2 | +// | |
| 3 | +// Minimal Canvas 2D implementation of the chart engine contract (/tmp/hfmd-charts-contract.md) so that the | |
| 4 | +// /charts page can be built and tested in parallel with the real engine. It draws candles (or a close line), a | |
| 5 | +// volume histogram, a right price axis and a bottom time axis, emits `visibleRangeChange` / `crosshairMove` / | |
| 6 | +// `toolChange` / `drawingsChange`, supports wheel zoom + drag pan, and stores indicators / compares / drawings | |
| 7 | +// without rendering them. Nothing here is meant to survive the merge. | |
| 8 | + | |
| 9 | +const TF_STEP = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000, '1day': 86_400_000 } | |
| 10 | +const AXIS_W = 64 | |
| 11 | +const AXIS_H = 24 | |
| 12 | +const pad2 = n => String(n).padStart(2, '0') | |
| 13 | + | |
| 14 | +function fmtTime(t, tf) { | |
| 15 | + const d = new Date(t) | |
| 16 | + const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}` | |
| 17 | + return tf === '1day' ? date : `${date} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}` | |
| 18 | +} | |
| 19 | + | |
| 20 | +function niceStep(range, target) { | |
| 21 | + const raw = range / Math.max(1, target) | |
| 22 | + const p = Math.pow(10, Math.floor(Math.log10(raw))) | |
| 23 | + for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p | |
| 24 | + return 10 * p | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function createChart(container, options = {}) { | |
| 28 | + const opts = { rightOffsetBars: 8, barSpacing: 8, minBarSpacing: 0.5, timeframe: '1day', locale: 'en-US', ...options } | |
| 29 | + let theme = opts.theme || {} | |
| 30 | + let bars = [] | |
| 31 | + let seriesType = 'candles' | |
| 32 | + let volumeVisible = true | |
| 33 | + let scaleMode = 'linear' | |
| 34 | + let crosshairMode = 'normal' | |
| 35 | + let tool = null | |
| 36 | + let fromIndex = 0, toIndex = 0 // visible index range (fractional allowed) | |
| 37 | + const listeners = new Map() | |
| 38 | + const indicators = new Map() | |
| 39 | + const compares = new Map() | |
| 40 | + let drawings = [] | |
| 41 | + const undoStack = [], redoStack = [] | |
| 42 | + let destroyed = false | |
| 43 | + let raf = 0 | |
| 44 | + let hover = null | |
| 45 | + | |
| 46 | + container.style.position = container.style.position || 'relative' | |
| 47 | + const main = document.createElement('canvas') | |
| 48 | + const overlay = document.createElement('canvas') | |
| 49 | + for (const c of [main, overlay]) { c.style.position = 'absolute'; c.style.inset = '0'; c.style.width = '100%'; c.style.height = '100%'; container.appendChild(c) } | |
| 50 | + overlay.style.touchAction = 'none' | |
| 51 | + overlay.setAttribute('data-hfmd-chart-canvas', '') | |
| 52 | + const ctx = main.getContext('2d') | |
| 53 | + const octx = overlay.getContext('2d') | |
| 54 | + | |
| 55 | + const emit = (ev, payload) => { for (const fn of listeners.get(ev) || []) fn(payload) } | |
| 56 | + const w = () => container.clientWidth || 300 | |
| 57 | + const h = () => container.clientHeight || 200 | |
| 58 | + const plotW = () => Math.max(10, w() - AXIS_W) | |
| 59 | + const plotH = () => Math.max(10, h() - AXIS_H) | |
| 60 | + const barSpacing = () => plotW() / Math.max(1, toIndex - fromIndex) | |
| 61 | + const xOf = i => (i - fromIndex + 0.5) * barSpacing() | |
| 62 | + | |
| 63 | + function visibleSlice() { | |
| 64 | + const a = Math.max(0, Math.floor(fromIndex)), b = Math.min(bars.length - 1, Math.ceil(toIndex)) | |
| 65 | + return [a, b] | |
| 66 | + } | |
| 67 | + function priceRange() { | |
| 68 | + const [a, b] = visibleSlice() | |
| 69 | + let lo = Infinity, hi = -Infinity | |
| 70 | + for (let i = a; i <= b; i++) { const bar = bars[i]; if (!bar) continue; if (bar.l < lo) lo = bar.l; if (bar.h > hi) hi = bar.h } | |
| 71 | + if (!isFinite(lo)) { lo = 0; hi = 1 } | |
| 72 | + if (hi === lo) { hi += 1; lo -= 1 } | |
| 73 | + const padY = (hi - lo) * 0.08 | |
| 74 | + return [lo - padY, hi + padY] | |
| 75 | + } | |
| 76 | + const yOf = (p, lo, hi) => { | |
| 77 | + const ph = plotH() * (volumeVisible ? 0.8 : 1) | |
| 78 | + if (scaleMode === 'log' && lo > 0) return ph - ((Math.log(p) - Math.log(lo)) / (Math.log(hi) - Math.log(lo))) * ph | |
| 79 | + return ph - ((p - lo) / (hi - lo)) * ph | |
| 80 | + } | |
| 81 | + | |
| 82 | + function emitRange() { | |
| 83 | + const barsLeft = Math.max(0, Math.floor(fromIndex)) | |
| 84 | + emit('visibleRangeChange', { fromIndex: Math.floor(fromIndex), toIndex: Math.ceil(toIndex), barsLeftOfViewport: barsLeft, needMoreLeft: bars.length > 0 && barsLeft < 200 }) | |
| 85 | + } | |
| 86 | + | |
| 87 | + function schedule() { if (destroyed || raf) return; raf = requestAnimationFrame(() => { raf = 0; draw() }) } | |
| 88 | + | |
| 89 | + function draw() { | |
| 90 | + const dpr = window.devicePixelRatio || 1 | |
| 91 | + const W = w(), H = h() | |
| 92 | + for (const c of [main, overlay]) { if (c.width !== Math.round(W * dpr) || c.height !== Math.round(H * dpr)) { c.width = Math.round(W * dpr); c.height = Math.round(H * dpr) } } | |
| 93 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) | |
| 94 | + ctx.fillStyle = theme.bg || '#0a0c10' | |
| 95 | + ctx.fillRect(0, 0, W, H) | |
| 96 | + if (!bars.length) { drawOverlay(); return } | |
| 97 | + const [lo, hi] = priceRange() | |
| 98 | + const pw = plotW(), ph = plotH() | |
| 99 | + // grid + price axis | |
| 100 | + ctx.font = `11px ${theme.mono || 'monospace'}` | |
| 101 | + ctx.textBaseline = 'middle' | |
| 102 | + const step = niceStep(hi - lo, Math.max(3, Math.floor(ph / 60))) | |
| 103 | + ctx.strokeStyle = theme.grid || '#232833' | |
| 104 | + ctx.fillStyle = theme.axisText || '#8f98a8' | |
| 105 | + ctx.lineWidth = 1 | |
| 106 | + for (let p = Math.ceil(lo / step) * step; p <= hi; p += step) { | |
| 107 | + const y = Math.round(yOf(p, lo, hi)) + 0.5 | |
| 108 | + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke() | |
| 109 | + ctx.fillText(p.toLocaleString(opts.locale, { maximumFractionDigits: 2 }), pw + 6, y) | |
| 110 | + } | |
| 111 | + // time axis labels | |
| 112 | + const [a, b] = visibleSlice() | |
| 113 | + const every = Math.max(1, Math.ceil(90 / barSpacing())) | |
| 114 | + ctx.textAlign = 'center' | |
| 115 | + for (let i = a; i <= b; i += every) { | |
| 116 | + const x = xOf(i) | |
| 117 | + ctx.fillText(fmtTime(bars[i].t, opts.timeframe).slice(opts.timeframe === '1day' ? 0 : 5), x, ph + AXIS_H / 2) | |
| 118 | + } | |
| 119 | + ctx.textAlign = 'left' | |
| 120 | + ctx.strokeStyle = theme.axisLine || '#313847' | |
| 121 | + ctx.beginPath(); ctx.moveTo(pw + 0.5, 0); ctx.lineTo(pw + 0.5, ph); ctx.moveTo(0, ph + 0.5); ctx.lineTo(pw, ph + 0.5); ctx.stroke() | |
| 122 | + // volume | |
| 123 | + if (volumeVisible) { | |
| 124 | + let vmax = 0 | |
| 125 | + for (let i = a; i <= b; i++) vmax = Math.max(vmax, bars[i].v || 0) | |
| 126 | + const vh = ph * 0.2 | |
| 127 | + for (let i = a; i <= b; i++) { | |
| 128 | + const bar = bars[i] | |
| 129 | + const vhh = vmax ? ((bar.v || 0) / vmax) * vh : 0 | |
| 130 | + ctx.fillStyle = bar.c >= bar.o ? (theme.volumeUp || 'rgba(47,191,113,.35)') : (theme.volumeDown || 'rgba(229,72,77,.35)') | |
| 131 | + ctx.fillRect(xOf(i) - Math.max(0.5, barSpacing() * 0.35), ph - vhh, Math.max(1, barSpacing() * 0.7), vhh) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + // series | |
| 135 | + const lineLike = seriesType === 'line' || seriesType === 'area' || seriesType === 'baseline' | |
| 136 | + if (lineLike || barSpacing() < 2) { | |
| 137 | + ctx.strokeStyle = theme.accent || '#34d399' | |
| 138 | + ctx.lineWidth = 1.5 | |
| 139 | + ctx.beginPath() | |
| 140 | + for (let i = a; i <= b; i++) { const y = yOf(bars[i].c, lo, hi); i === a ? ctx.moveTo(xOf(i), y) : ctx.lineTo(xOf(i), y) } | |
| 141 | + ctx.stroke() | |
| 142 | + } else { | |
| 143 | + const bw = Math.max(1, Math.floor(barSpacing() * 0.7)) | |
| 144 | + for (let i = a; i <= b; i++) { | |
| 145 | + const bar = bars[i] | |
| 146 | + const up = bar.c >= bar.o | |
| 147 | + const color = up ? (theme.up || '#2fbf71') : (theme.down || '#e5484d') | |
| 148 | + const x = Math.round(xOf(i)) + 0.5 | |
| 149 | + ctx.strokeStyle = color; ctx.fillStyle = color | |
| 150 | + ctx.beginPath(); ctx.moveTo(x, yOf(bar.h, lo, hi)); ctx.lineTo(x, yOf(bar.l, lo, hi)); ctx.stroke() | |
| 151 | + const y1 = yOf(Math.max(bar.o, bar.c), lo, hi), y2 = yOf(Math.min(bar.o, bar.c), lo, hi) | |
| 152 | + if (seriesType === 'hollow' && up) ctx.strokeRect(x - bw / 2, y1, bw, Math.max(1, y2 - y1)) | |
| 153 | + else ctx.fillRect(x - bw / 2, y1, bw, Math.max(1, y2 - y1)) | |
| 154 | + } | |
| 155 | + } | |
| 156 | + // last price | |
| 157 | + const last = bars[bars.length - 1] | |
| 158 | + const ly = Math.round(yOf(last.c, lo, hi)) + 0.5 | |
| 159 | + ctx.setLineDash([3, 3]); ctx.strokeStyle = last.c >= last.o ? (theme.lastPriceUp || theme.up) : (theme.lastPriceDown || theme.down) | |
| 160 | + ctx.beginPath(); ctx.moveTo(0, ly); ctx.lineTo(pw, ly); ctx.stroke(); ctx.setLineDash([]) | |
| 161 | + ctx.fillStyle = ctx.strokeStyle; ctx.fillRect(pw + 1, ly - 8, AXIS_W - 2, 16) | |
| 162 | + ctx.fillStyle = theme.crosshairLabelText || '#fff'; ctx.fillText(last.c.toFixed(2), pw + 6, ly) | |
| 163 | + if (opts.watermark) { ctx.fillStyle = theme.textMuted || '#5a6373'; ctx.globalAlpha = 0.35; ctx.font = `600 22px ${theme.font || 'sans-serif'}`; ctx.textAlign = 'center'; ctx.fillText(opts.watermark, pw / 2, ph / 2); ctx.globalAlpha = 1; ctx.textAlign = 'left' } | |
| 164 | + drawOverlay() | |
| 165 | + } | |
| 166 | + | |
| 167 | + function drawOverlay() { | |
| 168 | + const dpr = window.devicePixelRatio || 1 | |
| 169 | + octx.setTransform(dpr, 0, 0, dpr, 0, 0) | |
| 170 | + octx.clearRect(0, 0, w(), h()) | |
| 171 | + if (!hover || crosshairMode === 'hidden') return | |
| 172 | + octx.strokeStyle = theme.crosshair || '#8f98a8' | |
| 173 | + octx.setLineDash([2, 3]) | |
| 174 | + octx.beginPath(); octx.moveTo(hover.x + 0.5, 0); octx.lineTo(hover.x + 0.5, plotH()); octx.moveTo(0, hover.y + 0.5); octx.lineTo(plotW(), hover.y + 0.5); octx.stroke() | |
| 175 | + octx.setLineDash([]) | |
| 176 | + } | |
| 177 | + | |
| 178 | + function indexAt(x) { return Math.floor(fromIndex + x / barSpacing()) } | |
| 179 | + function priceAt(y) { const [lo, hi] = priceRange(); const ph = plotH() * (volumeVisible ? 0.8 : 1); return hi - (y / ph) * (hi - lo) } | |
| 180 | + | |
| 181 | + // ---- interaction ------------------------------------------------------------------------------ | |
| 182 | + let drag = null | |
| 183 | + const onDown = e => { overlay.setPointerCapture?.(e.pointerId); drag = { x: e.offsetX, from: fromIndex, to: toIndex } } | |
| 184 | + const onMove = e => { | |
| 185 | + if (drag) { | |
| 186 | + const di = (drag.x - e.offsetX) / barSpacing() | |
| 187 | + const span = drag.to - drag.from | |
| 188 | + fromIndex = Math.max(-span * 0.5, Math.min(bars.length - 1, drag.from + di)) | |
| 189 | + toIndex = fromIndex + span | |
| 190 | + emitRange(); schedule() | |
| 191 | + } | |
| 192 | + hover = { x: e.offsetX, y: e.offsetY } | |
| 193 | + const i = indexAt(e.offsetX) | |
| 194 | + const bar = bars[i] | |
| 195 | + emit('crosshairMove', bar ? { index: i, bar, x: e.offsetX, y: e.offsetY, price: priceAt(e.offsetY), pane: 'main', indicators: {}, compares: {} } : null) | |
| 196 | + schedule() | |
| 197 | + } | |
| 198 | + const onUp = e => { | |
| 199 | + if (drag && Math.abs(drag.x - e.offsetX) < 3) { | |
| 200 | + const i = indexAt(e.offsetX) | |
| 201 | + if (bars[i]) { | |
| 202 | + const info = { index: i, bar: bars[i], price: priceAt(e.offsetY), pane: 'main' } | |
| 203 | + emit('click', info) | |
| 204 | + if (tool) { pushDrawing({ id: `d${Date.now().toString(36)}`, type: tool, points: [{ t: bars[i].t, price: info.price }], style: { color: theme.drawing } }) } | |
| 205 | + } | |
| 206 | + } | |
| 207 | + drag = null | |
| 208 | + } | |
| 209 | + const onLeave = () => { hover = null; emit('crosshairMove', null); schedule() } | |
| 210 | + const onWheel = e => { e.preventDefault(); zoom(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.offsetX) } | |
| 211 | + const onDblClick = () => fitContent() | |
| 212 | + overlay.addEventListener('pointerdown', onDown) | |
| 213 | + overlay.addEventListener('pointermove', onMove) | |
| 214 | + overlay.addEventListener('pointerup', onUp) | |
| 215 | + overlay.addEventListener('pointerleave', onLeave) | |
| 216 | + overlay.addEventListener('wheel', onWheel, { passive: false }) | |
| 217 | + overlay.addEventListener('dblclick', onDblClick) | |
| 218 | + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => schedule()) : null | |
| 219 | + ro?.observe(container) | |
| 220 | + | |
| 221 | + function pushDrawing(d) { undoStack.push(drawings); redoStack.length = 0; drawings = [...drawings, d]; emit('drawingsChange', drawings) } | |
| 222 | + | |
| 223 | + function fitContent() { | |
| 224 | + const n = bars.length | |
| 225 | + const show = Math.min(n, 150) | |
| 226 | + fromIndex = n - show | |
| 227 | + toIndex = n + (opts.rightOffsetBars || 8) | |
| 228 | + emitRange(); schedule() | |
| 229 | + } | |
| 230 | + function zoom(factor, anchorX) { | |
| 231 | + const span = toIndex - fromIndex | |
| 232 | + const newSpan = Math.max(10, Math.min(bars.length * 2 + 20, span / factor)) | |
| 233 | + const ax = anchorX == null ? plotW() / 2 : anchorX | |
| 234 | + const anchorIdx = fromIndex + (ax / plotW()) * span | |
| 235 | + const ratio = (anchorIdx - fromIndex) / span | |
| 236 | + fromIndex = anchorIdx - ratio * newSpan | |
| 237 | + toIndex = fromIndex + newSpan | |
| 238 | + emitRange(); schedule() | |
| 239 | + } | |
| 240 | + | |
| 241 | + const chart = { | |
| 242 | + setData(list) { | |
| 243 | + const first = bars.length === 0 | |
| 244 | + bars = list.slice() | |
| 245 | + if (first || !bars.length) fitContent(); else { emitRange(); schedule() } | |
| 246 | + }, | |
| 247 | + prependData(older) { | |
| 248 | + if (!older.length) return | |
| 249 | + const shift = older.length | |
| 250 | + bars = older.concat(bars) | |
| 251 | + fromIndex += shift; toIndex += shift // viewport stays visually fixed | |
| 252 | + emitRange(); schedule() | |
| 253 | + }, | |
| 254 | + appendData(newer) { const stick = toIndex >= bars.length; bars = bars.concat(newer); if (stick) { const span = toIndex - fromIndex; toIndex = bars.length + (opts.rightOffsetBars || 8); fromIndex = toIndex - span } emitRange(); schedule() }, | |
| 255 | + updateLast(bar) { if (bars.length && bars[bars.length - 1].t === bar.t) bars[bars.length - 1] = bar; else bars.push(bar); schedule() }, | |
| 256 | + getData() { return bars }, | |
| 257 | + setTimeframe(tf) { opts.timeframe = tf; schedule() }, | |
| 258 | + setSeriesType(t) { seriesType = t; schedule() }, | |
| 259 | + setPriceScale(o) { if (o.mode) scaleMode = o.mode; emit('priceScaleChange', { mode: scaleMode, auto: o.auto !== false }); schedule() }, | |
| 260 | + setVolume(v) { volumeVisible = !!v; schedule() }, | |
| 261 | + setTheme(t) { theme = t; schedule() }, | |
| 262 | + setCrosshair(o) { if (o.mode) crosshairMode = o.mode; schedule() }, | |
| 263 | + setOptions(partial) { Object.assign(opts, partial); if (partial.theme) theme = partial.theme; schedule() }, | |
| 264 | + addIndicator(spec) { | |
| 265 | + const id = spec.id || `${spec.type}-${Math.random().toString(36).slice(2, 8)}` | |
| 266 | + indicators.set(id, { id, type: spec.type, params: { ...(spec.params || {}) }, pane: spec.pane || 'main', colors: spec.colors || [], values: {} }) | |
| 267 | + return id | |
| 268 | + }, | |
| 269 | + updateIndicator(id, params) { const ind = indicators.get(id); if (ind) ind.params = { ...ind.params, ...params } }, | |
| 270 | + removeIndicator(id) { indicators.delete(id) }, | |
| 271 | + getIndicators() { return [...indicators.values()] }, | |
| 272 | + addCompare(id, label, list, color) { compares.set(id, { id, label, bars: list, color }); scaleMode = 'percent'; schedule() }, | |
| 273 | + removeCompare(id) { compares.delete(id); if (!compares.size && scaleMode === 'percent') scaleMode = 'linear'; schedule() }, | |
| 274 | + setDrawingTool(t) { tool = t; emit('toolChange', t) }, | |
| 275 | + getDrawings() { return drawings }, | |
| 276 | + setDrawings(list) { drawings = (list || []).slice(); schedule() }, | |
| 277 | + clearDrawings() { if (drawings.length) { undoStack.push(drawings); drawings = []; emit('drawingsChange', drawings) } }, | |
| 278 | + deleteSelectedDrawing() { if (drawings.length) { undoStack.push(drawings); drawings = drawings.slice(0, -1); emit('drawingsChange', drawings) } }, | |
| 279 | + undo() { if (undoStack.length) { redoStack.push(drawings); drawings = undoStack.pop(); emit('drawingsChange', drawings) } }, | |
| 280 | + redo() { if (redoStack.length) { undoStack.push(drawings); drawings = redoStack.pop(); emit('drawingsChange', drawings) } }, | |
| 281 | + setVisibleRange(r) { | |
| 282 | + if ('fromIndex' in r) { fromIndex = r.fromIndex; toIndex = r.toIndex } | |
| 283 | + else { const f = bars.findIndex(b => b.t >= r.fromT); let t = bars.findIndex(b => b.t > r.toT); if (t < 0) t = bars.length; fromIndex = Math.max(0, f); toIndex = t } | |
| 284 | + emitRange(); schedule() | |
| 285 | + }, | |
| 286 | + getVisibleRange() { const [a, b] = visibleSlice(); return { fromIndex: a, toIndex: b, fromT: bars[a]?.t ?? null, toT: bars[b]?.t ?? null } }, | |
| 287 | + fitContent, | |
| 288 | + scrollToLatest() { const span = toIndex - fromIndex; toIndex = bars.length + (opts.rightOffsetBars || 8); fromIndex = toIndex - span; emitRange(); schedule() }, | |
| 289 | + zoom, | |
| 290 | + resetView: fitContent, | |
| 291 | + resize() { schedule() }, | |
| 292 | + toPNG() { return new Promise(resolve => { draw(); main.toBlob(b => resolve(b), 'image/png') }) }, | |
| 293 | + destroy() { | |
| 294 | + destroyed = true | |
| 295 | + if (raf) cancelAnimationFrame(raf) | |
| 296 | + ro?.disconnect() | |
| 297 | + overlay.removeEventListener('pointerdown', onDown); overlay.removeEventListener('pointermove', onMove); overlay.removeEventListener('pointerup', onUp) | |
| 298 | + overlay.removeEventListener('pointerleave', onLeave); overlay.removeEventListener('wheel', onWheel); overlay.removeEventListener('dblclick', onDblClick) | |
| 299 | + main.remove(); overlay.remove(); listeners.clear() | |
| 300 | + }, | |
| 301 | + on(ev, fn) { | |
| 302 | + if (!listeners.has(ev)) listeners.set(ev, new Set()) | |
| 303 | + listeners.get(ev).add(fn) | |
| 304 | + return () => listeners.get(ev)?.delete(fn) | |
| 305 | + }, | |
| 306 | + } | |
| 307 | + schedule() | |
| 308 | + return chart | |
| 309 | +} | |
| 310 | + | |
| 311 | +export default createChart | |
| 312 | ||