@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.
- package/LICENSE.md +3 -3
- package/README.md +138 -0
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/bin.js +2 -2
- package/dist/cli/bigQuery-I3F46SC6.js +75 -0
- package/dist/cli/bigQuery-I3F46SC6.js.map +7 -0
- package/dist/cli/chunk-OVWODUTJ.js +12849 -0
- package/dist/cli/chunk-OVWODUTJ.js.map +7 -0
- package/dist/cli/chunk-QAXEOZ43.js +53 -0
- package/dist/cli/chunk-QAXEOZ43.js.map +7 -0
- package/dist/cli/cli.js +245 -10290
- package/dist/cli/clickhouse-ZN5AN2UL.js +64 -0
- package/dist/cli/clickhouse-ZN5AN2UL.js.map +7 -0
- package/dist/cli/duckdb-IYBIO5KJ.js +87 -0
- package/dist/cli/duckdb-IYBIO5KJ.js.map +7 -0
- package/dist/cli/serve2-TNN5EROW.js +447 -0
- package/dist/cli/serve2-TNN5EROW.js.map +7 -0
- package/dist/cli/snowflake-MOQB5GA4.js +128 -0
- package/dist/cli/snowflake-MOQB5GA4.js.map +7 -0
- package/dist/index.d.ts +63 -0
- package/dist/lang/index.d.ts +63 -0
- package/dist/skills/graphene/SKILL.md +235 -0
- package/dist/skills/graphene/references/big-value.md +20 -0
- package/dist/skills/graphene/references/date-range.md +64 -0
- package/dist/skills/graphene/references/dropdown.md +62 -0
- package/dist/skills/graphene/references/echarts.md +162 -0
- package/dist/skills/graphene/references/gsql.md +393 -0
- package/dist/skills/graphene/references/model-gsql.md +72 -0
- package/dist/skills/graphene/references/table.md +143 -0
- package/dist/skills/graphene/references/text-input.md +29 -0
- package/dist/ui/app.css +263 -299
- package/dist/ui/component-utilities/dataShaping.ts +484 -0
- package/dist/ui/component-utilities/dataSummary.ts +57 -0
- package/dist/ui/component-utilities/enrich.ts +763 -0
- package/dist/ui/component-utilities/format.ts +177 -0
- package/dist/ui/component-utilities/inputUtils.ts +48 -9
- package/dist/ui/component-utilities/theme.ts +200 -0
- package/dist/ui/component-utilities/themeStores.ts +26 -21
- package/dist/ui/component-utilities/types.ts +70 -0
- package/dist/ui/components/AreaChart.svelte +57 -105
- package/dist/ui/components/BarChart.svelte +71 -129
- package/dist/ui/components/BigValue.svelte +24 -40
- package/dist/ui/components/Column.svelte +11 -19
- package/dist/ui/components/DateRange.svelte +71 -34
- package/dist/ui/components/Dropdown.svelte +82 -49
- package/dist/ui/components/DropdownOption.svelte +1 -2
- package/dist/ui/components/ECharts.svelte +179 -60
- package/dist/ui/components/InlineDelta.svelte +51 -32
- package/dist/ui/components/LineChart.svelte +54 -125
- package/dist/ui/components/PieChart.svelte +27 -37
- package/dist/ui/components/QueryLoad.svelte +78 -44
- package/dist/ui/components/Row.svelte +2 -1
- package/dist/ui/components/ScatterPlot.svelte +52 -0
- package/dist/ui/components/Skeleton.svelte +32 -0
- package/dist/ui/components/Table.svelte +3 -2
- package/dist/ui/components/TableGroupRow.svelte +28 -36
- package/dist/ui/components/TableHarness.svelte +32 -0
- package/dist/ui/components/TableHeader.svelte +34 -59
- package/dist/ui/components/TableRow.svelte +15 -39
- package/dist/ui/components/TableSubtotalRow.svelte +26 -21
- package/dist/ui/components/TableTotalRow.svelte +27 -37
- package/dist/ui/components/TextInput.svelte +17 -14
- package/dist/ui/components/Value.svelte +25 -0
- package/dist/ui/components/_Table.svelte +80 -76
- package/dist/ui/internal/ChartGallery.svelte +527 -0
- package/dist/ui/internal/ErrorDisplay.svelte +60 -0
- package/dist/ui/internal/LocalApp.svelte +87 -19
- package/dist/ui/internal/PageNavGroup.svelte +269 -0
- package/dist/ui/internal/Sidebar.svelte +178 -0
- package/dist/ui/internal/SidebarToggle.svelte +47 -0
- package/dist/ui/internal/StyleGallery.svelte +244 -0
- package/dist/ui/internal/clientCache.ts +15 -13
- package/dist/ui/internal/pageInputs.svelte.js +292 -0
- package/dist/ui/internal/queryEngine.ts +124 -132
- package/dist/ui/internal/runSocket.ts +59 -0
- package/dist/ui/internal/sidebar.svelte.js +18 -0
- package/dist/ui/internal/telemetry.ts +52 -17
- package/dist/ui/internal/types.d.ts +7 -0
- package/dist/ui/web.js +55 -13
- package/package.json +40 -41
- package/dist/docs/agent-instructions.md +0 -18
- package/dist/docs/base.md +0 -98
- package/dist/docs/cli.md +0 -22
- package/dist/docs/graphene.md +0 -1462
- package/dist/ui/component-utilities/autoFormatting.js +0 -301
- package/dist/ui/component-utilities/builtInFormats.js +0 -482
- package/dist/ui/component-utilities/chartContext.js +0 -12
- package/dist/ui/component-utilities/chartWindowDebug.js +0 -21
- package/dist/ui/component-utilities/checkInputs.js +0 -95
- package/dist/ui/component-utilities/convert.js +0 -15
- package/dist/ui/component-utilities/dateParsing.js +0 -57
- package/dist/ui/component-utilities/dropdownContext.ts +0 -1
- package/dist/ui/component-utilities/echarts.js +0 -272
- package/dist/ui/component-utilities/echartsThemes.js +0 -453
- package/dist/ui/component-utilities/formatTitle.js +0 -24
- package/dist/ui/component-utilities/formatting.js +0 -250
- package/dist/ui/component-utilities/getColumnExtents.js +0 -79
- package/dist/ui/component-utilities/getColumnSummary.js +0 -67
- package/dist/ui/component-utilities/getCompletedData.js +0 -114
- package/dist/ui/component-utilities/getDistinctCount.js +0 -7
- package/dist/ui/component-utilities/getDistinctValues.js +0 -15
- package/dist/ui/component-utilities/getSeriesConfig.js +0 -237
- package/dist/ui/component-utilities/getSortedData.js +0 -7
- package/dist/ui/component-utilities/getStackPercentages.js +0 -43
- package/dist/ui/component-utilities/getStackedData.js +0 -17
- package/dist/ui/component-utilities/getYAxisIndex.js +0 -15
- package/dist/ui/component-utilities/globalContexts.js +0 -1
- package/dist/ui/component-utilities/helpers/getCompletedData.helpers.js +0 -119
- package/dist/ui/component-utilities/replaceNulls.js +0 -14
- package/dist/ui/component-utilities/tableUtils.ts +0 -120
- package/dist/ui/components/Area.svelte +0 -214
- package/dist/ui/components/Bar.svelte +0 -350
- package/dist/ui/components/Chart.svelte +0 -989
- package/dist/ui/components/ErrorChart.svelte +0 -118
- package/dist/ui/components/Line.svelte +0 -227
- package/dist/ui/internal/NavSidebar.svelte +0 -396
- package/dist/ui/internal/PageError.svelte +0 -23
- package/dist/ui/internal/checkSocket.ts +0 -48
- package/dist/ui/internal/theme.ts +0 -88
- package/dist/ui/public/inter-latin-ext.woff2 +0 -0
- 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
|
+
}
|