Lexai vovkes222 Claude Opus 4.6 commited on
Commit
c87c3c8
·
unverified ·
1 Parent(s): 7a21b3d

feat: add animated D3.js architecture diagram to Архітектура tab (#5)

Browse files

Interactive SVG diagram with:
- 9 agent boxes with hover tooltips
- Animated flow arrows (6 flow types with filter chips)
- ConsultationState panel
- Research loop indicator
- Sequential entry animation

Replaces static ASCII art with rich visualization.

Co-authored-by: overthelex <mcvovkes@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +2 -53
  2. architecture.html +562 -0
app.py CHANGED
@@ -163,58 +163,7 @@ def stream_chat(message: str, history: list[dict]):
163
  yield accumulated
164
 
165
 
166
- ARCHITECTURE_MD = """
167
- ## Архітектура LMAF
168
-
169
- Дев'ять спеціалізованих LLM-агентів працюють у циклі. Кожен агент починає з чистого контексту.
170
- Весь стан зберігається у структурованому об'єкті `ConsultationState`.
171
-
172
- ```
173
- Запит клієнта
174
-
175
-
176
- ┌─────────────┐
177
- │ Surveyor │ Огляд правового ландшафту
178
- └──────┬──────┘
179
-
180
- ┌─────────────┐
181
- │ Planner │ Стратегія дослідження
182
- └──────┬──────┘
183
-
184
- ┌─────────────┐ ┌────────────┐
185
- │Orchestrator │────►│ Researcher │ Судова практика, НПА
186
- │ (dispatch) │────►│ Analyst │ Обчислення, строки
187
- └──────┬──────┘ └─────┬──────┘
188
- │ │
189
- ▼ ▼
190
- ┌─────────────┐ ┌────────────┐
191
- │ Reviewer │ │ Critic │ Аудит стратегії
192
- │(верифікація)│ │ (periodic) │
193
- └──────┬──────┘ └─────┬──────┘
194
- │ │
195
- ▼ ▼
196
- ┌─────────────┐ ┌──────────────┐
197
- │ Adjudicator │ │ Planner │
198
- │ (арбітраж) │ │ (revision) │
199
- └──────┬──────┘ └──────────────┘
200
-
201
- ┌─────────────┐
202
- │ Formatter │ Фінальна консультація
203
- └─────────────┘
204
- ```
205
-
206
- | Агент | Роль | Аналог в secondlayer-core |
207
- |-------|------|--------------------------|
208
- | **Surveyor** | Карта правового ландшафту | IntentClassifier + QueryPlanner |
209
- | **Planner** | Стратегія дослідження | Генерація ExecutionPlan |
210
- | **Orchestrator** | Координація та гіпотези | Агентний цикл ChatService |
211
- | **Researcher** | Пошук практики та НПА | Виклики інструментів |
212
- | **Analyst** | Обчислення строків, сум | Калькуляційні інструменти |
213
- | **Reviewer** | Верифікація доказів | CitationValidator + HallucinationGuard |
214
- | **Critic** | Аудит стратегії | *Нова можливість* |
215
- | **Adjudicator** | Арбітраж розбіжностей | *Нова можливість* |
216
- | **Formatter** | Оформлення консультації | Синтез відповіді |
217
- """
218
 
219
 
220
  DATASETS_MD = """
@@ -265,7 +214,7 @@ def build_app() -> gr.Blocks:
265
  )
266
 
267
  with gr.Tab("Архітектура"):
268
- gr.Markdown(ARCHITECTURE_MD)
269
 
270
  with gr.Tab("Датасети"):
271
  gr.Markdown(DATASETS_MD)
 
163
  yield accumulated
164
 
165
 
166
+ ARCHITECTURE_HTML = (Path(__file__).parent / "architecture.html").read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
 
169
  DATASETS_MD = """
 
214
  )
215
 
216
  with gr.Tab("Архітектура"):
217
+ gr.HTML(ARCHITECTURE_HTML)
218
 
219
  with gr.Tab("Датасети"):
220
  gr.Markdown(DATASETS_MD)
architecture.html ADDED
@@ -0,0 +1,562 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div id="lmaf-arch"></div>
2
+
3
+ <style>
4
+ #lmaf-arch {
5
+ width: 100%;
6
+ font-family: system-ui, -apple-system, sans-serif;
7
+ color: #e0e0e0;
8
+ background: #1a1a2e;
9
+ border-radius: 12px;
10
+ overflow: hidden;
11
+ position: relative;
12
+ }
13
+
14
+ /* Filter bar */
15
+ #lmaf-arch .filter-bar {
16
+ display: flex;
17
+ flex-wrap: wrap;
18
+ align-items: center;
19
+ gap: 8px;
20
+ padding: 14px 20px 10px;
21
+ font-size: 12px;
22
+ border-bottom: 1px solid rgba(255,255,255,0.08);
23
+ }
24
+ #lmaf-arch .filter-bar__label {
25
+ font-size: 11px;
26
+ letter-spacing: 0.06em;
27
+ text-transform: uppercase;
28
+ color: #888;
29
+ margin-right: 4px;
30
+ }
31
+ #lmaf-arch .filter-chip {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: 6px;
35
+ padding: 4px 12px;
36
+ border-radius: 999px;
37
+ border: 1px solid rgba(255,255,255,0.15);
38
+ background: transparent;
39
+ color: #999;
40
+ cursor: pointer;
41
+ transition: all .2s ease;
42
+ font-size: 12px;
43
+ font-family: inherit;
44
+ }
45
+ #lmaf-arch .filter-chip:hover {
46
+ color: #fff;
47
+ border-color: rgba(255,255,255,0.4);
48
+ }
49
+ #lmaf-arch .filter-chip.is-active {
50
+ color: #fff;
51
+ border-color: currentColor;
52
+ background: rgba(255,255,255,0.06);
53
+ }
54
+ #lmaf-arch .chip-swatch {
55
+ width: 10px;
56
+ height: 10px;
57
+ border-radius: 2px;
58
+ flex: 0 0 auto;
59
+ }
60
+
61
+ /* SVG */
62
+ #lmaf-arch svg {
63
+ display: block;
64
+ width: 100%;
65
+ height: auto;
66
+ }
67
+
68
+ /* Tooltip */
69
+ #lmaf-arch .arch-tooltip {
70
+ position: absolute;
71
+ pointer-events: none;
72
+ background: rgba(20,20,40,0.95);
73
+ border: 1px solid rgba(255,255,255,0.15);
74
+ border-radius: 8px;
75
+ padding: 12px 16px;
76
+ font-size: 13px;
77
+ line-height: 1.5;
78
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4);
79
+ opacity: 0;
80
+ transition: opacity .15s ease;
81
+ max-width: 280px;
82
+ z-index: 100;
83
+ backdrop-filter: blur(8px);
84
+ }
85
+ #lmaf-arch .arch-tooltip__title {
86
+ font-weight: 700;
87
+ font-size: 14px;
88
+ margin-bottom: 2px;
89
+ }
90
+ #lmaf-arch .arch-tooltip__sub {
91
+ font-size: 11px;
92
+ color: #888;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.06em;
95
+ margin-bottom: 6px;
96
+ }
97
+ #lmaf-arch .arch-tooltip__body {
98
+ color: #ccc;
99
+ font-size: 12px;
100
+ }
101
+ </style>
102
+
103
+ <script>
104
+ (() => {
105
+ const ensureD3 = (cb) => {
106
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
107
+ let s = document.getElementById('d3-cdn-lmaf');
108
+ if (!s) {
109
+ s = document.createElement('script');
110
+ s.id = 'd3-cdn-lmaf';
111
+ s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
112
+ document.head.appendChild(s);
113
+ }
114
+ s.addEventListener('load', () => { if (window.d3) cb(); }, { once: true });
115
+ };
116
+
117
+ ensureD3(() => {
118
+ const root = document.querySelector('#lmaf-arch');
119
+ if (!root || root.dataset.mounted === 'true') return;
120
+ root.dataset.mounted = 'true';
121
+ const d3 = window.d3;
122
+
123
+ const VB_W = 1100, VB_H = 620;
124
+
125
+ // Flow types
126
+ const FLOWS = [
127
+ { id: 'landscape', label: 'Правовий ландшафт', color: '#5b9bd5' },
128
+ { id: 'strategy', label: 'Стратегія', color: '#a855f7' },
129
+ { id: 'task', label: 'Дослідне завдання', color: '#f59e0b' },
130
+ { id: 'evidence', label: 'Докази', color: '#10b981' },
131
+ { id: 'critique', label: 'Критика', color: '#ef4444' },
132
+ { id: 'verdict', label: 'Вердикт', color: '#f97316' },
133
+ ];
134
+ const flowById = Object.fromEntries(FLOWS.map(f => [f.id, f]));
135
+
136
+ // ConsultationState box
137
+ const STATE_BOX = {
138
+ x: 148, y: 345, w: 250, h: 430,
139
+ items: [
140
+ { text: 'Запит клієнта' },
141
+ { text: 'Юрисдикція' },
142
+ { text: 'Огляд ландшафту', flow: 'landscape' },
143
+ { text: 'Стратегія дослідження', flow: 'strategy' },
144
+ { text: 'Правові позиції (гіпотези)', flow: 'evidence' },
145
+ { text: 'Зібрані докази', flow: 'evidence' },
146
+ { text: 'Відкриті питання', flow: 'task' },
147
+ { text: 'Критичні зауваження', flow: 'critique' },
148
+ { text: 'Обчислення та строки' },
149
+ { text: 'Фінальна консультація' },
150
+ ]
151
+ };
152
+
153
+ // Agent definitions
154
+ const AGENTS = [
155
+ {
156
+ id: 'question', label: 'Запит клієнта', kind: 'io',
157
+ x: 148, y: 60, w: 190, h: 55,
158
+ tip: ['Запит клієнта', 'Вхідні дані', 'Правове питання від користувача, яке запускає мульти-агентний пайплайн.']
159
+ },
160
+ {
161
+ id: 'surveyor', label: 'Surveyor', kind: 'agent',
162
+ x: 440, y: 60, w: 190, h: 55,
163
+ tip: ['Surveyor', 'Запускається один раз', 'Будує карту правового ландшафту: визначає галузі права, релев��нтне законодавство, ключові правові інститути.']
164
+ },
165
+ {
166
+ id: 'planner', label: 'Planner', kind: 'agent',
167
+ x: 740, y: 60, w: 190, h: 55,
168
+ tip: ['Planner', 'До циклу + при критиці', 'Формує стратегію дослідження: які питання досліджувати, яке законодавство аналізувати, які гіпотези перевіряти.']
169
+ },
170
+ {
171
+ id: 'orchestrator', label: 'Orchestrator', kind: 'orchestrator',
172
+ x: 740, y: 210, w: 260, h: 90,
173
+ tip: ['Orchestrator', 'Серце циклу', 'Читає ConsultationState, формулює гіпотези, диспетчеризує завдання Researcher або Analyst, вирішує коли зупинитись.']
174
+ },
175
+ {
176
+ id: 'researcher', label: 'Researcher', kind: 'agent',
177
+ x: 620, y: 345, w: 165, h: 55,
178
+ tip: ['Researcher', 'Пошук практики', 'Шукає судову практику, законодавство та доктрину через SecondLayer API (100М+ рішень ЄДРСР).']
179
+ },
180
+ {
181
+ id: 'analyst', label: 'Analyst', kind: 'agent',
182
+ x: 860, y: 345, w: 155, h: 55,
183
+ tip: ['Analyst', 'Обчислення', 'Розраховує строки, пеню, 3% річних, інфляційні втрати, процесуальні дедлайни.']
184
+ },
185
+ {
186
+ id: 'reviewer', label: 'Reviewer', kind: 'agent',
187
+ x: 740, y: 435, w: 165, h: 55,
188
+ tip: ['Reviewer', 'Автоматична верифікація', 'Перевіряє кожен доказ: чи існує рішення, чи правильно цитовано, чи відповідає позиції. Вердикт: VERIFIED / REFUTED.']
189
+ },
190
+ {
191
+ id: 'critic', label: 'Critic', kind: 'agent',
192
+ x: 440, y: 435, w: 175, h: 55,
193
+ tip: ['Critic', 'Періодичний аудит', 'Аудит стратегії та повноти аналізу. Виявляє прогалини, суперечності, пропущені аргументи.']
194
+ },
195
+ {
196
+ id: 'adjudicator', label: 'Adjudicator', kind: 'agent',
197
+ x: 620, y: 540, w: 195, h: 55,
198
+ tip: ['Adjudicator', 'Незалежний арбітр', 'Арбітрує конфлікти між агентами. Єдиний хто може скасувати встановлену правову позицію.']
199
+ },
200
+ {
201
+ id: 'formatter', label: 'Formatter', kind: 'agent',
202
+ x: 970, y: 490, w: 175, h: 55,
203
+ tip: ['Formatter', 'Фінальний етап', 'Оформлює структуровану правову консультацію з посиланнями на НПА та судову практику.']
204
+ },
205
+ {
206
+ id: 'answer', label: 'Консультація', kind: 'io',
207
+ x: 970, y: 575, w: 160, h: 45,
208
+ tip: ['Консультація', 'Результат', 'Структурована правова консультація з обґрунтуванням, посиланнями та рекомендаціями.']
209
+ }
210
+ ];
211
+ const agentById = Object.fromEntries(AGENTS.map(a => [a.id, a]));
212
+
213
+ // Color palette for agents
214
+ const PALETTE_ORDER = [
215
+ 'orchestrator','surveyor','planner','researcher',
216
+ 'analyst','reviewer','critic','adjudicator','formatter'
217
+ ];
218
+ const agentPalette = [
219
+ '#6366f1','#5b9bd5','#a855f7','#10b981',
220
+ '#14b8a6','#f59e0b','#ef4444','#f97316','#ec4899'
221
+ ];
222
+ const agentColor = {};
223
+ PALETTE_ORDER.forEach((id, i) => { agentColor[id] = agentPalette[i]; });
224
+
225
+ // Edges
226
+ const EDGES = [
227
+ { from: 'question', to: 'surveyor', flow: 'landscape', label: '' },
228
+ { from: 'surveyor', to: 'planner', flow: 'strategy', label: '' },
229
+ { from: 'planner', to: 'orchestrator', flow: 'strategy', label: '' },
230
+ { from: 'orchestrator', to: 'researcher', flow: 'task', label: '' },
231
+ { from: 'orchestrator', to: 'analyst', flow: 'task', label: '' },
232
+ { from: 'researcher', to: 'reviewer', flow: 'evidence', label: '' },
233
+ { from: 'analyst', to: 'reviewer', flow: 'evidence', label: '' },
234
+ { from: 'reviewer', to: 'orchestrator', flow: 'evidence', label: '' },
235
+ { from: 'critic', to: 'planner', flow: 'critique', label: '' },
236
+ { from: 'critic', to: 'adjudicator', flow: 'critique', label: '' },
237
+ { from: 'adjudicator', to: 'orchestrator', flow: 'verdict', label: '' },
238
+ { from: 'orchestrator', to: 'formatter', flow: 'evidence', label: '' },
239
+ { from: 'formatter', to: 'answer', flow: 'evidence', label: '' },
240
+ ];
241
+
242
+ // Build filter bar
243
+ const filterBar = document.createElement('div');
244
+ filterBar.className = 'filter-bar';
245
+ const filterLabel = document.createElement('span');
246
+ filterLabel.className = 'filter-bar__label';
247
+ filterLabel.textContent = 'Потоки:';
248
+ filterBar.appendChild(filterLabel);
249
+
250
+ const activeFlows = new Set(FLOWS.map(f => f.id));
251
+
252
+ FLOWS.forEach(f => {
253
+ const chip = document.createElement('button');
254
+ chip.className = 'filter-chip is-active';
255
+ chip.innerHTML = `<span class="chip-swatch" style="background:${f.color}"></span>${f.label}`;
256
+ chip.dataset.flow = f.id;
257
+ chip.addEventListener('click', () => {
258
+ if (activeFlows.has(f.id)) { activeFlows.delete(f.id); chip.classList.remove('is-active'); }
259
+ else { activeFlows.add(f.id); chip.classList.add('is-active'); }
260
+ updateVisibility();
261
+ });
262
+ filterBar.appendChild(chip);
263
+ });
264
+ root.appendChild(filterBar);
265
+
266
+ // Tooltip
267
+ const tooltip = document.createElement('div');
268
+ tooltip.className = 'arch-tooltip';
269
+ root.appendChild(tooltip);
270
+
271
+ // SVG
272
+ const svg = d3.select(root).append('svg')
273
+ .attr('viewBox', `0 0 ${VB_W} ${VB_H}`)
274
+ .attr('preserveAspectRatio', 'xMidYMid meet');
275
+
276
+ // Defs: arrow markers & glow
277
+ const defs = svg.append('defs');
278
+
279
+ FLOWS.forEach(f => {
280
+ defs.append('marker')
281
+ .attr('id', `arrow-${f.id}`)
282
+ .attr('viewBox', '0 0 10 10')
283
+ .attr('refX', 9).attr('refY', 5)
284
+ .attr('markerWidth', 7).attr('markerHeight', 7)
285
+ .attr('orient', 'auto-start-reverse')
286
+ .append('path')
287
+ .attr('d', 'M 0 1 L 10 5 L 0 9 Z')
288
+ .attr('fill', f.color);
289
+ });
290
+
291
+ // Glow filter
292
+ const glow = defs.append('filter').attr('id', 'glow');
293
+ glow.append('feGaussianBlur').attr('stdDeviation', '3').attr('result', 'blur');
294
+ glow.append('feMerge').selectAll('feMergeNode')
295
+ .data(['blur', 'SourceGraphic']).enter()
296
+ .append('feMergeNode').attr('in', d => d);
297
+
298
+ // Draw state box
299
+ const sb = STATE_BOX;
300
+ const stateG = svg.append('g').attr('class', 'state-group');
301
+
302
+ stateG.append('rect')
303
+ .attr('x', sb.x - sb.w/2).attr('y', sb.y - sb.h/2)
304
+ .attr('width', sb.w).attr('height', sb.h)
305
+ .attr('rx', 14).attr('ry', 14)
306
+ .attr('fill', 'rgba(255,255,255,0.03)')
307
+ .attr('stroke', 'rgba(255,255,255,0.12)')
308
+ .attr('stroke-width', 1.4)
309
+ .attr('stroke-dasharray', '6 4');
310
+
311
+ stateG.append('text')
312
+ .attr('x', sb.x).attr('y', sb.y - sb.h/2 + 24)
313
+ .attr('text-anchor', 'middle')
314
+ .attr('fill', '#999')
315
+ .attr('font-size', '12px')
316
+ .attr('font-weight', '600')
317
+ .attr('letter-spacing', '0.06em')
318
+ .text('CONSULTATION STATE');
319
+
320
+ sb.items.forEach((item, i) => {
321
+ const yPos = sb.y - sb.h/2 + 50 + i * 36;
322
+ stateG.append('rect')
323
+ .attr('x', sb.x - sb.w/2 + 14)
324
+ .attr('y', yPos - 12)
325
+ .attr('width', sb.w - 28)
326
+ .attr('height', 28)
327
+ .attr('rx', 6)
328
+ .attr('fill', item.flow ? `${flowById[item.flow].color}15` : 'rgba(255,255,255,0.04)')
329
+ .attr('stroke', item.flow ? `${flowById[item.flow].color}40` : 'rgba(255,255,255,0.06)')
330
+ .attr('stroke-width', 0.8);
331
+
332
+ stateG.append('text')
333
+ .attr('x', sb.x)
334
+ .attr('y', yPos + 4)
335
+ .attr('text-anchor', 'middle')
336
+ .attr('fill', item.flow ? flowById[item.flow].color : '#aaa')
337
+ .attr('font-size', '11px')
338
+ .text(item.text);
339
+ });
340
+
341
+ // Edge routing helper
342
+ function edgePath(from, to) {
343
+ const a = agentById[from], b = agentById[to];
344
+ const ax = a.x, ay = a.y, bx = b.x, by = b.y;
345
+ const dx = bx - ax, dy = by - ay;
346
+
347
+ // Exit/entry points on box edges
348
+ let sx, sy, ex, ey;
349
+
350
+ if (Math.abs(dx) > Math.abs(dy) * 0.6) {
351
+ // Horizontal-ish
352
+ sx = ax + (dx > 0 ? a.w/2 : -a.w/2);
353
+ sy = ay;
354
+ ex = bx + (dx > 0 ? -b.w/2 : b.w/2);
355
+ ey = by;
356
+ } else {
357
+ // Vertical-ish
358
+ sx = ax;
359
+ sy = ay + (dy > 0 ? a.h/2 : -a.h/2);
360
+ ex = bx;
361
+ ey = by + (dy > 0 ? -b.h/2 : b.h/2);
362
+ }
363
+
364
+ // Curved path
365
+ const mx = (sx + ex) / 2, my = (sy + ey) / 2;
366
+ if (Math.abs(sx - ex) < 5 || Math.abs(sy - ey) < 5) {
367
+ return `M ${sx} ${sy} L ${ex} ${ey}`;
368
+ }
369
+ return `M ${sx} ${sy} C ${sx} ${my}, ${ex} ${my}, ${ex} ${ey}`;
370
+ }
371
+
372
+ // Draw edges
373
+ const edgeGroup = svg.append('g').attr('class', 'edges');
374
+
375
+ EDGES.forEach(e => {
376
+ const flow = flowById[e.flow];
377
+ const path = edgePath(e.from, e.to);
378
+
379
+ // Glow under-path
380
+ edgeGroup.append('path')
381
+ .attr('d', path)
382
+ .attr('fill', 'none')
383
+ .attr('stroke', flow.color)
384
+ .attr('stroke-width', 4)
385
+ .attr('opacity', 0.15)
386
+ .attr('data-flow', e.flow)
387
+ .attr('class', 'edge-glow');
388
+
389
+ // Main path
390
+ edgeGroup.append('path')
391
+ .attr('d', path)
392
+ .attr('fill', 'none')
393
+ .attr('stroke', flow.color)
394
+ .attr('stroke-width', 1.8)
395
+ .attr('opacity', 0.7)
396
+ .attr('marker-end', `url(#arrow-${e.flow})`)
397
+ .attr('data-flow', e.flow)
398
+ .attr('class', 'edge-main');
399
+
400
+ // Animated dash
401
+ const totalLen = 400;
402
+ edgeGroup.append('path')
403
+ .attr('d', path)
404
+ .attr('fill', 'none')
405
+ .attr('stroke', flow.color)
406
+ .attr('stroke-width', 2.2)
407
+ .attr('opacity', 0.9)
408
+ .attr('stroke-dasharray', '8 20')
409
+ .attr('data-flow', e.flow)
410
+ .attr('class', 'edge-animated')
411
+ .style('animation', `dash-flow ${2 + Math.random()}s linear infinite`);
412
+ });
413
+
414
+ // Draw agents
415
+ const agentGroup = svg.append('g').attr('class', 'agents');
416
+
417
+ AGENTS.forEach(a => {
418
+ const g = agentGroup.append('g')
419
+ .attr('class', `agent agent--${a.kind}`)
420
+ .attr('data-id', a.id)
421
+ .style('cursor', 'pointer');
422
+
423
+ const color = a.kind === 'io' ? '#64748b' :
424
+ a.kind === 'orchestrator' ? agentColor.orchestrator :
425
+ agentColor[a.id] || '#888';
426
+
427
+ // Shadow
428
+ g.append('rect')
429
+ .attr('x', a.x - a.w/2 + 2).attr('y', a.y - a.h/2 + 2)
430
+ .attr('width', a.w).attr('height', a.h)
431
+ .attr('rx', a.kind === 'orchestrator' ? 14 : 10)
432
+ .attr('fill', 'rgba(0,0,0,0.3)');
433
+
434
+ // Box
435
+ g.append('rect')
436
+ .attr('x', a.x - a.w/2).attr('y', a.y - a.h/2)
437
+ .attr('width', a.w).attr('height', a.h)
438
+ .attr('rx', a.kind === 'orchestrator' ? 14 : 10)
439
+ .attr('fill', a.kind === 'io' ? 'rgba(100,116,139,0.15)' : `${color}18`)
440
+ .attr('stroke', a.kind === 'io' ? 'rgba(100,116,139,0.4)' : `${color}60`)
441
+ .attr('stroke-width', a.kind === 'orchestrator' ? 2 : 1.4)
442
+ .attr('class', 'agent-box');
443
+
444
+ // Label
445
+ g.append('text')
446
+ .attr('x', a.x).attr('y', a.y + 1)
447
+ .attr('text-anchor', 'middle')
448
+ .attr('dominant-baseline', 'central')
449
+ .attr('fill', a.kind === 'io' ? '#94a3b8' : '#e0e0e0')
450
+ .attr('font-size', a.kind === 'orchestrator' ? '16px' : '14px')
451
+ .attr('font-weight', a.kind === 'orchestrator' ? '700' : '600')
452
+ .text(a.label);
453
+
454
+ if (a.kind === 'orchestrator') {
455
+ g.append('text')
456
+ .attr('x', a.x).attr('y', a.y + 22)
457
+ .attr('text-anchor', 'middle')
458
+ .attr('fill', '#888')
459
+ .attr('font-size', '11px')
460
+ .text('координація та гіпотези');
461
+ }
462
+
463
+ // Hover
464
+ g.on('mouseenter', (event) => {
465
+ if (!a.tip) return;
466
+ g.select('.agent-box')
467
+ .transition().duration(150)
468
+ .attr('stroke-width', 2.5)
469
+ .attr('filter', 'url(#glow)');
470
+
471
+ tooltip.innerHTML = `
472
+ <div class="arch-tooltip__title" style="color:${color}">${a.tip[0]}</div>
473
+ <div class="arch-tooltip__sub">${a.tip[1]}</div>
474
+ <div class="arch-tooltip__body">${a.tip[2]}</div>
475
+ `;
476
+ tooltip.style.opacity = '1';
477
+
478
+ const rect = root.getBoundingClientRect();
479
+ const svgRect = root.querySelector('svg').getBoundingClientRect();
480
+ const scaleX = svgRect.width / VB_W;
481
+ const px = (a.x + a.w/2) * scaleX + svgRect.left - rect.left + 10;
482
+ const py = (a.y - a.h/2) * (svgRect.height / VB_H) + svgRect.top - rect.top;
483
+ tooltip.style.left = Math.min(px, rect.width - 300) + 'px';
484
+ tooltip.style.top = py + 'px';
485
+ })
486
+ .on('mouseleave', () => {
487
+ g.select('.agent-box')
488
+ .transition().duration(200)
489
+ .attr('stroke-width', a.kind === 'orchestrator' ? 2 : 1.4)
490
+ .attr('filter', null);
491
+ tooltip.style.opacity = '0';
492
+ });
493
+ });
494
+
495
+ // Loop indicator
496
+ const loopG = svg.append('g').attr('class', 'loop-indicator');
497
+ loopG.append('rect')
498
+ .attr('x', 370).attr('y', 165)
499
+ .attr('width', 680).attr('height', 355)
500
+ .attr('rx', 20)
501
+ .attr('fill', 'none')
502
+ .attr('stroke', 'rgba(255,255,255,0.06)')
503
+ .attr('stroke-width', 1.5)
504
+ .attr('stroke-dasharray', '8 6');
505
+
506
+ loopG.append('text')
507
+ .attr('x', 710).attr('y', 185)
508
+ .attr('text-anchor', 'middle')
509
+ .attr('fill', 'rgba(255,255,255,0.2)')
510
+ .attr('font-size', '11px')
511
+ .attr('letter-spacing', '0.1em')
512
+ .text('ДОСЛІДНИЦЬКИЙ ЦИКЛ (до 15 ітерацій)');
513
+
514
+ // Critic connection to loop (periodic)
515
+ loopG.append('text')
516
+ .attr('x', 440).attr('y', 490 + 18)
517
+ .attr('text-anchor', 'middle')
518
+ .attr('fill', 'rgba(255,255,255,0.25)')
519
+ .attr('font-size', '10px')
520
+ .text('кожні 3 ітерації');
521
+
522
+ // CSS animation
523
+ const style = document.createElement('style');
524
+ style.textContent = `
525
+ @keyframes dash-flow {
526
+ to { stroke-dashoffset: -56; }
527
+ }
528
+ `;
529
+ document.head.appendChild(style);
530
+
531
+ // Visibility toggle
532
+ function updateVisibility() {
533
+ svg.selectAll('.edge-main, .edge-glow, .edge-animated').each(function() {
534
+ const flow = this.getAttribute('data-flow');
535
+ d3.select(this).transition().duration(300)
536
+ .attr('opacity', activeFlows.has(flow) ?
537
+ (this.classList.contains('edge-glow') ? 0.15 :
538
+ this.classList.contains('edge-animated') ? 0.9 : 0.7) : 0.05);
539
+ });
540
+ }
541
+
542
+ // Entry animation: fade in agents sequentially
543
+ agentGroup.selectAll('.agent')
544
+ .style('opacity', 0)
545
+ .transition()
546
+ .delay((d, i) => i * 80)
547
+ .duration(400)
548
+ .style('opacity', 1);
549
+
550
+ edgeGroup.selectAll('path')
551
+ .style('opacity', 0)
552
+ .transition()
553
+ .delay(600)
554
+ .duration(800)
555
+ .style('opacity', function() {
556
+ if (this.classList.contains('edge-glow')) return 0.15;
557
+ if (this.classList.contains('edge-animated')) return 0.9;
558
+ return 0.7;
559
+ });
560
+ });
561
+ })();
562
+ </script>