Pinknose.Cytoscape.Razor 0.1.0-beta.1

This is a prerelease version of Pinknose.Cytoscape.Razor.
dotnet add package Pinknose.Cytoscape.Razor --version 0.1.0-beta.1
                    
NuGet\Install-Package Pinknose.Cytoscape.Razor -Version 0.1.0-beta.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Pinknose.Cytoscape.Razor" Version="0.1.0-beta.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Pinknose.Cytoscape.Razor" Version="0.1.0-beta.1" />
                    
Directory.Packages.props
<PackageReference Include="Pinknose.Cytoscape.Razor" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Pinknose.Cytoscape.Razor --version 0.1.0-beta.1
                    
#r "nuget: Pinknose.Cytoscape.Razor, 0.1.0-beta.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Pinknose.Cytoscape.Razor@0.1.0-beta.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Pinknose.Cytoscape.Razor&version=0.1.0-beta.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Pinknose.Cytoscape.Razor&version=0.1.0-beta.1&prerelease
                    
Install as a Cake Tool

Cytoscape.js for Blazor/.NET

ci docs

📖 API documentation

Pinknose.Cytoscape.Razor — an independent Blazor wrapper for Cytoscape.js 3.34.1, not affiliated with the Cytoscape.js project.

Render graphs — including compound (nested) graphs — from your own domain types through a typed <CytoscapeGraph> component, with a strongly-typed selector and stylesheet API and no hand-written JS interop. Runs under both Blazor Server and Blazor WebAssembly.

Beta. The API is likely to change, including in ways that break existing code, and under 0.x those changes arrive in minor versions rather than major ones. Pin an exact version if you depend on it.

Changes to the public surface are deliberate and recorded — the build fails if a public member is added, removed or changed without updating PublicAPI.*.txt — so breaks show up in the diff and in release notes, not silently.

Installing

dotnet add package Pinknose.Cytoscape.Razor

Targets .NET 10, and needs a Blazor app with an interactive render mode — Server or WebAssembly, both supported. No script tag or JS setup: the component brings its own assets, including the vendored Cytoscape bundle.

Minimal example

Implement ICyNode/ICyEdge on your own domain types (only Id, or Id/Source/Target, are required — everything else has a default), or use the ready-made CyNode/CyEdge:

public sealed record Person(string Id, string Name) : ICyNode
{
    public string? Label => Name;
}

public sealed record Reports(string Id, string Source, string Target) : ICyEdge;

Both must be reference typesclass or record, not record struct. TNode and TEdge are constrained to class on purpose: Elements.ParentAsync answers null for a node that is not nested, and for a struct node type that answer cannot be expressed at all — the call would hand back default(TNode), an instance with a null Id that no caller could tell from a real parent, and that ?. will not even compile against.

<CytoscapeGraph TNode="Person" TEdge="Reports"
                Nodes="@_people"
                Edges="@_reports"
                Stylesheet="@_stylesheet"
                Height="600px" />

@code {
    private List<Person> _people = [new("p1", "Alice"), new("p2", "Bob")];
    private List<Reports> _reports = [new("e1", "p2", "p1")];

    // Cytoscape draws unstyled elements as plain grey circles with no labels, so a stylesheet
    // is normally required to get a useful picture.
    private CyStylesheet _stylesheet = new()
    {
        { CySelector.Node(), new CyNodeStyle
            { Text = new() { Label = CyMap.Data("label"), Valign = CyValign.Center } } },
    };
}

The type-argument trap (read this first)

Blazor can infer TNode/TEdge from the Nodes/Edges collections only while the component has no EventCallback parameters wired up. The moment you add any event handler — OnNodeTap, OnEdgeMouseOver, any of them — type inference breaks and the compiler fails with CS1503 pointing at the callback, not at the missing type arguments.

@* Fails to compile with CS1503 once OnNodeTap is added, unless TNode/TEdge are explicit *@
<CytoscapeGraph TNode="Person" TEdge="Reports"
                Nodes="@_people"
                Edges="@_reports"
                OnNodeTap="@(e => _selected = e.Node)" />

This is almost always the first thing a new consumer hits. As soon as you add any event callback, add TNode="..." and TEdge="..." explicitly — don't wait for the error to tell you where to look.

The parameter is Stylesheet, not Style

Styling is set through the Stylesheet parameter, not Style. That naming is deliberate: Blazor resolves parameter names case-insensitively against a component's declared parameters before falling back to AdditionalAttributes, so a parameter literally named Style would intercept a consumer's plain HTML style="..." attribute and crash trying to bind it to CyStylesheet instead of letting it flow through as CSS. Don't "helpfully" rename it.

Compound graphs

Nested (compound) graphs use one flat Nodes collection: a node names the id of the node that visually contains it through Parent. There is no separate container concept to keep in sync.

public sealed record Department(string Id, string Name) : ICyNode
{
    public string? Label => Name;
}

public sealed record Person(string Id, string Name, string DepartmentId) : ICyNode
{
    public string? Label => Name;
    public string? Parent => DepartmentId;
}

Nesting is changed by changing Parent, and every transition works: naming a different department moves a person into it, and returning null lifts them back to the top level even when their old department is still in the graph.

Removing a container node while its children still name it as Parent does not delete them: Cytoscape's own removal cascade would, but this library detects it first and re-homes the orphaned children at the top level instead, logging a warning through ILogger. Remove the children explicitly if you actually want them gone.

One asymmetry is worth knowing about, because it is Cytoscape's and not this library's: element Data is merged across an update, not replaced. A key you supplied once and then stop supplying keeps its last value in the browser. Set it to null explicitly to clear it. Everything else — Parent, Classes, Locked, Selectable, Grabbable — is sent on every update precisely so that it can be cleared.

Driving the graph imperatively

Most of the time you change Nodes, Edges, Stylesheet or Layout and the component pushes the difference. For the things that have no declarative equivalent — moving the camera, asking what is selected, walking a hierarchy — capture the component with @ref and use its three handles: Viewport, Elements and Algorithms.

<CytoscapeGraph @ref="_graph" TNode="Person" TEdge="Reports"
                Nodes="@_people" Edges="@_reports" />

<button @onclick="ShowAliceAsync">Focus Alice's team</button>

@code {
    private CytoscapeGraph<Person, Reports>? _graph;

    private async Task ShowAliceAsync()
    {
        // Move the camera.
        await _graph!.Viewport.FitAsync(CySelector.Node().Class("engineering"), padding: 30);

        // Queries hand back YOUR instances, not id strings, so a result is usable directly.
        foreach (var person in await _graph.Elements.QueryNodesAsync(CySelector.Node().Selected()))
        {
            Console.WriteLine(person.Name);   // your own property, not a Cytoscape one
        }

        // Compound traversal, which is what compound graphs are for. ParentAsync answers
        // null for a node that is not nested -- a real null, because TNode is a reference
        // type -- so it is a case to handle rather than to suppress with `!`.
        if (await _graph.Elements.ParentAsync("alice") is { } department)
        {
            var everyone = await _graph.Elements.DescendantsAsync(department.Id);
        }
    }
}

Every one of these throws InvalidOperationException until the graph has finished initializing, and again once the component is disposed. Refusing is deliberate: a queued call would need a disposal rule of its own, and a silent no-op would hide the mistake entirely.

Initialization runs asynchronously after the component's first render, so an unguarded call from your own OnAfterRenderAsyncfirstRender included — is still too early and is refused. For a call at startup, use OnReady:

<CytoscapeGraph @ref="_graph" TNode="Person" TEdge="Reports"
                Nodes="@_people" Edges="@_reports"
                OnReady="FitOnceAsync" />

@code {
    private async Task FitOnceAsync() => await _graph!.Viewport.FitAsync(padding: 30);
}

OnReady is raised once per successful initialization, and not at all if initialization fails — a failed attempt is retried on the next render, and OnReady fires if that later attempt succeeds.

If you would rather await than handle a callback, await the Ready task instead. It completes true when the graph is live and false if initialization failed or the component was disposed first, so check the result rather than assuming it means ready.

Await it from your own OnAfterRenderAsync — that is where it belongs, and the @ref that gives you the component is not assigned until after the first render, so there is nowhere earlier to await it from. This is the same method whose unguarded call is refused above; the await is exactly what makes the difference, and it does not deadlock.

@code {
    private CytoscapeGraph<Person, Reports>? _graph;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender)
        {
            return;
        }

        if (await _graph!.Ready)
        {
            await _graph.Viewport.FitAsync(padding: 30);
        }
    }
}

Read Ready at the moment you await it rather than caching the task — a retry installs a fresh one, and a task captured before a failed attempt has already completed false.

Anything driven by a user action, or by one of the component's own event callbacks, is safe without either: the graph must already be live to have raised it.

    private async Task OnNodeTapAsync(CyNodeEventArgs<Person> e)
        => await _graph!.Viewport.CenterAsync(CySelector.Node().Id(e.Node.Id));

Exporting an image on Blazor Server

Viewport.ExportPngAsync() and ExportJpgAsync() return the image as a data URI — one string, which on Blazor Server crosses SignalR from the browser back to the server. HubOptions.MaximumReceiveMessageSize defaults to 32 KB. Blazor answers an oversized message by closing the circuit, so there is no exception to catch, nothing is logged, and the returned ValueTask<string> never completes.

PNG exceeds that almost immediately: a seven-node graph at 1280x720 measures 51,186 characters, 1.6x the cap. JPEG compresses far harder — the same graph at Quality = 0.8 measures 10,979 characters, comfortably under it. That is a fact about that graph, not a guarantee: more elements, a bigger viewport, higher quality or a background colour all push it up, and JPEG has no size at which it stops being able to cross 32 KB.

Until this is chunked, either cap the image with Width/Height, or raise the limit in the host:

    builder.Services.AddRazorComponents()
        .AddInteractiveServerComponents(options => options.MaximumReceiveMessageSize = 4 * 1024 * 1024);

Blazor WebAssembly is unaffected — nothing crosses a wire there.

Stylesheet vs. per-element styling, and why Data is opt-in

Stylesheet is an ordered set of selector rules — the typed equivalent of a Cytoscape .json/CSS-like stylesheet — not a per-element style bag. A rule matches every element satisfying its selector and stays live: an element added later is styled automatically, and a rule that reads element data repaints on its own when that data changes, with no round trip. Where two rules set the same property on the same element, the later rule wins.

ICyNode.Data/ICyEdge.Data is how your domain values become visible to that stylesheet (for example a data-driven rule like [weight > 5], or CyMap.Data("field")/CyMap.MapData(...)). It is opt-in on purpose: nothing from your domain type reaches the browser unless you name it in Data. This is both a privacy guarantee (a field you don't list can't leak into the page) and a trim-safety one — treat it as deliberate, not an oversight.

A few rules govern what you put there:

  • Values must be JSON scalars: strings, numbers, booleans, or null. DateTime, DateTimeOffset and Guid serialize in their standard textual form; enums and other custom types do not — convert them yourself.
  • The keys id, parent, source and target are reserved: the component writes the element's own identity and relationships into them, overwriting anything you put there. label is yours if you set it — it is only filled in from Label when you have not.

Styling

Style properties are grouped into small typed sub-objects — Background, Border, Outline, Overlay, Text, Line, Taxi and so on — rather than a flat bag of ~150 hyphenated names. Nothing is a magic string: enums carry the exact tokens Cytoscape accepts, and a number reaches it as a JSON number rather than a quoted string.

var stylesheet = new CyStylesheet
{
    { CySelector.Node(), new CyNodeStyle
        {
            Width = 40, Height = 40,
            Background = new() { Color = "#7f8c9a" },
            Text = new() { Label = CyMap.Data("label"), Valign = CyValign.Center, Color = "#fff" },
            // The stylesheet re-evaluates itself when element data changes, so this animates
            // the repaint instead of jumping to it.
            Transition = new()
            {
                Properties = ["background-color", "width", "height"],
                DurationMs = 250,
                TimingFunction = CyEasing.EaseOutQuad,
            },
        } },

    // A compound container: a gradient box drawn behind its children.
    { CySelector.Node().Class("department"), new CyNodeStyle
        {
            Shape = CyNodeShape.RoundRectangle,
            Background = new()
            {
                Fill = CyFill.LinearGradient,
                GradientStopColors = ["#eef3f7", "#c2ced9"],
                GradientStopPositions = [0, 100],
                GradientDirection = CyGradientDirection.ToBottom,
                Opacity = 0.4,
            },
            // Pixels here. `Padding` also takes a percentage --
            // CyKeywordValue.Keyword("20%") -- and that is the case PaddingRelativeTo picks
            // the basis for; against a pixel padding it has nothing to measure.
            Padding = 16,
            PaddingRelativeTo = CyPaddingRelativeTo.Max,
        } },

    // An outline is a ring OUTSIDE the border, so selection state can be shown without
    // disturbing the border the node already has.
    { CySelector.Node().Selected(), new CyNodeStyle
        { Outline = new() { Width = 3, Color = "#c0392b", Offset = 2 } } },

    // Cytoscape has no `:hover`; `:active` is the pseudo-class it raises while an element is
    // being pointed at or pressed.
    { CySelector.Node().Active(), new CyNodeStyle
        { Overlay = new() { Color = "#3498db", Opacity = 0.25, Padding = 6 } } },

    // Right-angled routing, which is what an org chart actually wants. RoundTaxi, not Taxi:
    // Cytoscape enables corner rounding on the curve style's name, so `Radius` is only read
    // under the round variant.
    { CySelector.Edge(), new CyEdgeStyle
        {
            Width = 2,
            CurveStyle = CyCurveStyle.RoundTaxi,
            Taxi = new() { Direction = CyTaxiDirection.Upward, Radius = 6 },
            Line = new() { Color = "#95a5a6" },
            TargetArrow = new() { Shape = CyArrow.Triangle, Color = "#95a5a6" },
        } },

    { CySelector.Edge().Class("dotted"), new CyEdgeStyle
        { Line = new() { Style = CyEdgeLineType.Dashed, DashPattern = [6, 4] } } },

    // `core` styles the viewport rather than any element, and is the only selector a
    // CyViewportStyle accepts.
    { CySelector.Core(), new CyViewportStyle
        { SelectionBox = new() { Color = "#3498db", Opacity = 0.2 } } },
};

Cytoscape warns about a style value it cannot use and then ignores it — the same silent failure an invalid selector has — so a misspelled property name or an out-of-range enum token would serialize perfectly and simply do nothing. That is what the enums exist to prevent, and the browser test suite asserts the sample's stylesheet produces no style warnings at all, and that no property name it emits turns out to be one of Cytoscape's aliases (which write a different property than the one named, and warn about nothing).

What is not modelled

The gap is a documented list rather than an unknown one. Not covered — whole properties first, then the values a modelled property does not accept:

  • pie-* and stripe-* (96 properties) — indexed families that need a slice collection of their own.
  • The 13 background-image-* placement propertiesfit, repeat, position-x/y, offset-x/y, width, height, *-relative-to, clip, containment, crossorigin, smoothing. Background.Image and Background.ImageOpacity are modelled; the rest only matter for image-backed nodes and belong together.
  • Cytoscape's aliases, deliberately: content (an alias of label), edge-text-rotation (of text-rotation), the singular segment-distance/-weight/-radius and control-point-distance/-weight, and padding-top/-right/-bottom/-left (all four aliases of padding). Use the property each one points to.
  • Niche properties: bounds-expansion, position, border-cap, border-join, border-position, line-height, line-outline-*, text-metrics, text-overflow-wrap, the mid-source-arrow-*/mid-target-arrow-* families, and the source-label/target-label family.
  • A few enum values, which are a gap of the same kind: CyValign omits top-inside and bottom-inside, and CyHalign omits left-inside and right-inside. All four are real, non-alias values. (CyGradientDirection's four omissions and the roundrectangle/cutrectangle spellings are aliases, and are excluded on purpose.) Every other enum on the surface is complete, and a browser test applies every member of every one of them to a live graph to keep the tokens honest.

Selectors are typed on purpose

Cytoscape does not reject a malformed selector string — it warns to the browser console and then matches broadly, so a single typo inside a stylesheet applies that rule's style to every element in the graph rather than the one you meant. CySelector is built by chaining (CySelector.Node().Class("vip").Selected(), CySelector.Edge().Data("weight", CyOp.Gt, 5), and so on) precisely so that class of mistake is unrepresentable — there is deliberately no implicit conversion from string.

Ids, class names and data keys come from your own domain data, where order:1234 and user.name are ordinary, so Id, Class, Data and NoData escape punctuation for you — Id("order:1234") emits #order\:1234, which matches exactly that one element. Characters Cytoscape's grammar cannot express at all (whitespace above all, and non-ASCII letters) have no escape sequence, so those throw ArgumentException at the call rather than becoming a selector that matches everything.

For the grammar corners the builder doesn't model, CySelector.Raw(string) is the escape hatch. Selectors — built or raw — are validated when the graph loads, when the stylesheet changes and when a layout runs, and any Cytoscape rejects are reported through ILogger, not thrown: a bad selector degrades the picture rather than crashing the app.

If the graph fails to start

Initialization can fail for reasons that have nothing to do with your code — the vendored bundle not reaching the browser, a circuit dropping mid-startup. The component logs the failure through ILogger and renders an empty host div rather than throwing, and it retries on the next render, so a transient failure recovers as soon as anything re-renders the component. A permanent one keeps logging rather than going quiet.

Extensions ship as separate packages

Cytoscape's built-in layouts (grid, circle, cose, and others) work out of the box, with no extra package. Everything beyond that — additional layouts, UI extensions — ships as its own NuGet package rather than being vendored into the core library. Four layout extensions are available today; a navigator extension is planned.

Package Layout Choose it when...
Pinknose.Cytoscape.Razor.Fcose fcose you have a compound (nested) graph. It places nested nodes far better than the built-in cose, and is the layout to reach for by default for that case.
Pinknose.Cytoscape.Razor.Dagre dagre the graph has a direction to show — a call tree, a dependency graph, an org chart — rather than a shape to relax into.
Pinknose.Cytoscape.Razor.Elk elk dagre's hierarchy isn't quite the shape you need — ELK also offers radial, rectpacking and its own force layout from the same wrapper. This is the one to weigh first: elk.bundled.js is 1.6 MB, roughly 3.7x the size of the Cytoscape bundle itself and by far the largest vendored asset in this repository, and elkjs is EPL-2.0 — so this package alone declares MIT AND EPL-2.0 where the other three are plain MIT.
Pinknose.Cytoscape.Razor.Cola cola you need physics-style constraints on top of a force layout — non-overlap, packed disconnected components, a directed flow short of dagre's strict tiers. It can also run continuously via Infinite = true: convenient for a live-updating graph, but the graph never settles and costs CPU for as long as it stays on screen.

An extension is registered through Extensions and paired with a matching CyLayout subclass naming the same layout:

dotnet add package Pinknose.Cytoscape.Razor.Fcose
@using Pinknose.Cytoscape.Razor.Fcose

<CytoscapeGraph TNode="Person" TEdge="Reports"
                Nodes="@_people"
                Edges="@_reports"
                Layout="@(new CyFcoseLayout { NestingFactor = 0.2 })"
                Extensions="@([new CyFcoseExtension()])" />

Extensions are loaded and registered once, before the graph is constructed; changing the Extensions parameter later has no effect, because Cytoscape registers extensions globally rather than per instance.

What's not in v1

  • Chunked image export. ExportPngAsync/ExportJpgAsync return the whole data URI in one message, which is why the Blazor Server limit above bites. Splitting it is planned.
  • Weighted paths and centrality. Algorithms covers breadth- and depth-first search; Dijkstra, A* and the centrality measures are not wrapped yet.
  • The remaining style properties. The style sub-objects (CyNodeStyle, CyEdgeStyle, and the rest) cover a solid working subset of Cytoscape's ~150 style properties, not all of them.

Contributing

Building the library, running the samples and tests, regenerating the API site, and cutting a release are covered in CONTRIBUTING.md.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0-beta.1 73 8/27/2026