This guide codifies the design patterns and conventions used across all 18 built-in diagram families and the 200+ shipped node types. Following these practices ensures your components integrate cleanly with the rendering pipeline, theme system, serialization engine, and interaction model.

Component Design

Keep DrawContent() Lightweight

DrawContent() is called on every frame during pan, zoom, and drag operations. A typical diagram with 200 components will call this method 200 times per frame at 60 FPS. Every allocation, path construction, and string measurement adds up quickly.

DoDon't
Pre-compute text measurements in Initialize() Call paint.MeasureText() inside DrawContent()
Cache SKPath objects and only rebuild when geometry changes Create new SKPath instances on every draw call
Use canvas.Save()/canvas.Restore() for localized transforms Leave the canvas in a modified state for subsequent draws
Use simple hit-test (bounds-based) by default Implement per-pixel hit-testing unless required for precise selection

Good: Lightweight DrawContent

private SKPath _cachedPath;
private SKRect _cachedBounds;

protected override void Initialize()
{
    base.Initialize();
    _cachedPath = new SKPath();
    BuildPath();
}

private void BuildPath()
{
    _cachedPath.Reset();
    _cachedPath.AddRoundRect(new SKRect(0, 0, Width, Height), 8f, 8f);
    _cachedBounds = _cachedPath.Bounds;
}

public override void DrawContent(SKCanvas canvas)
{
    canvas.Save();
    canvas.Translate(X, Y);
    canvas.DrawPath(_cachedPath, FillPaint);
    canvas.DrawPath(_cachedPath, StrokePaint);
    canvas.Restore();
}

SKPaint Pooling

Creating and destroying SKPaint objects is expensive. Pool them at the component level and reuse across draw calls:

Bad: Creating Paints in DrawContent

// DON'T DO THIS
public override void DrawContent(SKCanvas canvas)
{
    using var fill = new SKPaint { Color = SKColors.Blue, IsAntialias = true };
    using var stroke = new SKPaint { Color = SKColors.Black, Style = SKPaintStyle.Stroke };
    canvas.DrawRect(Bounds, fill);
    canvas.DrawRect(Bounds, stroke);
}

Good: Pooled Paints

// DO THIS INSTEAD
private SKPaint _fillPaint;
private SKPaint _strokePaint;

protected override void Initialize()
{
    base.Initialize();
    _fillPaint = new SKPaint { Color = SKColors.Blue, IsAntialias = true };
    _strokePaint = new SKPaint
    {
        Color = SKColors.Black,
        Style = SKPaintStyle.Stroke,
        IsAntialias = true,
        StrokeWidth = 2f
    };
}

public override void DrawContent(SKCanvas canvas)
{
    canvas.DrawRect(Bounds, _fillPaint);
    canvas.DrawRect(Bounds, _strokePaint);
}

public override void Dispose()
{
    _fillPaint?.Dispose();
    _strokePaint?.Dispose();
    base.Dispose();
}

Cache Complex Paths

If a component has a complex shape that only changes when Width or Height change, cache the SKPath and only rebuild it when the component is resized:

Path Caching Pattern

private SKPath _shapePath;
private float _lastWidth, _lastHeight;

private void EnsurePath()
{
    if (_shapePath != null && Math.Abs(_lastWidth - Width) < 0.01f
                           && Math.Abs(_lastHeight - Height) < 0.01f)
        return;

    _shapePath?.Dispose();
    _shapePath = new SKPath();

    // Build complex shape
    _shapePath.MoveTo(0, Height * 0.2f);
    _shapePath.LineTo(Width * 0.5f, 0);
    _shapePath.LineTo(Width, Height * 0.2f);
    _shapePath.LineTo(Width * 0.8f, Height);
    _shapePath.LineTo(Width * 0.2f, Height);
    _shapePath.Close();

    _lastWidth = Width;
    _lastHeight = Height;
}

Port Management

Always Call MarkPortsDirty() After Position/Size Changes

Connection points are computed lazily. If your component changes position, size, or layout, it must signal that port positions need recomputation. SkiaComponent already calls MarkPortsDirty() from its bounds setters, so call it yourself only when your own properties change port geometry:

Signalling a Port Layout Change

public int PortCount
{
    get => _portCount;
    set
    {
        if (_portCount == value) return;
        _portCount = value;
        MarkPortsDirty();      // inherited from SkiaComponent
        InvalidateVisual();    // also repaint the node
    }
}

Use Lazy Port Layout

Port geometry is recomputed only when the layout is stale. EnsurePortLayout(() => LayoutPorts()) is the gate: it runs your layout action only while ArePortsDirty is true, then clears the flag. Call it at the top of DrawContent:

Lazy Port Layout Pattern

protected override void DrawContent(SKCanvas canvas, DrawingContext context)
{
    EnsurePortLayout(() => LayoutPorts());
    // ... draw the node
}

protected override void LayoutPorts()
{
    var b = Bounds;
    float midY = b.MidY;

    InConnectionPoints.Clear();
    OutConnectionPoints.Clear();

    InConnectionPoints.Add(new ConnectionPoint
    {
        Type = ConnectionPointType.In,
        Center = new SKPoint(b.Left, midY),
        Position = new SKPoint(b.Left, midY),
        DataType = "object",
        Component = this,
        IsAvailable = true
    });

    OutConnectionPoints.Add(new ConnectionPoint
    {
        Type = ConnectionPointType.Out,
        Center = new SKPoint(b.Right, midY),
        Position = new SKPoint(b.Right, midY),
        DataType = "object",
        Component = this,
        IsAvailable = true
    });
}

Flowchart nodes get this for free: FlowchartControl.DrawContent already calls EnsurePortLayout(() => LayoutPorts()) and then delegates to DrawFlowchartContent, so derived nodes only override LayoutPorts() and DrawFlowchartContent().

Critical

Failing to mark ports dirty after a geometry change results in connection lines detaching from ports or rendering at stale positions. OnBoundsChanged() marks them dirty for moves and resizes; call MarkPortsDirty() yourself when your own properties change port geometry.

Port Count Guidelines

Component TypeInput PortsOutput PortsTypical Pattern
Simple node1–21–2Left/right or top/bottom pairs
Decision/condition node12–4Branching paths with labeled ports
ERD entity0–N0–NPer-field connection points
Container/pool00No ports — parenting via bounds containment

Performance

Avoid Allocations in OnPaint

The rendering path is extremely hot. Allocate once in Initialize(), reuse across frames, and dispose in Dispose():

Allocation SourceImpactMitigation
new SKPaint()GC pressure at 60 FPSPool paint objects as class fields
new SKPath()Native allocation overheadCache and rebuild only on geometry changes
new SKRect() / new SKPoint()Stack-allocated structs — freeNo action needed; these are value types
String concatenation for labelsGC from intermediate stringsPre-compute display strings in Initialize()
LINQ expressionsEnumerable allocationsUse plain for/foreach loops

Use Pre-Computed SKRect for Bounds

The Bounds property is accessed by hit-testing, spatial indexing, and clipping. Compute it once and invalidate only when position or size changes:

Pre-Computed Bounds

private SKRect _bounds;

public override SKRect Bounds
{
    get
    {
        if (_boundsDirty)
        {
            _bounds = new SKRect(X, Y, X + Width, Y + Height);
            _boundsDirty = false;
        }
        return _bounds;
    }
}

public override void Invalidate()
{
    _boundsDirty = true;
    base.Invalidate();
}

Batch Canvas Save/Restore

Each canvas.Save()/canvas.Restore() pair pushes/pops a state onto the canvas stack. Minimize nesting depth:

Good: Single Save/Restore Block

public override void DrawContent(SKCanvas canvas)
{
    canvas.Save();
    canvas.Translate(X, Y);
    canvas.ClipRect(new SKRect(0, 0, Width, Height));

    DrawBackground(canvas);
    DrawIcon(canvas);
    DrawLabel(canvas);
    DrawSelectionHighlight(canvas);

    canvas.Restore();
}

NodeProperties

Use ParameterInfo for Dynamic Properties

Instead of hardcoding property editors in WinForms PropertyGrid controls, expose component configuration through the NodeProperties dictionary. This enables the built-in dynamic property grid to render appropriate editors without reflection:

Property Definition Best Practices

protected override void InitializeComponent()
{
    base.InitializeComponent();

    NodeProperties["Text"] = new ParameterInfo
    {
        ParameterName = "Text",
        ParameterCurrentValue = "New Node",
        DefaultParameterValue = "New Node",
        ParameterType = typeof(string),
        Description = "Label text"
    };

    NodeProperties["FillColor"] = new ParameterInfo
    {
        ParameterName = "FillColor",
        ParameterCurrentValue = SKColors.CornflowerBlue,
        DefaultParameterValue = SKColors.CornflowerBlue,
        ParameterType = typeof(SKColor),
        Description = "Fill color"
    };

    NodeProperties["IsRequired"] = new ParameterInfo
    {
        ParameterName = "IsRequired",
        ParameterCurrentValue = false,
        DefaultParameterValue = false,
        ParameterType = typeof(bool),
        Description = "Required field"
    };

    NodeProperties["Priority"] = new ParameterInfo
    {
        ParameterName = "Priority",
        ParameterCurrentValue = Priority.Medium,
        DefaultParameterValue = Priority.Medium,
        ParameterType = typeof(Priority),   // enum
        Choices = new[] { "Low", "Medium", "High" },
        Description = "Priority"
    };

    NodeProperties["BorderRadius"] = new ParameterInfo
    {
        ParameterName = "BorderRadius",
        ParameterCurrentValue = 8f,
        DefaultParameterValue = 8f,
        ParameterType = typeof(float),
        Description = "Corner radius"
    };
}
ParameterTypeRendered Widget
stringSingle-line text input
SKColorColor swatch with picker dialog
boolToggle checkbox
enumDropdown selector (optionally constrained by Choices)
float / intNumeric input

NodeProperties Conventions

Keep ParameterName, the dictionary key and Description consistent across your family, and always set DefaultParameterValue so the property grid can reset values. Use Choices for enumerations that should render as a fixed dropdown.

Connection Validation

Check DataType Compatibility Before Connecting

When users drag connections between ports, validate that the source and target data types are compatible. This prevents runtime errors during data flow and provides immediate visual feedback:

Connection Validation

public static bool CanConnect(IConnectionPoint source, IConnectionPoint target)
{
    if (source == null || target == null)
        return false;

    if (source.Type == target.Type)
        return false;  // Can't connect two inputs or two outputs

    if (ReferenceEquals(source.Component, target.Component))
        return false;  // Can't self-connect

    if (target.Connection != null)
        return false;  // Target input already has a connection

    // Data type compatibility: DataType is a descriptive string (e.g. "string", "number")
    if (!string.IsNullOrEmpty(target.DataType) &&
        !string.Equals(target.DataType, source.DataType, StringComparison.Ordinal))
        return false;

    return true;
}

Handle Null Ports Gracefully

Connection operations can encounter null ports when components are being added, removed, or re-laid-out during a drag. Always guard with null checks:

Null-Safe Connection Handling

public void Connect(IConnectionPoint source, IConnectionPoint target)
{
    if (source == null) throw new ArgumentNullException(nameof(source));
    if (target == null) throw new ArgumentNullException(nameof(target));

    if (!CanConnect(source, target))
    {
        LogWarning($"Cannot connect {source.Component?.GetType().Name} to {target.Component?.GetType().Name}");
        return;
    }

    var connection = new ConnectionLine(source, target, () => RequestRedraw());
    source.ConnectTo(target);
    target.ConnectTo(source);

    RequestRedraw();
}

Connection DataType Convention

Ports carry a descriptive DataType string (for example "string", "number", "object") used for data-flow visualization and connection validation. Leave it empty for generic connections (most common for diagramming) and set it explicitly when your family models typed data flow; the DataFlow Diagram (DFD) and ETL families use typed connections extensively.

Anti-Patterns

These patterns appear in naive implementations and cause rendering bugs, memory leaks, or performance degradation. Avoid them.

Anti-PatternWhy It's WrongCorrect Approach
Creating components inside DrawContent() Adds components to the drawing manager every frame, causing exponential growth and rendering chaos Create components in response to user actions (toolbox drag, context menu, keyboard shortcut), never during rendering
Modifying ConnectionPoints during rendering The rendering loop iterates over connection points; mutating the collection causes InvalidOperationException Modify ports only in LayoutPorts(), which is called outside the render loop
Forgetting to Dispose() SKPaint objects Native Skia memory leaks. GC does not reclaim unmanaged Skia resources promptly Override Dispose(), call Dispose() on all SKPaint, SKPath, SKBitmap, and SKSurface fields, then call base.Dispose()
Calling canvas.Invalidate() inside DrawContent() Creates an infinite render loop: draw → invalidate → draw → ... Invalidate only in response to input events, property changes, or timer callbacks
Using SKMatrix on canvas directly SKMatrix modifies global canvas state; subsequent components inherit the transform Always wrap transforms in canvas.Save()/canvas.Restore()
Storing references to SKCanvas The canvas object is transient; it changes between frames and between render targets Accept SKCanvas as a method parameter, never store it as a field

The Most Dangerous Anti-Pattern

Never create SkiaComponent instances inside DrawContent(). This is the single most destructive anti-pattern. Each frame will add new components to the DrawingManager, which in turn will be drawn on the next frame, adding more components, and so on. The application will freeze within seconds. Component creation must always happen in response to discrete user actions outside the render loop.

Testing

Beep.Skia components are visual, but they are also pure data objects with deterministic rendering output. Unit testing is practical and encouraged.

Rendering Smoke Tests

Verify that every component renders without throwing exceptions and produces non-empty pixel output:

Rendering Smoke Test

[Test]
public void ValveNode_RendersSuccessfully()
{
    using var surface = SKSurface.Create(new SKImageInfo(200, 200));
    var canvas = surface.Canvas;
    canvas.Clear(SKColors.White);

    var node = new ValveNode();
    node.Initialize();
    node.X = 50;
    node.Y = 50;

    Assert.DoesNotThrow(() => node.DrawContent(canvas));

    using var image = surface.Snapshot();
    using var data = image.Encode(SKEncodedImageFormat.Png, 100);

    // Verify non-empty output
    Assert.That(data.Size, Is.GreaterThan(100), "Rendered output should be non-empty");

    node.Dispose();
}

Serialization Round-Trip Tests

Every component family must survive a serialize → deserialize round-trip without property loss:

Serialization Round-Trip Test

[Test]
public void ValveNode_SerializeDeserialize_RoundTrip()
{
    var original = new ValveNode();
    original.Initialize();
    original.X = 150;
    original.Y = 200;
    original.Width = 80;
    original.Height = 100;
    original.NodeLabel = "Test Valve";

    var json = JsonSerializer.Serialize(original);
    var restored = JsonSerializer.Deserialize<ValveNode>(json);

    Assert.That(restored.X, Is.EqualTo(150));
    Assert.That(restored.Y, Is.EqualTo(200));
    Assert.That(restored.Width, Is.EqualTo(80));
    Assert.That(restored.Height, Is.EqualTo(100));
    Assert.That(restored.NodeLabel, Is.EqualTo("Test Valve"));
    Assert.That(restored.Id, Is.EqualTo(original.Id));

    original.Dispose();
    restored.Dispose();
}

Port Layout Tests

Verify that ports are positioned correctly and respond to position/size changes:

Port Layout Test

[Fact]
public void ValveNode_PortsUpdateAfterMove()
{
    var node = new ValveNode { X = 100, Y = 100 };
    var manager = new DrawingManager();
    manager.AddComponent(node);

    using var bitmap = new SKBitmap(400, 200, SKColorType.Rgba8888, SKAlphaType.Unpremul);
    using var canvas = new SKCanvas(bitmap);

    manager.Draw(canvas);   // first draw computes the port layout
    var before = node.OutConnectionPoints[0].Center;

    node.X = 300;           // bounds change marks the layout dirty
    manager.Draw(canvas);   // next draw recomputes ports once

    var after = node.OutConnectionPoints[0].Center;
    Assert.True(after.X > before.X);
    Assert.Equal(before.Y, after.Y, 2);   // horizontal move, Y unchanged
}

Test Checklist

Test CategoryWhat to VerifyPriority
RenderingNo exceptions, non-empty output, correct color at known coordinatesHigh
SerializationRound-trip preserves Id, position, size, all NodePropertiesHigh
Port LayoutPorts reposition after a move, ports added/removed on size change, lazy layout recomputed once per changeMedium
NodePropertiesAll properties in dictionary, correct DataType, correct default valuesMedium
DisposeNo memory leak after 1000 create/dispose cycles, no ObjectDisposedExceptionMedium
Hit TestingHits at center, misses at far-outside point, edge behavior at boundsLow
Connection ValidationValid connections accepted, self-connect rejected, type-mismatch rejectedLow