emberwick 0.1.1 → 0.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
@@ -10,6 +10,8 @@ point it at your own market data.
10
10
  to new bounds, zoom is cursor-anchored and eased, panning has inertia.
11
11
  - **Fast on big data.** One `requestAnimationFrame` loop, dirty-flag driven,
12
12
  with visible-range culling — 500k bars loaded costs only the ~200 on screen.
13
+ - **Annotated.** Nine marker shapes, price lines and shaded zones, with
14
+ collision-aware stacking and hit-testing for hover and click.
13
15
 
14
16
  ---
15
17
 
@@ -25,10 +27,6 @@ npm install emberwick
25
27
  import { createChart, RandomFeed } from 'emberwick'
26
28
  ```
27
29
 
28
- > **Not published yet.** The package builds and packs (`npm run release`), but
29
- > it has not been pushed to the registry, and the name has not been confirmed
30
- > available — run `npm view emberwick` before relying on it.
31
-
32
30
  ### From a CDN, no build step
33
31
 
34
32
  ```html
@@ -213,7 +211,15 @@ const chart = createChart(el, {
213
211
  | `append(bar)` | Open a new candle |
214
212
  | `setFeed(feed)` | `async` — loads history, then subscribes. Detaches any previous feed |
215
213
  | `detachFeed()` | Unsubscribe, keep the bars on screen |
216
- | `subscribe('crosshair', fn)` | Returns an unsubscribe fn. See events below |
214
+ | `subscribe(event, fn)` | Returns an unsubscribe fn. See events below |
215
+ | `setMarkers(markers)` | Replace every marker |
216
+ | `getMarkers()` | Current markers, normalised, each with its resolved bar index |
217
+ | `addMarker(marker)` | Append one marker |
218
+ | `removeMarker(id)` | Remove by id |
219
+ | `clearMarkers()` | Remove all markers |
220
+ | `setPriceLines(lines)` | Replace every horizontal price line |
221
+ | `setZones(zones)` | Replace every shaded region |
222
+ | `markerAt(x, y)` | Hit-test plot coordinates, returns a marker or `null` |
217
223
  | `setTheme(partial)` | Merge theme keys and repaint |
218
224
  | `setPriceMode(mode)` | `'linear'` or `'log'` |
219
225
  | `setAnimate(bool)` | Toggle live-candle easing |
@@ -234,7 +240,137 @@ const off = chart.subscribe('crosshair', (payload) => {
234
240
  off() // unsubscribe
235
241
  ```
236
242
 
237
- `'crosshair'` is the only event that currently fires. See *Known gaps* below.
243
+ | Event | Payload |
244
+ |---|---|
245
+ | Event | Payload |
246
+ |---|---|
247
+ | `'crosshair'` | `{ index, bar, price }`, or `null` when the pointer leaves the plot |
248
+ | `'markerHover'` | The marker under the pointer, or `null` when none is |
249
+ | `'markerClick'` | The clicked marker. Only fires on a hit, never with `null` |
250
+ | `'visibleRange'` | `{ from, to, fromTime, toTime, barCount, spacing, settled }` |
251
+
252
+ A drag that happens to end on top of a marker does not fire `'markerClick'` —
253
+ panning and clicking stay distinct.
254
+
255
+ ### Tracking the visible range
256
+
257
+ `'visibleRange'` is a **state** event rather than a notification, which makes it
258
+ usable without any debouncing of your own:
259
+
260
+ - A new subscriber is called **immediately** with the current window, so it
261
+ never has to wait for the user to pan before it knows what is on screen.
262
+ - It then fires **only when the window actually changes**. Indices are
263
+ integers, so a slow pan at 9px/bar produces roughly one event every nine
264
+ frames, not one per frame.
265
+ - `spacing` is reported but is deliberately *not* part of the change test — it
266
+ is a float that moves every frame of an eased zoom, and keying on it would
267
+ turn this into a 60/sec firehose. Use it for level-of-detail decisions.
268
+ - `settled` *is* part of the change test, so the final event of a gesture
269
+ always arrives with `settled: true`. That makes "wait until the view stops
270
+ moving, then do the expensive thing" a safe pattern.
271
+
272
+ ```js
273
+ const off = chart.subscribe('visibleRange', async (r) => {
274
+ // paginate backwards when the user approaches the left edge
275
+ if (r.from < 50 && r.settled && r.fromTime) {
276
+ const older = await myApi.bars({ to: r.fromTime, limit: 500 })
277
+ chart.setData(older.concat(chart.bars))
278
+ }
279
+ })
280
+ ```
281
+
282
+ Syncing a second chart is the other common use — feed `fromTime`/`toTime`
283
+ straight into the other instance. And `chart.visibleRange()` returns the same
284
+ payload on demand if you would rather poll than subscribe.
285
+
286
+ > The chart also paginates backwards **on its own** through
287
+ > `feed.getBars({ to })` whenever you have attached a feed. This event is for
288
+ > when you want to drive that yourself, or to drive something other than data.
289
+
290
+ ---
291
+
292
+ ## Annotations
293
+
294
+ Three independent collections, each replaced wholesale. Zones paint behind the
295
+ candles; price lines and markers paint in front.
296
+
297
+ ```js
298
+ chart.setMarkers([
299
+ { time: 1717070400000, shape: 'arrowUp', text: 'BUY 120',
300
+ data: { orderId: 'A-7741' } },
301
+ { time: 1717074000000, shape: 'flag', text: 'Earnings', color: '#c084fc' },
302
+ { time: 1717077600000, shape: 'arrowDown', text: 'SELL 160' },
303
+ ])
304
+
305
+ chart.setPriceLines([
306
+ { price: 148.20, title: 'target', color: '#26a69a' },
307
+ { price: 141.05, title: 'stop', lineStyle: 'dotted' },
308
+ ])
309
+
310
+ chart.setZones([
311
+ { from: 143.5, to: 145.9, label: 'value area' },
312
+ ])
313
+
314
+ chart.subscribe('markerClick', (m) => openTicket(m.data.orderId))
315
+ ```
316
+
317
+ ### Markers
318
+
319
+ A marker is pinned to a **timestamp**, not a bar index, and resolves to the
320
+ nearest bar. Load an older page of history and every marker re-resolves, so
321
+ nothing drifts off its candle.
322
+
323
+ | Field | Default | Notes |
324
+ |---|---|---|
325
+ | `time` | *required* | ms since epoch, snapped to the closest bar |
326
+ | `id` | generated | Needed for `removeMarker(id)` |
327
+ | `shape` | `'circle'` | See the list below |
328
+ | `position` | shape-dependent | `'aboveBar'`, `'belowBar'`, `'inBar'`, `'atPrice'` |
329
+ | `price` | — | Only used when `position` is `'atPrice'` |
330
+ | `color` | `theme.up` / `theme.down` | Down-pointing shapes default to the down colour |
331
+ | `text` | — | Short caption. For `'label'` it is drawn inside the pill |
332
+ | `textColor` | theme | Caption colour |
333
+ | `size` | `1` | Scale factor |
334
+ | `data` | — | Anything. Handed straight back on hover and click |
335
+
336
+ Shapes: `arrowUp`, `arrowDown`, `triangleUp`, `triangleDown`, `circle`,
337
+ `square`, `diamond`, `flag`, `label`.
338
+
339
+ Position defaults follow the trading convention: up-pointing shapes sit
340
+ *below* the bar, down-pointing shapes *above* it, everything else above.
341
+
342
+ **Overlap and density.** Markers sharing a bar are stacked rather than drawn on
343
+ top of each other. Below about 3px per bar, dense runs thin to roughly one
344
+ marker per 4px — a thousand trades stay legible and stay fast. A marker on the
345
+ forming candle anchors to the animated values, so it flows with the live bar.
346
+
347
+ ### Price lines
348
+
349
+ | Field | Default | Notes |
350
+ |---|---|---|
351
+ | `price` | *required* | |
352
+ | `color` | `theme.textStrong` | |
353
+ | `lineStyle` | `'dashed'` | `'solid'`, `'dashed'`, `'dotted'` |
354
+ | `lineWidth` | `1` | |
355
+ | `title` | — | Pill drawn at the left end |
356
+ | `axisLabel` | `true` | Price tag on the axis |
357
+
358
+ ### Zones
359
+
360
+ Supply `from`/`to` for a **price band** spanning the full width, or
361
+ `fromTime`/`toTime` for a **time band** spanning the full height.
362
+
363
+ ```js
364
+ chart.setZones([
365
+ { from: 143.5, to: 145.9, label: 'value area' },
366
+ { fromTime: 1717070400000, toTime: 1717074000000,
367
+ color: 'rgba(239,83,80,0.07)', border: 'rgba(239,83,80,0.3)' },
368
+ ])
369
+ ```
370
+
371
+ Autoscale fits the **bars**, not the annotations — a price line far outside the
372
+ data range is simply off-screen. Derive extreme values from the visible range
373
+ if you need them guaranteed visible.
238
374
 
239
375
  ---
240
376
 
@@ -475,9 +611,12 @@ npm run build:umd # minified UMD -> dist-lib/umd/emberwick.umd.js
475
611
  npm run pack:lib # manifest + README + .d.ts into dist-lib/
476
612
  npm run size # gzipped size budget check
477
613
  npm run release # all four, in order
478
- npm publish dist-lib # publish the assembled directory
614
+ npm publish ./dist-lib # publish the assembled directory — note the ./
479
615
  ```
480
616
 
617
+ > The leading `./` is required. `npm publish dist-lib` makes npm look for a
618
+ > *registry package* named `dist-lib` and fail with `E404`.
619
+
481
620
  Publishing from `dist-lib/` keeps the playground, the build configs and the
482
621
  app's private `package.json` out of the artifact. `package.lib.json` is the
483
622
  manifest that becomes the published `package.json`.
@@ -505,13 +644,10 @@ src/App.jsx, src/styles.css the playground (not published)
505
644
 
506
645
  Honest list of what isn't there yet:
507
646
 
508
- - **`subscribe('visibleRange', fn)` never fires.** The event is accepted and
509
- the handler is stored, but nothing emits it. Use `'crosshair'` for now.
510
- - **Not published to npm.** The package builds and packs, but it has not been
511
- pushed, and the name is unverified check `npm view emberwick` first.
512
- - **The library builds are unverified.** `npm run build:lib` / `build:umd`
513
- have not been executed here; this environment only runs the app build. Run
514
- `npm run release` locally before publishing.
647
+ - **No OHLCV legend in the package.** `subscribe('crosshair', fn)` gives you
648
+ the hovered bar; rendering the readout is still yours to do.
649
+ - **Markers are not draggable.** They are hit-tested for hover and click, but
650
+ there is no drag-to-move or editing interaction.
515
651
  - **No indicators or drawing tools.** SMA/EMA/RSI/MACD, multi-pane layout and
516
652
  trendlines are planned but not implemented.
517
653
  - **Candlesticks only.** No Heikin-Ashi, line, area or baseline series yet.
package/index.d.ts CHANGED
@@ -40,6 +40,97 @@ export interface Theme {
40
40
  timeAxisHeight: number
41
41
  }
42
42
 
43
+ /* --------------------------------------------------------------- markers -- */
44
+
45
+ export type MarkerShape =
46
+ | 'arrowUp'
47
+ | 'arrowDown'
48
+ | 'triangleUp'
49
+ | 'triangleDown'
50
+ | 'circle'
51
+ | 'square'
52
+ | 'diamond'
53
+ | 'flag'
54
+ | 'label'
55
+
56
+ export type MarkerPosition = 'aboveBar' | 'belowBar' | 'inBar' | 'atPrice'
57
+
58
+ /** A point annotation pinned to the bar nearest `time`. */
59
+ export interface Marker<T = unknown> {
60
+ /** Stable id. Generated when omitted; required for removeMarker(). */
61
+ id?: string
62
+ /** ms since epoch — snapped to the closest bar. */
63
+ time: number
64
+ /** Only used when position is 'atPrice'. */
65
+ price?: number
66
+ /** Default 'circle'. */
67
+ shape?: MarkerShape
68
+ /**
69
+ * Default depends on the shape: up shapes sit below the bar, down shapes
70
+ * above it, everything else above.
71
+ */
72
+ position?: MarkerPosition
73
+ /** Defaults to theme.up, or theme.down for the down-pointing shapes. */
74
+ color?: string
75
+ textColor?: string
76
+ /** Short caption. For shape 'label' it is drawn inside the pill. */
77
+ text?: string
78
+ /** Scale factor. Default 1. */
79
+ size?: number
80
+ /** Passed straight back to you on hover/click. */
81
+ data?: T
82
+ }
83
+
84
+ /** A marker after normalisation, as returned by getMarkers(). */
85
+ export interface ResolvedMarker<T = unknown> extends Marker<T> {
86
+ id: string
87
+ shape: MarkerShape
88
+ position: MarkerPosition
89
+ size: number
90
+ /** Bar index the marker resolved to, or -1 when there are no bars. */
91
+ index: number
92
+ }
93
+
94
+ export type LineStyle = 'solid' | 'dashed' | 'dotted'
95
+
96
+ /** A horizontal line at a fixed price. */
97
+ export interface PriceLine {
98
+ id?: string
99
+ price: number
100
+ /** Defaults to theme.textStrong. */
101
+ color?: string
102
+ /** Default 'dashed'. */
103
+ lineStyle?: LineStyle
104
+ /** Default 1. */
105
+ lineWidth?: number
106
+ /** Optional pill drawn at the left end. */
107
+ title?: string
108
+ titleColor?: string
109
+ /** Price tag on the axis. Default true. */
110
+ axisLabel?: boolean
111
+ tagTextColor?: string
112
+ }
113
+
114
+ /**
115
+ * A shaded region: supply `from`/`to` for a price band spanning the full
116
+ * width, or `fromTime`/`toTime` for a time band spanning the full height.
117
+ */
118
+ export interface Zone {
119
+ id?: string
120
+ from?: number
121
+ to?: number
122
+ fromTime?: number
123
+ toTime?: number
124
+ /** Fill. Use rgba(). Default a faint theme-green wash. */
125
+ color?: string
126
+ /** Optional 1px outline. */
127
+ border?: string
128
+ label?: string
129
+ labelColor?: string
130
+ }
131
+
132
+ /* --------------------------------------------------------------- options -- */
133
+
43
134
  export interface TimeScaleOptions {
44
135
  /** Pixels per bar. Default 9. */
45
136
  spacing?: number
@@ -73,6 +164,10 @@ export interface ChartOptions {
73
164
  animate?: boolean
74
165
  /** Bars requested from the feed on setFeed(). Default 1500. */
75
166
  initialBars?: number
167
+ /** Initial markers; equivalent to calling setMarkers() after construction. */
168
+ markers?: Marker[]
169
+ priceLines?: PriceLine[]
170
+ zones?: Zone[]
76
171
  timeScale?: TimeScaleOptions
77
172
  priceScale?: PriceScaleOptions
78
173
  }
@@ -84,6 +179,30 @@ export interface CrosshairPayload {
84
179
  price: number
85
180
  }
86
181
 
182
+ /** Payload of the 'visibleRange' event, and the return of chart.visibleRange(). */
183
+ export interface VisibleRangePayload {
184
+ /** First visible bar index, clamped to the loaded data. */
185
+ from: number
186
+ /** Last visible bar index, clamped to the loaded data. */
187
+ to: number
188
+ /** `bars[from].time`, or null when no bars are loaded. */
189
+ fromTime: number | null
190
+ /** `bars[to].time`, or null when no bars are loaded. */
191
+ toTime: number | null
192
+ /** Total bars currently loaded. */
193
+ barCount: number
194
+ /** Current pixels per bar — useful for level-of-detail decisions. */
195
+ spacing: number
196
+ /**
197
+ * False while the view is still easing. The last event of a gesture always
198
+ * arrives with `settled: true`, so it is safe to defer expensive work
199
+ * (a fetch, a re-aggregation) until you see it.
200
+ */
201
+ settled: boolean
202
+ }
203
+
204
+ /* ------------------------------------------------------------------ feed -- */
205
+
87
206
  export interface GetBarsRequest {
88
207
  symbol: string
89
208
  timeframe: number
@@ -147,6 +266,8 @@ export declare class RandomFeed extends DataFeed {
147
266
  stop(): void
148
267
  }
149
268
 
269
+ /* ---------------------------------------------------------------- scales -- */
270
+
150
271
  export declare class TimeScale {
151
272
  constructor(opts?: TimeScaleOptions)
152
273
  visibleRange(): { from: number; to: number }
@@ -168,6 +289,8 @@ export declare class PriceScale {
168
289
  readonly hi: number
169
290
  }
170
291
 
292
+ /* ----------------------------------------------------------------- chart -- */
293
+
171
294
  export declare class Chart {
172
295
  constructor(container: HTMLElement, options?: ChartOptions)
173
296
 
@@ -181,6 +304,19 @@ export declare class Chart {
181
304
  setFeed(feed: Feed): Promise<void>
182
305
  detachFeed(): void
183
306
 
307
+ /** Replace every marker. */
308
+ setMarkers(markers: Marker[]): void
309
+ /** Current markers, normalised, each with its resolved bar index. */
310
+ getMarkers(): ResolvedMarker[]
311
+ addMarker(marker: Marker): void
312
+ /** Removes by id — including ids generated for you. */
313
+ removeMarker(id: string): void
314
+ clearMarkers(): void
315
+ setPriceLines(lines: PriceLine[]): void
316
+ setZones(zones: Zone[]): void
317
+ /** Topmost marker under a plot-relative point, else null. */
318
+ markerAt(x: number, y: number): ResolvedMarker | null
319
+
184
320
  setTheme(theme: Partial<Theme>): void
185
321
  setPriceMode(mode: PriceMode): void
186
322
  setAnimate(on: boolean): void
@@ -192,17 +328,27 @@ export declare class Chart {
192
328
  /** Rolling frames-per-second of the render loop. */
193
329
  readonly fps: number
194
330
 
331
+ /** The window currently on screen. Cheap enough to poll. */
332
+ visibleRange(): VisibleRangePayload
333
+
195
334
  /**
196
335
  * Subscribe to a chart event. Returns an unsubscribe function.
197
- * NOTE: 'visibleRange' is accepted but is not currently emitted.
336
+ *
337
+ * 'visibleRange' is a state event rather than a notification: a new
338
+ * subscriber is called immediately with the current window, and then only
339
+ * when that window actually changes — so no debouncing is required.
198
340
  */
199
341
  subscribe(event: 'crosshair', fn: (payload: CrosshairPayload | null) => void): () => void
200
- subscribe(event: 'visibleRange', fn: (range: { from: number; to: number }) => void): () => void
342
+ subscribe(event: 'markerClick', fn: (marker: ResolvedMarker) => void): () => void
343
+ subscribe(event: 'markerHover', fn: (marker: ResolvedMarker | null) => void): () => void
344
+ subscribe(event: 'visibleRange', fn: (range: VisibleRangePayload) => void): () => void
201
345
 
202
346
  /** Removes listeners, canvases and the render loop. */
203
347
  destroy(): void
204
348
 
205
349
  readonly bars: Bar[]
350
+ readonly priceLines: PriceLine[]
351
+ readonly zones: Zone[]
206
352
  readonly ts: TimeScale
207
353
  readonly ps: PriceScale
208
354
  }
@@ -214,6 +360,8 @@ export declare const defaultTheme: Theme
214
360
  export declare const lightTheme: Theme
215
361
  export declare const version: string
216
362
 
363
+ /* ------------------------------------------------------------- utilities -- */
364
+
217
365
  /** Seeded PRNG used by RandomFeed. */
218
366
  export declare function mulberry32(seed: number): () => number
219
367