wick-charts 0.9.0 → 0.10.1

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
@@ -208,6 +208,8 @@ const chart = createCandlestickChart(canvas, {
208
208
  },
209
209
  crosshair: {
210
210
  lineColor: '#9090904d',
211
+ lineWidth: 1,
212
+ lineDash: [4, 4], // [] for a solid line — same convention as ctx.setLineDash
211
213
  labelBackground: '#3a3a3a',
212
214
  labelTextColor: '#f0f0f0',
213
215
  labelPaddingX: 4,
@@ -249,12 +251,18 @@ it per chart instance via `formatLegend`:
249
251
  const chart = createCandlestickChart(canvas, {
250
252
  formatLegend(candle, style, { index, allPoints }) {
251
253
  const prev = allPoints[index - 1];
252
- const change = prev ? (((candle.close - prev.close) / prev.close) * 100).toFixed(2) : null;
254
+ const change = prev ? ((candle.close - prev.close) / prev.close) * 100 : null;
253
255
  return [
254
256
  `시가 ${candle.open.toLocaleString()}`,
255
257
  `고가 ${candle.high.toLocaleString()}`,
256
258
  `저가 ${candle.low.toLocaleString()}`,
257
- `종가 ${candle.close.toLocaleString()}${change === null ? '' : ` (${change}%)`}`,
259
+ `종가 ${candle.close.toLocaleString()}`,
260
+ // A line can be a plain string (drawn in legend.textColor, like the
261
+ // four above) or { text, color } for one that needs its own color —
262
+ // see "Coloring individual legend lines" just below.
263
+ change === null
264
+ ? '등락 —'
265
+ : { text: `등락 ${change >= 0 ? '+' : ''}${change.toFixed(2)}%`, color: change >= 0 ? '#22c55e' : '#ef4444' },
258
266
  ];
259
267
  },
260
268
  });
@@ -266,6 +274,20 @@ point currently loaded (not just visible), the same array a `ChartPlugin` reads
266
274
  `PluginRenderApi.allPoints`; `index` is `candle`'s position in it, so `allPoints[index - 1]` is
267
275
  the previous point regardless of where the user has panned/zoomed to.
268
276
 
277
+ #### Coloring individual legend lines
278
+
279
+ Every line returned by `formatLegend` is a `LegendLine` — either a plain `string`, drawn in
280
+ `legend.textColor` like every line before this existed, or `{ text, color }` for one line that
281
+ needs its own color independent of the rest (the `change` line above: green when positive, red
282
+ when negative, regardless of `legend.textColor`). Mixing both shapes in one returned array, as
283
+ above, is the normal case — only the lines that actually need a distinct color use the object
284
+ form. There's no chart-wide "green means up" setting for this: which color means "up" is a
285
+ convention that varies by market (green-up in most markets, red-up in South Korea and a few
286
+ others) and isn't something the library should hardcode, so `formatLegend` decides it per line
287
+ the same way it decides everything else about the legend's text. This is separate from
288
+ `CandlestickStyle.upColor`/`downColor`, which is the *candle's own* body/wick color, a
289
+ chart-wide setting rather than something recomputed per hovered point.
290
+
269
291
  `SeriesDefinition.formatLegend` (what candlestick/line ship with) is a shared default for every
270
292
  chart of that *type*; `WickChartOptions.formatLegend` is the per-*instance* override above it —
271
293
  reach for the latter for anything that varies by app, session, or locale rather than by chart
@@ -592,6 +614,62 @@ the price pane — an indicator pane's own hover readout, if you want one, is so
592
614
  plugin draws (it has the same `xForIndex`/`yForValue` a price-pane plugin does, just mapped
593
615
  against that pane's own value domain and pixel rect).
594
616
 
617
+ ### Splitting volume into its own pane
618
+
619
+ The candlestick series' default volume bars are a translucent backdrop *inside* the price
620
+ pane (`CandlestickStyle.volumeAreaHeightRatio`, `0.2` of the chart height by default) — fine
621
+ for a compact chart, but on a tall one, or whenever a candle's own range dips into that bottom
622
+ margin, the bars visually collide with the candles drawn over them. The same `addPane` +
623
+ `ChartPlugin.paneId` mechanism the previous section uses for RSI/MACD works just as well for
624
+ volume — it just reads `Candle.volume` instead of computing an indicator:
625
+
626
+ ```ts
627
+ // PaneOptions.getValueRange takes no arguments, so — same as the MACD pane above needing its
628
+ // own auto-fit range — it closes over the chart's current data itself rather than reading it
629
+ // from an argument. `allData` is whatever you last passed to `chart.setData()`.
630
+ let allData: Candle[] = [];
631
+
632
+ const chart = createCandlestickChart(canvas, {
633
+ style: { volumeAreaHeightRatio: 0 }, // suppress the in-pane backdrop — see below for why
634
+ });
635
+
636
+ chart.addPane({
637
+ id: 'volume',
638
+ heightRatio: 0.15,
639
+ getValueRange: () => {
640
+ const { startIndex, endIndex } = chart.getVisibleRange();
641
+ let max = 0;
642
+ for (let i = startIndex; i < endIndex; i++) max = Math.max(max, allData[i]?.volume ?? 0);
643
+ return { min: 0, max: max || 1 };
644
+ },
645
+ });
646
+
647
+ chart.addPlugin({
648
+ paneId: 'volume',
649
+ draw({ ctx, allPoints, visibleStartIndex, visibleEndIndex, xForIndex, yForValue }) {
650
+ const zeroY = yForValue(0);
651
+ const barWidth = (xForIndex(visibleStartIndex + 1) - xForIndex(visibleStartIndex)) * 0.6;
652
+ for (let i = visibleStartIndex; i < visibleEndIndex; i++) {
653
+ const c = allPoints[i];
654
+ if (c.volume === undefined) continue;
655
+ ctx.fillStyle = c.close >= c.open ? '#26a69a' : '#ef5350';
656
+ const y = yForValue(c.volume);
657
+ ctx.fillRect(xForIndex(i) - barWidth / 2, y, barWidth, zeroY - y);
658
+ }
659
+ },
660
+ });
661
+
662
+ chart.setData(candles);
663
+ allData = candles; // keep in sync on every later setData() call too
664
+ ```
665
+
666
+ Setting `volumeAreaHeightRatio: 0` on the style matters — leaving the default `0.2` active
667
+ alongside a dedicated volume pane double-draws the bars (once as the in-pane backdrop, once in
668
+ the new pane). Unlike an indicator pane's `getValueRange`, volume's domain is naturally
669
+ `[0, max]` rather than something auto-fit around a signed value the way MACD's is — the
670
+ example above recomputes `max` from the currently visible candles each frame, the same way
671
+ `computeMacdValueRange` does in the "Multi-pane indicators" example above.
672
+
595
673
  ### Cleanup
596
674
 
597
675
  Call `chart.destroy()` when you're done with a chart (component unmount, etc.) — it removes a
@@ -1,4 +1,4 @@
1
- import type { ChartCrosshairOptions, ChartFontOptions, ChartLegendOptions } from './types.js';
1
+ import type { ChartCrosshairOptions, ChartFontOptions, ChartLegendOptions, LegendLine } from './types.js';
2
2
  /** Everything one frame's hover crosshair/legend needs — computed by
3
3
  * `ChartRenderer.render()` (which owns the hovered point, the active
4
4
  * series, and its style) and handed in as plain data so this class stays
@@ -19,7 +19,7 @@ export interface CrosshairRenderInput {
19
19
  * across every indicator pane below it. */
20
20
  stackHeight: number;
21
21
  invertValueAxis: boolean;
22
- legendParts: string[];
22
+ legendParts: LegendLine[];
23
23
  /** The canvas's own backing-store width — needed only to clamp the
24
24
  * time-axis label chip so it never runs off the right edge. */
25
25
  canvasWidth: number;
@@ -27,7 +27,8 @@ export class CrosshairRenderer {
27
27
  const { ctx, crosshair } = this;
28
28
  ctx.save();
29
29
  ctx.strokeStyle = crosshair.lineColor;
30
- ctx.setLineDash([4, 4]);
30
+ ctx.lineWidth = crosshair.lineWidth;
31
+ ctx.setLineDash(crosshair.lineDash);
31
32
  // Spans the whole pane stack (not just the main pane's own
32
33
  // chartHeight) so hovering a candle lines up with the same column
33
34
  // across every indicator pane below it — see `stackHeight`'s own doc
@@ -74,8 +75,12 @@ export class CrosshairRenderer {
74
75
  ctx.font = this.legendFont();
75
76
  ctx.textAlign = 'left';
76
77
  ctx.textBaseline = 'top';
78
+ // Each line is either a plain string or { text, color } — normalize to
79
+ // its text once up front for sizing, and read its own color (falling
80
+ // back to legend.textColor) only when actually drawing it below.
81
+ const texts = lines.map((line) => (typeof line === 'string' ? line : line.text));
77
82
  const lineHeight = font.legendSize + 4;
78
- const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
83
+ const textWidth = Math.max(...texts.map((text) => ctx.measureText(text).width));
79
84
  const boxWidth = textWidth + legend.paddingX * 2;
80
85
  const boxHeight = lines.length * lineHeight + legend.paddingY * 2;
81
86
  const anchorY = hoverY ?? 0;
@@ -83,9 +88,9 @@ export class CrosshairRenderer {
83
88
  const top = Math.min(Math.max(anchorY - boxHeight - legend.cursorGap, 0), Math.max(0, chartHeight - boxHeight));
84
89
  ctx.fillStyle = legend.background;
85
90
  ctx.fillRect(left, top, boxWidth, boxHeight);
86
- ctx.fillStyle = legend.textColor;
87
91
  lines.forEach((line, i) => {
88
- ctx.fillText(line, left + legend.paddingX, top + legend.paddingY + i * lineHeight);
92
+ ctx.fillStyle = typeof line === 'string' ? legend.textColor : (line.color ?? legend.textColor);
93
+ ctx.fillText(texts[i], left + legend.paddingX, top + legend.paddingY + i * lineHeight);
89
94
  });
90
95
  }
91
96
  /** The highlighted price-axis label that follows the crosshair's
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ import type { DataLoader } from './dataSource.js';
2
2
  import type { ChartPlugin } from './plugins/types.js';
3
3
  import type { CandlestickStyle } from './series/candlestick.js';
4
4
  import type { LineStyle } from './series/line.js';
5
- import type { Candle, LegendFormatContext, LinePoint, PaneOptions, WickChartOptions, SeriesPoint, ValueRange, WickTime } from './types.js';
6
- export type { BusinessDay, Candle, LegendFormatContext, LinePoint, PaneOptions, WickChartOptions, WickTime, SeriesPoint, UnixMillis, ValueRange, } from './types.js';
5
+ import type { Candle, LegendFormatContext, LegendLine, LinePoint, PaneOptions, WickChartOptions, SeriesPoint, ValueRange, WickTime } from './types.js';
6
+ export type { BusinessDay, Candle, LegendFormatContext, LegendLine, LinePoint, PaneOptions, WickChartOptions, WickTime, SeriesPoint, UnixMillis, ValueRange, } from './types.js';
7
7
  export type { DataLoader, DataRequest } from './dataSource.js';
8
8
  export type { ChartPlugin, ChartPointerEvent, PluginRenderApi } from './plugins/types.js';
9
9
  export { distanceToSegment, hitTestPoint, hitTestSegment } from './hitTest.js';
@@ -332,7 +332,7 @@ export declare function createCandlestickChart(canvas: HTMLCanvasElement, option
332
332
  * `context` narrowed to this series's own types instead of the general
333
333
  * (unchecked) `SeriesPoint`/`unknown` shape `WickChartOptions` itself
334
334
  * allows — see its doc comment for what this is for. */
335
- formatLegend?(point: Candle, style: CandlestickStyle, context: LegendFormatContext<Candle>): string[];
335
+ formatLegend?(point: Candle, style: CandlestickStyle, context: LegendFormatContext<Candle>): LegendLine[];
336
336
  }): WickChart<Candle>;
337
337
  /**
338
338
  * The second series type's equivalent of `createCandlestickChart` above —
@@ -345,5 +345,5 @@ export declare function createLineChart(canvas: HTMLCanvasElement, options?: Omi
345
345
  style?: Partial<LineStyle>;
346
346
  /** Same override as `WickChartOptions.formatLegend`, narrowed to this
347
347
  * series's own types — see `createCandlestickChart`'s equivalent. */
348
- formatLegend?(point: LinePoint, style: LineStyle, context: LegendFormatContext<LinePoint>): string[];
348
+ formatLegend?(point: LinePoint, style: LineStyle, context: LegendFormatContext<LinePoint>): LegendLine[];
349
349
  }): WickChart<LinePoint>;
package/dist/renderer.js CHANGED
@@ -21,6 +21,8 @@ const DEFAULT_AXIS = {
21
21
  };
22
22
  const DEFAULT_CROSSHAIR = {
23
23
  lineColor: '#9090904d',
24
+ lineWidth: 1,
25
+ lineDash: [4, 4],
24
26
  labelBackground: '#3a3a3a',
25
27
  labelTextColor: '#f0f0f0',
26
28
  labelPaddingX: 4,
@@ -128,6 +130,8 @@ export class ChartRenderer {
128
130
  };
129
131
  const scaledCrosshair = {
130
132
  ...this.crosshair,
133
+ lineWidth: this.crosshair.lineWidth * ratio,
134
+ lineDash: this.crosshair.lineDash.map((segment) => segment * ratio),
131
135
  labelPaddingX: this.crosshair.labelPaddingX * ratio,
132
136
  labelPaddingY: this.crosshair.labelPaddingY * ratio,
133
137
  };
@@ -230,32 +234,6 @@ export class ChartRenderer {
230
234
  axisRenderer.renderPriceAxis(min, max, step, scale, chartWidth, rect.height, rect.top);
231
235
  }
232
236
  axisRenderer.renderTimeAxis(times, startIdx, visible.length, stackHeight, chartWidth, xForIndex);
233
- if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
234
- // The dashed vertical line spans the whole stack (every pane); the
235
- // horizontal line, price-label chip, and OHLC legend stay scoped
236
- // to the main pane only — an indicator pane's own hover readout,
237
- // if it wants one, is the job of whatever plugin draws into it.
238
- // `formatLegendOverride`, when set, replaces the series's own
239
- // formatLegend entirely for this chart instance rather than
240
- // merging with it — see `WickChartOptions.formatLegend`.
241
- const formatLegend = this.formatLegendOverride ?? seriesDefinition.formatLegend;
242
- const legendContext = { index: hoverIndex, allPoints: sorted };
243
- const legendParts = formatLegend?.(sorted[hoverIndex], style, legendContext) ?? [];
244
- crosshairRenderer.render({
245
- x: xForIndex(hoverIndex),
246
- timeSeconds: times[hoverIndex],
247
- hoverY,
248
- valueMin,
249
- valueMax,
250
- priceStep,
251
- chartWidth,
252
- chartHeight,
253
- stackHeight,
254
- invertValueAxis: this.invertValueAxis,
255
- legendParts,
256
- canvasWidth: canvas.width,
257
- });
258
- }
259
237
  if (plugins.length > 0) {
260
238
  // Everything every pane's PluginRenderApi shares — only the pane's
261
239
  // own rect/value-domain/scale differ between `buildPluginApi`
@@ -300,6 +278,37 @@ export class ChartRenderer {
300
278
  }
301
279
  }
302
280
  }
281
+ // Drawn last, after every plugin (including paneId'd ones and main-pane
282
+ // overlays like trade markers) rather than right after the axes, so the
283
+ // hover legend/crosshair always sits on top of everything else instead
284
+ // of being covered by a plugin's own output — the crosshair is the most
285
+ // "active"/topmost thing on the chart at the moment it's shown.
286
+ if (hoverIndex !== null && hoverIndex >= startIdx && hoverIndex < endIdx) {
287
+ // The dashed vertical line spans the whole stack (every pane); the
288
+ // horizontal line, price-label chip, and OHLC legend stay scoped
289
+ // to the main pane only — an indicator pane's own hover readout,
290
+ // if it wants one, is the job of whatever plugin draws into it.
291
+ // `formatLegendOverride`, when set, replaces the series's own
292
+ // formatLegend entirely for this chart instance rather than
293
+ // merging with it — see `WickChartOptions.formatLegend`.
294
+ const formatLegend = this.formatLegendOverride ?? seriesDefinition.formatLegend;
295
+ const legendContext = { index: hoverIndex, allPoints: sorted };
296
+ const legendParts = formatLegend?.(sorted[hoverIndex], style, legendContext) ?? [];
297
+ crosshairRenderer.render({
298
+ x: xForIndex(hoverIndex),
299
+ timeSeconds: times[hoverIndex],
300
+ hoverY,
301
+ valueMin,
302
+ valueMax,
303
+ priceStep,
304
+ chartWidth,
305
+ chartHeight,
306
+ stackHeight,
307
+ invertValueAxis: this.invertValueAxis,
308
+ legendParts,
309
+ canvasWidth: canvas.width,
310
+ });
311
+ }
303
312
  }
304
313
  finally {
305
314
  frameState.ended = true;
@@ -11,7 +11,12 @@ export interface CandlestickStyle {
11
11
  /** Fraction of the chart's full height that volume bars occupy, measured
12
12
  * up from the bottom. Candles still use the full height for their own
13
13
  * price scale regardless of this value — the bars sit in this bottom
14
- * margin, layered underneath. Defaults to 0.2. */
14
+ * margin, layered underneath (and can visually collide with a candle
15
+ * whose range dips into that margin). Defaults to 0.2. Set to 0 to
16
+ * suppress this in-pane backdrop entirely — do this when drawing volume
17
+ * in its own pane instead via `WickChart.addPane` + a `ChartPlugin`
18
+ * reading `Candle.volume` off `allPoints` (see "Splitting volume into
19
+ * its own pane" in the README), so the two don't double-draw. */
15
20
  volumeAreaHeightRatio: number;
16
21
  /** Opacity (0-1) of the volume bars, so they read as a backdrop rather
17
22
  * than competing with the candles drawn over them. Defaults to 0.5. */
@@ -18,6 +18,13 @@ function getValueRange(visible, scaleFactor) {
18
18
  * OHLC-only data look exactly as they did before this existed. */
19
19
  function drawVolumeBars(context, style) {
20
20
  const { ctx, visible, startIndex, xForIndex, slotWidth, chartHeight } = context;
21
+ // A caller drawing volume in its own pane via WickChart.addPane (see
22
+ // "Splitting volume into its own pane" in the README) sets this to 0 to
23
+ // fully suppress this in-pane backdrop — without this guard, the
24
+ // Math.max(1, ...) floor below still drew a 1px sliver per candle even
25
+ // at ratio 0.
26
+ if (style.volumeAreaHeightRatio <= 0)
27
+ return;
21
28
  const maxVolume = visible.reduce((max, c) => (c.volume !== undefined ? Math.max(max, c.volume) : max), 0);
22
29
  if (maxVolume <= 0)
23
30
  return;
@@ -1,5 +1,5 @@
1
1
  import type { Scale } from '../hybridScale.js';
2
- import type { LegendFormatContext, SeriesPoint, ValueRange } from '../types.js';
2
+ import type { LegendFormatContext, LegendLine, SeriesPoint, ValueRange } from '../types.js';
3
3
  export type { ValueRange } from '../types.js';
4
4
  /**
5
5
  * Everything a series's `draw` needs to turn its visible points into
@@ -68,5 +68,5 @@ export interface SeriesDefinition<TPoint extends SeriesPoint, TStyle> {
68
68
  * previous point — this built-in series doesn't need it, but a custom
69
69
  * one can. `WickChartOptions.formatLegend`, when set, overrides this per
70
70
  * chart instance rather than per series type — see its own doc comment. */
71
- formatLegend?(point: TPoint, style: TStyle, context: LegendFormatContext<TPoint>): string[];
71
+ formatLegend?(point: TPoint, style: TStyle, context: LegendFormatContext<TPoint>): LegendLine[];
72
72
  }
package/dist/types.d.ts CHANGED
@@ -103,8 +103,16 @@ export interface ChartAxisOptions {
103
103
  /** Coloring and padding for the hover crosshair's lines and its two
104
104
  * highlighted axis-label chips. */
105
105
  export interface ChartCrosshairOptions {
106
- /** Dashed crosshair line color. Defaults to `'#9090904d'`. */
106
+ /** Crosshair line color. Defaults to `'#9090904d'`. */
107
107
  lineColor?: string;
108
+ /** Crosshair line thickness, in CSS px (scaled by `devicePixelRatio`
109
+ * like every other size field here). Defaults to `1`. */
110
+ lineWidth?: number;
111
+ /** `CanvasRenderingContext2D.setLineDash` pattern, in CSS px (scaled by
112
+ * `devicePixelRatio` like every other size field here). Defaults to
113
+ * `[4, 4]`. Pass `[]` for a solid line — the same canvas convention
114
+ * `setLineDash` itself uses, so this needs no separate on/off flag. */
115
+ lineDash?: number[];
108
116
  /** Background fill of the price/time label chips. Defaults to `'#3a3a3a'`. */
109
117
  labelBackground?: string;
110
118
  /** Text color inside the label chips. Defaults to `'#f0f0f0'`. */
@@ -185,6 +193,18 @@ export interface LegendFormatContext<TPoint extends SeriesPoint = SeriesPoint> {
185
193
  * time — the same array `SeriesDrawContext.allPoints` is. */
186
194
  allPoints: readonly TPoint[];
187
195
  }
196
+ /**
197
+ * One line of the hover legend — a plain `string` (drawn in the legend's
198
+ * configured `textColor`, same as every version of this library before
199
+ * per-line color existed) or `{ text, color }` for a line that needs its
200
+ * own color independent of the rest — a percent-change line that should
201
+ * read green/red by sign, say. Mixing both in the same `formatLegend`
202
+ * return array is fine: each line is colored independently.
203
+ */
204
+ export type LegendLine = string | {
205
+ text: string;
206
+ color?: string;
207
+ };
188
208
  export interface WickChartOptions {
189
209
  /**
190
210
  * Which registered series type to render this chart as (see
@@ -218,7 +238,9 @@ export interface WickChartOptions {
218
238
  /**
219
239
  * Overrides the active series's own `formatLegend` (see
220
240
  * `SeriesDefinition.formatLegend`) for this chart instance specifically —
221
- * the hover legend's text, one string per line. `SeriesDefinition.formatLegend`
241
+ * the hover legend's text, one `LegendLine` per line (a plain string, or
242
+ * `{ text, color }` for a line that needs its own color — see
243
+ * `LegendLine`). `SeriesDefinition.formatLegend`
222
244
  * is a shared default for every chart of that series *type* (registered
223
245
  * once via `registerSeries`); this is a per-*instance* override for
224
246
  * whatever varies by app/session instead of by chart type — localized
@@ -238,7 +260,7 @@ export interface WickChartOptions {
238
260
  * both to the concrete series's own types, the same way they narrow
239
261
  * `style` — see their own doc comments.
240
262
  */
241
- formatLegend?(point: SeriesPoint, style: unknown, context: LegendFormatContext<SeriesPoint>): string[];
263
+ formatLegend?(point: SeriesPoint, style: unknown, context: LegendFormatContext<SeriesPoint>): LegendLine[];
242
264
  /**
243
265
  * Mirrors the value axis top-to-bottom — every pane's higher values
244
266
  * render lower on screen instead of higher, with no change to the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wick-charts",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "An open-source financial charting library — WASM (Rust) for compute, Canvas2D for rendering.",
5
5
  "license": "MIT",
6
6
  "author": "eatnows",