446 lines
19 KiB
C
446 lines
19 KiB
C
/**
|
|
* @file chart.c
|
|
* @brief Time-scaled high-resolution Braille chart renderer.
|
|
*/
|
|
|
|
#include "trainlog/chart.h"
|
|
|
|
#include <float.h>
|
|
#include <limits.h>
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
#include <stdarg.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#include "trainlog/presentation.h"
|
|
|
|
static void chart_surface_printf(TrainlogSurface *surface, int row, int column,
|
|
const char *format, ...)
|
|
{
|
|
va_list arguments;
|
|
char value[512];
|
|
va_start(arguments, format);
|
|
(void)trainlog_presentation_vformat(value, sizeof(value), format, arguments);
|
|
va_end(arguments);
|
|
trainlog_surface_printf(surface, row, column, "%s", value);
|
|
}
|
|
|
|
#define trainlog_surface_printf chart_surface_printf
|
|
|
|
#define CHART_LABEL_WIDTH 12
|
|
#define CHART_MIN_WIDTH 24
|
|
#define CHART_MIN_HEIGHT 6
|
|
|
|
static int clamp_coordinate(int value, int limit)
|
|
{
|
|
if (limit <= 1) return 0;
|
|
if (value < 0) return 0;
|
|
if (value >= limit) return limit - 1;
|
|
return value;
|
|
}
|
|
|
|
bool trainlog_chart_scale(const TrainlogChartPoint *points, size_t count,
|
|
TrainlogChartScale *scale)
|
|
{
|
|
return trainlog_chart_scale_with_options(points, count, NULL, scale);
|
|
}
|
|
|
|
bool trainlog_chart_scale_with_options(const TrainlogChartPoint *points,
|
|
size_t count, const TrainlogChartOptions *options,
|
|
TrainlogChartScale *scale)
|
|
{
|
|
size_t index;
|
|
double minimum;
|
|
double maximum;
|
|
double span;
|
|
double margin;
|
|
if (scale == NULL || (count > 0U && points == NULL) || count == 0U)
|
|
return false;
|
|
minimum = points[0].value;
|
|
maximum = minimum;
|
|
if (!isfinite(minimum)) return false;
|
|
for (index = 1U; index < count; ++index) {
|
|
if (!isfinite(points[index].value) ||
|
|
points[index].timestamp < points[index - 1U].timestamp)
|
|
return false;
|
|
if (points[index].value < minimum) minimum = points[index].value;
|
|
if (points[index].value > maximum) maximum = points[index].value;
|
|
}
|
|
span = maximum - minimum;
|
|
/* WHY: a modest 8% headroom keeps observations off the frame while the
|
|
* labelled scale prevents a visually amplified small change from hiding
|
|
* its actual magnitude. Constant series use 2% of their value (at least
|
|
* one unit total) solely to make their horizontal position defined. */
|
|
if (span > 0.0) margin = span * 0.08;
|
|
else {
|
|
margin = fabs(minimum) * 0.02;
|
|
if (margin < 0.5) margin = 0.5;
|
|
}
|
|
if (!isfinite(margin) || minimum < -DBL_MAX + margin ||
|
|
maximum > DBL_MAX - margin) return false;
|
|
scale->minimum_timestamp = options != NULL && options->has_time_domain
|
|
? options->minimum_timestamp : points[0].timestamp;
|
|
scale->maximum_timestamp = options != NULL && options->has_time_domain
|
|
? options->maximum_timestamp : points[count - 1U].timestamp;
|
|
if (scale->maximum_timestamp < scale->minimum_timestamp) return false;
|
|
scale->minimum_value = options != NULL && options->zero_baseline
|
|
? 0.0 : minimum - margin;
|
|
scale->maximum_value = maximum + margin;
|
|
if (options != NULL && options->non_negative && scale->minimum_value < 0.0)
|
|
scale->minimum_value = 0.0;
|
|
if (scale->maximum_value <= scale->minimum_value)
|
|
scale->maximum_value = scale->minimum_value + (maximum > 0.0 ? maximum * 0.08 : 1.0);
|
|
return true;
|
|
}
|
|
|
|
int trainlog_chart_map_x(const TrainlogChartScale *scale, int64_t timestamp,
|
|
int logical_width)
|
|
{
|
|
long double ratio;
|
|
if (scale == NULL || logical_width <= 1 ||
|
|
scale->maximum_timestamp <= scale->minimum_timestamp)
|
|
return logical_width > 1 ? (logical_width - 1) / 2 : 0;
|
|
ratio = ((long double)timestamp - (long double)scale->minimum_timestamp) /
|
|
((long double)scale->maximum_timestamp -
|
|
(long double)scale->minimum_timestamp);
|
|
return clamp_coordinate((int)llroundl(ratio * (logical_width - 1)),
|
|
logical_width);
|
|
}
|
|
|
|
int trainlog_chart_map_y(const TrainlogChartScale *scale, double value,
|
|
int logical_height)
|
|
{
|
|
double ratio;
|
|
if (scale == NULL || logical_height <= 1 ||
|
|
scale->maximum_value <= scale->minimum_value)
|
|
return logical_height > 1 ? (logical_height - 1) / 2 : 0;
|
|
ratio = (scale->maximum_value - value) /
|
|
(scale->maximum_value - scale->minimum_value);
|
|
return clamp_coordinate((int)llround(ratio * (logical_height - 1)),
|
|
logical_height);
|
|
}
|
|
|
|
uint8_t trainlog_chart_braille_dot(uint8_t cell, int subpixel_x,
|
|
int subpixel_y)
|
|
{
|
|
static const uint8_t dots[4][2] = {
|
|
{0x01U, 0x08U}, {0x02U, 0x10U}, {0x04U, 0x20U}, {0x40U, 0x80U}
|
|
};
|
|
if (subpixel_x < 0 || subpixel_x > 1 ||
|
|
subpixel_y < 0 || subpixel_y > 3) return cell;
|
|
return (uint8_t)(cell | dots[subpixel_y][subpixel_x]);
|
|
}
|
|
|
|
uint32_t trainlog_chart_braille_codepoint(uint8_t cell)
|
|
{
|
|
return 0x2800U + (uint32_t)cell;
|
|
}
|
|
|
|
static void raster_dot(uint8_t *cells, int cell_width, int cell_height,
|
|
int x, int y)
|
|
{
|
|
int cell_x;
|
|
int cell_y;
|
|
if (cells == NULL || x < 0 || y < 0 || x >= cell_width * 2 ||
|
|
y >= cell_height * 4) return;
|
|
cell_x = x / 2;
|
|
cell_y = y / 4;
|
|
cells[(size_t)cell_y * (size_t)cell_width + (size_t)cell_x] =
|
|
trainlog_chart_braille_dot(
|
|
cells[(size_t)cell_y * (size_t)cell_width + (size_t)cell_x],
|
|
x % 2, y % 4);
|
|
}
|
|
|
|
static void raster_line(uint8_t *cells, int cell_width, int cell_height,
|
|
int x0, int y0, int x1, int y1)
|
|
{
|
|
int64_t dx = llabs((int64_t)x1 - (int64_t)x0);
|
|
int sx = x0 < x1 ? 1 : -1;
|
|
int64_t dy = -llabs((int64_t)y1 - (int64_t)y0);
|
|
int sy = y0 < y1 ? 1 : -1;
|
|
int64_t error = dx + dy;
|
|
for (;;) {
|
|
raster_dot(cells, cell_width, cell_height, x0, y0);
|
|
if (x0 == x1 && y0 == y1) break;
|
|
{
|
|
int64_t doubled = error * INT64_C(2);
|
|
if (doubled >= dy) { error += dy; x0 += sx; }
|
|
if (doubled <= dx) { error += dx; y0 += sy; }
|
|
}
|
|
}
|
|
}
|
|
|
|
static void short_date(const TrainlogChartPoint *point, char output[6])
|
|
{
|
|
time_t seconds;
|
|
struct tm value;
|
|
char iso_date[11];
|
|
if (point != NULL && point->timestamp_label != NULL &&
|
|
strlen(point->timestamp_label) >= 10U &&
|
|
point->timestamp_label[4] == '-' && point->timestamp_label[7] == '-') {
|
|
if (trainlog_presentation_compact_date(point->timestamp_label, false,
|
|
output, 6U)) return;
|
|
}
|
|
seconds = point != NULL ? (time_t)point->timestamp : (time_t)-1;
|
|
if (point == NULL || (int64_t)seconds != point->timestamp ||
|
|
gmtime_r(&seconds, &value) == NULL ||
|
|
strftime(iso_date, sizeof(iso_date), "%Y-%m-%d", &value) != 10U ||
|
|
!trainlog_presentation_compact_date(iso_date, false, output, 6U))
|
|
(void)snprintf(output, 6U, "--/--");
|
|
}
|
|
|
|
static void render_empty(TrainlogSurface *surface, TrainlogChartRect rect,
|
|
const char *title)
|
|
{
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
|
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
|
|
title != NULL ? trainlog_presentation_source(title)
|
|
: trainlog_presentation_text("chart.timeline"));
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
|
if (rect.height > 2)
|
|
trainlog_surface_printf(surface, rect.top + 2, rect.left,
|
|
"%s", trainlog_presentation_text("empty.no_data"));
|
|
}
|
|
|
|
void trainlog_chart_render(TrainlogSurface *surface, TrainlogChartRect rect,
|
|
const TrainlogChartPoint *points, size_t count,
|
|
const char *unit, const char *title)
|
|
{
|
|
trainlog_chart_render_line(surface, rect, points, count, unit, title, NULL);
|
|
}
|
|
|
|
void trainlog_chart_render_line(TrainlogSurface *surface, TrainlogChartRect rect,
|
|
const TrainlogChartPoint *points, size_t count,
|
|
const char *unit, const char *title,
|
|
const TrainlogChartOptions *options)
|
|
{
|
|
TrainlogChartScale scale;
|
|
uint8_t *cells;
|
|
size_t cell_count;
|
|
int plot_left;
|
|
int plot_top;
|
|
int plot_width;
|
|
int plot_height;
|
|
int logical_width;
|
|
int logical_height;
|
|
size_t index;
|
|
char first_date[6];
|
|
char last_date[6];
|
|
const char *safe_unit = unit != NULL ? unit : "";
|
|
if (surface == NULL || rect.top < 0 || rect.left < 0 ||
|
|
rect.height <= 0 || rect.width <= 0 ||
|
|
rect.top > INT_MAX - rect.height ||
|
|
rect.left > INT_MAX - rect.width) return;
|
|
if (count == 0U) { render_empty(surface, rect, title); return; }
|
|
if (!trainlog_chart_scale_with_options(points, count, options, &scale)) {
|
|
render_empty(surface, rect, title);
|
|
return;
|
|
}
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
|
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
|
|
title != NULL ? trainlog_presentation_source(title)
|
|
: trainlog_presentation_text("chart.timeline"));
|
|
if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) {
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_ACCENT,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
|
if (count == 1U)
|
|
trainlog_surface_printf(surface,
|
|
rect.top + (rect.height > 2 ? 2 : 1), rect.left,
|
|
"◆ %.2f %s", points[0].value, safe_unit);
|
|
else
|
|
trainlog_surface_printf(surface,
|
|
rect.top + (rect.height > 2 ? 2 : 1), rect.left,
|
|
"◆ %zu relevés · %.2f %s", count,
|
|
points[count - 1U].value, safe_unit);
|
|
return;
|
|
}
|
|
plot_left = rect.left + CHART_LABEL_WIDTH;
|
|
plot_top = rect.top + 2;
|
|
plot_width = rect.width - CHART_LABEL_WIDTH - 1;
|
|
plot_height = rect.height - 5;
|
|
if (plot_width <= 0 || plot_height <= 0 ||
|
|
plot_width > INT_MAX / 2 || plot_height > INT_MAX / 4 ||
|
|
(size_t)plot_width > SIZE_MAX / (size_t)plot_height) return;
|
|
cell_count = (size_t)plot_width * (size_t)plot_height;
|
|
cells = calloc(cell_count, sizeof(*cells));
|
|
if (cells == NULL) return;
|
|
logical_width = plot_width * 2;
|
|
logical_height = plot_height * 4;
|
|
for (index = 1U; index < count; ++index) {
|
|
raster_line(cells, plot_width, plot_height,
|
|
trainlog_chart_map_x(&scale, points[index - 1U].timestamp,
|
|
logical_width),
|
|
trainlog_chart_map_y(&scale, points[index - 1U].value,
|
|
logical_height),
|
|
trainlog_chart_map_x(&scale, points[index].timestamp, logical_width),
|
|
trainlog_chart_map_y(&scale, points[index].value, logical_height));
|
|
}
|
|
if (count == 1U)
|
|
raster_dot(cells, plot_width, plot_height,
|
|
trainlog_chart_map_x(&scale, points[0].timestamp, logical_width),
|
|
trainlog_chart_map_y(&scale, points[0].value, logical_height));
|
|
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
|
trainlog_surface_printf(surface, plot_top, rect.left, "%8.2f %s",
|
|
scale.maximum_value, safe_unit);
|
|
trainlog_surface_printf(surface, plot_top + plot_height - 1, rect.left,
|
|
"%8.2f %s", scale.minimum_value, safe_unit);
|
|
for (int row = 0; row < plot_height; ++row)
|
|
trainlog_surface_draw(surface, plot_top + row, plot_left - 1, 0x2502U);
|
|
trainlog_surface_draw(surface, plot_top + plot_height, plot_left - 1, 0x2514U);
|
|
for (int column = 0; column < plot_width; ++column)
|
|
trainlog_surface_draw(surface, plot_top + plot_height,
|
|
plot_left + column, 0x2500U);
|
|
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_GRAPH,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
|
for (int row = 0; row < plot_height; ++row)
|
|
for (int column = 0; column < plot_width; ++column) {
|
|
uint8_t cell = cells[(size_t)row * (size_t)plot_width +
|
|
(size_t)column];
|
|
if (cell != 0U)
|
|
trainlog_surface_draw(surface, plot_top + row,
|
|
plot_left + column, trainlog_chart_braille_codepoint(cell));
|
|
}
|
|
free(cells);
|
|
|
|
/* CONTRACT: diamonds identify stored observations, while Braille dots
|
|
* between them are interpolation only. Several observations may map to one
|
|
* cell after clipping; this never changes or fabricates source values. */
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_ACCENT,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
|
for (index = 0U; index < count; ++index) {
|
|
int logical_x = trainlog_chart_map_x(&scale, points[index].timestamp,
|
|
logical_width);
|
|
int logical_y = trainlog_chart_map_y(&scale, points[index].value,
|
|
logical_height);
|
|
trainlog_surface_draw(surface, plot_top + logical_y / 4,
|
|
plot_left + logical_x / 2, 0x25c6U);
|
|
}
|
|
{
|
|
TrainlogChartPoint first_domain = {scale.minimum_timestamp, 0.0, NULL};
|
|
TrainlogChartPoint last_domain = {scale.maximum_timestamp, 0.0, NULL};
|
|
short_date(options != NULL && options->has_time_domain
|
|
? &first_domain : &points[0], first_date);
|
|
short_date(options != NULL && options->has_time_domain
|
|
? &last_domain : &points[count - 1U], last_date);
|
|
}
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
|
if (count == 1U) {
|
|
trainlog_surface_printf(surface, plot_top + plot_height + 1,
|
|
plot_left + (plot_width - 5) / 2, "%s", first_date);
|
|
} else {
|
|
trainlog_surface_printf(surface, plot_top + plot_height + 1,
|
|
plot_left, "%s", first_date);
|
|
trainlog_surface_printf(surface, plot_top + plot_height + 1,
|
|
plot_left + plot_width - 5, "%s", last_date);
|
|
}
|
|
if (count > 2U && plot_width >= 34) {
|
|
size_t middle = count / 2U;
|
|
int column = plot_left + trainlog_chart_map_x(&scale,
|
|
points[middle].timestamp, logical_width) / 2;
|
|
short_date(&points[middle], first_date);
|
|
if (column > plot_left + 6 && column < plot_left + plot_width - 10) {
|
|
trainlog_surface_draw(surface, plot_top + plot_height,
|
|
column, 0x252cU);
|
|
trainlog_surface_printf(surface, plot_top + plot_height + 1,
|
|
column - 2, "%s", first_date);
|
|
}
|
|
}
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
|
}
|
|
|
|
void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect,
|
|
const TrainlogChartPoint *points, size_t count, const char *unit,
|
|
const char *title, const TrainlogChartOptions *options)
|
|
{
|
|
TrainlogChartScale scale;
|
|
int plot_left, plot_top, plot_width, plot_height;
|
|
size_t index;
|
|
const char *safe_unit = unit != NULL ? unit : "";
|
|
if (surface == NULL || rect.height <= 0 || rect.width <= 0) return;
|
|
if (count == 0U) { render_empty(surface, rect, title); return; }
|
|
if (!trainlog_chart_scale_with_options(points, count, options, &scale)) {
|
|
render_empty(surface, rect, title); return;
|
|
}
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
|
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
|
|
title != NULL ? trainlog_presentation_source(title)
|
|
: trainlog_presentation_text("chart.totals"));
|
|
if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) {
|
|
trainlog_surface_printf(surface, rect.top + 2, rect.left,
|
|
"%zu périodes · %.2f %s", count,
|
|
points[count - 1U].value, safe_unit);
|
|
return;
|
|
}
|
|
plot_left = rect.left + CHART_LABEL_WIDTH;
|
|
plot_top = rect.top + 2;
|
|
plot_width = rect.width - CHART_LABEL_WIDTH - 1;
|
|
plot_height = rect.height - 5;
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
|
trainlog_surface_printf(surface, plot_top, rect.left, "%8.2f %s",
|
|
scale.maximum_value, safe_unit);
|
|
trainlog_surface_printf(surface, plot_top + plot_height - 1, rect.left,
|
|
"%8.2f %s", scale.minimum_value, safe_unit);
|
|
for (int row = 0; row < plot_height; ++row)
|
|
trainlog_surface_draw(surface, plot_top + row, plot_left - 1, 0x2502U);
|
|
for (int column = 0; column < plot_width; ++column)
|
|
trainlog_surface_draw(surface, plot_top + plot_height,
|
|
plot_left + column, 0x2500U);
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_GRAPH,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
|
for (index = 0U; index < count; ++index) {
|
|
int center = trainlog_chart_map_x(&scale, points[index].timestamp, plot_width);
|
|
int top_row = trainlog_chart_map_y(&scale, points[index].value, plot_height);
|
|
int half = count > 0U ? plot_width / (int)(count * 3U) : 0;
|
|
if (points[index].value <= scale.minimum_value) continue;
|
|
if (half < 0) half = 0;
|
|
if (half > 2) half = 2;
|
|
for (int row = top_row; row < plot_height; ++row)
|
|
for (int column = center - half; column <= center + half; ++column)
|
|
if (column >= 0 && column < plot_width)
|
|
trainlog_surface_draw(surface, plot_top + row,
|
|
plot_left + column, 0x2588U);
|
|
}
|
|
{
|
|
char first[6], last[6];
|
|
TrainlogChartPoint first_domain = {scale.minimum_timestamp, 0.0, NULL};
|
|
TrainlogChartPoint last_domain = {scale.maximum_timestamp, 0.0, NULL};
|
|
trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED,
|
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
|
if (count <= 8U && plot_width / (int)count >= 12) {
|
|
for (index = 0U; index < count; ++index) {
|
|
int center = trainlog_chart_map_x(&scale,
|
|
points[index].timestamp, plot_width);
|
|
int column = plot_left + center - 5;
|
|
const char *label = points[index].timestamp_label;
|
|
if (column < plot_left) column = plot_left;
|
|
if (column > plot_left + plot_width - 11)
|
|
column = plot_left + plot_width - 11;
|
|
if (label != NULL) {
|
|
char localized_label[32];
|
|
trainlog_surface_printf(surface,
|
|
plot_top + plot_height + 1, column, "%.11s",
|
|
trainlog_presentation_month_label(label, localized_label,
|
|
sizeof(localized_label)));
|
|
}
|
|
}
|
|
} else {
|
|
short_date(&first_domain, first); short_date(&last_domain, last);
|
|
trainlog_surface_printf(surface, plot_top + plot_height + 1,
|
|
plot_left, "%s", first);
|
|
trainlog_surface_printf(surface, plot_top + plot_height + 1,
|
|
plot_left + plot_width - 5, "%s", last);
|
|
}
|
|
}
|
|
}
|