Files
taskpile/frontend/node_modules/force-graph/example/text-links/index.html
Alvis f1d51b8cc8 Add side panels, task selection, graph animation, and project docs
- Foldable left panel (user profile) and right panel (task details)
- Clicking a task in the list or graph node selects it and shows details
- Both views (task list + graph) always mounted via absolute inset-0 for
  correct canvas dimensions; tabs toggle visibility with opacity
- Graph node selection animation: other nodes repel outward (charge -600),
  then selected node smoothly slides to center (500ms cubic ease-out),
  then charge restores to -120 and graph stabilizes
- Graph re-fits on tab switch and panel resize via ResizeObserver
- Fix UUID string IDs throughout (backend returns UUIDs, not integers)
- Add TaskDetailPanel, UserPanel components
- Add CLAUDE.md project documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:23:06 +00:00

68 lines
2.4 KiB
HTML

<head>
<style> body { margin: 0; } </style>
<script src="//cdn.jsdelivr.net/npm/force-graph"></script>
<!--<script src="../../dist/force-graph.js"></script>-->
</head>
<body>
<div id="graph"></div>
<script>
fetch('../datasets/miserables.json').then(res => res.json()).then(data => {
const Graph = new ForceGraph(document.getElementById('graph'))
.graphData(data)
.nodeId('id')
.nodeLabel('id')
.nodeAutoColorBy('group')
.linkCanvasObjectMode(() => 'after')
.linkCanvasObject((link, ctx) => {
const MAX_FONT_SIZE = 4;
const LABEL_NODE_MARGIN = Graph.nodeRelSize() * 1.5;
const start = link.source;
const end = link.target;
// ignore unbound links
if (typeof start !== 'object' || typeof end !== 'object') return;
// calculate label positioning
const textPos = Object.assign(...['x', 'y'].map(c => ({
[c]: start[c] + (end[c] - start[c]) / 2 // calc middle point
})));
const relLink = { x: end.x - start.x, y: end.y - start.y };
const maxTextLength = Math.sqrt(Math.pow(relLink.x, 2) + Math.pow(relLink.y, 2)) - LABEL_NODE_MARGIN * 2;
let textAngle = Math.atan2(relLink.y, relLink.x);
// maintain label vertical orientation for legibility
if (textAngle > Math.PI / 2) textAngle = -(Math.PI - textAngle);
if (textAngle < -Math.PI / 2) textAngle = -(-Math.PI - textAngle);
const label = `${link.source.id} > ${link.target.id}`;
// estimate fontSize to fit in link length
ctx.font = '1px Sans-Serif';
const fontSize = Math.min(MAX_FONT_SIZE, maxTextLength / ctx.measureText(label).width);
ctx.font = `${fontSize}px Sans-Serif`;
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2); // some padding
// draw text label (with background rect)
ctx.save();
ctx.translate(textPos.x, textPos.y);
ctx.rotate(textAngle);
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fillRect(- bckgDimensions[0] / 2, - bckgDimensions[1] / 2, ...bckgDimensions);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'darkgrey';
ctx.fillText(label, 0, 0);
ctx.restore();
});
});
</script>
</body>