@cfasim-ui/charts 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/charts",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Chart visualization components for cfasim-ui",
6
6
  "license": "Apache-2.0",
@@ -16,7 +16,8 @@
16
16
  ".": "./src/index.ts"
17
17
  },
18
18
  "dependencies": {
19
- "@cfasim-ui/shared": "0.1.0"
19
+ "reka-ui": "^2.9.2",
20
+ "@cfasim-ui/shared": "0.1.2"
20
21
  },
21
22
  "peerDependencies": {
22
23
  "vue": "^3.5.0"
@@ -0,0 +1,140 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ DropdownMenuRoot,
4
+ DropdownMenuTrigger,
5
+ DropdownMenuPortal,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ } from "reka-ui";
9
+
10
+ export interface ChartMenuItem {
11
+ label: string;
12
+ action: () => void;
13
+ }
14
+
15
+ defineProps<{
16
+ items: ChartMenuItem[];
17
+ }>();
18
+ </script>
19
+
20
+ <template>
21
+ <div class="chart-menu-trigger-area">
22
+ <!-- Single item: plain button -->
23
+ <button
24
+ v-if="items.length === 1"
25
+ class="chart-menu-button chart-menu-single"
26
+ :aria-label="items[0].label"
27
+ @click="items[0].action"
28
+ >
29
+ <svg
30
+ width="14"
31
+ height="14"
32
+ viewBox="0 0 14 14"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ stroke-width="1.5"
36
+ stroke-linecap="round"
37
+ stroke-linejoin="round"
38
+ aria-hidden="true"
39
+ >
40
+ <path d="M7 1v8M3 6l4 4 4-4M2 13h10" />
41
+ </svg>
42
+ </button>
43
+ <!-- Multiple items: dropdown menu -->
44
+ <DropdownMenuRoot v-else>
45
+ <DropdownMenuTrigger class="chart-menu-button" aria-label="Chart options">
46
+ <svg
47
+ width="16"
48
+ height="16"
49
+ viewBox="0 0 16 16"
50
+ fill="currentColor"
51
+ aria-hidden="true"
52
+ >
53
+ <circle cx="3" cy="8" r="1.5" />
54
+ <circle cx="8" cy="8" r="1.5" />
55
+ <circle cx="13" cy="8" r="1.5" />
56
+ </svg>
57
+ </DropdownMenuTrigger>
58
+ <DropdownMenuPortal>
59
+ <DropdownMenuContent
60
+ class="chart-menu-content"
61
+ :side-offset="4"
62
+ align="end"
63
+ >
64
+ <DropdownMenuItem
65
+ v-for="item in items"
66
+ :key="item.label"
67
+ class="chart-menu-item"
68
+ @select="item.action"
69
+ >
70
+ {{ item.label }}
71
+ </DropdownMenuItem>
72
+ </DropdownMenuContent>
73
+ </DropdownMenuPortal>
74
+ </DropdownMenuRoot>
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped>
79
+ .chart-menu-trigger-area {
80
+ position: absolute;
81
+ top: 0;
82
+ right: 0;
83
+ z-index: 1;
84
+ }
85
+
86
+ .chart-menu-button {
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ width: 28px;
91
+ height: 28px;
92
+ border: 1px solid var(--color-border);
93
+ border-radius: 0.25em;
94
+ background: var(--color-bg-0, #fff);
95
+ color: var(--color-text-secondary);
96
+ cursor: pointer;
97
+ opacity: 0;
98
+ transition: opacity 0.15s;
99
+ }
100
+
101
+ .chart-menu-button[data-state="open"] {
102
+ opacity: 1;
103
+ }
104
+
105
+ .chart-menu-button:hover {
106
+ background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
107
+ color: var(--color-text);
108
+ }
109
+ </style>
110
+
111
+ <style>
112
+ .chart-menu-content {
113
+ z-index: 100;
114
+ background: var(--color-bg-0);
115
+ border: 1px solid var(--color-border);
116
+ border-radius: 0.25em;
117
+ padding: 0.25em;
118
+ box-shadow:
119
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
120
+ 0 2px 4px -2px rgba(0, 0, 0, 0.1);
121
+ min-width: 140px;
122
+ }
123
+
124
+ .chart-menu-item {
125
+ display: flex;
126
+ align-items: center;
127
+ padding: 0.375em 0.5em;
128
+ border-radius: 0.25em;
129
+ font-size: var(--font-size-sm);
130
+ cursor: pointer;
131
+ user-select: none;
132
+ outline: none;
133
+ white-space: nowrap;
134
+ }
135
+
136
+ .chart-menu-item[data-highlighted] {
137
+ background: var(--color-primary);
138
+ color: white;
139
+ }
140
+ </style>
@@ -0,0 +1,44 @@
1
+ export function downloadBlob(blob: Blob, name: string) {
2
+ const url = URL.createObjectURL(blob);
3
+ const a = document.createElement("a");
4
+ a.href = url;
5
+ a.download = name;
6
+ a.click();
7
+ URL.revokeObjectURL(url);
8
+ }
9
+
10
+ export function saveSvg(svg: SVGSVGElement, filename: string) {
11
+ const clone = svg.cloneNode(true) as SVGSVGElement;
12
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
13
+ const xml = new XMLSerializer().serializeToString(clone);
14
+ downloadBlob(new Blob([xml], { type: "image/svg+xml" }), `${filename}.svg`);
15
+ }
16
+
17
+ export function savePng(svg: SVGSVGElement, filename: string) {
18
+ const clone = svg.cloneNode(true) as SVGSVGElement;
19
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
20
+ const xml = new XMLSerializer().serializeToString(clone);
21
+ const svgBlob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" });
22
+ const url = URL.createObjectURL(svgBlob);
23
+ const img = new Image();
24
+ const w = svg.width.baseVal.value || svg.clientWidth;
25
+ const h = svg.height.baseVal.value || svg.clientHeight;
26
+ const scale = 2;
27
+ img.onload = () => {
28
+ const canvas = document.createElement("canvas");
29
+ canvas.width = w * scale;
30
+ canvas.height = h * scale;
31
+ const ctx = canvas.getContext("2d")!;
32
+ ctx.scale(scale, scale);
33
+ ctx.drawImage(img, 0, 0, w, h);
34
+ canvas.toBlob((blob) => {
35
+ if (blob) downloadBlob(blob, `${filename}.png`);
36
+ });
37
+ URL.revokeObjectURL(url);
38
+ };
39
+ img.src = url;
40
+ }
41
+
42
+ export function downloadCsv(csv: string, filename: string) {
43
+ downloadBlob(new Blob([csv], { type: "text/csv" }), `${filename}.csv`);
44
+ }
@@ -2,6 +2,9 @@
2
2
  import { computed } from "vue";
3
3
  import type { CSSProperties } from "vue";
4
4
  import type { ModelOutput } from "@cfasim-ui/shared";
5
+ import ChartMenu from "../ChartMenu/ChartMenu.vue";
6
+ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
7
+ import { downloadCsv } from "../ChartMenu/download.js";
5
8
 
6
9
  export type TableRecord = Record<string, ArrayLike<number | string | boolean>>;
7
10
  export type TableData = TableRecord | ModelOutput;
@@ -21,11 +24,15 @@ const COLUMN_WIDTHS: Record<ColumnWidth, string> = {
21
24
  large: "250px",
22
25
  };
23
26
 
24
- const props = defineProps<{
25
- data: TableData;
26
- maxRows?: number;
27
- columnConfig?: Record<string, ColumnConfig>;
28
- }>();
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ data: TableData;
30
+ maxRows?: number;
31
+ columnConfig?: Record<string, ColumnConfig>;
32
+ menu?: boolean | string;
33
+ }>(),
34
+ { menu: true },
35
+ );
29
36
 
30
37
  function columnLabel(name: string): string {
31
38
  return props.columnConfig?.[name]?.label ?? name;
@@ -86,46 +93,89 @@ function cellValue(col: Column, row: number): string {
86
93
  if (typeof v === "boolean") return v ? "true" : "false";
87
94
  return String(v);
88
95
  }
96
+
97
+ function menuFilename() {
98
+ return typeof props.menu === "string" ? props.menu : "data";
99
+ }
100
+
101
+ function escapeCsvField(val: string): string {
102
+ if (val.includes(",") || val.includes('"') || val.includes("\n")) {
103
+ return `"${val.replace(/"/g, '""')}"`;
104
+ }
105
+ return val;
106
+ }
107
+
108
+ function toCsv(): string {
109
+ const cols = columns.value;
110
+ const rows = rowCount.value;
111
+ const headers = cols.map((c) => escapeCsvField(columnLabel(c.name)));
112
+ const lines = [headers.join(",")];
113
+ for (let r = 0; r < rows; r++) {
114
+ const cells = cols.map((c) => escapeCsvField(cellValue(c, r)));
115
+ lines.push(cells.join(","));
116
+ }
117
+ return lines.join("\n");
118
+ }
119
+
120
+ const menuItems = computed<ChartMenuItem[]>(() => [
121
+ { label: "Download CSV", action: () => downloadCsv(toCsv(), menuFilename()) },
122
+ ]);
89
123
  </script>
90
124
 
91
125
  <template>
92
- <div class="TableWrapper">
93
- <table class="Table">
94
- <colgroup>
95
- <col
96
- v-for="col in columns"
97
- :key="col.name"
98
- :style="columnStyle(col.name)"
99
- />
100
- </colgroup>
101
- <thead>
102
- <tr>
103
- <th
104
- v-for="col in columns"
105
- :key="col.name"
106
- :style="columnAlignStyle(col.name)"
107
- >
108
- {{ columnLabel(col.name) }}
109
- </th>
110
- </tr>
111
- </thead>
112
- <tbody>
113
- <tr v-for="row in rowCount" :key="row">
114
- <td
126
+ <div class="TableOuter" :class="{ 'has-menu': menu }">
127
+ <ChartMenu v-if="menu" :items="menuItems" />
128
+ <div class="TableWrapper">
129
+ <table class="Table">
130
+ <colgroup>
131
+ <col
115
132
  v-for="col in columns"
116
133
  :key="col.name"
117
- :class="columnConfig?.[col.name]?.cellClass"
118
- :style="columnAlignStyle(col.name)"
119
- >
120
- {{ cellValue(col, row - 1) }}
121
- </td>
122
- </tr>
123
- </tbody>
124
- </table>
134
+ :style="columnStyle(col.name)"
135
+ />
136
+ </colgroup>
137
+ <thead>
138
+ <tr>
139
+ <th
140
+ v-for="col in columns"
141
+ :key="col.name"
142
+ :style="columnAlignStyle(col.name)"
143
+ >
144
+ {{ columnLabel(col.name) }}
145
+ </th>
146
+ </tr>
147
+ </thead>
148
+ <tbody>
149
+ <tr v-for="row in rowCount" :key="row">
150
+ <td
151
+ v-for="col in columns"
152
+ :key="col.name"
153
+ :class="columnConfig?.[col.name]?.cellClass"
154
+ :style="columnAlignStyle(col.name)"
155
+ >
156
+ {{ cellValue(col, row - 1) }}
157
+ </td>
158
+ </tr>
159
+ </tbody>
160
+ </table>
161
+ </div>
125
162
  </div>
126
163
  </template>
127
164
 
128
165
  <style scoped>
166
+ .TableOuter {
167
+ position: relative;
168
+ display: inline-block;
169
+ }
170
+
171
+ .TableOuter.has-menu {
172
+ padding-top: 32px;
173
+ }
174
+
175
+ .TableOuter:hover :deep(.chart-menu-button) {
176
+ opacity: 1;
177
+ }
178
+
129
179
  .TableWrapper {
130
180
  overflow-x: auto;
131
181
  font-size: var(--font-size-sm);
@@ -5,6 +5,7 @@ test("LineChart page renders demos", async ({ page }) => {
5
5
  await expect(page.locator("h1")).toBeVisible();
6
6
  const demos = page.locator(".demo-preview");
7
7
  await expect(demos.first()).toBeVisible();
8
- await expect(demos.first().locator("svg")).toBeVisible();
9
- await expect(demos.first().locator("svg path")).toBeAttached();
8
+ const chartSvg = demos.first().locator("svg").last();
9
+ await expect(chartSvg).toBeVisible();
10
+ await expect(chartSvg.locator("path")).toBeAttached();
10
11
  });
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, onMounted, onUnmounted } from "vue";
3
+ import ChartMenu from "../ChartMenu/ChartMenu.vue";
4
+ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
5
+ import { saveSvg, savePng, downloadCsv } from "../ChartMenu/download.js";
3
6
 
4
7
  export interface Series {
5
8
  data: number[];
@@ -16,14 +19,17 @@ const props = withDefaults(
16
19
  width?: number;
17
20
  height?: number;
18
21
  lineOpacity?: number;
22
+ title?: string;
19
23
  xLabel?: string;
20
24
  yLabel?: string;
21
25
  debounce?: number;
26
+ menu?: boolean | string;
22
27
  }>(),
23
- { lineOpacity: 1 },
28
+ { lineOpacity: 1, menu: true },
24
29
  );
25
30
 
26
31
  const containerRef = ref<HTMLElement | null>(null);
32
+ const svgRef = ref<SVGSVGElement | null>(null);
27
33
  const measuredWidth = ref(0);
28
34
  let observer: ResizeObserver | null = null;
29
35
  let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -56,7 +62,7 @@ const width = computed(() => props.width ?? (measuredWidth.value || 400));
56
62
  const height = computed(() => props.height ?? 200);
57
63
 
58
64
  const padding = computed(() => ({
59
- top: 10,
65
+ top: props.title ? 30 : 10,
60
66
  right: 10,
61
67
  bottom: props.xLabel ? 46 : 30,
62
68
  left: props.yLabel ? 66 : 50,
@@ -170,11 +176,72 @@ const xTicks = computed(() => {
170
176
  }
171
177
  return ticks;
172
178
  });
179
+
180
+ function menuFilename() {
181
+ return typeof props.menu === "string" ? props.menu : "chart";
182
+ }
183
+
184
+ function getSvgEl(): SVGSVGElement | null {
185
+ return svgRef.value;
186
+ }
187
+
188
+ function toCsv(): string {
189
+ const series = allSeries.value;
190
+ if (series.length === 0) return "";
191
+ const len = maxLen.value;
192
+ const headers =
193
+ series.length === 1
194
+ ? ["index", "value"]
195
+ : ["index", ...series.map((_, i) => `series_${i}`)];
196
+ const rows = [headers.join(",")];
197
+ for (let r = 0; r < len; r++) {
198
+ const cells = [r.toString()];
199
+ for (const s of series) {
200
+ cells.push(r < s.data.length ? String(s.data[r]) : "");
201
+ }
202
+ rows.push(cells.join(","));
203
+ }
204
+ return rows.join("\n");
205
+ }
206
+
207
+ const menuItems = computed<ChartMenuItem[]>(() => {
208
+ const fname = menuFilename();
209
+ return [
210
+ {
211
+ label: "Save as SVG",
212
+ action: () => {
213
+ const el = getSvgEl();
214
+ if (el) saveSvg(el, fname);
215
+ },
216
+ },
217
+ {
218
+ label: "Save as PNG",
219
+ action: () => {
220
+ const el = getSvgEl();
221
+ if (el) savePng(el, fname);
222
+ },
223
+ },
224
+ { label: "Download CSV", action: () => downloadCsv(toCsv(), fname) },
225
+ ];
226
+ });
173
227
  </script>
174
228
 
175
229
  <template>
176
- <div ref="containerRef" style="width: 100%">
177
- <svg :width="width" :height="height">
230
+ <div ref="containerRef" class="line-chart-wrapper">
231
+ <ChartMenu v-if="menu" :items="menuItems" />
232
+ <svg ref="svgRef" :width="width" :height="height">
233
+ <!-- title -->
234
+ <text
235
+ v-if="title"
236
+ :x="width / 2"
237
+ :y="18"
238
+ text-anchor="middle"
239
+ font-size="14"
240
+ font-weight="600"
241
+ fill="currentColor"
242
+ >
243
+ {{ title }}
244
+ </text>
178
245
  <!-- axes -->
179
246
  <line
180
247
  :x1="padding.left"
@@ -256,3 +323,14 @@ const xTicks = computed(() => {
256
323
  </svg>
257
324
  </div>
258
325
  </template>
326
+
327
+ <style scoped>
328
+ .line-chart-wrapper {
329
+ position: relative;
330
+ width: 100%;
331
+ }
332
+
333
+ .line-chart-wrapper:hover :deep(.chart-menu-button) {
334
+ opacity: 1;
335
+ }
336
+ </style>