@trdrs/quickcharts 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +18 -0
  4. package/README.md +2325 -0
  5. package/THIRD-PARTY-NOTICES.md +263 -0
  6. package/dist/adapters/rest.d.ts +143 -0
  7. package/dist/adapters/rest.js +214 -0
  8. package/dist/adapters/rest.js.map +1 -0
  9. package/dist/ar-G7MVXRE4.js +1028 -0
  10. package/dist/ar-G7MVXRE4.js.map +1 -0
  11. package/dist/assets-C1GQvm0w.d.ts +1452 -0
  12. package/dist/ca_ES-EUSRU4VG.js +1028 -0
  13. package/dist/ca_ES-EUSRU4VG.js.map +1 -0
  14. package/dist/chunk-AKXIFHQN.js +172 -0
  15. package/dist/chunk-AKXIFHQN.js.map +1 -0
  16. package/dist/chunk-KFBQSPNA.js +107 -0
  17. package/dist/chunk-KFBQSPNA.js.map +1 -0
  18. package/dist/chunk-PD3DEWMQ.js +6959 -0
  19. package/dist/chunk-PD3DEWMQ.js.map +1 -0
  20. package/dist/de-2SVTG25S.js +1028 -0
  21. package/dist/de-2SVTG25S.js.map +1 -0
  22. package/dist/drawings.d.ts +237 -0
  23. package/dist/drawings.js +199 -0
  24. package/dist/drawings.js.map +1 -0
  25. package/dist/es-UDHZM4W7.js +1028 -0
  26. package/dist/es-UDHZM4W7.js.map +1 -0
  27. package/dist/feature-manifest.json +3453 -0
  28. package/dist/fr-35VN4G4S.js +1028 -0
  29. package/dist/fr-35VN4G4S.js.map +1 -0
  30. package/dist/he_IL-JPZMCURP.js +1028 -0
  31. package/dist/he_IL-JPZMCURP.js.map +1 -0
  32. package/dist/id_ID-XSTFHHJQ.js +1028 -0
  33. package/dist/id_ID-XSTFHHJQ.js.map +1 -0
  34. package/dist/index.d.ts +5487 -0
  35. package/dist/index.js +24040 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/it-EIY6JESQ.js +1028 -0
  38. package/dist/it-EIY6JESQ.js.map +1 -0
  39. package/dist/ja-42N4XRCW.js +1028 -0
  40. package/dist/ja-42N4XRCW.js.map +1 -0
  41. package/dist/ko-3VRPTIP3.js +1028 -0
  42. package/dist/ko-3VRPTIP3.js.map +1 -0
  43. package/dist/ms_MY-SKN7O6M5.js +1028 -0
  44. package/dist/ms_MY-SKN7O6M5.js.map +1 -0
  45. package/dist/pl-TYARHCLT.js +1028 -0
  46. package/dist/pl-TYARHCLT.js.map +1 -0
  47. package/dist/pt-72IIFLL4.js +1028 -0
  48. package/dist/pt-72IIFLL4.js.map +1 -0
  49. package/dist/quickcharts.css +6217 -0
  50. package/dist/resources-Ch8eA1Mw.d.ts +228 -0
  51. package/dist/rest-openapi.json +1664 -0
  52. package/dist/ru-TPH4F7IB.js +1028 -0
  53. package/dist/ru-TPH4F7IB.js.map +1 -0
  54. package/dist/sv-DC5RS67Z.js +1028 -0
  55. package/dist/sv-DC5RS67Z.js.map +1 -0
  56. package/dist/th-I6H6AOZ3.js +1028 -0
  57. package/dist/th-I6H6AOZ3.js.map +1 -0
  58. package/dist/theme-manifest.json +1246 -0
  59. package/dist/tr-7FSUVCNW.js +1028 -0
  60. package/dist/tr-7FSUVCNW.js.map +1 -0
  61. package/dist/vi-DLRPHLLH.js +1028 -0
  62. package/dist/vi-DLRPHLLH.js.map +1 -0
  63. package/dist/zh-ZXIQBBEP.js +1028 -0
  64. package/dist/zh-ZXIQBBEP.js.map +1 -0
  65. package/dist/zh_TW-TYNUJU7U.js +1028 -0
  66. package/dist/zh_TW-TYNUJU7U.js.map +1 -0
  67. package/package.json +100 -0
package/README.md ADDED
@@ -0,0 +1,2325 @@
1
+ # @trdrs/quickcharts
2
+
3
+ Quick Charts is a charting library that draws over the datafeed and the storage you supply. The chart
4
+ consumes the `ChartDatafeed` interface and never a concrete backend, so your feed drives it without a
5
+ change to the chart. It does not include market data, trading, accounts, execution, community, news
6
+ or hosting: the host supplies data and storage, and the chart draws.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @trdrs/quickcharts
12
+ ```
13
+
14
+ Supported npm versions automatically install the required renderer. You do not need to name
15
+ another package in the install command. Quick Charts ships ESM and TypeScript declarations.
16
+ From a CommonJS host, load it through dynamic `import()`.
17
+
18
+ The renderer remains a required `lightweight-charts` 5 peer so compatible consumers share one
19
+ runtime. Use default peer resolution; do not suppress peer checks.
20
+
21
+ Quick Charts is licensed under the Apache License 2.0: see `LICENSE` and `NOTICE`. Because the
22
+ renderer is *your* dependency, its own Apache-2.0 NOTICE
23
+ obligations attach to **your** bundle: `THIRD-PARTY-NOTICES.md` in this package spells out exactly
24
+ what to carry and how. The chart includes no trading, accounts or executions; an application that
25
+ trades composes those outside the chart, through the extension seam below.
26
+
27
+ Quickstart, the smallest working chart (see [The widget](#the-widget) for the full options). The
28
+ stylesheet import is not optional: it carries the chart's layout as well as its look, and without it
29
+ the chart has no size and paints nothing.
30
+
31
+ ```ts
32
+ import '@trdrs/quickcharts/styles.css'
33
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
34
+
35
+ const widget = createChart({
36
+ container,
37
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
38
+ })
39
+ await widget.ready()
40
+ ```
41
+
42
+ ## The datafeed contract
43
+
44
+ Implement `ChartDatafeed` (see `datafeed.ts`). Required methods: `search`, `resolve`, `history`,
45
+ `subscribeBars`. Optional: `serverTime` (countdown skew correction) and `config` (a feed-level
46
+ capability declaration, described [below](#capability-declaration-config-optional)). The datafeed serves
47
+ symbol metadata, bars and bar updates and nothing else: a quote board (last, change, volume) or a
48
+ top-of-book is not a chart concern, and your host fans quotes to its own consumers from its own
49
+ source.
50
+
51
+ When `appearance.countdown` is enabled, a live streaming time bar replaces the native last-value
52
+ label with one price-and-time label. The price uses the resolved symbol formatter. Replay, stale,
53
+ delayed or end-of-day data, tick bars, closed declared sessions and missing bars retain the native
54
+ price label. `serverTime` corrects clock skew when available; absence or failure uses the client
55
+ clock. Bar opens define fixed and calendar alignment, and declared session facts shorten only a
56
+ bar they actually close.
57
+
58
+ `resolve` answers with `SymbolInfo`, the symbology contract ([Symbology](#symbology)): the
59
+ symbol's identity (`ticker`, `name`, `description`), venue and type (`exchange`,
60
+ `listedExchange`, `type`), `supportedResolutions` (chart timeframe tokens; an empty list declares
61
+ no restriction), the exchange session triple (`timezone`, `session`, `sessionHolidays`),
62
+ `dataStatus`, `currencyCode` or `unitId`, `volumePrecision`, and the price-format facts in
63
+ `format`. Null means the symbol is unknown to your catalogs.
64
+
65
+ ```ts
66
+ import type { SymbolInfo } from '@trdrs/quickcharts'
67
+
68
+ const treasury: SymbolInfo = {
69
+ ticker: 'ZBZ2026',
70
+ name: 'ZBZ2026',
71
+ description: '30-year T-bond Dec 2026',
72
+ exchange: 'CBOT',
73
+ listedExchange: 'CBOT',
74
+ type: 'futures',
75
+ supportedResolutions: [], // no restriction: any timeframe token
76
+ timezone: 'America/Chicago',
77
+ session: '1700-1600:23456',
78
+ dataStatus: 'streaming',
79
+ currencyCode: 'USD',
80
+ volumePrecision: 0,
81
+ format: { pricescale: 32, minmov: 1, fractional: true },
82
+ }
83
+ void treasury
84
+ ```
85
+
86
+ ```ts
87
+ import type { ChartDatafeed, FeedBar } from '@trdrs/quickcharts'
88
+
89
+ export const myFeed: ChartDatafeed = {
90
+ async search(query, opts) {
91
+ const rows = await myBackend.search(query, opts?.cls, opts?.limit ?? 50, opts?.offset ?? 0)
92
+ return { hits: rows.hits, hasMore: rows.hasMore } // hasMore is EXACT, never a page-boundary guess
93
+ },
94
+ async resolve(symbol) {
95
+ return (await myBackend.symbolInfo(symbol)) ?? null // null = unknown symbol (a data answer)
96
+ },
97
+ async history(symbol, tf, range) {
98
+ const page = await myBackend.bars(symbol, tf, range?.from, range?.to, range?.countBack)
99
+ return { bars: page.bars, noData: page.endOfHistory } // noData ONLY on a countBack ask
100
+ },
101
+ subscribeBars(symbol, tf, handlers) {
102
+ const stream = myBackend.stream(symbol, tf)
103
+ stream.onSnapshot((bars: FeedBar[]) => handlers.onBars({ kind: 'snapshot', bars })) // on connect AND every reconnect
104
+ stream.onBar((bar: FeedBar) => handlers.onBars({ kind: 'bar', bar }))
105
+ return () => stream.close()
106
+ },
107
+ }
108
+ ```
109
+
110
+ ### The bar rules (the chart relies on them)
111
+
112
+ 1. **Ascending, unique, inclusive.** Bars are sorted by time, one bar per timestamp. A `history`
113
+ window is INCLUSIVE of both ends: a bar exactly at `from` or at `to` belongs to the answer.
114
+ The chart never re-requests a bar it holds: it pages with `to = oldest − 1`, so you never
115
+ re-send one either.
116
+ 2. **`countBack` outranks `from`, and the count is an obligation.** When `history` is called with
117
+ `countBack: N`, return the last N bars at/before `to` even if that reaches back past `from`
118
+ (a weekend or holiday week between `to` and the data is YOUR problem to reach across, not the
119
+ chart's). The widget asks once per scroll approach and does not loop to compensate: a short
120
+ answer is a visibly short chart. Fill outward in widening rounds until the count is met or
121
+ history is exhausted.
122
+ 3. **`noData` ends scroll-back.** When a `countBack` request finds nothing, return `{ bars: [], noData: true }`.
123
+ A plain `from/to` request with an empty window must **not** set `noData` (an empty window can be a
124
+ mid-history gap, not the end of history).
125
+ 4. **Live updates mutate the last bar or append.** `subscribeBars` emits `{ kind: 'snapshot', bars }` on
126
+ connect and after every reconnect (the self-healing re-sync), then `{ kind: 'bar', bar }` per update.
127
+ A bar's `t` is its **bucket-open** epoch-seconds time, never the update's wall-clock time.
128
+ 5. **Bar time is epoch SECONDS.** Not milliseconds.
129
+ 6. **No feed for a symbol is terminal.** Throw `FeedUnavailableError` from `history` when no feed serves
130
+ the symbol; the chart shows "market data unavailable" and stops. A transient fetch failure should
131
+ throw a normal error (the chart retries).
132
+ 7. **Never synthesize prices.** A bar carries what the market printed; a symbol with no data has no
133
+ bars, never invented ones.
134
+
135
+ ### Coarser grains than the feed serves (`withFoldedHistory`, optional)
136
+
137
+ A venue that keeps 15-minute bars keeps every 45-minute bar too, just not under that name. Wrap a
138
+ feed to answer those:
139
+
140
+ ```ts
141
+ import { withFoldedHistory, type ChartDatafeed } from '@trdrs/quickcharts'
142
+
143
+ declare const venueFeed: ChartDatafeed // your feed from the section above
144
+
145
+ const feed = withFoldedHistory(venueFeed, { serves: ['1m', '15m', '1h', '1d', '1mo'] })
146
+ ```
147
+
148
+ `serves` names the grains the feed answers natively. Every other request is folded from the coarsest
149
+ of them that divides it, so a 45-minute ask fetches 15-minute bars three at a time: first open,
150
+ extremes across the run, last close, summed volume. Month multiples fold by calendar month, so a
151
+ quarter is a quarter rather than ninety days, and weeks start on Monday.
152
+
153
+ Folding only works downward. A finer bar cannot be recovered from a coarser one, because the path
154
+ the price took inside it was never written down, so a request below everything the feed serves passes
155
+ through untouched and the feed answers for itself. This is rule 7 in wrapper form.
156
+
157
+ ### Capability declaration (`config`, optional)
158
+
159
+ A feed with a **fixed** capability set may declare it; the widget reads the declaration once at mount
160
+ and constrains itself. In particular, its opening timeframe must be servable: a sticky/default tf the
161
+ feed did not declare falls to your **first** declared resolution instead of dead-ending the first paint
162
+ on a refusal. The viewer's stored preference is *not* overwritten (capability is the feed's property,
163
+ preference is the viewer's, so a later feed that serves the preferred tf gets it back).
164
+
165
+ ```ts
166
+ import type { ChartDatafeed, DatafeedConfig } from '@trdrs/quickcharts'
167
+
168
+ declare const baseFeed: ChartDatafeed // your feed from the section above
169
+
170
+ export const feed: ChartDatafeed = {
171
+ ...baseFeed,
172
+ async config(): Promise<DatafeedConfig> {
173
+ return { resolutions: ['1m', '5m', '1h', '1d'] }
174
+ },
175
+ }
176
+ ```
177
+
178
+ Declare only what is **true**. Absent method / absent field / empty list = unconstrained, so a feed that
179
+ serves any interval must not declare a finite `resolutions` list, because the widget then enforces it.
180
+ The UDF adapter declares automatically from the server's own `/config` (and only ever declares
181
+ timeframes it would actually serve).
182
+
183
+ ## The UDF on-ramp
184
+
185
+ Already serving bars over the UDF wire protocol? Skip implementing the interface and point the
186
+ adapter at your server:
187
+
188
+ ```ts
189
+ import { createUdfDatafeed } from '@trdrs/quickcharts'
190
+
191
+ const datafeed = createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' })
192
+ ```
193
+
194
+ UDF is REST and **poll-based** (no push): `subscribeBars` polls `/history` for the newest bar. For true
195
+ real-time, implement `ChartDatafeed` directly over your own stream. UDF is the low-effort on-ramp,
196
+ not the endpoint.
197
+
198
+ What the adapter honors of the protocol:
199
+
200
+ - **`/config` is fetched once and drives the rest.** `supported_resolutions` is validated against:
201
+ asking for a resolution the server didn't declare is `FeedUnavailableError`, not a silent guess
202
+ (`'D'` and `'1D'` are recognized as the same declaration). A server without `/config` gets the
203
+ protocol's defaults (search on, no groups). The adapter also republishes the declaration through
204
+ the seam's `config()`, so the widget opens on a timeframe the server actually serves.
205
+ - **Group-catalog symbol search.** When `/config` declares `supports_group_request`, search is served
206
+ from the columnar `/symbol_info?group=` catalog instead of `/search`.
207
+ - **`no_data` + `nextTime` is a gap, not the end.** The chart re-asks once at `nextTime` (a session
208
+ gap hop); only `no_data` *without* the hint ends scroll-back. `nextTime` in ms or s both work.
209
+ - **The seam's inclusive `[from, to]` is bridged** to UDF's exclusive `to` inside the adapter, so your
210
+ server sees standard UDF ranges; implement nothing special.
211
+
212
+ ## Symbology
213
+
214
+ Symbology is the set of display facts that decide how a market's prices are written. Your datafeed
215
+ owns them (`resolve` answers with `SymbolInfo`), and one package formatter uses them everywhere a
216
+ price appears: the price scale, the crosshair and last-price labels, the legend, the level menu,
217
+ every drawing label, study scales, and the extension seam. Precision comes from the symbol, never
218
+ from the size of the price, so the same market reads at the same width on every surface. A study
219
+ that declares its own precision keeps it; every other value writes through the symbol formatter.
220
+
221
+ `PriceFormat` expresses every supported form in five facts:
222
+
223
+ | Fact | What it says |
224
+ |---|---|
225
+ | `pricescale` | Price units per whole unit. `100` writes cents, `100000` writes FX pipettes, `32` writes thirty-seconds. |
226
+ | `minmov` | The smallest move, in those units. It declares the grid, not the column width. |
227
+ | `minmove2` | The further division of one `minmov` step. `4` means quarters of a thirty-second. |
228
+ | `fractional` | Write the sub-unit part as a counted fraction rather than as decimals. |
229
+ | `variableTickSize` | An ordered ladder of tick sizes by price band, as a space-separated string. |
230
+
231
+ Build a formatter once per symbol and keep it:
232
+
233
+ ```ts
234
+ import { createPriceFormatter, type PriceFormat } from '@trdrs/quickcharts'
235
+
236
+ const equity: PriceFormat = { pricescale: 100, minmov: 1 }
237
+ createPriceFormatter(equity).format(123.4) // '123.40'
238
+
239
+ const emini: PriceFormat = { pricescale: 100, minmov: 25 }
240
+ createPriceFormatter(emini).format(4500.25) // '4500.25'
241
+
242
+ const treasury: PriceFormat = { pricescale: 32, minmov: 1, fractional: true }
243
+ createPriceFormatter(treasury).format(110.5) // "110'16"
244
+
245
+ const bonds: PriceFormat = { pricescale: 128, minmov: 1, minmove2: 4, fractional: true }
246
+ createPriceFormatter(bonds).format(110.515625) // "110'16'2"
247
+ ```
248
+
249
+ `format` and `parse` are exact inverses, so a formatter reads back what it wrote:
250
+
251
+ ```ts
252
+ import { createPriceFormatter } from '@trdrs/quickcharts'
253
+
254
+ const formatter = createPriceFormatter({ pricescale: 32, minmov: 1, fractional: true })
255
+ formatter.parse("110'16") // 110.5
256
+ formatter.parse('110.5') // null, because that symbol does not write decimals
257
+ formatter.precision() // 0, because a fractional format writes no decimal digits
258
+ ```
259
+
260
+ A ladder changes width by band, because the symbol declared those bands:
261
+
262
+ ```ts
263
+ import { createPriceFormatter, parseTickBands, tickBandFor } from '@trdrs/quickcharts'
264
+
265
+ const laddered = createPriceFormatter({ pricescale: 10000, minmov: 1, variableTickSize: '0.0001 1 0.001 10 0.01' })
266
+ laddered.format(0.5432) // '0.5432'
267
+ laddered.format(54.32) // '54.32'
268
+
269
+ const bands = parseTickBands('0.0001 1 0.001 10 0.01')
270
+ tickBandFor(bands, 5)?.size // 0.001
271
+ ```
272
+
273
+ The formatter writes a `.` decimal sign and no thousands separator. Pass a locale to take that
274
+ locale's decimal sign, or set the punctuation yourself:
275
+
276
+ ```ts
277
+ import { createPriceFormatter } from '@trdrs/quickcharts'
278
+
279
+ createPriceFormatter({ pricescale: 100, minmov: 1 }, { locale: 'de-DE' }).format(1234.5) // '1234,50'
280
+ createPriceFormatter({ pricescale: 100, minmov: 1 }, { numericPunctuation: { groupSign: ',' } }).format(1234.5) // '1,234.50'
281
+ ```
282
+
283
+ ### Naming
284
+
285
+ The same facts name the market on screen, through one rule with three faces. `symbolNames` reads a
286
+ resolved `SymbolInfo`, a search row, or the feed symbol alone before either has landed, and answers
287
+ the compact mark a label wears, the title a reading wears, and the description beside a mark. A
288
+ pair market reads in the codes it trades in, never a spelled-out currency; a market that is not a
289
+ pair keeps the feed's own words; the venue prefix never reaches the screen. The chart's pill, legend
290
+ and picker read these faces, so anything you name beside the chart reads the same:
291
+
292
+ ```ts
293
+ import { symbolNames, type SymbolNames, type SymbolRow } from '@trdrs/quickcharts'
294
+
295
+ const row: SymbolRow = { symbol: 'HYPERLIQUID:ETH', name: 'Ethereum perpetual', exchange: 'Hyperliquid', type: 'crypto', currencyCode: 'USDC' }
296
+ const perp: SymbolNames = symbolNames(row)
297
+ perp.mark // 'ETHUSDC'
298
+ perp.title // 'ETH / USDC'
299
+ perp.description // 'ETH / USDC'
300
+
301
+ symbolNames({ symbol: 'NASDAQ:AAPL', name: 'Apple Inc', exchange: 'NASDAQ', type: 'stock', currencyCode: 'USD' }).mark // 'AAPL'
302
+ symbolNames({ symbol: 'NASDAQ:AAPL', name: 'Apple Inc', exchange: 'NASDAQ', type: 'stock', currencyCode: 'USD' }).description // 'Apple Inc'
303
+ symbolNames('HYPERLIQUID:ETH').mark // 'ETH', before the symbol resolves
304
+ ```
305
+
306
+ Symbology is display truth, not trading truth. It carries no order quantity, price step, lot size,
307
+ or pip value. Your broker integration owns those, and a broker's execution grid can differ from a
308
+ chart's display grid.
309
+
310
+ If your data comes from a UDF server, map its `/symbols` answer without collapsing the facts:
311
+
312
+ ```ts
313
+ import { createPriceFormatter, udfPriceFormat, udfSymbolInfo } from '@trdrs/quickcharts'
314
+
315
+ const raw = { name: 'ZBZ2026', pricescale: 128, minmov: 1, minmove2: 4, fractional: true }
316
+ const info = udfSymbolInfo(raw, 'ZBZ2026')
317
+ createPriceFormatter(udfPriceFormat(raw)).format(110.515625) // "110'16'2"
318
+ info?.dataStatus // 'streaming'
319
+ ```
320
+
321
+ ## Save and load
322
+
323
+ Quick Charts persists two different kinds of thing, and the difference decides where each one lives.
324
+
325
+ Saved charts, layouts, drawing documents and templates are **entities**: a user names them, opens
326
+ them in two tabs, and can lose them. They have identity and versions, and they live on the
327
+ save/load adapter you supply. Viewer preferences (the last symbol and timeframe, the scale mode, the
328
+ replay speed) are **flat settings**: they need no identity, and they live on the small `ChartStorage`
329
+ port beside it.
330
+
331
+ The chart never writes on its own. It tells you when a save would be worth making, through the
332
+ `saveNeeded` event, and it runs a write only when a user asks for one through the built-in UI or
333
+ when you call a save method yourself.
334
+
335
+ ### What a saved entity carries
336
+
337
+ Every family works the same way. A read returns a `ResourceRef`: a stable id plus an opaque revision
338
+ token. A write quotes the revision it believes it is replacing. A write against a revision the store
339
+ has moved past returns a typed `conflict` carrying the current ref, which you resolve rather than
340
+ overwrite.
341
+
342
+ ```ts
343
+ import { memorySaveLoadAdapter } from '@trdrs/quickcharts'
344
+
345
+ const adapter = memorySaveLoadAdapter()
346
+
347
+ const created = await adapter.charts.create({ name: 'Morning', symbol: 'ESZ2026', timeframe: '5m', content: '{}' })
348
+ if (created.kind === 'ok') {
349
+ const accepted = await adapter.charts.update(created.ref, { name: 'Morning', symbol: 'ESZ2026', timeframe: '15m', content: '{}' })
350
+ const stale = await adapter.charts.update(created.ref, { name: 'Morning', symbol: 'ESZ2026', timeframe: '1h', content: '{}' })
351
+ accepted.kind // 'ok', and it carries the revision the store now holds
352
+ stale.kind // 'conflict', and it carries the ref that won
353
+ }
354
+ ```
355
+
356
+ A chart's, a layout's and a template's `content` is opaque to your store: pass it through, and the
357
+ chart's formats stay the chart's own. A drawing document is the one structured body, because two
358
+ surfaces showing the same drawings have to merge rather than overwrite.
359
+
360
+ Treat the revision as opaque. Mint it however your backend prefers, as an ETag, a counter or a
361
+ content hash, and compare it only for equality.
362
+
363
+ ### The built-in Save/Load UI
364
+
365
+ With the `layouts` feature on, the top bar carries the open layout's name, a marker for unsaved
366
+ changes, and a menu with Save, an Autosave switch, Make a copy, Rename, Download chart data, Create
367
+ new layout, the layouts used most recently, and Open layout: the Layouts dialog, with search, a sort
368
+ by name or by date modified, a star that keeps a layout at the top, and delete behind a
369
+ confirmation. The sort and the stars are the viewer's, kept in the widget's `ChartStorage`. Each
370
+ saved layout is listed by the market and interval its active chart showed when
371
+ it was saved, or by its age where the store kept neither.
372
+
373
+ Every row runs a command (`widget.layout.save`, `rename`, `load`, `open`, `delete`, `create`, `autosave`,
374
+ and `chart.data.download` for the export), so an access policy that refuses layout writes disables
375
+ the rows and refuses the same verb from every other door. Ctrl+S, and Cmd+S on a Mac keyboard, run
376
+ the save command from the chart's own root, under that same policy: an open layout saves, and a
377
+ never-saved one is asked for a name first. The period key opens the Layouts dialog through
378
+ `widget.layout.open`, under the same policy. Download chart data writes the active chart's loaded bars
379
+ as CSV with time, open, high, low, close and volume columns in UTC, and is unavailable on a chart
380
+ holding no bars. The menu hears what a verb did through the widget's `layout` event and
381
+ what it refused through `saveConflict`.
382
+
383
+ Save and Rename commands are unavailable while a layout load is pending. Hosts performing
384
+ background initialization should recheck `commands.available('widget.layout.save')` after any
385
+ awaited discovery, before creating an initial layout. Direct API calls retain caller-controlled
386
+ ordering; this command readiness is distinct from the unsafe-content `notSaving()` state.
387
+
388
+ Direct `widget.layout.saveLoad` calls publish the same events once at the committed binding,
389
+ so a host-created or reopened layout immediately updates the package toolbar. Cancelled loads
390
+ and stale write results publish no success. A rescue copy or partial load can update the name
391
+ without clearing `notSaving()` or the toolbar's existing dirty state. Removing an already absent
392
+ open row reports its removal and a `saveConflict` refusal; the absent binding is detached.
393
+
394
+ ```ts
395
+ import { createChart, createUdfDatafeed, memorySaveLoadAdapter } from '@trdrs/quickcharts'
396
+
397
+ declare const uiContainer: HTMLElement
398
+ const uiWidget = createChart({
399
+ container: uiContainer,
400
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
401
+ saveLoad: memorySaveLoadAdapter(),
402
+ })
403
+ uiWidget.on('saveNeeded', () => note('the viewer changed something a save would keep'))
404
+ uiWidget.on('layout', (event) => note(`${event.kind} ${event.name ?? ''}`))
405
+ uiWidget.commands.execute('widget.layout.save', { name: 'Desk', asNew: true })
406
+ ```
407
+
408
+ ### The transport-neutral adapter
409
+
410
+ `ChartSaveLoadAdapter` is four families over one contract. Each family is a `ResourceStore` with the
411
+ same five calls, and every call takes an `AbortSignal` so abandoned work stops cleanly:
412
+
413
+ ```ts
414
+ import { emptyDrawingDocument, memorySaveLoadAdapter, type ResourceRef } from '@trdrs/quickcharts'
415
+
416
+ const adapter = memorySaveLoadAdapter()
417
+ const controller = new AbortController()
418
+
419
+ await adapter.layouts.list(controller.signal)
420
+ await adapter.templates('study').list()
421
+ const context = { version: 1, kind: 'symbol-global', symbol: 'ESZ2026' } as const
422
+ await adapter.drawings(context).create(emptyDrawingDocument(context))
423
+
424
+ const ghost: ResourceRef = { id: 'gone', revision: 'rev-1' }
425
+ const outcome = await adapter.charts.remove(ghost)
426
+ outcome.kind // 'not-found'
427
+ ```
428
+
429
+ An aborted call rejects with an error named `AbortError` and changes nothing.
430
+
431
+ `memorySaveLoadAdapter` persists nothing. Use it for tests, server rendering and an intentionally
432
+ ephemeral embed, and implement the same contract over your own backend for durable storage.
433
+
434
+ ### The REST adapter and its wire contract
435
+
436
+ If your saved resources live behind HTTP, `@trdrs/quickcharts/adapters/rest` implements the same contract
437
+ over a published wire contract, and you implement the service.
438
+
439
+ ```ts
440
+ import { createRestSaveLoadAdapter } from '@trdrs/quickcharts/adapters/rest'
441
+
442
+ const restSaves = createRestSaveLoadAdapter({
443
+ baseUrl: 'https://api.example.com/chart-storage',
444
+ // Your transport. `fetch` fits as it is; wrap it to add what your product needs.
445
+ request: (url, init) => fetch(url, { ...init, credentials: 'include' }),
446
+ })
447
+ await restSaves.layouts.list()
448
+ ```
449
+
450
+ You supply two values and nothing else. `baseUrl` is where you mounted the routes, and `request` is
451
+ how a request is made. Authorization, cookies, CORS, retries, timeouts and tenancy stay in your
452
+ request function, because their consequences are yours: the adapter holds no credential, no token
453
+ store, no header policy and no default origin, and it makes no request until you call a verb.
454
+
455
+ Your service serves four collections under that base URL. Every path below is relative to it.
456
+
457
+ | Path | Verbs | Body |
458
+ | --- | --- | --- |
459
+ | `/charts` | `GET` lists, `POST` creates | `{ name, symbol, timeframe, content }` |
460
+ | `/charts/{id}` | `GET` reads, `PUT` replaces, `DELETE` removes | the same |
461
+ | `/layouts`, `/layouts/{id}` | the same five | `{ name, symbol?, timeframe?, content }` |
462
+ | `/drawings?symbol=&context=`, `/drawings/{id}` | the same five | `{ content }` |
463
+ | `/templates/{kind}`, `/templates/{kind}/{id}` | the same five | `{ name, tool?, content }` |
464
+
465
+ A layout's `symbol` and `timeframe` are listing text: the active chart's market as the chart writes it
466
+ (`BTCUSDC`) and its timeframe token, as they stood at the save. Keep them and return them in the
467
+ listing, or leave them out and the chart lists the layout by its age.
468
+
469
+ A listing answers `{ "items": [...] }` of metadata. A read answers `{ "id", "revision", "body" }`. A
470
+ create, update or delete answers `{ "id", "revision", "meta"? }`. Every update and delete sends the
471
+ revision it is replacing in `If-Match`, and your service must compare it before it writes.
472
+
473
+ Three statuses carry meaning, and the adapter turns each into the same outcome the in-memory adapter
474
+ gives you:
475
+
476
+ - `404` is `not-found` from a write, and `null` from a load.
477
+ - `409` is `conflict`, and its body states the ref that stands now: `{ "error": "conflict",
478
+ "current": { "id", "revision" } }`. A create quotes no revision, so it can conflict only on
479
+ identity: a drawings context that already holds a document, or a name a sibling already has.
480
+ - `428` states that a conditional route was reached with no `If-Match`. The adapter always sends
481
+ one, so a `428` means the request did not arrive as it was sent.
482
+
483
+ Any other status, and any body that is not the shape the route promises, raises a
484
+ `RestSaveLoadError` carrying the status, the method and the URL. Decide what it means: an outage is
485
+ not an empty listing, and answering one as an empty listing is a lie the next save acts on.
486
+
487
+ ```ts
488
+ import { createRestSaveLoadAdapter, RestSaveLoadError } from '@trdrs/quickcharts/adapters/rest'
489
+
490
+ const saves = createRestSaveLoadAdapter({ baseUrl: 'https://api.example.com/chart-storage', request: fetch })
491
+ try {
492
+ const rows = await saves.layouts.list()
493
+ note(`${rows.length} saved layouts`)
494
+ } catch (e) {
495
+ if (e instanceof RestSaveLoadError) note(`${e.method} ${e.url} answered ${e.status}`)
496
+ else throw e
497
+ }
498
+ ```
499
+
500
+ The drawings collection is scoped by two query parameters: `symbol`, and an opaque `context` token
501
+ for the drawing-resource context. Store rows under the pair and return them under the pair; the
502
+ token is not for reading.
503
+
504
+ The full schema ships with the package as `dist/rest-openapi.json`, an OpenAPI document generated
505
+ from the same contract the adapter implements. Read it, lint it, or generate a server stub from it.
506
+
507
+ The adapter is a separate entrypoint, so a project that never imports it carries none of it.
508
+
509
+ ### Charts, layouts and their low-level APIs
510
+
511
+ The widget drives the charts family for you. `createChart({ saveLoad })` takes your adapter, and
512
+ `chart.saveLoad` holds the open saved chart: `save(name)` updates it at the revision it was opened
513
+ at (or creates, when nothing is open or you pass `asNew`), `load(id)` applies a saved chart and
514
+ opens it, `remove()` deletes the open one at its held revision, `detach()` forgets the binding so
515
+ the next save creates, and `current()` reports the ref and name on screen. `serialize()` hands you
516
+ the same opaque content the save writes, and `restore(content)` applies one. A layout does the same
517
+ for itself through `widget.layout.saveLoad` over the layouts family.
518
+
519
+ A load is one transaction. The content is read and applied first, and the resource's id, name and
520
+ revision become the open resource only after that succeeds. A saved layout is read whole, its
521
+ version, arrangement, normalized custom pane geometry, sync flags, active tile and every chart
522
+ inside it included, before a single tile moves. The current layout reader accepts only this v2
523
+ shape. Divider drags preserve the selected arrangement's topology and save normalized geometry;
524
+ tile maximize is transient and preserves every chart instance. If a chart
525
+ callback synchronously detaches or starts another load while that content is applying, the later
526
+ operation owns the binding. The superseded load answers `cancelled` and cannot restore its id after
527
+ the callback returns.
528
+
529
+ The built-in layout setup control follows committed layout state, including direct API changes and
530
+ restores. Its arrangement glyph, accessible name, selected tile, and five synchronization switches
531
+ remain in step while the menu is open; transient tile maximize does not change the arrangement it
532
+ names.
533
+
534
+ Growing a layout keeps every existing chart instance and initializes each new tile from pane 0's
535
+ safe presentation: symbol, timeframe, style, studies and comparisons. Mutable study configuration
536
+ is copied per tile; replay, session state and saved-chart identity are not copied.
537
+
538
+ `load` answers rather than rejecting. Five kinds, told apart without reading a message:
539
+
540
+ | `kind` | What happened |
541
+ |---|---|
542
+ | `ok` | The content is on screen and the resource is open. Carries its `ref` and `body`. |
543
+ | `not-found` | The store answered, and holds no resource under that id. |
544
+ | `invalid` | The content could not be read, or could not be applied. |
545
+ | `unavailable` | The store could not be reached. Carries `cause`, the error it failed with. |
546
+ | `cancelled` | The load was abandoned: a later load took its place, your signal aborted, or the widget went down. |
547
+
548
+ Only `ok` binds. A refusal before application leaves the screen and its former binding untouched.
549
+ If a synchronous callback supersedes a load during application, the applied screen is left unbound
550
+ unless the newer operation has already committed its own binding, so it can never autosave through
551
+ the resource that was on screen before the load. An `AbortSignal` of your own ends the load as
552
+ `cancelled` too, so nothing in this family rejects for a load that was abandoned. A widget built
553
+ with no `saveLoad` adapter is the one case that is not an outcome: every verb of the family rejects,
554
+ because nothing was wired for them to reach.
555
+
556
+ ```ts
557
+ import { createChart, createUdfDatafeed, memorySaveLoadAdapter } from '@trdrs/quickcharts'
558
+
559
+ declare const container: HTMLElement
560
+ const widget = createChart({
561
+ container,
562
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
563
+ saveLoad: memorySaveLoadAdapter(),
564
+ })
565
+ widget.on('saveConflict', (info) => console.warn(info.message))
566
+ const saveLoad = widget.activeChart().saveLoad
567
+ const saved = await saveLoad.save('Morning')
568
+ if (saved.kind === 'conflict') console.warn(saved.message) // saved elsewhere since it was opened
569
+ saveLoad.current()?.name // 'Morning'
570
+
571
+ const opened = await saveLoad.load('chart-42')
572
+ switch (opened.kind) {
573
+ case 'ok':
574
+ break
575
+ case 'cancelled':
576
+ break // a later load is landing instead, so there is nothing to say
577
+ case 'unavailable':
578
+ console.warn(opened.message, opened.cause) // your store, not the content
579
+ break
580
+ case 'invalid':
581
+ case 'not-found':
582
+ console.warn(opened.message) // still bound to what you had
583
+ break
584
+ }
585
+ ```
586
+
587
+ A load that a later load supersedes is `cancelled`, with no signal of your own involved, and only
588
+ the later load binds. This includes a later load begun synchronously by a chart callback during
589
+ application. A widget disposed while the store is still answering answers the same way. That is
590
+ what keeps a slow answer for the chart a user has left from landing on the chart they are looking
591
+ at.
592
+
593
+ Where a body fails after part of it has been applied, the chart puts back the content it held. If
594
+ that fails too the chart is holding neither, and it stops saving: `saveLoad.notSaving()` reports it,
595
+ `save` answers `not-saving` and writes nothing, the built-in layouts menu says so on the toolbar and
596
+ stands its Save and its autosave down, and the sentence for the trader arrives on `saveConflict`.
597
+
598
+ Saving starts again when a load lands, and only then: a whole content out of the store, applied in
599
+ full, under the resource it came from. `save(name, { asNew: true })` stays available throughout, and
600
+ it is worth offering, because a copy creates a resource of its own and writes over nothing, so the
601
+ work on screen is kept and the last version that was whole is left standing. It is a rescue rather
602
+ than a repair: a store accepting a write says nothing about the content of what it took, so the
603
+ chart goes on saving nowhere else until a load puts a whole content back.
604
+
605
+ A layout also refuses writes while any child chart is not saving. A complete layout load recovers
606
+ its children only after the whole content and layout binding commit, and detaches their previous
607
+ standalone chart bindings. Partial content and copy saves do not establish recovery.
608
+
609
+ A saved chart carries its symbol, timeframe, style, scale, appearance, comparisons, extension
610
+ state, combined-mode drawings, and every indicator instance. Indicator definitions remain code in
611
+ your widget. The blob names each definition by `manifest.id` and carries the instance id, explicit
612
+ inputs, color, title, overrides and hidden state. Loading replaces the full instance list, so the
613
+ constructor's `indicators` are seeds only for a chart with no saved content. Definitions resolve
614
+ against the widget's built-ins and the host definitions that widget has carried. An unknown or
615
+ access-denied definition is left out with one counted notice while the remaining content lands. A
616
+ definition of your own survives a save only when its manifest declares an `id`.
617
+
618
+ The opaque version-4 reader validates the whole indicator list before changing a chart or layout.
619
+ Missing lists, duplicate instance ids and malformed inputs, titles, colors or overrides refuse the
620
+ body as `invalid`; values are not silently filtered or normalized. A load that omits a definition
621
+ because it is unavailable is partial and cannot clear an earlier `notSaving()` state.
622
+ Complete recovery requires every typed appearance leaf with a valid concrete color value and,
623
+ in combined mode, a drawings array. Separate-mode chart content does not need that array.
624
+
625
+ Hydration and rollback do not emit `saveNeeded` for their own storage writes. An event already
626
+ queued by a user edit is preserved. A late save result does not replace a newer load's binding;
627
+ the built-in commands announce a saved layout only while that result is still current.
628
+
629
+ ### Drawings: combined or separate
630
+
631
+ Drawings have two storage modes, and you pick one when you construct the widget. **Combined** is the
632
+ default: the drawings ride the chart's own saved content, so saving a chart or a layout saves the
633
+ drawings on it. **Separate** keeps them out of that content entirely and stores them as their own
634
+ documents in the adapter's drawings family, one document per drawing-resource context:
635
+
636
+ ```ts
637
+ import { createChart, createUdfDatafeed, memorySaveLoadAdapter } from '@trdrs/quickcharts'
638
+
639
+ declare const separateContainer: HTMLElement
640
+ const separate = createChart({
641
+ container: separateContainer,
642
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
643
+ saveLoad: memorySaveLoadAdapter(),
644
+ // chart-local: one document per chart, per symbol. `layout-shared` gives the layout's charts one
645
+ // document per symbol, and `symbol-global` gives every chart one.
646
+ drawingPersistence: { mode: 'separate', scope: 'chart-local', layoutId: 'desk-1' },
647
+ })
648
+ const documents = separate.activeChart().drawingResources
649
+ const read = await documents?.get()
650
+ if (read?.kind === 'ok') {
651
+ // The ref the read stood at rides the apply, so the chart's next write is an update at that
652
+ // revision rather than a create that learns the ref from the refusal it gets back.
653
+ const applied = documents?.apply(read.document, read.ref)
654
+ // 'ok' with the drawings it attached, and every one it would not: an entry whose source or pane
655
+ // is not on this chart is named rather than moved onto whatever is nearest.
656
+ applied?.kind
657
+ }
658
+ await documents?.reload()
659
+ ```
660
+
661
+ A document carries each drawing's stable id, the source and pane that own it, its type and its own
662
+ opaque state, plus ordered groups and deletion tombstones. The tombstones are why a drawing you
663
+ delete stays deleted: another chart showing the same document still holds it, and the merge that
664
+ follows a refused write drops it instead of taking it back. A refused write reaches you through the
665
+ widget's `saveConflict` event.
666
+
667
+ `layoutId` is yours to choose and must be stable across reloads; `chart-local` and `layout-shared`
668
+ require it, because a document keyed by an id the next page load mints again could never be read
669
+ back. There is no reader, fallback or mirrored write between the two modes.
670
+
671
+ A `chart-local` document is keyed by the chart's tile in the layout, which is what survives a
672
+ reload. It is a position, so it follows the position: changing the arrangement, or closing a chart
673
+ with charts after it, renumbers those tiles, and each of them then opens the document of the tile it
674
+ now occupies. Choose `layout-shared` when the drawings belong to the layout rather than to one
675
+ tile.
676
+
677
+ ### Templates
678
+
679
+ `adapter.templates(kind)` is a store per kind: `'study'` for indicator settings, `'drawing'` for a
680
+ drawing tool's look, and `'palette'` for chart appearance. A drawing template carries the `tool` it
681
+ belongs to, because a trend-line template means nothing on a rectangle. Their content is opaque,
682
+ like a chart's.
683
+
684
+ ### User settings
685
+
686
+ The widget keeps the viewer's flat preferences in a `ChartStorage`. Every key is an opaque string in
687
+ the `quickcharts.` namespace, and every value is an opaque string; a store routes or scopes them and
688
+ reads neither. The default is an in-memory store that lasts the page; supply your own to keep them
689
+ per device or per account. A browser store is a few lines you write; it is not part of the package,
690
+ because a device-local default is not a persistence architecture:
691
+
692
+ ```ts
693
+ import { memoryChartStorage, type ChartStorage } from '@trdrs/quickcharts'
694
+
695
+ const perDevice: ChartStorage = {
696
+ get: (key) => localStorage.getItem(key),
697
+ set: (key, value) => localStorage.setItem(key, value),
698
+ remove: (key) => localStorage.removeItem(key),
699
+ keys: () => Object.keys(localStorage),
700
+ }
701
+ void perDevice
702
+ void memoryChartStorage()
703
+ ```
704
+
705
+ Preferences need no identity or revision. Saved charts, layouts, drawings and templates do, and they
706
+ live on the saved-resource adapter above, never here.
707
+
708
+ ### Conflicts
709
+
710
+ A conflict means someone else wrote the same entity after the copy on screen was read. The chart
711
+ never resolves that by overwriting. It reports the case with a sentence from its own catalog and the
712
+ ref that stands now, and leaves the decision to you: reload and lose the local edit, save a copy
713
+ under a new name, or show the user both.
714
+
715
+ Reach the report in two places. A verb you called answers a typed `conflict` outcome. A write the
716
+ widget made for the viewer, through the built-in UI or its own drawing sync, arrives on the
717
+ `saveConflict` event with the family it came from.
718
+
719
+ ### Common issues
720
+
721
+ **Nothing saves and no error appears.** The widget has no adapter. `createChart({ saveLoad })` is
722
+ what enables the family, and `widget.capabilities().saveLoad` reports which families it sees.
723
+
724
+ **Every save creates another row.** Something detached the open resource, or each save passes
725
+ `asNew`. `current()` reports what the next save will update.
726
+
727
+ **A saved layout opens with the wrong drawings.** In separate mode the document is keyed by
728
+ `layoutId` and the chart's tile. An id that changes between reloads, or a re-tile that moves a
729
+ chart, points the chart at another document.
730
+
731
+ **A conflict on the first save of the day.** Two tabs are open on the same entity. Both hold a
732
+ revision, and the second write is refused; reload the newer one before saving it again.
733
+
734
+ **A REST call raises instead of answering.** The status is one the contract gives no meaning to.
735
+ Read `error.status` and `error.url`: a `401` is a session your request function has to renew, and a
736
+ `5xx` is your service.
737
+
738
+
739
+ ## Indicators
740
+
741
+ Indicators are **definitions**: a declarative manifest (typed inputs + declared plots/levels/fills,
742
+ placement, volume needs) paired with a pure compute over the chart's bars. The package owns the
743
+ whole rendering pipeline (overlay and per-indicator panes, lines/areas/histograms/markers, static
744
+ levels, band fills, per-instance style overrides) and treats every definition alike, so a
745
+ definition renders identically in this widget and in any richer host built on the same pipeline.
746
+
747
+ ### The built-in indicators
748
+
749
+ The package ships 23 built-in definitions as `BUILT_IN_INDICATORS`, in picker order: SMA, EMA,
750
+ HMA, VWMA, Bollinger Bands, Donchian Channels, Keltner Channels, Supertrend, Parabolic SAR, RSI,
751
+ MACD, Stochastic, Stochastic RSI, ADX, ATR, CCI, Williams %R, Rate of Change, Momentum, Volume,
752
+ VWAP, On-Balance Volume, and Money Flow Index. Each is a plain `IndicatorDefinition` with its
753
+ catalog identity beside it: `id` (the stable key you persist), `tag` (the short mark a legend chip
754
+ shows), `category`, and `nameKey` and `descriptionKey`, which resolve through the chart's own
755
+ language object in every built-in locale.
756
+
757
+ ```ts
758
+ import { BUILT_IN_INDICATORS, createChart, createChartI18n, createUdfDatafeed } from '@trdrs/quickcharts'
759
+
760
+ const i18n = createChartI18n('en')
761
+ const rsi = BUILT_IN_INDICATORS.find((definition) => definition.id === 'rsi')!
762
+ const widget = createChart({
763
+ container,
764
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
765
+ i18n,
766
+ indicators: [{ id: 'rsi-14', definition: rsi, title: i18n.t(rsi.nameKey), color: '#f5a623' }],
767
+ })
768
+
769
+ const picker = BUILT_IN_INDICATORS.map((definition) => ({
770
+ id: definition.id,
771
+ name: i18n.t(definition.nameKey),
772
+ description: i18n.t(definition.descriptionKey),
773
+ }))
774
+ ```
775
+
776
+ An instance's `inputs` override the manifest defaults by key (`{ period: 21, source: 5 }` for an
777
+ RSI over HLC3). A built-in's `plotTitles` and `inputTitles` label its plots and inputs in a
778
+ settings surface.
779
+
780
+ ### Your own definitions
781
+
782
+ A host definition is the same shape. The math is yours; the manifest declares what to draw.
783
+
784
+ ```ts
785
+ import type { IndicatorDefinition } from '@trdrs/quickcharts'
786
+
787
+ export const smaDefinition: IndicatorDefinition = {
788
+ manifest: {
789
+ name: 'SMA',
790
+ pane: 'overlay',
791
+ inputs: { period: { kind: 'int', default: 20, min: 1 } },
792
+ plots: { sma: { kind: 'line', lineWidth: 2 } },
793
+ },
794
+ compute(bars, inputs) {
795
+ const period = inputs.period ?? 20
796
+ const out: (number | null)[] = bars.map((_, i) => {
797
+ if (i + 1 < period) return null // warmup → whitespace, never a fake value
798
+ let sum = 0
799
+ for (let k = i + 1 - period; k <= i; k++) sum += bars[k]!.c
800
+ return sum / period
801
+ })
802
+ return { sma: out }
803
+ },
804
+ }
805
+ ```
806
+
807
+ Wire instances through `ChartWidgetOptions.indicators`:
808
+
809
+ ```ts
810
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
811
+
812
+ const widget = createChart({
813
+ container,
814
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
815
+ indicators: [{ id: 'sma-20', definition: smaDefinition, color: '#4c98fb' }],
816
+ })
817
+ ```
818
+
819
+ Rules the pipeline enforces:
820
+
821
+ - **Channels align 1:1 to bars.** `compute` returns one value array per declared plot key;
822
+ null/NaN entries become clean whitespace gaps (warmups break, never bridge).
823
+ - **Placement is the manifest's.** `pane: 'pane'` gives the instance its own bottom pane
824
+ (created and swept automatically); `'overlay'` rides the main price scale.
825
+ - **A plot can ride the volume band.** `scale: 'volume'` on a plot pins it to the chart's volume
826
+ histogram instead of the pane's price scale.
827
+ - **A fill can shade between levels.** A fill whose `between` names two levels rather than two
828
+ plots shades between those limit lines wherever the bars are.
829
+ - **`needsVolume` is honest.** On a feed whose bars carry no volume, the instance draws nothing
830
+ and reports "No volume from this feed" instead of painting a flat lie.
831
+ - **Overrides layer, never fork.** Per-instance styling (`IndicatorOverrides`) folds into the
832
+ manifest before the walk and gates visibility after, the same layering every host applies.
833
+ - **Instances save by definition id.** Saved charts and layouts retain each instance's explicit
834
+ inputs, look and hidden state. Give a host definition a stable `manifest.id` so the widget can
835
+ resolve it when content loads.
836
+
837
+ The lower-level pieces are exported for hosts that orchestrate their own compute:
838
+ `buildManifestPlots` (the walker), `attachIndicators` (the renderer), `overriddenManifest` /
839
+ `applyPlotOverrides` (the override fold), and the fill/shade canvas painters.
840
+
841
+ ## The widget
842
+
843
+ `createChart(options)` mounts a complete datafeed-driven chart into a DOM element, with no framework
844
+ required. It answers a `ChartWidget`: one or many charts under one root, one theme, one language,
845
+ one command registry.
846
+
847
+ ```ts
848
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
849
+
850
+ const widget = createChart({
851
+ container: document.getElementById('chart')!,
852
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
853
+ symbol: 'ES',
854
+ timeframe: '1m',
855
+ style: 'candles',
856
+ theme: { mode: 'dark' },
857
+ })
858
+ await widget.ready() // the first data has painted
859
+ widget.activeChart().setSymbol('NQ')
860
+ widget.dispose() // teardown; every handle and subscription is inert afterwards
861
+ ```
862
+
863
+ The widget paints the main series and volume, applies live updates by the bar rules above, pages
864
+ older history in as the viewer scrolls left (stopping at the feed's `noData`), persists the viewer's
865
+ preferences through `ChartStorage`, runs configured indicator instances through the manifest
866
+ pipeline, and mounts the drawing layer, the legend and the level menu.
867
+
868
+ When older main or comparison history extends the renderer's shared timeline, the chart retains
869
+ the latest fractional logical window, spacing and permitted off-data padding. Its compensating
870
+ range write is not a viewer event and does not fan through layout date-range synchronization. The
871
+ write owns the renderer's deferred reports only while the current symbol generation and navigation
872
+ epoch still match. A coalesced mouse, touch or wheel pan/zoom remains observable and
873
+ takes priority, including when a mouse drag continues outside the chart or renderer bounds retain
874
+ the maintenance span or either endpoint. Public range methods and built-in navigation commands
875
+ enter that same epoch, so every supported navigation door has the same precedence.
876
+
877
+ `toolbarContainer` optionally supplies a separate element for the top toolbar, such as a space
878
+ between a host's navigation and account controls. The widget appends one package-owned, themed
879
+ child there; it never clears or styles the supplied element. The toolbar follows the widget's
880
+ theme, language and active chart, and its child is removed at disposal. The toolbar's menus and
881
+ dialogs open in the widget's layer on the document body, at viewport coordinates, so each menu
882
+ drops from its control wherever you place the toolbar and stands over your own elements. Supply a
883
+ `fullscreen.target` containing both elements to keep the
884
+ toolbar available in fullscreen. Without `toolbarContainer`, the toolbar sits above the chart as
885
+ part of the default composition. When horizontal space is limited, the toolbar scrolls without
886
+ dropping controls from the keyboard order.
887
+
888
+ `drawingToolbarContainer` supplies separate host space for a single drawing rail. The package
889
+ fills that space's height with its own themed child, follows the active chart when a layout
890
+ changes, and removes the child on disposal. An external rail leaves the full chart width for the
891
+ canvas; without the option, each chart keeps its internal rail and reserved leading column.
892
+ Drawing flyouts stay beside the external rail within the widget's bounds regardless of the active
893
+ pane. Switching charts closes an open flyout before the rail targets the newly active chart.
894
+ Include this container in the custom fullscreen target alongside the chart and top toolbar.
895
+
896
+ ### Widget and chart
897
+
898
+ Two scopes, because two things are true at once: what the widget as a whole is doing, and what one
899
+ chart is doing. `widget.charts()` lists every chart; `widget.activeChart()` is the one the viewer
900
+ last pointed at; `widget.chart(id)` finds one by its stable id.
901
+
902
+ ```ts
903
+ import { CHART_STYLES, type ChartHandle, type ChartWidget } from '@trdrs/quickcharts'
904
+
905
+ function drive(widget: ChartWidget): void {
906
+ const chart: ChartHandle = widget.activeChart()
907
+ chart.setTimeframe('5m')
908
+ chart.setStyle(CHART_STYLES[3]!) // 'line'
909
+ chart.setScaleMode('log')
910
+ chart.scroll(-50) // fifty bars back
911
+ chart.zoom(0.8) // show fewer bars
912
+ chart.reset() // fit the loaded data
913
+ chart.goLive() // back to the live edge, same span
914
+ chart.indicators.set([])
915
+ void chart.visibleRange()
916
+ void chart.rangePreset() // selected bottom-bar range, or null after other navigation
917
+ void chart.logicalRange()
918
+ }
919
+ ```
920
+
921
+ ### The seven chart styles
922
+
923
+ `candles`, `hollow`, `bars`, `line`, `area`, `baseline`, `stepline`, listed in picker order as
924
+ `CHART_STYLES`. A style switch is presentation: nothing refetches, and the loaded bars, indicators,
925
+ drawings, comparisons, scale and visible range all survive it. Four of the seven are value-shaped
926
+ (`valueShaped(style)`), which is the one predicate a host branches on when it renders open, high and
927
+ low values of its own.
928
+
929
+ ### Events
930
+
931
+ Typed maps, one per scope. Every subscription returns its unsubscribe and is inert after
932
+ `dispose()`.
933
+
934
+ ```ts
935
+ import { type ChartWidget } from '@trdrs/quickcharts'
936
+
937
+ function watch(widget: ChartWidget): () => void {
938
+ const offTheme = widget.on('theme', (_theme, mode) => document.body.setAttribute('data-mode', mode))
939
+ const offSave = widget.on('saveNeeded', () => void persist())
940
+ const offSymbol = widget.activeChart().on('symbol', (symbol) => header.setSymbol(symbol))
941
+ const offStatus = widget.activeChart().on('feedStatus', (status) => banner.set(status))
942
+ return () => {
943
+ offTheme()
944
+ offSave()
945
+ offSymbol()
946
+ offStatus()
947
+ }
948
+ }
949
+ ```
950
+
951
+ Widget events: `ready`, `activeChart`, `theme`, `locale`, `saveNeeded`, `saveConflict`,
952
+ `fullscreen`, `dispose`. Chart events: `symbol`, `timeframe`, `style`, `visibleRange`,
953
+ `logicalRange`, `dataLoaded`, `feedStatus`, `scaleMode`, `timezone`, `indicator`, `drawing`,
954
+ `replay`, `compare`, `history`.
955
+
956
+ ### Commands
957
+
958
+ One registry is the only place a chart verb exists. The chart's own menu, your toolbar, a keyboard
959
+ binding and an automation adapter all read this list and run through this `execute`, so a command
960
+ your feature configuration hides or your access policy refuses cannot be reached from any of them.
961
+
962
+ ```ts
963
+ import { type ChartWidget, type CommandResult } from '@trdrs/quickcharts'
964
+
965
+ function toolbar(widget: ChartWidget): void {
966
+ for (const spec of widget.commands.list()) {
967
+ button(spec.labelText ?? translate(spec.label), {
968
+ enabled: widget.commands.available(spec.id),
969
+ shortcut: spec.shortcut,
970
+ run: () => {
971
+ const outcome: CommandResult = widget.commands.execute(spec.id)
972
+ if (outcome.kind === 'denied') note('not permitted here')
973
+ },
974
+ })
975
+ }
976
+ widget.commands.setShortcut('chart.view.reset', 'Alt+KeyR')
977
+ const off = widget.commands.onChange(() => rebuild())
978
+ void off
979
+ }
980
+ ```
981
+
982
+ A command whose one id spans many subjects declares `refuses(arg)` beside `available()`: an
983
+ argument your access policy turns away answers `denied` before availability is asked, which is how
984
+ `chart.drawings.arm` refuses a tool. A refusal is a value, never a throw: `ok`, `unavailable`,
985
+ `denied`, `unknown`, or `failed` with the
986
+ error. Your own commands register through the same door: a chart extension's `contributeCommands`
987
+ puts them in this list with `scope: 'chart'` and its own label text.
988
+
989
+ ### Configuration planes
990
+
991
+ Five planes configure a widget, and each answers one question.
992
+
993
+ | Plane | What it answers | Who decides |
994
+ |---|---|---|
995
+ | `capabilities()` | what the ports, the resolved symbol and the browser can do | derived, never set |
996
+ | `features` | which chart behaviors exist | you |
997
+ | `ui` | which of the chart's own controls render | you |
998
+ | `access` | which commands, drawing tools and indicators are permitted | you |
999
+ | `preferences` | the viewer's own values, persisted through `storage` | the viewer, seeded by you |
1000
+
1001
+ A behavior you turn off in `features` is gone: its commands report `unavailable`, and every control
1002
+ over it is absent. A control you hide in `ui` is the only thing that changes. The commands behind it stay available, so a control of your own can run them through
1003
+ `widget.commands`.
1004
+
1005
+ Two presentation flags also close the one command whose only job is opening their dialog.
1006
+ `ui.symbolSearch: false` removes the search dialog and `chart.symbol.search`, while
1007
+ `chart.symbol.set` still changes the symbol. `ui.indicatorPicker: false` removes the indicator
1008
+ browser and `chart.indicators.open`, while `chart.indicators.add` still adds an indicator.
1009
+
1010
+ Neither plane is authorization. A hidden control's command still answers to `access`, and no flag
1011
+ supplies a capability the datafeed lacks: a feed with no search keeps every search door closed.
1012
+
1013
+ ```ts
1014
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
1015
+
1016
+ const gated = createChart({
1017
+ container,
1018
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
1019
+ features: { replay: false },
1020
+ ui: { drawingToolbar: false, topBar: { layouts: false } },
1021
+ access: { command: (id) => !id.startsWith('chart.drawings.'), drawingTool: (tool) => tool !== 'brush' },
1022
+ preferences: { scaleMode: 'log', style: 'bars' },
1023
+ })
1024
+ if (gated.capabilities().search) mountSymbolPicker()
1025
+ ```
1026
+
1027
+ The library reads `features` and `ui` once, when the widget is created. A key or a value either
1028
+ plane does not take throws a `TypeError` naming its path before anything mounts. The package's
1029
+ `dist/feature-manifest.json` lists every flag in its `features` and `ui` blocks.
1030
+
1031
+ ### Your own interface
1032
+
1033
+ The widget supports three ways to present a chart, over the same behavior and the same commands.
1034
+
1035
+ - The default interface: pass no `ui`, and every control renders.
1036
+ - The branded default: keep the controls, and draw their glyphs with `icons` and their colors with
1037
+ the theme.
1038
+ - Your own controls: hide the chart's controls in `ui` and build yours over `widget.commands`, the
1039
+ chart handles and their events. Your control reaches what the built-in one reached and answers to
1040
+ the same access policy.
1041
+
1042
+ Every built-in control runs a command, so a control of yours needs nothing beyond the registry and
1043
+ the handles. `commands.available` says whether a verb would run now, and the widget and chart
1044
+ events tell you when to ask again.
1045
+
1046
+ ```ts
1047
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
1048
+
1049
+ const custom = createChart({
1050
+ container,
1051
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
1052
+ ui: { topBar: false, drawingToolbar: false },
1053
+ })
1054
+
1055
+ const undo = document.createElement('button')
1056
+ undo.textContent = 'Undo'
1057
+ const paint = (): void => {
1058
+ undo.disabled = !custom.commands.available('chart.history.undo')
1059
+ }
1060
+ undo.addEventListener('click', () => custom.commands.execute('chart.history.undo'))
1061
+ custom.activeChart().on('history', paint)
1062
+ paint()
1063
+
1064
+ const search = document.createElement('button')
1065
+ search.textContent = 'Search'
1066
+ search.addEventListener('click', () => custom.commands.execute('chart.symbol.search'))
1067
+ container.before(undo, search)
1068
+ ```
1069
+
1070
+ The chart's own dialogs still open from their commands while its bars are hidden.
1071
+ `chart.symbol.search`, `chart.indicators.open`, `chart.compare.open` and `widget.layout.open` raise
1072
+ them in the widget's layer on the document, and `widget.layout.save` asks a layout that was never
1073
+ saved for its name. Hide a dialog in `ui` when your interface provides its own.
1074
+
1075
+ The library does not export its internal components, replace its renderer, or take markup for its
1076
+ controls. The class names under the widget's root are private, apart from the styling hooks listed
1077
+ under Theme.
1078
+
1079
+ ### Fullscreen and image
1080
+
1081
+ Chart-root fullscreen fills the screen with the widget's own element. It never takes over your
1082
+ application shell, which is a different thing from a fit-to-container layout your CSS owns.
1083
+
1084
+ ```ts
1085
+ import { type ChartWidget } from '@trdrs/quickcharts'
1086
+
1087
+ async function shareable(widget: ChartWidget): Promise<void> {
1088
+ await widget.fullscreen.toggle()
1089
+ const off = widget.on('fullscreen', (active) => chrome.setCompact(active))
1090
+ if (!(await widget.image.copy())) await widget.image.download('desk.png')
1091
+ const blob: Blob = await widget.image.capture()
1092
+ void blob
1093
+ off()
1094
+ }
1095
+ ```
1096
+
1097
+ `capture()` composes the chart, or every chart of a layout, into one PNG under a header carrying the
1098
+ identity and the attribution you configured through `image`. Nothing is uploaded, shared or stored:
1099
+ you receive a blob and decide.
1100
+
1101
+ ### Indicator browser content
1102
+
1103
+ The Indicators button and `commands.execute('chart.indicators.open')` open the same browser.
1104
+ It lists the 23 shipped definitions, with search and Favorites. Add creates a new instance on the
1105
+ currently active chart and keeps the browser open. Favorites use your `ChartStorage` port.
1106
+
1107
+ Supply `ChartWidgetOptions.indicatorPicker` to include additional localized collections and rows
1108
+ in that browser. You supply data and opaque actions, not DOM, indicator definitions, or a renderer.
1109
+ Built-in annotations can add favorite state, counts and actions; they cannot replace a definition,
1110
+ its name, or Add. Without a source, the browser has only shipped built-ins and Favorites.
1111
+
1112
+ ```ts
1113
+ import type { IndicatorPickerSource } from '@trdrs/quickcharts'
1114
+
1115
+ const indicatorPicker: IndicatorPickerSource = {
1116
+ collections: [{ id: 'saved', label: 'Saved studies' }],
1117
+ async list({ collection, query, builtInIds }, signal) {
1118
+ if (signal.aborted) return { kind: 'unavailable', message: 'Content unavailable.' }
1119
+ return {
1120
+ kind: 'ok',
1121
+ items: collection === 'saved' && 'Example'.toLowerCase().includes(query.toLowerCase())
1122
+ ? [{ id: 'example', title: 'Example', primaryAction: 'inspect', actions: [{ id: 'inspect', label: 'Inspect' }] }]
1123
+ : [],
1124
+ builtIns: builtInIds.map((id) => ({ id })),
1125
+ }
1126
+ },
1127
+ async act({ target, action }, signal) {
1128
+ if (signal.aborted) return { kind: 'refused', message: 'Action cancelled.' }
1129
+ if (target.kind !== 'item' || action !== 'inspect') return { kind: 'refused', message: 'Action unavailable.' }
1130
+ return { kind: 'ok' }
1131
+ },
1132
+ }
1133
+ ```
1134
+
1135
+ The source receives the collection, query and shipped definition ids. It owns filtering and any
1136
+ service limits; the browser does not promise an unlimited catalog or implement paging for you.
1137
+ Collection ids `builtin` and `favorites`, and action ids `add` and `favorite`, are reserved.
1138
+ Item ids cannot collide with shipped definition ids or these reserved ids. Duplicate ids,
1139
+ malformed rows and missing primary actions refuse the response; the shipped catalog remains usable.
1140
+ An item has up to two actions; a built-in annotation has at most one beside Add. Collections can
1141
+ name a localized navigation group and use `layout: 'list'` for name/secondary-action rows.
1142
+ An absent count stays blank. Omit `favorite` to omit that action on a contributed item.
1143
+
1144
+ A favorite action receives the requested next boolean. Successful actions refresh the current
1145
+ collection unless they return `close: true`. Refusals and exceptions keep the browser open with
1146
+ plain text, never rendered markup. Icons reuse `ChartExtensionIcon`; invalid icons draw no glyph.
1147
+ The host localizes contributed labels and messages; the chart localizes its own controls.
1148
+
1149
+ Changing query or collection aborts pending work. Closing or disposing the widget aborts it too.
1150
+ Late responses are ignored even when the source ignores cancellation. Your action must check its
1151
+ signal before late UI effects; cancellation cannot undo a service mutation already accepted.
1152
+
1153
+ ### Default chrome
1154
+
1155
+ The widget mounts its complete chrome around the charts. Import `@trdrs/quickcharts/styles.css` once; the
1156
+ chrome is painted from it and renders nothing without it.
1157
+
1158
+ - **The top bar.** The symbol pill opens the symbol search for the active chart; the compare door
1159
+ opens it in compare mode. The timeframe picker shows the saved timeframes as chips and a list of
1160
+ the 26 presets in five groups, each row savable as a chip, with a composer for a custom interval
1161
+ under the unit's ceiling. The style picker lists the seven styles. Indicators opens the picker
1162
+ over the 23 built-in definitions, and the legend's gear opens the settings dialog for an instance
1163
+ (inputs, style, visibility). Bar replay enters and leaves replay for the active chart; entering
1164
+ asks where to begin rather than choosing a starting bar. Its starting-point menu answers with a
1165
+ bar picked on the plot, a date, the first available date, or a random bar. The first available
1166
+ date runs `chart.replay.startFirst`: the chart pages the feed's history back to its oldest bar,
1167
+ within the 20,000 bars one session holds, and starts there. Undo and
1168
+ redo step back and forward through the active chart's own content, and each names the change it
1169
+ would move. Layout setup offers the 55
1170
+ arrangements and the five sync switches; the saved-layouts menu saves, copies, renames, opens and
1171
+ deletes layouts through `saveLoad.layouts` and marks unsaved changes, with an autosave switch.
1172
+ Chart settings edits appearance, grid and session shading, the price-scale mode and the theme
1173
+ mode, and its Reset defaults row runs `chart.appearance.reset`, which drops the viewer's own
1174
+ appearance edits and returns the price scale to normal so the chart reads as the theme and your
1175
+ constructor options paint it. `ui: { topBar: { settings: { theme: false } } }` removes its Theme
1176
+ section and leaves the rest of the menu, for a host that offers the theme choice in its own
1177
+ settings; the widget's theme API and theme commands are untouched. Fullscreen and the image menu (Download image, and Copy image where the browser can) close
1178
+ the bar.
1179
+ - **The bottom bar.** The range presets, the clock in the display zone with the timezone list
1180
+ (UTC and the exchange choice first), and the session view for a symbol that trades outside
1181
+ regular hours.
1182
+ - **Around the charts.** A navigation cluster (zoom, scroll, reset) sits at the bottom of each
1183
+ pane, and past one tile it carries a sixth control that fills the layout with the active tile or
1184
+ gives the layout back, over `widget.layout.toggleMaximize`, the same verb the Alt press on a tile
1185
+ and the `Alt+Enter` chord run. It wears the mark and the name for what the next press does. The market-status popup opens behind the legend's dot. From the moment replay is
1186
+ entered, one reserved
1187
+ transport row spans the widget below the chart grid and above the bottom bar. The first chart to
1188
+ enter replay owns that row until it exits or is removed; activating or starting another chart
1189
+ does not retarget it. Concurrent charts keep their independent `ChartReplayApi` and active-chart
1190
+ top-bar command. An already-running non-owner is not promoted when the owner leaves, but its next
1191
+ replay entry can claim the row. Chart notices report a feed that cannot serve the symbol, an
1192
+ image that could not be copied, or a save the store refused.
1193
+
1194
+ Undo and redo step through the chart's own content: the symbol, the interval, the style, the price
1195
+ scale and whether it frames itself, the appearance a viewer authored, the comparisons, the
1196
+ indicators and the drawings. A step is one reading of that content, so a step back puts the whole
1197
+ reading back rather than reversing a single verb, and the two controls name the change they would
1198
+ move. Each chart keeps its own last 100 steps for as long as it is mounted. `chart.history.undo`
1199
+ and `chart.history.redo` are the verbs, `chart.history` on the handle reads the two stacks, the
1200
+ `history` event reports every move of either one. `features: { history: false }` removes the history
1201
+ and both controls, and `ui: { topBar: { history: false } }` removes only the controls.
1202
+
1203
+ #### Your own controls in the top bar
1204
+
1205
+ A service the chart does not implement can still stand in its toolbar. `widget.chrome.topBar(slot)`
1206
+ answers the element at one of nine named boundaries. Append your control to it, and remove your own
1207
+ node when you are done with it. The slot belongs to the chart and is never removed.
1208
+
1209
+ | Slot | Where it stands |
1210
+ | --- | --- |
1211
+ | `start` | Before the symbol pill |
1212
+ | `afterSymbol` | After the symbol pill and the compare door |
1213
+ | `afterTimeframe` | After the timeframe picker |
1214
+ | `afterStyle` | After the style picker |
1215
+ | `afterIndicators` | Between Indicators and Bar replay |
1216
+ | `afterReplay` | After Bar replay |
1217
+ | `afterHistory` | After Undo and Redo |
1218
+ | `afterLayouts` | After the layout menus |
1219
+ | `end` | After Fullscreen and the image menu |
1220
+
1221
+ Each name says which group the slot follows, so a control placed there leads the group after it.
1222
+ `TOP_BAR_SLOTS` lists them in reading order. Every slot exists whichever controls `ui` leaves
1223
+ standing, and an empty slot has no width and no ink.
1224
+
1225
+ `widget.chrome.toolbarButton(options)` makes a control that reads as one of the bar's own. It keeps
1226
+ the bar's height, spacing, hover, pressed and open states, and when the row runs short it gives up
1227
+ its words before the bar's own last label does. You supply the accessible name, optional words, an
1228
+ optional glyph drawn by an icon factory, and what a press does. `pressed` makes it a toggle, and
1229
+ `popup` announces a menu or dialog of your own whose open state you report through `update`.
1230
+
1231
+ ```ts
1232
+ import { createChart, createUdfDatafeed, TOP_BAR_SLOTS, type ChartIconFactory } from '@trdrs/quickcharts'
1233
+
1234
+ const bell: ChartIconFactory = ({ document }) => {
1235
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1236
+ svg.setAttribute('viewBox', '0 0 28 28')
1237
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1238
+ path.setAttribute('d', 'M14 6a5 5 0 0 0-5 5v5l-2 3h14l-2-3v-5a5 5 0 0 0-5-5Zm-2 15a2 2 0 0 0 4 0')
1239
+ path.setAttribute('fill', 'none')
1240
+ path.setAttribute('stroke', 'currentColor')
1241
+ svg.append(path)
1242
+ return svg
1243
+ }
1244
+
1245
+ const hosted = createChart({
1246
+ container,
1247
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
1248
+ })
1249
+
1250
+ const alerts = hosted.chrome.toolbarButton({
1251
+ label: 'Price alerts',
1252
+ text: 'Alerts',
1253
+ icon: bell,
1254
+ popup: 'dialog',
1255
+ onClick: () => {
1256
+ note(`alerts for ${hosted.activeChart().symbol()}`)
1257
+ alerts.update({ expanded: true })
1258
+ },
1259
+ })
1260
+ hosted.chrome.topBar('afterIndicators')?.appendChild(alerts.element)
1261
+ note(`${TOP_BAR_SLOTS.length} slots`)
1262
+ ```
1263
+
1264
+ The chart's class names stay private: style what you render inside your own popup. A glyph factory
1265
+ that fails leaves the control without a glyph and records a `toolbarButton` diagnostic, as the
1266
+ Icons section describes.
1267
+
1268
+ The chart still owns the bar's composition: which of its controls are present, the order they stand
1269
+ in, and where the rules fall between them. You choose what stands at a boundary. `topBar` answers
1270
+ null when `ui: { topBar: false }` removes the bar, which is what to check before composing a control
1271
+ you would have nowhere to put.
1272
+
1273
+ Every control acts through the command registry and reflects `commands.available`, so a command
1274
+ your access policy refuses renders disabled and does nothing. The saved-layouts menu runs the
1275
+ layout verbs `widget.layout.save`, `rename`, `load`, `open`, `delete`, `create` and `autosave`; what a
1276
+ verb did reports through the `layout` event and what it refused through `saveConflict`. Copy image
1277
+ reports through the `image` event (a refused copy falls back to a download). Each control has a flag
1278
+ in `ui`; hiding one removes the control and leaves its commands.
1279
+
1280
+ ```ts
1281
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
1282
+
1283
+ const trimmed = createChart({
1284
+ container,
1285
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
1286
+ ui: { navigation: false, topBar: { layouts: false, image: false } },
1287
+ search: { classNames: { future: 'Futures', crypto: 'Crypto' } },
1288
+ preferences: { savedTimeframes: ['1m', '15m', '1h', '1d'], layoutAutosave: true },
1289
+ })
1290
+ trimmed.on('saveNeeded', () => note('the layout has unsaved changes'))
1291
+ ```
1292
+
1293
+ The search dialog's class chips come from your datafeed's `config().classes`, named through
1294
+ `search.classNames`; a class you do not name wears its token. The range presets read your
1295
+ datafeed's optional `earliestBar(symbol)`: a preset deeper than the history you serve is disabled.
1296
+
1297
+ ```ts
1298
+ import { type ChartDatafeed } from '@trdrs/quickcharts'
1299
+
1300
+ const withDepth: Pick<ChartDatafeed, 'earliestBar'> = {
1301
+ async earliestBar(symbol): Promise<number | null> {
1302
+ return myBackend.firstBarTime(symbol)
1303
+ },
1304
+ }
1305
+ void withDepth
1306
+ ```
1307
+
1308
+ Keyboard and screen readers: every menu is one tab stop (arrow keys, Home and End move among rows,
1309
+ Escape closes and returns focus to the control that opened it); every dialog is modal, traps Tab and
1310
+ restores focus; every control carries an accessible name and its state; the root carries the
1311
+ language's reading direction, so the chrome mirrors for Arabic and Hebrew; motion flattens under
1312
+ `prefers-reduced-motion`.
1313
+
1314
+ ### Icons
1315
+
1316
+ `icons` draws the glyphs of the chart's own controls. Each key is a published icon id and each value
1317
+ is a factory that returns a fresh `<svg>` element. An id you leave out keeps the chart's own glyph.
1318
+ `CHART_ICON_IDS` lists every id, and the feature manifest's `icons` block lists them too. The
1319
+ `ChartIconId` type is exactly that list, so an id the chart does not draw is a type error as well
1320
+ as a `TypeError` when the widget is created.
1321
+
1322
+ An id names what a glyph means, not one control. `settings` is the top bar's settings button, a
1323
+ study's gear in the legend and the drawing settings bar's gear, so one drawing stands in all three.
1324
+ Drawing tools take `tool.<type>`, chart styles `style.<style>` and layout arrangements
1325
+ `layout.<code>`, from the registries the rest of the API uses.
1326
+
1327
+ ```ts
1328
+ import { CHART_ICON_IDS, createChart, createUdfDatafeed, type ChartIconFactory, type ChartIcons } from '@trdrs/quickcharts'
1329
+
1330
+ const outline = (d: string): ChartIconFactory => ({ document }) => {
1331
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1332
+ svg.setAttribute('viewBox', '0 0 28 28')
1333
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1334
+ path.setAttribute('d', d)
1335
+ path.setAttribute('fill', 'none')
1336
+ path.setAttribute('stroke', 'currentColor')
1337
+ svg.append(path)
1338
+ return svg
1339
+ }
1340
+
1341
+ const icons: ChartIcons = {
1342
+ settings: outline('M14 9a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z'),
1343
+ 'tool.trend_line': outline('M6 22 22 6'),
1344
+ 'style.candles': outline('M9 7v14M19 7v14'),
1345
+ }
1346
+
1347
+ const branded = createChart({ container, datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }), icons })
1348
+ note(`${CHART_ICON_IDS.length} icons, ${branded.chrome.iconDiagnostics().length} not drawn`)
1349
+ ```
1350
+
1351
+ A factory receives the document to create nodes in, the box it fills in CSS pixels, and the reading
1352
+ direction. The chart sizes your element to the box and hides it from assistive technology. The
1353
+ control keeps its accessible name, hit area, focus, and pressed and disabled states, so artwork
1354
+ changes how a control looks and never what it does. Draw in `currentColor` to take the control's ink
1355
+ in every theme and state.
1356
+
1357
+ The direction is the widget's when the glyph is drawn. When a language change turns it around, the
1358
+ chart asks each factory again for every glyph it drew, a `toolbarButton` glyph included, and puts
1359
+ the new drawing in place of the old one. A factory that refuses the new direction leaves the
1360
+ previous drawing standing and records the failure.
1361
+
1362
+ The chart mirrors the glyphs in `MIRRORED_ICONS` for a right-to-left language whatever artwork they
1363
+ wear, so draw their left-to-right form. The line-end icons are drawn for a line's left end, and the
1364
+ right end's picker wears the same drawing mirrored.
1365
+
1366
+ The library reads `icons` once, when the widget is created. An id the chart does not draw, or a value
1367
+ that is not a function, throws a `TypeError` before anything mounts. A factory that throws, answers
1368
+ something other than an `<svg>` element, or answers an element already in the document or answered
1369
+ before costs that one glyph its artwork. The control draws the chart's own glyph, and
1370
+ `widget.chrome.iconDiagnostics()` records the icon's first failure.
1371
+
1372
+ `icons` covers the chart's own controls. The emoji, sticker and icon glyphs a drawing places come
1373
+ through the asset port, and a chart extension draws its rows and layers from the
1374
+ `ChartExtensionIcon` descriptors it contributes. The product's mark in the plot's corner is not an
1375
+ icon. `mountContextMenu`, `openSymbolSearch` and `mountSymbolSearch` take the same `icons` for a menu
1376
+ or search you mount without a widget.
1377
+
1378
+ ### Neutral marks
1379
+
1380
+ Serve `marks` and `timescaleMarks` from your datafeed and the chart draws them. A mark is a note
1381
+ about a moment: its color is a semantic theme role rather than a literal, its words are yours, and
1382
+ the chart neither interprets nor acts on it.
1383
+
1384
+ ```ts
1385
+ import { type BarMark, type ChartDatafeed } from '@trdrs/quickcharts'
1386
+
1387
+ const withMarks: Pick<ChartDatafeed, 'marks'> = {
1388
+ async marks(symbol, from, to): Promise<readonly BarMark[]> {
1389
+ const rows = await myBackend.events(symbol, from, to)
1390
+ return rows.map((row: { id: string; at: number; headline: string }) => ({
1391
+ id: row.id,
1392
+ time: row.at,
1393
+ color: 'info',
1394
+ text: 'E',
1395
+ label: row.headline,
1396
+ }))
1397
+ },
1398
+ }
1399
+ void withMarks
1400
+ ```
1401
+
1402
+ `marks: false` draws none, even from a feed that serves them.
1403
+
1404
+ ### The rest of the widget
1405
+
1406
+ - **Scale modes.** `chart.setScaleMode('log' | 'percent' | 'indexed' | 'normal')`, persisted
1407
+ through `ChartStorage`, and reachable as commands.
1408
+ - **Session bands** (on by default; `features.sessions: false` opts out). Every stretch outside
1409
+ regular hours shades under the bars, driven by the session model built from the symbol's own
1410
+ `session`, `sessionHolidays`, `corrections` and `subsessions` in `resolve()`'s answer (see
1411
+ Timezones and sessions). A continuous market never bands; intraday only; an UNRESOLVED symbol
1412
+ never bands, which is the honest default rather than a coerced one, and it is enforced: the
1413
+ primitive's `model` getter admits `null` and a null draws nothing. A host that changes the model
1414
+ outside a `resolve()`, or supplies its own, calls the primitive's `refresh()` when it does; the
1415
+ chart does not invalidate the pane on a getter's value changing.
1416
+ - **A legend** (on by default; `ui: { legend: false }` removes it). The header shows the resolved
1417
+ display name, venue, timeframe, OHLC and change against the previous painted close. Prices use
1418
+ the symbol's formatter. Hover selects a bar; leaving restores the latest painted reading. Replay
1419
+ does not expose bars beyond its cursor. Missing metadata leaves the supplied symbol unchanged.
1420
+ Legend and search use a decorative monogram; they do not fetch symbol logos or infer a provider.
1421
+ Study and separate-pane comparison rows follow their renderer panes. Stable row controls retain
1422
+ focus during value updates. Study values read the first plot, using declared precision or the
1423
+ symbol formatter; Volume reads bar volume using resolved volume precision. The row-list toggle,
1424
+ study eye and pane collapse are independent controls. Pane restore remembers study identity
1425
+ across pane removal. The status control opens session details, retains an unknown-session state
1426
+ and is hidden during replay. Price-scale chips use the same commands as `setScaleMode`.
1427
+ - **An interface language** (`locale`, English by default), one of the 21 the package ships.
1428
+ `BUILT_IN_LOCALES` lists them for a picker: each carries its stable code, its canonical BCP 47
1429
+ `tag`, its reading direction (`ar` and `he_IL` are `rtl`), and its endonym. The chart's own chrome
1430
+ reads the language, and its axis and crosshair dates are formatted in it. English is in the
1431
+ bundle; every other dictionary is its own chunk, fetched the first time it is chosen and shared by
1432
+ every widget on the page. `widget.setLocale(code)` switches at runtime and resolves after the
1433
+ dictionary settles; `widget.locale()` reports the current one. Plural forms follow the language's
1434
+ CLDR rules through `Intl.PluralRules`, and a number in a message is written with the language's
1435
+ digits and grouping. The runtime is the package's own, framework-free and DOM-free, so a server
1436
+ render can import it.
1437
+ A host with a language the package does not ship registers it through `createChartI18n(code,
1438
+ { locales })`: a `ChartCustomLocale` names the code, tag, direction and endonym and supplies the
1439
+ dictionary chunk, a `ChartDictionary` typed against the English catalog so it cannot miss a key or
1440
+ flatten a plural. A code or tag the built-in inventory already holds is refused. A dictionary that
1441
+ arrives from data and misses a key reads English for that key and reports it to the `onMissing`
1442
+ option. A host can also supply `i18n: ChartI18n` of its own to own its codes, tags, dictionaries,
1443
+ loading, and fallback outright.
1444
+ Every piece of the chart's own chrome speaks the language; symbols, prices and anything the
1445
+ datafeed says are data and pass through untranslated. A host composing the chrome modules itself
1446
+ hands them a `ChartI18n` from `createChartI18n(code)` and reads the chart's words for drawing
1447
+ tools and arrangements through `toolName` and `arrangementName`.
1448
+
1449
+ ```ts
1450
+ import { createChart, createUdfDatafeed, SCALE_MODES } from '@trdrs/quickcharts'
1451
+
1452
+ const w = createChart({ container, datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }), locale: 'de' })
1453
+ w.activeChart().setScaleMode(SCALE_MODES.includes('log') ? 'log' : 'normal')
1454
+ w.activeChart().indicators.set([{ id: 'sma-20', definition: smaDefinition }])
1455
+ await w.setLocale('ja')
1456
+ ```
1457
+
1458
+ ```ts
1459
+ import { BUILT_IN_LOCALES, createChart, createChartI18n, createUdfDatafeed, type ChartCustomLocale } from '@trdrs/quickcharts'
1460
+
1461
+ // A language the package does not ship, with the dictionary served by the host.
1462
+ const frCA: ChartCustomLocale = {
1463
+ code: 'fr-CA',
1464
+ endonym: 'Français (Canada)',
1465
+ tag: 'fr-CA',
1466
+ dir: 'ltr',
1467
+ dictionary: () => myBackend.chartDictionary('fr-CA'),
1468
+ }
1469
+ const i18n = createChartI18n('fr-CA', {
1470
+ locales: [frCA],
1471
+ onMissing: (key, locale) => note(`${locale} has no text for ${key}`),
1472
+ })
1473
+ const picker = [...BUILT_IN_LOCALES, frCA].map(({ code, endonym, dir }) => ({ code, endonym, dir }))
1474
+ const w = createChart({ container, datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }), i18n })
1475
+ await w.setLocale(picker[0]!.code)
1476
+ ```
1477
+
1478
+ ## Theme
1479
+
1480
+ Quick Charts ships complete light and dark modes. Import the stylesheet once, choose a mode, and
1481
+ the chart canvas, toolbars, legend, scales, menus, dialogs and fields all render from it.
1482
+
1483
+ ```js
1484
+ import '@trdrs/quickcharts/styles.css'
1485
+ ```
1486
+
1487
+ **The stylesheet is required, and it is not only about color.** It carries the chart's LAYOUT: the
1488
+ root fills your container, the charts tile inside it, and the plot area sizes itself from that.
1489
+ Without it the root has no height, so the renderer measures zero and the chart paints nothing at
1490
+ all. If you mount a widget and see an empty box, this import is the first thing to check.
1491
+
1492
+ The stylesheet is scoped to the chart's own root element. It applies no reset to your document, it
1493
+ downloads no font, and it fetches nothing at runtime. Two charts on one page can run different
1494
+ modes.
1495
+
1496
+ ### Switch modes and customize the palette
1497
+
1498
+ `createThemeController` owns one chart's mode and its custom palettes. Every method resolves a
1499
+ complete theme and publishes it to subscribers once, so a switch never leaves a surface behind.
1500
+
1501
+ ```ts
1502
+ import { createThemeController, type CustomThemes } from '@trdrs/quickcharts'
1503
+
1504
+ const brand: CustomThemes = {
1505
+ light: { 'state.accent': '#1f6feb', 'canvas.background': '#fbfbfd' },
1506
+ dark: { 'state.accent': '#58a6ff' },
1507
+ }
1508
+
1509
+ const theme = createThemeController({ mode: 'light', custom: brand })
1510
+ const stop = theme.onChange((palette, mode) => note(`${mode} accent is ${palette['state.accent']}`))
1511
+
1512
+ theme.setMode('dark')
1513
+ theme.applyCustom({ dark: { 'state.accent': '#7ee787' } })
1514
+ theme.resetCustom()
1515
+ stop()
1516
+ ```
1517
+
1518
+ Switching modes keeps the symbol, timeframe, visible range, drawings and studies the chart already
1519
+ has. A palette you supply is a tint rather than a replacement: a role you do not name keeps its
1520
+ built-in value for that mode.
1521
+
1522
+ ### Roles
1523
+
1524
+ A role is a purpose, such as `text.muted` or `overlay.scrim`. `THEME_ROLES` is the published
1525
+ inventory, and the built-in palettes and the generated stylesheet are built from it. The custom
1526
+ property names are private, so a color, a size or a radius is always set through a role. The
1527
+ class names inside the stylesheet are private too, except the supported hooks below.
1528
+
1529
+ ### Cascade layers
1530
+
1531
+ The stylesheet declares two cascade layers and puts everything it contains in them: the built-in
1532
+ palettes in `trdrs.tokens`, every recipe in `trdrs.chart`. Nothing in it is unlayered. It marks a
1533
+ declaration `!important` only where a later layer must not undo it: an element's `hidden`
1534
+ attribute, the shortened motion under a reduced-motion preference, and the missing focus ring of a
1535
+ search field, which shows its focus by its caret. Your first stylesheet must declare the complete
1536
+ order before any product stylesheet loads, because a layer's position is fixed by the first
1537
+ statement that names it:
1538
+
1539
+ ```css
1540
+ @layer reset, trdrs.tokens, trdrs.chart, trdrs.platform, host;
1541
+ ```
1542
+
1543
+ Put your reset in `reset`. A reset left unlayered outranks every layered rule and strips the
1544
+ chart's controls of their borders and grounds. Put an intentional override of a supported hook in
1545
+ `host`, or leave it unlayered; either wins over the chart's recipe. A chart-only host declares only
1546
+ `reset`, the two chart layers and `host`; `trdrs.platform` is the layer a later product declares
1547
+ for itself. Inline styles the chart writes for measured geometry outrank any stylesheet rule, and
1548
+ a rule marked `!important` reverses layer order, so neither is a way to restyle the chart.
1549
+
1550
+ ### Supported styling hooks
1551
+
1552
+ A hook is a block-level surface you may write a rule against. Each announces its state through an
1553
+ attribute, never a modifier class, and each names the presentation a rule may change. A rule that
1554
+ changes anything else, or that targets a class not listed here, is unsupported and may stop
1555
+ applying in any release. Removing a hook, a state or a customization is a breaking change.
1556
+
1557
+ | Hook | Purpose | States | Customization |
1558
+ |---|---|---|---|
1559
+ | `.qc-topbar` | The top toolbar: symbol search, timeframe, chart style, indicators, layouts, replay and the widget menus. | | background-color, border, padding, gap, box-shadow |
1560
+ | `.qc-drawing-toolbar` | The drawing rail beside the plot. | `aria-orientation`: vertical beside the plot, horizontal in a host row. | background-color, border, padding, gap, box-shadow |
1561
+ | `.qc-bottombar` | The bottom bar: range shortcuts, the session clock and the timezone. | | background-color, border, padding, gap, box-shadow |
1562
+ | `.qc-legend` | The legend over the plot: the symbol, its reading and each study row. | | background-color, border, border-radius, padding, box-shadow, inset |
1563
+ | `.qc-menu-panel` | A floating menu opened from a toolbar control. | `hidden`: present while the menu is closed. | background-color, border, border-radius, padding, box-shadow |
1564
+ | `.qc-dialog` | A modal dialog: settings, search, layouts and the drawing editors. | `data-role`: which dialog this is, in the chart's own vocabulary. | background-color, border, border-radius, padding, box-shadow, max-width |
1565
+
1566
+ Scope a rule to your own chart container so it cannot reach a chart elsewhere on the page. The
1567
+ hooks, their states and their customization are published in `dist/theme-manifest.json` under
1568
+ `hooks`, beside the layer names under `layers`.
1569
+
1570
+ ```css
1571
+ @layer host {
1572
+ #my-chart .qc-topbar {
1573
+ background-color: #101828;
1574
+ border-bottom: 1px solid #1d2939;
1575
+ }
1576
+ }
1577
+ ```
1578
+
1579
+ ```ts
1580
+ import { THEME_ROLES, type ThemeRoleFamily } from '@trdrs/quickcharts'
1581
+
1582
+ const family: ThemeRoleFamily = 'text'
1583
+ const inkRoles = THEME_ROLES.filter((role) => role.family === family).map((role) => role.id)
1584
+ note(inkRoles.join(', '))
1585
+ ```
1586
+
1587
+ If a value you supply is not valid for the role it is written for, the chart keeps the built-in
1588
+ value and reports it. Read `theme.diagnostics()` for the role, the code and a sentence naming the
1589
+ problem. Configuration errors never reach a render.
1590
+
1591
+ ### Theme and appearance are two ladders
1592
+
1593
+ The theme palette is the broad brand surface. Chart appearance is the specific one: series colors,
1594
+ candle anatomy, grid visibility and study visuals in `ChartOverrides.appearance`. Where both could
1595
+ affect the same pixel, appearance wins.
1596
+
1597
+ Theme palette precedence, lowest first:
1598
+
1599
+ 1. the built-in palette for the selected mode;
1600
+ 2. your custom palette for that mode.
1601
+
1602
+ Chart appearance precedence, lowest first:
1603
+
1604
+ 1. the built-in appearance for the selected mode;
1605
+ 2. the constructor's `appearance` partial;
1606
+ 3. restored user appearance;
1607
+ 4. runtime `chart.applyAppearance` patches.
1608
+
1609
+ Resetting custom palettes returns the chart to the built-in mode and leaves saved chart appearance
1610
+ alone.
1611
+
1612
+ A saved chart carries the appearance leaves a viewer chose and no others, so a chart nobody restyled
1613
+ follows whatever theme the host gives it on the next load, and one whose owner picked candle colors
1614
+ opens in those colors on any theme.
1615
+
1616
+ ## Compare
1617
+
1618
+ Every chart can draw OTHER symbols beside its own. A compare is study-like: legend-managed, three
1619
+ placements, persisted in the chart content blob. `same-percent` shares the
1620
+ main price scale and flips it to percent while any such compare lives (the prior scale mode comes
1621
+ back when the last one leaves); `new-scale` binds the LEFT scale with absolute prices (the left
1622
+ axis exists only while such a compare does); `new-pane` takes a pane of its own. Compared bars
1623
+ clip to the main series window, so a compare never extends the time axis. `features.compareSymbols` supplies
1624
+ a curated quick-add list for the compare dialog; `compare.symbols()` reads it back.
1625
+
1626
+ The widget's standard Compare door lives in the top bar, and `ui: { topBar: { compare: false } }`
1627
+ hides it. The legend header does not duplicate that door. Commands and comparison-row actions remain
1628
+ available when the top-bar door is hidden, and `features: { compare: false }` removes comparing
1629
+ altogether. The built-in dialog's search rows add at any of the three placements, curated `compareSymbols` rows sit
1630
+ above results, and the ADDED section removes. Each compare takes a legend row whose title reopens
1631
+ the dialog in change-symbol mode (the pick re-keys the compare in place), with an eye and a remove
1632
+ beside the value (% under `same-percent`, the last close otherwise). In a layout, compares belong to
1633
+ each chart of their own (`widget.activeChart().compare`) and ride the layout blob with the rest of
1634
+ that chart's content.
1635
+
1636
+ ```ts
1637
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
1638
+
1639
+ const wc = createChart({
1640
+ container,
1641
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
1642
+ features: { compareSymbols: [{ symbol: 'ES', title: 'S&P 500 futures' }] },
1643
+ })
1644
+ const compare = wc.activeChart().compare
1645
+ compare.add('NQ', { placement: 'same-percent' }) // shares the scale; the axis flips to %
1646
+ compare.add('CL', { placement: 'new-pane' })
1647
+ compare.setVisible('CL', false)
1648
+ const active = compare.list() // [{ symbol, placement, color, visible }]
1649
+ note(`NQ last: ${compare.latest('NQ') ?? '-'}`)
1650
+ compare.remove('NQ') // the scale mode the trader held comes back
1651
+ ```
1652
+
1653
+ ## Timeframes
1654
+
1655
+ A timeframe token names a bar interval: a count and a unit, `1m`, `4h`, `1d`, `3mo`, `500t`. The
1656
+ units are ticks (`t`), seconds (`s`), minutes (`m`), hours (`h`), days (`d`), weeks (`w`) and
1657
+ months (`mo`), and each unit has a ceiling (`TIMEFRAME_MAX`). A token outside the grammar parses
1658
+ as null, and the chart never asks a feed for it.
1659
+
1660
+ ```ts
1661
+ import { formatTimeframe, isIntradayTimeframe, parseTimeframe, timeframeSeconds } from '@trdrs/quickcharts'
1662
+
1663
+ parseTimeframe('4h') // { count: 4, unit: 'h' }
1664
+ parseTimeframe('3mo') // { count: 3, unit: 'mo' }
1665
+ parseTimeframe('1441m') // null: past the minute ceiling
1666
+ formatTimeframe({ count: 15, unit: 'm' }) // '15m'
1667
+ timeframeSeconds({ count: 4, unit: 'h' }) // 14400
1668
+ isIntradayTimeframe('1d') // false
1669
+ ```
1670
+
1671
+ `TIMEFRAME_PRESETS` lists the 26 preset tokens in five picker groups, weeks and months in the
1672
+ Days group. `timeframeLabel` writes a token in the chart's language, and `timeframeOrder` sorts
1673
+ tokens smallest first. A picker offers only what the symbol and the feed serve: pass the symbol's
1674
+ `supportedResolutions` and the feed's `resolutions` to `allowedTimeframes`. An empty list is no
1675
+ restriction.
1676
+
1677
+ ```ts
1678
+ import { allowedTimeframes, createChartI18n, timeframeLabel, TIMEFRAME_PRESETS } from '@trdrs/quickcharts'
1679
+
1680
+ const { t } = createChartI18n()
1681
+ const tokens = TIMEFRAME_PRESETS.flatMap((group) => group.tokens) // 26 tokens
1682
+ timeframeLabel(t, '5t') // '5 Ticks'
1683
+ allowedTimeframes(tokens, { supportedResolutions: ['1m', '1h', '1d'] }) // ['1m', '1h', '1d']
1684
+ allowedTimeframes(tokens, { supportedResolutions: [] }).length // 26
1685
+ ```
1686
+
1687
+ ## Timezones and sessions
1688
+
1689
+ Bar times are UTC epoch seconds. A display timezone changes how the time axis, the crosshair and
1690
+ a clock write them, never the bars. `TIMEZONES` lists the 60 selectable zones as IANA ids with a
1691
+ display city, and `EXCHANGE_TIMEZONE` is the choice that follows the charted symbol's own
1692
+ `timezone`. Every formatter takes the host's BCP 47 tag, so a month or a weekday reads in the
1693
+ chart's language.
1694
+
1695
+ ```ts
1696
+ import { EXCHANGE_TIMEZONE, formatClock, makeCrosshairTimeFormatter, makeTickMarkFormatter, resolveDisplayTimezone, TIMEZONES, tzOffsetLabel } from '@trdrs/quickcharts'
1697
+
1698
+ TIMEZONES.length // 60
1699
+ tzOffsetLabel('Asia/Kolkata') // 'UTC+5:30'
1700
+ const zone = resolveDisplayTimezone(EXCHANGE_TIMEZONE, { timezone: 'America/Chicago' }) // 'America/Chicago'
1701
+ if (zone) {
1702
+ note(formatClock('en', zone))
1703
+ chart.applyOptions({
1704
+ localization: { timeFormatter: makeCrosshairTimeFormatter('en', zone, true) },
1705
+ timeScale: { tickMarkFormatter: makeTickMarkFormatter('en', zone) },
1706
+ })
1707
+ }
1708
+ ```
1709
+
1710
+ `timezoneListing` returns the picker rows: UTC, then the exchange choice, then every zone by its
1711
+ current offset, so daylight-saving changes reorder the list on their own.
1712
+
1713
+ A symbol's session facts build a session model: `session` in the session grammar (`0930-1600`,
1714
+ `1700-1600:23456` with `1` as Sunday, `24x7`, several stretches per day, previous-day markers),
1715
+ `sessionHolidays` as `YYYYMMDD` full closures, and `corrections` as `SESSION:YYYYMMDD` overrides
1716
+ that outrank a holiday. `subsessions` (`regular`, `extended`, `premarket`, `postmarket`, each
1717
+ with its own session string and `sessionCorrections`) split extended hours into pre-market,
1718
+ regular and after-hours; a symbol without them has one continuous session. The model answers the
1719
+ session state at an instant, the next change and the exchange-local day's timeline, and the
1720
+ market status combines the state with the feed's `dataStatus`: an end-of-day feed is its own
1721
+ state, never an open market.
1722
+
1723
+ ```ts
1724
+ import { createChartI18n, marketStatus, marketStatusText, marketStatusTitle, parseSessionModel, sessionStateAt } from '@trdrs/quickcharts'
1725
+
1726
+ const { t } = createChartI18n()
1727
+ const model = parseSessionModel({ timezone: 'America/Chicago', session: '1700-1600:23456', sessionHolidays: '20260101' })
1728
+ if (model) {
1729
+ const now = Date.UTC(2026, 6, 13, 15) / 1000 // a Monday, 10:00 in Chicago
1730
+ sessionStateAt(model, now) // 'open'
1731
+ const status = marketStatus(model, 'streaming', now)
1732
+ marketStatusTitle(t, status) // 'Market open'
1733
+ marketStatusText(t, status, now) // 'Market is open for regular trading. Closes in 6 hours.'
1734
+ }
1735
+ parseSessionModel({ timezone: 'Etc/UTC', session: 'later' }) // null: the chart claims no session it cannot read
1736
+ ```
1737
+
1738
+ Which named session a chart displays is its subsession, a per-chart preference
1739
+ with `regular` as the default. On a symbol with extended hours, `regular` filters intraday bars to
1740
+ regular hours and `extended` shows every bar; a symbol with one continuous session has nothing to
1741
+ filter.
1742
+
1743
+ ```ts
1744
+ import { DEFAULT_SUBSESSION, hasExtendedHours, parseSessionModel, subsessionBarFilter } from '@trdrs/quickcharts'
1745
+
1746
+ const equity = parseSessionModel({
1747
+ timezone: 'America/New_York',
1748
+ session: '0930-1600',
1749
+ subsessions: [
1750
+ { id: 'regular', session: '0930-1600' },
1751
+ { id: 'extended', session: '0400-2000' },
1752
+ { id: 'premarket', session: '0400-0930' },
1753
+ { id: 'postmarket', session: '1600-2000' },
1754
+ ],
1755
+ })
1756
+ if (equity) {
1757
+ hasExtendedHours(equity) // true
1758
+ const keep = subsessionBarFilter(equity, DEFAULT_SUBSESSION) // a function: regular hours only
1759
+ keep?.(Date.UTC(2026, 6, 13, 14) / 1000) // true, 10:00 in New York
1760
+ subsessionBarFilter(equity, 'extended') // null: nothing to filter
1761
+ }
1762
+ ```
1763
+
1764
+ ## Ranges
1765
+
1766
+ `RANGE_PRESETS` lists the nine range presets, each a visible span and the interval it reads best
1767
+ at: `1D` over one-minute bars through `All` over monthly bars. `rangeAvailable` withholds a preset
1768
+ deeper than the history a symbol has, and `frameRange` sets a pane's visible window for a span,
1769
+ anchored on the last real bar so a future whitespace horizon never frames as empty space. The
1770
+ navigation cluster's steps are constants: `ZOOM_FACTOR`, `MIN_BAR_SPACING` and
1771
+ `SCROLL_STEP_BARS`, applied by `zoomedBarSpacing` and `scrolledPosition`.
1772
+
1773
+ ```ts
1774
+ import { frameRange, RANGE_PRESETS, rangeAvailable, scrolledPosition, zoomedBarSpacing } from '@trdrs/quickcharts'
1775
+
1776
+ const oneYearAgo = Date.now() / 1000 - 365 * 86_400
1777
+ RANGE_PRESETS.filter((preset) => rangeAvailable(preset, oneYearAgo)).map((preset) => preset.key) // every preset but '5Y'
1778
+ frameRange(chart, series, RANGE_PRESETS[0]!.span, '1m')
1779
+ zoomedBarSpacing(8, 'in') // 10
1780
+ scrolledPosition(0, 'right') // 10
1781
+ ```
1782
+
1783
+ ## Search
1784
+
1785
+ `createSearchController` drives a symbol search over your datafeed's `search`: a debounce after
1786
+ the last keystroke, a bounded cache per query and class for the controller's lifetime, a cached answer
1787
+ shown at once and revalidated in the background, paging through `loadMore` with no repeated row,
1788
+ and a newer query cancelling an older one's result. The chart's compare dialog runs on it, and a
1789
+ host's own search surface subscribes to the same state.
1790
+
1791
+ The widget prefetches the default catalog and reuses completed pages across search, compare and
1792
+ change-symbol dialogs. Each opening owns its query, class filter and pending work. Closing it
1793
+ discards late results without clearing completed pages. Locale changes and widget disposal clear
1794
+ the widget cache. A replacement widget never inherits a previous provider's cache.
1795
+
1796
+ Completed reuse holds at most 32 queries and 5,000 rows, evicting least-recent entries. Retained
1797
+ multi-page results reopen without a first-page refetch. Eviction does not truncate an active
1798
+ list or change its next paging offset; an oversized list is not retained after close.
1799
+ Cancellation ignores obsolete responses; it does not abort your datafeed's transport.
1800
+
1801
+ ```ts
1802
+ import { createSearchController, createUdfDatafeed } from '@trdrs/quickcharts'
1803
+
1804
+ const datafeed = createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' })
1805
+ const search = createSearchController(datafeed, { pageSize: 50, debounceMs: 200 })
1806
+ const stop = search.subscribe((state) => note(`${state.hits.length} rows${state.hasMore ? ', more' : ''}`))
1807
+ search.prefetch('') // warm the default list
1808
+ search.search('es', 'future') // one page after the debounce
1809
+ search.loadMore() // the next page, once
1810
+ stop()
1811
+ search.dispose()
1812
+ ```
1813
+
1814
+ Recent picks go through a `RecentsPort`. `memoryRecents` keeps them for the page; back the port
1815
+ with your own storage to keep them longer. `matchSegments` splits a symbol around the query for a
1816
+ highlight, and `looksLikeSpread`, `isSymbolPair`, `spreadExpression` and `spreadSearchQuery` apply
1817
+ the spread-expression rules: an operator over a symbol leg offers the expression as a row, a plain
1818
+ `BTC/USD` pair is catalog identity, and your feed evaluates the expression.
1819
+
1820
+ ```ts
1821
+ import { looksLikeSpread, isSymbolPair, matchSegments, memoryRecents, spreadExpression } from '@trdrs/quickcharts'
1822
+
1823
+ const recents = memoryRecents()
1824
+ recents.promote({ symbol: 'ES', name: 'E-mini S&P 500', exchange: 'CME', type: 'future' })
1825
+ recents.list().length // 1
1826
+ matchSegments('BTCUSDT', 'usd') // [{ text: 'BTC', hit: false }, { text: 'USD', hit: true }, { text: 'T', hit: false }]
1827
+ looksLikeSpread('ES-NQ') // true
1828
+ isSymbolPair('BTC/USD') // true
1829
+ spreadExpression(' es - nq ') // 'ES-NQ'
1830
+ ```
1831
+
1832
+ ### The picker, away from a chart
1833
+
1834
+ A page that picks a market where no chart is mounted opens the same dialog the chart's symbol pill
1835
+ opens, over the same controller, grammar, recents rule and symbol names. `openSymbolSearch` stands
1836
+ it over the page; `mountSymbolSearch` builds it bare into a box you own and position. Both take what
1837
+ you already give a chart: the feed, the mode, the language, the classes and their names, the marks,
1838
+ and where recents are kept. Picking hands the symbol back and closes, because there is no chart here
1839
+ to set.
1840
+
1841
+ `createSymbolSearchCache` keeps one feed's catalog warm between opens, so the next picker a page
1842
+ opens stands on pages the viewer already saw rather than on a loading line.
1843
+
1844
+ ```ts
1845
+ import { createSymbolSearchCache, createUdfDatafeed, mountSymbolSearch, openSymbolSearch } from '@trdrs/quickcharts'
1846
+
1847
+ declare const pickerBox: HTMLElement
1848
+ declare const form: { setInstrument(symbol: string): void }
1849
+
1850
+ const searchFeed = createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' })
1851
+ const catalog = createSymbolSearchCache({ datafeed: searchFeed, pageSize: 50 })
1852
+ catalog.prefetch() // warm the list a picker opens on
1853
+
1854
+ const picker = openSymbolSearch({
1855
+ datafeed: searchFeed,
1856
+ cache: catalog,
1857
+ theme: { mode: 'light' },
1858
+ classes: ['future', 'crypto'],
1859
+ classNames: { future: 'Futures', crypto: 'Crypto' },
1860
+ query: 'ES', // opens holding it, selected
1861
+ onPick: (symbol) => form.setInstrument(symbol),
1862
+ })
1863
+ picker.close() // or the viewer closes it: the X, the scrim, Escape
1864
+
1865
+ const card = mountSymbolSearch({ container: pickerBox, datafeed: searchFeed, cache: catalog, onPick: (symbol) => form.setInstrument(symbol) })
1866
+ card.focus()
1867
+ card.dispose()
1868
+ catalog.dispose()
1869
+ ```
1870
+
1871
+ ### The legend
1872
+
1873
+ Every chart carries its own legend over its plot: an identity row naming the market, the interval
1874
+ and the venue, and a values row carrying the hovered bar's O H L C and its move against the previous
1875
+ close. The numbers are written through that chart's own formatter, so a level in the legend is the
1876
+ level its price axis writes, and they follow the pointer while the crosshair is on the plot and the
1877
+ latest bar when it is not. In a split layout each chart carries its own, for the market it shows.
1878
+
1879
+ `symbolMark` paints a market's mark, and the chart calls it **wherever it names one**: each chart's
1880
+ legend identity, the compare rows beneath it, and every row of the symbol-search and compare
1881
+ dialogs. It receives the symbol, the element to paint into and the size of the box, and returns a
1882
+ disposer that takes the mark down again. One hook rather than one per surface, so a market wears the
1883
+ same face across the product and you implement it once.
1884
+
1885
+ The `size` is the chart's, not a suggestion: the box is already laid out, and painting at the size
1886
+ you were given is what lines the marks up down a list. The chart ships no artwork and fetches none,
1887
+ so without this every mark is the neutral monogram it draws itself.
1888
+
1889
+ `venueMark` and `providerMark` paint the source at the end of every symbol search and compare row
1890
+ the same way: the venue a market lists on, or the data provider where a row names no venue. Each
1891
+ receives the name the row writes (`exchange` or `provider`), the element and the size, and returns
1892
+ a disposer. Without them a source wears its initial on a neutral disc.
1893
+
1894
+ ```ts
1895
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
1896
+
1897
+ const widget = createChart({
1898
+ container: document.getElementById('chart')!,
1899
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
1900
+ symbolMark: ({ symbol, host, size }) => {
1901
+ const img = document.createElement('img')
1902
+ img.src = `/logos/${symbol.toLowerCase()}.svg`
1903
+ img.width = size
1904
+ img.height = size
1905
+ host.replaceChildren(img)
1906
+ return () => host.replaceChildren()
1907
+ },
1908
+ // A touch surface has no pointer to hover with, so the reading is the last bar either way.
1909
+ features: { crosshair: false },
1910
+ ui: { legend: { values: false } },
1911
+ })
1912
+ ```
1913
+
1914
+ The market's name opens the chart's own search dialog where the feed serves one and
1915
+ `ui.symbolSearch` is on, and is a plain mark where it is not, since a control that opens nothing is
1916
+ a lie. `ui: { legend: { values: false } }` takes the reading off the legend and
1917
+ `features: { crosshair: false }` draws no crosshair, for a surface read by looking rather than by
1918
+ pointing; the crosshair SYNC lane is untouched either way. The reading also gives way on its own as a pane narrows: it wraps whole under
1919
+ the identity first, then holds the close and the change alone rather than covering the candles it
1920
+ describes.
1921
+
1922
+ ## Multi-chart layouts
1923
+
1924
+ A widget always has a layout, reached as `widget.layout`, and it tiles N charts over the widget root
1925
+ by an arrangement code. The catalog (`ARRANGEMENTS`, grouped for a picker as `LAYOUT_MENU_ROWS`)
1926
+ carries 55 arrangements from a single full-bleed chart to an 8x2 grid; `setArrangement` re-tiles
1927
+ live, surviving charts keep their state and new charts clone the active chart's symbol and
1928
+ timeframe.
1929
+
1930
+ One chart is ACTIVE: it follows pointerdown, `widget.activeChart()` is it, and the `activeChart`
1931
+ event reports it every time it moves: another chart activated, the active chart's symbol changed, a
1932
+ re-tile, a restore. Pointing your own surface at a chart moves no chart's symbol, so the two
1933
+ concepts stay separate: each chart keeps charting what it charts, and one of them is the one you are
1934
+ looking at.
1935
+
1936
+ Five sync toggles fan changes across the charts: `symbol`, `interval` and `dateRange` replay a
1937
+ change onto every chart, `crosshair` mirrors continuously by time, and `time` centers every chart on
1938
+ a clicked moment. The whole layout serializes as ONE opaque content blob (arrangement, sync flags,
1939
+ durable chart-entity identities, active chart, every chart's own content), so a saved multi-chart
1940
+ layout is one row in the same
1941
+ save/load backend a single chart uses.
1942
+
1943
+ ```ts
1944
+ import { createChart, createUdfDatafeed, LAYOUT_MENU_ROWS } from '@trdrs/quickcharts'
1945
+
1946
+ const widget = createChart({
1947
+ container: document.getElementById('charts')!,
1948
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
1949
+ layout: {
1950
+ arrangement: '2h',
1951
+ charts: [{ symbol: 'ES', timeframe: '1m' }, { symbol: 'NQ', timeframe: '5m' }],
1952
+ sync: { crosshair: true },
1953
+ },
1954
+ })
1955
+ widget.on('activeChart', (chart) => header.setSymbol(chart.symbol()))
1956
+ header.setSymbol(widget.activeChart().symbol()) // the value on mount; the event carries changes
1957
+ widget.layout.setSync({ symbol: true })
1958
+ widget.layout.setArrangement(LAYOUT_MENU_ROWS[3]!.codes[0]!) // '4', the 2x2 grid
1959
+ const saved = widget.layout.serialize().content // ONE blob for the whole layout
1960
+ widget.layout.restore(saved)
1961
+ widget.dispose()
1962
+ ```
1963
+
1964
+ A split is RESIZABLE. Every boundary between two charts carries a grab strip, and dragging one
1965
+ resizes each chart that sits on it, clamped so no chart is squeezed under a minimum share of the
1966
+ square. The sizes ride the layout blob and belong to the arrangement they were dragged on, so
1967
+ re-tiling opens at the catalog's own splits rather than inheriting numbers that describe a
1968
+ different shape.
1969
+
1970
+ One chart can also stand ALONE: `widget.layout.setMaximized(index)` fills the square with it and
1971
+ steps its siblings out of the flow, and `setMaximized(null)` puts them all back exactly where they
1972
+ were, still charting what they charted. `widget.layout.maximized()` reads which one stands, or
1973
+ `null` while every chart is tiled. Maximizing is a way of looking at a layout, not a shape it was
1974
+ saved in, so it is not carried by the blob and a re-tile restores it.
1975
+
1976
+ Three keys drive a split, all of them ordinary commands a host can rebind or turn off:
1977
+ `widget.layout.activateNext` on `Tab`, `widget.layout.activatePrevious` on `Shift+Tab`, and
1978
+ `widget.layout.toggleMaximize` on `Alt+Enter`. Tab is claimed only when nothing focusable inside
1979
+ the chart has focus, so tabbing along the toolbar still reaches the next button. Alt+click on a
1980
+ chart maximizes it, and alt+click again restores the split.
1981
+
1982
+ Each chart's handle stays reachable through `widget.charts()`, including the `sync`
1983
+ pane-composition primitives (`onCrosshair` and `setCrosshair`, `onTimeClick`, `onVisibleRange`) the
1984
+ layout itself is built on, so a host can compose charts its own way. `locale` sets every chart's
1985
+ interface language and `widget.setLocale(code)` switches them together; a chart created by a later
1986
+ re-tile opens in the current one.
1987
+
1988
+ ## Drawings
1989
+
1990
+ The widget ships with a complete drawing product, on by default: every one of the 90 tools places
1991
+ from the toolbar, the selected drawing gets a floating settings bar and a settings dialog, tool
1992
+ defaults and named templates ride the adapter's template family, and a symbol's drawings persist
1993
+ through the adapter's drawings family. Turn the whole layer off with `features.drawings: false`,
1994
+ or keep the layer and hide its toolbar or favorites bar to drive it from your own UI:
1995
+
1996
+ ```ts
1997
+ import { createChart, createUdfDatafeed } from '@trdrs/quickcharts'
1998
+
1999
+ const widget = createChart({
2000
+ container,
2001
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
2002
+ ui: { drawingToolbar: false, drawingFavorites: false },
2003
+ })
2004
+ const drawings = widget.activeChart().drawings
2005
+ drawings?.armTool('trend_line')
2006
+ drawings?.armTool('emoji', { glyph: '🚀' }) // props seed the next placement
2007
+ const selected = drawings?.selected() // the selection's style channels and flags, or null
2008
+ drawings?.updateStyle({ lineWidth: 3 }) // the edit becomes the tool's remembered default
2009
+ const saved = drawings?.export() // the persistence wire format (SerializedDrawing[])
2010
+ note(`${selected?.type ?? 'nothing'} selected, ${saved?.length ?? 0} drawings`)
2011
+ ```
2012
+
2013
+ ### The toolbar
2014
+
2015
+ The toolbar is the rail down the chart's leading edge. Its buttons are the cursor with its three
2016
+ pointer modes and the eraser; seven tool groups, each opening a flyout of the group's sections
2017
+ with a star on every row that lands the tool on the favorites bar; Measure and Zoom; the magnet
2018
+ with its weak and strong strengths; stay in drawing mode; lock all; the eye that hides drawings,
2019
+ indicators, or both; drawing sync, shown only in a layout of more than one chart; the remove menu,
2020
+ which names what each row takes and carries the locked-item policy; and the favorites star. Each
2021
+ group button wears the tool it last armed, and every action is a `chart.drawings.*` command
2022
+ through the registry: a control renders disabled, never hidden, while the registry would not run
2023
+ its command (a tool or verb your access policy refuses, a selection verb with nothing selected,
2024
+ the Image tool without an asset port), and a command the policy refuses answers `denied` from the
2025
+ toolbar as from anywhere else, including `chart.drawings.arm` for a refused tool. An image is
2026
+ placed through `chart.drawings.placeImage`, from the picker or a system-clipboard paste over the
2027
+ chart. Every flyout, palette and dialog a surface opens sits inside the chart root and closes with
2028
+ it, and the eye and lock all announce their state through a live region. Lock all also makes Paste
2029
+ unavailable, so a control never reports success for a drawing the layer refused.
2030
+
2031
+ Arm the transient tools by name: `measure` draws a readout the next gesture clears, `zoom` sets
2032
+ the visible range to the dragged box, and `eraser` removes what it presses until Escape or the
2033
+ cursor releases it. Measure disarms when it completes, while Cancel and Escape remain available to
2034
+ clear its standing readout. The `image` tool opens the picker, which places the picture once it is
2035
+ chosen.
2036
+
2037
+ ### The selected drawing
2038
+
2039
+ Selecting a drawing shows the settings bar: templates, the stroke color with its opacity, the
2040
+ background for tools that have one, the text color and font size for text tools, thickness and
2041
+ line style, the settings gear, lock, delete, and a More menu with the stacking moves, the
2042
+ per-interval visibility presets, clone, copy and hide. Every edit persists at once and becomes the
2043
+ tool's default for the next drawing of that type. The settings dialog opens from the gear with
2044
+ Inputs, Style, Text, Table, Coordinates and Visibility pages as the tool has them; its edits apply
2045
+ live, Cancel restores the drawing, and Ok commits the session as one edit.
2046
+
2047
+ Text-bearing tools open an inline editor where the text sits, in the drawing's own type. A fresh
2048
+ placement committed empty is removed; an existing note committed empty is blanked. Ctrl or Cmd
2049
+ with Enter commits, Escape cancels, and a press on the chart commits.
2050
+
2051
+ Templates are named setups a trader saves from either surface and applies on demand. They and
2052
+ the remembered defaults ride `ChartSaveLoadAdapter.templates('drawing')`, so a host that keeps
2053
+ saved charts on a server keeps these there too. Same-tool default changes are written in issue
2054
+ order, and a delayed read or failed write does not replace the newest local choice. Store work is
2055
+ admitted before subscribers hear an optimistic clear, save or removal, so a subscriber that
2056
+ replaces the last chart cannot reopen from stale data between the local change and its write. A
2057
+ subscriber exception still rejects the operation and releases its cache lifetime. Without an
2058
+ adapter they last the page.
2059
+
2060
+ ### The asset port
2061
+
2062
+ The image and glyph tools reach your host through `ChartWidgetOptions.assets`. `intakeImage`
2063
+ turns a picked file into a payload within the caps and answers a refusal as a code the chart
2064
+ resolves through its own catalog; `glyphSource` answers the artwork URL an emoji or sticker draws
2065
+ with, or null to draw the glyph as text. Without the port the image tool does not open and glyphs
2066
+ draw as text.
2067
+
2068
+ The layer is also mountable on its own lightweight-charts pair, without the widget:
2069
+
2070
+ ```ts
2071
+ import { attachDrawings } from '@trdrs/quickcharts'
2072
+
2073
+ const layer = attachDrawings({ chart, series, container, symbol: 'ES' })
2074
+ layer.armTool('rectangle')
2075
+ layer.destroy()
2076
+ ```
2077
+
2078
+ ### `@trdrs/quickcharts/drawings`
2079
+
2080
+ Everything about drawings a host builds its own UI from is one subpath. A host that never draws
2081
+ never imports any of it.
2082
+
2083
+ ```ts
2084
+ import { buildRailGroups, drawingTools, parseDrawingsStore, restoreDrawings, serializeDrawingsStore } from '@trdrs/quickcharts/drawings'
2085
+
2086
+ // The catalog: 90 tools in 14 categories, read-only.
2087
+ const trendLine = drawingTools.get('trend_line')
2088
+ const fibs = drawingTools.byCategory('fibonacci')
2089
+
2090
+ // The rail's own structure, as data: seven groups, their sections, and a catalog key per heading.
2091
+ for (const group of buildRailGroups()) {
2092
+ for (const section of group.sections) note(`${group.id}/${section.label}: ${section.tools.length}`)
2093
+ }
2094
+
2095
+ // Persistence: the store document, and live drawings back out of one symbol's bucket.
2096
+ const store = parseDrawingsStore(localStorage.getItem('acme.chart.drawings'))
2097
+ const drawings = restoreDrawings(store['ES'] ?? [])
2098
+ localStorage.setItem('acme.chart.drawings', serializeDrawingsStore(store))
2099
+ ```
2100
+
2101
+ The subpath carries the workflow models too: what the rail's eye blanks (`HideMode`, which reaches
2102
+ chart-owned drawings and indicators and nothing else), the cursor modes and the two transient
2103
+ tools, the magnet policy over `magnetSnap`, the remove menu, favorites over a `FavoritesPort`, the
2104
+ standing preference record, and per-tool defaults and named templates over
2105
+ `ChartSaveLoadAdapter.templates('drawing')`. Each is a pure function or a plain record, so a host
2106
+ builds its own controls without reimplementing the decisions behind them.
2107
+
2108
+ What a lock refuses is the library's own. A host locks and unlocks through the drawing commands and
2109
+ reads what they answer: `chart.drawings.lock` holds one drawing where it stands,
2110
+ `chart.drawings.lockAll` suspends the whole chart, and every editing command answers `ok` or
2111
+ `unavailable` for the state the chart is actually in, so a control built on those outcomes says
2112
+ exactly what the chart will accept. The gesture list below is what a trader meets.
2113
+
2114
+ ```ts
2115
+ import { blanks, chooseHideMode, DEFAULT_HIDE_STATE, DrawingTemplates } from '@trdrs/quickcharts/drawings'
2116
+
2117
+ const eye = chooseHideMode(DEFAULT_HIDE_STATE, 'all') // points at All and blanks it in one gesture
2118
+ if (blanks(eye, 'indicators')) note('indicators are blanked')
2119
+
2120
+ const templates = new DrawingTemplates(myBackend.templates('drawing'))
2121
+ await templates.save('trend_line', 'Thick red', { style: { lineWidth: 4, lineColor: '#ff3b30' } })
2122
+ const preset = await templates.defaultFor('trend_line')
2123
+ ```
2124
+
2125
+ Image-backed and glyph tools reach the host through one explicit asset port. The library owns the
2126
+ rules (JPG or PNG, 2 MB, a 2000 px longest edge, downscaled rather than refused) and names each
2127
+ refusal with a code that resolves through the chart's own catalog; the host owns the bytes.
2128
+
2129
+ ```ts
2130
+ import { checkImageFile, fittedSize, IMAGE_ACCEPT, type DrawingAssetPort } from '@trdrs/quickcharts/drawings'
2131
+
2132
+ const assets: DrawingAssetPort = {
2133
+ async intakeImage(file) {
2134
+ const bad = checkImageFile(file)
2135
+ if (bad) return { ok: false, ...bad }
2136
+ const { width, height } = fittedSize(1200, 900)
2137
+ return { ok: true, asset: { dataUrl: await myBackend.read(file), width, height, downscaled: false } }
2138
+ },
2139
+ glyphSource: (glyph) => myBackend.emojiUrl(glyph),
2140
+ }
2141
+ note(IMAGE_ACCEPT)
2142
+ ```
2143
+
2144
+ What to know:
2145
+
2146
+ - **The subpath is a subset, not a re-export.** The drawing classes, the model store and the
2147
+ mutable registry stay inside the library. There is no door for a host-authored tool: tool
2148
+ contribution belongs to the access-policy plane, not to a bare registration call.
2149
+ - **Every tool places.** Fixed-anchor tools place by press-drag-release or click then click; an
2150
+ instant tool (the position tools) lands whole from one press; a multipoint tool adds a point per
2151
+ click until a double-click ends the run; a freehand tool captures the drag as a stroke; a
2152
+ text-bearing tool opens the inline editor as it lands. `armTool` **throws** only for a name the
2153
+ catalog does not know, and `placeableByWidget(type)` answers in advance.
2154
+ - **Persistence speaks a shared codec.** The store document (`{ [symbol]: SerializedDrawing[] }`,
2155
+ via `parseDrawingsStore`/`serializeDrawingsStore`) is the SAME document every host of the codec
2156
+ reads and writes, so drawings survive moving between hosts. A drawing bound to one chart (sync
2157
+ off) lives in the adapter's chart-bound scope for the symbol; a shared drawing lives in the
2158
+ symbol's scope, and a refused write merges the stored document over the layer's own before it
2159
+ writes again.
2160
+ - **Keys run through the registry.** Delete and Backspace remove the selection, Escape cancels a
2161
+ placement, disarms, or closes the inline editor, and Ctrl (or Cmd) with C and V copy and paste a
2162
+ drawing. Each resolves to a `chart.drawings.*` command, so your access policy gates the keyboard
2163
+ as it gates the toolbar; the keys bind to the chart element and never the page, so an embedded
2164
+ chart cannot swallow the host page's keys.
2165
+ - **Gestures follow the standard grammar.** Drag a drawing to move it (rigid whole-bar translation,
2166
+ so anchors never drift apart); grab an anchor handle to reshape; hold Shift to constrain a
2167
+ two-point placement or an anchor drag to 45 degree rays; Ctrl-drag duplicates; the magnet pulls
2168
+ a placed or dragged anchor onto the bar's own open, high, low or close; a locked drawing
2169
+ selects, takes the Delete and Clone a trader asks for by name, and refuses a move, a resize, a
2170
+ text edit, the eraser and a Ctrl-drag copy; lock all suspends every edit, Delete and Clone
2171
+ included, until it is released.
2172
+
2173
+ ## Extensions
2174
+
2175
+ An extension is host code that draws on the chart, adds rows to its level menu, offers commands,
2176
+ and stores viewer state in the chart's own save blob. The chart attaches it at mount, pushes its
2177
+ changes at it, and takes it down at teardown, along with everything it drew.
2178
+
2179
+ ```ts
2180
+ import { createChart, createUdfDatafeed, type ChartExtension } from '@trdrs/quickcharts'
2181
+
2182
+ const alertLines: ChartExtension = {
2183
+ id: 'acme.alerts',
2184
+ attach(ctx) {
2185
+ let levels: number[] = []
2186
+ const lines = levels.map((price) => ctx.series.createPriceLine({ price, color: '#f5a623' }))
2187
+ ctx.contributeContextMenu((menu) => [
2188
+ {
2189
+ id: 'add',
2190
+ label: `Add alert at ${menu.priceText}`,
2191
+ icon: { paths: [{ d: 'M14 6 L22 20 H6 Z' }] },
2192
+ run: () => levels.push(menu.price),
2193
+ },
2194
+ ])
2195
+ ctx.contributeCommands([{ id: 'acme.alerts.clear', label: 'Clear alerts', execute: () => (levels = []) }])
2196
+ ctx.onSymbolChange(() => (levels = []))
2197
+ return {
2198
+ serialize: () => levels,
2199
+ restore: (state) => {
2200
+ levels = Array.isArray(state) ? state.filter((p): p is number => typeof p === 'number') : []
2201
+ },
2202
+ detach: () => lines.forEach((line) => line.remove()),
2203
+ }
2204
+ },
2205
+ }
2206
+
2207
+ const widget = createChart({
2208
+ container,
2209
+ datafeed: createUdfDatafeed({ baseUrl: 'https://feed.example.com/udf' }),
2210
+ extensions: [alertLines],
2211
+ })
2212
+ widget.commands.execute('acme.alerts.clear')
2213
+ ```
2214
+
2215
+ What to know:
2216
+
2217
+ - **The context is the whole surface.** `ChartExtensionContext` carries the chart's symbol,
2218
+ timeframe, bars, replay state, feed status, palette, pane geometry and whether the chart is the
2219
+ widget's active chart (`active()`, a sole chart being active), a subscription for each of
2220
+ the changing ones, the
2221
+ gesture box and the chrome overlay to mount DOM in, the chart's price formatter, and the series
2222
+ capabilities: `createPriceLine`, `attachPrimitive`, `priceToY` / `yToPrice`, `timeToX` /
2223
+ `xToTime`, `plotWidth`, and `lockPanZoom` for the length of a drag. Every `on…` returns its own
2224
+ unsubscribe.
2225
+ - **A popover that must stand over everything mounts on the layer.** `ctx.layer` is the widget's
2226
+ layer on the document body, painted with the widget's theme: a menu or an editor mounted there at
2227
+ viewport coordinates stands over every pane and over whatever the page stacks around the widget,
2228
+ which the pane's own overlay cannot promise. `ctx.symbolTitle()` is the name symbology gives the
2229
+ charted market, the name the legend and the level menu print; `ctx.chart.symbol()` stays the
2230
+ ticker an action is sent for. `ctx.painters` carries the mark painters you supplied as `.symbol`,
2231
+ `.venue` and `.provider`, so a surface that names a market paints it with the same mark the
2232
+ legend and the search rows wear rather than shipping artwork of its own; a painter you did not
2233
+ supply is `null`, and the surface writes the name alone.
2234
+ - **A contributed row may bring its own glyph.** `ChartExtensionMenuItem.icon` is a
2235
+ `ChartExtensionIcon`: shapes on the menu's 28-unit grid, each one SVG path data plus a closed set
2236
+ of paint values (`solid` or `outline` in the current text colour, a stroke width, a winding rule).
2237
+ The chart's own menu glyphs are described the same way and built by the same code, so there is one
2238
+ icon contract rather than two and a contributed row reads exactly like a built-in one. The chart
2239
+ creates elements and writes only the attributes the contract names, so a glyph has nowhere to
2240
+ carry a script, an event handler, a colour or a remote reference. It takes the ink of the row it
2241
+ sits in and is hidden from assistive technology; the row's label is its accessible name either way.
2242
+ - **A contributed row sits in one of two groups.** `ChartExtensionMenuItem.group` is `level` (the
2243
+ default), an action on the price the pointer landed on, placed under Copy price and Paste; or
2244
+ `view`, a switch over what the chart shows, placed under the remove rows. Rows keep the order
2245
+ they were contributed in within their group.
2246
+ - **A drawn layer can join the rail's eye.** `ctx.contributeHideLayer({ id, label, icon, apply })`
2247
+ lists the layer in the eye's menu after the chart's own drawings and indicators and before
2248
+ "Hide all", which blanks it with them. `label` carries the row's wording in both states, `icon`
2249
+ the two marks the eye wears while the layer is its subject, and `apply(hidden)` is called with
2250
+ the current state at contribution and on every change. The handle reads `hidden()`, flips
2251
+ `setHidden()` through the same eye the rail drives, and `remove()` withdraws the layer.
2252
+ - **A printed chord is a real binding.** `ChartExtensionMenuItem.shortcut` prints on the row and
2253
+ answers to the key. The chart's dispatcher offers a press no built-in verb claims to the rows your
2254
+ provider contributes for the level under the pointer and runs that row's own `run`, the same call
2255
+ the click makes. The press acts on the tile the pointer is inside, active or not; outside every
2256
+ tile, on a point with no readable level, while the viewer is typing, or while a modal holds the
2257
+ keyboard, the key is left to your page. A row your provider withholds claims no key, and a row
2258
+ carries one chord.
2259
+ - **A glyph is bounded, and a refusal is free.** Up to 8 shapes, each up to 2048 characters of path
2260
+ data with every number in it finite, and an `outline` stroke width above 0 and up to 8. A shape
2261
+ outside that is dropped and the rest of the glyph still draws; a descriptor outside it draws
2262
+ nothing at all. A row with no icon, or with one the chart will not draw, keeps the gutter and the
2263
+ label alignment it has, raises nothing and logs nothing. A checkable row shows the check instead.
2264
+ The chart names no glyph vocabulary for contributed rows: what a row means is the host's business.
2265
+ - **The chart owns the renderer.** Extensions receive capabilities, not the chart or series
2266
+ objects. A primitive mounted through `attachPrimitive` still meets the renderer's own
2267
+ `attached` callback, which is lightweight-charts' contract for primitives; the chart detaches
2268
+ that primitive for the extension when the extension comes down.
2269
+ - **Teardown is complete.** Detaching removes every price line and primitive the extension created
2270
+ and releases any pan/zoom lock it still holds, whether or not the extension took them down
2271
+ itself. Subscriptions stop, and the context becomes inert: afterwards every method is a no-op
2272
+ returning a neutral value rather than a throw into work already in flight.
2273
+ - **State is namespaced.** `serialize()` is stored under the extension's `id` inside the chart's
2274
+ content blob, and `restore(state)` receives only that slot, after the saved symbol, timeframe,
2275
+ scale and comparisons are on screen. One `id`, one attachment: a duplicate is refused.
2276
+ - **Scope is a declaration.** By default an extension stays attached for the chart's life and hears
2277
+ symbol switches through `onSymbolChange`. Declare `scope: 'symbol'` and the chart detaches and
2278
+ re-attaches it on every switch, so a market-scoped overlay cannot carry one market's drawing onto
2279
+ another's bars.
2280
+ - **A failing extension is its own problem.** A throw in `attach` drops that extension and the
2281
+ chart still mounts; a throw in a subscriber, a menu provider, a command or a teardown is
2282
+ contained.
2283
+ - **Layouts attach per chart.** A widget hands its shared options to every chart it tiles, so each
2284
+ chart gets its own attachment, its own context and its own state slot.
2285
+
2286
+ ## What is verified
2287
+
2288
+ Every claim in this document maps to a test or a generated artifact in the package:
2289
+
2290
+ - The feature inventory (commands, drawing tools, arrangements, indicators, locales, the `features`
2291
+ and `ui` flags and the icon ids) is `dist/feature-manifest.json`, generated from the source on
2292
+ every build; the inventory tests hold the built manifest to the source registries.
2293
+ - Hiding any control in `ui` leaves every command exactly as available as before, apart from the
2294
+ two dialog doors named under Configuration planes, across every flag the plane takes.
2295
+ - With a drawing for every icon, every glyph on each surface of the default interface is the host's,
2296
+ and nothing in the package draws a control's glyph except through the one resolver.
2297
+ - The theme roles, both built-in palettes and the stylesheet are `dist/theme-manifest.json` and
2298
+ `@trdrs/quickcharts/styles.css`, generated from the token schema; the theme vectors under `test/theme` pin
2299
+ the resolved values.
2300
+ - The REST wire contract is `dist/rest-openapi.json`, rendered from the typed contract the adapter
2301
+ implements; the adapter tests compare the committed schema with the rendering.
2302
+ - The exported names and their runtime kinds are pinned by the API-surface test, the declarations
2303
+ compile in a clean-room project with `skipLibCheck` off, and every `ts` block in this document
2304
+ type-checks against the real exports.
2305
+ - The dependency closure, the absence of private packages, hosts and credentials from the source
2306
+ and the tarball, and the third-party notices are held by the boundary tests and
2307
+ `THIRD-PARTY-NOTICES.md`.
2308
+
2309
+ ## Versioning & deprecation
2310
+
2311
+ - **SemVer, enforced at the gate.** The public surface is pinned by an API-surface test (every
2312
+ exported name and its runtime kind), the shipped type declarations are compiled against by a
2313
+ clean-room consumer with `skipLibCheck: false`, and this README's own `ts` examples type-check
2314
+ against the real exports. A change that trips any of those is decided as a version event: a
2315
+ removed/renamed export or a changed contract is **major**; new surface is **minor**; fixes are
2316
+ **patch**. None ships as silent drift.
2317
+ - **Optionality is the compatibility mechanism.** New seam capabilities arrive as *optional* methods
2318
+ and fields (`config` and `serverTime` are the pattern): an existing
2319
+ implementation keeps compiling, and the widget treats absence as "unconstrained / not supported".
2320
+ Your integration never breaks by standing still within a major.
2321
+ - **Deprecation runs a full major.** A deprecated export keeps working for the remainder of the
2322
+ current major, is marked `@deprecated` in the types with its replacement named in the note (your
2323
+ editor flags every call site), and is removed only in the next major, never silently.
2324
+ - **The wire timeframe grammar is stable vocabulary.** `<N><unit>` with units `t s m h d w mo`.
2325
+ Extensions may add units; an existing token never changes meaning.