@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.
- package/README.md +1 -1
- package/dist/cli/{bigQuery-OQUNH3VT.js → bigQuery-YIWXZPY6.js} +2 -2
- package/dist/cli/{chunk-56K2FF57.js → chunk-SQVXTHE5.js} +2 -2
- package/dist/cli/{chunk-56K2FF57.js.map → chunk-SQVXTHE5.js.map} +2 -2
- package/dist/cli/{chunk-TZTTALAV.js → chunk-UTV3ERGI.js} +248 -138
- package/dist/cli/chunk-UTV3ERGI.js.map +7 -0
- package/dist/cli/cli.js +3 -3
- package/dist/cli/{duckdb-TKVMONRK.js → duckdb-V6PJEA7H.js} +2 -2
- package/dist/cli/{serve2-S2LL4D4D.js → serve2-CGQSM7TD.js} +3 -3
- package/dist/cli/{snowflake-3VPDEYYP.js → snowflake-HVSTYBLB.js} +2 -2
- package/dist/index.d.ts +4 -4
- package/dist/lang/index.d.ts +4 -4
- package/dist/skills/graphene/SKILL.md +3 -3
- package/dist/skills/graphene/references/gsql.md +26 -23
- package/dist/ui/component-utilities/enrich.ts +72 -37
- package/dist/ui/component-utilities/format.ts +36 -21
- package/dist/ui/component-utilities/theme.ts +0 -1
- package/dist/ui/components/AreaChart.svelte +1 -1
- package/dist/ui/components/BarChart.svelte +1 -1
- package/dist/ui/components/LineChart.svelte +1 -1
- package/dist/ui/internal/PageNavGroup.svelte +2 -2
- package/dist/ui/internal/Sidebar.svelte +7 -7
- package/dist/ui/internal/sidebar.svelte.js +11 -1
- package/package.json +1 -1
- package/dist/cli/chunk-TZTTALAV.js.map +0 -7
- /package/dist/cli/{bigQuery-OQUNH3VT.js.map → bigQuery-YIWXZPY6.js.map} +0 -0
- /package/dist/cli/{duckdb-TKVMONRK.js.map → duckdb-V6PJEA7H.js.map} +0 -0
- /package/dist/cli/{serve2-S2LL4D4D.js.map → serve2-CGQSM7TD.js.map} +0 -0
- /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-
|
|
23
|
+
} from "./chunk-UTV3ERGI.js";
|
|
24
24
|
import {
|
|
25
25
|
config,
|
|
26
26
|
loadConfig,
|
|
27
27
|
setGlobalConfig
|
|
28
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
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-
|
|
87
|
+
//# sourceMappingURL=duckdb-V6PJEA7H.js.map
|
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
runQuery,
|
|
7
7
|
runVitePlugin,
|
|
8
8
|
toSql
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-UTV3ERGI.js";
|
|
10
10
|
import {
|
|
11
11
|
config
|
|
12
|
-
} from "./chunk-
|
|
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-
|
|
448
|
+
//# sourceMappingURL=serve2-CGQSM7TD.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
config
|
|
3
|
-
} from "./chunk-
|
|
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-
|
|
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
|
-
//
|
|
20
|
-
// `price: cogs * 1.15 #ratio #
|
|
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
|
-
|
|
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
|
package/dist/lang/index.d.ts
CHANGED
|
@@ -16,14 +16,14 @@ export type Field = {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// Metadata attached to fields.
|
|
19
|
-
//
|
|
20
|
-
// `price: cogs * 1.15 #ratio #
|
|
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
|
-
|
|
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 #
|
|
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) #
|
|
22
|
-
avg_order: revenue / count(*) -- measures can compose #
|
|
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 #
|
|
21
|
-
cost FLOAT -- Cost of materials #
|
|
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) #
|
|
34
|
-
cogs: sum(case when revenue_recognized then cost else 0 end) #
|
|
35
|
-
profit: revenue - cogs #
|
|
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) #
|
|
127
|
-
cogs: sum(case when revenue_recognized then cost else 0 end) #
|
|
128
|
-
profit: revenue - cogs #
|
|
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`, `#
|
|
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
|
-
| `#
|
|
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
|
|
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 #
|
|
245
|
-
cost FLOAT -- Cost of materials #
|
|
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) #
|
|
251
|
-
cogs: sum(case when revenue_recognized then cost else 0 end) #
|
|
252
|
-
profit: revenue - cogs #
|
|
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 #
|
|
334
|
-
cost FLOAT -- Cost of materials #
|
|
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) #
|
|
340
|
-
cogs: sum(case when revenue_recognized then cost else 0 end) #
|
|
341
|
-
profit: revenue - cogs #
|
|
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 #
|
|
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 #
|
|
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
|
|
512
|
-
// This keeps label formatting in sync with
|
|
513
|
-
// labelsUseYAxisFormat depends on
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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?.
|
|
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
|
+
}
|
|
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
|
|
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 = {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
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 (
|
|
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
|
|
43
|
-
|
|
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}${
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
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.
|
|
@@ -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
|
}
|
|
@@ -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.
|
|
119
|
-
if (b.
|
|
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.
|
|
3
|
-
// user hovers the paired SidebarToggle button
|
|
4
|
-
//
|
|
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 {
|