@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.
- package/README.md +65 -29
- package/dist/cli/{bigQuery-I3F46SC6.js → bigQuery-YIWXZPY6.js} +2 -2
- package/dist/cli/{chunk-QAXEOZ43.js → chunk-SQVXTHE5.js} +2 -2
- package/dist/cli/chunk-SQVXTHE5.js.map +7 -0
- package/dist/cli/{chunk-OVWODUTJ.js → chunk-UTV3ERGI.js} +279 -150
- package/dist/cli/chunk-UTV3ERGI.js.map +7 -0
- package/dist/cli/cli.js +33 -6
- package/dist/cli/{clickhouse-ZN5AN2UL.js → clickhouse-S3BJSKND.js} +3 -2
- package/dist/cli/clickhouse-S3BJSKND.js.map +7 -0
- package/dist/cli/{duckdb-IYBIO5KJ.js → duckdb-V6PJEA7H.js} +2 -2
- package/dist/cli/{serve2-TNN5EROW.js → serve2-CGQSM7TD.js} +7 -6
- package/dist/cli/{serve2-TNN5EROW.js.map → serve2-CGQSM7TD.js.map} +2 -2
- package/dist/cli/{snowflake-MOQB5GA4.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 +10 -3
- package/dist/skills/graphene/references/gsql.md +26 -23
- package/dist/skills/graphene/references/model-gsql.md +19 -21
- package/dist/ui/component-utilities/enrich.ts +88 -23
- 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/LocalApp.svelte +29 -27
- package/dist/ui/internal/PageNavGroup.svelte +2 -2
- package/dist/ui/internal/Sidebar.svelte +7 -7
- package/dist/ui/internal/queryEngine.ts +13 -15
- package/dist/ui/internal/runSocket.ts +2 -5
- package/dist/ui/internal/sidebar.svelte.js +11 -1
- package/dist/ui/web.js +4 -2
- package/package.json +5 -1
- package/dist/cli/chunk-OVWODUTJ.js.map +0 -7
- package/dist/cli/chunk-QAXEOZ43.js.map +0 -7
- package/dist/cli/clickhouse-ZN5AN2UL.js.map +0 -7
- /package/dist/cli/{bigQuery-I3F46SC6.js.map → bigQuery-YIWXZPY6.js.map} +0 -0
- /package/dist/cli/{duckdb-IYBIO5KJ.js.map → duckdb-V6PJEA7H.js.map} +0 -0
- /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.
|
|
3
|
+
Conventions and patterns for writing production-quality `.gsql` semantic models. Make sure you've read `references/gsql.md` before proceeding.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## New table workflow
|
|
6
6
|
|
|
7
|
-
1. Generate a `.gsql` file
|
|
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
|
-
|
|
12
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
-
|
|
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 --
|
|
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
|
|
49
|
+
dim_name: expression #annotationWithoutDescription
|
|
52
50
|
|
|
53
51
|
/* Measures */
|
|
54
52
|
|
|
55
|
-
measure_name: aggregate_expression
|
|
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 `
|
|
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
|
|
498
|
-
// This keeps label formatting in sync with
|
|
499
|
-
// 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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
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.
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
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?.()}
|
|
@@ -15,7 +15,7 @@ interface QueryNode {
|
|
|
15
15
|
contents: string
|
|
16
16
|
callback?: ResultHandler
|
|
17
17
|
loading: boolean
|
|
18
|
-
fields:
|
|
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:
|
|
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
|
-
//
|
|
48
|
-
let
|
|
49
|
-
let exprs
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
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
|
-
}
|
|
56
|
-
|
|
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 =
|
|
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 {
|
|
46
|
-
|
|
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
|
}
|