├── .github └── workflows │ └── main.yml ├── .gitignore ├── Blazor.GraphEditor.sln ├── LICENSE ├── README.md ├── benchmarks ├── Benchmarks.cs ├── Program.cs └── benchmarks.csproj ├── docs ├── demo.gif └── icon.png ├── samples └── KristofferStrube.Blazor.GraphEditor.WasmExample │ ├── App.razor │ ├── KristofferStrube.Blazor.GraphEditor.WasmExample.csproj │ ├── Pages │ ├── BigRandom.razor │ ├── Christmas.razor │ ├── FollowingGraph.razor │ ├── Index.razor │ ├── LiveData.razor │ └── SelectCallback.razor │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Shared │ ├── MainLayout.razor │ ├── MainLayout.razor.css │ ├── NavMenu.razor │ └── NavMenu.razor.css │ ├── _Imports.razor │ └── wwwroot │ ├── 404.html │ ├── css │ ├── app.css │ ├── bootstrap │ │ ├── bootstrap.min.css │ │ └── bootstrap.min.css.map │ └── open-iconic │ │ ├── FONT-LICENSE │ │ ├── ICON-LICENSE │ │ ├── README.md │ │ └── font │ │ ├── css │ │ └── open-iconic-bootstrap.min.css │ │ └── fonts │ │ ├── open-iconic.eot │ │ ├── open-iconic.otf │ │ ├── open-iconic.svg │ │ ├── open-iconic.ttf │ │ └── open-iconic.woff │ ├── favicon.png │ ├── icon-192.png │ └── index.html └── src └── KristofferStrube.Blazor.GraphEditor ├── Edge.cs ├── EdgeEditor.razor ├── Extensions ├── DoubleExtensions.cs └── StringExtensions.cs ├── GraphEditor.razor ├── GraphEditor.razor.cs ├── GraphEditorCallbackContext.cs ├── KristofferStrube.Blazor.GraphEditor.csproj ├── Node.cs └── NodeEditor.razor /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish application' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/README.md' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Checkout the code 15 | - uses: actions/checkout@v2 16 | 17 | # Install .NET 8.0 SDK 18 | - name: Setup .NET 8 19 | uses: actions/setup-dotnet@v1 20 | with: 21 | dotnet-version: '8.0.x' 22 | include-prerelease: true 23 | 24 | # Added Ahead-Of-Time workload 25 | - name: Add AOT Workload 26 | run: dotnet workload install wasm-tools 27 | 28 | # Generate the website 29 | - name: Publish 30 | run: dotnet publish samples/KristofferStrube.Blazor.GraphEditor.WasmExample/KristofferStrube.Blazor.GraphEditor.WasmExample.csproj --configuration Release --output build 31 | 32 | # Publish the website 33 | - name: GitHub Pages action 34 | if: ${{ github.ref == 'refs/heads/main' }} # Publish only when the push is on main 35 | uses: peaceiris/actions-gh-pages@v3 36 | with: 37 | github_token: ${{ secrets.PUBLISH_TOKEN }} 38 | publish_branch: gh-pages 39 | publish_dir: build/wwwroot 40 | allow_empty_commit: false 41 | keep_files: false 42 | force_orphan: true 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # Tye 66 | .tye/ 67 | 68 | # ASP.NET Scaffolding 69 | ScaffoldingReadMe.txt 70 | 71 | # StyleCop 72 | StyleCopReport.xml 73 | 74 | # Files built by Visual Studio 75 | *_i.c 76 | *_p.c 77 | *_h.h 78 | *.ilk 79 | *.meta 80 | *.obj 81 | *.iobj 82 | *.pch 83 | *.pdb 84 | *.ipdb 85 | *.pgc 86 | *.pgd 87 | *.rsp 88 | *.sbr 89 | *.tlb 90 | *.tli 91 | *.tlh 92 | *.tmp 93 | *.tmp_proj 94 | *_wpftmp.csproj 95 | *.log 96 | *.tlog 97 | *.vspscc 98 | *.vssscc 99 | .builds 100 | *.pidb 101 | *.svclog 102 | *.scc 103 | 104 | # Chutzpah Test files 105 | _Chutzpah* 106 | 107 | # Visual C++ cache files 108 | ipch/ 109 | *.aps 110 | *.ncb 111 | *.opendb 112 | *.opensdf 113 | *.sdf 114 | *.cachefile 115 | *.VC.db 116 | *.VC.VC.opendb 117 | 118 | # Visual Studio profiler 119 | *.psess 120 | *.vsp 121 | *.vspx 122 | *.sap 123 | 124 | # Visual Studio Trace Files 125 | *.e2e 126 | 127 | # TFS 2012 Local Workspace 128 | $tf/ 129 | 130 | # Guidance Automation Toolkit 131 | *.gpState 132 | 133 | # ReSharper is a .NET coding add-in 134 | _ReSharper*/ 135 | *.[Rr]e[Ss]harper 136 | *.DotSettings.user 137 | 138 | # TeamCity is a build add-in 139 | _TeamCity* 140 | 141 | # DotCover is a Code Coverage Tool 142 | *.dotCover 143 | 144 | # AxoCover is a Code Coverage Tool 145 | .axoCover/* 146 | !.axoCover/settings.json 147 | 148 | # Coverlet is a free, cross platform Code Coverage Tool 149 | coverage*.json 150 | coverage*.xml 151 | coverage*.info 152 | 153 | # Visual Studio code coverage results 154 | *.coverage 155 | *.coveragexml 156 | 157 | # NCrunch 158 | _NCrunch_* 159 | .*crunch*.local.xml 160 | nCrunchTemp_* 161 | 162 | # MightyMoose 163 | *.mm.* 164 | AutoTest.Net/ 165 | 166 | # Web workbench (sass) 167 | .sass-cache/ 168 | 169 | # Installshield output folder 170 | [Ee]xpress/ 171 | 172 | # DocProject is a documentation generator add-in 173 | DocProject/buildhelp/ 174 | DocProject/Help/*.HxT 175 | DocProject/Help/*.HxC 176 | DocProject/Help/*.hhc 177 | DocProject/Help/*.hhk 178 | DocProject/Help/*.hhp 179 | DocProject/Help/Html2 180 | DocProject/Help/html 181 | 182 | # Click-Once directory 183 | publish/ 184 | 185 | # Publish Web Output 186 | *.[Pp]ublish.xml 187 | *.azurePubxml 188 | # Note: Comment the next line if you want to checkin your web deploy settings, 189 | # but database connection strings (with potential passwords) will be unencrypted 190 | *.pubxml 191 | *.publishproj 192 | 193 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 194 | # checkin your Azure Web App publish settings, but sensitive information contained 195 | # in these scripts will be unencrypted 196 | PublishScripts/ 197 | 198 | # NuGet Packages 199 | *.nupkg 200 | # NuGet Symbol Packages 201 | *.snupkg 202 | # The packages folder can be ignored because of Package Restore 203 | **/[Pp]ackages/* 204 | # except build/, which is used as an MSBuild target. 205 | !**/[Pp]ackages/build/ 206 | # Uncomment if necessary however generally it will be regenerated when needed 207 | #!**/[Pp]ackages/repositories.config 208 | # NuGet v3's project.json files produces more ignorable files 209 | *.nuget.props 210 | *.nuget.targets 211 | 212 | # Microsoft Azure Build Output 213 | csx/ 214 | *.build.csdef 215 | 216 | # Microsoft Azure Emulator 217 | ecf/ 218 | rcf/ 219 | 220 | # Windows Store app package directories and files 221 | AppPackages/ 222 | BundleArtifacts/ 223 | Package.StoreAssociation.xml 224 | _pkginfo.txt 225 | *.appx 226 | *.appxbundle 227 | *.appxupload 228 | 229 | # Visual Studio cache files 230 | # files ending in .cache can be ignored 231 | *.[Cc]ache 232 | # but keep track of directories ending in .cache 233 | !?*.[Cc]ache/ 234 | 235 | # Others 236 | ClientBin/ 237 | ~$* 238 | *~ 239 | *.dbmdl 240 | *.dbproj.schemaview 241 | *.jfm 242 | *.pfx 243 | *.publishsettings 244 | orleans.codegen.cs 245 | 246 | # Including strong name files can present a security risk 247 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 248 | #*.snk 249 | 250 | # Since there are multiple workflows, uncomment next line to ignore bower_components 251 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 252 | #bower_components/ 253 | 254 | # RIA/Silverlight projects 255 | Generated_Code/ 256 | 257 | # Backup & report files from converting an old project file 258 | # to a newer Visual Studio version. Backup files are not needed, 259 | # because we have git ;-) 260 | _UpgradeReport_Files/ 261 | Backup*/ 262 | UpgradeLog*.XML 263 | UpgradeLog*.htm 264 | ServiceFabricBackup/ 265 | *.rptproj.bak 266 | 267 | # SQL Server files 268 | *.mdf 269 | *.ldf 270 | *.ndf 271 | 272 | # Business Intelligence projects 273 | *.rdl.data 274 | *.bim.layout 275 | *.bim_*.settings 276 | *.rptproj.rsuser 277 | *- [Bb]ackup.rdl 278 | *- [Bb]ackup ([0-9]).rdl 279 | *- [Bb]ackup ([0-9][0-9]).rdl 280 | 281 | # Microsoft Fakes 282 | FakesAssemblies/ 283 | 284 | # GhostDoc plugin setting file 285 | *.GhostDoc.xml 286 | 287 | # Node.js Tools for Visual Studio 288 | .ntvs_analysis.dat 289 | node_modules/ 290 | 291 | # Visual Studio 6 build log 292 | *.plg 293 | 294 | # Visual Studio 6 workspace options file 295 | *.opt 296 | 297 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 298 | *.vbw 299 | 300 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 301 | *.vbp 302 | 303 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 304 | *.dsw 305 | *.dsp 306 | 307 | # Visual Studio 6 technical files 308 | *.ncb 309 | *.aps 310 | 311 | # Visual Studio LightSwitch build output 312 | **/*.HTMLClient/GeneratedArtifacts 313 | **/*.DesktopClient/GeneratedArtifacts 314 | **/*.DesktopClient/ModelManifest.xml 315 | **/*.Server/GeneratedArtifacts 316 | **/*.Server/ModelManifest.xml 317 | _Pvt_Extensions 318 | 319 | # Paket dependency manager 320 | .paket/paket.exe 321 | paket-files/ 322 | 323 | # FAKE - F# Make 324 | .fake/ 325 | 326 | # CodeRush personal settings 327 | .cr/personal 328 | 329 | # Python Tools for Visual Studio (PTVS) 330 | __pycache__/ 331 | *.pyc 332 | 333 | # Cake - Uncomment if you are using it 334 | # tools/** 335 | # !tools/packages.config 336 | 337 | # Tabs Studio 338 | *.tss 339 | 340 | # Telerik's JustMock configuration file 341 | *.jmconfig 342 | 343 | # BizTalk build output 344 | *.btp.cs 345 | *.btm.cs 346 | *.odx.cs 347 | *.xsd.cs 348 | 349 | # OpenCover UI analysis results 350 | OpenCover/ 351 | 352 | # Azure Stream Analytics local run output 353 | ASALocalRun/ 354 | 355 | # MSBuild Binary and Structured Log 356 | *.binlog 357 | 358 | # NVidia Nsight GPU debugger configuration file 359 | *.nvuser 360 | 361 | # MFractors (Xamarin productivity tool) working folder 362 | .mfractor/ 363 | 364 | # Local History for Visual Studio 365 | .localhistory/ 366 | 367 | # Visual Studio History (VSHistory) files 368 | .vshistory/ 369 | 370 | # BeatPulse healthcheck temp database 371 | healthchecksdb 372 | 373 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 374 | MigrationBackup/ 375 | 376 | # Ionide (cross platform F# VS Code tools) working folder 377 | .ionide/ 378 | 379 | # Fody - auto-generated XML schema 380 | FodyWeavers.xsd 381 | 382 | # VS Code files for those working on multiple tools 383 | .vscode/* 384 | !.vscode/settings.json 385 | !.vscode/tasks.json 386 | !.vscode/launch.json 387 | !.vscode/extensions.json 388 | *.code-workspace 389 | 390 | # Local History for Visual Studio Code 391 | .history/ 392 | 393 | # Windows Installer files from build outputs 394 | *.cab 395 | *.msi 396 | *.msix 397 | *.msm 398 | *.msp 399 | 400 | # JetBrains Rider 401 | *.sln.iml 402 | 403 | ## 404 | ## Visual studio for Mac 405 | ## 406 | 407 | 408 | # globs 409 | Makefile.in 410 | *.userprefs 411 | *.usertasks 412 | config.make 413 | config.status 414 | aclocal.m4 415 | install-sh 416 | autom4te.cache/ 417 | *.tar.gz 418 | tarballs/ 419 | test-results/ 420 | 421 | # Mac bundle stuff 422 | *.dmg 423 | *.app 424 | 425 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 426 | # General 427 | .DS_Store 428 | .AppleDouble 429 | .LSOverride 430 | 431 | # Icon must end with two \r 432 | Icon 433 | 434 | 435 | # Thumbnails 436 | ._* 437 | 438 | # Files that might appear in the root of a volume 439 | .DocumentRevisions-V100 440 | .fseventsd 441 | .Spotlight-V100 442 | .TemporaryItems 443 | .Trashes 444 | .VolumeIcon.icns 445 | .com.apple.timemachine.donotpresent 446 | 447 | # Directories potentially created on remote AFP share 448 | .AppleDB 449 | .AppleDesktop 450 | Network Trash Folder 451 | Temporary Items 452 | .apdisk 453 | 454 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 455 | # Windows thumbnail cache files 456 | Thumbs.db 457 | ehthumbs.db 458 | ehthumbs_vista.db 459 | 460 | # Dump file 461 | *.stackdump 462 | 463 | # Folder config file 464 | [Dd]esktop.ini 465 | 466 | # Recycle Bin used on file shares 467 | $RECYCLE.BIN/ 468 | 469 | # Windows Installer files 470 | *.cab 471 | *.msi 472 | *.msix 473 | *.msm 474 | *.msp 475 | 476 | # Windows shortcuts 477 | *.lnk 478 | -------------------------------------------------------------------------------- /Blazor.GraphEditor.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KristofferStrube.Blazor.GraphEditor", "src\KristofferStrube.Blazor.GraphEditor\KristofferStrube.Blazor.GraphEditor.csproj", "{FE7F39BD-0864-40A7-8472-7AA788233ED9}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KristofferStrube.Blazor.GraphEditor.WasmExample", "samples\KristofferStrube.Blazor.GraphEditor.WasmExample\KristofferStrube.Blazor.GraphEditor.WasmExample.csproj", "{2825B392-30D9-40D3-9427-6483E5C56EF6}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "benchmarks", "benchmarks\benchmarks.csproj", "{1306D248-9B11-48C3-AFB8-19B9B32978D8}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {FE7F39BD-0864-40A7-8472-7AA788233ED9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {FE7F39BD-0864-40A7-8472-7AA788233ED9}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {FE7F39BD-0864-40A7-8472-7AA788233ED9}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {FE7F39BD-0864-40A7-8472-7AA788233ED9}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {2825B392-30D9-40D3-9427-6483E5C56EF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {2825B392-30D9-40D3-9427-6483E5C56EF6}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {2825B392-30D9-40D3-9427-6483E5C56EF6}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {2825B392-30D9-40D3-9427-6483E5C56EF6}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {1306D248-9B11-48C3-AFB8-19B9B32978D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {1306D248-9B11-48C3-AFB8-19B9B32978D8}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {1306D248-9B11-48C3-AFB8-19B9B32978D8}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {1306D248-9B11-48C3-AFB8-19B9B32978D8}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {0B07C01C-7211-4CF9-8C9A-277D144852F6} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kristoffer Strube 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blazor.GraphEditor 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](/LICENSE) 3 | [![GitHub issues](https://img.shields.io/github/issues/KristofferStrube/Blazor.GraphEditor)](https://github.com/KristofferStrube/Blazor.GraphEditor/issues) 4 | [![GitHub forks](https://img.shields.io/github/forks/KristofferStrube/Blazor.GraphEditor)](https://github.com/KristofferStrube/Blazor.GraphEditor/network/members) 5 | [![GitHub stars](https://img.shields.io/github/stars/KristofferStrube/Blazor.GraphEditor)](https://github.com/KristofferStrube/Blazor.GraphEditor/stargazers) 6 | [![NuGet Downloads (official NuGet)](https://img.shields.io/nuget/dt/KristofferStrube.Blazor.GraphEditor?label=NuGet%20Downloads)](https://www.nuget.org/packages/KristofferStrube.Blazor.GraphEditor/) 7 | 8 | A simple graph editor for Blazor. 9 | 10 | ![A video showing off the demo site](./docs/demo.gif?raw=true) 11 | 12 | # Demo 13 | The WASM sample project can be demoed at [https://kristofferstrube.github.io/Blazor.GraphEditor/](https://kristofferstrube.github.io/Blazor.GraphEditor/) 14 | 15 | # Getting Started 16 | ## Prerequisites 17 | You need to install .NET 7.0 or newer to use the library. 18 | 19 | [Download .NET 7](https://dotnet.microsoft.com/download/dotnet/7.0) 20 | 21 | ## Installation 22 | You can install the package via NuGet with the Package Manager in your IDE or alternatively using the command line: 23 | ```bash 24 | dotnet add package KristofferStrube.Blazor.GraphEditor 25 | ``` 26 | The package can be used in Blazor WebAssembly and Blazor Server projects. In the samples folder of this repository, you can find a project that shows how to use the `GraphEditor` component in both Blazor WASM. 27 | 28 | ## Import 29 | You need to reference the package to use it in your pages. This can be done in `_Import.razor` by adding the following. 30 | ```razor 31 | @using KristofferStrube.Blazor.GraphEditor 32 | ``` 33 | 34 | ## Add to service collection 35 | To use the component in your pages you also need to register some services in your service collection. We have a single method that is exposed via the **Blazor.** library which adds all these services. 36 | 37 | ```csharp 38 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 39 | builder.RootComponents.Add("#app"); 40 | builder.RootComponents.Add("head::after"); 41 | 42 | builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); 43 | 44 | // Adding the needed services. 45 | builder.Services.Add(); 46 | 47 | await builder.Build().RunAsync(); 48 | ``` 49 | 50 | ## Include needed stylesheets and scripts 51 | The libraries that the component uses also need to have some stylesheets and scripts added to function. 52 | For this, you need to insert the following tags in the `` section of your `index.html` or `Host.cshtml` file: 53 | ```html 54 | 55 | 56 | 57 | ``` 58 | The library uses Scoped CSS, so you must include your project-specific `.styles.css` CSS file in your project for the scoped styles of the library components. An example is in the test project in this repo: 59 | ```html 60 | 61 | ``` 62 | 63 | At the end of the file, after you have referenced the Blazor Server or Wasm bootstrapper, insert the following: 64 | 65 | ```html 66 | 67 | ``` 68 | 69 | ## Adding the component to a site. 70 | Now, you are ready to use the component in your page. A minimal example of this would be the following: 71 | 72 | ```razor 73 |
74 | 87 |
88 | @code { 89 | private GraphEditor GraphEditor = default!; 90 | private bool running = true; 91 | 92 | protected override async Task OnAfterRenderAsync(bool firstRender) 93 | { 94 | if (!firstRender) return; 95 | while (!GraphEditor.IsReadyToLoad) 96 | { 97 | await Task.Delay(50); 98 | } 99 | 100 | var pages = new List() { new("1", size: 60), new("2", "#3333AA"), new("3", "#AA33AA"), new("4", "#AA3333"), new("5", "#AAAA33"), new("6", "#33AAAA"), new("7"), new("8") }; 101 | 102 | var edges = new List() { 103 | new(pages[0], pages[1], 1), 104 | new(pages[0], pages[2], 1), 105 | new(pages[0], pages[3], 1), 106 | new(pages[0], pages[4], 1), 107 | new(pages[0], pages[5], 1), 108 | new(pages[0], pages[6], 1), 109 | new(pages[6], pages[7], 2, 150) }; 110 | 111 | await GraphEditor.LoadGraph(pages, edges); 112 | 113 | DateTimeOffset startTime = DateTimeOffset.UtcNow; 114 | while (running) 115 | { 116 | await GraphEditor.ForceDirectedLayout(); 117 | /// First 5 seconds also fit the viewport to all the nodes and edges in the graph. 118 | if (startTime - DateTimeOffset.UtcNow < TimeSpan.FromSeconds(5)) 119 | GraphEditor..FitViewportToAllShapes(delta: 0.8); 120 | await Task.Delay(1); 121 | } 122 | } 123 | 124 | public record Page(string id, string color = "#66BB6A", float size = 50); 125 | public record Transition(Page from, Page to, float weight, float length = 200); 126 | 127 | public void Dispose() 128 | { 129 | running = false; 130 | } 131 | } 132 | ``` 133 | -------------------------------------------------------------------------------- /benchmarks/Benchmarks.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.InteropServices; 5 | using BenchmarkDotNet.Attributes; 6 | 7 | namespace benchmarks; 8 | 9 | public class Benchmarks 10 | { 11 | private Node[] nodeElements = Enumerable.Range(0, 1000) 12 | .Select(i => new Node(i.ToString(), Random.Shared.NextDouble() * 10, Random.Shared.NextDouble() * 10, Random.Shared.NextDouble() * 10)) 13 | .ToArray(); 14 | 15 | public Benchmarks() 16 | { 17 | for (int i = 0; i < nodeElements.Length; i++) 18 | { 19 | nodeElements[i % nodeElements.Length].NeighborNodes.Add(nodeElements[(i + 1) % nodeElements.Length], 1); 20 | } 21 | } 22 | 23 | [Benchmark] 24 | public void Standard() 25 | { 26 | foreach (var node1 in nodeElements) 27 | { 28 | double mx = 0; 29 | double my = 0; 30 | foreach (var node2 in nodeElements) 31 | { 32 | if (node1 == node2) 33 | { 34 | continue; 35 | } 36 | 37 | double dx = node1.Cx - node2.Cx; 38 | double dy = node1.Cy - node2.Cy; 39 | double d = Math.Sqrt(dx * dx + dy * dy); 40 | double force; 41 | if (node1.NeighborNodes.TryGetValue(node2, out var edge)) 42 | { 43 | force = edge * Math.Log(d / edge); 44 | } 45 | else 46 | { 47 | force = -(node1.Repulsion + node2.Repulsion) / 2 / (d * d); 48 | } 49 | 50 | mx -= dx * 0.1 * force; 51 | my -= dy * 0.1 * force; 52 | } 53 | 54 | node1.Cx += mx; 55 | node1.Cy += my; 56 | } 57 | } 58 | 59 | [Benchmark] 60 | public void WithSpan() 61 | { 62 | Span nodes = nodeElements.AsSpan(); 63 | foreach (var node1 in nodes) 64 | { 65 | double mx = 0; 66 | double my = 0; 67 | foreach (var node2 in nodes) 68 | { 69 | if (node1 == node2) 70 | { 71 | continue; 72 | } 73 | 74 | double dx = node1.Cx - node2.Cx; 75 | double dy = node1.Cy - node2.Cy; 76 | double d = Math.Sqrt(dx * dx + dy * dy); 77 | double force; 78 | if (node1.NeighborNodes.TryGetValue(node2, out var edge)) 79 | { 80 | force = edge * Math.Log(d / edge); 81 | } 82 | else 83 | { 84 | force = -(node1.Repulsion + node2.Repulsion) / 2 / (d * d); 85 | } 86 | 87 | mx -= dx * 0.1 * force; 88 | my -= dy * 0.1 * force; 89 | } 90 | 91 | node1.Cx += mx; 92 | node1.Cy += my; 93 | } 94 | } 95 | 96 | [Benchmark] 97 | public void WithSpanAndNeigborsHandledSeparately() 98 | { 99 | Span nodes = nodeElements.AsSpan(); 100 | foreach (var node1 in nodes) 101 | { 102 | double mx = 0; 103 | double my = 0; 104 | foreach (var node2 in nodes) 105 | { 106 | if (node1 == node2) 107 | { 108 | continue; 109 | } 110 | 111 | double dx = node1.Cx - node2.Cx; 112 | double dy = node1.Cy - node2.Cy; 113 | double d = Math.Sqrt(dx * dx + dy * dy); 114 | 115 | double force = -(node1.Repulsion + node2.Repulsion) / 2 / (d * d); 116 | 117 | mx -= dx * 0.1 * force; 118 | my -= dy * 0.1 * force; 119 | } 120 | 121 | node1.Cx += mx; 122 | node1.Cy += my; 123 | } 124 | foreach(var node1 in nodes) 125 | { 126 | double mx = 0; 127 | double my = 0; 128 | foreach (var (node2, edge) in node1.NeighborNodes) 129 | { 130 | if (node1 == node2) 131 | { 132 | continue; 133 | } 134 | 135 | double dx = node1.Cx - node2.Cx; 136 | double dy = node1.Cy - node2.Cy; 137 | double d = Math.Sqrt(dx * dx + dy * dy); 138 | double force = edge * Math.Log(d / edge); 139 | 140 | mx -= dx * 0.1 * force; 141 | my -= dy * 0.1 * force; 142 | } 143 | 144 | node1.Cx += mx; 145 | node1.Cy += my; 146 | } 147 | } 148 | 149 | [Benchmark] 150 | public void Experiment() 151 | { 152 | Span nodes = nodeElements.AsSpan(); 153 | 154 | var sortedAccordingToX = nodeElements.OrderBy(n => n.Cx); 155 | var allLeft = sortedAccordingToX.Take(nodes.Length / 2).ToArray(); 156 | var allRigth = sortedAccordingToX.Skip(nodes.Length / 2).ToArray(); 157 | 158 | double allLeftSumX = 0; 159 | double allLeftSumY = 0; 160 | double allLeftSumRepulsion = 0; 161 | 162 | foreach (var node1 in allLeft) 163 | { 164 | allLeftSumX += node1.Cx; 165 | allLeftSumY += node1.Cy; 166 | allLeftSumRepulsion += node1.Repulsion; 167 | 168 | double mx = 0; 169 | double my = 0; 170 | foreach (var node2 in allLeft) 171 | { 172 | if (node1 == node2) 173 | { 174 | continue; 175 | } 176 | 177 | double dx = node1.Cx - node2.Cx; 178 | double dy = node1.Cy - node2.Cy; 179 | double d = Math.Sqrt(dx * dx + dy * dy); 180 | double force = -(node1.Repulsion + node2.Repulsion) / 2 / (d * d); 181 | 182 | mx -= dx * 0.1 * force; 183 | my -= dy * 0.1 * force; 184 | } 185 | 186 | node1.Cx += mx; 187 | node1.Cy += my; 188 | } 189 | 190 | double allRightSumX = 0; 191 | double allRightSumY = 0; 192 | double allRightSumRepulsion = 0; 193 | 194 | foreach (var node1 in allRigth) 195 | { 196 | allRightSumX += node1.Cx; 197 | allRightSumY += node1.Cy; 198 | allRightSumRepulsion += node1.Repulsion; 199 | 200 | double mx = 0; 201 | double my = 0; 202 | foreach (var node2 in allRigth) 203 | { 204 | if (node1 == node2) 205 | { 206 | continue; 207 | } 208 | 209 | double dx = node1.Cx - node2.Cx; 210 | double dy = node1.Cy - node2.Cy; 211 | double d = Math.Sqrt(dx * dx + dy * dy); 212 | double force = -(node1.Repulsion + node2.Repulsion) / 2 / (d * d); 213 | 214 | mx -= dx * 0.1 * force; 215 | my -= dy * 0.1 * force; 216 | } 217 | 218 | node1.Cx += mx; 219 | node1.Cy += my; 220 | } 221 | 222 | foreach(var node in allRigth) 223 | { 224 | double dx = node.Cx - allLeftSumX / (nodes.Length / 2); 225 | double dy = node.Cy - allLeftSumY / (nodes.Length / 2); 226 | double d = Math.Sqrt(dx * dx + dy * dy); 227 | double force = -(node.Repulsion + allLeftSumRepulsion / (nodes.Length / 2)) / 2 / (d * d); 228 | 229 | node.Cx -= dx * 0.1 * force; 230 | node.Cy -= dy * 0.1 * force; 231 | } 232 | 233 | foreach (var node in allLeft) 234 | { 235 | double dx = node.Cx - allRightSumX / (nodes.Length / 2); 236 | double dy = node.Cy - allRightSumY / (nodes.Length / 2); 237 | double d = Math.Sqrt(dx * dx + dy * dy); 238 | double force = -(node.Repulsion + allRightSumRepulsion / (nodes.Length / 2)) / 2 / (d * d); 239 | 240 | node.Cx -= dx * 0.1 * force; 241 | node.Cy -= dy * 0.1 * force; 242 | } 243 | 244 | foreach (var node1 in nodes) 245 | { 246 | double mx = 0; 247 | double my = 0; 248 | foreach (var (node2, edge) in node1.NeighborNodes) 249 | { 250 | if (node1 == node2) 251 | { 252 | continue; 253 | } 254 | 255 | double dx = node1.Cx - node2.Cx; 256 | double dy = node1.Cy - node2.Cy; 257 | double d = Math.Sqrt(dx * dx + dy * dy); 258 | double force = edge * Math.Log(d / edge); 259 | 260 | mx -= dx * 0.1 * force; 261 | my -= dy * 0.1 * force; 262 | } 263 | 264 | node1.Cx += mx; 265 | node1.Cy += my; 266 | } 267 | } 268 | 269 | private class Node(string id, double cx, double cy, double repulsion) 270 | { 271 | public double Cx { get; set; } = cx; 272 | public double Cy { get; set; } = cy; 273 | public double Repulsion => repulsion; 274 | public Dictionary NeighborNodes { get; set; } = []; 275 | }; 276 | } 277 | -------------------------------------------------------------------------------- /benchmarks/Program.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Configs; 2 | using BenchmarkDotNet.Running; 3 | using benchmarks; 4 | 5 | var config = DefaultConfig.Instance; 6 | var summary = BenchmarkRunner.Run(config, args); -------------------------------------------------------------------------------- /benchmarks/benchmarks.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0 4 | Exe 5 | 6 | 7 | AnyCPU 8 | pdbonly 9 | true 10 | true 11 | true 12 | Release 13 | false 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KristofferStrube/Blazor.GraphEditor/6bff778e84a17b00dd1494806d3664fc7bacf62e/docs/demo.gif -------------------------------------------------------------------------------- /docs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KristofferStrube/Blazor.GraphEditor/6bff778e84a17b00dd1494806d3664fc7bacf62e/docs/icon.png -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/App.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | Not found 8 | 9 |

Sorry, there's nothing at this address.

10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/KristofferStrube.Blazor.GraphEditor.WasmExample.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | true 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Pages/BigRandom.razor: -------------------------------------------------------------------------------- 1 | @page "/BigRandom" 2 | @implements IDisposable 3 | @using KristofferStrube.Blazor.GraphEditor 4 | @using System.Text; 5 | 6 | Blazor.GraphEditor - Big Random 7 | 8 |

Big Random Graph

9 | 10 |
11 | 23 |
24 | 25 | @code { 26 | private GraphEditor.GraphEditor GraphEditor = default!; 27 | private bool running = true; 28 | 29 | protected override async Task OnAfterRenderAsync(bool firstRender) 30 | { 31 | if (!firstRender) return; 32 | while (!GraphEditor.IsReadyToLoad) 33 | { 34 | await Task.Delay(50); 35 | } 36 | 37 | List pages = Enumerable 38 | .Range(0, 45) 39 | .Select(i => new Page(i.ToString(), RandomColor(), 50 + Random.Shared.Next(50), 2000 + Random.Shared.Next(2000))) 40 | .ToList(); 41 | 42 | List transitions = new(45); 43 | transitions.Add(new Transition(pages[0], pages[1], 1 + Random.Shared.NextDouble() * 2, 200 + Random.Shared.Next(300))); 44 | 45 | for (int i = 1; i < 45; i++) 46 | { 47 | transitions.Add(new Transition(pages[i], transitions[Random.Shared.Next(i)].from, 1 + Random.Shared.NextDouble() * 2, 200 + Random.Shared.Next(300))); 48 | } 49 | 50 | for (int i = 0; i < 5; i++) 51 | { 52 | transitions.Add(new Transition(pages[Random.Shared.Next(45)], pages[Random.Shared.Next(45)], 1 + Random.Shared.NextDouble() * 2, 200 + Random.Shared.Next(300))); 53 | } 54 | transitions = transitions.DistinctBy(t => t.from + "-" + t.to).ToList(); 55 | 56 | await GraphEditor.LoadGraph(pages, transitions); 57 | GraphEditor.SVGEditor.FitViewportToAllShapes(delta: 1, 200); 58 | GraphEditor.MoveEdgesToBack(); 59 | 60 | double prevUnixTimeSeconds = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0; 61 | while (running) 62 | { 63 | await GraphEditor.ForceDirectedLayout(); 64 | double unixTimeSeconds = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0; 65 | GraphEditor.SVGEditor.FitViewportToAllShapes(delta: Math.Min((unixTimeSeconds - prevUnixTimeSeconds) * 4, 1)); 66 | prevUnixTimeSeconds = unixTimeSeconds; 67 | await Task.Delay(1); 68 | } 69 | } 70 | 71 | public string RandomColor() 72 | { 73 | var random = Random.Shared.NextDouble() * Math.PI * 2; 74 | 75 | var red = $"{(int)(Math.Sin(random) * 100 + 150):X2}"; 76 | var green = $"{(int)(Math.Sin(random + Math.PI * 2 / 3) * 100 + 150):X2}"; 77 | var blue = $"{(int)(Math.Sin(random + Math.PI * 2 / 3 * 2) * 100 + 150):X2}"; 78 | return $"#{red}{green}{blue}"; 79 | } 80 | 81 | public record Page(string id, string color = "#66BB6A", double size = 50, double repulsion = 800); 82 | public record Transition(Page from, Page to, double weight, double length = 200); 83 | 84 | public void Dispose() 85 | { 86 | running = false; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Pages/Christmas.razor: -------------------------------------------------------------------------------- 1 | @page "/Christmas" 2 | @implements IDisposable 3 | @using KristofferStrube.Blazor.GraphEditor 4 | 5 | Blazor.GraphEditor - Christmas 6 | 7 |

Christmas

8 |
9 | "#FF4444") 14 | NodeRadiusMapper="_ => 20" 15 | EdgeFromMapper="e => e.From" 16 | EdgeToMapper="e => e.To" 17 | EdgeWidthMapper="e => e.Width" 18 | EdgeColorMapper="e => e.Color" 19 | EdgeSpringLengthMapper="e => e.Length" 20 | EdgeShowsArrow="_ => false" /> 21 |
22 | 23 | @code { 24 | private List points = new(); 25 | private List edges = new(); 26 | 27 | private GraphEditor.GraphEditor GraphEditor = default!; 28 | private bool running = true; 29 | private bool refitToScreen = true; 30 | 31 | protected override async Task OnAfterRenderAsync(bool firstRender) 32 | { 33 | if (!firstRender) return; 34 | while (!GraphEditor.IsReadyToLoad) 35 | { 36 | await Task.Delay(50); 37 | } 38 | 39 | await GraphEditor.LoadGraph(points, edges); 40 | 41 | await Task.WhenAll( 42 | Layout(), 43 | AddEdges() 44 | ); 45 | } 46 | 47 | private async Task Layout() 48 | { 49 | while (running) 50 | { 51 | await GraphEditor.ForceDirectedLayout(); 52 | if (edges.Count > 0 && refitToScreen) 53 | GraphEditor.SVGEditor.FitViewportToAllShapes(delta: 0.1); 54 | await Task.Delay(10); 55 | } 56 | } 57 | 58 | private async Task AddEdges() 59 | { 60 | List pointsToAdd = Enumerable.Range(0, 12) 61 | .Select(i => new Point(i.ToString())) 62 | .ToList(); 63 | 64 | List edgesToAdd = [ 65 | .. Enumerable.Range(0, 12).Select(i => new Edge(pointsToAdd[i], pointsToAdd[(i + 1) % 12], Width: 5, Color: "#FF2424")), 66 | new (pointsToAdd[0], pointsToAdd[6], 250), 67 | new (pointsToAdd[0], pointsToAdd[4], 200), 68 | new (pointsToAdd[0], pointsToAdd[8], 200), 69 | new (pointsToAdd[0], pointsToAdd[3], 200), 70 | new (pointsToAdd[0], pointsToAdd[9], 200), 71 | new (pointsToAdd[0], pointsToAdd[5], 200), 72 | new (pointsToAdd[0], pointsToAdd[7], 200), 73 | new (pointsToAdd[0], pointsToAdd[2], 170), 74 | new (pointsToAdd[0], pointsToAdd[10], 170), 75 | ]; 76 | 77 | refitToScreen = true; 78 | for (int i = 0; i < edgesToAdd.Count && running; i++) 79 | { 80 | Edge edge = edgesToAdd[i]; 81 | 82 | if (!points.Contains(edge.From)) 83 | points.Add(edge.From); 84 | if (!points.Contains(edge.To)) 85 | points.Add(edge.To); 86 | 87 | edges.Add(edgesToAdd[i]); 88 | 89 | await GraphEditor.UpdateGraph(points, edges); 90 | await Task.Delay(1000); 91 | } 92 | refitToScreen = false; 93 | } 94 | 95 | public record Point(string Id); 96 | 97 | public record Edge(Point From, Point To, float Length = 100, float Width = 1, string Color = "#000000"); 98 | 99 | public void Dispose() 100 | { 101 | running = false; 102 | } 103 | } -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Pages/FollowingGraph.razor: -------------------------------------------------------------------------------- 1 | @page "/FollowingGraph/" 2 | @implements IDisposable 3 | @inject HttpClient httpClient 4 | @using KristofferStrube.ActivityStreams 5 | @using KristofferStrube.Blazor.GraphEditor 6 | 7 | Blazor.GraphEditor - Following Graph 8 | 9 |

Following Graph

10 | 11 |

12 | The following shows a following graph from Mastodon for the user @@kristofferstrube@@hachyderm.io
13 | 14 | @if (selectedUser is null) 15 | { 16 | Select a node to show what user that is. 17 | } 18 | else 19 | { 20 | 21 | Selected user: @selectedUser.Id 22 |   23 | 24 |   (We only show 50 followings for each user) 25 | 26 | } 27 |

28 | 29 | 30 |
31 | 45 |
46 | 47 | @code { 48 | private GraphEditor.GraphEditor GraphEditor = default!; 49 | private bool running = true; 50 | private string? error; 51 | private User? selectedUser; 52 | List edges = []; 53 | List users = []; 54 | 55 | protected override async Task OnAfterRenderAsync(bool firstRender) 56 | { 57 | if (!firstRender) return; 58 | while (!GraphEditor.IsReadyToLoad) 59 | { 60 | await Task.Delay(50); 61 | } 62 | 63 | string primaryUserId = "@kristofferstrube@hachyderm.io"; 64 | 65 | User primaryUser = new(primaryUserId, "#70f070", size: 50); 66 | await GetImageForUser(primaryUser); 67 | users.Add(primaryUser); 68 | 69 | await AddMoreUsers(primaryUser, 30); 70 | 71 | await GraphEditor.LoadGraph(users, edges); 72 | 73 | double prevUnixTimeSeconds = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0; 74 | double startUnixTimeSeconds = prevUnixTimeSeconds; 75 | while (running) 76 | { 77 | await GraphEditor.ForceDirectedLayout(); 78 | double unixTimeSeconds = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0; 79 | if (unixTimeSeconds - startUnixTimeSeconds < 2) 80 | { 81 | GraphEditor.SVGEditor.FitViewportToAllShapes(delta: Math.Min((unixTimeSeconds - prevUnixTimeSeconds) * 2, 1), padding: 100); 82 | prevUnixTimeSeconds = unixTimeSeconds; 83 | } 84 | await Task.Delay(1); 85 | } 86 | } 87 | 88 | public async Task Expand() 89 | { 90 | await AddMoreUsers(selectedUser!, 20); 91 | await GraphEditor.UpdateGraph(users, edges); 92 | GraphEditor.MoveEdgesToBack(); 93 | } 94 | 95 | private async Task AddMoreUsers(User fromUser, int size) 96 | { 97 | selectedUser = null; 98 | StateHasChanged(); 99 | 100 | var response = await httpClient.GetAsync($"https://kristoffer-strube.dk/API/mastodon/Following/{fromUser.Id}"); 101 | 102 | if (!response.IsSuccessStatusCode) 103 | { 104 | error = await response.Content.ReadAsStringAsync(); 105 | return; 106 | } 107 | 108 | var followingUrls = await response.Content.ReadFromJsonAsync(); 109 | var following = followingUrls! 110 | .Select(url => url.Length > 12 && url[8..].Split("/users/") is { Length: 2 } parts ? parts : null) 111 | .Where(parts => parts is not null) 112 | .Select(parts => new User($"@{parts[1]}@{parts[0]}", "#7070f0", size: size)) 113 | .OrderByDescending(u => users.Any(existing => existing.Equals(u)) ? 1 : 0) 114 | .Take(50) 115 | .ToList(); 116 | var allNewUsers = following.Where(f => !users.Any(u => u.Equals(f))).ToList(); 117 | 118 | users = [.. users, .. allNewUsers]; 119 | 120 | // Increase distance to this user to make room for new neighbors. 121 | foreach (var edge in edges.Where(e => e.To.Equals(fromUser) || e.From.Equals(fromUser))) 122 | { 123 | edge.EdgeLength = 500; 124 | } 125 | 126 | foreach (var follow in following) 127 | { 128 | var newEdge = new Follow(fromUser, follow); 129 | if (!edges.Any(e => e.Equals(newEdge))) 130 | { 131 | edges.Add(newEdge); 132 | } 133 | } 134 | 135 | Task.WhenAll(allNewUsers.Select(GetImageForUser)); 136 | } 137 | 138 | public async Task GetImageForUser(User user) 139 | { 140 | var response = await httpClient.GetAsync($"https://kristoffer-strube.dk/API/mastodon/Profile/{user.Id}"); 141 | 142 | if (!response.IsSuccessStatusCode) 143 | return; 144 | 145 | Person? person = await response.Content.ReadFromJsonAsync(); 146 | 147 | user.Image = person?.Icon?.FirstOrDefault() is Image { } image ? image.Url.FirstOrDefault()?.Href?.ToString() : null; 148 | } 149 | 150 | public class User(string id, string color, float size = 30) : IEquatable 151 | { 152 | public string Id = id; 153 | public string Color = color; 154 | public float Size = size; 155 | 156 | public string? Image { get; set; } 157 | 158 | public override bool Equals(object? obj) => obj is User user && Equals(user); 159 | 160 | public bool Equals(User? other) => other?.Id == Id; 161 | 162 | public override int GetHashCode() => Id.GetHashCode(); 163 | } 164 | public class Follow(User from, User to) : IEquatable 165 | { 166 | public User From => from; 167 | public User To => to; 168 | 169 | public float EdgeLength { get; set; } = 250; 170 | 171 | public bool Equals(Follow? other) => other is not null && other.From.Equals(From) && other.To.Equals(To); 172 | } 173 | 174 | public void Dispose() 175 | { 176 | running = false; 177 | } 178 | } -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Pages/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @implements IDisposable 3 | @using KristofferStrube.Blazor.GraphEditor 4 | 5 | Blazor.GraphEditor 6 | 7 |

Force Directed Layout

8 | 9 |
10 | 23 |
24 | 25 | @code { 26 | private GraphEditor.GraphEditor GraphEditor = default!; 27 | private bool running = true; 28 | 29 | protected override async Task OnAfterRenderAsync(bool firstRender) 30 | { 31 | if (!firstRender) return; 32 | while (!GraphEditor.IsReadyToLoad) 33 | { 34 | await Task.Delay(50); 35 | } 36 | 37 | var pages = new List() { new("1", size: 60), new("2", "#3333AA"), new("3", "#AA33AA"), new("4", "#AA3333"), new("5", "#AAAA33"), new("6", "#33AAAA"), new("7"), new("8") }; 38 | 39 | var edges = new List() { 40 | new(pages[0], pages[1], 1), 41 | new(pages[0], pages[2], 1), 42 | new(pages[0], pages[3], 1), 43 | new(pages[0], pages[4], 1), 44 | new(pages[0], pages[5], 1), 45 | new(pages[0], pages[6], 1), 46 | new(pages[6], pages[7], 2, 150) }; 47 | 48 | await GraphEditor.LoadGraph(pages, edges); 49 | 50 | DateTimeOffset startTime = DateTimeOffset.UtcNow; 51 | while (running) 52 | { 53 | await GraphEditor.ForceDirectedLayout(); 54 | /// First 5 seconds also fit the viewport to all the nodes and edges in the graph. 55 | if (DateTimeOffset.UtcNow - startTime < TimeSpan.FromSeconds(5)) 56 | GraphEditor.SVGEditor.FitViewportToAllShapes(delta: 0.8); 57 | await Task.Delay(1); 58 | } 59 | } 60 | 61 | public record Page(string id, string color = "#66BB6A", float size = 50); 62 | public record Transition(Page from, Page to, float weight, float length = 200); 63 | 64 | public void Dispose() 65 | { 66 | running = false; 67 | } 68 | } -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Pages/LiveData.razor: -------------------------------------------------------------------------------- 1 | @page "/LiveData" 2 | @implements IDisposable 3 | @using KristofferStrube.Blazor.GraphEditor 4 | 5 | Blazor.GraphEditor - Live Data 6 | 7 |

Live Data

8 | 9 |

On this page we can animate some of the details of the nodes in the graph while we run the force directed layout.

10 | 11 |
12 | 13 | 14 | @if (pages.Count > 3) 15 | { 16 | 17 | } 18 |
19 | 20 |
21 | 32 |
33 | 34 | @code { 35 | private List pages = new(); 36 | private List transitions = new(); 37 | 38 | private GraphEditor.GraphEditor GraphEditor = default!; 39 | private bool running = true; 40 | 41 | private double edgeLengthSinAngle = 0; 42 | 43 | protected override async Task OnAfterRenderAsync(bool firstRender) 44 | { 45 | if (!firstRender) return; 46 | while (!GraphEditor.IsReadyToLoad) 47 | { 48 | await Task.Delay(50); 49 | } 50 | 51 | pages = Enumerable.Range(0, 8).Select(i => new Page(i.ToString()) { BaseOffset = i * Math.PI * 2 / 8 }).ToList(); 52 | transitions = Enumerable.Range(0, 8).Select(i => new Transition(pages[i % 8], pages[(i + 1) % 8], 1)).ToList(); 53 | 54 | await GraphEditor.LoadGraph(pages, transitions); 55 | 56 | double prevUnixTimeSeconds = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0; 57 | while (running) 58 | { 59 | double unixTimeSeconds = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0; 60 | edgeLengthSinAngle = unixTimeSeconds % Math.PI * 2; 61 | pages.ForEach(p => 62 | { 63 | p.Radians = p.BaseOffset + unixTimeSeconds % (Math.PI * 2); 64 | }); 65 | await GraphEditor.ForceDirectedLayout(); 66 | GraphEditor.SVGEditor.FitViewportToAllShapes(delta: Math.Min((unixTimeSeconds - prevUnixTimeSeconds) * 2, 1), 50); 67 | prevUnixTimeSeconds = unixTimeSeconds; 68 | StateHasChanged(); 69 | await Task.Delay(1); 70 | } 71 | } 72 | 73 | string ColorMapper(Page page) 74 | { 75 | var red = $"{(int)(Math.Sin(page.Radians) * 100 + 150):X2}"; 76 | var green = $"{(int)(Math.Sin(page.Radians + Math.PI * 2 / 3) * 100 + 150):X2}"; 77 | var blue = $"{(int)(Math.Sin(page.Radians + Math.PI * 2 / 3 * 2) * 100 + 150):X2}"; 78 | return $"#{red}{green}{blue}"; 79 | } 80 | 81 | 82 | public async Task AddNode() 83 | { 84 | pages.Add(new Page(Guid.NewGuid().ToString()) { BaseOffset = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0 }); 85 | double chance = 1; 86 | for (int i = 0; i < 3; i++) 87 | { 88 | if (chance > 0.93) 89 | { 90 | var fromNode = pages[Random.Shared.Next(Math.Max(0, pages.Count - 2))]; 91 | var toNode = pages[^1]; 92 | if (fromNode != toNode) 93 | { 94 | transitions.Add(new Transition(fromNode, toNode, 1, 200)); 95 | } 96 | } 97 | chance = Random.Shared.NextDouble(); 98 | } 99 | await GraphEditor.UpdateGraph(pages, transitions); 100 | } 101 | 102 | public async Task RemoveNode() 103 | { 104 | Page pageToRemove = pages[Random.Shared.Next(pages.Count - 1)]; 105 | pages.Remove(pageToRemove); 106 | transitions = transitions.Where(t => t.from != pageToRemove && t.to != pageToRemove).ToList(); 107 | await GraphEditor.UpdateGraph(pages, transitions); 108 | } 109 | 110 | double EdgeLengthMapper(Transition edge) => edge.length * (1.5 + Math.Sin(edgeLengthSinAngle) / 2); 111 | 112 | public record Page(string id) 113 | { 114 | public double BaseOffset { get; set; } 115 | public double Radians { get; set; } 116 | } 117 | public record Transition(Page from, Page to, float weight, float length = 100); 118 | 119 | public void Dispose() 120 | { 121 | running = false; 122 | } 123 | } -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Pages/SelectCallback.razor: -------------------------------------------------------------------------------- 1 | @page "/SelectCallback" 2 | @implements IDisposable 3 | @using KristofferStrube.Blazor.GraphEditor 4 | 5 | Blazor.GraphEditor - Select Callback 6 | 7 |

Select Callback

8 | 9 | @if (selectPage is null) 10 | { 11 |

On this page you can select a node to get information about it using callbacks.

12 | } 13 | else 14 | { 15 |

You selected the node with id: @selectPage.id which has the following description: @selectPage.description

16 | } 17 | 18 |
19 | 34 |
35 | 36 | @code { 37 | private GraphEditor.GraphEditor GraphEditor = default!; 38 | private bool running = true; 39 | private Page? selectPage; 40 | 41 | protected override async Task OnAfterRenderAsync(bool firstRender) 42 | { 43 | if (!firstRender) return; 44 | while (!GraphEditor.IsReadyToLoad) 45 | { 46 | await Task.Delay(50); 47 | } 48 | 49 | var pages = new List() { new("1", "First nodes description."), new("2", "Second nodes description."), new("3", "Third nodes description."), new("4", "Fourth nodes description.") }; 50 | 51 | var edges = new List() { new(pages[0], pages[1], 1), new(pages[1], pages[2], 1), new(pages[2], pages[3], 1), new(pages[3], pages[0], 1) }; 52 | 53 | await GraphEditor.LoadGraph(pages, edges); 54 | 55 | double prevUnixTimeSeconds = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0; 56 | double startUnixTimeSeconds = prevUnixTimeSeconds; 57 | while (running) 58 | { 59 | await GraphEditor.ForceDirectedLayout(); 60 | double unixTimeSeconds = DateTimeOffset.Now.ToUnixTimeMilliseconds() / 1000.0; 61 | if (unixTimeSeconds - startUnixTimeSeconds < 3) 62 | { 63 | GraphEditor.SVGEditor.FitViewportToAllShapes(delta: Math.Min((unixTimeSeconds - prevUnixTimeSeconds) * 2, 1), padding: 50); 64 | prevUnixTimeSeconds = unixTimeSeconds; 65 | } 66 | await Task.Delay(1); 67 | } 68 | } 69 | 70 | public record Page(string id, string description, string color = "#66BB6A", float size = 50); 71 | public record Transition(Page from, Page to, float weight, float length = 200); 72 | 73 | public void Dispose() 74 | { 75 | running = false; 76 | } 77 | } -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Program.cs: -------------------------------------------------------------------------------- 1 | using KristofferStrube.Blazor.GraphEditor.WasmExample; 2 | using KristofferStrube.Blazor.SVGEditor.Extensions; 3 | using Microsoft.AspNetCore.Components.Web; 4 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 5 | 6 | WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args); 7 | builder.RootComponents.Add("#app"); 8 | builder.RootComponents.Add("head::after"); 9 | 10 | builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); 11 | 12 | builder.Services.AddSVGEditor(); 13 | 14 | await builder.Build().RunAsync(); 15 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:55711", 7 | "sslPort": 44356 8 | } 9 | }, 10 | "profiles": { 11 | "http": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 16 | "applicationUrl": "http://localhost:5248", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "https": { 22 | "commandName": "Project", 23 | "dotnetRunMessages": true, 24 | "launchBrowser": true, 25 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 26 | "applicationUrl": "https://localhost:7297;http://localhost:5248", 27 | "environmentVariables": { 28 | "ASPNETCORE_ENVIRONMENT": "Development" 29 | } 30 | }, 31 | "IIS Express": { 32 | "commandName": "IISExpress", 33 | "launchBrowser": true, 34 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 35 | "environmentVariables": { 36 | "ASPNETCORE_ENVIRONMENT": "Development" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | @inject NavigationManager NavigationManager 3 | 4 |
5 | 8 | 9 |
10 |
11 | 12 | 13 | 14 |
15 | 16 |
17 | @Body 18 |
19 |
20 |
21 | 22 | 23 | @code { 24 | private string relativeUri => NavigationManager.ToBaseRelativePath(NavigationManager.Uri); 25 | 26 | protected string page => (string.IsNullOrEmpty(relativeUri) ? "Index" : relativeUri) + ".razor"; 27 | } -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Shared/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | height: 3.5rem; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .top-row ::deep a, .top-row ::deep .btn-link { 25 | white-space: nowrap; 26 | margin-left: 1.5rem; 27 | text-decoration: none; 28 | } 29 | 30 | .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { 31 | text-decoration: underline; 32 | } 33 | 34 | .top-row ::deep a:first-child { 35 | overflow: hidden; 36 | text-overflow: ellipsis; 37 | } 38 | 39 | @media (max-width: 640.98px) { 40 | .top-row:not(.auth) { 41 | display: none; 42 | } 43 | 44 | .top-row.auth { 45 | justify-content: space-between; 46 | } 47 | 48 | .top-row ::deep a, .top-row ::deep .btn-link { 49 | margin-left: 0; 50 | } 51 | } 52 | 53 | @media (min-width: 641px) { 54 | .page { 55 | flex-direction: row; 56 | } 57 | 58 | .sidebar { 59 | width: 250px; 60 | height: 100vh; 61 | position: sticky; 62 | top: 0; 63 | } 64 | 65 | .top-row { 66 | position: sticky; 67 | top: 0; 68 | z-index: 1; 69 | } 70 | 71 | .top-row.auth ::deep a:first-child { 72 | flex: 1; 73 | text-align: right; 74 | width: 0; 75 | } 76 | 77 | .top-row, article { 78 | padding-left: 2rem !important; 79 | padding-right: 1.5rem !important; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Shared/NavMenu.razor: -------------------------------------------------------------------------------- 1 |  9 | 10 | 47 | 48 | @code { 49 | private bool collapseNavMenu = true; 50 | 51 | private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; 52 | 53 | private void ToggleNavMenu() 54 | { 55 | collapseNavMenu = !collapseNavMenu; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/Shared/NavMenu.razor.css: -------------------------------------------------------------------------------- 1 | .navbar-toggler { 2 | background-color: rgba(255, 255, 255, 0.1); 3 | } 4 | 5 | .top-row { 6 | height: 3.5rem; 7 | background-color: rgba(0,0,0,0.4); 8 | } 9 | 10 | .navbar-brand { 11 | font-size: 1.1rem; 12 | } 13 | 14 | .oi { 15 | width: 2rem; 16 | font-size: 1.1rem; 17 | vertical-align: text-top; 18 | top: -2px; 19 | } 20 | 21 | .bi { 22 | display: inline-block; 23 | position: relative; 24 | width: 1.25rem; 25 | height: 1.25rem; 26 | margin-right: 0.75rem; 27 | top: -1px; 28 | background-size: cover; 29 | } 30 | 31 | .nav-item { 32 | font-size: 0.9rem; 33 | padding-bottom: 0.5rem; 34 | } 35 | 36 | .nav-item:first-of-type { 37 | padding-top: 1rem; 38 | } 39 | 40 | .nav-item:last-of-type { 41 | padding-bottom: 1rem; 42 | } 43 | 44 | .nav-item ::deep a { 45 | color: #d7d7d7; 46 | border-radius: 4px; 47 | height: 3rem; 48 | display: flex; 49 | align-items: center; 50 | line-height: 3rem; 51 | } 52 | 53 | .nav-item ::deep a.active { 54 | background-color: rgba(255,255,255,0.25); 55 | color: white; 56 | } 57 | 58 | .nav-item ::deep a:hover { 59 | background-color: rgba(255,255,255,0.1); 60 | color: white; 61 | } 62 | 63 | @media (min-width: 641px) { 64 | .navbar-toggler { 65 | display: none; 66 | } 67 | 68 | .collapse { 69 | /* Never collapse the sidebar for wide screens */ 70 | display: block; 71 | } 72 | 73 | .nav-scrollable { 74 | /* Allow sidebar to scroll for tall menus */ 75 | height: calc(100vh - 3.5rem); 76 | overflow-y: auto; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Forms 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using Microsoft.AspNetCore.Components.Web.Virtualization 7 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 8 | @using Microsoft.JSInterop 9 | @using KristofferStrube.Blazor.GraphEditor 10 | @using KristofferStrube.Blazor.GraphEditor.WasmExample 11 | @using KristofferStrube.Blazor.GraphEditor.WasmExample.Shared 12 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/404.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Blazor SVG Editor Redirect 6 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/app.css: -------------------------------------------------------------------------------- 1 | @import url('open-iconic/font/css/open-iconic-bootstrap.min.css'); 2 | 3 | html, body { 4 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 5 | } 6 | 7 | h1:focus { 8 | outline: none; 9 | } 10 | 11 | a, .btn-link { 12 | color: #0071c1; 13 | } 14 | 15 | .btn-primary { 16 | color: #fff; 17 | background-color: #1b6ec2; 18 | border-color: #1861ac; 19 | } 20 | 21 | .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { 22 | box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; 23 | } 24 | 25 | .content { 26 | padding-top: 1.1rem; 27 | } 28 | 29 | .valid.modified:not([type=checkbox]) { 30 | outline: 1px solid #26b050; 31 | } 32 | 33 | .invalid { 34 | outline: 1px solid red; 35 | } 36 | 37 | .validation-message { 38 | color: red; 39 | } 40 | 41 | #blazor-error-ui { 42 | background: lightyellow; 43 | bottom: 0; 44 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 45 | display: none; 46 | left: 0; 47 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 48 | position: fixed; 49 | width: 100%; 50 | z-index: 1000; 51 | } 52 | 53 | #blazor-error-ui .dismiss { 54 | cursor: pointer; 55 | position: absolute; 56 | right: 0.75rem; 57 | top: 0.5rem; 58 | } 59 | 60 | .blazor-error-boundary { 61 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 62 | padding: 1rem 1rem 1rem 3.7rem; 63 | color: white; 64 | } 65 | 66 | .blazor-error-boundary::after { 67 | content: "An error has occurred." 68 | } 69 | 70 | .loading-progress { 71 | position: relative; 72 | display: block; 73 | width: 8rem; 74 | height: 8rem; 75 | margin: 20vh auto 1rem auto; 76 | } 77 | 78 | .loading-progress circle { 79 | fill: none; 80 | stroke: #e0e0e0; 81 | stroke-width: 0.6rem; 82 | transform-origin: 50% 50%; 83 | transform: rotate(-90deg); 84 | } 85 | 86 | .loading-progress circle:last-child { 87 | stroke: #1b6ec2; 88 | stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; 89 | transition: stroke-dasharray 0.05s ease-in-out; 90 | } 91 | 92 | .loading-progress-text { 93 | position: absolute; 94 | text-align: center; 95 | font-weight: bold; 96 | inset: calc(20vh + 3.25rem) 0 auto 0.2rem; 97 | } 98 | 99 | .loading-progress-text:after { 100 | content: var(--blazor-load-percentage-text, "Loading"); 101 | } 102 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/FONT-LICENSE: -------------------------------------------------------------------------------- 1 | SIL OPEN FONT LICENSE Version 1.1 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | PREAMBLE 6 | The goals of the Open Font License (OFL) are to stimulate worldwide 7 | development of collaborative font projects, to support the font creation 8 | efforts of academic and linguistic communities, and to provide a free and 9 | open framework in which fonts may be shared and improved in partnership 10 | with others. 11 | 12 | The OFL allows the licensed fonts to be used, studied, modified and 13 | redistributed freely as long as they are not sold by themselves. The 14 | fonts, including any derivative works, can be bundled, embedded, 15 | redistributed and/or sold with any software provided that any reserved 16 | names are not used by derivative works. The fonts and derivatives, 17 | however, cannot be released under any other type of license. The 18 | requirement for fonts to remain under this license does not apply 19 | to any document created using the fonts or their derivatives. 20 | 21 | DEFINITIONS 22 | "Font Software" refers to the set of files released by the Copyright 23 | Holder(s) under this license and clearly marked as such. This may 24 | include source files, build scripts and documentation. 25 | 26 | "Reserved Font Name" refers to any names specified as such after the 27 | copyright statement(s). 28 | 29 | "Original Version" refers to the collection of Font Software components as 30 | distributed by the Copyright Holder(s). 31 | 32 | "Modified Version" refers to any derivative made by adding to, deleting, 33 | or substituting -- in part or in whole -- any of the components of the 34 | Original Version, by changing formats or by porting the Font Software to a 35 | new environment. 36 | 37 | "Author" refers to any designer, engineer, programmer, technical 38 | writer or other person who contributed to the Font Software. 39 | 40 | PERMISSION & CONDITIONS 41 | Permission is hereby granted, free of charge, to any person obtaining 42 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 43 | redistribute, and sell modified and unmodified copies of the Font 44 | Software, subject to the following conditions: 45 | 46 | 1) Neither the Font Software nor any of its individual components, 47 | in Original or Modified Versions, may be sold by itself. 48 | 49 | 2) Original or Modified Versions of the Font Software may be bundled, 50 | redistributed and/or sold with any software, provided that each copy 51 | contains the above copyright notice and this license. These can be 52 | included either as stand-alone text files, human-readable headers or 53 | in the appropriate machine-readable metadata fields within text or 54 | binary files as long as those fields can be easily viewed by the user. 55 | 56 | 3) No Modified Version of the Font Software may use the Reserved Font 57 | Name(s) unless explicit written permission is granted by the corresponding 58 | Copyright Holder. This restriction only applies to the primary font name as 59 | presented to the users. 60 | 61 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 62 | Software shall not be used to promote, endorse or advertise any 63 | Modified Version, except to acknowledge the contribution(s) of the 64 | Copyright Holder(s) and the Author(s) or with their explicit written 65 | permission. 66 | 67 | 5) The Font Software, modified or unmodified, in part or in whole, 68 | must be distributed entirely under this license, and must not be 69 | distributed under any other license. The requirement for fonts to 70 | remain under this license does not apply to any document created 71 | using the Font Software. 72 | 73 | TERMINATION 74 | This license becomes null and void if any of the above conditions are 75 | not met. 76 | 77 | DISCLAIMER 78 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 79 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 80 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 81 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 82 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 83 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 84 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 85 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 86 | OTHER DEALINGS IN THE FONT SOFTWARE. 87 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/ICON-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/README.md: -------------------------------------------------------------------------------- 1 | [Open Iconic v1.1.1](https://github.com/iconic/open-iconic) 2 | =========== 3 | 4 | ### Open Iconic is the open source sibling of [Iconic](https://github.com/iconic/open-iconic). It is a hyper-legible collection of 223 icons with a tiny footprint—ready to use with Bootstrap and Foundation. [View the collection](https://github.com/iconic/open-iconic) 5 | 6 | 7 | 8 | ## What's in Open Iconic? 9 | 10 | * 223 icons designed to be legible down to 8 pixels 11 | * Super-light SVG files - 61.8 for the entire set 12 | * SVG sprite—the modern replacement for icon fonts 13 | * Webfont (EOT, OTF, SVG, TTF, WOFF), PNG and WebP formats 14 | * Webfont stylesheets (including versions for Bootstrap and Foundation) in CSS, LESS, SCSS and Stylus formats 15 | * PNG and WebP raster images in 8px, 16px, 24px, 32px, 48px and 64px. 16 | 17 | 18 | ## Getting Started 19 | 20 | #### For code samples and everything else you need to get started with Open Iconic, check out our [Icons](https://github.com/iconic/open-iconic) and [Reference](https://github.com/iconic/open-iconic) sections. 21 | 22 | ### General Usage 23 | 24 | #### Using Open Iconic's SVGs 25 | 26 | We like SVGs and we think they're the way to display icons on the web. Since Open Iconic are just basic SVGs, we suggest you display them like you would any other image (don't forget the `alt` attribute). 27 | 28 | ``` 29 | icon name 30 | ``` 31 | 32 | #### Using Open Iconic's SVG Sprite 33 | 34 | Open Iconic also comes in a SVG sprite which allows you to display all the icons in the set with a single request. It's like an icon font, without being a hack. 35 | 36 | Adding an icon from an SVG sprite is a little different than what you're used to, but it's still a piece of cake. *Tip: To make your icons easily style able, we suggest adding a general class to the* `` *tag and a unique class name for each different icon in the* `` *tag.* 37 | 38 | ``` 39 | 40 | 41 | 42 | ``` 43 | 44 | Sizing icons only needs basic CSS. All the icons are in a square format, so just set the `` tag with equal width and height dimensions. 45 | 46 | ``` 47 | .icon { 48 | width: 16px; 49 | height: 16px; 50 | } 51 | ``` 52 | 53 | Coloring icons is even easier. All you need to do is set the `fill` rule on the `` tag. 54 | 55 | ``` 56 | .icon-account-login { 57 | fill: #f00; 58 | } 59 | ``` 60 | 61 | To learn more about SVG Sprites, read [Chris Coyier's guide](http://css-tricks.com/svg-sprites-use-better-icon-fonts/). 62 | 63 | #### Using Open Iconic's Icon Font... 64 | 65 | 66 | ##### …with Bootstrap 67 | 68 | You can find our Bootstrap stylesheets in `font/css/open-iconic-bootstrap.{css, less, scss, styl}` 69 | 70 | 71 | ``` 72 | 73 | ``` 74 | 75 | 76 | ``` 77 | 78 | ``` 79 | 80 | ##### …with Foundation 81 | 82 | You can find our Foundation stylesheets in `font/css/open-iconic-foundation.{css, less, scss, styl}` 83 | 84 | ``` 85 | 86 | ``` 87 | 88 | 89 | ``` 90 | 91 | ``` 92 | 93 | ##### …on its own 94 | 95 | You can find our default stylesheets in `font/css/open-iconic.{css, less, scss, styl}` 96 | 97 | ``` 98 | 99 | ``` 100 | 101 | ``` 102 | 103 | ``` 104 | 105 | 106 | ## License 107 | 108 | ### Icons 109 | 110 | All code (including SVG markup) is under the [MIT License](http://opensource.org/licenses/MIT). 111 | 112 | ### Fonts 113 | 114 | All fonts are under the [SIL Licensed](http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web). 115 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Icons;src:url(../fonts/open-iconic.eot);src:url(../fonts/open-iconic.eot?#iconic-sm) format('embedded-opentype'),url(../fonts/open-iconic.woff) format('woff'),url(../fonts/open-iconic.ttf) format('truetype'),url(../fonts/open-iconic.otf) format('opentype'),url(../fonts/open-iconic.svg#iconic-sm) format('svg');font-weight:400;font-style:normal}.oi{position:relative;top:1px;display:inline-block;speak:none;font-family:Icons;font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.oi:empty:before{width:1em;text-align:center;box-sizing:content-box}.oi.oi-align-center:before{text-align:center}.oi.oi-align-left:before{text-align:left}.oi.oi-align-right:before{text-align:right}.oi.oi-flip-horizontal:before{-webkit-transform:scale(-1,1);-ms-transform:scale(-1,1);transform:scale(-1,1)}.oi.oi-flip-vertical:before{-webkit-transform:scale(1,-1);-ms-transform:scale(-1,1);transform:scale(1,-1)}.oi.oi-flip-horizontal-vertical:before{-webkit-transform:scale(-1,-1);-ms-transform:scale(-1,1);transform:scale(-1,-1)}.oi-account-login:before{content:'\e000'}.oi-account-logout:before{content:'\e001'}.oi-action-redo:before{content:'\e002'}.oi-action-undo:before{content:'\e003'}.oi-align-center:before{content:'\e004'}.oi-align-left:before{content:'\e005'}.oi-align-right:before{content:'\e006'}.oi-aperture:before{content:'\e007'}.oi-arrow-bottom:before{content:'\e008'}.oi-arrow-circle-bottom:before{content:'\e009'}.oi-arrow-circle-left:before{content:'\e00a'}.oi-arrow-circle-right:before{content:'\e00b'}.oi-arrow-circle-top:before{content:'\e00c'}.oi-arrow-left:before{content:'\e00d'}.oi-arrow-right:before{content:'\e00e'}.oi-arrow-thick-bottom:before{content:'\e00f'}.oi-arrow-thick-left:before{content:'\e010'}.oi-arrow-thick-right:before{content:'\e011'}.oi-arrow-thick-top:before{content:'\e012'}.oi-arrow-top:before{content:'\e013'}.oi-audio-spectrum:before{content:'\e014'}.oi-audio:before{content:'\e015'}.oi-badge:before{content:'\e016'}.oi-ban:before{content:'\e017'}.oi-bar-chart:before{content:'\e018'}.oi-basket:before{content:'\e019'}.oi-battery-empty:before{content:'\e01a'}.oi-battery-full:before{content:'\e01b'}.oi-beaker:before{content:'\e01c'}.oi-bell:before{content:'\e01d'}.oi-bluetooth:before{content:'\e01e'}.oi-bold:before{content:'\e01f'}.oi-bolt:before{content:'\e020'}.oi-book:before{content:'\e021'}.oi-bookmark:before{content:'\e022'}.oi-box:before{content:'\e023'}.oi-briefcase:before{content:'\e024'}.oi-british-pound:before{content:'\e025'}.oi-browser:before{content:'\e026'}.oi-brush:before{content:'\e027'}.oi-bug:before{content:'\e028'}.oi-bullhorn:before{content:'\e029'}.oi-calculator:before{content:'\e02a'}.oi-calendar:before{content:'\e02b'}.oi-camera-slr:before{content:'\e02c'}.oi-caret-bottom:before{content:'\e02d'}.oi-caret-left:before{content:'\e02e'}.oi-caret-right:before{content:'\e02f'}.oi-caret-top:before{content:'\e030'}.oi-cart:before{content:'\e031'}.oi-chat:before{content:'\e032'}.oi-check:before{content:'\e033'}.oi-chevron-bottom:before{content:'\e034'}.oi-chevron-left:before{content:'\e035'}.oi-chevron-right:before{content:'\e036'}.oi-chevron-top:before{content:'\e037'}.oi-circle-check:before{content:'\e038'}.oi-circle-x:before{content:'\e039'}.oi-clipboard:before{content:'\e03a'}.oi-clock:before{content:'\e03b'}.oi-cloud-download:before{content:'\e03c'}.oi-cloud-upload:before{content:'\e03d'}.oi-cloud:before{content:'\e03e'}.oi-cloudy:before{content:'\e03f'}.oi-code:before{content:'\e040'}.oi-cog:before{content:'\e041'}.oi-collapse-down:before{content:'\e042'}.oi-collapse-left:before{content:'\e043'}.oi-collapse-right:before{content:'\e044'}.oi-collapse-up:before{content:'\e045'}.oi-command:before{content:'\e046'}.oi-comment-square:before{content:'\e047'}.oi-compass:before{content:'\e048'}.oi-contrast:before{content:'\e049'}.oi-copywriting:before{content:'\e04a'}.oi-credit-card:before{content:'\e04b'}.oi-crop:before{content:'\e04c'}.oi-dashboard:before{content:'\e04d'}.oi-data-transfer-download:before{content:'\e04e'}.oi-data-transfer-upload:before{content:'\e04f'}.oi-delete:before{content:'\e050'}.oi-dial:before{content:'\e051'}.oi-document:before{content:'\e052'}.oi-dollar:before{content:'\e053'}.oi-double-quote-sans-left:before{content:'\e054'}.oi-double-quote-sans-right:before{content:'\e055'}.oi-double-quote-serif-left:before{content:'\e056'}.oi-double-quote-serif-right:before{content:'\e057'}.oi-droplet:before{content:'\e058'}.oi-eject:before{content:'\e059'}.oi-elevator:before{content:'\e05a'}.oi-ellipses:before{content:'\e05b'}.oi-envelope-closed:before{content:'\e05c'}.oi-envelope-open:before{content:'\e05d'}.oi-euro:before{content:'\e05e'}.oi-excerpt:before{content:'\e05f'}.oi-expand-down:before{content:'\e060'}.oi-expand-left:before{content:'\e061'}.oi-expand-right:before{content:'\e062'}.oi-expand-up:before{content:'\e063'}.oi-external-link:before{content:'\e064'}.oi-eye:before{content:'\e065'}.oi-eyedropper:before{content:'\e066'}.oi-file:before{content:'\e067'}.oi-fire:before{content:'\e068'}.oi-flag:before{content:'\e069'}.oi-flash:before{content:'\e06a'}.oi-folder:before{content:'\e06b'}.oi-fork:before{content:'\e06c'}.oi-fullscreen-enter:before{content:'\e06d'}.oi-fullscreen-exit:before{content:'\e06e'}.oi-globe:before{content:'\e06f'}.oi-graph:before{content:'\e070'}.oi-grid-four-up:before{content:'\e071'}.oi-grid-three-up:before{content:'\e072'}.oi-grid-two-up:before{content:'\e073'}.oi-hard-drive:before{content:'\e074'}.oi-header:before{content:'\e075'}.oi-headphones:before{content:'\e076'}.oi-heart:before{content:'\e077'}.oi-home:before{content:'\e078'}.oi-image:before{content:'\e079'}.oi-inbox:before{content:'\e07a'}.oi-infinity:before{content:'\e07b'}.oi-info:before{content:'\e07c'}.oi-italic:before{content:'\e07d'}.oi-justify-center:before{content:'\e07e'}.oi-justify-left:before{content:'\e07f'}.oi-justify-right:before{content:'\e080'}.oi-key:before{content:'\e081'}.oi-laptop:before{content:'\e082'}.oi-layers:before{content:'\e083'}.oi-lightbulb:before{content:'\e084'}.oi-link-broken:before{content:'\e085'}.oi-link-intact:before{content:'\e086'}.oi-list-rich:before{content:'\e087'}.oi-list:before{content:'\e088'}.oi-location:before{content:'\e089'}.oi-lock-locked:before{content:'\e08a'}.oi-lock-unlocked:before{content:'\e08b'}.oi-loop-circular:before{content:'\e08c'}.oi-loop-square:before{content:'\e08d'}.oi-loop:before{content:'\e08e'}.oi-magnifying-glass:before{content:'\e08f'}.oi-map-marker:before{content:'\e090'}.oi-map:before{content:'\e091'}.oi-media-pause:before{content:'\e092'}.oi-media-play:before{content:'\e093'}.oi-media-record:before{content:'\e094'}.oi-media-skip-backward:before{content:'\e095'}.oi-media-skip-forward:before{content:'\e096'}.oi-media-step-backward:before{content:'\e097'}.oi-media-step-forward:before{content:'\e098'}.oi-media-stop:before{content:'\e099'}.oi-medical-cross:before{content:'\e09a'}.oi-menu:before{content:'\e09b'}.oi-microphone:before{content:'\e09c'}.oi-minus:before{content:'\e09d'}.oi-monitor:before{content:'\e09e'}.oi-moon:before{content:'\e09f'}.oi-move:before{content:'\e0a0'}.oi-musical-note:before{content:'\e0a1'}.oi-paperclip:before{content:'\e0a2'}.oi-pencil:before{content:'\e0a3'}.oi-people:before{content:'\e0a4'}.oi-person:before{content:'\e0a5'}.oi-phone:before{content:'\e0a6'}.oi-pie-chart:before{content:'\e0a7'}.oi-pin:before{content:'\e0a8'}.oi-play-circle:before{content:'\e0a9'}.oi-plus:before{content:'\e0aa'}.oi-power-standby:before{content:'\e0ab'}.oi-print:before{content:'\e0ac'}.oi-project:before{content:'\e0ad'}.oi-pulse:before{content:'\e0ae'}.oi-puzzle-piece:before{content:'\e0af'}.oi-question-mark:before{content:'\e0b0'}.oi-rain:before{content:'\e0b1'}.oi-random:before{content:'\e0b2'}.oi-reload:before{content:'\e0b3'}.oi-resize-both:before{content:'\e0b4'}.oi-resize-height:before{content:'\e0b5'}.oi-resize-width:before{content:'\e0b6'}.oi-rss-alt:before{content:'\e0b7'}.oi-rss:before{content:'\e0b8'}.oi-script:before{content:'\e0b9'}.oi-share-boxed:before{content:'\e0ba'}.oi-share:before{content:'\e0bb'}.oi-shield:before{content:'\e0bc'}.oi-signal:before{content:'\e0bd'}.oi-signpost:before{content:'\e0be'}.oi-sort-ascending:before{content:'\e0bf'}.oi-sort-descending:before{content:'\e0c0'}.oi-spreadsheet:before{content:'\e0c1'}.oi-star:before{content:'\e0c2'}.oi-sun:before{content:'\e0c3'}.oi-tablet:before{content:'\e0c4'}.oi-tag:before{content:'\e0c5'}.oi-tags:before{content:'\e0c6'}.oi-target:before{content:'\e0c7'}.oi-task:before{content:'\e0c8'}.oi-terminal:before{content:'\e0c9'}.oi-text:before{content:'\e0ca'}.oi-thumb-down:before{content:'\e0cb'}.oi-thumb-up:before{content:'\e0cc'}.oi-timer:before{content:'\e0cd'}.oi-transfer:before{content:'\e0ce'}.oi-trash:before{content:'\e0cf'}.oi-underline:before{content:'\e0d0'}.oi-vertical-align-bottom:before{content:'\e0d1'}.oi-vertical-align-center:before{content:'\e0d2'}.oi-vertical-align-top:before{content:'\e0d3'}.oi-video:before{content:'\e0d4'}.oi-volume-high:before{content:'\e0d5'}.oi-volume-low:before{content:'\e0d6'}.oi-volume-off:before{content:'\e0d7'}.oi-warning:before{content:'\e0d8'}.oi-wifi:before{content:'\e0d9'}.oi-wrench:before{content:'\e0da'}.oi-x:before{content:'\e0db'}.oi-yen:before{content:'\e0dc'}.oi-zoom-in:before{content:'\e0dd'}.oi-zoom-out:before{content:'\e0de'} -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KristofferStrube/Blazor.GraphEditor/6bff778e84a17b00dd1494806d3664fc7bacf62e/samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.eot -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KristofferStrube/Blazor.GraphEditor/6bff778e84a17b00dd1494806d3664fc7bacf62e/samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.otf -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Created by FontForge 20120731 at Tue Jul 1 20:39:22 2014 9 | By P.J. Onori 10 | Created by P.J. Onori with FontForge 2.0 (http://fontforge.sf.net) 11 | 12 | 13 | 14 | 27 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 45 | 47 | 49 | 51 | 53 | 55 | 57 | 59 | 61 | 63 | 65 | 67 | 69 | 71 | 74 | 76 | 79 | 81 | 84 | 86 | 88 | 91 | 93 | 95 | 98 | 100 | 102 | 104 | 106 | 109 | 112 | 115 | 117 | 121 | 123 | 125 | 127 | 130 | 132 | 134 | 136 | 138 | 141 | 143 | 145 | 147 | 149 | 151 | 153 | 155 | 157 | 159 | 162 | 165 | 167 | 169 | 172 | 174 | 177 | 179 | 181 | 183 | 185 | 189 | 191 | 194 | 196 | 198 | 200 | 202 | 205 | 207 | 209 | 211 | 213 | 215 | 218 | 220 | 222 | 224 | 226 | 228 | 230 | 232 | 234 | 236 | 238 | 241 | 243 | 245 | 247 | 249 | 251 | 253 | 256 | 259 | 261 | 263 | 265 | 267 | 269 | 272 | 274 | 276 | 280 | 282 | 285 | 287 | 289 | 292 | 295 | 298 | 300 | 302 | 304 | 306 | 309 | 312 | 314 | 316 | 318 | 320 | 322 | 324 | 326 | 330 | 334 | 338 | 340 | 343 | 345 | 347 | 349 | 351 | 353 | 355 | 358 | 360 | 363 | 365 | 367 | 369 | 371 | 373 | 375 | 377 | 379 | 381 | 383 | 386 | 388 | 390 | 392 | 394 | 396 | 399 | 401 | 404 | 406 | 408 | 410 | 412 | 414 | 416 | 419 | 421 | 423 | 425 | 428 | 431 | 435 | 438 | 440 | 442 | 444 | 446 | 448 | 451 | 453 | 455 | 457 | 460 | 462 | 464 | 466 | 468 | 471 | 473 | 477 | 479 | 481 | 483 | 486 | 488 | 490 | 492 | 494 | 496 | 499 | 501 | 504 | 506 | 509 | 512 | 515 | 517 | 520 | 522 | 524 | 526 | 529 | 532 | 534 | 536 | 539 | 542 | 543 | 544 | -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KristofferStrube/Blazor.GraphEditor/6bff778e84a17b00dd1494806d3664fc7bacf62e/samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KristofferStrube/Blazor.GraphEditor/6bff778e84a17b00dd1494806d3664fc7bacf62e/samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/css/open-iconic/font/fonts/open-iconic.woff -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KristofferStrube/Blazor.GraphEditor/6bff778e84a17b00dd1494806d3664fc7bacf62e/samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/favicon.png -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KristofferStrube/Blazor.GraphEditor/6bff778e84a17b00dd1494806d3664fc7bacf62e/samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/icon-192.png -------------------------------------------------------------------------------- /samples/KristofferStrube.Blazor.GraphEditor.WasmExample/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Blazor.GraphEditor 8 | 9 | 31 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
53 | 54 | 55 | 56 | 57 |
58 |
59 | 60 |
61 | An unhandled error has occurred. 62 | Reload 63 | 🗙 64 |
65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/Edge.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Dom; 2 | using KristofferStrube.Blazor.SVGEditor; 3 | using Microsoft.AspNetCore.Components.Web; 4 | 5 | namespace KristofferStrube.Blazor.GraphEditor; 6 | 7 | /// 8 | /// The edge between two s. 9 | /// 10 | /// The type parameter for the data that backs the nodes in this graph. 11 | /// The type parameter for the data that backs the nodes in this graph. 12 | public class Edge : Line where TNodeData : IEquatable 13 | { 14 | /// 15 | /// Constructs an edge. 16 | /// 17 | /// The SVG element that will be used as the base backing element for changes to the position of the edge. 18 | /// The that the edge resides in. 19 | public Edge(IElement element, SVGEditor.SVGEditor svg) : base(element, svg) 20 | { 21 | UpdateLine(); 22 | } 23 | 24 | /// 25 | /// The component type that will be used to edit this shape. 26 | /// 27 | public override Type Presenter => typeof(EdgeEditor); 28 | 29 | /// 30 | /// The that this edge belongs to. 31 | /// 32 | public required GraphEditor GraphEditor { get; set; } 33 | 34 | /// 35 | /// The data that this edge will get its characteristics from. 36 | /// 37 | public required TEdgeData Data { get; set; } 38 | 39 | /// 40 | /// The node that the edge goes from. 41 | /// 42 | public required Node From { get; set; } 43 | 44 | /// 45 | /// The node that the edge goes to. 46 | /// 47 | public required Node To { get; set; } 48 | 49 | /// 50 | /// The width of the edge mapped from its . 51 | /// 52 | public new string StrokeWidth => GraphEditor.EdgeWidthMapper(Data).AsString(); 53 | 54 | /// 55 | /// The color of the edge mapped from its . 56 | /// 57 | public new string Stroke => GraphEditor.EdgeColorMapper(Data); 58 | 59 | /// 60 | /// Whether the edge should show an arrow head at its end. 61 | /// 62 | public bool ShowArrow => GraphEditor.EdgeShowsArrow(Data); 63 | 64 | /// 65 | /// Handles when the shape is moved. 66 | /// 67 | /// The arguments from the pointer that is moved. 68 | public override void HandlePointerMove(PointerEventArgs eventArgs) 69 | { 70 | if (SVG.EditMode is EditMode.Add) 71 | { 72 | (X2, Y2) = SVG.LocalDetransform((eventArgs.OffsetX, eventArgs.OffsetY)); 73 | SetStart((X2, Y2)); 74 | } 75 | } 76 | 77 | /// 78 | /// Handles when the shape stops moving. 79 | /// 80 | /// The arguments from the pointer that stops. 81 | public override void HandlePointerUp(PointerEventArgs eventArgs) 82 | { 83 | if (SVG.EditMode is EditMode.Add 84 | && SVG.SelectedShapes.FirstOrDefault(s => s is Node node && node != From) is Node { } to) 85 | { 86 | if (to.Edges.Any(c => c.To == From || c.From == From)) 87 | { 88 | Complete(); 89 | } 90 | else 91 | { 92 | To = to; 93 | SVG.EditMode = EditMode.None; 94 | UpdateLine(); 95 | } 96 | } 97 | } 98 | 99 | /// 100 | /// The method that is invoked when an edge finishes its creation process. 101 | /// 102 | public override void Complete() 103 | { 104 | if (To is null) 105 | { 106 | SVG.RemoveElement(this); 107 | Changed?.Invoke(this); 108 | } 109 | } 110 | 111 | /// 112 | /// Updates the coordinates of the start of the line. 113 | /// 114 | /// The coordinates of where the edge will go towards. 115 | public void SetStart((double x, double y) towards) 116 | { 117 | double differenceX = towards.x - From!.Cx; 118 | double differenceY = towards.y - From!.Cy; 119 | double distance = Math.Sqrt((differenceX * differenceX) + (differenceY * differenceY)); 120 | 121 | if (distance > 0) 122 | { 123 | X1 = From!.Cx + (differenceX / distance * From.R); 124 | Y1 = From!.Cy + (differenceY / distance * From.R); 125 | } 126 | } 127 | 128 | /// 129 | /// Updates the coordinates of the start and end of the edge. 130 | /// 131 | public void UpdateLine() 132 | { 133 | if (From is null || To is null) 134 | { 135 | (X1, Y1) = (X2, Y2); 136 | return; 137 | } 138 | 139 | double differenceX = To.Cx - From.Cx; 140 | double differenceY = To.Cy - From.Cy; 141 | double distance = Math.Sqrt((differenceX * differenceX) + (differenceY * differenceY)); 142 | 143 | if (distance < To.R + From.R + (ShowArrow ? GraphEditor.EdgeWidthMapper(Data) * 3 : 0)) 144 | { 145 | (X1, Y1) = (X2, Y2); 146 | } 147 | else 148 | { 149 | SetStart((To.Cx, To.Cy)); 150 | X2 = To.Cx - (differenceX / distance * (To.R + (ShowArrow ? GraphEditor.EdgeWidthMapper(Data) * 3 : 0))); 151 | Y2 = To.Cy - (differenceY / distance * (To.R + (ShowArrow ? GraphEditor.EdgeWidthMapper(Data) * 3 : 0))); 152 | } 153 | } 154 | 155 | /// 156 | /// Adds a new edge. 157 | /// 158 | /// The that the shape will be create in. 159 | /// The that the edge resides in. 160 | /// The backing data that the edge will be created from. 161 | /// The that the edge goes from. 162 | /// The that the edge goes to. 163 | /// The new edge. 164 | public static Edge AddNew( 165 | SVGEditor.SVGEditor SVG, 166 | GraphEditor graphEditor, 167 | TEdgeData data, 168 | Node from, 169 | Node to) 170 | { 171 | IElement element = SVG.Document.CreateElement("LINE"); 172 | element.SetAttribute("data-elementtype", "edge"); 173 | 174 | Edge edge = new(element, SVG) 175 | { 176 | Id = graphEditor.NodeIdMapper(from.Data) + "-" + graphEditor.NodeIdMapper(to.Data), 177 | Changed = SVG.UpdateInput, 178 | GraphEditor = graphEditor, 179 | Data = data, 180 | From = from, 181 | To = to 182 | }; 183 | from.Edges.Add(edge); 184 | to.Edges.Add(edge); 185 | from.NeighborNodes[graphEditor.NodeIdMapper(to.Data)] = edge; 186 | to.NeighborNodes[graphEditor.NodeIdMapper(from.Data)] = edge; 187 | 188 | SVG.Elements.Add(edge); 189 | return edge; 190 | } 191 | } -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/EdgeEditor.razor: -------------------------------------------------------------------------------- 1 | @using BlazorContextMenu 2 | @using KristofferStrube.Blazor.SVGEditor.Extensions; 3 | @using KristofferStrube.Blazor.SVGEditor.ShapeEditors; 4 | @using KristofferStrube.Blazor.SVGEditor; 5 | @using Microsoft.AspNetCore.Components.Web 6 | 7 | @typeparam TNodeData where TNodeData : IEquatable 8 | @typeparam TEdgeData 9 | @inherits ShapeEditor> 10 | 11 | 12 | 13 | 31 | 32 | 33 | 34 | 35 | @code { 36 | protected override async Task OnAfterRenderAsync(bool firstRender) 37 | { 38 | if (firstRender) 39 | { 40 | SVGElement.UpdateLine(); 41 | } 42 | await base.OnAfterRenderAsync(firstRender); 43 | } 44 | } -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/Extensions/DoubleExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace KristofferStrube.Blazor.GraphEditor; 4 | 5 | internal static class DoubleExtensions 6 | { 7 | internal static string AsString(this double d) 8 | { 9 | return Math.Round(d, 9).ToString(CultureInfo.InvariantCulture); 10 | } 11 | } -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace KristofferStrube.Blazor.GraphEditor.Extensions; 4 | 5 | internal static class StringExtensions 6 | { 7 | internal static double ParseAsDouble(this string s) 8 | { 9 | return double.Parse(s, CultureInfo.InvariantCulture); 10 | } 11 | } -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/GraphEditor.razor: -------------------------------------------------------------------------------- 1 | @using KristofferStrube.Blazor.SVGEditor 2 | @typeparam TNode 3 | @typeparam TEdge 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/GraphEditor.razor.cs: -------------------------------------------------------------------------------- 1 | using KristofferStrube.Blazor.SVGEditor; 2 | using Microsoft.AspNetCore.Components; 3 | 4 | namespace KristofferStrube.Blazor.GraphEditor; 5 | 6 | /// 7 | /// An editor for graphs consisting of nodes and edges. 8 | /// 9 | /// The type that will represent the nodes in graph. 10 | /// The type that will represent the connections between the nodes in the graph. 11 | public partial class GraphEditor : ComponentBase where TNode : IEquatable 12 | { 13 | private GraphEditorCallbackContext callbackContext = default!; 14 | private Node[] nodeElements = []; 15 | private string EdgeId(TEdge e) 16 | { 17 | return NodeIdMapper(EdgeFromMapper(e)) + "-" + NodeIdMapper(EdgeToMapper(e)); 18 | } 19 | 20 | /// 21 | /// Maps each node to an unique id. 22 | /// 23 | [Parameter, EditorRequired] 24 | public required Func NodeIdMapper { get; set; } 25 | 26 | /// 27 | /// Defaults to "#66BB6A". 28 | /// 29 | [Parameter] 30 | public Func NodeColorMapper { get; set; } = _ => "#66BB6A"; 31 | 32 | /// 33 | /// Defaults to 50. 34 | /// 35 | [Parameter] 36 | public Func NodeRadiusMapper { get; set; } = _ => 50; 37 | 38 | /// 39 | /// Defaults to 800. 40 | /// 41 | [Parameter] 42 | public Func NodeRepulsionMapper { get; set; } = _ => 800; 43 | 44 | /// 45 | /// Defaults to . 46 | /// 47 | [Parameter] 48 | public Func NodeImageMapper { get; set; } = _ => null; 49 | 50 | /// 51 | /// Maps each edge to which node it goes from. 52 | /// 53 | [Parameter, EditorRequired] 54 | public required Func EdgeFromMapper { get; set; } 55 | 56 | /// 57 | /// Maps each edge to which node it goes to. 58 | /// 59 | [Parameter, EditorRequired] 60 | public required Func EdgeToMapper { get; set; } 61 | 62 | /// 63 | /// Defaults to 1. 64 | /// 65 | [Parameter] 66 | public Func EdgeWidthMapper { get; set; } = _ => 1; 67 | 68 | /// 69 | /// Defaults to 1. 70 | /// 71 | [Parameter] 72 | public Func EdgeSpringConstantMapper { get; set; } = _ => 1; 73 | 74 | /// 75 | /// Defaults to 200. 76 | /// 77 | [Parameter] 78 | public Func EdgeSpringLengthMapper { get; set; } = _ => 200; 79 | 80 | /// 81 | /// Defaults to "#000000". 82 | /// 83 | [Parameter] 84 | public Func EdgeColorMapper { get; set; } = _ => "#000000"; 85 | 86 | /// 87 | /// Defaults to 88 | /// 89 | [Parameter] 90 | public Func EdgeShowsArrow { get; set; } = _ => true; 91 | 92 | /// 93 | /// Callback that will be invoked when a specific node is selected by it getting focus. 94 | /// 95 | [Parameter] 96 | public Func? NodeSelectionCallback { get; set; } 97 | 98 | /// 99 | /// Whether the underlying has rendered. 100 | /// 101 | public bool IsReadyToLoad => SVGEditor.BBox is not null; 102 | 103 | /// 104 | /// The nodes of the graph. 105 | /// 106 | protected Dictionary Nodes { get; set; } = []; 107 | 108 | /// 109 | /// The edges of the graph. 110 | /// 111 | protected Dictionary Edges { get; set; } = []; 112 | 113 | /// 114 | /// The underlying . 115 | /// 116 | public SVGEditor.SVGEditor SVGEditor { get; set; } = default!; 117 | 118 | /// 119 | /// A text representation of the graph. 120 | /// 121 | protected string Input { get; set; } = ""; 122 | 123 | /// 124 | protected override void OnInitialized() 125 | { 126 | callbackContext = new() 127 | { 128 | NodeSelectionCallback = async (id) => 129 | { 130 | if (NodeSelectionCallback is not null && Nodes.TryGetValue(id, out TNode? node)) 131 | { 132 | await NodeSelectionCallback.Invoke(node); 133 | } 134 | } 135 | }; 136 | } 137 | 138 | /// 139 | /// Loads a graph of nodes and edges. 140 | /// 141 | /// The nodes of the graph. 142 | /// The edges that are present between the given . 143 | public async Task LoadGraph(List nodes, List edges) 144 | { 145 | if (SVGEditor.BBox is not null) 146 | { 147 | SVGEditor.Translate = (SVGEditor.BBox.Width / 2, SVGEditor.BBox.Height / 2); 148 | } 149 | else 150 | { 151 | SVGEditor.Translate = (200, 200); 152 | } 153 | 154 | Nodes = nodes.ToDictionary(NodeIdMapper, n => n); 155 | Edges = edges.ToDictionary(EdgeId, e => e); 156 | 157 | Dictionary> nodeElementDictionary = []; 158 | 159 | foreach (TNode node in nodes) 160 | { 161 | Node element = Node.AddNew(SVGEditor, this, node); 162 | element.Cx = Random.Shared.NextDouble() * 20; 163 | element.Cy = Random.Shared.NextDouble() * 20; 164 | nodeElementDictionary.Add(node, element); 165 | } 166 | foreach (TEdge edge in edges) 167 | { 168 | Edge.AddNew(SVGEditor, this, edge, nodeElementDictionary[EdgeFromMapper(edge)], nodeElementDictionary[EdgeToMapper(edge)]); 169 | } 170 | 171 | SVGEditor.SelectedShapes = SVGEditor.Elements.Where(e => e is Edge).Select(e => (Shape)e).ToList(); 172 | SVGEditor.MoveToBack(); 173 | SVGEditor.ClearSelectedShapes(); 174 | 175 | await Task.Yield(); 176 | StateHasChanged(); 177 | nodeElements = SVGEditor.Elements.Where(e => e is Node).Select(e => (Node)e).ToArray(); 178 | } 179 | 180 | /// 181 | /// Updates the nodes and edges that are in the graph without clearing the existing ones. 182 | /// 183 | /// The nodes of the graph. 184 | /// The edges that are present between the given . 185 | public async Task UpdateGraph(List nodes, List edges) 186 | { 187 | Dictionary> newNodeElementDictionary = []; 188 | 189 | HashSet newSetOfNodes = new(nodes.Count); 190 | HashSet newSetOfEdges = new(edges.Count); 191 | 192 | // Add new nodes 193 | foreach (TNode node in nodes) 194 | { 195 | string nodeKey = NodeIdMapper(node); 196 | if (Nodes.TryAdd(nodeKey, node)) 197 | { 198 | Node element = Node.CreateNew(SVGEditor, this, node); 199 | newNodeElementDictionary.Add(node, element); 200 | } 201 | newSetOfNodes.Add(nodeKey); 202 | } 203 | 204 | // Add new edges 205 | foreach (TEdge edge in edges) 206 | { 207 | string edgeKey = EdgeId(edge); 208 | if (Edges.TryAdd(edgeKey, edge)) 209 | { 210 | TNode from = EdgeFromMapper(edge); 211 | TNode to = EdgeToMapper(edge); 212 | Node fromElement = newNodeElementDictionary.TryGetValue(from, out var eFrom) ? eFrom : nodeElements.First(n => n.Data.Equals(from)); 213 | Node toElement = newNodeElementDictionary.TryGetValue(to, out var eTo) ? eTo : nodeElements.First(n => n.Data.Equals(to)); 214 | Edge.AddNew(SVGEditor, this, edge, fromElement, toElement); 215 | } 216 | newSetOfEdges.Add(edgeKey); 217 | } 218 | 219 | // Remove old edges 220 | foreach (string edgeKey in Edges.Keys) 221 | { 222 | if (!newSetOfEdges.Contains(edgeKey)) 223 | { 224 | Edge edgeToRemove = (Edge)SVGEditor.Elements.First(e => e is Edge edge && EdgeId(edge.Data) == edgeKey); 225 | SVGEditor.RemoveElement(edgeToRemove); 226 | Edges.Remove(edgeKey); 227 | } 228 | } 229 | 230 | // Remove old nodes 231 | foreach (string nodeKey in Nodes.Keys) 232 | { 233 | if (!newSetOfNodes.Contains(nodeKey)) 234 | { 235 | Node nodeToRemove = (Node)SVGEditor.Elements.First(e => e is Node node && NodeIdMapper(node.Data) == nodeKey); 236 | SVGEditor.RemoveElement(nodeToRemove); 237 | Nodes.Remove(nodeKey); 238 | } 239 | } 240 | 241 | foreach (var newNodeElement in newNodeElementDictionary.Values) 242 | { 243 | if (newNodeElement.Edges.Count is 0) 244 | { 245 | newNodeElement.Cx = Random.Shared.NextDouble() * 20; 246 | newNodeElement.Cy = Random.Shared.NextDouble() * 20; 247 | } 248 | else 249 | { 250 | double newX = 0; 251 | double newY = 0; 252 | foreach (var neighborEdge in newNodeElement.Edges) 253 | { 254 | var (x, y) = MirroredAverageOfNeighborNodes(neighborEdge, newNodeElement); 255 | newX += x / newNodeElement.Edges.Count; 256 | newY += y / newNodeElement.Edges.Count; 257 | } 258 | newNodeElement.Cx = newX; 259 | newNodeElement.Cy = newY; 260 | } 261 | SVGEditor.AddElement(newNodeElement); 262 | } 263 | 264 | await Task.Yield(); 265 | StateHasChanged(); 266 | nodeElements = SVGEditor.Elements.Where(e => e is Node).Select(e => (Node)e).ToArray(); 267 | 268 | foreach (Edge edge in SVGEditor.Elements.Where(e => e is Edge).Cast>()) 269 | { 270 | edge.UpdateLine(); 271 | } 272 | } 273 | 274 | private (double x, double y) MirroredAverageOfNeighborNodes(Edge neighborEdge, Node rootNode) 275 | { 276 | var neighborNode = neighborEdge.From == rootNode ? neighborEdge.To : neighborEdge.From; 277 | var neighborsNeighbors = neighborNode.Edges.Select(e => e.From == neighborNode ? e.To : e.From).Where(n => n != rootNode).ToArray(); 278 | 279 | var edgeLength = EdgeSpringLengthMapper(neighborEdge.Data); 280 | 281 | if (neighborsNeighbors.Length is 0) 282 | { 283 | var randomAngle = neighborNode.Cx + Random.Shared.NextDouble() * Math.PI; 284 | return ( 285 | x: neighborNode.Cx + Math.Sin(randomAngle) * edgeLength, 286 | y: neighborNode.Cy + Math.Cos(randomAngle) * edgeLength 287 | ); 288 | } 289 | else 290 | { 291 | double averageXPositionOfNeighborsNeighbors = 0; 292 | double averageYPositionOfNeighborsNeighbors = 0; 293 | 294 | foreach (var neighborsNeighborNode in neighborsNeighbors) 295 | { 296 | averageXPositionOfNeighborsNeighbors += neighborsNeighborNode.Cx / neighborsNeighbors.Length; 297 | averageYPositionOfNeighborsNeighbors += neighborsNeighborNode.Cy / neighborsNeighbors.Length; 298 | } 299 | // TODO: Handle the case where averagePositionOfNeighborsNeighbors==neighborsPosition; 300 | var differenceBetweenAverageNeighborsNeighborsAndNeighbor = ( 301 | x: averageXPositionOfNeighborsNeighbors - neighborNode.Cx, 302 | y: averageYPositionOfNeighborsNeighbors - neighborNode.Cy 303 | ); 304 | var distanceBetweenAverageNeighborsNeighborsAndNeighbor = Math.Sqrt(Math.Pow(differenceBetweenAverageNeighborsNeighborsAndNeighbor.x, 2) + Math.Pow(differenceBetweenAverageNeighborsNeighborsAndNeighbor.y, 2)); 305 | var (x, y) = ( 306 | differenceBetweenAverageNeighborsNeighborsAndNeighbor.x / distanceBetweenAverageNeighborsNeighborsAndNeighbor, 307 | differenceBetweenAverageNeighborsNeighborsAndNeighbor.y / distanceBetweenAverageNeighborsNeighborsAndNeighbor 308 | ); 309 | 310 | return ( 311 | x: neighborNode.Cx - x * edgeLength, 312 | y: neighborNode.Cy - y * edgeLength 313 | ); 314 | } 315 | } 316 | 317 | /// 318 | /// Updates the layout of the nodes so that they repulse each other while staying close to the ones that they are connected to via edges. 319 | /// 320 | public Task ForceDirectedLayout() 321 | { 322 | Span> nodes = nodeElements.AsSpan(); 323 | foreach (var node1 in nodes) 324 | { 325 | double mx = 0; 326 | double my = 0; 327 | foreach (var node2 in nodes) 328 | { 329 | if (node1 == node2 || node1.NeighborNodes.ContainsKey(NodeIdMapper(node2.Data))) 330 | { 331 | continue; 332 | } 333 | 334 | double dx = node1.Cx - node2.Cx; 335 | double dy = node1.Cy - node2.Cy; 336 | double d = Math.Sqrt(dx * dx + dy * dy); 337 | double force = -(NodeRepulsionMapper(node1.Data) + NodeRepulsionMapper(node2.Data)) / 2 / (d * d); 338 | 339 | mx -= dx * 0.1 * force; 340 | my -= dy * 0.1 * force; 341 | } 342 | 343 | if (!SVGEditor.SelectedShapes.Contains(node1)) 344 | { 345 | node1.Cx += mx; 346 | node1.Cy += my; 347 | } 348 | } 349 | 350 | foreach (Edge edge in SVGEditor.Elements.Where(e => e is Edge).Cast>()) 351 | { 352 | double dx = edge.From.Cx - edge.To.Cx; 353 | double dy = edge.From.Cy - edge.To.Cy; 354 | double d = Math.Sqrt(dx * dx + dy * dy); 355 | double force = EdgeSpringConstantMapper(edge.Data) * Math.Log(d / EdgeSpringLengthMapper(edge.Data)); 356 | 357 | double mx = dx * 0.1 * force; 358 | double my = dy * 0.1 * force; 359 | 360 | if (!SVGEditor.SelectedShapes.Contains(edge.From)) 361 | { 362 | edge.From.Cx -= mx; 363 | edge.From.Cy -= my; 364 | } 365 | if (!SVGEditor.SelectedShapes.Contains(edge.To)) 366 | { 367 | edge.To.Cx += mx; 368 | edge.To.Cy += my; 369 | } 370 | } 371 | foreach (Edge edge in SVGEditor.Elements.Where(e => e is Edge).Cast>()) 372 | { 373 | edge.UpdateLine(); 374 | } 375 | return Task.CompletedTask; 376 | } 377 | 378 | /// 379 | /// Move all edges to the back so that they are hidden if any nodes are displayed in the same position. 380 | /// 381 | public void MoveEdgesToBack() 382 | { 383 | var prevSelectedShapes = SVGEditor.SelectedShapes.ToList(); 384 | SVGEditor.ClearSelectedShapes(); 385 | foreach (Shape shape in SVGEditor.Elements.Where(e => e is Edge).Cast()) 386 | { 387 | SVGEditor.SelectShape(shape); 388 | } 389 | SVGEditor.MoveToBack(); 390 | SVGEditor.SelectedShapes = prevSelectedShapes; 391 | } 392 | 393 | /// 394 | /// The elements that can be rendered in this graph. This normally doesn't need adjustments as the graph defaults to having support for the nodes and edges it shows. 395 | /// 396 | protected List SupportedElements { get; set; } = 397 | [ 398 | new(typeof(Node), element => element.TagName is "CIRCLE" && element.GetAttribute("data-elementtype") == "node"), 399 | new(typeof(Edge), element => element.TagName is "LINE" && element.GetAttribute("data-elementtype") == "edge"), 400 | ]; 401 | } -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/GraphEditorCallbackContext.cs: -------------------------------------------------------------------------------- 1 | namespace KristofferStrube.Blazor.GraphEditor; 2 | 3 | /// 4 | /// The context that specifies any functions that needs to be triggered for specific events in the graph.. 5 | /// 6 | public class GraphEditorCallbackContext 7 | { 8 | /// 9 | /// The function that will be invoked when a node is selected by getting focus. 10 | /// 11 | public required Func NodeSelectionCallback { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/KristofferStrube.Blazor.GraphEditor.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net7.0;net8.0 5 | true 6 | True 7 | preview 8 | enable 9 | Blazor GraphEditor 10 | A Graph Editor for Blazor built with SVGs. 11 | KristofferStrube.Blazor.GraphEditor 12 | Blazor;nodes;edges;Wasm;Server;SVG;WYSIWYG;customizable;scaling;panning; 13 | https://github.com/KristofferStrube/Blazor.GraphEditor 14 | git 15 | MIT 16 | 0.1.0 17 | Kristoffer Strube 18 | README.md 19 | icon.png 20 | True 21 | 22 | 23 | 24 | 25 | True 26 | \ 27 | 28 | 29 | True 30 | \ 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/Node.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Dom; 2 | using KristofferStrube.Blazor.SVGEditor; 3 | using Microsoft.AspNetCore.Components.Web; 4 | 5 | namespace KristofferStrube.Blazor.GraphEditor; 6 | 7 | /// 8 | /// The nodes of the graph. 9 | /// 10 | /// The type parameter for the data that backs the nodes in this graph. 11 | /// The type parameter for the data that backs the nodes in this graph. 12 | public class Node : Circle where TNodeData : IEquatable 13 | { 14 | /// 15 | /// Constructs a node. 16 | /// 17 | /// The SVG element that will be used as the base backing element for changes to the position of the node. 18 | /// The that the node resides in. 19 | public Node(IElement element, SVGEditor.SVGEditor svg) : base(element, svg) 20 | { 21 | GraphEditor = default!; 22 | Data = default!; 23 | } 24 | 25 | /// 26 | /// The component type that will be used to edit this shape. 27 | /// 28 | public override Type Presenter => typeof(NodeEditor); 29 | 30 | /// 31 | /// The that this node belongs to. 32 | /// 33 | public GraphEditor GraphEditor { get; set; } 34 | 35 | /// 36 | /// The data that this node will get its characteristics from. 37 | /// 38 | public TNodeData Data { get; set; } 39 | 40 | /// 41 | /// The fill color of the node mapped from the stroke color defined by . 42 | /// 43 | public override string Fill 44 | { 45 | get 46 | { 47 | int[] parts = Stroke[1..].Chunk(2).Select(part => int.Parse(part, System.Globalization.NumberStyles.HexNumber)).ToArray(); 48 | return "#" + string.Join("", parts.Select(part => Math.Min(255, part + 50).ToString("X2"))); 49 | } 50 | } 51 | 52 | /// 53 | /// The unique of the node. 54 | /// 55 | public override string? Id { get; set; } 56 | 57 | /// 58 | /// The color of the nodes stroke mapped from its . 59 | /// 60 | public override string Stroke => GraphEditor.NodeColorMapper(Data); 61 | 62 | /// 63 | /// Used to detect whether the node has changed any characteristics that will need a re-render. 64 | /// 65 | public override string StateRepresentation => base.StateRepresentation + Stroke + R.ToString() + (Image ?? ""); 66 | 67 | private double r; 68 | /// 69 | /// The radius of the node. 70 | /// 71 | public new double R { 72 | get { 73 | var currentRadius = GraphEditor.NodeRadiusMapper(Data); 74 | if (currentRadius != r) 75 | { 76 | base.R = currentRadius; 77 | r = currentRadius; 78 | } 79 | return currentRadius; 80 | } 81 | } 82 | 83 | /// 84 | /// The optional image that will be shown in the middle of the node mapped from its . 85 | /// 86 | public string? Image => GraphEditor.NodeImageMapper(Data); 87 | 88 | /// 89 | /// All edges that connect to this node. 90 | /// 91 | public HashSet> Edges { get; } = []; 92 | 93 | /// 94 | /// All nodes that are connected via to this node. 95 | /// 96 | public Dictionary> NeighborNodes { get; } = []; 97 | 98 | /// 99 | /// Handles when the shape is moved. 100 | /// 101 | /// The arguments from the pointer that is moved. 102 | public override void HandlePointerMove(PointerEventArgs eventArgs) 103 | { 104 | base.HandlePointerMove(eventArgs); 105 | if (SVG.EditMode is EditMode.Move) 106 | { 107 | foreach (Edge edge in Edges) 108 | { 109 | edge.UpdateLine(); 110 | } 111 | } 112 | } 113 | 114 | /// 115 | /// Handles when the shape is removed. 116 | /// 117 | public override void BeforeBeingRemoved() 118 | { 119 | foreach (Edge edge in Edges) 120 | { 121 | SVG.RemoveElement(edge); 122 | } 123 | } 124 | 125 | /// 126 | /// Adds a new node. 127 | /// 128 | /// The that the shape will be create in. 129 | /// The that the node resides in. 130 | /// The backing data that the node will be created from. 131 | /// The new node. 132 | public static Node AddNew(SVGEditor.SVGEditor SVG, GraphEditor graphEditor, TNodeData data) 133 | { 134 | IElement element = SVG.Document.CreateElement("CIRCLE"); 135 | element.SetAttribute("data-elementtype", "node"); 136 | 137 | Node node = new(element, SVG) 138 | { 139 | Id = graphEditor.NodeIdMapper(data), 140 | Changed = null, 141 | GraphEditor = graphEditor, 142 | Data = data 143 | }; 144 | 145 | SVG.Elements.Add(node); 146 | SVG.Document.GetElementsByTagName("BODY")[0].AppendElement(element); 147 | return node; 148 | } 149 | 150 | /// 151 | /// Creates a new node without adding it to the . 152 | /// 153 | /// The that the shape will be create in. 154 | /// The that the node resides in. 155 | /// The backing data that the node will be created from. 156 | /// The new node. 157 | public static Node CreateNew(SVGEditor.SVGEditor SVG, GraphEditor graphEditor, TNodeData data) 158 | { 159 | IElement element = SVG.Document.CreateElement("CIRCLE"); 160 | element.SetAttribute("data-elementtype", "node"); 161 | 162 | Node node = new(element, SVG) 163 | { 164 | Id = graphEditor.NodeIdMapper(data), 165 | Changed = null, 166 | GraphEditor = graphEditor, 167 | Data = data 168 | }; 169 | 170 | return node; 171 | } 172 | 173 | /// 174 | /// A unique hashcode that builds on the fact that the id must be unique. 175 | /// 176 | /// 177 | public override int GetHashCode() 178 | { 179 | return Id?.GetHashCode() ?? 0; 180 | } 181 | 182 | /// 183 | /// How this node can be equiated to any other object. 184 | /// 185 | /// The other object. 186 | /// Whether they are the same node. 187 | public override bool Equals(object? obj) 188 | { 189 | return obj is Node node && Equals(node); 190 | } 191 | 192 | /// 193 | /// Checks whether two nodes have the same id. 194 | /// 195 | /// The other node. 196 | /// Whether they are the same node. 197 | public bool Equals(Node obj) 198 | { 199 | return obj.Id?.Equals(Id) ?? false; 200 | } 201 | } -------------------------------------------------------------------------------- /src/KristofferStrube.Blazor.GraphEditor/NodeEditor.razor: -------------------------------------------------------------------------------- 1 | @using BlazorContextMenu 2 | @using KristofferStrube.Blazor.SVGEditor.ShapeEditors 3 | @using KristofferStrube.Blazor.SVGEditor.Extensions 4 | @using KristofferStrube.Blazor.SVGEditor; 5 | @using Microsoft.AspNetCore.Components.Web; 6 | 7 | @typeparam TNodeData where TNodeData : IEquatable 8 | @typeparam TEdgeData 9 | @inherits ShapeEditor> 10 | 11 | 12 | 13 | 14 | 31 | 32 | @if (SVGElement.Image is not null) 33 | { 34 | 35 | 36 | 39 | 40 | 41 | 42 | 49 | 50 | } 51 | 52 | 53 | 54 | @code { 55 | [CascadingParameter] 56 | public required GraphEditorCallbackContext CallbackContext { get; set; } 57 | 58 | public new async Task SelectAsync(MouseEventArgs eventArgs) 59 | { 60 | if (SVGElement.SVG.EditMode is EditMode.Add && SVGElement.SVG.SelectedShapes.Any(s => s is Edge)) 61 | { 62 | SVGElement.SVG.SelectedShapes.Add(SVGElement); 63 | } 64 | else 65 | { 66 | await base.SelectAsync(eventArgs); 67 | } 68 | await CallbackContext.NodeSelectionCallback(SVGElement.Id!); 69 | } 70 | } --------------------------------------------------------------------------------