ui-svelte 0.2.4 → 0.2.6

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.
Files changed (45) hide show
  1. package/dist/assets/country-flags.d.ts +1 -0
  2. package/dist/assets/country-flags.js +1612 -0
  3. package/dist/charts/ArcChart.svelte +291 -48
  4. package/dist/charts/ArcChart.svelte.d.ts +32 -1
  5. package/dist/charts/Candlestick.svelte +663 -115
  6. package/dist/charts/Candlestick.svelte.d.ts +40 -0
  7. package/dist/charts/css/arc-chart.css +76 -6
  8. package/dist/charts/css/candlestick.css +234 -11
  9. package/dist/control/Button.svelte +3 -1
  10. package/dist/control/Button.svelte.d.ts +1 -0
  11. package/dist/control/IconButton.svelte +3 -1
  12. package/dist/control/IconButton.svelte.d.ts +1 -0
  13. package/dist/control/ToggleGroup.svelte +82 -0
  14. package/dist/control/ToggleGroup.svelte.d.ts +20 -0
  15. package/dist/control/css/toggle-group.css +85 -0
  16. package/dist/css/base.css +23 -43
  17. package/dist/css/utilities.css +45 -0
  18. package/dist/display/AvatarGroup.svelte +59 -0
  19. package/dist/display/AvatarGroup.svelte.d.ts +17 -0
  20. package/dist/display/Code.svelte +14 -7
  21. package/dist/display/Code.svelte.d.ts +1 -0
  22. package/dist/display/Section.svelte +1 -1
  23. package/dist/display/css/avatar-group.css +46 -0
  24. package/dist/display/css/avatar.css +1 -10
  25. package/dist/display/css/badge.css +14 -11
  26. package/dist/form/ComboBox.svelte.d.ts +1 -1
  27. package/dist/form/PhoneField.svelte +8 -4
  28. package/dist/form/Select.svelte.d.ts +1 -1
  29. package/dist/index.css +43 -21
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +3 -1
  32. package/dist/navigation/BottomNav.svelte +43 -16
  33. package/dist/navigation/NavMenu.svelte +25 -4
  34. package/dist/navigation/SideNav.svelte +20 -2
  35. package/dist/navigation/SideNav.svelte.d.ts +2 -0
  36. package/dist/navigation/css/bottom-nav.css +139 -15
  37. package/dist/navigation/css/nav-menu.css +192 -7
  38. package/dist/navigation/css/side-nav.css +80 -0
  39. package/dist/navigation/css/tabs.css +4 -4
  40. package/dist/utils/popover.js +6 -6
  41. package/package.json +2 -2
  42. /package/dist/{form/js → assets}/countries.d.ts +0 -0
  43. /package/dist/{form/js → assets}/countries.js +0 -0
  44. /package/dist/{form/js → assets}/phone-examples.d.ts +0 -0
  45. /package/dist/{form/js → assets}/phone-examples.js +0 -0
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils/class-names.js';
3
- import { onMount } from 'svelte';
3
+ import { onMount, type Snippet } from 'svelte';
4
4
 
5
5
  type LinearScale = {
6
6
  (value: number): number;
@@ -17,6 +17,11 @@
17
17
  };
18
18
 
19
19
  type Color = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger' | 'muted';
20
+ type Size = 'sm' | 'md' | 'lg' | 'xl';
21
+ type Theme = 'default' | 'tradingview' | 'dark' | 'light';
22
+ type CandleStyle = 'filled' | 'hollow' | 'heikinashi' | 'line' | 'area';
23
+ type ScaleType = 'linear' | 'log';
24
+ type GridStyle = 'solid' | 'dashed' | 'dotted' | 'none';
20
25
 
21
26
  type CandleData = {
22
27
  date: string | Date;
@@ -27,6 +32,11 @@
27
32
  volume?: number;
28
33
  };
29
34
 
35
+ type Indicator =
36
+ | { type: 'sma'; period: number; color?: Color }
37
+ | { type: 'ema'; period: number; color?: Color }
38
+ | { type: 'bollinger'; period: number; stdDev?: number; color?: Color };
39
+
30
40
  type Margin = {
31
41
  top: number;
32
42
  right: number;
@@ -53,41 +63,91 @@
53
63
  maxVisibleCandles?: number;
54
64
  rootClass?: string;
55
65
  chartClass?: string;
66
+ size?: Size;
67
+ theme?: Theme;
68
+ candleStyle?: CandleStyle;
69
+ scaleType?: ScaleType;
70
+ showCrosshair?: boolean;
71
+ showYAxisLabels?: boolean;
72
+ showXAxisLabels?: boolean;
73
+ gridStyle?: GridStyle;
74
+ showLastPrice?: boolean;
75
+ indicators?: Indicator[];
76
+ onClick?: (candle: CandleData, index: number) => void;
77
+ onRangeChange?: (start: number, end: number) => void;
78
+ priceFormatter?: (value: number) => string;
79
+ dateFormatter?: (date: Date | string) => string;
80
+ animated?: boolean;
81
+ tooltipContent?: Snippet<[{ candle: CandleData; change: number; changePercent: number }]>;
56
82
  };
57
83
 
58
84
  let {
59
85
  data = [],
60
- margin = { top: 20, right: 20, bottom: 40, left: 60 },
61
- showVolume = false,
62
- showGrid = true,
86
+ margin = { top: 20, right: 60, bottom: 40, left: 60 },
87
+ showVolume,
88
+ showGrid,
63
89
  candleWidth = 8,
64
90
  bullishColor = 'success' as Color,
65
91
  bearishColor = 'danger' as Color,
66
92
  wickWidth = 1,
67
- loading = false,
68
- empty = false,
93
+ loading,
94
+ empty,
69
95
  emptyText = 'No data available',
70
- enableZoom = true,
71
- enableScroll = true,
96
+ enableZoom,
97
+ enableScroll,
72
98
  initialVisibleCandles = 50,
73
99
  minVisibleCandles = 10,
74
100
  maxVisibleCandles = 200,
75
101
  rootClass,
76
- chartClass
102
+ chartClass,
103
+ size = 'md',
104
+ theme = 'default',
105
+ candleStyle = 'filled',
106
+ scaleType = 'linear',
107
+ showCrosshair,
108
+ showYAxisLabels,
109
+ showXAxisLabels,
110
+ gridStyle = 'dashed',
111
+ showLastPrice,
112
+ indicators = [],
113
+ onClick,
114
+ onRangeChange,
115
+ priceFormatter,
116
+ dateFormatter,
117
+ animated,
118
+ tooltipContent
77
119
  }: Props = $props();
78
120
 
121
+ const sizePresets: Record<Size, number> = {
122
+ sm: 200,
123
+ md: 350,
124
+ lg: 500,
125
+ xl: 700
126
+ };
127
+
128
+ const themeColors: Record<Theme, { bg: string; grid: string; text: string }> = {
129
+ default: { bg: 'transparent', grid: 'var(--color-muted)', text: 'var(--color-on-muted)' },
130
+ tradingview: { bg: '#131722', grid: '#363c4e', text: '#d1d4dc' },
131
+ dark: { bg: '#1a1a2e', grid: '#2d2d44', text: '#e0e0e0' },
132
+ light: { bg: '#ffffff', grid: '#e0e0e0', text: '#333333' }
133
+ };
134
+
79
135
  let containerEl: HTMLDivElement | undefined = $state();
80
136
  let svgEl: SVGSVGElement | undefined = $state();
81
137
  let containerSize = $state({ width: 0, height: 0 });
82
138
 
139
+ // svelte-ignore state_referenced_locally
83
140
  let visibleCandles = $state(initialVisibleCandles);
84
141
  let scrollOffset = $state(0);
85
142
  let isDragging = $state(false);
86
143
  let dragStartX = $state(0);
87
144
  let dragStartOffset = $state(0);
88
145
 
146
+ let crosshairPosition = $state<{ x: number; y: number } | null>(null);
147
+ let isCrosshairActive = $state(false);
148
+
89
149
  let width = $derived(containerSize.width || 800);
90
- let height = $derived(containerSize.height || 400);
150
+ let chartHeight = $derived(sizePresets[size]);
91
151
 
92
152
  let visibleData = $derived.by(() => {
93
153
  if (data.length === 0) return [];
@@ -96,6 +156,123 @@
96
156
  return data.slice(start, end);
97
157
  });
98
158
 
159
+ let processedData = $derived.by(() => {
160
+ if (candleStyle !== 'heikinashi') return visibleData;
161
+ return calculateHeikinAshi(visibleData);
162
+ });
163
+
164
+ function calculateHeikinAshi(candles: CandleData[]): CandleData[] {
165
+ if (candles.length === 0) return [];
166
+
167
+ const result: CandleData[] = [];
168
+ let prevHaOpen = candles[0].open;
169
+ let prevHaClose = (candles[0].open + candles[0].high + candles[0].low + candles[0].close) / 4;
170
+
171
+ for (let i = 0; i < candles.length; i++) {
172
+ const c = candles[i];
173
+ const haClose = (c.open + c.high + c.low + c.close) / 4;
174
+ const haOpen = i === 0 ? c.open : (prevHaOpen + prevHaClose) / 2;
175
+ const haHigh = Math.max(c.high, haOpen, haClose);
176
+ const haLow = Math.min(c.low, haOpen, haClose);
177
+
178
+ result.push({
179
+ date: c.date,
180
+ open: haOpen,
181
+ high: haHigh,
182
+ low: haLow,
183
+ close: haClose,
184
+ volume: c.volume
185
+ });
186
+
187
+ prevHaOpen = haOpen;
188
+ prevHaClose = haClose;
189
+ }
190
+
191
+ return result;
192
+ }
193
+
194
+ function calculateSMA(candles: CandleData[], period: number): (number | null)[] {
195
+ const result: (number | null)[] = [];
196
+ for (let i = 0; i < candles.length; i++) {
197
+ if (i < period - 1) {
198
+ result.push(null);
199
+ } else {
200
+ let sum = 0;
201
+ for (let j = 0; j < period; j++) {
202
+ sum += candles[i - j].close;
203
+ }
204
+ result.push(sum / period);
205
+ }
206
+ }
207
+ return result;
208
+ }
209
+
210
+ function calculateEMA(candles: CandleData[], period: number): (number | null)[] {
211
+ const result: (number | null)[] = [];
212
+ const multiplier = 2 / (period + 1);
213
+
214
+ for (let i = 0; i < candles.length; i++) {
215
+ if (i < period - 1) {
216
+ result.push(null);
217
+ } else if (i === period - 1) {
218
+ let sum = 0;
219
+ for (let j = 0; j < period; j++) {
220
+ sum += candles[i - j].close;
221
+ }
222
+ result.push(sum / period);
223
+ } else {
224
+ const prevEma = result[i - 1];
225
+ if (prevEma !== null) {
226
+ result.push((candles[i].close - prevEma) * multiplier + prevEma);
227
+ } else {
228
+ result.push(null);
229
+ }
230
+ }
231
+ }
232
+ return result;
233
+ }
234
+
235
+ function calculateBollingerBands(
236
+ candles: CandleData[],
237
+ period: number,
238
+ stdDev: number = 2
239
+ ): { upper: (number | null)[]; middle: (number | null)[]; lower: (number | null)[] } {
240
+ const sma = calculateSMA(candles, period);
241
+ const upper: (number | null)[] = [];
242
+ const lower: (number | null)[] = [];
243
+
244
+ for (let i = 0; i < candles.length; i++) {
245
+ if (sma[i] === null) {
246
+ upper.push(null);
247
+ lower.push(null);
248
+ } else {
249
+ let sumSquaredDiff = 0;
250
+ for (let j = 0; j < period; j++) {
251
+ sumSquaredDiff += Math.pow(candles[i - j].close - sma[i]!, 2);
252
+ }
253
+ const std = Math.sqrt(sumSquaredDiff / period);
254
+ upper.push(sma[i]! + stdDev * std);
255
+ lower.push(sma[i]! - stdDev * std);
256
+ }
257
+ }
258
+
259
+ return { upper, middle: sma, lower };
260
+ }
261
+
262
+ let indicatorData = $derived.by(() => {
263
+ return indicators.map((ind) => {
264
+ if (ind.type === 'sma') {
265
+ return { ...ind, values: calculateSMA(processedData, ind.period) };
266
+ } else if (ind.type === 'ema') {
267
+ return { ...ind, values: calculateEMA(processedData, ind.period) };
268
+ } else if (ind.type === 'bollinger') {
269
+ const bands = calculateBollingerBands(processedData, ind.period, ind.stdDev);
270
+ return { ...ind, bands };
271
+ }
272
+ return ind;
273
+ });
274
+ });
275
+
99
276
  function createLinearScale(domain: [number, number], range: [number, number]): LinearScale {
100
277
  const [d0, d1] = domain;
101
278
  const [r0, r1] = range;
@@ -114,6 +291,28 @@
114
291
  return scale;
115
292
  }
116
293
 
294
+ function createLogScale(domain: [number, number], range: [number, number]): LinearScale {
295
+ const [d0, d1] = domain;
296
+ const [r0, r1] = range;
297
+ const logD0 = Math.log10(Math.max(d0, 0.001));
298
+ const logD1 = Math.log10(Math.max(d1, 0.001));
299
+
300
+ const scale = (value: number): number => {
301
+ const logValue = Math.log10(Math.max(value, 0.001));
302
+ return r0 + ((logValue - logD0) / (logD1 - logD0)) * (r1 - r0);
303
+ };
304
+
305
+ scale.invert = (pixel: number): number => {
306
+ const logValue = logD0 + ((pixel - r0) / (r1 - r0)) * (logD1 - logD0);
307
+ return Math.pow(10, logValue);
308
+ };
309
+
310
+ scale.domain = domain;
311
+ scale.range = range;
312
+
313
+ return scale;
314
+ }
315
+
117
316
  function createBandScale(
118
317
  domain: (string | number | Date)[],
119
318
  range: [number, number],
@@ -148,15 +347,17 @@
148
347
  if (abs >= 1e9) return `${(value / 1e9).toFixed(1)}B`;
149
348
  if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
150
349
  if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
151
- if (abs < 1) return value.toFixed(2);
152
- return value.toFixed(0);
350
+ if (abs < 1) return value.toFixed(4);
351
+ return value.toFixed(2);
153
352
  }
154
353
 
155
354
  function formatPrice(value: number): string {
355
+ if (priceFormatter) return priceFormatter(value);
156
356
  return value.toFixed(2);
157
357
  }
158
358
 
159
359
  function formatDate(date: string | Date): string {
360
+ if (dateFormatter) return dateFormatter(date);
160
361
  if (typeof date === 'string') {
161
362
  const d = new Date(date);
162
363
  return `${d.getMonth() + 1}/${d.getDate()}`;
@@ -164,16 +365,33 @@
164
365
  return date.toLocaleDateString();
165
366
  }
166
367
 
368
+ function formatDateTime(date: string | Date): string {
369
+ if (dateFormatter) return dateFormatter(date);
370
+ const d = typeof date === 'string' ? new Date(date) : date;
371
+ return d.toLocaleString();
372
+ }
373
+
167
374
  let innerWidth = $derived(width - margin.left - margin.right);
168
- let innerHeight = $derived(height - margin.top - margin.bottom);
375
+ let innerHeight = $derived(chartHeight - margin.top - margin.bottom);
169
376
 
170
377
  let priceHeight = $derived(showVolume ? innerHeight * 0.75 : innerHeight);
171
378
  let volumeHeight = $derived(showVolume ? innerHeight * 0.2 : 0);
172
379
  let volumeTop = $derived(showVolume ? priceHeight + 10 : 0);
173
380
 
174
381
  let priceDomain = $derived.by((): [number, number] => {
175
- if (visibleData.length === 0) return [0, 1];
176
- const allPrices = visibleData.flatMap((d) => [d.high, d.low]);
382
+ if (processedData.length === 0) return [0, 1];
383
+ const allPrices = processedData.flatMap((d) => [d.high, d.low]);
384
+
385
+ indicatorData.forEach((ind: any) => {
386
+ if (ind.values) {
387
+ allPrices.push(...ind.values.filter((v: number | null) => v !== null));
388
+ }
389
+ if (ind.bands) {
390
+ allPrices.push(...ind.bands.upper.filter((v: number | null) => v !== null));
391
+ allPrices.push(...ind.bands.lower.filter((v: number | null) => v !== null));
392
+ }
393
+ });
394
+
177
395
  const min = Math.min(...allPrices);
178
396
  const max = Math.max(...allPrices);
179
397
  const padding = (max - min) * 0.1;
@@ -181,26 +399,36 @@
181
399
  });
182
400
 
183
401
  let volumeDomain = $derived.by((): [number, number] => {
184
- if (!showVolume || visibleData.length === 0) return [0, 1];
185
- const volumes = visibleData.map((d) => d.volume || 0);
402
+ if (!showVolume || processedData.length === 0) return [0, 1];
403
+ const volumes = processedData.map((d) => d.volume || 0);
186
404
  return [0, Math.max(...volumes) * 1.2];
187
405
  });
188
406
 
189
407
  let xScale = $derived(
190
408
  createBandScale(
191
- visibleData.map((d) => d.date),
409
+ processedData.map((d) => d.date),
192
410
  [0, innerWidth],
193
411
  0.3
194
412
  )
195
413
  );
196
- let priceScale = $derived(createLinearScale(priceDomain, [priceHeight, 0]));
414
+
415
+ let priceScale = $derived(
416
+ scaleType === 'log'
417
+ ? createLogScale(priceDomain, [priceHeight, 0])
418
+ : createLinearScale(priceDomain, [priceHeight, 0])
419
+ );
420
+
197
421
  let volumeScale = $derived(
198
422
  showVolume ? createLinearScale(volumeDomain, [volumeHeight, 0]) : null
199
423
  );
424
+
200
425
  let grid = $derived(createGridLines(priceHeight));
201
426
 
202
427
  function createGridLines(priceH: number): Array<{ x: number; y: number; value: number }> {
203
- const yScale = createLinearScale(priceDomain, [priceH, 0]);
428
+ const yScale =
429
+ scaleType === 'log'
430
+ ? createLogScale(priceDomain, [priceH, 0])
431
+ : createLinearScale(priceDomain, [priceH, 0]);
204
432
  const yTicks = 5;
205
433
 
206
434
  return Array.from({ length: yTicks + 1 }, (_, i) => {
@@ -209,8 +437,29 @@
209
437
  });
210
438
  }
211
439
 
440
+ let lastCandle = $derived(
441
+ processedData.length > 0 ? processedData[processedData.length - 1] : null
442
+ );
443
+ let lastPrice = $derived(lastCandle?.close || 0);
444
+ let lastPriceY = $derived(priceScale(lastPrice));
445
+
446
+ function getGridDashArray(): string {
447
+ switch (gridStyle) {
448
+ case 'solid':
449
+ return 'none';
450
+ case 'dotted':
451
+ return '1, 3';
452
+ case 'dashed':
453
+ default:
454
+ return '4, 4';
455
+ }
456
+ }
457
+
212
458
  function handleWheel(event: WheelEvent): void {
213
459
  if (!enableZoom || data.length === 0) return;
460
+
461
+ if (!event.ctrlKey && !event.metaKey) return;
462
+
214
463
  event.preventDefault();
215
464
 
216
465
  const delta = event.deltaY;
@@ -224,6 +473,10 @@
224
473
 
225
474
  const oldEnd = scrollOffset + visibleCandles;
226
475
  scrollOffset = Math.max(0, Math.min(oldEnd - visibleCandles, data.length - visibleCandles));
476
+
477
+ if (onRangeChange) {
478
+ onRangeChange(scrollOffset, scrollOffset + visibleCandles);
479
+ }
227
480
  }
228
481
 
229
482
  function handleMouseDown(event: MouseEvent): void {
@@ -234,22 +487,48 @@
234
487
  }
235
488
 
236
489
  function handleMouseMove(event: MouseEvent): void {
237
- if (!isDragging || !enableScroll) return;
490
+ if (isDragging && enableScroll) {
491
+ const deltaX = event.clientX - dragStartX;
492
+ const candlesPerPixel = visibleCandles / innerWidth;
493
+ const candlesDelta = Math.round(-deltaX * candlesPerPixel);
494
+
495
+ scrollOffset = Math.max(
496
+ 0,
497
+ Math.min(dragStartOffset + candlesDelta, data.length - visibleCandles)
498
+ );
499
+ }
238
500
 
239
- const deltaX = event.clientX - dragStartX;
240
- const candlesPerPixel = visibleCandles / innerWidth;
241
- const candlesDelta = Math.round(-deltaX * candlesPerPixel);
501
+ if (showCrosshair && svgEl && containerEl) {
502
+ const rect = svgEl.getBoundingClientRect();
503
+ const x = event.clientX - rect.left - margin.left;
504
+ const y = event.clientY - rect.top - margin.top;
242
505
 
243
- scrollOffset = Math.max(
244
- 0,
245
- Math.min(dragStartOffset + candlesDelta, data.length - visibleCandles)
246
- );
506
+ if (x >= 0 && x <= innerWidth && y >= 0 && y <= priceHeight) {
507
+ crosshairPosition = { x, y };
508
+ isCrosshairActive = true;
509
+ } else {
510
+ isCrosshairActive;
511
+ }
512
+ }
247
513
  }
248
514
 
249
515
  function handleMouseUp(): void {
250
516
  isDragging = false;
251
517
  }
252
518
 
519
+ function handleMouseLeave(): void {
520
+ isCrosshairActive = false;
521
+ }
522
+
523
+ function handleDoubleClick(): void {
524
+ visibleCandles = Math.min(initialVisibleCandles, data.length);
525
+ scrollOffset = Math.max(0, data.length - visibleCandles);
526
+
527
+ if (onRangeChange) {
528
+ onRangeChange(scrollOffset, scrollOffset + visibleCandles);
529
+ }
530
+ }
531
+
253
532
  let touchStartX = $state(0);
254
533
  let touchStartOffset = $state(0);
255
534
 
@@ -274,14 +553,19 @@
274
553
  }
275
554
 
276
555
  let tooltipData = $state<CandleData | null>(null);
556
+ let tooltipChange = $state<{ value: number; percent: number }>({ value: 0, percent: 0 });
277
557
  let tooltipPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 });
278
558
  let isTooltipActive = $state(false);
279
559
 
280
- function handleCandleHover(candle: CandleData, event: MouseEvent): void {
560
+ function handleCandleHover(candle: CandleData, index: number, event: MouseEvent): void {
281
561
  const target = event.target as SVGElement;
282
562
  const rect = target.getBoundingClientRect();
283
563
 
564
+ const change = candle.close - candle.open;
565
+ const changePercent = candle.open !== 0 ? (change / candle.open) * 100 : 0;
566
+
284
567
  tooltipData = candle;
568
+ tooltipChange = { value: change, percent: changePercent };
285
569
  tooltipPosition = {
286
570
  x: rect.right + 8,
287
571
  y: rect.top + rect.height / 2
@@ -298,16 +582,30 @@
298
582
  }, 100);
299
583
  }
300
584
 
301
- let scrollPercentage = $derived.by(() => {
302
- if (data.length === 0 || visibleCandles >= data.length) return 100;
303
- return (visibleCandles / data.length) * 100;
304
- });
585
+ function handleCandleClick(candle: CandleData, index: number): void {
586
+ if (onClick) {
587
+ onClick(candle, index);
588
+ }
589
+ }
590
+
591
+ let crosshairPrice = $derived(crosshairPosition ? priceScale.invert(crosshairPosition.y) : 0);
305
592
 
306
- let scrollPosition = $derived.by(() => {
307
- if (data.length === 0 || visibleCandles >= data.length) return 0;
308
- return (scrollOffset / (data.length - visibleCandles)) * (100 - scrollPercentage);
593
+ let crosshairCandleIndex = $derived.by(() => {
594
+ if (!crosshairPosition || processedData.length === 0) return -1;
595
+ const bandwidth = xScale.bandwidth();
596
+ for (let i = 0; i < processedData.length; i++) {
597
+ const candleX = xScale(processedData[i].date);
598
+ if (crosshairPosition.x >= candleX && crosshairPosition.x < candleX + bandwidth) {
599
+ return i;
600
+ }
601
+ }
602
+ return -1;
309
603
  });
310
604
 
605
+ let crosshairCandle = $derived(
606
+ crosshairCandleIndex >= 0 ? processedData[crosshairCandleIndex] : null
607
+ );
608
+
311
609
  onMount(() => {
312
610
  if (data.length > 0) {
313
611
  visibleCandles = Math.min(initialVisibleCandles, data.length);
@@ -321,6 +619,13 @@
321
619
  }
322
620
  };
323
621
 
622
+ const handleScroll = () => {
623
+ if (isTooltipActive) {
624
+ isTooltipActive = false;
625
+ tooltipData = null;
626
+ }
627
+ };
628
+
324
629
  updateSize();
325
630
  const resizeObserver = new ResizeObserver(updateSize);
326
631
  if (containerEl) {
@@ -329,11 +634,13 @@
329
634
 
330
635
  window.addEventListener('mousemove', handleMouseMove);
331
636
  window.addEventListener('mouseup', handleMouseUp);
637
+ window.addEventListener('scroll', handleScroll, true);
332
638
 
333
639
  return () => {
334
640
  resizeObserver.disconnect();
335
641
  window.removeEventListener('mousemove', handleMouseMove);
336
642
  window.removeEventListener('mouseup', handleMouseUp);
643
+ window.removeEventListener('scroll', handleScroll, true);
337
644
  };
338
645
  });
339
646
 
@@ -343,11 +650,73 @@
343
650
  scrollOffset = Math.max(0, Math.min(scrollOffset, data.length - visibleCandles));
344
651
  }
345
652
  });
653
+
654
+ function getIndicatorColor(ind: Indicator): string {
655
+ const colorMap: Record<Color, string> = {
656
+ primary: 'var(--color-primary)',
657
+ secondary: 'var(--color-secondary)',
658
+ success: 'var(--color-success)',
659
+ info: 'var(--color-info)',
660
+ warning: 'var(--color-warning)',
661
+ danger: 'var(--color-danger)',
662
+ muted: 'var(--color-muted)'
663
+ };
664
+ return colorMap[ind.color || 'primary'];
665
+ }
666
+
667
+ function createIndicatorPath(values: (number | null)[]): string {
668
+ const points: string[] = [];
669
+ let started;
670
+
671
+ for (let i = 0; i < values.length; i++) {
672
+ const val = values[i];
673
+ if (val === null) continue;
674
+
675
+ const x = xScale(processedData[i].date) + xScale.bandwidth() / 2;
676
+ const y = priceScale(val);
677
+
678
+ if (!started) {
679
+ points.push(`M ${x} ${y}`);
680
+ started = true;
681
+ } else {
682
+ points.push(`L ${x} ${y}`);
683
+ }
684
+ }
685
+
686
+ return points.join(' ');
687
+ }
688
+
689
+ function createAreaPath(): string {
690
+ if (processedData.length === 0) return '';
691
+
692
+ const points: string[] = [];
693
+ const firstX = xScale(processedData[0].date) + xScale.bandwidth() / 2;
694
+
695
+ points.push(`M ${firstX} ${priceHeight}`);
696
+
697
+ for (let i = 0; i < processedData.length; i++) {
698
+ const x = xScale(processedData[i].date) + xScale.bandwidth() / 2;
699
+ const y = priceScale(processedData[i].close);
700
+ points.push(`L ${x} ${y}`);
701
+ }
702
+
703
+ const lastX = xScale(processedData[processedData.length - 1].date) + xScale.bandwidth() / 2;
704
+ points.push(`L ${lastX} ${priceHeight}`);
705
+ points.push('Z');
706
+
707
+ return points.join(' ');
708
+ }
346
709
  </script>
347
710
 
348
- <div bind:this={containerEl} class={cn('candlestick-chart-container', rootClass)}>
711
+ <div
712
+ bind:this={containerEl}
713
+ class={cn('candlestick-chart-container', `is-${size}`, `theme-${theme}`, rootClass)}
714
+ style={theme !== 'default' ? `background: ${themeColors[theme].bg};` : ''}
715
+ >
349
716
  {#if loading}
350
- <div class="candlestick-chart-loading"></div>
717
+ <div class="candlestick-chart-loading">
718
+ <div class="candlestick-chart-loading-skeleton"></div>
719
+ </div>
351
720
  {:else if empty || data.length === 0}
352
721
  <div class="candlestick-chart-empty">
353
722
  <svg
@@ -366,21 +735,23 @@
366
735
  <span>{emptyText}</span>
367
736
  </div>
368
737
  {:else}
369
- <div class={cn('candlestick-chart', chartClass)}>
738
+ <div class={cn('candlestick-chart', `is-${size}`, chartClass)}>
370
739
  <!-- svelte-ignore a11y_no_static_element_interactions -->
371
740
  <svg
372
741
  bind:this={svgEl}
373
742
  class="candlestick-chart-svg"
374
743
  class:candlestick-chart-dragging={isDragging}
375
744
  {width}
376
- {height}
745
+ height={chartHeight}
377
746
  onwheel={handleWheel}
378
747
  onmousedown={handleMouseDown}
379
748
  ontouchstart={handleTouchStart}
380
749
  ontouchmove={handleTouchMove}
750
+ onmouseleave={handleMouseLeave}
751
+ ondblclick={handleDoubleClick}
381
752
  >
382
753
  <g transform="translate({margin.left}, {margin.top})">
383
- {#if showGrid}
754
+ {#if showGrid && gridStyle !== 'none'}
384
755
  <g class="candlestick-chart-grid">
385
756
  {#each grid as line}
386
757
  <line
@@ -389,6 +760,7 @@
389
760
  x2={line.x}
390
761
  y2={line.y}
391
762
  class="candlestick-chart-grid-line"
763
+ stroke-dasharray={getGridDashArray()}
392
764
  />
393
765
  {/each}
394
766
  </g>
@@ -404,7 +776,7 @@
404
776
  />
405
777
  <line x1={0} y1={0} x2={0} y2={priceHeight} class="candlestick-chart-axis-line" />
406
778
 
407
- {#if showGrid}
779
+ {#if showYAxisLabels && showGrid}
408
780
  {#each grid as line}
409
781
  <text
410
782
  x={-10}
@@ -418,17 +790,19 @@
418
790
  {/each}
419
791
  {/if}
420
792
 
421
- {#each visibleData.filter((_, i) => i % Math.ceil(visibleData.length / 6) === 0) as d}
422
- <text
423
- x={xScale(d.date) + xScale.bandwidth() / 2}
424
- y={priceHeight + 20}
425
- class="candlestick-chart-axis-label"
426
- text-anchor="middle"
427
- font-size="10"
428
- >
429
- {formatDate(d.date)}
430
- </text>
431
- {/each}
793
+ {#if showXAxisLabels}
794
+ {#each processedData.filter((_, i) => i % Math.ceil(processedData.length / 6) === 0) as d}
795
+ <text
796
+ x={xScale(d.date) + xScale.bandwidth() / 2}
797
+ y={priceHeight + 20}
798
+ class="candlestick-chart-axis-label"
799
+ text-anchor="middle"
800
+ font-size="10"
801
+ >
802
+ {formatDate(d.date)}
803
+ </text>
804
+ {/each}
805
+ {/if}
432
806
 
433
807
  {#if showVolume}
434
808
  <line
@@ -441,48 +815,199 @@
441
815
  {/if}
442
816
  </g>
443
817
 
444
- {#each visibleData as d}
445
- {@const x = xScale(d.date) + xScale.bandwidth() / 2}
446
- {@const open = priceScale(d.open)}
447
- {@const close = priceScale(d.close)}
448
- {@const high = priceScale(d.high)}
449
- {@const low = priceScale(d.low)}
450
- {@const isBullish = d.close >= d.open}
451
- {@const bodyTop = Math.min(open, close)}
452
- {@const bodyHeight = Math.abs(open - close)}
453
- {@const colorClass = isBullish ? bullishColor : bearishColor}
454
-
455
- <!-- svelte-ignore a11y_no_static_element_interactions -->
456
- <g onmouseenter={(e) => handleCandleHover(d, e)} onmouseleave={handleCandleLeave}>
457
- <line
458
- x1={x}
459
- y1={high}
460
- x2={x}
461
- y2={low}
462
- class="candlestick-chart-wick is-{colorClass}"
463
- style="stroke-width: {wickWidth};"
464
- />
818
+ {#if candleStyle === 'area'}
819
+ <path d={createAreaPath()} class="candlestick-chart-area is-{bullishColor}" />
820
+ <path
821
+ d={createIndicatorPath(processedData.map((d) => d.close))}
822
+ class="candlestick-chart-line is-{bullishColor}"
823
+ fill="none"
824
+ stroke-width="2"
825
+ />
826
+ {:else if candleStyle === 'line'}
827
+ <path
828
+ d={createIndicatorPath(processedData.map((d) => d.close))}
829
+ class="candlestick-chart-line is-{bullishColor}"
830
+ fill="none"
831
+ stroke-width="2"
832
+ />
833
+ {:else}
834
+ {#each processedData as d, i}
835
+ {@const x = xScale(d.date) + xScale.bandwidth() / 2}
836
+ {@const open = priceScale(d.open)}
837
+ {@const close = priceScale(d.close)}
838
+ {@const high = priceScale(d.high)}
839
+ {@const low = priceScale(d.low)}
840
+ {@const isBullish = d.close >= d.open}
841
+ {@const bodyTop = Math.min(open, close)}
842
+ {@const bodyHeight = Math.abs(open - close)}
843
+ {@const colorClass = isBullish ? bullishColor : bearishColor}
844
+ {@const isHollow = candleStyle === 'hollow' && isBullish}
845
+
846
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
847
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
848
+ <g
849
+ onmouseenter={(e) => handleCandleHover(d, i, e)}
850
+ onmouseleave={handleCandleLeave}
851
+ onclick={() => handleCandleClick(d, i)}
852
+ >
853
+ <line
854
+ x1={x}
855
+ y1={high}
856
+ x2={x}
857
+ y2={low}
858
+ class="candlestick-chart-wick is-{colorClass}"
859
+ style="stroke-width: {wickWidth};"
860
+ />
465
861
 
466
- <rect
467
- x={x - candleWidth / 2}
468
- y={bodyTop}
469
- width={candleWidth}
470
- height={Math.max(bodyHeight, 1)}
471
- class="candlestick-chart-candle is-{colorClass}"
862
+ <rect
863
+ x={x - candleWidth / 2}
864
+ y={bodyTop}
865
+ width={candleWidth}
866
+ height={Math.max(bodyHeight, 1)}
867
+ class={cn(
868
+ 'candlestick-chart-candle',
869
+ `is-${colorClass}`,
870
+ isHollow && 'is-hollow'
871
+ )}
872
+ />
873
+ </g>
874
+
875
+ {#if showVolume && d.volume && volumeScale}
876
+ {@const volumeBarHeight = volumeScale(volumeDomain[1] - d.volume)}
877
+ <rect
878
+ x={xScale(d.date)}
879
+ y={volumeTop + volumeBarHeight}
880
+ width={xScale.bandwidth()}
881
+ height={volumeHeight - volumeBarHeight}
882
+ class="candlestick-chart-volume is-{colorClass}"
883
+ />
884
+ {/if}
885
+ {/each}
886
+ {/if}
887
+
888
+ {#each indicatorData as ind}
889
+ {#if 'values' in ind && ind.values}
890
+ <path
891
+ d={createIndicatorPath(ind.values)}
892
+ class="candlestick-chart-indicator"
893
+ fill="none"
894
+ stroke={getIndicatorColor(ind)}
895
+ stroke-width="1.5"
472
896
  />
473
- </g>
897
+ {/if}
898
+ {#if 'bands' in ind && ind.bands}
899
+ <path
900
+ d={createIndicatorPath(ind.bands.upper)}
901
+ class="candlestick-chart-indicator candlestick-chart-bollinger-band"
902
+ fill="none"
903
+ stroke={getIndicatorColor(ind)}
904
+ stroke-width="1"
905
+ opacity="0.5"
906
+ />
907
+ <path
908
+ d={createIndicatorPath(ind.bands.middle)}
909
+ class="candlestick-chart-indicator"
910
+ fill="none"
911
+ stroke={getIndicatorColor(ind)}
912
+ stroke-width="1.5"
913
+ />
914
+ <path
915
+ d={createIndicatorPath(ind.bands.lower)}
916
+ class="candlestick-chart-indicator candlestick-chart-bollinger-band"
917
+ fill="none"
918
+ stroke={getIndicatorColor(ind)}
919
+ stroke-width="1"
920
+ opacity="0.5"
921
+ />
922
+ {/if}
923
+ {/each}
474
924
 
475
- {#if showVolume && d.volume && volumeScale}
476
- {@const volumeBarHeight = volumeScale(volumeDomain[1] - d.volume)}
925
+ {#if showLastPrice && lastCandle}
926
+ <line
927
+ x1={0}
928
+ y1={lastPriceY}
929
+ x2={innerWidth}
930
+ y2={lastPriceY}
931
+ class="candlestick-chart-last-price is-{lastCandle.close >= lastCandle.open
932
+ ? bullishColor
933
+ : bearishColor}"
934
+ stroke-dasharray="4, 2"
935
+ />
936
+ <rect
937
+ x={innerWidth + 2}
938
+ y={lastPriceY - 10}
939
+ width={50}
940
+ height={20}
941
+ rx="3"
942
+ class="candlestick-chart-last-price-label-bg is-{lastCandle.close >= lastCandle.open
943
+ ? bullishColor
944
+ : bearishColor}"
945
+ />
946
+ <text
947
+ x={innerWidth + 27}
948
+ y={lastPriceY + 4}
949
+ class="candlestick-chart-last-price-label"
950
+ text-anchor="middle"
951
+ font-size="10"
952
+ >
953
+ {formatPrice(lastPrice)}
954
+ </text>
955
+ {/if}
956
+
957
+ {#if showCrosshair && isCrosshairActive && crosshairPosition}
958
+ <line
959
+ x1={0}
960
+ y1={crosshairPosition.y}
961
+ x2={innerWidth}
962
+ y2={crosshairPosition.y}
963
+ class="candlestick-chart-crosshair"
964
+ />
965
+ <line
966
+ x1={crosshairPosition.x}
967
+ y1={0}
968
+ x2={crosshairPosition.x}
969
+ y2={priceHeight}
970
+ class="candlestick-chart-crosshair"
971
+ />
972
+
973
+ <rect
974
+ x={-55}
975
+ y={crosshairPosition.y - 10}
976
+ width={50}
977
+ height={20}
978
+ rx="3"
979
+ class="candlestick-chart-crosshair-label-bg"
980
+ />
981
+ <text
982
+ x={-30}
983
+ y={crosshairPosition.y + 4}
984
+ class="candlestick-chart-crosshair-label"
985
+ text-anchor="middle"
986
+ font-size="10"
987
+ >
988
+ {formatPrice(crosshairPrice)}
989
+ </text>
990
+
991
+ {#if crosshairCandle}
477
992
  <rect
478
- x={xScale(d.date)}
479
- y={volumeTop + volumeBarHeight}
480
- width={xScale.bandwidth()}
481
- height={volumeHeight - volumeBarHeight}
482
- class="candlestick-chart-volume is-{colorClass}"
993
+ x={xScale(crosshairCandle.date) + xScale.bandwidth() / 2 - 30}
994
+ y={priceHeight + 5}
995
+ width={60}
996
+ height={16}
997
+ rx="3"
998
+ class="candlestick-chart-crosshair-label-bg"
483
999
  />
1000
+ <text
1001
+ x={xScale(crosshairCandle.date) + xScale.bandwidth() / 2}
1002
+ y={priceHeight + 16}
1003
+ class="candlestick-chart-crosshair-label"
1004
+ text-anchor="middle"
1005
+ font-size="9"
1006
+ >
1007
+ {formatDate(crosshairCandle.date)}
1008
+ </text>
484
1009
  {/if}
485
- {/each}
1010
+ {/if}
486
1011
  </g>
487
1012
  </svg>
488
1013
  </div>
@@ -492,35 +1017,58 @@
492
1017
  class="candlestick-chart-tooltip"
493
1018
  style="top: {tooltipPosition.y}px; left: {tooltipPosition.x}px;"
494
1019
  >
495
- <div class="candlestick-chart-tooltip-content">
496
- <div class="candlestick-chart-tooltip-title">{formatDate(tooltipData.date)}</div>
497
- <div class="candlestick-chart-tooltip-rows">
498
- <div class="candlestick-chart-tooltip-row">
499
- <span class="candlestick-chart-tooltip-label">Open:</span>
500
- <span class="candlestick-chart-tooltip-value">{formatPrice(tooltipData.open)}</span>
501
- </div>
502
- <div class="candlestick-chart-tooltip-row">
503
- <span class="candlestick-chart-tooltip-label">High:</span>
504
- <span class="candlestick-chart-tooltip-value">{formatPrice(tooltipData.high)}</span>
505
- </div>
506
- <div class="candlestick-chart-tooltip-row">
507
- <span class="candlestick-chart-tooltip-label">Low:</span>
508
- <span class="candlestick-chart-tooltip-value">{formatPrice(tooltipData.low)}</span>
509
- </div>
510
- <div class="candlestick-chart-tooltip-row">
511
- <span class="candlestick-chart-tooltip-label">Close:</span>
512
- <span class="candlestick-chart-tooltip-value">{formatPrice(tooltipData.close)}</span>
513
- </div>
514
- {#if showVolume && tooltipData.volume}
515
- <div class="candlestick-chart-tooltip-row candlestick-chart-tooltip-volume">
516
- <span class="candlestick-chart-tooltip-label">Volume:</span>
517
- <span class="candlestick-chart-tooltip-value"
518
- >{formatNumber(tooltipData.volume)}</span
1020
+ {#if tooltipContent}
1021
+ {@render tooltipContent({
1022
+ candle: tooltipData,
1023
+ change: tooltipChange.value,
1024
+ changePercent: tooltipChange.percent
1025
+ })}
1026
+ {:else}
1027
+ <div class="candlestick-chart-tooltip-content">
1028
+ <div class="candlestick-chart-tooltip-title">{formatDateTime(tooltipData.date)}</div>
1029
+ <div class="candlestick-chart-tooltip-rows">
1030
+ <div class="candlestick-chart-tooltip-row">
1031
+ <span class="candlestick-chart-tooltip-label">Open:</span>
1032
+ <span class="candlestick-chart-tooltip-value">{formatPrice(tooltipData.open)}</span>
1033
+ </div>
1034
+ <div class="candlestick-chart-tooltip-row">
1035
+ <span class="candlestick-chart-tooltip-label">High:</span>
1036
+ <span class="candlestick-chart-tooltip-value">{formatPrice(tooltipData.high)}</span>
1037
+ </div>
1038
+ <div class="candlestick-chart-tooltip-row">
1039
+ <span class="candlestick-chart-tooltip-label">Low:</span>
1040
+ <span class="candlestick-chart-tooltip-value">{formatPrice(tooltipData.low)}</span>
1041
+ </div>
1042
+ <div class="candlestick-chart-tooltip-row">
1043
+ <span class="candlestick-chart-tooltip-label">Close:</span>
1044
+ <span class="candlestick-chart-tooltip-value">{formatPrice(tooltipData.close)}</span
519
1045
  >
520
1046
  </div>
521
- {/if}
1047
+ <div class="candlestick-chart-tooltip-row candlestick-chart-tooltip-change">
1048
+ <span class="candlestick-chart-tooltip-label">Change:</span>
1049
+ <span
1050
+ class={cn(
1051
+ 'candlestick-chart-tooltip-value',
1052
+ tooltipChange.value >= 0 ? 'is-bullish' : 'is-bearish'
1053
+ )}
1054
+ >
1055
+ {tooltipChange.value >= 0 ? '+' : ''}{formatPrice(tooltipChange.value)} ({tooltipChange.percent >=
1056
+ 0
1057
+ ? '+'
1058
+ : ''}{tooltipChange.percent.toFixed(2)}%)
1059
+ </span>
1060
+ </div>
1061
+ {#if showVolume && tooltipData.volume}
1062
+ <div class="candlestick-chart-tooltip-row candlestick-chart-tooltip-volume">
1063
+ <span class="candlestick-chart-tooltip-label">Volume:</span>
1064
+ <span class="candlestick-chart-tooltip-value"
1065
+ >{formatNumber(tooltipData.volume)}</span
1066
+ >
1067
+ </div>
1068
+ {/if}
1069
+ </div>
522
1070
  </div>
523
- </div>
1071
+ {/if}
524
1072
  </div>
525
1073
  {/if}
526
1074
  {/if}