@cfasim-ui/docs 0.3.17 → 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/components/NumberInput/NumberInput.md +52 -0
- package/components/NumberInput/NumberInput.vue +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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { computed, ref, type Ref } from "vue";
|
|
2
|
+
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
3
|
+
import { saveSvg, savePng, downloadCsv } from "../ChartMenu/download.js";
|
|
4
|
+
|
|
5
|
+
export interface ChartMenuOptions {
|
|
6
|
+
filename: () => string | undefined;
|
|
7
|
+
/** Used as the menu's filename fallback when `filename` is unset. */
|
|
8
|
+
legacyMenuLabel: () => boolean | string | undefined;
|
|
9
|
+
/** Builds the CSV content for downloads. */
|
|
10
|
+
getCsv: () => string;
|
|
11
|
+
/** Whether a separate download link is rendered (and the CSV menu item should be hidden). */
|
|
12
|
+
downloadLink: () => boolean | string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Computes the standard chart menu items (SVG / PNG / CSV) plus the
|
|
17
|
+
* CSV-download-link state shared by every chart.
|
|
18
|
+
*/
|
|
19
|
+
export function useChartMenu(opts: ChartMenuOptions) {
|
|
20
|
+
const svgRef = ref<SVGSVGElement | null>(null);
|
|
21
|
+
|
|
22
|
+
function resolvedFilename(): string {
|
|
23
|
+
const f = opts.filename();
|
|
24
|
+
if (f) return f;
|
|
25
|
+
const menu = opts.legacyMenuLabel();
|
|
26
|
+
return typeof menu === "string" ? menu : "chart";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const items = computed<ChartMenuItem[]>(() => {
|
|
30
|
+
const fname = resolvedFilename();
|
|
31
|
+
const out: ChartMenuItem[] = [
|
|
32
|
+
{
|
|
33
|
+
label: "Save as SVG",
|
|
34
|
+
action: () => {
|
|
35
|
+
if (svgRef.value) saveSvg(svgRef.value, fname);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: "Save as PNG",
|
|
40
|
+
action: () => {
|
|
41
|
+
if (svgRef.value) savePng(svgRef.value, fname);
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
if (!opts.downloadLink()) {
|
|
46
|
+
out.push({
|
|
47
|
+
label: "Download CSV",
|
|
48
|
+
action: () => downloadCsv(opts.getCsv(), fname),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const downloadLinkText = computed<string | null>(() => {
|
|
55
|
+
const v = opts.downloadLink();
|
|
56
|
+
if (!v) return null;
|
|
57
|
+
return typeof v === "string" ? v : "Download data (CSV)";
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const csvHref = computed<string | null>(() => {
|
|
61
|
+
if (!opts.downloadLink()) return null;
|
|
62
|
+
return `data:text/csv;charset=utf-8,${encodeURIComponent(opts.getCsv())}`;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
svgRef: svgRef as Ref<SVGSVGElement | null>,
|
|
67
|
+
items,
|
|
68
|
+
downloadLinkText,
|
|
69
|
+
csvHref,
|
|
70
|
+
resolvedFilename,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
|
|
3
|
+
/** Vertical space reserved at the top of the chart for inline legend swatches. */
|
|
4
|
+
export const INLINE_LEGEND_HEIGHT = 20;
|
|
5
|
+
|
|
6
|
+
export interface ChartPaddingOptions {
|
|
7
|
+
title: () => string | undefined;
|
|
8
|
+
xLabel: () => string | undefined;
|
|
9
|
+
yLabel: () => string | undefined;
|
|
10
|
+
hasInlineLegend: () => boolean;
|
|
11
|
+
width: () => number;
|
|
12
|
+
height: () => number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Computes the standard chart padding (top/right/bottom/left) and the
|
|
17
|
+
* derived inner plotting region (innerW, innerH). Shared by LineChart
|
|
18
|
+
* and BarChart so the axis label spacing and inline legend strip stay
|
|
19
|
+
* consistent.
|
|
20
|
+
*/
|
|
21
|
+
export function useChartPadding(opts: ChartPaddingOptions) {
|
|
22
|
+
const padding = computed(() => ({
|
|
23
|
+
top:
|
|
24
|
+
(opts.title() ? 30 : 10) +
|
|
25
|
+
(opts.hasInlineLegend() ? INLINE_LEGEND_HEIGHT : 0),
|
|
26
|
+
right: 10,
|
|
27
|
+
bottom: opts.xLabel() ? 46 : 30,
|
|
28
|
+
left: opts.yLabel() ? 66 : 50,
|
|
29
|
+
}));
|
|
30
|
+
const innerW = computed(
|
|
31
|
+
() => opts.width() - padding.value.left - padding.value.right,
|
|
32
|
+
);
|
|
33
|
+
const innerH = computed(
|
|
34
|
+
() => opts.height() - padding.value.top - padding.value.bottom,
|
|
35
|
+
);
|
|
36
|
+
return { padding, innerW, innerH };
|
|
37
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
export interface ChartSizeOptions {
|
|
4
|
+
/** Fallback width when measurement is not yet available. */
|
|
5
|
+
fallbackWidth?: number;
|
|
6
|
+
/** Optional debounce in ms applied to ResizeObserver updates. */
|
|
7
|
+
debounce?: () => number | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Watches an element ref for size changes and exposes the measured width.
|
|
12
|
+
* Mirrors the per-chart ResizeObserver wiring previously inlined in each
|
|
13
|
+
* chart component.
|
|
14
|
+
*/
|
|
15
|
+
export function useChartSize(opts: ChartSizeOptions = {}) {
|
|
16
|
+
const containerRef = ref<HTMLElement | null>(null);
|
|
17
|
+
const measuredWidth = ref(0);
|
|
18
|
+
let observer: ResizeObserver | null = null;
|
|
19
|
+
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
20
|
+
|
|
21
|
+
onMounted(() => {
|
|
22
|
+
if (!containerRef.value) return;
|
|
23
|
+
measuredWidth.value = containerRef.value.clientWidth;
|
|
24
|
+
observer = new ResizeObserver((entries) => {
|
|
25
|
+
const entry = entries[0];
|
|
26
|
+
if (!entry) return;
|
|
27
|
+
const debounce = opts.debounce?.();
|
|
28
|
+
if (debounce) {
|
|
29
|
+
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
30
|
+
resizeTimeout = setTimeout(() => {
|
|
31
|
+
measuredWidth.value = entry.contentRect.width;
|
|
32
|
+
}, debounce);
|
|
33
|
+
} else {
|
|
34
|
+
measuredWidth.value = entry.contentRect.width;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
observer.observe(containerRef.value);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
onUnmounted(() => {
|
|
41
|
+
observer?.disconnect();
|
|
42
|
+
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
containerRef,
|
|
47
|
+
measuredWidth,
|
|
48
|
+
} as { containerRef: Ref<HTMLElement | null>; measuredWidth: Ref<number> };
|
|
49
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { ref, computed, watch, type Ref } from "vue";
|
|
2
|
+
import { placeTooltip, type TooltipClamp } from "../tooltip-position.js";
|
|
3
|
+
|
|
4
|
+
export interface ChartTooltipOptions {
|
|
5
|
+
/** Whether tooltip interactions are wired up at all. */
|
|
6
|
+
enabled: () => boolean;
|
|
7
|
+
/** Tooltip activation mode. */
|
|
8
|
+
trigger?: () => "hover" | "click" | undefined;
|
|
9
|
+
/** Boundary for tooltip flip/clamp. */
|
|
10
|
+
clamp?: () => TooltipClamp;
|
|
11
|
+
/**
|
|
12
|
+
* Maps a client (x, y) pointer location to a data index, or null when
|
|
13
|
+
* the pointer is outside the chart. Most charts only need `clientX`
|
|
14
|
+
* (categorical or x-indexed), but horizontal bar charts also need
|
|
15
|
+
* `clientY`.
|
|
16
|
+
*/
|
|
17
|
+
pointerToIndex: (clientX: number, clientY: number) => number | null;
|
|
18
|
+
/** The chart's container element (used to compute relative position). */
|
|
19
|
+
containerRef: Ref<HTMLElement | null>;
|
|
20
|
+
/** Pointer-vertical offset applied for touch interactions. */
|
|
21
|
+
touchYOffset?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Emit hover events. The first arg is `{ index }` while hovering and
|
|
24
|
+
* `null` when leaving.
|
|
25
|
+
*/
|
|
26
|
+
onHover?: (payload: { index: number } | null) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shared tooltip state + pointer/touch handlers used by chart components.
|
|
31
|
+
* The caller wires the returned `handlers` to its hit-test overlay and
|
|
32
|
+
* places the floating tooltip with `tooltipPos`.
|
|
33
|
+
*/
|
|
34
|
+
export function useChartTooltip(opts: ChartTooltipOptions) {
|
|
35
|
+
const TOUCH_Y_OFFSET = opts.touchYOffset ?? 50;
|
|
36
|
+
const hoverIndex = ref<number | null>(null);
|
|
37
|
+
const isTouching = ref(false);
|
|
38
|
+
const tooltipRef = ref<HTMLElement | null>(null);
|
|
39
|
+
const pointer = ref<{ clientX: number; clientY: number } | null>(null);
|
|
40
|
+
const tooltipPos = ref<{ left: number; top: number } | null>(null);
|
|
41
|
+
|
|
42
|
+
function pointerFromEvent(
|
|
43
|
+
event: MouseEvent | TouchEvent,
|
|
44
|
+
): { clientX: number; clientY: number } | null {
|
|
45
|
+
if ("touches" in event) {
|
|
46
|
+
return event.touches[0] ?? null;
|
|
47
|
+
}
|
|
48
|
+
return event;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function updateHover(event: MouseEvent | TouchEvent) {
|
|
52
|
+
const pt = pointerFromEvent(event);
|
|
53
|
+
if (!pt) return;
|
|
54
|
+
const idx = opts.pointerToIndex(pt.clientX, pt.clientY);
|
|
55
|
+
if (idx === null) return;
|
|
56
|
+
hoverIndex.value = idx;
|
|
57
|
+
pointer.value = { clientX: pt.clientX, clientY: pt.clientY };
|
|
58
|
+
opts.onHover?.({ index: idx });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
watch(
|
|
62
|
+
[pointer, hoverIndex],
|
|
63
|
+
() => {
|
|
64
|
+
if (hoverIndex.value === null || !pointer.value) {
|
|
65
|
+
tooltipPos.value = null;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const el = tooltipRef.value;
|
|
69
|
+
const container = opts.containerRef.value;
|
|
70
|
+
if (!el || !container) return;
|
|
71
|
+
const rect = container.getBoundingClientRect();
|
|
72
|
+
const offset = isTouching.value ? TOUCH_Y_OFFSET : 0;
|
|
73
|
+
const clamp = opts.clamp?.() ?? "chart";
|
|
74
|
+
const { left, top } = placeTooltip(
|
|
75
|
+
pointer.value.clientX,
|
|
76
|
+
pointer.value.clientY - offset,
|
|
77
|
+
el.offsetWidth,
|
|
78
|
+
el.offsetHeight,
|
|
79
|
+
clamp,
|
|
80
|
+
rect,
|
|
81
|
+
);
|
|
82
|
+
tooltipPos.value = { left: left - rect.left, top: top - rect.top };
|
|
83
|
+
},
|
|
84
|
+
{ flush: "post" },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
function onMouseMove(event: MouseEvent) {
|
|
88
|
+
if (!opts.enabled()) return;
|
|
89
|
+
updateHover(event);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onMouseLeave() {
|
|
93
|
+
if (!opts.enabled()) return;
|
|
94
|
+
if (opts.trigger?.() !== "click") {
|
|
95
|
+
hoverIndex.value = null;
|
|
96
|
+
opts.onHover?.(null);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onClick(event: MouseEvent) {
|
|
101
|
+
if (!opts.enabled()) return;
|
|
102
|
+
if (opts.trigger?.() !== "click") return;
|
|
103
|
+
const pt = pointerFromEvent(event);
|
|
104
|
+
if (!pt) return;
|
|
105
|
+
const idx = opts.pointerToIndex(pt.clientX, pt.clientY);
|
|
106
|
+
if (idx === null) return;
|
|
107
|
+
hoverIndex.value = hoverIndex.value === idx ? null : idx;
|
|
108
|
+
opts.onHover?.(hoverIndex.value !== null ? { index: idx } : null);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onTouchStart(event: TouchEvent) {
|
|
112
|
+
if (!opts.enabled()) return;
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
isTouching.value = true;
|
|
115
|
+
updateHover(event);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onTouchMove(event: TouchEvent) {
|
|
119
|
+
if (!opts.enabled()) return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
updateHover(event);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onTouchEnd() {
|
|
125
|
+
if (!opts.enabled()) return;
|
|
126
|
+
isTouching.value = false;
|
|
127
|
+
hoverIndex.value = null;
|
|
128
|
+
opts.onHover?.(null);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Note: when binding via `v-on="handlers"`, Vue expects event names
|
|
132
|
+
// *without* the `on` prefix. Touch events default to passive in some
|
|
133
|
+
// contexts; consumers using touch overlays should still bind the
|
|
134
|
+
// touch handlers individually with `.prevent`.
|
|
135
|
+
const handlers = {
|
|
136
|
+
mousemove: onMouseMove,
|
|
137
|
+
mouseleave: onMouseLeave,
|
|
138
|
+
click: onClick,
|
|
139
|
+
touchstart: onTouchStart,
|
|
140
|
+
touchmove: onTouchMove,
|
|
141
|
+
touchend: onTouchEnd,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
hoverIndex,
|
|
146
|
+
isTouching,
|
|
147
|
+
pointer,
|
|
148
|
+
tooltipRef,
|
|
149
|
+
tooltipPos,
|
|
150
|
+
handlers,
|
|
151
|
+
};
|
|
152
|
+
}
|
package/charts/index.ts
CHANGED
|
@@ -16,6 +16,12 @@ const ageRange = ref([18, 65])
|
|
|
16
16
|
const coverageRange = ref([0.2, 0.8])
|
|
17
17
|
const minAge = ref(18)
|
|
18
18
|
const maxAge = ref(65)
|
|
19
|
+
const dayMs = 24 * 60 * 60 * 1000
|
|
20
|
+
const dateStart = Date.UTC(2024, 0, 1)
|
|
21
|
+
const dateEnd = Date.UTC(2024, 11, 31)
|
|
22
|
+
const dateRange = ref([Date.UTC(2024, 2, 1), Date.UTC(2024, 8, 30)])
|
|
23
|
+
const formatDate = (ms) =>
|
|
24
|
+
new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
19
25
|
</script>
|
|
20
26
|
|
|
21
27
|
<ComponentDemo>
|
|
@@ -226,6 +232,51 @@ Range mode works with `percent` and `live` as well:
|
|
|
226
232
|
</template>
|
|
227
233
|
</ComponentDemo>
|
|
228
234
|
|
|
235
|
+
### Custom slider display
|
|
236
|
+
|
|
237
|
+
Pass `slider-display` (a `(value: number) => string` function) to format the
|
|
238
|
+
thumb labels and the min/max labels however you like. The internal model is
|
|
239
|
+
still a number — only the displayed text changes. This applies to single
|
|
240
|
+
sliders and ranges; the regular text input is unaffected.
|
|
241
|
+
|
|
242
|
+
<ComponentDemo>
|
|
243
|
+
<div style="width: 300px">
|
|
244
|
+
<NumberInput
|
|
245
|
+
v-model:range="dateRange"
|
|
246
|
+
label="Date range"
|
|
247
|
+
:min="dateStart"
|
|
248
|
+
:max="dateEnd"
|
|
249
|
+
:step="dayMs"
|
|
250
|
+
:slider-display="formatDate"
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<template #code>
|
|
255
|
+
|
|
256
|
+
```vue
|
|
257
|
+
<script setup>
|
|
258
|
+
import { ref } from "vue";
|
|
259
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
260
|
+
const dateStart = Date.UTC(2024, 0, 1);
|
|
261
|
+
const dateEnd = Date.UTC(2024, 11, 31);
|
|
262
|
+
const dateRange = ref([Date.UTC(2024, 2, 1), Date.UTC(2024, 8, 30)]);
|
|
263
|
+
const formatDate = (ms) =>
|
|
264
|
+
new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
265
|
+
</script>
|
|
266
|
+
|
|
267
|
+
<NumberInput
|
|
268
|
+
v-model:range="dateRange"
|
|
269
|
+
label="Date range"
|
|
270
|
+
:min="dateStart"
|
|
271
|
+
:max="dateEnd"
|
|
272
|
+
:step="dayMs"
|
|
273
|
+
:slider-display="formatDate"
|
|
274
|
+
/>
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
</template>
|
|
278
|
+
</ComponentDemo>
|
|
279
|
+
|
|
229
280
|
### Live slider
|
|
230
281
|
|
|
231
282
|
With `live`, the model updates while dragging the slider thumb rather than only on release.
|
|
@@ -415,4 +466,5 @@ the input visually.
|
|
|
415
466
|
| `numberType` | `"integer" \| "float"` | No | — |
|
|
416
467
|
| `required` | `boolean` | No | — |
|
|
417
468
|
| `decimals` | `number` | No | — |
|
|
469
|
+
| `sliderDisplay` | `(value: number) => string` | No | — |
|
|
418
470
|
|
|
@@ -29,6 +29,10 @@ const props = defineProps<{
|
|
|
29
29
|
numberType?: "integer" | "float";
|
|
30
30
|
required?: boolean;
|
|
31
31
|
decimals?: number;
|
|
32
|
+
// Custom formatter for slider thumb labels and min/max labels. Overrides
|
|
33
|
+
// the default percent/decimal formatting when provided. Only consulted in
|
|
34
|
+
// slider/range mode — the text input keeps its own number-shaped formatting.
|
|
35
|
+
sliderDisplay?: (value: number) => string;
|
|
32
36
|
}>();
|
|
33
37
|
|
|
34
38
|
function isRangeValue(v: unknown): v is NumberRange {
|
|
@@ -109,6 +113,7 @@ function roundToDecimals(v: number, d: number): number {
|
|
|
109
113
|
|
|
110
114
|
function formatSliderValue(v: number | undefined) {
|
|
111
115
|
if (v == null) return "";
|
|
116
|
+
if (props.sliderDisplay) return props.sliderDisplay(v);
|
|
112
117
|
const d = displayDecimals.value;
|
|
113
118
|
if (props.percent) return (v * 100).toFixed(d) + "%";
|
|
114
119
|
return v.toLocaleString("en-US", {
|
package/index.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.4.0",
|
|
3
3
|
"package": "@cfasim-ui/docs",
|
|
4
4
|
"content": {
|
|
5
5
|
"components": [
|
|
@@ -120,6 +120,23 @@
|
|
|
120
120
|
}
|
|
121
121
|
],
|
|
122
122
|
"charts": [
|
|
123
|
+
{
|
|
124
|
+
"name": "BarChart",
|
|
125
|
+
"slug": "bar-chart",
|
|
126
|
+
"docs": "charts/BarChart/BarChart.md",
|
|
127
|
+
"source": "charts/BarChart/BarChart.vue",
|
|
128
|
+
"keywords": [
|
|
129
|
+
"bar",
|
|
130
|
+
"column",
|
|
131
|
+
"chart",
|
|
132
|
+
"categorical",
|
|
133
|
+
"grouped",
|
|
134
|
+
"stacked",
|
|
135
|
+
"vertical",
|
|
136
|
+
"horizontal",
|
|
137
|
+
"svg"
|
|
138
|
+
]
|
|
139
|
+
},
|
|
123
140
|
{
|
|
124
141
|
"name": "ChoroplethMap",
|
|
125
142
|
"slug": "choropleth-map",
|
package/package.json
CHANGED