plot
Header-only C++ SVG charts — heatmap, line, scatter — GR-style API + Solarized themes
Loading...
Searching...
No Matches
heatmap.hpp
1/* Copyright (c) 2026 Steven Varga, Toronto, ON, Canada
2 * MIT License — see LICENSE
3 *
4 * Heatmap renderers over the SVG canvas:
5 * - categorical (integral/enum): unique value -> palette index;
6 * - continuous (arithmetic, e.g. double): per-cell normalize across [min,max]
7 * then map to a 3-stop blue->yellow->red gradient.
8 * Ported from plot:: into plot::; the original third-party matrix
9 * dependency is replaced by a tiny non-owning row-major `mat<T>` view.
10 * Dependency-free.
11 */
12#ifndef PLOT_HEATMAP_HPP
13#define PLOT_HEATMAP_HPP
14
15#include <string>
16#include <vector>
17#include <tuple>
18#include <array>
19#include <algorithm>
20#include <type_traits>
21#include <cstdint>
22#include <cstddef>
23#include <fstream>
24#include <ostream>
25#include <sstream>
26#include <format>
27
28#include "attributes.hpp"
29#include "canvas.hpp"
30#include "axis.hpp"
31#include "text.hpp"
32#include "meta.hpp"
33#include "theme.hpp"
34#include "gr.hpp"
35
36namespace plot {
37 // Non-owning, row-major matrix view — the dependency-free replacement for the
38 // third-party dense matrix the original heatmap overload consumed.
39 template<class T> struct mat {
40 const T* p; std::size_t rows, cols;
41 T operator()(std::size_t i, std::size_t j) const { return p[i*cols + j]; }
42 const T* begin() const { return p; }
43 const T* end() const { return p + rows*cols; }
44 };
45}
46
47namespace plot::data {
48 template <class T>
49 struct point_t {
50 std::size_t x,y;
51 T value;
52 std::string label,href;
53 };
54}
55
56namespace plot::impl {
57 // 3-stop blue -> yellow -> red linear gradient; t in [0,1] -> 0xRRGGBB.
58 inline std::uint32_t gradient(double t){
59 if( t < 0.0 ) t = 0.0; else if( t > 1.0 ) t = 1.0;
60 // stop colours
61 static constexpr double blue[3] = { 0.0, 64.0, 255.0};
62 static constexpr double yellow[3] = {255.0, 255.0, 0.0};
63 static constexpr double red[3] = {220.0, 30.0, 30.0};
64 double r,g,b;
65 if( t < 0.5 ){
66 double u = t / 0.5;
67 r = blue[0] + u*(yellow[0]-blue[0]);
68 g = blue[1] + u*(yellow[1]-blue[1]);
69 b = blue[2] + u*(yellow[2]-blue[2]);
70 } else {
71 double u = (t - 0.5) / 0.5;
72 r = yellow[0] + u*(red[0]-yellow[0]);
73 g = yellow[1] + u*(red[1]-yellow[1]);
74 b = yellow[2] + u*(red[2]-yellow[2]);
75 }
76 auto clamp8 = [](double v)->std::uint32_t {
77 if( v < 0 ) v = 0; else if( v > 255 ) v = 255;
78 return static_cast<std::uint32_t>(v + 0.5);
79 };
80 return (clamp8(r) << 16) | (clamp8(g) << 8) | clamp8(b);
81 }
82}
83
84namespace plot::impl {
85 // ---- categorical heatmap over a mat<T> view (integral / enum) -------------
86 template <typename T>
87 typename std::enable_if<std::is_integral<T>::value || std::is_enum<T>::value>
88 ::type heatmap(impl::canvas_t& canvas, float x, float y,
89 std::vector<float>& dx, std::vector<float>& dy, float width, float height,
90 const plot::mat<T>& data, const plot::attribute::color_t& palette,
91 const std::array<std::uint32_t,3>& /*grad*/ ) {
92 using attribute_t = plot::attribute::element_t;
93 attribute_t mock_attr, gr_attr;
94 // find unique elements
95 std::vector<T> M(data.begin(), data.end());
96 std::sort(M.begin(), M.end());
97 auto last = std::unique(M.begin(), M.end());
98 M.erase(last, M.end());
99
100 canvas.group(static_cast<std::size_t>(x), static_cast<std::size_t>(y), mock_attr, [&]() -> void {
101 for( auto value : M ) {
102 gr_attr.color = palette[static_cast<std::ptrdiff_t>(value)];
103 canvas.group(std::size_t{0}, std::size_t{0}, gr_attr, [&]() -> void {
104 for(std::size_t j = 0; j < data.cols; j++) for( std::size_t i = 0; i < data.rows; i++)
105 if( data(i,j) == value )
106 canvas.rect( dx[j], dy[i], width, height, 1.5, 1.5, mock_attr);
107 });
108 }
109 });
110 }
111}
112
113namespace plot::impl {
114 // ---- categorical heatmap over a point list (integral / enum) --------------
115 template <typename T>
116 typename std::enable_if<std::is_integral<T>::value || std::is_enum<T>::value>
117 ::type heatmap(impl::canvas_t& canvas, float x, float y,
118 std::vector<float>& dx, std::vector<float>& dy, float width, float height,
119 const std::vector<plot::data::point_t<T>>& data, const plot::attribute::color_t& palette,
120 const std::array<std::uint32_t,3>& /*grad*/ ) {
121 using attribute_t = plot::attribute::element_t;
122 attribute_t rect_attr, gr_attr;
123
124 gr_attr.color.reset();
125 canvas.group(static_cast<std::size_t>(x), static_cast<std::size_t>(y), gr_attr, [&]() -> void {
126 for(auto v : data){
127 rect_attr.href = v.href; rect_attr.label = v.label; rect_attr.color = palette[v.value];
128 canvas.rect(dx[v.x], dy[v.y], width, height, 1.5, 1.5, rect_attr);
129 }
130 });
131 }
132}
133
134namespace plot::impl {
135 // ---- continuous-gradient heatmap over a mat<T> view (arithmetic) ----------
136 // Each cell is normalized across the grid's [min,max] and coloured via the
137 // blue->yellow->red gradient; a <title> carries the numeric value.
138 template <typename T>
139 typename std::enable_if<std::is_arithmetic<T>::value && !std::is_integral<T>::value>
140 ::type heatmap(impl::canvas_t& canvas, float x, float y,
141 std::vector<float>& dx, std::vector<float>& dy, float width, float height,
142 const plot::mat<T>& data, const plot::attribute::color_t& /*palette*/,
143 const std::array<std::uint32_t,3>& grad ) {
144 using attribute_t = plot::attribute::element_t;
145 if( data.rows == 0 || data.cols == 0 ) return;
146
147 double lo = static_cast<double>(*data.begin());
148 double hi = lo;
149 for(const T& v : data){
150 double d = static_cast<double>(v);
151 if( d < lo ) lo = d;
152 if( d > hi ) hi = d;
153 }
154 const double span = (hi > lo) ? (hi - lo) : 1.0;
155
156 attribute_t gr_attr;
157 canvas.group(static_cast<std::size_t>(x), static_cast<std::size_t>(y), gr_attr, [&]() -> void {
158 for(std::size_t i = 0; i < data.rows; i++)
159 for(std::size_t j = 0; j < data.cols; j++){
160 double value = static_cast<double>(data(i,j));
161 double t = (value - lo) / span;
162 attribute_t cell;
163 cell.color = plot::attribute::color_t{ impl::gradient3(grad, t) };
164 cell.label = std::format("{:.4g}", value);
165 canvas.rect( dx[j], dy[i], width, height, 1.5, 1.5, cell );
166 }
167 });
168 }
169}
170
171namespace plot::impl {
172 // ---- region-draw core: render the heatmap content at its natural layout,
173 // starting at the canvas origin, and return the natural (width,height). The
174 // caller wraps this in a translate/scale group to place/fit it. Both the
175 // ostream heatmap driver and the deferred heatmap_view::draw_into use it.
176 template <class T, class... arg_t>
177 std::pair<std::size_t,std::size_t>
178 heatmap_render(impl::canvas_t& canvas, const T& data, arg_t... args ) {
179 using title_t = typename arg::tpos<tag::title_t, arg_t...>;
180 using footnote_t = typename arg::tpos<tag::footnote_t, arg_t...>;
181 using legend_t = typename arg::tpos<tag::legend_t, arg_t...>;
182 using axisx_t = typename arg::tpos<tag::axis::x_t, arg_t...>;
183 using axisy_t = typename arg::tpos<tag::axis::y_t, arg_t...>;
184 using width_t = typename arg::tpos<tag::width_t, arg_t...>;
185 using height_t = typename arg::tpos<tag::height_t, arg_t...>;
186
187 static_assert( axisx_t::present, "x axis must be specified..." );
188 static_assert( axisy_t::present, "y axis must be specified..." );
189
190 auto tuple = std::forward_as_tuple(args...);
191
192 std::array<float,4> margin{5,5,5,5};
193 using margin_t = typename arg::tpos<tag::margin_t, arg_t...>;
194 if constexpr( margin_t::present )
195 margin = std::get<margin_t::position>( tuple ).value;
196
197 auto x_axis = std::get<axisx_t::position>( tuple );
198 auto y_axis = std::get<axisy_t::position>( tuple );
199
200 // when a title is present, push the x-axis (and thus the grid below it)
201 // down by a band so the title sits above the ticks instead of overlapping
202 // the top of the x-axis.
203 if constexpr( title_t::present ){
204 x_axis.position = position{ std::get<std::size_t>(x_axis.position->x),
205 std::get<std::size_t>(x_axis.position->y) + std::size_t{14} };
206 }
207
208 float offset_x = std::get<std::size_t>(x_axis.position->x) - .5f * x_axis.grid;
209 float offset_y = std::get<std::size_t>(x_axis.position->y) + .5f * y_axis.grid;
210 // when y axis label position is not preset, compute it from x layout
211 float pos_x = x_axis.dx.back() + 1.5f * x_axis.grid + offset_x;
212 y_axis.position = position{static_cast<std::size_t>(pos_x), static_cast<std::size_t>(offset_y + .5f * y_axis.grid) };
213 // compute natural size (width/height override only the reported extent).
214 std::size_t width, height;
215 if constexpr( width_t::present )
216 width = std::get<width_t::position>( tuple ).value;
217 else
218 width = static_cast<std::size_t>(y_axis.get_x() + margin[2]);
219 if constexpr( height_t::present )
220 height = std::get<height_t::position>( tuple ).value;
221 else
222 height = static_cast<std::size_t>(y_axis.get_y() + offset_y + margin[3]);
223
224 const theme_t& th = impl::resolve_theme(args...);
225
226 // theme background spanning the whole region (painted first, under all).
227 { plot::attribute::element_t bg; bg.color = plot::attribute::color_t{ th.bg };
228 canvas.rect(0, 0, static_cast<float>(width), static_cast<float>(height + 10), 0, 0, bg); }
229 // colour the axis tick labels with the theme foreground — otherwise the
230 // <text> inherits the SVG default (black), invisible on a dark theme.
231 x_axis.color = plot::attribute::color_t{ th.fg };
232 y_axis.color = plot::attribute::color_t{ th.fg };
233 canvas << x_axis; canvas << y_axis;
234
235 if constexpr (title_t::present) {
236 auto title = std::get<title_t::position>( tuple );
237 if( !title.color ) title.color = plot::attribute::color_t{ th.fg };
238 // place the title sensibly when the caller didn't (e.g. plot::title("…")
239 // inside a grid) — otherwise it would render at the {0,0} fallback.
240 if( !title.position ) title.position = position{ std::size_t{4}, std::size_t{10} };
241 canvas << title;
242 }
243 if constexpr (footnote_t::present) canvas << std::get<footnote_t::position>( tuple );
244
245 plot::attribute::color_t palette{ 0x4060FF };
246 if constexpr (legend_t::present) {
247 canvas << std::get<legend_t::position>( tuple );
248 auto legend = std::get<legend_t::position>( tuple );
249 if( legend.color ) palette = legend.color.value();
250 }
251 impl::heatmap(canvas,
252 offset_x, offset_y, x_axis.dx, y_axis.dy,
253 .9f * x_axis.grid, .9f * y_axis.grid, data, palette, th.gradient );
254 return { width, height + 10 };
255 }
256}
257
258namespace plot {
259 // ---- the heatmap driver (writes a standalone .svg to an ostream) ----------
260 template <class T, class... arg_t>
261 void heatmap(std::ostream& os, const T& data, arg_t... args ) {
262 // two-pass: render once into a throwaway stream to learn the natural size,
263 // then open the real canvas at that size and render for real. Cheap (the
264 // grids here are small) and keeps the single layout source of truth in
265 // heatmap_render — no size formula duplicated across call sites.
266 std::array<float,4> margin{5,5,5,5};
267 using margin_t = typename arg::tpos<tag::margin_t, arg_t...>;
268 auto mtuple = std::forward_as_tuple(args...);
269 if constexpr( margin_t::present )
270 margin = std::get<margin_t::position>( mtuple ).value;
271 std::pair<std::size_t,std::size_t> sz;
272 { std::ostringstream probe;
273 impl::canvas_t scratch(probe, 1, 1, margin);
274 sz = impl::heatmap_render(scratch, data, args...); }
275 impl::canvas_t canvas(os, sz.first, sz.second, margin);
276 impl::heatmap_render(canvas, data, args...);
277 }
278
279 // filename convenience: opens a truncating ofstream and renders into it.
280 template <class T, class... arg_t>
281 void heatmap(const std::string& filename, const T& data, arg_t... args ) {
282 std::ofstream ofs(filename, std::ios::out | std::ios::trunc);
283 heatmap(ofs, data, args...);
284 }
285}
286#endif