Spaces:
Sleeping
Sleeping
| // 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 | |
| // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. | |
| import type { FeederLabel } from '../../types'; | |
| /** | |
| * Build ``{friendlyName: [equipmentId, ...]}`` from the SLD feeder-label map so | |
| * an overloaded line reported by its grid2op / operator FRIENDLY name (e.g. | |
| * "MARSIL61PRAGN") can be resolved to the IIDM-id-keyed SLD cell (e.g. | |
| * "relation_8423569-225") and get its overload halo (Issue 2). | |
| */ | |
| export function buildFriendlyToEquip( | |
| feederLabels: Record<string, FeederLabel> | undefined, | |
| ): Map<string, string[]> { | |
| const map = new Map<string, string[]>(); | |
| if (!feederLabels) return map; | |
| for (const [eid, info] of Object.entries(feederLabels)) { | |
| if (info?.name) { | |
| const arr = map.get(info.name) ?? []; | |
| arr.push(eid); | |
| map.set(info.name, arr); | |
| } | |
| } | |
| return map; | |
| } | |
| /** | |
| * Candidate equipment ids for an overloaded line: the friendly→id mapping (if | |
| * any) plus the raw value as a fallback — covers networks whose overload list | |
| * is already IIDM-id-keyed. | |
| */ | |
| export function overloadCandidates( | |
| lineId: string, | |
| friendlyToEquip: Map<string, string[]>, | |
| ): string[] { | |
| const mapped = friendlyToEquip.get(lineId); | |
| return mapped && mapped.length > 0 ? [lineId, ...mapped] : [lineId]; | |
| } | |
| const SVG_NS = 'http://www.w3.org/2000/svg'; | |
| /** Wrap a relabelled feeder onto multiple lines past this width so a long | |
| * far-end VL name (e.g. "LANNEMEZAN 225kV 1") stops overprinting the adjacent | |
| * feeder's label at the top / bottom of the SLD. */ | |
| const FEEDER_WRAP_CHARS = 15; | |
| const FEEDER_MAX_LINES = 3; | |
| /** | |
| * Break a single word wider than ``budget`` into pieces no wider than it, | |
| * preferring a natural separator (``_`` ``-`` ``.``) at or before the budget so | |
| * an id like ``virtual_relation_8423568`` splits between segments rather than | |
| * mid-token; falls back to a hard character cut when a chunk has no separator. | |
| * The separator stays attached to the preceding piece so the pieces re-join | |
| * exactly into the original word. | |
| */ | |
| function breakLongWord(word: string, budget: number): string[] { | |
| if (word.length <= budget) return [word]; | |
| const pieces: string[] = []; | |
| let rest = word; | |
| while (rest.length > budget) { | |
| let cut = -1; | |
| for (let i = Math.min(budget, rest.length - 1); i >= 1; i--) { | |
| const ch = rest[i]; | |
| if (ch === '_' || ch === '-' || ch === '.') { cut = i + 1; break; } | |
| } | |
| if (cut <= 0) cut = budget; // no separator in range → hard split | |
| pieces.push(rest.slice(0, cut)); | |
| rest = rest.slice(cut); | |
| } | |
| if (rest) pieces.push(rest); | |
| return pieces; | |
| } | |
| /** | |
| * Break a feeder label into at most ``FEEDER_MAX_LINES`` lines no wider than | |
| * ``FEEDER_WRAP_CHARS``. Splits on whitespace first, and a single word still | |
| * wider than the budget (a long raw IIDM id — ``L_virtual_relation_8423568…``) | |
| * is further broken on its ``_`` / ``-`` / ``.`` separators so it stops running | |
| * horizontally off its feeder and overprinting the neighbour. Overflow past the | |
| * line cap is folded back into the last line. | |
| */ | |
| export function wrapFeederLabel(label: string): string[] { | |
| if (label.length <= FEEDER_WRAP_CHARS) return [label]; | |
| const lines: string[] = []; | |
| let cur = ''; | |
| for (const word of label.split(/\s+/).filter(Boolean)) { | |
| // A word that still fits on the current line joins it with a space. | |
| if (cur && (cur + ' ' + word).length <= FEEDER_WRAP_CHARS) { | |
| cur += ' ' + word; | |
| continue; | |
| } | |
| if (cur) { lines.push(cur); cur = ''; } | |
| const pieces = breakLongWord(word, FEEDER_WRAP_CHARS); | |
| for (let i = 0; i < pieces.length - 1; i++) lines.push(pieces[i]); | |
| cur = pieces[pieces.length - 1]; | |
| } | |
| if (cur) lines.push(cur); | |
| if (lines.length === 0) return [label]; | |
| if (lines.length > FEEDER_MAX_LINES) { | |
| const head = lines.slice(0, FEEDER_MAX_LINES - 1); | |
| head.push(lines.slice(FEEDER_MAX_LINES - 1).join(' ')); | |
| return head; | |
| } | |
| return lines; | |
| } | |
| /** | |
| * Write ``label`` into ``textEl``, wrapping onto multiple ``<tspan>`` lines when | |
| * it is long. The block is vertically centred on the original baseline (first | |
| * line lifted by half the block height) so the wrapped label sits where the | |
| * single-line id used to, spreading up AND down rather than only downward into | |
| * the diagram. | |
| */ | |
| function setFeederText(textEl: SVGTextElement, label: string): void { | |
| const lines = wrapFeederLabel(label); | |
| if (lines.length <= 1) { | |
| textEl.textContent = label; | |
| return; | |
| } | |
| const x = textEl.getAttribute('x') ?? '0'; | |
| textEl.textContent = ''; | |
| lines.forEach((line, i) => { | |
| const tspan = document.createElementNS(SVG_NS, 'tspan'); | |
| tspan.setAttribute('x', x); | |
| tspan.setAttribute('dy', i === 0 ? `${(-0.6 * (lines.length - 1)).toFixed(2)}em` : '1.2em'); | |
| tspan.textContent = line; | |
| textEl.appendChild(tspan); | |
| }); | |
| } | |
| /** | |
| * Relabel branch feeders with the NAME of the voltage level at the OTHER end of | |
| * the branch (e.g. "MARSILLON 225kV") instead of pypowsybl's default raw IIDM | |
| * branch id (e.g. "relation_8423569-225"), which is hard to interpret on the | |
| * PyPSA grids (Issue 1). The backend (``build_feeder_labels``) resolves the | |
| * far-end VL name + parallel-circuit index; here we swap the matching | |
| * ``<text>`` content, wrapping long labels so they don't occlude their | |
| * neighbours, and tag each with ``data-feeder-nav`` (the far-end VL id) so a | |
| * click can navigate to that VL's SLD. | |
| * | |
| * Idempotent: the original text is stashed in ``data-feeder-orig`` so a tab / | |
| * VL / preview switch restores cleanly, and highlight clones (which carry a | |
| * copy of the label but are removed on the next render) are skipped. | |
| */ | |
| export function applyFeederRelabels( | |
| container: HTMLElement, | |
| feederLabels: Record<string, FeederLabel> | undefined, | |
| activeSvg: string | null, | |
| ): void { | |
| // Restore any previous relabel first, then re-apply against the current SVG. | |
| container.querySelectorAll('[data-feeder-relabel]').forEach(el => { | |
| const orig = el.getAttribute('data-feeder-orig'); | |
| if (orig !== null) el.textContent = orig; // replacing textContent drops any wrap <tspan>s | |
| el.removeAttribute('data-feeder-relabel'); | |
| el.removeAttribute('data-feeder-orig'); | |
| el.removeAttribute('data-feeder-nav'); | |
| el.classList.remove('sld-feeder-navigable'); | |
| }); | |
| const entries = feederLabels | |
| ? Object.entries(feederLabels).filter(([, info]) => !!info && !!info.label) | |
| : []; | |
| if (entries.length === 0 || !activeSvg) return; | |
| const texts = Array.from(container.querySelectorAll<SVGTextElement>('text')) | |
| .filter(t => !t.closest('.sld-highlight-clone')); | |
| for (const [eid, info] of entries) { | |
| const label = info.label as string; | |
| const variants = new Set([eid, eid.replace(/\./g, '_'), eid.replace(/_/g, '.')]); | |
| const textEl = texts.find(t => | |
| !t.hasAttribute('data-feeder-relabel') | |
| && variants.has((t.textContent ?? '').trim()), | |
| ); | |
| if (!textEl) continue; | |
| textEl.setAttribute('data-feeder-orig', textEl.textContent ?? ''); | |
| textEl.setAttribute('data-feeder-relabel', '1'); | |
| setFeederText(textEl, label); | |
| if (info.other_vl) { | |
| textEl.setAttribute('data-feeder-nav', info.other_vl); | |
| textEl.classList.add('sld-feeder-navigable'); | |
| } | |
| } | |
| } | |
| /** Feeder cells whose name label sits at the diagram extremity. */ | |
| const FEEDER_CELL_SELECTOR = '.sld-extern-cell, .sld-intern-cell, .sld-shunt-cell'; | |
| /** A flow value (``-14`` / ``16.8 MVAr``) rather than an equipment name. */ | |
| const NUMERIC_LABEL_RE = /^[+-]?[\d.,\s]+(?:\s*[A-Za-z%°]+)?$/; | |
| /** | |
| * Wrap every long feeder NAME label at a substation's extremities so the names | |
| * stop overlapping — the generator / load / unmatched-branch names that | |
| * :func:`applyFeederRelabels` does NOT rewrite (only far-end-named branches get | |
| * relabelled). Targets the equipment-name ``<text>`` inside each feeder cell | |
| * (excluding the numeric P/Q flow labels and the already-relabelled feeders, | |
| * which wrap themselves) and rewrites it with the same centred multi-line | |
| * ``<tspan>`` block used for relabels. | |
| * | |
| * Idempotent: the original text is stashed in ``data-feeder-wrap-orig`` and | |
| * restored first, and highlight clones (removed on the next render) are skipped. | |
| * Run AFTER :func:`applyFeederRelabels` so relabelled feeders are left alone. | |
| */ | |
| export function applyFeederLabelWrap( | |
| container: HTMLElement, | |
| activeSvg: string | null, | |
| ): void { | |
| container.querySelectorAll('[data-feeder-wrap]').forEach(el => { | |
| const orig = el.getAttribute('data-feeder-wrap-orig'); | |
| if (orig !== null) el.textContent = orig; // replacing textContent drops any wrap <tspan>s | |
| el.removeAttribute('data-feeder-wrap'); | |
| el.removeAttribute('data-feeder-wrap-orig'); | |
| }); | |
| if (!activeSvg) return; | |
| container.querySelectorAll<SVGElement>(FEEDER_CELL_SELECTOR).forEach(cell => { | |
| cell.querySelectorAll<SVGTextElement>('text').forEach(textEl => { | |
| if (textEl.closest('.sld-highlight-clone')) return; | |
| if (textEl.hasAttribute('data-feeder-relabel')) return; // relabel wraps its own | |
| if (textEl.closest('.sld-active-power, .sld-reactive-power')) return; // flow value | |
| const raw = (textEl.textContent ?? '').trim(); | |
| if (raw.length <= FEEDER_WRAP_CHARS || NUMERIC_LABEL_RE.test(raw)) return; | |
| if (wrapFeederLabel(raw).length <= 1) return; | |
| textEl.setAttribute('data-feeder-wrap-orig', textEl.textContent ?? ''); | |
| textEl.setAttribute('data-feeder-wrap', '1'); | |
| setFeederText(textEl, raw); | |
| }); | |
| }); | |
| } | |