@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.
- package/charts/BarChart/BarChart.md +196 -0
- package/charts/BarChart/BarChart.vue +844 -0
- package/charts/ChartMenu/ChartMenu.vue +11 -4
- package/charts/ChoroplethMap/ChoroplethMap.md +42 -0
- package/charts/ChoroplethMap/ChoroplethMap.vue +22 -2
- package/charts/DataTable/DataTable.md +39 -9
- package/charts/DataTable/DataTable.vue +45 -59
- package/charts/LineChart/LineChart.md +3 -2
- package/charts/LineChart/LineChart.vue +86 -291
- 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
|
/**
|
|
@@ -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
|
-
/**
|
|
143
|
-
|
|
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
|
|
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);
|
|
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
|
|
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
|
-
);
|
|
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
|
-
|
|
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
|
-
}
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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())}`;
|
|
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
|
|
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;
|
|
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
|
-
|
|
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) ?
|
|
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
|
+
}
|