temp-monitor/src/temperature_chart.cpp
2026-01-11 12:51:35 +01:00

503 lines
17 KiB
C++

#include "temperature_chart.h"
#include <iostream>
#include <cmath>
#include <ctime>
#include <algorithm>
#include <limits>
#include <string>
TemperatureChart::TemperatureChart(GtkWidget *parent)
: drawingArea(nullptr), tooltipWindow(nullptr), tooltipLabel(nullptr),
maxDataPoints(600), tickHandler(0),
minTemp(MIN_TEMP), maxTemp(MAX_TEMP), minTime(0), maxTime(0),
lastMouseX(-1), lastMouseY(-1)
{
drawingArea = gtk_drawing_area_new();
gtk_widget_set_size_request(drawingArea, 800, 400);
gtk_widget_set_can_focus(drawingArea, TRUE);
gtk_widget_set_focusable(drawingArea, TRUE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(drawingArea),
[](GtkDrawingArea *area, cairo_t *cr, int width, int height, gpointer userData) {
TemperatureChart *self = static_cast<TemperatureChart*>(userData);
self->drawChart(area, cr, width, height);
}, this, nullptr);
// Setup event controller for mouse motion
GtkEventController *motion = gtk_event_controller_motion_new();
g_signal_connect_data(motion, "motion",
G_CALLBACK(+[](GtkEventControllerMotion *motion, double x, double y, gpointer userData) -> gboolean {
TemperatureChart *self = static_cast<TemperatureChart*>(userData);
self->lastMouseX = x;
self->lastMouseY = y;
GtkWidget *drawArea = self->drawingArea;
int width = gtk_widget_get_width(drawArea);
int height = gtk_widget_get_height(drawArea);
NearestPoint nearest = self->findNearestDataPoint(x, y, width, height);
if (nearest.found && nearest.distance < 5.0) {
self->showTooltip(x, y, nearest);
} else {
self->hideTooltip();
}
return FALSE;
}), this, nullptr, (GConnectFlags)0);
g_signal_connect(motion, "leave", G_CALLBACK(onLeave), this);
gtk_widget_add_controller(drawingArea, motion);
setupColors();
// Setup tick callback for redrawing
tickHandler = g_timeout_add(100, onTick, this);
}
TemperatureChart::~TemperatureChart()
{
if (tickHandler) {
g_source_remove(tickHandler);
tickHandler = 0;
}
if (tooltipWindow) {
gtk_widget_unparent(tooltipWindow);
tooltipWindow = nullptr;
tooltipLabel = nullptr;
}
}
void TemperatureChart::setupColors()
{
// Define colors for different devices
const GdkRGBA colors[] = {
{1.0, 0.0, 0.0, 1.0}, // Red
{0.0, 0.0, 1.0, 1.0}, // Blue
{0.0, 0.5, 0.0, 1.0}, // Green
{1.0, 0.647, 0.0, 1.0}, // Orange
{0.5, 0.0, 0.5, 1.0}, // Purple
{0.0, 0.5, 0.5, 1.0}, // Teal
{1.0, 0.753, 0.796, 1.0}, // Pink
{0.647, 0.165, 0.165, 1.0} // Brown
};
int index = 0;
for (auto &entry : seriesMap) {
entry.second.color = colors[index % 8];
index++;
}
updateThemeColors();
}
void TemperatureChart::updateThemeColors()
{
// Detect dark mode by checking the desktop theme
gboolean isDark = FALSE;
// Try to get the theme from both GTK settings and Gnome settings
GtkSettings *settings = gtk_settings_get_default();
if (settings) {
gchar *themeName = nullptr;
g_object_get(settings, "gtk-theme-name", &themeName, nullptr);
if (themeName) {
std::string theme(themeName);
// Check if theme name contains "dark"
isDark = (theme.find("dark") != std::string::npos ||
theme.find("Dark") != std::string::npos);
g_free(themeName);
}
}
// Also try GSetting if available
if (!isDark) {
GSettingsSchemaSource *source = g_settings_schema_source_get_default();
if (source) {
GSettingsSchema *schema = g_settings_schema_source_lookup(source, "org.gnome.desktop.interface", FALSE);
if (schema) {
GSettings *gsettings = g_settings_new("org.gnome.desktop.interface");
if (gsettings) {
gchar *gtkTheme = g_settings_get_string(gsettings, "gtk-theme");
if (gtkTheme) {
std::string theme(gtkTheme);
isDark = (theme.find("dark") != std::string::npos ||
theme.find("Dark") != std::string::npos);
g_free(gtkTheme);
}
g_object_unref(gsettings);
}
g_settings_schema_unref(schema);
}
}
}
if (isDark) {
// Dark theme colors
bgColor = {0.1, 0.1, 0.1, 1.0}; // Dark background
textColor = {0.9, 0.9, 0.9, 1.0}; // Light text
gridColor = {0.25, 0.25, 0.25, 1.0}; // Dark gray grid
axisColor = {0.9, 0.9, 0.9, 1.0}; // Light axis
} else {
// Light theme colors
bgColor = {1.0, 1.0, 1.0, 1.0}; // White background
textColor = {0.0, 0.0, 0.0, 1.0}; // Black text
gridColor = {0.9, 0.9, 0.9, 1.0}; // Light gray grid
axisColor = {0.0, 0.0, 0.0, 1.0}; // Black axis
}
}
GdkRGBA TemperatureChart::getColorForSeries(const std::string &seriesKey)
{
if (colorMap.find(seriesKey) == colorMap.end()) {
const GdkRGBA colors[] = {
{1.0, 0.0, 0.0, 1.0},
{0.0, 0.0, 1.0, 1.0},
{0.0, 0.5, 0.0, 1.0},
{1.0, 0.647, 0.0, 1.0},
{0.5, 0.0, 0.5, 1.0},
{0.0, 0.5, 0.5, 1.0},
{1.0, 0.753, 0.796, 1.0},
{0.647, 0.165, 0.165, 1.0}
};
int colorIndex = colorMap.size() % 8;
colorMap[seriesKey] = colors[colorIndex];
}
return colorMap[seriesKey];
}
bool TemperatureChart::addTemperatureData(const std::string &device, const std::string &sensor,
double temperature, int64_t timestamp)
{
std::string seriesKey = device + " - " + sensor;
bool isNew = false;
// Create series if it doesn't exist
if (seriesMap.find(seriesKey) == seriesMap.end()) {
SeriesData series;
series.color = getColorForSeries(seriesKey);
series.id = seriesKey;
series.name = seriesKey;
seriesMap[seriesKey] = series;
isNew = true;
}
// Add data point
DataPoint point;
point.temperature = temperature;
point.timestamp = timestamp;
seriesMap[seriesKey].points.push_back(point);
// Keep only last 10 minutes of data
int64_t cutoffTime = timestamp - MAX_TIME_MS;
for (auto &entry : seriesMap) {
auto &points = entry.second.points;
auto it = points.begin();
while (it != points.end() && it->timestamp < cutoffTime) {
it = points.erase(it);
}
}
// Update temperature range
minTemp = MIN_TEMP;
maxTemp = MAX_TEMP;
// Update time range - keep 10 minute window
maxTime = timestamp;
minTime = timestamp - MAX_TIME_MS;
// Trigger redraw
gtk_widget_queue_draw(drawingArea);
return isNew;
}
void TemperatureChart::clear()
{
seriesMap.clear();
colorMap.clear();
minTemp = MIN_TEMP;
maxTemp = MAX_TEMP;
minTime = 0;
maxTime = 0;
gtk_widget_queue_draw(drawingArea);
}
std::vector<std::pair<std::string, GdkRGBA>> TemperatureChart::getSeriesColors() const
{
std::vector<std::pair<std::string, GdkRGBA>> result;
for (const auto &pair : seriesMap) {
result.push_back({pair.first, pair.second.color});
}
return result;
}
void TemperatureChart::setSeriesColor(const std::string &seriesName, const GdkRGBA &color)
{
// Update color map
colorMap[seriesName] = color;
// Update series if it exists
if (seriesMap.find(seriesName) != seriesMap.end()) {
seriesMap[seriesName].color = color;
}
gtk_widget_queue_draw(drawingArea);
}
std::string TemperatureChart::getSeriesName(const std::string &seriesId) const
{
auto it = seriesMap.find(seriesId);
if (it != seriesMap.end()) {
return it->second.name;
}
return seriesId;
}
void TemperatureChart::setSeriesName(const std::string &seriesId, const std::string &name)
{
if (seriesMap.find(seriesId) != seriesMap.end()) {
seriesMap[seriesId].name = name;
}
}
gboolean TemperatureChart::onTick(gpointer userData)
{
TemperatureChart *self = static_cast<TemperatureChart*>(userData);
gtk_widget_queue_draw(self->drawingArea);
return TRUE;
}
void TemperatureChart::drawChart(GtkDrawingArea *area, cairo_t *cr, int width, int height)
{
// Update theme colors before drawing
updateThemeColors();
// Background with theme color
cairo_set_source_rgb(cr, bgColor.red, bgColor.green, bgColor.blue);
cairo_paint(cr);
if (seriesMap.empty() || maxTime == minTime) {
// Draw placeholder
cairo_set_source_rgb(cr, textColor.red * 0.6, textColor.green * 0.6, textColor.blue * 0.6);
cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL);
cairo_set_font_size(cr, 14);
cairo_move_to(cr, width / 2 - 100, height / 2);
cairo_show_text(cr, "Waiting for data...");
return;
}
// Margins
double marginLeft = 20, marginRight = 70, marginTop = 40, marginBottom = 40;
double plotWidth = width - marginLeft - marginRight;
double plotHeight = height - marginTop - marginBottom;
// Draw grid and axes
cairo_set_source_rgb(cr, gridColor.red, gridColor.green, gridColor.blue);
cairo_set_line_width(cr, 0.5);
// Y axis grid lines (temperature) - every 10 degrees
for (double temp = minTemp; temp <= maxTemp; temp += 10.0) {
double y = marginTop + plotHeight * (1.0 - (temp - minTemp) / (maxTemp - minTemp));
cairo_move_to(cr, marginLeft, y);
cairo_line_to(cr, marginLeft + plotWidth, y);
cairo_stroke(cr);
}
// X axis grid lines (time) - 5 lines for 10-minute window
for (int i = 0; i <= 5; i++) {
double x = marginLeft + (plotWidth * i / 5.0);
cairo_move_to(cr, x, marginTop);
cairo_line_to(cr, x, marginTop + plotHeight);
cairo_stroke(cr);
}
// Draw axes
cairo_set_source_rgb(cr, axisColor.red, axisColor.green, axisColor.blue);
cairo_set_line_width(cr, 2);
cairo_move_to(cr, marginLeft + plotWidth, marginTop);
cairo_line_to(cr, marginLeft + plotWidth, marginTop + plotHeight);
cairo_line_to(cr, marginLeft, marginTop + plotHeight);
cairo_stroke(cr);
// Draw axis labels
cairo_set_source_rgb(cr, textColor.red, textColor.green, textColor.blue);
cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL);
cairo_set_font_size(cr, 10);
// Temperature labels every 10 degrees on Y axis
for (double temp = minTemp; temp <= maxTemp; temp += 10.0) {
double y = marginTop + plotHeight * (1.0 - (temp - minTemp) / (maxTemp - minTemp));
char tempStr[16];
snprintf(tempStr, sizeof(tempStr), "%.0f°C", temp);
// Measure text width for proper alignment
cairo_text_extents_t extents;
cairo_text_extents(cr, tempStr, &extents);
// Position text 8 pixels after the plot area on the right
double textX = marginLeft + plotWidth + 8;
cairo_move_to(cr, textX, y + 4);
cairo_show_text(cr, tempStr);
}
// Time labels on X axis (5 points for 10-minute window)
for (int i = 0; i <= 5; i++) {
double x = marginLeft + (plotWidth * i / 5.0);
// Calculate time offset in seconds
int secondsOffset = (5 - i) * 120; // 10 minutes / 5 = 2 minutes between points
int minutes = secondsOffset / 60;
char timeStr[16];
snprintf(timeStr, sizeof(timeStr), "-%d m", minutes);
cairo_move_to(cr, x - 20, marginTop + plotHeight + 20);
cairo_show_text(cr, timeStr);
}
// Draw data series (right to left, with 10-minute window)
for (auto &seriesEntry : seriesMap) {
SeriesData &series = seriesEntry.second;
if (series.points.empty()) continue;
// Set series color
cairo_set_source_rgba(cr, series.color.red, series.color.green,
series.color.blue, series.color.alpha);
cairo_set_line_width(cr, 2.0);
bool firstPoint = true;
for (const DataPoint &point : series.points) {
// Reverse X axis - newer data on the right, older on the left
double x = marginLeft + plotWidth * (1.0 - (double)(maxTime - point.timestamp) / MAX_TIME_MS);
double y = marginTop + plotHeight * (1.0 - (point.temperature - minTemp) / (maxTemp - minTemp));
if (firstPoint) {
cairo_move_to(cr, x, y);
firstPoint = false;
} else {
cairo_line_to(cr, x, y);
}
}
cairo_stroke(cr);
}
// Draw title
cairo_set_source_rgb(cr, textColor.red, textColor.green, textColor.blue);
cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD);
cairo_set_font_size(cr, 16);
cairo_move_to(cr, width / 2 - 150, 25);
cairo_show_text(cr, "NVMe Disk Temperatures (Real-time)");
}
TemperatureChart::NearestPoint TemperatureChart::findNearestDataPoint(double mouseX, double mouseY, int width, int height)
{
NearestPoint result;
result.found = false;
result.distance = std::numeric_limits<double>::max();
if (seriesMap.empty() || maxTime == minTime) {
return result;
}
// Same margins as in drawChart
double marginLeft = 20, marginRight = 70, marginTop = 40, marginBottom = 40;
double plotWidth = width - marginLeft - marginRight;
double plotHeight = height - marginTop - marginBottom;
// Check if mouse is in plot area
if (mouseX < marginLeft || mouseX > marginLeft + plotWidth ||
mouseY < marginTop || mouseY > marginTop + plotHeight) {
return result;
}
for (auto &seriesEntry : seriesMap) {
SeriesData &series = seriesEntry.second;
for (const DataPoint &point : series.points) {
double x = marginLeft + plotWidth * (1.0 - (double)(maxTime - point.timestamp) / MAX_TIME_MS);
double y = marginTop + plotHeight * (1.0 - (point.temperature - minTemp) / (maxTemp - minTemp));
double distance = std::hypot(mouseX - x, mouseY - y);
if (distance < result.distance) {
result.distance = distance;
result.found = true;
result.temperature = point.temperature;
result.timestamp = point.timestamp;
result.seriesName = series.name;
}
}
}
return result;
}
void TemperatureChart::showTooltip(double x, double y, const NearestPoint &point)
{
if (!tooltipWindow) {
tooltipWindow = gtk_popover_new();
tooltipLabel = gtk_label_new("");
gtk_widget_set_margin_start(tooltipLabel, 8);
gtk_widget_set_margin_end(tooltipLabel, 8);
gtk_widget_set_margin_top(tooltipLabel, 5);
gtk_widget_set_margin_bottom(tooltipLabel, 5);
gtk_popover_set_child(GTK_POPOVER(tooltipWindow), tooltipLabel);
gtk_popover_set_position(GTK_POPOVER(tooltipWindow), GTK_POS_TOP);
gtk_popover_set_has_arrow(GTK_POPOVER(tooltipWindow), TRUE);
gtk_popover_set_autohide(GTK_POPOVER(tooltipWindow), FALSE);
gtk_widget_set_parent(tooltipWindow, drawingArea);
}
// Format the tooltip text with real current time
char tooltipText[256];
// Convert timestamp from milliseconds since epoch to time structure
time_t timeSeconds = point.timestamp / 1000;
struct tm *timeinfo = localtime(&timeSeconds);
char timeStr[32] = "??:??:??";
if (timeinfo) {
strftime(timeStr, sizeof(timeStr), "%H:%M:%S", timeinfo);
}
snprintf(tooltipText, sizeof(tooltipText),
"%s\n%.1f°C\n%s",
point.seriesName.c_str(),
point.temperature,
timeStr);
gtk_label_set_text(GTK_LABEL(tooltipLabel), tooltipText);
// Position the tooltip
GdkRectangle rect;
rect.x = (int)x;
rect.y = (int)y;
rect.width = 1;
rect.height = 1;
gtk_popover_set_pointing_to(GTK_POPOVER(tooltipWindow), &rect);
if (!gtk_widget_get_visible(tooltipWindow)) {
gtk_widget_set_visible(tooltipWindow, TRUE);
}
}
void TemperatureChart::hideTooltip()
{
if (tooltipWindow && gtk_widget_get_visible(tooltipWindow)) {
gtk_widget_set_visible(tooltipWindow, FALSE);
}
}
void TemperatureChart::onLeave(GtkEventControllerMotion *motion, gpointer userData)
{
TemperatureChart *self = static_cast<TemperatureChart*>(userData);
self->hideTooltip();
}