ccharts 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3088 @@
1
+ /* Generated by scripts/sync_sources.py from ccharts.h — do not edit.
2
+ * Change the original and re-run the script. */
3
+ /*
4
+ * ccharts.h — single-header OHLC charts, rendered to a string.
5
+ *
6
+ * Turns financial OHLC data into text: line and candlestick charts drawn with
7
+ * Unicode block characters. Nothing is printed and no stream is written to —
8
+ * the chart functions return a malloc'd string, so the result can go to a
9
+ * terminal, a log, an HTML block or a file just as easily.
10
+ *
11
+ * Color is optional. A color left NULL takes the library default (green
12
+ * rising, red falling); a color set to the EMPTY STRING makes that element
13
+ * carry no escape sequence at all, so setting all four yields plain text.
14
+ *
15
+ * ---------------------------------------------------------------------------
16
+ * USAGE
17
+ * ---------------------------------------------------------------------------
18
+ * Define CCHARTS_IMPLEMENTATION in exactly ONE translation unit before the
19
+ * include; every other TU includes the header as-is (declarations only):
20
+ *
21
+ * #define CCHARTS_IMPLEMENTATION
22
+ * #include "ccharts.h"
23
+ *
24
+ * Minimal example:
25
+ *
26
+ * const char* json =
27
+ * "[{\"open\":1,\"high\":2,\"low\":0.5,\"close\":1.5}]";
28
+ * cc_ohlc_t* ohlc = NULL;
29
+ * int size = 0;
30
+ * cc_json_to_ohlc(json, &ohlc, &size);
31
+ *
32
+ * cc_settings_t s = { .rise_color = CC_COLOR_BLUE };
33
+ * char* chart = cc_line_create(ohlc, size, 60, 8, &s);
34
+ * printf("%s\n", chart);
35
+ * free(chart);
36
+ * free(ohlc);
37
+ *
38
+ * ---------------------------------------------------------------------------
39
+ * DATA FLOW
40
+ * ---------------------------------------------------------------------------
41
+ * 1. Parse raw data into a heap-allocated cc_ohlc_t array:
42
+ * - cc_str_to_ohlc() CSV text (open,high,low,close[,timestamp])
43
+ * - cc_json_to_ohlc() fixed-schema JSON (see its doc comment)
44
+ * 2. Optionally override rendering via a cc_settings_t. Unspecified (NULL /
45
+ * 0) fields fall back to defaults; pass NULL to use all defaults.
46
+ * 3. Call cc_line_create() / cc_candle_create() to get a malloc'd string.
47
+ * 4. Print the string, then free it AND the cc_ohlc_t array.
48
+ *
49
+ * ---------------------------------------------------------------------------
50
+ * RENDERING MODEL
51
+ * ---------------------------------------------------------------------------
52
+ * Line charts : each cell is 8 pixels tall via the 1/8 blocks ▁▂▃▄▅▆▇█,
53
+ * so curves are smooth. Width keeps the same semantics.
54
+ * Candle charts : cells are 2 pixels tall (▀/▄/█). A candle draws a solid
55
+ * body between open/close and a thin vertical wick (│)
56
+ * between high/low; when there is room (width >= size) a
57
+ * gap column separates neighboring candles.
58
+ * Both charts : optionally prepend a left price margin (min/max) and
59
+ * append a time footer (first/last timestamp). The time
60
+ * format is chosen automatically from the average interval.
61
+ *
62
+ * MEMORY OWNERSHIP
63
+ * cc_str_to_ohlc / cc_json_to_ohlc allocate the cc_ohlc_t array (free it).
64
+ * cc_line_create / cc_candle_create return a malloc'd string (free it).
65
+ *
66
+ * INPUT CONSTRAINTS
67
+ * - size (number of candles) must be positive for the renderers and the
68
+ * CSV parser; the JSON parser requires at least one object.
69
+ * - width/height are clamped to CC_MAX_DIM cells per side and
70
+ * CC_MAX_CELLS total; invalid dimensions make the chart functions fail
71
+ * cleanly (empty string) instead of attempting a giant allocation.
72
+ * - CSV lines may be arbitrarily long (each line is heap-copied), and at
73
+ * most `size` lines are read.
74
+ * - JSON must match the fixed schema (see cc_json_to_ohlc); the scanner is
75
+ * an iterative mini-parser, so stray substrings in string values cannot
76
+ * cause false key matches.
77
+ * - ISO8601 timestamps may carry a UTC offset: "+HH:MM", "+HHMM", "+HH"
78
+ * or "Z" (a missing offset is treated as UTC). The offset is applied, so
79
+ * the stored epoch is the true instant, not the local wall time.
80
+ *
81
+ * PORTABILITY
82
+ * Plain C89-compatible code (no VLAs, no C99-only features), so it builds
83
+ * with GCC, Clang and MSVC out of the box; on Windows gmtime_s replaces
84
+ * gmtime_r and functions use internal (static) linkage via CC_INLINE.
85
+ */
86
+
87
+ #ifndef CCHARTS_H
88
+ #define CCHARTS_H
89
+
90
+ /* ============================ ANSI color codes ============================ */
91
+
92
+ #define CC_COLOR_RESET "\x1b[0m"
93
+ #define CC_COLOR_BLACK "\x1b[30m"
94
+ #define CC_COLOR_RED "\x1b[31m"
95
+ #define CC_COLOR_GREEN "\x1b[32m"
96
+ #define CC_COLOR_YELLOW "\x1b[33m"
97
+ #define CC_COLOR_BLUE "\x1b[34m"
98
+ #define CC_COLOR_MAGENTA "\x1b[35m"
99
+ #define CC_COLOR_CYAN "\x1b[36m"
100
+ #define CC_COLOR_WHITE "\x1b[37m"
101
+ #define CC_COLOR_BRIGHT_BLACK "\x1b[90m"
102
+ #define CC_COLOR_BRIGHT_RED "\x1b[91m"
103
+ #define CC_COLOR_BRIGHT_GREEN "\x1b[92m"
104
+ #define CC_COLOR_BRIGHT_YELLOW "\x1b[93m"
105
+ #define CC_COLOR_BRIGHT_BLUE "\x1b[94m"
106
+ #define CC_COLOR_BRIGHT_MAGENTA "\x1b[95m"
107
+ #define CC_COLOR_BRIGHT_CYAN "\x1b[96m"
108
+ #define CC_COLOR_BRIGHT_WHITE "\x1b[97m"
109
+
110
+ /* ============================ Block element characters ============================
111
+ * The 1/8..7/8 left-growing bars (▏▎▍▌▋▊▉) and the 1/8 lower bars (▁▂▃▅▆▇)
112
+ * give sub-cell horizontal/vertical resolution. ▀/▄/█ and the thin vertical
113
+ * line │ (used for candle wicks) round out the drawing primitives.
114
+ * ============================================================================ */
115
+
116
+ #define CC_BLOCK_FULL "\u2588"
117
+ #define CC_BLOCK_7_8 "\u2589"
118
+ #define CC_BLOCK_3_4 "\u258A"
119
+ #define CC_BLOCK_5_8 "\u258B"
120
+ #define CC_BLOCK_1_2 "\u258C"
121
+ #define CC_BLOCK_3_8 "\u258D"
122
+ #define CC_BLOCK_1_4 "\u258E"
123
+ #define CC_BLOCK_1_8 "\u258F"
124
+ #define CC_BLOCK_UPPER_HALF "\u2580"
125
+ #define CC_BLOCK_LOWER_HALF "\u2584"
126
+ #define CC_BLOCK_LOWER_1_8 "\u2581"
127
+ #define CC_BLOCK_LOWER_2_8 "\u2582"
128
+ #define CC_BLOCK_LOWER_3_8 "\u2583"
129
+ #define CC_BLOCK_LOWER_5_8 "\u2585"
130
+ #define CC_BLOCK_LOWER_6_8 "\u2586"
131
+ #define CC_BLOCK_LOWER_7_8 "\u2587"
132
+ #define CC_LINE_VERTICAL "\u2502"
133
+
134
+ #include <stdio.h>
135
+ #include <stdlib.h>
136
+ #include <string.h>
137
+ #include <math.h>
138
+ #include <time.h>
139
+
140
+ /* ============================ Portability macros ============================
141
+ * CC_INLINE: MSVC's C mode has no C11 inline-linkage semantics, so on
142
+ * Windows all functions get internal linkage (static). Everywhere else they
143
+ * are static inline, which keeps each translation unit self-contained and
144
+ * avoids any duplicate-symbol or emission problems on any compiler.
145
+ * CC_GMTIME_R: POSIX gmtime_r vs MSVC gmtime_s (note the argument order). */
146
+ #if defined(_MSC_VER)
147
+ #define CC_INLINE static
148
+ #else
149
+ #define CC_INLINE static inline
150
+ #endif
151
+
152
+ #ifdef _WIN32
153
+ #define CC_GMTIME_R(t, tm) (gmtime_s((tm), (t)) == 0)
154
+ #else
155
+ #define CC_GMTIME_R(t, tm) (gmtime_r((t), (tm)) != NULL)
156
+ #endif
157
+
158
+ #if defined(NAN)
159
+ #define CC_NAN ((double)NAN)
160
+ #elif defined(_MSC_VER)
161
+ #define CC_NAN ((double)sqrt(-1.0))
162
+ #else
163
+ #define CC_NAN (0.0 / 0.0)
164
+ #endif
165
+
166
+ /* Chart dimensions are bounded to keep allocations sane and to avoid
167
+ * integer-overflow surprises. A terminal chart needs nowhere near these
168
+ * sizes; dimension checks fail cleanly (empty string / NULL) instead of
169
+ * attempting a giant allocation. */
170
+ #define CC_MAX_DIM 100000 /* max width or height in cells */
171
+ #define CC_MAX_CELLS 1000000 /* max width * height (total cells) */
172
+
173
+ #ifdef __cplusplus
174
+ extern "C" {
175
+ #endif
176
+
177
+ /* ============================ Public API ============================ */
178
+
179
+ typedef struct cc_ohlc cc_ohlc_t;
180
+
181
+ /* Rendering options. Unspecified fields fall back to defaults:
182
+ * - rise_color : color for rising values / candles (default green)
183
+ * - fall_color : color for falling values / candles (default red)
184
+ * - bg_color : background of empty cells (default: none)
185
+ * - area_color : line chart fill below the line (default: none)
186
+ * - single_color: 1 = whole line in one color chosen from overall change,
187
+ * 0 = color each segment by its own direction (default)
188
+ * - show_prices : print max/min price labels in a left margin
189
+ * - show_times : print first/last timestamp footer under the chart
190
+ * Build with a designated initializer, e.g.
191
+ * cc_settings_t s = { .rise_color = CC_COLOR_BLUE };
192
+ * and pass NULL to any chart function for full defaults. */
193
+ typedef struct cc_settings {
194
+ const char* rise_color;
195
+ const char* fall_color;
196
+ const char* bg_color;
197
+ const char* area_color;
198
+ int single_color;
199
+ int show_prices;
200
+ int show_times;
201
+ } cc_settings_t;
202
+
203
+ /* Fills missing fields of `settings` with defaults and returns the result.
204
+ * Never NULL — passing NULL yields a struct with all defaults. Used
205
+ * internally by every chart function; safe to call directly. */
206
+ CC_INLINE cc_settings_t cc_settings_resolve(const cc_settings_t* settings);
207
+
208
+ /* Parses CSV text into a heap-allocated cc_ohlc_t array.
209
+ *
210
+ * Format: one candle per line, fields separated by `val_seperator`:
211
+ * open,high,low,close (timestamp = 0)
212
+ * open,high,low,close,timestamp (ISO8601 or epoch seconds)
213
+ *
214
+ * `size` is the expected number of lines; at most `size` lines are read
215
+ * (extra lines are ignored, not overflowed). Lines may be arbitrarily long.
216
+ * On success returns 0 and stores a calloc'd array in *ohlc (caller frees
217
+ * it). Returns non-zero on invalid arguments or allocation failure. */
218
+ CC_INLINE int cc_str_to_ohlc(const char* data, int size, cc_ohlc_t** ohlc,
219
+ char val_seperator, char line_seperator);
220
+
221
+ /* Parses a fixed-schema JSON document into a heap-allocated cc_ohlc_t array.
222
+ *
223
+ * Expected schema — an array of objects with string timestamps:
224
+ * [{"ts":"2026-07-20T00:00:00+00:00","open":328.75,"high":330.0,
225
+ * "low":323.75,"close":328.0,"volume":46622936}, ...]
226
+ * The "volume" field is ignored; unknown keys are skipped by value. Keys are
227
+ * matched exactly, so a substring inside a string value can never be
228
+ * mistaken for a key. On success returns 0 and stores the number of candles
229
+ * in *size and a calloc'd array in *ohlc (caller frees both). Returns
230
+ * non-zero on malformed input or allocation failure. */
231
+ CC_INLINE int cc_json_to_ohlc(const char* json, cc_ohlc_t** ohlc, int* size);
232
+
233
+ /* Renders a smooth line chart of the close prices.
234
+ * `width`/`height` are the chart plot area in cells, bounded by
235
+ * CC_MAX_DIM / CC_MAX_CELLS. Returns a malloc'd string (caller frees) that
236
+ * may additionally contain a left price margin and a time footer depending
237
+ * on the settings flags; invalid dimensions yield an empty string. */
238
+ CC_INLINE char* cc_line_create(const cc_ohlc_t* data, int size, int width, int height,
239
+ const cc_settings_t* settings);
240
+
241
+ /* Renders a candlestick chart from the OHLC data.
242
+ * `width`/`height` are the chart plot area in cells, bounded by
243
+ * CC_MAX_DIM / CC_MAX_CELLS. When width >= size each candle is a few cells
244
+ * wide with a gap between neighbors; when width < size neighboring candles
245
+ * are aggregated into virtual candles (like the line chart's downsampling).
246
+ * Returns a malloc'd string (caller frees); invalid dimensions yield an
247
+ * empty string. */
248
+ CC_INLINE char* cc_candle_create(const cc_ohlc_t* data, int size, int width, int height,
249
+ const cc_settings_t* settings);
250
+
251
+ /* ============================ Pie charts ============================
252
+ * A pie / donut chart turns (label, value) slices into a disk drawn with
253
+ * block characters. Unlike line/candle there is no OHLC data: each slice is
254
+ * an amount, the pie computes the percentages, and slices are drawn from
255
+ * 12 o'clock going counter-clockwise. Colors come from a fixed deterministic
256
+ * palette (never random, so every render is byte-identical — which is what
257
+ * keeps the conformance goldens stable) unless the settings override it. */
258
+
259
+ /* One pie slice: a label (may be NULL or empty) and a positive value. A slice
260
+ * whose value is not > 0 (zero, negative, or NaN) makes the whole render fail
261
+ * cleanly with an empty string; +inf is rejected upstream (the wrapper and
262
+ * ABI), which keeps this header free of C99-only `isfinite`. */
263
+ typedef struct cc_pie_slice {
264
+ const char* label;
265
+ double value;
266
+ } cc_pie_slice_t;
267
+
268
+ /* Pie/donut rendering options. Unspecified fields fall back to defaults:
269
+ * - bg_color : background of cells outside the disk (default: none)
270
+ * - colors : per-slice palette override — a NULL-terminated array of
271
+ * ANSI color strings — or NULL for the fixed default palette
272
+ * - donut : 1 = hollow center (donut), 0 = filled disk (default).
273
+ * Only consulted when inner_radius_ratio is left unspecified.
274
+ * - show_legend: 1 = print one legend line per slice below the disk
275
+ * - show_pct : 1 = append "(NN%)" to the default legend entries; ignored
276
+ * by the legend_format variants that always show the percent
277
+ * - slice_gap : angular gap between slices, in radians. 0 = slices touch
278
+ * (default); >0 leaves a thin blank gap between neighbors
279
+ * - inner_radius_ratio : donut thickness as a fraction of the outer radius,
280
+ * in [0,1]. 0 = filled disk; (0,1] = hollow center of that
281
+ * radius. NEGATIVE (e.g. -1) = unspecified: the `donut` flag
282
+ * then decides — 0.5 for a donut, 0 for a disk. Values > 1
283
+ * are clamped to 1.
284
+ * - legend_format : CC_PIE_LEGEND_* enum controlling each legend entry's
285
+ * composition (see below); 0 reproduces the original output
286
+ * - start_angle : angle (radians) where slice 0 begins. NEGATIVE =
287
+ * unspecified (default CC_PI/2, i.e. 12 o'clock)
288
+ * - counter_clockwise : 0 = library default direction (counter-clockwise,
289
+ * matching the original output); nonzero = mirrored
290
+ * (clockwise) sweep
291
+ * - center_text : text drawn in the hollow center. Only shown when there is
292
+ * a real hollow (inner_radius_ratio > 0); NULL or "" disables
293
+ * it. Truncated to fit the hole.
294
+ * Build with a designated initializer and pass NULL for full defaults. To
295
+ * keep the library defaults for a partial brace initializer, leave the double
296
+ * fields negative (their zero values are meaningful: inner_radius_ratio=0 is
297
+ * a filled disk, start_angle=0 is 3 o'clock) and set inner_radius_ratio =
298
+ * -1.0 when you want the `donut` flag's classic hollow. */
299
+ typedef struct cc_pie_settings {
300
+ const char* bg_color;
301
+ const char* const* colors;
302
+ int donut;
303
+ int show_legend;
304
+ int show_pct;
305
+ /* --- Fas 3 pie/donut settings (all optional) --- */
306
+ double slice_gap; /* radians; 0 = adjacent slices */
307
+ double inner_radius_ratio; /* [0,1]; <0 = donut flag decides */
308
+ int legend_format; /* CC_PIE_LEGEND_* */
309
+ double start_angle; /* radians; <0 = CC_PI/2 */
310
+ int counter_clockwise; /* 0 = default CCW sweep; nonzero = CW */
311
+ const char* center_text; /* NULL/"" = none; hollow-center label */
312
+ } cc_pie_settings_t;
313
+
314
+ /* legend_format values. All other values fall back to CC_PIE_LEGEND_VALUE
315
+ * (the original "label value (+ (NN%) when show_pct)" behavior). */
316
+ #define CC_PIE_LEGEND_VALUE 0 /* "label value" (+ " (NN%)" when show_pct) */
317
+ #define CC_PIE_LEGEND_LABEL_PCT 1 /* "label NN%" */
318
+ #define CC_PIE_LEGEND_VALUE_PCT 2 /* "value (NN%)" */
319
+ #define CC_PIE_LEGEND_LABEL 3 /* "label" only */
320
+
321
+ /* Renders a pie/donut chart of `count` slices into a `width` x `height` grid
322
+ * (cells) and returns a malloc'd string (caller frees). Slices are normalized
323
+ * to angles; each grid cell is colored by the slice whose angular range
324
+ * contains the cell's center, inside an outer radius (a donut leaves the
325
+ * cells inside the inner radius blank). A legend is appended below the disk
326
+ * when settings->show_legend is set. Returns an empty string for NULL slices,
327
+ * count <= 0, invalid dimensions, or any non-positive/NaN slice value. */
328
+ CC_INLINE char* cc_pie_create(const cc_pie_slice_t* slices, int count,
329
+ int width, int height,
330
+ const cc_pie_settings_t* settings);
331
+
332
+ /* ============================ Histogram ============================
333
+ * A histogram is a frequency distribution of a scalar sample sequence.
334
+ * Unlike line/candle there is no OHLC data: the samples are grouped into
335
+ * equal-width bins across a value window and each bin is drawn as a
336
+ * vertical bar sampled at 8 sub-pixels per cell (cc_lower_eighth bar
337
+ * tops, the same smoothness as the line chart). */
338
+
339
+ /* Histogram rendering options. Unspecified fields fall back to defaults:
340
+ * - rise_color : bar fill color (default green)
341
+ * - bg_color : background of empty cells below a bar (default: none)
342
+ * - bin_count : number of equal-width bins; <= 0 auto-selects (20 bins
343
+ * for >= 40 samples, else 10, trimmed to the width)
344
+ * - min_value : window minimum; NaN = the data minimum
345
+ * - max_value : window maximum; NaN = the data maximum
346
+ * - show_bins : append a value-axis footer row under the chart (window
347
+ * min on the left, window max on the right)
348
+ * - show_prices: print the max-count / min-count labels in an 8-column
349
+ * left margin (a vertical count axis)
350
+ * Build with a designated initializer and pass NULL to any chart function
351
+ * for full defaults. min_value/max_value use NaN as their "auto" sentinel,
352
+ * but a partial {0} initializer (min==max==0.0) is ALSO auto, because a
353
+ * valid explicit window always has min < max; any range where that does
354
+ * not hold (NaN, equal, inverted) falls back to the data min/max so the
355
+ * histogram auto-scales instead of collapsing to a single bar. */
356
+ typedef struct cc_hist_settings {
357
+ const char* rise_color;
358
+ const char* bg_color;
359
+ int bin_count;
360
+ double min_value;
361
+ double max_value;
362
+ int show_bins;
363
+ int show_prices;
364
+ } cc_hist_settings_t;
365
+
366
+ /* Renders a histogram of `count` samples into a `width` x `height` grid
367
+ * (cells) and returns a malloc'd string (caller frees). Samples are counted
368
+ * into equal-width bins across [min_value, max_value] — NaN endpoints
369
+ * auto-select from the data range. Outliers are clamped into the edge bins
370
+ * so a single huge value cannot empty every other bin, and a flat window
371
+ * (min == max) is widened by +-1 so the mapping never divides by zero. Each
372
+ * bar is scaled to the tallest bin and drawn with 8 sub-pixel rows
373
+ * (cc_lower_eighth tops). Returns an empty string for NULL samples,
374
+ * count <= 0, or invalid dimensions. */
375
+ CC_INLINE char* cc_hist_create(const double* samples, int count,
376
+ int width, int height,
377
+ const cc_hist_settings_t* settings);
378
+
379
+ /* ============================ Sparkline ============================
380
+ * A sparkline is an ultra-compact, axis-less trend line drawn from a series
381
+ * of close-like values. Unlike line/candle there is no OHLC data, no price
382
+ * margin and no time footer — just a single (or few-row) line reusing the
383
+ * line chart's 8-sub-pixel-per-cell smoothness (cc_lower_eighth). height is
384
+ * expected small (default 1, up to ~3). */
385
+
386
+ /* Sparkline rendering options. Unspecified fields fall back to defaults:
387
+ * - rise_color : the single line / trend color (default green)
388
+ * - area_color : faint fill under the line, or NULL for none (default)
389
+ * - min_above : reserved sub-pixels at the TOP edge so the line does not
390
+ * touch the top (default 0)
391
+ * - min_below : reserved sub-pixels at the BOTTOM edge (default 0)
392
+ * Build with a designated initializer and pass NULL to any chart function
393
+ * for full defaults. min_above/min_below are plain ints, so a partial {0}
394
+ * brace initializer gets the same defaults as NULL (0 sub-pixels reserved)
395
+ * with no sentinel ambiguity; rise_color/area_color fall back per field. */
396
+ typedef struct cc_spark_settings {
397
+ const char* rise_color;
398
+ const char* area_color;
399
+ int min_above;
400
+ int min_below;
401
+ } cc_spark_settings_t;
402
+
403
+ /* Renders a sparkline of `count` samples into a `width` x `height` grid
404
+ * (cells). Each column averages the samples in its span, exactly like the
405
+ * line chart's downsampling, then maps the average to an 8-sub-pixel row;
406
+ * heights are drawn with cc_lower_eighth tops and rows are assembled
407
+ * top-to-bottom with no margin/footer/legend. An optional area_color fills
408
+ * cells below the line. A flat (all-equal) series is centered exactly like
409
+ * the line chart (no divide-by-zero). Returns a malloc'd string (caller
410
+ * frees); invalid dimensions or NULL samples/count <= 0 yield an empty
411
+ * string. Scratch buffers are heap-allocated and freed on every path. */
412
+ CC_INLINE char* cc_spark_create(const double* samples, int count,
413
+ int width, int height,
414
+ const cc_spark_settings_t* settings);
415
+
416
+ /* ============================= Bar chart =============================
417
+ * A categorical bar chart draws an ordered list of (label, value) pairs as
418
+ * vertical bars growing up from a shared zero baseline. Unlike the pie
419
+ * (which normalizes slices to proportions) and the histogram (which bins raw
420
+ * samples), each bar is drawn at its OWN height in proportion to the largest
421
+ * value. Labels are categorical and are printed, when requested, in a footer
422
+ * row below the plot (one label per output column, truncated to the column
423
+ * width). */
424
+
425
+ /* One bar: a categorical label (may be NULL or empty) and a non-negative
426
+ * height. Values are clamped to zero for the render (a negative value draws
427
+ * that bar at zero height rather than below the axis; a future stage could
428
+ * add a diverging below-baseline variant). Non-finite values are rejected
429
+ * upstream (the wrapper and ABI), which keeps this header free of C99-only
430
+ * `isfinite`. */
431
+ typedef struct cc_bar_item {
432
+ const char* label;
433
+ double value;
434
+ } cc_bar_item_t;
435
+
436
+ /* Bar chart rendering options. Unspecified fields fall back to defaults:
437
+ * - rise_color : bar fill color (default green)
438
+ * - bg_color : background of empty cells above a bar (default: none)
439
+ * - show_labels: 1 = append a footer row under the plot with each column's
440
+ * label, truncated to the column width
441
+ * - show_prices: 1 = prepend an 8-column left value axis with the max bar
442
+ * value at the top and 0 (the baseline) at the bottom
443
+ * Build with a designated initializer and pass NULL to any chart function
444
+ * for full defaults. All fields here are pointers or plain ints, so a
445
+ * partial {0} brace initializer gets exactly the same defaults as NULL (no
446
+ * double sentinels, so there is no brace-init ambiguity — unlike the
447
+ * histogram's NaN window fields). */
448
+ typedef struct cc_bar_settings {
449
+ const char* rise_color;
450
+ const char* bg_color;
451
+ int show_labels;
452
+ int show_prices;
453
+ } cc_bar_settings_t;
454
+
455
+ /* Renders a bar chart of `count` (label, value) pairs into a `width` x
456
+ * `height` grid (cells) and returns a malloc'd string (caller frees). Bars
457
+ * grow up from a zero baseline, scaled so the largest value fills the full
458
+ * height, and are drawn with 8 sub-pixel rows (cc_lower_eighth tops) like the
459
+ * line chart. When width >= count each item maps to one or more columns;
460
+ * when width < count the items are folded deterministically into the columns
461
+ * (long-arithmetic index mapping, mirroring the histogram) and each column is
462
+ * drawn at the MAX of the values in its span. An optional label footer
463
+ * (show_labels) and 8-column value axis (show_prices) are assembled around
464
+ * the grid. Returns a malloc'd string (caller frees); NULL items, count <= 0,
465
+ * or invalid dimensions yield an empty string. Scratch buffers are
466
+ * heap-allocated (no VLAs) and freed on every path. */
467
+ CC_INLINE char* cc_bar_create(const cc_bar_item_t* items, int count,
468
+ int width, int height,
469
+ const cc_bar_settings_t* settings);
470
+
471
+ /* ============================ Stacked bar chart ===========================
472
+ * A stacked bar chart divides each category's total bar into vertical
473
+ * segments, one per series: every category has the same set of series, and
474
+ * its bar height is the SUM of the series' values for that category (a
475
+ * part-of-whole view), unlike the plain bar where each item is its own full
476
+ * height. All series share the same category count. Draws with 8 sub-pixel
477
+ * rows (cc_lower_eighth tops) exactly like the bar chart; each cell is
478
+ * colored by the series that sits on the stack's surface at that cell. */
479
+
480
+ /* One stacked-bar series: a name (may be NULL/empty — used only for
481
+ * documentation; the per-series color comes from the palette) and a `values`
482
+ * array with one entry per category. All series must share the same category
483
+ * count (supplied by settings->cats). Values are clamped to zero for the
484
+ * render (negative entries draw at zero height rather than below the axis);
485
+ * non-finite values are rejected upstream (the wrapper and ABI), which keeps
486
+ * this header free of C99-only `isfinite`. */
487
+ typedef struct cc_stack_series {
488
+ const char* name;
489
+ const double* values;
490
+ } cc_stack_series_t;
491
+
492
+ /* Stacked bar rendering options. Unspecified fields fall back to defaults:
493
+ * - colors : a NULL-terminated array of ANSI color strings used as the
494
+ * per-series palette, or NULL for the fixed default palette
495
+ * - bg_color : background of empty cells above the tallest stack (default:
496
+ * none)
497
+ * - cat_labels: optional array of `cats` category labels printed, when
498
+ * show_labels is set, one per output column in the footer
499
+ * (NULL = no category names)
500
+ * - series : number of series (mirrors the `series_count` argument)
501
+ * - cats : number of categories — the length of every series' values
502
+ * array; REQUIRED (a NULL settings or cats <= 0 yields an
503
+ * empty chart, since the values lengths cannot be derived
504
+ * from the pointers alone)
505
+ * - show_labels: 1 = append a footer row under the plot with each column's
506
+ * category label (from cat_labels), truncated to the column
507
+ * width
508
+ * - show_prices: 1 = prepend an 8-column left value axis with the tallest
509
+ * stack total at the top and 0 (the baseline) at the bottom
510
+ * All fields are pointers or plain ints, so a partial {0} brace initializer
511
+ * gets exactly the same defaults as NULL (no double sentinels, so there is
512
+ * no brace-init ambiguity — unlike the histogram's NaN window fields). */
513
+ typedef struct cc_stack_settings {
514
+ const char* const* colors;
515
+ const char* bg_color;
516
+ const char* const* cat_labels;
517
+ int series;
518
+ int cats;
519
+ int show_labels;
520
+ int show_prices;
521
+ } cc_stack_settings_t;
522
+
523
+ /* Renders a stacked bar chart of `series_count` series, each with `cats`
524
+ * values (settings->cats), into a `width` x `height` grid (cells) and returns
525
+ * a malloc'd string (caller frees). Within every category the series' values
526
+ * are summed into that category's total; each bar (category) is a vertical
527
+ * stack of segments drawn bottom-to-top, one per series, proportional to each
528
+ * series' value / the tallest stack total, and colored by the series via a
529
+ * deterministic palette (or the settings->colors override). When width >=
530
+ * cats each category maps to one or more columns; when width < cats the
531
+ * categories are folded into the columns deterministically (long-arithmetic
532
+ * index mapping, mirroring the bar chart) and each column aggregates its
533
+ * categories by SUM (stacked bars are about totals). A footer of drop and
534
+ * value-axis margin are assembled exactly like the bar chart. Returns a
535
+ * malloc'd string (caller frees); NULL series, series_count <= 0, settings
536
+ * NULL or cats <= 0, or invalid dimensions yield an empty string. Scratch
537
+ * buffers are heap-allocated (no VLAs) and freed on every path. */
538
+ CC_INLINE char* cc_stack_create(const cc_stack_series_t* series, int series_count,
539
+ int width, int height,
540
+ const cc_stack_settings_t* settings);
541
+
542
+ /* ============================= Heatmap chart =============================
543
+ * A heatmap draws a 2-D matrix of scalar values as a grid of colored cells:
544
+ * every value maps to one of a fixed, deterministic ladder of ANSI colors by
545
+ * its position between the matrix minimum and maximum. There is no OHLC
546
+ * data — the whole chart is a grid of values. The height / width arguments
547
+ * are the grid size in cells, and the matrix (rows x cols) is mapped onto
548
+ * that grid (see the downsample / padding rules in cc_heat_create). */
549
+
550
+ /* Heatmap rendering options. Unspecified fields fall back to defaults:
551
+ * - low_color : ANSI color for the minimum value (default bright-black,
552
+ * a dim gray; ladder index 0)
553
+ * - high_color : ANSI color for the maximum value (default bright-white,
554
+ * ladder index RAMP_LEN-1)
555
+ * - mid_color : optional ANSI color that replaces the ladder's middle
556
+ * entry (ladder index 5) for a 3-stop ramp; NULL (default)
557
+ * leaves the fixed middle ramp color in place (2-stop)
558
+ * - bg_color : color of the grid cells that the matrix does not cover
559
+ * (matrix smaller than width/height), or NULL for none
560
+ * - row_labels : optional `rows` labels printed in a left margin when
561
+ * show_labels is set, or NULL (no row margin)
562
+ * - col_labels : optional `cols` labels printed in a footer row under the
563
+ * grid when show_labels is set, or NULL (no footer)
564
+ * - show_labels: 1 = print the row/col label frame around the grid
565
+ * All fields are pointers or plain ints, so a partial {0} brace initializer
566
+ * gets exactly the same defaults as NULL (no double sentinels, so there is
567
+ * no brace-init ambiguity — unlike the histogram's NaN window fields). */
568
+ typedef struct cc_heat_settings {
569
+ const char* low_color;
570
+ const char* high_color;
571
+ const char* mid_color;
572
+ const char* bg_color;
573
+ const char* const* row_labels;
574
+ const char* const* col_labels;
575
+ int show_labels;
576
+ } cc_heat_settings_t;
577
+
578
+ /* The colormap ladder. This is the chart's determinism contract: a value's
579
+ * ratio r in [0,1] ((value - data_min) / (data_max - data_min), 0.0 for an
580
+ * all-equal matrix) selects color index (int)(r * (CC_HEAT_RAMP_LEN - 1)),
581
+ * clamped into range. Index 0 is the low end and index CC_HEAT_RAMP_LEN-1
582
+ * the high end; low_color/high_color substitute those two entries and an
583
+ * optional mid_color substitutes index CC_HEAT_MID_INDEX. The ramp's ANSI
584
+ * strings are fixed, so every binding reproduces the same bytes for the same
585
+ * ratio. The full mapping, by ratio bucket (r in [0,1]):
586
+ *
587
+ * r in [0/9, 1/9) -> index 0 (low_color default bright-black)
588
+ * r in [1/9, 2/9) -> index 1 (blue)
589
+ * r in [2/9, 3/9) -> index 2 (cyan)
590
+ * r in [3/9, 4/9) -> index 3 (bright-cyan)
591
+ * r in [4/9, 5/9) -> index 4 (green)
592
+ * r in [5/9, 6/9) -> index 5 (MID_INDEX; mid_color default yellow)
593
+ * r in [6/9, 7/9) -> index 6 (bright-yellow)
594
+ * r in [7/9, 8/9) -> index 7 (red)
595
+ * r in [8/9, 9/9) -> index 8 (bright-red)
596
+ * r == 1 (exactly) -> index 9 (high_color default bright-white)
597
+ */
598
+ #define CC_HEAT_RAMP_LEN 10
599
+ #define CC_HEAT_MID_INDEX 5
600
+
601
+ /* Renders a heatmap of a `rows` x `cols` row-major `values` matrix into a
602
+ * `width` x `height` grid (cells) and returns a malloc'd string (caller
603
+ * frees). Each grid cell (gx, gy — gy counting from the TOP row) represents
604
+ * a rectangular block of the matrix. A cell is "covered" when its block is
605
+ * non-empty: if the matrix is LARGER than the grid (rows > height or cols >
606
+ * width) the matrix is deterministically downsampled by BLOCK-AVERAGE (each
607
+ * covered cell renders the mean of the matrix values in its span); if the
608
+ * matrix is SMALLER than or equal to the grid, each matrix element occupies
609
+ * its own cell in the top-left (row 0 / column 0) and the remaining cells
610
+ * are background (bg_color or blank). Covered cells are colored by their
611
+ * block-average value on the fixed ladder above; an all-equal matrix maps
612
+ * every covered cell to ladder index 0 (a single color). Returns a malloc'd
613
+ * string (caller frees); NULL values, rows <= 0, cols <= 0, or invalid
614
+ * dimensions yield an empty string. Scratch buffers are heap-allocated (no
615
+ * VLAs) and freed on every path. */
616
+ CC_INLINE char* cc_heat_create(const double* values, int rows, int cols,
617
+ int width, int height,
618
+ const cc_heat_settings_t* settings);
619
+
620
+ /* ================================ Box plot ===============================
621
+ * A box plot draws a distribution per category: for each category its
622
+ * samples' five-number summary [min, Q1, median, Q3, max] is rendered as a
623
+ * vertical box (the Q1..Q3 interquartile region) with whiskers (the
624
+ * min..max span) and the median drawn as a solid line. The statistics are
625
+ * computed deterministically by the renderer from the raw samples (see the
626
+ * nearest-rank convention in cc_box_create), so every binding reproduces
627
+ * the same bytes for the same samples. There is no OHLC data — the input is
628
+ * one array of samples per category. */
629
+
630
+ /* One category: a label (may be NULL/empty; labels are not printed by the
631
+ * core — they exist for bindings' own footers) and its `samples` array of
632
+ * `n` values. A category with n <= 0 or a NULL samples array makes the
633
+ * WHOLE chart empty, because a box has no well-defined five-number summary
634
+ * without any samples. */
635
+ typedef struct cc_box_category {
636
+ const char* name;
637
+ const double* samples;
638
+ int n;
639
+ } cc_box_category_t;
640
+
641
+ /* Box plot rendering options. Unspecified fields fall back to defaults:
642
+ * - rise_color : ANSI color for the box (Q1..Q3) and the median line
643
+ * (default green)
644
+ * - area_color : ANSI color for the whiskers (the vertical min..max line);
645
+ * the empty string or NULL means the whiskers share
646
+ * rise_color (the default)
647
+ * - bg_color : color of the empty cells above/below each box, or NULL
648
+ * for none
649
+ * - show_prices: 1 = prepend an 8-column value axis with the global max
650
+ * on the top row and the global min on the bottom row
651
+ * All fields are pointers or plain ints, so a partial {0} brace initializer
652
+ * gets exactly the same defaults as NULL (no double sentinels, so there is
653
+ * no brace-init ambiguity — unlike the histogram's NaN window fields). */
654
+ typedef struct cc_box_settings {
655
+ const char* rise_color;
656
+ const char* area_color;
657
+ const char* bg_color;
658
+ int show_prices;
659
+ } cc_box_settings_t;
660
+
661
+ /* Renders a box plot of `cat_count` categories into a `width` x `height`
662
+ * grid and returns a malloc'd string (caller frees).
663
+ *
664
+ * QUARTILE CONVENTION (nearest-rank). For each category's n sorted samples
665
+ * s[0..n-1]:
666
+ * min = s[0]
667
+ * Q1 = s[floor((n-1) * 0.25)]
668
+ * median = s[floor((n-1) * 0.50)]
669
+ * Q3 = s[floor((n-1) * 0.75)]
670
+ * max = s[n-1]
671
+ * This is the nearest-rank method: a quartile is the value of the element
672
+ * at the rounded-down rank, with no interpolation between elements. For
673
+ * n == 1 all five collapse to the single sample.
674
+ *
675
+ * VALUE AXIS. The global minimum and maximum across every category's samples
676
+ * define the value span. Each value v maps to a sub-pixel row — there are 8
677
+ * sub-pixels per cell, height * 8 total — by
678
+ * level(v) = (gmax == gmin) ? (pixel_height / 2)
679
+ * : floor((v - gmin) / (gmax - gmin) *
680
+ * (pixel_height - 1))
681
+ * so the global min sits on the bottom sub-pixel (row 0) and the global max
682
+ * on the top sub-pixel (row pixel_height - 1). Cell row r (from the bottom)
683
+ * is level/8.
684
+ *
685
+ * COLUMN MAPPING. Output column w (0..width-1) draws the single category
686
+ * cs = floor(w * cat_count / width)
687
+ * — the FIRST category in that column's long-arithmetic span. When
688
+ * width >= cat_count a category whose span covers several columns is drawn
689
+ * in each of them (repeated); when width < cat_count some categories are
690
+ * skipped so every column still maps to exactly one category, always
691
+ * deterministically.
692
+ *
693
+ * GLYPH MAPPING (per column, bottom row 0 .. top row height-1). Let lo =
694
+ * level(min)/8, q1 = level(Q1)/8, md = level(median)/8, q3 = level(Q3)/8,
695
+ * hi = level(max)/8 and LQ1 = level(Q1), LQ3 = level(Q3). A cell row r is
696
+ * drawn with the first matching rule:
697
+ * 1. Median line (r == md): full block `█` in rise_color
698
+ * — always the full block, even when the median aliases the Q3 top
699
+ * edge, so the median is a solid horizontal line (the emphasis)
700
+ * 2. Box interior (q1 < r < q3): full block `█` in rise_color
701
+ * 3. Box top edge (r == q3): `cc_lower_eighth(h)` in
702
+ * rise_color, h = (LQ3 % 8) + 1 — the Q3 sub-pixel, so the top edge is
703
+ * sub-pixel precise (h == 8 renders a full block)
704
+ * 4. Box bottom edge (r == q1, q1 < q3): full block `█` in rise_color
705
+ * — the Q1 sub-pixel floor rounds down to a full cell; when q1 == q3
706
+ * (box within one row) it is `cc_lower_eighth(LQ3 - LQ1 + 1)`
707
+ * 5. Whisker (lo <= r <= hi, not a box row): vertical `│`
708
+ * (CC_LINE_VERTICAL) in area_color (default rise_color)
709
+ * 6. Background: space, or bg_color
710
+ * Partial top edges reuse cc_lower_eighth; the whiskers reach from the
711
+ * global-min row to the global-max row.
712
+ *
713
+ * Returns a malloc'd string (caller frees); NULL cats, cat_count <= 0, any
714
+ * category with n <= 0 / NULL samples, or invalid dimensions yield an empty
715
+ * string. Scratch buffers are heap-allocated (no VLAs) and freed on every
716
+ * path. NaN/inf samples are rejected upstream (wrapper/ABI), so this header
717
+ * stays free of C99-only isfinite. */
718
+ CC_INLINE char* cc_box_create(const cc_box_category_t* cats, int cat_count,
719
+ int width, int height,
720
+ const cc_box_settings_t* settings);
721
+
722
+ #ifdef __cplusplus
723
+ }
724
+ #endif
725
+
726
+ #ifdef CCHARTS_IMPLEMENTATION
727
+
728
+ /* ============================ Public constants ============================ */
729
+ /* (CC_MAX_DIM / CC_MAX_CELLS are defined above, outside the implementation
730
+ * block, so the Python wrapper and other clients can also use them.) */
731
+
732
+ /* One candlestick. `timestamp` is epoch seconds, 0 when unknown. */
733
+ struct cc_ohlc {
734
+ double open;
735
+ double high;
736
+ double low;
737
+ double close;
738
+ long long timestamp;
739
+ };
740
+
741
+ CC_INLINE cc_settings_t cc_settings_resolve(const cc_settings_t* settings) {
742
+ cc_settings_t resolved = {
743
+ .rise_color = (settings && settings->rise_color) ? settings->rise_color : CC_COLOR_GREEN,
744
+ .fall_color = (settings && settings->fall_color) ? settings->fall_color : CC_COLOR_RED,
745
+ .bg_color = (settings && settings->bg_color) ? settings->bg_color : NULL,
746
+ .area_color = (settings && settings->area_color) ? settings->area_color : NULL,
747
+ .single_color = settings ? settings->single_color : 0,
748
+ .show_prices = settings ? settings->show_prices : 0,
749
+ .show_times = settings ? settings->show_times : 0,
750
+ };
751
+ return resolved;
752
+ }
753
+
754
+ /* ============================ Internal helpers ============================
755
+ * Everything below CCHARTS_IMPLEMENTATION is private. The pieces build on
756
+ * each other in this order:
757
+ * 1. cc_settings_resolve -> normalize settings to full defaults
758
+ * 2. capacity/dim helpers -> allocation and dimension sanity checks
759
+ * 3. parsing helpers -> whitespace trim + timestamp conversion
760
+ * 4. cc_str_to_ohlc -> CSV -> cc_ohlc_t[]
761
+ * 5. JSON mini-scanner -> JSON -> cc_ohlc_t[]
762
+ * 6. rendering helpers -> value-to-pixel math + cell string builders
763
+ * 7. cc_line_create -> line chart renderer
764
+ * 8. cc_candle_create -> candle chart renderer
765
+ * ========================================================================== */
766
+
767
+ /* Validates chart dimensions against the public limits. */
768
+ CC_INLINE int cc_dim_ok(int width, int height) {
769
+ return width > 0 && height > 0 &&
770
+ width <= CC_MAX_DIM && height <= CC_MAX_DIM &&
771
+ (long long)width * (long long)height <= CC_MAX_CELLS;
772
+ }
773
+
774
+ CC_INLINE char* cc_trim_whitespace(char* str) {
775
+ if (str == NULL || *str == '\0') {
776
+ return str;
777
+ }
778
+
779
+ while (*str != '\0' && (*str == ' ' || *str == '\t' || *str == '\n' || *str == '\r')) {
780
+ str++;
781
+ }
782
+
783
+ if (*str == '\0') {
784
+ return str;
785
+ }
786
+
787
+ char* end = str + strlen(str) - 1;
788
+ while (end >= str && (*end == ' ' || *end == '\t' || *end == '\n' || *end == '\r')) {
789
+ end--;
790
+ }
791
+ *(end + 1) = '\0';
792
+
793
+ return str;
794
+ }
795
+
796
+ /* Days in a month, leap-year aware (used to validate ISO8601 input). */
797
+ CC_INLINE int cc_days_in_month(int year, int month) {
798
+ static const int days[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
799
+ if (month == 2) {
800
+ int leap = (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
801
+ return leap ? 29 : 28;
802
+ }
803
+ return days[month - 1];
804
+ }
805
+
806
+ CC_INLINE long long cc_iso8601_to_epoch(const char* s) {
807
+ /* Parses "YYYY-MM-DDTHH:MM:SS[+ZZ:ZZ|+ZZZZ|+ZZ|Z]" and converts the
808
+ * wall-clock time to epoch seconds using Howard Hinnant's civil_date
809
+ * algorithm, applying the UTC offset:
810
+ * epoch = civil_days(wall) * 86400 + seconds(wall) - offset
811
+ * A missing offset (or "Z") is UTC. Returns 0 for malformed input. */
812
+ int y = 0, mo = 0, d = 0, h = 0, mi = 0, se = 0, consumed = 0;
813
+ if (sscanf(s, "%d-%d-%dT%d:%d:%d%n", &y, &mo, &d, &h, &mi, &se, &consumed) < 6) {
814
+ return 0;
815
+ }
816
+ if (mo < 1 || mo > 12 || d < 1 || d > cc_days_in_month(y, mo) ||
817
+ h < 0 || h > 23 || mi < 0 || mi > 59 || se < 0 || se > 60) {
818
+ return 0;
819
+ }
820
+
821
+ long long offset = 0;
822
+ const char* tz = s + consumed;
823
+ if (*tz == 'Z' || *tz == 'z') {
824
+ tz++;
825
+ } else if (*tz == '+' || *tz == '-') {
826
+ int oh = 0, om = 0, n2 = 0;
827
+ int sign = (*tz == '-') ? -1 : 1;
828
+ if (sscanf(tz + 1, "%d:%d%n", &oh, &om, &n2) == 2) {
829
+ /* "+HH:MM" */
830
+ } else if (sscanf(tz + 1, "%d%n", &oh, &n2) == 1) {
831
+ if (n2 == 4) { om = oh % 100; oh = oh / 100; } /* "+HHMM" */
832
+ else if (n2 == 2) { om = 0; } /* "+HH" */
833
+ else return 0;
834
+ } else {
835
+ return 0;
836
+ }
837
+ if (oh > 23 || om > 59) return 0;
838
+ offset = sign * ((long long)oh * 3600 + (long long)om * 60);
839
+ tz += 1 + n2;
840
+ }
841
+ if (*tz != '\0') return 0; /* trailing garbage after the offset */
842
+
843
+ long long yy = y - (mo <= 2);
844
+ long long era = (yy >= 0 ? yy : yy - 399) / 400;
845
+ long long yoe = yy - era * 400;
846
+ long long doy = (153 * (mo + (mo > 2 ? -3 : 9)) + 2) / 5 + d - 1;
847
+ long long doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
848
+ long long days = era * 146097 + doe - 719468;
849
+
850
+ return days * 86400 + (long long)h * 3600 + (long long)mi * 60 + se - offset;
851
+ }
852
+
853
+ CC_INLINE long long cc_parse_ts(const char* s) {
854
+ /* Accepts either an ISO8601 string ("2026-07-20T21:00:00+03:00") or a
855
+ * plain epoch-seconds number (possibly negative), and returns epoch
856
+ * seconds (0 = none). ISO detection looks for the exact YYYY-MM-DD
857
+ * prefix so a negative number like "-500" cannot be mistaken for one. */
858
+ if (s == NULL || *s == '\0') {
859
+ return 0;
860
+ }
861
+ size_t len = strlen(s);
862
+ if (len >= 10 && s[4] == '-' && s[7] == '-' &&
863
+ s[0] >= '0' && s[0] <= '9' && s[1] >= '0' && s[1] <= '9' &&
864
+ s[2] >= '0' && s[2] <= '9' && s[3] >= '0' && s[3] <= '9') {
865
+ return cc_iso8601_to_epoch(s);
866
+ }
867
+ return (long long)atoll(s);
868
+ }
869
+
870
+ /* ------------------------------ CSV parser ------------------------------ */
871
+
872
+ CC_INLINE int cc_str_to_ohlc(const char* data, int size, cc_ohlc_t** ohlc,
873
+ char val_seperator, char line_seperator)
874
+ {
875
+ /* Splits `data` on `line_seperator` (at most `size` lines) and parses
876
+ * up to 5 `val_seperator`-separated fields per line:
877
+ * open,high,low,close[,timestamp]. Empty lines are skipped. Each line
878
+ * and field is heap/slice-based, so there is no fixed-size buffer that
879
+ * a long line could overflow. */
880
+ if (data == NULL || ohlc == NULL || size <= 0) {
881
+ return 1;
882
+ }
883
+
884
+ *ohlc = (cc_ohlc_t*)calloc((size_t)size, sizeof(cc_ohlc_t));
885
+ if (*ohlc == NULL) {
886
+ fprintf(stderr, "ccharts: memory allocation failed\n");
887
+ return 1;
888
+ }
889
+
890
+ const char* line_start = data;
891
+ int idx = 0;
892
+
893
+ while (*line_start != '\0' && idx < size) {
894
+ const char* eol = strchr(line_start, line_seperator);
895
+ size_t len = eol ? (size_t)(eol - line_start) : strlen(line_start);
896
+
897
+ if (len > 0) {
898
+ char* line = (char*)malloc(len + 1);
899
+ if (line == NULL) {
900
+ free(*ohlc);
901
+ *ohlc = NULL;
902
+ return 1;
903
+ }
904
+ memcpy(line, line_start, len);
905
+ line[len] = '\0';
906
+
907
+ char* tok = cc_trim_whitespace(line);
908
+ if (*tok != '\0') {
909
+ cc_ohlc_t o = {0};
910
+ int field = 0;
911
+ char* field_start = tok;
912
+ while (field < 5) {
913
+ char* field_end = strchr(field_start, val_seperator);
914
+ if (field_end != NULL) *field_end = '\0';
915
+
916
+ char* f = cc_trim_whitespace(field_start);
917
+ if (field == 4) {
918
+ o.timestamp = cc_parse_ts(f);
919
+ } else {
920
+ double v = atof(f);
921
+ if (field == 0) o.open = v;
922
+ else if (field == 1) o.high = v;
923
+ else if (field == 2) o.low = v;
924
+ else if (field == 3) o.close = v;
925
+ }
926
+ field++;
927
+ if (field_end == NULL) break;
928
+ field_start = field_end + 1;
929
+ }
930
+ (*ohlc)[idx++] = o;
931
+ }
932
+ free(line);
933
+ }
934
+
935
+ if (eol == NULL) break;
936
+ line_start = eol + 1;
937
+ }
938
+
939
+ return 0;
940
+ }
941
+
942
+ /* ------------------------------ JSON parser ------------------------------
943
+ * A small iterative scanner for the fixed schema (no recursion, no strstr):
944
+ * string literals are the only things that can hide braces or commas, and
945
+ * every value is skipped by structure instead of by substring search, so
946
+ * content like "open" inside a string value can never be misread as a key.
947
+ * ------------------------------------------------------------------------- */
948
+
949
+ /* Skips JSON whitespace. */
950
+ CC_INLINE void cc_json_skip_ws(const char** p) {
951
+ while (**p == ' ' || **p == '\t' || **p == '\n' || **p == '\r') (*p)++;
952
+ }
953
+
954
+ /* Parses a JSON string literal at *p into out (max n-1 chars + NUL) and
955
+ * advances *p past the closing quote. Escape sequences are consumed but not
956
+ * decoded (the fixed schema never needs them). Returns 0 on malformed input. */
957
+ CC_INLINE int cc_json_parse_string(const char** p, char* out, size_t n) {
958
+ const char* s = *p;
959
+ if (*s != '"') return 0;
960
+ s++;
961
+ size_t k = 0;
962
+ for (;;) {
963
+ if (*s == '\0') return 0;
964
+ if (*s == '\\') {
965
+ s++;
966
+ if (*s == '\0') return 0;
967
+ if (*s == 'u') { /* consume \uXXXX */
968
+ for (int i = 0; i < 4; i++) { s++; if (*s == '\0') return 0; }
969
+ }
970
+ s++;
971
+ continue;
972
+ }
973
+ if (*s == '"') break;
974
+ if (k + 1 < n) out[k++] = *s;
975
+ s++;
976
+ }
977
+ out[k] = '\0';
978
+ *p = s + 1;
979
+ return 1;
980
+ }
981
+
982
+ /* Skips a complete JSON value (string, number, literal, or nested
983
+ * object/array) at *p and advances past it. Iterative — no recursion. */
984
+ CC_INLINE int cc_json_skip_value(const char** p) {
985
+ const char* s = *p;
986
+ cc_json_skip_ws(&s);
987
+ if (*s == '\0') return 0;
988
+
989
+ if (*s == '"') {
990
+ s++;
991
+ while (*s != '"') {
992
+ if (*s == '\0') return 0;
993
+ if (*s == '\\') { s++; if (*s == '\0') return 0; }
994
+ s++;
995
+ }
996
+ s++;
997
+ *p = s;
998
+ return 1;
999
+ }
1000
+
1001
+ if (*s == '{' || *s == '[') {
1002
+ int depth = 0;
1003
+ for (;;) {
1004
+ if (*s == '\0') return 0;
1005
+ if (*s == '"') {
1006
+ s++;
1007
+ while (*s != '"') {
1008
+ if (*s == '\0') return 0;
1009
+ if (*s == '\\') { s++; if (*s == '\0') return 0; }
1010
+ s++;
1011
+ }
1012
+ } else if (*s == '{' || *s == '[') {
1013
+ depth++;
1014
+ } else if (*s == '}' || *s == ']') {
1015
+ depth--;
1016
+ if (depth == 0) { s++; break; }
1017
+ }
1018
+ s++;
1019
+ }
1020
+ *p = s;
1021
+ return 1;
1022
+ }
1023
+
1024
+ /* Bare token: number / true / false / null. */
1025
+ while (*s != '\0' && *s != ',' && *s != '}' && *s != ']' &&
1026
+ *s != ' ' && *s != '\t' && *s != '\n' && *s != '\r') s++;
1027
+ if (s == *p) return 0; /* nothing consumed */
1028
+ *p = s;
1029
+ return 1;
1030
+ }
1031
+
1032
+ /* Copies a bare JSON number token starting at *p into out (max n-1 chars)
1033
+ * and advances *p past it. Returns 0 when no number is present. */
1034
+ CC_INLINE int cc_json_read_number_token(const char** p, char* out, size_t n) {
1035
+ const char* s = *p;
1036
+ size_t k = 0;
1037
+ while (*s == '-' || *s == '+' || (*s >= '0' && *s <= '9') ||
1038
+ *s == '.' || *s == 'e' || *s == 'E') {
1039
+ if (k + 1 < n) out[k++] = *s;
1040
+ s++;
1041
+ }
1042
+ if (k == 0) return 0;
1043
+ out[k] = '\0';
1044
+ *p = s;
1045
+ return 1;
1046
+ }
1047
+
1048
+ CC_INLINE int cc_json_to_ohlc(const char* json, cc_ohlc_t** ohlc, int* size) {
1049
+ *ohlc = NULL;
1050
+ *size = 0;
1051
+ if (json == NULL) {
1052
+ return 1;
1053
+ }
1054
+
1055
+ const char* p = json;
1056
+ cc_json_skip_ws(&p);
1057
+ if (*p != '[') return 1;
1058
+ p++;
1059
+ cc_json_skip_ws(&p);
1060
+
1061
+ /* Pass 1: count the objects in the top-level array. */
1062
+ int count = 0;
1063
+ if (*p != ']') {
1064
+ for (;;) {
1065
+ cc_json_skip_ws(&p);
1066
+ if (*p != '{') return 1;
1067
+ int depth = 0;
1068
+ for (;;) { /* skim to the matching '}' */
1069
+ if (*p == '\0') return 1;
1070
+ if (*p == '"') {
1071
+ p++;
1072
+ while (*p != '"') {
1073
+ if (*p == '\0') return 1;
1074
+ if (*p == '\\') { p++; if (*p == '\0') return 1; }
1075
+ p++;
1076
+ }
1077
+ } else if (*p == '{') {
1078
+ depth++;
1079
+ } else if (*p == '}') {
1080
+ depth--;
1081
+ if (depth == 0) { p++; break; }
1082
+ }
1083
+ p++;
1084
+ }
1085
+ count++;
1086
+ cc_json_skip_ws(&p);
1087
+ if (*p == ',') { p++; continue; }
1088
+ if (*p == ']') break;
1089
+ return 1;
1090
+ }
1091
+ }
1092
+ if (count <= 0) return 1;
1093
+
1094
+ *ohlc = (cc_ohlc_t*)calloc((size_t)count, sizeof(cc_ohlc_t));
1095
+ if (*ohlc == NULL) {
1096
+ fprintf(stderr, "ccharts: memory allocation failed\n");
1097
+ return 1;
1098
+ }
1099
+
1100
+ /* Pass 2: parse each object into a cc_ohlc_t. */
1101
+ p = json;
1102
+ cc_json_skip_ws(&p);
1103
+ p++; /* '[' */
1104
+ cc_json_skip_ws(&p);
1105
+ int idx = 0;
1106
+ int valid = 1;
1107
+ if (*p != ']') {
1108
+ for (;;) {
1109
+ cc_json_skip_ws(&p);
1110
+ if (*p != '{') { valid = 0; break; }
1111
+ p++;
1112
+ cc_ohlc_t o = {0};
1113
+ for (;;) {
1114
+ cc_json_skip_ws(&p);
1115
+ if (*p == '}') { p++; break; }
1116
+ if (*p != '"') { valid = 0; break; }
1117
+ char key[16];
1118
+ if (!cc_json_parse_string(&p, key, sizeof(key))) { valid = 0; break; }
1119
+ cc_json_skip_ws(&p);
1120
+ if (*p != ':') { valid = 0; break; }
1121
+ p++; /* ':' */
1122
+ cc_json_skip_ws(&p);
1123
+
1124
+ if (strcmp(key, "ts") == 0) {
1125
+ char sval[64];
1126
+ if (*p == '"') {
1127
+ if (!cc_json_parse_string(&p, sval, sizeof(sval))) { valid = 0; break; }
1128
+ } else {
1129
+ if (!cc_json_read_number_token(&p, sval, sizeof(sval))) { valid = 0; break; }
1130
+ }
1131
+ o.timestamp = cc_parse_ts(sval);
1132
+ } else if (strcmp(key, "open") == 0 || strcmp(key, "high") == 0 ||
1133
+ strcmp(key, "low") == 0 || strcmp(key, "close") == 0) {
1134
+ char nval[32];
1135
+ if (!cc_json_read_number_token(&p, nval, sizeof(nval))) { valid = 0; break; }
1136
+ double v = atof(nval);
1137
+ if (strcmp(key, "open") == 0) o.open = v;
1138
+ else if (strcmp(key, "high") == 0) o.high = v;
1139
+ else if (strcmp(key, "low") == 0) o.low = v;
1140
+ else o.close = v;
1141
+ } else {
1142
+ /* Unknown key — "volume" and friends are skipped. */
1143
+ if (!cc_json_skip_value(&p)) { valid = 0; break; }
1144
+ }
1145
+
1146
+ cc_json_skip_ws(&p);
1147
+ if (*p == ',') { p++; continue; }
1148
+ if (*p == '}') { p++; break; }
1149
+ valid = 0;
1150
+ break;
1151
+ }
1152
+ if (!valid) break;
1153
+ (*ohlc)[idx++] = o;
1154
+ cc_json_skip_ws(&p);
1155
+ if (*p == ',') { p++; continue; }
1156
+ if (*p == ']') break;
1157
+ valid = 0;
1158
+ break;
1159
+ }
1160
+ }
1161
+ if (!valid) {
1162
+ free(*ohlc);
1163
+ *ohlc = NULL;
1164
+ return 1;
1165
+ }
1166
+
1167
+ *size = idx;
1168
+ return 0;
1169
+ }
1170
+
1171
+ /* --------------------------- Rendering helpers --------------------------- */
1172
+
1173
+ /* Smallest / largest value in arr[0..n-1]. */
1174
+ CC_INLINE double find_min(double arr[], int n) {
1175
+ double min = arr[0];
1176
+ for (int i = 1; i < n; i++) {
1177
+ if (arr[i] < min) {
1178
+ min = arr[i];
1179
+ }
1180
+ }
1181
+ return min;
1182
+ }
1183
+
1184
+ CC_INLINE double find_max(double arr[], int n) {
1185
+ double max = arr[0];
1186
+ for (int i = 1; i < n; i++) {
1187
+ if (arr[i] > max) {
1188
+ max = arr[i];
1189
+ }
1190
+ }
1191
+ return max;
1192
+ }
1193
+
1194
+ /* Maps a value in [min, max] to a pixel row in [0, pixel_height-1].
1195
+ * `range` is max - min; a flat range is centered to avoid divide-by-zero. */
1196
+ CC_INLINE int cc_pixel(double val, double min, double max, double range, int pixel_height) {
1197
+ /* `max` is kept for call-site symmetry (every caller has min/max/range at
1198
+ * hand); the mapping only needs min and range. Silences -Wextra for the
1199
+ * bindings, which compile this header with their own flags. */
1200
+ (void)max;
1201
+ double t = (range == 0.0) ? 0.5 : (val - min) / range;
1202
+ int p = (int)lround(t * (pixel_height - 1));
1203
+ if (p < 0) p = 0;
1204
+ if (p >= pixel_height) p = pixel_height - 1;
1205
+ return p;
1206
+ }
1207
+
1208
+ /* NULL as an empty string, for the printf calls below. */
1209
+ #define CC_S(p) ((p) != NULL ? (p) : "")
1210
+
1211
+ /* The reset sequence is only needed when something was actually colored.
1212
+ * A color set to the empty string means "emit no escape", so a chart whose
1213
+ * colors are all empty comes out as plain text with no escapes at all —
1214
+ * which is what lets the output go somewhere that is not a terminal. */
1215
+ CC_INLINE const char* cc_reset_for(const char* a, const char* b) {
1216
+ int colored = (a != NULL && *a != '\0') || (b != NULL && *b != '\0');
1217
+ return colored ? CC_COLOR_RESET : "";
1218
+ }
1219
+
1220
+ CC_INLINE void cc_render_cell(char out[32], unsigned char bodybits, unsigned char wickbits, const char* fg, const char* bg) {
1221
+ /* Turns a candle cell's mask bits into a ready-to-print string.
1222
+ * bodybits: bit0 = lower half filled, bit1 = upper half filled (body).
1223
+ * wickbits: non-zero means a wick passes through this cell. Body wins
1224
+ * over wick so a partial body still reads as a solid block. */
1225
+ const char* block;
1226
+ if (bodybits == 3) block = CC_BLOCK_FULL;
1227
+ else if (bodybits == 2) block = CC_BLOCK_UPPER_HALF;
1228
+ else if (bodybits == 1) block = CC_BLOCK_LOWER_HALF;
1229
+ else if (wickbits != 0) block = CC_LINE_VERTICAL;
1230
+ else block = NULL;
1231
+
1232
+ if (block != NULL) {
1233
+ snprintf(out, 32, "%s%s%s%s", CC_S(bg), CC_S(fg), block,
1234
+ cc_reset_for(bg, fg));
1235
+ } else if (bg != NULL) {
1236
+ snprintf(out, 32, "%s %s", bg, cc_reset_for(bg, NULL));
1237
+ } else {
1238
+ snprintf(out, 32, " ");
1239
+ }
1240
+ }
1241
+
1242
+ CC_INLINE cc_ohlc_t cc_agg_ohlc(const cc_ohlc_t* data, int start, int end) {
1243
+ /* Aggregates candles [start, end) into one virtual candle:
1244
+ * open = first open, high = max high, low = min low, close = last close.
1245
+ * Used to compress many candles into a few columns (width < size). */
1246
+ cc_ohlc_t v = { .open = data[start].open, .high = data[start].high,
1247
+ .low = data[start].low, .close = data[end - 1].close };
1248
+ for (int k = start + 1; k < end; k++) {
1249
+ if (data[k].high > v.high) v.high = data[k].high;
1250
+ if (data[k].low < v.low) v.low = data[k].low;
1251
+ }
1252
+ return v;
1253
+ }
1254
+
1255
+ CC_INLINE const char* cc_lower_eighth(int n) {
1256
+ /* Returns the lower 1/8 block whose bar height is n/8 (n in 1..8).
1257
+ * These characters give the line chart its 8-level vertical resolution:
1258
+ * ▁▂▃▄▅▆▇█. */
1259
+ static const char* table[] = {
1260
+ CC_BLOCK_LOWER_1_8, CC_BLOCK_LOWER_2_8, CC_BLOCK_LOWER_3_8, CC_BLOCK_LOWER_HALF,
1261
+ CC_BLOCK_LOWER_5_8, CC_BLOCK_LOWER_6_8, CC_BLOCK_LOWER_7_8, CC_BLOCK_FULL
1262
+ };
1263
+ if (n < 1) n = 1;
1264
+ if (n > 8) n = 8;
1265
+ return table[n - 1];
1266
+ }
1267
+
1268
+ CC_INLINE const char* cc_time_format(long long first, long long last, int count) {
1269
+ /* Picks a strftime format from the average interval between candles:
1270
+ * >= 20 hours (daily/weekly/monthly) -> "YYYY-MM-DD"
1271
+ * intraday spanning > 1 day -> "MM-DD HH:MM"
1272
+ * intraday within one day -> "HH:MM" */
1273
+ long long span = last - first;
1274
+ long long interval = (count > 1) ? span / (count - 1) : 0;
1275
+ if (interval >= 72000) {
1276
+ return "%Y-%m-%d";
1277
+ }
1278
+ if (span >= 86400) {
1279
+ return "%m-%d %H:%M";
1280
+ }
1281
+ return "%H:%M";
1282
+ }
1283
+
1284
+ CC_INLINE char* cc_assemble_chart(int width, int height, char* columns,
1285
+ const cc_settings_t* s,
1286
+ long long ts_first, long long ts_last, int count,
1287
+ double max, double min) {
1288
+ /* Joins the per-cell strings (columns[(x*height + y)*32 .. +32), y
1289
+ * counted from the bottom) into the final chart string, printing rows
1290
+ * top-to-bottom. Optionally adds a left price margin (max on the top
1291
+ * row, min on the bottom row) and a footer with the first/last
1292
+ * timestamps. Allocates the result with a pointer cursor (no strcat
1293
+ * re-scans); the caller frees it. */
1294
+ int margin = 0;
1295
+ char max_label[16] = "";
1296
+ char min_label[16] = "";
1297
+ if (s->show_prices) {
1298
+ margin = 8;
1299
+ snprintf(max_label, sizeof(max_label), "%.2f", max);
1300
+ snprintf(min_label, sizeof(min_label), "%.2f", min);
1301
+ }
1302
+
1303
+ char t_first[32] = "";
1304
+ char t_last[32] = "";
1305
+ int footer = 0;
1306
+ if (s->show_times && count > 0 && ts_first > 0 && ts_last > 0) {
1307
+ const char* fmt = cc_time_format(ts_first, ts_last, count);
1308
+ time_t a = (time_t)ts_first;
1309
+ time_t b = (time_t)ts_last;
1310
+ struct tm tmv;
1311
+ if (CC_GMTIME_R(&a, &tmv)) strftime(t_first, sizeof(t_first), fmt, &tmv);
1312
+ if (CC_GMTIME_R(&b, &tmv)) strftime(t_last, sizeof(t_last), fmt, &tmv);
1313
+ footer = 1;
1314
+ }
1315
+
1316
+ const int line_len = width + margin;
1317
+ const int rows = height + footer;
1318
+ /* Bounded by CC_MAX_CELLS: width*height <= 1e6, so this is <= ~32 MB. */
1319
+ size_t total_size = (size_t)line_len * (size_t)rows * 32 + (size_t)rows + 1;
1320
+ char* chart = (char*)calloc(total_size, 1);
1321
+ if (chart == NULL) return NULL;
1322
+
1323
+ char* w = chart;
1324
+ for (int y = height - 1; y >= 0; y--) {
1325
+ if (margin > 0) {
1326
+ char mlabel[16] = "";
1327
+ if (y == height - 1) snprintf(mlabel, sizeof(mlabel), "%8s", max_label);
1328
+ else if (y == 0) snprintf(mlabel, sizeof(mlabel), "%8s", min_label);
1329
+ else { memset(mlabel, ' ', 8); mlabel[8] = '\0'; }
1330
+ size_t ml = strlen(mlabel);
1331
+ memcpy(w, mlabel, ml);
1332
+ w += ml;
1333
+ }
1334
+ for (int x = 0; x < width; x++) {
1335
+ const char* cell = columns + ((size_t)x * height + (size_t)y) * 32;
1336
+ size_t cl = strlen(cell);
1337
+ memcpy(w, cell, cl);
1338
+ w += cl;
1339
+ }
1340
+ *w++ = '\n';
1341
+ }
1342
+
1343
+ if (footer) {
1344
+ for (int i = 0; i < line_len; i++) *w++ = ' ';
1345
+ size_t t1 = strlen(t_first);
1346
+ if (t1 > (size_t)line_len) t1 = (size_t)line_len;
1347
+ size_t t2 = strlen(t_last);
1348
+ if (t2 > (size_t)line_len) t2 = (size_t)line_len;
1349
+ if (t1 > 0) memcpy(w - line_len, t_first, t1);
1350
+ if (t2 > 0) memcpy(w - t2, t_last, t2);
1351
+ *w++ = '\n';
1352
+ }
1353
+ *w = '\0';
1354
+ return chart;
1355
+ }
1356
+
1357
+ /* ------------------------------ Line renderer ------------------------------
1358
+ * For each output column, average the closes that fall into that column
1359
+ * (like the candle chart, when width < size several candles share a column).
1360
+ * The column's height is snapped to a cell boundary on the bottom and drawn
1361
+ * with an 8-level bar on top (cc_lower_eighth), so the line's upper edge is
1362
+ * smooth. Adjacent columns share pixels to avoid gaps. With area_color set,
1363
+ * cells below the line are filled. Colors come from cc_settings_t: either
1364
+ * one color for the whole chart (single_color) or per segment.
1365
+ * All scratch buffers are heap-allocated (no VLAs) and freed on every path.
1366
+ * ------------------------------------------------------------------------- */
1367
+
1368
+ CC_INLINE char* cc_line_create(const cc_ohlc_t* data, int size, int width, int height,
1369
+ const cc_settings_t* settings) {
1370
+ if (data == NULL || size <= 0 || !cc_dim_ok(width, height)) {
1371
+ return (char*)calloc(1, sizeof(char));
1372
+ }
1373
+
1374
+ cc_settings_t s = cc_settings_resolve(settings);
1375
+
1376
+ double* closes = (double*)malloc((size_t)size * sizeof(double));
1377
+ double* vals = (double*)calloc((size_t)width, sizeof(double));
1378
+ const char** col_color = (const char**)malloc((size_t)width * sizeof(char*));
1379
+ int* py = (int*)malloc((size_t)width * sizeof(int));
1380
+ char* columns = (char*)malloc((size_t)width * (size_t)height * 32);
1381
+ if (closes == NULL || vals == NULL || col_color == NULL ||
1382
+ py == NULL || columns == NULL) {
1383
+ free(closes); free(vals); free(col_color); free(py); free(columns);
1384
+ return NULL;
1385
+ }
1386
+
1387
+ for (int i = 0; i < size; i++) {
1388
+ closes[i] = data[i].close;
1389
+ }
1390
+
1391
+ for (int w = 0; w < width; w++) {
1392
+ int start_idx = (int)(((long long)w * size) / width);
1393
+ int end_idx = (int)((((long long)w + 1) * size) / width);
1394
+ if (end_idx <= start_idx) end_idx = start_idx + 1;
1395
+
1396
+ double sum = 0.0;
1397
+ for (int k = start_idx; k < end_idx && k < size; k++) {
1398
+ sum += closes[k];
1399
+ }
1400
+ vals[w] = sum / (end_idx - start_idx);
1401
+ }
1402
+
1403
+ double min = find_min(vals, width);
1404
+ double max = find_max(vals, width);
1405
+ double diff_range = max - min;
1406
+
1407
+ if (s.single_color) {
1408
+ double change = (size > 1) ? (closes[size-1] - closes[size-2]) : 0.0;
1409
+ const char* color = (change >= 0.0) ? s.rise_color : s.fall_color;
1410
+ for (int w = 0; w < width; w++) {
1411
+ col_color[w] = color;
1412
+ }
1413
+ } else {
1414
+ for (int w = 0; w < width; w++) {
1415
+ double prev = (w > 0) ? vals[w-1] : vals[w];
1416
+ col_color[w] = (vals[w] >= prev) ? s.rise_color : s.fall_color;
1417
+ }
1418
+ }
1419
+
1420
+ int pixel_height = height * 8;
1421
+
1422
+ for (int i = 0; i < width; i++) {
1423
+ py[i] = cc_pixel(vals[i], min, max, diff_range, pixel_height);
1424
+ }
1425
+
1426
+ for (int i = 0; i < width; i++) {
1427
+ int lo = py[i];
1428
+ int hi = py[i];
1429
+ if (i + 1 < width) {
1430
+ if (py[i+1] < lo) lo = py[i+1];
1431
+ if (py[i+1] > hi) hi = py[i+1];
1432
+ }
1433
+
1434
+ int cell_lo = lo / 8;
1435
+ int cell_hi = hi / 8;
1436
+ char* col = columns + (size_t)i * height * 32;
1437
+
1438
+ for (int r = 0; r < cell_lo && r < height; r++) {
1439
+ char* cell = col + (size_t)r * 32;
1440
+ if (s.area_color != NULL) {
1441
+ snprintf(cell, 32, "%s %s", s.area_color,
1442
+ cc_reset_for(s.area_color, NULL));
1443
+ } else if (s.bg_color != NULL) {
1444
+ snprintf(cell, 32, "%s %s", s.bg_color,
1445
+ cc_reset_for(s.bg_color, NULL));
1446
+ } else {
1447
+ snprintf(cell, 32, " ");
1448
+ }
1449
+ }
1450
+
1451
+ for (int r = cell_lo; r < cell_hi && r < height; r++) {
1452
+ snprintf(col + (size_t)r * 32, 32, "%s%s%s", CC_S(col_color[i]),
1453
+ CC_BLOCK_FULL, cc_reset_for(col_color[i], NULL));
1454
+ }
1455
+
1456
+ if (cell_hi >= 0 && cell_hi < height) {
1457
+ int count = hi - cell_hi * 8 + 1;
1458
+ if (count > 8) count = 8;
1459
+ snprintf(col + (size_t)cell_hi * 32, 32, "%s%s%s",
1460
+ CC_S(col_color[i]), cc_lower_eighth(count),
1461
+ cc_reset_for(col_color[i], NULL));
1462
+ }
1463
+
1464
+ for (int r = cell_hi + 1; r < height; r++) {
1465
+ char* cell = col + (size_t)r * 32;
1466
+ if (s.bg_color != NULL) {
1467
+ snprintf(cell, 32, "%s %s", s.bg_color,
1468
+ cc_reset_for(s.bg_color, NULL));
1469
+ } else {
1470
+ snprintf(cell, 32, " ");
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ long long ts_first = data[0].timestamp;
1476
+ long long ts_last = data[size-1].timestamp;
1477
+ char* chart = cc_assemble_chart(width, height, columns, &s,
1478
+ ts_first, ts_last, size, max, min);
1479
+ free(closes); free(vals); free(col_color); free(py); free(columns);
1480
+ return chart;
1481
+ }
1482
+
1483
+ /* ----------------------------- Candle renderer -----------------------------
1484
+ * Two modes:
1485
+ * - width >= size : each candle owns several columns; the body (open-close)
1486
+ * is drawn as blocks, the wick (high-low) as a thin │ in the center
1487
+ * column, and the last column of each candle is left empty as a gap.
1488
+ * - width < size : neighboring candles are collapsed into virtual candles
1489
+ * (cc_agg_ohlc), one per column.
1490
+ * The vertical price range covers the whole high/low span so wicks never
1491
+ * clip. Rises use rise_color, falls use fall_color (close >= open).
1492
+ * All scratch buffers are heap-allocated (no VLAs) and freed on every path.
1493
+ * ------------------------------------------------------------------------- */
1494
+
1495
+ CC_INLINE char* cc_candle_create(const cc_ohlc_t* data, int size, int width, int height,
1496
+ const cc_settings_t* settings) {
1497
+ if (data == NULL || size <= 0 || !cc_dim_ok(width, height)) {
1498
+ return (char*)calloc(1, sizeof(char));
1499
+ }
1500
+
1501
+ cc_settings_t s = cc_settings_resolve(settings);
1502
+ int pixel_height = height * 2;
1503
+
1504
+ double min = data[0].low;
1505
+ double max = data[0].high;
1506
+ for (int i = 1; i < size; i++) {
1507
+ if (data[i].low < min) min = data[i].low;
1508
+ if (data[i].high > max) max = data[i].high;
1509
+ }
1510
+ double range = max - min;
1511
+
1512
+ size_t cells = (size_t)width * (size_t)height;
1513
+ unsigned char* body_mask = (unsigned char*)calloc(cells, 1);
1514
+ unsigned char* wick_mask = (unsigned char*)calloc(cells, 1);
1515
+ const char** col_color = (const char**)malloc((size_t)width * sizeof(char*));
1516
+ char* columns = (char*)malloc(cells * 32);
1517
+ if (body_mask == NULL || wick_mask == NULL || col_color == NULL || columns == NULL) {
1518
+ free(body_mask); free(wick_mask); free(col_color); free(columns);
1519
+ return NULL;
1520
+ }
1521
+
1522
+ if (width >= size) {
1523
+
1524
+ for (int i = 0; i < size; i++) {
1525
+ int cs = (int)(((long long)i * width) / size);
1526
+ int ce = (int)((((long long)i + 1) * width) / size);
1527
+ if (ce <= cs) ce = cs + 1;
1528
+ int gap = (ce - cs >= 2) ? (ce - 1) : ce;
1529
+ int cmid = cs + (gap - cs - 1) / 2;
1530
+
1531
+ const char* color = (data[i].close >= data[i].open) ? s.rise_color : s.fall_color;
1532
+
1533
+ int po = cc_pixel(data[i].open, min, max, range, pixel_height);
1534
+ int pc = cc_pixel(data[i].close, min, max, range, pixel_height);
1535
+ int ph = cc_pixel(data[i].high, min, max, range, pixel_height);
1536
+ int pl = cc_pixel(data[i].low, min, max, range, pixel_height);
1537
+
1538
+ int blo = (po < pc) ? po : pc;
1539
+ int bhi = (po > pc) ? po : pc;
1540
+
1541
+ for (int c = cs; c < ce; c++) {
1542
+ col_color[c] = color;
1543
+ if (c < gap) {
1544
+ unsigned char* bm = body_mask + (size_t)c * height;
1545
+ for (int p = blo; p <= bhi; p++) {
1546
+ bm[p / 2] |= (unsigned char)((p % 2) ? 2 : 1);
1547
+ }
1548
+ }
1549
+ }
1550
+
1551
+ unsigned char* wm = wick_mask + (size_t)cmid * height;
1552
+ for (int p = pl; p <= ph; p++) {
1553
+ wm[p / 2] |= (unsigned char)((p % 2) ? 2 : 1);
1554
+ }
1555
+ }
1556
+ } else {
1557
+
1558
+ for (int w = 0; w < width; w++) {
1559
+ int ss = (int)(((long long)w * size) / width);
1560
+ int se = (int)((((long long)w + 1) * size) / width);
1561
+ if (se <= ss) se = ss + 1;
1562
+
1563
+ cc_ohlc_t v = cc_agg_ohlc(data, ss, se);
1564
+ col_color[w] = (v.close >= v.open) ? s.rise_color : s.fall_color;
1565
+
1566
+ int po = cc_pixel(v.open, min, max, range, pixel_height);
1567
+ int pc = cc_pixel(v.close, min, max, range, pixel_height);
1568
+ int ph = cc_pixel(v.high, min, max, range, pixel_height);
1569
+ int pl = cc_pixel(v.low, min, max, range, pixel_height);
1570
+
1571
+ int blo = (po < pc) ? po : pc;
1572
+ int bhi = (po > pc) ? po : pc;
1573
+
1574
+ unsigned char* bm = body_mask + (size_t)w * height;
1575
+ for (int p = blo; p <= bhi; p++) {
1576
+ bm[p / 2] |= (unsigned char)((p % 2) ? 2 : 1);
1577
+ }
1578
+ unsigned char* wm = wick_mask + (size_t)w * height;
1579
+ for (int p = pl; p <= ph; p++) {
1580
+ wm[p / 2] |= (unsigned char)((p % 2) ? 2 : 1);
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ for (int i = 0; i < width; i++) {
1586
+ char* col = columns + (size_t)i * height * 32;
1587
+ for (int j = 0; j < height; j++) {
1588
+ cc_render_cell(col + (size_t)j * 32, body_mask[(size_t)i * height + j],
1589
+ wick_mask[(size_t)i * height + j], col_color[i], s.bg_color);
1590
+ }
1591
+ }
1592
+
1593
+ long long ts_first = data[0].timestamp;
1594
+ long long ts_last = data[size-1].timestamp;
1595
+ char* chart = cc_assemble_chart(width, height, columns, &s,
1596
+ ts_first, ts_last, size, max, min);
1597
+ free(body_mask); free(wick_mask); free(col_color); free(columns);
1598
+ return chart;
1599
+ }
1600
+
1601
+ /* ------------------------------- Pie renderer ------------------------------
1602
+ * A pie normalizes slice values to angles (value/total * 2*pi) and samples
1603
+ * each grid cell once at its center in sub-pixel space (8 sub-pixels per
1604
+ * cell, like the line chart, so the disk is round in pixel space). A cell is
1605
+ * colored by the slice whose angular range holds its center, inside an outer
1606
+ * radius; a donut additionally blanks the cells inside the inner radius.
1607
+ * Colors come from a fixed deterministic palette or a NULL-terminated
1608
+ * per-slice override. The legend, when requested, is appended below the disk,
1609
+ * one "label value (pct%)" line per slice. All scratch buffers are
1610
+ * heap-allocated (no VLAs) and freed on every path.
1611
+ * -------------------------------------------------------------------------- */
1612
+
1613
+ #define CC_PI 3.14159265358979323846 /* M_PI is not portable C89/C99 */
1614
+ #define CC_PIE_DONUT_RADIUS_RATIO 0.5 /* inner radius = this * outer radius */
1615
+ /* Terminal character cells are roughly twice as tall as they are wide
1616
+ * (~2:1). The pie renderer draws the disk in square sub-pixel space (8x8 per
1617
+ * cell), so an unscaled circle would read as a stretched ellipse on screen.
1618
+ * CC_PIE_ASPECT is the terminal cell aspect (width / height, ~0.5): the
1619
+ * renderer compresses the horizontal axis by this factor when testing
1620
+ * distance and slice angles, so the disk renders as a round circle. */
1621
+ #define CC_PIE_ASPECT 0.5 /* terminal cell width / height */
1622
+
1623
+ /* Deterministic fixed palette. Never random — a random palette would break
1624
+ * the byte-for-byte conformance goldens. Indexed per slice (mod length). */
1625
+ static const char* const CC_PIE_DEFAULT_PALETTE[] = {
1626
+ CC_COLOR_BLUE, CC_COLOR_GREEN, CC_COLOR_YELLOW, CC_COLOR_RED,
1627
+ CC_COLOR_MAGENTA, CC_COLOR_CYAN, CC_COLOR_BRIGHT_BLUE,
1628
+ CC_COLOR_BRIGHT_GREEN, CC_COLOR_BRIGHT_YELLOW, CC_COLOR_BRIGHT_RED,
1629
+ CC_COLOR_BRIGHT_MAGENTA, CC_COLOR_BRIGHT_CYAN, CC_COLOR_WHITE,
1630
+ CC_COLOR_BRIGHT_WHITE, CC_COLOR_BLACK, CC_COLOR_BRIGHT_BLACK,
1631
+ };
1632
+ #define CC_PIE_DEFAULT_PALETTE_COUNT \
1633
+ ((int)(sizeof(CC_PIE_DEFAULT_PALETTE) / sizeof(CC_PIE_DEFAULT_PALETTE[0])))
1634
+
1635
+ /* Resolves the color for `slice`: the per-slice override palette when set
1636
+ * (a NULL-terminated array), otherwise the fixed default palette. */
1637
+ CC_INLINE const char* cc_pie_palette_color(const cc_pie_settings_t* s, int slice) {
1638
+ if (s->colors != NULL) {
1639
+ int n = 0;
1640
+ while (s->colors[n] != NULL) n++;
1641
+ if (n > 0) return s->colors[slice % n];
1642
+ }
1643
+ return CC_PIE_DEFAULT_PALETTE[slice % CC_PIE_DEFAULT_PALETTE_COUNT];
1644
+ }
1645
+
1646
+ /* Returns the slice index whose angular range contains `angle`, given the
1647
+ * slices' start angles. The angle is normalized into the first slice's
1648
+ * revolution so the last slice can wrap past +2*pi and back to the first. */
1649
+ CC_INLINE int cc_pie_find_slice(const double* starts, int count, double angle) {
1650
+ double base = starts[0];
1651
+ double two_pi = 2.0 * CC_PI;
1652
+ while (angle < base) angle += two_pi;
1653
+ while (angle >= base + two_pi) angle -= two_pi;
1654
+ for (int i = 1; i < count; i++) {
1655
+ if (angle < starts[i]) return i - 1;
1656
+ }
1657
+ return count - 1;
1658
+ }
1659
+
1660
+ /* Gap-aware variant used when slice_gap > 0: each slice spans [starts[i],
1661
+ * starts[i] + widths[i]); the region between a slice's end and the next
1662
+ * slice's start (and the closing gap before 12 o'clock) is blank, so this
1663
+ * returns -1 there. Accounts for the last slice wrapping past the first. */
1664
+ CC_INLINE int cc_pie_find_slice_gap(const double* starts, const double* widths,
1665
+ int count, double angle) {
1666
+ double base = starts[0];
1667
+ double two_pi = 2.0 * CC_PI;
1668
+ int i;
1669
+
1670
+ while (angle < base) angle += two_pi;
1671
+ while (angle >= base + two_pi) angle -= two_pi;
1672
+ for (i = 0; i < count; i++) {
1673
+ double a, e;
1674
+ if (widths[i] <= 0.0) continue;
1675
+ a = starts[i];
1676
+ e = a + widths[i];
1677
+ if (angle >= a && angle < e) return i;
1678
+ }
1679
+ return -1;
1680
+ }
1681
+
1682
+ /* Joins the per-cell strings (columns[(x*height + y)*32 .. +32), y counted
1683
+ * from the bottom) into the final pie string, printing rows top-to-bottom
1684
+ * and appending the legend below when requested. Allocates with a pointer
1685
+ * cursor (no strcat re-scans); the caller frees the result. */
1686
+ CC_INLINE char* cc_pie_assemble(int width, int height, char* columns,
1687
+ const cc_pie_slice_t* slices, int count,
1688
+ double total, const cc_pie_settings_t* s) {
1689
+ int legend = s->show_legend ? count : 0;
1690
+ /* Bounded by CC_MAX_CELLS for the cells (width*height <= 1e6, each cell
1691
+ * at most 32 bytes); each legend row is capped at 512 bytes by the
1692
+ * snprintf below, so this allocation is always large enough. */
1693
+ size_t total_size = (size_t)width * (size_t)height * 32 + (size_t)height + 1
1694
+ + (size_t)legend * 512;
1695
+ char* chart = (char*)calloc(total_size, 1);
1696
+ if (chart == NULL) return NULL;
1697
+
1698
+ char* w = chart;
1699
+ for (int y = height - 1; y >= 0; y--) {
1700
+ for (int x = 0; x < width; x++) {
1701
+ const char* cell = columns + ((size_t)x * height + (size_t)y) * 32;
1702
+ size_t cl = strlen(cell);
1703
+ memcpy(w, cell, cl);
1704
+ w += cl;
1705
+ }
1706
+ *w++ = '\n';
1707
+ }
1708
+
1709
+ if (legend > 0) {
1710
+ char line[512];
1711
+ for (int i = 0; i < count; i++) {
1712
+ const char* label = CC_S(slices[i].label);
1713
+ double pct = slices[i].value / total * 100.0;
1714
+ switch (s->legend_format) {
1715
+ case CC_PIE_LEGEND_LABEL_PCT:
1716
+ snprintf(line, sizeof(line), "%s %.0f%%", label, pct);
1717
+ break;
1718
+ case CC_PIE_LEGEND_VALUE_PCT:
1719
+ snprintf(line, sizeof(line), "%g (%.0f%%)",
1720
+ slices[i].value, pct);
1721
+ break;
1722
+ case CC_PIE_LEGEND_LABEL:
1723
+ snprintf(line, sizeof(line), "%s", label);
1724
+ break;
1725
+ case CC_PIE_LEGEND_VALUE:
1726
+ default:
1727
+ if (s->show_pct) {
1728
+ snprintf(line, sizeof(line), "%s %g (%.0f%%)", label,
1729
+ slices[i].value, pct);
1730
+ } else {
1731
+ snprintf(line, sizeof(line), "%s %g", label,
1732
+ slices[i].value);
1733
+ }
1734
+ break;
1735
+ }
1736
+ size_t ll = strlen(line);
1737
+ memcpy(w, line, ll);
1738
+ w += ll;
1739
+ *w++ = '\n';
1740
+ }
1741
+ }
1742
+ *w = '\0';
1743
+ return chart;
1744
+ }
1745
+
1746
+ CC_INLINE char* cc_pie_create(const cc_pie_slice_t* slices, int count,
1747
+ int width, int height,
1748
+ const cc_pie_settings_t* settings) {
1749
+ if (slices == NULL || count <= 0 || !cc_dim_ok(width, height)) {
1750
+ return (char*)calloc(1, sizeof(char));
1751
+ }
1752
+ int i;
1753
+ double total = 0.0;
1754
+ for (i = 0; i < count; i++) {
1755
+ /* A plain positive-range check: NaN fails `> 0.0`, as do zero and
1756
+ * negative amounts. +inf and non-finite option doubles are rejected
1757
+ * upstream (wrapper/ABI), which keeps this header free of C99-only
1758
+ * `isfinite`. */
1759
+ if (!(slices[i].value > 0.0)) {
1760
+ return (char*)calloc(1, sizeof(char));
1761
+ }
1762
+ total += slices[i].value;
1763
+ }
1764
+
1765
+ cc_pie_settings_t s = {0};
1766
+ if (settings != NULL) {
1767
+ s.bg_color = settings->bg_color;
1768
+ s.colors = settings->colors;
1769
+ s.donut = settings->donut;
1770
+ s.show_legend = settings->show_legend;
1771
+ s.show_pct = settings->show_pct;
1772
+ s.slice_gap = settings->slice_gap;
1773
+ s.inner_radius_ratio = settings->inner_radius_ratio;
1774
+ s.legend_format = settings->legend_format;
1775
+ s.start_angle = settings->start_angle;
1776
+ s.counter_clockwise = settings->counter_clockwise;
1777
+ s.center_text = settings->center_text;
1778
+ }
1779
+
1780
+ /* Resolve the optional settings to concrete values. Negative doubles are
1781
+ * the "unspecified" sentinel: default to the library defaults. */
1782
+ if (s.slice_gap < 0.0) s.slice_gap = 0.0;
1783
+ if (s.legend_format < CC_PIE_LEGEND_VALUE ||
1784
+ s.legend_format > CC_PIE_LEGEND_LABEL) {
1785
+ s.legend_format = CC_PIE_LEGEND_VALUE;
1786
+ }
1787
+ if (s.start_angle < 0.0) s.start_angle = CC_PI / 2.0;
1788
+
1789
+ double* starts = (double*)malloc((size_t)count * sizeof(double));
1790
+ double* widths = (s.slice_gap > 0.0)
1791
+ ? (double*)malloc((size_t)count * sizeof(double)) : NULL;
1792
+ char* columns = (char*)malloc((size_t)width * (size_t)height * 32);
1793
+ if (starts == NULL || columns == NULL ||
1794
+ (s.slice_gap > 0.0 && widths == NULL)) {
1795
+ free(starts);
1796
+ free(widths);
1797
+ free(columns);
1798
+ return NULL;
1799
+ }
1800
+
1801
+ /* Sub-pixel space: 8 units per cell. The disk is a perfect circle in this
1802
+ * space, centered in the grid, with the radius taken from the smaller
1803
+ * dimension so it always fits. */
1804
+ double cx = (double)width * 4.0;
1805
+ double cy = (double)height * 4.0;
1806
+ double min_dim = (width < height) ? (double)width : (double)height;
1807
+ double outer_r = min_dim * 4.0;
1808
+ double inner_r;
1809
+ if (s.inner_radius_ratio < 0.0) {
1810
+ /* Unspecified: the donut flag's classic hollow (0.5) or a disk. */
1811
+ inner_r = s.donut ? outer_r * CC_PIE_DONUT_RADIUS_RATIO : 0.0;
1812
+ } else {
1813
+ double r = (s.inner_radius_ratio > 1.0) ? 1.0 : s.inner_radius_ratio;
1814
+ inner_r = outer_r * r;
1815
+ }
1816
+
1817
+ double angle = s.start_angle;
1818
+ for (i = 0; i < count; i++) {
1819
+ double frac = slices[i].value / total;
1820
+ starts[i] = angle;
1821
+ if (widths != NULL) {
1822
+ double w = frac * 2.0 * CC_PI - s.slice_gap;
1823
+ widths[i] = (w < 0.0) ? 0.0 : w;
1824
+ }
1825
+ angle += frac * 2.0 * CC_PI;
1826
+ }
1827
+
1828
+ for (int x = 0; x < width; x++) {
1829
+ double sx = (double)x * 8.0 + 4.0;
1830
+ for (int y = 0; y < height; y++) {
1831
+ double sy = (double)y * 8.0 + 4.0;
1832
+ double dx = (sx - cx) * CC_PIE_ASPECT; /* 1:1 with dy's terminal aspect */
1833
+ double dy = sy - cy;
1834
+ double dist = sqrt(dx * dx + dy * dy);
1835
+ char* cell = columns + ((size_t)x * height + (size_t)y) * 32;
1836
+
1837
+ if (dist > outer_r || (inner_r > 0.0 && dist < inner_r)) {
1838
+ if (s.bg_color != NULL) {
1839
+ snprintf(cell, 32, "%s %s", s.bg_color,
1840
+ cc_reset_for(s.bg_color, NULL));
1841
+ } else {
1842
+ snprintf(cell, 32, " ");
1843
+ }
1844
+ continue;
1845
+ }
1846
+
1847
+ /* counter_clockwise mirrors the horizontal axis, which reverses
1848
+ * the sweep (clockwise) while keeping slice 0 at 12 o'clock. */
1849
+ double probe_x = (s.counter_clockwise ? (cx - sx) : (sx - cx))
1850
+ * CC_PIE_ASPECT;
1851
+ double probe = atan2(dy, probe_x);
1852
+ int slice = (widths != NULL)
1853
+ ? cc_pie_find_slice_gap(starts, widths, count, probe)
1854
+ : cc_pie_find_slice(starts, count, probe);
1855
+ if (slice < 0) { /* in a slice gap: leave the cell blank */
1856
+ if (s.bg_color != NULL) {
1857
+ snprintf(cell, 32, "%s %s", s.bg_color,
1858
+ cc_reset_for(s.bg_color, NULL));
1859
+ } else {
1860
+ snprintf(cell, 32, " ");
1861
+ }
1862
+ continue;
1863
+ }
1864
+ const char* color = cc_pie_palette_color(&s, slice);
1865
+ snprintf(cell, 32, "%s%s%s", CC_S(color), CC_BLOCK_FULL,
1866
+ cc_reset_for(color, NULL));
1867
+ }
1868
+ }
1869
+
1870
+ /* center_text is drawn in the hollow center, centered on the row nearest
1871
+ * the vertical middle and truncated to the hole's width in cells. */
1872
+ if (inner_r > 0.0 && s.center_text != NULL && s.center_text[0] != '\0') {
1873
+ double best = 1.0e18;
1874
+ int y_center = 0;
1875
+ for (int y = 0; y < height; y++) {
1876
+ double sy = (double)y * 8.0 + 4.0;
1877
+ double d = (sy > cy) ? (sy - cy) : (cy - sy);
1878
+ if (d < best) {
1879
+ best = d;
1880
+ y_center = y;
1881
+ }
1882
+ }
1883
+ int x0 = -1;
1884
+ int x1 = -1;
1885
+ for (int x = 0; x < width; x++) {
1886
+ double sx = (double)x * 8.0 + 4.0;
1887
+ double d = (sx > cx) ? (sx - cx) : (cx - sx);
1888
+ /* Same aspect-scaled hollow test as the disk geometry. */
1889
+ if (d * CC_PIE_ASPECT < inner_r) {
1890
+ if (x0 < 0) x0 = x;
1891
+ x1 = x;
1892
+ }
1893
+ }
1894
+ if (x0 >= 0) {
1895
+ size_t tlen = strlen(s.center_text);
1896
+ int span = x1 - x0 + 1;
1897
+ int n = (span < (int)tlen) ? span : (int)tlen;
1898
+ int startcol = x0 + (span - n) / 2;
1899
+ for (int k = 0; k < n; k++) {
1900
+ char* cell = columns +
1901
+ ((size_t)(startcol + k) * height +
1902
+ (size_t)y_center) * 32;
1903
+ snprintf(cell, 32, "%c", s.center_text[k]);
1904
+ }
1905
+ }
1906
+ }
1907
+
1908
+ char* chart = cc_pie_assemble(width, height, columns, slices, count,
1909
+ total, &s);
1910
+ free(starts);
1911
+ free(widths);
1912
+ free(columns);
1913
+ return chart;
1914
+ }
1915
+
1916
+ /* ---------------------------- Histogram renderer ---------------------------
1917
+ * Samples are counted into equal-width bins across a [min, max] value
1918
+ * window; each bin / column is drawn as a vertical bar scaled to the
1919
+ * tallest bin and sampled at 8 sub-pixels per cell (cc_lower_eighth), so
1920
+ * bar tops are smooth like the line chart. When width >= bin_count a bin
1921
+ * may span several columns; when width < bin_count several bins are
1922
+ * aggregated into each column — a single deterministic long-arithmetic
1923
+ * mapping covers both. Outlier samples are clamped into the edge bins so a
1924
+ * single huge value cannot empty every other bin, and a flat window
1925
+ * (min == max) is widened by +-1 so the value-to-bin division never hits
1926
+ * zero. Non-finite samples are rejected upstream (wrapper/ABI); a NaN
1927
+ * window double is the "auto" sentinel that selects the data range. All
1928
+ * scratch buffers are heap-allocated (no VLAs) and freed on every path.
1929
+ * ------------------------------------------------------------------------- */
1930
+
1931
+ /* Joins the per-cell strings (columns[(x*height + y)*32 .. +32), y counted
1932
+ * from the bottom) into the final histogram string, printing rows
1933
+ * top-to-bottom. With show_prices it prepends an 8-column left margin
1934
+ * holding the max-count (top) / min-count (bottom) labels; with show_bins
1935
+ * it appends a value-axis footer row (window min left, window max right).
1936
+ * Allocates with a pointer cursor (no strcat re-scans); the caller frees
1937
+ * the result. */
1938
+ CC_INLINE char* cc_hist_assemble(int width, int height, char* columns,
1939
+ const cc_hist_settings_t* s,
1940
+ double wmin, double wmax,
1941
+ int max_count, int min_count) {
1942
+ int margin = s->show_prices ? 8 : 0;
1943
+ char max_label[16] = "";
1944
+ char min_label[16] = "";
1945
+ if (margin > 0) {
1946
+ snprintf(max_label, sizeof(max_label), "%8d", max_count);
1947
+ snprintf(min_label, sizeof(min_label), "%8d", min_count);
1948
+ }
1949
+
1950
+ char lo[16] = "";
1951
+ char hi[16] = "";
1952
+ int footer = 0;
1953
+ if (s->show_bins) {
1954
+ snprintf(lo, sizeof(lo), "%.2f", wmin);
1955
+ snprintf(hi, sizeof(hi), "%.2f", wmax);
1956
+ footer = 1;
1957
+ }
1958
+
1959
+ const int line_len = width + margin;
1960
+ const int rows = height + footer;
1961
+ /* Bounded by CC_MAX_CELLS: width*height <= 1e6, so this is <= ~32 MB. */
1962
+ size_t total_size = (size_t)line_len * (size_t)rows * 32 + (size_t)rows + 1;
1963
+ char* chart = (char*)calloc(total_size, 1);
1964
+ if (chart == NULL) return NULL;
1965
+
1966
+ char* w = chart;
1967
+ for (int y = height - 1; y >= 0; y--) {
1968
+ if (margin > 0) {
1969
+ char mlabel[16] = "";
1970
+ if (y == height - 1) snprintf(mlabel, sizeof(mlabel), "%8s", max_label);
1971
+ else if (y == 0) snprintf(mlabel, sizeof(mlabel), "%8s", min_label);
1972
+ else { memset(mlabel, ' ', 8); mlabel[8] = '\0'; }
1973
+ size_t ml = strlen(mlabel);
1974
+ memcpy(w, mlabel, ml);
1975
+ w += ml;
1976
+ }
1977
+ for (int x = 0; x < width; x++) {
1978
+ const char* cell = columns + ((size_t)x * height + (size_t)y) * 32;
1979
+ size_t cl = strlen(cell);
1980
+ memcpy(w, cell, cl);
1981
+ w += cl;
1982
+ }
1983
+ *w++ = '\n';
1984
+ }
1985
+
1986
+ if (footer) {
1987
+ for (int i = 0; i < line_len; i++) *w++ = ' ';
1988
+ size_t l0 = strlen(lo);
1989
+ if (l0 > (size_t)line_len) l0 = (size_t)line_len;
1990
+ size_t l1 = strlen(hi);
1991
+ if (l1 > (size_t)line_len) l1 = (size_t)line_len;
1992
+ if (l0 > 0) memcpy(w - line_len, lo, l0);
1993
+ if (l1 > 0) memcpy(w - l1, hi, l1);
1994
+ *w++ = '\n';
1995
+ }
1996
+ *w = '\0';
1997
+ return chart;
1998
+ }
1999
+
2000
+ CC_INLINE char* cc_hist_create(const double* samples, int count,
2001
+ int width, int height,
2002
+ const cc_hist_settings_t* settings) {
2003
+ if (samples == NULL || count <= 0 || !cc_dim_ok(width, height)) {
2004
+ return (char*)calloc(1, sizeof(char));
2005
+ }
2006
+
2007
+ cc_hist_settings_t s;
2008
+ s.rise_color = (settings && settings->rise_color) ? settings->rise_color : CC_COLOR_GREEN;
2009
+ s.bg_color = settings ? settings->bg_color : NULL;
2010
+ s.bin_count = settings ? settings->bin_count : 0;
2011
+ /* NaN is the "auto" sentinel for the window doubles (0.0 is meaningful). */
2012
+ s.min_value = (settings && settings->min_value == settings->min_value)
2013
+ ? settings->min_value : CC_NAN;
2014
+ s.max_value = (settings && settings->max_value == settings->max_value)
2015
+ ? settings->max_value : CC_NAN;
2016
+ s.show_bins = settings ? settings->show_bins : 0;
2017
+ s.show_prices = settings ? settings->show_prices : 0;
2018
+
2019
+ double dmin = samples[0];
2020
+ double dmax = samples[0];
2021
+ for (int i = 1; i < count; i++) {
2022
+ if (samples[i] < dmin) dmin = samples[i];
2023
+ if (samples[i] > dmax) dmax = samples[i];
2024
+ }
2025
+
2026
+ /* The value window used for binning. A valid explicit window ALWAYS
2027
+ * has min < max; anything else — the NaN auto-sentinels, a partial
2028
+ * {0} initializer that yields 0.0/0.0, or an inverted range — falls
2029
+ * back to the data min/max so a brace-init {0} histogram auto-scales
2030
+ * and renders multiple bars instead of fixing a single-bar [0,0] window. */
2031
+ double wmin, wmax;
2032
+ if (s.min_value < s.max_value) {
2033
+ wmin = s.min_value;
2034
+ wmax = s.max_value;
2035
+ } else {
2036
+ wmin = dmin;
2037
+ wmax = dmax;
2038
+ if (wmax == wmin) { /* all samples equal: widen the flat window */
2039
+ wmin -= 1.0;
2040
+ wmax += 1.0;
2041
+ }
2042
+ }
2043
+ double range = wmax - wmin;
2044
+
2045
+ /* Auto-select the bin count when <= 0: 20 for busy sequences, 10
2046
+ * otherwise, and never more bins than the chart has columns. */
2047
+ int bins = s.bin_count;
2048
+ if (bins <= 0) {
2049
+ bins = (count >= 40) ? 20 : 10;
2050
+ }
2051
+ if (bins > width) bins = width;
2052
+ if (bins < 1) bins = 1;
2053
+
2054
+ int* counts = (int*)calloc((size_t)bins, sizeof(int));
2055
+ int* colv = (int*)calloc((size_t)width, sizeof(int));
2056
+ char* columns = (char*)malloc((size_t)width * (size_t)height * 32);
2057
+ if (counts == NULL || colv == NULL || columns == NULL) {
2058
+ free(counts); free(colv); free(columns);
2059
+ return NULL;
2060
+ }
2061
+
2062
+ for (int i = 0; i < count; i++) {
2063
+ double t = (samples[i] - wmin) / range;
2064
+ int b = (int)(t * bins);
2065
+ if (b < 0) b = 0;
2066
+ if (b >= bins) b = bins - 1;
2067
+ counts[b]++;
2068
+ }
2069
+
2070
+ /* Fold the bins into `width` columns. The same long-arithmetic mapping
2071
+ * covers both decompositions: width >= bins gives each bin several
2072
+ * columns (aggregating the single bin's count), width < bins aggregates
2073
+ * several bins per column. */
2074
+ for (int w = 0; w < width; w++) {
2075
+ int bs = (int)(((long long)w * bins) / width);
2076
+ int be = (int)((((long long)w + 1) * bins) / width);
2077
+ if (be <= bs) be = bs + 1;
2078
+ int sum = 0;
2079
+ for (int b = bs; b < be && b < bins; b++) sum += counts[b];
2080
+ colv[w] = sum;
2081
+ }
2082
+
2083
+ int max_count = 0;
2084
+ int min_count = colv[0];
2085
+ for (int w = 0; w < width; w++) {
2086
+ if (colv[w] > max_count) max_count = colv[w];
2087
+ if (colv[w] < min_count) min_count = colv[w];
2088
+ }
2089
+
2090
+ const char* color = s.rise_color;
2091
+ int pixel_height = height * 8;
2092
+
2093
+ for (int w = 0; w < width; w++) {
2094
+ int pix;
2095
+ if (max_count <= 0) pix = 0;
2096
+ else pix = (int)((double)colv[w] * pixel_height / max_count);
2097
+ if (pix < 0) pix = 0;
2098
+ if (pix > pixel_height) pix = pixel_height;
2099
+
2100
+ int nfull = pix / 8;
2101
+ int rem = pix % 8;
2102
+ char* col = columns + (size_t)w * height * 32;
2103
+
2104
+ for (int r = 0; r < nfull && r < height; r++) {
2105
+ snprintf(col + (size_t)r * 32, 32, "%s%s%s", CC_S(color),
2106
+ CC_BLOCK_FULL, cc_reset_for(color, NULL));
2107
+ }
2108
+ if (rem > 0 && nfull < height) {
2109
+ snprintf(col + (size_t)nfull * 32, 32, "%s%s%s", CC_S(color),
2110
+ cc_lower_eighth(rem), cc_reset_for(color, NULL));
2111
+ }
2112
+ for (int r = (rem > 0 ? nfull + 1 : nfull); r < height; r++) {
2113
+ char* cell = col + (size_t)r * 32;
2114
+ if (s.bg_color != NULL) {
2115
+ snprintf(cell, 32, "%s %s", s.bg_color,
2116
+ cc_reset_for(s.bg_color, NULL));
2117
+ } else {
2118
+ snprintf(cell, 32, " ");
2119
+ }
2120
+ }
2121
+ }
2122
+
2123
+ char* chart = cc_hist_assemble(width, height, columns, &s,
2124
+ wmin, wmax, max_count, min_count);
2125
+ free(counts); free(colv); free(columns);
2126
+ return chart;
2127
+ }
2128
+
2129
+ /* ---------------------------- Sparkline renderer ---------------------------
2130
+ * An axis-less trend line. For each output column, average the samples that
2131
+ * fall into that column (exactly the line chart's downsampling mapping, so
2132
+ * width < count aggregates and width >= count spreads), map the column
2133
+ * average to an 8-sub-pixel row, then draw the column exactly like the line
2134
+ * chart: cells below the (current,next) line segment get the optional area
2135
+ * fill (or blank), the top partial cell uses cc_lower_eighth, and rows are
2136
+ * assembled top-to-bottom with no margin, footer or legend. min_above /
2137
+ * min_below reserve sub-pixels at the plot's top/bottom edges so the line
2138
+ * does not clip against the very edge at tiny heights. A flat (all-equal)
2139
+ * series is centered (cc_pixel semantics: range 0 -> middle), so there is
2140
+ * no divide-by-zero. Non-finite samples are rejected upstream (wrapper/ABI).
2141
+ * All scratch buffers are heap-allocated (no VLAs), sizeof(char) calloc on
2142
+ * bad args, and freed on every path. ------------------------------------------------------------------------- */
2143
+
2144
+ /* Joins the per-cell strings (columns[(x*height + y)*32 .. +32), y counted
2145
+ * from the bottom) into the final sparkline string, printing rows
2146
+ * top-to-bottom with a trailing newline per row and no margin/footer/legend.
2147
+ * Allocates with a pointer cursor (no strcat re-scans); caller frees. */
2148
+ CC_INLINE char* cc_spark_assemble(int width, int height, char* columns) {
2149
+ /* Bounded by CC_MAX_CELLS: width*height <= 1e6, so this is <= ~32 MB. */
2150
+ size_t total_size = (size_t)width * (size_t)height * 32 + (size_t)height + 1;
2151
+ char* chart = (char*)calloc(total_size, 1);
2152
+ if (chart == NULL) return NULL;
2153
+
2154
+ char* w = chart;
2155
+ for (int y = height - 1; y >= 0; y--) {
2156
+ for (int x = 0; x < width; x++) {
2157
+ const char* cell = columns + ((size_t)x * height + (size_t)y) * 32;
2158
+ size_t cl = strlen(cell);
2159
+ memcpy(w, cell, cl);
2160
+ w += cl;
2161
+ }
2162
+ *w++ = '\n';
2163
+ }
2164
+ *w = '\0';
2165
+ return chart;
2166
+ }
2167
+
2168
+ CC_INLINE char* cc_spark_create(const double* samples, int count,
2169
+ int width, int height,
2170
+ const cc_spark_settings_t* settings) {
2171
+ if (samples == NULL || count <= 0 || !cc_dim_ok(width, height)) {
2172
+ return (char*)calloc(1, sizeof(char));
2173
+ }
2174
+
2175
+ cc_spark_settings_t s;
2176
+ /* min_above/min_below are plain ints: a partial {0} brace initializer or
2177
+ * a NULL settings both yield 0, so no sentinel ambiguity (unlike the
2178
+ * histogram's double window fields). */
2179
+ s.rise_color = (settings && settings->rise_color) ? settings->rise_color : CC_COLOR_GREEN;
2180
+ s.area_color = (settings && settings->area_color) ? settings->area_color : NULL;
2181
+ s.min_above = settings ? settings->min_above : 0;
2182
+ s.min_below = settings ? settings->min_below : 0;
2183
+ if (s.min_above < 0) s.min_above = 0;
2184
+ if (s.min_below < 0) s.min_below = 0;
2185
+
2186
+ double* vals = (double*)calloc((size_t)width, sizeof(double));
2187
+ int* py = (int*)malloc((size_t)width * sizeof(int));
2188
+ char* columns = (char*)malloc((size_t)width * (size_t)height * 32);
2189
+ if (vals == NULL || py == NULL || columns == NULL) {
2190
+ free(vals); free(py); free(columns);
2191
+ return NULL;
2192
+ }
2193
+
2194
+ /* Column averages, exactly the line chart's downsampling mapping. */
2195
+ for (int w = 0; w < width; w++) {
2196
+ int start_idx = (int)(((long long)w * count) / width);
2197
+ int end_idx = (int)((((long long)w + 1) * count) / width);
2198
+ if (end_idx <= start_idx) end_idx = start_idx + 1;
2199
+
2200
+ double sum = 0.0;
2201
+ for (int k = start_idx; k < end_idx && k < count; k++) {
2202
+ sum += samples[k];
2203
+ }
2204
+ vals[w] = sum / (end_idx - start_idx);
2205
+ }
2206
+
2207
+ double min = find_min(vals, width);
2208
+ double max = find_max(vals, width);
2209
+ double range = max - min;
2210
+
2211
+ int pixel_height = height * 8;
2212
+ int span = pixel_height - s.min_above - s.min_below;
2213
+ if (span < 1) span = 1;
2214
+
2215
+ for (int i = 0; i < width; i++) {
2216
+ /* Mirrors cc_pixel but reserves the top/bottom sub-pixel margins.
2217
+ * range == 0 (flat series) centers the line in the usable band. */
2218
+ double t = (range == 0.0) ? 0.5 : (vals[i] - min) / range;
2219
+ int p = (int)lround(s.min_above + t * (span - 1));
2220
+ if (p < 0) p = 0;
2221
+ if (p >= pixel_height) p = pixel_height - 1;
2222
+ py[i] = p;
2223
+ }
2224
+
2225
+ for (int i = 0; i < width; i++) {
2226
+ int lo = py[i];
2227
+ int hi = py[i];
2228
+ if (i + 1 < width) {
2229
+ if (py[i+1] < lo) lo = py[i+1];
2230
+ if (py[i+1] > hi) hi = py[i+1];
2231
+ }
2232
+
2233
+ int cell_lo = lo / 8;
2234
+ int cell_hi = hi / 8;
2235
+ char* col = columns + (size_t)i * height * 32;
2236
+
2237
+ /* Below the line segment: area fill or blank. */
2238
+ for (int r = 0; r < cell_lo && r < height; r++) {
2239
+ char* cell = col + (size_t)r * 32;
2240
+ if (s.area_color != NULL) {
2241
+ snprintf(cell, 32, "%s %s", s.area_color,
2242
+ cc_reset_for(s.area_color, NULL));
2243
+ } else {
2244
+ snprintf(cell, 32, " ");
2245
+ }
2246
+ }
2247
+
2248
+ /* Line body: full blocks between columns' pixels. */
2249
+ for (int r = cell_lo; r < cell_hi && r < height; r++) {
2250
+ snprintf(col + (size_t)r * 32, 32, "%s%s%s", CC_S(s.rise_color),
2251
+ CC_BLOCK_FULL, cc_reset_for(s.rise_color, NULL));
2252
+ }
2253
+
2254
+ /* Top partial cell: smooth 8-level top. */
2255
+ if (cell_hi >= 0 && cell_hi < height) {
2256
+ int count8 = hi - cell_hi * 8 + 1;
2257
+ if (count8 > 8) count8 = 8;
2258
+ snprintf(col + (size_t)cell_hi * 32, 32, "%s%s%s",
2259
+ CC_S(s.rise_color), cc_lower_eighth(count8),
2260
+ cc_reset_for(s.rise_color, NULL));
2261
+ }
2262
+
2263
+ /* Above the line: blank. */
2264
+ for (int r = cell_hi + 1; r < height; r++) {
2265
+ snprintf(col + (size_t)r * 32, 32, " ");
2266
+ }
2267
+ }
2268
+
2269
+ char* chart = cc_spark_assemble(width, height, columns);
2270
+ free(vals); free(py); free(columns);
2271
+ return chart;
2272
+ }
2273
+
2274
+ /* ------------------------------ Bar renderer ------------------------------
2275
+ * An ordered list of (label, value) pairs drawn as vertical bars from a
2276
+ * shared zero baseline. Each output column maps to a deterministic span of
2277
+ * items via the same long-arithmetic mapping as the histogram and sparkline
2278
+ * (width >= count spreads an item over several columns; width < count folds
2279
+ * several items into one column, drawn at their max value). The tallest bar
2280
+ * fills the full height; heights use 8 sub-pixel rows (cc_lower_eighth tops)
2281
+ * like the line chart. Values are non-negative for now — a negative value is
2282
+ * clamped to zero — and non-finite values are rejected upstream (wrapper/ABI)
2283
+ * so this header stays free of C99-only `isfinite`. Scratch buffers are
2284
+ * heap-allocated (no VLAs), sizeof(char) calloc on bad args, freed on every
2285
+ * path. ------------------------------------------------------------------- */
2286
+
2287
+ /* Joins the per-cell strings (columns[(x*height + y)*32 .. +32), y counted
2288
+ * from the bottom) into the final bar string, printing rows top-to-bottom.
2289
+ * An optional 8-column value axis (show_prices: max value top, 0 bottom) is
2290
+ * prepended to every row, and an optional label footer (show_labels) is
2291
+ * appended below the plot: for each column, the label of the first item in
2292
+ * that column's span, truncated to the column width. Allocates with a pointer
2293
+ * cursor (no strcat re-scans); caller frees. */
2294
+ CC_INLINE char* cc_bar_assemble(int width, int height, char* columns,
2295
+ const cc_bar_item_t* items, int count,
2296
+ const cc_bar_settings_t* s,
2297
+ double max_value,
2298
+ const int* first_idx, const int* span_len) {
2299
+ int margin = s->show_prices ? 8 : 0;
2300
+ char max_label[16] = "";
2301
+ char min_label[16] = "";
2302
+ if (margin > 0) {
2303
+ snprintf(max_label, sizeof(max_label), "%8.4g", max_value);
2304
+ snprintf(min_label, sizeof(min_label), "%8.0f", 0.0);
2305
+ }
2306
+
2307
+ int footer = s->show_labels ? 1 : 0;
2308
+ const int line_len = width + margin;
2309
+ const int rows = height + footer;
2310
+ /* Bounded by CC_MAX_CELLS: width*height <= 1e6, so this is <= ~32 MB. */
2311
+ size_t total_size = (size_t)line_len * (size_t)rows * 32 + (size_t)rows + 1;
2312
+ char* chart = (char*)calloc(total_size, 1);
2313
+ if (chart == NULL) return NULL;
2314
+
2315
+ char* w = chart;
2316
+ for (int y = height - 1; y >= 0; y--) {
2317
+ if (margin > 0) {
2318
+ char mlabel[16] = "";
2319
+ if (y == height - 1) snprintf(mlabel, sizeof(mlabel), "%8s", max_label);
2320
+ else if (y == 0) snprintf(mlabel, sizeof(mlabel), "%8s", min_label);
2321
+ else { memset(mlabel, ' ', 8); mlabel[8] = '\0'; }
2322
+ size_t ml = strlen(mlabel);
2323
+ memcpy(w, mlabel, ml);
2324
+ w += ml;
2325
+ }
2326
+ for (int x = 0; x < width; x++) {
2327
+ const char* cell = columns + ((size_t)x * height + (size_t)y) * 32;
2328
+ size_t cl = strlen(cell);
2329
+ memcpy(w, cell, cl);
2330
+ w += cl;
2331
+ }
2332
+ *w++ = '\n';
2333
+ }
2334
+
2335
+ if (footer) {
2336
+ if (margin > 0) {
2337
+ memset(w, ' ', (size_t)margin);
2338
+ w += margin;
2339
+ }
2340
+ for (int x = 0; x < width; x++) {
2341
+ int span = span_len[x];
2342
+ if (span < 1) span = 1;
2343
+ const char* label = items[first_idx[x]].label;
2344
+ const char* L = (label != NULL) ? label : "";
2345
+ size_t n = strlen(L);
2346
+ if (n > (size_t)span) n = (size_t)span;
2347
+ if (n > 0) { memcpy(w, L, n); w += n; }
2348
+ if ((size_t)span > n) {
2349
+ memset(w, ' ', (size_t)span - n);
2350
+ w += (size_t)span - n;
2351
+ }
2352
+ }
2353
+ *w++ = '\n';
2354
+ }
2355
+ *w = '\0';
2356
+ (void)count;
2357
+ return chart;
2358
+ }
2359
+
2360
+ CC_INLINE char* cc_bar_create(const cc_bar_item_t* items, int count,
2361
+ int width, int height,
2362
+ const cc_bar_settings_t* settings) {
2363
+ if (items == NULL || count <= 0 || !cc_dim_ok(width, height)) {
2364
+ return (char*)calloc(1, sizeof(char));
2365
+ }
2366
+
2367
+ cc_bar_settings_t s;
2368
+ /* All pointer/int fields: a partial {0} brace initializer matches NULL,
2369
+ * so there is no sentinel ambiguity (unlike the histogram's doubles). */
2370
+ s.rise_color = (settings && settings->rise_color) ? settings->rise_color : CC_COLOR_GREEN;
2371
+ s.bg_color = settings ? settings->bg_color : NULL;
2372
+ s.show_labels = settings ? settings->show_labels : 0;
2373
+ s.show_prices = settings ? settings->show_prices : 0;
2374
+ if (s.show_labels < 0) s.show_labels = 0;
2375
+ if (s.show_prices < 0) s.show_prices = 0;
2376
+
2377
+ double* colv = (double*)calloc((size_t)width, sizeof(double));
2378
+ int* first_idx = (int*)malloc((size_t)width * sizeof(int));
2379
+ int* span_len = (int*)malloc((size_t)width * sizeof(int));
2380
+ char* columns = (char*)malloc((size_t)width * (size_t)height * 32);
2381
+ if (colv == NULL || first_idx == NULL || span_len == NULL || columns == NULL) {
2382
+ free(colv); free(first_idx); free(span_len); free(columns);
2383
+ return NULL;
2384
+ }
2385
+
2386
+ /* Fold the items into `width` columns. The same long-arithmetic mapping
2387
+ * covers both decompositions: width >= count spreads each item over
2388
+ * several columns (repeating its value), width < count aggregates several
2389
+ * items per column drawn at their max value (so a peak is never hidden by
2390
+ * a collapsing average). */
2391
+ double max_value = 0.0;
2392
+ for (int w = 0; w < width; w++) {
2393
+ int is = (int)(((long long)w * count) / width);
2394
+ int ie = (int)(((((long long)w + 1) * count) / width));
2395
+ if (ie <= is) ie = is + 1;
2396
+ if (ie > count) ie = count;
2397
+ first_idx[w] = is;
2398
+ span_len[w] = ie - is;
2399
+
2400
+ double v = 0.0; /* zero baseline; negative values clamp to 0 */
2401
+ for (int k = is; k < ie; k++) {
2402
+ double val = items[k].value;
2403
+ if (val < 0.0) val = 0.0;
2404
+ if (val > v) v = val;
2405
+ }
2406
+ colv[w] = v;
2407
+ if (v > max_value) max_value = v;
2408
+ }
2409
+
2410
+ const char* color = s.rise_color;
2411
+ int pixel_height = height * 8;
2412
+
2413
+ for (int w = 0; w < width; w++) {
2414
+ int pix;
2415
+ if (max_value <= 0.0) pix = 0;
2416
+ else pix = (int)(colv[w] * pixel_height / max_value);
2417
+ if (pix < 0) pix = 0;
2418
+ if (pix > pixel_height) pix = pixel_height;
2419
+
2420
+ int nfull = pix / 8;
2421
+ int rem = pix % 8;
2422
+ char* col = columns + (size_t)w * height * 32;
2423
+
2424
+ for (int r = 0; r < nfull && r < height; r++) {
2425
+ snprintf(col + (size_t)r * 32, 32, "%s%s%s", CC_S(color),
2426
+ CC_BLOCK_FULL, cc_reset_for(color, NULL));
2427
+ }
2428
+ if (rem > 0 && nfull < height) {
2429
+ snprintf(col + (size_t)nfull * 32, 32, "%s%s%s", CC_S(color),
2430
+ cc_lower_eighth(rem), cc_reset_for(color, NULL));
2431
+ }
2432
+ for (int r = (rem > 0 ? nfull + 1 : nfull); r < height; r++) {
2433
+ char* cell = col + (size_t)r * 32;
2434
+ if (s.bg_color != NULL) {
2435
+ snprintf(cell, 32, "%s %s", s.bg_color,
2436
+ cc_reset_for(s.bg_color, NULL));
2437
+ } else {
2438
+ snprintf(cell, 32, " ");
2439
+ }
2440
+ }
2441
+ }
2442
+
2443
+ char* chart = cc_bar_assemble(width, height, columns, items, count, &s,
2444
+ max_value, first_idx, span_len);
2445
+ free(colv); free(first_idx); free(span_len); free(columns);
2446
+ return chart;
2447
+ }
2448
+
2449
+ /* --------------------------- Stacked bar renderer -------------------------
2450
+ * A stacked bar is a vertical stack of segments (one per series) per
2451
+ * category; the category's bar height is the SUM of its series' values. All
2452
+ * series share one category count (settings->cats). Within a column the
2453
+ * segments are stacked bottom-to-top, proportional to each series'
2454
+ * value / the tallest stack total, drawn with 8 sub-pixel rows exactly like
2455
+ * the bar chart: every cell is colored by the series that sits on the stack's
2456
+ * surface at that cell, and the stack's top partial cell uses cc_lower_eighth
2457
+ * (so the final, tallest series' cell carries the top edge). Falls back to
2458
+ * the pie default palette or a per-series override. All scratch buffers are
2459
+ * heap-allocated (no VLAs) and freed on every path. */
2460
+
2461
+ CC_INLINE const char* cc_stack_palette_color(const cc_stack_settings_t* s,
2462
+ int series_idx) {
2463
+ if (s->colors != NULL) {
2464
+ int n = 0;
2465
+ while (s->colors[n] != NULL) n++;
2466
+ if (n > 0) return s->colors[series_idx % n];
2467
+ }
2468
+ return CC_PIE_DEFAULT_PALETTE[series_idx % CC_PIE_DEFAULT_PALETTE_COUNT];
2469
+ }
2470
+
2471
+ /* Assembles the plot columns plus optional value-axis margin and category
2472
+ * label footer into the returned malloc'd string (caller frees). `max_total`
2473
+ * is the tallest stack total for the value axis; `cat_labels` (may be NULL)
2474
+ * supplies each column's footer label from the first category in that
2475
+ * column's span, truncated to the column width like the bar footer. Allocates
2476
+ * with a pointer cursor; caller frees. */
2477
+ CC_INLINE char* cc_stack_assemble(int width, int height, char* columns,
2478
+ const cc_stack_settings_t* s,
2479
+ double max_total,
2480
+ const char* const* cat_labels,
2481
+ const int* first_idx, const int* span_len) {
2482
+ int margin = s->show_prices ? 8 : 0;
2483
+ char max_label[16] = "";
2484
+ char min_label[16] = "";
2485
+ if (margin > 0) {
2486
+ snprintf(max_label, sizeof(max_label), "%8.4g", max_total);
2487
+ snprintf(min_label, sizeof(min_label), "%8.0f", 0.0);
2488
+ }
2489
+
2490
+ int footer = s->show_labels ? 1 : 0;
2491
+ const int line_len = width + margin;
2492
+ const int rows = height + footer;
2493
+ /* Bounded by CC_MAX_CELLS: width*height <= 1e6, so this is <= ~32 MB. */
2494
+ size_t total_size = (size_t)line_len * (size_t)rows * 32 + (size_t)rows + 1;
2495
+ char* chart = (char*)calloc(total_size, 1);
2496
+ if (chart == NULL) return NULL;
2497
+
2498
+ char* w = chart;
2499
+ for (int y = height - 1; y >= 0; y--) {
2500
+ if (margin > 0) {
2501
+ char mlabel[16] = "";
2502
+ if (y == height - 1) snprintf(mlabel, sizeof(mlabel), "%8s", max_label);
2503
+ else if (y == 0) snprintf(mlabel, sizeof(mlabel), "%8s", min_label);
2504
+ else { memset(mlabel, ' ', 8); mlabel[8] = '\0'; }
2505
+ size_t ml = strlen(mlabel);
2506
+ memcpy(w, mlabel, ml);
2507
+ w += ml;
2508
+ }
2509
+ for (int x = 0; x < width; x++) {
2510
+ const char* cell = columns + ((size_t)x * height + (size_t)y) * 32;
2511
+ size_t cl = strlen(cell);
2512
+ memcpy(w, cell, cl);
2513
+ w += cl;
2514
+ }
2515
+ *w++ = '\n';
2516
+ }
2517
+
2518
+ if (footer) {
2519
+ if (margin > 0) {
2520
+ memset(w, ' ', (size_t)margin);
2521
+ w += margin;
2522
+ }
2523
+ for (int x = 0; x < width; x++) {
2524
+ int span = span_len[x];
2525
+ if (span < 1) span = 1;
2526
+ const char* label = (cat_labels != NULL) ? cat_labels[first_idx[x]] : NULL;
2527
+ const char* L = (label != NULL) ? label : "";
2528
+ size_t n = strlen(L);
2529
+ if (n > (size_t)span) n = (size_t)span;
2530
+ if (n > 0) { memcpy(w, L, n); w += n; }
2531
+ if ((size_t)span > n) {
2532
+ memset(w, ' ', (size_t)span - n);
2533
+ w += (size_t)span - n;
2534
+ }
2535
+ }
2536
+ *w++ = '\n';
2537
+ }
2538
+ *w = '\0';
2539
+ return chart;
2540
+ }
2541
+
2542
+ CC_INLINE char* cc_stack_create(const cc_stack_series_t* series, int series_count,
2543
+ int width, int height,
2544
+ const cc_stack_settings_t* settings) {
2545
+ /* The values lengths come only from settings->cats, so a NULL settings
2546
+ * (or cats <= 0) is a parsing failure, not a "use defaults" signal. */
2547
+ if (series == NULL || series_count <= 0 || !cc_dim_ok(width, height) ||
2548
+ settings == NULL || settings->cats <= 0) {
2549
+ return (char*)calloc(1, sizeof(char));
2550
+ }
2551
+
2552
+ int cats = settings->cats;
2553
+
2554
+ cc_stack_settings_t s;
2555
+ /* All pointer/int fields: a partial {0} brace initializer matches NULL,
2556
+ * so there is no sentinel ambiguity (unlike the histogram's doubles). */
2557
+ s.colors = settings->colors;
2558
+ s.bg_color = settings->bg_color;
2559
+ s.cat_labels = settings->cat_labels;
2560
+ s.series = settings->series;
2561
+ s.cats = cats;
2562
+ s.show_labels = (settings->show_labels < 0) ? 0 : settings->show_labels;
2563
+ s.show_prices = (settings->show_prices < 0) ? 0 : settings->show_prices;
2564
+
2565
+ double* segs = (double*)malloc((size_t)series_count * sizeof(double));
2566
+ double* cum = (double*)malloc(((size_t)series_count + 1) * sizeof(double));
2567
+ int* pix = (int*)malloc(((size_t)series_count + 1) * sizeof(int));
2568
+ double* coltot = (double*)malloc((size_t)width * sizeof(double));
2569
+ int* first_idx = (int*)malloc((size_t)width * sizeof(int));
2570
+ int* span_len = (int*)malloc((size_t)width * sizeof(int));
2571
+ char* columns = (char*)malloc((size_t)width * (size_t)height * 32);
2572
+ if (segs == NULL || cum == NULL || pix == NULL || coltot == NULL ||
2573
+ first_idx == NULL || span_len == NULL || columns == NULL) {
2574
+ free(segs); free(cum); free(pix); free(coltot);
2575
+ free(first_idx); free(span_len); free(columns);
2576
+ return NULL;
2577
+ }
2578
+
2579
+ /* Fold the categories into `width` columns. The same long-arithmetic
2580
+ * mapping covers both decompositions: width >= cats spreads each category
2581
+ * over several columns (repeating its totals), width < cats aggregates
2582
+ * several categories per column whose segment sums are ADDED (stacked
2583
+ * bars are about totals). */
2584
+ double max_total = 0.0;
2585
+ for (int w = 0; w < width; w++) {
2586
+ int is = (int)(((long long)w * cats) / width);
2587
+ int ie = (int)(((((long long)w + 1) * cats) / width));
2588
+ if (ie <= is) ie = is + 1;
2589
+ if (ie > cats) ie = cats;
2590
+ first_idx[w] = is;
2591
+ span_len[w] = ie - is;
2592
+
2593
+ double ct = 0.0; /* zero baseline; negative values clamp to 0 */
2594
+ for (int s_i = 0; s_i < series_count; s_i++) {
2595
+ const double* vv = series[s_i].values;
2596
+ double seg = 0.0;
2597
+ for (int c = is; c < ie; c++) {
2598
+ double val = vv[c];
2599
+ if (val < 0.0) val = 0.0;
2600
+ seg += val;
2601
+ }
2602
+ segs[s_i] = seg;
2603
+ ct += seg;
2604
+ }
2605
+ coltot[w] = ct;
2606
+ if (ct > max_total) max_total = ct;
2607
+ }
2608
+
2609
+ int pixel_height = height * 8;
2610
+ double scale = (max_total > 0.0) ? (double)pixel_height / max_total : 0.0;
2611
+
2612
+ for (int w = 0; w < width; w++) {
2613
+ /* Running top pixel after each series (cum[s+1]) — the boundary
2614
+ * between stacked segment s and s+1 in render pixels. */
2615
+ cum[0] = 0.0;
2616
+ for (int s_i = 0; s_i < series_count; s_i++) cum[s_i + 1] = cum[s_i] + segs[s_i];
2617
+ cum[series_count] = coltot[w]; /* exact, not a rounding drift */
2618
+ for (int s_i = 0; s_i <= series_count; s_i++) {
2619
+ pix[s_i] = (int)(cum[s_i] * scale);
2620
+ if (pix[s_i] < 0) pix[s_i] = 0;
2621
+ if (pix[s_i] > pixel_height) pix[s_i] = pixel_height;
2622
+ }
2623
+ int stack_top = pix[series_count];
2624
+
2625
+ char* col = columns + (size_t)w * height * 32;
2626
+ for (int r = 0; r < height; r++) {
2627
+ char* cell = col + (size_t)r * 32;
2628
+ int cell_lo = r * 8;
2629
+ if (stack_top <= cell_lo) {
2630
+ if (s.bg_color != NULL) {
2631
+ snprintf(cell, 32, "%s %s", s.bg_color,
2632
+ cc_reset_for(s.bg_color, NULL));
2633
+ } else {
2634
+ snprintf(cell, 32, " ");
2635
+ }
2636
+ continue;
2637
+ }
2638
+ /* The surface series: the segment that owns the topmost filled
2639
+ * pixel in this cell. pix is the running top after each series,
2640
+ * so the owning segment s satisfies pix[s] <= top_pixel <
2641
+ * pix[s+1]; zero-height (collapsed) series are skipped by the
2642
+ * loop naturally. */
2643
+ int cell_hi = r * 8 + 8;
2644
+ int top_pixel = (stack_top < cell_hi) ? (stack_top - 1) : (cell_hi - 1);
2645
+ int top_s = 0;
2646
+ while (top_s < series_count && pix[top_s + 1] <= top_pixel) top_s++;
2647
+ const char* color = cc_stack_palette_color(&s, top_s);
2648
+ int filled = stack_top - cell_lo;
2649
+ if (filled >= 8) {
2650
+ snprintf(cell, 32, "%s%s%s", CC_S(color), CC_BLOCK_FULL,
2651
+ cc_reset_for(color, NULL));
2652
+ } else {
2653
+ /* 1..7: the stack's top partial cell, in the surface series. */
2654
+ snprintf(cell, 32, "%s%s%s", CC_S(color), cc_lower_eighth(filled),
2655
+ cc_reset_for(color, NULL));
2656
+ }
2657
+ }
2658
+ }
2659
+
2660
+ char* chart = cc_stack_assemble(width, height, columns, &s, max_total,
2661
+ s.cat_labels, first_idx, span_len);
2662
+ free(segs); free(cum); free(pix); free(coltot);
2663
+ free(first_idx); free(span_len); free(columns);
2664
+ return chart;
2665
+ }
2666
+
2667
+ /* ------------------------------ Heatmap renderer --------------------------
2668
+ * A heatmap draws a 2-D matrix onto a grid, coloring every covered cell by
2669
+ * its value's position between the matrix min and max on the fixed ladder
2670
+ * (CC_HEAT_RAMP above). Grid row 0 is the TOP (matrix row 0); columns run
2671
+ * left to right (matrix column 0). Each covered cell block-averages the
2672
+ * matrix values in its span; cells the matrix does not reach (matrix smaller
2673
+ * than the grid) are background. All scratch buffers are heap-allocated (no
2674
+ * VLAs) and freed on every path. */
2675
+
2676
+ /* Returns the matrix row span [*ys, *ye) that output row `gy` (from the top)
2677
+ * covers, plus a non-zero `*covered` when that span is non-empty. When the
2678
+ * matrix has rows > height it is downsampled (block-average); otherwise each
2679
+ * matrix row occupies its own output row on top and the rest are background. */
2680
+ static void cc_heat_row_span(int rows, int height, int gy,
2681
+ int* ys, int* ye, int* covered) {
2682
+ if (rows <= height) {
2683
+ if (gy < rows) { *ys = gy; *ye = gy + 1; *covered = 1; }
2684
+ else { *ys = rows; *ye = rows; *covered = 0; }
2685
+ } else {
2686
+ *ys = (int)(((long long)gy * rows) / height);
2687
+ *ye = (int)(((long long)(gy + 1) * rows) / height);
2688
+ if (*ye <= *ys) *ye = *ys + 1;
2689
+ if (*ye > rows) *ye = rows;
2690
+ *covered = (*ys < rows) ? 1 : 0;
2691
+ }
2692
+ }
2693
+
2694
+ static void cc_heat_col_span(int cols, int width, int gx,
2695
+ int* xs, int* xe, int* covered) {
2696
+ if (cols <= width) {
2697
+ if (gx < cols) { *xs = gx; *xe = gx + 1; *covered = 1; }
2698
+ else { *xs = cols; *xe = cols; *covered = 0; }
2699
+ } else {
2700
+ *xs = (int)(((long long)gx * cols) / width);
2701
+ *xe = (int)(((long long)(gx + 1) * cols) / width);
2702
+ if (*xe <= *xs) *xe = *xs + 1;
2703
+ if (*xe > cols) *xe = cols;
2704
+ *covered = (*xs < cols) ? 1 : 0;
2705
+ }
2706
+ }
2707
+
2708
+ CC_INLINE char* cc_heat_create(const double* values, int rows, int cols,
2709
+ int width, int height,
2710
+ const cc_heat_settings_t* settings) {
2711
+ int gy, gx, i;
2712
+ if (values == NULL || rows <= 0 || cols <= 0 ||
2713
+ !cc_dim_ok(width, height)) {
2714
+ return (char*)calloc(1, sizeof(char));
2715
+ }
2716
+
2717
+ cc_heat_settings_t s;
2718
+ /* All pointer/int fields: a partial {0} brace initializer matches NULL,
2719
+ * so there is no sentinel ambiguity (unlike the histogram's doubles). */
2720
+ s.low_color = (settings && settings->low_color) ? settings->low_color : CC_COLOR_BRIGHT_BLACK;
2721
+ s.high_color = (settings && settings->high_color) ? settings->high_color : CC_COLOR_BRIGHT_WHITE;
2722
+ s.mid_color = settings ? settings->mid_color : NULL;
2723
+ s.bg_color = settings ? settings->bg_color : NULL;
2724
+ s.row_labels = settings ? settings->row_labels : NULL;
2725
+ s.col_labels = settings ? settings->col_labels : NULL;
2726
+ s.show_labels = (settings && settings->show_labels < 0) ? 0
2727
+ : (settings ? settings->show_labels : 0);
2728
+
2729
+ double dmin = values[0];
2730
+ double dmax = values[0];
2731
+ for (i = 1; i < rows * cols; i++) {
2732
+ if (values[i] < dmin) dmin = values[i];
2733
+ if (values[i] > dmax) dmax = values[i];
2734
+ }
2735
+ double range = dmax - dmin;
2736
+
2737
+ /* Resolve the ladder: fixed ramp, with the settings color endpoints
2738
+ * substituted and an optional mid color replacing the middle entry. */
2739
+ const char* ladder[CC_HEAT_RAMP_LEN];
2740
+ ladder[0] = CC_COLOR_BRIGHT_BLACK;
2741
+ ladder[1] = CC_COLOR_BLUE;
2742
+ ladder[2] = CC_COLOR_CYAN;
2743
+ ladder[3] = CC_COLOR_BRIGHT_CYAN;
2744
+ ladder[4] = CC_COLOR_GREEN;
2745
+ ladder[5] = CC_COLOR_YELLOW;
2746
+ ladder[6] = CC_COLOR_BRIGHT_YELLOW;
2747
+ ladder[7] = CC_COLOR_RED;
2748
+ ladder[8] = CC_COLOR_BRIGHT_RED;
2749
+ ladder[9] = CC_COLOR_BRIGHT_WHITE;
2750
+ ladder[0] = s.low_color;
2751
+ ladder[CC_HEAT_RAMP_LEN - 1] = s.high_color;
2752
+ if (s.mid_color != NULL) ladder[CC_HEAT_MID_INDEX] = s.mid_color;
2753
+ /* Plain mode mirrors the other charts' empty-color convention: an empty
2754
+ * low_color (what the Python plain=True path hands in) blanks the WHOLE
2755
+ * ladder so no ANSI escape from the fixed interior ramp leaks out. */
2756
+ if (s.low_color != NULL && s.low_color[0] == '\0') {
2757
+ for (i = 0; i < CC_HEAT_RAMP_LEN; i++) ladder[i] = "";
2758
+ }
2759
+
2760
+ char* columns = (char*)malloc((size_t)width * (size_t)height * 32);
2761
+ if (columns == NULL) return NULL;
2762
+
2763
+ /* Cells are indexed [(gy * width + gx) * 32], gy from the TOP, so the
2764
+ * assembly loop reads rows top-to-bottom in matrix order. */
2765
+ for (gy = 0; gy < height; gy++) {
2766
+ int ys, ye, ycov;
2767
+ cc_heat_row_span(rows, height, gy, &ys, &ye, &ycov);
2768
+ for (gx = 0; gx < width; gx++) {
2769
+ int xs, xe, xcov;
2770
+ cc_heat_col_span(cols, width, gx, &xs, &xe, &xcov);
2771
+ char* cell = columns + ((size_t)gy * (size_t)width + (size_t)gx) * 32;
2772
+
2773
+ if (!ycov || !xcov) {
2774
+ if (s.bg_color != NULL) {
2775
+ snprintf(cell, 32, "%s %s", s.bg_color,
2776
+ cc_reset_for(s.bg_color, NULL));
2777
+ } else {
2778
+ snprintf(cell, 32, " ");
2779
+ }
2780
+ continue;
2781
+ }
2782
+
2783
+ /* Block-average the covered matrix cells. */
2784
+ double sum = 0.0;
2785
+ int r, c, n = 0;
2786
+ for (r = ys; r < ye; r++) {
2787
+ for (c = xs; c < xe; c++) {
2788
+ sum += values[(size_t)r * (size_t)cols + (size_t)c];
2789
+ n++;
2790
+ }
2791
+ }
2792
+ if (n < 1) continue;
2793
+ double avg = sum / (double)n;
2794
+
2795
+ double rr = (range > 0.0) ? (avg - dmin) / range : 0.0;
2796
+ if (rr < 0.0) rr = 0.0;
2797
+ if (rr > 1.0) rr = 1.0;
2798
+ int idx = (int)(rr * (CC_HEAT_RAMP_LEN - 1));
2799
+ if (idx >= CC_HEAT_RAMP_LEN) idx = CC_HEAT_RAMP_LEN - 1;
2800
+ const char* color = ladder[idx];
2801
+ snprintf(cell, 32, "%s%s%s", CC_S(color), CC_BLOCK_FULL,
2802
+ cc_reset_for(color, NULL));
2803
+ }
2804
+ }
2805
+
2806
+ /* Label frame: an optional left row-label margin and an optional footer
2807
+ * row of column labels, both gated by show_labels, mirroring the bar/
2808
+ * stack footer (each label truncated to its column span). */
2809
+ int rmargin = 0;
2810
+ if (s.show_labels && s.row_labels != NULL) {
2811
+ for (i = 0; i < rows; i++) {
2812
+ if (s.row_labels[i] != NULL) {
2813
+ size_t n = strlen(s.row_labels[i]);
2814
+ if (n > (size_t)rmargin) rmargin = (int)n;
2815
+ }
2816
+ }
2817
+ if (rmargin > 255) rmargin = 255; /* fixed label-margin cap */
2818
+ }
2819
+ int footer = (s.show_labels && s.col_labels != NULL) ? 1 : 0;
2820
+ const int line_len = width + rmargin;
2821
+ const int rows_out = height + footer;
2822
+ /* Bounded by CC_MAX_CELLS: width*height <= 1e6. */
2823
+ size_t total = (size_t)line_len * (size_t)rows_out * 32 + (size_t)rows_out + 1;
2824
+ char* chart = (char*)calloc(total, 1);
2825
+ if (chart == NULL) {
2826
+ free(columns);
2827
+ return NULL;
2828
+ }
2829
+
2830
+ char* w = chart;
2831
+ for (gy = 0; gy < height; gy++) {
2832
+ if (rmargin > 0) {
2833
+ int ys, ye, ycov;
2834
+ cc_heat_row_span(rows, height, gy, &ys, &ye, &ycov);
2835
+ char lbl[256] = "";
2836
+ if (ycov && s.row_labels != NULL && s.row_labels[ys] != NULL) {
2837
+ snprintf(lbl, sizeof(lbl), "%*s", rmargin, s.row_labels[ys]);
2838
+ } else {
2839
+ memset(lbl, ' ', (size_t)rmargin);
2840
+ lbl[rmargin] = '\0';
2841
+ }
2842
+ memcpy(w, lbl, (size_t)rmargin);
2843
+ w += (size_t)rmargin;
2844
+ }
2845
+ for (gx = 0; gx < width; gx++) {
2846
+ const char* cell = columns + ((size_t)gy * (size_t)width + (size_t)gx) * 32;
2847
+ size_t cl = strlen(cell);
2848
+ memcpy(w, cell, cl);
2849
+ w += cl;
2850
+ }
2851
+ *w++ = '\n';
2852
+ }
2853
+
2854
+ if (footer) {
2855
+ if (rmargin > 0) {
2856
+ memset(w, ' ', (size_t)rmargin);
2857
+ w += (size_t)rmargin;
2858
+ }
2859
+ for (gx = 0; gx < width; gx++) {
2860
+ int xs, xe, xcov;
2861
+ cc_heat_col_span(cols, width, gx, &xs, &xe, &xcov);
2862
+ if (!xcov) { *w++ = ' '; continue; }
2863
+ int span = xe - xs;
2864
+ if (span < 1) span = 1;
2865
+ const char* T = (s.col_labels != NULL && s.col_labels[xs] != NULL)
2866
+ ? s.col_labels[xs] : "";
2867
+ size_t n = strlen(T);
2868
+ if (n > (size_t)span) n = (size_t)span;
2869
+ if (n > 0) { memcpy(w, T, n); w += n; }
2870
+ if ((size_t)span > n) {
2871
+ memset(w, ' ', (size_t)span - n);
2872
+ w += (size_t)span - n;
2873
+ }
2874
+ }
2875
+ *w++ = '\n';
2876
+ }
2877
+ *w = '\0';
2878
+ free(columns);
2879
+ return chart;
2880
+ }
2881
+
2882
+ /* ------------------------------- Box renderer ----------------------------
2883
+ * A box plot draws one five-number summary per category. The five values are
2884
+ * computed by nearest-rank (see cc_box_create's documented convention) from
2885
+ * a sorted copy of each category's samples; the global min/max across every
2886
+ * category span the value axis, and each box, its whiskers and its median
2887
+ * are placed at 8-sub-pixel rows. The horizontal placement and exact glyph
2888
+ * mapping are fully specified in cc_box_create. All scratch buffers are
2889
+ * heap-allocated (no VLAs) and freed on every path. */
2890
+
2891
+ /* A category's five-number summary (computed by cc_box_summary). */
2892
+ typedef struct cc_box_five {
2893
+ double lo; /* min */
2894
+ double q1; /* Q1 */
2895
+ double md; /* median */
2896
+ double q3; /* Q3 */
2897
+ double hi; /* max */
2898
+ } cc_box_five;
2899
+
2900
+ CC_INLINE int cc_box_cmp(const void* a, const void* b) {
2901
+ double x = *(const double*)a;
2902
+ double y = *(const double*)b;
2903
+ return (x > y) - (x < y);
2904
+ }
2905
+
2906
+ /* Sorts s[0..n-1] in place (the caller owns a copy) and returns the
2907
+ * nearest-rank five-number summary. The rank indices use integer arithmetic
2908
+ * so floor((n-1)*0.25) is exact: idx/4, idx/2, idx*3/4 for idx = n-1. */
2909
+ CC_INLINE cc_box_five cc_box_summary(double* s, int n) {
2910
+ cc_box_five f;
2911
+ long long idx = (long long)(n - 1);
2912
+ int q1i = (int)(idx / 4);
2913
+ int mdi = (int)(idx / 2);
2914
+ int q3i = (int)((idx * 3) / 4);
2915
+ qsort(s, (size_t)n, sizeof(double), cc_box_cmp);
2916
+ f.lo = s[0];
2917
+ f.q1 = s[q1i];
2918
+ f.md = s[mdi];
2919
+ f.q3 = s[q3i];
2920
+ f.hi = s[n - 1];
2921
+ return f;
2922
+ }
2923
+
2924
+ /* Maps a value to a 0-based sub-pixel row (bottom 0 .. pixel_height - 1),
2925
+ * scaling the full value span across the chart height. */
2926
+ CC_INLINE int cc_box_level(double v, double gmin, double gmax,
2927
+ int pixel_height) {
2928
+ if (gmax == gmin) return pixel_height / 2;
2929
+ double r = (v - gmin) / (gmax - gmin);
2930
+ int l = (int)(r * (pixel_height - 1));
2931
+ if (l < 0) l = 0;
2932
+ if (l >= pixel_height) l = pixel_height - 1;
2933
+ return l;
2934
+ }
2935
+
2936
+ /* Joins the per-cell strings (columns[(x*height + y)*32 .. +32), y counted
2937
+ * from the bottom) into the final box-string, printing rows top-to-bottom.
2938
+ * An optional 8-column value axis (show_prices: global max top, global min
2939
+ * bottom) is prepended to every row. Allocates with a pointer cursor (no
2940
+ * strcat re-scans); caller frees. */
2941
+ CC_INLINE char* cc_box_assemble(int width, int height, char* columns,
2942
+ const cc_box_settings_t* s,
2943
+ double gmin, double gmax) {
2944
+ int margin = s->show_prices ? 8 : 0;
2945
+ char max_label[16] = "";
2946
+ char min_label[16] = "";
2947
+ if (margin > 0) {
2948
+ snprintf(max_label, sizeof(max_label), "%8.4g", gmax);
2949
+ snprintf(min_label, sizeof(min_label), "%8.4g", gmin);
2950
+ }
2951
+ const int line_len = width + margin;
2952
+ /* Bounded by CC_MAX_CELLS: width*height <= 1e6, so this is <= ~32 MB. */
2953
+ size_t total_size = (size_t)line_len * (size_t)height * 32 + (size_t)height + 1;
2954
+ char* chart = (char*)calloc(total_size, 1);
2955
+ if (chart == NULL) return NULL;
2956
+
2957
+ char* w = chart;
2958
+ for (int y = height - 1; y >= 0; y--) {
2959
+ if (margin > 0) {
2960
+ char mlabel[16] = "";
2961
+ if (y == height - 1) snprintf(mlabel, sizeof(mlabel), "%8s", max_label);
2962
+ else if (y == 0) snprintf(mlabel, sizeof(mlabel), "%8s", min_label);
2963
+ else { memset(mlabel, ' ', 8); mlabel[8] = '\0'; }
2964
+ size_t ml = strlen(mlabel);
2965
+ memcpy(w, mlabel, ml);
2966
+ w += ml;
2967
+ }
2968
+ for (int x = 0; x < width; x++) {
2969
+ const char* cell = columns + ((size_t)x * height + (size_t)y) * 32;
2970
+ size_t cl = strlen(cell);
2971
+ memcpy(w, cell, cl);
2972
+ w += cl;
2973
+ }
2974
+ *w++ = '\n';
2975
+ }
2976
+ *w = '\0';
2977
+ return chart;
2978
+ }
2979
+
2980
+ CC_INLINE char* cc_box_create(const cc_box_category_t* cats, int cat_count,
2981
+ int width, int height,
2982
+ const cc_box_settings_t* settings) {
2983
+ int i, w, r;
2984
+ long long off = 0;
2985
+ long long total = 0;
2986
+ if (cats == NULL || cat_count <= 0 || !cc_dim_ok(width, height)) {
2987
+ return (char*)calloc(1, sizeof(char));
2988
+ }
2989
+ /* A category with no samples has no well-defined five-number summary, so
2990
+ * one empty category empties the whole chart. */
2991
+ for (i = 0; i < cat_count; i++) {
2992
+ if (cats[i].samples == NULL || cats[i].n <= 0) {
2993
+ return (char*)calloc(1, sizeof(char));
2994
+ }
2995
+ total += cats[i].n;
2996
+ }
2997
+
2998
+ cc_box_settings_t s;
2999
+ /* All pointer/int fields: a partial {0} brace initializer matches NULL,
3000
+ * so there is no sentinel ambiguity (unlike the histogram's doubles). */
3001
+ s.rise_color = (settings && settings->rise_color) ? settings->rise_color
3002
+ : CC_COLOR_GREEN;
3003
+ s.area_color = (settings && settings->area_color &&
3004
+ settings->area_color[0] != '\0')
3005
+ ? settings->area_color : s.rise_color;
3006
+ s.bg_color = settings ? settings->bg_color : NULL;
3007
+ s.show_prices = settings ? settings->show_prices : 0;
3008
+ if (s.show_prices < 0) s.show_prices = 0;
3009
+
3010
+ double* scratch = (double*)malloc((size_t)total * sizeof(double));
3011
+ cc_box_five* fives = (cc_box_five*)malloc((size_t)cat_count * sizeof(cc_box_five));
3012
+ char* columns = (char*)malloc((size_t)width * (size_t)height * 32);
3013
+ if (scratch == NULL || fives == NULL || columns == NULL) {
3014
+ free(scratch); free(fives); free(columns);
3015
+ return NULL;
3016
+ }
3017
+
3018
+ double gmin = 0.0, gmax = 0.0;
3019
+ int have_minmax = 0;
3020
+ for (i = 0; i < cat_count; i++) {
3021
+ memcpy(scratch + off, cats[i].samples,
3022
+ (size_t)cats[i].n * sizeof(double));
3023
+ fives[i] = cc_box_summary(scratch + off, cats[i].n);
3024
+ if (!have_minmax || fives[i].lo < gmin) gmin = fives[i].lo;
3025
+ if (!have_minmax || fives[i].hi > gmax) gmax = fives[i].hi;
3026
+ have_minmax = 1;
3027
+ off += cats[i].n;
3028
+ }
3029
+
3030
+ int pixel_height = height * 8;
3031
+
3032
+ for (w = 0; w < width; w++) {
3033
+ int cs = (int)(((long long)w * cat_count) / width);
3034
+ cc_box_five f = fives[cs];
3035
+ int lo = cc_box_level(f.lo, gmin, gmax, pixel_height) / 8;
3036
+ int q1 = cc_box_level(f.q1, gmin, gmax, pixel_height) / 8;
3037
+ int md = cc_box_level(f.md, gmin, gmax, pixel_height) / 8;
3038
+ int q3 = cc_box_level(f.q3, gmin, gmax, pixel_height) / 8;
3039
+ int hi = cc_box_level(f.hi, gmin, gmax, pixel_height) / 8;
3040
+ int lq1 = cc_box_level(f.q1, gmin, gmax, pixel_height);
3041
+ int lq3 = cc_box_level(f.q3, gmin, gmax, pixel_height);
3042
+ const char* wc = s.area_color;
3043
+ const char* bc = s.rise_color;
3044
+ char* col = columns + (size_t)w * (size_t)height * 32;
3045
+ const char* reset_b = cc_reset_for(bc, NULL);
3046
+ const char* reset_w = cc_reset_for(wc, NULL);
3047
+
3048
+ for (r = 0; r < height; r++) {
3049
+ char* cell = col + (size_t)r * 32;
3050
+ int in_box = (q1 <= r && r <= q3);
3051
+ int in_whisker = (lo <= r && r <= hi);
3052
+ const char* boxglyph = CC_BLOCK_FULL;
3053
+
3054
+ if (in_box) {
3055
+ /* Median (rule 1) is a solid full line even on a partial
3056
+ * top edge; interior (rule 2) is a full block. */
3057
+ if (r == md) {
3058
+ boxglyph = CC_BLOCK_FULL;
3059
+ } else if (r == q3 && q1 < q3) {
3060
+ /* top edge (rule 3): the Q3 sub-pixel, precise. */
3061
+ boxglyph = cc_lower_eighth((lq3 % 8) + 1);
3062
+ } else if (r == q1 && q1 == q3) {
3063
+ /* single-row box (rule 4): Q1..Q3 height. */
3064
+ boxglyph = cc_lower_eighth(lq3 - lq1 + 1);
3065
+ } else {
3066
+ boxglyph = CC_BLOCK_FULL; /* bottom edge / interior */
3067
+ }
3068
+ snprintf(cell, 32, "%s%s%s", CC_S(bc), boxglyph, reset_b);
3069
+ } else if (in_whisker) {
3070
+ snprintf(cell, 32, "%s%s%s", CC_S(wc), CC_LINE_VERTICAL, reset_w);
3071
+ } else if (s.bg_color != NULL) {
3072
+ snprintf(cell, 32, "%s %s", s.bg_color,
3073
+ cc_reset_for(s.bg_color, NULL));
3074
+ } else {
3075
+ snprintf(cell, 32, " ");
3076
+ }
3077
+ }
3078
+ }
3079
+
3080
+ char* chart = cc_box_assemble(width, height, columns, &s, gmin, gmax);
3081
+ free(scratch);
3082
+ free(fives);
3083
+ free(columns);
3084
+ return chart;
3085
+ }
3086
+
3087
+ #endif /* CCHARTS_IMPLEMENTATION */
3088
+ #endif /* CCHARTS_H */