@cfasim-ui/docs 0.3.18 → 0.4.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.
@@ -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
  /**
@@ -180,64 +179,30 @@ defineSlots<{
180
179
  }): unknown;
181
180
  }>();
182
181
 
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);
182
+ const { containerRef, measuredWidth } = useChartSize({
183
+ debounce: () => props.debounce,
211
184
  });
212
185
 
213
186
  const width = computed(() => props.width ?? (measuredWidth.value || 400));
214
187
  const height = computed(() => props.height ?? 200);
215
188
 
216
- const INLINE_LEGEND_HEIGHT = 20;
217
-
218
189
  const hasInlineLegend = computed(
219
190
  () =>
220
191
  allSeries.value.some((s) => s.legend) ||
221
192
  props.areaSections?.some(
222
193
  (s) => s.legend === "inline" && (s.label || s.description),
223
- ),
194
+ ) ||
195
+ false,
224
196
  );
225
197
 
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
- );
198
+ const { padding, innerW, innerH } = useChartPadding({
199
+ title: () => props.title,
200
+ xLabel: () => props.xLabel,
201
+ yLabel: () => props.yLabel,
202
+ hasInlineLegend: () => hasInlineLegend.value,
203
+ width: () => width.value,
204
+ height: () => height.value,
205
+ });
241
206
 
242
207
  /**
243
208
  * Internal series shape where `data` (y-values) is always resolved.
@@ -607,47 +572,6 @@ const sectionLabelBaseY = computed(
607
572
  SECTION_LABEL_TOP_MARGIN,
608
573
  );
609
574
 
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
575
  const yTickItems = computed(() => {
652
576
  const { min, max } = extent.value;
653
577
  const toY = (v: number) =>
@@ -663,15 +587,12 @@ const yTickItems = computed(() => {
663
587
  return [{ value: fmt(min), y: snap(padding.value.top + innerH.value / 2) }];
664
588
  }
665
589
 
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
- }
590
+ const values = computeTickValues({
591
+ min,
592
+ max,
593
+ ticks: props.yTicks,
594
+ targetTickCount: innerH.value / 50,
595
+ });
675
596
  return values.map((v) => ({ value: fmt(v), y: toY(v) }));
676
597
  });
677
598
 
@@ -698,32 +619,26 @@ const xTickItems = computed(() => {
698
619
  };
699
620
 
700
621
  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 (
622
+ if (
623
+ props.xTicks == null &&
713
624
  !hasExplicitX.value &&
714
625
  props.xLabels &&
715
626
  props.xLabels.length === len
716
627
  ) {
628
+ // xLabels fallback: pick evenly-spaced index ticks so every label
629
+ // bucket gets at most one tick.
717
630
  const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
718
631
  const step = Math.max(1, Math.round((len - 1) / targetTicks));
719
632
  values = [];
720
633
  for (let i = 0; i < len; i += step) values.push(i);
721
634
  } 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
- );
635
+ values = computeTickValues({
636
+ min: xMin,
637
+ max: xMax,
638
+ ticks: props.xTicks,
639
+ targetTickCount: innerW.value / 80,
640
+ displayOffset: offset,
641
+ });
727
642
  }
728
643
 
729
644
  const leftEdge = padding.value.left;
@@ -738,50 +653,12 @@ const xTickItems = computed(() => {
738
653
  });
739
654
  });
740
655
 
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
656
  function toCsv(): string {
751
657
  if (typeof props.csv === "function") return props.csv();
752
658
  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");
659
+ return seriesToCsv(allSeries.value);
776
660
  }
777
661
 
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
662
  const hasTooltipSlot = computed(
786
663
  () => !!props.tooltipData || !!props.tooltipTrigger,
787
664
  );
@@ -866,15 +743,6 @@ const hoverSlotProps = computed(() => {
866
743
  };
867
744
  });
868
745
 
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
746
  function indexFromPointer(clientX: number): number | null {
879
747
  const rect = containerRef.value?.getBoundingClientRect();
880
748
  if (!rect) return null;
@@ -887,114 +755,31 @@ function indexFromPointer(clientX: number): number | null {
887
755
  return nearestIndex(s0, targetX);
888
756
  }
889
757
 
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())}`;
758
+ const {
759
+ hoverIndex,
760
+ tooltipRef,
761
+ tooltipPos,
762
+ handlers: tooltipHandlers,
763
+ } = useChartTooltip({
764
+ enabled: () => hasTooltipSlot.value,
765
+ trigger: () => props.tooltipTrigger,
766
+ clamp: () => props.tooltipClamp,
767
+ pointerToIndex: indexFromPointer,
768
+ containerRef,
769
+ onHover: (payload) => emit("hover", payload),
971
770
  });
972
771
 
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;
772
+ const {
773
+ svgRef,
774
+ items: menuItems,
775
+ downloadLinkText,
776
+ csvHref,
777
+ resolvedFilename: menuFilename,
778
+ } = useChartMenu({
779
+ filename: () => props.filename,
780
+ legacyMenuLabel: () => props.menu,
781
+ getCsv: toCsv,
782
+ downloadLink: () => props.downloadLink,
998
783
  });
999
784
  </script>
1000
785
 
@@ -1272,12 +1057,7 @@ const menuItems = computed<ChartMenuItem[]>(() => {
1272
1057
  :height="innerH"
1273
1058
  fill="transparent"
1274
1059
  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"
1060
+ v-on="tooltipHandlers"
1281
1061
  />
1282
1062
  <!-- area section labels -->
1283
1063
  <g v-for="(item, i) in sectionLabels.labels" :key="'seclab' + i">
@@ -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
+ }