@cfasim-ui/docs 0.4.13 → 0.4.15

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.
@@ -1,8 +1,8 @@
1
1
  # BarChart
2
2
 
3
- A responsive SVG bar chart supporting single, grouped, and stacked series in
4
- either vertical (column) or horizontal orientation. Shares axis, tooltip,
5
- menu, and CSV export wiring with [`LineChart`](./line-chart).
3
+ A responsive SVG bar chart supporting single, grouped, stacked, and overlay
4
+ series in either vertical (column) or horizontal orientation. Shares axis,
5
+ tooltip, menu, and CSV export wiring with [`LineChart`](./line-chart).
6
6
 
7
7
  ## Examples
8
8
 
@@ -100,6 +100,101 @@ menu, and CSV export wiring with [`LineChart`](./line-chart).
100
100
  </template>
101
101
  </ComponentDemo>
102
102
 
103
+ ### Overlay series
104
+
105
+ `layout="overlay"` draws each series at full group width from the shared
106
+ baseline, painting later series on top of earlier ones. Use it to compare
107
+ a backdrop value (e.g. target, capacity, prior period) against a shorter
108
+ foreground value sharing the same axis.
109
+
110
+ Series render in the order they appear in `series`, so put the taller
111
+ backdrop first and the shorter foreground after. Use `opacity`, a
112
+ distinct color, or a per-series `blendMode` (see below) if bars might
113
+ overlap fully.
114
+
115
+ <ComponentDemo>
116
+ <BarChart
117
+ :series="[
118
+ { data: [40, 40, 40, 40], legend: 'Target', color: '#d4d4d8' },
119
+ { data: [22, 35, 28, 38], legend: 'Actual', color: '#0057b7' },
120
+ ]"
121
+ :categories="['Q1', 'Q2', 'Q3', 'Q4']"
122
+ layout="overlay"
123
+ :height="220"
124
+ y-label="Doses (thousands)"
125
+ tooltip-trigger="hover"
126
+ />
127
+
128
+ <template #code>
129
+
130
+ ```vue
131
+ <BarChart
132
+ :series="[
133
+ { data: [40, 40, 40, 40], legend: 'Target', color: '#d4d4d8' },
134
+ { data: [22, 35, 28, 38], legend: 'Actual', color: '#0057b7' },
135
+ ]"
136
+ :categories="['Q1', 'Q2', 'Q3', 'Q4']"
137
+ layout="overlay"
138
+ :height="220"
139
+ y-label="Doses (thousands)"
140
+ tooltip-trigger="hover"
141
+ />
142
+ ```
143
+
144
+ </template>
145
+ </ComponentDemo>
146
+
147
+ ### Blend mode
148
+
149
+ Set `blendMode` on a series to apply a CSS `mix-blend-mode` to every
150
+ bar in that series. Useful with `layout="overlay"` (or just two
151
+ overlapping grouped bars) to combine colors instead of one obscuring
152
+ the other. Common picks: `"multiply"` (darkens light fills where bars
153
+ overlap), `"screen"` (lightens dark fills), `"difference"` (high
154
+ contrast).
155
+
156
+ <ComponentDemo>
157
+ <BarChart
158
+ :series="[
159
+ { data: [22, 35, 28, 38], legend: 'Treatment A', color: '#ef4444', blendMode: 'multiply' },
160
+ { data: [30, 24, 36, 32], legend: 'Treatment B', color: '#3b82f6', blendMode: 'multiply' },
161
+ ]"
162
+ :categories="['Q1', 'Q2', 'Q3', 'Q4']"
163
+ layout="overlay"
164
+ :height="220"
165
+ y-label="Cases avoided"
166
+ tooltip-trigger="hover"
167
+ />
168
+
169
+ <template #code>
170
+
171
+ ```vue
172
+ <BarChart
173
+ :series="[
174
+ {
175
+ data: [22, 35, 28, 38],
176
+ legend: 'Treatment A',
177
+ color: '#ef4444',
178
+ blendMode: 'multiply',
179
+ },
180
+ {
181
+ data: [30, 24, 36, 32],
182
+ legend: 'Treatment B',
183
+ color: '#3b82f6',
184
+ blendMode: 'multiply',
185
+ },
186
+ ]"
187
+ :categories="['Q1', 'Q2', 'Q3', 'Q4']"
188
+ layout="overlay"
189
+ :height="220"
190
+ y-label="Cases avoided"
191
+ tooltip-trigger="hover"
192
+ />
193
+ ```
194
+
195
+ </template>
196
+ </ComponentDemo>
197
+
103
198
  ### Horizontal orientation
104
199
 
105
200
  <ComponentDemo>
@@ -208,6 +303,54 @@ LineChart's `x-tick-format` (e.g. `"iso"`, `"month-year"`, an
208
303
  `Intl.DateTimeFormatOptions` object, or a `(ms, unit?) => string`
209
304
  function).
210
305
 
306
+ ### Crowded categorical labels
307
+
308
+ Non-date categorical labels are also thinned when they don't all fit:
309
+ the chart estimates label width from character count and font size,
310
+ then strides through the categories so adjacent labels never overlap.
311
+ Every bar still renders — only the tick labels are skipped.
312
+
313
+ <ComponentDemo>
314
+ <BarChart
315
+ data-testid="thinned-bar-chart"
316
+ :data="[3, 5, 8, 12, 18, 24, 31, 27, 22, 16, 11, 7, 4, 2]"
317
+ :categories="['2.11', '2.33', '2.56', '2.78', '3.00', '3.22', '3.44', '3.67', '3.89', '4.11', '4.33', '4.56', '4.78', '5.00']"
318
+ :width="380"
319
+ :height="220"
320
+ x-label="R₀"
321
+ y-label="Frequency"
322
+ />
323
+
324
+ <template #code>
325
+
326
+ ```vue
327
+ <BarChart
328
+ :data="[3, 5, 8, 12, 18, 24, 31, 27, 22, 16, 11, 7, 4, 2]"
329
+ :categories="[
330
+ '2.11',
331
+ '2.33',
332
+ '2.56',
333
+ '2.78',
334
+ '3.00',
335
+ '3.22',
336
+ '3.44',
337
+ '3.67',
338
+ '3.89',
339
+ '4.11',
340
+ '4.33',
341
+ '4.56',
342
+ '4.78',
343
+ '5.00',
344
+ ]"
345
+ :height="220"
346
+ x-label="R₀"
347
+ y-label="Frequency"
348
+ />
349
+ ```
350
+
351
+ </template>
352
+ </ComponentDemo>
353
+
211
354
  ### Logarithmic value axis
212
355
 
213
356
  Set `value-scale-type="log"` to switch the value axis to base-10 log
@@ -242,6 +385,80 @@ top still represents the sum.
242
385
  </template>
243
386
  </ComponentDemo>
244
387
 
388
+ ### Summary line overlay
389
+
390
+ Layer a curve (KDE, rolling mean, target) on top of the bars with
391
+ `summary-lines`. Each line scales to **its own value extent** —
392
+ independent of the bar axis — so a probability-density curve in
393
+ `[0, 0.02]` and a histogram in `[0, 500]` can share the same chart.
394
+
395
+ <ComponentDemo>
396
+ <BarChart
397
+ :data="[3, 5, 8, 12, 18, 24, 31, 27, 22, 16, 11, 7, 4, 2]"
398
+ :categories="['2.11', '2.33', '2.56', '2.78', '3.00', '3.22', '3.44', '3.67', '3.89', '4.11', '4.33', '4.56', '4.78', '5.00']"
399
+ :summary-lines="[
400
+ {
401
+ data: [0.05, 0.10, 0.20, 0.40, 0.75, 1.10, 1.32, 1.20, 0.92, 0.60, 0.32, 0.16, 0.07, 0.03],
402
+ color: '#ef4444',
403
+ strokeWidth: 2,
404
+ legend: 'KDE',
405
+ },
406
+ ]"
407
+ :width="380"
408
+ :height="240"
409
+ x-label="R₀"
410
+ y-label="Frequency"
411
+ />
412
+
413
+ <template #code>
414
+
415
+ ```vue
416
+ <BarChart
417
+ :data="[3, 5, 8, 12, 18, 24, 31, 27, 22, 16, 11, 7, 4, 2]"
418
+ :categories="[
419
+ '2.11',
420
+ '2.33',
421
+ '2.56',
422
+ '2.78',
423
+ '3.00',
424
+ '3.22',
425
+ '3.44',
426
+ '3.67',
427
+ '3.89',
428
+ '4.11',
429
+ '4.33',
430
+ '4.56',
431
+ '4.78',
432
+ '5.00',
433
+ ]"
434
+ :summary-lines="[
435
+ {
436
+ data: [
437
+ 0.05, 0.1, 0.2, 0.4, 0.75, 1.1, 1.32, 1.2, 0.92, 0.6, 0.32, 0.16, 0.07,
438
+ 0.03,
439
+ ],
440
+ color: '#ef4444',
441
+ strokeWidth: 2,
442
+ legend: 'KDE',
443
+ },
444
+ ]"
445
+ :height="240"
446
+ x-label="R₀"
447
+ y-label="Frequency"
448
+ />
449
+ ```
450
+
451
+ </template>
452
+ </ComponentDemo>
453
+
454
+ Each line accepts `color`, `strokeWidth`, `dashed`, `opacity`,
455
+ `blendMode`, `dots`, and `dotRadius` for styling — same vocabulary as
456
+ `LineChart` series (both extend `LineMarkStyle`). Override the line's
457
+ extent with `valueMin` / `valueMax`. Pass `x: number[]` (in
458
+ category-index space — `0` is the first category center, fractional
459
+ values land between) to plot at custom positions; useful when the
460
+ summary samples are denser or sparser than the histogram bins.
461
+
245
462
  ### Annotations
246
463
 
247
464
  Pin callouts to specific bars with `annotations`. `x` is the category
@@ -381,9 +598,10 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
381
598
  | `data` | `BarChartData` | No | — |
382
599
  | `y` | `BarChartData` | No | — |
383
600
  | `series` | `BarSeries[]` | No | — |
601
+ | `summaryLines` | `BarSummaryLine[]` | No | — |
384
602
  | `categories` | `readonly (string \| Date)[]` | No | — |
385
603
  | `orientation` | `"vertical" \| "horizontal"` | No | `"vertical"` |
386
- | `layout` | `"grouped" \| "stacked"` | No | `"grouped"` |
604
+ | `layout` | `"grouped" \| "stacked" \| "overlay"` | No | `"grouped"` |
387
605
  | `valueMin` | `number` | No | `0` |
388
606
  | `valueScaleType` | `"linear" \| "log"` | No | `"linear"` |
389
607
  | `valueTicks` | `number \| number[]` | No | — |
@@ -32,6 +32,8 @@ import {
32
32
  type ChartHoverPayload,
33
33
  type ChartTooltipBaseProps,
34
34
  type ChartTooltipValue,
35
+ type BlendMode,
36
+ type LineMarkStyle,
35
37
  } from "../_shared/index.js";
36
38
 
37
39
  export type BarChartData = ChartData;
@@ -42,6 +44,12 @@ export interface BarSeries {
42
44
  data?: BarChartData;
43
45
  color?: string;
44
46
  opacity?: number;
47
+ /**
48
+ * CSS `mix-blend-mode` applied to each bar in this series. Lets
49
+ * overlapping bars (e.g. `layout="overlay"`) combine their colors
50
+ * instead of one obscuring the other.
51
+ */
52
+ blendMode?: BlendMode;
45
53
  /** Label shown in the inline legend. */
46
54
  legend?: string;
47
55
  /**
@@ -56,6 +64,38 @@ export interface BarSeries {
56
64
  showInTooltip?: boolean;
57
65
  }
58
66
 
67
+ /**
68
+ * A line overlay drawn on top of the bars — e.g. a KDE over a histogram,
69
+ * a rolling mean, or any other summary curve. Each line scales to its
70
+ * own value extent (independent of the bars), so a probability-density
71
+ * curve in `[0, 0.02]` and a count histogram in `[0, 500]` can coexist.
72
+ *
73
+ * Sample the curve densely on the input side — `summaryLines` connects
74
+ * points with straight segments (matching matplotlib / seaborn `kde=True`).
75
+ *
76
+ * Shares visual styling (`color`, `strokeWidth`, `dashed`, `opacity`,
77
+ * `blendMode`, `dots`, `dotRadius`, `legend`, `showInLegend`) with
78
+ * `LineChart`'s `Series` via `LineMarkStyle`.
79
+ */
80
+ export interface BarSummaryLine extends LineMarkStyle {
81
+ /** Y values along the line. */
82
+ data: BarChartData;
83
+ /**
84
+ * X positions in category-index space (0 = first category center,
85
+ * `categories.length - 1` = last). Fractional values land between
86
+ * category centers. When omitted, points plot at successive category
87
+ * centers — one point per `data` entry.
88
+ */
89
+ x?: BarChartData;
90
+ /**
91
+ * Override the line's value-axis floor. When unset, defaults to the
92
+ * line's own min (clamped to 0 when all data is non-negative).
93
+ */
94
+ valueMin?: number;
95
+ /** Override the line's value-axis ceiling. Defaults to the line's own max. */
96
+ valueMax?: number;
97
+ }
98
+
59
99
  interface BarChartProps extends ChartCommonProps {
60
100
  /** Single-series values. Equivalent to `y`. */
61
101
  data?: BarChartData;
@@ -63,6 +103,12 @@ interface BarChartProps extends ChartCommonProps {
63
103
  y?: BarChartData;
64
104
  /** Multi-series mode. Each series has its own values. */
65
105
  series?: BarSeries[];
106
+ /**
107
+ * Line overlays drawn on top of the bars (e.g. a KDE, a rolling mean,
108
+ * or any summary curve). Each line scales to its own value extent —
109
+ * unrelated to the bar value axis. See `BarSummaryLine`.
110
+ */
111
+ summaryLines?: BarSummaryLine[];
66
112
  /**
67
113
  * Category labels for the categorical axis. Length should match the
68
114
  * longest series. When omitted, indices (0, 1, 2, ...) are used.
@@ -73,8 +119,14 @@ interface BarChartProps extends ChartCommonProps {
73
119
  categories?: readonly (string | Date)[];
74
120
  /** "vertical" (default, aka column) draws upright bars; "horizontal" draws sideways. */
75
121
  orientation?: "vertical" | "horizontal";
76
- /** "grouped" (default) places series side-by-side; "stacked" stacks them. */
77
- layout?: "grouped" | "stacked";
122
+ /**
123
+ * "grouped" (default) places series side-by-side; "stacked" stacks
124
+ * them; "overlay" draws each series at full group width from the
125
+ * shared baseline, painting later series on top of earlier ones.
126
+ * In overlay mode set `opacity` per series, or order series so the
127
+ * tallest renders first, so bars behind remain visible.
128
+ */
129
+ layout?: "grouped" | "stacked" | "overlay";
78
130
  /** Force the value axis to start at this value or lower (default 0). */
79
131
  valueMin?: number;
80
132
  /**
@@ -148,6 +200,7 @@ type ResolvedSeries = {
148
200
  data: BarChartData;
149
201
  color?: string;
150
202
  opacity?: number;
203
+ blendMode?: BlendMode;
151
204
  legend?: string;
152
205
  showInLegend?: boolean;
153
206
  showInTooltip?: boolean;
@@ -158,6 +211,7 @@ function resolveSeries(s: BarSeries): ResolvedSeries {
158
211
  data: s.y ?? s.data ?? EMPTY_DATA,
159
212
  color: s.color,
160
213
  opacity: s.opacity,
214
+ blendMode: s.blendMode,
161
215
  legend: s.legend,
162
216
  showInLegend: s.showInLegend,
163
217
  showInTooltip: s.showInTooltip,
@@ -334,6 +388,7 @@ interface BarRect {
334
388
  h: number;
335
389
  color: string;
336
390
  opacity: number;
391
+ blendMode?: BlendMode;
337
392
  value: number;
338
393
  categoryIndex: number;
339
394
  seriesIndex: number;
@@ -351,6 +406,7 @@ function makeBar(
351
406
  span: number,
352
407
  color: string,
353
408
  opacity: number,
409
+ blendMode: BlendMode | undefined,
354
410
  value: number,
355
411
  categoryIndex: number,
356
412
  seriesIndex: number,
@@ -365,6 +421,7 @@ function makeBar(
365
421
  h: size,
366
422
  color,
367
423
  opacity,
424
+ blendMode,
368
425
  value,
369
426
  categoryIndex,
370
427
  seriesIndex,
@@ -377,6 +434,7 @@ function makeBar(
377
434
  h: span,
378
435
  color,
379
436
  opacity,
437
+ blendMode,
380
438
  value,
381
439
  categoryIndex,
382
440
  seriesIndex,
@@ -414,6 +472,7 @@ const bars = computed<BarRect[]>(() => {
414
472
  group,
415
473
  series.color ?? defaultColor(s),
416
474
  series.opacity ?? 1,
475
+ series.blendMode,
417
476
  raw,
418
477
  i,
419
478
  s,
@@ -422,6 +481,26 @@ const bars = computed<BarRect[]>(() => {
422
481
  if (raw >= 0) posCursor = top;
423
482
  else negCursor = top;
424
483
  }
484
+ } else if (props.layout === "overlay") {
485
+ for (let s = 0; s < k; s++) {
486
+ const series = seriesList[s];
487
+ const raw = Number(series.data[i] ?? NaN);
488
+ if (!isFinite(raw)) continue;
489
+ out.push(
490
+ makeBar(
491
+ baseline,
492
+ valuePixel(raw),
493
+ groupStart,
494
+ group,
495
+ series.color ?? defaultColor(s),
496
+ series.opacity ?? 1,
497
+ series.blendMode,
498
+ raw,
499
+ i,
500
+ s,
501
+ ),
502
+ );
503
+ }
425
504
  } else {
426
505
  for (let s = 0; s < k; s++) {
427
506
  const series = seriesList[s];
@@ -436,6 +515,7 @@ const bars = computed<BarRect[]>(() => {
436
515
  bw,
437
516
  series.color ?? defaultColor(s),
438
517
  series.opacity ?? 1,
518
+ series.blendMode,
439
519
  raw,
440
520
  i,
441
521
  s,
@@ -447,6 +527,138 @@ const bars = computed<BarRect[]>(() => {
447
527
  return out;
448
528
  });
449
529
 
530
+ /**
531
+ * Per-line styling, resolved with defaults but without pixel work. Kept
532
+ * separate from `ResolvedSummaryLine` so the inline legend can depend on
533
+ * it without pulling in the chart's pixel layout (which depends on the
534
+ * legend row count — a circular reference otherwise).
535
+ */
536
+ type StyledSummaryLine = {
537
+ data: BarChartData;
538
+ x?: BarChartData;
539
+ color: string;
540
+ strokeWidth: number;
541
+ dashed: boolean;
542
+ opacity: number;
543
+ blendMode?: BlendMode;
544
+ dots: boolean;
545
+ dotRadius: number;
546
+ extent: { min: number; max: number };
547
+ legend?: string;
548
+ showInLegend: boolean;
549
+ };
550
+
551
+ type ResolvedSummaryLine = StyledSummaryLine & {
552
+ pathD: string;
553
+ points: { x: number; y: number }[];
554
+ };
555
+
556
+ /** Compute the line's own value extent. */
557
+ function computeLineExtent(line: BarSummaryLine): { min: number; max: number } {
558
+ let min = Infinity;
559
+ let max = -Infinity;
560
+ for (const v of line.data) {
561
+ const n = Number(v);
562
+ if (!isFinite(n)) continue;
563
+ if (n < min) min = n;
564
+ if (n > max) max = n;
565
+ }
566
+ if (!isFinite(min)) {
567
+ min = 0;
568
+ max = 1;
569
+ }
570
+ if (line.valueMin !== undefined) {
571
+ min = line.valueMin;
572
+ } else if (min > 0) {
573
+ // Default to a 0 floor for all-positive data so the line shape
574
+ // reads as "rises from zero" rather than filling the full plot.
575
+ min = 0;
576
+ }
577
+ if (line.valueMax !== undefined) max = line.valueMax;
578
+ if (min === max) max = min + 1;
579
+ return { min, max };
580
+ }
581
+
582
+ /**
583
+ * Project a single line point (x in category-index space, y in the
584
+ * line's own value space) to pixels. Orientation flips which axis is
585
+ * categorical vs value.
586
+ */
587
+ function projectLinePoint(
588
+ x: number,
589
+ y: number,
590
+ extent: { min: number; max: number },
591
+ ): { x: number; y: number } {
592
+ const base = isVertical.value ? padding.value.left : padding.value.top;
593
+ const categoricalPx = base + (x + 0.5) * slotSize.value;
594
+ const span = extent.max - extent.min || 1;
595
+ const frac = (y - extent.min) / span;
596
+ if (isVertical.value) {
597
+ return {
598
+ x: categoricalPx,
599
+ y: padding.value.top + innerH.value - frac * innerH.value,
600
+ };
601
+ }
602
+ return {
603
+ x: padding.value.left + frac * innerW.value,
604
+ y: categoricalPx,
605
+ };
606
+ }
607
+
608
+ const summaryLinesStyled = computed<StyledSummaryLine[]>(() => {
609
+ const lines = props.summaryLines;
610
+ if (!lines || lines.length === 0) return [];
611
+ return lines.map((line, i): StyledSummaryLine => {
612
+ return {
613
+ data: line.data ?? EMPTY_DATA,
614
+ x: line.x,
615
+ color: line.color ?? defaultLineColor(i),
616
+ strokeWidth: line.strokeWidth ?? 2,
617
+ dashed: line.dashed ?? false,
618
+ opacity: line.opacity ?? 1,
619
+ blendMode: line.blendMode,
620
+ dots: line.dots ?? false,
621
+ dotRadius: line.dotRadius ?? (line.strokeWidth ?? 2) + 1,
622
+ extent: computeLineExtent(line),
623
+ legend: line.legend,
624
+ showInLegend: line.showInLegend !== false,
625
+ };
626
+ });
627
+ });
628
+
629
+ const summaryLinesResolved = computed<ResolvedSummaryLine[]>(() => {
630
+ if (slotSize.value === 0) return [];
631
+ return summaryLinesStyled.value.map((line): ResolvedSummaryLine => {
632
+ const points: { x: number; y: number }[] = [];
633
+ let d = "";
634
+ let inSeg = false;
635
+ for (let j = 0; j < line.data.length; j++) {
636
+ const yv = Number(line.data[j]);
637
+ const xv = line.x ? Number(line.x[j]) : j;
638
+ if (!isFinite(yv) || !isFinite(xv)) {
639
+ inSeg = false;
640
+ continue;
641
+ }
642
+ const p = projectLinePoint(xv, yv, line.extent);
643
+ points.push(p);
644
+ d += inSeg ? `L${p.x},${p.y}` : `M${p.x},${p.y}`;
645
+ inSeg = true;
646
+ }
647
+ return { ...line, pathD: d, points };
648
+ });
649
+ });
650
+
651
+ const DEFAULT_LINE_COLORS = [
652
+ "var(--color-fg-1, #111)",
653
+ "var(--color-danger, #ef4444)",
654
+ "var(--color-success, #10b981)",
655
+ "var(--color-info, #6366f1)",
656
+ ];
657
+
658
+ function defaultLineColor(i: number): string {
659
+ return DEFAULT_LINE_COLORS[i % DEFAULT_LINE_COLORS.length];
660
+ }
661
+
450
662
  const DEFAULT_COLORS = [
451
663
  "var(--color-primary, #3b82f6)",
452
664
  "var(--color-accent, #f59e0b)",
@@ -547,10 +759,29 @@ const categoryTickItems = computed<CategoryTickItem[]>(() => {
547
759
  const out: CategoryTickItem[] = [];
548
760
  const fmt = (label: string, i: number) =>
549
761
  props.categoryFormat ? props.categoryFormat(label, i) : label;
762
+ const formatted = new Array<string>(n);
763
+ let maxLen = 0;
550
764
  for (let i = 0; i < n; i++) {
765
+ formatted[i] = fmt(categoryLabels.value[i], i);
766
+ if (formatted[i].length > maxLen) maxLen = formatted[i].length;
767
+ }
768
+ // Skip labels when slots are too narrow (or short) to fit them
769
+ // without overlap. Vertical orientation measures horizontal label
770
+ // width (≈ chars × fontSize × 0.6); horizontal orientation just
771
+ // measures stacked line height (≈ fontSize × 1.2).
772
+ const fontSize = props.tickLabelStyle?.fontSize ?? TICK_LABEL_FONT_SIZE;
773
+ const minGap = 8;
774
+ const needPerLabel = isVertical.value
775
+ ? maxLen * fontSize * 0.6 + minGap
776
+ : fontSize * 1.2 + minGap;
777
+ const stride =
778
+ slotSize.value > 0
779
+ ? Math.max(1, Math.ceil(needPerLabel / slotSize.value))
780
+ : 1;
781
+ for (let i = 0; i < n; i += stride) {
551
782
  const center = slotStart(i) + slotSize.value / 2;
552
783
  out.push({
553
- label: fmt(categoryLabels.value[i], i),
784
+ label: formatted[i],
554
785
  pos: center,
555
786
  anchor: "middle",
556
787
  });
@@ -561,13 +792,28 @@ const categoryTickItems = computed<CategoryTickItem[]>(() => {
561
792
  interface InlineLegendItem {
562
793
  label: string;
563
794
  color: string;
795
+ kind: "bar" | "line";
796
+ dashed?: boolean;
564
797
  }
565
798
 
566
799
  const inlineLegendItems = computed<InlineLegendItem[]>(() => {
567
800
  const items: InlineLegendItem[] = [];
568
801
  allSeries.value.forEach((s, i) => {
569
802
  if (!s.legend || s.showInLegend === false) return;
570
- items.push({ label: s.legend, color: s.color ?? defaultColor(i) });
803
+ items.push({
804
+ label: s.legend,
805
+ color: s.color ?? defaultColor(i),
806
+ kind: "bar",
807
+ });
808
+ });
809
+ summaryLinesStyled.value.forEach((line) => {
810
+ if (!line.legend || !line.showInLegend) return;
811
+ items.push({
812
+ label: line.legend,
813
+ color: line.color,
814
+ kind: "line",
815
+ dashed: line.dashed,
816
+ });
571
817
  });
572
818
  return items;
573
819
  });
@@ -805,12 +1051,23 @@ const positionedLegendItems = computed(() => {
805
1051
  <g v-if="positionedLegendItems.length > 0">
806
1052
  <template v-for="(item, i) in positionedLegendItems" :key="'ileg' + i">
807
1053
  <rect
1054
+ v-if="item.kind === 'bar'"
808
1055
  :x="item.x"
809
1056
  :y="item.y - 5"
810
1057
  width="12"
811
1058
  height="10"
812
1059
  :fill="item.color"
813
1060
  />
1061
+ <line
1062
+ v-else
1063
+ :x1="item.x"
1064
+ :y1="item.y"
1065
+ :x2="item.x + 12"
1066
+ :y2="item.y"
1067
+ :stroke="item.color"
1068
+ stroke-width="2"
1069
+ :stroke-dasharray="item.dashed ? '4 2' : undefined"
1070
+ />
814
1071
  <text
815
1072
  :x="item.x + 18"
816
1073
  :y="item.y + 4"
@@ -969,7 +1226,38 @@ const positionedLegendItems = computed(() => {
969
1226
  :height="bar.h"
970
1227
  :fill="bar.color"
971
1228
  :fill-opacity="bar.opacity"
1229
+ :style="bar.blendMode ? { mixBlendMode: bar.blendMode } : undefined"
972
1230
  />
1231
+ <!-- summary lines (drawn above bars, below annotations) -->
1232
+ <template v-for="(line, i) in summaryLinesResolved" :key="'sl' + i">
1233
+ <path
1234
+ v-if="line.pathD"
1235
+ data-testid="summary-line"
1236
+ :d="line.pathD"
1237
+ fill="none"
1238
+ :stroke="line.color"
1239
+ :stroke-width="line.strokeWidth"
1240
+ :stroke-opacity="line.opacity"
1241
+ :stroke-dasharray="line.dashed ? '6 3' : undefined"
1242
+ stroke-linecap="round"
1243
+ stroke-linejoin="round"
1244
+ :style="line.blendMode ? { mixBlendMode: line.blendMode } : undefined"
1245
+ />
1246
+ <template v-if="line.dots">
1247
+ <circle
1248
+ v-for="(pt, j) in line.points"
1249
+ :key="'sld' + i + '-' + j"
1250
+ :cx="pt.x"
1251
+ :cy="pt.y"
1252
+ :r="line.dotRadius"
1253
+ :fill="line.color"
1254
+ :fill-opacity="line.opacity"
1255
+ :style="
1256
+ line.blendMode ? { mixBlendMode: line.blendMode } : undefined
1257
+ "
1258
+ />
1259
+ </template>
1260
+ </template>
973
1261
  <!-- Tooltip: interaction overlay -->
974
1262
  <rect
975
1263
  v-if="hasTooltipSlot"
@@ -478,6 +478,66 @@ busy background — the outline keeps each series visually distinct.
478
478
  </template>
479
479
  </ComponentDemo>
480
480
 
481
+ ### Blend mode
482
+
483
+ Set `blendMode` on a `Series` to apply a CSS `mix-blend-mode` to that
484
+ series' line (and dots, if enabled). Useful when two thick or
485
+ high-contrast lines overlap — `"multiply"` darkens the crossing point
486
+ on light backgrounds, `"screen"` lightens on dark, `"difference"`
487
+ inverts. The white `outline`, if any, is unaffected.
488
+
489
+ <ComponentDemo>
490
+ <LineChart
491
+ :series="[
492
+ {
493
+ data: Array.from({ length: 30 }, (_, t) => 20 + 15 * Math.sin(t / 4)),
494
+ color: '#ef4444',
495
+ strokeWidth: 3,
496
+ blendMode: 'multiply',
497
+ legend: 'Strain A',
498
+ },
499
+ {
500
+ data: Array.from({ length: 30 }, (_, t) => 20 + 15 * Math.cos(t / 4)),
501
+ color: '#3b82f6',
502
+ strokeWidth: 3,
503
+ blendMode: 'multiply',
504
+ legend: 'Strain B',
505
+ },
506
+ ]"
507
+ :height="240"
508
+ x-label="Day"
509
+ y-label="Incidence"
510
+ />
511
+
512
+ <template #code>
513
+
514
+ ```vue
515
+ <LineChart
516
+ :series="[
517
+ {
518
+ data: strainA,
519
+ color: '#ef4444',
520
+ strokeWidth: 3,
521
+ blendMode: 'multiply',
522
+ legend: 'Strain A',
523
+ },
524
+ {
525
+ data: strainB,
526
+ color: '#3b82f6',
527
+ strokeWidth: 3,
528
+ blendMode: 'multiply',
529
+ legend: 'Strain B',
530
+ },
531
+ ]"
532
+ :height="240"
533
+ x-label="Day"
534
+ y-label="Incidence"
535
+ />
536
+ ```
537
+
538
+ </template>
539
+ </ComponentDemo>
540
+
481
541
  ### Grid lines
482
542
 
483
543
  <ComponentDemo>
@@ -35,6 +35,8 @@ import {
35
35
  type ChartHoverPayload,
36
36
  type ChartTooltipBaseProps,
37
37
  type ChartTooltipValue,
38
+ type BlendMode,
39
+ type LineMarkStyle,
38
40
  } from "../_shared/index.js";
39
41
 
40
42
  /**
@@ -52,7 +54,13 @@ export type LineChartData = ChartData;
52
54
  */
53
55
  export type LineChartXInput = LineChartData | readonly (string | Date)[];
54
56
 
55
- export interface Series {
57
+ /**
58
+ * A single line/dot series. Inherits visual styling
59
+ * (`color`, `strokeWidth`, `dashed`, `opacity`, `blendMode`, `dots`,
60
+ * `dotRadius`, `legend`, `showInLegend`) from `LineMarkStyle`, shared
61
+ * with `BarChart`'s `BarSummaryLine`.
62
+ */
63
+ export interface Series extends LineMarkStyle {
56
64
  /**
57
65
  * Y-values. One of `y` or `data` must be supplied; `y` wins if both
58
66
  * are set.
@@ -67,12 +75,11 @@ export interface Series {
67
75
  * date strings or `Date` objects to enable date-axis mode.
68
76
  */
69
77
  x?: LineChartXInput;
70
- color?: string;
71
- dashed?: boolean;
72
- strokeWidth?: number;
73
- opacity?: number;
78
+ /** Overrides `opacity` for the line stroke only. */
74
79
  lineOpacity?: number;
80
+ /** Overrides `opacity` for the dots only. */
75
81
  dotOpacity?: number;
82
+ /** Draw the line. Default true. */
76
83
  line?: boolean;
77
84
  /**
78
85
  * Draw a page-colored stroke behind the line so it stays visually
@@ -80,17 +87,8 @@ export interface Series {
80
87
  * effect when `line` is `false`.
81
88
  */
82
89
  outline?: boolean;
83
- dots?: boolean;
84
- dotRadius?: number;
85
90
  dotFill?: string;
86
91
  dotStroke?: string;
87
- /** Label shown in the inline legend */
88
- legend?: string;
89
- /**
90
- * Whether this series appears in the inline legend. Defaults to true.
91
- * Has no effect when `legend` is unset (no legend entry to begin with).
92
- */
93
- showInLegend?: boolean;
94
92
  /**
95
93
  * Whether this series contributes a value to the tooltip and shows a
96
94
  * hover dot. Defaults to true. The series line/dots are still drawn.
@@ -1208,6 +1206,7 @@ const positionedLegendItems = computed(() => {
1208
1206
  :stroke-width="s.strokeWidth ?? 1.5"
1209
1207
  :stroke-opacity="s.lineOpacity ?? s.opacity ?? lineOpacity"
1210
1208
  :stroke-dasharray="s.dashed ? '6 3' : undefined"
1209
+ :style="s.blendMode ? { mixBlendMode: s.blendMode } : undefined"
1211
1210
  />
1212
1211
  <template v-if="s.dots">
1213
1212
  <circle
@@ -1219,6 +1218,7 @@ const positionedLegendItems = computed(() => {
1219
1218
  :fill="s.dotFill ?? s.color ?? 'currentColor'"
1220
1219
  :fill-opacity="s.dotOpacity ?? s.opacity ?? lineOpacity"
1221
1220
  :stroke="s.dotStroke ?? 'none'"
1221
+ :style="s.blendMode ? { mixBlendMode: s.blendMode } : undefined"
1222
1222
  />
1223
1223
  </template>
1224
1224
  </template>
@@ -35,6 +35,59 @@ export interface LabelStyle {
35
35
  fontWeight?: number | string;
36
36
  }
37
37
 
38
+ /**
39
+ * CSS `mix-blend-mode` values usable on chart series. Drives how a
40
+ * series' pixels combine with what's painted behind them. Useful for
41
+ * overlapping series (e.g. `"multiply"` darkens light fills where bars
42
+ * overlap; `"screen"` lightens dark fills).
43
+ */
44
+ export type BlendMode =
45
+ | "normal"
46
+ | "multiply"
47
+ | "screen"
48
+ | "overlay"
49
+ | "darken"
50
+ | "lighten"
51
+ | "color-dodge"
52
+ | "color-burn"
53
+ | "hard-light"
54
+ | "soft-light"
55
+ | "difference"
56
+ | "exclusion"
57
+ | "hue"
58
+ | "saturation"
59
+ | "color"
60
+ | "luminosity";
61
+
62
+ /**
63
+ * Visual styling shared by anything drawn as "a stroked line with
64
+ * optional dots" — used by `LineChart`'s `Series` and `BarChart`'s
65
+ * `BarSummaryLine`. Chart-specific data (y/x arrays, scaling overrides,
66
+ * tooltip/outline behavior) lives on the extending type.
67
+ */
68
+ export interface LineMarkStyle {
69
+ color?: string;
70
+ strokeWidth?: number;
71
+ dashed?: boolean;
72
+ opacity?: number;
73
+ /**
74
+ * CSS `mix-blend-mode` applied to the line and dots. Lets overlapping
75
+ * marks combine their colors instead of one obscuring the other.
76
+ */
77
+ blendMode?: BlendMode;
78
+ /** Draw a dot at each sample point. Default false. */
79
+ dots?: boolean;
80
+ /** Radius of each dot in pixels. */
81
+ dotRadius?: number;
82
+ /** Label shown in the inline legend. */
83
+ legend?: string;
84
+ /**
85
+ * Whether this mark appears in the inline legend. Defaults to true.
86
+ * Has no effect when `legend` is unset.
87
+ */
88
+ showInLegend?: boolean;
89
+ }
90
+
38
91
  /**
39
92
  * Props common to every cartesian chart component. Anything specific to
40
93
  * the chart type (series shape, layout, value-axis details) lives on the
@@ -51,6 +51,8 @@ export type {
51
51
  ChartTooltipBaseProps,
52
52
  TitleStyle,
53
53
  LabelStyle,
54
+ BlendMode,
55
+ LineMarkStyle,
54
56
  } from "./chartProps.js";
55
57
  export {
56
58
  parseDate,
package/charts/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  default as BarChart,
12
12
  type BarChartData,
13
13
  type BarSeries,
14
+ type BarSummaryLine,
14
15
  } from "./BarChart/BarChart.vue";
15
16
  export {
16
17
  default as ChoroplethMap,
@@ -33,3 +34,4 @@ export {
33
34
  type ColumnWidth,
34
35
  } from "./DataTable/DataTable.vue";
35
36
  export type { ChartAnnotation } from "./_shared/annotations.js";
37
+ export type { BlendMode, LineMarkStyle } from "./_shared/chartProps.js";
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.13",
2
+ "version": "0.4.15",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
@@ -149,6 +149,7 @@
149
149
  "categorical",
150
150
  "grouped",
151
151
  "stacked",
152
+ "overlay",
152
153
  "vertical",
153
154
  "horizontal",
154
155
  "svg"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {