Initial commit: drawNET Alpha v1.0 - Professional Topology Designer with Full i18n and Performance Optimizations

This commit is contained in:
leeyj
2026-03-22 22:37:24 +09:00
commit 5cea93e317
192 changed files with 14449 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
{
"categories": [
{
"id": "Network",
"label": "Network Devices",
"icon": "router.svg"
},
{
"id": "Security",
"label": "Security & Firewalls",
"icon": "firewall.svg"
}
],
"assets": [
{
"id": "router",
"type": "Router",
"category": "Network",
"label": "Standard Router",
"path": "router.svg",
"format": "svg",
"vendor": "Cisco"
},
{
"id": "switch",
"type": "Switch",
"category": "Network",
"label": "L3 Switch",
"path": "switch.svg",
"format": "svg",
"vendor": "Cisco"
},
{
"id": "firewall_cisco",
"type": "Firewall",
"category": "Security",
"label": "Cisco Firewall",
"path": "firewall.svg",
"format": "svg",
"vendor": "Cisco"
},
{
"id": "firewall_png",
"type": "F/W",
"category": "Security",
"label": "Legacy F/w",
"path": "firewall.png",
"format": "png",
"vendor": "Generic"
},
{
"id": "server",
"type": "Server",
"category": "Compute",
"label": "Enterprise Server",
"path": "server.svg",
"format": "svg",
"vendor": "Generic"
},
{
"id": "cloud",
"type": "Internet",
"category": "External",
"label": "External Cloud",
"path": "cloud.svg",
"format": "svg",
"vendor": "Generic"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M25 40 A15 15 0 0 1 50 25 A25 25 0 0 1 85 50 A20 20 0 0 1 70 85 H30 A20 20 0 0 1 25 40" fill="#cbd5e1" stroke="#64748b" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="15" y="20" width="70" height="60" rx="4" fill="#ef4444" stroke="#b91c1c" stroke-width="2"/>
<path d="M15 40 H85 M15 60 H85 M35 20 V40 M65 20 V40 M25 40 V60 M55 40 V60 M85 40 V60 M40 60 V80 M70 60 V80" stroke="white" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,125 @@
{
"id": "cisco_base_pack",
"name": "cisco_pack",
"vendor": "Unknown",
"version": "1.0.0",
"assets": [
{
"id": "cisco_firewall",
"label": "Cisco Firewall",
"category": "Security",
"views": {
"icon": "cisco_firewall.png",
"front": "cisco_firewall.png"
}
},
{
"id": "cisco_firewallservicemodule",
"label": "Cisco Firewall service module",
"category": "Security",
"views": {
"icon": "cisco_firewallservicemodule.png",
"front": "cisco_firewallservicemodule.png"
}
},
{
"id": "cisco_generic",
"label": "Cisco Generic",
"category": "Other",
"views": {
"icon": "cisco_generic.png",
"front": "cisco_generic.png"
}
},
{
"id": "cisco_iosslb",
"label": "Cisco Iosslb",
"category": "Network",
"views": {
"icon": "cisco_iosslb.png",
"front": "cisco_iosslb.png"
}
},
{
"id": "cisco_laptop",
"label": "Cisco Laptop",
"category": "Device",
"views": {
"icon": "cisco_laptop.png",
"front": "cisco_laptop.png"
}
},
{
"id": "cisco_printer",
"label": "Cisco Printer",
"category": "Device",
"views": {
"icon": "cisco_printer.png",
"front": "cisco_printer.png"
}
},
{
"id": "cisco_wirelessrouter",
"label": "Cisco Wirelessrouter",
"category": "Network",
"views": {
"icon": "cisco_wirelessrouter.png",
"front": "cisco_wirelessrouter.png"
}
},
{
"id": "cisco_wwwserver",
"label": "Cisco Wwwserver",
"category": "Device",
"views": {
"icon": "cisco_wwwserver.png",
"front": "cisco_wwwserver.png"
}
},
{
"id": "cisco_contentservicerouter",
"label": "Cisco Contentservicerouter",
"category": "Network",
"views": {
"icon": "cisco_contentservicerouter.png",
"front": "cisco_contentservicerouter.png"
}
},
{
"id": "cisco_database",
"label": "Cisco Database",
"category": "DBMS",
"views": {
"icon": "cisco_database.png",
"front": "cisco_database.png"
}
},
{
"id": "cisco_mgx8000",
"label": "Cisco Mgx8000",
"category": "Network",
"views": {
"icon": "cisco_mgx8000.png",
"front": "cisco_mgx8000.png"
}
},
{
"id": "cisco_l2_100base",
"label": "Cisco L2 ",
"category": "Network",
"views": {
"icon": "cisco_l2_100base.png",
"front": "cisco_l2_100base.png"
}
},
{
"id": "cisco_cloud",
"label": "Cisco Cloud",
"category": "Cloud",
"views": {
"icon": "cisco_cloud.png",
"front": "cisco_cloud.png"
}
}
]
}
@@ -0,0 +1,24 @@
{
"id": "sample_cisco",
"name": "Cisco Enterprise Pack",
"vendor": "Cisco",
"version": "1.0.0",
"assets": [
{
"id": "cisco_router",
"label": "Integrated Services Router",
"category": "Network",
"views": {
"icon": "router.svg"
}
},
{
"id": "cisco_switch",
"label": "Catalyst Switch",
"category": "Network",
"views": {
"icon": "switch.svg"
}
}
]
}
@@ -0,0 +1,5 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="45" fill="#3b82f6" stroke="#1d4ed8" stroke-width="2"/>
<path d="M50 20 L50 40 M50 80 L50 60 M20 50 L40 50 M80 50 L60 50" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M45 40 L50 35 L55 40 M45 60 L50 65 L55 60 M40 45 L35 50 L40 55 M60 45 L65 50 L60 55" fill="none" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 499 B

@@ -0,0 +1,5 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="25" width="80" height="50" rx="5" fill="#3b82f6" stroke="#1d4ed8" stroke-width="2"/>
<path d="M30 40 L50 40 M50 60 L70 60" stroke="white" stroke-width="4" stroke-linecap="round"/>
<path d="M45 35 L50 40 L45 45 M55 55 L50 60 L55 65" fill="none" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="45" fill="#3b82f6" stroke="#1d4ed8" stroke-width="2"/>
<path d="M50 20 L50 40 M50 80 L50 60 M20 50 L40 50 M80 50 L60 50" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M45 40 L50 35 L55 40 M45 60 L50 65 L55 60 M40 45 L35 50 L40 55 M60 45 L65 50 L60 55" fill="none" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 499 B

+7
View File
@@ -0,0 +1,7 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="10" width="50" height="80" rx="2" fill="#64748b" stroke="#334155" stroke-width="2"/>
<rect x="30" y="20" width="40" height="5" fill="#94a3b8"/>
<rect x="30" y="35" width="40" height="5" fill="#94a3b8"/>
<rect x="30" y="50" width="40" height="5" fill="#94a3b8"/>
<circle cx="35" cy="75" r="3" fill="#22c55e"/>
</svg>

After

Width:  |  Height:  |  Size: 440 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="25" width="80" height="50" rx="5" fill="#3b82f6" stroke="#1d4ed8" stroke-width="2"/>
<path d="M30 40 L50 40 M50 60 L70 60" stroke="white" stroke-width="4" stroke-linecap="round"/>
<path d="M45 35 L50 40 L45 45 M55 55 L50 60 L55 65" fill="none" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

+120
View File
@@ -0,0 +1,120 @@
/* analysis.css - Theme-aware styles for Inventory and Analysis Panels */
.inventory-panel {
position: absolute;
bottom: 24px;
right: 24px;
width: 260px;
max-height: 320px;
z-index: 1000;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: auto !important;
}
.inventory-header {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 800;
color: var(--text-color);
border-bottom: 1px solid var(--panel-border);
padding-bottom: 10px;
letter-spacing: -0.02em;
}
.inventory-header i {
color: var(--accent-color);
font-size: 14px;
}
.inventory-list {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
padding-right: 4px;
}
/* Custom Scrollbar for Inventory */
.inventory-list::-webkit-scrollbar {
width: 4px;
}
.inventory-list::-webkit-scrollbar-track {
background: transparent;
}
.inventory-list::-webkit-scrollbar-thumb {
background: var(--panel-border);
border-radius: 4px;
}
.inventory-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--item-bg);
padding: 8px 14px 8px 18px;
border-radius: 12px;
border: 1px solid var(--panel-border);
transition: all 0.2s ease;
}
.inventory-item:hover {
background: var(--item-hover-bg);
transform: translateX(-2px);
}
.inventory-item .lbl {
font-weight: 600;
color: var(--sub-text);
font-size: 11px;
text-transform: capitalize;
}
.inventory-item .val {
font-weight: 800;
color: var(--accent-color);
background: rgba(59, 130, 246, 0.1);
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
min-width: 24px;
text-align: center;
}
.inventory-footer {
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--panel-border);
}
.btn-export-bom {
width: 100%;
padding: 10px;
background: var(--accent-color);
color: white;
border: none;
border-radius: 12px;
font-size: 11px;
font-weight: 800;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-export-bom:hover {
background: #2563eb;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}
.btn-export-bom i {
font-size: 12px;
}
+32
View File
@@ -0,0 +1,32 @@
/* Animations */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-up {
animation: fadeInUp 0.6s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}
/* Edge Flow Animation */
@keyframes drawnet-flow {
from { stroke-dashoffset: 20; }
to { stroke-dashoffset: 0; }
}
.flow-animation path {
stroke-dasharray: 5, 5;
animation: drawnet-flow 1s linear infinite;
}
/* Modal Animations */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes scaleUp { from { opacity: 0; transform: scale(0.9) translateY(20px); } to { opacity: 1; transform: scale(1) translateY(0); } }
@keyframes scaleDown { from { opacity: 1; transform: scale(1) translateY(0); } to { opacity: 0; transform: scale(0.9) translateY(20px); } }
.animate-fade-in { animation: fadeIn 0.3s ease forwards; }
.animate-fade-out { animation: fadeOut 0.2s ease forwards; }
.animate-scale-up { animation: scaleUp 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28) forwards; }
.animate-scale-down { animation: scaleDown 0.3s ease forwards; }
+175
View File
@@ -0,0 +1,175 @@
.asset-library {
padding: 0 16px;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.asset-library h3 {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--sub-text);
margin: 24px 16px 12px;
white-space: nowrap;
font-weight: 800;
}
.sidebar.collapsed .asset-library h3 {
opacity: 0;
}
.asset-category {
margin-bottom: 8px;
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--item-bg);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s;
}
.category-header:hover {
background: var(--item-hover-bg);
}
.category-header span {
font-size: 11px;
font-weight: 800;
color: var(--text-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.category-header i {
font-size: 10px;
color: var(--sub-text);
transition: transform 0.3s;
}
.asset-category.active .category-header i {
transform: rotate(180deg);
}
.category-content {
display: none;
padding: 12px 0;
}
.asset-category.active .category-content {
display: block;
}
.sidebar.collapsed .category-content {
display: none !important; /* Always hide content in sidebar when collapsed */
}
.asset-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 0 8px;
}
.asset-item {
background: var(--item-bg);
border: 1px solid var(--panel-border);
padding: 12px;
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: grab;
transition: all 0.3s;
min-width: 0;
}
.asset-item span {
font-size: 9px;
color: var(--text-color);
text-align: center;
word-break: break-all;
line-height: 1.3;
opacity: 0.8;
}
.sidebar.collapsed .asset-item span {
display: none;
}
.asset-item:hover {
background: var(--item-hover-bg);
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.05);
}
.asset-item img {
pointer-events: none;
}
/* Fixed Category Optimization */
.fixed-category .asset-grid {
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.fixed-category .asset-item {
padding: 8px;
height: 44px;
width: 44px;
justify-content: center;
}
.fixed-category .asset-item span {
display: none;
}
.fixed-category .asset-item i,
.fixed-category .asset-item img {
margin-bottom: 0 !important;
}
.category-divider {
height: 1px;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.1), transparent);
margin: 20px 0;
position: relative;
}
.category-divider::after {
content: '-';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--sidebar-bg);
padding: 0 10px;
font-size: 10px;
color: var(--sub-text);
font-weight: 800;
}
.asset-group-label {
font-size: 9px;
font-weight: 800;
color: var(--sub-text);
padding: 12px 16px 6px;
text-transform: uppercase;
letter-spacing: 0.8px;
opacity: 0.7;
display: flex;
align-items: center;
gap: 8px;
}
.asset-group-label::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255,255,255,0.05);
}
+140
View File
@@ -0,0 +1,140 @@
:root {
--bg-color: #f8fafc;
--bg-rgb: 248, 250, 252;
--text-color: #0f172a;
--accent-color: #3b82f6;
--panel-bg: rgba(255, 255, 255, 0.7);
--panel-border: rgba(255, 255, 255, 0.5);
--shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
--sub-text: #64748b;
--canvas-bg: #ffffff;
--grid-color: rgba(0, 0, 0, 0.05);
--grid-size: 20px;
--item-bg: rgba(255, 255, 255, 0.4);
--item-hover-bg: #e2e8f0;
}
[data-theme="dark"] {
--bg-color: #020617;
--bg-rgb: 2, 6, 23;
--text-color: #f8fafc;
--accent-color: #3b82f6;
--panel-bg: rgba(15, 23, 42, 0.8);
--panel-border: rgba(255, 255, 255, 0.1);
--shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
--sub-text: #cbd5e1;
--canvas-bg: #1e293b;
--grid-color: rgba(255, 255, 255, 0.1);
--grid-size: 20px;
--item-bg: rgba(255, 255, 255, 0.05);
--item-hover-bg: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
height: 100vh;
overflow: hidden;
}
.glass-panel {
background: var(--panel-bg);
backdrop-filter: blur(12px) saturate(180%);
border: 1px solid var(--panel-border);
box-shadow: var(--shadow);
border-radius: 32px;
}
/* Markdown Rendering Styles */
.markdown-body {
font-size: 14px;
line-height: 1.7;
color: var(--text-color);
}
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 800;
}
.markdown-body h1 { font-size: 1.8em; }
.markdown-body h2 {
font-size: 1.4em;
border-bottom: 1px solid var(--panel-border);
padding-bottom: 8px;
color: var(--accent-color);
}
.markdown-body h3 { font-size: 1.2em; }
.markdown-body p { margin-bottom: 16px; color: var(--sub-text); }
.markdown-body ul, .markdown-body ol {
padding-left: 20px;
margin-bottom: 16px;
}
.markdown-body li { margin-bottom: 8px; }
.markdown-body blockquote {
padding: 12px 20px;
margin: 16px 0;
background: var(--item-bg);
border-left: 4px solid var(--accent-color);
border-radius: 4px;
font-style: italic;
color: var(--sub-text);
}
.markdown-body code {
background: var(--item-bg);
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.9em;
color: var(--accent-color);
}
.markdown-body pre {
background: var(--item-bg);
padding: 16px;
border-radius: 12px;
overflow-x: auto;
margin-bottom: 16px;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
border: 1px solid var(--panel-border);
border-radius: 12px;
overflow: hidden;
}
.markdown-body th, .markdown-body td {
padding: 12px 16px;
border-bottom: 1px solid var(--panel-border);
text-align: left;
}
.markdown-body th {
background: var(--item-bg);
font-weight: 800;
color: var(--accent-color);
}
.markdown-body img {
max-width: 100%;
border-radius: 16px;
border: 1px solid var(--panel-border);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
margin: 16px 0;
}
+49
View File
@@ -0,0 +1,49 @@
.editor-panel {
position: absolute;
bottom: 24px;
right: 24px;
width: 480px;
min-width: 400px;
height: 200px;
z-index: 90;
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: bottom right;
}
.editor-panel.collapsed {
height: 40px; /* Header only height */
opacity: 0.8;
}
.editor-header {
padding: 12px 25px;
border-bottom: 1px solid var(--panel-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.editor-header span {
font-size: 10px;
font-weight: 900;
color: var(--sub-text);
text-transform: uppercase;
letter-spacing: 1px;
}
#editor {
flex: 1;
width: 100%;
padding: 20px;
background: transparent;
border: none;
resize: none;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
color: var(--text-color);
outline: none;
line-height: 1.6;
}
+148
View File
@@ -0,0 +1,148 @@
.sidebar.collapsed .category-header {
display: none;
}
.category-collapsed-icon {
display: none;
width: 100%;
padding: 12px 0;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
border-radius: 12px;
}
.sidebar.collapsed .category-collapsed-icon {
display: flex;
z-index: 10;
}
.category-collapsed-icon:hover {
background: white;
transform: scale(1.1);
}
/* Flyout Panel */
.category-flyout {
position: fixed;
left: 90px;
top: auto;
width: 280px;
max-height: 80vh;
z-index: 9999;
display: flex;
flex-direction: column;
padding: 20px;
opacity: 0;
transform: translateX(-10px);
transition: all 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
pointer-events: none;
}
.category-flyout.active {
opacity: 1;
transform: translateX(0);
pointer-events: all;
}
.flyout-menu {
position: absolute;
bottom: 80px;
left: 20px;
width: 220px;
padding: 12px;
z-index: 1000;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
border: 1px solid var(--panel-border);
flex-direction: column;
gap: 2px;
}
.flyout-section {
display: flex;
flex-direction: column;
gap: 2px;
}
.section-label {
font-size: 9px;
font-weight: 800;
color: var(--sub-text);
text-transform: uppercase;
letter-spacing: 1px;
padding: 8px 14px 4px;
opacity: 0.6;
}
.flyout-item {
width: 100%;
padding: 10px 14px;
border: none;
background: transparent;
color: var(--text-color);
font-size: 13px;
font-weight: 600;
text-align: left;
display: flex;
align-items: center;
gap: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.flyout-item i {
width: 16px;
text-align: center;
color: var(--sub-text);
}
.flyout-item:hover {
background: var(--accent-color);
color: white;
}
.flyout-item:hover i {
color: white;
}
.flyout-item.primary {
color: var(--accent-color);
}
.flyout-item.primary:hover {
background: var(--accent-color);
color: white;
}
.divider {
height: 1px;
background: var(--panel-border);
margin: 8px 4px;
}
.flyout-header {
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.flyout-header strong {
font-size: 14px;
display: block;
}
.flyout-header small {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
}
.flyout-grid {
overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding-right: 5px;
}
+139
View File
@@ -0,0 +1,139 @@
.header {
position: absolute;
top: 24px;
right: 24px;
height: 80px;
z-index: 90;
padding: 0 30px;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.3s ease;
}
.header-compact {
height: 50px;
padding: 0 20px;
top: 15px;
right: 24px;
}
.header-compact h2 {
font-size: 13px !important;
}
.header-compact p {
font-size: 9px !important;
}
.header-info {
display: flex;
flex-direction: column;
}
.header-status {
display: flex;
align-items: center;
gap: 12px;
background: rgba(16, 185, 129, 0.1);
padding: 4px 12px;
border-radius: 20px;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.help-circle-btn {
background: transparent;
border: none;
color: #047857;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
}
.help-circle-btn:hover {
color: #10b981;
transform: scale(1.1);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-dot.online {
background: #10b981;
box-shadow: 0 0 8px #10b981;
}
.header h2 {
font-size: 14px;
font-weight: 800;
}
.header-btns {
display: flex;
gap: 15px;
}
.btn {
padding: 12px 24px;
border-radius: 16px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn-secondary {
background: var(--item-bg);
color: var(--sub-text);
}
.btn-primary {
background: var(--accent-color);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.header-search {
flex: 1;
max-width: 400px;
margin: 0 40px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid var(--panel-border);
border-radius: 20px;
padding: 6px 15px;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.2s;
}
.header-search:focus-within {
background: rgba(255, 255, 255, 0.4);
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
.header-search i {
color: var(--sub-text);
font-size: 13px;
}
.header-search input {
background: transparent;
border: none;
outline: none;
color: var(--text-color);
font-size: 12px;
width: 100%;
}
.btn:active {
transform: scale(0.95);
}
+318
View File
@@ -0,0 +1,318 @@
/* layers.css - Layer Management Panel Styles */
.layer-panel {
position: absolute;
bottom: 24px;
right: 300px; /* Offset from Inventory */
width: 250px;
max-height: 400px;
z-index: 1000;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: auto !important;
}
.layer-item.dragging {
opacity: 0.4;
background: var(--primary-color-dim);
border: 1px dashed var(--primary-color);
}
.drag-handle {
cursor: grab;
color: #64748b;
margin-right: 12px;
font-size: 12px;
}
.layer-item:active .drag-handle {
cursor: grabbing;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 13px;
font-weight: 800;
color: var(--text-color);
border-bottom: 1px solid var(--panel-border);
padding-bottom: 10px;
}
.panel-header i {
color: var(--accent-color);
}
.layer-list {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.layer-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--item-bg);
border: 1px solid var(--panel-border);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
.layer-item:hover {
background: var(--item-hover-bg);
border-color: var(--accent-color);
}
.layer-item.active {
border-color: var(--accent-color);
background: rgba(59, 130, 246, 0.1);
}
.layer-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.layer-name {
flex: 1;
font-size: 11px;
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-actions {
display: flex;
gap: 4px;
}
.layer-actions button {
background: none;
border: none;
color: var(--sub-text);
cursor: pointer;
font-size: 12px;
padding: 2px;
}
.layer-actions button:hover {
color: var(--accent-color);
}
.panel-header button {
background: var(--accent-color);
color: white !important;
border: none;
border-radius: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
.panel-header button:hover {
background: #2563eb;
transform: scale(1.1);
}
.panel-header button i {
color: white !important; /* Force icon color */
}
.mini-btn:hover {
background: #2563eb;
}
.layer-footer {
margin-top: auto;
padding-top: 10px;
border-top: 1px solid var(--panel-border);
}
.opacity-control {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--sub-text);
}
.opacity-control input[type="range"] {
flex: 1;
height: 6px;
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
appearance: none;
cursor: pointer;
outline: none;
}
.opacity-control input[type="range"]::-webkit-slider-runnable-track {
background: linear-gradient(to right, var(--accent-color), #ddd);
height: 6px;
border-radius: 3px;
}
.opacity-control input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
background: var(--accent-color);
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
border-radius: 50%;
cursor: pointer;
margin-top: -4px; /* Align with track */
}
#opacity-val {
min-width: 30px;
text-align: right;
}
.layer-rename-input {
background: white;
border: 1px solid var(--accent-color);
border-radius: 4px;
padding: 2px 4px;
font-size: 13px;
width: 100px;
outline: none;
color: var(--main-text);
}
/* Trash Bin for Deletion */
.layer-trash {
margin-top: 12px;
padding: 12px;
border: 1.5px dashed rgba(239, 68, 68, 0.4);
border-radius: 10px;
color: #ef4444;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 11px;
font-weight: 700;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: rgba(239, 68, 68, 0.05);
}
.layer-trash i {
font-size: 14px;
}
.layer-trash.drag-over {
background: #ef4444;
color: white;
border-color: #ef4444;
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
/* Visual Nudge: Pulse Animation */
@keyframes nudgePulse {
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
.pulse-nudge {
animation: nudgePulse 1.5s infinite;
border-radius: 4px;
background: rgba(59, 130, 246, 0.1);
}
/* Mini Guide Tooltip */
.nudge-tooltip {
position: absolute;
left: 45px;
background: #3b82f6;
color: white;
padding: 6px 10px;
border-radius: 6px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
animation: tooltipFadeIn 0.3s ease-out forwards;
z-index: 1001;
}
.nudge-tooltip::before {
content: '';
position: absolute;
left: -4px;
top: 50%;
transform: translateY(-50%);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-right: 4px solid #3b82f6;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes tooltipFadeOut {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(5px); }
}
/* Active Layer Floating Badge */
.active-layer-badge {
position: absolute;
top: 20px;
right: 20px;
z-index: 100;
pointer-events: none;
display: flex;
align-items: center;
gap: 8px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(12px);
padding: 8px 16px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
animation: fadeInDown 0.5s ease-out;
}
.active-layer-badge .badge-label {
font-size: 9px;
font-weight: 800;
color: #94a3b8;
letter-spacing: 1px;
}
.active-layer-badge .badge-value {
font-size: 13px;
font-weight: 900;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
transition: all 0.3s ease;
}
@keyframes badgePop {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.badge-pop {
animation: badgePop 0.3s ease-out;
}
+84
View File
@@ -0,0 +1,84 @@
/* Main Area Adjustment */
.header, .editor-panel {
left: 284px; /* Default sidebar width + margin */
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar.collapsed ~ .header,
.sidebar.collapsed ~ .editor-panel,
.sidebar.collapsed ~ .main-viewport {
left: 104px; /* Collapsed sidebar width + margin */
}
.main-viewport {
position: absolute;
top: 0;
left: 284px;
right: 0;
bottom: 0;
overflow: auto;
background-color: var(--bg-color);
display: flex;
/* justify-content and align-items removed to allow margin:auto to handle centering with overflow */
padding: 60px; /* Space for shadow and boundaries */
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
scrollbar-gutter: stable;
}
#cy {
flex-shrink: 0;
background-color: var(--canvas-bg); /* Theme-aware paper background */
position: relative;
/* Sizes will be set by JS or default to 100% */
width: 100%;
height: 100%;
margin: auto; /* Handles centering when sized fixed (A3/A4) */
box-shadow: 0 0 40px rgba(0,0,0,0.1);
border: 1px solid var(--panel-border); /* Visible canvas boundary */
}
#cy.grid-solid {
background-image:
linear-gradient(var(--grid-color) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
}
#cy.grid-dashed {
background-image:
radial-gradient(var(--grid-color) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
}
/* Inventory Panel Overlay (Legacy - handled in analysis.css) */
/* .inventory-panel { ... } */
.inventory-header {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
color: var(--sub-text);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.inventory-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
font-size: 12px;
font-weight: 600;
color: var(--text-color);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.inventory-item .count {
background: var(--accent-color);
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 800;
}
+141
View File
@@ -0,0 +1,141 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.modal-overlay.active {
opacity: 1;
pointer-events: all;
}
.modal-content {
width: 500px;
max-width: 90vw;
background: var(--panel-bg);
backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--panel-border);
border-radius: 32px;
box-shadow: 0 20px 50px rgba(0,0,0,0.1);
transform: scale(0.9) translateY(20px);
transition: all 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-overlay.active .modal-content {
transform: scale(1) translateY(0);
}
.modal-header {
padding: 24px 30px;
border-bottom: 1px solid var(--panel-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 18px;
font-weight: 800;
color: var(--text-color);
}
.close-modal {
background: transparent;
border: none;
font-size: 20px;
color: var(--sub-text);
cursor: pointer;
transition: color 0.2s;
}
.close-modal:hover {
color: var(--text-color);
}
.modal-body {
padding: 30px;
flex: 1;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--sub-text);
margin-bottom: 12px;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.setting-label {
font-size: 14px;
font-weight: 600;
}
.setting-item select,
.setting-item input[type="number"],
.setting-item input[type="text"] {
background: var(--item-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
color: var(--text-color);
outline: none;
transition: border-color 0.2s;
}
.setting-item select:focus,
.setting-item input:focus {
border-color: var(--accent-color);
}
.setting-item select option {
background-color: var(--bg-color);
color: var(--text-color);
}
.setting-item input[type="color"] {
appearance: none;
width: 40px;
height: 24px;
border: none;
border-radius: 4px;
cursor: pointer;
background: none;
}
.setting-item input[type="color"]::-webkit-color-swatch {
border: 1px solid var(--panel-border);
border-radius: 4px;
}
.modal-footer {
padding: 20px 30px;
border-top: 1px solid var(--panel-border);
display: flex;
justify-content: flex-end;
}
+256
View File
@@ -0,0 +1,256 @@
/* Properties Sidebar Styles */
#properties-sidebar {
position: fixed;
right: -320px; /* Hidden by default */
top: 60px; /* Below header */
bottom: 0;
width: 300px;
background: var(--panel-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-left: 1px solid var(--panel-border);
box-shadow: -10px 0 25px rgba(0, 0, 0, 0.3);
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 900;
display: flex;
flex-direction: column;
color: var(--text-color);
}
#properties-sidebar.open {
right: 0;
}
.sidebar-header {
padding: 18px 20px;
border-bottom: 1px solid var(--panel-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--accent-color);
}
.close-sidebar {
cursor: pointer;
font-size: 20px;
color: var(--sub-text);
transition: color 0.2s;
}
.close-sidebar:hover {
color: var(--text-color);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 24px 20px;
}
.prop-group {
margin-bottom: 20px;
}
.prop-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.prop-row .prop-group {
flex: 1;
margin-bottom: 0;
}
.prop-group.horizontal {
display: flex;
align-items: center;
gap: 12px;
}
.prop-group.horizontal label {
flex: 0 0 80px;
margin-bottom: 0;
}
.prop-group.horizontal .prop-input {
flex: 1;
}
.prop-group label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--sub-text);
margin-bottom: 8px;
font-weight: 700;
}
.prop-input {
width: 100%;
background: var(--item-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 9px 12px;
color: var(--text-color);
font-size: 14px;
outline: none;
transition: all 0.2s;
}
.prop-input:focus {
border-color: var(--accent-color);
background: var(--item-hover-bg);
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--panel-border);
display: flex;
gap: 12px;
background: rgba(var(--bg-rgb), 0.05);
}
.btn-apply {
flex: 1;
background: var(--accent-color);
color: white;
border: none;
padding: 12px;
border-radius: 8px;
cursor: pointer;
font-weight: 700;
transition: all 0.2s;
}
.btn-apply:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
/* Toggle Switch Styles */
.toggle-group {
margin-bottom: 24px;
}
.toggle-switch {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 12px 14px;
background: var(--item-bg);
border: 1px solid var(--panel-border);
border-radius: 12px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-switch:hover {
background: var(--item-hover-bg);
border-color: var(--accent-color);
}
.toggle-switch span {
font-size: 13px;
font-weight: 600;
color: var(--text-color);
}
.toggle-switch input {
display: none;
}
.switch-slider {
position: relative;
width: 36px;
height: 20px;
background: #475569;
border-radius: 20px;
transition: background 0.3s;
}
.switch-slider:before {
content: "";
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
background: white;
border-radius: 50%;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.toggle-switch input:checked + .switch-slider {
background: var(--accent-color);
}
.toggle-switch input:checked + .switch-slider:before {
transform: translateX(16px);
}
/* Multi-Selection Alignment UI */
.alignment-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-top: 12px;
}
.align-btn {
aspect-ratio: 1;
background: var(--item-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
color: var(--sub-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s;
}
.align-btn:hover:not(:disabled) {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
.align-btn:disabled {
opacity: 0.2;
cursor: not-allowed;
}
/* Color Input Consistency */
.input-with-color {
display: flex;
gap: 8px;
align-items: center;
}
.input-with-color .prop-input[type="color"] {
width: 44px;
height: 38px;
padding: 3px;
cursor: pointer;
flex-shrink: 0;
}
.input-with-color .prop-input[type="color"]::-webkit-color-swatch {
border: 1px solid var(--panel-border);
border-radius: 4px;
}
/* Danger variant for toggle-switch */
.toggle-switch.danger input:checked + .switch-slider {
background: #ef4444;
}
+202
View File
@@ -0,0 +1,202 @@
/* settings_panel.css - Professional Sliding Settings Panel */
.settings-panel {
position: fixed;
top: 10px;
right: -420px; /* Hidden by default */
width: 400px;
height: calc(100vh - 20px);
z-index: 2000;
display: flex;
flex-direction: column;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3);
}
.settings-panel.active {
right: 10px;
}
.settings-header {
padding: 20px 24px;
border-bottom: 1px solid var(--panel-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 16px;
font-weight: 800;
color: var(--text-color);
}
.header-title i {
color: var(--accent-color);
}
.settings-container {
flex: 1;
display: flex;
overflow: hidden;
}
/* Sidebar Tabs */
.settings-sidebar {
width: 100px;
background: var(--item-bg);
border-right: 1px solid var(--panel-border);
display: flex;
flex-direction: column;
padding: 10px 0;
}
.settings-tab {
padding: 18px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--sub-text);
transition: all 0.2s ease;
text-align: center;
}
.settings-tab i {
font-size: 20px;
}
.settings-tab span {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.settings-tab:hover {
color: var(--text-color);
background: var(--item-hover-bg);
}
.settings-tab.active {
color: var(--accent-color);
background: rgba(59, 130, 246, 0.1);
border-left: 3px solid var(--accent-color);
}
/* Body Content */
.settings-body {
flex: 1;
padding: 24px;
overflow-y: auto;
background: rgba(var(--bg-rgb), 0.1);
}
.settings-section {
margin-bottom: 30px;
animation: fadeIn 0.3s ease;
}
.settings-section h3 {
font-size: 11px;
color: var(--sub-text);
text-transform: uppercase;
margin-bottom: 16px;
letter-spacing: 0.8px;
font-weight: 800;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--panel-border);
}
.setting-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-info .label {
font-size: 14px;
font-weight: 600;
color: var(--text-color);
}
.setting-info .desc {
font-size: 11px;
color: var(--sub-text);
}
.setting-ctrl select,
.setting-ctrl input[type="number"],
.setting-ctrl input[type="text"] {
background: var(--btn-bg, #1e293b);
border: 1px solid var(--panel-border);
color: var(--text-color);
padding: 7px 12px;
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
cursor: pointer;
color-scheme: dark light; /* Support both schemes */
}
.setting-ctrl select option {
background-color: #1e293b; /* Solid background for options */
color: white;
}
.setting-ctrl select:focus,
.setting-ctrl input:focus {
border-color: var(--accent-color);
}
/* Toggle switch adjustments within settings */
.toggle-switch {
background: var(--item-bg) !important;
border: 1px solid var(--panel-border) !important;
}
.toggle-switch:hover {
background: var(--item-hover-bg) !important;
border-color: var(--accent-color) !important;
}
.toggle-switch .label {
color: var(--sub-text) !important;
}
/* Animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.close-btn {
background: none;
border: none;
color: var(--sub-text);
cursor: pointer;
font-size: 20px;
transition: all 0.2s;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: #ef4444;
color: white;
}
+140
View File
@@ -0,0 +1,140 @@
.sidebar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 260px;
z-index: 100;
display: flex;
flex-direction: column;
border-radius: 0;
border-left: none;
border-top: none;
border-bottom: none;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
overflow-x: hidden;
}
.sidebar.collapsed {
width: 80px;
overflow-x: hidden !important;
}
.sidebar-header {
padding: 24px;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 80px;
transition: padding 0.3s;
overflow: hidden;
}
.sidebar.collapsed .sidebar-header {
padding: 24px 0;
flex-direction: column;
gap: 15px;
justify-content: center;
}
.menu-toggle {
background: transparent;
border: none;
color: var(--sub-text);
font-size: 20px;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s;
}
.sidebar.collapsed .menu-toggle {
order: -1; /* Toggle button first when collapsed */
}
.menu-toggle:hover {
background: var(--item-bg);
}
.logo-area {
display: flex;
align-items: center;
gap: 12px;
transition: opacity 0.2s;
white-space: nowrap;
}
.sidebar.collapsed .logo-text {
display: none;
}
.logo-icon {
min-width: 40px;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #3b82f6, #6366f1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 900;
font-size: 18px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.search-container {
padding: 0 24px 20px;
position: relative;
transition: padding 0.3s;
}
.search-container i {
position: absolute;
left: 40px;
top: 13px;
font-size: 14px;
color: var(--sub-text);
z-index: 5;
transition: all 0.3s;
}
#asset-search {
width: 100%;
background: var(--item-bg);
border: 1px solid var(--panel-border);
padding: 10px 15px 10px 40px;
border-radius: 12px;
font-size: 13px;
font-family: inherit;
outline: none;
transition: all 0.2s;
}
#asset-search:focus {
background: var(--item-hover-bg);
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
border-color: #3b82f6;
}
.sidebar.collapsed .search-container {
padding: 10px 0;
display: flex;
justify-content: center;
}
.sidebar.collapsed #asset-search {
display: none;
}
.sidebar.collapsed .search-container i {
font-size: 18px;
color: var(--sub-text);
cursor: pointer;
position: static;
}
+68
View File
@@ -0,0 +1,68 @@
/* Sidebar Footer Settings */
.sidebar-footer {
padding: 20px 24px;
border-top: 1px solid var(--panel-border);
display: flex;
align-items: center;
gap: 12px;
margin-top: auto; /* Push to bottom */
}
.footer-btns {
display: flex;
gap: 8px;
}
.footer-btn {
width: 32px;
height: 32px;
background: var(--item-bg);
border: none;
border-radius: 8px;
color: var(--sub-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.footer-btn:hover {
background: var(--item-hover-bg);
color: white;
}
#settings-btn:hover {
transform: rotate(45deg);
}
#system-menu-btn.active {
background: var(--accent-color);
color: white;
}
.footer-text {
flex: 1;
}
.footer-text p {
font-size: 11px;
font-weight: 800;
margin: 0;
}
.footer-text small {
font-size: 9px;
color: var(--sub-text);
}
.sidebar.collapsed .sidebar-footer {
padding: 20px 0;
flex-direction: column;
justify-content: center;
}
.sidebar.collapsed .footer-text {
display: none;
}
+99
View File
@@ -0,0 +1,99 @@
.floating-window {
position: fixed;
background: var(--sidebar-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--panel-border);
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
overflow: hidden;
animation: win-appear 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes win-appear {
from { opacity: 0; transform: scale(0.9) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.win-header {
padding: 12px 20px;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid var(--panel-border);
display: flex;
align-items: center;
justify-content: space-between;
cursor: grab;
user-select: none;
}
.win-header:active {
cursor: grabbing;
}
.win-title {
font-size: 13px;
font-weight: 800;
color: var(--text-color);
display: flex;
align-items: center;
gap: 10px;
}
.win-controls {
display: flex;
gap: 10px;
}
.win-btn {
background: transparent;
border: none;
color: var(--sub-text);
cursor: pointer;
font-size: 12px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
}
.win-btn:hover {
background: var(--item-hover-bg);
color: var(--text-color);
}
.win-btn.win-close:hover {
background: #ef4444;
color: white;
}
.floating-window.minimized {
height: 48px !important;
width: 250px !important;
}
.floating-window.minimized .win-body {
display: none;
}
.win-body {
flex: 1;
overflow: auto;
padding: 20px;
}
/* Studio Specific Polish */
.studio-container {
height: 100%;
}
.studio-modal .preview-area {
min-height: 200px;
}
[data-theme='dark'] .floating-window {
background: rgba(15, 23, 42, 0.8);
}
+349
View File
@@ -0,0 +1,349 @@
/*
drawNET Premium CSS Manifest
This file imports modules to keep the production bundle organized.
*/
@import "modules/base.css";
@import "modules/sidebar.css";
@import "modules/sidebar_footer.css";
@import "modules/assets.css";
@import "modules/flyout.css";
@import "modules/header.css";
@import "modules/editor.css";
@import "modules/layout.css";
@import "modules/animations.css";
@import "modules/modal.css";
@import "modules/settings_panel.css";
@import "modules/properties_sidebar.css";
/* Floating Context Menu */
.floating-menu {
background: rgba(30, 41, 59, 0.85); /* Slate 800-ish with glassmorphism */
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
padding: 6px;
min-width: 180px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.menu-item {
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: #f1f5f9;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
}
.menu-item:hover {
background: #3b82f6; /* Blue 500 */
color: white;
transform: translateY(-1px);
}
.menu-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 6px 0;
}
.menu-label {
padding: 6px 14px 4px;
font-size: 10px;
font-weight: 700;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.menu-icon-row {
display: flex;
justify-content: space-between;
padding: 4px 8px;
gap: 4px;
}
.icon-btn {
flex: 1;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
color: #94a3b8;
transition: all 0.2s;
font-size: 14px;
}
.icon-btn:hover {
background: #3b82f6;
color: white;
}
.icon-btn i.fa-rotate-90 {
transform: rotate(90deg);
}
/* --- X6 Port Styles --- */
.x6-port {
visibility: hidden;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.x6-node:hover .x6-port {
visibility: visible;
}
.x6-port circle {
cursor: crosshair;
}
.x6-port circle:hover {
fill: #3b82f6 !important;
r: 6 !important;
}
/* --- X6 Edge Selection Highlight --- */
.x6-edge-selected path {
stroke: #3b82f6 !important;
stroke-width: 3px !important;
filter: drop-shadow(0 0 3px rgba(59, 130, 246, 0.8));
transition: all 0.2s ease;
}
/* Flow animation for selected flow lines */
.x6-edge-selected.flow-animation path {
stroke-width: 4px !important;
}
/* Mini buttons for editor actions */
.footer-btn.mini {
width: auto;
padding: 6px 12px;
font-size: 11px;
height: 28px;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
color: #3b82f6;
border-radius: 6px;
margin-left: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 700;
}
.footer-btn.mini:hover {
background: #3b82f6;
color: white;
}
/* --- Export Loading Overlay --- */
.export-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(15, 23, 42, 0.65); /* Slate 900 with transparency */
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
color: white;
font-family: inherit;
animation: fadeIn 0.3s ease-out;
}
.export-spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-left-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
.export-text {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* --- License Management UI --- */
.license-status-card {
background: rgba(30, 41, 59, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 20px;
margin: 16px 0 24px;
backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.edition-label {
font-size: 10px;
font-weight: 800;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 1px;
display: block;
margin-bottom: 4px;
}
.edition-name {
font-size: 22px;
font-weight: 900;
color: white;
margin: 0;
letter-spacing: -0.5px;
}
.status-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
}
.status-icon.valid {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
}
.status-icon.expired {
background: linear-gradient(135deg, #475569 0%, #1e293b 100%);
color: #94a3b8;
}
.hwid-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.hwid-label-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
font-weight: 700;
color: #cbd5e1;
}
.text-link-btn {
background: none;
border: none;
color: #3b82f6;
font-size: 11px;
font-weight: 700;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.text-link-btn:hover {
background: rgba(59, 130, 246, 0.1);
}
.hwid-value-box {
background: rgba(15, 23, 42, 0.4);
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 12px;
border-radius: 10px;
min-height: 40px;
display: flex;
align-items: center;
}
.hwid-value-box code {
font-family: 'Fira Code', 'Cascadia Code', monospace;
font-size: 13px;
color: #3b82f6;
word-break: break-all;
width: 100%;
}
.license-action-section {
margin-top: 20px;
}
.key-input-wrapper textarea {
width: 100%;
height: 100px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #f1f5f9;
padding: 12px;
font-family: monospace;
font-size: 12px;
resize: none;
margin-bottom: 12px;
transition: all 0.2s;
}
.key-input-wrapper textarea:focus {
border-color: #3b82f6;
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.primary-btn-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 10px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
width: 100%;
}
.primary-btn-premium:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
}
.primary-btn-premium:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+344
View File
@@ -0,0 +1,344 @@
/*
* drawNET Studio - Asset Management Styles
*/
body.studio-body {
margin: 0;
padding: 0;
background: #0f172a;
color: #f8fafc;
height: 100vh;
overflow: hidden;
font-family: 'Inter', sans-serif;
}
.studio-layout {
display: grid;
grid-template-columns: 280px 1fr 320px;
grid-template-rows: 60px 1fr;
height: 100vh;
}
.studio-header {
grid-column: 1 / 4;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: rgba(30, 41, 59, 0.7);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
}
.studio-header-left {
display: flex;
align-items: center;
gap: 20px;
}
.header-metadata {
display: flex;
gap: 15px;
align-items: center;
}
.meta-input-group {
display: flex;
flex-direction: column;
}
.meta-label {
font-size: 9px;
color: #64748b;
font-weight: 800;
text-transform: uppercase;
}
.meta-input {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #f8fafc;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
transition: border-color 0.2s;
}
.meta-input:focus {
outline: none;
border-color: #38bdf8;
}
.meta-input.highlight {
color: #38bdf8;
font-weight: 700;
}
.panel {
background: rgba(15, 23, 42, 0.5);
border-right: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
padding: 15px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 12px;
font-weight: 800;
color: #94a3b8;
letter-spacing: 0.05em;
text-transform: uppercase;
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* Source Panel (Left) */
.source-panel {
background: rgba(15, 23, 42, 0.8);
}
.upload-area {
border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 30px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 20px;
}
.upload-area:hover {
background: rgba(255, 255, 255, 0.02);
border-color: #38bdf8;
}
.upload-area i {
display: block;
font-size: 24px;
color: #38bdf8;
margin-bottom: 10px;
}
.upload-text {
font-size: 12px;
margin: 0;
}
.upload-hint {
font-size: 10px;
color: #64748b;
}
.source-list {
list-style: none;
padding: 0;
margin: 0;
}
.source-item {
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: background 0.2s;
}
.source-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.source-item i {
width: 16px;
opacity: 0.6;
}
/* Workspace (Center) */
.workspace {
background: #020617;
padding: 30px;
}
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 20px;
}
.asset-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 15px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
transition: all 0.2s;
}
.asset-card:hover {
transform: translateY(-2px);
background: rgba(30, 41, 59, 0.8);
border-color: #38bdf8;
}
.asset-card.selected {
border-color: #38bdf8;
background: rgba(56, 189, 248, 0.1);
box-shadow: 0 0 0 1px #38bdf8;
}
.asset-preview {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.asset-preview img {
max-width: 100%;
max-height: 100%;
}
.asset-name {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
text-align: center;
color: #cbd5e1;
}
/* Comparison View */
.comparison-view {
display: flex;
gap: 20px;
margin-top: 20px;
padding: 15px;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.comp-box {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.comp-preview {
width: 100px;
height: 100px;
background: #1e293b;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.comp-preview img, .comp-preview svg {
max-width: 90%;
max-height: 90%;
}
.comp-label {
font-size: 10px;
font-weight: 800;
color: #64748b;
text-transform: uppercase;
}
.choice-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #94a3b8;
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
width: 100%;
transition: all 0.2s;
}
.choice-btn.active {
background: #38bdf8;
color: #0f172a;
border-color: #38bdf8;
font-weight: 700;
}
/* Property Panel (Right) */
.property-panel {
border-left: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(15, 23, 42, 0.8);
}
.property-empty {
text-align: center;
color: #64748b;
margin-top: 50px;
}
.property-empty i {
font-size: 30px;
margin-bottom: 15px;
display: block;
}
.property-empty p {
font-size: 13px;
}
.btn-primary {
background: #38bdf8;
color: #0f172a;
border: none;
padding: 8px 16px;
border-radius: 8px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
background: #7dd3fc;
transform: translateY(-1px);
}
.back-link {
text-decoration: none;
color: #94a3b8;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.back-link:hover {
color: #cbd5e1;
}
.placeholder-icon {
font-size: 32px;
color: #334155;
}
+1
View File
@@ -0,0 +1 @@
/* Ghost file to prevent MIME type 404 errors in legacy/library environments */
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+54
View File
@@ -0,0 +1,54 @@
{
"hotkeys": [
{ "key": "Delete", "code": "Delete", "action": "deleteSelected", "description": "Remove selected elements" },
{ "key": "Backspace", "code": "Backspace", "action": "deleteSelected", "description": "Remove selected elements" },
{ "ctrl": true, "key": "f", "code": "KeyF", "action": "fitScreen", "description": "Fit to screen" },
{ "ctrl": true, "key": "=", "code": "Equal", "action": "zoomIn", "description": "Zoom in" },
{ "ctrl": true, "key": "+", "code": "Equal", "shift": true, "action": "zoomIn", "description": "Zoom in" },
{ "ctrl": true, "key": "-", "code": "Minus", "action": "zoomOut", "description": "Zoom out" },
{ "ctrl": true, "key": "s", "code": "KeyS", "action": "exportJson", "description": "Export JSON" },
{ "ctrl": true, "key": "o", "code": "KeyO", "action": "importJson", "description": "Import JSON" },
{ "alt": true, "key": "0", "code": "Digit0", "alwaysEnabled": true, "action": "toggleInventory", "description": "Toggle Inventory Panel" },
{ "alt": true, "key": "1", "code": "Digit1", "alwaysEnabled": true, "action": "toggleProperties", "description": "Toggle Properties Sidebar" },
{ "alt": true, "key": "Enter", "code": "Enter", "alwaysEnabled": true, "action": "toggleProperties", "description": "Open/Toggle Properties Sidebar" },
{ "key": "F2", "code": "F2", "action": "editLabel", "description": "Edit label of selected element" },
{ "key": "[", "code": "BracketLeft", "action": "sendToBack", "description": "Send selected to back" },
{ "key": "]", "code": "BracketRight", "action": "bringToFront", "description": "Bring selected to front" },
{ "ctrl": true, "key": "l", "code": "KeyL", "action": "toggleLock", "description": "Lock/Unlock selected element" },
{ "alt": true, "key": "9", "code": "Digit9", "alwaysEnabled": true, "action": "toggleLayers", "description": "Toggle Layer Panel" },
{ "ctrl": true, "shift": true, "key": "l", "code": "KeyL", "action": "arrangeLayout", "description": "Auto-arrange layout" },
{ "shift": true, "key": "a", "code": "KeyA", "action": "connectNodes", "description": "Connect selected nodes (Manhattan)" },
{ "shift": true, "key": "s", "code": "KeyS", "action": "connectStraight", "description": "Connect selected nodes (Straight)" },
{ "alt": true, "key": "m", "code": "KeyM", "action": "connectNodes", "description": "Change to Manhattan Line" },
{ "alt": true, "key": "l", "code": "KeyL", "action": "connectStraight", "description": "Change to Straight Line" },
{ "shift": true, "key": "d", "code": "KeyD", "action": "disconnectNodes", "description": "Disconnect selected nodes" },
{ "ctrl": true, "key": "g", "code": "KeyG", "action": "groupNodes", "description": "Group Selected" },
{ "ctrl": true, "key": "z", "code": "KeyZ", "action": "undo", "alwaysEnabled": true, "description": "Undo last action" },
{ "ctrl": true, "key": "y", "code": "KeyY", "action": "redo", "alwaysEnabled": true, "description": "Redo last undone action" },
{ "ctrl": true, "shift": true, "key": "z", "code": "KeyZ", "action": "redo", "alwaysEnabled": true, "description": "Redo last undone action" },
{ "ctrl": true, "key": "c", "code": "KeyC", "action": "copyNodes", "description": "Copy selected nodes" },
{ "ctrl": true, "key": "v", "code": "KeyV", "action": "pasteNodes", "description": "Paste nodes from clipboard" },
{ "ctrl": true, "key": "d", "code": "KeyD", "action": "duplicateNodes", "description": "Duplicate selected nodes (Immediate)" },
{ "ctrl": true, "shift": true, "key": "c", "code": "KeyC", "action": "copyAttributes", "description": "Copy node attributes (Format Painter)" },
{ "ctrl": true, "shift": true, "key": "v", "code": "KeyV", "action": "pasteAttributes", "description": "Paste node attributes" },
{ "shift": true, "key": "1", "code": "Digit1", "action": "alignTop", "description": "Align nodes to top" },
{ "shift": true, "key": "2", "code": "Digit2", "action": "alignBottom", "description": "Align nodes to bottom" },
{ "shift": true, "key": "3", "code": "Digit3", "action": "alignLeft", "description": "Align nodes to left" },
{ "shift": true, "key": "4", "code": "Digit4", "action": "alignRight", "description": "Align nodes to right" },
{ "shift": true, "key": "5", "code": "Digit5", "action": "alignMiddle", "description": "Align nodes to vertical center" },
{ "shift": true, "key": "6", "code": "Digit6", "action": "alignCenter", "description": "Align nodes to horizontal center" },
{ "shift": true, "key": "7", "code": "Digit7", "action": "distributeHorizontal", "description": "Distribute nodes horizontally" },
{ "shift": true, "key": "8", "code": "Digit8", "action": "distributeVertical", "description": "Distribute nodes vertically" },
{ "key": "ArrowUp", "code": "ArrowUp", "action": "moveUp", "description": "Move nodes up" },
{ "key": "ArrowDown", "code": "ArrowDown", "action": "moveDown", "description": "Move nodes down" },
{ "key": "ArrowLeft", "code": "ArrowLeft", "action": "moveLeft", "description": "Move nodes left" },
{ "key": "ArrowRight", "code": "ArrowRight", "action": "moveRight", "description": "Move nodes right" },
{ "shift": true, "key": "ArrowUp", "code": "ArrowUp", "action": "moveUpLarge", "description": "Move nodes up (large)" },
{ "shift": true, "key": "ArrowDown", "code": "ArrowDown", "action": "moveDownLarge", "description": "Move nodes down (large)" },
{ "shift": true, "key": "ArrowLeft", "code": "ArrowLeft", "action": "moveLeftLarge", "description": "Move nodes left (large)" },
{ "shift": true, "key": "ArrowRight", "code": "ArrowRight", "action": "moveRightLarge", "description": "Move nodes right (large)" },
{ "key": "Escape", "code": "Escape", "alwaysEnabled": true, "action": "cancel", "description": "Deselect all and close sidebar" }
]
}
+78
View File
@@ -0,0 +1,78 @@
import { initAssets } from './modules/assets/index.js';
import { initGraph } from './modules/graph.js';
import { initUIToggles } from './modules/ui.js';
import { initSettings } from './modules/settings/ui.js';
import { applySavedSettings, applySavedTheme } from './modules/settings/store.js';
import { initHotkeys } from './modules/hotkeys/index.js';
import { initGlobalSearch } from './modules/graph/search.js';
import { initInventory } from './modules/graph/analysis.js';
import { initLayerPanel } from './modules/ui/layer_panel.js';
import { applyLayerFilters } from './modules/graph/layers.js';
import { initI18n } from './modules/i18n.js';
import { initPropertiesSidebar } from './modules/properties_sidebar/index.js';
import { initPersistence } from './modules/persistence.js';
import { initGraphIO } from './modules/graph/io/index.js';
import { initContextMenu } from './modules/ui/context_menu/index.js';
import { initLicense } from './modules/ui/license.js';
import { logger } from './modules/utils/logger.js';
document.addEventListener('DOMContentLoaded', async () => {
// 0. Initialize i18n (Language first)
await initI18n();
// 1. Apply Saved Theme immediately (prevent flash)
applySavedTheme();
// 2. Load Assets & Sidebar
await initAssets();
// 2.5 Load Global Config
try {
const configRes = await fetch('/api/config');
if (configRes.ok) {
const configData = await configRes.json();
const { state } = await import('./modules/state.js');
state.appConfig = configData;
logger.info("Global configuration loaded from server.");
}
} catch (err) {
logger.high("Failed to load global config, using defaults.");
}
// 3. Initialize X6 Graph
initGraph();
// 4. Initialize UI Toggles
initUIToggles();
// 5. Initialize Settings Modal Logic
initSettings();
// 6. Apply Saved Visual Settings (Grid, Size)
applySavedSettings();
// 7. Initialize Keyboard Hotkeys
initHotkeys();
// 8. Initialize UI Components & Persistence
initPropertiesSidebar();
initContextMenu();
initGraphIO();
initGlobalSearch();
initInventory();
initLayerPanel();
applyLayerFilters();
initPersistence();
initLicense();
// Help Button
const helpBtn = document.getElementById('help-btn');
if (helpBtn) {
helpBtn.addEventListener('click', async () => {
const { showHelpModal } = await import('./modules/ui/help_modal.js');
showHelpModal();
});
}
logger.high("drawNET Premium initialized successfully.");
});
+17
View File
@@ -0,0 +1,17 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
/**
* fetchAssets - Fetches the complete asset and pack list from the server
*/
export async function fetchAssets() {
try {
const response = await fetch('/assets');
const data = await response.json();
state.assetsData = data.assets || [];
return data;
} catch (e) {
logger.critical("Failed to fetch assets:", e);
return { assets: [], packs: [] };
}
}
+110
View File
@@ -0,0 +1,110 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
export const FIXED_ASSETS = [
{ id: 'fixed-group', type: 'group', label: 'Group Container', icon: 'group.png', is_img: true },
{ id: 'fixed-rect', type: 'rect', label: 'Rectangle', icon: 'far fa-square', is_img: false },
{ id: 'fixed-rounded-rect', type: 'rounded-rect', label: 'Rounded Rectangle', icon: 'fas fa-vector-square', is_img: false },
{ id: 'fixed-circle', type: 'circle', label: 'Circle', icon: 'far fa-circle', is_img: false },
{ id: 'fixed-text', type: 'text-box', label: 'Text Box', icon: 'fas fa-font', is_img: false },
{ id: 'fixed-label', type: 'label', label: 'Label', icon: 'fas fa-tag', is_img: false },
{ id: 'fixed-blank', type: 'blank', label: 'Blank Point (Invisible)', icon: 'fas fa-crosshairs', is_img: false, extra_style: 'opacity: 0.5;' },
{ id: 'fixed-dot', type: 'dot', label: 'Dot (Small visible point)', icon: 'fas fa-circle', is_img: false, extra_style: 'font-size: 6px;' },
{ id: 'fixed-ellipse', type: 'ellipse', label: 'Ellipse', icon: 'fas fa-egg', is_img: false, extra_style: 'transform: rotate(-45deg);' },
{ id: 'fixed-polyline', type: 'polyline', label: 'Polyline', icon: 'fas fa-chart-line', is_img: false },
{ id: 'fixed-browser', type: 'browser', label: 'Browser', icon: 'fas fa-window-maximize', is_img: false },
{ id: 'fixed-user', type: 'user', label: 'User', icon: 'fas fa-user', is_img: false },
{ id: 'fixed-admin', type: 'admin', label: 'Admin', icon: 'fas fa-user-shield', is_img: false },
{ id: 'fixed-triangle', type: 'triangle', label: 'Triangle', icon: 'fas fa-caret-up', is_img: false, extra_style: 'font-size: 22px; margin-top: -2px;' },
{ id: 'fixed-diamond', type: 'diamond', label: 'Diamond', icon: 'fas fa-square', is_img: false, extra_style: 'transform: rotate(45deg) scale(0.8);' },
{ id: 'fixed-parallelogram', type: 'parallelogram', label: 'Parallelogram', icon: 'fas fa-square', is_img: false, extra_style: 'transform: skewX(-20deg) scaleX(1.2);' },
{ id: 'fixed-cylinder', type: 'cylinder', label: 'Cylinder', icon: 'fas fa-database', is_img: false },
{ id: 'fixed-document', type: 'document', label: 'Document', icon: 'fas fa-file-alt', is_img: false },
{ id: 'fixed-manual-input', type: 'manual-input', label: 'Manual Input', icon: 'fas fa-keyboard', is_img: false },
{ id: 'fixed-table', type: 'table', label: 'Table', icon: 'fas fa-table', is_img: false },
{ id: 'fixed-rack', type: 'rack', label: 'Rack', icon: 'fas fa-columns', is_img: false },
{ id: 'fixed-hourglass', type: 'hourglass', label: 'Hourglass', icon: 'fas fa-hourglass-half', is_img: false },
{ id: 'fixed-arrow-up', type: 'arrow-up', label: 'Arrow Up', icon: 'fas fa-arrow-up', is_img: false },
{ id: 'fixed-arrow-down', type: 'arrow-down', label: 'Arrow Down', icon: 'fas fa-arrow-down', is_img: false },
{ id: 'fixed-arrow-left', type: 'arrow-left', label: 'Arrow Left', icon: 'fas fa-arrow-left', is_img: false },
{ id: 'fixed-arrow-right', type: 'arrow-right', label: 'Arrow Right', icon: 'fas fa-arrow-right', is_img: false },
{ id: 'fixed-rich-card', type: 'rich-card', label: t('rich_card') || 'Rich Text Card', icon: 'fas fa-address-card', is_img: false }
];
export function renderFixedSection(container) {
const fixedSection = document.createElement('div');
fixedSection.className = 'asset-category fixed-category';
const title = t('fixed_objects') || 'Fixed Objects';
fixedSection.innerHTML = `
<div class="category-header">
<span data-i18n="fixed_objects">${title}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="category-collapsed-icon" title="${title}">
<i class="fas fa-layer-group" style="font-size: 20px; color: #64748b;"></i>
</div>
<div class="category-content">
<div class="asset-grid"></div>
</div>
<div class="category-divider" style="height: 1px; background: rgba(255,255,255,0.1); margin: 15px 10px;"></div>
`;
// Toggle Logic
const header = fixedSection.querySelector('.category-header');
header.addEventListener('click', () => {
if (!document.getElementById('sidebar').classList.contains('collapsed')) {
fixedSection.classList.toggle('active');
}
});
// Handle Sidebar Collapsed State (Flyout)
const collapsedIcon = fixedSection.querySelector('.category-collapsed-icon');
collapsedIcon.addEventListener('click', (e) => {
if (document.getElementById('sidebar').classList.contains('collapsed')) {
e.stopPropagation();
// Since we don't want to import renderFlyout here to avoid circular dependencies
// we'll dispatch a custom event or check if it's available globally (though not recommended)
// or just let assets.js handle it if we restructure.
// For now, let's trigger a click on a hidden flyout handler or use state.
const event = new CustomEvent('render-fixed-flyout', {
detail: {
container: fixedSection,
assets: FIXED_ASSETS,
meta: { id: 'fixed_objects', label: title }
}
});
document.dispatchEvent(event);
}
});
const grid = fixedSection.querySelector('.asset-grid');
FIXED_ASSETS.forEach(asset => {
const item = document.createElement('div');
item.className = 'asset-item';
item.draggable = true;
item.dataset.type = asset.type;
item.dataset.assetId = asset.id;
item.title = asset.label;
if (asset.is_img) {
item.innerHTML = `<img src="/static/assets/${asset.icon}" width="22" height="22" loading="lazy">`;
} else {
item.innerHTML = `<i class="${asset.icon}" style="font-size: 18px; color: #64748b; ${asset.extra_style || ''}"></i>`;
}
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetId', asset.id);
state.assetMap[asset.id] = {
id: asset.id,
type: asset.type,
path: asset.is_img ? asset.icon : '',
label: asset.label
};
});
grid.appendChild(item);
});
container.appendChild(fixedSection);
}
+60
View File
@@ -0,0 +1,60 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
import { fetchAssets } from './api.js';
import { renderLibrary, createAssetElement, renderFlyout } from './renderer.js';
import { renderPackSelector } from './selector.js';
import { initSearch } from './search.js';
import { renderFixedSection } from './fixed_objects.js';
import { logger } from '../utils/logger.js';
/**
* initAssets - Main initialization for the asset library sidebar
*/
export async function initAssets() {
try {
const data = await fetchAssets();
const library = document.querySelector('.asset-library');
if (!library) return;
// 1. Initial Header & Global UI
library.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin: 24px 16px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 8px;">
<h3 style="margin:0; font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--sub-text); font-weight: 800;">
${t('asset_library') || 'Asset Library'}
</h3>
<button id="manage-packs-btn" style="background:none; border:none; color: var(--sub-text); cursor:pointer; font-size: 12px; transition: color 0.2s;" title="Manage Packages">
<i class="fas fa-filter"></i>
</button>
</div>
`;
// 2. Setup Manage Packs Button
document.getElementById('manage-packs-btn').onclick = (e) => {
e.stopPropagation();
renderPackSelector(data.packs || []);
};
// 3. Initialize selection if empty
if (state.selectedPackIds.length === 0 && data.packs && data.packs.length > 0) {
state.selectedPackIds = data.packs.map(p => p.id);
localStorage.setItem('selectedPackIds', JSON.stringify(state.selectedPackIds));
}
// 4. Render Sections
renderFixedSection(library);
renderLibrary({ ...data, state }, library, renderPackSelector);
// 5. Handle Flyouts for Fixed/Custom Sections via Event
document.addEventListener('render-fixed-flyout', (e) => {
const { container, assets, meta } = e.detail;
renderFlyout(container, assets, meta);
});
initSearch();
} catch (e) {
logger.critical("Asset Initialization Error:", e);
}
}
export { createAssetElement, renderFlyout, renderPackSelector };
+197
View File
@@ -0,0 +1,197 @@
import { t } from '../i18n.js';
import { DEFAULTS } from '../constants.js';
/**
* createAssetElement - Creates a single asset item DOM element
* @param {Object} asset
* @returns {HTMLElement}
*/
export function createAssetElement(asset, compositeId = null) {
const item = document.createElement('div');
item.className = 'asset-item';
item.draggable = true;
item.dataset.type = asset.type || asset.id;
item.dataset.assetId = compositeId || asset.id;
item.title = asset.label;
// Support legacy 'path', new 'views.icon' or FontAwesome icon class
const isFontAwesome = asset.is_img === false || (!asset.path && !asset.views && asset.icon && asset.icon.includes('fa-'));
if (isFontAwesome) {
item.innerHTML = `
<i class="${asset.icon}" style="font-size: 18px; color: #64748b; ${asset.extra_style || ''}"></i>
<span>${t(asset.id) || asset.label || asset.id}</span>
`;
} else {
const iconPath = (asset.views && asset.views.icon) ? asset.views.icon : (asset.path || DEFAULTS.DEFAULT_ICON);
item.innerHTML = `
<img src="/static/assets/${iconPath}" width="24" height="24" loading="lazy">
<span>${t(asset.id) || asset.label || asset.id}</span>
`;
}
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetId', item.dataset.assetId);
});
return item;
}
/**
* renderFlyout - Renders the floating category window when sidebar is collapsed
* @param {HTMLElement} container
* @param {Array} assets
* @param {Object} meta
*/
export function renderFlyout(container, assets, meta) {
document.querySelectorAll('.category-flyout').forEach(f => f.remove());
const flyout = document.createElement('div');
flyout.className = 'category-flyout glass-panel active';
const rect = container.getBoundingClientRect();
flyout.style.top = `${Math.max(10, Math.min(window.innerHeight - 400, rect.top))}px`;
flyout.style.left = '90px';
flyout.innerHTML = `
<div class="flyout-header">
<strong>${t(meta.id) || meta.name || meta.label}</strong>
<small>${assets.length} items</small>
</div>
<div class="flyout-content" style="max-height: 400px; overflow-y: auto;"></div>
`;
const content = flyout.querySelector('.flyout-content');
// Group by category for flyout as well
const groups = assets.reduce((acc, a) => {
const cat = a.category || 'Other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(a);
return acc;
}, {});
Object.keys(groups).sort().forEach(catName => {
if (Object.keys(groups).length > 1 || catName !== 'Other') {
const label = document.createElement('div');
label.className = 'asset-group-label';
label.textContent = catName;
content.appendChild(label);
}
const grid = document.createElement('div');
grid.className = 'asset-grid flyout-grid';
groups[catName].forEach(asset => grid.appendChild(createAssetElement(asset)));
content.appendChild(grid);
});
document.body.appendChild(flyout);
const closeFlyout = (e) => {
if (!flyout.contains(e.target)) {
flyout.remove();
document.removeEventListener('click', closeFlyout);
}
};
setTimeout(() => document.addEventListener('click', closeFlyout), 10);
}
/**
* renderLibrary - The main loop that renders the entire asset library
*/
export function renderLibrary(data, library, renderPackSelector) {
const { state } = data; // We expect state to be passed or accessible
const packs = data.packs || [];
const filteredPacks = packs.filter(p => state.selectedPackIds.includes(p.id));
// 1. Clear Library (except fixed categories and header)
const dynamicPacks = library.querySelectorAll('.asset-category:not(.fixed-category)');
dynamicPacks.forEach(p => p.remove());
// 2. Group Custom Assets by Pack -> Category
const packGroups = state.assetsData.reduce((acc, asset) => {
const packId = asset.pack_id || 'legacy';
if (packId !== 'legacy' && !state.selectedPackIds.includes(packId)) return acc;
const category = asset.category || 'Other';
if (!acc[packId]) acc[packId] = {};
if (!acc[packId][category]) acc[packId][category] = [];
acc[packId][category].push(asset);
return acc;
}, {});
// 3. Render each visible Pack
Object.keys(packGroups).forEach((packId, index) => {
const packMeta = packs.find(p => p.id === packId) || { id: packId, name: (packId === 'legacy' ? t('other_assets') : packId) };
const categories = packGroups[packId];
const allAssetsInPack = Object.values(categories).flat();
// Ensure proper path is set for pack-based assets
if (packId !== 'legacy') {
allAssetsInPack.forEach(asset => {
if (asset.views && asset.views.icon) {
const icon = asset.views.icon;
// Only prepend packs/ if not already present
if (!icon.startsWith('packs/')) {
asset.path = `packs/${packId}/${icon}`;
} else {
asset.path = icon;
}
}
});
}
const firstAsset = allAssetsInPack[0] || {};
const iconPath = (firstAsset.views && firstAsset.views.icon) ? firstAsset.views.icon : (firstAsset.path || DEFAULTS.DEFAULT_ICON);
const catContainer = document.createElement('div');
catContainer.className = `asset-category shadow-sm`;
const header = document.createElement('div');
header.className = 'category-header';
header.innerHTML = `<span>${packMeta.name || packMeta.id}</span><i class="fas fa-chevron-down"></i>`;
header.onclick = () => {
if (!document.getElementById('sidebar').classList.contains('collapsed')) {
catContainer.classList.toggle('active');
}
};
const collapsedIcon = document.createElement('div');
collapsedIcon.className = 'category-collapsed-icon';
collapsedIcon.title = packMeta.name || packMeta.id;
collapsedIcon.innerHTML = `<img src="/static/assets/${iconPath}" width="28" height="28">`;
collapsedIcon.onclick = (e) => {
if (document.getElementById('sidebar').classList.contains('collapsed')) {
e.stopPropagation();
renderFlyout(catContainer, allAssetsInPack, packMeta);
}
};
const content = document.createElement('div');
content.className = 'category-content';
// Render each Category within the Pack
Object.keys(categories).sort().forEach(catName => {
const assets = categories[catName];
if (Object.keys(categories).length > 1 || catName !== 'Other') {
const subHeader = document.createElement('div');
subHeader.className = 'asset-group-label';
subHeader.textContent = catName;
content.appendChild(subHeader);
}
const itemGrid = document.createElement('div');
itemGrid.className = 'asset-grid';
assets.forEach(asset => {
const compositeId = `${packId}|${asset.id}`;
state.assetMap[compositeId] = asset;
itemGrid.appendChild(createAssetElement(asset, compositeId));
});
content.appendChild(itemGrid);
});
catContainer.append(header, collapsedIcon, content);
library.appendChild(catContainer);
});
}
+35
View File
@@ -0,0 +1,35 @@
import { state } from '../state.js';
/**
* initSearch - Initializes the asset search input listener
*/
export function initSearch() {
const searchInput = document.getElementById('asset-search');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
const categories = document.querySelectorAll('.asset-category');
categories.forEach(cat => {
const items = cat.querySelectorAll('.asset-item');
let hasVisibleItem = false;
items.forEach(item => {
const assetId = item.dataset.assetId;
const asset = state.assetMap[assetId];
if (!asset) return;
const matches = (asset.type && asset.type.toLowerCase().includes(query)) ||
(asset.label && asset.label.toLowerCase().includes(query)) ||
(asset.vendor && asset.vendor.toLowerCase().includes(query));
item.style.display = matches ? 'flex' : 'none';
if (matches) hasVisibleItem = true;
});
cat.style.display = (hasVisibleItem || query === '') ? 'block' : 'none';
if (query !== '' && hasVisibleItem) cat.classList.add('active');
});
});
}
+64
View File
@@ -0,0 +1,64 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
/**
* renderPackSelector - Show a modal to enable/disable asset packages
*/
export function renderPackSelector(packs) {
let modal = document.getElementById('pack-selector-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'pack-selector-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
const selectedIds = state.selectedPackIds;
modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h3>${t('manage_packages') || 'Manage Packages'}</h3>
<i class="fas fa-times close-modal"></i>
</div>
<div class="modal-body" style="padding: 15px;">
<p style="font-size: 12px; color: #64748b; margin-bottom: 15px;">Select which asset packages to load in your sidebar.</p>
<div class="pack-list">
${packs.map(p => `
<div class="pack-item" style="display: flex; align-items: center; justify-content: space-between; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-box" style="color: #38bdf8; font-size: 14px;"></i>
<span style="font-size: 13px; font-weight: 600;">${p.name || p.id}</span>
</div>
<input type="checkbox" data-id="${p.id}" ${selectedIds.includes(p.id) ? 'checked' : ''} style="width: 18px; height: 18px; cursor: pointer;">
</div>
`).join('')}
</div>
</div>
<div class="modal-footer" style="padding: 15px; border-top: 1px solid rgba(255,255,255,0.05); text-align: right;">
<button class="btn-primary apply-packs" style="background: #38bdf8; color: #0f172a; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 700; cursor: pointer;">Apply & Refresh</button>
</div>
</div>
`;
modal.classList.add('active');
// Close handlers
modal.querySelector('.close-modal').onclick = () => modal.classList.remove('active');
modal.onclick = (e) => { if (e.target === modal) modal.classList.remove('active'); };
// Apply handler
modal.querySelector('.apply-packs').onclick = () => {
const checkboxes = modal.querySelectorAll('input[type="checkbox"]');
const newSelectedIds = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.dataset.id);
state.selectedPackIds = newSelectedIds;
localStorage.setItem('selectedPackIds', JSON.stringify(newSelectedIds));
// This is a global refresh to re-init everything with new filters
// For a more seamless experience, we could re-call initAssets()
window.location.reload();
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* constants.js - Central repository for magic strings and app configuration
*/
// LocalStorage Keys
export const STORAGE_KEYS = {
SETTINGS: 'drawNET-settings',
THEME: 'drawNET-theme',
AUTOSAVE: 'drawNET_autosave',
LANGUAGE: 'drawNET_lang'
};
// Default Configuration
export const DEFAULTS = {
GRID_SPACING: 20,
GRID_COLOR: '#e2e8f0',
MAJOR_GRID_COLOR: '#cbd5e1',
MAJOR_GRID_INTERVAL: 5,
CANVAS_PRESET: 'A4',
CANVAS_ORIENTATION: 'portrait',
APP_VERSION: '2.0.0-pro',
DEFAULT_ICON: 'router.svg'
};
// UI Toggles & States
export const UI_STATES = {
QUAKE_COLLAPSED_HEIGHT: '40px',
QUAKE_EXPANDED_HEIGHT: '300px'
};
+34
View File
@@ -0,0 +1,34 @@
/**
* graph.js - Main Entry for AntV X6 Graph Module
* Modularized version splitting styles, events, interactions, and IO.
*/
import { state } from './state.js';
import { registerCustomShapes } from './graph/styles.js';
import { initGraphEvents } from './graph/events/index.js';
import { initInteractions } from './graph/interactions/index.js';
import { initGraphIO } from './graph/io/index.js';
import { initPropertiesSidebar } from './properties_sidebar/index.js';
import { initContextMenu } from './ui/context_menu/index.js';
import { getGraphConfig } from './graph/config.js';
import { initGraphPlugins } from './graph/plugins.js';
import { logger } from './utils/logger.js';
export function initGraph() {
const container = document.getElementById('graph-container');
if (!container) return;
// 1. Register Custom Shapes
registerCustomShapes();
// 2. Initialize AntV X6 Graph Instance
state.graph = new X6.Graph(getGraphConfig(container));
// 3. Enable Plugins
initGraphPlugins(state.graph);
// 4. Initialize Sub-modules
initGraphEvents();
initInteractions();
logger.high("AntV X6 Graph initialized with modules.");
}
+139
View File
@@ -0,0 +1,139 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
/**
* Alignment and Distribution tools for drawNET (X6 version).
*/
/**
* alignNodes - Aligns selected nodes based on the FIRST selected node
* @param {'top'|'bottom'|'left'|'right'|'middle'|'center'} type
*/
export function alignNodes(type) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length < 2) return;
// First selected object is the reference per user request
const reference = selected[0];
const refPos = reference.getPosition();
const refSize = reference.getSize();
selected.slice(1).forEach(node => {
const pos = node.getPosition();
const size = node.getSize();
switch (type) {
case 'top':
node.setPosition(pos.x, refPos.y);
break;
case 'bottom':
node.setPosition(pos.x, refPos.y + refSize.height - size.height);
break;
case 'left':
node.setPosition(refPos.x, pos.y);
break;
case 'right':
node.setPosition(refPos.x + refSize.width - size.width, pos.y);
break;
case 'middle':
node.setPosition(pos.x, refPos.y + (refSize.height / 2) - (size.height / 2));
break;
case 'center':
node.setPosition(refPos.x + (refSize.width / 2) - (size.width / 2), pos.y);
break;
}
});
notifyChanges();
logger.info(`drawNET: Aligned ${selected.length} nodes (${type}).`);
}
/**
* moveNodes - Moves selected nodes by dx, dy
*/
export function moveNodes(dx, dy) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length === 0) return;
selected.forEach(node => {
const pos = node.getPosition();
node.setPosition(pos.x + dx, pos.y + dy);
});
// Sync with persistence after move (with debounce)
clearTimeout(state.moveSyncTimer);
state.moveSyncTimer = setTimeout(() => {
notifyChanges();
}, 500);
}
export function distributeNodes(type) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length < 3) return;
const bboxes = selected.map(node => ({
node,
bbox: node.getBBox()
}));
if (type === 'horizontal') {
// Sort nodes from left to right
bboxes.sort((a, b) => a.bbox.x - b.bbox.x);
const first = bboxes[0];
const last = bboxes[bboxes.length - 1];
// Calculate Total Span (Right edge of last - Left edge of first)
const totalSpan = (last.bbox.x + last.bbox.width) - first.bbox.x;
// Calculate Sum of Node Widths
const sumWidths = bboxes.reduce((sum, b) => sum + b.bbox.width, 0);
// Total Gap Space
const totalGapSpace = totalSpan - sumWidths;
if (totalGapSpace < 0) return; // Overlapping too much? Skip for now
const gapSize = totalGapSpace / (bboxes.length - 1);
let currentX = first.bbox.x;
bboxes.forEach((b, i) => {
if (i > 0) {
currentX += bboxes[i-1].bbox.width + gapSize;
b.node.setPosition(currentX, b.bbox.y);
}
});
} else if (type === 'vertical') {
// Sort nodes from top to bottom
bboxes.sort((a, b) => a.bbox.y - b.bbox.y);
const first = bboxes[0];
const last = bboxes[bboxes.length - 1];
const totalSpan = (last.bbox.y + last.bbox.height) - first.bbox.y;
const sumHeights = bboxes.reduce((sum, b) => sum + b.bbox.height, 0);
const totalGapSpace = totalSpan - sumHeights;
if (totalGapSpace < 0) return;
const gapSize = totalGapSpace / (bboxes.length - 1);
let currentY = first.bbox.y;
bboxes.forEach((b, i) => {
if (i > 0) {
currentY += bboxes[i-1].bbox.height + gapSize;
b.node.setPosition(b.bbox.x, currentY);
}
});
}
notifyChanges();
}
/**
* notifyChanges - Marks dirty for persistence
*/
function notifyChanges() {
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
+167
View File
@@ -0,0 +1,167 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
import { t } from '../i18n.js';
import { makeDraggable } from '../ui/utils.js';
/**
* initInventory - Initializes the real-time inventory counting panel
*/
export function initInventory() {
const inventoryPanel = document.getElementById('inventory-panel');
const inventoryList = document.getElementById('inventory-list');
if (!inventoryPanel || !inventoryList) return;
// Make Draggable
makeDraggable(inventoryPanel, '.inventory-header');
// Initial update
updateInventory(inventoryList);
// Listen for graph changes
if (state.graph) {
state.graph.on('node:added', () => updateInventory(inventoryList));
state.graph.on('node:removed', () => updateInventory(inventoryList));
state.graph.on('cell:changed', ({ cell }) => {
if (cell.isNode()) updateInventory(inventoryList);
});
// project:restored 이벤트 수신 (복구 시점)
state.graph.on('project:restored', () => {
logger.info("Project restored. Updating inventory.");
updateInventory(inventoryList);
});
}
}
/**
* toggleInventory - Toggles the visibility of the inventory panel
*/
export function toggleInventory() {
const panel = document.getElementById('inventory-panel');
if (!panel) return;
// Use getComputedStyle for more reliable check
const currentDisplay = window.getComputedStyle(panel).display;
if (currentDisplay === 'none') {
panel.style.display = 'flex';
panel.classList.add('animate-up');
} else {
panel.style.display = 'none';
}
}
/**
* updateInventory - Aggregates node counts by type and updates the UI
*/
export function updateInventory(container) {
if (!state.graph) return;
// Use default container if not provided
if (!container) {
container = document.getElementById('inventory-list');
}
if (!container) return;
const nodes = state.graph.getNodes();
const counts = {};
nodes.forEach(node => {
const data = node.getData() || {};
const type = data.type || 'Unknown';
counts[type] = (counts[type] || 0) + 1;
});
// Render list
container.innerHTML = '';
const sortedTypes = Object.keys(counts).sort();
if (sortedTypes.length === 0) {
container.innerHTML = `<div style="font-size: 10px; color: #94a3b8; text-align: center; margin-top: 10px;">${t('no_objects_found') || 'No objects found'}</div>`;
return;
}
sortedTypes.forEach(type => {
const item = document.createElement('div');
item.className = 'inventory-item';
const displayType = t(type.toLowerCase()) || type;
item.innerHTML = `
<span class="lbl">${displayType}</span>
<span class="val">${counts[type]}</span>
`;
container.appendChild(item);
});
// BOM 내보내기 버튼 추가 (푸터)
let footer = document.querySelector('.inventory-footer');
if (!footer) {
footer = document.createElement('div');
footer.className = 'inventory-footer';
}
footer.innerHTML = `
<button id="btn-export-bom" class="btn-export-bom">
<i class="fas fa-file-excel"></i>
<span>${t('export_bom') || 'Export BOM (Excel)'}</span>
</button>
`;
container.appendChild(footer);
const btn = document.getElementById('btn-export-bom');
if (btn) btn.onclick = downloadBOM;
}
/**
* downloadBOM - 가시화된 모든 노드 정보를 엑셀/CSV 형태로 추출
*/
function downloadBOM() {
if (!state.graph) return;
const nodes = state.graph.getNodes();
if (nodes.length === 0) return;
// 헤더 정의
const headers = [
t('prop_id'), t('display_name'), t('prop_type'),
t('prop_vendor'), t('prop_model'), t('prop_ip'),
t('prop_status'), t('prop_asset_tag'), t('prop_project'), t('prop_env')
];
// 데이터 행 생성
const rows = nodes.map(node => {
const data = node.getData() || {};
const label = node.attr('label/text') || '';
return [
node.id,
label,
data.type || '',
data.vendor || '',
data.model || '',
data.ip || '',
data.status || '',
data.asset_tag || '',
data.project || '',
data.env || ''
].map(val => `"${String(val).replace(/"/g, '""')}"`); // CSV 이스케이프
});
// CSV 문자열 합치기
const csvContent = "\uFEFF" + // UTF-8 BOM for Excel kor
headers.join(',') + "\n" +
rows.map(r => r.join(',')).join('\n');
// 다운로드 트리거
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.setAttribute("href", url);
link.setAttribute("download", `drawNET_BOM_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
logger.info("BOM exported successfully.");
}
+134
View File
@@ -0,0 +1,134 @@
import { DEFAULTS } from '../constants.js';
import { state } from '../state.js';
import { calculateCellZIndex } from './layers.js';
import { getSettings } from '../settings/store.js';
/**
* graph/config.js - X6 Graph configuration options
*/
export const getGraphConfig = (container) => {
const settings = getSettings();
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const defaultBg = isDark ? '#1e293b' : '#f8fafc';
return {
container: container,
autoResize: true,
background: { color: settings.bgColor || defaultBg },
grid: {
visible: settings.gridStyle !== 'none',
type: settings.gridStyle === 'solid' ? 'mesh' : (settings.gridStyle === 'dashed' ? 'doubleMesh' : 'dot'),
size: settings.gridSpacing || DEFAULTS.GRID_SPACING,
args: {
color: settings.gridColor || DEFAULTS.GRID_COLOR,
thickness: settings.gridThickness || 1
},
},
panning: {
enabled: true,
modifiers: 'space',
},
history: {
enabled: true,
},
mousewheel: {
enabled: true,
modifiers: ['ctrl', 'meta'],
factor: state.appConfig?.mousewheel?.factor || 1.05,
minScale: state.appConfig?.mousewheel?.minScale || 0.1,
maxScale: state.appConfig?.mousewheel?.maxScale || 10,
},
connecting: {
router: null,
connector: { name: 'jumpover', args: { type: 'arc', size: 6 } },
anchor: 'orth',
connectionPoint: 'boundary',
allowNode: false,
allowBlank: false,
snap: { radius: 20 },
createEdge() {
const edgeData = {
layerId: state.activeLayerId || 'l1',
routing_offset: 20,
direction: 'none',
description: "",
asset_tag: ""
};
return new X6.Shape.Edge({
attrs: {
line: {
stroke: '#94a3b8',
strokeWidth: 2,
targetMarker: null
},
},
zIndex: calculateCellZIndex(edgeData, 0, false),
data: edgeData
})
},
validateConnection({ sourceMagnet, targetMagnet }) {
return !!sourceMagnet && !!targetMagnet;
},
highlight: true,
},
embedding: {
enabled: true,
findParent({ node }) {
const bbox = node.getBBox();
const candidates = this.getNodes().filter((n) => {
const data = n.getData();
if (data && data.is_group && n.id !== node.id) {
const targetBBox = n.getBBox();
return targetBBox.containsRect(bbox);
}
return false;
});
if (candidates.length === 0) return [];
if (candidates.length === 1) return [candidates[0]];
// Return the one with the smallest area (the innermost one)
const bestParent = candidates.reduce((smallest, current) => {
const smallestBBox = smallest.getBBox();
const currentBBox = current.getBBox();
const currentArea = currentBBox.width * currentBBox.height;
const smallestArea = smallestBBox.width * smallestBBox.height;
if (currentArea < smallestArea) return current;
if (currentArea > smallestArea) return smallest;
// If areas are equal, pick the one that is a descendant of the other
if (current.getAncestors().some(a => a.id === smallest.id)) return current;
return smallest;
});
return [bestParent];
},
validate({ child, parent }) {
return parent.getData()?.is_group;
},
},
interacting: {
nodeMovable: (view) => {
if (state.isCtrlPressed) return false; // Block default move during Ctrl+Drag
return view.cell.getProp('movable') !== false;
},
edgeMovable: (view) => {
if (state.isCtrlPressed) return false;
return view.cell.getProp('movable') !== false;
},
edgeLabelMovable: (view) => view.cell.getProp('movable') !== false,
arrowheadMovable: (view) => view.cell.getProp('movable') !== false,
vertexMovable: (view) => view.cell.getProp('movable') !== false,
vertexAddable: (view) => {
if (view.cell.getProp('movable') === false) return false;
// Only allow adding vertices if the edge is ALREADY selected.
// This prevents accidental addition on the first click (which selects the edge).
return view.graph.isSelected(view.cell);
},
vertexDeletable: (view) => view.cell.getProp('movable') !== false,
},
};
};
+18
View File
@@ -0,0 +1,18 @@
import { initSystemEvents } from './system.js';
import { initViewportEvents } from './viewport.js';
import { initSelectionEvents } from './selection.js';
import { initNodeEvents } from './nodes.js';
import { initRoutingEvents } from './routing.js';
export { updateGridBackground } from './system.js';
/**
* initGraphEvents - Orchestrates the initialization of all specialized event modules.
*/
export function initGraphEvents() {
initSystemEvents();
initViewportEvents();
initSelectionEvents();
initNodeEvents();
initRoutingEvents();
}
+98
View File
@@ -0,0 +1,98 @@
import { state } from '../../state.js';
import { generateRackUnitsPath } from '../styles/utils.js';
export function initNodeEvents() {
if (!state.graph) return;
// Hover Guides for specific objects
state.graph.on('node:mouseenter', ({ node }) => {
if (node.shape === 'drawnet-dot') {
node.attr('hit/stroke', 'rgba(59, 130, 246, 0.4)');
node.attr('hit/strokeDasharray', '2,2');
}
});
state.graph.on('node:mouseleave', ({ node }) => {
if (node.shape === 'drawnet-dot') {
node.attr('hit/stroke', 'transparent');
node.attr('hit/strokeDasharray', '0');
}
});
// Rack Drill Down Interaction
state.graph.on('node:dblclick', ({ node }) => {
const data = node.getData() || {};
if (data.layerId !== state.activeLayerId) return; // Guard: Block drills into other layers
if (node.shape === 'drawnet-rack') {
state.graph.zoomToCell(node, {
padding: { top: 60, bottom: 60, left: 100, right: 100 },
animation: { duration: 600 }
});
state.graph.getCells().forEach(cell => {
if (cell.id !== node.id && !node.getDescendants().some(d => d.id === cell.id)) {
cell.setAttrs({ body: { opacity: 0.1 }, image: { opacity: 0.1 }, label: { opacity: 0.1 }, line: { opacity: 0.1 } });
} else {
cell.setAttrs({ body: { opacity: 1 }, image: { opacity: 1 }, label: { opacity: 1 }, line: { opacity: 1 } });
}
});
showBackToTopBtn();
}
});
// Parent/Structural Changes
state.graph.on('node:change:parent', ({ node, current }) => {
const parentId = current || null;
const currentData = node.getData() || {};
node.setData({ ...currentData, parent: parentId });
import('../layers.js').then(m => m.applyLayerFilters());
import('/static/js/modules/properties_sidebar/index.js').then(m => m.renderProperties());
import('/static/js/modules/persistence.js').then(m => m.markDirty());
});
// Data Syncing (Rack-specific)
state.graph.on('node:change:data', ({ node, current }) => {
if (node.shape === 'drawnet-rack') {
const slots = current.slots || 42;
const bbox = node.getBBox();
node.attr('units/d', generateRackUnitsPath(bbox.height, slots));
if (!current.label || current.label.startsWith('Rack (')) {
node.attr('label/text', `Rack (${slots}U)`);
}
}
// Rich Card Content Refresh on Data Change
if (node.shape === 'drawnet-rich-card') {
import('../styles/utils.js').then(m => m.renderRichContent(node));
}
});
}
function showBackToTopBtn() {
let btn = document.getElementById('rack-back-btn');
if (btn) btn.remove();
btn = document.createElement('div');
btn.id = 'rack-back-btn';
btn.innerHTML = '<i class="fas fa-arrow-left"></i> Back to Topology';
btn.style.cssText = `
position: absolute; top: 100px; left: 300px; z-index: 1000;
padding: 10px 20px; background: #3b82f6; color: white;
border-radius: 8px; cursor: pointer; font-weight: bold;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.2s;
`;
btn.onclick = () => {
state.graph.zoomToFit({ padding: 100, animation: { duration: 500 } });
state.graph.getCells().forEach(cell => {
cell.setAttrs({ body: { opacity: 1 }, image: { opacity: 1 }, label: { opacity: 1 }, line: { opacity: 1 } });
});
btn.remove();
};
document.getElementById('graph-container').parentElement.appendChild(btn);
}
+60
View File
@@ -0,0 +1,60 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
export function initRoutingEvents() {
if (!state.graph) return;
// Re-evaluate routing when nodes are moved to handle adaptive fallback (Phase 1.18)
state.graph.on('node:change:position', ({ node }) => {
const edges = state.graph.getConnectedEdges(node);
if (edges.length === 0) return;
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
edges.forEach(edge => {
const data = edge.getData() || {};
const config = getX6EdgeConfig({
source: edge.getSource().cell,
target: edge.getTarget().cell,
...data
});
if (config.router !== edge.getRouter()) {
edge.setRouter(config.router || null);
logger.info(`drawNET: Dynamic Routing Update for ${edge.id}`);
}
if (config.connector !== edge.getConnector()) {
edge.setConnector(config.connector);
}
// Sync physical connections during move (especially for manual anchors)
if (config.source) edge.setSource(config.source);
if (config.target) edge.setTarget(config.target);
});
});
});
// Unify styling when an edge is newly connected via mouse (Phase 1.19)
state.graph.on('edge:connected', ({ edge }) => {
if (!edge || !edge.isEdge()) return;
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
const data = edge.getData() || {};
const config = getX6EdgeConfig({
source: edge.getSource().cell,
target: edge.getTarget().cell,
...data
});
// Apply official style & configuration
if (config.source) edge.setSource(config.source);
if (config.target) edge.setTarget(config.target);
edge.setRouter(config.router || null);
edge.setConnector(config.connector);
edge.setZIndex(config.zIndex || 5);
if (config.attrs) {
edge.setAttrs(config.attrs);
}
logger.info(`drawNET: Unified Style applied to new edge ${edge.id}`);
});
});
}
@@ -0,0 +1,55 @@
import { state } from '../../state.js';
export function initSelectionEvents() {
if (!state.graph) return;
state.graph.on('cell:selected', ({ cell }) => {
if (cell.isNode()) {
if (!state.selectionOrder.includes(cell.id)) {
state.selectionOrder.push(cell.id);
}
} else if (cell.isEdge()) {
// Add vertices tool for manual routing control (unless locked)
if (!cell.getData()?.locked) {
cell.addTools([{ name: 'vertices' }]);
}
// ADD S/T labels for clear identification of Start and Target
const currentLabels = cell.getLabels() || [];
cell.setLabels([
...currentLabels,
{ id: 'selection-source-label', position: { distance: 0.05 }, attrs: { text: { text: 'S', fill: '#10b981', fontSize: 10, fontWeight: 'bold' }, rect: { fill: '#ffffff', stroke: '#10b981', strokeWidth: 1, rx: 2, ry: 2 } } },
{ id: 'selection-target-label', position: { distance: 0.95 }, attrs: { text: { text: 'T', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' }, rect: { fill: '#ffffff', stroke: '#ef4444', strokeWidth: 1, rx: 2, ry: 2 } } }
]);
}
});
state.graph.on('cell:unselected', ({ cell }) => {
const isLocked = cell.getData()?.locked;
if (cell.isEdge()) {
if (!isLocked) {
cell.removeTools();
} else {
// Keep boundary if locked, but remove selection-only tools if any
cell.removeTools(['vertices']);
}
// Remove selection labels safely
const labels = cell.getLabels() || [];
const filteredLabels = labels.filter(l =>
l.id !== 'selection-source-label' && l.id !== 'selection-target-label'
);
cell.setLabels(filteredLabels);
} else if (cell.isNode()) {
if (!isLocked) {
cell.removeTools();
}
}
state.selectionOrder = state.selectionOrder.filter(id => id !== cell.id);
});
state.graph.on('blank:click', () => {
state.selectionOrder = [];
});
}
+132
View File
@@ -0,0 +1,132 @@
import { state } from '../../state.js';
import { updateGraphTheme } from '../styles.js';
import { DEFAULTS } from '../../constants.js';
export function initSystemEvents() {
if (!state.graph) return;
// Theme changes
window.addEventListener('themeChanged', () => {
updateGraphTheme();
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
state.graph.drawBackground({ color: isDark ? '#1e293b' : '#f8fafc' });
});
// Background color changes (from settings)
window.addEventListener('backgroundChanged', (e) => {
if (state.graph) {
state.graph.drawBackground({ color: e.detail.color });
}
});
// Canvas resize event
window.addEventListener('canvasResize', (e) => {
const { width, height } = e.detail;
state.canvasSize = { width, height };
const container = document.getElementById('graph-container');
if (width === '100%') {
container.style.width = '100%';
container.style.height = '100%';
container.style.boxShadow = 'none';
container.style.margin = '0';
state.graph.resize();
} else {
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.boxShadow = '0 10px 30px rgba(0,0,0,0.1)';
container.style.margin = '40px auto';
container.style.backgroundColor = '#fff';
state.graph.resize(width, height);
}
});
// Grid type change
window.addEventListener('gridChanged', (e) => {
state.gridStyle = e.detail.style;
if (!state.graph) return;
if (e.detail.style === 'none') {
state.graph.hideGrid();
state.isSnapEnabled = false;
} else {
state.graph.showGrid();
state.isSnapEnabled = true;
if (e.detail.showMajor) {
state.graph.drawGrid({
type: 'doubleMesh',
args: [
{
color: e.detail.majorColor || DEFAULTS.MAJOR_GRID_COLOR,
thickness: (e.detail.thickness || 1) * 1.5,
factor: e.detail.majorInterval || DEFAULTS.MAJOR_GRID_INTERVAL
},
{
color: e.detail.color || DEFAULTS.GRID_COLOR,
thickness: e.detail.thickness || 1
},
],
});
} else {
const gridType = e.detail.style === 'solid' ? 'mesh' : (e.detail.style === 'dashed' ? 'doubleMesh' : 'dot');
state.graph.drawGrid({
type: gridType,
args: [
{
color: e.detail.color || DEFAULTS.GRID_COLOR,
thickness: e.detail.thickness || 1
},
],
});
}
state.graph.setGridSize(state.gridSpacing || DEFAULTS.GRID_SPACING);
}
});
// Grid spacing change
window.addEventListener('gridSpacingChanged', (e) => {
state.gridSpacing = e.detail.spacing;
if (state.graph) {
state.graph.setGridSize(state.gridSpacing);
}
});
// Global Data Sanitizer & Enforcer (Ensures all objects have latest schema defaults)
// This handles all creation paths: Drag-and-Drop, Copy-Paste, Grouping, and programatic addEdge.
state.graph.on('cell:added', ({ cell }) => {
const data = cell.getData() || {};
let updated = false;
// 1. Common Fields for all Node/Edge objects
if (data.description === undefined) { data.description = ""; updated = true; }
if (data.asset_tag === undefined) { data.asset_tag = ""; updated = true; }
if (!data.tags) { data.tags = []; updated = true; }
if (data.locked === undefined) { data.locked = false; updated = true; }
// 2. Ensure label_pos exists to prevent display reset
if (data.label_pos === undefined) {
data.label_pos = data.is_group ? 'top' : (['rect', 'circle', 'rounded-rect', 'text-box', 'label'].includes((data.type || '').toLowerCase()) ? 'center' : 'bottom');
updated = true;
}
// 3. Edge Specific Defaults
if (cell.isEdge()) {
if (data.routing_offset === undefined) { data.routing_offset = 20; updated = true; }
}
if (updated) {
// Use silent: true to prevent triggering another 'cell:added' if someone is listening to data changes
cell.setData(data, { silent: true });
}
// Keep existing layer logic
import('../layers.js').then(m => m.applyLayerFilters());
});
}
export function updateGridBackground() {
if (!state.graph) return;
state.graph.setGridSize(state.gridSpacing || DEFAULTS.GRID_SPACING);
state.graph.showGrid();
}
@@ -0,0 +1,80 @@
import { state } from '../../state.js';
let isRightDragging = false;
let startX = 0;
let startY = 0;
const threshold = 5;
/**
* Handle mousedown on the graph to initialize right-click panning.
*/
const handleGraphMouseDown = ({ e }) => {
if (e.button === 2) { // Right Click
isRightDragging = false;
startX = e.clientX;
startY = e.clientY;
state.isRightDragging = false;
}
};
/**
* Global event handler for mousemove to support panning.
* Registered only once on document level.
*/
const onMouseMove = (e) => {
if (e.buttons === 2 && state.graph) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// Detect dragging start (beyond threshold)
if (!isRightDragging && (Math.abs(dx) > threshold || Math.abs(dy) > threshold)) {
isRightDragging = true;
state.isRightDragging = true;
// Sync start positions to current mouse position to avoid the initial jump
startX = e.clientX;
startY = e.clientY;
return;
}
if (isRightDragging) {
state.graph.translateBy(dx, dy);
startX = e.clientX;
startY = e.clientY;
}
}
};
/**
* Global event handler for mouseup to finish panning.
* Registered only once on document level.
*/
const onMouseUp = () => {
if (isRightDragging) {
// Reset after a short delay to allow contextmenu event to check the flag
setTimeout(() => {
state.isRightDragging = false;
isRightDragging = false;
}, 100);
}
};
/**
* Initializes viewport-related events for the current graph instance.
* Should be called whenever a new graph is created.
*/
export function initViewportEvents() {
if (!state.graph) return;
// Attach to the specific graph instance, ensuring no duplicates
state.graph.off('blank:mousedown', handleGraphMouseDown);
state.graph.on('blank:mousedown', handleGraphMouseDown);
state.graph.off('cell:mousedown', handleGraphMouseDown);
state.graph.on('cell:mousedown', handleGraphMouseDown);
// Register document-level listeners only once per page session
if (!window._drawNetViewportEventsInitialized) {
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
window._drawNetViewportEventsInitialized = true;
}
}
@@ -0,0 +1,95 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
import { getX6NodeConfig } from '../styles.js';
/**
* initDropHandling - Sets up drag and drop from the asset library to the graph
*/
export function initDropHandling(container) {
container.addEventListener('dragover', (e) => e.preventDefault());
container.addEventListener('drop', (e) => {
e.preventDefault();
const assetId = e.dataTransfer.getData('assetId');
const asset = state.assetMap[assetId];
const pos = state.graph.clientToLocal(e.clientX, e.clientY);
logger.info(`Drop Event at {${pos.x}, ${pos.y}}`, {
assetId: assetId,
found: !!asset,
id: asset?.id,
path: asset?.path,
type: asset?.type,
rawAsset: asset
});
if (!asset) {
logger.high(`Asset [${assetId}] not found in map`);
return;
}
// Logical Layer Guard: Block dropping new nodes
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
if (activeLayer && activeLayer.type === 'logical') {
import('../../ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
return;
}
const isPrimitive = ['rect', 'rounded-rect', 'circle', 'text-box', 'label'].includes(asset.type);
const prefix = asset.id === 'fixed-group' ? 'group_' : (asset.type === 'blank' || asset.type === 'dot' ? 'p_' : (isPrimitive ? 'shp_' : 'node_'));
const id = prefix + Math.random().toString(36).substr(2, 4);
// Use translate keys or asset label first, or fallback to generic name
let initialLabel = asset.label || t(asset.id) || (asset.id === 'fixed-group' ? 'New Group' : 'New ' + (asset.type || 'Object'));
if (asset.id === 'fixed-group') initialLabel = t('group') || 'New Group';
// Find if dropped over a group (prefer the smallest/innermost one)
const candidates = state.graph.getNodes().filter(n => {
if (!n.getData()?.is_group) return false;
const bbox = n.getBBox();
return bbox.containsPoint(pos);
});
const parent = candidates.length > 0
? candidates.reduce((smallest, current) => {
const smallestBBox = smallest.getBBox();
const currentBBox = current.getBBox();
const currentArea = currentBBox.width * currentBBox.height;
const smallestArea = smallestBBox.width * smallestBBox.height;
if (currentArea < smallestArea) return current;
if (currentArea > smallestArea) return smallest;
// If areas are equal, pick the one that is a descendant of the other
if (current.getAncestors().some(a => a.id === smallest.id)) return current;
return smallest;
})
: undefined;
// 2. Add to X6 immediately
const config = getX6NodeConfig({
id: id,
label: initialLabel,
type: asset.type || asset.category || asset.label || 'Unknown',
assetId: assetId, // Store unique composite ID for lookup
assetPath: asset.id === 'fixed-group' ? undefined : asset.path,
is_group: asset.id === 'fixed-group' || asset.is_group === true,
pos: { x: pos.x, y: pos.y },
parent: parent ? parent.id : undefined,
layerId: state.activeLayerId,
description: "",
asset_tag: "",
tags: []
});
const newNode = state.graph.addNode(config);
if (parent) {
parent.addChild(newNode);
}
// Ensure the new node follows the active layer's interaction rules immediately
import('../layers.js').then(m => m.applyLayerFilters());
});
}
@@ -0,0 +1,47 @@
/**
* graph/interactions/edges.js - Handles edge connection logic and metadata assignment.
*/
import { state } from '../../state.js';
/**
* initEdgeHandling - Sets up edge connection events
*/
export function initEdgeHandling() {
state.graph.on('edge:connected', ({ edge }) => {
const sourceId = edge.getSourceCellId();
const targetId = edge.getTargetCellId();
if (!sourceId || !targetId) return;
// Assign to current active layer and apply standard styling
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
// [6개월 뒤의 나를 위한 메모]
// 단순 유추가 아닌 명시적 기록을 통해, 도면 전체를 전송하지 않고도 연결선 단독으로
// 크로스 레이어 여부를 파악할수 있게 하여 외부 도구/AI 연동 안정성을 높임.
const sourceNode = state.graph.getCellById(sourceId);
const targetNode = state.graph.getCellById(targetId);
const sourceLayer = sourceNode?.getData()?.layerId || state.activeLayerId;
const targetLayer = targetNode?.getData()?.layerId || state.activeLayerId;
const isCrossLayer = (sourceLayer !== targetLayer);
const edgeData = {
source: sourceId,
target: targetId,
layerId: state.activeLayerId,
source_layer: sourceLayer,
target_layer: targetLayer,
is_cross_layer: isCrossLayer,
...edge.getData()
};
const config = getX6EdgeConfig(edgeData);
// Apply standardized config to the new edge
edge.setData(edgeData, { silent: true });
if (config.router) edge.setRouter(config.router);
else edge.setRouter(null);
if (config.connector) edge.setConnector(config.connector);
edge.setAttrs(config.attrs);
});
});
}
@@ -0,0 +1,23 @@
import { state } from '../../state.js';
import { initDropHandling } from './drop.js';
import { initEdgeHandling } from './edges.js';
import { initSnapping } from './snapping.js';
import { initSpecialShortcuts } from './shortcuts.js';
import { logger } from '../../utils/logger.js';
/**
* initInteractions - Entry point for all graph interactions
*/
export function initInteractions() {
if (!state.graph) return;
const container = state.graph.container;
// 1. Initialize Sub-modules
initDropHandling(container);
initEdgeHandling();
initSnapping();
initSpecialShortcuts();
logger.info("Graph interactions initialized via modules.");
}
@@ -0,0 +1,125 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* initSpecialShortcuts - Sets up specialized mouse/keyboard combined gestures
*/
export function initSpecialShortcuts() {
if (!state.graph) return;
let dragStartData = null;
// Ctrl + Drag to Copy: Improved Ghost Logic
state.graph.on('node:mousedown', ({ e, node }) => {
// Support both Ctrl (Windows) and Cmd (Mac)
const isModifier = e.ctrlKey || e.metaKey;
if (isModifier && e.button === 0) {
const selected = state.graph.getSelectedCells();
const targets = selected.includes(node) ? selected : [node];
// 1. Initial State for tracking
const mouseStart = state.graph.clientToLocal(e.clientX, e.clientY);
dragStartData = {
targets: targets,
mouseStart: mouseStart,
initialNodesPos: targets.map(t => ({ ...t.position() })),
ghost: null
};
const onMouseMove = (moveEvt) => {
if (!dragStartData) return;
const mouseNow = state.graph.clientToLocal(moveEvt.clientX, moveEvt.clientY);
const dx = mouseNow.x - dragStartData.mouseStart.x;
const dy = mouseNow.y - dragStartData.mouseStart.y;
// Create Ghost on movement
if (!dragStartData.ghost && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
const cellsBBox = state.graph.getCellsBBox(dragStartData.targets);
dragStartData.ghost = state.graph.addNode({
shape: 'rect',
x: cellsBBox.x,
y: cellsBBox.y,
width: cellsBBox.width,
height: cellsBBox.height,
attrs: {
body: {
fill: 'rgba(59, 130, 246, 0.1)',
stroke: '#3b82f6',
strokeWidth: 2,
strokeDasharray: '5,5',
pointerEvents: 'none'
}
},
zIndex: 1000
});
dragStartData.initialGhostPos = { x: cellsBBox.x, y: cellsBBox.y };
}
if (dragStartData.ghost) {
dragStartData.ghost.position(
dragStartData.initialGhostPos.x + dx,
dragStartData.initialGhostPos.y + dy
);
}
};
const onMouseUp = (upEvt) => {
cleanup(upEvt.button === 0);
};
const onKeyDown = (keyEvt) => {
if (keyEvt.key === 'Escape') {
cleanup(false); // Cancel on ESC
}
};
function cleanup(doPaste) {
if (dragStartData) {
if (doPaste && dragStartData.ghost) {
const finalPos = dragStartData.ghost.position();
const dx = finalPos.x - dragStartData.initialGhostPos.x;
const dy = finalPos.y - dragStartData.initialGhostPos.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
state.graph.copy(dragStartData.targets, { deep: true });
let clones = state.graph.paste({ offset: { dx, dy }, select: true });
// Logical Layer Guard: Filter out nodes if target layer is logical
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
if (activeLayer && activeLayer.type === 'logical') {
clones.forEach(clone => {
if (clone.isNode()) clone.remove();
});
clones = clones.filter(c => !c.isNode());
if (clones.length === 0) {
import('/static/js/modules/ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
}
}
clones.forEach(clone => {
if (clone.isNode()) {
const d = clone.getData() || {};
clone.setData({ ...d, id: clone.id }, { silent: true });
}
});
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
}
if (dragStartData.ghost) dragStartData.ghost.remove();
}
dragStartData = null;
window.removeEventListener('mousemove', onMouseMove, true);
window.removeEventListener('mouseup', onMouseUp, true);
window.removeEventListener('keydown', onKeyDown, true);
}
window.addEventListener('mousemove', onMouseMove, true);
window.addEventListener('mouseup', onMouseUp, true);
window.addEventListener('keydown', onKeyDown, true);
}
});
logger.info("initSpecialShortcuts (Ctrl+Drag with ESC Failsafe) initialized.");
}
@@ -0,0 +1,48 @@
import { state } from '../../state.js';
/**
* initSnapping - Sets up snapping and grouping interactions during movement
*/
export function initSnapping() {
// 1. Rack Snapping Logic
state.graph.on('node:moving', ({ node }) => {
const parent = node.getParent();
if (parent && parent.shape === 'drawnet-rack') {
const rackBBox = parent.getBBox();
const nodeSize = node.size();
const pos = node.getPosition();
const headerHeight = 30;
const rackBodyHeight = rackBBox.height - headerHeight;
const unitCount = parent.getData()?.slots || 42;
const unitHeight = rackBodyHeight / unitCount;
const centerX = rackBBox.x + (rackBBox.width - nodeSize.width) / 2;
const relativeY = pos.y - rackBBox.y - headerHeight;
const snapY = rackBBox.y + headerHeight + Math.round(relativeY / unitHeight) * unitHeight;
node.setPosition(centerX, snapY, { silent: true });
}
});
// 2. Parent-Child Relationship Update on Move End
state.graph.on('node:moved', ({ node }) => {
if (node.getData()?.is_group) return;
let parent = node.getParent();
if (!parent) {
const pos = node.getPosition();
const center = { x: pos.x + node.size().width / 2, y: pos.y + node.size().height / 2 };
parent = state.graph.getNodes().find(n => {
if (n.id === node.id || !n.getData()?.is_group) return false;
const bbox = n.getBBox();
return bbox.containsPoint(center);
});
}
if (parent && node.getParent()?.id !== parent.id) {
parent.addChild(node);
}
});
}
@@ -0,0 +1,120 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
/**
* excel_exporter.js - Exports topology data to a structured CSV format compatible with Excel.
* Includes all metadata (Parent Group, Tags, Description) for both Nodes and Edges.
*/
export function exportToExcel() {
if (!state.graph) return;
const nodes = state.graph.getNodes();
const edges = state.graph.getEdges();
if (nodes.length === 0 && edges.length === 0) {
alert(t('pptx_no_data') || 'No data to export.');
return;
}
// Header Definition (Unified for mapping ease)
const headers = [
"Category",
"ID",
"Label/Name",
"Type",
"Parent Group",
"Source (ID/Label)",
"Target (ID/Label)",
"Tags",
"Description",
"IP Address",
"Vendor",
"Model",
"Asset Tag"
];
const csvRows = [];
// Add Nodes
nodes.forEach(node => {
const d = node.getData() || {};
const label = node.attr('label/text') || d.label || '';
// Find Parent Group Name
let parentName = '';
const parent = node.getParent();
if (parent && parent.isNode()) {
parentName = parent.attr('label/text') || parent.getData()?.label || parent.id;
}
csvRows.push([
"Node",
node.id,
label,
d.type || '',
parentName,
"", // Source
"", // Target
(d.tags || []).join('; '),
d.description || '',
d.ip || '',
d.vendor || '',
d.model || '',
d.asset_tag || ''
]);
});
// Add Edges
edges.forEach(edge => {
const d = edge.getData() || {};
const label = (edge.getLabels()[0]?.attrs?.label?.text) || d.label || '';
const sourceCell = edge.getSourceCell();
const targetCell = edge.getTargetCell();
const sourceStr = sourceCell ? (sourceCell.attr('label/text') || sourceCell.id) : (edge.getSource().id || 'Unknown');
const targetStr = targetCell ? (targetCell.attr('label/text') || targetCell.id) : (edge.getTarget().id || 'Unknown');
csvRows.push([
"Edge",
edge.id,
label,
d.type || 'Connection',
"", // Parent Group (Edges usually not grouped in same way)
sourceStr,
targetStr,
(d.tags || []).join('; '),
d.description || '',
"", // IP
"", // Vendor
"", // Model
d.asset_tag || ''
]);
});
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_start').replace('{format}', 'Excel'), 'info', 2000));
// Generate CSV Content with UTF-8 BOM
const csvContent = "\uFEFF" + // UTF-8 BOM for Excel (Required for Korean/Special chars)
headers.join(',') + "\n" +
csvRows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
// Trigger Download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const projectName = document.getElementById('project-title')?.innerText || "Project";
link.setAttribute("href", url);
link.setAttribute("download", `drawNET_Inventory_${projectName}_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_success').replace('{format}', 'Excel'), 'success'));
logger.high(`Inventory exported for ${nodes.length} nodes and ${edges.length} edges.`);
}
@@ -0,0 +1,259 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
import { showToast } from '../../ui/toast.js';
/**
* 전역 내보내기 로딩 오버레이 표시/숨김
*/
function showLoading() {
const overlay = document.createElement('div');
overlay.id = 'export-loading-overlay';
overlay.className = 'export-overlay';
overlay.innerHTML = `
<div class="export-spinner"></div>
<div class="export-text">${t('export_preparing')}</div>
`;
document.body.appendChild(overlay);
}
function hideLoading() {
const overlay = document.getElementById('export-loading-overlay');
if (overlay) overlay.remove();
}
/**
* 이미지를 Base64 Data URL로 변환 (캐시 및 재시도 적용)
*/
const assetCache = new Map();
async function toDataURL(url, retries = 2) {
if (!url || typeof url !== 'string') return url;
if (assetCache.has(url)) return assetCache.get(url);
const origin = window.location.origin;
const absoluteUrl = url.startsWith('http') ? url :
(url.startsWith('/') ? `${origin}${url}` : `${origin}/${url}`);
for (let i = 0; i <= retries; i++) {
try {
const response = await fetch(absoluteUrl);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
assetCache.set(url, dataUrl);
return dataUrl;
} catch (e) {
if (i === retries) {
logger.critical(`Base64 conversion failed after ${retries} retries for: ${url}`, e);
return url;
}
await new Promise(r => setTimeout(r, 100 * (i + 1))); // 지수 백오프 대기
}
}
}
/**
* 내보내기 전 모든 노드의 이미지 속성을 Base64로 일괄 변환 (상속된 속성 포함)
*/
export async function prepareExport() {
if (!state.graph) return null;
const backupNodes = [];
const nodes = state.graph.getNodes();
const promises = nodes.map(async (node) => {
const imageAttrs = [];
const attrs = node.getAttrs();
// 1. 노드의 모든 속성을 재귀적으로 탐색하여 이미지 경로처럼 보이는 문자열 식별
const selectors = Object.keys(attrs);
// 기본 'image' 선택자 외에도 커스텀 선택자가 있을 수 있으므로 모두 확인
const targetSelectors = new Set(['image', 'icon', 'body', ...selectors]);
for (const selector of targetSelectors) {
// xlink:href와 href 두 가지 모두 명시적으로 점검
const keys = ['xlink:href', 'href', 'src'];
for (const key of keys) {
const path = `${selector}/${key}`;
const val = node.attr(path);
if (val && typeof val === 'string' && !val.startsWith('data:')) {
const trimmedVal = val.trim();
if (trimmedVal.includes('.') || trimmedVal.startsWith('/') || trimmedVal.startsWith('http')) {
const base64Data = await toDataURL(trimmedVal);
if (base64Data && base64Data !== trimmedVal) {
imageAttrs.push({ path, originalValue: val });
// 중요: 한 쪽에서만 발견되어도 호환성을 위해 두 가지 속성 모두 업데이트
const pairKey = key === 'xlink:href' ? 'href' : (key === 'href' ? 'xlink:href' : null);
node.attr(path, base64Data);
if (pairKey) {
const pairPath = `${selector}/${pairKey}`;
imageAttrs.push({ path: pairPath, originalValue: node.attr(pairPath) });
node.attr(pairPath, base64Data);
}
}
}
}
}
}
if (imageAttrs.length > 0) {
backupNodes.push({ node, imageAttrs });
}
});
await Promise.all(promises);
// PC 사양에 따른 DOM 동기화 안정성을 위해 1초 대기 (사용자 요청 반영)
await new Promise(resolve => setTimeout(resolve, 1000));
return backupNodes;
}
/**
* 내보내기 후 원래 속성으로 복구
*/
export function cleanupExport(backup) {
if (!backup) return;
try {
backup.forEach(item => {
item.imageAttrs.forEach(attr => {
item.node.attr(attr.path, attr.originalValue);
});
});
} catch (e) {
console.error("Cleanup export failed", e);
}
}
/**
* exportToPNG - PNG 이미지 내보내기
*/
export async function exportToPNG() {
if (!state.graph) return;
showLoading();
showToast(t('msg_export_start').replace('{format}', 'PNG'), 'info', 2000);
const backup = await prepareExport();
try {
const bbox = state.graph.getCellsBBox(state.graph.getCells());
const padding = 100;
state.graph.toPNG((dataUri) => {
const link = document.createElement('a');
link.download = `drawNET_Topology_${Date.now()}.png`;
link.href = dataUri;
link.click();
cleanupExport(backup);
hideLoading();
showToast(t('msg_export_success').replace('{format}', 'PNG'), 'success');
logger.high("Exported to PNG successfully.");
}, {
padding,
backgroundColor: '#ffffff',
width: (bbox.width + padding * 2) * 2,
height: (bbox.height + padding * 2) * 2,
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
});
} catch (err) {
if (backup) cleanupExport(backup);
hideLoading();
showToast(t('msg_export_failed').replace('{format}', 'PNG'), 'error');
logger.critical("PNG Export failed", err);
}
}
/**
* exportToSVG - SVG 벡터 내보내기
*/
export async function exportToSVG() {
if (!state.graph) return;
showLoading();
const backup = await prepareExport();
try {
// 실제 셀들의 정확한 좌표 영역 계산 (줌/이동 무관한 절대 영역)
const bbox = state.graph.getCellsBBox(state.graph.getCells());
const padding = 120;
// 여백을 포함한 최종 영역 설정
const exportArea = {
x: bbox.x - padding,
y: bbox.y - padding,
width: bbox.width + padding * 2,
height: bbox.height + padding * 2
};
state.graph.toSVG((svg) => {
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `drawNET_Topology_${Date.now()}.svg`;
link.href = url;
link.click();
cleanupExport(backup);
hideLoading();
showToast(t('msg_export_success').replace('{format}', 'SVG'), 'success');
URL.revokeObjectURL(url);
logger.high("Exported to SVG successfully.");
}, {
// 명시적 viewBox 지정으로 짤림 방지
viewBox: exportArea,
width: exportArea.width,
height: exportArea.height,
backgroundColor: '#ffffff',
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
});
} catch (err) {
if (backup) cleanupExport(backup);
hideLoading();
showToast(t('msg_export_failed').replace('{format}', 'SVG'), 'error');
logger.critical("SVG Export failed", err);
}
}
/**
* exportToPDF - PDF 문서 내보내기
*/
export async function exportToPDF() {
if (!state.graph) return;
showLoading();
const backup = await prepareExport();
try {
const { jsPDF } = window.jspdf;
const bbox = state.graph.getCellsBBox(state.graph.getCells());
const padding = 120;
const totalW = bbox.width + padding * 2;
const totalH = bbox.height + padding * 2;
state.graph.toPNG((dataUri) => {
const orientation = totalW > totalH ? 'l' : 'p';
const pdf = new jsPDF(orientation, 'px', [totalW, totalH]);
pdf.addImage(dataUri, 'PNG', padding, padding, bbox.width, bbox.height);
pdf.save(`drawNET_Topology_${Date.now()}.pdf`);
cleanupExport(backup);
hideLoading();
showToast(t('msg_export_success').replace('{format}', 'PDF'), 'success');
logger.high("Exported to PDF successfully.");
}, {
padding,
backgroundColor: '#ffffff',
width: totalW * 2,
height: totalH * 2,
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
});
} catch (err) {
if (backup) cleanupExport(backup);
hideLoading();
showToast(t('msg_export_failed').replace('{format}', 'PDF'), 'error');
logger.critical("PDF Export failed", err);
}
}
+116
View File
@@ -0,0 +1,116 @@
import { state } from '../../state.js';
import { getProjectData, restoreProjectData } from './json_handler.js';
import { exportToPPTX } from './pptx_exporter.js';
import { logger } from '../../utils/logger.js';
/**
* initGraphIO - 그래프 관련 I/O 이벤트 리스너 초기화
*/
export function initGraphIO() {
// Export JSON (.dnet)
const exportBtn = document.getElementById('export-json');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
const projectData = getProjectData();
if (!projectData) return;
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(projectData, null, 2));
const safeName = (state.projectName || "project").replace(/[^a-z0-9]/gi, '_').toLowerCase();
const dlAnchorElem = document.createElement('a');
dlAnchorElem.setAttribute("href", dataStr);
dlAnchorElem.setAttribute("download", `${safeName}_${Date.now()}.dnet`);
dlAnchorElem.click();
logger.high("Project exported (.dnet v3.0).");
});
}
// Export PPTX
const exportPptxBtn = document.getElementById('export-pptx');
if (exportPptxBtn) {
exportPptxBtn.addEventListener('click', () => {
if (state.license.level === 'Trial') return;
exportToPPTX();
});
}
// Export Excel
const exportExcelBtn = document.getElementById('export-excel');
if (exportExcelBtn) {
exportExcelBtn.addEventListener('click', () => {
if (state.license.level === 'Trial') return;
import('./excel_exporter.js').then(m => m.exportToExcel());
});
}
// Export PNG (Allowed for Trial)
const exportPngBtn = document.getElementById('export-png');
if (exportPngBtn) {
import('./image_exporter.js').then(m => {
exportPngBtn.addEventListener('click', () => m.exportToPNG());
});
}
// Export SVG
const exportSvgBtn = document.getElementById('export-svg');
if (exportSvgBtn) {
import('./image_exporter.js').then(m => {
exportSvgBtn.addEventListener('click', () => {
if (state.license.level === 'Trial') return;
m.exportToSVG();
});
});
}
// Export PDF (Allowed for Trial)
const exportPdfBtn = document.getElementById('export-pdf');
if (exportPdfBtn) {
import('./image_exporter.js').then(m => {
exportPdfBtn.addEventListener('click', () => m.exportToPDF());
});
}
// Import
const importInput = document.getElementById('import-json-input');
if (importInput) {
importInput.setAttribute('accept', '.dnet,.json');
}
const importBtn = document.getElementById('import-json');
if (importBtn && importInput) {
importBtn.addEventListener('click', () => importInput.click());
importInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const data = JSON.parse(event.target.result);
if (!state.graph) return;
const isDrawNET = data.header?.app === "drawNET Premium";
if (!isDrawNET) {
alert("지원하지 않는 파일 형식입니다.");
return;
}
if (data.graphJson) {
const ok = restoreProjectData(data);
if (ok) {
logger.high("Import complete (v3.0 JSON).");
}
} else {
alert("이 파일은 구버전(v2.x) 형식입니다. 현재 버전에서는 지원되지 않습니다.\n새로 작성 후 저장해 주세요.");
}
} catch (err) {
logger.critical("Failed to parse project file.", err);
alert("프로젝트 파일을 읽을 수 없습니다.");
}
};
reader.readAsText(file);
e.target.value = '';
});
}
}
// Re-export for compatibility
export { getProjectData, restoreProjectData, exportToPPTX };
+153
View File
@@ -0,0 +1,153 @@
/**
* graph/io/json_handler.js - Project serialization and restoration with auto-repair logic.
*/
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* getProjectData - 프로젝트 전체를 JSON으로 직렬화 (graph.toJSON 기반)
*/
export function getProjectData() {
if (!state.graph) return null;
const graphJson = state.graph.toJSON();
const viewport = state.graph.translate();
return {
header: {
app: "drawNET Premium",
version: "3.0.0",
export_time: new Date().toISOString(),
projectName: state.projectName || "Untitled_Project"
},
graphJson: graphJson,
viewport: {
zoom: state.graph.zoom(),
tx: viewport.tx,
ty: viewport.ty
},
settings: {
gridSpacing: state.gridSpacing,
gridStyle: state.gridStyle,
isSnapEnabled: state.isSnapEnabled,
canvasSize: state.canvasSize,
theme: document.documentElement.getAttribute('data-theme')
},
layers: state.layers,
activeLayerId: state.activeLayerId
};
}
/**
* restoreProjectData - JSON 프로젝트 데이터로 그래프 복원
*/
export function restoreProjectData(data) {
if (!state.graph || !data) return false;
try {
if (data.graphJson) {
state.graph.fromJSON(data.graphJson);
// Migrate old data to new schema (Add defaults if missing)
state.graph.getCells().forEach(cell => {
const cellData = cell.getData() || {};
let updated = false;
// 1. Recover missing position/size for nodes from data.pos
if (cell.isNode()) {
const pos = cell.getPosition();
if ((!pos || (pos.x === 0 && pos.y === 0)) && cellData.pos) {
cell.setPosition(cellData.pos.x, cellData.pos.y);
logger.info(`Recovered position for ${cell.id} from data.pos`);
updated = true;
}
const size = cell.getSize();
if (!size || (size.width === 0 && size.height === 0)) {
if (cellData.is_group) cell.setSize(200, 200);
else cell.setSize(60, 60);
updated = true;
}
}
// 2. Ensure description exists for all objects
if (cellData.description === undefined) {
cellData.description = "";
updated = true;
}
// 3. Ensure routing_offset exists for edges
if (cell.isEdge()) {
if (cellData.routing_offset === undefined) {
cellData.routing_offset = 20;
updated = true;
}
// [6개월 뒤의 나를 위한 메모]
// 하위 호환성 유지 및 데이터 무결성 강화를 위한 자가 치유(Self-healing) 로직.
// 기존 도면을 불러올 때 누락된 크로스 레이어 정보를 노드 데이터를 참조하여 실시간 복구함.
if (cellData.source_layer === undefined || cellData.target_layer === undefined) {
const srcNode = state.graph.getCellById(cellData.source);
const dstNode = state.graph.getCellById(cellData.target);
// Default to current edge's layer if node is missing (safe fallback)
const srcLayer = srcNode?.getData()?.layerId || cellData.layerId || 'l1';
const dstLayer = dstNode?.getData()?.layerId || cellData.layerId || 'l1';
cellData.source_layer = srcLayer;
cellData.target_layer = dstLayer;
cellData.is_cross_layer = (srcLayer !== dstLayer);
updated = true;
logger.info(`Auto-repaired layer metadata for edge ${cell.id}`);
}
}
// 4. Ensure label consistency (data vs attrs)
const pureLabel = cellData.label || "";
const visualLabel = cell.attr('label/text');
if (visualLabel !== pureLabel) {
cell.attr('label/text', pureLabel);
updated = true;
}
if (updated) {
cell.setData(cellData, { silent: true });
}
});
}
const vp = data.viewport || {};
if (vp.zoom !== undefined) state.graph.zoomTo(vp.zoom);
if (vp.tx !== undefined) state.graph.translate(vp.tx, vp.ty || 0);
const settings = data.settings || {};
if (settings.gridSpacing) {
window.dispatchEvent(new CustomEvent('gridSpacingChanged', { detail: { spacing: settings.gridSpacing } }));
}
if (settings.gridStyle) {
window.dispatchEvent(new CustomEvent('gridChanged', { detail: { style: settings.gridStyle } }));
}
if (settings.theme) {
document.documentElement.setAttribute('data-theme', settings.theme);
}
// Restore Layers
if (data.layers && Array.isArray(data.layers)) {
state.layers = data.layers;
state.activeLayerId = data.activeLayerId || data.layers[0]?.id;
}
// Restore Metadata
if (data.header?.projectName) {
state.projectName = data.header.projectName;
const titleEl = document.getElementById('project-title');
if (titleEl) titleEl.innerText = state.projectName;
}
state.graph.trigger('project:restored');
return true;
} catch (e) {
logger.critical("Failed to restore project data.", e);
return false;
}
}
+238
View File
@@ -0,0 +1,238 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
import { applyLayerFilters } from '../layers.js';
import { prepareExport, cleanupExport } from './image_exporter.js';
/**
* PPTX로 내보내기 (PptxGenJS 활용)
* 단순 캡처가 아닌 개별 이미지 배치 및 선 객체 생성을 통한 고도화된 리포트 생성
*/
export async function exportToPPTX() {
if (!state.graph) return;
if (typeof PptxGenJS === 'undefined') {
alert(t('pptx_lib_error'));
return;
}
try {
const pptx = new PptxGenJS();
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_start').replace('{format}', 'PPTX'), 'info', 3000));
pptx.layout = 'LAYOUT_16x9';
const projectTitle = document.getElementById('project-title')?.innerText || t('untitled_project');
const allCells = state.graph.getCells();
if (allCells.length === 0) {
alert(t('pptx_no_data'));
return;
}
// --- 1. 슬라이드 1: 요약 정보 ---
const summarySlide = pptx.addSlide();
summarySlide.addText(t('pptx_report_title').replace('{title}', projectTitle), {
x: 0.5, y: 0.3, w: '90%', h: 0.6, fontSize: 24, color: '363636', bold: true
});
const stats = {};
allCells.filter(c => c.isNode()).forEach(node => {
const type = node.getData()?.type || 'Unknown';
stats[type] = (stats[type] || 0) + 1;
});
const summaryRows = [[t('pptx_obj_type'), t('pptx_quantity')]];
Object.entries(stats).sort().forEach(([type, count]) => {
summaryRows.push([type, count.toString()]);
});
summarySlide.addTable(summaryRows, {
x: 0.5, y: 1.2, w: 4.0, colW: [2.5, 1.5],
border: { pt: 1, color: 'E2E8F0' }, fill: { color: 'F8FAFC' },
fontSize: 11, align: 'center'
});
summarySlide.addText(t('pptx_generated_at').replace('{date}', new Date().toLocaleString()), {
x: 6.0, y: 5.2, w: 3.5, h: 0.3, fontSize: 9, color: '999999', align: 'right'
});
// --- 좌표 변환 헬퍼 (Inches) ---
const MARGIN = 0.5;
const SLIDE_W = 10 - (MARGIN * 2);
const SLIDE_H = 5.625 - (MARGIN * 2);
const addGraphToSlide = (slide, cells, title) => {
if (cells.length === 0) return;
if (title) {
slide.addText(title, { x: 0.5, y: 0.2, w: 5.0, h: 0.4, fontSize: 14, bold: true, color: '4B5563' });
}
// High-Fidelity Hybrid: Capture topology as PNG
// Since toPNG is asynchronous with a callback, we use a different approach for batching or just use the current canvas state
// But for PPTX report, we want to ensure it's high quality.
// X6 toPNG is usually sync if no external images, but here we have SVGs.
// To keep it simple and stable, we'll use a placeholder or wait for the user to confirm.
// Actually, we can use the plugin's sync return if possible, or just export the whole thing.
return new Promise(async (resolve) => {
const backup = await prepareExport();
const bbox = state.graph.getContentBBox();
const ratio = bbox.width / bbox.height;
const maxW = 8.0;
const maxH = 4.5;
let imgW = maxW;
let imgH = imgW / ratio;
if (imgH > maxH) {
imgH = maxH;
imgW = imgH * ratio;
}
const imgX = 1.0 + (maxW - imgW) / 2;
const imgY = 0.8 + (maxH - imgH) / 2;
state.graph.toPNG((dataUri) => {
slide.addImage({ data: dataUri, x: imgX, y: imgY, w: imgW, h: imgH });
cleanupExport(backup);
resolve();
}, {
padding: 10,
backgroundColor: '#ffffff',
width: bbox.width * 2,
height: bbox.height * 2,
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
});
});
};
// --- 2. 슬라이드 2: 전체 조감도 ---
const mainDiagSlide = pptx.addSlide();
// Backup visibility
const originalVisibility = state.layers.map(l => ({ id: l.id, visible: l.visible }));
// Show everything for overall view
state.layers.forEach(l => l.visible = true);
await applyLayerFilters();
await addGraphToSlide(mainDiagSlide, allCells, t('pptx_overall_topo'));
// --- 3. 슬라이드 3~: 레이어별 상세도 ---
if (state.layers && state.layers.length > 1) {
for (const layer of state.layers) {
// Only show this layer
state.layers.forEach(l => l.visible = (l.id === layer.id));
await applyLayerFilters();
const layerCells = allCells.filter(c => (c.getData()?.layerId || 'l1') === layer.id);
if (layerCells.length > 0) {
const layerSlide = pptx.addSlide();
await addGraphToSlide(layerSlide, layerCells, t('pptx_layer_title').replace('{name}', layer.name));
}
}
}
// Restore original visibility
originalVisibility.forEach(orig => {
const l = state.layers.find(ly => ly.id === orig.id);
if (l) l.visible = orig.visible;
});
await applyLayerFilters();
// --- 4. 슬라이드 마지막: 상세 인벤토리 테이블 ---
const nodes = allCells.filter(c => c.isNode());
if (nodes.length > 0) {
const tableSlide = pptx.addSlide();
tableSlide.addText(t('pptx_inv_title'), { x: 0.5, y: 0.3, w: 5.0, h: 0.5, fontSize: 18, bold: true });
const tableRows = [[t('prop_label'), t('prop_type'), t('prop_parent'), t('prop_model'), t('prop_ip'), t('prop_status')]];
nodes.forEach(node => {
const d = node.getData() || {};
const parent = node.getParent();
const parentLabel = parent ? (parent.getData()?.label || parent.attr('label/text') || parent.id) : '-';
tableRows.push([
d.label || node.attr('label/text') || '',
d.type || '',
parentLabel,
d.model || '',
d.ip || '',
d.status || ''
]);
});
tableSlide.addTable(tableRows, {
x: 0.5, y: 1.0, w: 9.0,
colW: [1.5, 1.2, 1.5, 1.6, 1.6, 1.6],
border: { pt: 0.5, color: 'CCCCCC' },
fontSize: 8,
autoPage: true
});
}
// --- 5. 슬라이드: 객체별 상세 설명 (Label & Description) ---
const descObjects = allCells.filter(c => {
const d = c.getData() || {};
if (c.isNode()) {
// Include all standard nodes, but only include groups if they have a description
return !d.is_group || !!d.description;
}
// Include edges only if they have a user-defined label or description
return !!(d.label || d.description);
});
if (descObjects.length > 0) {
const descSlide = pptx.addSlide();
descSlide.addText(t('pptx_desc_table_title'), {
x: 0.5, y: 0.3, w: 8.0, h: 0.5, fontSize: 18, bold: true, color: '2D3748'
});
const descRows = [[t('prop_label'), t('prop_type'), t('prop_tags'), t('prop_description')]];
descObjects.forEach(cell => {
const d = cell.getData() || {};
let label = '';
if (cell.isNode()) {
label = d.label || cell.attr('label/text') || `Node (${cell.id.substring(0,8)})`;
} else {
// Optimized Edge Label: Use d.label if available, else Source -> Target name
if (d.label) {
label = d.label;
} else {
const src = state.graph.getCellById(cell.getSource().cell);
const dst = state.graph.getCellById(cell.getTarget().cell);
const srcName = src?.getData()?.label || src?.attr('label/text') || 'Unknown';
const dstName = dst?.getData()?.label || dst?.attr('label/text') || 'Unknown';
label = `${srcName} -> ${dstName}`;
}
}
const type = cell.isNode() ? (d.type || 'Node') : `Edge (${d.routing || 'manhattan'})`;
const tags = (d.tags || []).join(', ');
descRows.push([
label,
type,
tags || '-',
d.description || '-'
]);
});
descSlide.addTable(descRows, {
x: 0.5, y: 1.0, w: 9.0,
colW: [1.5, 1.2, 1.8, 4.5],
border: { pt: 0.5, color: 'CCCCCC' },
fill: { color: 'FFFFFF' },
fontSize: 9,
autoPage: true,
newSlideProps: { margin: [0.5, 0.5, 0.5, 0.5] }
});
}
pptx.writeFile({ fileName: `drawNET_Report_${Date.now()}.pptx` });
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_success').replace('{format}', 'PPTX'), 'success'));
} catch (err) {
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_failed').replace('{format}', 'PPTX'), 'error'));
logger.critical("PPTX export failed.", err);
alert(t('pptx_export_error').replace('{message}', err.message));
}
}
+82
View File
@@ -0,0 +1,82 @@
import { state } from '../state.js';
/**
* calculateCellZIndex - Logic to determine the Z-Index of a cell based on its layer and type.
* Hierarchy: Nodes(50) > Edges(30) > Groups(1).
*/
export function calculateCellZIndex(data, ancestorsCount, isNode) {
const layerId = data.layerId || 'l1';
const layerIndex = state.layers.findIndex(l => l.id === layerId);
const layerOffset = layerIndex !== -1 ? (state.layers.length - layerIndex) * 100 : 0;
let baseZ = 0;
if (isNode) {
if (data.is_group) {
baseZ = 1 + ancestorsCount;
} else {
baseZ = 50 + ancestorsCount;
}
} else {
baseZ = 30; // Edges: Always above groups
}
return layerOffset + baseZ;
}
/**
* applyLayerFilters - Updates the visibility of all cells in the graph based on their layer.
* Also enforces a strict Z-Index hierarchy: Nodes(50) > Edges(30) > Groups(1).
* Auto-Repairs existing edges to the latest styling standards.
*/
export async function applyLayerFilters() {
if (!state.graph) return;
// Pre-import style mapping for efficiency
const { getX6EdgeConfig, getLabelAttributes } = await import('./styles.js');
const cells = state.graph.getCells();
const visibleLayerIds = new Set(
state.layers
.filter(l => l.visible)
.map(l => l.id)
);
state.graph.batchUpdate(() => {
cells.forEach(cell => {
const data = cell.getData() || {};
const cellLayerId = data.layerId || 'l1';
const isActive = (cellLayerId === state.activeLayerId);
const ancestorsCount = cell.getAncestors().length;
const zIndex = calculateCellZIndex(data, ancestorsCount, cell.isNode());
cell.setZIndex(zIndex);
const isVisible = visibleLayerIds.has(cellLayerId);
if (isVisible) {
cell.show();
const opacity = isActive ? 1 : (state.inactiveLayerOpacity || 0.3);
cell.attr('body/opacity', opacity);
cell.attr('image/opacity', opacity);
cell.attr('label/opacity', opacity);
cell.attr('line/opacity', opacity);
// Re-enable pointer events to allow "Ghost Snapping" (Connecting to other layers)
// We rely on Selection Plugin Filter and interacting rules for isolation.
cell.attr('root/style/pointer-events', 'auto');
const layerIndex = state.layers.findIndex(l => l.id === cellLayerId);
const isTrialLocked = state.license.level === 'Trial' && layerIndex >= 3;
const isManuallyLocked = data.locked === true || isTrialLocked;
const shouldBeInteractable = isActive && !isManuallyLocked;
cell.setProp('movable', shouldBeInteractable);
cell.setProp('deletable', shouldBeInteractable);
if (cell.isNode()) cell.setProp('rotatable', shouldBeInteractable);
} else {
cell.hide();
}
});
});
}
+67
View File
@@ -0,0 +1,67 @@
/**
* graph/plugins.js - X6 Plugin initialization
*/
export function initGraphPlugins(graph) {
if (window.X6PluginSelection) {
graph.use(new window.X6PluginSelection.Selection({
enabled: true,
multiple: true,
modifiers: 'shift',
rubberband: true,
showNodeSelectionBox: true,
showEdgeSelectionBox: false,
pointerEvents: 'none',
filter: (cell) => {
const data = cell.getData() || {};
const cellLayerId = data.layerId || 'l1';
return cellLayerId === state.activeLayerId;
}
}));
}
if (window.X6PluginSnapline) {
graph.use(new window.X6PluginSnapline.Snapline({
enabled: true,
sharp: true,
}));
}
/* Disable X6 Keyboard plugin to prevent interception of global hotkeys */
/*
if (window.X6PluginKeyboard) {
graph.use(new window.X6PluginKeyboard.Keyboard({
enabled: true,
global: false,
}));
}
*/
if (window.X6PluginClipboard) {
graph.use(new window.X6PluginClipboard.Clipboard({
enabled: true,
}));
}
if (window.X6PluginHistory) {
graph.use(new window.X6PluginHistory.History({
enabled: true,
}));
}
if (window.X6PluginTransform) {
graph.use(new window.X6PluginTransform.Transform({
resizing: { enabled: true, orthographic: false },
rotating: { enabled: true },
// 선(Edge) 제외 및 현재 활성 레이어만 허용
filter: (cell) => {
if (!cell.isNode()) return false;
const data = cell.getData() || {};
return (data.layerId || 'l1') === state.activeLayerId;
},
}));
}
if (window.X6PluginExport) {
graph.use(new window.X6PluginExport.Export());
}
}
+99
View File
@@ -0,0 +1,99 @@
import { state } from '../state.js';
/**
* initGlobalSearch - Initializes the canvas-wide search and highlight functionality
*/
export function initGlobalSearch() {
const searchInput = document.getElementById('global-search');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
if (!state.graph) return;
const allNodes = state.graph.getNodes();
if (query === '') {
allNodes.forEach(node => {
node.attr('body/opacity', 1);
node.attr('image/opacity', 1);
node.attr('label/opacity', 1);
node.removeTools();
});
return;
}
allNodes.forEach(node => {
const data = node.getData() || {};
const label = (node.attr('label/text') || '').toLowerCase();
const id = node.id.toLowerCase();
// PM 전용 표준 속성 검색 대상 포함
const searchFields = [
id, label,
(data.type || '').toLowerCase(),
(data.ip || '').toLowerCase(),
(data.model || '').toLowerCase(),
(data.vendor || '').toLowerCase(),
(data.asset_tag || '').toLowerCase(),
(data.serial || '').toLowerCase(),
(data.project || '').toLowerCase(),
(data.tags || []).join(' ').toLowerCase()
];
const isMatch = searchFields.some(field => field.includes(query));
if (isMatch) {
node.attr('body/opacity', 1);
node.attr('image/opacity', 1);
node.attr('label/opacity', 1);
// 검색 강조 툴 추가
node.addTools({
name: 'boundary',
args: {
padding: 5,
attrs: {
fill: 'none',
stroke: '#3b82f6',
strokeWidth: 3,
strokeDasharray: '0',
},
},
});
} else {
node.attr('body/opacity', 0.1);
node.attr('image/opacity', 0.1);
node.attr('label/opacity', 0.1);
node.removeTools();
}
});
});
// 엔터 키 입력 시 검색된 첫 번째 항목으로 포커싱
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const query = e.target.value.toLowerCase().trim();
if (!query || !state.graph) return;
const matches = state.graph.getNodes().filter(node => {
const data = node.getData() || {};
const label = (node.attr('label/text') || '').toLowerCase();
const searchFields = [
node.id.toLowerCase(), label,
(data.ip || ''), (data.asset_tag || ''), (data.model || '')
];
return searchFields.some(f => f.toLowerCase().includes(query));
});
if (matches.length > 0) {
// 첫 번째 매치된 항목으로 줌인
state.graph.zoomToCell(matches[0], {
padding: 100,
animation: { duration: 600 }
});
state.graph.select(matches[0]);
}
}
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* graph/styles.js - Main Entry for Graph Styling (Plugin-based)
*/
import { shapeRegistry } from './styles/registry.js';
import { registerNodes } from './styles/node_shapes.js';
import { getX6NodeConfig, getX6EdgeConfig } from './styles/mapping/index.js';
import { getLabelAttributes } from './styles/utils.js';
/**
* registerCustomShapes - Initializes and registers all shape plugins
*/
export function registerCustomShapes() {
// 1. Initialize Registry (Load plugins)
registerNodes();
// 2. Install to X6
if (typeof X6 !== 'undefined') {
shapeRegistry.install(X6);
}
}
/**
* Re-exporting functions for compatibility with other modules
*/
export {
getX6NodeConfig,
getX6EdgeConfig,
getLabelAttributes,
shapeRegistry
};
// Default Theme Update
export function updateGraphTheme() {}
+52
View File
@@ -0,0 +1,52 @@
export function getEdgeConfig(data) {
let routingType = data.routing || 'manhattan';
if (routingType === 'u-shape') routingType = 'manhattan-u';
const srcAnchor = (data.routing === 'u-shape') ? 'top' : (data.source_anchor || 'orth');
const dstAnchor = (data.routing === 'u-shape') ? 'top' : (data.target_anchor || 'orth');
const routerArgs = {
step: 5, // Finer step for better pathfinding
padding: 10, // Reduced padding to avoid "unreachable" errors in tight spaces
};
const directions = ['top', 'bottom', 'left', 'right'];
if (directions.includes(srcAnchor)) routerArgs.startDirections = [srcAnchor];
if (directions.includes(dstAnchor)) routerArgs.endDirections = [dstAnchor];
// For U-shape, we need a bit more padding but not too much to fail
if (data.routing === 'u-shape') routerArgs.padding = 15;
const routerConfigs = {
manhattan: { name: 'manhattan', args: routerArgs },
'manhattan-u': { name: 'manhattan', args: routerArgs },
orthogonal: { name: 'orth' },
straight: null,
metro: { name: 'metro' }
};
return {
id: data.id || `e_${data.source}_${data.target}`,
source: { cell: data.source, anchor: srcAnchor, connectionPoint: 'boundary' },
target: { cell: data.target, anchor: dstAnchor, connectionPoint: 'boundary' },
router: routerConfigs[routingType] || routerConfigs.manhattan,
connector: { name: 'jumpover', args: { type: 'arc', size: 5, radius: 12 } },
attrs: {
line: {
stroke: data.is_tunnel ? (data.color || '#ef4444') : (data.color || '#94a3b8'),
strokeWidth: data.is_tunnel ? 2 : (data.width || 2),
strokeDasharray: data.flow ? '5,5' : (data.is_tunnel ? '6,3' : (data.style === 'dashed' ? '5,5' : '0')),
targetMarker: (data.direction === 'forward' || data.direction === 'both') ? 'block' : null,
sourceMarker: (data.direction === 'backward' || data.direction === 'both') ? 'block' : null,
class: data.flow ? 'flow-animation' : '',
}
},
labels: data.is_tunnel ? [{
attrs: {
text: { text: 'VPN', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' },
rect: { fill: '#ffffff', rx: 4, ry: 4 }
}
}] : [],
data: data
};
}
@@ -0,0 +1,208 @@
import { state } from '../../../state.js';
import { logger } from '../../../utils/logger.js';
import { calculateCellZIndex } from '../../layers.js';
/**
* getX6EdgeConfig - Maps edge data to X6 connecting properties
*/
export function getX6EdgeConfig(data) {
if (!data || !data.source || !data.target) {
return {
source: data?.source,
target: data?.target,
router: null,
attrs: {
line: {
stroke: '#94a3b8',
strokeWidth: 2,
targetMarker: 'block'
}
}
};
}
// 1. Resolve Routing Type (Explicit choice > Adaptive)
let routingType = data.routing || 'manhattan';
if (routingType === 'u-shape') routingType = 'manhattan-u';
// Adaptive Routing: Only force straight if NO explicit choice was made or if it's currently manhattan
if (state.graph && (routingType === 'manhattan' || !data.routing)) {
const srcNode = state.graph.getCellById(data.source);
const dstNode = state.graph.getCellById(data.target);
if (srcNode && dstNode && srcNode.isNode() && dstNode.isNode()) {
const b1 = srcNode.getBBox();
const b2 = dstNode.getBBox();
const p1 = { x: b1.x + b1.width / 2, y: b1.y + b1.height / 2 };
const p2 = { x: b2.x + b2.width / 2, y: b2.y + b2.height / 2 };
const dist = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
if (dist < 100) {
routingType = 'straight';
}
}
}
const isStraight = (routingType === 'straight');
// 2. Resolve Anchors & Router Args
const srcAnchor = (data.routing === 'u-shape') ? 'top' : (data.source_anchor || 'orth');
const dstAnchor = (data.routing === 'u-shape') ? 'top' : (data.target_anchor || 'orth');
// Manhattan Router Args (Enhanced for topological diagrams)
const routerArgs = {
padding: 10,
step: 5, // [TWEAK] Increase precision (10 -> 5) to reduce redundant bends
offset: data.routing_offset || 20,
exclude(cell) {
return cell && typeof cell.getData === 'function' && cell.getData()?.is_group;
}
};
// Sync Manhattan directions with manual anchors to force the router to respect the side
const validSides = ['top', 'bottom', 'left', 'right'];
if (srcAnchor && validSides.includes(srcAnchor)) {
routerArgs.startDirections = [srcAnchor];
}
if (dstAnchor && validSides.includes(dstAnchor)) {
routerArgs.endDirections = [dstAnchor];
}
if (routingType === 'manhattan-u') {
routerArgs.startDirections = ['top'];
routerArgs.endDirections = ['top'];
}
// 3. Intelligent Port Matching (Shortest Path)
// If ports are not explicitly provided (e.g. via Auto-connect Shift+A),
// we find the closest port pair to ensure the most direct connection.
let resolvedSourcePort = data.source_port;
let resolvedTargetPort = data.target_port;
if (state.graph && !resolvedSourcePort && !resolvedTargetPort && srcAnchor === 'orth' && dstAnchor === 'orth') {
const srcNode = state.graph.getCellById(data.source);
const dstNode = state.graph.getCellById(data.target);
if (srcNode && dstNode && srcNode.isNode() && dstNode.isNode()) {
const bestPorts = findClosestPorts(srcNode, dstNode);
if (bestPorts) {
resolvedSourcePort = bestPorts.source;
resolvedTargetPort = bestPorts.target;
}
}
}
const routerConfigs = {
manhattan: { name: 'manhattan', args: routerArgs },
'manhattan-u': { name: 'manhattan', args: routerArgs },
orthogonal: { name: 'orth' },
straight: null,
metro: { name: 'metro' }
};
const isStraightFinal = (routingType === 'straight');
const direction = data.direction || 'none';
// Resolve source/target configuration
// Rule: If a port is explicitly provided AND the anchor is set to 'orth' (Auto),
// we keep the port. If a manual anchor (top, left, etc.) is set, we clear the port.
const sourceConfig = {
cell: data.source,
connectionPoint: { name: 'boundary', args: { padding: 6 } }
};
if (resolvedSourcePort && srcAnchor === 'orth') {
sourceConfig.port = resolvedSourcePort;
} else {
// [FIX] In Straight mode, directional anchors (top, left etc) force a bend.
// We use 'center' but rely on 'boundary' connection point for a perfect straight diagonal.
sourceConfig.anchor = isStraight ? { name: 'center' } : { name: srcAnchor };
}
const targetConfig = {
cell: data.target,
connectionPoint: { name: 'boundary', args: { padding: 6 } }
};
if (resolvedTargetPort && dstAnchor === 'orth') {
targetConfig.port = resolvedTargetPort;
} else {
targetConfig.anchor = isStraight ? { name: 'center' } : { name: dstAnchor };
}
const config = {
id: data.id || `e_${data.source}_${data.target}`,
source: sourceConfig,
target: targetConfig,
router: isStraight ? null : (routerConfigs[routingType] || routerConfigs.manhattan),
connector: isStraight ? { name: 'normal' } : { name: 'jumpover', args: { type: 'arc', size: 6 } },
attrs: {
line: {
stroke: data.is_tunnel ? (data.color || '#ef4444') : (data.color || '#94a3b8'),
strokeWidth: data.is_tunnel ? 2 : (data.width || (data.style === 'double' ? 4 : 2)),
strokeDasharray: data.flow ? '5,5' : (data.is_tunnel ? '6,3' : (data.style === 'dashed' ? '5,5' : (data.style === 'dotted' ? '2,2' : '0'))),
targetMarker: (direction === 'forward' || direction === 'both') ? 'block' : null,
sourceMarker: (direction === 'backward' || direction === 'both') ? 'block' : null,
class: data.flow ? 'flow-animation' : '',
}
},
labels: (() => {
const lbls = [];
if (data.label) {
lbls.push({
attrs: {
text: { text: data.label, fill: data.color || '#64748b', fontSize: 11, fontWeight: '600' },
rect: { fill: 'rgba(255,255,255,0.8)', rx: 4, ry: 4, strokeWidth: 0 }
}
});
}
if (data.is_tunnel) {
lbls.push({
attrs: {
text: { text: 'VPN', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' },
rect: { fill: '#ffffff', rx: 3, ry: 3, stroke: '#ef4444', strokeWidth: 1 }
},
position: { distance: 0.25 }
});
}
return lbls;
})(),
zIndex: data.zIndex || calculateCellZIndex(data, 0, false), // Edges default to 30 (Above groups)
movable: !data.locked,
deletable: !data.locked,
data: data
};
return config;
}
/**
* findClosestPorts - Finds the pair of ports with the minimum distance between two nodes.
*/
function findClosestPorts(srcNode, dstNode) {
const srcPorts = srcNode.getPorts();
const dstPorts = dstNode.getPorts();
if (srcPorts.length === 0 || dstPorts.length === 0) return null;
let minParams = null;
let minDistance = Infinity;
srcPorts.forEach(sp => {
dstPorts.forEach(dp => {
const p1 = srcNode.getPortProp(sp.id, 'args/point') || { x: 0, y: 0 };
const p2 = dstNode.getPortProp(dp.id, 'args/point') || { x: 0, y: 0 };
// Convert to absolute coordinates
const pos1 = srcNode.getPosition();
const pos2 = dstNode.getPosition();
const abs1 = { x: pos1.x + p1.x, y: pos1.y + p1.y };
const abs2 = { x: pos2.x + p2.x, y: pos2.y + p2.y };
const dist = Math.sqrt(Math.pow(abs1.x - abs2.x, 2) + Math.pow(abs1.y - abs2.y, 2));
if (dist < minDistance) {
minDistance = dist;
minParams = { source: sp.id, target: dp.id };
}
});
});
return minParams;
}
@@ -0,0 +1,2 @@
export { getX6NodeConfig } from './node.js';
export { getX6EdgeConfig } from './edge.js';
@@ -0,0 +1,138 @@
import { state } from '../../../state.js';
import { shapeRegistry } from '../registry.js';
import { getLabelAttributes, generateRackUnitsPath } from '../utils.js';
import { DEFAULTS } from '../../../constants.js';
import { calculateCellZIndex } from '../../layers.js';
/**
* getX6NodeConfig - Maps data properties to X6 node attributes using ShapeRegistry
*/
export function getX6NodeConfig(data) {
const nodeType = (data.type || '').toLowerCase();
// 1. Find the plugin in the registry
let plugin = shapeRegistry.get(nodeType);
// Fallback for generic types if not found by direct name
if (!plugin) {
if (nodeType === 'rectangle') plugin = shapeRegistry.get('rect');
else if (nodeType === 'blank') plugin = shapeRegistry.get('dummy');
}
// Default to 'default' plugin if still not found
if (!plugin) plugin = shapeRegistry.get('default');
// 2. Resolve asset path (Skip for groups and primitives!)
const primitives = ['rect', 'circle', 'ellipse', 'rounded-rect', 'text-box', 'label', 'dot', 'dummy', 'rack', 'triangle', 'diamond', 'parallelogram', 'cylinder', 'document', 'manual-input', 'table', 'user', 'admin', 'hourglass', 'polyline'];
let assetPath = `/static/assets/${DEFAULTS.DEFAULT_ICON}`;
if (data.is_group || primitives.includes(nodeType)) {
assetPath = undefined;
} else if (plugin.shape === 'drawnet-node' || plugin.type === 'default' || !plugin.icon) {
const currentPath = data.assetPath || data.asset_path;
if (currentPath) {
assetPath = currentPath.startsWith('/static/assets/') ? currentPath : `/static/assets/${currentPath}`;
} else {
const matchedAsset = state.assetsData?.find(a => {
const searchType = (a.type || '').toLowerCase();
const searchId = (a.id || '').toLowerCase();
return nodeType === searchType || nodeType === searchId ||
nodeType.includes(searchType) || nodeType.includes(searchId);
});
if (matchedAsset) {
const iconFileName = matchedAsset.path || (matchedAsset.views && matchedAsset.views.icon) || DEFAULTS.DEFAULT_ICON;
assetPath = `/static/assets/${iconFileName}`;
data.assetPath = iconFileName;
}
}
}
// 3. Build Base Configuration
let config = {
id: data.id,
shape: plugin.shape,
position: data.pos ? { x: data.pos.x, y: data.pos.y } : { x: 100, y: 100 },
zIndex: data.zIndex || calculateCellZIndex(data, 0, true),
movable: !data.locked,
deletable: !data.locked,
data: data,
attrs: {
label: {
text: data.label || '',
fill: data.color || data['text-color'] || '#64748b',
...getLabelAttributes(data.label_pos || data['label-pos'], nodeType, data.is_group)
}
}
};
// 4. Plugin Specific Overrides
if (data.is_group) {
config.attrs.body = {
fill: data.background || '#f1f5f9',
stroke: data['border-color'] || '#3b82f6'
};
} else if (plugin.shape === 'drawnet-icon' && plugin.icon) {
config.attrs.icon = {
text: plugin.icon,
fill: data.color || '#64748b'
};
} else if (plugin.shape === 'drawnet-node') {
config.attrs.image = { 'xlink:href': assetPath };
}
// 5. Primitive Styling Persistence
if (primitives.includes(nodeType)) {
if (!config.attrs.body) config.attrs.body = {};
if (data.fill) config.attrs.body.fill = data.fill;
if (data.border) config.attrs.body.stroke = data.border;
}
// Rack specific handling
if (nodeType === 'rack') {
const slots = data.slots || 42;
const height = data.height || 600;
config.data.is_group = true;
config.attrs = {
...config.attrs,
units: { d: generateRackUnitsPath(height, slots) },
label: { text: data.label || `Rack (${slots}U)` }
};
}
// Rich Card specific handling
if (nodeType === 'rich-card' || plugin.shape === 'drawnet-rich-card') {
config.data.headerText = data.headerText || data.label || 'RICH INFO CARD';
config.data.content = data.content || '1. Content goes here...\n2. Support multiple lines';
config.data.cardType = data.cardType || 'numbered';
config.data.headerColor = data.headerColor || '#3b82f6';
config.data.headerAlign = data.headerAlign || 'left';
config.data.contentAlign = data.contentAlign || 'left';
const hAnchor = config.data.headerAlign === 'center' ? 'middle' : (config.data.headerAlign === 'right' ? 'end' : 'start');
const hX = config.data.headerAlign === 'center' ? 0.5 : (config.data.headerAlign === 'right' ? '100%' : 10);
const hX2 = config.data.headerAlign === 'right' ? -10 : 0;
config.attrs = {
...config.attrs,
headerLabel: {
text: config.data.headerText,
textAnchor: hAnchor,
refX: hX,
refX2: hX2
},
header: { fill: config.data.headerColor },
body: { stroke: config.data.headerColor }
};
// Initial render trigger (Hack: X6 might not be ready, so we defer)
setTimeout(() => {
const cell = state.graph.getCellById(config.id);
if (cell) {
import('../utils.js').then(m => m.renderRichContent(cell));
}
}, 50);
}
return config;
}
@@ -0,0 +1,338 @@
/**
* graph/styles/node_shapes.js - Registration of individual shape plugins
*/
import { shapeRegistry } from './registry.js';
import { commonPorts } from './ports.js';
import { generateRackUnitsPath, getLabelAttributes } from './utils.js';
import { DEFAULTS } from '../../constants.js';
/**
* Initialize all shape plugins into the registry
*/
export function registerNodes() {
// 1. Default Node
shapeRegistry.register('default', {
shape: 'drawnet-node',
definition: {
inherit: 'rect',
width: 60, height: 60,
attrs: {
body: { strokeWidth: 0, fill: 'transparent', rx: 4, ry: 4 },
image: { 'xlink:href': `/static/assets/${DEFAULTS.DEFAULT_ICON || 'router.svg'}`, width: 48, height: 48, refX: 6, refY: 6 },
label: {
text: '', fill: '#64748b', fontSize: 12, fontFamily: 'Inter, sans-serif',
fontWeight: 'bold', textVerticalAnchor: 'top', refX: 0.5, refY: '100%', refY2: 10,
},
},
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'image', selector: 'image' },
{ tagName: 'text', selector: 'label' },
],
ports: { ...commonPorts }
}
});
// 2. Group Node
shapeRegistry.register('group', {
shape: 'drawnet-group',
definition: {
inherit: 'rect',
width: 200, height: 200,
attrs: {
body: { strokeWidth: 2, stroke: '#3b82f6', fill: '#f1f5f9', fillOpacity: 0.2, rx: 8, ry: 8 },
label: {
text: '', fill: '#1e293b', fontSize: 14, fontFamily: 'Inter, sans-serif',
fontWeight: 'bold', textVerticalAnchor: 'bottom', refX: 0.5, refY: -15,
},
},
ports: { ...commonPorts }
}
});
// 3. Dummy/Blank Node
shapeRegistry.register('dummy', {
shape: 'drawnet-dummy',
definition: {
inherit: 'circle',
width: 8, height: 8,
attrs: {
body: { strokeWidth: 1, stroke: 'rgba(148, 163, 184, 0.2)', fill: 'rgba(255, 255, 255, 0.1)' },
},
ports: {
groups: { ...commonPorts.groups, center: { position: 'absolute', attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } } } },
items: [{ id: 'center', group: 'center' }],
}
}
});
// 4. Dot Node
shapeRegistry.register('dot', {
shape: 'drawnet-dot',
definition: {
inherit: 'circle',
width: 6, height: 6,
attrs: {
body: { strokeWidth: 1, stroke: '#64748b', fill: '#64748b' },
},
ports: {
groups: { ...commonPorts.groups, center: { position: 'absolute', attrs: { circle: { r: 3, magnet: true, stroke: '#31d0c6', strokeWidth: 1, fill: '#fff' } } } },
items: [{ id: 'center', group: 'center' }],
}
}
});
// 5. Basic Shapes (Primitives)
const basicAttrs = {
body: { strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: 0.5, textAnchor: 'middle', textVerticalAnchor: 'middle' }
};
shapeRegistry.register('rect', {
shape: 'drawnet-rect',
definition: { inherit: 'rect', width: 100, height: 60, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('rounded-rect', {
shape: 'drawnet-rounded-rect',
definition: {
inherit: 'rect', width: 100, height: 60,
attrs: { ...basicAttrs, body: { ...basicAttrs.body, rx: 12, ry: 12 } },
ports: commonPorts
}
});
shapeRegistry.register('circle', {
shape: 'drawnet-circle',
definition: { inherit: 'circle', width: 60, height: 60, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('ellipse', {
shape: 'drawnet-ellipse',
definition: { inherit: 'ellipse', width: 80, height: 50, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('text-box', {
shape: 'drawnet-text',
definition: {
inherit: 'rect', width: 100, height: 40,
attrs: { body: { strokeWidth: 0, fill: 'transparent' }, label: { ...basicAttrs.label, text: 'Text', fill: '#1e293b', fontSize: 14 } }
}
});
shapeRegistry.register('label', {
shape: 'drawnet-label',
definition: {
inherit: 'rect', width: 80, height: 30,
attrs: {
...basicAttrs,
body: { ...basicAttrs.body, rx: 4, ry: 4, strokeWidth: 1 },
label: { ...basicAttrs.label, fontSize: 13, fill: '#1e293b' }
},
ports: commonPorts
}
});
// 6. Practical Shapes
shapeRegistry.register('browser', {
shape: 'drawnet-browser',
definition: {
inherit: 'rect', width: 120, height: 80,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'circle', selector: 'btn1' },
{ tagName: 'circle', selector: 'btn2' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff', rx: 4, ry: 4 },
header: { strokeWidth: 2, stroke: '#94a3b8', fill: '#f1f5f9', height: 18, refWidth: '100%', rx: 4, ry: 4 },
btn1: { r: 3, fill: '#cbd5e1', refX: 10, refY: 9 },
btn2: { r: 3, fill: '#cbd5e1', refX: 20, refY: 9 },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: 0.6, textAnchor: 'middle', textVerticalAnchor: 'middle' }
},
ports: commonPorts
}
});
shapeRegistry.register('triangle', {
shape: 'drawnet-triangle',
definition: {
inherit: 'polygon', width: 60, height: 60,
attrs: {
body: { refPoints: '0,10 5,0 10,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
label: { ...basicAttrs.label, refY: 0.7 }
},
ports: commonPorts
}
});
shapeRegistry.register('diamond', {
shape: 'drawnet-diamond',
definition: {
inherit: 'polygon', width: 80, height: 80,
attrs: { body: { refPoints: '0,5 5,0 10,5 5,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('parallelogram', {
shape: 'drawnet-parallelogram',
definition: {
inherit: 'polygon', width: 100, height: 60,
attrs: { body: { refPoints: '2,0 10,0 8,10 0,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('cylinder', {
shape: 'drawnet-cylinder',
definition: {
inherit: 'rect', width: 60, height: 80,
markup: [
{ tagName: 'path', selector: 'body' },
{ tagName: 'path', selector: 'top' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { d: 'M 0 10 L 0 70 C 0 80 60 80 60 70 L 60 10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
top: { d: 'M 0 10 C 0 0 60 0 60 10 C 60 20 0 20 0 10 Z', strokeWidth: 2, stroke: '#94a3b8', fill: '#e2e8f0' },
label: basicAttrs.label
},
ports: commonPorts
}
});
shapeRegistry.register('document', {
shape: 'drawnet-document',
definition: {
inherit: 'rect', width: 60, height: 80,
markup: [
{ tagName: 'path', selector: 'body' },
{ tagName: 'path', selector: 'fold' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { d: 'M 0 0 L 45 0 L 60 15 L 60 80 L 0 80 Z', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
fold: { d: 'M 45 0 L 45 15 L 60 15', strokeWidth: 2, stroke: '#94a3b8', fill: '#e2e8f0' },
label: basicAttrs.label
},
ports: commonPorts
}
});
shapeRegistry.register('manual-input', {
shape: 'drawnet-manual-input',
definition: {
inherit: 'polygon', width: 100, height: 60,
attrs: { body: { refPoints: '0,3 10,0 10,10 0,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('table', {
// ... (previous table registration)
});
// --- 랙(Rack) 컨테이너 플러그인 ---
shapeRegistry.register('rack', {
shape: 'drawnet-rack',
definition: {
inherit: 'rect',
width: 160, height: 600,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'path', selector: 'units' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 3, stroke: '#334155', fill: '#f8fafc', rx: 4, ry: 4 },
header: { refWidth: '100%', height: 30, fill: '#1e293b', stroke: '#334155', strokeWidth: 2, rx: 4, ry: 4 },
units: {
d: generateRackUnitsPath(600, 42),
stroke: '#94a3b8', strokeWidth: 1
},
label: {
text: 'Rack', fill: '#ffffff', fontSize: 14, fontWeight: 'bold',
refX: 0.5, refY: 15, textAnchor: 'middle', textVerticalAnchor: 'middle'
}
},
ports: commonPorts
}
});
// 8. Rich Text Card (Header + Listing)
shapeRegistry.register('rich-card', {
shape: 'drawnet-rich-card',
definition: {
inherit: 'rect',
width: 280, height: 180,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'text', selector: 'headerLabel' },
{
tagName: 'foreignObject',
selector: 'fo',
children: [
{
tagName: 'div',
ns: 'http://www.w3.org/1999/xhtml',
selector: 'content',
style: {
width: '100%',
height: '100%',
padding: '10px',
fontSize: '12px',
color: '#334155',
overflow: 'hidden',
boxSizing: 'border-box',
lineHeight: '1.4'
}
}
]
}
],
attrs: {
body: { fill: '#ffffff', stroke: '#3b82f6', strokeWidth: 1, rx: 4, ry: 4 },
header: { refWidth: '100%', height: 32, fill: '#3b82f6', rx: 4, ry: 4 },
headerLabel: {
text: 'TITLE', fill: '#ffffff', fontSize: 13, fontWeight: 'bold',
refX: 10, refY: 16, textVerticalAnchor: 'middle'
},
fo: { refX: 0, refY: 32, refWidth: '100%', refHeight: 'calc(100% - 32)' }
},
ports: commonPorts
}
});
// 7. Dynamic Icon-based Plugins
const icons = {
'user': '\uf007', 'admin': '\uf505', 'hourglass': '\uf252',
'table': '\uf0ce', 'tag': '\uf02b', 'polyline': '\uf201',
'arrow-up': '\uf062', 'arrow-down': '\uf063', 'arrow-left': '\uf060', 'arrow-right': '\uf061'
};
Object.keys(icons).forEach(type => {
shapeRegistry.register(type, {
shape: 'drawnet-icon',
icon: icons[type],
definition: {
inherit: 'rect', width: 60, height: 60,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'text', selector: 'icon' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 0, fill: 'transparent' },
icon: { text: icons[type], fontFamily: 'FontAwesome', fontSize: 40, fill: '#64748b', refX: 0.5, refY: 0.5, textAnchor: 'middle', textVerticalAnchor: 'middle' },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: '100%', refY2: 12, textAnchor: 'middle', textVerticalAnchor: 'top' }
},
ports: commonPorts
}
});
});
}
+39
View File
@@ -0,0 +1,39 @@
/**
* graph/styles/ports.js - Core port configurations for X6 nodes
*/
export const commonPorts = {
groups: {
top: {
position: 'top',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
bottom: {
position: 'bottom',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
left: {
position: 'left',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
right: {
position: 'right',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
center: {
position: 'absolute',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
}
},
items: [
{ id: 'top', group: 'top' },
{ id: 'bottom', group: 'bottom' },
{ id: 'left', group: 'left' },
{ id: 'right', group: 'right' }
],
};
export const centerPort = {
...commonPorts,
items: [{ id: 'center', group: 'center' }]
};

Some files were not shown because too many files have changed in this diff Show More