Synsema docsENES

CSV, stats & charts

dataviz.syn
-- 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"))

The report pipeline is native and pure (no capability — it also works inside sandbox): data → aggregate → chart. It is data-source-agnostic: everything consumes plain values, so rows from sql(), mongo_find, csv_parse or a literal all plot the same.

CSV

csv_parse(text, opts?) returns a list of maps (first row = headers — the same shape sql() returns), so a CSV feeds group_by/charts directly. Full RFC 4180: quoted fields, embedded commas/newlines, "" escapes, CRLF/LF, BOM.

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

All options (every name below is doctest-verified):

OptionWhereMeaning
"headers"parsefalse → list of lists (all rows are data). Default true: first row = headers → list of maps
"headers"encodelist of column names → order and subset of columns
"delimiter"bothsingle ASCII char, e.g. ";" or "\t" (default ",")
"numbers"parsetrue → numeric-looking fields become numbers. Default is lossless text ("00123" stays text)
"eol"encode"\n" or the default "\r\n" (Excel-friendly)

csv_encode takes a list of maps (headers = the first map's keys, in order) or a list of lists. Integers encode without decimals, nothing → empty field, bytes → base64, and a secret encodes as [redacted] — never the plaintext. Errors carry the line (unclosed quote, uneven fields, duplicate headers, unknown option) and are catchable with try/recover.

Descriptive statistics

median(x), percentile(x, p) (linear interpolation, p 0–100; percentile(x, 50) == median(x)) and histogram(x, bins?) work on a list of numbers or a numeric array, with NumPy semantics. bins is an integer (default 10, equispaced over [min, max]) or an explicit ascending edge list; the result is {"counts": [..], "edges": [..]} with length(edges) == length(counts) + 1, the last bin closed, and out-of-range values dropped when edges are explicit. Empty data or NaN → a clear error, never a silent garbage result.

Charts

chart_svg(kind, data, opts?) returns plain SVG text — embed it with { raw svg } in a render() template, serve it with respond(svg, "image/svg+xml"), or save it with write_file. Deterministic: same input, byte-identical output.

Kinds (an unknown kind errors listing exactly this set): "area", "bar", "boxplot", "donut", "heatmap", "histogram", "line", "pie", "scatter", "waterfall". There is no "stacked_bar" kind: stacking is the {"stack": true} option on bar/area.

Data shapes: list of maps + {"x": "field", "y": "field"} (multi-series: "y": [..]; heatmap takes {"x", "y", "value"}; boxplot groups by x), map of label→value (bar/line/area/pie/donut; in waterfall the value is the DELTA, not the running total; in boxplot it is label→list of numbers), list of numbers (bar/line/area/histogram, x = index; boxplot = a single box), [x, y] pairs (line/scatter/area), a 1-D array, a matrix (list of lists or 2-D array — heatmap only; rows = y, columns = x, with optional x_labels/y_labels), or the {"counts", "edges"} map that histogram() returns (histogram kind only — it shares the builtin's binning). Pie/donut take one series of non-negative values.

Options common to all kinds (every name below is doctest-verified):

OptionMeaning
"title"chart title (also the SVG's accessible <title>)
"x" / "y"field names when data is a list of maps; "y" may be a list → one series per field. On any other data shape they error instead of being silently ignored
"x_label" / "y_label"axis labels
"legend"true/false; default: shown automatically for ≥2 series or pie/donut. On heatmap the legend is the gradient bar
"width" / "height"canvas in px (defaults 640×360)
"colors"list of hex colors that replaces the default palette. Heatmap: ≥2 gradient stops; waterfall: [up, down, total]
"background"hex fill (default: transparent)
"theme""light" (default) or "dark" — brightened series, dark-ready ink/grid/scales. An explicit "colors" beats the theme

Per-kind options (using one on the wrong kind errors naming the kinds that take it; an unknown option errors listing all valid ones — a typo never passes silently):

KindExtra optionsSemantics
bar, area"stack"true stacks the series. Bar: positives stack up, negatives down. Stacked area with mixed signs at one x → error (suggests stacked bar). Single series + stack = harmless no-op
boxplot— (x/y = group/value)Tukey: box q1–q3 (the same linear interpolation as percentile()), whiskers at 1.5×IQR, outliers as dots. At least 2 values per group
heatmap"value", "x_labels", "y_labels", "scale", "center""scale": "auto" (default: sequential when all values share a sign; diverging centered on 0 when they cross it), "sequential" or "diverging". "center" requires an explicit {"scale": "diverging"}. Missing cell (tidy form) = transparent; duplicate cell → error
histogram"bins"integer (default 10) or ascending edge list — a float like 4.0 errors. chart_svg("histogram", data, {"bins": n}) produces the same SVG as chart_svg("histogram", histogram(data, n))
waterfall"total"values are deltas; the running total is computed for you. true appends the "Total" bar (or pass a text label). Delta 0 is valid. Semantic CVD-safe colors: blue up / orange down / ink total (not green/red — override with "colors")

Defaults include a colorblind-safe 8-color palette in fixed order (more than 8 series/slices is an error — colors are never cycled; group into "Other" or pass {"colors": [..]}), a single y-axis, bars that always include zero, and XSS-safe escaping of all data text. NaN/infinite values in plotted data → a clear error. A secret as a label renders [redacted]; as a numeric value it is a type error.

Charts agents can read

Inside content(), the chart(...) node negotiates per client — same URL:

Exact agent-facing output per kind (Markdown table headers are in English even when your data is not — they are runtime output, stable to parse):

KindMarkdownJSON data fields
bar/line/pie/scatter/area/donuttable: x column + one per series"series": [{"name", "points": [[x, y], ..]}] (+ "stack": true only when stacked)
heatmapmatrix: rows = y_labels, columns = x_labels"x_labels", "y_labels", "values": [[..]] (missing cell = null)
histogram`\range \count \ — ranges [a, b), last one [a, b]`"counts", "edges"
boxplot`\group \min \q1 \median \q3 \max \outliers \`"groups": [{"name", "min", "q1", "median", "q3", "max", "outliers": [..]}]
waterfall`\label \delta \running \` (+ total row if requested)"steps": [{"label", "delta", "running"}], "total"
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"}))

A human sees the chart; an agent fetching .md gets the numbers. No other language does this natively.

PNG / PDF export

svg_to_png(svg, opts?) and svg_to_pdf(svg, opts?) convert any SVG text (a chart, a handwritten SVG) to bytes — for email attachments, downloads, or printing:

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