@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +201 -0
- package/NOTICE +18 -0
- package/README.md +2325 -0
- package/THIRD-PARTY-NOTICES.md +263 -0
- package/dist/adapters/rest.d.ts +143 -0
- package/dist/adapters/rest.js +214 -0
- package/dist/adapters/rest.js.map +1 -0
- package/dist/ar-G7MVXRE4.js +1028 -0
- package/dist/ar-G7MVXRE4.js.map +1 -0
- package/dist/assets-C1GQvm0w.d.ts +1452 -0
- package/dist/ca_ES-EUSRU4VG.js +1028 -0
- package/dist/ca_ES-EUSRU4VG.js.map +1 -0
- package/dist/chunk-AKXIFHQN.js +172 -0
- package/dist/chunk-AKXIFHQN.js.map +1 -0
- package/dist/chunk-KFBQSPNA.js +107 -0
- package/dist/chunk-KFBQSPNA.js.map +1 -0
- package/dist/chunk-PD3DEWMQ.js +6959 -0
- package/dist/chunk-PD3DEWMQ.js.map +1 -0
- package/dist/de-2SVTG25S.js +1028 -0
- package/dist/de-2SVTG25S.js.map +1 -0
- package/dist/drawings.d.ts +237 -0
- package/dist/drawings.js +199 -0
- package/dist/drawings.js.map +1 -0
- package/dist/es-UDHZM4W7.js +1028 -0
- package/dist/es-UDHZM4W7.js.map +1 -0
- package/dist/feature-manifest.json +3453 -0
- package/dist/fr-35VN4G4S.js +1028 -0
- package/dist/fr-35VN4G4S.js.map +1 -0
- package/dist/he_IL-JPZMCURP.js +1028 -0
- package/dist/he_IL-JPZMCURP.js.map +1 -0
- package/dist/id_ID-XSTFHHJQ.js +1028 -0
- package/dist/id_ID-XSTFHHJQ.js.map +1 -0
- package/dist/index.d.ts +5487 -0
- package/dist/index.js +24040 -0
- package/dist/index.js.map +1 -0
- package/dist/it-EIY6JESQ.js +1028 -0
- package/dist/it-EIY6JESQ.js.map +1 -0
- package/dist/ja-42N4XRCW.js +1028 -0
- package/dist/ja-42N4XRCW.js.map +1 -0
- package/dist/ko-3VRPTIP3.js +1028 -0
- package/dist/ko-3VRPTIP3.js.map +1 -0
- package/dist/ms_MY-SKN7O6M5.js +1028 -0
- package/dist/ms_MY-SKN7O6M5.js.map +1 -0
- package/dist/pl-TYARHCLT.js +1028 -0
- package/dist/pl-TYARHCLT.js.map +1 -0
- package/dist/pt-72IIFLL4.js +1028 -0
- package/dist/pt-72IIFLL4.js.map +1 -0
- package/dist/quickcharts.css +6217 -0
- package/dist/resources-Ch8eA1Mw.d.ts +228 -0
- package/dist/rest-openapi.json +1664 -0
- package/dist/ru-TPH4F7IB.js +1028 -0
- package/dist/ru-TPH4F7IB.js.map +1 -0
- package/dist/sv-DC5RS67Z.js +1028 -0
- package/dist/sv-DC5RS67Z.js.map +1 -0
- package/dist/th-I6H6AOZ3.js +1028 -0
- package/dist/th-I6H6AOZ3.js.map +1 -0
- package/dist/theme-manifest.json +1246 -0
- package/dist/tr-7FSUVCNW.js +1028 -0
- package/dist/tr-7FSUVCNW.js.map +1 -0
- package/dist/vi-DLRPHLLH.js +1028 -0
- package/dist/vi-DLRPHLLH.js.map +1 -0
- package/dist/zh-ZXIQBBEP.js +1028 -0
- package/dist/zh-ZXIQBBEP.js.map +1 -0
- package/dist/zh_TW-TYNUJU7U.js +1028 -0
- package/dist/zh_TW-TYNUJU7U.js.map +1 -0
- 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.
|