Skip to main content
Glama
d3_hop_dashboard.ts16.7 kB
/** * D3 Hop Dashboard Renderer * * Gantt-style visualization of Shrimp task progress using D3 force-directed layout. * Reuses D3GraphRenderer for graph generation, adapts to horizontal timeline layout. * * Features: * - Phase-based timeline (horizontal axis) * - Color-coded hop status (pending, in-progress, completed) * - Dependency arrows (left-to-right flow) * - Interactive controls (filter by phase/status) * - Stats panel (completion rate, LOC budget) * - Dark/light theme support (via ThemeManager) * - Export support (PNG/SVG/PDF via ExportCoordinator) * * @module artifacts/renderers/d3_hop_dashboard */ import { ArtifactRenderer, ArtifactMetadata } from '../types.js'; import { D3GraphRenderer, GraphNode, GraphEdge } from '../visualizations/D3GraphRenderer.js'; /** * Hop data structure */ export interface HopData { id: string; name: string; status: 'planned' | 'in_progress' | 'completed'; description: string; estimatedLOC: number; actualLOC?: number; ctsCompliant: boolean; phase: string; dependencies: string[]; tests?: { total: number; passing: number; coverage: number; }; } /** * Phase data structure */ export interface PhaseData { name: string; hops: HopData[]; } /** * Hop dashboard data structure */ export interface HopDashboardData { currentPhase: string; phases: PhaseData[]; stats: { totalLOC: number; plannedLOC: number; ctsComplianceRate: number; completionRate: number; }; } /** * D3-based Hop Dashboard Renderer * * Converts Shrimp task data into interactive Gantt chart visualization. * Reuses D3GraphRenderer for force-directed layout engine. * * @example Basic usage * ```typescript * import { D3HopDashboardRenderer } from './renderers/d3_hop_dashboard.js'; * * const renderer = new D3HopDashboardRenderer(); * * const hopData = { * phases: [ * { * name: 'Phase 1: Foundation', * hops: [ * { * id: '1.1a', * name: 'Project Setup', * status: 'completed', * description: 'Initialize project', * estimatedLOC: 200, * actualLOC: 185, * ctsCompliant: true, * phase: 'Phase 1: Foundation', * dependencies: [] * } * ] * } * ] * }; * * const html = await renderer.render(hopData); * // Returns: HTML string with Gantt-style hop dashboard * ``` * * @example Large dashboard (progressive rendering) * ```typescript * const largeHopData = { * phases: [ * { * name: 'Phase 1', * hops: Array.from({ length: 50 }, (_, i) => ({ * id: `1.${i}a`, * name: `Hop ${i}`, * status: 'planned', * description: 'Sample hop', * estimatedLOC: 300, * ctsCompliant: true, * phase: 'Phase 1', * dependencies: [] * })) * } * ] * }; * * const html = await renderer.render(largeHopData); * // Progressive rendering active (>10 hops) * // Yields to main thread after each section (prevents UI freezing) * ``` */ export class D3HopDashboardRenderer implements ArtifactRenderer { readonly type = 'hop_dashboard'; private readonly d3Renderer: D3GraphRenderer; constructor() { // Wider canvas for timeline layout this.d3Renderer = new D3GraphRenderer(1400, 600); } /** * Render hop dashboard as interactive D3 visualization. * Uses progressive rendering for large dashboards (>10 hops). * * @param data HopDashboardData structure with phases and hops * @returns Promise resolving to HTML string with embedded D3 graph * * @example Small dashboard (synchronous render) * ```typescript * const hopData = { * phases: [{ * name: 'Phase 1', * hops: [ * { id: '1.1a', name: 'Setup', status: 'completed', description: '', estimatedLOC: 200, ctsCompliant: true, phase: 'Phase 1', dependencies: [] }, * { id: '1.2a', name: 'Architecture', status: 'in_progress', description: '', estimatedLOC: 300, ctsCompliant: true, phase: 'Phase 1', dependencies: ['1.1a'] } * ] * }] * }; * * const html = await renderer.render(hopData); * // Renders immediately (~200ms for 5 hops) * ``` * * @example Large dashboard (progressive render) * ```typescript * const largeHopData = { * phases: [ * { name: 'Phase 1', hops: [...] }, // 20 hops * { name: 'Phase 2', hops: [...] }, // 15 hops * { name: 'Phase 3', hops: [...] } // 10 hops * ] * }; * * const html = await renderer.render(largeHopData); * // Progressive rendering: * // 1. Generate graph (~500ms) * // 2. Yield to main thread (UI remains responsive) * // 3. Inject axis/controls (~300ms) * // 4. Yield again * // 5. Add stats panel (~200ms) * // Total: ~1s with UI responsiveness maintained * ``` */ async render(data: unknown): Promise<string> { const hopData = data as HopDashboardData; // Count total hops for progressive rendering decision const totalHops = hopData.phases.reduce((sum, phase) => sum + phase.hops.length, 0); const useProgressiveRendering = totalHops > 10; if (useProgressiveRendering) { console.log(`[D3HopDashboardRenderer] Progressive rendering enabled for ${totalHops} hops`); } // Convert phases/hops to graph nodes (all at once for simplicity) const nodes: GraphNode[] = this.convertToGraphNodes(hopData); // Convert hop dependencies to graph edges const edges: GraphEdge[] = this.convertToGraphEdges(hopData); // Generate base D3 graph let html = this.d3Renderer.generateForceDirectedGraph(nodes, edges); // Yield to main thread for large dashboards (prevent blocking) if (useProgressiveRendering) { await this.yieldToMainThread(); } // Inject timeline axis (phase names) html = this.injectTimelineAxis(html, hopData.phases); // Yield to main thread if (useProgressiveRendering) { await this.yieldToMainThread(); } // Inject stats panel (completion rate, LOC budget) html = this.injectStatsPanel(html, hopData.stats, hopData.currentPhase); // Yield to main thread if (useProgressiveRendering) { await this.yieldToMainThread(); } // Inject interactive controls (filter by phase/status) html = this.injectControls(html); return html; } /** * Yield to main thread to prevent UI blocking * Uses setImmediate pattern (Node.js) or setTimeout (browser) * @private */ private async yieldToMainThread(): Promise<void> { return new Promise(resolve => { if (typeof setImmediate !== 'undefined') { setImmediate(resolve); } else { setTimeout(resolve, 0); } }); } /** * Convert hop dashboard data to graph nodes * * Each hop becomes a node with: * - Position based on phase index * - Color based on status * - Label with hop name and LOC * * @private */ private convertToGraphNodes(hopData: HopDashboardData): GraphNode[] { const nodes: GraphNode[] = []; hopData.phases.forEach((phase, phaseIndex) => { phase.hops.forEach((hop, hopIndex) => { nodes.push({ id: hop.id, label: `${hop.name}\n(${hop.actualLOC || hop.estimatedLOC} LOC)`, type: hop.status, // 'planned', 'in_progress', 'completed' filePath: `Phase ${phaseIndex + 1}`, line: hopIndex, params: [`${hop.estimatedLOC} LOC`, `${hop.tests?.coverage || 0}% coverage`], clusterId: phase.name, // Preset positions for Gantt-style layout x: phaseIndex * 400 + 200, y: hopIndex * 100 + 100, fx: phaseIndex * 400 + 200, // Fixed x-position fy: hopIndex * 100 + 100 // Fixed y-position }); }); }); return nodes; } /** * Convert hop dependencies to graph edges * * Each dependency becomes an arrow from prerequisite hop to dependent hop. * * @private */ private convertToGraphEdges(hopData: HopDashboardData): GraphEdge[] { const edges: GraphEdge[] = []; hopData.phases.forEach(phase => { phase.hops.forEach(hop => { hop.dependencies.forEach(depId => { edges.push({ source: depId, target: hop.id, type: 'dependency' }); }); }); }); return edges; } /** * Inject timeline axis showing phase names * * Adds horizontal axis at top of graph with phase labels. * * @private */ private injectTimelineAxis(html: string, phases: PhaseData[]): string { const phaseLabels = phases.map((phase, index) => { const x = index * 400 + 200; return ` <div style=" position: absolute; left: ${x}px; top: 20px; transform: translateX(-50%); font-size: 16px; font-weight: bold; color: #333; background: rgba(255, 255, 255, 0.9); padding: 8px 16px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); "> ${phase.name} <div style="font-size: 12px; font-weight: normal; color: #666;"> ${phase.hops.length} hops </div> </div> `; }).join(''); // Inject before SVG element return html.replace( '<svg', `<div style="position: relative; width: 1400px; margin: 0 auto;"> ${phaseLabels} </div> <svg` ); } /** * Inject stats panel showing completion metrics * * Adds panel with: * - Current phase indicator * - Total LOC vs planned LOC * - CTS compliance rate * - Overall completion rate * * @private */ private injectStatsPanel( html: string, stats: HopDashboardData['stats'], currentPhase: string ): string { const completionPercent = Math.round(stats.completionRate * 100); const ctsPercent = Math.round(stats.ctsComplianceRate * 100); const locPercent = Math.round((stats.totalLOC / stats.plannedLOC) * 100); const statsPanel = ` <div id="stats-panel" style=" position: fixed; top: 20px; right: 20px; background: rgba(255, 255, 255, 0.95); border: 2px solid #ccc; border-radius: 8px; padding: 16px; min-width: 250px; box-shadow: 0 4px 16px rgba(0,0,0,0.2); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; z-index: 1000; "> <h3 style="margin: 0 0 12px 0; font-size: 18px; color: #333;"> 📊 Dashboard Stats </h3> <div style="margin-bottom: 8px;"> <strong style="color: #1f77b4;">Current Phase:</strong> <div style=" background: #1f77b4; color: white; padding: 4px 8px; border-radius: 4px; margin-top: 4px; font-weight: bold; "> ${currentPhase} </div> </div> <div style="margin-bottom: 8px;"> <strong>Completion:</strong> <div style=" background: #f0f0f0; height: 20px; border-radius: 10px; overflow: hidden; margin-top: 4px; "> <div style=" background: ${completionPercent === 100 ? '#2ca02c' : '#ff7f0e'}; height: 100%; width: ${completionPercent}%; transition: width 0.3s ease; "></div> </div> <div style="font-size: 12px; margin-top: 2px; color: #666;"> ${completionPercent}% </div> </div> <div style="margin-bottom: 8px;"> <strong>LOC Budget:</strong> <div style="font-size: 12px; color: #666; margin-top: 2px;"> ${stats.totalLOC.toLocaleString()} / ${stats.plannedLOC.toLocaleString()} (${locPercent}%) </div> </div> <div style="margin-bottom: 8px;"> <strong>CTS Compliance:</strong> <div style="font-size: 12px; margin-top: 2px; color: ${ctsPercent >= 90 ? '#2ca02c' : '#ff7f0e'};"> ${ctsPercent}% </div> </div> </div> `; // Inject stats panel before closing body tag return html.replace('</body>', `${statsPanel}\n</body>`); } /** * Inject interactive controls for filtering * * Adds control panel with: * - Phase filter dropdown * - Status filter checkboxes * - Reset button * * @private */ private injectControls(html: string): string { const controls = ` <div id="controls-panel" style=" position: fixed; bottom: 20px; left: 20px; background: rgba(255, 255, 255, 0.95); border: 2px solid #ccc; border-radius: 8px; padding: 16px; min-width: 200px; box-shadow: 0 4px 16px rgba(0,0,0,0.2); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; z-index: 1000; "> <h4 style="margin: 0 0 12px 0; font-size: 16px; color: #333;"> 🎛️ Controls </h4> <div style="margin-bottom: 12px;"> <label style="display: block; margin-bottom: 4px; font-weight: bold; font-size: 14px;"> Filter by Status: </label> <div style="display: flex; flex-direction: column; gap: 4px;"> <label style="cursor: pointer;"> <input type="checkbox" id="filter-planned" checked> <span style="margin-left: 4px; color: #ff7f0e;">⬤ Planned</span> </label> <label style="cursor: pointer;"> <input type="checkbox" id="filter-in-progress" checked> <span style="margin-left: 4px; color: #1f77b4;">⬤ In Progress</span> </label> <label style="cursor: pointer;"> <input type="checkbox" id="filter-completed" checked> <span style="margin-left: 4px; color: #2ca02c;">⬤ Completed</span> </label> </div> </div> <button id="reset-view" style=" width: 100%; padding: 8px; background: #1f77b4; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; "> Reset View </button> </div> <script> // Filter controls document.getElementById('filter-planned')?.addEventListener('change', updateFilter); document.getElementById('filter-in-progress')?.addEventListener('change', updateFilter); document.getElementById('filter-completed')?.addEventListener('change', updateFilter); function updateFilter() { const showPlanned = document.getElementById('filter-planned')?.checked || false; const showInProgress = document.getElementById('filter-in-progress')?.checked || false; const showCompleted = document.getElementById('filter-completed')?.checked || false; // Filter nodes by status d3.selectAll('.node').style('opacity', function(d) { if (d.type === 'planned' && !showPlanned) return 0.1; if (d.type === 'in_progress' && !showInProgress) return 0.1; if (d.type === 'completed' && !showCompleted) return 0.1; return 1; }); // Filter edges connected to hidden nodes d3.selectAll('.link').style('opacity', function(d) { const sourceVisible = (d.source.type === 'planned' && showPlanned) || (d.source.type === 'in_progress' && showInProgress) || (d.source.type === 'completed' && showCompleted); const targetVisible = (d.target.type === 'planned' && showPlanned) || (d.target.type === 'in_progress' && showInProgress) || (d.target.type === 'completed' && showCompleted); return (sourceVisible && targetVisible) ? 1 : 0.1; }); } // Reset view button document.getElementById('reset-view')?.addEventListener('click', () => { document.getElementById('filter-planned').checked = true; document.getElementById('filter-in-progress').checked = true; document.getElementById('filter-completed').checked = true; updateFilter(); }); </script> `; // Inject controls before closing body tag return html.replace('</body>', `${controls}\n</body>`); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/EricA1019/CTS_MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server