@walterra/pi-charts 0.0.1

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.
@@ -0,0 +1,1597 @@
1
+ # Vega-Lite Visualization Reference
2
+
3
+ A comprehensive reference for creating data visualizations using Vega-Lite specifications. This guide follows best practices from the [UW Interactive Data Lab Visualization Curriculum](https://idl.uw.edu/visualization-curriculum/intro.html) and established visualization research.
4
+
5
+ ## Prerequisites
6
+
7
+ Dependencies are **auto-installed** via `uv` (Python package manager) when first using this extension:
8
+
9
+ - `uv` is auto-installed if not present
10
+ - Python 3, `altair`, `pandas`, `vl-convert-python` are managed by uv automatically
11
+
12
+ If auto-install fails, manual installation:
13
+
14
+ ```bash
15
+ # Install uv
16
+ curl -LsSf https://astral.sh/uv/install.sh | sh
17
+
18
+ # Run with dependencies (uv handles Python + packages)
19
+ uv run --with altair --with pandas --with vl-convert-python python3 your_script.py
20
+ ```
21
+
22
+ If dependencies cannot be installed, the tool returns an error with instructions. Do NOT fall back to ASCII charts.
23
+
24
+ ## Philosophy
25
+
26
+ > "A visualization is a mapping from data to visual properties. The key insight is that this mapping should be **declarative** rather than imperative."
27
+
28
+ Vega-Lite embodies a **grammar of graphics**: you describe _what_ you want to visualize, not _how_ to draw it. This enables:
29
+
30
+ - Concise specifications
31
+ - Automatic inference of scales, axes, legends
32
+ - Composable multi-view displays
33
+ - Reproducible visualizations
34
+
35
+ ---
36
+
37
+ ## Critical Pitfalls (Read First!)
38
+
39
+ > ⚠️ **These issues cause silent failures. Your chart will render but show wrong/missing data.**
40
+
41
+ ### 1. Dot-Notation Field Names
42
+
43
+ **Problem:** Field names containing dots (e.g., `room.name`, `host.ip`, `metric.value`) are interpreted as nested object paths.
44
+
45
+ ```json
46
+ // Data from ES|QL: {"room.name": "Kitchen", "temp": 21}
47
+ // Vega-Lite looks for: {room: {name: "Kitchen"}}
48
+ // Result: "undefined" in labels, collapsed bars, broken legends
49
+ ```
50
+
51
+ **Solution:** Always transform data to use simple field names:
52
+
53
+ ```json
54
+ // ❌ BROKEN
55
+ {"data": {"values": [{"room.name": "Kitchen"}]}}
56
+
57
+ // ✅ WORKS
58
+ {"data": {"values": [{"room": "Kitchen"}]}}
59
+ ```
60
+
61
+ ### 2. Horizontal Bar Chart Label Truncation
62
+
63
+ Y-axis labels get cut off on horizontal bar charts. Always set `labelLimit`:
64
+
65
+ ```json
66
+ "y": {"field": "category", "axis": {"labelLimit": 200}}
67
+ ```
68
+
69
+ Or use vertical bars with angled labels:
70
+
71
+ ```json
72
+ "x": {"field": "category", "axis": {"labelAngle": -45, "labelLimit": 120}}
73
+ ```
74
+
75
+ ### 3. Facet/Repeat Incompatibility
76
+
77
+ Top-level `facet` and `repeat` with `spec` fail in Altair v6. Use encoding-based faceting:
78
+
79
+ ```json
80
+ // ❌ FAILS
81
+ {"facet": {"column": {"field": "region"}}, "spec": {...}}
82
+
83
+ // ✅ WORKS
84
+ {"encoding": {"column": {"field": "region", "type": "nominal"}, ...}}
85
+ ```
86
+
87
+ ### 4. Aspect Ratio Mistakes
88
+
89
+ - **Time series too tall:** Makes trends unreadable. Use 3:1 or 4:1 (width:height)
90
+ - **Bar charts too short:** Labels get truncated. Give adequate height per category
91
+
92
+ ```json
93
+ // Time series: wide
94
+ {"width": 600, "height": 200}
95
+
96
+ // Bar chart with 8 categories: give height
97
+ {"width": 450, "height": 300}
98
+ ```
99
+
100
+ ### 5. Legends vs Direct Labels
101
+
102
+ Legends force the reader's eye to jump back and forth. Label lines directly:
103
+
104
+ ```json
105
+ {
106
+ "layer": [
107
+ { "mark": "line" },
108
+ {
109
+ "mark": { "type": "text", "align": "left", "dx": 5 },
110
+ "transform": [{ "filter": "datum.x == datum.max_x" }],
111
+ "encoding": { "text": { "field": "series" } }
112
+ }
113
+ ]
114
+ }
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Specification Structure
120
+
121
+ A Vega-Lite specification is a JSON object:
122
+
123
+ ```json
124
+ {
125
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
126
+ "data": { ... },
127
+ "mark": "...",
128
+ "encoding": { ... },
129
+ "width": 600,
130
+ "height": 400
131
+ }
132
+ ```
133
+
134
+ ### Core Components
135
+
136
+ | Component | Description |
137
+ | ----------- | --------------------------------------------------- |
138
+ | `data` | Input data (inline values, URL, or named dataset) |
139
+ | `mark` | Geometric shape (bar, line, point, area, etc.) |
140
+ | `encoding` | Mapping of data fields to visual channels |
141
+ | `transform` | Data transformations (filter, aggregate, calculate) |
142
+ | `config` | Styling defaults |
143
+
144
+ ---
145
+
146
+ ## Data Types
147
+
148
+ Understanding data types is fundamental to choosing appropriate visual encodings.
149
+
150
+ | Type | Symbol | Description | Example | Appropriate Channels |
151
+ | ---------------- | ------ | ------------------------ | ---------------------------- | ------------------------------ |
152
+ | **Nominal** | `N` | Categories without order | country, product type | color hue, shape, row/column |
153
+ | **Ordinal** | `O` | Ordered categories | rating (low/med/high), month | position, color value, size |
154
+ | **Quantitative** | `Q` | Continuous numbers | temperature, revenue | position, size, color gradient |
155
+ | **Temporal** | `T` | Date/time values | timestamp, date | position (time axis) |
156
+
157
+ ### Type Selection Guidelines
158
+
159
+ - **Nominal**: Use when equality comparison matters (A = B?)
160
+ - **Ordinal**: Use when rank order matters (A < B?)
161
+ - **Quantitative**: Use when magnitude/distance matters (A - B = ?)
162
+ - **Temporal**: Use for time-based data with calendar semantics
163
+
164
+ ---
165
+
166
+ ## Encoding Channels
167
+
168
+ Channels map data fields to visual properties.
169
+
170
+ ### Position Channels
171
+
172
+ ```json
173
+ "encoding": {
174
+ "x": {"field": "date", "type": "temporal"},
175
+ "y": {"field": "value", "type": "quantitative"},
176
+ "x2": {"field": "end_date"},
177
+ "y2": {"field": "high_value"}
178
+ }
179
+ ```
180
+
181
+ | Channel | Description | Best For |
182
+ | -------------------- | --------------------------- | ---------------------- |
183
+ | `x`, `y` | Primary position | All data types |
184
+ | `x2`, `y2` | Secondary position (ranges) | Range bars, error bars |
185
+ | `xOffset`, `yOffset` | Position offset within band | Grouped/dodged bars |
186
+
187
+ ### Mark Property Channels
188
+
189
+ ```json
190
+ "encoding": {
191
+ "color": {"field": "category", "type": "nominal"},
192
+ "size": {"field": "population", "type": "quantitative"},
193
+ "shape": {"field": "region", "type": "nominal"},
194
+ "opacity": {"field": "confidence", "type": "quantitative"}
195
+ }
196
+ ```
197
+
198
+ | Channel | Description | Best For |
199
+ | ------------- | ------------------ | -------------------------------------- |
200
+ | `color` | Fill/stroke color | Nominal (hue), Quantitative (gradient) |
201
+ | `size` | Mark size/area | Quantitative values |
202
+ | `shape` | Point symbol shape | Nominal (≤6 categories) |
203
+ | `opacity` | Transparency | Quantitative, overlapping data |
204
+ | `strokeWidth` | Line thickness | Quantitative |
205
+ | `strokeDash` | Dash pattern | Nominal (≤3 categories) |
206
+
207
+ ### Text & Tooltip Channels
208
+
209
+ ```json
210
+ "encoding": {
211
+ "text": {"field": "label"},
212
+ "tooltip": [
213
+ {"field": "name", "title": "Country"},
214
+ {"field": "value", "title": "GDP", "format": ",.0f"}
215
+ ]
216
+ }
217
+ ```
218
+
219
+ ### Facet Channels
220
+
221
+ ```json
222
+ "encoding": {
223
+ "row": {"field": "region", "type": "nominal"},
224
+ "column": {"field": "year", "type": "ordinal"}
225
+ }
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Mark Types
231
+
232
+ ### Basic Marks
233
+
234
+ | Mark | Use Case | Example |
235
+ | -------- | -------------------- | ------------------ |
236
+ | `point` | Scatter plots | `"mark": "point"` |
237
+ | `circle` | Filled scatter plots | `"mark": "circle"` |
238
+ | `square` | Matrix displays | `"mark": "square"` |
239
+ | `bar` | Bar charts | `"mark": "bar"` |
240
+ | `line` | Time series, trends | `"mark": "line"` |
241
+ | `area` | Volume over time | `"mark": "area"` |
242
+ | `tick` | Strip plots | `"mark": "tick"` |
243
+ | `rule` | Reference lines | `"mark": "rule"` |
244
+ | `text` | Labels | `"mark": "text"` |
245
+ | `rect` | Heatmaps | `"mark": "rect"` |
246
+ | `arc` | Pie/donut charts | `"mark": "arc"` |
247
+
248
+ ### Composite Marks
249
+
250
+ | Mark | Use Case |
251
+ | ----------- | ------------------------- |
252
+ | `boxplot` | Distribution summary |
253
+ | `errorbar` | Uncertainty visualization |
254
+ | `errorband` | Confidence intervals |
255
+
256
+ ### Mark Properties
257
+
258
+ ```json
259
+ "mark": {
260
+ "type": "bar",
261
+ "color": "#4c78a8",
262
+ "opacity": 0.8,
263
+ "cornerRadius": 2,
264
+ "strokeWidth": 0
265
+ }
266
+ ```
267
+
268
+ ---
269
+
270
+ ## Scales
271
+
272
+ Scales map data values to visual values.
273
+
274
+ ### Scale Types
275
+
276
+ | Type | Description | Use For |
277
+ | --------- | ------------------- | --------------------------- |
278
+ | `linear` | Linear mapping | Quantitative data |
279
+ | `log` | Logarithmic | Wide-ranging values, ratios |
280
+ | `sqrt` | Square root | Area-based size encoding |
281
+ | `pow` | Power scale | Custom nonlinear |
282
+ | `time` | Time-based | Temporal data |
283
+ | `utc` | UTC time | Cross-timezone data |
284
+ | `ordinal` | Discrete categories | Nominal/ordinal |
285
+ | `band` | Discrete with width | Bar charts |
286
+ | `point` | Discrete points | Dot plots |
287
+
288
+ ### Scale Configuration
289
+
290
+ ```json
291
+ "encoding": {
292
+ "x": {
293
+ "field": "value",
294
+ "type": "quantitative",
295
+ "scale": {
296
+ "domain": [0, 100],
297
+ "range": [0, 400],
298
+ "zero": true,
299
+ "nice": true,
300
+ "clamp": true
301
+ }
302
+ }
303
+ }
304
+ ```
305
+
306
+ ### Color Scales
307
+
308
+ ```json
309
+ "encoding": {
310
+ "color": {
311
+ "field": "temperature",
312
+ "type": "quantitative",
313
+ "scale": {
314
+ "scheme": "viridis",
315
+ "domain": [-10, 40]
316
+ }
317
+ }
318
+ }
319
+ ```
320
+
321
+ **Recommended Color Schemes:**
322
+
323
+ | Type | Schemes |
324
+ | ----------- | ----------------------------------------------- |
325
+ | Sequential | `viridis`, `blues`, `greens`, `oranges`, `reds` |
326
+ | Diverging | `redblue`, `redyellowblue`, `spectral` |
327
+ | Categorical | `category10`, `tableau10`, `set1` |
328
+
329
+ ---
330
+
331
+ ## Transforms
332
+
333
+ Data transformations within the spec.
334
+
335
+ ### Filter
336
+
337
+ ```json
338
+ "transform": [
339
+ {"filter": "datum.year == 2020"},
340
+ {"filter": {"field": "country", "oneOf": ["USA", "China", "India"]}}
341
+ ]
342
+ ```
343
+
344
+ ### Calculate
345
+
346
+ ```json
347
+ "transform": [
348
+ {"calculate": "datum.revenue - datum.cost", "as": "profit"},
349
+ {"calculate": "datum.value * 100 / datum.total", "as": "percentage"}
350
+ ]
351
+ ```
352
+
353
+ ### Aggregate
354
+
355
+ ```json
356
+ "transform": [
357
+ {
358
+ "aggregate": [
359
+ {"op": "mean", "field": "temperature", "as": "avg_temp"},
360
+ {"op": "count", "as": "n"}
361
+ ],
362
+ "groupby": ["month", "location"]
363
+ }
364
+ ]
365
+ ```
366
+
367
+ **Aggregation Operations:** `count`, `sum`, `mean`, `median`, `min`, `max`, `stdev`, `variance`, `q1`, `q3`, `distinct`, `values`
368
+
369
+ ### Bin
370
+
371
+ ```json
372
+ "encoding": {
373
+ "x": {
374
+ "bin": true,
375
+ "field": "temperature"
376
+ },
377
+ "y": {"aggregate": "count"}
378
+ }
379
+ ```
380
+
381
+ Or explicit:
382
+
383
+ ```json
384
+ "transform": [
385
+ {"bin": {"maxbins": 20}, "field": "value", "as": "value_bin"}
386
+ ]
387
+ ```
388
+
389
+ ### Time Unit
390
+
391
+ ```json
392
+ "encoding": {
393
+ "x": {
394
+ "timeUnit": "yearmonth",
395
+ "field": "date"
396
+ }
397
+ }
398
+ ```
399
+
400
+ **Time Units:** `year`, `quarter`, `month`, `week`, `day`, `dayofyear`, `date`, `hours`, `minutes`, `seconds`, `milliseconds`, `yearmonth`, `yearmonthdate`, `monthdate`, `hoursminutes`
401
+
402
+ ### Window
403
+
404
+ ```json
405
+ "transform": [
406
+ {
407
+ "window": [
408
+ {"op": "row_number", "as": "rank"},
409
+ {"op": "sum", "field": "value", "as": "cumulative"}
410
+ ],
411
+ "sort": [{"field": "value", "order": "descending"}]
412
+ }
413
+ ]
414
+ ```
415
+
416
+ ### Fold (Unpivot)
417
+
418
+ ```json
419
+ "transform": [
420
+ {"fold": ["temp_min", "temp_max"], "as": ["measure", "value"]}
421
+ ]
422
+ ```
423
+
424
+ ### Pivot
425
+
426
+ ```json
427
+ "transform": [
428
+ {"pivot": "category", "value": "amount", "groupby": ["date"]}
429
+ ]
430
+ ```
431
+
432
+ ### Regression & Loess
433
+
434
+ ```json
435
+ "transform": [
436
+ {"regression": "y", "on": "x", "method": "linear"}
437
+ ]
438
+ ```
439
+
440
+ ```json
441
+ "transform": [
442
+ {"loess": "y", "on": "x", "bandwidth": 0.3}
443
+ ]
444
+ ```
445
+
446
+ ---
447
+
448
+ ## Multi-View Composition
449
+
450
+ Composition is fundamental to professional data visualization.
451
+
452
+ ### Layer
453
+
454
+ Superimpose multiple marks on shared axes.
455
+
456
+ ```json
457
+ {
458
+ "layer": [
459
+ {
460
+ "mark": "area",
461
+ "encoding": {
462
+ "x": { "field": "date", "type": "temporal" },
463
+ "y": { "field": "value", "type": "quantitative" }
464
+ }
465
+ },
466
+ {
467
+ "mark": { "type": "line", "color": "black" },
468
+ "encoding": {
469
+ "x": { "field": "date", "type": "temporal" },
470
+ "y": { "field": "value", "type": "quantitative" }
471
+ }
472
+ }
473
+ ]
474
+ }
475
+ ```
476
+
477
+ **Use Cases:**
478
+
479
+ - Area with line overlay
480
+ - Points with trend line
481
+ - Bars with reference rules
482
+ - Confidence bands with mean line
483
+
484
+ ### Horizontal Concatenation (hconcat)
485
+
486
+ ```json
487
+ {
488
+ "hconcat": [
489
+ {"mark": "bar", "encoding": {...}},
490
+ {"mark": "line", "encoding": {...}}
491
+ ]
492
+ }
493
+ ```
494
+
495
+ ### Vertical Concatenation (vconcat)
496
+
497
+ ```json
498
+ {
499
+ "vconcat": [
500
+ {"mark": "bar", "encoding": {...}},
501
+ {"mark": "line", "encoding": {...}}
502
+ ]
503
+ }
504
+ ```
505
+
506
+ ### Facet (Small Multiples)
507
+
508
+ Partition data into sub-plots.
509
+
510
+ ```json
511
+ {
512
+ "facet": {
513
+ "column": { "field": "region", "type": "nominal" },
514
+ "row": { "field": "year", "type": "ordinal" }
515
+ },
516
+ "spec": {
517
+ "mark": "point",
518
+ "encoding": {
519
+ "x": { "field": "x", "type": "quantitative" },
520
+ "y": { "field": "y", "type": "quantitative" }
521
+ }
522
+ }
523
+ }
524
+ ```
525
+
526
+ Or using encoding channels:
527
+
528
+ ```json
529
+ {
530
+ "mark": "bar",
531
+ "encoding": {
532
+ "x": { "field": "value", "type": "quantitative" },
533
+ "y": { "field": "category", "type": "nominal" },
534
+ "column": { "field": "region", "type": "nominal" }
535
+ }
536
+ }
537
+ ```
538
+
539
+ ### Repeat
540
+
541
+ Generate multiple views from a template.
542
+
543
+ ```json
544
+ {
545
+ "repeat": {
546
+ "column": ["temp", "humidity", "pressure"]
547
+ },
548
+ "spec": {
549
+ "mark": "line",
550
+ "encoding": {
551
+ "x": { "field": "date", "type": "temporal" },
552
+ "y": { "field": { "repeat": "column" }, "type": "quantitative" }
553
+ }
554
+ }
555
+ }
556
+ ```
557
+
558
+ **SPLOM (Scatter Plot Matrix):**
559
+
560
+ ```json
561
+ {
562
+ "repeat": {
563
+ "row": ["mpg", "hp", "weight"],
564
+ "column": ["mpg", "hp", "weight"]
565
+ },
566
+ "spec": {
567
+ "mark": "point",
568
+ "encoding": {
569
+ "x": { "field": { "repeat": "column" }, "type": "quantitative" },
570
+ "y": { "field": { "repeat": "row" }, "type": "quantitative" }
571
+ }
572
+ }
573
+ }
574
+ ```
575
+
576
+ ### Resolve
577
+
578
+ Control how scales/axes/legends are shared or independent.
579
+
580
+ ```json
581
+ {
582
+ "layer": [...],
583
+ "resolve": {
584
+ "scale": {"y": "independent"},
585
+ "axis": {"y": "independent"},
586
+ "legend": {"color": "independent"}
587
+ }
588
+ }
589
+ ```
590
+
591
+ ---
592
+
593
+ ## Common Chart Patterns (Complete Working Examples)
594
+
595
+ > All examples use inline data with simple field names to avoid dot-notation issues.
596
+
597
+ ### Horizontal Bar Chart with Value Labels
598
+
599
+ ```json
600
+ {
601
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
602
+ "title": { "text": "Sales by Region", "anchor": "start" },
603
+ "width": 400,
604
+ "height": 200,
605
+ "data": {
606
+ "values": [
607
+ { "region": "North America", "sales": 65000 },
608
+ { "region": "Europe", "sales": 48000 },
609
+ { "region": "Asia Pacific", "sales": 35000 },
610
+ { "region": "Latin America", "sales": 12000 }
611
+ ]
612
+ },
613
+ "layer": [
614
+ { "mark": { "type": "bar", "cornerRadiusEnd": 3 } },
615
+ {
616
+ "mark": { "type": "text", "align": "left", "dx": 5, "fontSize": 11 },
617
+ "encoding": { "text": { "field": "sales", "format": "," } }
618
+ }
619
+ ],
620
+ "encoding": {
621
+ "y": {
622
+ "field": "region",
623
+ "type": "nominal",
624
+ "sort": "-x",
625
+ "title": null,
626
+ "axis": { "labelLimit": 150 }
627
+ },
628
+ "x": { "field": "sales", "type": "quantitative", "title": "Sales ($)" },
629
+ "color": { "value": "#4c78a8" }
630
+ },
631
+ "config": { "view": { "stroke": null } }
632
+ }
633
+ ```
634
+
635
+ ### Vertical Bar Chart (Safer for Many Categories)
636
+
637
+ ```json
638
+ {
639
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
640
+ "width": 450,
641
+ "height": 300,
642
+ "data": {
643
+ "values": [
644
+ { "month": "Jan", "revenue": 4200 },
645
+ { "month": "Feb", "revenue": 3800 },
646
+ { "month": "Mar", "revenue": 5100 }
647
+ ]
648
+ },
649
+ "mark": { "type": "bar", "cornerRadius": 3 },
650
+ "encoding": {
651
+ "x": { "field": "month", "type": "ordinal", "title": null, "axis": { "labelAngle": 0 } },
652
+ "y": {
653
+ "field": "revenue",
654
+ "type": "quantitative",
655
+ "title": "Revenue ($)",
656
+ "scale": { "zero": true }
657
+ }
658
+ }
659
+ }
660
+ ```
661
+
662
+ ### Time Series with Area, Line, Points
663
+
664
+ ```json
665
+ {
666
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
667
+ "title": { "text": "Daily Temperature", "subtitle": "December 2025", "anchor": "start" },
668
+ "width": 600,
669
+ "height": 200,
670
+ "data": {
671
+ "values": [
672
+ { "date": "2025-12-01", "temp": 18.5 },
673
+ { "date": "2025-12-08", "temp": 21.2 },
674
+ { "date": "2025-12-15", "temp": 22.0 },
675
+ { "date": "2025-12-22", "temp": 19.1 },
676
+ { "date": "2025-12-29", "temp": 17.8 }
677
+ ]
678
+ },
679
+ "layer": [
680
+ { "mark": { "type": "area", "opacity": 0.2, "color": "#e45756" } },
681
+ { "mark": { "type": "line", "color": "#e45756", "strokeWidth": 2 } },
682
+ { "mark": { "type": "point", "color": "#e45756", "filled": true, "size": 50 } },
683
+ {
684
+ "mark": { "type": "rule", "strokeDash": [4, 4], "color": "#999" },
685
+ "encoding": { "y": { "datum": 20 } }
686
+ }
687
+ ],
688
+ "encoding": {
689
+ "x": { "field": "date", "type": "temporal", "title": null, "axis": { "format": "%b %d" } },
690
+ "y": {
691
+ "field": "temp",
692
+ "type": "quantitative",
693
+ "title": "Temperature (°C)",
694
+ "scale": { "domain": [15, 25] }
695
+ }
696
+ },
697
+ "config": { "view": { "stroke": null } }
698
+ }
699
+ ```
700
+
701
+ ### Multi-Line Chart with Direct Labels
702
+
703
+ ```json
704
+ {
705
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
706
+ "title": { "text": "Performance by Team", "anchor": "start" },
707
+ "width": 500,
708
+ "height": 280,
709
+ "data": {
710
+ "values": [
711
+ { "date": "2025-01", "team": "Alpha", "score": 72 },
712
+ { "date": "2025-02", "team": "Alpha", "score": 78 },
713
+ { "date": "2025-03", "team": "Alpha", "score": 85 },
714
+ { "date": "2025-01", "team": "Beta", "score": 65 },
715
+ { "date": "2025-02", "team": "Beta", "score": 70 },
716
+ { "date": "2025-03", "team": "Beta", "score": 68 }
717
+ ]
718
+ },
719
+ "layer": [
720
+ {
721
+ "mark": { "type": "line", "strokeWidth": 2.5, "point": { "filled": true, "size": 50 } }
722
+ },
723
+ {
724
+ "transform": [{ "filter": "datum.date == '2025-03'" }],
725
+ "mark": { "type": "text", "align": "left", "dx": 8, "fontSize": 12, "fontWeight": "bold" },
726
+ "encoding": { "text": { "field": "team" } }
727
+ }
728
+ ],
729
+ "encoding": {
730
+ "x": { "field": "date", "type": "ordinal", "title": null },
731
+ "y": {
732
+ "field": "score",
733
+ "type": "quantitative",
734
+ "title": "Score",
735
+ "scale": { "domain": [60, 90] }
736
+ },
737
+ "color": {
738
+ "field": "team",
739
+ "type": "nominal",
740
+ "scale": { "range": ["#4c78a8", "#f58518"] },
741
+ "legend": null
742
+ }
743
+ }
744
+ }
745
+ ```
746
+
747
+ ### Heatmap with Labels
748
+
749
+ ```json
750
+ {
751
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
752
+ "title": { "text": "Activity by Day and Hour", "anchor": "start" },
753
+ "width": 400,
754
+ "height": 250,
755
+ "data": {
756
+ "values": [
757
+ { "day": "Mon", "hour": "9am", "activity": 45 },
758
+ { "day": "Mon", "hour": "12pm", "activity": 78 },
759
+ { "day": "Mon", "hour": "3pm", "activity": 62 },
760
+ { "day": "Tue", "hour": "9am", "activity": 52 },
761
+ { "day": "Tue", "hour": "12pm", "activity": 85 },
762
+ { "day": "Tue", "hour": "3pm", "activity": 70 }
763
+ ]
764
+ },
765
+ "mark": { "type": "rect", "cornerRadius": 2 },
766
+ "encoding": {
767
+ "x": { "field": "hour", "type": "ordinal", "title": null },
768
+ "y": { "field": "day", "type": "nominal", "title": null },
769
+ "color": {
770
+ "field": "activity",
771
+ "type": "quantitative",
772
+ "scale": { "scheme": "blues" },
773
+ "title": "Activity"
774
+ }
775
+ }
776
+ }
777
+ ```
778
+
779
+ ### Grouped Bar Chart
780
+
781
+ ```json
782
+ {
783
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
784
+ "width": 400,
785
+ "height": 250,
786
+ "data": {
787
+ "values": [
788
+ { "category": "A", "group": "2024", "value": 28 },
789
+ { "category": "A", "group": "2025", "value": 35 },
790
+ { "category": "B", "group": "2024", "value": 45 },
791
+ { "category": "B", "group": "2025", "value": 52 }
792
+ ]
793
+ },
794
+ "mark": { "type": "bar", "cornerRadius": 2 },
795
+ "encoding": {
796
+ "x": { "field": "category", "type": "nominal", "title": null },
797
+ "y": { "field": "value", "type": "quantitative", "title": "Value" },
798
+ "xOffset": { "field": "group", "type": "nominal" },
799
+ "color": {
800
+ "field": "group",
801
+ "type": "nominal",
802
+ "title": "Year",
803
+ "scale": { "range": ["#4c78a8", "#72b7b2"] }
804
+ }
805
+ }
806
+ }
807
+ ```
808
+
809
+ ### Stacked Bar Chart
810
+
811
+ ```json
812
+ {
813
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
814
+ "width": 400,
815
+ "height": 250,
816
+ "data": {
817
+ "values": [
818
+ { "month": "Jan", "category": "Product A", "sales": 30 },
819
+ { "month": "Jan", "category": "Product B", "sales": 25 },
820
+ { "month": "Feb", "category": "Product A", "sales": 35 },
821
+ { "month": "Feb", "category": "Product B", "sales": 28 }
822
+ ]
823
+ },
824
+ "mark": "bar",
825
+ "encoding": {
826
+ "x": { "field": "month", "type": "ordinal", "title": null },
827
+ "y": { "field": "sales", "type": "quantitative", "stack": "zero", "title": "Sales" },
828
+ "color": { "field": "category", "type": "nominal", "title": null }
829
+ }
830
+ }
831
+ ```
832
+
833
+ ### Scatter Plot with Trend Line
834
+
835
+ ```json
836
+ {
837
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
838
+ "width": 400,
839
+ "height": 300,
840
+ "data": {
841
+ "values": [
842
+ { "x": 1, "y": 2.1 },
843
+ { "x": 2, "y": 3.8 },
844
+ { "x": 3, "y": 4.2 },
845
+ { "x": 4, "y": 5.5 },
846
+ { "x": 5, "y": 6.1 },
847
+ { "x": 6, "y": 7.9 }
848
+ ]
849
+ },
850
+ "layer": [
851
+ {
852
+ "mark": { "type": "point", "filled": true, "size": 60, "opacity": 0.7 }
853
+ },
854
+ {
855
+ "mark": { "type": "line", "color": "firebrick", "strokeWidth": 2 },
856
+ "transform": [{ "regression": "y", "on": "x" }]
857
+ }
858
+ ],
859
+ "encoding": {
860
+ "x": { "field": "x", "type": "quantitative", "title": "X Variable" },
861
+ "y": { "field": "y", "type": "quantitative", "title": "Y Variable" }
862
+ }
863
+ }
864
+ ```
865
+
866
+ ### Box Plot
867
+
868
+ ```json
869
+ {
870
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
871
+ "width": 400,
872
+ "height": 250,
873
+ "data": {
874
+ "values": [
875
+ { "category": "A", "value": 10 },
876
+ { "category": "A", "value": 15 },
877
+ { "category": "A", "value": 20 },
878
+ { "category": "A", "value": 25 },
879
+ { "category": "B", "value": 30 },
880
+ { "category": "B", "value": 35 },
881
+ { "category": "B", "value": 40 },
882
+ { "category": "B", "value": 45 }
883
+ ]
884
+ },
885
+ "mark": "boxplot",
886
+ "encoding": {
887
+ "x": { "field": "category", "type": "nominal", "title": null },
888
+ "y": { "field": "value", "type": "quantitative", "title": "Value" }
889
+ }
890
+ }
891
+ ```
892
+
893
+ ### ❌ AVOID: Donut Chart
894
+
895
+ Use sorted bar chart instead - see "Never Use Pie or Donut Charts" in Best Practices.
896
+
897
+ ---
898
+
899
+ ## Advanced Techniques
900
+
901
+ ### Layered Area with Line and Points
902
+
903
+ ```json
904
+ {
905
+ "layer": [
906
+ {
907
+ "mark": { "type": "area", "opacity": 0.3 },
908
+ "encoding": {
909
+ "x": { "field": "date", "type": "temporal" },
910
+ "y": { "field": "value", "type": "quantitative" }
911
+ }
912
+ },
913
+ {
914
+ "mark": { "type": "line" },
915
+ "encoding": {
916
+ "x": { "field": "date", "type": "temporal" },
917
+ "y": { "field": "value", "type": "quantitative" }
918
+ }
919
+ },
920
+ {
921
+ "mark": { "type": "point", "filled": true },
922
+ "encoding": {
923
+ "x": { "field": "date", "type": "temporal" },
924
+ "y": { "field": "value", "type": "quantitative" }
925
+ }
926
+ }
927
+ ]
928
+ }
929
+ ```
930
+
931
+ ### Line with Trend Overlay
932
+
933
+ ```json
934
+ {
935
+ "layer": [
936
+ {
937
+ "mark": { "type": "point", "opacity": 0.3 },
938
+ "encoding": {
939
+ "x": { "field": "x", "type": "quantitative" },
940
+ "y": { "field": "y", "type": "quantitative" }
941
+ }
942
+ },
943
+ {
944
+ "mark": { "type": "line", "color": "firebrick" },
945
+ "transform": [{ "regression": "y", "on": "x" }],
946
+ "encoding": {
947
+ "x": { "field": "x", "type": "quantitative" },
948
+ "y": { "field": "y", "type": "quantitative" }
949
+ }
950
+ }
951
+ ]
952
+ }
953
+ ```
954
+
955
+ ### Dual-Axis Chart
956
+
957
+ ```json
958
+ {
959
+ "layer": [
960
+ {
961
+ "mark": "bar",
962
+ "encoding": {
963
+ "x": { "field": "date", "type": "temporal" },
964
+ "y": { "field": "precipitation", "type": "quantitative", "title": "Precipitation (mm)" }
965
+ }
966
+ },
967
+ {
968
+ "mark": { "type": "line", "color": "firebrick" },
969
+ "encoding": {
970
+ "x": { "field": "date", "type": "temporal" },
971
+ "y": { "field": "temperature", "type": "quantitative", "title": "Temperature (°C)" }
972
+ }
973
+ }
974
+ ],
975
+ "resolve": { "scale": { "y": "independent" } }
976
+ }
977
+ ```
978
+
979
+ ### Faceted Dashboard
980
+
981
+ ```json
982
+ {
983
+ "vconcat": [
984
+ {
985
+ "hconcat": [
986
+ {
987
+ "repeat": {"row": ["x1", "x2"], "column": ["x1", "x2"]},
988
+ "spec": {
989
+ "mark": "point",
990
+ "encoding": {
991
+ "x": {"field": {"repeat": "column"}, "type": "quantitative"},
992
+ "y": {"field": {"repeat": "row"}, "type": "quantitative"}
993
+ }
994
+ }
995
+ },
996
+ {
997
+ "repeat": {"row": ["y1", "y2"]},
998
+ "spec": {
999
+ "layer": [
1000
+ {"mark": "bar", "encoding": {...}},
1001
+ {"mark": "rule", "encoding": {...}}
1002
+ ]
1003
+ }
1004
+ }
1005
+ ]
1006
+ },
1007
+ {
1008
+ "facet": {"column": {"field": "category"}},
1009
+ "spec": {"mark": "bar", "encoding": {...}}
1010
+ }
1011
+ ]
1012
+ }
1013
+ ```
1014
+
1015
+ ---
1016
+
1017
+ ## Best Practices
1018
+
1019
+ ### 1. Never Use Pie or Donut Charts
1020
+
1021
+ Humans cannot accurately compare arc lengths or angles. **Always use sorted bar charts instead.**
1022
+
1023
+ ```json
1024
+ // ❌ AVOID - pie/donut charts
1025
+ {"mark": {"type": "arc", "innerRadius": 50}}
1026
+
1027
+ // ✅ USE - sorted horizontal bar chart
1028
+ {
1029
+ "mark": "bar",
1030
+ "encoding": {
1031
+ "y": {"field": "category", "sort": "-x"},
1032
+ "x": {"field": "value"}
1033
+ }
1034
+ }
1035
+ ```
1036
+
1037
+ ### 2. Use Color to Encode Data, Not Decorate
1038
+
1039
+ Color should highlight, not decorate:
1040
+
1041
+ - **Single series = single color** (don't add rainbow gradients)
1042
+ - Reserve color for encoding **meaningful data dimensions** (e.g., floor level, status)
1043
+ - Prefer **sequential** schemes for quantitative data
1044
+ - Use **categorical** schemes only for nominal data (≤10 categories)
1045
+
1046
+ **Good - color encodes floor:**
1047
+
1048
+ ```json
1049
+ "color": {"field": "floor", "scale": {"domain": ["Ground", "Basement"], "range": ["#4c78a8", "#72b7b2"]}}
1050
+ ```
1051
+
1052
+ **Bad - color is decoration:**
1053
+
1054
+ ```json
1055
+ "color": {"field": "category", "scale": {"scheme": "rainbow"}}
1056
+ ```
1057
+
1058
+ ### 3. Annotate Values Directly on Bars
1059
+
1060
+ Don't make readers estimate from axis - show the number:
1061
+
1062
+ ```json
1063
+ {
1064
+ "layer": [
1065
+ { "mark": "bar" },
1066
+ {
1067
+ "mark": { "type": "text", "align": "left", "dx": 5, "fontSize": 11 },
1068
+ "encoding": { "text": { "field": "value", "format": "," } }
1069
+ }
1070
+ ]
1071
+ }
1072
+ ```
1073
+
1074
+ ### 4. Sort by Value, Not Alphabetically
1075
+
1076
+ ```json
1077
+ "encoding": {
1078
+ "y": {"field": "category", "sort": "-x"}
1079
+ }
1080
+ ```
1081
+
1082
+ ### 5. Use Proper Aspect Ratios
1083
+
1084
+ | Chart Type | Aspect Ratio | Example |
1085
+ | ------------ | ------------------------ | ------------------------- |
1086
+ | Time series | 3:1 to 4:1 (wide) | `width: 600, height: 200` |
1087
+ | Bar chart | Height per category | 8 bars → `height: 300` |
1088
+ | Heatmap | Based on data dimensions | Match row/column count |
1089
+ | Scatter plot | Square or slight wide | `width: 400, height: 350` |
1090
+
1091
+ ```json
1092
+ // ❌ WRONG - time series too tall
1093
+ {"width": 400, "height": 600}
1094
+
1095
+ // ✅ CORRECT - time series wide
1096
+ {"width": 600, "height": 200}
1097
+ ```
1098
+
1099
+ ### 6. Direct Label Instead of Legends
1100
+
1101
+ Legends force eye movement. Label directly on the chart:
1102
+
1103
+ ```json
1104
+ {
1105
+ "layer": [
1106
+ {
1107
+ "mark": { "type": "line", "strokeWidth": 2 },
1108
+ "encoding": { "color": { "field": "series", "legend": null } }
1109
+ },
1110
+ {
1111
+ "mark": { "type": "text", "align": "left", "dx": 8, "fontWeight": "bold" },
1112
+ "transform": [{ "filter": "datum.date == '2025-12-29'" }],
1113
+ "encoding": {
1114
+ "x": { "field": "date", "type": "temporal" },
1115
+ "y": { "field": "value", "type": "quantitative" },
1116
+ "text": { "field": "series" },
1117
+ "color": { "field": "series", "legend": null }
1118
+ }
1119
+ }
1120
+ ]
1121
+ }
1122
+ ```
1123
+
1124
+ ### 7. Add Reference Lines for Context
1125
+
1126
+ Help readers interpret values:
1127
+
1128
+ ```json
1129
+ {
1130
+ "layer": [
1131
+ {"mark": "bar", "encoding": {...}},
1132
+ {
1133
+ "mark": {"type": "rule", "strokeDash": [4, 4], "color": "#999"},
1134
+ "encoding": {"y": {"datum": 20}}
1135
+ },
1136
+ {
1137
+ "mark": {"type": "text", "align": "left", "dx": 5, "color": "#666"},
1138
+ "encoding": {
1139
+ "y": {"datum": 20},
1140
+ "text": {"value": "Target: 20"}
1141
+ }
1142
+ }
1143
+ ]
1144
+ }
1145
+ ```
1146
+
1147
+ ### 8. Include Titles and Subtitles
1148
+
1149
+ Explain what the reader is looking at:
1150
+
1151
+ ```json
1152
+ "title": {
1153
+ "text": "Temperature by Room",
1154
+ "subtitle": "Daily averages, December 2025",
1155
+ "anchor": "start",
1156
+ "fontSize": 16,
1157
+ "subtitleFontSize": 12,
1158
+ "subtitleColor": "#666"
1159
+ }
1160
+ ```
1161
+
1162
+ ### 9. Use Small Multiples Over Complexity
1163
+
1164
+ Instead of overloading one chart with many encodings, use faceting:
1165
+
1166
+ ```json
1167
+ {
1168
+ "mark": "line",
1169
+ "encoding": {
1170
+ "column": { "field": "region", "type": "nominal" },
1171
+ "x": { "field": "date", "type": "temporal" },
1172
+ "y": { "field": "value", "type": "quantitative" }
1173
+ }
1174
+ }
1175
+ ```
1176
+
1177
+ ---
1178
+
1179
+ ## Configuration
1180
+
1181
+ ### Global Config
1182
+
1183
+ ```json
1184
+ {
1185
+ "config": {
1186
+ "view": { "stroke": null, "continuousWidth": 400, "continuousHeight": 300 },
1187
+ "axis": { "labelFontSize": 12, "titleFontSize": 14 },
1188
+ "title": { "fontSize": 16, "fontWeight": "bold" },
1189
+ "legend": { "labelFontSize": 11, "titleFontSize": 12 },
1190
+ "bar": { "color": "#4c78a8" },
1191
+ "line": { "strokeWidth": 2 },
1192
+ "point": { "size": 60 }
1193
+ }
1194
+ }
1195
+ ```
1196
+
1197
+ ### Typography
1198
+
1199
+ ```json
1200
+ "config": {
1201
+ "font": "Helvetica Neue",
1202
+ "axis": {
1203
+ "labelFont": "Helvetica Neue",
1204
+ "titleFont": "Helvetica Neue"
1205
+ }
1206
+ }
1207
+ ```
1208
+
1209
+ ---
1210
+
1211
+ ## Professional Theming
1212
+
1213
+ Consistent theming across charts creates a professional, cohesive look. These themes align with the Graphviz theming in this skill.
1214
+
1215
+ ### Visual Design Principles
1216
+
1217
+ Three approaches to visualization design, each suited to different audiences:
1218
+
1219
+ #### Minimalist (Data-Ink Ratio)
1220
+
1221
+ Maximize data, minimize non-data ink:
1222
+
1223
+ - Remove decorative elements that don't encode information
1224
+ - Use position and length as primary encodings (most accurate)
1225
+ - Avoid chartjunk (3D effects, gradients, unnecessary fills)
1226
+ - Every pixel should convey data
1227
+
1228
+ **When to apply:** Technical documentation, data-dense dashboards, publications.
1229
+
1230
+ **Limitation:** Can be too sparse for audiences who need visual anchors.
1231
+
1232
+ #### Communicative (Functional Clarity)
1233
+
1234
+ Emphasize communication over minimalism:
1235
+
1236
+ - **Functional decoration is not chartjunk** — grid lines, reference marks aid reading
1237
+ - **Redundant encoding for critical data** — alerts need color + size + position
1238
+ - **Know your audience** — label for the least technical viewer
1239
+ - **Guide the eye** — create visual hierarchy that leads to the insight
1240
+ - **Title states the insight** — not just what the chart shows, but what it means
1241
+
1242
+ **When to apply:** Operational dashboards, presentations to mixed audiences, alerting systems.
1243
+
1244
+ #### Modern SaaS (Clean Professional)
1245
+
1246
+ Clean, professional design for modern tooling:
1247
+
1248
+ - **White space is design** — let elements breathe
1249
+ - **One accent color** — gray scale for structure, color for emphasis only
1250
+ - **No gradients, no shadows** — flat, honest design
1251
+ - **Typography carries hierarchy** — weight and size, not decoration
1252
+ - **Thin lines, subtle gridlines** — `#e5e5e5` not black
1253
+
1254
+ **When to apply:** Product documentation, engineering blogs, modern dashboards.
1255
+
1256
+ #### Choosing an Approach
1257
+
1258
+ | Audience | Approach | Style |
1259
+ | ---------------------- | ------------- | ------------------------- |
1260
+ | Engineers reading docs | Minimalist | Sparse, data-dense |
1261
+ | Ops team monitoring | Communicative | Clear, redundant encoding |
1262
+ | Product/stakeholders | Modern SaaS | Clean, professional |
1263
+ | Print/publication | Minimalist | High information density |
1264
+
1265
+ ### Light Theme
1266
+
1267
+ ```json
1268
+ {
1269
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
1270
+ "background": "white",
1271
+ "config": {
1272
+ "font": "Arial",
1273
+ "title": {
1274
+ "color": "#171717",
1275
+ "subtitleColor": "#737373",
1276
+ "fontSize": 16,
1277
+ "subtitleFontSize": 12,
1278
+ "anchor": "start"
1279
+ },
1280
+ "axis": {
1281
+ "labelColor": "#525252",
1282
+ "titleColor": "#525252",
1283
+ "gridColor": "#e5e5e5",
1284
+ "domainColor": "#d4d4d4",
1285
+ "tickColor": "#d4d4d4"
1286
+ },
1287
+ "legend": {
1288
+ "labelColor": "#525252",
1289
+ "titleColor": "#525252"
1290
+ },
1291
+ "view": { "stroke": null }
1292
+ }
1293
+ }
1294
+ ```
1295
+
1296
+ **Light palette:**
1297
+
1298
+ | Element | Color | Usage |
1299
+ | -------------- | --------- | ------------------------- |
1300
+ | Background | `#ffffff` | Chart background |
1301
+ | Primary text | `#171717` | Titles, labels |
1302
+ | Secondary text | `#525252` | Axis labels, annotations |
1303
+ | Muted text | `#737373` | Subtitles, secondary info |
1304
+ | Grid lines | `#e5e5e5` | Subtle grid |
1305
+ | Domain/tick | `#d4d4d4` | Axis lines |
1306
+ | Normal data | `#a3a3a3` | Default bars, lines |
1307
+ | Error/alert | `#dc2626` | Problem indicators |
1308
+ | Warning | `#f97316` | Threshold lines |
1309
+
1310
+ ### Dark Theme
1311
+
1312
+ ```json
1313
+ {
1314
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
1315
+ "background": "#0a0a0a",
1316
+ "config": {
1317
+ "font": "Arial",
1318
+ "title": {
1319
+ "color": "#e5e5e5",
1320
+ "subtitleColor": "#a3a3a3",
1321
+ "fontSize": 16,
1322
+ "subtitleFontSize": 12,
1323
+ "anchor": "start"
1324
+ },
1325
+ "axis": {
1326
+ "labelColor": "#a3a3a3",
1327
+ "titleColor": "#a3a3a3",
1328
+ "gridColor": "#262626",
1329
+ "domainColor": "#404040",
1330
+ "tickColor": "#404040"
1331
+ },
1332
+ "legend": {
1333
+ "labelColor": "#a3a3a3",
1334
+ "titleColor": "#a3a3a3"
1335
+ },
1336
+ "view": { "stroke": null }
1337
+ }
1338
+ }
1339
+ ```
1340
+
1341
+ **Dark palette:**
1342
+
1343
+ | Element | Color | Usage |
1344
+ | -------------- | --------- | ----------------------------------------- |
1345
+ | Background | `#0a0a0a` | Chart background |
1346
+ | Primary text | `#e5e5e5` | Titles, labels |
1347
+ | Secondary text | `#a3a3a3` | Axis labels, annotations |
1348
+ | Muted text | `#737373` | Secondary info |
1349
+ | Grid lines | `#262626` | Subtle grid |
1350
+ | Domain/tick | `#404040` | Axis lines |
1351
+ | Normal data | `#525252` | Default bars, lines |
1352
+ | Error/alert | `#f87171` | Problem indicators (lighter for contrast) |
1353
+ | Warning | `#fb923c` | Threshold lines |
1354
+
1355
+ ### Color Encoding Strategy
1356
+
1357
+ Use color sparingly and consistently:
1358
+
1359
+ ```json
1360
+ {
1361
+ "color": {
1362
+ "condition": { "test": "datum.error_rate > 5", "value": "#dc2626" },
1363
+ "value": "#a3a3a3"
1364
+ }
1365
+ }
1366
+ ```
1367
+
1368
+ **Rules:**
1369
+
1370
+ 1. **Gray is the default** — only use color when it encodes meaning
1371
+ 2. **Red for errors only** — don't dilute its meaning
1372
+ 3. **One accent per chart** — avoid rainbow palettes
1373
+ 4. **Consistent across dashboard** — same colors mean same things
1374
+
1375
+ ### Title with Insight
1376
+
1377
+ Don't just label what the chart shows—state what it means:
1378
+
1379
+ ```json
1380
+ {
1381
+ "title": {
1382
+ "text": "Service Error Rates",
1383
+ "subtitle": "telegram-bot exceeds 5% SLO threshold",
1384
+ "color": "#171717",
1385
+ "subtitleColor": "#737373",
1386
+ "anchor": "start"
1387
+ }
1388
+ }
1389
+ ```
1390
+
1391
+ **Bad:** "Error Rate by Service"
1392
+ **Good:** "telegram-bot exceeds 5% SLO threshold"
1393
+
1394
+ ### Reference Lines for Context
1395
+
1396
+ Add threshold/target lines to give context:
1397
+
1398
+ ```json
1399
+ {
1400
+ "layer": [
1401
+ {
1402
+ "data": {"values": [{"threshold": 5}]},
1403
+ "mark": {"type": "rule", "strokeDash": [4, 4], "strokeWidth": 1.5},
1404
+ "encoding": {
1405
+ "y": {"field": "threshold", "type": "quantitative"},
1406
+ "color": {"value": "#f97316"}
1407
+ }
1408
+ },
1409
+ {
1410
+ "data": {"values": [{"threshold": 5, "label": "5% SLO"}]},
1411
+ "mark": {"type": "text", "align": "right", "dy": -8, "fontSize": 10},
1412
+ "encoding": {
1413
+ "y": {"field": "threshold", "type": "quantitative"},
1414
+ "x": {"value": "width"},
1415
+ "text": {"field": "label"},
1416
+ "color": {"value": "#f97316"}
1417
+ }
1418
+ },
1419
+ {
1420
+ "mark": "bar",
1421
+ "encoding": {...}
1422
+ }
1423
+ ]
1424
+ }
1425
+ ```
1426
+
1427
+ ### Direct Labeling over Legends
1428
+
1429
+ Place labels directly on data points instead of using legends:
1430
+
1431
+ ```json
1432
+ {
1433
+ "layer": [
1434
+ {
1435
+ "mark": { "type": "line", "strokeWidth": 2 },
1436
+ "encoding": {
1437
+ "color": { "field": "service", "legend": null }
1438
+ }
1439
+ },
1440
+ {
1441
+ "mark": { "type": "text", "align": "left", "dx": 8, "fontSize": 11 },
1442
+ "transform": [{ "filter": "datum.time == 'final_point'" }],
1443
+ "encoding": {
1444
+ "text": { "field": "service" },
1445
+ "color": { "field": "service", "legend": null }
1446
+ }
1447
+ }
1448
+ ]
1449
+ }
1450
+ ```
1451
+
1452
+ ### Value Labels on Bars
1453
+
1454
+ Add the actual values as text marks:
1455
+
1456
+ ```json
1457
+ {
1458
+ "layer": [
1459
+ {
1460
+ "mark": { "type": "bar", "cornerRadiusEnd": 3 },
1461
+ "encoding": {
1462
+ "y": { "field": "service", "sort": "-x" },
1463
+ "x": { "field": "count" }
1464
+ }
1465
+ },
1466
+ {
1467
+ "mark": { "type": "text", "align": "left", "dx": 5, "fontSize": 11 },
1468
+ "encoding": {
1469
+ "y": { "field": "service", "sort": "-x" },
1470
+ "x": { "field": "count" },
1471
+ "text": { "field": "count", "format": ",.0f" },
1472
+ "color": { "value": "#525252" }
1473
+ }
1474
+ }
1475
+ ]
1476
+ }
1477
+ ```
1478
+
1479
+ ### Complete Example: Service Health Dashboard
1480
+
1481
+ ```json
1482
+ {
1483
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
1484
+ "title": {
1485
+ "text": "Service Error Rates",
1486
+ "subtitle": "telegram-bot exceeds 5% SLO threshold",
1487
+ "color": "#171717",
1488
+ "subtitleColor": "#737373",
1489
+ "fontSize": 16,
1490
+ "subtitleFontSize": 12,
1491
+ "anchor": "start"
1492
+ },
1493
+ "width": 450,
1494
+ "height": 180,
1495
+ "background": "white",
1496
+ "config": {
1497
+ "font": "Arial",
1498
+ "axis": {
1499
+ "labelColor": "#525252",
1500
+ "titleColor": "#525252",
1501
+ "gridColor": "#f5f5f5",
1502
+ "domainColor": "#d4d4d4",
1503
+ "tickColor": "#d4d4d4"
1504
+ },
1505
+ "view": { "stroke": null }
1506
+ },
1507
+ "layer": [
1508
+ {
1509
+ "data": { "values": [{ "threshold": 5 }] },
1510
+ "mark": { "type": "rule", "strokeDash": [4, 4], "strokeWidth": 1.5 },
1511
+ "encoding": {
1512
+ "x": { "field": "threshold", "type": "quantitative" },
1513
+ "color": { "value": "#f97316" }
1514
+ }
1515
+ },
1516
+ {
1517
+ "data": {
1518
+ "values": [
1519
+ { "service": "telegram-bot", "error_rate": 8.05 },
1520
+ { "service": "web-api", "error_rate": 0.33 },
1521
+ { "service": "web-client", "error_rate": 0.23 },
1522
+ { "service": "mcp-server", "error_rate": 0.16 }
1523
+ ]
1524
+ },
1525
+ "mark": { "type": "bar", "cornerRadiusEnd": 3 },
1526
+ "encoding": {
1527
+ "y": {
1528
+ "field": "service",
1529
+ "type": "nominal",
1530
+ "sort": "-x",
1531
+ "title": null,
1532
+ "axis": { "labelLimit": 150 }
1533
+ },
1534
+ "x": {
1535
+ "field": "error_rate",
1536
+ "type": "quantitative",
1537
+ "title": "Error Rate %",
1538
+ "scale": { "domain": [0, 10] }
1539
+ },
1540
+ "color": {
1541
+ "condition": { "test": "datum.error_rate > 5", "value": "#dc2626" },
1542
+ "value": "#a3a3a3"
1543
+ }
1544
+ }
1545
+ },
1546
+ {
1547
+ "data": {
1548
+ "values": [
1549
+ { "service": "telegram-bot", "error_rate": 8.05 },
1550
+ { "service": "web-api", "error_rate": 0.33 },
1551
+ { "service": "web-client", "error_rate": 0.23 },
1552
+ { "service": "mcp-server", "error_rate": 0.16 }
1553
+ ]
1554
+ },
1555
+ "mark": { "type": "text", "align": "left", "dx": 5, "fontSize": 11 },
1556
+ "encoding": {
1557
+ "y": { "field": "service", "sort": "-x" },
1558
+ "x": { "field": "error_rate" },
1559
+ "text": { "field": "error_rate", "format": ".2f" },
1560
+ "color": {
1561
+ "condition": { "test": "datum.error_rate > 5", "value": "#dc2626" },
1562
+ "value": "#525252"
1563
+ }
1564
+ }
1565
+ }
1566
+ ]
1567
+ }
1568
+ ```
1569
+
1570
+ ### Quick Reference: Quality Checklist
1571
+
1572
+ Before finalizing any chart, verify:
1573
+
1574
+ - [ ] **Title states the insight** (not just what the chart shows)
1575
+ - [ ] **Subtitle provides context** if needed
1576
+ - [ ] **One accent color** — red for errors, gray for normal
1577
+ - [ ] **Sort bars by value** (`sort: "-x"`) not alphabetically
1578
+ - [ ] **Value labels** on bars for precise reading
1579
+ - [ ] **Direct labels** on lines instead of legends
1580
+ - [ ] **Reference lines** for thresholds/targets with labels
1581
+ - [ ] **Adequate dimensions** — 3:1 for time series, height for bar charts
1582
+ - [ ] **Simple field names** — no dots in field names
1583
+ - [ ] **Consistent theming** — same colors mean same things across charts
1584
+
1585
+ ## References
1586
+
1587
+ - [Vega-Lite Documentation](https://vega.github.io/vega-lite/docs/)
1588
+ - [Vega-Lite Examples](https://vega.github.io/vega-lite/examples/)
1589
+ - [UW Visualization Curriculum](https://idl.uw.edu/visualization-curriculum/intro.html)
1590
+ - [Altair Documentation](https://altair-viz.github.io/)
1591
+
1592
+ ### Visual Design References
1593
+
1594
+ - _Sémiologie Graphique_ (1967) — semiotics of graphics, visual variables
1595
+ - _The Visual Display of Quantitative Information_ (1983) — data-ink ratio, minimalist approach
1596
+ - _The Functional Art_, _How Charts Lie_ — clarity over minimalism, communicative approach
1597
+ - _Visualization Analysis and Design_ (2014) — systematic approach to visualization design