/
komarovdd
/
GraphViz
Обзор
Документация
Войти
/
komarovdd
/
GraphViz
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
index.html
2 599 строк
119 KB
komarovdd
шаг интеграции и интеграций за шаг
10 фев 2026, 14:51
Верифицирован
10 фев 2026, 14:51
41a9e10
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>GraphViz Pro - Визуализация Графов</title> <style> :root { --transition-fast: 0.2s ease; --transition-slow: 0.4s ease; --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.2); --glass-bg: rgba(255, 255, 255, 0.05); --glass-border: rgba(255, 255, 255, 0.1); --sidebar-width: 340px; } body.dark-theme { --bg-color: #0f1115; --sidebar-bg: #161a21; --panel-bg: #1c222d; --text-color: #e2e8f0; --text-muted: #94a3b8; --accent-color: #38bdf8; --accent-hover: #0ea5e9; --secondary-color: #818cf8; --border-color: #2d3748; --input-bg: #0f172a; --canvas-dots: #334155; --button-secondary-bg: #334155; } body.light-theme { --bg-color: #f8fafc; --sidebar-bg: #ffffff; --panel-bg: #f1f5f9; --text-color: #1e293b; --text-muted: #64748b; --accent-color: #0284c7; --accent-hover: #0369a1; --secondary-color: #4f46e5; --border-color: #e2e8f0; --input-bg: #ffffff; --canvas-dots: #cbd5e1; --button-secondary-bg: #e2e8f0; --glass-bg: rgba(0, 0, 0, 0.02); --glass-border: rgba(0, 0, 0, 0.05); } * { box-sizing: border-box; margin: 0; padding: 0; } body, html { height: 100%; font-family: 'Inter', -apple-system, system-ui, sans-serif; background-color: var(--bg-color); color: var(--text-color); overflow: hidden; transition: background-color var(--transition-slow), color var(--transition-slow); } .app-container { display: flex; height: 100vh; width: 100vw; } .sidebar { width: var(--sidebar-width); background-color: var(--sidebar-bg); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; padding: 2rem 1.75rem; overflow-y: auto; z-index: 10; box-shadow: var(--shadow-lg); } header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2.5rem; } header h1 { font-size: 1.15rem; font-weight: 800; color: var(--accent-color); letter-spacing: -0.5px; text-transform: uppercase; } .theme-toggle button { background: none; border: 1px solid transparent; color: var(--text-muted); cursor: pointer; width: 40px; height: 40px; padding: 0; border-radius: 10px; display: flex; align-items: center; justify-content: center; transition: all var(--transition-fast); } .theme-toggle button:hover { background-color: var(--button-secondary-bg); color: var(--text-color); } .control-group { margin-bottom: 2rem; } .control-group h3 { font-size: 0.7rem; font-weight: 700; margin-bottom: 1rem; text-transform: uppercase; color: var(--text-muted); letter-spacing: 1.2px; } textarea { width: 100%; height: 110px; background-color: var(--input-bg); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 0.85rem; margin-bottom: 1rem; font-family: 'Fira Code', monospace; font-size: 0.8rem; resize: none; outline: none; transition: border-color var(--transition-fast); } textarea:focus { border-color: var(--accent-color); } .button-row { display: flex; gap: 0.6rem; margin-bottom: 0.6rem; flex-wrap: wrap; } button { background-color: var(--button-secondary-bg); color: var(--text-color); border: 1px solid var(--border-color); padding: 0.65rem 0.9rem; border-radius: 8px; cursor: pointer; font-size: 0.82rem; font-weight: 600; transition: all var(--transition-fast); display: inline-flex; align-items: center; justify-content: center; gap: 0.6rem; } button:hover { background-color: var(--panel-bg); border-color: var(--text-muted); transform: translateY(-1px); } button.primary { background-color: var(--accent-color); border-color: var(--accent-color); color: white; } button.primary:hover { background-color: var(--accent-hover); border-color: var(--accent-hover); box-shadow: 0 4px 14px rgba(56, 189, 248, 0.35); } button.secondary { background-color: var(--secondary-color); border-color: var(--secondary-color); color: white; } .input-row { margin-top: 1.2rem; } .input-row label { display: block; font-size: 0.78rem; margin-bottom: 0.6rem; color: var(--text-muted); font-weight: 600; } .input-controls { display: flex; align-items: center; gap: 1rem; } .input-block { background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1rem; margin-bottom: 1rem; } .input-hint { display: block; font-size: 0.65rem; color: var(--text-muted); margin-top: 0.2rem; font-style: italic; } .input-controls input[type="range"] { flex-grow: 1; } .input-controls input[type="number"] { width: 55px; background-color: var(--input-bg); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 6px; padding: 0.3rem 0.4rem; font-size: 0.75rem; font-weight: 600; outline: none; text-align: center; } .input-controls input[type="number"]:focus { border-color: var(--accent-color); } .physics-memo { margin-top: 1.5rem; padding: 1rem; background-color: var(--input-bg); border-radius: 8px; font-size: 0.72rem; line-height: 1.4; color: var(--text-muted); border-left: 3px solid var(--accent-color); } .physics-memo strong { color: var(--text-color); display: block; margin-bottom: 0.3rem; } .physics-memo p { margin-bottom: 0.6rem; } .physics-memo p:last-child { margin-bottom: 0; } select { width: 100%; background-color: var(--input-bg); color: var(--text-color); border: 1px solid var(--border-color); padding: 0.7rem; border-radius: 8px; font-size: 0.85rem; font-weight: 500; outline: none; cursor: pointer; } .advanced-settings summary { cursor: pointer; font-size: 0.78rem; font-weight: 700; color: var(--text-muted); margin-bottom: 0.8rem; list-style: none; display: flex; align-items: center; gap: 0.6rem; transition: color var(--transition-fast); } .advanced-settings summary:hover { color: var(--text-color); } .advanced-settings summary::before { content: '→'; font-size: 0.8rem; transition: transform 0.2s; } .advanced-settings[open] summary::before { transform: rotate(90deg); } .toggle-row { display: flex; align-items: center; justify-content: space-between; margin-top: 1rem; padding: 0.6rem 0.8rem; background-color: var(--input-bg); border-radius: 8px; cursor: pointer; transition: all 0.2s ease; border: 1px solid transparent; } .toggle-row:hover { border-color: var(--accent-color); } .toggle-row span { font-size: 0.75rem; font-weight: 600; color: var(--text-color); } .toggle-pill { width: 32px; height: 18px; background-color: var(--border-color); border-radius: 9px; position: relative; transition: background-color 0.3s ease; } .toggle-pill::after { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; background-color: #fff; border-radius: 50%; transition: transform 0.3s ease; } .toggle-row.active .toggle-pill { background-color: var(--accent-color); } .toggle-row.active .toggle-pill::after { transform: translateX(14px); } .advanced-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px dashed var(--border-color); } .advanced-section h4 { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 0.8rem; } .legend { margin-top: 1.5rem; padding: 1rem; background-color: var(--panel-bg); border-radius: 12px; border: 1px solid var(--border-color); font-size: 0.78rem; } .cluster-item { margin-bottom: 1rem; padding-bottom: 0.8rem; border-bottom: 1px solid var(--border-color); } .cluster-item:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } .cluster-header { display: flex; align-items: center; gap: 8px; margin-bottom: 0.5rem; } .cluster-header input[type="color"] { width: 20px; height: 20px; padding: 0; border: none; border-radius: 4px; cursor: pointer; background: none; } .cluster-nodes { font-size: 0.7rem; color: var(--text-muted); background: var(--input-bg); padding: 6px 8px; border-radius: 6px; word-break: break-all; max-height: 60px; overflow-y: auto; line-height: 1.4; } .stats { margin-top: auto; padding-top: 1.5rem; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); font-weight: 500; } .stats span { color: var(--text-color); font-weight: 700; } .viewport { flex-grow: 1; position: relative; background-color: var(--bg-color); background-image: radial-gradient(var(--canvas-dots) 1px, transparent 1px); background-size: 28px 28px; } #graphCanvas { width: 100%; height: 100%; } .overlay-controls { position: absolute; bottom: 2.5rem; right: 2.5rem; display: flex; flex-direction: column; gap: 0.85rem; align-items: center; } .overlay-controls button { width: 44px; height: 44px; border-radius: 14px; background-color: var(--sidebar-bg); border: 1px solid var(--border-color); color: var(--text-color); box-shadow: var(--shadow-lg); font-size: 1.3rem; } #zoomPercent { font-size: 0.7rem; font-weight: 800; color: var(--text-muted); background: var(--glass-bg); backdrop-filter: blur(8px); padding: 5px 10px; border-radius: 8px; border: 1px solid var(--glass-border); text-transform: uppercase; letter-spacing: 0.5px; } /* Modal styling */ .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: none; justify-content: center; align-items: center; z-index: 1000; } .modal-content { background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 12px; width: 450px; max-width: 90%; padding: 1.5rem; box-shadow: var(--shadow-md); } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.2rem; } .modal-footer { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; } /* Tooltip System */ .info-trigger { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 50%; background: var(--button-secondary-bg); border: 1px solid var(--border-color); color: var(--text-muted); font-size: 10px; cursor: help; margin-left: 6px; transition: all var(--transition-fast); position: relative; vertical-align: middle; z-index: 1000; } .info-trigger:hover { background: var(--accent-color); color: white; border-color: var(--accent-color); z-index: 10001; } .tooltip-box { position: absolute; bottom: calc(100% + 12px); left: auto; right: -10px; /* Slight offset from trigger */ width: 240px; padding: 1.1rem; background: var(--sidebar-bg); border: 1px solid var(--accent-color); border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); font-size: 0.75rem; line-height: 1.5; color: var(--text-color); pointer-events: none; opacity: 0; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); z-index: 10002; text-transform: none; text-align: left; letter-spacing: normal; font-weight: 400; visibility: hidden; white-space: normal; backdrop-filter: blur(10px); } .tooltip-box b { color: var(--accent-color); display: block; margin-bottom: 0.5rem; font-size: 0.85rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3rem; } .info-trigger:hover .tooltip-box { opacity: 1; transform: translateY(-5px); visibility: visible; } .tooltip-box::after { content: ''; position: absolute; bottom: -6px; right: 11px; /* Center under the 14px trigger */ border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--accent-color); } /* Manual Clustering Modal */ .manual-cluster-container { display: flex; gap: 1.2rem; overflow-x: auto; flex-grow: 1; padding: 1.5rem; background: var(--input-bg); border-radius: 12px; align-items: flex-start; min-height: 0; border: 1px solid var(--border-color); } .cluster-col { min-width: 140px; max-width: 180px; background: var(--panel-bg); border: 1px solid var(--border-color); border-radius: 12px; display: flex; flex-direction: column; max-height: 100%; transition: all var(--transition-fast); box-shadow: var(--shadow-sm); } .cluster-col.drag-over { border-color: var(--accent-color); background: rgba(56, 189, 248, 0.05); transform: scale(1.02); } .cluster-col-header { padding: 1rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; background: rgba(255, 255, 255, 0.02); border-radius: 12px 12px 0 0; } .cluster-col-header h4 { font-size: 0.85rem; margin: 0; font-weight: 700; color: var(--accent-color); } .cluster-col-list { flex-grow: 1; overflow-y: auto; padding: 0.75rem; min-height: 100px; scrollbar-width: thin; } .cluster-node-item { background: var(--sidebar-bg); border: 1px solid var(--border-color); padding: 0.6rem 0.8rem; margin-bottom: 0.5rem; border-radius: 8px; font-size: 0.78rem; cursor: grab; user-select: none; display: flex; align-items: center; gap: 0.6rem; transition: all var(--transition-fast); } .cluster-node-item:hover { border-color: var(--text-muted); background: var(--panel-bg); } .cluster-node-item.selected { border-color: var(--accent-color); background: rgba(56, 189, 248, 0.15); box-shadow: 0 0 0 1px var(--accent-color); } .cluster-node-item:active { cursor: grabbing; } .node-color-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .viz-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.2rem; } .viz-section { border-bottom: 1px solid var(--glass-border); padding-bottom: 1rem; margin-bottom: 1rem; } .viz-section h4 { margin-bottom: 0.8rem; font-size: 0.85rem; color: var(--accent-color); } .icon { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2.5; fill: none; stroke-linecap: round; stroke-linejoin: round; } </style> </head> <body class="dark-theme"> <div class="app-container"> <aside class="sidebar"> <header> <h1>GraphViz Pro</h1> <div class="theme-toggle"> <button id="toggleTheme" title="Сменить тему"> <svg class="icon theme-icon-sun" viewBox="0 0 24 24" style="display:none;"> <circle cx="12" cy="12" r="5"></circle> <line x1="12" y1="1" x2="12" y2="3"></line> <line x1="12" y1="21" x2="12" y2="23"></line> <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> <line x1="1" y1="12" x2="3" y2="12"></line> <line x1="21" y1="12" x2="23" y2="12"></line> <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> </svg> <svg class="icon theme-icon-moon" viewBox="0 0 24 24"> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> </svg> </button> </div> </header> <section class="control-group"> <h3>Данные</h3> <textarea id="adjacencyList" placeholder="1001 1101 5.2 1101 1201 3.4..."></textarea> <div class="button-row"> <button id="loadGraph" class="primary" style="width:100%"> <svg class="icon" viewBox="0 0 24 24"> <path d="M12 5v14M5 12h14"></path> </svg> Загрузить данные </button> </div> <div class="button-row"> <button id="importJSON" style="flex:1"><svg class="icon" viewBox="0 0 24 24"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"></path> </svg>Импорт</button> <button id="exportJSON" style="flex:1"><svg class="icon" viewBox="0 0 24 24"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"></path> </svg>Экспорт</button> </div> </section> <section class="control-group"> <h3>Вид</h3> <div class="button-row"> <button id="randomizeLayout" title="Перемешать (Случайно)"><svg class="icon" viewBox="0 0 24 24"> <path d="M2 18h2.82c.5 0 .97-.21 1.31-.58L10 13M2 6h2.82c.5 0 .97.21 1.31.58L10 11M22 18h-2.82c-.5 0-.97-.21-1.31-.58L14 13M22 6h-2.82c-.5 0-.97.21-1.31.58L14 11"> </path> </svg></button> <button id="centerGraph" title="В центр (0,0)"><svg class="icon" viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="3"></circle> </svg></button> <button id="gatherNodes" title="Собрать всё в одну точку"><svg class="icon" viewBox="0 0 24 24"> <path d="M12 2v20M2 12h20M12 12l8-8M12 12l-8 8M12 12l8 8M12 12l-8-8"></path> </svg></button> <button id="fitScale" title="Автомасштаб (Уместить всё)"><svg class="icon" viewBox="0 0 24 24"> <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"></path> </svg></button> </div> <div class="button-row" style="margin-top: 0.6rem;"> <button id="openVizModal" class="secondary" style="width:100%; border-style: dashed; opacity: 0.9;"> <svg class="icon" viewBox="0 0 24 24"> <path d="M12 20v-6M9 20v-10M15 20v-2M18 20v-8M6 20v-4"></path> </svg> Отображение ребер </button> </div> </section> <section class="control-group"> <h3>Симуляция</h3> <div class="button-row"> <button id="toggleSimulation" class="primary" style="flex:2"> <svg class="icon" id="simIcon" viewBox="0 0 24 24"> <polygon points="5 3 19 12 5 21 5 3"></polygon> </svg> <span id="simText">Запустить</span> </button> <button id="stepSimulation" title="Один шаг" style="flex:1"> <svg class="icon" viewBox="0 0 24 24"> <path d="M13 19l6-7-6-7M5 19l6-7-6-7"></path> </svg> </button> </div> <div class="input-row"> <label>Порог веса (Min Weight)</label> <div class="input-controls"> <input type="range" id="paramWeightFilter" min="0" max="1000" step="1" value="0"> <input type="number" id="paramWeightFilterNum" min="0" max="1000" step="1" value="0"> </div> <span class="input-hint">Скрывает из расчетов и отображения слабые связи</span> </div> <div class="input-row"> <label>Шаг симуляции (Integration Step)</label> <div class="input-controls"> <input type="range" id="simSpeed" min="0.1" max="10.0" step="0.1" value="2.5"> <input type="number" id="simSpeedNum" min="0.1" max="10.0" step="0.1" value="2.5"> </div> <span class="input-hint">Величина дискретного шага. Выше = больше «энергии», но меньше стабильность (узлы могут улетать)</span> </div> <div class="input-row"> <label>Итераций за кадр (Substeps)</label> <div class="input-controls"> <input type="range" id="simSubsteps" min="1" max="25" step="1" value="1"> <input type="number" id="simSubstepsNum" min="1" max="25" step="1" value="1"> </div> <span class="input-hint">Кол-во просчетов физики на 1 кадр. Выше = быстрее визуальная скорость без потери стабильности</span> </div> <details class="advanced-settings" open> <summary>Настройка сил</summary> <div class="input-block"> <div class="input-row"> <label>Притяжение (Attraction)</label> <div class="input-controls"> <input type="range" id="paramAttract" min="0.01" max="5" step="0.01" value="0.5"> <input type="number" id="paramAttractNum" min="0.01" max="5" step="0.01" value="0.5"> </div> <span class="input-hint">Стягивает узлы. Выше = короче рёбра</span> </div> <div class="input-row"> <label>Степень веса (Weight Power)</label> <div class="input-controls"> <input type="range" id="paramAttractPower" min="1" max="10" step="0.1" value="1"> <input type="number" id="paramAttractPowerNum" min="1" max="10" step="0.1" value="1"> </div> <span class="input-hint">Усиливает влияние тяжелых связей над легкими</span> </div> <div class="toggle-row" id="toggleElastic"> <span>Эффект «резинки»</span> <div class="toggle-pill"></div> </div> <div class="input-row" id="rowElasticPower" style="display:none"> <label>Сила растяжения (Dist Power)</label> <div class="input-controls"> <input type="range" id="paramAttractDistPower" min="1" max="5" step="0.1" value="1"> <input type="number" id="paramAttractDistPowerNum" min="1" max="5" step="0.1" value="1"> </div> <span class="input-hint">Выше = сильнее тянет при растяжении</span> </div> </div> <div class="input-block"> <div class="input-row"> <label>Отталкивание (Repulsion)</label> <div class="input-controls"> <input type="range" id="paramRepulse" min="100" max="10000" step="100" value="2000"> <input type="number" id="paramRepulseNum" min="100" max="10000" step="100" value="2000"> </div> <span class="input-hint">Расталкивает все узлы. Помогает избежать наложений</span> </div> <div class="input-row"> <label>Степень расст. (Dist Power)</label> <div class="input-controls"> <input type="range" id="paramRepulsePower" min="1" max="10" step="0.1" value="2"> <input type="number" id="paramRepulsePowerNum" min="1" max="10" step="0.1" value="2"> </div> <span class="input-hint">Выше = силы действуют только вблизи</span> </div> </div> <div class="input-row"> <label>Гравитация (Gravity)</label> <div class="input-controls"> <input type="range" id="paramGravity" min="0" max="2" step="0.01" value="0.05"> <input type="number" id="paramGravityNum" min="0" max="2" step="0.01" value="0.05"> </div> <span class="input-hint">Тянет всё к центру, чтобы граф не "разлетался"</span> </div> <div class="input-row"> <label>Затухание (Damping)</label> <div class="input-controls"> <input type="range" id="paramDamping" min="0.1" max="0.99" step="0.01" value="0.7"> <input type="number" id="paramDampingNum" min="0.1" max="0.99" step="0.01" value="0.7"> </div> <span class="input-hint">Сопротивление. Ниже — инертно, выше — стабильно</span> </div> <div class="input-row"> <label>Температура (Текущая)</label> <div class="input-controls"> <input type="range" id="paramTemp" min="0" max="200" step="1" value="0"> <input type="number" id="paramTempNum" min="0" max="200" step="1" value="0"> </div> <span class="input-hint">Вносит случайные вибрации для выхода из тупиков</span> </div> </details> </section> <section class="control-group"> <h3>Анализ</h3> <div class="input-row"><select id="clusteringAlgo"></select></div> <div id="clusteringParams" style="margin-top: 0.8rem;"> <div id="kcoreParams" class="algo-params" style="display:none"> <div class="input-row"> <label>Минимум (K)</label> <div class="input-controls"> <input type="number" id="kcoreK" min="1" max="50" value="1"> </div> <span class="input-hint">Узлы с coreness ниже K не будут окрашены</span> </div> </div> <div id="leidenParams" class="algo-params"> <div class="input-row"> <label>Разрешение (Resolution)</label> <div class="input-controls"> <input type="number" id="leidenRes" min="0.1" max="10" step="0.1" value="1.0"> </div> <span class="input-hint">Выше — более мелкие и многочисленные группы</span> </div> <div class="input-row"> <label>Итерации (Iterations)</label> <div class="input-controls"> <input type="number" id="leidenIter" min="1" max="50" value="30"> </div> <span class="input-hint">Кол-во проходов для уточнения структуры</span> </div> </div> <div id="ensembleParams" class="algo-params" style="display:none"> <div class="input-row"> <label>Прогоны (Runs)</label> <div class="input-controls"> <input type="number" id="ensembleRuns" min="2" max="50" value="30"> </div> <span class="input-hint">Количество полных запусков алгоритма</span> </div> <div class="input-row"> <label>Порог (Threshold)</label> <div class="input-controls"> <input type="number" id="ensembleThreshold" min="0" max="1" step="0.05" value="0.5"> </div> <span class="input-hint">Минимальная частота связи для объединения</span> </div> <div class="input-row"> <label>Разрешение (Resolution)</label> <div class="input-controls"> <input type="number" id="ensembleRes" min="0.1" max="10" step="0.1" value="1.0"> </div> </div> <div class="input-row"> <label>Итерации (Iterations)</label> <div class="input-controls"> <input type="number" id="ensembleIter" min="1" max="50" value="30"> </div> </div> </div> <div id="kmeansParams" class="algo-params" style="display:none"> <div class="input-row"> <label>Количество кластеров (K)</label> <div class="input-controls"> <input type="number" id="kmeansK" min="1" max="8" value="3"> </div> <span class="input-hint">Жестко заданное число целевых групп</span> </div> </div> <div id="dbscanParams" class="algo-params" style="display:none"> <div class="input-row"> <label>Радиус (Epsilon)</label> <div class="input-controls"> <input type="number" id="dbscanEps" min="1" max="500" value="100"> </div> <span class="input-hint">Макс. расстояние между узлами в кластере</span> </div> <div class="input-row"> <label>Мин. узлов (MinPts)</label> <div class="input-controls"> <input type="number" id="dbscanMinPts" min="1" max="10" value="2"> </div> <span class="input-hint">Минимум соседей для начала кластера</span> </div> </div> </div> <div class="button-row" style="margin-top: 0.8rem;"> <button id="applyClustering" class="secondary" style="flex:2">Кластеризация</button> <button id="resetClustering" style="flex:1"><svg class="icon" viewBox="0 0 24 24"> <path d="M18 6L6 18M6 6l12 12"></path> </svg></button> </div> <div class="button-row" style="margin-top: 0.5rem;"> <button id="layoutByClusters" class="secondary" style="flex: 2; border-style: dashed; opacity: 0.9;"> Сгруппировать </button> <button id="openManualCluster" class="secondary" style="flex: 3;"> Редактировать вручную </button> </div> <div id="clusteringLegend" class="legend" style="display:none"></div> </section> <footer class="stats"> <div>Узлов: <span id="statNodes">0</span></div> <div>Рёбер: <span id="statEdges">0</span></div> </footer> </aside> <main class="viewport"> <canvas id="graphCanvas"></canvas> <div class="overlay-controls"> <button id="zoomIn" title="Масштаб +"><svg class="icon" viewBox="0 0 24 24"> <line x1="12" y1="5" x2="12" y2="19"></line> <line x1="5" y1="12" x2="19" y2="12"></line> </svg></button> <div id="zoomPercent">100%</div> <button id="zoomOut" title="Масштаб -"><svg class="icon" viewBox="0 0 24 24"> <line x1="5" y1="12" x2="19" y2="12"></line> </svg></button> <button id="savePNG" title="Снимок PNG"><svg class="icon" viewBox="0 0 24 24"> <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"> </path> <circle cx="12" cy="13" r="4"></circle> </svg></button> </div> </main> </div> <!-- Visualization Settings Modal --> <div id="vizModal" class="modal-overlay"> <div class="modal-content" style="width: 620px; max-width: 95vw; padding: 2rem;"> <div class="modal-header"> <h3>Визуализация рёбер (3-х сегментная модель)</h3> <button class="icon-btn" id="closeViz">×</button> </div> <p style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 1.2rem; line-height: 1.4;"> Модель делит веса ребер на 3 участка с помощью порогов <b>P1</b> и <b>P2</b>. Это позволяет раздельно настраивать читаемость слабых, средних и сильных связей. </p> <div class="viz-grid"> <div class="viz-section" style="grid-column: span 2"> <h4>Пороги переходов (0% → P1 → P2 → 100%)</h4> <div class="viz-grid" style="grid-template-columns: 1fr 1fr; margin-top:0.5rem"> <div> <label style="font-size:0.65rem">Порог P1 (Слабые/Средние)</label> <input type="number" id="vizP1" value="15" min="0" max="100"> </div> <div> <label style="font-size:0.65rem">Порог P2 (Средние/Сильные)</label> <input type="number" id="vizP2" value="64" min="0" max="100"> </div> </div> </div> <div class="viz-section" style="grid-column: span 2"> <h4>1. Слабые (0 → P1)</h4> <p style="font-size:0.65rem; color:var(--text-muted); margin-bottom:0.4rem">Растет только толщина. Прозрачность фикс.</p> <div style="display:grid; grid-template-columns: 1fr 1fr; gap:8px"> <div> <label style="font-size:0.6rem">W Min</label> <input type="number" id="vizWMin" value="1.0" step="0.1"> </div> <div> <label style="font-size:0.6rem">Alpha (Weak)</label> <input type="number" id="vizAWeak" value="0.1" step="0.05"> </div> </div> </div> <div class="viz-section" style="grid-column: span 2"> <h4>2. Средние (P1 → P2)</h4> <p style="font-size:0.65rem; color:var(--text-muted); margin-bottom:0.4rem">Толщина замерла на W Mid. Растет прозрачность.</p> <div style="display:grid; grid-template-columns: 1fr; gap:8px"> <div> <label style="font-size:0.6rem">W Mid (Фикс)</label> <input type="number" id="vizWMid" value="2.0" step="0.1"> </div> </div> </div> <div class="viz-section" style="grid-column: span 2"> <h4>3. Сильные (P2 → 100%)</h4> <p style="font-size:0.65rem; color:var(--text-muted); margin-bottom:0.4rem">Снова растет толщина. Прозрачность замерла на Alpha Strong.</p> <div style="display:grid; grid-template-columns: 1fr 1fr; gap:8px"> <div> <label style="font-size:0.6rem">W Max (100%)</label> <input type="number" id="vizWMax" value="4.0" step="0.1"> </div> <div> <label style="font-size:0.6rem">Alpha (Strong)</label> <input type="number" id="vizAStrong" value="1.0" step="0.05"> </div> </div> </div> </div> <div class="modal-footer"> <button id="resetViz">Сброс</button> <div style="flex-grow: 1;"></div> <button id="applyViz" class="primary">Применить</button> </div> </div> </div> <!-- Manual Cluster Editor Modal --> <div id="manualClusterModal" class="modal-overlay" style="display:none"> <div class="modal-content" style="width: 90vw; max-width: 1200px; height: 85vh; display: flex; flex-direction: column;"> <div class="modal-header"> <h3>Ручное управление кластерами</h3> <div style="display: flex; align-items: center; gap: 1.5rem;"> <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; cursor: pointer;"> <input type="checkbox" id="manualLiveModularity" checked> Живой Q </label> <div style="display: flex; align-items: center; gap: 0.4rem;"> <span style="font-size: 0.75rem; color: var(--text-muted);">Res:</span> <input type="number" id="manualRes" step="0.1" value="1.0" style="width: 50px; background: none; border: 1px solid var(--border-color); color: var(--text-color); border-radius: 4px; padding: 2px 4px; font-size: 0.75rem;"> </div> <div id="manualModularityVal" style="font-weight: 700; color: var(--accent-color); font-size: 0.9rem; min-width: 80px;"> </div> <button class="icon-btn" id="closeManualCluster">×</button> </div> </div> <div class="manual-cluster-container" id="manualClusterCols"> <!-- Columns will be injected by JS --> </div> <div class="modal-footer"> <button id="addManualCluster" class="secondary"> <svg class="icon" viewBox="0 0 24 24"> <line x1="12" y1="5" x2="12" y2="19"></line> <line x1="5" y1="12" x2="19" y2="12"></line> </svg> Добавить кластер </button> <div style="flex-grow: 1;"></div> <button id="saveManualCluster" class="primary">Сохранить изменения</button> </div> </div> </div> <script> // --- Core Module --- class Node { constructor(id, x = Math.random() * 800, y = Math.random() * 600) { this.id = id; this.x = x; this.y = y; this.vx = 0; this.vy = 0; this.clusterId = null; this.isDragging = false; this.isSelected = false; } } class Edge { constructor(source, target, weight) { this.source = source; this.target = target; this.weight = weight; } } class Graph { constructor() { this.nodes = new Map(); this.edges = []; } addNode(id, x, y) { if (!this.nodes.has(id)) this.nodes.set(id, new Node(id, x, y)); return this.nodes.get(id); } addEdge(src, tgt, w) { this.addNode(src); this.addNode(tgt); this.edges.push(new Edge(src, tgt, w)); } static parse(txt) { const g = new Graph(); txt.split('\n').forEach(l => { l = l.trim(); if (!l || l.startsWith('#')) return; const p = l.split(/\s+/); if (p.length < 3) return; g.addEdge(p[0], p[1], parseFloat(p[2])); }); return g; } toJSON() { return { nodes: Array.from(this.nodes.values()).map(n => ({ id: n.id, x: n.x, y: n.y, clusterId: n.clusterId })), edges: this.edges.map(e => ({ source: e.source, target: e.target, weight: e.weight })) }; } } // --- Physics Module --- class Physics { constructor() { this.params = { attraction: 0.1, attractionPower: 1, elastic: false, attractDistPower: 1, repulsion: 2500, repulsionPower: 2, gravity: 0.1, damping: 0.7, timeScale: 1.0, minWeight: 0 }; this.state = { iteration: 0, temp: 0 }; } reset() { this.state.iteration = 0; } update(g, dt, w, h) { const nodes = Array.from(g.nodes.values()); const ts = this.params.timeScale; const effDt = dt * ts; this.state.iteration++; // Phase logic removed let curRep = this.params.repulsion; let curAttr = this.params.attraction; nodes.forEach(a => { // 1. Repulsion nodes.forEach(b => { if (a === b) return; const dx = a.x - b.x, dy = a.y - b.y; const d = Math.sqrt(dx * dx + dy * dy) + 5; // Min distance buffer const forceFactor = d < 50 ? 5 : 1; // Scale repulsion coefficient based on power to keep forces manageable // Base 100 normalization: at d=100, forces are similar across powers const powerScale = Math.pow(100, this.params.repulsionPower - 2); const f = (curRep * forceFactor * powerScale) / Math.pow(d, this.params.repulsionPower); a.vx += (dx / d) * f * effDt * 100; a.vy += (dy / d) * f * effDt * 100; }); // 2. Central Gravity const cx = w / 2 || 400, cy = h / 2 || 300; a.vx += (cx - a.x) * this.params.gravity * effDt; a.vy += (cy - a.y) * this.params.gravity * effDt; }); // 3. Attraction g.edges.forEach(e => { if (e.weight < (this.params.minWeight || 0)) return; const a = g.nodes.get(e.source), b = g.nodes.get(e.target); if (!a || !b) return; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) + 0.1; // Power dependency for weight // Adjusted for weights 10-250 (normalization attempt) const normalizedWeight = e.weight / 100; const weightedAttr = Math.pow(normalizedWeight, this.params.attractionPower); let force = (weightedAttr * curAttr * 0.05); // Base attraction force // "Rubber band" effect: increase force as distance grows if (this.params.elastic && this.params.attractDistPower > 1) { // Normalizing by 100px so it doesn't explode at huge distances // Force scaling: (dist / 100) ^ (P - 1) const distFactor = Math.pow(d / 100, this.params.attractDistPower - 1); force *= 100 * distFactor; } else { // If not elastic, force is proportional to distance force *= d; } const fx = (dx / d) * force * effDt * 20, fy = (dy / d) * force * effDt * 20; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; }); // 4. Integrate & Annealing & Overlap const maxV = 50; const minDistance = 40; nodes.forEach(n => { if (!n.isDragging) { // Noise based on current temperature if (this.state.temp > 0.1) { n.vx += (Math.random() - 0.5) * this.state.temp * 0.5; n.vy += (Math.random() - 0.5) * this.state.temp * 0.5; } const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy); if (speed > maxV) { n.vx = (n.vx / speed) * maxV; n.vy = (n.vy / speed) * maxV; } n.x += n.vx * effDt * 20; n.y += n.vy * effDt * 20; // Overlap prevention nodes.forEach(other => { if (n === other) return; const dx = other.x - n.x, dy = other.y - n.y; const d2 = dx * dx + dy * dy; if (d2 < minDistance * minDistance && d2 > 0) { const d = Math.sqrt(d2); const overlap = (minDistance - d) / 2; const ox = (dx / d) * overlap, oy = (dy / d) * overlap; n.x -= ox; n.y -= oy; if (!other.isDragging) { other.x += ox; other.y += oy; } } }); } else { n.vx = 0; n.vy = 0; } n.vx *= this.params.damping; n.vy *= this.params.damping; }); // Cooling logic removed (manual control) } } // --- Renderer Module --- class Renderer { constructor(cv) { this.cv = cv; this.ctx = cv.getContext('2d'); this.tx = { x: 0, y: 0, scale: 1.0 }; this.hovId = null; this.isDark = true; this.selBox = null; this.weightFilter = 0; this.viz = { p1: 0.15, p2: 0.64, wMin: 1.0, wMid: 2.0, wMax: 4.0, aWeak: 0.1, aStrong: 1.0 }; this.clusterColors = new Map(); // id -> color hex } getColor(id) { if (id === null || id === undefined) return this.isDark ? '#1e293b' : '#f1f5f9'; const sId = String(id); if (this.clusterColors.has(sId)) return this.clusterColors.get(sId); const hash = isNaN(sId) ? sId.split('').reduce((a, b) => a + b.charCodeAt(0), 0) : parseInt(sId); const hue = Math.abs(hash * 137.5) % 360; return `hsl(${hue}, 75%, ${this.isDark ? '60%' : '50%'})`; } resize() { this.cv.width = this.cv.parentElement.clientWidth; this.cv.height = this.cv.parentElement.clientHeight; } screenToWorld(x, y) { return { x: (x - this.tx.x) / this.tx.scale, y: (y - this.tx.y) / this.tx.scale }; } render(g) { const { ctx, cv, tx } = this; ctx.clearRect(0, 0, cv.width, cv.height); ctx.save(); ctx.translate(tx.x, tx.y); ctx.scale(tx.scale, tx.scale); let minW = Infinity, maxW = -Infinity; const visibleEdges = g.edges.filter(e => e.weight >= (this.weightFilter || 0)); visibleEdges.forEach(e => { minW = Math.min(minW, e.weight); maxW = Math.max(maxW, e.weight); }); const hasRange = maxW > minW; visibleEdges.forEach(e => { const a = g.nodes.get(e.source), b = g.nodes.get(e.target); if (!a || !b) return; const h = a.id === this.hovId || b.id === this.hovId; const p = hasRange ? (e.weight - minW) / (maxW - minW) : 0.5; let alpha = 0.5, width = 2; const v = this.viz; if (p < v.p1) { const t = p / v.p1; width = v.wMin + (v.wMid - v.wMin) * t; alpha = v.aWeak; } else if (p < v.p2) { const t = (p - v.p1) / (v.p2 - v.p1); width = v.wMid; alpha = v.aWeak + (v.aStrong - v.aWeak) * t; } else { const t = (v.p2 === 1) ? 1 : (p - v.p2) / (1 - v.p2); width = v.wMid + (v.wMax - v.wMid) * t; alpha = v.aStrong; } ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineWidth = width / tx.scale; ctx.strokeStyle = this.isDark ? '#fff' : '#000'; ctx.globalAlpha = h ? 1.0 : alpha; ctx.stroke(); }); ctx.globalAlpha = 1.0; g.nodes.forEach(n => { const h = n.id === this.hovId; const r = 14 / tx.scale; // Constant visual size ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, 7); let color = this.getColor(null); if (n.clusterId !== null) { color = this.getColor(n.clusterId); } ctx.fillStyle = color; ctx.fill(); ctx.lineWidth = (h ? 3 : 1.5) / tx.scale; ctx.strokeStyle = h ? (this.isDark ? '#fff' : '#000') : (this.isDark ? '#334155' : '#cbd5e1'); ctx.stroke(); if (n.isSelected) { ctx.beginPath(); ctx.arc(n.x, n.y, r + 4 / tx.scale, 0, 7); ctx.lineWidth = 2 / tx.scale; ctx.strokeStyle = '#38bdf8'; ctx.stroke(); } ctx.fillStyle = this.isDark ? '#fff' : '#1e293b'; // Labels stay constant visual size (matching the node behavior) const fontSize = 9 / tx.scale; ctx.font = `bold ${fontSize}px Inter`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(n.id, n.x, n.y); }); ctx.restore(); if (this.selBox) { ctx.setLineDash([5, 5]); ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 1; ctx.strokeRect(this.selBox.x, this.selBox.y, this.selBox.w, this.selBox.h); ctx.fillStyle = 'rgba(56, 189, 248, 0.1)'; ctx.fillRect(this.selBox.x, this.selBox.y, this.selBox.w, this.selBox.h); ctx.setLineDash([]); } } } // --- Clustering --- const calculateModularity = (g, clusters, res = 1.0, minWeight = 0) => { const nodes = Array.from(g.nodes.keys()); const validEdges = g.edges.filter(e => e.weight >= minWeight); const m2 = validEdges.reduce((acc, e) => acc + e.weight, 0) * 2 || 1; const degs = new Map(); nodes.forEach(id => degs.set(id, 0)); validEdges.forEach(e => { degs.set(e.source, (degs.get(e.source) || 0) + e.weight); degs.set(e.target, (degs.get(e.target) || 0) + e.weight); }); // Assign unique cluster IDs to nodes not in the 'clusters' map const fullClusters = new Map(); let nextC = 0; clusters.forEach((c, id) => { if (c !== null) { fullClusters.set(id, c); nextC = Math.max(nextC, c + 1); } }); nodes.forEach(id => { if (!fullClusters.has(id)) fullClusters.set(id, 'singleton_' + (nextC++)); }); let q = 0; const cIn = new Map(), cTot = new Map(); fullClusters.forEach((c, id) => { cTot.set(c, (cTot.get(c) || 0) + degs.get(id)); if (!cIn.has(c)) cIn.set(c, 0); }); validEdges.forEach(e => { const ca = fullClusters.get(e.source), cb = fullClusters.get(e.target); if (ca === cb) cIn.set(ca, cIn.get(ca) + e.weight * 2); }); cIn.forEach((sumIn, c) => { const sumTot = cTot.get(c); q += (sumIn / m2) - res * Math.pow(sumTot / m2, 2); }); const metrics = new Map(); cIn.forEach((sumIn, c) => { if (typeof c === 'string' && c.startsWith('singleton_')) return; const sumTot = cTot.get(c); const ratio = sumTot > 0 ? sumIn / sumTot : 0; let quality = 'Низкая (сомнительный)'; if (ratio > 0.8) quality = 'Высокая (отличный)'; else if (ratio > 0.5) quality = 'Средняя (хороший)'; metrics.set(c, { ratio, quality }); }); return { modularity: q, metrics }; }; const runLeiden = (g, params) => { const minW = params.minWeight || 0; const res = params.res || 1.0; const iterations = params.iterations || 10; const nodes = Array.from(g.nodes.keys()); const validEdges = g.edges.filter(e => e.weight >= minW); const activeNodes = new Set(); validEdges.forEach(e => { activeNodes.add(e.source); activeNodes.add(e.target); }); let communities = new Map(); activeNodes.forEach(id => communities.set(id, id)); const m2 = validEdges.reduce((acc, e) => acc + e.weight, 0) * 2 || 1; const degs = new Map(); activeNodes.forEach(id => degs.set(id, 0)); validEdges.forEach(e => { degs.set(e.source, (degs.get(e.source) || 0) + e.weight); degs.set(e.target, (degs.get(e.target) || 0) + e.weight); }); const activeNodesArr = Array.from(activeNodes); for (let i = 0; i < iterations; i++) { let moved = false; activeNodesArr.sort(() => Math.random() - 0.5); activeNodesArr.forEach(u => { const curC = communities.get(u); const neighborC = new Map(); validEdges.forEach(e => { if (e.source === u || e.target === u) { const v = e.source === u ? e.target : e.source; const c = communities.get(v); neighborC.set(c, (neighborC.get(c) || 0) + e.weight); } }); let bestC = curC, maxG = 0; const ki = degs.get(u); neighborC.forEach((k_in, c) => { let sigma = 0; communities.forEach((comm, node) => { if (comm === c) sigma += degs.get(node); }); const gain = (k_in / m2) - res * (ki * (sigma - (c === curC ? ki : 0)) / (m2 * m2)); if (gain > maxG) { maxG = gain; bestC = c; } }); if (bestC !== curC) { communities.set(u, bestC); moved = true; } }); if (!moved) break; } const stats = calculateModularity(g, communities, res, minW); return { clusters: communities, ...stats }; }; const Algos = [ { name: 'Leiden (Topological)', run: runLeiden }, { name: 'Ensemble Leiden (Consensus)', run: (g, params) => { const runs = params.runs || 10; const threshold = params.threshold || 0.5; const subParams = { res: params.ensembleRes || 1.0, iterations: params.ensembleIter || 5, minWeight: params.minWeight }; const nodes = Array.from(g.nodes.keys()); const activeNodes = new Set(); g.edges.forEach(e => { if (e.weight >= (params.minWeight || 0)) { activeNodes.add(e.source); activeNodes.add(e.target); } }); if (activeNodes.size === 0) return { clusters: new Map() }; const cooc = new Map(); // Map<id1, Map<id2, count>> for (let r = 0; r < runs; r++) { const result = runLeiden(g, subParams); const clusters = result.clusters; const reverse = new Map(); clusters.forEach((cId, nId) => { if (!reverse.has(cId)) reverse.set(cId, []); reverse.get(cId).push(nId); }); reverse.forEach(members => { for (let i = 0; i < members.length; i++) { for (let j = i + 1; j < members.length; j++) { const u = members[i], v = members[j]; const a = u < v ? u : v, b = u < v ? v : u; if (!cooc.has(a)) cooc.set(a, new Map()); cooc.get(a).set(b, (cooc.get(a).get(b) || 0) + 1); } } }); } // Build consensus graph const cg = { nodes: g.nodes, edges: [] }; cooc.forEach((targets, u) => { targets.forEach((count, v) => { const freq = count / runs; if (freq >= threshold) { cg.edges.push({ source: u, target: v, weight: freq }); } }); }); // Final run on consensus graph const finalResult = runLeiden(cg, { ...subParams, minWeight: 0 }); // CRITICAL: Recalculate modularity based on the ORIGINAL graph g // This allows the user to see how well the ensemble result explains the original data. const originalStats = calculateModularity(g, finalResult.clusters, subParams.res, subParams.minWeight); finalResult.modularity = originalStats.modularity; finalResult.metrics = originalStats.metrics; return finalResult; } }, { name: 'K-Core (Topology)', run: (g, params) => { const nodes = Array.from(g.nodes.keys()); const degrees = new Map(); const adj = new Map(); nodes.forEach(id => { degrees.set(id, 0); adj.set(id, []); }); g.edges.forEach(e => { if (e.weight < (params.minWeight || 0)) return; const u = e.source, v = e.target; if (adj.has(u) && adj.has(v)) { adj.get(u).push(v); adj.get(v).push(u); degrees.set(u, degrees.get(u) + 1); degrees.set(v, degrees.get(v) + 1); } }); const coreness = new Map(); const sortedNodes = [...nodes].sort((a, b) => degrees.get(a) - degrees.get(b)); let currentK = 0; const exists = new Set(nodes); while (sortedNodes.length > 0) { sortedNodes.sort((a, b) => degrees.get(a) - degrees.get(b)); const v = sortedNodes.shift(); currentK = Math.max(currentK, degrees.get(v)); coreness.set(v, currentK); exists.delete(v); (adj.get(v) || []).forEach(u => { if (exists.has(u)) degrees.set(u, degrees.get(u) - 1); }); } const filtered = new Map(); const minK = params.kcoreK || 1; coreness.forEach((k, v) => { if (k >= minK && (adj.get(v) || []).length > 0) filtered.set(v, k); }); const stats = calculateModularity(g, filtered, params.res || 1.0, params.minWeight); return { clusters: filtered, ...stats }; } }, { name: 'K-Means (Spatial)', run: (g, params) => { const k = params.k || 3; const nodes = Array.from(g.nodes.values()); if (nodes.length < k) return { clusters: new Map() }; // Initial centroids let centroids = nodes.slice(0, k).map(n => ({ x: n.x, y: n.y })); let clusters = new Map(); for (let iter = 0; iter < 10; iter++) { clusters = new Map(); nodes.forEach(n => { let minDist = Infinity, best = 0; centroids.forEach((c, i) => { const d = Math.hypot(n.x - c.x, n.y - c.y); if (d < minDist) { minDist = d; best = i; } }); clusters.set(n.id, best); }); // Update centroids const newCentroids = Array.from({ length: k }, () => ({ x: 0, y: 0, count: 0 })); nodes.forEach(n => { const c = newCentroids[clusters.get(n.id)]; c.x += n.x; c.y += n.y; c.count++; }); centroids = newCentroids.map(c => c.count > 0 ? { x: c.x / c.count, y: c.y / c.count } : { x: Math.random() * 800, y: Math.random() * 600 }); } const stats = calculateModularity(g, clusters, params.res || 1.0, params.minWeight); return { clusters, ...stats }; } }, { name: 'DBSCAN (Spatial)', run: (g, params) => { const eps = params.eps || 100, minPts = params.minPts || 2; const nodes = Array.from(g.nodes.values()); const m = new Map(); const visited = new Set(), noise = new Set(); let clusterCount = 0; const getNeighbors = n => nodes.filter(other => Math.hypot(n.x - other.x, n.y - other.y) < eps); nodes.forEach(n => { if (visited.has(n.id)) return; visited.add(n.id); const neighbors = getNeighbors(n); if (neighbors.length < minPts) { noise.add(n.id); } else { const cId = clusterCount++; m.set(n.id, cId); const stack = [...neighbors]; while (stack.length) { const curr = stack.pop(); if (noise.has(curr.id)) { noise.delete(curr.id); m.set(curr.id, cId); } if (visited.has(curr.id)) continue; visited.add(curr.id); m.set(curr.id, cId); const currNeighbors = getNeighbors(curr); if (currNeighbors.length >= minPts) stack.push(...currNeighbors); } } }); const stats = calculateModularity(g, m, params.res || 1.0, params.minWeight); return { clusters: m, ...stats }; } } ]; // --- Main App --- class App { constructor() { this.g = new Graph(); this.cv = document.getElementById('graphCanvas'); this.ren = new Renderer(this.cv); this.phys = new Physics(); this.sim = false; this.last = 0; this.substeps = 1; // Added substeps property this.init(); } toHex(str) { if (str.startsWith('#')) return str; if (str.startsWith('hsl')) { const m = str.match(/\d+(\.\d+)?/g); if (!m) return '#000000'; const h = parseFloat(m[0]) / 360; const s = 0.75; const l = (this.ren.isDark ? 60 : 50) / 100; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; const hue2rgb = (t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const r = Math.round(hue2rgb(h + 1 / 3) * 255); const g = Math.round(hue2rgb(h) * 255); const b = Math.round(hue2rgb(h - 1 / 3) * 255); return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join(''); } return '#000000'; } init() { this.ren.resize(); window.onresize = () => this.ren.resize(); const sel = document.getElementById('clusteringAlgo'); Algos.forEach((a, i) => { const o = document.createElement('option'); o.value = i; o.textContent = a.name; sel.add(o); }); this.initTheme(); this.setupEvents(); this.initManualClustering(); sel.dispatchEvent(new Event('change')); this.loop(); } initManualClustering() { const modal = document.getElementById('manualClusterModal'); const openBtn = document.getElementById('openManualCluster'); const closeBtn = document.getElementById('closeManualCluster'); const addBtn = document.getElementById('addManualCluster'); const saveBtn = document.getElementById('saveManualCluster'); const liveCheck = document.getElementById('manualLiveModularity'); this.manualState = { nodeClusters: new Map(), // nodeId -> clusterId clusters: new Set(), selectedNodes: new Set() }; openBtn.onclick = () => { this.manualState.nodeClusters = new Map(); this.manualState.clusters = new Set(); this.manualState.selectedNodes.clear(); this.g.nodes.forEach((n, id) => { let c = n.clusterId; if (c !== null && !isNaN(c)) c = Number(c); // Normalize to number if (c === null) c = -1; this.manualState.nodeClusters.set(id, c); if (c !== -1) this.manualState.clusters.add(c); }); // Sync resolution from current algo const idx = document.getElementById('clusteringAlgo').value; let res = 1.0; if (idx == 0) res = parseFloat(document.getElementById('leidenRes').value); else if (idx == 1) res = parseFloat(document.getElementById('ensembleRes').value); document.getElementById('manualRes').value = res; modal.style.display = 'flex'; this.renderManualClusters(); this.updateManualModularity(); }; closeBtn.onclick = () => modal.style.display = 'none'; addBtn.onclick = () => { let nextId = 0; if (this.manualState.clusters.size > 0) { nextId = Math.max(...this.manualState.clusters) + 1; } this.manualState.clusters.add(nextId); this.renderManualClusters(); this.updateManualModularity(); }; document.getElementById('saveManualCluster').onclick = () => { const clusters = new Map(); this.manualState.nodeClusters.forEach((c, id) => { this.g.nodes.get(id).clusterId = c === -1 ? null : c; if (c !== -1) clusters.set(id, c); }); const res = parseFloat(document.getElementById('manualRes').value) || 1.0; const stats = calculateModularity(this.g, clusters, res, this.phys.params.minWeight); modal.style.display = 'none'; this.updateClusteringUI({ clusters, ...stats }); }; liveCheck.onchange = () => this.updateManualModularity(); document.getElementById('manualRes').oninput = () => this.updateManualModularity(); } renderManualClusters() { const container = document.getElementById('manualClusterCols'); container.innerHTML = ''; const colIds = [-1, ...Array.from(this.manualState.clusters).sort((a, b) => a - b)]; colIds.forEach(cId => { const col = document.createElement('div'); col.className = 'cluster-col'; col.dataset.cid = cId; const name = cId === -1 ? 'Без кластера' : `Кластер ${cId}`; const color = cId === -1 ? 'var(--text-muted)' : this.ren.getColor(String(cId)); const header = document.createElement('div'); header.className = 'cluster-col-header'; if (cId !== -1) { header.draggable = true; header.style.cursor = 'grab'; header.ondragstart = (e) => { e.dataTransfer.setData('sourceClusterId', cId); e.dataTransfer.effectAllowed = 'move'; const ghost = document.createElement('div'); ghost.style.cssText = ` padding: 10px 20px; background: var(--panel-bg); border: 2px solid var(--accent-color); color: var(--accent-color); border-radius: 12px; font-weight: bold; position: absolute; top: -1000px; `; ghost.textContent = `Объединение: Кластер ${cId}`; document.body.appendChild(ghost); e.dataTransfer.setDragImage(ghost, 0, 0); setTimeout(() => document.body.removeChild(ghost), 0); }; } header.innerHTML = `<h4 style="color:${color}">${name}</h4>`; if (cId !== -1) { const delBtn = document.createElement('button'); delBtn.className = 'icon-btn remove-col'; delBtn.dataset.cid = cId; delBtn.style.cssText = 'font-size:1.2rem; background:none; border:none; color:var(--text-muted); cursor:pointer; padding:0 5px;'; delBtn.innerHTML = '×'; delBtn.onclick = (e) => { e.stopPropagation(); this.manualState.clusters.delete(cId); this.manualState.nodeClusters.forEach((c, id) => { if (c == cId) this.manualState.nodeClusters.set(id, -1); }); this.renderManualClusters(); this.updateManualModularity(); }; header.appendChild(delBtn); } col.appendChild(header); const list = document.createElement('div'); list.className = 'cluster-col-list'; this.manualState.nodeClusters.forEach((nodeCId, nodeId) => { if (nodeCId == cId) { // Loose equality to handle any remaining type variations const item = document.createElement('div'); item.className = 'cluster-node-item'; if (this.manualState.selectedNodes.has(nodeId)) item.classList.add('selected'); item.draggable = true; item.dataset.nid = nodeId; item.innerHTML = `<div class="node-color-dot" style="background:${color}"></div>${nodeId}`; item.onclick = (e) => { e.stopPropagation(); if (e.ctrlKey || e.metaKey) { if (this.manualState.selectedNodes.has(nodeId)) this.manualState.selectedNodes.delete(nodeId); else this.manualState.selectedNodes.add(nodeId); } else { this.manualState.selectedNodes.clear(); this.manualState.selectedNodes.add(nodeId); } this.renderManualClusters(); }; item.ondragstart = (e) => { if (!this.manualState.selectedNodes.has(nodeId)) { this.manualState.selectedNodes.clear(); this.manualState.selectedNodes.add(nodeId); container.querySelectorAll('.cluster-node-item.selected').forEach(el => el.classList.remove('selected')); item.classList.add('selected'); } const count = this.manualState.selectedNodes.size; if (count > 1) { const ghost = document.createElement('div'); ghost.style.cssText = ` padding: 8px 16px; background: var(--accent-color); color: white; border-radius: 20px; font-size: 0.8rem; font-weight: bold; box-shadow: var(--shadow-lg); position: absolute; top: -1000px; pointer-events: none; z-index: 10001; `; ghost.textContent = `Перемещение: ${count} узлов`; document.body.appendChild(ghost); e.dataTransfer.setDragImage(ghost, 0, 0); // Clean up ghost after drag starts setTimeout(() => document.body.removeChild(ghost), 0); } e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', nodeId); }; list.appendChild(item); } }); col.appendChild(list); col.ondragover = (e) => { e.preventDefault(); col.classList.add('drag-over'); }; col.ondragleave = () => col.classList.remove('drag-over'); col.ondrop = (e) => { e.preventDefault(); col.classList.remove('drag-over'); const targetCId = parseInt(col.dataset.cid); const sourceClusterId = e.dataTransfer.getData('sourceClusterId'); if (sourceClusterId !== "") { // Merge cluster const sId = parseInt(sourceClusterId); if (sId === targetCId) return; this.manualState.nodeClusters.forEach((c, id) => { if (c == sId) this.manualState.nodeClusters.set(id, targetCId); }); this.manualState.clusters.delete(sId); } else { // Move nodes this.manualState.selectedNodes.forEach(nid => { this.manualState.nodeClusters.set(nid, targetCId); }); this.manualState.selectedNodes.clear(); } this.renderManualClusters(); this.updateManualModularity(); }; container.appendChild(col); }); } updateManualModularity() { const display = document.getElementById('manualModularityVal'); const live = document.getElementById('manualLiveModularity').checked; if (!live) { display.textContent = ''; return; } const res = parseFloat(document.getElementById('manualRes').value) || 1.0; const clusters = new Map(); this.manualState.nodeClusters.forEach((c, id) => { if (c !== -1) clusters.set(id, c); }); const stats = calculateModularity(this.g, clusters, res, this.phys.params.minWeight); display.textContent = `Q = ${stats.modularity.toFixed(4)}`; } updateClusteringUI(resObj) { const res = resObj.clusters; const leg = document.getElementById('clusteringLegend'); leg.style.display = 'block'; leg.innerHTML = ''; let legHTML = '<strong>Результаты:</strong>'; if (resObj.modularity !== undefined) { legHTML += ` <div style="font-size:0.75rem; color:var(--text-muted); margin:1.2rem 0 0.5rem 0; display:flex; align-items:center;"> Модулярность (качество): ${resObj.modularity.toFixed(3)} <div class="info-trigger">? <div class="tooltip-box"> <b>Что такое модулярность (Q)?</b> Это метрика качества структуры сообществ. Она сравнивает плотность связей внутри групп с ожидаемой плотностью в случайной сети. <br><br> • <b>Q ≈ 0</b>: структура случайна.<br> • <b>Q > 0.3</b>: хорошая структура.<br> • <b>Q > 0.7</b>: очень четкие группы. </div> </div> </div>`; } legHTML += '<div style="margin-top:0.8rem"></div>'; leg.innerHTML = legHTML; const groupNodes = new Map(); res.forEach((cId, nId) => { if (!groupNodes.has(cId)) groupNodes.set(cId, []); groupNodes.get(cId).push(nId); }); Array.from(groupNodes.keys()).sort((a, b) => a - b).forEach(cId => { const ids = groupNodes.get(cId); const sId = String(cId); const currentColor = this.ren.getColor(sId); const metric = resObj.metrics ? resObj.metrics.get(cId) : null; const qualityTag = metric ? `<span style="font-size:0.65rem; padding:2px 6px; border-radius:4px; background:var(--glass-bg); margin-left:8px; border:1px solid var(--glass-border)">${metric.quality}</span>` : ''; const item = document.createElement('div'); item.className = 'cluster-item'; item.innerHTML = ` <div class="cluster-header"> <input type="color" data-cid="${sId}" value="${this.toHex(currentColor)}"> <span style="font-weight:700">Кластер ${cId}</span> ${qualityTag} <span style="color:var(--text-muted)">(${ids.length} узлов)</span> </div> <div class="cluster-nodes">${ids.join(', ')}</div> `; leg.appendChild(item); }); this.ren.render(this.g); } initTheme() { const t = localStorage.getItem('theme') || 'dark-theme'; document.body.className = t; this.ren.isDark = t === 'dark-theme'; document.querySelector('.theme-icon-sun').style.display = this.ren.isDark ? 'none' : 'block'; document.querySelector('.theme-icon-moon').style.display = this.ren.isDark ? 'block' : 'none'; } syncUIFromState() { // Physics const p = this.phys.params; const map = { 'paramWeightFilter': p.minWeight, 'paramAttract': p.attraction, 'paramAttractPower': p.attractionPower, 'paramAttractDistPower': p.attractDistPower, 'paramRepulse': p.repulsion, 'paramRepulsePower': p.repulsionPower, 'paramGravity': p.gravity, 'paramDamping': p.damping, 'paramTemp': this.phys.state.temp }; for (const [id, val] of Object.entries(map)) { const s = document.getElementById(id); const n = document.getElementById(id + 'Num'); if (s) s.value = val; if (n) n.value = val; } const simS = document.getElementById('simSpeed'); const simN = document.getElementById('simSpeedNum'); if (simS) simS.value = this.phys.params.timeScale; if (simN) simN.value = this.phys.params.timeScale; const substepsS = document.getElementById('simSubsteps'); const substepsN = document.getElementById('simSubstepsNum'); if (substepsS) substepsS.value = this.substeps; if (substepsN) substepsN.value = this.substeps; const elasticPill = document.querySelector('#toggleElastic .toggle-pill'); if (elasticPill) { elasticPill.classList.toggle('active', p.elastic); document.getElementById('rowElasticPower').style.display = p.elastic ? 'block' : 'none'; } // Viz const v = this.ren.viz; const vMap = { 'vizP1': v.p1 * 100, 'vizP2': v.p2 * 100, 'vizWMin': v.wMin, 'vizWMid': v.wMid, 'vizWMax': v.wMax, 'vizAWeak': v.aWeak, 'vizAStrong': v.aStrong }; for (const [id, val] of Object.entries(vMap)) { const el = document.getElementById(id); if (el) el.value = val; } } setupEvents() { document.getElementById('toggleTheme').onclick = () => { const n = document.body.className === 'dark-theme' ? 'light-theme' : 'dark-theme'; document.body.className = n; localStorage.setItem('theme', n); this.initTheme(); this.ren.render(this.g); }; document.getElementById('loadGraph').onclick = () => { this.g = Graph.parse(document.getElementById('adjacencyList').value); this.updateStats(); this.center(); this.ren.render(this.g); }; document.getElementById('exportJSON').onclick = () => { const bundle = { version: "1.1", graph: this.g.toJSON(), physics: { params: this.phys.params, speed: parseFloat(document.getElementById('simSpeed').value), substeps: parseInt(document.getElementById('simSubsteps').value) // Export substeps }, viz: this.ren.viz, clusters: { colors: Object.fromEntries(this.ren.clusterColors), algoIndex: document.getElementById('clusteringAlgo').value, algoParams: { leidenRes: document.getElementById('leidenRes').value, leidenIter: document.getElementById('leidenIter').value, ensembleRuns: document.getElementById('ensembleRuns').value, ensembleThreshold: document.getElementById('ensembleThreshold').value, ensembleRes: document.getElementById('ensembleRes').value, ensembleIter: document.getElementById('ensembleIter').value, kcoreK: document.getElementById('kcoreK').value, kmeansK: document.getElementById('kmeansK').value, dbscanEps: document.getElementById('dbscanEps').value, dbscanMinPts: document.getElementById('dbscanMinPts').value, } } }; const b = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'graph_state.json'; a.click(); }; document.getElementById('importJSON').onclick = () => { const i = document.createElement('input'); i.type = 'file'; i.onchange = e => { const r = new FileReader(); r.onload = ev => { const data = JSON.parse(ev.target.result); // Backward compatibility check const isNewFormat = data.version === "1.1"; const gData = isNewFormat ? data.graph : data; this.g = new Graph(); gData.nodes.forEach(n => { const node = this.g.addNode(n.id, n.x, n.y); if (n.clusterId !== undefined) node.clusterId = n.clusterId; }); gData.edges.forEach(e => this.g.addEdge(e.source, e.target, e.weight)); if (isNewFormat) { if (data.physics) { Object.assign(this.phys.params, data.physics.params); if (data.physics.speed) this.phys.params.timeScale = data.physics.speed; if (data.physics.substeps) this.substeps = data.physics.substeps; // Import substeps } if (data.viz) Object.assign(this.ren.viz, data.viz); if (data.clusters) { this.ren.clusterColors = new Map(Object.entries(data.clusters.colors || {})); document.getElementById('clusteringAlgo').value = data.clusters.algoIndex; // Trigger algo change to show correct params row document.getElementById('clusteringAlgo').dispatchEvent(new Event('change')); if (data.clusters.algoParams) { for (const [id, val] of Object.entries(data.clusters.algoParams)) { const el = document.getElementById(id); if (el) el.value = val; } } } this.syncUIFromState(); } this.phys.reset(); this.updateStats(); // Update legend if clusters exist const clusterMap = new Map(); this.g.nodes.forEach(n => { if (n.clusterId !== null) clusterMap.set(n.id, n.clusterId); }); if (clusterMap.size > 0) { const qRes = parseFloat(document.getElementById('leidenRes').value) || 1.0; const stats = calculateModularity(this.g, clusterMap, qRes, this.phys.params.minWeight); this.updateClusteringUI({ clusters: clusterMap, ...stats }); } else { document.getElementById('clusteringLegend').style.display = 'none'; this.ren.render(this.g); } }; r.readAsText(e.target.files[0]); }; i.click(); }; document.getElementById('toggleSimulation').onclick = () => { this.sim = !this.sim; const text = this.sim ? 'Остановить' : 'Запустить'; const icon = this.sim ? '<rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect>' : '<polygon points="5 3 19 12 5 21 5 3"></polygon>'; document.getElementById('simText').textContent = text; document.getElementById('simIcon').innerHTML = icon; if (this.sim) { this.last = performance.now(); requestAnimationFrame(t => this.loop(t)); } }; document.getElementById('stepSimulation').onclick = () => { // Run a single step with current substeps for (let i = 0; i < (this.substeps || 1); i++) { this.phys.update(this.g, 0.016, this.cv.width, this.cv.height); } this.ren.render(this.g); }; // Synced Controls logic const sync = (sliderId, numId, setterFn) => { const slider = document.getElementById(sliderId); const num = document.getElementById(numId); const update = (val) => { val = parseFloat(val); if (isNaN(val)) return; slider.value = val; num.value = val; setterFn(val); if (sliderId === 'paramWeightFilter') { // Special handling for weight filter this.ren.weightFilter = val; if (!this.sim) this.ren.render(this.g); } }; slider.oninput = (e) => update(e.target.value); num.oninput = (e) => update(e.target.value); }; sync('simSpeed', 'simSpeedNum', v => this.phys.params.timeScale = parseFloat(v)); sync('simSubsteps', 'simSubstepsNum', v => this.substeps = parseInt(v)); sync('paramWeightFilter', 'paramWeightFilterNum', v => this.phys.params.minWeight = parseFloat(v)); sync('paramAttract', 'paramAttractNum', v => this.phys.params.attraction = parseFloat(v)); sync('paramAttractPower', 'paramAttractPowerNum', v => this.phys.params.attractionPower = parseFloat(v)); sync('paramRepulse', 'paramRepulseNum', v => this.phys.params.repulsion = parseFloat(v)); sync('paramRepulsePower', 'paramRepulsePowerNum', v => this.phys.params.repulsionPower = parseFloat(v)); sync('paramGravity', 'paramGravityNum', v => this.phys.params.gravity = parseFloat(v)); sync('paramDamping', 'paramDampingNum', v => this.phys.params.damping = parseFloat(v)); sync('paramAttractDistPower', 'paramAttractDistPowerNum', v => this.phys.params.attractDistPower = parseFloat(v)); sync('paramTemp', 'paramTempNum', v => this.phys.state.temp = parseFloat(v)); document.getElementById('toggleElastic').onclick = () => { this.phys.params.elastic = !this.phys.params.elastic; document.getElementById('toggleElastic').classList.toggle('active', this.phys.params.elastic); document.getElementById('rowElasticPower').style.display = this.phys.params.elastic ? 'block' : 'none'; }; // Viz Modal Logic const vizModal = document.getElementById('vizModal'); const openViz = document.getElementById('openVizModal'); const closeViz = document.getElementById('closeViz'); const applyViz = document.getElementById('applyViz'); const resetViz = document.getElementById('resetViz'); openViz.onclick = () => { vizModal.style.display = 'flex'; const v = this.ren.viz; document.getElementById('vizP1').value = Math.round(v.p1 * 100); document.getElementById('vizP2').value = Math.round(v.p2 * 100); document.getElementById('vizWMin').value = v.wMin; document.getElementById('vizWMid').value = v.wMid; document.getElementById('vizWMax').value = v.wMax; document.getElementById('vizAWeak').value = v.aWeak; document.getElementById('vizAStrong').value = v.aStrong; }; const closeModal = () => vizModal.style.display = 'none'; closeViz.onclick = closeModal; vizModal.onclick = (e) => { if (e.target === vizModal) closeModal(); }; applyViz.onclick = () => { const v = this.ren.viz; v.p1 = parseFloat(document.getElementById('vizP1').value) / 100; v.p2 = parseFloat(document.getElementById('vizP2').value) / 100; v.wMin = parseFloat(document.getElementById('vizWMin').value); v.wMid = parseFloat(document.getElementById('vizWMid').value); v.wMax = parseFloat(document.getElementById('vizWMax').value); v.aWeak = parseFloat(document.getElementById('vizAWeak').value); v.aStrong = parseFloat(document.getElementById('vizAStrong').value); if (!this.sim) this.ren.render(this.g); closeModal(); }; resetViz.onclick = () => { this.ren.viz = { p1: 0.15, p2: 0.64, wMin: 1.0, wMid: 2.0, wMax: 4.0, aWeak: 0.1, aStrong: 1.0 }; openViz.click(); // Refresh inputs if (!this.sim) this.ren.render(this.g); }; document.getElementById('clusteringAlgo').onchange = e => { const idx = parseInt(e.target.value); document.getElementById('leidenParams').style.display = idx === 0 ? 'block' : 'none'; document.getElementById('ensembleParams').style.display = idx === 1 ? 'block' : 'none'; document.getElementById('kcoreParams').style.display = idx === 2 ? 'block' : 'none'; document.getElementById('kmeansParams').style.display = idx === 3 ? 'block' : 'none'; document.getElementById('dbscanParams').style.display = idx === 4 ? 'block' : 'none'; }; document.getElementById('applyClustering').onclick = () => { const idx = document.getElementById('clusteringAlgo').value; const params = { kcoreK: parseInt(document.getElementById('kcoreK').value), k: parseInt(document.getElementById('kmeansK').value), eps: parseFloat(document.getElementById('dbscanEps').value), minPts: parseInt(document.getElementById('dbscanMinPts').value), res: parseFloat(document.getElementById('leidenRes').value), iterations: parseInt(document.getElementById('leidenIter').value), runs: parseInt(document.getElementById('ensembleRuns').value), threshold: parseFloat(document.getElementById('ensembleThreshold').value), ensembleRes: parseFloat(document.getElementById('ensembleRes').value), ensembleIter: parseInt(document.getElementById('ensembleIter').value), minWeight: this.phys.params.minWeight }; // Ensure we use the correct resolution for benchmark if (idx == 0) params.res = parseFloat(document.getElementById('leidenRes').value); else if (idx == 1) params.res = parseFloat(document.getElementById('ensembleRes').value); else params.res = 1.0; const resObj = Algos[idx].run(this.g, params); const res = resObj.clusters; this.g.nodes.forEach((n, id) => n.clusterId = res.has(id) ? res.get(id) : null); this.updateClusteringUI(resObj); }; document.getElementById('clusteringLegend').oninput = (e) => { if (e.target.type === 'color') { const cId = String(e.target.dataset.cid); this.ren.clusterColors.set(cId, e.target.value); this.ren.render(this.g); } }; document.getElementById('resetClustering').onclick = () => { this.g.nodes.forEach(n => n.clusterId = null); this.ren.clusterColors.clear(); document.getElementById('clusteringLegend').style.display = 'none'; this.ren.render(this.g); }; document.getElementById('layoutByClusters').onclick = () => { const clusters = new Map(); this.g.nodes.forEach(n => { const cId = n.clusterId !== null ? n.clusterId : -1; if (!clusters.has(cId)) clusters.set(cId, []); clusters.get(cId).push(n); }); const clusterIds = Array.from(clusters.keys()); if (clusterIds.length === 0) return; const centerX = this.cv.width / 2; const centerY = this.cv.height / 2; const nClust = clusterIds.length; const globalRadius = Math.min(centerX, centerY) * 0.7; clusterIds.forEach((cId, i) => { const angle = (i / nClust) * Math.PI * 2; const tx = centerX + Math.cos(angle) * globalRadius; const ty = centerY + Math.sin(angle) * globalRadius; const members = clusters.get(cId); members.forEach((n, j) => { const subAngle = (j / members.length) * Math.PI * 2; const subRadius = 40 + Math.random() * 30; // Spread nodes slightly n.x = tx + Math.cos(subAngle) * subRadius; n.y = ty + Math.sin(subAngle) * subRadius; n.vx = 0; n.vy = 0; // Freeze }); }); this.phys.reset(); this.ren.render(this.g); }; document.getElementById('zoomIn').onclick = () => this.zoom(1.1); document.getElementById('zoomOut').onclick = () => this.zoom(0.9); document.getElementById('centerGraph').onclick = () => this.center(); document.getElementById('gatherNodes').onclick = () => this.gatherNodes(); document.getElementById('fitScale').onclick = () => this.fitToScreen(); document.getElementById('randomizeLayout').onclick = () => { this.g.nodes.forEach(n => { n.x = (Math.random() - 0.5) * 800; n.y = (Math.random() - 0.5) * 600; }); this.phys.reset(); this.ren.render(this.g); }; document.getElementById('savePNG').onclick = () => { const a = document.createElement('a'); a.download = 'graph.png'; a.href = this.cv.toDataURL(); a.click(); }; let drag = null, pan = false, lastP = { x: 0, y: 0 }; const getCanvasPos = (e) => { const r = this.cv.getBoundingClientRect(); return { x: (e.clientX - r.left) * (this.cv.width / r.width), y: (e.clientY - r.top) * (this.cv.height / r.height), rawX: e.clientX, rawY: e.clientY }; }; this.cv.onmousedown = e => { const p = getCanvasPos(e); const w = this.ren.screenToWorld(p.x, p.y); let hitNode = null; const hitRadius = 16 / this.ren.tx.scale; for (const n of this.g.nodes.values()) { if (Math.hypot(n.x - w.x, n.y - w.y) < hitRadius) { hitNode = n; break; } } if (hitNode) { if (e.ctrlKey) { hitNode.isSelected = !hitNode.isSelected; } else if (!hitNode.isSelected) { this.g.nodes.forEach(n => n.isSelected = false); hitNode.isSelected = true; } drag = hitNode; this.g.nodes.forEach(n => { if (n.isSelected) n.isDragging = true; }); } else { if (!e.ctrlKey && e.button === 0 && !e.shiftKey) { this.g.nodes.forEach(n => n.isSelected = false); this.ren.selBox = { x: p.x, y: p.y, w: 0, h: 0 }; } if (e.button === 2 || e.button === 1 || e.shiftKey) pan = true; } lastP = { x: p.rawX, y: p.rawY }; this.ren.render(this.g); }; window.onmousemove = e => { const p = getCanvasPos(e); const r = this.cv.getBoundingClientRect(); const dx = p.rawX - lastP.x; const dy = p.rawY - lastP.y; const canvasDx = dx * (this.cv.width / r.width); const canvasDy = dy * (this.cv.height / r.height); if (drag) { const worldDx = canvasDx / this.ren.tx.scale; const worldDy = canvasDy / this.ren.tx.scale; this.g.nodes.forEach(n => { if (n.isSelected) { n.x += worldDx; n.y += worldDy; } }); } else if (pan) { this.ren.tx.x += canvasDx; this.ren.tx.y += canvasDy; } else if (this.ren.selBox) { this.ren.selBox.w = p.x - this.ren.selBox.x; this.ren.selBox.h = p.y - this.ren.selBox.y; } else { const w = this.ren.screenToWorld(p.x, p.y); const hitRadius = 16 / this.ren.tx.scale; let h = null; for (const n of this.g.nodes.values()) { if (Math.hypot(n.x - w.x, n.y - w.y) < hitRadius) { h = n.id; break; } } this.ren.hovId = h; } lastP = { x: p.rawX, y: p.rawY }; this.ren.render(this.g); }; window.onmouseup = () => { if (this.ren.selBox) { const box = this.ren.selBox; const x1 = Math.min(box.x, box.x + box.w), x2 = Math.max(box.x, box.x + box.w); const y1 = Math.min(box.y, box.y + box.h), y2 = Math.max(box.y, box.y + box.h); this.g.nodes.forEach(n => { const sx = n.x * this.ren.tx.scale + this.ren.tx.x; const sy = n.y * this.ren.tx.scale + this.ren.tx.y; if (sx >= x1 && sx <= x2 && sy >= y1 && sy <= y2) n.isSelected = true; }); this.ren.selBox = null; } this.g.nodes.forEach(n => n.isDragging = false); drag = null; pan = false; this.ren.render(this.g); }; this.cv.onwheel = e => { e.preventDefault(); const p = getCanvasPos(e); this.zoom(e.deltaY > 0 ? 0.9 : 1.1, p.x, p.y); }; this.cv.oncontextmenu = e => e.preventDefault(); } zoom(f, mx, my) { if (mx === undefined) { mx = this.cv.width / 2; my = this.cv.height / 2; } const wBefore = this.ren.screenToWorld(mx, my); this.ren.tx.scale = Math.max(0.05, Math.min(20, this.ren.tx.scale * f)); this.ren.tx.x = mx - wBefore.x * this.ren.tx.scale; this.ren.tx.y = my - wBefore.y * this.ren.tx.scale; document.getElementById('zoomPercent').textContent = Math.round(this.ren.tx.scale * 100) + '%'; this.ren.render(this.g); } fitToScreen() { if (!this.g.nodes.size) return; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; this.g.nodes.forEach(n => { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); }); const w = maxX - minX || 100; const h = maxY - minY || 100; const pad = 100; // Padding from edges const scaleX = (this.cv.width - pad) / w; const scaleY = (this.cv.height - pad) / h; this.ren.tx.scale = Math.max(0.05, Math.min(scaleX, scaleY, 2.0)); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; this.ren.tx.x = this.cv.width / 2 - cx * this.ren.tx.scale; this.ren.tx.y = this.cv.height / 2 - cy * this.ren.tx.scale; document.getElementById('zoomPercent').textContent = Math.round(this.ren.tx.scale * 100) + '%'; this.ren.render(this.g); } center() { if (!this.g.nodes.size) return; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; this.g.nodes.forEach(n => { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); }); const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; this.ren.tx.x = this.cv.width / 2 - cx * this.ren.tx.scale; this.ren.tx.y = this.cv.height / 2 - cy * this.ren.tx.scale; this.ren.render(this.g); } gatherNodes() { this.g.nodes.forEach(n => { n.x = 0; n.y = 0; }); this.phys.reset(); this.ren.render(this.g); } updateStats() { document.getElementById('statNodes').textContent = this.g.nodes.size; document.getElementById('statEdges').textContent = this.g.edges.length; // Update weight filter range if (this.g.edges.length > 0) { let minW = Infinity, maxW = -Infinity; this.g.edges.forEach(e => { minW = Math.min(minW, e.weight); maxW = Math.max(maxW, e.weight); }); const slider = document.getElementById('paramWeightFilter'); const num = document.getElementById('paramWeightFilterNum'); slider.min = Math.floor(minW); slider.max = Math.ceil(maxW); num.min = Math.floor(minW); num.max = Math.ceil(maxW); // Don't auto-reset value unless it's out of bounds if (this.phys.params.minWeight < minW) { this.phys.params.minWeight = minW; slider.value = minW; num.value = minW; } } } loop(t) { if (!this.sim) return; const dt = Math.min((t - this.last) / 1000, 0.05); this.last = t; this.ren.weightFilter = this.phys.params.minWeight; // Run multiple physics steps per rendering frame for (let i = 0; i < (this.substeps || 1); i++) { this.phys.update(this.g, dt, this.cv.width, this.cv.height); } this.ren.render(this.g); requestAnimationFrame(tm => this.loop(tm)); } } new App(); </script> </body> </html> ```