Initial commit: drawNET Alpha v1.0 - Professional Topology Designer with Full i18n and Performance Optimizations
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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 |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
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 |
@@ -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,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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/* Ghost file to prevent MIME type 404 errors in legacy/library environments */
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = [];
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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' }]
|
||||
};
|
||||