← All ivy-nodes
IVYXSTUDIO · IVY NODE
D

Design: charts as SVG

ivy.node.design-chart-svg · v0.1.0

ivyx✓

Draws bar or line charts of rows as SVG, one per chart asked for: axes with nice ticks, labels, a legend, the values on the bars. The monthly report's figures as pictures; no model, no network.

#design#chart#svg#report#agency

Inputs

FieldTypeDescription
chartsarrayOne object per chart: title, rows, x, series, kind (bar, line), size, caption.
rowsarrayFor a single chart: the rows.
xstringNot declared
seriesarrayNot declared
kindstringNot declared
titlestringNot declared
sizestringWxH; 900x500 when omitted.
paletteobjectbg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair.
name_prefixstringNot declared

Outputs

FieldTypeDescription
filesrequiredarrayThe files made, each with its bytes as base64.
countrequiredintegerNot declared
skippedintegerNot declared

Source

python

# Input preparation
inp = __ivy_ctx__["nodes"][__ivy_node_id__]["input"]

# Compute
# Shared by every design node (built into each by build.mjs): colours, text, files.
import base64, re, math

SAFE_PALETTES = [
    {"bg": "#1E1B4B", "accent": "#F6A23B", "text": "#F8F7FF"},
    {"bg": "#F4F2FF", "accent": "#6C3BFF", "text": "#1E1B4B"},
    {"bg": "#0F3D3E", "accent": "#E8C547", "text": "#F3F7F6"},
    {"bg": "#FFF4E6", "accent": "#D9480F", "text": "#2B1D12"},
    {"bg": "#101418", "accent": "#3DDC97", "text": "#F2F4F6"},
]

def hex_ok(s):
    return isinstance(s, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", s.strip()) is not None

def palette_of(p, seed=0):
    """A palette with three valid hex colours; what the model got wrong is replaced from a safe pair."""
    base = SAFE_PALETTES[abs(int(seed)) % len(SAFE_PALETTES)]
    out = {}
    p = p if isinstance(p, dict) else {}
    for k in ("bg", "accent", "text"):
        v = p.get(k)
        out[k] = v.strip().upper() if hex_ok(v) else base[k]
    if out["bg"].upper() == out["text"].upper():
        out["text"] = base["text"] if base["text"].upper() != out["bg"].upper() else base["bg"]
    return out

def rgb(h):
    h = h.lstrip("#")
    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))

def mix(a, b, t):
    return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))

def to_hex(c):
    return "#%02X%02X%02X" % c

def lum(h):
    r, g, b = [x / 255 for x in rgb(h)]
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def esc(s):
    return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))

def text_of(v, limit, default=""):
    s = " ".join(str(v).split()) if isinstance(v, (str, int, float)) else ""
    if not s:
        s = default
    return s[:limit].rstrip() if len(s) > limit else s

def wrap(s, max_chars):
    words = s.split()
    lines, cur = [], ""
    for w in words:
        if cur and len(cur) + 1 + len(w) > max_chars:
            lines.append(cur)
            cur = w
        else:
            cur = (cur + " " + w).strip()
    if cur:
        lines.append(cur)
    return lines or [""]

def b64(data):
    return base64.b64encode(data).decode("ascii")

def file_entry(name, mime, data, kind, width=None, height=None, caption=None, variant=None, extra=None):
    f = {"name": name, "mime": mime, "kind": kind, "bytes": len(data), "data": b64(data)}
    if width:
        f["width"] = int(width)
    if height:
        f["height"] = int(height)
    if caption:
        f["caption"] = caption
    if variant:
        f["variant"] = variant
    if extra:
        f.update(extra)
    return f

def slug(s):
    s = re.sub(r"[^a-z0-9]+", "-", str(s).lower()).strip("-")
    return s[:40] or "design"

def font():
    return "Inter, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"

# design-chart-svg: bar or line charts of rows as SVG, one per chart asked for; the monthly report's figures.
import math


class DesignChartSvg:
    "Design: charts as SVG"

    def run(self, inp):
        out = {}
        pal = palette_of(inp.get("palette"), 1)
        FF = font()
        charts = inp.get("charts") if isinstance(inp.get("charts"), list) else [inp]
        prefix = slug(inp.get("name_prefix") or "chart")
        SERIES_COLOURS = [pal["accent"], "#6C3BFF", "#3DDC97", "#F472B6", "#60A5FA", "#F59E0B"]

        def nice(v):
            if v <= 0:
                return 1.0
            p = 10 ** math.floor(math.log10(v))
            f = v / p
            n = 1 if f <= 1 else 2 if f <= 2 else 5 if f <= 5 else 10
            return n * p

        def fmt(v):
            if abs(v) >= 1000:
                return "{:,.0f}".format(v)
            if float(v).is_integer():
                return "%d" % v
            return "%.1f" % v

        def chart_svg(c, idx):
            rows = [r for r in (c.get("rows") or []) if isinstance(r, dict)]
            title = text_of(c.get("title"), 80, "Chart %d" % (idx + 1))
            kind = c.get("kind") if c.get("kind") in ("bar", "line") else "bar"
            try:
                w, h = [int(x) for x in str(c.get("size") or inp.get("size") or "900x500").lower().split("x")]
            except Exception:
                w, h = 900, 500
            if not rows:
                return None, title, w, h
            keys = list(rows[0].keys())
            x = c.get("x") if c.get("x") in keys else keys[0]
            series = [s for s in (c.get("series") or []) if s in keys and s != x] or [k for k in keys if k != x and isinstance(rows[0].get(k), (int, float)) and not isinstance(rows[0].get(k), bool)][:4]
            if not series:
                return None, title, w, h
            vals = [float(r.get(s) or 0) for r in rows for s in series]
            top = nice(max(vals + [1]))
            while top < max(vals + [1]):
                top *= 2
            pad_l, pad_r, pad_t, pad_b = int(w * 0.09), int(w * 0.04), int(h * 0.16), int(h * 0.16)
            pw, ph = w - pad_l - pad_r, h - pad_t - pad_b
            is_dark = lum(pal["bg"]) < 0.5
            grid = "#FFFFFF" if is_dark else "#000000"
            out = ['<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">' % (w, h, w, h, esc(title))]
            out.append('<title>%s</title>' % esc(title))
            out.append('<rect width="%d" height="%d" fill="%s"/>' % (w, h, pal["bg"]))
            out.append('<text x="%d" y="%d" font-family="%s" font-size="%d" font-weight="700" fill="%s">%s</text>' % (pad_l, int(pad_t * 0.55), FF, int(h * 0.05), pal["text"], esc(title)))
            ticks = 4
            for i in range(ticks + 1):
                v = top * i / ticks
                y = pad_t + ph - ph * i / ticks
                out.append('<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-opacity="0.12"/>' % (pad_l, y, w - pad_r, y, grid))
                out.append('<text x="%d" y="%.1f" text-anchor="end" font-family="%s" font-size="%d" fill="%s" opacity="0.7">%s</text>' % (pad_l - 8, y + 4, FF, int(h * 0.026), pal["text"], fmt(v)))
            n = len(rows)
            slot = pw / max(n, 1)
            for i, r in enumerate(rows):
                label = text_of(r.get(x), 14, "")
                out.append('<text x="%.1f" y="%d" text-anchor="middle" font-family="%s" font-size="%d" fill="%s" opacity="0.8">%s</text>' % (pad_l + slot * (i + 0.5), h - pad_b + int(h * 0.05), FF, int(h * 0.026), pal["text"], esc(label)))
            if kind == "bar":
                bw = slot * 0.7 / len(series)
                for si, s in enumerate(series):
                    col = SERIES_COLOURS[si % len(SERIES_COLOURS)]
                    for i, r in enumerate(rows):
                        v = float(r.get(s) or 0)
                        bh = ph * v / top if top else 0
                        bx = pad_l + slot * i + slot * 0.15 + bw * si
                        out.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="3" fill="%s"/>' % (bx, pad_t + ph - bh, bw - 2, bh, col))
                        if n <= 8:
                            out.append('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="%s" font-size="%d" fill="%s" opacity="0.85">%s</text>' % (bx + bw / 2 - 1, pad_t + ph - bh - 6, FF, int(h * 0.024), pal["text"], fmt(v)))
            else:
                for si, s in enumerate(series):
                    col = SERIES_COLOURS[si % len(SERIES_COLOURS)]
                    pts = []
                    for i, r in enumerate(rows):
                        v = float(r.get(s) or 0)
                        pts.append((pad_l + slot * (i + 0.5), pad_t + ph - ph * v / top))
                    out.append('<polyline fill="none" stroke="%s" stroke-width="%.1f" stroke-linejoin="round" stroke-linecap="round" points="%s"/>' % (col, max(2, h * 0.006), " ".join("%.1f,%.1f" % p for p in pts)))
                    for p in pts:
                        out.append('<circle cx="%.1f" cy="%.1f" r="%.1f" fill="%s"/>' % (p[0], p[1], max(3, h * 0.009), col))
            lx = pad_l
            for si, s in enumerate(series):
                col = SERIES_COLOURS[si % len(SERIES_COLOURS)]
                out.append('<rect x="%d" y="%d" width="12" height="12" rx="3" fill="%s"/>' % (lx, int(pad_t * 0.72), col))
                out.append('<text x="%d" y="%d" font-family="%s" font-size="%d" fill="%s" opacity="0.85">%s</text>' % (lx + 18, int(pad_t * 0.72) + 11, FF, int(h * 0.028), pal["text"], esc(s)))
                lx += 30 + int(len(s) * h * 0.028 * 0.58)
            out.append("</svg>")
            return "\n".join(out), title, w, h

        files = []
        for i, c in enumerate(charts):
            svg, title, w, h = chart_svg(c if isinstance(c, dict) else {}, i)
            if svg is None:
                continue
            files.append(file_entry("%s-%d-%s.svg" % (prefix, i + 1, slug(title)), "image/svg+xml", svg.encode("utf-8"), "vector", w, h, text_of(c.get("caption") if isinstance(c, dict) else None, 160, title), "chart"))
        out = {"files": files, "count": len(files), "skipped": len(charts) - len(files)}
        return out


result = DesignChartSvg().run(inp)

# Output collection (the runner reads it here)
out = __ivy_ctx__["nodes"][__ivy_node_id__]["output"]
out.update(result)

Tests

Requires: python:3.9

  • one-of-two

    A chart with rows is drawn at 900 by 500 and a chart with none is counted as skipped.

  • nothing

    No rows is no picture, said as one skipped chart.