@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.
@@ -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
@@ -5,6 +5,11 @@ export {
5
5
  type Area,
6
6
  type AreaSection,
7
7
  } from "./LineChart/LineChart.vue";
8
+ export {
9
+ default as BarChart,
10
+ type BarChartData,
11
+ type BarSeries,
12
+ } from "./BarChart/BarChart.vue";
8
13
  export {
9
14
  default as ChoroplethMap,
10
15
  type GeoType,
@@ -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) =&gt; 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.3.17",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.3.17",
3
+ "version": "0.4.0",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/pyodide/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export {
2
2
  asyncRunPython,
3
+ callPython,
3
4
  loadModule,
4
5
  loadModuleOnWorker,
6
+ warmWorkers,
5
7
  type WorkerName,
6
8
  } from "./pyodideWorkerApi.js";
7
9
  export { useModel } from "./useModel.js";