← 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
| Field | Type | Description |
|---|---|---|
| charts | array | One object per chart: title, rows, x, series, kind (bar, line), size, caption. |
| rows | array | For a single chart: the rows. |
| x | string | Not declared |
| series | array | Not declared |
| kind | string | Not declared |
| title | string | Not declared |
| size | string | WxH; 900x500 when omitted. |
| palette | object | bg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair. |
| name_prefix | string | Not declared |
Outputs
| Field | Type | Description |
|---|---|---|
| filesrequired | array | The files made, each with its bytes as base64. |
| countrequired | integer | Not declared |
| skipped | integer | Not 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """))
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.