/* graphsvg.du -- Pure Duso SVG chart generation Work in progress. Needs some love, but it's already useful. */ var CW = 1200 var CH = 800 function style() return { ac = "#5FBB46", cc = "#000000", pc = "#212121", bc = "#ffffff", ff = "'Noto Sans', Lato, Helvetica, Arial, sans-serif", ts = 36, als = 20, dls = 20, alw = 2, dlw = 7, tlw = 1, zlw = 1, dds = 4, tks = 8, mt = 100, mb = 100, ml = 160, mr = 40, alp = 30 } end function line(x1, y1, x2, y2, c, w) return '' end function rect(x, y, w, h, f, s, sw) return '' end function circ(cx, cy, r, f, s, sw) return '' end function txt(x, y, t, c, a, ff, s) t = replace(t, "&", "&") t = replace(t, "<", "<") t = replace(t, ">", ">") t = replace(t, "\"", """) return '{{t}}' end function poly(pts, c, w, f) var ps = [] var i = 0 while i < len(pts) do push(ps, floor(pts[i]) + "," + floor(pts[i+1])) i = i + 2 end var pa = join(ps, " ") return '' end function path(d, f, s, sw) return '' end function minmax(v, step) if not step then step = 1 end if len(v) == 0 then return {mn = 0, mx = 1} end var mn = v[step - 1] var mx = v[step - 1] var i = step - 1 + step while i < len(v) do if v[i] < mn then mn = v[i] end if v[i] > mx then mx = v[i] end i = i + step end return {mn = mn, mx = mx} end // Helper: setup xy-coordinate graph with axes function xy_graph(sp, st, mn, mx) var p = [rect(0, 0, CW, CH, st.bc, "none", 0)] if sp.title then push(p,txt(CW/2, st.mt/2, sp.title, st.pc, "middle", st.ff, st.ts)) end var gw = CW - st.ml - st.mr var gh = CH - st.mt - st.mb var x1 = st.ml var x2 = CW - st.mr var y1 = st.mt var y2 = CH - st.mb push(p,line(x1, y1, x1, y2, st.pc, st.alw)) push(p,line(x1, y2, x2, y2, st.pc, st.alw)) var yt = [mn, (mn + mx) / 2, mx] for val in yt do var y = y2 - ((val - mn) / (mx - mn)) * gh push(p,line(x1 - st.tks, y, x1, y, st.pc, st.tlw)) var lbl = floor(val * 10) / 10 push(p,txt(x1 - st.tks - st.alp, y + 5, "" + lbl, st.pc, "end", st.ff, st.als)) end return {p = p, gw = gw, gh = gh, x1 = x1, x2 = x2, y1 = y1, y2 = y2, mn = mn, mx = mx} end function lin_graph(sp, st) if len(sp.data) == 0 then throw("No data") end var r = minmax(sp.data) var vr = r.mx - r.mn if vr == 0 then vr = 1 end var pd = vr * 0.1 var pmn = (r.mn >= 0 and r.mn - pd < 0) ? 0 : r.mn - pd var pmx = (r.mx <= 0 and r.mx + pd > 0) ? 0 : r.mx + pd var g = xy_graph(sp, st, pmn, pmx) var pts = [] var k = 0 while k < len(sp.data) do var rat = 0 if len(sp.data) > 1 then rat = k / (len(sp.data) - 1) end var x = g.x1 + rat * g.gw var y = g.y2 - ((sp.data[k] - pmn) / (pmx - pmn)) * g.gh push(pts, x) push(pts, y) k = k + 1 end push(g.p, poly(pts, st.ac, st.dlw, "none")) k = 0 while k < len(sp.data) do rat = 0 if len(sp.data) > 1 then rat = k / (len(sp.data) - 1) end var x = g.x1 + rat * g.gw var y = g.y2 - ((sp.data[k] - pmn) / (pmx - pmn)) * g.gh push(g.p, circ(x, y, st.dds, st.ac, "none", 0)) k = k + 1 end if sp.axis then if sp.axis[0] then push(g.p, txt(st.ml, CH - st.mb + st.alp + 20, sp.axis[0], st.pc, "start", st.ff, st.als)) end if sp.axis[1] then push(g.p, txt(st.ml - st.alp - 20, st.mt + 50, sp.axis[1], st.pc, "middle", st.ff, st.als)) end end return join(g.p, "\n") end function bar_graph(sp, st) if len(sp.data) % 2 != 0 then throw("Need label,value pairs") end if len(sp.data) == 0 then throw("No data") end var r = minmax(sp.data, 2) var mn = r.mn < 0 ? r.mn * 1.1 : 0 var mx = r.mx * 1.1 var g = xy_graph(sp, st, mn, mx) var bs = 10 var aw = g.gw - 2 * bs var nc = len(sp.data) / 2 var tbw = aw / nc var bw = tbw - bs var b = 0 while b < nc do var bx = g.x1+ bs + b * tbw + bs / 2 var zy = g.y2 - ((0 - mn) / (mx - mn)) * g.gh var vy = g.y2 - ((sp.data[b*2+1] - mn) / (mx - mn)) * g.gh var bh = zy - vy var by = vy if bh < 0 then by = zy bh = -bh end push(g.p, rect(bx, by, bw, bh, st.ac, "none", 0)) var lx = bx + bw / 2 var ly = g.y2 + st.alp + 10 push(g.p, txt(lx, ly, sp.data[b*2], st.pc, "middle", st.ff, st.als)) b = b + 1 end return join(g.p, "\n") end function area_graph(sp, st) if len(sp.data) == 0 then throw("No data") end var r = minmax(sp.data) var vr = r.mx - r.mn if vr == 0 then vr = 1 end var pd = vr * 0.1 var pmn = (r.mn >= 0 and r.mn - pd < 0) ? 0 : r.mn - pd var pmx = (r.mx <= 0 and r.mx + pd > 0) ? 0 : r.mx + pd var g = xy_graph(sp, st, pmn, pmx) // Build polygon for filled area var pts = [] var k = 0 while k < len(sp.data) do var rat = 0 if len(sp.data) > 1 then rat = k / (len(sp.data) - 1) end var x = g.x1 +rat * g.gw var y = g.y2 -((sp.data[k] - pmn) / (pmx - pmn)) * g.gh push(pts, x) push(pts, y) k = k + 1 end // Add baseline points to close polygon k = len(sp.data) - 1 while k >= 0 do rat = 0 if len(sp.data) > 1 then rat = k / (len(sp.data) - 1) end var x = g.x1 +rat * g.gw push(pts, x) push(pts, g.y2) k = k - 1 end push(g.p,poly(pts, st.ac, 0, st.ac)) // Draw outline k = 0 var ops = [] while k < len(sp.data) do rat = 0 if len(sp.data) > 1 then rat = k / (len(sp.data) - 1) end var x = g.x1 +rat * g.gw var y = g.y2 -((sp.data[k] - pmn) / (pmx - pmn)) * g.gh push(ops, x) push(ops, y) k = k + 1 end push(g.p,poly(ops, st.ac, st.dlw, "none")) if sp.axis then if sp.axis[0] then push(g.p,txt(st.ml, CH - st.mb + st.alp + 20, sp.axis[0], st.pc, "start", st.ff, st.als)) end if sp.axis[1] then push(g.p,txt(st.ml - st.alp - 20, st.mt + 50, sp.axis[1], st.pc, "middle", st.ff, st.als)) end end return join(g.p, "\n") end function scatter_graph(sp, st) if len(sp.data) % 2 != 0 then throw("Need x,y pairs") end if len(sp.data) == 0 then throw("No data") end var xv = [] var yv = [] var i = 0 while i < len(sp.data) do push(xv, sp.data[i]) push(yv, sp.data[i+1]) i = i + 2 end var xr = minmax(xv) var yr = minmax(yv) var xmn = xr.mn - (xr.mx - xr.mn) * 0.1 var xmx = xr.mx + (xr.mx - xr.mn) * 0.1 var ymn = yr.mn - (yr.mx - yr.mn) * 0.1 var ymx = yr.mx + (yr.mx - yr.mn) * 0.1 var g = xy_graph(sp, st, ymn, ymx) var xt = [xmn, (xmn + xmx) / 2, xmx] for val in xt do var x = g.x1 + ((val - xmn) / (xmx - xmn)) * g.gw push(g.p,line(x, g.y2, x, g.y2 + st.tks, st.pc, st.tlw)) var lbl = floor(val * 10) / 10 push(g.p,txt(x, g.y2 + st.alp + 10, "" + lbl, st.pc, "middle", st.ff, st.als)) end var k = 0 while k < len(xv) do var x = g.x1 + ((xv[k] - xmn) / (xmx - xmn)) * g.gw var y = g.y2 - ((yv[k] - ymn) / (ymx - ymn)) * g.gh push(g.p,circ(x, y, st.dds * 2, st.ac, "none", 0)) k = k + 1 end if sp.axis then if sp.axis[0] then push(g.p,txt(CW/2, CH - 20, sp.axis[0], st.pc, "middle", st.ff, st.als)) end if sp.axis[1] then push(g.p,txt(30, st.mt + 50, sp.axis[1], st.pc, "middle", st.ff, st.als)) end end return join(g.p, "\n") end function hbar_graph(sp, st) if len(sp.data) % 2 != 0 then throw("Need label,value pairs") end if len(sp.data) == 0 then throw("No data") end var r = minmax(sp.data, 2) var mn = r.mn < 0 ? r.mn * 1.1 : 0 var mx = r.mx * 1.1 var g = xy_graph(sp, st, mn, mx) var xt = [mn, (mn + mx) / 2, mx] var j = 0 while j < len(xt) do var val = xt[j] var x = g.x1 + ((val - mn) / (mx - mn)) * g.gw push(g.p,line(x, g.y2, x, g.y2 + st.tks, st.pc, st.tlw)) var lbl = floor(val * 10) / 10 push(g.p,txt(x, g.y2 + st.alp + 10, "" + lbl, st.pc, "middle", st.ff, st.als)) j = j + 1 end var bs = 10 var ah = g.gh - 2 * bs var nc = len(sp.data) / 2 var tbh = ah / nc var bh = tbh - bs var eg = bs var b = 0 while b < nc do var by = g.y1 + eg + b * tbh + bs / 2 var zx = g.x1 + ((0 - mn) / (mx - mn)) * g.gw var vx = g.x1 + ((sp.data[b*2+1] - mn) / (mx - mn)) * g.gw var bw = vx - zx var bx = zx if bw < 0 then bx = vx bw = -bw end push(g.p,rect(bx, by, bw, bh, st.ac, "none", 0)) var ly = by + bh / 2 var lx = g.x1 - st.alp push(g.p,txt(lx, ly + 5, sp.data[b*2], st.pc, "end", st.ff, st.als)) b = b + 1 end return join(g.p, "\n") end function bubble_graph(sp, st) // Data: x, y, size triplets if len(sp.data) % 3 != 0 then throw("Need x,y,size triplets") end var xv = [] var yv = [] var sz = [] var i = 0 while i < len(sp.data) do push(xv, sp.data[i]) push(yv, sp.data[i+1]) push(sz, sp.data[i+2]) i = i + 3 end if len(xv) == 0 then throw("No data") end var xr = minmax(xv) var yr = minmax(yv) var sr = minmax(sz) var xmn = xr.mn - (xr.mx - xr.mn) * 0.1 var xmx = xr.mx + (xr.mx - xr.mn) * 0.1 var ymn = yr.mn - (yr.mx - yr.mn) * 0.1 var ymx = yr.mx + (yr.mx - yr.mn) * 0.1 var g = xy_graph(sp, st, ymn, ymx) var xt = [xmn, (xmn + xmx) / 2, xmx] var j = 0 while j < len(xt) do var val = xt[j] var x = g.x1 + ((val - xmn) / (xmx - xmn)) * g.gw push(g.p,line(x, g.y2, x, g.y2 + st.tks, st.pc, st.tlw)) push(g.p,txt(x, g.y2 + st.alp + 10, "" + floor(val * 10) / 10, st.pc, "middle", st.ff, st.als)) j = j + 1 end var k = 0 while k < len(xv) do var x = g.x1 + ((xv[k] - xmn) / (xmx - xmn)) * g.gw var y = g.y2 - ((yv[k] - ymn) / (ymx - ymn)) * g.gh var r = 3 + ((sz[k] - sr.mn) / (sr.mx - sr.mn)) * 15 push(g.p,circ(x, y, r, st.ac, "none", 0)) k = k + 1 end return join(g.p, "\n") end function multi_line_graph(sp, st) // Data: [[line1_vals], [line2_vals], ...] var ds = sp.data if len(ds) == 0 then throw("No series") end var n = len(ds[0]) var i = 0 while i < len(ds) do if len(ds[i]) != n then throw("All series must be same length") end i = i + 1 end // Find global min/max var gmn = ds[0][0] var gmx = ds[0][0] var i = 0 while i < len(ds) do var j = 0 while j < len(ds[i]) do if ds[i][j] < gmn then gmn = ds[i][j] end if ds[i][j] > gmx then gmx = ds[i][j] end j = j + 1 end i = i + 1 end var pmn = gmn - (gmx - gmn) * 0.1 var pmx = gmx + (gmx - gmn) * 0.1 var g = xy_graph(sp, st, pmn, pmx) // Colors for different series var cols = ["#5FBB46", "#2563eb", "#dc2626", "#f59e0b", "#8b5cf6"] var si = 0 while si < len(ds) do var col = cols[si % len(cols)] var pts = [] var k = 0 while k < len(ds[si]) do var rat = 0 if n > 1 then rat = k / (n - 1) end var x = g.x1 + rat * g.gw var y = g.y2 - ((ds[si][k] - pmn) / (pmx - pmn)) * g.gh push(pts, x) push(pts, y) k = k + 1 end push(g.p,poly(pts, col, st.dlw, "none")) k = 0 while k < len(ds[si]) do rat = 0 if n > 1 then rat = k / (n - 1) end var x = g.x1 + rat * g.gw var y = g.y2 - ((ds[si][k] - pmn) / (pmx - pmn)) * g.gh push(g.p,circ(x, y, st.dds, col, "none", 0)) k = k + 1 end si = si + 1 end return join(g.p, "\n") end function donut_graph(sp, st) if len(sp.data) % 2 != 0 then throw("Need label,value pairs") end if len(sp.data) == 0 then throw("No data") end var tot = 0 var i = 1 while i < len(sp.data) do tot = tot + sp.data[i] i = i + 2 end if tot == 0 then throw("Total value cannot be zero") end var cx = CW / 2 var cy = CH / 2 var orus = 150 var irus = 75 var gap = pi() / 60 // 3-degree gap in radians var p = [rect(0, 0, CW, CH, st.bc, "none", 0)] if sp.title then push(p,txt(CW/2, st.mt/2, sp.title, st.pc, "middle", st.ff, st.ts)) end var cols = ["#5FBB46", "#2563eb", "#dc2626", "#f59e0b", "#8b5cf6", "#06b6d4", "#ec4899"] // Calculate sectors and center of mass var sectors = [] var nc = len(sp.data) / 2 var avail_ang = 2 * pi() - nc * gap var ca = -pi() / 2 // Start at top (12 o'clock) var i = 0 while i < nc do var sa = ca var da = (sp.data[i*2+1] / tot) * avail_ang var ea = ca + da var ma = (sa + ea) / 2 push(sectors, {sa = sa, ea = ea, ma = ma, i = i}) ca = ea + gap i = i + 1 end // Calculate center of mass for rotation var wsum = 0 var w = 0 var i = 0 while i < len(sectors) do var sec = sectors[i] var ss = sec.ea - sec.sa wsum = wsum + sec.ma * ss w = w + ss i = i + 1 end var com = wsum / w var rot = -com // Apply rotation var i = 0 while i < len(sectors) do sectors[i].sa = sectors[i].sa + rot sectors[i].ea = sectors[i].ea + rot sectors[i].ma = sectors[i].ma + rot i = i + 1 end // Split labels into left and right var l_lbl = [] var r_lbl = [] var i = 0 while i < len(sectors) do var sec = sectors[i] var na = sec.ma while na < 0 do na = na + 2 * pi() end while na >= 2 * pi() do na = na - 2 * pi() end var ext_r = orus + 80 var ty = cy + ext_r * sin(sec.ma) if na > pi() / 2 and na < 3 * pi() / 2 then push(l_lbl, {sec = sec, ty = ty, ay = ty}) else push(r_lbl, {sec = sec, ty = ty, ay = ty}) end i = i + 1 end // Sort labels by target Y sort(l_lbl, function(a, b) return a.ty < b.ty end) sort(r_lbl, function(a, b) return a.ty < b.ty end) // Collision resolution function resolve_coll(lbl) var min_sp = 30 var i = 1 while i < len(lbl) do if lbl[i].ay - lbl[i-1].ay < min_sp then lbl[i].ay = lbl[i-1].ay + min_sp end i = i + 1 end var i = len(lbl) - 2 while i >= 0 do if lbl[i+1].ay - lbl[i].ay < min_sp then lbl[i].ay = lbl[i+1].ay - min_sp end i = i - 1 end return lbl end l_lbl = resolve_coll(l_lbl) r_lbl = resolve_coll(r_lbl) // Draw sectors var vir = orus * 0.038 * (3.0 / 3.0) // coefficient * gap angle ratio for sec in sectors do var col = cols[sec.i % len(cols)] // Calculate convergence point var cvx = cx + vir * cos(sec.ma) var cvy = cy + vir * sin(sec.ma) // Outer arc points var osx = cx + orus * cos(sec.sa) var osy = cy + orus * sin(sec.sa) var oex = cx + orus * cos(sec.ea) var oey = cy + orus * sin(sec.ea) // Inner arc points - find intersection of radial line with inner circle var isx = cvx + (osx - cvx) / orus * irus var isy = cvy + (osy - cvy) / orus * irus var iex = cvx + (oex - cvx) / orus * irus var iey = cvy + (oey - cvy) / orus * irus // Large arc flag for outer arc var olaf = sec.ea - sec.sa > pi() ? 1 : 0 // Build SVG path var d = 'M {{floor(osx)}} {{floor(osy)}} A {{floor(orus)}} {{floor(orus)}} 0 {{olaf}} 1 {{floor(oex)}} {{floor(oey)}} L {{floor(iex)}} {{floor(iey)}} A {{floor(irus)}} {{floor(irus)}} 0 {{olaf}} 0 {{floor(isx)}} {{floor(isy)}} Z' push(p,path(d, col, "none", 0)) end // Helper: draw label with pointer line function draw_label(lbl_arr, cx, cy, orus, st, sp, cols, is_left) for lbl in lbl_arr do var sec = lbl.sec var ly = lbl.ay var ptr_r = orus + 5 var dx = cos(sec.ma) var dy = sin(sec.ma) var dot_x = cx + ptr_r * dx var dot_y = cy + ptr_r * dy var bend_y = ly var bend_x = dot_x if abs(dy) > 0.01 then var t = (ly - dot_y) / dy if abs(t) >= 30 and abs(t) <= 100 then bend_x = dot_x + t * dx else bend_x = dot_x + 40 * dx end else bend_x = dot_x + 40 * dx end var lx = is_left ? 140 : CW - 140 var txt_x = is_left ? lx - 5 : lx + 5 var anchor = is_left ? "end" : "start" push(p,line(dot_x, dot_y, bend_x, bend_y, st.pc, 1)) push(p,line(bend_x, bend_y, lx, bend_y, st.pc, 1)) push(p,circ(dot_x, dot_y, 3, st.pc, st.pc, 1)) push(p,txt(txt_x, ly + 5, sp.data[sec.i*2], st.pc, anchor, st.ff, st.dls)) end end draw_label(l_lbl, cx, cy, orus, st, sp, cols, true) draw_label(r_lbl, cx, cy, orus, st, sp, cols, false) return join(p, "\n") end var renderers = { line = lin_graph, bar = bar_graph, area = area_graph, scatter = scatter_graph, hbar = hbar_graph, bubble = bubble_graph, multi = multi_line_graph, donut = donut_graph } function render(sp) var st = style() if not sp.type then throw("Need: line|bar|area|scatter|hbar|bubble|multi|donut") end var renderer = renderers[sp.type] if not renderer then throw("Unknown: " + sp.type) end var c = renderer(sp, st) var s = """ {{c}} """ return s end return {render = render}