@graphenedata/cli 0.0.15 → 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 (117) hide show
  1. package/README.md +138 -0
  2. package/dist/cli/bigQuery-I3F46SC6.js +75 -0
  3. package/dist/cli/bigQuery-I3F46SC6.js.map +7 -0
  4. package/dist/cli/chunk-OVWODUTJ.js +12849 -0
  5. package/dist/cli/chunk-OVWODUTJ.js.map +7 -0
  6. package/dist/cli/chunk-QAXEOZ43.js +53 -0
  7. package/dist/cli/chunk-QAXEOZ43.js.map +7 -0
  8. package/dist/cli/cli.js +234 -11197
  9. package/dist/cli/clickhouse-ZN5AN2UL.js +64 -0
  10. package/dist/cli/clickhouse-ZN5AN2UL.js.map +7 -0
  11. package/dist/cli/duckdb-IYBIO5KJ.js +87 -0
  12. package/dist/cli/duckdb-IYBIO5KJ.js.map +7 -0
  13. package/dist/cli/serve2-TNN5EROW.js +447 -0
  14. package/dist/cli/serve2-TNN5EROW.js.map +7 -0
  15. package/dist/cli/snowflake-MOQB5GA4.js +128 -0
  16. package/dist/cli/snowflake-MOQB5GA4.js.map +7 -0
  17. package/dist/index.d.ts +63 -0
  18. package/dist/lang/index.d.ts +63 -0
  19. package/dist/skills/graphene/SKILL.md +150 -96
  20. package/dist/skills/graphene/references/big-value.md +6 -41
  21. package/dist/skills/graphene/references/date-range.md +64 -0
  22. package/dist/skills/graphene/references/dropdown.md +3 -4
  23. package/dist/skills/graphene/references/echarts.md +162 -0
  24. package/dist/skills/graphene/references/gsql.md +55 -25
  25. package/dist/skills/graphene/references/model-gsql.md +72 -0
  26. package/dist/skills/graphene/references/table.md +13 -14
  27. package/dist/skills/graphene/references/text-input.md +2 -1
  28. package/dist/ui/app.css +239 -340
  29. package/dist/ui/component-utilities/dataShaping.ts +484 -0
  30. package/dist/ui/component-utilities/dataSummary.ts +57 -0
  31. package/dist/ui/component-utilities/enrich.ts +763 -0
  32. package/dist/ui/component-utilities/format.ts +177 -0
  33. package/dist/ui/component-utilities/inputUtils.ts +44 -8
  34. package/dist/ui/component-utilities/theme.ts +200 -0
  35. package/dist/ui/component-utilities/themeStores.ts +21 -8
  36. package/dist/ui/component-utilities/types.ts +70 -0
  37. package/dist/ui/components/AreaChart.svelte +57 -105
  38. package/dist/ui/components/BarChart.svelte +71 -129
  39. package/dist/ui/components/BigValue.svelte +24 -40
  40. package/dist/ui/components/Column.svelte +10 -18
  41. package/dist/ui/components/DateRange.svelte +54 -21
  42. package/dist/ui/components/Dropdown.svelte +47 -26
  43. package/dist/ui/components/DropdownOption.svelte +1 -2
  44. package/dist/ui/components/ECharts.svelte +181 -67
  45. package/dist/ui/components/InlineDelta.svelte +50 -31
  46. package/dist/ui/components/LineChart.svelte +54 -125
  47. package/dist/ui/components/PieChart.svelte +27 -37
  48. package/dist/ui/components/QueryLoad.svelte +77 -45
  49. package/dist/ui/components/Row.svelte +2 -1
  50. package/dist/ui/components/ScatterPlot.svelte +52 -0
  51. package/dist/ui/components/Skeleton.svelte +32 -0
  52. package/dist/ui/components/Table.svelte +3 -2
  53. package/dist/ui/components/TableGroupRow.svelte +28 -36
  54. package/dist/ui/components/TableHarness.svelte +32 -0
  55. package/dist/ui/components/TableHeader.svelte +34 -59
  56. package/dist/ui/components/TableRow.svelte +14 -38
  57. package/dist/ui/components/TableSubtotalRow.svelte +18 -21
  58. package/dist/ui/components/TableTotalRow.svelte +27 -37
  59. package/dist/ui/components/TextInput.svelte +13 -12
  60. package/dist/ui/components/Value.svelte +25 -0
  61. package/dist/ui/components/_Table.svelte +72 -70
  62. package/dist/ui/internal/ChartGallery.svelte +527 -0
  63. package/dist/ui/internal/ErrorDisplay.svelte +22 -97
  64. package/dist/ui/internal/LocalApp.svelte +80 -17
  65. package/dist/ui/internal/PageNavGroup.svelte +269 -0
  66. package/dist/ui/internal/Sidebar.svelte +178 -0
  67. package/dist/ui/internal/SidebarToggle.svelte +47 -0
  68. package/dist/ui/internal/StyleGallery.svelte +244 -0
  69. package/dist/ui/internal/clientCache.ts +2 -2
  70. package/dist/ui/internal/pageInputs.svelte.js +292 -0
  71. package/dist/ui/internal/queryEngine.ts +102 -117
  72. package/dist/ui/internal/runSocket.ts +32 -12
  73. package/dist/ui/internal/sidebar.svelte.js +18 -0
  74. package/dist/ui/internal/telemetry.ts +51 -16
  75. package/dist/ui/internal/types.d.ts +7 -0
  76. package/dist/ui/web.js +28 -11
  77. package/package.json +36 -38
  78. package/dist/skills/graphene/references/area-chart.md +0 -95
  79. package/dist/skills/graphene/references/bar-chart.md +0 -112
  80. package/dist/skills/graphene/references/line-chart.md +0 -108
  81. package/dist/skills/graphene/references/pie-chart.md +0 -29
  82. package/dist/skills/graphene/references/value-formats.md +0 -104
  83. package/dist/ui/component-utilities/autoFormatting.js +0 -280
  84. package/dist/ui/component-utilities/builtInFormats.js +0 -481
  85. package/dist/ui/component-utilities/chartContext.js +0 -12
  86. package/dist/ui/component-utilities/chartWindowDebug.js +0 -21
  87. package/dist/ui/component-utilities/checkInputs.js +0 -84
  88. package/dist/ui/component-utilities/convert.js +0 -15
  89. package/dist/ui/component-utilities/dateParsing.js +0 -56
  90. package/dist/ui/component-utilities/dropdownContext.ts +0 -1
  91. package/dist/ui/component-utilities/echarts.js +0 -252
  92. package/dist/ui/component-utilities/echartsThemes.js +0 -443
  93. package/dist/ui/component-utilities/formatTitle.js +0 -24
  94. package/dist/ui/component-utilities/formatting.js +0 -241
  95. package/dist/ui/component-utilities/getColumnExtents.js +0 -79
  96. package/dist/ui/component-utilities/getColumnSummary.js +0 -62
  97. package/dist/ui/component-utilities/getCompletedData.js +0 -122
  98. package/dist/ui/component-utilities/getDistinctCount.js +0 -7
  99. package/dist/ui/component-utilities/getDistinctValues.js +0 -15
  100. package/dist/ui/component-utilities/getSeriesConfig.js +0 -231
  101. package/dist/ui/component-utilities/getSortedData.js +0 -9
  102. package/dist/ui/component-utilities/getStackPercentages.js +0 -45
  103. package/dist/ui/component-utilities/getStackedData.js +0 -19
  104. package/dist/ui/component-utilities/getYAxisIndex.js +0 -15
  105. package/dist/ui/component-utilities/globalContexts.js +0 -1
  106. package/dist/ui/component-utilities/helpers/getCompletedData.helpers.js +0 -119
  107. package/dist/ui/component-utilities/replaceNulls.js +0 -16
  108. package/dist/ui/component-utilities/tableUtils.ts +0 -107
  109. package/dist/ui/component-utilities/tidyWithTypes.js +0 -9
  110. package/dist/ui/components/Area.svelte +0 -214
  111. package/dist/ui/components/Bar.svelte +0 -347
  112. package/dist/ui/components/Chart.svelte +0 -995
  113. package/dist/ui/components/Line.svelte +0 -227
  114. package/dist/ui/internal/NavSidebar.svelte +0 -396
  115. package/dist/ui/internal/theme.ts +0 -60
  116. package/dist/ui/public/inter-latin-ext.woff2 +0 -0
  117. package/dist/ui/public/inter-latin.woff2 +0 -0
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import {onMount} from 'svelte'
3
3
  import {toBoolean} from '../component-utilities/inputUtils'
4
+ import {captureInitial, getPageInputs} from '../internal/pageInputs.svelte.js'
4
5
 
5
6
  interface Props {
6
7
  name: string
@@ -27,13 +28,15 @@
27
28
  let mounted = false
28
29
  let queryKey = ''
29
30
  let queryHandler: ((res: {rows?: any[]; error?: any}) => void) | null = null
31
+ let pageInputs = getPageInputs()
32
+ let field = captureInitial(() => pageInputs.dateRange(name))
30
33
 
31
34
  let domainStart: string | null = $state(null)
32
35
  let domainEnd: string | null = $state(null)
33
36
 
34
37
  let currentStart: string | null = $state(null)
35
38
  let currentEnd: string | null = $state(null)
36
- let currentPreset: string = $state('')
39
+ let currentPreset: string | null = $state(null)
37
40
  let touched = false
38
41
 
39
42
  let hidePrint = $derived(toBoolean(hideDuringPrint))
@@ -46,16 +49,16 @@
46
49
 
47
50
  onMount(() => {
48
51
  mounted = true
49
- currentStart = normalizeInput(start)
50
- currentEnd = normalizeInput(end)
51
- if (defaultValue && presetList.includes(defaultValue)) {
52
- applyPreset(defaultValue, false)
53
- } else {
54
- updateParams()
55
- }
52
+ currentStart = field.hasExternalValue ? field.value.start : normalizeInput(start)
53
+ currentEnd = field.hasExternalValue ? field.value.end : normalizeInput(end)
54
+ currentPreset = inferPreset(currentStart, currentEnd)
55
+ if (field.hasExternalValue) updateParams()
56
+ else if (defaultValue && presetList.includes(defaultValue)) applyPreset(defaultValue, false)
57
+ else updateParams()
56
58
  refreshQuery()
57
59
  return () => {
58
60
  mounted = false
61
+ field.destroy()
59
62
  if (queryHandler) {
60
63
  window.$GRAPHENE?.unsubscribe?.(queryHandler)
61
64
  queryHandler = null
@@ -67,6 +70,12 @@
67
70
  refreshQuery()
68
71
  })
69
72
 
73
+ $effect(() => {
74
+ if (currentStart === field.value.start && currentEnd === field.value.end) return
75
+ if (!mounted) return
76
+ setRange(field.value.start, field.value.end, inferPreset(field.value.start, field.value.end), {persist: false})
77
+ })
78
+
70
79
  function refreshQuery() {
71
80
  if (!mounted) return
72
81
  let key = data && dates ? `${data}::${dates}` : ''
@@ -78,7 +87,7 @@
78
87
  queryKey = key
79
88
  if (!data || !dates) return
80
89
  let handler = (res: {rows?: any[]; error?: any}) => {
81
- if (res.error || !res.rows?.length) return
90
+ if (!res || res.error || !res.rows?.length) return
82
91
  let values = res.rows
83
92
  .map(row => normalizeInput(row[dates]))
84
93
  .filter((val): val is string => !!val)
@@ -86,13 +95,15 @@
86
95
  values.sort()
87
96
  domainStart = values[0]
88
97
  domainEnd = values[values.length - 1]
89
- if (!touched) {
98
+ if (field.hasExternalValue) {
99
+ currentPreset = inferPreset(currentStart, currentEnd)
100
+ } else if (!touched) {
90
101
  if (defaultValue && presetList.includes(defaultValue)) {
91
102
  applyPreset(defaultValue, false)
92
103
  } else {
93
104
  let startCandidate = currentStart ?? domainStart
94
105
  let endCandidate = currentEnd ?? (domainEnd ? addDaysString(domainEnd, 1) : null)
95
- setRange(startCandidate, endCandidate, currentPreset, false)
106
+ setRange(startCandidate, endCandidate, currentPreset, {markTouched: false, persist: true})
96
107
  }
97
108
  }
98
109
  }
@@ -149,27 +160,48 @@
149
160
  return copy
150
161
  }
151
162
 
152
- function setRange(startValue: string | null, endValue: string | null, preset: string, markTouched: boolean) {
163
+ // We only persist start/end in URL state, so the preset label is inferred by matching
164
+ // the current range against the configured preset definitions.
165
+ function inferPreset(startValue: string | null, endValue: string | null): string | null {
166
+ if (!startValue && !endValue) return null
167
+ let baseEnd = (() => {
168
+ if (endValue) {
169
+ let parsed = new Date(endValue)
170
+ if (!Number.isNaN(parsed.getTime())) return addDays(parsed, -1)
171
+ }
172
+ if (domainEnd) return new Date(domainEnd)
173
+ return new Date()
174
+ })()
175
+ if (Number.isNaN(baseEnd.getTime())) return null
176
+ for (let preset of presetList) {
177
+ let range = computePresetRange(preset, baseEnd)
178
+ let presetStart = range?.start ? formatDate(range.start) : null
179
+ let presetEnd = range?.end ? formatDate(range.end) : null
180
+ if (presetStart === startValue && presetEnd === endValue) return preset
181
+ }
182
+ return null
183
+ }
184
+
185
+ function setRange(startValue: string | null, endValue: string | null, preset: string | null, {markTouched = false, persist = true}: {markTouched?: boolean; persist?: boolean} = {}) {
153
186
  currentStart = startValue
154
187
  currentEnd = endValue
155
188
  currentPreset = preset
156
189
  if (markTouched) touched = true
157
- updateParams()
190
+ if (persist) updateParams()
158
191
  }
159
192
 
160
193
  function updateParams() {
161
- window.$GRAPHENE.updateParam(`${name}_start`, currentStart)
162
- window.$GRAPHENE.updateParam(`${name}_end`, currentEnd)
194
+ field.set({start: currentStart, end: currentEnd})
163
195
  }
164
196
 
165
197
  function onStartChange(event: Event) {
166
198
  let value = (event.currentTarget as HTMLInputElement).value || null
167
- setRange(value, currentEnd, '', true)
199
+ setRange(value, currentEnd, null, {markTouched: true, persist: true})
168
200
  }
169
201
 
170
202
  function onEndChange(event: Event) {
171
203
  let value = (event.currentTarget as HTMLInputElement).value || null
172
- setRange(currentStart, value, '', true)
204
+ setRange(currentStart, value, null, {markTouched: true, persist: true})
173
205
  }
174
206
 
175
207
  function applyPreset(preset: string, markTouched = true) {
@@ -183,7 +215,7 @@
183
215
  if (!range) return
184
216
  let startVal = range.start ? formatDate(range.start) : null
185
217
  let endVal = range.end ? formatDate(range.end) : null
186
- setRange(startVal, endVal, preset, markTouched)
218
+ setRange(startVal, endVal, preset, {markTouched, persist: true})
187
219
  }
188
220
 
189
221
  function computePresetRange(preset: string, baseEndInclusive: Date): {start: Date | null; end: Date | null} | null {
@@ -257,7 +289,7 @@
257
289
  function onPresetChange(event: Event) {
258
290
  let value = (event.currentTarget as HTMLSelectElement).value
259
291
  if (!value) {
260
- currentPreset = ''
292
+ currentPreset = null
261
293
  touched = true
262
294
  return
263
295
  }
@@ -300,6 +332,7 @@
300
332
  }
301
333
  }
302
334
  .input-label {
335
+ font-family: var(--font-ui);
303
336
  font-size: 12px;
304
337
  font-weight: 600;
305
338
  color: var(--input-label-color, #374151);
@@ -323,7 +356,7 @@
323
356
  border: 1px solid rgba(107, 114, 128, 0.4);
324
357
  font-size: 14px;
325
358
  min-width: 150px;
326
- font-family: var(--ui-font-family);
359
+ font-family: var(--font-ui);
327
360
  font-synthesis: none;
328
361
  }
329
362
  .preset-select {
@@ -332,7 +365,7 @@
332
365
  border-radius: 6px;
333
366
  border: 1px solid rgba(107, 114, 128, 0.4);
334
367
  font-size: 13px;
335
- font-family: var(--ui-font-family);
368
+ font-family: var(--font-ui);
336
369
  font-synthesis: none;
337
370
  }
338
371
  </style>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import {onMount, setContext, tick, type Snippet} from 'svelte'
3
- import {DROPDOWN_CONTEXT} from '../component-utilities/dropdownContext'
4
3
  import {ensureArray, toBoolean} from '../component-utilities/inputUtils'
4
+ import {captureInitial, getPageInputs} from '../internal/pageInputs.svelte.js'
5
5
 
6
6
  interface Option {
7
7
  value: any
@@ -42,6 +42,8 @@
42
42
  let touched = false
43
43
  let queryHandler: ((res: {rows?: any[]; error?: any}) => void) | null = null
44
44
  let queryKey = ''
45
+ let pageInputs = getPageInputs()
46
+ let field = captureInitial(() => pageInputs.dropdown(name, toBoolean(multiple)))
45
47
 
46
48
  let isOpen = $state(false)
47
49
  let searchTerm = $state('')
@@ -59,7 +61,7 @@
59
61
  syncSelection(false)
60
62
  }
61
63
  }
62
- setContext(DROPDOWN_CONTEXT, registerOption)
64
+ setContext('dropdown', registerOption)
63
65
 
64
66
  const optionKey = (val: any): string => {
65
67
  if (val === null) return 'null'
@@ -104,6 +106,12 @@
104
106
  if (isOpen) activeIndex = ensureActiveIndex(activeIndex, filteredOptions)
105
107
  })
106
108
 
109
+ $effect(() => {
110
+ if (!sameSelection(selection, field.value)) {
111
+ setSelection(field.value, {persist: false})
112
+ }
113
+ })
114
+
107
115
  function setupQuery() {
108
116
  if (!mounted) return
109
117
  let key = data ? `${data}::${value}::${resolvedLabelField || ''}` : ''
@@ -121,7 +129,7 @@
121
129
  let columns = [value]
122
130
  if (resolvedLabelField && resolvedLabelField !== value) columns.push(resolvedLabelField)
123
131
  let handler = (res: {rows?: any[]; error?: any}) => {
124
- if (res.error) return
132
+ if (!res || res.error) return
125
133
  if (!res.rows) return
126
134
  queryOptions = res.rows.map(row => ({
127
135
  value: row[value],
@@ -273,12 +281,12 @@
273
281
  let exists = selection.some(val => optionKey(val) === key)
274
282
  if (exists) {
275
283
  let next = selection.filter(val => optionKey(val) !== key)
276
- setSelection(next, true)
284
+ setSelection(next, {fromUser: true, persist: true})
277
285
  } else {
278
- setSelection([...selection, opt.value], true)
286
+ setSelection([...selection, opt.value], {fromUser: true, persist: true})
279
287
  }
280
288
  } else {
281
- setSelection([opt.value], true)
289
+ setSelection([opt.value], {fromUser: true, persist: true})
282
290
  closeMenu(fromKeyboard)
283
291
  }
284
292
  }
@@ -295,8 +303,11 @@
295
303
 
296
304
  onMount(() => {
297
305
  mounted = true
298
- let defaults = ensureArray(defaultValue)
299
- if (!hasNoDefault && defaults.length) setSelection(defaults, false)
306
+ if (field.hasExternalValue) setSelection(field.value, {persist: false})
307
+ else {
308
+ let defaults = ensureArray(defaultValue)
309
+ if (!hasNoDefault && defaults.length) setSelection(defaults, {persist: true})
310
+ }
300
311
  syncSelection(false)
301
312
  setupQuery()
302
313
  if (typeof document !== 'undefined') {
@@ -305,6 +316,7 @@
305
316
  }
306
317
  return () => {
307
318
  mounted = false
319
+ field.destroy()
308
320
  if (queryHandler) {
309
321
  window.$GRAPHENE?.unsubscribe?.(queryHandler)
310
322
  queryHandler = null
@@ -327,15 +339,21 @@
327
339
  })
328
340
 
329
341
  function syncSelection(fromUser: boolean) {
342
+ // Reconcile persisted selection with the current option set, while keeping external values
343
+ // authoritative and only applying defaults/select-all before the user has interacted.
330
344
  let opts = availableOptions
331
345
  if (!opts.length) {
332
- if (selection.length) updateInputPayload(selection)
346
+ // Keep the bound param initialized even before options load.
347
+ // This prevents $param references from throwing "Missing param" on first render.
348
+ updateInputPayload(selection)
333
349
  return
334
350
  }
335
351
  let nextSelection = selection.filter(val => valueMap.has(optionKey(val)))
336
352
  if (!fromUser) {
337
353
  let defaults = ensureArray(defaultValue)
338
- if (multi && selectAllDefault) {
354
+ if (field.hasExternalValue) {
355
+ nextSelection = nextSelection
356
+ } else if (multi && selectAllDefault) {
339
357
  nextSelection = opts.map(o => o.value)
340
358
  } else if (!touched) {
341
359
  if (defaults.length && !hasNoDefault) {
@@ -345,34 +363,36 @@
345
363
  }
346
364
  }
347
365
  }
348
- setSelection(nextSelection, fromUser)
366
+ setSelection(nextSelection, {fromUser, persist: true})
367
+ }
368
+
369
+ function sameSelection(left: any[], right: any[]) {
370
+ let leftKeys = left.map(optionKey)
371
+ let rightKeys = right.map(optionKey)
372
+ return leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
349
373
  }
350
374
 
351
- function setSelection(values: any[], fromUser: boolean) {
352
- let keys = values.map(optionKey)
353
- let existingKeys = selection.map(optionKey)
354
- let changed = keys.length !== existingKeys.length || keys.some((k, idx) => k !== existingKeys[idx])
355
- if (!changed) {
356
- if (!fromUser) updateInputPayload(selection)
375
+ function setSelection(values: any[], {fromUser = false, persist = true}: {fromUser?: boolean; persist?: boolean} = {}) {
376
+ if (sameSelection(values, selection)) {
377
+ if (persist && !fromUser) updateInputPayload(selection)
357
378
  return
358
379
  }
359
380
  selection = values
360
381
  if (fromUser) touched = true
361
- updateInputPayload(selection)
382
+ if (persist) updateInputPayload(selection)
362
383
  }
363
384
 
364
385
  function updateInputPayload(values: any[]) {
365
- let paramValue = values.length ? values[0] : null
366
- window.$GRAPHENE.updateParam(name, paramValue)
386
+ field.set(values)
367
387
  }
368
388
 
369
389
  function selectAll() {
370
390
  if (!multi) return
371
- setSelection(availableOptions.map(opt => opt.value), true)
391
+ setSelection(availableOptions.map(opt => opt.value), {fromUser: true, persist: true})
372
392
  }
373
393
 
374
394
  function clearSelection() {
375
- setSelection([], true)
395
+ setSelection([], {fromUser: true, persist: true})
376
396
  }
377
397
 
378
398
  let elementId = $derived(`dropdown-${name}`)
@@ -554,6 +574,7 @@
554
574
  }
555
575
  }
556
576
  .input-label {
577
+ font-family: var(--font-ui);
557
578
  font-size: 12px;
558
579
  font-weight: 600;
559
580
  color: var(--input-label-color, #374151);
@@ -583,7 +604,7 @@
583
604
  background: #ffffff;
584
605
  color: #1f2937;
585
606
  font-size: 14px;
586
- font-family: var(--ui-font-family);
607
+ font-family: var(--font-ui);
587
608
  font-synthesis: none;
588
609
  cursor: pointer;
589
610
  transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
@@ -660,7 +681,7 @@
660
681
  padding: 6px 10px;
661
682
  font-size: 13px;
662
683
  line-height: 16px;
663
- font-family: var(--ui-font-family);
684
+ font-family: var(--font-ui);
664
685
  font-synthesis: none;
665
686
  background: #f9fafb;
666
687
  color: inherit;
@@ -688,7 +709,7 @@
688
709
  cursor: pointer;
689
710
  font-size: 14px;
690
711
  line-height: 18px;
691
- font-family: var(--ui-font-family);
712
+ font-family: var(--font-ui);
692
713
  font-synthesis: none;
693
714
  transition: background 100ms ease, color 100ms ease;
694
715
  color: #1f2937;
@@ -755,7 +776,7 @@
755
776
  background: none;
756
777
  color: #2563eb;
757
778
  font-size: 13px;
758
- font-family: var(--ui-font-family);
779
+ font-family: var(--font-ui);
759
780
  font-synthesis: none;
760
781
  cursor: pointer;
761
782
  padding: 4px 0;
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import {getContext, onMount} from 'svelte'
3
- import {DROPDOWN_CONTEXT} from '../component-utilities/dropdownContext'
4
3
 
5
4
  interface Props {
6
5
  value: any
@@ -10,7 +9,7 @@
10
9
  let {value, valueLabel = undefined}: Props = $props()
11
10
 
12
11
  type RegisterFn = ((option: {value: any; label: string}) => (() => void) | void) | undefined
13
- const register = getContext<RegisterFn>(DROPDOWN_CONTEXT)
12
+ const register = getContext<RegisterFn>('dropdown')
14
13
 
15
14
  let unregister: (() => void) | void
16
15
 
@@ -1,91 +1,205 @@
1
1
  <script lang="ts">
2
- import echarts from '../component-utilities/echarts.js'
3
- import {getThemeStores} from '../component-utilities/themeStores'
2
+ import {init} from 'echarts'
3
+ import {onDestroy, onMount, untrack} from 'svelte'
4
+ import ErrorDisplay from '../internal/ErrorDisplay.svelte'
5
+ import {componentLogger, logExtraProps} from '../internal/telemetry.ts'
6
+ import {enrich, horizontalBarCount} from '../component-utilities/enrich.ts'
7
+ import type {EChartsConfig, NormalConfig, QueryResult} from '../component-utilities/types.ts'
8
+ import '../component-utilities/theme.ts'
9
+ import Skeleton from './Skeleton.svelte'
4
10
 
5
11
  interface Props {
6
- config: any
12
+ config: EChartsConfig
13
+ data: string | QueryResult
7
14
  height?: string | number
8
15
  width?: string | number
9
- data: any
10
- queryID?: any
11
16
  renderer?: 'canvas' | 'svg'
12
- echartsOptions?: any
13
- seriesOptions?: any
14
- seriesColors?: any
15
- connectGroup?: string
16
- xAxisLabelOverflow?: 'truncate' | 'break'
17
- showAllXAxisLabels?: boolean
18
- swapXY?: boolean
19
- chartTitle?: string
20
- onclick?: (params: any) => void
17
+ componentId?: string
21
18
  }
22
19
 
23
- const {activeAppearance} = getThemeStores()
24
-
25
20
  let {
26
- config, height = '240px', width = '100%', data, queryID = undefined, renderer = undefined,
27
- echartsOptions = undefined, seriesOptions = undefined, seriesColors = undefined,
28
- connectGroup = undefined, xAxisLabelOverflow = undefined, showAllXAxisLabels = undefined,
29
- swapXY = undefined, chartTitle = undefined, onclick = undefined,
30
- }: Props = $props()
31
-
32
- const isBrowser = typeof window !== 'undefined'
33
-
34
- const toDimension = (dimension: string | number | undefined, fallback: string) => {
35
- if (typeof dimension === 'number') return `${dimension}px`
36
- if (!dimension) return fallback
37
- return dimension
21
+ config = {},
22
+ data,
23
+ height = undefined,
24
+ width = '100%',
25
+ renderer = 'svg',
26
+ componentId = undefined,
27
+ ...extraProps
28
+ }: Props & Record<string, unknown> = $props()
29
+
30
+ config ||= {}
31
+
32
+ let queryFieldsForLogger = untrack(() => typeof data == 'string' ? queryFields(config) : {})
33
+ let chartLogger = untrack(() => componentLogger(componentId || 'ECharts', componentId ? {} : {data: typeof data == 'string' ? data : undefined, ...queryFieldsForLogger}))
34
+ let displayId = untrack(() => componentId || chartLogger.id)
35
+ untrack(() => logExtraProps(chartLogger, 'ECharts', extraProps))
36
+
37
+ // not state, because we don't want `$effect` to run when they change
38
+ let node: HTMLDivElement | null = null
39
+ let chart: any
40
+ let resizeObserver: ResizeObserver | null = null
41
+
42
+ // Use `raw` because data can be big, and there's little upside to making it reactive
43
+ let loaded = $state.raw<QueryResult | null>(null)
44
+ let chartError: Error | null = $state(null)
45
+ let mountedComponentId: string | null = $state(displayId)
46
+ let chartTitle: string | undefined = $state(undefined)
47
+ let chartSizeStyle: string = $state(calculateChartSize())
48
+
49
+ function handleResults (res: QueryResult) {
50
+ chartError = null
51
+ loaded = res
52
+ if (res?.error) chartLogger.error(res.error, {...res.error, componentId: displayId})
53
+ }
54
+
55
+ // If `data` is just a string, kick off a query to fetch the data.
56
+ // This maybe could be an effect, but we'd have to ensure we don't double-subscribe.
57
+ onMount(() => {
58
+ resizeObserver = new ResizeObserver(() => chart?.resize())
59
+ if (node) resizeObserver.observe(node)
60
+
61
+ if (typeof data == 'string') {
62
+ try {
63
+ mountedComponentId = window.$GRAPHENE.query(data, queryFieldsForLogger, handleResults, displayId)
64
+ } catch (error) {
65
+ chartError = error instanceof Error ? error : new Error(String(error))
66
+ }
67
+ } else {
68
+ loaded = data
69
+ }
70
+ })
71
+
72
+ onDestroy(() => {
73
+ resizeObserver?.disconnect()
74
+ resizeObserver = null
75
+ window.$GRAPHENE.unsubscribe(handleResults)
76
+ destroyChart()
77
+ })
78
+
79
+ $effect(() => {
80
+ if (chartError) return
81
+
82
+ if (!loaded || loaded.error || loaded.rows.length == 0) {
83
+ destroyChart()
84
+ return
85
+ }
86
+
87
+ if (!chart) {
88
+ chart = init(node, 'graphene-theme', {renderer})
89
+ chart.on('legendselectchanged', renderChart)
90
+ }
91
+
92
+ try {
93
+ window.$GRAPHENE?.renderStart?.(`chart:${chart.id}`)
94
+ renderChart()
95
+ chartError = null
96
+ window.$GRAPHENE?.renderComplete?.(`chart:${chart.id}`)
97
+ } catch (error) {
98
+ console.error('Chart failed to render', error)
99
+ chartError = error instanceof Error ? error : new Error(String(error))
100
+ chartLogger.error(chartError, {componentId: displayId})
101
+ window.$GRAPHENE?.renderComplete?.(`chart:${chart.id}`)
102
+ destroyChart()
103
+ }
104
+ })
105
+
106
+ // Build a fresh enriched option each render so legend-driven stack rounding
107
+ // always reflects the currently visible series.
108
+ function renderChart() {
109
+ if (!chart || !loaded) return
110
+
111
+ // clone config, since enriching mutates the config, and mutating a prop is weird
112
+ // structuredClone doesn't like proxies, so use state.snapshot
113
+ let cloned = structuredClone($state.snapshot(config)) as EChartsConfig
114
+ let rows = loaded.rows
115
+ let fields = loaded.fields || []
116
+ cloned.legendSelection = chart.getOption()?.legend?.[0]?.selected
117
+ let enriched = enrich(cloned, rows, fields)
118
+
119
+ chartTitle = enriched.title.find(t => t?.text)?.text
120
+ chartSizeStyle = calculateChartSize(enriched, rows, fields)
121
+ chart.setOption({...enriched, animation: false, animationDuration: 0, animationDurationUpdate: 0}, true)
122
+ }
123
+
124
+ function destroyChart() {
125
+ if (!chart) return
126
+ chart.off('legendselectchanged', renderChart)
127
+ chart.dispose()
128
+ chart = null
38
129
  }
130
+
131
+ function queryFields(config: EChartsConfig) {
132
+ let fields: Record<string, string[]> = {}
133
+ let series = Array.isArray(config.series) ? config.series : [config.series]
134
+ let entries = series.flatMap(s => Object.entries(s?.encode || {}))
135
+
136
+ for (let [attr, col] of entries) {
137
+ let value = queryableEncodeValue(attr, col)
138
+ if (!value) continue
139
+ fields[attr] ||= []
140
+ if (!fields[attr].includes(value)) fields[attr].push(value)
141
+ }
142
+
143
+ return fields
144
+ }
145
+
146
+ function queryableEncodeValue(attr: string, value: unknown) {
147
+ if (typeof value !== 'string') return undefined
148
+ let trimmed = value.trim()
149
+ if (!trimmed) return undefined
150
+
151
+ // sort supports "column" or "column asc|desc". We only need the field in SELECT.
152
+ if (attr === 'sort') return trimmed.split(/\s+/)[0]
153
+ return trimmed
154
+ }
155
+
156
+ function calculateChartSize(config?: NormalConfig, rows: Record<string, any>[] = [], fields: any[] = []) {
157
+ let threshold = 8 // over this many bars, start to grow
158
+ let resolvedHeight: string | number = height ?? '320px'
159
+ let barSeries = config?.series.find(s => s.type == 'bar')
160
+ let categoricalY = config?.yAxis[0]?.type == 'category'
161
+
162
+ if (config && barSeries && categoricalY) {
163
+ let distinctX = horizontalBarCount(config, rows, fields)
164
+ if (distinctX > threshold) resolvedHeight = 320 * Math.max(1, distinctX / threshold)
165
+ }
166
+
167
+ return `height:${toDim(resolvedHeight)};width:${toDim(width ?? '100%')};`
168
+ }
169
+
170
+ function toDim(dim: string | number) {
171
+ let t = typeof dim
172
+ if (t == 'number' || (t == 'string' && (dim as string).match(/^\d+$/))) return `${dim}px`
173
+ return dim
174
+ }
175
+
39
176
  </script>
40
177
 
41
- <div class="echarts-container">
42
- {#if !isBrowser}
43
- <div class="echarts-loading" style={`height:${toDimension(height, '240px')}`}>Loading…</div>
44
- {:else}
45
- <div
46
- class="echarts-chart"
47
- data-chart-title={chartTitle ?? undefined}
48
- data-query-id={queryID}
49
- style={`height:${toDimension(height, '240px')};width:${toDimension(width, '100%')}`}
50
- use:echarts={{
51
- config,
52
- data,
53
- echartsOptions,
54
- seriesOptions,
55
- onclick,
56
- renderer,
57
- connectGroup,
58
- xAxisLabelOverflow,
59
- showAllXAxisLabels,
60
- swapXY,
61
- seriesColors,
62
- theme: $activeAppearance,
63
- }}
64
- ></div>
178
+ <div class="echarts" bind:this={node} style={chartSizeStyle} data-component-id={mountedComponentId} data-chart-title={chartTitle}>
179
+ {#if loaded?.error || chartError}
180
+ <ErrorDisplay error={loaded?.error || chartError} />
181
+ {:else if !loaded}
182
+ <Skeleton />
183
+ {:else if loaded.rows.length == 0}
184
+ <div class="empty-chart" role="note">Dataset is empty - query ran successfully, but no data was returned from the database</div>
65
185
  {/if}
66
186
  </div>
67
187
 
68
188
  <style>
69
- .echarts-container {
189
+ .echarts {
70
190
  position: relative;
71
- margin: 8px 0;
72
191
  }
73
192
 
74
- .echarts-chart {
193
+ .empty-chart {
75
194
  width: 100%;
76
- overflow: visible;
77
- user-select: none;
78
- }
79
-
80
- .echarts-loading {
81
- width: 100%;
82
- display: flex;
83
- align-items: center;
84
- justify-content: center;
85
- border: 1px solid rgba(209, 213, 219, 0.8);
195
+ height: 100%;
196
+ padding: 12px;
197
+ margin: 8px 0;
198
+ border: 1px dashed rgba(107, 114, 128, 0.6);
86
199
  border-radius: 4px;
87
- background: rgba(249, 250, 251, 0.6);
88
- color: rgba(107, 114, 128, 0.95);
89
200
  font-size: 12px;
201
+ color: rgba(75, 85, 99, 0.9);
202
+ text-align: center;
203
+ background: rgba(243, 244, 246, 0.6);
90
204
  }
91
205
  </style>