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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user