Files
ledgrab/server/src/ledgrab/static/css/graph-editor.css
T
alexei.dolgolyov a5effba553 feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers
Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
  presets and system into one payload for the HA coordinator, collapsing
  the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
  unit-tested graph_schema engine — one authoritative connectable-field
  registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
  the routes for multi-broker MQTT; shared validate_mqtt_source_exists
  (_mqtt_validation.py) reused by device + output-target routes; stop
  update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
  (shared by __main__, android_entry, demo) so a lingering events WebSocket
  can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
  its authenticated token label (never the secret); uvicorn access_log off.

Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
  connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
  and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.

Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
2026-05-28 22:51:04 +03:00

1422 lines
29 KiB
CSS

/* ── Graph Editor ─────────────────────────────────────── */
/* Full viewport: hide footer & scroll-to-top when graph is active */
#tab-graph {
overflow: hidden;
}
/* Hide the global scrollbar when graph tab is active */
html:has(#tab-graph.active) {
overflow: hidden;
}
#tab-graph #graph-editor-content {
padding: 0;
margin: 0;
}
.graph-container {
position: relative;
width: 100%;
height: calc(100vh - var(--header-height, 60px) - 20px);
overflow: hidden;
background: transparent;
border-radius: 8px;
}
.graph-toolbar {
position: absolute;
top: 12px;
left: 12px;
display: flex;
gap: 4px;
z-index: 20;
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-md, 6px);
padding: 4px;
box-shadow: var(--lux-shadow-rack, 0 2px 8px var(--shadow-color));
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.graph-toolbar-drag {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
cursor: grab;
color: var(--text-muted);
font-size: 0.85rem;
user-select: none;
border-right: 1px solid var(--border-color);
margin-right: 2px;
padding-right: 2px;
letter-spacing: -1px;
flex-shrink: 0;
}
.graph-toolbar-drag:active,
.graph-toolbar-drag.dragging {
cursor: grabbing;
}
/* Vertical toolbar (docked to left/right side) */
.graph-toolbar.vertical {
flex-direction: column;
}
.graph-toolbar.vertical .graph-toolbar-drag {
width: auto;
height: 16px;
border-right: none;
border-bottom: 1px solid var(--border-color);
margin-right: 0;
margin-bottom: 2px;
padding-right: 0;
padding-bottom: 2px;
}
.graph-toolbar.vertical .graph-toolbar-sep {
width: 100%;
height: 1px;
}
.graph-toolbar.vertical .graph-zoom-label {
text-align: center;
}
/* Dock position indicators during toolbar drag */
.graph-dock-indicators {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 19;
}
.graph-dock-dot {
position: absolute;
width: 10px;
height: 10px;
margin-left: -5px;
margin-top: -5px;
border-radius: 50%;
background: var(--text-muted);
opacity: 0.25;
transition: opacity 0.15s, transform 0.15s, background 0.15s;
}
.graph-dock-dot.nearest {
opacity: 1;
background: var(--primary-color);
transform: scale(1.6);
box-shadow: 0 0 8px var(--primary-color);
}
.graph-toolbar .btn-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-color);
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background 0.15s;
}
.graph-toolbar .btn-icon:hover {
background: var(--bg-secondary);
}
.graph-toolbar .btn-icon.active {
background: var(--primary-color);
color: var(--primary-contrast);
}
.graph-toolbar .btn-icon:disabled {
opacity: 0.25;
cursor: default;
pointer-events: none;
}
.graph-toolbar-sep {
width: 1px;
background: var(--border-color);
margin: 4px 2px;
}
/* ── Toolbar overflow button (hidden on wide screens) ── */
.graph-tb-overflow-btn {
display: none;
}
/* ── Toolbar overflow menu ── */
.graph-overflow-menu {
display: none;
position: absolute;
z-index: 25;
min-width: 170px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 4px;
box-shadow: 0 4px 16px var(--shadow-color);
flex-direction: column;
gap: 2px;
}
.graph-overflow-menu.open {
display: flex;
}
.graph-overflow-menu.flip-up {
transform: translateY(-100%);
}
.graph-overflow-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border: none;
background: transparent;
color: var(--text-color);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
transition: background 0.15s;
}
.graph-overflow-menu button:hover {
background: var(--bg-secondary);
}
.graph-overflow-menu button.active {
background: var(--primary-color);
color: var(--primary-contrast);
}
.graph-overflow-menu button:disabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.graph-overflow-menu .icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.graph-overflow-sep {
height: 1px;
background: var(--border-color);
margin: 2px 4px;
}
/* ── Responsive: collapse toolbar on narrow viewports ── */
@media (max-width: 700px) {
.graph-toolbar [data-collapse] {
display: none;
}
.graph-tb-overflow-btn {
display: flex;
}
}
/* ── Legend panel ── */
.graph-legend {
position: absolute;
top: 12px;
z-index: 20;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 2px 8px var(--shadow-color);
font-size: 0.8rem;
display: none;
overflow: hidden;
}
.graph-legend.visible {
display: block;
}
.graph-legend-header {
display: flex;
align-items: center;
padding: 4px 10px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
cursor: grab;
user-select: none;
}
.graph-legend-header.dragging {
cursor: grabbing;
}
.graph-legend-title {
font-weight: 600;
font-size: 0.7rem;
color: var(--text-muted);
}
.graph-legend-body {
padding: 6px 12px;
max-height: 55vh;
overflow-y: auto;
}
.graph-legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
color: var(--text-secondary);
white-space: nowrap;
}
.graph-legend-dot {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
/* ── Minimap ── */
.graph-minimap {
position: absolute;
z-index: 20;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 2px 8px var(--shadow-color);
overflow: hidden;
display: none;
min-width: 120px;
min-height: 80px;
}
.graph-minimap.visible {
display: block;
}
.graph-minimap svg {
width: 100%;
height: 100%;
display: block;
cursor: crosshair;
}
.graph-minimap-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 6px;
cursor: grab;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
font-size: 0.7rem;
color: var(--text-muted);
user-select: none;
}
.graph-minimap-header.dragging {
cursor: grabbing;
}
.graph-minimap-resize {
position: absolute;
bottom: 0;
width: 14px;
height: 14px;
z-index: 2;
}
.graph-minimap-resize::after {
content: '';
position: absolute;
bottom: 2px;
width: 8px;
height: 8px;
border-bottom: 2px solid var(--text-muted);
opacity: 0.5;
}
.graph-minimap-resize-br {
right: 0;
cursor: se-resize;
}
.graph-minimap-resize-br::after {
right: 2px;
border-right: 2px solid var(--text-muted);
}
.graph-minimap-resize-bl {
left: 0;
cursor: sw-resize;
}
.graph-minimap-resize-bl::after {
left: 2px;
border-left: 2px solid var(--text-muted);
}
.graph-minimap-viewport {
fill: var(--primary-color);
fill-opacity: 0.08;
stroke: var(--primary-color);
stroke-width: 2;
stroke-opacity: 0.7;
vector-effect: non-scaling-stroke;
}
.graph-minimap-node {
rx: 2;
ry: 2;
}
/* ── SVG canvas ── */
.graph-svg {
width: 100%;
height: 100%;
cursor: grab;
}
.graph-svg.panning {
cursor: grabbing;
}
.graph-svg.connecting {
cursor: crosshair;
}
/* ── Grid background ── */
.graph-grid-dot {
fill: var(--lux-line, var(--border-color));
opacity: 0.32;
}
/* ── Node styles ── */
.graph-node {
cursor: pointer;
}
.graph-node.dragging {
cursor: grabbing;
opacity: 0.85;
}
.graph-node.dragging .graph-node-overlay {
display: none !important;
}
.graph-node-body {
fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--lux-line, var(--border-color));
stroke-width: 1;
rx: 6;
ry: 6;
transition: stroke 0.15s, stroke-width 0.15s, filter 0.2s ease;
}
.graph-node:hover .graph-node-body {
stroke: var(--lux-line-bold, var(--text-secondary));
stroke-width: 1;
filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.25));
}
.graph-node.selected .graph-node-body {
stroke: var(--ch-signal, var(--primary-color));
stroke-width: 2;
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
}
.graph-node-color-bar {
rx: 8;
ry: 8;
}
/* Clip the right side of color bar to be square */
.graph-node-color-bar-clip rect {
rx: 8;
ry: 8;
}
.graph-node-title {
fill: var(--lux-ink, var(--text-color));
font-size: 12px;
font-weight: 600;
/* Body font, not display — Big Shoulders is condensed and reads as
* "stretched" at 12 px in a node label. Display font is for hero
* headers only. */
font-family: var(--font-body, 'Manrope', 'DM Sans', sans-serif);
letter-spacing: 0;
}
.graph-node-subtitle {
fill: var(--lux-ink-dim, var(--text-secondary));
font-size: 9.5px;
font-weight: 600;
font-family: var(--font-mono, monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.graph-node-icon {
stroke: var(--lux-ink-mute, var(--text-muted));
fill: none;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.55;
}
.graph-node.running .graph-node-icon {
stroke: var(--ch-signal, var(--primary-color));
opacity: 0.95;
}
/* Custom per-entity icon: the embedded SVG strokes with currentColor, so it is
tinted via `color` (default muted; the node's icon_color overrides inline). */
.graph-node-custom-icon {
color: var(--lux-ink-mute, var(--text-muted));
opacity: 0.9;
}
.graph-node.running .graph-node-custom-icon {
color: var(--ch-signal, var(--primary-color));
opacity: 1;
}
/* ── Running indicator (animated gradient border + signal-flow glow) ── */
.graph-node.running .graph-node-body {
stroke: url(#running-gradient);
stroke-width: 2;
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
}
@keyframes graph-running-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.graph-node.running .graph-node-running-ring {
animation: graph-running-rotate 3s linear infinite;
transform-origin: center;
}
/* Running dot indicator */
.graph-node-running-dot {
r: 4;
fill: var(--success-color);
}
/* ── Ports ── */
.graph-port {
stroke: var(--bg-color);
stroke-width: 2;
opacity: 0.85;
transition: r 0.15s, opacity 0.15s;
pointer-events: all;
}
.graph-node:hover .graph-port,
.graph-node.selected .graph-port {
r: 5;
opacity: 1;
}
/* Larger touch targets for ports on touch devices */
@media (pointer: coarse) {
.graph-port { r: 6; }
.graph-node:hover .graph-port,
.graph-node.selected .graph-port { r: 7; }
}
/* Port labels — hidden by default, shown on node hover, positioned outside node */
.graph-port-label {
font-size: 9px;
font-weight: 700;
font-family: var(--font-mono, monospace);
letter-spacing: 0.08em;
text-transform: uppercase;
fill: var(--lux-ink-dim, var(--text-color));
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
paint-order: stroke fill;
stroke: var(--lux-bg-0, var(--bg-color));
stroke-width: 3px;
stroke-linejoin: round;
}
.graph-node:hover .graph-port-label,
.graph-node.selected .graph-port-label {
opacity: 1;
}
/* Port output cursor: draggable */
.graph-port-out {
cursor: crosshair;
}
/* Port interaction states during connection drag */
.graph-port-compatible {
r: 6 !important;
opacity: 1 !important;
cursor: pointer;
filter: drop-shadow(0 0 3px currentColor);
}
.graph-port-incompatible {
opacity: 0.15 !important;
}
.graph-port-drop-target {
r: 7 !important;
stroke: var(--ch-signal, var(--primary-color)) !important;
stroke-width: 3 !important;
filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color)));
}
/* Whole-node drop targets: a source can be dropped on any compatible node to
wire one of its slots — including empty slots that have no input port yet. */
.graph-svg.connecting .graph-node-compatible .graph-node-body {
stroke: var(--ch-signal, var(--primary-color));
stroke-dasharray: 4 3;
stroke-width: 1.5;
}
.graph-node-drop-target .graph-node-body {
stroke: var(--ch-signal, var(--primary-color)) !important;
stroke-width: 2.5 !important;
stroke-dasharray: none !important;
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent));
}
/* ── Edges ── */
.graph-edge {
fill: none;
stroke-width: 2;
opacity: 0.6;
cursor: pointer;
transition: opacity 0.15s, stroke-width 0.15s;
}
.graph-edge:hover {
opacity: 1;
stroke-width: 3;
}
/* Wider invisible hit area for thin edges */
.graph-edge {
stroke-linecap: round;
}
.graph-edge-arrow {
fill: currentColor;
opacity: 0.6;
}
.graph-edge.highlighted {
opacity: 1;
stroke-width: 2.5;
}
.graph-edge.dimmed,
.graph-edge.dimmed.graph-edge-active {
opacity: 0.12;
filter: none;
}
/* Nested edges (composite layers, zones) — not drag-editable */
.graph-edge-nested {
stroke-dasharray: 2 2;
opacity: 0.4;
}
/* Edge field labels — hidden until zoomed in enough to read them. */
.graph-edge-label {
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono, monospace);
fill: var(--text-secondary);
paint-order: stroke;
stroke: var(--lux-bg-1, var(--card-bg));
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.graph-edges.show-labels .graph-edge-label {
opacity: 0.85;
}
/* Edge type colors */
.graph-edge-picture { stroke: #42A5F5; color: #42A5F5; }
.graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; }
.graph-edge-value { stroke: #FFA726; color: #FFA726; }
.graph-edge-device { stroke: #78909C; color: #78909C; }
.graph-edge-clock { stroke: #26C6DA; color: #26C6DA; stroke-dasharray: 6 3; }
.graph-edge-audio { stroke: #EF5350; color: #EF5350; }
.graph-edge-template { stroke: #AB47BC; color: #AB47BC; stroke-dasharray: 4 2; }
.graph-edge-scene { stroke: #CE93D8; color: #CE93D8; }
.graph-edge-default { stroke: var(--text-muted); color: var(--text-muted); }
/* ── Active edge flow (hybrid: thicker + saturated + animated dash) ── */
.graph-edge.graph-edge-active {
opacity: 1;
stroke-width: 2.5;
filter: drop-shadow(0 0 3px currentColor);
}
/* Clear dash patterns on active edges so glow looks clean */
.graph-edge-clock.graph-edge-active,
.graph-edge-template.graph-edge-active { stroke-dasharray: none; }
.graph-edge-nested.graph-edge-active { stroke-dasharray: none; }
/* Flow dots on active edges */
.graph-edge-flow { pointer-events: none; }
.graph-edge-flow circle { filter: drop-shadow(0 0 2px currentColor); }
/* ── Drag connection preview ── */
.graph-drag-edge {
fill: none;
stroke: var(--primary-color);
stroke-width: 2;
stroke-dasharray: 6 3;
opacity: 0.7;
pointer-events: none;
}
/* ── Edge context menu ── */
.graph-edge-menu {
position: absolute;
z-index: 40;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 16px var(--shadow-color);
padding: 4px;
min-width: 120px;
}
.graph-edge-menu-item {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.85rem;
font-family: inherit;
text-align: left;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
}
.graph-edge-menu-item:hover {
background: var(--bg-secondary);
}
.graph-edge-menu-item.danger {
color: var(--danger-color);
}
.graph-edge-menu-item.danger:hover {
background: var(--danger-color);
color: var(--primary-contrast);
}
/* ── Hover overlay (action buttons) ── */
.graph-node-overlay {
display: none;
pointer-events: none;
}
.graph-node:hover .graph-node-overlay,
.graph-node.selected .graph-node-overlay {
display: block;
}
.graph-node-overlay-bg {
fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--border-color);
stroke-width: 1;
rx: 6;
ry: 6;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
}
.graph-node-overlay-btn {
cursor: pointer;
pointer-events: all;
}
.graph-node-overlay-btn rect {
fill: transparent;
rx: 4;
ry: 4;
}
.graph-node-overlay-btn:hover rect {
fill: var(--bg-secondary);
}
.graph-node-overlay-btn text {
fill: var(--text-color);
font-size: 14px;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
}
.graph-node-overlay-btn.danger:hover rect {
fill: var(--danger-color);
}
.graph-node-overlay-btn.danger:hover text {
fill: var(--primary-contrast);
}
.graph-node-overlay-btn.success:hover rect {
fill: var(--success-color);
}
.graph-node-overlay-btn.success:hover text {
fill: var(--primary-contrast);
}
/* ── Selection rectangle ── */
.graph-selection-rect {
fill: var(--primary-color);
fill-opacity: 0.08;
stroke: var(--primary-color);
stroke-width: 1;
stroke-dasharray: 4 2;
}
/* ── Orphan warning ── */
.graph-node.orphan .graph-node-body {
stroke: var(--warning-color);
stroke-width: 1;
stroke-dasharray: 4 3;
}
/* ── Health overlay: configuration issues (broken refs / cycles) ── */
.graph-node.has-issue .graph-node-body {
stroke: var(--danger-color);
stroke-width: 2;
stroke-dasharray: 5 3;
}
.graph-node-issue {
color: var(--danger-color);
}
/* ── Search highlight ── */
.graph-node.search-match .graph-node-body {
stroke: var(--info-color);
stroke-width: 2.5;
}
/* ── Zoom label (inline in toolbar) ── */
.graph-zoom-label {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
font-size: 0.7rem;
color: var(--text-muted);
user-select: none;
padding: 0 2px;
}
/* ── Empty state ── */
.graph-empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--text-muted);
}
.graph-empty h3 {
font-size: 1.2rem;
margin-bottom: 8px;
}
.graph-empty p {
font-size: 0.9rem;
}
/* ── Graph command palette ── */
.graph-search {
position: absolute;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
width: 360px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: 0 8px 24px var(--shadow-color);
display: none;
}
.graph-search.visible {
display: block;
}
.graph-search-input {
width: 100%;
padding: 10px 14px;
border: none;
border-bottom: 1px solid var(--border-color);
background: transparent;
color: var(--text-color);
font-size: 0.9rem;
font-family: inherit;
outline: none;
border-radius: 10px 10px 0 0;
}
.graph-search-results {
max-height: 280px;
overflow-y: auto;
}
.graph-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.1s;
}
.graph-search-item:hover,
.graph-search-item.active {
background: var(--bg-secondary);
}
.graph-search-item-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
.graph-search-item-name {
font-size: 0.85rem;
color: var(--text-color);
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.graph-search-item-type {
font-size: 0.75rem;
color: var(--text-muted);
}
/* ── Filter bar ── */
.graph-filter {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: none;
flex-direction: column;
gap: 6px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 10px;
z-index: 30;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
min-width: 260px;
max-width: 480px;
}
.graph-filter.visible {
display: flex;
}
.graph-filter-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.graph-filter-actions {
gap: 4px;
}
.graph-filter-pill {
background: transparent;
border: 1px solid var(--pill-color, var(--border-color));
color: var(--pill-color, var(--text-muted));
border-radius: 12px;
padding: 2px 8px;
font-size: 0.7rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
line-height: 1.4;
}
.graph-filter-pill:hover {
background: color-mix(in srgb, var(--pill-color, var(--border-color)) 15%, transparent);
}
.graph-filter-pill.active {
background: var(--pill-color, var(--primary-color));
color: #fff;
border-color: var(--pill-color, var(--primary-color));
}
/* ── Types button + popover ── */
.graph-filter-types-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-muted);
border-radius: 12px;
padding: 2px 10px;
font-size: 0.7rem;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
line-height: 1.4;
margin-right: auto;
}
.graph-filter-types-btn:hover {
border-color: var(--primary-color);
color: var(--text-color);
}
.graph-filter-types-badge {
display: none;
background: var(--primary-color);
color: #fff;
border-radius: 8px;
font-size: 0.6rem;
font-weight: 700;
min-width: 14px;
height: 14px;
line-height: 14px;
text-align: center;
padding: 0 3px;
}
.graph-filter-types-badge.visible {
display: inline-block;
}
/* Issues toolbar button + count badge */
.graph-issues-btn {
position: relative;
color: var(--danger-color);
}
.graph-issues-count {
position: absolute;
top: 0;
right: 0;
transform: translate(35%, -35%);
background: var(--danger-color);
color: #fff;
border-radius: 8px;
font-size: 0.6rem;
font-weight: 700;
min-width: 14px;
height: 14px;
line-height: 14px;
text-align: center;
padding: 0 3px;
}
.graph-issues-count:empty {
display: none;
}
.graph-filter-types-popover {
display: none;
flex-direction: column;
gap: 6px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px;
max-height: 320px;
overflow-y: auto;
}
.graph-filter-types-popover.visible {
display: flex;
}
.graph-filter-type-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.graph-filter-type-group-header {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
padding: 2px 4px;
cursor: pointer;
border-radius: 4px;
user-select: none;
}
.graph-filter-type-group-header:hover {
background: var(--hover-bg, rgba(255,255,255,0.05));
color: var(--text-color);
}
.graph-filter-type-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 4px 3px 8px;
font-size: 0.75rem;
color: var(--text-color);
cursor: pointer;
border-radius: 4px;
user-select: none;
}
.graph-filter-type-item:hover {
background: var(--hover-bg, rgba(255,255,255,0.05));
}
.graph-filter-type-item input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--primary-color);
cursor: pointer;
flex-shrink: 0;
}
.graph-filter-type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.graph-filter-icon {
flex-shrink: 0;
stroke: var(--text-muted);
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.graph-filter-input {
flex: 1;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.85rem;
outline: none;
min-width: 0;
}
.graph-filter-clear {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.graph-filter-clear:hover {
color: var(--text-color);
}
.graph-filter-btn.active {
color: var(--primary-color);
}
/* ── Filtered-out state ── */
.graph-node.graph-filtered-out {
opacity: 0.12;
pointer-events: none;
}
.graph-edge.graph-filtered-out {
opacity: 0.06;
}
/* ── Loading overlay for relayout ── */
/* ── Add entity menu ── */
.graph-add-entity-menu {
position: absolute;
z-index: 30;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: 0 8px 24px var(--shadow-color);
padding: 6px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
min-width: 280px;
}
.graph-add-entity-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.8rem;
font-family: inherit;
text-align: left;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
white-space: nowrap;
}
.graph-add-entity-item:hover {
background: var(--bg-secondary);
}
.graph-add-entity-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
.graph-add-entity-icon {
font-size: 1rem;
flex-shrink: 0;
}
/* ── Fullscreen mode ── */
.graph-container:fullscreen {
background: var(--bg-color);
height: 100vh;
}
.graph-container:fullscreen #bg-anim-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.graph-container:fullscreen .graph-svg {
position: relative;
z-index: 1;
}
/* ── Loading overlay for relayout ── */
.graph-loading-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
border-radius: 8px;
}
/* ── Keyboard shortcuts help panel ── */
.graph-help-panel {
position: absolute;
z-index: 30;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
width: 300px;
display: none;
flex-direction: column;
font-size: 0.8rem;
}
.graph-help-panel.visible {
display: flex;
}
.graph-help-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
}
.graph-help-body {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 360px;
overflow-y: auto;
}
.graph-help-row {
display: flex;
align-items: center;
gap: 10px;
}
.graph-help-row kbd,
.graph-help-row .graph-help-mouse {
min-width: 80px;
text-align: right;
flex-shrink: 0;
}
.graph-help-row kbd {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 1px 5px;
font-size: 0.7rem;
font-family: var(--font-mono, monospace);
}
.graph-help-row .graph-help-mouse {
color: var(--text-muted);
font-size: 0.7rem;
}
.graph-help-row span:last-child {
color: var(--text-color);
}
.graph-help-sep {
height: 1px;
background: var(--border-color);
margin: 4px 0;
}
/* ── Node hover FPS tooltip ── */
.graph-node-tooltip {
position: absolute;
z-index: 50;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 6px);
box-shadow: 0 4px 14px var(--shadow-color, rgba(0,0,0,0.25));
padding: 8px 12px;
pointer-events: none;
font-size: 0.8rem;
width: 200px;
color: var(--text-color);
}
.graph-node-tooltip .gnt-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
line-height: 1.6;
}
.graph-node-tooltip .gnt-label {
color: var(--text-muted);
white-space: nowrap;
}
.graph-node-tooltip .gnt-value {
font-variant-numeric: tabular-nums;
font-weight: 500;
text-align: right;
min-width: 72px;
display: inline-block;
font-family: var(--font-mono, 'Consolas', 'Monaco', monospace);
}
.graph-node-tooltip .gnt-fps-row {
margin-top: 4px;
padding: 2px 0;
background: transparent;
}
.graph-node-tooltip.gnt-fade-in {
animation: gntFadeIn 0.15s ease-out forwards;
}
.graph-node-tooltip.gnt-fade-out {
animation: gntFadeOut 0.12s ease-in forwards;
}
@keyframes gntFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes gntFadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(4px); }
}