EasyBI.Core
0.4.0-beta.1
See the version list below for details.
dotnet add package EasyBI.Core --version 0.4.0-beta.1
NuGet\Install-Package EasyBI.Core -Version 0.4.0-beta.1
<PackageReference Include="EasyBI.Core" Version="0.4.0-beta.1" />
<PackageVersion Include="EasyBI.Core" Version="0.4.0-beta.1" />
<PackageReference Include="EasyBI.Core" />
paket add EasyBI.Core --version 0.4.0-beta.1
#r "nuget: EasyBI.Core, 0.4.0-beta.1"
#:package EasyBI.Core@0.4.0-beta.1
#addin nuget:?package=EasyBI.Core&version=0.4.0-beta.1&prerelease
#tool nuget:?package=EasyBI.Core&version=0.4.0-beta.1&prerelease
EasyBI
A self-service BI library for WPF. The developer declares what can be analyzed, end users build their visuals by drag-and-drop, and rendering is delegated to interchangeable connectors (LiveCharts2, OxyPlot, ...).
Source (IQueryable) ─▶ Semantic model ─▶ AnalyticQuery ─▶ ResultSet ─▶ ChartData ─▶ IVisualRenderer
(developer) (end user) (engine) (pivot) (connector)
Projects
| Project | Role | Dependencies |
|---|---|---|
EasyBI.Abstractions |
The contract: model, query, ResultSet, VisualDefinition, ReportDefinition |
none |
EasyBI.Core |
Model builder, query engine over IQueryable (expression trees), pivot, filtering, JSON |
Abstractions |
EasyBI.Wpf |
IVisualRenderer, EasyBIContext, VisualView, ReportDesigner, FilterBar, dashboard (DashboardPanel, DashboardView, behaviors), built-in KPI, Table, Text and Image renderers |
Core + Microsoft.Xaml.Behaviors.Wpf |
EasyBI.Renderers.LiveCharts2 |
LiveCharts2 connector (bars, stacked bars, lines, areas, points, combined charts, pies) | Wpf + LiveCharts2 |
EasyBI.Renderers.OxyPlot |
OxyPlot connector (bars, stacked bars, lines, areas, points, combined charts, pies) | Wpf + OxyPlot |
EasyBI.Themes.MaterialDesign |
EasyBI follows a Material Design in XAML theme | Wpf + MaterialDesignThemes |
EasyBI.Themes.WpfUi |
EasyBI follows a WPF UI (lepoco) theme | Wpf + WPF-UI |
samples/EasyBI.Sample |
Demo: dashboard + designer + renderer switch + JSON save/load | |
tests/EasyBI.Core.Tests |
Engine, filtering and serialization tests | |
tests/EasyBI.Wpf.Tests |
Designer and dashboard tests |
Installation
dotnet add package EasyBI.Wpf
dotnet add package EasyBI.Renderers.LiveCharts2 # and/or EasyBI.Renderers.OxyPlot
dotnet add package EasyBI.Themes.WpfUi # optional: if the application uses WPF UI or Material Design
EasyBI.Wpf brings EasyBI.Core and EasyBI.Abstractions. Every public WPF type is available
under a single XAML namespace:
xmlns:bi="https://github.com/TheoCvg/EasyBI"
For the WPF developer
var model = SemanticModel.For(dbContext.Sales, "sales", "Sales")
.Dimension(s => s.Date, "Date")
.Dimension(s => s.Region, "Region")
.Dimension(s => s.Product.Category, "Category")
.Measure(s => s.Amount, "Revenue", format: "#,##0 €")
.Count("Number of sales");
Context = EasyBIContext.Create()
.AddModel(model)
.UseRenderer(new LiveCharts2Renderer())
.UseRenderer(new OxyPlotRenderer());
With EF Core, prefer a context factory: each query opens its own DbContext, and the visuals of a
dashboard run in parallel. On a shared IQueryable (other than LINQ to objects), queries run one
after another, because a DbContext does not support concurrent operations.
var model = SemanticModel.For(() => dbFactory.CreateDbContext(), db => db.Sales, "sales", "Sales")
.Dimension(s => s.Region, "Region") /* ... */;
Each visual becomes one database query (filters, grouping, aggregates, sort and top in SQL): only the aggregated rows reach the application, whatever the number of rows in the table.
Visuals cache their last result and query again only when their query changes. When the data behind a model changes (new rows, another source), tell them, from any thread:
Context.RefreshData("sales"); // the visuals of this model; RefreshData() for all of them
<bi:ReportDesigner Context="{Binding Context}" />
<bi:VisualView Context="{Binding Context}" Definition="{Binding Visual}" />
The designer
The ReportDesigner has three zones: the fields on the left, the wells and the preview in the middle, the
properties on the right (both side panels fold: IsFieldPanelOpen, IsPropertiesPanelOpen).
- Chips show their field and badges summing their settings up (Σ, avg, Quarter, PY, Y2, ↓...). A click selects one
(
DesignerViewModel.SelectedItem) and the properties panel shows its settings in sections: aggregation, granularity, time calculation, chart type, secondary axis, sort for a field; the editor of a filter. Without a selection it shows the visual's: title, sort, Top N, and the options of the renderer. - The visual type is chosen in a gallery grouped by use (comparison, trend, composition, indicators and tables); types no renderer draws are greyed.
- Wells follow the type: a pie shows "Category" and "Value", a table "Rows", a KPI only "Values"; a well the type
does not use refuses fields and stays shown while it still holds some. Empty wells say what to drop, and while a
field or a chip is dragged the wells that accept it light up (
DragSource.DragStarted). - Undo and redo every change of the visual (Ctrl+Z, Ctrl+Y or Ctrl+Shift+Z, the header's buttons,
UndoCommand/RedoCommand; 100 steps,MaxHistory). Loading another visual starts a new history. - Keyboard: arrows go from chip to chip (Up/Down from well to well), Ctrl+Left/Right move the selected chip within its well, Delete removes it, Escape selects the visual again.
Everything stays replaceable: the template is lookless and made of slots (see below); the properties are descriptors
(DesignerProperty: toggle, choice, number, text, info or any object shown with its DataTemplate, grouped in
DesignerPropertySections), which DesignerViewModel.PropertiesBuilding lets an application add, remove or reorder;
each kind of editor is a DataTemplate under a DesignerPropertyTemplates key; the icons of the types are geometries
under VisualTypeIcons keys.
designer.ViewModel!.PropertiesBuilding += (_, e) =>
{
if (e.Selection is ShelfItemViewModel { IsValue: true } chip)
e.Sections.Add(new DesignerPropertySection("Notes", () => "Notes",
[DesignerProperty.Text("Comment", () => "Comment", () => Comments.Get(chip), text => Comments.Set(chip, text))]));
};
Calculated measures
A measure is a row value that the end user aggregates (sum, average...). Ratios need more: the margin rate of a region is its total margin divided by its total revenue, not the average of its sales' rates. Calculated measures are computed from the rows of each group, inside the same query (in SQL with EF Core), and can be sorted and limited like the others; the end user cannot change their aggregation.
model
.Ratio(s => s.Margin, s => s.Amount, "Margin rate", "MarginRate", format: "P1") // Σ margin / Σ revenue
.Ratio(s => s.Amount, s => 1, "Average basket", "AverageBasket", format: "#,##0 €") // Σ revenue / number of sales
.Calculated(g => g.Sum(s => s.Amount) - g.Sum(s => s.Cost), "Gross profit", "GrossProfit");
Ratio is empty when the denominator sums to zero. With Calculated, guard divisions yourself: some databases fail
on a division by zero.
End users create their own from the field list (+ Calculated measure, then ✎ and ✕ on hover): a name, a format and a formula over the model's measures, typed with their captions and checked as it is typed, with its total as a preview:
[Margin] / [Revenue] ([Revenue] - [Margin]) / [Number of sales] [Margin] * 100 / [Revenue]
Measures in brackets are aggregated as in the field list; the operators are + - * / with parentheses and numbers
with a decimal point; a division by zero gives an empty value. The formula runs in the same query as the other
measures. These measures live in EasyBIContext.Measures (one context per open report): every field list, designer
and visual of the context sees them, and a DashboardView loads and saves them with its document (undo does not
remove them). An application that saves reports without a dashboard puts Context.Measures.Measures in
ReportDefinition.Measures and loads them back with Context.Measures.Load(...).
Comparisons over time
Each measure of a visual can be seen through time (MeasureRef.Time, the "Calculation" list of a measure chip in the
designer): its value, its value the previous year, the change since (in value or in %), or its total since the start of
the year along the date axis.
Values = [new("Amount"), new("Amount") { Time = TimeCalculation.PercentChangeFromPreviousYear }],
Filters = [PeriodFilters.Relative("Date", FilterOperator.Current, DateGrain.Year)], // this year vs last year
The previous year is the same query with its dates shifted back one year (period filters, clicked periods), run once
more and matched on the groups (each month with the same month a year earlier, per series). It needs a date on the axis
or a date filter, and is empty otherwise. The year-to-date total adds the values up along the date axis and starts
again each year. A KPI shows a change with ▲ / ▼ in the measures' or the error color. This runs in
VisualPipeline.PrepareAsync (ISemanticModel.ExecuteWithTimeAsync) on top of any model.
Combined charts
The cartesian chart types (Bar, StackedBar, Line, Area, Scatter) can be mixed in one chart, one per
measure: MeasureRef.Mark overrides the visual's type for that measure's series (with a legend, every series of
the measure keeps its type), and MeasureRef.SecondaryAxis draws it against a second value axis on the right. In
the designer, each measure of a chart has a type selector and a "Y2" toggle. These settings only change the drawing:
the query is the same, so changing them does not query the source again.
new VisualDefinition
{
Model = "sales",
Mark = MarkType.Bar, // default type of the measures
Axis = [new("Date", DateGrain.Quarter)],
Values =
[
new("Amount"), // bars
new("Margin") { Mark = MarkType.Line }, // line
new("Count") { Mark = MarkType.Scatter, SecondaryAxis = true }, // points, right axis
],
};
Besides charts, KPIs and tables, a visual can be a text (MarkType.Text, content in
VisualDefinition.Text) or an image (MarkType.Image, embedded as base64 in VisualDefinition.Image).
They query no model and are drawn by the built-in TextRenderer and ImageRenderer.
Dashboard
Arranging visuals is a WPF concern: the report (easybi/v1) never contains a layout. The dashboard is
built in three layers, each usable on its own.
The layout (DashboardLayoutDefinition) is a stack of bands, each with a mode. Its items are greedy:
without a constraint, they share the room their neighbours leave, so a dashboard never has a gap.
- Fit bands are a grid of 12 columns that fills the width. A band is a stack of lines; a line is an item, or a
container (
LayoutNode.Row: side by side,LayoutNode.Column: stacked) of items and containers. Along a container, constrained children keep their size (ColumnSpanin columns,RowSpanin rows) and the others share the rest; across it, every child takes the whole room. An item with aRowSpansets the height of its line and the items next to it stretch, so items stacked beside a tall item share its height. - Column tiers: on a narrow panel, Fit bands show fewer columns (
DashboardPanel.ColumnTiers; by default 1 column under 600 px and 6 under 960 px,ColumnTier.Defaults). Spans are scaled to the tier, a line whose items would get less than 3 columns each is broken into several lines (one item per line in a single column), and constrained items keep their proportions while filling their line. Only the display adapts: the saved layout does not change, and a resize in a tier is saved in the layout's columns (DashboardPanel.ActiveColumns,ToLayoutSpan). - Wrap bands start their items at
DashboardPanel.MinItemWidth(or their fixedWidthin pixels), break lines when the next one does not fit, then share what is left on each line. Scroll bands do the same on one line, and scroll horizontally when the items do not fit. - A container whose children are all constrained leaves a free space, which the dashboard offers to fill.
new DashboardLayoutDefinition
{
Bands =
[
new BandLayout
{
Items =
[
new LayoutNode { VisualId = title.Id, RowSpan = 1 }, // a whole line
LayoutNode.Row(
new LayoutNode { VisualId = map.Id, ColumnSpan = 6, RowSpan = 6 }, // left half, 2 lines high
LayoutNode.Column(LayoutNode.Item(sales.Id), LayoutNode.Item(margin.Id))), // stacked beside it
],
},
new BandLayout { Mode = BandMode.Scroll, Items = [LayoutNode.Item(a.Id), new LayoutNode { VisualId = b.Id, Width = 360 }] },
],
};
DashboardPanel shows a layout (Layout property); children are matched to its items by the attached
DashboardPanel.ItemId. The geometry itself is a pure function (BandArrangement.Arrange), and the panel exposes
hit tests for interactions (EdgeAt, FlowIndexAt, FreeSpaces, and CaptureGeometry to hit-test the layout from
before a drag).
DashboardLayout is the static class that changes a panel's layout while keeping it consistent: AddNextTo,
MoveNextTo (ItemSide.Right, Left, Below, Above), InsertAt / MoveTo (a new line), Resize,
ResizeWidth, SetAuto, Remove, InsertBand, RemoveBand, SetBandMode, plus Update for custom
operations, Capture (snapshot to restore) and GetLayout / SetLayout (to save and restore). Next to an item, a
side along its container joins it (the line's columns are shared when it has no room left), a side across it
wraps the item and the newcomer in a new container. The rules are pure functions on records in
DashboardLayoutEngine, usable without any UI.
DashboardView is the ready-made (lookless) control: DashboardItems showing visuals in a
DashboardPanel, with every interaction provided by a behavior (Microsoft.Xaml.Behaviors):
| Behavior | Feature |
|---|---|
AddButtonsBehavior |
"+" on the right and bottom edges of an item, "+" between lines and between bands |
FreeSpaceBehavior |
fills free spaces with a placeholder that adds an item there |
FieldDropBehavior |
makes visuals from fields dragged from a FieldList (see below) |
MoveBehavior |
grip to drag an item: a picture follows the pointer and a placeholder shows where it lands, the others making room live; near an item's edge it goes next to it (like "+"), between two lines it becomes a line, over a free space it fills it; across bands, auto-scroll near the edges; Escape cancels |
ResizeBehavior |
corner handle: constrains the item (columns and rows in a grid band, pixels and rows elsewhere) |
ItemToolbarBehavior |
automatic size (for a resized item), edit, remove; text size, bold, alignment; image fit |
SelectionBehavior |
click selects an item (Ctrl+click adds or removes it), click on the background unselects, drag from the background draws a selection box; arrows go to the nearest item (Shift adds it), Escape unselects |
KeyboardLayoutBehavior |
Ctrl+arrows move the selected item, Ctrl+Shift+Down/Up put it in a new band, Alt+arrows extend the selected items over the free room next to them (through the commands below) |
BandMenuBehavior |
⋮ menu of a band: Fit / Scroll / Wrap, insert, remove (MenuOpening to customize it) |
BandScrollBehavior |
scroll bar and Shift+wheel for scrolling bands |
TextEditBehavior |
double-click a text to edit it in place |
UndoRedoBehavior |
Ctrl+Z / Ctrl+Y (ApplicationCommands.Undo / Redo) |
<bi:DashboardView x:Name="Dashboard" Context="{Binding Context}" Document="{Binding Dashboard}">
<i:Interaction.Behaviors>
<bi:UndoRedoBehavior MaxHistory="100" />
</i:Interaction.Behaviors>
</bi:DashboardView>
Hooks for applications:
- events
EditVisualRequested(replace the built-in designer dialog),ImageRequested(replace the file dialog),TextEditRequested,ItemRemoving(veto),Changed(every change, with the documents before and after),DocumentLoaded; - the item selection:
SelectedItems,SelectionChanged,Select(items, DashboardSelectionMode.Replace/Add/Toggle),SelectAll,UnselectAll,RemoveSelectedandDuplicateSelected(one change each), also reached throughApplicationCommands.Delete(Del),ApplicationCommands.SelectAll(Ctrl+A) andDashboardCommands.Duplicate(Ctrl+D);DashboardItem.IsSelectedfor templates (the default one draws a ring) and theDashboardOverlay.SelectionBoxStyleKeystyle. A read-only dashboard has no selection; - lazy loading: only the visuals of the items in or near the visible part of the dashboard query their data
(
DashboardItem.IsInViewport, bound toVisualView.IsActiveby the default template); the others query once they scroll into view, a change of filters or data giving them one query then.ViewportMarginsets how far ahead items load (one viewport by default),LoadsVisibleItemsOnly = falseloads them all (to print or export, for instance).VisualView.IsActive = falseholds back the queries of any visual; - keyboard layout:
MoveSelected(side, toNewBand)(DashboardCommands.MoveItemandMoveItemToNewBand, Ctrl+arrows and Ctrl+Shift+Down/Up;DashboardLayoutEngine.Step): Down takes an item sharing its line to a new line below; alone on its line, it joins the next line, and from the last line it goes into the band below (created past the last one); Up the same upwards, so that Down and Up undo each other; Ctrl+Shift+Down/Up puts it in a new band below/above; Left/Right swap it with its neighbour.ExtendSelected(width)(DashboardCommands.ExtendItem, Alt+arrows;DashboardLayoutEngine.Fill) extends the selected items over the free room next to them without shrinking their neighbours; - copy and paste of the selected items, possibly between dashboards:
CopySelected/CutSelected/Paste(Ctrl+C, Ctrl+X, Ctrl+V throughApplicationCommands;CreateCopyandPaste(data)work without the clipboard). The clipboard holds aneasybi/v1report (formatVisualTransfer.ClipboardFormat) with the visuals and the calculated measures they use, plus their titles as text, and their layout (formatDashboardView.LayoutClipboardFormat: their arrangement and sizes,DashboardLayoutEngine.Extract). Pasted items get new ids, keep their arrangement and sizes, and go after the line of the last selected item, or at the end (DashboardLayoutEngine.Insert; lines of a grid band join the anchor's grid band, other bands come as new bands); without the layout format, each one goes to the right of the previous one. Visuals of a model the context does not know are left out, and a calculated measure whose name is taken by another definition is renamed (VisualTransferinEasyBI.Core); DashboardCommands(AddVisual,AddText,AddImage,EditItem,RemoveItem,ResetSize,AddRight,AddBelow,InsertBand,RemoveBand,Duplicate,MoveItem,MoveItemToNewBand,ExtendItem— the last three take anItemSideor its name, asCommandParameter="Below"...) for custom templates, and public methods (AddNextTo,AddIna free space,InsertLine,SetAutoSize,SetVisual,InsertBand,BeginEditto group changes into one undo step...);- a default template for
DashboardView(itsPanelin a scroll viewer, under anAdornerDecoratorwhere behaviors draw: a custom template shows it with<ContentPresenter Content="{Binding Panel, RelativeSource={RelativeSource TemplatedParent}}" />), an implicit style forDashboardItem(lookless control) and theDashboardOverlaystyle keys (PlusButtonStyleKey,IconButtonStyleKey,GripStyleKey,ResizeGripStyleKey,FreeSpaceStyleKey,DropPlaceholderStyleKey,DragGhostStyleKey) to restyle; DashboardBehavioras a base class for new interactions: it gives access to the view, the panel and the overlay where behaviors draw their controls;DragGesturehandles press-drag-release gestures (with auto-scroll:AutoScroll,ScrollsBands).
IsReadOnly="True" hides the editing chrome; clicks keep cross-filtering in every mode.
Building visuals by dragging fields. FieldList shows the fields of the context's models (model picker,
dimensions, measures) and drags them as ModelFields. Placed next to a dashboard, it lets users build it directly:
<DockPanel>
<bi:FieldList DockPanel.Dock="Left" Width="210" Context="{Binding Context}" />
<bi:DashboardView Context="{Binding Context}" />
</DockPanel>
- dropped where an item can go (an edge, between two lines, a free space, a scrolling or wrapping band), a field makes a new visual: a KPI for a measure, the first measure by that dimension for a dimension (a line by month for a date, bars otherwise); the other items make room live around a placeholder;
- dropped on an empty item, it fills it; dropped on a visual, it is added to it (a measure to its values, a dimension to its axis, then its legend; a dimension breaks a KPI down into bars);
- the rules are pure functions in
FieldVisuals(Create,AddField);FieldDropBehavior.CreateVisualandAddFieldreplace them. Each drop is one undo step.
The designer's field panel is the same FieldList (its shelves accept the ModelFields of the selected model).
A search box at the top of the field list (FieldList.Search) and above the values of a filter (from 8 values on,
FilterItemViewModel.Search; checked values that are not shown stay checked) narrow the lists down: case and accents
are ignored and every word must appear (TextMatch.Matches in EasyBI.Core).
In the designer, the visual is sorted from the properties of a chip of the Axis or Values well (ascending ↑ or
descending ↓; one sorted field at a time, the sort following the field when its aggregation or grain changes). The
visual's properties recall the active sort and limit the visual to its first rows (Top).
DashboardDropTarget.Find, the place an item dropped at a point goes, is shared by moves and field drops.
Drag and drop
The designer's drag and drop is built on reusable attached properties (namespace EasyBI.Wpf.DragAndDrop,
XAML prefix bi:), usable on any ItemsControl of an application. Being attached properties, they can be set
from a style:
<ListBox ItemsSource="{Binding Fields}" bi:DragSource.IsEnabled="True" bi:DragSource.AllowedEffects="Copy" />
<ItemsControl ItemsSource="{Binding Columns}" bi:DropTarget.Handler="{Binding}" bi:DropTarget.Key="Columns" />
public sealed class MyViewModel : IDropHandler
{
public DragDropEffects DragOver(DropInfo drop) => drop.Item is Field ? DragDropEffects.Copy : DragDropEffects.None;
public void Drop(DropInfo drop) => Columns.Insert(drop.Index, ((Field)drop.Item!).ToColumn());
}
DropInfogives the draggedItem, the target'sKey(one handler for several targets), the insertionIndexamong the target's items and the rawDatafor drags coming from elsewhere.DropTarget.IsDragOveris true while an accepted drag is over the target (style triggers);DropTarget.Orientationswitches the insertion rules to a vertical stack.- A handler may explain a refusal (
IDropHandler.RefusalReason, optional): the reason is shown under the target and set on it asDropTarget.Refusalwhile the drag is over it. - A scrollable target, or its scroll viewer, scrolls when the pointer nears its edges (
AutoScroller, also usable on its own);InsertionIndexholds the pure insertion rules. - Style keys
DragSource.PreviewStyleKey(aContentControl;DragSource.PreviewTemplatesets the content),DropTarget.InsertionLineStyleKey(aBorder) andDropTarget.RefusalStyleKey(aContentControlholding the reason).
In the designer, fields are dropped between chips, chips are reordered within a shelf, and dimensions move between
Axis and Legend keeping their granularity. A refused field says why (DesignerViewModel.RefusalReason): only measures
in Values, only dimensions elsewhere, a field already on the shelf, a full axis, a field of another model.
Filtering: global filters and cross-filtering
A FilterContext holds the filtering state of a dashboard. Set once on the container (it is an
inherited property), it is used by the FilterBar and by every VisualView:
<DockPanel bi:VisualView.FilterContext="{Binding Filters}">
<bi:FilterBar DockPanel.Dock="Top" Context="{Binding Context}" />
</DockPanel>
- Global filters (slicers): added from the
FilterBar, they apply to every visual of the same model and are saved with the report (ReportDefinition.Filters). - Cross-filtering: clicking a bar, a point, a slice or a table row filters the other visuals of
the same model on the element's dimensions (axis, plus legend for a bar). Clicking the same element
again clears the selection. The source visual is not filtered and shows the current selection.
Periods (quarter, month...) filter through
FilterSpec.Grain. - Period filters: a date dimension, in the
FilterBaror on the designer's Filters shelf, is filtered on a period rather than on a list of values: between two dates, the current day / month / quarter / year, the last n (current one included: "Last 12 months") or the n previous complete ones ("Last month"). Relative periods are resolved when the query runs, so a saved report keeps showing the last 12 months; the date column is compared to the period's bounds (date >= start AND date < end), so a database can use an index on it.
PeriodFilters.Relative("Date", FilterOperator.Last, DateGrain.Month, 12); // last 12 months
PeriodFilters.Between("Date", new DateOnly(2024, 1, 1), null); // since January 1st, 2024
PeriodFilters.Describe(filter); // "Last 12 months", localized
A connector only has to call request.Select?.Invoke(new CategorySelection(category, series)) on
click (or RowSelection for a table): Select is null when interactivity is disabled, for example
in the designer preview.
Customizing the components: slots
The parts of the ReportDesigner, FieldList, FilterBar, FilterEditor and DashboardView are slots: named places
where an application adds its own content (before or after the default content), replaces it, or hides it.
<bi:ReportDesigner Context="{Binding Context}">
<bi:Slots.Items>
<bi:Slot Name="{x:Static bi:ReportDesignerSlots.Preview}">
<Button Content="Publish" Command="{Binding DataContext.PublishCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
</bi:Slot>
<bi:Slot Name="{x:Static bi:ReportDesignerSlots.Title}" Mode="Before"><TextBlock Text="Title:" /></bi:Slot>
<bi:Slot Name="{x:Static bi:ReportDesignerSlots.FiltersShelf}" Mode="Hide" />
</bi:Slots.Items>
</bi:ReportDesigner>
Slots.Add(designer, ReportDesignerSlots.Header, exportButton); // after the default content
Slots.Add(designer, ReportDesignerSlots.Fields, help, SlotMode.Before); // above the field list
Slots.Add(filterBar, FilterBarSlots.AddButton, myAddButton, SlotMode.Replace);
Slots.Hide(designer, ReportDesignerSlots.FieldPanel);
- Names:
ReportDesignerSlots(FieldPanel,Fields,Header,Title,VisualTypes,Shelves,AxisShelf,LegendShelf,ValuesShelf,FiltersShelf,Preview,Properties),FieldListSlots(Model,Search,Dimensions,Measures,AddMeasureButton),FilterBarSlots(Filters,Label,Slicer,AddButton,Selection),FilterEditorSlots(Values,Period) andDashboardSlots(Body,ItemContent,EmptyItemChoices,ItemToolbar). Slots can be nested:HeadercontainsTitleandVisualTypes. The band menu of a dashboard is customized throughBandMenuBehavior.MenuOpening. - Modes:
After(default),Before,Replace,Hide. Before and after are left and right of the default content, or above and below it in a vertical slot (Fields,Shelves,Preview); several slots with the same name are shown in declaration order, and the lastReplaceorHidewins. - Data context: the content takes the data context of the place it is shown in: the
DesignerViewModelin the designer, the application's in the filter bar and the field list, theSlicerViewModelinFilterBarSlots.Slicer, theFilterItemViewModelin the filter editor (shown once per filter, so its slots take a template), theDashboardItemin the dashboard item slots (commands such asDashboardCommands.RemoveItemact on the item containing them). - Repeated slots: an element can only be shown once. A slot shown several times (
FilterBarSlots.Slicer, once per global filter, the dashboard item slots, once per item, or slots declared for several components) takes aContentTemplate(Slots.AddTemplate(...)in code), applied to the data context of each place. - Inheritance:
Slots.Itemsis inherited, so slots declared on a window or a panel apply to every component in it; the nearest declaration replaces the others. The designer opened to edit a dashboard visual takes the slots of the dashboard. - Custom templates: the components are lookless controls. Their default templates (
Themes/*.xamlofEasyBI.Wpf) are built fromSlotHosts (<bi:SlotHost Slot="..." Orientation="Vertical">with the default content inside), so a template of the application keeps the slots it hosts. A template works with the components' read-only properties and commands:ReportDesigner.ViewModel(the template's data context);FieldList.ModelOptions,Dimensions,Measures,CanAddMeasureandAddMeasureCommand,EditMeasureCommand,DeleteMeasureCommand;FilterBar.Slicers,SelectionDescriptionandAddFilterCommand,RemoveFilterCommand(on the slicer of its data context),ClearSelectionCommand;DashboardView.PanelandDashboardCommands.
Theming
Every EasyBI control (designer, dashboard, filter bar, field list, KPI and table renderers, chart connectors) draws with resources looked up dynamically: an application restyles EasyBI by defining them, and switches theme at runtime by replacing the dictionary that defines them. Charts follow too, without querying their data again.
Light and dark
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<bi:EasyBIThemeDictionary Mode="Dark" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
Without it, the light theme applies. To switch at runtime, change Mode (or swap the dictionary):
foreach (var theme in Application.Current.Resources.MergedDictionaries.OfType<EasyBIThemeDictionary>())
theme.Mode = EasyBIThemeMode.Light;
Overriding a few colors
Define the keys you want in your resources, after the theme dictionary; the others keep the theme's values:
<SolidColorBrush x:Key="{x:Static bi:EasyBITheme.AccentBrushKey}" Color="#7C3AED" />
<bi:ChartPalette x:Key="{x:Static bi:EasyBITheme.ChartPaletteKey}">
<Color>#7C3AED</Color>
<Color>#F59E0B</Color>
<Color>#10B981</Color>
</bi:ChartPalette>
<CornerRadius x:Key="{x:Static bi:EasyBITheme.CornerRadiusKey}">4</CornerRadius>
The complete list, with their light and dark values, is in
Themes/Light.xaml and Themes/Dark.xaml:
Key (EasyBITheme.…) |
Used for |
|---|---|
BackgroundBrushKey |
behind the dashboard, the designer and the visual editor |
SurfaceBrushKey |
cards (dashboard items, designer preview), popups, toolbars, chart tooltips |
SurfaceAltBrushKey |
field lists, designer shelves, empty items, alternate table rows |
BorderBrushKey, BorderStrongBrushKey |
borders and separators; empty items and free spaces |
ForegroundBrushKey, MutedForegroundBrushKey, DisabledForegroundBrushKey |
text, secondary text (headers, hints, icons, axis labels), unavailable options |
AccentBrushKey, AccentHoverBrushKey, AccentForegroundBrushKey |
selection, "+" buttons, drop indicators, and the text on them |
AccentSoftBrushKey, AccentSoftBorderBrushKey |
field chips, selection chip, drop placeholders |
SecondarySoftBrushKey, SecondarySoftBorderBrushKey |
filter chips |
HoverBrushKey |
small buttons under the pointer |
ErrorBrushKey |
error messages |
MeasureBrushKey |
the Σ of measures in field lists |
GridLineBrushKey |
grid lines of charts and tables |
ChartPaletteKey (ChartPalette) |
colors of chart series, in order |
CornerRadiusKey (CornerRadius) |
rounding of cards |
Control-specific parts have their own style keys (DashboardOverlay.PlusButtonStyleKey, FreeSpaceStyleKey,
DropPlaceholderStyleKey, DragSource.PreviewStyleKey... see Dashboard and
Drag and drop), and DashboardItem has an implicit style.
Standard controls
Buttons, combo boxes, text boxes and check boxes inside EasyBI have no explicit style: they take your application's implicit styles, so a control library (WPF UI, Material Design...) styles them too. EasyBI's own light and dark themes color EasyBI's surfaces, text and charts, not the standard controls: with the dark theme and no control library, they keep the system's light look.
Following a control library
With WPF UI or Material Design in XAML, install the matching package and merge its dictionary after the library's:
EasyBI's surfaces, text, accent and charts then take the library's colors, light or dark, and follow its theme switches
(ApplicationThemeManager.Apply, PaletteHelper.SetTheme). Its classes are in the bi: XAML namespace.
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark" />
<ui:ControlsDictionary />
<bi:WpfUiTheme />
</ResourceDictionary.MergedDictionaries>
<ResourceDictionary.MergedDictionaries>
<md:BundledTheme BaseTheme="Dark" PrimaryColor="DeepPurple" SecondaryColor="Lime" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesign3.Defaults.xaml" />
<bi:MaterialDesignTheme />
</ResourceDictionary.MergedDictionaries>
WpfUiTheme.Map and MaterialDesignTheme.Map list which library resource each EasyBI key takes. For another library
or an application's own design system, derive from EasyBIThemeMapping and map the keys you need; unmapped keys keep
EasyBI's light values, and the chart palette follows the mapped background (light or dark palette). If the library
switches theme by swapping dictionaries without notifying, call Refresh() on the mapping afterwards.
ApplicationThemeManager.Apply sets the main window's Background itself: put an EasyBI background on the window's root
panel rather than on the window (as the sample does).
public sealed class MyTheme() : EasyBIThemeMapping(new Dictionary<ComponentResourceKey, object>
{
[EasyBITheme.BackgroundBrushKey] = "MyWindowBrush",
[EasyBITheme.AccentBrushKey] = "MyPrimaryBrush",
});
Charts
VisualView passes the theme to renderers in RenderRequest.Theme (BiTheme): the palette, and the Foreground,
MutedForeground, GridLine and Background colors (hexadecimal, null when unknown). It is built by
EasyBITheme.ChartTheme(element) from the resources above. A palette or colors set on EasyBIContext.Theme take
precedence over the resources.
Localization
EasyBI's own texts (designer, filter bar, messages, granularity and aggregation names, blank and boolean values) ship in English and French. The language can change at runtime: designer, filter bar and visuals update immediately.
EasyBILocalization.Culture = CultureInfo.GetCultureInfo("fr-FR"); // null = follow CurrentUICulture
The same culture formats numbers and dates. Your semantic model captions can follow the language too:
call Localize with your own resources, and every caption becomes a resource key (a missing key falls
back to the caption itself).
var model = SemanticModel.For(db.Sales, "sales", "Sales")
.Dimension(s => s.Region, "Region")
.Measure(s => s.Amount, "Revenue")
.Localize(MyStrings.ResourceManager);
Your own XAML texts can follow it as well with LocalizedStrings and the Localize markup extension:
public static LocalizedStrings Texts { get; } = new(MyStrings.ResourceManager);
<Button Content="{bi:Localize Save, Source={x:Static local:App.Texts}}" />
Visual and report titles are user content: they are stored as typed and not translated.
Writing a connector
A connector implements a single interface. It receives already queried and pivoted data
(request.Chart: categories + series) and the raw ResultSet if needed:
public sealed class MyRenderer : IVisualRenderer
{
public string Name => "MyLib";
public RendererCapabilities Capabilities { get; } = new(new HashSet<MarkType> { MarkType.Bar });
public FrameworkElement Render(RenderRequest request) { /* request.Chart.Categories, request.Chart.Series */ }
}
Each ChartSeries comes with its resolved Mark and SecondaryAxis: a connector draws every series with its own
type, so one chart can mix bars, lines, areas and points.
Colors come from request.Theme: series take ColorAt(index), text Foreground, axis labels MutedForeground, grid
lines GridLine, tooltips Background (each one null when the theme does not set it: keep the library's default).
Draw on a transparent background, so that the chart sits on the theme's surface.
EasyBIContext.ResolveRenderer negotiates capabilities: the active renderer if it supports every type the
visual uses (the visual's and its measures'), otherwise another plugged-in renderer, otherwise a built-in one. The designer disables
the types that no renderer can draw.
Renderer options
A connector offers settings of its own to the end user by declaring them in its capabilities: the designer shows them
in the "Options" section of its properties panel (DesignerPropertySections.Options) for the visuals they apply to, as
a check box, a list or a number box, and saves their values with the visual.
public RendererCapabilities Capabilities { get; } = new(marks)
{
Options =
[
RendererOption.Toggle("scrollable", () => Strings.Scrollable, appliesTo: v => v.Mark.IsCartesian()),
RendererOption.Choice("curve", () => Strings.Curve, [new("straight", () => Strings.Straight), new("smooth", () => Strings.Smooth)]),
RendererOption.Number("barWidth", () => Strings.BarWidth, 48, minimum: 4, maximum: 120),
],
};
public FrameworkElement Render(RenderRequest request)
{
var scrollable = request.Visual.GetRendererOption<bool>(Name, "scrollable"); // EasyBI.Core
...
}
- Labels are functions, read when shown, so that they follow the EasyBI language (
EasyBILocalization.UICulture). - Values are stored in
VisualDefinition.Extensions, one JSON object per renderer under its name ("LiveCharts2": "{\"scrollable\":true}"); a value equal to the option's default is not stored. The settings of a renderer are kept while another one draws the visual.WithRendererOptionsets one in code. - The LiveCharts2 connector offers Scroll bar (
LiveCharts2Renderer.ScrollableOption) on cartesian charts: the chart shows the first 20 categories (ScrollWindow) above a small chart of all of them, where a band marks the visible ones; dragging on it scrolls, the mouse wheel on the chart zooms.
Report format (easybi/v1)
{
"schema": "easybi/v1",
"title": "Dashboard",
"visuals": [
{
"title": "Revenue by region and channel",
"model": "sales",
"mark": "Bar",
"axis": [ { "field": "Region", "grain": "None" } ],
"legend": { "field": "Channel", "grain": "None" },
"values": [ { "measure": "Amount" }, { "measure": "Count", "mark": "Line", "secondaryAxis": true } ],
"filters": [
{ "field": "Product.Category", "operator": "In", "values": [ "Services" ] },
{ "field": "Date", "operator": "Last", "values": [ "12" ], "grain": "Month" }
]
}
],
"filters": [
{ "model": "sales", "filter": { "field": "Region", "operator": "In", "values": [ "North", null ] } }
]
}
null in values means a missing value. Besides In and NotIn, a date filter's operator can be Between
(values: from and to as yyyy-MM-dd, inclusive, null for an open end), Current, Last or Previous (value: the
number of periods; grain is their unit). EasyBIContext.Validate(report) reports the unknown
models and fields of a loaded report, and the formulas that no longer match their model.
Calculated measures created by the end user are saved with the report (the property is omitted when there are none), and visuals reference them by name like a field:
"measures": [
{ "name": "calc_3f9a1c0d2b7e", "caption": "Margin rate", "model": "sales", "formula": "[Margin] / [Amount]", "format": "P1" }
]
Text and image visuals carry their content and no model:
{ "mark": "Text", "text": { "text": "Sales dashboard", "fontSize": 24, "isBold": true, "alignment": "Left" } }
{ "mark": "Image", "image": { "data": "iVBORw0KGgo...", "mediaType": "image/png", "fit": "Contain" } }
Dashboard file (easybi-dashboard/v1)
DashboardSerializer saves what a DashboardView shows: the unchanged report next to the WPF layout,
which references visuals by id. Sizes that are not saved are greedy. It also opens a plain easybi/v1 report
(visuals then get a default layout).
{
"schema": "easybi-dashboard/v1",
"report": { "schema": "easybi/v1", "visuals": [ ... ], "filters": [ ... ] },
"layout": {
"columns": 12,
"rowHeight": 110,
"bands": [
{
"mode": "Fit",
"items": [
{ "visualId": "...", "rowSpan": 1 },
{ "split": "Row", "children": [ { "visualId": "...", "columnSpan": 6 }, { "visualId": "..." } ] }
]
},
{ "mode": "Scroll", "items": [ { "visualId": "...", "width": 360 }, { "visualId": "..." } ] }
]
}
}
The same file renders identically with any connector.
Run
dotnet test
dotnet run --project samples/EasyBI.Sample
The demo starts in your system language (English or French) and has a language picker in its toolbar. Its data
picker switches the same sales between memory (20,000 rows) and a SQLite database of 1 or 5 million rows, generated
once in %LOCALAPPDATA%\EasyBI.Sample; the toolbar shows how long the last refresh took.
Contributing
See AGENTS.md for the code style, language rules and Git workflow.
Known limitations / roadmap
- Cross-filtering: one selection at a time; the selected element is not yet highlighted in the source chart (a "Sélection : ..." banner shows it).
- Designer and
FilterBarfilters: "one of" a list of values, or a period for a date dimension; no ranges on numbers. - Relative periods ("last 12 months") are resolved in the local time zone when a visual queries; an open dashboard does not query again at midnight.
Topapplies to aggregated rows, not to categories when a legend is present.- Grouping, sorting and
Topare pushed down to the provider, and execution is asynchronous when the source implementsIAsyncEnumerable(EF Core). The SQL translation was checked manually with EF Core 8 on SQLite (every query shape, including date granularities, navigation properties and sort on an aggregate); there is no automated test against a database. SQLite cannot sumdecimalcolumns (EF Core refuses the query). - At most 4 grouping dimensions (axis + legend).
- The built-in light and dark themes do not restyle standard controls (buttons, combo boxes...): use a control library for those (see Following a control library).
EasyBI.Themes.WpfUiis checked against WPF UI's light and dark themes; its high contrast themes are not verified.- Dashboard images are embedded in the file (2 MB by default,
DashboardView.MaxImageBytes).
| 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 was computed. 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 was computed. 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. |
-
net8.0
- EasyBI.Abstractions (>= 0.4.0-beta.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on EasyBI.Core:
| Package | Downloads |
|---|---|
|
EasyBI.Wpf
Self-service BI for WPF: drag-and-drop report designer, visual view, filter bar with cross-filtering, built-in KPI and table renderers, pluggable chart connectors. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.6.0-beta.1 | 96 | 10/28/2026 |
| 0.5.0-beta.1 | 90 | 10/26/2026 |
| 0.4.0-beta.1 | 89 | 10/21/2026 |
| 0.3.0-beta.1 | 92 | 10/19/2026 |
| 0.2.0-beta.1 | 84 | 10/19/2026 |
| 0.1.0-beta.1 | 81 | 10/16/2026 |