@graphenedata/cli 0.0.15 → 0.0.17

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 (117) hide show
  1. package/README.md +174 -0
  2. package/dist/cli/bigQuery-OQUNH3VT.js +75 -0
  3. package/dist/cli/bigQuery-OQUNH3VT.js.map +7 -0
  4. package/dist/cli/chunk-56K2FF57.js +53 -0
  5. package/dist/cli/chunk-56K2FF57.js.map +7 -0
  6. package/dist/cli/chunk-TZTTALAV.js +12868 -0
  7. package/dist/cli/chunk-TZTTALAV.js.map +7 -0
  8. package/dist/cli/cli.js +260 -11196
  9. package/dist/cli/clickhouse-S3BJSKND.js +65 -0
  10. package/dist/cli/clickhouse-S3BJSKND.js.map +7 -0
  11. package/dist/cli/duckdb-TKVMONRK.js +87 -0
  12. package/dist/cli/duckdb-TKVMONRK.js.map +7 -0
  13. package/dist/cli/serve2-S2LL4D4D.js +448 -0
  14. package/dist/cli/serve2-S2LL4D4D.js.map +7 -0
  15. package/dist/cli/snowflake-3VPDEYYP.js +128 -0
  16. package/dist/cli/snowflake-3VPDEYYP.js.map +7 -0
  17. package/dist/index.d.ts +63 -0
  18. package/dist/lang/index.d.ts +63 -0
  19. package/dist/skills/graphene/SKILL.md +156 -95
  20. package/dist/skills/graphene/references/big-value.md +6 -41
  21. package/dist/skills/graphene/references/date-range.md +64 -0
  22. package/dist/skills/graphene/references/dropdown.md +3 -4
  23. package/dist/skills/graphene/references/echarts.md +162 -0
  24. package/dist/skills/graphene/references/gsql.md +55 -25
  25. package/dist/skills/graphene/references/model-gsql.md +70 -0
  26. package/dist/skills/graphene/references/table.md +13 -14
  27. package/dist/skills/graphene/references/text-input.md +2 -1
  28. package/dist/ui/app.css +239 -340
  29. package/dist/ui/component-utilities/dataShaping.ts +484 -0
  30. package/dist/ui/component-utilities/dataSummary.ts +57 -0
  31. package/dist/ui/component-utilities/enrich.ts +793 -0
  32. package/dist/ui/component-utilities/format.ts +177 -0
  33. package/dist/ui/component-utilities/inputUtils.ts +44 -8
  34. package/dist/ui/component-utilities/theme.ts +200 -0
  35. package/dist/ui/component-utilities/themeStores.ts +21 -8
  36. package/dist/ui/component-utilities/types.ts +70 -0
  37. package/dist/ui/components/AreaChart.svelte +57 -105
  38. package/dist/ui/components/BarChart.svelte +71 -129
  39. package/dist/ui/components/BigValue.svelte +24 -40
  40. package/dist/ui/components/Column.svelte +10 -18
  41. package/dist/ui/components/DateRange.svelte +54 -21
  42. package/dist/ui/components/Dropdown.svelte +47 -26
  43. package/dist/ui/components/DropdownOption.svelte +1 -2
  44. package/dist/ui/components/ECharts.svelte +181 -67
  45. package/dist/ui/components/InlineDelta.svelte +50 -31
  46. package/dist/ui/components/LineChart.svelte +54 -125
  47. package/dist/ui/components/PieChart.svelte +27 -37
  48. package/dist/ui/components/QueryLoad.svelte +77 -45
  49. package/dist/ui/components/Row.svelte +2 -1
  50. package/dist/ui/components/ScatterPlot.svelte +52 -0
  51. package/dist/ui/components/Skeleton.svelte +32 -0
  52. package/dist/ui/components/Table.svelte +3 -2
  53. package/dist/ui/components/TableGroupRow.svelte +28 -36
  54. package/dist/ui/components/TableHarness.svelte +32 -0
  55. package/dist/ui/components/TableHeader.svelte +34 -59
  56. package/dist/ui/components/TableRow.svelte +14 -38
  57. package/dist/ui/components/TableSubtotalRow.svelte +18 -21
  58. package/dist/ui/components/TableTotalRow.svelte +27 -37
  59. package/dist/ui/components/TextInput.svelte +13 -12
  60. package/dist/ui/components/Value.svelte +25 -0
  61. package/dist/ui/components/_Table.svelte +72 -70
  62. package/dist/ui/internal/ChartGallery.svelte +527 -0
  63. package/dist/ui/internal/ErrorDisplay.svelte +22 -97
  64. package/dist/ui/internal/LocalApp.svelte +84 -19
  65. package/dist/ui/internal/PageNavGroup.svelte +269 -0
  66. package/dist/ui/internal/Sidebar.svelte +178 -0
  67. package/dist/ui/internal/SidebarToggle.svelte +47 -0
  68. package/dist/ui/internal/StyleGallery.svelte +244 -0
  69. package/dist/ui/internal/clientCache.ts +2 -2
  70. package/dist/ui/internal/pageInputs.svelte.js +292 -0
  71. package/dist/ui/internal/queryEngine.ts +112 -129
  72. package/dist/ui/internal/runSocket.ts +31 -14
  73. package/dist/ui/internal/sidebar.svelte.js +18 -0
  74. package/dist/ui/internal/telemetry.ts +51 -16
  75. package/dist/ui/internal/types.d.ts +7 -0
  76. package/dist/ui/web.js +30 -11
  77. package/package.json +40 -38
  78. package/dist/skills/graphene/references/area-chart.md +0 -95
  79. package/dist/skills/graphene/references/bar-chart.md +0 -112
  80. package/dist/skills/graphene/references/line-chart.md +0 -108
  81. package/dist/skills/graphene/references/pie-chart.md +0 -29
  82. package/dist/skills/graphene/references/value-formats.md +0 -104
  83. package/dist/ui/component-utilities/autoFormatting.js +0 -280
  84. package/dist/ui/component-utilities/builtInFormats.js +0 -481
  85. package/dist/ui/component-utilities/chartContext.js +0 -12
  86. package/dist/ui/component-utilities/chartWindowDebug.js +0 -21
  87. package/dist/ui/component-utilities/checkInputs.js +0 -84
  88. package/dist/ui/component-utilities/convert.js +0 -15
  89. package/dist/ui/component-utilities/dateParsing.js +0 -56
  90. package/dist/ui/component-utilities/dropdownContext.ts +0 -1
  91. package/dist/ui/component-utilities/echarts.js +0 -252
  92. package/dist/ui/component-utilities/echartsThemes.js +0 -443
  93. package/dist/ui/component-utilities/formatTitle.js +0 -24
  94. package/dist/ui/component-utilities/formatting.js +0 -241
  95. package/dist/ui/component-utilities/getColumnExtents.js +0 -79
  96. package/dist/ui/component-utilities/getColumnSummary.js +0 -62
  97. package/dist/ui/component-utilities/getCompletedData.js +0 -122
  98. package/dist/ui/component-utilities/getDistinctCount.js +0 -7
  99. package/dist/ui/component-utilities/getDistinctValues.js +0 -15
  100. package/dist/ui/component-utilities/getSeriesConfig.js +0 -231
  101. package/dist/ui/component-utilities/getSortedData.js +0 -9
  102. package/dist/ui/component-utilities/getStackPercentages.js +0 -45
  103. package/dist/ui/component-utilities/getStackedData.js +0 -19
  104. package/dist/ui/component-utilities/getYAxisIndex.js +0 -15
  105. package/dist/ui/component-utilities/globalContexts.js +0 -1
  106. package/dist/ui/component-utilities/helpers/getCompletedData.helpers.js +0 -119
  107. package/dist/ui/component-utilities/replaceNulls.js +0 -16
  108. package/dist/ui/component-utilities/tableUtils.ts +0 -107
  109. package/dist/ui/component-utilities/tidyWithTypes.js +0 -9
  110. package/dist/ui/components/Area.svelte +0 -214
  111. package/dist/ui/components/Bar.svelte +0 -347
  112. package/dist/ui/components/Chart.svelte +0 -995
  113. package/dist/ui/components/Line.svelte +0 -227
  114. package/dist/ui/internal/NavSidebar.svelte +0 -396
  115. package/dist/ui/internal/theme.ts +0 -60
  116. package/dist/ui/public/inter-latin-ext.woff2 +0 -0
  117. package/dist/ui/public/inter-latin.woff2 +0 -0
@@ -0,0 +1,793 @@
1
+ import type {EChartsConfig, Field, NormalConfig, SeriesWithGroupingHint} from './types.ts'
2
+
3
+ import {applyMissingPointDefaults, applySorting, applyStackPercentage, inlineDataIntoSeries} from './dataShaping.ts'
4
+ import {formatTimeOrdinal, makeTimeFormatter, makeValueFormatter} from './format.ts'
5
+ import {paletteForPath} from './theme.ts'
6
+
7
+ // Enrichment is the process through which we take an echarts config and add in some defaults to make it really nice.
8
+ // A lot of defaulting happens in themes but there are some defaults themes can't handle, like when it depends on the shape of data being charted.
9
+ // Each enrichment function is a small, ideally single-purpose manipulation of the config.
10
+ // As a rule, if the provided config sets something, enrichments will not change it.
11
+
12
+ // Each enrichment must have a comment above it describing what it does, and perhaps why it's needed if it isn't obvious.
13
+ // Enrichments must also have comments inside explaining how they work if the logic is non-trivial
14
+ // Avoid creating new helpers unless the logic is used in several places.
15
+
16
+ // Run enrichment in a fixed order so defaults stay predictable.
17
+ export function enrich(config: EChartsConfig, rows: Record<string, any>[], fields: Field[]) {
18
+ let normalized = normalize(config)
19
+ ensureAxes(normalized)
20
+ ensureTooltip(normalized)
21
+ ensureColors(normalized)
22
+
23
+ // Resolve axis metadata up front so row shaping (like explicit sorting) can use it.
24
+ inferAxesFromEncodedFields(normalized, fields, rows)
25
+ extendValueAxisDomainsForBars(normalized)
26
+
27
+ // Mutate row/field data before dataset creation so synthesized fields are reflected in dataset dimensions.
28
+ applyMissingPointDefaults(normalized, rows)
29
+ applyStackPercentage(normalized, rows, fields)
30
+ applySorting(normalized, rows, fields)
31
+
32
+ let baseDatasetId = ensureDataset(normalized, rows, fields)
33
+ expandSeriesSplitBy(normalized, rows, fields, baseDatasetId)
34
+ expandTreeMapData(normalized, rows, fields)
35
+ expandNodeLinkData(normalized, rows, fields)
36
+ expandThemeRiverData(normalized, rows, fields)
37
+
38
+ // stylistic rules to provide great defaults
39
+ lineSeriesMarkerVisibility(normalized, rows, fields)
40
+ horizontalBarGuard(normalized, fields)
41
+ computeTitleLegendAndGridPadding(normalized)
42
+ applyLegendSelection(normalized)
43
+ hideStackPercentageValueAxis(normalized, fields)
44
+ removeHiddenValueAxisPadding(normalized)
45
+ valueFormatting(normalized, fields)
46
+ timeFormatting(normalized)
47
+ styleSecondaryAxisForSimpleBarLineLayout(normalized, fields)
48
+ applyIntegerYAxisTicks(normalized, rows, fields)
49
+ barLabelPositioning(normalized)
50
+ labelsUseYAxisFormat(normalized, fields)
51
+ addPieTooltips(normalized, fields)
52
+ inlineDataIntoSeries(normalized, rows)
53
+ stackedBarCornerRadius(normalized)
54
+ return normalized
55
+ }
56
+
57
+ // For horizontal bars, count distinct category values so wrappers can size containers.
58
+ export function horizontalBarCount(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
59
+ if (!isHorizontalBar(config)) return 0
60
+
61
+ let categoryFields = config.series
62
+ .filter(series => series?.type === 'bar')
63
+ .map(series => getEncodeField(series, fields, 'y'))
64
+ .filter((f): f is Field => !!f)
65
+
66
+ if (categoryFields.length === 0) return 0
67
+ return Math.max(...categoryFields.map(field => distinctValues(rows, field.name).length))
68
+ }
69
+
70
+ // Normalize options we read in enrichments so later rules can always iterate arrays.
71
+ function normalize(config: EChartsConfig): NormalConfig {
72
+ let target = config as NormalConfig
73
+ target.series = normalizeArray<SeriesWithGroupingHint>(config.series)
74
+ target.xAxis = normalizeArray<NormalConfig['xAxis'][number]>(config.xAxis)
75
+ target.yAxis = normalizeArray<NormalConfig['yAxis'][number]>(config.yAxis)
76
+ target.dataset = normalizeArray<NormalConfig['dataset'][number]>(config.dataset)
77
+ target.grid = normalizeArray<NormalConfig['grid'][number]>(config.grid)
78
+ if (target.grid.length === 0) target.grid.push({} as NormalConfig['grid'][number])
79
+ target.legend = normalizeArray<NormalConfig['legend'][number]>(config.legend)
80
+ target.title = normalizeArray<NormalConfig['title'][number]>(config.title)
81
+
82
+ target.tooltip = normalizeArray<NormalConfig['tooltip'][number]>(config.tooltip).filter(tooltip => tooltip && typeof tooltip === 'object')
83
+ return target
84
+ }
85
+
86
+ // Every chart gets a base dataset sourced from rows.
87
+ // If callers already provided a dataset, we preserve it and make sure we can reference one source dataset by id.
88
+ function ensureDataset(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
89
+ let dimensions = fields.length > 0 ? fields.map(field => field.name) : inferDimensions(rows)
90
+ let baseId = '__graphene_base'
91
+
92
+ if (config.dataset.length === 0) {
93
+ config.dataset.push({id: baseId, source: rows, dimensions})
94
+ return baseId
95
+ }
96
+
97
+ let base = config.dataset.find(entry => entry?.source != null)
98
+ if (!base) {
99
+ config.dataset.unshift({id: baseId, source: rows, dimensions})
100
+ return baseId
101
+ }
102
+
103
+ if (!base.id) base.id = baseId
104
+ if (base.dimensions == null && dimensions.length > 0) base.dimensions = dimensions
105
+ return String(base.id)
106
+ }
107
+
108
+ // We've added `encode.splitBy` as a way to concisely configure a chart whose data should be split into many series.
109
+ // This enrichment takes care of generating both a dataset and a series pointing at that dataset for each distinct value in splitBy.
110
+ // We do this with ECharts dataset filter transforms so wrappers stay small and users don't need to duplicate series configs.
111
+ function expandSeriesSplitBy(config: NormalConfig, rows: Record<string, any>[], fields: Field[], baseDatasetId: string) {
112
+ let expanded: SeriesWithGroupingHint[] = []
113
+
114
+ config.series.forEach((series, templateIndex) => {
115
+ let splitFields = getEncodeFields(series, fields, 'splitBy')
116
+
117
+ // Non-split series pass through unchanged. ECharts will read from the base dataset (index 0) by default.
118
+ if (splitFields.length === 0) {
119
+ expanded.push(series)
120
+ return
121
+ }
122
+
123
+ if (splitFields.length > 2) throw new Error('encode.splitBy supports at most two fields')
124
+
125
+ let sourceDatasetId = series.datasetId ?? baseDatasetId
126
+
127
+ if (splitFields.length === 2) {
128
+ if (series?.type !== 'bar') throw new Error('encode.splitBy with two fields is only supported for bar series')
129
+
130
+ let [groupField, stackField] = splitFields
131
+ let groupValues = distinctValues(rows, groupField.name)
132
+ let stackValues = distinctValues(rows, stackField.name)
133
+ if (groupValues.length === 0 || stackValues.length === 0) return
134
+
135
+ groupValues.forEach((groupValue, groupIndex) => {
136
+ let groupedDatasetId = `__graphene_series_${templateIndex}_${groupIndex}`
137
+ config.dataset.push({
138
+ id: groupedDatasetId,
139
+ fromDatasetId: sourceDatasetId,
140
+ transform: {type: 'filter', config: {dimension: groupField.name, '=': groupValue}},
141
+ })
142
+
143
+ stackValues.forEach((stackValue, stackIndex) => {
144
+ let datasetId = `__graphene_series_${templateIndex}_${groupIndex}_${stackIndex}`
145
+ config.dataset.push({
146
+ id: datasetId,
147
+ fromDatasetId: groupedDatasetId,
148
+ transform: {type: 'filter', config: {dimension: stackField.name, '=': stackValue}},
149
+ })
150
+
151
+ expanded.push(buildSplitSeries(series, datasetId, `${String(groupValue ?? '')} · ${String(stackValue ?? '')}`, String(groupValue ?? '')))
152
+ })
153
+ })
154
+
155
+ return
156
+ }
157
+
158
+ let splitField = splitFields[0]
159
+ let seriesValues = distinctValues(rows, splitField.name)
160
+ if (seriesValues.length === 0) return
161
+
162
+ seriesValues.forEach((seriesValue, valueIndex) => {
163
+ let datasetId = `__graphene_series_${templateIndex}_${valueIndex}`
164
+ config.dataset.push({
165
+ id: datasetId,
166
+ fromDatasetId: sourceDatasetId,
167
+ transform: {type: 'filter', config: {dimension: splitField.name, '=': seriesValue}},
168
+ })
169
+
170
+ expanded.push(buildSplitSeries(series, datasetId, String(seriesValue ?? '')))
171
+ })
172
+ })
173
+
174
+ config.series = expanded
175
+ }
176
+
177
+ // ECharts themeRiver doesn't consume our base dataset shape - it expects rows as [date, value, seriesName] tuples.
178
+ // themeRiver handles its own grouping by seriesName, so we translate `encode.single/value/seriesName` into explicit `data`.
179
+ function expandThemeRiverData(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
180
+ for (let series of config.series) {
181
+ if (series?.type !== 'themeRiver' || series.data != null) continue
182
+
183
+ let singleField = getEncodeField(series, fields, 'single')
184
+ let valueField = getEncodeField(series, fields, 'value')
185
+ let nameField = getEncodeField(series, fields, 'seriesName')
186
+ if (!singleField || !valueField || !nameField) continue
187
+
188
+ series.data = rows.map(row => [row[singleField.name], row[valueField.name], row[nameField.name]])
189
+ delete series.datasetId
190
+ }
191
+ }
192
+
193
+ // Sankey and chord both ignore datasets and want explicit `data` (nodes) and `links` (edges).
194
+ // We build nodes from the distinct source+target names and map each row to a link.
195
+ function expandNodeLinkData(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
196
+ for (let series of config.series) {
197
+ if (series?.type !== 'sankey' && series?.type !== 'chord') continue
198
+ if (series.data != null || series.links != null) continue
199
+
200
+ let sourceField = getEncodeField(series, fields, 'source')
201
+ let targetField = getEncodeField(series, fields, 'target')
202
+ let valueField = getEncodeField(series, fields, 'value')
203
+ if (!sourceField || !targetField || !valueField) continue
204
+
205
+ let nodeNames = new Set<string>()
206
+ for (let row of rows) {
207
+ nodeNames.add(String(row[sourceField.name]))
208
+ nodeNames.add(String(row[targetField.name]))
209
+ }
210
+ series.data = Array.from(nodeNames, name => ({name}))
211
+ series.links = rows.map(row => ({source: String(row[sourceField.name]), target: String(row[targetField.name]), value: row[valueField.name]}))
212
+ delete series.datasetId
213
+ }
214
+ }
215
+
216
+ // ECharts treemap doesn't read from a dataset - it requires an explicit hierarchical `series.data`.
217
+ // We turn our tabular rows into a flat list of leaves keyed by the encoded itemName/value fields.
218
+ // Nested hierarchies could be supported later by accepting a list of itemName fields.
219
+ function expandTreeMapData(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
220
+ for (let series of config.series) {
221
+ if (series?.type !== 'treemap' || series.data != null) continue
222
+
223
+ let nameField = getEncodeField(series, fields, 'itemName')
224
+ let valueField = getEncodeField(series, fields, 'value')
225
+ if (!nameField || !valueField) continue
226
+
227
+ series.data = rows.map(row => ({name: row[nameField.name], value: row[valueField.name]}))
228
+ delete series.datasetId
229
+ }
230
+ }
231
+
232
+ // Produce a concrete series derived from a splitBy template, bound to a filtered dataset.
233
+ function buildSplitSeries(template: SeriesWithGroupingHint, datasetId: string, name: string, stack?: string): SeriesWithGroupingHint {
234
+ let next: SeriesWithGroupingHint = {...template, datasetId}
235
+ if (stack != null) next.stack = stack
236
+ if (next.name == null) next.name = name
237
+ if (next.encode) {
238
+ next.encode = {...next.encode}
239
+ delete next.encode.splitBy
240
+ }
241
+ return next
242
+ }
243
+
244
+ // Ensure cartesian series always have at least one x/y axis object.
245
+ // This gives later enrichments an axis target to infer into, and avoids
246
+ // ECharts runtime errors like `xAxis "0" not found`.
247
+ function ensureAxes(config: NormalConfig) {
248
+ let cartesianSeriesTypes = new Set(['line', 'bar', 'scatter', 'candlestick', 'heatmap', 'boxplot', 'effectScatter'])
249
+ let needsCartesianAxes = config.series.some(series => series?.type != null && cartesianSeriesTypes.has(series.type))
250
+ if (!needsCartesianAxes) return
251
+
252
+ if (!config.xAxis[0]) config.xAxis[0] = {}
253
+ if (!config.yAxis[0]) config.yAxis[0] = {}
254
+ }
255
+
256
+ // Ensure we always have exactly one top-level tooltip object in normalized config.
257
+ function ensureTooltip(config: NormalConfig) {
258
+ if (config.tooltip.length > 0) return
259
+ config.tooltip.push({trigger: 'axis'})
260
+ }
261
+
262
+ // Ensure we have a color palette set for the chart.
263
+ // This rotates by default.
264
+ function ensureColors(config: NormalConfig) {
265
+ config.color ||= paletteForPath()
266
+ }
267
+
268
+ // Infer axis config from encoded field metadata.
269
+ function inferAxesFromEncodedFields(config: NormalConfig, fields: Field[], rows: Record<string, any>[]) {
270
+ for (let [axisIndex, axis] of config.xAxis.entries()) {
271
+ if (!axis) continue
272
+ let seriesOnAxis = config.series.filter(entry => Number(entry?.xAxisIndex ?? 0) === axisIndex)
273
+ let field = seriesOnAxis.map(entry => getEncodeField(entry, fields, 'x')).find(Boolean)
274
+ let inferred = inferAxisFromField(field, rows)
275
+
276
+ config.xAxis[axisIndex] = {...inferred, ...axis, axisLabel: {...inferred.axisLabel, ...axis.axisLabel}, axisPointer: {...inferred.axisPointer, ...axis.axisPointer}}
277
+ }
278
+
279
+ for (let [axisIndex, axis] of config.yAxis.entries()) {
280
+ if (!axis) continue
281
+ let seriesOnAxis = config.series.filter(entry => Number(entry?.yAxisIndex ?? 0) === axisIndex)
282
+ let field = seriesOnAxis.map(entry => getSeriesValueField(entry, fields)).find(Boolean)
283
+ let inferred = inferAxisFromField(field, rows)
284
+
285
+ config.yAxis[axisIndex] = {...inferred, ...axis, axisLabel: {...inferred.axisLabel, ...axis.axisLabel}, axisPointer: {...inferred.axisPointer, ...axis.axisPointer}}
286
+ }
287
+
288
+ // Ordinal x axes already use labels to communicate the bucket boundaries, so
289
+ // the y-axis line reads like an extra vertical grid line at the left edge.
290
+ // Hide the paired y-axis line unless the caller explicitly configured it.
291
+ for (let [axisIndex, axis] of config.xAxis.entries()) {
292
+ if (!axis?.field?.metadata?.timeOrdinal) continue
293
+
294
+ let yAxisIndexes = config.series.filter(entry => Number(entry?.xAxisIndex ?? 0) === axisIndex).map(entry => Number(entry?.yAxisIndex ?? 0))
295
+ for (let yAxisIndex of yAxisIndexes) {
296
+ let yAxis = config.yAxis[yAxisIndex]
297
+ if (!yAxis || yAxis.axisLine?.show != null) continue
298
+ yAxis.axisLine = {...yAxis.axisLine, show: false}
299
+ }
300
+ }
301
+ }
302
+
303
+ // Value-axis bars are centered on their x/y value, so explicit min/max domains clip edge bars.
304
+ // Expand only already-set value domains to give bars half a bucket of breathing room.
305
+ function extendValueAxisDomainsForBars(config: NormalConfig) {
306
+ for (let [dimension, axes] of [
307
+ ['x', config.xAxis],
308
+ ['y', config.yAxis],
309
+ ] as const) {
310
+ for (let [axisIndex, axis] of axes.entries()) {
311
+ let mutable = axis as Record<string, any>
312
+ if (mutable?.type !== 'value' || mutable.min == null || mutable.max == null) continue
313
+
314
+ let hasBarSeries = config.series.some(series => series?.type === 'bar' && Number(series?.[`${dimension}AxisIndex`] ?? 0) === axisIndex)
315
+ if (!hasBarSeries) continue
316
+
317
+ mutable.min -= 0.5
318
+ mutable.max += 0.5
319
+ }
320
+ }
321
+ }
322
+
323
+ // Ensure that times looks nice. Unlike base echarts, we have metadata about the time value we can use.
324
+ function timeFormatting(config: NormalConfig) {
325
+ let tooltip = config.tooltip[0] as Record<string, any> | undefined
326
+ if (tooltip?.axisPointer?.label?.formatter) return
327
+
328
+ for (let axis of config.xAxis) {
329
+ if (!axis || axis.type !== 'time') continue
330
+ if (axis.axisPointer?.label?.formatter != null) continue
331
+
332
+ let timeGrain = String(axis.field?.metadata?.timeGrain || '').toLowerCase()
333
+ if (!timeGrain) continue
334
+
335
+ // axisPointer affects the formatting of the tooltip, but not the axis labels themselves
336
+ axis.axisPointer ||= {}
337
+ axis.axisPointer.label ||= {}
338
+ axis.axisPointer.label.formatter = makeTimeFormatter(axis.field)
339
+ }
340
+ }
341
+
342
+ // Keep line/area markers readable by default.
343
+ // - Respect explicit `showSymbol` from users.
344
+ // - Category/time/ordinal axes: show markers for small series (< 30 points).
345
+ // - Other value axes: hide markers by default.
346
+ function lineSeriesMarkerVisibility(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
347
+ for (let series of config.series) {
348
+ if (series?.type !== 'line' || series.showSymbol != null) continue
349
+
350
+ let axisIndex = Number(series.xAxisIndex ?? 0)
351
+ let axis = config.xAxis[axisIndex]
352
+ if (axis?.type === 'value' && !axis.field?.metadata?.timeOrdinal) {
353
+ series.showSymbol = false
354
+ continue
355
+ }
356
+
357
+ if (axis?.type !== 'category' && axis?.type !== 'time' && axis?.type !== 'value') {
358
+ series.showSymbol = false
359
+ continue
360
+ }
361
+
362
+ let xField = getEncodeField(series, fields, 'x')
363
+ if (!xField) {
364
+ series.showSymbol = false
365
+ continue
366
+ }
367
+
368
+ series.showSymbol = distinctValues(rows, xField.name).length < 30
369
+ }
370
+ }
371
+
372
+ // ECharts just does a bad job of this, and the title, legend, and chart can often overlap
373
+ // This computes the proper offsets depending on what's visible
374
+ function computeTitleLegendAndGridPadding(config: NormalConfig) {
375
+ // you're doing crazy stuff, and on your own
376
+ if (config.legend.length > 1 || config.title.length > 1 || config.grid.length > 1) return
377
+
378
+ let legend = config.legend[0] || {}
379
+ let title = config.title[0] || {}
380
+ let grid = config.grid[0] || {}
381
+
382
+ title.top = numericOffset(title.top, 2)
383
+ legend.top = numericOffset(legend.top, 6)
384
+ grid.top = numericOffset(grid.top, 12)
385
+
386
+ if (title?.text) {
387
+ legend.top = numericOffset(legend.top, 18)
388
+ grid.top = numericOffset(grid.top, 28)
389
+ }
390
+
391
+ if (legend?.show) {
392
+ grid.top = numericOffset(grid.top, 24)
393
+ }
394
+ }
395
+
396
+ // When you toggle a series in the legend, we re-render the chart.
397
+ // This preserves the users selection, but also means that the currently selected series are available to enrichments.
398
+ function applyLegendSelection(config: NormalConfig) {
399
+ if (!config.legendSelection) return
400
+ config.legend[0] = {...config.legend[0], selected: config.legendSelection as any}
401
+ }
402
+
403
+ // Set default value formatting for value axes and series tooltips.
404
+ // We derive one formatter per field so axis labels and hover values stay consistent.
405
+ function valueFormatting(config: NormalConfig, fields: Field[]) {
406
+ let valueAxes = [...config.xAxis, ...config.yAxis].filter(axis => axis?.type === 'value' && !axis.field?.metadata?.timeOrdinal)
407
+ for (let axis of valueAxes) {
408
+ if (axis.axisLabel?.formatter != null) continue
409
+ axis.axisLabel = {...axis.axisLabel, formatter: makeValueFormatter(axis.field ? [axis.field] : [])}
410
+ }
411
+
412
+ for (let series of config.series) {
413
+ series.tooltip ||= {}
414
+ if (series.tooltip?.formatter || series.tooltip.valueFormatter) continue
415
+ series.tooltip.valueFormatter = makeValueFormatter(getSeriesValueFields(series, fields))
416
+ }
417
+ }
418
+
419
+ // Hide value y-axes for stacked-100 charts, since values are percentages and labels are usually redundant.
420
+ function hideStackPercentageValueAxis(config: NormalConfig, fields: Field[]) {
421
+ for (let [axisIndex, axis] of config.yAxis.entries()) {
422
+ if (!axis || axis.type !== 'value' || axis.show != null) continue
423
+
424
+ let seriesOnAxis = config.series.filter(entry => Number(entry?.yAxisIndex ?? 0) === axisIndex)
425
+ if (seriesOnAxis.length === 0) continue
426
+
427
+ let yFields = seriesOnAxis.map(entry => getSeriesValueField(entry, fields)).filter((f): f is Field => !!f)
428
+ if (yFields.length === 0) continue
429
+
430
+ if (yFields.every(field => field.name.startsWith('__graphene_stack_pct_'))) axis.show = false
431
+ }
432
+ }
433
+
434
+ // When value axes are hidden (like stacked-100 charts), reclaim the default left gutter.
435
+ function removeHiddenValueAxisPadding(config: NormalConfig) {
436
+ if (config.grid.length !== 1) return
437
+ if (config.yAxis.length === 0) return
438
+ if (config.yAxis.some(axis => axis?.show !== false)) return
439
+
440
+ let grid = config.grid[0]
441
+ if (!grid || grid.left != null) return
442
+ grid.left = 16
443
+ }
444
+
445
+ // For the simple bar+line mixed-chart case, keep axis styling consistent with assigned series:
446
+ // - axis labels/values on the second axis match primary axis formatting
447
+ // - first axis uses bar series color (when there is only one bar series shape)
448
+ // - second axis uses line series color
449
+ // In anything more complex, we bail to avoid surprising defaults.
450
+ function styleSecondaryAxisForSimpleBarLineLayout(config: NormalConfig, fields: Field[]) {
451
+ if (config.yAxis.length < 2) return
452
+
453
+ let series = config.series
454
+
455
+ let bars = series.filter(entry => Number(entry?.yAxisIndex ?? 0) === 0 && entry?.type === 'bar')
456
+ if (bars.length === 0) return
457
+
458
+ let secondary = series.filter(entry => Number(entry?.yAxisIndex ?? 0) === 1)
459
+ if (secondary.length !== 1 || secondary[0]?.type !== 'line') return
460
+
461
+ if (series.some(entry => Number(entry?.yAxisIndex ?? 0) === 0 && entry?.type !== 'bar')) return
462
+ if (series.some(entry => Number(entry?.yAxisIndex ?? 0) > 1)) return
463
+
464
+ let barYFields = new Set(bars.map(entry => getSeriesValueField(entry, fields)?.name).filter(Boolean))
465
+ if (barYFields.size !== 1) return
466
+
467
+ let primaryAxis = config.yAxis[0]
468
+ let secondaryAxis = config.yAxis[1]
469
+ if (!primaryAxis || !secondaryAxis) return
470
+
471
+ let barSeriesColor = seriesColorForIndex(config, series, bars[0])
472
+ let lineSeriesColor = seriesColorForIndex(config, series, secondary[0])
473
+
474
+ if (barSeriesColor) applyAxisColor(primaryAxis, barSeriesColor)
475
+ if (lineSeriesColor) applyAxisColor(secondaryAxis, lineSeriesColor)
476
+
477
+ let primaryFormatter = primaryAxis.axisLabel?.formatter
478
+ if (typeof primaryFormatter === 'function' && secondaryAxis.axisLabel?.formatter == null) {
479
+ secondaryAxis.axisLabel = {...secondaryAxis.axisLabel, formatter: (value: unknown) => formatAxisValue(primaryFormatter, value)}
480
+ }
481
+ }
482
+
483
+ // This is trying to fix an issue with charts where every value is either 0 or 1.
484
+ // TODO: just make this a test, and see if we still need it
485
+ function applyIntegerYAxisTicks(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
486
+ let yAxis = config.yAxis[0]
487
+ if (!yAxis || yAxis.type !== 'value' || yAxis.minInterval != null) return
488
+
489
+ let yFields = Array.from(new Set(config.series.map(series => getSeriesValueField(series, fields)?.name).filter(Boolean))) as string[]
490
+ let values = rows.flatMap(row => yFields.map(field => Number(row?.[field]))).filter(value => Number.isFinite(value))
491
+
492
+ if (values.length === 0) return
493
+ if (values.every(value => Number.isInteger(value))) yAxis.minInterval = 1
494
+ }
495
+
496
+ // Keep bar labels readable by default: place them outside bars and avoid overlap when possible.
497
+ function barLabelPositioning(config: NormalConfig) {
498
+ let horizontal = isHorizontalBar(config)
499
+
500
+ for (let series of config.series) {
501
+ if (series?.type !== 'bar' || !series.label || series.label.show !== true) continue
502
+
503
+ if (series.label.position == null) series.label.position = horizontal ? 'right' : 'top'
504
+ if (series.label.distance == null) series.label.distance = 4
505
+ if (series.labelLayout == null || typeof series.labelLayout === 'function') series.labelLayout = {}
506
+ let labelLayout = series.labelLayout as Record<string, any>
507
+ if (labelLayout.hideOverlap == null) labelLayout.hideOverlap = true
508
+ }
509
+ }
510
+
511
+ // Match series data labels to the assigned y-axis formatter when labels are enabled.
512
+ // This keeps label formatting in sync with the y-axis without asking callers to repeat it.
513
+ // labelsUseYAxisFormat depends on valueAxisFormatting running first so labels inherit axis formatting.
514
+ function labelsUseYAxisFormat(config: NormalConfig, fields: Field[]) {
515
+ for (let series of config.series) {
516
+ // No-op when labels are off or already explicitly formatted.
517
+ if (!series?.label || series.label.show !== true || series.label.formatter != null) continue
518
+
519
+ let yField = getSeriesValueField(series, fields)?.name
520
+ let axisIndex = Number(series.yAxisIndex ?? 0)
521
+ let axisFormatter = config.yAxis[axisIndex]?.axisLabel?.formatter
522
+ if (typeof axisFormatter !== 'function') continue
523
+
524
+ // ECharts can pass different value shapes depending on series/transform shape.
525
+ // We resolve the numeric value in a few fallback steps so labels always use the
526
+ // same value the y-axis is formatting.
527
+ series.label.formatter = (params: unknown) => {
528
+ let typed = params as {value?: unknown; data?: Record<string, unknown>}
529
+ let value = typed?.value
530
+
531
+ if (yField) {
532
+ if (typed?.data && typeof typed.data === 'object' && yField in typed.data) value = typed.data[yField]
533
+ if (typed?.value && typeof typed.value === 'object' && !Array.isArray(typed.value) && yField in (typed.value as Record<string, unknown>)) {
534
+ value = (typed.value as Record<string, unknown>)[yField]
535
+ }
536
+ }
537
+
538
+ return formatAxisValue(axisFormatter, value)
539
+ }
540
+ }
541
+ }
542
+
543
+ // Add a pie-friendly default tooltip formatter when charts include pie series.
544
+ // Pie params can pass row objects as `params.value`, so we format from the encoded value field.
545
+ function addPieTooltips(config: NormalConfig, fields: Field[]) {
546
+ if (!config.series.some(series => series?.type === 'pie')) return
547
+
548
+ let tooltip = config.tooltip[0]
549
+ if (!tooltip || tooltip.formatter != null) return
550
+
551
+ tooltip.trigger = 'item'
552
+ tooltip.formatter = (params: any) => {
553
+ let value = params?.value
554
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
555
+ let series = config.series[Number(params?.seriesIndex ?? 0)]
556
+ let yField = getSeriesValueField(series, fields)?.name
557
+ value = yField && value[yField] != null ? value[yField] : value.value
558
+ }
559
+ return `${params?.name ?? ''}: ${value ?? ''} (${params?.percent ?? 0}%)`
560
+ }
561
+ }
562
+
563
+ // Round only the topmost (or rightmost for horizontal) visible non-zero bar in each stack slot.
564
+ function stackedBarCornerRadius(config: NormalConfig) {
565
+ let horizontal = isHorizontalBar(config)
566
+ let cornerRadius = horizontal ? [0, 3, 3, 0] : [3, 3, 0, 0]
567
+ let valueIndex = horizontal ? 0 : 1
568
+ let selected = config.legend[0]?.selected || {}
569
+ let stacks = new Map<string, SeriesWithGroupingHint[]>()
570
+
571
+ // Unstacked bars can use a single series-level radius.
572
+ for (let series of config.series) {
573
+ if (series?.type !== 'bar' || series?.stack || series?.itemStyle?.borderRadius != null) continue
574
+ series.itemStyle = {...series.itemStyle, borderRadius: cornerRadius}
575
+ }
576
+
577
+ for (let [index, series] of config.series.entries()) {
578
+ if (series?.type !== 'bar' || series?.itemStyle?.borderRadius != null || !Array.isArray(series.data)) continue
579
+
580
+ let axisKey = `${Number(series.xAxisIndex ?? 0)}:${Number(series.yAxisIndex ?? 0)}`
581
+ let stackKey = series.stack ?? `__ graphene_unstacked_${index}`
582
+ let key = `${axisKey}::${stackKey}`
583
+ let group = stacks.get(key) ?? []
584
+ group.push(series)
585
+ stacks.set(key, group)
586
+ }
587
+
588
+ // For each stack slot, scan top-down and round the first visible non-zero segment.
589
+ for (let stackSeries of stacks.values()) {
590
+ let maxPoints = Math.max(...stackSeries.map(series => (series.data as unknown[]).length), 0)
591
+
592
+ for (let pointIndex = 0; pointIndex < maxPoints; pointIndex++) {
593
+ for (let seriesIndex = stackSeries.length - 1; seriesIndex >= 0; seriesIndex--) {
594
+ let series = stackSeries[seriesIndex]
595
+ if (selected[series.name || ''] === false) continue
596
+
597
+ let point = (series.data as unknown[])[pointIndex]
598
+ if (!point || typeof point !== 'object') continue
599
+
600
+ let value = Number((point as Record<string, any>)?.value?.[valueIndex])
601
+ if (!Number.isFinite(value) || value === 0) continue
602
+
603
+ let typed = point as Record<string, any>
604
+ let existingItemStyle = typed.itemStyle && typeof typed.itemStyle === 'object' ? typed.itemStyle : {}
605
+ ;(series.data as Record<string, any>[])[pointIndex] = {...typed, itemStyle: {...existingItemStyle, borderRadius: cornerRadius}}
606
+ break
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ function normalizeArray<T>(value: unknown): T[] {
613
+ if (value == null) return []
614
+ return Array.isArray(value) ? (value as T[]) : [value as T]
615
+ }
616
+
617
+ function numericOffset(value: unknown, delta: number) {
618
+ return typeof value === 'number' ? value + delta : delta
619
+ }
620
+
621
+ function formatAxisValue(formatter: (...args: any[]) => unknown, value: unknown) {
622
+ return String(formatter(value, 0))
623
+ }
624
+
625
+ function seriesColorForIndex(config: NormalConfig, seriesList: SeriesWithGroupingHint[], targetSeries: SeriesWithGroupingHint) {
626
+ let index = seriesList.indexOf(targetSeries)
627
+ if (index < 0) return undefined
628
+
629
+ let explicit = targetSeries?.itemStyle?.color || targetSeries?.lineStyle?.color || targetSeries?.areaStyle?.color || targetSeries?.color
630
+ if (typeof explicit === 'string') return explicit
631
+
632
+ if (!Array.isArray(config.color)) return undefined
633
+ let palette = config.color.filter(color => typeof color === 'string')
634
+ if (palette.length === 0) return undefined
635
+ return palette[index % palette.length]
636
+ }
637
+
638
+ function applyAxisColor(axis: NormalConfig['yAxis'][number], color: string) {
639
+ if (!axis) return
640
+ axis.axisLine = {...axis.axisLine, lineStyle: {...axis.axisLine?.lineStyle, color}}
641
+ axis.axisTick = {...axis.axisTick, lineStyle: {...axis.axisTick?.lineStyle, color}}
642
+ axis.nameTextStyle = {...axis.nameTextStyle, color}
643
+ axis.axisLabel = {...axis.axisLabel, color}
644
+ }
645
+
646
+ function isHorizontalBar(config: NormalConfig) {
647
+ let xAxis = config.xAxis[0]
648
+ let yAxis = config.yAxis[0]
649
+ let hasBarSeries = config.series.some(series => series?.type === 'bar')
650
+ return Boolean(hasBarSeries && xAxis?.type === 'value' && yAxis?.type === 'category')
651
+ }
652
+
653
+ function horizontalBarGuard(config: NormalConfig, fields: Field[]) {
654
+ if (!isHorizontalBar(config)) return
655
+
656
+ let hasInvalidCategoryField = config.series
657
+ .filter(series => series?.type === 'bar')
658
+ .map(series => getEncodeField(series, fields, 'y')?.type)
659
+ .some(type => type === 'date' || type === 'timestamp' || type === 'number')
660
+
661
+ if (hasInvalidCategoryField) throw new Error('Horizontal charts do not support a value or time-based x-axis')
662
+ }
663
+
664
+ // Build axis defaults from field metadata, including temporal domains and formatters.
665
+ function inferAxisFromField(field: Field | undefined, rows: Record<string, any>[]) {
666
+ if (!field) return {type: 'category'}
667
+ if (typeof field.type !== 'string') throw new Error(`Field ${field.name} has unsupported non-scalar type: array`)
668
+
669
+ let type: 'time' | 'value' | 'category' = 'category'
670
+ if (field.type === 'date' || field.type === 'timestamp') type = 'time'
671
+ if (field.type === 'number') type = 'value'
672
+ let axis: Record<string, any> = {field, type}
673
+
674
+ if (type === 'value') {
675
+ let domain = temporalValueDomain(field, rows)
676
+ if (domain) {
677
+ axis.min = domain[0]
678
+ axis.max = domain[1]
679
+ }
680
+
681
+ if (field.metadata?.timeOrdinal) {
682
+ axis.minInterval = 1
683
+
684
+ // Ordinal values are numeric so we use a value axis with a fixed domain, but
685
+ // visually they are discrete buckets. Hide value-axis grid lines by default
686
+ // and ask ECharts for denser integer ticks than its generic value defaults.
687
+ axis.axisLine = {show: false}
688
+ axis.splitLine = {show: false}
689
+ axis.axisLabel = {hideOverlap: true, formatter: (value: unknown) => (domain && (Number(value) < domain[0] || Number(value) > domain[1]) ? '' : formatTimeOrdinal(field, value))}
690
+
691
+ // splitNumber is a hint rather than an exact spacing, but it works better
692
+ // with value-axis bars because interval can fight the half-bucket padding we
693
+ // add later. These defaults keep compact ordinals readable without forcing
694
+ // every possible day/week label.
695
+ let ordinal = field.metadata.timeOrdinal
696
+ if (ordinal === 'month_of_year' || ordinal === 'quarter_of_year' || ordinal === 'dow_0s' || ordinal === 'dow_1s' || ordinal === 'dow_1m')
697
+ axis.splitNumber = domain ? domain[1] - domain[0] + 1 : 5
698
+ if (ordinal === 'hour_of_day') axis.splitNumber = 8
699
+ if (ordinal === 'day_of_month') axis.splitNumber = 6
700
+ if (ordinal === 'week_of_year') axis.splitNumber = 13
701
+ if (ordinal === 'day_of_year') axis.splitNumber = 12
702
+
703
+ axis.axisPointer = {label: {formatter: (value: unknown) => formatTimeOrdinal(field, value)}}
704
+ return axis
705
+ }
706
+
707
+ if (field.metadata?.timePart === 'year') {
708
+ axis.minInterval = 1
709
+ axis.axisLabel = {formatter: (value: unknown) => (Number.isInteger(Number(value)) ? String(Number(value)) : '')}
710
+ }
711
+ }
712
+
713
+ if (type === 'category' && field.metadata?.timeOrdinal) {
714
+ axis.axisLabel = {formatter: (value: unknown) => formatTimeOrdinal(field, value)}
715
+ axis.axisPointer = {label: {formatter: (value: unknown) => formatTimeOrdinal(field, value)}}
716
+ }
717
+
718
+ return axis
719
+ }
720
+
721
+ // Return the natural numeric domain for temporal values that are encoded as numbers.
722
+ function temporalValueDomain(field: Field, rows: Record<string, any>[]): [number, number] | undefined {
723
+ let ordinal = field.metadata?.timeOrdinal
724
+ if (ordinal === 'hour_of_day') return [0, 23]
725
+ if (ordinal === 'day_of_month') return [1, 31]
726
+ if (ordinal === 'day_of_year') return [1, 366]
727
+ if (ordinal === 'week_of_year') return [1, 53]
728
+ if (ordinal === 'month_of_year') return [1, 12]
729
+ if (ordinal === 'quarter_of_year') return [1, 4]
730
+ if (ordinal === 'dow_0s') return [0, 6]
731
+ if (ordinal === 'dow_1s' || ordinal === 'dow_1m') return [1, 7]
732
+
733
+ if (field.metadata?.timePart == 'year') {
734
+ let values = rows.map(row => Number(row?.[field.name])).filter(value => Number.isFinite(value))
735
+ if (values.length === 0) return undefined
736
+ return [Math.min(...values), Math.max(...values)]
737
+ }
738
+ }
739
+
740
+ // Series sometimes encode their value field as `y` and sometimes as `value` (pie, funnel, etc).
741
+ function getSeriesValueField(series: SeriesWithGroupingHint | undefined, fields: Field[]) {
742
+ return getEncodeField(series, fields, 'y') ?? getEncodeField(series, fields, 'value')
743
+ }
744
+
745
+ // The field(s) to format in a series' tooltip. Depends on series type since scatter/bar put the numeric value in different encode props.
746
+ function getSeriesValueFields(series: SeriesWithGroupingHint, fields: Field[]) {
747
+ switch (series.type) {
748
+ case 'scatter':
749
+ case 'effectScatter':
750
+ return [getEncodeField(series, fields, 'x'), getEncodeField(series, fields, 'y')].filter((f): f is Field => !!f)
751
+ case 'bar': {
752
+ let xField = getEncodeField(series, fields, 'x')
753
+ let yField = getEncodeField(series, fields, 'y')
754
+ return xField?.type == 'number' ? [xField] : [yField].filter((f): f is Field => !!f)
755
+ }
756
+ default:
757
+ return [getEncodeField(series, fields, 'y')].filter((f): f is Field => !!f)
758
+ }
759
+ }
760
+
761
+ // The props on series.encode can either be a string, or an array.
762
+ // In all cases, this returns the corresponding fields for each item.
763
+ function getEncodeFields(series: SeriesWithGroupingHint | undefined, fields: Field[], encodeProp: string): Field[] {
764
+ let raw = series?.encode?.[encodeProp]
765
+ let names: string[] = []
766
+ if (Array.isArray(raw)) names = raw.filter((v): v is string => typeof v === 'string')
767
+ if (typeof raw === 'string') names = [raw]
768
+
769
+ return names.map(name => fields.find(f => f.name === name)).filter((f): f is Field => !!f)
770
+ }
771
+
772
+ function getEncodeField(series: SeriesWithGroupingHint | undefined, fields: Field[], encodeProp: string): Field | undefined {
773
+ return getEncodeFields(series, fields, encodeProp)[0]
774
+ }
775
+
776
+ function inferDimensions(rows: Record<string, any>[]) {
777
+ let sample = rows.find(row => row && typeof row === 'object')
778
+ if (!sample) return []
779
+ return Object.keys(sample)
780
+ }
781
+
782
+ function distinctValues(rows: Record<string, any>[], field: string) {
783
+ let values: unknown[] = []
784
+ let seen = new Set<string>()
785
+ for (let row of rows) {
786
+ let value = row?.[field]
787
+ let key = JSON.stringify(value ?? null)
788
+ if (seen.has(key)) continue
789
+ seen.add(key)
790
+ values.push(value)
791
+ }
792
+ return values
793
+ }