@cfasim-ui/docs 0.3.18 → 0.4.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.
@@ -1,9 +1,18 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch, onMounted, onUnmounted } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import ChartMenu from "../ChartMenu/ChartMenu.vue";
4
- import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
5
- import { saveSvg, savePng, downloadCsv } from "../ChartMenu/download.js";
6
- import { placeTooltip } from "../tooltip-position.js";
4
+ import {
5
+ snap,
6
+ formatTick,
7
+ computeTickValues,
8
+ seriesToCsv,
9
+ useChartSize,
10
+ useChartTooltip,
11
+ useChartMenu,
12
+ useChartPadding,
13
+ INLINE_LEGEND_HEIGHT,
14
+ type ChartData,
15
+ } from "../_shared/index.js";
7
16
 
8
17
  /**
9
18
  * Numeric input accepted by the chart. `number[]` and any standard numeric
@@ -11,17 +20,7 @@ import { placeTooltip } from "../tooltip-position.js";
11
20
  * `ModelOutput.column('x')` (e.g. a `Float64Array`) can be passed directly
12
21
  * without copying into a plain array.
13
22
  */
14
- export type LineChartData =
15
- | readonly number[]
16
- | Float64Array
17
- | Float32Array
18
- | Int32Array
19
- | Uint32Array
20
- | Int16Array
21
- | Uint16Array
22
- | Int8Array
23
- | Uint8Array
24
- | Uint8ClampedArray;
23
+ export type LineChartData = ChartData;
25
24
 
26
25
  export interface Series {
27
26
  /**
@@ -129,6 +128,11 @@ const props = withDefaults(
129
128
  xTickFormat?: (value: number, index: number) => string;
130
129
  /** Formatter for y-axis tick labels. Receives the raw numeric value. */
131
130
  yTickFormat?: (value: number) => string;
131
+ /**
132
+ * Formatter for numeric values shown in the default tooltip. Receives
133
+ * the raw value. Defaults to the same tick formatter used for axes.
134
+ */
135
+ tooltipValueFormat?: (value: number) => string;
132
136
  /**
133
137
  * @deprecated Use `xTickFormat` (e.g. `(_, i) => labels[i]`) together
134
138
  * with `xTicks` for explicit control. Still honored for tooltip x-labels
@@ -139,8 +143,12 @@ const props = withDefaults(
139
143
  menu?: boolean | string;
140
144
  xGrid?: boolean;
141
145
  yGrid?: boolean;
142
- /** Custom per-index data passed to the tooltip slot */
143
- tooltipData?: unknown[];
146
+ /**
147
+ * Custom per-index data passed to the tooltip slot. Accepts a plain
148
+ * array or any `ArrayLike` (e.g. a typed array column from a
149
+ * `ModelOutput`).
150
+ */
151
+ tooltipData?: ArrayLike<unknown>;
144
152
  /** Tooltip activation mode. Default: 'hover' */
145
153
  tooltipTrigger?: "hover" | "click";
146
154
  /**
@@ -180,64 +188,30 @@ defineSlots<{
180
188
  }): unknown;
181
189
  }>();
182
190
 
183
- const containerRef = ref<HTMLElement | null>(null);
184
- const svgRef = ref<SVGSVGElement | null>(null);
185
- const measuredWidth = ref(0);
186
- let observer: ResizeObserver | null = null;
187
- let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
188
-
189
- onMounted(() => {
190
- if (containerRef.value) {
191
- measuredWidth.value = containerRef.value.clientWidth;
192
- observer = new ResizeObserver((entries) => {
193
- const entry = entries[0];
194
- if (!entry) return;
195
- if (props.debounce) {
196
- if (resizeTimeout) clearTimeout(resizeTimeout);
197
- resizeTimeout = setTimeout(() => {
198
- measuredWidth.value = entry.contentRect.width;
199
- }, props.debounce);
200
- } else {
201
- measuredWidth.value = entry.contentRect.width;
202
- }
203
- });
204
- observer.observe(containerRef.value);
205
- }
206
- });
207
-
208
- onUnmounted(() => {
209
- observer?.disconnect();
210
- if (resizeTimeout) clearTimeout(resizeTimeout);
191
+ const { containerRef, measuredWidth } = useChartSize({
192
+ debounce: () => props.debounce,
211
193
  });
212
194
 
213
195
  const width = computed(() => props.width ?? (measuredWidth.value || 400));
214
196
  const height = computed(() => props.height ?? 200);
215
197
 
216
- const INLINE_LEGEND_HEIGHT = 20;
217
-
218
198
  const hasInlineLegend = computed(
219
199
  () =>
220
200
  allSeries.value.some((s) => s.legend) ||
221
201
  props.areaSections?.some(
222
202
  (s) => s.legend === "inline" && (s.label || s.description),
223
- ),
203
+ ) ||
204
+ false,
224
205
  );
225
206
 
226
- const padding = computed(() => ({
227
- top:
228
- (props.title ? 30 : 10) +
229
- (hasInlineLegend.value ? INLINE_LEGEND_HEIGHT : 0),
230
- right: 10,
231
- bottom: props.xLabel ? 46 : 30,
232
- left: props.yLabel ? 66 : 50,
233
- }));
234
-
235
- const innerW = computed(
236
- () => width.value - padding.value.left - padding.value.right,
237
- );
238
- const innerH = computed(
239
- () => height.value - padding.value.top - padding.value.bottom,
240
- );
207
+ const { padding, innerW, innerH } = useChartPadding({
208
+ title: () => props.title,
209
+ xLabel: () => props.xLabel,
210
+ yLabel: () => props.yLabel,
211
+ hasInlineLegend: () => hasInlineLegend.value,
212
+ width: () => width.value,
213
+ height: () => height.value,
214
+ });
241
215
 
242
216
  /**
243
217
  * Internal series shape where `data` (y-values) is always resolved.
@@ -251,6 +225,12 @@ function resolveSeries(s: Series): ResolvedSeries {
251
225
  return { ...s, data: s.y ?? s.data ?? EMPTY_DATA };
252
226
  }
253
227
 
228
+ function formatTooltipValue(v: number): string {
229
+ if (props.tooltipValueFormat) return props.tooltipValueFormat(v);
230
+ if (props.yTickFormat) return props.yTickFormat(v);
231
+ return formatTick(v);
232
+ }
233
+
254
234
  const allSeries = computed<ResolvedSeries[]>(() => {
255
235
  if (props.series && props.series.length > 0)
256
236
  return props.series.map(resolveSeries);
@@ -607,47 +587,6 @@ const sectionLabelBaseY = computed(
607
587
  SECTION_LABEL_TOP_MARGIN,
608
588
  );
609
589
 
610
- function niceStep(range: number, targetTicks: number): number {
611
- const rough = range / targetTicks;
612
- const mag = Math.pow(10, Math.floor(Math.log10(rough)));
613
- const norm = rough / mag;
614
- let step: number;
615
- if (norm <= 1.5) step = 1;
616
- else if (norm <= 3) step = 2;
617
- else if (norm <= 7) step = 5;
618
- else step = 10;
619
- return step * mag;
620
- }
621
-
622
- /** Round to nearest half-pixel so 1px SVG strokes stay sharp. */
623
- function snap(v: number): number {
624
- return Math.round(v) + 0.5;
625
- }
626
-
627
- const numFmt = new Intl.NumberFormat();
628
- function formatTick(v: number): string {
629
- if (Math.abs(v) >= 1000) return numFmt.format(v);
630
- if (Number.isInteger(v)) return v.toString();
631
- return v.toFixed(1);
632
- }
633
-
634
- /** Generate interval-spaced values in [min, max], inclusive. */
635
- function intervalValues(min: number, max: number, step: number): number[] {
636
- if (!(step > 0) || !isFinite(step)) return [];
637
- const out: number[] = [];
638
- const start = Math.ceil(min / step) * step;
639
- // Cap iteration to avoid runaway loops from pathological inputs.
640
- const maxIterations = 1000;
641
- for (
642
- let i = 0, v = start;
643
- v <= max + 1e-9 && i < maxIterations;
644
- i++, v = start + i * step
645
- ) {
646
- out.push(v);
647
- }
648
- return out;
649
- }
650
-
651
590
  const yTickItems = computed(() => {
652
591
  const { min, max } = extent.value;
653
592
  const toY = (v: number) =>
@@ -663,15 +602,12 @@ const yTickItems = computed(() => {
663
602
  return [{ value: fmt(min), y: snap(padding.value.top + innerH.value / 2) }];
664
603
  }
665
604
 
666
- let values: number[];
667
- if (Array.isArray(props.yTicks)) {
668
- values = props.yTicks.filter((v) => v >= min && v <= max);
669
- } else if (typeof props.yTicks === "number") {
670
- values = intervalValues(min, max, props.yTicks);
671
- } else {
672
- const targetTicks = Math.max(3, Math.floor(innerH.value / 50));
673
- values = intervalValues(min, max, niceStep(max - min, targetTicks));
674
- }
605
+ const values = computeTickValues({
606
+ min,
607
+ max,
608
+ ticks: props.yTicks,
609
+ targetTickCount: innerH.value / 50,
610
+ });
675
611
  return values.map((v) => ({ value: fmt(v), y: toY(v) }));
676
612
  });
677
613
 
@@ -698,32 +634,26 @@ const xTickItems = computed(() => {
698
634
  };
699
635
 
700
636
  let values: number[];
701
- if (Array.isArray(props.xTicks)) {
702
- // User supplies display-space values; shift to data-space.
703
- values = props.xTicks
704
- .map((v) => v - offset)
705
- .filter((v) => v >= xMin && v <= xMax);
706
- } else if (typeof props.xTicks === "number") {
707
- // Align to multiples of the step in display space (preserves
708
- // e.g. `xMin: 3, xTicks: 5` → display ticks 5, 10, 15 behavior).
709
- values = intervalValues(xMin + offset, xMax + offset, props.xTicks).map(
710
- (v) => v - offset,
711
- );
712
- } else if (
637
+ if (
638
+ props.xTicks == null &&
713
639
  !hasExplicitX.value &&
714
640
  props.xLabels &&
715
641
  props.xLabels.length === len
716
642
  ) {
643
+ // xLabels fallback: pick evenly-spaced index ticks so every label
644
+ // bucket gets at most one tick.
717
645
  const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
718
646
  const step = Math.max(1, Math.round((len - 1) / targetTicks));
719
647
  values = [];
720
648
  for (let i = 0; i < len; i += step) values.push(i);
721
649
  } else {
722
- const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
723
- const step = niceStep(xMax - xMin, targetTicks);
724
- values = intervalValues(xMin + offset, xMax + offset, step).map(
725
- (v) => v - offset,
726
- );
650
+ values = computeTickValues({
651
+ min: xMin,
652
+ max: xMax,
653
+ ticks: props.xTicks,
654
+ targetTickCount: innerW.value / 80,
655
+ displayOffset: offset,
656
+ });
727
657
  }
728
658
 
729
659
  const leftEdge = padding.value.left;
@@ -738,50 +668,12 @@ const xTickItems = computed(() => {
738
668
  });
739
669
  });
740
670
 
741
- function menuFilename() {
742
- if (props.filename) return props.filename;
743
- return typeof props.menu === "string" ? props.menu : "chart";
744
- }
745
-
746
- function getSvgEl(): SVGSVGElement | null {
747
- return svgRef.value;
748
- }
749
-
750
671
  function toCsv(): string {
751
672
  if (typeof props.csv === "function") return props.csv();
752
673
  if (typeof props.csv === "string") return props.csv;
753
- const series = allSeries.value;
754
- if (series.length === 0) return "";
755
- const len = maxLen.value;
756
- // Use an `x` column when every series shares the same x source;
757
- // otherwise fall back to `index`.
758
- const sharedX = series.every((s) => s.x === series[0].x)
759
- ? series[0].x
760
- : undefined;
761
- const xHeader = sharedX ? "x" : "index";
762
- const headers =
763
- series.length === 1
764
- ? [xHeader, "value"]
765
- : [xHeader, ...series.map((_, i) => `series_${i}`)];
766
- const rows = [headers.join(",")];
767
- for (let r = 0; r < len; r++) {
768
- const xCell = sharedX ? String(sharedX[r]) : r.toString();
769
- const cells = [xCell];
770
- for (const s of series) {
771
- cells.push(r < s.data.length ? String(s.data[r]) : "");
772
- }
773
- rows.push(cells.join(","));
774
- }
775
- return rows.join("\n");
674
+ return seriesToCsv(allSeries.value);
776
675
  }
777
676
 
778
- // Tooltip hover state
779
- const TOUCH_Y_OFFSET = 50;
780
- const hoverIndex = ref<number | null>(null);
781
- const isTouching = ref(false);
782
- const tooltipRef = ref<HTMLElement | null>(null);
783
- const pointer = ref<{ clientX: number; clientY: number } | null>(null);
784
- const tooltipPos = ref<{ left: number; top: number } | null>(null);
785
677
  const hasTooltipSlot = computed(
786
678
  () => !!props.tooltipData || !!props.tooltipTrigger,
787
679
  );
@@ -866,15 +758,6 @@ const hoverSlotProps = computed(() => {
866
758
  };
867
759
  });
868
760
 
869
- function pointerFromEvent(
870
- event: MouseEvent | TouchEvent,
871
- ): { clientX: number; clientY: number } | null {
872
- if ("touches" in event) {
873
- return event.touches[0] ?? null;
874
- }
875
- return event;
876
- }
877
-
878
761
  function indexFromPointer(clientX: number): number | null {
879
762
  const rect = containerRef.value?.getBoundingClientRect();
880
763
  if (!rect) return null;
@@ -887,114 +770,31 @@ function indexFromPointer(clientX: number): number | null {
887
770
  return nearestIndex(s0, targetX);
888
771
  }
889
772
 
890
- function updateHover(event: MouseEvent | TouchEvent) {
891
- const pt = pointerFromEvent(event);
892
- if (!pt) return;
893
- const idx = indexFromPointer(pt.clientX);
894
- if (idx === null) return;
895
- hoverIndex.value = idx;
896
- pointer.value = { clientX: pt.clientX, clientY: pt.clientY };
897
- emit("hover", { index: idx });
898
- }
899
-
900
- watch(
901
- [pointer, hoverIndex],
902
- () => {
903
- if (hoverIndex.value === null || !pointer.value) {
904
- tooltipPos.value = null;
905
- return;
906
- }
907
- const el = tooltipRef.value;
908
- const container = containerRef.value;
909
- if (!el || !container) return;
910
- const rect = container.getBoundingClientRect();
911
- const offset = isTouching.value ? TOUCH_Y_OFFSET : 0;
912
- const { left, top } = placeTooltip(
913
- pointer.value.clientX,
914
- pointer.value.clientY - offset,
915
- el.offsetWidth,
916
- el.offsetHeight,
917
- props.tooltipClamp,
918
- rect,
919
- );
920
- tooltipPos.value = { left: left - rect.left, top: top - rect.top };
921
- },
922
- { flush: "post" },
923
- );
924
-
925
- function onChartMouseMove(event: MouseEvent) {
926
- updateHover(event);
927
- }
928
-
929
- function onChartMouseLeave() {
930
- if (props.tooltipTrigger !== "click") {
931
- hoverIndex.value = null;
932
- emit("hover", null);
933
- }
934
- }
935
-
936
- function onChartClick(event: MouseEvent) {
937
- if (props.tooltipTrigger !== "click") return;
938
- const pt = pointerFromEvent(event);
939
- if (!pt) return;
940
- const idx = indexFromPointer(pt.clientX);
941
- if (idx === null) return;
942
- hoverIndex.value = hoverIndex.value === idx ? null : idx;
943
- emit("hover", hoverIndex.value !== null ? { index: idx } : null);
944
- }
945
-
946
- function onTouchStart(event: TouchEvent) {
947
- isTouching.value = true;
948
- updateHover(event);
949
- }
950
-
951
- function onTouchMove(event: TouchEvent) {
952
- updateHover(event);
953
- }
954
-
955
- function onTouchEnd() {
956
- isTouching.value = false;
957
- hoverIndex.value = null;
958
- emit("hover", null);
959
- }
960
-
961
- const downloadLinkText = computed(() => {
962
- if (!props.downloadLink) return null;
963
- return typeof props.downloadLink === "string"
964
- ? props.downloadLink
965
- : "Download data (CSV)";
966
- });
967
-
968
- const csvHref = computed(() => {
969
- if (!props.downloadLink) return null;
970
- return `data:text/csv;charset=utf-8,${encodeURIComponent(toCsv())}`;
773
+ const {
774
+ hoverIndex,
775
+ tooltipRef,
776
+ tooltipPos,
777
+ handlers: tooltipHandlers,
778
+ } = useChartTooltip({
779
+ enabled: () => hasTooltipSlot.value,
780
+ trigger: () => props.tooltipTrigger,
781
+ clamp: () => props.tooltipClamp,
782
+ pointerToIndex: indexFromPointer,
783
+ containerRef,
784
+ onHover: (payload) => emit("hover", payload),
971
785
  });
972
786
 
973
- const menuItems = computed<ChartMenuItem[]>(() => {
974
- const fname = menuFilename();
975
- const items: ChartMenuItem[] = [
976
- {
977
- label: "Save as SVG",
978
- action: () => {
979
- const el = getSvgEl();
980
- if (el) saveSvg(el, fname);
981
- },
982
- },
983
- {
984
- label: "Save as PNG",
985
- action: () => {
986
- const el = getSvgEl();
987
- if (el) savePng(el, fname);
988
- },
989
- },
990
- ];
991
- if (!props.downloadLink) {
992
- items.push({
993
- label: "Download CSV",
994
- action: () => downloadCsv(toCsv(), fname),
995
- });
996
- }
997
- return items;
787
+ const {
788
+ svgRef,
789
+ items: menuItems,
790
+ downloadLinkText,
791
+ csvHref,
792
+ resolvedFilename: menuFilename,
793
+ } = useChartMenu({
794
+ filename: () => props.filename,
795
+ legacyMenuLabel: () => props.menu,
796
+ getCsv: toCsv,
797
+ downloadLink: () => props.downloadLink,
998
798
  });
999
799
  </script>
1000
800
 
@@ -1272,12 +1072,7 @@ const menuItems = computed<ChartMenuItem[]>(() => {
1272
1072
  :height="innerH"
1273
1073
  fill="transparent"
1274
1074
  style="cursor: crosshair; touch-action: none"
1275
- @mousemove="onChartMouseMove"
1276
- @mouseleave="onChartMouseLeave"
1277
- @click="onChartClick"
1278
- @touchstart.prevent="onTouchStart"
1279
- @touchmove.prevent="onTouchMove"
1280
- @touchend="onTouchEnd"
1075
+ v-on="tooltipHandlers"
1281
1076
  />
1282
1077
  <!-- area section labels -->
1283
1078
  <g v-for="(item, i) in sectionLabels.labels" :key="'seclab' + i">
@@ -1342,7 +1137,7 @@ const menuItems = computed<ChartMenuItem[]>(() => {
1342
1137
  class="line-chart-tooltip-swatch"
1343
1138
  :style="{ background: v.color }"
1344
1139
  />
1345
- {{ isFinite(v.value) ? formatTick(v.value) : "—" }}
1140
+ {{ isFinite(v.value) ? formatTooltipValue(v.value) : "—" }}
1346
1141
  </div>
1347
1142
  </div>
1348
1143
  </slot>
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pure helpers shared by chart components for axis tick math and value
3
+ * formatting. No Vue reactivity here; see `useAxisTicks` for the
4
+ * reactive wrapper.
5
+ */
6
+
7
+ /** Round to nearest half-pixel so 1px SVG strokes stay sharp. */
8
+ export function snap(v: number): number {
9
+ return Math.round(v) + 0.5;
10
+ }
11
+
12
+ export function niceStep(range: number, targetTicks: number): number {
13
+ const rough = range / targetTicks;
14
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
15
+ const norm = rough / mag;
16
+ let step: number;
17
+ if (norm <= 1.5) step = 1;
18
+ else if (norm <= 3) step = 2;
19
+ else if (norm <= 7) step = 5;
20
+ else step = 10;
21
+ return step * mag;
22
+ }
23
+
24
+ /** Generate interval-spaced values in [min, max], inclusive. */
25
+ export function intervalValues(
26
+ min: number,
27
+ max: number,
28
+ step: number,
29
+ ): number[] {
30
+ if (!(step > 0) || !isFinite(step)) return [];
31
+ const out: number[] = [];
32
+ const start = Math.ceil(min / step) * step;
33
+ // Cap iteration to avoid runaway loops from pathological inputs.
34
+ const maxIterations = 1000;
35
+ for (
36
+ let i = 0, v = start;
37
+ v <= max + 1e-9 && i < maxIterations;
38
+ i++, v = start + i * step
39
+ ) {
40
+ out.push(v);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ const numFmt = new Intl.NumberFormat();
46
+
47
+ export function formatTick(v: number): string {
48
+ if (Math.abs(v) >= 1000) return numFmt.format(v);
49
+ if (Number.isInteger(v)) return v.toString();
50
+ return v.toFixed(1);
51
+ }
52
+
53
+ /**
54
+ * Numeric input accepted by chart components. `number[]` and any standard
55
+ * numeric typed array are supported, so the output of
56
+ * `ModelOutput.column('x')` (e.g. a `Float64Array`) can be passed
57
+ * directly without copying.
58
+ */
59
+ export type ChartData =
60
+ | readonly number[]
61
+ | Float64Array
62
+ | Float32Array
63
+ | Int32Array
64
+ | Uint32Array
65
+ | Int16Array
66
+ | Uint16Array
67
+ | Int8Array
68
+ | Uint8Array
69
+ | Uint8ClampedArray;
@@ -0,0 +1,42 @@
1
+ import { intervalValues, niceStep } from "./axes.js";
2
+
3
+ export interface TickValueOptions {
4
+ /** Data-space extent. */
5
+ min: number;
6
+ max: number;
7
+ /** Tick spec: number = interval, array = explicit values, undefined = auto. */
8
+ ticks?: number | number[];
9
+ /** Target tick count when auto-spacing (typically innerSize / 50 for y, /80 for x). */
10
+ targetTickCount?: number;
11
+ /**
12
+ * Display-space offset added to user-supplied explicit/interval values
13
+ * before they are interpreted. Used by LineChart's `xMin` semantics so
14
+ * users supply tick values in display coordinates.
15
+ */
16
+ displayOffset?: number;
17
+ }
18
+
19
+ /**
20
+ * Returns tick values in data-space ([min, max]) according to the spec.
21
+ * Display-space conversions (for labels) happen at the call site so
22
+ * each chart can layer its own formatting / fallback behavior.
23
+ */
24
+ export function computeTickValues(opts: TickValueOptions): number[] {
25
+ const { min, max, ticks } = opts;
26
+ if (min === max) return [];
27
+ const offset = opts.displayOffset ?? 0;
28
+
29
+ if (Array.isArray(ticks)) {
30
+ return ticks.map((v) => v - offset).filter((v) => v >= min && v <= max);
31
+ }
32
+ if (typeof ticks === "number") {
33
+ return intervalValues(min + offset, max + offset, ticks).map(
34
+ (v) => v - offset,
35
+ );
36
+ }
37
+ const target = Math.max(3, Math.floor(opts.targetTickCount ?? 3));
38
+ const step = niceStep(max - min, target);
39
+ return intervalValues(min + offset, max + offset, step).map(
40
+ (v) => v - offset,
41
+ );
42
+ }
@@ -0,0 +1,20 @@
1
+ export {
2
+ snap,
3
+ niceStep,
4
+ intervalValues,
5
+ formatTick,
6
+ type ChartData,
7
+ } from "./axes.js";
8
+ export { computeTickValues, type TickValueOptions } from "./computeTicks.js";
9
+ export { useChartSize, type ChartSizeOptions } from "./useChartSize.js";
10
+ export {
11
+ useChartPadding,
12
+ INLINE_LEGEND_HEIGHT,
13
+ type ChartPaddingOptions,
14
+ } from "./useChartPadding.js";
15
+ export {
16
+ useChartTooltip,
17
+ type ChartTooltipOptions,
18
+ } from "./useChartTooltip.js";
19
+ export { useChartMenu, type ChartMenuOptions } from "./useChartMenu.js";
20
+ export { seriesToCsv, categoricalToCsv, type CsvSeries } from "./seriesCsv.js";
@@ -0,0 +1,68 @@
1
+ import type { ChartData } from "./axes.js";
2
+
3
+ export interface CsvSeries {
4
+ data: ChartData;
5
+ /** Optional parallel x-values; when all series share the same x, an `x` column is used. */
6
+ x?: ChartData;
7
+ }
8
+
9
+ /**
10
+ * Build a CSV string for one or more numeric series. When every series
11
+ * shares the same `x` reference an `x` column is emitted; otherwise
12
+ * rows are indexed.
13
+ */
14
+ export function seriesToCsv(series: CsvSeries[]): string {
15
+ if (series.length === 0) return "";
16
+ let maxLen = 0;
17
+ for (const s of series) if (s.data.length > maxLen) maxLen = s.data.length;
18
+ const sharedX = series.every((s) => s.x === series[0].x)
19
+ ? series[0].x
20
+ : undefined;
21
+ const xHeader = sharedX ? "x" : "index";
22
+ const headers =
23
+ series.length === 1
24
+ ? [xHeader, "value"]
25
+ : [xHeader, ...series.map((_, i) => `series_${i}`)];
26
+ const rows = [headers.join(",")];
27
+ for (let r = 0; r < maxLen; r++) {
28
+ const xCell = sharedX ? String(sharedX[r]) : r.toString();
29
+ const cells = [xCell];
30
+ for (const s of series) {
31
+ cells.push(r < s.data.length ? String(s.data[r]) : "");
32
+ }
33
+ rows.push(cells.join(","));
34
+ }
35
+ return rows.join("\n");
36
+ }
37
+
38
+ /**
39
+ * Build a CSV for a categorical chart (BarChart vertical / horizontal):
40
+ * one row per category, one column per series.
41
+ */
42
+ export function categoricalToCsv(
43
+ categories: readonly string[],
44
+ series: { label?: string; data: ChartData }[],
45
+ categoryHeader = "category",
46
+ ): string {
47
+ if (series.length === 0 || categories.length === 0) return "";
48
+ const headers =
49
+ series.length === 1
50
+ ? [categoryHeader, series[0].label || "value"]
51
+ : [categoryHeader, ...series.map((s, i) => s.label || `series_${i}`)];
52
+ const rows = [headers.join(",")];
53
+ for (let r = 0; r < categories.length; r++) {
54
+ const cells = [escapeCsv(categories[r])];
55
+ for (const s of series) {
56
+ cells.push(r < s.data.length ? String(s.data[r]) : "");
57
+ }
58
+ rows.push(cells.join(","));
59
+ }
60
+ return rows.join("\n");
61
+ }
62
+
63
+ function escapeCsv(value: string): string {
64
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
65
+ return `"${value.replace(/"/g, '""')}"`;
66
+ }
67
+ return value;
68
+ }