EdgeRerouteStrategy.java

package com.taxonomy.export;

import com.taxonomy.diagram.DiagramNode;

import java.util.*;

/**
 * Determines the best reroute target for a suppressed node during edge rerouting.
 *
 * <p>Instead of always routing to the single highest-relevance survivor (which
 * can leave other survivors edge-less), this strategy distributes rerouted edges
 * across all survivors in the same layer using a load-balancing approach:
 * <ol>
 *   <li>If the suppressed node is a known parent of a survivor (via parentId chain),
 *       route to the direct descendant.</li>
 *   <li>If multiple survivors exist, route to the highest-scoring survivor that
 *       has not yet received more than {@value #MAX_REROUTES_PER_TARGET} rerouted edges.</li>
 *   <li>Fallback: highest relevance, same as the original behaviour.</li>
 * </ol>
 */
class EdgeRerouteStrategy {

    /** Maximum number of rerouted edges before a survivor is deprioritised. */
    static final int MAX_REROUTES_PER_TARGET = 3;

    private final Map<String, Integer> rerouteCount = new LinkedHashMap<>();

    /**
     * Selects the best reroute target for a suppressed node from the given
     * same-type survivors.
     *
     * @param suppressedNode the original node that was suppressed
     * @param sameTypeSurvivors surviving nodes in the same type/layer
     * @return the target node ID, or empty if no suitable target exists
     */
    Optional<String> selectTarget(DiagramNode suppressedNode, List<DiagramNode> sameTypeSurvivors) {
        if (sameTypeSurvivors == null || sameTypeSurvivors.isEmpty()) {
            return Optional.empty();
        }

        // Strategy 1: Direct descendant (parentId match)
        for (DiagramNode survivor : sameTypeSurvivors) {
            if (suppressedNode.id().equals(survivor.parentId())) {
                return recordAndReturn(survivor.id());
            }
        }

        // Strategy 2: Load-balanced — prefer high-relevance survivors that haven't
        // already accumulated too many rerouted edges
        Optional<DiagramNode> balanced = sameTypeSurvivors.stream()
                .filter(s -> rerouteCount.getOrDefault(s.id(), 0) < MAX_REROUTES_PER_TARGET)
                .max(Comparator.comparingDouble(DiagramNode::relevance));

        if (balanced.isPresent()) {
            return recordAndReturn(balanced.get().id());
        }

        // Strategy 3: Fallback — highest relevance regardless of load
        return sameTypeSurvivors.stream()
                .max(Comparator.comparingDouble(DiagramNode::relevance))
                .map(n -> {
                    rerouteCount.merge(n.id(), 1, Integer::sum);
                    return n.id();
                });
    }

    private Optional<String> recordAndReturn(String id) {
        rerouteCount.merge(id, 1, Integer::sum);
        return Optional.of(id);
    }
}