@cfasim-ui/charts 0.7.7 → 0.8.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.
Files changed (63) hide show
  1. package/dist/BarChart/BarChart.d.ts +9 -26
  2. package/dist/ChartMenu/ChartMenu.d.ts +3 -2
  3. package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
  4. package/dist/ChoroplethMap/ChoroplethMap.d.ts +28 -189
  5. package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
  6. package/dist/ChoroplethMap/canvasLayer.d.ts +27 -3
  7. package/dist/DataTable/DataTable.d.ts +3 -2
  8. package/dist/LineChart/LineChart.d.ts +9 -26
  9. package/dist/_shared/ChartAnnotations.d.ts +3 -2
  10. package/dist/_shared/ChartZoomControls.d.ts +3 -2
  11. package/dist/_shared/index.d.ts +2 -1
  12. package/dist/_shared/mapTheme.d.ts +159 -0
  13. package/dist/_shared/mapTheme.test.d.ts +1 -0
  14. package/dist/index.css +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +1710 -1432
  17. package/docs/BarChart.md +776 -0
  18. package/docs/ChoroplethMap.md +1267 -0
  19. package/docs/DataTable.md +386 -0
  20. package/docs/LineChart.md +1267 -0
  21. package/docs/index.json +56 -0
  22. package/package.json +25 -21
  23. package/src/BarChart/BarChart.md +743 -0
  24. package/src/BarChart/BarChart.vue +1901 -0
  25. package/src/ChartMenu/ChartMenu.vue +220 -0
  26. package/src/ChartMenu/download.ts +120 -0
  27. package/src/ChartTooltip/ChartTooltip.vue +97 -0
  28. package/src/ChoroplethMap/ChoroplethMap.md +1227 -0
  29. package/src/ChoroplethMap/ChoroplethMap.vue +3676 -0
  30. package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
  31. package/src/ChoroplethMap/canvasLayer.ts +373 -0
  32. package/src/ChoroplethMap/cityLayout.ts +261 -0
  33. package/src/ChoroplethMap/hsaMapping.ts +4116 -0
  34. package/src/DataTable/DataTable.md +372 -0
  35. package/src/DataTable/DataTable.vue +406 -0
  36. package/src/LineChart/LineChart.md +1225 -0
  37. package/src/LineChart/LineChart.vue +1555 -0
  38. package/src/_shared/ChartAnnotations.vue +420 -0
  39. package/src/_shared/ChartZoomControls.vue +138 -0
  40. package/src/_shared/annotations.ts +106 -0
  41. package/src/_shared/axes.ts +69 -0
  42. package/src/_shared/chartProps.ts +201 -0
  43. package/src/_shared/computeTicks.ts +42 -0
  44. package/src/_shared/contrast.ts +100 -0
  45. package/src/_shared/dateAxis.ts +501 -0
  46. package/src/_shared/index.ts +78 -0
  47. package/src/_shared/mapTheme.ts +551 -0
  48. package/src/_shared/scale.ts +86 -0
  49. package/src/_shared/seriesCsv.ts +68 -0
  50. package/src/_shared/touch.ts +8 -0
  51. package/src/_shared/useChartFoundation.ts +175 -0
  52. package/src/_shared/useChartFullscreen.ts +254 -0
  53. package/src/_shared/useChartMenu.ts +111 -0
  54. package/src/_shared/useChartPadding.ts +235 -0
  55. package/src/_shared/useChartSize.ts +58 -0
  56. package/src/_shared/useChartTooltip.ts +205 -0
  57. package/src/env.d.ts +4 -0
  58. package/src/hsa-mapping.ts +1 -0
  59. package/src/index.ts +41 -0
  60. package/src/tooltip-position.ts +55 -0
  61. package/src/us-cities/data.ts +1371 -0
  62. package/src/us-cities/index.ts +122 -0
  63. package/src/us-cities.ts +7 -0
@@ -0,0 +1,406 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import type { CSSProperties } from "vue";
4
+ import {
5
+ formatNumber,
6
+ type ModelOutput,
7
+ type NumberFormat,
8
+ } from "@cfasim-ui/shared";
9
+ import ChartMenu from "../ChartMenu/ChartMenu.vue";
10
+ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
11
+ import { downloadCsv } from "../ChartMenu/download.js";
12
+ import { escapeCsvField } from "../_shared/seriesCsv.js";
13
+
14
+ export type TableRecord = Record<string, ArrayLike<number | string | boolean>>;
15
+ export type TableData = TableRecord | ModelOutput;
16
+ export type ColumnWidth = "small" | "medium" | "large";
17
+ export type ColumnAlign = "left" | "center" | "right";
18
+ export type CellValue = number | string | boolean;
19
+ export type ColumnFormatter = (value: CellValue, row: number) => string;
20
+
21
+ export interface ColumnConfig {
22
+ label?: string;
23
+ width?: ColumnWidth | number;
24
+ align?: ColumnAlign;
25
+ /** Class applied to every body `<td>` in this column. */
26
+ cellClass?: string;
27
+ /**
28
+ * Class applied to both the header `<th>` and every body `<td>` in
29
+ * this column. Use for styling that should span the whole column
30
+ * (borders, backgrounds, fonts). For body-only styling, use
31
+ * `cellClass`.
32
+ */
33
+ columnClass?: string;
34
+ /**
35
+ * Allow cell contents to wrap to multiple lines when wider than the
36
+ * column. Default `false` — cells stay on one line and truncate with
37
+ * an ellipsis. Set to `true` for text-heavy columns where the full
38
+ * value should remain visible.
39
+ */
40
+ wrap?: boolean;
41
+ /**
42
+ * Custom formatter for cell values in this column. Accepts a
43
+ * {@link NumberFormat} (preset name, printf-style string, or
44
+ * `(value) => string` function — see `formatNumber` in
45
+ * `@cfasim-ui/shared`) or a `(value, row) => string` function for full
46
+ * control. Number presets/sprintf only apply to numeric cells; other
47
+ * types fall back to default rendering. Used in CSV exports.
48
+ */
49
+ format?: NumberFormat | ColumnFormatter;
50
+ }
51
+
52
+ const COLUMN_WIDTHS: Record<ColumnWidth, string> = {
53
+ small: "80px",
54
+ medium: "150px",
55
+ large: "250px",
56
+ };
57
+
58
+ const props = withDefaults(
59
+ defineProps<{
60
+ data: TableData;
61
+ maxRows?: number;
62
+ columnConfig?: Record<string, ColumnConfig>;
63
+ menu?: boolean | string;
64
+ /**
65
+ * Custom CSV content for the Download menu item. Can be a raw CSV string
66
+ * or a function returning one. When omitted, CSV is generated from the
67
+ * table data.
68
+ */
69
+ csv?: string | (() => string);
70
+ /** Filename (without extension) for downloaded CSV files. */
71
+ filename?: string;
72
+ /**
73
+ * Label for the Download item in the table's top-right menu, and for
74
+ * the button rendered when `downloadButton` is true. Defaults to
75
+ * "Download".
76
+ */
77
+ downloadMenuLink?: string;
78
+ /**
79
+ * Render a visible "Download" button beneath the table instead of
80
+ * exposing the action only via the top-right menu. When enabled, the
81
+ * menu's Download item is suppressed to avoid duplicate controls.
82
+ */
83
+ downloadButton?: boolean;
84
+ /**
85
+ * Render a plain text link beneath the table for downloading CSV.
86
+ * Pass `true` for the default "Download data (CSV)" label, or a
87
+ * string to customize. When set, the menu's Download item is
88
+ * suppressed. Mutually exclusive with `downloadButton`; if both are
89
+ * set, `downloadButton` wins.
90
+ */
91
+ downloadLink?: boolean | string;
92
+ /** Stretch the table to fill its container's width. */
93
+ fullWidth?: boolean;
94
+ }>(),
95
+ {
96
+ menu: true,
97
+ fullWidth: false,
98
+ downloadMenuLink: "Download",
99
+ downloadButton: false,
100
+ },
101
+ );
102
+
103
+ function columnLabel(name: string): string {
104
+ return props.columnConfig?.[name]?.label ?? name;
105
+ }
106
+
107
+ function columnStyle(name: string): Record<string, string> | undefined {
108
+ const w = props.columnConfig?.[name]?.width;
109
+ if (w == null) {
110
+ if (props.fullWidth) return undefined;
111
+ return { width: COLUMN_WIDTHS.medium, minWidth: COLUMN_WIDTHS.medium };
112
+ }
113
+ const value = typeof w === "number" ? `${w}px` : COLUMN_WIDTHS[w];
114
+ return { width: value, minWidth: value };
115
+ }
116
+
117
+ function columnAlignStyle(name: string): CSSProperties | undefined {
118
+ const align = props.columnConfig?.[name]?.align;
119
+ if (!align) return undefined;
120
+ return { textAlign: align };
121
+ }
122
+
123
+ /** Classes shared by `<th>` and `<td>` for a column: `columnClass` + wrap flag. */
124
+ function columnSharedClass(name: string): (string | undefined)[] {
125
+ const cfg = props.columnConfig?.[name];
126
+ return [cfg?.columnClass, cfg?.wrap ? "cell-wrap" : undefined];
127
+ }
128
+
129
+ function headerCellClass(name: string): (string | undefined)[] {
130
+ return columnSharedClass(name);
131
+ }
132
+
133
+ function bodyCellClass(name: string): (string | undefined)[] {
134
+ return [...columnSharedClass(name), props.columnConfig?.[name]?.cellClass];
135
+ }
136
+
137
+ function isModelOutput(d: TableData): d is ModelOutput {
138
+ return typeof (d as ModelOutput).column === "function";
139
+ }
140
+
141
+ interface Column {
142
+ name: string;
143
+ values: ArrayLike<number | string | boolean>;
144
+ enumLabels?: string[];
145
+ }
146
+
147
+ const columns = computed<Column[]>(() => {
148
+ const d = props.data;
149
+ if (isModelOutput(d)) {
150
+ return d.columns.map((col) => ({
151
+ name: col.name,
152
+ values: d.column(col.name),
153
+ enumLabels: col.enumLabels,
154
+ }));
155
+ }
156
+ return Object.entries(d).map(([name, values]) => ({ name, values }));
157
+ });
158
+
159
+ const rowCount = computed(() => {
160
+ const cols = columns.value;
161
+ if (cols.length === 0) return 0;
162
+ let max = 0;
163
+ for (const col of cols) max = Math.max(max, col.values.length);
164
+ return props.maxRows ? Math.min(max, props.maxRows) : max;
165
+ });
166
+
167
+ function cellValue(col: Column, row: number): string {
168
+ const v = col.values[row];
169
+ if (v === undefined || v === null) return "";
170
+ const format = props.columnConfig?.[col.name]?.format;
171
+ if (format !== undefined) {
172
+ // Function variant — either `(value: number) => string` (NumberFormat
173
+ // function) or `(value, row) => string` (ColumnFormatter). Both call
174
+ // sites are compatible; the narrower variant ignores `row`.
175
+ if (typeof format === "function") {
176
+ return (format as ColumnFormatter)(v, row);
177
+ }
178
+ // String preset/sprintf — only applies to numeric cells; other types
179
+ // fall through to default rendering.
180
+ if (typeof v === "number") return formatNumber(v, format);
181
+ }
182
+ if (col.enumLabels && typeof v === "number")
183
+ return col.enumLabels[v] ?? String(v);
184
+ if (typeof v === "number") {
185
+ if (Number.isInteger(v)) return v.toString();
186
+ return v.toFixed(4);
187
+ }
188
+ if (typeof v === "boolean") return v ? "true" : "false";
189
+ return String(v);
190
+ }
191
+
192
+ function menuFilename() {
193
+ if (props.filename) return props.filename;
194
+ return typeof props.menu === "string" ? props.menu : "data";
195
+ }
196
+
197
+ function toCsv(): string {
198
+ if (typeof props.csv === "function") return props.csv();
199
+ if (typeof props.csv === "string") return props.csv;
200
+ const cols = columns.value;
201
+ const rows = rowCount.value;
202
+ const headers = cols.map((c) => escapeCsvField(columnLabel(c.name)));
203
+ const lines = [headers.join(",")];
204
+ for (let r = 0; r < rows; r++) {
205
+ const cells = cols.map((c) => escapeCsvField(cellValue(c, r)));
206
+ lines.push(cells.join(","));
207
+ }
208
+ return lines.join("\n");
209
+ }
210
+
211
+ function triggerDownload() {
212
+ downloadCsv(toCsv(), menuFilename());
213
+ }
214
+
215
+ const menuItems = computed<ChartMenuItem[]>(() => {
216
+ if (props.downloadButton || props.downloadLink) return [];
217
+ return [{ label: props.downloadMenuLink, action: triggerDownload }];
218
+ });
219
+
220
+ const downloadLinkText = computed<string | null>(() => {
221
+ if (props.downloadButton) return null;
222
+ const v = props.downloadLink;
223
+ if (!v) return null;
224
+ return typeof v === "string" ? v : "Download data (CSV)";
225
+ });
226
+
227
+ const csvHref = computed<string | null>(() => {
228
+ if (!downloadLinkText.value) return null;
229
+ return `data:text/csv;charset=utf-8,${encodeURIComponent(toCsv())}`;
230
+ });
231
+
232
+ const showMenu = computed(
233
+ () => Boolean(props.menu) && menuItems.value.length > 0,
234
+ );
235
+ </script>
236
+
237
+ <template>
238
+ <div
239
+ class="TableOuter"
240
+ :class="{ 'full-width': fullWidth, 'has-menu': showMenu }"
241
+ >
242
+ <ChartMenu v-if="showMenu" :items="menuItems" force-dropdown />
243
+ <div class="TableWrapper">
244
+ <table class="Table" :class="{ 'full-width': fullWidth }">
245
+ <colgroup>
246
+ <col
247
+ v-for="col in columns"
248
+ :key="col.name"
249
+ :style="columnStyle(col.name)"
250
+ />
251
+ </colgroup>
252
+ <thead>
253
+ <tr>
254
+ <th
255
+ v-for="col in columns"
256
+ :key="col.name"
257
+ :class="headerCellClass(col.name)"
258
+ :style="columnAlignStyle(col.name)"
259
+ >
260
+ {{ columnLabel(col.name) }}
261
+ </th>
262
+ </tr>
263
+ </thead>
264
+ <tbody>
265
+ <tr v-for="row in rowCount" :key="row">
266
+ <td
267
+ v-for="col in columns"
268
+ :key="col.name"
269
+ :class="bodyCellClass(col.name)"
270
+ :style="columnAlignStyle(col.name)"
271
+ >
272
+ {{ cellValue(col, row - 1) }}
273
+ </td>
274
+ </tr>
275
+ </tbody>
276
+ </table>
277
+ </div>
278
+ <button
279
+ v-if="downloadButton"
280
+ type="button"
281
+ class="data-table-download-button"
282
+ @click="triggerDownload"
283
+ >
284
+ {{ downloadMenuLink }}
285
+ </button>
286
+ <a
287
+ v-else-if="downloadLinkText"
288
+ class="data-table-download-link"
289
+ :href="csvHref!"
290
+ :download="`${menuFilename()}.csv`"
291
+ >
292
+ {{ downloadLinkText }}
293
+ </a>
294
+ </div>
295
+ </template>
296
+
297
+ <style scoped>
298
+ .TableOuter {
299
+ position: relative;
300
+ display: inline-block;
301
+ }
302
+
303
+ .TableOuter.full-width {
304
+ display: block;
305
+ }
306
+
307
+ .TableWrapper {
308
+ overflow-x: auto;
309
+ font-size: var(--font-size-sm);
310
+ }
311
+
312
+ .Table {
313
+ display: table;
314
+ margin: 0;
315
+ border-collapse: collapse;
316
+ font-variant-numeric: tabular-nums;
317
+ border: 1px solid var(--color-border);
318
+ table-layout: fixed;
319
+ }
320
+
321
+ .Table.full-width {
322
+ width: 100%;
323
+ }
324
+
325
+ .Table tr,
326
+ .Table th,
327
+ .Table td {
328
+ background: transparent;
329
+ border: none;
330
+ }
331
+
332
+ .Table th,
333
+ .Table td {
334
+ padding: 0.75em 1.25em;
335
+ white-space: nowrap;
336
+ overflow: hidden;
337
+ text-overflow: ellipsis;
338
+ text-align: left;
339
+ }
340
+
341
+ .Table th.cell-wrap,
342
+ .Table td.cell-wrap {
343
+ white-space: normal;
344
+ overflow: visible;
345
+ text-overflow: clip;
346
+ }
347
+
348
+ .Table th {
349
+ font-weight: 600;
350
+ border-bottom: 1px solid var(--color-border-header);
351
+ position: sticky;
352
+ top: 0;
353
+ }
354
+
355
+ .Table tbody td {
356
+ border-bottom: 1px solid var(--color-border);
357
+ }
358
+
359
+ .Table tbody tr:last-child td {
360
+ border-bottom: none;
361
+ }
362
+
363
+ .TableOuter :deep(.chart-menu-trigger-area) {
364
+ top: 4px;
365
+ right: 4px;
366
+ }
367
+
368
+ .TableOuter :deep(.chart-menu-button) {
369
+ opacity: 1;
370
+ }
371
+
372
+ .TableOuter.has-menu .Table thead th:last-child {
373
+ padding-right: 2.5em;
374
+ }
375
+ </style>
376
+
377
+ <style>
378
+ .data-table-download-button {
379
+ display: inline-flex;
380
+ align-items: center;
381
+ margin-top: 0.75em;
382
+ padding: 0.5em 1em;
383
+ border: 1px solid var(--color-border);
384
+ border-radius: 0.25em;
385
+ background: var(--color-bg-0, #fff);
386
+ color: var(--color-text);
387
+ font-size: var(--font-size-sm);
388
+ cursor: pointer;
389
+ }
390
+
391
+ .data-table-download-button:hover {
392
+ background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
393
+ }
394
+
395
+ .data-table-download-button:focus-visible {
396
+ outline: 2px solid var(--color-primary);
397
+ outline-offset: 2px;
398
+ }
399
+
400
+ .data-table-download-link {
401
+ display: block;
402
+ text-align: right;
403
+ font-size: var(--font-size-sm);
404
+ margin-top: 0.25em;
405
+ }
406
+ </style>