@cfasim-ui/docs 0.4.9 → 0.4.11

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.
@@ -25,11 +25,22 @@ export interface ChartFoundationOptions {
25
25
  downloadLink: () => boolean | string | undefined;
26
26
  chartPadding: () => ChartPadding | undefined;
27
27
  // Chart-specific hooks that the composable can't infer.
28
- hasInlineLegend: () => boolean;
28
+ /**
29
+ * Labels for the inline legend in render order. Empty array = no
30
+ * legend strip. Drives wrapping into multiple rows and the resulting
31
+ * top-padding reservation.
32
+ */
33
+ inlineLegendLabels: () => readonly string[];
29
34
  hasTooltipSlot: () => boolean;
30
35
  getCsv: () => string;
31
36
  pointerToIndex: (clientX: number, clientY: number) => number | null;
32
37
  onHover: (payload: { index: number } | null) => void;
38
+ /**
39
+ * Extra height (in px) the chart adds *below* the SVG plot area
40
+ * (e.g. LineChart's area-section labels). Used to keep the SVG total
41
+ * height matched to the container when fullscreen.
42
+ */
43
+ extraBelowHeight?: () => number;
33
44
  }
34
45
 
35
46
  /**
@@ -39,25 +50,51 @@ export interface ChartFoundationOptions {
39
50
  * consumes.
40
51
  */
41
52
  export function useChartFoundation(opts: ChartFoundationOptions) {
42
- const { containerRef, measuredWidth } = useChartSize({
53
+ const { containerRef, measuredWidth, measuredHeight } = useChartSize({
43
54
  debounce: opts.debounce,
44
55
  });
45
56
 
46
- const width = computed(
47
- () => opts.width() ?? (measuredWidth.value || DEFAULT_WIDTH_FALLBACK),
48
- );
49
- const height = computed(() => opts.height() ?? DEFAULT_HEIGHT);
57
+ const {
58
+ svgRef,
59
+ items: menuItems,
60
+ downloadLinkText,
61
+ csvHref,
62
+ resolvedFilename: menuFilename,
63
+ isFullscreen,
64
+ } = useChartMenu({
65
+ filename: opts.filename,
66
+ legacyMenuLabel: opts.menu,
67
+ getCsv: opts.getCsv,
68
+ downloadLink: opts.downloadLink,
69
+ fullscreen: true,
70
+ });
71
+
72
+ const width = computed(() => {
73
+ if (isFullscreen.value && measuredWidth.value > 0) {
74
+ return measuredWidth.value;
75
+ }
76
+ return opts.width() ?? (measuredWidth.value || DEFAULT_WIDTH_FALLBACK);
77
+ });
50
78
 
51
- const { padding, legendY, innerW, innerH, bounds } = useChartPadding({
52
- title: opts.title,
53
- xLabel: opts.xLabel,
54
- yLabel: opts.yLabel,
55
- hasInlineLegend: opts.hasInlineLegend,
56
- width: () => width.value,
57
- height: () => height.value,
58
- extraPadding: opts.chartPadding,
79
+ const height = computed(() => {
80
+ if (isFullscreen.value && measuredHeight.value > 0) {
81
+ const extra = opts.extraBelowHeight?.() ?? 0;
82
+ return measuredHeight.value - extra;
83
+ }
84
+ return opts.height() ?? DEFAULT_HEIGHT;
59
85
  });
60
86
 
87
+ const { padding, legendY, inlineLegendLayout, innerW, innerH, bounds } =
88
+ useChartPadding({
89
+ title: opts.title,
90
+ xLabel: opts.xLabel,
91
+ yLabel: opts.yLabel,
92
+ inlineLegendLabels: opts.inlineLegendLabels,
93
+ width: () => width.value,
94
+ height: () => height.value,
95
+ extraPadding: opts.chartPadding,
96
+ });
97
+
61
98
  const {
62
99
  hoverIndex,
63
100
  tooltipRef,
@@ -72,19 +109,6 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
72
109
  onHover: opts.onHover,
73
110
  });
74
111
 
75
- const {
76
- svgRef,
77
- items: menuItems,
78
- downloadLinkText,
79
- csvHref,
80
- resolvedFilename: menuFilename,
81
- } = useChartMenu({
82
- filename: opts.filename,
83
- legacyMenuLabel: opts.menu,
84
- getCsv: opts.getCsv,
85
- downloadLink: opts.downloadLink,
86
- });
87
-
88
112
  return {
89
113
  containerRef,
90
114
  svgRef,
@@ -92,6 +116,7 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
92
116
  height,
93
117
  padding,
94
118
  legendY,
119
+ inlineLegendLayout,
95
120
  innerW,
96
121
  innerH,
97
122
  bounds,
@@ -103,6 +128,8 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
103
128
  downloadLinkText,
104
129
  csvHref,
105
130
  menuFilename,
131
+ isFullscreen,
132
+ measuredHeight,
106
133
  };
107
134
  }
108
135
 
@@ -0,0 +1,104 @@
1
+ import { computed, onMounted, onUnmounted, ref } from "vue";
2
+ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
3
+
4
+ /**
5
+ * Module-level refcount so multiple expanded charts on the same page
6
+ * share one body-scroll lock — the last one to collapse restores the
7
+ * original `overflow` value.
8
+ */
9
+ let bodyLockCount = 0;
10
+ let savedBodyOverflow = "";
11
+
12
+ function lockBodyScroll() {
13
+ if (typeof document === "undefined") return;
14
+ if (bodyLockCount === 0) {
15
+ savedBodyOverflow = document.body.style.overflow;
16
+ document.body.style.overflow = "hidden";
17
+ }
18
+ bodyLockCount++;
19
+ }
20
+
21
+ function unlockBodyScroll() {
22
+ if (typeof document === "undefined") return;
23
+ if (bodyLockCount === 0) return;
24
+ bodyLockCount--;
25
+ if (bodyLockCount === 0) {
26
+ document.body.style.overflow = savedBodyOverflow;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Tracks whether the chart is in "expanded" mode (fills the window via
32
+ * CSS). The browser Fullscreen API isn't used — the consumer wires a
33
+ * `.is-fullscreen` class to its wrapper and the CSS handles positioning,
34
+ * which avoids Fullscreen API restrictions (user-gesture requirement,
35
+ * permission policy, iframe sandboxing) and keeps the chart inside the
36
+ * document so the rest of the page remains keyboard-navigable.
37
+ */
38
+ export function useChartFullscreen() {
39
+ const isFullscreen = ref(false);
40
+ let locked = false;
41
+
42
+ function setExpanded(value: boolean) {
43
+ if (value === isFullscreen.value) return;
44
+ isFullscreen.value = value;
45
+ if (value && !locked) {
46
+ lockBodyScroll();
47
+ locked = true;
48
+ } else if (!value && locked) {
49
+ unlockBodyScroll();
50
+ locked = false;
51
+ }
52
+ }
53
+
54
+ function onKey(e: KeyboardEvent) {
55
+ if (e.key !== "Escape" || !isFullscreen.value) return;
56
+ // Skip when Esc is closing a menu/dialog layered on top of us so we
57
+ // don't collapse the chart at the same time. Reka's dropdown menu
58
+ // uses a document-level Esc listener and doesn't call preventDefault,
59
+ // so checking the event target is the most reliable signal.
60
+ const t = e.target;
61
+ if (t instanceof Element && t.closest('[role="menu"], [role="dialog"]')) {
62
+ return;
63
+ }
64
+ setExpanded(false);
65
+ }
66
+
67
+ function enter() {
68
+ setExpanded(true);
69
+ }
70
+ function exit() {
71
+ setExpanded(false);
72
+ }
73
+ function toggle() {
74
+ setExpanded(!isFullscreen.value);
75
+ }
76
+
77
+ onMounted(() => {
78
+ if (typeof document === "undefined") return;
79
+ document.addEventListener("keydown", onKey);
80
+ });
81
+ onUnmounted(() => {
82
+ if (typeof document === "undefined") return;
83
+ document.removeEventListener("keydown", onKey);
84
+ // Component torn down while expanded — release the lock so the body
85
+ // doesn't stay frozen.
86
+ if (locked) {
87
+ unlockBodyScroll();
88
+ locked = false;
89
+ }
90
+ });
91
+
92
+ /**
93
+ * Returns a reactive ChartMenuItem ref that flips between Expand and
94
+ * Collapse — shared by useChartMenu and any chart (e.g. ChoroplethMap)
95
+ * that builds its menu list directly.
96
+ */
97
+ const menuItem = computed<ChartMenuItem>(() => ({
98
+ label: isFullscreen.value ? "Collapse" : "Expand",
99
+ action: toggle,
100
+ ariaPressed: isFullscreen.value,
101
+ }));
102
+
103
+ return { isFullscreen, toggle, enter, exit, menuItem };
104
+ }
@@ -1,6 +1,7 @@
1
1
  import { computed, ref, type Ref } from "vue";
2
2
  import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
3
3
  import { saveSvg, savePng, downloadCsv } from "../ChartMenu/download.js";
4
+ import { useChartFullscreen } from "./useChartFullscreen.js";
4
5
 
5
6
  export interface ChartMenuOptions {
6
7
  filename: () => string | undefined;
@@ -10,11 +11,18 @@ export interface ChartMenuOptions {
10
11
  getCsv: () => string;
11
12
  /** Whether a separate download link is rendered (and the CSV menu item should be hidden). */
12
13
  downloadLink: () => boolean | string | undefined;
14
+ /**
15
+ * When true, prepends an Expand/Collapse menu item that toggles the
16
+ * chart into a full-window view. The consumer is responsible for
17
+ * binding the returned `isFullscreen` ref to a CSS class on its
18
+ * wrapper.
19
+ */
20
+ fullscreen?: boolean;
13
21
  }
14
22
 
15
23
  /**
16
- * Computes the standard chart menu items (SVG / PNG / CSV) plus the
17
- * CSV-download-link state shared by every chart.
24
+ * Computes the standard chart menu items (Expand / SVG / PNG / CSV) plus
25
+ * the CSV-download-link state shared by every chart.
18
26
  */
19
27
  export function useChartMenu(opts: ChartMenuOptions) {
20
28
  const svgRef = ref<SVGSVGElement | null>(null);
@@ -26,9 +34,15 @@ export function useChartMenu(opts: ChartMenuOptions) {
26
34
  return typeof menu === "string" ? menu : "chart";
27
35
  }
28
36
 
37
+ const fullscreen = opts.fullscreen ? useChartFullscreen() : null;
38
+
29
39
  const items = computed<ChartMenuItem[]>(() => {
30
40
  const fname = resolvedFilename();
31
- const out: ChartMenuItem[] = [
41
+ const out: ChartMenuItem[] = [];
42
+ if (fullscreen) {
43
+ out.push(fullscreen.menuItem.value);
44
+ }
45
+ out.push(
32
46
  {
33
47
  label: "Save as SVG",
34
48
  action: () => {
@@ -41,7 +55,7 @@ export function useChartMenu(opts: ChartMenuOptions) {
41
55
  if (svgRef.value) savePng(svgRef.value, fname);
42
56
  },
43
57
  },
44
- ];
58
+ );
45
59
  if (!opts.downloadLink()) {
46
60
  out.push({
47
61
  label: "Download CSV",
@@ -68,5 +82,6 @@ export function useChartMenu(opts: ChartMenuOptions) {
68
82
  downloadLinkText,
69
83
  csvHref,
70
84
  resolvedFilename,
85
+ isFullscreen: fullscreen?.isFullscreen ?? ref(false),
71
86
  };
72
87
  }
@@ -1,7 +1,17 @@
1
1
  import { computed } from "vue";
2
2
 
3
- /** Vertical space reserved at the top of the chart for inline legend swatches. */
4
- export const INLINE_LEGEND_HEIGHT = 20;
3
+ /** Vertical space reserved per row of the inline legend strip. */
4
+ export const INLINE_LEGEND_ROW_HEIGHT = 20;
5
+ /**
6
+ * Estimated character width at font-size 11 used by inline-legend
7
+ * measurement. Matches the value used by LineChart's area-section labels
8
+ * so wrapping behaves consistently across the chart's text overlays.
9
+ */
10
+ const LEGEND_CHAR_WIDTH = 7;
11
+ /** X offset of legend text from the start of its row slot. */
12
+ const LEGEND_TEXT_INDENT = 18;
13
+ /** Horizontal gap between adjacent legend items on the same row. */
14
+ const LEGEND_ITEM_GAP = 16;
5
15
 
6
16
  /**
7
17
  * Extra space added around the chart's standard layout. A number applies
@@ -22,13 +32,26 @@ export interface ChartPaddingOptions {
22
32
  title: () => string | undefined;
23
33
  xLabel: () => string | undefined;
24
34
  yLabel: () => string | undefined;
25
- hasInlineLegend: () => boolean;
35
+ /**
36
+ * Labels for the inline legend items, in render order. Empty array
37
+ * (or omitted) means no legend strip. Drives both row-count for
38
+ * reserving top padding and per-item wrap positions.
39
+ */
40
+ inlineLegendLabels: () => readonly string[];
26
41
  width: () => number;
27
42
  height: () => number;
28
43
  /** Extra pixels added on top of the standard axis spacing. */
29
44
  extraPadding?: () => ChartPadding | undefined;
30
45
  }
31
46
 
47
+ /** Position of a single legend item within the legend strip. */
48
+ export interface PositionedLegendItem {
49
+ /** X offset relative to `padding.left`. */
50
+ x: number;
51
+ /** Row index, 0-based. */
52
+ row: number;
53
+ }
54
+
32
55
  function resolvePadding(p: ChartPadding | undefined) {
33
56
  if (p == null) return { top: 0, right: 0, bottom: 0, left: 0 };
34
57
  if (typeof p === "number") return { top: p, right: p, bottom: p, left: p };
@@ -40,6 +63,14 @@ function resolvePadding(p: ChartPadding | undefined) {
40
63
  };
41
64
  }
42
65
 
66
+ /**
67
+ * Estimated rendered width (in px) of a single legend item — indicator,
68
+ * text, and surrounding spacing. Used to flow items into rows.
69
+ */
70
+ function estimateLegendItemWidth(label: string): number {
71
+ return LEGEND_TEXT_INDENT + label.length * LEGEND_CHAR_WIDTH;
72
+ }
73
+
43
74
  /**
44
75
  * Computes the standard chart padding (top/right/bottom/left) and the
45
76
  * derived inner plotting region (innerW, innerH). Shared by LineChart
@@ -47,30 +78,78 @@ function resolvePadding(p: ChartPadding | undefined) {
47
78
  * consistent. `extraPadding` adds extra space outside the plot — useful
48
79
  * for annotations that need to extend past the data area without
49
80
  * clipping.
81
+ *
82
+ * The inline legend wraps to multiple rows when items don't fit in the
83
+ * available width; `padding.top` grows by `INLINE_LEGEND_ROW_HEIGHT` per
84
+ * row so the plot stays clear of the legend strip.
50
85
  */
51
86
  export function useChartPadding(opts: ChartPaddingOptions) {
87
+ // Horizontal padding is computed first because `innerW` depends only
88
+ // on left/right (not on legend row count). Splitting padding this way
89
+ // keeps the legend layout — which depends on `innerW` — out of the
90
+ // reactivity cycle for left/right.
91
+ const horizontalPadding = computed(() => {
92
+ const extra = resolvePadding(opts.extraPadding?.());
93
+ return {
94
+ left: (opts.yLabel() ? 56 : 50) + extra.left,
95
+ right: 10 + extra.right,
96
+ };
97
+ });
98
+
99
+ const innerW = computed(
100
+ () =>
101
+ opts.width() -
102
+ horizontalPadding.value.left -
103
+ horizontalPadding.value.right,
104
+ );
105
+
106
+ const inlineLegendLayout = computed<{
107
+ positions: PositionedLegendItem[];
108
+ rowCount: number;
109
+ }>(() => {
110
+ const labels = opts.inlineLegendLabels();
111
+ if (labels.length === 0) return { positions: [], rowCount: 0 };
112
+ const available = Math.max(0, innerW.value);
113
+ const positions: PositionedLegendItem[] = [];
114
+ let row = 0;
115
+ let x = 0;
116
+ for (const label of labels) {
117
+ const w = estimateLegendItemWidth(label);
118
+ // Wrap to a new row when the item won't fit, except when we're
119
+ // already at row-start (`x === 0`): a single oversized label gets
120
+ // its own row instead of triggering an infinite-wrap loop.
121
+ if (x > 0 && x + w > available) {
122
+ row++;
123
+ x = 0;
124
+ }
125
+ positions.push({ x, row });
126
+ x += w + LEGEND_ITEM_GAP;
127
+ }
128
+ return { positions, rowCount: row + 1 };
129
+ });
130
+
52
131
  const padding = computed(() => {
53
132
  const extra = resolvePadding(opts.extraPadding?.());
133
+ const rowCount = inlineLegendLayout.value.rowCount;
54
134
  return {
55
135
  top:
56
136
  (opts.title() ? 26 : 10) +
57
- (opts.hasInlineLegend() ? INLINE_LEGEND_HEIGHT : 0) +
137
+ rowCount * INLINE_LEGEND_ROW_HEIGHT +
58
138
  extra.top,
59
- right: 10 + extra.right,
60
139
  bottom: (opts.xLabel() ? 38 : 30) + extra.bottom,
61
- left: (opts.yLabel() ? 56 : 50) + extra.left,
140
+ left: horizontalPadding.value.left,
141
+ right: horizontalPadding.value.right,
62
142
  };
63
143
  });
64
- // Y-center of the inline legend strip. Sits at the top of the padding
65
- // band (just below any title), so any user-supplied `extraPadding.top`
66
- // becomes empty room between the legend and the plot — useful for
67
- // annotations that need to extend above the data area.
144
+
145
+ // Y-center of the first inline-legend row. Sits at the top of the
146
+ // padding band (just below any title), so any user-supplied
147
+ // `extraPadding.top` becomes empty room between the legend and the
148
+ // plot. Subsequent rows are offset by `INLINE_LEGEND_ROW_HEIGHT`.
68
149
  const legendY = computed(
69
- () => (opts.title() ? 26 : 10) + INLINE_LEGEND_HEIGHT / 2,
70
- );
71
- const innerW = computed(
72
- () => opts.width() - padding.value.left - padding.value.right,
150
+ () => (opts.title() ? 26 : 10) + INLINE_LEGEND_ROW_HEIGHT / 2,
73
151
  );
152
+
74
153
  const innerH = computed(
75
154
  () => opts.height() - padding.value.top - padding.value.bottom,
76
155
  );
@@ -88,7 +167,14 @@ export function useChartPadding(opts: ChartPaddingOptions) {
88
167
  height: innerH.value,
89
168
  };
90
169
  });
91
- return { padding, legendY, innerW, innerH, bounds };
170
+ return {
171
+ padding,
172
+ legendY,
173
+ inlineLegendLayout,
174
+ innerW,
175
+ innerH,
176
+ bounds,
177
+ };
92
178
  }
93
179
 
94
180
  export type ChartBounds = {
@@ -15,23 +15,27 @@ export interface ChartSizeOptions {
15
15
  export function useChartSize(opts: ChartSizeOptions = {}) {
16
16
  const containerRef = ref<HTMLElement | null>(null);
17
17
  const measuredWidth = ref(0);
18
+ const measuredHeight = ref(0);
18
19
  let observer: ResizeObserver | null = null;
19
20
  let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
20
21
 
21
22
  onMounted(() => {
22
23
  if (!containerRef.value) return;
23
24
  measuredWidth.value = containerRef.value.clientWidth;
25
+ measuredHeight.value = containerRef.value.clientHeight;
24
26
  observer = new ResizeObserver((entries) => {
25
27
  const entry = entries[0];
26
28
  if (!entry) return;
29
+ const apply = () => {
30
+ measuredWidth.value = entry.contentRect.width;
31
+ measuredHeight.value = entry.contentRect.height;
32
+ };
27
33
  const debounce = opts.debounce?.();
28
34
  if (debounce) {
29
35
  if (resizeTimeout) clearTimeout(resizeTimeout);
30
- resizeTimeout = setTimeout(() => {
31
- measuredWidth.value = entry.contentRect.width;
32
- }, debounce);
36
+ resizeTimeout = setTimeout(apply, debounce);
33
37
  } else {
34
- measuredWidth.value = entry.contentRect.width;
38
+ apply();
35
39
  }
36
40
  });
37
41
  observer.observe(containerRef.value);
@@ -45,5 +49,10 @@ export function useChartSize(opts: ChartSizeOptions = {}) {
45
49
  return {
46
50
  containerRef,
47
51
  measuredWidth,
48
- } as { containerRef: Ref<HTMLElement | null>; measuredWidth: Ref<number> };
52
+ measuredHeight,
53
+ } as {
54
+ containerRef: Ref<HTMLElement | null>;
55
+ measuredWidth: Ref<number>;
56
+ measuredHeight: Ref<number>;
57
+ };
49
58
  }
package/charts/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import "./_shared/fullscreen.css";
2
+
1
3
  export {
2
4
  default as LineChart,
3
5
  type LineChartData,
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.9",
2
+ "version": "0.4.11",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -2,7 +2,7 @@ import {
2
2
  postWithTransfer,
3
3
  postModelOutputsWithTransfer,
4
4
  postErrorWithTransfer,
5
- } from "@cfasim-ui/shared";
5
+ } from "@cfasim-ui/shared/transfer";
6
6
  import type { ColumnDescriptor, ModelOutputsWire } from "@cfasim-ui/shared";
7
7
 
8
8
  interface RunMessage {
@@ -231,12 +231,17 @@ function send(id: number, result: unknown) {
231
231
  }
232
232
  }
233
233
 
234
+ // Silence the unhandled-rejection warning if init fails before any message
235
+ // arrives. Each message's onmessage handler still awaits the promise inside
236
+ // its own try/catch, so the rejection is reported back to the caller.
237
+ pyodideReadyPromise.catch(() => {});
238
+
234
239
  self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
235
- const pyodide = await pyodideReadyPromise;
236
240
  const msg = event.data;
237
241
  const { id } = msg;
238
242
 
239
243
  try {
244
+ const pyodide = await pyodideReadyPromise;
240
245
  if (msg.type === "loadModule") {
241
246
  await ensureModule(pyodide, msg.module);
242
247
  postWithTransfer(self, id, true);