/* ============================================================================ * HFChart — application : catalogue de charts HF, sélection, personnalisation * Données : API HF Market Data (https://www.hfmarketdata.io) * Author : Simon-Pierre Boucher — contact@spboucher.ai * ==========================================================================*/ (function () { 'use strict'; const HF = window.HF; const $ = s => document.querySelector(s); const $$ = s => Array.from(document.querySelectorAll(s)); /* ------------------------------------------------------------ catalogue */ const CHART_TYPES = [ { id: 'candles', label: 'Chandelles', desc: 'OHLC classique, corps plein', draw: 'candles', vis: 220 }, { id: 'hollow', label: 'Chandelles creuses', desc: 'Hausse creuse / baisse pleine — lisible sans couleur', draw: 'hollow', vis: 220 }, { id: 'ohlc', label: 'Barres OHLC', desc: 'Barres à ticks open/close', draw: 'ohlc', vis: 260 }, { id: 'line', label: 'Ligne', desc: 'Close en trait 2px', draw: 'line', vis: 600 }, { id: 'step', label: 'Escalier', desc: 'Close en marches', draw: 'step', vis: 400 }, { id: 'area', label: 'Aire', desc: 'Ligne + dégradé vers la base', draw: 'area', vis: 600 }, { id: 'baseline', label: 'Baseline', desc: 'Au-dessus / en-dessous de l’ancre', draw: 'baseline', vis: 600 }, { id: 'heikin', label: 'Heikin-Ashi', desc: 'Chandelles lissées de tendance', draw: 'candles', transform: 'heikinAshi', vis: 220 }, { id: 'renko', label: 'Renko', desc: 'Briques à taille fixe (auto-ATR)', draw: 'bricks', transform: 'renko', vis: 160 }, { id: 'linebreak', label: 'Three-Line Break', desc: 'Nouvelle barre sur cassure des 3 dernières', draw: 'bricks', transform: 'lineBreak', vis: 160 }, { id: 'rangebars', label: 'Range Bars', desc: 'Chaque barre couvre un range fixe', draw: 'candles', transform: 'rangeBars', vis: 200 }, { id: 'kagi', label: 'Kagi', desc: 'Yang épais / yin fin, renversement fixe', draw: 'kagi', transform: 'kagi', vis: 140, noVol: true }, { id: 'pnf', label: 'Point & Figure', desc: 'Colonnes de X et O', draw: 'pnf', transform: 'pnf', vis: 90, noVol: true }, { id: 'compare', label: 'Comparaison %', desc: 'Jusqu’à 4 symboles, base commune → variation %', draw: 'line', compare: true, vis: 600, noVol: true }, ]; const OVERLAYS = [ { id: 'sma20', label: 'SMA 20', slot: 3, make: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma20 || 20) }) }, { id: 'sma50', label: 'SMA 50', slot: 1, make: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma50 || 50) }) }, { id: 'ema20', label: 'EMA 20', slot: 2, make: (bars, p) => ({ vals: HF.ind.ema(HF.ind.closes(bars), p.ema20 || 20) }) }, { id: 'boll', label: 'Bollinger 20·2', slot: 6, make: (bars, p) => { const b = HF.ind.bollinger(bars, p.bollN || 20, p.bollK || 2); return { band: b }; } }, { id: 'vwap', label: 'VWAP session ±2σ', slot: 4, needVol: true, intraday: true, make: bars => { const v = HF.ind.vwapSession(bars); return { band: { up: v.up2, lo: v.dn2, mid: v.v } }; } }, ]; const PANES = [ { id: 'rsi', label: 'RSI', kind: 'rsi', make: (bars, p) => HF.ind.rsi(bars, p.rsiN || 14) }, { id: 'macd', label: 'MACD', kind: 'macd', make: (bars, p) => HF.ind.macd(bars, p.macdF || 12, p.macdS || 26, p.macdSig || 9) }, { id: 'atr', label: 'ATR', kind: 'atr', make: (bars, p) => HF.ind.atr(bars, p.atrN || 14) }, ]; const TFS = [ { id: '1', label: '1m' }, { id: '5', label: '5m' }, { id: '15', label: '15m' }, { id: '30', label: '30m' }, { id: '60', label: '1h' }, { id: '240', label: '4h' }, { id: 'D', label: '1J' }, { id: 'W', label: '1S' }, ]; const TF_WANT = { 1: 5000, 5: 5000, 15: 5000, 30: 5000, 60: 5000, 240: 3000, D: 12000, W: 12000 }; const DEFAULT_SYMBOL = { index: 'SPX', etf: 'SPY', stock: 'AAPL', futures: 'ES', crypto: 'BTC', fx: 'EURUSD' }; /* ------------------------------------------------------------ état */ const state = Object.assign({ group: 'index', symbol: 'SPX', tf: '60', type: 'candles', theme: 'light', scheme: 'classic', log: false, inds: [], compare: [], params: {}, }, load(), urlState()); state.params = state.params || {}; /* Surcharges via l'URL (partage de vues) : ?symbol=SPY&group=etf&tf=60&type=renko * &inds=rsi,vwap&compare=QQQ,IWM&theme=dark&scheme=cvd&log=1 */ function urlState() { const q = new URLSearchParams(location.search); const out = {}; for (const k of ['group', 'symbol', 'tf', 'type', 'theme', 'scheme']) { if (q.get(k)) out[k] = q.get(k); } if (q.get('inds') != null) out.inds = q.get('inds').split(',').filter(Boolean); if (q.get('compare') != null) out.compare = q.get('compare').split(',').filter(Boolean); if (q.get('log') != null) out.log = q.get('log') === '1'; return out; } function load() { try { return JSON.parse(localStorage.getItem('hfchart-state') || '{}'); } catch (e) { return {}; } } function save() { localStorage.setItem('hfchart-state', JSON.stringify({ group: state.group, symbol: state.symbol, tf: state.tf, type: state.type, theme: state.theme, scheme: state.scheme, log: state.log, inds: state.inds, compare: state.compare, params: state.params, })); } let chart = null; let raw = null; // {parsed, hasVolume, decimals, srcLimitReached} let compareRaw = {}; // symbole → réponse let tfAvail = {}; // tf UI → dispo pour le groupe courant let loading = false; let curWant = 0; function toast(msg, isErr) { const el = $('#toast'); el.textContent = msg; el.className = 'toast show' + (isErr ? ' err' : ''); clearTimeout(toast._t); toast._t = setTimeout(() => { el.className = 'toast'; }, 4000); } /* ------------------------------------------------------------ spec */ function typeDef() { return CHART_TYPES.find(t => t.id === state.type) || CHART_TYPES[0]; } function buildSpec(keepView) { if (!raw) return; const def = typeDef(); const bars = raw.parsed; let items = bars; const p = state.params; if (def.transform) { const opts = {}; if (def.id === 'renko' && p.brick > 0) opts.brick = p.brick; if (def.id === 'rangebars' && p.range > 0) opts.range = p.range; if (def.id === 'kagi' && p.reversal > 0) opts.reversal = p.reversal; if (def.id === 'pnf') { if (p.box > 0) opts.box = p.box; if (p.pnfRev > 0) opts.reversal = p.pnfRev; } if (def.id === 'linebreak' && p.lines > 0) opts.lines = p.lines; items = HF.transforms[def.transform](bars, opts); if (!items.length) { toast('Pas assez de données pour ce type sur ce timeframe', true); items = bars; } } const spec = { items, draw: def.draw, decimals: raw.decimals, logScale: state.log && !def.compare, defaultVisible: def.vis, hasVolume: raw.hasVolume, showVolume: raw.hasVolume && !def.noVol && !def.compare, overlays: [], panes: [], anchor: def.id === 'baseline' && items.length ? items[0].c : null, volumeProfile: state.inds.includes('vprofile') && raw.hasVolume && !def.transform && !def.compare, meta: { symbol: state.symbol, tfLabel: (TFS.find(t => t.id === state.tf) || {}).label || state.tf }, }; if (!def.compare) { for (const ov of OVERLAYS) { if (!state.inds.includes(ov.id)) continue; if (ov.needVol && !raw.hasVolume) continue; if (ov.intraday && (state.tf === 'D' || state.tf === 'W')) continue; const made = ov.make(items, p); spec.overlays.push({ ...made, color: schemeSeries()[ov.slot], label: ov.label }); } for (const pn of PANES) { if (!state.inds.includes(pn.id)) continue; spec.panes.push({ id: pn.id, kind: pn.kind, label: pn.label, data: pn.make(items, p) }); } } else { buildCompare(spec); } chart.setSeries(spec, keepView); if (spec._absMain) { // comparaison : base % = première barre visible rebaseCompare(spec, chart.visibleRange()[0]); chart.render(); } renderLegendStatic(spec); } function schemeSeries() { return chart ? chart.th.series : []; } /* Comparaison : tout est ré-exprimé en % depuis le début de la fenêtre. * Slots catégoriels FIXES (jamais recyclés) : le symbole principal garde * le slot 1, chaque comparé garde le slot de sa position. */ function buildCompare(spec) { const main = raw.parsed; if (!main.length) return; spec.decimals = 2; spec.percent = true; spec.draw = 'none'; spec.overlays = []; spec.anchor = null; const colors = schemeSeries(); // séries absolues alignées sur la timeline du symbole principal // (report de la dernière valeur connue dans les trous de séance) spec._absMain = main; spec._absComp = state.compare.slice(0, 3).map((sym, k) => { const cr = compareRaw[sym]; if (!cr) return null; const map = new Map(cr.parsed.map(b => [b.t, b.c])); let lastC = null; const closes = main.map(b => { const c = map.get(b.t); if (c != null) lastC = c; return lastC; }); return { label: sym, color: colors[k + 1], closes }; }).filter(Boolean); spec.items = main.map(b => ({ t: b.t, o: 0, h: 0, l: 0, c: 0, v: b.v })); spec.compare = [{ label: state.symbol, color: colors[0], vals: new Array(main.length).fill(null) }]; for (const s of spec._absComp) spec.compare.push({ label: s.label, color: s.color, vals: new Array(main.length).fill(null) }); rebaseCompare(spec, 0); } /* Rebasage % sur la première barre visible — recalculé au pan/zoom, * comme une vraie échelle "percent". */ let lastRebaseI0 = -1; function rebaseCompare(spec, i0) { const main = spec._absMain; if (!main) return; i0 = Math.max(0, Math.min(main.length - 1, i0)); const baseM = main[i0].c; for (let i = 0; i < main.length; i++) { const b = main[i], it = spec.items[i]; it.o = ((b.o / baseM) - 1) * 100; it.h = ((b.h / baseM) - 1) * 100; it.l = ((b.l / baseM) - 1) * 100; it.c = ((b.c / baseM) - 1) * 100; spec.compare[0].vals[i] = it.c; } spec._absComp.forEach((s, k) => { let base = null; for (let i = i0; i < s.closes.length; i++) { if (s.closes[i] != null) { base = s.closes[i]; break; } } const vals = spec.compare[k + 1].vals; for (let i = 0; i < s.closes.length; i++) { vals[i] = (base && s.closes[i] != null) ? ((s.closes[i] / base) - 1) * 100 : null; } }); lastRebaseI0 = i0; } /* ------------------------------------------------------------ chargement */ async function loadMain(keepView) { const def = typeDef(); loading = true; $('#chart-wrap').classList.add('loading'); try { tfAvail = HF.data.tfAvailability(state.group); if (!tfAvail[state.tf]) { state.tf = tfAvail['60'] ? '60' : 'D'; syncToolbar(); } curWant = curWant || TF_WANT[state.tf] || 5000; raw = await HF.data.bars(state.group, state.symbol, state.tf, curWant); if (def.compare) { await Promise.all(state.compare.slice(0, 3).map(async s => { compareRaw[s] = await HF.data.bars(state.group, s, state.tf, curWant); })); } buildSpec(keepView); } catch (e) { toast('Erreur : ' + e.message, true); } finally { loading = false; $('#chart-wrap').classList.remove('loading'); syncTfButtons(); } } function reload(resetLimit) { if (resetLimit) curWant = 0; loadMain(false); save(); syncToolbar(); } /* pan/zoom : rebase la comparaison sur la 1re barre visible, * puis étend l'historique quand on bute sur le bord gauche */ function maybeExtend(view) { if (chart.spec && chart.spec._absMain) { const i0 = Math.max(0, Math.floor(view.first)); if (i0 !== lastRebaseI0) { rebaseCompare(chart.spec, i0); chart.render(); } } if (loading || !raw) return; if (view.first > 40) return; if (raw.srcLimitReached) return; if (raw.parsed.length < curWant * 0.9) return; // le lac n'a pas plus d'historique curWant = Math.min(HF.data.MAX_API_LIMIT, curWant * 2); loadMain(true); } /* ------------------------------------------------------------ légende OHLCV */ function renderLegendStatic(spec) { const el = $('#legend'); const def = typeDef(); const tfLabel = (TFS.find(t => t.id === state.tf) || {}).label || state.tf; let html = `${state.symbol}· ${tfLabel} · ${def.label}`; if (def.compare) { const colors = schemeSeries(); const syms = [state.symbol, ...state.compare.slice(0, 3)]; html = `Comparaison % · ${tfLabel}` + syms.map((s, k) => `${s}`).join(''); } el.innerHTML = html; updateOHLCV(null, null); } function updateOHLCV(i, item) { const el = $('#ohlcv'); if (!el || !raw || !chart.spec) return; const spec = chart.spec; const it = item || spec.items[spec.items.length - 1]; if (!it) { el.textContent = ''; return; } const idx = i != null ? i : spec.items.length - 1; const prev = spec.items[idx - 1]; const ref = prev ? prev.c : it.o; const chg = ref ? ((it.c / ref) - 1) * 100 : 0; const cls = it.c >= ref ? 'up' : 'dn'; const f = v => HF.fmt.price(v, spec.decimals); el.innerHTML = `O${f(it.o)} H${f(it.h)} ` + `L${f(it.l)} C${f(it.c)} ` + `${chg >= 0 ? '+' : ''}${chg.toFixed(2)} %` + (spec.hasVolume && it.v ? ` Vol${HF.fmt.vol(it.v)}` : ''); } /* ------------------------------------------------------------ sidebar */ function renderGroupTabs() { const tabs = $('#group-tabs'); tabs.innerHTML = ''; for (const g of HF.data.GROUPS) { const b = document.createElement('button'); b.textContent = g.label; b.className = g.key === state.group ? 'active' : ''; b.onclick = async () => { state.group = g.key; state.compare = []; state.symbol = DEFAULT_SYMBOL[g.key] || ''; renderGroupTabs(); await renderSymbolList(true); reload(true); }; tabs.appendChild(b); } } async function renderSymbolList(validate) { const ul = $('#symbols'); ul.innerHTML = '
Aucun réglage pour cette configuration.
'; return; } for (const [key, label] of fields) { const row = document.createElement('label'); row.className = 'param-row'; row.innerHTML = `${label}`; row.querySelector('input').onchange = e => { const v = parseFloat(e.target.value); if (isFinite(v) && v > 0) state.params[key] = v; else delete state.params[key]; buildSpec(true); save(); }; host.appendChild(row); } } /* ------------------------------------------------------------ galerie */ async function openGallery() { const overlay = $('#gallery'); overlay.classList.add('open'); const grid = $('#gallery-grid'); grid.innerHTML = ''; let daily; try { daily = (await HF.data.bars(state.group, state.symbol, 'D', 400)).parsed.slice(-260); } catch (e) { toast('Erreur galerie : ' + e.message, true); return; } for (const t of CHART_TYPES) { const card = document.createElement('button'); card.className = 'g-card' + (t.id === state.type ? ' active' : ''); card.innerHTML = `${t.label}${t.desc}`; card.onclick = () => { state.type = t.id; overlay.classList.remove('open'); reload(false); updateCompareHint(); }; grid.appendChild(card); let items = daily; if (t.transform) items = HF.transforms[t.transform](daily, {}); const draw = t.compare ? 'line' : t.draw; // les types à corps ont besoin de barres larges pour être reconnaissables const n = ['candles', 'hollow', 'ohlc', 'bricks'].includes(draw) ? 55 : 120; requestAnimationFrame(() => HF.mini(card.querySelector('canvas'), draw, items.slice(-n), chart.th)); } $('#gallery-close').onclick = () => overlay.classList.remove('open'); } /* ------------------------------------------------------------ export snippet */ const MAX_EXPORT_BARS = 3000; /* Barres brutes couvrant la fenêtre visible (les transforms — Renko, Kagi… — * sont recalculées à l'identique dans l'embed à partir de ces barres). */ function exportBars() { const def = typeDef(); const [i0, i1] = chart.visibleRange(); let bars; if (def.transform) { const t0 = chart.spec.items[i0] ? chart.spec.items[i0].t : raw.parsed[0].t; bars = raw.parsed.filter(b => b.t >= t0); } else { bars = raw.parsed.slice(i0, i1 + 1); } return bars.slice(-MAX_EXPORT_BARS); } function buildEmbedSnippet() { const def = typeDef(); const bars = exportBars(); const d = raw.decimals; const r = v => +v.toFixed(d); const tfLabel = (TFS.find(t => t.id === state.tf) || {}).label || state.tf; const inds = state.inds.filter(x => x !== 'vprofile'); const cfg = { symbol: state.symbol, tf: tfLabel, type: state.type, typeLabel: def.label, theme: state.theme, scheme: state.scheme, inds, params: state.params, decimals: d, hasVolume: raw.hasVolume && !def.noVol, visible: Math.min(bars.length, Math.round(chart.view.count)), bars: bars.map(b => [b.t, r(b.o), r(b.h), r(b.l), r(b.c), Math.round(b.v)]), }; const id = 'hfchart-' + Math.random().toString(36).slice(2, 8); const base = location.origin.startsWith('http') ? location.origin : 'https://www.hfchart.io'; return { count: bars.length, code: ` `, }; } function buildIframeSnippet() { const base = location.origin.startsWith('http') ? location.origin : 'https://www.hfchart.io'; const q = new URLSearchParams({ group: state.group, symbol: state.symbol, tf: state.tf, type: state.type, theme: state.theme, scheme: state.scheme, embed: '1', }); if (state.inds.length) q.set('inds', state.inds.join(',')); if (state.compare.length && compareMode()) q.set('compare', state.compare.join(',')); if (state.log) q.set('log', '1'); return { code: ` `, }; } let exportTab = 'html'; function renderExport() { const noteEl = $('#export-note'); const codeEl = $('#export-code'); $$('#export-tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === exportTab)); if (exportTab === 'html') { if (compareMode()) { noteEl.textContent = 'Le mode Comparaison % s’exporte en iframe live (onglet suivant) — le snippet HTML autonome couvre les 13 autres types.'; codeEl.value = ''; return; } const s = buildEmbedSnippet(); const kb = Math.round(s.code.length / 1024); noteEl.textContent = `Graphe 100 % autonome : ${s.count} barres visibles intégrées dans la page (~${kb} Ko), ` + `interactif (zoom, pan, crosshair), indicateurs et réglages inclus. Collez tel quel dans n’importe quelle page HTML.`; codeEl.value = s.code; } else { noteEl.textContent = 'Version live : le graphe embarqué charge la plateforme (données à jour via hfmarketdata.io).'; codeEl.value = buildIframeSnippet().code; } } function openExport() { if (!raw || !chart.spec) return; $('#export-view').classList.add('open'); renderExport(); $('#export-copied').textContent = ''; $('#export-close').onclick = () => $('#export-view').classList.remove('open'); $$('#export-tabs button').forEach(b => { b.onclick = () => { exportTab = b.dataset.tab; renderExport(); }; }); $('#export-copy').onclick = async () => { const code = $('#export-code').value; if (!code) return; try { await navigator.clipboard.writeText(code); } catch (e) { $('#export-code').select(); document.execCommand('copy'); } $('#export-copied').textContent = '✓ copié dans le presse-papiers'; setTimeout(() => { $('#export-copied').textContent = ''; }, 2500); }; } /* ------------------------------------------------------------ vue table (accessibilité) */ function openTable() { if (!chart || !chart.spec) return; const overlay = $('#table-view'); overlay.classList.add('open'); const [i0, i1] = chart.visibleRange(); const items = chart.spec.items.slice(i0, i1 + 1).slice(-500); const f = v => HF.fmt.price(v, chart.spec.decimals); const hasV = chart.spec.hasVolume; let html = `