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%

feat: initial commit

simon-pierre boucher committed 21 h ago (Aug 10, 2026)

Showing 19 changed files with +3,409 and −0

added .gitignore +8 −0
@@ -0,0 +1,8 @@
1 +# Échantillons de données brutes 1-min (12 Go) — la plateforme vit sur l'API hfmarketdata.io
2 +index_full_1min_*/
3 +etf_*_full_1min_*/
4 +.hfchart_cache/
5 +
6 +.DS_Store
7 +.claude/
8 +*.log
added README.md +161 −0
@@ -0,0 +1,161 @@
1 +# HFChart
2 +
3 +**La référence des charts financiers haute fréquence — moteur canvas écrit from scratch, zéro dépendance.**
4 +
5 +[![Live](https://img.shields.io/badge/live-www.hfchart.io-2a78d6)](https://www.hfchart.io)
6 +[![Données](https://img.shields.io/badge/donn%C3%A9es-hfmarketdata.io-1baf7a)](https://www.hfmarketdata.io)
7 +[![Dépendances](https://img.shields.io/badge/d%C3%A9pendances-0-008300)](#architecture)
8 +[![Types de charts](https://img.shields.io/badge/types_de_charts-14-eb6834)](#le-catalogue)
9 +[![Indicateurs](https://img.shields.io/badge/indicateurs-8-eda100)](#indicateurs)
10 +[![Embed gzip](https://img.shields.io/badge/embed.js-18.3_Ko_gzip-4a3aa7)](#embed--snippet-autonome)
11 +[![Mobile](https://img.shields.io/badge/mobile-tactile_%2B_pinch--zoom-e87ba4)](#interactions)
12 +[![Python](https://img.shields.io/badge/serveur-Python_stdlib-3776ab)](#architecture)
13 +[![CVD safe](https://img.shields.io/badge/daltonisme-sch%C3%A9ma_valid%C3%A9_ΔE_19.2-52514e)](#couleurs)
14 +
15 +[![Author](https://img.shields.io/badge/author-Simon--Pierre_Boucher-0b0b0b)](https://www.spboucher.ai)
16 +[![Contact](https://img.shields.io/badge/contact-contact%40spboucher.ai-d03b3b)](mailto:contact@spboucher.ai)
17 +
18 +![HFChart — thème clair](docs/screenshot-light.png)
19 +
20 +Plateforme-catalogue de graphiques financiers haute fréquence : chaque pixel est
21 +dessiné à la main sur un canvas 2D — **pas de TradingView, pas de lib de charts,
22 +pas de framework**. Les données proviennent de l'API ouverte
23 +[HF Market Data](https://www.hfmarketdata.io) (interrogée directement par le
24 +navigateur), qui couvre **6 classes d'actifs et ~13 000 instruments**.
25 +
26 +---
27 +
28 +## Métriques
29 +
30 +| Métrique | Valeur |
31 +|---|---|
32 +| Dépendances (front + serveur) | **0** |
33 +| Lignes de code totales | **3 240** |
34 +| — moteur de rendu (`engine.js`) | 867 |
35 +| — application (`app.js`) | 688 |
36 +| — renderers (`renderers.js`) | 348 |
37 +| — transformations (`transforms.js`) | 208 |
38 +| — client API (`data.js`) | 185 |
39 +| — indicateurs (`indicators.js`) | 164 |
40 +| — bootstrapper embed (`embed-boot.js`) | 151 |
41 +| — serveur statique (`server.py`, stdlib pur) | 109 |
42 +| Bundle embed complet (`/embed.js`) | 68,5 Ko brut · **18,3 Ko gzip** |
43 +| Types de charts | **14** |
44 +| Indicateurs techniques | **8** |
45 +| Classes d'actifs | 6 (indices, ETF, actions, futures, crypto, FX) |
46 +| Instruments accessibles | ~13 200 |
47 +| Thèmes | clair (défaut) + sombre, sélectionnés — pas dérivés |
48 +| Schémas hausse/baisse | vert/rouge classique + bleu/rouge validé daltonisme |
49 +
50 +## Le catalogue
51 +
52 +![Galerie des 14 types](docs/screenshot-gallery.png)
53 +
54 +| Type | Rendu |
55 +|---|---|
56 +| **Chandelles** | corps pleins, mèches, fast-path haute densité (< 2 px/barre → traits hi-lo) |
57 +| **Chandelles creuses** | hausse creuse / baisse pleine — lisible sans la couleur |
58 +| **Barres OHLC** | ticks open/close |
59 +| **Ligne · Escalier · Aire · Baseline** | close 2 px, marches, dégradé 3 arrêts, ancre ± |
60 +| **Heikin-Ashi** | chandelles lissées de tendance |
61 +| **Renko** | briques à taille fixe, auto-dimensionnées par ATR, personnalisables |
62 +| **Three-Line Break** | nouvelle barre sur cassure des N dernières |
63 +| **Range Bars** | chaque barre couvre exactement un range fixe |
64 +| **Kagi** | yang épais / yin fin, renversement fixe |
65 +| **Point & Figure** | colonnes de X et O, renversement en boîtes |
66 +| **Comparaison %** | jusqu'à 4 symboles, **rebasage dynamique sur la 1ʳᵉ barre visible** |
67 +
68 +![Renko](docs/screenshot-renko.png)
69 +![Comparaison %](docs/screenshot-compare.png)
70 +
71 +## Indicateurs
72 +
73 +SMA · EMA · Bollinger · VWAP de session ±2σ · Profil de volume (POC) —
74 +en overlay, et RSI · MACD · ATR en panes dédiées. Toutes les périodes sont
75 +réglables (menu Réglages). Chaque calcul est implémenté from scratch dans
76 +`indicators.js` (Wilder pour RSI/ATR, σ cumulée pour les bandes VWAP).
77 +
78 +![Thème sombre + indicateurs](docs/screenshot-dark-indicators.png)
79 +
80 +## Interactions
81 +
82 +- Molette = zoom ancré au curseur · glisser = pan · double-clic = reset
83 +- **Tactile** : pan au doigt, **pinch-zoom à deux doigts**, drawer de symboles
84 +- Crosshair avec lecture OHLCV + variation %, chips de prix/temps
85 +- Échelle logarithmique, extension automatique de l'historique au bord gauche
86 +- Vue table (accessibilité), galerie live des types, état persistant
87 +- **Vues partageables par URL** :
88 +
89 +```
90 +https://www.hfchart.io/?group=etf&symbol=SPY&tf=60&type=renko&inds=rsi,vwap&theme=dark
91 +https://www.hfchart.io/?group=index&symbol=SPX&tf=D&type=compare&compare=NDX,DJI,RUT
92 +https://www.hfchart.io/?panel=gallery
93 +```
94 +
95 +![Mobile 390 px](docs/screenshot-mobile.png)
96 +
97 +## Embed — snippet autonome
98 +
99 +Le bouton **Exporter** génère un snippet HTML **avec les données intégrées**
100 +(barres visibles, type, indicateurs, réglages) : le graphe collé dans n'importe
101 +quelle page est pleinement interactif (zoom, pan, crosshair, panes) et ne fait
102 +**aucune requête réseau** pour les données. Un seul script :
103 +
104 +```html
105 +<div id="hfchart-x7k2" style="width:100%;height:420px"></div>
106 +<script>
107 +(window.HFChartEmbedQueue = window.HFChartEmbedQueue || []).push({
108 + el: "#hfchart-x7k2",
109 + config: { symbol: "SPX", tf: "1h", type: "candles", theme: "light",
110 + inds: ["sma20","rsi"], decimals: 2, hasVolume: true,
111 + bars: [[1700000000, 100.0, 101.2, 99.8, 100.9, 12000], ...] }
112 +});
113 +</script>
114 +<script async src="https://www.hfchart.io/embed.js"></script>
115 +```
116 +
117 +Une variante **iframe live** (`?embed=1`) est aussi proposée.
118 +
119 +![Embed autonome dans une page](docs/screenshot-embed.png)
120 +
121 +## Couleurs
122 +
123 +Le schéma hausse/baisse classique (vert `#0ca30c` / rouge `#d03b3b`) est doublé
124 +d'un schéma **« Bleu / rouge (daltonisme) »** dont la paire passe tous les
125 +checks du validateur de palette (CVD ΔE 19,2 ; contraste ≥ 3:1 sur les deux
126 +surfaces). Les séries d'indicateurs et de comparaison suivent un ordre
127 +catégoriel fixe validé — jamais recyclé.
128 +
129 +## Architecture
130 +
131 +```
132 +server.py hôte statique (Python stdlib) + route /embed.js (bundle concaténé, cache mtime)
133 +web/index.html shell de l'app
134 +web/css/style.css tokens clair/sombre + layout responsive (drawer mobile)
135 +web/js/engine.js moteur canvas : panes, échelles lin/log, axes, crosshair, zoom/pan/pinch
136 +web/js/renderers.js dessin des 14 types + colonnes de volume + bandes + profil
137 +web/js/transforms.js Heikin-Ashi, Renko, Kagi, PnF, Line Break, Range Bars
138 +web/js/indicators.js SMA/EMA/Bollinger/RSI/MACD/ATR/VWAP/profil de volume
139 +web/js/data.js client API hfmarketdata.io + resampling client (1h→4h, 1J→1S, 5min→15m)
140 +web/js/app.js état, UI, comparaison, galerie, export snippet
141 +web/js/embed-boot.js bootstrapper des graphes intégrés
142 +```
143 +
144 +Les timeframes absents du lac de données sont dérivés côté client, et les
145 +boutons s'activent automatiquement via `/v1/status` quand le lac se remplit.
146 +
147 +## Lancer
148 +
149 +```bash
150 +python3 server.py 8787 # aucune dépendance à installer
151 +# → http://localhost:8787
152 +```
153 +
154 +## Déploiement
155 +
156 +Production : **https://www.hfchart.io** — nœud MacLustr `M4M64b`, PM2
157 +(`hfchart-web` + `hfchart-ngrok`), tunnel ngrok sur domaine custom.
158 +
159 +---
160 +
161 +**Author : Simon-Pierre Boucher** · [contact@spboucher.ai](mailto:contact@spboucher.ai) · [spboucher.ai](https://www.spboucher.ai)
added docs/screenshot-compare.png +0 −0

Binary file not shown.

added docs/screenshot-dark-indicators.png +0 −0

Binary file not shown.

added docs/screenshot-embed.png +0 −0

Binary file not shown.

added docs/screenshot-gallery.png +0 −0

Binary file not shown.

added docs/screenshot-light.png +0 −0

Binary file not shown.

added docs/screenshot-mobile.png +0 −0

Binary file not shown.

added docs/screenshot-renko.png +0 −0

Binary file not shown.

added server.py +109 −0
@@ -0,0 +1,109 @@
1 +#!/usr/bin/env python3
2 +# -*- coding: utf-8 -*-
3 +"""
4 +================================================================================
5 + HFChart — plateforme de référence des charts haute fréquence
6 + Hôte statique minimal (stdlib uniquement).
7 +
8 + Les données des graphs proviennent de l'API HF Market Data :
9 + https://www.hfmarketdata.io (interrogée directement par le navigateur)
10 +
11 + Author : Simon-Pierre Boucher
12 + Contact : contact@spboucher.ai
13 +================================================================================
14 +
15 + python3 server.py [port] # défaut: 8787
16 +"""
17 +import os
18 +import sys
19 +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20 +from urllib.parse import urlparse
21 +
22 +ROOT = os.path.dirname(os.path.abspath(__file__))
23 +WEB = os.path.join(ROOT, "web")
24 +
25 +MIME = {
26 + ".html": "text/html; charset=utf-8",
27 + ".js": "application/javascript; charset=utf-8",
28 + ".css": "text/css; charset=utf-8",
29 + ".json": "application/json; charset=utf-8",
30 + ".svg": "image/svg+xml",
31 + ".png": "image/png",
32 + ".ico": "image/x-icon",
33 + ".webmanifest": "application/manifest+json",
34 +}
35 +
36 +
37 +# /embed.js = moteur complet concaténé (transforms + indicateurs + renderers +
38 +# engine + bootstrapper d'embed) — un seul <script> pour les snippets exportés.
39 +EMBED_PARTS = ["js/transforms.js", "js/indicators.js", "js/renderers.js",
40 + "js/engine.js", "js/embed-boot.js"]
41 +_embed_cache = {"key": None, "body": b""}
42 +
43 +
44 +def embed_js():
45 + paths = [os.path.join(WEB, p) for p in EMBED_PARTS]
46 + key = tuple(os.path.getmtime(p) for p in paths)
47 + if _embed_cache["key"] != key:
48 + chunks = [b"/* HFChart embed bundle - Simon-Pierre Boucher - "
49 + b"contact@spboucher.ai - https://www.hfchart.io */\n"]
50 + for p in paths:
51 + with open(p, "rb") as fh:
52 + chunks.append(fh.read())
53 + chunks.append(b"\n;\n")
54 + _embed_cache["key"] = key
55 + _embed_cache["body"] = b"".join(chunks)
56 + return _embed_cache["body"]
57 +
58 +
59 +class Handler(BaseHTTPRequestHandler):
60 + protocol_version = "HTTP/1.1"
61 +
62 + def log_message(self, fmt, *args):
63 + sys.stderr.write("[hfchart] %s\n" % (fmt % args))
64 +
65 + def _send(self, code, body, mime="text/plain; charset=utf-8", cache="no-cache"):
66 + self.send_response(code)
67 + self.send_header("Content-Type", mime)
68 + self.send_header("Content-Length", str(len(body)))
69 + self.send_header("Cache-Control", cache)
70 + self.end_headers()
71 + self.wfile.write(body)
72 +
73 + def do_GET(self):
74 + try:
75 + path = urlparse(self.path).path
76 + if path == "/health":
77 + return self._send(200, b'{"status":"ok","service":"hfchart"}',
78 + "application/json; charset=utf-8")
79 + if path == "/embed.js":
80 + self.send_response(200)
81 + self.send_header("Content-Type", MIME[".js"])
82 + body = embed_js()
83 + self.send_header("Content-Length", str(len(body)))
84 + self.send_header("Cache-Control", "public, max-age=300")
85 + self.send_header("Access-Control-Allow-Origin", "*")
86 + self.end_headers()
87 + self.wfile.write(body)
88 + return None
89 + if path == "/":
90 + path = "/index.html"
91 + fp = os.path.normpath(os.path.join(WEB, path.lstrip("/")))
92 + if not fp.startswith(WEB) or not os.path.isfile(fp):
93 + return self._send(404, b"not found")
94 + with open(fp, "rb") as fh:
95 + body = fh.read()
96 + return self._send(200, body, MIME.get(os.path.splitext(fp)[1],
97 + "application/octet-stream"))
98 + except BrokenPipeError:
99 + pass
100 +
101 +
102 +def main():
103 + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8787
104 + print(f"HFChart — http://localhost:{port} (données : https://www.hfmarketdata.io)")
105 + ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()
106 +
107 +
108 +if __name__ == "__main__":
109 + main()
added web/css/style.css +402 −0
@@ -0,0 +1,402 @@
1 +/* ============================================================================
2 + * HFChart — styles & tokens (clair par défaut, sombre sélectionné, pas dérivé)
3 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
4 + * ----------------------------------------------------------------------------
5 + * Les couleurs de séries suivent la palette catégorielle validée (ordre FIXE).
6 + * Hausse/baisse : schéma "classic" (vert/rouge, avec encodage secondaire par
7 + * la géométrie des chandelles) ou "cvd" (bleu/rouge, validé CVD ΔE 19,2).
8 + * ==========================================================================*/
9 +
10 +:root {
11 + color-scheme: light;
12 + --page: #f4f4f2;
13 + --surface: #fcfcfb;
14 + --ink: #0b0b0b;
15 + --ink2: #52514e;
16 + --muted: #898781;
17 + --grid: #e1e0d9;
18 + --axis: #c3c2b7;
19 + --border: rgba(11, 11, 11, 0.10);
20 + --chip: #0b0b0b;
21 + --chip-text: #ffffff;
22 + --series-1: #2a78d6;
23 + --series-2: #eb6834;
24 + --series-3: #1baf7a;
25 + --series-4: #eda100;
26 + --series-5: #e87ba4;
27 + --series-6: #008300;
28 + --series-7: #4a3aa7;
29 + --series-8: #e34948;
30 + --up: #0ca30c;
31 + --dn: #d03b3b;
32 + --accent-ui: #2a78d6;
33 +}
34 +
35 +:root[data-theme="dark"] {
36 + color-scheme: dark;
37 + --page: #0d0d0d;
38 + --surface: #1a1a19;
39 + --ink: #ffffff;
40 + --ink2: #c3c2b7;
41 + --muted: #898781;
42 + --grid: #2c2c2a;
43 + --axis: #383835;
44 + --border: rgba(255, 255, 255, 0.10);
45 + --chip: #ffffff;
46 + --chip-text: #0b0b0b;
47 + --series-1: #3987e5;
48 + --series-2: #d95926;
49 + --series-3: #199e70;
50 + --series-4: #c98500;
51 + --series-5: #d55181;
52 + --series-6: #008300;
53 + --series-7: #9085e9;
54 + --series-8: #e66767;
55 + --up: #0ca30c;
56 + --dn: #d03b3b;
57 + --accent-ui: #3987e5;
58 +}
59 +
60 +/* Schéma hausse/baisse CVD-sûr (paire divergente bleu↔rouge validée). */
61 +:root[data-scheme="cvd"] { --up: #2a78d6; --dn: #e34948; }
62 +:root[data-theme="dark"][data-scheme="cvd"] { --up: #3987e5; --dn: #e66767; }
63 +
64 +* { box-sizing: border-box; }
65 +
66 +html, body {
67 + margin: 0;
68 + height: 100%;
69 + font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
70 + background: var(--page);
71 + color: var(--ink);
72 +}
73 +
74 +body { display: flex; flex-direction: column; }
75 +
76 +a { color: var(--accent-ui); text-decoration: none; }
77 +a:hover { text-decoration: underline; }
78 +.dim { color: var(--muted); }
79 +
80 +/* ------------------------------------------------------------------ topbar */
81 +#topbar {
82 + display: flex;
83 + align-items: center;
84 + gap: 8px;
85 + padding: 8px 12px;
86 + background: var(--surface);
87 + border-bottom: 1px solid var(--border);
88 + flex-wrap: wrap;
89 +}
90 +
91 +.brand { display: flex; align-items: center; gap: 8px; margin-right: 8px; }
92 +.brand .logo {
93 + background: var(--accent-ui);
94 + color: #fff;
95 + font-weight: 700;
96 + font-size: 11px;
97 + padding: 3px 6px;
98 + border-radius: 6px;
99 + letter-spacing: 0.5px;
100 +}
101 +.brand strong { font-size: 15px; }
102 +.brand .provider {
103 + font-size: 11px;
104 + color: var(--muted);
105 + border: 1px solid var(--border);
106 + border-radius: 999px;
107 + padding: 2px 9px;
108 +}
109 +.brand .provider:hover { color: var(--accent-ui); text-decoration: none; }
110 +
111 +button, select, input[type="search"], input[type="number"] {
112 + font: inherit;
113 + color: var(--ink);
114 + background: var(--surface);
115 + border: 1px solid var(--border);
116 + border-radius: 7px;
117 + padding: 5px 10px;
118 + cursor: pointer;
119 +}
120 +select { cursor: pointer; }
121 +button:hover:not(:disabled), select:hover { border-color: var(--accent-ui); }
122 +button:disabled { opacity: 0.35; cursor: not-allowed; }
123 +button.active { background: var(--accent-ui); color: #fff; border-color: var(--accent-ui); }
124 +
125 +.seg { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
126 +.seg button { border: none; border-radius: 0; padding: 5px 9px; }
127 +.seg button + button { border-left: 1px solid var(--border); }
128 +
129 +.dropdown { position: relative; }
130 +.dropdown .menu {
131 + display: none;
132 + position: absolute;
133 + top: calc(100% + 4px);
134 + left: 0;
135 + z-index: 40;
136 + min-width: 240px;
137 + background: var(--surface);
138 + border: 1px solid var(--border);
139 + border-radius: 10px;
140 + box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18);
141 + padding: 8px;
142 + max-height: 60vh;
143 + overflow: auto;
144 +}
145 +.dropdown.open .menu { display: block; }
146 +.dropdown .menu label { display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px; cursor: pointer; }
147 +.dropdown .menu label:hover { background: var(--page); }
148 +.dropdown .menu em { color: var(--muted); font-style: normal; font-size: 11px; margin-left: auto; }
149 +.param-row { justify-content: space-between; }
150 +.param-row input { width: 90px; cursor: text; }
151 +
152 +/* ------------------------------------------------------------------ layout */
153 +#layout { display: flex; flex: 1; min-height: 0; }
154 +
155 +#sidebar {
156 + width: 218px;
157 + flex: none;
158 + display: flex;
159 + flex-direction: column;
160 + gap: 8px;
161 + padding: 10px;
162 + background: var(--surface);
163 + border-right: 1px solid var(--border);
164 + min-height: 0;
165 +}
166 +#group-tabs { display: flex; flex-wrap: wrap; gap: 4px; }
167 +#group-tabs button { padding: 3px 8px; font-size: 12px; border-radius: 999px; }
168 +#search { cursor: text; }
169 +#symbols {
170 + list-style: none;
171 + margin: 0;
172 + padding: 0;
173 + overflow-y: auto;
174 + flex: 1;
175 + min-height: 0;
176 + border-top: 1px solid var(--border);
177 +}
178 +#symbols li {
179 + padding: 4px 8px;
180 + border-radius: 6px;
181 + cursor: pointer;
182 + font-variant-numeric: tabular-nums;
183 +}
184 +#symbols li:hover { background: var(--page); }
185 +#symbols li.active { background: var(--accent-ui); color: #fff; }
186 +#symbols li.compared { outline: 2px solid var(--accent-ui); outline-offset: -2px; }
187 +#sym-count { margin: 0; font-size: 11px; }
188 +.hint {
189 + margin: 0;
190 + font-size: 11.5px;
191 + color: var(--ink2);
192 + background: var(--page);
193 + border: 1px solid var(--border);
194 + border-radius: 7px;
195 + padding: 6px 8px;
196 + display: none;
197 +}
198 +
199 +#chart-area { flex: 1; position: relative; min-width: 0; display: flex; flex-direction: column; }
200 +#chart-wrap { position: relative; flex: 1; min-height: 0; background: var(--surface); }
201 +#chart-wrap.loading::after {
202 + content: "";
203 + position: absolute;
204 + top: 10px; right: 84px;
205 + width: 14px; height: 14px;
206 + border: 2px solid var(--grid);
207 + border-top-color: var(--accent-ui);
208 + border-radius: 50%;
209 + animation: spin 0.8s linear infinite;
210 + z-index: 5;
211 +}
212 +@keyframes spin { to { transform: rotate(360deg); } }
213 +
214 +#legend {
215 + position: absolute;
216 + top: 8px; left: 10px;
217 + z-index: 4;
218 + display: flex;
219 + align-items: center;
220 + gap: 8px;
221 + flex-wrap: wrap;
222 + pointer-events: none;
223 + font-size: 12.5px;
224 + background: color-mix(in srgb, var(--surface) 74%, transparent);
225 + backdrop-filter: blur(8px) saturate(1.2);
226 + -webkit-backdrop-filter: blur(8px) saturate(1.2);
227 + border: 1px solid var(--border);
228 + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
229 + border-radius: 9px;
230 + padding: 4px 10px;
231 + max-width: calc(100% - 110px);
232 +}
233 +#legend .sym { font-weight: 700; font-size: 14px; }
234 +#legend .k { color: var(--muted); margin-right: 1px; }
235 +#legend b.up { color: var(--up); font-weight: 600; }
236 +#legend b.dn { color: var(--dn); font-weight: 600; }
237 +#legend #ohlcv { font-variant-numeric: tabular-nums; }
238 +.chip-sym { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; }
239 +.chip-sym i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
240 +
241 +#footer {
242 + padding: 5px 12px;
243 + font-size: 11px;
244 + color: var(--muted);
245 + background: var(--surface);
246 + border-top: 1px solid var(--border);
247 +}
248 +
249 +/* ------------------------------------------------------------------ panels */
250 +.overlay-panel {
251 + display: none;
252 + position: fixed;
253 + inset: 0;
254 + z-index: 60;
255 + background: rgba(0, 0, 0, 0.42);
256 + padding: 4vh 4vw;
257 +}
258 +.overlay-panel.open { display: flex; }
259 +.overlay-panel .panel {
260 + margin: auto;
261 + width: min(1100px, 100%);
262 + max-height: 92vh;
263 + overflow: auto;
264 + background: var(--page);
265 + border: 1px solid var(--border);
266 + border-radius: 14px;
267 + padding: 16px 20px;
268 +}
269 +.panel-head { display: flex; align-items: center; justify-content: space-between; }
270 +.panel-head h2 { margin: 0; font-size: 17px; }
271 +
272 +#gallery-grid {
273 + display: grid;
274 + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
275 + gap: 12px;
276 + margin-top: 12px;
277 +}
278 +.g-card {
279 + display: flex;
280 + flex-direction: column;
281 + gap: 4px;
282 + text-align: left;
283 + padding: 10px;
284 + border-radius: 10px;
285 + background: var(--surface);
286 +}
287 +.g-card canvas { width: 100%; height: 96px; display: block; }
288 +.g-card strong { font-size: 13px; }
289 +.g-card span { font-size: 11px; color: var(--muted); }
290 +.g-card.active {
291 + /* neutralise le style bouton .active (fond bleu) : la sélection se marque
292 + par un anneau, le mini-chart garde ses couleurs lisibles */
293 + background: var(--surface);
294 + color: inherit;
295 + border-color: var(--border);
296 + outline: 2px solid var(--accent-ui);
297 + outline-offset: 1px;
298 +}
299 +
300 +.table-scroll { overflow: auto; max-height: 74vh; margin-top: 10px; }
301 +#table-view table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
302 +#table-view th, #table-view td { padding: 4px 10px; text-align: right; border-bottom: 1px solid var(--border); }
303 +#table-view th:first-child, #table-view td:first-child { text-align: left; }
304 +#table-view th { position: sticky; top: 0; background: var(--page); color: var(--ink2); }
305 +
306 +/* ------------------------------------------------------------------ toast */
307 +.toast {
308 + position: fixed;
309 + bottom: 40px;
310 + left: 50%;
311 + transform: translate(-50%, 20px);
312 + background: var(--chip);
313 + color: var(--chip-text);
314 + padding: 8px 16px;
315 + border-radius: 9px;
316 + opacity: 0;
317 + pointer-events: none;
318 + transition: opacity 0.2s, transform 0.2s;
319 + z-index: 90;
320 + max-width: 80vw;
321 +}
322 +.toast.show { opacity: 1; transform: translate(-50%, 0); }
323 +.toast.err { background: #d03b3b; color: #fff; }
324 +
325 +/* ------------------------------------------------------------------ export */
326 +#export-code {
327 + width: 100%;
328 + height: 260px;
329 + margin-top: 10px;
330 + font: 11.5px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
331 + color: var(--ink);
332 + background: var(--surface);
333 + border: 1px solid var(--border);
334 + border-radius: 8px;
335 + padding: 10px;
336 + resize: vertical;
337 + white-space: pre;
338 + cursor: text;
339 +}
340 +.export-actions { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
341 +button.primary { background: var(--accent-ui); color: #fff; border-color: var(--accent-ui); }
342 +#export-tabs { margin-top: 10px; }
343 +#export-note { margin: 10px 0 0; }
344 +
345 +/* ------------------------------------------------------------------ embed (iframe ?embed=1) */
346 +body.embed #topbar, body.embed #sidebar, body.embed #footer { display: none; }
347 +body.embed #layout { height: 100vh; }
348 +
349 +/* ------------------------------------------------------------------ mobile */
350 +#btn-symbols { display: none; }
351 +
352 +@media (max-width: 900px) {
353 + .brand .provider { display: none; }
354 +}
355 +
356 +@media (max-width: 760px) {
357 + #topbar {
358 + flex-wrap: nowrap;
359 + overflow-x: auto;
360 + -webkit-overflow-scrolling: touch;
361 + gap: 6px;
362 + padding: 6px 8px;
363 + scrollbar-width: none;
364 + }
365 + #topbar::-webkit-scrollbar { display: none; }
366 + #topbar button, #topbar select { padding: 6px 9px; flex: none; }
367 + .brand { margin-right: 2px; }
368 + .brand strong { display: none; }
369 + .seg { flex: none; }
370 +
371 + #btn-symbols { display: inline-block; font-size: 16px; padding: 5px 10px; }
372 + #sidebar {
373 + position: fixed;
374 + top: 0; bottom: 0; left: 0;
375 + width: min(300px, 84vw);
376 + z-index: 55;
377 + transform: translateX(-105%);
378 + transition: transform 0.22s ease;
379 + box-shadow: 8px 0 32px rgba(0, 0, 0, 0.25);
380 + border-right: 1px solid var(--border);
381 + }
382 + body.drawer-open #sidebar { transform: none; }
383 + #drawer-backdrop {
384 + display: none;
385 + position: fixed;
386 + inset: 0;
387 + z-index: 54;
388 + background: rgba(0, 0, 0, 0.35);
389 + }
390 + body.drawer-open #drawer-backdrop { display: block; }
391 + #symbols li { padding: 9px 10px; } /* cibles tactiles ≥ 36px */
392 + #group-tabs button { padding: 6px 11px; }
393 +
394 + #legend { font-size: 11.5px; gap: 6px; max-width: calc(100% - 70px); }
395 + #legend .sym { font-size: 13px; }
396 + .overlay-panel { padding: 2vh 2vw; }
397 + .overlay-panel .panel { padding: 12px 14px; max-height: 96vh; }
398 + #gallery-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
399 + .g-card canvas { height: 74px; }
400 + #footer { display: none; }
401 + #export-code { height: 200px; font-size: 10.5px; }
402 +}
added web/index.html +118 −0
@@ -0,0 +1,118 @@
1 +<!DOCTYPE html>
2 +<!-- ==========================================================================
3 + HFChart — la référence des charts haute fréquence, from scratch (zéro dépendance)
4 + Données : API HF Market Data — https://www.hfmarketdata.io
5 + Author : Simon-Pierre Boucher — contact@spboucher.ai
6 +=========================================================================== -->
7 +<html lang="fr" data-theme="light" data-scheme="classic">
8 +<head>
9 + <meta charset="utf-8">
10 + <meta name="viewport" content="width=device-width, initial-scale=1">
11 + <meta name="author" content="Simon-Pierre Boucher — contact@spboucher.ai">
12 + <meta name="description" content="HFChart — catalogue de référence des charts haute fréquence : chandelles, Heikin-Ashi, Renko, Kagi, Point & Figure… rendus canvas from scratch, données HF Market Data.">
13 + <title>HFChart — charts haute fréquence · HF Market Data</title>
14 + <link rel="stylesheet" href="css/style.css">
15 +</head>
16 +<body>
17 + <header id="topbar">
18 + <button id="btn-symbols" type="button" aria-label="Symboles" title="Symboles">☰</button>
19 + <div class="brand">
20 + <span class="logo">HF</span>
21 + <strong>HFChart</strong>
22 + <a class="provider" href="https://www.hfmarketdata.io" target="_blank" rel="noopener">
23 + données · hfmarketdata.io
24 + </a>
25 + </div>
26 + <div id="tf-row" class="seg" role="group" aria-label="Timeframe"></div>
27 + <select id="type-select" aria-label="Type de chart"></select>
28 + <div class="dropdown">
29 + <button type="button">Indicateurs ▾</button>
30 + <div class="menu" id="ind-menu"></div>
31 + </div>
32 + <div class="dropdown">
33 + <button type="button">Réglages ▾</button>
34 + <div class="menu" id="params"></div>
35 + </div>
36 + <button id="btn-gallery" type="button" title="Catalogue des types de charts">Galerie</button>
37 + <button id="btn-table" type="button" title="Vue table des barres visibles">Données</button>
38 + <button id="btn-export" type="button" title="Copier un snippet du graphe avec ses données intégrées">Exporter</button>
39 + <button id="btn-log" type="button" title="Échelle logarithmique">log</button>
40 + <select id="scheme-select" aria-label="Couleurs hausse/baisse" title="Couleurs hausse/baisse">
41 + <option value="classic">Vert / rouge</option>
42 + <option value="cvd">Bleu / rouge (daltonisme)</option>
43 + </select>
44 + <button id="btn-theme" type="button" title="Thème clair / sombre">☾</button>
45 + </header>
46 +
47 + <div id="layout">
48 + <div id="drawer-backdrop"></div>
49 + <aside id="sidebar">
50 + <nav id="group-tabs" aria-label="Classes d’actifs"></nav>
51 + <input id="search" type="search" placeholder="Rechercher un symbole…" autocomplete="off">
52 + <p id="compare-hint" class="hint">Mode comparaison : cliquez des symboles pour les ajouter / retirer (max 4 séries).</p>
53 + <ul id="symbols"></ul>
54 + <p id="sym-count" class="dim"></p>
55 + </aside>
56 +
57 + <main id="chart-area">
58 + <div id="legend" aria-live="polite"></div>
59 + <div id="chart-wrap"></div>
60 + </main>
61 + </div>
62 +
63 + <footer id="footer">
64 + HFChart — moteur de rendu canvas écrit from scratch, zéro dépendance ·
65 + Données <a href="https://www.hfmarketdata.io" target="_blank" rel="noopener">HF Market Data</a> ·
66 + Simon-Pierre Boucher — <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
67 + </footer>
68 +
69 + <div id="gallery" class="overlay-panel" role="dialog" aria-label="Catalogue des types de charts">
70 + <div class="panel">
71 + <div class="panel-head">
72 + <h2>Catalogue des charts haute fréquence</h2>
73 + <button id="gallery-close" type="button" aria-label="Fermer">✕</button>
74 + </div>
75 + <p class="dim">Chaque type est rendu en direct sur le symbole courant (journalier). Cliquez pour l’ouvrir.</p>
76 + <div id="gallery-grid"></div>
77 + </div>
78 + </div>
79 +
80 + <div id="export-view" class="overlay-panel" role="dialog" aria-label="Exporter le graphe">
81 + <div class="panel">
82 + <div class="panel-head">
83 + <h2>Exporter ce graphe</h2>
84 + <button id="export-close" type="button" aria-label="Fermer">✕</button>
85 + </div>
86 + <div class="seg" id="export-tabs">
87 + <button data-tab="html" class="active" type="button">HTML autonome (données intégrées)</button>
88 + <button data-tab="iframe" type="button">iframe live</button>
89 + </div>
90 + <p id="export-note" class="dim"></p>
91 + <textarea id="export-code" readonly spellcheck="false" wrap="off"></textarea>
92 + <div class="export-actions">
93 + <button id="export-copy" type="button" class="primary">Copier le snippet</button>
94 + <span id="export-copied" class="dim"></span>
95 + </div>
96 + </div>
97 + </div>
98 +
99 + <div id="table-view" class="overlay-panel" role="dialog" aria-label="Vue table">
100 + <div class="panel">
101 + <div class="panel-head">
102 + <h2 id="table-title">Données</h2>
103 + <button id="table-close" type="button" aria-label="Fermer">✕</button>
104 + </div>
105 + <div class="table-scroll"><table></table></div>
106 + </div>
107 + </div>
108 +
109 + <div id="toast" class="toast"></div>
110 +
111 + <script src="js/transforms.js"></script>
112 + <script src="js/indicators.js"></script>
113 + <script src="js/renderers.js"></script>
114 + <script src="js/engine.js"></script>
115 + <script src="js/data.js"></script>
116 + <script src="js/app.js"></script>
117 +</body>
118 +</html>
added web/js/app.js +688 −0
@@ -0,0 +1,688 @@
1 +/* ============================================================================
2 + * HFChart — application : catalogue de charts HF, sélection, personnalisation
3 + * Données : API HF Market Data (https://www.hfmarketdata.io)
4 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
5 + * ==========================================================================*/
6 +(function () {
7 + 'use strict';
8 + const HF = window.HF;
9 + const $ = s => document.querySelector(s);
10 + const $$ = s => Array.from(document.querySelectorAll(s));
11 +
12 + /* ------------------------------------------------------------ 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 + ];
29 +
30 + 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' };
50 +
51 + /* ------------------------------------------------------------ é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 || {};
58 +
59 + /* Surcharges via l'URL (partage de vues) : ?symbol=SPY&group=etf&tf=60&type=renko
60 + * &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 + }
72 +
73 + 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 + }
83 +
84 + let chart = null;
85 + let raw = null; // {parsed, hasVolume, decimals, srcLimitReached}
86 + let compareRaw = {}; // symbole → réponse
87 + let tfAvail = {}; // tf UI → dispo pour le groupe courant
88 + let loading = false;
89 + let curWant = 0;
90 +
91 + 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 + }
98 +
99 + /* ------------------------------------------------------------ spec */
100 + function typeDef() { return CHART_TYPES.find(t => t.id === state.type) || CHART_TYPES[0]; }
101 +
102 + 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 + }
118 +
119 + 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 + };
133 +
134 + 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 visible
151 + rebaseCompare(spec, chart.visibleRange()[0]);
152 + chart.render();
153 + }
154 + renderLegendStatic(spec);
155 + }
156 +
157 + function schemeSeries() { return chart ? chart.th.series : []; }
158 +
159 + /* 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 garde
161 + * 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 principal
172 + // (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 + }
191 +
192 + /* 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 + }
218 +
219 + /* ------------------------------------------------------------ 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 + }
246 +
247 + function reload(resetLimit) {
248 + if (resetLimit) curWant = 0;
249 + loadMain(false);
250 + save();
251 + syncToolbar();
252 + }
253 +
254 + /* 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'historique
268 + curWant = Math.min(HF.data.MAX_API_LIMIT, curWant * 2);
269 + loadMain(true);
270 + }
271 +
272 + /* ------------------------------------------------------------ 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 + }
287 +
288 + 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 + }
306 +
307 + /* ------------------------------------------------------------ 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 + }
326 +
327 + 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 + }
362 +
363 + function compareMode() { return typeDef().compare; }
364 +
365 + 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 + }
374 +
375 + /* ------------------------------------------------------------ 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 + }
383 +
384 + 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 + }
396 +
397 + 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(); };
413 +
414 + 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'), '');
428 +
429 + $('#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 mobile
437 + $('#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 + }
451 +
452 + function updateCompareHint() {
453 + $('#compare-hint').style.display = compareMode() ? 'block' : 'none';
454 + }
455 +
456 + /* 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 + }
490 +
491 + /* ------------------------------------------------------------ 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 reconnaissables
511 + 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 + }
516 +
517 + /* ------------------------------------------------------------ export snippet */
518 + const MAX_EXPORT_BARS = 3000;
519 +
520 + /* 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 + }
534 +
535 + 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ées
556 + 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 + }
568 +
569 + 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 + }
586 +
587 + 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 + }
609 +
610 + 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 + }
632 +
633 + /* ------------------------------------------------------------ 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 + }
651 +
652 + /* ------------------------------------------------------------ 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 seul
658 + }
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 + }
686 +
687 + boot();
688 +})();
added web/js/data.js +185 −0
@@ -0,0 +1,185 @@
1 +/* ============================================================================
2 + * HFChart — couche données : API HF Market Data (https://www.hfmarketdata.io)
3 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
4 + * ----------------------------------------------------------------------------
5 + * Le navigateur interroge l'API directement (CORS ouvert). Les timeframes
6 + * absents du lac sont dérivés côté client par resampling (1h→4h, 1J→1S,
7 + * 5min→15m, …). `/v1/status` pilote dynamiquement ce qui est disponible.
8 + * ==========================================================================*/
9 +(function () {
10 + 'use strict';
11 + const HF = (window.HF = window.HF || {});
12 + const API = window.HFMD_API || 'https://www.hfmarketdata.io';
13 + const MAX_API_LIMIT = 50000;
14 +
15 + const GROUPS = [
16 + { key: 'index', label: 'Indices' },
17 + { key: 'etf', label: 'ETF' },
18 + { key: 'stock', label: 'Actions' },
19 + { key: 'futures', label: 'Futures' },
20 + { key: 'crypto', label: 'Crypto' },
21 + { key: 'fx', label: 'FX' },
22 + ];
23 +
24 + /* tf UI → sources candidates (ordre de préférence) + facteur de resampling */
25 + const TF_SOURCES = {
26 + '1': [['1min', 1]],
27 + '5': [['5min', 1], ['1min', 5]],
28 + '15': [['5min', 3], ['1min', 15]],
29 + '30': [['30min', 1], ['5min', 6], ['1min', 30]],
30 + '60': [['1hour', 1], ['30min', 2], ['5min', 12]],
31 + '240': [['1hour', 4], ['30min', 8]],
32 + 'D': [['1day', 1]],
33 + 'W': [['1day', 'W']],
34 + };
35 +
36 + let status = null; // /v1/status → { asset: { tf: { adj: n } } }
37 +
38 + async function getJSON(url) {
39 + const res = await fetch(url);
40 + if (!res.ok) {
41 + let msg = res.statusText;
42 + try { msg = (await res.json()).detail || msg; } catch (e) { /* texte brut */ }
43 + throw new Error(msg);
44 + }
45 + return res.json();
46 + }
47 +
48 + async function loadStatus() {
49 + if (status) return status;
50 + status = (await getJSON(`${API}/v1/status`)).datasets || {};
51 + return status;
52 + }
53 +
54 + function srcAvailable(asset, srcTf) {
55 + const a = status && status[asset];
56 + if (!a || !a[srcTf]) return false;
57 + return Object.values(a[srcTf]).some(n => n > 0);
58 + }
59 +
60 + /* Résout un tf UI vers (source, facteur) selon la disponibilité réelle. */
61 + function resolveTf(asset, uiTf) {
62 + for (const [src, factor] of (TF_SOURCES[uiTf] || [])) {
63 + if (srcAvailable(asset, src)) return { src, factor };
64 + }
65 + return null;
66 + }
67 +
68 + function tfAvailability(asset) {
69 + const out = {};
70 + for (const tf of Object.keys(TF_SOURCES)) out[tf] = !!resolveTf(asset, tf);
71 + return out;
72 + }
73 +
74 + function parseDT(s) { // "YYYY-MM-DD HH:MM:SS" → epoch (UTC naïf)
75 + return Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10),
76 + +(s.slice(11, 13) || 0), +(s.slice(14, 16) || 0), +(s.slice(17, 19) || 0)) / 1000;
77 + }
78 +
79 + function resampleSec(bars, sec) {
80 + const out = [];
81 + let cur = null, key = null;
82 + for (const b of bars) {
83 + const k = b.t - (b.t % sec);
84 + if (k !== key) {
85 + if (cur) out.push(cur);
86 + key = k;
87 + cur = { t: k, o: b.o, h: b.h, l: b.l, c: b.c, v: b.v };
88 + } else {
89 + cur.h = Math.max(cur.h, b.h);
90 + cur.l = Math.min(cur.l, b.l);
91 + cur.c = b.c;
92 + cur.v += b.v;
93 + }
94 + }
95 + if (cur) out.push(cur);
96 + return out;
97 + }
98 +
99 + function resampleWeekly(daily) {
100 + const out = [];
101 + let cur = null, key = null;
102 + for (const b of daily) {
103 + const wd = (Math.floor(b.t / 86400) + 3) % 7; // lundi = 0
104 + const k = b.t - wd * 86400;
105 + if (k !== key) {
106 + if (cur) out.push(cur);
107 + key = k;
108 + cur = { t: k, o: b.o, h: b.h, l: b.l, c: b.c, v: b.v };
109 + } else {
110 + cur.h = Math.max(cur.h, b.h);
111 + cur.l = Math.min(cur.l, b.l);
112 + cur.c = b.c;
113 + cur.v += b.v;
114 + }
115 + }
116 + if (cur) out.push(cur);
117 + return out;
118 + }
119 +
120 + function detectDecimals(bars) {
121 + let dec = 0;
122 + for (let i = Math.max(0, bars.length - 80); i < bars.length; i++) {
123 + const c = bars[i].c;
124 + for (let d = dec; d <= 6; d++) {
125 + if (Math.abs(c * Math.pow(10, d) - Math.round(c * Math.pow(10, d))) < 1e-6) {
126 + dec = Math.max(dec, d);
127 + break;
128 + }
129 + if (d === 6) dec = 6;
130 + }
131 + }
132 + return dec;
133 + }
134 +
135 + const tickerCache = new Map();
136 + async function tickers(asset) {
137 + if (tickerCache.has(asset)) return tickerCache.get(asset);
138 + await loadStatus();
139 + // n'importe quel tf disponible fait l'affaire pour lister
140 + const tfs = Object.keys(status[asset] || {});
141 + const tf = tfs.includes('1day') ? '1day' : tfs[0];
142 + if (!tf) return [];
143 + const j = await getJSON(`${API}/v1/${asset}/tickers?timeframe=${tf}&limit=20000`);
144 + const list = (j.tickers || []).filter(t => t && t.trim());
145 + tickerCache.set(asset, list);
146 + return list;
147 + }
148 +
149 + const barCache = new Map();
150 + /* Retourne {parsed:[{t,o,h,l,c,v}], hasVolume, decimals, srcLimitReached} */
151 + async function bars(asset, ticker, uiTf, wantBars) {
152 + await loadStatus();
153 + const r = resolveTf(asset, uiTf);
154 + if (!r) throw new Error(`Timeframe indisponible pour ${asset} (lac de données)`);
155 + const factor = r.factor === 'W' ? 5 : r.factor;
156 + const apiLimit = Math.min(MAX_API_LIMIT, Math.ceil(wantBars * factor * 1.1));
157 + const key = `${asset}|${ticker}|${r.src}|${apiLimit}`;
158 + if (barCache.has(key)) return barCache.get(key);
159 + const j = await getJSON(
160 + `${API}/v1/bars/${asset}/${encodeURIComponent(ticker)}?timeframe=${r.src}&order=desc&limit=${apiLimit}`);
161 + const rows = (j.data || []).reverse();
162 + let parsed = rows.map(rec => ({
163 + t: parseDT(rec.datetime), o: rec.open, h: rec.high, l: rec.low, c: rec.close,
164 + v: rec.volume != null ? rec.volume : 0,
165 + }));
166 + if (r.factor === 'W') parsed = resampleWeekly(parsed);
167 + else if (r.factor > 1) {
168 + const secOf = { '1min': 60, '5min': 300, '30min': 1800, '1hour': 3600 };
169 + parsed = resampleSec(parsed, secOf[r.src] * r.factor);
170 + }
171 + const out = {
172 + parsed,
173 + hasVolume: rows.some(rec => rec.volume != null && rec.volume > 0),
174 + decimals: detectDecimals(parsed),
175 + srcLimitReached: (j.count || rows.length) >= apiLimit && apiLimit >= MAX_API_LIMIT,
176 + src: r.src,
177 + };
178 + if (barCache.size > 24) barCache.delete(barCache.keys().next().value);
179 + barCache.set(key, out);
180 + return out;
181 + }
182 +
183 + HF.data = { API, GROUPS, loadStatus, tfAvailability, resolveTf, tickers, bars,
184 + resampleSec, resampleWeekly, MAX_API_LIMIT };
185 +})();
added web/js/embed-boot.js +151 −0
@@ -0,0 +1,151 @@
1 +/* ============================================================================
2 + * HFChart Embed — bootstrapper des graphes intégrés (données inline)
3 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
4 + * ----------------------------------------------------------------------------
5 + * Servi concaténé après transforms/indicators/renderers/engine dans /embed.js.
6 + * Usage (snippet généré par le bouton « Exporter » de www.hfchart.io) :
7 + * <div id="hfchart-xxxx" style="width:100%;height:420px"></div>
8 + * <script>(window.HFChartEmbedQueue=window.HFChartEmbedQueue||[])
9 + * .push({el:'#hfchart-xxxx',config:{symbol,tf,type,theme,scheme,inds,
10 + * params,decimals,hasVolume,bars:[[t,o,h,l,c,v],…]}});</script>
11 + * <script async src="https://www.hfchart.io/embed.js"></script>
12 + * Le graphe est pleinement interactif (zoom, pan, crosshair) et 100 % autonome :
13 + * les données sont dans la page, aucune requête réseau.
14 + * ==========================================================================*/
15 +(function () {
16 + 'use strict';
17 + if (window.__HFEmbedLoaded) return;
18 + window.__HFEmbedLoaded = true;
19 + const HF = window.HF;
20 +
21 + const TYPES = {
22 + candles: { draw: 'candles' },
23 + hollow: { draw: 'hollow' },
24 + ohlc: { draw: 'ohlc' },
25 + line: { draw: 'line' },
26 + step: { draw: 'step' },
27 + area: { draw: 'area' },
28 + baseline: { draw: 'baseline' },
29 + heikin: { draw: 'candles', transform: 'heikinAshi' },
30 + renko: { draw: 'bricks', transform: 'renko' },
31 + linebreak: { draw: 'bricks', transform: 'lineBreak' },
32 + rangebars: { draw: 'candles', transform: 'rangeBars' },
33 + kagi: { draw: 'kagi', transform: 'kagi', noVol: true },
34 + pnf: { draw: 'pnf', transform: 'pnf', noVol: true },
35 + };
36 +
37 + /* Tokens des deux thèmes (mêmes valeurs que style.css de la plateforme). */
38 + const TOKENS = {
39 + light: {
40 + '--surface': '#fcfcfb', '--ink': '#0b0b0b', '--ink2': '#52514e',
41 + '--muted': '#898781', '--grid': '#e1e0d9', '--axis': '#c3c2b7',
42 + '--chip': '#0b0b0b', '--chip-text': '#ffffff',
43 + '--series-1': '#2a78d6', '--series-2': '#eb6834', '--series-3': '#1baf7a',
44 + '--series-4': '#eda100', '--series-5': '#e87ba4', '--series-6': '#008300',
45 + '--series-7': '#4a3aa7', '--series-8': '#e34948',
46 + },
47 + dark: {
48 + '--surface': '#1a1a19', '--ink': '#ffffff', '--ink2': '#c3c2b7',
49 + '--muted': '#898781', '--grid': '#2c2c2a', '--axis': '#383835',
50 + '--chip': '#ffffff', '--chip-text': '#0b0b0b',
51 + '--series-1': '#3987e5', '--series-2': '#d95926', '--series-3': '#199e70',
52 + '--series-4': '#c98500', '--series-5': '#d55181', '--series-6': '#008300',
53 + '--series-7': '#9085e9', '--series-8': '#e66767',
54 + },
55 + };
56 + const UPDN = {
57 + classic: { '--up': '#0ca30c', '--dn': '#d03b3b' },
58 + cvd_light: { '--up': '#2a78d6', '--dn': '#e34948' },
59 + cvd_dark: { '--up': '#3987e5', '--dn': '#e66767' },
60 + };
61 +
62 + const OVERLAYS = {
63 + sma20: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma20 || 20), slot: 3 }),
64 + sma50: (bars, p) => ({ vals: HF.ind.sma(HF.ind.closes(bars), p.sma50 || 50), slot: 1 }),
65 + ema20: (bars, p) => ({ vals: HF.ind.ema(HF.ind.closes(bars), p.ema20 || 20), slot: 2 }),
66 + boll: (bars, p) => ({ band: HF.ind.bollinger(bars, p.bollN || 20, p.bollK || 2), slot: 6 }),
67 + vwap: bars => { const v = HF.ind.vwapSession(bars); return { band: { up: v.up2, lo: v.dn2, mid: v.v }, slot: 4, needVol: true }; },
68 + };
69 + const PANES = {
70 + rsi: (bars, p) => ({ kind: 'rsi', data: HF.ind.rsi(bars, p.rsiN || 14) }),
71 + macd: (bars, p) => ({ kind: 'macd', data: HF.ind.macd(bars, p.macdF || 12, p.macdS || 26, p.macdSig || 9) }),
72 + atr: (bars, p) => ({ kind: 'atr', data: HF.ind.atr(bars, p.atrN || 14) }),
73 + };
74 +
75 + function render(item) {
76 + const el = typeof item.el === 'string' ? document.querySelector(item.el) : item.el;
77 + const cfg = item.config || {};
78 + if (!el || !cfg.bars || !cfg.bars.length) return;
79 + const theme = cfg.theme === 'dark' ? 'dark' : 'light';
80 + const scheme = cfg.scheme === 'cvd' ? (theme === 'dark' ? 'cvd_dark' : 'cvd_light') : 'classic';
81 + el.innerHTML = '';
82 + el.style.position = el.style.position || 'relative';
83 + if (!el.style.height) el.style.height = '420px';
84 + for (const [k, v] of Object.entries(TOKENS[theme])) el.style.setProperty(k, v);
85 + for (const [k, v] of Object.entries(UPDN[scheme])) el.style.setProperty(k, v);
86 + el.style.background = TOKENS[theme]['--surface'];
87 + el.style.borderRadius = el.style.borderRadius || '8px';
88 + el.style.overflow = 'hidden';
89 + el.style.fontFamily = 'system-ui, -apple-system, "Segoe UI", sans-serif';
90 +
91 + const chartDiv = document.createElement('div');
92 + chartDiv.style.cssText = 'position:absolute;inset:0 0 20px 0;';
93 + el.appendChild(chartDiv);
94 +
95 + // barre de crédit (attribution + identité de la vue)
96 + const credit = document.createElement('div');
97 + credit.style.cssText =
98 + `position:absolute;left:0;right:0;bottom:0;height:20px;display:flex;align-items:center;` +
99 + `justify-content:space-between;padding:0 8px;font-size:10.5px;box-sizing:border-box;` +
100 + `color:${TOKENS[theme]['--muted']};border-top:1px solid ${TOKENS[theme]['--grid']};`;
101 + const label = [cfg.symbol, cfg.tf, cfg.typeLabel || cfg.type].filter(Boolean).join(' · ');
102 + credit.innerHTML =
103 + `<span style="font-weight:600;color:${TOKENS[theme]['--ink2']}">${label}</span>` +
104 + `<a href="https://www.hfchart.io" target="_blank" rel="noopener" ` +
105 + `style="color:${TOKENS[theme]['--muted']};text-decoration:none">HFChart · données hfmarketdata.io</a>`;
106 + el.appendChild(credit);
107 +
108 + const bars = cfg.bars.map(b => ({ t: b[0], o: b[1], h: b[2], l: b[3], c: b[4], v: b[5] || 0 }));
109 + const def = TYPES[cfg.type] || TYPES.candles;
110 + const p = cfg.params || {};
111 + let items = bars;
112 + if (def.transform) {
113 + const opts = {};
114 + if (cfg.type === 'renko' && p.brick > 0) opts.brick = p.brick;
115 + if (cfg.type === 'rangebars' && p.range > 0) opts.range = p.range;
116 + if (cfg.type === 'kagi' && p.reversal > 0) opts.reversal = p.reversal;
117 + if (cfg.type === 'pnf') { if (p.box > 0) opts.box = p.box; if (p.pnfRev > 0) opts.reversal = p.pnfRev; }
118 + if (cfg.type === 'linebreak' && p.lines > 0) opts.lines = p.lines;
119 + items = HF.transforms[def.transform](bars, opts);
120 + if (!items.length) items = bars;
121 + }
122 +
123 + const chart = new HF.HFChart(chartDiv, {});
124 + const spec = {
125 + items,
126 + draw: def.draw,
127 + decimals: cfg.decimals != null ? cfg.decimals : 2,
128 + defaultVisible: Math.min(items.length, cfg.visible || 320),
129 + hasVolume: !!cfg.hasVolume,
130 + showVolume: !!cfg.hasVolume && !def.noVol,
131 + overlays: [],
132 + panes: [],
133 + anchor: cfg.type === 'baseline' && items.length ? items[0].c : null,
134 + };
135 + for (const id of (cfg.inds || [])) {
136 + if (OVERLAYS[id]) {
137 + const made = OVERLAYS[id](items, p);
138 + if (made.needVol && !cfg.hasVolume) continue;
139 + spec.overlays.push({ ...made, color: chart.th.series[made.slot] });
140 + } else if (PANES[id]) {
141 + spec.panes.push({ id, label: id.toUpperCase(), ...PANES[id](items, p) });
142 + }
143 + }
144 + chart.setSeries(spec);
145 + }
146 +
147 + const q = window.HFChartEmbedQueue = window.HFChartEmbedQueue || [];
148 + const boot = () => { q.splice(0).forEach(render); q.push = it => { render(it); return 0; }; };
149 + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
150 + else boot();
151 +})();
added web/js/engine.js +867 −0
@@ -0,0 +1,867 @@
1 +/* ============================================================================
2 + * HFChart — moteur de chart canvas haute fréquence, from scratch (zéro dépendance)
3 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
4 + * ==========================================================================*/
5 +(function () {
6 + 'use strict';
7 + const HF = (window.HF = window.HF || {});
8 +
9 + const MONTHS = ['janv', 'févr', 'mars', 'avr', 'mai', 'juin', 'juil', 'août', 'sept', 'oct', 'nov', 'déc'];
10 + const DAYS = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'];
11 +
12 + function pad(n) { return n < 10 ? '0' + n : '' + n; }
13 +
14 + function fmtPrice(v, dec) {
15 + if (v == null || !isFinite(v)) return '—';
16 + const d = dec != null ? dec : 2;
17 + return v.toLocaleString('fr-CA', { minimumFractionDigits: d, maximumFractionDigits: d });
18 + }
19 +
20 + function fmtVol(v) {
21 + if (v == null || !isFinite(v) || v === 0) return '0';
22 + const a = Math.abs(v);
23 + if (a >= 1e9) return (v / 1e9).toFixed(2) + ' G';
24 + if (a >= 1e6) return (v / 1e6).toFixed(2) + ' M';
25 + if (a >= 1e3) return (v / 1e3).toFixed(1) + ' k';
26 + return String(Math.round(v));
27 + }
28 +
29 + function fmtTimeFull(t) {
30 + const d = new Date(t * 1000);
31 + return `${DAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} · ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
32 + }
33 +
34 + function hexToRgb(hex) {
35 + const h = hex.replace('#', '');
36 + const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
37 + return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
38 + }
39 +
40 + function niceStep(raw) {
41 + if (!(raw > 0)) return 1;
42 + const p = Math.pow(10, Math.floor(Math.log10(raw)));
43 + const m = raw / p;
44 + return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p;
45 + }
46 +
47 + /* Échelle de temps candidate (secondes) pour l'axe X. */
48 + const TIME_LADDER = [60, 120, 300, 600, 900, 1800, 3600, 7200, 14400, 21600, 43200,
49 + 86400, 2 * 86400, 7 * 86400, 14 * 86400, 'M', '3M', '6M', 'Y', '2Y', '5Y', '10Y'];
50 +
51 + class HFChart {
52 + constructor(container, opts) {
53 + this.el = container;
54 + this.opts = opts || {};
55 + this.base = document.createElement('canvas');
56 + this.over = document.createElement('canvas');
57 + for (const c of [this.base, this.over]) {
58 + c.style.position = 'absolute';
59 + c.style.inset = '0';
60 + container.appendChild(c);
61 + }
62 + this.over.style.cursor = 'crosshair';
63 + this.over.style.touchAction = 'none'; // pan/pinch gérés par le chart, pas par la page
64 + this.spec = null;
65 + this.view = { first: 0, count: 200 };
66 + this.cursor = null; // {x, y} en px CSS
67 + this.axisW = 72;
68 + this.axisH = 26;
69 + this._bindEvents();
70 + this._ro = new ResizeObserver(() => this._resize());
71 + this._ro.observe(container);
72 + this.readTheme();
73 + this._resize();
74 + }
75 +
76 + /* ----- thème : lit les custom properties CSS du container ----- */
77 + readTheme() {
78 + const cs = getComputedStyle(this.el);
79 + const g = name => (cs.getPropertyValue(name) || '').trim();
80 + const alpha = (hex, a) => {
81 + const [r, gg, b] = hexToRgb(hex);
82 + return `rgba(${r},${gg},${b},${a})`;
83 + };
84 + const t = {
85 + surface: g('--surface') || '#fcfcfb',
86 + up: g('--up') || '#0ca30c',
87 + dn: g('--dn') || '#d03b3b',
88 + accent: g('--series-1') || '#2a78d6',
89 + ink: g('--ink') || '#0b0b0b',
90 + ink2: g('--ink2') || '#52514e',
91 + muted: g('--muted') || '#898781',
92 + grid: g('--grid') || '#e1e0d9',
93 + baselineAxis: g('--axis') || '#c3c2b7',
94 + chip: g('--chip') || '#0b0b0b',
95 + chipText: g('--chip-text') || '#ffffff',
96 + series: [1, 2, 3, 4, 5, 6, 7, 8].map(i => g('--series-' + i) || '#2a78d6'),
97 + alpha,
98 + };
99 + t.accentA25 = alpha(t.accent, 0.25);
100 + t.accentA0 = alpha(t.accent, 0.0);
101 + this.th = t;
102 + }
103 +
104 + _resize() {
105 + const r = this.el.getBoundingClientRect();
106 + const dpr = window.devicePixelRatio || 1;
107 + this.w = Math.max(50, r.width);
108 + this.h = Math.max(50, r.height);
109 + // axisW recalculé à chaque render (largeur du dernier prix mesurée)
110 + for (const c of [this.base, this.over]) {
111 + c.width = Math.round(this.w * dpr);
112 + c.height = Math.round(this.h * dpr);
113 + c.style.width = this.w + 'px';
114 + c.style.height = this.h + 'px';
115 + c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
116 + }
117 + this.render();
118 + }
119 +
120 + /* ----- données ----- */
121 + setSeries(spec, keepView) {
122 + const prevN = this.spec ? this.spec.items.length : 0;
123 + this.spec = spec;
124 + const n = spec.items.length;
125 + if (!keepView || !prevN) {
126 + const def = Math.min(n, spec.defaultVisible || 220);
127 + // marge de respiration à droite (~6 %), comme sur les plateformes pro
128 + this.view = { first: n - def, count: Math.max(8, Math.round(def * 1.06)) };
129 + } else {
130 + // conserve la fenêtre relative à la fin (nouvelles données à gauche)
131 + const shift = n - prevN;
132 + this.view.first += shift;
133 + }
134 + this._clampView();
135 + this.render();
136 + }
137 +
138 + _clampView() {
139 + const n = this.spec ? this.spec.items.length : 0;
140 + this.view.count = Math.max(8, Math.min(this.view.count, Math.max(16, n * 1.25)));
141 + const minFirst = -this.view.count * 0.5;
142 + const maxFirst = Math.max(minFirst, n - this.view.count * 0.15);
143 + this.view.first = Math.max(minFirst, Math.min(this.view.first, maxFirst));
144 + }
145 +
146 + resetView() {
147 + if (!this.spec) return;
148 + const n = this.spec.items.length;
149 + const def = Math.min(n, this.spec.defaultVisible || 220);
150 + this.view = { first: n - def, count: Math.max(8, Math.round(def * 1.06)) };
151 + this.render();
152 + }
153 +
154 + visibleRange() {
155 + const n = this.spec ? this.spec.items.length : 0;
156 + const i0 = Math.max(0, Math.floor(this.view.first));
157 + const i1 = Math.min(n - 1, Math.ceil(this.view.first + this.view.count));
158 + return [i0, i1];
159 + }
160 +
161 + /* ----- layout des panes ----- */
162 + _layout() {
163 + const spec = this.spec;
164 + const plotW = this.w - this.axisW;
165 + const plotH = this.h - this.axisH;
166 + const panes = [];
167 + const subs = (spec.panes || []);
168 + const showVol = spec.showVolume;
169 + let subH = 0;
170 + const volH = showVol ? Math.max(52, Math.min(110, plotH * 0.16)) : 0;
171 + const indH = subs.length ? Math.max(72, Math.min(140, plotH * 0.18)) : 0;
172 + subH = volH + indH * subs.length;
173 + const mainH = Math.max(80, plotH - subH);
174 + let y = 0;
175 + panes.push({ id: 'main', kind: 'price', rect: { x: 0, y, w: plotW, h: mainH } });
176 + y += mainH;
177 + if (showVol) { panes.push({ id: 'volume', kind: 'volume', rect: { x: 0, y, w: plotW, h: volH } }); y += volH; }
178 + for (const s of subs) { panes.push({ ...s, rect: { x: 0, y, w: plotW, h: indH } }); y += indH; }
179 + return panes;
180 + }
181 +
182 + _xScale(rect) {
183 + const bw = rect.w / this.view.count;
184 + const first = this.view.first;
185 + return { bw, X: i => rect.x + (i - first + 0.5) * bw };
186 + }
187 +
188 + _iAtX(x, rect) {
189 + const bw = rect.w / this.view.count;
190 + return Math.round(this.view.first + (x - rect.x) / bw - 0.5);
191 + }
192 +
193 + /* ----- échelle Y d'un pane ----- */
194 + _yScale(pane, i0, i1) {
195 + const spec = this.spec;
196 + const items = spec.items;
197 + const pad = 0.08;
198 + if (pane.kind === 'price') {
199 + let lo = Infinity, hi = -Infinity;
200 + for (let i = i0; i <= i1; i++) {
201 + const b = items[i];
202 + if (!b) continue;
203 + if (b.l < lo) lo = b.l;
204 + if (b.h > hi) hi = b.h;
205 + }
206 + const extraArrs = [];
207 + for (const ov of (spec.overlays || [])) {
208 + if (ov.band) extraArrs.push(ov.band.up, ov.band.lo);
209 + else extraArrs.push(ov.vals);
210 + }
211 + for (const cs of (spec.compare || [])) extraArrs.push(cs.vals);
212 + for (const arr of extraArrs) {
213 + if (!arr) continue;
214 + for (let i = i0; i <= i1; i++) {
215 + const v = arr[i];
216 + if (v == null) continue;
217 + if (v < lo) lo = v;
218 + if (v > hi) hi = v;
219 + }
220 + }
221 + if (spec.anchor != null) { lo = Math.min(lo, spec.anchor); hi = Math.max(hi, spec.anchor); }
222 + if (!isFinite(lo) || !isFinite(hi)) { lo = 0; hi = 1; }
223 + if (hi - lo < 1e-9) { hi += 1; lo -= 1; }
224 + const r = pane.rect;
225 + if (spec.logScale && lo > 0) {
226 + const llo = Math.log(lo), lhi = Math.log(hi);
227 + const span = (lhi - llo) || 1;
228 + const p = span * pad;
229 + return { Y: v => r.y + r.h - ((Math.log(Math.max(v, 1e-12)) - (llo - p)) / (span + 2 * p)) * r.h, lo, hi, log: true };
230 + }
231 + const span = hi - lo;
232 + const p = span * pad;
233 + return { Y: v => r.y + r.h - ((v - (lo - p)) / (span + 2 * p)) * r.h, lo, hi, log: false };
234 + }
235 + if (pane.kind === 'volume') {
236 + let maxV = 0;
237 + for (let i = i0; i <= i1; i++) { const b = items[i]; if (b && b.v > maxV) maxV = b.v; }
238 + return { maxV: maxV || 1 };
239 + }
240 + if (pane.kind === 'rsi') {
241 + const r = pane.rect;
242 + return { Y: v => r.y + r.h - (v / 100) * r.h, lo: 0, hi: 100 };
243 + }
244 + // macd / atr : autoscale sur les valeurs du pane
245 + let lo = Infinity, hi = -Infinity;
246 + const arrs = pane.kind === 'macd' ? [pane.data.line, pane.data.signal, pane.data.hist] : [pane.data];
247 + for (const arr of arrs) {
248 + for (let i = i0; i <= i1; i++) {
249 + const v = arr[i];
250 + if (v == null) continue;
251 + if (v < lo) lo = v;
252 + if (v > hi) hi = v;
253 + }
254 + }
255 + if (!isFinite(lo)) { lo = 0; hi = 1; }
256 + if (pane.kind === 'macd') { const m = Math.max(Math.abs(lo), Math.abs(hi), 1e-9); lo = -m; hi = m; }
257 + if (hi - lo < 1e-9) { hi += 1; lo -= 1; }
258 + const r = pane.rect;
259 + const p = (hi - lo) * 0.12;
260 + return { Y: v => r.y + r.h - ((v - (lo - p)) / ((hi - lo) + 2 * p)) * r.h, lo, hi };
261 + }
262 +
263 + /* ----- ticks de temps ----- */
264 + _timeTicks(i0, i1, bw) {
265 + const items = this.spec.items;
266 + if (i1 <= i0) return [];
267 + // tf médian
268 + const deltas = [];
269 + for (let i = Math.max(1, i0); i <= Math.min(i1, i0 + 60); i++) {
270 + if (items[i] && items[i - 1]) deltas.push(items[i].t - items[i - 1].t);
271 + }
272 + deltas.sort((a, b) => a - b);
273 + const tf = deltas[Math.floor(deltas.length / 2)] || 60;
274 + const targetSec = (92 / bw) * tf;
275 + let unit = TIME_LADDER[TIME_LADDER.length - 1];
276 + for (const u of TIME_LADDER) {
277 + const sec = typeof u === 'number' ? u
278 + : u === 'M' ? 30 * 86400 : u === '3M' ? 91 * 86400 : u === '6M' ? 182 * 86400
279 + : u === 'Y' ? 365 * 86400 : u === '2Y' ? 730 * 86400 : u === '5Y' ? 1826 * 86400 : 3652 * 86400;
280 + if (sec >= targetSec) { unit = u; break; }
281 + }
282 + const ticks = [];
283 + let lastKey = null, lastPx = -1e9;
284 + const keyOf = t => {
285 + const d = new Date(t * 1000);
286 + if (typeof unit === 'number') return Math.floor(t / unit);
287 + const y = d.getUTCFullYear(), m = d.getUTCMonth();
288 + if (unit === 'M') return y * 12 + m;
289 + if (unit === '3M') return y * 4 + Math.floor(m / 3);
290 + if (unit === '6M') return y * 2 + Math.floor(m / 6);
291 + if (unit === 'Y') return y;
292 + if (unit === '2Y') return Math.floor(y / 2);
293 + if (unit === '5Y') return Math.floor(y / 5);
294 + return Math.floor(y / 10);
295 + };
296 + for (let i = Math.max(0, i0); i <= i1; i++) {
297 + const b = items[i];
298 + if (!b) continue;
299 + const k = keyOf(b.t);
300 + if (k === lastKey) continue;
301 + const px = (i - this.view.first + 0.5) * bw;
302 + const d = new Date(b.t * 1000);
303 + const prev = items[i - 1] ? new Date(items[i - 1].t * 1000) : null;
304 + // force des frontières : année > mois > reste (une année ne saute jamais
305 + // au profit d'un simple tick de mois trop proche)
306 + const strength = !prev ? 0
307 + : prev.getUTCFullYear() !== d.getUTCFullYear() ? 2
308 + : prev.getUTCMonth() !== d.getUTCMonth() ? 1 : 0;
309 + if (lastKey !== null && px - lastPx < 68) {
310 + lastKey = k;
311 + const lastTick = ticks[ticks.length - 1];
312 + if (!(lastTick && strength > lastTick.strength)) continue;
313 + ticks.pop(); // la frontière forte remplace le tick faible trop proche
314 + }
315 + lastKey = k; lastPx = px;
316 + if (ticks.length === 0 && px < 8) continue;
317 + let label;
318 + if (typeof unit === 'number' && unit < 86400) {
319 + label = (!prev || prev.getUTCDate() !== d.getUTCDate())
320 + ? `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`
321 + : `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
322 + } else if (typeof unit === 'number') {
323 + label = (!prev || prev.getUTCFullYear() !== d.getUTCFullYear())
324 + ? String(d.getUTCFullYear())
325 + : (prev.getUTCMonth() !== d.getUTCMonth())
326 + ? MONTHS[d.getUTCMonth()]
327 + : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
328 + } else if (unit === 'M' || unit === '3M' || unit === '6M') {
329 + label = d.getUTCMonth() === 0 ? String(d.getUTCFullYear()) : MONTHS[d.getUTCMonth()];
330 + } else {
331 + label = String(d.getUTCFullYear());
332 + }
333 + ticks.push({ i, label, strength });
334 + }
335 + return ticks;
336 + }
337 +
338 + _priceTicks(scale, rect) {
339 + const ticks = [];
340 + const n = Math.max(2, Math.floor(rect.h / 56));
341 + if (scale.log && scale.hi / scale.lo > 3) {
342 + // décades 1-2-5 en échelle log
343 + let p = Math.pow(10, Math.floor(Math.log10(scale.lo)));
344 + const mults = [1, 2, 5];
345 + for (let dec = 0; dec < 20 && p < scale.hi * 10; dec++, p *= 10) {
346 + for (const m of mults) {
347 + const v = m * p;
348 + if (v >= scale.lo && v <= scale.hi) ticks.push(v);
349 + }
350 + }
351 + return ticks;
352 + }
353 + const step = niceStep((scale.hi - scale.lo) / n);
354 + for (let v = Math.ceil(scale.lo / step) * step; v <= scale.hi + step * 1e-6; v += step) ticks.push(v);
355 + return ticks;
356 + }
357 +
358 + /* ----- rendu principal ----- */
359 + render() {
360 + const ctx = this.base.getContext('2d');
361 + ctx.clearRect(0, 0, this.w, this.h);
362 + const th = this.th;
363 + ctx.fillStyle = th.surface;
364 + ctx.fillRect(0, 0, this.w, this.h);
365 + if (!this.spec || !this.spec.items.length) {
366 + ctx.fillStyle = th.muted;
367 + ctx.font = '13px system-ui, -apple-system, sans-serif';
368 + ctx.textAlign = 'center';
369 + ctx.fillText('Chargement…', this.w / 2, this.h / 2);
370 + this._renderOverlay();
371 + return;
372 + }
373 + const spec = this.spec;
374 + // largeur d'axe adaptée au prix courant (jamais de label rogné)
375 + {
376 + const lastBar = spec.items[spec.items.length - 1];
377 + ctx.font = '600 11px system-ui, -apple-system, sans-serif';
378 + const w = lastBar ? ctx.measureText(fmtPrice(lastBar.c, spec.decimals) + (spec.percent ? ' %' : '')).width : 50;
379 + this.axisW = Math.max(46, Math.min(96, Math.ceil(w) + 16));
380 + }
381 + const panes = this._layout();
382 + this.panes = panes;
383 + const [i0, i1] = this.visibleRange();
384 + const main = panes[0];
385 + const { bw, X } = this._xScale(main.rect);
386 + this._bw = bw; this._X = X;
387 + const font = '11px system-ui, -apple-system, sans-serif';
388 +
389 + // ticks temps (partagés)
390 + const tticks = this._timeTicks(i0, i1, bw);
391 +
392 + for (const pane of panes) {
393 + const r = pane.rect;
394 + const scale = this._yScale(pane, i0, i1);
395 + pane.scale = scale;
396 + ctx.save();
397 + ctx.beginPath();
398 + ctx.rect(r.x, r.y, r.w, r.h);
399 + ctx.clip();
400 +
401 + // grille verticale (plus discrète que l'horizontale)
402 + ctx.strokeStyle = th.alpha(th.grid, 0.6);
403 + ctx.lineWidth = 1;
404 + ctx.beginPath();
405 + for (const tk of tticks) {
406 + const x = Math.round(X(tk.i)) + 0.5;
407 + ctx.moveTo(x, r.y);
408 + ctx.lineTo(x, r.y + r.h);
409 + }
410 + ctx.stroke();
411 +
412 + const env = { ctx, items: spec.items, i0, i1, X, Y: scale.Y, rect: r, bw, th, anchor: spec.anchor };
413 +
414 + if (pane.kind === 'price') {
415 + // grille horizontale + labels
416 + const pticks = this._priceTicks(scale, r);
417 + ctx.beginPath();
418 + ctx.strokeStyle = th.grid;
419 + for (const v of pticks) {
420 + const y = Math.round(scale.Y(v)) + 0.5;
421 + ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y);
422 + }
423 + ctx.stroke();
424 + pane.pticks = pticks;
425 +
426 + // filigrane symbole (repère d'identité, très en retrait)
427 + if (spec.meta && spec.meta.symbol && r.h > 90) {
428 + ctx.save();
429 + const size = Math.min(r.h * 0.30, r.w * 0.13, 110);
430 + ctx.font = `700 ${size}px system-ui, -apple-system, sans-serif`;
431 + ctx.fillStyle = th.alpha(th.ink, 0.045);
432 + ctx.textAlign = 'center';
433 + ctx.textBaseline = 'middle';
434 + const wm = spec.meta.tfLabel ? `${spec.meta.symbol} · ${spec.meta.tfLabel}` : spec.meta.symbol;
435 + ctx.fillText(wm, r.x + r.w / 2, r.y + r.h / 2);
436 + ctx.restore();
437 + }
438 +
439 + // bandes (Bollinger / VWAP) sous la série
440 + for (const ov of (spec.overlays || [])) {
441 + if (ov.band) HF.render.bandFill(env, ov.band.up, ov.band.lo, th.alpha(ov.color, 0.10));
442 + }
443 + // profil de volume (avant la série pour rester en fond)
444 + if (spec.volumeProfile) this._drawProfile(ctx, env, i0, i1);
445 + // série principale ('none' = mode comparaison, lignes uniquement)
446 + if (spec.draw !== 'none') (HF.render[spec.draw] || HF.render.candles)(env);
447 + // lignes d'overlays
448 + for (const ov of (spec.overlays || [])) {
449 + if (ov.band) {
450 + HF.render.indicatorLine(env, ov.band.up, ov.color, 1, [3, 3]);
451 + HF.render.indicatorLine(env, ov.band.lo, ov.color, 1, [3, 3]);
452 + if (ov.band.mid) HF.render.indicatorLine(env, ov.band.mid, ov.color, 1.5);
453 + } else if (ov.vals) {
454 + HF.render.indicatorLine(env, ov.vals, ov.color, ov.width || 1.75, ov.dash);
455 + }
456 + }
457 + // lignes de comparaison
458 + if (spec.compare) {
459 + for (const cs of spec.compare) {
460 + HF.render.indicatorLine(env, cs.vals, cs.color, 2);
461 + }
462 + }
463 + } else if (pane.kind === 'volume') {
464 + HF.render.volumeColumns(env, scale.maxV);
465 + } else if (pane.kind === 'rsi') {
466 + for (const lvl of [30, 70]) {
467 + ctx.strokeStyle = th.grid;
468 + ctx.setLineDash([3, 3]);
469 + ctx.beginPath();
470 + const y = Math.round(scale.Y(lvl)) + 0.5;
471 + ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y);
472 + ctx.stroke();
473 + ctx.setLineDash([]);
474 + }
475 + ctx.fillStyle = th.alpha(th.series[0], 0.06);
476 + ctx.fillRect(r.x, scale.Y(70), r.w, scale.Y(30) - scale.Y(70));
477 + HF.render.indicatorLine(env, pane.data, th.series[0], 2);
478 + } else if (pane.kind === 'macd') {
479 + const zero = Math.round(scale.Y(0)) + 0.5;
480 + ctx.strokeStyle = th.baselineAxis;
481 + ctx.beginPath(); ctx.moveTo(r.x, zero); ctx.lineTo(r.x + r.w, zero); ctx.stroke();
482 + const w = Math.max(1, Math.min(bw - 2, Math.round(bw * 0.7)));
483 + for (const pos of [true, false]) {
484 + ctx.fillStyle = th.alpha(pos ? th.up : th.dn, 0.45);
485 + ctx.beginPath();
486 + for (let i = i0; i <= i1; i++) {
487 + const v = pane.data.hist[i];
488 + if (v == null || (v >= 0) !== pos) continue;
489 + const y = scale.Y(v);
490 + ctx.rect(Math.round(X(i) - w / 2), Math.min(y, zero), w, Math.abs(y - zero) || 1);
491 + }
492 + ctx.fill();
493 + }
494 + HF.render.indicatorLine(env, pane.data.line, th.series[0], 1.75);
495 + HF.render.indicatorLine(env, pane.data.signal, th.series[1], 1.75);
496 + } else if (pane.kind === 'atr') {
497 + HF.render.indicatorLine(env, pane.data, th.series[2], 2);
498 + }
499 + ctx.restore();
500 +
501 + // séparateur de pane
502 + if (pane !== panes[0]) {
503 + ctx.strokeStyle = th.baselineAxis;
504 + ctx.beginPath();
505 + ctx.moveTo(0, Math.round(r.y) + 0.5);
506 + ctx.lineTo(this.w, Math.round(r.y) + 0.5);
507 + ctx.stroke();
508 + }
509 + }
510 +
511 + // ---- axe des prix (pane principal) + labels des sous-panes ----
512 + ctx.font = font;
513 + ctx.textBaseline = 'middle';
514 + ctx.textAlign = 'left';
515 + const axisX = this.w - this.axisW;
516 + ctx.strokeStyle = th.baselineAxis;
517 + ctx.beginPath();
518 + ctx.moveTo(Math.round(axisX) + 0.5, 0);
519 + ctx.lineTo(Math.round(axisX) + 0.5, this.h - this.axisH);
520 + ctx.stroke();
521 + ctx.fillStyle = th.muted;
522 + const pctSuffix = spec.percent ? ' %' : '';
523 + for (const v of (main.pticks || [])) {
524 + const y = main.scale.Y(v);
525 + if (y < main.rect.y + 8 || y > main.rect.y + main.rect.h - 8) continue;
526 + ctx.fillText(fmtPrice(v, this._tickDecimals(main.pticks)) + pctSuffix, axisX + 6, y);
527 + }
528 + // labels min/max + titre des sous-panes
529 + for (const pane of panes.slice(1)) {
530 + const r = pane.rect;
531 + ctx.fillStyle = th.muted;
532 + if (pane.kind === 'volume') {
533 + ctx.fillText(fmtVol(pane.scale.maxV), axisX + 6, r.y + 10);
534 + } else if (pane.scale && pane.scale.Y) {
535 + ctx.fillText(fmtPrice(pane.scale.hi, pane.kind === 'rsi' ? 0 : 2), axisX + 6, r.y + 10);
536 + ctx.fillText(fmtPrice(pane.scale.lo, pane.kind === 'rsi' ? 0 : 2), axisX + 6, r.y + r.h - 10);
537 + }
538 + const title = pane.kind === 'volume' ? 'Volume' : (pane.label || pane.kind.toUpperCase());
539 + ctx.font = '600 10px system-ui, -apple-system, sans-serif';
540 + ctx.fillStyle = th.ink2;
541 + ctx.textAlign = 'left';
542 + ctx.fillText(title, 8, r.y + 11);
543 + ctx.font = font;
544 + }
545 +
546 + // ---- axe du temps ----
547 + const axisY = Math.round(this.h - this.axisH) + 0.5;
548 + ctx.strokeStyle = th.baselineAxis;
549 + ctx.beginPath();
550 + ctx.moveTo(0, axisY);
551 + ctx.lineTo(this.w, axisY);
552 + for (const tk of tticks) { // petites graduations
553 + const x = Math.round(X(tk.i)) + 0.5;
554 + if (x > 4 && x < axisX - 4) { ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); }
555 + }
556 + ctx.stroke();
557 + ctx.fillStyle = th.muted;
558 + ctx.textAlign = 'center';
559 + for (const tk of tticks) {
560 + const x = X(tk.i);
561 + if (x > 4 && x < axisX - 4) ctx.fillText(tk.label, x, this.h - this.axisH / 2 + 2);
562 + }
563 +
564 + // ---- dernier prix (chip sur l'axe, sans objet en mode comparaison) ----
565 + const last = spec.items[spec.items.length - 1];
566 + if (last && main.scale && !spec.compare) {
567 + const prev = spec.items[spec.items.length - 2];
568 + const up = prev ? last.c >= prev.c : true;
569 + const y = Math.max(main.rect.y + 9, Math.min(main.rect.y + main.rect.h - 9, main.scale.Y(last.c)));
570 + // ligne pointillée du dernier prix
571 + ctx.strokeStyle = th.alpha(up ? th.up : th.dn, 0.55);
572 + ctx.setLineDash([2, 4]);
573 + ctx.beginPath();
574 + ctx.moveTo(0, Math.round(main.scale.Y(last.c)) + 0.5);
575 + ctx.lineTo(axisX, Math.round(main.scale.Y(last.c)) + 0.5);
576 + ctx.stroke();
577 + ctx.setLineDash([]);
578 + ctx.save();
579 + ctx.shadowColor = 'rgba(0,0,0,0.25)';
580 + ctx.shadowBlur = 6;
581 + ctx.shadowOffsetY = 1;
582 + ctx.fillStyle = up ? th.up : th.dn;
583 + const label = fmtPrice(last.c, spec.decimals);
584 + const tw = ctx.measureText(label).width;
585 + HF.render.roundRect(ctx, axisX + 2, y - 9, Math.max(tw + 12, this.axisW - 6), 18, 4);
586 + ctx.fill();
587 + ctx.restore();
588 + ctx.fillStyle = '#ffffff';
589 + ctx.textAlign = 'left';
590 + ctx.font = '600 11px system-ui, -apple-system, sans-serif';
591 + ctx.fillText(label, axisX + 8, y + 0.5);
592 + ctx.font = font;
593 + }
594 +
595 + // labels de fin de ligne pour la comparaison (identité ≠ couleur seule)
596 + if (spec.compare && main.scale) {
597 + ctx.textAlign = 'left';
598 + ctx.font = 'bold 11px system-ui, -apple-system, sans-serif';
599 + const used = [];
600 + for (const cs of spec.compare) {
601 + let li = i1;
602 + while (li >= i0 && cs.vals[li] == null) li--;
603 + if (li < i0) continue;
604 + let y = main.scale.Y(cs.vals[li]);
605 + y = Math.max(main.rect.y + 8, Math.min(main.rect.y + main.rect.h - 8, y));
606 + while (used.some(u => Math.abs(u - y) < 14)) y += 14;
607 + used.push(y);
608 + const tw = ctx.measureText(cs.label).width;
609 + const lx = Math.min(X(li) + 13, axisX - tw - 4); // jamais rogné par l'axe
610 + ctx.fillStyle = cs.color;
611 + ctx.beginPath();
612 + ctx.arc(lx - 7, y, 3.5, 0, Math.PI * 2);
613 + ctx.fill();
614 + ctx.fillText(cs.label, lx, y);
615 + }
616 + ctx.font = font;
617 + }
618 +
619 + this._renderOverlay();
620 + }
621 +
622 + _tickDecimals(ticks) {
623 + if (!ticks || ticks.length < 2) return this.spec.decimals;
624 + const step = Math.abs(ticks[1] - ticks[0]);
625 + if (step >= 1) return Math.min(this.spec.decimals, step >= 10 ? 0 : 1);
626 + return Math.min(6, Math.max(this.spec.decimals, Math.ceil(-Math.log10(step))));
627 + }
628 +
629 + _drawProfile(ctx, env, i0, i1) {
630 + const prof = HF.ind.volumeProfile(this.spec.items, i0, i1, 26);
631 + if (!prof) return;
632 + const r = env.rect;
633 + const th = this.th;
634 + const maxW = r.w * 0.22;
635 + prof.rows.forEach((row, k) => {
636 + const y0 = env.Y(row.p1), y1 = env.Y(row.p0);
637 + const h = Math.max(1, y1 - y0 - 1);
638 + const wUp = (row.up / prof.max) * maxW;
639 + const wDn = (row.dn / prof.max) * maxW;
640 + const xR = r.x + r.w;
641 + ctx.fillStyle = th.alpha(th.up, k === prof.poc ? 0.5 : 0.22);
642 + ctx.fillRect(xR - wUp - wDn, y0, wUp, h);
643 + ctx.fillStyle = th.alpha(th.dn, k === prof.poc ? 0.5 : 0.22);
644 + ctx.fillRect(xR - wDn, y0, wDn, h);
645 + });
646 + // ligne POC
647 + const poc = prof.rows[prof.poc];
648 + const yP = env.Y((poc.p0 + poc.p1) / 2);
649 + ctx.strokeStyle = th.alpha(th.ink, 0.4);
650 + ctx.setLineDash([5, 4]);
651 + ctx.beginPath();
652 + ctx.moveTo(r.x, Math.round(yP) + 0.5);
653 + ctx.lineTo(r.x + r.w, Math.round(yP) + 0.5);
654 + ctx.stroke();
655 + ctx.setLineDash([]);
656 + }
657 +
658 + /* ----- overlay : crosshair + chips ----- */
659 + _renderOverlay() {
660 + const ctx = this.over.getContext('2d');
661 + ctx.clearRect(0, 0, this.w, this.h);
662 + if (!this.cursor || !this.spec || !this.spec.items.length || !this.panes) {
663 + if (this.opts.onCrosshair) this.opts.onCrosshair(null, null);
664 + return;
665 + }
666 + const th = this.th;
667 + const { x, y } = this.cursor;
668 + const main = this.panes[0];
669 + const i = Math.max(0, Math.min(this.spec.items.length - 1, this._iAtX(x, main.rect)));
670 + const item = this.spec.items[i];
671 + const cx = Math.round(this._X(i)) + 0.5;
672 + const axisX = this.w - this.axisW;
673 + ctx.strokeStyle = th.alpha(th.ink, 0.45);
674 + ctx.setLineDash([4, 4]);
675 + ctx.lineWidth = 1;
676 + ctx.beginPath();
677 + ctx.moveTo(cx, 0);
678 + ctx.lineTo(cx, this.h - this.axisH);
679 + ctx.stroke();
680 + // ligne horizontale dans le pane sous le curseur
681 + const pane = this.panes.find(p => y >= p.rect.y && y <= p.rect.y + p.rect.h);
682 + if (pane) {
683 + ctx.beginPath();
684 + ctx.moveTo(0, Math.round(y) + 0.5);
685 + ctx.lineTo(axisX, Math.round(y) + 0.5);
686 + ctx.stroke();
687 + }
688 + ctx.setLineDash([]);
689 + ctx.font = '11px system-ui, -apple-system, sans-serif';
690 + ctx.textBaseline = 'middle';
691 + // chip prix
692 + if (pane && pane.kind === 'price' && pane.scale) {
693 + const s = pane.scale;
694 + let v;
695 + if (s.log) {
696 + const r = pane.rect;
697 + const span = Math.log(s.hi) - Math.log(s.lo) || 1;
698 + const p = span * 0.08;
699 + v = Math.exp((Math.log(s.lo) - p) + (1 - (y - r.y) / r.h) * (span + 2 * p));
700 + } else {
701 + const r = pane.rect;
702 + const span = s.hi - s.lo, p = span * 0.08;
703 + v = (s.lo - p) + (1 - (y - r.y) / r.h) * (span + 2 * p);
704 + }
705 + const label = fmtPrice(v, this.spec.decimals) + (this.spec.percent ? ' %' : '');
706 + ctx.save();
707 + ctx.shadowColor = 'rgba(0,0,0,0.22)';
708 + ctx.shadowBlur = 5;
709 + ctx.fillStyle = th.chip;
710 + const tw = ctx.measureText(label).width;
711 + HF.render.roundRect(ctx, axisX + 2, y - 9, Math.max(tw + 12, this.axisW - 6), 18, 4);
712 + ctx.fill();
713 + ctx.restore();
714 + ctx.fillStyle = th.chipText;
715 + ctx.textAlign = 'left';
716 + ctx.fillText(label, axisX + 8, y + 0.5);
717 + }
718 + // chip temps
719 + if (item) {
720 + const label = fmtTimeFull(item.t);
721 + const tw = ctx.measureText(label).width + 14;
722 + const bx = Math.max(2, Math.min(this.w - tw - 2, cx - tw / 2));
723 + ctx.save();
724 + ctx.shadowColor = 'rgba(0,0,0,0.22)';
725 + ctx.shadowBlur = 5;
726 + ctx.fillStyle = th.chip;
727 + HF.render.roundRect(ctx, bx, this.h - this.axisH + 2, tw, this.axisH - 5, 4);
728 + ctx.fill();
729 + ctx.restore();
730 + ctx.fillStyle = th.chipText;
731 + ctx.textAlign = 'center';
732 + ctx.fillText(label, bx + tw / 2, this.h - this.axisH / 2);
733 + // point sur les types "ligne"
734 + if (['line', 'step', 'area', 'baseline'].includes(this.spec.draw) && main.scale) {
735 + ctx.fillStyle = th.accent;
736 + ctx.beginPath();
737 + ctx.arc(this._X(i), main.scale.Y(item.c), 4, 0, Math.PI * 2);
738 + ctx.fill();
739 + ctx.strokeStyle = th.surface;
740 + ctx.lineWidth = 2;
741 + ctx.stroke();
742 + }
743 + }
744 + if (this.opts.onCrosshair) this.opts.onCrosshair(i, item);
745 + }
746 +
747 + /* ----- interactions ----- */
748 + _bindEvents() {
749 + const el = this.over;
750 + let drag = null;
751 + let pinch = null;
752 + const pts = new Map(); // pointerId → position (pinch tactile à 2 doigts)
753 + const pinchDist = () => {
754 + const [a, b] = [...pts.values()];
755 + return Math.max(12, Math.hypot(a.x - b.x, a.y - b.y));
756 + };
757 + el.addEventListener('pointerdown', e => {
758 + pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
759 + el.setPointerCapture(e.pointerId);
760 + if (pts.size === 2) {
761 + drag = null;
762 + const r = el.getBoundingClientRect();
763 + const [a, b] = [...pts.values()];
764 + const midX = (a.x + b.x) / 2 - r.left;
765 + const plotW = this.w - this.axisW;
766 + pinch = {
767 + dist: pinchDist(),
768 + count: this.view.count,
769 + frac: Math.max(0, Math.min(1, midX / plotW)),
770 + iMid: this.view.first + (midX / plotW) * this.view.count,
771 + };
772 + } else {
773 + drag = { x: e.clientX, first: this.view.first };
774 + el.style.cursor = 'grabbing';
775 + }
776 + });
777 + const endPointer = e => {
778 + pts.delete(e.pointerId);
779 + if (pts.size < 2) pinch = null;
780 + if (pts.size === 0) drag = null;
781 + el.style.cursor = 'crosshair';
782 + };
783 + el.addEventListener('pointerup', endPointer);
784 + el.addEventListener('pointercancel', endPointer);
785 + el.addEventListener('pointermove', e => {
786 + if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
787 + const r = el.getBoundingClientRect();
788 + this.cursor = { x: e.clientX - r.left, y: e.clientY - r.top };
789 + if (pinch && pts.size === 2) {
790 + const scale = pinch.dist / pinchDist();
791 + this.view.count = pinch.count * scale;
792 + this.view.first = pinch.iMid - pinch.frac * this.view.count;
793 + this._clampView();
794 + this.render();
795 + if (this.opts.onViewChange) this.opts.onViewChange(this.view);
796 + } else if (drag) {
797 + const bw = (this.w - this.axisW) / this.view.count;
798 + this.view.first = drag.first - (e.clientX - drag.x) / bw;
799 + this._clampView();
800 + this.render();
801 + if (this.opts.onViewChange) this.opts.onViewChange(this.view);
802 + } else {
803 + this._renderOverlay();
804 + }
805 + });
806 + el.addEventListener('pointerleave', () => {
807 + this.cursor = null;
808 + this._renderOverlay();
809 + });
810 + el.addEventListener('wheel', e => {
811 + e.preventDefault();
812 + if (!this.spec) return;
813 + const r = el.getBoundingClientRect();
814 + const x = e.clientX - r.left;
815 + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
816 + // pan horizontal (trackpad)
817 + const bw = (this.w - this.axisW) / this.view.count;
818 + this.view.first += e.deltaX / bw;
819 + } else {
820 + const factor = Math.exp(e.deltaY * 0.0016);
821 + const iAt = this.view.first + (x / (this.w - this.axisW)) * this.view.count;
822 + const newCount = this.view.count * factor;
823 + this.view.count = newCount;
824 + this.view.first = iAt - (x / (this.w - this.axisW)) * newCount;
825 + }
826 + this._clampView();
827 + this.render();
828 + if (this.opts.onViewChange) this.opts.onViewChange(this.view);
829 + }, { passive: false });
830 + el.addEventListener('dblclick', () => this.resetView());
831 + }
832 +
833 + destroy() {
834 + this._ro.disconnect();
835 + this.el.innerHTML = '';
836 + }
837 + }
838 +
839 + /* Mini-rendu statique pour la galerie (pas d'axes, pas d'interaction). */
840 + function mini(canvas, draw, items, th, extras) {
841 + const dpr = window.devicePixelRatio || 1;
842 + const w = canvas.clientWidth || 220, h = canvas.clientHeight || 110;
843 + canvas.width = w * dpr; canvas.height = h * dpr;
844 + const ctx = canvas.getContext('2d');
845 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
846 + ctx.clearRect(0, 0, w, h);
847 + if (!items || !items.length) return;
848 + let lo = Infinity, hi = -Infinity;
849 + for (const b of items) { lo = Math.min(lo, b.l); hi = Math.max(hi, b.h); }
850 + if (!(hi > lo)) return;
851 + const pad = (hi - lo) * 0.06;
852 + const rect = { x: 0, y: 0, w, h };
853 + const bw = w / items.length;
854 + const env = {
855 + ctx, items, i0: 0, i1: items.length - 1,
856 + X: i => (i + 0.5) * bw,
857 + Y: v => h - ((v - (lo - pad)) / ((hi - lo) + 2 * pad)) * h,
858 + rect, bw, th,
859 + anchor: extras && extras.anchor != null ? extras.anchor : items[0].c,
860 + };
861 + (HF.render[draw] || HF.render.candles)(env);
862 + }
863 +
864 + HF.HFChart = HFChart;
865 + HF.mini = mini;
866 + HF.fmt = { price: fmtPrice, vol: fmtVol, time: fmtTimeFull, MONTHS, DAYS };
867 +})();
added web/js/indicators.js +164 −0
@@ -0,0 +1,164 @@
1 +/* ============================================================================
2 + * HFChart — indicateurs techniques, calculés from scratch
3 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
4 + * ----------------------------------------------------------------------------
5 + * Toutes les fonctions retournent des tableaux alignés sur l'index des barres
6 + * (null avant la période de chauffe).
7 + * ==========================================================================*/
8 +(function () {
9 + 'use strict';
10 + const HF = (window.HF = window.HF || {});
11 +
12 + const closes = bars => bars.map(b => b.c);
13 +
14 + function sma(vals, n) {
15 + const out = new Array(vals.length).fill(null);
16 + let sum = 0;
17 + for (let i = 0; i < vals.length; i++) {
18 + sum += vals[i];
19 + if (i >= n) sum -= vals[i - n];
20 + if (i >= n - 1) out[i] = sum / n;
21 + }
22 + return out;
23 + }
24 +
25 + function ema(vals, n) {
26 + const out = new Array(vals.length).fill(null);
27 + const k = 2 / (n + 1);
28 + let prev = null, seed = 0;
29 + for (let i = 0; i < vals.length; i++) {
30 + if (i < n - 1) { seed += vals[i]; continue; }
31 + if (i === n - 1) { prev = (seed + vals[i]) / n; out[i] = prev; continue; }
32 + prev = vals[i] * k + prev * (1 - k);
33 + out[i] = prev;
34 + }
35 + return out;
36 + }
37 +
38 + function bollinger(bars, n, mult) {
39 + n = n || 20; mult = mult || 2;
40 + const c = closes(bars);
41 + const mid = sma(c, n);
42 + const up = new Array(c.length).fill(null);
43 + const lo = new Array(c.length).fill(null);
44 + let sum = 0, sumSq = 0;
45 + for (let i = 0; i < c.length; i++) {
46 + sum += c[i]; sumSq += c[i] * c[i];
47 + if (i >= n) { sum -= c[i - n]; sumSq -= c[i - n] * c[i - n]; }
48 + if (i >= n - 1) {
49 + const mean = sum / n;
50 + const sd = Math.sqrt(Math.max(0, sumSq / n - mean * mean));
51 + up[i] = mean + mult * sd;
52 + lo[i] = mean - mult * sd;
53 + }
54 + }
55 + return { mid, up, lo };
56 + }
57 +
58 + function rsi(bars, n) {
59 + n = n || 14;
60 + const c = closes(bars);
61 + const out = new Array(c.length).fill(null);
62 + let avgG = 0, avgL = 0;
63 + for (let i = 1; i < c.length; i++) {
64 + const d = c[i] - c[i - 1];
65 + const g = d > 0 ? d : 0, l = d < 0 ? -d : 0;
66 + if (i <= n) {
67 + avgG += g / n; avgL += l / n;
68 + if (i === n) out[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
69 + } else {
70 + avgG = (avgG * (n - 1) + g) / n;
71 + avgL = (avgL * (n - 1) + l) / n;
72 + out[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
73 + }
74 + }
75 + return out;
76 + }
77 +
78 + function macd(bars, fast, slow, signal) {
79 + fast = fast || 12; slow = slow || 26; signal = signal || 9;
80 + const c = closes(bars);
81 + const ef = ema(c, fast), es = ema(c, slow);
82 + const line = c.map((_, i) => (ef[i] != null && es[i] != null) ? ef[i] - es[i] : null);
83 + // EMA du signal sur la portion définie
84 + const start = line.findIndex(v => v != null);
85 + const sig = new Array(c.length).fill(null);
86 + if (start >= 0) {
87 + const seg = ema(line.slice(start), signal);
88 + for (let i = 0; i < seg.length; i++) sig[start + i] = seg[i];
89 + }
90 + const hist = line.map((v, i) => (v != null && sig[i] != null) ? v - sig[i] : null);
91 + return { line, signal: sig, hist };
92 + }
93 +
94 + function atr(bars, n) {
95 + n = n || 14;
96 + const out = new Array(bars.length).fill(null);
97 + let prev = null;
98 + for (let i = 1; i < bars.length; i++) {
99 + const b = bars[i], p = bars[i - 1];
100 + const tr = Math.max(b.h - b.l, Math.abs(b.h - p.c), Math.abs(b.l - p.c));
101 + if (i <= n) {
102 + prev = (prev || 0) + tr / n;
103 + if (i === n) out[i] = prev;
104 + } else {
105 + prev = (prev * (n - 1) + tr) / n;
106 + out[i] = prev;
107 + }
108 + }
109 + return out;
110 + }
111 +
112 + /* VWAP de session (reset à chaque journée) + bandes ±1σ / ±2σ.
113 + * Nécessite du volume — désactivé pour les indices. */
114 + function vwapSession(bars) {
115 + const v = new Array(bars.length).fill(null);
116 + const up1 = v.slice(), dn1 = v.slice(), up2 = v.slice(), dn2 = v.slice();
117 + let day = null, cumV = 0, cumPV = 0, cumP2V = 0;
118 + for (let i = 0; i < bars.length; i++) {
119 + const b = bars[i];
120 + const d = Math.floor(b.t / 86400);
121 + if (d !== day) { day = d; cumV = 0; cumPV = 0; cumP2V = 0; }
122 + const tp = (b.h + b.l + b.c) / 3;
123 + cumV += b.v; cumPV += tp * b.v; cumP2V += tp * tp * b.v;
124 + if (cumV > 0) {
125 + const w = cumPV / cumV;
126 + const sd = Math.sqrt(Math.max(0, cumP2V / cumV - w * w));
127 + v[i] = w; up1[i] = w + sd; dn1[i] = w - sd; up2[i] = w + 2 * sd; dn2[i] = w - 2 * sd;
128 + }
129 + }
130 + return { v, up1, dn1, up2, dn2 };
131 + }
132 +
133 + /* Profil de volume sur une plage d'items visibles.
134 + * Retourne {rows:[{p0,p1,up,dn,total}], max, poc} — poc = index de la ligne max. */
135 + function volumeProfile(bars, i0, i1, nRows) {
136 + nRows = nRows || 24;
137 + let lo = Infinity, hi = -Infinity;
138 + for (let i = i0; i <= i1; i++) { const b = bars[i]; if (!b) continue; lo = Math.min(lo, b.l); hi = Math.max(hi, b.h); }
139 + if (!(hi > lo)) return null;
140 + const rows = Array.from({ length: nRows }, (_, k) => ({
141 + p0: lo + (k / nRows) * (hi - lo), p1: lo + ((k + 1) / nRows) * (hi - lo), up: 0, dn: 0, total: 0,
142 + }));
143 + const step = (hi - lo) / nRows;
144 + for (let i = i0; i <= i1; i++) {
145 + const b = bars[i];
146 + if (!b || !(b.v > 0)) continue;
147 + // volume réparti uniformément sur la plage h..l de la barre
148 + const span = Math.max(b.h - b.l, step * 0.5);
149 + const k0 = Math.max(0, Math.floor((b.l - lo) / step));
150 + const k1 = Math.min(nRows - 1, Math.floor((b.h - lo) / step));
151 + const per = b.v / (k1 - k0 + 1);
152 + const isUp = b.c >= b.o;
153 + for (let k = k0; k <= k1; k++) {
154 + rows[k].total += per;
155 + if (isUp) rows[k].up += per; else rows[k].dn += per;
156 + }
157 + }
158 + let max = 0, poc = 0;
159 + rows.forEach((r, k) => { if (r.total > max) { max = r.total; poc = k; } });
160 + return { rows, max, poc };
161 + }
162 +
163 + HF.ind = { sma, ema, bollinger, rsi, macd, atr, vwapSession, volumeProfile, closes };
164 +})();
added web/js/renderers.js +348 −0
@@ -0,0 +1,348 @@
1 +/* ============================================================================
2 + * HFChart — renderers canvas, from scratch
3 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
4 + * ==========================================================================*/
5 +/* Chaque renderer reçoit un environnement :
6 + * { ctx, items, i0, i1, X(i)→px centre, Y(prix)→px, rect, bw, th }
7 + * th = tokens du thème (up, dn, ink, grid, series[], surface, …).
8 + * Specs de marques : corps fins avec 2px d'écart entre barres adjacentes,
9 + * lignes 2px, extrémités arrondies discrètes. */
10 +(function () {
11 + 'use strict';
12 + const HF = (window.HF = window.HF || {});
13 +
14 + function bodyWidth(bw) {
15 + // écart de 2px garanti entre corps adjacents dès que la place existe
16 + return Math.max(1, Math.min(bw - 2, Math.round(bw * 0.8)));
17 + }
18 +
19 + function roundRect(ctx, x, y, w, h, r) {
20 + if (h < 0) { y += h; h = -h; }
21 + r = Math.min(r, w / 2, h / 2);
22 + if (r < 0.5) { ctx.rect(x, y, w, h); return; }
23 + ctx.moveTo(x + r, y);
24 + ctx.arcTo(x + w, y, x + w, y + h, r);
25 + ctx.arcTo(x + w, y + h, x, y + h, r);
26 + ctx.arcTo(x, y + h, x, y, r);
27 + ctx.arcTo(x, y, x + w, y, r);
28 + ctx.closePath();
29 + }
30 +
31 + /* Fast path haute densité : 1 trait hi-lo par barre quand bw < 2px. */
32 + function denseHiLo(env) {
33 + const { ctx, items, i0, i1, X, Y, th } = env;
34 + ctx.lineWidth = 1;
35 + for (const up of [true, false]) {
36 + ctx.strokeStyle = up ? th.up : th.dn;
37 + ctx.beginPath();
38 + for (let i = i0; i <= i1; i++) {
39 + const b = items[i];
40 + if (!b || (b.c >= b.o) !== up) continue;
41 + const x = Math.round(X(i)) + 0.5;
42 + ctx.moveTo(x, Y(b.h));
43 + ctx.lineTo(x, Y(b.l));
44 + }
45 + ctx.stroke();
46 + }
47 + }
48 +
49 + function candles(env, hollow) {
50 + const { ctx, items, i0, i1, X, Y, bw, th } = env;
51 + if (bw < 2) return denseHiLo(env);
52 + const w = bodyWidth(bw);
53 + const wick = bw >= 10 ? 1.5 : 1;
54 + const r = w >= 6 ? 1.5 : 0;
55 + for (const up of [true, false]) {
56 + const col = up ? th.up : th.dn;
57 + // mèches
58 + ctx.strokeStyle = col;
59 + ctx.lineWidth = wick;
60 + ctx.beginPath();
61 + for (let i = i0; i <= i1; i++) {
62 + const b = items[i];
63 + if (!b || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue;
64 + const x = Math.round(X(i)) + (wick === 1 ? 0.5 : 0);
65 + const top = Math.max(b.o, b.c), bot = Math.min(b.o, b.c);
66 + if (b.h > top) { ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(top)); }
67 + if (b.l < bot) { ctx.moveTo(x, Y(bot)); ctx.lineTo(x, Y(b.l)); }
68 + }
69 + ctx.stroke();
70 + // corps
71 + ctx.beginPath();
72 + for (let i = i0; i <= i1; i++) {
73 + const b = items[i];
74 + if (!b || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue;
75 + const x = Math.round(X(i) - w / 2);
76 + const y0 = Y(b.o), y1 = Y(b.c);
77 + const h = Math.abs(y1 - y0) < 1 ? 1 : y1 - y0;
78 + roundRect(ctx, x, y0, w, h, r);
79 + }
80 + if (hollow && up) {
81 + ctx.strokeStyle = col;
82 + ctx.lineWidth = Math.min(2, Math.max(1, w / 5));
83 + ctx.stroke();
84 + } else {
85 + ctx.fillStyle = col;
86 + ctx.fill();
87 + }
88 + }
89 + }
90 +
91 + function ohlcBars(env) {
92 + const { ctx, items, i0, i1, X, Y, bw, th } = env;
93 + if (bw < 3) return denseHiLo(env);
94 + const tick = Math.max(2, Math.min(bw * 0.4, 8));
95 + ctx.lineWidth = bw >= 8 ? 1.5 : 1;
96 + for (const up of [true, false]) {
97 + ctx.strokeStyle = up ? th.up : th.dn;
98 + ctx.beginPath();
99 + for (let i = i0; i <= i1; i++) {
100 + const b = items[i];
101 + if (!b || (b.c >= b.o) !== up) continue;
102 + const x = Math.round(X(i)) + 0.5;
103 + ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(b.l));
104 + ctx.moveTo(x - tick, Y(b.o)); ctx.lineTo(x, Y(b.o));
105 + ctx.moveTo(x, Y(b.c)); ctx.lineTo(x + tick, Y(b.c));
106 + }
107 + ctx.stroke();
108 + }
109 + }
110 +
111 + function linePath(env, color, width) {
112 + const { ctx, items, i0, i1, X, Y } = env;
113 + ctx.strokeStyle = color;
114 + ctx.lineWidth = width || 2;
115 + ctx.lineJoin = 'round';
116 + ctx.lineCap = 'round';
117 + ctx.beginPath();
118 + let started = false;
119 + for (let i = i0; i <= i1; i++) {
120 + const b = items[i];
121 + if (!b) continue;
122 + const x = X(i), y = Y(b.c);
123 + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
124 + }
125 + ctx.stroke();
126 + }
127 +
128 + function line(env) { linePath(env, env.th.accent, 2); }
129 +
130 + function step(env) {
131 + const { ctx, items, i0, i1, X, Y, th } = env;
132 + ctx.strokeStyle = th.accent;
133 + ctx.lineWidth = 2;
134 + ctx.lineJoin = 'round';
135 + ctx.beginPath();
136 + let started = false, py = 0;
137 + for (let i = i0; i <= i1; i++) {
138 + const b = items[i];
139 + if (!b) continue;
140 + const x = X(i), y = Y(b.c);
141 + if (!started) { ctx.moveTo(x, y); started = true; }
142 + else { ctx.lineTo(x, py); ctx.lineTo(x, y); }
143 + py = y;
144 + }
145 + ctx.stroke();
146 + }
147 +
148 + function area(env) {
149 + const { ctx, items, i0, i1, X, Y, rect, th } = env;
150 + const grad = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h);
151 + grad.addColorStop(0, th.alpha(th.accent, 0.30));
152 + grad.addColorStop(0.55, th.alpha(th.accent, 0.10));
153 + grad.addColorStop(1, th.alpha(th.accent, 0));
154 + ctx.beginPath();
155 + let started = false, lastX = 0, firstX = 0;
156 + for (let i = i0; i <= i1; i++) {
157 + const b = items[i];
158 + if (!b) continue;
159 + const x = X(i), y = Y(b.c);
160 + if (!started) { ctx.moveTo(x, y); firstX = x; started = true; } else ctx.lineTo(x, y);
161 + lastX = x;
162 + }
163 + if (!started) return;
164 + ctx.lineTo(lastX, rect.y + rect.h);
165 + ctx.lineTo(firstX, rect.y + rect.h);
166 + ctx.closePath();
167 + ctx.fillStyle = grad;
168 + ctx.fill();
169 + linePath(env, th.accent, 2);
170 + }
171 +
172 + /* Baseline : ancre = close de la première barre chargée (env.anchor).
173 + * Au-dessus → up, en dessous → dn (aires + segments de ligne). */
174 + function baseline(env) {
175 + const { ctx, items, i0, i1, X, Y, rect, th } = env;
176 + const anchor = env.anchor;
177 + const yA = Math.max(rect.y, Math.min(rect.y + rect.h, Y(anchor)));
178 + for (const side of ['up', 'dn']) {
179 + ctx.save();
180 + ctx.beginPath();
181 + if (side === 'up') ctx.rect(rect.x, rect.y, rect.w, yA - rect.y);
182 + else ctx.rect(rect.x, yA, rect.w, rect.y + rect.h - yA);
183 + ctx.clip();
184 + const col = side === 'up' ? th.up : th.dn;
185 + const grad = side === 'up'
186 + ? ctx.createLinearGradient(0, rect.y, 0, yA)
187 + : ctx.createLinearGradient(0, yA, 0, rect.y + rect.h);
188 + grad.addColorStop(side === 'up' ? 0 : 1, th.alpha(col, 0.28));
189 + grad.addColorStop(side === 'up' ? 1 : 0, th.alpha(col, 0.02));
190 + // aire fermée sur la baseline
191 + ctx.beginPath();
192 + let started = false, lastX = 0, firstX = 0;
193 + for (let i = i0; i <= i1; i++) {
194 + const b = items[i];
195 + if (!b) continue;
196 + const x = X(i), y = Y(b.c);
197 + if (!started) { ctx.moveTo(x, y); firstX = x; started = true; } else ctx.lineTo(x, y);
198 + lastX = x;
199 + }
200 + if (started) {
201 + ctx.lineTo(lastX, yA); ctx.lineTo(firstX, yA); ctx.closePath();
202 + ctx.fillStyle = grad; ctx.fill();
203 + linePath(env, col, 2);
204 + }
205 + ctx.restore();
206 + }
207 + // trait d'ancre
208 + ctx.strokeStyle = th.baselineAxis;
209 + ctx.setLineDash([4, 4]);
210 + ctx.lineWidth = 1;
211 + ctx.beginPath();
212 + ctx.moveTo(rect.x, Math.round(yA) + 0.5);
213 + ctx.lineTo(rect.x + rect.w, Math.round(yA) + 0.5);
214 + ctx.stroke();
215 + ctx.setLineDash([]);
216 + }
217 +
218 + /* Briques pleines (Renko, Line Break, Range) — chandelles sans mèche. */
219 + function bricks(env) { candles(env, false); }
220 +
221 + function kagiDraw(env) {
222 + const { ctx, items, i0, i1, X, Y, th } = env;
223 + for (const thick of [false, true]) {
224 + ctx.strokeStyle = thick ? th.up : th.dn;
225 + ctx.lineWidth = thick ? 2.5 : 1.25;
226 + ctx.lineJoin = 'miter';
227 + ctx.beginPath();
228 + for (let i = Math.max(1, i0); i <= i1; i++) {
229 + const b = items[i];
230 + if (!b || b.up !== thick) continue;
231 + const x = Math.round(X(i)) + 0.5;
232 + const xPrev = Math.round(X(i - 1)) + 0.5;
233 + ctx.moveTo(xPrev, Y(b.o)); // épaule horizontale depuis la ligne précédente
234 + ctx.lineTo(x, Y(b.o));
235 + ctx.lineTo(x, Y(b.c)); // ligne verticale
236 + }
237 + const first = items[i0];
238 + if (first && first.up === thick && i0 === 0) {
239 + ctx.moveTo(Math.round(X(0)) + 0.5, Y(first.o));
240 + ctx.lineTo(Math.round(X(0)) + 0.5, Y(first.c));
241 + }
242 + ctx.stroke();
243 + }
244 + }
245 +
246 + function pnfDraw(env) {
247 + const { ctx, items, i0, i1, X, Y, bw, th } = env;
248 + if (bw < 3) return denseHiLo(env);
249 + const w = bodyWidth(bw);
250 + ctx.lineWidth = Math.max(1, Math.min(2, w / 6));
251 + for (let i = i0; i <= i1; i++) {
252 + const col = items[i];
253 + if (!col || !col.box) continue;
254 + const x = X(i);
255 + const glyphW = Math.min(w, Math.abs(Y(col.lo) - Y(col.lo + col.box)) * 0.9);
256 + ctx.strokeStyle = col.type === 'X' ? th.up : th.dn;
257 + ctx.beginPath();
258 + for (let k = 0; k < col.n; k++) {
259 + const p0 = col.lo + k * col.box;
260 + const yB = Y(p0), yT = Y(p0 + col.box);
261 + const half = Math.min(glyphW / 2, Math.abs(yB - yT) / 2 - 1);
262 + if (half < 1) { // trop dense : trait plein
263 + ctx.moveTo(x, yB); ctx.lineTo(x, yT);
264 + continue;
265 + }
266 + const cy = (yB + yT) / 2;
267 + if (col.type === 'X') {
268 + ctx.moveTo(x - half, cy + half); ctx.lineTo(x + half, cy - half);
269 + ctx.moveTo(x - half, cy - half); ctx.lineTo(x + half, cy + half);
270 + } else {
271 + ctx.moveTo(x + half, cy);
272 + ctx.arc(x, cy, half, 0, Math.PI * 2);
273 + }
274 + }
275 + ctx.stroke();
276 + }
277 + }
278 +
279 + /* Colonnes de volume, colorées par direction de la barre (alpha pour rester
280 + * une couche secondaire), 2px d'écart. */
281 + function volumeColumns(env, maxV) {
282 + const { ctx, items, i0, i1, X, rect, bw, th } = env;
283 + const w = Math.max(1, Math.min(bw - 2, Math.round(bw * 0.8)));
284 + const y0 = rect.y + rect.h;
285 + for (const up of [true, false]) {
286 + // dégradé vertical : plus dense à la base, léger vers le haut du pane
287 + const col = up ? th.up : th.dn;
288 + const grad = ctx.createLinearGradient(0, rect.y, 0, y0);
289 + grad.addColorStop(0, th.alpha(col, 0.28));
290 + grad.addColorStop(1, th.alpha(col, 0.6));
291 + ctx.fillStyle = grad;
292 + ctx.beginPath();
293 + for (let i = i0; i <= i1; i++) {
294 + const b = items[i];
295 + if (!b || !(b.v > 0) || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue;
296 + const h = Math.max(1, (b.v / maxV) * (rect.h - 4));
297 + roundRect(ctx, Math.round(X(i) - w / 2), y0 - h, w, h, w >= 6 ? 2 : 0);
298 + }
299 + ctx.fill();
300 + }
301 + }
302 +
303 + /* Ligne d'indicateur (overlay ou pane) sur un tableau de valeurs alignées. */
304 + function indicatorLine(env, vals, color, width, dash) {
305 + const { ctx, i0, i1, X, Y } = env;
306 + ctx.strokeStyle = color;
307 + ctx.lineWidth = width || 2;
308 + ctx.lineJoin = 'round';
309 + if (dash) ctx.setLineDash(dash);
310 + ctx.beginPath();
311 + let started = false;
312 + for (let i = i0; i <= i1; i++) {
313 + const v = vals[i];
314 + if (v == null) { continue; }
315 + const x = X(i), y = Y(v);
316 + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
317 + }
318 + ctx.stroke();
319 + if (dash) ctx.setLineDash([]);
320 + }
321 +
322 + /* Remplissage entre deux tableaux (bandes Bollinger / VWAP). */
323 + function bandFill(env, upVals, loVals, color) {
324 + const { ctx, i0, i1, X, Y } = env;
325 + ctx.fillStyle = color;
326 + ctx.beginPath();
327 + let started = false;
328 + const pts = [];
329 + for (let i = i0; i <= i1; i++) {
330 + if (upVals[i] == null || loVals[i] == null) continue;
331 + const x = X(i);
332 + if (!started) { ctx.moveTo(x, Y(upVals[i])); started = true; } else ctx.lineTo(x, Y(upVals[i]));
333 + pts.push([x, Y(loVals[i])]);
334 + }
335 + for (let k = pts.length - 1; k >= 0; k--) ctx.lineTo(pts[k][0], pts[k][1]);
336 + if (started) { ctx.closePath(); ctx.fill(); }
337 + }
338 +
339 + HF.render = {
340 + candles: env => candles(env, false),
341 + hollow: env => candles(env, true),
342 + ohlc: ohlcBars,
343 + line, step, area, baseline, bricks,
344 + kagi: kagiDraw,
345 + pnf: pnfDraw,
346 + volumeColumns, indicatorLine, bandFill, linePath, roundRect, denseHiLo,
347 + };
348 +})();
added web/js/transforms.js +208 −0
@@ -0,0 +1,208 @@
1 +/* ============================================================================
2 + * HFChart — transformations de séries (from scratch, zéro dépendance)
3 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
4 + * ----------------------------------------------------------------------------
5 + * Un "bar" = {t,o,h,l,c,v}. Chaque transform retourne un tableau de barres
6 + * pseudo-temporelles ; l'axe X reste l'index de barre, comme sur tout chart HF.
7 + * ==========================================================================*/
8 +(function () {
9 + 'use strict';
10 + const HF = (window.HF = window.HF || {});
11 +
12 + /* Arrondi "joli" (1 / 2 / 2.5 / 5 × 10^n) — sert de taille de brique auto. */
13 + function niceRound(x) {
14 + if (!(x > 0)) return 1;
15 + const p = Math.pow(10, Math.floor(Math.log10(x)));
16 + const m = x / p;
17 + const step = m < 1.5 ? 1 : m < 2.25 ? 2 : m < 3.5 ? 2.5 : m < 7.5 ? 5 : 10;
18 + return step * p;
19 + }
20 +
21 + /* Brique auto façon ATR : moyenne des true ranges des ~500 dernières barres. */
22 + function autoBrick(bars, k) {
23 + const n = bars.length;
24 + if (n < 3) return niceRound((n ? bars[n - 1].c : 1) * 0.005);
25 + let sum = 0, cnt = 0;
26 + for (let i = Math.max(1, n - 500); i < n; i++) {
27 + const b = bars[i], p = bars[i - 1];
28 + sum += Math.max(b.h - b.l, Math.abs(b.h - p.c), Math.abs(b.l - p.c));
29 + cnt++;
30 + }
31 + return niceRound((sum / cnt) * (k || 1)) || niceRound(bars[n - 1].c * 0.002);
32 + }
33 +
34 + function heikinAshi(bars) {
35 + const out = new Array(bars.length);
36 + let po = 0, pc = 0;
37 + for (let i = 0; i < bars.length; i++) {
38 + const b = bars[i];
39 + const c = (b.o + b.h + b.l + b.c) / 4;
40 + const o = i === 0 ? (b.o + b.c) / 2 : (po + pc) / 2;
41 + out[i] = { t: b.t, o, h: Math.max(b.h, o, c), l: Math.min(b.l, o, c), c, v: b.v };
42 + po = o; pc = c;
43 + }
44 + return out;
45 + }
46 +
47 + /* Renko : briques de taille fixe, renversement = 2 briques. */
48 + function renko(bars, opts) {
49 + const brick = (opts && opts.brick) || autoBrick(bars, 1);
50 + const out = [];
51 + if (!bars.length || !(brick > 0)) return out;
52 + let lo = Math.floor(bars[0].c / brick) * brick; // plancher de la brique courante
53 + let hi = lo + brick;
54 + let dir = 0, vol = 0;
55 + const push = (t, up) => {
56 + if (up) { out.push({ t, o: hi, c: hi + brick, h: hi + brick, l: hi, v: vol, up: true }); lo = hi; hi += brick; dir = 1; }
57 + else { out.push({ t, o: lo, c: lo - brick, h: lo, l: lo - brick, v: vol, up: false }); hi = lo; lo -= brick; dir = -1; }
58 + vol = 0;
59 + };
60 + for (const b of bars) {
61 + vol += b.v;
62 + for (;;) {
63 + if (b.c >= hi + brick - 1e-9 && dir >= 0) push(b.t, true);
64 + else if (b.c <= lo - brick + 1e-9 && dir <= 0) push(b.t, false);
65 + else if (dir > 0 && b.c <= lo - brick + 1e-9) push(b.t, false); // renversement (2 briques sous hi)
66 + else if (dir < 0 && b.c >= hi + brick - 1e-9) push(b.t, true); // renversement haussier
67 + else break;
68 + }
69 + }
70 + return out;
71 + }
72 +
73 + /* Three-Line Break : nouvelle barre seulement si le close casse les N dernières. */
74 + function lineBreak(bars, opts) {
75 + const nBreak = (opts && opts.lines) || 3;
76 + const out = [];
77 + for (const b of bars) {
78 + if (!out.length) {
79 + if (b.c !== b.o) out.push({ t: b.t, o: b.o, c: b.c, h: Math.max(b.o, b.c), l: Math.min(b.o, b.c), v: b.v, up: b.c >= b.o });
80 + continue;
81 + }
82 + const last = out[out.length - 1];
83 + const win = out.slice(-nBreak);
84 + let maxC = -Infinity, minC = Infinity;
85 + for (const x of win) { maxC = Math.max(maxC, x.o, x.c); minC = Math.min(minC, x.o, x.c); }
86 + if (b.c > maxC + 1e-9) {
87 + const o = Math.max(last.o, last.c);
88 + out.push({ t: b.t, o, c: b.c, h: b.c, l: o, v: b.v, up: true });
89 + } else if (b.c < minC - 1e-9) {
90 + const o = Math.min(last.o, last.c);
91 + out.push({ t: b.t, o, c: b.c, h: o, l: b.c, v: b.v, up: false });
92 + }
93 + }
94 + return out;
95 + }
96 +
97 + /* Range Bars : chaque barre couvre exactement `range` de haut en bas. */
98 + function rangeBars(bars, opts) {
99 + const range = (opts && opts.range) || autoBrick(bars, 1.5);
100 + const out = [];
101 + if (!bars.length || !(range > 0)) return out;
102 + let cur = null;
103 + for (const b of bars) {
104 + for (const p of [b.o, b.h, b.l, b.c]) {
105 + if (!cur) { cur = { t: b.t, o: p, h: p, l: p, c: p, v: 0 }; continue; }
106 + cur.h = Math.max(cur.h, p); cur.l = Math.min(cur.l, p);
107 + cur.c = p; cur.t = b.t;
108 + if (cur.h - cur.l >= range - 1e-9) {
109 + const up = cur.c >= cur.o;
110 + const h = cur.l + range, l = cur.l;
111 + const hh = up ? h : cur.h, ll = up ? cur.l : cur.h - range;
112 + const close = up ? hh : ll;
113 + out.push({ t: cur.t, o: cur.o, h: hh, l: ll, c: close, v: cur.v + b.v, up });
114 + cur = { t: b.t, o: close, h: close, l: close, c: close, v: 0 };
115 + }
116 + }
117 + if (cur) cur.v += b.v;
118 + }
119 + return out;
120 + }
121 +
122 + /* Kagi : item = une ligne verticale {o: départ, c: arrivée, up: yang (épaisse)}.
123 + * Passe yang au-dessus de l'épaule précédente, yin sous la taille précédente. */
124 + function kagi(bars, opts) {
125 + const rev = (opts && opts.reversal) || autoBrick(bars, 3);
126 + const out = [];
127 + if (!bars.length || !(rev > 0)) return out;
128 + let dir = 0, anchor = bars[0].c, extreme = bars[0].c, yang = true;
129 + let prevShoulder = null, prevWaist = null;
130 + for (const b of bars) {
131 + const p = b.c;
132 + if (dir === 0) {
133 + extreme = p;
134 + if (p >= anchor + rev) dir = 1;
135 + else if (p <= anchor - rev) dir = -1;
136 + continue;
137 + }
138 + if (dir > 0) {
139 + if (p > extreme) extreme = p;
140 + if (extreme - p >= rev) {
141 + if (prevShoulder != null && extreme > prevShoulder) yang = true;
142 + out.push({ t: b.t, o: anchor, c: extreme, h: extreme, l: anchor, v: 0, up: yang });
143 + prevShoulder = extreme;
144 + anchor = extreme; extreme = p; dir = -1;
145 + }
146 + } else {
147 + if (p < extreme) extreme = p;
148 + if (p - extreme >= rev) {
149 + if (prevWaist != null && extreme < prevWaist) yang = false;
150 + out.push({ t: b.t, o: anchor, c: extreme, h: anchor, l: extreme, v: 0, up: yang });
151 + prevWaist = extreme;
152 + anchor = extreme; extreme = p; dir = 1;
153 + }
154 + }
155 + }
156 + if (dir !== 0 && extreme !== anchor) {
157 + out.push({ t: bars[bars.length - 1].t, o: anchor, c: extreme,
158 + h: Math.max(anchor, extreme), l: Math.min(anchor, extreme), v: 0, up: yang });
159 + }
160 + return out;
161 + }
162 +
163 + /* Point & Figure : colonnes de X (hausse) / O (baisse), renversement N boîtes.
164 + * item = {type:'X'|'O', lo, n, box} → n boîtes empilées à partir de lo. */
165 + function pnf(bars, opts) {
166 + const box = (opts && opts.box) || autoBrick(bars, 1);
167 + const revN = (opts && opts.reversal) || 3;
168 + const cols = [];
169 + if (!bars.length || !(box > 0)) return cols;
170 + const fb = p => Math.floor(p / box + 1e-9);
171 + let dir = 0, top = fb(bars[0].c), bot = top;
172 + const pushCol = (t, type) => {
173 + cols.push({
174 + t, type, lo: bot * box, n: top - bot + 1, box,
175 + o: type === 'X' ? bot * box : (top + 1) * box,
176 + c: type === 'X' ? (top + 1) * box : bot * box,
177 + h: (top + 1) * box, l: bot * box, v: 0, up: type === 'X',
178 + });
179 + };
180 + for (const b of bars) {
181 + const hiB = fb(b.h), loB = fb(b.l);
182 + if (dir === 0) {
183 + if (hiB > top) { dir = 1; top = hiB; }
184 + else if (loB < bot) { dir = -1; bot = loB; }
185 + continue;
186 + }
187 + if (dir > 0) {
188 + if (hiB > top) top = hiB;
189 + else if (top - loB >= revN) {
190 + pushCol(b.t, 'X');
191 + const newTop = top - 1;
192 + bot = loB; top = newTop; dir = -1;
193 + }
194 + } else {
195 + if (loB < bot) bot = loB;
196 + else if (hiB - bot >= revN) {
197 + pushCol(b.t, 'O');
198 + const newBot = bot + 1;
199 + top = hiB; bot = newBot; dir = 1;
200 + }
201 + }
202 + }
203 + if (dir !== 0 && top >= bot) pushCol(bars[bars.length - 1].t, dir > 0 ? 'X' : 'O');
204 + return cols;
205 + }
206 +
207 + HF.transforms = { heikinAshi, renko, lineBreak, rangeBars, kagi, pnf, autoBrick, niceRound };
208 +})();
209