Route workflow arrows through row gaps with semantic colors
This commit is contained in:
parent
f398616291
commit
22bad863c1
7 changed files with 116 additions and 22 deletions
|
|
@ -65,3 +65,9 @@ HubSnapshot: generation, seq, timestamp, profiles_by_provider, all_profiles, rea
|
|||
## 2026-08-31 — оркестратор сверху по запросу владельца
|
||||
|
||||
Владелец явно запросил изменить сохранённые координаты: оркестратор (430,60), первый ряд субагентов — два кодера и ревьюер на y=250; остальные 9 субагентов в трёх колонках ниже. Раскладка применена через штатную команду и сохранена `save_workflow`. После перезагрузки координаты подтверждены по DOM. Связи, модели и настройки агентов не менялись. Начальный вид клиента центрирует выбранного агента по горизонтали. Новый снимок: `docs/screenshots/a48/after/overview-orchestrator-top.png`; предыдущие сравнительные снимки показывают состояние до этого запроса. Проверка синтаксиса workflow.js прошла.
|
||||
|
||||
## 2026-08-31 — разводка и цвета стрелок
|
||||
|
||||
По запросу владельца изменено только отображение существующих связей. Нисходящая связь выходит снизу и входит сверху; переходы между соседними карточками идут напрямую, обратные связи — по отдельным нижним дорожкам, возврат к оркестратору — снаружи справа. Углы скруглены. SUCCESS/REVIEW_PASSED зелёные, REVIEW_FAILED/ERROR красные пунктирные, NEXT/ALWAYS золотые; наконечники и подписи используют соответствующий цвет. Невидимый hit-path больше не перекрашивает видимый путь. Длинные подписи переносятся в межузловом промежутке. Fit учитывает внешние линии.
|
||||
|
||||
`node tests/test_workflow_routes_a48.js`: шесть связей текущей раскладки не пересекают 13 карточек, направление портов и возвратные дорожки проверены. Оба прежних JS-набора проходят. В браузере подтверждены разные stroke/marker, прозрачность шести hit-path, отсутствие пересечений подписей с узлами и ошибок консоли. Новый снимок: `after/overview-colored-arrows.png`. Старые снимки в галерее остаются историческим сравнением. Топология и настройки workflow не менялись. Полный произвольный граф с препятствиями не проверен; маршрутизация рассчитана на текущую раскладку по рядам.
|
||||
|
|
|
|||
BIN
docs/screenshots/a48/after/overview-colored-arrows.png
Normal file
BIN
docs/screenshots/a48/after/overview-colored-arrows.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>A48 — визуальное сравнение</title>
|
||||
<style>:root{--paper:#f7f1e3;--ink:#1a2a1f;--line:#c9bea4;--accent:#866019}*{box-sizing:border-box}body{margin:24px;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui}header{max-width:1000px}h1{font-size:26px}strong{color:var(--accent)}select{padding:8px;background:var(--paper);color:var(--ink);border:1px solid var(--line)}.comparison{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}figure{margin:0;border:1px solid var(--line);padding:8px}img{width:100%;height:auto}figcaption{min-height:48px;font-size:12px}section{margin:32px 0}a{color:var(--accent)}@media(max-width:800px){.comparison{grid-template-columns:1fr}}</style>
|
||||
<header><h1>A48 · Сравнение с макетами</h1><p><strong>Клиент проверен с живыми данными; визуальное согласование остаётся за владельцем.</strong> «До»: хаб 81a58f6. «После»: клиент A48 с тем же защищённым API. 21 снимок до и 21 после. Аккаунтов 0, поэтому заполненные карточки и выбор реальной модели не проверены.</p><p>База кода: 80aab00. Контрольная ширина снимков: 1280 px. Макеты имеют разные исходные размеры; изображения показаны полностью, без подмены данных.</p><p><a href="after/overview-reference-size.png">Обзор 1536×1024</a> · <a href="after/canvas-fit-after-drag.png">Граф после перетаскивания и вписывания</a> · <a href="after/overview-760.png">Адаптивность 760 px</a></p><label>Тема снимков <select id="theme"><option value="dark">Тёмная</option><option value="medium">Средняя</option><option value="light">Светлая</option></select></label></header><main id="gallery"></main>
|
||||
<header><h1>A48 · Сравнение с макетами</h1><p><strong>Клиент проверен с живыми данными; визуальное согласование остаётся за владельцем.</strong> «До»: хаб 81a58f6. «После»: клиент A48 с тем же защищённым API. 21 снимок до и 21 после. Аккаунтов 0, поэтому заполненные карточки и выбор реальной модели не проверены.</p><p>База кода: 80aab00. Контрольная ширина снимков: 1280 px. Макеты имеют разные исходные размеры; изображения показаны полностью, без подмены данных.</p><p><a href="after/overview-reference-size.png">Обзор 1536×1024</a> · <a href="after/canvas-fit-after-drag.png">Граф после перетаскивания и вписывания</a> · <a href="after/overview-760.png">Адаптивность 760 px</a></p><p><a href="after/overview-colored-arrows.png">Последняя доработка: оркестратор сверху и цветные стрелки</a></p><label>Тема снимков <select id="theme"><option value="dark">Тёмная</option><option value="medium">Средняя</option><option value="light">Светлая</option></select></label></header><main id="gallery"></main>
|
||||
<script>const views=[['overview','Обзор'],['accounts','Аккаунты'],['routing','Маршрутизация'],['analytics','Аналитика'],['health','Состояние системы'],['logs','Журнал событий'],['settings','Настройки']];function render(){const theme=document.getElementById('theme').value;document.getElementById('gallery').innerHTML=views.map(([view,label],i)=>`<section><h2>${i+1}. ${label}</h2><div class="comparison">${[[`reference/${i+1}.1.png`,`Макет ${i+1}.1 — пример данных`],[`before/${view}-${theme}.png`,'ДО · живой API'],[`after/${view}-${theme}.png`,'ПОСЛЕ · живой API']].map(([src,title])=>`<figure><figcaption>${title}</figcaption><a href="${src}"><img src="${src}" alt="${label} · ${title}" loading="lazy"></a></figure>`).join('')}</div></section>`).join('')}document.getElementById('theme').onchange=render;render();</script></html>
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@
|
|||
</header>
|
||||
<div class="workflow-canvas" id="workflow-canvas" tabindex="0" aria-label="Редактор графа workflow">
|
||||
<div class="canvas-tools" role="toolbar" aria-label="Инструменты холста"><button id="canvas-select" title="Выбор агента" aria-label="Выбор агента">↖</button><button id="canvas-add" title="Добавить агента" aria-label="Добавить агента на холст">+</button><button id="canvas-connect" title="Соединить агентов в EDIT" aria-label="Создать связь агентов">⇄</button><button id="canvas-fit" title="Вписать граф" aria-label="Вписать весь граф">⛶</button><button id="canvas-arrange" title="Разнести узлы (черновик)" aria-label="Разнести узлы">▦</button></div><div id="workflow-viewport" class="workflow-viewport">
|
||||
<svg id="workflow-edges" class="workflow-edges" aria-hidden="true"><defs><marker id="wf-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"></path></marker></defs><g id="workflow-edge-layer"></g></svg>
|
||||
<svg id="workflow-edges" class="workflow-edges" aria-hidden="true"><defs><marker id="wf-arrow-success" class="edge-success" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M1,1 L8,4.5 L1,8 L3,4.5 Z"/></marker><marker id="wf-arrow-return" class="edge-return" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M1,1 L8,4.5 L1,8 L3,4.5 Z"/></marker><marker id="wf-arrow-next" class="edge-next" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M1,1 L8,4.5 L1,8 L3,4.5 Z"/></marker></defs><g id="workflow-edge-layer"></g></svg>
|
||||
<div id="workflow-node-layer" class="workflow-node-layer"></div><svg class="workflow-labels" aria-hidden="true"><g id="workflow-label-layer"></g></svg>
|
||||
</div>
|
||||
<div id="workflow-empty" class="workflow-empty hidden"><strong>В workflow пока нет агентов</strong><span>Добавьте агента, затем переключитесь в EDIT и соедините узлы.</span></div>
|
||||
|
|
@ -136,7 +136,7 @@
|
|||
</div>
|
||||
<footer class="workflow-legend">
|
||||
<span><i class="state-waiting"></i>Ожидает</span><span><i class="state-working"></i>Работает</span><span><i class="state-reviewing"></i>Проверяет</span><span><i class="state-error"></i>Ошибка</span><span><i class="state-completed"></i>Завершено</span>
|
||||
<span class="edge-legend success">→ Успех</span><span class="edge-legend return">⇢ Возврат</span>
|
||||
<span class="edge-legend success">→ Успех</span><span class="edge-legend return">⇢ Возврат / ошибка</span><span class="edge-legend next">→ Следующий шаг</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -188,3 +188,16 @@ body[data-workflow-mode="edit"] .workflow-toolbar { display:flex; justify-conten
|
|||
.workflow-inspector .inspector-section { padding:10px; margin:8px 0; }
|
||||
.workflow-inspector .inspector-section p { line-height:1.4; margin:6px 0; }
|
||||
.workflow-inspector dl { gap:6px 10px; margin:8px 0; }
|
||||
/* Keep the interaction hit area invisible: it must not paint over colored links. */
|
||||
.workflow-edges .workflow-edge-hit { stroke:transparent; stroke-width:16; fill:none; marker-end:none; }
|
||||
.edge-success { --edge-color:var(--status-healthy); }
|
||||
.edge-return { --edge-color:var(--status-error); }
|
||||
.edge-next { --edge-color:var(--accent); }
|
||||
.workflow-edges .workflow-link { stroke:var(--edge-color); stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; marker-end:revert-layer; }
|
||||
.workflow-edges .workflow-link.edge-success { marker-end:url(#wf-arrow-success); }
|
||||
.workflow-edges .workflow-link.edge-return { marker-end:url(#wf-arrow-return); stroke-dasharray:5 5; }
|
||||
.workflow-edges .workflow-link.edge-next { marker-end:url(#wf-arrow-next); }
|
||||
.workflow-edges marker path { fill:var(--edge-color); stroke:none; marker-end:none; }
|
||||
.edge-label-group .workflow-edge-label { fill:var(--edge-color); font-size:10px; }
|
||||
.edge-label-group .workflow-label-bg { fill:var(--canvas-bg); stroke:none; }
|
||||
.edge-legend.next { color:var(--accent); }
|
||||
|
|
|
|||
|
|
@ -326,33 +326,76 @@ function finishConnection(event) {
|
|||
openEdgeDialog({ id: `edge-${Date.now()}`, source, target, condition: 'SUCCESS', label: '' }, true);
|
||||
}
|
||||
|
||||
function roundedWorkflowPath(points, radius = 18) {
|
||||
let path = `M ${points[0].x} ${points[0].y}`;
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const prev = points[i-1], corner = points[i], next = points[i+1];
|
||||
const before = Math.hypot(corner.x-prev.x,corner.y-prev.y);
|
||||
const after = Math.hypot(next.x-corner.x,next.y-corner.y);
|
||||
const r = Math.min(radius,before/2,after/2);
|
||||
if (!r) continue;
|
||||
const x = corner.x+(prev.x-corner.x)*r/before, y = corner.y+(prev.y-corner.y)*r/before;
|
||||
const nx = corner.x+(next.x-corner.x)*r/after, ny = corner.y+(next.y-corner.y)*r/after;
|
||||
path += ` L ${x} ${y} Q ${corner.x} ${corner.y} ${nx} ${ny}`;
|
||||
}
|
||||
const end = points.at(-1);
|
||||
return path + ` L ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
function workflowEdgeRoute(source, target, lane = 0) {
|
||||
const sx = source.x+source.width/2, tx = target.x+target.width/2;
|
||||
const sy = source.y+source.height/2, ty = target.y+target.height/2;
|
||||
// Downward branches leave the bottom and enter the top of the next row.
|
||||
if (target.y >= source.y+source.height+32) {
|
||||
const y = (source.y+source.height+target.y)/2;
|
||||
return {points:[{x:sx,y:source.y+source.height+5},{x:sx,y},{x:tx,y},{x:tx,y:target.y-7}],x:(sx+tx)/2,y:y-10,width:Math.max(100,Math.abs(tx-sx)-24)};
|
||||
}
|
||||
// Forward transitions between peers use the open horizontal corridor.
|
||||
if (Math.abs(sy-ty)<40 && target.x >= source.x+source.width+24) {
|
||||
const x1=source.x+source.width+5, x2=target.x-7;
|
||||
return {points:[{x:x1,y:sy},{x:x2,y:ty}],x:(x1+x2)/2,y:Math.min(sy,ty)-16,width:Math.max(50,x2-x1-10)};
|
||||
}
|
||||
// Feedback between peers runs below their row, separate from forward arrows.
|
||||
if (Math.abs(sy-ty)<40) {
|
||||
const y=Math.max(source.y+source.height,target.y+target.height)+38+lane*28;
|
||||
return {points:[{x:sx,y:source.y+source.height+5},{x:sx,y},{x:tx,y},{x:tx,y:target.y+target.height+7}],x:(sx+tx)/2,y:y+14,width:Math.max(90,Math.abs(tx-sx)-30)};
|
||||
}
|
||||
// Upward return travels outside both nodes and enters from the right.
|
||||
const x=Math.max(source.x+source.width,target.x+target.width)+48+lane*24;
|
||||
return {points:[{x:source.x+source.width+5,y:sy},{x,y:sy},{x,y:ty},{x:target.x+target.width+7,y:ty}],x:(x+target.x+target.width)/2,y:ty-14,width:Math.max(80,x-target.x-target.width-24)};
|
||||
}
|
||||
|
||||
function workflowLabelLines(text, width) {
|
||||
const limit=Math.max(8,Math.floor(width/5.7));
|
||||
const words=String(text).split(/\s+/), lines=[];
|
||||
let line='';
|
||||
for (const word of words) {
|
||||
if (line && (line+' '+word).length>limit) { lines.push(line); line=''; }
|
||||
line+=(line?' ':'')+word;
|
||||
}
|
||||
if(line) lines.push(line);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function drawWorkflowEdges() {
|
||||
const svg = document.getElementById('workflow-edges');
|
||||
const layer = document.getElementById('workflow-edge-layer');
|
||||
if (!svg || !layer) return;
|
||||
const parts = [];
|
||||
const labels = [];
|
||||
const occupiedLabels = [];
|
||||
const parts = [], labels = [];
|
||||
let feedbackLane=0;
|
||||
workflowUi.draftEdges.forEach((edge) => {
|
||||
const source = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.source)}"]`);
|
||||
const target = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.target)}"]`);
|
||||
if (!source || !target) return;
|
||||
const x1 = source.offsetLeft + source.offsetWidth;
|
||||
const y1 = source.offsetTop + source.offsetHeight / 2;
|
||||
const x2 = target.offsetLeft;
|
||||
const y2 = target.offsetTop + target.offsetHeight / 2;
|
||||
const klass = String(edge.condition || '').toLowerCase();
|
||||
const label = edge.label || edge.condition;
|
||||
const labelWidth = Math.max(70, String(label).length * 6 + 16);
|
||||
const labelX = (x1 + x2) / 2;
|
||||
let labelY = Math.min(source.offsetTop, target.offsetTop) - 30;
|
||||
const boxes = [...document.querySelectorAll('.workflow-node')].map(node => ({left:node.offsetLeft,top:node.offsetTop,width:node.offsetWidth,height:node.offsetHeight}));
|
||||
const overlaps = box => labelX + labelWidth/2 > box.left - 6 && labelX - labelWidth/2 < box.left + box.width + 6 && labelY + 12 > box.top - 6 && labelY - 12 < box.top + box.height + 6;
|
||||
while ([...boxes,...occupiedLabels].some(overlaps)) labelY -= 30;
|
||||
occupiedLabels.push({left:labelX-labelWidth/2,top:labelY-12,width:labelWidth,height:24});
|
||||
const path = `M ${x1} ${y1} C ${x1+28} ${y1}, ${x1+28} ${labelY}, ${x1} ${labelY} L ${x2} ${labelY} C ${x2-28} ${labelY}, ${x2-28} ${y2}, ${x2} ${y2}`;
|
||||
labels.push(`<g><rect class="workflow-label-bg" x="${labelX-labelWidth/2}" y="${labelY-12}" width="${labelWidth}" height="24" rx="4"></rect><text class="workflow-edge-label" x="${labelX}" y="${labelY+3}">${wfEscape(label)}</text></g>`);
|
||||
parts.push(`<path class="${wfEscape(klass)}" d="${path}"></path><path class="workflow-edge-hit" data-edge-id="${wfEscape(edge.id)}" d="${path}"></path>`);
|
||||
const rect=node=>({x:node.offsetLeft,y:node.offsetTop,width:node.offsetWidth,height:node.offsetHeight});
|
||||
const condition=String(edge.condition || '').toUpperCase();
|
||||
const tone=['REVIEW_FAILED','ERROR'].includes(condition)?'return':['SUCCESS','REVIEW_PASSED'].includes(condition)?'success':'next';
|
||||
const route=workflowEdgeRoute(rect(source),rect(target),tone==='return'?feedbackLane++:0);
|
||||
const path=roundedWorkflowPath(route.points);
|
||||
const lines=workflowLabelLines(edge.label || condition,Math.min(220,route.width));
|
||||
const width=Math.max(...lines.map(line=>line.length))*5.7+12, height=lines.length*13+6;
|
||||
labels.push(`<g class="edge-label-group edge-${tone}"><title>${wfEscape(edge.label || condition)} · ${wfEscape(condition)}</title><rect class="workflow-label-bg" x="${route.x-width/2}" y="${route.y-height/2}" width="${width}" height="${height}" rx="4"/><text class="workflow-edge-label" x="${route.x}" y="${route.y-(lines.length-1)*6.5+3}">${lines.map((line,i)=>`<tspan x="${route.x}" dy="${i?13:0}">${wfEscape(line)}</tspan>`).join('')}</text></g>`);
|
||||
parts.push(`<path class="workflow-link edge-${tone}" d="${path}" marker-end="url(#wf-arrow-${tone})"></path><path class="workflow-edge-hit" data-edge-id="${wfEscape(edge.id)}" d="${path}"></path>`);
|
||||
});
|
||||
layer.innerHTML = parts.join('');
|
||||
document.getElementById('workflow-label-layer').innerHTML = labels.join('');
|
||||
|
|
@ -424,6 +467,11 @@ function fitWorkflowGraph() {
|
|||
maxX = Math.max(maxX, x + Number(label.getAttribute('width')));
|
||||
maxY = Math.max(maxY, y + Number(label.getAttribute('height')));
|
||||
});
|
||||
document.querySelectorAll('.workflow-link').forEach(path => {
|
||||
const box = path.getBBox();
|
||||
minX = Math.min(minX, box.x); minY = Math.min(minY, box.y);
|
||||
maxX = Math.max(maxX, box.x+box.width); maxY = Math.max(maxY, box.y+box.height);
|
||||
});
|
||||
const width = maxX - minX || 200;
|
||||
const height = maxY - minY || 120;
|
||||
const pad = 60;
|
||||
|
|
|
|||
27
tests/test_workflow_routes_a48.js
Normal file
27
tests/test_workflow_routes_a48.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const context = vm.createContext({document:{addEventListener(){}},console});
|
||||
vm.runInContext(fs.readFileSync('src/antigravity_provider/router/web/static/workflow.js','utf8'),context);
|
||||
const nodes=[{x:430,y:60,width:250,height:116},...Array.from({length:12},(_,i)=>({x:70+i%3*360,y:250+Math.floor(i/3)*240,width:250,height:116}))];
|
||||
const edges=[[0,1,0],[1,2,0],[2,1,0],[2,3,0],[3,2,1],[3,0,0]];
|
||||
for(const [source,target,lane] of edges){
|
||||
const route=context.workflowEdgeRoute(nodes[source],nodes[target],lane);
|
||||
for(let i=1;i<route.points.length;i++){
|
||||
const a=route.points[i-1],b=route.points[i];
|
||||
assert(a.x===b.x||a.y===b.y,'Routes use orthogonal segments');
|
||||
for(const n of nodes){
|
||||
const crosses=a.x===b.x
|
||||
? a.x>n.x&&a.x<n.x+n.width&&Math.max(a.y,b.y)>n.y&&Math.min(a.y,b.y)<n.y+n.height
|
||||
: a.y>n.y&&a.y<n.y+n.height&&Math.max(a.x,b.x)>n.x&&Math.min(a.x,b.x)<n.x+n.width;
|
||||
assert(!crosses,`Route ${source}->${target} crosses a card`);
|
||||
}
|
||||
}
|
||||
assert(!context.roundedWorkflowPath(route.points).includes('NaN'));
|
||||
}
|
||||
const down=context.workflowEdgeRoute(nodes[0],nodes[1]);
|
||||
assert.equal(down.points[0].x,nodes[0].x+nodes[0].width/2);
|
||||
assert.equal(down.points.at(-1).y,nodes[1].y-7);
|
||||
const feedback=context.workflowEdgeRoute(nodes[2],nodes[1]);
|
||||
assert(feedback.points[1].y>nodes[2].y+nodes[2].height);
|
||||
console.log('A48 routes: downward ports, separate feedback lanes, six routes avoid all 13 cards');
|
||||
Loading…
Reference in a new issue