BlazorResourceTimeline 0.8.0
dotnet add package BlazorResourceTimeline --version 0.8.0
NuGet\Install-Package BlazorResourceTimeline -Version 0.8.0
<PackageReference Include="BlazorResourceTimeline" Version="0.8.0" />
<PackageVersion Include="BlazorResourceTimeline" Version="0.8.0" />
<PackageReference Include="BlazorResourceTimeline" />
paket add BlazorResourceTimeline --version 0.8.0
#r "nuget: BlazorResourceTimeline, 0.8.0"
#:package BlazorResourceTimeline@0.8.0
#addin nuget:?package=BlazorResourceTimeline&version=0.8.0
#tool nuget:?package=BlazorResourceTimeline&version=0.8.0
BlazorResourceTimeline
A high-performance resource-timeline / planner component for ASP.NET Core Blazor, with pluggable canvas, SVG and HTML renderers (canvas by default).
It renders a wide time window along the horizontal axis and a list of resources along the vertical axis, drawing allocation bars in the grid between them. It is built for dense, glanceable planning boards - flight/gate planning, train scheduling, fleet and crew rostering, and similar transport use-cases - where a lot of data must stay readable and interactive.

<sub>The demo in its dark theme: 68 resources and ~3,300 allocations on the canvas renderer.</sub>
Features
- Pluggable renderers: one engine (data, layout, interaction) driving your
choice of renderer via
Options.Renderer- Canvas (default; HiDPI/Retina crispness viaResizeObserver+device-pixel-content-box, fastest for dense data), SVG (resolution-independent, inspectable, print friendly) or HTML (each bar is a real, CSS-styleable element). Per-frame culling keeps all three bounded by what is visible, and the renderer can be switched at runtime. - Zoom from a multi-day overview down to hour-level detail
(
Ctrl/Cmd+ mouse wheel, trackpad pinch, or the programmatic API), with adaptive tick/label density.ZoomToDaysAsync(days)fits a given number of days into the current viewport. - Viewport control: open with the current time centered
(
Options.AutoScrollToNow), keep the view where it was across a data reload (Options.PreserveScrollOnReload), and set how often the "now" line catches up with the wall clock (Options.NowLineRefreshMs). - Day/week navigation: step the view a day at a time - or a week - with
PanByDaysAsync(). By default a step is exactly 24 hours; setOptions.PanToDayStart(or passpanToDayStart:on the call) to land on local midnight instead. Hosts can put the same steps on their own keyboard shortcuts. - Viewport:
OnViewChangedreports the visible time span and pixels-per-hour (at most once per frame).SelectAsync,ScrollToAllocationAsyncandScrollToResourceAsyncdrive selection and scroll from the host. - Selection: click,
Ctrl/Cmd-click to toggle,Shift-click for a contiguous range, and click-and-drag marquee selection (stacked lanes are hit-tested in 2D). - Editing (opt-in): drag a bar to move it in time (or onto another
resource), or grab an edge to resize it, with wall-clock snapping (set
SnapToTimeZone = falsefor the old Unix-epoch grid). A host can refuse a drop viaOnAllocationChanging(no reload). Dragging one selected bar moves the whole selection. Locked bars stay selectable. Drag empty content to create whenEmptyDragActionisCreate.AllowDeleteplus Delete/Backspace, and Ctrl/Cmd+C / Ctrl/Cmd+V for copy/paste via host callbacks. - Context menu (right-click): the native browser menu is suppressed and a callback reports what was hit - the bar, the resource row, the time under the pointer and the click's viewport coordinates - so you can render your own menu.
- Overlap stacking: allocations that overlap in time on the same row are
automatically stacked into lanes instead of drawn on top of each other, and
the row grows to keep the whole stack inside it.
Options.ResourceHeightis the minimum row height;Options.BarMarginsets the gap between lanes, and by default that gap also covers each bar's labels and icons so stacked text stays readable (Options.StackLabelClearance). Bars close enough that their labels, icons or delay bars would collide are stacked as well (Options.StackOnLabelCollision).Options.MaxStackLanescaps the stack and draws a+Noverflow label. - Hover tooltips: per-bar tooltips (custom text, an auto-generated default,
or a
TooltipTemplateoverlay on every renderer), on by default and configurable. - Working-time shading:
NonWorkingDaysandWorkingHoursStart/WorkingHoursEndwash weekends and off-hours (visual only; snap is unchanged). - Resource hierarchy: nest resources into multi-level, collapsible groups
via
ParentId; click a group row (or use it from data) to expand/collapse. - On-demand (windowed) loading: for effectively unbounded datasets, serve only the visible time window (plus a buffer) via a callback; the renderer refetches as the user scrolls/zooms.
- Resource-column template: replace the renderer-drawn resource labels with a rich, interactive HTML template per row (badges, links, avatars, …).
- Resizable resource column: drag the divider at the right edge of the left panel (or focus it and use the arrow keys) to change its width.
- Keyboard & screen-reader accessible: a click (or Tab) focuses the region
(
role/aria-label); arrow keys then move between bars, with keyboard selection, editing, and live-region announcements. - Time-zone-aware axes (IANA ids via
Intl), correct across DST, with an optionalLocalefor day labels, tooltips and announcements, an optional 12-hour hour row (Options.Hour12),Options.FirstDayOfWeek(for week banding when present), and an optional second hour row in UTC (Options.ShowUtcTime) above the local one. Day titles pin to the leading edge of their day and yield as the next midnight scrolls in, so they never stack. - Touch & pen support via Pointer Events.
- Streaming data load for very large datasets (batched interop instead of one giant payload).
- Theming: every painted color is overridable through
Options.Colors, so a light, dark or brand palette is just anotherOptionsinstance - switchable at runtime (the demo ships a dark theme). - Rich bars: per-bar colors and heights, labels (above/below/start/end),
image/SVG icons anchored to any side, start/end "edge" (delay) bars, and
ClassNameon the HTML renderer. - Dimensions & fonts: axis sizes, row height, bar sizing and every label font are configurable. The resource column is resizable by default.
Installation
dotnet add package BlazorResourceTimeline
Targets net8.0, net9.0, and net10.0, for both Blazor WebAssembly and
Blazor Server / interactive-render-mode apps.
No script or stylesheet registration is needed: the JavaScript engine is imported
on demand from _content/BlazorResourceTimeline/, and the component's scoped CSS
arrives through the framework's own bundle. That bundle is linked by every Blazor
template already - just make sure your host page keeps its
<link href="YourApp.styles.css" rel="stylesheet" />
line (with YourApp being your app's assembly name).
Trimming
The assembly is marked trim-compatible, so trimmed apps (Blazor WebAssembly trims on publish) shrink it rather than skipping it. The types crossing the JavaScript interop boundary are rooted in an embedded trimming descriptor, so every option and allocation property keeps working after trimming - nothing needs configuring on your side.
Ahead-of-time (AOT) compilation is not declared: interop marshalling relies on
reflection-based System.Text.Json, so the component is not AOT-safe.
Quick start
Add the component and give it a Config:
@using BlazorResourceTimeline
<div style="height: 600px;">
<BlazorResourceTimeline Config="_config"
OnSelectionChanged="OnSelectionChanged" />
</div>
@code {
private BlazorResourceTimelineConfig? _config;
protected override void OnInitialized()
{
_config = new BlazorResourceTimelineConfig
{
StartDate = DateTimeOffset.UtcNow.Date,
EndDate = DateTimeOffset.UtcNow.Date.AddDays(1),
Resources =
[
new() { Id = "gate-a1", Name = "Gate A1" },
new() { Id = "gate-a2", Name = "Gate A2" },
],
Allocations =
[
new()
{
Id = "f-100",
ResourceId = "gate-a1",
StartTime = DateTimeOffset.UtcNow.Date.AddHours(8),
EndTime = DateTimeOffset.UtcNow.Date.AddHours(10),
TextAbove = "LH441",
},
],
};
}
private void OnSelectionChanged(BlazorResourceTimelineAllocation[] selected)
{
// selected are the same instances you supplied in Config.Allocations.
}
}
The component sizes itself to its container, so give the wrapping element a height.
Note:
Configis compared by reference. Assign a newBlazorResourceTimelineConfiginstance to trigger a full reload (this clears selection and focus). To change a few bars without that, mutate them in place and callUpsertAllocationsAsync, orRemoveAllocationsAsyncto drop ids.ReloadAsync()still does a fullsetData.
Renderers
One engine owns the data, layout, hit-testing and interaction; only the painting
step differs. Pick it with Options.Renderer, and switch at runtime by assigning
a new Options instance - data, events and keyboard behavior are identical
across all three.
Renderer |
Output | Best for |
|---|---|---|
Canvas (default) |
Immediate-mode 2D drawing, HiDPI/Retina-crisp via ResizeObserver + device-pixel-content-box |
Dense boards; the fastest option |
Svg |
Resolution-independent vector nodes | Inspecting, copying or printing the scene |
Html |
One real DOM element per visible bar, carrying data-bar-id and optional Allocation.ClassName |
Styling bars with your own CSS |
All three cull to the visible viewport every frame, so cost tracks what is on screen rather than the size of the dataset.
<BlazorResourceTimeline Config="_config" Options="_options" />
@code {
private BlazorResourceTimelineOptions _options = new()
{
Renderer = BlazorResourceTimelineRendererType.Svg,
};
}
Configuration (Options)
Pass a BlazorResourceTimelineOptions to customize appearance and behavior
(dimensions, fonts, colors, time zone, zoom scale). Assign a new Options
instance to re-apply at runtime (e.g. to switch themes or time zone):
<BlazorResourceTimeline Config="_config" Options="_options" />
@code {
private BlazorResourceTimelineOptions _options = new()
{
TimeZone = "UTC",
ResourceHeight = 44, // minimum row height; rows grow to fit stacked bars
BarHeight = 8,
BarMargin = 2, // gap between bars that overlap in time on the same row
Colors = new() { Bar = "#74c0fc", Now = "#e03131" },
};
}
Every property is nullable, so a partial instance overrides only what it sets:
values left null keep the renderer's defaults. Note that re-applying Options
merges - a null property keeps whatever was applied before, rather than
resetting it to the default.
Where the view starts, and what a reload does to it
Three independent options decide where the viewport sits and how lively the "now" line is. None of them affects the others:
@code {
private BlazorResourceTimelineOptions _options = new()
{
AutoScrollToNow = true, // open with "now" centered
PreserveScrollOnReload = true, // a reload keeps the current view
NowLineRefreshMs = 1000, // advance the "now" line every second
};
}
AutoScrollToNowcenters the current time in the content area as soon as the first data load is laid out - whereGoToTodayAsync()would leave it, minus the scroll animation - so a timeline meant to open "at now" needs no call after rendering. Only the first load is affected; from then on the viewport belongs to the user. Nothing happens if "now" is outside the loaded range.PreserveScrollOnReloadkeeps a reload showing what it was showing: the time at the left edge of the content area, and the row at the top. Both are restored by time and resource id rather than by pixel offset, so the view holds even when the reload shifts the overall range, changes the scale or re-orders the rows. If the anchored row is gone entirely, the vertical position is left as it was.NowLineRefreshMsis how often the "now" line is repainted so it keeps up with the wall clock on a timeline nobody is touching (default60000- once a minute). Repaints are skipped while the tab is hidden and whenever the line would land on the same pixel, so a one-second interval costs nothing on a zoomed-out view.0stops the ticking.
Stepping through the timeline a day at a time
PanByDaysAsync(days) moves the view forward or back by whole days without
touching the zoom. By default a step is exactly 24 hours, keeping the same
time of day at the leading edge. Set Options.PanToDayStart = true to land
on a local midnight instead - the start of the day days calendar days away
in Options.TimeZone (or the viewer's zone) - so a DST 23- or 25-hour day
is still one step, and a mid-afternoon view jumps to the next (or previous)
day's start rather than the same clock time tomorrow. Pass
panToDayStart: true or false on a call to override the option for that
step only (null / omitted keeps the option):
<button title="Back one day (←)" @onclick="() => _timeline.PanByDaysAsync(-1)">‹ Day</button>
<button title="Forward one day (→)" @onclick="() => _timeline.PanByDaysAsync(1)">Day ›</button>
<button title="Forward one week (Ctrl+→)" @onclick="() => _timeline.PanByDaysAsync(7)">Week »</button>
<button title="Next midnight" @onclick="() => _timeline.PanByDaysAsync(1, panToDayStart: true)">Day start ›</button>
<BlazorResourceTimeline @ref="_timeline" Config="_config" Options="_options" />
The component does not bind these steps to the arrow keys itself: ←/→
always move the roving bar focus. Wire the same PanByDaysAsync calls from
your own page-level key handler if you want ←/→ to step a day and
Ctrl/Cmd+←/→ a week, and ignore those keys while the timeline holds
focus so a click still hands the arrows to bar navigation. A pan is clamped
to the timeline's range, so a press at either end does nothing, and the new
leading time is announced through the live region.
Fitting a number of days into the viewport
ZoomToDaysAsync(days) sets the horizontal scale so exactly that many days
fill the content area (the viewport minus the resource axis), keeping the
time under the center where it is. Useful when a planner should open - or
switch - to a one-day, three-day or week view without the host computing
pixels-per-hour from the layout:
<button @onclick="() => _timeline.ZoomToDaysAsync(1)">1 day</button>
<button @onclick="() => _timeline.ZoomToDaysAsync(3)">3 days</button>
<button @onclick="() => _timeline.ZoomToDaysAsync(7)">7 days</button>
<BlazorResourceTimeline @ref="_timeline" Config="_config" Options="_options" />
The resulting scale is clamped to Options.MinPixelsPerHour /
Options.MaxPixelsPerHour. A non-positive or non-finite value is ignored
and the current scale is returned.
Resizing the resource column
The left resource column is resizable by default. Drag the divider on its right
edge, or Tab to the divider and use ←/→ (Shift for a larger step, Home/End
for the min/max). The starting width is Options.ResourceAxisWidth (default
150). The committed width is reported via OnResourceAxisWidthChanged so a host
can persist it and pass it back:
<BlazorResourceTimeline Config="_config"
Options="_options"
OnResourceAxisWidthChanged="OnResourceAxisWidthChanged" />
@code {
private BlazorResourceTimelineOptions _options = new() { ResourceAxisWidth = 150 };
private void OnResourceAxisWidthChanged(int width)
{
_options = new() { ResourceAxisWidth = width };
}
}
Options.ResourceAxisMinWidth (default 80) and Options.ResourceAxisMaxWidth
clamp the gesture; the column also cannot grow past the viewport minus 100px of
content area. Set Options.ResourceAxisResizable = false for a fixed column.
Dual time rows (UTC)
Options.ShowUtcTime = true adds a second row of hour labels to the time axis,
in UTC, between the day labels and the row that follows TimeZone - useful for
aviation, operations and any other schedule read in Zulu time alongside a local
clock:
@code {
private BlazorResourceTimelineOptions _options = new()
{
TimeZone = "Europe/Berlin",
ShowUtcTime = true,
TimeAxisHeight = 76, // the two hour rows split the band below the day row
};
}
The UTC row is rendered exactly like the zone row - same ticks, same density,
whole-hour labels - but on UTC's own hour boundaries. Where the axis zone's
offset carries minutes, as Asia/Kolkata (+05:30) does, that puts its numbers
horizontally between the zone row's rather than beneath them:
Mon, Jun 15 <- day row, in the axis zone
00 02 04 06 08 <- UTC row
06 08 10 12 14 <- Asia/Kolkata row, shifted by :30
Under a whole-hour offset the two rows line up and differ only in their numbers,
and with TimeZone = "UTC" they read the same. The band below the day row is
split evenly between them, so raise TimeAxisHeight (default 60) to give them
more room.
Options.Hour12 = true keeps tick positions on whole hours but labels them with
the locale's 12-hour clock (3 PM in en-US). Options.FirstDayOfWeek (Sunday
= 0 … Saturday = 6) is reserved for week-oriented banding; PanByDaysAsync(7)
already steps a week. When null, the engine uses Intl.Locale weekInfo where
available, otherwise Monday.
Working-time shading
Options.NonWorkingDays (0 = Sunday … 6 = Saturday) and
WorkingHoursStart / WorkingHoursEnd (minutes from local midnight) draw a wash
behind bars. Colors.NonWorking is the fill. This is visual only: snap, scale
and hit-testing are unchanged. Empty / omitted days and a missing hours window
draw nothing.
The rows are not captioned - use TopStartContent to
label them in the otherwise blank top-start corner, as the demo does.
Theming and dark mode
Options.Colors (BlazorResourceTimelineColors) covers everything the renderer
paints - backgrounds, axis borders, ticks and labels, grid lines, bar fill,
selected fill/outline, bar labels, the "now" line, the marquee rectangle, the
focus ring and the tooltip. Assigning a new Options instance repaints
immediately, which is all a theme toggle needs:
<BlazorResourceTimeline Config="_config" Options="_options" />
@code {
private bool _dark;
private BlazorResourceTimelineOptions _options = new() { Colors = Light };
private void ToggleTheme()
{
_dark = !_dark;
// A new instance: Options is compared by reference.
_options = new BlazorResourceTimelineOptions { Colors = _dark ? Dark : Light };
}
private static readonly BlazorResourceTimelineColors Light = new()
{
ContentBg = "#ffffff", AxisBg = "#f8f9fa", AxisBorder = "#dee2e6",
Tick = "#adb5bd", Label = "#495057", DateLabel = "#212529", Grid = "#e9ecef",
Bar = "#74c0fc", BarSelected = "#4dabf7", BarSelectedBorder = "#1971c2",
BarLabel = "#495057", Now = "#e03131",
SelectionFill = "rgba(77, 171, 247, 0.18)", SelectionBorder = "#4dabf7",
Focus = "#1971c2", TooltipBg = "#212529", TooltipText = "#ffffff",
};
private static readonly BlazorResourceTimelineColors Dark = new()
{
ContentBg = "#1a1d21", AxisBg = "#141618", AxisBorder = "#2c3035",
Tick = "#6c757d", Label = "#adb5bd", DateLabel = "#e9ecef", Grid = "#2c3035",
Bar = "#4dabf7", BarSelected = "#74c0fc", BarSelectedBorder = "#a5d8ff",
BarLabel = "#ced4da", Now = "#ff6b6b",
SelectionFill = "rgba(77, 171, 247, 0.22)", SelectionBorder = "#74c0fc",
Focus = "#74c0fc", TooltipBg = "#f8f9fa", TooltipText = "#212529",
};
}
Note: colors are merged, not replaced - a
nullentry keeps whatever was applied before, not the built-in default. That is convenient for tweaking one color, but when swapping between themes send a complete palette, otherwise stray colors from the previous theme stick around.
Bar appearance
Beyond its time span, each BlazorResourceTimelineAllocation can carry its own
presentation:
| Property | Effect |
|---|---|
Color |
CSS color for the bar fill (falls back to Colors.Bar). |
Height |
Per-bar height in pixels (falls back to Options.BarHeight). Edge bars share it. |
TextAbove / TextBelow |
Labels centered above / below the bar. |
TextStart / TextEnd |
Labels just outside the start / end edge. |
Icons |
Images or data-URI SVGs anchored Start, End, Above, Below or Center. Several at one position lay out side by side, growing away from the bar - or as one centered group for Center. Inside = true moves an icon within the bar, against the edge its position names (Center is always inside). |
StartBar / EndBar |
Decorative "edge" bars extending before the start / after the end, each with its own Duration and Color - typically delays. |
Tooltip |
Hover text (see Tooltips). |
ClassName |
CSS class on the HTML-renderer bar element (paint only; canvas/SVG ignore it). Pointer events still go to the surface, so :hover on the bar does not fire. |
Locked |
Selectable, not movable/resizable. |
Data |
Host payload (JsonElement). The engine never reads it. |
new BlazorResourceTimelineAllocation
{
Id = "f-100",
ResourceId = "gate-a1",
StartTime = start,
EndTime = start.AddHours(2),
Color = "#74c0fc",
Height = 12,
TextAbove = "LH441",
TextEnd = "FRA",
// 25 minutes late off-blocks, drawn in red after the planned end.
EndBar = new() { Duration = TimeSpan.FromMinutes(25), Color = "#e03131" },
Icons = [new() { Source = "/icons/warning.svg", Position = BlazorResourceTimelineBarIconPosition.Start }],
// Host DTO; the engine ignores it. Read it back from the same instance
// after selection or OnAllocationChanged.
Data = JsonSerializer.SerializeToElement(new { flightNo = "LH441" }),
}
Labels and icons are skipped on bars narrower than Options.MinBarWidthForLabels,
so zoomed-out boards stay readable. Edge bars and icons are decoration only -
they are not selectable and never become hit targets. When a decorated bar
overlaps another on the same row, the two are stacked far enough apart to keep
both readable (see Overlapping allocations).
Editing
Set Options.Editable = true to let users move and resize allocations directly
on the timeline (mouse/pen):
- Move – drag a bar's body to shift it in time. Unless
AllowResourceChangeisfalse, dragging vertically also reassigns it to the resource row under the pointer. - Resize – drag within
EditResizeHandlePxof a bar's start or end edge. - Snapping – moves and resizes snap to
EditSnapMinutes(default 15; set0for continuous) on wall-clock multiples from local midnight inOptions.TimeZone(SnapToTimeZone, defaulttrue). Setfalsefor the previous Unix-epoch grid. A resize never shrinks a bar belowEditMinDurationMinutes. - Multi-move – when the dragged bar is part of a multi-selection, the same
time delta (and optional row delta) is applied to every selected unlocked bar.
OnAllocationsChangingis one round-trip for the list; if that handler is unset, a single-bar edit still usesOnAllocationChanging. - Delete – with
AllowDelete(defaultfalse), Delete/Backspace asksOnAllocationsDeletingthen removes the selected (or focused) bars. - Copy / paste – Ctrl/Cmd+C copies selected ids; Ctrl/Cmd+V asks
OnAllocationsCopyingfor new allocations (new ids). The engine does not invent ids.
<BlazorResourceTimeline Config="_config"
Options="_options"
OnAllocationChanging="OnAllocationChanging"
OnAllocationChanged="OnAllocationChanged" />
@code {
private BlazorResourceTimelineOptions _options = new()
{
Editable = true,
EditSnapMinutes = 15,
// AllowOverlap = false, // refuse drops onto a busy slot on the same row
// AllowResourceChange = false, // to lock rows and only edit in time
};
private Task<bool> OnAllocationChanging(BlazorResourceTimelineAllocationChange change)
{
// `change.Allocation` is already updated to the preview. Return false
// to snap it back (previous resource/times are on `change`).
return Task.FromResult(true);
}
private void OnAllocationChanged(BlazorResourceTimelineAllocation edited)
{
// Fires only after a successful change. Persist `edited` here.
}
}
Editing is also keyboard accessible: focus a bar and use Alt+arrows to move it,
Alt+Shift+←/→ to resize the end edge, Alt+Shift+↑/↓ to resize the
start edge, and Alt+↑/↓ to change resource. Locked bars are skipped
(the live region announces that they cannot be edited).
Options.AllowOverlap defaults to true (overlapping bars stack). Set it to
false to refuse a commit that would overlap another unlocked bar on the same
resource; touching end-to-start is still allowed.
To create bars by dragging empty content, set EmptyDragAction to Create
(and Editable). Ctrl/Cmd-drag still marquees. The engine does not invent
ids: handle OnAllocationCreating and return a full allocation, or null to
cancel.
<BlazorResourceTimeline Config="_config"
Options="_options"
OnAllocationCreating="OnAllocationCreating" />
@code {
private BlazorResourceTimelineOptions _options = new()
{
Editable = true,
EmptyDragAction = BlazorResourceTimelineEmptyDragAction.Create,
};
private Task<BlazorResourceTimelineAllocation?> OnAllocationCreating(
BlazorResourceTimelineCreateRequest request)
{
return Task.FromResult<BlazorResourceTimelineAllocation?>(new()
{
Id = Guid.NewGuid().ToString("N"),
ResourceId = request.ResourceId,
StartTime = request.StartTime,
EndTime = request.EndTime,
});
}
}
Context menu (right-click)
Right-clicking the timeline suppresses the browser's own menu and raises
OnContextMenu with everything needed to show your own. The args identify what
was under the pointer and where the click happened on screen:
| Property | Description |
|---|---|
Allocation |
The bar under the pointer, or null when the click missed every bar. Your own instance from Config. |
Resource |
The row under the pointer; null below the last row. |
Time |
The time at the pointer's horizontal position; null on the resource axis (which has no time coordinate). |
ClientX / ClientY |
Viewport coordinates of the click, suited to a position: fixed menu. |
It fires for bars, for empty space in the content area, and for resource-axis
rows - but not for the time axis or the corner. Right-clicking never changes
the selection, so an existing multi-selection survives opening a menu. The
args type derives from BlazorResourceTimelinePointerArgs, so Area, X/Y,
overflow bars and modifier keys are also available (see Click and double-click).
<BlazorResourceTimeline Config="_config" OnContextMenu="ShowMenu" />
@if (_menu is { } menu)
{
<div class="my-menu" style="position:fixed;left:@((int)menu.ClientX)px;top:@((int)menu.ClientY)px">
@if (menu.Allocation is { } bar)
{
<button @onclick="() => Delete(bar)">Delete @bar.Id</button>
}
else if (menu.Time is { } time)
{
<button @onclick="() => Create(menu.Resource, time)">New allocation here…</button>
}
</div>
}
@code {
private BlazorResourceTimelineContextMenuArgs? _menu;
private void ShowMenu(BlazorResourceTimelineContextMenuArgs args) => _menu = args;
}
Remember to close the menu yourself (for example from a backdrop click or
Escape) - the component only reports the event.
Click and double-click
A still click (or tap) raises OnClick after selection has been updated. A
second still click within 500 ms of the first also raises OnDoubleClick.
Both use BlazorResourceTimelinePointerArgs:
| Property | Description |
|---|---|
Allocation |
The bar under the pointer, or null when the click missed every bar (including a +N overflow label). Your own instance from Config. |
OverflowAllocations |
Hidden bars when the pointer hit a cluster's +N label; empty otherwise. |
Resource |
The row under the pointer; null on the time axis, the corner, or below the last row. |
Time |
The time at the pointer's horizontal position; null on the resource axis and the corner. |
Area |
Content, ResourceAxis, TimeAxis or Corner. |
X / Y |
Coordinates within the timeline surface (origin at the top-left, including the sticky axes). |
ClientX / ClientY |
Viewport coordinates, suited to a position: fixed overlay. |
CtrlKey / ShiftKey / MetaKey / AltKey |
Modifier keys held during the click. |
Unlike OnContextMenu, click and double-click also fire on the time axis and
the corner. They do not fire after a marquee, a committed edit, or a pan.
<BlazorResourceTimeline Config="_config"
OnClick="OnTimelineClick"
OnDoubleClick="OnTimelineDoubleClick" />
@code {
private void OnTimelineClick(BlazorResourceTimelinePointerArgs args)
{
// Selection has already been updated. Use Area / Time / Resource /
// Allocation to drive host chrome (status bar, inspector).
Console.WriteLine($"{args.Area} at {args.X:0},{args.Y:0} → {args.Allocation?.Id ?? args.Resource?.Id}");
}
private void OnTimelineDoubleClick(BlazorResourceTimelinePointerArgs args)
{
if (args.Allocation is { } bar)
{
// Open an editor for the bar.
}
else if (args.Time is { } time && args.Resource is { } resource)
{
// Create an allocation at this time on this resource.
}
}
}
Overlapping allocations
Allocations that overlap in time on the same resource row are laid out in vertical lanes rather than drawn on top of each other. Overlapping bars are grouped into clusters, each bar takes the first lane free at its start time, and the cluster is centered on the row's center line. A bar that overlaps nothing keeps sitting exactly on that line, so simple rows look unchanged.
Options.BarMargin (default 2) sets the vertical gap between stacked bars;
0 stacks them touching. Lane positions account for the actual height of the
bars in each lane - Options.BarHeight or an allocation's own Height - so a
cluster mixing bar heights still lays out without overlap. Bars that merely touch
(one ends the instant the next starts) are not treated as overlapping.
What counts as an overlap
Keeping bars off each other is not the same as keeping them readable, so
Options.StackOnLabelCollision (default true) stacks bars whose painted
spans collide, not only those whose times overlap. Two bars eight minutes apart
still draw a start time, an end time and any icons into the eight pixels between
them; separate lanes are the only way both stay legible. Delay (edge) bars count
towards the painted span too, since they eat the gap a label would otherwise use.
A collision is a matter of pixels, so lane membership - and with it row height -
is recomputed whenever the zoom or the viewport width changes the horizontal
scale. Zooming in until the labels fit drops the bars back onto one lane. Zooming
out shrinks bars below Options.MinBarWidthForLabels, where decorations are not
drawn at all, so they stop claiming room instead of stacking the whole row. The
row under the top of the viewport is held in place across a zoom, so a change of
row height does not make the view drift. Set the option to false to stack on a
time overlap alone.
Room for the labels
Keeping the bars apart is not enough to read them: with the default 4px bar,
two lanes 2px apart still draw each bar's text over its neighbour. So
Options.StackLabelClearance (default true) widens the gap between two lanes
by the vertical room the decorations facing that gap need - TextAbove /
TextBelow, above/below icons, and anything centered on a bar's center line that
is taller than the bar, such as TextStart / TextEnd and start/end icons.
Clearance is reserved per side, so a lane whose labels all point away from its
neighbour does not push it away. The outermost labels of a stack sit in the row's
own padding, exactly where a single bar's labels do.
Icons count for the size of their box (Options.BarIconSize or the icon's own
Size) rather than their loaded, aspect-fitted size, and the reserved room does
not depend on the zoom level even though decorations themselves are dropped below
Options.MinBarWidthForLabels - so rows never reflow as images arrive or as the
user zooms. Set the option to false for the tighter stack of bars that carry no
decorations.
<BlazorResourceTimeline Config="_config" Options="_options" />
@code {
private BlazorResourceTimelineOptions _options = new()
{
BarHeight = 10,
BarMargin = 4, // 4 px between bars that overlap in time
// StackOnLabelCollision = false, // stack on a time overlap alone
// StackLabelClearance = false, // stack tightly, ignoring labels/icons
};
}
Rows grow to fit their stack, so a dense cluster never spills into the
neighbouring row. Options.ResourceHeight is the minimum row height: a row
whose tallest cluster needs sum(lane heights) + BarMargin × (lanes − 1) pixels
(plus the label clearance between its lanes) grows to that height plus the same
top/bottom padding a single default-height bar has in a minimum-height row. A row
whose bars never overlap stays exactly at ResourceHeight, so simple boards look
unchanged, and the resource column, hit-testing, keyboard navigation and the
ResourceTemplate overlay all follow the per-row heights.
Options.MaxStackLanes (null or 0 = unlimited) caps how many lanes a cluster
may use. Extra bars are hidden and a +N label is drawn at the cluster's
trailing edge; clicking it selects the overflow ids and hovering it lists them
(see Tooltips). Row height stays at the max-lane stack, so a
40-overlap row cannot blow the layout.
Resource-column template
By default the sticky resource column is drawn by the renderer (fast, plain text).
For richer, interactive content set ResourceTemplate: the renderer then stops
drawing the labels and an HTML overlay renders your template once per visible row
(row counts are bounded, so this stays cheap). Group rows automatically get an
expand/collapse chevron before your content, and the overlay follows vertical
scroll. The context exposes the resource, its Depth, and its group state.
The column stays resizable: the divider sits above the overlay so a drag still
reaches it.
<BlazorResourceTimeline Config="_config">
<ResourceTemplate Context="row">
<span class="res-cell">
<strong>@row.Resource.Name</strong>
@if (row.HasChildren) { <span class="badge">group</span> }
</span>
</ResourceTemplate>
</BlazorResourceTimeline>
On-demand (windowed) loading
For datasets too large to send up front, set LoadAllocationsAsync. The timeline
then requests only the allocations overlapping the currently needed time window
(the visible range widened by a buffer) and calls the delegate again as the user
scrolls or zooms toward the edge of the loaded window. Config still supplies the
resources and the overall StartDate/EndDate; its Allocations are ignored.
<BlazorResourceTimeline Config="_config" LoadAllocationsAsync="LoadWindowAsync" />
@code {
// Return every allocation whose span overlaps the requested window, across
// all resources. Query your database/service here.
private async Task<IReadOnlyList<BlazorResourceTimelineAllocation>> LoadWindowAsync(
BlazorResourceTimelineWindow window)
{
return await _repository.GetAllocationsAsync(window.Start, window.End);
}
}
Requests are debounced, coalesced, and tagged so a slow fetch superseded by newer
scrolling is discarded rather than overwriting the current window. Each window
merges by id: incoming bars are upserted, bars that no longer overlap the
loaded range and are absent from the payload are dropped, and selection/focus
survive for ids that remain. Tune the buffer
and refetch sensitivity with Options.WindowBufferFactor,
Options.WindowRefetchThreshold and Options.WindowDebounceMs.
Resource hierarchy
Resources form a flat list by default. Set a resource's ParentId to the Id of
another resource to nest it, building a multi-level tree. A resource that has
children renders as a collapsible group header (indented by its depth, with a
chevron); clicking the header row toggles it, and Collapsed = true starts a
group collapsed. Groups can still own their own allocations. Sibling and root
order follows the order of the resources list.
var resources = new List<BlazorResourceTimelineResource>
{
new() { Id = "dc", Name = "Data Center" },
new() { Id = "srv", Name = "Servers", ParentId = "dc" },
new() { Id = "s1", Name = "Server-01", ParentId = "srv" },
new() { Id = "s2", Name = "Server-02", ParentId = "srv" },
new() { Id = "db", Name = "Databases", ParentId = "dc", Collapsed = true },
new() { Id = "d1", Name = "Database-01", ParentId = "db" },
};
Tooltips
Hovering a bar (mouse/pen) shows a tooltip after Options.TooltipDelayMs
(default 300 ms). Set an allocation's Tooltip to control the text, or leave it
null for a default built from the bar's labels, resource name and time range.
Disable tooltips entirely with Options.ShowTooltips = false, and theme them via
Colors.TooltipBg / Colors.TooltipText.
Hovering a +N overflow marker (see Overlapping
allocations) describes what the lane cap hid there: how
many bars, then one line each with its label and time range. Long clusters are cut
off with a count of the rest - clicking the marker selects them all, which is the
way to see the whole set.
For a rich tooltip on every renderer (including canvas), set TooltipTemplate.
The engine reports the hovered bar and pointer coordinates; Blazor renders the
fragment in a positioned overlay and the built-in text tooltip is not shown. A
+N marker is not a bar and has no allocation to pass to the template, so it
keeps the built-in text tooltip.
<BlazorResourceTimeline Config="_config">
<TooltipTemplate>
<div class="my-tip">
<strong>@context.Id</strong>
<span>@context.Tooltip</span>
</div>
</TooltipTemplate>
</BlazorResourceTimeline>
Programmatic API
Programmatic API
Capture the component with @ref to drive it from code:
| Method | Description |
|---|---|
ReloadAsync() |
Re-sends the current Config even if the reference is unchanged (full replace; clears selection). |
UpsertAllocationsAsync(allocations) |
Merge by id without clearing selection or focus. |
RemoveAllocationsAsync(ids) |
Drop ids from the set, selection and focus. |
ClearSelectionAsync() |
Clears the current selection. |
SelectAsync(ids, additive?) |
Sets the selection to the given ids (additive: true unions). Unknown ids are ignored. |
GetSelectedBarsAsync() |
Returns the selected allocations, in selection order. |
GoToTodayAsync() |
Centers "now" in view (if within range). Options.AutoScrollToNow does this on the first load without a call. |
ScrollToTimeAsync(unixMs) |
Centers the given time in view. |
ScrollToAllocationAsync(id) |
Scrolls so the bar's start and its resource row are on screen. false when unknown. |
ScrollToResourceAsync(id) |
Scrolls vertically so the resource row is on screen. Horizontal scroll is unchanged. |
PanByDaysAsync(days, panToDayStart?) |
Steps the view forward (or back) by whole days at the current zoom; pass ±7 for a week. With Options.PanToDayStart (or a non-null panToDayStart argument, which wins), each step lands on local midnight. false when already at that end of the range. |
ZoomInAsync() / ZoomOutAsync() |
Zoom around the viewport center. |
ZoomToDaysAsync(days) |
Zooms so exactly that many days fill the current viewport, keeping the center time fixed. |
SetPixelsPerHourAsync(value?) |
Sets an explicit scale, or null for auto. |
ResetZoomAsync() |
Returns to the auto/config scale. |
GetPixelsPerHourAsync() |
Current horizontal scale. |
Keyboard shortcuts
Click the timeline, or Tab to it, to give it keyboard focus. Shortcuts apply
only while it holds focus. The resource-column divider is a separate tab stop;
its ←/→ / Home/End shortcuts apply while it is focused.
| Key | Action |
|---|---|
← / → |
Move between allocations in the focused row |
↑ / ↓ |
Move to the nearest allocation in the adjacent row |
Home / End |
First / last allocation in the row |
Enter |
Select the focused bar (Ctrl/Cmd+Enter toggles) |
Space |
Toggle the focused bar in a multi-selection |
Escape |
Clear the selection |
PageUp / PageDown |
Pan the time axis by a viewport |
Ctrl/Cmd + + / - / 0 |
Zoom in / out / reset |
Alt + ← / → |
Move the focused bar earlier / later (editing only) |
Alt + Shift + ← / → |
Resize the focused bar's end edge (editing only) |
Alt + Shift + ↑ / ↓ |
Resize the focused bar's start edge (editing only) |
Alt + ↑ / ↓ |
Move the focused bar to the previous / next resource (editing only) |
Delete / Backspace |
Delete selected (or focused) bars when AllowDelete is set |
Ctrl/Cmd + C / V |
Copy / paste (paste requires OnAllocationsCopying) |
Resource-column divider: ← / → |
Narrow / widen the column (Shift for a larger step) |
Resource-column divider: Home / End |
Min / max column width |
Mouse and pointer shortcuts
A click or a still tap also focuses the timeline. Mouse and pen share these gestures; a moving touch pans natively instead of starting a drag.
| Gesture | Action |
|---|---|
| Click a bar | Select it (replaces the current selection); raise OnClick |
Ctrl/Cmd-click a bar |
Toggle it in the selection; raise OnClick |
Shift-click a bar |
Select the inclusive range from the last selected (or focused) bar to the clicked bar, in row-major display order; raise OnClick |
| Click empty content or an axis | Clear the selection; Ctrl/Cmd-click on empty space leaves it; raise OnClick |
| Double-click | Raise OnDoubleClick (in addition to OnClick on each click) |
| Click a group row | Expand or collapse the group; raise OnClick |
| Click-and-drag | Marquee-select every bar whose body intersects the rectangle (hidden overflow lanes are skipped) |
Ctrl/Cmd + drag |
Additive marquee (unions with the existing selection) |
| Right-click | Raise OnContextMenu (not on the time axis); does not change the selection |
Ctrl/Cmd + wheel, or pinch |
Zoom around the cursor |
| Wheel, scrollbar, or touch-drag | Pan (native scrolling) |
| Hover a bar | Show its tooltip |
| Drag a bar's body / edge | Move / resize it (editing only) |
| Drag the resource-column divider | Resize the left panel |
Notable parameters
Config- resources, time window, and allocation bars.Options- visual/behavioral configuration.OnSelectionChanged- raised with the selected allocations (your own instances) only when the selected set actually changes.OnViewChanged- visible[Start, End]andPixelsPerHourafter scroll/zoom/layout (once per frame max).OnAllocationChanged- raised after a move/resize (editing) with the updated instance.OnAllocationChanging- returnfalseto refuse a previewed single-bar edit without a reload.OnAllocationsChanging- same gate for one or more bars (preferred for multi-move).OnAllocationsDeleting- returnfalseto keep bars on Delete/Backspace.OnAllocationsCopying- return new allocations (new ids) on paste, ornullto cancel.OnAllocationCreating- return a new allocation (withId) ornullto cancel create-on-empty-drag.OnContextMenu- raised on right-click with the bar/resource/time under the pointer, surface and viewport coordinates, and modifier keys (same payload as click).OnClick/OnDoubleClick- raised on a still click or tap (and the second click of a double-click) withBlazorResourceTimelinePointerArgs: bar, overflow cluster, resource, time, hit area, surface/viewport position and modifiers.OnResourceAxisWidthChanged- raised after the resource column is resized, with the new width in pixels.AriaLabel- accessible name (default"Resource timeline").LoadBatchSize- allocations per interop call for streaming large datasets (default10000;0sends everything at once).LoadingMinDurationMs- minimum time the loading overlay stays visible (default0).TopStartContent/LoadingContent/TooltipTemplate- custom render fragments for the top-start corner, the loading overlay, and a rich hover tooltip.
Demo
src/Demo is a Blazor WebAssembly playground for everything above: dataset size
(7 to 365 days), renderer, bar height and margin, max stack lanes, editing
(including delete, copy/paste and multi-move), on-demand loading, the
custom resource column, time zone, 12-hour ticks, weekend/off-hour shading,
zoom (including 1 / 3 / 7-day viewport presets), pan-to-day-start, and a
light/dark theme toggle - plus a live view of the visible day range, the
selection, the last edit, the last click / double-click and the last context-menu action. Drag the divider
at the right edge of the resource column to resize it. Go to calls
ScrollToAllocationAsync on the first selected bar. The demo wires ←/→
(and Ctrl/Cmd+arrows for a week) to PanByDaysAsync itself while the
timeline is unfocused; those shortcuts are not part of the component. Click the
timeline and the same keys move between bars instead.
dotnet run --project src/Demo
Repository layout
src/BlazorResourceTimeline- the component library (C# component, models and the JavaScript engine/renderers underwwwroot).src/Demo- the Blazor WebAssembly demo.src/Tests/BlazorResourceTimeline.Tests- .NET unit tests.src/Tests/js- rendering-engine tests (node's built-in test runner).
Building
dotnet build src/BlazorResourceTimeline.slnx
dotnet test src/BlazorResourceTimeline.slnx
dotnet run --project src/Demo
The rendering engine is JavaScript and is covered by node's built-in test runner (no npm dependencies):
node --test "src/Tests/js/**/*.test.mjs"
Releasing
Packing and publishing are driven by the tag: pushing a v* tag builds, tests,
packs and pushes to NuGet, taking the package version from the tag name
(v1.2.3 produces 1.2.3).
dotnet pack src/BlazorResourceTimeline/BlazorResourceTimeline.csproj -c Release -o artifacts
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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.8)
-
net8.0
- Microsoft.AspNetCore.Components.Web (>= 8.0.0)
-
net9.0
- Microsoft.AspNetCore.Components.Web (>= 9.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.