@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.
- package/README.md +174 -0
- package/dist/cli/bigQuery-OQUNH3VT.js +75 -0
- package/dist/cli/bigQuery-OQUNH3VT.js.map +7 -0
- package/dist/cli/chunk-56K2FF57.js +53 -0
- package/dist/cli/chunk-56K2FF57.js.map +7 -0
- package/dist/cli/chunk-TZTTALAV.js +12868 -0
- package/dist/cli/chunk-TZTTALAV.js.map +7 -0
- package/dist/cli/cli.js +260 -11196
- package/dist/cli/clickhouse-S3BJSKND.js +65 -0
- package/dist/cli/clickhouse-S3BJSKND.js.map +7 -0
- package/dist/cli/duckdb-TKVMONRK.js +87 -0
- package/dist/cli/duckdb-TKVMONRK.js.map +7 -0
- package/dist/cli/serve2-S2LL4D4D.js +448 -0
- package/dist/cli/serve2-S2LL4D4D.js.map +7 -0
- package/dist/cli/snowflake-3VPDEYYP.js +128 -0
- package/dist/cli/snowflake-3VPDEYYP.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 +156 -95
- package/dist/skills/graphene/references/big-value.md +6 -41
- package/dist/skills/graphene/references/date-range.md +64 -0
- package/dist/skills/graphene/references/dropdown.md +3 -4
- package/dist/skills/graphene/references/echarts.md +162 -0
- package/dist/skills/graphene/references/gsql.md +55 -25
- package/dist/skills/graphene/references/model-gsql.md +70 -0
- package/dist/skills/graphene/references/table.md +13 -14
- package/dist/skills/graphene/references/text-input.md +2 -1
- package/dist/ui/app.css +239 -340
- 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 +793 -0
- package/dist/ui/component-utilities/format.ts +177 -0
- package/dist/ui/component-utilities/inputUtils.ts +44 -8
- package/dist/ui/component-utilities/theme.ts +200 -0
- package/dist/ui/component-utilities/themeStores.ts +21 -8
- 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 +10 -18
- package/dist/ui/components/DateRange.svelte +54 -21
- package/dist/ui/components/Dropdown.svelte +47 -26
- package/dist/ui/components/DropdownOption.svelte +1 -2
- package/dist/ui/components/ECharts.svelte +181 -67
- package/dist/ui/components/InlineDelta.svelte +50 -31
- package/dist/ui/components/LineChart.svelte +54 -125
- package/dist/ui/components/PieChart.svelte +27 -37
- package/dist/ui/components/QueryLoad.svelte +77 -45
- 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 +14 -38
- package/dist/ui/components/TableSubtotalRow.svelte +18 -21
- package/dist/ui/components/TableTotalRow.svelte +27 -37
- package/dist/ui/components/TextInput.svelte +13 -12
- package/dist/ui/components/Value.svelte +25 -0
- package/dist/ui/components/_Table.svelte +72 -70
- package/dist/ui/internal/ChartGallery.svelte +527 -0
- package/dist/ui/internal/ErrorDisplay.svelte +22 -97
- package/dist/ui/internal/LocalApp.svelte +84 -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 +2 -2
- package/dist/ui/internal/pageInputs.svelte.js +292 -0
- package/dist/ui/internal/queryEngine.ts +112 -129
- package/dist/ui/internal/runSocket.ts +31 -14
- package/dist/ui/internal/sidebar.svelte.js +18 -0
- package/dist/ui/internal/telemetry.ts +51 -16
- package/dist/ui/internal/types.d.ts +7 -0
- package/dist/ui/web.js +30 -11
- package/package.json +40 -38
- package/dist/skills/graphene/references/area-chart.md +0 -95
- package/dist/skills/graphene/references/bar-chart.md +0 -112
- package/dist/skills/graphene/references/line-chart.md +0 -108
- package/dist/skills/graphene/references/pie-chart.md +0 -29
- package/dist/skills/graphene/references/value-formats.md +0 -104
- package/dist/ui/component-utilities/autoFormatting.js +0 -280
- package/dist/ui/component-utilities/builtInFormats.js +0 -481
- 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 -84
- package/dist/ui/component-utilities/convert.js +0 -15
- package/dist/ui/component-utilities/dateParsing.js +0 -56
- package/dist/ui/component-utilities/dropdownContext.ts +0 -1
- package/dist/ui/component-utilities/echarts.js +0 -252
- package/dist/ui/component-utilities/echartsThemes.js +0 -443
- package/dist/ui/component-utilities/formatTitle.js +0 -24
- package/dist/ui/component-utilities/formatting.js +0 -241
- package/dist/ui/component-utilities/getColumnExtents.js +0 -79
- package/dist/ui/component-utilities/getColumnSummary.js +0 -62
- package/dist/ui/component-utilities/getCompletedData.js +0 -122
- 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 -231
- package/dist/ui/component-utilities/getSortedData.js +0 -9
- package/dist/ui/component-utilities/getStackPercentages.js +0 -45
- package/dist/ui/component-utilities/getStackedData.js +0 -19
- 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 -16
- package/dist/ui/component-utilities/tableUtils.ts +0 -107
- package/dist/ui/component-utilities/tidyWithTypes.js +0 -9
- package/dist/ui/components/Area.svelte +0 -214
- package/dist/ui/components/Bar.svelte +0 -347
- package/dist/ui/components/Chart.svelte +0 -995
- package/dist/ui/components/Line.svelte +0 -227
- package/dist/ui/internal/NavSidebar.svelte +0 -396
- package/dist/ui/internal/theme.ts +0 -60
- package/dist/ui/public/inter-latin-ext.woff2 +0 -0
- package/dist/ui/public/inter-latin.woff2 +0 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import type {Field, NormalConfig, SeriesWithGroupingHint} from './types.ts'
|
|
2
|
+
|
|
3
|
+
// Fill sparse grouped data so each split series has a value for each x bucket.
|
|
4
|
+
//
|
|
5
|
+
// This only applies to split templates (`encode.splitBy`).
|
|
6
|
+
// We do not attempt to fabricate x values here; we only ensure a full Cartesian
|
|
7
|
+
// product of existing x values and split values.
|
|
8
|
+
//
|
|
9
|
+
// Missing-value behavior by chart type (Evidence defaults):
|
|
10
|
+
// - line (no area): null -> visible line gaps
|
|
11
|
+
// - area (line + areaStyle):
|
|
12
|
+
// - stacked area: 0 -> continuous stacked area baseline
|
|
13
|
+
// - unstacked area: null -> visible gaps like line charts
|
|
14
|
+
// - bar: 0 -> missing category bars render as zero-height bars
|
|
15
|
+
export function applyMissingPointDefaults(config: NormalConfig, rows: Record<string, any>[]) {
|
|
16
|
+
let series = config.series
|
|
17
|
+
if (series.length === 0 || rows.length === 0) return
|
|
18
|
+
|
|
19
|
+
let groups = new Map<string, {xField: string; splitFields: string[]; fills: Map<string, any>}>()
|
|
20
|
+
|
|
21
|
+
for (let entry of series) {
|
|
22
|
+
let splitFields = getSplitFields(entry)
|
|
23
|
+
let xField = getSeriesXField(entry)
|
|
24
|
+
let yField = getSeriesYField(entry)
|
|
25
|
+
if (splitFields.length === 0 || !xField || !yField) continue
|
|
26
|
+
|
|
27
|
+
let key = `${xField}::${splitFields.join('::')}`
|
|
28
|
+
if (!groups.has(key)) groups.set(key, {xField, splitFields, fills: new Map()})
|
|
29
|
+
|
|
30
|
+
// This line is where chart-specific missing-value behavior is chosen.
|
|
31
|
+
// See getMissingFillValueForSeries() below for the type mapping.
|
|
32
|
+
let fillValue = getMissingFillValueForSeries(entry)
|
|
33
|
+
let fills = groups.get(key)!.fills
|
|
34
|
+
|
|
35
|
+
// If multiple templates target the same y field, prefer zero over null.
|
|
36
|
+
// (bar/area should win over plain line when mixed configs exist.)
|
|
37
|
+
if (!fills.has(yField) || fillValue === 0) fills.set(yField, fillValue)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (let group of groups.values()) {
|
|
41
|
+
let xValues = distinctValues(rows, group.xField)
|
|
42
|
+
let splitValues = group.splitFields.map(field => distinctValues(rows, field))
|
|
43
|
+
if (xValues.length === 0 || splitValues.some(values => values.length === 0)) continue
|
|
44
|
+
|
|
45
|
+
let existing = new Set<string>()
|
|
46
|
+
for (let row of rows) {
|
|
47
|
+
existing.add(compositeKey([row?.[group.xField], ...group.splitFields.map(field => row?.[field])]))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (let xValue of xValues) {
|
|
51
|
+
for (let splitCombination of cartesianValues(splitValues)) {
|
|
52
|
+
if (existing.has(compositeKey([xValue, ...splitCombination]))) continue
|
|
53
|
+
|
|
54
|
+
let row: Record<string, any> = {[group.xField]: xValue}
|
|
55
|
+
group.splitFields.forEach((field, index) => {
|
|
56
|
+
row[field] = splitCombination[index]
|
|
57
|
+
})
|
|
58
|
+
for (let [yField, fillValue] of group.fills.entries()) row[yField] = fillValue
|
|
59
|
+
rows.push(row)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Evidence stacked100 behavior: compute percentages per x-domain and rewrite series to synthetic pct fields.
|
|
66
|
+
export function applyStackPercentage(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
|
|
67
|
+
let series = config.series
|
|
68
|
+
if (series.length === 0 || rows.length === 0) return
|
|
69
|
+
|
|
70
|
+
let groupIndex = 0
|
|
71
|
+
|
|
72
|
+
for (let entry of series) {
|
|
73
|
+
let xField = getSeriesXField(entry)
|
|
74
|
+
let yField = getSeriesYField(entry)
|
|
75
|
+
if (entry?.stackPercentage !== true || !entry?.stack || !xField || !yField || entry?.datasetId != null) continue
|
|
76
|
+
|
|
77
|
+
let stackGroup = series.filter(candidate => {
|
|
78
|
+
return candidate?.stack === entry.stack && getSeriesXField(candidate) === xField && getSeriesYField(candidate)
|
|
79
|
+
})
|
|
80
|
+
if (stackGroup[0] !== entry) continue
|
|
81
|
+
|
|
82
|
+
let yFields = Array.from(new Set(stackGroup.map(candidate => getSeriesYField(candidate)).filter(Boolean))) as string[]
|
|
83
|
+
let pctFieldByY = Object.fromEntries(yFields.map((y, index) => [y, `__graphene_stack_pct_${groupIndex}_${index}`])) as Record<string, string>
|
|
84
|
+
|
|
85
|
+
let totalsByX = new Map<string, number>()
|
|
86
|
+
for (let row of rows) {
|
|
87
|
+
let xKey = valueKey(row?.[xField])
|
|
88
|
+
let rowTotal = yFields.reduce((sum, y) => sum + (Number(row?.[y]) || 0), 0)
|
|
89
|
+
totalsByX.set(xKey, (totalsByX.get(xKey) ?? 0) + rowTotal)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (let row of rows) {
|
|
93
|
+
let xKey = valueKey(row?.[xField])
|
|
94
|
+
let total = totalsByX.get(xKey) ?? 0
|
|
95
|
+
for (let y of yFields) row[pctFieldByY[y]] = total <= 0 ? 0 : (Number(row?.[y]) || 0) / total
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (let y of yFields) ensureField(fields, pctFieldByY[y], {metadata: {ratio: true}})
|
|
99
|
+
|
|
100
|
+
for (let candidate of stackGroup) {
|
|
101
|
+
let y = getSeriesYField(candidate)
|
|
102
|
+
if (!y) continue
|
|
103
|
+
candidate.encode = {...candidate.encode, y: pctFieldByY[y]}
|
|
104
|
+
delete candidate.stackPercentage
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
groupIndex++
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sort rows with either an explicit `encode.sort` rule or our built-in defaults.
|
|
112
|
+
// Explicit sort format: "column" or "column asc|desc".
|
|
113
|
+
export function applySorting(config: NormalConfig, rows: Record<string, any>[], fields: Field[]) {
|
|
114
|
+
let series = config.series
|
|
115
|
+
if (series.length === 0) return
|
|
116
|
+
|
|
117
|
+
// Explicit sort always wins and only applies to categorical axes.
|
|
118
|
+
let explicitSort = resolveExplicitSort(series, fields)
|
|
119
|
+
removeSortHints(series)
|
|
120
|
+
if (rows.length === 0) return
|
|
121
|
+
|
|
122
|
+
let categoryField = [...config.xAxis, ...config.yAxis].find(axis => axis?.type === 'category')?.field?.name
|
|
123
|
+
if (explicitSort) {
|
|
124
|
+
if (!categoryField) throw new Error('sort is only supported when the chart has a categorical axis')
|
|
125
|
+
sortCategoriesByField(rows, categoryField, explicitSort.field, explicitSort.direction, fields)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let primaryXField = config.xAxis[0]?.field
|
|
130
|
+
if (!primaryXField) return
|
|
131
|
+
|
|
132
|
+
let timeOrdinal = String(primaryXField.metadata?.timeOrdinal || '').toLowerCase()
|
|
133
|
+
if (timeOrdinal) {
|
|
134
|
+
sortRowsByXTimeOrdinal(rows, primaryXField.name, timeOrdinal)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// time/value x fields keep natural ascending order
|
|
139
|
+
if (primaryXField.type === 'date' || primaryXField.type === 'timestamp' || primaryXField.type === 'number') {
|
|
140
|
+
sortRowsByXAscending(rows, primaryXField.name, primaryXField.type === 'number' ? 'number' : 'date')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (primaryXField.type !== 'string') return
|
|
145
|
+
|
|
146
|
+
let primarySeries = series.filter(entry => getSeriesXField(entry) === primaryXField.name && getSeriesYField(entry))
|
|
147
|
+
if (primarySeries.length === 0) return
|
|
148
|
+
|
|
149
|
+
let hasStackedBars = primarySeries.some(entry => entry?.type === 'bar' && (!!entry?.stack || getSplitFields(entry).length === 2))
|
|
150
|
+
if (hasStackedBars) {
|
|
151
|
+
let yFields = Array.from(new Set(primarySeries.map(entry => getSeriesYField(entry)).filter(Boolean))) as string[]
|
|
152
|
+
sortCategoriesByValue(rows, primaryXField.name, row => yFields.reduce((sum, y) => sum + (Number(row?.[y]) || 0), 0), 'desc')
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let firstY = getSeriesYField(primarySeries[0])
|
|
157
|
+
if (!firstY) return
|
|
158
|
+
sortCategoriesByValue(rows, primaryXField.name, row => Number(row?.[firstY]) || 0, 'desc')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Materialize dataset-backed bar series into explicit point arrays so later enrichments can mutate points.
|
|
162
|
+
// This is needed to round the corners of bars, which can only be done with point-level item styles.
|
|
163
|
+
export function inlineDataIntoSeries(config: NormalConfig, rows: Record<string, any>[]) {
|
|
164
|
+
let horizontal = isHorizontalBar(config)
|
|
165
|
+
let datasetsById = new Map<string, Record<string, any>>()
|
|
166
|
+
for (let dataset of config.dataset) {
|
|
167
|
+
if (!dataset?.id) continue
|
|
168
|
+
datasetsById.set(String(dataset.id), dataset as Record<string, any>)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let memo = new Map<string, Record<string, any>[] | null>()
|
|
172
|
+
let datasetRows = (datasetId?: string): Record<string, any>[] | null => {
|
|
173
|
+
if (!datasetId) return rows
|
|
174
|
+
if (memo.has(datasetId)) return memo.get(datasetId) ?? null
|
|
175
|
+
|
|
176
|
+
let dataset = datasetsById.get(datasetId)
|
|
177
|
+
if (!dataset) return null
|
|
178
|
+
|
|
179
|
+
if (Array.isArray(dataset.source)) {
|
|
180
|
+
memo.set(datasetId, dataset.source as Record<string, any>[])
|
|
181
|
+
return dataset.source as Record<string, any>[]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let parentId = dataset.fromDatasetId != null ? String(dataset.fromDatasetId) : undefined
|
|
185
|
+
if (!parentId) return null
|
|
186
|
+
|
|
187
|
+
let parentRows = datasetRows(parentId)
|
|
188
|
+
if (!parentRows) return null
|
|
189
|
+
|
|
190
|
+
let transform = dataset.transform as Record<string, any> | undefined
|
|
191
|
+
if (transform?.type !== 'filter') return null
|
|
192
|
+
|
|
193
|
+
let filterConfig = transform.config as Record<string, any> | undefined
|
|
194
|
+
let filterField = filterConfig?.dimension
|
|
195
|
+
if (typeof filterField !== 'string' || !filterConfig || !Object.prototype.hasOwnProperty.call(filterConfig, '=')) return null
|
|
196
|
+
|
|
197
|
+
let filtered = parentRows.filter(row => row?.[filterField] === filterConfig['='])
|
|
198
|
+
memo.set(datasetId, filtered)
|
|
199
|
+
return filtered
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (let series of config.series) {
|
|
203
|
+
if (series?.type !== 'bar' || !series?.stack || series?.data != null) continue
|
|
204
|
+
|
|
205
|
+
let xField = getSeriesXField(series)
|
|
206
|
+
let yField = getSeriesYField(series)
|
|
207
|
+
let categoryField = horizontal ? yField : xField
|
|
208
|
+
if (!xField || !yField || !categoryField) continue
|
|
209
|
+
|
|
210
|
+
let seriesRows = datasetRows(series.datasetId)
|
|
211
|
+
if (!seriesRows) continue
|
|
212
|
+
|
|
213
|
+
let rowByCategory = new Map<string, Record<string, any>>()
|
|
214
|
+
for (let row of seriesRows) {
|
|
215
|
+
let key = valueKey(row?.[categoryField])
|
|
216
|
+
if (!rowByCategory.has(key)) rowByCategory.set(key, row)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let categories = distinctValues(rows, categoryField)
|
|
220
|
+
series.data = categories.map(categoryValue => {
|
|
221
|
+
let sourceRow = rowByCategory.get(valueKey(categoryValue))!
|
|
222
|
+
return {...sourceRow, value: [sourceRow[xField], sourceRow[yField]]}
|
|
223
|
+
})
|
|
224
|
+
delete series.datasetId
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sortRowsByXAscending(rows: Record<string, any>[], xField: string, xType: 'date' | 'number') {
|
|
229
|
+
let indexed = rows.map((row, index) => ({row, index}))
|
|
230
|
+
indexed.sort((a, b) => {
|
|
231
|
+
let aValue = sortableValue(a.row?.[xField], xType)
|
|
232
|
+
let bValue = sortableValue(b.row?.[xField], xType)
|
|
233
|
+
if (aValue < bValue) return -1
|
|
234
|
+
if (aValue > bValue) return 1
|
|
235
|
+
return a.index - b.index
|
|
236
|
+
})
|
|
237
|
+
for (let i = 0; i < indexed.length; i++) rows[i] = indexed[i].row
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sortRowsByXTimeOrdinal(rows: Record<string, any>[], xField: string, timeOrdinal: string) {
|
|
241
|
+
let indexed = rows.map((row, index) => ({row, index}))
|
|
242
|
+
indexed.sort((a, b) => {
|
|
243
|
+
let aValue = ordinalSortValue(a.row?.[xField], timeOrdinal)
|
|
244
|
+
let bValue = ordinalSortValue(b.row?.[xField], timeOrdinal)
|
|
245
|
+
if (aValue < bValue) return -1
|
|
246
|
+
if (aValue > bValue) return 1
|
|
247
|
+
return a.index - b.index
|
|
248
|
+
})
|
|
249
|
+
for (let i = 0; i < indexed.length; i++) rows[i] = indexed[i].row
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Aggregate one numeric value per category, then order categories by that value.
|
|
253
|
+
function sortCategoriesByValue(rows: Record<string, any>[], categoryField: string, metricForRow: (row: Record<string, any>) => number, direction: 'asc' | 'desc') {
|
|
254
|
+
let metricByCategory = new Map<string, number>()
|
|
255
|
+
|
|
256
|
+
for (let row of rows) {
|
|
257
|
+
let key = valueKey(row?.[categoryField])
|
|
258
|
+
metricByCategory.set(key, (metricByCategory.get(key) ?? 0) + metricForRow(row))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let orderedCategoryKeys = Array.from(metricByCategory.keys())
|
|
262
|
+
orderedCategoryKeys.sort((left, right) => {
|
|
263
|
+
let leftMetric = metricByCategory.get(left) ?? 0
|
|
264
|
+
let rightMetric = metricByCategory.get(right) ?? 0
|
|
265
|
+
return direction === 'asc' ? leftMetric - rightMetric : rightMetric - leftMetric
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
sortRowsByCategoryOrder(rows, categoryField, orderedCategoryKeys)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Sort categories by a specific field.
|
|
272
|
+
// Numeric fields are summed per category; non-numeric fields use first value seen.
|
|
273
|
+
function sortCategoriesByField(rows: Record<string, any>[], categoryField: string, sortField: string, direction: 'asc' | 'desc', fields: Field[]) {
|
|
274
|
+
let sortType = inferFieldType(fields, sortField)
|
|
275
|
+
|
|
276
|
+
if (sortType === 'number') {
|
|
277
|
+
sortCategoriesByValue(rows, categoryField, row => Number(row?.[sortField]) || 0, direction)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let sortValueByCategory = new Map<string, unknown>()
|
|
282
|
+
for (let row of rows) {
|
|
283
|
+
let key = valueKey(row?.[categoryField])
|
|
284
|
+
if (sortValueByCategory.has(key)) continue
|
|
285
|
+
sortValueByCategory.set(key, row?.[sortField])
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let orderedCategoryKeys = distinctValues(rows, categoryField).map(value => valueKey(value))
|
|
289
|
+
orderedCategoryKeys.sort((left, right) => {
|
|
290
|
+
let leftValue = sortValueByCategory.get(left)
|
|
291
|
+
let rightValue = sortValueByCategory.get(right)
|
|
292
|
+
|
|
293
|
+
if (sortType === 'date') {
|
|
294
|
+
let leftDate = sortableValue(leftValue, 'date')
|
|
295
|
+
let rightDate = sortableValue(rightValue, 'date')
|
|
296
|
+
return direction === 'asc' ? leftDate - rightDate : rightDate - leftDate
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let comparison = String(leftValue ?? '').localeCompare(String(rightValue ?? ''), undefined, {numeric: true})
|
|
300
|
+
return direction === 'asc' ? comparison : -comparison
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
sortRowsByCategoryOrder(rows, categoryField, orderedCategoryKeys)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Apply a category order while preserving original row order within each category.
|
|
307
|
+
function sortRowsByCategoryOrder(rows: Record<string, any>[], categoryField: string, orderedCategoryKeys: string[]) {
|
|
308
|
+
let positionByCategory = new Map<string, number>(orderedCategoryKeys.map((key, index) => [key, index]))
|
|
309
|
+
let indexed = rows.map((row, index) => ({row, index}))
|
|
310
|
+
|
|
311
|
+
indexed.sort((left, right) => {
|
|
312
|
+
let leftPos = positionByCategory.get(valueKey(left.row?.[categoryField])) ?? Number.MAX_SAFE_INTEGER
|
|
313
|
+
let rightPos = positionByCategory.get(valueKey(right.row?.[categoryField])) ?? Number.MAX_SAFE_INTEGER
|
|
314
|
+
if (leftPos !== rightPos) return leftPos - rightPos
|
|
315
|
+
return left.index - right.index
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
for (let i = 0; i < indexed.length; i++) rows[i] = indexed[i].row
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function ensureField(fields: Field[], name: string, options?: Partial<Field>) {
|
|
322
|
+
if (fields.some(field => field.name === name)) return
|
|
323
|
+
fields.push({name, type: 'number', ...options})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Default missing datapoint handling differs by chart type.
|
|
327
|
+
// - bar: missing grouped points become 0
|
|
328
|
+
// - area: stacked -> 0, unstacked -> null (gap)
|
|
329
|
+
// - line: missing grouped points become null (shows a gap unless connectNulls is enabled)
|
|
330
|
+
function getMissingFillValueForSeries(series: SeriesWithGroupingHint) {
|
|
331
|
+
if (series?.type === 'bar') return 0
|
|
332
|
+
|
|
333
|
+
let isArea = series?.type === 'line' && series?.areaStyle != null
|
|
334
|
+
if (isArea && series?.stack) return 0
|
|
335
|
+
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function inferFieldType(fields: Field[], fieldName: string) {
|
|
340
|
+
let field = fields.find(entry => entry.name === fieldName)
|
|
341
|
+
if (!field) return 'string'
|
|
342
|
+
if (typeof field.type !== 'string') throw new Error(`Field ${fieldName} has unsupported non-scalar type: array`)
|
|
343
|
+
if (field.type === 'date' || field.type === 'timestamp') return 'date'
|
|
344
|
+
if (field.type === 'number') return 'number'
|
|
345
|
+
return 'string'
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function resolveExplicitSort(series: SeriesWithGroupingHint[], fields: Field[]) {
|
|
349
|
+
let specs = Array.from(
|
|
350
|
+
new Set(
|
|
351
|
+
series
|
|
352
|
+
.map(entry => entry?.encode?.sort)
|
|
353
|
+
.filter(value => typeof value === 'string')
|
|
354
|
+
.map(value => String(value)),
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
if (specs.length === 0) return undefined
|
|
358
|
+
if (specs.length > 1) throw new Error('sort must be the same across all series')
|
|
359
|
+
|
|
360
|
+
let parsed = parseSortSpec(specs[0])
|
|
361
|
+
if (!fields.some(field => field.name === parsed.field))
|
|
362
|
+
throw new Error(`${parsed.field} is not a column in the dataset. sort should contain one column name and optionally a direction (asc or desc).`)
|
|
363
|
+
return parsed
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// encode.sort is a Graphene-only hint. Remove it once parsed so ECharts does not
|
|
367
|
+
// treat the sort column as another encoded dimension in tooltips.
|
|
368
|
+
function removeSortHints(series: SeriesWithGroupingHint[]) {
|
|
369
|
+
for (let entry of series) {
|
|
370
|
+
if (!entry?.encode || entry.encode.sort == null) continue
|
|
371
|
+
entry.encode = {...entry.encode}
|
|
372
|
+
delete entry.encode.sort
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function parseSortSpec(sort: string): {field: string; direction: 'asc' | 'desc'} {
|
|
377
|
+
let parts = sort.trim().split(/\s+/).filter(Boolean)
|
|
378
|
+
if (parts.length === 0 || parts.length > 2) throw new Error('sort should contain one column name and optionally a direction (asc or desc).')
|
|
379
|
+
|
|
380
|
+
let field = parts[0]
|
|
381
|
+
let direction = parts[1]?.toLowerCase()
|
|
382
|
+
if (!field) throw new Error('sort should contain one column name and optionally a direction (asc or desc).')
|
|
383
|
+
if (!direction) return {field, direction: 'asc'}
|
|
384
|
+
if (direction !== 'asc' && direction !== 'desc') throw new Error('sort should contain one column name and optionally a direction (asc or desc).')
|
|
385
|
+
return {field, direction}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function getSplitFields(series: SeriesWithGroupingHint) {
|
|
389
|
+
let splitBy = series?.encode?.splitBy
|
|
390
|
+
if (typeof splitBy === 'string') return [splitBy]
|
|
391
|
+
if (!Array.isArray(splitBy)) return []
|
|
392
|
+
return splitBy
|
|
393
|
+
.filter(value => typeof value === 'string')
|
|
394
|
+
.map(value => value.trim())
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isHorizontalBar(config: NormalConfig) {
|
|
399
|
+
let xAxis = config.xAxis[0]
|
|
400
|
+
let yAxis = config.yAxis[0]
|
|
401
|
+
let hasBarSeries = config.series.some(series => series?.type === 'bar')
|
|
402
|
+
return Boolean(hasBarSeries && xAxis?.type === 'value' && yAxis?.type === 'category')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getSeriesXField(series?: SeriesWithGroupingHint) {
|
|
406
|
+
return getEncodeField(series?.encode?.x)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getSeriesYField(series?: SeriesWithGroupingHint) {
|
|
410
|
+
return getEncodeField(series?.encode?.y) ?? getEncodeField(series?.encode?.value)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function getEncodeField(value: unknown): string | undefined {
|
|
414
|
+
if (typeof value === 'string') return value
|
|
415
|
+
if (Array.isArray(value)) return value.find(entry => typeof entry === 'string')
|
|
416
|
+
return undefined
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function distinctValues(rows: Record<string, any>[], field: string) {
|
|
420
|
+
let values: unknown[] = []
|
|
421
|
+
let seen = new Set<string>()
|
|
422
|
+
for (let row of rows) {
|
|
423
|
+
let value = row?.[field]
|
|
424
|
+
let key = valueKey(value)
|
|
425
|
+
if (seen.has(key)) continue
|
|
426
|
+
seen.add(key)
|
|
427
|
+
values.push(value)
|
|
428
|
+
}
|
|
429
|
+
return values
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function sortableValue(value: unknown, type: 'date' | 'number') {
|
|
433
|
+
if (type === 'number') {
|
|
434
|
+
let parsed = Number(value)
|
|
435
|
+
return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY
|
|
436
|
+
}
|
|
437
|
+
let timestamp = value instanceof Date ? value.getTime() : Date.parse(String(value ?? ''))
|
|
438
|
+
return Number.isFinite(timestamp) ? timestamp : Number.POSITIVE_INFINITY
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function ordinalSortValue(value: unknown, timeOrdinal: string) {
|
|
442
|
+
let numeric = Number(value)
|
|
443
|
+
if (!Number.isFinite(numeric)) return Number.POSITIVE_INFINITY
|
|
444
|
+
|
|
445
|
+
if (timeOrdinal === 'dow_1m') return numeric >= 1 && numeric <= 7 ? numeric : Number.POSITIVE_INFINITY
|
|
446
|
+
|
|
447
|
+
if (timeOrdinal === 'dow_1s') {
|
|
448
|
+
if (numeric < 1 || numeric > 7) return Number.POSITIVE_INFINITY
|
|
449
|
+
return numeric === 1 ? 7 : numeric - 1
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (timeOrdinal === 'dow_0s') {
|
|
453
|
+
if (numeric < 0 || numeric > 6) return Number.POSITIVE_INFINITY
|
|
454
|
+
return numeric === 0 ? 7 : numeric
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (timeOrdinal === 'month_of_year') return numeric >= 1 && numeric <= 12 ? numeric : Number.POSITIVE_INFINITY
|
|
458
|
+
if (timeOrdinal === 'quarter_of_year') return numeric >= 1 && numeric <= 4 ? numeric : Number.POSITIVE_INFINITY
|
|
459
|
+
|
|
460
|
+
return numeric
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function cartesianValues(valueLists: unknown[][]) {
|
|
464
|
+
if (valueLists.length === 0) return [[]] as unknown[][]
|
|
465
|
+
|
|
466
|
+
return valueLists.reduce<unknown[][]>(
|
|
467
|
+
(acc, values) => {
|
|
468
|
+
let next: unknown[][] = []
|
|
469
|
+
for (let prefix of acc) {
|
|
470
|
+
for (let value of values) next.push([...prefix, value])
|
|
471
|
+
}
|
|
472
|
+
return next
|
|
473
|
+
},
|
|
474
|
+
[[]],
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function compositeKey(values: unknown[]) {
|
|
479
|
+
return values.map(value => valueKey(value)).join('|')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function valueKey(value: unknown) {
|
|
483
|
+
return JSON.stringify(value ?? null)
|
|
484
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type {Field} from './types.ts'
|
|
2
|
+
|
|
3
|
+
export type SummaryMetric = 'min' | 'max' | 'median' | 'mean' | 'sum' | 'count' | 'countDistinct'
|
|
4
|
+
|
|
5
|
+
export type ColumnUnitSummary = Partial<Record<SummaryMetric, number>>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Summarize a field using only the requested metrics.
|
|
9
|
+
*/
|
|
10
|
+
export function summarizeColumn(rows: Record<string, unknown>[], field: Field, metrics: SummaryMetric[] = []): ColumnUnitSummary {
|
|
11
|
+
if (!Array.isArray(rows) || rows.length === 0 || metrics.length === 0 || !field?.name) return {}
|
|
12
|
+
|
|
13
|
+
let requested = new Set(metrics)
|
|
14
|
+
let result: ColumnUnitSummary = {}
|
|
15
|
+
let values = rows.map(row => row?.[field.name])
|
|
16
|
+
|
|
17
|
+
if (requested.has('count')) result.count = rows.length
|
|
18
|
+
|
|
19
|
+
if (requested.has('countDistinct')) {
|
|
20
|
+
let distinct = new Set(values.filter(value => value !== undefined && value !== null).map(value => String(value)))
|
|
21
|
+
result.countDistinct = distinct.size
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let needsNumeric = ['min', 'max', 'median', 'mean', 'sum'].some(metric => requested.has(metric as SummaryMetric))
|
|
25
|
+
let isNumeric = String(field.type || '').toLowerCase() === 'number'
|
|
26
|
+
if (!isNumeric || !needsNumeric) return result
|
|
27
|
+
|
|
28
|
+
let numericValues = values.map(value => (typeof value === 'number' ? value : Number(value))).filter(value => Number.isFinite(value))
|
|
29
|
+
if (!numericValues.length) return result
|
|
30
|
+
|
|
31
|
+
if (requested.has('sum') || requested.has('mean')) {
|
|
32
|
+
let total = 0
|
|
33
|
+
for (let value of numericValues) total += value
|
|
34
|
+
if (requested.has('sum')) result.sum = total
|
|
35
|
+
if (requested.has('mean')) result.mean = total / numericValues.length
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (requested.has('min')) {
|
|
39
|
+
let min = numericValues[0]
|
|
40
|
+
for (let value of numericValues) if (value < min) min = value
|
|
41
|
+
result.min = min
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (requested.has('max')) {
|
|
45
|
+
let max = numericValues[0]
|
|
46
|
+
for (let value of numericValues) if (value > max) max = value
|
|
47
|
+
result.max = max
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (requested.has('median')) {
|
|
51
|
+
let sorted = [...numericValues].sort((a, b) => a - b)
|
|
52
|
+
let midpoint = Math.floor(sorted.length / 2)
|
|
53
|
+
result.median = sorted.length % 2 ? sorted[midpoint] : (sorted[midpoint - 1] + sorted[midpoint]) / 2
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
}
|