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 Goo.Components;
using Sandbox;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform;
namespace Sandbox;
public class CanvasUI : GooPanel<Container>
{
readonly Viewport _view = new() { MinZoom = 0.25f, MaxZoom = 4f };
Vector2 _lastMouse;
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.
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" ), "a (100, 100)" ) );
world.Children.Add( Card( "card-b", 520, 280, Color.Parse( "#22c55e" ), "b (520, 280)" ) );
world.Children.Add( Card( "card-c", 940, 580, Color.Parse( "#f97316" ), "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).
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 ); Rebuild(); }
},
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.
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 ); Rebuild(); }
},
OnMouseWheel = scroll =>
{
_view.ZoomAt( _lastMouse, scroll.y > 0 ? 1.1f : 1f / 1.1f );
Rebuild();
},
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:
Viewportowns state and math.PanByaccumulates a screen-space delta intoPan.ZoomAtmultipliesZoomby a factor, clamps it, then adjustsPanso the anchor point stays put. it knows nothing about panels.- the events (
OnMouseMove,OnMouseWheel) read input, call the relevantViewportmethod, and callRebuild(). the guarde.Target.HasActiveinOnMouseMoveis the only application-level policy here. World()reads_view.Zoomand_view.PaninBuild()and turns them into aPanelTransformwith origin at the container's top-left corner.
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.
see also¶
- panel transform - the
Viewportsurface, thePanelTransformchain ops, and the companion origin properties. - events - all six mouse-event properties and the
MousePanelEventpayload includingLocalPosition,Target, andHasActive. - container reference -
Overflow,Position,Left,Top, and the full layout surface forContainer. - build method - how
Rebuild()schedules a freshBuild(), structural diff, andKeyidentity.