From a4a8282efec53aa6facf519090d9634819fdc55a Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 10 May 2026 19:12:51 +0200 Subject: [PATCH 1/3] [Add] SpecObject relation matrix view; fixes #18 [Add] cancellation capability on matrix [Updarte] set related as default on matrix [Add] virtualization [Improve] scroll bar on matrix page [Add] persist Relation Matrix picker state in the URL [Add] persist Relation Matrix top-left row and column across navigations --- .../RelationMatrixPageTestFixture.cs | 286 ++++++++++ .../RelationMatrixExtensionsTestFixture.cs | 208 +++++++ .../RelationMatrix/RelationMatrixPage.razor | 138 +++++ .../RelationMatrixPage.razor.cs | 530 ++++++++++++++++++ .../RelationMatrixPage.razor.css | 104 ++++ reqifviewer/Pages/ReqIF/ReqIFStatistics.razor | 19 + reqifviewer/Pages/_Host.cshtml | 1 + .../RelationMatrixExtensions.cs | 162 ++++++ reqifviewer/Shared/SideMenu.razor | 1 + reqifviewer/wwwroot/css/app.css | 44 ++ reqifviewer/wwwroot/js/matrix-scroll.js | 58 ++ 11 files changed, 1551 insertions(+) create mode 100644 reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs create mode 100644 reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs create mode 100644 reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor create mode 100644 reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs create mode 100644 reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.css create mode 100644 reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs create mode 100644 reqifviewer/wwwroot/js/matrix-scroll.js diff --git a/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs b/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs new file mode 100644 index 0000000..1f9ee57 --- /dev/null +++ b/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs @@ -0,0 +1,286 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests.Pages.RelationMatrix +{ + using System.IO; + using System.Linq; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + + using Bunit; + + using Microsoft.AspNetCore.Components; + using Microsoft.AspNetCore.Components.Web.Virtualization; + using Microsoft.Extensions.DependencyInjection; + + using Moq; + + using NUnit.Framework; + + using Radzen.Blazor; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using reqifviewer.Pages.RelationMatrix; + + using TestContext = Bunit.TestContext; + + /// + /// Suite of tests for the Blazor component. + /// + [TestFixture] + public class RelationMatrixPageTestFixture + { + private TestContext context; + private Mock reqIfLoaderService; + private ReqIF reqIf; + + [SetUp] + public async Task SetUp() + { + this.context = new TestContext(); + this.context.JSInterop.Mode = JSRuntimeMode.Loose; + this.reqIfLoaderService = new Mock(); + + var reqifPath = Path.Combine(NUnit.Framework.TestContext.CurrentContext.TestDirectory, "TestData", "ProR_Traceability-Template-v1.0.reqif"); + var cts = new CancellationTokenSource(); + + await using var fileStream = new FileStream(reqifPath, FileMode.Open); + var loader = new ReqIFLoaderService(new ReqIFDeserializer()); + await loader.LoadAsync(fileStream, SupportedFileExtensionKind.Reqif, cts.Token); + this.reqIf = loader.ReqIFData.Single(); + + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns(loader.ReqIFData); + + this.context.Services.AddSingleton(this.reqIfLoaderService.Object); + } + + [TearDown] + public void TearDown() + { + this.context.Dispose(); + } + + [Test] + public void Verify_that_page_renders_pickers_and_matrix_for_a_loaded_ReqIF() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + var dropDowns = renderer.FindComponents>(); + Assert.That(dropDowns, Has.Count.EqualTo(2), "Expected one row and one column SpecObjectType picker"); + + var relationDropDowns = renderer.FindComponents>(); + Assert.That(relationDropDowns, Has.Count.EqualTo(1), "Expected exactly one SpecRelationType picker"); + + var swapButtons = renderer.FindComponents(); + Assert.That(swapButtons, Is.Not.Empty, "Swap-axes button should render"); + } + + [Test] + public void Verify_that_page_shows_a_friendly_message_when_the_ReqIF_identifier_is_unknown() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, "no-such-id")); + + Assert.That(renderer.Markup, Does.Contain("No ReqIF with identifier")); + } + + [Test] + public async Task Verify_that_matrix_rows_are_rendered_through_Virtualize() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + var content = this.reqIf.CoreContent; + var relation = content.SpecRelations.First(r => r.Source != null && r.Target != null); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var pageType = typeof(RelationMatrixPage); + + pageType.GetProperty("RowType", flags)!.SetValue(renderer.Instance, relation.Source.Type); + pageType.GetProperty("ColumnType", flags)!.SetValue(renderer.Instance, relation.Target.Type); + pageType.GetProperty("RelationType", flags)!.SetValue(renderer.Instance, relation.Type); + pageType.GetProperty("ShowOnlyRelated", flags)!.SetValue(renderer.Instance, false); + + var recompute = pageType.GetMethod("RecomputeMatrixAsync", flags)!; + await renderer.InvokeAsync(async () => + { + var task = (Task)recompute.Invoke(renderer.Instance, new object[] { CancellationToken.None })!; + await task; + }); + + renderer.Render(); + + var virtualizeComponents = renderer.FindComponents>(); + Assert.That(virtualizeComponents, Is.Not.Empty, + "Row axis must be rendered through Virtualize for large-matrix performance"); + } + + [Test] + public void Verify_that_query_string_seeds_picker_state_on_initial_render() + { + var content = this.reqIf.CoreContent; + var relation = content.SpecRelations.First(r => r.Source != null && r.Target != null); + + var nav = this.context.Services.GetRequiredService(); + nav.NavigateTo( + $"/reqif/{this.reqIf.TheHeader.Identifier}/relationmatrix" + + $"?row={relation.Source.Type.Identifier}" + + $"&col={relation.Target.Type.Identifier}" + + $"&rel={relation.Type.Identifier}" + + "&related=false"); + + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var pageType = typeof(RelationMatrixPage); + + Assert.Multiple(() => + { + Assert.That(pageType.GetProperty("RowType", flags)!.GetValue(renderer.Instance), + Is.SameAs(relation.Source.Type), "row query parameter must seed RowType"); + Assert.That(pageType.GetProperty("ColumnType", flags)!.GetValue(renderer.Instance), + Is.SameAs(relation.Target.Type), "col query parameter must seed ColumnType"); + Assert.That(pageType.GetProperty("RelationType", flags)!.GetValue(renderer.Instance), + Is.SameAs(relation.Type), "rel query parameter must seed RelationType"); + Assert.That(pageType.GetProperty("ShowOnlyRelated", flags)!.GetValue(renderer.Instance), + Is.EqualTo(false), "related=false in the URL must turn the ShowOnlyRelated filter off"); + }); + } + + [Test] + public async Task Verify_that_changing_a_picker_pushes_query_string_to_the_URL() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var pageType = typeof(RelationMatrixPage); + + var onAxisChanged = pageType.GetMethod("OnAxisChanged", flags)!; + await renderer.InvokeAsync(async () => + { + var task = (Task)onAxisChanged.Invoke(renderer.Instance, null)!; + await task; + }); + + var nav = this.context.Services.GetRequiredService(); + var rowType = (SpecObjectType)pageType.GetProperty("RowType", flags)!.GetValue(renderer.Instance)!; + var colType = (SpecObjectType)pageType.GetProperty("ColumnType", flags)!.GetValue(renderer.Instance)!; + var relType = (SpecRelationType)pageType.GetProperty("RelationType", flags)!.GetValue(renderer.Instance)!; + + Assert.Multiple(() => + { + Assert.That(nav.Uri, Does.Contain($"/reqif/{this.reqIf.TheHeader.Identifier}/relationmatrix?"), + "URL must point back at the matrix page route"); + Assert.That(nav.Uri, Does.Contain($"row={rowType.Identifier}"), + "Row type identifier must be encoded in the URL"); + Assert.That(nav.Uri, Does.Contain($"col={colType.Identifier}"), + "Column type identifier must be encoded in the URL"); + Assert.That(nav.Uri, Does.Contain($"rel={relType.Identifier}"), + "Relation type identifier must be encoded in the URL"); + Assert.That(nav.Uri, Does.Contain("related="), + "ShowOnlyRelated flag must always be encoded after a picker has been touched"); + }); + } + + [Test] + public async Task Verify_that_matrix_anchor_state_is_initialised_via_JSInterop() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + var content = this.reqIf.CoreContent; + var relation = content.SpecRelations.First(r => r.Source != null && r.Target != null); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var pageType = typeof(RelationMatrixPage); + + pageType.GetProperty("RowType", flags)!.SetValue(renderer.Instance, relation.Source.Type); + pageType.GetProperty("ColumnType", flags)!.SetValue(renderer.Instance, relation.Target.Type); + pageType.GetProperty("RelationType", flags)!.SetValue(renderer.Instance, relation.Type); + pageType.GetProperty("ShowOnlyRelated", flags)!.SetValue(renderer.Instance, false); + + var recompute = pageType.GetMethod("RecomputeMatrixAsync", flags)!; + await renderer.InvokeAsync(async () => + { + var task = (Task)recompute.Invoke(renderer.Instance, new object[] { CancellationToken.None })!; + await task; + }); + + renderer.Render(); + + var attachInvocations = this.context.JSInterop.Invocations + .Where(i => i.Identifier == "matrixScroll.attach") + .ToList(); + + Assert.That(attachInvocations, Is.Not.Empty, + "matrixScroll.attach must be invoked once a matrix is rendered so anchor restoration is wired up"); + + var attach = attachInvocations.Last(); + var expectedKey = + $"matrix-scroll:{this.reqIf.TheHeader.Identifier}" + + $":{relation.Source.Type.Identifier}" + + $":{relation.Target.Type.Identifier}" + + $":{relation.Type.Identifier}" + + ":all"; + + Assert.Multiple(() => + { + Assert.That(attach.Arguments[0], Is.EqualTo(".relation-matrix-wrapper"), + "First argument must be the wrapper selector"); + Assert.That(attach.Arguments[1], Is.EqualTo(expectedKey), + "Scroll key must encode ReqIF identifier + picker state"); + Assert.That(attach.Arguments[2], Is.EqualTo(32), + "Third argument is the row-height-in-px used by JS for index <-> scrollTop math"); + Assert.That(attach.Arguments[3], Is.EqualTo(36), + "Fourth argument is the cell-width-in-px used by JS for index <-> scrollLeft math"); + }); + } + + [Test] + public void Verify_that_invoking_OnCancel_outside_of_a_recompute_is_a_safe_noop() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + Assert.That(renderer.Instance.IsBusy, Is.False, "Component should not be busy after initial render"); + + var onCancel = typeof(RelationMatrixPage).GetMethod( + "OnCancel", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + + Assert.That(onCancel, Is.Not.Null, "OnCancel handler must exist on the page"); + + Assert.DoesNotThrow(() => + { + onCancel!.Invoke(renderer.Instance, null); + onCancel!.Invoke(renderer.Instance, null); + }, "OnCancel must be idempotent and safe to call when no recompute is in flight"); + + Assert.That(renderer.Instance.IsBusy, Is.False, "Cancel should not flip IsBusy"); + } + } +} diff --git a/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs b/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs new file mode 100644 index 0000000..8c94ab8 --- /dev/null +++ b/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs @@ -0,0 +1,208 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests.ReqIFExtensions +{ + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + using NUnit.Framework; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using ReqifViewer.ReqIFExtensions; + + /// + /// Suite of tests for . + /// + [TestFixture] + public class RelationMatrixExtensionsTestFixture + { + private ReqIF reqIf; + + [SetUp] + public async Task SetUp() + { + var reqIfDeserializer = new ReqIFDeserializer(); + var cts = new CancellationTokenSource(); + var reqifPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", "ProR_Traceability-Template-v1.0.reqif"); + + await using var fileStream = new FileStream(reqifPath, FileMode.Open); + var reqIfLoaderService = new ReqIFLoaderService(reqIfDeserializer); + await reqIfLoaderService.LoadAsync(fileStream, SupportedFileExtensionKind.Reqif, cts.Token); + + this.reqIf = reqIfLoaderService.ReqIFData.Single(); + } + + [Test] + public void Verify_that_BuildRelationMatrix_returns_empty_when_inputs_are_null() + { + var matrix = this.reqIf.CoreContent.BuildRelationMatrix(null, null, null); + + Assert.Multiple(() => + { + Assert.That(matrix.Rows, Is.Empty); + Assert.That(matrix.Columns, Is.Empty); + Assert.That(matrix.Cells, Is.Empty); + }); + } + + [Test] + public void Verify_that_BuildRelationMatrix_filters_rows_and_columns_by_type() + { + var content = this.reqIf.CoreContent; + var rowType = content.SpecTypes.OfType().First(); + var columnType = content.SpecTypes.OfType().First(); + var relationType = content.SpecTypes.OfType().FirstOrDefault(); + + var matrix = content.BuildRelationMatrix(rowType, columnType, relationType); + + var expectedRowCount = content.SpecObjects.Count(o => o.Type == rowType); + + Assert.Multiple(() => + { + Assert.That(matrix.Rows, Has.Count.EqualTo(expectedRowCount)); + Assert.That(matrix.Columns, Has.Count.EqualTo(expectedRowCount)); + Assert.That(matrix.Rows, Is.All.Matches(o => o.Type == rowType)); + }); + } + + [Test] + public void Verify_that_BuildRelationMatrix_marks_Forward_for_existing_relation_and_Backward_when_axes_are_swapped() + { + var content = this.reqIf.CoreContent; + + var sampleRelation = content.SpecRelations.FirstOrDefault(r => r.Source != null && r.Target != null); + Assume.That(sampleRelation, Is.Not.Null, "Sample ReqIF must contain at least one fully-formed SpecRelation"); + + var rowType = sampleRelation.Source.Type; + var columnType = sampleRelation.Target.Type; + var relationType = sampleRelation.Type; + + var matrix = content.BuildRelationMatrix(rowType, columnType, relationType); + + Assert.That( + matrix.Cells.TryGetValue((sampleRelation.Source.Identifier, sampleRelation.Target.Identifier), out var cell), + Is.True, + "Expected a cell for the sample relation's (source, target) pair"); + Assert.That(cell.Direction, Is.EqualTo(RelationDirection.Forward)); + Assert.That(cell.Forward, Is.SameAs(sampleRelation)); + + var swapped = content.BuildRelationMatrix(columnType, rowType, relationType); + + Assert.That( + swapped.Cells.TryGetValue((sampleRelation.Target.Identifier, sampleRelation.Source.Identifier), out var swappedCell), + Is.True, + "After swapping axes, the same relation must populate the (target, source) cell"); + Assert.That(swappedCell.Direction, Is.EqualTo(RelationDirection.Backward)); + Assert.That(swappedCell.Backward, Is.SameAs(sampleRelation)); + } + + [Test] + public void Verify_that_ShowOnlyRelated_filter_keeps_exactly_the_rows_and_columns_present_in_cells() + { + var content = this.reqIf.CoreContent; + + var sampleRelation = content.SpecRelations.FirstOrDefault(r => r.Source != null && r.Target != null); + Assume.That(sampleRelation, Is.Not.Null); + + var rowType = sampleRelation.Source.Type; + var columnType = sampleRelation.Target.Type; + var relationType = sampleRelation.Type; + + var matrix = content.BuildRelationMatrix(rowType, columnType, relationType); + + // Mirror the page's "Show only related" filter exactly. + var connectedRowIds = new System.Collections.Generic.HashSet(matrix.Cells.Keys.Select(k => k.rowId)); + var connectedColIds = new System.Collections.Generic.HashSet(matrix.Cells.Keys.Select(k => k.columnId)); + var visibleRows = matrix.Rows.Where(r => connectedRowIds.Contains(r.Identifier)).ToList(); + var visibleColumns = matrix.Columns.Where(c => connectedColIds.Contains(c.Identifier)).ToList(); + + Assert.Multiple(() => + { + Assert.That(visibleRows.Count, Is.EqualTo(connectedRowIds.Count), + "Number of visible rows must equal number of distinct row identifiers across all cells"); + Assert.That(visibleColumns.Count, Is.EqualTo(connectedColIds.Count), + "Number of visible columns must equal number of distinct column identifiers across all cells"); + + foreach (var row in visibleRows) + { + Assert.That( + matrix.Cells.Keys.Any(k => k.rowId == row.Identifier), + Is.True, + $"Visible row {row.Identifier} must appear in at least one populated cell"); + } + + foreach (var column in visibleColumns) + { + Assert.That( + matrix.Cells.Keys.Any(k => k.columnId == column.Identifier), + Is.True, + $"Visible column {column.Identifier} must appear in at least one populated cell"); + } + + // No row that is *not* connected sneaks through the filter. + Assert.That(visibleRows.All(r => connectedRowIds.Contains(r.Identifier)), Is.True); + Assert.That(visibleColumns.All(c => connectedColIds.Contains(c.Identifier)), Is.True); + + // Sanity bound: visible row count cannot exceed the count of distinct sources/targets that fall on the row axis. + var sourcesOfRowType = content.SpecRelations + .Where(r => r.Type == relationType && r.Source != null && r.Source.Type == rowType) + .Select(r => r.Source.Identifier); + var targetsOfRowType = content.SpecRelations + .Where(r => r.Type == relationType && r.Target != null && r.Target.Type == rowType) + .Select(r => r.Target.Identifier); + var expectedRowCap = new System.Collections.Generic.HashSet(sourcesOfRowType.Concat(targetsOfRowType)).Count; + Assert.That(visibleRows.Count, Is.LessThanOrEqualTo(expectedRowCap), + "Filter must not produce more visible rows than there are distinct row-typed SpecObjects participating in any relation of the chosen type"); + }); + } + + [Test] + public void Verify_that_BuildRelationMatrix_only_includes_relations_of_the_requested_type() + { + var content = this.reqIf.CoreContent; + var relationTypes = content.SpecTypes.OfType().ToList(); + Assume.That(relationTypes, Is.Not.Empty); + + var pickedType = relationTypes.First(); + var rowType = content.SpecTypes.OfType().First(); + var columnType = rowType; + + var matrix = content.BuildRelationMatrix(rowType, columnType, pickedType); + + foreach (var cell in matrix.Cells.Values) + { + if (cell.Forward != null) + { + Assert.That(cell.Forward.Type, Is.SameAs(pickedType)); + } + + if (cell.Backward != null) + { + Assert.That(cell.Backward.Type, Is.SameAs(pickedType)); + } + } + } + } +} diff --git a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor new file mode 100644 index 0000000..c49df5a --- /dev/null +++ b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor @@ -0,0 +1,138 @@ +@page "/reqif/{Identifier}/relationmatrix" + + +@using ReqIFSharp +@using ReqifViewer.ReqIFExtensions + +@if (this.IsLoading) +{ + +} +else if (this.reqIf == null) +{ + +

No ReqIF with identifier @this.Identifier is loaded.

+} +else +{ +
+ + + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (this.IsBusy) + { +
+
+ +
+ +
+ } + + @if (this.matrix == null || this.RowType == null || this.ColumnType == null || this.RelationType == null) + { +

Pick a row Spec Object Type, a column Spec Object Type and a Spec Relation Type to view the matrix.

+ } + else if (this.VisibleRows.Count == 0 || this.VisibleColumns.Count == 0) + { +

No SpecObjects match the current selection@(this.ShowOnlyRelated ? " with at least one relation" : "").

+ } + else + { + @if (this.ShowLargeMatrixHint) + { + + Rendering @(this.VisibleRows.Count) × @(this.VisibleColumns.Count) cells. Tip: enable + Show only related to focus on the cells that actually carry a relation. + + } + + var gridTemplate = $"var(--row-header-width) repeat({this.VisibleColumns.Count}, var(--cell-width))"; +
+
+
+ @foreach (var column in this.VisibleColumns) + { + + } +
+ +
+ + @foreach (var column in this.VisibleColumns) + { + if (this.renderedCells.TryGetValue((row.Identifier, column.Identifier), out var rendered)) + { +
+ @if (rendered.TargetUrl != null) + { + @rendered.Glyph + } + else + { + @rendered.Glyph + } +
+ } + else + { +
+ } + } +
+
+
+ } +
+} diff --git a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs new file mode 100644 index 0000000..c7632c4 --- /dev/null +++ b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs @@ -0,0 +1,530 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace reqifviewer.Pages.RelationMatrix +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + using Microsoft.AspNetCore.Components; + using Microsoft.AspNetCore.Components.Routing; + using Microsoft.AspNetCore.WebUtilities; + using Microsoft.JSInterop; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using ReqifViewer.Navigation; + using ReqifViewer.ReqIFExtensions; + + using Serilog; + + /// + /// Code-behind for : a SpecObject × SpecObject matrix + /// filtered by a chosen , displaying directional + /// arrows in cells (read-only; cells link to the existing SpecRelation detail page). + /// + /// + /// Hot path: a row × column render touches every visible cell, so all per-cell strings + /// (labels, URLs, tooltip, glyph, css class) are computed once during the recompute and + /// cached. The Razor template only does dictionary lookups. The recompute itself is async + /// and yields periodically so the user can cancel a slow build via the Cancel button. + /// New caches are built into local variables and committed to this.* in one block, + /// so a cancelled recompute leaves the previously displayed matrix on screen unchanged. + /// + public partial class RelationMatrixPage : ComponentBase, IDisposable + { + /// Soft cap for the cell count above which the user is nudged toward "Show only related". + private const int LargeMatrixCellCount = 5_000; + + /// How often the recompute yields the UI thread and checks for cancellation. + private const int YieldEvery = 250; + + /// Body-class toggle that pins the layout to the viewport on this page only (see app.css). + private const string BodyLockClass = "matrix-page-active"; + + /// Pixel height of a single matrix row. Must stay in sync with the Virtualize ItemSize attribute and the .matrix-row height in scoped CSS — used to translate between scrollTop and row index. + private const int RowHeightPx = 32; + + /// Pixel width of a single matrix data cell. Must stay in sync with --cell-width in scoped CSS — used to translate between scrollLeft and column index. + private const int CellWidthPx = 36; + + [Parameter] + public string Identifier { get; set; } + + [Inject] + public IReqIFLoaderService ReqIfLoaderService { get; set; } + + [Inject] + public IJSRuntime JSRuntime { get; set; } + + [Inject] + public NavigationManager NavigationManager { get; set; } + + public bool IsLoading { get; private set; } = true; + + /// True while a recompute is in flight; drives the inline progress bar + Cancel button. + public bool IsBusy { get; private set; } + + private ReqIF reqIf; + + private IReadOnlyList specObjectTypes = Array.Empty(); + + private IReadOnlyList specRelationTypes = Array.Empty(); + + private SpecObjectType RowType { get; set; } + + private SpecObjectType ColumnType { get; set; } + + private SpecRelationType RelationType { get; set; } + + private bool ShowOnlyRelated { get; set; } = true; + + private RelationMatrixData matrix; + + private ICollection VisibleRows { get; set; } = Array.Empty(); + + private ICollection VisibleColumns { get; set; } = Array.Empty(); + + private Dictionary rowLabels = new(); + + private Dictionary columnLabels = new(); + + private Dictionary rowUrls = new(); + + private Dictionary columnUrls = new(); + + private Dictionary<(string rowId, string columnId), RenderedCell> renderedCells = new(); + + private CancellationTokenSource cts; + + private bool ShowLargeMatrixHint => !this.ShowOnlyRelated + && this.VisibleRows.Count * this.VisibleColumns.Count > LargeMatrixCellCount; + + protected override void OnInitialized() + { + this.NavigationManager.LocationChanged += this.OnLocationChanged; + } + + protected override async Task OnParametersSetAsync() + { + try + { + this.IsLoading = true; + + this.reqIf = this.ReqIfLoaderService.ReqIFData? + .SingleOrDefault(x => x.TheHeader.Identifier == this.Identifier); + + if (this.reqIf == null) + { + return; + } + + this.specObjectTypes = this.reqIf.CoreContent.SpecTypes + .OfType().ToList(); + + this.specRelationTypes = this.reqIf.CoreContent.SpecTypes + .OfType().ToList(); + + this.RowType ??= this.specObjectTypes.FirstOrDefault(); + this.ColumnType ??= this.specObjectTypes.FirstOrDefault(); + this.RelationType ??= this.specRelationTypes.FirstOrDefault(); + + this.ApplyPickerStateFromQueryString(); + + await this.RecomputeMatrixAsync(CancellationToken.None); + } + catch (Exception e) + { + Log.ForContext().Error(e, "OnParametersSetAsync Failed"); + } + finally + { + this.IsLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + await this.JSRuntime.InvokeVoidAsync("document.body.classList.add", BodyLockClass); + } + catch (Exception e) + { + Log.ForContext().Warning(e, "Failed to apply matrix-page body lock"); + } + } + + if (this.matrix != null && this.VisibleRows.Count > 0) + { + try + { + await this.JSRuntime.InvokeVoidAsync( + "matrixScroll.attach", + ".relation-matrix-wrapper", + this.BuildScrollKey(), + RowHeightPx, + CellWidthPx); + } + catch (Exception e) + { + Log.ForContext().Warning(e, "Failed to wire matrix scroll restoration"); + } + } + } + + private async Task OnAxisChanged() + { + this.NavigationManager.NavigateTo(this.BuildMatrixUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); + await this.RunBusyAsync(this.RecomputeMatrixAsync); + } + + private async Task OnSwapAxes() + { + (this.RowType, this.ColumnType) = (this.ColumnType, this.RowType); + this.NavigationManager.NavigateTo(this.BuildMatrixUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); + await this.RunBusyAsync(this.RecomputeMatrixAsync); + } + + /// + /// Reacts to URL changes that arrive *without* a remount (typically: user edits the address bar, + /// or another component on the page issues a NavigateTo). Internal picker changes route through here + /// too, but their projected state already matches the page's fields — so the change-detection guard + /// stops them from triggering a second recompute. + /// + private void OnLocationChanged(object sender, LocationChangedEventArgs args) + { + if (this.Identifier == null) + { + return; + } + + var uri = this.NavigationManager.ToAbsoluteUri(args.Location); + if (!uri.AbsolutePath.EndsWith($"/reqif/{this.Identifier}/relationmatrix", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var oldRow = this.RowType; + var oldCol = this.ColumnType; + var oldRel = this.RelationType; + var oldShow = this.ShowOnlyRelated; + + this.ApplyPickerStateFromQueryString(); + + if (!ReferenceEquals(oldRow, this.RowType) + || !ReferenceEquals(oldCol, this.ColumnType) + || !ReferenceEquals(oldRel, this.RelationType) + || oldShow != this.ShowOnlyRelated) + { + _ = this.InvokeAsync(() => this.RunBusyAsync(this.RecomputeMatrixAsync)); + } + } + + /// + /// Reads row / col / rel / related from the current URL and projects them + /// onto / / / . + /// IDs that don't resolve to a currently-loaded spec type are silently ignored — the existing value + /// (already defaulted to first-of-its-kind in ) wins. + /// + private void ApplyPickerStateFromQueryString() + { + if (this.NavigationManager.TryGetQueryString("row", out var rowId)) + { + var match = this.specObjectTypes.FirstOrDefault(t => t.Identifier == rowId); + if (match != null) + { + this.RowType = match; + } + } + + if (this.NavigationManager.TryGetQueryString("col", out var colId)) + { + var match = this.specObjectTypes.FirstOrDefault(t => t.Identifier == colId); + if (match != null) + { + this.ColumnType = match; + } + } + + if (this.NavigationManager.TryGetQueryString("rel", out var relId)) + { + var match = this.specRelationTypes.FirstOrDefault(t => t.Identifier == relId); + if (match != null) + { + this.RelationType = match; + } + } + + if (this.NavigationManager.TryGetQueryString("related", out var relatedStr) + && bool.TryParse(relatedStr, out var related)) + { + this.ShowOnlyRelated = related; + } + } + + /// + /// Builds the sessionStorage key under which this picker view's top-left-corner anchor + /// (row + column index) is remembered. Mirrors 's key set so + /// switching the picker gives each view its own remembered anchor. + /// + private string BuildScrollKey() + { + return $"matrix-scroll:{this.Identifier}" + + $":{this.RowType?.Identifier ?? "_"}" + + $":{this.ColumnType?.Identifier ?? "_"}" + + $":{this.RelationType?.Identifier ?? "_"}" + + $":{(this.ShowOnlyRelated ? "rel" : "all")}"; + } + + /// + /// Builds the matrix-page URL from the current picker state. Used by the picker handlers when + /// they write the URL via . + /// + private string BuildMatrixUrl() + { + var query = new Dictionary(); + + if (this.RowType != null) + { + query["row"] = this.RowType.Identifier; + } + + if (this.ColumnType != null) + { + query["col"] = this.ColumnType.Identifier; + } + + if (this.RelationType != null) + { + query["rel"] = this.RelationType.Identifier; + } + + query["related"] = this.ShowOnlyRelated ? "true" : "false"; + + return QueryHelpers.AddQueryString($"/reqif/{this.Identifier}/relationmatrix", query); + } + + /// + /// Cancels the current in-flight recompute. The previously displayed matrix is preserved + /// because uses an atomic-commit pattern. + /// + private void OnCancel() + { + this.cts?.Cancel(); + } + + /// + /// Sets , paints the spinner, then runs with a + /// fresh . If the user changed selections again + /// before the previous work finished, the previous CTS is cancelled here. + /// + private async Task RunBusyAsync(Func work) + { + this.cts?.Cancel(); + this.cts?.Dispose(); + this.cts = new CancellationTokenSource(); + var ct = this.cts.Token; + + this.IsBusy = true; + await this.InvokeAsync(this.StateHasChanged); + + try + { + // Yield so the spinner paints before the (potentially heavy) recompute begins. + await Task.Yield(); + await work(ct); + } + catch (OperationCanceledException) + { + Log.ForContext().Information("Recompute cancelled"); + } + catch (Exception e) + { + Log.ForContext().Error(e, "RecomputeMatrix Failed"); + } + finally + { + this.IsBusy = false; + await this.InvokeAsync(this.StateHasChanged); + } + } + + /// + /// Computes the new matrix, visible rows / columns, and per-cell render caches into + /// local variables, yielding to the UI thread every + /// iterations and observing . Only commits the results to + /// this.* once everything has been built — so a cancellation mid-flight leaves + /// the previously rendered matrix on screen. + /// + private async Task RecomputeMatrixAsync(CancellationToken ct) + { + if (this.reqIf == null || this.RowType == null || this.ColumnType == null || this.RelationType == null) + { + this.matrix = null; + this.VisibleRows = Array.Empty(); + this.VisibleColumns = Array.Empty(); + this.rowLabels.Clear(); + this.columnLabels.Clear(); + this.rowUrls.Clear(); + this.columnUrls.Clear(); + this.renderedCells.Clear(); + return; + } + + var newMatrix = this.reqIf.CoreContent.BuildRelationMatrix(this.RowType, this.ColumnType, this.RelationType); + + ct.ThrowIfCancellationRequested(); + + ICollection newVisibleRows; + ICollection newVisibleColumns; + + if (this.ShowOnlyRelated && newMatrix.Cells.Count > 0) + { + var connectedRowIds = new HashSet(newMatrix.Cells.Keys.Select(k => k.rowId)); + var connectedColIds = new HashSet(newMatrix.Cells.Keys.Select(k => k.columnId)); + + newVisibleRows = newMatrix.Rows.Where(r => connectedRowIds.Contains(r.Identifier)).ToList(); + newVisibleColumns = newMatrix.Columns.Where(c => connectedColIds.Contains(c.Identifier)).ToList(); + } + else if (this.ShowOnlyRelated) + { + newVisibleRows = Array.Empty(); + newVisibleColumns = Array.Empty(); + } + else + { + newVisibleRows = newMatrix.Rows.ToList(); + newVisibleColumns = newMatrix.Columns.ToList(); + } + + var newRowLabels = new Dictionary(newVisibleRows.Count); + var newRowUrls = new Dictionary(newVisibleRows.Count); + var index = 0; + foreach (var row in newVisibleRows) + { + newRowLabels[row.Identifier] = ExtractDisplayNameOf(row); + newRowUrls[row.Identifier] = row.CreateUrl(); + + if (++index % YieldEvery == 0) + { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } + + var newColumnLabels = new Dictionary(newVisibleColumns.Count); + var newColumnUrls = new Dictionary(newVisibleColumns.Count); + index = 0; + foreach (var column in newVisibleColumns) + { + newColumnLabels[column.Identifier] = ExtractDisplayNameOf(column); + newColumnUrls[column.Identifier] = column.CreateUrl(); + + if (++index % YieldEvery == 0) + { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } + + var newRenderedCells = new Dictionary<(string, string), RenderedCell>(newMatrix.Cells.Count); + var relName = this.RelationType.LongName ?? this.RelationType.Identifier; + + index = 0; + foreach (var ((rowId, colId), cell) in newMatrix.Cells) + { + if (!newRowLabels.TryGetValue(rowId, out var rowLabel) + || !newColumnLabels.TryGetValue(colId, out var colLabel)) + { + continue; // cell exists for an object filtered out by ShowOnlyRelated logic + } + + var (glyph, css, tooltip) = cell.Direction switch + { + RelationDirection.Forward => ("→", "matrix-cell forward", $"{rowLabel} —{relName}→ {colLabel}"), + RelationDirection.Backward => ("←", "matrix-cell backward", $"{rowLabel} ←{relName}— {colLabel}"), + RelationDirection.Both => ("↔", "matrix-cell both", $"{rowLabel} ↔{relName}↔ {colLabel}"), + _ => (string.Empty, "matrix-cell empty", string.Empty) + }; + + var target = (cell.Forward ?? cell.Backward)?.CreateUrl(); + + newRenderedCells[(rowId, colId)] = new RenderedCell(glyph, css, tooltip, target); + + if (++index % YieldEvery == 0) + { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } + + // Final cancellation check before atomic commit. After this point we own the new state. + ct.ThrowIfCancellationRequested(); + + this.matrix = newMatrix; + this.VisibleRows = newVisibleRows; + this.VisibleColumns = newVisibleColumns; + this.rowLabels = newRowLabels; + this.columnLabels = newColumnLabels; + this.rowUrls = newRowUrls; + this.columnUrls = newColumnUrls; + this.renderedCells = newRenderedCells; + } + + public void Dispose() + { + if (this.NavigationManager != null) + { + this.NavigationManager.LocationChanged -= this.OnLocationChanged; + } + + this.cts?.Cancel(); + this.cts?.Dispose(); + this.cts = null; + + try + { + _ = this.JSRuntime?.InvokeVoidAsync("document.body.classList.remove", BodyLockClass).AsTask(); + } + catch + { + // circuit may be gone; nothing to do + } + } + + private static string ExtractDisplayNameOf(SpecObject specObject) + { + return specObject.ExtractDisplayName()?.ToString() ?? specObject.Identifier; + } + + /// + /// Per-cell rendering bundle: glyph (→ ← ↔), css class, tooltip text, and optional drill URL. + /// Built once in ; the template just emits these. + /// + private sealed record RenderedCell(string Glyph, string CssClass, string Tooltip, string TargetUrl); + } +} diff --git a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.css b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.css new file mode 100644 index 0000000..c20a4c0 --- /dev/null +++ b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.css @@ -0,0 +1,104 @@ +.matrix-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.relation-matrix-wrapper { + overflow: auto; + flex: 1; + min-height: 0; + max-height: calc(100vh - 280px); + border: 1px solid var(--rz-border-color, #dee2e6); + border-radius: var(--rz-border-radius, 4px); + background: var(--rz-base-50, #fff); + font-size: 0.85rem; + --row-header-width: 320px; + --cell-width: 36px; + --row-height: 32px; +} + + +.matrix-header-row, +.matrix-row { + display: grid; +} + +.matrix-header-row { + position: sticky; + top: 0; + z-index: 2; + height: var(--row-height); +} + +.matrix-row { + height: var(--row-height); +} + +.col-header, +.row-header, +.corner, +.matrix-cell { + border-right: 1px solid var(--rz-border-color, #dee2e6); + border-bottom: 1px solid var(--rz-border-color, #dee2e6); + background: var(--rz-base-50, #fff); + box-sizing: border-box; + height: var(--row-height); + overflow: hidden; +} + +.col-header, +.row-header { + background: var(--rz-base-100, #f6f8fa); + font-weight: 600; + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; + padding: 4px 8px; + display: flex; + align-items: center; +} + +.row-header { + position: sticky; + left: 0; + z-index: 1; +} + +.corner { + position: sticky; + top: 0; + left: 0; + z-index: 3; + background: var(--rz-base-200, #e9ecef); +} + +.matrix-cell { + text-align: center; + font-size: 1.1rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + content-visibility: auto; + contain-intrinsic-size: var(--row-height) var(--cell-width); +} + +.matrix-cell.forward { color: #1b6ec2; } +.matrix-cell.backward { color: #b35900; } +.matrix-cell.both { color: #2e7d32; font-weight: 700; } +.matrix-cell.empty { color: var(--rz-base-400, #adb5bd); } + +.matrix-header-row a, +.matrix-row a { + color: inherit; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; +} + +.matrix-header-row a:hover, +.matrix-row a:hover { + text-decoration: underline; +} diff --git a/reqifviewer/Pages/ReqIF/ReqIFStatistics.razor b/reqifviewer/Pages/ReqIF/ReqIFStatistics.razor index 03ae028..369eb96 100644 --- a/reqifviewer/Pages/ReqIF/ReqIFStatistics.razor +++ b/reqifviewer/Pages/ReqIF/ReqIFStatistics.razor @@ -129,6 +129,25 @@ else + +
diff --git a/reqifviewer/Pages/_Host.cshtml b/reqifviewer/Pages/_Host.cshtml index e59fa45..b4fec19 100644 --- a/reqifviewer/Pages/_Host.cshtml +++ b/reqifviewer/Pages/_Host.cshtml @@ -56,6 +56,7 @@ + diff --git a/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs b/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs new file mode 100644 index 0000000..3555bc6 --- /dev/null +++ b/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs @@ -0,0 +1,162 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.ReqIFExtensions +{ + using System.Collections.Generic; + using System.Linq; + + using ReqIFSharp; + + /// + /// Direction of the (s) in a relative to the + /// row and the column . + /// + public enum RelationDirection + { + /// No relation between row and column. + None, + + /// A relation exists with row as and column as . + Forward, + + /// A relation exists with column as and row as . + Backward, + + /// Relations exist in both directions. + Both + } + + /// + /// Single cell of a : the direction(s) of the underlying (s) + /// between a row and a column . + /// + public sealed class MatrixCell + { + /// The relation with row as , column as ; null if absent. + public SpecRelation Forward { get; set; } + + /// The relation with column as , row as ; null if absent. + public SpecRelation Backward { get; set; } + + /// Direction summary derived from and . + public RelationDirection Direction => + (this.Forward, this.Backward) switch + { + (null, null) => RelationDirection.None, + (not null, null) => RelationDirection.Forward, + (null, not null) => RelationDirection.Backward, + _ => RelationDirection.Both + }; + } + + /// + /// The result of : row and column + /// s and the populated s. Cells with no relation + /// are not stored — absence in means . + /// + public sealed class RelationMatrixData + { + public IReadOnlyList Rows { get; init; } + + public IReadOnlyList Columns { get; init; } + + public IReadOnlyDictionary<(string rowId, string columnId), MatrixCell> Cells { get; init; } + } + + /// + /// Provides the matrix computation that backs the relation-matrix page: for a chosen row + /// , column and + /// it returns the row/column s and the directional s. + /// + public static class RelationMatrixExtensions + { + /// + /// Build a row × column matrix of s for the given types. + /// + public static RelationMatrixData BuildRelationMatrix(this ReqIFContent content, SpecObjectType rowType, SpecObjectType columnType, SpecRelationType relationType) + { + var rows = (rowType == null + ? Enumerable.Empty() + : content.SpecObjects.Where(o => o.Type == rowType)).ToList(); + + var columns = (columnType == null + ? Enumerable.Empty() + : content.SpecObjects.Where(o => o.Type == columnType)).ToList(); + + var cells = new Dictionary<(string, string), MatrixCell>(); + + if (relationType == null || rows.Count == 0 || columns.Count == 0) + { + return new RelationMatrixData + { + Rows = rows, + Columns = columns, + Cells = cells + }; + } + + var rowIds = new HashSet(rows.Select(r => r.Identifier)); + var columnIds = new HashSet(columns.Select(c => c.Identifier)); + + foreach (var relation in content.SpecRelations.Where(r => r.Type == relationType)) + { + var sourceId = relation.Source?.Identifier; + var targetId = relation.Target?.Identifier; + + if (sourceId == null || targetId == null) + { + continue; + } + + if (rowIds.Contains(sourceId) && columnIds.Contains(targetId)) + { + var key = (sourceId, targetId); + if (!cells.TryGetValue(key, out var cell)) + { + cell = new MatrixCell(); + cells[key] = cell; + } + + cell.Forward ??= relation; + } + + if (rowIds.Contains(targetId) && columnIds.Contains(sourceId)) + { + var key = (targetId, sourceId); + if (!cells.TryGetValue(key, out var cell)) + { + cell = new MatrixCell(); + cells[key] = cell; + } + + cell.Backward ??= relation; + } + } + + return new RelationMatrixData + { + Rows = rows, + Columns = columns, + Cells = cells + }; + } + } +} diff --git a/reqifviewer/Shared/SideMenu.razor b/reqifviewer/Shared/SideMenu.razor index bb25021..f088dc2 100644 --- a/reqifviewer/Shared/SideMenu.razor +++ b/reqifviewer/Shared/SideMenu.razor @@ -44,6 +44,7 @@ + diff --git a/reqifviewer/wwwroot/css/app.css b/reqifviewer/wwwroot/css/app.css index 8beedfd..a2b61dc 100644 --- a/reqifviewer/wwwroot/css/app.css +++ b/reqifviewer/wwwroot/css/app.css @@ -48,3 +48,47 @@ a, .btn-link { right: 0.75rem; top: 0.5rem; } + +.rz-footer { + min-height: 0; + padding: 4px 16px; +} + +.rz-footer p { + margin: 0; + font-size: 0.75rem; + line-height: 1.3; +} + +body.matrix-page-active, +html:has(body.matrix-page-active) { + height: 100vh; + overflow: hidden; + margin: 0; +} + +body.matrix-page-active .rz-layout { + height: 100vh; + max-height: 100vh; +} + +body.matrix-page-active .rz-body { + flex: 1; + min-height: 0; + overflow: hidden; +} + +body.matrix-page-active .rz-content-container { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* When the matrix-page body lock is on, the flex chain in the scoped CSS + already sizes .relation-matrix-wrapper correctly — the safety-net max-height + from the scoped sheet would otherwise leave a gap above the footer. */ +body.matrix-page-active .relation-matrix-wrapper { + max-height: none; +} diff --git a/reqifviewer/wwwroot/js/matrix-scroll.js b/reqifviewer/wwwroot/js/matrix-scroll.js new file mode 100644 index 0000000..b4bac56 --- /dev/null +++ b/reqifviewer/wwwroot/js/matrix-scroll.js @@ -0,0 +1,58 @@ +window.matrixScroll = (() => { + const debounceMs = 250; + const handlers = new Map(); + + function attach(selector, key, rowHeight, cellWidth) { + const el = document.querySelector(selector); + if (!el || !rowHeight || !cellWidth) { + return; + } + + const previous = handlers.get(selector); + if (previous) { + previous.el.removeEventListener('scroll', previous.listener); + } + + let savedRow = 0; + let savedCol = 0; + try { + const raw = sessionStorage.getItem(key); + if (raw) { + const parsed = JSON.parse(raw); + if (typeof parsed.row === 'number') { + savedRow = Math.max(0, parsed.row); + } + if (typeof parsed.col === 'number') { + savedCol = Math.max(0, parsed.col); + } + } + } catch (_) { + // corrupted entry — treat as no state + } + + requestAnimationFrame(() => { + el.scrollTop = savedRow * rowHeight; + el.scrollLeft = savedCol * cellWidth; + }); + + let timer = null; + const listener = () => { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + const row = Math.max(0, Math.floor(el.scrollTop / rowHeight)); + const col = Math.max(0, Math.floor(el.scrollLeft / cellWidth)); + try { + sessionStorage.setItem(key, JSON.stringify({ row, col })); + } catch (_) { + // quota exhausted — best effort + } + }, debounceMs); + }; + el.addEventListener('scroll', listener, { passive: true }); + handlers.set(selector, { el, listener }); + } + + return { attach }; +})(); From 517c1961f89df659130c43f6cdf68828b51c81b7 Mon Sep 17 00:00:00 2001 From: samatstarion Date: Thu, 14 May 2026 10:47:55 +0200 Subject: [PATCH 2/3] [Refactor] relationship matrix into a component --- .../RelationMatrixComponentTestFixture.cs | 177 ++++++++++ .../RelationMatrixPageTestFixture.cs | 126 +------ .../RelationMatrixExtensionsTestFixture.cs | 17 + .../Components/RelationMatrixComponent.razor | 88 +++++ .../RelationMatrixComponent.razor.cs | 295 ++++++++++++++++ .../RelationMatrixComponent.razor.css} | 18 +- .../RelationMatrix/RelationMatrixPage.razor | 95 +----- .../RelationMatrixPage.razor.cs | 314 +----------------- reqifviewer/Pages/_Host.cshtml | 4 +- .../RelationMatrixExtensions.cs | 21 +- reqifviewer/wwwroot/css/app.css | 33 -- reqifviewer/wwwroot/js/matrix-scroll.js | 11 +- 12 files changed, 654 insertions(+), 545 deletions(-) create mode 100644 reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs create mode 100644 reqifviewer/Components/RelationMatrixComponent.razor create mode 100644 reqifviewer/Components/RelationMatrixComponent.razor.cs rename reqifviewer/{Pages/RelationMatrix/RelationMatrixPage.razor.css => Components/RelationMatrixComponent.razor.css} (88%) diff --git a/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs b/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs new file mode 100644 index 0000000..54c4a32 --- /dev/null +++ b/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs @@ -0,0 +1,177 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests.Components +{ + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + + using Bunit; + + using Microsoft.AspNetCore.Components.Web.Virtualization; + using Microsoft.Extensions.DependencyInjection; + + using NUnit.Framework; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using reqifviewer.Components; + + using TestContext = Bunit.TestContext; + + /// + /// Tests for : the self-contained matrix view that owns + /// the async build, render caches, scroll-restoration JS interop, and the dismissible + /// large-matrix tip. + /// + [TestFixture] + public class RelationMatrixComponentTestFixture + { + private TestContext context; + private ReqIF reqIf; + + [SetUp] + public async Task SetUp() + { + this.context = new TestContext(); + this.context.JSInterop.Mode = JSRuntimeMode.Loose; + + var reqifPath = Path.Combine(NUnit.Framework.TestContext.CurrentContext.TestDirectory, "TestData", "ProR_Traceability-Template-v1.0.reqif"); + var cts = new CancellationTokenSource(); + + await using var fileStream = new FileStream(reqifPath, FileMode.Open); + var loader = new ReqIFLoaderService(new ReqIFDeserializer()); + await loader.LoadAsync(fileStream, SupportedFileExtensionKind.Reqif, cts.Token); + this.reqIf = loader.ReqIFData.Single(); + } + + [TearDown] + public void TearDown() + { + this.context.Dispose(); + } + + [Test] + public void Verify_that_component_renders_Virtualize_for_a_loaded_relation_set() + { + var relation = this.reqIf.CoreContent.SpecRelations.First(r => r.Source != null && r.Target != null); + + var renderer = this.context.RenderComponent(p => p + .Add(x => x.ReqIf, this.reqIf) + .Add(x => x.RowType, relation.Source.Type) + .Add(x => x.ColumnType, relation.Target.Type) + .Add(x => x.RelationType, relation.Type) + .Add(x => x.ShowOnlyRelated, false)); + + // OnParametersSetAsync runs the build via Task.Run; wait for it to commit + // visible rows before asserting on the rendered Virtualize element. + renderer.WaitForState(() => !renderer.Instance.IsBusy && renderer.Instance.VisibleRows.Count > 0); + renderer.Render(); + + var virtualizeComponents = renderer.FindComponents>(); + Assert.That(virtualizeComponents, Is.Not.Empty, + "Row axis must be rendered through Virtualize for large-matrix performance"); + } + + [Test] + public void Verify_that_component_calls_matrixScroll_attach_when_a_matrix_renders() + { + var relation = this.reqIf.CoreContent.SpecRelations.First(r => r.Source != null && r.Target != null); + + var expectedKey = + $"matrix-scroll:{this.reqIf.TheHeader.Identifier}" + + $":{relation.Source.Type.Identifier}" + + $":{relation.Target.Type.Identifier}" + + $":{relation.Type.Identifier}" + + ":all"; + + var renderer = this.context.RenderComponent(p => p + .Add(x => x.ReqIf, this.reqIf) + .Add(x => x.RowType, relation.Source.Type) + .Add(x => x.ColumnType, relation.Target.Type) + .Add(x => x.RelationType, relation.Type) + .Add(x => x.ShowOnlyRelated, false) + .Add(x => x.ScrollKey, expectedKey)); + + renderer.WaitForState(() => !renderer.Instance.IsBusy && renderer.Instance.VisibleRows.Count > 0); + + // After the matrix commits, OnAfterRenderAsync should have called matrixScroll.attach. + renderer.WaitForState(() => this.context.JSInterop.Invocations.Any(i => i.Identifier == "matrixScroll.attach")); + + var attach = this.context.JSInterop.Invocations + .Where(i => i.Identifier == "matrixScroll.attach") + .Last(); + + Assert.Multiple(() => + { + Assert.That(attach.Arguments[0], Is.EqualTo(".relation-matrix-wrapper"), + "First argument must be the wrapper selector"); + Assert.That(attach.Arguments[1], Is.EqualTo(expectedKey), + "Scroll key must come from the ScrollKey parameter"); + Assert.That(attach.Arguments[2], Is.EqualTo(32), + "Third argument is the row-height-in-px used by JS for index <-> scrollTop math"); + Assert.That(attach.Arguments[3], Is.EqualTo(36), + "Fourth argument is the cell-width-in-px used by JS for index <-> scrollLeft math"); + }); + } + + [Test] + public void Verify_that_component_dismisses_the_large_matrix_hint_after_OnDismiss() + { + // A loaded ReqIF satisfies the component's "matrix is loaded" gating; we don't + // care which selectors — we'll overwrite VisibleRows/Columns via reflection to + // force the hint into the "would render" state regardless of the real fixture size. + var relation = this.reqIf.CoreContent.SpecRelations.First(r => r.Source != null && r.Target != null); + + var renderer = this.context.RenderComponent(p => p + .Add(x => x.ReqIf, this.reqIf) + .Add(x => x.RowType, relation.Source.Type) + .Add(x => x.ColumnType, relation.Target.Type) + .Add(x => x.RelationType, relation.Type) + .Add(x => x.ShowOnlyRelated, false)); + + renderer.WaitForState(() => !renderer.Instance.IsBusy); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + var componentType = typeof(RelationMatrixComponent); + + // Force a state where the hint *would* render: 80 x 80 = 6_400 visible cells + // (above the 5_000 LargeMatrixCellCount soft cap) with Show-only-related off. + var largeList = (ICollection)Enumerable.Repeat(null, 80).ToList(); + componentType.GetProperty("VisibleRows", flags)!.SetValue(renderer.Instance, largeList); + componentType.GetProperty("VisibleColumns", flags)!.SetValue(renderer.Instance, largeList); + + var showHint = componentType.GetProperty("ShowLargeMatrixHint", flags)!; + Assert.That((bool)showHint.GetValue(renderer.Instance)!, Is.True, + "Pre-condition: with 6_400 cells visible and Show-only-related off, the hint must be shown"); + + var dismiss = componentType.GetMethod("OnDismissLargeMatrixHint", flags)!; + dismiss.Invoke(renderer.Instance, null); + + Assert.That((bool)showHint.GetValue(renderer.Instance)!, Is.False, + "After OnDismissLargeMatrixHint fires, ShowLargeMatrixHint must stay false even though the matrix is still large"); + } + } +} diff --git a/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs b/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs index 1f9ee57..0558c8f 100644 --- a/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs +++ b/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs @@ -29,7 +29,6 @@ namespace ReqifViewer.Tests.Pages.RelationMatrix using Bunit; using Microsoft.AspNetCore.Components; - using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -41,12 +40,15 @@ namespace ReqifViewer.Tests.Pages.RelationMatrix using ReqIFSharp; using ReqIFSharp.Extensions.Services; + using reqifviewer.Components; using reqifviewer.Pages.RelationMatrix; using TestContext = Bunit.TestContext; /// - /// Suite of tests for the Blazor component. + /// Suite of tests for the Blazor component. Tests that + /// exercise the matrix itself (Virtualize, scroll-restoration JS interop, large-matrix tip + /// dismissal) live in . /// [TestFixture] public class RelationMatrixPageTestFixture @@ -82,7 +84,7 @@ public void TearDown() } [Test] - public void Verify_that_page_renders_pickers_and_matrix_for_a_loaded_ReqIF() + public void Verify_that_page_renders_pickers_and_hosts_the_matrix_component() { var renderer = this.context.RenderComponent(p => p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); @@ -95,6 +97,10 @@ public void Verify_that_page_renders_pickers_and_matrix_for_a_loaded_ReqIF() var swapButtons = renderer.FindComponents(); Assert.That(swapButtons, Is.Not.Empty, "Swap-axes button should render"); + + var matrixComponents = renderer.FindComponents(); + Assert.That(matrixComponents, Has.Count.EqualTo(1), + "Page must host exactly one RelationMatrixComponent below the picker card"); } [Test] @@ -106,37 +112,6 @@ public void Verify_that_page_shows_a_friendly_message_when_the_ReqIF_identifier_ Assert.That(renderer.Markup, Does.Contain("No ReqIF with identifier")); } - [Test] - public async Task Verify_that_matrix_rows_are_rendered_through_Virtualize() - { - var renderer = this.context.RenderComponent(p => - p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); - - var content = this.reqIf.CoreContent; - var relation = content.SpecRelations.First(r => r.Source != null && r.Target != null); - - const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; - var pageType = typeof(RelationMatrixPage); - - pageType.GetProperty("RowType", flags)!.SetValue(renderer.Instance, relation.Source.Type); - pageType.GetProperty("ColumnType", flags)!.SetValue(renderer.Instance, relation.Target.Type); - pageType.GetProperty("RelationType", flags)!.SetValue(renderer.Instance, relation.Type); - pageType.GetProperty("ShowOnlyRelated", flags)!.SetValue(renderer.Instance, false); - - var recompute = pageType.GetMethod("RecomputeMatrixAsync", flags)!; - await renderer.InvokeAsync(async () => - { - var task = (Task)recompute.Invoke(renderer.Instance, new object[] { CancellationToken.None })!; - await task; - }); - - renderer.Render(); - - var virtualizeComponents = renderer.FindComponents>(); - Assert.That(virtualizeComponents, Is.Not.Empty, - "Row axis must be rendered through Virtualize for large-matrix performance"); - } - [Test] public void Verify_that_query_string_seeds_picker_state_on_initial_render() { @@ -180,11 +155,7 @@ public async Task Verify_that_changing_a_picker_pushes_query_string_to_the_URL() var pageType = typeof(RelationMatrixPage); var onAxisChanged = pageType.GetMethod("OnAxisChanged", flags)!; - await renderer.InvokeAsync(async () => - { - var task = (Task)onAxisChanged.Invoke(renderer.Instance, null)!; - await task; - }); + await renderer.InvokeAsync(() => onAxisChanged.Invoke(renderer.Instance, null)); var nav = this.context.Services.GetRequiredService(); var rowType = (SpecObjectType)pageType.GetProperty("RowType", flags)!.GetValue(renderer.Instance)!; @@ -205,82 +176,5 @@ await renderer.InvokeAsync(async () => "ShowOnlyRelated flag must always be encoded after a picker has been touched"); }); } - - [Test] - public async Task Verify_that_matrix_anchor_state_is_initialised_via_JSInterop() - { - var renderer = this.context.RenderComponent(p => - p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); - - var content = this.reqIf.CoreContent; - var relation = content.SpecRelations.First(r => r.Source != null && r.Target != null); - - const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; - var pageType = typeof(RelationMatrixPage); - - pageType.GetProperty("RowType", flags)!.SetValue(renderer.Instance, relation.Source.Type); - pageType.GetProperty("ColumnType", flags)!.SetValue(renderer.Instance, relation.Target.Type); - pageType.GetProperty("RelationType", flags)!.SetValue(renderer.Instance, relation.Type); - pageType.GetProperty("ShowOnlyRelated", flags)!.SetValue(renderer.Instance, false); - - var recompute = pageType.GetMethod("RecomputeMatrixAsync", flags)!; - await renderer.InvokeAsync(async () => - { - var task = (Task)recompute.Invoke(renderer.Instance, new object[] { CancellationToken.None })!; - await task; - }); - - renderer.Render(); - - var attachInvocations = this.context.JSInterop.Invocations - .Where(i => i.Identifier == "matrixScroll.attach") - .ToList(); - - Assert.That(attachInvocations, Is.Not.Empty, - "matrixScroll.attach must be invoked once a matrix is rendered so anchor restoration is wired up"); - - var attach = attachInvocations.Last(); - var expectedKey = - $"matrix-scroll:{this.reqIf.TheHeader.Identifier}" - + $":{relation.Source.Type.Identifier}" - + $":{relation.Target.Type.Identifier}" - + $":{relation.Type.Identifier}" - + ":all"; - - Assert.Multiple(() => - { - Assert.That(attach.Arguments[0], Is.EqualTo(".relation-matrix-wrapper"), - "First argument must be the wrapper selector"); - Assert.That(attach.Arguments[1], Is.EqualTo(expectedKey), - "Scroll key must encode ReqIF identifier + picker state"); - Assert.That(attach.Arguments[2], Is.EqualTo(32), - "Third argument is the row-height-in-px used by JS for index <-> scrollTop math"); - Assert.That(attach.Arguments[3], Is.EqualTo(36), - "Fourth argument is the cell-width-in-px used by JS for index <-> scrollLeft math"); - }); - } - - [Test] - public void Verify_that_invoking_OnCancel_outside_of_a_recompute_is_a_safe_noop() - { - var renderer = this.context.RenderComponent(p => - p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); - - Assert.That(renderer.Instance.IsBusy, Is.False, "Component should not be busy after initial render"); - - var onCancel = typeof(RelationMatrixPage).GetMethod( - "OnCancel", - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - - Assert.That(onCancel, Is.Not.Null, "OnCancel handler must exist on the page"); - - Assert.DoesNotThrow(() => - { - onCancel!.Invoke(renderer.Instance, null); - onCancel!.Invoke(renderer.Instance, null); - }, "OnCancel must be idempotent and safe to call when no recompute is in flight"); - - Assert.That(renderer.Instance.IsBusy, Is.False, "Cancel should not flip IsBusy"); - } } } diff --git a/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs b/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs index 8c94ab8..0615bfb 100644 --- a/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs +++ b/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs @@ -20,6 +20,7 @@ namespace ReqifViewer.Tests.ReqIFExtensions { + using System; using System.IO; using System.Linq; using System.Threading; @@ -178,6 +179,22 @@ public void Verify_that_ShowOnlyRelated_filter_keeps_exactly_the_rows_and_column }); } + [Test] + public void Verify_that_BuildRelationMatrix_throws_when_cancellation_is_requested() + { + var content = this.reqIf.CoreContent; + var rowType = content.SpecTypes.OfType().First(); + var columnType = rowType; + var relationType = content.SpecTypes.OfType().FirstOrDefault(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.That( + () => content.BuildRelationMatrix(rowType, columnType, relationType, cts.Token), + Throws.InstanceOf()); + } + [Test] public void Verify_that_BuildRelationMatrix_only_includes_relations_of_the_requested_type() { diff --git a/reqifviewer/Components/RelationMatrixComponent.razor b/reqifviewer/Components/RelationMatrixComponent.razor new file mode 100644 index 0000000..5ce88c2 --- /dev/null +++ b/reqifviewer/Components/RelationMatrixComponent.razor @@ -0,0 +1,88 @@ +@*------------------------------------------------------------------------------ + Copyright 2021-2026 Starion Group S.A. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +------------------------------------------------------------------------------*@ + +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using ReqIFSharp + +@if (this.IsBusy) +{ +
+ + +
+} + +@if (this.matrix == null || this.RowType == null || this.ColumnType == null || this.RelationType == null) +{ +

Pick a row Spec Object Type, a column Spec Object Type and a Spec Relation Type to view the matrix.

+} +else if (this.VisibleRows.Count == 0 || this.VisibleColumns.Count == 0) +{ +

No SpecObjects match the current selection@(this.ShowOnlyRelated ? " with at least one relation" : "").

+} +else +{ + @if (this.ShowLargeMatrixHint) + { + + Rendering @(this.VisibleRows.Count) × @(this.VisibleColumns.Count) cells. Tip: enable + Show only related to focus on the cells that actually carry a relation. + + } + + var gridTemplate = $"var(--row-header-width) repeat({this.VisibleColumns.Count}, var(--cell-width))"; +
+ + +
+ + @foreach (var column in this.VisibleColumns) + { + if (this.RenderedCells.TryGetValue((row.Identifier, column.Identifier), out var rendered)) + { +
+ @if (rendered.TargetUrl != null) + { + @rendered.Glyph + } + else + { + @rendered.Glyph + } +
+ } + else + { +
+ } + } +
+
+
+} diff --git a/reqifviewer/Components/RelationMatrixComponent.razor.cs b/reqifviewer/Components/RelationMatrixComponent.razor.cs new file mode 100644 index 0000000..eae122f --- /dev/null +++ b/reqifviewer/Components/RelationMatrixComponent.razor.cs @@ -0,0 +1,295 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace reqifviewer.Components +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + using Microsoft.AspNetCore.Components; + using Microsoft.JSInterop; + + using ReqIFSharp; + + using ReqifViewer.ReqIFExtensions; + + using Serilog; + + /// + /// Renders a SpecObject × SpecObject relation matrix for a given ReqIF + selectors. Owns the + /// async build (run on the threadpool with a ), the busy + /// indicator, the render caches, the scroll-restoration JS interop, and the dismissible + /// large-matrix tip. The hosting page just supplies parameters and re-renders on changes. + /// + public partial class RelationMatrixComponent : ComponentBase, IDisposable + { + /// Soft cap for the cell count above which the user is nudged toward "Show only related". + private const int LargeMatrixCellCount = 5_000; + + /// Pixel height of a single matrix row. Must stay in sync with the Virtualize ItemSize attribute and the .matrix-row height in scoped CSS — used to translate between scrollTop and row index. + private const int RowHeightPx = 32; + + /// Pixel width of a single matrix data cell. Must stay in sync with --cell-width in scoped CSS — used to translate between scrollLeft and column index. + private const int CellWidthPx = 36; + + /// CSS selector the component-local scoped CSS applies to the scrollable wrapper. Passed to the matrixScroll JS module so it can attach/detach listeners. + private const string ScrollWrapperSelector = ".relation-matrix-wrapper"; + + [Inject] + public IJSRuntime JSRuntime { get; set; } + + [Parameter] + public ReqIF ReqIf { get; set; } + + [Parameter] + public SpecObjectType RowType { get; set; } + + [Parameter] + public SpecObjectType ColumnType { get; set; } + + [Parameter] + public SpecRelationType RelationType { get; set; } + + [Parameter] + public bool ShowOnlyRelated { get; set; } + + [Parameter] + public string ScrollKey { get; set; } + + public bool IsBusy { get; private set; } + + private RelationMatrixData matrix; + + public ICollection VisibleRows { get; private set; } = Array.Empty(); + + public ICollection VisibleColumns { get; private set; } = Array.Empty(); + + public Dictionary RowLabels { get; private set; } = new(); + + public Dictionary ColumnLabels { get; private set; } = new(); + + public Dictionary RowUrls { get; private set; } = new(); + + public Dictionary ColumnUrls { get; private set; } = new(); + + public Dictionary<(string rowId, string columnId), RenderedCell> RenderedCells { get; private set; } = new(); + + /// Set to true once the user clicks the X on the large-matrix tip. Per-mount only — resets when the component is remounted. + private bool isLargeMatrixHintDismissed; + + private CancellationTokenSource cts; + + public bool ShowLargeMatrixHint => !this.ShowOnlyRelated + && !this.isLargeMatrixHintDismissed + && this.VisibleRows.Count * this.VisibleColumns.Count > LargeMatrixCellCount; + + protected override async Task OnParametersSetAsync() + { + this.cts?.Cancel(); + this.cts?.Dispose(); + this.cts = new CancellationTokenSource(); + var ct = this.cts.Token; + + this.IsBusy = true; + await this.InvokeAsync(this.StateHasChanged); + + try + { + await Task.Yield(); + await Task.Run(() => this.BuildAndCache(ct), ct); + } + catch (OperationCanceledException) + { + Log.ForContext().Information("Matrix build cancelled (parameters changed or user clicked Cancel)"); + } + catch (Exception e) + { + Log.ForContext().Error(e, "Matrix build failed"); + } + finally + { + this.IsBusy = false; + await this.InvokeAsync(this.StateHasChanged); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (this.matrix != null && this.VisibleRows.Count > 0 && !string.IsNullOrEmpty(this.ScrollKey)) + { + try + { + await this.JSRuntime.InvokeVoidAsync( + "matrixScroll.attach", + ScrollWrapperSelector, + this.ScrollKey, + RowHeightPx, + CellWidthPx); + } + catch (Exception e) + { + Log.ForContext().Warning(e, "Failed to wire matrix scroll restoration"); + } + } + } + + private void OnCancel() + { + Log.ForContext().Information("Cancel clicked"); + this.cts?.Cancel(); + } + + private void OnDismissLargeMatrixHint() + { + this.isLargeMatrixHintDismissed = true; + } + + /// + /// Builds the matrix and all per-cell caches into local variables, observing + /// between micro-batches; commits atomically at the end so a + /// cancelled rebuild leaves the previously rendered matrix on screen. + /// + private void BuildAndCache(CancellationToken ct) + { + if (this.ReqIf == null || this.RowType == null || this.ColumnType == null || this.RelationType == null) + { + this.matrix = null; + this.VisibleRows = Array.Empty(); + this.VisibleColumns = Array.Empty(); + this.RowLabels = new(); + this.ColumnLabels = new(); + this.RowUrls = new(); + this.ColumnUrls = new(); + this.RenderedCells = new(); + return; + } + + var newMatrix = this.ReqIf.CoreContent.BuildRelationMatrix(this.RowType, this.ColumnType, this.RelationType, ct); + ct.ThrowIfCancellationRequested(); + + ICollection newVisibleRows; + ICollection newVisibleColumns; + + if (this.ShowOnlyRelated && newMatrix.Cells.Count > 0) + { + var connectedRowIds = new HashSet(newMatrix.Cells.Keys.Select(k => k.rowId)); + var connectedColIds = new HashSet(newMatrix.Cells.Keys.Select(k => k.columnId)); + + newVisibleRows = newMatrix.Rows.Where(r => connectedRowIds.Contains(r.Identifier)).ToList(); + newVisibleColumns = newMatrix.Columns.Where(c => connectedColIds.Contains(c.Identifier)).ToList(); + } + else if (this.ShowOnlyRelated) + { + newVisibleRows = Array.Empty(); + newVisibleColumns = Array.Empty(); + } + else + { + newVisibleRows = newMatrix.Rows.ToList(); + newVisibleColumns = newMatrix.Columns.ToList(); + } + + ct.ThrowIfCancellationRequested(); + + var newRowLabels = new Dictionary(newVisibleRows.Count); + var newRowUrls = new Dictionary(newVisibleRows.Count); + foreach (var row in newVisibleRows) + { + newRowLabels[row.Identifier] = ExtractDisplayNameOf(row); + newRowUrls[row.Identifier] = row.CreateUrl(); + } + + var newColumnLabels = new Dictionary(newVisibleColumns.Count); + var newColumnUrls = new Dictionary(newVisibleColumns.Count); + foreach (var column in newVisibleColumns) + { + newColumnLabels[column.Identifier] = ExtractDisplayNameOf(column); + newColumnUrls[column.Identifier] = column.CreateUrl(); + } + + ct.ThrowIfCancellationRequested(); + + var newRenderedCells = new Dictionary<(string, string), RenderedCell>(newMatrix.Cells.Count); + var relName = this.RelationType.LongName ?? this.RelationType.Identifier; + + foreach (var ((rowId, colId), cell) in newMatrix.Cells) + { + if (!newRowLabels.TryGetValue(rowId, out var rowLabel) + || !newColumnLabels.TryGetValue(colId, out var colLabel)) + { + continue; + } + + var (glyph, css, tooltip) = cell.Direction switch + { + RelationDirection.Forward => ("→", "matrix-cell forward", $"{rowLabel} —{relName}→ {colLabel}"), + RelationDirection.Backward => ("←", "matrix-cell backward", $"{rowLabel} ←{relName}— {colLabel}"), + RelationDirection.Both => ("↔", "matrix-cell both", $"{rowLabel} ↔{relName}↔ {colLabel}"), + _ => (string.Empty, "matrix-cell empty", string.Empty) + }; + + var target = (cell.Forward ?? cell.Backward)?.CreateUrl(); + + newRenderedCells[(rowId, colId)] = new RenderedCell(glyph, css, tooltip, target); + } + + ct.ThrowIfCancellationRequested(); + + this.matrix = newMatrix; + this.VisibleRows = newVisibleRows; + this.VisibleColumns = newVisibleColumns; + this.RowLabels = newRowLabels; + this.ColumnLabels = newColumnLabels; + this.RowUrls = newRowUrls; + this.ColumnUrls = newColumnUrls; + this.RenderedCells = newRenderedCells; + } + + public void Dispose() + { + this.cts?.Cancel(); + this.cts?.Dispose(); + this.cts = null; + + try + { + _ = this.JSRuntime?.InvokeVoidAsync("matrixScroll.detach", ScrollWrapperSelector).AsTask(); + } + catch + { + // circuit may be gone; nothing to do + } + } + + private static string ExtractDisplayNameOf(SpecObject specObject) + { + return specObject.ExtractDisplayName()?.ToString() ?? specObject.Identifier; + } + + /// + /// Per-cell rendering bundle: glyph (→ ← ↔), css class, tooltip text, and optional drill URL. + /// Built once in ; the template just emits these. + /// + public sealed record RenderedCell(string Glyph, string CssClass, string Tooltip, string TargetUrl); + } +} diff --git a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.css b/reqifviewer/Components/RelationMatrixComponent.razor.css similarity index 88% rename from reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.css rename to reqifviewer/Components/RelationMatrixComponent.razor.css index c20a4c0..55756c7 100644 --- a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.css +++ b/reqifviewer/Components/RelationMatrixComponent.razor.css @@ -1,15 +1,18 @@ -.matrix-page { +.matrix-busy-row { display: flex; - flex-direction: column; - height: 100%; - min-height: 0; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.matrix-busy-row > :first-child { + flex: 1; } .relation-matrix-wrapper { overflow: auto; - flex: 1; - min-height: 0; - max-height: calc(100vh - 280px); + height: calc(100vh - var(--matrix-vertical-offset, 220px)); + height: calc(100dvh - var(--matrix-vertical-offset, 220px)); border: 1px solid var(--rz-border-color, #dee2e6); border-radius: var(--rz-border-radius, 4px); background: var(--rz-base-50, #fff); @@ -19,7 +22,6 @@ --row-height: 32px; } - .matrix-header-row, .matrix-row { display: grid; diff --git a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor index c49df5a..3fd5dc9 100644 --- a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor +++ b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor @@ -16,7 +16,7 @@ -------------------------------------------------------------------------------> @using ReqIFSharp -@using ReqifViewer.ReqIFExtensions +@using reqifviewer.Components @if (this.IsLoading) { @@ -29,7 +29,6 @@ else if (this.reqIf == null) } else { -
@@ -37,102 +36,34 @@ else
+ @bind-Value="this.RowType" Change="@(_ => this.OnAxisChanged())" Style="width: 100%" />
-
+ @bind-Value="this.ColumnType" Change="@(_ => this.OnAxisChanged())" Style="width: 100%" />
+ @bind-Value="this.RelationType" Change="@(_ => this.OnAxisChanged())" Style="width: 100%" />
- +
- @if (this.IsBusy) - { -
-
- -
- -
- } - - @if (this.matrix == null || this.RowType == null || this.ColumnType == null || this.RelationType == null) - { -

Pick a row Spec Object Type, a column Spec Object Type and a Spec Relation Type to view the matrix.

- } - else if (this.VisibleRows.Count == 0 || this.VisibleColumns.Count == 0) - { -

No SpecObjects match the current selection@(this.ShowOnlyRelated ? " with at least one relation" : "").

- } - else - { - @if (this.ShowLargeMatrixHint) - { - - Rendering @(this.VisibleRows.Count) × @(this.VisibleColumns.Count) cells. Tip: enable - Show only related to focus on the cells that actually carry a relation. - - } - - var gridTemplate = $"var(--row-header-width) repeat({this.VisibleColumns.Count}, var(--cell-width))"; -
-
-
- @foreach (var column in this.VisibleColumns) - { - - } -
- -
- - @foreach (var column in this.VisibleColumns) - { - if (this.renderedCells.TryGetValue((row.Identifier, column.Identifier), out var rendered)) - { -
- @if (rendered.TargetUrl != null) - { - @rendered.Glyph - } - else - { - @rendered.Glyph - } -
- } - else - { -
- } - } -
-
-
- } -
+ } diff --git a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs index c7632c4..e6cd7a0 100644 --- a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs +++ b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs @@ -23,69 +23,37 @@ namespace reqifviewer.Pages.RelationMatrix using System; using System.Collections.Generic; using System.Linq; - using System.Threading; - using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.WebUtilities; - using Microsoft.JSInterop; using ReqIFSharp; using ReqIFSharp.Extensions.Services; using ReqifViewer.Navigation; - using ReqifViewer.ReqIFExtensions; using Serilog; /// - /// Code-behind for : a SpecObject × SpecObject matrix - /// filtered by a chosen , displaying directional - /// arrows in cells (read-only; cells link to the existing SpecRelation detail page). + /// Hosts the picker shell + URL state for the Relation Matrix view. The matrix itself — + /// async build, busy indicator, cancellation, render caches, scroll restoration, dismissible + /// tip — lives in , which receives + /// the selectors as parameters and rebuilds whenever they change. /// - /// - /// Hot path: a row × column render touches every visible cell, so all per-cell strings - /// (labels, URLs, tooltip, glyph, css class) are computed once during the recompute and - /// cached. The Razor template only does dictionary lookups. The recompute itself is async - /// and yields periodically so the user can cancel a slow build via the Cancel button. - /// New caches are built into local variables and committed to this.* in one block, - /// so a cancelled recompute leaves the previously displayed matrix on screen unchanged. - /// public partial class RelationMatrixPage : ComponentBase, IDisposable { - /// Soft cap for the cell count above which the user is nudged toward "Show only related". - private const int LargeMatrixCellCount = 5_000; - - /// How often the recompute yields the UI thread and checks for cancellation. - private const int YieldEvery = 250; - - /// Body-class toggle that pins the layout to the viewport on this page only (see app.css). - private const string BodyLockClass = "matrix-page-active"; - - /// Pixel height of a single matrix row. Must stay in sync with the Virtualize ItemSize attribute and the .matrix-row height in scoped CSS — used to translate between scrollTop and row index. - private const int RowHeightPx = 32; - - /// Pixel width of a single matrix data cell. Must stay in sync with --cell-width in scoped CSS — used to translate between scrollLeft and column index. - private const int CellWidthPx = 36; - [Parameter] public string Identifier { get; set; } [Inject] public IReqIFLoaderService ReqIfLoaderService { get; set; } - [Inject] - public IJSRuntime JSRuntime { get; set; } - [Inject] public NavigationManager NavigationManager { get; set; } public bool IsLoading { get; private set; } = true; - /// True while a recompute is in flight; drives the inline progress bar + Cancel button. - public bool IsBusy { get; private set; } - private ReqIF reqIf; private IReadOnlyList specObjectTypes = Array.Empty(); @@ -100,33 +68,12 @@ public partial class RelationMatrixPage : ComponentBase, IDisposable private bool ShowOnlyRelated { get; set; } = true; - private RelationMatrixData matrix; - - private ICollection VisibleRows { get; set; } = Array.Empty(); - - private ICollection VisibleColumns { get; set; } = Array.Empty(); - - private Dictionary rowLabels = new(); - - private Dictionary columnLabels = new(); - - private Dictionary rowUrls = new(); - - private Dictionary columnUrls = new(); - - private Dictionary<(string rowId, string columnId), RenderedCell> renderedCells = new(); - - private CancellationTokenSource cts; - - private bool ShowLargeMatrixHint => !this.ShowOnlyRelated - && this.VisibleRows.Count * this.VisibleColumns.Count > LargeMatrixCellCount; - protected override void OnInitialized() { this.NavigationManager.LocationChanged += this.OnLocationChanged; } - protected override async Task OnParametersSetAsync() + protected override void OnParametersSet() { try { @@ -151,12 +98,10 @@ protected override async Task OnParametersSetAsync() this.RelationType ??= this.specRelationTypes.FirstOrDefault(); this.ApplyPickerStateFromQueryString(); - - await this.RecomputeMatrixAsync(CancellationToken.None); } catch (Exception e) { - Log.ForContext().Error(e, "OnParametersSetAsync Failed"); + Log.ForContext().Error(e, "OnParametersSet Failed"); } finally { @@ -164,56 +109,22 @@ protected override async Task OnParametersSetAsync() } } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - try - { - await this.JSRuntime.InvokeVoidAsync("document.body.classList.add", BodyLockClass); - } - catch (Exception e) - { - Log.ForContext().Warning(e, "Failed to apply matrix-page body lock"); - } - } - - if (this.matrix != null && this.VisibleRows.Count > 0) - { - try - { - await this.JSRuntime.InvokeVoidAsync( - "matrixScroll.attach", - ".relation-matrix-wrapper", - this.BuildScrollKey(), - RowHeightPx, - CellWidthPx); - } - catch (Exception e) - { - Log.ForContext().Warning(e, "Failed to wire matrix scroll restoration"); - } - } - } - - private async Task OnAxisChanged() + private void OnAxisChanged() { this.NavigationManager.NavigateTo(this.BuildMatrixUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); - await this.RunBusyAsync(this.RecomputeMatrixAsync); } - private async Task OnSwapAxes() + private void OnSwapAxes() { (this.RowType, this.ColumnType) = (this.ColumnType, this.RowType); this.NavigationManager.NavigateTo(this.BuildMatrixUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); - await this.RunBusyAsync(this.RecomputeMatrixAsync); } /// /// Reacts to URL changes that arrive *without* a remount (typically: user edits the address bar, - /// or another component on the page issues a NavigateTo). Internal picker changes route through here - /// too, but their projected state already matches the page's fields — so the change-detection guard - /// stops them from triggering a second recompute. + /// or another component on the page issues a NavigateTo). Picker changes don't need this path — + /// they mutate state directly and Blazor's automatic re-render passes the new selectors to the + /// matrix component. /// private void OnLocationChanged(object sender, LocationChangedEventArgs args) { @@ -240,16 +151,10 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args) || !ReferenceEquals(oldRel, this.RelationType) || oldShow != this.ShowOnlyRelated) { - _ = this.InvokeAsync(() => this.RunBusyAsync(this.RecomputeMatrixAsync)); + _ = this.InvokeAsync(this.StateHasChanged); } } - /// - /// Reads row / col / rel / related from the current URL and projects them - /// onto / / / . - /// IDs that don't resolve to a currently-loaded spec type are silently ignored — the existing value - /// (already defaulted to first-of-its-kind in ) wins. - /// private void ApplyPickerStateFromQueryString() { if (this.NavigationManager.TryGetQueryString("row", out var rowId)) @@ -300,10 +205,6 @@ private string BuildScrollKey() + $":{(this.ShowOnlyRelated ? "rel" : "all")}"; } - /// - /// Builds the matrix-page URL from the current picker state. Used by the picker handlers when - /// they write the URL via . - /// private string BuildMatrixUrl() { var query = new Dictionary(); @@ -328,203 +229,12 @@ private string BuildMatrixUrl() return QueryHelpers.AddQueryString($"/reqif/{this.Identifier}/relationmatrix", query); } - /// - /// Cancels the current in-flight recompute. The previously displayed matrix is preserved - /// because uses an atomic-commit pattern. - /// - private void OnCancel() - { - this.cts?.Cancel(); - } - - /// - /// Sets , paints the spinner, then runs with a - /// fresh . If the user changed selections again - /// before the previous work finished, the previous CTS is cancelled here. - /// - private async Task RunBusyAsync(Func work) - { - this.cts?.Cancel(); - this.cts?.Dispose(); - this.cts = new CancellationTokenSource(); - var ct = this.cts.Token; - - this.IsBusy = true; - await this.InvokeAsync(this.StateHasChanged); - - try - { - // Yield so the spinner paints before the (potentially heavy) recompute begins. - await Task.Yield(); - await work(ct); - } - catch (OperationCanceledException) - { - Log.ForContext().Information("Recompute cancelled"); - } - catch (Exception e) - { - Log.ForContext().Error(e, "RecomputeMatrix Failed"); - } - finally - { - this.IsBusy = false; - await this.InvokeAsync(this.StateHasChanged); - } - } - - /// - /// Computes the new matrix, visible rows / columns, and per-cell render caches into - /// local variables, yielding to the UI thread every - /// iterations and observing . Only commits the results to - /// this.* once everything has been built — so a cancellation mid-flight leaves - /// the previously rendered matrix on screen. - /// - private async Task RecomputeMatrixAsync(CancellationToken ct) - { - if (this.reqIf == null || this.RowType == null || this.ColumnType == null || this.RelationType == null) - { - this.matrix = null; - this.VisibleRows = Array.Empty(); - this.VisibleColumns = Array.Empty(); - this.rowLabels.Clear(); - this.columnLabels.Clear(); - this.rowUrls.Clear(); - this.columnUrls.Clear(); - this.renderedCells.Clear(); - return; - } - - var newMatrix = this.reqIf.CoreContent.BuildRelationMatrix(this.RowType, this.ColumnType, this.RelationType); - - ct.ThrowIfCancellationRequested(); - - ICollection newVisibleRows; - ICollection newVisibleColumns; - - if (this.ShowOnlyRelated && newMatrix.Cells.Count > 0) - { - var connectedRowIds = new HashSet(newMatrix.Cells.Keys.Select(k => k.rowId)); - var connectedColIds = new HashSet(newMatrix.Cells.Keys.Select(k => k.columnId)); - - newVisibleRows = newMatrix.Rows.Where(r => connectedRowIds.Contains(r.Identifier)).ToList(); - newVisibleColumns = newMatrix.Columns.Where(c => connectedColIds.Contains(c.Identifier)).ToList(); - } - else if (this.ShowOnlyRelated) - { - newVisibleRows = Array.Empty(); - newVisibleColumns = Array.Empty(); - } - else - { - newVisibleRows = newMatrix.Rows.ToList(); - newVisibleColumns = newMatrix.Columns.ToList(); - } - - var newRowLabels = new Dictionary(newVisibleRows.Count); - var newRowUrls = new Dictionary(newVisibleRows.Count); - var index = 0; - foreach (var row in newVisibleRows) - { - newRowLabels[row.Identifier] = ExtractDisplayNameOf(row); - newRowUrls[row.Identifier] = row.CreateUrl(); - - if (++index % YieldEvery == 0) - { - ct.ThrowIfCancellationRequested(); - await Task.Yield(); - } - } - - var newColumnLabels = new Dictionary(newVisibleColumns.Count); - var newColumnUrls = new Dictionary(newVisibleColumns.Count); - index = 0; - foreach (var column in newVisibleColumns) - { - newColumnLabels[column.Identifier] = ExtractDisplayNameOf(column); - newColumnUrls[column.Identifier] = column.CreateUrl(); - - if (++index % YieldEvery == 0) - { - ct.ThrowIfCancellationRequested(); - await Task.Yield(); - } - } - - var newRenderedCells = new Dictionary<(string, string), RenderedCell>(newMatrix.Cells.Count); - var relName = this.RelationType.LongName ?? this.RelationType.Identifier; - - index = 0; - foreach (var ((rowId, colId), cell) in newMatrix.Cells) - { - if (!newRowLabels.TryGetValue(rowId, out var rowLabel) - || !newColumnLabels.TryGetValue(colId, out var colLabel)) - { - continue; // cell exists for an object filtered out by ShowOnlyRelated logic - } - - var (glyph, css, tooltip) = cell.Direction switch - { - RelationDirection.Forward => ("→", "matrix-cell forward", $"{rowLabel} —{relName}→ {colLabel}"), - RelationDirection.Backward => ("←", "matrix-cell backward", $"{rowLabel} ←{relName}— {colLabel}"), - RelationDirection.Both => ("↔", "matrix-cell both", $"{rowLabel} ↔{relName}↔ {colLabel}"), - _ => (string.Empty, "matrix-cell empty", string.Empty) - }; - - var target = (cell.Forward ?? cell.Backward)?.CreateUrl(); - - newRenderedCells[(rowId, colId)] = new RenderedCell(glyph, css, tooltip, target); - - if (++index % YieldEvery == 0) - { - ct.ThrowIfCancellationRequested(); - await Task.Yield(); - } - } - - // Final cancellation check before atomic commit. After this point we own the new state. - ct.ThrowIfCancellationRequested(); - - this.matrix = newMatrix; - this.VisibleRows = newVisibleRows; - this.VisibleColumns = newVisibleColumns; - this.rowLabels = newRowLabels; - this.columnLabels = newColumnLabels; - this.rowUrls = newRowUrls; - this.columnUrls = newColumnUrls; - this.renderedCells = newRenderedCells; - } - public void Dispose() { if (this.NavigationManager != null) { this.NavigationManager.LocationChanged -= this.OnLocationChanged; } - - this.cts?.Cancel(); - this.cts?.Dispose(); - this.cts = null; - - try - { - _ = this.JSRuntime?.InvokeVoidAsync("document.body.classList.remove", BodyLockClass).AsTask(); - } - catch - { - // circuit may be gone; nothing to do - } - } - - private static string ExtractDisplayNameOf(SpecObject specObject) - { - return specObject.ExtractDisplayName()?.ToString() ?? specObject.Identifier; } - - /// - /// Per-cell rendering bundle: glyph (→ ← ↔), css class, tooltip text, and optional drill URL. - /// Built once in ; the template just emits these. - /// - private sealed record RenderedCell(string Glyph, string CssClass, string Tooltip, string TargetUrl); } } diff --git a/reqifviewer/Pages/_Host.cshtml b/reqifviewer/Pages/_Host.cshtml index b4fec19..60791f0 100644 --- a/reqifviewer/Pages/_Host.cshtml +++ b/reqifviewer/Pages/_Host.cshtml @@ -36,8 +36,8 @@ ReqIF Viewer - - + + diff --git a/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs b/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs index 3555bc6..6ef845d 100644 --- a/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs +++ b/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs @@ -22,6 +22,7 @@ namespace ReqifViewer.ReqIFExtensions { using System.Collections.Generic; using System.Linq; + using System.Threading; using ReqIFSharp; @@ -88,11 +89,23 @@ public sealed class RelationMatrixData /// public static class RelationMatrixExtensions { + /// + /// How often the relation scan checks the . Power-of-two so the + /// loop can use a bitmask instead of modulo — keeps the inner check ~1 cycle on a 1 M-relation file. + /// + private const int CancellationCheckEvery = 512; + /// /// Build a row × column matrix of s for the given types. /// - public static RelationMatrixData BuildRelationMatrix(this ReqIFContent content, SpecObjectType rowType, SpecObjectType columnType, SpecRelationType relationType) + /// + /// The relation scan checks every + /// iterations so a caller running this on the threadpool can interrupt it on a long ReqIF. + /// + public static RelationMatrixData BuildRelationMatrix(this ReqIFContent content, SpecObjectType rowType, SpecObjectType columnType, SpecRelationType relationType, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var rows = (rowType == null ? Enumerable.Empty() : content.SpecObjects.Where(o => o.Type == rowType)).ToList(); @@ -116,8 +129,14 @@ public static RelationMatrixData BuildRelationMatrix(this ReqIFContent content, var rowIds = new HashSet(rows.Select(r => r.Identifier)); var columnIds = new HashSet(columns.Select(c => c.Identifier)); + var index = 0; foreach (var relation in content.SpecRelations.Where(r => r.Type == relationType)) { + if ((++index & (CancellationCheckEvery - 1)) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + var sourceId = relation.Source?.Identifier; var targetId = relation.Target?.Identifier; diff --git a/reqifviewer/wwwroot/css/app.css b/reqifviewer/wwwroot/css/app.css index a2b61dc..ba5c365 100644 --- a/reqifviewer/wwwroot/css/app.css +++ b/reqifviewer/wwwroot/css/app.css @@ -59,36 +59,3 @@ a, .btn-link { font-size: 0.75rem; line-height: 1.3; } - -body.matrix-page-active, -html:has(body.matrix-page-active) { - height: 100vh; - overflow: hidden; - margin: 0; -} - -body.matrix-page-active .rz-layout { - height: 100vh; - max-height: 100vh; -} - -body.matrix-page-active .rz-body { - flex: 1; - min-height: 0; - overflow: hidden; -} - -body.matrix-page-active .rz-content-container { - height: 100%; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* When the matrix-page body lock is on, the flex chain in the scoped CSS - already sizes .relation-matrix-wrapper correctly — the safety-net max-height - from the scoped sheet would otherwise leave a gap above the footer. */ -body.matrix-page-active .relation-matrix-wrapper { - max-height: none; -} diff --git a/reqifviewer/wwwroot/js/matrix-scroll.js b/reqifviewer/wwwroot/js/matrix-scroll.js index b4bac56..5060118 100644 --- a/reqifviewer/wwwroot/js/matrix-scroll.js +++ b/reqifviewer/wwwroot/js/matrix-scroll.js @@ -54,5 +54,14 @@ window.matrixScroll = (() => { handlers.set(selector, { el, listener }); } - return { attach }; + function detach(selector) { + const previous = handlers.get(selector); + if (!previous) { + return; + } + previous.el.removeEventListener('scroll', previous.listener); + handlers.delete(selector); + } + + return { attach, detach }; })(); From a0df0eb82923d99dbac82f80cc1891c2ddc8b1cb Mon Sep 17 00:00:00 2001 From: samatstarion Date: Fri, 15 May 2026 10:10:19 +0200 Subject: [PATCH 3/3] [Fix] harden RelationMatrixComponent: snapshot inputs, gate commits on CTS ownership, deepen cancellation, scope scroll restoration --- .../RelationMatrixComponentTestFixture.cs | 7 +- .../Components/RelationMatrixComponent.razor | 2 +- .../RelationMatrixComponent.razor.cs | 230 ++++++++++++------ reqifviewer/wwwroot/js/matrix-scroll.js | 31 ++- 4 files changed, 182 insertions(+), 88 deletions(-) diff --git a/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs b/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs index 54c4a32..de81adb 100644 --- a/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs +++ b/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs @@ -29,6 +29,7 @@ namespace ReqifViewer.Tests.Components using Bunit; + using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.Extensions.DependencyInjection; @@ -126,8 +127,10 @@ public void Verify_that_component_calls_matrixScroll_attach_when_a_matrix_render Assert.Multiple(() => { - Assert.That(attach.Arguments[0], Is.EqualTo(".relation-matrix-wrapper"), - "First argument must be the wrapper selector"); + Assert.That(attach.Arguments[0], Is.Not.EqualTo(".relation-matrix-wrapper"), + "First argument must no longer be the legacy shared class selector"); + Assert.That(attach.Arguments[0], Is.InstanceOf(), + "First argument must be the component instance's wrapper ElementReference"); Assert.That(attach.Arguments[1], Is.EqualTo(expectedKey), "Scroll key must come from the ScrollKey parameter"); Assert.That(attach.Arguments[2], Is.EqualTo(32), diff --git a/reqifviewer/Components/RelationMatrixComponent.razor b/reqifviewer/Components/RelationMatrixComponent.razor index 5ce88c2..1a8475d 100644 --- a/reqifviewer/Components/RelationMatrixComponent.razor +++ b/reqifviewer/Components/RelationMatrixComponent.razor @@ -47,7 +47,7 @@ else } var gridTemplate = $"var(--row-header-width) repeat({this.VisibleColumns.Count}, var(--cell-width))"; -
+
@foreach (var column in this.VisibleColumns) diff --git a/reqifviewer/Components/RelationMatrixComponent.razor.cs b/reqifviewer/Components/RelationMatrixComponent.razor.cs index eae122f..6d92403 100644 --- a/reqifviewer/Components/RelationMatrixComponent.razor.cs +++ b/reqifviewer/Components/RelationMatrixComponent.razor.cs @@ -52,8 +52,11 @@ public partial class RelationMatrixComponent : ComponentBase, IDisposable /// Pixel width of a single matrix data cell. Must stay in sync with --cell-width in scoped CSS — used to translate between scrollLeft and column index. private const int CellWidthPx = 36; - /// CSS selector the component-local scoped CSS applies to the scrollable wrapper. Passed to the matrixScroll JS module so it can attach/detach listeners. - private const string ScrollWrapperSelector = ".relation-matrix-wrapper"; + /// How often the per-axis/label/cell loops in the builder check the cancellation token. Power-of-two so the check is a bitmask, not a modulo. + private const int BuilderCancellationCheckEvery = 256; + + /// The scrollable matrix wrapper element, handed to the matrixScroll JS module so attach/detach are scoped to this component instance rather than a shared selector. + private ElementReference wrapperRef; [Inject] public IJSRuntime JSRuntime { get; set; } @@ -105,10 +108,20 @@ public partial class RelationMatrixComponent : ComponentBase, IDisposable protected override async Task OnParametersSetAsync() { + // Snapshot inputs — the build runs on the threadpool and must not race a fresh + // parameter set arriving while it is mid-flight. + var reqIf = this.ReqIf; + var rowType = this.RowType; + var columnType = this.ColumnType; + var relationType = this.RelationType; + var showOnlyRelated = this.ShowOnlyRelated; + + // Cancel any in-flight build and start a new generation we own a handle to. this.cts?.Cancel(); this.cts?.Dispose(); - this.cts = new CancellationTokenSource(); - var ct = this.cts.Token; + var operationCts = new CancellationTokenSource(); + this.cts = operationCts; + var ct = operationCts.Token; this.IsBusy = true; await this.InvokeAsync(this.StateHasChanged); @@ -116,7 +129,16 @@ protected override async Task OnParametersSetAsync() try { await Task.Yield(); - await Task.Run(() => this.BuildAndCache(ct), ct); + var result = await Task.Run( + () => BuildResult(reqIf, rowType, columnType, relationType, showOnlyRelated, ct), + ct); + + // Commit only if we still own the current generation — a newer + // OnParametersSetAsync run will have replaced this.cts. + if (ReferenceEquals(this.cts, operationCts)) + { + this.ApplyResult(result); + } } catch (OperationCanceledException) { @@ -128,8 +150,13 @@ protected override async Task OnParametersSetAsync() } finally { - this.IsBusy = false; - await this.InvokeAsync(this.StateHasChanged); + // Only the owning generation clears IsBusy; a stale finally must not hide + // the progress bar while a newer build is still running. + if (ReferenceEquals(this.cts, operationCts)) + { + this.IsBusy = false; + await this.InvokeAsync(this.StateHasChanged); + } } } @@ -141,7 +168,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { await this.JSRuntime.InvokeVoidAsync( "matrixScroll.attach", - ScrollWrapperSelector, + this.wrapperRef, this.ScrollKey, RowHeightPx, CellWidthPx); @@ -165,77 +192,108 @@ private void OnDismissLargeMatrixHint() } /// - /// Builds the matrix and all per-cell caches into local variables, observing - /// between micro-batches; commits atomically at the end so a - /// cancelled rebuild leaves the previously rendered matrix on screen. + /// Pure, threadpool-safe build. Produces the matrix and every per-cell cache from the + /// snapshotted inputs only — reads no this.* — so the renderer can commit the + /// result back onto the component only when this operation still owns the generation. + /// Observes in every loop that grows with the ReqIF. /// - private void BuildAndCache(CancellationToken ct) + private static MatrixBuildResult BuildResult(ReqIF reqIf, SpecObjectType rowType, SpecObjectType columnType, SpecRelationType relationType, bool showOnlyRelated, CancellationToken ct) { - if (this.ReqIf == null || this.RowType == null || this.ColumnType == null || this.RelationType == null) + if (reqIf == null || rowType == null || columnType == null || relationType == null) { - this.matrix = null; - this.VisibleRows = Array.Empty(); - this.VisibleColumns = Array.Empty(); - this.RowLabels = new(); - this.ColumnLabels = new(); - this.RowUrls = new(); - this.ColumnUrls = new(); - this.RenderedCells = new(); - return; + return MatrixBuildResult.Empty; } - var newMatrix = this.ReqIf.CoreContent.BuildRelationMatrix(this.RowType, this.ColumnType, this.RelationType, ct); + var matrix = reqIf.CoreContent.BuildRelationMatrix(rowType, columnType, relationType, ct); ct.ThrowIfCancellationRequested(); - ICollection newVisibleRows; - ICollection newVisibleColumns; + var visibleRows = FilterVisible(matrix.Rows, matrix.Cells.Keys, k => k.rowId, showOnlyRelated, matrix.Cells.Count, ct); + var visibleColumns = FilterVisible(matrix.Columns, matrix.Cells.Keys, k => k.columnId, showOnlyRelated, matrix.Cells.Count, ct); - if (this.ShowOnlyRelated && newMatrix.Cells.Count > 0) - { - var connectedRowIds = new HashSet(newMatrix.Cells.Keys.Select(k => k.rowId)); - var connectedColIds = new HashSet(newMatrix.Cells.Keys.Select(k => k.columnId)); + var (rowLabels, rowUrls) = BuildLabelsAndUrls(visibleRows, ct); + var (columnLabels, columnUrls) = BuildLabelsAndUrls(visibleColumns, ct); - newVisibleRows = newMatrix.Rows.Where(r => connectedRowIds.Contains(r.Identifier)).ToList(); - newVisibleColumns = newMatrix.Columns.Where(c => connectedColIds.Contains(c.Identifier)).ToList(); - } - else if (this.ShowOnlyRelated) + var renderedCells = BuildRenderedCells(matrix.Cells, rowLabels, columnLabels, relationType, ct); + + return new MatrixBuildResult(matrix, visibleRows, visibleColumns, rowLabels, columnLabels, rowUrls, columnUrls, renderedCells); + } + + /// + /// Filters the matrix axis to the visible s, honouring the + /// "show only related" toggle, checking periodically. + /// + private static ICollection FilterVisible(IReadOnlyList all, IEnumerable<(string rowId, string columnId)> cellKeys, Func<(string rowId, string columnId), string> idSelector, bool showOnlyRelated, int cellCount, CancellationToken ct) + { + if (showOnlyRelated && cellCount == 0) { - newVisibleRows = Array.Empty(); - newVisibleColumns = Array.Empty(); + return Array.Empty(); } - else + + HashSet connected = null; + if (showOnlyRelated) { - newVisibleRows = newMatrix.Rows.ToList(); - newVisibleColumns = newMatrix.Columns.ToList(); + connected = new HashSet(cellKeys.Select(idSelector)); } - ct.ThrowIfCancellationRequested(); - - var newRowLabels = new Dictionary(newVisibleRows.Count); - var newRowUrls = new Dictionary(newVisibleRows.Count); - foreach (var row in newVisibleRows) + var result = new List(all.Count); + var i = 0; + foreach (var o in all) { - newRowLabels[row.Identifier] = ExtractDisplayNameOf(row); - newRowUrls[row.Identifier] = row.CreateUrl(); + if ((++i & (BuilderCancellationCheckEvery - 1)) == 0) + { + ct.ThrowIfCancellationRequested(); + } + + if (connected == null || connected.Contains(o.Identifier)) + { + result.Add(o); + } } - var newColumnLabels = new Dictionary(newVisibleColumns.Count); - var newColumnUrls = new Dictionary(newVisibleColumns.Count); - foreach (var column in newVisibleColumns) + return result; + } + + /// + /// Builds the display-name and drill-down URL caches for an axis, checking + /// periodically. + /// + private static (Dictionary labels, Dictionary urls) BuildLabelsAndUrls(ICollection objects, CancellationToken ct) + { + var labels = new Dictionary(objects.Count); + var urls = new Dictionary(objects.Count); + var i = 0; + foreach (var o in objects) { - newColumnLabels[column.Identifier] = ExtractDisplayNameOf(column); - newColumnUrls[column.Identifier] = column.CreateUrl(); - } + if ((++i & (BuilderCancellationCheckEvery - 1)) == 0) + { + ct.ThrowIfCancellationRequested(); + } - ct.ThrowIfCancellationRequested(); + labels[o.Identifier] = ExtractDisplayNameOf(o); + urls[o.Identifier] = o.CreateUrl(); + } - var newRenderedCells = new Dictionary<(string, string), RenderedCell>(newMatrix.Cells.Count); - var relName = this.RelationType.LongName ?? this.RelationType.Identifier; + return (labels, urls); + } - foreach (var ((rowId, colId), cell) in newMatrix.Cells) + /// + /// Builds the per-cell render bundles (glyph, css class, tooltip, drill URL), checking + /// periodically. + /// + private static Dictionary<(string, string), RenderedCell> BuildRenderedCells(IReadOnlyDictionary<(string rowId, string columnId), MatrixCell> cells, Dictionary rowLabels, Dictionary columnLabels, SpecRelationType relationType, CancellationToken ct) + { + var rendered = new Dictionary<(string, string), RenderedCell>(cells.Count); + var relName = relationType.LongName ?? relationType.Identifier; + var i = 0; + foreach (var ((rowId, colId), cell) in cells) { - if (!newRowLabels.TryGetValue(rowId, out var rowLabel) - || !newColumnLabels.TryGetValue(colId, out var colLabel)) + if ((++i & (BuilderCancellationCheckEvery - 1)) == 0) + { + ct.ThrowIfCancellationRequested(); + } + + if (!rowLabels.TryGetValue(rowId, out var rowLabel) + || !columnLabels.TryGetValue(colId, out var colLabel)) { continue; } @@ -250,19 +308,27 @@ private void BuildAndCache(CancellationToken ct) var target = (cell.Forward ?? cell.Backward)?.CreateUrl(); - newRenderedCells[(rowId, colId)] = new RenderedCell(glyph, css, tooltip, target); + rendered[(rowId, colId)] = new RenderedCell(glyph, css, tooltip, target); } - ct.ThrowIfCancellationRequested(); + return rendered; + } - this.matrix = newMatrix; - this.VisibleRows = newVisibleRows; - this.VisibleColumns = newVisibleColumns; - this.RowLabels = newRowLabels; - this.ColumnLabels = newColumnLabels; - this.RowUrls = newRowUrls; - this.ColumnUrls = newColumnUrls; - this.RenderedCells = newRenderedCells; + /// + /// Copies a completed onto the component. Called only + /// when the producing operation still owns the current generation, so a cancelled or + /// superseded build never mutates component state. + /// + private void ApplyResult(MatrixBuildResult result) + { + this.matrix = result.Matrix; + this.VisibleRows = result.VisibleRows; + this.VisibleColumns = result.VisibleColumns; + this.RowLabels = result.RowLabels; + this.ColumnLabels = result.ColumnLabels; + this.RowUrls = result.RowUrls; + this.ColumnUrls = result.ColumnUrls; + this.RenderedCells = result.RenderedCells; } public void Dispose() @@ -273,7 +339,7 @@ public void Dispose() try { - _ = this.JSRuntime?.InvokeVoidAsync("matrixScroll.detach", ScrollWrapperSelector).AsTask(); + _ = this.JSRuntime?.InvokeVoidAsync("matrixScroll.detach", this.wrapperRef).AsTask(); } catch { @@ -288,8 +354,34 @@ private static string ExtractDisplayNameOf(SpecObject specObject) /// /// Per-cell rendering bundle: glyph (→ ← ↔), css class, tooltip text, and optional drill URL. - /// Built once in ; the template just emits these. + /// Built once in ; the template just emits these. /// public sealed record RenderedCell(string Glyph, string CssClass, string Tooltip, string TargetUrl); + + /// + /// Immutable output of . Produced entirely from snapshotted + /// inputs so the renderer can commit it back onto the component (via + /// ) only when the build still owns the current generation. + /// + private sealed record MatrixBuildResult( + RelationMatrixData Matrix, + ICollection VisibleRows, + ICollection VisibleColumns, + Dictionary RowLabels, + Dictionary ColumnLabels, + Dictionary RowUrls, + Dictionary ColumnUrls, + Dictionary<(string rowId, string columnId), RenderedCell> RenderedCells) + { + public static MatrixBuildResult Empty { get; } = new( + null, + Array.Empty(), + Array.Empty(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary<(string, string), RenderedCell>()); + } } } diff --git a/reqifviewer/wwwroot/js/matrix-scroll.js b/reqifviewer/wwwroot/js/matrix-scroll.js index 5060118..ac9478c 100644 --- a/reqifviewer/wwwroot/js/matrix-scroll.js +++ b/reqifviewer/wwwroot/js/matrix-scroll.js @@ -1,16 +1,15 @@ window.matrixScroll = (() => { const debounceMs = 250; - const handlers = new Map(); + const handlers = new Map(); // element -> { listener } - function attach(selector, key, rowHeight, cellWidth) { - const el = document.querySelector(selector); - if (!el || !rowHeight || !cellWidth) { + function attach(element, key, rowHeight, cellWidth) { + if (!element || !rowHeight || !cellWidth) { return; } - const previous = handlers.get(selector); + const previous = handlers.get(element); if (previous) { - previous.el.removeEventListener('scroll', previous.listener); + element.removeEventListener('scroll', previous.listener); } let savedRow = 0; @@ -31,8 +30,8 @@ window.matrixScroll = (() => { } requestAnimationFrame(() => { - el.scrollTop = savedRow * rowHeight; - el.scrollLeft = savedCol * cellWidth; + element.scrollTop = savedRow * rowHeight; + element.scrollLeft = savedCol * cellWidth; }); let timer = null; @@ -41,8 +40,8 @@ window.matrixScroll = (() => { clearTimeout(timer); } timer = setTimeout(() => { - const row = Math.max(0, Math.floor(el.scrollTop / rowHeight)); - const col = Math.max(0, Math.floor(el.scrollLeft / cellWidth)); + const row = Math.max(0, Math.floor(element.scrollTop / rowHeight)); + const col = Math.max(0, Math.floor(element.scrollLeft / cellWidth)); try { sessionStorage.setItem(key, JSON.stringify({ row, col })); } catch (_) { @@ -50,17 +49,17 @@ window.matrixScroll = (() => { } }, debounceMs); }; - el.addEventListener('scroll', listener, { passive: true }); - handlers.set(selector, { el, listener }); + element.addEventListener('scroll', listener, { passive: true }); + handlers.set(element, { listener }); } - function detach(selector) { - const previous = handlers.get(selector); + function detach(element) { + const previous = handlers.get(element); if (!previous) { return; } - previous.el.removeEventListener('scroll', previous.listener); - handlers.delete(selector); + element.removeEventListener('scroll', previous.listener); + handlers.delete(element); } return { attach, detach };