github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
28.7 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
This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
*/
/* ===== SVG Container ===== */
.svg-container {
width: 100%;
height: 100%;
overflow: hidden;
/* The pypowsybl SVG is transparent, so the container provides the
diagram backdrop. Tokenised so dark mode flips it to near-black. */
background: var(--color-diagram-surface);
/* CSS containment: tells the browser this subtree's layout/paint is
independent of the rest of the page, reducing work during viewBox changes */
contain: layout style paint;
}
/* ===== Dark-mode diagram legibility =====
pypowsybl bakes voltage-coded line colours into the SVG via an inline
<style> block; those read fine on the near-black backdrop. The text
does NOT. Two distinct cases:
- edge-info flow values are SVG <text> (dark fill) → recolour light.
- VL labels are HTML <div>s inside <foreignObject> (dark text, no
backing) → give them a light text + dark chip.
We deliberately do NOT blanket-recolour every <text> in the SVG: the
action-overview pin labels are <text> with their own fills (dark text
on a white pin glyph) and must stay dark. So edge-info is targeted by
class. `!important` beats pypowsybl's appended inline <style>. */
[data-theme="dark"] .svg-container .nad-edge-infos text {
fill: var(--color-text-primary) !important;
/* pypowsybl paints a white stroke "halo" under each flow value so it
reads over the coloured line. On the near-black backdrop that white
halo turns the light text fuzzy — repaint the halo in the backdrop
colour (still legible over lines, no white fringe). */
stroke: var(--color-diagram-surface) !important;
paint-order: stroke fill !important;
}
[data-theme="dark"] .svg-container foreignObject.nad-text-nodes div,
[data-theme="dark"] .svg-container .nad-vl-nodes foreignObject div,
[data-theme="dark"] .svg-container .nad-label-nodes foreignObject div,
[data-theme="dark"] .svg-container .nad-label-box {
color: var(--color-text-primary) !important;
background-color: rgba(27, 31, 36, 0.85) !important;
border-color: var(--color-border) !important;
}
/* Action-overview "dim the network" rect — set to fill='white' inline
for light mode. Drive its colour from the diagram-surface token (a
CSS `fill` property beats the inline presentation attribute), so in
dark mode it dims toward near-black instead of leaving a grey square
over the dark backdrop. */
.svg-container .nad-overview-dim-rect {
fill: var(--color-diagram-surface);
}
/* ===== Dark-mode SLD legibility =====
The Single-Line-Diagram overlay injects pypowsybl `sld-*` markup onto
a dark panel. Feeder / power-value labels (`.sld-label`) and graph
labels are dark <text> → recolour them to light. We exclude the
flow-delta text classes (`sld-delta-text-*`) because those carry a
meaningful positive / negative / neutral colour applied by
SldOverlay. Scoped to the overlay root so nothing else is touched. */
[data-theme="dark"] [data-testid="sld-overlay"] text:not([class*="sld-delta-text"]) {
fill: var(--color-text-primary) !important;
}
.svg-container svg {
width: 100%;
height: 100%;
}
/* Keep strokes at constant screen-pixel width regardless of viewBox zoom.
Without this, lines become invisible at full zoom-out and extremely thick
when zoomed in, causing both visual loss of pypowsybl native colors and
expensive rendering / zoom lag on large grids. */
.svg-container svg path,
.svg-container svg line,
.svg-container svg polyline,
.svg-container svg rect {
vector-effect: non-scaling-stroke;
}
/* During active pan/zoom interaction disable pointer-events on all SVG
children to skip expensive hit-testing during drag/zoom. */
.svg-container.svg-interacting svg * {
pointer-events: none !important;
}
/* ===== Interaction-time paint culling =====
On a large NAD (≈5k voltage levels / ≈100k DOM nodes) the per-frame
cost of a viewBox pan/zoom is dominated by two element classes the
browser must re-rasterise every frame: the HTML voltage-level labels
(<foreignObject> — by far the most expensive SVG primitive to paint)
and the edge-info flow values + direction arrows. They are unreadable
mid-gesture anyway, so we drop them from the render tree while a
gesture is active (`usePanZoom` toggles `.svg-interacting` on
wheel/drag start and clears it on settle). On settle they repaint
once at the resting viewBox.
This is GPU-independent — it shrinks the work of the plain viewBox
repaint path itself, so it holds up even on software-rendered /
remote-desktop / GPU-blocklisted sessions (common for control-room
operators) where a CSS-transform compositing trick would instead
regress badly (every pan frame re-rasters new tiles of the whole
layer). Measured on the pypsa_eur_eur220_225_380_400 grid (5247 VLs,
8205 branches, 7.3 MB SVG) with an interleaved A/B headless benchmark,
per painted frame: pan 290 ms -> 188 ms (~1.5x), zoom 378 ms -> 226 ms
(~1.7x). The win is dropping the <foreignObject> HTML labels (the most
expensive SVG paint primitive) and is expected to be larger on GPU
hardware where that CPU-side raster dominates relatively more.
See docs/performance/history/interaction-paint-culling.md. The selector
list mirrors `.nad-hide-vl-labels` below (every shape pypowsybl uses
for a VL label) plus the edge-info group. */
.svg-container.svg-interacting .nad-edge-infos,
.svg-container.svg-interacting .nad-text-nodes,
.svg-container.svg-interacting foreignObject.nad-text-nodes,
.svg-container.svg-interacting .nad-vl-nodes foreignObject,
.svg-container.svg-interacting .nad-label-nodes foreignObject,
.svg-container.svg-interacting .nad-label-nodes,
.svg-container.svg-interacting .nad-label-box,
.svg-container.svg-interacting .nad-text-edges {
display: none !important;
}
/* ===== Zoom-tier Level-of-Detail =====
data-zoom-tier is set by usePanZoom based on the ratio of the current
viewBox width to the original (full-extent) viewBox width.
- "overview" (ratio > 0.5): hide text-heavy elements to reduce paint cost
- "region" (0.15 < ratio ≤ 0.5): show edge info, keep minor labels hidden
- "detail" (ratio ≤ 0.15): everything visible (default rendering)
Elements are hidden via display:none, which removes them from the render
tree without removing them from the DOM — preserving metadata, ID maps,
and highlight state. */
/* Overview: hide edge-info flow values and node label boxes */
[data-zoom-tier="overview"] .nad-edge-infos {
display: none;
}
[data-zoom-tier="overview"] .nad-vl-nodes foreignObject {
display: none;
}
/* Region: edge-info visible, but node label boxes still hidden */
[data-zoom-tier="region"] .nad-vl-nodes foreignObject {
display: none;
}
/* User-toggle: hide every voltage-level label on the diagram. pypowsybl
emits VL labels in two interchangeable shapes depending on the diagram
configuration: a root-level `<foreignObject class="nad-text-nodes">`
carrying HTML `<div>` labels, and inline `<text>` siblings under
`.nad-vl-nodes` / `.nad-label-nodes`. Cover both shapes (and the
inline `nad-label-box` divs that some pypowsybl builds emit at the
SVG root without a foreignObject wrapper) so the toggle works across
all networks. `.nad-text-edges` hides the connector tick that links
each voltage-level node to its label box — without it the label is
gone but a dangling line remains. `!important` overrides pypowsybl's
own inline `<style>` block, which is appended to the document AFTER
App.css when the SVG is injected by `replaceChildren(svg)`. */
.svg-container.nad-hide-vl-labels .nad-text-nodes,
.svg-container.nad-hide-vl-labels foreignObject.nad-text-nodes,
.svg-container.nad-hide-vl-labels .nad-vl-nodes foreignObject,
.svg-container.nad-hide-vl-labels .nad-label-nodes foreignObject,
.svg-container.nad-hide-vl-labels .nad-label-nodes,
.svg-container.nad-hide-vl-labels .nad-label-box,
.svg-container.nad-hide-vl-labels .nad-text-edges {
display: none !important;
}
/* Voltage-level disks are interactive: single-click selects (Inspect +
auto-zoom), double-click opens the SLD, hover shows the name while the
static labels are hidden (see utils/svg/vlInteractions.ts). The VL name
box is interactive too — a single click opens the SLD. The pointer
cursor is a static rule so the affordance costs nothing at render or
gesture time; during a pan/zoom `.svg-interacting` disables SVG hit-
testing, so the container's grab/grabbing cursor wins instead. */
.svg-container .nad-vl-nodes,
.svg-container .nad-label-box {
cursor: pointer;
}
/* ===== Highlight styles ===== */
.nad-highlight {
stroke: var(--color-danger) !important;
stroke-width: 4px !important;
opacity: 1;
}
circle.nad-highlight {
fill: var(--color-danger) !important;
}
/* Highlight overloaded lines in orange (clone-based halo) */
.nad-overloaded {
opacity: 1 !important;
}
.nad-overloaded path,
.nad-overloaded line,
.nad-overloaded polyline,
.nad-overloaded rect,
.nad-overloaded polygon {
stroke: var(--signal-overload) !important;
stroke-width: 120px !important;
stroke-opacity: 1 !important;
stroke-dasharray: none !important;
fill: none !important;
stroke-linecap: round !important;
stroke-linejoin: round !important;
}
.nad-overloaded text,
.nad-overloaded foreignObject {
display: none !important;
}
/*
* Disconnected branch marker used by the svgPatch DOM-recycling path
* (see docs/performance/history/svg-dom-recycling.md). Applied to
* edges whose equipment ID is listed in the patch payload's
* `disconnected_edges` — the contingency line on the N-1 tab, plus
* any action-induced extras. Mirrors pypowsybl's native
* disconnected-branch look so a patched N-1 reads the same as a
* full-fetch N-1. `vector-effect: non-scaling-stroke` keeps the
* dash length stable at zoom-out on large grids (same rationale as
* the guard in docs/performance/rendering-optimization-plan.md).
*/
.nad-disconnected,
.nad-disconnected > g,
.nad-disconnected path,
.nad-disconnected line,
.nad-disconnected polyline,
.nad-disconnected > g path,
.nad-disconnected > g line,
.nad-disconnected > g polyline {
stroke-dasharray: 20 10 !important;
stroke-opacity: 1 !important;
vector-effect: non-scaling-stroke !important;
}
/* Remedial action targets: purple-pink */
.nad-action-target {
opacity: 1 !important;
}
/* Contingency highlight: yellow */
.nad-contingency-highlight {
opacity: 1 !important;
}
/* For lines and paths, use grid units for stroke-width so the halo is always
significantly wider than the actual element it highlights. */
.nad-action-target path,
.nad-action-target line,
.nad-action-target polyline,
.nad-action-target rect,
.nad-action-target polygon {
stroke: var(--signal-action-target) !important;
stroke-width: 150px !important;
stroke-opacity: 1 !important;
stroke-dasharray: none !important;
fill: none !important;
stroke-linecap: round !important;
stroke-linejoin: round !important;
}
.nad-contingency-highlight path,
.nad-contingency-highlight line,
.nad-contingency-highlight polyline,
.nad-contingency-highlight rect,
.nad-contingency-highlight polygon {
stroke: var(--signal-contingency) !important;
stroke-width: 150px !important;
stroke-opacity: 1 !important;
stroke-dasharray: none !important;
fill: none !important;
stroke-linecap: round !important;
stroke-linejoin: round !important;
}
/* ===== Zoom-adaptive halo width (smooth, no tier snap) =====
The overload / action / contingency line halos are screen-space
(`vector-effect: non-scaling-stroke`), so their `stroke-width` is rendered
pixels. usePanZoom drives `--nad-halo-w` CONTINUOUSLY from the zoom ratio:
- zoomed IN → thin (HALO_MIN_PX, ~24px): a clean trace of the branch, the
look the operator wants at deep zoom;
- zoomed OUT → grows toward a prominent marker (HALO_MAX_PX) so the
contingency stays visible on the full-extent view,
with no abrupt jump at a tier boundary (the old `data-zoom-tier` 24px-vs-
120px snap). This rule comes AFTER the per-class colour rules above, so the
var overrides their base `stroke-width`. It defaults thin if JS hasn't set
the var yet. `non-scaling-stroke` is re-asserted here as a guard against a
pypowsybl build flipping equipment paths back to `vector-effect: none`. */
.nad-overloaded path, .nad-overloaded line, .nad-overloaded polyline, .nad-overloaded rect, .nad-overloaded polygon,
.nad-action-target path, .nad-action-target line, .nad-action-target polyline, .nad-action-target rect, .nad-action-target polygon,
.nad-contingency-highlight path, .nad-contingency-highlight line, .nad-contingency-highlight polyline, .nad-contingency-highlight rect, .nad-contingency-highlight polygon {
stroke-width: var(--nad-halo-w, 24px) !important;
vector-effect: non-scaling-stroke !important;
}
/* For nodes (circles and rects), add a screen-space stroke to create a glowing
background halo that doesn't shrink into invisibility on large grids. */
.nad-action-target circle,
.nad-action-target rect {
stroke: var(--signal-action-target) !important;
stroke-width: 45px !important;
stroke-opacity: 1 !important;
fill: var(--signal-action-target) !important;
fill-opacity: 1 !important;
vector-effect: non-scaling-stroke !important;
}
.nad-contingency-highlight circle,
.nad-contingency-highlight rect {
stroke: var(--signal-contingency) !important;
stroke-width: 45px !important;
stroke-opacity: 1 !important;
fill: var(--signal-contingency) !important;
fill-opacity: 1 !important;
opacity: 1 !important;
vector-effect: non-scaling-stroke !important;
}
.nad-highlight-clone {
opacity: 1 !important;
}
/* Force full opacity on original assets that are currently being highlighted
as action targets. This prevents them from being dimmed by Impacts mode
logic (which might consider them "grey" or non-impacted), which would
otherwise allow the background halo to show through and make them look
transparent. */
.nad-action-target-original,
.sld-highlight-action-original,
.sld-highlight-breaker-original,
.sld-highlight-contingency-original,
.sld-highlight-overloaded-original {
opacity: 1 !important;
stroke-opacity: 1 !important;
fill-opacity: 1 !important;
}
/* Hide text and foreignObjects in the background clone to avoid messy overlays */
.nad-action-target text,
.nad-action-target foreignObject {
display: none !important;
}
/* Suppress expensive drop-shadow filters during active interaction.
The compositing cost of multiple drop-shadows on cloned highlight
elements is high — skip it while the viewBox is changing rapidly. */
.svg-container.svg-interacting .nad-action-target,
.svg-container.svg-interacting .nad-contingency-highlight,
.svg-container.svg-interacting .nad-overloaded {
filter: none !important;
}
/* ===== Delta flow visualization ===== */
/* Positive delta: flow increased after action (orange) */
.nad-delta-positive path,
.nad-delta-positive line,
.nad-delta-positive polyline {
stroke: var(--signal-delta-positive) !important;
stroke-width: 3px !important;
}
/* Negative delta: flow decreased after action (blue) */
.nad-delta-negative path,
.nad-delta-negative line,
.nad-delta-negative polyline {
stroke: var(--signal-delta-negative) !important;
stroke-width: 3px !important;
}
/* Grey: negligible change (below threshold) */
.nad-delta-grey path,
.nad-delta-grey line,
.nad-delta-grey polyline {
stroke: var(--signal-delta-neutral) !important;
stroke-opacity: 0.4 !important;
}
/* ===== SLD overlay delta flow visualization ===== */
/* Applied to feeder group elements (sld-top-feeder / sld-bottom-feeder) inside
the Single Line Diagram overlay when in Impacts mode. Cascades into child
wires, arrows, symbols. */
.sld-delta-positive path,
.sld-delta-positive line,
.sld-delta-positive polyline,
.sld-delta-positive rect,
.sld-delta-positive circle {
stroke: var(--signal-overload) !important;
stroke-width: 2px !important;
}
/* Target-topology preview: flow values + arrows are stale (no load
flow was run) so they're greyed until the operator re-simulates.
pypowsybl renders feeder flow info as <text> and ARROW_* glyphs. */
.sld-preview-stale text {
fill: var(--signal-delta-neutral) !important;
opacity: 0.55 !important;
}
.sld-preview-stale .sld-feeder-info,
.sld-preview-stale [class*="ARROW"] {
opacity: 0.4 !important;
}
/* Editable-element affordance while editing the SLD: a plain pointer
cursor on the cells (no recolouring of branches / busbars). Loads and
generators ALSO get their NAME rendered as a dark-blue button (see
.sld-injection-name-btn below) to invite a click. */
.sld-switch-editable,
.sld-injection-editable {
cursor: pointer;
}
/* Relabelled branch feeder names are clickable: a click navigates the SLD
overlay to that voltage level (the branch's other extremity). The dashed
underline + pointer cursor signal the affordance without recolouring. */
.sld-feeder-navigable {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
}
.sld-feeder-navigable:hover {
fill: var(--color-brand-strong) !important;
}
/* Injection name rendered as a button (same dark blue + white text as the
former "Manual action" button): a rounded rect injected behind the name
<text>, with the label recoloured on top. */
.sld-injection-name-btn {
fill: var(--color-brand);
cursor: pointer;
}
.sld-injection-name-btn:hover {
fill: var(--color-brand-strong);
}
.sld-injection-name-label {
fill: var(--color-text-on-brand) !important;
font-weight: 600;
cursor: pointer;
}
/* Pending switch toggles (interactive SLD topology edit). */
.sld-user-toggle-open path,
.sld-user-toggle-open line,
.sld-user-toggle-open polyline,
.sld-user-toggle-open rect,
.sld-user-toggle-open circle {
stroke: var(--signal-edit-open) !important;
stroke-width: 2.5px !important;
stroke-dasharray: 4 2 !important;
}
.sld-user-toggle-closed path,
.sld-user-toggle-closed line,
.sld-user-toggle-closed polyline,
.sld-user-toggle-closed rect,
.sld-user-toggle-closed circle {
stroke: var(--signal-edit-closed) !important;
stroke-width: 2.5px !important;
stroke-dasharray: 4 2 !important;
}
/* Pending injection (load / generator active-power) retune. */
.sld-user-injection path,
.sld-user-injection line,
.sld-user-injection polyline,
.sld-user-injection rect,
.sld-user-injection circle {
stroke: var(--signal-edit-injection) !important;
stroke-width: 2.5px !important;
stroke-dasharray: 4 2 !important;
}
.sld-delta-negative path,
.sld-delta-negative line,
.sld-delta-negative polyline,
.sld-delta-negative rect,
.sld-delta-negative circle {
stroke: var(--signal-delta-negative) !important;
stroke-width: 2px !important;
}
.sld-delta-grey path,
.sld-delta-grey line,
.sld-delta-grey polyline,
.sld-delta-grey rect,
.sld-delta-grey circle {
stroke: var(--signal-delta-neutral) !important;
stroke-opacity: 0.5 !important;
}
/* Delta text label styling */
.sld-delta-text-positive {
fill: var(--signal-overload) !important;
}
.sld-delta-text-negative {
fill: var(--signal-delta-negative) !important;
}
.sld-delta-text-grey {
fill: var(--signal-delta-neutral) !important;
fill-opacity: 0.5 !important;
}
/* ===== SLD impact highlights ===== */
/* These classes are applied to CLONED elements placed in a background layer,
so they don't affect the original SLD rendering (flows/impacts modes). */
/* Contingency element on N-1 SLD (yellow glow behind) */
.sld-highlight-clone.sld-highlight-contingency {
filter: drop-shadow(0 0 4px var(--signal-contingency)) drop-shadow(0 0 8px var(--signal-contingency));
}
.sld-highlight-clone.sld-highlight-contingency path,
.sld-highlight-clone.sld-highlight-contingency line,
.sld-highlight-clone.sld-highlight-contingency polyline,
.sld-highlight-clone.sld-highlight-contingency rect,
.sld-highlight-clone.sld-highlight-contingency circle {
stroke: var(--signal-contingency) !important;
stroke-width: 6px !important;
stroke-opacity: 1 !important;
stroke-dasharray: none !important;
fill: none !important;
}
/* Action target on action SLD (purple-pink halo behind) */
.sld-highlight-clone.sld-highlight-action {
filter: drop-shadow(0 0 4px var(--signal-action-target)) drop-shadow(0 0 8px var(--signal-action-target));
}
.sld-highlight-clone.sld-highlight-action path,
.sld-highlight-clone.sld-highlight-action line,
.sld-highlight-clone.sld-highlight-action polyline,
.sld-highlight-clone.sld-highlight-action rect,
.sld-highlight-clone.sld-highlight-action circle {
stroke: var(--signal-action-target) !important;
stroke-width: 6px !important;
stroke-opacity: 1 !important;
fill: none !important;
}
/* Overloaded lines on SLD (orange halo, consistent with contingency style) */
.sld-highlight-clone.sld-highlight-overloaded {
filter: drop-shadow(0 0 4px var(--signal-overload)) drop-shadow(0 0 8px var(--signal-overload));
}
.sld-highlight-clone.sld-highlight-overloaded path,
.sld-highlight-clone.sld-highlight-overloaded line,
.sld-highlight-clone.sld-highlight-overloaded polyline,
.sld-highlight-clone.sld-highlight-overloaded rect,
.sld-highlight-clone.sld-highlight-overloaded circle {
stroke: var(--signal-overload) !important;
stroke-width: 6px !important;
stroke-opacity: 1 !important;
stroke-dasharray: none !important;
fill: none !important;
}
/* Affected breakers/switches on SLD (light purple halo behind) */
.sld-highlight-clone.sld-highlight-breaker {
filter: drop-shadow(0 0 3px var(--signal-breaker)) drop-shadow(0 0 6px var(--signal-breaker));
}
.sld-highlight-clone.sld-highlight-breaker path,
.sld-highlight-clone.sld-highlight-breaker line,
.sld-highlight-clone.sld-highlight-breaker rect,
.sld-highlight-clone.sld-highlight-breaker circle {
stroke: var(--signal-breaker) !important;
stroke-width: 6px !important;
stroke-opacity: 1 !important;
fill: none !important;
}
/* Hide text/labels in cloned highlight elements */
.sld-highlight-clone text,
.sld-highlight-clone .sld-label,
.sld-highlight-clone foreignObject {
display: none !important;
}
/* ===== Voltage Range Slider (vertical, right sidebar) ===== */
.voltage-sidebar {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 62px;
background: var(--color-surface-muted);
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
align-items: center;
z-index: 15;
padding: var(--space-2) 0;
box-sizing: border-box;
}
.voltage-sidebar .vs-label {
font-size: 0.65rem;
font-weight: bold;
color: var(--color-chrome);
writing-mode: vertical-rl;
text-orientation: mixed;
letter-spacing: 0.5px;
margin-bottom: var(--space-1);
}
.voltage-sidebar .vs-range-label {
font-size: 0.6rem;
font-weight: bold;
color: var(--color-chrome);
background: var(--color-brand-soft);
border-radius: 3px;
padding: var(--space-half) var(--space-1);
margin-bottom: var(--space-2);
text-align: center;
line-height: 1.2;
}
.voltage-slider-container {
position: relative;
flex: 1;
width: 28px;
min-height: 0;
}
.voltage-slider-track {
position: absolute;
left: 12px;
top: 0;
bottom: 0;
width: 4px;
background: var(--color-border);
border-radius: 2px;
}
.voltage-slider-range {
position: absolute;
left: 12px;
width: 4px;
background: var(--color-brand-mid);
border-radius: 2px;
}
.voltage-slider-container input[type=range] {
position: absolute;
left: 0;
width: 28px;
-webkit-appearance: none;
appearance: none;
background: transparent;
pointer-events: none;
margin: 0;
writing-mode: vertical-lr;
direction: rtl;
}
/* Suppress the Chrome / Safari spinner arrows on the compact
* Max-loading threshold input inside the ActionFilterRings — the bar
* needs to fit on a single row so the extra arrow column is dropped.
* Firefox is covered by the inline ``MozAppearance: textfield`` style.
*/
input[data-testid="sidebar-filter-threshold-input"]::-webkit-inner-spin-button,
input[data-testid="sidebar-filter-threshold-input"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.voltage-slider-container input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
border-radius: 50%;
background: var(--color-brand-mid);
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
cursor: pointer;
pointer-events: all;
}
.voltage-slider-container input[type=range]::-moz-range-thumb {
height: 18px;
width: 18px;
border-radius: 50%;
background: var(--color-brand-mid);
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
cursor: pointer;
pointer-events: all;
}
.voltage-slider-ticks {
position: absolute;
top: 0;
bottom: 0;
left: 28px;
width: 24px;
}
.voltage-slider-ticks span {
position: absolute;
font-size: 0.58rem;
color: var(--color-text-tertiary);
white-space: nowrap;
transform: translateY(-50%);
left: 2px;
}
/* Collapsed voltage filter toggle button */
.voltage-sidebar-toggle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 24px;
background: var(--color-surface-muted);
border: none;
border-left: 1px solid var(--color-border);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 15;
color: var(--color-text-tertiary);
font-size: var(--text-xs);
padding: 0;
}
.voltage-sidebar-toggle:hover {
background: var(--color-surface-raised);
color: var(--color-text-primary);
}
/* ============================================================
ActionCard progressive disclosure
============================================================
The star (⭐) and reject (✕) controls live in a "rail" in
the top-right of the card header (next to the severity icon
+ title row). The rail fades in on hover, on focus, and
whenever the card is the currently-viewed action. This trims
the at-rest visual load while keeping the controls a single
mouse-move away.
Implemented in CSS (not React state) so hover doesn't trigger
a re-render on every card. */
.action-card-rail {
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
}
.action-card:hover .action-card-rail,
.action-card:focus-within .action-card-rail,
.action-card.is-viewing .action-card-rail {
opacity: 1;
pointer-events: auto;
}
/* ============================================================
Action-overview pins (Remedial Action tab, no card selected)
============================================================ */
.nad-action-overview-pin {
transition: transform 0.15s ease;
transform-box: fill-box;
transform-origin: center bottom;
}
.nad-action-overview-pin:hover {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35));
}
.nad-action-overview-pin:hover > path {
stroke-width: 2.5;
}
/* The dim effect is achieved by a single white <rect> with
opacity 0.65 inserted ABOVE the NAD content. Highlights are
inserted at the START of the SVG (behind NAD content), matching
the N-1 tab's background-layer pattern. Pins sit on top.
Visual stack: highlights → NAD content → dim rect → pins.
No per-child opacity or stacking contexts needed.
See ActionOverviewDiagram.tsx preparedSvg. */
.nad-action-overview-container svg {
display: block;
/* Trade anti-aliasing quality for rendering speed. On a
10k+ element power-grid NAD the difference can be several
seconds of paint time. */
shape-rendering: optimizeSpeed;
text-rendering: optimizeSpeed;
}