a pannable canvas

drag to pan, scroll-wheel to zoom, anchored under the cursor

by the end of this guide you will have a full-screen canvas you can drag to pan and scroll to zoom. the zoom is anchored under the cursor so the world point under it stays fixed, and the scale is clamped to a min and max so you cannot zoom out to nothing or in to infinity. this is the foundation for a node graph, a map, or any zoomable diagram you build on top.

the viewport helper

goo ships Goo.Viewport, a plain C# object that owns pan and zoom state and keeps the anchor math correct.

create Code/Canvas/CanvasUI.cs and declare the class with a Viewport field:

using Goo;
using Sandbox;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform;

namespace Goo.Demo;

public class CanvasUI : GooPanel<Container>
{
    readonly Viewport _view = new() { MinZoom = 0.25f, MaxZoom = 4f };
    Vector2 _lastMouse;
    bool _showHud = true;

    protected override void OnEnabled()
    {
        base.OnEnabled();
        Panel.Style.Width  = Length.Percent( 100 );
        Panel.Style.Height = Length.Percent( 100 );
    }
}

MinZoom and MaxZoom are init-only clamps. _lastMouse tracks the cursor position so you can compute drag deltas and pass it to ZoomAt. _showHud is used later, once there is a HUD to toggle.

the outer shell

add a Build() that returns a full-screen container with Overflow.Hidden. clipping is what makes pan feel like a viewport rather than a panel that slides around on top of the page.

protected override Container Build() => new Container
{
    Width           = Length.Percent( 100 ),
    Height          = Length.Percent( 100 ),
    BackgroundColor = Color.Black.WithAlpha( 0.85f ),
    Overflow        = OverflowMode.Hidden,
    Children =
    {
        World(),
    },
};

the transformed world

add the World() helper. this container holds the canvas content and is the only thing that moves. TransformOriginX and TransformOriginY are both 0 so the scale pivot matches the top-left origin Viewport math assumes (see panel transform).

Container World()
{
    var world = new Container
    {
        Key              = "world",
        Position         = PositionMode.Absolute,
        Left             = 0,
        Top              = 0,
        TransformOriginX = 0,
        TransformOriginY = 0,
        Transform = PanelTransform
            .Scale( _view.Zoom )
            .Translate( Px.Of( _view.Pan.x ), Px.Of( _view.Pan.y ) ),
    };

    // placeholder content: a few colored cards at world coordinates
    world.Children.Add( Card( "card-a", 100, 100, Color.Parse( "#3c82f6" ).Value, "a (100, 100)" ) );
    world.Children.Add( Card( "card-b", 520, 280, Color.Parse( "#22c55e" ).Value, "b (520, 280)" ) );
    world.Children.Add( Card( "card-c", 940, 580, Color.Parse( "#f97316" ).Value, "c (940, 580)" ) );

    return world;
}

static Container Card( string key, float x, float y, Color color, string label ) => new Container
{
    Key             = key,
    Position        = PositionMode.Absolute,
    Left            = x,
    Top             = y,
    Padding         = 14,
    BorderRadius    = 10,
    BackgroundColor = color,
    Children        = { new Text( label ) { FontColor = Color.White } },
};

Key on the world container lets goo recognize it across rebuilds so the element is patched rather than torn down and re-created on every mouse-move.

wiring pan

pan updates on OnMouseMove. you compute the delta from the previous position and call PanBy only when the mouse button is held (e.Target.HasActive). _view and _lastMouse are plain fields mutated only inside this handler, so there is no Rebuild() to call: goo rebuilds automatically once the handler returns.

update Build() to add the event:

protected override Container Build() => new Container
{
    Width           = Length.Percent( 100 ),
    Height          = Length.Percent( 100 ),
    BackgroundColor = Color.Black.WithAlpha( 0.85f ),
    Overflow        = OverflowMode.Hidden,
    OnMouseMove = e =>
    {
        var delta = e.LocalPosition - _lastMouse;
        _lastMouse = e.LocalPosition;
        if ( e.Target.HasActive ) _view.PanBy( delta );
    },
    Children =
    {
        World(),
    },
};

press play, hold the left button, and drag. the cards move with the cursor.

wiring zoom

add OnMouseWheel to the same container. _lastMouse already holds the cursor position from the last OnMouseMove, so you can pass it straight to ZoomAt. a positive scroll.y zooms in (factor 1.1), negative zooms out (factor 1 / 1.1). Viewport clamps the result to [MinZoom, MaxZoom] automatically, and again there is no Rebuild() to write: the wheel handler auto-rebuilds the same way the move handler does.

protected override Container Build() => new Container
{
    Width           = Length.Percent( 100 ),
    Height          = Length.Percent( 100 ),
    BackgroundColor = Color.Black.WithAlpha( 0.85f ),
    Overflow        = OverflowMode.Hidden,
    OnMouseMove = e =>
    {
        var delta = e.LocalPosition - _lastMouse;
        _lastMouse = e.LocalPosition;
        if ( e.Target.HasActive ) _view.PanBy( delta );
    },
    OnMouseWheel = scroll => _view.ZoomAt( _lastMouse, scroll.y > 0 ? 1.1f : 1f / 1.1f ),
    Children =
    {
        World(),
    },
};

scroll over card-b and the card stays fixed under the cursor.

what just happened

there are three layers, each with one job:

because Build() is a pure function of _view.Zoom and _view.Pan, every rebuild from any input produces the correct frame. there is no incremental state to get out of sync.

one consequence worth knowing: AutoRebuildOnEvents fires after the handler returns, not after a value actually changes. moving the mouse over the canvas without holding a button still runs OnMouseMove and still triggers a rebuild, even though _view did not change that frame. Build() producing the same PanelTransform keeps the diff cheap, but it is still a full Build() call every frame the mouse moves. the next section measures exactly that cost.

showing pan and zoom

a small HUD readout makes the anchor-under-cursor behavior easy to verify: watch the numbers while you scroll and Pan should jump so the world point under the cursor keeps its screen position.

add the field for the toggle, then two more helpers. Hud() reads _view directly, the same as World() does. ToggleButton() flips _showHud, a plain field mutated only inside its own OnClick, so again no Rebuild() is needed.

Container Hud() => new Container
{
    Key             = "hud",
    Position        = PositionMode.Absolute,
    Left            = 16,
    Top             = 16,
    Padding         = 10,
    BorderRadius    = 6,
    BackgroundColor = Color.White.WithAlpha( 0.15f ),
    Children        = { new Text( $"zoom {_view.Zoom:F2}  pan ({_view.Pan.x:F0}, {_view.Pan.y:F0})" ) { FontColor = Color.White } },
};

Container ToggleButton() => new Container
{
    Key             = "hud-toggle",
    Position        = PositionMode.Absolute,
    Right           = 16,
    Top             = 16,
    Padding         = 8,
    BorderRadius    = 6,
    BackgroundColor = Color.White.WithAlpha( 0.15f ),
    OnClick         = _ => _showHud = !_showHud,
    Children        = { new Text( _showHud ? "hide info" : "show info" ) { FontColor = Color.White } },
};

add both to the outer container's Children. Hud() is only added when _showHud is true, using a nullable ternary directly in the initializer; Children.Add<T>(in T? child) skips a null silently, so the false branch costs nothing:

Children =
{
    World(),
    ToggleButton(),
    _showHud ? Hud() : null,
},

click "hide info" and the readout disappears; ToggleButton() stays put since it is unconditional.

measuring the cost of panning

the previous section noted that every OnMouseMove, held button or not, costs a full Build(), diff, and apply. Goo.Perf measures exactly that.

set Perf.Enabled = true and reset the counters before the scenario you want to measure, exercise the canvas, then read Perf.Report():

Perf.Enabled = true;
Perf.Reset();
// drag and scroll around the canvas for a few seconds, then check the log
Log.Info( Perf.Report() );

Report() breaks down total and per-rebuild time across the build, diff, and apply phases, plus a per-op-kind table. on a three-card world like this one, expect the diff and apply phases to be tiny even at a high mouse-move rate, since Key keeps every card matched to its prior instance and only the world container's Transform op actually changes. leave Perf.Enabled false in shipped code: the check costs a branch per rebuild and per op even while off, so only turn it on for the measurement itself.

see also