nk 1.0.0
C++ Numerical Computing Runtime
Loading...
Searching...
No Matches
figure_manager.hpp
Go to the documentation of this file.
1
3#pragma once
4
5#include <cstdio>
6#include <functional>
7#include <iostream>
8#include <map>
9#include <sstream>
10#include <string>
11#include <unordered_map>
12#include <utility>
13#include <vector>
14#include <cmath>
15#include <cstdlib>
16#include <algorithm>
17#include <cstdint>
18
19#include <nk/ops/decimate.hpp>
20
21namespace nk {
22
25
26
27// ── Flat JSON number-array helpers (line-series downsampling) ────────────
28namespace figdetail {
29
30// Count top-level elements in a flat JSON number array "[a,b,c]" cheaply
31// (commas + 1) — gates the expensive parse on series size.
32inline std::size_t countFlatElements(const std::string &j) {
33 std::size_t commas = 0;
34 bool any = false;
35 for (char c : j) {
36 if (c == ',') commas++;
37 else if (c != '[' && c != ']' && c != ' ' && c != '\t' && c != '\n') any = true;
38 }
39 return any ? commas + 1 : 0;
40}
41
42// Parse a flat JSON number array → doubles. "null" → NaN; bare/quoted
43// Inf/NaN handled by strtod. The figure xJson is engine-built (well-formed).
44inline std::vector<double> parseFlatDoubles(const std::string &j) {
45 std::vector<double> out;
46 const char *p = j.c_str();
47 const char *end = p + j.size();
48 while (p < end && *p != '[') ++p;
49 if (p < end) ++p; // skip '['
50 while (p < end) {
51 while (p < end && (*p == ' ' || *p == ',' || *p == '"' || *p == '\t' || *p == '\n')) ++p;
52 if (p >= end || *p == ']') break;
53 if ((p[0] == 'n' || p[0] == 'N') && (p + 4 <= end) &&
54 (p[1] == 'u') && (p[2] == 'l') && (p[3] == 'l')) {
55 out.push_back(std::nan(""));
56 p += 4;
57 continue;
58 }
59 char *q = nullptr;
60 double v = std::strtod(p, &q);
61 if (q == p) { ++p; continue; } // skip an unparseable token
62 out.push_back(v);
63 p = q;
64 }
65 return out;
66}
67
68// Serialize doubles → flat JSON array. NaN → null, ±Inf → "Inf"/"-Inf".
69inline std::string serializeFlatDoubles(const std::vector<double> &v) {
70 std::ostringstream os;
71 os << "[";
72 os.precision(17);
73 for (std::size_t i = 0; i < v.size(); ++i) {
74 if (i) os << ",";
75 double x = v[i];
76 if (std::isnan(x)) os << "null";
77 else if (std::isinf(x)) os << (x > 0 ? "\"Inf\"" : "\"-Inf\"");
78 else os << x;
79 }
80 os << "]";
81 return os.str();
82}
83
84} // namespace figdetail
85
87{
88 std::string xJson;
89 std::string yJson;
90 std::string zJson; // 2D matrix for imagesc, e.g. [[1,2],[3,4]]
91 // Per-point auxiliary columns. Decoupled from xJson/yJson/zJson
92 // because they carry semantically different data — overloading
93 // zJson for marker sizes (as the original Phase 2e polar work
94 // did) leaks coordinate semantics into a sizing channel and
95 // breaks any future 3-D polar use case.
96 // sizeJson — polarbubblechart / scatter point areas
97 // (points^2, MATLAB convention).
98 // colorJson — per-point colour. May be a single RGB row
99 // "[[r,g,b]]" or N rows "[[r,g,b],...]". Renderer
100 // falls back to the dataset's `style.color` when
101 // empty.
102 std::string sizeJson;
103 std::string colorJson;
104 std::string type; // "line", "bar", "scatter", "stem", "stairs", "imagesc", "errorbar", …
105 std::string label; // for legend
106 std::string style; // MATLAB style hint, e.g. "r--o", "b:", "g-."
107 double lineWidth = 0; // 0 = default
108 double markerSize = 0; // 0 = default
109 // set(h,'Visible','off') — emitted as "visible":false only when off
110 // (absence = visible, so the wire format stays back-compatible).
111 bool visible = true;
112
113 // yyaxis side. Empty = left (default). When the user calls
114 // yyaxis('right') and then plots, FigureManager::pushDataset stamps
115 // 'right' here so the renderer can route the layer through the
116 // secondary Y mapping. Emitted in JSON only when non-empty.
117 std::string yside;
118
119 // ── Errorbar — symmetric (eJson) or asymmetric (eNegJson + ePosJson) ──
120 // For symmetric: errorbar(x, y, e) → eJson is non-empty, eNeg/ePos empty.
121 // For asymmetric: errorbar(x, y, neg, pos) → eNegJson + ePosJson non-empty.
122 // The renderer derives bar bounds as y - eNeg .. y + ePos (with eJson
123 // doubled-up when symmetric). Empty for non-errorbar datasets.
124 std::string eJson;
125 std::string eNegJson;
126 std::string ePosJson;
127
128 // ── Quiver — vector field (per-point u/v components) ────────────────
129 // quiver(x, y, u, v) → N arrows starting at (x, y) pointing
130 // in direction (u, v). Empty for non-quiver datasets.
131 std::string uJson;
132 std::string vJson;
133
134 // ── Imagesc storage (uint8 quantized) ──────────────────────────────
135 // Display path is fundamentally indexed: each pixel is a colormap
136 // index 0..255. Storing the full-precision float is overkill — for a
137 // 16k×42k spectrogram the float32 backing store would be 2.7 GB and
138 // wouldn't fit in the WASM heap. Quantizing once at imagesc-time
139 // brings storage down to ~890 MB (with the LOD pyramid) for the same
140 // shape and enables fast LUT-based rendering.
141 //
142 // Quantization: idx = clamp(round((v - cminOrig) / range * 254), 0, 254)
143 // idx 255 = NaN / Inf sentinel
144 //
145 // Layout: column-major (MATLAB convention), idx = c * rows + r.
146 //
147 // colorScaleBaked is true when log10 was applied before quantization
148 // — toggling color scale post-imagesc requires re-emitting the figure.
149 // Hover values reconstruct via cminOrig + (idx/254) * range; the
150 // 256-level resolution gives ≤0.4% step on a typical [cmin..cmax].
151 std::vector<uint8_t> zQuantized;
152 double cminOrig = 0.0;
153 double cmaxOrig = 1.0;
154 bool colorScaleBaked = false; // log10 applied before quantization?
155
156 // Set by imagesc when rows*cols > 2M and the inline JSON preview was
157 // mean-pooled. originalRows/Cols always describe zQuantized's shape
158 // regardless of whether the inline preview was downsampled.
159 bool downsampled = false;
160 size_t originalRows = 0;
161 size_t originalCols = 0;
162
163 // ── Phase 2 large line-series downsampling ──────────────────────────
164 // For a huge line/stairs series (> kSeriesPreviewThreshold points) the
165 // full x/y are parsed out of xJson/yJson into xRaw/yRaw, xJson/yJson are
166 // replaced by a small M4 preview, and seriesDownsampled is set, so the
167 // frontend receives O(previewCols) points instead of O(N).
168 // getSeriesDisplayTile decimates xRaw/yRaw over a viewport for zoom
169 // detail. Empty for small series (which keep their full inline JSON).
170 std::vector<double> xRaw, yRaw;
171 bool seriesDownsampled = false;
172 size_t seriesN = 0;
173 double sxLo = 0, sxHi = 0, syLo = 0, syHi = 0; // x/y data ranges
174 // Lazy LOD pyramid (coarser levels only — xRaw/yRaw is the finest level)
175 // built on the first getSeriesDisplayTile so every viewport tile is
176 // O(width), not O(visible), even when fully zoomed out.
177 mutable std::vector<DecimatedSeries> seriesPyramid;
178
179 // ── Lazy LOD pyramid ───────────────────────────────────────────────
180 // L0 is zQuantized itself (originalRows × originalCols, column-major).
181 // Level k≥1 is lodLevels[k-1] with shape lodDims[k-1], built by 2×2
182 // mean-pooling the previous level (NaN sentinel propagates: only finite
183 // children contribute to a parent block, all-NaN block stays 255).
184 //
185 // Built lazily on getFigureDisplayTile when the resampler picks a
186 // level higher than what's already cached. Bounded by ~10 levels —
187 // beyond that the matrix is < 64×64 and further pooling is pointless.
188 //
189 // Memory cost: 4/3× original (geometric series). For 16k×42k uint8 =
190 // 670 MB → pyramid total ~890 MB.
191 mutable std::vector<std::vector<uint8_t>> lodLevels;
192 mutable std::vector<std::pair<size_t, size_t>> lodDims;
193
194 // ── Per-vertex colours for fill3 / polygon3d datasets.
195 // Layout: parallel to xJson/yJson/zJson — for each finite (x, y, z)
196 // sample there are three uint8 RGB bytes, in the SAME order. The
197 // null separators between polygons / triangles do NOT consume
198 // colour entries (the renderer skips them when walking xRaw/yRaw).
199 // Empty for datasets that should fall back to the single-colour
200 // `style` material.
201 std::string vertexColorsJson;
202
203 // ── animatedline storage. When type=="line" and the dataset was
204 // created via animatedline / addpoints, these vectors hold the
205 // raw point data; xJson/yJson are rebuilt from them on every
206 // emit. Empty for non-animated datasets.
207 std::vector<double> animatedX;
208 std::vector<double> animatedY;
209 bool isAnimated = false;
210
211 // ── Image-RGB (truecolor, set by imshow with M×N×3 input) ──────────
212 // Source bytes are ALWAYS uint8 0..255 — imshow casts double→u8 with
213 // *255 and clamps before reaching here. Layout is row-major RGBA
214 // QUADS (r0,g0,b0,a0, r1,g1,b1,a1, ...) — alpha is synthesized 255
215 // for plain RGB input. originalRows/originalCols give the
216 // pixel dims (same field as imagesc reuses).
217 //
218 // rgbJson is the inline preview: "[[r,g,b],[r,g,b],...]" row-major,
219 // mean-pooled if pixel count > 2M (same cap as imagesc). Empty for
220 // non-RGB datasets. type == "image-rgb" gates the renderer.
221 std::vector<uint8_t> rgbBytes;
222 std::string rgbJson;
223};
224
227{
228 std::vector<DatasetInfo> datasets;
229 std::string title;
230 std::string subtitle;
231 std::string xlabel;
232 std::string ylabel;
233 std::string xlimJson;
234 std::string ylimJson;
235 std::string rlimJson;
236 std::string thetalimJson;
237 std::string climJson; // color limits for imagesc, e.g. "[0,1]"
238 std::string colormapName; // "parula","jet","hot","cool","gray","viridis","turbo","hsv"
239 // Custom palette via `colormap(M)` with M an N×3 RGB matrix. JSON
240 // array string "[[r,g,b],[r,g,b],...]" with values in [0, 1]. When
241 // non-empty, overrides colormapName.
242 std::string customColormapJson;
243 bool polar = false;
244 bool holdOn = false;
245 // Major / minor grid as two independent booleans, matching MATLAB
246 // semantics:
247 // grid → toggles `gridMajor` (minor untouched)
248 // grid on → gridMajor = true
249 // grid off → both = false
250 // grid minor → toggles `gridMinor` (major untouched)
251 // gridUserTouched lets the adapter tell "user said off" apart from
252 // "script never called grid()" — important because the JS side
253 // wants different defaults for 2-D (off) and 3-D (on).
254 bool gridMajor = false;
255 bool gridMinor = false;
256 bool gridUserTouched = false;
257 std::vector<std::string> legendLabels;
258 // Legend placement. MATLAB Location values: best (default), north,
259 // south, east, west, northeast, northwest, southeast, southwest, and
260 // the *outside variants. We normalise to lowercase. Empty = renderer
261 // picks a default. Outside positions have a `outside` suffix.
262 std::string legendLocation;
263 // legendBoxOn — `legend boxoff` toggles the legend frame off.
264 // Default true (MATLAB default). JSON emits only when off.
265 bool legendBoxOn = true;
266 // legendHidden — `legend off` / `legend('hide')` / Visible='off' on
267 // the Legend handle: labels and location are PRESERVED (MATLAB
268 // semantics), the render is suppressed. JSON emits only when true.
269 bool legendHidden = false;
270 // Colorbar placement. MATLAB: east (default), west, north, south,
271 // and *outside variants. Same lowercase normalisation.
272 std::string colorbarLocation;
273
274 std::string xscale = "linear";
275 std::string yscale = "linear";
276 std::string colorScale = "linear"; // 'linear' | 'log' — survives prepareForPlot
277 std::string axisMode;
278 // Custom tick positions / labels. Empty = renderer auto-generates
279 // (`niceTicks`). Non-empty values override the auto-generated set
280 // for the corresponding axis. Labels override only when their length
281 // matches the corresponding ticks count.
282 std::string xTicksJson;
283 std::string yTicksJson;
284 std::string zTicksJson;
285 // Tick MODE (MATLAB XTickMode semantics, probe-verified R2025b):
286 // 'auto' until any explicit tick assignment (set(gca,'XTick',v) /
287 // xticks(v)); the renderer then generates ticks itself (empty json).
288 // 'manual' freezes the json as the exact set; a later xlim change
289 // leaves it untouched. xticks('auto') / XTickMode 'auto' clears the
290 // json and re-enables generation.
291 bool xTicksAuto = true;
292 bool yTicksAuto = true;
293 bool zTicksAuto = true;
294 std::string xTickLabelsJson; // ["lo","mid","hi"] format
295 std::string yTickLabelsJson;
296 std::string zTickLabelsJson;
297
298 // ── Axes font / frame (portion 34; MATLAB R2025b probe-verified) ──
299 // FontSize/Weight/Name apply to tick labels AND propagate to the
300 // label Text objects (title/xlabel/… default to 1.1× the axes font;
301 // setting the axes font RESETS even an explicitly-set Text font —
302 // MATLAB behaviour, not a default-tracking inheritance).
303 double fontSize = 10.0;
304 std::string fontWeight = "normal"; // normal | bold
305 std::string fontName = "Helvetica";
306 // Axes frame (box + tick marks) stroke width.
307 double lineWidth = 0.5;
308 // sprintf-style format string for tick labels (e.g. "%.2f").
309 // Empty = renderer's auto format. Honoured only when no explicit
310 // tick labels are set.
311 std::string xTickFormat;
312 std::string yTickFormat;
313 std::string zTickFormat;
314 // Tick-label rotation in degrees. 0 = horizontal (default).
315 double xTickAngle = 0.0;
316 double yTickAngle = 0.0;
317 // axisVisible — MATLAB `axis off` / `axis on` controls whether the
318 // axes lines / ticks / labels render. true = drawn (default), false
319 // = hidden. imshow ships axis off implicitly (image-only viewport).
320 // axisMode and axisVisible are independent: `axis image off` is
321 // axisMode='image' + axisVisible=false. JSON emit only when false
322 // so untouched figures keep the existing wire format.
323 bool axisVisible = true;
324 // boxOn — MATLAB `box on/off` controls the axis FRAME rectangle.
325 // true (default) draws the full box; false draws only the X / Y
326 // axis lines (left + bottom edges of the panel).
327 bool boxOn = true;
328 // Index into datasets[] of the most recent animatedline dataset.
329 // -1 = no animated line yet. addpoints / clearpoints / getpoints
330 // act on this dataset (nk doesn't model graphics handles, so
331 // animatedline operations target the axes' single active line).
332 int animatedDatasetIdx = -1;
333 // Axis direction. MATLAB: set(gca, 'XDir'/'YDir', 'normal'|'reverse').
334 // 'normal' (default) is left-to-right / bottom-to-top. 'reverse'
335 // flips. axis('ij') is shorthand for yDir='reverse'.
336 std::string xDir = "normal";
337 std::string yDir = "normal";
338
339 // ── yyaxis (dual Y) ────────────────────────────────────────────
340 // MATLAB:
341 // yyaxis left|right — switches the active Y-side; subsequent
342 // plot/scatter/etc. write to that side, and ylim/ylabel/yscale
343 // target the active side too.
344 // We model it as a "shadow" set of fields on the right side of the
345 // existing axes. yyEnabled stays false until the first yyaxis call,
346 // so single-axis plots keep their current JSON shape.
347 bool yyEnabled = false;
348 std::string activeYside = "left"; // "left" | "right"
349 std::string ylabel2;
350 std::string ylim2Json;
351 std::string yscale2 = "linear";
352
353 // 3-D camera view set by view(az, el); empty = renderer default
354 // (-37.5°, 30°). Wire format: "[az,el]" with degrees.
355 std::string viewJson;
356
357 // Z-axis label / limits (3-D figures). Empty = auto-derived from
358 // data extent, mirroring xlim/ylim semantics.
359 std::string zlabel;
360 std::string zlimJson;
361
362 // 3-D lighting / material state. Empty strings = renderer default
363 // (gouraud + plain Lambert + 1 ambient + 1 directional key).
364 // lightingMode: "flat" | "gouraud" | "phong" | "none"
365 // materialPreset: "shiny" | "metal" | "dull" | ""
366 // camlight: "left" | "right" | "headlight" | ""
367 std::string lightingMode;
368 std::string materialPreset;
369 std::string camlightPos;
370
371 // Interaction toggles for 3-D figures (rotate3d / pan3d / zoom3d).
372 // "" = default (all enabled when interactive). "off" = disable
373 // that specific axis of interaction. The renderer reads these and
374 // updates OrbitControls accordingly.
375 std::string rotate3dMode;
376 std::string pan3dMode;
377 std::string zoom3dMode;
378
379 std::string thetaDir = "counterclockwise";
380 std::string thetaZeroLocation = "right";
381 // Custom polar tick positions + labels — MATLAB thetaticks(),
382 // rticks(), thetaticklabels(), rticklabels(). Empty JSON arrays
383 // = renderer falls back to its auto-generated nice ticks.
384 // thetaticks: array of DEGREES (e.g. [0 45 90 135 180])
385 // rticks: array of radial values matching rlim units
386 // *ticklabels: array of strings, one per corresponding tick;
387 // length must match the tick array for it to take
388 // effect (renderer drops to auto when mismatched).
389 std::string thetaticksJson;
390 std::string rticksJson;
391 std::string thetaticklabelsJson;
392 std::string rticklabelsJson;
393
394 // Position in subplot grid (1-based), 0 = not a subplot
395 int subplotIndex = 0;
396};
397
399{
400 int id = 1;
401 bool modified = false;
402
403 // Subplot grid: 0 = no subplots (single axes)
404 int subplotRows = 0;
405 int subplotCols = 0;
406
407 // All axes in this figure; for non-subplot figures, size == 1
408 std::vector<AxesState> axes;
409 int currentAxes = 0; // index into axes[]
410
411 // linkaxes mode for the figure's subplot cells. Empty / "off" = no
412 // link. "x" / "y" / "xy" = pan/zoom propagates across all cells on
413 // those axes. We model this at the figure level — handles aren't
414 // a thing in nk's graphics layer yet, so calling linkaxes(...)
415 // unconditionally links every subplot cell in the current figure.
416 std::string linkMode;
417
418 // Figure-level "super title" set via sgtitle(...). MATLAB renders
419 // it above the entire subplot grid, separate from per-axes
420 // Title.String. Empty = no super-title (no header strip drawn).
421 std::string superTitle;
422
423 // ── annotation() overlays (portion 41). Coordinates are NORMALIZED
424 // FIGURE units [0,1] (MATLAB convention), independent of axes.
425 // 'line'/'arrow'/'textarrow': x1,y1=tail x2,y2=head;
426 // 'ellipse'/'rectangle'/'textbox': x1,y1=lower-left, x2,y2=width,
427 // height. MATLAB defaults probed R2025b: Color (0.1294)^3 gray,
428 // HeadSize 10, LineWidth 0.5, textarrow text at the TAIL.
429 struct Annotation {
430 std::string type; // line|arrow|textarrow|ellipse|rectangle|textbox
431 double x1 = 0, y1 = 0, x2 = 0, y2 = 0;
432 std::string text; // textarrow/textbox String
433 std::string colorHex; // "" = default gray
434 std::string faceHex; // "" = none
435 double fontSize = 10;
436 };
437 std::vector<Annotation> annotations;
438
441 {
442 if (axes.empty())
443 axes.push_back(AxesState{});
444 return axes[currentAxes];
445 }
446};
447
448static std::string jsonEscapeFig(const std::string &s)
449{
450 std::string out;
451 out.reserve(s.size());
452 for (char c : s) {
453 if (c == '"')
454 out += "\\\"";
455 else if (c == '\\')
456 out += "\\\\";
457 else if (c == '\n')
458 out += "\\n";
459 else
460 out += c;
461 }
462 return out;
463}
464
466{
467public:
468 using OutputFunc = std::function<void(const std::string &)>;
469
470 // ── Graphics handle registry (bugs/closed/graphics/plot-family-no-
471 // return-value.md) ────────────────────────────────────────────────
472 // Chart builtins create a handle record for each dataset they push;
473 // gca/gcf/figure create records for axes/figures; title/xlabel/…
474 // create slot-bound Text records; legend a per-axes Legend record;
475 // groot one global Root record. The returned handle object references
476 // it by id. set/get/delete/isgraphics consult this registry.
477 // Properties are the user-set values (defaults resolved by the
478 // accessor layer).
480 {
481 std::string type; // "line", "stem", … | "axes" | "figure"
482 // | "text" | "legend" | "root"
483 std::string className; // "matlab.graphics.chart.primitive.Line"
484 int figureId = 0;
485 // kNoAxes marks figure-scoped records (sgtitle Text) — scoped
486 // axes sweeps (prepareForPlot) leave them alone; full-figure
487 // sweeps (clf/close) invalidate them.
488 static constexpr size_t kNoAxes = static_cast<size_t>(-1);
489 size_t axesIndex = 0;
490 // Chart records: index into axes[axesIndex].datasets.
491 // kNoDataset for everything else (they address state directly).
492 static constexpr size_t kNoDataset = static_cast<size_t>(-1);
493 size_t datasetIndex = kNoDataset;
494 // Text records: which label slot this Text writes to
495 // ("title", "subtitle", "sgtitle", "xlabel", "ylabel", "zlabel").
496 std::string slot;
497 // Property name → JSON-encoded value. The figure layer stays
498 // core-free (no Value); the adapter (which has Value access)
499 // encodes/decodes.
500 std::map<std::string, std::string> props;
501 bool deleted = false;
502
503 bool isChart() const { return datasetIndex != kNoDataset; }
504 };
505
506 // Chart record for the dataset just pushed at `datasetIndex` of the
507 // CURRENT axes; non-chart records of the current figure/axes.
508 int createHandle(std::string type, std::string className,
509 size_t datasetIndex = HandleRecord::kNoDataset,
510 std::string slot = "")
511 {
512 auto &fig = current();
513 return createHandleAt(currentFigure_,
514 static_cast<size_t>(fig.currentAxes),
515 std::move(type), std::move(className),
516 std::move(slot), datasetIndex);
517 }
518
519 // Fully-explicit form (root records pass figureId 0 + kNoAxes;
520 // sgtitle passes the figure + kNoAxes; gcf.Children addresses
521 // non-current axes).
522 int createHandleAt(int figureId, size_t axesIndex, std::string type,
523 std::string className, std::string slot = "",
524 size_t datasetIndex = HandleRecord::kNoDataset)
525 {
526 const int id = nextHandleId_++;
527 HandleRecord rec;
528 rec.type = std::move(type);
529 rec.className = std::move(className);
530 rec.figureId = figureId;
531 rec.axesIndex = axesIndex;
532 rec.slot = std::move(slot);
533 rec.datasetIndex = datasetIndex;
534 handleRegistry_[id] = std::move(rec);
535 return id;
536 }
537
538 HandleRecord *findHandle(int id)
539 {
540 auto it = handleRegistry_.find(id);
541 if (it == handleRegistry_.end() || it->second.deleted)
542 return nullptr;
543 return &it->second;
544 }
545
546 void deleteHandle(int id)
547 {
548 auto it = handleRegistry_.find(id);
549 if (it == handleRegistry_.end() || it->second.deleted)
550 return;
551 it->second.deleted = true;
552 if (!it->second.isChart())
553 return;
554 // Remove the dataset from its axes so the render no longer
555 // carries it; reindex the surviving chart handles of that axes
556 // (their dataset slots shift down by one).
557 DatasetInfo *ds = datasetOf(it->second);
558 if (!ds)
559 return;
560 auto fit = figures_.find(it->second.figureId);
561 if (fit == figures_.end() || it->second.axesIndex >= fit->second.axes.size())
562 return;
563 auto &ax = fit->second.axes[it->second.axesIndex];
564 if (it->second.datasetIndex >= ax.datasets.size())
565 return;
566 ax.datasets.erase(ax.datasets.begin() + it->second.datasetIndex);
567 fit->second.modified = true;
568 for (auto &[hid, rec] : handleRegistry_) {
569 if (!rec.deleted && rec.figureId == it->second.figureId
570 && rec.axesIndex == it->second.axesIndex
571 && rec.datasetIndex > it->second.datasetIndex)
572 --rec.datasetIndex;
573 }
574 }
575
576 // Remove one axes from a figure (delete(axesHandle)). Reindexes the
577 // surviving records' axesIndex; marks the figure modified.
578 void removeAxes(int figureId, size_t axesIndex)
579 {
580 auto fit = figures_.find(figureId);
581 if (fit == figures_.end() || axesIndex >= fit->second.axes.size())
582 return;
583 for (auto &[hid, rec] : handleRegistry_)
584 if (!rec.deleted && rec.figureId == figureId
585 && rec.axesIndex == axesIndex)
586 rec.deleted = true;
587 fit->second.axes.erase(fit->second.axes.begin() + axesIndex);
588 if (fit->second.currentAxes >= (int)axesIndex
589 && fit->second.currentAxes > 0)
590 --fit->second.currentAxes;
591 for (auto &[hid, rec] : handleRegistry_) {
592 if (!rec.deleted && rec.figureId == figureId
593 && rec.axesIndex > axesIndex)
594 --rec.axesIndex;
595 }
596 fit->second.modified = true;
597 }
598
599 // Live dataset behind a chart record (nullptr when the axes or the
600 // dataset slot is gone — e.g. after prepareForPlot/closing).
601 DatasetInfo *datasetOf(const HandleRecord &rec)
602 {
603 if (!rec.isChart())
604 return nullptr;
605 auto fit = figures_.find(rec.figureId);
606 if (fit == figures_.end() || rec.axesIndex >= fit->second.axes.size())
607 return nullptr;
608 auto &ax = fit->second.axes[rec.axesIndex];
609 if (rec.datasetIndex >= ax.datasets.size())
610 return nullptr;
611 return &ax.datasets[rec.datasetIndex];
612 }
613
614 AxesState *axesOf(const HandleRecord &rec)
615 {
616 auto fit = figures_.find(rec.figureId);
617 if (fit == figures_.end() || rec.axesIndex >= fit->second.axes.size())
618 return nullptr;
619 return &fit->second.axes[rec.axesIndex];
620 }
621
622 FigureState *figureOf(int figureId)
623 {
624 auto it = figures_.find(figureId);
625 return it == figures_.end() ? nullptr : &it->second;
626 }
627
628 void markFigureModified(int figureId)
629 {
630 auto fit = figures_.find(figureId);
631 if (fit != figures_.end())
632 fit->second.modified = true;
633 }
634
635 // Invalidate handle records whose backing state died wholesale.
636 // close(fig) → the whole tree: charts + axes + the figure
637 // record itself (axesIdx=-1, T, T)
638 // clf → charts + axes records, figure survives
639 // (axesIdx=-1, T, F)
640 // prepareForPlot → ONLY the charts of one axes — the axes
641 // record survives the content reset
642 // (axesIdx=k, F, F)
643 // Stale handles then read as deleted, exactly like MATLAB handles
644 // over a cleared axes.
645 void invalidateHandles(int figureId, int axesIdx,
646 bool includeAxesRecord, bool includeFigureRecord)
647 {
648 for (auto &[hid, rec] : handleRegistry_) {
649 if (rec.figureId != figureId || rec.deleted)
650 continue;
651 if (axesIdx >= 0 && rec.axesIndex != (size_t)axesIdx)
652 continue;
653 if (!includeFigureRecord && rec.type == "figure")
654 continue;
655 if (!includeAxesRecord && rec.type == "axes")
656 continue;
657 rec.deleted = true;
658 }
659 }
660
661 std::unordered_map<int, HandleRecord> &handles() { return handleRegistry_; }
662
663
667 void setOutputFunc(OutputFunc f) { outputFunc_ = std::move(f); }
668
669 FigureState &current()
670 {
671 if (figures_.find(currentFigure_) == figures_.end()) {
672 FigureState fs;
673 fs.id = currentFigure_;
674 fs.axes.push_back(AxesState{});
675 figures_[currentFigure_] = fs;
676 }
677 return figures_[currentFigure_];
678 }
679
681 AxesState &currentAxes() { return current().cur(); }
682
689 // Large line-series → downsampled-preview thresholds. Below the
690 // threshold the full array is shipped and the JS-side viewport
691 // decimation (Phase 1) keeps full zoom detail; at/above it the engine
692 // ships a small preview + retains raw x/y for getSeriesDisplayTile.
693 static constexpr std::size_t kSeriesPreviewThreshold = 1000000; // points
694 static constexpr int kSeriesPreviewCols = 2000; // preview cols
695
696 void pushDataset(DatasetInfo ds)
697 {
698 auto &ax = currentAxes();
699 if (ax.yyEnabled && ds.yside.empty())
700 ds.yside = ax.activeYside;
701 maybeDownsampleSeries(ds);
702 ax.datasets.push_back(std::move(ds));
703 }
704
705 // For a huge line/stairs series with ascending x, retain raw x/y for
706 // viewport tiles and replace the inline JSON with a small M4 preview —
707 // the frontend then receives O(previewCols) points, not O(N). Mirrors
708 // imagesc's downsampled-preview path. No-op for small or non-monotonic
709 // series (they keep their full inline JSON + the JS-side decimation).
710 static void maybeDownsampleSeries(DatasetInfo &ds)
711 {
712 if (ds.type != "line" && ds.type != "stairs") return;
713 if (ds.xJson.empty() || ds.yJson.empty()) return;
714 if (figdetail::countFlatElements(ds.xJson) <= kSeriesPreviewThreshold) return;
715 auto xr = figdetail::parseFlatDoubles(ds.xJson);
716 auto yr = figdetail::parseFlatDoubles(ds.yJson);
717 const std::size_t n = xr.size();
718 if (n != yr.size() || n <= kSeriesPreviewThreshold) return;
719 // Decimation buckets by x — valid only for ascending x. Bail on a
720 // non-monotonic series (rare for huge data; keeps the full JSON).
721 for (std::size_t i = 1; i < n; ++i) if (xr[i] < xr[i - 1]) return;
722 double xlo = xr.front(), xhi = xr.back();
723 if (xhi < xlo) std::swap(xlo, xhi);
724 double ylo = INFINITY, yhi = -INFINITY;
725 for (double v : yr) if (std::isfinite(v)) { ylo = std::min(ylo, v); yhi = std::max(yhi, v); }
726 if (!std::isfinite(ylo)) { ylo = 0; yhi = 0; }
727 auto prev = decimateM4(xr.data(), yr.data(), n, xlo, xhi, kSeriesPreviewCols);
728 ds.xJson = figdetail::serializeFlatDoubles(prev.x);
729 ds.yJson = figdetail::serializeFlatDoubles(prev.y);
730 ds.xRaw = std::move(xr);
731 ds.yRaw = std::move(yr);
732 ds.seriesDownsampled = true;
733 ds.seriesN = n;
734 ds.sxLo = xlo; ds.sxHi = xhi; ds.syLo = ylo; ds.syHi = yhi;
735 }
736
737 int newFigure()
738 {
739 int id = 1;
740 while (figures_.find(id) != figures_.end())
741 id++;
742 currentFigure_ = id;
743 FigureState fs;
744 fs.id = id;
745 fs.axes.push_back(AxesState{});
746 figures_[id] = fs;
747 return id;
748 }
749
750 int setFigure(int n)
751 {
752 currentFigure_ = n;
753 if (figures_.find(n) == figures_.end()) {
754 FigureState fs;
755 fs.id = n;
756 fs.axes.push_back(AxesState{});
757 figures_[n] = fs;
758 }
759 return n;
760 }
761
762 int currentFigureId() const { return currentFigure_; }
763
765 void setSubplot(int m, int n, int p)
766 {
767 auto &fig = current();
768 fig.subplotRows = m;
769 fig.subplotCols = n;
770
771 // Find the axes for this subplot position
772 for (int i = 0; i < static_cast<int>(fig.axes.size()); ++i) {
773 if (fig.axes[i].subplotIndex == p) {
774 fig.currentAxes = i;
775 return;
776 }
777 }
778 // MATLAB subplot DELETES the overlapping default axes (a lone
779 // subplotIndex-0 axes spans the whole figure): the first subplot
780 // call adopts its slot instead of ghosting it, so
781 // gcf.Children counts match MATLAB
782 // (bugs/closed/graphics/subplot-replaces-default-axes.md).
783 for (int i = 0; i < static_cast<int>(fig.axes.size()); ++i) {
784 if (fig.axes[i].subplotIndex == 0) {
785 invalidateHandles(fig.id, i,
786 /*axes records*/ true, /*figure rec*/ false);
787 fig.axes[i] = AxesState{};
788 fig.axes[i].subplotIndex = p;
789 fig.currentAxes = i;
790 fig.modified = true;
791 return;
792 }
793 }
794 // Create new axes for position p
795 AxesState ax;
796 ax.subplotIndex = p;
797 fig.axes.push_back(ax);
798 fig.currentAxes = static_cast<int>(fig.axes.size()) - 1;
799 }
800
801 void prepareForPlot()
802 {
803 auto &fig = current();
804 auto &ax = fig.cur();
805 if (!ax.holdOn) {
806 // The previous charts die with the fresh axes — their handles
807 // go stale (MATLAB: hold off + plot invalidates old Lines).
808 // The axes itself (and the figure) survive the content reset.
809 invalidateHandles(fig.id, fig.currentAxes,
810 /*axes records*/ false, /*figure record*/ false);
811 // The datasets this axes held are about to be wiped, so the
812 // chart-call handle base restarts at 0 (see beginChartCall).
813 chartCallBase_ = 0;
814 // Preserve fields that "survive a fresh plot" — subplot position
815 // and colorScale (the latter so `colorscale('log'); imagesc(M)`
816 // bakes log into the new dataset's quantization).
817 int savedSubplot = ax.subplotIndex;
818 std::string savedColorScale = ax.colorScale;
819 ax = AxesState{};
820 ax.subplotIndex = savedSubplot;
821 ax.colorScale = savedColorScale;
822 }
823 fig.modified = true;
824 }
825
826 // ── Chart-call handle base ─────────────────────────────────────
827 // The adapter (graphics_library.cpp) snapshots the current axes'
828 // dataset count before running a chart builtin's body; prepareForPlot
829 // resets it to 0 when it wipes the axes. The post-hook then binds
830 // handles for datasets [base, after) — exactly the ones THIS call
831 // pushed, whether or not a wipe intervened.
832 void beginChartCall() { chartCallBase_ = currentAxes().datasets.size(); }
833 size_t chartCallBase() const { return chartCallBase_; }
834
837 {
838 for (auto &[id, fig] : figures_) {
839 if (!fig.modified)
840 continue;
841 fig.modified = false;
842
843 std::ostringstream os;
844 os << "__FIGURE_DATA__:{\"id\":" << fig.id;
845
846 // Subplot grid info
847 if (fig.subplotRows > 0) {
848 os << ",\"subplotGrid\":[" << fig.subplotRows << "," << fig.subplotCols << "]";
849 }
850 if (!fig.linkMode.empty())
851 os << ",\"linkMode\":\"" << fig.linkMode << "\"";
852 if (!fig.superTitle.empty())
853 os << ",\"superTitle\":\"" << jsonEscapeFig(fig.superTitle) << "\"";
854 if (!fig.annotations.empty()) {
855 os << ",\"annotations\":[";
856 for (size_t ai = 0; ai < fig.annotations.size(); ++ai) {
857 const auto &an = fig.annotations[ai];
858 if (ai) os << ',';
859 os << "{\"type\":\"" << an.type << "\""
860 << ",\"x\":[" << an.x1 << ',' << an.x2 << "]"
861 << ",\"y\":[" << an.y1 << ',' << an.y2 << "]"
862 << ",\"text\":\"" << jsonEscapeFig(an.text) << "\"";
863 if (!an.colorHex.empty())
864 os << ",\"color\":\"" << an.colorHex << "\"";
865 if (!an.faceHex.empty())
866 os << ",\"face\":\"" << an.faceHex << "\"";
867 if (an.fontSize != 10)
868 os << ",\"fontSize\":" << an.fontSize;
869 os << '}';
870 }
871 os << ']';
872 }
873
874 os << ",\"axes\":[";
875 for (size_t ai = 0; ai < fig.axes.size(); ++ai) {
876 if (ai)
877 os << ",";
878 auto &ax = fig.axes[ai];
879 os << "{";
880 if (ax.subplotIndex > 0)
881 os << "\"subplotIndex\":" << ax.subplotIndex << ",";
882 os << "\"datasets\":[";
883 for (size_t i = 0; i < ax.datasets.size(); ++i) {
884 if (i)
885 os << ",";
886 auto &ds = ax.datasets[i];
887 os << "{\"x\":" << ds.xJson << ",\"y\":" << ds.yJson << ",\"type\":\""
888 << ds.type << "\"";
889 if (ds.seriesDownsampled)
890 os << ",\"seriesDownsampled\":true,\"n\":" << ds.seriesN
891 << ",\"xRange\":[" << ds.sxLo << "," << ds.sxHi << "]"
892 << ",\"yRange\":[" << ds.syLo << "," << ds.syHi << "]";
893 if (!ds.label.empty())
894 os << ",\"label\":\"" << jsonEscapeFig(ds.label) << "\"";
895 if (!ds.style.empty())
896 os << ",\"style\":\"" << ds.style << "\"";
897 if (ds.lineWidth > 0)
898 os << ",\"lineWidth\":" << ds.lineWidth;
899 if (ds.markerSize > 0)
900 os << ",\"markerSize\":" << ds.markerSize;
901 if (!ds.visible)
902 os << ",\"visible\":false";
903 // Per-point size / colour columns — used by
904 // polarbubblechart and (future) bubblechart on
905 // cartesian axes. Emitted as `size` / `pointColor`
906 // to avoid colliding with the dataset-level
907 // `color` style attribute.
908 if (!ds.sizeJson.empty())
909 os << ",\"size\":" << ds.sizeJson;
910 if (!ds.colorJson.empty())
911 os << ",\"pointColor\":" << ds.colorJson;
912 if (!ds.eJson.empty())
913 os << ",\"e\":" << ds.eJson;
914 if (!ds.eNegJson.empty())
915 os << ",\"eNeg\":" << ds.eNegJson;
916 if (!ds.ePosJson.empty())
917 os << ",\"ePos\":" << ds.ePosJson;
918 if (!ds.uJson.empty())
919 os << ",\"u\":" << ds.uJson;
920 if (!ds.vJson.empty())
921 os << ",\"v\":" << ds.vJson;
922 if (!ds.yside.empty())
923 os << ",\"yside\":\"" << ds.yside << "\"";
924 if (!ds.zJson.empty())
925 os << ",\"z\":" << ds.zJson;
926 if (!ds.vertexColorsJson.empty())
927 os << ",\"vertexColors\":" << ds.vertexColorsJson;
928 if (!ds.rgbJson.empty()) {
929 os << ",\"rgb\":" << ds.rgbJson;
930 os << ",\"originalRows\":" << ds.originalRows
931 << ",\"originalCols\":" << ds.originalCols;
932 if (ds.downsampled)
933 os << ",\"downsampled\":true";
934 }
935 if (!ds.zQuantized.empty()) {
936 os << ",\"cminOrig\":" << ds.cminOrig
937 << ",\"cmaxOrig\":" << ds.cmaxOrig;
938 if (ds.colorScaleBaked)
939 os << ",\"colorScaleBaked\":\"log\"";
940 os << ",\"originalRows\":" << ds.originalRows
941 << ",\"originalCols\":" << ds.originalCols;
942 if (ds.downsampled)
943 os << ",\"downsampled\":true";
944 }
945 os << "}";
946 }
947 os << "],\"config\":{";
948 os << "\"title\":\"" << jsonEscapeFig(ax.title) << "\"";
949 if (!ax.subtitle.empty())
950 os << ",\"subtitle\":\"" << jsonEscapeFig(ax.subtitle) << "\"";
951 os << ",\"xlabel\":\"" << jsonEscapeFig(ax.xlabel) << "\"";
952 os << ",\"ylabel\":\"" << jsonEscapeFig(ax.ylabel) << "\"";
953 if (!ax.xlimJson.empty())
954 os << ",\"xlim\":" << ax.xlimJson;
955 if (!ax.ylimJson.empty())
956 os << ",\"ylim\":" << ax.ylimJson;
957 if (!ax.rlimJson.empty())
958 os << ",\"rlim\":" << ax.rlimJson;
959 if (!ax.thetalimJson.empty())
960 os << ",\"thetalim\":" << ax.thetalimJson;
961 if (!ax.climJson.empty())
962 os << ",\"clim\":" << ax.climJson;
963 if (!ax.colormapName.empty())
964 os << ",\"colormap\":\"" << ax.colormapName << "\"";
965 if (!ax.customColormapJson.empty())
966 os << ",\"customColormap\":" << ax.customColormapJson;
967 // Emit "grid" only when the user explicitly called
968 // grid(...). Field absence means "script never asked";
969 // the JS adapter then picks a type-appropriate default
970 // (2-D off, 3-D on, mirroring MATLAB).
971 if (ax.gridUserTouched)
972 os << ",\"grid\":\"" << (ax.gridMajor ? "on" : "off") << "\"";
973 if (ax.gridMinor)
974 os << ",\"gridMinor\":\"on\"";
975 os << ",\"polar\":" << (ax.polar ? "true" : "false");
976 os << ",\"xscale\":\"" << ax.xscale << "\"";
977 os << ",\"yscale\":\"" << ax.yscale << "\"";
978 if (!ax.axisMode.empty())
979 os << ",\"axisMode\":\"" << ax.axisMode << "\"";
980 if (!ax.xTicksJson.empty())
981 os << ",\"xticks\":" << ax.xTicksJson;
982 if (!ax.yTicksJson.empty())
983 os << ",\"yticks\":" << ax.yTicksJson;
984 if (!ax.zTicksJson.empty())
985 os << ",\"zticks\":" << ax.zTicksJson;
986 if (!ax.xTickLabelsJson.empty())
987 os << ",\"xticklabels\":" << ax.xTickLabelsJson;
988 if (!ax.yTickLabelsJson.empty())
989 os << ",\"yticklabels\":" << ax.yTickLabelsJson;
990 if (!ax.zTickLabelsJson.empty())
991 os << ",\"zticklabels\":" << ax.zTickLabelsJson;
992 if (!ax.xTickFormat.empty())
993 os << ",\"xtickformat\":\"" << jsonEscapeFig(ax.xTickFormat) << "\"";
994 if (!ax.yTickFormat.empty())
995 os << ",\"ytickformat\":\"" << jsonEscapeFig(ax.yTickFormat) << "\"";
996 if (!ax.zTickFormat.empty())
997 os << ",\"ztickformat\":\"" << jsonEscapeFig(ax.zTickFormat) << "\"";
998 if (ax.xTickAngle != 0.0)
999 os << ",\"xtickangle\":" << ax.xTickAngle;
1000 if (ax.yTickAngle != 0.0)
1001 os << ",\"ytickangle\":" << ax.yTickAngle;
1002 // Axes font / frame (portion 34). Emitted only when
1003 // non-default so untouched figures keep the wire shape.
1004 if (ax.fontSize != 10.0)
1005 os << ",\"fontSize\":" << ax.fontSize;
1006 if (ax.fontWeight != "normal")
1007 os << ",\"fontWeight\":\"" << ax.fontWeight << "\"";
1008 if (ax.fontName != "Helvetica")
1009 os << ",\"fontName\":\"" << jsonEscapeFig(ax.fontName) << "\"";
1010 if (ax.lineWidth != 0.5)
1011 os << ",\"axesLineWidth\":" << ax.lineWidth;
1012 // axisVisible default = true; emit only when off
1013 // (imshow / `axis off` set this).
1014 if (!ax.axisVisible)
1015 os << ",\"axisVisible\":false";
1016 if (!ax.boxOn)
1017 os << ",\"box\":\"off\"";
1018 if (ax.xDir == "reverse")
1019 os << ",\"xDir\":\"reverse\"";
1020 if (ax.yDir == "reverse")
1021 os << ",\"yDir\":\"reverse\"";
1022 if (ax.polar) {
1023 os << ",\"thetaDir\":\"" << ax.thetaDir << "\"";
1024 os << ",\"thetaZeroLocation\":\"" << ax.thetaZeroLocation << "\"";
1025 if (!ax.thetaticksJson.empty())
1026 os << ",\"thetaticks\":" << ax.thetaticksJson;
1027 if (!ax.rticksJson.empty())
1028 os << ",\"rticks\":" << ax.rticksJson;
1029 if (!ax.thetaticklabelsJson.empty())
1030 os << ",\"thetaticklabels\":" << ax.thetaticklabelsJson;
1031 if (!ax.rticklabelsJson.empty())
1032 os << ",\"rticklabels\":" << ax.rticklabelsJson;
1033 }
1034 if (!ax.legendLabels.empty()) {
1035 os << ",\"legend\":[";
1036 for (size_t i = 0; i < ax.legendLabels.size(); ++i) {
1037 if (i)
1038 os << ",";
1039 os << "\"" << jsonEscapeFig(ax.legendLabels[i]) << "\"";
1040 }
1041 os << "]";
1042 }
1043 if (ax.legendHidden)
1044 os << ",\"legendHidden\":true";
1045 if (!ax.legendLocation.empty())
1046 os << ",\"legendLocation\":\"" << ax.legendLocation << "\"";
1047 if (!ax.legendBoxOn)
1048 os << ",\"legendBox\":\"off\"";
1049 if (!ax.colorbarLocation.empty())
1050 os << ",\"colorbarLocation\":\"" << ax.colorbarLocation << "\"";
1051 if (ax.yyEnabled) {
1052 os << ",\"yyEnabled\":true";
1053 if (!ax.ylabel2.empty())
1054 os << ",\"ylabel2\":\"" << jsonEscapeFig(ax.ylabel2) << "\"";
1055 if (!ax.ylim2Json.empty())
1056 os << ",\"ylim2\":" << ax.ylim2Json;
1057 if (ax.yscale2 != "linear")
1058 os << ",\"yscale2\":\"" << ax.yscale2 << "\"";
1059 }
1060 if (!ax.viewJson.empty())
1061 os << ",\"view\":" << ax.viewJson;
1062 if (!ax.zlabel.empty())
1063 os << ",\"zlabel\":\"" << jsonEscapeFig(ax.zlabel) << "\"";
1064 if (!ax.zlimJson.empty())
1065 os << ",\"zlim\":" << ax.zlimJson;
1066 if (!ax.lightingMode.empty())
1067 os << ",\"lighting\":\"" << ax.lightingMode << "\"";
1068 if (!ax.materialPreset.empty())
1069 os << ",\"material\":\"" << ax.materialPreset << "\"";
1070 if (!ax.camlightPos.empty())
1071 os << ",\"camlight\":\"" << ax.camlightPos << "\"";
1072 if (!ax.rotate3dMode.empty())
1073 os << ",\"rotate3d\":\"" << ax.rotate3dMode << "\"";
1074 if (!ax.pan3dMode.empty())
1075 os << ",\"pan3d\":\"" << ax.pan3dMode << "\"";
1076 if (!ax.zoom3dMode.empty())
1077 os << ",\"zoom3d\":\"" << ax.zoom3dMode << "\"";
1078 os << "}}";
1079 }
1080 os << "]}";
1081 std::string line = os.str() + "\n";
1082 if (outputFunc_)
1083 outputFunc_(line);
1084 else
1085 std::cout << line;
1086 }
1087 }
1088
1089 void closeFigure(int id)
1090 {
1091 invalidateHandles(id, -1, /*axes records*/ true, /*figure record*/ true);
1092 figures_.erase(id);
1093 if (currentFigure_ == id) {
1094 if (!figures_.empty())
1095 currentFigure_ = figures_.rbegin()->first;
1096 else
1097 currentFigure_ = 1;
1098 }
1099 }
1100
1103 {
1104 closeFigure(id);
1105 std::string marker = "__FIGURE_CLOSE__:" + std::to_string(id) + "\n";
1106 if (outputFunc_) outputFunc_(marker); else std::cout << marker;
1107 }
1108
1109 void closeCurrent() { closeFigure(currentFigure_); }
1110
1111 void closeCurrentNotify()
1112 {
1113 int id = currentFigure_;
1114 closeCurrent();
1115 std::string marker = "__FIGURE_CLOSE__:" + std::to_string(id) + "\n";
1116 if (outputFunc_) outputFunc_(marker); else std::cout << marker;
1117 }
1118
1119 void closeAll()
1120 {
1121 for (auto &[hid, rec] : handleRegistry_)
1122 rec.deleted = true;
1123 figures_.clear();
1124 currentFigure_ = 1;
1125 }
1126
1127 void closeAllNotify()
1128 {
1129 closeAll();
1130 std::string marker = "__FIGURE_CLOSE_ALL__\n";
1131 if (outputFunc_) outputFunc_(marker); else std::cout << marker;
1132 }
1133
1134 const std::map<int, FigureState> &figures() const { return figures_; }
1135
1136 // ── Reactive-session checkpoints ─────────────────────────────
1137 // Rollback point for incremental re-runs (ReactiveSession). A
1138 // re-run `plot` under a persisted `hold on` would APPEND lines
1139 // (each tick accumulating curves) — restoring the checkpoint
1140 // taken before the dirty set truncates datasets back to the
1141 // pre-tick count and re-applies the hold flag, so the replayed
1142 // graphics statements reproduce the full-re-run figure state.
1144 bool holdOn = false;
1145 size_t datasetCount = 0;
1146 };
1148 // figure id → per-axes snapshot (sized to the axes vector at
1149 // capture time; figures absent here are treated as "created
1150 // after the checkpoint" on restore).
1151 std::vector<std::pair<int, std::vector<AxesCheckpoint>>> figures;
1152 };
1153 FigureCheckpoint checkpointFigures() const;
1154 void restoreFigures(const FigureCheckpoint &cp);
1155
1183 // Decimate a downsampled line series' raw x/y over the viewport [x0,x1]
1184 // to `width` columns. algo: 0=M4 (default), 1=LTTB, 2=none. Empty when
1185 // the dataset isn't a downsampled series or indices are out of range —
1186 // the frontend refines zoom detail without ever shipping the full array.
1187 DecimatedSeries getSeriesDisplayTile(int figId, int axIdx, int dsIdx,
1188 double x0, double x1, int width, int algo) const
1189 {
1190 DecimatedSeries out;
1191 auto it = figures_.find(figId);
1192 if (it == figures_.end()) return out;
1193 const auto &fig = it->second;
1194 if (axIdx < 0 || axIdx >= static_cast<int>(fig.axes.size())) return out;
1195 const auto &ax = fig.axes[axIdx];
1196 if (dsIdx < 0 || dsIdx >= static_cast<int>(ax.datasets.size())) return out;
1197 const auto &ds = ax.datasets[dsIdx];
1198 if (ds.xRaw.empty() || ds.xRaw.size() != ds.yRaw.size()) return out;
1199 // Build the coarse LOD levels once, then pick the cheapest level for
1200 // this viewport so the decimation is O(width) even fully zoomed out.
1201 if (ds.seriesPyramid.empty() && ds.xRaw.size() > 8000)
1202 ds.seriesPyramid = buildPyramid(ds.xRaw.data(), ds.yRaw.data(), ds.xRaw.size());
1203 DecimAlgo a = (algo == 1) ? DecimAlgo::LTTB
1204 : (algo == 2) ? DecimAlgo::None
1205 : (algo == 3) ? DecimAlgo::M2 : DecimAlgo::M4;
1206 return decimateLOD(ds.xRaw.data(), ds.yRaw.data(), ds.xRaw.size(),
1207 ds.seriesPyramid, x0, x1, width, a);
1208 }
1209
1210 bool getFigureDisplayTile(int figId, int axIdx, int dsIdx,
1211 double srcR0, double srcC0,
1212 double srcH, double srcW,
1213 int displayH, int displayW,
1214 bool xLog, bool yLog,
1215 uint8_t *out) const
1216 {
1217 if (!out || displayH <= 0 || displayW <= 0) return false;
1218
1219 auto figIt = figures_.find(figId);
1220 if (figIt == figures_.end()) return false;
1221 const auto &fig = figIt->second;
1222 if (axIdx < 0 || axIdx >= static_cast<int>(fig.axes.size())) return false;
1223 const auto &axState = fig.axes[axIdx];
1224 if (dsIdx < 0 || dsIdx >= static_cast<int>(axState.datasets.size())) return false;
1225 const auto &ds = axState.datasets[dsIdx];
1226 if (ds.zQuantized.empty() || ds.type != "imagesc") return false;
1227
1228 const size_t fullRows = ds.originalRows;
1229 const size_t fullCols = ds.originalCols;
1230 if (fullRows == 0 || fullCols == 0) return false;
1231 if (ds.zQuantized.size() < fullRows * fullCols) return false;
1232
1233 // Clamp source-rect to the dataset bounds.
1234 if (srcR0 < 0) { srcH += srcR0; srcR0 = 0; }
1235 if (srcC0 < 0) { srcW += srcC0; srcC0 = 0; }
1236 const double srcR1 = std::min(static_cast<double>(fullRows), srcR0 + srcH);
1237 const double srcC1 = std::min(static_cast<double>(fullCols), srcC0 + srcW);
1238 if (srcR1 <= srcR0 || srcC1 <= srcC0) return false;
1239
1240 // Log axes need a strictly-positive lower bound.
1241 if (yLog && srcR0 <= 0) return false;
1242 if (xLog && srcC0 <= 0) return false;
1243
1244 // ── LOD pyramid: pick the smallest level whose cells are ≤1 per
1245 // display-pixel. At level k each cell covers 2^k × 2^k of L0, so
1246 // the optimal level is floor(log2(min(rStep, cStep))) clamped to
1247 // [0, 10]. Building higher levels lazily — first zoom-out builds
1248 // up the tree once, subsequent zooms reuse cached levels.
1249 const double rStep0 = (srcR1 - srcR0) / displayH;
1250 const double cStep0 = (srcC1 - srcC0) / displayW;
1251 const double minStep = std::min(rStep0, cStep0);
1252 int level = 0;
1253 if (minStep > 1.5) {
1254 level = static_cast<int>(std::floor(std::log2(minStep)));
1255 if (level < 0) level = 0;
1256 if (level > 10) level = 10;
1257 }
1258 ensureLOD(ds, level);
1259
1260 // Resolve the chosen level's data + dims.
1261 const uint8_t *lvlData;
1262 size_t lvlRows, lvlCols;
1263 if (level == 0) {
1264 lvlData = ds.zQuantized.data();
1265 lvlRows = fullRows;
1266 lvlCols = fullCols;
1267 } else if (level <= static_cast<int>(ds.lodLevels.size())) {
1268 lvlData = ds.lodLevels[level - 1].data();
1269 lvlRows = ds.lodDims[level - 1].first;
1270 lvlCols = ds.lodDims[level - 1].second;
1271 } else {
1272 // ensureLOD couldn't build (e.g. dataset too small) — fall back to L0.
1273 lvlData = ds.zQuantized.data();
1274 lvlRows = fullRows;
1275 lvlCols = fullCols;
1276 level = 0;
1277 }
1278
1279 // Map source-rect from L0 coords to the chosen level's coords.
1280 const double scaleFactor = static_cast<double>(1 << level);
1281 const double lvlR0 = srcR0 / scaleFactor;
1282 const double lvlC0 = srcC0 / scaleFactor;
1283 const double lvlR1 = srcR1 / scaleFactor;
1284 const double lvlC1 = srcC1 / scaleFactor;
1285 const double rStep = (lvlR1 - lvlR0) / displayH;
1286 const double cStep = (lvlC1 - lvlC0) / displayW;
1287 const int rBlock = std::max(1, static_cast<int>(std::ceil(rStep)));
1288 const int cBlock = std::max(1, static_cast<int>(std::ceil(cStep)));
1289
1290 // Log-ratios computed in level coords (log preserves ratios).
1291 const double yLogRatio = yLog ? std::log(lvlR1 / lvlR0) : 0.0;
1292 const double xLogRatio = xLog ? std::log(lvlC1 / lvlC0) : 0.0;
1293
1294 for (int orow = 0; orow < displayH; ++orow) {
1295 const double v = (orow + 0.5) / displayH;
1296 const double rCenter = yLog
1297 ? lvlR0 * std::exp(v * yLogRatio)
1298 : lvlR0 + v * (lvlR1 - lvlR0);
1299 const int rA = std::max(0, static_cast<int>(rCenter) - rBlock / 2);
1300 const int rB = std::min(static_cast<int>(lvlRows), rA + rBlock);
1301 for (int ocol = 0; ocol < displayW; ++ocol) {
1302 const double u = (ocol + 0.5) / displayW;
1303 const double cCenter = xLog
1304 ? lvlC0 * std::exp(u * xLogRatio)
1305 : lvlC0 + u * (lvlC1 - lvlC0);
1306 const int cA = std::max(0, static_cast<int>(cCenter) - cBlock / 2);
1307 const int cB = std::min(static_cast<int>(lvlCols), cA + cBlock);
1308 int sum = 0, n = 0;
1309 for (int cc = cA; cc < cB; ++cc) {
1310 for (int rr = rA; rr < rB; ++rr) {
1311 const uint8_t q = lvlData[cc * lvlRows + rr];
1312 if (q != 255) { sum += q; ++n; }
1313 }
1314 }
1315 out[orow * displayW + ocol] = (n > 0)
1316 ? static_cast<uint8_t>((sum + n / 2) / n)
1317 : uint8_t{255};
1318 }
1319 }
1320 return true;
1321 }
1322
1331 void ensureLOD(const DatasetInfo &ds, int targetLevel) const
1332 {
1333 while (static_cast<int>(ds.lodLevels.size()) < targetLevel) {
1334 size_t pRows, pCols;
1335 const uint8_t *parent;
1336 if (ds.lodLevels.empty()) {
1337 parent = ds.zQuantized.data();
1338 pRows = ds.originalRows;
1339 pCols = ds.originalCols;
1340 } else {
1341 const auto &dims = ds.lodDims.back();
1342 pRows = dims.first;
1343 pCols = dims.second;
1344 parent = ds.lodLevels.back().data();
1345 }
1346 const size_t cRows = (pRows + 1) / 2;
1347 const size_t cCols = (pCols + 1) / 2;
1348 if (cRows < 32 || cCols < 32) break; // stop refining
1349
1350 std::vector<uint8_t> child(cRows * cCols);
1351 for (size_t cc = 0; cc < cCols; ++cc) {
1352 const size_t pc0 = 2 * cc;
1353 const size_t pc1 = std::min(pCols, pc0 + 2);
1354 for (size_t rr = 0; rr < cRows; ++rr) {
1355 const size_t pr0 = 2 * rr;
1356 const size_t pr1 = std::min(pRows, pr0 + 2);
1357 int sum = 0, n = 0;
1358 for (size_t pc = pc0; pc < pc1; ++pc) {
1359 for (size_t pr = pr0; pr < pr1; ++pr) {
1360 const uint8_t q = parent[pc * pRows + pr];
1361 if (q != 255) { sum += q; ++n; }
1362 }
1363 }
1364 child[cc * cRows + rr] = (n > 0)
1365 ? static_cast<uint8_t>((sum + n / 2) / n)
1366 : uint8_t{255};
1367 }
1368 }
1369 ds.lodLevels.push_back(std::move(child));
1370 ds.lodDims.emplace_back(cRows, cCols);
1371 }
1372 }
1373
1381 bool getFigureTile(int figId, int axIdx, int dsIdx,
1382 int r0, int c0, int h, int w, int lod,
1383 std::vector<uint8_t> &out,
1384 size_t &outRows, size_t &outCols) const
1385 {
1386 outRows = 0;
1387 outCols = 0;
1388 out.clear();
1389
1390 auto figIt = figures_.find(figId);
1391 if (figIt == figures_.end()) return false;
1392 const auto &fig = figIt->second;
1393 if (axIdx < 0 || axIdx >= static_cast<int>(fig.axes.size())) return false;
1394 const auto &axState = fig.axes[axIdx];
1395 if (dsIdx < 0 || dsIdx >= static_cast<int>(axState.datasets.size())) return false;
1396 const auto &ds = axState.datasets[dsIdx];
1397 if (ds.zQuantized.empty() || ds.type != "imagesc") return false;
1398
1399 const size_t fullRows = ds.originalRows;
1400 const size_t fullCols = ds.originalCols;
1401 if (fullRows == 0 || fullCols == 0) return false;
1402 if (ds.zQuantized.size() < fullRows * fullCols) return false;
1403
1404 if (lod < 1) lod = 1;
1405 if (r0 < 0) r0 = 0;
1406 if (c0 < 0) c0 = 0;
1407 if (h < 0 || w < 0) return false;
1408
1409 const size_t rEnd = std::min(fullRows, static_cast<size_t>(r0) + static_cast<size_t>(h));
1410 const size_t cEnd = std::min(fullCols, static_cast<size_t>(c0) + static_cast<size_t>(w));
1411 if (static_cast<size_t>(r0) >= fullRows || static_cast<size_t>(c0) >= fullCols
1412 || rEnd <= static_cast<size_t>(r0) || cEnd <= static_cast<size_t>(c0)) {
1413 return false;
1414 }
1415
1416 const size_t srcH = rEnd - static_cast<size_t>(r0);
1417 const size_t srcW = cEnd - static_cast<size_t>(c0);
1418 const size_t L = static_cast<size_t>(lod);
1419 const size_t oH = (srcH + L - 1) / L;
1420 const size_t oW = (srcW + L - 1) / L;
1421
1422 out.resize(oH * oW);
1423
1424 // Mean-pool indices over L×L blocks. Index 255 = NaN, skipped.
1425 // Row-major output (suits canvas blit). zQuantized is column-major.
1426 for (size_t orow = 0; orow < oH; ++orow) {
1427 const size_t rA = static_cast<size_t>(r0) + orow * L;
1428 const size_t rB = std::min(rEnd, rA + L);
1429 for (size_t ocol = 0; ocol < oW; ++ocol) {
1430 const size_t cA = static_cast<size_t>(c0) + ocol * L;
1431 const size_t cB = std::min(cEnd, cA + L);
1432 int sum = 0;
1433 int n = 0;
1434 for (size_t cc = cA; cc < cB; ++cc) {
1435 for (size_t rr = rA; rr < rB; ++rr) {
1436 const uint8_t q = ds.zQuantized[cc * fullRows + rr];
1437 if (q != 255) {
1438 sum += q;
1439 ++n;
1440 }
1441 }
1442 }
1443 out[orow * oW + ocol] = (n > 0)
1444 ? static_cast<uint8_t>((sum + n / 2) / n)
1445 : uint8_t{255};
1446 }
1447 }
1448
1449 outRows = oH;
1450 outCols = oW;
1451 return true;
1452 }
1453
1454private:
1455 std::map<int, FigureState> figures_;
1456 int currentFigure_ = 1;
1457 OutputFunc outputFunc_;
1458 int nextHandleId_ = 1;
1459 std::unordered_map<int, HandleRecord> handleRegistry_;
1460 size_t chartCallBase_ = 0;
1461};
1462
1463
1465} // namespace nk
void setSubplot(int m, int n, int p)
subplot(m,n,p) — set grid and switch to axes at position p
void ensureLOD(const DatasetInfo &ds, int targetLevel) const
Build the LOD pyramid up to targetLevel if it isn't already cached.
bool getFigureTile(int figId, int axIdx, int dsIdx, int r0, int c0, int h, int w, int lod, std::vector< uint8_t > &out, size_t &outRows, size_t &outCols) const
Source-grid tile fetcher (kept for Stage A/B compat tests).
DecimatedSeries getSeriesDisplayTile(int figId, int axIdx, int dsIdx, double x0, double x1, int width, int algo) const
Display-grid tile resampler.
static constexpr std::size_t kSeriesPreviewThreshold
Append a dataset to the current axes, stamping its yside from AxesState::activeYside when yyaxis is o...
AxesState & currentAxes()
Convenience: current axes of current figure.
void setOutputFunc(OutputFunc f)
Set the output callback for figure/close markers.
void emitModified()
Emit JSON for all modified figures.
void closeFigureNotify(int id)
Close and emit notification marker.
Value sum(const Value &x, std::pmr::memory_resource *mr=nullptr)
Sum of array elements along first non-singleton dimension (sum(x)).
Value any(const Value &x, int dim=0, std::pmr::memory_resource *mr=nullptr)
True if any element of array is non-zero (y = any(x, dim)).
Per-axes state — one subplot panel has one AxesState.
AxesState & cur()
Get the current axes, creating if needed.