Skip to content

Conversation

@Math-R
Copy link

@Math-R Math-R commented Oct 7, 2025

Description

This PR implements the collapsed nodes functionality for the Netzgrafik editor, allowing intermediate nodes to be hidden while maintaining proper visual representation of train routes.

Features Implemented

1. Node Model Enhancement

  • Added isCollapsed field to Node model with getter/setter methods
  • Integrated collapsed state into node serialization/deserialization
  • Maintains backward compatibility with existing data

2. Visual Filtering System

  • NodesView: Filter out collapsed nodes from visual display
  • ConnectionsView: Hide connections associated with collapsed nodes
  • TransitionsView: Hide transitions for collapsed nodes
  • Collapsed nodes become completely invisible in the editor

3. Intelligent Section Grouping

  • TrainrunSectionService.groupTrainrunSectionsIntoChains(): Algorithm to group sections with collapsed intermediate nodes
  • Automatically detects chains like A→B→C→D where B,C are collapsed
  • Creates grouped objects with proper start/end node references

4. Direct Path Calculation

  • TrainrunSectionsView.createViewObjectForCollapsedChain(): Creates direct visual paths A→D bypassing collapsed nodes
  • Automatic routing that circumvents collapsed intermediate nodes
  • Proper port calculation for direct connections

User Experience

  • Nodes marked as collapsed become invisible
  • Train routes A→B→C→D (with B,C collapsed) display as direct A→D connections
  • Interface remains coherent and schedules are preserved
  • No impact on existing data or workflows

Technical Implementation

  • Zero breaking changes to existing data structures
  • Clean separation between data model and visual representation
  • Proper TypeScript typing throughout

Issues

Related to feature request for collapsed nodes functionality to simplify complex network display.

Checklist

  • This PR contains a description of the changes I'm making
  • I've read the Contribution Guidelines
  • Core functionality implemented and tested: Node filtering, section grouping, path calculation working
  • I've added tests for changes or features I've introduced (comprehensive tests planned for follow-up PR)
  • I documented any high-level concepts I'm introducing in documentation/ (documentation to be added in follow-up)
  • TypeScript compilation clean: No compilation errors
  • Backward compatibility preserved: No breaking changes to existing data
  • Ready for functional testing: Core collapsed nodes feature fully operational

Next Steps (Future PRs)

  • Node creation UI for collapsed nodes
  • Legacy file compatibility (numberOfStops conversion)
  • Comprehensive unit test suite
  • User documentation and examples
  • Port assignment optimization for parallel routing

@Math-R Math-R force-pushed the mrd/modify-node-display branch 4 times, most recently from 087bc7c to 185666b Compare October 7, 2025 16:41
@Math-R Math-R marked this pull request as ready for review October 7, 2025 16:41
@Math-R Math-R requested a review from aiAdrian as a code owner October 7, 2025 16:41
@Math-R Math-R self-assigned this Oct 7, 2025
@aiAdrian
Copy link
Contributor

aiAdrian commented Oct 7, 2025

Thanks you for the new feature - would it be possible to get a live demo?

@aiAdrian
Copy link
Contributor

aiAdrian commented Oct 7, 2025

I haven't done a full review yet, but at first glance I don't understand why:

  • TrainrunSectionViewObject → there's no change trigger for isCollapsed

  • NodeViewObject → there's no change trigger for isCollapsed

It seems there's no change status (generateKey) related to isCollapsed. These objects are supposed to signal that something has changed so that a visual update can be triggered (i.e., only render what has changed to improve performance). I assume that if we don't explicitly trigger the update (mark the object as changed), it won't be updated (rendered).

  displayNodes(inputNodes: Node[]) {
    const nodes = inputNodes.filter(
      (n) =>
        this.editorView.doCullCheckPositionsInViewport([
          new Vec2D(n.getPositionX(), n.getPositionY()),
          new Vec2D(n.getPositionX() + n.getNodeWidth(), n.getPositionY()),
          new Vec2D(n.getPositionX(), n.getPositionY() + n.getNodeHeight()),
          new Vec2D(n.getPositionX() + n.getNodeWidth(), n.getPositionY() + n.getNodeHeight()),
        ]) && this.filterNodesToDisplay(n),
    );

    const group = this.nodeGroup
      .selectAll(StaticDomTags.NODE_ROOT_CONTAINER_DOM_REF)
      .data(this.createViewNodeDataObjects(nodes), (n: NodeViewObject) => n.key);

... 

(see: https://github.com/OpenRailAssociation/netzgrafik-editor-frontend/blob/main/src/app/view/editor-main-view/data-views/nodes.view.ts#L106)


It would be very helpful if we could add some tests to verify that changes to isCollapsed and it's functionality are functioning as intended.

Even if this doesn't directly affect the visualization layer, it’s still important for the underlying service methods, especially in areas like data migration. The base functionality should be properly tested to ensure robustness and maintainability.


const node: Node = this.editorView.getNodeFromConnection(con);

// filter if node is collapsed - do not show connections for collapsed nodes
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nit: why the comment here and not in transitions.view.ts?

@louisgreiner
Copy link
Contributor

Context: This PR implements part of the 3 first sections of the implementation plan given here

Copy link
Contributor

@louisgreiner louisgreiner left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, not tested.

Maybe, instead of fully hiding the nodes when isCollapsed is true, you should display them as a "Dot", as it is shown here (mock-up from main issue). But maybe you've planned to do it in the next PR, ignore this comment if so.


filterNodesToDisplay(node: Node): boolean {
return this.editorView.isNodeVisible(node);
return this.editorView.isNodeVisible(node) && !node.getIsCollapsed();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: isCollapsed() would be nice instead of getIsCollapsed()

Comment on lines 1479 to 1483
groupTrainrunSectionsIntoChains(trainrunSections: TrainrunSection[]): Array<{
sections: TrainrunSection[];
startNode: Node;
endNode: Node;
}> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice function, very readable. I wonder if we could/should create a new type for the group object, if it will be reused elsewhere

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, as I simplified it thanks to @emersion it's no longer necessary

Copy link
Member

@emersion emersion left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for splitting the changes into small commits! Here are a few comments.

Copy link

@Synar Synar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made a pass, nothing to add for now. I'll do another pass after you resolved @emersion's comments.

@Math-R Math-R force-pushed the mrd/modify-node-display branch 3 times, most recently from c4cc009 to 6145bff Compare October 16, 2025 13:06
@Math-R
Copy link
Author

Math-R commented Oct 16, 2025

I haven't done a full review yet, but at first glance I don't understand why:

  • TrainrunSectionViewObject → there's no change trigger for isCollapsed
  • NodeViewObject → there's no change trigger for isCollapsed

It seems there's no change status (generateKey) related to isCollapsed. These objects are supposed to signal that something has changed so that a visual update can be triggered (i.e., only render what has changed to improve performance). I assume that if we don't explicitly trigger the update (mark the object as changed), it won't be updated (rendered).

  displayNodes(inputNodes: Node[]) {
    const nodes = inputNodes.filter(
      (n) =>
        this.editorView.doCullCheckPositionsInViewport([
          new Vec2D(n.getPositionX(), n.getPositionY()),
          new Vec2D(n.getPositionX() + n.getNodeWidth(), n.getPositionY()),
          new Vec2D(n.getPositionX(), n.getPositionY() + n.getNodeHeight()),
          new Vec2D(n.getPositionX() + n.getNodeWidth(), n.getPositionY() + n.getNodeHeight()),
        ]) && this.filterNodesToDisplay(n),
    );

    const group = this.nodeGroup
      .selectAll(StaticDomTags.NODE_ROOT_CONTAINER_DOM_REF)
      .data(this.createViewNodeDataObjects(nodes), (n: NodeViewObject) => n.key);

... 

(see: https://github.com/OpenRailAssociation/netzgrafik-editor-frontend/blob/main/src/app/view/editor-main-view/data-views/nodes.view.ts#L106)

It would be very helpful if we could add some tests to verify that changes to isCollapsed and it's functionality are functioning as intended.

Even if this doesn't directly affect the visualization layer, it’s still important for the underlying service methods, especially in areas like data migration. The base functionality should be properly tested to ensure robustness and maintainability.

Sorry for the late answer. i'm not sure to completly understand your first point. And sure, I will add some test in the next commit

@Math-R Math-R force-pushed the mrd/modify-node-display branch 2 times, most recently from a1d2b05 to fa9b231 Compare October 16, 2025 15:03
Copy link
Member

@emersion emersion left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't time to read this in full yet, sorry. Here are some comments still.

As mentioned in the implementation plan, I don't think keeping a TrainrunSectionViewObject.trainrunSection is great. I think replacing it with a trainrunSections: TrainrunSection[] field would ensure we never grab information from the first trainrun when we would need to grab it from the last, or from intermediate sections. For instance the target arrival time as mentioned below in comments, but applies to others as well.

// Check if this section is part of a collapsed chain
const collapsedChainPath = this.getCollapsedChainNodePath(trainrunSection);

if (collapsedChainPath && collapsedChainPath.length >= 2) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to have two codepaths? I think the collapsed codepath should work when the full chain just has a single element too?

- Add createViewObjectForCollapsedChain() static method to handle path routing between start/end nodes

- Replace standard TrainrunSectionViewObject creation with custom method for collapsed chains

- Path now correctly goes from first visible node to last visible node instead of using primary section's path
TrainrunSectionViewObject will represent multiple trainrun sections
soon. Add a helper to get the parent trainrun to reduce reliance
on a single trainrun.

Signed-off-by: Simon Ser <[email protected]>
This makes it clear that there isn't a single trainrun section
per TrainrunSectionViewObject: a single TrainrunSectionViewObject
can hold multiple sections. The full collapsed chain is stored.

For now, blindly replace trainrunSection with trainrunSections[0],
to be cleaned up in future commits.

Signed-off-by: Simon Ser <[email protected]>
The TrainrunSectionViewObject already contains the full chain, no
need to re-compute it.

Signed-off-by: Simon Ser <[email protected]>
Copy link

@clarani clarani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, I left some small comments and some questions :) I didn't have the time to read all the files

Comment on lines +1507 to +1509
if (visitedSections.has(pair.trainrunSection.getId())) {
break; // Already processed this section
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small question, this case should never happen right ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, normally this case should never happen, it's a relicat from previous development, but now we find the first section of the chain, thanks to the backward iterator.
But it can prevent infinite cycle if this iterator is changed in further development, maybe we can use this check to log an error and break to help and prevent regression in further development ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing an error sounds good to me.

return new BackwardNonStopTrainrunIterator(this.logService, node, trainrunSection);
}

public getExpandedIterator(node: Node, trainrunSection: TrainrunSection) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this function is used anymore

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, good catch i'll remove it

Comment on lines 165 to 166
key += "_SRC_" + d.getSourceNode().getPositionX() + "_" + d.getSourceNode().getPositionY();
key += "_TRG_" + d.getTargetNode().getPositionX() + "_" + d.getTargetNode().getPositionY();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Why do you need to add these information in the key ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i added thsi to the key, to refresh the path when boundaries nodes moves. Préviously when i was moving the arrival node, the TrS was in a disconnected visual state, until i also move the start node.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been dropped in #605.

Comment on lines 2426 to 2430
if (connectedSections.length === 1) {
currentSection = connectedSections[0];
} else {
break;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, the goal here is to break if there is no connection (because we have reached the end of the chain). Then, it would be clearer to state this in the if condition:

Suggested change
if (connectedSections.length === 1) {
currentSection = connectedSections[0];
} else {
break;
}
if (connectedSections.length === 0) {
break;
} else {
currentSection = connectedSections[0];
}

Also, is it possible to have connectedSections.length > 1 ? is it normal to break right now if it is the case ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No if you reach the end of the section there is only one connected section, the previous one, if there is 0 connectedSection is because you're not in a chain, if there is 2 you are between 2 sections

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code has been dropped in #605.

Comment on lines 294 to 295
const sourcePort =
startNode.getPortOfTrainrunSection(firstSection.getId()) ?? startNode.getPorts()[0];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, if you don't find a port with the correct section id, you take the first one ? Is it a good behavior ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yes it can end up using an already used port. I'm not sure of how to handle this one @louisgreiner ?

…Key()

We were only using the first trainrun section's path here.

Expose a getPath() method to get a view object's path. We need to
call it from inside generateKey() so drop the static attribute.

While at it, make the generateKey() helper function private because
it shouldn't be called from elsewhere.

Signed-off-by: Simon Ser <[email protected]>
Target arrival was included twice, and source arrival was missing.

Signed-off-by: Simon Ser <[email protected]>
We were only grabbing metadata from the first section, but we need
to make the key change when the last section changes.

Signed-off-by: Simon Ser <[email protected]>
Instead, grab the path from TrainrunSectionViewObject.getPath().

We need to update a few functions to take the veiw object instead
of the first trainrun section.

Signed-off-by: Simon Ser <[email protected]>
This function overwrites a trainrun section's path. Instead, pick
the right section (first or last) when computing path-related
outputs.

Signed-off-by: Simon Ser <[email protected]>
/**
* Get the value to show for collapsed chains (with corrected times)
*/
getTrainrunSectionValueToShowWithCollapsedSupport(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to merge this function with getTrainrunSectionValueToShow: the logic is very similar, just need to use the correct TrainrunSection from there instead of always picking the first one.

/**
* Apply basic filtering to a path
*/
private applyBasicFiltering(path: Vec2D[], ts: TrainrunSection): Vec2D[] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto: I'd like to drop this function and pick the trgNode from the last section in transformPath, instead of picking it from the first section.

/**
* Version that works with TrainrunSectionViewObject for collapsed chains
*/
static getAdditionPositioningValueForViewObject(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto: I'd like to fold this into getAdditionPositioningValue

* Version that works with TrainrunSectionViewObject to handle custom paths for collapsed chains
* This method needs access to the view instance to get the collapsed path
*/
static translateAndRotateTextForViewObject(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto for this function, as well as getTextPositionsForCollapsedChain, getAdditionPositioningValueForViewObjectWithCollapsedSupport and translateAndRotateTextForViewObjectWithCollapsedSupport. We shouldn't need to duplicate all of the logic here.

firstSection.getNumberOfStops() +
"_" +
d.getTravelTime() +
firstSection.getTravelTime() +
Copy link
Member

@emersion emersion Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of picking the travel time of the first section, we should compute the total travel time of the whole chain.

We already have logic for this in getTrainrunSectionValueToShowWithCollapsedSupport, I think we can add a new TrainrunSectionViewObject.getTravelTime method to centralize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants