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
JavaScript
/**
* 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;