Pinknose.Cytoscape.Razor
0.1.0-beta.1
dotnet add package Pinknose.Cytoscape.Razor --version 0.1.0-beta.1
NuGet\Install-Package Pinknose.Cytoscape.Razor -Version 0.1.0-beta.1
<PackageReference Include="Pinknose.Cytoscape.Razor" Version="0.1.0-beta.1" />
<PackageVersion Include="Pinknose.Cytoscape.Razor" Version="0.1.0-beta.1" />
<PackageReference Include="Pinknose.Cytoscape.Razor" />
paket add Pinknose.Cytoscape.Razor --version 0.1.0-beta.1
#r "nuget: Pinknose.Cytoscape.Razor, 0.1.0-beta.1"
#:package Pinknose.Cytoscape.Razor@0.1.0-beta.1
#addin nuget:?package=Pinknose.Cytoscape.Razor&version=0.1.0-beta.1&prerelease
#tool nuget:?package=Pinknose.Cytoscape.Razor&version=0.1.0-beta.1&prerelease
Cytoscape.js for Blazor/.NET
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.xthose 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 types — class 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 OnAfterRenderAsync — firstRender 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,DateTimeOffsetandGuidserialize in their standard textual form; enums and other custom types do not — convert them yourself. - The keys
id,parent,sourceandtargetare reserved: the component writes the element's own identity and relationships into them, overwriting anything you put there.labelis yours if you set it — it is only filled in fromLabelwhen 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-*andstripe-*(96 properties) — indexed families that need a slice collection of their own.- The 13
background-image-*placement properties —fit,repeat,position-x/y,offset-x/y,width,height,*-relative-to,clip,containment,crossorigin,smoothing.Background.ImageandBackground.ImageOpacityare modelled; the rest only matter for image-backed nodes and belong together. - Cytoscape's aliases, deliberately:
content(an alias oflabel),edge-text-rotation(oftext-rotation), the singularsegment-distance/-weight/-radiusandcontrol-point-distance/-weight, andpadding-top/-right/-bottom/-left(all four aliases ofpadding). 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, themid-source-arrow-*/mid-target-arrow-*families, and thesource-label/target-labelfamily. - A few enum values, which are a gap of the same kind:
CyValignomitstop-insideandbottom-inside, andCyHalignomitsleft-insideandright-inside. All four are real, non-alias values. (CyGradientDirection's four omissions and theroundrectangle/cutrectanglespellings 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/ExportJpgAsyncreturn the whole data URI in one message, which is why the Blazor Server limit above bites. Splitting it is planned. - Weighted paths and centrality.
Algorithmscovers 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 | Versions 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. |
-
net10.0
- Microsoft.AspNetCore.Components.Web (>= 10.0.11)
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 |