From a4a8282efec53aa6facf519090d9634819fdc55a Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 10 May 2026 19:12:51 +0200 Subject: [PATCH] [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 }; +})();