nk 1.0.0
C++ Numerical Computing Runtime
Loading...
Searching...
No Matches
decimate.hpp
1#pragma once
2//
3// decimate.hpp — line-series downsampling for fast Plot rendering.
4//
5// Engine-side twin of the frontend ide/src/components/v3/decimate.js: the
6// same M4 / LTTB algorithms so a series decimated here (Phase 2, binary
7// tiles) renders identically to one decimated in JS (Phase 1). A plot is
8// only ~W pixels wide, so a series of N≫W points is downsampled to O(W)
9// points over the visible x-range before it ever crosses to JS.
10//
11// M4 — per pixel-column keep {first, min, max, last}. Pixel-faithful:
12// smooth data stays a thin line; spikes and the true oscillation
13// extent survive. Default.
14// LTTB — Largest-Triangle-Three-Buckets: smoother for trends, ~1 point
15// per column, but can hide narrow spikes. Opt-in.
16//
17// x is assumed ascending (plot(y) → 1..N; plot(x,y) with sorted x). A
18// non-finite y is a gap; it is kept when it lands on a bucket endpoint.
19
20#include <vector>
21#include <cstddef>
22#include <cmath>
23#include <algorithm>
24
25namespace nk {
26
28 std::vector<double> x;
29 std::vector<double> y;
30};
31
32// First index i with x[i] >= target (x ascending); n if none.
33inline std::size_t decimLowerBound(const double *x, std::size_t n, double target) {
34 std::size_t lo = 0, hi = n;
35 while (lo < hi) {
36 std::size_t mid = (lo + hi) >> 1;
37 if (x[mid] < target) lo = mid + 1; else hi = mid;
38 }
39 return lo;
40}
41
42// Index range [i0, i1) covering the visible x-window, padded one sample each
43// side so the line enters/leaves the viewport. Clamped to [0, n].
44inline void decimVisibleRange(const double *x, std::size_t n, double x0, double x1,
45 std::size_t &i0, std::size_t &i1) {
46 if (n == 0) { i0 = i1 = 0; return; }
47 i0 = decimLowerBound(x, n, x0);
48 i1 = decimLowerBound(x, n, x1);
49 if (i0 > 0) i0 -= 1;
50 if (i1 < n) i1 += 1;
51 if (i1 > n) i1 = n;
52 if (i1 < i0) i1 = i0;
53}
54
55// M4: {first, min, max, last} per pixel column over the visible range.
56// At most 4*width points, in x order.
57inline DecimatedSeries decimateM4(const double *x, const double *y, std::size_t n,
58 double x0, double x1, int width) {
60 std::size_t i0, i1;
61 decimVisibleRange(x, n, x0, x1, i0, i1);
62 if (i1 <= i0) return out;
63 const int cols = width < 1 ? 1 : width;
64 const double span = (x1 - x0) != 0.0 ? (x1 - x0) : 1.0;
65 auto bucketOf = [&](double xv) -> int {
66 int b = static_cast<int>(std::floor(((xv - x0) / span) * cols));
67 if (b < 0) b = 0; else if (b >= cols) b = cols - 1;
68 return b;
69 };
70
71 out.x.reserve(static_cast<std::size_t>(cols) * 4);
72 out.y.reserve(static_cast<std::size_t>(cols) * 4);
73 int curBucket = -1;
74 std::size_t bFirst = i0, bLast = i0, bMinI = i0, bMaxI = i0;
75 double bMinY = 0.0, bMaxY = 0.0;
76 auto flush = [&]() {
77 if (curBucket < 0) return;
78 std::size_t idxs[4] = { bFirst, bMinI, bMaxI, bLast };
79 std::sort(idxs, idxs + 4);
80 long long prev = -1;
81 for (std::size_t ii : idxs) {
82 if (static_cast<long long>(ii) != prev) {
83 out.x.push_back(x[ii]);
84 out.y.push_back(y[ii]);
85 prev = static_cast<long long>(ii);
86 }
87 }
88 };
89
90 for (std::size_t i = i0; i < i1; ++i) {
91 double xv = x[i], yv = y[i];
92 int b = bucketOf(xv);
93 if (b != curBucket) {
94 flush();
95 curBucket = b;
96 bFirst = i; bLast = i; bMinI = i; bMaxI = i;
97 bMinY = std::isfinite(yv) ? yv : INFINITY;
98 bMaxY = std::isfinite(yv) ? yv : -INFINITY;
99 } else {
100 bLast = i;
101 if (std::isfinite(yv)) {
102 if (yv < bMinY) { bMinY = yv; bMinI = i; }
103 if (yv > bMaxY) { bMaxY = yv; bMaxI = i; }
104 }
105 }
106 }
107 flush();
108 return out;
109}
110
111// M2: {min, max} per pixel column (2 points vs M4's 4) — a lighter mode for
112// dense data; keeps spikes / true extent. Mirrors decimate.js decimateM2.
113inline DecimatedSeries decimateM2(const double *x, const double *y, std::size_t n,
114 double x0, double x1, int width) {
115 DecimatedSeries out;
116 std::size_t i0, i1;
117 decimVisibleRange(x, n, x0, x1, i0, i1);
118 if (i1 <= i0) return out;
119 const int cols = width < 1 ? 1 : width;
120 const double span = (x1 - x0) != 0.0 ? (x1 - x0) : 1.0;
121 auto bucketOf = [&](double xv) -> int {
122 int b = static_cast<int>(std::floor(((xv - x0) / span) * cols));
123 if (b < 0) b = 0; else if (b >= cols) b = cols - 1;
124 return b;
125 };
126 out.x.reserve(static_cast<std::size_t>(cols) * 2);
127 out.y.reserve(static_cast<std::size_t>(cols) * 2);
128 int curBucket = -1;
129 std::size_t bMinI = i0, bMaxI = i0;
130 double bMinY = 0.0, bMaxY = 0.0;
131 auto flush = [&]() {
132 if (curBucket < 0) return;
133 std::size_t a = std::min(bMinI, bMaxI), b = std::max(bMinI, bMaxI);
134 out.x.push_back(x[a]); out.y.push_back(y[a]);
135 if (b != a) { out.x.push_back(x[b]); out.y.push_back(y[b]); }
136 };
137 for (std::size_t i = i0; i < i1; ++i) {
138 double xv = x[i], yv = y[i];
139 int bk = bucketOf(xv);
140 if (bk != curBucket) {
141 flush();
142 curBucket = bk; bMinI = i; bMaxI = i;
143 bMinY = std::isfinite(yv) ? yv : INFINITY;
144 bMaxY = std::isfinite(yv) ? yv : -INFINITY;
145 } else if (std::isfinite(yv)) {
146 if (yv < bMinY) { bMinY = yv; bMinI = i; }
147 if (yv > bMaxY) { bMaxY = yv; bMaxI = i; }
148 }
149 }
150 flush();
151 return out;
152}
153
154// LTTB: ~threshold points preserving visual shape (smooth trends).
155inline DecimatedSeries decimateLTTB(const double *x, const double *y, std::size_t n,
156 double x0, double x1, int threshold) {
157 DecimatedSeries out;
158 std::size_t i0, i1;
159 decimVisibleRange(x, n, x0, x1, i0, i1);
160 const std::size_t cnt = i1 - i0;
161 if (cnt == 0) return out;
162 if (threshold < 3 || static_cast<std::size_t>(threshold) >= cnt) {
163 for (std::size_t i = i0; i < i1; ++i) { out.x.push_back(x[i]); out.y.push_back(y[i]); }
164 return out;
165 }
166 out.x.reserve(static_cast<std::size_t>(threshold));
167 out.y.reserve(static_cast<std::size_t>(threshold));
168 const double bucketSize = static_cast<double>(cnt - 2) / (threshold - 2);
169 std::size_t a = i0; // first point is always kept
170 out.x.push_back(x[i0]); out.y.push_back(y[i0]);
171 for (int i = 0; i < threshold - 2; ++i) {
172 std::size_t avgStart = i0 + static_cast<std::size_t>(std::floor((i + 1) * bucketSize)) + 1;
173 std::size_t avgEnd = i0 + static_cast<std::size_t>(std::floor((i + 2) * bucketSize)) + 1;
174 if (avgEnd > i1) avgEnd = i1;
175 double avgX = 0.0, avgY = 0.0;
176 const std::size_t avgN = avgEnd > avgStart ? (avgEnd - avgStart) : 1;
177 for (std::size_t j = avgStart; j < avgEnd; ++j) { avgX += x[j]; avgY += y[j]; }
178 avgX /= avgN; avgY /= avgN;
179 const std::size_t rangeStart = i0 + static_cast<std::size_t>(std::floor(i * bucketSize)) + 1;
180 const std::size_t rangeEnd = i0 + static_cast<std::size_t>(std::floor((i + 1) * bucketSize)) + 1;
181 const double ax = x[a], ay = y[a];
182 double maxArea = -1.0;
183 std::size_t maxIdx = rangeStart;
184 for (std::size_t j = rangeStart; j < rangeEnd && j < i1; ++j) {
185 double area = std::fabs((ax - avgX) * (y[j] - ay) - (ax - x[j]) * (avgY - ay));
186 if (area > maxArea) { maxArea = area; maxIdx = j; }
187 }
188 out.x.push_back(x[maxIdx]); out.y.push_back(y[maxIdx]);
189 a = maxIdx;
190 }
191 out.x.push_back(x[i1 - 1]); out.y.push_back(y[i1 - 1]);
192 return out;
193}
194
195// Dispatcher. algo: 0 = M4 (default), 1 = LTTB, 2 = none (raw visible
196// slice). Also returns raw when the visible count is already <= 2*width.
197enum class DecimAlgo { M4 = 0, LTTB = 1, None = 2, M2 = 3 };
198
199inline DecimatedSeries decimateSeries(const double *x, const double *y, std::size_t n,
200 double x0, double x1, int width, DecimAlgo algo) {
201 const int w = width < 1 ? 1 : width;
202 std::size_t i0, i1;
203 decimVisibleRange(x, n, x0, x1, i0, i1);
204 const std::size_t cnt = i1 - i0;
205 if (algo == DecimAlgo::None || cnt <= static_cast<std::size_t>(w) * 2) {
206 DecimatedSeries out;
207 for (std::size_t i = i0; i < i1; ++i) { out.x.push_back(x[i]); out.y.push_back(y[i]); }
208 return out;
209 }
210 if (algo == DecimAlgo::LTTB) return decimateLTTB(x, y, n, x0, x1, w);
211 if (algo == DecimAlgo::M2) return decimateM2(x, y, n, x0, x1, w);
212 return decimateM4(x, y, n, x0, x1, w);
213}
214
215// ── LOD pyramid (engine twin of decimate.js buildPyramid/decimateLOD) ───
216//
217// Returns the COARSER levels only (raw stays the implicit finest level, so
218// we never copy the full series). level[0] ≈ raw/8, each next ≤ half, all
219// extrema-preserving (M4). Stops at ~baseTarget points. O(N) total.
220inline std::vector<DecimatedSeries> buildPyramid(const double *x, const double *y,
221 std::size_t n, std::size_t baseTarget = 8000) {
222 std::vector<DecimatedSeries> levels;
223 const double *cx = x;
224 const double *cy = y;
225 std::size_t s = n;
226 while (s > baseTarget) {
227 const int cols = static_cast<int>(std::max<std::size_t>(1, (s + 7) / 8));
228 DecimatedSeries d = decimateM4(cx, cy, s, cx[0], cx[s - 1], cols);
229 if (d.x.size() >= s) break; // no progress
230 levels.push_back(std::move(d));
231 cx = levels.back().x.data();
232 cy = levels.back().y.data();
233 s = levels.back().x.size();
234 }
235 return levels;
236}
237
238// Decimate from the coarsest of {raw, pyramid...} that still has ≥ 2*width
239// points in [x0,x1] (finer as you zoom in) → O(width) per call at any zoom.
240inline DecimatedSeries decimateLOD(const double *rawX, const double *rawY, std::size_t rawN,
241 const std::vector<DecimatedSeries> &pyramid,
242 double x0, double x1, int width, DecimAlgo algo) {
243 const int w = width < 1 ? 1 : width;
244 const double *bx = rawX;
245 const double *by = rawY;
246 std::size_t bn = rawN;
247 for (int i = static_cast<int>(pyramid.size()) - 1; i >= -1; --i) {
248 const double *lx; const double *ly; std::size_t ln;
249 if (i >= 0) { lx = pyramid[i].x.data(); ly = pyramid[i].y.data(); ln = pyramid[i].x.size(); }
250 else { lx = rawX; ly = rawY; ln = rawN; }
251 std::size_t a, b;
252 decimVisibleRange(lx, ln, x0, x1, a, b);
253 if (b - a >= static_cast<std::size_t>(w) * 2 || i == -1) { bx = lx; by = ly; bn = ln; break; }
254 }
255 return decimateSeries(bx, by, bn, x0, x1, w, algo);
256}
257
258} // namespace nk