@funnest/charts 0.6.0 → 0.6.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/dist/chart.d.ts CHANGED
@@ -99,8 +99,23 @@ export interface ChartEvents {
99
99
  };
100
100
  comparesChange: CompareInfo[];
101
101
  replayChange: ReplayStatus;
102
+ /**
103
+ * Right-click on the chart (mouse only). While anything listens, the browser's own menu is
104
+ * suppressed — the widget shows its menu (undo, horizontal line here, reset…).
105
+ */
106
+ contextMenu: ChartContextMenu;
102
107
  error: unknown;
103
108
  }
109
+ export interface ChartContextMenu {
110
+ /** Pixels relative to the chart element. */
111
+ x: number;
112
+ y: number;
113
+ pane: PaneId;
114
+ /** Price under the pointer (tick-rounded) when it's over the price pane, else null. */
115
+ price: number | null;
116
+ /** Time of the bar under the pointer, or null past the data. */
117
+ time: number | null;
118
+ }
104
119
  export interface ChartPaneLayout {
105
120
  id: PaneId;
106
121
  kind: 'main' | 'volume' | 'indicator';
@@ -205,6 +220,9 @@ export declare class Chart {
205
220
  private percentBaseIndex;
206
221
  private replayState;
207
222
  private replaySelecting;
223
+ /** Pane height weights the user set by dragging borders (unset panes use PANE_WEIGHT). */
224
+ private readonly paneWeights;
225
+ private paneWeightTotal;
208
226
  constructor(container: HTMLElement, options?: ChartOptions);
209
227
  /** Replaces all bars and scrolls to the latest. */
210
228
  setData(bars: readonly Bar[]): void;
@@ -296,6 +314,8 @@ export declare class Chart {
296
314
  /** Restores drawings (e.g. from storage). */
297
315
  setDrawings(drawings: readonly Drawing[]): void;
298
316
  updateDrawing(id: string, patch: Partial<Omit<Drawing, 'id'>>): void;
317
+ /** Adds a drawing (e.g. a horizontal line at a price) and selects it. Returns its id. */
318
+ addDrawing(drawing: Omit<Drawing, 'id'>): string;
299
319
  removeDrawing(id: string): void;
300
320
  clearDrawings(): void;
301
321
  get selectedDrawing(): Drawing | null;
@@ -317,6 +337,13 @@ export declare class Chart {
317
337
  indicatorDefinition(type: IndicatorType): IndicatorDefinition | undefined;
318
338
  /** Built-in and custom drawing tools, in drawing-bar order. */
319
339
  drawingDefinitions(): DrawingDefinition[];
340
+ /**
341
+ * Moves the border above pane `index` (1 = between the price pane and the next one) by `dy` px.
342
+ * The two neighbouring panes trade height; the rest keep theirs. Users do this by dragging.
343
+ */
344
+ resizePane(index: number, dy: number): void;
345
+ /** Back to the default pane heights. */
346
+ resetPaneSizes(): void;
320
347
  get canUndo(): boolean;
321
348
  get canRedo(): boolean;
322
349
  /** Reverts the last drawing or indicator change. */
package/dist/events.d.ts CHANGED
@@ -3,5 +3,6 @@ export declare class Emitter<Events extends object> {
3
3
  private readonly listeners;
4
4
  on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): () => void;
5
5
  emit<K extends keyof Events>(event: K, payload: Events[K]): void;
6
+ has<K extends keyof Events>(event: K): boolean;
6
7
  clear(): void;
7
8
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { Chart, createChart, fallbackResolution } from './chart';
2
2
  export { DEFAULT_CHART_SETTINGS, parseChartState } from './settings';
3
3
  export type { ChartSettings, ChartState } from './settings';
4
- export type { ChartEvents, ChartLayout, ChartOptions, ChartPaneLayout, CompareInfo, IndicatorLegendItem, ReplayStatus } from './chart';
4
+ export type { ChartContextMenu, ChartEvents, ChartLayout, ChartOptions, ChartPaneLayout, CompareInfo, IndicatorLegendItem, ReplayStatus } 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';
package/dist/index.js CHANGED
@@ -12,6 +12,9 @@ var Emitter = class {
12
12
  if (!set) return;
13
13
  for (const listener of set) listener(payload);
14
14
  }
15
+ has(event) {
16
+ return (this.listeners.get(event)?.size ?? 0) > 0;
17
+ }
15
18
  clear() {
16
19
  this.listeners.clear();
17
20
  }
@@ -102,6 +105,9 @@ var Interaction = class {
102
105
  case "timeAxis":
103
106
  this.mode = "timeScale";
104
107
  break;
108
+ case "paneBorder":
109
+ this.mode = "paneResize";
110
+ break;
105
111
  case "plot": {
106
112
  if (this.host.toolActive()) {
107
113
  this.mode = "draw";
@@ -173,6 +179,9 @@ var Interaction = class {
173
179
  case "timeScale":
174
180
  this.host.zoomTime(this.host.plotWidth(), Math.exp((p.x - this.last.x) * AXIS_DRAG_SPEED));
175
181
  break;
182
+ case "paneResize":
183
+ if (this.region.border) this.host.resizePane(this.region.border, p.y - this.last.y);
184
+ break;
176
185
  }
177
186
  this.last = p;
178
187
  }
@@ -187,7 +196,7 @@ var Interaction = class {
187
196
  }
188
197
  if (this.mode === "draw") this.host.drawRelease(p.x, p.y, this.moved);
189
198
  if (this.mode === "object") this.host.objectUp(this.moved);
190
- const tapModes = ["pending", "priceScale", "timeScale"];
199
+ const tapModes = ["pending", "priceScale", "timeScale", "paneResize"];
191
200
  if (this.touch && !this.moved && tapModes.includes(this.mode)) this.handleTap(p);
192
201
  if (this.mode === "pan") this.startKinetic();
193
202
  if (this.mode === "crosshair") this.host.clearCrosshair();
@@ -213,7 +222,7 @@ var Interaction = class {
213
222
  }
214
223
  hover(p) {
215
224
  const region = this.host.hitTest(p.x, p.y);
216
- let cursor = region.area === "priceAxis" ? "ns-resize" : region.area === "timeAxis" ? "ew-resize" : "crosshair";
225
+ let cursor = region.area === "priceAxis" ? "ns-resize" : region.area === "timeAxis" ? "ew-resize" : region.area === "paneBorder" ? "row-resize" : "crosshair";
217
226
  let hovered = null;
218
227
  if (region.area === "plot") {
219
228
  if (this.host.toolActive()) {
@@ -244,6 +253,7 @@ var Interaction = class {
244
253
  if (isDouble) {
245
254
  this.lastTap.t = 0;
246
255
  if (this.region.area === "priceAxis" && this.region.pane) this.host.resetPriceScale(this.region.pane);
256
+ else if (this.region.area === "paneBorder") this.host.resetPaneSizes();
247
257
  else this.host.resetView();
248
258
  } else {
249
259
  this.lastTap = { t, x: p.x, y: p.y };
@@ -331,13 +341,19 @@ var Interaction = class {
331
341
  const p = this.local(e);
332
342
  const region = this.host.hitTest(p.x, p.y);
333
343
  if (region.area === "priceAxis" && region.pane) this.host.resetPriceScale(region.pane);
344
+ else if (region.area === "paneBorder") this.host.resetPaneSizes();
334
345
  else if (region.area !== "none") this.host.resetView();
335
346
  }
336
347
  onTouchMove(e) {
337
348
  if (this.mode !== "idle" && this.mode !== "pending") e.preventDefault();
338
349
  }
339
350
  onContextMenu(e) {
340
- if (this.touch) e.preventDefault();
351
+ if (this.touch) {
352
+ e.preventDefault();
353
+ return;
354
+ }
355
+ const p = this.local(e);
356
+ if (this.host.contextMenu(p.x, p.y)) e.preventDefault();
341
357
  }
342
358
  };
343
359
 
@@ -1801,6 +1817,7 @@ function parseChartState(input) {
1801
1817
  settings: { ...DEFAULT_CHART_SETTINGS, ...s.settings ?? {} },
1802
1818
  indicators: Array.isArray(s.indicators) ? s.indicators : [],
1803
1819
  drawings: s.drawings && typeof s.drawings === "object" ? s.drawings : {},
1820
+ paneWeights: s.paneWeights && typeof s.paneWeights === "object" ? Object.fromEntries(Object.entries(s.paneWeights).filter(([, w]) => typeof w === "number" && w > 0)) : {},
1804
1821
  compares: Array.isArray(s.compares) ? s.compares.filter((x) => typeof x === "string") : [],
1805
1822
  view: {
1806
1823
  barSpacing: Number(s.view?.barSpacing) || 8,
@@ -2561,6 +2578,8 @@ function createTheme(base, overrides = {}, direction) {
2561
2578
  var TIME_AXIS_HEIGHT = 28;
2562
2579
  var MIN_PRICE_AXIS_WIDTH = 56;
2563
2580
  var PANE_WEIGHT = { main: 4, volume: 1, indicator: 1.3 };
2581
+ var MIN_PANE_HEIGHT = 32;
2582
+ var PANE_BORDER_GRAB = 4;
2564
2583
  var HISTORY_TRIGGER_BARS = 20;
2565
2584
  var TIME_LABEL_MIN_GAP = 72;
2566
2585
  var PRICE_TICK_SPACING = 48;
@@ -2676,6 +2695,9 @@ var Chart = class {
2676
2695
  percentBaseIndex = 0;
2677
2696
  replayState = null;
2678
2697
  replaySelecting = false;
2698
+ /** Pane height weights the user set by dragging borders (unset panes use PANE_WEIGHT). */
2699
+ paneWeights = /* @__PURE__ */ new Map();
2700
+ paneWeightTotal = 1;
2679
2701
  constructor(container, options = {}) {
2680
2702
  this.theme = { ...lightTheme, ...options.theme };
2681
2703
  this.locale = options.locale ?? "ko";
@@ -3035,7 +3057,8 @@ var Chart = class {
3035
3057
  indicators: this.getIndicators(),
3036
3058
  drawings: JSON.parse(JSON.stringify(drawings)),
3037
3059
  view: { barSpacing: this.timeScale.barSpacing, rightOffset: this.timeScale.rightOffset },
3038
- compares: [...this.compares.keys()]
3060
+ compares: [...this.compares.keys()],
3061
+ paneWeights: Object.fromEntries(this.paneWeights)
3039
3062
  };
3040
3063
  }
3041
3064
  /**
@@ -3058,6 +3081,8 @@ var Chart = class {
3058
3081
  this.timeScale.barSpacing = state.view.barSpacing;
3059
3082
  this.timeScale.rightOffset = state.view.rightOffset;
3060
3083
  this.timeScale.clamp();
3084
+ this.paneWeights.clear();
3085
+ for (const [id, w] of Object.entries(state.paneWeights ?? {})) if (w > 0) this.paneWeights.set(id, w);
3061
3086
  const compares = state.compares ?? [];
3062
3087
  for (const symbol of [...this.compares.keys()]) if (!compares.includes(symbol)) this.removeCompare(symbol);
3063
3088
  if (this.datafeed) for (const symbol of compares) void this.addCompare(symbol);
@@ -3107,6 +3132,13 @@ var Chart = class {
3107
3132
  updateDrawing(id, patch) {
3108
3133
  this.drawings.update(id, patch);
3109
3134
  }
3135
+ /** Adds a drawing (e.g. a horizontal line at a price) and selects it. Returns its id. */
3136
+ addDrawing(drawing) {
3137
+ const id = `d_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
3138
+ this.drawings.set([...this.drawings.drawings, { ...drawing, id, points: drawing.points.map((p) => ({ ...p })) }]);
3139
+ this.drawings.select(id);
3140
+ return id;
3141
+ }
3110
3142
  removeDrawing(id) {
3111
3143
  this.drawings.remove(id);
3112
3144
  }
@@ -3129,6 +3161,7 @@ var Chart = class {
3129
3161
  removeIndicator(id) {
3130
3162
  if (!this.indicators.remove(id)) return;
3131
3163
  this.indicatorScales.delete(id);
3164
+ this.paneWeights.delete(id);
3132
3165
  this.indicatorsChanged();
3133
3166
  }
3134
3167
  /** Changes params, per-series styles (color/width/visible) and/or overall visibility. */
@@ -3188,6 +3221,32 @@ var Chart = class {
3188
3221
  drawingDefinitions() {
3189
3222
  return [...this.drawings.registry.values()];
3190
3223
  }
3224
+ // ── Pane sizes ─────────────────────────────────────────────────────
3225
+ /**
3226
+ * Moves the border above pane `index` (1 = between the price pane and the next one) by `dy` px.
3227
+ * The two neighbouring panes trade height; the rest keep theirs. Users do this by dragging.
3228
+ */
3229
+ resizePane(index, dy) {
3230
+ const panes = this.frame?.panes;
3231
+ const above = panes?.[index - 1];
3232
+ const below = panes?.[index];
3233
+ if (!panes || !above || !below || !this.frame) return;
3234
+ const pair = above.height + below.height;
3235
+ const top = Math.min(pair - MIN_PANE_HEIGHT, Math.max(MIN_PANE_HEIGHT, above.height + dy));
3236
+ const unit = this.paneWeightTotal / Math.max(1, this.frame.panesHeight);
3237
+ for (const p of panes) this.paneWeights.set(p.id, p.height * unit);
3238
+ this.paneWeights.set(above.id, top * unit);
3239
+ this.paneWeights.set(below.id, (pair - top) * unit);
3240
+ this.invalidate();
3241
+ this.stateChanged();
3242
+ }
3243
+ /** Back to the default pane heights. */
3244
+ resetPaneSizes() {
3245
+ if (this.paneWeights.size === 0) return;
3246
+ this.paneWeights.clear();
3247
+ this.invalidate();
3248
+ this.stateChanged();
3249
+ }
3191
3250
  // ── Undo / redo ────────────────────────────────────────────────────
3192
3251
  get canUndo() {
3193
3252
  return this.history.undo.length > 0;
@@ -3689,11 +3748,16 @@ var Chart = class {
3689
3748
  const panesHeight = Math.max(0, height - TIME_AXIS_HEIGHT);
3690
3749
  this.timeScale.barCount = this.store.length;
3691
3750
  const indicatorPanes = this.indicators.panes;
3692
- const totalWeight = PANE_WEIGHT.main + (this.settingsValue.volume ? PANE_WEIGHT.volume : 0) + indicatorPanes.length * PANE_WEIGHT.indicator;
3693
- const share = (w) => Math.round(panesHeight * w / totalWeight);
3694
- const volumeHeight = this.settingsValue.volume ? share(PANE_WEIGHT.volume) : 0;
3695
- const indicatorHeight = share(PANE_WEIGHT.indicator);
3696
- const mainHeight = panesHeight - volumeHeight - indicatorHeight * indicatorPanes.length;
3751
+ const weightOf = (id, kind) => this.paneWeights.get(id) ?? PANE_WEIGHT[kind];
3752
+ const weights = [["main", weightOf("main", "main")]];
3753
+ if (this.settingsValue.volume) weights.push(["volume", weightOf("volume", "volume")]);
3754
+ for (const c of indicatorPanes) weights.push([c.id, weightOf(c.id, "indicator")]);
3755
+ const totalWeight = weights.reduce((sum, [, w]) => sum + w, 0);
3756
+ this.paneWeightTotal = totalWeight;
3757
+ const heightOf = new Map(weights.map(([id, w]) => [id, Math.max(MIN_PANE_HEIGHT, Math.round(panesHeight * w / totalWeight))]));
3758
+ const volumeHeight = heightOf.get("volume") ?? 0;
3759
+ const indicatorHeights = indicatorPanes.map((c) => heightOf.get(c.id));
3760
+ const mainHeight = Math.max(MIN_PANE_HEIGHT, panesHeight - volumeHeight - indicatorHeights.reduce((a, b) => a + b, 0));
3697
3761
  this.mainScale.height = mainHeight;
3698
3762
  this.volumeScale.height = volumeHeight;
3699
3763
  const drawable = (config) => {
@@ -3713,7 +3777,7 @@ var Chart = class {
3713
3777
  this.countdownLabel = this.countdownText();
3714
3778
  const subPanes = indicatorPanes.map((config) => {
3715
3779
  const scale = this.indicatorScale(config.id);
3716
- scale.height = indicatorHeight;
3780
+ scale.height = heightOf.get(config.id);
3717
3781
  return { config, scale, output: drawable(config) };
3718
3782
  });
3719
3783
  const indicatorFormat = (output) => {
@@ -3743,8 +3807,8 @@ var Chart = class {
3743
3807
  for (const pane of subPanes) this.autoScaleIndicator(pane.scale, pane.output, range);
3744
3808
  priceTicks = this.mainTicks(mainHeight);
3745
3809
  subTicks = subPanes.map((p) => {
3746
- const [lo, hi] = p.output.range ?? [p.scale.yToPrice(indicatorHeight), p.scale.yToPrice(0)];
3747
- return niceTicks(lo, hi, Math.max(1, Math.floor(indicatorHeight / 36)));
3810
+ const [lo, hi] = p.output.range ?? [p.scale.yToPrice(p.scale.height), p.scale.yToPrice(0)];
3811
+ return niceTicks(lo, hi, Math.max(1, Math.floor(p.scale.height / 36)));
3748
3812
  });
3749
3813
  let labelWidth = 0;
3750
3814
  const measure = (text) => labelWidth = Math.max(labelWidth, ctx.measureText(text).width);
@@ -3789,14 +3853,14 @@ var Chart = class {
3789
3853
  id: p.config.id,
3790
3854
  kind: "indicator",
3791
3855
  top,
3792
- height: indicatorHeight,
3856
+ height: p.scale.height,
3793
3857
  scale: p.scale,
3794
3858
  ticks: subTicks[i],
3795
3859
  format: indicatorFormat(p.output),
3796
3860
  tickFormat: tickFormat(subTicks[i], p.output),
3797
3861
  indicator: p.output
3798
3862
  });
3799
- top += indicatorHeight;
3863
+ top += p.scale.height;
3800
3864
  });
3801
3865
  return {
3802
3866
  width,
@@ -4062,6 +4126,8 @@ var Chart = class {
4062
4126
  const f = this.frame;
4063
4127
  if (!f) return { area: "none", pane: null };
4064
4128
  if (y >= f.panesHeight) return { area: x < f.plotWidth ? "timeAxis" : "none", pane: null };
4129
+ const border = f.panes.findIndex((p, i) => i > 0 && Math.abs(y - p.top) <= PANE_BORDER_GRAB);
4130
+ if (border > 0) return { area: "paneBorder", pane: f.panes[border].id, border };
4065
4131
  return { area: x >= f.plotWidth ? "priceAxis" : "plot", pane: this.paneAt(f, y).id };
4066
4132
  },
4067
4133
  plotWidth: () => this.timeScale.width,
@@ -4097,6 +4163,23 @@ var Chart = class {
4097
4163
  this.invalidate();
4098
4164
  },
4099
4165
  resetView: () => this.resetView(),
4166
+ resizePane: (index, dy) => this.resizePane(index, dy),
4167
+ contextMenu: (x, y) => {
4168
+ const f = this.frame;
4169
+ if (!this.emitter.has("contextMenu") || !f || x > f.plotWidth || y > f.panesHeight) return false;
4170
+ const pane = this.paneAt(f, y);
4171
+ const index = this.timeScale.xToIndex(x);
4172
+ const bar = this.store.at(index);
4173
+ this.emitter.emit("contextMenu", {
4174
+ x,
4175
+ y,
4176
+ pane: pane.id,
4177
+ price: pane.kind === "main" ? roundToTick(this.mainScale.yToPrice(y), this.tickSize) : null,
4178
+ time: bar?.time ?? null
4179
+ });
4180
+ return true;
4181
+ },
4182
+ resetPaneSizes: () => this.resetPaneSizes(),
4100
4183
  toolActive: () => (this.drawings.tool !== null || this.replaySelecting) && this.store.length > 0,
4101
4184
  hitObject: (x, y, touch) => {
4102
4185
  if (y > this.mainScale.height) return null;
@@ -1,8 +1,10 @@
1
1
  import type { ObjectHit } from './objects/types';
2
2
  import type { PaneId } from './render/renderer';
3
3
  export interface HitRegion {
4
- area: 'plot' | 'priceAxis' | 'timeAxis' | 'none';
4
+ area: 'plot' | 'priceAxis' | 'timeAxis' | 'paneBorder' | 'none';
5
5
  pane: PaneId | null;
6
+ /** paneBorder: index of the pane below the border. */
7
+ border?: number;
6
8
  }
7
9
  /** What the interaction layer needs from the chart. Kept narrow so gestures stay testable. */
8
10
  export interface InteractionHost {
@@ -17,6 +19,9 @@ export interface InteractionHost {
17
19
  scalePrice(pane: PaneId, factor: number): void;
18
20
  resetPriceScale(pane: PaneId): void;
19
21
  resetView(): void;
22
+ /** Drag of the border above pane `index`. */
23
+ resizePane(index: number, dy: number): void;
24
+ resetPaneSizes(): void;
20
25
  /** A drawing tool is armed: presses create drawings instead of panning. */
21
26
  toolActive(): boolean;
22
27
  hitObject(x: number, y: number, touch: boolean): ObjectHit | null;
@@ -29,6 +34,8 @@ export interface InteractionHost {
29
34
  /** Mouse hover over an object (or nothing) — e.g. to show a fill tooltip. */
30
35
  hoverObject(hit: ObjectHit | null): void;
31
36
  clearSelection(): void;
37
+ /** Mouse right-click at (x, y). Return true to suppress the browser's own menu. */
38
+ contextMenu(x: number, y: number): boolean;
32
39
  }
33
40
  /**
34
41
  * Unified mouse / touch / pen gestures on top of Pointer Events.
@@ -44,6 +44,8 @@ export interface ChartState {
44
44
  };
45
45
  /** Symbols overlaid for comparison (see `Chart.addCompare`). */
46
46
  compares?: string[];
47
+ /** Pane heights the user dragged, as weights by pane id ('main', 'volume', indicator ids). */
48
+ paneWeights?: Record<string, number>;
47
49
  }
48
50
  /** Validates untrusted JSON (e.g. from storage) into a ChartState; throws on an incompatible shape. */
49
51
  export declare function parseChartState(input: unknown): ChartState;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnest/charts",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Canvas charting engine for financial charts",
5
5
  "keywords": [
6
6
  "chart",