Co-Study4Grid / frontend /src /utils /svg /actionPinData.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
18.2 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
/*
* Action-overview pin DATA builders — pure functions, no DOM access.
*
* Separated from `actionPinRender.ts` so the severity palette, anchor
* resolution, and pin descriptor construction can be tested without
* jsdom.
*/
import type {
ActionDetail,
ActionSeverityCategory,
MetadataIndex,
NodeMeta,
UnsimulatedActionScoreInfo,
} from '../../types';
import { pinColors, pinColorsDimmed, pinColorsHighlighted } from '../../styles/tokens';
import { getActionTargetLines, getActionTargetVoltageLevels } from './highlights';
export interface ActionPinInfo {
id: string;
x: number;
y: number;
severity: 'green' | 'orange' | 'red' | 'grey';
label: string;
title: string;
/**
* When true this pin represents an action from the scored table
* that has NOT yet been simulated. Rendered with a dashed outline
* and reduced opacity, and double-clicking it kicks off a manual
* simulation (the same code path as the Manual Selection dropdown).
*/
unsimulated?: boolean;
/**
* When true this pin was filtered out by the overview header
* filter but kept visible because a passing combined action
* references it. Rendered with reduced opacity so it reads as
* a context pin rather than a first-class action.
*/
dimmedByFilter?: boolean;
}
/**
* Descriptor for a combined-action pin — rendered at the midpoint of
* a curved connection between the two unitary action pins it combines.
*/
export interface CombinedPinInfo {
/** Pair key, e.g. "action1+action2". */
pairId: string;
/** The two unitary action ids. */
action1Id: string;
action2Id: string;
/** Anchor positions of the two unitary pins (endpoints of the curve). */
p1: { x: number; y: number };
p2: { x: number; y: number };
/** Midpoint of the curve (where the combined pin sits). */
x: number;
y: number;
/** Max loading after combined application. */
label: string;
title: string;
severity: ActionPinInfo['severity'];
}
export const severityFill: Record<ActionPinInfo['severity'], string> = pinColors;
/**
* Dimmed fill colours for rejected actions — each severity hue is
* shifted toward grey and lowered in saturation so the pin recedes
* visually while still being colour-identifiable.
*/
export const severityFillDimmed: Record<ActionPinInfo['severity'], string> = pinColorsDimmed;
/**
* Highlighted (selected) fill colours — slightly more vivid/brighter
* versions of the severity palette so the pin stands out.
*/
export const severityFillHighlighted: Record<ActionPinInfo['severity'], string> = pinColorsHighlighted;
export const computeActionSeverity = (
details: ActionDetail,
monitoringFactor: number,
): ActionPinInfo['severity'] => {
if (details.non_convergence || details.is_islanded) return 'grey';
if (details.max_rho == null) {
return details.is_rho_reduction ? 'green' : 'red';
}
if (details.max_rho > monitoringFactor) return 'red';
if (details.max_rho > monitoringFactor - 0.05) return 'orange';
return 'green';
};
/**
* Predicate used by both the overview (pin visibility) and the sidebar
* ActionFeed (card visibility) to decide whether an action passes the
* active category + threshold filters.
*
* - `categoryEnabled` controls the four severity buckets (green / orange
* / red / grey); if the action's bucket is disabled, it is hidden.
* - `threshold` is a max-loading cap: actions whose `max_rho` is
* **strictly greater** than the threshold are hidden. Actions with a
* null `max_rho` (divergent / islanded — all in the 'grey' bucket)
* bypass the threshold so the operator keeps seeing non-numeric
* outcomes when the grey category is enabled.
*/
export const actionPassesOverviewFilter = (
details: ActionDetail,
monitoringFactor: number,
categoryEnabled: Record<ActionSeverityCategory, boolean>,
threshold: number,
): boolean => {
const severity = computeActionSeverity(details, monitoringFactor);
if (!categoryEnabled[severity]) return false;
if (details.max_rho != null && details.max_rho > threshold) return false;
return true;
};
/**
* Format the loading label shown on the pin body.
*
* Exported so tests can pin the exact string contract: percentage,
* "DIV" on divergence, "ISL" on islanding, or em-dash otherwise.
*/
export const formatPinLabel = (details: ActionDetail): string => {
if (details.max_rho != null) return `${(details.max_rho * 100).toFixed(0)}%`;
if (details.non_convergence) return 'DIV';
if (details.is_islanded) return 'ISL';
return '\u2014';
};
/**
* Build the human-readable title (tooltip) shown on hover. The `idLabel`
* is the pin id as the user thinks of it (e.g. "a+b" for combined).
*/
export const formatPinTitle = (idLabel: string, details: ActionDetail): string => {
const parts = [
idLabel,
details.description_unitaire,
details.max_rho != null
? `max loading ${(details.max_rho * 100).toFixed(1)}%${details.max_rho_line ? ` on ${details.max_rho_line}` : ''}`
: details.non_convergence
? 'load-flow divergent'
: details.is_islanded
? 'islanding'
: '',
];
return parts.filter(Boolean).join(' \u2014 ');
};
/**
* Resolve an action to a point on the NAD background.
*
* For line / PST actions we take the midpoint of the edge; for
* nodal actions we take the voltage-level node position. Returns
* null when no impacted asset can be located — the pin is then
* silently skipped.
*/
export const resolveActionAnchor = (
actionId: string,
details: ActionDetail,
metaIndex: MetadataIndex,
): { x: number; y: number } | null => {
const { nodesByEquipmentId, nodesBySvgId, edgesByEquipmentId } = metaIndex;
const lookupNode = (nodeRef: unknown): NodeMeta | undefined => {
if (typeof nodeRef !== 'string') return undefined;
return nodesBySvgId.get(nodeRef) ?? nodesByEquipmentId.get(nodeRef);
};
// Load shedding / curtailment actions carry an explicit
// voltage_level_id in their detail objects — use it directly
// so the pin lands on the VL node, not on an unrelated line.
if (details.load_shedding_details?.length) {
const vlId = details.load_shedding_details[0].voltage_level_id;
if (vlId) {
const node = nodesByEquipmentId.get(vlId);
if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y };
}
}
if (details.curtailment_details?.length) {
const vlId = details.curtailment_details[0].voltage_level_id;
if (vlId) {
const node = nodesByEquipmentId.get(vlId);
if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y };
}
}
if (details.redispatch_details?.length) {
const vlId = details.redispatch_details[0].voltage_level_id;
if (vlId) {
const node = nodesByEquipmentId.get(vlId);
if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y };
}
}
// Try line targets first
const lineTargets = getActionTargetLines(details, actionId, edgesByEquipmentId);
for (const lineName of lineTargets) {
const edge = edgesByEquipmentId.get(lineName);
if (!edge) continue;
const n1 = lookupNode(edge.node1);
const n2 = lookupNode(edge.node2);
if (n1 && n2 && Number.isFinite(n1.x) && Number.isFinite(n2.x)) {
return { x: (n1.x + n2.x) / 2, y: (n1.y + n2.y) / 2 };
}
if (n1 && Number.isFinite(n1.x)) return { x: n1.x, y: n1.y };
if (n2 && Number.isFinite(n2.x)) return { x: n2.x, y: n2.y };
}
// Fallback on voltage-level targets
const vlTargets = getActionTargetVoltageLevels(details, actionId, nodesByEquipmentId);
for (const vlName of vlTargets) {
const node = nodesByEquipmentId.get(vlName);
if (node && Number.isFinite(node.x)) {
return { x: node.x, y: node.y };
}
}
// Last resort: max_rho_line (a line the action redistributes onto)
if (details.max_rho_line) {
const edge = edgesByEquipmentId.get(details.max_rho_line);
if (edge) {
const n1 = lookupNode(edge.node1);
const n2 = lookupNode(edge.node2);
if (n1 && n2 && Number.isFinite(n1.x) && Number.isFinite(n2.x)) {
return { x: (n1.x + n2.x) / 2, y: (n1.y + n2.y) / 2 };
}
}
}
return null;
};
/**
* Fan out pins that share the same anchor position so they don't
* stack on top of each other and remain individually clickable.
*
* Mutates `pins` in place. Exported so tests can exercise the
* fan-out independently from the full builder.
*/
export const fanOutColocatedPins = (pins: ActionPinInfo[], offsetRadius = 30 * 1.2): void => {
const bucketKey = (p: ActionPinInfo) =>
`${Math.round(p.x * 100)}:${Math.round(p.y * 100)}`;
const groups = new Map<string, number[]>();
pins.forEach((p, i) => {
const k = bucketKey(p);
const arr = groups.get(k);
if (arr) arr.push(i);
else groups.set(k, [i]);
});
for (const indices of groups.values()) {
if (indices.length < 2) continue;
const angleStep = (2 * Math.PI) / indices.length;
indices.forEach((idx, i) => {
const angle = -Math.PI / 2 + i * angleStep;
pins[idx] = {
...pins[idx],
x: pins[idx].x + offsetRadius * Math.cos(angle),
y: pins[idx].y + offsetRadius * Math.sin(angle),
};
});
}
};
/**
* Build the list of pin descriptors for the action-overview view.
* Pure function — no DOM access — so it can be unit-tested.
*
* When `overviewFilter` is provided, pins whose action does not pass
* the active category + threshold filters are dropped. The same
* predicate drives the ActionFeed card filtering, keeping the two
* views in lock-step.
*/
export const buildActionOverviewPins = (
actions: Record<string, ActionDetail>,
metaIndex: MetadataIndex,
monitoringFactor: number,
filterIds?: Iterable<string>,
overviewFilter?: {
categories: Record<ActionSeverityCategory, boolean>;
threshold: number;
} | null,
): ActionPinInfo[] => {
const allowed = filterIds ? new Set(filterIds) : null;
const pins: ActionPinInfo[] = [];
for (const [actionId, details] of Object.entries(actions)) {
if (allowed && !allowed.has(actionId)) continue;
// Skip combined-action entries (key contains '+') — those are
// rendered separately by buildCombinedActionPins with a curved
// connection between their constituent unitary pins.
if (actionId.includes('+')) continue;
if (overviewFilter && !actionPassesOverviewFilter(
details, monitoringFactor, overviewFilter.categories, overviewFilter.threshold,
)) continue;
const anchor = resolveActionAnchor(actionId, details, metaIndex);
if (!anchor) continue;
const severity = computeActionSeverity(details, monitoringFactor);
const label = formatPinLabel(details);
const title = formatPinTitle(actionId, details);
pins.push({ id: actionId, x: anchor.x, y: anchor.y, severity, label, title });
}
fanOutColocatedPins(pins);
return pins;
};
/**
* Format the SVG-title tooltip shown on hover for an un-simulated pin.
* When {@link scoreInfo} is provided the tooltip carries the same
* score-table data the Manual Selection dropdown exposes (type,
* score, rank in category, MW/tap start), so the operator can triage
* without leaving the overview. Falls back to a generic prompt when
* score info is absent (e.g. older session reloads).
*/
export const buildUnsimulatedPinTitle = (
id: string,
info: UnsimulatedActionScoreInfo | undefined,
): string => {
if (!info) {
return `${id} — not yet simulated (double-click to run)`;
}
const lines: string[] = [
`${id} — not yet simulated (double-click to run)`,
`Type: ${info.type}`,
`Score: ${info.score.toFixed(2)} — rank ${info.rankInType} of ${info.countInType} (max ${info.maxScoreInType.toFixed(2)})`,
];
if (info.mwStart != null) {
lines.push(`MW start: ${info.mwStart.toFixed(1)} MW`);
}
if (info.tapStart) {
const ts = info.tapStart;
const range = ts.low_tap != null && ts.high_tap != null
? ` (range ${ts.low_tap}${ts.high_tap})`
: '';
lines.push(`Tap start: ${ts.tap}${range}`);
}
return lines.join('\n');
};
/**
* Build pin descriptors for actions that appear in the score table but
* have NOT yet been simulated. These are rendered as dimmed, dashed
* pins the operator can double-click to trigger a manual simulation.
*
* An unsimulated action has no `ActionDetail`, so anchoring goes
* through `resolveActionAnchor` with a minimal stub; the helper
* internally falls back on edge/VL lookups based on the id alone,
* which matches what the score table does for line / coupling / PST
* actions. Items that cannot be resolved are silently skipped.
*
* Pins whose id is already present in `simulatedIds` are skipped so
* we never double-pin an action that is both scored and simulated.
*
* Pure function — no DOM access.
*/
export const buildUnsimulatedActionPins = (
scoredActionIds: readonly string[],
simulatedIds: ReadonlySet<string>,
metaIndex: MetadataIndex,
scoreInfo?: Readonly<Record<string, UnsimulatedActionScoreInfo>>,
): ActionPinInfo[] => {
const pins: ActionPinInfo[] = [];
const seen = new Set<string>();
const stub: ActionDetail = {
description_unitaire: '',
rho_before: null,
rho_after: null,
max_rho: null,
max_rho_line: '',
is_rho_reduction: false,
};
for (const rawId of scoredActionIds) {
const id = rawId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
if (simulatedIds.has(id)) continue;
const anchor = resolveActionAnchor(id, stub, metaIndex);
if (!anchor) continue;
pins.push({
id,
x: anchor.x,
y: anchor.y,
severity: 'grey',
label: '?',
title: buildUnsimulatedPinTitle(id, scoreInfo?.[id]),
unsimulated: true,
});
}
fanOutColocatedPins(pins);
return pins;
};
/**
* Quadratic Bezier midpoint of (p1, ctrl, p2) at t=0.5.
* Exported so the renderer can reuse the same control-point math for
* drawing the connecting curve path.
*/
export const curveMidpoint = (
p1: { x: number; y: number },
p2: { x: number; y: number },
offsetFraction = 0.3,
): { ctrlX: number; ctrlY: number; midX: number; midY: number } => {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const ctrlX = (p1.x + p2.x) / 2 + (-dy / dist) * dist * offsetFraction;
const ctrlY = (p1.y + p2.y) / 2 + (dx / dist) * dist * offsetFraction;
const t = 0.5;
const midX = (1 - t) * (1 - t) * p1.x + 2 * t * (1 - t) * ctrlX + t * t * p2.x;
const midY = (1 - t) * (1 - t) * p1.y + 2 * t * (1 - t) * ctrlY + t * t * p2.y;
return { ctrlX, ctrlY, midX, midY };
};
/**
* Build descriptors for combined-action pins. A simulated combined
* action is identified by an action key containing '+' in the
* `actions` dict (e.g. "disco_X+reco_Y"). For each such entry the
* function locates the two constituent unitary pins and produces a
* `CombinedPinInfo` with a curved connection between them and a
* dedicated pin at the curve midpoint.
*
* NOTE: simulated pairs land in `actions` (not `combined_actions`)
* — see CombinedActionsModal's handleSimulate. That is why this
* function scans `actions` for '+' keys rather than iterating over
* `combined_actions`.
*
* Pure function — no DOM access.
*/
export const buildCombinedActionPins = (
actions: Record<string, ActionDetail> | null | undefined,
unitaryPins: readonly ActionPinInfo[],
monitoringFactor: number,
): CombinedPinInfo[] => {
if (!actions) return [];
const pinById = new Map(unitaryPins.map(p => [p.id, p]));
const result: CombinedPinInfo[] = [];
const combinedKeys = Object.keys(actions).filter(k => k.includes('+'));
if (combinedKeys.length > 0) {
console.log('[buildCombinedActionPins] combined keys in actions:', combinedKeys);
console.log('[buildCombinedActionPins] unitary pin ids:', [...pinById.keys()]);
}
for (const [actionId, detail] of Object.entries(actions)) {
if (!actionId.includes('+')) continue;
const parts = actionId.split('+');
if (parts.length !== 2) continue;
const [id1, id2] = parts;
const pin1 = pinById.get(id1);
const pin2 = pinById.get(id2);
if (!pin1 || !pin2) {
console.warn(`[buildCombinedActionPins] skipping "${actionId}": pin1(${id1})=${!!pin1}, pin2(${id2})=${!!pin2}`);
continue;
}
const { midX, midY } = curveMidpoint(pin1, pin2);
const severity = computeActionSeverity(detail, monitoringFactor);
const label = formatPinLabel(detail);
const title = formatPinTitle(`${id1} + ${id2}`, detail);
result.push({
pairId: actionId,
action1Id: id1,
action2Id: id2,
p1: { x: pin1.x, y: pin1.y },
p2: { x: pin2.x, y: pin2.y },
x: midX,
y: midY,
label,
title,
severity,
});
}
return result;
};