2360 lines
170 KiB
HTML
2360 lines
170 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fa" dir="rtl">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ابزار رسم نمودارهای UML</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/vazirmatn@5.0.0/index.css">
|
||
<style>
|
||
body{font-family:'Vazirmatn',sans-serif;overflow:hidden}
|
||
:root{
|
||
--bg-primary:#f7fafc;--bg-secondary:#fff;--bg-tertiary:#edf2f7;--bg-hover:#f0f4f8;
|
||
--text-primary:#2d3748;--text-secondary:#4a5568;--text-muted:#718096;--border-color:#e2e8f0;
|
||
--sidebar-bg:#fff;--canvas-bg:#fff;--grid-line:rgba(0,0,0,.06);--tab-bg:#fff;
|
||
--tab-bar-bg:linear-gradient(to bottom,#f8fafc,#f1f5f9);--status-bg:#2d3748;--status-text:#fff;
|
||
--modal-bg:#fff;--modal-overlay:rgba(0,0,0,.5);--input-bg:#fff;--input-border:#cbd5e0;
|
||
--input-readonly-bg:#f7fafc;--card-bg:#f7fafc;--card-border:#e2e8f0;--section-border:#edf2f7;
|
||
--shadow-sm:0 1px 3px rgba(0,0,0,.08);--shadow-md:0 4px 12px rgba(0,0,0,.1);
|
||
--shadow-lg:0 10px 30px rgba(0,0,0,.12);
|
||
--actor-stroke:#2d3748;--uc-fill:#f7fafc;--uc-stroke:#2d3748;--rel-stroke:#2d3748;
|
||
--sb-stroke:#4a5568;--label-bg:#fff;--state-fill:#ebf8ff;--state-stroke:#2b6cb0;
|
||
--state-init:#2d3748;--state-final-outer:#2d3748;--state-final-inner:#2d3748;
|
||
--transition-stroke:#4a5568;--transition-label:#2d3748;--activity-fill:#f0fff4;
|
||
--activity-stroke:#276749;--decision-fill:#fffaf0;--decision-stroke:#c05621;
|
||
--fork-stroke:#2d3748;--flow-stroke:#4a5568;--flow-label:#2d3748;
|
||
--comp-fill:#faf5ff;--comp-stroke:#6b46c1;--comp-icon:#6b46c1;--iface-stroke:#6b46c1;
|
||
--pkg-fill:#fffaf0;--pkg-stroke:#c05621;--dep-stroke:#4a5568;
|
||
--node-face-top:#e6fffa;--node-face-front:#b2f5ea;--node-face-side:#81e6d9;
|
||
--node-stroke:#234e52;--artifact-fill:#fff5f5;--artifact-stroke:#c53030;
|
||
--class-fill:#ebf8ff;--class-stroke:#2c5282;--class-header:#bee3f8;
|
||
--class-attr:#2c5282;--class-method:#2f855a;
|
||
--seq-lifeline:#4a5568;--seq-participant-fill:#e2e8f0;--seq-participant-stroke:#2d3748;
|
||
--seq-activation:#bee3f8;--seq-activation-stroke:#2c5282;--seq-msg:#2d3748;--seq-return:#718096;
|
||
--seq-fragment-fill:#fffaf0;--seq-fragment-stroke:#c05621;
|
||
--swimlane-default:#ebf8ff;--swimlane-stroke:#2c5282;
|
||
}
|
||
body.dark{
|
||
--bg-primary:#0f1724;--bg-secondary:#1a2332;--bg-tertiary:#243044;--bg-hover:#2a3a52;
|
||
--text-primary:#e2e8f0;--text-secondary:#cbd5e0;--text-muted:#8899aa;--border-color:#2d3f56;
|
||
--sidebar-bg:#1a2332;--canvas-bg:#0f1724;--grid-line:rgba(255,255,255,.05);--tab-bg:#1a2332;
|
||
--tab-bar-bg:linear-gradient(to bottom,#1a2332,#162030);--status-bg:#0a1018;--status-text:#cbd5e0;
|
||
--modal-bg:#1a2332;--modal-overlay:rgba(0,0,0,.7);--input-bg:#243044;--input-border:#3a5068;
|
||
--input-readonly-bg:#1e2d40;--card-bg:#1e2d40;--card-border:#2d3f56;--section-border:#2d3f56;
|
||
--shadow-sm:0 1px 3px rgba(0,0,0,.3);--shadow-md:0 4px 12px rgba(0,0,0,.4);
|
||
--shadow-lg:0 10px 30px rgba(0,0,0,.5);
|
||
--actor-stroke:#cbd5e0;--uc-fill:#1e2d40;--uc-stroke:#cbd5e0;--rel-stroke:#a0b4c8;
|
||
--sb-stroke:#8899aa;--label-bg:#1a2332;--state-fill:#1e2d40;--state-stroke:#63b3ed;
|
||
--state-init:#e2e8f0;--state-final-outer:#e2e8f0;--state-final-inner:#e2e8f0;
|
||
--transition-stroke:#8899aa;--transition-label:#cbd5e0;--activity-fill:#1e2d40;
|
||
--activity-stroke:#68d391;--decision-fill:#2d2418;--decision-stroke:#fbd38d;
|
||
--fork-stroke:#e2e8f0;--flow-stroke:#8899aa;--flow-label:#cbd5e0;
|
||
--comp-fill:#2a1f3d;--comp-stroke:#b794f4;--comp-icon:#b794f4;--iface-stroke:#b794f4;
|
||
--pkg-fill:#2d2418;--pkg-stroke:#fbd38d;--dep-stroke:#8899aa;
|
||
--node-face-top:#234e52;--node-face-front:#1d4044;--node-face-side:#1a3a3e;
|
||
--node-stroke:#81e6d9;--artifact-fill:#3b1a1a;--artifact-stroke:#fc8181;
|
||
--class-fill:#1e2d40;--class-stroke:#63b3ed;--class-header:#2c5282;
|
||
--class-attr:#90cdf4;--class-method:#9ae6b4;
|
||
--seq-lifeline:#8899aa;--seq-participant-fill:#243044;--seq-participant-stroke:#cbd5e0;
|
||
--seq-activation:#2c5282;--seq-activation-stroke:#63b3ed;--seq-msg:#cbd5e0;--seq-return:#8899aa;
|
||
--seq-fragment-fill:#2d2418;--seq-fragment-stroke:#fbd38d;
|
||
--swimlane-default:#1e2d40;--swimlane-stroke:#63b3ed;
|
||
}
|
||
body{background:var(--bg-primary);color:var(--text-primary)}
|
||
.tool-btn{transition:all .2s;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid transparent}
|
||
.tool-btn:hover{transform:translateY(-1px);box-shadow:var(--shadow-sm);background:var(--bg-hover)}
|
||
.tool-btn.active{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%)!important;color:#fff!important;border-color:transparent!important}
|
||
body.dark .tool-btn-red{background:#3b1a1a!important;color:#fc8181!important}
|
||
body.dark .tool-btn-orange{background:#3b2a1a!important;color:#fbd38d!important}
|
||
body.dark .tool-btn-purple{background:#2a1a3b!important;color:#d6bcfa!important}
|
||
body.dark .tool-btn-teal{background:#1a3b35!important;color:#81e6d9!important}
|
||
body.dark .tool-btn-cyan{background:#1a2e3b!important;color:#76e4f7!important}
|
||
body.dark .tool-btn-green{background:#1a3b1e!important;color:#9ae6b4!important}
|
||
body.dark .tool-btn-blue{background:#1a253b!important;color:#90cdf4!important}
|
||
body.dark .tool-btn-indigo{background:#1e2544!important;color:#a3bffa!important}
|
||
.tool-btn-red{background:#fff5f5!important;color:#c53030!important}
|
||
.tool-btn-orange{background:#fffaf0!important;color:#c05621!important}
|
||
.tool-btn-purple{background:#faf5ff!important;color:#6b46c1!important}
|
||
.tool-btn-teal{background:#e6fffa!important;color:#285e61!important}
|
||
.tool-btn-cyan{background:#edfdfd!important;color:#0987a0!important}
|
||
.tool-btn-green{background:#f0fff4!important;color:#276749!important}
|
||
.tool-btn-blue{background:#ebf8ff!important;color:#2b6cb0!important}
|
||
.tool-btn-indigo{background:#ebf4ff!important;color:#4c51bf!important}
|
||
canvas{cursor:crosshair}
|
||
.canvas-container{background-color:var(--canvas-bg)}
|
||
.sidebar-wrapper{transition:width .3s cubic-bezier(.4,0,.2,1),opacity .2s ease;width:260px;overflow:hidden;flex-shrink:0;background:var(--sidebar-bg);border-left:1px solid var(--border-color)}
|
||
.sidebar-wrapper.collapsed{width:0;opacity:0;pointer-events:none}
|
||
.sidebar-inner{width:260px;height:100%;overflow-y:auto}
|
||
.tool-group{margin-bottom:12px}
|
||
.tool-group-header{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;padding-bottom:6px;margin-bottom:6px;border-bottom:1px solid var(--section-border);cursor:pointer;user-select:none;transition:color .2s}
|
||
.tool-group-header:hover{color:var(--text-secondary)}
|
||
.tool-group-arrow{font-size:10px;transition:transform .3s ease;color:var(--text-muted)}
|
||
.tool-group.collapsed .tool-group-arrow{transform:rotate(-90deg)}
|
||
.tool-group-content{max-height:1500px;overflow:hidden;transition:max-height .35s ease,opacity .25s ease;opacity:1}
|
||
.tool-group.collapsed .tool-group-content{max-height:0;opacity:0}
|
||
.properties-overlay{position:fixed;bottom:40px;left:50%;transform:translateX(-50%) translateY(120%);z-index:1000;transition:transform .35s cubic-bezier(.4,0,.2,1),opacity .35s ease;opacity:0;pointer-events:none;min-width:520px;max-width:90vw}
|
||
.properties-overlay.visible{transform:translateX(-50%) translateY(0);opacity:1;pointer-events:auto}
|
||
.properties-overlay .panel-shadow{background:var(--modal-bg);box-shadow:0 -8px 30px rgba(0,0,0,.25),0 0 0 1px var(--border-color)}
|
||
.modal-overlay{position:fixed;inset:0;background:var(--modal-overlay);backdrop-filter:blur(4px);z-index:2000;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .3s ease}
|
||
.modal-overlay.visible{opacity:1;pointer-events:auto}
|
||
.modal-content{background:var(--modal-bg);border-radius:16px;box-shadow:var(--shadow-lg);border:1px solid var(--border-color);max-width:600px;width:90%;max-height:80vh;overflow-y:auto;transform:scale(.9) translateY(20px);transition:transform .3s cubic-bezier(.4,0,.2,1);color:var(--text-primary)}
|
||
.modal-overlay.visible .modal-content{transform:scale(1) translateY(0)}
|
||
.save-modal-content{max-width:520px}
|
||
.class-editor-modal{max-width:720px}
|
||
.tab-bar{background:var(--tab-bar-bg);border-bottom:1px solid var(--border-color);display:flex;align-items:center;padding:0 8px;flex-shrink:0;height:44px;gap:4px}
|
||
.tabs-scroll{display:flex;overflow-x:auto;overflow-y:hidden;flex:1;gap:4px;padding:6px 4px;scrollbar-width:thin}
|
||
.tabs-scroll::-webkit-scrollbar{height:4px}
|
||
.tabs-scroll::-webkit-scrollbar-thumb{background:var(--border-color);border-radius:2px}
|
||
.diagram-tab{display:flex;align-items:center;gap:6px;padding:6px 10px 6px 12px;background:var(--tab-bg);border:1px solid var(--border-color);border-radius:8px 8px 0 0;cursor:pointer;min-width:140px;max-width:240px;transition:all .2s;user-select:none;flex-shrink:0}
|
||
.diagram-tab:hover{background:var(--bg-hover)}
|
||
.diagram-tab.active{background:var(--bg-secondary);border-color:#667eea;border-bottom:2px solid var(--bg-secondary);margin-bottom:-1px;box-shadow:0 -2px 8px rgba(102,126,234,.15)}
|
||
.diagram-tab.active .tab-title{color:#667eea;font-weight:600}
|
||
.tab-title{font-size:12px;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
|
||
.tab-type-badge{font-size:9px;padding:1px 5px;border-radius:4px;background:var(--bg-tertiary);color:var(--text-muted);font-weight:600;flex-shrink:0}
|
||
.tab-close{width:18px;height:18px;display:flex;align-items:center;justify-content:center;border-radius:4px;color:var(--text-muted);font-size:14px;transition:all .15s}
|
||
.tab-close:hover{background:#fed7d7;color:#c53030}
|
||
.header-bar-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:#fff;border-radius:8px;cursor:pointer;font-size:16px;transition:all .2s;flex-shrink:0}
|
||
.header-bar-btn:hover{transform:scale(1.08)}
|
||
.btn-new{background:linear-gradient(135deg,#48bb78,#38a169);box-shadow:0 2px 4px rgba(72,187,120,.3)}
|
||
.btn-toggle{background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 2px 4px rgba(102,126,234,.3)}
|
||
.btn-theme{background:linear-gradient(135deg,#ed8936,#dd6b20);box-shadow:0 2px 4px rgba(237,137,54,.3);font-size:18px}
|
||
.btn-undo{background:linear-gradient(135deg,#4299e1,#3182ce);box-shadow:0 2px 4px rgba(66,153,225,.3);font-size:14px}
|
||
.btn-redo{background:linear-gradient(135deg,#4299e1,#3182ce);box-shadow:0 2px 4px rgba(66,153,225,.3);font-size:14px}
|
||
.btn-zoom{background:linear-gradient(135deg,#9f7aea,#805ad5);box-shadow:0 2px 4px rgba(159,122,234,.3);font-size:14px;width:28px;height:28px}
|
||
.zoom-display{font-size:11px;color:var(--text-muted);min-width:45px;text-align:center;font-family:monospace;background:var(--bg-tertiary);padding:4px 6px;border-radius:6px;cursor:pointer;user-select:none}
|
||
.zoom-display:hover{background:var(--bg-hover);color:var(--text-secondary)}
|
||
.themed-input{background:var(--input-bg);border:1px solid var(--input-border);color:var(--text-primary);border-radius:8px;padding:8px 12px;font-size:13px;transition:border-color .2s,box-shadow .2s;width:100%}
|
||
.themed-input:focus{outline:none;border-color:#667eea;box-shadow:0 0 0 3px rgba(102,126,234,.15)}
|
||
.themed-input-readonly{background:var(--input-readonly-bg);border:1px solid var(--border-color);color:var(--text-muted);border-radius:8px;padding:8px 12px;font-size:13px;width:100%}
|
||
.themed-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:8px;padding:12px}
|
||
.status-bar{background:var(--status-bg);color:var(--status-text);border-top:1px solid var(--border-color)}
|
||
.main-header{background:linear-gradient(to left,#4c51bf,#6b46c1);box-shadow:var(--shadow-md)}
|
||
.grid-type-selector{display:flex;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:8px;padding:2px;gap:2px}
|
||
.grid-type-option{flex:1;display:flex;align-items:center;justify-content:center;gap:4px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:11px;font-weight:500;color:var(--text-muted);transition:all .2s;user-select:none}
|
||
.grid-type-option:hover{color:var(--text-secondary);background:var(--bg-hover)}
|
||
.grid-type-option.active{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;box-shadow:0 2px 4px rgba(102,126,234,.25)}
|
||
.diagram-type-card{border:2px solid var(--border-color);border-radius:12px;padding:16px;cursor:pointer;transition:all .2s;background:var(--card-bg)}
|
||
.diagram-type-card:hover{border-color:#667eea;transform:translateY(-2px);box-shadow:var(--shadow-md)}
|
||
.diagram-type-card.selected{border-color:#667eea;background:linear-gradient(135deg,rgba(102,126,234,.08),rgba(118,75,162,.08));box-shadow:0 0 0 3px rgba(102,126,234,.15)}
|
||
.diagram-type-card.disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.5)}
|
||
.diagram-type-card.disabled:hover{border-color:var(--border-color);transform:none;box-shadow:none}
|
||
.diagram-type-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:24px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;margin-bottom:10px}
|
||
.diagram-type-card.disabled .diagram-type-icon{background:var(--bg-tertiary)}
|
||
.file-preview{background:linear-gradient(135deg,rgba(102,126,234,.08),rgba(118,75,162,.08));border:1px dashed #667eea;border-radius:12px;padding:14px;display:flex;align-items:center;gap:12px}
|
||
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--text-muted)}
|
||
.class-editor-tabs{display:flex;border-bottom:2px solid var(--border-color);margin-bottom:16px}
|
||
.class-editor-tab{flex:1;padding:10px;text-align:center;cursor:pointer;font-size:13px;font-weight:500;color:var(--text-muted);border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .2s}
|
||
.class-editor-tab:hover{color:var(--text-secondary)}
|
||
.class-editor-tab.active{color:#667eea;border-bottom-color:#667eea}
|
||
.member-table{width:100%;border-collapse:collapse;font-size:12px}
|
||
.member-table th{background:var(--bg-tertiary);color:var(--text-muted);padding:6px 8px;text-align:right;font-weight:600;border-bottom:1px solid var(--border-color)}
|
||
.member-table td{padding:4px;border-bottom:1px solid var(--border-color)}
|
||
.member-table input,.member-table select{background:var(--input-bg);border:1px solid var(--input-border);color:var(--text-primary);border-radius:4px;padding:4px 6px;font-size:12px;width:100%}
|
||
.member-table input:focus,.member-table select:focus{outline:none;border-color:#667eea}
|
||
.member-btn{padding:4px 8px;border-radius:4px;font-size:11px;cursor:pointer;border:none;transition:all .15s}
|
||
.member-btn-add{background:#48bb78;color:#fff}
|
||
.member-btn-add:hover{background:#38a169}
|
||
.member-btn-del{background:#fc8181;color:#fff}
|
||
.member-btn-del:hover{background:#f56565}
|
||
@media print{.no-print{display:none!important}canvas{page-break-inside:avoid}}
|
||
</style>
|
||
</head>
|
||
<body class="dark">
|
||
<div class="flex flex-col h-screen">
|
||
<header class="main-header text-white flex-shrink-0 no-print">
|
||
<div class="px-4 py-3">
|
||
<div class="flex justify-between items-center">
|
||
<div>
|
||
<h1 class="text-xl font-bold">🎯 ابزار رسم نمودارهای UML</h1>
|
||
<p class="text-xs opacity-90 mt-1">مدیریت چند نمودار با قابلیت ذخیره و بازیابی</p>
|
||
</div>
|
||
<div class="text-left text-sm"><div class="opacity-90">طراح: هادی خزاعی اصل</div></div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="tab-bar no-print">
|
||
<button id="toggleSidebarBtn" class="header-bar-btn btn-toggle" title="نمایش/مخفی ابزارها (B)">☰</button>
|
||
<button id="newDiagramBtn" class="header-bar-btn btn-new" title="دیاگرام جدید (Ctrl+N)">+</button>
|
||
<button id="themeToggleBtn" class="header-bar-btn btn-theme" title="تغییر تم">🌙</button>
|
||
<div style="width:1px;height:24px;background:var(--border-color);margin:0 4px"></div>
|
||
<button id="undoBtn" class="header-bar-btn btn-undo" title="بازگشت (Ctrl+Z)">↶</button>
|
||
<button id="redoBtn" class="header-bar-btn btn-redo" title="از نو (Ctrl+Y)">↷</button>
|
||
<div style="width:1px;height:24px;background:var(--border-color);margin:0 4px"></div>
|
||
<button id="zoomOutBtn" class="header-bar-btn btn-zoom" title="کوچکنمایی">−</button>
|
||
<div id="zoomDisplay" class="zoom-display" title="کلیک برای بازنشانی">100%</div>
|
||
<button id="zoomInBtn" class="header-bar-btn btn-zoom" title="بزرگنمایی">+</button>
|
||
<div style="width:1px;height:24px;background:var(--border-color);margin:0 4px"></div>
|
||
<div id="tabsContainer" class="tabs-scroll"></div>
|
||
</div>
|
||
|
||
<div class="flex flex-1 overflow-hidden">
|
||
<div id="sidebarWrapper" class="sidebar-wrapper no-print">
|
||
<div class="sidebar-inner p-3">
|
||
<h2 class="font-bold mb-3 text-base" style="color:var(--text-primary)">🛠️ ابزارها</h2>
|
||
|
||
<div class="tool-group collapsed" data-group="selection">
|
||
<div class="tool-group-header"><span>🖱️</span><span style="flex:1">انتخاب</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn active w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="select" data-types="all"><span>🖱️</span><span>انتخاب و جابجایی</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="uc-elements" data-diagram-types="usecase">
|
||
<div class="tool-group-header"><span>📐</span><span style="flex:1">المانهای Use Case</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="actor" data-types="usecase"><span>👤</span><span>Actor</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="useCase" data-types="usecase"><span>⭕</span><span>Use Case</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="boundary" data-types="usecase"><span>📦</span><span>System Boundary</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group collapsed" data-group="uc-relations" data-diagram-types="usecase">
|
||
<div class="tool-group-header"><span>🔗</span><span style="flex:1">روابط Use Case</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="association" data-types="usecase"><span>➖</span><span>Association</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="include" data-types="usecase"><span>🔗</span><span>Include</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="extend" data-types="usecase"><span>🔀</span><span>Extend</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="generalization" data-types="usecase"><span>🔺</span><span>Generalization</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="sm-elements" data-diagram-types="statemachine">
|
||
<div class="tool-group-header"><span>🔵</span><span style="flex:1">المانهای State Machine</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="initialState" data-types="statemachine"><span>⚫</span><span>Initial State</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="state" data-types="statemachine"><span>🔲</span><span>State</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="finalState" data-types="statemachine"><span>🎯</span><span>Final State</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="compositeState" data-types="statemachine"><span>📦</span><span>Composite State</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group collapsed" data-group="sm-relations" data-diagram-types="statemachine">
|
||
<div class="tool-group-header"><span>➡️</span><span style="flex:1">روابط State Machine</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="transition" data-types="statemachine"><span>➡️</span><span>Transition</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="act-elements" data-diagram-types="activity">
|
||
<div class="tool-group-header"><span>🔷</span><span style="flex:1">المانهای Activity</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="initialNode" data-types="activity"><span>⚫</span><span>Initial Node</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="finalNode" data-types="activity"><span>🎯</span><span>Final Node</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="action" data-types="activity"><span>▭</span><span>Action</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="decision" data-types="activity"><span>◇</span><span>Decision</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="merge" data-types="activity"><span>◇</span><span>Merge</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="forkJoin" data-types="activity"><span>▬</span><span>Fork/Join</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group collapsed" data-group="act-flows" data-diagram-types="activity">
|
||
<div class="tool-group-header"><span>➡️</span><span style="flex:1">جریانهای Activity</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="controlFlow" data-types="activity"><span>→</span><span>Control Flow</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="objectFlow" data-types="activity"><span>⇢</span><span>Object Flow</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="comp-elements" data-diagram-types="component">
|
||
<div class="tool-group-header"><span>🧩</span><span style="flex:1">المانهای Component</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="component" data-types="component"><span>🧩</span><span>Component</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="interfaceProv" data-types="component"><span>🍭</span><span>Interface Provided</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="interfaceReq" data-types="component"><span>◗</span><span>Interface Required</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="package" data-types="component"><span>📁</span><span>Package</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group collapsed" data-group="comp-relations" data-diagram-types="component">
|
||
<div class="tool-group-header"><span>🔗</span><span style="flex:1">روابط Component</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="dependency" data-types="component"><span>⇢</span><span>Dependency</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="connector" data-types="component"><span>—</span><span>Connector</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="compAssociation" data-types="component"><span>➖</span><span>Association</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="dep-elements" data-diagram-types="deployment">
|
||
<div class="tool-group-header"><span>🖥️</span><span style="flex:1">المانهای Deployment</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="node" data-types="deployment"><span>🖥️</span><span>Node</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="device" data-types="deployment"><span>💻</span><span>Device</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="execEnv" data-types="deployment"><span>⚙️</span><span>Execution Env</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="artifact" data-types="deployment"><span>📄</span><span>Artifact</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group collapsed" data-group="dep-relations" data-diagram-types="deployment">
|
||
<div class="tool-group-header"><span>🔗</span><span style="flex:1">روابط Deployment</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="commPath" data-types="deployment"><span>⇌</span><span>Communication Path</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="deployRel" data-types="deployment"><span>⇣</span><span>Deployment</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="manifestRel" data-types="deployment"><span>⊳</span><span>Manifestation</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="cls-elements" data-diagram-types="class">
|
||
<div class="tool-group-header"><span>🗂️</span><span style="flex:1">المانهای Class</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="class" data-types="class"><span>🗂️</span><span>Class</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="interfaceClass" data-types="class"><span>◯</span><span>Interface</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="abstractClass" data-types="class"><span>🗂️</span><span>Abstract Class</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="enumeration" data-types="class"><span>📋</span><span>Enumeration</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group collapsed" data-group="cls-relations" data-diagram-types="class">
|
||
<div class="tool-group-header"><span>🔗</span><span style="flex:1">روابط Class</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="clsAssociation" data-types="class"><span>➖</span><span>Association</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="aggregation" data-types="class"><span>◇</span><span>Aggregation</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="composition" data-types="class"><span>◆</span><span>Composition</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="inheritance" data-types="class"><span>△</span><span>Inheritance</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="clsDependency" data-types="class"><span>⇢</span><span>Dependency</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="realization" data-types="class"><span>▷</span><span>Realization</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="seq-elements" data-diagram-types="sequence">
|
||
<div class="tool-group-header"><span>🔢</span><span style="flex:1">المانهای Sequence</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="participant" data-types="sequence"><span>▭</span><span>Participant</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="activation" data-types="sequence"><span>▮</span><span>Activation</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="fragment" data-types="sequence"><span>▣</span><span>Combined Fragment</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group collapsed" data-group="seq-messages" data-diagram-types="sequence">
|
||
<div class="tool-group-header"><span>➡️</span><span style="flex:1">پیامهای Sequence</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="syncMsg" data-types="sequence"><span>→●</span><span>Synchronous</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="asyncMsg" data-types="sequence"><span>→○</span><span>Asynchronous</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="returnMsg" data-types="sequence"><span>⇠</span><span>Return</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="selfMsg" data-types="sequence"><span>↷</span><span>Self Message</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="sw-elements" data-diagram-types="swimlane">
|
||
<div class="tool-group-header"><span>▤</span><span style="flex:1">المانهای Swimlane</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="swimlane" data-types="swimlane"><span>▤</span><span>Swimlane</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="swAction" data-types="swimlane"><span>▭</span><span>Action</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="swDecision" data-types="swimlane"><span>◇</span><span>Decision</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="swForkJoin" data-types="swimlane"><span>▬</span><span>Fork/Join</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="swInitial" data-types="swimlane"><span>⚫</span><span>Initial Node</span></button>
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="swFinal" data-types="swimlane"><span>🎯</span><span>Final Node</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="tool-group collapsed" data-group="sw-flows" data-diagram-types="swimlane">
|
||
<div class="tool-group-header"><span>➡️</span><span style="flex:1">جریانهای Swimlane</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button class="tool-btn w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm" data-tool="swFlow" data-types="swimlane"><span>→</span><span>Control Flow</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="display">
|
||
<div class="tool-group-header"><span>📏</span><span style="flex:1">نوع خطکشی بوم</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<div class="grid-type-selector">
|
||
<div class="grid-type-option" data-grid-type="none"><span>⊘</span><span>خاموش</span></div>
|
||
<div class="grid-type-option" data-grid-type="lines"><span>▦</span><span>خط</span></div>
|
||
<div class="grid-type-option active" data-grid-type="dots"><span>⠿</span><span>نقطه</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="actions">
|
||
<div class="tool-group-header"><span>⚡</span><span style="flex:1">عملیات</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button id="deleteBtn" class="tool-btn tool-btn-red w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm"><span>🗑️</span><span>حذف انتخاب شده</span></button>
|
||
<button id="clearBtn" class="tool-btn tool-btn-orange w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm"><span>🧹</span><span>پاک کردن دیاگرام</span></button>
|
||
<button id="exportPngBtn" class="tool-btn tool-btn-purple w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm"><span>🖼️</span><span>خروجی PNG</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group collapsed" data-group="file">
|
||
<div class="tool-group-header"><span>💾</span><span style="flex:1">فایل و پروژه</span><span class="tool-group-arrow">▼</span></div>
|
||
<div class="tool-group-content">
|
||
<button id="fileSettingsBtn" class="tool-btn tool-btn-teal w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm"><span>📋</span><span>تنظیمات فایل پروژه</span></button>
|
||
<button id="settingsBtn" class="tool-btn tool-btn-cyan w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm"><span>⚙️</span><span>تنظیمات دیاگرام</span></button>
|
||
<button id="saveBtn" class="tool-btn tool-btn-green w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm"><span>💾</span><span>ذخیره پروژه</span></button>
|
||
<button id="loadBtn" class="tool-btn tool-btn-blue w-full py-2 px-3 rounded-lg mb-1 flex items-center gap-2 text-sm"><span>📂</span><span>بارگذاری فایل</span></button>
|
||
<input type="file" id="fileInput" accept=".json" class="hidden">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="themed-card text-xs" style="border-color:rgba(102,126,234,.3)">
|
||
<h4 class="font-bold mb-1" style="color:var(--text-primary)">💡 راهنما:</h4>
|
||
<ul class="space-y-1 leading-relaxed" style="color:var(--text-muted)">
|
||
<li>• <b>Ctrl+Wheel</b> زوم | <b>Space+Drag</b> Pan</li>
|
||
<li>• <b>Ctrl+Z/Y</b> Undo/Redo</li>
|
||
<li>• <b>دابلکلیک Class</b>: ویرایشگر</li>
|
||
<li>• <b>دابلکلیک Swimlane</b>: تغییر جهت</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<main class="flex-1 relative overflow-hidden">
|
||
<div id="canvasWrapper" class="w-full h-full canvas-container"><canvas id="canvas" class="block"></canvas></div>
|
||
<div id="emptyState" class="empty-state absolute inset-0 hidden">
|
||
<div class="text-6xl mb-4">📊</div>
|
||
<h3 class="text-xl font-bold mb-2" style="color:var(--text-secondary)">هیچ دیاگرامی وجود ندارد</h3>
|
||
<p class="text-sm mb-4" style="color:var(--text-muted)">برای شروع، روی دکمه + کلیک کنید</p>
|
||
<button onclick="showNewDiagramModal()" class="bg-gradient-to-l from-indigo-600 to-purple-600 text-white py-2 px-6 rounded-lg text-sm font-medium">+ ایجاد اولین دیاگرام</button>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<footer class="status-bar px-4 py-1.5 text-xs no-print flex-shrink-0 z-50">
|
||
<div class="flex justify-between items-center"><div id="statusText">آماده</div><div id="diagramInfo">دیاگرامها: 0</div></div>
|
||
</footer>
|
||
</div>
|
||
|
||
<!-- New Diagram Modal -->
|
||
<div id="newDiagramModal" class="modal-overlay no-print">
|
||
<div class="modal-content" style="max-width:720px">
|
||
<div class="p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h3 class="text-lg font-bold flex items-center gap-2"><span>📊</span> ایجاد دیاگرام جدید</h3>
|
||
<button id="closeNewDiagram" class="p-1 rounded-lg hover:opacity-70" style="color:var(--text-muted)">✕</button>
|
||
</div>
|
||
<p class="text-sm mb-4" style="color:var(--text-secondary)">نوع نمودار مورد نظر خود را انتخاب کنید:</p>
|
||
<div class="grid grid-cols-2 gap-3 mb-4" id="diagramTypeGrid"></div>
|
||
<div class="flex gap-3 pt-2">
|
||
<button id="confirmNewDiagram" class="flex-1 bg-gradient-to-l from-green-600 to-emerald-600 text-white py-2.5 px-4 rounded-lg text-sm font-medium">✓ ایجاد دیاگرام</button>
|
||
<button id="cancelNewDiagram" class="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium" style="background:var(--bg-tertiary);color:var(--text-secondary)">❌ انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Properties Panel -->
|
||
<div id="propertiesOverlay" class="properties-overlay no-print">
|
||
<div class="panel-shadow rounded-t-2xl border-t border-r border-l" style="border-color:var(--border-color)">
|
||
<div class="flex justify-center pt-2 pb-1"><div class="w-10 h-1 rounded-full" style="background:var(--border-color)"></div></div>
|
||
<div class="px-5 pb-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="font-bold text-sm flex items-center gap-2" style="color:var(--text-primary)"><span>⚙️</span> ویژگیهای المان</h3>
|
||
<button id="closeProperties" class="p-1 rounded-lg hover:opacity-70" style="color:var(--text-muted)">✕</button>
|
||
</div>
|
||
<div class="grid grid-cols-3 gap-3">
|
||
<div><label class="block text-xs mb-1" style="color:var(--text-muted)">نام</label><input type="text" id="elementName" class="themed-input"></div>
|
||
<div><label class="block text-xs mb-1" style="color:var(--text-muted)">نوع</label><input type="text" id="elementType" class="themed-input-readonly" readonly></div>
|
||
<div><label class="block text-xs mb-1" style="color:var(--text-muted)">موقعیت</label><input type="text" id="elementPosition" class="themed-input-readonly" readonly></div>
|
||
</div>
|
||
<div id="swimlaneColorSection" style="display:none" class="mt-3 pt-3 border-t" style="border-color:var(--border-color)">
|
||
<label class="block text-xs mb-1" style="color:var(--text-muted)">رنگ Swimlane</label>
|
||
<input type="color" id="swimlaneColorPicker" class="w-full h-10 rounded cursor-pointer">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Diagram Settings Modal -->
|
||
<div id="settingsModal" class="modal-overlay no-print"><div class="modal-content"><div class="p-6">
|
||
<div class="flex items-center justify-between mb-4"><h3 class="text-lg font-bold"><span>⚙️</span> تنظیمات دیاگرام</h3><button id="closeSettings" class="p-1 rounded-lg hover:opacity-70" style="color:var(--text-muted)">✕</button></div>
|
||
<div class="space-y-4">
|
||
<div><label class="block text-sm font-medium mb-2">شناسه</label><input type="text" id="diagramId" class="themed-input-readonly" readonly></div>
|
||
<div><label class="block text-sm font-medium mb-2">نوع نمودار</label><input type="text" id="diagramType" class="themed-input-readonly" readonly></div>
|
||
<div><label class="block text-sm font-medium mb-2">عنوان <span class="text-red-500">*</span></label><input type="text" id="diagramTitle" class="themed-input"></div>
|
||
<div><label class="block text-sm font-medium mb-2">توضیحات</label><textarea id="diagramDescription" rows="3" class="themed-input resize-none"></textarea></div>
|
||
<div class="flex gap-3 pt-2"><button id="saveSettings" class="flex-1 bg-gradient-to-l from-indigo-600 to-purple-600 text-white py-2 px-4 rounded-lg text-sm font-medium">💾 ذخیره</button><button id="cancelSettings" class="flex-1 py-2 px-4 rounded-lg text-sm font-medium" style="background:var(--bg-tertiary);color:var(--text-secondary)">❌ انصراف</button></div>
|
||
</div>
|
||
</div></div></div>
|
||
|
||
<!-- File Settings Modal -->
|
||
<div id="fileSettingsModal" class="modal-overlay no-print"><div class="modal-content"><div class="p-6">
|
||
<div class="flex items-center justify-between mb-4"><h3 class="text-lg font-bold"><span>📋</span> تنظیمات فایل پروژه</h3><button id="closeFileSettings" class="p-1 rounded-lg hover:opacity-70" style="color:var(--text-muted)">✕</button></div>
|
||
<div class="space-y-4">
|
||
<div><label class="block text-sm font-medium mb-2">عنوان پروژه <span class="text-red-500">*</span></label><input type="text" id="fileProjectTitle" class="themed-input"></div>
|
||
<div><label class="block text-sm font-medium mb-2">توضیحات</label><textarea id="fileProjectDescription" rows="3" class="themed-input resize-none"></textarea></div>
|
||
<div class="grid grid-cols-2 gap-3"><div><label class="block text-sm font-medium mb-2">نویسنده</label><input type="text" id="fileProjectAuthor" class="themed-input"></div><div><label class="block text-sm font-medium mb-2">سازمان</label><input type="text" id="fileProjectOrganization" class="themed-input"></div></div>
|
||
<div class="themed-card text-xs space-y-1"><div class="flex justify-between"><span style="color:var(--text-muted)">تعداد:</span><span id="fileProjectDiagramCount" class="font-bold">0</span></div><div class="flex justify-between"><span style="color:var(--text-muted)">ایجاد:</span><span id="fileProjectCreatedAt" class="font-bold">-</span></div><div class="flex justify-between"><span style="color:var(--text-muted)">ویرایش:</span><span id="fileProjectUpdatedAt" class="font-bold">-</span></div></div>
|
||
<div class="flex gap-3 pt-2"><button id="saveFileSettings" class="flex-1 bg-gradient-to-l from-teal-600 to-cyan-600 text-white py-2 px-4 rounded-lg text-sm font-medium">💾 ذخیره</button><button id="cancelFileSettings" class="flex-1 py-2 px-4 rounded-lg text-sm font-medium" style="background:var(--bg-tertiary);color:var(--text-secondary)">❌ انصراف</button></div>
|
||
</div>
|
||
</div></div></div>
|
||
|
||
<!-- Save Modal -->
|
||
<div id="saveModal" class="modal-overlay no-print"><div class="modal-content save-modal-content"><div class="p-6">
|
||
<div class="flex items-center justify-between mb-4"><h3 class="text-lg font-bold"><span>💾</span> ذخیره فایل پروژه</h3><button id="closeSaveModal" class="p-1 rounded-lg hover:opacity-70" style="color:var(--text-muted)">✕</button></div>
|
||
<div class="space-y-4">
|
||
<div><label class="block text-sm font-medium mb-2">نام فایل <span class="text-red-500">*</span></label><div class="relative"><input type="text" id="saveFileName" class="themed-input pl-14" dir="ltr" style="font-family:monospace"><span class="absolute left-3 top-1/2 -translate-y-1/2 text-xs px-2 py-0.5 rounded" style="color:var(--text-muted);background:var(--bg-tertiary);font-family:monospace">.json</span></div></div>
|
||
<div class="file-preview"><div class="w-11 h-11 rounded-xl flex items-center justify-center text-2xl flex-shrink-0" style="background:linear-gradient(135deg,#667eea,#764ba2)">📄</div><div class="flex-1 min-w-0"><div class="text-sm font-semibold truncate" style="font-family:monospace;direction:ltr;text-align:left" id="saveFilePreviewName">-</div><div class="text-xs mt-1" style="color:var(--text-muted)" id="saveFilePreviewMeta">-</div></div></div>
|
||
<div class="themed-card text-xs space-y-1.5"><div class="flex justify-between"><span style="color:var(--text-muted)">📊 تعداد:</span><span id="saveDiagramCount" class="font-bold">0</span></div><div class="flex justify-between"><span style="color:var(--text-muted)">📦 حجم:</span><span id="saveFileSize" class="font-bold">-</span></div></div>
|
||
<div class="flex gap-3 pt-2"><button id="confirmSaveBtn" class="flex-1 bg-gradient-to-l from-green-600 to-emerald-600 text-white py-2.5 px-4 rounded-lg text-sm font-medium">💾 ذخیره</button><button id="cancelSaveBtn" class="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium" style="background:var(--bg-tertiary);color:var(--text-secondary)">❌ انصراف</button></div>
|
||
</div>
|
||
</div></div></div>
|
||
|
||
<!-- Export PNG Modal -->
|
||
<div id="exportPngModal" class="modal-overlay no-print"><div class="modal-content save-modal-content"><div class="p-6">
|
||
<div class="flex items-center justify-between mb-4"><h3 class="text-lg font-bold"><span>🖼️</span> خروجی PNG</h3><button id="closeExportPng" class="p-1 rounded-lg hover:opacity-70" style="color:var(--text-muted)">✕</button></div>
|
||
<div class="space-y-4">
|
||
<div><label class="block text-sm font-medium mb-2">رنگ پسزمینه</label><div class="flex items-center gap-3"><input type="color" id="pngBgColor" value="#ffffff" class="w-12 h-10 rounded-lg cursor-pointer border-0 p-0" style="background:transparent"><input type="text" id="pngBgColorText" value="#ffffff" class="themed-input flex-1" dir="ltr" style="font-family:monospace"><button id="pngBgTransparent" class="py-2 px-3 rounded-lg text-xs font-medium" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border-color)">شفاف</button></div></div>
|
||
<div id="pngBgPreview" class="w-full h-16 rounded-lg border-2 flex items-center justify-center text-sm font-medium" style="border-color:var(--border-color);background:#fff;color:#333">پیشنمایش</div>
|
||
<div class="flex gap-3 pt-2"><button id="confirmExportPng" class="flex-1 bg-gradient-to-l from-purple-600 to-pink-600 text-white py-2.5 px-4 rounded-lg text-sm font-medium">🖼️ دانلود</button><button id="cancelExportPng" class="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium" style="background:var(--bg-tertiary);color:var(--text-secondary)">❌ انصراف</button></div>
|
||
</div>
|
||
</div></div></div>
|
||
|
||
<!-- Class Editor Modal -->
|
||
<div id="classEditorModal" class="modal-overlay no-print">
|
||
<div class="modal-content class-editor-modal">
|
||
<div class="p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h3 class="text-lg font-bold flex items-center gap-2"><span>🗂️</span> ویرایشگر Class</h3>
|
||
<button id="closeClassEditor" class="p-1 rounded-lg hover:opacity-70" style="color:var(--text-muted)">✕</button>
|
||
</div>
|
||
<div class="mb-4">
|
||
<label class="block text-sm font-medium mb-2">نام Class</label>
|
||
<input type="text" id="classEditorName" class="themed-input">
|
||
</div>
|
||
<div class="class-editor-tabs">
|
||
<div class="class-editor-tab active" data-tab="attributes">📋 Attributes</div>
|
||
<div class="class-editor-tab" data-tab="methods">⚙️ Methods</div>
|
||
</div>
|
||
<div id="attributesTab" class="class-editor-panel">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<span class="text-sm font-medium" style="color:var(--text-secondary)">ویژگیها</span>
|
||
<button id="addAttrBtn" class="member-btn member-btn-add">+ افزودن</button>
|
||
</div>
|
||
<table class="member-table">
|
||
<thead><tr><th style="width:60px">Visibility</th><th>نام</th><th style="width:100px">نوع</th><th style="width:50px">Static</th><th style="width:40px"></th></tr></thead>
|
||
<tbody id="attributesTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="methodsTab" class="class-editor-panel" style="display:none">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<span class="text-sm font-medium" style="color:var(--text-secondary)">متدها</span>
|
||
<button id="addMethodBtn" class="member-btn member-btn-add">+ افزودن</button>
|
||
</div>
|
||
<table class="member-table">
|
||
<thead><tr><th style="width:60px">Visibility</th><th>نام</th><th style="width:120px">پارامترها</th><th style="width:80px">نوع بازگشت</th><th style="width:50px">Static</th><th style="width:40px"></th></tr></thead>
|
||
<tbody id="methodsTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="flex gap-3 pt-4 mt-4" style="border-top:1px solid var(--border-color)">
|
||
<button id="saveClassEditor" class="flex-1 bg-gradient-to-l from-indigo-600 to-purple-600 text-white py-2 px-4 rounded-lg text-sm font-medium">💾 ذخیره تغییرات</button>
|
||
<button id="cancelClassEditor" class="flex-1 py-2 px-4 rounded-lg text-sm font-medium" style="background:var(--bg-tertiary);color:var(--text-secondary)">❌ انصراف</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ===== DIAGRAM TYPES REGISTRY =====
|
||
const DIAGRAM_TYPES = {
|
||
usecase: { id:'usecase', name:'نمودار مورد کاربرد (Use Case)', nameShort:'Use Case', icon:'⭕', available:true,
|
||
tools:['select','actor','useCase','boundary','association','include','extend','generalization'],
|
||
groups:['selection','uc-elements','uc-relations','display','actions','file'] },
|
||
statemachine: { id:'statemachine', name:'نمودار ماشین حالت (State Machine)', nameShort:'State Machine', icon:'🔄', available:true,
|
||
tools:['select','initialState','state','finalState','compositeState','transition'],
|
||
groups:['selection','sm-elements','sm-relations','display','actions','file'] },
|
||
activity: { id:'activity', name:'نمودار فعالیت (Activity Diagram)', nameShort:'Activity', icon:'🔀', available:true,
|
||
tools:['select','initialNode','finalNode','action','decision','merge','forkJoin','controlFlow','objectFlow'],
|
||
groups:['selection','act-elements','act-flows','display','actions','file'] },
|
||
component: { id:'component', name:'نمودار کامپوننت (Component Diagram)', nameShort:'Component', icon:'🧩', available:true,
|
||
tools:['select','component','interfaceProv','interfaceReq','package','dependency','connector','compAssociation'],
|
||
groups:['selection','comp-elements','comp-relations','display','actions','file'] },
|
||
deployment: { id:'deployment', name:'نمودار استقرار (Deployment Diagram)', nameShort:'Deployment', icon:'🖥️', available:true,
|
||
tools:['select','node','device','execEnv','artifact','commPath','deployRel','manifestRel'],
|
||
groups:['selection','dep-elements','dep-relations','display','actions','file'] },
|
||
class: { id:'class', name:'نمودار کلاس (Class Diagram)', nameShort:'Class', icon:'🗂️', available:true,
|
||
tools:['select','class','interfaceClass','abstractClass','enumeration','clsAssociation','aggregation','composition','inheritance','clsDependency','realization'],
|
||
groups:['selection','cls-elements','cls-relations','display','actions','file'] },
|
||
sequence: { id:'sequence', name:'نمودار توالی (Sequence Diagram)', nameShort:'Sequence', icon:'🔢', available:true,
|
||
tools:['select','participant','activation','fragment','syncMsg','asyncMsg','returnMsg','selfMsg'],
|
||
groups:['selection','seq-elements','seq-messages','display','actions','file'] },
|
||
swimlane: { id:'swimlane', name:'نمودار Swimlane', nameShort:'Swimlane', icon:'▤', available:true,
|
||
tools:['select','swimlane','swAction','swDecision','swForkJoin','swInitial','swFinal','swFlow'],
|
||
groups:['selection','sw-elements','sw-flows','display','actions','file'] }
|
||
};
|
||
|
||
// ===== STATE =====
|
||
const state = {
|
||
diagrams: [], activeDiagramId: null, currentTool: 'select',
|
||
selectedElement: null, isDragging: false, dragStart: null,
|
||
isDrawingRelationship: false, relationshipStart: null, relationshipStartId: null,
|
||
mousePos: { x:0, y:0 }, sidebarVisible: true, gridType: 'dots', darkMode: true,
|
||
collapsedGroups: new Set(), newDiagramSelectedType: 'usecase',
|
||
editingClassId: null, resizingSwimlane: null, resizeHandle: null,
|
||
// ZOOM & PAN
|
||
zoom: 1.0, panX: 0, panY: 0, isPanning: false, panStart: null, spacePressed: false,
|
||
fileMetadata: { title:'', description:'', author:'هادی خزاعی اصل', organization:'رایان پرداز کاوش', createdAt:new Date().toISOString(), updatedAt:new Date().toISOString() }
|
||
};
|
||
|
||
// ===== UTILITIES =====
|
||
function genId(){ return 'diagram-'+Date.now()+'-'+Math.random().toString(36).substr(2,9); }
|
||
function genElId(d){ return d.nextId++; }
|
||
function genTS(){ const n=new Date(); return `${n.getFullYear()}${String(n.getMonth()+1).padStart(2,'0')}${String(n.getDate()).padStart(2,'0')}-${String(n.getHours()).padStart(2,'0')}${String(n.getMinutes()).padStart(2,'0')}`; }
|
||
function sanitize(n){ return n.replace(/[\\/:*?"<>|]/g,'_').replace(/\s+/g,' ').trim().substring(0,100); }
|
||
function fmtDate(iso){ if(!iso) return '-'; try{ return new Date(iso).toLocaleDateString('fa-IR',{year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit'}); }catch{return '-';} }
|
||
function fmtSize(b){ if(b<1024) return b+' B'; if(b<1048576) return (b/1024).toFixed(1)+' KB'; return (b/1048576).toFixed(2)+' MB'; }
|
||
function getActive(){ return state.diagrams.find(d=>d.id===state.activeDiagramId)||null; }
|
||
|
||
// ===== HISTORY (UNDO/REDO) =====
|
||
const MAX_HISTORY = 50;
|
||
|
||
function getDiagramData(d){
|
||
// Extract only the data fields (not history/metadata)
|
||
return {
|
||
actors:d.actors||[], useCases:d.useCases||[], systemBoundary:d.systemBoundary||null,
|
||
states:d.states||[], transitions:d.transitions||[],
|
||
nodes:d.nodes||[], flows:d.flows||[],
|
||
components:d.components||[], interfaces:d.interfaces||[], packages:d.packages||[], compRelations:d.compRelations||[],
|
||
deployNodes:d.deployNodes||[], artifacts:d.artifacts||[], deployRelations:d.deployRelations||[],
|
||
classes:d.classes||[], classRelations:d.classRelations||[],
|
||
participants:d.participants||[], activations:d.activations||[], messages:d.messages||[], fragments:d.fragments||[],
|
||
swimlanes:d.swimlanes||[], swNodes:d.swNodes||[], swFlows:d.swFlows||[],
|
||
relationships:d.relationships||[], nextId:d.nextId
|
||
};
|
||
}
|
||
|
||
function pushHistory(d){
|
||
if(!d) return;
|
||
if(!d._history) d._history = [];
|
||
if(!d._historyIndex && d._historyIndex !== 0) d._historyIndex = -1;
|
||
|
||
// Remove any future states (if we undid some)
|
||
d._history = d._history.slice(0, d._historyIndex + 1);
|
||
|
||
// Push current state
|
||
d._history.push(JSON.stringify(getDiagramData(d)));
|
||
|
||
// Limit history size
|
||
if(d._history.length > MAX_HISTORY){
|
||
d._history.shift();
|
||
} else {
|
||
d._historyIndex++;
|
||
}
|
||
}
|
||
|
||
function restoreFromHistory(d, snapshot){
|
||
const data = JSON.parse(snapshot);
|
||
Object.assign(d, data);
|
||
}
|
||
|
||
function canUndo(){
|
||
const d = getActive();
|
||
return d && d._history && d._historyIndex > 0;
|
||
}
|
||
|
||
function canRedo(){
|
||
const d = getActive();
|
||
return d && d._history && d._historyIndex < d._history.length - 1;
|
||
}
|
||
|
||
function undo(){
|
||
const d = getActive();
|
||
if(!canUndo()) return;
|
||
d._historyIndex--;
|
||
restoreFromHistory(d, d._history[d._historyIndex]);
|
||
state.selectedElement = null;
|
||
hideProps();
|
||
draw();
|
||
updateStatus('↶ بازگشت');
|
||
}
|
||
|
||
function redo(){
|
||
const d = getActive();
|
||
if(!canRedo()) return;
|
||
d._historyIndex++;
|
||
restoreFromHistory(d, d._history[d._historyIndex]);
|
||
state.selectedElement = null;
|
||
hideProps();
|
||
draw();
|
||
updateStatus('↷ از نو');
|
||
}
|
||
|
||
// ===== THEME =====
|
||
function applyTheme(){ document.body.classList.toggle('dark',state.darkMode); document.getElementById('themeToggleBtn').textContent=state.darkMode?'☀️':'🌙'; draw(); }
|
||
document.getElementById('themeToggleBtn').addEventListener('click',()=>{ state.darkMode=!state.darkMode; applyTheme(); });
|
||
|
||
// ===== GRID =====
|
||
document.querySelectorAll('.grid-type-option').forEach(o=>o.addEventListener('click',()=>{ state.gridType=o.dataset.gridType; document.querySelectorAll('.grid-type-option').forEach(x=>x.classList.toggle('active',x===o)); draw(); }));
|
||
function setGridUI(t){ document.querySelectorAll('.grid-type-option').forEach(o=>o.classList.toggle('active',o.dataset.gridType===t)); }
|
||
|
||
// ===== COLLAPSIBLE GROUPS =====
|
||
document.querySelectorAll('.tool-group-header').forEach(h=>h.addEventListener('click',()=>{ const g=h.parentElement; const id=g.dataset.group; if(state.collapsedGroups.has(id)){state.collapsedGroups.delete(id);g.classList.remove('collapsed');}else{state.collapsedGroups.add(id);g.classList.add('collapsed');} }));
|
||
|
||
// ===== TOOL VISIBILITY =====
|
||
function updateToolVisibility(){
|
||
const d = getActive();
|
||
const allGroups = document.querySelectorAll('.tool-group');
|
||
const allBtns = document.querySelectorAll('.tool-btn[data-tool]');
|
||
if(!d){
|
||
// Hide all diagram-specific groups, show only selection/display/actions/file
|
||
allGroups.forEach(g=>{
|
||
const dt=g.dataset.diagramTypes;
|
||
if(dt) g.style.display='none';
|
||
else g.style.display='';
|
||
});
|
||
allBtns.forEach(b=>{ b.style.display = b.dataset.tool==='select'?'':'none'; });
|
||
return;
|
||
}
|
||
const dt = DIAGRAM_TYPES[d.diagramType];
|
||
if(!dt) return;
|
||
const allowedGroups = dt.groups;
|
||
allGroups.forEach(g=>{
|
||
const req = g.dataset.diagramTypes;
|
||
if(!req){ g.style.display=''; return; }
|
||
g.style.display = allowedGroups.includes(g.dataset.group)?'':'none';
|
||
});
|
||
const allowedTools = dt.tools;
|
||
allBtns.forEach(b=>{
|
||
if(b.dataset.tool==='select'){ b.style.display=''; return; }
|
||
const req = b.dataset.types;
|
||
if(!req||req==='all'){ b.style.display=''; return; }
|
||
const types = req.split(',');
|
||
b.style.display = types.includes(d.diagramType)?'':'none';
|
||
if(b.style.display==='none' && state.currentTool===b.dataset.tool) setCurrentTool('select');
|
||
});
|
||
}
|
||
|
||
// ===== ZOOM & PAN =====
|
||
const MIN_ZOOM = 0.25;
|
||
const MAX_ZOOM = 4.0;
|
||
|
||
function setZoom(newZoom, centerX, centerY){
|
||
const oldZoom = state.zoom;
|
||
newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
|
||
|
||
if(centerX !== undefined && centerY !== undefined){
|
||
// Zoom towards center point
|
||
const worldX = (centerX - state.panX) / oldZoom;
|
||
const worldY = (centerY - state.panY) / oldZoom;
|
||
state.panX = centerX - worldX * newZoom;
|
||
state.panY = centerY - worldY * newZoom;
|
||
}
|
||
|
||
state.zoom = newZoom;
|
||
updateZoomDisplay();
|
||
draw();
|
||
}
|
||
|
||
function resetZoom(){
|
||
state.zoom = 1.0;
|
||
state.panX = 0;
|
||
state.panY = 0;
|
||
updateZoomDisplay();
|
||
draw();
|
||
}
|
||
|
||
function updateZoomDisplay(){
|
||
document.getElementById('zoomDisplay').textContent = Math.round(state.zoom * 100) + '%';
|
||
}
|
||
|
||
document.getElementById('zoomInBtn').addEventListener('click', ()=>{
|
||
const rect = canvas.getBoundingClientRect();
|
||
setZoom(state.zoom * 1.2, rect.width/2, rect.height/2);
|
||
});
|
||
document.getElementById('zoomOutBtn').addEventListener('click', ()=>{
|
||
const rect = canvas.getBoundingClientRect();
|
||
setZoom(state.zoom / 1.2, rect.width/2, rect.height/2);
|
||
});
|
||
document.getElementById('zoomDisplay').addEventListener('click', resetZoom);
|
||
|
||
// ===== NEW DIAGRAM MODAL =====
|
||
function buildDiagramTypeGrid(){
|
||
const grid = document.getElementById('diagramTypeGrid');
|
||
grid.innerHTML = '';
|
||
Object.values(DIAGRAM_TYPES).forEach(dt=>{
|
||
const card = document.createElement('div');
|
||
card.className = 'diagram-type-card' + (dt.available?'':' disabled') + (dt.id===state.newDiagramSelectedType&&dt.available?' selected':'');
|
||
card.dataset.diagramType = dt.id;
|
||
card.innerHTML = `<div class="flex items-start gap-3">
|
||
<div class="diagram-type-icon">${dt.icon}</div>
|
||
<div class="flex-1">
|
||
<h4 class="font-bold text-sm mb-1" style="color:var(--text-primary)">${dt.name}</h4>
|
||
${dt.available?'':'<span style="display:inline-block;font-size:9px;padding:2px 6px;border-radius:4px;background:var(--bg-tertiary);color:var(--text-muted);font-weight:600;margin-top:6px">بهزودی</span>'}
|
||
</div>
|
||
${dt.available&&dt.id===state.newDiagramSelectedType?'<div style="color:#667eea">✓</div>':''}
|
||
</div>`;
|
||
if(dt.available) card.addEventListener('click',()=>{ state.newDiagramSelectedType=dt.id; buildDiagramTypeGrid(); });
|
||
grid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function showNewDiagramModal(){ state.newDiagramSelectedType='usecase'; buildDiagramTypeGrid(); document.getElementById('newDiagramModal').classList.add('visible'); }
|
||
function hideNewDiagramModal(){ document.getElementById('newDiagramModal').classList.remove('visible'); }
|
||
document.getElementById('newDiagramBtn').addEventListener('click', showNewDiagramModal);
|
||
document.getElementById('closeNewDiagram').addEventListener('click', hideNewDiagramModal);
|
||
document.getElementById('cancelNewDiagram').addEventListener('click', hideNewDiagramModal);
|
||
document.getElementById('newDiagramModal').addEventListener('click', e=>{ if(e.target.id==='newDiagramModal') hideNewDiagramModal(); });
|
||
document.getElementById('confirmNewDiagram').addEventListener('click',()=>{
|
||
const dt=DIAGRAM_TYPES[state.newDiagramSelectedType];
|
||
if(!dt||!dt.available){ alert('این نوع نمودار هنوز پشتیبانی نمیشود'); return; }
|
||
hideNewDiagramModal(); createNewDiagram(state.newDiagramSelectedType);
|
||
});
|
||
|
||
// ===== DIAGRAM MANAGEMENT =====
|
||
function createNewDiagram(type='usecase'){
|
||
const dt=DIAGRAM_TYPES[type]; if(!dt) return;
|
||
const count=state.diagrams.filter(d=>d.diagramType===type).length+1;
|
||
const nd = {
|
||
id:genId(), diagramType:type, title:`${dt.nameShort} جدید ${count}`, description:'',
|
||
createdAt:new Date().toISOString(), updatedAt:new Date().toISOString(),
|
||
author:state.fileMetadata.author, version:'1.0', nextId:1,
|
||
actors:[], useCases:[], systemBoundary:null,
|
||
states:[], transitions:[],
|
||
nodes:[], flows:[],
|
||
components:[], interfaces:[], packages:[], compRelations:[],
|
||
deployNodes:[], artifacts:[], deployRelations:[],
|
||
classes:[], classRelations:[],
|
||
participants:[], activations:[], messages:[], fragments:[],
|
||
swimlanes:[], swNodes:[], swFlows:[],
|
||
relationships:[],
|
||
_history: [], _historyIndex: -1
|
||
};
|
||
state.diagrams.push(nd);
|
||
pushHistory(nd); // Initial state
|
||
state.fileMetadata.updatedAt=new Date().toISOString();
|
||
switchDiagram(nd.id); renderTabs(); updateInfo();
|
||
updateStatus(`✅ ${dt.nameShort}: "${nd.title}" ایجاد شد`);
|
||
}
|
||
|
||
function deleteDiagram(id){
|
||
const d=state.diagrams.find(x=>x.id===id); if(!d) return;
|
||
if(!confirm(`حذف "${d.title}"؟`)) return;
|
||
state.diagrams=state.diagrams.filter(x=>x.id!==id);
|
||
if(state.activeDiagramId===id){
|
||
if(state.diagrams.length>0) switchDiagram(state.diagrams[0].id);
|
||
else{
|
||
state.activeDiagramId=null;
|
||
state.selectedElement=null;
|
||
hideProps();
|
||
// Reset zoom/pan
|
||
state.zoom = 1.0;
|
||
state.panX = 0;
|
||
state.panY = 0;
|
||
updateZoomDisplay();
|
||
updateToolVisibility(); // Hide diagram-specific tools
|
||
}
|
||
}
|
||
renderTabs(); updateInfo(); updateVis(); updateStatus('🗑️ حذف شد');
|
||
}
|
||
|
||
function switchDiagram(id){
|
||
if(state.activeDiagramId===id) return;
|
||
state.activeDiagramId=id; state.selectedElement=null; state.currentTool='select';
|
||
state.isDragging=false; state.isDrawingRelationship=false;
|
||
// Reset zoom/pan when switching diagrams
|
||
state.zoom = 1.0;
|
||
state.panX = 0;
|
||
state.panY = 0;
|
||
updateZoomDisplay();
|
||
setActiveUI('select'); hideProps(); renderTabs(); updateVis(); updateToolVisibility();
|
||
const d=getActive(); if(d){ const dt=DIAGRAM_TYPES[d.diagramType]; updateStatus(`📊 ${dt?.nameShort}: ${d.title}`); }
|
||
scheduleResize();
|
||
}
|
||
|
||
function updateVis(){ const c=document.getElementById('canvasWrapper'),e=document.getElementById('emptyState'); if(!state.diagrams.length){c.classList.add('hidden');e.classList.remove('hidden');}else{c.classList.remove('hidden');e.classList.add('hidden');} }
|
||
function toggleSidebar(){ state.sidebarVisible=!state.sidebarVisible; document.getElementById('sidebarWrapper').classList.toggle('collapsed',!state.sidebarVisible); setTimeout(scheduleResize,320); }
|
||
document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar);
|
||
|
||
// Undo/Redo buttons
|
||
document.getElementById('undoBtn').addEventListener('click', undo);
|
||
document.getElementById('redoBtn').addEventListener('click', redo);
|
||
|
||
// ===== TABS =====
|
||
function renderTabs(){
|
||
const c=document.getElementById('tabsContainer'); c.innerHTML='';
|
||
state.diagrams.forEach(d=>{
|
||
const dt=DIAGRAM_TYPES[d.diagramType]||DIAGRAM_TYPES.usecase;
|
||
const tab=document.createElement('div');
|
||
tab.className='diagram-tab'+(d.id===state.activeDiagramId?' active':'');
|
||
tab.innerHTML=`<span style="font-size:14px">${dt.icon}</span><span class="tab-title" title="${d.title}">${d.title||'بدون عنوان'}</span><span class="tab-type-badge">${dt.nameShort}</span><span class="tab-close" title="حذف">×</span>`;
|
||
tab.querySelector('.tab-close').addEventListener('click',e=>{e.stopPropagation();deleteDiagram(d.id);});
|
||
tab.addEventListener('click',()=>switchDiagram(d.id));
|
||
tab.addEventListener('dblclick',e=>{e.stopPropagation();switchDiagram(d.id);showSettings();});
|
||
c.appendChild(tab);
|
||
});
|
||
const at=c.querySelector('.diagram-tab.active'); if(at) at.scrollIntoView({behavior:'smooth',block:'nearest',inline:'center'});
|
||
}
|
||
|
||
function updateInfo(){
|
||
const a=getActive(); let info=`دیاگرامها: ${state.diagrams.length}`;
|
||
if(state.fileMetadata.title) info=`📁 ${state.fileMetadata.title} | `+info;
|
||
if(a){ const dt=DIAGRAM_TYPES[a.diagramType]||DIAGRAM_TYPES.usecase;
|
||
let ec=0;
|
||
if(a.diagramType==='usecase') ec=a.actors.length+a.useCases.length+(a.systemBoundary?1:0)+a.relationships.length;
|
||
else if(a.diagramType==='statemachine') ec=a.states.length+a.transitions.length;
|
||
else if(a.diagramType==='activity') ec=(a.nodes||[]).length+(a.flows||[]).length;
|
||
else if(a.diagramType==='component') ec=(a.components||[]).length+(a.interfaces||[]).length+(a.packages||[]).length+(a.compRelations||[]).length;
|
||
else if(a.diagramType==='deployment') ec=(a.deployNodes||[]).length+(a.artifacts||[]).length+(a.deployRelations||[]).length;
|
||
else if(a.diagramType==='class') ec=(a.classes||[]).length+(a.classRelations||[]).length;
|
||
else if(a.diagramType==='sequence') ec=(a.participants||[]).length+(a.activations||[]).length+(a.messages||[]).length+(a.fragments||[]).length;
|
||
else if(a.diagramType==='swimlane') ec=(a.swimlanes||[]).length+(a.swNodes||[]).length+(a.swFlows||[]).length;
|
||
info+=` | ${dt.nameShort}: ${a.title} | المانها: ${ec}`;
|
||
}
|
||
document.getElementById('diagramInfo').textContent=info;
|
||
}
|
||
function updateStatus(t){ document.getElementById('statusText').textContent=t; }
|
||
|
||
// ===== CANVAS =====
|
||
const canvas=document.getElementById('canvas'), ctx=canvas.getContext('2d'), cw=document.getElementById('canvasWrapper');
|
||
function resizeCanvas(){ if(cw.classList.contains('hidden')||cw.offsetParent===null) return false; const w=cw.clientWidth,h=cw.clientHeight; if(w<=0||h<=0) return false; if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;} draw(); return true; }
|
||
function scheduleResize(){ requestAnimationFrame(()=>requestAnimationFrame(()=>{ if(!resizeCanvas()) setTimeout(resizeCanvas,50); })); }
|
||
window.addEventListener('resize', scheduleResize);
|
||
|
||
// ===== PROPERTIES =====
|
||
const propsOvl=document.getElementById('propertiesOverlay');
|
||
function showProps(){ propsOvl.classList.add('visible'); }
|
||
function hideProps(){ propsOvl.classList.remove('visible'); }
|
||
document.getElementById('closeProperties').addEventListener('click',()=>{ state.selectedElement=null; hideProps(); draw(); });
|
||
function updatePropsPanel(){
|
||
if(!state.selectedElement){ hideProps(); return; }
|
||
showProps();
|
||
document.getElementById('elementName').value=state.selectedElement.name||'';
|
||
const tn={actor:'Actor',useCase:'Use Case',systemBoundary:'System Boundary',initialState:'Initial State',state:'State',finalState:'Final State',compositeState:'Composite State',initialNode:'Initial Node',finalNode:'Final Node',action:'Action',decision:'Decision',merge:'Merge',forkJoin:'Fork/Join',component:'Component',interfaceProv:'Interface Provided',interfaceReq:'Interface Required',package:'Package',node:'Node',device:'Device',execEnv:'Execution Env',artifact:'Artifact',class:'Class',interfaceClass:'Interface',abstractClass:'Abstract Class',enumeration:'Enumeration',participant:'Participant',activation:'Activation',fragment:'Combined Fragment',swimlane:'Swimlane',swAction:'Action',swDecision:'Decision',swForkJoin:'Fork/Join',swInitial:'Initial Node',swFinal:'Final Node'};
|
||
document.getElementById('elementType').value=tn[state.selectedElement.elType]||state.selectedElement.elType;
|
||
document.getElementById('elementPosition').value=state.selectedElement.x!==undefined?`${Math.round(state.selectedElement.x)}, ${Math.round(state.selectedElement.y)}`:'N/A';
|
||
|
||
const colorSection = document.getElementById('swimlaneColorSection');
|
||
if(state.selectedElement.elType==='swimlane'){
|
||
colorSection.style.display='block';
|
||
document.getElementById('swimlaneColorPicker').value=state.selectedElement.color||'#ebf8ff';
|
||
} else {
|
||
colorSection.style.display='none';
|
||
}
|
||
}
|
||
|
||
document.getElementById('swimlaneColorPicker').addEventListener('input',e=>{
|
||
if(!state.selectedElement||state.selectedElement.elType!=='swimlane') return;
|
||
const d=getActive(); if(!d) return;
|
||
const i=(d.swimlanes||[]).findIndex(s=>s.id===state.selectedElement.id);
|
||
if(i>=0){ d.swimlanes[i].color=e.target.value; draw(); }
|
||
});
|
||
|
||
// ===== TOOL SELECTION =====
|
||
const toolNames={select:'انتخاب',actor:'Actor',useCase:'Use Case',boundary:'Boundary',association:'Association',include:'Include',extend:'Extend',generalization:'Generalization',initialState:'Initial State',state:'State',finalState:'Final State',compositeState:'Composite State',transition:'Transition',initialNode:'Initial Node',finalNode:'Final Node',action:'Action',decision:'Decision',merge:'Merge',forkJoin:'Fork/Join',controlFlow:'Control Flow',objectFlow:'Object Flow',component:'Component',interfaceProv:'Interface Provided',interfaceReq:'Interface Required',package:'Package',dependency:'Dependency',connector:'Connector',compAssociation:'Association',node:'Node',device:'Device',execEnv:'Execution Env',artifact:'Artifact',commPath:'Communication Path',deployRel:'Deployment',manifestRel:'Manifestation',class:'Class',interfaceClass:'Interface',abstractClass:'Abstract Class',enumeration:'Enumeration',clsAssociation:'Association',aggregation:'Aggregation',composition:'Composition',inheritance:'Inheritance',clsDependency:'Dependency',realization:'Realization',participant:'Participant',activation:'Activation',fragment:'Fragment',syncMsg:'Synchronous',asyncMsg:'Asynchronous',returnMsg:'Return',selfMsg:'Self Message',swimlane:'Swimlane',swAction:'Action',swDecision:'Decision',swForkJoin:'Fork/Join',swInitial:'Initial Node',swFinal:'Final Node',swFlow:'Control Flow'};
|
||
function setActiveUI(t){ document.querySelectorAll('.tool-btn').forEach(b=>b.classList.remove('active')); const b=document.getElementById(t+'Tool'); if(b) b.classList.add('active'); }
|
||
function setCurrentTool(t){ state.currentTool=t; setActiveUI(t); updateStatus(`آماده | ابزار: ${toolNames[t]||t}`); }
|
||
function switchToSelect(){ setCurrentTool('select'); }
|
||
document.querySelectorAll('.tool-btn[data-tool]').forEach(b=>b.addEventListener('click',()=>setCurrentTool(b.dataset.tool)));
|
||
|
||
// ===== MODALS =====
|
||
const settingsModal=document.getElementById('settingsModal');
|
||
function showSettings(){ const d=getActive(); if(!d){alert('دیاگرامی نیست');return;} const dt=DIAGRAM_TYPES[d.diagramType]||DIAGRAM_TYPES.usecase; document.getElementById('diagramId').value=d.id; document.getElementById('diagramType').value=dt.name; document.getElementById('diagramTitle').value=d.title; document.getElementById('diagramDescription').value=d.description; settingsModal.classList.add('visible'); }
|
||
function hideSettings(){ settingsModal.classList.remove('visible'); }
|
||
document.getElementById('settingsBtn').addEventListener('click',showSettings);
|
||
document.getElementById('closeSettings').addEventListener('click',hideSettings);
|
||
document.getElementById('cancelSettings').addEventListener('click',hideSettings);
|
||
document.getElementById('saveSettings').addEventListener('click',()=>{
|
||
const d=getActive(); if(!d) return;
|
||
const t=document.getElementById('diagramTitle').value.trim();
|
||
if(!t){alert('عنوان را وارد کنید');return;}
|
||
d.title=t; d.description=document.getElementById('diagramDescription').value.trim();
|
||
d.updatedAt=new Date().toISOString();
|
||
pushHistory(d); // Save change
|
||
hideSettings(); renderTabs(); updateInfo(); updateStatus('✅ ذخیره شد');
|
||
});
|
||
settingsModal.addEventListener('click',e=>{if(e.target===settingsModal)hideSettings();});
|
||
|
||
const fileSettingsModal=document.getElementById('fileSettingsModal');
|
||
function showFileSettings(){ document.getElementById('fileProjectTitle').value=state.fileMetadata.title||''; document.getElementById('fileProjectDescription').value=state.fileMetadata.description||''; document.getElementById('fileProjectAuthor').value=state.fileMetadata.author||''; document.getElementById('fileProjectOrganization').value=state.fileMetadata.organization||''; document.getElementById('fileProjectDiagramCount').textContent=state.diagrams.length; document.getElementById('fileProjectCreatedAt').textContent=fmtDate(state.fileMetadata.createdAt); document.getElementById('fileProjectUpdatedAt').textContent=fmtDate(state.fileMetadata.updatedAt); fileSettingsModal.classList.add('visible'); }
|
||
function hideFileSettings(){ fileSettingsModal.classList.remove('visible'); }
|
||
document.getElementById('fileSettingsBtn').addEventListener('click',showFileSettings);
|
||
document.getElementById('closeFileSettings').addEventListener('click',hideFileSettings);
|
||
document.getElementById('cancelFileSettings').addEventListener('click',hideFileSettings);
|
||
document.getElementById('saveFileSettings').addEventListener('click',()=>{ const t=document.getElementById('fileProjectTitle').value.trim(); if(!t){alert('عنوان را وارد کنید');return;} state.fileMetadata.title=t; state.fileMetadata.description=document.getElementById('fileProjectDescription').value.trim(); state.fileMetadata.author=document.getElementById('fileProjectAuthor').value.trim()||'هادی خزاعی اصل'; state.fileMetadata.organization=document.getElementById('fileProjectOrganization').value.trim()||'رایان پرداز کاوش'; state.fileMetadata.updatedAt=new Date().toISOString(); hideFileSettings(); updateInfo(); updateStatus('✅ ذخیره شد'); });
|
||
fileSettingsModal.addEventListener('click',e=>{if(e.target===fileSettingsModal)hideFileSettings();});
|
||
|
||
const saveModal=document.getElementById('saveModal'), saveFN=document.getElementById('saveFileName');
|
||
function showSaveModal(){ if(!state.diagrams.length){alert('دیاگرامی نیست');return;} const unt=state.diagrams.filter(d=>!d.title?.trim()); if(unt.length){alert(`${unt.length} دیاگرام بدون عنوان`);return;} saveFN.value=(state.fileMetadata.title?sanitize(state.fileMetadata.title):'uml-diagrams')+'-'+genTS(); updateSavePreview(); document.getElementById('saveDiagramCount').textContent=state.diagrams.length; saveModal.classList.add('visible'); setTimeout(()=>{saveFN.focus();saveFN.select();},100); }
|
||
function hideSaveModal(){ saveModal.classList.remove('visible'); }
|
||
function buildSaveData(){
|
||
return {
|
||
version:'5.2', fileTitle:state.fileMetadata.title, fileDescription:state.fileMetadata.description,
|
||
author:state.fileMetadata.author, organization:state.fileMetadata.organization,
|
||
createdAt:state.fileMetadata.createdAt, updatedAt:new Date().toISOString(),
|
||
preferences:{gridType:state.gridType,darkMode:state.darkMode},
|
||
activeDiagramId:state.activeDiagramId,
|
||
diagrams:state.diagrams.map(d=>{
|
||
const data = getDiagramData(d);
|
||
return {
|
||
id:d.id, diagramType:d.diagramType, title:d.title, description:d.description,
|
||
createdAt:d.createdAt, updatedAt:new Date().toISOString(),
|
||
author:d.author, version:d.version,
|
||
...data
|
||
};
|
||
})
|
||
};
|
||
}
|
||
function updateSavePreview(){ const sn=sanitize(saveFN.value.trim()); document.getElementById('saveFilePreviewName').textContent=sn?sn+'.json':'(نام وارد نشده)'; document.getElementById('saveFilePreviewMeta').textContent=`JSON • ${new Date().toLocaleString('fa-IR',{year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}`; try{document.getElementById('saveFileSize').textContent=fmtSize(new Blob([JSON.stringify(buildSaveData(),null,2)]).size);}catch{document.getElementById('saveFileSize').textContent='-';} }
|
||
saveFN.addEventListener('input',updateSavePreview);
|
||
document.getElementById('closeSaveModal').addEventListener('click',hideSaveModal);
|
||
document.getElementById('cancelSaveBtn').addEventListener('click',hideSaveModal);
|
||
saveModal.addEventListener('click',e=>{if(e.target===saveModal)hideSaveModal();});
|
||
document.getElementById('confirmSaveBtn').addEventListener('click',()=>{ const sn=sanitize(saveFN.value.trim()); if(!sn){alert('نام معتبر');saveFN.focus();return;} const b=new Blob([JSON.stringify(buildSaveData(),null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(b); a.download=sn+'.json'; a.click(); URL.revokeObjectURL(a.href); hideSaveModal(); updateStatus(`✅ "${sn}.json" ذخیره شد`); });
|
||
document.getElementById('saveBtn').addEventListener('click',showSaveModal);
|
||
|
||
// Export PNG
|
||
const expModal=document.getElementById('exportPngModal'), pngC=document.getElementById('pngBgColor'), pngCT=document.getElementById('pngBgColorText'), pngP=document.getElementById('pngBgPreview');
|
||
let pngTrans=false;
|
||
function updPng(){ const c=pngC.value; pngCT.value=c; pngP.style.background=c; const r=parseInt(c.substr(1,2),16),g=parseInt(c.substr(3,2),16),b=parseInt(c.substr(5,2),16); pngP.style.color=(0.299*r+0.587*g+0.114*b)/255>0.5?'#333':'#eee'; pngTrans=false; }
|
||
pngC.addEventListener('input',updPng);
|
||
pngCT.addEventListener('input',()=>{ if(/^#[0-9a-fA-F]{6}$/.test(pngCT.value.trim())){pngC.value=pngCT.value.trim();updPng();} });
|
||
document.getElementById('pngBgTransparent').addEventListener('click',()=>{ pngTrans=true; pngP.style.background='repeating-conic-gradient(#ccc 0% 25%,#fff 0% 50%) 0 0/16px 16px'; pngP.style.color='#666'; pngCT.value='transparent'; });
|
||
function showExpPng(){ const d=getActive(); if(!d){alert('دیاگرامی نیست');return;} pngC.value=state.darkMode?'#1a202c':'#ffffff'; pngTrans=false; updPng(); expModal.classList.add('visible'); }
|
||
function hideExpPng(){ expModal.classList.remove('visible'); }
|
||
document.getElementById('exportPngBtn').addEventListener('click',showExpPng);
|
||
document.getElementById('closeExportPng').addEventListener('click',hideExpPng);
|
||
document.getElementById('cancelExportPng').addEventListener('click',hideExpPng);
|
||
expModal.addEventListener('click',e=>{if(e.target===expModal)hideExpPng();});
|
||
document.getElementById('confirmExportPng').addEventListener('click',()=>{
|
||
const d=getActive(); if(!d) return;
|
||
const ts=state.selectedElement; state.selectedElement=null;
|
||
// Export without zoom/pan (1:1 scale)
|
||
const oc=document.createElement('canvas'); oc.width=canvas.width; oc.height=canvas.height; const ox=oc.getContext('2d');
|
||
if(!pngTrans){ox.fillStyle=pngC.value;ox.fillRect(0,0,oc.width,oc.height);}
|
||
const tc=getTC(); drawGrid(ox,oc.width,oc.height,tc,pngTrans);
|
||
// Draw diagram at 1:1 scale (no zoom)
|
||
const savedZoom = state.zoom, savedPanX = state.panX, savedPanY = state.panY;
|
||
state.zoom = 1; state.panX = 0; state.panY = 0;
|
||
drawDiag(ox,d,tc);
|
||
state.zoom = savedZoom; state.panX = savedPanX; state.panY = savedPanY;
|
||
const a=document.createElement('a'); a.download=`${d.title}-${Date.now()}.png`; a.href=oc.toDataURL('image/png'); a.click();
|
||
state.selectedElement=ts; draw(); hideExpPng(); updateStatus('✅ PNG ذخیره شد');
|
||
});
|
||
|
||
// Load
|
||
document.getElementById('loadBtn').addEventListener('click',()=>document.getElementById('fileInput').click());
|
||
document.getElementById('fileInput').addEventListener('change',e=>{
|
||
const f=e.target.files[0]; if(!f) return;
|
||
if(state.diagrams.length&&!confirm('جایگزین شود؟')){e.target.value='';return;}
|
||
const r=new FileReader(); r.onload=ev=>{
|
||
try{
|
||
const d=JSON.parse(ev.target.result);
|
||
state.fileMetadata={title:d.fileTitle||d.title||'',description:d.fileDescription||d.description||'',author:d.author||'هادی خزاعی اصل',organization:d.organization||'رایان پرداز کاوش',createdAt:d.createdAt||new Date().toISOString(),updatedAt:d.updatedAt||new Date().toISOString()};
|
||
if(d.preferences){if(d.preferences.gridType){state.gridType=d.preferences.gridType;setGridUI(state.gridType);}if(typeof d.preferences.darkMode==='boolean'){state.darkMode=d.preferences.darkMode;applyTheme();}}
|
||
if(d.diagrams&&Array.isArray(d.diagrams)){
|
||
state.diagrams=d.diagrams.map(di=>{
|
||
const loaded = {
|
||
...di,
|
||
diagramType:di.diagramType||di.type||'usecase',
|
||
states:di.states||[], transitions:di.transitions||[],
|
||
actors:di.actors||[], useCases:di.useCases||[],
|
||
nodes:di.nodes||[], flows:di.flows||[],
|
||
components:di.components||[], interfaces:di.interfaces||[], packages:di.packages||[], compRelations:di.compRelations||[],
|
||
deployNodes:di.deployNodes||[], artifacts:di.artifacts||[], deployRelations:di.deployRelations||[],
|
||
classes:di.classes||[], classRelations:di.classRelations||[],
|
||
participants:di.participants||[], activations:di.activations||[], messages:di.messages||[], fragments:di.fragments||[],
|
||
swimlanes:di.swimlanes||[], swNodes:di.swNodes||[], swFlows:di.swFlows||[],
|
||
_history: [], _historyIndex: -1
|
||
};
|
||
return loaded;
|
||
});
|
||
state.activeDiagramId=d.activeDiagramId||state.diagrams[0]?.id||null;
|
||
// Initialize history for loaded diagrams
|
||
state.diagrams.forEach(di => pushHistory(di));
|
||
} else if(d.id||d.actors){
|
||
const loaded = {
|
||
id:d.id||genId(), diagramType:d.diagramType||d.type||'usecase',
|
||
title:d.title||'وارد شده', description:d.description||'',
|
||
createdAt:d.createdAt||new Date().toISOString(), updatedAt:new Date().toISOString(),
|
||
author:d.author||'هادی خزاعی اصل', version:'1.0',
|
||
actors:d.actors||[], useCases:d.useCases||[], systemBoundary:d.systemBoundary||null,
|
||
states:d.states||[], transitions:d.transitions||[],
|
||
nodes:d.nodes||[], flows:d.flows||[],
|
||
components:d.components||[], interfaces:d.interfaces||[], packages:d.packages||[], compRelations:d.compRelations||[],
|
||
deployNodes:d.deployNodes||[], artifacts:d.artifacts||[], deployRelations:d.deployRelations||[],
|
||
classes:d.classes||[], classRelations:d.classRelations||[],
|
||
participants:d.participants||[], activations:d.activations||[], messages:d.messages||[], fragments:d.fragments||[],
|
||
swimlanes:d.swimlanes||[], swNodes:d.swNodes||[], swFlows:d.swFlows||[],
|
||
_history: [], _historyIndex: -1,
|
||
nextId:d.nextId||1
|
||
};
|
||
state.diagrams=[loaded];
|
||
state.activeDiagramId=loaded.id;
|
||
pushHistory(loaded);
|
||
} else throw new Error('فرمت نامعتبر');
|
||
state.selectedElement=null; state.currentTool='select'; setActiveUI('select');
|
||
state.zoom = 1; state.panX = 0; state.panY = 0; updateZoomDisplay();
|
||
renderTabs(); updateInfo(); updateVis(); updatePropsPanel(); updateToolVisibility(); scheduleResize();
|
||
updateStatus(`✅ بارگذاری شد`);
|
||
}catch(err){alert('خطا: '+err.message);}
|
||
}; r.readAsText(f); e.target.value='';
|
||
});
|
||
|
||
// ===== DRAWING ENGINE =====
|
||
function getTC(){
|
||
const cs=getComputedStyle(document.body);
|
||
return { actorStroke:cs.getPropertyValue('--actor-stroke').trim(), ucFill:cs.getPropertyValue('--uc-fill').trim(), ucStroke:cs.getPropertyValue('--uc-stroke').trim(), relStroke:cs.getPropertyValue('--rel-stroke').trim(), sbStroke:cs.getPropertyValue('--sb-stroke').trim(), labelBg:cs.getPropertyValue('--label-bg').trim(), textPrimary:cs.getPropertyValue('--text-primary').trim(), textMuted:cs.getPropertyValue('--text-muted').trim(), gridLine:cs.getPropertyValue('--grid-line').trim(), canvasBg:cs.getPropertyValue('--canvas-bg').trim(), selected:'#667eea', stateFill:cs.getPropertyValue('--state-fill').trim(), stateStroke:cs.getPropertyValue('--state-stroke').trim(), stateInit:cs.getPropertyValue('--state-init').trim(), stateFinalOuter:cs.getPropertyValue('--state-final-outer').trim(), stateFinalInner:cs.getPropertyValue('--state-final-inner').trim(), transStroke:cs.getPropertyValue('--transition-stroke').trim(), transLabel:cs.getPropertyValue('--transition-label').trim(), activityFill:cs.getPropertyValue('--activity-fill').trim(), activityStroke:cs.getPropertyValue('--activity-stroke').trim(), decisionFill:cs.getPropertyValue('--decision-fill').trim(), decisionStroke:cs.getPropertyValue('--decision-stroke').trim(), forkStroke:cs.getPropertyValue('--fork-stroke').trim(), flowStroke:cs.getPropertyValue('--flow-stroke').trim(), flowLabel:cs.getPropertyValue('--flow-label').trim(), compFill:cs.getPropertyValue('--comp-fill').trim(), compStroke:cs.getPropertyValue('--comp-stroke').trim(), compIcon:cs.getPropertyValue('--comp-icon').trim(), ifaceStroke:cs.getPropertyValue('--iface-stroke').trim(), pkgFill:cs.getPropertyValue('--pkg-fill').trim(), pkgStroke:cs.getPropertyValue('--pkg-stroke').trim(), depStroke:cs.getPropertyValue('--dep-stroke').trim(), nodeFaceTop:cs.getPropertyValue('--node-face-top').trim(), nodeFaceFront:cs.getPropertyValue('--node-face-front').trim(), nodeFaceSide:cs.getPropertyValue('--node-face-side').trim(), nodeStroke:cs.getPropertyValue('--node-stroke').trim(), artifactFill:cs.getPropertyValue('--artifact-fill').trim(), artifactStroke:cs.getPropertyValue('--artifact-stroke').trim(), classFill:cs.getPropertyValue('--class-fill').trim(), classStroke:cs.getPropertyValue('--class-stroke').trim(), classHeader:cs.getPropertyValue('--class-header').trim(), classAttr:cs.getPropertyValue('--class-attr').trim(), classMethod:cs.getPropertyValue('--class-method').trim(), seqLifeline:cs.getPropertyValue('--seq-lifeline').trim(), seqParticipantFill:cs.getPropertyValue('--seq-participant-fill').trim(), seqParticipantStroke:cs.getPropertyValue('--seq-participant-stroke').trim(), seqActivation:cs.getPropertyValue('--seq-activation').trim(), seqActivationStroke:cs.getPropertyValue('--seq-activation-stroke').trim(), seqMsg:cs.getPropertyValue('--seq-msg').trim(), seqReturn:cs.getPropertyValue('--seq-return').trim(), seqFragmentFill:cs.getPropertyValue('--seq-fragment-fill').trim(), seqFragmentStroke:cs.getPropertyValue('--seq-fragment-stroke').trim(), swimlaneDefault:cs.getPropertyValue('--swimlane-default').trim(), swimlaneStroke:cs.getPropertyValue('--swimlane-stroke').trim() };
|
||
}
|
||
|
||
function drawGrid(c,w,h,tc,trans){
|
||
if(state.gridType==='none') return;
|
||
const s=20;
|
||
// Calculate visible area in world coordinates
|
||
const startX = -state.panX / state.zoom;
|
||
const startY = -state.panY / state.zoom;
|
||
const endX = startX + w / state.zoom;
|
||
const endY = startY + h / state.zoom;
|
||
|
||
// Snap to grid
|
||
const gridStartX = Math.floor(startX / s) * s;
|
||
const gridStartY = Math.floor(startY / s) * s;
|
||
|
||
if(state.gridType==='lines'){
|
||
c.strokeStyle=trans?'rgba(128,128,128,.15)':tc.gridLine;
|
||
c.lineWidth=.5 / state.zoom;
|
||
c.setLineDash([]);
|
||
for(let x=gridStartX;x<=endX;x+=s){c.beginPath();c.moveTo(x,gridStartY);c.lineTo(x,endY);c.stroke();}
|
||
for(let y=gridStartY;y<=endY;y+=s){c.beginPath();c.moveTo(gridStartX,y);c.lineTo(endX,y);c.stroke();}
|
||
} else if(state.gridType==='dots'){
|
||
c.fillStyle=trans?'rgba(128,128,128,.3)':tc.gridLine;
|
||
const r=(state.darkMode?1.2:1) / state.zoom;
|
||
for(let x=gridStartX;x<=endX;x+=s){
|
||
for(let y=gridStartY;y<=endY;y+=s){
|
||
c.beginPath();c.arc(x,y,r,0,Math.PI*2);c.fill();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function draw(){
|
||
if(canvas.width<=0||canvas.height<=0) return;
|
||
const d=getActive(), tc=getTC();
|
||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||
ctx.fillStyle=tc.canvasBg; ctx.fillRect(0,0,canvas.width,canvas.height);
|
||
|
||
// Apply zoom & pan transform
|
||
ctx.save();
|
||
ctx.translate(state.panX, state.panY);
|
||
ctx.scale(state.zoom, state.zoom);
|
||
|
||
drawGrid(ctx,canvas.width,canvas.height,tc,false);
|
||
if(d) drawDiag(ctx,d,tc);
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawDiag(c,d,tc){
|
||
if(d.diagramType==='usecase') drawUseCase(c,d,tc);
|
||
else if(d.diagramType==='statemachine') drawStateMachine(c,d,tc);
|
||
else if(d.diagramType==='activity') drawActivity(c,d,tc);
|
||
else if(d.diagramType==='component') drawComponent(c,d,tc);
|
||
else if(d.diagramType==='deployment') drawDeployment(c,d,tc);
|
||
else if(d.diagramType==='class') drawClassDiagram(c,d,tc);
|
||
else if(d.diagramType==='sequence') drawSequenceDiagram(c,d,tc);
|
||
else if(d.diagramType==='swimlane') drawSwimlaneDiagram(c,d,tc);
|
||
|
||
if(state.isDrawingRelationship&&state.relationshipStart&&c===ctx){
|
||
c.strokeStyle=tc.selected; c.lineWidth=2/state.zoom; c.setLineDash([5/state.zoom,5/state.zoom]);
|
||
c.beginPath(); c.moveTo(state.relationshipStart.x,state.relationshipStart.y); c.lineTo(state.mousePos.x,state.mousePos.y); c.stroke(); c.setLineDash([]);
|
||
}
|
||
if(state.selectedElement&&c===ctx) drawSel(c,state.selectedElement,tc);
|
||
}
|
||
|
||
// ===== USE CASE =====
|
||
function drawUseCase(c,d,tc){
|
||
if(d.systemBoundary){ const sb=d.systemBoundary, isS=state.selectedElement?.id===sb.id; c.strokeStyle=isS?tc.selected:tc.sbStroke; c.lineWidth=(isS?3:2)/state.zoom; c.setLineDash([]); c.strokeRect(sb.x,sb.y,sb.width,sb.height); c.fillStyle=tc.textPrimary; c.font=`bold ${16/state.zoom}px Vazirmatn`; c.textAlign='center'; c.textBaseline='bottom'; c.fillText(sb.name,sb.x+sb.width/2,sb.y-8/state.zoom); }
|
||
d.relationships.forEach(r=>drawRel(c,r,d,tc));
|
||
d.actors.forEach(a=>{ const isS=state.selectedElement?.id===a.id; c.strokeStyle=isS?tc.selected:tc.actorStroke; c.lineWidth=(isS?3:2)/state.zoom; c.beginPath();c.arc(a.x,a.y-20/state.zoom,10/state.zoom,0,Math.PI*2);c.stroke(); c.beginPath();c.moveTo(a.x,a.y-10/state.zoom);c.lineTo(a.x,a.y+15/state.zoom);c.stroke(); c.beginPath();c.moveTo(a.x-15/state.zoom,a.y);c.lineTo(a.x+15/state.zoom,a.y);c.stroke(); c.beginPath();c.moveTo(a.x,a.y+15/state.zoom);c.lineTo(a.x-12/state.zoom,a.y+30/state.zoom);c.stroke(); c.beginPath();c.moveTo(a.x,a.y+15/state.zoom);c.lineTo(a.x+12/state.zoom,a.y+30/state.zoom);c.stroke(); c.fillStyle=tc.textPrimary;c.font=`${13/state.zoom}px Vazirmatn`;c.textAlign='center';c.textBaseline='top';c.fillText(a.name,a.x,a.y+36/state.zoom); });
|
||
d.useCases.forEach(u=>{ const isS=state.selectedElement?.id===u.id; c.fillStyle=isS?(state.darkMode?'#2a2050':'#eef2ff'):tc.ucFill; c.strokeStyle=isS?tc.selected:tc.ucStroke; c.lineWidth=(isS?3:2)/state.zoom; c.beginPath();c.ellipse(u.x,u.y,u.radius*1.5,u.radius,0,0,Math.PI*2);c.fill();c.stroke(); c.fillStyle=tc.textPrimary;c.font=`${13/state.zoom}px Vazirmatn`;c.textAlign='center';c.textBaseline='middle'; c.fillText(u.name,u.x,u.y); });
|
||
}
|
||
|
||
function drawRel(c,r,d,tc){
|
||
const from=findEl(r.from,d), to=findEl(r.to,d); if(!from||!to) return;
|
||
const fp=elCenter(from), tp=elCenter(to);
|
||
c.strokeStyle=tc.relStroke; c.lineWidth=2/state.zoom; c.setLineDash([]);
|
||
if(r.type==='association'){ c.beginPath();c.moveTo(fp.x,fp.y);c.lineTo(tp.x,tp.y);c.stroke(); }
|
||
else if(r.type==='include'||r.type==='extend'){ c.setLineDash([6/state.zoom,4/state.zoom]);c.beginPath();c.moveTo(fp.x,fp.y);c.lineTo(tp.x,tp.y);c.stroke();c.setLineDash([]); drawArr(c,tp.x,tp.y,fp.x,fp.y,tc); const mx=(fp.x+tp.x)/2,my=(fp.y+tp.y)/2,lbl=`<<${r.type}>>`,tw=c.measureText(lbl).width+8/state.zoom; c.fillStyle=tc.labelBg;c.fillRect(mx-tw/2,my-18/state.zoom,tw,16/state.zoom); c.fillStyle=tc.textMuted;c.font=`${11/state.zoom}px Vazirmatn`;c.textAlign='center';c.textBaseline='middle';c.fillText(lbl,mx,my-10/state.zoom); }
|
||
else if(r.type==='generalization'){ c.beginPath();c.moveTo(fp.x,fp.y);c.lineTo(tp.x,tp.y);c.stroke(); drawTri(c,tp.x,tp.y,fp.x,fp.y,tc); }
|
||
}
|
||
|
||
function drawArr(c,tx,ty,fx,fy,tc){ const a=Math.atan2(ty-fy,tx-fx),l=12/state.zoom,ang=Math.PI/7; c.strokeStyle=tc.relStroke; c.lineWidth=2/state.zoom; c.beginPath();c.moveTo(tx,ty);c.lineTo(tx-l*Math.cos(a-ang),ty-l*Math.sin(a-ang));c.moveTo(tx,ty);c.lineTo(tx-l*Math.cos(a+ang),ty-l*Math.sin(a+ang));c.stroke(); }
|
||
function drawTri(c,tx,ty,fx,fy,tc){ const a=Math.atan2(ty-fy,tx-fx),s=14/state.zoom; c.fillStyle=tc.labelBg;c.strokeStyle=tc.relStroke;c.lineWidth=2/state.zoom; c.beginPath();c.moveTo(tx,ty);c.lineTo(tx-s*Math.cos(a-Math.PI/6),ty-s*Math.sin(a-Math.PI/6));c.lineTo(tx-s*Math.cos(a+Math.PI/6),ty-s*Math.sin(a+Math.PI/6));c.closePath();c.fill();c.stroke(); }
|
||
|
||
// ===== STATE MACHINE =====
|
||
function drawStateMachine(c,d,tc){
|
||
(d.transitions||[]).forEach(t=>drawTransition(c,t,d,tc));
|
||
(d.states||[]).forEach(s=>drawState(c,s,tc));
|
||
}
|
||
|
||
function drawState(c,s,tc){
|
||
const isS = state.selectedElement?.id===s.id;
|
||
const selColor = tc.selected;
|
||
const z = state.zoom;
|
||
if(s.stateType==='initial'){ c.fillStyle = isS ? selColor : tc.stateInit; c.beginPath(); c.arc(s.x, s.y, 10/z, 0, Math.PI*2); c.fill(); if(isS){ c.strokeStyle=selColor; c.lineWidth=2/z; c.setLineDash([4/z,4/z]); c.beginPath(); c.arc(s.x,s.y,15/z,0,Math.PI*2); c.stroke(); c.setLineDash([]); } }
|
||
else if(s.stateType==='final'){ c.strokeStyle = isS ? selColor : tc.stateFinalOuter; c.lineWidth = (isS ? 3 : 2)/z; c.beginPath(); c.arc(s.x, s.y, 14/z, 0, Math.PI*2); c.stroke(); c.fillStyle = isS ? selColor : tc.stateFinalInner; c.beginPath(); c.arc(s.x, s.y, 8/z, 0, Math.PI*2); c.fill(); }
|
||
else if(s.stateType==='state'){ const w=s.width||120, h=s.height||50, r=12; c.fillStyle = isS ? (state.darkMode?'#2a2050':'#eef2ff') : tc.stateFill; c.strokeStyle = isS ? selColor : tc.stateStroke; c.lineWidth = (isS ? 3 : 2)/z; roundRect(c, s.x-w/2, s.y-h/2, w, h, r); c.fill(); c.stroke(); c.fillStyle = tc.textPrimary; c.font = `bold ${13/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(s.name, s.x, s.y); }
|
||
else if(s.stateType==='composite'){ const w=s.width||180, h=s.height||120, r=12; c.fillStyle = isS ? (state.darkMode?'#1e2540':'#f0f4ff') : tc.stateFill; c.strokeStyle = isS ? selColor : tc.stateStroke; c.lineWidth = (isS ? 3 : 2)/z; roundRect(c, s.x-w/2, s.y-h/2, w, h, r); c.fill(); c.stroke(); const headerH = 28; c.beginPath(); c.moveTo(s.x-w/2, s.y-h/2+headerH); c.lineTo(s.x+w/2, s.y-h/2+headerH); c.stroke(); c.fillStyle = tc.textPrimary; c.font = `bold ${13/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(s.name, s.x, s.y-h/2+headerH/2); c.fillStyle = tc.textMuted; c.font = `${11/z}px Vazirmatn`; c.fillText('(زیرحالتها)', s.x, s.y+10/z); }
|
||
}
|
||
|
||
function drawTransition(c,t,d,tc){
|
||
const from = (d.states||[]).find(s=>s.id===t.from); const to = (d.states||[]).find(s=>s.id===t.to);
|
||
if(!from||!to) return;
|
||
const fp = {x:from.x, y:from.y}; const tp = {x:to.x, y:to.y};
|
||
const z = state.zoom;
|
||
if(t.from === t.to){ const loopR = 30/z; c.strokeStyle = tc.transStroke; c.lineWidth = 2/z; c.setLineDash([]); c.beginPath(); c.arc(fp.x + loopR, fp.y - loopR, loopR, Math.PI*0.3, Math.PI*1.7); c.stroke(); const endAngle = Math.PI*1.7; const ax = fp.x + loopR + loopR*Math.cos(endAngle); const ay = fp.y - loopR + loopR*Math.sin(endAngle); drawSmallArrow(c, ax, ay, endAngle + Math.PI/2, tc); if(t.label){ c.fillStyle = tc.transLabel; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'bottom'; c.fillText(t.label, fp.x + loopR*2 + 5/z, fp.y - loopR); } }
|
||
else { c.strokeStyle = tc.transStroke; c.lineWidth = 2/z; c.setLineDash([]); c.beginPath(); c.moveTo(fp.x, fp.y); c.lineTo(tp.x, tp.y); c.stroke(); const angle = Math.atan2(tp.y-fp.y, tp.x-fp.x); drawSmallArrow(c, tp.x, tp.y, angle, tc); if(t.label){ const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2; const tw = c.measureText(t.label).width + 10/z; c.fillStyle = tc.labelBg; c.fillRect(mx-tw/2, my-18/z, tw, 16/z); c.fillStyle = tc.transLabel; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(t.label, mx, my-10/z); } }
|
||
}
|
||
|
||
function drawSmallArrow(c, x, y, angle, tc){ const z=state.zoom; const len=10/z, a=Math.PI/6; c.fillStyle = tc.transStroke; c.beginPath(); c.moveTo(x, y); c.lineTo(x-len*Math.cos(angle-a), y-len*Math.sin(angle-a)); c.lineTo(x-len*Math.cos(angle+a), y-len*Math.sin(angle+a)); c.closePath(); c.fill(); }
|
||
|
||
// ===== ACTIVITY =====
|
||
function drawActivity(c,d,tc){ (d.flows||[]).forEach(f=>drawFlow(c,f,d,tc)); (d.nodes||[]).forEach(n=>drawNode(c,n,tc)); }
|
||
|
||
function drawNode(c,n,tc){
|
||
const isS = state.selectedElement?.id===n.id; const selColor = tc.selected;
|
||
const z = state.zoom;
|
||
if(n.nodeType==='initialNode'){ c.fillStyle = isS ? selColor : tc.stateInit; c.beginPath(); c.arc(n.x, n.y, 10/z, 0, Math.PI*2); c.fill(); if(isS){ c.strokeStyle=selColor; c.lineWidth=2/z; c.setLineDash([4/z,4/z]); c.beginPath(); c.arc(n.x,n.y,15/z,0,Math.PI*2); c.stroke(); c.setLineDash([]); } }
|
||
else if(n.nodeType==='finalNode'){ c.strokeStyle = isS ? selColor : tc.stateFinalOuter; c.lineWidth = (isS ? 3 : 2)/z; c.beginPath(); c.arc(n.x, n.y, 14/z, 0, Math.PI*2); c.stroke(); c.fillStyle = isS ? selColor : tc.stateFinalInner; c.beginPath(); c.arc(n.x, n.y, 8/z, 0, Math.PI*2); c.fill(); }
|
||
else if(n.nodeType==='action'){ const w=n.width||120, h=n.height||50, r=12; c.fillStyle = isS ? (state.darkMode?'#1e3a2e':'#e6fffa') : tc.activityFill; c.strokeStyle = isS ? selColor : tc.activityStroke; c.lineWidth = (isS ? 3 : 2)/z; roundRect(c, n.x-w/2, n.y-h/2, w, h, r); c.fill(); c.stroke(); c.fillStyle = tc.textPrimary; c.font = `${13/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(n.name, n.x, n.y); }
|
||
else if(n.nodeType==='decision' || n.nodeType==='merge'){ const size = n.size || 40; c.fillStyle = isS ? (state.darkMode?'#3a2e1e':'#fffaf0') : tc.decisionFill; c.strokeStyle = isS ? selColor : tc.decisionStroke; c.lineWidth = (isS ? 3 : 2)/z; c.beginPath(); c.moveTo(n.x, n.y-size/z); c.lineTo(n.x+size/z, n.y); c.lineTo(n.x, n.y+size/z); c.lineTo(n.x-size/z, n.y); c.closePath(); c.fill(); c.stroke(); if(n.name){ c.fillStyle = tc.textPrimary; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(n.name, n.x, n.y); } }
|
||
else if(n.nodeType==='forkJoin'){ const w = n.orientation==='horizontal' ? (n.width||150) : 6/z; const h = n.orientation==='horizontal' ? 6/z : (n.height||150); c.fillStyle = isS ? selColor : tc.forkStroke; c.fillRect(n.x-w/2, n.y-h/2, w, h); if(isS){ c.strokeStyle = selColor; c.lineWidth = 2/z; c.setLineDash([4/z,4/z]); c.strokeRect(n.x-w/2-4/z, n.y-h/2-4/z, w+8/z, h+8/z); c.setLineDash([]); } }
|
||
}
|
||
|
||
function drawFlow(c,f,d,tc){ const from = (d.nodes||[]).find(n=>n.id===f.from); const to = (d.nodes||[]).find(n=>n.id===f.to); if(!from||!to) return; const fp = {x:from.x, y:from.y}; const tp = {x:to.x, y:to.y}; const z=state.zoom; c.strokeStyle = tc.flowStroke; c.lineWidth = 2/z; c.setLineDash([]); c.beginPath(); c.moveTo(fp.x, fp.y); c.lineTo(tp.x, tp.y); c.stroke(); const angle = Math.atan2(tp.y-fp.y, tp.x-fp.x); drawSmallArrow(c, tp.x, tp.y, angle, tc); if(f.label){ const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2; const tw = c.measureText(f.label).width + 10/z; c.fillStyle = tc.labelBg; c.fillRect(mx-tw/2, my-18/z, tw, 16/z); c.fillStyle = tc.flowLabel; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(f.label, mx, my-10/z); } }
|
||
|
||
// ===== COMPONENT =====
|
||
function drawComponent(c,d,tc){ (d.packages||[]).forEach(p=>drawPackage(c,p,tc)); (d.interfaces||[]).forEach(i=>drawInterface(c,i,tc)); (d.compRelations||[]).forEach(r=>drawCompRelation(c,r,d,tc)); (d.components||[]).forEach(comp=>drawComponentEl(c,comp,tc)); }
|
||
|
||
function drawComponentEl(c,comp,tc){ const isS = state.selectedElement?.id===comp.id; const z=state.zoom; const w=comp.width||140, h=comp.height||70; const x=comp.x-w/2, y=comp.y-h/2; c.fillStyle = isS ? (state.darkMode?'#3a2e55':'#f3e8ff') : tc.compFill; c.strokeStyle = isS ? tc.selected : tc.compStroke; c.lineWidth = (isS ? 3 : 2)/z; c.setLineDash([]); c.fillRect(x, y, w, h); c.strokeRect(x, y, w, h); const iconX = x + w - 25/z, iconY = y + 8/z; drawComponentIcon(c, iconX, iconY, tc); c.fillStyle = tc.textPrimary; c.font = `bold ${13/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(comp.name, comp.x, comp.y + 5/z); }
|
||
|
||
function drawComponentIcon(c, x, y, tc){ const z=state.zoom; c.strokeStyle = tc.compIcon; c.lineWidth = 1.5/z; c.strokeRect(x, y, 18/z, 14/z); c.fillStyle = tc.compIcon; c.fillRect(x-4/z, y+2/z, 4/z, 3/z); c.fillRect(x-4/z, y+9/z, 4/z, 3/z); }
|
||
|
||
function drawInterface(c,iface,tc){ const isS = state.selectedElement?.id===iface.id; const z=state.zoom; if(iface.ifaceType==='provided'){ const stickLen = 30/z; const dir = iface.direction || 'right'; let ex, ey; if(dir==='right'){ ex=iface.x+stickLen; ey=iface.y; } else if(dir==='left'){ ex=iface.x-stickLen; ey=iface.y; } else if(dir==='up'){ ex=iface.x; ey=iface.y-stickLen; } else { ex=iface.x; ey=iface.y+stickLen; } c.strokeStyle = isS ? tc.selected : tc.ifaceStroke; c.lineWidth = (isS ? 3 : 2)/z; c.setLineDash([]); c.beginPath(); c.moveTo(iface.x, iface.y); c.lineTo(ex, ey); c.stroke(); c.beginPath(); c.arc(ex, ey, 8/z, 0, Math.PI*2); c.stroke(); if(iface.name){ c.fillStyle = tc.textPrimary; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'bottom'; c.fillText(iface.name, ex, ey-12/z); } } else { const dir = iface.direction || 'right'; c.strokeStyle = isS ? tc.selected : tc.ifaceStroke; c.lineWidth = (isS ? 3 : 2)/z; c.setLineDash([]); let startAngle, endAngle; if(dir==='right'){ startAngle=-Math.PI/2; endAngle=Math.PI/2; } else if(dir==='left'){ startAngle=Math.PI/2; endAngle=-Math.PI/2; } else if(dir==='up'){ startAngle=Math.PI; endAngle=0; } else { startAngle=0; endAngle=Math.PI; } c.beginPath(); c.arc(iface.x, iface.y, 10/z, startAngle, endAngle); c.stroke(); if(iface.name){ c.fillStyle = tc.textPrimary; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'bottom'; c.fillText(iface.name, iface.x, iface.y-14/z); } } }
|
||
|
||
function drawPackage(c,pkg,tc){ const isS = state.selectedElement?.id===pkg.id; const z=state.zoom; const w=pkg.width||200, h=pkg.height||150; const x=pkg.x, y=pkg.y; const tabW = 60/z, tabH = 18/z; c.fillStyle = isS ? (state.darkMode?'#3a2e1e':'#fffaf0') : tc.pkgFill; c.strokeStyle = isS ? tc.selected : tc.pkgStroke; c.lineWidth = (isS ? 3 : 2)/z; c.setLineDash([]); c.beginPath(); c.moveTo(x, y); c.lineTo(x+tabW, y); c.lineTo(x+tabW, y-tabH); c.lineTo(x, y-tabH); c.closePath(); c.fill(); c.stroke(); c.fillRect(x, y, w, h); c.strokeRect(x, y, w, h); c.fillStyle = tc.textPrimary; c.font = `bold ${13/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(pkg.name, x+w/2, y-tabH/2); }
|
||
|
||
function drawCompRelation(c,r,d,tc){ const from = findElInComp(r.from,d); const to = findElInComp(r.to,d); if(!from||!to) return; const fp = elCenter(from), tp = elCenter(to); const z=state.zoom; if(r.relType==='dependency'){ c.strokeStyle = tc.depStroke; c.lineWidth = 2/z; c.setLineDash([6/z,4/z]); c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke(); c.setLineDash([]); const angle = Math.atan2(tp.y-fp.y, tp.x-fp.x); drawOpenArrow(c, tp.x, tp.y, angle, tc); if(r.label){ const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2; c.fillStyle = tc.labelBg; const tw = c.measureText(r.label).width + 8/z; c.fillRect(mx-tw/2, my-18/z, tw, 16/z); c.fillStyle = tc.textMuted; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(r.label, mx, my-10/z); } } else if(r.relType==='connector'){ c.strokeStyle = tc.depStroke; c.lineWidth = 2/z; c.setLineDash([]); c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke(); } else if(r.relType==='compAssociation'){ c.strokeStyle = tc.depStroke; c.lineWidth = 2/z; c.setLineDash([]); c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke(); if(r.label){ const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2; c.fillStyle = tc.labelBg; const tw = c.measureText(r.label).width + 8/z; c.fillRect(mx-tw/2, my-18/z, tw, 16/z); c.fillStyle = tc.textMuted; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(r.label, mx, my-10/z); } } }
|
||
|
||
function drawOpenArrow(c, x, y, angle, tc){ const z=state.zoom; const len=12/z, a=Math.PI/6; c.strokeStyle = tc.depStroke; c.lineWidth = 2/z; c.beginPath(); c.moveTo(x-len*Math.cos(angle-a), y-len*Math.sin(angle-a)); c.lineTo(x, y); c.lineTo(x-len*Math.cos(angle+a), y-len*Math.sin(angle+a)); c.stroke(); }
|
||
|
||
// ===== DEPLOYMENT =====
|
||
function drawDeployment(c,d,tc){ (d.deployRelations||[]).forEach(r=>drawDeployRelation(c,r,d,tc)); (d.artifacts||[]).forEach(a=>drawArtifact(c,a,tc)); (d.deployNodes||[]).forEach(n=>drawDeployNode(c,n,tc)); }
|
||
|
||
function drawDeployNode(c,node,tc){ const isS = state.selectedElement?.id===node.id; const z=state.zoom; const w=node.width||160, h=node.height||100; const offset = 20/z; const x=node.x-w/2, y=node.y-h/2; let topFace = tc.nodeFaceTop, frontFace = tc.nodeFaceFront, sideFace = tc.nodeFaceSide; if(node.nodeType==='device'){ topFace = state.darkMode?'#1e3a3a':'#b2f5ea'; frontFace = state.darkMode?'#1a3030':'#81e6d9'; sideFace = state.darkMode?'#162828':'#4fd1c5'; } else if(node.nodeType==='execEnv'){ topFace = state.darkMode?'#3a2e1e':'#fefcbf'; frontFace = state.darkMode?'#302818':'#f6e05e'; sideFace = state.darkMode?'#282010':'#ecc94b'; } if(isS){ topFace = '#4c51bf'; frontFace = '#5a67d8'; sideFace = '#667eea'; } c.fillStyle = frontFace; c.strokeStyle = tc.nodeStroke; c.lineWidth = (isS ? 3 : 2)/z; c.beginPath(); c.moveTo(x, y); c.lineTo(x+w, y); c.lineTo(x+w, y+h); c.lineTo(x, y+h); c.closePath(); c.fill(); c.stroke(); c.fillStyle = topFace; c.beginPath(); c.moveTo(x, y); c.lineTo(x+offset, y-offset); c.lineTo(x+w+offset, y-offset); c.lineTo(x+w, y); c.closePath(); c.fill(); c.stroke(); c.fillStyle = sideFace; c.beginPath(); c.moveTo(x+w, y); c.lineTo(x+w+offset, y-offset); c.lineTo(x+w+offset, y+h-offset); c.lineTo(x+w, y+h); c.closePath(); c.fill(); c.stroke(); const stereotype = node.nodeType==='device'?'«device»':node.nodeType==='execEnv'?'«executionEnvironment»':'«node»'; c.fillStyle = tc.textMuted; c.font = `italic ${10/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'bottom'; c.fillText(stereotype, node.x, y-2/z); c.fillStyle = tc.textPrimary; c.font = `bold ${13/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(node.name, node.x, node.y); }
|
||
|
||
function drawArtifact(c,art,tc){ const isS = state.selectedElement?.id===art.id; const z=state.zoom; const w=art.width||120, h=art.height||50; const x=art.x-w/2, y=art.y-h/2; const corner = 12/z; c.fillStyle = isS ? (state.darkMode?'#3b1a1a':'#fff5f5') : tc.artifactFill; c.strokeStyle = isS ? tc.selected : tc.artifactStroke; c.lineWidth = (isS ? 3 : 2)/z; c.setLineDash([]); c.beginPath(); c.moveTo(x, y); c.lineTo(x+w-corner, y); c.lineTo(x+w, y+corner); c.lineTo(x+w, y+h); c.lineTo(x, y+h); c.closePath(); c.fill(); c.stroke(); c.beginPath(); c.moveTo(x+w-corner, y); c.lineTo(x+w-corner, y+corner); c.lineTo(x+w, y+corner); c.stroke(); c.fillStyle = tc.textMuted; c.font = `italic ${10/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'bottom'; c.fillText('«artifact»', art.x, y-2/z); c.fillStyle = tc.textPrimary; c.font = `${13/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(art.name, art.x, art.y+5/z); }
|
||
|
||
function drawDeployRelation(c,r,d,tc){ const from = findElInDeploy(r.from,d); const to = findElInDeploy(r.to,d); if(!from||!to) return; const fp = elCenter(from), tp = elCenter(to); const z=state.zoom; if(r.relType==='commPath'){ c.strokeStyle = tc.depStroke; c.lineWidth = 2/z; c.setLineDash([]); c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke(); if(r.label){ const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2; c.fillStyle = tc.labelBg; const tw = c.measureText(r.label).width + 8/z; c.fillRect(mx-tw/2, my-18/z, tw, 16/z); c.fillStyle = tc.textMuted; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(r.label, mx, my-10/z); } } else if(r.relType==='deployRel'){ c.strokeStyle = tc.depStroke; c.lineWidth = 2/z; c.setLineDash([6/z,4/z]); c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke(); c.setLineDash([]); const angle = Math.atan2(tp.y-fp.y, tp.x-fp.x); drawOpenArrow(c, tp.x, tp.y, angle, tc); const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2; const label = r.label || '«deploy»'; const tw = c.measureText(label).width + 8/z; c.fillStyle = tc.labelBg; c.fillRect(mx-tw/2, my-18/z, tw, 16/z); c.fillStyle = tc.textMuted; c.font = `italic ${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(label, mx, my-10/z); } else if(r.relType==='manifestRel'){ c.strokeStyle = tc.depStroke; c.lineWidth = 2/z; c.setLineDash([6/z,4/z]); c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke(); c.setLineDash([]); drawHollowTriangle(c, tp.x, tp.y, fp.x, fp.y, tc); if(r.label){ const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2; c.fillStyle = tc.labelBg; const tw = c.measureText(r.label).width + 8/z; c.fillRect(mx-tw/2, my-18/z, tw, 16/z); c.fillStyle = tc.textMuted; c.font = `${11/z}px Vazirmatn`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(r.label, mx, my-10/z); } } }
|
||
|
||
function drawHollowTriangle(c, tx, ty, fx, fy, tc){ const z=state.zoom; const angle = Math.atan2(ty-fy, tx-fx); const s = 12/z; c.fillStyle = tc.labelBg; c.strokeStyle = tc.depStroke; c.lineWidth = 2/z; c.beginPath(); c.moveTo(tx, ty); c.lineTo(tx-s*Math.cos(angle-Math.PI/6), ty-s*Math.sin(angle-Math.PI/6)); c.lineTo(tx-s*Math.cos(angle+Math.PI/6), ty-s*Math.sin(angle+Math.PI/6)); c.closePath(); c.fill(); c.stroke(); }
|
||
|
||
// ===== CLASS DIAGRAM =====
|
||
function drawClassDiagram(c,d,tc){
|
||
(d.classRelations||[]).forEach(r=>drawClassRelation(c,r,d,tc));
|
||
(d.classes||[]).forEach(cls=>drawClassEl(c,cls,tc));
|
||
}
|
||
|
||
function drawClassEl(c,cls,tc){
|
||
const isS = state.selectedElement?.id===cls.id;
|
||
const z=state.zoom;
|
||
const w=cls.width||180;
|
||
const headerH=30/z;
|
||
const attrH=Math.max(20/z, (cls.attributes||[]).length*18/z + 10/z);
|
||
const methH=Math.max(20/z, (cls.methods||[]).length*18/z + 10/z);
|
||
const h=headerH+attrH+methH;
|
||
cls._computedH = h;
|
||
|
||
const x=cls.x-w/2, y=cls.y-h/2;
|
||
|
||
c.fillStyle = isS ? (state.darkMode?'#1e3a55':'#dbeafe') : tc.classFill;
|
||
c.strokeStyle = isS ? tc.selected : tc.classStroke;
|
||
c.lineWidth = (isS ? 3 : 2)/z;
|
||
c.setLineDash([]);
|
||
|
||
c.fillRect(x, y, w, h);
|
||
c.strokeRect(x, y, w, h);
|
||
|
||
c.fillStyle = tc.classHeader;
|
||
c.fillRect(x, y, w, headerH);
|
||
c.strokeRect(x, y, w, headerH);
|
||
|
||
let stereotype = '';
|
||
if(cls.classType==='interface') stereotype = '«interface»';
|
||
else if(cls.classType==='abstract') stereotype = '«abstract»';
|
||
else if(cls.classType==='enumeration') stereotype = '«enumeration»';
|
||
|
||
if(stereotype){
|
||
c.fillStyle = tc.textMuted;
|
||
c.font = `italic ${10/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'bottom';
|
||
c.fillText(stereotype, cls.x, y+13/z);
|
||
}
|
||
|
||
c.fillStyle = tc.textPrimary;
|
||
c.font = cls.classType==='abstract' ? `italic bold ${13/z}px Vazirmatn` : `bold ${13/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
const nameY = stereotype ? y+headerH/2+5/z : y+headerH/2;
|
||
c.fillText(cls.name, cls.x, nameY);
|
||
|
||
c.fillStyle = tc.classAttr;
|
||
c.font = `${11/z}px Vazirmatn`;
|
||
c.textAlign = 'left'; c.textBaseline = 'top';
|
||
let attrY = y + headerH + 5/z;
|
||
if((cls.attributes||[]).length === 0){
|
||
c.fillStyle = tc.textMuted;
|
||
c.fillText('(بدون ویژگی)', x+8/z, attrY);
|
||
} else {
|
||
(cls.attributes||[]).forEach(attr=>{
|
||
const vis = attr.visibility || '+';
|
||
const staticMark = attr.isStatic ? '_' : '';
|
||
const typeStr = attr.type ? ': '+attr.type : '';
|
||
const text = `${vis} ${staticMark}${attr.name}${typeStr}`;
|
||
c.fillStyle = tc.classAttr;
|
||
c.fillText(text, x+8/z, attrY);
|
||
attrY += 18/z;
|
||
});
|
||
}
|
||
|
||
const sep1Y = y + headerH + attrH;
|
||
c.beginPath(); c.moveTo(x, sep1Y); c.lineTo(x+w, sep1Y); c.stroke();
|
||
|
||
c.font = `${11/z}px Vazirmatn`;
|
||
let methY = sep1Y + 5/z;
|
||
if((cls.methods||[]).length === 0){
|
||
c.fillStyle = tc.textMuted;
|
||
c.fillText('(بدون متد)', x+8/z, methY);
|
||
} else {
|
||
(cls.methods||[]).forEach(meth=>{
|
||
const vis = meth.visibility || '+';
|
||
const staticMark = meth.isStatic ? '_' : '';
|
||
const params = meth.params ? `(${meth.params})` : '()';
|
||
const retType = meth.returnType ? ': '+meth.returnType : '';
|
||
const text = `${vis} ${staticMark}${meth.name}${params}${retType}`;
|
||
c.fillStyle = tc.classMethod;
|
||
c.fillText(text, x+8/z, methY);
|
||
methY += 18/z;
|
||
});
|
||
}
|
||
}
|
||
|
||
function drawClassRelation(c,r,d,tc){
|
||
const from = (d.classes||[]).find(cls=>cls.id===r.from);
|
||
const to = (d.classes||[]).find(cls=>cls.id===r.to);
|
||
if(!from||!to) return;
|
||
|
||
const fp = {x:from.x, y:from.y};
|
||
const tp = {x:to.x, y:to.y};
|
||
const angle = Math.atan2(tp.y-fp.y, tp.x-fp.x);
|
||
const z=state.zoom;
|
||
|
||
c.strokeStyle = tc.depStroke;
|
||
c.lineWidth = 2/z;
|
||
|
||
if(r.relType==='clsAssociation'){
|
||
c.setLineDash([]);
|
||
c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke();
|
||
} else if(r.relType==='aggregation'){
|
||
c.setLineDash([]);
|
||
c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke();
|
||
drawDiamond(c, fp.x, fp.y, angle, false, tc);
|
||
} else if(r.relType==='composition'){
|
||
c.setLineDash([]);
|
||
c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke();
|
||
drawDiamond(c, fp.x, fp.y, angle, true, tc);
|
||
} else if(r.relType==='inheritance'){
|
||
c.setLineDash([]);
|
||
c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke();
|
||
drawHollowTriangle(c, tp.x, tp.y, fp.x, fp.y, tc);
|
||
} else if(r.relType==='clsDependency'){
|
||
c.setLineDash([6/z,4/z]);
|
||
c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke();
|
||
c.setLineDash([]);
|
||
drawOpenArrow(c, tp.x, tp.y, angle, tc);
|
||
} else if(r.relType==='realization'){
|
||
c.setLineDash([6/z,4/z]);
|
||
c.beginPath(); c.moveTo(fp.x,fp.y); c.lineTo(tp.x,tp.y); c.stroke();
|
||
c.setLineDash([]);
|
||
drawHollowTriangle(c, tp.x, tp.y, fp.x, fp.y, tc);
|
||
}
|
||
|
||
if(r.label || r.fromMultiplicity || r.toMultiplicity){
|
||
const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2;
|
||
if(r.label){
|
||
const tw = c.measureText(r.label).width + 8/z;
|
||
c.fillStyle = tc.labelBg; c.fillRect(mx-tw/2, my-18/z, tw, 16/z);
|
||
c.fillStyle = tc.textMuted; c.font = `${11/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(r.label, mx, my-10/z);
|
||
}
|
||
if(r.fromMultiplicity){
|
||
c.fillStyle = tc.textMuted; c.font = `${10/z}px Vazirmatn`;
|
||
c.textAlign = 'left'; c.textBaseline = 'bottom';
|
||
c.fillText(r.fromMultiplicity, fp.x+5/z, fp.y-3/z);
|
||
}
|
||
if(r.toMultiplicity){
|
||
c.fillStyle = tc.textMuted; c.font = `${10/z}px Vazirmatn`;
|
||
c.textAlign = 'right'; c.textBaseline = 'bottom';
|
||
c.fillText(r.toMultiplicity, tp.x-5/z, tp.y-3/z);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawDiamond(c, x, y, angle, filled, tc){
|
||
const z=state.zoom;
|
||
const size = 10/z;
|
||
c.save();
|
||
c.translate(x, y);
|
||
c.rotate(angle);
|
||
c.beginPath();
|
||
c.moveTo(0, 0);
|
||
c.lineTo(-size, -size/2);
|
||
c.lineTo(-size*2, 0);
|
||
c.lineTo(-size, size/2);
|
||
c.closePath();
|
||
if(filled){
|
||
c.fillStyle = tc.depStroke;
|
||
c.fill();
|
||
} else {
|
||
c.fillStyle = tc.labelBg;
|
||
c.fill();
|
||
}
|
||
c.strokeStyle = tc.depStroke;
|
||
c.lineWidth = 2/z;
|
||
c.stroke();
|
||
c.restore();
|
||
}
|
||
|
||
// ===== SEQUENCE DIAGRAM =====
|
||
function drawSequenceDiagram(c,d,tc){
|
||
(d.fragments||[]).forEach(f=>drawFragment(c,f,tc));
|
||
(d.participants||[]).forEach(p=>drawParticipant(c,p,d,tc));
|
||
(d.activations||[]).forEach(a=>drawActivation(c,a,d,tc));
|
||
(d.messages||[]).forEach(m=>drawMessage(c,m,d,tc));
|
||
}
|
||
|
||
function drawParticipant(c,p,d,tc){
|
||
const isS = state.selectedElement?.id===p.id;
|
||
const z=state.zoom;
|
||
const w=p.width||120, h=40/z;
|
||
const x=p.x-w/2, y=p.y-h/2;
|
||
|
||
c.fillStyle = isS ? (state.darkMode?'#2a3a55':'#cbd5e0') : tc.seqParticipantFill;
|
||
c.strokeStyle = isS ? tc.selected : tc.seqParticipantStroke;
|
||
c.lineWidth = (isS ? 3 : 2)/z;
|
||
c.setLineDash([]);
|
||
c.fillRect(x, y, w, h);
|
||
c.strokeRect(x, y, w, h);
|
||
|
||
c.fillStyle = tc.textPrimary;
|
||
c.font = `bold ${12/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(p.name, p.x, p.y);
|
||
|
||
const canvasBottom = (canvas.height - state.panY) / state.zoom - 20/z;
|
||
c.strokeStyle = tc.seqLifeline;
|
||
c.lineWidth = 1/z;
|
||
c.setLineDash([5/z,5/z]);
|
||
c.beginPath();
|
||
c.moveTo(p.x, p.y+h/2);
|
||
c.lineTo(p.x, canvasBottom);
|
||
c.stroke();
|
||
c.setLineDash([]);
|
||
}
|
||
|
||
function drawActivation(c,a,d,tc){
|
||
const isS = state.selectedElement?.id===a.id;
|
||
const z=state.zoom;
|
||
const participant = (d.participants||[]).find(p=>p.id===a.participantId);
|
||
if(!participant) return;
|
||
|
||
const w=12/z, h=a.height||60/z;
|
||
const x=participant.x-w/2, y=a.y;
|
||
|
||
c.fillStyle = isS ? tc.selected : tc.seqActivation;
|
||
c.strokeStyle = isS ? tc.selected : tc.seqActivationStroke;
|
||
c.lineWidth = (isS ? 2 : 1.5)/z;
|
||
c.setLineDash([]);
|
||
c.fillRect(x, y, w, h);
|
||
c.strokeRect(x, y, w, h);
|
||
}
|
||
|
||
function drawFragment(c,f,tc){
|
||
const isS = state.selectedElement?.id===f.id;
|
||
const z=state.zoom;
|
||
const w=f.width||300/z, h=f.height||150/z;
|
||
const x=f.x, y=f.y;
|
||
const tabW=50/z, tabH=20/z;
|
||
|
||
c.fillStyle = isS ? (state.darkMode?'#3a2e1e':'#fefcbf') : tc.seqFragmentFill;
|
||
c.strokeStyle = isS ? tc.selected : tc.seqFragmentStroke;
|
||
c.lineWidth = (isS ? 3 : 2)/z;
|
||
c.setLineDash([]);
|
||
|
||
c.fillRect(x, y, w, h);
|
||
c.strokeRect(x, y, w, h);
|
||
|
||
c.beginPath();
|
||
c.moveTo(x, y);
|
||
c.lineTo(x+tabW, y);
|
||
c.lineTo(x+tabW, y+tabH);
|
||
c.lineTo(x, y+tabH);
|
||
c.closePath();
|
||
c.fill(); c.stroke();
|
||
|
||
const typeLabel = f.fragmentType==='alt'?'alt':f.fragmentType==='opt'?'opt':f.fragmentType==='loop'?'loop':'break';
|
||
c.fillStyle = tc.textPrimary;
|
||
c.font = `bold italic ${11/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(`[${typeLabel}]`, x+tabW/2, y+tabH/2);
|
||
|
||
if(f.label){
|
||
c.fillStyle = tc.textMuted;
|
||
c.font = `italic ${10/z}px Vazirmatn`;
|
||
c.textAlign = 'left'; c.textBaseline = 'middle';
|
||
c.fillText(f.label, x+tabW+5/z, y+tabH/2);
|
||
}
|
||
|
||
if(f.operands && f.operands.length > 1){
|
||
c.strokeStyle = tc.seqFragmentStroke;
|
||
c.lineWidth = 1/z;
|
||
c.setLineDash([4/z,4/z]);
|
||
let currentY = y + tabH;
|
||
f.operands.forEach((op, idx)=>{
|
||
if(idx < f.operands.length - 1){
|
||
currentY += (op.height || 40/z);
|
||
c.beginPath();
|
||
c.moveTo(x, currentY);
|
||
c.lineTo(x+w, currentY);
|
||
c.stroke();
|
||
if(op.label){
|
||
c.fillStyle = tc.textMuted;
|
||
c.font = `italic ${10/z}px Vazirmatn`;
|
||
c.textAlign = 'left'; c.textBaseline = 'middle';
|
||
c.fillText(`[${op.label}]`, x+5/z, currentY-8/z);
|
||
}
|
||
}
|
||
});
|
||
c.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
function drawMessage(c,m,d,tc){
|
||
const fromP = (d.participants||[]).find(p=>p.id===m.fromParticipant);
|
||
const toP = (d.participants||[]).find(p=>p.id===m.toParticipant);
|
||
if(!fromP || !toP) return;
|
||
|
||
const z=state.zoom;
|
||
const y = m.y;
|
||
const fromX = fromP.x;
|
||
const toX = toP.x;
|
||
|
||
c.lineWidth = 2/z;
|
||
c.setLineDash([]);
|
||
|
||
if(m.msgType==='syncMsg'){
|
||
c.strokeStyle = tc.seqMsg;
|
||
c.beginPath(); c.moveTo(fromX, y); c.lineTo(toX, y); c.stroke();
|
||
const angle = toX > fromX ? 0 : Math.PI;
|
||
drawFilledArrow(c, toX, y, angle, tc);
|
||
} else if(m.msgType==='asyncMsg'){
|
||
c.strokeStyle = tc.seqMsg;
|
||
c.beginPath(); c.moveTo(fromX, y); c.lineTo(toX, y); c.stroke();
|
||
const angle = toX > fromX ? 0 : Math.PI;
|
||
drawOpenArrowSide(c, toX, y, angle, tc);
|
||
} else if(m.msgType==='returnMsg'){
|
||
c.strokeStyle = tc.seqReturn;
|
||
c.setLineDash([5/z,5/z]);
|
||
c.beginPath(); c.moveTo(fromX, y); c.lineTo(toX, y); c.stroke();
|
||
c.setLineDash([]);
|
||
const angle = toX > fromX ? 0 : Math.PI;
|
||
drawOpenArrowSide(c, toX, y, angle, tc);
|
||
} else if(m.msgType==='selfMsg'){
|
||
c.strokeStyle = tc.seqMsg;
|
||
const loopW = 30/z, loopH = 20/z;
|
||
c.beginPath();
|
||
c.moveTo(fromX, y);
|
||
c.lineTo(fromX+loopW, y);
|
||
c.lineTo(fromX+loopW, y+loopH);
|
||
c.lineTo(fromX, y+loopH);
|
||
c.stroke();
|
||
drawFilledArrow(c, fromX, y+loopH, Math.PI, tc);
|
||
}
|
||
|
||
if(m.label){
|
||
const mx = (fromX + toX) / 2;
|
||
c.fillStyle = tc.labelBg;
|
||
const tw = c.measureText(m.label).width + 8/z;
|
||
c.fillRect(mx-tw/2, y-18/z, tw, 16/z);
|
||
c.fillStyle = tc.textPrimary;
|
||
c.font = `${11/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(m.label, mx, y-10/z);
|
||
}
|
||
}
|
||
|
||
function drawFilledArrow(c, x, y, angle, tc){
|
||
const z=state.zoom;
|
||
const len=10/z, a=Math.PI/6;
|
||
c.fillStyle = tc.seqMsg;
|
||
c.beginPath();
|
||
c.moveTo(x, y);
|
||
c.lineTo(x-len*Math.cos(angle-a), y-len*Math.sin(angle-a));
|
||
c.lineTo(x-len*Math.cos(angle+a), y-len*Math.sin(angle+a));
|
||
c.closePath();
|
||
c.fill();
|
||
}
|
||
|
||
function drawOpenArrowSide(c, x, y, angle, tc){
|
||
const z=state.zoom;
|
||
const len=10/z, a=Math.PI/6;
|
||
c.strokeStyle = tc.seqMsg;
|
||
c.lineWidth = 2/z;
|
||
c.beginPath();
|
||
c.moveTo(x-len*Math.cos(angle-a), y-len*Math.sin(angle-a));
|
||
c.lineTo(x, y);
|
||
c.lineTo(x-len*Math.cos(angle+a), y-len*Math.sin(angle+a));
|
||
c.stroke();
|
||
}
|
||
|
||
// ===== SWIMLANE DIAGRAM =====
|
||
function drawSwimlaneDiagram(c,d,tc){
|
||
(d.swimlanes||[]).forEach(sw=>drawSwimlane(c,sw,d,tc));
|
||
(d.swFlows||[]).forEach(f=>drawSwFlow(c,f,d,tc));
|
||
(d.swNodes||[]).forEach(n=>drawSwNode(c,n,tc));
|
||
}
|
||
|
||
function drawSwimlane(c,sw,d,tc){
|
||
const isS = state.selectedElement?.id===sw.id;
|
||
const z=state.zoom;
|
||
const w=sw.width||400/z, h=sw.height||300/z;
|
||
const x=sw.x, y=sw.y;
|
||
const headerSize = 40/z;
|
||
|
||
const fillColor = sw.color || tc.swimlaneDefault;
|
||
c.fillStyle = isS ? (state.darkMode?'#2a3a55':'#dbeafe') : fillColor;
|
||
c.strokeStyle = isS ? tc.selected : tc.swimlaneStroke;
|
||
c.lineWidth = (isS ? 3 : 2)/z;
|
||
c.setLineDash([]);
|
||
|
||
c.fillRect(x, y, w, h);
|
||
c.strokeRect(x, y, w, h);
|
||
|
||
if(sw.orientation==='vertical'){
|
||
c.fillStyle = tc.swimlaneStroke;
|
||
c.fillRect(x+w-headerSize, y, headerSize, h);
|
||
c.strokeRect(x+w-headerSize, y, headerSize, h);
|
||
|
||
c.save();
|
||
c.translate(x+w-headerSize/2, y+h/2);
|
||
c.rotate(-Math.PI/2);
|
||
c.fillStyle = tc.textPrimary;
|
||
c.font = `bold ${13/z}px Vazirmatn`;
|
||
c.textAlign = 'center';
|
||
c.textBaseline = 'middle';
|
||
c.fillText(sw.name, 0, 0);
|
||
c.restore();
|
||
} else {
|
||
c.fillStyle = tc.swimlaneStroke;
|
||
c.fillRect(x, y, w, headerSize);
|
||
c.strokeRect(x, y, w, headerSize);
|
||
|
||
c.fillStyle = tc.textPrimary;
|
||
c.font = `bold ${13/z}px Vazirmatn`;
|
||
c.textAlign = 'center';
|
||
c.textBaseline = 'middle';
|
||
c.fillText(sw.name, x+w/2, y+headerSize/2);
|
||
}
|
||
|
||
if(isS){
|
||
drawResizeHandles(c, sw);
|
||
}
|
||
}
|
||
|
||
function drawResizeHandles(c, sw){
|
||
const handles = getResizeHandles(sw);
|
||
const z=state.zoom;
|
||
c.fillStyle = '#667eea';
|
||
c.strokeStyle = '#ffffff';
|
||
c.lineWidth = 2/z;
|
||
|
||
handles.forEach(h=>{
|
||
c.fillRect(h.x-4/z, h.y-4/z, 8/z, 8/z);
|
||
c.strokeRect(h.x-4/z, h.y-4/z, 8/z, 8/z);
|
||
});
|
||
}
|
||
|
||
function getResizeHandles(sw){
|
||
const z=state.zoom;
|
||
const w=(sw.width||400/z), h=(sw.height||300/z);
|
||
const x=sw.x, y=sw.y;
|
||
return [
|
||
{x:x, y:y, pos:'tl'},
|
||
{x:x+w, y:y, pos:'tr'},
|
||
{x:x, y:y+h, pos:'bl'},
|
||
{x:x+w, y:y+h, pos:'br'},
|
||
{x:x+w/2, y:y, pos:'t'},
|
||
{x:x+w/2, y:y+h, pos:'b'},
|
||
{x:x, y:y+h/2, pos:'l'},
|
||
{x:x+w, y:y+h/2, pos:'r'}
|
||
];
|
||
}
|
||
|
||
function drawSwNode(c,n,tc){
|
||
const isS = state.selectedElement?.id===n.id;
|
||
const selColor = tc.selected;
|
||
const z=state.zoom;
|
||
|
||
if(n.nodeType==='swInitial'){
|
||
c.fillStyle = isS ? selColor : tc.stateInit;
|
||
c.beginPath(); c.arc(n.x, n.y, 10/z, 0, Math.PI*2); c.fill();
|
||
if(isS){ c.strokeStyle=selColor; c.lineWidth=2/z; c.setLineDash([4/z,4/z]); c.beginPath(); c.arc(n.x,n.y,15/z,0,Math.PI*2); c.stroke(); c.setLineDash([]); }
|
||
}
|
||
else if(n.nodeType==='swFinal'){
|
||
c.strokeStyle = isS ? selColor : tc.stateFinalOuter;
|
||
c.lineWidth = (isS ? 3 : 2)/z;
|
||
c.beginPath(); c.arc(n.x, n.y, 14/z, 0, Math.PI*2); c.stroke();
|
||
c.fillStyle = isS ? selColor : tc.stateFinalInner;
|
||
c.beginPath(); c.arc(n.x, n.y, 8/z, 0, Math.PI*2); c.fill();
|
||
}
|
||
else if(n.nodeType==='swAction'){
|
||
const w=n.width||120/z, h=n.height||50/z, r=12/z;
|
||
c.fillStyle = isS ? (state.darkMode?'#1e3a2e':'#e6fffa') : tc.activityFill;
|
||
c.strokeStyle = isS ? selColor : tc.activityStroke;
|
||
c.lineWidth = (isS ? 3 : 2)/z;
|
||
roundRect(c, n.x-w/2, n.y-h/2, w, h, r);
|
||
c.fill(); c.stroke();
|
||
c.fillStyle = tc.textPrimary; c.font = `${13/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(n.name, n.x, n.y);
|
||
}
|
||
else if(n.nodeType==='swDecision'){
|
||
const size = (n.size || 40)/z;
|
||
c.fillStyle = isS ? (state.darkMode?'#3a2e1e':'#fffaf0') : tc.decisionFill;
|
||
c.strokeStyle = isS ? selColor : tc.decisionStroke;
|
||
c.lineWidth = (isS ? 3 : 2)/z;
|
||
c.beginPath();
|
||
c.moveTo(n.x, n.y-size);
|
||
c.lineTo(n.x+size, n.y);
|
||
c.lineTo(n.x, n.y+size);
|
||
c.lineTo(n.x-size, n.y);
|
||
c.closePath();
|
||
c.fill(); c.stroke();
|
||
if(n.name){
|
||
c.fillStyle = tc.textPrimary; c.font = `${11/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(n.name, n.x, n.y);
|
||
}
|
||
}
|
||
else if(n.nodeType==='swForkJoin'){
|
||
const w = n.orientation==='horizontal' ? (n.width||150/z) : 6/z;
|
||
const h = n.orientation==='horizontal' ? 6/z : (n.height||150/z);
|
||
c.fillStyle = isS ? selColor : tc.forkStroke;
|
||
c.fillRect(n.x-w/2, n.y-h/2, w, h);
|
||
if(isS){
|
||
c.strokeStyle = selColor; c.lineWidth = 2/z; c.setLineDash([4/z,4/z]);
|
||
c.strokeRect(n.x-w/2-4/z, n.y-h/2-4/z, w+8/z, h+8/z);
|
||
c.setLineDash([]);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawSwFlow(c,f,d,tc){
|
||
const from = (d.swNodes||[]).find(n=>n.id===f.from);
|
||
const to = (d.swNodes||[]).find(n=>n.id===f.to);
|
||
if(!from||!to) return;
|
||
|
||
const fp = {x:from.x, y:from.y};
|
||
const tp = {x:to.x, y:to.y};
|
||
const z=state.zoom;
|
||
|
||
c.strokeStyle = tc.flowStroke; c.lineWidth = 2/z; c.setLineDash([]);
|
||
c.beginPath(); c.moveTo(fp.x, fp.y); c.lineTo(tp.x, tp.y); c.stroke();
|
||
|
||
const angle = Math.atan2(tp.y-fp.y, tp.x-fp.x);
|
||
drawSmallArrow(c, tp.x, tp.y, angle, tc);
|
||
|
||
if(f.label){
|
||
const mx=(fp.x+tp.x)/2, my=(fp.y+tp.y)/2;
|
||
const tw = c.measureText(f.label).width + 10/z;
|
||
c.fillStyle = tc.labelBg; c.fillRect(mx-tw/2, my-18/z, tw, 16/z);
|
||
c.fillStyle = tc.flowLabel; c.font = `${11/z}px Vazirmatn`;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(f.label, mx, my-10/z);
|
||
}
|
||
}
|
||
|
||
function roundRect(c, x, y, w, h, r){ c.beginPath(); c.moveTo(x+r, y); c.lineTo(x+w-r, y); c.quadraticCurveTo(x+w, y, x+w, y+r); c.lineTo(x+w, y+h-r); c.quadraticCurveTo(x+w, y+h, x+w-r, y+h); c.lineTo(x+r, y+h); c.quadraticCurveTo(x, y+h, x, y+h-r); c.lineTo(x, y+r); c.quadraticCurveTo(x, y, x+r, y); c.closePath(); }
|
||
|
||
function drawSel(c,el,tc){
|
||
const z=state.zoom;
|
||
c.strokeStyle=tc.selected; c.lineWidth=2/z; c.setLineDash([5/z,5/z]);
|
||
if(el.elType==='actor') c.strokeRect(el.x-25/z,el.y-35/z,50/z,80/z);
|
||
else if(el.elType==='useCase'){c.beginPath();c.ellipse(el.x,el.y,el.radius*1.5+6/z,el.radius+6/z,0,0,Math.PI*2);c.stroke();}
|
||
else if(el.elType==='systemBoundary') c.strokeRect(el.x-5/z,el.y-5/z,el.width+10/z,el.height+10/z);
|
||
else if(el.elType==='state'||el.elType==='composite'){ const w=(el.width||120)+10/z,h=(el.height||50)+10/z; c.strokeRect(el.x-w/2,el.y-h/2,w,h); }
|
||
else if(el.elType==='initial'||el.elType==='initialNode'||el.elType==='swInitial') { c.beginPath();c.arc(el.x,el.y,16/z,0,Math.PI*2);c.stroke(); }
|
||
else if(el.elType==='final'||el.elType==='finalNode'||el.elType==='swFinal') { c.beginPath();c.arc(el.x,el.y,20/z,0,Math.PI*2);c.stroke(); }
|
||
else if(el.elType==='action'||el.elType==='swAction'){ const w=(el.width||120)+10/z,h=(el.height||50)+10/z; c.strokeRect(el.x-w/2,el.y-h/2,w,h); }
|
||
else if(el.elType==='decision'||el.elType==='merge'||el.elType==='swDecision'){ const s=(el.size||40)+6/z; c.strokeRect(el.x-s,el.y-s,s*2,s*2); }
|
||
else if(el.elType==='forkJoin'||el.elType==='swForkJoin'){ const w=el.orientation==='horizontal'?(el.width||150):6/z, h=el.orientation==='horizontal'?6/z:(el.height||150); c.strokeRect(el.x-w/2-4/z,el.y-h/2-4/z,w+8/z,h+8/z); }
|
||
else if(el.elType==='component'){ const w=(el.width||140)+10/z,h=(el.height||70)+10/z; c.strokeRect(el.x-w/2,el.y-h/2,w,h); }
|
||
else if(el.elType==='interfaceProv'||el.elType==='interfaceReq'){ c.beginPath(); c.arc(el.x,el.y,20/z,0,Math.PI*2); c.stroke(); }
|
||
else if(el.elType==='package'){ const w=(el.width||200)+10/z,h=(el.height||150)+28/z; c.strokeRect(el.x-5/z,el.y-23/z,w,h); }
|
||
else if(el.elType==='node'||el.elType==='device'||el.elType==='execEnv'){ const w=(el.width||160)+30/z,h=(el.height||100)+30/z; c.strokeRect(el.x-w/2,el.y-h/2,w,h); }
|
||
else if(el.elType==='artifact'){ const w=(el.width||120)+10/z,h=(el.height||50)+10/z; c.strokeRect(el.x-w/2,el.y-h/2,w,h); }
|
||
else if(el.elType==='class'||el.elType==='interfaceClass'||el.elType==='abstractClass'||el.elType==='enumeration'){ const w=(el.width||180)+10/z,h=(el._computedH||100)+10/z; c.strokeRect(el.x-w/2,el.y-h/2,w,h); }
|
||
else if(el.elType==='participant'){ const w=(el.width||120)+10/z,h=50/z; c.strokeRect(el.x-w/2,el.y-h/2,w,h); }
|
||
else if(el.elType==='activation'){ c.strokeRect(el.x-10/z,el.y-4/z,20/z,(el.height||60/z)+8/z); }
|
||
else if(el.elType==='fragment'){ const w=(el.width||300)+10/z,h=(el.height||150)+10/z; c.strokeRect(el.x-5/z,el.y-5/z,w,h); }
|
||
else if(el.elType==='swimlane'){ const w=(el.width||400)+10/z,h=(el.height||300)+10/z; c.strokeRect(el.x-5/z,el.y-5/z,w,h); }
|
||
c.setLineDash([]);
|
||
}
|
||
|
||
// ===== HELPERS =====
|
||
function findEl(id,d){
|
||
if(d.diagramType==='usecase'){ let e=d.actors.find(a=>a.id===id); if(e) return {...e,elType:'actor'}; e=d.useCases.find(u=>u.id===id); if(e) return {...e,elType:'useCase'}; if(d.systemBoundary?.id===id) return {...d.systemBoundary,elType:'systemBoundary'}; }
|
||
else if(d.diagramType==='statemachine'){ let e=(d.states||[]).find(s=>s.id===id); if(e) return {...e,elType:e.stateType}; }
|
||
else if(d.diagramType==='activity'){ let e=(d.nodes||[]).find(n=>n.id===id); if(e) return {...e,elType:e.nodeType}; }
|
||
else if(d.diagramType==='component'){ return findElInComp(id,d); }
|
||
else if(d.diagramType==='deployment'){ return findElInDeploy(id,d); }
|
||
else if(d.diagramType==='class'){ let e=(d.classes||[]).find(cls=>cls.id===id); if(e) return {...e,elType:e.classType}; }
|
||
else if(d.diagramType==='sequence'){ let e=(d.participants||[]).find(p=>p.id===id); if(e) return {...e,elType:'participant'}; e=(d.activations||[]).find(a=>a.id===id); if(e) return {...e,elType:'activation'}; e=(d.fragments||[]).find(f=>f.id===id); if(e) return {...e,elType:'fragment'}; }
|
||
else if(d.diagramType==='swimlane'){ let e=(d.swimlanes||[]).find(s=>s.id===id); if(e) return {...e,elType:'swimlane'}; e=(d.swNodes||[]).find(n=>n.id===id); if(e) return {...e,elType:e.nodeType}; }
|
||
return null;
|
||
}
|
||
|
||
function findElInComp(id,d){ let e=(d.components||[]).find(c=>c.id===id); if(e) return {...e,elType:'component'}; e=(d.interfaces||[]).find(i=>i.id===id); if(e) return {...e,elType:e.ifaceType==='provided'?'interfaceProv':'interfaceReq'}; e=(d.packages||[]).find(p=>p.id===id); if(e) return {...e,elType:'package'}; return null; }
|
||
function findElInDeploy(id,d){ let e=(d.deployNodes||[]).find(n=>n.id===id); if(e) return {...e,elType:e.nodeType}; e=(d.artifacts||[]).find(a=>a.id===id); if(e) return {...e,elType:'artifact'}; return null; }
|
||
|
||
function elCenter(el){ return {x:el.x,y:el.y}; }
|
||
|
||
// Convert screen coordinates to world coordinates (accounting for zoom & pan)
|
||
function screenToWorld(sx, sy){
|
||
return {
|
||
x: (sx - state.panX) / state.zoom,
|
||
y: (sy - state.panY) / state.zoom
|
||
};
|
||
}
|
||
|
||
function hitTest(x,y){
|
||
const d=getActive(); if(!d) return null;
|
||
if(d.diagramType==='usecase'){ for(const a of d.actors) if(Math.abs(x-a.x)<25&&Math.abs(y-a.y)<45) return {...a,elType:'actor'}; for(const u of d.useCases){ const dx=(x-u.x)/(u.radius*1.5),dy=(y-u.y)/u.radius; if(dx*dx+dy*dy<=1) return {...u,elType:'useCase'}; } if(d.systemBoundary){ const s=d.systemBoundary; if(x>=s.x&&x<=s.x+s.width&&y>=s.y&&y<=s.y+s.height) return {...s,elType:'systemBoundary'}; } }
|
||
else if(d.diagramType==='statemachine'){ for(const s of (d.states||[])){ if(s.stateType==='initial'){ if(Math.hypot(x-s.x,y-s.y)<=14) return {...s,elType:'initialState'}; } else if(s.stateType==='final'){ if(Math.hypot(x-s.x,y-s.y)<=18) return {...s,elType:'finalState'}; } else if(s.stateType==='state'){ const w=(s.width||120)/2,h=(s.height||50)/2; if(x>=s.x-w&&x<=s.x+w&&y>=s.y-h&&y<=s.y+h) return {...s,elType:'state'}; } else if(s.stateType==='composite'){ const w=(s.width||180)/2,h=(s.height||120)/2; if(x>=s.x-w&&x<=s.x+w&&y>=s.y-h&&y<=s.y+h) return {...s,elType:'compositeState'}; } } }
|
||
else if(d.diagramType==='activity'){ for(const n of (d.nodes||[])){ if(n.nodeType==='initialNode'||n.nodeType==='finalNode'){ if(Math.hypot(x-n.x,y-n.y)<=18) return {...n,elType:n.nodeType}; } else if(n.nodeType==='action'){ const w=(n.width||120)/2,h=(n.height||50)/2; if(x>=n.x-w&&x<=n.x+w&&y>=n.y-h&&y<=n.y+h) return {...n,elType:'action'}; } else if(n.nodeType==='decision'||n.nodeType==='merge'){ const s=n.size||40; const dx=Math.abs(x-n.x),dy=Math.abs(y-n.y); if(dx+dy<=s) return {...n,elType:n.nodeType}; } else if(n.nodeType==='forkJoin'){ const w=n.orientation==='horizontal'?(n.width||150)/2:3, h=n.orientation==='horizontal'?3:(n.height||150)/2; if(x>=n.x-w&&x<=n.x+w&&y>=n.y-h&&y<=n.y+h) return {...n,elType:'forkJoin'}; } } }
|
||
else if(d.diagramType==='component'){ for(const comp of (d.components||[])){ const w=(comp.width||140)/2, h=(comp.height||70)/2; if(x>=comp.x-w&&x<=comp.x+w&&y>=comp.y-h&&y<=comp.y+h) return {...comp,elType:'component'}; } for(const iface of (d.interfaces||[])){ if(Math.hypot(x-iface.x,y-iface.y)<=15) return {...iface,elType:iface.ifaceType==='provided'?'interfaceProv':'interfaceReq'}; } for(const pkg of (d.packages||[])){ const w=pkg.width||200, h=pkg.height||150; if(x>=pkg.x&&x<=pkg.x+w&&y>=pkg.y-18&&y<=pkg.y+h) return {...pkg,elType:'package'}; } }
|
||
else if(d.diagramType==='deployment'){ for(const art of (d.artifacts||[])){ const w=(art.width||120)/2, h=(art.height||50)/2; if(x>=art.x-w&&x<=art.x+w&&y>=art.y-h&&y<=art.y+h) return {...art,elType:'artifact'}; } for(const n of (d.deployNodes||[])){ const w=(n.width||160)/2, h=(n.height||100)/2; if(x>=n.x-w&&x<=n.x+w&&y>=n.y-h&&y<=n.y+h) return {...n,elType:n.nodeType}; } }
|
||
else if(d.diagramType==='class'){ for(const cls of (d.classes||[])){ const w=(cls.width||180)/2, h=(cls._computedH||100)/2; if(x>=cls.x-w&&x<=cls.x+w&&y>=cls.y-h&&y<=cls.y+h) return {...cls,elType:cls.classType}; } }
|
||
else if(d.diagramType==='sequence'){ for(const p of (d.participants||[])){ const w=(p.width||120)/2, h=20; if(x>=p.x-w&&x<=p.x+w&&y>=p.y-h&&y<=p.y+h) return {...p,elType:'participant'}; } for(const a of (d.activations||[])){ const part=(d.participants||[]).find(p=>p.id===a.participantId); if(part){ const h=a.height||60; if(x>=part.x-6&&x<=part.x+6&&y>=a.y&&y<=a.y+h) return {...a,elType:'activation'}; } } for(const f of (d.fragments||[])){ const w=f.width||300, h=f.height||150; if(x>=f.x&&x<=f.x+w&&y>=f.y&&y<=f.y+h) return {...f,elType:'fragment'}; } }
|
||
else if(d.diagramType==='swimlane'){
|
||
for(const sw of (d.swimlanes||[])){
|
||
const w=sw.width||400, h=sw.height||300;
|
||
if(x>=sw.x&&x<=sw.x+w&&y>=sw.y&&y<=sw.y+h) return {...sw,elType:'swimlane'};
|
||
}
|
||
for(const n of (d.swNodes||[])){
|
||
if(n.nodeType==='swInitial'||n.nodeType==='swFinal'){ if(Math.hypot(x-n.x,y-n.y)<=18) return {...n,elType:n.nodeType}; }
|
||
else if(n.nodeType==='swAction'){ const w=(n.width||120)/2,h=(n.height||50)/2; if(x>=n.x-w&&x<=n.x+w&&y>=n.y-h&&y<=n.y+h) return {...n,elType:'swAction'}; }
|
||
else if(n.nodeType==='swDecision'){ const s=n.size||40; const dx=Math.abs(x-n.x),dy=Math.abs(y-n.y); if(dx+dy<=s) return {...n,elType:'swDecision'}; }
|
||
else if(n.nodeType==='swForkJoin'){ const w=n.orientation==='horizontal'?(n.width||150)/2:3, h=n.orientation==='horizontal'?3:(n.height||150)/2; if(x>=n.x-w&&x<=n.x+w&&y>=n.y-h&&y<=n.y+h) return {...n,elType:'swForkJoin'}; }
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== CANVAS EVENTS =====
|
||
function getPos(e){
|
||
const r=canvas.getBoundingClientRect();
|
||
const sx = (e.clientX-r.left)*(canvas.width/r.width);
|
||
const sy = (e.clientY-r.top)*(canvas.height/r.height);
|
||
return screenToWorld(sx, sy);
|
||
}
|
||
|
||
canvas.addEventListener('mousedown',e=>{
|
||
const d=getActive(); if(!d||canvas.width<=0) return;
|
||
const {x,y}=getPos(e);
|
||
const tool=state.currentTool;
|
||
|
||
// Pan with middle mouse or space+left click
|
||
if(e.button === 1 || (e.button === 0 && state.spacePressed)){
|
||
state.isPanning = true;
|
||
state.panStart = { x: e.clientX - state.panX, y: e.clientY - state.panY };
|
||
canvas.style.cursor = 'grabbing';
|
||
return;
|
||
}
|
||
|
||
if(tool==='select'){
|
||
const el=hitTest(x,y);
|
||
|
||
if(el&&el.elType==='swimlane'){
|
||
const handles = getResizeHandles(el);
|
||
for(const h of handles){
|
||
if(Math.abs(x-h.x)<=6&&Math.abs(y-h.y)<=6){
|
||
state.resizingSwimlane = el;
|
||
state.resizeHandle = h.pos;
|
||
state.dragStart = {x, y, elX:el.x, elY:el.y, elW:el.width||400, elH:el.height||300};
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
state.selectedElement=el; updatePropsPanel();
|
||
if(el){state.isDragging=true;state.dragStart={x,y,elX:el.x||0,elY:el.y||0};}
|
||
}
|
||
else if(tool==='actor'){ d.actors.push({id:genElId(d),name:'Actor',x,y}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='useCase'){ d.useCases.push({id:genElId(d),name:'Use Case',x,y,radius:40}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='boundary'){ if(!d.systemBoundary){d.systemBoundary={id:genElId(d),name:'System',x:x-150,y:y-100,width:300,height:250};pushHistory(d);updateInfo();draw();switchToSelect();return;} }
|
||
else if(tool==='initialState'){ d.states.push({id:genElId(d),stateType:'initial',name:'',x,y}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='state'){ d.states.push({id:genElId(d),stateType:'state',name:'State',x,y,width:120,height:50}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='finalState'){ d.states.push({id:genElId(d),stateType:'final',name:'',x,y}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='compositeState'){ d.states.push({id:genElId(d),stateType:'composite',name:'Composite',x,y,width:180,height:120}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='initialNode'){ d.nodes.push({id:genElId(d),nodeType:'initialNode',name:'',x,y}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='finalNode'){ d.nodes.push({id:genElId(d),nodeType:'finalNode',name:'',x,y}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='action'){ d.nodes.push({id:genElId(d),nodeType:'action',name:'Action',x,y,width:120,height:50}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='decision'){ d.nodes.push({id:genElId(d),nodeType:'decision',name:'',x,y,size:40}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='merge'){ d.nodes.push({id:genElId(d),nodeType:'merge',name:'',x,y,size:40}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='forkJoin'){ d.nodes.push({id:genElId(d),nodeType:'forkJoin',name:'',x,y,width:150,height:150,orientation:'horizontal'}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='component'){ d.components.push({id:genElId(d),name:'Component',x,y,width:140,height:70}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='interfaceProv'){ d.interfaces.push({id:genElId(d),ifaceType:'provided',name:'IProvided',x,y,direction:'right'}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='interfaceReq'){ d.interfaces.push({id:genElId(d),ifaceType:'required',name:'IRequired',x,y,direction:'right'}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='package'){ d.packages.push({id:genElId(d),name:'Package',x:x-100,y:y-90,width:200,height:150}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='node'){ d.deployNodes.push({id:genElId(d),nodeType:'node',name:'Node',x:x-80,y:y-50,width:160,height:100}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='device'){ d.deployNodes.push({id:genElId(d),nodeType:'device',name:'Device',x:x-80,y:y-50,width:160,height:100}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='execEnv'){ d.deployNodes.push({id:genElId(d),nodeType:'execEnv',name:'ExecEnv',x:x-80,y:y-50,width:160,height:100}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='artifact'){ d.artifacts.push({id:genElId(d),name:'Artifact',x,y,width:120,height:50}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='class'){ d.classes.push({id:genElId(d),classType:'class',name:'NewClass',x,y,width:180,attributes:[],methods:[]}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='interfaceClass'){ d.classes.push({id:genElId(d),classType:'interface',name:'INewInterface',x,y,width:180,attributes:[],methods:[]}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='abstractClass'){ d.classes.push({id:genElId(d),classType:'abstract',name:'AbstractClass',x,y,width:180,attributes:[],methods:[]}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='enumeration'){ d.classes.push({id:genElId(d),classType:'enumeration',name:'Enum',x,y,width:180,attributes:[],methods:[]}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='participant'){ d.participants.push({id:genElId(d),name:'Participant',x,y:60,width:120}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='activation'){
|
||
let nearest=null, minDist=Infinity;
|
||
(d.participants||[]).forEach(p=>{ const dist=Math.abs(x-p.x); if(dist<minDist){minDist=dist;nearest=p;} });
|
||
if(nearest && minDist<50){
|
||
d.activations.push({id:genElId(d),participantId:nearest.id,x:nearest.x,y:y,width:12,height:60});
|
||
pushHistory(d); updateInfo(); draw(); switchToSelect(); return;
|
||
}
|
||
}
|
||
else if(tool==='fragment'){
|
||
const fragType = prompt('نوع Fragment (alt/opt/loop/break):','alt') || 'alt';
|
||
if(['alt','opt','loop','break'].includes(fragType)){
|
||
d.fragments.push({id:genElId(d),fragmentType:fragType,label:'',x,y,width:300,height:150,operands:[{label:'',height:70},{label:'',height:70}]});
|
||
pushHistory(d); updateInfo(); draw(); switchToSelect(); return;
|
||
}
|
||
}
|
||
else if(tool==='swimlane'){ d.swimlanes.push({id:genElId(d),name:'Swimlane',x:x-200,y:y-150,width:400,height:300,orientation:'vertical',color:null}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='swAction'){ d.swNodes.push({id:genElId(d),nodeType:'swAction',name:'Action',x,y,width:120,height:50}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='swDecision'){ d.swNodes.push({id:genElId(d),nodeType:'swDecision',name:'',x,y,size:40}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='swForkJoin'){ d.swNodes.push({id:genElId(d),nodeType:'swForkJoin',name:'',x,y,width:150,height:150,orientation:'horizontal'}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='swInitial'){ d.swNodes.push({id:genElId(d),nodeType:'swInitial',name:'',x,y}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(tool==='swFinal'){ d.swNodes.push({id:genElId(d),nodeType:'swFinal',name:'',x,y}); pushHistory(d); updateInfo(); draw(); switchToSelect(); return; }
|
||
else if(['association','include','extend','generalization','transition','controlFlow','objectFlow','dependency','connector','compAssociation','commPath','deployRel','manifestRel','clsAssociation','aggregation','composition','inheritance','clsDependency','realization','syncMsg','asyncMsg','returnMsg','selfMsg','swFlow'].includes(tool)){
|
||
const el=hitTest(x,y);
|
||
if(el){state.isDrawingRelationship=true;state.relationshipStart=elCenter(el);state.relationshipStartId=el.id;}
|
||
}
|
||
draw();
|
||
});
|
||
|
||
canvas.addEventListener('mousemove',e=>{
|
||
const d=getActive(); if(!d) return;
|
||
const {x,y}=getPos(e); state.mousePos={x,y};
|
||
|
||
// Panning
|
||
if(state.isPanning){
|
||
state.panX = e.clientX - state.panStart.x;
|
||
state.panY = e.clientY - state.panStart.y;
|
||
draw();
|
||
return;
|
||
}
|
||
|
||
if(state.resizingSwimlane){
|
||
const dx=x-state.dragStart.x, dy=y-state.dragStart.y;
|
||
const sw=state.resizingSwimlane;
|
||
const i=(d.swimlanes||[]).findIndex(s=>s.id===sw.id);
|
||
if(i>=0){
|
||
const handle = state.resizeHandle;
|
||
let newX=state.dragStart.elX, newY=state.dragStart.elY, newW=state.dragStart.elW, newH=state.dragStart.elH;
|
||
|
||
if(handle.includes('r')) newW = Math.max(100, state.dragStart.elW + dx);
|
||
if(handle.includes('l')){ newX = state.dragStart.elX + dx; newW = Math.max(100, state.dragStart.elW - dx); }
|
||
if(handle.includes('b')) newH = Math.max(100, state.dragStart.elH + dy);
|
||
if(handle.includes('t')){ newY = state.dragStart.elY + dy; newH = Math.max(100, state.dragStart.elH - dy); }
|
||
|
||
d.swimlanes[i].x = newX;
|
||
d.swimlanes[i].y = newY;
|
||
d.swimlanes[i].width = newW;
|
||
d.swimlanes[i].height = newH;
|
||
}
|
||
draw();
|
||
return;
|
||
}
|
||
|
||
if(state.isDragging&&state.selectedElement){
|
||
const dx=x-state.dragStart.x, dy=y-state.dragStart.y;
|
||
const el=state.selectedElement;
|
||
if(d.diagramType==='usecase'){
|
||
if(el.elType==='actor'){const i=d.actors.findIndex(a=>a.id===el.id);if(i>=0){d.actors[i].x=state.dragStart.elX+dx;d.actors[i].y=state.dragStart.elY+dy;}}
|
||
else if(el.elType==='useCase'){const i=d.useCases.findIndex(u=>u.id===el.id);if(i>=0){d.useCases[i].x=state.dragStart.elX+dx;d.useCases[i].y=state.dragStart.elY+dy;}}
|
||
else if(el.elType==='systemBoundary'){d.systemBoundary.x=state.dragStart.elX+dx;d.systemBoundary.y=state.dragStart.elY+dy;}
|
||
} else if(d.diagramType==='statemachine'){ const i=(d.states||[]).findIndex(s=>s.id===el.id); if(i>=0){d.states[i].x=state.dragStart.elX+dx;d.states[i].y=state.dragStart.elY+dy;} }
|
||
else if(d.diagramType==='activity'){ const i=(d.nodes||[]).findIndex(n=>n.id===el.id); if(i>=0){d.nodes[i].x=state.dragStart.elX+dx;d.nodes[i].y=state.dragStart.elY+dy;} }
|
||
else if(d.diagramType==='component'){ if(el.elType==='component'){const i=d.components.findIndex(c=>c.id===el.id);if(i>=0){d.components[i].x=state.dragStart.elX+dx;d.components[i].y=state.dragStart.elY+dy;}} else if(el.elType==='interfaceProv'||el.elType==='interfaceReq'){const i=d.interfaces.findIndex(iface=>iface.id===el.id);if(i>=0){d.interfaces[i].x=state.dragStart.elX+dx;d.interfaces[i].y=state.dragStart.elY+dy;}} else if(el.elType==='package'){const i=d.packages.findIndex(p=>p.id===el.id);if(i>=0){d.packages[i].x=state.dragStart.elX+dx;d.packages[i].y=state.dragStart.elY+dy;}} }
|
||
else if(d.diagramType==='deployment'){ if(el.elType==='node'||el.elType==='device'||el.elType==='execEnv'){const i=d.deployNodes.findIndex(n=>n.id===el.id);if(i>=0){d.deployNodes[i].x=state.dragStart.elX+dx;d.deployNodes[i].y=state.dragStart.elY+dy;}} else if(el.elType==='artifact'){const i=d.artifacts.findIndex(a=>a.id===el.id);if(i>=0){d.artifacts[i].x=state.dragStart.elX+dx;d.artifacts[i].y=state.dragStart.elY+dy;}} }
|
||
else if(d.diagramType==='class'){ const i=(d.classes||[]).findIndex(cls=>cls.id===el.id); if(i>=0){d.classes[i].x=state.dragStart.elX+dx;d.classes[i].y=state.dragStart.elY+dy;} }
|
||
else if(d.diagramType==='sequence'){
|
||
if(el.elType==='participant'){const i=d.participants.findIndex(p=>p.id===el.id);if(i>=0){d.participants[i].x=state.dragStart.elX+dx;d.participants[i].y=state.dragStart.elY+dy;}}
|
||
else if(el.elType==='activation'){const i=d.activations.findIndex(a=>a.id===el.id);if(i>=0){d.activations[i].y=state.dragStart.elY+dy;}}
|
||
else if(el.elType==='fragment'){const i=d.fragments.findIndex(f=>f.id===el.id);if(i>=0){d.fragments[i].x=state.dragStart.elX+dx;d.fragments[i].y=state.dragStart.elY+dy;}}
|
||
}
|
||
else if(d.diagramType==='swimlane'){
|
||
if(el.elType==='swimlane'){const i=d.swimlanes.findIndex(s=>s.id===el.id);if(i>=0){d.swimlanes[i].x=state.dragStart.elX+dx;d.swimlanes[i].y=state.dragStart.elY+dy;}}
|
||
else {const i=(d.swNodes||[]).findIndex(n=>n.id===el.id);if(i>=0){d.swNodes[i].x=state.dragStart.elX+dx;d.swNodes[i].y=state.dragStart.elY+dy;}}
|
||
}
|
||
updatePropsPanel(); draw();
|
||
} else if(state.isDrawingRelationship) draw();
|
||
});
|
||
|
||
canvas.addEventListener('mouseup',e=>{
|
||
const d=getActive(); if(!d) return;
|
||
|
||
// End panning
|
||
if(state.isPanning){
|
||
state.isPanning = false;
|
||
canvas.style.cursor = 'crosshair';
|
||
return;
|
||
}
|
||
|
||
const {x,y}=getPos(e);
|
||
|
||
if(state.resizingSwimlane){
|
||
pushHistory(d);
|
||
state.resizingSwimlane = null;
|
||
state.resizeHandle = null;
|
||
return;
|
||
}
|
||
|
||
if(state.isDrawingRelationship){
|
||
const el=hitTest(x,y);
|
||
if(el&&el.id!==state.relationshipStartId){
|
||
let changed = false;
|
||
if(state.currentTool==='transition'){ const label = prompt('برچسب Transition (اختیاری):','') || ''; d.transitions.push({id:genElId(d),from:state.relationshipStartId,to:el.id,label:label}); changed=true; }
|
||
else if(state.currentTool==='controlFlow'||state.currentTool==='objectFlow'){ const label = prompt('برچسب Flow (اختیاری):','') || ''; d.flows.push({id:genElId(d),from:state.relationshipStartId,to:el.id,flowType:state.currentTool,label:label}); changed=true; }
|
||
else if(state.currentTool==='dependency'||state.currentTool==='connector'||state.currentTool==='compAssociation'){ const label = prompt('برچسب رابطه (اختیاری):','') || ''; d.compRelations.push({id:genElId(d),from:state.relationshipStartId,to:el.id,relType:state.currentTool,label:label}); changed=true; }
|
||
else if(state.currentTool==='commPath'||state.currentTool==='deployRel'||state.currentTool==='manifestRel'){ const label = prompt('برچسب رابطه (اختیاری):','') || ''; d.deployRelations.push({id:genElId(d),from:state.relationshipStartId,to:el.id,relType:state.currentTool,label:label}); changed=true; }
|
||
else if(state.currentTool==='clsAssociation'||state.currentTool==='aggregation'||state.currentTool==='composition'||state.currentTool==='inheritance'||state.currentTool==='clsDependency'||state.currentTool==='realization'){
|
||
const label = prompt('برچسب رابطه (اختیاری):','') || '';
|
||
const fromMult = prompt('Multiplicity مبدأ (مثال: 1, 0..*, 1..*):','') || '';
|
||
const toMult = prompt('Multiplicity مقصد (مثال: 1, 0..*, 1..*):','') || '';
|
||
d.classRelations.push({id:genElId(d),from:state.relationshipStartId,to:el.id,relType:state.currentTool,label:label,fromMultiplicity:fromMult,toMultiplicity:toMult});
|
||
changed=true;
|
||
}
|
||
else if(state.currentTool==='syncMsg'||state.currentTool==='asyncMsg'||state.currentTool==='returnMsg'){
|
||
const label = prompt('برچسب پیام (مثال: doSomething()):','') || '';
|
||
d.messages.push({id:genElId(d),fromParticipant:state.relationshipStartId,toParticipant:el.id,msgType:state.currentTool,label:label,y:y});
|
||
changed=true;
|
||
}
|
||
else if(state.currentTool==='selfMsg'){
|
||
const label = prompt('برچسب پیام خودی:','') || '';
|
||
d.messages.push({id:genElId(d),fromParticipant:state.relationshipStartId,toParticipant:state.relationshipStartId,msgType:'selfMsg',label:label,y:y});
|
||
changed=true;
|
||
}
|
||
else if(state.currentTool==='swFlow'){
|
||
const label = prompt('برچسب Flow (اختیاری):','') || '';
|
||
d.swFlows.push({id:genElId(d),from:state.relationshipStartId,to:el.id,label:label});
|
||
changed=true;
|
||
}
|
||
else { d.relationships.push({id:genElId(d),from:state.relationshipStartId,to:el.id,type:state.currentTool}); changed=true; }
|
||
if(changed) pushHistory(d);
|
||
updateInfo(); draw(); switchToSelect();
|
||
}
|
||
state.isDrawingRelationship=false; state.relationshipStart=null; state.relationshipStartId=null;
|
||
}
|
||
if(state.isDragging && state.selectedElement){
|
||
pushHistory(d);
|
||
}
|
||
state.isDragging=false; draw();
|
||
});
|
||
|
||
canvas.addEventListener('dblclick', e=>{
|
||
const d=getActive(); if(!d) return;
|
||
const {x,y}=getPos(e);
|
||
const el=hitTest(x,y);
|
||
if(!el) return;
|
||
|
||
if((el.elType==='interfaceProv'||el.elType==='interfaceReq') && d.diagramType==='component'){
|
||
const i=d.interfaces.findIndex(iface=>iface.id===el.id);
|
||
if(i>=0){ const dirs=['right','down','left','up']; const cur=d.interfaces[i].direction||'right'; d.interfaces[i].direction = dirs[(dirs.indexOf(cur)+1)%4]; pushHistory(d); draw(); }
|
||
}
|
||
else if((el.elType==='class'||el.elType==='interfaceClass'||el.elType==='abstractClass'||el.elType==='enumeration') && d.diagramType==='class'){
|
||
openClassEditor(el.id);
|
||
}
|
||
else if(el.elType==='swimlane' && d.diagramType==='swimlane'){
|
||
const i=d.swimlanes.findIndex(s=>s.id===el.id);
|
||
if(i>=0){
|
||
d.swimlanes[i].orientation = d.swimlanes[i].orientation==='vertical' ? 'horizontal' : 'vertical';
|
||
pushHistory(d);
|
||
draw();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Zoom with mouse wheel (Ctrl + Wheel)
|
||
canvas.addEventListener('wheel', e=>{
|
||
e.preventDefault();
|
||
if(e.ctrlKey || e.metaKey){
|
||
const rect = canvas.getBoundingClientRect();
|
||
const centerX = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||
const centerY = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||
setZoom(state.zoom * delta, centerX, centerY);
|
||
}
|
||
}, {passive: false});
|
||
|
||
// ===== CLASS EDITOR =====
|
||
const classEditorModal = document.getElementById('classEditorModal');
|
||
let classEditorData = { attributes: [], methods: [] };
|
||
let activeEditorTab = 'attributes';
|
||
|
||
function openClassEditor(classId){
|
||
const d=getActive(); if(!d) return;
|
||
const cls = (d.classes||[]).find(c=>c.id===classId);
|
||
if(!cls) return;
|
||
|
||
state.editingClassId = classId;
|
||
document.getElementById('classEditorName').value = cls.name || '';
|
||
classEditorData = {
|
||
attributes: JSON.parse(JSON.stringify(cls.attributes||[])),
|
||
methods: JSON.parse(JSON.stringify(cls.methods||[]))
|
||
};
|
||
|
||
activeEditorTab = 'attributes';
|
||
updateEditorTabsUI();
|
||
renderAttributesTable();
|
||
renderMethodsTable();
|
||
|
||
classEditorModal.classList.add('visible');
|
||
}
|
||
|
||
function closeClassEditor(){
|
||
classEditorModal.classList.remove('visible');
|
||
state.editingClassId = null;
|
||
}
|
||
|
||
function updateEditorTabsUI(){
|
||
document.querySelectorAll('.class-editor-tab').forEach(t=>{
|
||
t.classList.toggle('active', t.dataset.tab===activeEditorTab);
|
||
});
|
||
document.getElementById('attributesTab').style.display = activeEditorTab==='attributes'?'':'none';
|
||
document.getElementById('methodsTab').style.display = activeEditorTab==='methods'?'':'none';
|
||
}
|
||
|
||
document.querySelectorAll('.class-editor-tab').forEach(t=>{
|
||
t.addEventListener('click', ()=>{
|
||
activeEditorTab = t.dataset.tab;
|
||
updateEditorTabsUI();
|
||
});
|
||
});
|
||
|
||
function renderAttributesTable(){
|
||
const tbody = document.getElementById('attributesTableBody');
|
||
tbody.innerHTML = '';
|
||
classEditorData.attributes.forEach((attr, idx)=>{
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>
|
||
<select data-idx="${idx}" data-field="visibility">
|
||
<option value="+" ${attr.visibility==='+'?'selected':''}>+ public</option>
|
||
<option value="-" ${attr.visibility==='-'?'selected':''}>- private</option>
|
||
<option value="#" ${attr.visibility==='#'?'selected':''}># protected</option>
|
||
<option value="~" ${attr.visibility==='~'?'selected':''}>~ package</option>
|
||
</select>
|
||
</td>
|
||
<td><input type="text" value="${attr.name||''}" data-idx="${idx}" data-field="name" placeholder="نام"></td>
|
||
<td><input type="text" value="${attr.type||''}" data-idx="${idx}" data-field="type" placeholder="نوع"></td>
|
||
<td style="text-align:center"><input type="checkbox" ${attr.isStatic?'checked':''} data-idx="${idx}" data-field="isStatic"></td>
|
||
<td><button class="member-btn member-btn-del" data-idx="${idx}" data-action="del-attr">×</button></td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
tbody.querySelectorAll('select, input').forEach(el=>{
|
||
el.addEventListener('change', e=>{
|
||
const idx = parseInt(e.target.dataset.idx);
|
||
const field = e.target.dataset.field;
|
||
if(field==='isStatic'){
|
||
classEditorData.attributes[idx].isStatic = e.target.checked;
|
||
} else {
|
||
classEditorData.attributes[idx][field] = e.target.value;
|
||
}
|
||
});
|
||
});
|
||
tbody.querySelectorAll('[data-action="del-attr"]').forEach(btn=>{
|
||
btn.addEventListener('click', e=>{
|
||
const idx = parseInt(e.target.dataset.idx);
|
||
classEditorData.attributes.splice(idx, 1);
|
||
renderAttributesTable();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderMethodsTable(){
|
||
const tbody = document.getElementById('methodsTableBody');
|
||
tbody.innerHTML = '';
|
||
classEditorData.methods.forEach((meth, idx)=>{
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>
|
||
<select data-idx="${idx}" data-field="visibility">
|
||
<option value="+" ${meth.visibility==='+'?'selected':''}>+ public</option>
|
||
<option value="-" ${meth.visibility==='-'?'selected':''}>- private</option>
|
||
<option value="#" ${meth.visibility==='#'?'selected':''}># protected</option>
|
||
<option value="~" ${meth.visibility==='~'?'selected':''}>~ package</option>
|
||
</select>
|
||
</td>
|
||
<td><input type="text" value="${meth.name||''}" data-idx="${idx}" data-field="name" placeholder="نام"></td>
|
||
<td><input type="text" value="${meth.params||''}" data-idx="${idx}" data-field="params" placeholder="x: int, y: string"></td>
|
||
<td><input type="text" value="${meth.returnType||''}" data-idx="${idx}" data-field="returnType" placeholder="void"></td>
|
||
<td style="text-align:center"><input type="checkbox" ${meth.isStatic?'checked':''} data-idx="${idx}" data-field="isStatic"></td>
|
||
<td><button class="member-btn member-btn-del" data-idx="${idx}" data-action="del-meth">×</button></td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
tbody.querySelectorAll('select, input').forEach(el=>{
|
||
el.addEventListener('change', e=>{
|
||
const idx = parseInt(e.target.dataset.idx);
|
||
const field = e.target.dataset.field;
|
||
if(field==='isStatic'){
|
||
classEditorData.methods[idx].isStatic = e.target.checked;
|
||
} else {
|
||
classEditorData.methods[idx][field] = e.target.value;
|
||
}
|
||
});
|
||
});
|
||
tbody.querySelectorAll('[data-action="del-meth"]').forEach(btn=>{
|
||
btn.addEventListener('click', e=>{
|
||
const idx = parseInt(e.target.dataset.idx);
|
||
classEditorData.methods.splice(idx, 1);
|
||
renderMethodsTable();
|
||
});
|
||
});
|
||
}
|
||
|
||
document.getElementById('addAttrBtn').addEventListener('click', ()=>{
|
||
classEditorData.attributes.push({visibility:'+',name:'newAttr',type:'String',isStatic:false});
|
||
renderAttributesTable();
|
||
});
|
||
document.getElementById('addMethodBtn').addEventListener('click', ()=>{
|
||
classEditorData.methods.push({visibility:'+',name:'newMethod',params:'',returnType:'void',isStatic:false});
|
||
renderMethodsTable();
|
||
});
|
||
|
||
document.getElementById('closeClassEditor').addEventListener('click', closeClassEditor);
|
||
document.getElementById('cancelClassEditor').addEventListener('click', closeClassEditor);
|
||
classEditorModal.addEventListener('click', e=>{ if(e.target===classEditorModal) closeClassEditor(); });
|
||
|
||
document.getElementById('saveClassEditor').addEventListener('click', ()=>{
|
||
const d=getActive(); if(!d) return;
|
||
const cls = (d.classes||[]).find(c=>c.id===state.editingClassId);
|
||
if(!cls) return;
|
||
|
||
cls.name = document.getElementById('classEditorName').value.trim() || 'Unnamed';
|
||
cls.attributes = classEditorData.attributes;
|
||
cls.methods = classEditorData.methods;
|
||
|
||
pushHistory(d);
|
||
closeClassEditor();
|
||
draw();
|
||
updateStatus('✅ Class ویرایش شد');
|
||
});
|
||
|
||
// ===== PROPERTIES INPUT =====
|
||
document.getElementById('elementName').addEventListener('input',e=>{
|
||
if(!state.selectedElement) return;
|
||
const d=getActive(); if(!d) return;
|
||
const id=state.selectedElement.id, v=e.target.value;
|
||
if(d.diagramType==='usecase'){ if(state.selectedElement.elType==='actor'){const i=d.actors.findIndex(a=>a.id===id);if(i>=0)d.actors[i].name=v;} else if(state.selectedElement.elType==='useCase'){const i=d.useCases.findIndex(u=>u.id===id);if(i>=0)d.useCases[i].name=v;} else if(state.selectedElement.elType==='systemBoundary') d.systemBoundary.name=v; }
|
||
else if(d.diagramType==='statemachine'){ const i=(d.states||[]).findIndex(s=>s.id===id); if(i>=0) d.states[i].name=v; }
|
||
else if(d.diagramType==='activity'){ const i=(d.nodes||[]).findIndex(n=>n.id===id); if(i>=0) d.nodes[i].name=v; }
|
||
else if(d.diagramType==='component'){ if(state.selectedElement.elType==='component'){const i=d.components.findIndex(c=>c.id===id);if(i>=0)d.components[i].name=v;} else if(state.selectedElement.elType==='interfaceProv'||state.selectedElement.elType==='interfaceReq'){const i=d.interfaces.findIndex(iface=>iface.id===id);if(i>=0)d.interfaces[i].name=v;} else if(state.selectedElement.elType==='package'){const i=d.packages.findIndex(p=>p.id===id);if(i>=0)d.packages[i].name=v;} }
|
||
else if(d.diagramType==='deployment'){ if(state.selectedElement.elType==='node'||state.selectedElement.elType==='device'||state.selectedElement.elType==='execEnv'){const i=d.deployNodes.findIndex(n=>n.id===id);if(i>=0)d.deployNodes[i].name=v;} else if(state.selectedElement.elType==='artifact'){const i=d.artifacts.findIndex(a=>a.id===id);if(i>=0)d.artifacts[i].name=v;} }
|
||
else if(d.diagramType==='class'){ const i=(d.classes||[]).findIndex(cls=>cls.id===id); if(i>=0) d.classes[i].name=v; }
|
||
else if(d.diagramType==='sequence'){ if(state.selectedElement.elType==='participant'){const i=d.participants.findIndex(p=>p.id===id);if(i>=0)d.participants[i].name=v;} }
|
||
else if(d.diagramType==='swimlane'){
|
||
if(state.selectedElement.elType==='swimlane'){const i=d.swimlanes.findIndex(s=>s.id===id);if(i>=0)d.swimlanes[i].name=v;}
|
||
else {const i=(d.swNodes||[]).findIndex(n=>n.id===id);if(i>=0)d.swNodes[i].name=v;}
|
||
}
|
||
draw();
|
||
});
|
||
|
||
// ===== ACTION BUTTONS =====
|
||
document.getElementById('deleteBtn').addEventListener('click',()=>{
|
||
if(!state.selectedElement) return;
|
||
const d=getActive(); if(!d) return;
|
||
const {id,elType}=state.selectedElement;
|
||
if(d.diagramType==='usecase'){ if(elType==='actor') d.actors=d.actors.filter(a=>a.id!==id); else if(elType==='useCase') d.useCases=d.useCases.filter(u=>u.id!==id); else if(elType==='systemBoundary') d.systemBoundary=null; d.relationships=d.relationships.filter(r=>r.from!==id&&r.to!==id); }
|
||
else if(d.diagramType==='statemachine'){ d.states=(d.states||[]).filter(s=>s.id!==id); d.transitions=(d.transitions||[]).filter(t=>t.from!==id&&t.to!==id); }
|
||
else if(d.diagramType==='activity'){ d.nodes=(d.nodes||[]).filter(n=>n.id!==id); d.flows=(d.flows||[]).filter(f=>f.from!==id&&f.to!==id); }
|
||
else if(d.diagramType==='component'){ if(elType==='component'){ d.components=d.components.filter(c=>c.id!==id); } else if(elType==='interfaceProv'||elType==='interfaceReq'){ d.interfaces=d.interfaces.filter(i=>i.id!==id); } else if(elType==='package'){ d.packages=d.packages.filter(p=>p.id!==id); } d.compRelations=(d.compRelations||[]).filter(r=>r.from!==id&&r.to!==id); }
|
||
else if(d.diagramType==='deployment'){ if(elType==='node'||elType==='device'||elType==='execEnv'){ d.deployNodes=d.deployNodes.filter(n=>n.id!==id); } else if(elType==='artifact'){ d.artifacts=d.artifacts.filter(a=>a.id!==id); } d.deployRelations=(d.deployRelations||[]).filter(r=>r.from!==id&&r.to!==id); }
|
||
else if(d.diagramType==='class'){ d.classes=(d.classes||[]).filter(c=>c.id!==id); d.classRelations=(d.classRelations||[]).filter(r=>r.from!==id&&r.to!==id); }
|
||
else if(d.diagramType==='sequence'){
|
||
if(elType==='participant'){ d.participants=d.participants.filter(p=>p.id!==id); d.activations=d.activations.filter(a=>a.participantId!==id); d.messages=d.messages.filter(m=>m.fromParticipant!==id&&m.toParticipant!==id); }
|
||
else if(elType==='activation'){ d.activations=d.activations.filter(a=>a.id!==id); }
|
||
else if(elType==='fragment'){ d.fragments=d.fragments.filter(f=>f.id!==id); }
|
||
}
|
||
else if(d.diagramType==='swimlane'){
|
||
if(elType==='swimlane'){ d.swimlanes=d.swimlanes.filter(s=>s.id!==id); }
|
||
else { d.swNodes=(d.swNodes||[]).filter(n=>n.id!==id); }
|
||
d.swFlows=(d.swFlows||[]).filter(f=>f.from!==id&&f.to!==id);
|
||
}
|
||
pushHistory(d);
|
||
state.selectedElement=null; updatePropsPanel(); updateInfo(); draw();
|
||
});
|
||
|
||
document.getElementById('clearBtn').addEventListener('click',()=>{
|
||
const d=getActive(); if(!d) return;
|
||
if(!confirm(`پاک کردن "${d.title}"؟`)) return;
|
||
if(d.diagramType==='usecase'){d.actors=[];d.useCases=[];d.systemBoundary=null;d.relationships=[];}
|
||
else if(d.diagramType==='statemachine'){d.states=[];d.transitions=[];}
|
||
else if(d.diagramType==='activity'){d.nodes=[];d.flows=[];}
|
||
else if(d.diagramType==='component'){d.components=[];d.interfaces=[];d.packages=[];d.compRelations=[];}
|
||
else if(d.diagramType==='deployment'){d.deployNodes=[];d.artifacts=[];d.deployRelations=[];}
|
||
else if(d.diagramType==='class'){d.classes=[];d.classRelations=[];}
|
||
else if(d.diagramType==='sequence'){d.participants=[];d.activations=[];d.messages=[];d.fragments=[];}
|
||
else if(d.diagramType==='swimlane'){d.swimlanes=[];d.swNodes=[];d.swFlows=[];}
|
||
pushHistory(d);
|
||
state.selectedElement=null; updatePropsPanel(); updateInfo(); draw();
|
||
});
|
||
|
||
// ===== KEYBOARD =====
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT'){
|
||
if(e.key==='Enter'&&saveModal.classList.contains('visible')&&e.target===saveFN){e.preventDefault();document.getElementById('confirmSaveBtn').click();}
|
||
return;
|
||
}
|
||
if(e.key==='Delete'||e.key==='Backspace') document.getElementById('deleteBtn').click();
|
||
else if(e.key===' '){
|
||
e.preventDefault();
|
||
state.spacePressed = true;
|
||
canvas.style.cursor = 'grab';
|
||
}
|
||
else if(e.key==='Escape'){
|
||
if(classEditorModal.classList.contains('visible')) closeClassEditor();
|
||
else if(document.getElementById('newDiagramModal').classList.contains('visible')) hideNewDiagramModal();
|
||
else if(expModal.classList.contains('visible')) hideExpPng();
|
||
else if(saveModal.classList.contains('visible')) hideSaveModal();
|
||
else if(fileSettingsModal.classList.contains('visible')) hideFileSettings();
|
||
else if(settingsModal.classList.contains('visible')) hideSettings();
|
||
else { state.selectedElement=null; updatePropsPanel(); draw(); }
|
||
}
|
||
else if(e.ctrlKey&&e.key==='n'){e.preventDefault();showNewDiagramModal();}
|
||
else if(e.ctrlKey&&e.key==='s'){e.preventDefault();showSaveModal();}
|
||
else if(e.ctrlKey&&e.key==='z'){e.preventDefault();undo();}
|
||
else if(e.ctrlKey&&(e.key==='y'||(e.shiftKey&&e.key==='Z'))){e.preventDefault();redo();}
|
||
else if((e.key==='b'||e.key==='B')&&!e.ctrlKey&&!e.altKey) toggleSidebar();
|
||
else if((e.key==='g'||e.key==='G')&&!e.ctrlKey&&!e.altKey){ const o=['none','lines','dots']; state.gridType=o[(o.indexOf(state.gridType)+1)%3]; setGridUI(state.gridType); draw(); }
|
||
});
|
||
|
||
document.addEventListener('keyup',e=>{
|
||
if(e.key===' '){
|
||
state.spacePressed = false;
|
||
if(!state.isPanning) canvas.style.cursor = 'crosshair';
|
||
}
|
||
});
|
||
|
||
// ===== INIT =====
|
||
// Initialize collapsed groups (all except selection)
|
||
['uc-elements','uc-relations','sm-elements','sm-relations','act-elements','act-flows',
|
||
'comp-elements','comp-relations','dep-elements','dep-relations','cls-elements','cls-relations',
|
||
'seq-elements','seq-messages','sw-elements','sw-flows','display','actions','file'].forEach(g=>state.collapsedGroups.add(g));
|
||
// Apply collapsed class to all groups except selection
|
||
document.querySelectorAll('.tool-group').forEach(g=>{
|
||
if(g.dataset.group !== 'selection'){
|
||
g.classList.add('collapsed');
|
||
}
|
||
});
|
||
|
||
applyTheme(); setGridUI(state.gridType); updateToolVisibility(); updateZoomDisplay();
|
||
renderTabs(); updateInfo(); updateVis(); scheduleResize();
|
||
</script>
|
||
</body>
|
||
</html> |