Search code examples
winapigdi

GDI: Create Mountain Chart/Graph?


I can use Polyline() GDI function to plot values to create a graph but now I want the lower part of it filled in to create a mountain type chart. Is there something built-in to help create that? (I don't need gradient, but that would be a nice touch).

TIA!!


Solution

  • For this diagram type you need to draw a filled shape. The Polygon function can be used to draw an irregularly shaped filled object:

    The polygon is outlined by using the current pen and filled by using the current brush [...].

    The polygon points need to be constructed from the data points (left to right). To turn this into a closed shape, the bottom right and bottom left points of the diagram area need to be appended. The Polygon function then closes the shape automatically by drawing a line from the last vertex to the first.

    The following implementation renders a single data set into a given device context's area:

    /// \brief Renders a diagram into a DC
    ///
    /// \param dc       Device context to render into
    /// \param area     Diagram area in client coordinates
    /// \param pen_col  Diagram outline color
    /// \param fill_col Diagram fill color
    /// \param data     Data points to render in data coordinate space
    /// \param y_min    Y-axis minimum in data coordinate space
    /// \param y_max    Y-axis maximum in data coordiante space
    void render_diagram(HDC dc, RECT area,
                        COLORREF pen_col, COLORREF fill_col,
                        std::vector<int> const& data, int y_min, int y_max) {
        // Make sure we have data
        if (data.size() < 2) { return; }
        // Make sure the diagram area isn't empty
        if (::IsRectEmpty(&area)) { return; }
        // Make sure the y-scale is sane
        if (y_max <= y_min) { return; }
    
        std::vector<POINT> polygon{};
        // Reserve enough room for the data points plus bottom/right
        // and bottom/left to close the shape
        polygon.reserve(data.size() + 2);
    
        auto const area_width{ area.right - area.left };
        auto const area_height{ area.bottom - area.top };
        // Translate coordinates from data space to diagram space
        // In lieu of a `zip` view in C++ we're using a raw loop here
        // (we need the index to scale the x coordinate, so we cannot
        // use a range-based `for` loop)
        for (int index{}; index < static_cast<int>(data.size()); ++index) {
            // Scale x value
            auto const x = ::MulDiv(index, area_width - 1, static_cast<int>(data.size()) - 1) + area.left;
            // Flip y value so that the origin is in the bottom/left
            auto const y_flipped = y_max - (data[index] - y_min);
            // Scale y value
            auto const y = ::MulDiv(y_flipped, area_height - 1, y_max - y_min);
    
            polygon.emplace_back(POINT{ x, y });
        }
    
        // Semi-close the shape
        polygon.emplace_back(POINT{ area.right - 1, area.bottom - 1 });
        polygon.emplace_back(POINT{ area.left, area.bottom - 1 });
    
        // Prepare the DC for rendering
        auto const prev_pen{ ::SelectObject(dc, ::GetStockObject(DC_PEN)) };
        auto const prev_brush{ ::SelectObject(dc, ::GetStockObject(DC_BRUSH)) };
        ::SetDCPenColor(dc, pen_col);
        ::SetDCBrushColor(dc, fill_col);
    
        // Render the graph
        ::Polygon(dc, polygon.data(), static_cast<int>(polygon.size()));
    
        // Restore DC (stock objects do not need to be destroyed)
        ::SelectObject(dc, prev_brush);
        ::SelectObject(dc, prev_pen);
    }
    

    Most of this function deals with translating and scaling data values into the target (client) coordinate space. The actual rendering is fairly compact in comparison, and starts from the comment reading // Prepare the DC for rendering.

    To test this you can start from a standard Windows Desktop application, and dump the following into the WM_PAINT handler:

        case WM_PAINT:
        {
            RECT rc{};
            ::GetClientRect(hWnd, &rc);
            // Leave a 10px border around the diagram area
            ::InflateRect(&rc, -10, -10);
    
            PAINTSTRUCT ps;
            HDC hdc = BeginPaint(hWnd, &ps);
    
            auto pen_col = RGB(0x00, 0x91, 0x7C);
            auto fill_col = RGB(0xCC, 0xE9, 0xE4);
            render_diagram(hdc, rc, pen_col, fill_col, g_dataset1, 0, 100);
    
            pen_col = RGB(0x02, 0x59, 0x55);
            fill_col = RGB(0xCC, 0xDD, 0xDD);
            render_diagram(hdc, rc, pen_col, fill_col, g_dataset2, 0, 100);
    
            EndPaint(hWnd, &ps);
        }
        break;
    

    g_dataset1/g_dataset2 are containers holding random values that serve as test input. It is important to understand, that the final diagram is rendered back to front, meaning that data sets with smaller values need to be rendered after data sets with higher values; the lower portion gets repeatedly overdrawn.

    This produces output that looks something like this:

    Screenshot (standard DPI)

    Note that on a HiDpi display device GDI rendering gets auto-scaled. This produces the following:

    Screenshot (HiDpi non-DPI aware)

    Looking closely you'll observe that the lines are wider than 1 device pixel. If you'd rather have a more crisp look, you can disable DPI virtualization by declaring the application as DPI aware. Things don't change for a standard DPI display; on a HiDpi display the rendering now looks like this:

    Screenshot (HiDpi DPI aware