├── .env.example
├── screenshot-1.png
├── screenshot-2.png
├── src
├── contexts
│ ├── ThemeContextDef.js
│ └── ThemeContext.jsx
├── hooks
│ └── useTheme.js
├── config
│ └── nodeTypes.js
├── main.jsx
├── components
│ ├── ThemeToggle.jsx
│ ├── SystemDiagram.jsx
│ ├── CustomNodes.jsx
│ ├── UploadZone.jsx
│ ├── InfoPanel.jsx
│ └── MermaidDisplay.jsx
├── index.css
├── assets
│ └── react.svg
├── themes
│ └── index.js
├── services
│ └── analysisService.js
└── App.jsx
├── postcss.config.js
├── vite.config.js
├── tailwind.config.js
├── index.html
├── .gitignore
├── eslint.config.js
├── package.json
├── public
└── vite.svg
└── README.md
/.env.example:
--------------------------------------------------------------------------------
1 | VITE_OPENAI_API_KEY=your_sk_key_here
--------------------------------------------------------------------------------
/screenshot-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mallahyari/system-design-visualizer/HEAD/screenshot-1.png
--------------------------------------------------------------------------------
/screenshot-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mallahyari/system-design-visualizer/HEAD/screenshot-2.png
--------------------------------------------------------------------------------
/src/contexts/ThemeContextDef.js:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 |
3 | export const ThemeContext = createContext(undefined);
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "./index.html",
5 | "./src/**/*.{js,ts,jsx,tsx}",
6 | ],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
12 |
--------------------------------------------------------------------------------
/src/hooks/useTheme.js:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { ThemeContext } from "../contexts/ThemeContextDef";
3 |
4 | export const useTheme = () => {
5 | const context = useContext(ThemeContext);
6 | if (context === undefined) {
7 | throw new Error("useTheme must be used within a ThemeProvider");
8 | }
9 | return context;
10 | };
11 |
--------------------------------------------------------------------------------
/src/config/nodeTypes.js:
--------------------------------------------------------------------------------
1 | import {
2 | CacheNode,
3 | ClientNode,
4 | DatabaseNode,
5 | LoadBalancerNode,
6 | ServerNode,
7 | } from "../components/CustomNodes";
8 |
9 | export const nodeTypes = {
10 | databaseNode: DatabaseNode,
11 | serverNode: ServerNode,
12 | clientNode: ClientNode,
13 | loadBalancerNode: LoadBalancerNode,
14 | cacheNode: CacheNode,
15 | };
16 |
--------------------------------------------------------------------------------
/src/main.jsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import App from "./App.jsx";
4 | import { ThemeProvider } from "./contexts/ThemeContext.jsx";
5 | import "./index.css";
6 |
7 | createRoot(document.getElementById("root")).render(
8 |
9 |
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | interactive-system-design
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Environment variables
27 | .env
28 | .env.*
29 | !.env.example
30 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import { defineConfig, globalIgnores } from 'eslint/config'
6 |
7 | export default defineConfig([
8 | globalIgnores(['dist']),
9 | {
10 | files: ['**/*.{js,jsx}'],
11 | extends: [
12 | js.configs.recommended,
13 | reactHooks.configs.flat.recommended,
14 | reactRefresh.configs.vite,
15 | ],
16 | languageOptions: {
17 | ecmaVersion: 2020,
18 | globals: globals.browser,
19 | parserOptions: {
20 | ecmaVersion: 'latest',
21 | ecmaFeatures: { jsx: true },
22 | sourceType: 'module',
23 | },
24 | },
25 | rules: {
26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27 | },
28 | },
29 | ])
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "interactive-system-design",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "clsx": "^2.1.1",
14 | "lucide-react": "^0.555.0",
15 | "mermaid": "^11.12.1",
16 | "react": "^19.2.0",
17 | "react-dom": "^19.2.0",
18 | "reactflow": "^11.11.4",
19 | "tailwind-merge": "^3.4.0"
20 | },
21 | "devDependencies": {
22 | "@eslint/js": "^9.39.1",
23 | "@tailwindcss/postcss": "^4.1.17",
24 | "@types/react": "^19.2.5",
25 | "@types/react-dom": "^19.2.3",
26 | "@vitejs/plugin-react": "^5.1.1",
27 | "autoprefixer": "^10.4.22",
28 | "eslint": "^9.39.1",
29 | "eslint-plugin-react-hooks": "^7.0.1",
30 | "eslint-plugin-react-refresh": "^0.4.24",
31 | "globals": "^16.5.0",
32 | "postcss": "^8.5.6",
33 | "tailwindcss": "^4.1.17",
34 | "vite": "^7.2.4"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/ThemeToggle.jsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from "lucide-react";
2 | import { useTheme } from "../hooks/useTheme";
3 |
4 | const ThemeToggle = () => {
5 | const { themeName, toggleTheme } = useTheme();
6 | const isDark = themeName === "dark";
7 |
8 | return (
9 |
19 |
20 |
27 |
34 |
35 |
36 | );
37 | };
38 |
39 | export default ThemeToggle;
40 |
--------------------------------------------------------------------------------
/src/components/SystemDiagram.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import ReactFlow, {
3 | addEdge,
4 | Background,
5 | Controls,
6 | useEdgesState,
7 | useNodesState,
8 | } from "reactflow";
9 | import "reactflow/dist/style.css";
10 | import { nodeTypes } from "../config/nodeTypes";
11 | import { useTheme } from "../hooks/useTheme";
12 |
13 | const SystemDiagram = ({ initialNodes, initialEdges, onNodeClick }) => {
14 | const [nodes, , onNodesChange] = useNodesState(initialNodes);
15 | const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
16 | const { themeName } = useTheme();
17 |
18 | const nodeTypesMemo = React.useMemo(() => nodeTypes, []);
19 |
20 | const onConnect = useCallback(
21 | (params) => setEdges((eds) => addEdge(params, eds)),
22 | [setEdges]
23 | );
24 |
25 | // Dynamic colors based on theme
26 | const bgColor = themeName === "dark" ? "#333" : "#ccc";
27 |
28 | return (
29 |
36 | onNodeClick(node)}
44 | fitView
45 | style={{ backgroundColor: "var(--bg-primary)" }}
46 | proOptions={{ hideAttribution: true }}
47 | >
48 |
49 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default SystemDiagram;
56 |
--------------------------------------------------------------------------------
/src/contexts/ThemeContext.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { applyTheme, getTheme, themes } from "../themes";
3 | import { ThemeContext } from "./ThemeContextDef";
4 |
5 | const THEME_STORAGE_KEY = "sdv-theme";
6 |
7 | export const ThemeProvider = ({ children }) => {
8 | const [themeName, setThemeName] = useState(() => {
9 | // Check localStorage first
10 | const stored = localStorage.getItem(THEME_STORAGE_KEY);
11 | if (stored && themes[stored]) {
12 | return stored;
13 | }
14 | // Check system preference
15 | if (window.matchMedia("(prefers-color-scheme: light)").matches) {
16 | return "light";
17 | }
18 | return "dark";
19 | });
20 |
21 | const theme = getTheme(themeName);
22 |
23 | useEffect(() => {
24 | applyTheme(theme);
25 | localStorage.setItem(THEME_STORAGE_KEY, themeName);
26 | }, [themeName, theme]);
27 |
28 | // Listen for system preference changes
29 | useEffect(() => {
30 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
31 | const handleChange = (e) => {
32 | const stored = localStorage.getItem(THEME_STORAGE_KEY);
33 | // Only auto-switch if user hasn't explicitly set a preference
34 | if (!stored) {
35 | setThemeName(e.matches ? "dark" : "light");
36 | }
37 | };
38 |
39 | mediaQuery.addEventListener("change", handleChange);
40 | return () => mediaQuery.removeEventListener("change", handleChange);
41 | }, []);
42 |
43 | const toggleTheme = () => {
44 | setThemeName((prev) => (prev === "dark" ? "light" : "dark"));
45 | };
46 |
47 | const setTheme = (name) => {
48 | if (themes[name]) {
49 | setThemeName(name);
50 | }
51 | };
52 |
53 | return (
54 |
57 | {children}
58 |
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | :root {
4 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
5 | line-height: 1.5;
6 | font-weight: 400;
7 |
8 | /* Default to dark theme - these will be overridden by ThemeContext */
9 | --bg-primary: #020617;
10 | --bg-secondary: #0f172a;
11 | --bg-tertiary: #1e293b;
12 | --bg-elevated: rgba(15, 23, 42, 0.7);
13 | --bg-overlay: rgba(0, 0, 0, 0.2);
14 |
15 | --text-primary: #f8fafc;
16 | --text-secondary: #94a3b8;
17 | --text-tertiary: #64748b;
18 | --text-muted: #475569;
19 |
20 | --border-primary: rgba(255, 255, 255, 0.1);
21 | --border-secondary: rgba(255, 255, 255, 0.05);
22 | --border-hover: rgba(255, 255, 255, 0.2);
23 |
24 | --interactive-bg: #1e293b;
25 | --interactive-hover: #334155;
26 | --interactive-active: #475569;
27 |
28 | --accent-blue: #3b82f6;
29 | --accent-blue-hover: #2563eb;
30 | --accent-blue-glow: rgba(59, 130, 246, 0.2);
31 | --accent-emerald: #10b981;
32 | --accent-purple: #a855f7;
33 | --accent-orange: #f97316;
34 | --accent-yellow: #eab308;
35 |
36 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
37 | --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
38 | --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3);
39 | --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
40 |
41 | --node-bg: #111827;
42 | --node-border: #374151;
43 | --node-border-hover: #4b5563;
44 | --panel-bg: rgba(17, 24, 39, 0.95);
45 |
46 | --mermaid-theme: dark;
47 | }
48 |
49 | body {
50 | margin: 0;
51 | min-width: 320px;
52 | min-height: 100vh;
53 | background-color: var(--bg-primary);
54 | color: var(--text-primary);
55 | transition: background-color 0.3s ease, color 0.3s ease;
56 | }
57 |
58 | /* Smooth theme transitions */
59 | *,
60 | *::before,
61 | *::after {
62 | transition: background-color 0.2s ease, border-color 0.2s ease,
63 | color 0.2s ease, box-shadow 0.2s ease;
64 | }
65 |
66 | /* ReactFlow overrides for theming */
67 | .react-flow__controls {
68 | background-color: var(--bg-tertiary) !important;
69 | border-color: var(--border-primary) !important;
70 | box-shadow: var(--shadow-lg) !important;
71 | }
72 |
73 | .react-flow__controls-button {
74 | background-color: var(--bg-tertiary) !important;
75 | border-color: var(--border-primary) !important;
76 | fill: var(--text-secondary) !important;
77 | }
78 |
79 | .react-flow__controls-button:hover {
80 | background-color: var(--interactive-hover) !important;
81 | }
82 |
83 | .react-flow__background {
84 | background-color: var(--bg-primary) !important;
85 | }
86 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # System Design Visualizer
2 |
3 | An interactive tool that transforms static system design diagrams into explorable, interactive visualizations using AI.
4 |
5 | ## 📸 Screenshots
6 |
7 | 
8 | *Original Image and Mermaid Diagram side-by-side*
9 |
10 | 
11 | *Interactive React Flow Graph*
12 |
13 | ## 🚀 Features
14 |
15 | - **AI-Powered Analysis**: Upload any system design image (architecture diagrams, flowcharts, etc.).
16 | - **Mermaid Generation**: Automatically converts images into editable Mermaid.js diagrams.
17 | - **Interactive Visualization**: Converts Mermaid diagrams into interactive React Flow graphs.
18 | - **Deep Dive**: Click on any component (Load Balancer, Database, etc.) to see inferred details like technology stack and role.
19 | - **Premium UI**: A modern, dark-themed dashboard with zoom, pan, and copy controls.
20 |
21 | ## 🛠️ Tech Stack
22 |
23 | - **Frontend**: React, Vite, Tailwind CSS
24 | - **Visualization**: React Flow, Mermaid.js
25 | - **AI**: OpenAI GPT-4o (Vision & Code Generation)
26 | - **Icons**: Lucide React
27 |
28 | ## 🏃♂️ Running Locally
29 |
30 | ### Prerequisites
31 |
32 | - Node.js (v18 or higher)
33 | - An OpenAI API Key (for AI analysis features)
34 |
35 | ### Installation
36 |
37 | 1. **Clone the repository**
38 | ```bash
39 | git clone https://github.com/mallahyari/system-design-visualizer.git
40 | cd system-design-visualizer
41 | ```
42 |
43 | 2. **Install dependencies**
44 | ```bash
45 | npm install
46 | ```
47 |
48 | 3. **Configure Environment**
49 | Create a `.env` file in the root directory:
50 | ```bash
51 | touch .env
52 | ```
53 | Add your OpenAI API key:
54 | ```env
55 | VITE_OPENAI_API_KEY=your_sk_key_here
56 | ```
57 | > **Note**: If no API key is provided, the app will run in **Mock Mode**, generating sample data for testing.
58 |
59 | 4. **Start the Development Server**
60 | ```bash
61 | npm run dev
62 | ```
63 |
64 | 5. **Open in Browser**
65 | Navigate to `http://localhost:5173` to see the app in action.
66 |
67 | ## 📸 Workflow
68 |
69 | 1. **Upload**: Drag & drop your system design image.
70 | 2. **Review**: See the generated Mermaid diagram code and preview. Use the toolbar to zoom or copy the code.
71 | 3. **Convert**: Click "Convert to Interactive" to generate the node-based graph.
72 | 4. **Explore**: Interact with the graph nodes to learn more about your system's architecture.
73 |
74 | ## 🤝 Contributing
75 |
76 | Contributions are welcome! Please feel free to submit a Pull Request.
77 |
78 | ## 📄 License
79 |
80 | This project is licensed under the MIT License.
81 |
--------------------------------------------------------------------------------
/src/components/CustomNodes.jsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { Database, Globe, Layers, Server, Smartphone } from "lucide-react";
3 | import { memo } from "react";
4 | import { Handle, Position } from "reactflow";
5 |
6 | const BaseNode = ({ data, icon: Icon, colorClass, isSelected }) => {
7 | return (
8 |
19 |
25 |
26 |
27 |
33 |
34 |
35 |
36 |
40 | {data.label}
41 |
42 |
43 | {data.tech}
44 |
45 |
46 |
47 |
48 |
54 |
55 | );
56 | };
57 |
58 | export const DatabaseNode = memo(({ data, selected }) => (
59 |
65 | ));
66 |
67 | export const ServerNode = memo(({ data, selected }) => (
68 |
74 | ));
75 |
76 | export const ClientNode = memo(({ data, selected }) => (
77 |
83 | ));
84 |
85 | export const LoadBalancerNode = memo(({ data, selected }) => (
86 |
92 | ));
93 |
94 | export const CacheNode = memo(({ data, selected }) => (
95 |
101 | ));
102 |
--------------------------------------------------------------------------------
/src/components/UploadZone.jsx:
--------------------------------------------------------------------------------
1 | import { FileUp, Loader2 } from "lucide-react";
2 | import { useCallback } from "react";
3 |
4 | const UploadZone = ({ onUpload, isAnalyzing }) => {
5 | const handleDrop = useCallback(
6 | (e) => {
7 | e.preventDefault();
8 | e.stopPropagation();
9 |
10 | if (e.dataTransfer.files && e.dataTransfer.files[0]) {
11 | console.log("UploadZone: File dropped", e.dataTransfer.files[0]);
12 | onUpload(e.dataTransfer.files[0]);
13 | }
14 | },
15 | [onUpload]
16 | );
17 |
18 | const handleDragOver = (e) => {
19 | e.preventDefault();
20 | e.stopPropagation();
21 | };
22 |
23 | const handleChange = (e) => {
24 | console.log("UploadZone: File input changed", e.target.files);
25 | if (e.target.files && e.target.files[0]) {
26 | onUpload(e.target.files[0]);
27 | e.target.value = null;
28 | }
29 | };
30 |
31 | return (
32 | document.getElementById("file-upload").click()}
41 | >
42 |
50 |
51 | {isAnalyzing ? (
52 | <>
53 |
57 |
61 | Analyzing System Architecture...
62 |
63 |
64 | Identifying components and relationships
65 |
66 | >
67 | ) : (
68 | <>
69 |
73 |
77 |
78 |
79 |
83 | Drop your system design here
84 |
85 |
86 | or click to browse (JPG, PNG)
87 |
88 |
89 | >
90 | )}
91 |
92 | );
93 | };
94 |
95 | export default UploadZone;
96 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/InfoPanel.jsx:
--------------------------------------------------------------------------------
1 | import { Activity, Code2, Info, X } from "lucide-react";
2 |
3 | const InfoPanel = ({ node, onClose }) => {
4 | if (!node) return null;
5 |
6 | return (
7 |
15 |
22 |
26 |
27 | Component Details
28 |
29 | {
34 | e.currentTarget.style.backgroundColor = "var(--interactive-hover)";
35 | e.currentTarget.style.color = "var(--text-primary)";
36 | }}
37 | onMouseLeave={(e) => {
38 | e.currentTarget.style.backgroundColor = "transparent";
39 | e.currentTarget.style.color = "var(--text-secondary)";
40 | }}
41 | >
42 |
43 |
44 |
45 |
46 |
47 |
48 |
52 | Name
53 |
54 |
58 | {node.data.label}
59 |
60 |
68 | {node.type.replace("Node", "")}
69 |
70 |
71 |
72 |
73 |
77 | Description
78 |
79 |
83 | {node.data.description}
84 |
85 |
86 |
87 |
88 |
92 | Technologies
93 |
94 |
95 | {node.data.tech.split(",").map((tech, i) => (
96 |
105 | {tech.trim()}
106 |
107 | ))}
108 |
109 |
110 |
111 |
112 | );
113 | };
114 |
115 | export default InfoPanel;
116 |
--------------------------------------------------------------------------------
/src/components/MermaidDisplay.jsx:
--------------------------------------------------------------------------------
1 | import { Check, Copy, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
2 | import mermaid from "mermaid";
3 | import { useEffect, useRef, useState } from "react";
4 | import { useTheme } from "../hooks/useTheme";
5 |
6 | const MermaidDisplay = ({ chart }) => {
7 | const containerRef = useRef(null);
8 | const [scale, setScale] = useState(1);
9 | const [copied, setCopied] = useState(false);
10 | const { themeName } = useTheme();
11 |
12 | // Re-initialize mermaid when theme changes
13 | useEffect(() => {
14 | mermaid.initialize({
15 | startOnLoad: true,
16 | theme: themeName === "dark" ? "dark" : "default",
17 | securityLevel: "loose",
18 | flowchart: {
19 | useMaxWidth: false,
20 | htmlLabels: true,
21 | },
22 | });
23 | }, [themeName]);
24 |
25 | useEffect(() => {
26 | if (containerRef.current && chart) {
27 | mermaid.render(`mermaid-${Date.now()}`, chart).then(({ svg }) => {
28 | containerRef.current.innerHTML = svg;
29 | });
30 | }
31 | }, [chart, themeName]);
32 |
33 | const handleZoomIn = () => setScale((s) => Math.min(s + 0.2, 3));
34 | const handleZoomOut = () => setScale((s) => Math.max(s - 0.2, 0.5));
35 | const handleReset = () => setScale(1);
36 |
37 | const handleCopy = async () => {
38 | try {
39 | await navigator.clipboard.writeText(chart);
40 | setCopied(true);
41 | setTimeout(() => setCopied(false), 2000);
42 | } catch (err) {
43 | console.error("Failed to copy:", err);
44 | }
45 | };
46 |
47 | return (
48 |
52 | {/* Toolbar */}
53 |
61 |
67 |
68 |
69 |
73 | {Math.round(scale * 100)}%
74 |
75 |
81 |
82 |
83 |
87 |
93 |
94 |
95 |
99 |
107 | {copied ? (
108 |
109 | ) : (
110 |
111 | )}
112 |
113 |
114 |
115 | {/* Diagram Container */}
116 |
124 |
125 | );
126 | };
127 |
128 | export default MermaidDisplay;
129 |
--------------------------------------------------------------------------------
/src/themes/index.js:
--------------------------------------------------------------------------------
1 | // Theme definitions using CSS custom properties
2 | // Each theme defines color tokens that map to Tailwind-style naming
3 |
4 | export const themes = {
5 | dark: {
6 | name: "dark",
7 | label: "Dark",
8 | colors: {
9 | // Background colors
10 | "--bg-primary": "#020617", // slate-950
11 | "--bg-secondary": "#0f172a", // slate-900
12 | "--bg-tertiary": "#1e293b", // slate-800
13 | "--bg-elevated": "rgba(15, 23, 42, 0.7)", // slate-900/70
14 | "--bg-overlay": "rgba(0, 0, 0, 0.2)",
15 |
16 | // Text colors
17 | "--text-primary": "#f8fafc", // slate-50
18 | "--text-secondary": "#94a3b8", // slate-400
19 | "--text-tertiary": "#64748b", // slate-500
20 | "--text-muted": "#475569", // slate-600
21 |
22 | // Border colors
23 | "--border-primary": "rgba(255, 255, 255, 0.1)",
24 | "--border-secondary": "rgba(255, 255, 255, 0.05)",
25 | "--border-hover": "rgba(255, 255, 255, 0.2)",
26 |
27 | // Interactive elements
28 | "--interactive-bg": "#1e293b", // slate-800
29 | "--interactive-hover": "#334155", // slate-700
30 | "--interactive-active": "#475569", // slate-600
31 |
32 | // Accent colors (these stay consistent across themes)
33 | "--accent-blue": "#3b82f6",
34 | "--accent-blue-hover": "#2563eb",
35 | "--accent-blue-glow": "rgba(59, 130, 246, 0.2)",
36 | "--accent-emerald": "#10b981",
37 | "--accent-purple": "#a855f7",
38 | "--accent-orange": "#f97316",
39 | "--accent-yellow": "#eab308",
40 |
41 | // Shadows
42 | "--shadow-sm": "0 1px 2px rgba(0, 0, 0, 0.3)",
43 | "--shadow-md": "0 4px 6px rgba(0, 0, 0, 0.3)",
44 | "--shadow-lg": "0 10px 15px rgba(0, 0, 0, 0.3)",
45 | "--shadow-xl": "0 20px 25px rgba(0, 0, 0, 0.4)",
46 |
47 | // Component-specific
48 | "--node-bg": "#111827", // gray-900
49 | "--node-border": "#374151", // gray-700
50 | "--node-border-hover": "#4b5563", // gray-600
51 | "--panel-bg": "rgba(17, 24, 39, 0.95)",
52 |
53 | // Mermaid theme
54 | "--mermaid-theme": "dark",
55 | },
56 | },
57 |
58 | light: {
59 | name: "light",
60 | label: "Light",
61 | colors: {
62 | // Background colors
63 | "--bg-primary": "#ffffff",
64 | "--bg-secondary": "#f8fafc", // slate-50
65 | "--bg-tertiary": "#f1f5f9", // slate-100
66 | "--bg-elevated": "rgba(248, 250, 252, 0.9)",
67 | "--bg-overlay": "rgba(255, 255, 255, 0.5)",
68 |
69 | // Text colors
70 | "--text-primary": "#0f172a", // slate-900
71 | "--text-secondary": "#475569", // slate-600
72 | "--text-tertiary": "#64748b", // slate-500
73 | "--text-muted": "#94a3b8", // slate-400
74 |
75 | // Border colors
76 | "--border-primary": "rgba(0, 0, 0, 0.1)",
77 | "--border-secondary": "rgba(0, 0, 0, 0.05)",
78 | "--border-hover": "rgba(0, 0, 0, 0.15)",
79 |
80 | // Interactive elements
81 | "--interactive-bg": "#f1f5f9", // slate-100
82 | "--interactive-hover": "#e2e8f0", // slate-200
83 | "--interactive-active": "#cbd5e1", // slate-300
84 |
85 | // Accent colors
86 | "--accent-blue": "#2563eb",
87 | "--accent-blue-hover": "#1d4ed8",
88 | "--accent-blue-glow": "rgba(37, 99, 235, 0.15)",
89 | "--accent-emerald": "#059669",
90 | "--accent-purple": "#9333ea",
91 | "--accent-orange": "#ea580c",
92 | "--accent-yellow": "#ca8a04",
93 |
94 | // Shadows
95 | "--shadow-sm": "0 1px 2px rgba(0, 0, 0, 0.05)",
96 | "--shadow-md": "0 4px 6px rgba(0, 0, 0, 0.07)",
97 | "--shadow-lg": "0 10px 15px rgba(0, 0, 0, 0.1)",
98 | "--shadow-xl": "0 20px 25px rgba(0, 0, 0, 0.15)",
99 |
100 | // Component-specific
101 | "--node-bg": "#ffffff",
102 | "--node-border": "#e2e8f0", // slate-200
103 | "--node-border-hover": "#cbd5e1", // slate-300
104 | "--panel-bg": "rgba(255, 255, 255, 0.95)",
105 |
106 | // Mermaid theme
107 | "--mermaid-theme": "default",
108 | },
109 | },
110 | };
111 |
112 | export const getTheme = (themeName) => themes[themeName] || themes.dark;
113 |
114 | export const applyTheme = (theme) => {
115 | const root = document.documentElement;
116 | Object.entries(theme.colors).forEach(([property, value]) => {
117 | root.style.setProperty(property, value);
118 | });
119 |
120 | // Set data-theme attribute for potential CSS selectors
121 | root.setAttribute("data-theme", theme.name);
122 | };
123 |
--------------------------------------------------------------------------------
/src/services/analysisService.js:
--------------------------------------------------------------------------------
1 | import { MarkerType } from 'reactflow';
2 |
3 | /**
4 | * Analyzes an image using OpenAI's GPT-4o to generate a Mermaid diagram.
5 | * @param {File} imageFile
6 | * @returns {Promise} Mermaid diagram string
7 | */
8 | export const generateMermaidFromImage = async (imageFile) => {
9 | const apiKey = import.meta.env.VITE_OPENAI_API_KEY;
10 |
11 | if (apiKey) {
12 | console.log("analysisService: Using OpenAI API for Mermaid generation");
13 | return generateMermaidWithOpenAI(imageFile, apiKey);
14 | } else {
15 | console.log("analysisService: No API key found, using mock data");
16 | return new Promise((resolve) => {
17 | setTimeout(() => {
18 | resolve(getMockMermaid());
19 | }, 1500);
20 | });
21 | }
22 | };
23 |
24 | /**
25 | * Converts a Mermaid diagram string to React Flow nodes and edges.
26 | * @param {string} mermaidCode
27 | * @returns {Promise<{nodes: Array, edges: Array}>}
28 | */
29 | export const convertMermaidToFlow = async (mermaidCode) => {
30 | const apiKey = import.meta.env.VITE_OPENAI_API_KEY;
31 |
32 | if (apiKey) {
33 | console.log("analysisService: Using OpenAI API for Flow conversion");
34 | return convertToFlowWithOpenAI(mermaidCode, apiKey);
35 | } else {
36 | console.log("analysisService: No API key found, using mock data");
37 | return new Promise((resolve) => {
38 | setTimeout(() => {
39 | resolve(getMockGraph());
40 | }, 1500);
41 | });
42 | }
43 | };
44 |
45 | const generateMermaidWithOpenAI = async (file, apiKey) => {
46 | try {
47 | const base64Image = await toBase64(file);
48 |
49 | const response = await fetch('https://api.openai.com/v1/chat/completions', {
50 | method: 'POST',
51 | headers: {
52 | 'Content-Type': 'application/json',
53 | 'Authorization': `Bearer ${apiKey}`
54 | },
55 | body: JSON.stringify({
56 | model: "gpt-4o",
57 | messages: [
58 | {
59 | role: "system",
60 | content: `You are a system architecture expert. Analyze the provided system design diagram image and convert it into a Mermaid JS diagram.
61 |
62 | Return ONLY the Mermaid code string. Do not include markdown code blocks (like \`\`\`mermaid).
63 |
64 | Rules:
65 | 1. Use 'graph TD' or 'graph LR' based on the layout.
66 | 2. Use appropriate shapes for components (cylinder for databases, rect for servers, etc).
67 | 3. Ensure directionality of arrows matches the image.`
68 | },
69 | {
70 | role: "user",
71 | content: [
72 | { type: "text", text: "Convert this system design to Mermaid." },
73 | { type: "image_url", image_url: { url: base64Image } }
74 | ]
75 | }
76 | ],
77 | max_tokens: 4000
78 | })
79 | });
80 |
81 | const data = await response.json();
82 |
83 | if (data.error) {
84 | throw new Error(data.error.message);
85 | }
86 |
87 | let content = data.choices[0].message.content;
88 | // Clean up markdown code blocks if present
89 | content = content.replace(/^```mermaid\n/, '').replace(/^```\n/, '').replace(/```$/, '');
90 | return content.trim();
91 |
92 | } catch (error) {
93 | console.error("OpenAI API Error:", error);
94 | alert("Failed to analyze image with AI. Falling back to mock data.");
95 | return getMockMermaid();
96 | }
97 | };
98 |
99 | const convertToFlowWithOpenAI = async (mermaidCode, apiKey) => {
100 | try {
101 | const response = await fetch('https://api.openai.com/v1/chat/completions', {
102 | method: 'POST',
103 | headers: {
104 | 'Content-Type': 'application/json',
105 | 'Authorization': `Bearer ${apiKey}`
106 | },
107 | body: JSON.stringify({
108 | model: "gpt-4o",
109 | messages: [
110 | {
111 | role: "system",
112 | content: `You are a system architecture expert. Convert the provided Mermaid diagram code into a structured graph format for React Flow.
113 |
114 | Return ONLY a valid JSON object (no markdown formatting) with this structure:
115 | {
116 | "nodes": [
117 | {
118 | "id": "string",
119 | "type": "one of: clientNode, serverNode, databaseNode, loadBalancerNode, cacheNode",
120 | "position": { "x": number, "y": number },
121 | "data": {
122 | "label": "string",
123 | "description": "brief description of role inferred from context",
124 | "tech": "inferred technologies"
125 | }
126 | }
127 | ],
128 | "edges": [
129 | { "id": "string", "source": "nodeId", "target": "nodeId", "animated": true, "label": "optional connection label" }
130 | ]
131 | }
132 |
133 | Rules:
134 | 1. Map Mermaid shapes/names to the most appropriate node type.
135 | 2. Space out the position coordinates (x, y) so the graph is readable.
136 | 3. Ensure all source and target IDs in edges exist in the nodes array.`
137 | },
138 | {
139 | role: "user",
140 | content: `Convert this Mermaid code to React Flow JSON:\n\n${mermaidCode}`
141 | }
142 | ],
143 | max_tokens: 4000,
144 | response_format: { type: "json_object" }
145 | })
146 | });
147 |
148 | const data = await response.json();
149 |
150 | if (data.error) {
151 | throw new Error(data.error.message);
152 | }
153 |
154 | const result = JSON.parse(data.choices[0].message.content);
155 | return result;
156 |
157 | } catch (error) {
158 | console.error("OpenAI API Error:", error);
159 | alert("Failed to convert Mermaid to Flow. Falling back to mock data.");
160 | return getMockGraph();
161 | }
162 | };
163 |
164 | const toBase64 = (file) => new Promise((resolve, reject) => {
165 | const reader = new FileReader();
166 | reader.readAsDataURL(file);
167 | reader.onload = () => resolve(reader.result);
168 | reader.onerror = error => reject(error);
169 | });
170 |
171 | const getMockMermaid = () => {
172 | return `graph TD
173 | Client[Client / Browser] -->|HTTPS| LB[Load Balancer]
174 | LB --> Web1[Web Server 1]
175 | LB --> Web2[Web Server 2]
176 | Web1 --> DB[(Primary Database)]
177 | Web2 --> DB
178 | Web1 -.-> Cache[(Redis Cache)]`;
179 | };
180 |
181 | const getMockGraph = () => {
182 | // This is a hardcoded "standard" 3-tier architecture for demo purposes
183 | const nodes = [
184 | {
185 | id: 'client',
186 | type: 'clientNode',
187 | position: { x: 250, y: 0 },
188 | data: {
189 | label: 'Client / Browser',
190 | description: 'The user interface running in the browser. Sends requests to the Load Balancer.',
191 | tech: 'React, Mobile App'
192 | },
193 | },
194 | {
195 | id: 'lb',
196 | type: 'loadBalancerNode',
197 | position: { x: 250, y: 150 },
198 | data: {
199 | label: 'Load Balancer',
200 | description: 'Distributes incoming network traffic across multiple servers to ensure reliability and performance.',
201 | tech: 'NGINX, AWS ALB'
202 | },
203 | },
204 | {
205 | id: 'web-server-1',
206 | type: 'serverNode',
207 | position: { x: 100, y: 300 },
208 | data: {
209 | label: 'Web Server 1',
210 | description: 'Handles application logic and processes user requests.',
211 | tech: 'Node.js, Express'
212 | },
213 | },
214 | {
215 | id: 'web-server-2',
216 | type: 'serverNode',
217 | position: { x: 400, y: 300 },
218 | data: {
219 | label: 'Web Server 2',
220 | description: 'Secondary server for horizontal scaling and high availability.',
221 | tech: 'Node.js, Express'
222 | },
223 | },
224 | {
225 | id: 'db-primary',
226 | type: 'databaseNode',
227 | position: { x: 250, y: 450 },
228 | data: {
229 | label: 'Primary Database',
230 | description: 'Stores persistent data. Handles write operations.',
231 | tech: 'PostgreSQL, MongoDB'
232 | },
233 | },
234 | {
235 | id: 'cache',
236 | type: 'cacheNode',
237 | position: { x: 50, y: 450 },
238 | data: {
239 | label: 'Redis Cache',
240 | description: 'Stores frequently accessed data to reduce database load.',
241 | tech: 'Redis'
242 | },
243 | },
244 | ];
245 |
246 | const edges = [
247 | { id: 'e1', source: 'client', target: 'lb', animated: true, label: 'HTTPS' },
248 | { id: 'e2', source: 'lb', target: 'web-server-1', animated: true },
249 | { id: 'e3', source: 'lb', target: 'web-server-2', animated: true },
250 | { id: 'e4', source: 'web-server-1', target: 'db-primary', animated: true },
251 | { id: 'e5', source: 'web-server-2', target: 'db-primary', animated: true },
252 | { id: 'e6', source: 'web-server-1', target: 'cache', type: 'smoothstep', style: { strokeDasharray: '5,5' } },
253 | ];
254 |
255 | return { nodes, edges };
256 | };
257 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Activity,
3 | ArrowDown,
4 | ArrowLeft,
5 | Code,
6 | Image as ImageIcon,
7 | Layout,
8 | } from "lucide-react";
9 | import { useEffect, useRef, useState } from "react";
10 | import { ReactFlowProvider } from "reactflow";
11 | import InfoPanel from "./components/InfoPanel";
12 | import MermaidDisplay from "./components/MermaidDisplay";
13 | import SystemDiagram from "./components/SystemDiagram";
14 | import ThemeToggle from "./components/ThemeToggle";
15 | import UploadZone from "./components/UploadZone";
16 | import {
17 | convertMermaidToFlow,
18 | generateMermaidFromImage,
19 | } from "./services/analysisService";
20 |
21 | function App() {
22 | const [graphData, setGraphData] = useState(null);
23 | const [mermaidCode, setMermaidCode] = useState(null);
24 | const [uploadedImageUrl, setUploadedImageUrl] = useState(null);
25 | const [isAnalyzing, setIsAnalyzing] = useState(false);
26 | const [isConverting, setIsConverting] = useState(false);
27 | const [selectedNode, setSelectedNode] = useState(null);
28 |
29 | const interactiveSectionRef = useRef(null);
30 |
31 | const handleUpload = async (file) => {
32 | console.log("App: handleUpload called with file:", file);
33 |
34 | // Create a local URL for the uploaded image to display it
35 | const objectUrl = URL.createObjectURL(file);
36 | setUploadedImageUrl(objectUrl);
37 |
38 | setIsAnalyzing(true);
39 | try {
40 | console.log("App: calling generateMermaidFromImage...");
41 | const code = await generateMermaidFromImage(file);
42 | console.log("App: generateMermaidFromImage returned:", code);
43 | setMermaidCode(code);
44 | } catch (error) {
45 | console.error("Analysis failed:", error);
46 | } finally {
47 | setIsAnalyzing(false);
48 | }
49 | };
50 |
51 | const handleConvertToInteractive = async () => {
52 | if (!mermaidCode) return;
53 | setIsConverting(true);
54 | try {
55 | console.log("App: calling convertMermaidToFlow...");
56 | const data = await convertMermaidToFlow(mermaidCode);
57 | console.log("App: convertMermaidToFlow returned:", data);
58 | setGraphData(data);
59 |
60 | // Scroll to interactive section after a short delay to allow render
61 | setTimeout(() => {
62 | interactiveSectionRef.current?.scrollIntoView({ behavior: "smooth" });
63 | }, 100);
64 | } catch (error) {
65 | console.error("Conversion failed:", error);
66 | } finally {
67 | setIsConverting(false);
68 | }
69 | };
70 |
71 | const handleReset = () => {
72 | setGraphData(null);
73 | setMermaidCode(null);
74 | setUploadedImageUrl(null);
75 | setSelectedNode(null);
76 | };
77 |
78 | // Clean up object URL when component unmounts or image changes
79 | useEffect(() => {
80 | return () => {
81 | if (uploadedImageUrl) {
82 | URL.revokeObjectURL(uploadedImageUrl);
83 | }
84 | };
85 | }, [uploadedImageUrl]);
86 |
87 | const showDashboard = uploadedImageUrl || mermaidCode || graphData;
88 |
89 | return (
90 |
97 | {/* Header */}
98 |
106 |
107 |
108 |
112 |
113 |
114 |
118 | System Design Visualizer
119 |
120 |
121 |
122 |
123 | {showDashboard && (
124 |
{
133 | e.currentTarget.style.backgroundColor =
134 | "var(--interactive-hover)";
135 | e.currentTarget.style.borderColor = "var(--border-hover)";
136 | }}
137 | onMouseLeave={(e) => {
138 | e.currentTarget.style.backgroundColor =
139 | "var(--interactive-bg)";
140 | e.currentTarget.style.borderColor = "var(--border-primary)";
141 | }}
142 | >
143 |
144 | Upload New Design
145 |
146 | )}
147 |
148 |
149 |
150 |
151 |
152 | {/* Main Content */}
153 |
154 | {!showDashboard ? (
155 |
156 |
157 |
169 |
173 | Bring your architecture to life
174 |
175 |
179 | Upload a system design image and let our AI convert it into an
180 | interactive, explorable diagram with detailed component
181 | information.
182 |
183 |
184 |
185 |
186 | ) : (
187 |
188 | {/* Row 1: Source Materials */}
189 |
190 | {/* Top Left: Original Image (35%) */}
191 |
198 |
205 |
209 |
213 | Original Design
214 |
215 |
216 |
220 | {uploadedImageUrl ? (
221 |
230 | ) : (
231 |
235 | No image loaded
236 |
237 | )}
238 |
239 |
240 |
241 | {/* Top Right: Mermaid Diagram (65%) */}
242 |
249 |
256 |
257 |
261 |
265 | Mermaid Definition
266 |
267 |
268 | {mermaidCode && !graphData && (
269 |
278 | {isConverting
279 | ? "Converting..."
280 | : "Convert to Interactive"}
281 |
282 |
283 | )}
284 |
285 |
289 | {mermaidCode ? (
290 |
291 | ) : isAnalyzing ? (
292 |
296 |
303 |
304 | Generating Mermaid diagram...
305 |
306 |
307 | ) : (
308 |
312 | Waiting for analysis...
313 |
314 | )}
315 |
316 |
317 |
318 |
319 | {/* Row 2: Interactive Diagram (Full Width) */}
320 |
328 |
335 |
336 |
340 |
344 | Interactive Visualization
345 |
346 |
347 |
348 |
349 |
353 | {graphData ? (
354 | <>
355 |
356 |
361 |
362 |
setSelectedNode(null)}
365 | />
366 | >
367 | ) : (
368 |
372 | {mermaidCode ? (
373 |
374 |
387 |
391 | Ready to Visualize
392 |
393 |
394 | Review the Mermaid diagram above. When you're ready,
395 | click "Convert to Interactive" to generate the
396 | explorable graph.
397 |
398 |
407 | {isConverting
408 | ? "Converting..."
409 | : "Convert to Interactive"}
410 |
411 |
412 |
413 | ) : (
414 |
415 |
422 |
423 |
424 |
Upload an image to start the analysis.
425 |
426 | )}
427 |
428 | )}
429 |
430 |
431 |
432 | )}
433 |
434 |
435 | );
436 | }
437 |
438 | export default App;
439 |
--------------------------------------------------------------------------------