Skip to content

Instantly share code, notes, and snippets.

@matt212
Created September 5, 2024 08:48
Show Gist options
  • Save matt212/4e208a3f28b154f270ad0e05e4b0f37d to your computer and use it in GitHub Desktop.
Save matt212/4e208a3f28b154f270ad0e05e4b0f37d to your computer and use it in GitHub Desktop.
JSONCanvas to Mermaid config
/**
* 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