@funnest/charts 0.6.23 → 0.6.25

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/dist/chart.d.ts CHANGED
@@ -145,6 +145,15 @@ export interface LegendItem extends LegendItemOptions {
145
145
  id: string;
146
146
  visible: boolean;
147
147
  }
148
+ export interface ScreenshotOptions {
149
+ /**
150
+ * Paints the legend into the image, where @funnest/charts-ui shows it: symbol · interval ·
151
+ * exchange and the capture time, the last bar's OHLC and change, compares, and the values of
152
+ * visible indicators (overlays at the top, pane indicators and volume on their panes).
153
+ * Default false: a bare chart has no legend on screen either.
154
+ */
155
+ legend?: boolean;
156
+ }
148
157
  export interface IndicatorLegendItem {
149
158
  id: string;
150
159
  type: IndicatorType;
@@ -321,7 +330,9 @@ export declare class Chart {
321
330
  /** Inverse of indexToTime(). */
322
331
  private timeToIndex;
323
332
  /** Snapshot of the chart (without the crosshair) at device resolution. */
324
- takeScreenshot(): HTMLCanvasElement;
333
+ takeScreenshot(options?: ScreenshotOptions): HTMLCanvasElement;
334
+ /** The legend rows, painted where the DOM legend sits (see ScreenshotOptions.legend). */
335
+ private paintScreenshotLegend;
325
336
  /**
326
337
  * Re-fetches the latest window and merges it in, keeping the view. Datafeeds trigger this
327
338
  * via the `onResetCache` callback of subscribeBars after a WebSocket reconnect, so bars
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { Chart, createChart, fallbackResolution } from './chart';
2
2
  export { CHART_THEMES, DEFAULT_CHART_SETTINGS, matchChartTheme, parseChartState } from './settings';
3
3
  export type { ChartSettings, ChartState, ChartThemeColors, ChartThemePreset } from './settings';
4
- export type { ChartContextMenu, ChartEvents, ChartLayout, ChartOptions, ChartPaneLayout, CompareInfo, IndicatorLegendItem, LegendItem, LegendItemOptions, ReplayStatus } from './chart';
4
+ export type { ChartContextMenu, ChartEvents, ChartLayout, ChartOptions, ChartPaneLayout, CompareInfo, IndicatorLegendItem, LegendItem, LegendItemOptions, ReplayStatus, ScreenshotOptions } from './chart';
5
5
  export type { PriceScaleMode } from './model/price-scale';
6
6
  export type { CrosshairInfo, PaneId } from './render/renderer';
7
7
  export type { Locale } from './model/time-format';
@@ -12,7 +12,7 @@ export type { DirectionPreset, ThemeTokens } from './theme';
12
12
  export { createTheme, darkTheme, directionColors, lightTheme } from './theme';
13
13
  export type { FormatPriceOptions } from './price';
14
14
  export { decimalsOf, formatCompact, formatPrice, krxTickSize, resolveTickSize, roundToTick } from './price';
15
- export { isIntradayResolution, resolutionToMs } from './resolution';
15
+ export { isIntradayResolution, resolutionLabel, resolutionToMs } from './resolution';
16
16
  export type { ChartType } from './render/renderer';
17
17
  export type { BuiltinDrawingType, Drawing, DrawingDefinition, DrawingGeometry, DrawingPoint, DrawingRenderContext, DrawingStyle, DrawingColorSlot, DrawingType, } from './objects/drawings';
18
18
  export { BUILTIN_DRAWINGS, distToSegment, FIB_LEVELS } from './objects/drawings';
package/dist/index.js CHANGED
@@ -2587,6 +2587,85 @@ function drawCrosshairValue(p, m, ch) {
2587
2587
  p.text(valueLabel, f.plotWidth + 8, by, pal.background);
2588
2588
  }
2589
2589
 
2590
+ // src/render/screenshot-legend.ts
2591
+ var ROW_GAP = 2;
2592
+ var DOT2 = 8;
2593
+ var DOT_GAP = 6;
2594
+ var LEGEND_LEFT = 12;
2595
+ var LEGEND_TOP = 8;
2596
+ var LEGEND_RIGHT_CLEARANCE = 80;
2597
+ function layoutLegend(rows, left, top, maxWidth, measure) {
2598
+ const runs = [];
2599
+ let y = top;
2600
+ let right = left;
2601
+ rows.forEach((row, i) => {
2602
+ if (i > 0) y += ROW_GAP;
2603
+ let x = left;
2604
+ let lineStart = true;
2605
+ const widths = row.groups.map((g) => g.reduce((w, r) => w + runWidth(r, row.size, measure), 0));
2606
+ row.groups.forEach((group, gi) => {
2607
+ const w = widths[gi];
2608
+ if (!lineStart && x + row.gap + w > left + maxWidth) {
2609
+ y += row.lineHeight;
2610
+ x = left;
2611
+ lineStart = true;
2612
+ }
2613
+ if (!lineStart) x += row.gap;
2614
+ for (const run of group) {
2615
+ runs.push({ run, x, y: y + row.lineHeight / 2, size: row.size });
2616
+ x += runWidth(run, row.size, measure);
2617
+ }
2618
+ right = Math.max(right, x);
2619
+ lineStart = false;
2620
+ });
2621
+ y += row.lineHeight;
2622
+ });
2623
+ return { runs, bottom: y, right };
2624
+ }
2625
+ function runWidth(run, size, measure) {
2626
+ return (run.dot ? DOT2 + DOT_GAP : 0) + (run.text ? measure(run.text, size, run.weight ?? 400) : 0);
2627
+ }
2628
+ var font = (size, weight, family) => `${weight} ${size}px ${family}`;
2629
+ function canvasMeasure(ctx, family) {
2630
+ return (text, size, weight) => {
2631
+ ctx.font = font(size, weight, family);
2632
+ return ctx.measureText(text).width;
2633
+ };
2634
+ }
2635
+ function paintLegend(ctx, layout, family, backdrop) {
2636
+ ctx.save();
2637
+ if (backdrop && layout.runs.length) {
2638
+ const pad = 4;
2639
+ ctx.globalAlpha = 0.85;
2640
+ ctx.fillStyle = backdrop.color;
2641
+ ctx.beginPath();
2642
+ ctx.roundRect(backdrop.left - pad, backdrop.top - pad, layout.right - backdrop.left + pad * 2, layout.bottom - backdrop.top + pad * 2, 6);
2643
+ ctx.fill();
2644
+ ctx.globalAlpha = 1;
2645
+ }
2646
+ ctx.textBaseline = "middle";
2647
+ ctx.textAlign = "left";
2648
+ for (const { run, x, y, size } of layout.runs) {
2649
+ let tx = x;
2650
+ if (run.dot) {
2651
+ ctx.fillStyle = run.color;
2652
+ ctx.beginPath();
2653
+ ctx.arc(x + DOT2 / 2, y, DOT2 / 2, 0, Math.PI * 2);
2654
+ ctx.fill();
2655
+ tx += DOT2 + DOT_GAP;
2656
+ if (!run.text) continue;
2657
+ }
2658
+ ctx.font = font(size, run.weight ?? 400, family);
2659
+ ctx.fillStyle = run.color;
2660
+ ctx.fillText(run.text, tx, y);
2661
+ }
2662
+ ctx.restore();
2663
+ }
2664
+ var LEGEND_STRINGS = {
2665
+ ko: { open: "\uC2DC", high: "\uACE0", low: "\uC800", close: "\uC885", volume: "\uAC70\uB798\uB7C9", volumePane: "\uAC70\uB798\uB7C9" },
2666
+ en: { open: "O", high: "H", low: "L", close: "C", volume: "Vol", volumePane: "Volume" }
2667
+ };
2668
+
2590
2669
  // src/resolution.ts
2591
2670
  var MINUTE = 6e4;
2592
2671
  var DAY_MS = 864e5;
@@ -2610,6 +2689,28 @@ function resolutionToMs(resolution) {
2610
2689
  function isIntradayResolution(resolution) {
2611
2690
  return resolutionToMs(resolution) < DAY_MS;
2612
2691
  }
2692
+ function resolutionLabel(resolution, locale, form = "short") {
2693
+ const match = /^(\d*)([SDWM]?)$/i.exec(resolution.trim());
2694
+ if (!match) return resolution;
2695
+ const n = match[1] ? Number(match[1]) : 1;
2696
+ const unit = (match[2] ?? "").toUpperCase();
2697
+ const ko = locale === "ko";
2698
+ const long = form === "long";
2699
+ switch (unit) {
2700
+ case "S":
2701
+ return ko ? `${n}\uCD08` : `${n}s`;
2702
+ case "D":
2703
+ return ko ? n === 1 && !long ? "\uC77C" : `${n}\uC77C` : `${n}D`;
2704
+ case "W":
2705
+ return ko ? n === 1 && !long ? "\uC8FC" : `${n}\uC8FC` : `${n}W`;
2706
+ case "M":
2707
+ if (n % 12 === 0) return ko ? `${n / 12}\uB144` : `${n / 12}Y`;
2708
+ return ko ? n === 1 && !long ? "\uC6D4" : `${n}\uAC1C\uC6D4` : `${n}M`;
2709
+ default:
2710
+ if (n >= 60 && n % 60 === 0) return ko ? `${n / 60}\uC2DC\uAC04` : `${n / 60}h`;
2711
+ return ko ? `${n}\uBD84` : `${n}m`;
2712
+ }
2713
+ }
2613
2714
 
2614
2715
  // src/realtime.ts
2615
2716
  var offsetFormatters = /* @__PURE__ */ new Map();
@@ -3178,15 +3279,86 @@ var Chart = class {
3178
3279
  return i + (b > a ? (time - a) / (b - a) : 0);
3179
3280
  }
3180
3281
  /** Snapshot of the chart (without the crosshair) at device resolution. */
3181
- takeScreenshot() {
3282
+ takeScreenshot(options = {}) {
3182
3283
  if (this.rafId || this.mainDirty) this.render();
3183
3284
  const src2 = this.mainLayer.canvas;
3184
3285
  const out = document.createElement("canvas");
3185
3286
  out.width = src2.width;
3186
3287
  out.height = src2.height;
3187
- out.getContext("2d")?.drawImage(src2, 0, 0);
3288
+ const ctx = out.getContext("2d");
3289
+ if (ctx) {
3290
+ ctx.drawImage(src2, 0, 0);
3291
+ if (options.legend) this.paintScreenshotLegend(ctx);
3292
+ }
3188
3293
  return out;
3189
3294
  }
3295
+ /** The legend rows, painted where the DOM legend sits (see ScreenshotOptions.legend). */
3296
+ paintScreenshotLegend(ctx) {
3297
+ const symbol = this.symbol;
3298
+ const frame = this.frame;
3299
+ if (!symbol || !frame) return;
3300
+ const pal = this.resolvePalette();
3301
+ const family = this.colors.fontFamily(this.theme.fontFamily);
3302
+ const measure = canvasMeasure(ctx, family);
3303
+ const words = LEGEND_STRINGS[this.locale];
3304
+ const bars = this.store.bars;
3305
+ const last = bars.length - 1;
3306
+ const bar = bars[last];
3307
+ const prev = bars[last - 1];
3308
+ const fmt = (p) => formatPrice(p, this.pricePrecision);
3309
+ const dir = (v) => v > 0 ? pal.up : v < 0 ? pal.down : pal.text;
3310
+ const label = (text) => ({ text, color: pal.textSecondary });
3311
+ const value = (text, color) => ({ text, color, weight: 600 });
3312
+ const dot = { text: "\xB7", color: pal.textSecondary };
3313
+ const title = [[{ text: symbol.name, color: pal.text, weight: 700 }], [dot]];
3314
+ if (this.resolution) title.push([label(resolutionLabel(this.resolution, this.locale))], [dot]);
3315
+ title.push([label(symbol.exchange)], [label(this.formatter.crosshairLabel(Date.now(), true))]);
3316
+ const rows = [{ groups: title, gap: 6, size: 13, lineHeight: 20 }];
3317
+ if (bar) {
3318
+ const c = dir(bar.close - bar.open);
3319
+ const change = prev ? bar.close - prev.close : 0;
3320
+ const pct = prev && prev.close ? change / prev.close * 100 : 0;
3321
+ const sign = change > 0 ? "+" : "";
3322
+ const groups = [
3323
+ [label(`${words.open} `), value(fmt(bar.open), c)],
3324
+ [label(`${words.high} `), value(fmt(bar.high), c)],
3325
+ [label(`${words.low} `), value(fmt(bar.low), c)],
3326
+ [label(`${words.close} `), value(fmt(bar.close), c)],
3327
+ [value(`${sign}${fmt(change)} (${sign}${pct.toFixed(2)}%)`, dir(change))]
3328
+ ];
3329
+ if (bar.volume !== void 0) groups.push([label(`${words.volume} `), value(formatCompact(bar.volume, this.locale), pal.text)]);
3330
+ rows.push({ groups, gap: 10, size: 12, lineHeight: 18 });
3331
+ }
3332
+ for (const cmp of this.compareLegend()) {
3333
+ rows.push({ groups: [[{ text: "", color: cmp.color, dot: true }, { text: cmp.name, color: pal.text }], [value(cmp.value, pal.text)]], gap: 6, size: 12, lineHeight: 22 });
3334
+ }
3335
+ const indicators = this.indicatorLegend().filter((item) => item.visible);
3336
+ const indicatorRow = (item) => ({
3337
+ groups: [[label(item.title)], ...item.values.map((v) => [value(v.value, v.color)])],
3338
+ gap: 8,
3339
+ size: 12,
3340
+ lineHeight: 20
3341
+ });
3342
+ for (const item of indicators) if (item.overlay) rows.push(indicatorRow(item));
3343
+ for (const item of this.legendExtras) if (item.visible) rows.push({ groups: [[label(item.name)]], gap: 8, size: 12, lineHeight: 20 });
3344
+ const dpr = this.mainLayer.dpr;
3345
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3346
+ const maxWidth = Math.max(80, this.size.width - LEGEND_LEFT - LEGEND_RIGHT_CLEARANCE);
3347
+ const main = layoutLegend(rows, LEGEND_LEFT, LEGEND_TOP, maxWidth, measure);
3348
+ const covers = main.bottom + 4 > this.mainScale.insetTop;
3349
+ paintLegend(ctx, main, family, covers ? { color: pal.background, top: LEGEND_TOP, left: LEGEND_LEFT } : null);
3350
+ for (const pane of frame.panes) {
3351
+ let row = null;
3352
+ if (pane.kind === "volume") {
3353
+ const v = bar?.volume;
3354
+ row = { groups: [[label(words.volumePane)], ...v !== void 0 ? [[value(formatCompact(v, this.locale), pal.text)]] : []], gap: 8, size: 12, lineHeight: 20 };
3355
+ } else if (pane.kind === "indicator") {
3356
+ const item = indicators.find((i) => i.id === pane.id);
3357
+ if (item) row = indicatorRow(item);
3358
+ }
3359
+ if (row) paintLegend(ctx, layoutLegend([row], LEGEND_LEFT, pane.top + 4, maxWidth, measure), family, null);
3360
+ }
3361
+ }
3190
3362
  /**
3191
3363
  * Re-fetches the latest window and merges it in, keeping the view. Datafeeds trigger this
3192
3364
  * via the `onResetCache` callback of subscribeBars after a WebSocket reconnect, so bars
@@ -4042,9 +4214,9 @@ var Chart = class {
4042
4214
  return (v) => formatPrice(v, decimals);
4043
4215
  };
4044
4216
  const fontFamily = this.colors.fontFamily(this.theme.fontFamily);
4045
- const font = `${this.theme.fontSize}px ${fontFamily}`;
4217
+ const font2 = `${this.theme.fontSize}px ${fontFamily}`;
4046
4218
  const ctx = this.mainLayer.ctx;
4047
- ctx.font = font;
4219
+ ctx.font = font2;
4048
4220
  let range = { from: 0, to: -1 };
4049
4221
  let highLow = null;
4050
4222
  let priceTicks = [];
@@ -4128,7 +4300,7 @@ var Chart = class {
4128
4300
  settings: this.settingsValue,
4129
4301
  overlays: [...overlays.map((o) => o.output), ...compareOverlays],
4130
4302
  palette: this.resolvePalette(),
4131
- font,
4303
+ font: font2,
4132
4304
  radius: this.theme.radius,
4133
4305
  countdown: this.countdownLabel
4134
4306
  };
@@ -4807,6 +4979,7 @@ export {
4807
4979
  monitorLatency,
4808
4980
  parseChartState,
4809
4981
  priceSource,
4982
+ resolutionLabel,
4810
4983
  resolutionToMs,
4811
4984
  resolveTickSize,
4812
4985
  roundToTick,
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The legend painted into screenshots: the same rows @funnest/charts-ui shows over the chart
3
+ * (symbol · interval · exchange, OHLC and change, compares, indicator values), as text on the
4
+ * canvas, so a saved or shared image says what it is. Layout mirrors the DOM legend's CSS
5
+ * (12px from the left, 8px from the top, 80px clear of the price axis, wrapping items).
6
+ */
7
+ /** A piece of text (or a color dot) drawn as-is. */
8
+ export interface LegendRun {
9
+ text: string;
10
+ color: string;
11
+ /** Default 400. */
12
+ weight?: number;
13
+ /** A filled circle of this color before the text (compare rows). */
14
+ dot?: boolean;
15
+ }
16
+ /** Runs that stay together on one line; groups wrap between them. */
17
+ export type LegendGroup = LegendRun[];
18
+ export interface LegendRow {
19
+ groups: LegendGroup[];
20
+ /** Space between groups. */
21
+ gap: number;
22
+ size: number;
23
+ lineHeight: number;
24
+ }
25
+ export interface PlacedRun {
26
+ run: LegendRun;
27
+ x: number;
28
+ /** Middle of the line. */
29
+ y: number;
30
+ size: number;
31
+ }
32
+ export interface LegendLayout {
33
+ runs: PlacedRun[];
34
+ /** Bottom of the last line. */
35
+ bottom: number;
36
+ /** Right edge of the widest line. */
37
+ right: number;
38
+ }
39
+ export type MeasureText = (text: string, size: number, weight: number) => number;
40
+ export declare const LEGEND_LEFT = 12;
41
+ export declare const LEGEND_TOP = 8;
42
+ export declare const LEGEND_RIGHT_CLEARANCE = 80;
43
+ /** Places rows top-down from (left, top), wrapping groups that don't fit within maxWidth. */
44
+ export declare function layoutLegend(rows: readonly LegendRow[], left: number, top: number, maxWidth: number, measure: MeasureText): LegendLayout;
45
+ export declare function canvasMeasure(ctx: CanvasRenderingContext2D, family: string): MeasureText;
46
+ /**
47
+ * Paints a layout. With `backdrop`, a translucent panel goes behind it first — for legends taller
48
+ * than the space the chart keeps clear above the candles.
49
+ */
50
+ export declare function paintLegend(ctx: CanvasRenderingContext2D, layout: LegendLayout, family: string, backdrop: {
51
+ color: string;
52
+ top: number;
53
+ left: number;
54
+ } | null): void;
55
+ /** Words the screenshot legend uses (the widget's own strings live in @funnest/charts-ui). */
56
+ export declare const LEGEND_STRINGS: {
57
+ readonly ko: {
58
+ readonly open: '시';
59
+ readonly high: '고';
60
+ readonly low: '저';
61
+ readonly close: '종';
62
+ readonly volume: '거래량';
63
+ readonly volumePane: '거래량';
64
+ };
65
+ readonly en: {
66
+ readonly open: 'O';
67
+ readonly high: 'H';
68
+ readonly low: 'L';
69
+ readonly close: 'C';
70
+ readonly volume: 'Vol';
71
+ readonly volumePane: 'Volume';
72
+ };
73
+ };
@@ -1,3 +1,4 @@
1
+ import type { Locale } from './model/time-format';
1
2
  import type { Resolution } from './types';
2
3
  export declare const DAY_MS = 86400000;
3
4
  /**
@@ -6,3 +7,8 @@ export declare const DAY_MS = 86400000;
6
7
  */
7
8
  export declare function resolutionToMs(resolution: Resolution): number;
8
9
  export declare function isIntradayResolution(resolution: Resolution): boolean;
10
+ /**
11
+ * '1' → 1분, '60' → 1시간, '1D' → 일 (short) / 1일 (long), '12M' → 1년.
12
+ * Short labels are for the toolbar chips, long ones for the interval menu.
13
+ */
14
+ export declare function resolutionLabel(resolution: Resolution, locale: Locale, form?: 'short' | 'long'): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnest/charts",
3
- "version": "0.6.23",
3
+ "version": "0.6.25",
4
4
  "description": "Canvas charting engine for financial charts",
5
5
  "keywords": [
6
6
  "chart",