@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.
- package/charts/BarChart/BarChart.md +189 -0
- package/charts/BarChart/BarChart.vue +829 -0
- package/charts/LineChart/LineChart.vue +68 -288
- package/charts/_shared/axes.ts +69 -0
- package/charts/_shared/computeTicks.ts +42 -0
- package/charts/_shared/index.ts +20 -0
- package/charts/_shared/seriesCsv.ts +68 -0
- package/charts/_shared/useChartMenu.ts +72 -0
- package/charts/_shared/useChartPadding.ts +37 -0
- package/charts/_shared/useChartSize.ts +49 -0
- package/charts/_shared/useChartTooltip.ts +152 -0
- package/charts/index.ts +5 -0
- package/index.json +18 -1
- package/package.json +1 -1
- package/pyodide/index.ts +2 -0
- package/pyodide/pyodide.worker.ts +109 -72
- package/pyodide/pyodideWorkerApi.ts +157 -63
- package/pyodide/useModel.ts +10 -21
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
3
|
import ChartMenu from "../ChartMenu/ChartMenu.vue";
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
184
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
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 (
|
|
702
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
|
|
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
|
+
}
|