@graphenedata/cli 0.0.14 → 0.0.16

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 (121) hide show
  1. package/LICENSE.md +3 -3
  2. package/README.md +138 -0
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/bin.js +2 -2
  5. package/dist/cli/bigQuery-I3F46SC6.js +75 -0
  6. package/dist/cli/bigQuery-I3F46SC6.js.map +7 -0
  7. package/dist/cli/chunk-OVWODUTJ.js +12849 -0
  8. package/dist/cli/chunk-OVWODUTJ.js.map +7 -0
  9. package/dist/cli/chunk-QAXEOZ43.js +53 -0
  10. package/dist/cli/chunk-QAXEOZ43.js.map +7 -0
  11. package/dist/cli/cli.js +245 -10290
  12. package/dist/cli/clickhouse-ZN5AN2UL.js +64 -0
  13. package/dist/cli/clickhouse-ZN5AN2UL.js.map +7 -0
  14. package/dist/cli/duckdb-IYBIO5KJ.js +87 -0
  15. package/dist/cli/duckdb-IYBIO5KJ.js.map +7 -0
  16. package/dist/cli/serve2-TNN5EROW.js +447 -0
  17. package/dist/cli/serve2-TNN5EROW.js.map +7 -0
  18. package/dist/cli/snowflake-MOQB5GA4.js +128 -0
  19. package/dist/cli/snowflake-MOQB5GA4.js.map +7 -0
  20. package/dist/index.d.ts +63 -0
  21. package/dist/lang/index.d.ts +63 -0
  22. package/dist/skills/graphene/SKILL.md +235 -0
  23. package/dist/skills/graphene/references/big-value.md +20 -0
  24. package/dist/skills/graphene/references/date-range.md +64 -0
  25. package/dist/skills/graphene/references/dropdown.md +62 -0
  26. package/dist/skills/graphene/references/echarts.md +162 -0
  27. package/dist/skills/graphene/references/gsql.md +393 -0
  28. package/dist/skills/graphene/references/model-gsql.md +72 -0
  29. package/dist/skills/graphene/references/table.md +143 -0
  30. package/dist/skills/graphene/references/text-input.md +29 -0
  31. package/dist/ui/app.css +263 -299
  32. package/dist/ui/component-utilities/dataShaping.ts +484 -0
  33. package/dist/ui/component-utilities/dataSummary.ts +57 -0
  34. package/dist/ui/component-utilities/enrich.ts +763 -0
  35. package/dist/ui/component-utilities/format.ts +177 -0
  36. package/dist/ui/component-utilities/inputUtils.ts +48 -9
  37. package/dist/ui/component-utilities/theme.ts +200 -0
  38. package/dist/ui/component-utilities/themeStores.ts +26 -21
  39. package/dist/ui/component-utilities/types.ts +70 -0
  40. package/dist/ui/components/AreaChart.svelte +57 -105
  41. package/dist/ui/components/BarChart.svelte +71 -129
  42. package/dist/ui/components/BigValue.svelte +24 -40
  43. package/dist/ui/components/Column.svelte +11 -19
  44. package/dist/ui/components/DateRange.svelte +71 -34
  45. package/dist/ui/components/Dropdown.svelte +82 -49
  46. package/dist/ui/components/DropdownOption.svelte +1 -2
  47. package/dist/ui/components/ECharts.svelte +179 -60
  48. package/dist/ui/components/InlineDelta.svelte +51 -32
  49. package/dist/ui/components/LineChart.svelte +54 -125
  50. package/dist/ui/components/PieChart.svelte +27 -37
  51. package/dist/ui/components/QueryLoad.svelte +78 -44
  52. package/dist/ui/components/Row.svelte +2 -1
  53. package/dist/ui/components/ScatterPlot.svelte +52 -0
  54. package/dist/ui/components/Skeleton.svelte +32 -0
  55. package/dist/ui/components/Table.svelte +3 -2
  56. package/dist/ui/components/TableGroupRow.svelte +28 -36
  57. package/dist/ui/components/TableHarness.svelte +32 -0
  58. package/dist/ui/components/TableHeader.svelte +34 -59
  59. package/dist/ui/components/TableRow.svelte +15 -39
  60. package/dist/ui/components/TableSubtotalRow.svelte +26 -21
  61. package/dist/ui/components/TableTotalRow.svelte +27 -37
  62. package/dist/ui/components/TextInput.svelte +17 -14
  63. package/dist/ui/components/Value.svelte +25 -0
  64. package/dist/ui/components/_Table.svelte +80 -76
  65. package/dist/ui/internal/ChartGallery.svelte +527 -0
  66. package/dist/ui/internal/ErrorDisplay.svelte +60 -0
  67. package/dist/ui/internal/LocalApp.svelte +87 -19
  68. package/dist/ui/internal/PageNavGroup.svelte +269 -0
  69. package/dist/ui/internal/Sidebar.svelte +178 -0
  70. package/dist/ui/internal/SidebarToggle.svelte +47 -0
  71. package/dist/ui/internal/StyleGallery.svelte +244 -0
  72. package/dist/ui/internal/clientCache.ts +15 -13
  73. package/dist/ui/internal/pageInputs.svelte.js +292 -0
  74. package/dist/ui/internal/queryEngine.ts +124 -132
  75. package/dist/ui/internal/runSocket.ts +59 -0
  76. package/dist/ui/internal/sidebar.svelte.js +18 -0
  77. package/dist/ui/internal/telemetry.ts +52 -17
  78. package/dist/ui/internal/types.d.ts +7 -0
  79. package/dist/ui/web.js +55 -13
  80. package/package.json +40 -41
  81. package/dist/docs/agent-instructions.md +0 -18
  82. package/dist/docs/base.md +0 -98
  83. package/dist/docs/cli.md +0 -22
  84. package/dist/docs/graphene.md +0 -1462
  85. package/dist/ui/component-utilities/autoFormatting.js +0 -301
  86. package/dist/ui/component-utilities/builtInFormats.js +0 -482
  87. package/dist/ui/component-utilities/chartContext.js +0 -12
  88. package/dist/ui/component-utilities/chartWindowDebug.js +0 -21
  89. package/dist/ui/component-utilities/checkInputs.js +0 -95
  90. package/dist/ui/component-utilities/convert.js +0 -15
  91. package/dist/ui/component-utilities/dateParsing.js +0 -57
  92. package/dist/ui/component-utilities/dropdownContext.ts +0 -1
  93. package/dist/ui/component-utilities/echarts.js +0 -272
  94. package/dist/ui/component-utilities/echartsThemes.js +0 -453
  95. package/dist/ui/component-utilities/formatTitle.js +0 -24
  96. package/dist/ui/component-utilities/formatting.js +0 -250
  97. package/dist/ui/component-utilities/getColumnExtents.js +0 -79
  98. package/dist/ui/component-utilities/getColumnSummary.js +0 -67
  99. package/dist/ui/component-utilities/getCompletedData.js +0 -114
  100. package/dist/ui/component-utilities/getDistinctCount.js +0 -7
  101. package/dist/ui/component-utilities/getDistinctValues.js +0 -15
  102. package/dist/ui/component-utilities/getSeriesConfig.js +0 -237
  103. package/dist/ui/component-utilities/getSortedData.js +0 -7
  104. package/dist/ui/component-utilities/getStackPercentages.js +0 -43
  105. package/dist/ui/component-utilities/getStackedData.js +0 -17
  106. package/dist/ui/component-utilities/getYAxisIndex.js +0 -15
  107. package/dist/ui/component-utilities/globalContexts.js +0 -1
  108. package/dist/ui/component-utilities/helpers/getCompletedData.helpers.js +0 -119
  109. package/dist/ui/component-utilities/replaceNulls.js +0 -14
  110. package/dist/ui/component-utilities/tableUtils.ts +0 -120
  111. package/dist/ui/components/Area.svelte +0 -214
  112. package/dist/ui/components/Bar.svelte +0 -350
  113. package/dist/ui/components/Chart.svelte +0 -989
  114. package/dist/ui/components/ErrorChart.svelte +0 -118
  115. package/dist/ui/components/Line.svelte +0 -227
  116. package/dist/ui/internal/NavSidebar.svelte +0 -396
  117. package/dist/ui/internal/PageError.svelte +0 -23
  118. package/dist/ui/internal/checkSocket.ts +0 -48
  119. package/dist/ui/internal/theme.ts +0 -88
  120. package/dist/ui/public/inter-latin-ext.woff2 +0 -0
  121. package/dist/ui/public/inter-latin.woff2 +0 -0
@@ -0,0 +1,763 @@
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
+
289
+ // Value-axis bars are centered on their x/y value, so explicit min/max domains clip edge bars.
290
+ // Expand only already-set value domains to give bars half a bucket of breathing room.
291
+ function extendValueAxisDomainsForBars(config: NormalConfig) {
292
+ for (let [dimension, axes] of [
293
+ ['x', config.xAxis],
294
+ ['y', config.yAxis],
295
+ ] as const) {
296
+ for (let [axisIndex, axis] of axes.entries()) {
297
+ let mutable = axis as Record<string, any>
298
+ if (mutable?.type !== 'value' || mutable.min == null || mutable.max == null) continue
299
+
300
+ let hasBarSeries = config.series.some(series => series?.type === 'bar' && Number(series?.[`${dimension}AxisIndex`] ?? 0) === axisIndex)
301
+ if (!hasBarSeries) continue
302
+
303
+ mutable.min -= 0.5
304
+ mutable.max += 0.5
305
+ }
306
+ }
307
+ }
308
+
309
+ // Ensure that times looks nice. Unlike base echarts, we have metadata about the time value we can use.
310
+ function timeFormatting(config: NormalConfig) {
311
+ let tooltip = config.tooltip[0] as Record<string, any> | undefined
312
+ if (tooltip?.axisPointer?.label?.formatter) return
313
+
314
+ for (let axis of config.xAxis) {
315
+ if (!axis || axis.type !== 'time') continue
316
+ if (axis.axisPointer?.label?.formatter != null) continue
317
+
318
+ let timeGrain = String(axis.field?.metadata?.timeGrain || '').toLowerCase()
319
+ if (!timeGrain) continue
320
+
321
+ // axisPointer affects the formatting of the tooltip, but not the axis labels themselves
322
+ axis.axisPointer ||= {}
323
+ axis.axisPointer.label ||= {}
324
+ axis.axisPointer.label.formatter = makeTimeFormatter(axis.field)
325
+ }
326
+ }
327
+
328
+ // Keep line/area markers readable by default.
329
+ // - Respect explicit `showSymbol` from users.
330
+ // - Category/time/ordinal axes: show markers for small series (< 30 points).
331
+ // - Other value axes: hide markers by default.
332
+ function lineSeriesMarkerVisibility(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
333
+ for (let series of config.series) {
334
+ if (series?.type !== 'line' || series.showSymbol != null) continue
335
+
336
+ let axisIndex = Number(series.xAxisIndex ?? 0)
337
+ let axis = config.xAxis[axisIndex]
338
+ if (axis?.type === 'value' && !axis.field?.metadata?.timeOrdinal) {
339
+ series.showSymbol = false
340
+ continue
341
+ }
342
+
343
+ if (axis?.type !== 'category' && axis?.type !== 'time' && axis?.type !== 'value') {
344
+ series.showSymbol = false
345
+ continue
346
+ }
347
+
348
+ let xField = getEncodeField(series, fields, 'x')
349
+ if (!xField) {
350
+ series.showSymbol = false
351
+ continue
352
+ }
353
+
354
+ series.showSymbol = distinctValues(rows, xField.name).length < 30
355
+ }
356
+ }
357
+
358
+ // ECharts just does a bad job of this, and the title, legend, and chart can often overlap
359
+ // This computes the proper offsets depending on what's visible
360
+ function computeTitleLegendAndGridPadding(config: NormalConfig) {
361
+ // you're doing crazy stuff, and on your own
362
+ if (config.legend.length > 1 || config.title.length > 1 || config.grid.length > 1) return
363
+
364
+ let legend = config.legend[0] || {}
365
+ let title = config.title[0] || {}
366
+ let grid = config.grid[0] || {}
367
+
368
+ title.top = numericOffset(title.top, 2)
369
+ legend.top = numericOffset(legend.top, 6)
370
+ grid.top = numericOffset(grid.top, 12)
371
+
372
+ if (title?.text) {
373
+ legend.top = numericOffset(legend.top, 18)
374
+ grid.top = numericOffset(grid.top, 28)
375
+ }
376
+
377
+ if (legend?.show) {
378
+ grid.top = numericOffset(grid.top, 24)
379
+ }
380
+ }
381
+
382
+ // When you toggle a series in the legend, we re-render the chart.
383
+ // This preserves the users selection, but also means that the currently selected series are available to enrichments.
384
+ function applyLegendSelection(config: NormalConfig) {
385
+ if (!config.legendSelection) return
386
+ config.legend[0] = {...config.legend[0], selected: config.legendSelection as any}
387
+ }
388
+
389
+ // Set default value formatting for value axes and series tooltips.
390
+ // We derive one formatter per field so axis labels and hover values stay consistent.
391
+ function valueFormatting(config: NormalConfig, fields: Field[]) {
392
+ let valueAxes = [...config.xAxis, ...config.yAxis].filter(axis => axis?.type === 'value' && !axis.field?.metadata?.timeOrdinal)
393
+ for (let axis of valueAxes) {
394
+ if (axis.axisLabel?.formatter != null) continue
395
+ axis.axisLabel = {...axis.axisLabel, formatter: makeValueFormatter(axis.field ? [axis.field] : [])}
396
+ }
397
+
398
+ for (let series of config.series) {
399
+ series.tooltip ||= {}
400
+ if (series.tooltip?.formatter || series.tooltip.valueFormatter) continue
401
+ series.tooltip.valueFormatter = makeValueFormatter(getSeriesValueFields(series, fields))
402
+ }
403
+ }
404
+
405
+ // Hide value y-axes for stacked-100 charts, since values are percentages and labels are usually redundant.
406
+ function hideStackPercentageValueAxis(config: NormalConfig, fields: Field[]) {
407
+ for (let [axisIndex, axis] of config.yAxis.entries()) {
408
+ if (!axis || axis.type !== 'value' || axis.show != null) continue
409
+
410
+ let seriesOnAxis = config.series.filter(entry => Number(entry?.yAxisIndex ?? 0) === axisIndex)
411
+ if (seriesOnAxis.length === 0) continue
412
+
413
+ let yFields = seriesOnAxis.map(entry => getSeriesValueField(entry, fields)).filter((f): f is Field => !!f)
414
+ if (yFields.length === 0) continue
415
+
416
+ if (yFields.every(field => field.name.startsWith('__graphene_stack_pct_'))) axis.show = false
417
+ }
418
+ }
419
+
420
+ // When value axes are hidden (like stacked-100 charts), reclaim the default left gutter.
421
+ function removeHiddenValueAxisPadding(config: NormalConfig) {
422
+ if (config.grid.length !== 1) return
423
+ if (config.yAxis.length === 0) return
424
+ if (config.yAxis.some(axis => axis?.show !== false)) return
425
+
426
+ let grid = config.grid[0]
427
+ if (!grid || grid.left != null) return
428
+ grid.left = 16
429
+ }
430
+
431
+ // For the simple bar+line mixed-chart case, keep axis styling consistent with assigned series:
432
+ // - axis labels/values on the second axis match primary axis formatting
433
+ // - first axis uses bar series color (when there is only one bar series shape)
434
+ // - second axis uses line series color
435
+ // In anything more complex, we bail to avoid surprising defaults.
436
+ function styleSecondaryAxisForSimpleBarLineLayout(config: NormalConfig, fields: Field[]) {
437
+ if (config.yAxis.length < 2) return
438
+
439
+ let series = config.series
440
+
441
+ let bars = series.filter(entry => Number(entry?.yAxisIndex ?? 0) === 0 && entry?.type === 'bar')
442
+ if (bars.length === 0) return
443
+
444
+ let secondary = series.filter(entry => Number(entry?.yAxisIndex ?? 0) === 1)
445
+ if (secondary.length !== 1 || secondary[0]?.type !== 'line') return
446
+
447
+ if (series.some(entry => Number(entry?.yAxisIndex ?? 0) === 0 && entry?.type !== 'bar')) return
448
+ if (series.some(entry => Number(entry?.yAxisIndex ?? 0) > 1)) return
449
+
450
+ let barYFields = new Set(bars.map(entry => getSeriesValueField(entry, fields)?.name).filter(Boolean))
451
+ if (barYFields.size !== 1) return
452
+
453
+ let primaryAxis = config.yAxis[0]
454
+ let secondaryAxis = config.yAxis[1]
455
+ if (!primaryAxis || !secondaryAxis) return
456
+
457
+ let barSeriesColor = seriesColorForIndex(config, series, bars[0])
458
+ let lineSeriesColor = seriesColorForIndex(config, series, secondary[0])
459
+
460
+ if (barSeriesColor) applyAxisColor(primaryAxis, barSeriesColor)
461
+ if (lineSeriesColor) applyAxisColor(secondaryAxis, lineSeriesColor)
462
+
463
+ let primaryFormatter = primaryAxis.axisLabel?.formatter
464
+ if (typeof primaryFormatter === 'function' && secondaryAxis.axisLabel?.formatter == null) {
465
+ secondaryAxis.axisLabel = {...secondaryAxis.axisLabel, formatter: (value: unknown) => formatAxisValue(primaryFormatter, value)}
466
+ }
467
+ }
468
+
469
+ // This is trying to fix an issue with charts where every value is either 0 or 1.
470
+ // TODO: just make this a test, and see if we still need it
471
+ function applyIntegerYAxisTicks(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
472
+ let yAxis = config.yAxis[0]
473
+ if (!yAxis || yAxis.type !== 'value' || yAxis.minInterval != null) return
474
+
475
+ let yFields = Array.from(new Set(config.series.map(series => getSeriesValueField(series, fields)?.name).filter(Boolean))) as string[]
476
+ let values = rows.flatMap(row => yFields.map(field => Number(row?.[field]))).filter(value => Number.isFinite(value))
477
+
478
+ if (values.length === 0) return
479
+ if (values.every(value => Number.isInteger(value))) yAxis.minInterval = 1
480
+ }
481
+
482
+ // Keep bar labels readable by default: place them outside bars and avoid overlap when possible.
483
+ function barLabelPositioning(config: NormalConfig) {
484
+ let horizontal = isHorizontalBar(config)
485
+
486
+ for (let series of config.series) {
487
+ if (series?.type !== 'bar' || !series.label || series.label.show !== true) continue
488
+
489
+ if (series.label.position == null) series.label.position = horizontal ? 'right' : 'top'
490
+ if (series.label.distance == null) series.label.distance = 4
491
+ if (series.labelLayout == null || typeof series.labelLayout === 'function') series.labelLayout = {}
492
+ let labelLayout = series.labelLayout as Record<string, any>
493
+ if (labelLayout.hideOverlap == null) labelLayout.hideOverlap = true
494
+ }
495
+ }
496
+
497
+ // Match series data labels to the assigned y-axis formatter when labels are enabled.
498
+ // This keeps label formatting in sync with the y-axis without asking callers to repeat it.
499
+ // labelsUseYAxisFormat depends on valueAxisFormatting running first so labels inherit axis formatting.
500
+ function labelsUseYAxisFormat(config: NormalConfig, fields: Field[]) {
501
+ for (let series of config.series) {
502
+ // No-op when labels are off or already explicitly formatted.
503
+ if (!series?.label || series.label.show !== true || series.label.formatter != null) continue
504
+
505
+ let yField = getSeriesValueField(series, fields)?.name
506
+ let axisIndex = Number(series.yAxisIndex ?? 0)
507
+ let axisFormatter = config.yAxis[axisIndex]?.axisLabel?.formatter
508
+ if (typeof axisFormatter !== 'function') continue
509
+
510
+ // ECharts can pass different value shapes depending on series/transform shape.
511
+ // We resolve the numeric value in a few fallback steps so labels always use the
512
+ // same value the y-axis is formatting.
513
+ series.label.formatter = (params: unknown) => {
514
+ let typed = params as {value?: unknown; data?: Record<string, unknown>}
515
+ let value = typed?.value
516
+
517
+ if (yField) {
518
+ if (typed?.data && typeof typed.data === 'object' && yField in typed.data) value = typed.data[yField]
519
+ if (typed?.value && typeof typed.value === 'object' && !Array.isArray(typed.value) && yField in (typed.value as Record<string, unknown>)) {
520
+ value = (typed.value as Record<string, unknown>)[yField]
521
+ }
522
+ }
523
+
524
+ return formatAxisValue(axisFormatter, value)
525
+ }
526
+ }
527
+ }
528
+
529
+ // Add a pie-friendly default tooltip formatter when charts include pie series.
530
+ // Pie params can pass row objects as `params.value`, so we format from the encoded value field.
531
+ function addPieTooltips(config: NormalConfig, fields: Field[]) {
532
+ if (!config.series.some(series => series?.type === 'pie')) return
533
+
534
+ let tooltip = config.tooltip[0]
535
+ if (!tooltip || tooltip.formatter != null) return
536
+
537
+ tooltip.trigger = 'item'
538
+ tooltip.formatter = (params: any) => {
539
+ let value = params?.value
540
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
541
+ let series = config.series[Number(params?.seriesIndex ?? 0)]
542
+ let yField = getSeriesValueField(series, fields)?.name
543
+ value = yField && value[yField] != null ? value[yField] : value.value
544
+ }
545
+ return `${params?.name ?? ''}: ${value ?? ''} (${params?.percent ?? 0}%)`
546
+ }
547
+ }
548
+
549
+ // Round only the topmost (or rightmost for horizontal) visible non-zero bar in each stack slot.
550
+ function stackedBarCornerRadius(config: NormalConfig) {
551
+ let horizontal = isHorizontalBar(config)
552
+ let cornerRadius = horizontal ? [0, 3, 3, 0] : [3, 3, 0, 0]
553
+ let valueIndex = horizontal ? 0 : 1
554
+ let selected = config.legend[0]?.selected || {}
555
+ let stacks = new Map<string, SeriesWithGroupingHint[]>()
556
+
557
+ // Unstacked bars can use a single series-level radius.
558
+ for (let series of config.series) {
559
+ if (series?.type !== 'bar' || series?.stack || series?.itemStyle?.borderRadius != null) continue
560
+ series.itemStyle = {...series.itemStyle, borderRadius: cornerRadius}
561
+ }
562
+
563
+ for (let [index, series] of config.series.entries()) {
564
+ if (series?.type !== 'bar' || series?.itemStyle?.borderRadius != null || !Array.isArray(series.data)) continue
565
+
566
+ let axisKey = `${Number(series.xAxisIndex ?? 0)}:${Number(series.yAxisIndex ?? 0)}`
567
+ let stackKey = series.stack ?? `__ graphene_unstacked_${index}`
568
+ let key = `${axisKey}::${stackKey}`
569
+ let group = stacks.get(key) ?? []
570
+ group.push(series)
571
+ stacks.set(key, group)
572
+ }
573
+
574
+ // For each stack slot, scan top-down and round the first visible non-zero segment.
575
+ for (let stackSeries of stacks.values()) {
576
+ let maxPoints = Math.max(...stackSeries.map(series => (series.data as unknown[]).length), 0)
577
+
578
+ for (let pointIndex = 0; pointIndex < maxPoints; pointIndex++) {
579
+ for (let seriesIndex = stackSeries.length - 1; seriesIndex >= 0; seriesIndex--) {
580
+ let series = stackSeries[seriesIndex]
581
+ if (selected[series.name || ''] === false) continue
582
+
583
+ let point = (series.data as unknown[])[pointIndex]
584
+ if (!point || typeof point !== 'object') continue
585
+
586
+ let value = Number((point as Record<string, any>)?.value?.[valueIndex])
587
+ if (!Number.isFinite(value) || value === 0) continue
588
+
589
+ let typed = point as Record<string, any>
590
+ let existingItemStyle = typed.itemStyle && typeof typed.itemStyle === 'object' ? typed.itemStyle : {}
591
+ ;(series.data as Record<string, any>[])[pointIndex] = {...typed, itemStyle: {...existingItemStyle, borderRadius: cornerRadius}}
592
+ break
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ function normalizeArray<T>(value: unknown): T[] {
599
+ if (value == null) return []
600
+ return Array.isArray(value) ? (value as T[]) : [value as T]
601
+ }
602
+
603
+ function numericOffset(value: unknown, delta: number) {
604
+ return typeof value === 'number' ? value + delta : delta
605
+ }
606
+
607
+ function formatAxisValue(formatter: (...args: any[]) => unknown, value: unknown) {
608
+ return String(formatter(value, 0))
609
+ }
610
+
611
+ function seriesColorForIndex(config: NormalConfig, seriesList: SeriesWithGroupingHint[], targetSeries: SeriesWithGroupingHint) {
612
+ let index = seriesList.indexOf(targetSeries)
613
+ if (index < 0) return undefined
614
+
615
+ let explicit = targetSeries?.itemStyle?.color || targetSeries?.lineStyle?.color || targetSeries?.areaStyle?.color || targetSeries?.color
616
+ if (typeof explicit === 'string') return explicit
617
+
618
+ if (!Array.isArray(config.color)) return undefined
619
+ let palette = config.color.filter(color => typeof color === 'string')
620
+ if (palette.length === 0) return undefined
621
+ return palette[index % palette.length]
622
+ }
623
+
624
+ function applyAxisColor(axis: NormalConfig['yAxis'][number], color: string) {
625
+ if (!axis) return
626
+ axis.axisLine = {...axis.axisLine, lineStyle: {...axis.axisLine?.lineStyle, color}}
627
+ axis.axisTick = {...axis.axisTick, lineStyle: {...axis.axisTick?.lineStyle, color}}
628
+ axis.nameTextStyle = {...axis.nameTextStyle, color}
629
+ axis.axisLabel = {...axis.axisLabel, color}
630
+ }
631
+
632
+ function isHorizontalBar(config: NormalConfig) {
633
+ let xAxis = config.xAxis[0]
634
+ let yAxis = config.yAxis[0]
635
+ let hasBarSeries = config.series.some(series => series?.type === 'bar')
636
+ return Boolean(hasBarSeries && xAxis?.type === 'value' && yAxis?.type === 'category')
637
+ }
638
+
639
+ function horizontalBarGuard(config: NormalConfig, fields: Field[]) {
640
+ if (!isHorizontalBar(config)) return
641
+
642
+ let hasInvalidCategoryField = config.series
643
+ .filter(series => series?.type === 'bar')
644
+ .map(series => getEncodeField(series, fields, 'y')?.type)
645
+ .some(type => type === 'date' || type === 'timestamp' || type === 'number')
646
+
647
+ if (hasInvalidCategoryField) throw new Error('Horizontal charts do not support a value or time-based x-axis')
648
+ }
649
+
650
+ // Build axis defaults from field metadata, including temporal domains and formatters.
651
+ function inferAxisFromField(field: Field | undefined, rows: Record<string, any>[]) {
652
+ if (!field) return {type: 'category'}
653
+ if (typeof field.type !== 'string') throw new Error(`Field ${field.name} has unsupported non-scalar type: array`)
654
+
655
+ let type: 'time' | 'value' | 'category' = 'category'
656
+ if (field.type === 'date' || field.type === 'timestamp') type = 'time'
657
+ if (field.type === 'number') type = 'value'
658
+ let axis: Record<string, any> = {field, type}
659
+
660
+ if (type === 'value') {
661
+ let domain = temporalValueDomain(field, rows)
662
+ if (domain) {
663
+ axis.min = domain[0]
664
+ axis.max = domain[1]
665
+ }
666
+
667
+ if (field.metadata?.timeOrdinal) {
668
+ axis.minInterval = 1
669
+ if (field.metadata.timeOrdinal === 'dow_0s' || field.metadata.timeOrdinal === 'dow_1s' || field.metadata.timeOrdinal === 'dow_1m' || field.metadata.timeOrdinal === 'quarter_of_year') {
670
+ axis.splitNumber = Math.max(1, domain ? domain[1] - domain[0] + 1 : 5)
671
+ }
672
+ axis.axisLabel = {formatter: (value: unknown) => (domain && (Number(value) < domain[0] || Number(value) > domain[1]) ? '' : formatTimeOrdinal(field, value))}
673
+ axis.axisPointer = {label: {formatter: (value: unknown) => formatTimeOrdinal(field, value)}}
674
+ return axis
675
+ }
676
+
677
+ if (field.metadata?.timePart === 'year') {
678
+ axis.minInterval = 1
679
+ axis.axisLabel = {formatter: (value: unknown) => (Number.isInteger(Number(value)) ? String(Number(value)) : '')}
680
+ }
681
+ }
682
+
683
+ if (type === 'category' && field.metadata?.timeOrdinal) {
684
+ axis.axisLabel = {formatter: (value: unknown) => formatTimeOrdinal(field, value)}
685
+ axis.axisPointer = {label: {formatter: (value: unknown) => formatTimeOrdinal(field, value)}}
686
+ }
687
+
688
+ return axis
689
+ }
690
+
691
+ // Return the natural numeric domain for temporal values that are encoded as numbers.
692
+ function temporalValueDomain(field: Field, rows: Record<string, any>[]): [number, number] | undefined {
693
+ let ordinal = field.metadata?.timeOrdinal
694
+ if (ordinal === 'hour_of_day') return [0, 23]
695
+ if (ordinal === 'day_of_month') return [1, 31]
696
+ if (ordinal === 'day_of_year') return [1, 366]
697
+ if (ordinal === 'week_of_year') return [1, 53]
698
+ if (ordinal === 'month_of_year') return [1, 12]
699
+ if (ordinal === 'quarter_of_year') return [1, 4]
700
+ if (ordinal === 'dow_0s') return [0, 6]
701
+ if (ordinal === 'dow_1s' || ordinal === 'dow_1m') return [1, 7]
702
+
703
+ if (field.metadata?.timePart == 'year') {
704
+ let values = rows.map(row => Number(row?.[field.name])).filter(value => Number.isFinite(value))
705
+ if (values.length === 0) return undefined
706
+ return [Math.min(...values), Math.max(...values)]
707
+ }
708
+ }
709
+
710
+ // Series sometimes encode their value field as `y` and sometimes as `value` (pie, funnel, etc).
711
+ function getSeriesValueField(series: SeriesWithGroupingHint | undefined, fields: Field[]) {
712
+ return getEncodeField(series, fields, 'y') ?? getEncodeField(series, fields, 'value')
713
+ }
714
+
715
+ // The field(s) to format in a series' tooltip. Depends on series type since scatter/bar put the numeric value in different encode props.
716
+ function getSeriesValueFields(series: SeriesWithGroupingHint, fields: Field[]) {
717
+ switch (series.type) {
718
+ case 'scatter':
719
+ case 'effectScatter':
720
+ return [getEncodeField(series, fields, 'x'), getEncodeField(series, fields, 'y')].filter((f): f is Field => !!f)
721
+ case 'bar': {
722
+ let xField = getEncodeField(series, fields, 'x')
723
+ let yField = getEncodeField(series, fields, 'y')
724
+ return xField?.type == 'number' ? [xField] : [yField].filter((f): f is Field => !!f)
725
+ }
726
+ default:
727
+ return [getEncodeField(series, fields, 'y')].filter((f): f is Field => !!f)
728
+ }
729
+ }
730
+
731
+ // The props on series.encode can either be a string, or an array.
732
+ // In all cases, this returns the corresponding fields for each item.
733
+ function getEncodeFields(series: SeriesWithGroupingHint | undefined, fields: Field[], encodeProp: string): Field[] {
734
+ let raw = series?.encode?.[encodeProp]
735
+ let names: string[] = []
736
+ if (Array.isArray(raw)) names = raw.filter((v): v is string => typeof v === 'string')
737
+ if (typeof raw === 'string') names = [raw]
738
+
739
+ return names.map(name => fields.find(f => f.name === name)).filter((f): f is Field => !!f)
740
+ }
741
+
742
+ function getEncodeField(series: SeriesWithGroupingHint | undefined, fields: Field[], encodeProp: string): Field | undefined {
743
+ return getEncodeFields(series, fields, encodeProp)[0]
744
+ }
745
+
746
+ function inferDimensions(rows: Record<string, any>[]) {
747
+ let sample = rows.find(row => row && typeof row === 'object')
748
+ if (!sample) return []
749
+ return Object.keys(sample)
750
+ }
751
+
752
+ function distinctValues(rows: Record<string, any>[], field: string) {
753
+ let values: unknown[] = []
754
+ let seen = new Set<string>()
755
+ for (let row of rows) {
756
+ let value = row?.[field]
757
+ let key = JSON.stringify(value ?? null)
758
+ if (seen.has(key)) continue
759
+ seen.add(key)
760
+ values.push(value)
761
+ }
762
+ return values
763
+ }