charts: contrat du moteur de charts (API, thème, exigences de rendu)
1 changed file +145 −0
added
hfmarketdata/web/src/charts/CONTRACT.md
+145 −0
@@ -0,0 +1,145 @@ | ||
| 1 | +# Contrat moteur de charts hfmarketdata — `hfmarketdata/web/src/charts/engine/index.js` | |
| 2 | + | |
| 3 | +Moteur de graphiques financiers **écrit de zéro** (Canvas 2D, ES modules, ZÉRO dépendance : pas de lightweight-charts, | |
| 4 | +pas de d3, pas de code TradingView ni de copie d'un projet existant). Le fichier `index.js` exporte `createChart`. | |
| 5 | +Tout le code, les noms, la doc et les commentaires sont en anglais (produit anglais) ; commits en français. | |
| 6 | + | |
| 7 | +## Données | |
| 8 | + | |
| 9 | +```ts | |
| 10 | +type Bar = { t: number; o: number; h: number; l: number; c: number; v?: number; oi?: number } | |
| 11 | +// t = millisecondes. Les timestamps de l'API sont des heures murales US/Eastern naïves ("2017-07-11 09:30:00") | |
| 12 | +// ou des dates ("2024-01-02") : l'appelant les convertit avec Date.UTC(y, m-1, d, H, M, S) et le moteur formate | |
| 13 | +// TOUJOURS avec les getters UTC (getUTCHours…) → les libellés affichent l'heure murale ET sans conversion. | |
| 14 | +// Les barres sont triées par t croissant, sans doublon. L'axe X est INDEXÉ (une barre = un pas), pas linéaire en | |
| 15 | +// temps : les trous de session (nuit, week-end) ne laissent pas de blanc, comme sur les terminaux pros. | |
| 16 | +``` | |
| 17 | + | |
| 18 | +## API | |
| 19 | + | |
| 20 | +```ts | |
| 21 | +createChart(container: HTMLElement, options?: ChartOptions): Chart | |
| 22 | + | |
| 23 | +ChartOptions = { | |
| 24 | + theme: Theme, | |
| 25 | + timeframe: '1min' | '5min' | '30min' | '1hour' | '1day', | |
| 26 | + priceFormat?: { decimals?: number | 'auto', minMove?: number }, // 'auto' = déduit des données | |
| 27 | + locale?: string, // Intl pour les nombres (défaut 'en-US') | |
| 28 | + sessionLabel?: string, // ex. 'ET' affiché à côté de l'heure dans l'axe/tooltip | |
| 29 | + watermark?: string, // texte discret centré (ex. "AAPL · 1D · HF Market Data") | |
| 30 | + rightOffsetBars?: number, // espace vide à droite (défaut 8) | |
| 31 | + barSpacing?: number, // px par barre au départ (défaut 8) | |
| 32 | + minBarSpacing?: number, // défaut 0.5 (rendu "ligne" automatique sous 2 px) | |
| 33 | + maxBars?: number, // garde mémoire (défaut 500 000) | |
| 34 | + reducedMotion?: boolean, // désactive les animations | |
| 35 | +} | |
| 36 | + | |
| 37 | +Theme = { | |
| 38 | + bg: string, paneBorder: string, grid: string, gridStrong: string, | |
| 39 | + axisText: string, axisLine: string, crosshair: string, crosshairLabelBg: string, crosshairLabelText: string, | |
| 40 | + up: string, down: string, upWick?: string, downWick?: string, neutral: string, | |
| 41 | + volumeUp: string, volumeDown: string, // avec alpha | |
| 42 | + series: string[], // 8 couleurs catégorielles validées (indicateurs, comparaisons), ordre fixe | |
| 43 | + text: string, textMuted: string, accent: string, | |
| 44 | + lastPriceUp: string, lastPriceDown: string, | |
| 45 | + font: string, mono: string, // familles CSS ; tailles gérées par le moteur (11/12 px) | |
| 46 | + drawing: string, drawingHandle: string, selection: string, | |
| 47 | +} | |
| 48 | + | |
| 49 | +Chart = { | |
| 50 | + // données | |
| 51 | + setData(bars: Bar[]): void // remplace ; premier chargement → fitContent() sur les ~150 dernières barres | |
| 52 | + prependData(older: Bar[]): void // historique plus ancien : le viewport reste VISUELLEMENT fixe (aucun saut) | |
| 53 | + appendData(newer: Bar[]): void // barres plus récentes ; si la vue collait au bord droit, elle suit | |
| 54 | + updateLast(bar: Bar): void // met à jour la dernière barre (même t) ou l'ajoute | |
| 55 | + getData(): Bar[] | |
| 56 | + setTimeframe(tf): void // formatage des libellés uniquement | |
| 57 | + | |
| 58 | + // apparence | |
| 59 | + setSeriesType(type: 'candles' | 'hollow' | 'ohlc' | 'line' | 'area' | 'baseline' | 'heikin' | 'columns' | 'hlc'): void | |
| 60 | + setPriceScale(o: { mode?: 'linear' | 'log' | 'percent', auto?: boolean, invert?: boolean }): void | |
| 61 | + setVolume(visible: boolean): void // histogramme de volume dans le panneau principal (bas, 20 % de la hauteur) coloré up/down | |
| 62 | + setTheme(theme: Theme): void | |
| 63 | + setCrosshair(o: { mode?: 'normal' | 'magnet' | 'hidden', showLabels?: boolean }): void | |
| 64 | + setOptions(partial: Partial<ChartOptions>): void | |
| 65 | + | |
| 66 | + // indicateurs (calculs dans src/charts/indicators/*.js, purs, testés) | |
| 67 | + addIndicator(spec: { type: IndicatorType, params?: object, pane?: 'main' | 'new', colors?: string[], id?: string }): string | |
| 68 | + updateIndicator(id: string, params: object): void | |
| 69 | + removeIndicator(id: string): void | |
| 70 | + getIndicators(): Array<{ id, type, params, pane, colors, values: Record<string, (number|null)[]> }> | |
| 71 | + // IndicatorType : 'sma' | 'ema' | 'wma' | 'vwap' | 'bollinger' | 'keltner' | 'donchian' | 'supertrend' | 'ichimoku' | |
| 72 | + // (overlay, pane 'main') · 'rsi' | 'macd' | 'stoch' | 'atr' | 'obv' | 'adx' | 'cci' | 'mfi' | 'volume-ma' (pane 'new' par défaut) | |
| 73 | + // Chaque type a des params par défaut documentés (sma {length:20}, macd {fast:12,slow:26,signal:9}, rsi {length:14}…) | |
| 74 | + | |
| 75 | + // comparaison (overlay normalisé en % depuis la première barre visible commune ; force mode 'percent' tant qu'il y a une comparaison) | |
| 76 | + addCompare(id: string, label: string, bars: Bar[], color?: string): void | |
| 77 | + removeCompare(id: string): void | |
| 78 | + | |
| 79 | + // dessins (persistants via JSON, coordonnées en {t, price}) | |
| 80 | + setDrawingTool(tool: null | 'trendline' | 'ray' | 'extended' | 'hline' | 'vline' | 'rect' | 'fib' | 'measure' | 'text' | 'arrow' | 'channel' | 'brush'): void | |
| 81 | + getDrawings(): DrawingJSON[] | |
| 82 | + setDrawings(list: DrawingJSON[]): void | |
| 83 | + clearDrawings(): void | |
| 84 | + deleteSelectedDrawing(): void | |
| 85 | + undo(): void; redo(): void | |
| 86 | + // DrawingJSON = { id, type, points: {t:number, price:number}[], style?: {color, width, dash}, text?: string, locked?: boolean } | |
| 87 | + // sélection au clic, poignées de redimensionnement, déplacement, Suppr/Backspace supprime, Échap annule l'outil, | |
| 88 | + // magnétisme sur OHLC quand crosshair en mode 'magnet'. 'measure' affiche Δprix, Δ%, nb de barres, durée. | |
| 89 | + // 'fib' : niveaux 0, 0.236, 0.382, 0.5, 0.618, 0.786, 1 (+1.618 en option), libellés prix. | |
| 90 | + | |
| 91 | + // navigation | |
| 92 | + setVisibleRange(r: { fromIndex: number, toIndex: number } | { fromT: number, toT: number }, animate?: boolean): void | |
| 93 | + getVisibleRange(): { fromIndex: number, toIndex: number, fromT: number | null, toT: number | null } | |
| 94 | + fitContent(animate?: boolean): void | |
| 95 | + scrollToLatest(animate?: boolean): void | |
| 96 | + zoom(factor: number, anchorX?: number): void // >1 zoom in | |
| 97 | + resetView(): void | |
| 98 | + resize(): void // aussi automatique via ResizeObserver | |
| 99 | + toPNG(o?: { scale?: number, watermark?: string, background?: string }): Promise<Blob> | |
| 100 | + destroy(): void | |
| 101 | + | |
| 102 | + // événements → () => void (désabonnement) | |
| 103 | + on('visibleRangeChange', (r: { fromIndex, toIndex, barsLeftOfViewport, needMoreLeft: boolean }) => void) | |
| 104 | + // needMoreLeft = true quand moins de 200 barres restent hors écran à gauche (l'appelant charge l'historique) | |
| 105 | + on('crosshairMove', (info: null | { index, bar, x, y, price, pane, indicators: Record<id, Record<key, number|null>>, compares: Record<id, number|null> }) => void) | |
| 106 | + on('drawingsChange', (list: DrawingJSON[]) => void) | |
| 107 | + on('priceScaleChange', (o) => void) | |
| 108 | + on('click', (info: { index, bar, price, pane }) => void) | |
| 109 | + on('toolChange', (tool) => void) | |
| 110 | +} | |
| 111 | +``` | |
| 112 | + | |
| 113 | +## Exigences de rendu et d'interaction (niveau terminal professionnel) | |
| 114 | + | |
| 115 | +- Canvas 2D avec `devicePixelRatio` (traits 1 px nets alignés sur 0,5 ; corps de bougie ≥ 1 px ; mèche centrée sur le pixel). | |
| 116 | +- Deux canvas superposés par panneau : couche principale (données, indicateurs, grille, axes) et couche overlay | |
| 117 | + (crosshair, dessins, sélection) → le crosshair ne redessine jamais les données. Batching `requestAnimationFrame`, un | |
| 118 | + seul draw par frame. 200 000 barres : pan/zoom à 60 fps (culler au viewport, `Path2D` par couleur, pas d'objets par barre). | |
| 119 | +- Panneaux empilés (principal + un par indicateur en 'new'), séparateurs déplaçables, bouton fermer/réduire par | |
| 120 | + panneau (dessiné dans le canvas ou via callback `onPaneClose`), axe des prix à DROITE pour chaque panneau, axe du temps | |
| 121 | + en bas unique. | |
| 122 | +- Axe des prix : ticks "nice" (1-2-5), format adapté (décimales auto, milliers), mode log, mode %, drag vertical sur | |
| 123 | + l'axe = étirement de l'échelle (désactive auto), double-clic = retour auto, molette sur l'axe = zoom vertical. | |
| 124 | +- Axe du temps : libellés sans collision, hiérarchisés (changement de jour en gras / de mois / d'année), format selon le | |
| 125 | + timeframe ; drag horizontal sur l'axe = zoom ; libellé du crosshair avec date+heure complète. | |
| 126 | +- Interaction : molette = zoom autour du curseur, drag = pan, inertie (kinetic scroll), double-clic = fitContent, | |
| 127 | + clic droit géré par l'appelant (`preventDefault` non forcé), pinch-to-zoom et pan tactile, appui long = crosshair | |
| 128 | + tactile, clavier (← → pan, + − zoom, Home/End, Échap), tout via Pointer Events. | |
| 129 | +- Crosshair : lignes pointillées fines, étiquettes prix/temps sur les axes, mode magnet (aimante O/H/L/C), tooltip | |
| 130 | + NON dessiné par le moteur (l'appelant rend une légende HTML depuis `crosshairMove`, pour l'accessibilité). | |
| 131 | +- Dernier prix : ligne pointillée + étiquette colorée up/down sur l'axe ; marqueurs High/Low de la plage visible (petites | |
| 132 | + étiquettes) ; grille discrète (`grid`), séparateurs de session (ligne verticale très légère au changement de jour en intraday). | |
| 133 | +- Types de séries : candles pleines, hollow (creuses à la hausse = encodage secondaire daltonien), OHLC bars, line, | |
| 134 | + area (dégradé), baseline (au-dessus/au-dessous d'une valeur), Heikin-Ashi, columns (close), HLC. | |
| 135 | +- Animations 150–200 ms (easeOutCubic) sur fitContent/zoom programmatique/auto-scale, désactivées si `reducedMotion`. | |
| 136 | +- Filigrane discret (symbole + timeframe) et attribution "hfmarketdata.io" dans `toPNG` uniquement. | |
| 137 | +- Aucune fuite : `destroy()` retire listeners, ResizeObserver, rAF. | |
| 138 | + | |
| 139 | +## Palette validée (`scripts/validate_palette.js` du guide dataviz, surfaces #10131a / #ffffff) | |
| 140 | + | |
| 141 | +- series sombre : `#3987e5, #d95926, #199e70, #c98500, #d55181, #008300, #9085e9, #e66767` | |
| 142 | +- series clair : `#2a78d6, #eb6834, #1baf7a, #eda100, #e87ba4, #008300, #4a3aa7, #e34948` (aqua/jaune/magenta < 3:1 → | |
| 143 | + la légende HTML porte toujours le nom + la valeur : jamais la couleur seule) | |
| 144 | +- up/down = tokens CSS `--up`/`--down` du site (sombre #2fbf71/#e5484d, clair #15803d/#d92d20) ; ΔE deutan 7,8 → encodage | |
| 145 | + secondaire obligatoire : type `hollow` disponible + préréglage daltonien (bleu #3987e5 / orange #d95926) côté page. | |
| 146 | ||