@graphenedata/cli 0.0.17 → 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 (29) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/{bigQuery-OQUNH3VT.js → bigQuery-YIWXZPY6.js} +2 -2
  3. package/dist/cli/{chunk-56K2FF57.js → chunk-SQVXTHE5.js} +2 -2
  4. package/dist/cli/{chunk-56K2FF57.js.map → chunk-SQVXTHE5.js.map} +2 -2
  5. package/dist/cli/{chunk-TZTTALAV.js → chunk-UTV3ERGI.js} +248 -138
  6. package/dist/cli/chunk-UTV3ERGI.js.map +7 -0
  7. package/dist/cli/cli.js +3 -3
  8. package/dist/cli/{duckdb-TKVMONRK.js → duckdb-V6PJEA7H.js} +2 -2
  9. package/dist/cli/{serve2-S2LL4D4D.js → serve2-CGQSM7TD.js} +3 -3
  10. package/dist/cli/{snowflake-3VPDEYYP.js → snowflake-HVSTYBLB.js} +2 -2
  11. package/dist/index.d.ts +4 -4
  12. package/dist/lang/index.d.ts +4 -4
  13. package/dist/skills/graphene/SKILL.md +3 -3
  14. package/dist/skills/graphene/references/gsql.md +26 -23
  15. package/dist/ui/component-utilities/enrich.ts +72 -37
  16. package/dist/ui/component-utilities/format.ts +36 -21
  17. package/dist/ui/component-utilities/theme.ts +0 -1
  18. package/dist/ui/components/AreaChart.svelte +1 -1
  19. package/dist/ui/components/BarChart.svelte +1 -1
  20. package/dist/ui/components/LineChart.svelte +1 -1
  21. package/dist/ui/internal/PageNavGroup.svelte +2 -2
  22. package/dist/ui/internal/Sidebar.svelte +7 -7
  23. package/dist/ui/internal/sidebar.svelte.js +11 -1
  24. package/package.json +1 -1
  25. package/dist/cli/chunk-TZTTALAV.js.map +0 -7
  26. /package/dist/cli/{bigQuery-OQUNH3VT.js.map → bigQuery-YIWXZPY6.js.map} +0 -0
  27. /package/dist/cli/{duckdb-TKVMONRK.js.map → duckdb-V6PJEA7H.js.map} +0 -0
  28. /package/dist/cli/{serve2-S2LL4D4D.js.map → serve2-CGQSM7TD.js.map} +0 -0
  29. /package/dist/cli/{snowflake-3VPDEYYP.js.map → snowflake-HVSTYBLB.js.map} +0 -0
package/dist/cli/cli.js CHANGED
@@ -20,12 +20,12 @@ import {
20
20
  runServeInBackground,
21
21
  stopGrapheneIfRunning,
22
22
  toSql
23
- } from "./chunk-TZTTALAV.js";
23
+ } from "./chunk-UTV3ERGI.js";
24
24
  import {
25
25
  config,
26
26
  loadConfig,
27
27
  setGlobalConfig
28
- } from "./chunk-56K2FF57.js";
28
+ } from "./chunk-SQVXTHE5.js";
29
29
 
30
30
  // cli.ts
31
31
  import { Command } from "commander";
@@ -193,7 +193,7 @@ program.command("serve").description("Run the local server").option("--bg", "Run
193
193
  await runServeInBackground();
194
194
  return exit(0);
195
195
  } else {
196
- let mod = await import("./serve2-S2LL4D4D.js");
196
+ let mod = await import("./serve2-CGQSM7TD.js");
197
197
  await mod.serve2(telemetry);
198
198
  }
199
199
  })
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  config
3
- } from "./chunk-56K2FF57.js";
3
+ } from "./chunk-SQVXTHE5.js";
4
4
 
5
5
  // connections/duckdb.ts
6
6
  import { DuckDBTimestampValue, DuckDBInstance, DuckDBDateValue, DuckDBDecimalValue } from "@duckdb/node-api";
@@ -84,4 +84,4 @@ var DuckDBConnection = class {
84
84
  export {
85
85
  DuckDBConnection
86
86
  };
87
- //# sourceMappingURL=duckdb-TKVMONRK.js.map
87
+ //# sourceMappingURL=duckdb-V6PJEA7H.js.map
@@ -6,10 +6,10 @@ import {
6
6
  runQuery,
7
7
  runVitePlugin,
8
8
  toSql
9
- } from "./chunk-TZTTALAV.js";
9
+ } from "./chunk-UTV3ERGI.js";
10
10
  import {
11
11
  config
12
- } from "./chunk-56K2FF57.js";
12
+ } from "./chunk-SQVXTHE5.js";
13
13
 
14
14
  // serve2.ts
15
15
  import { svelte, vitePreprocess } from "@sveltejs/vite-plugin-svelte";
@@ -445,4 +445,4 @@ export {
445
445
  serve2,
446
446
  svelteWarnings
447
447
  };
448
- //# sourceMappingURL=serve2-S2LL4D4D.js.map
448
+ //# sourceMappingURL=serve2-CGQSM7TD.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  config
3
- } from "./chunk-56K2FF57.js";
3
+ } from "./chunk-SQVXTHE5.js";
4
4
 
5
5
  // connections/snowflake.ts
6
6
  import { createPrivateKey } from "node:crypto";
@@ -125,4 +125,4 @@ function snowflakeIdent(value) {
125
125
  export {
126
126
  SnowflakeConnection
127
127
  };
128
- //# sourceMappingURL=snowflake-3VPDEYYP.js.map
128
+ //# sourceMappingURL=snowflake-HVSTYBLB.js.map
package/dist/index.d.ts CHANGED
@@ -16,14 +16,14 @@ export type Field = {
16
16
  }
17
17
 
18
18
  // Metadata attached to fields.
19
- // There are a few built-in ones that Graphene already uses, but you can always attach your own metadata:
20
- // `price: cogs * 1.15 #ratio #format="US Dollar"` -> {ratio: true, format: 'US Dollar'}
19
+ // Graphene validates user-authored metadata annotations, while inferred metadata may add internal keys.
20
+ // `price: cogs * 1.15 #ratio #currency=USD` -> {ratio: true, currency: 'USD'}
21
21
  export type FieldMeta = {
22
22
  ratio?: true // 0 to 1 value
23
23
  pct?: true // 0 to 100 value
24
- units?: string
24
+ currency?: string // ISO 4217 currency code
25
+ unit?: string // physical unit label
25
26
  timeGrain?: TimeGrain // resolution when the field is a date or timestamp
26
- timePart?: string // extracted temporal part, normalized across backend spellings
27
27
  timeOrdinal?: TimeOrdinal // if the value represents something special like day_of_week, week_of_year, etc
28
28
  defaultName?: string // preferred output column name when an expression is selected without an alias
29
29
  [key: string]: string | true | undefined
@@ -16,14 +16,14 @@ export type Field = {
16
16
  }
17
17
 
18
18
  // Metadata attached to fields.
19
- // There are a few built-in ones that Graphene already uses, but you can always attach your own metadata:
20
- // `price: cogs * 1.15 #ratio #format="US Dollar"` -> {ratio: true, format: 'US Dollar'}
19
+ // Graphene validates user-authored metadata annotations, while inferred metadata may add internal keys.
20
+ // `price: cogs * 1.15 #ratio #currency=USD` -> {ratio: true, currency: 'USD'}
21
21
  export type FieldMeta = {
22
22
  ratio?: true // 0 to 1 value
23
23
  pct?: true // 0 to 100 value
24
- units?: string
24
+ currency?: string // ISO 4217 currency code
25
+ unit?: string // physical unit label
25
26
  timeGrain?: TimeGrain // resolution when the field is a date or timestamp
26
- timePart?: string // extracted temporal part, normalized across backend spellings
27
27
  timeOrdinal?: TimeOrdinal // if the value represents something special like day_of_week, week_of_year, etc
28
28
  defaultName?: string // preferred output column name when an expression is selected without an alias
29
29
  [key: string]: string | true | undefined
@@ -14,12 +14,12 @@ table orders (
14
14
  id bigint
15
15
  created_at datetime
16
16
  user_id bigint
17
- amount float #units=usd
17
+ amount float #currency=USD
18
18
  status string
19
19
  join one users on user_id = users.id -- many orders per user
20
20
  is_complete: status = 'Complete' -- dimension (scalar expression)
21
- revenue: sum(amount) -- measure (agg expression) #units=usd
22
- avg_order: revenue / count(*) -- measures can compose #units=usd
21
+ revenue: sum(amount) -- measure (agg expression) #currency=USD
22
+ avg_order: revenue / count(*) -- measures can compose #currency=USD
23
23
  )
24
24
  table users (
25
25
  id bigint
@@ -17,8 +17,8 @@ table orders (
17
17
  user_id BIGINT
18
18
  created_at DATETIME
19
19
  status STRING -- One of 'Processing', 'Shipped', 'Complete', 'Cancelled', 'Returned'
20
- amount FLOAT -- Amount paid by customer #units=usd
21
- cost FLOAT -- Cost of materials #units=usd
20
+ amount FLOAT -- Amount paid by customer #currency=USD
21
+ cost FLOAT -- Cost of materials #currency=USD
22
22
 
23
23
  -- Join relationships
24
24
 
@@ -30,9 +30,9 @@ table orders (
30
30
 
31
31
  -- Measures
32
32
 
33
- revenue: sum(case when revenue_recognized then amount else 0 end) #units=usd
34
- cogs: sum(case when revenue_recognized then cost else 0 end) #units=usd
35
- profit: revenue - cogs #units=usd
33
+ revenue: sum(case when revenue_recognized then amount else 0 end) #currency=USD
34
+ cogs: sum(case when revenue_recognized then cost else 0 end) #currency=USD
35
+ profit: revenue - cogs #currency=USD
36
36
  profit_margin: profit / revenue #ratio
37
37
  )
38
38
 
@@ -123,9 +123,9 @@ table orders (
123
123
  revenue_recognized: status in ('Processing', 'Shipped', 'Complete')
124
124
 
125
125
  /* Agg expressions */
126
- revenue: sum(case when revenue_recognized then amount else 0 end) #units=usd
127
- cogs: sum(case when revenue_recognized then cost else 0 end) #units=usd
128
- profit: revenue - cogs #units=usd -- even though there are no agg functions here, this is still aggregative as it references other aggregative expressions
126
+ revenue: sum(case when revenue_recognized then amount else 0 end) #currency=USD
127
+ cogs: sum(case when revenue_recognized then cost else 0 end) #currency=USD
128
+ profit: revenue - cogs #currency=USD -- even though there are no agg functions here, this is still aggregative as it references other aggregative expressions
129
129
  profit_margin: profit / revenue #ratio
130
130
  )
131
131
  ```
@@ -138,7 +138,7 @@ There isn't always a SQL expression that can tip Graphene to the semantic meanin
138
138
  - The field could be a base column that has no source expression
139
139
  - There might not be enough information in the expression (eg. what currency a float is tied to)
140
140
 
141
- For this reason, some metadata should be set explicitly in the GSQL model, using annotations. Metadata annotations resemble hashtags (eg. `#ratio`, `#units=usd`) that can be inlined or written above the object they decorate.
141
+ For this reason, some metadata should be set explicitly in the GSQL model, using annotations. Metadata annotations resemble hashtags (eg. `#ratio`, `#currency=USD`) that can be inlined or written above the object they decorate.
142
142
 
143
143
  #### Recognized metadata
144
144
 
@@ -146,9 +146,12 @@ For this reason, some metadata should be set explicitly in the GSQL model, using
146
146
  |---|---|---|
147
147
  | `#ratio` | no | Value is 0–1; rendered as `value × 100%` (e.g. `0.42` → `42%`) |
148
148
  | `#pct` | no | Value is already 0–100; rendered as `value%` (e.g. `42` → `42%`) |
149
- | `#units=<currency>` | no | Adds currency symbol and compacts to K/M/B. Accepted values: `usd`, `eur`, `gbp`, `cad`, `aud`, `jpy` |
149
+ | `#currency=<code>` | no | Adds currency symbol and compacts to K/M/B. Accepts ISO 4217 currency codes like `USD`, `EUR`, and `JPY` |
150
+ | `#unit=<unit>` | no | Appends the provided value to the end of labels in visualizations (e.g. `unit=minutes` appends "minutes", or "(minutes)" on axes). Any non-empty value is accepted |
150
151
  | `#timeGrain=<grain>` | yes (from `date_trunc`, `date_bin`, casts) | Controls time axis label format. Values: `year`, `quarter`, `month`, `week`, `day`, `hour`, `minute`, `second` |
151
- | `#timeOrdinal=<ordinal>` | yes (from `extract`) | Treats values as ordinal positions on a categorical axis. Values: `hour_of_day`, `day_of_month`, `day_of_year`, `week_of_year`, `month_of_year`, `quarter_of_year`, `dow_0s` (0=Sun), `dow_1s` (1=Sun), `dow_1m` (1=Mon) |
152
+ | `#timeOrdinal=<ordinal>` | yes (from `extract`) | Treats extracted time values as ordered positions. Values: `hour_of_day`, `day_of_month`, `day_of_year`, `week_of_year`, `month_of_year`, `quarter_of_year`, `dow_0s` (0=Sun), `dow_1s` (1=Sun), `dow_1m` (1=Mon) |
153
+ | `#description=<text>` | no | Description text for a table or field. `--` comments are also collected as descriptions |
154
+ | `#pii` | no | Marks a field as containing personally identifiable information. |
152
155
 
153
156
  ## `select` statements
154
157
 
@@ -241,15 +244,15 @@ table orders (
241
244
  user_id BIGINT
242
245
  created_at DATETIME
243
246
  status STRING -- One of 'Processing', 'Shipped', 'Complete', 'Cancelled', 'Returned'
244
- amount FLOAT -- Amount paid by customer #units=usd
245
- cost FLOAT -- Cost of materials #units=usd
247
+ amount FLOAT -- Amount paid by customer #currency=USD
248
+ cost FLOAT -- Cost of materials #currency=USD
246
249
 
247
250
  join one users on user_id = users.id
248
251
 
249
252
  revenue_recognized: status in ('Processing', 'Shipped', 'Complete')
250
- revenue: sum(case when revenue_recognized then amount else 0 end) #units=usd
251
- cogs: sum(case when revenue_recognized then cost else 0 end) #units=usd
252
- profit: revenue - cogs #units=usd
253
+ revenue: sum(case when revenue_recognized then amount else 0 end) #currency=USD
254
+ cogs: sum(case when revenue_recognized then cost else 0 end) #currency=USD
255
+ profit: revenue - cogs #currency=USD
253
256
  profit_margin: profit / revenue #ratio
254
257
  )
255
258
  ```
@@ -330,15 +333,15 @@ table orders (
330
333
  user_id BIGINT
331
334
  created_at DATETIME
332
335
  status STRING -- One of 'Processing', 'Shipped', 'Complete', 'Cancelled', 'Returned'
333
- amount FLOAT -- Amount paid by customer #units=usd
334
- cost FLOAT -- Cost of materials #units=usd
336
+ amount FLOAT -- Amount paid by customer #currency=USD
337
+ cost FLOAT -- Cost of materials #currency=USD
335
338
 
336
339
  join one users on user_id = users.id
337
340
 
338
341
  revenue_recognized: status in ('Processing', 'Shipped', 'Complete')
339
- revenue: sum(case when revenue_recognized then amount else 0 end) #units=usd
340
- cogs: sum(case when revenue_recognized then cost else 0 end) #units=usd
341
- profit: revenue - cogs #units=usd
342
+ revenue: sum(case when revenue_recognized then amount else 0 end) #currency=USD
343
+ cogs: sum(case when revenue_recognized then cost else 0 end) #currency=USD
344
+ profit: revenue - cogs #currency=USD
342
345
  profit_margin: profit / revenue #ratio
343
346
  )
344
347
 
@@ -351,7 +354,7 @@ table users (
351
354
  join many orders on id = orders.user_id
352
355
  join one user_facts on id = user_facts.id
353
356
 
354
- ltv: user_facts.ltv #units=usd
357
+ ltv: user_facts.ltv #currency=USD
355
358
  lifetime_orders: user_facts.lifetime_orders
356
359
  )
357
360
 
@@ -388,6 +391,6 @@ We can extend this table to add measures or joins:
388
391
  extend regional_orders (
389
392
  join one regions on region = regions.name
390
393
 
391
- avg_order_value: total_revenue / num_orders #units=usd
394
+ avg_order_value: total_revenue / num_orders #currency=USD
392
395
  )
393
396
  ```
@@ -289,7 +289,7 @@ function inferAxesFromEncodedFields(config: NormalConfig, fields: Field[], rows:
289
289
  // the y-axis line reads like an extra vertical grid line at the left edge.
290
290
  // Hide the paired y-axis line unless the caller explicitly configured it.
291
291
  for (let [axisIndex, axis] of config.xAxis.entries()) {
292
- if (!axis?.field?.metadata?.timeOrdinal) continue
292
+ if (!axis?.field?.metadata?.timeOrdinal && axis?.field?.metadata?.timeGrain !== 'year') continue
293
293
 
294
294
  let yAxisIndexes = config.series.filter(entry => Number(entry?.xAxisIndex ?? 0) === axisIndex).map(entry => Number(entry?.yAxisIndex ?? 0))
295
295
  for (let yAxisIndex of yAxisIndexes) {
@@ -406,7 +406,7 @@ function valueFormatting(config: NormalConfig, fields: Field[]) {
406
406
  let valueAxes = [...config.xAxis, ...config.yAxis].filter(axis => axis?.type === 'value' && !axis.field?.metadata?.timeOrdinal)
407
407
  for (let axis of valueAxes) {
408
408
  if (axis.axisLabel?.formatter != null) continue
409
- axis.axisLabel = {...axis.axisLabel, formatter: makeValueFormatter(axis.field ? [axis.field] : [])}
409
+ axis.axisLabel = {...axis.axisLabel, formatter: makeValueFormatter(axis.field ? [axis.field] : [], {unitStyle: 'axis'})}
410
410
  }
411
411
 
412
412
  for (let series of config.series) {
@@ -508,22 +508,24 @@ function barLabelPositioning(config: NormalConfig) {
508
508
  }
509
509
  }
510
510
 
511
- // Match series data labels to the assigned y-axis formatter when labels are enabled.
512
- // This keeps label formatting in sync with the y-axis without asking callers to repeat it.
513
- // 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.
514
514
  function labelsUseYAxisFormat(config: NormalConfig, fields: Field[]) {
515
515
  for (let series of config.series) {
516
516
  // No-op when labels are off or already explicitly formatted.
517
517
  if (!series?.label || series.label.show !== true || series.label.formatter != null) continue
518
518
 
519
- let yField = getSeriesValueField(series, fields)?.name
519
+ let valueField = getSeriesValueField(series, fields)
520
+ let yField = valueField?.name
520
521
  let axisIndex = Number(series.yAxisIndex ?? 0)
521
522
  let axisFormatter = config.yAxis[axisIndex]?.axisLabel?.formatter
522
- if (typeof axisFormatter !== 'function') continue
523
+ let labelFormatter = valueField ? makeValueFormatter([valueField]) : axisFormatter
524
+ if (typeof labelFormatter !== 'function') continue
523
525
 
524
526
  // ECharts can pass different value shapes depending on series/transform shape.
525
527
  // We resolve the numeric value in a few fallback steps so labels always use the
526
- // same value the y-axis is formatting.
528
+ // same field that tooltips format.
527
529
  series.label.formatter = (params: unknown) => {
528
530
  let typed = params as {value?: unknown; data?: Record<string, unknown>}
529
531
  let value = typed?.value
@@ -535,7 +537,7 @@ function labelsUseYAxisFormat(config: NormalConfig, fields: Field[]) {
535
537
  }
536
538
  }
537
539
 
538
- return formatAxisValue(axisFormatter, value)
540
+ return formatAxisValue(labelFormatter, value)
539
541
  }
540
542
  }
541
543
  }
@@ -678,36 +680,34 @@ function inferAxisFromField(field: Field | undefined, rows: Record<string, any>[
678
680
  axis.max = domain[1]
679
681
  }
680
682
 
681
- if (field.metadata?.timeOrdinal) {
682
- axis.minInterval = 1
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
+ }
683
693
 
694
+ if (field.metadata?.timeOrdinal) {
684
695
  // Ordinal values are numeric so we use a value axis with a fixed domain, but
685
696
  // visually they are discrete buckets. Hide value-axis grid lines by default
686
- // and ask ECharts for denser integer ticks than its generic value defaults.
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]) : []
687
700
  axis.axisLine = {show: false}
688
701
  axis.splitLine = {show: false}
689
- axis.axisLabel = {hideOverlap: true, formatter: (value: unknown) => (domain && (Number(value) < domain[0] || Number(value) > domain[1]) ? '' : formatTimeOrdinal(field, value))}
690
-
691
- // splitNumber is a hint rather than an exact spacing, but it works better
692
- // with value-axis bars because interval can fight the half-bucket padding we
693
- // add later. These defaults keep compact ordinals readable without forcing
694
- // every possible day/week label.
695
- let ordinal = field.metadata.timeOrdinal
696
- if (ordinal === 'month_of_year' || ordinal === 'quarter_of_year' || ordinal === 'dow_0s' || ordinal === 'dow_1s' || ordinal === 'dow_1m')
697
- axis.splitNumber = domain ? domain[1] - domain[0] + 1 : 5
698
- if (ordinal === 'hour_of_day') axis.splitNumber = 8
699
- if (ordinal === 'day_of_month') axis.splitNumber = 6
700
- if (ordinal === 'week_of_year') axis.splitNumber = 13
701
- if (ordinal === 'day_of_year') axis.splitNumber = 12
702
-
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)),
706
+ }
707
+ axis.axisTick = {customValues: ticks}
703
708
  axis.axisPointer = {label: {formatter: (value: unknown) => formatTimeOrdinal(field, value)}}
704
709
  return axis
705
710
  }
706
-
707
- if (field.metadata?.timePart === 'year') {
708
- axis.minInterval = 1
709
- axis.axisLabel = {formatter: (value: unknown) => (Number.isInteger(Number(value)) ? String(Number(value)) : '')}
710
- }
711
711
  }
712
712
 
713
713
  if (type === 'category' && field.metadata?.timeOrdinal) {
@@ -718,9 +718,50 @@ function inferAxisFromField(field: Field | undefined, rows: Record<string, any>[
718
718
  return axis
719
719
  }
720
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
+
721
756
  // Return the natural numeric domain for temporal values that are encoded as numbers.
722
757
  function temporalValueDomain(field: Field, rows: Record<string, any>[]): [number, number] | undefined {
723
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
+
724
765
  if (ordinal === 'hour_of_day') return [0, 23]
725
766
  if (ordinal === 'day_of_month') return [1, 31]
726
767
  if (ordinal === 'day_of_year') return [1, 366]
@@ -729,12 +770,6 @@ function temporalValueDomain(field: Field, rows: Record<string, any>[]): [number
729
770
  if (ordinal === 'quarter_of_year') return [1, 4]
730
771
  if (ordinal === 'dow_0s') return [0, 6]
731
772
  if (ordinal === 'dow_1s' || ordinal === 'dow_1m') return [1, 7]
732
-
733
- if (field.metadata?.timePart == 'year') {
734
- let values = rows.map(row => Number(row?.[field.name])).filter(value => Number.isFinite(value))
735
- if (values.length === 0) return undefined
736
- return [Math.min(...values), Math.max(...values)]
737
- }
738
773
  }
739
774
 
740
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
  }
@@ -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?.()}
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphenedata/cli",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "license": "Elastic-2.0",
5
5
  "author": "Graphene Systems Inc",
6
6
  "repository": {