Co-Study4Grid / frontend /src /utils /svg /overflowPinPayload.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
23.4 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
/*
* Pin descriptors posted from the parent React app to the overflow-graph
* iframe. The injected overlay (`expert_backend/services/overflow_overlay.py`)
* renders one teardrop pin per descriptor anchored on the matching
* substation node group (`<g class="node" data-name="<substation>">`).
*
* Pure module — no DOM access, no React — so it can be unit-tested
* without jsdom and re-used from a worker if we later move pin
* computation off the main thread.
*/
import type {
ActionDetail,
ActionOverviewFilters,
MetadataIndex,
UnsimulatedActionScoreInfo,
} from '../../types';
import {
actionPassesOverviewFilter,
buildUnsimulatedPinTitle,
computeActionSeverity,
formatPinLabel,
type ActionPinInfo,
} from './actionPinData';
import {
getActionTargetLines,
getActionTargetVoltageLevels,
} from './highlights';
import { matchesActionTypeFilter } from '../actionTypes';
export interface OverflowPin {
actionId: string;
/**
* Marks combined-action pins (action ids containing ``+``). The
* overlay places one such pin at the midpoint of a curved Bézier
* connector drawn between the two unitary constituent pins —
* mirroring the Action Overview NAD's ``CombinedPinInfo`` layer
* (see ``actionPinData.buildCombinedActionPins`` /
* ``actionPinRender.renderCombinedPin``). When set, ``action1Id``
* + ``action2Id`` MUST also be populated; ``substation`` /
* ``lineNames`` / ``nodeCandidates`` are ignored — the combined
* pin's position is derived from the constituent positions
* client-side.
*/
isCombined?: boolean;
action1Id?: string;
action2Id?: string;
/**
* When true the unitary pin failed the active overview filter
* (severity / threshold / action-type) but is kept on the graph
* because a passing combined-action pin references it as one
* of its constituents. Mirrors the
* ``ActionPinInfo.dimmedByFilter`` flag in
* ``actionPinData.ts:39``. The overlay renders dimmedByFilter
* pins with a reduced opacity so they read as context for the
* combined glyph rather than as first-class actions.
*/
dimmedByFilter?: boolean;
/**
* Primary anchor — substation id when the overflow graph nodes are
* substations, otherwise the first matching voltage-level id. The
* overlay JS tries this name first against ``data-name`` and falls
* back to ``nodeCandidates``.
*/
substation: string;
/**
* Additional anchor candidates the overlay tries in order if
* ``substation`` doesn't match a graph node. Populated with the
* action's target voltage-level ids, because some recommender
* configurations emit VL-keyed nodes (e.g. small French grid)
* while others emit substation-keyed nodes.
*/
nodeCandidates?: string[];
/**
* Line names the overlay should try as edge anchors BEFORE
* falling back to a single-node anchor. For branch actions
* (disco / reco / max_rho_line) the pin should land at the
* midpoint of the connecting edge — same anchoring rule the
* Action Overview NAD pins use (see ``resolveActionAnchor`` in
* ``actionPinData.ts``). The overlay matches each name against
* the ``data-attr-name`` attribute of every ``<g class="edge">``
* in the SVG and uses the midpoint of the edge's source/target
* node centres.
*/
lineNames?: string[];
/** Loading-percentage label rendered inside the pin body. */
label: string;
severity: ActionPinInfo['severity'];
isSelected: boolean;
isRejected: boolean;
/**
* Marks scored-but-not-yet-simulated pins. The overlay renders
* them with a dashed outline + dimmed fill and re-routes the
* double-click message so the parent kicks off a manual
* simulation instead of opening the SLD overlay. Mirrors the
* Action Overview's unsimulated-pin contract.
*/
unsimulated?: boolean;
/**
* Multi-line tooltip text rendered inside the pin's native
* ``<title>`` element. Populated for un-simulated pins by
* ``buildUnsimulatedPinTitle`` so hovering an overflow pin
* surfaces the same score / rank / MW-start summary the Action
* Overview pin tooltip shows. Falls back to ``actionId`` when
* absent, preserving the legacy hover for simulated pins.
*/
title?: string;
}
/**
* Resolve an action to its first matching SUBSTATION on the overflow
* graph. We look for a target VL via the same heuristics used by the
* NAD pin layer, then map VL → substation through the network's
* `voltage-level-substations` table.
*
* Returns null when no substation can be resolved (action has no
* known VL target, or the network's VLs are not mapped to a
* substation — e.g. a pure-VL test fixture). The pin is then silently
* dropped, matching the NAD overview behaviour.
*/
interface ResolvedAnchor {
/** Best-guess substation id (overflow graph node ``data-name``). */
substation: string;
/**
* Voltage-level ids the action targets, ordered so the most
* specific match comes first. These act as fallback anchors when
* the overflow graph nodes are voltage levels rather than
* substations.
*/
vlIds: string[];
/**
* Line names the action targets, in priority order. The overlay
* tries each as an edge anchor (midpoint of source/target nodes)
* before falling back to single-node candidates. Empty when the
* action does not target a line (load shedding / curtailment /
* pure topology actions).
*/
lineNames: string[];
}
const resolveActionSubstation = (
actionId: string,
details: ActionDetail,
metaIndex: MetadataIndex,
vlToSubstation: Readonly<Record<string, string>>,
overflowSubstationSet: ReadonlySet<string>,
): ResolvedAnchor | null => {
const candidateVls: string[] = [];
const candidateLines: string[] = [];
const pushVl = (vlId: string | null | undefined) => {
if (vlId && !candidateVls.includes(vlId)) candidateVls.push(vlId);
};
const pushLine = (lineName: string | null | undefined) => {
if (lineName && !candidateLines.includes(lineName)) {
candidateLines.push(lineName);
}
};
// Load-shedding / curtailment actions land on a single VL —
// they do NOT target a branch even though their description /
// id may incidentally contain a line name (e.g. ``max_rho_line``
// is set on EVERY action). EARLY RETURN with only the VL anchor
// here, exactly like ``resolveActionAnchor`` in actionPinData,
// so the overlay's edge-midpoint path is skipped and the pin
// lands on the substation node.
if (details.load_shedding_details?.length) {
const vlId = details.load_shedding_details[0].voltage_level_id;
if (vlId) {
const sub = vlToSubstation[vlId];
const substation = (sub && overflowSubstationSet.has(sub))
? sub
: (sub || vlId);
return { substation, vlIds: [vlId], lineNames: [] };
}
}
if (details.curtailment_details?.length) {
const vlId = details.curtailment_details[0].voltage_level_id;
if (vlId) {
const sub = vlToSubstation[vlId];
const substation = (sub && overflowSubstationSet.has(sub))
? sub
: (sub || vlId);
return { substation, vlIds: [vlId], lineNames: [] };
}
}
if (details.redispatch_details?.length) {
const vlId = details.redispatch_details[0].voltage_level_id;
if (vlId) {
const sub = vlToSubstation[vlId];
const substation = (sub && overflowSubstationSet.has(sub))
? sub
: (sub || vlId);
return { substation, vlIds: [vlId], lineNames: [] };
}
}
// Track the action's PRIMARY branch targets vs a max_rho_line
// fallback separately so we can apply the same priority order
// ``resolveActionAnchor`` uses on the Action Overview NAD:
// 1. primary line targets → edge midpoint
// 2. voltage-level targets → VL node
// 3. max_rho_line → edge midpoint (LAST resort only)
// Without this split, coupler / node-merging / generic VL
// actions would land on the action's incidental max_rho_line
// instead of their actual target VL.
const primaryLines: string[] = [];
const lineTargets = getActionTargetLines(
details, actionId, metaIndex.edgesByEquipmentId,
);
for (const lineName of lineTargets) {
primaryLines.push(lineName);
const edge = metaIndex.edgesByEquipmentId.get(lineName);
if (!edge) continue;
for (const ref of [edge.node1, edge.node2]) {
if (typeof ref !== 'string') continue;
const node = metaIndex.nodesBySvgId.get(ref)
?? metaIndex.nodesByEquipmentId.get(ref);
if (node?.equipmentId) pushVl(node.equipmentId);
}
}
// Voltage-level targets parsed from the action description / id.
const vlTargetIds = getActionTargetVoltageLevels(
details, actionId, metaIndex.nodesByEquipmentId,
);
for (const vlId of vlTargetIds) {
pushVl(vlId);
}
// max_rho_line — used as a LAST-resort line anchor (after VLs)
// AND its endpoints feed candidateVls so pins still resolve when
// neither primary lines nor explicit VL targets are present.
let fallbackLine: string | null = null;
if (details.max_rho_line) {
fallbackLine = details.max_rho_line;
const edge = metaIndex.edgesByEquipmentId.get(details.max_rho_line);
if (edge) {
for (const ref of [edge.node1, edge.node2]) {
if (typeof ref !== 'string') continue;
const node = metaIndex.nodesBySvgId.get(ref)
?? metaIndex.nodesByEquipmentId.get(ref);
pushVl(node?.equipmentId);
}
}
}
// Pick the line-anchor strategy in priority order. When the
// action has VL targets (couplers, node-merging, …) we leave
// ``candidateLines`` empty so the overlay goes straight to the
// VL node anchor — the max_rho_line is only an anchor of last
// resort.
if (primaryLines.length > 0) {
for (const ln of primaryLines) pushLine(ln);
} else if (vlTargetIds.length === 0 && fallbackLine) {
pushLine(fallbackLine);
}
if (candidateVls.length === 0 && candidateLines.length === 0) {
return null;
}
// Pick the first VL whose substation is present in the overflow
// graph (best UX when the graph is substation-keyed). Otherwise
// fall back to the first candidate's substation — the overlay JS
// will still try the VL ids themselves as anchor candidates.
let substation: string | null = null;
for (const vlId of candidateVls) {
const sub = vlToSubstation[vlId];
if (sub && overflowSubstationSet.has(sub)) {
substation = sub;
break;
}
}
if (!substation) {
// No substation matched the overflow set; pick the first
// mapped substation (or the first VL itself when no mapping
// exists — e.g. test fixtures).
for (const vlId of candidateVls) {
const sub = vlToSubstation[vlId];
if (sub) { substation = sub; break; }
}
}
if (!substation) substation = candidateVls[0] ?? "";
return { substation, vlIds: candidateVls, lineNames: candidateLines };
};
/**
* Build the descriptor list to post to the overflow-graph iframe.
*
* @param overflowSubstations Optional set of substation ids known to
* be present in the overflow graph. When
* provided, pins for substations outside
* the set are dropped (avoids pinning on
* a substation hidden by the
* `keep_overloads_components` filter).
* When omitted, every resolvable pin is
* kept — useful for tests.
*/
export const buildOverflowPinPayload = (
actions: Record<string, ActionDetail> | null | undefined,
metaIndex: MetadataIndex | null | undefined,
vlToSubstation: Readonly<Record<string, string>>,
monitoringFactor: number,
selectedActionIds: ReadonlySet<string>,
rejectedActionIds: ReadonlySet<string>,
overflowSubstations?: ReadonlySet<string>,
/**
* Optional Action-Overview-style filters. When passed, pins are
* dropped according to the SAME rules ``ActionOverviewDiagram``
* applies to its NAD pins (severity category, max-loading
* threshold, action-type chip). Without it every resolvable pin
* is emitted (legacy behaviour preserved for tests / standalone).
*/
overviewFilters?: ActionOverviewFilters,
): OverflowPin[] => {
if (!actions || !metaIndex) return [];
// When no substation set is provided, accept every resolved one.
const knownSet = overflowSubstations ?? new Set<string>();
const acceptAny = !overflowSubstations;
// Three-pass pipeline mirroring ``ActionOverviewDiagram`` (the
// ``pins``/``combinedPins`` memos at component.tsx:314-380):
//
// 1. Build every unitary anchor UNFILTERED so combined pins
// have endpoints to anchor on even when one half would
// fail the active severity / threshold / action-type chip.
// 2. Determine which combined actions pass the filter; their
// two unitary ids form the ``protectedIds`` set.
// 3. Re-filter the unitary list:
// - passes filter → emit at full strength,
// - fails but in protectedIds → emit with dimmedByFilter,
// - fails and unprotected → drop entirely.
// 4. Emit combined-pin descriptors for the passing combined
// actions. The overlay JS computes their midpoint client-
// side from the constituent positions.
interface UnitaryDraft {
actionId: string;
details: ActionDetail;
anchor: ResolvedAnchor;
}
const unitaryDrafts: UnitaryDraft[] = [];
const unitaryDraftIds = new Set<string>();
for (const [actionId, details] of Object.entries(actions)) {
if (actionId.includes('+')) continue;
const anchor = resolveActionSubstation(
actionId, details, metaIndex, vlToSubstation,
acceptAny ? new Set(Object.values(vlToSubstation)) : knownSet,
);
if (!anchor) continue;
unitaryDrafts.push({ actionId, details, anchor });
unitaryDraftIds.add(actionId);
}
// ``passesAll`` mirrors the same name in ActionOverviewDiagram —
// category + threshold AND the single-select action-type chip.
const passesAll = (id: string, det: ActionDetail): boolean => {
if (!overviewFilters) return true;
if (!actionPassesOverviewFilter(
det, monitoringFactor,
overviewFilters.categories, overviewFilters.threshold,
)) return false;
return matchesActionTypeFilter(
overviewFilters.actionType,
id, det.description_unitaire, null,
);
};
// A combined pin passes the type chip if EITHER constituent
// matches — combined actions are inherently multi-type and
// hiding a pair because one side doesn't match would surprise
// the operator. (Identical rule to ``combinedPassesTypeFilter``
// in ActionOverviewDiagram.)
const combinedPassesTypeFilter = (id1: string, id2: string): boolean => {
if (!overviewFilters || overviewFilters.actionType === 'all') return true;
const d1 = actions[id1];
const d2 = actions[id2];
return (
(d1 ? matchesActionTypeFilter(
overviewFilters.actionType, id1, d1.description_unitaire, null,
) : false)
|| (d2 ? matchesActionTypeFilter(
overviewFilters.actionType, id2, d2.description_unitaire, null,
) : false)
);
};
// Pass 2 — find combined actions that pass the filter and
// build the protected-id set.
interface CombinedDraft {
actionId: string;
details: ActionDetail;
action1Id: string;
action2Id: string;
}
const combinedDrafts: CombinedDraft[] = [];
const protectedIds = new Set<string>();
for (const [actionId, details] of Object.entries(actions)) {
if (!actionId.includes('+')) continue;
const parts = actionId.split('+');
if (parts.length !== 2) continue;
const [id1, id2] = parts;
if (!unitaryDraftIds.has(id1) || !unitaryDraftIds.has(id2)) continue;
if (overviewFilters) {
if (!actionPassesOverviewFilter(
details, monitoringFactor,
overviewFilters.categories, overviewFilters.threshold,
)) continue;
if (!combinedPassesTypeFilter(id1, id2)) continue;
}
combinedDrafts.push({ actionId, details, action1Id: id1, action2Id: id2 });
protectedIds.add(id1);
protectedIds.add(id2);
}
// Pass 3 — emit the unitary pins, dimming the protected fails.
// When the "combined only" toggle is active, drop every unitary
// pin that is NOT a constituent of a passing combined pin and
// mark the survivors as dimmed-by-filter — same contract as the
// ``ActionOverviewDiagram`` ``pins`` memo, so the two surfaces
// stay in lock-step.
const combinedOnly = !!overviewFilters?.showCombinedOnly;
const out: OverflowPin[] = [];
for (const u of unitaryDrafts) {
const passes = passesAll(u.actionId, u.details);
if (combinedOnly) {
if (!protectedIds.has(u.actionId)) continue;
} else if (!passes && !protectedIds.has(u.actionId)) {
continue;
}
const dimmedByFilter = combinedOnly || !passes;
out.push({
actionId: u.actionId,
substation: u.anchor.substation,
nodeCandidates: u.anchor.vlIds.filter(v => v !== u.anchor.substation),
lineNames: u.anchor.lineNames,
label: formatPinLabel(u.details),
severity: computeActionSeverity(u.details, monitoringFactor),
isSelected: selectedActionIds.has(u.actionId),
isRejected: rejectedActionIds.has(u.actionId),
...(dimmedByFilter ? { dimmedByFilter: true } : {}),
});
}
// Pass 4 — emit the combined-pin descriptors.
for (const cp of combinedDrafts) {
out.push({
actionId: cp.actionId,
isCombined: true,
action1Id: cp.action1Id,
action2Id: cp.action2Id,
substation: '',
label: formatPinLabel(cp.details),
severity: computeActionSeverity(cp.details, monitoringFactor),
isSelected: selectedActionIds.has(cp.actionId),
isRejected: rejectedActionIds.has(cp.actionId),
});
}
return out;
};
/**
* Build the descriptor list of un-simulated action pins for the
* overflow-graph iframe. Mirrors ``buildUnsimulatedActionPins`` in
* ``actionPinData.ts``: every scored-but-not-simulated id resolves
* via the same anchor logic with a stub ``ActionDetail``, lands as
* a dimmed pin with ``severity='grey'`` / ``label='?'`` and the
* ``unsimulated: true`` flag the overlay reads to switch shape /
* dblclick semantics.
*
* Items already in ``simulatedIds`` are skipped (they're rendered
* by ``buildOverflowPinPayload`` instead).
*
* When ``overviewFilters.actionType`` is provided and not ``'all'``,
* ids that don't match the active type chip (DISCO / RECO / LS /
* OPEN / CLOSE / PST / RC) are dropped — mirroring the Action
* Overview's ``unsimulatedPins`` memo so the chip filter behaves
* consistently across both tabs. Like the Overview, we prefer the
* score-info ``type`` field when available and fall back to
* id-based heuristics in ``classifyActionType``.
*/
export const buildOverflowUnsimulatedPinPayload = (
scoredActionIds: readonly string[],
simulatedIds: ReadonlySet<string>,
metaIndex: MetadataIndex | null | undefined,
vlToSubstation: Readonly<Record<string, string>>,
scoreInfo?: Readonly<Record<string, UnsimulatedActionScoreInfo>>,
overflowSubstations?: ReadonlySet<string>,
overviewFilters?: ActionOverviewFilters,
): OverflowPin[] => {
if (!metaIndex || scoredActionIds.length === 0) return [];
// Un-simulated actions can never participate in a computed pair —
// when the operator restricts the overview to combined-only pins
// there is nothing for this layer to emit.
if (overviewFilters?.showCombinedOnly) return [];
const knownSet = overflowSubstations ?? new Set<string>();
const acceptAny = !overflowSubstations;
// Stub ActionDetail used for anchor resolution — un-simulated
// actions have no rho yet, so we feed empty strings / nulls and
// rely on the action id's structure (line / VL / coupler tokens)
// to drive ``getActionTargetLines`` / ``getActionTargetVoltageLevels``.
const stub: ActionDetail = {
description_unitaire: '',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
const activeType = overviewFilters?.actionType ?? 'all';
const out: OverflowPin[] = [];
const seen = new Set<string>();
for (const rawId of scoredActionIds) {
const id = rawId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
if (simulatedIds.has(id)) continue;
// Action-type chip filter — same rule as the Action Overview
// (`ActionOverviewDiagram.tsx` `unsimulatedPins` memo).
if (activeType !== 'all') {
const scoreType = scoreInfo?.[id]?.type ?? null;
if (!matchesActionTypeFilter(activeType, id, null, scoreType)) continue;
}
const anchor = resolveActionSubstation(
id, stub, metaIndex, vlToSubstation,
acceptAny ? new Set(Object.values(vlToSubstation)) : knownSet,
);
if (!anchor) continue;
out.push({
actionId: id,
substation: anchor.substation,
nodeCandidates: anchor.vlIds.filter(v => v !== anchor.substation),
lineNames: anchor.lineNames,
label: '?',
severity: 'grey',
isSelected: false,
isRejected: false,
unsimulated: true,
// Multi-line hover tooltip mirrors the Action Overview's
// un-simulated pin: id + score + rank + MW-start.
title: buildUnsimulatedPinTitle(id, scoreInfo?.[id]),
});
}
return out;
};