feat: 上传 网关冗余/更新以及规则引擎 源码

This commit is contained in:
Diego
2025-02-10 09:32:02 +08:00
parent 957a80da4b
commit d62de3d72c
279 changed files with 15716 additions and 283 deletions

View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "9.0.1",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -39,11 +39,7 @@
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.3.1" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">

View File

@@ -1,8 +1,8 @@
<Project>
<PropertyGroup>
<PluginVersion>10.0.0.29</PluginVersion>
<ProPluginVersion>10.0.0.29</ProPluginVersion>
<PluginVersion>10.0.1.1</PluginVersion>
<ProPluginVersion>10.0.1.1</ProPluginVersion>
</PropertyGroup>
<PropertyGroup>

View File

@@ -0,0 +1,67 @@
using ThingsGateway.Blazor.Diagrams.Components.Controls;
using ThingsGateway.Blazor.Diagrams.Core;
using ThingsGateway.Blazor.Diagrams.Core.Controls.Default;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Options;
namespace ThingsGateway.Blazor.Diagrams;
public class BlazorDiagram : Diagram
{
private readonly Dictionary<Type, Type> _componentsMapping;
public BlazorDiagram(BlazorDiagramOptions? options = null, bool registerDefaultBehaviors = true) : base(registerDefaultBehaviors)
{
_componentsMapping = new Dictionary<Type, Type>
{
[typeof(RemoveControl)] = typeof(RemoveControlWidget),
[typeof(BoundaryControl)] = typeof(BoundaryControlWidget),
[typeof(DragNewLinkControl)] = typeof(DragNewLinkControlWidget),
[typeof(ArrowHeadControl)] = typeof(ArrowHeadControlWidget)
};
Options = options ?? new BlazorDiagramOptions();
}
public override BlazorDiagramOptions Options { get; }
public void RegisterComponent<TModel, TComponent>(bool replace = false)
{
RegisterComponent(typeof(TModel), typeof(TComponent), replace);
}
public void RegisterComponent(Type modelType, Type componentType, bool replace = false)
{
if (!replace && _componentsMapping.ContainsKey(modelType))
throw new Exception($"Component already registered for model '{modelType.Name}'.");
_componentsMapping[modelType] = componentType;
}
public Type? GetComponent(Type modelType, bool checkSubclasses = true)
{
if (_componentsMapping.TryGetValue(modelType, out Type? value))
return value;
if (!checkSubclasses)
return null;
foreach (var rmt in _componentsMapping.Keys)
{
if (modelType.IsSubclassOf(rmt))
return _componentsMapping[rmt];
}
return null;
}
public Type? GetComponent<TModel>(bool checkSubclasses = true)
{
return GetComponent(typeof(TModel), checkSubclasses);
}
public Type? GetComponent(Model model, bool checkSubclasses = true)
{
return GetComponent(model.GetType(), checkSubclasses);
}
}

View File

@@ -0,0 +1,8 @@
namespace ThingsGateway.Blazor.Diagrams;
public class BlazorDiagramsException : Exception
{
public BlazorDiagramsException(string? message) : base(message)
{
}
}

View File

@@ -0,0 +1,13 @@

<g transform="rotate(@Control.Angle.ToInvariantString())">
<path d="@Control.Marker.Path" fill="black" cursor="move"></path>
</g>
@code
{
[Parameter]
public ArrowHeadControl Control { get; set; } = null!;
[Parameter]
public BaseLinkModel Model { get; set; } = null!;
}

View File

@@ -0,0 +1,35 @@
@if (Model.IsSvg())
{
<rect fill="none"
stroke="gray"
stroke-width="1"
stroke-dasharray="5"
pointer-events="none"
class="boundary"
width="@Control.Bounds.Width.ToInvariantString()"
height="@Control.Bounds.Height.ToInvariantString()">
</rect>
}
else
{
<svg>
<rect fill="none"
stroke="gray"
stroke-width="1"
stroke-dasharray="5"
pointer-events="none"
class="boundary"
width="@Control.Bounds.Width.ToInvariantString()"
height="@Control.Bounds.Height.ToInvariantString()">
</rect>
</svg>
}
@code
{
[Parameter]
public BoundaryControl Control { get; set; } = null!;
[Parameter]
public Model Model { get; set; } = null!;
}

View File

@@ -0,0 +1,33 @@
@foreach (var model in BlazorDiagram.Controls.Models)
{
var controls = BlazorDiagram.Controls.GetFor(model)!;
if (!controls.Visible || controls.Count == 0)
continue;
if (Svg && model.IsSvg())
{
<g class="controls" data-model-type="@model.GetType().Name" data-model-id="@model.Id">
@foreach (var control in controls)
{
var position = control.GetPosition(model);
if (position == null)
continue;
@RenderControl(model, control, position, true)
}
</g>
}
else if (!Svg && !model.IsSvg())
{
<div class="controls" data-model-type="@model.GetType().Name" data-model-id="@model.Id">
@foreach (var control in controls)
{
var position = control.GetPosition(model);
if (position == null)
continue;
@RenderControl(model, control, position, false)
}
</div>
}
}

View File

@@ -0,0 +1,85 @@
using Microsoft.AspNetCore.Components.Web;
using ThingsGateway.Blazor.Diagrams.Core.Controls;
using ThingsGateway.Blazor.Diagrams.Core.Extensions;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Extensions;
namespace ThingsGateway.Blazor.Diagrams.Components.Controls;
public partial class ControlsLayerRenderer : IDisposable
{
private bool _shouldRender;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public bool Svg { get; set; }
public void Dispose()
{
BlazorDiagram.Controls.ChangeCaused -= OnControlsChangeCaused;
}
protected override void OnInitialized()
{
BlazorDiagram.Controls.ChangeCaused += OnControlsChangeCaused;
}
protected override bool ShouldRender()
{
if (!_shouldRender)
return false;
_shouldRender = false;
return true;
}
private void OnControlsChangeCaused(Model cause)
{
if (Svg != cause.IsSvg())
return;
_shouldRender = true;
InvokeAsync(StateHasChanged);
}
private RenderFragment RenderControl(Model model, Control control, Point position, bool svg)
{
var componentType = BlazorDiagram.GetComponent(control.GetType());
if (componentType == null)
throw new BlazorDiagramsException(
$"A component couldn't be found for the user action {control.GetType().Name}");
return builder =>
{
builder.OpenElement(0, svg ? "g" : "div");
builder.AddAttribute(1, "class",
$"{(control is ExecutableControl ? "executable " : "")}diagram-control {control.GetType().Name}");
if (svg)
builder.AddAttribute(2, "transform",
$"translate({position.X.ToInvariantString()} {position.Y.ToInvariantString()})");
else
builder.AddAttribute(2, "style",
$"top: {position.Y.ToInvariantString()}px; left: {position.X.ToInvariantString()}px");
if (control is ExecutableControl ec)
{
builder.AddAttribute(3, "onpointerdown",
EventCallback.Factory.Create<PointerEventArgs>(this, e => OnPointerDown(e, model, ec)));
builder.AddEventStopPropagationAttribute(4, "onpointerdown", true);
}
builder.OpenComponent(5, componentType);
builder.AddAttribute(6, "Control", control);
builder.AddAttribute(7, "Model", model);
builder.CloseComponent();
builder.CloseElement();
};
}
private async Task OnPointerDown(PointerEventArgs e, Model model, ExecutableControl control)
{
if (e.Button == 0 || e.Buttons == 1) await control.OnPointerDown(BlazorDiagram, model, e.ToCore()).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,25 @@
@if (Model is SvgNodeModel)
{
<circle r="14" fill="lightgray"></circle>
<path d="M 6.79 -1.26 L 2.17 -5.46 C 1.82 -5.81 1.4 -5.46 1.4 -4.9 V -2.8 C -1.89 -2.8 -4.69 -0.77 -6.02 1.96 C -6.51 2.87 -6.79 3.85 -7 4.83 C -7.14 5.53 -6.09 5.88 -5.67 5.25 C -4.13 2.8 -1.54 1.19 1.4 1.19 V 3.5 C 1.4 4.06 1.82 4.41 2.17 4.06 L 6.79 -0.14 C 7.07 -0.42 7.07 -0.98 6.79 -1.26 Z"
fill="black">
</path>
}
else
{
<svg width="20" height="20" viewBox="0 0 20 20" style="overflow: visible;">
<circle r="14" fill="lightgray"></circle>
<path d="M 6.79 -1.26 L 2.17 -5.46 C 1.82 -5.81 1.4 -5.46 1.4 -4.9 V -2.8 C -1.89 -2.8 -4.69 -0.77 -6.02 1.96 C -6.51 2.87 -6.79 3.85 -7 4.83 C -7.14 5.53 -6.09 5.88 -5.67 5.25 C -4.13 2.8 -1.54 1.19 1.4 1.19 V 3.5 C 1.4 4.06 1.82 4.41 2.17 4.06 L 6.79 -0.14 C 7.07 -0.42 7.07 -0.98 6.79 -1.26 Z"
fill="black">
</path>
</svg>
}
@code
{
[Parameter]
public DragNewLinkControl Control { get; set; } = null!;
[Parameter]
public Model Model { get; set; } = null!;
}

View File

@@ -0,0 +1,21 @@
@if (Model is SvgNodeModel)
{
<circle r="10" fill="red"></circle>
<path d="M -5 -5 L 5 5 M -5 5 L 5 -5" stroke="white" stroke-width="2"></path>
}
else
{
<svg width="20" height="20" viewBox="0 0 20 20" style="overflow: visible;">
<circle r="10" fill="red"></circle>
<path d="M -5 -5 L 5 5 M -5 5 L 5 -5" stroke="white" stroke-width="2"></path>
</svg>
}
@code
{
[Parameter]
public RemoveControl Control { get; set; } = null!;
[Parameter]
public Model Model { get; set; } = null!;
}

View File

@@ -0,0 +1,13 @@
@code {
[Parameter]
public GroupModel Group { get; set; } = null!;
}
<GroupNodes Group="Group"/>
@foreach (var port in Group.Ports)
{
<PortRenderer @key="port" Port="port" Class="group-port"></PortRenderer>
}

View File

@@ -0,0 +1,10 @@
<div class="blazor-diagram-link-label">
@Label.Content
</div>
@code {
[Parameter]
public LinkLabelModel Label { get; set; } = null!;
}

View File

@@ -0,0 +1,58 @@
<div class="diagram-canvas @Class"
tabindex="-1"
@ref="elementReference"
@onpointerdown="OnPointerDown"
@onpointermove="OnPointerMove"
@onpointerup="OnPointerUp"
@onkeydown="OnKeyDown"
@onwheel="OnWheel"
@onpointermove:preventDefault
@onwheel:stopPropagation>
<svg class="diagram-svg-layer" style="@GetLayerStyle(BlazorDiagram.Options.LinksLayerOrder)">
@foreach (var model in BlazorDiagram.OrderedSelectables)
{
if (model is SvgNodeModel node && node.Group == null)
{
<NodeRenderer @key="node" Node="node" />
}
else if (model is SvgGroupModel group && group.Group == null)
{
<GroupRenderer @key="group" Group="group" />
}
else if (model is BaseLinkModel link)
{
<LinkRenderer @key="link" Link="link" />
}
}
<ControlsLayerRenderer Svg="@true"></ControlsLayerRenderer>
@AdditionalSvg
</svg>
<div class="diagram-html-layer" style="@GetLayerStyle(BlazorDiagram.Options.NodesLayerOrder)">
@foreach (var model in BlazorDiagram.OrderedSelectables)
{
if (model is GroupModel group)
{
if (group.Group == null && group is not SvgGroupModel)
{
<GroupRenderer @key="group" Group="group" />
}
}
else if (model is NodeModel node && node.Group == null && node is not SvgNodeModel)
{
<NodeRenderer @key="node" Node="node" />
}
}
<ControlsLayerRenderer Svg="@false"></ControlsLayerRenderer>
@AdditionalHtml
</div>
@Widgets
</div>

View File

@@ -0,0 +1,111 @@
#pragma warning disable CA2007 // 考虑对等待的任务调用 ConfigureAwait
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Extensions;
namespace ThingsGateway.Blazor.Diagrams.Components;
public partial class DiagramCanvas : IDisposable
{
private DotNetObjectReference<DiagramCanvas>? _reference;
private bool _shouldRender;
protected ElementReference elementReference;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public RenderFragment? Widgets { get; set; }
[Parameter] public RenderFragment? AdditionalSvg { get; set; }
[Parameter] public RenderFragment? AdditionalHtml { get; set; }
[Parameter] public string? Class { get; set; }
[Inject] public IJSRuntime JSRuntime { get; set; } = null!;
public void Dispose()
{
BlazorDiagram.Changed -= OnDiagramChanged;
if (_reference == null)
return;
if (elementReference.Id != null)
_ = JSRuntime.UnobserveResizes(elementReference);
_reference.Dispose();
}
private string GetLayerStyle(int order)
{
return FormattableString.Invariant(
$"transform: translate({BlazorDiagram.Pan.X}px, {BlazorDiagram.Pan.Y}px) scale({BlazorDiagram.Zoom}); z-index: {order};");
}
protected override void OnInitialized()
{
base.OnInitialized();
_reference = DotNetObjectReference.Create(this);
BlazorDiagram.Changed += OnDiagramChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
BlazorDiagram.SetContainer(await JSRuntime.GetBoundingClientRect(elementReference));
await JSRuntime.ObserveResizes(elementReference, _reference!);
}
}
[JSInvokable]
public void OnResize(Rectangle rect)
{
BlazorDiagram.SetContainer(rect);
}
protected override bool ShouldRender()
{
if (!_shouldRender) return false;
_shouldRender = false;
return true;
}
private void OnPointerDown(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerDown(null, e.ToCore());
}
private void OnPointerMove(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerMove(null, e.ToCore());
}
private void OnPointerUp(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerUp(null, e.ToCore());
}
private void OnKeyDown(KeyboardEventArgs e)
{
BlazorDiagram.TriggerKeyDown(e.ToCore());
}
private void OnWheel(WheelEventArgs e)
{
BlazorDiagram.TriggerWheel(e.ToCore());
}
private void OnDiagramChanged()
{
_shouldRender = true;
InvokeAsync(StateHasChanged);
}
}

View File

@@ -0,0 +1,31 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using MouseEventArgs = Microsoft.AspNetCore.Components.Web.MouseEventArgs;
namespace ThingsGateway.Blazor.Diagrams.Extensions;
public static class EventsExtensions
{
public static PointerEventArgs ToCore(this Microsoft.AspNetCore.Components.Web.PointerEventArgs e)
{
return new PointerEventArgs(e.ClientX, e.ClientY, e.Button, e.Buttons, e.CtrlKey, e.ShiftKey, e.AltKey,
e.PointerId, e.Width, e.Height, e.Pressure, e.TiltX, e.TiltY, e.PointerType, e.IsPrimary);
}
public static PointerEventArgs ToCore(this MouseEventArgs e)
{
return new PointerEventArgs(e.ClientX, e.ClientY, e.Button, e.Buttons, e.CtrlKey, e.ShiftKey, e.AltKey,
0, 0, 0, 0, 0, 0, string.Empty, false);
}
public static KeyboardEventArgs ToCore(this Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
{
return new KeyboardEventArgs(e.Key, e.Code, e.Location, e.CtrlKey, e.ShiftKey, e.AltKey);
}
public static WheelEventArgs ToCore(this Microsoft.AspNetCore.Components.Web.WheelEventArgs e)
{
return new WheelEventArgs(e.ClientX, e.ClientY, e.Button, e.Buttons, e.CtrlKey, e.ShiftKey, e.AltKey, e.DeltaX,
e.DeltaY, e.DeltaZ, e.DeltaMode);
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.JSInterop;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
namespace ThingsGateway.Blazor.Diagrams.Extensions;
public static class JSRuntimeExtensions
{
public static async Task<Rectangle> GetBoundingClientRect(this IJSRuntime jsRuntime, ElementReference element)
{
return await jsRuntime.InvokeAsync<Rectangle>("BlazorDiagrams.getBoundingClientRect", element).ConfigureAwait(false);
}
public static async Task ObserveResizes<T>(this IJSRuntime jsRuntime, ElementReference element,
DotNetObjectReference<T> reference) where T : class
{
try
{
await jsRuntime.InvokeVoidAsync("BlazorDiagrams.observe", element, reference, element.Id).ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
// Ignore, DotNetObjectReference was likely disposed
}
}
public static async Task UnobserveResizes(this IJSRuntime jsRuntime, ElementReference element)
{
await jsRuntime.InvokeVoidAsync("BlazorDiagrams.unobserve", element, element.Id).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,12 @@
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Models;
namespace ThingsGateway.Blazor.Diagrams.Extensions;
public static class ModelExtensions
{
public static bool IsSvg(this Model model)
{
return model is SvgNodeModel or SvgGroupModel or BaseLinkModel;
}
}

View File

@@ -0,0 +1,13 @@
using System.Text;
namespace ThingsGateway.Blazor.Diagrams.Extensions;
public static class StringBuilderExtensions
{
public static StringBuilder AppendIf(this StringBuilder builder, string str, bool condition)
{
if (condition) builder.Append(str);
return builder;
}
}

View File

@@ -0,0 +1,40 @@
@code {
[Parameter]
public GroupModel Group { get; set; } = null!;
// Remove duplicated code
}
@if (Group is SvgGroupModel)
{
<g class="children" transform="translate(@((-Group.Position.X).ToInvariantString()) @((-Group.Position.Y).ToInvariantString()))">
@foreach (var node in Group.Children)
{
if (node is GroupModel g)
{
<GroupRenderer @key="g.Id" Group="g"></GroupRenderer>
}
else
{
<NodeRenderer @key="node.Id" Node="node"></NodeRenderer>
}
}
</g>
}
else
{
<div class="children" style="top: @((-Group.Position.Y).ToInvariantString())px; left: @((-Group.Position.X).ToInvariantString())px">
@foreach (var node in Group.Children)
{
if (node is GroupModel g)
{
<GroupRenderer @key="g.Id" Group="g"></GroupRenderer>
}
else
{
<NodeRenderer @key="node.Id" Node="node"></NodeRenderer>
}
}
</div>
}

View File

@@ -0,0 +1,65 @@
@using ThingsGateway.Blazor.Diagrams.Core.Behaviors;
@{
var color = Link.Selected ? Link.SelectedColor ?? BlazorDiagram.Options.Links.DefaultSelectedColor : Link.Color ?? BlazorDiagram.Options.Links.DefaultColor;
var result = Link.PathGeneratorResult;
if (result == null)
return;
var dnlb = BlazorDiagram.GetBehavior<DragNewLinkBehavior>();
var d = result.FullPath.ToString();
}
<path d="@d"
stroke-width="@Link.Width.ToInvariantString()"
fill="none"
stroke="@color" />
@if (dnlb!.OngoingLink == null || dnlb.OngoingLink != Link)
{
@if (Link.Vertices.Count == 0)
{
@GetSelectionHelperPath(color, d, 0)
}
else
{
@for (var i = 0; i < result.Paths.Length; i++)
{
d = result.Paths[i].ToString();
var index = i;
@GetSelectionHelperPath(color, d, index)
}
}
}
@if (Link.SourceMarker != null && result.SourceMarkerAngle != null && result.SourceMarkerPosition != null)
{
<g transform="@(FormattableString.Invariant($"translate({result.SourceMarkerPosition.X}, {result.SourceMarkerPosition.Y}) rotate({result.SourceMarkerAngle})"))">
<path d="@Link.SourceMarker.Path" fill="@color"></path>
</g>
}
@if (Link.TargetMarker != null && result.TargetMarkerAngle != null && result.TargetMarkerPosition != null)
{
<g transform="@(FormattableString.Invariant($"translate({result.TargetMarkerPosition.X}, {result.TargetMarkerPosition.Y}) rotate({result.TargetMarkerAngle})"))">
<path d="@Link.TargetMarker.Path" fill="@color"></path>
</g>
}
@if (Link.Vertices.Count > 0)
{
var selectedColor = Link.SelectedColor ?? BlazorDiagram.Options.Links.DefaultSelectedColor;
var normalColor = Link.Color ?? BlazorDiagram.Options.Links.DefaultColor;
@foreach (var vertex in Link.Vertices)
{
<LinkVertexRenderer @key="vertex.Id"
Vertex="vertex"
Color="@normalColor"
SelectedColor="@selectedColor" />
}
}
@foreach (var label in Link.Labels)
{
<LinkLabelRenderer @key="label.Id" Label="@label" Path="@result.FullPath" />
}

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Components.Web;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Extensions;
namespace ThingsGateway.Blazor.Diagrams.Components;
public partial class LinkWidget
{
private bool _hovered;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public LinkModel Link { get; set; } = null!;
private RenderFragment GetSelectionHelperPath(string color, string d, int index)
{
return builder =>
{
builder.OpenElement(0, "path");
builder.AddAttribute(1, "class", "selection-helper");
builder.AddAttribute(2, "stroke", color);
builder.AddAttribute(3, "stroke-width", 12);
builder.AddAttribute(4, "d", d);
builder.AddAttribute(5, "stroke-linecap", "butt");
builder.AddAttribute(6, "stroke-opacity", _hovered ? "0.05" : "0");
builder.AddAttribute(7, "fill", "none");
builder.AddAttribute(8, "onmouseenter", EventCallback.Factory.Create<MouseEventArgs>(this, OnMouseEnter));
builder.AddAttribute(9, "onmouseleave", EventCallback.Factory.Create<MouseEventArgs>(this, OnMouseLeave));
builder.AddAttribute(10, "onpointerdown", EventCallback.Factory.Create<Microsoft.AspNetCore.Components.Web.PointerEventArgs>(this, e => OnPointerDown(e, index)));
builder.AddEventStopPropagationAttribute(11, "onpointerdown", Link.Segmentable);
builder.CloseElement();
};
}
private void OnPointerDown(PointerEventArgs e, int index)
{
if (!Link.Segmentable)
return;
var vertex = CreateVertex(e.ClientX, e.ClientY, index);
BlazorDiagram.TriggerPointerDown(vertex, e.ToCore());
}
private void OnMouseEnter(MouseEventArgs e)
{
_hovered = true;
}
private void OnMouseLeave(MouseEventArgs e)
{
_hovered = false;
}
private LinkVertexModel CreateVertex(double clientX, double clientY, int index)
{
var rPt = BlazorDiagram.GetRelativeMousePoint(clientX, clientY);
var vertex = new LinkVertexModel(Link, rPt);
Link.Vertices.Insert(index, vertex);
Link.Refresh();
return vertex;
}
}

View File

@@ -0,0 +1,10 @@
using ThingsGateway.Blazor.Diagrams.Core.Models;
namespace ThingsGateway.Blazor.Diagrams.Models;
public class SvgGroupModel : GroupModel
{
public SvgGroupModel(IEnumerable<NodeModel> children, byte padding = 30, bool autoSize = true) : base(children, padding, autoSize)
{
}
}

View File

@@ -0,0 +1,15 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
namespace ThingsGateway.Blazor.Diagrams.Models;
public class SvgNodeModel : NodeModel
{
public SvgNodeModel(Point? position = null) : base(position)
{
}
public SvgNodeModel(string id, Point? position = null) : base(id, position)
{
}
}

View File

@@ -0,0 +1,15 @@

<div class="blazor-diagram-node @(Node.Group != null ? "grouped" : "") @(Node.Selected ? "selected" : "")">
@(Node.Title ?? "Title")
@foreach (var port in Node.Ports)
{
<PortRenderer @key="port" Port="port" Class="blazor-diagram"></PortRenderer>
}
</div>
@code {
[Parameter]
public NodeModel Node { get; set; } = null!;
}

View File

@@ -0,0 +1,7 @@
using ThingsGateway.Blazor.Diagrams.Core.Options;
namespace ThingsGateway.Blazor.Diagrams.Options;
public class BlazorDiagramConstraintsOptions : DiagramConstraintsOptions
{
}

View File

@@ -0,0 +1,7 @@
using ThingsGateway.Blazor.Diagrams.Core.Options;
namespace ThingsGateway.Blazor.Diagrams.Options;
public class BlazorDiagramGroupOptions : DiagramGroupOptions
{
}

View File

@@ -0,0 +1,9 @@
using ThingsGateway.Blazor.Diagrams.Core.Options;
namespace ThingsGateway.Blazor.Diagrams.Options;
public class BlazorDiagramLinkOptions : DiagramLinkOptions
{
public string DefaultColor { get; set; } = "black";
public string DefaultSelectedColor { get; set; } = "rgb(110, 159, 212)";
}

View File

@@ -0,0 +1,15 @@
using ThingsGateway.Blazor.Diagrams.Core.Options;
namespace ThingsGateway.Blazor.Diagrams.Options;
public class BlazorDiagramOptions : DiagramOptions
{
public int LinksLayerOrder { get; set; } = 0;
public int NodesLayerOrder { get; set; } = 0;
public override BlazorDiagramZoomOptions Zoom { get; } = new();
public override BlazorDiagramLinkOptions Links { get; } = new();
public override BlazorDiagramGroupOptions Groups { get; } = new();
public override BlazorDiagramConstraintsOptions Constraints { get; } = new();
public override BlazorDiagramVirtualizationOptions Virtualization { get; } = new();
}

View File

@@ -0,0 +1,7 @@
using ThingsGateway.Blazor.Diagrams.Core.Options;
namespace ThingsGateway.Blazor.Diagrams.Options;
public class BlazorDiagramVirtualizationOptions : DiagramVirtualizationOptions
{
}

View File

@@ -0,0 +1,7 @@
using ThingsGateway.Blazor.Diagrams.Core.Options;
namespace ThingsGateway.Blazor.Diagrams.Options;
public class BlazorDiagramZoomOptions : DiagramZoomOptions
{
}

View File

@@ -0,0 +1,144 @@
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using System.Text;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Extensions;
using ThingsGateway.Blazor.Diagrams.Models;
namespace ThingsGateway.Blazor.Diagrams.Components.Renderers;
public class GroupRenderer : ComponentBase, IDisposable
{
private bool _isSvg;
private Size? _lastSize;
private bool _shouldRender = true;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public GroupModel Group { get; set; } = null!;
public void Dispose()
{
Group.Changed -= OnGroupChanged;
Group.VisibilityChanged -= OnGroupChanged;
}
protected override void OnInitialized()
{
Group.Changed += OnGroupChanged;
Group.VisibilityChanged += OnGroupChanged;
}
protected override void OnParametersSet()
{
base.OnParametersSet();
_isSvg = Group is SvgGroupModel;
}
protected override bool ShouldRender()
{
if (_shouldRender)
{
_shouldRender = false;
return true;
}
return false;
}
protected override void OnAfterRender(bool firstRender)
{
if (Size.Zero.Equals(Group.Size))
return;
// Update the port positions (and links) when the size of the group changes
// This will save us some JS trips as well as useless rerenders
if (_lastSize == null || !_lastSize.Equals(Group.Size))
{
Group.ReinitializePorts();
Group.RefreshLinks();
_lastSize = Group.Size;
}
}
private void OnGroupChanged(Model _)
{
_shouldRender = true;
InvokeAsync(StateHasChanged);
}
private static string GenerateStyle(double top, double left, double width, double height)
{
return FormattableString.Invariant($"top: {top}px; left: {left}px; width: {width}px; height: {height}px");
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (!Group.Visible)
return;
var componentType = BlazorDiagram.GetComponent(Group) ?? typeof(DefaultGroupWidget);
var classes = new StringBuilder("diagram-group")
.AppendIf(" locked", Group.Locked)
.AppendIf(" selected", Group.Selected)
.AppendIf(" default", componentType == typeof(DefaultGroupWidget));
builder.OpenElement(0, _isSvg ? "g" : "div");
builder.AddAttribute(1, "class", classes.ToString());
builder.AddAttribute(2, "data-group-id", Group.Id);
if (_isSvg)
builder.AddAttribute(3, "transform",
FormattableString.Invariant($"translate({Group.Position.X} {Group.Position.Y})"));
else
builder.AddAttribute(3, "style",
GenerateStyle(Group.Position.Y, Group.Position.X, Group.Size!.Width, Group.Size.Height));
builder.AddAttribute(4, "onpointerdown", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerDown));
builder.AddEventStopPropagationAttribute(5, "onpointerdown", true);
builder.AddAttribute(6, "onpointerup", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerUp));
builder.AddEventStopPropagationAttribute(7, "onpointerup", true);
builder.AddAttribute(8, "onmouseenter", EventCallback.Factory.Create<MouseEventArgs>(this, OnMouseEnter));
builder.AddAttribute(9, "onmouseleave", EventCallback.Factory.Create<MouseEventArgs>(this, OnMouseLeave));
if (_isSvg)
{
builder.OpenElement(10, "rect");
builder.AddAttribute(11, "width", Group.Size!.Width);
builder.AddAttribute(12, "height", Group.Size.Height);
builder.AddAttribute(13, "fill", "none");
builder.CloseElement();
}
builder.OpenComponent(14, componentType);
builder.AddAttribute(15, "Group", Group);
builder.CloseComponent();
builder.CloseElement();
}
private void OnPointerDown(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerDown(Group, e.ToCore());
}
private void OnPointerUp(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerUp(Group, e.ToCore());
}
private void OnMouseEnter(MouseEventArgs e)
{
BlazorDiagram.TriggerPointerEnter(Group, e.ToCore());
}
private void OnMouseLeave(MouseEventArgs e)
{
BlazorDiagram.TriggerPointerLeave(Group, e.ToCore());
}
}

View File

@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Components.Rendering;
using SvgPathProperties;
using ThingsGateway.Blazor.Diagrams.Core.Extensions;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Components.Renderers;
public class LinkLabelRenderer : ComponentBase, IDisposable
{
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public LinkLabelModel Label { get; set; } = null!;
[Parameter] public SvgPath Path { get; set; } = null!;
public void Dispose()
{
Label.Changed -= OnLabelChanged;
Label.VisibilityChanged -= OnLabelChanged;
}
protected override void OnInitialized()
{
Label.Changed += OnLabelChanged;
Label.VisibilityChanged += OnLabelChanged;
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (!Label.Visible)
return;
var position = FindPosition();
if (position == null)
return;
var componentType = BlazorDiagram.GetComponent(Label) ?? typeof(DefaultLinkLabelWidget);
builder.OpenElement(0, "foreignObject");
builder.AddAttribute(1, "class", "diagram-link-label");
builder.AddAttribute(2, "x", (position.X + (Label.Offset?.X ?? 0)).ToInvariantString());
builder.AddAttribute(3, "y", (position.Y + (Label.Offset?.Y ?? 0)).ToInvariantString());
builder.OpenComponent(4, componentType);
builder.AddAttribute(5, "Label", Label);
builder.CloseComponent();
builder.CloseElement();
}
private void OnLabelChanged(Model _)
{
InvokeAsync(StateHasChanged);
}
private Point? FindPosition()
{
var totalLength = Path.Length;
var length = Label.Distance switch
{
<= 1 and >= 0 => Label.Distance.Value * totalLength,
> 1 => Label.Distance.Value,
< 0 => totalLength + Label.Distance.Value,
_ => totalLength * (Label.Parent.Labels.IndexOf(Label) + 1) / (Label.Parent.Labels.Count + 1)
};
var pt = Path.GetPointAtLength(length);
return new Point(pt.X, pt.Y);
}
}

View File

@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using System.Text;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Extensions;
namespace ThingsGateway.Blazor.Diagrams.Components.Renderers;
public class LinkRenderer : ComponentBase, IDisposable
{
private bool _shouldRender = true;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public BaseLinkModel Link { get; set; } = null!;
public void Dispose()
{
Link.Changed -= OnLinkChanged;
Link.VisibilityChanged -= OnLinkChanged;
}
protected override void OnInitialized()
{
base.OnInitialized();
Link.Changed += OnLinkChanged;
Link.VisibilityChanged += OnLinkChanged;
}
protected override bool ShouldRender()
{
if (!_shouldRender)
return false;
_shouldRender = false;
return true;
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (!Link.Visible)
return;
var componentType = BlazorDiagram.GetComponent(Link) ?? typeof(LinkWidget);
var classes = new StringBuilder()
.Append("diagram-link")
.AppendIf(" attached", Link.IsAttached)
.ToString();
builder.OpenElement(0, "g");
builder.AddAttribute(1, "class", classes);
builder.AddAttribute(2, "data-link-id", Link.Id);
builder.AddAttribute(3, "onpointerdown", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerDown));
builder.AddEventStopPropagationAttribute(4, "onpointerdown", true);
builder.AddAttribute(5, "onpointerup", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerUp));
builder.AddEventStopPropagationAttribute(6, "onpointerup", true);
builder.AddAttribute(7, "onmouseenter", EventCallback.Factory.Create<MouseEventArgs>(this, OnMouseEnter));
builder.AddAttribute(8, "onmouseleave", EventCallback.Factory.Create<MouseEventArgs>(this, OnMouseLeave));
builder.OpenComponent(9, componentType);
builder.AddAttribute(10, "Link", Link);
builder.CloseComponent();
builder.CloseElement();
}
private void OnLinkChanged(Model _)
{
_shouldRender = true;
InvokeAsync(StateHasChanged);
}
private void OnPointerDown(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerDown(Link, e.ToCore());
}
private void OnPointerUp(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerUp(Link, e.ToCore());
}
private void OnMouseEnter(MouseEventArgs e)
{
BlazorDiagram.TriggerPointerEnter(Link, e.ToCore());
}
private void OnMouseLeave(MouseEventArgs e)
{
BlazorDiagram.TriggerPointerLeave(Link, e.ToCore());
}
}

View File

@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using ThingsGateway.Blazor.Diagrams.Core.Extensions;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Extensions;
namespace ThingsGateway.Blazor.Diagrams.Components.Renderers;
public class LinkVertexRenderer : ComponentBase, IDisposable
{
private bool _shouldRender = true;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public LinkVertexModel Vertex { get; set; } = null!;
[Parameter] public string? Color { get; set; }
[Parameter] public string? SelectedColor { get; set; }
private string? ColorToUse => Vertex.Selected ? SelectedColor : Color;
public void Dispose()
{
Vertex.Changed -= OnVertexChanged;
}
protected override void OnInitialized()
{
Vertex.Changed += OnVertexChanged;
}
protected override bool ShouldRender()
{
if (!_shouldRender) return false;
_shouldRender = false;
return true;
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
var componentType = BlazorDiagram.GetComponent(Vertex);
builder.OpenElement(0, "g");
builder.AddAttribute(1, "class", "diagram-link-vertex");
builder.AddAttribute(4, "cursor", "move");
builder.AddAttribute(5, "ondblclick", value: EventCallback.Factory.Create<MouseEventArgs>(this, OnDoubleClick));
builder.AddAttribute(6, "onpointerdown", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerDown));
builder.AddAttribute(7, "onpointerup", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerUp));
builder.AddEventStopPropagationAttribute(8, "onpointerdown", true);
builder.AddEventStopPropagationAttribute(9, "onpointerup", true);
if (componentType == null)
{
builder.OpenElement(10, "circle");
builder.AddAttribute(11, "cx", Vertex.Position.X.ToInvariantString());
builder.AddAttribute(12, "cy", Vertex.Position.Y.ToInvariantString());
builder.AddAttribute(13, "r", "5");
builder.AddAttribute(14, "fill", ColorToUse);
builder.CloseElement();
}
else
{
builder.OpenComponent(15, componentType);
builder.AddAttribute(16, "Vertex", Vertex);
builder.AddAttribute(17, "Color", ColorToUse);
builder.CloseComponent();
}
builder.CloseElement();
}
private void OnVertexChanged(Model _)
{
_shouldRender = true;
InvokeAsync(StateHasChanged);
}
private void OnPointerDown(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerDown(Vertex, e.ToCore());
}
private void OnPointerUp(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerUp(Vertex, e.ToCore());
}
private void OnDoubleClick(MouseEventArgs e)
{
Vertex.Parent.Vertices.Remove(Vertex);
Vertex.Parent.Refresh();
}
}

View File

@@ -0,0 +1,182 @@
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using System.Text;
using ThingsGateway.Blazor.Diagrams.Core.Extensions;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Extensions;
using ThingsGateway.Blazor.Diagrams.Models;
namespace ThingsGateway.Blazor.Diagrams.Components.Renderers;
public class NodeRenderer : ComponentBase, IDisposable
{
private bool _becameVisible;
private ElementReference _element;
private bool _isSvg;
private DotNetObjectReference<NodeRenderer>? _reference;
private bool _shouldRender;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public NodeModel Node { get; set; } = null!;
[Inject] private IJSRuntime JsRuntime { get; set; } = null!;
public void Dispose()
{
Node.Changed -= OnNodeChanged;
Node.VisibilityChanged -= OnVisibilityChanged;
if (_element.Id != null && !Node.ControlledSize)
{
_ = JsRuntime.UnobserveResizes(_element);
}
_reference?.Dispose();
}
[JSInvokable]
public void OnResize(Size size)
{
// When the node becomes invisible (a.k.a unrendered), the size is zero
if (Size.Zero.Equals(size))
return;
size = new Size(size.Width / BlazorDiagram.Zoom, size.Height / BlazorDiagram.Zoom);
if (Node.Size != null && Node.Size.Width.AlmostEqualTo(size.Width) &&
Node.Size.Height.AlmostEqualTo(size.Height))
{
return;
}
Node.Size = size;
Node.Refresh();
Node.RefreshLinks();
Node.ReinitializePorts();
}
protected override void OnInitialized()
{
base.OnInitialized();
_reference = DotNetObjectReference.Create(this);
Node.Changed += OnNodeChanged;
Node.VisibilityChanged += OnVisibilityChanged;
}
protected override void OnParametersSet()
{
base.OnParametersSet();
_isSvg = Node is SvgNodeModel;
}
protected override bool ShouldRender()
{
if (!_shouldRender)
return false;
_shouldRender = false;
return true;
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (!Node.Visible)
return;
var componentType = BlazorDiagram.GetComponent(Node) ??
(_isSvg ? typeof(SvgNodeWidget) : typeof(NodeWidget));
var classes = new StringBuilder("diagram-node")
.AppendIf(" locked", Node.Locked)
.AppendIf(" selected", Node.Selected)
.AppendIf(" grouped", Node.Group != null);
builder.OpenElement(0, _isSvg ? "g" : "div");
builder.AddAttribute(1, "class", classes.ToString());
builder.AddAttribute(2, "data-node-id", Node.Id);
if (_isSvg)
{
builder.AddAttribute(3, "transform",
$"translate({Node.Position.X.ToInvariantString()} {Node.Position.Y.ToInvariantString()})");
}
else
{
builder.AddAttribute(3, "style",
$"top: {Node.Position.Y.ToInvariantString()}px; left: {Node.Position.X.ToInvariantString()}px");
}
builder.AddAttribute(4, "onpointerdown", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerDown));
builder.AddEventStopPropagationAttribute(5, "onpointerdown", true);
builder.AddAttribute(6, "onpointerup", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerUp));
builder.AddEventStopPropagationAttribute(7, "onpointerup", true);
builder.AddAttribute(8, "onmouseenter", EventCallback.Factory.Create<MouseEventArgs>(this, OnMouseEnter));
builder.AddAttribute(9, "onmouseleave", EventCallback.Factory.Create<MouseEventArgs>(this, OnMouseLeave));
builder.AddElementReferenceCapture(10, value => _element = value);
builder.OpenComponent(11, componentType);
builder.AddAttribute(12, "Node", Node);
builder.CloseComponent();
builder.CloseElement();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !Node.Visible)
return;
if (firstRender || _becameVisible)
{
_becameVisible = false;
if (!Node.ControlledSize)
{
await JsRuntime.ObserveResizes(_element, _reference!).ConfigureAwait(false);
}
}
}
private void OnNodeChanged(Model _)
{
ReRender();
}
private void OnVisibilityChanged(Model _)
{
_becameVisible = Node.Visible;
ReRender();
}
private void ReRender()
{
_shouldRender = true;
InvokeAsync(StateHasChanged);
}
private void OnPointerDown(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerDown(Node, e.ToCore());
}
private void OnPointerUp(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerUp(Node, e.ToCore());
}
private void OnMouseEnter(MouseEventArgs e)
{
BlazorDiagram.TriggerPointerEnter(Node, e.ToCore());
}
private void OnMouseLeave(MouseEventArgs e)
{
BlazorDiagram.TriggerPointerLeave(Node, e.ToCore());
}
}

View File

@@ -0,0 +1,159 @@
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Extensions;
using ThingsGateway.Blazor.Diagrams.Models;
namespace ThingsGateway.Blazor.Diagrams.Components.Renderers;
public class PortRenderer : ComponentBase, IDisposable
{
private ElementReference _element;
private bool _isParentSvg;
private bool _shouldRefreshPort;
private bool _shouldRender = true;
private bool _updatingDimensions;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
[Parameter] public PortModel Port { get; set; } = null!;
[Parameter] public string? Class { get; set; }
[Parameter] public string? Style { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
public void Dispose()
{
Port.Changed -= OnPortChanged;
Port.VisibilityChanged -= OnPortChanged;
}
protected override void OnInitialized()
{
base.OnInitialized();
Port.Changed += OnPortChanged;
Port.VisibilityChanged += OnPortChanged;
}
protected override void OnParametersSet()
{
base.OnParametersSet();
_isParentSvg = Port.Parent is SvgNodeModel;
}
protected override bool ShouldRender()
{
if (!_shouldRender)
return false;
_shouldRender = false;
return true;
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (!Port.Visible)
return;
builder.OpenElement(0, _isParentSvg ? "g" : "div");
builder.AddAttribute(1, "style", Style);
builder.AddAttribute(2, "class",
"diagram-port" + " " + Port.Alignment.ToString().ToLower() + " " + (Port.Links.Count > 0 ? "has-links" : "") + " " +
Class);
builder.AddAttribute(3, "data-port-id", Port.Id);
builder.AddAttribute(4, "onpointerdown", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerDown));
builder.AddEventStopPropagationAttribute(5, "onpointerdown", true);
builder.AddAttribute(6, "onpointerup", EventCallback.Factory.Create<PointerEventArgs>(this, OnPointerUp));
builder.AddEventStopPropagationAttribute(7, "onpointerup", true);
builder.AddElementReferenceCapture(8, __value => { _element = __value; });
builder.AddContent(9, ChildContent);
builder.CloseElement();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!Port.Initialized)
{
await UpdateDimensions().ConfigureAwait(false);
}
}
private void OnPointerDown(PointerEventArgs e)
{
BlazorDiagram.TriggerPointerDown(Port, e.ToCore());
}
private void OnPointerUp(PointerEventArgs e)
{
var model = e.PointerType == "mouse" ? Port : FindPortOn(e.ClientX, e.ClientY);
BlazorDiagram.TriggerPointerUp(model, e.ToCore());
}
private PortModel? FindPortOn(double clientX, double clientY)
{
var allPorts = BlazorDiagram.Nodes.SelectMany(n => n.Ports)
.Union(BlazorDiagram.Groups.SelectMany(g => g.Ports));
foreach (var port in allPorts)
{
if (!port.Initialized)
continue;
var relativePt = BlazorDiagram.GetRelativeMousePoint(clientX, clientY);
if (port.GetBounds().ContainsPoint(relativePt))
return port;
}
return null;
}
private async Task UpdateDimensions()
{
if (BlazorDiagram.Container == null)
return;
_updatingDimensions = true;
var zoom = BlazorDiagram.Zoom;
var pan = BlazorDiagram.Pan;
var rect = await JSRuntime.GetBoundingClientRect(_element).ConfigureAwait(false);
Port.Size = new Size(rect.Width / zoom, rect.Height / zoom);
Port.Position = new Point((rect.Left - BlazorDiagram.Container.Left - pan.X) / zoom,
(rect.Top - BlazorDiagram.Container.Top - pan.Y) / zoom);
Port.Initialized = true;
_updatingDimensions = false;
if (_shouldRefreshPort)
{
_shouldRefreshPort = false;
Port.RefreshAll();
}
else
{
Port.RefreshLinks();
}
}
private async void OnPortChanged(Model _)
{
// If an update is ongoing and the port is refreshed again,
// it's highly likely the port needs to be refreshed (e.g. link added)
if (_updatingDimensions) _shouldRefreshPort = true;
if (Port.Initialized)
{
_shouldRender = true;
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
else
{
await UpdateDimensions().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,8 @@
<rect width="50" height="50" style="fill:rgb(0,0,255);stroke-width:3;stroke:rgb(0,0,0)"/>
@code {
[Parameter]
public NodeModel Node { get; set; } = null!;
}

View File

@@ -0,0 +1,8 @@
@if (_visible)
{
<div class="grid" style="width: 100%; height: 100%; background-color: @BackgroundColor; @GenerateStyle()"></div>
}
else
{
<div class="grid"></div>
}

View File

@@ -0,0 +1,78 @@
using System.Text;
using ThingsGateway.Blazor.Diagrams.Core.Extensions;
namespace ThingsGateway.Blazor.Diagrams.Components.Widgets;
public partial class GridWidget : IDisposable
{
private bool _visible;
private double _scaledSize;
private double _posX;
private double _posY;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public double Size { get; set; } = 20;
[Parameter] public double ZoomThreshold { get; set; } = 0;
[Parameter] public GridMode Mode { get; set; } = GridMode.Line;
[Parameter] public string BackgroundColor { get; set; } = "rgb(241 241 241)";
public void Dispose()
{
BlazorDiagram.PanChanged -= RefreshPosition;
BlazorDiagram.ZoomChanged -= RefreshPosition;
}
protected override void OnInitialized()
{
BlazorDiagram.PanChanged += RefreshPosition;
BlazorDiagram.ZoomChanged += RefreshPosition;
}
protected override void OnParametersSet()
{
_posX = BlazorDiagram.Pan.X;
_posY = BlazorDiagram.Pan.Y;
_scaledSize = Size * BlazorDiagram.Zoom;
_visible = BlazorDiagram.Zoom > ZoomThreshold;
}
private void RefreshPosition()
{
_posX = BlazorDiagram.Pan.X;
_posY = BlazorDiagram.Pan.Y;
_scaledSize = Size * BlazorDiagram.Zoom;
_visible = BlazorDiagram.Zoom > ZoomThreshold;
InvokeAsync(StateHasChanged);
}
private string GenerateStyle()
{
var sb = new StringBuilder();
sb.Append($"background-color: {BackgroundColor};");
sb.Append($"background-size: {_scaledSize.ToInvariantString()}px {_scaledSize.ToInvariantString()}px;");
sb.Append($"background-position-x: {_posX.ToInvariantString()}px;");
sb.Append($"background-position-y: {_posY.ToInvariantString()}px;");
switch (Mode)
{
case GridMode.Line:
sb.Append("background-image: linear-gradient(rgb(211, 211, 211) 1px, transparent 1px), linear-gradient(90deg, rgb(211, 211, 211) 1px, transparent 1px);");
break;
case GridMode.Point:
sb.Append("background-image: radial-gradient(circle at 0 0, rgb(129, 129, 129) 1px, transparent 1px);");
break;
default:
throw new ArgumentOutOfRangeException();
}
return sb.ToString();
}
}
public enum GridMode
{
Line,
Point
}

View File

@@ -0,0 +1,40 @@
<svg class="navigator @Class"
style="@Style"
width="@Width.ToInvariantString()"
height="@Height.ToInvariantString()"
viewBox="@(FormattableString.Invariant($"{_x} {_y} {_width} {_height}"))">
@foreach (var group in BlazorDiagram.Groups)
{
if (group.Size == null)
continue;
<rect @key="group"
class="navigator-group"
x="@group.Position.X.ToInvariantString()"
y="@group.Position.Y.ToInvariantString()"
width="@group.Size.Width.ToInvariantString()"
height="@group.Size.Height.ToInvariantString()"
fill="@GroupColor">
</rect>
}
@foreach (var node in BlazorDiagram.Nodes)
{
if (node.Size == null)
continue;
@GetNodeRenderFragment(node)
}
<rect class="navigator-current-view"
x="@(_vX.ToInvariantString())"
y="@(_vY.ToInvariantString())"
width="@_vWidth.ToInvariantString()"
height="@_vHeight.ToInvariantString()"
fill="none"
stroke="@ViewStrokeColor"
stroke-width="@ViewStrokeWidth">
</rect>
</svg>

View File

@@ -0,0 +1,155 @@
using Microsoft.AspNetCore.Components.Rendering;
using ThingsGateway.Blazor.Diagrams.Core.Extensions;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Components.Widgets;
public partial class NavigatorWidget : IDisposable
{
private double _x;
private double _y;
private double _width;
private double _height;
private double _scaledMargin;
private double _vX;
private double _vY;
private double _vWidth;
private double _vHeight;
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public bool UseNodeShape { get; set; } = true;
[Parameter] public double Width { get; set; }
[Parameter] public double Height { get; set; }
[Parameter] public double Margin { get; set; } = 5;
[Parameter] public string NodeColor { get; set; } = "#40babd";
[Parameter] public string GroupColor { get; set; } = "#9fd0d1";
[Parameter] public string ViewStrokeColor { get; set; } = "#40babd";
[Parameter] public int ViewStrokeWidth { get; set; } = 4;
[Parameter] public string? Class { get; set; }
[Parameter] public string? Style { get; set; }
public void Dispose()
{
BlazorDiagram.Changed -= Refresh;
BlazorDiagram.Nodes.Added -= OnNodeAdded;
BlazorDiagram.Nodes.Removed -= OnNodeRemoved;
BlazorDiagram.Groups.Added -= OnNodeAdded;
BlazorDiagram.Groups.Removed -= OnNodeRemoved;
foreach (var node in BlazorDiagram.Nodes)
node.Changed -= OnNodeChanged;
foreach (var group in BlazorDiagram.Groups)
group.Changed -= OnNodeChanged;
}
protected override void OnInitialized()
{
BlazorDiagram.Changed += Refresh;
BlazorDiagram.Nodes.Added += OnNodeAdded;
BlazorDiagram.Nodes.Removed += OnNodeRemoved;
BlazorDiagram.Groups.Added += OnNodeAdded;
BlazorDiagram.Groups.Removed += OnNodeRemoved;
foreach (var node in BlazorDiagram.Nodes)
node.Changed += OnNodeChanged;
foreach (var group in BlazorDiagram.Groups)
group.Changed += OnNodeChanged;
}
private void OnNodeAdded(NodeModel node) => node.Changed += OnNodeChanged;
private void OnNodeRemoved(NodeModel node) => node.Changed -= OnNodeChanged;
private void OnNodeChanged(Model _) => Refresh();
private void Refresh()
{
if (BlazorDiagram.Container == null)
return;
_vX = -BlazorDiagram.Pan.X / BlazorDiagram.Zoom;
_vY = -BlazorDiagram.Pan.Y / BlazorDiagram.Zoom;
_vWidth = BlazorDiagram.Container.Width / BlazorDiagram.Zoom;
_vHeight = BlazorDiagram.Container.Height / BlazorDiagram.Zoom;
var minX = _vX;
var minY = _vY;
var maxX = _vX + _vWidth;
var maxY = _vY + _vHeight;
foreach (var node in BlazorDiagram.Nodes.Union(BlazorDiagram.Groups))
{
if (node.Size == null)
continue;
minX = Math.Min(minX, node.Position.X);
minY = Math.Min(minY, node.Position.Y);
maxX = Math.Max(maxX, node.Position.X + node.Size.Width);
maxY = Math.Max(maxY, node.Position.Y + node.Size.Height);
}
var width = maxX - minX;
var height = maxY - minY;
var scaledWidth = width / Width;
var scaledHeight = height / Height;
var scale = Math.Max(scaledWidth, scaledHeight);
var viewWidth = scale * Width;
var viewHeight = scale * Height;
_scaledMargin = Margin * scale;
_x = minX - (viewWidth - width) / 2 - _scaledMargin;
_y = minY - (viewHeight - height) / 2 - _scaledMargin;
_width = viewWidth + _scaledMargin * 2;
_height = viewHeight + _scaledMargin * 2;
InvokeAsync(StateHasChanged);
}
private RenderFragment GetNodeRenderFragment(NodeModel node)
{
return builder =>
{
if (UseNodeShape)
{
var shape = node.GetShape();
if (shape is Ellipse ellipse)
{
RenderEllipse(node, builder, ellipse);
return;
}
}
RenderRect(node, builder);
};
}
private void RenderRect(NodeModel node, RenderTreeBuilder builder)
{
builder.OpenElement(0, "rect");
builder.SetKey(node);
builder.AddAttribute(1, "class", "navigator-node");
builder.AddAttribute(2, "fill", NodeColor);
builder.AddAttribute(2, "x", node.Position.X.ToInvariantString());
builder.AddAttribute(2, "y", node.Position.Y.ToInvariantString());
builder.AddAttribute(2, "width", node.Size!.Width.ToInvariantString());
builder.AddAttribute(2, "height", node.Size.Height.ToInvariantString());
builder.CloseElement();
}
private void RenderEllipse(NodeModel node, RenderTreeBuilder builder, Ellipse ellipse)
{
builder.OpenElement(0, "ellipse");
builder.SetKey(node);
builder.AddAttribute(1, "class", "navigator-node");
builder.AddAttribute(2, "fill", NodeColor);
builder.AddAttribute(2, "cx", ellipse.Cx.ToInvariantString());
builder.AddAttribute(2, "cy", ellipse.Cy.ToInvariantString());
builder.AddAttribute(2, "rx", ellipse.Rx.ToInvariantString());
builder.AddAttribute(2, "ry", ellipse.Ry.ToInvariantString());
builder.CloseElement();
}
}

View File

@@ -0,0 +1,4 @@
@if (_selectionBoxTopLeft != null && _selectionBoxSize != null)
{
<div style="@GenerateStyle()"></div>
}

View File

@@ -0,0 +1,89 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Components.Widgets;
public partial class SelectionBoxWidget : IDisposable
{
private Point? _initialClientPoint;
private Size? _selectionBoxSize; // Todo: remove unneeded instantiations
private Point? _selectionBoxTopLeft; // Todo: remove unneeded instantiations
[CascadingParameter] public BlazorDiagram BlazorDiagram { get; set; } = null!;
[Parameter] public string Background { get; set; } = "rgb(110 159 212 / 25%)";
public void Dispose()
{
BlazorDiagram.PointerDown -= OnPointerDown;
BlazorDiagram.PointerMove -= OnPointerMove;
BlazorDiagram.PointerUp -= OnPointerUp;
}
protected override void OnInitialized()
{
BlazorDiagram.PointerDown += OnPointerDown;
BlazorDiagram.PointerMove += OnPointerMove;
BlazorDiagram.PointerUp += OnPointerUp;
}
private string GenerateStyle()
{
return FormattableString.Invariant(
$"position: absolute; background: {Background}; top: {_selectionBoxTopLeft!.Y}px; left: {_selectionBoxTopLeft.X}px; width: {_selectionBoxSize!.Width}px; height: {_selectionBoxSize.Height}px;");
}
private void OnPointerDown(Model? model, MouseEventArgs e)
{
if (model != null || !e.ShiftKey)
return;
_initialClientPoint = new Point(e.ClientX, e.ClientY);
}
private void OnPointerMove(Model? model, MouseEventArgs e)
{
if (_initialClientPoint == null)
return;
SetSelectionBoxInformation(e);
var start = BlazorDiagram.GetRelativeMousePoint(_initialClientPoint.X, _initialClientPoint.Y);
var end = BlazorDiagram.GetRelativeMousePoint(e.ClientX, e.ClientY);
var (sX, sY) = (Math.Min(start.X, end.X), Math.Min(start.Y, end.Y));
var (eX, eY) = (Math.Max(start.X, end.X), Math.Max(start.Y, end.Y));
var bounds = new Rectangle(sX, sY, eX, eY);
foreach (var node in BlazorDiagram.Nodes)
{
var nodeBounds = node.GetBounds();
if (nodeBounds == null)
continue;
if (bounds.Overlap(nodeBounds))
BlazorDiagram.SelectModel(node, false);
else if (node.Selected) BlazorDiagram.UnselectModel(node);
}
InvokeAsync(StateHasChanged);
}
private void SetSelectionBoxInformation(MouseEventArgs e)
{
var start = BlazorDiagram.GetRelativePoint(_initialClientPoint!.X, _initialClientPoint.Y);
var end = BlazorDiagram.GetRelativePoint(e.ClientX, e.ClientY);
var (sX, sY) = (Math.Min(start.X, end.X), Math.Min(start.Y, end.Y));
var (eX, eY) = (Math.Max(start.X, end.X), Math.Max(start.Y, end.Y));
_selectionBoxTopLeft = new Point(sX, sY);
_selectionBoxSize = new Size(eX - sX, eY - sY);
}
private void OnPointerUp(Model? model, MouseEventArgs e)
{
_initialClientPoint = null;
_selectionBoxTopLeft = null;
_selectionBoxSize = null;
InvokeAsync(StateHasChanged);
}
}

View File

@@ -0,0 +1,47 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Anchors;
public abstract class Anchor
{
protected Anchor(ILinkable? model = null)
{
Model = model;
}
public ILinkable? Model { get; }
public abstract Point? GetPosition(BaseLinkModel link, Point[] route);
public abstract Point? GetPlainPosition();
public Point? GetPosition(BaseLinkModel link) => GetPosition(link, Array.Empty<Point>());
protected static Point? GetOtherPosition(BaseLinkModel link, bool isTarget)
{
var anchor = isTarget ? link.Source : link.Target!;
return anchor.GetPlainPosition();
}
protected static Point? GetClosestPointTo(IEnumerable<Point?> points, Point point)
{
var minDist = double.MaxValue;
Point? minPoint = null;
foreach (var pt in points)
{
if (pt == null)
continue;
var dist = pt.DistanceTo(point);
if (dist < minDist)
{
minDist = dist;
minPoint = pt;
}
}
return minPoint;
}
}

View File

@@ -0,0 +1,34 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Core.Positions;
namespace ThingsGateway.Blazor.Diagrams.Core.Anchors;
public sealed class DynamicAnchor : Anchor
{
public DynamicAnchor(NodeModel model, IPositionProvider[] providers) : base(model)
{
if (providers.Length == 0)
throw new InvalidOperationException("No providers provided");
Node = model;
Providers = providers;
}
public NodeModel Node { get; }
public IPositionProvider[] Providers { get; }
public override Point? GetPosition(BaseLinkModel link, Point[] route)
{
if (Node.Size == null)
return null;
var isTarget = link.Target == this;
var pt = route.Length > 0 ? route[isTarget ? ^1 : 0] : GetOtherPosition(link, isTarget);
var positions = Providers.Select(e => e.GetPosition(Node));
return pt is null ? null : GetClosestPointTo(positions, pt);
}
public override Point? GetPlainPosition() => Node.GetBounds()?.Center ?? null;
}

View File

@@ -0,0 +1,22 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Core.Positions;
namespace ThingsGateway.Blazor.Diagrams.Core.Anchors;
public class LinkAnchor : Anchor
{
private readonly LinkPathPositionProvider _positionProvider;
public LinkAnchor(BaseLinkModel link, double distance, double offsetX = 0, double offsetY = 0) : base(link)
{
_positionProvider = new LinkPathPositionProvider(distance, offsetX, offsetY);
Link = link;
}
public BaseLinkModel Link { get; }
public override Point? GetPosition(BaseLinkModel link, Point[] route) => _positionProvider.GetPosition(Link);
public override Point? GetPlainPosition() => _positionProvider.GetPosition(Link);
}

View File

@@ -0,0 +1,20 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Anchors;
public sealed class PositionAnchor : Anchor
{
private Point _position;
public PositionAnchor(Point position) : base(null)
{
_position = position;
}
public void SetPosition(Point position) => _position = position;
public override Point? GetPlainPosition() => _position;
public override Point? GetPosition(BaseLinkModel link, Point[] route) => _position;
}

View File

@@ -0,0 +1,41 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Anchors;
public sealed class ShapeIntersectionAnchor : Anchor
{
public ShapeIntersectionAnchor(NodeModel model) : base(model)
{
Node = model;
}
public NodeModel Node { get; }
public override Point? GetPosition(BaseLinkModel link, Point[] route)
{
if (Node.Size == null)
return null;
var isTarget = link.Target == this;
var nodeCenter = Node.GetBounds()!.Center;
Point? pt;
if (route.Length > 0)
{
pt = route[isTarget ? ^1 : 0];
}
else
{
pt = GetOtherPosition(link, isTarget);
}
if (pt is null) return null;
var line = new Line(pt, nodeCenter);
var intersections = Node.GetShape().GetIntersectionsWithLine(line);
return GetClosestPointTo(intersections, pt);
}
public override Point? GetPlainPosition() => Node.GetBounds()?.Center ?? null;
}

View File

@@ -0,0 +1,57 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Anchors;
public sealed class SinglePortAnchor : Anchor
{
public SinglePortAnchor(PortModel port) : base(port)
{
Port = port;
}
public PortModel Port { get; }
public bool MiddleIfNoMarker { get; set; } = false;
public bool UseShapeAndAlignment { get; set; } = true;
public override Point? GetPosition(BaseLinkModel link, Point[] route)
{
if (!Port.Initialized)
return null;
if (MiddleIfNoMarker && ((link.Source == this && link.SourceMarker is null) || (link.Target == this && link.TargetMarker is null)))
return Port.MiddlePosition;
var pt = Port.Position;
if (UseShapeAndAlignment)
{
return Port.Alignment switch
{
PortAlignment.Top => Port.GetShape().GetPointAtAngle(270),
PortAlignment.TopRight => Port.GetShape().GetPointAtAngle(315),
PortAlignment.Right => Port.GetShape().GetPointAtAngle(0),
PortAlignment.BottomRight => Port.GetShape().GetPointAtAngle(45),
PortAlignment.Bottom => Port.GetShape().GetPointAtAngle(90),
PortAlignment.BottomLeft => Port.GetShape().GetPointAtAngle(135),
PortAlignment.Left => Port.GetShape().GetPointAtAngle(180),
PortAlignment.TopLeft => Port.GetShape().GetPointAtAngle(225),
_ => null,
};
}
return Port.Alignment switch
{
PortAlignment.Top => new Point(pt.X + Port.Size.Width / 2, pt.Y),
PortAlignment.TopRight => new Point(pt.X + Port.Size.Width, pt.Y),
PortAlignment.Right => new Point(pt.X + Port.Size.Width, pt.Y + Port.Size.Height / 2),
PortAlignment.BottomRight => new Point(pt.X + Port.Size.Width, pt.Y + Port.Size.Height),
PortAlignment.Bottom => new Point(pt.X + Port.Size.Width / 2, pt.Y + Port.Size.Height),
PortAlignment.BottomLeft => new Point(pt.X, pt.Y + Port.Size.Height),
PortAlignment.Left => new Point(pt.X, pt.Y + Port.Size.Height / 2),
_ => pt,
};
}
public override Point? GetPlainPosition() => Port.MiddlePosition;
}

View File

@@ -0,0 +1,13 @@
namespace ThingsGateway.Blazor.Diagrams.Core;
public abstract class Behavior : IDisposable
{
public Behavior(Diagram diagram)
{
Diagram = diagram;
}
protected Diagram Diagram { get; }
public abstract void Dispose();
}

View File

@@ -0,0 +1,92 @@
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class DebugEventsBehavior : Behavior
{
public DebugEventsBehavior(Diagram diagram) : base(diagram)
{
Diagram.Changed += Diagram_Changed;
Diagram.ContainerChanged += Diagram_ContainerChanged;
Diagram.PanChanged += Diagram_PanChanged;
Diagram.Nodes.Added += Nodes_Added;
Diagram.Nodes.Removed += Nodes_Removed;
Diagram.Links.Added += Links_Added;
Diagram.Links.Removed += Links_Removed;
Diagram.Groups.Added += Diagram_GroupAdded;
Diagram.Groups.Removed += Diagram_GroupRemoved;
Diagram.SelectionChanged += Diagram_SelectionChanged;
Diagram.ZoomChanged += Diagram_ZoomChanged;
}
private void Diagram_ZoomChanged()
{
Console.WriteLine($"ZoomChanged, Zoom={Diagram.Zoom}");
}
private void Diagram_SelectionChanged(SelectableModel obj)
{
Console.WriteLine($"SelectionChanged, Model={obj.GetType().Name}, Selected={obj.Selected}");
}
private void Links_Removed(BaseLinkModel obj)
{
Console.WriteLine($"Links.Removed, Links=[{obj}]");
}
private void Links_Added(BaseLinkModel obj)
{
Console.WriteLine($"Links.Added, Links=[{obj}]");
}
private void Nodes_Removed(NodeModel obj)
{
Console.WriteLine($"Nodes.Removed, Nodes=[{obj}]");
}
private void Nodes_Added(NodeModel obj)
{
Console.WriteLine($"Nodes.Added, Nodes=[{obj}]");
}
private void Diagram_PanChanged()
{
Console.WriteLine($"PanChanged, Pan={Diagram.Pan}");
}
private void Diagram_GroupRemoved(GroupModel obj)
{
Console.WriteLine($"GroupRemoved, Id={obj.Id}");
}
private void Diagram_GroupAdded(GroupModel obj)
{
Console.WriteLine($"GroupAdded, Id={obj.Id}");
}
private void Diagram_ContainerChanged()
{
Console.WriteLine($"ContainerChanged, Container={Diagram.Container}");
}
private void Diagram_Changed()
{
Console.WriteLine("Changed");
}
public override void Dispose()
{
Diagram.Changed -= Diagram_Changed;
Diagram.ContainerChanged -= Diagram_ContainerChanged;
Diagram.PanChanged -= Diagram_PanChanged;
Diagram.Nodes.Added -= Nodes_Added;
Diagram.Nodes.Removed -= Nodes_Removed;
Diagram.Links.Added -= Links_Added;
Diagram.Links.Removed -= Links_Removed;
Diagram.Groups.Added -= Diagram_GroupAdded;
Diagram.Groups.Removed -= Diagram_GroupRemoved;
Diagram.SelectionChanged -= Diagram_SelectionChanged;
Diagram.ZoomChanged -= Diagram_ZoomChanged;
}
}

View File

@@ -0,0 +1,112 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class DragMovablesBehavior : Behavior
{
private readonly Dictionary<MovableModel, Point> _initialPositions;
private double? _lastClientX;
private double? _lastClientY;
private bool _moved;
public DragMovablesBehavior(Diagram diagram) : base(diagram)
{
_initialPositions = new Dictionary<MovableModel, Point>();
Diagram.PointerDown += OnPointerDown;
Diagram.PointerMove += OnPointerMove;
Diagram.PointerUp += OnPointerUp;
}
private void OnPointerDown(Model? model, PointerEventArgs e)
{
if (model is not MovableModel)
return;
_initialPositions.Clear();
foreach (var sm in Diagram.GetSelectedModels())
{
if (sm is not MovableModel movable || movable.Locked)
continue;
// Special case: groups without auto size on
if (sm is NodeModel node && node.Group != null && !node.Group.AutoSize)
continue;
var position = movable.Position;
if (Diagram.Options.GridSnapToCenter && movable is NodeModel n)
{
position = new Point(movable.Position.X + (n.Size?.Width ?? 0) / 2,
movable.Position.Y + (n.Size?.Height ?? 0) / 2);
}
_initialPositions.Add(movable, position);
}
_lastClientX = e.ClientX;
_lastClientY = e.ClientY;
_moved = false;
}
private void OnPointerMove(Model? model, PointerEventArgs e)
{
if (_initialPositions.Count == 0 || _lastClientX == null || _lastClientY == null)
return;
_moved = true;
var deltaX = (e.ClientX - _lastClientX.Value) / Diagram.Zoom;
var deltaY = (e.ClientY - _lastClientY.Value) / Diagram.Zoom;
foreach (var (movable, initialPosition) in _initialPositions)
{
var ndx = ApplyGridSize(deltaX + initialPosition.X);
var ndy = ApplyGridSize(deltaY + initialPosition.Y);
if (Diagram.Options.GridSnapToCenter && movable is NodeModel node)
{
node.SetPosition(ndx - (node.Size?.Width ?? 0) / 2, ndy - (node.Size?.Height ?? 0) / 2);
}
else
{
movable.SetPosition(ndx, ndy);
}
}
}
private void OnPointerUp(Model? model, PointerEventArgs e)
{
if (_initialPositions.Count == 0)
return;
if (_moved)
{
foreach (var (movable, _) in _initialPositions)
{
movable.TriggerMoved();
}
}
_initialPositions.Clear();
_lastClientX = null;
_lastClientY = null;
}
private double ApplyGridSize(double n)
{
if (Diagram.Options.GridSize == null)
return n;
var gridSize = Diagram.Options.GridSize.Value;
return gridSize * Math.Floor((n + gridSize / 2.0) / gridSize);
}
public override void Dispose()
{
_initialPositions.Clear();
Diagram.PointerDown -= OnPointerDown;
Diagram.PointerMove -= OnPointerMove;
Diagram.PointerUp -= OnPointerUp;
}
}

View File

@@ -0,0 +1,177 @@
using ThingsGateway.Blazor.Diagrams.Core.Anchors;
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class DragNewLinkBehavior : Behavior
{
private PositionAnchor? _targetPositionAnchor;
public BaseLinkModel? OngoingLink { get; private set; }
public DragNewLinkBehavior(Diagram diagram) : base(diagram)
{
Diagram.PointerDown += OnPointerDown;
Diagram.PointerMove += OnPointerMove;
Diagram.PointerUp += OnPointerUp;
}
public void StartFrom(ILinkable source, double clientX, double clientY)
{
if (OngoingLink != null)
return;
_targetPositionAnchor = new PositionAnchor(CalculateTargetPosition(clientX, clientY));
OngoingLink = Diagram.Options.Links.Factory(Diagram, source, _targetPositionAnchor);
if (OngoingLink == null)
return;
Diagram.Links.Add(OngoingLink);
}
public void StartFrom(BaseLinkModel link, double clientX, double clientY)
{
if (OngoingLink != null)
return;
_targetPositionAnchor = new PositionAnchor(CalculateTargetPosition(clientX, clientY));
OngoingLink = link;
OngoingLink.SetTarget(_targetPositionAnchor);
OngoingLink.Refresh();
OngoingLink.RefreshLinks();
}
private void OnPointerDown(Model? model, MouseEventArgs e)
{
if (e.Button != (int)MouseEventButton.Left)
return;
OngoingLink = null;
_targetPositionAnchor = null;
if (model is PortModel port)
{
if (port.Locked)
return;
_targetPositionAnchor = new PositionAnchor(CalculateTargetPosition(e.ClientX, e.ClientY));
OngoingLink = Diagram.Options.Links.Factory(Diagram, port, _targetPositionAnchor);
if (OngoingLink == null)
return;
OngoingLink.SetTarget(_targetPositionAnchor);
Diagram.Links.Add(OngoingLink);
}
}
private void OnPointerMove(Model? model, MouseEventArgs e)
{
if (OngoingLink == null || model != null)
return;
_targetPositionAnchor!.SetPosition(CalculateTargetPosition(e.ClientX, e.ClientY));
if (Diagram.Options.Links.EnableSnapping)
{
var nearPort = FindNearPortToAttachTo();
if ((nearPort != null && nearPort?.Alignment == PortAlignment.Top) || OngoingLink.Target is not PositionAnchor)
{
OngoingLink.SetTarget(nearPort is null ? _targetPositionAnchor : new SinglePortAnchor(nearPort));
}
}
OngoingLink.Refresh();
OngoingLink.RefreshLinks();
}
private void OnPointerUp(Model? model, MouseEventArgs e)
{
if (OngoingLink == null)
return;
if (OngoingLink.IsAttached) // Snapped already
{
OngoingLink.TriggerTargetAttached();
OngoingLink = null;
return;
}
if (model is ILinkable linkable && (OngoingLink.Source.Model == null || OngoingLink.Source.Model.CanAttachTo(linkable)))
{
var targetAnchor = Diagram.Options.Links.TargetAnchorFactory(Diagram, OngoingLink, linkable);
if (targetAnchor is SinglePortAnchor singlePortAnchor && singlePortAnchor.Model is PortModel portModel && portModel.Alignment == PortAlignment.Top)
{
OngoingLink.SetTarget(targetAnchor);
OngoingLink.TriggerTargetAttached();
OngoingLink.Refresh();
OngoingLink.RefreshLinks();
}
else
{
Diagram.Links.Remove(OngoingLink);
}
}
else if (Diagram.Options.Links.RequireTarget)
{
Diagram.Links.Remove(OngoingLink);
}
else if (!Diagram.Options.Links.RequireTarget)
{
OngoingLink.Refresh();
}
OngoingLink = null;
}
private Point CalculateTargetPosition(double clientX, double clientY)
{
var target = Diagram.GetRelativeMousePoint(clientX, clientY);
if (OngoingLink == null)
{
return target;
}
var source = OngoingLink.Source.GetPlainPosition()!;
var dirVector = target.Subtract(source).Normalize();
var change = dirVector.Multiply(5);
return target.Subtract(change);
}
private PortModel? FindNearPortToAttachTo()
{
if (OngoingLink is null || _targetPositionAnchor is null)
return null;
PortModel? nearestSnapPort = null;
var nearestSnapPortDistance = double.PositiveInfinity;
var position = _targetPositionAnchor!.GetPosition(OngoingLink)!;
foreach (var port in Diagram.Nodes.SelectMany((NodeModel n) => n.Ports))
{
var distance = position.DistanceTo(port.Position);
if (distance <= Diagram.Options.Links.SnappingRadius && (OngoingLink.Source.Model?.CanAttachTo(port) != false))
{
if (distance < nearestSnapPortDistance)
{
nearestSnapPortDistance = distance;
nearestSnapPort = port;
}
}
}
return nearestSnapPort;
}
public override void Dispose()
{
Diagram.PointerDown -= OnPointerDown;
Diagram.PointerMove -= OnPointerMove;
Diagram.PointerUp -= OnPointerUp;
}
}

View File

@@ -0,0 +1,71 @@
using System.Diagnostics;
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class EventsBehavior : Behavior
{
private readonly Stopwatch _mouseClickSw;
private Model? _model;
private bool _captureMouseMove;
private int _mouseMovedCount;
public EventsBehavior(Diagram diagram) : base(diagram)
{
_mouseClickSw = new Stopwatch();
Diagram.PointerDown += OnPointerDown;
Diagram.PointerMove += OnPointerMove;
Diagram.PointerUp += OnPointerUp;
Diagram.PointerClick += OnPointerClick;
}
private void OnPointerClick(Model? model, PointerEventArgs e)
{
if (_mouseClickSw.IsRunning && _mouseClickSw.ElapsedMilliseconds <= 500)
{
Diagram.TriggerPointerDoubleClick(model, e);
}
_mouseClickSw.Restart();
}
private void OnPointerDown(Model? model, PointerEventArgs e)
{
_captureMouseMove = true;
_mouseMovedCount = 0;
_model = model;
}
private void OnPointerMove(Model? model, PointerEventArgs e)
{
if (!_captureMouseMove)
return;
_mouseMovedCount++;
}
private void OnPointerUp(Model? model, PointerEventArgs e)
{
if (!_captureMouseMove) return; // Only set by OnMouseDown
_captureMouseMove = false;
if (_mouseMovedCount > 0) return;
if (_model == model)
{
Diagram.TriggerPointerClick(model, e);
_model = null;
}
}
public override void Dispose()
{
Diagram.PointerDown -= OnPointerDown;
Diagram.PointerMove -= OnPointerMove;
Diagram.PointerUp -= OnPointerUp;
Diagram.PointerClick -= OnPointerClick;
_model = null;
}
}

View File

@@ -0,0 +1,59 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Utils;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class CaseInsensitiveComparer : IEqualityComparer<string>
{
public bool Equals(string x, string y)
{
// 比较两个字符串,忽略大小写
return string.Equals(x, y, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(string obj)
{
// 生成不区分大小写的哈希码
return obj == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(obj);
}
}
public class KeyboardShortcutsBehavior : Behavior
{
private readonly Dictionary<string, Func<Diagram, ValueTask>> _shortcuts;
public KeyboardShortcutsBehavior(Diagram diagram) : base(diagram)
{
_shortcuts = new Dictionary<string, Func<Diagram, ValueTask>>(10000, new CaseInsensitiveComparer());
SetShortcut("Delete", false, false, false, KeyboardShortcutsDefaults.DeleteSelection);
SetShortcut("g", true, false, true, KeyboardShortcutsDefaults.Grouping);
Diagram.KeyDown += OnDiagramKeyDown;
}
public void SetShortcut(string key, bool ctrl, bool shift, bool alt, Func<Diagram, ValueTask> action)
{
var k = KeysUtils.GetStringRepresentation(ctrl, shift, alt, key);
_shortcuts[k] = action;
}
public bool RemoveShortcut(string key, bool ctrl, bool shift, bool alt)
{
var k = KeysUtils.GetStringRepresentation(ctrl, shift, alt, key);
return _shortcuts.Remove(k);
}
private async void OnDiagramKeyDown(KeyboardEventArgs e)
{
var k = KeysUtils.GetStringRepresentation(e.CtrlKey, e.ShiftKey, e.AltKey, e.Key);
if (_shortcuts.TryGetValue(k, out var action))
{
await action(Diagram).ConfigureAwait(false);
}
}
public override void Dispose()
{
Diagram.KeyDown -= OnDiagramKeyDown;
}
}

View File

@@ -0,0 +1,71 @@
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public static class KeyboardShortcutsDefaults
{
public static async ValueTask DeleteSelection(Diagram diagram)
{
var wasSuspended = diagram.SuspendRefresh;
if (!wasSuspended) diagram.SuspendRefresh = true;
foreach (var sm in diagram.GetSelectedModels().ToArray())
{
if (sm.Locked)
continue;
if (sm is GroupModel group && (await diagram.Options.Constraints.ShouldDeleteGroup(group).ConfigureAwait(false)))
{
diagram.Groups.Delete(group);
}
else if (sm is NodeModel node && (await diagram.Options.Constraints.ShouldDeleteNode(node).ConfigureAwait(false)))
{
diagram.Nodes.Remove(node);
}
else if (sm is BaseLinkModel link && (await diagram.Options.Constraints.ShouldDeleteLink(link).ConfigureAwait(false)))
{
diagram.Links.Remove(link);
}
}
if (!wasSuspended)
{
diagram.SuspendRefresh = false;
diagram.Refresh();
}
}
public static ValueTask Grouping(Diagram diagram)
{
if (!diagram.Options.Groups.Enabled)
return ValueTask.CompletedTask;
if (!diagram.GetSelectedModels().Any())
return ValueTask.CompletedTask;
var selectedNodes = diagram.Nodes.Where(n => n.Selected).ToArray();
var nodesWithGroup = selectedNodes.Where(n => n.Group != null).ToArray();
if (nodesWithGroup.Length > 0)
{
// Ungroup
foreach (var group in nodesWithGroup.GroupBy(n => n.Group!).Select(g => g.Key))
{
diagram.Groups.Remove(group);
}
}
else
{
// Group
if (selectedNodes.Length < 2)
return ValueTask.CompletedTask;
if (selectedNodes.Any(n => n.Group != null))
return ValueTask.CompletedTask;
diagram.Groups.Group(selectedNodes);
}
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,66 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class PanBehavior : Behavior
{
private Point? _initialPan;
private double _lastClientX;
private double _lastClientY;
public PanBehavior(Diagram diagram) : base(diagram)
{
Diagram.PointerDown += OnPointerDown;
Diagram.PointerMove += OnPointerMove;
Diagram.PointerUp += OnPointerUp;
}
private void OnPointerDown(Model? model, PointerEventArgs e)
{
if (e.Button != (int)MouseEventButton.Left)
return;
Start(model, e.ClientX, e.ClientY, e.ShiftKey);
}
private void OnPointerMove(Model? model, PointerEventArgs e) => Move(e.ClientX, e.ClientY);
private void OnPointerUp(Model? model, PointerEventArgs e) => End();
private void Start(Model? model, double clientX, double clientY, bool shiftKey)
{
if (!Diagram.Options.AllowPanning || model != null || shiftKey)
return;
_initialPan = Diagram.Pan;
_lastClientX = clientX;
_lastClientY = clientY;
}
private void Move(double clientX, double clientY)
{
if (!Diagram.Options.AllowPanning || _initialPan == null)
return;
var deltaX = clientX - _lastClientX - (Diagram.Pan.X - _initialPan.X);
var deltaY = clientY - _lastClientY - (Diagram.Pan.Y - _initialPan.Y);
Diagram.UpdatePan(deltaX, deltaY);
}
private void End()
{
if (!Diagram.Options.AllowPanning)
return;
_initialPan = null;
}
public override void Dispose()
{
Diagram.PointerDown -= OnPointerDown;
Diagram.PointerMove -= OnPointerMove;
Diagram.PointerUp -= OnPointerUp;
}
}

View File

@@ -0,0 +1,40 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class SelectionBehavior : Behavior
{
public SelectionBehavior(Diagram diagram) : base(diagram)
{
Diagram.PointerDown += OnPointerDown;
}
private void OnPointerDown(Model? model, PointerEventArgs e)
{
var ctrlKey = e.CtrlKey;
switch (model)
{
case null:
Diagram.UnselectAll();
break;
case SelectableModel sm when ctrlKey && sm.Selected:
Diagram.UnselectModel(sm);
break;
case SelectableModel sm:
{
if (!sm.Selected)
{
Diagram.SelectModel(sm, !ctrlKey || !Diagram.Options.AllowMultiSelection);
}
break;
}
}
}
public override void Dispose()
{
Diagram.PointerDown -= OnPointerDown;
}
}

View File

@@ -0,0 +1,69 @@
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class VirtualizationBehavior : Behavior
{
public VirtualizationBehavior(Diagram diagram) : base(diagram)
{
Diagram.ZoomChanged += CheckVisibility;
Diagram.PanChanged += CheckVisibility;
Diagram.ContainerChanged += CheckVisibility;
}
private void CheckVisibility()
{
if (!Diagram.Options.Virtualization.Enabled)
return;
if (Diagram.Container == null)
return;
if (Diagram.Options.Virtualization.OnNodes)
{
foreach (var node in Diagram.Nodes)
{
CheckVisibility(node);
}
}
if (Diagram.Options.Virtualization.OnGroups)
{
foreach (var group in Diagram.Groups)
{
CheckVisibility(group);
}
}
if (Diagram.Options.Virtualization.OnLinks)
{
foreach (var link in Diagram.Links)
{
CheckVisibility(link);
}
}
}
private void CheckVisibility(Model model)
{
if (model is not IHasBounds ihb)
return;
var bounds = ihb.GetBounds();
if (bounds == null)
return;
var left = bounds.Left * Diagram.Zoom + Diagram.Pan.X;
var top = bounds.Top * Diagram.Zoom + Diagram.Pan.Y;
var right = left + bounds.Width * Diagram.Zoom;
var bottom = top + bounds.Height * Diagram.Zoom;
model.Visible = right > 0 && left < Diagram.Container!.Width && bottom > 0 && top < Diagram.Container.Height;
}
public override void Dispose()
{
Diagram.ZoomChanged -= CheckVisibility;
Diagram.PanChanged -= CheckVisibility;
Diagram.ContainerChanged -= CheckVisibility;
}
}

View File

@@ -0,0 +1,53 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
namespace ThingsGateway.Blazor.Diagrams.Core.Behaviors;
public class ZoomBehavior : Behavior
{
public ZoomBehavior(Diagram diagram) : base(diagram)
{
Diagram.Wheel += Diagram_Wheel;
}
private void Diagram_Wheel(WheelEventArgs e)
{
if (Diagram.Container == null || e.DeltaY == 0)
return;
if (!Diagram.Options.Zoom.Enabled)
return;
var scale = Diagram.Options.Zoom.ScaleFactor;
var oldZoom = Diagram.Zoom;
var deltaY = Diagram.Options.Zoom.Inverse ? e.DeltaY * -1 : e.DeltaY;
var newZoom = deltaY > 0 ? oldZoom * scale : oldZoom / scale;
newZoom = Math.Clamp(newZoom, Diagram.Options.Zoom.Minimum, Diagram.Options.Zoom.Maximum);
if (newZoom < 0 || newZoom == Diagram.Zoom)
return;
// Other algorithms (based only on the changes in the zoom) don't work for our case
// This solution is taken as is from react-diagrams (ZoomCanvasAction)
var clientWidth = Diagram.Container.Width;
var clientHeight = Diagram.Container.Height;
var widthDiff = clientWidth * newZoom - clientWidth * oldZoom;
var heightDiff = clientHeight * newZoom - clientHeight * oldZoom;
var clientX = e.ClientX - Diagram.Container.Left;
var clientY = e.ClientY - Diagram.Container.Top;
var xFactor = (clientX - Diagram.Pan.X) / oldZoom / clientWidth;
var yFactor = (clientY - Diagram.Pan.Y) / oldZoom / clientHeight;
var newPanX = Diagram.Pan.X - widthDiff * xFactor;
var newPanY = Diagram.Pan.Y - heightDiff * yFactor;
Diagram.Batch(() =>
{
Diagram.SetPan(newPanX, newPanY);
Diagram.SetZoom(newZoom);
});
}
public override void Dispose()
{
Diagram.Wheel -= Diagram_Wheel;
}
}

View File

@@ -0,0 +1,9 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls;
public abstract class Control
{
public abstract Point? GetPosition(Model model);
}

View File

@@ -0,0 +1,61 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls;
public class ControlsBehavior : Behavior
{
public ControlsBehavior(Diagram diagram) : base(diagram)
{
Diagram.PointerEnter += OnPointerEnter;
Diagram.PointerLeave += OnPointerLeave;
Diagram.SelectionChanged += OnSelectionChanged;
}
private void OnSelectionChanged(SelectableModel model)
{
var controls = Diagram.Controls.GetFor(model);
if (controls is not { Type: ControlsType.OnSelection })
return;
if (model.Selected)
{
controls.Show();
}
else
{
controls.Hide();
}
}
private void OnPointerEnter(Model? model, PointerEventArgs e)
{
if (model == null)
return;
var controls = Diagram.Controls.GetFor(model);
if (controls is not { Type: ControlsType.OnHover })
return;
controls.Show();
}
private void OnPointerLeave(Model? model, PointerEventArgs e)
{
if (model == null)
return;
var controls = Diagram.Controls.GetFor(model);
if (controls is not { Type: ControlsType.OnHover })
return;
controls.Hide();
}
public override void Dispose()
{
Diagram.PointerEnter -= OnPointerEnter;
Diagram.PointerLeave -= OnPointerLeave;
Diagram.SelectionChanged -= OnSelectionChanged;
}
}

View File

@@ -0,0 +1,63 @@
using System.Collections;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls;
public class ControlsContainer : IReadOnlyList<Control>
{
private readonly List<Control> _controls = new(4);
public event Action<Model>? VisibilityChanged;
public event Action<Model>? ControlsChanged;
public ControlsContainer(Model model, ControlsType type = ControlsType.OnSelection)
{
Model = model;
Type = type;
}
public Model Model { get; }
public ControlsType Type { get; set; }
public bool Visible { get; private set; }
public void Show()
{
if (Visible)
return;
Visible = true;
VisibilityChanged?.Invoke(Model);
}
public void Hide()
{
if (!Visible)
return;
Visible = false;
VisibilityChanged?.Invoke(Model);
}
public ControlsContainer Add(Control control)
{
_controls.Add(control);
ControlsChanged?.Invoke(Model);
return this;
}
public ControlsContainer Remove(Control control)
{
if (_controls.Remove(control))
{
ControlsChanged?.Invoke(Model);
}
return this;
}
public int Count => _controls.Count;
public Control this[int index] => _controls[index];
public IEnumerator<Control> GetEnumerator() => _controls.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _controls.GetEnumerator();
}

View File

@@ -0,0 +1,60 @@
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls;
public class ControlsLayer
{
private readonly Dictionary<Model, ControlsContainer> _containers;
public event Action<Model>? ChangeCaused;
public ControlsLayer()
{
_containers = new Dictionary<Model, ControlsContainer>();
}
public IReadOnlyCollection<Model> Models => _containers.Keys;
public ControlsContainer AddFor(Model model, ControlsType type = ControlsType.OnSelection)
{
if (_containers.TryGetValue(model, out ControlsContainer? value))
return value;
var container = new ControlsContainer(model, type);
container.VisibilityChanged += OnVisibilityChanged;
container.ControlsChanged += RefreshIfVisible;
model.Changed += RefreshIfVisible;
_containers.Add(model, container);
return container;
}
public ControlsContainer? GetFor(Model model)
{
return _containers.TryGetValue(model, out var container) ? container : null;
}
public bool RemoveFor(Model model)
{
if (!_containers.TryGetValue(model, out var container))
return false;
container.VisibilityChanged -= OnVisibilityChanged;
container.ControlsChanged -= RefreshIfVisible;
model.Changed -= RefreshIfVisible;
_containers.Remove(model);
ChangeCaused?.Invoke(model);
return true;
}
public bool AreVisibleFor(Model model) => GetFor(model)?.Visible ?? false;
private void RefreshIfVisible(Model cause)
{
if (!AreVisibleFor(cause))
return;
ChangeCaused?.Invoke(cause);
}
private void OnVisibilityChanged(Model cause) => ChangeCaused?.Invoke(cause);
}

View File

@@ -0,0 +1,7 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Controls;
public enum ControlsType
{
OnHover,
OnSelection
}

View File

@@ -0,0 +1,56 @@
using ThingsGateway.Blazor.Diagrams.Core.Behaviors;
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Core.Positions;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls.Default;
public class ArrowHeadControl : ExecutableControl
{
public ArrowHeadControl(bool source, LinkMarker? marker = null)
{
Source = source;
Marker = marker ?? LinkMarker.NewArrow(20, 20);
}
public bool Source { get; }
public LinkMarker Marker { get; }
public double Angle { get; private set; }
public override Point? GetPosition(Model model)
{
if (model is not BaseLinkModel link)
throw new DiagramsException("ArrowHeadControl only works for models of type BaseLinkModel");
var dist = Source ? Marker.Width - (link.SourceMarker?.Width ?? 0) : (link.TargetMarker?.Width ?? 0) - Marker.Width;
var pp = new LinkPathPositionProvider(dist);
var p1 = pp.GetPosition(link);
if (p1 is not null)
{
var p2 = Source ? link.Source.GetPosition(link) : link.Target.GetPosition(link);
if (p2 is not null)
{
Angle = Math.Atan2(p2.Y - p1.Y, p2.X - p1.X) * 180 / Math.PI;
}
}
return p1;
}
public override ValueTask OnPointerDown(Diagram diagram, Model model, PointerEventArgs e)
{
if (model is not BaseLinkModel link)
throw new DiagramsException("ArrowHeadControl only works for models of type BaseLinkModel");
var dnlb = diagram.GetBehavior<DragNewLinkBehavior>()!;
if (Source)
{
link.SetSource(link.Target);
}
dnlb.StartFrom(link, e.ClientX, e.ClientY);
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,22 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls.Default;
public class BoundaryControl : Control
{
public Rectangle Bounds { get; private set; } = Rectangle.Zero;
public override Point? GetPosition(Model model)
{
if (model is not IHasBounds hb)
return null;
var bounds = hb.GetBounds();
if (bounds == null)
return null;
Bounds = bounds.Inflate(10, 10);
return Bounds.NorthWest;
}
}

View File

@@ -0,0 +1,38 @@
using ThingsGateway.Blazor.Diagrams.Core.Behaviors;
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Core.Positions;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls.Default;
public class DragNewLinkControl : ExecutableControl
{
private readonly IPositionProvider _positionProvider;
public DragNewLinkControl(double x, double y, double offsetX = 0, double offsetY = 0)
: this(new BoundsBasedPositionProvider(x, y, offsetX, offsetY))
{
}
public DragNewLinkControl(IPositionProvider positionProvider)
{
_positionProvider = positionProvider;
}
public override Point? GetPosition(Model model) => _positionProvider.GetPosition(model);
public override ValueTask OnPointerDown(Diagram diagram, Model model, PointerEventArgs e)
{
if (model is not NodeModel node || node.Locked)
return ValueTask.CompletedTask;
var behavior = diagram.GetBehavior<DragNewLinkBehavior>();
if (behavior == null)
throw new DiagramsException($"DragNewLinkBehavior was not found");
behavior.StartFrom(node, e.ClientX, e.ClientY);
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,65 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Core.Positions;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls.Default;
public class RemoveControl : ExecutableControl
{
private readonly IPositionProvider _positionProvider;
public RemoveControl(double x, double y, double offsetX = 0, double offsetY = 0)
: this(new BoundsBasedPositionProvider(x, y, offsetX, offsetY))
{
}
public RemoveControl(IPositionProvider positionProvider)
{
_positionProvider = positionProvider;
}
public override Point? GetPosition(Model model) => _positionProvider.GetPosition(model);
public override async ValueTask OnPointerDown(Diagram diagram, Model model, PointerEventArgs _)
{
if (await ShouldDeleteModel(diagram, model).ConfigureAwait(false))
{
DeleteModel(diagram, model);
}
}
private static void DeleteModel(Diagram diagram, Model model)
{
switch (model)
{
case GroupModel group:
diagram.Groups.Delete(group);
return;
case NodeModel node:
diagram.Nodes.Remove(node);
return;
case BaseLinkModel link:
diagram.Links.Remove(link);
return;
}
}
private static async ValueTask<bool> ShouldDeleteModel(Diagram diagram, Model model)
{
if (model.Locked)
{
return false;
}
return model switch
{
GroupModel group => await diagram.Options.Constraints.ShouldDeleteGroup.Invoke(group).ConfigureAwait(false),
NodeModel node => await diagram.Options.Constraints.ShouldDeleteNode.Invoke(node).ConfigureAwait(false),
BaseLinkModel link => await diagram.Options.Constraints.ShouldDeleteLink.Invoke(link).ConfigureAwait(false),
_ => false,
};
}
}

View File

@@ -0,0 +1,9 @@
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Controls;
public abstract class ExecutableControl : Control
{
public abstract ValueTask OnPointerDown(Diagram diagram, Model model, PointerEventArgs e);
}

View File

@@ -0,0 +1,11 @@
using ThingsGateway.Blazor.Diagrams.Core.Anchors;
using ThingsGateway.Blazor.Diagrams.Core.Models;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core;
public delegate BaseLinkModel? LinkFactory(Diagram diagram, ILinkable source, Anchor targetAnchor);
public delegate Anchor AnchorFactory(Diagram diagram, BaseLinkModel link, ILinkable model);
public delegate GroupModel GroupFactory(Diagram diagram, NodeModel[] children);

View File

@@ -0,0 +1,407 @@
using System.Runtime.CompilerServices;
using ThingsGateway.Blazor.Diagrams.Core.Behaviors;
using ThingsGateway.Blazor.Diagrams.Core.Controls;
using ThingsGateway.Blazor.Diagrams.Core.Events;
using ThingsGateway.Blazor.Diagrams.Core.Extensions;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Layers;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
using ThingsGateway.Blazor.Diagrams.Core.Options;
[assembly: InternalsVisibleTo("ThingsGateway.Blazor.Diagrams")]
[assembly: InternalsVisibleTo("ThingsGateway.Blazor.Diagrams.Tests")]
[assembly: InternalsVisibleTo("ThingsGateway.Blazor.Diagrams.Core.Tests")]
namespace ThingsGateway.Blazor.Diagrams.Core;
public abstract class Diagram
{
private readonly Dictionary<Type, Behavior> _behaviors;
private readonly List<SelectableModel> _orderedSelectables;
public event Action<Model?, PointerEventArgs>? PointerDown;
public event Action<Model?, PointerEventArgs>? PointerMove;
public event Action<Model?, PointerEventArgs>? PointerUp;
public event Action<Model?, PointerEventArgs>? PointerEnter;
public event Action<Model?, PointerEventArgs>? PointerLeave;
public event Action<KeyboardEventArgs>? KeyDown;
public event Action<WheelEventArgs>? Wheel;
public event Action<Model?, PointerEventArgs>? PointerClick;
public event Action<Model?, PointerEventArgs>? PointerDoubleClick;
public event Action<SelectableModel>? SelectionChanged;
public event Action? PanChanged;
public event Action? ZoomChanged;
public event Action? ContainerChanged;
public event Action? Changed;
protected Diagram(bool registerDefaultBehaviors = true)
{
_behaviors = new Dictionary<Type, Behavior>();
_orderedSelectables = new List<SelectableModel>();
Nodes = new NodeLayer(this);
Links = new LinkLayer(this);
Groups = new GroupLayer(this);
Controls = new ControlsLayer();
Nodes.Added += OnSelectableAdded;
Links.Added += OnSelectableAdded;
Groups.Added += OnSelectableAdded;
Nodes.Removed += OnSelectableRemoved;
Links.Removed += OnSelectableRemoved;
Groups.Removed += OnSelectableRemoved;
if (!registerDefaultBehaviors)
return;
RegisterBehavior(new SelectionBehavior(this));
RegisterBehavior(new DragMovablesBehavior(this));
RegisterBehavior(new DragNewLinkBehavior(this));
RegisterBehavior(new PanBehavior(this));
RegisterBehavior(new ZoomBehavior(this));
RegisterBehavior(new EventsBehavior(this));
RegisterBehavior(new KeyboardShortcutsBehavior(this));
RegisterBehavior(new ControlsBehavior(this));
RegisterBehavior(new VirtualizationBehavior(this));
}
public abstract DiagramOptions Options { get; }
public NodeLayer Nodes { get; }
public LinkLayer Links { get; }
public GroupLayer Groups { get; }
public ControlsLayer Controls { get; }
public Rectangle? Container { get; private set; }
public Point Pan { get; private set; } = Point.Zero;
public double Zoom { get; private set; } = 1;
public bool SuspendRefresh { get; set; }
public bool SuspendSorting { get; set; }
public IReadOnlyList<SelectableModel> OrderedSelectables => _orderedSelectables;
public void Refresh()
{
if (SuspendRefresh)
return;
Changed?.Invoke();
}
public void Batch(Action action)
{
if (SuspendRefresh)
{
// If it's already suspended, just execute the action and leave it suspended
// It's probably handled by an outer batch
action();
return;
}
SuspendRefresh = true;
action();
SuspendRefresh = false;
Refresh();
}
#region Selection
public IEnumerable<SelectableModel> GetSelectedModels()
{
foreach (var node in Nodes)
{
if (node.Selected)
yield return node;
}
foreach (var link in Links)
{
if (link.Selected)
yield return link;
foreach (var vertex in link.Vertices)
{
if (vertex.Selected)
yield return vertex;
}
}
foreach (var group in Groups)
{
if (group.Selected)
yield return group;
}
}
public void SelectModel(SelectableModel model, bool unselectOthers)
{
if (model.Selected)
return;
if (unselectOthers)
UnselectAll();
model.Selected = true;
model.Refresh();
SelectionChanged?.Invoke(model);
}
public void UnselectModel(SelectableModel model)
{
if (!model.Selected)
return;
model.Selected = false;
model.Refresh();
SelectionChanged?.Invoke(model);
}
public void UnselectAll()
{
foreach (var model in GetSelectedModels())
{
model.Selected = false;
model.Refresh();
// Todo: will result in many events, maybe one event for all of them?
SelectionChanged?.Invoke(model);
}
}
#endregion
#region Behaviors
public void RegisterBehavior(Behavior behavior)
{
var type = behavior.GetType();
if (_behaviors.ContainsKey(type))
throw new Exception($"Behavior '{type.Name}' already registered");
_behaviors.Add(type, behavior);
}
public T? GetBehavior<T>() where T : Behavior
{
var type = typeof(T);
return (T?)(_behaviors.TryGetValue(type, out Behavior? value) ? value : null);
}
public void UnregisterBehavior<T>() where T : Behavior
{
var type = typeof(T);
if (!_behaviors.TryGetValue(type, out Behavior? value))
return;
value.Dispose();
_behaviors.Remove(type);
}
#endregion
public void ZoomToFit(double margin = 10)
{
if (Container == null || Nodes.Count == 0)
return;
var selectedNodes = Nodes.Where(s => s.Selected);
var nodesToUse = selectedNodes.Any() ? selectedNodes : Nodes;
var bounds = nodesToUse.GetBounds();
var width = bounds.Width + 2 * margin;
var height = bounds.Height + 2 * margin;
var minX = bounds.Left - margin;
var minY = bounds.Top - margin;
SuspendRefresh = true;
var xf = Container.Width / width;
var yf = Container.Height / height;
SetZoom(Math.Min(xf, yf));
var nx = Container.Left + Pan.X + minX * Zoom;
var ny = Container.Top + Pan.Y + minY * Zoom;
UpdatePan(Container.Left - nx, Container.Top - ny);
SuspendRefresh = false;
Refresh();
}
public void SetPan(double x, double y)
{
Pan = new Point(x, y);
PanChanged?.Invoke();
Refresh();
}
public void UpdatePan(double deltaX, double deltaY)
{
Pan = Pan.Add(deltaX, deltaY);
PanChanged?.Invoke();
Refresh();
}
public void SetZoom(double newZoom)
{
if (newZoom <= 0)
throw new ArgumentException($"{nameof(newZoom)} cannot be equal or lower than 0");
if (newZoom < Options.Zoom.Minimum)
newZoom = Options.Zoom.Minimum;
Zoom = newZoom;
ZoomChanged?.Invoke();
Refresh();
}
public void SetContainer(Rectangle newRect)
{
if (newRect.Equals(Container))
return;
Container = newRect;
ContainerChanged?.Invoke();
Refresh();
}
public Point GetRelativeMousePoint(double clientX, double clientY)
{
if (Container == null)
throw new Exception(
"Container not available. Make sure you're not using this method before the diagram is fully loaded");
return new Point((clientX - Container.Left - Pan.X) / Zoom, (clientY - Container.Top - Pan.Y) / Zoom);
}
public Point GetRelativePoint(double clientX, double clientY)
{
if (Container == null)
throw new Exception(
"Container not available. Make sure you're not using this method before the diagram is fully loaded");
return new Point(clientX - Container.Left, clientY - Container.Top);
}
public Point GetScreenPoint(double clientX, double clientY)
{
if (Container == null)
throw new Exception(
"Container not available. Make sure you're not using this method before the diagram is fully loaded");
return new Point(Zoom * clientX + Container.Left + Pan.X, Zoom * clientY + Container.Top + Pan.Y);
}
#region Ordering
public void SendToBack(SelectableModel model)
{
var minOrder = GetMinOrder();
if (model.Order == minOrder)
return;
if (!_orderedSelectables.Remove(model))
return;
_orderedSelectables.Insert(0, model);
// Todo: can optimize this by only updating the order of items before model
Batch(() =>
{
SuspendSorting = true;
for (var i = 0; i < _orderedSelectables.Count; i++)
{
_orderedSelectables[i].Order = i + 1;
}
SuspendSorting = false;
});
}
public void SendToFront(SelectableModel model)
{
var maxOrder = GetMaxOrder();
if (model.Order == maxOrder)
return;
if (!_orderedSelectables.Remove(model))
return;
_orderedSelectables.Add(model);
SuspendSorting = true;
model.Order = maxOrder + 1;
SuspendSorting = false;
Refresh();
}
public int GetMinOrder()
{
return _orderedSelectables.Count > 0 ? _orderedSelectables[0].Order : 0;
}
public int GetMaxOrder()
{
return _orderedSelectables.Count > 0 ? _orderedSelectables[^1].Order : 0;
}
/// <summary>
/// Sorts the list of selectables based on their order
/// </summary>
public void RefreshOrders(bool refresh = true)
{
_orderedSelectables.Sort((a, b) => a.Order.CompareTo(b.Order));
if (refresh)
{
Refresh();
}
}
private void OnSelectableAdded(SelectableModel model)
{
var maxOrder = GetMaxOrder();
_orderedSelectables.Add(model);
if (model.Order == 0)
{
model.Order = maxOrder + 1;
}
model.OrderChanged += OnModelOrderChanged;
}
private void OnSelectableRemoved(SelectableModel model)
{
model.OrderChanged -= OnModelOrderChanged;
_orderedSelectables.Remove(model);
}
private void OnModelOrderChanged(Model model)
{
if (SuspendSorting)
return;
RefreshOrders();
}
#endregion
#region Events
public void TriggerPointerDown(Model? model, PointerEventArgs e) => PointerDown?.Invoke(model, e);
public void TriggerPointerMove(Model? model, PointerEventArgs e) => PointerMove?.Invoke(model, e);
public void TriggerPointerUp(Model? model, PointerEventArgs e) => PointerUp?.Invoke(model, e);
public void TriggerPointerEnter(Model? model, PointerEventArgs e) => PointerEnter?.Invoke(model, e);
public void TriggerPointerLeave(Model? model, PointerEventArgs e) => PointerLeave?.Invoke(model, e);
public void TriggerKeyDown(KeyboardEventArgs e) => KeyDown?.Invoke(e);
public void TriggerWheel(WheelEventArgs e) => Wheel?.Invoke(e);
public void TriggerPointerClick(Model? model, PointerEventArgs e) => PointerClick?.Invoke(model, e);
public void TriggerPointerDoubleClick(Model? model, PointerEventArgs e) => PointerDoubleClick?.Invoke(model, e);
#endregion
}

View File

@@ -0,0 +1,16 @@
namespace ThingsGateway.Blazor.Diagrams.Core;
public class DiagramsException : Exception
{
public DiagramsException(string? message) : base(message)
{
}
public DiagramsException() : base()
{
}
public DiagramsException(string? message, Exception? innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,3 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Events;
public record KeyboardEventArgs(string Key, string Code, float Location, bool CtrlKey, bool ShiftKey, bool AltKey);

View File

@@ -0,0 +1,3 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Events;
public record MouseEventArgs(double ClientX, double ClientY, long Button, long Buttons, bool CtrlKey, bool ShiftKey, bool AltKey);

View File

@@ -0,0 +1,5 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Events;
public record PointerEventArgs(double ClientX, double ClientY, long Button, long Buttons, bool CtrlKey, bool ShiftKey,
bool AltKey, long PointerId, float Width, float Height, float Pressure, float TiltX, float TiltY,
string PointerType, bool IsPrimary) : MouseEventArgs(ClientX, ClientY, Button, Buttons, CtrlKey, ShiftKey, AltKey);

View File

@@ -0,0 +1,4 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Events;
public record TouchEventArgs(TouchPoint[] ChangedTouches, bool CtrlKey, bool ShiftKey, bool AltKey);
public record TouchPoint(long Identifier, double ClientX, double ClientY);

View File

@@ -0,0 +1,14 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Events;
public record WheelEventArgs(
double ClientX,
double ClientY,
long Button,
long Buttons,
bool CtrlKey,
bool ShiftKey,
bool AltKey,
double DeltaX,
double DeltaY,
double DeltaZ,
long DeltaMode) : MouseEventArgs(ClientX, ClientY, Button, Buttons, CtrlKey, ShiftKey, AltKey);

View File

@@ -0,0 +1,46 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.Models;
namespace ThingsGateway.Blazor.Diagrams.Core.Extensions;
public static class DiagramExtensions
{
public static Rectangle GetBounds(this IEnumerable<NodeModel> nodes)
{
if (!nodes.Any())
return Rectangle.Zero;
var minX = double.MaxValue;
var maxX = double.MinValue;
var minY = double.MaxValue;
var maxY = double.MinValue;
foreach (var node in nodes)
{
if (node.Size == null) // Ignore nodes that didn't get a size yet
continue;
var trX = node.Position.X + node.Size!.Width;
var bY = node.Position.Y + node.Size.Height;
if (node.Position.X < minX)
{
minX = node.Position.X;
}
if (trX > maxX)
{
maxX = trX;
}
if (node.Position.Y < minY)
{
minY = node.Position.Y;
}
if (bY > maxY)
{
maxY = bY;
}
}
return new Rectangle(minX, minY, maxX, maxY);
}
}

View File

@@ -0,0 +1,7 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Extensions;
public static class DoubleExtensions
{
public static bool AlmostEqualTo(this double double1, double double2, double tolerance = 0.0001)
=> Math.Abs(double1 - double2) < tolerance;
}

View File

@@ -0,0 +1,8 @@
using System.Globalization;
namespace ThingsGateway.Blazor.Diagrams.Core.Extensions;
public static class NumberExtensions
{
public static string ToInvariantString(this double n) => n.ToString(CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,91 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Geometry;
public static class BezierSpline
{
/// <summary>
/// Get open-ended Bezier Spline Control Points.
/// </summary>
/// <param name="knots">Input Knot Bezier spline points.</param>
/// <param name="firstControlPoints">Output First Control points array of knots.Length - 1 length.</param>
/// <param name="secondControlPoints">Output Second Control points array of knots.Length - 1 length.</param>
/// <exception cref="ArgumentNullException"><paramref name="knots"/> parameter must be not null.</exception>
/// <exception cref="ArgumentException"><paramref name="knots"/> array must containg at least two points.</exception>
public static void GetCurveControlPoints(Point[] knots, out Point[] firstControlPoints, out Point[] secondControlPoints)
{
if (knots == null)
throw new ArgumentNullException(nameof(knots));
int n = knots.Length - 1;
if (n < 1)
throw new ArgumentException("At least two knot points required", nameof(knots));
if (n == 1)
{ // Special case: Bezier curve should be a straight line.
firstControlPoints = new Point[1];
// 3P1 = 2P0 + P3
firstControlPoints[0] = new Point((2 * knots[0].X + knots[1].X) / 3, (2 * knots[0].Y + knots[1].Y) / 3);
secondControlPoints = new Point[1];
// P2 = 2P1 P0
secondControlPoints[0] = new Point(2 * firstControlPoints[0].X - knots[0].X, 2 * firstControlPoints[0].Y - knots[0].Y);
return;
}
// Calculate first Bezier control points
// Right hand side vector
double[] rhs = new double[n];
// Set right hand side X values
for (int i = 1; i < n - 1; ++i)
rhs[i] = 4 * knots[i].X + 2 * knots[i + 1].X;
rhs[0] = knots[0].X + 2 * knots[1].X;
rhs[n - 1] = (8 * knots[n - 1].X + knots[n].X) / 2.0;
// Get first control points X-values
double[] x = GetFirstControlPoints(rhs);
// Set right hand side Y values
for (int i = 1; i < n - 1; ++i)
rhs[i] = 4 * knots[i].Y + 2 * knots[i + 1].Y;
rhs[0] = knots[0].Y + 2 * knots[1].Y;
rhs[n - 1] = (8 * knots[n - 1].Y + knots[n].Y) / 2.0;
// Get first control points Y-values
double[] y = GetFirstControlPoints(rhs);
// Fill output arrays.
firstControlPoints = new Point[n];
secondControlPoints = new Point[n];
for (int i = 0; i < n; ++i)
{
// First control point
firstControlPoints[i] = new Point(x[i], y[i]);
// Second control point
if (i < n - 1)
secondControlPoints[i] = new Point(2 * knots[i + 1].X - x[i + 1], 2 * knots[i + 1].Y - y[i + 1]);
else
secondControlPoints[i] = new Point((knots[n].X + x[n - 1]) / 2, (knots[n].Y + y[n - 1]) / 2);
}
}
/// <summary>
/// Solves a tridiagonal system for one of coordinates (x or y) of first Bezier control points.
/// </summary>
/// <param name="rhs">Right hand side vector.</param>
/// <returns>Solution vector.</returns>
private static double[] GetFirstControlPoints(double[] rhs)
{
int n = rhs.Length;
double[] x = new double[n]; // Solution vector.
double[] tmp = new double[n]; // Temp workspace.
double b = 2.0;
x[0] = rhs[0] / b;
for (int i = 1; i < n; i++) // Decomposition and forward substitution.
{
tmp[i] = 1 / b;
b = (i < n - 1 ? 4.0 : 3.5) - tmp[i];
x[i] = (rhs[i] - x[i - 1]) / b;
}
for (int i = 1; i < n; i++)
x[n - i - 1] -= tmp[n - i] * x[n - i]; // Backsubstitution.
return x;
}
}

View File

@@ -0,0 +1,64 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Geometry;
public class Ellipse : IShape
{
public Ellipse(double cx, double cy, double rx, double ry)
{
Cx = cx;
Cy = cy;
Rx = rx;
Ry = ry;
}
public double Cx { get; }
public double Cy { get; }
public double Rx { get; }
public double Ry { get; }
public IEnumerable<Point> GetIntersectionsWithLine(Line line)
{
var a1 = line.Start;
var a2 = line.End;
var dir = new Point(line.End.X - line.Start.X, line.End.Y - line.Start.Y);
var diff = a1.Subtract(Cx, Cy);
var mDir = new Point(dir.X / (Rx * Rx), dir.Y / (Ry * Ry));
var mDiff = new Point(diff.X / (Rx * Rx), diff.Y / (Ry * Ry));
var a = dir.Dot(mDir);
var b = dir.Dot(mDiff);
var c = diff.Dot(mDiff) - 1.0;
var d = b * b - a * c;
if (d > 0)
{
var root = Math.Sqrt(d);
var ta = (-b - root) / a;
var tb = (-b + root) / a;
if (ta >= 0 && 1 >= ta || tb >= 0 && 1 >= tb)
{
if (0 <= ta && ta <= 1)
yield return a1.Lerp(a2, ta);
if (0 <= tb && tb <= 1)
yield return a1.Lerp(a2, tb);
}
}
else
{
var t = -b / a;
if (0 <= t && t <= 1)
{
yield return a1.Lerp(a2, t);
}
}
}
public Point? GetPointAtAngle(double a)
{
var t = Math.Tan(a / 360 * Math.PI);
var px = Rx * (1 - Math.Pow(t, 2)) / (1 + Math.Pow(t, 2));
var py = Ry * 2 * t / (1 + Math.Pow(t, 2));
return new Point(Cx + px, Cy + py);
}
}

View File

@@ -0,0 +1,7 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Geometry;
public interface IShape
{
public IEnumerable<Point> GetIntersectionsWithLine(Line line);
public Point? GetPointAtAngle(double a);
}

View File

@@ -0,0 +1,42 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Geometry;
public class Line
{
public Line(Point start, Point end)
{
Start = start;
End = end;
}
public Point Start { get; }
public Point End { get; }
public Point? GetIntersection(Line line)
{
var pt1Dir = new Point(End.X - Start.X, End.Y - Start.Y);
var pt2Dir = new Point(line.End.X - line.Start.X, line.End.Y - line.Start.Y);
var det = (pt1Dir.X * pt2Dir.Y) - (pt1Dir.Y * pt2Dir.X);
var deltaPt = new Point(line.Start.X - Start.X, line.Start.Y - Start.Y);
var alpha = (deltaPt.X * pt2Dir.Y) - (deltaPt.Y * pt2Dir.X);
var beta = (deltaPt.X * pt1Dir.Y) - (deltaPt.Y * pt1Dir.X);
if (det == 0 || alpha * det < 0 || beta * det < 0)
return null;
if (det > 0)
{
if (alpha > det || beta > det)
return null;
}
else
{
if (alpha < det || beta < det)
return null;
}
return new Point(Start.X + (alpha * pt1Dir.X / det), Start.Y + (alpha * pt1Dir.Y / det));
}
public override string ToString() => $"Line from {Start} to {End}";
}

View File

@@ -0,0 +1,60 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Geometry;
public record Point
{
public static Point Zero { get; } = new(0, 0);
public Point(double x, double y)
{
X = x;
Y = y;
}
public double X { get; init; }
public double Y { get; init; }
public double Length => Math.Sqrt(Dot(this));
public double Dot(Point other) => X * other.X + Y * other.Y;
public Point Lerp(Point other, double t)
=> new(X * (1.0 - t) + other.X * t, Y * (1.0 - t) + other.Y * t);
public Point Add(double value) => new(X + value, Y + value);
public Point Add(double x, double y) => new(X + x, Y + y);
public Point Subtract(double value) => new(X - value, Y - value);
public Point Subtract(double x, double y) => new(X - x, Y - y);
public Point Subtract(Point other) => new(X - other.X, Y - other.Y);
public Point Divide(Point other) => new(X / other.X, Y / other.Y);
public Point Multiply(double value) => new(X * value, Y * value);
public Point Normalize()
{
var length = Length;
return new Point(X / length, Y / length);
}
public double DistanceTo(Point other) => Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
public double DistanceTo(double x, double y) => Math.Sqrt(Math.Pow(X - x, 2) + Math.Pow(Y - y, 2));
public Point MoveAlongLine(Point from, double dist)
{
var x = X - from.X;
var y = Y - from.Y;
var angle = Math.Atan2(y, x);
var xOffset = Math.Cos(angle) * dist;
var yOffset = Math.Sin(angle) * dist;
return new Point(X + xOffset, Y + yOffset);
}
public static Point operator -(Point a, Point b) => new(a.X - b.X, a.Y - b.Y);
public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
public void Deconstruct(out double x, out double y)
{
x = X;
y = Y;
}
}

View File

@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace ThingsGateway.Blazor.Diagrams.Core.Geometry;
public class Rectangle : IShape
{
public static Rectangle Zero { get; } = new(0, 0, 0, 0);
public double Width { get; }
public double Height { get; }
public double Top { get; }
public double Right { get; }
public double Bottom { get; }
public double Left { get; }
[JsonConstructor]
public Rectangle(double left, double top, double right, double bottom)
{
Left = left;
Top = top;
Right = right;
Bottom = bottom;
Width = Math.Abs(Left - Right);
Height = Math.Abs(Top - Bottom);
}
public Rectangle(Point position, Size size)
{
ArgumentNullException.ThrowIfNull(position, nameof(position));
ArgumentNullException.ThrowIfNull(size, nameof(size));
Left = position.X;
Top = position.Y;
Right = Left + size.Width;
Bottom = Top + size.Height;
Width = size.Width;
Height = size.Height;
}
public bool Overlap(Rectangle r)
=> Left < r.Right && Right > r.Left && Top < r.Bottom && Bottom > r.Top;
public bool Intersects(Rectangle r)
{
var thisX = Left;
var thisY = Top;
var thisW = Width;
var thisH = Height;
var rectX = r.Left;
var rectY = r.Top;
var rectW = r.Width;
var rectH = r.Height;
return rectX < thisX + thisW && thisX < rectX + rectW && rectY < thisY + thisH && thisY < rectY + rectH;
}
public Rectangle Inflate(double horizontal, double vertical)
=> new(Left - horizontal, Top - vertical, Right + horizontal, Bottom + vertical);
public Rectangle Union(Rectangle r)
{
var x1 = Math.Min(Left, r.Left);
var x2 = Math.Max(Left + Width, r.Left + r.Width);
var y1 = Math.Min(Top, r.Top);
var y2 = Math.Max(Top + Height, r.Top + r.Height);
return new(x1, y1, x2, y2);
}
public bool ContainsPoint(Point point) => ContainsPoint(point.X, point.Y);
public bool ContainsPoint(double x, double y)
=> x >= Left && x <= Right && y >= Top && y <= Bottom;
public IEnumerable<Point> GetIntersectionsWithLine(Line line)
{
var borders = new[] {
new Line(NorthWest, NorthEast),
new Line(NorthEast, SouthEast),
new Line(SouthWest, SouthEast),
new Line(NorthWest, SouthWest)
};
for (var i = 0; i < borders.Length; i++)
{
var intersectionPt = borders[i].GetIntersection(line);
if (intersectionPt != null)
yield return intersectionPt;
}
}
public Point? GetPointAtAngle(double a)
{
var vx = Math.Cos(a * Math.PI / 180);
var vy = Math.Sin(a * Math.PI / 180);
var px = Left + Width / 2;
var py = Top + Height / 2;
double? t1 = (Left - px) / vx; // left
double? t2 = (Right - px) / vx; // right
double? t3 = (Top - py) / vy; // top
double? t4 = (Bottom - py) / vy; // bottom
var t = (new[] { t1, t2, t3, t4 }).Where(n => n.HasValue && double.IsFinite(n.Value) && n.Value > 0).DefaultIfEmpty(null).Min();
if (t == null) return null;
var x = px + t.Value * vx;
var y = py + t.Value * vy;
return new Point(x, y);
}
public Point Center => new(Left + Width / 2, Top + Height / 2);
public Point NorthEast => new(Right, Top);
public Point SouthEast => new(Right, Bottom);
public Point SouthWest => new(Left, Bottom);
public Point NorthWest => new(Left, Top);
public Point East => new(Right, Top + Height / 2);
public Point North => new(Left + Width / 2, Top);
public Point South => new(Left + Width / 2, Bottom);
public Point West => new(Left, Top + Height / 2);
public bool Equals(Rectangle? other)
{
return other != null && Left == other.Left && Right == other.Right && Top == other.Top &&
Bottom == other.Bottom && Width == other.Width && Height == other.Height;
}
public override string ToString()
=> $"Rectangle(width={Width}, height={Height}, top={Top}, right={Right}, bottom={Bottom}, left={Left})";
}

View File

@@ -0,0 +1,37 @@
using ThingsGateway.Blazor.Diagrams.Core.Models;
namespace ThingsGateway.Blazor.Diagrams.Core.Geometry;
public static class Shapes
{
public static IShape Rectangle(NodeModel node) => Rectangle(node.Position, node.Size!);
public static IShape Circle(NodeModel node) => Circle(node.Position, node.Size!);
public static IShape Ellipse(NodeModel node) => Ellipse(node.Position, node.Size!);
public static IShape Rectangle(PortModel port) => Rectangle(port.Position, port.Size!);
public static IShape Circle(PortModel port) => Circle(port.Position, port.Size!);
public static IShape Ellipse(PortModel port) => Ellipse(port.Position, port.Size!);
private static Rectangle Rectangle(Point position, Size size) => new Rectangle(position, size);
private static Ellipse Circle(Point position, Size size)
{
var halfWidth = size.Width / 2;
var centerX = position.X + halfWidth;
var centerY = position.Y + size.Height / 2;
return new Ellipse(centerX, centerY, halfWidth, halfWidth);
}
private static Ellipse Ellipse(Point position, Size size)
{
var halfWidth = size.Width / 2;
var halfHeight = size.Height / 2;
var centerX = position.X + halfWidth;
var centerY = position.Y + halfHeight;
return new Ellipse(centerX, centerY, halfWidth, halfHeight);
}
}

View File

@@ -0,0 +1,15 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Geometry;
public record Size
{
public static Size Zero { get; } = new(0, 0);
public Size(double width, double height)
{
Width = width;
Height = height;
}
public double Width { get; init; }
public double Height { get; init; }
}

View File

@@ -0,0 +1,111 @@
using System.Collections;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core;
public abstract class BaseLayer<T> : IReadOnlyList<T> where T : Model
{
private readonly List<T> _items = new List<T>();
public event Action<T>? Added;
public event Action<T>? Removed;
public BaseLayer(Diagram diagram)
{
Diagram = diagram;
}
public virtual TSpecific Add<TSpecific>(TSpecific item) where TSpecific : T
{
if (item is null)
throw new ArgumentNullException(nameof(item));
Diagram.Batch(() =>
{
_items.Add(item);
OnItemAdded(item);
Added?.Invoke(item);
});
return item;
}
public virtual void Add(IEnumerable<T> items)
{
if (items is null)
throw new ArgumentNullException(nameof(items));
Diagram.Batch(() =>
{
foreach (var item in items)
{
_items.Add(item);
OnItemAdded(item);
Added?.Invoke(item);
}
});
}
public virtual void Remove(T item)
{
if (item is null)
throw new ArgumentNullException(nameof(item));
if (_items.Remove(item))
{
Diagram.Batch(() =>
{
OnItemRemoved(item);
Removed?.Invoke(item);
});
}
}
public virtual void Remove(IEnumerable<T> items)
{
if (items is null)
throw new ArgumentNullException(nameof(items));
Diagram.Batch(() =>
{
foreach (var item in items)
{
if (_items.Remove(item))
{
OnItemRemoved(item);
Removed?.Invoke(item);
}
}
});
}
public bool Contains(T item) => _items.Contains(item);
public void Clear()
{
if (Count == 0)
return;
Diagram.Batch(() =>
{
for (var i = _items.Count - 1; i >= 0; i--)
{
var item = _items[i];
_items.RemoveAt(i);
OnItemRemoved(item);
Removed?.Invoke(item);
}
});
}
protected virtual void OnItemAdded(T item) { }
protected virtual void OnItemRemoved(T item) { }
public Diagram Diagram { get; }
public int Count => _items.Count;
public T this[int index] => _items[index];
public IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator();
}

View File

@@ -0,0 +1,48 @@
using ThingsGateway.Blazor.Diagrams.Core.Models;
namespace ThingsGateway.Blazor.Diagrams.Core.Layers;
public class GroupLayer : BaseLayer<GroupModel>
{
public GroupLayer(Diagram diagram) : base(diagram)
{
}
public GroupModel Group(params NodeModel[] children)
{
return Add(Diagram.Options.Groups.Factory(Diagram, children));
}
/// <summary>
/// Removes the group AND its children
/// </summary>
public void Delete(GroupModel group)
{
Diagram.Batch(() =>
{
var children = group.Children.ToArray();
Remove(group);
foreach (var child in children)
{
if (child is GroupModel g)
{
Delete(g);
}
else
{
Diagram.Nodes.Remove(child);
}
}
});
}
protected override void OnItemRemoved(GroupModel group)
{
Diagram.Links.Remove(group.PortLinks.ToArray());
Diagram.Links.Remove(group.Links.ToArray());
group.Ungroup();
group.Group?.RemoveChild(group);
}
}

View File

@@ -0,0 +1,63 @@
using ThingsGateway.Blazor.Diagrams.Core.Anchors;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Core.Layers;
public class LinkLayer : BaseLayer<BaseLinkModel>
{
public LinkLayer(Diagram diagram) : base(diagram) { }
protected override void OnItemAdded(BaseLinkModel link)
{
link.Diagram = Diagram;
HandleAnchor(link, link.Source, true);
HandleAnchor(link, link.Target, true);
link.Refresh();
link.SourceChanged += OnLinkSourceChanged;
link.TargetChanged += OnLinkTargetChanged;
}
protected override void OnItemRemoved(BaseLinkModel link)
{
link.Diagram = null;
HandleAnchor(link, link.Source, false);
HandleAnchor(link, link.Target, false);
link.Refresh();
link.SourceChanged -= OnLinkSourceChanged;
link.TargetChanged -= OnLinkTargetChanged;
Diagram.Controls.RemoveFor(link);
Remove(link.Links.ToList());
}
private static void OnLinkSourceChanged(BaseLinkModel link, Anchor old, Anchor @new)
{
HandleAnchor(link, old, add: false);
HandleAnchor(link, @new, add: true);
}
private static void OnLinkTargetChanged(BaseLinkModel link, Anchor old, Anchor @new)
{
HandleAnchor(link, old, add: false);
HandleAnchor(link, @new, add: true);
}
private static void HandleAnchor(BaseLinkModel link, Anchor anchor, bool add)
{
if (add)
{
anchor.Model?.AddLink(link);
}
else
{
anchor.Model?.RemoveLink(link);
}
if (anchor.Model is Model model)
{
model.Refresh();
}
}
}

View File

@@ -0,0 +1,16 @@
using ThingsGateway.Blazor.Diagrams.Core.Models;
namespace ThingsGateway.Blazor.Diagrams.Core.Layers;
public class NodeLayer : BaseLayer<NodeModel>
{
public NodeLayer(Diagram diagram) : base(diagram) { }
protected override void OnItemRemoved(NodeModel node)
{
Diagram.Links.Remove(node.PortLinks.ToList());
Diagram.Links.Remove(node.Links.ToList());
node.Group?.RemoveChild(node);
Diagram.Controls.RemoveFor(node);
}
}

View File

@@ -0,0 +1,63 @@
using ThingsGateway.Blazor.Diagrams.Core;
using ThingsGateway.Blazor.Diagrams.Core.Anchors;
using ThingsGateway.Blazor.Diagrams.Core.Models.Base;
namespace ThingsGateway.Blazor.Diagrams.Algorithms;
public static class LinksReconnectionAlgorithms
{
public static void ReconnectLinksToClosestPorts(this Diagram diagram)
{
// Only refresh ports once
var modelsToRefresh = new HashSet<Model>();
foreach (var link in diagram.Links.ToArray())
{
if (link.Source is not SinglePortAnchor spa1 || link.Target is not SinglePortAnchor spa2)
continue;
var sourcePorts = spa1.Port.Parent.Ports;
var targetPorts = spa2.Port.Parent.Ports;
// Find the ports with minimal distance
var minDistance = double.MaxValue;
var minSourcePort = spa1.Port;
var minTargetPort = spa2.Port;
foreach (var sourcePort in sourcePorts)
{
foreach (var targetPort in targetPorts)
{
var distance = sourcePort.Position.DistanceTo(targetPort.Position);
if (distance < minDistance)
{
minDistance = distance;
minSourcePort = sourcePort;
minTargetPort = targetPort;
}
}
}
// Reconnect
if (spa1.Port != minSourcePort)
{
modelsToRefresh.Add(spa1.Port);
modelsToRefresh.Add(minSourcePort);
link.SetSource(new SinglePortAnchor(minSourcePort));
modelsToRefresh.Add(link);
}
if (spa2.Port != minTargetPort)
{
modelsToRefresh.Add(spa2.Port);
modelsToRefresh.Add(minTargetPort);
link.SetTarget(new SinglePortAnchor(minTargetPort));
modelsToRefresh.Add(link);
}
}
foreach (var model in modelsToRefresh)
{
model.Refresh();
}
}
}

View File

@@ -0,0 +1,144 @@
using ThingsGateway.Blazor.Diagrams.Core.Anchors;
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
using ThingsGateway.Blazor.Diagrams.Core.PathGenerators;
using ThingsGateway.Blazor.Diagrams.Core.Routers;
namespace ThingsGateway.Blazor.Diagrams.Core.Models.Base;
public abstract class BaseLinkModel : SelectableModel, IHasBounds, ILinkable
{
private readonly List<BaseLinkModel> _links = new();
public event Action<BaseLinkModel, Anchor, Anchor>? SourceChanged;
public event Action<BaseLinkModel, Anchor, Anchor>? TargetChanged;
public event Action<BaseLinkModel>? TargetAttached;
protected BaseLinkModel(Anchor source, Anchor target)
{
Source = source;
Target = target;
}
protected BaseLinkModel(string id, Anchor source, Anchor target) : base(id)
{
Source = source;
Target = target;
}
public Anchor Source { get; private set; }
public Anchor Target { get; private set; }
public Diagram? Diagram { get; internal set; }
public Point[]? Route { get; private set; }
public PathGeneratorResult? PathGeneratorResult { get; private set; }
public bool IsAttached => Source is not PositionAnchor && Target is not PositionAnchor;
public Router? Router { get; set; }
public PathGenerator? PathGenerator { get; set; }
public LinkMarker? SourceMarker { get; set; }
public LinkMarker? TargetMarker { get; set; }
public bool Segmentable { get; set; } = false;
public List<LinkVertexModel> Vertices { get; } = new();
public List<LinkLabelModel> Labels { get; } = new();
public IReadOnlyList<BaseLinkModel> Links => _links;
public override void Refresh()
{
GeneratePath();
base.Refresh();
}
public void RefreshLinks()
{
foreach (var link in Links)
{
link.Refresh();
}
}
public LinkLabelModel AddLabel(string content, double? distance = null, Point? offset = null)
{
var label = new LinkLabelModel(this, content, distance, offset);
Labels.Add(label);
return label;
}
public LinkVertexModel AddVertex(Point? position = null)
{
var vertex = new LinkVertexModel(this, position);
Vertices.Add(vertex);
return vertex;
}
public void SetSource(Anchor anchor)
{
ArgumentNullException.ThrowIfNull(anchor, nameof(anchor));
if (Source == anchor)
return;
var old = Source;
Source = anchor;
SourceChanged?.Invoke(this, old, Source);
}
public void SetTarget(Anchor anchor)
{
if (Target == anchor)
return;
var old = Target;
Target = anchor;
TargetChanged?.Invoke(this, old, Target);
}
public Rectangle? GetBounds()
{
if (PathGeneratorResult == null)
return null;
var minX = double.PositiveInfinity;
var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity;
var maxY = double.NegativeInfinity;
var path = PathGeneratorResult.FullPath;
var bbox = path.GetBBox();
minX = Math.Min(minX, bbox.Left);
minY = Math.Min(minY, bbox.Top);
maxX = Math.Max(maxX, bbox.Right);
maxY = Math.Max(maxY, bbox.Bottom);
return new Rectangle(minX, minY, maxX, maxY);
}
public bool CanAttachTo(ILinkable other) => true;
/// <summary>
/// Triggers the TargetAttached event
/// </summary>
public void TriggerTargetAttached() => TargetAttached?.Invoke(this);
private void GeneratePath()
{
if (Diagram != null)
{
var router = Router ?? Diagram.Options.Links.DefaultRouter;
var pathGenerator = PathGenerator ?? Diagram.Options.Links.DefaultPathGenerator;
var route = router.GetRoute(Diagram, this);
var source = Source.GetPosition(this, route);
var target = Target.GetPosition(this, route);
if (source != null && target != null)
{
Route = route;
PathGeneratorResult = pathGenerator.GetResult(Diagram, this, route, source, target);
return;
}
}
Route = null;
PathGeneratorResult = null;
}
void ILinkable.AddLink(BaseLinkModel link) => _links.Add(link);
void ILinkable.RemoveLink(BaseLinkModel link) => _links.Remove(link);
}

View File

@@ -0,0 +1,8 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
namespace ThingsGateway.Blazor.Diagrams.Core.Models.Base;
public interface IHasBounds
{
public Rectangle? GetBounds();
}

View File

@@ -0,0 +1,8 @@
using ThingsGateway.Blazor.Diagrams.Core.Geometry;
namespace ThingsGateway.Blazor.Diagrams.Core.Models.Base;
public interface IHasShape
{
public IShape GetShape();
}

View File

@@ -0,0 +1,12 @@
namespace ThingsGateway.Blazor.Diagrams.Core.Models.Base;
public interface ILinkable
{
public IReadOnlyList<BaseLinkModel> Links { get; }
public bool CanAttachTo(ILinkable other);
internal void AddLink(BaseLinkModel link);
internal void RemoveLink(BaseLinkModel link);
}

Some files were not shown because too many files have changed in this diff Show More