---
slug: 37-dataviz
title: CSV, estadística y gráficos
description: Import/export CSV (RFC 4180), estadística descriptiva (median, percentile, histogram), gráficos SVG nativos server-side — incluyendo gráficos que los agentes leen como datos — y export PNG/PDF.
example_ids: [dataviz]
---

# CSV, estadística y gráficos

```synsema
-- Doc example: CSV parsing, descriptive statistics and native SVG charts (all pure).
intent: "doc example: csv, stats and charts"

let raw be "month,total\njan,10\nfeb,25\nmar,17\napr,25\n"
let rows be csv_parse(raw, {"numbers": true})
let svg be chart_svg("bar", rows, {"x": "month", "y": "total", "title": "Sales"})
print("rows = " + text(length(rows)) + ",  median = " + text(median([10, 25, 17, 25])) + ",  svg = " + text(starts_with(svg, "<svg")))

test "csv_parse: rows are maps (same shape sql() returns), lossless by default"
    let plain be csv_parse("a,b\n00123,x\n")
    assert_eq(plain[0]["a"], "00123")
    assert_eq(type_of(plain[0]["a"]), "text")
    assert_eq(rows[1]["total"], 25)

test "csv round-trip survives embedded commas and quotes"
    let tricky be [{"name": "a,b", "note": "say \"hi\""}]
    assert_eq(csv_parse(csv_encode(tricky)), tricky)

test "median / percentile / histogram (NumPy semantics)"
    assert_eq(median([1, 2, 3, 4]), 2.5)
    assert_eq(percentile([1, 2, 3, 4], 25), 1.75)
    let h be histogram(range(10), 5)
    assert_eq(h["counts"], [2, 2, 2, 2, 2])
    assert_eq(length(h["edges"]), 6)

test "chart_svg is deterministic and escapes data text"
    assert(contains(svg, "<svg"))
    assert_eq(svg, chart_svg("bar", rows, {"x": "month", "y": "total", "title": "Sales"}))
    let hostile be chart_svg("pie", {"<b>x</b>": 4, "y": 2})
    assert(contains(hostile, "&lt;b&gt;"))

test "chart errors are clear and catchable"
    let msg be ""
    try
        chart_svg("bar", [])
    recover e
        set msg to e
    assert(contains(msg, "data is empty"))

test "every chart option, exercised: title/x_label/y_label/legend/colors/width/height/background"
    let multi be [{"m": "a", "sales": 10, "costs": 6}, {"m": "b", "sales": 25, "costs": 12}]
    let full be chart_svg("bar", multi, {
        "x": "m", "y": ["sales", "costs"],
        "title": "Sales vs costs", "x_label": "Month", "y_label": "USD",
        "legend": true, "colors": ["#112233", "#445566"],
        "width": 400, "height": 300, "background": "#fcfcfb"
    })
    assert(contains(full, "Sales vs costs"))
    assert(contains(full, "Month") and contains(full, "USD"))
    assert(contains(full, "sales") and contains(full, "costs"))
    assert(contains(full, "#112233") and contains(full, "#445566"))
    assert(contains(full, "viewBox=\"0 0 400 300\""))
    -- legend: false apaga los nombres de serie; el resto de las formas de datos:
    assert(contains(chart_svg("line", multi, {"x": "m", "y": "sales", "legend": false}), ">sales<") == false)
    assert(contains(chart_svg("pie", {"a": 3, "b": 1}), "<path"))
    assert(contains(chart_svg("line", [1, 2, 3]), "<polyline"))
    assert(contains(chart_svg("scatter", [[1, 2], [3, 4]]), "<circle"))

test "csv options, exercised: headers/delimiter/numbers/eol"
    let semi be csv_parse("a;b\n1;2\n", {"delimiter": ";", "numbers": true})
    assert_eq(semi[0]["b"], 2)
    let positional be csv_parse("1,2\n3,4\n", {"headers": false})
    assert_eq(positional[1][0], "3")
    assert_eq(csv_encode([{"a": "1", "b": "2"}], {"headers": ["b"], "eol": "\n"}), "b\n2\n")

test "histogram bins: integer or explicit edges (last bin closed, out-of-range dropped)"
    assert_eq(histogram([1, 2, 3, 4], [0, 2, 4])["counts"], [1, 3])
    assert_eq(histogram([-5, 1, 99], [0, 2])["counts"], [1])

test "business kinds: area/heatmap/histogram/boxplot/donut/waterfall + stack"
    let multi be [{"m": "a", "sales": 10, "costs": 6}, {"m": "b", "sales": 25, "costs": 12}]
    -- stack is an OPT on bar/area (there is no "stacked_bar" kind)
    assert(contains(chart_svg("bar", multi, {"x": "m", "y": ["sales", "costs"], "stack": true}), "<rect"))
    assert(contains(chart_svg("area", multi, {"x": "m", "y": ["sales", "costs"], "stack": true}), "<polygon"))
    -- heatmap: tidy rows (x/y/value) or a matrix (+ x_labels/y_labels); scale auto/sequential/diverging
    let cells be [{"d": "mon", "h": "9", "v": 1}, {"d": "mon", "h": "10", "v": 5}, {"d": "tue", "h": "9", "v": 9}]
    assert(contains(chart_svg("heatmap", cells, {"x": "h", "y": "d", "value": "v"}), "<rect"))
    let diverging be chart_svg("heatmap", [[-8, 0], [4, 9]], {"x_labels": ["q1", "q2"], "y_labels": ["a", "b"], "scale": "diverging", "center": 0})
    assert(contains(diverging, "<rect"))
    -- histogram plots raw numbers OR the exact map histogram() returns (same binning)
    let data be [1, 2, 2, 3, 3, 3, 9]
    assert_eq(chart_svg("histogram", data, {"bins": 4}), chart_svg("histogram", histogram(data, 4)))
    -- boxplot: >= 2 values per group; quartiles match percentile()
    assert(contains(chart_svg("boxplot", {"web": [1, 2, 3, 4, 100], "tel": [2, 3, 8, 9]}), "<circle"))
    -- donut = pie with a hole (same rules); waterfall takes DELTAS, total is opt-in
    assert(contains(chart_svg("donut", {"a": 4, "b": 2}), "<path"))
    assert(contains(chart_svg("waterfall", {"sales": 100, "costs": -40}, {"total": true}), ">Total<"))

test "theme dark + kind/opt validation errors are clear and catchable"
    let multi be [{"m": "a", "sales": 10, "costs": 6}, {"m": "b", "sales": 25, "costs": 12}]
    let dark be chart_svg("bar", multi, {"x": "m", "y": "sales", "theme": "dark"})
    assert(contains(dark, "<svg"))
    assert(dark != chart_svg("bar", multi, {"x": "m", "y": "sales"}))
    let msg be ""
    try
        chart_svg("treemap", [1])
    recover e
        set msg to e
    assert(contains(msg, "valid kinds are: area, bar, boxplot, donut, heatmap, histogram, line, pie, scatter, waterfall"))
    set msg to ""
    try
        chart_svg("pie", {"a": 1}, {"stack": true})
    recover e
        set msg to e
    assert(contains(msg, "bar, area"))
    set msg to ""
    try
        chart_svg("heatmap", [[1]], {"center": 5})
    recover e
        set msg to e
    assert(contains(msg, "diverging"))

test "svg_to_png / svg_to_pdf: every option, real deterministic bytes"
    let png be svg_to_png(svg, {"scale": 2})
    assert(contains(decode(png, "hex"), "89504e470d0a1a0a"))
    assert_eq(png, svg_to_png(svg, {"scale": 2}))
    let sized be svg_to_png(svg, {"width": 320, "background": "#ffffff", "max_pixels": 1000000})
    assert(contains(decode(sized, "hex"), "89504e470d0a1a0a"))
    let pdf be svg_to_pdf(svg, {"width": 400})
    assert(contains(decode(pdf, "hex"), "255044462d"))
    -- el techo anti-DoS avisa nombrando la opción:
    let msg be ""
    try
        svg_to_png(svg, {"width": 10000000})
    recover e
        set msg to e
    assert(contains(msg, "max_pixels"))
```

El pipeline de reportes es nativo y **puro** (sin capability — también funciona dentro de `sandbox`): datos → agregación → gráfico. Es **agnóstico de la fuente de datos**: todo consume valores planos, así que las filas de `sql()`, `mongo_find`, `csv_parse` o un literal se grafican igual.

## CSV

`csv_parse(text, opts?)` devuelve una **lista de mapas** (primera fila = cabeceras — la misma forma que devuelve `sql()`), así que un CSV alimenta `group_by`/gráficos directamente. RFC 4180 completo: campos entre comillas, comas/saltos embebidos, escapes `""`, CRLF/LF, BOM.

```synsema
let rows be csv_parse(read_file("sales.csv"), {"numbers": true})
write_file("out.csv", csv_encode(rows))
```

Todas las opciones (cada nombre de abajo está verificado por el doctest):

| Opción | Dónde | Significado |
|---|---|---|
| `"headers"` | parse | `false` → lista de listas (todas las filas son datos). Default `true`: primera fila = cabeceras → lista de mapas |
| `"headers"` | encode | lista de nombres de columna → orden y subconjunto de columnas |
| `"delimiter"` | ambos | un carácter ASCII, p. ej. `";"` o `"\t"` (default `","`) |
| `"numbers"` | parse | `true` → los campos con pinta numérica se vuelven números. El default es **texto lossless** (`"00123"` queda texto) |
| `"eol"` | encode | `"\n"` o el default `"\r\n"` (compatible con Excel) |

`csv_encode` toma una lista de mapas (cabeceras = las claves del primer mapa, en su orden) o una lista de listas. Los enteros se codifican sin decimales, `nothing` → campo vacío, `bytes` → base64, y un `secret` se codifica como `[redacted]` — jamás el plaintext. Los errores traen la línea (comilla sin cerrar, campos desparejos, cabeceras duplicadas, opción desconocida) y se atrapan con `try`/`recover`.

## Estadística descriptiva

`median(x)`, `percentile(x, p)` (interpolación lineal, `p` 0–100; `percentile(x, 50) == median(x)`) e `histogram(x, bins?)` funcionan sobre una lista de números o un `array` numérico, con semántica NumPy. `bins` es un entero (default 10, equiespaciado sobre `[min, max]`) **o** una lista explícita de bordes crecientes; el resultado es `{"counts": [..], "edges": [..]}` con `length(edges) == length(counts) + 1`, el último bin cerrado, y los valores fuera de rango se descartan cuando los bordes son explícitos. Datos vacíos o NaN → error claro, nunca un resultado basura silencioso.

## Gráficos

`chart_svg(kind, data, opts?)` devuelve **texto SVG plano** — incrustalo con `{ raw svg }` en un template de `render()`, servilo con `respond(svg, "image/svg+xml")` o guardalo con `write_file`. Determinista: mismo input, salida idéntica byte a byte.

Kinds (un kind desconocido da error listando exactamente este set): `"area"`, `"bar"`, `"boxplot"`, `"donut"`, `"heatmap"`, `"histogram"`, `"line"`, `"pie"`, `"scatter"`, `"waterfall"`. **No existe un kind `"stacked_bar"`**: el apilado es la opción `{"stack": true}` sobre `bar`/`area`.

Formas de datos: lista de mapas + `{"x": "campo", "y": "campo"}` (multi-serie: `"y": [..]`; heatmap usa `{"x", "y", "value"}`; boxplot agrupa por `x`), mapa label→valor (bar/line/area/pie/donut; en **waterfall el valor es el DELTA**, no el acumulado; en boxplot es label→**lista** de números), lista de números (bar/line/area/histogram, x = índice; boxplot = una sola caja), pares `[x, y]` (line/scatter/area), `array` 1-D, **matriz** (lista de listas o `array` 2-D — solo heatmap; filas = y, columnas = x, con `x_labels`/`y_labels` opcionales), o el mapa `{"counts", "edges"}` que devuelve `histogram()` (solo el kind histogram, que comparte el binning con el builtin). Pie/donut toman una sola serie de valores no negativos.

Opciones comunes a todos los kinds (cada nombre está verificado por el doctest):

| Opción | Significado |
|---|---|
| `"title"` | título del gráfico (también el `<title>` accesible del SVG) |
| `"x"` / `"y"` | nombres de campo cuando data es lista de mapas; `"y"` puede ser una lista → una serie por campo. Con cualquier otra forma de datos dan error en vez de ignorarse en silencio |
| `"x_label"` / `"y_label"` | labels de los ejes |
| `"legend"` | `true`/`false`; default: se muestra automáticamente con ≥2 series o pie/donut. En heatmap la leyenda es la barra de gradiente |
| `"width"` / `"height"` | lienzo en px (defaults 640×360) |
| `"colors"` | lista de colores hex que **reemplaza** la paleta default. En heatmap: ≥2 stops del gradiente; en waterfall: `[sube, baja, total]` |
| `"background"` | relleno hex (default: transparente) |
| `"theme"` | `"light"` (default) o `"dark"` — series aclaradas, tinta/grid/escalas para fondo oscuro. Un `"colors"` explícito le gana al tema |

Opciones por kind (usarlas en el kind equivocado da error nombrando cuáles sí las aceptan; una opción desconocida da error listando todas las válidas — un typo nunca pasa en silencio):

| Kind | Opciones extra | Semántica |
|---|---|---|
| `bar`, `area` | `"stack"` | `true` apila las series. Bar: positivos hacia arriba, negativos hacia abajo. Area apilada con signos mezclados en un mismo x → error (sugiere bar apilado). Una sola serie + stack = no-op inofensivo |
| `boxplot` | — (`x`/`y` = grupo/valor) | Tukey: caja q1–q3 (la misma interpolación lineal de `percentile()`), bigotes a 1,5×IQR, outliers como puntos. **Mínimo 2 valores por grupo** |
| `heatmap` | `"value"`, `"x_labels"`, `"y_labels"`, `"scale"`, `"center"` | `"scale"`: `"auto"` (default: secuencial si todos los valores tienen el mismo signo; divergente centrada en 0 si cruzan el cero), `"sequential"` o `"diverging"`. `"center"` **exige** `{"scale": "diverging"}` explícito. Celda ausente (forma tidy) = transparente; celda duplicada → error |
| `histogram` | `"bins"` | entero (default 10) o lista de bordes crecientes — un float como `4.0` da error. `chart_svg("histogram", datos, {"bins": n})` produce el mismo SVG que `chart_svg("histogram", histogram(datos, n))` |
| `waterfall` | `"total"` | los valores son **deltas**; el acumulado se calcula solo. `true` agrega la barra "Total" (o pasá un texto como label). Delta 0 es válido. Colores semánticos CVD-safe: azul sube / naranja baja / tinta el total (no verde/rojo — sobreescribible con `"colors"`) |

Los defaults incluyen una paleta de 8 colores segura para daltonismo en orden fijo (más de 8 series/slices es un **error** — los colores nunca se ciclan; agrupá en "Other" o pasá `{"colors": [..]}`), un solo eje Y, barras que siempre incluyen el cero, y escapado XSS-safe de todo texto de datos. NaN/infinitos en los datos ploteados → error claro. Un `secret` como label sale `[redacted]`; como valor numérico es error de tipo.

## Gráficos que los agentes pueden leer

Dentro de `content()`, el nodo `chart(...)` **negocia por cliente** — misma URL:

- **HTML** → el SVG inline
- **Markdown** (`.md` o `Accept: text/markdown`) → el título del gráfico + una **tabla con los datos**
- **JSON** (`.json`) → `{"type": "chart", "kind", "title", ...}` + los campos de datos del kind

Salida exacta para agentes, por kind (las **cabeceras de las tablas Markdown están en inglés** aunque tus datos no lo estén — son output de runtime, estables para parsear):

| Kind | Markdown | Campos JSON |
|---|---|---|
| bar/line/pie/scatter/area/donut | tabla: columna x + una por serie | `"series": [{"name", "points": [[x, y], ..]}]` (+ `"stack": true` solo si está apilado) |
| heatmap | **matriz**: filas = y_labels, columnas = x_labels | `"x_labels"`, `"y_labels"`, `"values": [[..]]` (celda ausente = `null`) |
| histogram | `\| range \| count \|` — rangos `[a, b)`, el último `[a, b]` | `"counts"`, `"edges"` |
| boxplot | `\| group \| min \| q1 \| median \| q3 \| max \| outliers \|` | `"groups": [{"name", "min", "q1", "median", "q3", "max", "outliers": [..]}]` |
| waterfall | `\| label \| delta \| running \|` (+ fila de total si se pidió) | `"steps": [{"label", "delta", "running"}]`, `"total"` |

```synsema
route "GET /report/:name"
    let rows be sql("SELECT month, total FROM sales ORDER BY month")
    give content(page([
        heading(1, "Sales 2026"),
        chart("bar", rows, {"x": "month", "y": "total", "title": "Sales by month"})
    ], {"title": "Report"}))
```

Un humano ve el gráfico; un agente que pide `.md` obtiene los números. Ningún otro lenguaje hace esto de forma nativa.

## Export PNG / PDF

`svg_to_png(svg, opts?)` y `svg_to_pdf(svg, opts?)` convierten **cualquier texto SVG** (un gráfico, un SVG a mano) a `bytes` — para adjuntos de email, descargas o impresión:

```synsema
write_file("report.png", svg_to_png(svg, {"scale": 2}))    -- necesita file.write
route "GET /report.pdf"
    give binary(svg_to_pdf(svg), "application/pdf")
```

- Opts de PNG (todas verificadas por el doctest): `"width"`/`"height"` en px (una sola mantiene el aspecto), `"scale"` (p. ej. `2` para retina — entra en conflicto con width/height, error explícito), `"background"` (hex; default transparente), `"max_pixels"` (techo de seguridad sobreescribible, default ~16,7M — el error nombra la opción).
- Opts de PDF: `"width"`/`"height"` en puntos — una sola escala proporcionalmente; las dos juntas deben respetar el aspecto del SVG (error claro si no). El PDF es **vectorial** de una página: nítido a cualquier zoom.
- Determinista: una fuente sans embebida (DejaVu Sans), así el texto rasteriza idéntico en toda plataforma. Mismo input → salida idéntica byte a byte.
- Seguro por construcción: un `<script>` embebido jamás se ejecuta, y un `<image href>` externo (URLs o rutas locales) **nunca se descarga** — un builtin puro no toca red ni disco. Ambos son puros, así que también funcionan dentro de `sandbox`.
- Límites honestos: el PNG es el estado *estático* (scripts/animaciones se ignoran); las familias de fuente desconocidas caen a la embebida; los glifos que le faltan (CJK completo, emoji de color) salen como tofu.
