@juspay/svelte-ui-components 2.85.0 → 2.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,16 @@
5
5
  import Axis from '../_chart/Axis.svelte';
6
6
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
7
7
  import Legend from '../_chart/Legend.svelte';
8
- import { createLinearScale, niceLinearDomain } from '../_chart/scales';
9
- import { computeChartDimensions, computeStackedValues } from '../_chart/geometry';
8
+ import { createLinearScale, niceLinearDomain, computeLinearTicks } from '../_chart/scales';
9
+ import { computeAutoLayout, computeStackedValues } from '../_chart/geometry';
10
10
  import { linePath, areaPath } from '../_chart/paths';
11
11
  import { getColor } from '../_chart/colors';
12
- import { formatNumber, formatPercent } from '../_chart/format';
12
+ import { formatNumber, formatPercent, defaultTickFormat } from '../_chart/format';
13
+ import { measureText } from '../_chart/measure';
14
+ import { resolvePointLabels } from '../_chart/labels';
15
+ import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
13
16
  import type { LegendItem, Point } from '../_chart/types';
17
+ import { DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
14
18
 
15
19
  // ── Props ──────────────────────────────────────────────────────
16
20
 
@@ -42,8 +46,11 @@
42
46
  xTickFormat,
43
47
  yTickFormat,
44
48
  aspectRatio = 16 / 9,
49
+ minHeight = 0,
50
+ maxHeight = DEFAULT_CHART_MAX_HEIGHT,
45
51
  tooltipSnippet,
46
52
  empty,
53
+ tooltipPortal = false,
47
54
  onpointhover,
48
55
  onpointclick,
49
56
  testId,
@@ -53,6 +60,7 @@
53
60
  // ── State ──────────────────────────────────────────────────────
54
61
 
55
62
  let containerEl: HTMLDivElement | null = $state(null);
63
+ let plotEl: HTMLDivElement | null = $state(null);
56
64
  let chartWidth = $state(0);
57
65
  let chartHeight = $state(0);
58
66
  let hovered = $state<{ si: number; pi: number } | null>(null);
@@ -65,7 +73,6 @@
65
73
 
66
74
  // ── Layout ─────────────────────────────────────────────────────
67
75
 
68
- let dims = $derived(computeChartDimensions(chartWidth, chartHeight));
69
76
  let isStacked = $derived(stacked || stackNormalize);
70
77
  let isEmpty = $derived(series.length === 0 || series.every((s) => s.data.length === 0));
71
78
 
@@ -116,6 +123,24 @@
116
123
  return niceLinearDomain(Math.min(0, ...allY), Math.max(...allY));
117
124
  });
118
125
 
126
+ let yTickCount = $derived(Math.max(2, Math.min(6, Math.floor(chartHeight / 70))));
127
+ let xTickCount = $derived(Math.max(2, Math.min(8, Math.floor(chartWidth / 90))));
128
+
129
+ let layout = $derived.by(() => {
130
+ const yFmt = yTickFormat ?? defaultTickFormat;
131
+ const xFmt = xTickFormat ?? defaultTickFormat;
132
+ return computeAutoLayout({
133
+ width: chartWidth,
134
+ height: chartHeight,
135
+ yTickLabels: showYAxis ? computeLinearTicks(yExtent, yTickCount).map((t) => yFmt(t)) : [],
136
+ xTickLabels: showXAxis ? computeLinearTicks(xExtent, xTickCount).map((t) => xFmt(t)) : [],
137
+ hasYAxisLabel: Boolean(yAxisLabel) && showYAxis,
138
+ hasXAxisLabel: Boolean(xAxisLabel) && showXAxis,
139
+ base: { top: 20, right: 20, bottom: showXAxis ? 40 : 8, left: showYAxis ? 50 : 20 }
140
+ });
141
+ });
142
+ let dims = $derived(layout);
143
+
119
144
  let xScale = $derived(createLinearScale(xExtent, [0, dims.innerWidth]));
120
145
  let yScale = $derived(createLinearScale(yExtent, [dims.innerHeight, 0]));
121
146
 
@@ -206,15 +231,40 @@
206
231
  };
207
232
  });
208
233
 
234
+ // ── Point labels ───────────────────────────────────────────────
235
+
236
+ function pointDisplayValue(si: number, pi: number): string {
237
+ const y = series[si]?.data[pi]?.y ?? 0;
238
+ if (stackNormalize) {
239
+ const columnTotal = series.reduce((sum, s) => sum + Math.max(0, s.data[pi]?.y ?? 0), 0);
240
+ return formatPercent(Math.max(0, y), columnTotal);
241
+ }
242
+ return formatNumber(y);
243
+ }
244
+
245
+ let pointLabelPlacements = $derived.by(() => {
246
+ if (!showValues) {
247
+ return [];
248
+ }
249
+ const plot = { width: dims.innerWidth, height: dims.innerHeight };
250
+ const font = { size: 11 };
251
+ return areas.map((area, si) =>
252
+ resolvePointLabels({
253
+ points: area.points,
254
+ labels: series[si].data.map((d, pi) => measureText(pointDisplayValue(si, pi), font)),
255
+ plot
256
+ })
257
+ );
258
+ });
259
+
209
260
  // ── Interactions ───────────────────────────────────────────────
210
261
 
211
- function trackMouse(e: MouseEvent) {
212
- if (containerEl === null) {
213
- return;
262
+ function trackMouse(e: PointerEvent) {
263
+ const position = pointerPositionIn(plotEl, e);
264
+ if (position !== null) {
265
+ mouseX = position.x;
266
+ mouseY = position.y;
214
267
  }
215
- const rect = containerEl.getBoundingClientRect();
216
- mouseX = e.clientX - rect.left;
217
- mouseY = e.clientY - rect.top;
218
268
  }
219
269
 
220
270
  function findNearest(plotX: number, plotY: number): { si: number; pi: number } | null {
@@ -253,7 +303,7 @@
253
303
  return { si: nearestSi, pi: nearestPi };
254
304
  }
255
305
 
256
- function handleOverlayMove(e: MouseEvent) {
306
+ function handleOverlayMove(e: PointerEvent) {
257
307
  trackMouse(e);
258
308
  const plotX = mouseX - dims.margin.left;
259
309
  const plotY = mouseY - dims.margin.top;
@@ -285,6 +335,15 @@
285
335
  onpointclick?.({ seriesIndex: hovered.si, pointIndex: hovered.pi, point });
286
336
  }
287
337
  }
338
+
339
+ // Touch taps have no pointerleave: dismiss when a pointerdown lands outside.
340
+ // eslint-disable-next-line no-restricted-syntax
341
+ $effect(() => {
342
+ if (hovered === null) {
343
+ return;
344
+ }
345
+ return dismissOnOutsidePointerDown(containerEl, handleLeave);
346
+ });
288
347
  </script>
289
348
 
290
349
  <div
@@ -299,134 +358,172 @@
299
358
  <Legend items={legendItems} position="top" />
300
359
  {/if}
301
360
 
302
- <ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
303
- {#if gradientFill}
304
- <!-- <defs> must be a direct child of <svg> (SVG root), not inside a transformed <g>.
361
+ <div class="chart-plot" bind:this={plotEl}>
362
+ <ChartContainer
363
+ bind:width={chartWidth}
364
+ bind:height={chartHeight}
365
+ {aspectRatio}
366
+ {minHeight}
367
+ {maxHeight}
368
+ >
369
+ {#if gradientFill}
370
+ <!-- <defs> must be a direct child of <svg> (SVG root), not inside a transformed <g>.
305
371
  gradientUnits="userSpaceOnUse" with y1/y2 in the inner coordinate space (0..innerHeight)
306
372
  correctly spans the full chart height regardless of how thin each band is. -->
307
- <defs>
308
- {#each areas as area, si (si)}
309
- <linearGradient
310
- id="area-grad-{uid}-{si}"
311
- x1="0"
312
- y1="0"
313
- x2="0"
314
- y2={dims.innerHeight}
315
- gradientUnits="userSpaceOnUse"
316
- >
317
- <!-- The gradient top stop is fillOpacity + 0.3 (clamped to 1), giving a richer
373
+ <defs>
374
+ {#each areas as area, si (si)}
375
+ <linearGradient
376
+ id="area-grad-{uid}-{si}"
377
+ x1="0"
378
+ y1="0"
379
+ x2="0"
380
+ y2={dims.innerHeight}
381
+ gradientUnits="userSpaceOnUse"
382
+ >
383
+ <!-- The gradient top stop is fillOpacity + 0.3 (clamped to 1), giving a richer
318
384
  anchor at the top that fades to transparent at the bottom. This intentionally
319
385
  exceeds the base fillOpacity so that gradient-fill areas appear more vivid
320
386
  than their solid-fill counterparts (where fill-opacity equals fillOpacity). -->
321
- <stop
322
- offset="0%"
323
- stop-color={area.color}
324
- stop-opacity={Math.min(
325
- (hovered?.si === si ? fillOpacity + 0.2 : fillOpacity) + 0.3,
326
- 1
327
- )}
328
- />
329
- <stop offset="100%" stop-color={area.color} stop-opacity={0} />
330
- </linearGradient>
331
- {/each}
332
- </defs>
333
- {/if}
334
- <g transform="translate({dims.margin.left}, {dims.margin.top})">
335
- {#if showYAxis}
336
- <Axis
337
- orientation="left"
338
- scale={yScale}
339
- {showGridlines}
340
- gridlineLength={dims.innerWidth}
341
- label={yAxisLabel}
342
- tickFormat={yTickFormat}
343
- />
344
- {/if}
345
- {#if showXAxis}
346
- <g transform="translate(0, {dims.innerHeight})">
347
- <Axis orientation="bottom" scale={xScale} label={xAxisLabel} tickFormat={xTickFormat} />
348
- </g>
387
+ <stop
388
+ offset="0%"
389
+ stop-color={area.color}
390
+ stop-opacity={Math.min(
391
+ (hovered?.si === si ? fillOpacity + 0.2 : fillOpacity) + 0.3,
392
+ 1
393
+ )}
394
+ />
395
+ <stop offset="100%" stop-color={area.color} stop-opacity={0} />
396
+ </linearGradient>
397
+ {/each}
398
+ </defs>
349
399
  {/if}
350
-
351
- {#each areas as area, si (si)}
352
- <path
353
- class="area-fill"
354
- class:dimmed={hovered !== null && hovered.si !== si}
355
- d={area.areaD}
356
- fill={gradientFill ? `url(#area-grad-${uid}-${si})` : area.color}
357
- fill-opacity={gradientFill ? 1 : hovered?.si === si ? fillOpacity + 0.2 : fillOpacity}
358
- />
359
- {#if showLine}
360
- <path
361
- class="area-line"
362
- class:dimmed={hovered !== null && hovered.si !== si}
363
- d={area.lineD}
364
- stroke={area.color}
365
- stroke-width={strokeWidth}
366
- fill="none"
400
+ <g transform="translate({dims.margin.left}, {dims.margin.top})">
401
+ {#if showYAxis}
402
+ <Axis
403
+ orientation="left"
404
+ scale={yScale}
405
+ tickCount={yTickCount}
406
+ {showGridlines}
407
+ gridlineLength={dims.innerWidth}
408
+ label={yAxisLabel}
409
+ tickFormat={yTickFormat}
367
410
  />
368
411
  {/if}
369
- {#if area.points.length === 1 && !showDots}
370
- <circle
371
- class="single-point"
412
+ {#if showXAxis}
413
+ <g transform="translate(0, {dims.innerHeight})">
414
+ <Axis
415
+ orientation="bottom"
416
+ scale={xScale}
417
+ tickCount={xTickCount}
418
+ rotateTicks={layout.xRotate}
419
+ tickEvery={layout.xEvery}
420
+ labelOffset={layout.xLabelOffset}
421
+ label={xAxisLabel}
422
+ tickFormat={xTickFormat}
423
+ />
424
+ </g>
425
+ {/if}
426
+
427
+ {#each areas as area, si (si)}
428
+ <path
429
+ class="area-fill"
372
430
  class:dimmed={hovered !== null && hovered.si !== si}
373
- cx={area.points[0].x}
374
- cy={area.points[0].y}
375
- r={6}
376
- fill={area.color}
431
+ d={area.areaD}
432
+ fill={gradientFill ? `url(#area-grad-${uid}-${si})` : area.color}
433
+ fill-opacity={gradientFill ? 1 : hovered?.si === si ? fillOpacity + 0.2 : fillOpacity}
377
434
  />
378
- {/if}
379
- {#if showDots}
380
- {#each area.points as point, pi (pi)}
435
+ {#if showLine}
436
+ <path
437
+ class="area-line"
438
+ class:dimmed={hovered !== null && hovered.si !== si}
439
+ d={area.lineD}
440
+ stroke={area.color}
441
+ stroke-width={strokeWidth}
442
+ fill="none"
443
+ />
444
+ {/if}
445
+ {#if area.points.length === 1 && !showDots}
381
446
  <circle
382
- class="dot"
383
- cx={point.x}
384
- cy={point.y}
385
- r={hovered?.si === si && hovered?.pi === pi ? 6 : 3}
447
+ class="single-point"
448
+ class:dimmed={hovered !== null && hovered.si !== si}
449
+ cx={area.points[0].x}
450
+ cy={area.points[0].y}
451
+ r={6}
386
452
  fill={area.color}
387
453
  />
388
- {/each}
389
- {/if}
390
- {#if showValues}
391
- {#each area.points as point, pi (pi)}
392
- <text
393
- class="point-value"
394
- x={point.x}
395
- y={point.y - 8}
396
- text-anchor="middle"
397
- dominant-baseline="auto">{formatNumber(series[si].data[pi].y)}</text
398
- >
399
- {/each}
454
+ {/if}
455
+ {#if showDots}
456
+ {#each area.points as point, pi (pi)}
457
+ <circle
458
+ class="dot"
459
+ cx={point.x}
460
+ cy={point.y}
461
+ r={hovered?.si === si && hovered?.pi === pi ? 6 : 3}
462
+ fill={area.color}
463
+ />
464
+ {/each}
465
+ {/if}
466
+ {#if showValues && pointLabelPlacements[si]}
467
+ {#each area.points as _point, pi (pi)}
468
+ {@const pl = pointLabelPlacements[si][pi]}
469
+ {#if pl?.visible}
470
+ <text
471
+ class="point-value"
472
+ x={pl.x}
473
+ y={pl.y}
474
+ text-anchor="middle"
475
+ dominant-baseline={pl.dominantBaseline}>{pointDisplayValue(si, pi)}</text
476
+ >
477
+ {/if}
478
+ {/each}
479
+ {/if}
480
+ {/each}
481
+
482
+ {#if hoverLineX !== null}
483
+ <line class="hover-line" x1={hoverLineX} x2={hoverLineX} y1={0} y2={dims.innerHeight} />
400
484
  {/if}
401
- {/each}
402
485
 
403
- {#if hoverLineX !== null}
404
- <line class="hover-line" x1={hoverLineX} x2={hoverLineX} y1={0} y2={dims.innerHeight} />
405
- {/if}
486
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
487
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
488
+ <rect
489
+ class="hover-overlay"
490
+ x={0}
491
+ y={0}
492
+ width={dims.innerWidth}
493
+ height={dims.innerHeight}
494
+ fill="transparent"
495
+ onpointermove={handleOverlayMove}
496
+ onpointerleave={handleLeave}
497
+ onclick={handleClick}
498
+ />
499
+ </g>
500
+ </ChartContainer>
406
501
 
407
- <!-- svelte-ignore a11y_no_static_element_interactions -->
408
- <!-- svelte-ignore a11y_click_events_have_key_events -->
409
- <rect
410
- class="hover-overlay"
411
- x={0}
412
- y={0}
413
- width={dims.innerWidth}
414
- height={dims.innerHeight}
415
- fill="transparent"
416
- onmousemove={handleOverlayMove}
417
- onmouseleave={handleLeave}
418
- onclick={handleClick}
502
+ {#if typeof tooltipSnippet === 'function'}
503
+ <ChartTooltip
504
+ data={tooltipData}
505
+ {mouseX}
506
+ {mouseY}
507
+ portal={tooltipPortal}
508
+ originEl={plotEl}
509
+ unstyled
510
+ >
511
+ {#snippet content()}
512
+ {#if tooltipContext !== null}
513
+ {@render tooltipSnippet(tooltipContext)}
514
+ {/if}
515
+ {/snippet}
516
+ </ChartTooltip>
517
+ {:else}
518
+ <ChartTooltip
519
+ data={tooltipData}
520
+ {mouseX}
521
+ {mouseY}
522
+ portal={tooltipPortal}
523
+ originEl={plotEl}
419
524
  />
420
- </g>
421
- </ChartContainer>
422
-
423
- {#if typeof tooltipSnippet === 'function' && tooltipContext !== null}
424
- <div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
425
- {@render tooltipSnippet(tooltipContext)}
426
- </div>
427
- {:else}
428
- <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
429
- {/if}
525
+ {/if}
526
+ </div>
430
527
  {/if}
431
528
  </div>
432
529
 
@@ -435,6 +532,9 @@
435
532
  width: 100%;
436
533
  position: relative;
437
534
  }
535
+ .chart-plot {
536
+ position: relative;
537
+ }
438
538
  .area-fill {
439
539
  transition:
440
540
  fill-opacity var(--chart-transition-duration, 0.2s) ease,
@@ -463,18 +563,21 @@
463
563
  transition:
464
564
  r var(--chart-transition-duration, 0.2s) ease,
465
565
  opacity var(--chart-transition-duration, 0.2s) ease;
466
- stroke: var(--chart-background, #fff);
566
+ stroke: var(--chart-dot-stroke, light-dark(#fff, #111827));
467
567
  stroke-width: 2;
468
568
  pointer-events: none;
469
569
  }
470
570
  .point-value {
471
- fill: var(--areachart-value-color, #333);
571
+ fill: var(--areachart-value-color, light-dark(#333, #e5e7eb));
472
572
  font-size: var(--areachart-value-font-size, 11px);
473
573
  font-family: var(--chart-font-family, inherit);
474
574
  pointer-events: none;
475
575
  }
476
576
  .hover-line {
477
- stroke: var(--areachart-hover-line-color, var(--linechart-hover-line-color, #ccc));
577
+ stroke: var(
578
+ --areachart-hover-line-color,
579
+ var(--linechart-hover-line-color, light-dark(#ccc, #4b5563))
580
+ );
478
581
  stroke-width: 1;
479
582
  stroke-dasharray: var(--areachart-hover-line-dash, var(--linechart-hover-line-dash, 4 4));
480
583
  pointer-events: none;
@@ -482,14 +585,9 @@
482
585
  .hover-overlay {
483
586
  cursor: crosshair;
484
587
  }
485
- .chart-tooltip-slot {
486
- position: absolute;
487
- z-index: 10;
488
- pointer-events: none;
489
- }
490
588
  .chart-empty {
491
589
  padding: var(--chart-empty-padding, 32px 24px);
492
- color: var(--chart-empty-color, #9ca3af);
590
+ color: var(--chart-empty-color, light-dark(#9ca3af, #6b7280));
493
591
  text-align: center;
494
592
  }
495
593
  </style>
@@ -44,8 +44,14 @@ export type OptionalAreaChartProperties = {
44
44
  xTickFormat?: (value: number | string) => string;
45
45
  yTickFormat?: (value: number | string) => string;
46
46
  aspectRatio?: number;
47
+ /** Minimum rendered height in px (parity with LineChart). */
48
+ minHeight?: number;
49
+ /** Maximum rendered height in px; defaults to DEFAULT_CHART_MAX_HEIGHT (420). */
50
+ maxHeight?: number;
47
51
  tooltipSnippet?: Snippet<[AreaChartTooltipContext]>;
48
52
  empty?: Snippet;
53
+ /** Render the tooltip into document.body so scroll/overflow ancestors never clip it. */
54
+ tooltipPortal?: boolean;
49
55
  testId?: string;
50
56
  classes?: string;
51
57
  };