Hello! Let's continue building our telemetry engine.
In our last lesson, you mastered the ImDrawList API, giving us direct, low-level control to draw any shape we want on the screen. We can now place lines, rectangles, and text with pixel-perfect precision. However, we left off with a crucial question: our telemetry data lives in its own world of seconds, function calls, and memory usage, while ImDrawList only understands screen pixels. How do we bridge this gap?
This lesson provides the answer. We will implement the mathematical heart of our custom plotter: a coordinate transformation function. You will learn how to take a data point from any numerical range (our "data space") and accurately map it to a specific pixel on the screen (our "screen space"). This is the fundamental step that transforms raw data into a visual representation.
By the end of this lesson, you will be able to take a set of data points, like a time series, and render them as a correctly scaled line graph inside an ImGui window.
Two Worlds: Data Space and Screen Space
First, let's clarify the two coordinate systems we're dealing with:
-
Data Space: This is the coordinate system of the information we want to visualize. For a time-series plot, the X-axis might represent time in seconds (e.g., from 0 to 60) and the Y-axis might represent a value like FPS (e.g., from 0 to 144). The units and ranges are defined by the data itself.
-
Screen Space: This is the coordinate system of your monitor, measured in pixels. For an ImGui window, the origin
(0,0)is at the top-left corner of the window's content area, with X increasing to the right and Y increasing downwards.

Our goal is to create a function that acts as a translator, converting a point from Data Space to its corresponding location in Screen Space.

The Core Formula: Linear Range Mapping
The process of converting a value from one numerical range to another is called linear mapping or rescaling. The logic is surprisingly straightforward and can be broken down into three steps:
-
Normalize: Take the input value and determine its proportional position within its original range. The result is a number between 0.0 and 1.0. For example, in the range [100, 200], the value 150 would be normalized to 0.5.
normalized_value = (value - input_start) / (input_end - input_start) -
Scale: Multiply this normalized value by the size (or "span") of the target output range.
scaled_value = normalized_value * (output_end - output_start) -
Shift: Add the start of the output range to this scaled value to get the final result.
final_value = scaled_value + output_start
Combining these steps gives us one elegant formula:
To get a more intuitive feel for how this formula is derived, let's consult a fantastic explanation.
Mapping a numeric range onto another
The following resource provides a step-by-step, intuitive derivation of the range mapping formula. It's an excellent way to build a solid understanding of why this works.
Read the first answer by Alok Singhal for an intuitive walkthrough. Then, read the answer by Erti-Chris Eelmaa which presents the same logic from a more algebraic perspective (normalization, scaling, shifting). Focus on understanding the concept, not memorizing the C code snippets yet.
Implementing a Generic map Function in C++
Now, let's translate this formula into a reusable C++ function. Based on the resources, we'll use floating-point types (double) to maintain precision, which is crucial for smooth plotting.
// Maps a value from one range to another.
double map(double value, double in_start, double in_end, double out_start, double out_end)
{
// The formula from our derivation
return out_start + (out_end - out_start) * ((value - in_start) / (in_end - in_start));
}
This simple function is incredibly powerful. We will use it for both the X and Y axes of our plot.
Test your understanding!
Imagine you have a PlotArea rectangle in your ImGui window that starts at pixel x=100 and ends at x=500. Your data's time values range from t=0.0 to t=60.0 seconds. What would be the screen-space x coordinate for a data point at t=15.0 seconds? Try to calculate it using the map function's logic.
Show answer
We want to map the value 15.0 from the input range [0.0, 60.0] to the output range [100, 500].
value = 15.0in_start = 0.0,in_end = 60.0out_start = 100,out_end = 500
x = map(15.0, 0.0, 60.0, 100.0, 500.0)x = 100 + (500 - 100) * ((15.0 - 0.0) / (60.0 - 0.0))x = 100 + 400 * (15.0 / 60.0)x = 100 + 400 * 0.25x = 100 + 100x = 200
The screen-space x coordinate would be 200.
The Y-Axis Inversion Problem
There's one critical detail we must handle. In mathematics and standard graphing, the Y-axis value increases as you go up. In screen space, the Y pixel coordinate increases as you go down.
A standard Cartesian graph (left) vs. screen coordinates (right). Notice the Y-axis is flipped.
If we map our data's Y-range directly to the plot area's Y-range, our graph will be upside-down!
The solution is simple: when we call our map function for the Y-axis, we invert the output range.
Instead of mapping [data_min_y, data_max_y] to [plot_min_y, plot_max_y], we map it to [plot_max_y, plot_min_y].
screen_y = map(data_y, data_min_y, data_max_y, plot_area_bottom_y, plot_area_top_y);
This correctly maps the smallest data value to the bottom of the plot area and the largest data value to the top.
Translating between cartesian and screen coordinates
The concept of inverting the Y-axis is a standard part of converting from a mathematical (Cartesian) system to a screen system. This resource discusses exactly that.
Briefly read the first answer by MerickOWA. Focus on the formula screenY = screen_height/2 - cartY. This demonstrates the core idea of inverting and shifting the Y coordinate, which is what our map function does for us when we swap the output range.
Putting It All Together: Plotting a Sine Wave
Let's combine the ImDrawList skills from the last lesson with our new map function to draw a complete graph. We will generate some points for a sine wave and plot them.
You can add this code to your application's update loop.
#include <vector>
#include <cmath>
// Our mapping function from before
double map(double value, double in_start, double in_end, double out_start, double out_end)
{
return out_start + (out_end - out_start) * ((value - in_start) / (in_end - in_start));
}
// A simple struct to hold our 2D data points
struct DataPoint {
double x, y;
};
// This function will be called every frame in your main loop
void PlotMyData()
{
ImGui::Begin("My First Plot");
// 1. Get the drawing canvas
ImVec2 canvas_p1 = ImGui::GetCursorScreenPos(); // Top-left
ImVec2 canvas_size = ImGui::GetContentRegionAvail(); // Available space
if (canvas_size.x < 50.0f) canvas_size.x = 50.0f;
if (canvas_size.y < 50.0f) canvas_size.y = 50.0f;
ImVec2 canvas_p2 = ImVec2(canvas_p1.x + canvas_size.x, canvas_p1.y + canvas_size.y); // Bottom-right
ImDrawList* draw_list = ImGui::GetWindowDrawList();
// 2. Draw the plot background
draw_list->AddRectFilled(canvas_p1, canvas_p2, IM_COL32(50, 50, 50, 255));
draw_list->AddRect(canvas_p1, canvas_p2, IM_COL32(255, 255, 255, 255));
ImGui::InvisibleButton("canvas", canvas_size); // Reserve the space
// 3. Define our data space
static std::vector<DataPoint> my_data;
if (my_data.empty()) // Generate some data once
{
for (int i = 0; i < 100; ++i)
{
double x = i / 10.0; // Data X-range: 0.0 to 9.9
double y = sin(x); // Data Y-range: -1.0 to 1.0
my_data.push_back({x, y});
}
}
const double data_min_x = 0.0, data_max_x = 10.0;
const double data_min_y = -1.0, data_max_y = 1.0;
// 4. Draw the data points
draw_list->PushClipRect(canvas_p1, canvas_p2, true); // Clip drawing to the canvas
for (size_t i = 0; i < my_data.size() - 1; ++i)
{
const DataPoint& p1 = my_data[i];
const DataPoint& p2 = my_data[i+1];
// Map data points to screen space
ImVec2 screen_p1, screen_p2;
screen_p1.x = (float)map(p1.x, data_min_x, data_max_x, canvas_p1.x, canvas_p2.x);
screen_p1.y = (float)map(p1.y, data_min_y, data_max_y, canvas_p2.y, canvas_p1.y); // Note the Y-inversion!
screen_p2.x = (float)map(p2.x, data_min_x, data_max_x, canvas_p1.x, canvas_p2.x);
screen_p2.y = (float)map(p2.y, data_min_y, data_max_y, canvas_p2.y, canvas_p1.y); // Note the Y-inversion!
draw_list->AddLine(screen_p1, screen_p2, IM_COL32(0, 255, 255, 255), 2.0f);
}
draw_list->PopClipRect();
ImGui::End();
}
When you run this code, you should see a perfectly rendered sine wave inside your ImGui window. Try resizing the window—the plot will rescale automatically because our map function uses the dynamic canvas_p1 and canvas_p2 values each frame!
Conclusion
You have just implemented one of the most fundamental components of any custom graphics or visualization work. By creating a robust function to map between coordinate systems, you've built the bridge that turns abstract data into a concrete, visual plot.
Key Takeaways:
- Visualizations involve translating from a data space (the units of your data) to screen space (pixels).
- The core of this translation is a linear mapping function that rescales a value from an input range to an output range.
- The formula is:
output = out_start + (value - in_start) * (out_end - out_start) / (in_end - in_start). - A critical step for 2D plots is inverting the Y-axis to account for screen coordinates increasing downwards. This is done by swapping the
out_startandout_endarguments for the Y-axis mapping. - By combining this mapping with the
ImDrawListAPI, we can now render actual datasets.
What's Next?
Our plot is now functional, but it's static. We are always viewing the entire dataset. For a high-performance profiler that captures millions of events, this is not practical. We need to be able to explore the data interactively.
In the next lesson, we will build on our coordinate transformation function to implement zooming and panning. You will learn how to modify the input data range that is visible on screen, allowing you to "fly through" your data with the mouse. This is the next major step toward our LOD Plotter.