From 327c524ececfcd7c333e83429600a4e70e779ac7 Mon Sep 17 00:00:00 2001 From: Eliot Hedeman Date: Mon, 20 Apr 2026 13:09:14 -0400 Subject: [PATCH] feat(toolpath-desktop): html-card graph viz with expandable dead branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dagre-d3-es SVG renderer in the Preview with HTML cards laid out by @dagrejs/dagre + an SVG edge overlay. The default view shows only the HEAD-path; each HEAD card that has dead children renders with a stacked-shadow peek and a small "expand" chip — clicking the chip flips a key in preview.expandedBranches and the graph re-layouts to include the dead subtree. Why: the SVG-node viz didn't scale readably to longer Claude sessions, and dead-end branches dominated the canvas for no user benefit. Cards as HTML let us reuse the desktop palette (cream/rust) and show richer per-step metadata (id, intent, actor, optional timestamps + files) in the graph itself while keeping the existing detail panel for full diffs. - Add @dagrejs/dagre; drop d3/@types/d3/dagre-d3-es (now unused). - Model: replace PreviewSlice.showDead with expandedBranches: Record; add PreviewToggleBranch { nodeId } msg + handler; bump vizEpoch on toggle. - Rewrite lib/viz.ts as renderCard + dagre-layout + SVG edge paths. Dead edges render red + dashed; arrowheads via SVG marker defs. - Preview.svelte: swap for a
container; drop the Dead Ends checkbox (superseded by per-branch expand); pass new opts. - styles.css: add .path-graph* + .pg-card* rules (warm palette, actor- typed border-left, stacked-shadow peek for .pg-card--has-hidden). - app.svelte + .screen--wide: preview route breaks out of the 560px screen cap so the graph has room to breathe. Also includes index.html at the repo root — the self-contained design prototype the UX was iterated against. Easy to delete if not wanted. --- crates/toolpath-desktop/frontend/bun.lock | 152 +-- crates/toolpath-desktop/frontend/package.json | 4 +- .../toolpath-desktop/frontend/src/app.svelte | 2 +- .../frontend/src/lib/types.ts | 6 +- .../frontend/src/lib/update.ts | 16 +- .../toolpath-desktop/frontend/src/lib/viz.ts | 589 ++++++----- .../frontend/src/routes/Preview.svelte | 31 +- .../toolpath-desktop/frontend/src/styles.css | 182 +++- index.html | 953 ++++++++++++++++++ 9 files changed, 1512 insertions(+), 423 deletions(-) create mode 100644 index.html diff --git a/crates/toolpath-desktop/frontend/bun.lock b/crates/toolpath-desktop/frontend/bun.lock index beeb9fa..6aa33f3 100644 --- a/crates/toolpath-desktop/frontend/bun.lock +++ b/crates/toolpath-desktop/frontend/bun.lock @@ -10,11 +10,9 @@ "@tauri-apps/plugin-opener": "^2.5.3", }, "devDependencies": { + "@dagrejs/dagre": "^1.1.4", "@sveltejs/vite-plugin-svelte": "^5", "@tsconfig/svelte": "^5", - "@types/d3": "^7", - "d3": "^7", - "dagre-d3-es": "^7", "svelte": "^5", "svelte-check": "^4", "typescript": "^5", @@ -23,6 +21,10 @@ }, }, "packages": { + "@dagrejs/dagre": ["@dagrejs/dagre@1.1.8", "", { "dependencies": { "@dagrejs/graphlib": "2.2.4" } }, "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@2.2.4", "", {}, "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -149,72 +151,8 @@ "@tsconfig/svelte": ["@tsconfig/svelte@5.0.8", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="], - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], - - "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], - - "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], - - "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], - - "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], - - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - - "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], - - "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], - - "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], - - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], - - "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], - - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - - "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], - - "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], - - "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], - - "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], - - "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - - "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - - "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], - - "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], - - "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], - - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], - - "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], - - "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], - - "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], - - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], - - "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], - - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], - - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -227,78 +165,10 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - - "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], - - "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - - "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], - - "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], - - "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], - - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], - - "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], - - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], - - "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], - - "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], - - "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], - - "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - - "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], - - "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], - - "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], - - "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - - "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - - "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], - - "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - - "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], - "devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -311,18 +181,12 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], - "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -339,16 +203,10 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], - "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], - "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="], diff --git a/crates/toolpath-desktop/frontend/package.json b/crates/toolpath-desktop/frontend/package.json index f53807b..7f8aa4f 100644 --- a/crates/toolpath-desktop/frontend/package.json +++ b/crates/toolpath-desktop/frontend/package.json @@ -14,11 +14,9 @@ "@tauri-apps/plugin-opener": "^2.5.3" }, "devDependencies": { + "@dagrejs/dagre": "^1.1.4", "@sveltejs/vite-plugin-svelte": "^5", "@tsconfig/svelte": "^5", - "@types/d3": "^7", - "d3": "^7", - "dagre-d3-es": "^7", "svelte": "^5", "svelte-check": "^4", "typescript": "^5", diff --git a/crates/toolpath-desktop/frontend/src/app.svelte b/crates/toolpath-desktop/frontend/src/app.svelte index 6bf2268..8a5461a 100644 --- a/crates/toolpath-desktop/frontend/src/app.svelte +++ b/crates/toolpath-desktop/frontend/src/app.svelte @@ -40,7 +40,7 @@ -
+
{#if store.m.error}
{store.m.error} diff --git a/crates/toolpath-desktop/frontend/src/lib/types.ts b/crates/toolpath-desktop/frontend/src/lib/types.ts index cd01798..c150c40 100644 --- a/crates/toolpath-desktop/frontend/src/lib/types.ts +++ b/crates/toolpath-desktop/frontend/src/lib/types.ts @@ -161,7 +161,8 @@ export interface PreviewSlice { filename: string; selectedStep: StepRef | null; selectedActors: Record | null; - showDead: boolean; + /** HEAD-ancestor node ids whose dead subtrees are expanded. */ + expandedBranches: Record; showTs: boolean; showFiles: boolean; vizEpoch: number; @@ -232,7 +233,8 @@ export type Msg = | { t: "DeriveFailed"; error: unknown } // Preview - | { t: "PreviewToggle"; key: "showDead" | "showTs" | "showFiles" } + | { t: "PreviewToggle"; key: "showTs" | "showFiles" } + | { t: "PreviewToggleBranch"; nodeId: string } | { t: "PreviewSelectStep"; step: StepRef; actors: Record | null } | { t: "PreviewExport" } | { t: "PreviewExportDone" } diff --git a/crates/toolpath-desktop/frontend/src/lib/update.ts b/crates/toolpath-desktop/frontend/src/lib/update.ts index d3c2d01..12ea9c9 100644 --- a/crates/toolpath-desktop/frontend/src/lib/update.ts +++ b/crates/toolpath-desktop/frontend/src/lib/update.ts @@ -440,7 +440,7 @@ export function update(msg: Msg, m: Model): [Model, Cmd | null] { filename: msg.filename, selectedStep: null, selectedActors: null, - showDead: true, + expandedBranches: {}, showTs: false, showFiles: false, vizEpoch: 0, @@ -474,6 +474,20 @@ export function update(msg: Msg, m: Model): [Model, Cmd | null] { null, ]; } + case "PreviewToggleBranch": { + if (!m.preview) return [m, null]; + const cur = m.preview.expandedBranches; + const next = { ...cur }; + if (next[msg.nodeId]) delete next[msg.nodeId]; + else next[msg.nodeId] = true; + return [ + { + ...m, + preview: { ...m.preview, expandedBranches: next, vizEpoch: m.preview.vizEpoch + 1 }, + }, + null, + ]; + } case "PreviewSelectStep": if (!m.preview) return [m, null]; return [ diff --git a/crates/toolpath-desktop/frontend/src/lib/viz.ts b/crates/toolpath-desktop/frontend/src/lib/viz.ts index 5ceb2e2..137eebb 100644 --- a/crates/toolpath-desktop/frontend/src/lib/viz.ts +++ b/crates/toolpath-desktop/frontend/src/lib/viz.ts @@ -1,284 +1,373 @@ -// DAG renderer adapted from site/js/visualizer.js. -// Takes a parsed Toolpath Document + a target SVG element. - -import * as d3 from "d3"; -// dagre-d3-es is a module-ESM fork of dagre-d3; API identical but split into -// named exports. Types are incomplete, so the render factory is cast through -// `unknown` to a callable signature. -import { graphlib, render as dagreRenderRaw } from "dagre-d3-es"; -const dagreRender = dagreRenderRaw as unknown as () => ( - group: unknown, - graph: unknown, -) => void; +// Path graph renderer. +// +// Renders a Toolpath Document as a dagre-laid-out DAG of HTML "cards" +// connected by SVG edges. Dead-end subtrees are hidden behind their +// HEAD-path sibling until the user clicks the card's "expand" chip — +// clicking the chip fires `onToggleBranch(nodeId)`, which flips a key in +// `expandedBranches` and causes the graph to re-layout including those +// nodes. +// +// Usage: `render(doc, containerEl, opts)`. Call again on any state change +// (selected / expanded / toggles) — the function rebuilds the DOM under +// `containerEl` from scratch. +import * as dagre from "@dagrejs/dagre"; import type { ActorDef, Document, StepRef } from "./types"; -const COLORS = { - human: { fill: "#b5652b18", stroke: "#b5652b" }, - agent: { fill: "#b5652b30", stroke: "#b5652b" }, - tool: { fill: "#8a807815", stroke: "#8a8078" }, - ci: { fill: "#8a807815", stroke: "#8a8078" }, - dead: { fill: "#c4403018", stroke: "#c44030" }, - base: { fill: "#ece5db", stroke: "#8a8078" }, -}; -const EDGE_ACTIVE = { stroke: "#2d2a26", width: 2 }; -const EDGE_INACTIVE = { stroke: "#8a8078", width: 1 }; -const EDGE_BASE = { stroke: "#b5652b", width: 1.5 }; - -function actorType(a: string): keyof typeof COLORS | "tool" { +export interface RenderOpts { + selectedStepId: string | null; + expandedBranches: Record; + showTs: boolean; + showFiles: boolean; + onSelectStep: (step: StepRef, actors: Record | null) => void; + onToggleBranch: (stepId: string) => void; +} + +// ─── Document normalization ────────────────────────────────────────────── + +interface Normalized { + steps: StepRef[]; + head: string | null; + actors: Record | null; + stepMap: Map; + childrenMap: Map; + headSet: Set; +} + +function normalize(doc: Document): Normalized { + let steps: StepRef[] = []; + let head: string | null = null; + let actors: Record | null = null; + + if ("Step" in doc) { + steps = [doc.Step]; + actors = doc.Step.meta?.actors ?? null; + } else if ("Path" in doc) { + steps = doc.Path.steps; + head = doc.Path.path.head; + actors = doc.Path.meta?.actors ?? null; + } else if ("Graph" in doc) { + actors = doc.Graph.meta?.actors ?? null; + for (const p of doc.Graph.paths) { + if ("$ref" in p) continue; + if (head == null) head = p.path.head; + for (const s of p.steps) steps.push(s); + } + } + + const stepMap = new Map(steps.map((s) => [s.step.id, s])); + const childrenMap = new Map(); + for (const s of steps) { + for (const pid of s.step.parents || []) { + const list = childrenMap.get(pid); + if (list) list.push(s.step.id); + else childrenMap.set(pid, [s.step.id]); + } + } + + const headSet = new Set(); + if (head && stepMap.has(head)) { + const stack: string[] = [head]; + while (stack.length) { + const id = stack.pop()!; + if (headSet.has(id)) continue; + headSet.add(id); + const s = stepMap.get(id); + if (s?.step.parents) for (const p of s.step.parents) stack.push(p); + } + } + return { steps, head, actors, stepMap, childrenMap, headSet }; +} + +// ─── Visibility + helpers ──────────────────────────────────────────────── + +function actorType(a: string): string { const i = a.indexOf(":"); - const t = i < 0 ? a : a.substring(0, i); - return (t in COLORS ? (t as keyof typeof COLORS) : "tool"); + return i < 0 ? a : a.slice(0, i); } -function actorName(a: string): string { +function actorName( + a: string, + actors: Record | null, +): string { + const def = actors?.[a]; + if (def?.name) return def.name; const i = a.indexOf(":"); - return i < 0 ? a : a.substring(i + 1); + return i < 0 ? a : a.slice(i + 1); } -function actorDisplay(a: string, defs: Record | null): string { - const def = defs?.[a]; - return def?.name ?? actorName(a); +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); } -function truncate(s: string | undefined, n: number): string { - return s && s.length > n ? `${s.substring(0, n)}…` : s ?? ""; +function cssSafeId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, "_"); } -function ancestors(steps: StepRef[], headId: string): Record { - const map: Record = {}; - for (const s of steps) map[s.step.id] = s; - const out: Record = {}; - const stack = [headId]; - while (stack.length) { - const id = stack.pop()!; - if (out[id]) continue; - out[id] = true; - const s = map[id]; - if (s?.step.parents) for (const p of s.step.parents) stack.push(p); +/** Walk dead-node → parent chain until hitting a HEAD-path ancestor. */ +function findHeadAncestor( + id: string, + stepMap: Map, + headSet: Set, +): string | null { + if (headSet.has(id)) return null; + let current: string | undefined = id; + while (current) { + const s = stepMap.get(current); + const parents = s?.step.parents; + if (!parents?.length) return null; + const p: string = parents[0]; + if (headSet.has(p)) return p; + current = p; } - return out; + return null; } -interface Cluster { - pathInfo: { id: string; head?: string } | null; - steps: StepRef[]; - headId: string | null; - base: { uri: string; ref?: string } | null; - actors: Record | null; - isRef?: boolean; +function deadChildrenCount( + id: string, + headSet: Set, + childrenMap: Map, +): number { + if (!headSet.has(id)) return 0; + const kids = childrenMap.get(id) ?? []; + let n = 0; + for (const k of kids) if (!headSet.has(k)) n++; + return n; } -function normalizeClusters(doc: Document): Cluster[] { - if ("Step" in doc) { - return [ - { - pathInfo: null, - steps: [doc.Step], - headId: null, - base: null, - actors: doc.Step.meta?.actors ?? null, - }, - ]; - } - if ("Path" in doc) { - const p = doc.Path; - return [ - { - pathInfo: p.path, - steps: p.steps, - headId: p.path.head, - base: p.path.base ?? null, - actors: p.meta?.actors ?? null, - }, - ]; - } - if ("Graph" in doc) { - const g = doc.Graph; - const gActors = g.meta?.actors ?? null; - return g.paths.map((e) => { - if ("$ref" in e) { - return { - pathInfo: { id: e.$ref }, - steps: [], - headId: null, - base: null, - isRef: true, - actors: gActors, - }; - } - return { - pathInfo: e.path, - steps: e.steps, - headId: e.path.head, - base: e.path.base ?? null, - actors: (e as { meta?: { actors?: Record } }).meta?.actors ?? gActors, - }; - }); - } - return []; +function isStepVisible( + id: string, + headSet: Set, + stepMap: Map, + expandedBranches: Record, +): boolean { + if (headSet.has(id)) return true; + const anc = findHeadAncestor(id, stepMap, headSet); + return !!(anc && expandedBranches[anc]); } -export interface RenderOpts { - showDead: boolean; - showTs: boolean; - showFiles: boolean; - onStepClick?: (step: StepRef, actors: Record | null) => void; +// ─── Rendering ──────────────────────────────────────────────────────────── + +function renderCard( + s: StepRef, + flags: { + isHead: boolean; + isDead: boolean; + isFocused: boolean; + deadKids: number; + isExpanded: boolean; + actors: Record | null; + showTs: boolean; + showFiles: boolean; + }, +): string { + const id = s.step.id; + const atype = actorType(s.step.actor); + const changeKeys = s.change ? Object.keys(s.change) : []; + const hasHidden = flags.deadKids > 0 && !flags.isExpanded; + + const classes = [ + "pg-card", + `pg-card--role-${atype}`, + flags.isHead ? "pg-card--head" : "", + flags.isDead ? "pg-card--dead" : "", + flags.isFocused ? "pg-card--focused" : "", + hasHidden ? "pg-card--has-hidden" : "", + ] + .filter(Boolean) + .join(" "); + + const chips: string[] = []; + if (flags.isHead) + chips.push(`HEAD`); + if (flags.isDead) + chips.push(`dead`); + + const toggle = + flags.deadKids > 0 + ? `` + : ""; + + const intent = s.meta?.intent ? esc(s.meta.intent) : ""; + const ts = + flags.showTs && s.step.timestamp + ? `
${esc(s.step.timestamp.replace("T", " ").replace("Z", " UTC"))}
` + : ""; + const files = + flags.showFiles && changeKeys.length + ? `
${changeKeys + .map((k) => `${esc(k)}`) + .join(" · ")}
` + : ""; + + return ` +
+
+ ${esc(id)} +
${chips.join("")}
+
+ ${intent ? `
${intent}
` : ""} +
${esc(actorName(s.step.actor, flags.actors))}
+ ${ts} + ${files} + ${toggle ? `` : ""} +
`; +} + +function pointsToPath(pts: { x: number; y: number }[]): string { + if (pts.length === 0) return ""; + if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`; + let d = `M ${pts[0].x} ${pts[0].y}`; + for (let i = 1; i < pts.length; i++) d += ` L ${pts[i].x} ${pts[i].y}`; + return d; } +const NS = "http://www.w3.org/2000/svg"; + export function render( doc: Document, - svgEl: SVGSVGElement, + container: HTMLElement, opts: RenderOpts, -): { fit: () => void } | null { - const clusters = normalizeClusters(doc); - if (!clusters.length) return null; - - const graph = new graphlib.Graph({ compound: true, multigraph: false }) - .setGraph({ rankdir: "TB", nodesep: 60, ranksep: 50, marginx: 30, marginy: 30 }) - .setDefaultEdgeLabel(() => ({})); - - clusters.forEach((cluster, ci) => { - const prefix = clusters.length > 1 ? `c${ci}/` : ""; - const anc = cluster.headId ? ancestors(cluster.steps, cluster.headId) : null; - - if (clusters.length > 1) { - graph.setNode(`cluster_${ci}`, { - label: cluster.pathInfo?.id ?? `cluster-${ci}`, - clusterLabelPos: "top", - style: "fill: transparent; stroke: #b5652b26; stroke-dasharray: 4,3;", - }); - } +): void { + const { steps, head, actors, stepMap, childrenMap, headSet } = normalize(doc); - if (cluster.base) { - const baseId = `${prefix}__BASE__`; - graph.setNode(baseId, { - label: "BASE", - shape: "ellipse", - style: `fill: ${COLORS.base.fill}; stroke: ${COLORS.base.stroke}; stroke-width: 2px;`, - labelStyle: "font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 600;", - }); - if (clusters.length > 1) graph.setParent(baseId, `cluster_${ci}`); - } + // Build container DOM + container.innerHTML = ""; + const graphEl = document.createElement("div"); + graphEl.className = "path-graph"; + const svgEl = document.createElementNS(NS, "svg"); + svgEl.setAttribute("class", "path-graph__edges"); + // Arrow markers live in + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = ` + + + + + + `; + svgEl.appendChild(defs); + const nodesEl = document.createElement("div"); + nodesEl.className = "path-graph__nodes"; + graphEl.appendChild(svgEl); + graphEl.appendChild(nodesEl); + container.appendChild(graphEl); - if (cluster.isRef) { - const refId = `${prefix}${cluster.pathInfo!.id}`; - graph.setNode(refId, { - label: `$ref: ${cluster.pathInfo!.id}`, - shape: "rect", - style: "fill: #8a807815; stroke: #8a8078; stroke-dasharray: 4,3; stroke-width: 1px;", - labelStyle: "font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-style: italic;", - }); - return; - } + if (steps.length === 0) { + nodesEl.innerHTML = `
This document has no steps to visualize.
`; + return; + } - const roots: string[] = []; - for (const s of cluster.steps) { - const sid = s.step.id; - const nodeId = `${prefix}${sid}`; - const isDead = anc && !anc[sid]; - const isHead = sid === cluster.headId; - if (isDead && !opts.showDead) continue; - if (!s.step.parents || !s.step.parents.length) roots.push(nodeId); - - const t = actorType(s.step.actor); - const colors = COLORS[t]; - const lines = [sid, actorDisplay(s.step.actor, cluster.actors)]; - if (s.meta?.intent) lines.push(truncate(s.meta.intent, 30)); - if (opts.showTs && s.step.timestamp) lines.push(s.step.timestamp.substring(11, 19)); - if (opts.showFiles && s.change) for (const f of Object.keys(s.change)) lines.push(truncate(f, 28)); - - const fill = isDead ? COLORS.dead.fill : colors.fill; - const stroke = isDead ? COLORS.dead.stroke : colors.stroke; - graph.setNode(nodeId, { - label: lines.join("\n"), - shape: "rect", - style: `fill: ${fill}; stroke: ${stroke}; stroke-width: ${isHead ? "3px" : "1.5px"}; stroke-dasharray: ${isDead || t === "ci" ? "4,3" : "none"};`, - labelStyle: `font-family: 'IBM Plex Mono', monospace; font-size: 10px; ${isHead ? "font-weight: bold;" : ""}`, - _step: s, - _clusterIndex: ci, - _isDead: isDead, - _isHead: isHead, + // Visible step set + const visible = steps.filter((s) => + isStepVisible(s.step.id, headSet, stepMap, opts.expandedBranches), + ); + + // Pass 1 — render cards so the browser can measure them. + nodesEl.innerHTML = visible + .map((s) => { + const id = s.step.id; + return renderCard(s, { + isHead: id === head, + isDead: !headSet.has(id), + isFocused: id === opts.selectedStepId, + deadKids: deadChildrenCount(id, headSet, childrenMap), + isExpanded: !!opts.expandedBranches[id], + actors, + showTs: opts.showTs, + showFiles: opts.showFiles, }); - if (clusters.length > 1) graph.setParent(nodeId, `cluster_${ci}`); - } + }) + .join(""); - for (const s of cluster.steps) { - const sid = s.step.id; - const targetId = `${prefix}${sid}`; - const isDead = anc && !anc[sid]; - if (isDead && !opts.showDead) continue; - if (!s.step.parents) continue; - for (const pid of s.step.parents) { - const srcId = `${prefix}${pid}`; - if (!graph.node(srcId)) continue; - if (!opts.showDead && anc && !anc[pid]) continue; - const bothActive = anc && anc[sid] && anc[pid]; - const style = bothActive ? EDGE_ACTIVE : EDGE_INACTIVE; - const dash = bothActive ? "" : "4,3"; - graph.setEdge(srcId, targetId, { - style: `stroke: ${style.stroke}; stroke-width: ${style.width}px;${dash ? ` stroke-dasharray: ${dash};` : ""}`, - arrowheadStyle: `fill: ${style.stroke}`, - curve: d3.curveBasis, - }); - } - } + // Pass 2 — measure. + const dims = new Map(); + for (const s of visible) { + const el = document.getElementById(`pg-card-${cssSafeId(s.step.id)}`); + if (!el) continue; + dims.set(s.step.id, { + width: el.offsetWidth, + height: el.offsetHeight, + }); + } - if (cluster.base) { - const baseNodeId = `${prefix}__BASE__`; - for (const rid of roots) { - if (graph.node(rid)) { - graph.setEdge(baseNodeId, rid, { - style: `stroke: ${EDGE_BASE.stroke}; stroke-width: ${EDGE_BASE.width}px;`, - arrowheadStyle: `fill: ${EDGE_BASE.stroke}`, - curve: d3.curveBasis, - }); - } - } - } + // Pass 3 — dagre layout. + const g = new dagre.graphlib.Graph(); + g.setGraph({ + rankdir: "TB", + nodesep: 30, + ranksep: 48, + marginx: 16, + marginy: 16, }); + g.setDefaultEdgeLabel(() => ({})); + for (const s of visible) { + const d = dims.get(s.step.id); + if (!d) continue; + g.setNode(s.step.id, d); + } + const visibleIds = new Set(visible.map((s) => s.step.id)); + for (const s of visible) { + const id = s.step.id; + for (const p of s.step.parents || []) { + if (!visibleIds.has(p)) continue; + const childIsDead = !headSet.has(id); + g.setEdge(p, id, { dead: childIsDead }); + } + } + dagre.layout(g); + const gi = g.graph(); - const svg = d3.select(svgEl); - svg.selectAll("*").remove(); - const group = svg.append("g"); - dagreRender()(group, graph); - - const zoom = d3 - .zoom() - .scaleExtent([0.1, 4]) - .on("zoom", (ev) => group.attr("transform", ev.transform)); - svg.call(zoom); - - function fit() { - const gNode = group.node(); - if (!gNode) return; - const bounds = (gNode as SVGGraphicsElement).getBBox(); - if (!bounds.width || !bounds.height) return; - const parent = svgEl.parentElement; - if (!parent) return; - const sx = parent.clientWidth / (bounds.width + 60); - const sy = parent.clientHeight / (bounds.height + 60); - const scale = Math.min(sx, sy, 1.5); - const tx = (parent.clientWidth - bounds.width * scale) / 2 - bounds.x * scale; - const ty = (parent.clientHeight - bounds.height * scale) / 2 - bounds.y * scale; - svg - .transition() - .duration(300) - .call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale)); + // Pass 4 — size + position. + const w = Math.ceil(gi.width ?? 0); + const h = Math.ceil(gi.height ?? 0); + graphEl.style.width = w + "px"; + graphEl.style.height = h + "px"; + svgEl.setAttribute("width", String(w)); + svgEl.setAttribute("height", String(h)); + for (const s of visible) { + const n = g.node(s.step.id); + if (!n) continue; + const el = document.getElementById(`pg-card-${cssSafeId(s.step.id)}`); + if (!el) continue; + el.style.left = n.x - n.width / 2 + "px"; + el.style.top = n.y - n.height / 2 + "px"; + el.style.visibility = "visible"; } - fit(); - - if (opts.onStepClick) { - svg.selectAll("g.node").on("click", function () { - // dagre-d3 stashes node id on __data__ - const id = (this as SVGGElement & { __data__: string }).__data__; - const data = graph.node(id) as { - _step?: StepRef; - _clusterIndex?: number; - } | undefined; - if (data?._step && typeof data._clusterIndex === "number") { - const cluster = clusters[data._clusterIndex]; - opts.onStepClick!(data._step, cluster?.actors ?? null); - } - }); + + // Pass 5 — edges. + for (const path of Array.from(svgEl.querySelectorAll("path.path-graph__edge"))) { + path.remove(); + } + for (const e of g.edges()) { + const edge = g.edge(e) as { points: { x: number; y: number }[]; dead?: boolean }; + if (!edge?.points?.length) continue; + const d = pointsToPath(edge.points); + const path = document.createElementNS(NS, "path"); + path.setAttribute("d", d); + path.setAttribute( + "class", + "path-graph__edge" + (edge.dead ? " path-graph__edge--dead" : ""), + ); + path.setAttribute( + "marker-end", + edge.dead ? "url(#pg-arrow-dead)" : "url(#pg-arrow)", + ); + svgEl.appendChild(path); } - return { fit }; + // Click delegation + nodesEl.onclick = (ev: MouseEvent) => { + const target = ev.target as HTMLElement; + const toggle = target.closest("[data-toggle-branch]"); + if (toggle) { + ev.stopPropagation(); + const id = toggle.getAttribute("data-toggle-branch")!; + opts.onToggleBranch(id); + return; + } + const card = target.closest("[data-step-id]"); + if (!card) return; + const id = card.getAttribute("data-step-id")!; + const step = stepMap.get(id); + if (!step) return; + opts.onSelectStep(step, actors); + }; } diff --git a/crates/toolpath-desktop/frontend/src/routes/Preview.svelte b/crates/toolpath-desktop/frontend/src/routes/Preview.svelte index 0126583..6c640ce 100644 --- a/crates/toolpath-desktop/frontend/src/routes/Preview.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/Preview.svelte @@ -3,19 +3,23 @@ import { store } from "../lib/store.svelte"; import type { StepRef } from "../lib/types"; - let svgEl: SVGSVGElement | null = $state(null); + let canvasEl: HTMLDivElement | null = $state(null); - // Re-render the viz when the underlying doc or toggles change. + // Re-render the graph whenever the underlying doc, selection, or toggles + // change. `vizEpoch` bumps on every toggle/branch-expand. $effect(() => { const p = store.m.preview; - if (!svgEl || !p) return; - const doc = p.doc; - const toggles = { showDead: p.showDead, showTs: p.showTs, showFiles: p.showFiles }; - // Referenced to establish dep tracking: + if (!canvasEl || !p) return; const _epoch = p.vizEpoch; - renderViz(doc, svgEl, { - ...toggles, - onStepClick: (step, actors) => store.dispatch({ t: "PreviewSelectStep", step, actors }), + renderViz(p.doc, canvasEl, { + selectedStepId: p.selectedStep?.step.id ?? null, + expandedBranches: p.expandedBranches, + showTs: p.showTs, + showFiles: p.showFiles, + onSelectStep: (step, actors) => + store.dispatch({ t: "PreviewSelectStep", step, actors }), + onToggleBranch: (nodeId) => + store.dispatch({ t: "PreviewToggleBranch", nodeId }), }); }); @@ -51,10 +55,6 @@

-
+ Click a card's expand chip to reveal its dead-end branch.
-
- -
+
{#if !preview.selectedStep}
Click a step in the graph to inspect its diff and metadata.
diff --git a/crates/toolpath-desktop/frontend/src/styles.css b/crates/toolpath-desktop/frontend/src/styles.css index 92e5a5b..1edac4d 100644 --- a/crates/toolpath-desktop/frontend/src/styles.css +++ b/crates/toolpath-desktop/frontend/src/styles.css @@ -42,6 +42,7 @@ body { .appbar__tag { color: var(--text-muted); font-size: 12px; margin-left: 6px; } .screen { padding: 20px; max-width: 560px; margin: 0 auto; } +.screen--wide { max-width: none; padding: 20px 24px; } h1 { font-size: 22px; margin: 0 0 4px 0; font-weight: 600; } p.subtitle { color: var(--text-muted); margin: 0 0 20px 0; } @@ -175,10 +176,10 @@ input:focus { outline: none; border-color: var(--accent); } } .preview-canvas { border: 1px solid var(--border); border-radius: 8px; - background: var(--panel); - position: relative; overflow: hidden; + background: #faf6ee; + position: relative; overflow: auto; + padding: 16px; } -.preview-canvas svg { width: 100%; height: 100%; display: block; } .preview-panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; overflow: auto; @@ -237,3 +238,178 @@ input:focus { outline: none; border-color: var(--accent); } color: white; } .banner--warn code { background: rgba(255, 255, 255, 0.18); } + +/* ─── Path graph (viz.ts) ──────────────────────────────────────────── */ +.path-graph { + position: relative; + margin: 0 auto; +} +.path-graph__edges { + position: absolute; + top: 0; left: 0; + overflow: visible; + pointer-events: none; + z-index: 1; +} +.path-graph__nodes { + position: relative; + width: 100%; + height: 100%; +} +.path-graph__empty { + color: var(--text-muted); + font-size: 13px; + padding: 20px; + text-align: center; +} +.path-graph__edge { + fill: none; + stroke: var(--text-muted); + stroke-width: 1.5; +} +.path-graph__edge--dead { + stroke: var(--danger); + stroke-dasharray: 5 3; + opacity: 0.8; +} + +/* Card */ +.pg-card { + position: absolute; + visibility: hidden; /* revealed by viz.ts after layout */ + box-sizing: border-box; + width: 260px; + background: var(--panel); + border: 1px solid var(--border); + border-left: 3px solid var(--text-muted); + border-radius: 6px; + padding: 10px 12px; + z-index: 2; + cursor: pointer; + font-family: var(--sans); + font-size: 12.5px; + transition: border-color 0.12s, transform 0.12s, box-shadow 0.12s; +} +.pg-card:hover { + border-color: var(--accent); +} +.pg-card--role-human, +.pg-card--role-agent { border-left-color: var(--accent); } +.pg-card--role-tool, +.pg-card--role-ci { border-left-color: var(--text-muted); } +.pg-card--dead { + border-left-color: var(--danger); + background: #fcfaf6; +} +.pg-card--head { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-soft); +} +.pg-card--focused { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-hot); +} +/* Stacked-card shadow when HEAD card has collapsed dead children. */ +.pg-card--has-hidden { + box-shadow: + 6px 6px 0 -1px var(--panel), + 6px 6px 0 0 var(--border), + 12px 12px 0 -1px var(--panel), + 12px 12px 0 0 var(--danger); +} + +.pg-card__head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; +} +.pg-card__id { + font-family: var(--mono); + font-size: 11.5px; + font-weight: 600; + color: var(--text); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pg-card__chips { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.pg-card__chip { + font-family: var(--mono); + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid currentColor; +} +.pg-card__chip--head { + color: var(--accent); + background: var(--accent-soft); +} +.pg-card__chip--dead { + color: var(--danger); + background: var(--dead); +} + +.pg-card__intent { + color: var(--text); + font-size: 12.5px; + line-height: 1.35; + margin: 2px 0 6px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.pg-card__actor { + font-family: var(--mono); + font-size: 10.5px; + color: var(--text-muted); +} +.pg-card__ts { + font-family: var(--mono); + font-size: 10px; + color: var(--text-muted); + margin-top: 2px; +} +.pg-card__files { + font-family: var(--mono); + font-size: 10px; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.35; + word-break: break-all; +} + +.pg-card__footer { + margin-top: 8px; + display: flex; + justify-content: flex-end; +} +.pg-card__toggle { + font: inherit; + font-size: 10.5px; + font-family: var(--mono); + padding: 2px 8px; + border: 1px solid var(--border); + background: #faf6ee; + color: var(--text-muted); + border-radius: 3px; + cursor: pointer; +} +.pg-card__toggle:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-soft); +} +.pg-card__toggle--on { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-soft); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..ca049dc --- /dev/null +++ b/index.html @@ -0,0 +1,953 @@ + + + + + + Toolpath — viz prototype (dagre graph) + + + + +
+ + +
+
+
+
+ + + + + + + + + + +
+
+
+
+
+ + + +