@cfasim-ui/charts 0.1.9 → 0.2.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.
@@ -1,101 +0,0 @@
1
- # DataTable
2
-
3
- A table for displaying columnar data. Accepts a plain record of arrays or a `ModelOutput` from a simulation.
4
-
5
- ## Examples
6
-
7
- ### Basic usage
8
-
9
- <ComponentDemo>
10
- <DataTable :data="{ day: [0, 1, 2, 3, 4], susceptible: [1000, 980, 945, 900, 860], infected: [1, 21, 56, 101, 141] }" />
11
-
12
- <template #code>
13
-
14
- ```vue
15
- <DataTable
16
- :data="{
17
- day: [0, 1, 2, 3, 4],
18
- susceptible: [1000, 980, 945, 900, 860],
19
- infected: [1, 21, 56, 101, 141],
20
- }"
21
- />
22
- ```
23
-
24
- </template>
25
- </ComponentDemo>
26
-
27
- ### Column labels and width
28
-
29
- <ComponentDemo>
30
- <DataTable
31
- :data="{ day: [0, 1, 2, 3, 4], susceptible: [1000, 980, 945, 900, 860], infected: [1, 21, 56, 101, 141] }"
32
- :column-config="{
33
- day: { label: 'Day', width: 'small' },
34
- susceptible: { label: 'Susceptible' },
35
- infected: { label: 'Infected' },
36
- }"
37
- />
38
-
39
- <template #code>
40
-
41
- ```vue
42
- <DataTable
43
- :data="{
44
- day: [0, 1, 2, 3, 4],
45
- susceptible: [1000, 980, 945, 900, 860],
46
- infected: [1, 21, 56, 101, 141],
47
- }"
48
- :column-config="{
49
- day: { label: 'Day', width: 'small' },
50
- susceptible: { label: 'Susceptible' },
51
- infected: { label: 'Infected' },
52
- }"
53
- />
54
- ```
55
-
56
- </template>
57
- </ComponentDemo>
58
-
59
- ### Cell class and max rows
60
-
61
- <ComponentDemo>
62
- <DataTable
63
- :data="{ generation: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], cases: [1, 3, 8, 15, 28, 45, 62, 71, 55, 30] }"
64
- :max-rows="5"
65
- :column-config="{
66
- generation: { label: 'Gen', cellClass: 'text-secondary', width: 50 },
67
- cases: { label: 'Cases' },
68
- }"
69
- />
70
-
71
- <template #code>
72
-
73
- ```vue
74
- <DataTable
75
- :data="{
76
- generation: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
77
- cases: [1, 3, 8, 15, 28, 45, 62, 71, 55, 30],
78
- }"
79
- :max-rows="5"
80
- :column-config="{
81
- generation: { label: 'Gen', cellClass: 'text-secondary', width: 50 },
82
- cases: { label: 'Cases' },
83
- }"
84
- />
85
- ```
86
-
87
- </template>
88
- </ComponentDemo>
89
-
90
- <!--@include: ./_api/data-table.md-->
91
-
92
- ### ColumnConfig
93
-
94
- ```ts
95
- interface ColumnConfig {
96
- label?: string;
97
- width?: "small" | "medium" | "large" | number;
98
- align?: "left" | "center" | "right";
99
- cellClass?: string;
100
- }
101
- ```
@@ -1,177 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { mount } from "@vue/test-utils";
3
- import DataTable from "./DataTable.vue";
4
- import { ModelOutput } from "@cfasim-ui/shared";
5
-
6
- describe("Table", () => {
7
- it("renders a plain record", () => {
8
- const wrapper = mount(DataTable, {
9
- props: {
10
- data: {
11
- day: [0, 1, 2],
12
- infected: [10, 25, 50],
13
- },
14
- },
15
- });
16
- const headers = wrapper.findAll("th");
17
- expect(headers.map((h) => h.text())).toEqual(["day", "infected"]);
18
- const rows = wrapper.findAll("tbody tr");
19
- expect(rows).toHaveLength(3);
20
- expect(rows[0].findAll("td").map((td) => td.text())).toEqual(["0", "10"]);
21
- expect(rows[2].findAll("td").map((td) => td.text())).toEqual(["2", "50"]);
22
- });
23
-
24
- it("formats floats to 4 decimal places", () => {
25
- const wrapper = mount(DataTable, {
26
- props: {
27
- data: { value: [1.23456789] },
28
- },
29
- });
30
- const cell = wrapper.find("tbody td");
31
- expect(cell.text()).toBe("1.2346");
32
- });
33
-
34
- it("renders a ModelOutput", () => {
35
- const output = new ModelOutput(
36
- 2,
37
- [
38
- { name: "day", type: "i32" },
39
- { name: "cases", type: "f64" },
40
- ],
41
- [new Int32Array([0, 1]), new Float64Array([100.5, 200.75])],
42
- );
43
- const wrapper = mount(DataTable, { props: { data: output } });
44
- const headers = wrapper.findAll("th");
45
- expect(headers.map((h) => h.text())).toEqual(["day", "cases"]);
46
- const rows = wrapper.findAll("tbody tr");
47
- expect(rows).toHaveLength(2);
48
- expect(rows[0].findAll("td").map((td) => td.text())).toEqual([
49
- "0",
50
- "100.5000",
51
- ]);
52
- });
53
-
54
- it("renders enum labels from ModelOutput", () => {
55
- const output = new ModelOutput(
56
- 2,
57
- [{ name: "status", type: "enum", enumLabels: ["S", "I", "R"] }],
58
- [new Uint32Array([0, 2])],
59
- );
60
- const wrapper = mount(DataTable, { props: { data: output } });
61
- const cells = wrapper.findAll("tbody td");
62
- expect(cells.map((c) => c.text())).toEqual(["S", "R"]);
63
- });
64
-
65
- it("respects maxRows", () => {
66
- const wrapper = mount(DataTable, {
67
- props: {
68
- data: { x: [1, 2, 3, 4, 5] },
69
- maxRows: 3,
70
- },
71
- });
72
- expect(wrapper.findAll("tbody tr")).toHaveLength(3);
73
- });
74
-
75
- it("handles empty data", () => {
76
- const wrapper = mount(DataTable, { props: { data: {} } });
77
- expect(wrapper.findAll("th")).toHaveLength(0);
78
- expect(wrapper.findAll("tbody tr")).toHaveLength(0);
79
- });
80
-
81
- it("uses columnConfig labels", () => {
82
- const wrapper = mount(DataTable, {
83
- props: {
84
- data: { day: [0], infected: [10] },
85
- columnConfig: {
86
- day: { label: "Day #" },
87
- infected: { label: "Total Infected" },
88
- },
89
- },
90
- });
91
- const headers = wrapper.findAll("th");
92
- expect(headers.map((h) => h.text())).toEqual(["Day #", "Total Infected"]);
93
- });
94
-
95
- it("falls back to column name when no label configured", () => {
96
- const wrapper = mount(DataTable, {
97
- props: {
98
- data: { day: [0], infected: [10] },
99
- columnConfig: { day: { label: "Day #" } },
100
- },
101
- });
102
- const headers = wrapper.findAll("th");
103
- expect(headers.map((h) => h.text())).toEqual(["Day #", "infected"]);
104
- });
105
-
106
- it("applies named column widths", () => {
107
- const wrapper = mount(DataTable, {
108
- props: {
109
- data: { day: [0], infected: [10] },
110
- columnConfig: {
111
- day: { width: "small" },
112
- infected: { width: "large" },
113
- },
114
- },
115
- });
116
- const cols = wrapper.findAll("col");
117
- expect(cols[0].attributes("style")).toContain("width: 80px");
118
- expect(cols[1].attributes("style")).toContain("width: 250px");
119
- });
120
-
121
- it("applies pixel column widths", () => {
122
- const wrapper = mount(DataTable, {
123
- props: {
124
- data: { day: [0] },
125
- columnConfig: { day: { width: 120 } },
126
- },
127
- });
128
- const col = wrapper.find("col");
129
- expect(col.attributes("style")).toContain("width: 120px");
130
- expect(col.attributes("style")).toContain("min-width: 120px");
131
- });
132
-
133
- it("applies column alignment to th and td", () => {
134
- const wrapper = mount(DataTable, {
135
- props: {
136
- data: { day: [0], infected: [10] },
137
- columnConfig: {
138
- day: { align: "center" },
139
- infected: { align: "left" },
140
- },
141
- },
142
- });
143
- const headers = wrapper.findAll("th");
144
- expect(headers[0].attributes("style")).toContain("text-align: center");
145
- expect(headers[1].attributes("style")).toContain("text-align: left");
146
- const cells = wrapper.findAll("tbody td");
147
- expect(cells[0].attributes("style")).toContain("text-align: center");
148
- expect(cells[1].attributes("style")).toContain("text-align: left");
149
- });
150
-
151
- it("uses default alignment when no align configured", () => {
152
- const wrapper = mount(DataTable, {
153
- props: {
154
- data: { day: [0] },
155
- },
156
- });
157
- const th = wrapper.find("th");
158
- expect(th.attributes("style")).toBeUndefined();
159
- });
160
-
161
- it("applies cellClass to td elements", () => {
162
- const wrapper = mount(DataTable, {
163
- props: {
164
- data: { day: [0, 1], value: [10, 20] },
165
- columnConfig: {
166
- day: { cellClass: "text-secondary" },
167
- },
168
- },
169
- });
170
- const rows = wrapper.findAll("tbody tr");
171
- for (const row of rows) {
172
- const cells = row.findAll("td");
173
- expect(cells[0].classes()).toContain("text-secondary");
174
- expect(cells[1].classes()).not.toContain("text-secondary");
175
- }
176
- });
177
- });
@@ -1,217 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from "vue";
3
- import type { CSSProperties } from "vue";
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";
8
-
9
- export type TableRecord = Record<string, ArrayLike<number | string | boolean>>;
10
- export type TableData = TableRecord | ModelOutput;
11
- export type ColumnWidth = "small" | "medium" | "large";
12
- export type ColumnAlign = "left" | "center" | "right";
13
-
14
- export interface ColumnConfig {
15
- label?: string;
16
- width?: ColumnWidth | number;
17
- align?: ColumnAlign;
18
- cellClass?: string;
19
- }
20
-
21
- const COLUMN_WIDTHS: Record<ColumnWidth, string> = {
22
- small: "80px",
23
- medium: "150px",
24
- large: "250px",
25
- };
26
-
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
- );
36
-
37
- function columnLabel(name: string): string {
38
- return props.columnConfig?.[name]?.label ?? name;
39
- }
40
-
41
- function columnStyle(name: string): Record<string, string> | undefined {
42
- const w = props.columnConfig?.[name]?.width;
43
- if (w == null) return undefined;
44
- const value = typeof w === "number" ? `${w}px` : COLUMN_WIDTHS[w];
45
- return { width: value, minWidth: value };
46
- }
47
-
48
- function columnAlignStyle(name: string): CSSProperties | undefined {
49
- const align = props.columnConfig?.[name]?.align;
50
- if (!align) return undefined;
51
- return { textAlign: align };
52
- }
53
-
54
- function isModelOutput(d: TableData): d is ModelOutput {
55
- return typeof (d as ModelOutput).column === "function";
56
- }
57
-
58
- interface Column {
59
- name: string;
60
- values: ArrayLike<number | string | boolean>;
61
- enumLabels?: string[];
62
- }
63
-
64
- const columns = computed<Column[]>(() => {
65
- const d = props.data;
66
- if (isModelOutput(d)) {
67
- return d.columns.map((col) => ({
68
- name: col.name,
69
- values: d.column(col.name),
70
- enumLabels: col.enumLabels,
71
- }));
72
- }
73
- return Object.entries(d).map(([name, values]) => ({ name, values }));
74
- });
75
-
76
- const rowCount = computed(() => {
77
- const cols = columns.value;
78
- if (cols.length === 0) return 0;
79
- let max = 0;
80
- for (const col of cols) max = Math.max(max, col.values.length);
81
- return props.maxRows ? Math.min(max, props.maxRows) : max;
82
- });
83
-
84
- function cellValue(col: Column, row: number): string {
85
- const v = col.values[row];
86
- if (v === undefined || v === null) return "";
87
- if (col.enumLabels && typeof v === "number")
88
- return col.enumLabels[v] ?? String(v);
89
- if (typeof v === "number") {
90
- if (Number.isInteger(v)) return v.toString();
91
- return v.toFixed(4);
92
- }
93
- if (typeof v === "boolean") return v ? "true" : "false";
94
- return String(v);
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
- ]);
123
- </script>
124
-
125
- <template>
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
132
- v-for="col in columns"
133
- :key="col.name"
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>
162
- </div>
163
- </template>
164
-
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
-
179
- .TableWrapper {
180
- overflow-x: auto;
181
- font-size: var(--font-size-sm);
182
- }
183
-
184
- .Table {
185
- border-collapse: collapse;
186
- font-variant-numeric: tabular-nums;
187
- border: 1px solid var(--color-border);
188
- }
189
-
190
- .Table tr,
191
- .Table th,
192
- .Table td {
193
- background: transparent;
194
- border: none;
195
- }
196
-
197
- .Table th,
198
- .Table td {
199
- padding: 0.75em 1.25em;
200
- white-space: nowrap;
201
- }
202
-
203
- .Table th {
204
- font-weight: 600;
205
- border-bottom: 1px solid var(--color-border-header);
206
- position: sticky;
207
- top: 0;
208
- }
209
-
210
- .Table tbody td {
211
- border-bottom: 1px solid var(--color-border);
212
- }
213
-
214
- .Table tbody tr:last-child td {
215
- border-bottom: none;
216
- }
217
- </style>
@@ -1,182 +0,0 @@
1
- # LineChart
2
-
3
- A responsive SVG line chart with support for multiple series, axis labels, and custom styling.
4
-
5
- ## Examples
6
-
7
- ### Single series
8
-
9
- <ComponentDemo>
10
- <LineChart :data="[0, 4, 8, 15, 22, 30, 28, 20, 12, 5, 2]" :height="200" x-label="Days" y-label="Cases" />
11
-
12
- <template #code>
13
-
14
- ```vue
15
- <LineChart
16
- :data="[0, 4, 8, 15, 22, 30, 28, 20, 12, 5, 2]"
17
- :height="200"
18
- x-label="Days"
19
- y-label="Cases"
20
- />
21
- ```
22
-
23
- </template>
24
- </ComponentDemo>
25
-
26
- ### Multiple series
27
-
28
- <ComponentDemo>
29
- <LineChart
30
- :series="[
31
- { data: [0, 10, 25, 45, 60, 55, 40, 20, 8], color: '#fb7e38', strokeWidth: 3 },
32
- { data: [0, 5, 12, 20, 28, 25, 18, 10, 4], color: '#0057b7', strokeWidth: 3 },
33
- ]"
34
- :height="200"
35
- x-label="Weeks"
36
- y-label="Incidence"
37
- />
38
-
39
- <template #code>
40
-
41
- ```vue
42
- <LineChart
43
- :series="[
44
- {
45
- data: [0, 10, 25, 45, 60, 55, 40, 20, 8],
46
- color: '#fb7e38',
47
- strokeWidth: 3,
48
- },
49
- {
50
- data: [0, 5, 12, 20, 28, 25, 18, 10, 4],
51
- color: '#0057b7',
52
- strokeWidth: 3,
53
- },
54
- ]"
55
- :height="200"
56
- x-label="Weeks"
57
- y-label="Incidence"
58
- />
59
- ```
60
-
61
- </template>
62
- </ComponentDemo>
63
-
64
- ### Dashed baseline
65
-
66
- <ComponentDemo>
67
- <LineChart
68
- :series="[
69
- { data: [0, 10, 25, 45, 60, 55, 40, 20, 8], color: '#999', dashed: true, strokeWidth: 2 },
70
- { data: [0, 5, 12, 20, 28, 25, 18, 10, 4], color: '#2563eb', strokeWidth: 2 },
71
- ]"
72
- :height="200"
73
- />
74
-
75
- <template #code>
76
-
77
- ```vue
78
- <LineChart
79
- :series="[
80
- {
81
- data: [0, 10, 25, 45, 60, 55, 40, 20, 8],
82
- color: '#999',
83
- dashed: true,
84
- strokeWidth: 2,
85
- },
86
- {
87
- data: [0, 5, 12, 20, 28, 25, 18, 10, 4],
88
- color: '#2563eb',
89
- strokeWidth: 2,
90
- },
91
- ]"
92
- :height="200"
93
- />
94
- ```
95
-
96
- </template>
97
- </ComponentDemo>
98
-
99
- ### Many trajectories with low opacity
100
-
101
- <ComponentDemo>
102
- <LineChart
103
- :series="Array.from({ length: 20 }, (_, i) => ({
104
- data: Array.from({ length: 50 }, (_, t) => Math.max(0, 30 * Math.sin(t / 8) + (Math.random() - 0.5) * 15 + i * 0.5)),
105
- color: '#0057b7',
106
- }))"
107
- :height="250"
108
- :line-opacity="0.15"
109
- x-label="Days"
110
- y-label="Incidence"
111
- />
112
-
113
- <template #code>
114
-
115
- ```vue
116
- <LineChart
117
- :series="trajectories"
118
- :height="250"
119
- :line-opacity="0.15"
120
- x-label="Days"
121
- y-label="Incidence"
122
- />
123
- ```
124
-
125
- </template>
126
- </ComponentDemo>
127
-
128
- ### Grid lines
129
-
130
- <ComponentDemo>
131
- <LineChart
132
- :series="[
133
- { data: [0, 10, 25, 45, 60, 55, 40, 20, 8], color: '#fb7e38', strokeWidth: 3 },
134
- { data: [0, 5, 12, 20, 28, 25, 18, 10, 4], color: '#0057b7', strokeWidth: 3 },
135
- ]"
136
- :height="200"
137
- x-label="Weeks"
138
- y-label="Incidence"
139
- x-grid
140
- y-grid
141
- />
142
-
143
- <template #code>
144
-
145
- ```vue
146
- <LineChart
147
- :series="[
148
- {
149
- data: [0, 10, 25, 45, 60, 55, 40, 20, 8],
150
- color: '#fb7e38',
151
- strokeWidth: 3,
152
- },
153
- {
154
- data: [0, 5, 12, 20, 28, 25, 18, 10, 4],
155
- color: '#0057b7',
156
- strokeWidth: 3,
157
- },
158
- ]"
159
- :height="200"
160
- x-label="Weeks"
161
- y-label="Incidence"
162
- x-grid
163
- y-grid
164
- />
165
- ```
166
-
167
- </template>
168
- </ComponentDemo>
169
-
170
- <!--@include: ./_api/line-chart.md-->
171
-
172
- ### Series
173
-
174
- ```ts
175
- interface Series {
176
- data: number[];
177
- color?: string;
178
- dashed?: boolean;
179
- strokeWidth?: number;
180
- opacity?: number;
181
- }
182
- ```
@@ -1,11 +0,0 @@
1
- import { test, expect } from "@playwright/test";
2
-
3
- test("LineChart page renders demos", async ({ page }) => {
4
- await page.goto("./cfasim-ui/charts/line-chart");
5
- await expect(page.locator("h1")).toBeVisible();
6
- const demos = page.locator(".demo-preview");
7
- await expect(demos.first()).toBeVisible();
8
- const chartSvg = demos.first().locator("svg").last();
9
- await expect(chartSvg).toBeVisible();
10
- await expect(chartSvg.locator("path")).toBeAttached();
11
- });