Add visual graph editor for entity interconnections

SVG-based node graph with ELK.js autolayout showing all 13 entity types
and their relationships. Features include:

- Pan/zoom canvas with bounds clamping and dead-zone click detection
- Interactive minimap with viewport rectangle, click-to-pan, drag-to-move,
  and dual resize handles (bottom-left/bottom-right)
- Movable toolbar with drag handle and inline zoom percentage indicator
- Entity-type SVG icons from Lucide icon set with subtype-specific overrides
- Command palette search (/) with keyboard navigation and fly-to
- Node selection with upstream/downstream chain highlighting
- Double-click to zoom-to-card, edit/delete overlay on hover
- Legend panel, orphan node detection, running state indicators
- Full i18n support with languageChanged re-render
- Catmull-Rom-to-cubic bezier edge routing for smooth curves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 15:01:47 +03:00
parent 42b5ecf1cd
commit bd7a315c2c
14 changed files with 2320 additions and 4 deletions
@@ -0,0 +1,606 @@
/* ── Graph Editor ─────────────────────────────────────── */
/* Full viewport: hide footer & scroll-to-top when graph is active */
#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: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 4px;
box-shadow: 0 2px 8px var(--shadow-color);
}
.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;
}
.graph-toolbar-drag:active {
cursor: grabbing;
}
.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-sep {
width: 1px;
background: var(--border-color);
margin: 4px 2px;
}
/* ── Legend panel ── */
.graph-legend {
position: absolute;
top: 12px;
right: 12px;
z-index: 20;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
box-shadow: 0 2px 8px var(--shadow-color);
font-size: 0.8rem;
display: none;
max-height: 60vh;
overflow-y: auto;
}
.graph-legend.visible {
display: block;
}
.graph-legend-title {
font-weight: 600;
margin-bottom: 6px;
color: var(--text-color);
}
.graph-legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
color: var(--text-secondary);
}
.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(--border-color);
opacity: 0.3;
}
/* ── Node styles ── */
.graph-node {
cursor: pointer;
}
.graph-node-body {
fill: var(--card-bg);
stroke: var(--border-color);
stroke-width: 1;
rx: 8;
ry: 8;
transition: stroke 0.15s;
}
.graph-node:hover .graph-node-body {
stroke: var(--text-secondary);
}
.graph-node.selected .graph-node-body {
stroke: var(--primary-color);
stroke-width: 2;
}
.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(--text-color);
font-size: 12px;
font-weight: 600;
font-family: 'DM Sans', sans-serif;
}
.graph-node-subtitle {
fill: var(--text-secondary);
font-size: 10px;
font-family: 'DM Sans', sans-serif;
}
.graph-node-icon {
stroke: var(--text-muted);
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.5;
}
/* ── Running indicator (animated gradient border) ── */
.graph-node.running .graph-node-body {
stroke: url(#running-gradient);
stroke-width: 2;
}
@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 {
cursor: crosshair;
}
.graph-port circle {
fill: var(--bg-secondary);
stroke: var(--text-muted);
stroke-width: 1.5;
r: 5;
transition: fill 0.15s, stroke 0.15s, r 0.15s;
}
.graph-port:hover circle {
fill: var(--primary-color);
stroke: var(--primary-color);
r: 6;
}
.graph-port.connected circle {
fill: var(--text-secondary);
stroke: var(--text-secondary);
}
.graph-port-label {
fill: var(--text-muted);
font-size: 9px;
font-family: 'DM Sans', sans-serif;
}
/* ── Edges ── */
.graph-edge {
fill: none;
stroke-width: 2;
opacity: 0.6;
transition: opacity 0.15s, stroke-width 0.15s;
}
.graph-edge:hover {
opacity: 1;
stroke-width: 3;
}
.graph-edge-arrow {
fill: currentColor;
opacity: 0.6;
}
.graph-edge.highlighted {
opacity: 1;
stroke-width: 2.5;
}
.graph-edge.dimmed {
opacity: 0.12;
}
/* 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); }
/* Animated flow dots on active edges */
.graph-edge-flow {
fill: none;
stroke-width: 0;
}
.graph-edge-flow circle {
r: 3;
opacity: 0.8;
}
/* ── 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;
}
/* ── Hover overlay (action buttons) ── */
.graph-node-overlay {
display: none;
pointer-events: none;
}
.graph-node:hover .graph-node-overlay {
display: block;
}
.graph-node-overlay-bg {
fill: 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);
}
/* ── 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-dasharray: 4 3;
}
/* ── 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);
}
/* ── 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;
}