@trdrs/quickcharts 1.2.0 → 1.3.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/README.md CHANGED
@@ -918,14 +918,277 @@ function drive(widget: ChartWidget): void {
918
918
  }
919
919
  ```
920
920
 
921
- ### The seven chart styles
921
+ ### Chart styles
922
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
923
+ `candles`, `hollow`, `bars`, `line`, `area`, `baseline`, `stepline`, listed as `CHART_STYLES`. A
924
+ style switch is presentation: nothing refetches, and the loaded bars, indicators, drawings,
925
+ comparisons, scale and visible range all survive it. Four of the seven are value-shaped
926
926
  (`valueShaped(style)`), which is the one predicate a host branches on when it renders open, high and
927
927
  low values of its own.
928
928
 
929
+ By default a chart offers all seven styles, and the style picker groups them by family. `styles`
930
+ names the styles you offer, in the order the picker lists them, and `style` must be one of them:
931
+
932
+ ```ts
933
+ import { createChart, type ChartDatafeed } from '@trdrs/quickcharts'
934
+
935
+ declare const datafeed: ChartDatafeed
936
+
937
+ // Candles, line and area, in this order, opening on the line.
938
+ createChart({ container, datafeed, styles: ['candles', 'line', 'area'], style: 'line' })
939
+
940
+ // Every style and no picker: a control of your own runs the chart.style.<id> commands.
941
+ createChart({ container, datafeed, ui: { topBar: { styles: false } } })
942
+
943
+ // Candles and line, with no picker.
944
+ createChart({ container, datafeed, styles: ['candles', 'line'], ui: { topBar: { styles: false } } })
945
+ ```
946
+
947
+ A style you leave out has no `chart.style.<id>` command, so no menu, shortcut or host control
948
+ reaches it, and `setStyle` ignores it. With one style offered the picker is not shown, since there
949
+ is nothing to choose. `ui.topBar.styles: false` hides the picker on its own terms, so you can
950
+ restrict the set, hide the picker, do both, or do neither. A saved layout, saved chart or stored
951
+ preference that names a style you left out opens on the first style in `styles`, and the rest of it
952
+ restores. An empty list, an id outside `CHART_STYLES`, a repeated id and a `style` outside the list
953
+ are setup errors that `createChart` throws.
954
+
955
+ ### Offered timeframes
956
+
957
+ By default a chart offers the 26 presets (`TIMEFRAME_PRESETS`) and lets the viewer compose custom
958
+ timeframes. `timeframes` names the timeframe tokens you offer, presets or any other token the
959
+ grammar reads, and `timeframe` must be one of them. The order you give does not matter: the picker
960
+ lists each unit's group smallest first. `customTimeframes: false` keeps the presets and removes
961
+ custom timeframes:
962
+
963
+ ```ts
964
+ import { createChart, type ChartDatafeed } from '@trdrs/quickcharts'
965
+
966
+ declare const datafeed: ChartDatafeed
967
+
968
+ // Four timeframes, one of them beyond the presets, opening on five minutes.
969
+ createChart({ container, datafeed, timeframes: ['2m', '5m', '1h', '1d'], timeframe: '5m' })
970
+
971
+ // Every preset and no custom timeframes.
972
+ createChart({ container, datafeed, customTimeframes: false })
973
+
974
+ // Two timeframes and no picker: a control of your own runs the chart.timeframe.<token> commands.
975
+ createChart({ container, datafeed, timeframes: ['1m', '5m'], ui: { topBar: { timeframes: false } } })
976
+ ```
977
+
978
+ A preset you leave out has no `chart.timeframe.<token>` command, and `chart.timeframe.set` and
979
+ `setTimeframe` ignore any token you leave out. The picker shows only what you list: a group with
980
+ no listed token is not drawn, a listed token beyond the presets sits in its unit's group without a
981
+ delete, and there is no custom-timeframe composer. The viewer's saved chips show where you list
982
+ them. When you list none of them, the chips are your five smallest timeframes, and the viewer's
983
+ stored chips stay stored for a chart that offers them. With one timeframe offered the picker is not
984
+ shown. `ui.topBar.timeframes: false` hides the picker on its own terms. A range preset whose
985
+ interval you leave out reads its span at your nearest coarser timeframe, or at your largest when
986
+ none is coarser. Within what you offer, the feed's `resolutions` and the symbol's
987
+ `supportedResolutions` still apply: a chip the feed or the symbol does not serve is disabled, and
988
+ the picker's list leaves its row out.
989
+
990
+ A saved layout, saved chart or stored preference that names a timeframe you left out opens on your
991
+ smallest timeframe, and the rest of it restores. With `customTimeframes: false` a stored custom
992
+ token opens on `1m`. An empty list, a token the grammar cannot read, a repeated token, a
993
+ `timeframe` or `layout.charts[].timeframe` outside what you offer, and `customTimeframes: true`
994
+ beside a list are setup errors that `createChart` throws. `customTimeframes` is the switch, and
995
+ `preferences.customTimeframes` is the list of custom tokens a first-run viewer starts with.
996
+
997
+ ### Offered layouts
998
+
999
+ By default a chart offers all 55 arrangements (`ARRANGEMENTS`) and lets the viewer change every
1000
+ sync switch. `layouts` names the arrangement codes you offer, and the layout opens on
1001
+ `layout.arrangement` or else on the first code you list. `layoutSync` names the sync switches the
1002
+ viewer may change:
1003
+
1004
+ ```ts
1005
+ import { createChart, type ChartDatafeed, type ChartSaveLoadAdapter } from '@trdrs/quickcharts'
1006
+
1007
+ declare const datafeed: ChartDatafeed
1008
+ declare const saveLoad: ChartSaveLoadAdapter
1009
+
1010
+ // A single chart: no layout setup menu, and no re-tile from any door.
1011
+ createChart({ container, datafeed, layouts: ['s'] })
1012
+
1013
+ // One chart, two side by side or a 2x2 grid, with the crosshair always synced and symbol sync the
1014
+ // only switch the viewer changes.
1015
+ createChart({ container, datafeed, layouts: ['s', '2h', '4'], layoutSync: ['symbol'], layout: { sync: { crosshair: true } } })
1016
+
1017
+ // Every arrangement, with the layout setup menu kept and the saved-layouts menu hidden.
1018
+ createChart({ container, datafeed, saveLoad, ui: { topBar: { savedLayouts: false } } })
1019
+ ```
1020
+
1021
+ The layout setup menu shows only the arrangements you list, in its own rows by chart count, and
1022
+ leaves out a row with none of them. An arrangement you leave out has no tile, and
1023
+ `widget.layout.setArrangement` and its command ignore it. With one arrangement offered there is
1024
+ nothing to choose, so the menu is not shown, unless that arrangement holds several charts and a
1025
+ sync switch is offered, in which case the menu holds the switches alone. A switch you leave out holds
1026
+ the value `layout.sync` gives it, or off: the menu does not show it, `widget.layout.setSync` and its
1027
+ command leave it as it is, and a saved layout cannot change it. An empty `layoutSync` fixes every
1028
+ switch.
1029
+
1030
+ A saved layout whose arrangement you left out opens on the offered arrangement with the most charts
1031
+ not above the saved count, the first one listed winning a tie. When every offered arrangement holds
1032
+ more charts, it opens on the one with the fewest, and the extra panes clone the first chart as a
1033
+ re-tile does. The saved charts past the ones shown are not dropped. The layout carries them with the
1034
+ saved arrangement and its divider positions, and a save writes them back unchanged beside the shown
1035
+ charts as they are now: a host offering only `['s']` opens a saved 4-chart layout as one chart, and
1036
+ after it saves, a host offering every arrangement still opens all four. A re-tile is a new
1037
+ arrangement and ends the carry, as it tears down the charts it has no pane for. A saved active chart
1038
+ among the hidden ones leaves the first chart active, and the sync switches restore as saved, apart
1039
+ from those `layoutSync` leaves out. An empty `layouts`, an unknown or repeated code, a
1040
+ `layout.arrangement` outside the list, and an unknown or repeated sync switch are setup errors that
1041
+ `createChart` throws.
1042
+
1043
+ `ui.topBar.layouts: false` hides the layout setup menu and the saved-layouts menu together;
1044
+ `ui.topBar.layoutSetup: false` and `ui.topBar.savedLayouts: false` hide one of them, and neither
1045
+ shows a menu `layouts: false` hid. The saved-layouts menu saves, opens and lists layouts, so it is
1046
+ shown only with a layouts store (`saveLoad.layouts`). Without one, its Download chart data row joins
1047
+ the image menu beside Download image and Copy image, the way an export menu offers a chart as a
1048
+ picture or as data.
1049
+
1050
+ ### Offered drawing tools
1051
+
1052
+ By default a chart offers every drawing tool. `drawingTools` names the tools you offer, by the type
1053
+ `access.drawingTool` and the `tool.<type>` icon ids use: a type `drawingTools.all()` lists (from
1054
+ `@trdrs/quickcharts/drawings`), `zoom` or `eraser`. The order you give does not matter, because the
1055
+ rail keeps its own groups and sections:
1056
+
1057
+ ```ts
1058
+ import { createChart, type ChartDatafeed } from '@trdrs/quickcharts'
1059
+
1060
+ declare const datafeed: ChartDatafeed
1061
+
1062
+ // Lines, levels and a ruler: no shapes, patterns, notes or glyphs.
1063
+ createChart({ container, datafeed, drawingTools: ['trend_line', 'horizontal_line', 'fib_retracement', 'measure'] })
1064
+
1065
+ // A chart whose viewers keep and remove the drawings on it and make nothing new.
1066
+ createChart({ container, datafeed, drawingTools: ['eraser'] })
1067
+ ```
1068
+
1069
+ A tool you leave out is not drawn anywhere a tool is chosen: not in its group's flyout, not as a
1070
+ group's face, which wears the first tool the group offers, not on the favorites bar, and not on the
1071
+ rail for `measure` and `zoom`. A section or group it empties goes with it. The glyph picker's kinds
1072
+ are the `emoji`, `sticker` and `icon` tools, so a kind you leave out has no tab, and leaving out all
1073
+ three removes the glyph group. Every door that would arm it refuses: `chart.drawings.arm` answers
1074
+ `denied`, and `armTool`, `placeImage` and an image pasted over the chart place nothing. A copy is a
1075
+ new drawing, so a drawing of a tool you leave out is not copied into another: `chart.drawings.clone`
1076
+ and `chart.drawings.paste` are unavailable for it, `clone` and `paste` make nothing, and a
1077
+ Control- or Command-drag moves the drawing instead of duplicating it. Copying it to the clipboard is
1078
+ not refused, so it pastes on a chart that offers its tool.
1079
+
1080
+ Drawings of a tool you leave out that are already on the chart, from a saved chart or layout, a
1081
+ drawings document or another host, render and stay fully editable. They select, restyle through the
1082
+ selection's settings bar and the settings dialog, lock, hide and delete exactly as any drawing does:
1083
+ from the settings bar, the eraser, the remove menu and the Delete key. The eraser is always offered,
1084
+ listed or not, because it removes drawings rather than making them, which is why `['eraser']` is the
1085
+ way to offer no tool that creates one.
1086
+
1087
+ The list composes with your access policy: a tool is offered when you list it and the policy permits
1088
+ it, and a listed tool the policy refuses is drawn as `access.refused` says (see Refused controls).
1089
+ Nothing stored is rewritten: a starred tool you leave out keeps its star and returns to the favorites
1090
+ bar on a chart that offers it, and a group's remembered face is kept the same way.
1091
+ `features.drawings: false` removes drawing altogether, whatever the list says. An empty list, a type
1092
+ that names no tool and a repeated type are setup errors that `createChart` throws.
1093
+
1094
+ ### Offered indicators
1095
+
1096
+ By default a chart offers every built-in indicator. `builtInIndicators` names the built-ins you
1097
+ offer, by the definition id `access.indicator` receives: an id `BUILT_IN_INDICATORS` lists, such as
1098
+ `sma`. The order you give does not matter, because the indicator browser keeps its own:
1099
+
1100
+ ```ts
1101
+ import { BUILT_IN_INDICATORS, createChart, type ChartDatafeed } from '@trdrs/quickcharts'
1102
+
1103
+ declare const datafeed: ChartDatafeed
1104
+
1105
+ // Moving averages and RSI only.
1106
+ createChart({ container, datafeed, builtInIndicators: ['sma', 'ema', 'rsi'] })
1107
+
1108
+ // A chart that opens with a study it offers.
1109
+ const sma = BUILT_IN_INDICATORS.find((definition) => definition.id === 'sma')!
1110
+ createChart({ container, datafeed, builtInIndicators: ['sma'], indicators: [{ id: 'sma-1', definition: sma }] })
1111
+ ```
1112
+
1113
+ A built-in you leave out is not listed in the indicator browser: not among the built-ins, not
1114
+ among the favorites, and not in search results. Every door that would add one refuses:
1115
+ `chart.indicators.add` (from the browser, the keyboard or your own control) answers `denied`,
1116
+ `indicators.add` adds nothing, `indicators.set` leaves out an instance of it the chart does not
1117
+ already hold, and a new pane that a re-tile adds copies the first chart's studies without it. The
1118
+ chart has no verb that duplicates an indicator, so a second instance is an add like any other.
1119
+
1120
+ Instances of a built-in you leave out that are already on the chart, from a saved chart or layout,
1121
+ an undo step or another host sharing the store, render and stay fully editable and removable. The
1122
+ settings dialog, `chart.indicators.update`, the legend's eye and remove control, remove all and
1123
+ `indicators.remove` work on them exactly as on any instance, and an edit that would move one onto
1124
+ another built-in you leave out keeps it as it stands. Nothing saved is rewritten because of the
1125
+ list: a starred built-in you leave out keeps its star and returns to the favorites on a chart that
1126
+ offers it.
1127
+
1128
+ The list filters the built-ins alone. A definition of your own, whose `manifest.id` names no
1129
+ built-in, is never filtered, and neither is what your `indicatorPicker` source lists: its
1130
+ collections and rows are your content. The source is handed only the offered ids as `builtInIds`.
1131
+ The list composes with your access policy: a built-in is offered when you list it and the policy
1132
+ permits it, and a listed one the policy refuses is drawn as `access.refused` says (see Refused
1133
+ controls). A non-list, an empty list, an id that names no built-in, a repeated id and an
1134
+ `indicators` instance whose built-in you do not list are setup errors that `createChart` throws.
1135
+
1136
+ ### Offered ranges
1137
+
1138
+ By default the bottom bar offers every range preset in `RANGE_PRESETS`, from `1D` to `All`.
1139
+ `ranges` names the presets you offer by their keys, in the order the bottom bar draws them:
1140
+
1141
+ ```ts
1142
+ import { createChart, type ChartDatafeed } from '@trdrs/quickcharts'
1143
+
1144
+ declare const datafeed: ChartDatafeed
1145
+
1146
+ // A day, a week's worth of sessions and a year, in this order.
1147
+ createChart({ container, datafeed, ranges: ['1D', '5D', '1Y'] })
1148
+
1149
+ // No range buttons: the bottom bar keeps its clock, timezone picker and session view.
1150
+ createChart({ container, datafeed, ranges: [] })
1151
+ ```
1152
+
1153
+ A preset you leave out has no `chart.range.<key>` command, so no menu, shortcut or host control
1154
+ reaches it, `chart.range.set` ignores its key, and the bottom bar draws no button for it. An empty
1155
+ list offers none, which is how you keep the rest of the bottom bar without range buttons;
1156
+ `ui.bottomBar: false` removes the whole bar. `chart.range.set` still takes an explicit
1157
+ `{ from, to }` window either way. A preset whose interval your `timeframes` leave out reads its
1158
+ span at your nearest coarser timeframe, as it does without a list. A non-list, a key that names no
1159
+ preset and a repeated key are setup errors that `createChart` throws.
1160
+
1161
+ ### Offered timezones
1162
+
1163
+ By default the timezone picker offers every zone in `TIMEZONES` and the exchange choice.
1164
+ `timezones` names the choices you offer: zone ids, and `exchange` (`EXCHANGE_TIMEZONE`) for the
1165
+ charted symbol's own zone. The picker keeps its own order (UTC, the exchange choice, then every
1166
+ zone by its current offset); the order you give decides only which choice comes first:
1167
+
1168
+ ```ts
1169
+ import { createChart, EXCHANGE_TIMEZONE, type ChartDatafeed } from '@trdrs/quickcharts'
1170
+
1171
+ declare const datafeed: ChartDatafeed
1172
+
1173
+ // The exchange's own time, New York or London, opening on the exchange's time for a first-run viewer.
1174
+ createChart({ container, datafeed, timezones: [EXCHANGE_TIMEZONE, 'America/New_York', 'Europe/London'] })
1175
+
1176
+ // Always Tokyo time: no picker, and the clock reads Tokyo.
1177
+ createChart({ container, datafeed, timezones: ['Asia/Tokyo'] })
1178
+ ```
1179
+
1180
+ A choice you leave out has no `chart.timezone.<id>` command (`chart.timezone.exchange` for the
1181
+ exchange choice), `chart.timezone.set` and `setTimezone` ignore it, and the picker lists no row for
1182
+ it. With one choice offered the picker is not shown, since there is nothing to choose, and the
1183
+ bottom bar's clock still reads the time in that zone.
1184
+
1185
+ A chart opens on the viewer's stored choice, else on `preferences.timezone`, when you list it, and
1186
+ otherwise on the first choice you list. A stored choice you leave out is not rewritten: it stays
1187
+ stored until the viewer picks another zone, so a chart that offers it again opens on it. The
1188
+ exported registry is not filtered: `TIMEZONES`, `isTimezoneChoice` and `timezoneListing` answer
1189
+ the full set for code of your own. A non-list, an empty list, an id that names no zone and a
1190
+ repeated id are setup errors that `createChart` throws.
1191
+
929
1192
  ### Events
930
1193
 
931
1194
  Typed maps, one per scope. Every subscription returns its unsubscribe and is inert after
@@ -981,7 +1244,9 @@ function toolbar(widget: ChartWidget): void {
981
1244
 
982
1245
  A command whose one id spans many subjects declares `refuses(arg)` beside `available()`: an
983
1246
  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`,
1247
+ `chart.drawings.arm` refuses a tool. `onChange` fires when the registered set or a shortcut changes,
1248
+ and when `widget.refreshAccess()` says your policy may answer differently. A refusal is a value,
1249
+ never a throw: `ok`, `unavailable`,
985
1250
  `denied`, `unknown`, or `failed` with the
986
1251
  error. Your own commands register through the same door: a chart extension's `contributeCommands`
987
1252
  puts them in this list with `scope: 'chart'` and its own label text.
@@ -1028,6 +1293,99 @@ The library reads `features` and `ui` once, when the widget is created. A key or
1028
1293
  plane does not take throws a `TypeError` naming its path before anything mounts. The package's
1029
1294
  `dist/feature-manifest.json` lists every flag in its `features` and `ui` blocks.
1030
1295
 
1296
+ ### Refused controls
1297
+
1298
+ By default the chart's own controls draw what your access policy refuses disabled, which suits an
1299
+ offer the viewer can unlock. `access.refused: 'hide'` leaves it out instead, for a chart that simply
1300
+ does not offer it:
1301
+
1302
+ ```ts
1303
+ import { createChart, type ChartDatafeed } from '@trdrs/quickcharts'
1304
+
1305
+ declare const datafeed: ChartDatafeed
1306
+ declare const session: { may(feature: string): boolean }
1307
+
1308
+ createChart({
1309
+ container,
1310
+ datafeed,
1311
+ access: {
1312
+ refused: 'hide',
1313
+ command: (id) => id !== 'chart.replay.start' || session.may('replay'),
1314
+ drawingTool: (tool) => tool !== 'brush' && tool !== 'highlighter',
1315
+ indicator: (id) => id !== 'vwap',
1316
+ },
1317
+ })
1318
+ ```
1319
+
1320
+ With `'hide'`, a refused drawing tool is not in its group's flyout, on the favorites bar or in the
1321
+ glyph picker; a section, a group or a favorites bar it leaves empty goes with it, and a group's face
1322
+ that would wear a refused tool wears the first one the group still offers. A refused indicator is
1323
+ not in the indicator browser. A control or menu row whose command the policy refuses is not drawn
1324
+ (top bar, bottom bar, menus, the drawing toolbar and the selection's bar, the navigation cluster,
1325
+ the replay transport, legend row controls and the level menu), and a rule left with nothing beside it
1326
+ goes too. A picker always keeps its current choice: the chosen style, timeframe, timezone or scale
1327
+ is listed even when its command is refused.
1328
+
1329
+ Only a refusal hides. A permitted command that cannot run now (nothing to undo, no bars loaded,
1330
+ nothing selected) is drawn disabled as before. The policy is asked whenever the chrome syncs (a
1331
+ change on the active chart, a change to the command registry) and whenever a menu or flyout opens,
1332
+ and `widget.refreshAccess()` asks it again at any moment (see A policy that changes), so a policy
1333
+ that follows your session moves the controls with it. Nothing stored is rewritten: a
1334
+ viewer's favorite keeps its star and returns to the favorites bar once you permit the tool again,
1335
+ and drawings and indicators already on the chart render exactly as they do under a refusal by
1336
+ default. The keyboard, `widget.commands` and the chart handles refuse exactly as they do with
1337
+ `'disable'`. A value other than `'disable'` or `'hide'` is a setup error that `createChart` throws.
1338
+
1339
+ Under either value the policy refuses creating content, never what is already on the chart or in
1340
+ saved content: it refuses adding, never restoring. A saved chart, a layout load, an undo or a redo,
1341
+ a drawings document and shared drawing storage put back every indicator and drawing they carry,
1342
+ those the policy refuses included, so a load followed by a save never rewrites a viewer's content
1343
+ without them. An indicator whose definition `access.indicator` refuses, once on the chart (added
1344
+ before the policy changed, or put back by a restore), renders and stays fully editable and
1345
+ removable:
1346
+ `indicators.set`, `chart.indicators.update` and the settings dialog edit it, an edit of any other
1347
+ study keeps it, and an edit that would move it, or another study, onto a refused definition leaves
1348
+ that study as it stands. A new instance of a refused definition is still left out by every door. A
1349
+ drawing whose tool `access.drawingTool` refuses selects, restyles, locks, hides and deletes as any
1350
+ drawing does; a copy is a new drawing, so it is not cloned, pasted or duplicated by a Control- or
1351
+ Command-drag, while copying it to the clipboard is not refused.
1352
+
1353
+ ### A policy that changes
1354
+
1355
+ The predicates are asked live: every door that runs a command, arms a tool or adds an indicator asks
1356
+ at that moment, so a refusal holds from the instant your policy answers it. The chart's own controls
1357
+ read the policy when they sync and when a menu opens. When its answers change and nothing on the
1358
+ chart does (a viewer's plan changed mid-session), call `widget.refreshAccess()`:
1359
+
1360
+ ```ts
1361
+ import { createChart, type ChartDatafeed } from '@trdrs/quickcharts'
1362
+
1363
+ declare const datafeed: ChartDatafeed
1364
+ declare const session: { may(feature: string): boolean; onChange(listener: () => void): void }
1365
+
1366
+ const widget = createChart({
1367
+ container,
1368
+ datafeed,
1369
+ access: {
1370
+ refused: 'hide',
1371
+ command: (id) => id !== 'chart.replay.start' || session.may('replay'),
1372
+ indicator: (id) => id !== 'vwap' || session.may('vwap'),
1373
+ },
1374
+ })
1375
+ session.onChange(() => widget.refreshAccess())
1376
+ ```
1377
+
1378
+ Every surface that reads the policy reads it again at once: the top and bottom bars and their
1379
+ controls, the drawing toolbar's tools and groups, the favorites bar, the selection's bar, the
1380
+ navigation cluster, the replay transport and the legend's row controls, shown or left out as
1381
+ `access.refused` says and enabled or disabled. A menu, flyout or dialog that is open reads it too:
1382
+ the bars' menus, the level menu, the indicator browser and the saved layouts dialog rebuild their
1383
+ rows in place, and a drawing toolbar flyout (the glyph picker among them) or a selection bar panel
1384
+ opens again from its control, or closes when that control is now left out or disabled. Listeners of `widget.commands.onChange`
1385
+ hear it as well, so a control of your own can read `commands.available` again. Nothing stored
1386
+ changes, nothing on the chart changes, and no `saveNeeded` is raised: a refresh is a reading, never
1387
+ a write.
1388
+
1031
1389
  ### Your own interface
1032
1390
 
1033
1391
  The widget supports three ways to present a chart, over the same behavior and the same commands.
@@ -1134,7 +1492,9 @@ const indicatorPicker: IndicatorPickerSource = {
1134
1492
  }
1135
1493
  ```
1136
1494
 
1137
- The source receives the collection, query and shipped definition ids. It owns filtering and any
1495
+ The source receives the collection, query and the shipped definition ids the browser lists (only
1496
+ those `builtInIndicators` offers, when you name a list). Its own collections and rows are never
1497
+ filtered by that list. It owns filtering and any
1138
1498
  service limits; the browser does not promise an unlimited catalog or implement paging for you.
1139
1499
  Collection ids `builtin` and `favorites`, and action ids `add` and `favorite`, are reserved.
1140
1500
  Item ids cannot collide with shipped definition ids or these reserved ids. Duplicate ids,
@@ -1159,8 +1519,9 @@ chrome is painted from it and renders nothing without it.
1159
1519
 
1160
1520
  - **The top bar.** The symbol pill opens the symbol search for the active chart; the compare door
1161
1521
  opens it in compare mode. The timeframe picker shows the saved timeframes as chips and a list of
1162
- the 26 presets in five groups, each row savable as a chip, with a composer for a custom interval
1163
- under the unit's ceiling. The style picker lists the seven styles. Indicators opens the picker
1522
+ the timeframes the chart offers (by default the 26 presets in five groups), each row savable as a
1523
+ chip, with a composer for a custom interval under the unit's ceiling while custom timeframes are
1524
+ offered. The style picker lists the styles the chart offers. Indicators opens the picker
1164
1525
  over the 23 built-in definitions, and the legend's gear opens the settings dialog for an instance
1165
1526
  (inputs, style, visibility). Bar replay enters and leaves replay for the active chart; entering
1166
1527
  asks where to begin rather than choosing a starting bar. Its starting-point menu answers with a
@@ -1168,19 +1529,20 @@ chrome is painted from it and renders nothing without it.
1168
1529
  date runs `chart.replay.startFirst`: the chart pages the feed's history back to its oldest bar,
1169
1530
  within the 20,000 bars one session holds, and starts there. Undo and
1170
1531
  redo step back and forward through the active chart's own content, and each names the change it
1171
- would move. Layout setup offers the 55
1172
- arrangements and the five sync switches; the saved-layouts menu saves, copies, renames, opens and
1173
- deletes layouts through `saveLoad.layouts` and marks unsaved changes, with an autosave switch.
1532
+ would move. Layout setup offers the arrangements and sync switches the chart offers (by default
1533
+ the 55 arrangements and the five sync switches); the saved-layouts menu, shown with a layouts
1534
+ store, saves, copies, renames, opens and deletes layouts through `saveLoad.layouts` and marks
1535
+ unsaved changes, with an autosave switch and Download chart data.
1174
1536
  Chart settings edits appearance, grid and session shading, the price-scale mode and the theme
1175
1537
  mode, and its Reset defaults row runs `chart.appearance.reset`, which drops the viewer's own
1176
1538
  appearance edits and returns the price scale to normal so the chart reads as the theme and your
1177
1539
  constructor options paint it. `ui: { topBar: { settings: { theme: false } } }` removes its Theme
1178
1540
  section and leaves the rest of the menu, for a host that offers the theme choice in its own
1179
- 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
1180
- the bar.
1181
- - **The bottom bar.** The range presets, the clock in the display zone with the timezone list
1182
- (UTC and the exchange choice first), and the session view for a symbol that trades outside
1183
- regular hours.
1541
+ settings; the widget's theme API and theme commands are untouched. Fullscreen and the image menu (Download image, Copy image where the browser can, and
1542
+ Download chart data when there is no layouts store) close the bar.
1543
+ - **The bottom bar.** The range presets the chart offers, the clock in the display zone with the
1544
+ list of the timezones it offers (UTC and the exchange choice first), and the session view for a
1545
+ symbol that trades outside regular hours.
1184
1546
  - **Around the charts.** A navigation cluster (zoom, scroll, reset) sits at the bottom of each
1185
1547
  pane, and past one tile it carries a sixth control that fills the layout with the active tile or
1186
1548
  gives the layout back, over `widget.layout.toggleMaximize`, the same verb the Alt press on a tile
@@ -1273,7 +1635,8 @@ null when `ui: { topBar: false }` removes the bar, which is what to check before
1273
1635
  you would have nowhere to put.
1274
1636
 
1275
1637
  Every control acts through the command registry and reflects `commands.available`, so a command
1276
- your access policy refuses renders disabled and does nothing. The saved-layouts menu runs the
1638
+ your access policy refuses renders disabled and does nothing, or is not drawn with
1639
+ `access.refused: 'hide'`. The saved-layouts menu runs the
1277
1640
  layout verbs `widget.layout.save`, `rename`, `load`, `open`, `delete`, `create` and `autosave`; what a
1278
1641
  verb did reports through the `layout` event and what it refused through `saveConflict`. Copy image
1279
1642
  reports through the `image` event (a refused copy falls back to a download). Each control has a flag
@@ -1298,7 +1661,9 @@ trimmed.on('saveNeeded', () => note('the layout has unsaved changes'))
1298
1661
  ```
1299
1662
 
1300
1663
  The search dialog's class chips come from your datafeed's `config().classes`, named through
1301
- `search.classNames`; a class you do not name wears its token. `search.scope` names what your
1664
+ `search.classNames`; a class you do not name wears its token. `search.spreads`,
1665
+ `search.allClasses` and `search.classSelection` choose how the classes and the spread operators are
1666
+ offered, as "Classes and spread operators" under Search describes. `search.scope` names what your
1302
1667
  search is limited to at the far edge of that strip, with the mark its `mark` paints; your datafeed
1303
1668
  decides what is found. The range presets read your
1304
1669
  datafeed's optional `earliestBar(symbol)`: a preset deeper than the history you serve is disabled.
@@ -1381,8 +1746,8 @@ before costs that one glyph its artwork. The control draws the chart's own glyph
1381
1746
  `icons` covers the chart's own controls. The emoji, sticker and icon glyphs a drawing places come
1382
1747
  through the asset port, and a chart extension draws its rows and layers from the
1383
1748
  `ChartExtensionIcon` descriptors it contributes. The product's mark in the plot's corner is not an
1384
- icon. `mountContextMenu`, `openSymbolSearch` and `mountSymbolSearch` take the same `icons` for a menu
1385
- or search you mount without a widget.
1749
+ icon. `mountContextMenu`, `openSymbolSearch`, `mountSymbolSearch`, `openTimeframePicker` and
1750
+ `mountTimeframePicker` take the same `icons` for a menu, search or picker you mount without a widget.
1386
1751
 
1387
1752
  ### Neutral marks
1388
1753
 
@@ -1535,15 +1900,61 @@ inventory, and the built-in palettes and the generated stylesheet are built from
1535
1900
  property names are private, so a color, a size or a radius is always set through a role. The
1536
1901
  class names inside the stylesheet are private too, except the supported hooks below.
1537
1902
 
1903
+ ### Motion
1904
+
1905
+ Every duration, timing function and motion scale the chart uses is a role in the `motion` family,
1906
+ so you retune the chart's motion the way you retune its colors. Both built-in palettes carry the
1907
+ same values:
1908
+
1909
+ | Role | Default | What it times |
1910
+ |---|---|---|
1911
+ | `motion.durationFast` | `90ms` | A state change under the pointer, such as a hover fill. |
1912
+ | `motion.durationBase` | `150ms` | A modal dialog and its backdrop opening and closing, and the replay row entering and leaving. |
1913
+ | `motion.durationModerate` | `250ms` | A control moving to its new state, such as a switch knob sliding. |
1914
+ | `motion.durationSlow` | `350ms` | A small mark settling, such as a disclosure caret turning or a checkbox filling. |
1915
+ | `motion.durationSlower` | `500ms` | A larger mark turning, such as the drawing rail's chevron. |
1916
+ | `motion.easingStandard` | `ease` | A modal dialog box, the replay row, a caret dip, a checkbox fill. |
1917
+ | `motion.easingOut` | `ease-out` | A modal backdrop fading, a switch knob sliding. |
1918
+ | `motion.easingLinear` | `linear` | A color or opacity change under the pointer. |
1919
+ | `motion.easingSpring` | `cubic-bezier(0.175, 0.885, 0.32, 1.275)` | A caret or chevron turning with a slight overshoot. |
1920
+ | `motion.scaleEnter` | `0.97` | The scale a modal dialog box grows from as it opens. |
1921
+
1922
+ A modal dialog, such as the symbol search or chart settings, opens with its backdrop fading in over
1923
+ `motion.durationBase` on `motion.easingOut`, and its box fading in and growing from
1924
+ `motion.scaleEnter` to full size over `motion.durationBase` on `motion.easingStandard`. It closes
1925
+ with the same motion reversed. A closing dialog stops taking input and returns focus at once, and the
1926
+ chart removes it when the same `motion.durationBase` has elapsed, so a duration you set times both
1927
+ the transition and the removal.
1928
+
1929
+ A duration takes a CSS duration such as `200ms` or `0.2s`; an easing takes any CSS timing function,
1930
+ such as `ease-in-out`, `steps(4)` or `cubic-bezier(0.2, 0, 0, 1)`; a scale takes a unitless factor
1931
+ such as `0.95`.
1932
+
1933
+ ```ts
1934
+ import { createThemeController, type CustomThemes } from '@trdrs/quickcharts'
1935
+
1936
+ const calm: CustomThemes = {
1937
+ light: { 'motion.durationBase': '200ms', 'motion.scaleEnter': '0.95' },
1938
+ dark: { 'motion.durationBase': '200ms', 'motion.scaleEnter': '0.95' },
1939
+ }
1940
+ const theme = createThemeController({ mode: 'dark', custom: calm })
1941
+ note(theme.get()['motion.durationBase'])
1942
+ ```
1943
+
1944
+ When the reader's system asks for reduced motion (`prefers-reduced-motion: reduce`), the
1945
+ stylesheet resolves every duration role to `0ms` and marks those declarations `!important`, so a
1946
+ palette you supply cannot bring motion back: dialogs, menus and the replay row open and close at
1947
+ once.
1948
+
1538
1949
  ### Cascade layers
1539
1950
 
1540
1951
  The stylesheet declares two cascade layers and puts everything it contains in them: the built-in
1541
1952
  palettes in `trdrs.tokens`, every recipe in `trdrs.chart`. Nothing in it is unlayered. It marks a
1542
- declaration `!important` only where a later layer must not undo it: an element's `hidden`
1543
- attribute, the shortened motion under a reduced-motion preference, and the missing focus ring of a
1544
- search field, which shows its focus by its caret. Your first stylesheet must declare the complete
1545
- order before any product stylesheet loads, because a layer's position is fixed by the first
1546
- statement that names it:
1953
+ declaration `!important` only where a later layer or an inline palette must not undo it: an
1954
+ element's `hidden` attribute, the zero motion durations under a reduced-motion preference, and the
1955
+ missing focus ring of a search field, which shows its focus by its caret. Your first stylesheet must
1956
+ declare the complete order before any product stylesheet loads, because a layer's position is fixed
1957
+ by the first statement that names it:
1547
1958
 
1548
1959
  ```css
1549
1960
  @layer reset, trdrs.tokens, trdrs.chart, trdrs.platform, host;
@@ -1838,6 +2249,87 @@ isSymbolPair('BTC/USD') // true
1838
2249
  spreadExpression(' es - nq ') // 'ES-NQ'
1839
2250
  ```
1840
2251
 
2252
+ ### Classes and spread operators
2253
+
2254
+ The dialog offers your datafeed's `config().classes` as a strip of chips, an All chip first, one
2255
+ class selected at a time and sent to your feed as `cls`. Its field offers six spread operators
2256
+ behind a toggle, and a query that reads as an expression leads the list with the expression row.
2257
+ Three `search` options change that, and a chart that sets none of them searches exactly as above.
2258
+
2259
+ - `spreads`: `false` offers no operator toggle, no operator and no expression row, and your feed
2260
+ receives the query exactly as typed. `{ operators }` keeps spreads on and offers only the listed
2261
+ operators (`SpreadOperatorId`: `division`, `subtraction`, `addition`, `multiplication`,
2262
+ `exponentiation`, `reciprocal`), in the listed order. Default `true`. The compare dialog never
2263
+ offers the operators.
2264
+ - `allClasses`: `false` offers no All chip. With one class at a time, the first class you declare
2265
+ starts selected; with several, no selection means every class. `{ label }` writes the All chip
2266
+ with your label. Default: the chip, with the catalog's label.
2267
+ - `classSelection`: `'multiple'` makes every chip a toggle, and the All chip clears the selection.
2268
+ Your feed receives `classes` with every selected class in declared order, and `cls` too while
2269
+ exactly one is selected, so a feed that reads only `cls` still narrows a single chip. Default
2270
+ `'single'`.
2271
+
2272
+ A `classes` entry is a class token, or a `SearchClassNode`, `{ id, children }`, whose children are
2273
+ narrower classes. Selecting such a class opens a second row of chips beneath the strip: its own
2274
+ all chip, written like the All chip, and one chip per child, named through `search.classNames`.
2275
+ Your feed always receives the most specific class selected: the child when one is picked, the
2276
+ class when none is. The dialog renders two levels; a child's own `children` are not offered, and
2277
+ the child narrows by its `id`. The second row is a labelled group of pressed buttons, reached by
2278
+ Tab after the strip, as the strip is.
2279
+
2280
+ ```ts
2281
+ import { createChart, type ChartDatafeed, type DatafeedConfig } from '@trdrs/quickcharts'
2282
+
2283
+ declare const cryptoBase: ChartDatafeed
2284
+
2285
+ // Spot markets by quote currency, beside perpetuals.
2286
+ const cryptoFeed: ChartDatafeed = {
2287
+ ...cryptoBase,
2288
+ async config(): Promise<DatafeedConfig> {
2289
+ return { classes: [{ id: 'spot', children: ['usdc', 'usdt'] }, 'perp'] }
2290
+ },
2291
+ // `cls` is 'spot' while the whole class is selected, and 'usdc' once USDC is picked under it.
2292
+ search: (query, options) => cryptoBase.search(query, options),
2293
+ }
2294
+
2295
+ const crypto = createChart({
2296
+ container,
2297
+ datafeed: cryptoFeed,
2298
+ search: {
2299
+ classNames: { spot: 'Spot', usdc: 'USDC', usdt: 'USDT', perp: 'Perpetuals' },
2300
+ allClasses: { label: 'Every market' },
2301
+ spreads: { operators: ['division', 'subtraction'] },
2302
+ },
2303
+ })
2304
+ void crypto
2305
+ ```
2306
+
2307
+ With several classes at once, read `classes` and fall back to `cls`:
2308
+
2309
+ ```ts
2310
+ import { createChart, type ChartDatafeed } from '@trdrs/quickcharts'
2311
+
2312
+ declare const catalogFeed: ChartDatafeed
2313
+
2314
+ const multiFeed: ChartDatafeed = {
2315
+ ...catalogFeed,
2316
+ async search(query, options = {}) {
2317
+ // ['future', 'option'] with two chips selected; absent with none, which is every class.
2318
+ const classes = options.classes ?? (options.cls ? [options.cls] : [])
2319
+ return myBackend.search(query, classes, options.limit, options.offset)
2320
+ },
2321
+ }
2322
+
2323
+ const multi = createChart({
2324
+ container,
2325
+ datafeed: multiFeed,
2326
+ search: { classSelection: 'multiple', allClasses: false, spreads: false },
2327
+ })
2328
+ void multi
2329
+ ```
2330
+
2331
+ `openSymbolSearch` and `mountSymbolSearch` take the same three options beside their `classes`.
2332
+
1841
2333
  ### The picker, away from a chart
1842
2334
 
1843
2335
  A page that picks a market where no chart is mounted opens the same dialog the chart's symbol pill
@@ -1877,6 +2369,60 @@ card.dispose()
1877
2369
  catalog.dispose()
1878
2370
  ```
1879
2371
 
2372
+ ### The timeframe picker, away from a chart
2373
+
2374
+ A page that asks for an interval where no chart is mounted (a backtest's timeframe, an alert's
2375
+ interval) opens the list the chart's timeframe caret drops: the same unit groups, rows, labels and
2376
+ custom composer, built by the same code, so an interval reads there exactly as it reads on a chart.
2377
+ `openTimeframePicker` drops it from a control you own, which gets `aria-expanded` while it is open
2378
+ and focus back when it closes; `mountTimeframePicker` builds it bare into a box you own and
2379
+ position, as a card on a phone. Picking a row, or composing a custom timeframe, hands the token to
2380
+ `onPick`, because there is no chart here to set. The drop-down then closes; the card calls
2381
+ `onClose`, your cue to close its box.
2382
+
2383
+ It takes what you already give a chart. `timeframe` is the value it opens on, checked and focused,
2384
+ and a custom value sits in its unit's group. `timeframes` and `customTimeframes` offer exactly what
2385
+ they offer a chart (see Offered timeframes), with the same setup errors, thrown before anything
2386
+ mounts, plus one for a `timeframe` the grammar cannot read. `resolutions` and
2387
+ `supportedResolutions` say what your feed and the symbol serve: as in the chart's list, a row they
2388
+ do not serve is left out, and the composer does not offer one. `theme`, `locale` (or a prepared
2389
+ `i18n`) and `icons` paint it as they paint a chart, on a theme root of its own, so the stylesheet
2390
+ reaches it outside any chart in either mode.
2391
+
2392
+ It carries no saved chips and no stars, and keeps nothing: the chips are a chart's quick-select row,
2393
+ stored with the chart's preferences, and a field that asks for one interval has no row to put them
2394
+ in. A composed timeframe is handed back and not added to any list. The drop-down takes the keyboard
2395
+ as the chart's does (arrow keys, Home and End rove the rows; Escape and a press outside close
2396
+ it) and moves as every menu does.
2397
+
2398
+ ```ts
2399
+ import { mountTimeframePicker, openTimeframePicker } from '@trdrs/quickcharts'
2400
+
2401
+ declare const intervalField: HTMLButtonElement
2402
+ declare const intervalBox: HTMLElement
2403
+ declare const backtest: { timeframe: string; setTimeframe(timeframe: string): void }
2404
+
2405
+ intervalField.addEventListener('click', () => {
2406
+ const picker = openTimeframePicker({
2407
+ anchor: intervalField,
2408
+ timeframe: backtest.timeframe,
2409
+ resolutions: ['1m', '5m', '15m', '1h', '4h', '1d'], // what the feed serves
2410
+ theme: { mode: 'light' },
2411
+ onPick: (timeframe) => backtest.setTimeframe(timeframe),
2412
+ })
2413
+ void picker // picker.close() closes it from outside; Escape and a press outside close it too
2414
+ })
2415
+
2416
+ const card = mountTimeframePicker({
2417
+ container: intervalBox,
2418
+ timeframes: ['5m', '15m', '1h', '4h', '1d'],
2419
+ timeframe: '1h',
2420
+ onPick: (timeframe) => backtest.setTimeframe(timeframe),
2421
+ onClose: () => intervalBox.remove(),
2422
+ })
2423
+ card.focus()
2424
+ ```
2425
+
1880
2426
  ### The legend
1881
2427
 
1882
2428
  Every chart carries its own legend over its plot: an identity row naming the market, the interval
@@ -2028,10 +2574,12 @@ with its weak and strong strengths; stay in drawing mode; lock all; the eye that
2028
2574
  indicators, or both; drawing sync, shown only in a layout of more than one chart; the remove menu,
2029
2575
  which names what each row takes and carries the locked-item policy; and the favorites star. Each
2030
2576
  group button wears the tool it last armed, and every action is a `chart.drawings.*` command
2031
- through the registry: a control renders disabled, never hidden, while the registry would not run
2032
- its command (a tool or verb your access policy refuses, a selection verb with nothing selected,
2033
- the Image tool without an asset port), and a command the policy refuses answers `denied` from the
2034
- toolbar as from anywhere else, including `chart.drawings.arm` for a refused tool. An image is
2577
+ through the registry: a control renders disabled while the registry would not run its command (a
2578
+ tool or verb your access policy refuses, a selection verb with nothing selected, the Image tool
2579
+ without an asset port), and with `access.refused: 'hide'` a tool or verb the policy refuses is left
2580
+ out instead (see Refused controls), and a command the policy refuses answers `denied` from the
2581
+ toolbar as from anywhere else, including `chart.drawings.arm` for a refused tool. A tool your
2582
+ `drawingTools` list leaves out is not on the toolbar at all (see Offered drawing tools). An image is
2035
2583
  placed through `chart.drawings.placeImage`, from the picker or a system-clipboard paste over the
2036
2584
  chart. Every flyout, palette and dialog a surface opens sits inside the chart root and closes with
2037
2585
  it, and the eye and lock all announce their state through a live region. Lock all also makes Paste
@@ -2085,6 +2633,11 @@ layer.armTool('rectangle')
2085
2633
  layer.destroy()
2086
2634
  ```
2087
2635
 
2636
+ A layer you mount yourself arms whatever you arm. Its `copies` option answers whether a new drawing
2637
+ may be made as a copy of a drawing of a type: return false for a tool you do not offer, and clone,
2638
+ paste and a Control- or Command-drag duplicate make nothing for its drawings, `canPaste` answers
2639
+ false, and the drag moves the drawing itself.
2640
+
2088
2641
  ### `@trdrs/quickcharts/drawings`
2089
2642
 
2090
2643
  Everything about drawings a host builds its own UI from is one subpath. A host that never draws