Created
September 5, 2024 08:48
-
-
Save matt212/4e208a3f28b154f270ad0e05e4b0f37d to your computer and use it in GitHub Desktop.
JSONCanvas to Mermaid config
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Validates the structure and content of JSON Canvas data. | |
* @param {Object} data - The JSON Canvas data to validate. | |
* @throws {Error} If the data or structure is invalid. | |
*/ | |
function validateJsonCanvasData(data) { | |
if (typeof data !== 'object' || data === null) { | |
throw new Error('Invalid data: must be a non-null object'); | |
} | |
if (!Array.isArray(data.nodes)) { | |
throw new Error('Invalid data: nodes must be an array'); | |
} | |
if (!Array.isArray(data.edges)) { | |
throw new Error('Invalid data: edges must be an array'); | |
} | |
// Validate nodes | |
const nodeIds = new Set(); | |
data.nodes.forEach((node, index) => { | |
if (typeof node !== 'object' || node === null) { | |
throw new Error(`Invalid node at index ${index}: must be a non-null object`); | |
} | |
if (typeof node.id !== 'string' || node.id.trim() === '') { | |
throw new Error(`Invalid node at index ${index}: id must be a non-empty string`); | |
} | |
if (nodeIds.has(node.id)) { | |
throw new Error(`Duplicate node id: ${node.id}`); | |
} | |
nodeIds.add(node.id); | |
if (!['text', 'file', 'link', 'group'].includes(node.type)) { | |
throw new Error(`Invalid node type at index ${index}: ${node.type}`); | |
} | |
if ( | |
typeof node.x !== 'number' || | |
typeof node.y !== 'number' || | |
typeof node.width !== 'number' || | |
typeof node.height !== 'number' | |
) { | |
throw new Error(`Invalid node dimensions at index ${index}`); | |
} | |
if (node.color && typeof node.color !== 'string') { | |
throw new Error(`Invalid node color at index ${index}: must be a string`); | |
} | |
// Type-specific validations | |
switch (node.type) { | |
case 'text': | |
if (typeof node.text !== 'string') { | |
throw new Error(`Invalid text node at index ${index}: text must be a string`); | |
} | |
break; | |
case 'file': | |
if (typeof node.file !== 'string' || node.file.trim() === '') { | |
throw new Error(`Invalid file node at index ${index}: file must be a non-empty string`); | |
} | |
if (node.subpath && typeof node.subpath !== 'string') { | |
throw new Error(`Invalid file node at index ${index}: subpath must be a string`); | |
} | |
break; | |
case 'link': | |
if (typeof node.url !== 'string' || node.url.trim() === '') { | |
throw new Error(`Invalid link node at index ${index}: url must be a non-empty string`); | |
} | |
break; | |
case 'group': | |
if (node.label && typeof node.label !== 'string') { | |
throw new Error(`Invalid group node at index ${index}: label must be a string`); | |
} | |
break; | |
} | |
}); | |
// Validate edges | |
data.edges.forEach((edge, index) => { | |
if (typeof edge !== 'object' || edge === null) { | |
throw new Error(`Invalid edge at index ${index}: must be a non-null object`); | |
} | |
if (typeof edge.id !== 'string' || edge.id.trim() === '') { | |
throw new Error(`Invalid edge at index ${index}: id must be a non-empty string`); | |
} | |
if (!nodeIds.has(edge.fromNode) || !nodeIds.has(edge.toNode)) { | |
throw new Error(`Invalid edge at index ${index}: fromNode or toNode does not exist`); | |
} | |
if (edge.fromSide && !['top', 'right', 'bottom', 'left'].includes(edge.fromSide)) { | |
throw new Error(`Invalid edge fromSide at index ${index}: ${edge.fromSide}`); | |
} | |
if (edge.toSide && !['top', 'right', 'bottom', 'left'].includes(edge.toSide)) { | |
throw new Error(`Invalid edge toSide at index ${index}: ${edge.toSide}`); | |
} | |
if (edge.fromEnd && !['none', 'arrow'].includes(edge.fromEnd)) { | |
throw new Error(`Invalid edge fromEnd at index ${index}: ${edge.fromEnd}`); | |
} | |
if (edge.toEnd && !['none', 'arrow'].includes(edge.toEnd)) { | |
throw new Error(`Invalid edge toEnd at index ${index}: ${edge.toEnd}`); | |
} | |
if (edge.color && typeof edge.color !== 'string') { | |
throw new Error(`Invalid edge color at index ${index}: must be a string`); | |
} | |
if (edge.label && typeof edge.label !== 'string') { | |
throw new Error(`Invalid edge label at index ${index}: must be a string`); | |
} | |
}); | |
} | |
/** | |
* Validates the custom colors object. | |
* @param {Object} customColors - The custom colors object to validate. | |
* @throws {Error} If the custom colors are invalid. | |
*/ | |
function validateCustomColors(customColors) { | |
if (typeof customColors !== 'object' || customColors === null) { | |
throw new Error('Invalid customColors: must be a non-null object'); | |
} | |
if (Object.keys(customColors).length > 6) { | |
throw new Error('Invalid customColors: maximum of 6 colors allowed'); | |
} | |
for (const [key, value] of Object.entries(customColors)) { | |
if (!/^[1-6]$/.test(key)) { | |
throw new Error(`Invalid color key: ${key}. Must be a number from 1 to 6.`); | |
} | |
if (typeof value !== 'string' || !/^#[0-9A-Fa-f]{6}$/.test(value)) { | |
throw new Error( | |
`Invalid color value for key ${key}: ${value}. Must be a valid hex color code.` | |
); | |
} | |
} | |
} | |
/** | |
* Validates the custom direction parameter. | |
* @param {string} graphDirection - The graph direction to validate. | |
* @throws {Error} If the graph direction is invalid. | |
*/ | |
function validateGraphDirection(graphDirection) { | |
const validDirections = ['TB', 'LR', 'BT', 'RL']; | |
if (!validDirections.includes(graphDirection)) { | |
throw new Error( | |
`Invalid graph direction ${graphDirection}. Only "TB", "LR", "BT", and "RL" are allowed.` | |
); | |
} | |
} | |
// ========== CREATE NODE HIERARCHY ========== | |
/** | |
* Builds a hierarchical structure from JSON Canvas data by assigning children to group nodes. | |
* | |
* @param {Object} data - The JSON Canvas data object. | |
* @param {Array} data.nodes - An array of nodes from the JSON Canvas data. | |
* @param {Array} data.edges - An array of edges from the JSON Canvas data. | |
* @returns {Object} The hierarchical structure object. | |
* @returns {Array} output.nodes - An array of nodes with the `children` property added. | |
* @returns {Array} output.edges - An array of edges (remains unchanged from the input). | |
* | |
* @description | |
* This function creates a parent-child hierarchy for nodes based on their spatial relationships: | |
* - Group nodes can contain other nodes (including other groups). | |
* - A node is considered a child of a group if its center point is within the group's bounds. | |
* - Each node can have only one parent group. | |
* - Non-group nodes have their 'children' property set to null. | |
* - The function preserves the original edge data. | |
*/ | |
function createNodeTree(data) { | |
validateJsonCanvasData(data); | |
function isPointInsideGroup(point, group) { | |
const { x, y, width, height } = group; | |
return point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height; | |
} | |
function findMidpoint(node) { | |
return { | |
x: node.x + node.width / 2, | |
y: node.y + node.height / 2, | |
}; | |
} | |
function sortGroupsByArea(nodes) { | |
return nodes.slice().sort((a, b) => { | |
if (a.type !== 'group' || b.type !== 'group') return 0; | |
const areaA = a.width * a.height; | |
const areaB = b.width * b.height; | |
return areaA - areaB; | |
}); | |
} | |
const sortedNodes = sortGroupsByArea(data.nodes); | |
const output = { | |
nodes: [], | |
edges: [...data.edges], | |
}; | |
sortedNodes.forEach((node) => { | |
output.nodes.push({ | |
...node, | |
children: node.type === 'group' ? [] : null, | |
}); | |
}); | |
const nodeMap = output.nodes.reduce((acc, node) => { | |
acc[node.id] = node; | |
return acc; | |
}, {}); | |
output.nodes.forEach((node, index) => { | |
if (node.type === 'group') { | |
const midpoint = findMidpoint(node); | |
for (let i = index + 1; i < sortedNodes.length; i++) { | |
const potentialParent = sortedNodes[i]; | |
if (potentialParent.type !== 'group') continue; | |
if (isPointInsideGroup(midpoint, potentialParent)) { | |
nodeMap[potentialParent.id].children.push(node.id); | |
break; | |
} | |
} | |
} else { | |
const nodeCenter = findMidpoint(node); | |
for (let i = 0; i < sortedNodes.length; i++) { | |
const potentialParent = sortedNodes[i]; | |
if (potentialParent.type !== 'group') continue; | |
if (isPointInsideGroup(nodeCenter, potentialParent)) { | |
nodeMap[potentialParent.id].children.push(node.id); | |
break; | |
} | |
} | |
} | |
}); | |
return output; | |
} | |
// ========== CREATE MERMAID SYNTAX ========== | |
/** | |
* Generates a Mermaid Flowchart syntax based on the provided JSON Canvas data. | |
* | |
* @param {Object} data - The JSON Canvas data object containing nodes and edges. | |
* @param {Object} [customColors={}] - Optional custom color mapping for nodes and edges. | |
* Keys are color identifiers, values are hex color codes. Maximum of 6 colors. | |
* Example: { 1: '#ff0000', 2: '#00ff00', 3: '#0000ff' } | |
* @param {string} [graphDirection='TB'] - Optional direction of the graph. | |
* Valid options are: 'TB' (top to bottom), 'LR' (left to right), | |
* 'BT' (bottom to top), 'RL' (right to left). | |
* @returns {string} The generated Mermaid Flowchart syntax. | |
* @throws {Error} If an invalid graph direction is provided. | |
* | |
* @description | |
* This function converts JSON Canvas data into Mermaid Flowchart syntax: | |
* - Supports various node types: text, file, link, and group. | |
* - Handles nested group structures. | |
* - Applies custom colors to nodes and edges if provided. | |
* - Generates appropriate syntax for different edge types and labels. | |
* - The output can be used directly with Mermaid to render a flowchart. | |
*/ | |
function convertToMermaid(data, customColors = {}, graphDirection = 'TB') { | |
// Validate parameters | |
// The data parameter is validated in the createNodeTree function so we don't need to validate it here. | |
validateCustomColors(customColors); | |
validateGraphDirection(graphDirection); | |
// ========== COLOR GENERATION ========== | |
// Adds custom colors to the default color map if provided. | |
function createColorMap(customColors) { | |
const defaultColorMap = { | |
1: '#fb464c', // red | |
2: '#e9973f', // orange | |
3: '#e0de71', // yellow | |
4: '#44cf6e', // green | |
5: '#53dfdd', // cyan | |
6: '#a882ff', // purple | |
}; | |
const colorMap = { ...defaultColorMap, ...customColors }; | |
return colorMap; | |
} | |
const colorMap = createColorMap(customColors); | |
// Helper function to get the color based on the custom color map | |
function getColor(color) { | |
return colorMap[color] || color; | |
} | |
//Bu8ild new data with children arrays | |
const hierarchicalData = createNodeTree(data); | |
// ========== GENERATE MERMAID CODE ========== | |
// Uses the hierarchical data to generate the Mermaid Flowchart syntax | |
function generateMermaidFlowchart(data) { | |
const { nodes, edges } = data; | |
// This will store styles for nodes and edges/lines for use later | |
let graphStyles = ''; | |
// Helper function to generate Mermaid Flowchart syntax for a node | |
function generateNodeSyntax(node) { | |
const { id, type, label, text, file, subpath, url, color } = node; | |
// Add styling for node | |
generateNodeStyle(node); | |
if (type === 'group') { | |
// Handle empty group label | |
let newGroupLabel; | |
if (label === '') { | |
newGroupLabel = ' '; | |
} else { | |
newGroupLabel = label; | |
} | |
return `subgraph ${id}["${newGroupLabel}"]\n${generateSubgraphSyntax(node)}\nend\n`; | |
} else if (type === 'text') { | |
//Handle empty node label | |
let newText; | |
if (text === '') { | |
newText = ' '; | |
} else { | |
newText = text; | |
} | |
return `${id}["${newText}"]\n`; | |
} else if (type === 'file') { | |
const fileLabel = subpath ? `${file}${subpath}` : file; | |
return `${id}["${fileLabel}"]\n`; | |
} else if (type === 'link') { | |
return `${id}["${url}"]\n`; | |
} | |
return ''; | |
} | |
// Helper function to generate Mermaid Flowchart syntax for a subgraph | |
function generateSubgraphSyntax(node) { | |
const { children } = node; | |
let syntax = ''; | |
if (children && children.length > 0) { | |
for (const childId of children) { | |
const childNode = nodes.find((n) => n.id === childId); | |
if (childNode) { | |
syntax += generateNodeSyntax(childNode); | |
} | |
} | |
} | |
return syntax; | |
} | |
// Helper function to generate Mermaid Flowchart syntax for an edge | |
function generateEdgeSyntax(edge) { | |
const { fromNode, toNode, fromEnd = 'none', toEnd = 'arrow', label } = edge; | |
generateEdgeStyle(edge); | |
const arrowStyleMap = { | |
'none-arrow': '-->', | |
'arrow-none': '<--', | |
'arrow-arrow': '<-->', | |
'none-none': '---', | |
}; | |
const arrowStyle = arrowStyleMap[`${fromEnd}-${toEnd}`] || '---'; | |
// check if lable exists | |
const edgeLabel = label ? ` |${label}|` : ''; | |
return `${fromNode} ${arrowStyle}${edgeLabel} ${toNode}\n`; | |
} | |
// Helper function to push brightness of hex colors around | |
function adjustBrightness(hex, percent) { | |
// Remove the '#' character if present | |
hex = hex.replace('#', ''); | |
// Convert the hex color to RGB | |
let r = parseInt(hex.substring(0, 2), 16); | |
let g = parseInt(hex.substring(2, 4), 16); | |
let b = parseInt(hex.substring(4, 6), 16); | |
// Adjust the brightness by the specified percentage | |
const amount = Math.round(2.55 * percent); | |
r = Math.max(0, Math.min(255, r + amount)); | |
g = Math.max(0, Math.min(255, g + amount)); | |
b = Math.max(0, Math.min(255, b + amount)); | |
// Convert the RGB values back to hex | |
const rr = r.toString(16).padStart(2, '0'); | |
const gg = g.toString(16).padStart(2, '0'); | |
const bb = b.toString(16).padStart(2, '0'); | |
return `#${rr}${gg}${bb}`; | |
} | |
// Helper function to generate Mermaid Styling for a node | |
function generateNodeStyle(node) { | |
const { id, color, type } = node; | |
// Check to see if color exists | |
if (!color) { | |
return; | |
} | |
const nodeColor = getColor(color); | |
const nodeColorALT = adjustBrightness(nodeColor, -20); | |
let nodeStyle = `style ${id} fill:${nodeColor}, stroke:${nodeColorALT}\n`; | |
graphStyles += nodeStyle; | |
} | |
// Helper function to generate Mermaid Styling for an edge | |
let edgeCounter = 0; | |
function generateEdgeStyle(edge) { | |
const { color } = edge; | |
// Check to see if color exists | |
if (!color) { | |
edgeCounter++; | |
return; | |
} | |
const edgeColor = getColor(color); | |
let edgeStyle = `linkStyle ${edgeCounter} stroke:${edgeColor}\n`; | |
edgeCounter++; | |
graphStyles += edgeStyle; | |
} | |
// Start writing graph syntax | |
let flowchartSyntax = `graph ${graphDirection}\n`; | |
// Generate Mermaid Flowchart syntax for each node | |
for (const node of nodes) { | |
flowchartSyntax += generateNodeSyntax(node); | |
} | |
// Generate Mermaid Flowchart syntax for each edge/line | |
for (const edge of edges) { | |
flowchartSyntax += generateEdgeSyntax(edge); | |
} | |
// Add generated styles at the end | |
flowchartSyntax += graphStyles; | |
return flowchartSyntax; | |
} | |
const mermaidFlowchart = generateMermaidFlowchart(hierarchicalData); | |
return mermaidFlowchart; | |
} | |
// USAGE | |
const jsonCanvasData = { | |
nodes: [ | |
{ id: 'node1', type: 'text', text: 'Hello', x: 0, y: 0, width: 100, height: 50 }, | |
{ id: 'node2', type: 'text', text: 'World', x: 200, y: 0, width: 100, height: 50 }, | |
], | |
edges: [{ id: 'edge1', fromNode: 'node1', toNode: 'node2' }], | |
}; | |
const mermaidSyntax = convertToMermaid(jsonCanvasData); | |
console.log(mermaidSyntax); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment