| 37 |
37 |
│ ├─ render/overlay.js crosshair, axis labels, last price (pulse), High/Low markers, watermark, pane header |
| 38 |
38 |
│ ├─ panes/pane.js Pane = two canvases + PriceScale + indicator ids |
| 39 |
39 |
│ ├─ interactions/pointer.js Pointer Events: pan, kinetic, wheel zoom, axis drags, separators, pinch, long press, keyboard |
| 40 |
|
−│ ├─ drawings/model.js tools, JSON (de)serialization, validation |
| 41 |
|
−│ ├─ drawings/geometry.js distances, hit-testing (pure) |
| 42 |
|
−│ ├─ drawings/manager.js tool state machine, selection/handles/move, undo-redo, rendering |
| 43 |
|
−│ └─ export/png.js toPNG (composes layers, adds attribution) |
|
40 |
+│ ├─ drawings/model.js 42 tools, JSON (de)serialization, validation (pane, visible, timeframes, style, props) |
|
41 |
+│ ├─ drawings/geometry.js distances, primitive decomposition of every tool (seg/ray/line/poly/rect/ellipse/arc), hit-testing (pure) |
|
42 |
+│ ├─ drawings/manager.js tool state machine (click, drag, multi-click), multi-selection, handles, Alt+drag, nudge, per-pane, undo-redo, rendering |
|
43 |
+│ └─ export/png.js toPNG (composes layers, optional legend, attribution) |
| 44 |
44 |
└─ indicators/ |
| 45 |
|
− ├─ index.js REGISTRY (defaults, pane, plots, levels, ranges) + computeIndicator |
| 46 |
|
− ├─ util.js rolling mean / ema / rma / std / max / min, true range, source selection |
| 47 |
|
− ├─ moving-averages.js sma, ema, wma, vwap |
|
45 |
+ ├─ index.js REGISTRY (60 types: defaults, typed inputs, category, plots, levels/zones, range, precision, dynamic) + listIndicators |
|
46 |
+ ├─ util.js rolling mean / ema / rma / std / sum / wma / linreg / max / min, true range, sources, compact helpers, day/week/month keys |
|
47 |
+ ├─ moving-averages.js sma, ema, wma, hma, dema, tema, kama, alma, lsma (+channel), envelope, vwap (+σ bands), avwap |
| 48 |
48 |
├─ bands.js bollinger, keltner, donchian, supertrend, ichimoku |
| 49 |
49 |
├─ oscillators.js rsi, macd, stoch, atr, adx, cci |
| 50 |
|
− ├─ volume.js obv, mfi, volumeMa |
|
50 |
+ ├─ momentum.js stochRsi, williams %R, roc, momentum, awesome, trix, ultimate, vortex, coppock, dpo, kst, tsi, rvi, cmo, aroon, choppiness, mass index, elder ray, %B, bandwidth, hv, stddev |
|
51 |
+ ├─ volume.js obv, mfi, volumeMa, adLine, cmf, chaikinOsc, forceIndex, eom, klinger, volumeOsc |
|
52 |
+ ├─ overlays.js psar, zigzag, pivots (classic/fibonacci/camarilla/woodie · day/week/month), volumeProfile, autoFib (visible-range) |
| 51 |
53 |
└─ heikin-ashi.js heikinAshi(bars) → bars |
| 52 |
54 |
``` |
| 53 |
55 |
|
| 56 |
58 |
* **Index-based X axis.** One bar = one step; session gaps never leave blank space. `TimeScale.x(i)` gives the |
| 57 |
59 |
center of bar `i`; fractional indices are allowed (drawings, crosshair). `prependData` shifts `leftIndex` by the |
| 58 |
60 |
number of bars added so the viewport does not move. |
| 59 |
|
−* **Two canvases per pane** (`main` = grid, series, indicators, axis; `overlay` = crosshair, last price, drawings, |
| 60 |
|
− pane header) plus two for the time axis. The crosshair only repaints overlays. All drawing happens in CSS pixels |
| 61 |
|
− on a DPR-scaled context; 1 px strokes are snapped to `n + 0.5`, candle bodies are odd-width integers centered on a |
| 62 |
|
− pixel so they stay razor sharp at every zoom. |
|
61 |
+* **Two canvases per pane** (`main` = grid, series, indicators, axis; `overlay` = crosshair, last price, markers, |
|
62 |
+ price lines, drawings, pane header) plus two for the time axis. The crosshair only repaints overlays. |
|
63 |
+* **Device-pixel crisp.** Drawing happens in CSS pixels on a DPR-scaled context, but every hairline (grid, wicks, |
|
64 |
+ candle outlines, crosshair, axes, last price, separators) is exactly one *device* pixel, centered with |
|
65 |
+ `crisp(v) = (round(v·dpr) + 0.5) / dpr` (`render/canvas.js`: `crisp`, `snap`, `hair`, `lw`, `alignFor`; the DPR of the |
|
66 |
+ layer being painted is published by `layer.clear()`). Candle bodies have an odd device-pixel width centered on the |
|
67 |
+ wick with ≥ 1 device pixel between bodies (`TimeScale.bodyWidthDevice(dpr)`), plus a one-device-pixel darker |
|
68 |
+ outline. Indicator lines default to 1.5 device px (`plots[].width` is in device px; `lw()` never goes below 0.75 |
|
69 |
+ CSS px so DPR 3 stays readable). Axis text uses the alphabetic baseline snapped to device rows (`text.js: textY`) |
|
70 |
+ in a monospaced 11 px font (tabular digits). `npm run charts:shots -- --sharp=<tag> --dprs=1,2,3` produces |
|
71 |
+ before/after proofs with a ×3 nearest-neighbour zoom. |
|
72 |
+* **Left axis / own scales.** An overlay indicator with `scale: 'left' | 'hidden'` owns a `PriceScale` |
|
73 |
+ (`ind.ownScale`, auto-ranged from its own values). When a left axis is shown the plot starts at `chart.plotX0`; |
|
74 |
+ pane contexts are translated by `plotX0`, and every pointer position handed to the chart is plot-relative |
|
75 |
+ (`hitRegion().x`), so the time scale never knows about the axis. |
|
76 |
+* **Dynamic indicators** (`spec.dynamic`: volume profile, auto Fib) receive `{ from, to }` and are recomputed by |
|
77 |
+ `_syncDynamic` whenever the visible range or the data version changes. |
|
78 |
+* **Bar replay** swaps `chart.store` for a truncated `BarStore` (`chart.fullStore` keeps everything), so rendering, |
|
79 |
+ axes, indicators and crosshair naturally see only bars `0…index`. |
| 63 |
80 |
* **One draw per frame.** `chart.invalidate('data' | 'overlay' | 'layout')` sets dirty flags and schedules a single |
| 64 |
81 |
`requestAnimationFrame`. The frame advances tweens (view animations), kinetic scrolling and price-scale |
| 65 |
82 |
"breathing" (exponential approach, τ ≈ 70 ms), computes auto-ranges, and repaints only what is dirty. Animations |
| 85 |
102 |
|
| 86 |
103 |
## Adding an indicator |
| 87 |
104 |
|
| 88 |
|
−1. Write a pure function `(bars, params) → { key: (number|null)[] }` in `indicators/*.js`: `null` until the |
| 89 |
|
− window is full, never an invented value; reuse `util.js` helpers. Output arrays may be longer than `bars` when a |
| 90 |
|
− plot is shifted forward (Ichimoku). |
| 91 |
|
−2. Register it in `indicators/index.js`: `{ label, compute, defaults, pane: 'main' | 'new', title(params), |
| 92 |
|
− plots: [...], levels?, range?, format? }`. Plot kinds: `line` (`key`, `color` = series index or `'up'`/`'down'`, |
| 93 |
|
− `width`, `dash`), `histogram` (`color: 'updown' | 'volume' | index`), `band` (`upper`, `lower`), `cloud` (`a`, `b`). |
| 94 |
|
−3. Add hand-computed reference values to `indicators/indicators.test.js` (`npm run test:charts`). |
|
105 |
+1. Write a pure function `(bars, params[, range]) → { key: (number|null)[] }` in `indicators/*.js`: `null` until |
|
106 |
+ the window is full, never an invented value; reuse `util.js` helpers. Output arrays may be longer than `bars` |
|
107 |
+ when a plot is shifted forward (Ichimoku). Dynamic indicators return plain objects computed on `range`. |
|
108 |
+2. Register it in `indicators/index.js`: `{ label, category, compute, defaults, inputs: [I.int('length'), I.source()…], |
|
109 |
+ pane: 'main' | 'new', title(params), plots: [...], levels?, zones?, range?, format?, precision?, dynamic? }`. |
|
110 |
+ Plot kinds: `line` (`key`, `color` = series index or `'up'`/`'down'`, `width` in device px, `dash`/`style`, |
|
111 |
+ `step`, `connectGaps`), `histogram` (`color: 'updown' | 'delta' | 'volume' | 'up' | 'down' | index`), `band` |
|
112 |
+ (`upper`, `lower`), `cloud` (`a`, `b`), `circles` (`key`, `radius`), `arrows` (±1 values), `hlevels` |
|
113 |
+ (`values.levels = [{ ratio, price }]`), `profile` (volume profile object). |
|
114 |
+3. Add hand-computed reference values to `indicators/indicators-v2.test.js` (`npm run test:charts`). |
| 95 |
115 |
|
| 96 |
116 |
## Adding a drawing tool |
| 97 |
117 |
|
| 98 |
|
−1. Add the name to `TOOLS` and its anchor count to `POINT_COUNT` in `drawings/model.js`. |
| 99 |
|
−2. Hit-testing: add a case to `hitDrawing` in `drawings/geometry.js` (pixel space, tolerance 6 px). |
| 100 |
|
−3. Rendering: add a case to `DrawingManager._drawOne` in `drawings/manager.js` (points are already converted to |
| 101 |
|
− pixels; clip to the plot is applied; use `theme.drawing` unless `style.color` is set). |
| 102 |
|
−4. The state machine (click-click or drag to create, handles, move, undo/redo, JSON) is generic — nothing else |
| 103 |
|
− to do. Add a serialization / hit test to `engine/drawings.test.js`. |
|
118 |
+1. Add the name to `TOOLS_V2`, its anchor count to `POINT_COUNT` and a label to `TOOL_LABELS` in |
|
119 |
+ `drawings/model.js` (`CREATE_CLICKS` when creation needs fewer clicks than points, `MULTI_MODE` for free-form, |
|
120 |
+ `TEXT_TOOLS` / `PANE_TOOLS` when applicable). |
|
121 |
+2. Geometry: add a case to `primitives()` in `drawings/geometry.js` returning segments / rays / lines / polys / |
|
122 |
+ rects / ellipses / arcs in pixel space — hit-testing (`hitDrawing`) and the line rendering come for free. |
|
123 |
+3. Decorations (labels, boxes, markers): add a case to `DrawingManager._decorate` in `drawings/manager.js`. |
|
124 |
+4. The state machine (creation, multi-selection, handles, move, Alt+drag, nudge, undo/redo, JSON, per-pane) is |
|
125 |
+ generic. Add primitive / hit tests to `engine/drawings.test.js`. |
| 104 |
126 |
|
| 105 |
127 |
## Tests, harness, screenshots |
| 106 |
128 |
|
| 107 |
|
−* `npm run test:charts` — `node --test` (no DOM): scales, ticks, formats, store, indicators vs hand-computed values, |
| 108 |
|
− drawings serialization and geometry, prepend-without-jump. |
|
129 |
+* `npm run test:charts` — `node --test` (no DOM, 64 tests): scales, ticks, formats, store, 60 indicators vs |
|
130 |
+ hand-computed / closed-form values, registry metadata, drawings serialization, primitives and hit-tests. |
| 109 |
131 |
* `npm run dev` then open `/dev/charts-harness.html` — synthetic data (sessions 09:30–16:00 ET, overnight and |
| 110 |
132 |
weekend gaps, 1min / 1day, up to 200 000 bars), every series type, indicator, tool and theme, live simulation, |
| 111 |
133 |
history prepend on `needMoreLeft`, `Bench` button. URL params are listed at the top of `dev/charts-harness.js`. |
| 112 |
|
−* `npm run charts:shots` — Playwright: desktop 1440×900 and mobile 390×844 captures in `/tmp/hfmd-charts/`, an |
|
134 |
+* `npm run charts:shots` — Playwright: desktop 1440×900 and mobile 390×844 captures in `/tmp/hfmd-charts/` |
|
135 |
+ (including `desktop-v2-*`: overlays, oscillators, left scale, two drawing showcases, chart features), an |
| 113 |
136 |
interaction smoke test (drag, kinetic, wheel, axis drag, keyboard, drawing, pinch, prepend, toPNG, destroy) and |
| 114 |
|
− the render time of 100 programmatic pan/zoom frames. |
|
137 |
+ the render time of 100 programmatic pan/zoom frames. `--sharp=<tag> --dprs=1,2,3` writes the sharpness proofs. |
|
138 |
+ Harness URL params: `ind=sma:50,obv@left,rsi,stoch@pane`, `drawings=1|2|3`, `features=1`. |
| 115 |
139 |
|
| 116 |
140 |
## Known limits |
| 117 |
141 |
|
| 118 |
142 |
* `minBarSpacing` defaults to 0.5 px (contract), i.e. at most ~2 bars per pixel; pass a smaller value to show |
| 119 |
143 |
hundreds of thousands of bars at once (decimated rendering handles it). |
| 120 |
|
−* Text drawings are created with the placeholder `"Text"`: the engine never opens inputs — edit via |
| 121 |
|
− `setDrawings`. Drawings live on the main pane only. |
| 122 |
|
−* Indicator params are validated loosely (positive integers fall back to defaults); no per-plot style overrides |
| 123 |
|
− yet beyond `colors`. |
|
144 |
+* The engine never opens inputs: text / callout creation and double-clicks emit `textEdit` and the page answers |
|
145 |
+ with `setDrawingText`. Fib arcs are circles in pixel space (they deform when the scale is stretched). |
|
146 |
+* Indicator params are validated loosely (positive integers fall back to defaults). Pivot points use the previous |
|
147 |
+ period's H/L/C (Woodie: current open); Klinger uses the signed-volume form; Volume Profile spreads each bar's |
|
148 |
+ volume uniformly over its high–low range. |
|
149 |
+* A left axis is drawn for the first visible `scale: 'left'` indicator only (others still own their scale). |
|
150 |
+* Replay hides bars from rendering and indicators but drawings keep their absolute times. |
| 124 |
151 |
|