nk 1.0.0
C++ Numerical Computing Runtime
Loading...
Searching...
No Matches
figure_render.hpp
Go to the documentation of this file.
1
22#pragma once
23
25
26#include <algorithm>
27#include <cmath>
28#include <cstdint>
29#include <cstdio>
30#include <cstdlib>
31#include <cstring>
32#include <functional>
33#include <map>
34#include <string>
35#include <vector>
36
37namespace nk {
38namespace figrender {
39
40// ── Colour helpers ───────────────────────────────────────────────────
41struct Rgb {
42 std::uint8_t r = 0, g = 0, b = 0;
43 Rgb() = default;
44 Rgb(std::uint8_t rr, std::uint8_t gg, std::uint8_t bb)
45 : r(rr), g(gg), b(bb) {}
46 static Rgb fromHex(const std::string &hex)
47 {
48 if (hex.size() >= 7 && hex[0] == '#') {
49 auto h = [&](size_t i) {
50 return static_cast<std::uint8_t>(std::strtoul(hex.substr(i, 2).c_str(), nullptr, 16));
51 };
52 return Rgb(h(1), h(3), h(5));
53 }
54 return Rgb(0, 0, 0);
55 }
56 std::string hex() const
57 {
58 char buf[8];
59 std::snprintf(buf, sizeof buf, "#%02x%02x%02x", r, g, b);
60 return buf;
61 }
62};
63
64// Series palette — the SAME hexes the IDE renderer's KIND_PALETTE uses,
65// so saveas output matches the on-screen figure.
66inline const std::vector<Rgb> &palette()
67{
68 static const std::vector<Rgb> p = {
69 Rgb(0x7f, 0xd9, 0x9a), Rgb(0x5f, 0xb3, 0xd4), Rgb(0xe9, 0xb8, 0x70),
70 Rgb(0x9b, 0x8c, 0xf2), Rgb(0xe2, 0x6a, 0x6a), Rgb(0xd4, 0xa5, 0xe6),
71 Rgb(0xf2, 0xa3, 0x7e), Rgb(0x6f, 0xcf, 0xbf),
72 };
73 return p;
74}
75
76// MATLAB single-char linespec colours — mirrors the JS STYLE_COLOR map.
77inline bool charSpecColor(char c, Rgb &out)
78{
79 switch (c) {
80 case 'r': out = Rgb(0xf0, 0x70, 0x70); return true;
81 case 'g': out = Rgb(0x6e, 0xe7, 0xa0); return true;
82 case 'b': out = Rgb(0x60, 0xd0, 0xf0); return true;
83 case 'k': out = Rgb(0x28, 0x28, 0x30); return true;
84 case 'm': out = Rgb(0xe0, 0x70, 0xc0); return true;
85 case 'c': out = Rgb(0x60, 0xd0, 0xf0); return true;
86 case 'y': out = Rgb(0xe8, 0xd0, 0x60); return true;
87 case 'w': out = Rgb(0xff, 0xff, 0xff); return true;
88 default: return false;
89 }
90}
91
92// Parsed style string (both wire dialects: "r--o" and the kv extras).
93struct StyleParts {
94 Rgb color{0x7f, 0xd9, 0x9a};
95 bool hasColor = false; // explicit colour (char or kv)
96 std::string lineStyle; // "-", "--", ":", "-.", "" (unset), "none"
97 std::string marker; // "", "o", "+", "*", ".", "x", "s", "d", …
98 // histogram extras (portion 39; engine histogram() packs them):
99 Rgb faceColor{0x00, 0x72, 0xbd}; // MATLAB FaceColor 'auto'
100 bool hasFaceColor = false;
101 Rgb edgeColor{0xd9, 0xd9, 0xd9}; // MATLAB default light-gray edge
102 bool hasEdgeColor = false;
103 double binWidth = 0; // 0 = derive from consecutive spacing
104 double faceAlpha = 1; // histogram FaceAlpha (default 0.6 comes
105 // in the kv from the engine)
106};
107
108// text() annotation extras, unpacked from the engine's ds.style
109// "key=val;key=val" wire format (layout.cpp text() builds it):
110// color=<MATLAB colour spec> rgb=r,g,b (0..1 floats)
111// fontSize=N halign=left|center|right
112// Defaults are MATLAB R2025b probe-verified (2026-09-12): FontSize 10,
113// HorizontalAlignment LEFT, VerticalAlignment middle, black.
115 Rgb color{0, 0, 0};
116 double fontSize = 10;
117 int halign = 0; // 0 left, 1 center, 2 right
118};
119
120// One MATLAB colour spec → Rgb. Accepts the linespec short letters,
121// long colour names, #rrggbb, and "r,g,b" / "r g b" float triplets
122// (0..1, MATLAB convention). Returns false when unrecognised.
123inline bool textColorSpec(const std::string &v, Rgb &out)
124{
125 // MATLAB-exact named colours (short letters are the SAME values:
126 // 'r' == 'red' == (1,0,0)) — NOT the soft series palette, which is
127 // a deliberate nk aesthetic for LINES only.
128 static const std::pair<const char *, Rgb> kNames[] = {
129 {"red", {255, 0, 0}}, {"green", {0, 128, 0}},
130 {"blue", {0, 0, 255}}, {"cyan", {0, 255, 255}},
131 {"magenta", {255, 0, 255}}, {"yellow", {255, 255, 0}},
132 {"black", {0, 0, 0}}, {"white", {255, 255, 255}},
133 {"none", {255, 255, 255}},
134 };
135 std::string s;
136 for (char c : v)
137 if (c != ' ' && c != '[' && c != ']')
138 s += (char)std::tolower((unsigned char)c);
139 if (s.size() == 1) {
140 for (const auto &kv : kNames)
141 if (s[0] == kv.first[0]) {
142 out = kv.second;
143 return true;
144 }
145 return false;
146 }
147 for (const auto &kv : kNames)
148 if (s == kv.first) {
149 out = kv.second;
150 return true;
151 }
152 if (s.size() >= 7 && s[0] == '#') {
153 out = Rgb::fromHex(s);
154 return true;
155 }
156 double ch[3] = {0, 0, 0};
157 int n = 0;
158 size_t i = 0;
159 while (i < s.size() && n < 3) {
160 size_t e = i;
161 while (e < s.size() && s[e] != ',')
162 ++e;
163 try {
164 ch[n++] = std::stod(s.substr(i, e - i));
165 } catch (...) {
166 return false;
167 }
168 i = e + 1;
169 }
170 if (n == 3) {
171 out = Rgb((std::uint8_t)std::lround(std::min(1.0, std::max(0.0, ch[0])) * 255),
172 (std::uint8_t)std::lround(std::min(1.0, std::max(0.0, ch[1])) * 255),
173 (std::uint8_t)std::lround(std::min(1.0, std::max(0.0, ch[2])) * 255));
174 return true;
175 }
176 return false;
177}
178
179inline TextExtras parseTextExtras(const std::string &style)
180{
181 TextExtras t;
182 if (style.empty())
183 return t;
184 size_t i = 0;
185 while (i < style.size()) {
186 size_t semi = style.find(';', i);
187 std::string kv = style.substr(
188 i, (semi == std::string::npos ? style.size() : semi) - i);
189 size_t eq = kv.find('=');
190 if (eq != std::string::npos) {
191 std::string key = kv.substr(0, eq);
192 std::string val = kv.substr(eq + 1);
193 if (key == "color") {
194 Rgb c;
195 if (textColorSpec(val, c))
196 t.color = c;
197 } else if (key == "rgb") {
198 Rgb c;
199 if (textColorSpec(val, c))
200 t.color = c;
201 } else if (key == "fontSize") {
202 try {
203 t.fontSize = std::stod(val);
204 } catch (...) {
205 }
206 } else if (key == "halign") {
207 t.halign = val == "center" ? 1 : val == "right" ? 2 : 0;
208 }
209 }
210 i = semi == std::string::npos ? style.size() : semi + 1;
211 }
212 return t;
213}
214
215inline StyleParts parseStyle(const std::string &s)
216{
217 StyleParts p;
218 if (s.empty())
219 return p;
220 if (s.find('=') != std::string::npos) {
221 for (size_t i = 0; i < s.size();) {
222 size_t eq = s.find('=', i);
223 if (eq == std::string::npos)
224 break;
225 size_t semi = s.find(';', eq);
226 std::string key = s.substr(i, eq - i);
227 std::string val = s.substr(
228 eq + 1, (semi == std::string::npos ? s.size() : semi) - eq - 1);
229 if (key == "color") {
230 p.color = Rgb::fromHex(val);
231 p.hasColor = true;
232 } else if (key == "lineStyle") {
233 p.lineStyle = val;
234 } else if (key == "marker") {
235 p.marker = val;
236 } else if (key == "faceColor") {
237 p.faceColor = Rgb::fromHex(val);
238 p.hasFaceColor = true;
239 } else if (key == "edgeColor") {
240 p.edgeColor = Rgb::fromHex(val);
241 p.hasEdgeColor = true;
242 } else if (key == "binWidth") {
243 try {
244 p.binWidth = std::stod(val);
245 } catch (...) {
246 }
247 } else if (key == "faceAlpha") {
248 try {
249 p.faceAlpha = std::stod(val);
250 } catch (...) {
251 }
252 }
253 i = semi == std::string::npos ? s.size() : semi + 1;
254 }
255 return p;
256 }
257 static const std::string markers = "o+*.xsd^v<>ph";
258 size_t i = 0;
259 while (i < s.size()) {
260 if (p.lineStyle.empty() && s.compare(i, 2, "--") == 0) { p.lineStyle = "--"; i += 2; continue; }
261 if (p.lineStyle.empty() && s.compare(i, 2, "-.") == 0) { p.lineStyle = "-."; i += 2; continue; }
262 char c = s[i];
263 if (p.lineStyle.empty() && (c == '-' || c == ':')) { p.lineStyle = std::string(1, c); ++i; continue; }
264 if (!p.hasColor) {
265 Rgb col;
266 if (charSpecColor(c, col)) {
267 p.color = col;
268 p.hasColor = true;
269 ++i;
270 continue;
271 }
272 }
273 if (p.marker.empty() && markers.find(c) != std::string::npos) {
274 p.marker = std::string(1, c);
275 ++i;
276 continue;
277 }
278 ++i;
279 }
280 return p;
281}
282
283// ── Colormap tables ──────────────────────────────────────────────────
284// Ground truth: MATLAB R2025b feval(<name>, 256) dumped as hex RGB
285// (2026-09-11 probe; first parula entry 3e26a8 = (62,38,168) verified
286// against exported-PNG pixels). MATLAB maps a scaled CData value v to
287// entry round(t*(N-1)) + 1 (1-based) with t = (v-clo)/(chi-clo)
288// clamped to [0,1]; NaN renders as entry 1 (the first color).
289// viridis: MATLAB R2025b has no viridis() (exist()==0) — this table is
290// the matplotlib-approximate stops from the IDE colormaps.js, linearly
291// sampled to 256, so engine export and IDE render identically.
292struct NamedPaletteHex { const char *name; const char *hex; };
293inline const NamedPaletteHex kNamedPaletteHex[] = {
294 {"parula", "3e26a83e27ac3f28af3f29b2402ab4402bb7412cba412dbd422ebf422fc24330c54331c84332ca4433cd4434d04535d24537d54538d74639d9463adc463bde463de0473ee1473fe34741e54742e64744e84745e94746eb4848ec4849ed484bee484cf0484ef1484ff24850f34852f44853f54854f64756f74757f74759f8475af9475bfa475dfa465efb4660fb4661fc4562fc4564fd4465fd4367fd4368fe426afe416bfe406dfe3f6eff3e70ff3c71ff3b73ff3974ff3876fe3677fe3579fd337afd327cfc317dfc307ffb2f80fa2f82fa2e83f92e84f82e86f82e87f72d88f62d8af52d8bf42d8cf32d8ef22c8ff12c90f02b91ef2a93ee2994ed2895ec2797eb2798ea2699e9269ae8259be8259ce7249ee6249fe523a0e523a1e422a2e421a3e320a5e31fa6e21ea7e11da8e11da9e01caadf1babde1aacdd19addc17aeda16afd914b0d812b1d610b2d50eb3d40bb3d208b4d106b5cf04b6ce02b7cc01b7ca00b8c900b9c700bac601bac402bbc204bbc106bcbf09bdbd0dbdbc10beba14beb817bfb61ac0b51dc0b320c1b123c1af25c2ae27c2ac29c3aa2bc3a82cc4a62ec4a52fc5a331c5a132c69f33c79d35c79b36c89938c89639c9943bc9923dca9040ca8d42ca8b45cb8948cb864bcb844ecc8151cc7f54cc7c57cc7a5acc775ecd7461cd7264cd6f67cd6c6bcd696ecd6672cd6476cc6179cc5e7dcc5b81cc5984cc5688cb538bcb518fcb4e93ca4b96ca489ac9469dc943a1c840a4c83ea7c73babc739aec637b2c635b5c533b8c431bbc42fbec32dc2c32cc5c22ac8c129cbc128cec027d0bf27d3bf27d6be27d9be28dbbd28debc29e1bc2ae3bc2be6bb2de8bb2eeaba30ecba32efba35f1ba37f3ba39f5ba3bf7ba3df9ba3efbbb3efcbc3efebd3dfebe3cfec03bfec13afec239fec438fec537fec735fec834feca33fdcb32fdcd31fdce31fcd030fbd22ffbd32efad52ef9d62df9d82cf8d92bf7db2af7dd2af6de29f6e028f5e128f5e327f5e526f5e626f5e825f5e924f5eb23f5ec22f5ee21f6ef20f6f11ff6f21ef7f41cf7f51bf8f71af8f818f9f916f9fb15"},
295 {"turbo", "30123b32154333184a341b51351e5836215f37246638276d392a733a2d793b2f803c32863d358b3e38913f3b973f3e9c4040a24143a74146ac4249b1424bb5434eba4451bf4454c34456c74559cb455ccf455ed34661d64664da4666dd4669e0466be3476ee64771e94773eb4776ee4778f0477bf2467df44680f64682f84685fa4687fb458afc458cfd448ffe4391fe4294ff4196ff4099ff3e9bfe3d9efe3ba0fd3aa3fc38a5fb37a8fa35abf833adf731aff52fb2f42eb4f22cb7f02ab9ee28bceb27bee925c0e723c3e422c5e220c7df1fc9dd1ecbda1ccdd81bd0d51ad2d21ad4d019d5cd18d7ca18d9c818dbc518ddc218dec018e0bd19e2bb19e3b91ae4b61ce6b41de7b21fe9af20eaac22ebaa25eca727eea42aefa12cf09e2ff19b32f29835f39438f4913cf58e3ff68a43f78746f8844af8804ef97d52fa7a55fa7659fb735dfc6f61fc6c65fd6969fd666dfe6271fe5f75fe5c79fe597dff5680ff5384ff5188ff4e8bff4b8fff4992ff4796fe4499fe429cfe409ffd3fa1fd3da4fc3ca7fc3aa9fb39acfb38affa37b1f936b4f836b7f735b9f635bcf534bef434c1f334c3f134c6f034c8ef34cbed34cdec34d0ea34d2e935d4e735d7e535d9e436dbe236dde037dfdf37e1dd37e3db38e5d938e7d739e9d539ebd339ecd13aeecf3aefcd3af1cb3af2c93af4c73af5c53af6c33af7c13af8be39f9bc39faba39fbb838fbb637fcb336fcb136fdae35fdac34fea933fea732fea431fea130fe9e2ffe9b2dfe992cfe962bfe932afe9029fd8d27fd8a26fc8725fc8423fb8122fb7e21fa7b1ff9781ef9751df8721cf76f1af66c19f56918f46617f36315f26014f15d13f05b12ef5811ed5510ec530feb500eea4e0de84b0ce7490ce5470be4450ae2430ae14109df3f08dd3d08dc3b07da3907d83706d63506d43305d23105d02f05ce2d04cc2b04ca2a04c82803c52603c32503c12302be2102bc2002b91e02b71d02b41b01b21a01af1801ac1701a91601a71401a41301a112019e10019b0f01980e01950d01920b018e0a018b09028808028507028106027e05027a0403"},
296 {"jet", "00008300008700008b00008f00009300009700009b00009f0000a30000a70000ab0000af0000b30000b70000bb0000bf0000c30000c70000cb0000cf0000d30000d70000db0000df0000e30000e70000eb0000ef0000f30000f70000fb0000ff0004ff0008ff000cff0010ff0014ff0018ff001cff0020ff0024ff0028ff002cff0030ff0034ff0038ff003cff0040ff0044ff0048ff004cff0050ff0054ff0058ff005cff0060ff0064ff0068ff006cff0070ff0074ff0078ff007cff0080ff0083ff0087ff008bff008fff0093ff0097ff009bff009fff00a3ff00a7ff00abff00afff00b3ff00b7ff00bbff00bfff00c3ff00c7ff00cbff00cfff00d3ff00d7ff00dbff00dfff00e3ff00e7ff00ebff00efff00f3ff00f7ff00fbff00ffff04fffb08fff70cfff310ffef14ffeb18ffe71cffe320ffdf24ffdb28ffd72cffd330ffcf34ffcb38ffc73cffc340ffbf44ffbb48ffb74cffb350ffaf54ffab58ffa75cffa360ff9f64ff9b68ff976cff9370ff8f74ff8b78ff877cff8380ff8083ff7c87ff788bff748fff7093ff6c97ff689bff649fff60a3ff5ca7ff58abff54afff50b3ff4cb7ff48bbff44bfff40c3ff3cc7ff38cbff34cfff30d3ff2cd7ff28dbff24dfff20e3ff1ce7ff18ebff14efff10f3ff0cf7ff08fbff04ffff00fffb00fff700fff300ffef00ffeb00ffe700ffe300ffdf00ffdb00ffd700ffd300ffcf00ffcb00ffc700ffc300ffbf00ffbb00ffb700ffb300ffaf00ffab00ffa700ffa300ff9f00ff9b00ff9700ff9300ff8f00ff8b00ff8700ff8300ff8000ff7c00ff7800ff7400ff7000ff6c00ff6800ff6400ff6000ff5c00ff5800ff5400ff5000ff4c00ff4800ff4400ff4000ff3c00ff3800ff3400ff3000ff2c00ff2800ff2400ff2000ff1c00ff1800ff1400ff1000ff0c00ff0800ff0400ff0000fb0000f70000f30000ef0000eb0000e70000e30000df0000db0000d70000d30000cf0000cb0000c70000c30000bf0000bb0000b70000b30000af0000ab0000a70000a300009f00009b00009700009300008f00008b0000870000830000800000"},
297 {"hot", "0300000500000800000b00000d00001000001300001500001800001b00001d00002000002300002500002800002b00002d00003000003200003500003800003a00003d00004000004200004500004800004a00004d00005000005200005500005800005a00005d00006000006200006500006800006a00006d00007000007200007500007800007a00007d00008000008200008500008700008a00008d00008f00009200009500009700009a00009d00009f0000a20000a50000a70000aa0000ad0000af0000b20000b50000b70000ba0000bd0000bf0000c20000c50000c70000ca0000cd0000cf0000d20000d50000d70000da0000dc0000df0000e20000e40000e70000ea0000ec0000ef0000f20000f40000f70000fa0000fc0000ff0000ff0300ff0500ff0800ff0b00ff0d00ff1000ff1300ff1500ff1800ff1b00ff1d00ff2000ff2300ff2500ff2800ff2b00ff2d00ff3000ff3200ff3500ff3800ff3a00ff3d00ff4000ff4200ff4500ff4800ff4a00ff4d00ff5000ff5200ff5500ff5800ff5a00ff5d00ff6000ff6200ff6500ff6800ff6a00ff6d00ff7000ff7200ff7500ff7800ff7a00ff7d00ff8000ff8200ff8500ff8700ff8a00ff8d00ff8f00ff9200ff9500ff9700ff9a00ff9d00ff9f00ffa200ffa500ffa700ffaa00ffad00ffaf00ffb200ffb500ffb700ffba00ffbd00ffbf00ffc200ffc500ffc700ffca00ffcd00ffcf00ffd200ffd500ffd700ffda00ffdc00ffdf00ffe200ffe400ffe700ffea00ffec00ffef00fff200fff400fff700fffa00fffc00ffff00ffff04ffff08ffff0cffff10ffff14ffff18ffff1cffff20ffff24ffff28ffff2cffff30ffff34ffff38ffff3cffff40ffff44ffff48ffff4cffff50ffff54ffff58ffff5cffff60ffff64ffff68ffff6cffff70ffff74ffff78ffff7cffff80ffff83ffff87ffff8bffff8fffff93ffff97ffff9bffff9fffffa3ffffa7ffffabffffafffffb3ffffb7ffffbbffffbfffffc3ffffc7ffffcbffffcfffffd3ffffd7ffffdbffffdfffffe3ffffe7ffffebffffeffffff3fffff7fffffbffffff"},
298 {"cool", "00ffff01feff02fdff03fcff04fbff05faff06f9ff07f8ff08f7ff09f6ff0af5ff0bf4ff0cf3ff0df2ff0ef1ff0ff0ff10efff11eeff12edff13ecff14ebff15eaff16e9ff17e8ff18e7ff19e6ff1ae5ff1be4ff1ce3ff1de2ff1ee1ff1fe0ff20dfff21deff22ddff23dcff24dbff25daff26d9ff27d8ff28d7ff29d6ff2ad5ff2bd4ff2cd3ff2dd2ff2ed1ff2fd0ff30cfff31ceff32cdff33ccff34cbff35caff36c9ff37c8ff38c7ff39c6ff3ac5ff3bc4ff3cc3ff3dc2ff3ec1ff3fc0ff40bfff41beff42bdff43bcff44bbff45baff46b9ff47b8ff48b7ff49b6ff4ab5ff4bb4ff4cb3ff4db2ff4eb1ff4fb0ff50afff51aeff52adff53acff54abff55aaff56a9ff57a8ff58a7ff59a6ff5aa5ff5ba4ff5ca3ff5da2ff5ea1ff5fa0ff609fff619eff629dff639cff649bff659aff6699ff6798ff6897ff6996ff6a95ff6b94ff6c93ff6d92ff6e91ff6f90ff708fff718eff728dff738cff748bff758aff7689ff7788ff7887ff7986ff7a85ff7b84ff7c83ff7d82ff7e81ff7f80ff807fff817eff827dff837cff847bff857aff8679ff8778ff8877ff8976ff8a75ff8b74ff8c73ff8d72ff8e71ff8f70ff906fff916eff926dff936cff946bff956aff9669ff9768ff9867ff9966ff9a65ff9b64ff9c63ff9d62ff9e61ff9f60ffa05fffa15effa25dffa35cffa45bffa55affa659ffa758ffa857ffa956ffaa55ffab54ffac53ffad52ffae51ffaf50ffb04fffb14effb24dffb34cffb44bffb54affb649ffb748ffb847ffb946ffba45ffbb44ffbc43ffbd42ffbe41ffbf40ffc03fffc13effc23dffc33cffc43bffc53affc639ffc738ffc837ffc936ffca35ffcb34ffcc33ffcd32ffce31ffcf30ffd02fffd12effd22dffd32cffd42bffd52affd629ffd728ffd827ffd926ffda25ffdb24ffdc23ffdd22ffde21ffdf20ffe01fffe11effe21dffe31cffe41bffe51affe619ffe718ffe817ffe916ffea15ffeb14ffec13ffed12ffee11ffef10fff00ffff10efff20dfff30cfff40bfff50afff609fff708fff807fff906fffa05fffb04fffc03fffd02fffe01ffff00ff"},
299 {"gray", "0000000101010202020303030404040505050606060707070808080909090a0a0a0b0b0b0c0c0c0d0d0d0e0e0e0f0f0f1010101111111212121313131414141515151616161717171818181919191a1a1a1b1b1b1c1c1c1d1d1d1e1e1e1f1f1f2020202121212222222323232424242525252626262727272828282929292a2a2a2b2b2b2c2c2c2d2d2d2e2e2e2f2f2f3030303131313232323333333434343535353636363737373838383939393a3a3a3b3b3b3c3c3c3d3d3d3e3e3e3f3f3f4040404141414242424343434444444545454646464747474848484949494a4a4a4b4b4b4c4c4c4d4d4d4e4e4e4f4f4f5050505151515252525353535454545555555656565757575858585959595a5a5a5b5b5b5c5c5c5d5d5d5e5e5e5f5f5f6060606161616262626363636464646565656666666767676868686969696a6a6a6b6b6b6c6c6c6d6d6d6e6e6e6f6f6f7070707171717272727373737474747575757676767777777878787979797a7a7a7b7b7b7c7c7c7d7d7d7e7e7e7f7f7f8080808181818282828383838484848585858686868787878888888989898a8a8a8b8b8b8c8c8c8d8d8d8e8e8e8f8f8f9090909191919292929393939494949595959696969797979898989999999a9a9a9b9b9b9c9c9c9d9d9d9e9e9e9f9f9fa0a0a0a1a1a1a2a2a2a3a3a3a4a4a4a5a5a5a6a6a6a7a7a7a8a8a8a9a9a9aaaaaaabababacacacadadadaeaeaeafafafb0b0b0b1b1b1b2b2b2b3b3b3b4b4b4b5b5b5b6b6b6b7b7b7b8b8b8b9b9b9babababbbbbbbcbcbcbdbdbdbebebebfbfbfc0c0c0c1c1c1c2c2c2c3c3c3c4c4c4c5c5c5c6c6c6c7c7c7c8c8c8c9c9c9cacacacbcbcbcccccccdcdcdcecececfcfcfd0d0d0d1d1d1d2d2d2d3d3d3d4d4d4d5d5d5d6d6d6d7d7d7d8d8d8d9d9d9dadadadbdbdbdcdcdcdddddddedededfdfdfe0e0e0e1e1e1e2e2e2e3e3e3e4e4e4e5e5e5e6e6e6e7e7e7e8e8e8e9e9e9eaeaeaebebebecececedededeeeeeeefefeff0f0f0f1f1f1f2f2f2f3f3f3f4f4f4f5f5f5f6f6f6f7f7f7f8f8f8f9f9f9fafafafbfbfbfcfcfcfdfdfdfefefeffffff"},
300 {"bone", "00000001010202020303030404040504040605050806060907070a08080b09090c0a0a0e0b0b0f0b0b100c0c110d0d120e0e140f0f1510101611111712121812121a13131b14141c15151d16161f1717201818211818221919231a1a251b1b261c1c271d1d281e1e291f1f2b20202c20202d21212e22222f2323312424322525332626342727352727372828382929392a2a3a2b2b3b2c2c3d2d2d3e2e2e3f2e2e402f2f4230304331314432324533334634344835354935354a36364b37374c38384e39394f3a3a503b3b513c3c523c3c543d3d553e3e563f3f5740405841415a42425b43435c43435d44445e4545604646614747624848634949654a4a664a4a674b4b684c4c694d4d6b4e4e6c4f4f6d50506e51516f515171525272535373545474555675565776575877585977585a78595c795a5d7a5b5e7b5c5f7c5d607d5e627e5f637e5f647f606580616681626882636983646a84656b84666c85666e86676f876870886971896a738a6b748b6c758c6d768c6d778d6e798e6f7a8f707b90717c91727d92737f937480937481947582957683967785977886987987997a889a7b899a7b8b9b7c8c9c7d8d9d7e8e9e7f8f9f8091a08192a18293a18294a28396a38497a48598a58699a6879aa7889ca8899da8899ea98a9faa8ba0ab8ca2ac8da3ad8ea4ae8fa5af90a6af90a8b091a9b192aab293abb394acb495aeb596afb597b0b697b1b798b2b899b4b99ab5ba9bb6bb9cb7bc9db9bc9ebabd9ebbbe9fbcbfa0bdc0a1bfc1a2c0c2a3c1c3a4c2c3a5c3c4a5c5c5a6c6c6a7c7c7a8c8c8aac9c9abcacaadcbcbaecbcbafccccb1cdcdb2ceceb3cfcfb5d0d0b6d1d1b8d2d2b9d2d2bad3d3bcd4d4bdd5d5bed6d6c0d7d7c1d8d8c3d9d9c4d9d9c5dadac7dbdbc8dcdcc9ddddcbdedeccdfdfcee0e0cfe0e0d0e1e1d2e2e2d3e3e3d4e4e4d6e5e5d7e6e6d9e6e6dae7e7dbe8e8dde9e9deeaeadfebebe1ecece2edede4edede5eeeee6efefe8f0f0e9f1f1eaf2f2ecf3f3edf4f4eff4f4f0f5f5f1f6f6f3f7f7f4f8f8f5f9f9f7fafaf8fbfbfafbfbfbfcfcfcfdfdfefefeffffff"},
301 {"copper", "0000000101000302010402010503020604020805030905030a06040b07040d08050e09050f0906100a06120b07130c07140c08150d08160e09180f0919100a1a100a1c110b1d120b1e130c1f140c21140d22150d23160e24170e26170f27180f281910291a102b1b112c1b112d1c122e1d12301e13311e13321f143320143521153622153722163823163a24173b25173c25183d26183f271940281941291a42291a442a1b452b1b462c1c472d1c492d1d4a2e1d4b2f1e4c301e4e301f4f311f5032205133205334215434215535225636225837235937235a38245b39245d3a255e3b255f3b26603c26623d27633e27643e28653f2867402968412969422a6a422a6c432b6d442b6e452c6f462c71462d72472d73482e74492e76492f774a2f784b30794c307b4d317c4d317d4e327e4f328050338150338251348352348553358654358754368855368a56378b57378c57388d58388f5939905a39915b3a925b3a945c3b955d3b965e3c975f3c995f3d9a603d9b613e9c623e9e623f9f633fa06440a16540a36641a46641a56742a66842a86943a96943aa6a44ab6b44ac6c45ae6d45af6d46b06e46b26f47b37047b47048b57148b67249b87349b9744aba744abc754bbd764bbe774cbf784cc1784dc2794dc37a4ec47b4ec67b4fc77c4fc87d50c97e50cb7f51cc7f51cd8052ce8152d08253d18253d28354d38454d48555d68655d78656d88756da8857db8957dc8958dd8a58df8b59e08c59e18d5ae28d5ae48e5be58f5be6905ce7915ce9915dea925deb935eec945eee945fef955ff09660f19760f39861f49861f59962f69a62f89b63f99b63fa9c64fb9d64fc9e64fe9f65ff9f65ffa066ffa166ffa267ffa267ffa368ffa468ffa569ffa669ffa66affa76affa86bffa96bffaa6cffaa6cffab6dffac6dffad6effad6effae6fffaf6fffb070ffb170ffb171ffb271ffb372ffb472ffb473ffb573ffb674ffb774ffb875ffb875ffb976ffba76ffbb77ffbb77ffbc78ffbd78ffbe79ffbf79ffbf7affc07affc17bffc27bffc37cffc37cffc47dffc57dffc67effc67effc77f"},
302 {"spring", "ff00ffff01feff02fdff03fcff04fbff05faff06f9ff07f8ff08f7ff09f6ff0af5ff0bf4ff0cf3ff0df2ff0ef1ff0ff0ff10efff11eeff12edff13ecff14ebff15eaff16e9ff17e8ff18e7ff19e6ff1ae5ff1be4ff1ce3ff1de2ff1ee1ff1fe0ff20dfff21deff22ddff23dcff24dbff25daff26d9ff27d8ff28d7ff29d6ff2ad5ff2bd4ff2cd3ff2dd2ff2ed1ff2fd0ff30cfff31ceff32cdff33ccff34cbff35caff36c9ff37c8ff38c7ff39c6ff3ac5ff3bc4ff3cc3ff3dc2ff3ec1ff3fc0ff40bfff41beff42bdff43bcff44bbff45baff46b9ff47b8ff48b7ff49b6ff4ab5ff4bb4ff4cb3ff4db2ff4eb1ff4fb0ff50afff51aeff52adff53acff54abff55aaff56a9ff57a8ff58a7ff59a6ff5aa5ff5ba4ff5ca3ff5da2ff5ea1ff5fa0ff609fff619eff629dff639cff649bff659aff6699ff6798ff6897ff6996ff6a95ff6b94ff6c93ff6d92ff6e91ff6f90ff708fff718eff728dff738cff748bff758aff7689ff7788ff7887ff7986ff7a85ff7b84ff7c83ff7d82ff7e81ff7f80ff807fff817eff827dff837cff847bff857aff8679ff8778ff8877ff8976ff8a75ff8b74ff8c73ff8d72ff8e71ff8f70ff906fff916eff926dff936cff946bff956aff9669ff9768ff9867ff9966ff9a65ff9b64ff9c63ff9d62ff9e61ff9f60ffa05fffa15effa25dffa35cffa45bffa55affa659ffa758ffa857ffa956ffaa55ffab54ffac53ffad52ffae51ffaf50ffb04fffb14effb24dffb34cffb44bffb54affb649ffb748ffb847ffb946ffba45ffbb44ffbc43ffbd42ffbe41ffbf40ffc03fffc13effc23dffc33cffc43bffc53affc639ffc738ffc837ffc936ffca35ffcb34ffcc33ffcd32ffce31ffcf30ffd02fffd12effd22dffd32cffd42bffd52affd629ffd728ffd827ffd926ffda25ffdb24ffdc23ffdd22ffde21ffdf20ffe01fffe11effe21dffe31cffe41bffe51affe619ffe718ffe817ffe916ffea15ffeb14ffec13ffed12ffee11ffef10fff00ffff10efff20dfff30cfff40bfff50afff609fff708fff807fff906fffa05fffb04fffc03fffd02fffe01ffff00"},
303 {"summer", "0080660180660281660381660482660582660683660783660884660984660a85660b85660c86660d86660e87660f8766108866118866128966138966148a66158a66168b66178b66188c66198c661a8d661b8d661c8e661d8e661e8f661f8f662090662190662291662391662492662592662693662793662893662994662a95662b95662c96662d96662e97662f9766309866319866329966339966349a66359a66369b66379b66389c66399c663a9d663b9d663c9e663d9e663e9f663f9f6640a06641a06642a16643a16644a26645a26646a36647a36648a36649a4664aa5664ba5664ca6664da6664ea7664fa76650a86651a86652a96653a96654aa6655aa6656ab6657ab6658ac6659ac665aad665bad665cae665dae665eaf665faf6660b06661b06662b16663b16664b26665b26666b36667b36668b36669b4666ab5666bb5666cb6666db6666eb7666fb76670b86671b86672b96673b96674ba6675ba6676bb6677bb6678bc6679bc667abd667bbd667cbe667dbe667ebf667fbf6680c06681c06682c16683c16684c26685c26686c36687c36688c36689c4668ac4668bc5668cc6668dc6668ec7668fc76690c86691c86692c96693c96694ca6695ca6696cb6697cb6698cc6699cc669acd669bcd669cce669dce669ecf669fcf66a0d066a1d066a2d166a3d166a4d266a5d266a6d366a7d366a8d366a9d466aad466abd566acd666add666aed766afd766b0d866b1d866b2d966b3d966b4da66b5da66b6db66b7db66b8dc66b9dc66badd66bbdd66bcde66bdde66bedf66bfdf66c0e066c1e066c2e166c3e166c4e266c5e266c6e366c7e366c8e366c9e466cae466cbe566cce666cde666cee766cfe766d0e866d1e866d2e966d3e966d4ea66d5ea66d6eb66d7eb66d8ec66d9ec66daed66dbed66dcee66ddee66deef66dfef66e0f066e1f066e2f166e3f166e4f266e5f266e6f366e7f366e8f366e9f466eaf466ebf566ecf666edf666eef766eff766f0f866f1f866f2f966f3f966f4fa66f5fa66f6fb66f7fb66f8fc66f9fc66fafd66fbfd66fcfe66fdfe66feff66ffff66"},
304 {"autumn", "ff0000ff0100ff0200ff0300ff0400ff0500ff0600ff0700ff0800ff0900ff0a00ff0b00ff0c00ff0d00ff0e00ff0f00ff1000ff1100ff1200ff1300ff1400ff1500ff1600ff1700ff1800ff1900ff1a00ff1b00ff1c00ff1d00ff1e00ff1f00ff2000ff2100ff2200ff2300ff2400ff2500ff2600ff2700ff2800ff2900ff2a00ff2b00ff2c00ff2d00ff2e00ff2f00ff3000ff3100ff3200ff3300ff3400ff3500ff3600ff3700ff3800ff3900ff3a00ff3b00ff3c00ff3d00ff3e00ff3f00ff4000ff4100ff4200ff4300ff4400ff4500ff4600ff4700ff4800ff4900ff4a00ff4b00ff4c00ff4d00ff4e00ff4f00ff5000ff5100ff5200ff5300ff5400ff5500ff5600ff5700ff5800ff5900ff5a00ff5b00ff5c00ff5d00ff5e00ff5f00ff6000ff6100ff6200ff6300ff6400ff6500ff6600ff6700ff6800ff6900ff6a00ff6b00ff6c00ff6d00ff6e00ff6f00ff7000ff7100ff7200ff7300ff7400ff7500ff7600ff7700ff7800ff7900ff7a00ff7b00ff7c00ff7d00ff7e00ff7f00ff8000ff8100ff8200ff8300ff8400ff8500ff8600ff8700ff8800ff8900ff8a00ff8b00ff8c00ff8d00ff8e00ff8f00ff9000ff9100ff9200ff9300ff9400ff9500ff9600ff9700ff9800ff9900ff9a00ff9b00ff9c00ff9d00ff9e00ff9f00ffa000ffa100ffa200ffa300ffa400ffa500ffa600ffa700ffa800ffa900ffaa00ffab00ffac00ffad00ffae00ffaf00ffb000ffb100ffb200ffb300ffb400ffb500ffb600ffb700ffb800ffb900ffba00ffbb00ffbc00ffbd00ffbe00ffbf00ffc000ffc100ffc200ffc300ffc400ffc500ffc600ffc700ffc800ffc900ffca00ffcb00ffcc00ffcd00ffce00ffcf00ffd000ffd100ffd200ffd300ffd400ffd500ffd600ffd700ffd800ffd900ffda00ffdb00ffdc00ffdd00ffde00ffdf00ffe000ffe100ffe200ffe300ffe400ffe500ffe600ffe700ffe800ffe900ffea00ffeb00ffec00ffed00ffee00ffef00fff000fff100fff200fff300fff400fff500fff600fff700fff800fff900fffa00fffb00fffc00fffd00fffe00ffff00"},
305 {"winter", "0000ff0001ff0002fe0003fe0004fd0005fd0006fc0007fc0008fb0009fb000afa000bfa000cf9000df9000ef8000ff80010f70011f70012f60013f60014f50015f40016f40017f30018f30019f3001af2001bf2001cf1001df1001ef0001ff00020ef0021ef0022ee0023ee0024ed0025ed0026ec0027ec0028eb0029eb002aea002bea002ce9002de9002ee8002fe80030e70031e70032e60033e60034e50035e40036e40037e30038e30039e3003ae2003be2003ce1003de1003ee0003fe00040df0041df0042de0043de0044dd0045dd0046dc0047dc0048db0049db004ada004bda004cd9004dd9004ed8004fd80050d70051d70052d60053d60054d50055d50056d40057d30058d30059d3005ad2005bd2005cd1005dd1005ed0005fd00060cf0061cf0062ce0063ce0064cd0065cd0066cc0067cc0068cb0069cb006aca006bca006cc9006dc9006ec8006fc80070c70071c70072c60073c60074c50075c50076c40077c30078c30079c3007ac2007bc2007cc1007dc1007ec0007fc00080bf0081bf0082be0083be0084bd0085bd0086bc0087bc0088bb0089bb008aba008bba008cb9008db9008eb8008fb80090b70091b70092b60093b60094b50095b40096b40097b30098b30099b3009ab2009bb2009cb1009db1009eb0009fb000a0af00a1af00a2ae00a3ae00a4ad00a5ad00a6ac00a7ac00a8ab00a9ab00aaaa00abaa00aca900ada900aea800afa800b0a700b1a700b2a600b3a600b4a500b5a400b6a400b7a300b8a300b9a300baa200bba200bca100bda100bea000bfa000c09f00c19f00c29e00c39e00c49d00c59d00c69c00c79c00c89b00c99b00ca9a00cb9a00cc9900cd9900ce9800cf9800d09700d19700d29600d39600d49500d59400d69400d79300d89300d99300da9200db9200dc9100dd9100de9000df9000e08f00e18f00e28e00e38e00e48d00e58d00e68c00e78c00e88b00e98b00ea8a00eb8a00ec8900ed8900ee8800ef8800f08700f18700f28600f38600f48500f58400f68400f78400f88300f98300fa8200fb8200fc8100fd8100fe8000ff80"},
306 {"hsv", "ff0000ff0600ff0c00ff1200ff1800ff1e00ff2400ff2a00ff3000ff3600ff3c00ff4200ff4800ff4e00ff5400ff5a00ff6000ff6600ff6c00ff7200ff7800ff7e00ff8300ff8900ff8f00ff9500ff9b00ffa100ffa700ffad00ffb300ffb900ffbf00ffc500ffcb00ffd100ffd700ffdd00ffe300ffe900ffef00fff500fffb00fdff00f7ff00f1ff00ebff00e5ff00dfff00d9ff00d3ff00cdff00c7ff00c1ff00bbff00b5ff00afff00a9ff00a3ff009dff0097ff0091ff008bff0085ff0080ff007aff0074ff006eff0068ff0062ff005cff0056ff0050ff004aff0044ff003eff0038ff0032ff002cff0026ff0020ff001aff0014ff000eff0008ff0002ff0000ff0400ff0a00ff1000ff1600ff1c00ff2200ff2800ff2e00ff3400ff3a00ff4000ff4600ff4c00ff5200ff5800ff5e00ff6400ff6a00ff7000ff7600ff7c00ff8100ff8700ff8d00ff9300ff9900ff9f00ffa500ffab00ffb100ffb700ffbd00ffc300ffc900ffcf00ffd500ffdb00ffe100ffe700ffed00fff300fff900ffff00f9ff00f3ff00edff00e7ff00e1ff00dbff00d5ff00cfff00c9ff00c3ff00bdff00b7ff00b1ff00abff00a5ff009fff0099ff0093ff008dff0087ff0081ff007cff0076ff0070ff006aff0064ff005eff0058ff0052ff004cff0046ff0040ff003aff0034ff002eff0028ff0022ff001cff0016ff0010ff000aff0004ff0200ff0800ff0e00ff1400ff1a00ff2000ff2600ff2c00ff3200ff3800ff3e00ff4400ff4a00ff5000ff5600ff5c00ff6200ff6800ff6e00ff7400ff7a00ff8000ff8500ff8b00ff9100ff9700ff9d00ffa300ffa900ffaf00ffb500ffbb00ffc100ffc700ffcd00ffd300ffd900ffdf00ffe500ffeb00fff100fff700fffd00ffff00fbff00f5ff00efff00e9ff00e3ff00ddff00d7ff00d1ff00cbff00c5ff00bfff00b9ff00b3ff00adff00a7ff00a1ff009bff0095ff008fff0089ff0083ff007eff0078ff0072ff006cff0066ff0060ff005aff0054ff004eff0048ff0042ff003cff0036ff0030ff002aff0024ff001eff0018ff0012ff000cff0006"},
307 {"viridis", "44015444035544045745055845075945085a45095c450b5d450c5e450d5f460f6146106246126346136546146646166747176847186a471a6b471b6c471c6d471e6f471f70482071482273482374482475482676472776472877472978462b79462c79462d7a462e7b45307b45317c45327d44337e44357e44367f443780433881433a81433b82423c83423d83423f844140854141864142864144874045874046883f47883f48883e49883e4a893d4b893d4c893c4d893c4e893b4f8a3b508a3b518a3a538a3a548b39558b39568b38578b38588c37598c375a8c365b8c365c8c355d8d355e8d355f8d34608d34618d33628d33638d32648d32658d32668d31678d31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c728e2c738e2b748e2b758e2b768e2a778e2a788e29798e297a8e297b8e287c8e287d8e287e8e277f8e27808e27818e26828e26838e25848d25848d25858d24868d24878d24888d23898d238a8d238b8d228c8d228d8d228e8d218f8d21908d21918c21928c21938c21938b21948b21958b21968a21978a21988a219989219a89219b89229c88229d88229e88229f8722a08722a08722a18622a28622a38622a48522a58522a68522a78422a88424a98325aa8226ab8228ac8129ac802aad7f2cae7f2daf7e2eb07d30b17c31b27c32b37b34b47a35b57936b57937b67839b7773ab8763bb9753dba753ebb743fbc7341bd7242bd7243be7145bf7047c06f49c16d4bc16c4dc26b50c36a52c46854c46756c56658c6655ac7645cc7625ec86160c96063c95f65ca5d67cb5c69cc5b6bcc5a6dcd586fce5771cf5673cf5576d05478d1527ad1517cd24f7fd24e82d34c84d44a87d4498ad5478cd5458fd64492d64294d74097d73f99d83d9cd83b9fd93aa1d938a4da36a7da35a9db33acdb31afdc30b1dc2eb4dd2cb6dd2bb9de29bcde27bedf26c1df26c3df26c6e026c8e026cbe026cde126d0e126d2e126d5e226d8e226dae226dde326dfe325e2e325e4e425e7e425e9e425ece525eee525f1e525f3e625f6e625f8e625fbe725fde725"},
308};
309
310// Parsed-once 256x3 RGB tables, keyed by name (lowercase).
311inline const std::uint8_t *namedPalette(const std::string &name, int &n)
312{
313 static const std::map<std::string, std::vector<std::uint8_t>> tables = [] {
314 std::map<std::string, std::vector<std::uint8_t>> m;
315 for (const auto &p : kNamedPaletteHex) {
316 std::vector<std::uint8_t> v;
317 v.reserve(256 * 3);
318 for (size_t i = 0; i + 1 < strlen(p.hex); i += 2)
319 v.push_back((std::uint8_t)strtoul(std::string(p.hex, i, 2).c_str(),
320 nullptr, 16));
321 m[p.name] = std::move(v);
322 }
323 return m;
324 }();
325 std::string key;
326 for (char c : name)
327 key += (char)std::tolower((unsigned char)c);
328 auto it = tables.find(key);
329 if (it == tables.end())
330 it = tables.find("parula");
331 n = 256;
332 return it->second.data();
333}
334
335// Custom palette from colormap(M) JSON "[[r,g,b],...]" (values [0,1]).
336// figdetail::parseFlatDoubles stops at the first ']' (flat arrays), so
337// brackets are stripped first and the numbers re-wrapped — "[[0,0,0],
338// [1,0,0]]" → "[0,0,0,1,0,0]" — then grouped into RGB triplets.
339inline std::vector<Rgb> parseCustomPalette(const std::string &json)
340{
341 std::string flat = "[";
342 for (char ch : json)
343 if (ch != '[' && ch != ']')
344 flat += ch;
345 flat += "]";
346 auto v = figdetail::parseFlatDoubles(flat);
347 std::vector<Rgb> out;
348 out.reserve(v.size() / 3);
349 for (size_t k = 0; k + 3 <= v.size(); k += 3)
350 out.push_back(Rgb((std::uint8_t)std::lround(v[k] * 255),
351 (std::uint8_t)std::lround(v[k + 1] * 255),
352 (std::uint8_t)std::lround(v[k + 2] * 255)));
353 return out;
354}
355
356// ── Display list ─────────────────────────────────────────────────────
357struct Cmd {
358 enum Kind { LINE, POLY, TEXT, MARK, IMAGE } kind;
359 std::vector<double> xs, ys; // LINE/POLY pixel points; MARK single;
360 // IMAGE screen rect {x0,x1}/{y0,y1} of
361 // the DATA rect [ix0,ix1]x[iy0,iy1]
362 Rgb color;
363 double width = 1.0; // stroke width / marker size px
364 bool fill = false; // POLY: filled
365 bool dash = false; // LINE: dashed
366 std::string text; // TEXT
367 double fontSize = 12; // TEXT
368 double x = 0, y = 0; // TEXT baseline-left anchor / MARK centre
369 bool rot90 = false; // TEXT: rotate CCW (ylabel)
370 bool bold = false; // TEXT: FontWeight bold
371 std::string fontFamily; // TEXT: font name (SVG honours it;
372 // the raster backend is monospace)
373
374 // ── IMAGE (imagesc / image / imshow) ─────────────────────────────
375 // Indexed form: img holds cell indices 0..254 with 255 = NaN
376 // sentinel, COLUMN-major (idx = col*imgH + row) — the layout
377 // DatasetInfo::zQuantized already uses. rgbDirect form (imshow
378 // truecolor): img holds row-major RGBA QUADS (r,g,b,a — alpha is
379 // synthesized 255 for plain RGB input, see image.cpp). qLo/qHi is
380 // the baked quantization range; a cell's reconstructed value is
381 // qLo + idx/254*(qHi-qLo). climLo/climHi is the display window
382 // (CLim; auto = [qLo,qHi]).
383 std::vector<std::uint8_t> img;
384 int imgW = 0, imgH = 0;
385 double ix0 = 0, ix1 = 1, iy0 = 0, iy1 = 1;
386 double qLo = 0, qHi = 1;
387 double climLo = 0, climHi = 1;
388 std::string cmap = "parula";
389 std::string cmapCustom; // colormap(M) JSON, overrides cmap
390 bool rgbDirect = false;
391};
392
393// Screen-space RGB buffer for an IMAGE cmd, shared by the raster and
394// SVG backends so both produce identical pixels. The buffer size is the
395// rounded on-screen rect (at least 1x1); each output pixel samples the
396// containing cell via its centre (MATLAB-style nearest cell). Row/col 0
397// sit at the screen end where the DATA rect's iy0/ix0 edge landed — the
398// axes transform has already baked in yDir/xDir.
399inline std::vector<std::uint8_t> imageCmdRgb(const Cmd &c)
400{
401 double sx0 = std::min(c.xs[0], c.xs[1]), sx1 = std::max(c.xs[0], c.xs[1]);
402 double sy0 = std::min(c.ys[0], c.ys[1]), sy1 = std::max(c.ys[0], c.ys[1]);
403 int pw = std::max(1, (int)std::lround(sx1 - sx0));
404 int ph = std::max(1, (int)std::lround(sy1 - sy0));
405 std::vector<std::uint8_t> buf((size_t)pw * ph * 3, 255);
406
407 // u increases from the screen end of data edge ix0 toward ix1; same
408 // for v with iy0. When the transform flipped the axis, the ix0 end
409 // is the HIGH screen coordinate.
410 auto frac = [](double lo, double hi, double v) {
411 return hi > lo ? (v - lo) / (hi - lo) : 0.5;
412 };
413 std::vector<Rgb> custom;
414 if (!c.cmapCustom.empty())
415 custom = parseCustomPalette(c.cmapCustom);
416 int nPal = 256;
417 const std::uint8_t *pal = c.cmapCustom.empty()
418 ? namedPalette(c.cmap, nPal) : nullptr;
419 if (!custom.empty())
420 nPal = (int)custom.size();
421
422 for (int py = 0; py < ph; ++py) {
423 double cy = sy0 + py + 0.5;
424 double v = c.ys[0] <= c.ys[1] ? frac(sy0, sy1, cy)
425 : 1.0 - frac(sy0, sy1, cy);
426 int row = (int)(v * c.imgH);
427 if (row < 0) row = 0;
428 if (row >= c.imgH) row = c.imgH - 1;
429 for (int px = 0; px < pw; ++px) {
430 double cx = sx0 + px + 0.5;
431 double u = c.xs[0] <= c.xs[1] ? frac(sx0, sx1, cx)
432 : 1.0 - frac(sx0, sx1, cx);
433 int col = (int)(u * c.imgW);
434 if (col < 0) col = 0;
435 if (col >= c.imgW) col = c.imgW - 1;
436 Rgb rgb(255, 255, 255);
437 if (c.rgbDirect) {
438 size_t o = ((size_t)row * c.imgW + col) * 4; // RGBA quad
439 rgb = Rgb(c.img[o], c.img[o + 1], c.img[o + 2]);
440 } else {
441 std::uint8_t idx = c.img[(size_t)col * c.imgH + row];
442 int e = 0; // NaN sentinel and t<=0 both give entry 1
443 if (idx != 255) {
444 double val = c.qLo + (idx / 254.0) * (c.qHi - c.qLo);
445 double t = (val - c.climLo) / (c.climHi - c.climLo);
446 if (t < 0) t = 0;
447 if (t > 1) t = 1;
448 e = (int)std::lround(t * (nPal - 1));
449 if (e < 0) e = 0;
450 if (e >= nPal) e = nPal - 1;
451 }
452 if (pal) {
453 rgb = Rgb(pal[e * 3], pal[e * 3 + 1], pal[e * 3 + 2]);
454 } else {
455 rgb = custom[std::min((size_t)e, custom.size() - 1)];
456 }
457 }
458 size_t o = ((size_t)py * pw + px) * 3;
459 buf[o] = rgb.r;
460 buf[o + 1] = rgb.g;
461 buf[o + 2] = rgb.b;
462 }
463 }
464 return buf;
465}
466
467// Nested 2-D JSON rows "[[a,b],[c,d]]" → row-major doubles + dims.
468// Null entries (NaN cells) → NaN. Depth-tracked: only INNER brackets
469// (depth 2) delimit rows — the outer pair is the wrapper (counting it
470// as a row over-read the value vector).
471inline void parseNestedDoubles(const std::string &j, std::vector<double> &out,
472 size_t &rows, size_t &cols)
473{
474 out.clear();
475 rows = cols = 0;
476 size_t i = 0;
477 size_t colCount = 0;
478 int depth = 0;
479 while (i < j.size()) {
480 char c = j[i];
481 if (c == '[') {
482 ++depth;
483 if (depth == 2)
484 colCount = 0;
485 ++i;
486 } else if (c == ']') {
487 if (depth == 2) {
488 ++rows;
489 if (cols == 0)
490 cols = colCount;
491 }
492 --depth;
493 ++i;
494 } else if (c == ',' || c == ' ' || c == '\n' || c == '\t') {
495 ++i;
496 } else if (depth >= 2) {
497 size_t e = i;
498 while (e < j.size() && j[e] != ',' && j[e] != ']')
499 ++e;
500 std::string tok = j.substr(i, e - i);
501 bool nullTok = tok.find("null") != std::string::npos;
502 try {
503 out.push_back(nullTok ? std::nan("") : std::stod(tok));
504 } catch (...) {
505 out.push_back(std::nan(""));
506 }
507 ++colCount;
508 i = e;
509 } else {
510 ++i;
511 }
512 }
513 if (!rows)
514 out.clear();
515}
516
517// ── Ticks (ported from ide/src/components/plot/plotTicks.js) ─────────
518// MATLAB-exact auto ticks (probe-verified R2025b, portion 34): target
519// 10 intervals, ladder breakpoints 1/2/5 — [0,10]→step 1 (11 ticks),
520// [0,3]→step 0.5 (7), [0,7]→step 1 (8).
521inline std::vector<double> niceTicks(double mn, double mx, int target = 10)
522{
523 std::vector<double> major;
524 double range = mx - mn;
525 if (!(range > 0))
526 return {mn};
527 double rough = range / target;
528 double pow = std::pow(10.0, std::floor(std::log10(rough)));
529 double norm = rough / pow;
530 double step = norm <= 1.0 ? pow
531 : norm <= 2.0 ? 2 * pow
532 : norm <= 5.0 ? 5 * pow : 10 * pow;
533 for (double v = std::ceil(mn / step) * step; v <= mx + step * 1e-6; v += step)
534 major.push_back(std::stod(std::to_string(v).substr(0, 12)));
535 return major;
536}
537
538inline std::vector<double> logTicks(double mn, double mx)
539{
540 std::vector<double> major;
541 if (!(mn > 0) || !(mx > mn))
542 return major;
543 int lmin = (int)std::floor(std::log10(mn));
544 int lmax = (int)std::ceil(std::log10(mx));
545 for (int p = lmin; p <= lmax; ++p) {
546 double base = std::pow(10.0, p);
547 if (base >= mn && base <= mx)
548 major.push_back(base);
549 }
550 return major;
551}
552
553// Default tick label: %g-style with trailing-zero trim + exponent
554// compaction ("1e+06" → "1e6").
555inline std::string tickLabel(double v)
556{
557 if (v == 0)
558 return "0";
559 char buf[32];
560 std::snprintf(buf, sizeof buf, "%.6g", v);
561 std::string s = buf;
562 size_t e = s.find('e');
563 if (e != std::string::npos) {
564 std::string mant = s.substr(0, e);
565 std::string exp = s.substr(e + 1);
566 bool neg = !exp.empty() && exp[0] == '-';
567 size_t k = 0;
568 while (k < exp.size() && (exp[k] == '+' || exp[k] == '-'))
569 ++k;
570 while (k + 1 < exp.size() && exp[k] == '0')
571 exp.erase(k, 1);
572 if (mant.size() > 1 && mant.compare(mant.size() - 1, 1, "0") == 0
573 && mant.find('.') != std::string::npos)
574 while (mant.back() == '0')
575 mant.pop_back();
576 if (!mant.empty() && mant.back() == '.')
577 mant.pop_back();
578 s = mant + "e" + (neg ? "-" : "") + exp.substr(k);
579 } else if (s.find('.') != std::string::npos) {
580 while (!s.empty() && s.back() == '0')
581 s.pop_back();
582 if (!s.empty() && s.back() == '.')
583 s.pop_back();
584 }
585 return s;
586}
587
588// ── Per-axes data range ──────────────────────────────────────────────
589struct AxisRange {
590 double lo = 0, hi = 1;
591 bool log = false;
592 bool valid() const { return hi > lo; }
593 double map(double v, double p0, double p1) const // data → pixel
594 {
595 double t;
596 if (log) {
597 double a = std::log10(lo), b = std::log10(hi);
598 t = (std::log10(v) - a) / (b - a);
599 } else {
600 t = (v - lo) / (hi - lo);
601 }
602 return p0 + t * (p1 - p0);
603 }
604};
605
606inline void dataRange(const std::vector<double> &v, double &lo, double &hi)
607{
608 lo = +INFINITY;
609 hi = -INFINITY;
610 for (double x : v)
611 if (std::isfinite(x)) {
612 lo = std::min(lo, x);
613 hi = std::max(hi, x);
614 }
615}
616
617inline std::vector<double> parseVec(const std::string &json)
618{
619 return figdetail::parseFlatDoubles(json);
620}
621
622// ["a","b",…] tick-label json → strings.
623inline std::vector<std::string> parseTickLabelVec(const std::string &json)
624{
625 std::vector<std::string> labels;
626 size_t i = 0;
627 while (i < json.size()) {
628 size_t q1 = json.find('"', i);
629 if (q1 == std::string::npos)
630 break;
631 size_t q2 = json.find('"', q1 + 1);
632 if (q2 == std::string::npos)
633 break;
634 labels.push_back(json.substr(q1 + 1, q2 - q1 - 1));
635 i = q2 + 1;
636 }
637 return labels;
638}
639
640// ── Display-list builder ─────────────────────────────────────────────
641struct Rect { double x0, y0, x1, y1; }; // pixel, y DOWN
642
643inline void addLine(std::vector<Cmd> &out, std::vector<double> xs,
644 std::vector<double> ys, Rgb c, double w, bool dash)
645{
646 Cmd cmd;
647 cmd.kind = Cmd::LINE;
648 cmd.xs = std::move(xs);
649 cmd.ys = std::move(ys);
650 cmd.color = c;
651 cmd.width = w;
652 cmd.dash = dash;
653 out.push_back(std::move(cmd));
654}
655
656inline void addText(std::vector<Cmd> &out, const std::string &s, double x,
657 double y, double size, Rgb c, bool rot90 = false,
658 bool bold = false, const std::string &family = "")
659{
660 if (s.empty())
661 return;
662 Cmd cmd;
663 cmd.kind = Cmd::TEXT;
664 cmd.text = s;
665 cmd.x = x;
666 cmd.y = y;
667 cmd.fontSize = size;
668 cmd.color = c;
669 cmd.rot90 = rot90;
670 cmd.bold = bold;
671 cmd.fontFamily = family;
672 out.push_back(std::move(cmd));
673}
674
675inline void addMarker(std::vector<Cmd> &out, double x, double y, double size,
676 Rgb c, const std::string &kind)
677{
678 Cmd cmd;
679 cmd.kind = Cmd::MARK;
680 cmd.x = x;
681 cmd.y = y;
682 cmd.width = size;
683 cmd.color = c;
684 cmd.text = kind; // marker glyph selector
685 cmd.fill = true;
686 out.push_back(std::move(cmd));
687}
688
689inline void addPoly(std::vector<Cmd> &out, std::vector<double> xs,
690 std::vector<double> ys, Rgb c, bool fill)
691{
692 if (xs.size() < 2)
693 return;
694 Cmd cmd;
695 cmd.kind = Cmd::POLY;
696 cmd.xs = std::move(xs);
697 cmd.ys = std::move(ys);
698 cmd.color = c;
699 cmd.fill = fill;
700 cmd.width = 1;
701 out.push_back(std::move(cmd));
702}
703
704// Render ONE axes into its pixel rect. `seriesIdxBase` continues the
705// palette cycle across subplot cells (MATLAB restarts per axes; keep
706// simple: per-axes reset).
707inline void renderAxes(const FigureState &fig, const AxesState &ax, Rect box,
708 std::vector<Cmd> &out)
709{
710 const Rgb kAxisCol(0x40, 0x40, 0x44);
711 const Rgb kGridCol(0xd8, 0xd8, 0xdc);
712 const Rgb kTextCol(0x28, 0x28, 0x30);
713
714 // Margin: room for tick labels + axis labels. axis off (`axis off`,
715 // imshow) drops the decorations — a slim inset only, the image gets
716 // the canvas (probe: imshow fills the saved figure).
717 double ml = 64, mr = 14, mt = 20, mb = 48;
718 if (!ax.title.empty())
719 mt += 18;
720 // Colorbar reserves space on its side (MATLAB eastoutside shrinks the
721 // axes to ~0.68 width; we reserve strip + ticks + labels).
722 std::string cbSide = ax.colorbarLocation;
723 for (char &c : cbSide)
724 c = (char)std::tolower((unsigned char)c);
725 if (cbSide.size() > 7
726 && cbSide.compare(cbSide.size() - 7, 7, "outside") == 0)
727 cbSide.erase(cbSide.size() - 7);
728 if (cbSide.empty() || cbSide == "best")
729 cbSide = ax.colorbarLocation.empty() ? "" : "east";
730 if (!cbSide.empty() && cbSide != "none") {
731 if (cbSide == "east")
732 mr += 48;
733 else if (cbSide == "west")
734 ml += 48;
735 else if (cbSide == "south")
736 mb += 48;
737 else if (cbSide == "north")
738 mt += 34;
739 }
740 Rect plot{box.x0 + ml, box.y0 + mt, box.x1 - mr, box.y1 - mb};
741 if (!ax.axisVisible) {
742 plot = Rect{box.x0 + 4, box.y0 + 4, box.x1 - 4, box.y1 - 4};
743 if (!ax.title.empty())
744 plot.y0 += 18;
745 }
746
747 // ── Collect series data → ranges ────────────────────────────────
748 auto applyLim = [](const std::string &json, double &lo, double &hi) {
749 if (json.empty())
750 return;
751 auto v = figdetail::parseFlatDoubles(json);
752 if (v.size() == 2) {
753 lo = v[0];
754 hi = v[1];
755 }
756 };
757 double xlo = +INFINITY, xhi = -INFINITY, ylo = +INFINITY, yhi = -INFINITY;
758 std::vector<std::vector<double>> xs(ax.datasets.size()), ys(ax.datasets.size());
759 int imgDatasets = 0, seriesDatasets = 0;
760
761 // Image data rect (probe-verified MATLAB R2025b, portion 35). The
762 // wire xJson/yJson are "[1,C]"/"[1,R]" ranges by default, a
763 // per-column/row CENTER vector when numel==cells, or EDGE vectors
764 // when numel==cells+1. Image edges: centers get ±spacing/2 (the
765 // default [1,C] reduces to ±0.5); edge vectors are used as-is. When
766 // the axes limits are auto, MATLAB pads the image extent by
767 // span/(2*(cells-1)) where span = x(end)-x(1) of the GIVEN vector —
768 // e.g. default 3x4 image → XLim [0.5 4.5]; centers [10..40] →
769 // [5 45]; edges [10..50] → [3.333 56.667].
770 auto imageEdges = [](const std::vector<double> &v, size_t cells,
771 double &e0, double &e1, double &pad) {
772 if (v.size() < 2 || cells == 0) {
773 e0 = 0.5;
774 e1 = cells > 0 ? cells + 0.5 : 1.5;
775 pad = 0.5;
776 return;
777 }
778 double span = v.back() - v.front();
779 if (v.size() == cells + 1) { // edges as given
780 e0 = v.front();
781 e1 = v.back();
782 } else { // centers (or [1,C] range)
783 double sp = cells > 1 ? span / (double)(cells - 1) : (span > 0 ? span : 1.0);
784 e0 = v.front() - sp / 2;
785 e1 = v.back() + sp / 2;
786 }
787 pad = cells > 1 ? span / (2.0 * (double)(cells - 1)) : (span > 0 ? span / 2 : 0.5);
788 };
789 for (size_t i = 0; i < ax.datasets.size(); ++i) {
790 const DatasetInfo &ds = ax.datasets[i];
791 if (!ds.visible)
792 continue;
793 if (ds.type == "surf" || ds.type == "mesh" || ds.type == "waterfall"
794 || (ds.type == "plot3" && !ds.zJson.empty())) {
795 continue; // rendered in the 3-D block above
796 }
797 if (ds.type == "imagesc" || ds.type == "image-rgb") {
798 ++imgDatasets;
799 size_t cols = ds.originalCols ? ds.originalCols : 1;
800 size_t rows = ds.originalRows ? ds.originalRows : 1;
801 double e0, e1, pd;
802 imageEdges(parseVec(ds.xJson), cols, e0, e1, pd);
803 xlo = std::min(xlo, e0 - pd);
804 xhi = std::max(xhi, e1 + pd);
805 imageEdges(parseVec(ds.yJson), rows, e0, e1, pd);
806 ylo = std::min(ylo, e0 - pd);
807 yhi = std::max(yhi, e1 + pd);
808 continue;
809 }
810 if (ds.type == "text")
811 continue; // deferred: text layer
812 ++seriesDatasets;
813 xs[i] = parseVec(ds.xJson);
814 ys[i] = parseVec(ds.yJson);
815 if (xs[i].empty()) {
816 xs[i].resize(ys[i].size());
817 for (size_t k = 0; k < ys[i].size(); ++k)
818 xs[i][k] = (double)(k + 1);
819 }
820 double a, b;
821 dataRange(xs[i], a, b);
822 xlo = std::min(xlo, a);
823 xhi = std::max(xhi, b);
824 dataRange(ys[i], a, b);
825 ylo = std::min(ylo, a);
826 yhi = std::max(yhi, a * 0 + b);
827 // quiver: arrow tips extend the data range (u/v offsets).
828 if (ds.type == "quiver") {
829 auto uv = parseVec(ds.uJson);
830 auto vv = parseVec(ds.vJson);
831 const size_t nq = std::min(xs[i].size(), ys[i].size());
832 for (size_t k = 0; k < nq && k < uv.size(); ++k) {
833 xlo = std::min(xlo, xs[i][k] + uv[k]);
834 xhi = std::max(xhi, xs[i][k] + uv[k]);
835 }
836 for (size_t k = 0; k < nq && k < vv.size(); ++k) {
837 ylo = std::min(ylo, ys[i][k] + vv[k]);
838 yhi = std::max(yhi, ys[i][k] + vv[k]);
839 }
840 }
841 // errorbar extents
842 auto ext = [&](const std::string &j, bool pos) {
843 auto e = parseVec(j);
844 if (e.empty())
845 return;
846 for (size_t k = 0; k < e.size() && k < ys[i].size(); ++k) {
847 ylo = std::min(ylo, ys[i][k] - (pos ? 0 : e[k]));
848 yhi = std::max(yhi, ys[i][k] + (pos ? e[k] : 0));
849 }
850 };
851 ext(ds.eJson, true);
852 ext(ds.eNegJson, false);
853 ext(ds.ePosJson, true);
854 if (ds.type == "stem") {
855 ylo = std::min(ylo, 0.0);
856 yhi = std::max(yhi, 0.0);
857 }
858 }
859 if (!(xhi > xlo)) {
860 xlo = 0;
861 xhi = xhi > xlo ? xhi : xlo + 1;
862 }
863 if (!(yhi > ylo)) {
864 double mid = ylo;
865 ylo = mid - 0.5;
866 yhi = mid + 0.5;
867 }
868 applyLim(ax.xlimJson, xlo, xhi);
869 applyLim(ax.ylimJson, ylo, yhi);
870 // 5% padding on non-explicit ranges (MATLAB-style breathing room).
871 auto pad = [](double &lo, double &hi, const std::string &lim) {
872 if (!lim.empty())
873 return;
874 double d = (hi - lo) * 0.05;
875 lo -= d;
876 hi += d;
877 };
878 // Image-only axes keep MATLAB's exact image limits (probe: XLim
879 // [0.5 4.5] for a 3x4 — NO extra 5% breathing room). Mixed
880 // image+series axes approximate MATLAB's union algorithm with the
881 // usual 5% pad on top (rare in practice; noted in memory).
882 bool imageOnly = imgDatasets > 0 && seriesDatasets == 0;
883 if (!imageOnly) {
884 pad(xlo, xhi, ax.xlimJson);
885 pad(ylo, yhi, ax.ylimJson);
886 }
887 if (ax.xscale == "log") {
888 xlo = std::max(xlo, 1e-12);
889 if (!(xhi > xlo))
890 xhi = xlo * 100;
891 }
892 if (ax.yscale == "log") {
893 ylo = std::max(ylo, 1e-12);
894 if (!(yhi > ylo))
895 yhi = ylo * 100;
896 }
897
898 // axis image / axis equal — equal data units per pixel on both
899 // axes: shrink the plot rect to the data aspect (MATLAB keeps the
900 // box centered-ish; probe: imshow renders undistorted cells).
901 if (ax.axisMode == "image" || ax.axisMode == "equal") {
902 double dataAspect = (xhi - xlo) / (yhi - ylo);
903 double pxAspect = (plot.x1 - plot.x0) / (plot.y1 - plot.y0);
904 if (dataAspect > 0 && pxAspect > 0) {
905 if (pxAspect > dataAspect) {
906 double w = (plot.y1 - plot.y0) * dataAspect;
907 double cx = (plot.x0 + plot.x1) / 2;
908 plot.x0 = cx - w / 2;
909 plot.x1 = cx + w / 2;
910 } else {
911 double h = (plot.x1 - plot.x0) / dataAspect;
912 double cy = (plot.y0 + plot.y1) / 2;
913 plot.y0 = cy - h / 2;
914 plot.y1 = cy + h / 2;
915 }
916 }
917 }
918
919 AxisRange XR{xlo, xhi, ax.xscale == "log"};
920 AxisRange YR{ylo, yhi, ax.yscale == "log"};
921 double px0 = plot.x0, px1 = plot.x1;
922 double py0 = plot.y1, py1 = plot.y0; // y DOWN: data-up = pixel-up
923 if (ax.xDir == "reverse")
924 std::swap(px0, px1);
925 if (ax.yDir == "reverse")
926 std::swap(py0, py1);
927 auto X = [&](double v) { return XR.map(v, px0, px1); };
928 auto Y = [&](double v) { return YR.map(v, py0, py1); };
929
930 // ── Grid (under the data) ───────────────────────────────────────
931 if (ax.gridMajor && ax.gridUserTouched) {
932 auto gx = XR.log ? logTicks(xlo, xhi) : niceTicks(xlo, xhi);
933 auto gy = YR.log ? logTicks(ylo, yhi) : niceTicks(ylo, yhi);
934 for (double v : gx)
935 addLine(out, {X(v), X(v)}, {plot.y0, plot.y1}, kGridCol, 1, false);
936 for (double v : gy)
937 addLine(out, {plot.x0, plot.x1}, {Y(v), Y(v)}, kGridCol, 1, false);
938 }
939
940 // ── Images (under series — MATLAB draws lines over the image) ───
941 for (size_t i = 0; i < ax.datasets.size(); ++i) {
942 const DatasetInfo &ds = ax.datasets[i];
943 if (!ds.visible || (ds.type != "imagesc" && ds.type != "image-rgb"))
944 continue;
945 size_t cols = ds.originalCols ? ds.originalCols : 1;
946 size_t rows = ds.originalRows ? ds.originalRows : 1;
947 double e0, e1, pd;
948 imageEdges(parseVec(ds.xJson), cols, e0, e1, pd);
949 double xe0 = e0, xe1 = e1;
950 imageEdges(parseVec(ds.yJson), rows, e0, e1, pd);
951 double ye0 = e0, ye1 = e1;
952 Cmd c;
953 c.kind = Cmd::IMAGE;
954 if (ds.type == "imagesc") {
955 c.img = ds.zQuantized;
956 c.imgW = (int)cols;
957 c.imgH = (int)rows;
958 c.qLo = ds.cminOrig;
959 c.qHi = ds.cmaxOrig;
960 auto cl = figdetail::parseFlatDoubles(ax.climJson);
961 if (cl.size() == 2 && cl[0] < cl[1]) {
962 c.climLo = cl[0];
963 c.climHi = cl[1];
964 } else {
965 c.climLo = c.qLo; // CLim auto = data range
966 c.climHi = c.qHi;
967 }
968 c.cmap = ax.colormapName.empty() ? "parula" : ax.colormapName;
969 c.cmapCustom = ax.customColormapJson;
970 } else {
971 c.img = ds.rgbBytes;
972 c.imgW = (int)cols;
973 c.imgH = (int)rows;
974 c.rgbDirect = true;
975 }
976 c.ix0 = xe0;
977 c.ix1 = xe1;
978 c.iy0 = ye0;
979 c.iy1 = ye1;
980 c.xs = {X(xe0), X(xe1)};
981 c.ys = {Y(ye0), Y(ye1)};
982 out.push_back(std::move(c));
983 }
984
985 // ── 3-D content (portion 40; MATLAB probes R2025b): surf = flat ──
986 // per-quad faces coloured by the FIRST corner's Z through the
987 // colormap (FaceColor 'flat', CData=Z), edges (0.129)x3; mesh /
988 // waterfall = segments coloured by Z (EdgeColor 'flat'); view from
989 // ax.viewJson, default (-37.5, 30); orthographic az/el projection;
990 // painter's sort (far quads first). clim auto = [min Z, max Z] —
991 // same rule as imagesc. The 3-D box replaces the 2-D frame.
992 {
993 bool has3d = false;
994 for (const auto &ds : ax.datasets)
995 if (ds.visible
996 && (ds.type == "surf" || ds.type == "mesh"
997 || ds.type == "waterfall"
998 || (ds.type == "plot3" && !ds.zJson.empty()))
999 && !ds.zJson.empty())
1000 has3d = true;
1001 if (has3d) {
1002 // Bounds over all 3-D datasets.
1003 double xlo3 = 1e300, xhi3 = -1e300, ylo3 = 1e300,
1004 yhi3 = -1e300, zlo3 = 1e300, zhi3 = -1e300;
1005 auto acc = [](double &lo, double &hi, double v) {
1006 if (!std::isfinite(v))
1007 return;
1008 lo = std::min(lo, v);
1009 hi = std::max(hi, v);
1010 };
1011 struct SurfGrid {
1012 std::vector<double> z; // row-major R x C
1013 std::vector<double> gx; // column coords
1014 std::vector<double> gy; // row coords
1015 size_t r, c;
1016 };
1017 std::vector<SurfGrid> grids;
1018 for (const auto &ds : ax.datasets) {
1019 if (!ds.visible || ds.zJson.empty())
1020 continue;
1021 if (ds.type == "surf" || ds.type == "waterfall") {
1022 auto xv = parseVec(ds.xJson);
1023 auto yv = parseVec(ds.yJson);
1024 std::vector<double> zf;
1025 size_t r = 0, c = 0;
1026 parseNestedDoubles(ds.zJson, zf, r, c);
1027 if (xv.empty() || yv.empty() || !r || !c)
1028 continue;
1029 SurfGrid g;
1030 g.z = std::move(zf);
1031 for (size_t cc = 0; cc < c; ++cc)
1032 g.gx.push_back(xv[std::min(cc, xv.size() - 1)]);
1033 for (size_t rr = 0; rr < r; ++rr)
1034 g.gy.push_back(yv[std::min(rr, yv.size() - 1)]);
1035 g.r = r;
1036 g.c = c;
1037 for (size_t rr = 0; rr < r; ++rr)
1038 for (size_t cc = 0; cc < c; ++cc) {
1039 acc(xlo3, xhi3, g.gx[cc]);
1040 acc(ylo3, yhi3, g.gy[rr]);
1041 acc(zlo3, zhi3, g.z[rr * c + cc]);
1042 }
1043 grids.push_back(std::move(g));
1044 } else if (ds.type == "mesh" || ds.type == "plot3") {
1045 auto xv = parseVec(ds.xJson);
1046 auto yv = parseVec(ds.yJson);
1047 auto zv = parseVec(ds.zJson);
1048 size_t n = std::min(std::min(xv.size(), yv.size()),
1049 zv.size());
1050 for (size_t k = 0; k < n; ++k) {
1051 acc(xlo3, xhi3, xv[k]);
1052 acc(ylo3, yhi3, yv[k]);
1053 acc(zlo3, zhi3, zv[k]);
1054 }
1055 }
1056 }
1057 if (!(xhi3 > xlo3)) { xlo3 = 0; xhi3 = 1; }
1058 if (!(yhi3 > ylo3)) { ylo3 = 0; yhi3 = 1; }
1059 if (!(zhi3 > zlo3)) { zlo3 = 0; zhi3 = 1; }
1060 applyLim(ax.xlimJson, xlo3, xhi3);
1061 applyLim(ax.ylimJson, ylo3, yhi3);
1062 applyLim(ax.zlimJson, zlo3, zhi3);
1063 // CLim + palette (same resolution as imagesc/colorbar).
1064 double clo3 = zlo3, chi3 = zhi3;
1065 {
1066 auto cl = figdetail::parseFlatDoubles(ax.climJson);
1067 if (cl.size() == 2 && cl[0] < cl[1]) {
1068 clo3 = cl[0];
1069 chi3 = cl[1];
1070 }
1071 }
1072 std::vector<Rgb> custom3;
1073 if (!ax.customColormapJson.empty())
1074 custom3 = parseCustomPalette(ax.customColormapJson);
1075 int nPal3 = 256;
1076 const std::uint8_t *pal3 =
1077 custom3.empty()
1078 ? namedPalette(ax.colormapName.empty()
1079 ? std::string("parula")
1080 : ax.colormapName, nPal3)
1081 : nullptr;
1082 if (!custom3.empty())
1083 nPal3 = (int)custom3.size();
1084 auto zColor = [&](double z) -> Rgb {
1085 if (!std::isfinite(z))
1086 z = clo3;
1087 double t = (z - clo3) / (chi3 - clo3);
1088 if (t < 0) t = 0;
1089 if (t > 1) t = 1;
1090 int e = (int)std::lround(t * (nPal3 - 1));
1091 if (e < 0) e = 0;
1092 if (e >= nPal3) e = nPal3 - 1;
1093 return pal3 ? Rgb(pal3[e * 3], pal3[e * 3 + 1],
1094 pal3[e * 3 + 2])
1095 : custom3[(size_t)e];
1096 };
1097 // View (az, el) in degrees; MATLAB orthographic transform.
1098 double azDeg = -37.5, elDeg = 30;
1099 {
1100 auto vv = figdetail::parseFlatDoubles(ax.viewJson);
1101 if (vv.size() == 2) {
1102 azDeg = vv[0];
1103 elDeg = vv[1];
1104 }
1105 }
1106 const double az = azDeg * 3.14159265358979323846 / 180.0;
1107 const double el = elDeg * 3.14159265358979323846 / 180.0;
1108 const double sAz = std::sin(az), cAz = std::cos(az);
1109 const double sEl = std::sin(el), cEl = std::cos(el);
1110 auto norm3 = [&](double v, double lo, double hi) {
1111 return hi > lo ? (v - lo) / (hi - lo) - 0.5 : 0.0;
1112 };
1113 // (sx, sy) in MATLAB screen space (y up), empirically
1114 // pinned by the corner-blob orientation test (view [0 90],
1115 // hot corner at data (x_min, y_min) renders LEFT-BOTTOM in
1116 // MATLAB R2025b): sx = -x*sin + y*cos; sy = (x*cos +
1117 // y*sin)*sin(el) + z*cos(el). Depth toward the camera =
1118 // -forward . p with forward = right x up (painter's sort,
1119 // larger = nearer).
1120 auto proj = [&](double x, double y, double z,
1121 double &sx, double &sy, double &depth) {
1122 double xn = norm3(x, xlo3, xhi3);
1123 double yn = norm3(y, ylo3, yhi3);
1124 double zn = norm3(z, zlo3, zhi3);
1125 sx = -xn * sAz + yn * cAz;
1126 sy = (xn * cAz + yn * sAz) * sEl + zn * cEl;
1127 depth = zn * sEl - cEl * (xn * cAz + yn * sAz);
1128 };
1129 // Fit the projected unit cube into the plot rect.
1130 double minSx = 1e300, maxSx = -1e300, minSy = 1e300,
1131 maxSy = -1e300;
1132 for (int corner = 0; corner < 8; ++corner) {
1133 double sx, sy, d;
1134 proj(corner & 1 ? xhi3 : xlo3, corner & 2 ? yhi3 : ylo3,
1135 corner & 4 ? zhi3 : zlo3, sx, sy, d);
1136 minSx = std::min(minSx, sx);
1137 maxSx = std::max(maxSx, sx);
1138 minSy = std::min(minSy, sy);
1139 maxSy = std::max(maxSy, sy);
1140 }
1141 double spanX = std::max(1e-9, maxSx - minSx);
1142 double spanY = std::max(1e-9, maxSy - minSy);
1143 double availW = (plot.x1 - plot.x0) * 0.92;
1144 double availH = (plot.y1 - plot.y0) * 0.92;
1145 double sc = std::min(availW / spanX, availH / spanY);
1146 auto PX = [&](double sx) {
1147 return (plot.x0 + plot.x1) / 2
1148 + (sx - (minSx + maxSx) / 2) * sc;
1149 };
1150 auto PY = [&](double sy) {
1151 return (plot.y0 + plot.y1) / 2
1152 - (sy - (minSy + maxSy) / 2) * sc;
1153 };
1154 // Box: the 12 cube edges, projected.
1155 if (ax.axisVisible) {
1156 const double bx[2] = {xlo3, xhi3}, by[2] = {ylo3, yhi3},
1157 bz[2] = {zlo3, zhi3};
1158 for (int axis = 0; axis < 3; ++axis)
1159 for (int f1 = 0; f1 <= 1; ++f1)
1160 for (int f2 = 0; f2 <= 1; ++f2) {
1161 double q0[3], q1[3];
1162 int o1 = (axis + 1) % 3, o2 = (axis + 2) % 3;
1163 double fd0[3] = {0, 0, 0}, fd1[3] = {0, 0, 0};
1164 fd0[axis] = 0; fd1[axis] = 1;
1165 fd0[o1] = f1; fd1[o1] = f1;
1166 fd0[o2] = f2; fd1[o2] = f2;
1167 double w0[3] = {bx[(int)fd0[0]], by[(int)fd0[1]],
1168 bz[(int)fd0[2]]};
1169 double w1[3] = {bx[(int)fd1[0]], by[(int)fd1[1]],
1170 bz[(int)fd1[2]]};
1171 (void)q0; (void)q1;
1172 double s0x, s0y, d0, s1x, s1y, d1;
1173 proj(w0[0], w0[1], w0[2], s0x, s0y, d0);
1174 proj(w1[0], w1[1], w1[2], s1x, s1y, d1);
1175 addLine(out, {PX(s0x), PX(s1x)},
1176 {PY(s0y), PY(s1y)}, kAxisCol, 0.75,
1177 false);
1178 }
1179 }
1180 // surf quads, painter's sort (far first).
1181 for (const auto &g : grids) {
1182 if (g.r < 2 || g.c < 2)
1183 continue; // single cell — no quads (mesh lines only)
1184 struct Q {
1185 double px[4], py[4], depth;
1186 Rgb col;
1187 };
1188 std::vector<Q> quads;
1189 for (size_t rr = 0; rr + 1 < g.r; ++rr)
1190 for (size_t cc = 0; cc + 1 < g.c; ++cc) {
1191 double z00 = g.z[rr * g.c + cc];
1192 double z10 = g.z[(rr + 1) * g.c + cc];
1193 double z01 = g.z[rr * g.c + cc + 1];
1194 double z11 = g.z[(rr + 1) * g.c + cc + 1];
1195 if (!std::isfinite(z00) || !std::isfinite(z10)
1196 || !std::isfinite(z01) || !std::isfinite(z11))
1197 continue;
1198 double xs[4] = {g.gx[cc], g.gx[cc + 1], g.gx[cc + 1],
1199 g.gx[cc]};
1200 double ys[4] = {g.gy[rr], g.gy[rr], g.gy[rr + 1],
1201 g.gy[rr + 1]};
1202 double zs[4] = {z00, z01, z11, z10};
1203 Q q;
1204 q.depth = 0;
1205 for (int k = 0; k < 4; ++k) {
1206 double sx, sy, d;
1207 proj(xs[k], ys[k], zs[k], sx, sy, d);
1208 q.px[k] = PX(sx);
1209 q.py[k] = PY(sy);
1210 q.depth += d;
1211 }
1212 q.depth /= 4;
1213 q.col = zColor(z00); // FaceColor 'flat'
1214 quads.push_back(q);
1215 }
1216 std::sort(quads.begin(), quads.end(),
1217 [](const Q &a, const Q &b) {
1218 return a.depth < b.depth;
1219 });
1220 const Rgb edge3(33, 33, 33); // MATLAB 0.1294 gray
1221 for (const auto &q : quads) {
1222 addPoly(out, {q.px[0], q.px[1], q.px[2], q.px[3]},
1223 {q.py[0], q.py[1], q.py[2], q.py[3]}, q.col,
1224 true);
1225 if (ax.axisVisible)
1226 addLine(out,
1227 {q.px[0], q.px[1], q.px[2], q.px[3],
1228 q.px[0]},
1229 {q.py[0], q.py[1], q.py[2], q.py[3],
1230 q.py[0]}, edge3, 0.75, false);
1231 }
1232 }
1233 // mesh / waterfall / plot3 polylines: segments; mesh and
1234 // waterfall coloured by the segment's first Z ('flat').
1235 for (const auto &ds : ax.datasets) {
1236 if (!ds.visible || ds.zJson.empty())
1237 continue;
1238 if (ds.type != "mesh" && ds.type != "plot3"
1239 && ds.type != "waterfall")
1240 continue;
1241 if (ds.type == "waterfall") {
1242 auto xv = parseVec(ds.xJson);
1243 auto yv = parseVec(ds.yJson);
1244 std::vector<double> zf;
1245 size_t r, c;
1246 parseNestedDoubles(ds.zJson, zf, r, c);
1247 if (!r || !c)
1248 continue;
1249 for (size_t rr = 0; rr < r; ++rr)
1250 for (size_t cc = 0; cc + 1 < c; ++cc) {
1251 double z0 = zf[rr * c + cc];
1252 double z1 = zf[rr * c + cc + 1];
1253 if (!std::isfinite(z0) || !std::isfinite(z1))
1254 continue;
1255 double s0x, s0y, d0, s1x, s1y, d1;
1256 proj(xv[std::min(cc, xv.size() - 1)],
1257 yv[std::min(rr, yv.size() - 1)], z0, s0x,
1258 s0y, d0);
1259 proj(xv[std::min(cc + 1, xv.size() - 1)],
1260 yv[std::min(rr, yv.size() - 1)], z1, s1x,
1261 s1y, d1);
1262 addLine(out, {PX(s0x), PX(s1x)},
1263 {PY(s0y), PY(s1y)}, zColor(z0), 1.2,
1264 false);
1265 }
1266 continue;
1267 }
1268 auto xv = parseVec(ds.xJson);
1269 auto yv = parseVec(ds.yJson);
1270 auto zv = parseVec(ds.zJson);
1271 size_t n = std::min(std::min(xv.size(), yv.size()),
1272 zv.size());
1273 StyleParts st3 = parseStyle(ds.style);
1274 for (size_t k = 0; k + 1 < n; ++k) {
1275 if (!std::isfinite(xv[k]) || !std::isfinite(yv[k])
1276 || !std::isfinite(zv[k]) || !std::isfinite(xv[k + 1])
1277 || !std::isfinite(yv[k + 1])
1278 || !std::isfinite(zv[k + 1]))
1279 continue;
1280 double s0x, s0y, d0, s1x, s1y, d1;
1281 proj(xv[k], yv[k], zv[k], s0x, s0y, d0);
1282 proj(xv[k + 1], yv[k + 1], zv[k + 1], s1x, s1y, d1);
1283 Rgb col = ds.type == "mesh"
1284 ? zColor(zv[k])
1285 : (st3.hasColor ? st3.color
1286 : Rgb(0x7f, 0xd9, 0x9a));
1287 addLine(out, {PX(s0x), PX(s1x)}, {PY(s0y), PY(s1y)},
1288 col, 1.2, false);
1289 }
1290 }
1291 }
1292 }
1293
1294 // ── Series ──────────────────────────────────────────────────────
1295 size_t seriesIdx = 0;
1296 for (size_t i = 0; i < ax.datasets.size(); ++i) {
1297 const DatasetInfo &ds = ax.datasets[i];
1298 if (!ds.visible || xs[i].empty())
1299 continue;
1300 if (ds.type == "surf" || ds.type == "mesh" || ds.type == "waterfall"
1301 || (ds.type == "plot3" && !ds.zJson.empty()))
1302 continue; // rendered in the 3-D block above
1303 StyleParts st = parseStyle(ds.style);
1304 if (!st.hasColor)
1305 st.color = palette()[seriesIdx % palette().size()];
1306 ++seriesIdx;
1307 Rgb col = st.color;
1308 double lw = ds.lineWidth > 0 ? ds.lineWidth : (st.lineStyle == "-" || st.lineStyle.empty() ? 1.4 : 1.4);
1309 double ms = ds.markerSize > 0 ? ds.markerSize * 1.4 : 6.0;
1310 bool dashed = st.lineStyle == "--" || st.lineStyle == ":" || st.lineStyle == "-.";
1311 const size_t n = std::min(xs[i].size(), ys[i].size());
1312 const std::string &t = ds.type;
1313
1314 if (t == "line" || t == "comet" || t == "animatedline") {
1315 // Non-finite points (null separators — contour level
1316 // segments) SPLIT the polyline: dropping them would join
1317 // unrelated segments with spurious chords.
1318 std::vector<double> px, py;
1319 auto flushRun = [&]() {
1320 if (st.lineStyle != "none" && px.size() >= 2)
1321 addLine(out, px, py, col, lw, dashed);
1322 px.clear();
1323 py.clear();
1324 };
1325 for (size_t k = 0; k < n; ++k) {
1326 if (std::isfinite(xs[i][k]) && std::isfinite(ys[i][k])) {
1327 px.push_back(X(xs[i][k]));
1328 py.push_back(Y(ys[i][k]));
1329 } else {
1330 flushRun();
1331 }
1332 }
1333 flushRun();
1334 if (!st.marker.empty())
1335 for (size_t k = 0; k < px.size(); ++k)
1336 addMarker(out, px[k], py[k], ms, col, st.marker);
1337 } else if (t == "stairs") {
1338 std::vector<double> px, py;
1339 for (size_t k = 0; k < n; ++k) {
1340 if (k && px.size()) {
1341 px.push_back(X(xs[i][k]));
1342 py.push_back(py.back());
1343 }
1344 px.push_back(X(xs[i][k]));
1345 py.push_back(Y(ys[i][k]));
1346 }
1347 if (px.size() >= 2)
1348 addLine(out, px, py, col, lw, dashed);
1349 if (!st.marker.empty())
1350 for (size_t k = 0; k < n; ++k)
1351 addMarker(out, X(xs[i][k]), Y(ys[i][k]), ms, col, st.marker);
1352 } else if (t == "stem") {
1353 double base = 0;
1354 if (base >= YR.lo && base <= YR.hi)
1355 for (size_t k = 0; k < n; ++k)
1356 addLine(out, {X(xs[i][k]), X(xs[i][k])},
1357 {Y(base), Y(ys[i][k])}, col, lw, dashed);
1358 for (size_t k = 0; k < n; ++k)
1359 addMarker(out, X(xs[i][k]), Y(ys[i][k]), ms,
1360 col, st.marker.empty() ? "o" : st.marker);
1361 } else if (t == "scatter" || t == "bubble") {
1362 std::vector<double> sz;
1363 if (!ds.sizeJson.empty())
1364 sz = parseVec(ds.sizeJson);
1365 for (size_t k = 0; k < n; ++k)
1366 addMarker(out, X(xs[i][k]), Y(ys[i][k]),
1367 sz.empty() ? ms : std::max(3.0, std::sqrt(sz[k] / 3.14) * 1.6),
1368 col, "o");
1369 } else if (t == "histogram") {
1370 // MATLAB histogram: bars TOUCH (full bin width), FaceColor
1371 // 'auto' = (0,0.447,0.741), light-gray edge, LW 0.5
1372 // (probe-verified R2025b). binWidth kv when the engine
1373 // packed it; otherwise derive from consecutive spacing.
1374 Rgb face = st.hasFaceColor ? st.faceColor : Rgb(0x00, 0x72, 0xbd);
1375 Rgb edge = st.edgeColor;
1376 if (st.faceAlpha < 1) {
1377 // MATLAB default FaceAlpha 0.6 composites with the
1378 // (white) canvas: 0.6·face + 0.4·white — probe-verified
1379 // mechanism (dark-theme renders were the SAME alpha over
1380 // a dark background, not a colour tint).
1381 double a = std::min(1.0, std::max(0.0, st.faceAlpha));
1382 face = Rgb((std::uint8_t)std::lround(a * face.r + (1 - a) * 255),
1383 (std::uint8_t)std::lround(a * face.g + (1 - a) * 255),
1384 (std::uint8_t)std::lround(a * face.b + (1 - a) * 255));
1385 }
1386 double bw = st.binWidth;
1387 if (!(bw > 0) && n > 1)
1388 bw = std::abs(xs[i][1] - xs[i][0]);
1389 for (size_t k = 0; k < n; ++k) {
1390 double x0p = X(xs[i][k] - bw / 2);
1391 double x1p = X(xs[i][k] + bw / 2);
1392 double yBase = Y(std::min(std::max(0.0, YR.lo), YR.hi));
1393 double yTop = Y(ys[i][k]);
1394 addPoly(out, {x0p, x1p, x1p, x0p},
1395 {yBase, yBase, yTop, yTop}, face, true);
1396 addLine(out, {x0p, x1p}, {yTop, yTop}, edge, 0.75, false);
1397 }
1398 } else if (t == "bar") {
1399 // Grouped vertical bars, width 0.8 of slot.
1400 const int groups = (int)n;
1401 const int series = 1; // per-dataset grouping (adjacent bar
1402 // datasets share slots below)
1403 for (size_t k = 0; k < n; ++k) {
1404 double slot = groups > 1 ? (xhi - xlo) / (groups - 1) : 1;
1405 double w = std::min(slot * 0.8, (plot.x1 - plot.x0) * 0.05) / series;
1406 double x0p = X(xs[i][k]) - w / 2;
1407 double yBase = Y(std::min(std::max(0.0, YR.lo), YR.hi));
1408 double yTop = Y(ys[i][k]);
1409 addPoly(out, {x0p, x0p + w, x0p + w, x0p},
1410 {yBase, yBase, yTop, yTop}, col, true);
1411 }
1412 } else if (t == "quiver") {
1413 // Arrows: shaft (x,y)→(x+u,y+v) plus a chevron head at the
1414 // tip (two strokes ~25° back, length = max(4px, 12% of the
1415 // on-screen arrow length). NaN breaks skip the arrow.
1416 auto uv = parseVec(ds.uJson);
1417 auto vv = parseVec(ds.vJson);
1418 for (size_t k = 0; k < n; ++k) {
1419 double u = k < uv.size() ? uv[k] : 0.0;
1420 double v = k < vv.size() ? vv[k] : 0.0;
1421 if (!std::isfinite(xs[i][k]) || !std::isfinite(ys[i][k])
1422 || !std::isfinite(u) || !std::isfinite(v))
1423 continue;
1424 double x0 = X(xs[i][k]), y0 = Y(ys[i][k]);
1425 double x1 = X(xs[i][k] + u), y1 = Y(ys[i][k] + v);
1426 addLine(out, {x0, x1}, {y0, y1}, col, 1.2, false);
1427 double dx = x1 - x0, dy = y1 - y0;
1428 double len = std::hypot(dx, dy);
1429 if (len < 1e-9)
1430 continue;
1431 double hl = std::max(4.0, len * 0.12);
1432 double ux = dx / len, uy = dy / len;
1433 // Back-left / back-right head strokes.
1434 double a1 = 0.45; // ~26 degrees off the shaft
1435 double c1 = std::cos(a1), s1 = std::sin(a1);
1436 double bxl = x1 - hl * (ux * c1 - uy * s1);
1437 double byl = y1 - hl * (uy * c1 + ux * s1);
1438 double bxr = x1 - hl * (ux * c1 + uy * s1);
1439 double byr = y1 - hl * (uy * c1 - ux * s1);
1440 addLine(out, {bxl, x1}, {byl, y1}, col, 1.2, false);
1441 addLine(out, {bxr, x1}, {byr, y1}, col, 1.2, false);
1442 }
1443 } else if (t == "polygon") {
1444 // Filled rings (contourf bands, patch) — null-separated in
1445 // one dataset; each ring fills with the dataset colour.
1446 std::vector<double> px, py;
1447 auto flushRing = [&]() {
1448 if (px.size() >= 3)
1449 addPoly(out, px, py, col, true);
1450 px.clear();
1451 py.clear();
1452 };
1453 for (size_t k = 0; k < n; ++k) {
1454 if (std::isfinite(xs[i][k]) && std::isfinite(ys[i][k])) {
1455 px.push_back(X(xs[i][k]));
1456 py.push_back(Y(ys[i][k]));
1457 } else {
1458 flushRing();
1459 }
1460 }
1461 flushRing();
1462 } else if (t == "area") {
1463 std::vector<double> px, py;
1464 double yBase = Y(std::min(std::max(0.0, YR.lo), YR.hi));
1465 px.push_back(X(xs[i].front()));
1466 py.push_back(yBase);
1467 for (size_t k = 0; k < n; ++k) {
1468 px.push_back(X(xs[i][k]));
1469 py.push_back(Y(ys[i][k]));
1470 }
1471 px.push_back(X(xs[i][n - 1]));
1472 py.push_back(yBase);
1473 addPoly(out, px, py, col, true);
1474 } else if (t == "errorbar") {
1475 auto sym = parseVec(ds.eJson);
1476 auto eneg = parseVec(ds.eNegJson);
1477 auto epos = parseVec(ds.ePosJson);
1478 for (size_t k = 0; k < n; ++k) {
1479 double loV = ys[i][k], hiV = ys[i][k];
1480 if (k < sym.size()) {
1481 loV -= sym[k];
1482 hiV += sym[k];
1483 }
1484 if (k < eneg.size())
1485 loV = ys[i][k] - eneg[k];
1486 if (k < epos.size())
1487 hiV = ys[i][k] + epos[k];
1488 double pxk = X(xs[i][k]);
1489 addLine(out, {pxk, pxk}, {Y(loV), Y(hiV)}, col, 1, false);
1490 double cap = 4;
1491 addLine(out, {pxk - cap, pxk + cap}, {Y(loV), Y(loV)}, col, 1, false);
1492 addLine(out, {pxk - cap, pxk + cap}, {Y(hiV), Y(hiV)}, col, 1, false);
1493 }
1494 if (!st.marker.empty())
1495 for (size_t k = 0; k < n; ++k)
1496 addMarker(out, X(xs[i][k]), Y(ys[i][k]), ms, col, st.marker);
1497 }
1498 // Unhandled types (quiver/feather/pie/…) silently skipped — see
1499 // the portion-32 export-gaps bug.
1500 }
1501
1502 // ── text() annotations (portion 38; drawn OVER the series like ──
1503 // MATLAB). Defaults probe-verified R2025b: FontSize 10,
1504 // HorizontalAlignment LEFT, VerticalAlignment middle, black.
1505 for (size_t i = 0; i < ax.datasets.size(); ++i) {
1506 const DatasetInfo &ds = ax.datasets[i];
1507 if (!ds.visible || ds.type != "text")
1508 continue;
1509 auto xv = parseVec(ds.xJson);
1510 auto yv = parseVec(ds.yJson);
1511 if (xv.empty() || yv.empty())
1512 continue;
1513 TextExtras te = parseTextExtras(ds.style);
1514 double scale = std::max(1.0, (double)std::lround(te.fontSize / 7.0));
1515 double w = 6.0 * scale * ds.label.size();
1516 double x = X(xv[0]) - (te.halign == 1 ? w / 2
1517 : te.halign == 2 ? w : 0.0);
1518 // Raster text() anchors at the glyph-box TOP; middle alignment
1519 // lifts the anchor by half the glyph height.
1520 double y = Y(yv[0]) - 3.5 * scale;
1521 addText(out, ds.label, x, y, te.fontSize, te.color, false, false,
1522 "");
1523 }
1524
1525 // ── Box + ticks + labels (portion 34: custom ticks/labels, fonts,
1526 // frame width — all MATLAB-probe-verified). `axis off` (imshow,
1527 // `axis off`) skips every decoration except the title. ───────────
1528 const double boxW = std::max(0.75, ax.lineWidth);
1529 const double tickFS = ax.fontSize;
1530 const double labelFS = ax.fontSize * 1.1;
1531 const bool fBold = ax.fontWeight == "bold";
1532 const std::string &fam = ax.fontName;
1533 if (ax.axisVisible) {
1534 addLine(out, {plot.x0, plot.x1, plot.x1, plot.x0, plot.x0},
1535 {plot.y0, plot.y0, plot.y1, plot.y1, plot.y0}, kAxisCol, boxW,
1536 false);
1537 auto tx = !ax.xTicksJson.empty()
1538 ? parseVec(ax.xTicksJson)
1539 : (XR.log ? logTicks(xlo, xhi) : niceTicks(xlo, xhi));
1540 auto ty = !ax.yTicksJson.empty()
1541 ? parseVec(ax.yTicksJson)
1542 : (YR.log ? logTicks(ylo, yhi) : niceTicks(ylo, yhi));
1543 std::vector<std::string> txLab, tyLab;
1544 if (!ax.xTickLabelsJson.empty())
1545 txLab = parseTickLabelVec(ax.xTickLabelsJson);
1546 if (!ax.yTickLabelsJson.empty())
1547 tyLab = parseTickLabelVec(ax.yTickLabelsJson);
1548 for (size_t k = 0; k < tx.size(); ++k) {
1549 const double v = tx[k];
1550 double p = X(v);
1551 addLine(out, {p, p}, {plot.y1, plot.y1 + 4}, kAxisCol, boxW, false);
1552 std::string lab = k < txLab.size()
1553 ? txLab[k]
1554 : tickLabel(v);
1555 if (k >= txLab.size() && !ax.xTickFormat.empty()
1556 && ax.xTickFormat.size() > 1 && ax.xTickFormat[0] == '%') {
1557 char b[40];
1558 std::snprintf(b, sizeof b, ax.xTickFormat.c_str(), v);
1559 lab = b;
1560 }
1561 addText(out, lab, p - 3.0 * lab.size(), plot.y1 + 8, tickFS,
1562 kTextCol, false, fBold, fam);
1563 }
1564 for (size_t k = 0; k < ty.size(); ++k) {
1565 const double v = ty[k];
1566 double p = Y(v);
1567 addLine(out, {plot.x0 - 4, plot.x0}, {p, p}, kAxisCol, boxW, false);
1568 std::string lab = k < tyLab.size()
1569 ? tyLab[k]
1570 : tickLabel(v);
1571 if (k >= tyLab.size() && !ax.yTickFormat.empty()
1572 && ax.yTickFormat.size() > 1 && ax.yTickFormat[0] == '%') {
1573 char b[40];
1574 std::snprintf(b, sizeof b, ax.yTickFormat.c_str(), v);
1575 lab = b;
1576 }
1577 double w = 6.0 * lab.size();
1578 addText(out, lab, plot.x0 - 8 - w, p + 4, tickFS, kTextCol, false,
1579 fBold, fam);
1580 }
1581 if (!ax.xlabel.empty())
1582 addText(out, ax.xlabel, (plot.x0 + plot.x1) / 2 - 3.2 * ax.xlabel.size(),
1583 plot.y1 + 34, labelFS, kTextCol, false, fBold, fam);
1584 if (!ax.ylabel.empty())
1585 addText(out, ax.ylabel, plot.x0 - 46,
1586 (plot.y0 + plot.y1) / 2 + 3.2 * ax.ylabel.size(), labelFS,
1587 kTextCol, /*rot90=*/true, fBold, fam);
1588 } // end axisVisible decorations
1589 if (!ax.title.empty())
1590 addText(out, ax.title, (plot.x0 + plot.x1) / 2 - 3.2 * ax.title.size(),
1591 plot.y0 - 18, labelFS + 2, kTextCol, false, fBold, fam);
1592
1593 // ── Colorbar (portion 38; probe-verified R2025b: limits = CLim, ──
1594 // vertical ticks = the 10-interval ladder (12 for [1 12]), a
1595 // horizontal strip uses the 5-interval ladder, FontSize 9, strip
1596 // low end at bottom/left).
1597 if (!cbSide.empty() && cbSide != "none") {
1598 double lo = 0, hi = 1;
1599 auto cl = figdetail::parseFlatDoubles(ax.climJson);
1600 if (cl.size() == 2 && cl[0] < cl[1]) {
1601 lo = cl[0];
1602 hi = cl[1];
1603 } else {
1604 for (const auto &ds : ax.datasets)
1605 if (!ds.zQuantized.empty()) {
1606 lo = ds.cminOrig;
1607 hi = ds.cmaxOrig;
1608 break;
1609 }
1610 }
1611 std::vector<Rgb> customPal;
1612 if (!ax.customColormapJson.empty())
1613 customPal = parseCustomPalette(ax.customColormapJson);
1614 int nPal = 256;
1615 const std::uint8_t *pal =
1616 customPal.empty()
1617 ? namedPalette(ax.colormapName.empty()
1618 ? std::string("parula")
1619 : ax.colormapName,
1620 nPal)
1621 : nullptr;
1622 auto entryColor = [&](double t) -> Rgb {
1623 if (t < 0) t = 0;
1624 if (t > 1) t = 1;
1625 int e = (int)std::lround(t * (nPal - 1));
1626 if (e < 0) e = 0;
1627 if (e >= nPal) e = nPal - 1;
1628 if (pal)
1629 return Rgb(pal[e * 3], pal[e * 3 + 1], pal[e * 3 + 2]);
1630 return customPal[(size_t)e];
1631 };
1632 const double cbFS = 9; // probe: colorbar FontSize 9
1633 const double thick = 14;
1634 bool vert = cbSide == "east" || cbSide == "west";
1635 double sx, sy, sw, sh;
1636 if (vert) {
1637 sx = cbSide == "west" ? plot.x0 - 8 - thick : plot.x1 + 8;
1638 sy = plot.y0;
1639 sw = thick;
1640 sh = plot.y1 - plot.y0;
1641 } else {
1642 sx = plot.x0;
1643 sw = plot.x1 - plot.x0;
1644 sh = thick;
1645 sy = cbSide == "north" ? plot.y0 - 8 - thick : plot.y1 + 8;
1646 }
1647 int pw = std::max(1, (int)std::lround(sw));
1648 int ph = std::max(1, (int)std::lround(sh));
1649 std::vector<std::uint8_t> grad((size_t)pw * ph * 4, 255);
1650 for (int py = 0; py < ph; ++py)
1651 for (int px = 0; px < pw; ++px) {
1652 double t = vert ? 1.0 - (py + 0.5) / ph
1653 : (px + 0.5) / pw;
1654 Rgb c = entryColor(t);
1655 size_t o = ((size_t)py * pw + px) * 4;
1656 grad[o] = c.r;
1657 grad[o + 1] = c.g;
1658 grad[o + 2] = c.b;
1659 }
1660 Cmd strip;
1661 strip.kind = Cmd::IMAGE;
1662 strip.rgbDirect = true; // RGBA quads
1663 strip.imgW = pw;
1664 strip.imgH = ph;
1665 strip.img = std::move(grad);
1666 strip.xs = {sx, sx + pw};
1667 strip.ys = {sy, sy + ph};
1668 out.push_back(std::move(strip));
1669 // Frame around the strip (MATLAB draws a thin box).
1670 addLine(out, {sx, sx + sw, sx + sw, sx, sx},
1671 {sy, sy, sy + sh, sy + sh, sy}, kAxisCol, 0.75, false);
1672 auto ticks = niceTicks(lo, hi, vert ? 10 : 5);
1673 for (double v : ticks) {
1674 double t = (v - lo) / (hi - lo);
1675 if (t < -1e-9 || t > 1 + 1e-9)
1676 continue;
1677 std::string lab = tickLabel(v);
1678 double w = 6.0 * lab.size();
1679 if (vert) {
1680 double pyv = sy + sh - t * sh;
1681 bool west = cbSide == "west";
1682 addLine(out,
1683 {west ? sx - 4 : sx + sw, west ? sx : sx + sw + 4},
1684 {pyv, pyv}, kAxisCol, 0.75, false);
1685 addText(out, lab, west ? sx - 7 - w : sx + sw + 7,
1686 pyv - 3.5, cbFS, kTextCol);
1687 } else {
1688 double pxv = sx + t * sw;
1689 bool north = cbSide == "north";
1690 addLine(out, {pxv, pxv},
1691 {north ? sy - 4 : sy + sh, north ? sy : sy + sh + 4},
1692 kAxisCol, 0.75, false);
1693 addText(out, lab, pxv - w / 2,
1694 north ? sy - 9 - 7 : sy + sh + 9, cbFS, kTextCol);
1695 }
1696 }
1697 }
1698
1699 // ── Legend ──────────────────────────────────────────────────────
1700 if (!ax.legendLabels.empty() && !ax.legendHidden) {
1701 double rowH = 16, pad2 = 6;
1702 double lw2 = 22, tw = 0;
1703 for (auto &s2 : ax.legendLabels)
1704 tw = std::max(tw, 6.0 * s2.size());
1705 double wLegend = pad2 + lw2 + 6 + tw + pad2;
1706 double hLegend = pad2 + rowH * ax.legendLabels.size() + pad2;
1707 double lx = plot.x1 - 10 - wLegend, ly = plot.y0 + 10;
1708 if (ax.legendLocation.find("south") != std::string::npos)
1709 ly = plot.y1 - 10 - hLegend;
1710 if (ax.legendLocation.find("west") != std::string::npos)
1711 lx = plot.x0 + 10;
1712 if (ax.legendLocation.find("outside") != std::string::npos)
1713 lx = plot.x1 + 8;
1714 addPoly(out, {lx, lx + wLegend, lx + wLegend, lx},
1715 {ly, ly, ly + hLegend, ly + hLegend}, kAxisCol, false);
1716 for (size_t k = 0; k < ax.legendLabels.size(); ++k) {
1717 double ry = ly + pad2 + rowH * k + rowH / 2;
1718 Rgb c = palette()[k % palette().size()];
1719 for (size_t d = 0; d < ax.datasets.size(); ++d) {
1720 if (ax.datasets[d].label == ax.legendLabels[k]) {
1721 StyleParts sp = parseStyle(ax.datasets[d].style);
1722 if (sp.hasColor)
1723 c = sp.color;
1724 break;
1725 }
1726 }
1727 addLine(out, {lx + pad2, lx + pad2 + lw2}, {ry, ry}, c, 1.6, false);
1728 addText(out, ax.legendLabels[k], lx + pad2 + lw2 + 6, ry + 4, 11,
1729 kTextCol);
1730 }
1731 }
1732}
1733
1734// Full figure → display list. Subplot grid tiling; single axes uses the
1735// whole canvas.
1736inline std::vector<Cmd> renderFigureDisplay(const FigureState &fig, int W,
1737 int H)
1738{
1739 std::vector<Cmd> out;
1740 int rows = std::max(1, fig.subplotRows);
1741 int cols = std::max(1, fig.subplotCols);
1742 // Order axes by subplotIndex (1-based reading order) when gridded.
1743 std::vector<const AxesState *> ordered;
1744 for (int p = 1; p <= rows * cols; ++p)
1745 for (const auto &ax : fig.axes)
1746 if (ax.subplotIndex == p) {
1747 ordered.push_back(&ax);
1748 break;
1749 }
1750 if (ordered.empty())
1751 for (const auto &ax : fig.axes)
1752 ordered.push_back(&ax);
1753 for (size_t i = 0; i < ordered.size(); ++i) {
1754 int r = rows > 1 || cols > 1 ? (int)i / cols : 0;
1755 int c = rows > 1 || cols > 1 ? (int)i % cols : 0;
1756 Rect box;
1757 box.x0 = 10 + c * (W - 20.0) / cols + 6;
1758 box.x1 = 10 + (c + 1) * (W - 20.0) / cols - 6;
1759 box.y0 = 10 + r * (H - 20.0) / rows + 6;
1760 box.y1 = 10 + (r + 1) * (H - 20.0) / rows - 6;
1761 if (fig.subplotRows == 0) {
1762 box = Rect{10, 10, (double)W - 10, (double)H - 10};
1763 }
1764 if (!fig.superTitle.empty() && r == 0 && c == (cols - 1) / 2) {
1765 addText(out, fig.superTitle,
1766 W / 2.0 - 3.4 * fig.superTitle.size(), 6, 13,
1767 Rgb(0x28, 0x28, 0x30));
1768 box.y0 += 10;
1769 }
1770 renderAxes(fig, *ordered[i], box, out);
1771 }
1772 // ── Figure-level annotations (portion 41): normalized [0,1] figure
1773 // units mapped straight onto the canvas. MATLAB defaults: Color
1774 // (0.1294)^3, HeadSize 10 (chevron ~12% of length), textarrow text
1775 // at the TAIL.
1776 for (const auto &an : fig.annotations) {
1777 Rgb col = an.colorHex.empty() ? Rgb(33, 33, 33)
1778 : Rgb::fromHex(an.colorHex);
1779 double ax0 = an.x1 * W, ay0 = (1.0 - an.y1) * H;
1780 double ax1 = an.x2 * W, ay1 = (1.0 - an.y2) * H;
1781 if (an.type == "line" || an.type == "arrow"
1782 || an.type == "textarrow") {
1783 addLine(out, {ax0, ax1}, {ay0, ay1}, col, 0.75, false);
1784 if (an.type != "line") {
1785 double dx = ax1 - ax0, dy = ay1 - ay0;
1786 double len = std::hypot(dx, dy);
1787 if (len > 1e-9) {
1788 double hl = std::max(4.0, len * 0.12);
1789 double ux = dx / len, uy = dy / len;
1790 double a1 = 0.45;
1791 double c1 = std::cos(a1), s1 = std::sin(a1);
1792 addLine(out,
1793 {ax1 - hl * (ux * c1 - uy * s1), ax1},
1794 {ay1 - hl * (uy * c1 + ux * s1), ay1},
1795 col, 0.75, false);
1796 addLine(out,
1797 {ax1 - hl * (ux * c1 + uy * s1), ax1},
1798 {ay1 - hl * (uy * c1 - ux * s1), ay1},
1799 col, 0.75, false);
1800 }
1801 }
1802 if (an.type == "textarrow" && !an.text.empty()) {
1803 double w = 6.0 * (an.fontSize / 10.0) * an.text.size();
1804 addText(out, an.text, ax0 - w - 6, ay0 - 3.5,
1805 an.fontSize, col);
1806 }
1807 } else if (an.type == "rectangle" || an.type == "textbox") {
1808 double x0p = ax0, y0p = ay1, x1p = ax1, y1p = ay0;
1809 if (!an.faceHex.empty()) {
1810 Rgb fc = Rgb::fromHex(an.faceHex);
1811 addPoly(out, {x0p, x1p, x1p, x0p}, {y0p, y0p, y1p, y1p},
1812 fc, true);
1813 }
1814 addLine(out, {x0p, x1p, x1p, x0p, x0p},
1815 {y0p, y0p, y1p, y1p, y0p}, col, 0.75, false);
1816 if (an.type == "textbox" && !an.text.empty())
1817 addText(out, an.text, x0p + 6, (y0p + y1p) / 2 - 3.5,
1818 an.fontSize, Rgb(0x28, 0x28, 0x30));
1819 } else if (an.type == "ellipse") {
1820 double cx = (ax0 + ax1) / 2, cy = (ay0 + ay1) / 2;
1821 double rx = std::abs(ax1 - ax0) / 2, ry = std::abs(ay1 - ay0) / 2;
1822 std::vector<double> ex, ey;
1823 for (int k = 0; k <= 64; ++k) {
1824 double t = 2 * 3.14159265358979323846 * k / 64.0;
1825 ex.push_back(cx + rx * std::cos(t));
1826 ey.push_back(cy + ry * std::sin(t));
1827 }
1828 if (!an.faceHex.empty()) {
1829 Rgb fc = Rgb::fromHex(an.faceHex);
1830 addPoly(out, ex, ey, fc, true);
1831 }
1832 for (size_t k = 0; k + 1 < ex.size(); ++k)
1833 addLine(out, {ex[k], ex[k + 1]}, {ey[k], ey[k + 1]}, col,
1834 0.75, false);
1835 }
1836 }
1837 return out;
1838}
1839
1840// ── SVG backend ─────────────────────────────────────────────────────
1841// Embeds an on-screen RGB buffer (top-down, w×h×3) as an image payload
1842// for SVG (a data: URL). The figure layer stays free of codec code —
1843// the bundle export glue supplies a PNG+base64 implementation; without
1844// one, SVG export skips IMAGE cmds (raster export always renders them).
1845using SvgImageEmbedder =
1846 std::function<std::string(const std::vector<std::uint8_t> &, int, int)>;
1847
1848inline std::string renderFigureSvg(const FigureState &fig, int W, int H,
1849 const SvgImageEmbedder &embed = {})
1850{
1851 std::vector<Cmd> cmds = renderFigureDisplay(fig, W, H);
1852 std::string s = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
1853 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"";
1854 s += std::to_string(W) + "\" height=\"" + std::to_string(H)
1855 + "\" viewBox=\"0 0 " + std::to_string(W) + " " + std::to_string(H)
1856 + "\">\n<rect width=\"100%\" height=\"100%\" fill=\"white\"/>\n";
1857 auto esc = [](const std::string &t) {
1858 std::string o;
1859 for (char ch : t) {
1860 if (ch == '&')
1861 o += "&amp;";
1862 else if (ch == '<')
1863 o += "&lt;";
1864 else if (ch == '>')
1865 o += "&gt;";
1866 else
1867 o += ch;
1868 }
1869 return o;
1870 };
1871 for (const Cmd &c : cmds) {
1872 if (c.kind == Cmd::IMAGE) {
1873 if (!embed)
1874 continue; // no codec available — skip (PNG path renders)
1875 std::vector<std::uint8_t> buf = imageCmdRgb(c);
1876 int pw = std::max(1, (int)std::lround(std::max(c.xs[0], c.xs[1])
1877 - std::min(c.xs[0], c.xs[1])));
1878 int ph = std::max(1, (int)std::lround(std::max(c.ys[0], c.ys[1])
1879 - std::min(c.ys[0], c.ys[1])));
1880 s += "<image x=\""
1881 + std::to_string(std::lround(std::min(c.xs[0], c.xs[1])))
1882 + "\" y=\""
1883 + std::to_string(std::lround(std::min(c.ys[0], c.ys[1])))
1884 + "\" width=\"" + std::to_string(pw) + "\" height=\""
1885 + std::to_string(ph) + "\" preserveAspectRatio=\"none\" href=\""
1886 + embed(buf, pw, ph) + "\"/>\n";
1887 } else if (c.kind == Cmd::TEXT) {
1888 s += "<text x=\"" + std::to_string(c.x) + "\" y=\""
1889 + std::to_string(c.y) + "\" font-size=\""
1890 + std::to_string(c.fontSize)
1891 + "\" font-family=\""
1892 + (c.fontFamily.empty() ? std::string("monospace")
1893 : c.fontFamily)
1894 + "\"" + std::string(c.bold ? " font-weight=\"bold\"" : "")
1895 + " fill=\"" + c.color.hex() + "\"";
1896 if (c.rot90)
1897 s += " transform=\"rotate(-90 " + std::to_string(c.x) + " "
1898 + std::to_string(c.y) + ")\"";
1899 s += ">" + esc(c.text) + "</text>\n";
1900 } else if (c.kind == Cmd::MARK) {
1901 double r = c.width / 2;
1902 if (c.text == "s" || c.text == "d") {
1903 s += "<rect x=\"" + std::to_string(c.x - r) + "\" y=\""
1904 + std::to_string(c.y - r) + "\" width=\""
1905 + std::to_string(c.width) + "\" height=\""
1906 + std::to_string(c.width) + "\" fill=\""
1907 + (c.text == "d" ? "none" : c.color.hex()) + "\" stroke=\""
1908 + c.color.hex() + "\"/>\n";
1909 } else if (c.text == "+" || c.text == "x") {
1910 s += "<path d=\"M" + std::to_string(c.x - r) + " "
1911 + std::to_string(c.y) + " L" + std::to_string(c.x + r) + " "
1912 + std::to_string(c.y) + " M" + std::to_string(c.x) + " "
1913 + std::to_string(c.y - r) + " L" + std::to_string(c.x)
1914 + " " + std::to_string(c.y + r)
1915 + (c.text == "x" ? " M" + std::to_string(c.x - r) + " "
1916 + std::to_string(c.y - r) + " L"
1917 + std::to_string(c.x + r) + " "
1918 + std::to_string(c.y + r) + " M"
1919 + std::to_string(c.x + r) + " "
1920 + std::to_string(c.y - r) + " L"
1921 + std::to_string(c.x - r) + " "
1922 + std::to_string(c.y + r)
1923 : "")
1924 + "\" stroke=\"" + c.color.hex() + "\" stroke-width=\"1.2\" fill=\"none\"/>\n";
1925 } else { // o . * ^ v < > p h → circle
1926 s += "<circle cx=\"" + std::to_string(c.x) + "\" cy=\""
1927 + std::to_string(c.y) + "\" r=\"" + std::to_string(r)
1928 + "\" fill=\"none\" stroke=\"" + c.color.hex()
1929 + "\" stroke-width=\"1.4\"/>\n";
1930 }
1931 } else {
1932 // LINE / POLY → <polyline>/<polygon>
1933 std::string pts;
1934 for (size_t i = 0; i < c.xs.size(); ++i) {
1935 if (i)
1936 pts += " ";
1937 char b[48];
1938 std::snprintf(b, sizeof b, "%.1f,%.1f", c.xs[i], c.ys[i]);
1939 pts += b;
1940 }
1941 if (c.kind == Cmd::POLY && c.fill) {
1942 s += "<polygon points=\"" + pts + "\" fill=\"" + c.color.hex()
1943 + "\" stroke=\"none\"/>\n";
1944 } else {
1945 s += "<polyline points=\"" + pts + "\" fill=\"none\" stroke=\""
1946 + c.color.hex() + "\" stroke-width=\""
1947 + std::to_string(c.width) + "\"";
1948 if (c.dash)
1949 s += " stroke-dasharray=\"7 5\"";
1950 s += "/>\n";
1951 }
1952 }
1953 }
1954 s += "</svg>\n";
1955 return s;
1956}
1957
1958// ── RGB raster backend ───────────────────────────────────────────────
1959// Classic public-domain 5×7 bitmap font (column-encoded, LSB = top row),
1960// ASCII 0x20..0x7E.
1961inline const std::uint8_t *font5x7(char c)
1962{
1963 // Canonical public-domain 5x7 font (Adafruit glcdfont, chars 0x20..0x7E),
1964 // column-encoded, LSB = top row. Unsized so a dropped row fails to
1965 // compile instead of silently shifting every later glyph.
1966 static const std::uint8_t F[][5] = {
1967 {0x00,0x00,0x00,0x00,0x00},{0x00,0x00,0x5F,0x00,0x00},{0x00,0x07,0x00,0x07,0x00},
1968 {0x14,0x7F,0x14,0x7F,0x14},{0x24,0x2A,0x7F,0x2A,0x12},{0x23,0x13,0x08,0x64,0x62},
1969 {0x36,0x49,0x56,0x20,0x50},{0x00,0x08,0x07,0x03,0x00},{0x00,0x1C,0x22,0x41,0x00},
1970 {0x00,0x41,0x22,0x1C,0x00},{0x2A,0x1C,0x7F,0x1C,0x2A},{0x08,0x08,0x3E,0x08,0x08},
1971 {0x00,0x80,0x70,0x30,0x00},{0x08,0x08,0x08,0x08,0x08},{0x00,0x00,0x60,0x60,0x00},
1972 {0x20,0x10,0x08,0x04,0x02},{0x3E,0x51,0x49,0x45,0x3E},{0x00,0x42,0x7F,0x40,0x00},
1973 {0x72,0x49,0x49,0x49,0x46},{0x21,0x41,0x49,0x4D,0x33},{0x18,0x14,0x12,0x7F,0x10},
1974 {0x27,0x45,0x45,0x45,0x39},{0x3C,0x4A,0x49,0x49,0x31},{0x41,0x21,0x11,0x09,0x07},
1975 {0x36,0x49,0x49,0x49,0x36},{0x46,0x49,0x49,0x29,0x1E},{0x00,0x00,0x14,0x00,0x00},
1976 {0x00,0x40,0x34,0x00,0x00},{0x00,0x08,0x14,0x22,0x41},{0x14,0x14,0x14,0x14,0x14},
1977 {0x00,0x41,0x22,0x14,0x08},{0x02,0x01,0x59,0x09,0x06},{0x3E,0x41,0x5D,0x59,0x4E},
1978 {0x7C,0x12,0x11,0x12,0x7C},{0x7F,0x49,0x49,0x49,0x36},{0x3E,0x41,0x41,0x41,0x22},
1979 {0x7F,0x41,0x41,0x41,0x3E},{0x7F,0x49,0x49,0x49,0x41},{0x7F,0x09,0x09,0x09,0x01},
1980 {0x3E,0x41,0x41,0x51,0x73},{0x7F,0x08,0x08,0x08,0x7F},{0x00,0x41,0x7F,0x41,0x00},
1981 {0x20,0x40,0x41,0x3F,0x01},{0x7F,0x08,0x14,0x22,0x41},{0x7F,0x40,0x40,0x40,0x40},
1982 {0x7F,0x02,0x1C,0x02,0x7F},{0x7F,0x04,0x08,0x10,0x7F},{0x3E,0x41,0x41,0x41,0x3E},
1983 {0x7F,0x09,0x09,0x09,0x06},{0x3E,0x41,0x51,0x21,0x5E},{0x7F,0x09,0x19,0x29,0x46},
1984 {0x26,0x49,0x49,0x49,0x32},{0x03,0x01,0x7F,0x01,0x03},{0x3F,0x40,0x40,0x40,0x3F},
1985 {0x1F,0x20,0x40,0x20,0x1F},{0x3F,0x40,0x38,0x40,0x3F},{0x63,0x14,0x08,0x14,0x63},
1986 {0x03,0x04,0x78,0x04,0x03},{0x61,0x59,0x49,0x4D,0x43},{0x00,0x7F,0x41,0x41,0x41},
1987 {0x02,0x04,0x08,0x10,0x20},{0x00,0x41,0x41,0x41,0x7F},{0x04,0x02,0x01,0x02,0x04},
1988 {0x40,0x40,0x40,0x40,0x40},{0x00,0x03,0x07,0x08,0x00},{0x20,0x54,0x54,0x78,0x40},
1989 {0x7F,0x28,0x44,0x44,0x38},{0x38,0x44,0x44,0x44,0x28},{0x38,0x44,0x44,0x28,0x7F},
1990 {0x38,0x54,0x54,0x54,0x18},{0x00,0x08,0x7E,0x09,0x02},{0x18,0xA4,0xA4,0x9C,0x78},
1991 {0x7F,0x08,0x04,0x04,0x78},{0x00,0x44,0x7D,0x40,0x00},{0x20,0x40,0x40,0x3D,0x00},
1992 {0x7F,0x10,0x28,0x44,0x00},{0x00,0x41,0x7F,0x40,0x00},{0x7C,0x04,0x78,0x04,0x78},
1993 {0x7C,0x08,0x04,0x04,0x78},{0x38,0x44,0x44,0x44,0x38},{0xFC,0x18,0x24,0x24,0x18},
1994 {0x18,0x24,0x24,0x18,0xFC},{0x7C,0x08,0x04,0x04,0x08},{0x48,0x54,0x54,0x54,0x24},
1995 {0x04,0x04,0x3F,0x44,0x24},{0x3C,0x40,0x40,0x20,0x7C},{0x1C,0x20,0x40,0x20,0x1C},
1996 {0x3C,0x40,0x30,0x40,0x3C},{0x44,0x28,0x10,0x28,0x44},{0x4C,0x90,0x90,0x90,0x7C},
1997 {0x44,0x64,0x54,0x4C,0x44},{0x00,0x08,0x36,0x41,0x00},{0x00,0x00,0x77,0x00,0x00},
1998 {0x00,0x41,0x36,0x08,0x00},{0x02,0x01,0x02,0x04,0x02}
1999 };
2000 static_assert(sizeof F / sizeof F[0] == 95,
2001 "font5x7 must cover exactly ASCII 0x20..0x7E");
2002 if (c < 0x20 || c > 0x7E)
2003 c = '?';
2004 return F[c - 0x20];
2005}
2006
2007class Raster
2008{
2009public:
2010 Raster(int w, int h) : w_(w), h_(h), px_(w * h * 3, 255) {}
2011
2012 void px(int x, int y, Rgb c)
2013 {
2014 if (x < 0 || y < 0 || x >= w_ || y >= h_)
2015 return;
2016 size_t o = (size_t)(y * w_ + x) * 3;
2017 px_[o] = c.r;
2018 px_[o + 1] = c.g;
2019 px_[o + 2] = c.b;
2020 }
2021
2022 // Thick-ish Bresenham with optional dashing (7 on / 5 off).
2023 void line(double x0, double y0, double x1, double y1, Rgb c, double wd,
2024 bool dash)
2025 {
2026 double len = std::hypot(x1 - x0, y1 - y0);
2027 if (len < 1e-9)
2028 return;
2029 int steps = (int)std::ceil(len * 2);
2030 double dashOn = 7, dashOff = 5;
2031 double run = 0;
2032 int side = (int)std::max(1.0, std::round(wd));
2033 for (int i = 0; i <= steps; ++i) {
2034 double t = (double)i / steps;
2035 double x = x0 + (x1 - x0) * t;
2036 double y = y0 + (y1 - y0) * t;
2037 if (dash) {
2038 double m = std::fmod(run, dashOn + dashOff);
2039 run += len / steps;
2040 if (m > dashOn)
2041 continue;
2042 }
2043 for (int dx = 0; dx < side; ++dx)
2044 for (int dy = 0; dy < side; ++dy)
2045 px((int)x + dx - side / 2, (int)y + dy - side / 2, c);
2046 }
2047 }
2048
2049 void polygon(const std::vector<double> &xs, const std::vector<double> &ys,
2050 Rgb c, bool fill)
2051 {
2052 if (xs.size() < 2)
2053 return;
2054 if (fill) {
2055 // Scanline fill (even-odd).
2056 double ymn = ys[0], ymx = ys[0];
2057 for (double y : ys) {
2058 ymn = std::min(ymn, y);
2059 ymx = std::max(ymx, y);
2060 }
2061 for (int y = (int)std::floor(ymn); y <= (int)std::ceil(ymx); ++y) {
2062 std::vector<double> xi;
2063 for (size_t i = 0; i < xs.size(); ++i) {
2064 size_t j = (i + 1) % xs.size();
2065 double ya = ys[i], yb = ys[j];
2066 if ((y + 0.5 >= ya && y + 0.5 < yb)
2067 || (y + 0.5 >= yb && y + 0.5 < ya))
2068 xi.push_back(xs[i] + (y + 0.5 - ya) / (yb - ya)
2069 * (xs[j] - xs[i]));
2070 }
2071 std::sort(xi.begin(), xi.end());
2072 for (size_t k = 0; k + 1 < xi.size(); k += 2)
2073 for (int x = (int)std::ceil(xi[k]); x <= (int)xi[k + 1]; ++x)
2074 px(x, y, c);
2075 }
2076 } else {
2077 for (size_t i = 0; i + 1 < xs.size(); ++i)
2078 line(xs[i], ys[i], xs[i + 1], ys[i + 1], c, 1, false);
2079 line(xs.back(), ys.back(), xs.front(), ys.front(), c, 1, false);
2080 }
2081 }
2082
2083 void marker(double x, double y, double size, Rgb c, const std::string &k)
2084 {
2085 int r = (int)std::max(2.0, size / 2);
2086 if (k == "s") {
2087 for (int dy = -r; dy <= r; ++dy)
2088 for (int dx = -r; dx <= r; ++dx)
2089 px((int)x + dx, (int)y + dy, c);
2090 } else if (k == "+" || k == "x") {
2091 for (int d = -r; d <= r; ++d) {
2092 px((int)x + d, (int)y, c);
2093 px((int)x, (int)y + d, c);
2094 if (k == "x") {
2095 px((int)x + d, (int)y + d, c);
2096 px((int)x + d, (int)y - d, c);
2097 }
2098 }
2099 } else { // o . * ^ v < > p h → outline circle (+ rays for *)
2100 for (int a = 0; a < 360; a += 2) {
2101 double rad = a * 3.14159265358979 / 180.0;
2102 px((int)std::lround(x + r * std::cos(rad)),
2103 (int)std::lround(y + r * std::sin(rad)), c);
2104 }
2105 if (k == "*")
2106 for (int d = -r - 1; d <= r + 1; ++d) {
2107 px((int)x + d, (int)y, c);
2108 px((int)x, (int)y + d, c);
2109 px((int)x + d, (int)y + d, c);
2110 px((int)x + d, (int)y - d, c);
2111 }
2112 }
2113 }
2114
2115 // Monospace text, scale = fontSize / 7 (glyph 5×7 + 1 col spacing).
2116 // rot90 renders the run bottom-to-top (ylabel).
2117 void text(double x, double y, const std::string &s, double fontSize, Rgb c,
2118 bool rot90)
2119 {
2120 int scale = std::max(1, (int)std::lround(fontSize / 7.0));
2121 if (!rot90) {
2122 double cx = x;
2123 for (char ch : s) {
2124 const std::uint8_t *g = font5x7(ch);
2125 for (int col = 0; col < 5; ++col)
2126 for (int row = 0; row < 7; ++row)
2127 if (g[col] & (1 << row))
2128 for (int sx = 0; sx < scale; ++sx)
2129 for (int sy = 0; sy < scale; ++sy)
2130 px((int)cx + col * scale + sx,
2131 (int)y + row * scale + sy, c);
2132 cx += 6 * scale;
2133 }
2134 } else {
2135 // Glyph columns run UPWARD from (x,y); within a glyph rows
2136 // go left→right after rotation.
2137 double cy = y;
2138 for (auto it = s.rbegin(); it != s.rend(); ++it) {
2139 const std::uint8_t *g = font5x7(*it);
2140 for (int row = 0; row < 7; ++row)
2141 for (int col = 0; col < 5; ++col)
2142 if (g[col] & (1 << row))
2143 for (int sx = 0; sx < scale; ++sx)
2144 for (int sy = 0; sy < scale; ++sy)
2145 px((int)x + (6 - row) * scale + sx,
2146 (int)cy - (5 - col) * scale - sy, c);
2147 cy -= 6 * scale;
2148 }
2149 }
2150 }
2151
2152 // Blit a top-down RGB buffer (pw×ph×3) at pixel (x0,y0). Used by the
2153 // IMAGE cmd; the buffer is already at on-screen resolution.
2154 void image(int x0, int y0, const std::vector<std::uint8_t> &buf, int pw,
2155 int ph)
2156 {
2157 for (int y = 0; y < ph; ++y) {
2158 int dy = y0 + y;
2159 if (dy < 0 || dy >= h_)
2160 continue;
2161 for (int x = 0; x < pw; ++x) {
2162 int dx = x0 + x;
2163 if (dx < 0 || dx >= w_)
2164 continue;
2165 size_t src = ((size_t)y * pw + x) * 3;
2166 size_t o = ((size_t)dy * w_ + dx) * 3;
2167 px_[o] = buf[src];
2168 px_[o + 1] = buf[src + 1];
2169 px_[o + 2] = buf[src + 2];
2170 }
2171 }
2172 }
2173
2174 const std::vector<std::uint8_t> &bytes() const { return px_; }
2175 int width() const { return w_; }
2176 int height() const { return h_; }
2177
2178private:
2179 int w_, h_;
2180 std::vector<std::uint8_t> px_;
2181};
2182
2183// Render to a top-down RGB buffer (H×W×3 bytes).
2184inline std::vector<std::uint8_t> renderFigureRgb(const FigureState &fig,
2185 int W, int H)
2186{
2187 Raster r(W, H);
2188 for (const Cmd &c : renderFigureDisplay(fig, W, H)) {
2189 switch (c.kind) {
2190 case Cmd::TEXT:
2191 r.text(c.x, c.y, c.text, c.fontSize, c.color, c.rot90);
2192 if (c.bold) // fake bold: second strike one pixel over
2193 r.text(c.x + 1, c.y, c.text, c.fontSize, c.color, c.rot90);
2194 break;
2195 case Cmd::MARK:
2196 r.marker(c.x, c.y, c.width, c.color, c.text);
2197 break;
2198 case Cmd::POLY:
2199 r.polygon(c.xs, c.ys, c.color, c.fill);
2200 break;
2201 case Cmd::LINE:
2202 for (size_t i = 0; i + 1 < c.xs.size(); ++i)
2203 r.line(c.xs[i], c.ys[i], c.xs[i + 1], c.ys[i + 1], c.color,
2204 c.width, c.dash);
2205 break;
2206 case Cmd::IMAGE: {
2207 std::vector<std::uint8_t> buf = imageCmdRgb(c);
2208 int pw = std::max(1, (int)std::lround(std::max(c.xs[0], c.xs[1])
2209 - std::min(c.xs[0], c.xs[1])));
2210 int ph = std::max(1, (int)std::lround(std::max(c.ys[0], c.ys[1])
2211 - std::min(c.ys[0], c.ys[1])));
2212 r.image((int)std::lround(std::min(c.xs[0], c.xs[1])),
2213 (int)std::lround(std::min(c.ys[0], c.ys[1])), buf, pw, ph);
2214 break;
2215 }
2216 }
2217 }
2218 return r.bytes();
2219}
2220
2221} // namespace figrender
2222} // namespace nk
static Value empty()
Returns a default-constructed (Unset) Value — NOT a MATLAB empty matrix despite the name.
Value series(const Value &sys1, const Value &sys2, std::pmr::memory_resource *mr=nullptr)
Cascade two LTI blocks (series(sys1, sys2)).
Value pow(const Value &x, const Value &y, std::pmr::memory_resource *mr=nullptr)
Elementwise power (z = x .
Value exp(const Value &x, std::pmr::memory_resource *mr=nullptr)
Natural exponential (y = exp(x)).
Value log(const Value &x, std::pmr::memory_resource *mr=nullptr)
Natural logarithm (ln(x)).
Value size(const Value &x, std::pmr::memory_resource *mr=nullptr)
Returns array dimensions as a row vector (size(x)).
Value edge(const Value &I, const std::string &method, double thresh_lo, double thresh_hi, std::pmr::memory_resource *mr=nullptr)
Binary edge map (BW = edge(I, method, thresh_lo, thresh_hi)).
Value corner(const Value &I, const std::string &method, int maxN, double quality_level, double sensitivity, const Value &filter_coef, std::pmr::memory_resource *mr=nullptr)
Detect corner points (C = corner(I, ...)).
Value eq(const Value &a, const Value &b, std::pmr::memory_resource *mr=nullptr)
Elementwise equality (y = a == b).
Value pad(const Value &s, size_t n, const Value &side=Value(), const Value &padChar=Value(), std::pmr::memory_resource *mr=nullptr)
Pads string to fixed length (pad(s, n, side, padChar)).
Value strip(const Value &s, std::pmr::memory_resource *mr=nullptr)
Strips leading and trailing whitespace from string array (strip(s)).
Per-axes state — one subplot panel has one AxesState.