@graphenedata/cli 0.0.16 → 0.0.18

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 (38) hide show
  1. package/README.md +65 -29
  2. package/dist/cli/{bigQuery-I3F46SC6.js → bigQuery-YIWXZPY6.js} +2 -2
  3. package/dist/cli/{chunk-QAXEOZ43.js → chunk-SQVXTHE5.js} +2 -2
  4. package/dist/cli/chunk-SQVXTHE5.js.map +7 -0
  5. package/dist/cli/{chunk-OVWODUTJ.js → chunk-UTV3ERGI.js} +279 -150
  6. package/dist/cli/chunk-UTV3ERGI.js.map +7 -0
  7. package/dist/cli/cli.js +33 -6
  8. package/dist/cli/{clickhouse-ZN5AN2UL.js → clickhouse-S3BJSKND.js} +3 -2
  9. package/dist/cli/clickhouse-S3BJSKND.js.map +7 -0
  10. package/dist/cli/{duckdb-IYBIO5KJ.js → duckdb-V6PJEA7H.js} +2 -2
  11. package/dist/cli/{serve2-TNN5EROW.js → serve2-CGQSM7TD.js} +7 -6
  12. package/dist/cli/{serve2-TNN5EROW.js.map → serve2-CGQSM7TD.js.map} +2 -2
  13. package/dist/cli/{snowflake-MOQB5GA4.js → snowflake-HVSTYBLB.js} +2 -2
  14. package/dist/index.d.ts +4 -4
  15. package/dist/lang/index.d.ts +4 -4
  16. package/dist/skills/graphene/SKILL.md +10 -3
  17. package/dist/skills/graphene/references/gsql.md +26 -23
  18. package/dist/skills/graphene/references/model-gsql.md +19 -21
  19. package/dist/ui/component-utilities/enrich.ts +88 -23
  20. package/dist/ui/component-utilities/format.ts +36 -21
  21. package/dist/ui/component-utilities/theme.ts +0 -1
  22. package/dist/ui/components/AreaChart.svelte +1 -1
  23. package/dist/ui/components/BarChart.svelte +1 -1
  24. package/dist/ui/components/LineChart.svelte +1 -1
  25. package/dist/ui/internal/LocalApp.svelte +29 -27
  26. package/dist/ui/internal/PageNavGroup.svelte +2 -2
  27. package/dist/ui/internal/Sidebar.svelte +7 -7
  28. package/dist/ui/internal/queryEngine.ts +13 -15
  29. package/dist/ui/internal/runSocket.ts +2 -5
  30. package/dist/ui/internal/sidebar.svelte.js +11 -1
  31. package/dist/ui/web.js +4 -2
  32. package/package.json +5 -1
  33. package/dist/cli/chunk-OVWODUTJ.js.map +0 -7
  34. package/dist/cli/chunk-QAXEOZ43.js.map +0 -7
  35. package/dist/cli/clickhouse-ZN5AN2UL.js.map +0 -7
  36. /package/dist/cli/{bigQuery-I3F46SC6.js.map → bigQuery-YIWXZPY6.js.map} +0 -0
  37. /package/dist/cli/{duckdb-IYBIO5KJ.js.map → duckdb-V6PJEA7H.js.map} +0 -0
  38. /package/dist/cli/{snowflake-MOQB5GA4.js.map → snowflake-HVSTYBLB.js.map} +0 -0
@@ -1,29 +1,27 @@
1
1
  # Modeling GSQL
2
2
 
3
- Conventions and patterns for writing production-quality `.gsql` semantic models. For GSQL syntax reference, see `references/gsql.md`.
3
+ Conventions and patterns for writing production-quality `.gsql` semantic models. Make sure you've read `references/gsql.md` before proceeding.
4
4
 
5
- ## Quick start: New table workflow
5
+ ## New table workflow
6
6
 
7
- 1. Generate a `.gsql` file from the base schema: `npx graphene schema {DB.SCHEMA.TABLE} > tables/{snake_case_table_name}.gsql`
7
+ 1. Generate a plain `.gsql` file first: `graphene schema {DB.SCHEMA.TABLE} > tables/{snake_case_table_name}.gsql`
8
8
  2. Add table and grain descriptions at the top of the file. If given a dbt project, look up the table's definition, lineage, and related metadata so that you have the full picture.
9
9
  3. Add join relationships.
10
+ - If no join documentation is provided, make an educated guess from PK/FK names.
11
+ - Use `graphene run <query>` to confirm the join works as expected: keys match, row counts are sane, and there is no fan-out.
12
+ - Model joins from boths sides (ie. add the join to each respective `table` statement)
10
13
 
11
- - If no join documentation is provided, make an educated guess from PK/FK names.
12
- - Use `npx graphene run <query>` to confirm the join works as expected: keys match, row counts are sane, and there is no fan-out.
14
+ 4. Add dimensions and measures **ONLY** if a semantic model to migrate from has been provided.
15
+ - Compile-verify: `npx graphene compile "from TABLE select dimension1, dimension2, measure1, measure2"`
13
16
 
14
- 4. Add dimensions and measures only if a semantic model to migrate from has been provided.
17
+ 5. Add descriptions to columns, dimensions, and measures via comments.
18
+ - Do not add a description if it already obvious from the name. For example, skip `is_debooked_opportunity BOOLEAN -- Whether the opportunity has been debooked`.
19
+ - Use example values for categorical columns: `graphene run "from TABLE select distinct col limit 10"`.
20
+ - Add synonyms, but only if provided. **DO NOT** guess them.
21
+ - Descriptions can be inlined or placed as a block comment on the line above.
15
22
 
16
- - Do not invent dimensions or measures. Only codify known query patterns.
17
- - Compile-verify: `npx graphene compile "from TABLE select dimension1, dimension2, measure1, measure2"`
18
-
19
- 5. Add descriptions via comments.
20
-
21
- - Only add a comment when the column name is not self-explanatory. For example, skip `is_debooked_opportunity BOOLEAN -- Whether the opportunity has been debooked`.
22
- - Use example values for categorical columns: `npx graphene run "from TABLE select distinct col limit 10"`.
23
- - Add synonyms only if provided. Do not guess them.
24
- - Descriptions can be inlined or placed as a block comment on the line above.
25
-
26
- 6. Add GSQL metadata annotations where applicable eg. `#ratio`, `#pct`, `#timeGrain=day`, etc.
23
+ 6. Add GSQL metadata annotations where applicable eg. `#ratio`, `#pct`, `#timeGrain=day`, etc.
24
+ - Use only annotations that Graphene recognizes (see `references/gsql.md`)
27
25
 
28
26
  ## File structure
29
27
 
@@ -36,7 +34,7 @@ table DATABASE.SCHEMA.TABLE_NAME (
36
34
 
37
35
  /* Sub-section headers as needed, to group up fields if there are many columns */
38
36
 
39
- column_name TYPE -- Description/metadata if necessary
37
+ column_name TYPE -- A description and a #annotation
40
38
 
41
39
  -- OR, descriptions/metadata for a field/dimension/measure can be on the lines above it
42
40
  -- as long as there is NOT an empty line separating
@@ -48,11 +46,11 @@ table DATABASE.SCHEMA.TABLE_NAME (
48
46
 
49
47
  /* Dimensions */
50
48
 
51
- dim_name: expression -- Description/metadata if necessary
49
+ dim_name: expression #annotationWithoutDescription
52
50
 
53
51
  /* Measures */
54
52
 
55
- measure_name: aggregate_expression -- Description/metadata if necessary
53
+ measure_name: aggregate_expression
56
54
  )
57
55
 
58
56
  /* Example queries */ -- Only if correct query usage patterns are not obvious
@@ -68,5 +66,5 @@ Section headers use `/* Header */` style. Section headers need a full newline fo
68
66
 
69
67
  Always verify after changes. A parse error in any `.gsql` file prevents all tables from loading. If you see "Unknown table" errors everywhere, check for syntax errors in recently modified files.
70
68
 
71
- You can syntax check the whole project with `npx graphene check`.
69
+ You can syntax check the whole project with `graphene check`.
72
70
 
@@ -284,6 +284,20 @@ function inferAxesFromEncodedFields(config: NormalConfig, fields: Field[], rows:
284
284
 
285
285
  config.yAxis[axisIndex] = {...inferred, ...axis, axisLabel: {...inferred.axisLabel, ...axis.axisLabel}, axisPointer: {...inferred.axisPointer, ...axis.axisPointer}}
286
286
  }
287
+
288
+ // Ordinal x axes already use labels to communicate the bucket boundaries, so
289
+ // the y-axis line reads like an extra vertical grid line at the left edge.
290
+ // Hide the paired y-axis line unless the caller explicitly configured it.
291
+ for (let [axisIndex, axis] of config.xAxis.entries()) {
292
+ if (!axis?.field?.metadata?.timeOrdinal && axis?.field?.metadata?.timeGrain !== 'year') continue
293
+
294
+ let yAxisIndexes = config.series.filter(entry => Number(entry?.xAxisIndex ?? 0) === axisIndex).map(entry => Number(entry?.yAxisIndex ?? 0))
295
+ for (let yAxisIndex of yAxisIndexes) {
296
+ let yAxis = config.yAxis[yAxisIndex]
297
+ if (!yAxis || yAxis.axisLine?.show != null) continue
298
+ yAxis.axisLine = {...yAxis.axisLine, show: false}
299
+ }
300
+ }
287
301
  }
288
302
 
289
303
  // Value-axis bars are centered on their x/y value, so explicit min/max domains clip edge bars.
@@ -392,7 +406,7 @@ function valueFormatting(config: NormalConfig, fields: Field[]) {
392
406
  let valueAxes = [...config.xAxis, ...config.yAxis].filter(axis => axis?.type === 'value' && !axis.field?.metadata?.timeOrdinal)
393
407
  for (let axis of valueAxes) {
394
408
  if (axis.axisLabel?.formatter != null) continue
395
- axis.axisLabel = {...axis.axisLabel, formatter: makeValueFormatter(axis.field ? [axis.field] : [])}
409
+ axis.axisLabel = {...axis.axisLabel, formatter: makeValueFormatter(axis.field ? [axis.field] : [], {unitStyle: 'axis'})}
396
410
  }
397
411
 
398
412
  for (let series of config.series) {
@@ -494,22 +508,24 @@ function barLabelPositioning(config: NormalConfig) {
494
508
  }
495
509
  }
496
510
 
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.
511
+ // Match series data labels to the assigned value field when labels are enabled.
512
+ // This keeps label formatting in sync with tooltips without asking callers to repeat it.
513
+ // labelsUseYAxisFormat depends on valueFormatting running first so labels inherit axis formatting.
500
514
  function labelsUseYAxisFormat(config: NormalConfig, fields: Field[]) {
501
515
  for (let series of config.series) {
502
516
  // No-op when labels are off or already explicitly formatted.
503
517
  if (!series?.label || series.label.show !== true || series.label.formatter != null) continue
504
518
 
505
- let yField = getSeriesValueField(series, fields)?.name
519
+ let valueField = getSeriesValueField(series, fields)
520
+ let yField = valueField?.name
506
521
  let axisIndex = Number(series.yAxisIndex ?? 0)
507
522
  let axisFormatter = config.yAxis[axisIndex]?.axisLabel?.formatter
508
- if (typeof axisFormatter !== 'function') continue
523
+ let labelFormatter = valueField ? makeValueFormatter([valueField]) : axisFormatter
524
+ if (typeof labelFormatter !== 'function') continue
509
525
 
510
526
  // ECharts can pass different value shapes depending on series/transform shape.
511
527
  // We resolve the numeric value in a few fallback steps so labels always use the
512
- // same value the y-axis is formatting.
528
+ // same field that tooltips format.
513
529
  series.label.formatter = (params: unknown) => {
514
530
  let typed = params as {value?: unknown; data?: Record<string, unknown>}
515
531
  let value = typed?.value
@@ -521,7 +537,7 @@ function labelsUseYAxisFormat(config: NormalConfig, fields: Field[]) {
521
537
  }
522
538
  }
523
539
 
524
- return formatAxisValue(axisFormatter, value)
540
+ return formatAxisValue(labelFormatter, value)
525
541
  }
526
542
  }
527
543
  }
@@ -664,20 +680,34 @@ function inferAxisFromField(field: Field | undefined, rows: Record<string, any>[
664
680
  axis.max = domain[1]
665
681
  }
666
682
 
683
+ if (field.metadata?.timeGrain === 'year') {
684
+ // Pin year ticks to evenly-spaced integers so a domain like [2000, 2005]
685
+ // doesn't end up with the 2000/2002/2004/2005 stub-label pattern.
686
+ let ticks = domain ? niceIntegerTicks(domain[0], domain[1]) : []
687
+ axis.axisLabel = {customValues: ticks, formatter: (value: unknown) => (Number.isInteger(Number(value)) ? String(Number(value)) : '')}
688
+ axis.axisTick = {customValues: ticks}
689
+ axis.axisLine = {show: false}
690
+ axis.splitLine = {show: false}
691
+ return axis
692
+ }
693
+
667
694
  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)
695
+ // Ordinal values are numeric so we use a value axis with a fixed domain, but
696
+ // visually they are discrete buckets. Hide value-axis grid lines by default
697
+ // and pin tick positions to evenly-spaced integers so we never get a stub
698
+ // boundary label (e.g. weeks 1, 14, 27, 40, 53 instead of 1, 11, 21, 31, 41, 51, 53).
699
+ let ticks = domain ? niceIntegerTicks(domain[0], domain[1]) : []
700
+ axis.axisLine = {show: false}
701
+ axis.splitLine = {show: false}
702
+ axis.axisLabel = {
703
+ hideOverlap: true,
704
+ customValues: ticks,
705
+ formatter: (value: unknown) => (domain && (Number(value) < domain[0] || Number(value) > domain[1]) ? '' : formatTimeOrdinal(field, value)),
671
706
  }
672
- axis.axisLabel = {formatter: (value: unknown) => (domain && (Number(value) < domain[0] || Number(value) > domain[1]) ? '' : formatTimeOrdinal(field, value))}
707
+ axis.axisTick = {customValues: ticks}
673
708
  axis.axisPointer = {label: {formatter: (value: unknown) => formatTimeOrdinal(field, value)}}
674
709
  return axis
675
710
  }
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
711
  }
682
712
 
683
713
  if (type === 'category' && field.metadata?.timeOrdinal) {
@@ -688,9 +718,50 @@ function inferAxisFromField(field: Field | undefined, rows: Record<string, any>[
688
718
  return axis
689
719
  }
690
720
 
721
+ // Pick evenly-spaced integer tick positions across [min, max] for ordinal/year value axes.
722
+ // Strategy:
723
+ // 1. If the range is small enough that labeling every value is readable
724
+ // (≤ denseLimit, sized to cover the 12-month ordinal), label every value.
725
+ // 2. Otherwise prefer step sizes that divide the range exactly so the last tick
726
+ // lands on max (avoiding the stub-label problem where ECharts' auto-picked step
727
+ // doesn't reach max).
728
+ // 3. If no candidate divides the range cleanly, fall back to the smallest step that
729
+ // fits inside targetMax ticks; the final tick may fall short of max, but the chart's
730
+ // domain still extends visually via half-bucket padding.
731
+ function niceIntegerTicks(min: number, max: number, targetMin = 4, targetMax = 8, denseLimit = 13): number[] {
732
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max < min) return []
733
+ let range = max - min
734
+ if (range === 0) return [min]
735
+ if (range + 1 <= denseLimit) return tickRange(min, max, 1)
736
+
737
+ let candidates = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000]
738
+ for (let step of candidates) {
739
+ if (range % step !== 0) continue
740
+ let count = range / step + 1
741
+ if (count >= targetMin && count <= targetMax) return tickRange(min, max, step)
742
+ }
743
+ for (let step of candidates) {
744
+ let count = Math.floor(range / step) + 1
745
+ if (count >= targetMin && count <= targetMax) return tickRange(min, max, step)
746
+ }
747
+ return [min, max]
748
+ }
749
+
750
+ function tickRange(min: number, max: number, step: number): number[] {
751
+ let values: number[] = []
752
+ for (let v = min; v <= max + 1e-9; v += step) values.push(Math.round(v))
753
+ return values
754
+ }
755
+
691
756
  // Return the natural numeric domain for temporal values that are encoded as numbers.
692
757
  function temporalValueDomain(field: Field, rows: Record<string, any>[]): [number, number] | undefined {
693
758
  let ordinal = field.metadata?.timeOrdinal
759
+ if (field.metadata?.timeGrain === 'year') {
760
+ let values = rows.map(row => Number(row?.[field.name])).filter(value => Number.isFinite(value))
761
+ if (values.length === 0) return undefined
762
+ return [Math.min(...values), Math.max(...values)]
763
+ }
764
+
694
765
  if (ordinal === 'hour_of_day') return [0, 23]
695
766
  if (ordinal === 'day_of_month') return [1, 31]
696
767
  if (ordinal === 'day_of_year') return [1, 366]
@@ -699,12 +770,6 @@ function temporalValueDomain(field: Field, rows: Record<string, any>[]): [number
699
770
  if (ordinal === 'quarter_of_year') return [1, 4]
700
771
  if (ordinal === 'dow_0s') return [0, 6]
701
772
  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
773
  }
709
774
 
710
775
  // Series sometimes encode their value field as `y` and sometimes as `value` (pie, funnel, etc).
@@ -1,6 +1,6 @@
1
1
  import type {Field} from './types.ts'
2
2
 
3
- const currencySymbols = {usd: '$', eur: '€', gbp: '£', cad: 'C$', aud: 'A$', jpy: '¥'} as const
3
+ const supportedCurrencyCodes = new Set(Intl.supportedValuesOf('currency'))
4
4
  const percent = new Intl.NumberFormat('en-US', {maximumFractionDigits: 0})
5
5
  const currencyCompact = new Intl.NumberFormat('en-US', {notation: 'compact', maximumFractionDigits: 1})
6
6
  const monthYearFormatter = new Intl.DateTimeFormat('en-US', {month: 'long', year: 'numeric'})
@@ -11,6 +11,8 @@ const yearMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep
11
11
  const titleCaseAcronyms = ['id', 'gdp']
12
12
  const titleCaseLowerWords = ['of', 'the', 'and', 'in', 'on']
13
13
 
14
+ type ValueFormatterOptions = {unitStyle?: 'label' | 'axis'}
15
+
14
16
  // Formats a raw column name into a readable title.
15
17
  export function formatTitle(column: string) {
16
18
  let cleaned = column.replace(/"/g, '').replace(/_/g, ' ')
@@ -24,43 +26,56 @@ export function formatTitle(column: string) {
24
26
  // ECharts valueFormatter will take different arguments depending on the chart type.
25
27
  // For bar/line/area it's just a number
26
28
  // for scatter, it's [x,y], for candlestick [open, close, low, high], etc
27
- export function makeValueFormatter(fields: Field[] = []) {
29
+ export function makeValueFormatter(fields: Field[] = [], options: ValueFormatterOptions = {}) {
28
30
  return (value: unknown) => {
29
- if (Array.isArray(value)) return value.map((entry, index) => formatSingleValue(entry, fields[index] || fields[0])).join(', ')
30
- return formatSingleValue(value, fields[0])
31
+ if (Array.isArray(value)) return value.map((entry, index) => formatSingleValue(entry, fields[index] || fields[0], options)).join(', ')
32
+ return formatSingleValue(value, fields[0], options)
31
33
  }
32
34
  }
33
35
 
34
- // Formats one numeric value with field metadata (units, ratio/pct, compact notation).
35
- export function formatSingleValue(value: any, field?: Field) {
36
+ // Formats one numeric value with field metadata (currency, ratio/pct, compact notation).
37
+ export function formatSingleValue(value: any, field?: Field, options: ValueFormatterOptions = {}) {
36
38
  let amount = Number(value)
37
39
  if (!Number.isFinite(amount)) return String(value ?? '')
38
40
 
39
41
  if (field?.metadata?.ratio) return `${percent.format(amount * 100)}%`
40
42
  if (field?.metadata?.pct) return `${percent.format(amount)}%`
41
43
 
42
- let unit = field?.metadata?.units?.toLowerCase() as keyof typeof currencySymbols | undefined
43
- let currencyUnit = unit != null && unit in currencySymbols ? unit : undefined
44
- if (currencyUnit) {
44
+ let currency = field?.metadata?.currency?.toUpperCase()
45
+ if (currency && supportedCurrencyCodes.has(currency)) {
45
46
  let sign = amount < 0 ? '-' : ''
46
47
  let formatted = currencyCompact.format(Math.abs(amount)).replace('K', 'k').replace('M', 'm').replace('B', 'b')
47
- return `${sign}${currencySymbols[currencyUnit]}${formatted}`
48
+ return `${sign}${formatCurrencySymbol(currency)}${formatted}`
48
49
  }
49
50
 
50
- if (amount === 0) return '0'
51
+ if (amount === 0) return addUnit('0', field, options)
51
52
  let sign = amount < 0 ? '-' : ''
52
53
  let absolute = Math.abs(amount)
54
+ let formatted = ''
55
+
56
+ if (absolute >= 1e12) formatted = `${compactValue(absolute / 1e12)}T`
57
+ else if (absolute >= 1e9) formatted = `${compactValue(absolute / 1e9)}B`
58
+ else if (absolute >= 1e6) formatted = `${compactValue(absolute / 1e6)}M`
59
+ else if (absolute >= 1e3) formatted = `${compactValue(absolute / 1e3)}k`
60
+ else if (absolute >= 1) formatted = compactValue(absolute)
61
+ else if (absolute >= 1e-3) formatted = compactValue(absolute)
62
+ else if (absolute >= 1e-6) formatted = `${compactValue(absolute * 1e3)}m`
63
+ else if (absolute >= 1e-9) formatted = `${compactValue(absolute * 1e6)}u`
64
+ else if (absolute >= 1e-12) formatted = `${compactValue(absolute * 1e9)}n`
65
+ else formatted = compactValue(absolute)
66
+
67
+ return addUnit(`${sign}${formatted}`, field, options)
68
+ }
69
+
70
+ function formatCurrencySymbol(currency: string) {
71
+ let parts = new Intl.NumberFormat('en-US', {style: 'currency', currency, currencyDisplay: 'symbol', maximumFractionDigits: 0}).formatToParts(0)
72
+ return parts.find(part => part.type === 'currency')?.value || currency
73
+ }
53
74
 
54
- if (absolute >= 1e12) return `${sign}${compactValue(absolute / 1e12)}T`
55
- if (absolute >= 1e9) return `${sign}${compactValue(absolute / 1e9)}B`
56
- if (absolute >= 1e6) return `${sign}${compactValue(absolute / 1e6)}M`
57
- if (absolute >= 1e3) return `${sign}${compactValue(absolute / 1e3)}k`
58
- if (absolute >= 1) return `${sign}${compactValue(absolute)}`
59
- if (absolute >= 1e-3) return `${sign}${compactValue(absolute)}`
60
- if (absolute >= 1e-6) return `${sign}${compactValue(absolute * 1e3)}m`
61
- if (absolute >= 1e-9) return `${sign}${compactValue(absolute * 1e6)}u`
62
- if (absolute >= 1e-12) return `${sign}${compactValue(absolute * 1e9)}n`
63
- return `${sign}${compactValue(absolute)}`
75
+ function addUnit(value: string, field: Field | undefined, options: ValueFormatterOptions) {
76
+ let unit = field?.metadata?.unit?.trim()
77
+ if (!unit) return value
78
+ return options.unitStyle === 'axis' ? `${value} (${unit})` : `${value} ${unit}`
64
79
  }
65
80
 
66
81
  // Creates a formatter function that renders date/timestamp values based on field metadata.timeGrain.
@@ -111,7 +111,6 @@ registerTheme('graphene-theme', {
111
111
  containLabel: false,
112
112
  },
113
113
  line: {
114
- smooth: true,
115
114
  symbol: 'emptyCircle',
116
115
  symbolSize: 6,
117
116
  lineStyle: {width: 2},
@@ -59,7 +59,7 @@
59
59
  tooltip: {trigger: 'axis'},
60
60
  legend: {show: Boolean(splitBy || y2 || yFields.length > 1)},
61
61
  xAxis: {},
62
- yAxis: [{max: stackPercentage ? 1 : undefined}, ...(y2 ? [{}] : [])],
62
+ yAxis: [{max: stackPercentage ? 1 : undefined}, ...(y2 ? [{alignTicks: true}] : [])],
63
63
  series,
64
64
  }
65
65
  }
@@ -75,7 +75,7 @@
75
75
  tooltip: {trigger: 'axis'},
76
76
  legend: {show: Boolean(splitBy || y2 || (!horizontal && yFields.length > 1) || (horizontal && xFields.length > 1))},
77
77
  xAxis: {},
78
- yAxis: [{max: stackPercentage ? 1 : undefined}, ...(y2 ? [{}] : [])],
78
+ yAxis: [{max: stackPercentage ? 1 : undefined}, ...(y2 ? [{alignTicks: true}] : [])],
79
79
  series,
80
80
  }
81
81
  }
@@ -55,7 +55,7 @@
55
55
  tooltip: {trigger: 'axis'},
56
56
  legend: {show: Boolean(splitBy || y2 || yFields.length > 1)},
57
57
  xAxis: {},
58
- yAxis: [{}, ...(y2 ? [{}] : [])],
58
+ yAxis: [{}, ...(y2 ? [{alignTicks: true}] : [])],
59
59
  series,
60
60
  }
61
61
  }
@@ -19,15 +19,18 @@
19
19
  let navData = $state(navFiles)
20
20
  import.meta.hot?.accept('virtual:nav', mod => navData = mod.default)
21
21
 
22
+ let pathName = window.location.pathname.replace(/^\//, '') || 'index'
23
+
22
24
  // Track compile errors from both initial load and subsequent HMR failures.
23
25
  let compileError = $state<GrapheneError | null>(null)
24
26
  import.meta.hot?.on('vite:error', (payload) => {
27
+ let path = String(payload.err.id || '').split('?')[0].replace(/^file:\/\//, '').replace(/\\/g, '/').replace(/^\/+/, '')
28
+ if (!path.endsWith(pathName + '.md')) return // ignore errors on md pages that are not this page
29
+
25
30
  let line = Math.max(0, (payload.err.loc?.line || 1) - 1)
26
31
  let col = Math.max(0, payload.err.loc?.column || 0)
27
- let path = String(payload.err.id || '').replace(/^file:\/\//, '').replace(/\\/g, '/').replace(/^\/+/, '')
28
- let message = String(payload.err.message || '').replace(/^.*?:\d+:\d+\s*/, '').replace(/\s*https:\/\/svelte\.dev\/\S+/g, '').trim()
29
32
  compileError = {
30
- message,
33
+ message: String(payload.err.message || '').replace(/^.*?:\d+:\d+\s*/, '').replace(/\s*https:\/\/svelte\.dev\/\S+/g, '').trim(),
31
34
  frame: payload.err.frame,
32
35
  file: path,
33
36
  from: {line, col, offset: 0},
@@ -40,33 +43,32 @@
40
43
  // The md file is dynamically imported, so even if there's a compile error, we'll still load LocalApp and can show the user the issue
41
44
  let Page = $state<any>(null)
42
45
  let pageMeta = $state<any>({})
43
- let pageReadyResolve: (() => void) | undefined
44
- window.$GRAPHENE.pageReady = new Promise<void>(resolve => pageReadyResolve = resolve)
45
46
 
46
47
  onMount(async () => {
47
- let pathName = window.location.pathname.replace(/^\//, '') || 'index'
48
-
49
- // force fonts to load before we mount the component.
50
- // This is important for echarts, as it measures text and if done with the wrong font, then
51
- // a) when the right font loads, things will just slightly not line up with edges
52
- // b) test snapshots will differ, as they measure with whatever the system sans font is
53
- // c) screenshots taken by `graphene run` might have the wrong font
54
- document.fonts.load("12px 'Source Sans 3'")
55
- await document.fonts.ready
48
+ try {
49
+ // force fonts to load before we mount the component.
50
+ // This is important for echarts, as it measures text and if done with the wrong font, then
51
+ // a) when the right font loads, things will just slightly not line up with edges
52
+ // b) test snapshots will differ, as they measure with whatever the system sans font is
53
+ // c) screenshots taken by `graphene run` might have the wrong font
54
+ document.fonts.load("12px 'Source Sans 3'")
55
+ await document.fonts.ready
56
56
 
57
- if (pathName == '_charts') {
58
- Page = ChartGallery
59
- } else if (pathName == '_styles') {
60
- Page = StyleGallery
61
- } else if (pathName !== '__ct') {
62
- let mod = await import(/* @vite-ignore */ '/' + pathName + '.md')
63
- Page = mod.default
64
- pageMeta = mod.metadata || {}
65
- compileError = null
66
- setErrorFor('compile', null)
57
+ if (pathName == '_charts') {
58
+ Page = ChartGallery
59
+ } else if (pathName == '_styles') {
60
+ Page = StyleGallery
61
+ } else if (pathName !== '__ct') {
62
+ let mod = await import(/* @vite-ignore */ '/' + pathName + '.md')
63
+ Page = mod.default
64
+ pageMeta = mod.metadata || {}
65
+ compileError = null
66
+ setErrorFor('compile', null)
67
+ }
68
+ } finally {
69
+ await tick()
70
+ window.$GRAPHENE.appLoading = false
67
71
  }
68
- await tick()
69
- pageReadyResolve?.()
70
72
  })
71
73
  </script>
72
74
 
@@ -75,7 +77,7 @@
75
77
  <PageNavGroup files={navData} />
76
78
  </Sidebar>
77
79
 
78
- <main id="content" class={{pageContent: !!Page, dashboardLayout: pageMeta.layout == 'dashboard'}}>
80
+ <main id="content" class={{pageContent: compileError || !!Page, dashboardLayout: pageMeta.layout == 'dashboard'}}>
79
81
  {#if compileError}
80
82
  <h1 class="page-error-heading">Error loading page</h1>
81
83
  <ErrorDisplay error={compileError} />
@@ -115,8 +115,8 @@
115
115
  return nodes
116
116
  .map(n => n.type === 'folder' && n.children?.length ? {...n, children: sortNodes(n.children)} : n)
117
117
  .sort((a, b) => {
118
- if (a.label === 'Home') return -1
119
- if (b.label === 'Home') return 1
118
+ if (a.path?.toLowerCase() === 'index.md') return -1
119
+ if (b.path?.toLowerCase() === 'index.md') return 1
120
120
  if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
121
121
  return a.label.localeCompare(b.label)
122
122
  })
@@ -1,18 +1,18 @@
1
1
  <script>
2
- // Content-agnostic sidebar shell. The panel slides in as an overlay when the
3
- // user hovers the paired SidebarToggle button (rendered separately by the host)
4
- // or the panel itself. Styling values are ported from shadcn-svelte's sidebar registry.
2
+ // Content-agnostic sidebar shell. By default the panel slides in as an overlay
3
+ // when the user hovers the paired SidebarToggle button or the panel itself;
4
+ // alwaysOpen keeps the same shell visible for persistent navigation.
5
5
  import {sidebar} from './sidebar.svelte.js'
6
- let {children, width = '16rem'} = $props()
6
+ let {children, width = '16rem', alwaysOpen = false} = $props()
7
7
  </script>
8
8
 
9
9
  <nav
10
10
  id="nav"
11
11
  class="sb-panel pretty-scrollbar"
12
12
  style="--sb-w:{width}"
13
- data-open={sidebar.open}
14
- onmouseenter={sidebar.enter}
15
- onmouseleave={sidebar.leave}
13
+ data-open={alwaysOpen || sidebar.open}
14
+ onmouseenter={() => { if (!alwaysOpen) sidebar.enter() }}
15
+ onmouseleave={() => { if (!alwaysOpen) sidebar.leave() }}
16
16
  >
17
17
  <div class="sb-inner">
18
18
  {@render children?.()}
@@ -15,7 +15,7 @@ interface QueryNode {
15
15
  contents: string
16
16
  callback?: ResultHandler
17
17
  loading: boolean
18
- fields: Map<string, string | string[]>
18
+ fields: string[]
19
19
  componentId?: string
20
20
  error?: GrapheneError
21
21
  }
@@ -39,24 +39,22 @@ export const setQueryFetcher = f => (queryFetcher = f)
39
39
  // Called by GrapheneQuery tags to register a named query on the page
40
40
  function registerQuery(name: string, contents: string) {
41
41
  queries = queries.filter(q => q.name !== name)
42
- queries.push({name, contents, loading: false, fields: new Map()})
42
+ queries.push({name, contents, loading: false, fields: []})
43
43
  }
44
44
 
45
45
  // Called by viz components to request a particular query of data
46
46
  function query(source: string, fields: Record<string, string | string[]>, callback: ResultHandler, componentId?: string) {
47
- // using Map here because it preserves the order in which we add fields to the select, which we use when we get the result.
48
- let map = new Map(Object.entries(fields))
49
- let exprs: string[] = []
50
- if (map.size > 0) {
51
- map.forEach(value => {
52
- if (Array.isArray(value)) exprs.push(...value)
53
- else exprs.push(value)
47
+ // Preserve field order because translateData maps result fields back to requested expressions by index.
48
+ let seen = new Set<string>()
49
+ let exprs = Object.values(fields)
50
+ .flatMap(value => (Array.isArray(value) ? value : [value]))
51
+ .filter(field => {
52
+ if (seen.has(field)) return false
53
+ seen.add(field)
54
+ return true
54
55
  })
55
- } else {
56
- exprs = ['*']
57
- }
58
- let contents = `from ${source} select ${exprs.join(', ')}`
59
- queries.push({contents, callback, loading: false, fields: map, source, componentId})
56
+ let contents = `from ${source} select ${(exprs.length ? exprs : ['*']).join(', ')}`
57
+ queries.push({contents, callback, loading: false, fields: exprs, source, componentId})
60
58
  runAll()
61
59
  return componentId
62
60
  }
@@ -147,7 +145,7 @@ export function translateData(data: any, node: QueryNode): QueryResult {
147
145
  let rows = data.rows || []
148
146
  let fields: Field[] = []
149
147
 
150
- let requestFields = Array.from(node.fields.values()).flatMap(f => f)
148
+ let requestFields = node.fields
151
149
 
152
150
  data.fields.forEach((field, index) => {
153
151
  let requested = requestFields[index]
@@ -42,17 +42,14 @@ function connect() {
42
42
  socket.onopen = () => socket!.send(JSON.stringify({type: 'register', url: window.location.href}))
43
43
 
44
44
  socket.onmessage = async event => {
45
- let {type, requestId, action, chart} = JSON.parse(event.data)
46
- if (type !== 'check') return
45
+ let {requestId, action, chart} = JSON.parse(event.data)
46
+ let finished = await window.$GRAPHENE.waitForLoad(20_000)
47
47
 
48
48
  if (action === 'list') {
49
- await window.$GRAPHENE.pageReady
50
- await new Promise(resolve => requestAnimationFrame(resolve))
51
49
  socket!.send(JSON.stringify({type: 'checkResponse', requestId, componentIds: listComponentIds()}))
52
50
  return
53
51
  }
54
52
 
55
- let finished = await window.$GRAPHENE.waitForLoad(20_000)
56
53
  let screenshot = chart ? await captureChart(chart) : await takeScreenshot()
57
54
  socket!.send(JSON.stringify({type: 'checkResponse', requestId, errors: getErrors(), stillLoading: !finished, screenshot}))
58
55
  }
@@ -1,4 +1,4 @@
1
- let state = $state({ open: false });
1
+ let state = $state({ open: false, pinned: false });
2
2
  let closeTimer;
3
3
  const sidebar = {
4
4
  get open() {
@@ -10,7 +10,17 @@ const sidebar = {
10
10
  },
11
11
  leave() {
12
12
  clearTimeout(closeTimer);
13
+ if (state.pinned) return;
13
14
  closeTimer = setTimeout(() => state.open = false, 120);
15
+ },
16
+ pin() {
17
+ clearTimeout(closeTimer);
18
+ state.pinned = true;
19
+ state.open = true;
20
+ },
21
+ unpin() {
22
+ state.pinned = false;
23
+ this.leave();
14
24
  }
15
25
  };
16
26
  export {
package/dist/ui/web.js CHANGED
@@ -41,6 +41,7 @@ import LocalApp from './internal/LocalApp.svelte'
41
41
  // code only has to load once.
42
42
  // In theory we could do this with Vite splitting, but then we have a hard dependency on the exact format vite uses. Plus I find the easier to understand.
43
43
  window.$GRAPHENE = window.$GRAPHENE || {}
44
+ window.$GRAPHENE.appLoading = false
44
45
 
45
46
  let nextRenderId = 0
46
47
  let pendingRenders = new Set()
@@ -64,11 +65,11 @@ window.$GRAPHENE.waitForLoad = async (timeout = 20_000) => {
64
65
  let g = window.$GRAPHENE
65
66
  let end = Date.now() + timeout
66
67
  while (Date.now() < end) {
67
- if (!g.isQueryLoading() && pendingRenders.size == 0) {
68
+ if (!g.appLoading && !g.isQueryLoading() && pendingRenders.size == 0) {
68
69
  if (document.fonts?.ready) await document.fonts.ready
69
70
  await new Promise(resolve => setTimeout(resolve, 300))
70
71
  await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
71
- if (!g.isQueryLoading() && pendingRenders.size == 0) return true
72
+ if (!g.appLoading && !g.isQueryLoading() && pendingRenders.size == 0) return true
72
73
  }
73
74
  await new Promise(resolve => setTimeout(resolve, 100))
74
75
  }
@@ -109,5 +110,6 @@ window.$GRAPHENE.components = {
109
110
  window.$GRAPHENE.svelte = {mount, unmount}
110
111
 
111
112
  if (window.location.pathname.replace(/\/+$/, '') !== '/__ct') {
113
+ window.$GRAPHENE.appLoading = true
112
114
  mount(LocalApp, {target: document.body})
113
115
  }