Skip to content

Instantly share code, notes, and snippets.

@ursisterbtw
Last active April 17, 2025 20:06
Show Gist options
  • Save ursisterbtw/19417b96831df086953bf5f451c8c380 to your computer and use it in GitHub Desktop.
Save ursisterbtw/19417b96831df086953bf5f451c8c380 to your computer and use it in GitHub Desktop.
flowchart generation in .dot & .svg formats with .png as a fallback
#!/usr/bin/env python3
import argparse
import logging
import os
import re
import shutil
import subprocess
import sys
import uuid
from pathlib import Path
from typing import Dict, List, Optional, Set
import pydot # type: ignore
# --- Configuration ---
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger(__name__)
# Default directories/files to exclude from scanning
DEFAULT_EXCLUDE_DIRS: Set[str] = {
".git",
"__pycache__",
".DS_Store",
"node_modules",
".venv",
"venv",
"env",
"build",
"dist",
"target",
"*.egg-info",
"cache",
".cache",
"secrets",
".idea", # IDE specific
".vscode", # IDE specific
}
# Styling constants
NEON_COLORS: List[str] = [
"#00ff99",
"#00ffcc",
"#33ccff",
"#ff00cc",
"#ff3366",
"#ffff00",
]
BG_COLOR = "#121212"
NODE_FILL_COLOR = "#1a1a1a"
DEFAULT_NODE_COLOR = "#00ff99"
DEFAULT_NODE_FONT_COLOR = "#00ffcc"
DEFAULT_EDGE_COLOR = "#ff00cc"
# --- Core Logic ---
def scan_directory(
path: Path,
max_depth: int = 5,
current_depth: int = 0,
exclude_dirs: Optional[Set[str]] = None,
) -> Dict[str, Optional[Dict]]:
"""
Recursively scans a directory using os.scandir() for better performance
and returns its structure as a dictionary.
Args:
path: The directory path (Path object) to scan.
max_depth: Maximum recursion depth.
current_depth: Current recursion depth (used internally).
exclude_dirs: A set of directory/file names to exclude.
Returns:
A dictionary representing the directory structure. Files are keys
with None values, directories are keys with nested dictionaries.
Special entries like '...' indicate depth limit reached, or
permission/error messages.
"""
if exclude_dirs is None:
exclude_dirs = DEFAULT_EXCLUDE_DIRS
if current_depth >= max_depth:
return {"... (max depth reached)": None}
structure: Dict[str, Optional[Dict]] = {}
try:
# Use os.scandir for better performance
for entry in os.scandir(path):
if entry.name in exclude_dirs:
continue
# Skip symlinks gracefully to avoid cycles and potential errors
if entry.is_symlink():
structure[f"{entry.name} (symlink)"] = None
continue
try:
if entry.is_dir(follow_symlinks=False):
# Recurse into subdirectory
structure[entry.name] = scan_directory(
Path(entry.path), max_depth, current_depth + 1, exclude_dirs
)
elif entry.is_file(follow_symlinks=False):
structure[entry.name] = None
# Silently ignore other types like block devices, sockets etc.
# Or add specific handling if needed:
# else:
# structure[f"{entry.name} (type: unknown)"] = None
except OSError as e:
log.warning(f"Could not access metadata for {entry.path}: {e}")
structure[f"{entry.name} (access error)"] = None
except PermissionError:
log.warning(f"Permission denied accessing directory: {path}")
return {"Permission denied": None}
except FileNotFoundError:
log.error(f"Directory not found: {path}")
return {"Not found": None}
except OSError as e:
log.error(f"OS error scanning directory {path}: {e}")
return {f"Error: {e}": None}
return structure
def create_graph(
structure: Dict[str, Optional[Dict]],
parent_id: Optional[str] = None,
graph: Optional[pydot.Dot] = None,
) -> pydot.Dot:
"""
Recursively creates a pydot graph from the directory structure dictionary.
Args:
structure: The directory structure dictionary from scan_directory.
parent_id: The UUID of the parent node in the graph (used internally).
graph: The pydot graph instance (used internally).
Returns:
The completed pydot graph.
"""
if graph is None:
# Initialize the graph with defaults
graph = pydot.Dot(
graph_type="digraph",
rankdir="LR", # Left-to-right layout
bgcolor=BG_COLOR,
fontname="Arial", # Default font for labels if not overridden
fontsize="12", # Default font size
)
graph.set_node_defaults(
style="filled,rounded",
fillcolor=NODE_FILL_COLOR,
fontcolor=DEFAULT_NODE_FONT_COLOR,
fontname="Arial",
fontsize="12",
penwidth="1.5",
color=DEFAULT_NODE_COLOR, # Default border color
)
graph.set_edge_defaults(
color=DEFAULT_EDGE_COLOR,
penwidth="1.2",
)
# Sort items for consistent graph layout (optional but nice)
sorted_items = sorted(structure.items())
for key, value in sorted_items:
# Generate a unique and safe ID for each node
# Using UUID ensures valid DOT identifiers regardless of the key content
node_id = f"node_{uuid.uuid4().hex[:10]}" # Use slightly longer UUID part
# Escape double quotes and backslashes in labels for DOT compatibility
# pydot usually handles this, but explicit escaping is safer
escaped_label = key.replace("\\", "\\\\").replace('"', '\\"')
# Determine node shape and potentially color
is_dir = isinstance(value, dict)
shape = "box" if is_dir else "ellipse"
# Vary node border color slightly based on name hash
color_index = hash(key) % len(NEON_COLORS)
node_color = NEON_COLORS[color_index]
node = pydot.Node(
node_id,
label=f'"{escaped_label}"', # Ensure label is quoted
shape=shape,
color=node_color, # Border color
# other attributes inherit from graph defaults (fillcolor, fontcolor etc.)
)
graph.add_node(node)
if parent_id:
edge = pydot.Edge(parent_id, node_id)
graph.add_edge(edge)
# Recurse if it's a directory (value is a dictionary)
if is_dir and value: # Check if value is a non-empty dict
create_graph(value, node_id, graph)
return graph
SVG_ANIMATION_CSS = f"""
<style type="text/css">
<![CDATA[
/* Overall SVG background */
svg {{
background-color: {BG_COLOR};
}}
/* Node styling and animation */
.node {{
transition: transform 0.3s ease-in-out, filter 0.4s ease-in-out;
animation: fadeIn 0.5s ease-out forwards, pulseGlow 4s infinite alternate;
opacity: 0; /* Start transparent for fadeIn */
filter: drop-shadow(0 0 4px rgba(0, 255, 153, 0.6));
}}
.node:hover {{
transform: scale(1.1);
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 1));
cursor: pointer;
}}
/* Edge styling and animation */
.edge {{
stroke-dasharray: 8, 4; /* Dashed line pattern */
animation: dash 10s linear infinite, glow 3s infinite alternate;
}}
.edge path {{
stroke: {DEFAULT_EDGE_COLOR}; /* Ensure path color is set */
}}
.edge polygon {{
fill: {DEFAULT_EDGE_COLOR}; /* Ensure arrowhead color is set */
stroke: {DEFAULT_EDGE_COLOR};
}}
/* Text styling within nodes */
.node text {{
fill: {DEFAULT_NODE_FONT_COLOR} !important; /* Ensure high specificity */
font-weight: bold;
pointer-events: none; /* Prevent text from blocking node hover */
}}
/* Keyframe animations */
@keyframes fadeIn {{
to {{ opacity: 1; }}
}}
@keyframes pulseGlow {{
0% {{ filter: drop-shadow(0 0 4px rgba(0, 255, 153, 0.6)); opacity: 0.9; }}
50% {{ filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.8)); }}
100% {{ filter: drop-shadow(0 0 6px rgba(0, 255, 204, 0.9)); opacity: 1; }}
}}
@keyframes glow {{
from {{ stroke-opacity: 0.7; filter: drop-shadow(0 0 2px {DEFAULT_EDGE_COLOR}); }}
to {{ stroke-opacity: 1; filter: drop-shadow(0 0 4px {DEFAULT_EDGE_COLOR}); }}
}}
@keyframes dash {{
to {{ stroke-dashoffset: -100; }} /* Adjust value based on dasharray */
}}
]]>
</style>
"""
def add_svg_animation(svg_content: str) -> str:
"""
Injects CSS animations and classes into the SVG content.
Args:
svg_content: The original SVG content as a string.
Returns:
The modified SVG content with animations.
"""
# Improved regex that properly preserves whitespace between attributes
# Check if class already exists first
svg_content = re.sub(
r'(<g\s+id="node\d+")(?!\s+class=")', # Match node without class
r'\1 class="node"', # Add class with proper space
svg_content,
flags=re.IGNORECASE,
)
# Same fix for edge groups
svg_content = re.sub(
r'(<g\s+id="edge\d+")(?!\s+class=")', # Match edge without class
r'\1 class="edge"', # Add class with proper space
svg_content,
flags=re.IGNORECASE,
)
if svg_tag_match := re.search(r"<svg[^>]*>", svg_content, re.IGNORECASE):
insert_pos = svg_tag_match.end()
# Inject the CSS styles right after the opening <svg> tag
return svg_content[:insert_pos] + SVG_ANIMATION_CSS + svg_content[insert_pos:]
else:
log.warning("Could not find <svg> tag to inject CSS animations.")
return svg_content # Return unmodified content if tag not found
def check_dependencies() -> bool:
"""Checks if the 'dot' executable (Graphviz) is available."""
dot_path = shutil.which("dot")
if not dot_path:
log.error("Graphviz 'dot' command not found in PATH.")
log.error("Please install Graphviz: https://graphviz.org/download/")
return False
log.info(f"Found 'dot' executable at: {dot_path}")
return True
def run_dot(dot_file: Path, svg_file: Path) -> bool:
"""Runs the Graphviz 'dot' command to convert DOT to SVG."""
command = [
"dot",
"-Tsvg",
str(dot_file),
"-o",
str(svg_file),
]
log.info(f"Running command: {' '.join(command)}")
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False, # Don't raise exception on non-zero exit code immediately
encoding="utf-8",
)
if result.returncode != 0:
log.error(f"'dot' command failed with exit code {result.returncode}")
log.error(f"Stderr:\n{result.stderr}")
log.error(f"Stdout:\n{result.stdout}")
return False
log.info(f"'dot' command completed successfully. SVG saved to {svg_file}")
return True
except FileNotFoundError:
log.error("Failed to run 'dot' command. Is Graphviz installed and in PATH?")
return False
except Exception as e:
log.exception(f"An unexpected error occurred while running 'dot': {e}")
return False
def main():
parser = argparse.ArgumentParser(
description="Generate an animated SVG flowchart of a directory structure.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"repo_path",
nargs="?",
default=os.getcwd(),
help="Path to the directory (repository root) to scan.",
type=Path,
)
parser.add_argument(
"-o",
"--output",
default="flowchart.svg",
help="Output SVG file name.",
type=Path,
)
parser.add_argument(
"--dot-output",
default="flowchart.dot",
help="Intermediate DOT file name.",
type=Path,
)
parser.add_argument(
"-d",
"--max-depth",
type=int,
default=4,
help="Maximum depth to scan directories.",
)
parser.add_argument(
"-e",
"--exclude",
nargs="*",
default=list(DEFAULT_EXCLUDE_DIRS),
help="Directory or file names to exclude.",
)
parser.add_argument(
"--no-animation",
action="store_true",
help="Generate a static SVG without animations.",
)
args = parser.parse_args()
# Convert exclude list to set for efficient lookup
exclude_set = set(args.exclude)
# --- Dependency Check ---
if not check_dependencies():
sys.exit(1)
# --- Directory Scanning ---
if not args.repo_path.is_dir():
log.error(f"Input path is not a valid directory: {args.repo_path}")
sys.exit(1)
log.info(
f"Scanning directory: {args.repo_path.resolve()} up to depth {args.max_depth}"
)
log.info(f"Excluding: {', '.join(sorted(exclude_set))}")
directory_structure = scan_directory(
args.repo_path, max_depth=args.max_depth, exclude_dirs=exclude_set
)
if not directory_structure:
log.warning("Scan returned an empty structure. No graph will be generated.")
sys.exit(0)
# --- Graph Creation ---
log.info("Generating flowchart graph...")
try:
graph = create_graph(directory_structure)
except Exception as e:
log.exception(f"Failed to create graph structure: {e}")
sys.exit(1)
# --- DOT File Generation ---
log.info(f"Saving DOT representation to {args.dot_output}...")
try:
# Use write_raw for potentially better compatibility, ensure encoding
with open(args.dot_output, "w", encoding="utf-8") as f:
# pydot's to_string() can sometimes be more reliable than write() methods
dot_string = graph.to_string()
f.write(dot_string)
log.info(f"DOT file saved successfully to {args.dot_output}")
except IOError as e:
log.error(f"Failed to write DOT file {args.dot_output}: {e}")
sys.exit(1)
except Exception as e:
# Catch potential pydot errors during string conversion/writing
log.exception(f"An error occurred writing the DOT file: {e}")
sys.exit(1)
# --- SVG Generation ---
log.info("Generating SVG flowchart using 'dot'...")
if not run_dot(args.dot_output, args.output):
log.error(
"Failed to generate SVG file. Check Graphviz installation and DOT file validity."
)
sys.exit(1)
# --- SVG Animation ---
if not args.no_animation:
log.info("Adding animations to SVG...")
try:
with open(args.output, "r", encoding="utf-8") as f:
svg_content = f.read()
animated_svg = add_svg_animation(svg_content)
with open(args.output, "w", encoding="utf-8") as f:
f.write(animated_svg)
log.info(f"Animated flowchart saved as {args.output.resolve()}")
except FileNotFoundError:
# This shouldn't happen if run_dot succeeded, but check anyway
log.error(f"SVG file {args.output} not found after generation step.")
sys.exit(1)
except IOError as e:
log.error(
f"Failed to read or write SVG file {args.output} for animation: {e}"
)
sys.exit(1)
except Exception as e:
log.exception(
f"An unexpected error occurred during SVG animation processing: {e}"
)
sys.exit(1)
else:
log.info(f"Static flowchart saved as {args.output.resolve()}")
log.info("Process completed successfully.")
def entry_point():
main()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
import argparse
import logging
import os
import re
import shutil
import subprocess
import sys
import uuid
import webbrowser
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple, Any
import pydot # type: ignore
from tqdm import tqdm # type: ignore
# --- Configuration ---
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger(__name__)
# Default directories/files to exclude from scanning
DEFAULT_EXCLUDE_DIRS: Set[str] = {
".git",
"__pycache__",
".DS_Store",
"node_modules",
".venv",
"venv",
"env",
"build",
"dist",
"target",
"*.egg-info", # Python package metadata
"cache",
".cache",
"secrets",
".idea", # IDE specific
".vscode", # IDE specific
".trunk",
".mypy_cache",
}
# Styling constants
NEON_COLORS: List[str] = [
"#00ff99",
"#00ffcc",
"#33ccff",
"#ff00cc",
"#ff3366",
"#ffff00",
]
BG_COLOR = "#121212"
NODE_FILL_COLOR = "#1a1a1a"
DEFAULT_NODE_COLOR = "#00ff99"
DEFAULT_NODE_FONT_COLOR = "#00ffcc"
DEFAULT_EDGE_COLOR = "#32CD32" # Changed to lime green
# --- Core Logic ---
def should_exclude(entry_name: str, exclude_patterns: Set[str]) -> bool:
"""
Check if a file or directory should be excluded based on patterns.
Args:
entry_name: Name of the file or directory
exclude_patterns: Set of patterns to match against
Returns:
True if the entry should be excluded
"""
# Direct matches
if entry_name in exclude_patterns:
return True
# Wildcard pattern matching
for pattern in exclude_patterns:
if '*' in pattern and re.match(f"^{pattern.replace('*', '.*')}$", entry_name):
return True
return False
def scan_directory_parallel(
path: Path,
max_depth: int = 5,
current_depth: int = 0,
exclude_dirs: Optional[Set[str]] = None,
max_workers: int = 4,
) -> Dict[str, Optional[Dict]]:
"""
Recursively scans a directory with parallel processing for subdirectories.
Args:
path: The directory path to scan
max_depth: Maximum recursion depth
current_depth: Current depth (used internally)
exclude_dirs: Directories/files to exclude
max_workers: Maximum number of parallel workers
Returns:
Directory structure as nested dictionaries
"""
if exclude_dirs is None:
exclude_dirs = DEFAULT_EXCLUDE_DIRS
if current_depth >= max_depth:
return {"... (max depth reached)": None}
structure: Dict[str, Optional[Dict]] = {}
try:
# Collect directory entries first
entries = []
for entry in os.scandir(path):
if should_exclude(entry.name, exclude_dirs):
continue
# Handle symlinks
if entry.is_symlink():
structure[f"{entry.name} (symlink)"] = None
continue
entries.append(entry)
# Process directories in parallel if we're not too deep
if current_depth < 2 and len(entries) > 5: # Only parallelize larger dirs near the top
dirs_to_process = []
# Separate files (process directly) and directories (process in parallel)
for entry in entries:
try:
if not entry.is_dir(follow_symlinks=False):
if entry.is_file(follow_symlinks=False):
structure[entry.name] = None
else:
dirs_to_process.append((entry.name, Path(entry.path)))
except OSError as e:
log.warning(f"Could not access metadata for {entry.path}: {e}")
structure[f"{entry.name} (access error)"] = None
# Process directories in parallel
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Create a dictionary of futures
future_to_dir = {
executor.submit(
scan_directory_parallel,
dir_path,
max_depth,
current_depth + 1,
exclude_dirs,
max_workers
): dir_name
for dir_name, dir_path in dirs_to_process
}
# Process results as they complete
for future in future_to_dir:
dir_name = future_to_dir[future]
try:
structure[dir_name] = future.result()
except Exception as e:
log.error(f"Error processing directory {dir_name}: {e}")
structure[f"{dir_name} (error)"] = None
else:
# Process sequentially for smaller directories or deeper levels
for entry in entries:
try:
if entry.is_dir(follow_symlinks=False):
structure[entry.name] = scan_directory_parallel(
Path(entry.path),
max_depth,
current_depth + 1,
exclude_dirs,
max_workers
)
elif entry.is_file(follow_symlinks=False):
structure[entry.name] = None
except OSError as e:
log.warning(f"Could not access metadata for {entry.path}: {e}")
structure[f"{entry.name} (access error)"] = None
except PermissionError:
log.warning(f"Permission denied accessing directory: {path}")
return {"Permission denied": None}
except FileNotFoundError:
log.error(f"Directory not found: {path}")
return {"Not found": None}
except OSError as e:
log.error(f"OS error scanning directory {path}: {e}")
return {f"Error: {e}": None}
return structure
def scan_directory(
path: Path,
max_depth: int = 5,
current_depth: int = 0,
exclude_dirs: Optional[Set[str]] = None,
) -> Dict[str, Optional[Dict]]:
"""
Recursively scans a directory using os.scandir() for better performance
and returns its structure as a dictionary.
Args:
path: The directory path (Path object) to scan.
max_depth: Maximum recursion depth.
current_depth: Current recursion depth (used internally).
exclude_dirs: A set of directory/file names to exclude.
Returns:
A dictionary representing the directory structure. Files are keys
with None values, directories are keys with nested dictionaries.
Special entries like '...' indicate depth limit reached, or
permission/error messages.
"""
if exclude_dirs is None:
exclude_dirs = DEFAULT_EXCLUDE_DIRS
if current_depth >= max_depth:
return {"... (max depth reached)": None}
structure: Dict[str, Optional[Dict]] = {}
try:
# Use os.scandir for better performance
for entry in os.scandir(path):
if should_exclude(entry.name, exclude_dirs):
continue
# Skip symlinks gracefully to avoid cycles and potential errors
if entry.is_symlink():
structure[f"{entry.name} (symlink)"] = None
continue
try:
if entry.is_dir(follow_symlinks=False):
# Recurse into subdirectory
structure[entry.name] = scan_directory(
Path(entry.path), max_depth, current_depth + 1, exclude_dirs
)
elif entry.is_file(follow_symlinks=False):
structure[entry.name] = None
# Silently ignore other types like block devices, sockets etc.
# Or add specific handling if needed:
# else:
# structure[f"{entry.name} (type: unknown)"] = None
except OSError as e:
log.warning(f"Could not access metadata for {entry.path}: {e}")
structure[f"{entry.name} (access error)"] = None
except PermissionError:
log.warning(f"Permission denied accessing directory: {path}")
return {"Permission denied": None}
except FileNotFoundError:
log.error(f"Directory not found: {path}")
return {"Not found": None}
except OSError as e:
log.error(f"OS error scanning directory {path}: {e}")
return {f"Error: {e}": None}
return structure
def create_graph(
structure: Dict[str, Optional[Dict]],
parent_id: Optional[str] = None,
graph: Optional[pydot.Dot] = None,
color_scheme: Optional[Dict[str, str]] = None,
) -> pydot.Dot:
"""
Recursively creates a pydot graph from the directory structure dictionary.
Args:
structure: The directory structure dictionary from scan_directory.
parent_id: The UUID of the parent node in the graph (used internally).
graph: The pydot graph instance (used internally).
color_scheme: Optional custom color scheme dictionary.
Returns:
The completed pydot graph.
"""
# Use default colors if not specified
if color_scheme is None:
color_scheme = {
"bg_color": BG_COLOR,
"node_fill": NODE_FILL_COLOR,
"node_color": DEFAULT_NODE_COLOR,
"node_font": DEFAULT_NODE_FONT_COLOR,
"edge_color": DEFAULT_EDGE_COLOR
}
if graph is None:
# Initialize the graph with defaults
graph = pydot.Dot(
graph_type="digraph",
rankdir="LR", # Left-to-right layout
bgcolor=color_scheme["bg_color"],
fontname="Arial", # Default font for labels if not overridden
fontsize="12", # Default font size
)
graph.set_node_defaults(
style="filled,rounded",
fillcolor=color_scheme["node_fill"],
fontcolor=color_scheme["node_font"],
fontname="Arial",
fontsize="12",
penwidth="1.5",
color=color_scheme["node_color"], # Default border color
)
graph.set_edge_defaults(
color=color_scheme["edge_color"],
penwidth="1.2",
)
# Sort items for consistent graph layout (optional but nice)
sorted_items = sorted(structure.items())
for key, value in sorted_items:
# Generate a unique and safe ID for each node
# Using UUID ensures valid DOT identifiers regardless of the key content
node_id = f"node_{uuid.uuid4().hex[:10]}" # Use slightly longer UUID part
# Escape double quotes and backslashes in labels for DOT compatibility
# pydot usually handles this, but explicit escaping is safer
escaped_label = key.replace("\\", "\\\\").replace('"', '\\"')
# Determine node shape and potentially color
is_dir = isinstance(value, dict)
shape = "box" if is_dir else "ellipse"
# Vary node border color slightly based on name hash
color_index = hash(key) % len(NEON_COLORS)
node_color = NEON_COLORS[color_index]
node = pydot.Node(
node_id,
label=f'"{escaped_label}"', # Ensure label is quoted
shape=shape,
color=node_color, # Border color
# other attributes inherit from graph defaults (fillcolor, fontcolor etc.)
)
graph.add_node(node)
if parent_id:
edge = pydot.Edge(parent_id, node_id)
graph.add_edge(edge)
# Recurse if it's a directory (value is a dictionary)
if is_dir and value: # Check if value is a non-empty dict
create_graph(value, node_id, graph, color_scheme)
return graph
def generate_svg_animation_css(color_scheme: Dict[str, str]) -> str:
"""Generate SVG animation CSS with provided color scheme."""
return f"""
<style type="text/css">
<![CDATA[
/* Overall SVG background */
svg {{
background-color: {color_scheme["bg_color"]};
}}
/* Node styling and animation */
.node {{
transition: transform 0.3s ease-in-out, filter 0.4s ease-in-out;
animation: fadeIn 0.5s ease-out forwards, pulseGlow 4s infinite alternate;
opacity: 0; /* Start transparent for fadeIn */
filter: drop-shadow(0 0 4px rgba(0, 255, 153, 0.6));
}}
.node:hover {{
transform: scale(1.1);
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 1));
cursor: pointer;
}}
/* Edge styling and animation */
.edge {{
stroke-dasharray: 8, 4; /* Dashed line pattern */
animation: dash 10s linear infinite, glow 3s infinite alternate;
}}
.edge path {{
stroke: {color_scheme["edge_color"]}; /* Ensure path color is set */
}}
.edge polygon {{
fill: {color_scheme["edge_color"]}; /* Ensure arrowhead color is set */
stroke: {color_scheme["edge_color"]};
}}
/* Text styling within nodes */
.node text {{
fill: {color_scheme["node_font"]} !important; /* Ensure high specificity */
font-weight: bold;
pointer-events: none; /* Prevent text from blocking node hover */
}}
/* Keyframe animations */
@keyframes fadeIn {{
to {{ opacity: 1; }}
}}
@keyframes pulseGlow {{
0% {{ filter: drop-shadow(0 0 4px rgba(0, 255, 153, 0.6)); opacity: 0.9; }}
50% {{ filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.8)); }}
100% {{ filter: drop-shadow(0 0 6px rgba(0, 255, 204, 0.9)); opacity: 1; }}
}}
@keyframes glow {{
from {{ stroke-opacity: 0.7; filter: drop-shadow(0 0 2px {color_scheme["edge_color"]}); }}
to {{ stroke-opacity: 1; filter: drop-shadow(0 0 4px {color_scheme["edge_color"]}); }}
}}
@keyframes dash {{
to {{ stroke-dashoffset: -100; }} /* Adjust value based on dasharray */
}}
]]>
</style>
"""
def add_svg_animation(svg_content: str, color_scheme: Dict[str, str]) -> str:
"""
Injects CSS animations and classes into the SVG content.
Args:
svg_content: The original SVG content as a string.
color_scheme: Dictionary with color settings.
Returns:
The modified SVG content with animations.
"""
# Improved regex that properly preserves whitespace between attributes
# Check if class already exists first
svg_content = re.sub(
r'(<g\s+id="node\d+")(?!\s+class=")', # Match node without class
r'\1 class="node"', # Add class with proper space
svg_content,
flags=re.IGNORECASE,
)
# Same fix for edge groups
svg_content = re.sub(
r'(<g\s+id="edge\d+")(?!\s+class=")', # Match edge without class
r'\1 class="edge"', # Add class with proper space
svg_content,
flags=re.IGNORECASE,
)
if svg_tag_match := re.search(r"<svg[^>]*>", svg_content, re.IGNORECASE):
insert_pos = svg_tag_match.end()
# Inject the CSS styles right after the opening <svg> tag
css = generate_svg_animation_css(color_scheme)
return svg_content[:insert_pos] + css + svg_content[insert_pos:]
else:
log.warning("Could not find <svg> tag to inject CSS animations.")
return svg_content # Return unmodified content if tag not found
def check_dependencies() -> bool:
"""Checks if the 'dot' executable (Graphviz) is available."""
dot_path = shutil.which("dot")
if not dot_path:
log.error("Graphviz 'dot' command not found in PATH.")
log.error("Please install Graphviz: https://graphviz.org/download/")
return False
log.info(f"Found 'dot' executable at: {dot_path}")
return True
def run_dot(dot_file: Path, svg_file: Path) -> bool:
"""Runs the Graphviz 'dot' command to convert DOT to SVG."""
command = [
"dot",
"-Tsvg",
str(dot_file),
"-o",
str(svg_file),
]
log.info(f"Running command: {' '.join(command)}")
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False, # Don't raise exception on non-zero exit code immediately
encoding="utf-8",
)
if result.returncode != 0:
log.error(f"'dot' command failed with exit code {result.returncode}")
log.error(f"Stderr:\n{result.stderr}")
log.error(f"Stdout:\n{result.stdout}")
return False
log.info(f"'dot' command completed successfully. SVG saved to {svg_file}")
return True
except FileNotFoundError:
log.error("Failed to run 'dot' command. Is Graphviz installed and in PATH?")
return False
except Exception as e:
log.exception(f"An unexpected error occurred while running 'dot': {e}")
return False
def parse_color(color_str: str) -> str:
"""Validates and normalizes a color string."""
# Allow common color names
color_names = {
"lime": "#00FF00", "green": "#008000", "red": "#FF0000", "blue": "#0000FF",
"cyan": "#00FFFF", "magenta": "#FF00FF", "yellow": "#FFFF00", "purple": "#800080",
"orange": "#FFA500", "black": "#000000", "white": "#FFFFFF", "gray": "#808080"
}
if color_str.lower() in color_names:
return color_names[color_str.lower()]
# Validate hex format
if re.match(r'^#(?:[0-9a-fA-F]{3}){1,2}$', color_str):
return color_str
# Default fallback
log.warning(f"Invalid color format: {color_str}. Using default.")
return "#00FF00" # Default to lime green if invalid
def main():
parser = argparse.ArgumentParser(
description="Generate an animated SVG flowchart of a directory structure.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"repo_path",
nargs="?",
default=os.getcwd(),
help="Path to the directory (repository root) to scan.",
type=Path,
)
parser.add_argument(
"-o",
"--output",
default="flowchart.svg",
help="Output SVG file name.",
type=Path,
)
parser.add_argument(
"--dot-output",
default="flowchart.dot",
help="Intermediate DOT file name.",
type=Path,
)
parser.add_argument(
"-d",
"--max-depth",
type=int,
default=4,
help="Maximum depth to scan directories.",
)
parser.add_argument(
"-e",
"--exclude",
nargs="*",
default=list(DEFAULT_EXCLUDE_DIRS),
help="Directory or file names to exclude.",
)
parser.add_argument(
"--no-animation",
action="store_true",
help="Generate a static SVG without animations.",
)
parser.add_argument(
"--parallel",
action="store_true",
help="Use parallel processing for directory scanning (faster for large repos).",
)
parser.add_argument(
"--open",
action="store_true",
help="Open the generated SVG in the default browser.",
)
parser.add_argument(
"--edge-color",
default="#32CD32", # Lime green
help="Specify custom edge/arrow color (hex code or color name).",
)
parser.add_argument(
"--node-color",
default=DEFAULT_NODE_COLOR,
help="Specify custom node border color (hex code or color name).",
)
parser.add_argument(
"--bg-color",
default=BG_COLOR,
help="Specify custom background color (hex code or color name).",
)
args = parser.parse_args()
# Convert exclude list to set for efficient lookup
exclude_set = set(args.exclude)
# --- Dependency Check ---
if not check_dependencies():
sys.exit(1)
# --- Directory Scanning ---
if not args.repo_path.is_dir():
log.error(f"Input path is not a valid directory: {args.repo_path}")
sys.exit(1)
# Process custom colors
color_scheme = {
"bg_color": parse_color(args.bg_color),
"node_fill": NODE_FILL_COLOR,
"node_color": parse_color(args.node_color),
"node_font": DEFAULT_NODE_FONT_COLOR,
"edge_color": parse_color(args.edge_color)
}
log.info(
f"Scanning directory: {args.repo_path.resolve()} up to depth {args.max_depth}"
)
log.info(f"Excluding: {', '.join(sorted(exclude_set))}")
log.info(f"Using edge color: {color_scheme['edge_color']}")
# Use parallel scanning if requested
if args.parallel:
log.info("Using parallel directory scanning")
directory_structure = scan_directory_parallel(
args.repo_path, max_depth=args.max_depth, exclude_dirs=exclude_set
)
else:
directory_structure = scan_directory(
args.repo_path, max_depth=args.max_depth, exclude_dirs=exclude_set
)
if not directory_structure:
log.warning("Scan returned an empty structure. No graph will be generated.")
sys.exit(0)
# --- Graph Creation ---
log.info("Generating flowchart graph...")
try:
graph = create_graph(directory_structure, color_scheme=color_scheme)
except Exception as e:
log.exception(f"Failed to create graph structure: {e}")
sys.exit(1)
# --- DOT File Generation ---
log.info(f"Saving DOT representation to {args.dot_output}...")
try:
# Use write_raw for potentially better compatibility, ensure encoding
with open(args.dot_output, "w", encoding="utf-8") as f:
# pydot's to_string() can sometimes be more reliable than write() methods
dot_string = graph.to_string()
f.write(dot_string)
log.info(f"DOT file saved successfully to {args.dot_output}")
except IOError as e:
log.error(f"Failed to write DOT file {args.dot_output}: {e}")
sys.exit(1)
except Exception as e:
# Catch potential pydot errors during string conversion/writing
log.exception(f"An error occurred writing the DOT file: {e}")
sys.exit(1)
# --- SVG Generation ---
log.info("Generating SVG flowchart using 'dot'...")
if not run_dot(args.dot_output, args.output):
log.error(
"Failed to generate SVG file. Check Graphviz installation and DOT file validity."
)
sys.exit(1)
# --- SVG Animation ---
if not args.no_animation:
log.info("Adding animations to SVG...")
try:
with open(args.output, "r", encoding="utf-8") as f:
svg_content = f.read()
animated_svg = add_svg_animation(svg_content, color_scheme)
with open(args.output, "w", encoding="utf-8") as f:
f.write(animated_svg)
log.info(f"Animated flowchart saved as {args.output.resolve()}")
except FileNotFoundError:
# This shouldn't happen if run_dot succeeded, but check anyway
log.error(f"SVG file {args.output} not found after generation step.")
sys.exit(1)
except IOError as e:
log.error(
f"Failed to read or write SVG file {args.output} for animation: {e}"
)
sys.exit(1)
except Exception as e:
log.exception(
f"An unexpected error occurred during SVG animation processing: {e}"
)
sys.exit(1)
else:
log.info(f"Static flowchart saved as {args.output.resolve()}")
# Open in browser if requested
if args.open:
try:
log.info(f"Opening {args.output} in default browser...")
webbrowser.open(f"file://{args.output.absolute()}")
except Exception as e:
log.error(f"Failed to open browser: {e}")
log.info("Process completed successfully.")
def entry_point():
main()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment