Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 57

Implementing Zoom with Coordinate Transformation

Recommended resources

Hello! Welcome back to building our high-performance telemetry engine.

In the last lesson, we built the essential bridge between our data and the screen. You created a coordinate transformation function to map data points from their own "data space" into the "screen space" of our plot window. This allowed us to render a complete, but static, dataset.

Today, we'll bring that static plot to life. This lesson focuses on implementing one of the most crucial features for data exploration: zooming. You will learn how to apply a scale factor to our coordinate transformation, enabling you to zoom in and out of your data using the mouse wheel. More importantly, we'll implement it the right way, ensuring the zoom is centered on your mouse cursor for an intuitive user experience.

This is a critical step towards our LOD Plotter. To navigate millions of data points effectively, a user must be able to fluidly change their level of focus, and that's exactly what zooming provides.

The Mathematics of Zooming

In our previous lesson, our map function took an input data range (e.g., time from 0 to 60 seconds) and fit it perfectly into our plot's pixel width. Zooming is the process of changing that input data range.

  • Zooming In: We display a smaller slice of the data range across the same number of pixels. This makes details larger.
  • Zooming Out: We display a wider slice of the data range, making everything smaller to fit more in.

This is fundamentally a scaling transformation. Let's start by watching a video that provides an excellent conceptual overview of both panning and zooming. We'll focus on the zooming aspects for now.

Programming Panning & Zooming

Watch this video from javidx9 to get a clear, visual understanding of how scaling and translation (panning) work together. Pay close attention to the concept of zooming around the mouse cursor.

Watch the sections 'Zooming' (from 11:09 to 13:32) and the following unnamed section about fixing the zoom focus (from 13:32 to 17:07). The first part introduces the basic math of scaling. The second part explains the crucial logic for making the zoom centered on the mouse, which is the core of today's lesson.


The Problem with Naive Zooming

As the video demonstrates, simply applying a scale factor to your coordinate system isn't enough. If you just scale the world around a fixed point (like the origin (0,0)), the view will appear to fly off into a corner as you zoom. The user will constantly get lost.

The desired behavior is that the point under the mouse cursor should remain stationary. This gives the user a stable anchor point to explore from.


The Solution: Zooming Around the Cursor

The key insight, as explained in the video and demonstrated in many professional tools, is a three-step process executed whenever the user scrolls the mouse wheel:

  1. Find the Anchor: Before changing the zoom, identify the exact point in data space that is currently under the mouse cursor. Let's call this data_pos_before_zoom.
  2. Apply the Zoom: Update your zoom level (scale factor).
  3. Correct the Offset: After the zoom, the data point at data_pos_before_zoom will have visually shifted. We must calculate how much our view needs to be panned (offset) to bring that exact data point back under the mouse cursor.

This correction is the magic that makes the zoom feel anchored and intuitive. Let's look at how to implement this specific logic in C++ with ImGui.

Zoom in and out centered about the mouse cursor in 2D using ImGui

This Stack Overflow answer provides a perfect, concise C++ code snippet that implements the exact logic we need for ImGui. It demonstrates how to calculate the pan correction.

Read the accepted answer by Patrick W. Focus on the code block. Notice how it gets the mouse world position (mpos1), applies the zoom, gets the new mouse world position (mpos2), and then adjusts the pan variable by the difference. This is the practical implementation of the concept from the video.


Refactoring for Interactivity: State and Transforms

To implement this, our plotting function can no longer be stateless. It needs to remember the current pan (offset) and zoom (scale) between frames. We also need to formalize the transformation logic from the previous lesson into dedicated WorldToScreen and ScreenToWorld functions. This refactoring will make our code much cleaner and easier to manage.

Let's add these members to a class or as static variables inside your plotting function:

// State for our interactive plot
static ImVec2 pan_offset(0.0f, 0.0f); // The world-space coordinate at the top-left of the canvas
static float zoom_level = 1.0f;      // How many world units fit in one pixel (inversely related to visual scale)

Wait, let's redefine zoom_level to be more intuitive. A higher zoom level should mean more zoomed in. Let's define it as pixels per world unit.

// State for our interactive plot
static ImVec2 pan_offset(0.0f, 0.0f); // The world-space coordinate at the top-left of the canvas
static float zoom_level = 10.0f;     // e.g., 10 pixels per 1 world unit

Now, let's create our transformation functions. These will replace the direct calls to map() in our drawing loop.

// Converts a point from data space to screen space
ImVec2 WorldToScreen(const ImVec2& world_pos, const ImVec2& pan, float zoom, const ImVec2& canvas_p1, const ImVec2& canvas_p2)
{
    float screen_x = (world_pos.x - pan.x) * zoom + canvas_p1.x;
    float screen_y = (pan.y - world_pos.y) * zoom + canvas_p1.y; // Y is inverted
    return ImVec2(screen_x, screen_y);
}

// Converts a point from screen space to data space
ImVec2 ScreenToWorld(const ImVec2& screen_pos, const ImVec2& pan, float zoom, const ImVec2& canvas_p1, const ImVec2& canvas_p2)
{
    float world_x = (screen_pos.x - canvas_p1.x) / zoom + pan.x;
    float world_y = pan.y - (screen_pos.y - canvas_p1.y) / zoom; // Y is inverted
    return ImVec2(world_x, world_y);
}

Correction: Let's look at the WorldToScreen function again. The standard approach is to have the pan_offset be the world coordinate that maps to the canvas's top-left corner. Let's adjust the formula for the Y-axis. If world_pos.y == pan_offset.y, then screen_y should be canvas_p1.y. Our formula (pan.y - world_pos.y) * zoom + canvas_p1.y gives (pan.y - pan.y) * zoom + canvas_p1.y = canvas_p1.y. This seems correct.


Integrating Zoom into the Plot Function

Now we can update the PlotMyData function from the last lesson. We will add the zoom handling logic and use our new transformation functions for drawing.

#include <cmath> // For std::pow and std::clamp

// ... Your DataPoint struct, WorldToScreen, and ScreenToWorld functions ...

void PlotMyData()
{
    // Plot state
    static ImVec2 pan_offset(0.0f, -1.0f); // Initial view: X=0, Y is centered around 0 in a [-1, 1] range
    static float zoom_level = 50.0f;     // Initial zoom

    ImGui::Begin("Interactive Plot");

    // 1. Get the drawing canvas
    ImVec2 canvas_p1 = ImGui::GetCursorScreenPos();
    ImVec2 canvas_size = ImGui::GetContentRegionAvail();
    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);
    ImDrawList* draw_list = ImGui::GetWindowDrawList();

    // Draw background and reserve space
    draw_list->AddRectFilled(canvas_p1, canvas_p2, IM_COL32(50, 50, 50, 255));
    ImGui::InvisibleButton("canvas", canvas_size, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight);
    
    // 2. Handle Zooming (the core of this lesson)
    if (ImGui::IsItemHovered() && ImGui::GetIO().MouseWheel != 0)
    {
        ImVec2 mouse_pos_screen = ImGui::GetMousePos();
        
        // Get the world-space coordinate under the mouse before the zoom
        ImVec2 mouse_pos_world_before = ScreenToWorld(mouse_pos_screen, pan_offset, zoom_level, canvas_p1, canvas_p2);

        // Update the zoom level (exponentially for a smoother feel)
        float zoom_delta = ImGui::GetIO().MouseWheel * 0.1f;
        zoom_level *= std::pow(2.0f, zoom_delta);
        zoom_level = std::clamp(zoom_level, 1.0f, 1000.0f); // Prevent zooming too far in or out

        // Get the world-space coordinate under the mouse after the zoom
        ImVec2 mouse_pos_world_after = ScreenToWorld(mouse_pos_screen, pan_offset, zoom_level, canvas_p1, canvas_p2);

        // Adjust the pan offset to keep the point under the mouse stationary
        pan_offset.x += mouse_pos_world_before.x - mouse_pos_world_after.x;
        pan_offset.y += mouse_pos_world_before.y - mouse_pos_world_after.y;
    }
    
    // 3. Draw the grid/data
    draw_list->AddRect(canvas_p1, canvas_p2, IM_COL32(255, 255, 255, 255));
    draw_list->PushClipRect(canvas_p1, canvas_p2, true);

    // ... Generate your sine wave data as before ...
    static std::vector<DataPoint> my_data;
    // ... (code to fill my_data is the same as the previous lesson)

    // Draw the data using the new transform functions
    for (size_t i = 0; i < my_data.size() - 1; ++i)
    {
        const DataPoint& p1_world = my_data[i];
        const DataPoint& p2_world = my_data[i+1];

        ImVec2 p1_screen = WorldToScreen({(float)p1_world.x, (float)p1_world.y}, pan_offset, zoom_level, canvas_p1, canvas_p2);
        ImVec2 p2_screen = WorldToScreen({(float)p2_world.x, (float)p2_world.y}, pan_offset, zoom_level, canvas_p1, canvas_p2);

        draw_list->AddLine(p1_screen, p2_screen, IM_COL32(0, 255, 255, 255), 2.0f);
    }

    draw_list->PopClipRect();
    ImGui::End();
}
Test your understanding!

In our zoom handling code, we calculate mouse_pos_world_before - mouse_pos_world_after and add it to pan_offset.

Let's say you zoom in. This means zoom_level increases.
Will the value of mouse_pos_world_after.x be greater or less than mouse_pos_world_before.x? Why does adding their difference to pan_offset.x correctly adjust the view?

(Hint: Look at the ScreenToWorld function.)

Show answer

When you zoom in, zoom_level increases. In the ScreenToWorld function, zoom_level is in the denominator: (screen_pos.x - canvas_p1.x) / zoom + pan.x.

Therefore, a larger zoom_level makes the (screen_pos.x - canvas_p1.x) / zoom term smaller. This means mouse_pos_world_after.x will be less than mouse_pos_world_before.x (assuming pan.x is constant during the calculation).

The difference mouse_pos_world_before.x - mouse_pos_world_after.x will be a positive value. Adding this positive value to pan_offset.x effectively shifts our view to the right. This is the correct behavior: when you zoom in on a point, the data "expands" around it. To keep the anchor point centered, you have to shift your view's origin (pan_offset) to compensate for this expansion.


The Deeper Connection: Transformation Matrices

The operations we've coded—scaling and translating—are fundamental in computer graphics. While we've implemented them with simple arithmetic, they are more formally represented by transformation matrices. Your experience with computer science means you know that abstractions often hide a more powerful, general mechanism underneath.

LearnOpenGL - Transformations

To understand the deeper theory, this resource explains how scaling and translation are represented by matrices. This will be invaluable as you move into more advanced graphics.

Read the sections on 'Scaling', 'Translation', and 'Combining matrices'. You don't need to implement matrices yourself (GLM or ImGui's math types do that), but understand that our WorldToScreen function is effectively performing the same job as multiplying a data vector by a combined scale-and-translate matrix.

Our WorldToScreen function performs these steps in order:

  1. Translate the world by our pan_offset. (world_pos.x - pan.x)
  2. Scale the result by our zoom_level. (... * zoom)
  3. Translate again to the final position on the canvas. (... + canvas_p1.x)

This is equivalent to multiplying a position vector by a series of transformation matrices: Translation_Canvas * Scaling_Zoom * Translation_Pan * Position_Vector. Understanding this link between simple formulas and linear algebra is key to mastering systems-level graphics programming.


Conclusion

Congratulations! Your plot is no longer just a static image; it's an interactive canvas. You can now explore your data by zooming in and out, with the mouse cursor acting as a precise and intuitive focal point.

Key Takeaways:

  • Zooming is achieved by applying a scale factor to the coordinate transformation.
  • To implement intuitive, cursor-centered zooming, you must calculate the data point under the cursor before and after the zoom, then adjust the view's pan/offset to compensate for the shift.
  • This requires maintaining state (pan_offset, zoom_level) and using dedicated WorldToScreen and ScreenToWorld functions.
  • The math we implemented is a practical application of the scaling and translation transformations fundamental to all computer graphics.

What's Next?

We've implemented zooming, but a truly interactive plot also needs panning. In the next lesson, we will leverage the pan_offset variable we introduced today to implement panning by dragging the mouse. This will complete the core navigation features of our custom plotter.