plot
Header-only C++ SVG charts — heatmap, line, scatter — GR-style API + Solarized themes
Loading...
Searching...
No Matches
gr.hpp
1/* Copyright (c) 2026 Steven Varga, Toronto, ON, Canada
2 * MIT License — see LICENSE
3 *
4 * GR.jl-style high-level drivers over the SVG canvas:
5 * - plot::line(os, xs, ys, opts...) single auto-scaled series
6 * - plot::line(os, xs, {{"label",data},...}, …) multi-series + legend
7 * - plot::scatter(os, xs, ys, opts...) points via <circle>
8 *
9 * Shared named-argument options (same order-independent `tpos` dispatch the
10 * heatmap uses): plot::title{}, plot::xlabel{}, plot::ylabel{}, plot::xlog{base},
11 * plot::ylog{base}, plot::legend{}, plot::width{}, plot::height{}, plot::margin{},
12 * plot::use{theme}. Themes drive every colour: a background rect is painted with
13 * theme.bg, text/grid default to theme.fg/theme.grid, and series cycle through
14 * theme.series. Dependency-free (standard library only).
15 */
16#ifndef PLOT_GR_HPP
17#define PLOT_GR_HPP
18
19#include <string>
20#include <vector>
21#include <array>
22#include <tuple>
23#include <utility>
24#include <algorithm>
25#include <cmath>
26#include <limits>
27#include <cstdint>
28#include <cstddef>
29#include <fstream>
30#include <ostream>
31#include <format>
32
33#include "tags.hpp"
34#include "meta.hpp"
35#include "attributes.hpp"
36#include "canvas.hpp"
37#include "theme.hpp"
38
39namespace plot {
40 // ---- GR-style named-argument option carriers ----------------------------
41 struct xlabel { using value_type = tag::xlabel_t; std::string value; };
42 struct ylabel { using value_type = tag::ylabel_t; std::string value; };
43 // log-scale on an axis; `base` selects the logarithm base (default 10).
44 struct xlog { using value_type = tag::xlog_t; double base = 10.0; };
45 struct ylog { using value_type = tag::ylog_t; double base = 10.0; };
46}
47
48namespace plot::impl {
49 // a labelled data series for the multi-series line() overload.
50 struct series_t { std::string label; std::vector<double> data; };
51
52 // pick the active theme: per-call plot::use{} override or the global default.
53 template <class... arg_t>
54 const theme_t& resolve_theme(arg_t... args){
55 using theme_pos = typename arg::tpos<tag::theme_t, arg_t...>;
56 if constexpr( theme_pos::present ){
57 auto tuple = std::forward_as_tuple(args...);
58 // stash the override so a reference outlives this scope: copy into a
59 // function-local static is unsafe across threads, so return by value
60 // is preferred — but the drivers only need a const ref for the render
61 // duration, so we materialise into thread_local storage.
62 thread_local theme_t picked;
63 picked = std::get<theme_pos::position>(tuple).value;
64 return picked;
65 } else {
66 return current_theme;
67 }
68 }
69
70 // theme-driven continuous gradient: 3 stops, t in [0,1] -> 0xRRGGBB.
71 inline std::uint32_t gradient3(const std::array<std::uint32_t,3>& g, double t){
72 if( t < 0.0 ) t = 0.0; else if( t > 1.0 ) t = 1.0;
73 auto chan = [](std::uint32_t c, int sh){ return double((c >> sh) & 0xFF); };
74 auto lerp = [](double a, double b, double u){ return a + u*(b-a); };
75 double r,gn,b;
76 if( t < 0.5 ){
77 double u = t / 0.5;
78 r = lerp(chan(g[0],16), chan(g[1],16), u);
79 gn = lerp(chan(g[0], 8), chan(g[1], 8), u);
80 b = lerp(chan(g[0], 0), chan(g[1], 0), u);
81 } else {
82 double u = (t - 0.5) / 0.5;
83 r = lerp(chan(g[1],16), chan(g[2],16), u);
84 gn = lerp(chan(g[1], 8), chan(g[2], 8), u);
85 b = lerp(chan(g[1], 0), chan(g[2], 0), u);
86 }
87 auto c8 = [](double v)->std::uint32_t {
88 if( v < 0 ) v = 0; else if( v > 255 ) v = 255;
89 return std::uint32_t(v + 0.5); };
90 return (c8(r) << 16) | (c8(gn) << 8) | c8(b);
91 }
92
93 // "nice" tick step (1/2/5 * 10^k) covering [lo,hi] with ~count ticks.
94 inline double nice_step(double range, int count){
95 if( range <= 0 || count <= 0 ) return 1.0;
96 double raw = range / count;
97 double mag = std::pow(10.0, std::floor(std::log10(raw)));
98 double norm = raw / mag;
99 double step;
100 if( norm < 1.5 ) step = 1;
101 else if( norm < 3 ) step = 2;
102 else if( norm < 7 ) step = 5;
103 else step = 10;
104 return step * mag;
105 }
106
107 // numeric axis tick model: min/max plus rendered tick label strings/positions.
108 struct scale_t {
109 double lo = 0, hi = 1; // data domain (already log-transformed if log)
110 bool log = false;
111 double base = 10.0;
112 std::vector<double> ticks; // tick positions in domain space
113 std::vector<std::string> labels;
114
115 // map a (already-transformed) value to [0,1] across the domain.
116 double norm(double v) const {
117 double span = (hi > lo) ? (hi - lo) : 1.0;
118 return (v - lo) / span;
119 }
120 // transform a raw data value into domain space (log if enabled).
121 double xform(double v) const {
122 if( !log ) return v;
123 double b = (base > 0 && base != 1.0) ? base : 10.0;
124 double safe = v > 0 ? v : std::numeric_limits<double>::min();
125 return std::log(safe) / std::log(b);
126 }
127 };
128
129 inline std::string tick_label(double v){
130 // integer-ish values print without a decimal point; else 4 sig figs.
131 if( std::abs(v - std::llround(v)) < 1e-9 * (1.0 + std::abs(v)) )
132 return std::format("{}", std::llround(v));
133 return std::format("{:.4g}", v);
134 }
135
136 // build an axis scale from raw min/max, applying log transform + nice ticks.
137 inline scale_t make_scale(double dmin, double dmax, bool log, double base){
138 scale_t s; s.log = log; s.base = base;
139 if( log ){
140 double b = (base > 0 && base != 1.0) ? base : 10.0;
141 double lmin = dmin > 0 ? dmin : std::numeric_limits<double>::min();
142 double lmax = dmax > 0 ? dmax : std::numeric_limits<double>::min();
143 s.lo = std::log(lmin)/std::log(b);
144 s.hi = std::log(lmax)/std::log(b);
145 if( s.hi <= s.lo ) s.hi = s.lo + 1.0;
146 // nudge by an epsilon before floor/ceil: log10(1000) computes to
147 // 2.9999999996, whose floor is 2 — which would add a spurious decade
148 // (a 100 tick below a data minimum of 1000). The epsilon snaps it.
149 double t0 = std::floor(s.lo + 1e-9), t1 = std::ceil(s.hi - 1e-9);
150 s.lo = t0; s.hi = t1;
151 for(double t = t0; t <= t1 + 0.5; t += 1.0){
152 s.ticks.push_back(t);
153 s.labels.push_back(tick_label(std::pow(b, t)));
154 }
155 } else {
156 if( dmax <= dmin ){ dmax = dmin + 1.0; }
157 double step = nice_step(dmax - dmin, 5);
158 double t0 = std::floor(dmin/step)*step;
159 double t1 = std::ceil(dmax/step)*step;
160 s.lo = t0; s.hi = t1;
161 for(double t = t0; t <= t1 + step*0.5; t += step){
162 s.ticks.push_back(t);
163 s.labels.push_back(tick_label(t));
164 }
165 }
166 return s;
167 }
168
169 // shared core: lay out a cartesian panel (bg + panel + grid + axes + labels)
170 // into the region [ox,ox+width] × [oy,oy+height] of an existing canvas, then
171 // invoke draw(canvas, px, py) where px/py map domain coords to pixels. The
172 // whole panel is wrapped in canvas.group(ox, oy, …) so all local coordinates
173 // run in [0,width]×[0,height] and the group's translate supplies the offset.
174 // Used by both line() and scatter(). Callers pre-transform values (log etc.)
175 // via scale_t::xform before calling px/py.
176 template <class draw_fn>
177 void render_panel_into(canvas_t& canvas, float ox, float oy,
178 std::size_t width, std::size_t height, const theme_t& th,
179 const scale_t& sx, const scale_t& sy,
180 const std::string& title, const std::string& xlab, const std::string& ylab,
181 const std::vector<std::string>& legend_labels,
182 draw_fn&& draw){
183 using attribute_t = plot::attribute::element_t;
184 using color_t = plot::attribute::color_t;
185 using stroke_t = plot::attribute::stroke_t;
186
187 attribute_t grp; // the translate group placing this panel in its cell.
188 canvas.group(static_cast<std::size_t>(ox), static_cast<std::size_t>(oy), grp,
189 [&]() -> void {
190
191 // reserved gutters (px) for the axes/labels/title.
192 const float tick_char = 0.60f * 9.0f; // Ubuntu Mono tick-label advance at 9px
193 const float left = 56.0f;
194 // widen the right gutter to fit the last x-tick label: the canvas draws it
195 // left-anchored at the rightmost tick, so a fixed gutter clips wide labels.
196 const float right = std::max(14.0f, sx.labels.empty() ? 14.0f
197 : tick_char * float(sx.labels.back().size()) + 8.0f);
198 const float top = title.empty() ? 18.0f : 34.0f;
199 const float bottom = 44.0f;
200 const float x0 = left;
201 const float y0 = top;
202 const float pw = std::max(1.0f, float(width) - left - right);
203 const float ph = std::max(1.0f, float(height) - top - bottom);
204
205 // background (theme.bg) over the whole canvas.
206 { attribute_t bg; bg.color = color_t{ th.bg };
207 canvas.rect(0,0, float(width), float(height), 0,0, bg); }
208 // panel (theme.panel).
209 { attribute_t pn; pn.color = color_t{ th.panel };
210 canvas.rect(x0, y0, pw, ph, 2,2, pn); }
211
212 auto px = [&](double v){ return x0 + float(sx.norm(v)) * pw; };
213 auto py = [&](double v){ return y0 + ph - float(sy.norm(v)) * ph; };
214
215 // grid + tick labels (theme.grid / theme.fg).
216 attribute_t grid_attr; grid_attr.color = color_t{ th.grid };
217 grid_attr.stroke = stroke_t{1.0f, 0.5f, {}, {}, {}};
218 attribute_t tick_attr; tick_attr.color = color_t{ th.fg };
219 tick_attr.font = plot::attribute::font_t{"Ubuntu Mono, monospace", "normal", 9u};
220
221 // the bottom scale sits `label_gap` px below the x-axis; the left scale
222 // uses the SAME gap from the y-axis. Tick text is left-anchored by the
223 // canvas, so the y labels are placed by their (monospace) width so their
224 // right edge lands `label_gap` px left of the axis — matching the bottom.
225 const float label_gap = 14.0f;
226 for(std::size_t i=0;i<sx.ticks.size();++i){
227 float X = px(sx.ticks[i]);
228 canvas.line(X, y0, X, y0+ph, grid_attr);
229 attribute_t a = tick_attr; a.align = plot::attribute::align_t::center;
230 canvas.text(sx.labels[i], std::size_t(X), std::size_t(y0+ph+label_gap), a);
231 }
232 for(std::size_t i=0;i<sy.ticks.size();++i){
233 float Y = py(sy.ticks[i]);
234 canvas.line(x0, Y, x0+pw, Y, grid_attr);
235 attribute_t a = tick_attr; a.align = plot::attribute::align_t::right;
236 float tx = x0 - label_gap - tick_char * float(sy.labels[i].size());
237 canvas.text(sy.labels[i], std::size_t(tx < 0 ? 0 : tx), std::size_t(Y+3), a);
238 }
239
240 // axis frame (theme.axis).
241 attribute_t axis_attr; axis_attr.color = color_t{ th.axis };
242 axis_attr.stroke = stroke_t{1.0f, 1.2f, {}, {}, {}};
243 canvas.line(x0, y0, x0, y0+ph, axis_attr);
244 canvas.line(x0, y0+ph, x0+pw, y0+ph, axis_attr);
245
246 // title + axis labels (theme.fg).
247 if( !title.empty() ){
248 attribute_t a; a.color = color_t{ th.fg };
249 a.font = plot::attribute::font_t{"Arial, sans-serif", "bold", 13u};
250 a.align = plot::attribute::align_t::center;
251 canvas.text(title, std::size_t(x0 + pw/2), std::size_t(top-16), a);
252 }
253 if( !xlab.empty() ){
254 attribute_t a; a.color = color_t{ th.fg };
255 a.font = plot::attribute::font_t{"Arial, sans-serif", "normal", 10u};
256 a.align = plot::attribute::align_t::center;
257 canvas.text(xlab, std::size_t(x0 + pw/2), std::size_t(height-8), a);
258 }
259 if( !ylab.empty() ){
260 attribute_t a; a.color = color_t{ th.fg };
261 a.font = plot::attribute::font_t{"Arial, sans-serif", "normal", 10u};
262 a.align = plot::attribute::align_t::center;
263 a.rotate = plot::attribute::degree_t{270.0f};
264 canvas.text(ylab, std::size_t(14), std::size_t(y0 + ph/2), a);
265 }
266
267 // the series payload.
268 draw(canvas, px, py);
269
270 // legend swatches (top-right inside the panel), cycling theme.series.
271 if( !legend_labels.empty() ){
272 float lx = x0 + pw - 110.0f;
273 float ly = y0 + 12.0f;
274 for(std::size_t i=0;i<legend_labels.size();++i){
275 std::uint32_t c = th.series.empty() ? th.fg
276 : th.series[i % th.series.size()];
277 attribute_t sw; sw.color = color_t{ c };
278 canvas.rect(lx, ly + float(i)*14.0f - 8.0f, 10, 10, 1,1, sw);
279 attribute_t tx; tx.color = color_t{ th.fg };
280 tx.font = plot::attribute::font_t{"Arial, sans-serif", "normal", 9u};
281 tx.align = plot::attribute::align_t::left;
282 canvas.text(legend_labels[i], std::size_t(lx+14),
283 std::size_t(ly + float(i)*14.0f), tx);
284 }
285 }
286
287 }); // close the translate group
288 }
289
290 // thin wrapper: own one <svg> at (width,height) and draw the panel into it.
291 template <class draw_fn>
292 void render_panel(std::ostream& os, const theme_t& th,
293 std::size_t width, std::size_t height, const std::array<float,4>& margin,
294 const scale_t& sx, const scale_t& sy,
295 const std::string& title, const std::string& xlab, const std::string& ylab,
296 const std::vector<std::string>& legend_labels,
297 draw_fn&& draw){
298 canvas_t canvas(os, width, height, margin);
299 render_panel_into(canvas, 0, 0, width, height, th, sx, sy,
300 title, xlab, ylab, legend_labels, std::forward<draw_fn>(draw));
301 }
302
303 // extract width/height/margin from named args, with sensible defaults.
304 template <class... arg_t>
305 std::tuple<std::size_t,std::size_t,std::array<float,4>>
306 geometry(arg_t... args){
307 using width_t = typename arg::tpos<tag::width_t, arg_t...>;
308 using height_t = typename arg::tpos<tag::height_t, arg_t...>;
309 using margin_t = typename arg::tpos<tag::margin_t, arg_t...>;
310 auto tuple = std::forward_as_tuple(args...);
311 std::size_t W = 640, H = 400;
312 std::array<float,4> M{5,5,5,5};
313 if constexpr( width_t::present ) W = std::get<width_t::position>(tuple).value;
314 if constexpr( height_t::present ) H = std::get<height_t::position>(tuple).value;
315 if constexpr( margin_t::present ) M = std::get<margin_t::position>(tuple).value;
316 return {W,H,M};
317 }
318
319 template <class... arg_t>
320 std::tuple<std::string,std::string,std::string>
321 texts(arg_t... args){
322 using title_t = typename arg::tpos<tag::title_t, arg_t...>;
323 using xlabel_t = typename arg::tpos<tag::xlabel_t, arg_t...>;
324 using ylabel_t = typename arg::tpos<tag::ylabel_t, arg_t...>;
325 auto tuple = std::forward_as_tuple(args...);
326 std::string t, xl, yl;
327 if constexpr( title_t::present ) t = std::get<title_t::position>(tuple).txt;
328 if constexpr( xlabel_t::present ) xl = std::get<xlabel_t::position>(tuple).value;
329 if constexpr( ylabel_t::present ) yl = std::get<ylabel_t::position>(tuple).value;
330 return {t, xl, yl};
331 }
332
333 // log-flag/base extraction.
334 template <class... arg_t>
335 std::pair<bool,double> xlog_of(arg_t... args){
336 using xlog_t = typename arg::tpos<tag::xlog_t, arg_t...>;
337 auto tuple = std::forward_as_tuple(args...);
338 if constexpr( xlog_t::present ) return {true, std::get<xlog_t::position>(tuple).base};
339 else return {false, 10.0};
340 }
341 template <class... arg_t>
342 std::pair<bool,double> ylog_of(arg_t... args){
343 using ylog_t = typename arg::tpos<tag::ylog_t, arg_t...>;
344 auto tuple = std::forward_as_tuple(args...);
345 if constexpr( ylog_t::present ) return {true, std::get<ylog_t::position>(tuple).base};
346 else return {false, 10.0};
347 }
348
349 template <class V>
350 std::pair<double,double> minmax_of(const V& v){
351 double lo = std::numeric_limits<double>::infinity();
352 double hi = -std::numeric_limits<double>::infinity();
353 for(auto e : v){ double d = double(e); if(d<lo) lo=d; if(d>hi) hi=d; }
354 if( !(lo <= hi) ){ lo = 0; hi = 1; }
355 return {lo, hi};
356 }
357}
358
359namespace plot {
360 // ---- single-series line --------------------------------------------------
361 template <class X, class Y, class... arg_t>
362 void line(std::ostream& os, const std::vector<X>& xs, const std::vector<Y>& ys, arg_t... args){
363 const theme_t& th = impl::resolve_theme(args...);
364 auto [W,H,M] = impl::geometry(args...);
365 auto [title, xl, yl] = impl::texts(args...);
366 auto [xlg, xb] = impl::xlog_of(args...);
367 auto [ylg, yb] = impl::ylog_of(args...);
368
369 auto [xmn,xmx] = impl::minmax_of(xs);
370 auto [ymn,ymx] = impl::minmax_of(ys);
371 impl::scale_t sx = impl::make_scale(xmn,xmx,xlg,xb);
372 impl::scale_t sy = impl::make_scale(ymn,ymx,ylg,yb);
373
374 impl::render_panel(os, th, W, H, M, sx, sy, title, xl, yl, {},
375 [&](impl::canvas_t& cv, auto px, auto py){
376 using attribute_t = plot::attribute::element_t;
377 std::vector<float> xpx, ypx;
378 const std::size_t n = std::min(xs.size(), ys.size());
379 for(std::size_t i=0;i<n;++i){
380 xpx.push_back(px(sx.xform(double(xs[i]))));
381 ypx.push_back(py(sy.xform(double(ys[i]))));
382 }
383 attribute_t a;
384 a.color = plot::attribute::color_t{ th.series.empty()? th.fg : th.series[0] };
385 a.stroke = plot::attribute::stroke_t{1.0f, 1.8f, {}, {}, {}};
386 cv.poly_line(xpx, ypx, a);
387 });
388 }
389
390 // ---- multi-series line (legend) -----------------------------------------
391 template <class X, class... arg_t>
392 void line(std::ostream& os, const std::vector<X>& xs,
393 const std::vector<impl::series_t>& series, arg_t... args){
394 const theme_t& th = impl::resolve_theme(args...);
395 auto [W,H,M] = impl::geometry(args...);
396 auto [title, xl, yl] = impl::texts(args...);
397 auto [xlg, xb] = impl::xlog_of(args...);
398 auto [ylg, yb] = impl::ylog_of(args...);
399
400 auto [xmn,xmx] = impl::minmax_of(xs);
401 double ymn = std::numeric_limits<double>::infinity();
402 double ymx = -std::numeric_limits<double>::infinity();
403 for(const auto& s : series){
404 auto [a,b] = impl::minmax_of(s.data);
405 if(a<ymn) ymn=a; if(b>ymx) ymx=b;
406 }
407 if( !(ymn<=ymx) ){ ymn=0; ymx=1; }
408 impl::scale_t sx = impl::make_scale(xmn,xmx,xlg,xb);
409 impl::scale_t sy = impl::make_scale(ymn,ymx,ylg,yb);
410
411 std::vector<std::string> labels;
412 for(const auto& s : series) labels.push_back(s.label);
413
414 impl::render_panel(os, th, W, H, M, sx, sy, title, xl, yl, labels,
415 [&](impl::canvas_t& cv, auto px, auto py){
416 using attribute_t = plot::attribute::element_t;
417 for(std::size_t k=0;k<series.size();++k){
418 const auto& s = series[k];
419 std::vector<float> xpx, ypx;
420 const std::size_t n = std::min(xs.size(), s.data.size());
421 for(std::size_t i=0;i<n;++i){
422 xpx.push_back(px(sx.xform(double(xs[i]))));
423 ypx.push_back(py(sy.xform(double(s.data[i]))));
424 }
425 std::uint32_t c = th.series.empty()? th.fg
426 : th.series[k % th.series.size()];
427 attribute_t a; a.color = plot::attribute::color_t{ c };
428 a.stroke = plot::attribute::stroke_t{1.0f, 1.8f, {}, {}, {}};
429 cv.poly_line(xpx, ypx, a);
430 }
431 });
432 }
433
434 // initializer-list convenience: plot::line(os, xs, {{"a",da},{"b",db}}, ...)
435 template <class X, class... arg_t>
436 void line(std::ostream& os, const std::vector<X>& xs,
437 std::initializer_list<impl::series_t> series, arg_t... args){
438 line(os, xs, std::vector<impl::series_t>(series), args...);
439 }
440
441 // ---- scatter -------------------------------------------------------------
442 template <class X, class Y, class... arg_t>
443 void scatter(std::ostream& os, const std::vector<X>& xs, const std::vector<Y>& ys, arg_t... args){
444 const theme_t& th = impl::resolve_theme(args...);
445 auto [W,H,M] = impl::geometry(args...);
446 auto [title, xl, yl] = impl::texts(args...);
447 auto [xlg, xb] = impl::xlog_of(args...);
448 auto [ylg, yb] = impl::ylog_of(args...);
449
450 auto [xmn,xmx] = impl::minmax_of(xs);
451 auto [ymn,ymx] = impl::minmax_of(ys);
452 impl::scale_t sx = impl::make_scale(xmn,xmx,xlg,xb);
453 impl::scale_t sy = impl::make_scale(ymn,ymx,ylg,yb);
454
455 impl::render_panel(os, th, W, H, M, sx, sy, title, xl, yl, {},
456 [&](impl::canvas_t& cv, auto px, auto py){
457 using attribute_t = plot::attribute::element_t;
458 const std::size_t n = std::min(xs.size(), ys.size());
459 std::uint32_t c = th.series.empty()? th.fg : th.series[0];
460 for(std::size_t i=0;i<n;++i){
461 attribute_t a; a.color = plot::attribute::color_t{ c };
462 cv.circle(px(sx.xform(double(xs[i]))),
463 py(sy.xform(double(ys[i]))), 3.0f, a);
464 }
465 });
466 }
467
468 // ---- filename convenience overloads -------------------------------------
469 template <class X, class Y, class... arg_t>
470 void line(const std::string& filename, const std::vector<X>& xs, const std::vector<Y>& ys, arg_t... args){
471 std::ofstream ofs(filename, std::ios::out | std::ios::trunc);
472 line(ofs, xs, ys, args...);
473 }
474 template <class X, class... arg_t>
475 void line(const std::string& filename, const std::vector<X>& xs,
476 std::initializer_list<impl::series_t> series, arg_t... args){
477 std::ofstream ofs(filename, std::ios::out | std::ios::trunc);
478 line(ofs, xs, std::vector<impl::series_t>(series), args...);
479 }
480 template <class X, class Y, class... arg_t>
481 void scatter(const std::string& filename, const std::vector<X>& xs, const std::vector<Y>& ys, arg_t... args){
482 std::ofstream ofs(filename, std::ios::out | std::ios::trunc);
483 scatter(ofs, xs, ys, args...);
484 }
485}
486#endif