Co-Study4Grid / frontend /src /utils /svg /edgeInfoDeclutter.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
9.17 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
/**
* Edge-info flow-label de-cluttering by *sliding along the edge*.
*
* pypowsybl places each branch's two flow values (the `<text class="nad-active">`
* + direction arrow) at ~22 % from each terminal, in fixed user-space units, with
* NO label de-collision. On a geographic layout the values that fan out of a busy
* substation all land near that shared node and pile up into an unreadable blob —
* the operator sees flow values "missing" because they overprint each other.
*
* Rather than HIDE overlapping values (so all flows stay visible), this pass
* nudges each overlapping label a bit further along its own edge — away from the
* crowd — until they separate. Because the values are vector `<text>` that scale
* with the viewBox, overlap is **invariant to zoom in user space**, so this runs
* ONCE at diagram-processing time (in `boostSvgForLargeGrid`), never per frame —
* it has zero effect on pan/zoom gesture cost.
*
* The motion is constrained to each label's edge tangent (so values never float
* off their line) and **capped per label**: a label may slide at most
* `maxSlideToMid` along the +tangent (toward its branch mid-point) and a little
* the other way, so on short dense-core edges it can't overshoot past mid onto a
* neighbouring branch. The displacement direction is emergent mutual repulsion:
* for a substation cluster the crowd centre is the shared node, so "away from the
* crowd" is "toward mid-segment" — exactly the desired behaviour. Labels with no
* overlap are returned untouched (offset 0).
*
* Implementation note: the hot path uses flat `Float64Array`s and a numeric-keyed
* spatial hash (no per-element string keys) so it stays in the single-digit-ms
* range even at ~16 k labels × many relaxation passes.
*/
export interface EdgeInfoLabel {
/** Anchor position in user space (the group's `translate`). */
x: number;
y: number;
/**
* Unit tangent along the edge, oriented TOWARD the branch mid-point
* (so a positive slide moves the label away from its terminal node).
*/
tx: number;
ty: number;
/** Half-extent of the rendered label ALONG the tangent (user space). */
halfLen: number;
/** Half-extent of the rendered label PERPENDICULAR to the tangent. */
halfThick: number;
/**
* Max distance this label may slide toward mid (+tangent) before it would
* reach its branch mid-point. Omit / non-finite ⇒ fall back to the
* label-size cap (`maxSlideFactor`). The backward (−tangent) cap is always
* the label-size cap, kept small so labels don't drift back into the node.
*/
maxSlideToMid?: number;
}
export interface DeclutterOptions {
/** Relaxation passes (default 8). More = better separation in dense cores. */
iterations?: number;
/** Per-pass step fraction of the residual overlap (default 0.5). */
damping?: number;
/** Extra clearance added between labels, user space (default 0). */
padding?: number;
/**
* Fallback slide cap when a label has no geometric `maxSlideToMid`:
* cap = maxSlideFactor × full label length (default 5). Also caps the
* small backward (toward-node) slide.
*/
maxSlideFactor?: number;
}
/**
* Resolve overlapping flow labels by sliding each along its edge tangent.
* Returns the signed along-tangent offset for each input label (same order);
* the caller applies `pos += offset × tangent`. Pure + deterministic.
*/
export const declutterEdgeInfoLabels = (
labels: EdgeInfoLabel[],
opts: DeclutterOptions = {},
): number[] => {
const n = labels.length;
const offset = new Float64Array(n);
if (n < 2) return Array.from(offset);
const iterations = opts.iterations ?? 8;
const damping = opts.damping ?? 0.5;
const padding = opts.padding ?? 0;
const maxSlideFactor = opts.maxSlideFactor ?? 5;
// Flatten into typed arrays for the hot loop (no per-element property reads).
const X = new Float64Array(n);
const Y = new Float64Array(n);
const TX = new Float64Array(n);
const TY = new Float64Array(n);
const HL = new Float64Array(n);
const HT = new Float64Array(n);
const capFwd = new Float64Array(n);
const capBack = new Float64Array(n);
let sumHL = 0;
let maxHL = 0;
for (let i = 0; i < n; i++) {
const l = labels[i];
X[i] = l.x; Y[i] = l.y; TX[i] = l.tx; TY[i] = l.ty;
HL[i] = l.halfLen; HT[i] = l.halfThick;
const sizeCap = maxSlideFactor * 2 * l.halfLen;
const toMid = l.maxSlideToMid;
const hasGeom = typeof toMid === 'number' && isFinite(toMid) && toMid > 0;
// With geometry: slide freely toward mid (+tangent), capped at mid, and
// only a LITTLE back toward the node — bounded relative to the node
// distance (toMid ≈ 1.6× node distance) so a label can never drift back
// past its own substation. Without geometry: symmetric size cap.
capFwd[i] = hasGeom ? Math.min(toMid, sizeCap * 2) : sizeCap;
capBack[i] = hasGeom ? toMid * 0.25 : sizeCap;
sumHL += l.halfLen;
if (l.halfLen > maxHL) maxHL = l.halfLen;
}
// Spatial hash cell + collision-free numeric key. Cell ≈ a couple of label
// lengths so any overlapping pair lands within a 3×3 neighbourhood; the grid
// is rebuilt on the (slid) positions each pass to keep buckets small in the
// dense core.
const cell = Math.max(1e-6, 2 * (sumHL / n) + 2 * maxHL);
const KOFF = 1 << 20; // shift cell indices non-negative
const KSTRIDE = 1 << 21; // > 2·KOFF so (gx,gy) → unique key < 2^42
const delta = new Float64Array(n);
const cx = new Float64Array(n);
const cy = new Float64Array(n);
// Spatial hash as an intrusive linked list (head cell→first index, `next`
// chains same-cell labels) so each pass rebuilds in place with ZERO bucket-
// array allocation — the dominant cost when iterating many passes over ~16k
// labels. The `head` Map is reused (cleared) every pass.
const next = new Int32Array(n);
const head = new Map<number, number>();
for (let iter = 0; iter < iterations; iter++) {
head.clear();
for (let i = 0; i < n; i++) {
const px = X[i] + offset[i] * TX[i];
const py = Y[i] + offset[i] * TY[i];
cx[i] = px; cy[i] = py;
const gx = Math.floor(px / cell);
const gy = Math.floor(py / cell);
const key = (gx + KOFF) * KSTRIDE + (gy + KOFF);
const h = head.get(key);
next[i] = h === undefined ? -1 : h;
head.set(key, i);
}
delta.fill(0);
for (let i = 0; i < n; i++) {
const ix = cx[i], iy = cy[i];
const tix = TX[i], tiy = TY[i];
const nix = -tiy, niy = tix; // edge normal
const hli = HL[i], hti = HT[i];
const gx = Math.floor(ix / cell);
const gy = Math.floor(iy / cell);
let d = 0;
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const h = head.get((gx + dx + KOFF) * KSTRIDE + (gy + dy + KOFF));
for (let j = h === undefined ? -1 : h; j >= 0; j = next[j]) {
if (j === i) continue;
const ddx = ix - cx[j];
const ddy = iy - cy[j];
const along = ddx * tix + ddy * tiy;
const ovAlong = (hli + HL[j] + padding) - Math.abs(along);
if (ovAlong <= 0) continue;
const perp = ddx * nix + ddy * niy;
const ovPerp = (hti + HT[j]) - Math.abs(perp);
if (ovPerp <= 0) continue;
// Push i away from j along its tangent; deterministic
// tiebreak for (near-)coincident anchors.
const dir = along > 1e-6 ? 1 : (along < -1e-6 ? -1 : (i < j ? 1 : -1));
d += dir * ovAlong * damping * 0.5;
}
}
}
delta[i] = d;
}
for (let i = 0; i < n; i++) {
let o = offset[i] + delta[i];
if (o > capFwd[i]) o = capFwd[i];
else if (o < -capBack[i]) o = -capBack[i];
offset[i] = o;
}
}
return Array.from(offset);
};
/** Parse a `rotate(<deg>)` angle (degrees) from a transform string, or null. */
export const parseRotateDeg = (transform: string | null): number | null => {
if (!transform) return null;
const m = /rotate\(\s*([-0-9.eE+]+)/.exec(transform);
if (!m) return null;
const deg = parseFloat(m[1]);
return Number.isFinite(deg) ? deg : null;
};