SPB Git

spb/hfchart Public

HFChart — la référence des charts haute fréquence : 14 types rendus canvas from scratch (zéro dépendance), données HF Market Data — www.hfchart.io

JavaScript 82.1% CSS 10% HTML 5.8% Python 2.1%
29.5 KB · 689 lines javascript
Raw Blame History
1/* ============================================================================2 * HFChart — application : catalogue de charts HF, sélection, personnalisation3 * Données : API HF Market Data (https://www.hfmarketdata.io)4 * Author : Simon-Pierre Boucher — contact@spboucher.ai5 * ==========================================================================*/6(function () {7  'use strict';8  const HF = window.HF;9  const $ = s => document.querySelector(s);10  const $$ = s => Array.from(document.querySelectorAll(s));1112  /* ------------------------------------------------------------ catalogue */13  const CHART_TYPES = [14    { id: 'candles', label: 'Chandelles', desc: 'OHLC classique, corps plein', draw: 'candles', vis: 220 },15    { id: 'hollow', label: 'Chandelles creuses', desc: 'Hausse creuse / baisse pleine — lisible sans couleur', draw: 'hollow', vis: 220 },16    { id: 'ohlc', label: 'Barres OHLC', desc: 'Barres à ticks open/close', draw: 'ohlc', vis: 260 },17    { id: 'line', label: 'Ligne', desc: 'Close en trait 2px', draw: 'line', vis: 600 },18    { id: 'step', label: 'Escalier', desc: 'Close en marches', draw: 'step', vis: 400 },19    { id: 'area', label: 'Aire', desc: 'Ligne + dégradé vers la base', draw: 'area', vis: 600 },20    { id: 'baseline', label: 'Baseline', desc: 'Au-dessus / en-dessous de l’ancre', draw: 'baseline', vis: 600 },21    { id: 'heikin', label: 'Heikin-Ashi', desc: 'Chandelles lissées de tendance', draw: 'candles', transform: 'heikinAshi', vis: 220 },22    { id: 'renko', label: 'Renko', desc: 'Briques à taille fixe (auto-ATR)', draw: 'bricks', transform: 'renko', vis: 160 },23    { id: 'linebreak', label: 'Three-Line Break', desc: 'Nouvelle barre sur cassure des 3 dernières', draw: 'bricks', transform: 'lineBreak', vis: 160 },24    { id: 'rangebars', label: 'Range Bars', desc: 'Chaque barre couvre un range fixe', draw: 'candles', transform: 'rangeBars', vis: 200 },25    { id: 'kagi', label: 'Kagi', desc: 'Yang épais / yin fin, renversement fixe', draw: 'kagi', transform: 'kagi', vis: 140, noVol: true },26    { id: 'pnf', label: 'Point & Figure', desc: 'Colonnes de X et O', draw: 'pnf', transform: 'pnf', vis: 90, noVol: true },27    { id: 'compare', label: 'Comparaison %', desc: 'Jusqu’à 4 symboles, base commune → variation %', draw: 'line', compare: true, vis: 600, noVol: true },28  ];2930  const OVERLAYS = [31    { id: 'sma20', label: 'SMA 20', slot: 3, make: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma20 || 20) }) },32    { id: 'sma50', label: 'SMA 50', slot: 1, make: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma50 || 50) }) },33    { id: 'ema20', label: 'EMA 20', slot: 2, make: (bars, p) => ({ vals: HF.ind.ema(HF.ind.closes(bars), p.ema20 || 20) }) },34    { 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 }; } },35    { id: 'vwap', label: 'VWAP session ±2σ', slot: 4, needVol: true, intraday: true,36      make: bars => { const v = HF.ind.vwapSession(bars); return { band: { up: v.up2, lo: v.dn2, mid: v.v } }; } },37  ];38  const PANES = [39    { id: 'rsi', label: 'RSI', kind: 'rsi', make: (bars, p) => HF.ind.rsi(bars, p.rsiN || 14) },40    { id: 'macd', label: 'MACD', kind: 'macd', make: (bars, p) => HF.ind.macd(bars, p.macdF || 12, p.macdS || 26, p.macdSig || 9) },41    { id: 'atr', label: 'ATR', kind: 'atr', make: (bars, p) => HF.ind.atr(bars, p.atrN || 14) },42  ];43  const TFS = [44    { id: '1', label: '1m' }, { id: '5', label: '5m' }, { id: '15', label: '15m' },45    { id: '30', label: '30m' }, { id: '60', label: '1h' }, { id: '240', label: '4h' },46    { id: 'D', label: '1J' }, { id: 'W', label: '1S' },47  ];48  const TF_WANT = { 1: 5000, 5: 5000, 15: 5000, 30: 5000, 60: 5000, 240: 3000, D: 12000, W: 12000 };49  const DEFAULT_SYMBOL = { index: 'SPX', etf: 'SPY', stock: 'AAPL', futures: 'ES', crypto: 'BTC', fx: 'EURUSD' };5051  /* ------------------------------------------------------------ état */52  const state = Object.assign({53    group: 'index', symbol: 'SPX', tf: '60', type: 'candles',54    theme: 'light', scheme: 'classic', log: false,55    inds: [], compare: [], params: {},56  }, load(), urlState());57  state.params = state.params || {};5859  /* Surcharges via l'URL (partage de vues) : ?symbol=SPY&group=etf&tf=60&type=renko60   * &inds=rsi,vwap&compare=QQQ,IWM&theme=dark&scheme=cvd&log=1 */61  function urlState() {62    const q = new URLSearchParams(location.search);63    const out = {};64    for (const k of ['group', 'symbol', 'tf', 'type', 'theme', 'scheme']) {65      if (q.get(k)) out[k] = q.get(k);66    }67    if (q.get('inds') != null) out.inds = q.get('inds').split(',').filter(Boolean);68    if (q.get('compare') != null) out.compare = q.get('compare').split(',').filter(Boolean);69    if (q.get('log') != null) out.log = q.get('log') === '1';70    return out;71  }7273  function load() {74    try { return JSON.parse(localStorage.getItem('hfchart-state') || '{}'); } catch (e) { return {}; }75  }76  function save() {77    localStorage.setItem('hfchart-state', JSON.stringify({78      group: state.group, symbol: state.symbol, tf: state.tf, type: state.type,79      theme: state.theme, scheme: state.scheme, log: state.log,80      inds: state.inds, compare: state.compare, params: state.params,81    }));82  }8384  let chart = null;85  let raw = null;           // {parsed, hasVolume, decimals, srcLimitReached}86  let compareRaw = {};      // symbole → réponse87  let tfAvail = {};         // tf UI → dispo pour le groupe courant88  let loading = false;89  let curWant = 0;9091  function toast(msg, isErr) {92    const el = $('#toast');93    el.textContent = msg;94    el.className = 'toast show' + (isErr ? ' err' : '');95    clearTimeout(toast._t);96    toast._t = setTimeout(() => { el.className = 'toast'; }, 4000);97  }9899  /* ------------------------------------------------------------ spec */100  function typeDef() { return CHART_TYPES.find(t => t.id === state.type) || CHART_TYPES[0]; }101102  function buildSpec(keepView) {103    if (!raw) return;104    const def = typeDef();105    const bars = raw.parsed;106    let items = bars;107    const p = state.params;108    if (def.transform) {109      const opts = {};110      if (def.id === 'renko' && p.brick > 0) opts.brick = p.brick;111      if (def.id === 'rangebars' && p.range > 0) opts.range = p.range;112      if (def.id === 'kagi' && p.reversal > 0) opts.reversal = p.reversal;113      if (def.id === 'pnf') { if (p.box > 0) opts.box = p.box; if (p.pnfRev > 0) opts.reversal = p.pnfRev; }114      if (def.id === 'linebreak' && p.lines > 0) opts.lines = p.lines;115      items = HF.transforms[def.transform](bars, opts);116      if (!items.length) { toast('Pas assez de données pour ce type sur ce timeframe', true); items = bars; }117    }118119    const spec = {120      items,121      draw: def.draw,122      decimals: raw.decimals,123      logScale: state.log && !def.compare,124      defaultVisible: def.vis,125      hasVolume: raw.hasVolume,126      showVolume: raw.hasVolume && !def.noVol && !def.compare,127      overlays: [],128      panes: [],129      anchor: def.id === 'baseline' && items.length ? items[0].c : null,130      volumeProfile: state.inds.includes('vprofile') && raw.hasVolume && !def.transform && !def.compare,131      meta: { symbol: state.symbol, tfLabel: (TFS.find(t => t.id === state.tf) || {}).label || state.tf },132    };133134    if (!def.compare) {135      for (const ov of OVERLAYS) {136        if (!state.inds.includes(ov.id)) continue;137        if (ov.needVol && !raw.hasVolume) continue;138        if (ov.intraday && (state.tf === 'D' || state.tf === 'W')) continue;139        const made = ov.make(items, p);140        spec.overlays.push({ ...made, color: schemeSeries()[ov.slot], label: ov.label });141      }142      for (const pn of PANES) {143        if (!state.inds.includes(pn.id)) continue;144        spec.panes.push({ id: pn.id, kind: pn.kind, label: pn.label, data: pn.make(items, p) });145      }146    } else {147      buildCompare(spec);148    }149    chart.setSeries(spec, keepView);150    if (spec._absMain) { // comparaison : base % = première barre visible151      rebaseCompare(spec, chart.visibleRange()[0]);152      chart.render();153    }154    renderLegendStatic(spec);155  }156157  function schemeSeries() { return chart ? chart.th.series : []; }158159  /* Comparaison : tout est ré-exprimé en % depuis le début de la fenêtre.160   * Slots catégoriels FIXES (jamais recyclés) : le symbole principal garde161   * le slot 1, chaque comparé garde le slot de sa position. */162  function buildCompare(spec) {163    const main = raw.parsed;164    if (!main.length) return;165    spec.decimals = 2;166    spec.percent = true;167    spec.draw = 'none';168    spec.overlays = [];169    spec.anchor = null;170    const colors = schemeSeries();171    // séries absolues alignées sur la timeline du symbole principal172    // (report de la dernière valeur connue dans les trous de séance)173    spec._absMain = main;174    spec._absComp = state.compare.slice(0, 3).map((sym, k) => {175      const cr = compareRaw[sym];176      if (!cr) return null;177      const map = new Map(cr.parsed.map(b => [b.t, b.c]));178      let lastC = null;179      const closes = main.map(b => {180        const c = map.get(b.t);181        if (c != null) lastC = c;182        return lastC;183      });184      return { label: sym, color: colors[k + 1], closes };185    }).filter(Boolean);186    spec.items = main.map(b => ({ t: b.t, o: 0, h: 0, l: 0, c: 0, v: b.v }));187    spec.compare = [{ label: state.symbol, color: colors[0], vals: new Array(main.length).fill(null) }];188    for (const s of spec._absComp) spec.compare.push({ label: s.label, color: s.color, vals: new Array(main.length).fill(null) });189    rebaseCompare(spec, 0);190  }191192  /* Rebasage % sur la première barre visible — recalculé au pan/zoom,193   * comme une vraie échelle "percent". */194  let lastRebaseI0 = -1;195  function rebaseCompare(spec, i0) {196    const main = spec._absMain;197    if (!main) return;198    i0 = Math.max(0, Math.min(main.length - 1, i0));199    const baseM = main[i0].c;200    for (let i = 0; i < main.length; i++) {201      const b = main[i], it = spec.items[i];202      it.o = ((b.o / baseM) - 1) * 100;203      it.h = ((b.h / baseM) - 1) * 100;204      it.l = ((b.l / baseM) - 1) * 100;205      it.c = ((b.c / baseM) - 1) * 100;206      spec.compare[0].vals[i] = it.c;207    }208    spec._absComp.forEach((s, k) => {209      let base = null;210      for (let i = i0; i < s.closes.length; i++) { if (s.closes[i] != null) { base = s.closes[i]; break; } }211      const vals = spec.compare[k + 1].vals;212      for (let i = 0; i < s.closes.length; i++) {213        vals[i] = (base && s.closes[i] != null) ? ((s.closes[i] / base) - 1) * 100 : null;214      }215    });216    lastRebaseI0 = i0;217  }218219  /* ------------------------------------------------------------ chargement */220  async function loadMain(keepView) {221    const def = typeDef();222    loading = true;223    $('#chart-wrap').classList.add('loading');224    try {225      tfAvail = HF.data.tfAvailability(state.group);226      if (!tfAvail[state.tf]) {227        state.tf = tfAvail['60'] ? '60' : 'D';228        syncToolbar();229      }230      curWant = curWant || TF_WANT[state.tf] || 5000;231      raw = await HF.data.bars(state.group, state.symbol, state.tf, curWant);232      if (def.compare) {233        await Promise.all(state.compare.slice(0, 3).map(async s => {234          compareRaw[s] = await HF.data.bars(state.group, s, state.tf, curWant);235        }));236      }237      buildSpec(keepView);238    } catch (e) {239      toast('Erreur : ' + e.message, true);240    } finally {241      loading = false;242      $('#chart-wrap').classList.remove('loading');243      syncTfButtons();244    }245  }246247  function reload(resetLimit) {248    if (resetLimit) curWant = 0;249    loadMain(false);250    save();251    syncToolbar();252  }253254  /* pan/zoom : rebase la comparaison sur la 1re barre visible,255   * puis étend l'historique quand on bute sur le bord gauche */256  function maybeExtend(view) {257    if (chart.spec && chart.spec._absMain) {258      const i0 = Math.max(0, Math.floor(view.first));259      if (i0 !== lastRebaseI0) {260        rebaseCompare(chart.spec, i0);261        chart.render();262      }263    }264    if (loading || !raw) return;265    if (view.first > 40) return;266    if (raw.srcLimitReached) return;267    if (raw.parsed.length < curWant * 0.9) return; // le lac n'a pas plus d'historique268    curWant = Math.min(HF.data.MAX_API_LIMIT, curWant * 2);269    loadMain(true);270  }271272  /* ------------------------------------------------------------ légende OHLCV */273  function renderLegendStatic(spec) {274    const el = $('#legend');275    const def = typeDef();276    const tfLabel = (TFS.find(t => t.id === state.tf) || {}).label || state.tf;277    let html = `<span class="sym">${state.symbol}</span><span class="dim">· ${tfLabel} · ${def.label}</span><span id="ohlcv"></span>`;278    if (def.compare) {279      const colors = schemeSeries();280      const syms = [state.symbol, ...state.compare.slice(0, 3)];281      html = `<span class="dim">Comparaison % · ${tfLabel}</span>` + syms.map((s, k) =>282        `<span class="chip-sym"><i style="background:${colors[k]}"></i>${s}</span>`).join('');283    }284    el.innerHTML = html;285    updateOHLCV(null, null);286  }287288  function updateOHLCV(i, item) {289    const el = $('#ohlcv');290    if (!el || !raw || !chart.spec) return;291    const spec = chart.spec;292    const it = item || spec.items[spec.items.length - 1];293    if (!it) { el.textContent = ''; return; }294    const idx = i != null ? i : spec.items.length - 1;295    const prev = spec.items[idx - 1];296    const ref = prev ? prev.c : it.o;297    const chg = ref ? ((it.c / ref) - 1) * 100 : 0;298    const cls = it.c >= ref ? 'up' : 'dn';299    const f = v => HF.fmt.price(v, spec.decimals);300    el.innerHTML =301      `<span class="k">O</span>${f(it.o)} <span class="k">H</span>${f(it.h)} ` +302      `<span class="k">L</span>${f(it.l)} <span class="k">C</span><b class="${cls}">${f(it.c)}</b> ` +303      `<b class="${cls}">${chg >= 0 ? '+' : ''}${chg.toFixed(2)} %</b>` +304      (spec.hasVolume && it.v ? ` <span class="k">Vol</span>${HF.fmt.vol(it.v)}` : '');305  }306307  /* ------------------------------------------------------------ sidebar */308  function renderGroupTabs() {309    const tabs = $('#group-tabs');310    tabs.innerHTML = '';311    for (const g of HF.data.GROUPS) {312      const b = document.createElement('button');313      b.textContent = g.label;314      b.className = g.key === state.group ? 'active' : '';315      b.onclick = async () => {316        state.group = g.key;317        state.compare = [];318        state.symbol = DEFAULT_SYMBOL[g.key] || '';319        renderGroupTabs();320        await renderSymbolList(true);321        reload(true);322      };323      tabs.appendChild(b);324    }325  }326327  async function renderSymbolList(validate) {328    const ul = $('#symbols');329    ul.innerHTML = '<li class="dim">Chargement…</li>';330    let list;331    try {332      list = await HF.data.tickers(state.group);333    } catch (e) {334      ul.innerHTML = '';335      toast('Tickers : ' + e.message, true);336      return;337    }338    if (validate && !list.includes(state.symbol)) state.symbol = list[0] || '';339    const q = ($('#search').value || '').toUpperCase();340    ul.innerHTML = '';341    const frag = document.createDocumentFragment();342    let shown = 0;343    for (const s of list) {344      if (q && !s.toUpperCase().includes(q)) continue;345      if (++shown > 800) break; // le champ recherche affine au-delà346      const li = document.createElement('li');347      li.textContent = s;348      if (s === state.symbol) li.className = 'active';349      if (state.compare.includes(s)) li.className += ' compared';350      li.onclick = () => {351        if (compareMode() && s !== state.symbol) { toggleCompareSymbol(s); renderSymbolList(); return; }352        state.symbol = s;353        renderSymbolList();354        document.body.classList.remove('drawer-open');355        reload(true);356      };357      frag.appendChild(li);358    }359    ul.appendChild(frag);360    $('#sym-count').textContent = `${list.length} symboles`;361  }362363  function compareMode() { return typeDef().compare; }364365  function toggleCompareSymbol(sym) {366    const i = state.compare.indexOf(sym);367    if (i >= 0) state.compare.splice(i, 1);368    else {369      if (state.compare.length >= 3) { toast('Maximum 4 séries (lisibilité) — retirez-en une d’abord'); return; }370      state.compare.push(sym);371    }372    reload(false);373  }374375  /* ------------------------------------------------------------ toolbar */376  function syncTfButtons() {377    $$('#tf-row button').forEach(b => {378      b.classList.toggle('active', b.dataset.tf === state.tf);379      b.disabled = tfAvail[b.dataset.tf] === false;380      b.title = b.disabled ? 'Pas encore dans le lac de données' : '';381    });382  }383384  function syncToolbar() {385    syncTfButtons();386    $('#type-select').value = state.type;387    $('#btn-log').classList.toggle('active', state.log);388    $('#btn-theme').textContent = state.theme === 'dark' ? '☀︎' : '☾';389    $('#scheme-select').value = state.scheme;390    document.documentElement.dataset.theme = state.theme;391    document.documentElement.dataset.scheme = state.scheme;392    $$('#ind-menu input').forEach(cb => { cb.checked = state.inds.includes(cb.value); });393    renderParams();394    if (chart) { chart.readTheme(); chart.render(); }395  }396397  function buildToolbar() {398    const tfRow = $('#tf-row');399    for (const tf of TFS) {400      const b = document.createElement('button');401      b.textContent = tf.label;402      b.dataset.tf = tf.id;403      b.onclick = () => { if (!b.disabled) { state.tf = tf.id; reload(true); } };404      tfRow.appendChild(b);405    }406    const sel = $('#type-select');407    for (const t of CHART_TYPES) {408      const o = document.createElement('option');409      o.value = t.id; o.textContent = t.label;410      sel.appendChild(o);411    }412    sel.onchange = () => { state.type = sel.value; reload(false); updateCompareHint(); };413414    const menu = $('#ind-menu');415    const mk = (id, label, note) => {416      const l = document.createElement('label');417      l.innerHTML = `<input type="checkbox" value="${id}"> ${label}${note ? ` <em>${note}</em>` : ''}`;418      l.querySelector('input').onchange = e => {419        if (e.target.checked) state.inds.push(id);420        else state.inds = state.inds.filter(x => x !== id);421        reload(false);422      };423      menu.appendChild(l);424    };425    for (const ov of OVERLAYS) mk(ov.id, ov.label, ov.needVol ? 'volume requis' : '');426    mk('vprofile', 'Profil de volume', 'volume requis');427    for (const pn of PANES) mk(pn.id, pn.label + (pn.id === 'rsi' ? ' 14' : pn.id === 'atr' ? ' 14' : ' 12·26·9'), '');428429    $('#btn-log').onclick = () => { state.log = !state.log; buildSpec(true); save(); syncToolbar(); };430    $('#btn-theme').onclick = () => { state.theme = state.theme === 'dark' ? 'light' : 'dark'; save(); syncToolbar(); };431    $('#scheme-select').onchange = e => { state.scheme = e.target.value; save(); syncToolbar(); };432    $('#btn-gallery').onclick = openGallery;433    $('#btn-table').onclick = openTable;434    $('#btn-export').onclick = openExport;435    $('#search').oninput = () => renderSymbolList();436    // drawer mobile437    $('#btn-symbols').onclick = () => document.body.classList.toggle('drawer-open');438    $('#drawer-backdrop').onclick = () => document.body.classList.remove('drawer-open');439    document.addEventListener('click', e => {440      for (const dd of $$('.dropdown')) {441        if (!dd.contains(e.target)) dd.classList.remove('open');442      }443    });444    for (const dd of $$('.dropdown > button')) {445      dd.onclick = e => { e.stopPropagation(); dd.parentElement.classList.toggle('open'); };446    }447    for (const ov of $$('.overlay-panel')) {448      ov.addEventListener('click', e => { if (e.target === ov) ov.classList.remove('open'); });449    }450  }451452  function updateCompareHint() {453    $('#compare-hint').style.display = compareMode() ? 'block' : 'none';454  }455456  /* Réglages du type actif (brique, renversement, boîtes…) + périodes. */457  function renderParams() {458    const host = $('#params');459    const def = typeDef();460    host.innerHTML = '';461    const fields = [];462    if (def.id === 'renko') fields.push(['brick', 'Taille de brique (vide = auto ATR)']);463    if (def.id === 'rangebars') fields.push(['range', 'Range par barre (vide = auto)']);464    if (def.id === 'kagi') fields.push(['reversal', 'Renversement (vide = auto)']);465    if (def.id === 'pnf') { fields.push(['box', 'Taille de boîte (vide = auto)']); fields.push(['pnfRev', 'Renversement (boîtes, défaut 3)']); }466    if (def.id === 'linebreak') fields.push(['lines', 'Lignes à casser (défaut 3)']);467    if (state.inds.includes('sma20')) fields.push(['sma20', 'Période SMA 20']);468    if (state.inds.includes('sma50')) fields.push(['sma50', 'Période SMA 50']);469    if (state.inds.includes('ema20')) fields.push(['ema20', 'Période EMA 20']);470    if (state.inds.includes('boll')) { fields.push(['bollN', 'Bollinger : période']); fields.push(['bollK', 'Bollinger : écart-type']); }471    if (state.inds.includes('rsi')) fields.push(['rsiN', 'Période RSI']);472    if (state.inds.includes('atr')) fields.push(['atrN', 'Période ATR']);473    if (!fields.length) {474      host.innerHTML = '<p class="dim">Aucun réglage pour cette configuration.</p>';475      return;476    }477    for (const [key, label] of fields) {478      const row = document.createElement('label');479      row.className = 'param-row';480      row.innerHTML = `<span>${label}</span><input type="number" step="any" value="${state.params[key] ?? ''}">`;481      row.querySelector('input').onchange = e => {482        const v = parseFloat(e.target.value);483        if (isFinite(v) && v > 0) state.params[key] = v; else delete state.params[key];484        buildSpec(true);485        save();486      };487      host.appendChild(row);488    }489  }490491  /* ------------------------------------------------------------ galerie */492  async function openGallery() {493    const overlay = $('#gallery');494    overlay.classList.add('open');495    const grid = $('#gallery-grid');496    grid.innerHTML = '';497    let daily;498    try {499      daily = (await HF.data.bars(state.group, state.symbol, 'D', 400)).parsed.slice(-260);500    } catch (e) { toast('Erreur galerie : ' + e.message, true); return; }501    for (const t of CHART_TYPES) {502      const card = document.createElement('button');503      card.className = 'g-card' + (t.id === state.type ? ' active' : '');504      card.innerHTML = `<canvas></canvas><strong>${t.label}</strong><span>${t.desc}</span>`;505      card.onclick = () => { state.type = t.id; overlay.classList.remove('open'); reload(false); updateCompareHint(); };506      grid.appendChild(card);507      let items = daily;508      if (t.transform) items = HF.transforms[t.transform](daily, {});509      const draw = t.compare ? 'line' : t.draw;510      // les types à corps ont besoin de barres larges pour être reconnaissables511      const n = ['candles', 'hollow', 'ohlc', 'bricks'].includes(draw) ? 55 : 120;512      requestAnimationFrame(() => HF.mini(card.querySelector('canvas'), draw, items.slice(-n), chart.th));513    }514    $('#gallery-close').onclick = () => overlay.classList.remove('open');515  }516517  /* ------------------------------------------------------------ export snippet */518  const MAX_EXPORT_BARS = 3000;519520  /* Barres brutes couvrant la fenêtre visible (les transforms — Renko, Kagi… —521   * sont recalculées à l'identique dans l'embed à partir de ces barres). */522  function exportBars() {523    const def = typeDef();524    const [i0, i1] = chart.visibleRange();525    let bars;526    if (def.transform) {527      const t0 = chart.spec.items[i0] ? chart.spec.items[i0].t : raw.parsed[0].t;528      bars = raw.parsed.filter(b => b.t >= t0);529    } else {530      bars = raw.parsed.slice(i0, i1 + 1);531    }532    return bars.slice(-MAX_EXPORT_BARS);533  }534535  function buildEmbedSnippet() {536    const def = typeDef();537    const bars = exportBars();538    const d = raw.decimals;539    const r = v => +v.toFixed(d);540    const tfLabel = (TFS.find(t => t.id === state.tf) || {}).label || state.tf;541    const inds = state.inds.filter(x => x !== 'vprofile');542    const cfg = {543      symbol: state.symbol, tf: tfLabel, type: state.type, typeLabel: def.label,544      theme: state.theme, scheme: state.scheme,545      inds, params: state.params, decimals: d,546      hasVolume: raw.hasVolume && !def.noVol,547      visible: Math.min(bars.length, Math.round(chart.view.count)),548      bars: bars.map(b => [b.t, r(b.o), r(b.h), r(b.l), r(b.c), Math.round(b.v)]),549    };550    const id = 'hfchart-' + Math.random().toString(36).slice(2, 8);551    const base = location.origin.startsWith('http') ? location.origin : 'https://www.hfchart.io';552    return {553      count: bars.length,554      code:555`<!-- HFChart — ${state.symbol} · ${tfLabel} · ${def.label} — ${bars.length} barres intégrées556     Graphe interactif autonome (zoom, pan, crosshair), aucune requête réseau pour les données.557     https://www.hfchart.io — Simon-Pierre Boucher — contact@spboucher.ai -->558<div id="${id}" style="width:100%;height:420px"></div>559<script>560(window.HFChartEmbedQueue = window.HFChartEmbedQueue || []).push({561  el: "#${id}",562  config: ${JSON.stringify(cfg)}563});564</script>565<script async src="${base}/embed.js"></script>`,566    };567  }568569  function buildIframeSnippet() {570    const base = location.origin.startsWith('http') ? location.origin : 'https://www.hfchart.io';571    const q = new URLSearchParams({572      group: state.group, symbol: state.symbol, tf: state.tf, type: state.type,573      theme: state.theme, scheme: state.scheme, embed: '1',574    });575    if (state.inds.length) q.set('inds', state.inds.join(','));576    if (state.compare.length && compareMode()) q.set('compare', state.compare.join(','));577    if (state.log) q.set('log', '1');578    return {579      code:580`<!-- HFChart live — ${state.symbol} — https://www.hfchart.io -->581<iframe src="${base}/?${q.toString()}"582        style="width:100%;height:480px;border:0;border-radius:8px"583        loading="lazy" title="HFChart — ${state.symbol}"></iframe>`,584    };585  }586587  let exportTab = 'html';588  function renderExport() {589    const noteEl = $('#export-note');590    const codeEl = $('#export-code');591    $$('#export-tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === exportTab));592    if (exportTab === 'html') {593      if (compareMode()) {594        noteEl.textContent = 'Le mode Comparaison % s’exporte en iframe live (onglet suivant) — le snippet HTML autonome couvre les 13 autres types.';595        codeEl.value = '';596        return;597      }598      const s = buildEmbedSnippet();599      const kb = Math.round(s.code.length / 1024);600      noteEl.textContent =601        `Graphe 100 % autonome : ${s.count} barres visibles intégrées dans la page (~${kb} Ko), ` +602        `interactif (zoom, pan, crosshair), indicateurs et réglages inclus. Collez tel quel dans n’importe quelle page HTML.`;603      codeEl.value = s.code;604    } else {605      noteEl.textContent = 'Version live : le graphe embarqué charge la plateforme (données à jour via hfmarketdata.io).';606      codeEl.value = buildIframeSnippet().code;607    }608  }609610  function openExport() {611    if (!raw || !chart.spec) return;612    $('#export-view').classList.add('open');613    renderExport();614    $('#export-copied').textContent = '';615    $('#export-close').onclick = () => $('#export-view').classList.remove('open');616    $$('#export-tabs button').forEach(b => {617      b.onclick = () => { exportTab = b.dataset.tab; renderExport(); };618    });619    $('#export-copy').onclick = async () => {620      const code = $('#export-code').value;621      if (!code) return;622      try {623        await navigator.clipboard.writeText(code);624      } catch (e) {625        $('#export-code').select();626        document.execCommand('copy');627      }628      $('#export-copied').textContent = '✓ copié dans le presse-papiers';629      setTimeout(() => { $('#export-copied').textContent = ''; }, 2500);630    };631  }632633  /* ------------------------------------------------------------ vue table (accessibilité) */634  function openTable() {635    if (!chart || !chart.spec) return;636    const overlay = $('#table-view');637    overlay.classList.add('open');638    const [i0, i1] = chart.visibleRange();639    const items = chart.spec.items.slice(i0, i1 + 1).slice(-500);640    const f = v => HF.fmt.price(v, chart.spec.decimals);641    const hasV = chart.spec.hasVolume;642    let html = `<tr><th>Temps</th><th>Ouverture</th><th>Haut</th><th>Bas</th><th>Clôture</th>${hasV ? '<th>Volume</th>' : ''}</tr>`;643    for (let k = items.length - 1; k >= 0; k--) {644      const b = items[k];645      html += `<tr><td>${HF.fmt.time(b.t)}</td><td>${f(b.o)}</td><td>${f(b.h)}</td><td>${f(b.l)}</td><td>${f(b.c)}</td>${hasV ? `<td>${HF.fmt.vol(b.v)}</td>` : ''}</tr>`;646    }647    $('#table-view table').innerHTML = html;648    $('#table-title').textContent = `${state.symbol} — ${items.length} barres visibles (max 500)`;649    $('#table-close').onclick = () => overlay.classList.remove('open');650  }651652  /* ------------------------------------------------------------ boot */653  async function boot() {654    document.documentElement.dataset.theme = state.theme;655    document.documentElement.dataset.scheme = state.scheme;656    if (new URLSearchParams(location.search).get('embed') === '1') {657      document.body.classList.add('embed'); // mode iframe : chart seul658    }659    chart = new HF.HFChart($('#chart-wrap'), {660      onCrosshair: updateOHLCV,661      onViewChange: maybeExtend,662    });663    buildToolbar();664    syncToolbar();665    updateCompareHint();666    try {667      await HF.data.loadStatus();668    } catch (e) {669      toast('API hfmarketdata.io injoignable : ' + e.message, true);670      return;671    }672    renderGroupTabs();673    await renderSymbolList(true);674    reload(true);675    const panel = new URLSearchParams(location.search).get('panel');676    if (panel === 'gallery') openGallery();677    if (panel === 'table') setTimeout(openTable, 1500);678    if (new URLSearchParams(location.search).get('debug') === '1') {679      setTimeout(() => {680        $('#legend').textContent =681          `iw:${innerWidth} body:${document.body.clientWidth} wrap:${$('#chart-wrap').clientWidth}` +682          ` area:${$('#chart-area').clientWidth} layout:${$('#layout').clientWidth} chartW:${chart.w}|axis:${chart.axisW}`;683      }, 3000);684    }685  }686687  boot();688})();689