UNPKG

pdm-ai

Version:

PDM-AI - Transform customer feedback into structured product insights using the Jobs-to-be-Done (JTBD) methodology

469 lines (406 loc) 16.6 kB
/** * Mermaid diagram generator for PDM-AI * Handles the generation of Mermaid diagrams for JTBDs and scenarios */ /** * Escape special characters in text for Mermaid diagram * @param {string} text - Text to escape * @returns {string} Escaped text */ function escapeText(text) { if (!text) return ''; return text .replace(/"/g, '\\"') .replace(/\n/g, '\\n'); } /** * Make an ID safe for use in Mermaid diagrams * @param {string} id - The original ID * @returns {string} Safe ID for Mermaid */ function makeIdSafe(id) { // Replace characters that might cause issues in Mermaid syntax return id.replace(/[^a-zA-Z0-9]/g, '_'); } /** * Format a scenario label with persona and statement * @param {Object} scenario - Scenario object * @returns {string} Formatted label */ function formatScenarioLabel(scenario) { // Show complete scenario statement without truncation let label = scenario.statement || 'Unnamed Scenario'; // Escape special characters but don't truncate return escapeText(label); } /** * Generate a JTBD-centric Mermaid diagram * @param {Object} data - Input data with JTBDs and scenarios * @param {Object} options - Visualization options * @param {Object} stats - Statistics object to update * @returns {string} Mermaid diagram content */ function generateJtbdCentricView(data, options, stats) { // Initialize Mermaid diagram with header and style definitions let diagram = ''; // Style definitions with clean pastel colors, subtle matching borders let styles = []; // Pastel yellow for Layer 2 JTBDs - with light matching border styles.push('classDef abstractJtbd fill:#FFF5BA,stroke:#E6DCB2,stroke-width:1px;'); // Pastel blue for Layer 1 JTBDs - with light matching border styles.push('classDef concreteJtbd fill:#BADFFF,stroke:#ADC8E6,stroke-width:1px;'); // Pastel green for scenarios - with light matching border styles.push('classDef scenario fill:#BAFFC9,stroke:#AAEAB8,stroke-width:1px;'); // Edge styling styles.push('linkStyle default stroke:#888888,stroke-width:1.5px;'); // Disable default theme and styling to prevent gradient borders styles.push('%%{init: {"themeVariables": {"primaryBorderColor": "#888888", "primaryColor": "#ffffff", "primaryTextColor": "#000000", "fontFamily": "arial", "fontSize": "16px"}, "theme": "base", "themeCSS": ".subgraph rect { stroke: #888888 !important; fill: #ffffff !important; } .subgraph .label { color: #000000 !important; }"}}%%'); // Extract the JTBDs and scenarios from the data const { jtbds, scenarios } = data; // Filter JTBDs based on options and remove empty entries let filteredJtbds = (jtbds || []) .filter(jtbd => jtbd && jtbd.id && typeof jtbd.id === 'string' && jtbd.statement); // Create maps for different entities const jtbdMap = {}; const layer1Jtbds = []; const layer2Jtbds = []; // Separate JTBDs by layer filteredJtbds.forEach(jtbd => { jtbdMap[jtbd.id] = jtbd; if (jtbd.level === 1) { layer1Jtbds.push(jtbd); } else if (jtbd.level === 2) { layer2Jtbds.push(jtbd); } }); // Build parent-child relationships between JTBDs const childToParentMap = new Map(); // Maps child ID to parent ID // Process childIds arrays in Layer 2 JTBDs layer2Jtbds.forEach(parentJtbd => { if (Array.isArray(parentJtbd.childIds) && parentJtbd.childIds.length > 0) { parentJtbd.childIds.forEach(childId => { childToParentMap.set(childId, parentJtbd.id); }); } // Also handle if there's a jtbdIds array (alternative format) if (Array.isArray(parentJtbd.jtbdIds) && parentJtbd.jtbdIds.length > 0) { parentJtbd.jtbdIds.forEach(childId => { childToParentMap.set(childId, parentJtbd.id); }); } }); // Process parentId fields in Layer 1 JTBDs layer1Jtbds.forEach(childJtbd => { if (childJtbd.parentId) { // Check if parentId is a direct ID of another JTBD if (jtbdMap[childJtbd.parentId]) { childToParentMap.set(childJtbd.id, childJtbd.parentId); } // It might be a cluster ID instead - look for a JTBD with matching clusterId else { const parentJtbd = layer2Jtbds.find(j => j.clusterId === childJtbd.parentId); if (parentJtbd) { childToParentMap.set(childJtbd.id, parentJtbd.id); } } } }); // Add title diagram += 'graph TD\n'; diagram += ' %% JTBD-centric visualization with full statements\n'; diagram += ' %% Generated by PDM-AI tool\n\n'; // Add styling initialization to disable default theme styling diagram += ' %%{init: {"themeVariables": {"primaryBorderColor": "#888888", "primaryColor": "#ffffff", "primaryTextColor": "#000000", "fontFamily": "arial", "fontSize": "16px"}, "theme": "base", "themeCSS": ".subgraph rect { stroke: #888888 !important; fill: #ffffff !important; } .subgraph .label { color: #000000 !important; }"}}%%\n\n'; // Add node style definitions right away for (const style of styles) { diagram += ' ' + style + '\n'; } diagram += '\n'; // Add nodes for Layer 2 JTBDs (abstract/higher-level) if (layer2Jtbds.length > 0) { diagram += ' %% Layer 2 JTBD nodes\n'; layer2Jtbds.forEach(jtbd => { const safeId = makeIdSafe(jtbd.id); const label = escapeText(jtbd.statement || 'Unnamed Abstract JTBD'); // Remove priority display diagram += ` ${safeId}["${label}"]\n`; stats.nodeCount++; // Style this node diagram += ` class ${safeId} abstractJtbd;\n`; }); diagram += '\n'; } // Add nodes for Layer 1 JTBDs (concrete/lower-level) if (layer1Jtbds.length > 0) { diagram += ' %% Layer 1 JTBD nodes\n'; layer1Jtbds.forEach(jtbd => { const safeId = makeIdSafe(jtbd.id); const label = escapeText(jtbd.statement || 'Unnamed JTBD'); // Remove priority display diagram += ` ${safeId}["${label}"]\n`; stats.nodeCount++; // Style this node diagram += ` class ${safeId} concreteJtbd;\n`; }); diagram += '\n'; } // Add edges for top-down JTBD hierarchical relationships diagram += ' %% JTBD hierarchical relationships\n'; // Use the childToParentMap to draw edges from parent to child childToParentMap.forEach((parentId, childId) => { if (jtbdMap[parentId] && jtbdMap[childId]) { const parentIdSafe = makeIdSafe(parentId); const childIdSafe = makeIdSafe(childId); diagram += ` ${parentIdSafe} --> ${childIdSafe}\n`; stats.edgeCount++; } }); diagram += '\n'; // Check if scenarios have required properties (id, statement) const hasValidScenarios = scenarios && Array.isArray(scenarios) && scenarios.some(s => s && s.id && s.statement); // If we have valid scenarios data, proceed with scenario visualization if (hasValidScenarios) { // Filter out scenarios without valid IDs and statements let relevantScenarios = scenarios.filter(scenario => scenario && scenario.id && typeof scenario.id === 'string' && scenario.statement); // Track which scenarios are connected to JTBDs for visualization purposes const connectedScenarios = new Set(); // Build a relationship map from JTBDs to scenarios const jtbdToScenarioMap = new Map(); // Map relationships from both directions // First, map from JTBD to scenarios based on scenarioIds or relatedScenarios array in JTBD filteredJtbds.forEach(jtbd => { const scenarioIds = jtbd.scenarioIds || jtbd.relatedScenarios || []; if (Array.isArray(scenarioIds) && scenarioIds.length > 0) { if (!jtbdToScenarioMap.has(jtbd.id)) { jtbdToScenarioMap.set(jtbd.id, new Set()); } scenarioIds.forEach(scenarioId => { jtbdToScenarioMap.get(jtbd.id).add(scenarioId); connectedScenarios.add(scenarioId); }); } }); // Then, check if scenarios have relatedJtbds and add those relationships relevantScenarios.forEach(scenario => { const jtbdIds = scenario.relatedJtbds || []; if (Array.isArray(jtbdIds) && jtbdIds.length > 0) { jtbdIds.forEach(jtbdId => { if (jtbdMap[jtbdId]) { if (!jtbdToScenarioMap.has(jtbdId)) { jtbdToScenarioMap.set(jtbdId, new Set()); } jtbdToScenarioMap.get(jtbdId).add(scenario.id); connectedScenarios.add(scenario.id); } }); } }); // Create a map of scenarios by ID for quick lookup const scenarioMap = {}; relevantScenarios.forEach(scenario => { scenarioMap[scenario.id] = scenario; }); // Filter to include only connected scenarios if we have too many if (relevantScenarios.length > options.maxNodes - stats.nodeCount) { // Prioritize connected scenarios only, remove sorting by priority const prioritizedScenarios = [...relevantScenarios].sort((a, b) => { // Prioritize connected scenarios const aConnected = connectedScenarios.has(a.id); const bConnected = connectedScenarios.has(b.id); if (aConnected !== bConnected) { return aConnected ? -1 : 1; } return 0; // No priority-based sorting }); // Limit to remaining node slots relevantScenarios = prioritizedScenarios.slice(0, options.maxNodes - stats.nodeCount); } // Add nodes for scenarios diagram += ' %% Scenario nodes\n'; relevantScenarios.forEach(scenario => { const safeId = makeIdSafe(scenario.id); const label = formatScenarioLabel(scenario); diagram += ` ${safeId}["${label}"]\n`; stats.nodeCount++; }); diagram += '\n'; // Add class assignments for scenarios diagram += ' %% Scenario styling\n'; relevantScenarios.forEach(scenario => { const safeId = makeIdSafe(scenario.id); diagram += ` class ${safeId} scenario;\n`; }); diagram += '\n'; // Add connections from Layer 1 JTBDs to scenarios diagram += ' %% JTBD to Scenario relationships\n'; // Use the relationship map to create connections jtbdToScenarioMap.forEach((scenarioIds, jtbdId) => { const jtbd = jtbdMap[jtbdId]; if (jtbd && jtbd.level === 1) { // Only connect Layer 1 JTBDs directly to scenarios const jtbdIdSafe = makeIdSafe(jtbdId); scenarioIds.forEach(scenarioId => { if (scenarioMap[scenarioId]) { // Only connect if scenario is in the visualization const scenIdSafe = makeIdSafe(scenarioId); diagram += ` ${jtbdIdSafe} --> ${scenIdSafe}\n`; stats.edgeCount++; } }); } }); } else { // If no valid scenarios, add a note to the diagram if (scenarios && scenarios.length > 0) { diagram += ' %% Note about scenarios\n'; diagram += ' scenarioNote["Scenarios were found in the data, but they lack required details (e.g., IDs or statements)"]\n'; diagram += ' style scenarioNote fill:#ffffcc,stroke:#999,stroke-dasharray: 5 5\n\n'; } else if (!scenarios) { // If no scenarios array at all, add a note diagram += ' %% Note about missing scenarios\n'; diagram += ' scenarioNote["No scenarios data found. To show relationships, include scenarios in the input file."]\n'; diagram += ' style scenarioNote fill:#ffffcc,stroke:#999,stroke-dasharray: 5 5\n\n'; } } return diagram; } /** * Generate a persona-centric Mermaid diagram * @param {Object} data - Input data with JTBDs and scenarios * @param {Object} options - Visualization options * @param {Object} stats - Statistics object to update * @returns {string} Mermaid diagram content */ function generatePersonaCentricView(data, options, stats) { const { scenarios } = data; // Initialize Mermaid diagram let diagram = 'graph TD\n'; diagram += ' %% Persona-centric visualization\n'; diagram += ' %% Generated by PDM-AI tool\n\n'; // Style definitions with matching clean pastel colors diagram += ' %% Node styles\n'; // Pastel purple for personas with light matching border diagram += ' classDef persona fill:#E6BAFF,stroke:#D1ADDB,stroke-width:1px;\n'; // Pastel green for scenarios with light matching border diagram += ' classDef scenario fill:#BAFFC9,stroke:#AAEAB8,stroke-width:1px;\n'; // Edge styling diagram += ' linkStyle default stroke:#888888,stroke-width:1.5px;\n\n'; // Check if scenarios have required properties (id, statement, persona) if (!scenarios || !Array.isArray(scenarios) || scenarios.length === 0) { return generatePlaceholderDiagram('No scenarios found in the input data'); } const validScenarios = scenarios.filter(s => s && s.id && s.statement && s.persona); if (validScenarios.length === 0) { return generatePlaceholderDiagram('No valid scenarios with persona information found'); } // Group scenarios by persona const personaGroups = {}; validScenarios.forEach(scenario => { const persona = scenario.persona || 'Unknown'; if (!personaGroups[persona]) { personaGroups[persona] = []; } personaGroups[persona].push(scenario); }); // Add persona nodes diagram += ' %% Persona nodes\n'; Object.keys(personaGroups).forEach(persona => { const safeId = makeIdSafe(`persona_${persona}`); diagram += ` ${safeId}["${escapeText(persona)}"]\n`; stats.nodeCount++; }); diagram += '\n'; // Add scenario nodes diagram += ' %% Scenario nodes\n'; validScenarios.forEach(scenario => { const safeId = makeIdSafe(scenario.id); const label = formatScenarioLabel(scenario); diagram += ` ${safeId}["${label}"]\n`; stats.nodeCount++; }); diagram += '\n'; // Add connections from personas to scenarios diagram += ' %% Persona to Scenario relationships\n'; Object.entries(personaGroups).forEach(([persona, scenarios]) => { const personaId = makeIdSafe(`persona_${persona}`); scenarios.forEach(scenario => { const scenarioId = makeIdSafe(scenario.id); diagram += ` ${personaId} --> ${scenarioId}\n`; stats.edgeCount++; }); }); diagram += '\n'; // Add styling for nodes diagram += ' %% Node styling\n'; Object.keys(personaGroups).forEach(persona => { const safeId = makeIdSafe(`persona_${persona}`); diagram += ` class ${safeId} persona;\n`; }); validScenarios.forEach(scenario => { const safeId = makeIdSafe(scenario.id); diagram += ` class ${safeId} scenario;\n`; }); return diagram; } /** * Generate a placeholder diagram for views not yet implemented * @param {string} message - Message to display in the diagram * @returns {string} Mermaid diagram content */ function generatePlaceholderDiagram(message) { return `graph TD %% Placeholder diagram A["${message}"] style A fill:#f9f9f9,stroke:#cccccc,stroke-width:1px `; } /** * Generate a Mermaid diagram from input data * @param {Object} data - Input data with JTBDs and scenarios * @param {Object} options - Visualization options * @returns {Object} Result object with diagram content and statistics */ function generateMermaidDiagram(data, options) { // Track stats for the generated diagram const stats = { nodeCount: 0, edgeCount: 0 }; let mermaidContent = ''; // Select the appropriate view type switch (options.view) { case 'jtbd': mermaidContent = generateJtbdCentricView(data, options, stats); break; case 'persona': mermaidContent = generatePersonaCentricView(data, options, stats); break; case 'priority': // For Phase 2, we'll add a placeholder for future implementations mermaidContent = generatePlaceholderDiagram('Priority heat map will be implemented in Phase 2'); break; case 'source': // For Phase 2, we'll add a placeholder for future implementations mermaidContent = generatePlaceholderDiagram('Source attribution view will be implemented in Phase 2'); break; default: mermaidContent = generateJtbdCentricView(data, options, stats); } // Add header comments and timestamp const timestamp = new Date().toISOString(); const finalMermaidContent = `--- title: PDM-AI Visualization date: ${timestamp} --- \`\`\`mermaid ${mermaidContent} \`\`\``; return { content: finalMermaidContent, stats }; } const mermaidGenerator = { generateMermaidDiagram }; export default mermaidGenerator;