From 5c4b83203c184f870282047fa00d89c3b08da642 Mon Sep 17 00:00:00 2001 From: SysVis AI Date: Mon, 29 Dec 2025 09:58:43 +0700 Subject: [PATCH] feat: add mobile-first UX improvements and theme-aware exports - Mobile hamburger header with slide-in menu - Mobile bottom action bar for canvas controls - Full-screen settings modal on mobile - Mobile empty state with Get Started prompt - Theme-aware PNG/JPG/SVG exports (respects light/dark mode) - Added file-saver package for reliable cross-browser downloads - Updated README with new features and Docker Hub instructions --- Dockerfile | 46 +- docker-compose.yml | 70 +- nginx.conf | 57 +- package-lock.json | 108 +- package.json | 2 + public/manifest.json | 48 +- scripts/setup-ollama-models.ps1 | 76 +- src/components/FlowCanvas.tsx | 219 +-- src/components/Settings.tsx | 19 +- src/components/SmartGuide.tsx | 178 +-- src/components/VisualOrganizerFAB.tsx | 284 ++-- src/components/VisualOrganizerPanel.tsx | 388 +++--- src/components/editor/EditorHeader.tsx | 405 +++++- src/components/editor/EditorToolbar.tsx | 122 +- src/components/editor/MonacoWrapper.tsx | 214 +-- src/components/nodes/ShapeNode.tsx | 138 +- src/components/ui/Button.tsx | 76 +- src/components/ui/Card.tsx | 56 +- src/components/ui/OrchestratorLoader.tsx | 46 +- src/hooks/useDiagramAPI.ts | 140 +- src/hooks/useMobileDetect.ts | 176 +-- src/hooks/useVisualOrganizer.ts | 174 +-- src/lib/__tests__/aiService.test.ts | 308 ++--- src/lib/__tests__/mermaidParser.test.ts | 210 +-- src/lib/exportUtils.ts | 101 +- src/lib/layoutEngine.ts | 848 ++++++------ src/lib/mermaidTest.ts | 60 +- src/lib/prompts.ts | 236 ++-- src/lib/shapes.ts | 122 +- src/lib/visionService.ts | 259 ++-- src/lib/visualOrganizer.ts | 1586 +++++++++++----------- src/lib/webLlmService.ts | 222 ++- src/pages/Editor.tsx | 36 + src/pages/History.tsx | 264 ++-- src/plugins/types.ts | 52 +- src/store/pluginStore.ts | 126 +- src/styles/ui.css | 32 + src/test/setup.ts | 84 +- src/types/visualOrganization.ts | 210 +-- todo.md | 50 - vitest.config.ts | 22 +- 41 files changed, 4150 insertions(+), 3720 deletions(-) delete mode 100644 todo.md diff --git a/Dockerfile b/Dockerfile index 32052e5..f9e48b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,23 @@ -# Stage 1: Build the application -FROM node:20-alpine AS builder - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci - -COPY . . -RUN npm run build - -# Stage 2: Serve with Nginx -FROM nginx:alpine - -# Copy built assets from builder stage -COPY --from=builder /app/dist /usr/share/nginx/html - -# Copy custom nginx config -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] +# Stage 1: Build the application +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Stage 2: Serve with Nginx +FROM nginx:alpine + +# Copy built assets from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml index 2573460..fa52748 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,35 @@ -version: '3.8' - -services: - webapp: - build: . - container_name: kv-graph-web - restart: always - ports: - - "8338:80" - # Optional if you want to have Ollama running in a container - depends_on: - - ollama - - # Optional if you want to have Ollama running in a container - ollama: - image: ollama/ollama:latest - container_name: ollama-service - restart: always - ports: - - "11434:11434" - volumes: - - ./ollama_data:/root/.ollama - environment: - - OLLAMA_KEEP_ALIVE=24h - - OLLAMA_ORIGINS="*" - # NVIDIA GPU Support Configuration - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [ gpu ] - # Fallback for systems without 'deploy' support (older compose versions) or explicit runtime - # runtime: nvidia +version: '3.8' + +services: + webapp: + build: . + container_name: kv-graph-web + restart: always + ports: + - "8338:80" + # Optional if you want to have Ollama running in a container + depends_on: + - ollama + + # Optional if you want to have Ollama running in a container + ollama: + image: ollama/ollama:latest + container_name: ollama-service + restart: always + ports: + - "11434:11434" + volumes: + - ./ollama_data:/root/.ollama + environment: + - OLLAMA_KEEP_ALIVE=24h + - OLLAMA_ORIGINS="*" + # NVIDIA GPU Support Configuration + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [ gpu ] + # Fallback for systems without 'deploy' support (older compose versions) or explicit runtime + # runtime: nvidia diff --git a/nginx.conf b/nginx.conf index fb8199d..cb19732 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,21 +1,36 @@ -server { - listen 80; - - # Enable gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - # Root directory for the app - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; - } - - # NOTE: Ollama proxy is NOT included by default to allow standalone operation. - # The app works with Browser AI (WebLLM/Transformers.js) without any external services. - # - # If you need to proxy requests to Ollama, either: - # 1. Set the Ollama URL directly in the app settings (e.g., http://your-nas-ip:11434) - # 2. Or mount a custom nginx.conf with your proxy configuration -} +server { + listen 80; + + # Enable gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Root directory for the app + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # Proxy Ollama API requests + # This solves Mixed Content (HTTPS -> HTTP) and CORS issues + location /api/ { + proxy_pass http://ollama:11434/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # CORS headers (redundant if OLLAMA_ORIGINS is set, but good for safety) + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always; + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + } +} diff --git a/package-lock.json b/package-lock.json index 0473d86..8624fac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "@mlc-ai/web-llm": "^0.2.80", "@monaco-editor/react": "^4.7.0", "@types/dagre": "^0.7.53", + "@types/file-saver": "^2.0.7", "@types/randomcolor": "^0.5.9", "@xyflow/react": "^12.10.0", "clsx": "^2.1.1", "dagre": "^0.8.5", + "file-saver": "^2.0.5", "html-to-image": "^1.11.13", "lucide-react": "^0.562.0", "mermaid": "^11.12.2", @@ -170,7 +172,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -577,7 +578,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -621,7 +621,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1236,9 +1235,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz", - "integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz", + "integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==", "dev": true, "license": "MIT", "engines": { @@ -2736,7 +2735,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3067,6 +3067,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -3174,7 +3180,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3185,7 +3190,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3266,7 +3270,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -3688,7 +3691,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3739,6 +3741,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3884,7 +3887,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4187,7 +4189,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -4588,7 +4589,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4817,12 +4817,13 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4969,7 +4970,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5238,6 +5238,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5911,7 +5917,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -6436,6 +6441,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6451,15 +6457,15 @@ } }, "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/matcher": { @@ -6509,18 +6515,6 @@ "uuid": "^11.1.0" } }, - "node_modules/mermaid/node_modules/marked": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", - "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6615,6 +6609,29 @@ "marked": "14.0.0" } }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6929,7 +6946,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7033,6 +7049,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7048,6 +7065,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7129,7 +7147,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7139,7 +7156,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7152,7 +7168,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -7809,9 +7826,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", + "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", "dev": true, "license": "MIT", "engines": { @@ -7868,7 +7885,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7988,7 +8004,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8393,7 +8408,6 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz", "integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.99" }, @@ -8425,7 +8439,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -8466,7 +8479,6 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/package.json b/package.json index ef30173..553c097 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,12 @@ "@mlc-ai/web-llm": "^0.2.80", "@monaco-editor/react": "^4.7.0", "@types/dagre": "^0.7.53", + "@types/file-saver": "^2.0.7", "@types/randomcolor": "^0.5.9", "@xyflow/react": "^12.10.0", "clsx": "^2.1.1", "dagre": "^0.8.5", + "file-saver": "^2.0.5", "html-to-image": "^1.11.13", "lucide-react": "^0.562.0", "mermaid": "^11.12.2", diff --git a/public/manifest.json b/public/manifest.json index a791bdb..60bc193 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,25 +1,25 @@ -{ - "name": "SysVis.AI - System Design Visualizer", - "short_name": "SysVis.AI", - "description": "AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts", - "start_url": "/", - "display": "standalone", - "background_color": "#0B1221", - "theme_color": "#0B1221", - "orientation": "any", - "icons": [ - { - "src": "/vite.svg", - "sizes": "any", - "type": "image/svg+xml", - "purpose": "any" - } - ], - "categories": [ - "productivity", - "developer tools", - "design" - ], - "scope": "/", - "lang": "en" +{ + "name": "SysVis.AI - System Design Visualizer", + "short_name": "SysVis.AI", + "description": "AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts", + "start_url": "/", + "display": "standalone", + "background_color": "#0B1221", + "theme_color": "#0B1221", + "orientation": "any", + "icons": [ + { + "src": "/vite.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ], + "categories": [ + "productivity", + "developer tools", + "design" + ], + "scope": "/", + "lang": "en" } \ No newline at end of file diff --git a/scripts/setup-ollama-models.ps1 b/scripts/setup-ollama-models.ps1 index a944cbf..b8418a4 100644 --- a/scripts/setup-ollama-models.ps1 +++ b/scripts/setup-ollama-models.ps1 @@ -1,38 +1,38 @@ -# Check if Ollama is installed -if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) { - Write-Host "Error: Ollama is not installed or not in your PATH." -ForegroundColor Red - Write-Host "Please install Ollama from https://ollama.com/" - exit 1 -} - -Write-Host "Checking local Ollama service status..." -ForegroundColor Cyan -try { - $status = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -ErrorAction Stop - Write-Host "Ollama service is running!" -ForegroundColor Green -} catch { - Write-Host "Error: Could not connect to Ollama at http://localhost:11434" -ForegroundColor Red - Write-Host "Please ensure 'ollama serve' is running in another terminal." - exit 1 -} - -# Define recommended models -$models = @( - @{ Name = "moondream"; Desc = "Tiny, fast vision model (1.7GB). Best for low-end hardware." }, - @{ Name = "llava-phi3"; Desc = "High-performance small vision model (2.3GB). Good balance." }, - @{ Name = "llama3"; Desc = "Fast standard LLM for text logic (4.7GB)." } -) - -Write-Host "`nReady to pull recommended models:" -ForegroundColor Yellow -foreach ($m in $models) { - Write-Host " - $($m.Name): $($m.Desc)" -} - -$confirm = Read-Host "`nDo you want to pull these models now? (Y/n)" -if ($confirm -eq 'n') { exit } - -foreach ($m in $models) { - Write-Host "`nPulling $($m.Name)..." -ForegroundColor Cyan - ollama pull $m.Name -} - -Write-Host "`nAll models ready! You can now select them in the KV-Graph settings." -ForegroundColor Green +# Check if Ollama is installed +if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) { + Write-Host "Error: Ollama is not installed or not in your PATH." -ForegroundColor Red + Write-Host "Please install Ollama from https://ollama.com/" + exit 1 +} + +Write-Host "Checking local Ollama service status..." -ForegroundColor Cyan +try { + $status = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -ErrorAction Stop + Write-Host "Ollama service is running!" -ForegroundColor Green +} catch { + Write-Host "Error: Could not connect to Ollama at http://localhost:11434" -ForegroundColor Red + Write-Host "Please ensure 'ollama serve' is running in another terminal." + exit 1 +} + +# Define recommended models +$models = @( + @{ Name = "moondream"; Desc = "Tiny, fast vision model (1.7GB). Best for low-end hardware." }, + @{ Name = "llava-phi3"; Desc = "High-performance small vision model (2.3GB). Good balance." }, + @{ Name = "llama3"; Desc = "Fast standard LLM for text logic (4.7GB)." } +) + +Write-Host "`nReady to pull recommended models:" -ForegroundColor Yellow +foreach ($m in $models) { + Write-Host " - $($m.Name): $($m.Desc)" +} + +$confirm = Read-Host "`nDo you want to pull these models now? (Y/n)" +if ($confirm -eq 'n') { exit } + +foreach ($m in $models) { + Write-Host "`nPulling $($m.Name)..." -ForegroundColor Cyan + ollama pull $m.Name +} + +Write-Host "`nAll models ready! You can now select them in the KV-Graph settings." -ForegroundColor Green diff --git a/src/components/FlowCanvas.tsx b/src/components/FlowCanvas.tsx index 2fc4230..1a9229a 100644 --- a/src/components/FlowCanvas.tsx +++ b/src/components/FlowCanvas.tsx @@ -231,99 +231,156 @@ export function FlowCanvas() { size={1} /> - {/* Control Panel - Top Right (Unified Toolkit) */} - -
- + > + + Toolkit + + - {/* Dropdown Menu */} - {showToolkit && ( -
+ {/* Dropdown Menu */} + {showToolkit && ( +
- {/* Section: Interaction Mode */} -
- Mode -
- - + {/* Section: Interaction Mode */} +
+ Mode +
+ + +
+
+ +
+ + {/* Section: View Controls */} +
+ View +
+ zoomOut()} label="Out" /> + zoomIn()} label="In" /> + +
+
+ +
+ + {/* Section: Layout & Overlays */} +
+ Actions + + + + setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')} + /> + + setShowMiniMap(!showMiniMap)} + />
+ )} +
-
+ + )} - {/* Section: View Controls */} -
- View -
- zoomOut()} label="Out" /> - zoomIn()} label="In" /> - -
-
+ {/* Mobile Bottom Action Bar */} + {isMobile && nodes.length > 0 && !focusMode && ( + +
+ {/* Zoom Out */} + -
+ {/* Zoom In */} + - {/* Section: Layout & Overlays */} -
- Actions + {/* Fit View */} + - + {/* Divider */} +
- setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')} - /> + {/* Auto Layout */} + - setShowMiniMap(!showMiniMap)} - /> -
-
- )} -
- - + {/* Toggle Edge Style */} + +
+
+ )} {/* MiniMap Container - Bottom Right (Hidden on Mobile) */} @@ -342,8 +399,8 @@ export function FlowCanvas() { )} - {/* Status Indicator - Bottom Left */} - {nodes.length > 0 && ( + {/* Status Indicator - Bottom Left (Hidden on Mobile - shown in header) */} + {nodes.length > 0 && !isMobile && (
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 5cb0cf7..ac1875b 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -189,17 +189,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
-
+
-
- +
+
-

System Settings

+

System Settings

Configuration

@@ -210,14 +210,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { Lite
)} -
{/* Scrollable Content Area */} -
+
{/* Mode Selection */}
- ); - } - - // Expanded State (Floating Card) - return ( -
- - {/* Header */} -
-
- - Organizer -
- -
- - {/* Content */} -
- {status === 'analyzing' && ( -
-
-
-
-
- -
-
-

Analyzing structure...

-
- )} - - {status === 'ready' && bestSuggestion && ( -
-
- -

Optimization Ready

-

{bestSuggestion.description}

-
-
- - -
-
- )} - - {status === 'applied' && ( -
-
- -

Clean & Organized!

-
-
- - -
-
- )} -
-
-
- ); -}; +import React, { useState } from 'react'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; +import { useVisualOrganizer } from '../hooks/useVisualOrganizer'; +import { useDiagramStore } from '../store'; +import { Sparkles, Wand2, Scan, CheckCircle2, X } from 'lucide-react'; +import type { LayoutSuggestion } from '../types/visualOrganization'; + +export const VisualOrganizerFAB: React.FC = () => { + const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer(); + const { nodes, edges, setNodes, setEdges } = useDiagramStore(); + + // UI States + const [isOpen, setIsOpen] = useState(false); + const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle'); + const [bestSuggestion, setBestSuggestion] = useState(null); + const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null); + + const handleAIOrganize = async () => { + setStatus('analyzing'); + setIsOpen(true); + + analyzeLayout(); + + try { + await new Promise(resolve => setTimeout(resolve, 1500)); + const results = await generateSuggestions(); + + if (results.length > 0) { + setBestSuggestion(results[0]); + setStatus('ready'); + } else { + setStatus('idle'); + // Could show a generic "No suggestions" toast here + setIsOpen(false); + } + } catch (error) { + console.error(error); + setStatus('idle'); + setIsOpen(false); + } + }; + + const handleApply = () => { + if (!bestSuggestion) return; + setSnapshot({ nodes: [...nodes], edges: [...edges] }); + applySuggestion(bestSuggestion); + setStatus('applied'); + }; + + const handleUndo = () => { + if (snapshot) { + setNodes(snapshot.nodes); + setEdges(snapshot.edges); + setSnapshot(null); + setStatus('ready'); + } + }; + + const handleClose = () => { + setIsOpen(false); + if (status === 'applied') { + setStatus('idle'); + setSnapshot(null); + setBestSuggestion(null); + } + }; + + // If closed, show just the FAB + if (!isOpen) { + return ( + + ); + } + + // Expanded State (Floating Card) + return ( +
+ + {/* Header */} +
+
+ + Organizer +
+ +
+ + {/* Content */} +
+ {status === 'analyzing' && ( +
+
+
+
+
+ +
+
+

Analyzing structure...

+
+ )} + + {status === 'ready' && bestSuggestion && ( +
+
+ +

Optimization Ready

+

{bestSuggestion.description}

+
+
+ + +
+
+ )} + + {status === 'applied' && ( +
+
+ +

Clean & Organized!

+
+
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/src/components/VisualOrganizerPanel.tsx b/src/components/VisualOrganizerPanel.tsx index d12a4c1..79644f2 100644 --- a/src/components/VisualOrganizerPanel.tsx +++ b/src/components/VisualOrganizerPanel.tsx @@ -1,194 +1,194 @@ -import React, { useState } from 'react'; -import { Button } from './ui/Button'; -import { Card } from './ui/Card'; -import { useVisualOrganizer } from '../hooks/useVisualOrganizer'; -import { useDiagramStore } from '../store'; -import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react'; -import type { LayoutSuggestion } from '../types/visualOrganization'; - -export const VisualOrganizerPanel: React.FC = () => { - const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer(); - const { nodes, edges, setNodes, setEdges } = useDiagramStore(); - - // UI States - const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle'); - const [bestSuggestion, setBestSuggestion] = useState(null); - const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null); - - // AI Organize Handler - const handleAIOrganize = async () => { - setStatus('analyzing'); - - // 1. Analyze (Simulate brief delay for effect) - analyzeLayout(); - - // 2. Generate Suggestions - try { - // Artificial delay for "Scanning" animation effect - await new Promise(resolve => setTimeout(resolve, 1500)); - - const results = await generateSuggestions(); - - // Pick best suggestion (or default to first non-current) - if (results.length > 0) { - setBestSuggestion(results[0]); - setStatus('ready'); - } else { - // Fallback if no suggestions - setStatus('idle'); - } - } catch (error) { - console.error(error); - setStatus('idle'); - } - }; - - const handleApply = () => { - if (!bestSuggestion) return; - - // Take snapshot before applying - setSnapshot({ nodes: [...nodes], edges: [...edges] }); - - applySuggestion(bestSuggestion); - setStatus('applied'); - }; - - const handleUndo = () => { - if (snapshot) { - setNodes(snapshot.nodes); - setEdges(snapshot.edges); - setSnapshot(null); - setStatus('ready'); - } - }; - - const handleReset = () => { - setStatus('idle'); - setSnapshot(null); - setBestSuggestion(null); - }; - - return ( -
- - - {/* IDLE STATE: Main AI Button */} - {status === 'idle' && ( -
-
-
-
- -
-
- -
-
- -
-

AI Visual Organizer

-

- Click to instantly analyze and reorganize your flow for maximum clarity. -

-
- - -
- )} - - {/* ANALYZING STATE: Scanning Animation */} - {status === 'analyzing' && ( -
-
-
-
-
- -
-
-

- Analyzing Layout Logic... -

-
- )} - - {/* READY STATE: Suggestion Found */} - {status === 'ready' && bestSuggestion && ( -
-
- - Optimization Found! -
- -
-
- -

{bestSuggestion.title}

-
-

- {bestSuggestion.description} -

-
- -
- - -
-
- )} - - {/* APPLIED STATE: Success & Undo */} - {status === 'applied' && ( -
-
- -
- -
-

Beautifully Organized!

-

- Your graph has been transformed. -

-
- -
- - -
-
- )} - -
-
- ); -}; +import React, { useState } from 'react'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; +import { useVisualOrganizer } from '../hooks/useVisualOrganizer'; +import { useDiagramStore } from '../store'; +import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react'; +import type { LayoutSuggestion } from '../types/visualOrganization'; + +export const VisualOrganizerPanel: React.FC = () => { + const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer(); + const { nodes, edges, setNodes, setEdges } = useDiagramStore(); + + // UI States + const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle'); + const [bestSuggestion, setBestSuggestion] = useState(null); + const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null); + + // AI Organize Handler + const handleAIOrganize = async () => { + setStatus('analyzing'); + + // 1. Analyze (Simulate brief delay for effect) + analyzeLayout(); + + // 2. Generate Suggestions + try { + // Artificial delay for "Scanning" animation effect + await new Promise(resolve => setTimeout(resolve, 1500)); + + const results = await generateSuggestions(); + + // Pick best suggestion (or default to first non-current) + if (results.length > 0) { + setBestSuggestion(results[0]); + setStatus('ready'); + } else { + // Fallback if no suggestions + setStatus('idle'); + } + } catch (error) { + console.error(error); + setStatus('idle'); + } + }; + + const handleApply = () => { + if (!bestSuggestion) return; + + // Take snapshot before applying + setSnapshot({ nodes: [...nodes], edges: [...edges] }); + + applySuggestion(bestSuggestion); + setStatus('applied'); + }; + + const handleUndo = () => { + if (snapshot) { + setNodes(snapshot.nodes); + setEdges(snapshot.edges); + setSnapshot(null); + setStatus('ready'); + } + }; + + const handleReset = () => { + setStatus('idle'); + setSnapshot(null); + setBestSuggestion(null); + }; + + return ( +
+ + + {/* IDLE STATE: Main AI Button */} + {status === 'idle' && ( +
+
+
+
+ +
+
+ +
+
+ +
+

AI Visual Organizer

+

+ Click to instantly analyze and reorganize your flow for maximum clarity. +

+
+ + +
+ )} + + {/* ANALYZING STATE: Scanning Animation */} + {status === 'analyzing' && ( +
+
+
+
+
+ +
+
+

+ Analyzing Layout Logic... +

+
+ )} + + {/* READY STATE: Suggestion Found */} + {status === 'ready' && bestSuggestion && ( +
+
+ + Optimization Found! +
+ +
+
+ +

{bestSuggestion.title}

+
+

+ {bestSuggestion.description} +

+
+ +
+ + +
+
+ )} + + {/* APPLIED STATE: Success & Undo */} + {status === 'applied' && ( +
+
+ +
+ +
+

Beautifully Organized!

+

+ Your graph has been transformed. +

+
+ +
+ + +
+
+ )} + +
+
+ ); +}; diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index ad06e88..d81d58d 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -1,7 +1,8 @@ import { Link } from 'react-router-dom'; import { Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save, - ChevronDown, FileCode, ImageIcon, FileText, Frame, Cloud, Server, Cpu, RotateCcw, RotateCw + ChevronDown, FileCode, ImageIcon, FileText, Frame, Cloud, Server, Cpu, RotateCcw, RotateCw, + Menu, X, Home } from 'lucide-react'; import { useStore } from 'zustand'; import { useFlowStore, useDiagramStore } from '../../store'; @@ -13,6 +14,49 @@ import { import { useState } from 'react'; import { SettingsModal } from '../Settings'; +// Mobile Menu Item Component - extracted outside to avoid hook issues +interface MobileMenuItemProps { + icon: any; + label: string; + onClick: () => void; + active?: boolean; + disabled?: boolean; + variant?: 'default' | 'primary' | 'success'; + iconColor?: string; +} + +function MobileMenuItem({ + icon: Icon, + label, + onClick, + active = false, + disabled = false, + variant = 'default', + iconColor = '' +}: MobileMenuItemProps) { + return ( + + ); +} + export function EditorHeader() { const { nodes, edges, leftPanelOpen, setLeftPanelOpen, @@ -24,11 +68,17 @@ export function EditorHeader() { } = useFlowStore(); const { isMobile } = useMobileDetect(); + // Temporal state hooks - MUST be called unconditionally at top level + const canUndo = useStore(useDiagramStore.temporal, (state: any) => state.pastStates.length > 0); + const canRedo = useStore(useDiagramStore.temporal, (state: any) => state.futureStates.length > 0); + const [showSettings, setShowSettings] = useState(false); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [showExportMenu, setShowExportMenu] = useState(false); + const [showMobileMenu, setShowMobileMenu] = useState(false); const handleSave = () => { + setShowMobileMenu(false); const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`); if (name) { setSaveStatus('saving'); @@ -44,6 +94,7 @@ export function EditorHeader() { const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => { setShowExportMenu(false); + setShowMobileMenu(false); const viewport = document.querySelector('.react-flow__viewport') as HTMLElement; try { @@ -72,6 +123,260 @@ export function EditorHeader() { } }; + const handleOpenSettings = () => { + setShowMobileMenu(false); + setShowSettings(true); + }; + + + + // MOBILE HEADER + if (isMobile) { + return ( + <> +
+ {/* Left: Logo + Back */} +
+ + + +
+
+ +
+ SysVis +
+
+ + {/* Center: Node count (if any) */} + {nodes.length > 0 && ( +
+
+ + {nodes.filter(n => n.type !== 'group').length} nodes + +
+ )} + + {/* Right: Menu Button */} + +
+ + {/* Mobile Full-Screen Menu */} + {showMobileMenu && ( +
+ {/* Backdrop */} +
setShowMobileMenu(false)} + /> + + {/* Menu Panel - slides from right */} +
+ {/* Menu Header */} +
+
+
+ +
+ Menu +
+ +
+ + {/* Menu Content - Scrollable */} +
+ {/* AI Mode Indicator */} +
+ {aiMode === 'offline' ? ( + <> + +
+ Local Mode +

Using Ollama

+
+ + ) : aiMode === 'browser' ? ( + <> + +
+ Browser Mode +

WebLLM In-Device

+
+ + ) : ( + <> + +
+ Cloud Mode +

+ {onlineProvider === 'openai' ? 'OpenAI' : onlineProvider === 'gemini' ? 'Gemini' : 'Cloud AI'} +

+
+ + )} +
+ + {/* History Section */} +
+

History

+
+ + +
+
+ + {/* View Section */} +
+

View

+
+ { + setFocusMode(!focusMode); + setShowMobileMenu(false); + }} + active={focusMode} + /> + { + toggleTheme(); + setShowMobileMenu(false); + }} + /> +
+
+ + {/* Actions Section */} +
+

Actions

+
+ + +
+
+ + {/* Export Section */} +
+

Export

+
+ handleExport('code')} + disabled={nodes.length === 0} + iconColor="text-blue-500" + /> + handleExport('json')} + disabled={nodes.length === 0} + iconColor="text-purple-500" + /> + handleExport('png')} + disabled={nodes.length === 0} + iconColor="text-indigo-500" + /> + handleExport('jpg')} + disabled={nodes.length === 0} + iconColor="text-amber-500" + /> + handleExport('svg')} + disabled={nodes.length === 0} + iconColor="text-emerald-500" + /> + handleExport('txt')} + disabled={nodes.length === 0} + iconColor="text-slate-400" + /> +
+
+
+ + {/* Menu Footer */} +
+ + + Back to Dashboard + +
+
+
+ )} + + setShowSettings(false)} + /> + + ); + } + + // DESKTOP HEADER (unchanged) return (
@@ -88,7 +393,7 @@ export function EditorHeader() {
- {!isMobile && ( - <> -
-
- - -
- - )} +
+
+ + +
{/* AI Mode Status - Desktop Only */} - {!isMobile && ( -
- {aiMode === 'offline' ? ( - <> - - Local - - ) : aiMode === 'browser' ? ( - <> - - Browser - - ) : ( - <> - - Cloud - - )} -
- )} +
+ {aiMode === 'offline' ? ( + <> + + Local + + ) : aiMode === 'browser' ? ( + <> + + Browser + + ) : ( + <> + + Cloud + + )} +
- ))} -
- - {/* Right: Actions */} -
- -
-
- ); -} +import { Loader2, Zap } from 'lucide-react'; + +interface EditorToolbarProps { + handleGenerate: () => void; + isLoading: boolean; + hasCode: boolean; + hasCode: boolean; +} + +import { usePluginStore } from '../../store/pluginStore'; + +export function EditorToolbar({ + handleGenerate, + isLoading, + hasCode, +}: EditorToolbarProps) { + const { toolbarItems } = usePluginStore(); + return ( +
+ + + + {/* Center: Insert Tools */} +
+ + + {/* Plugin Items */} + {toolbarItems.map((item) => ( + + ))} +
+ + {/* Right: Actions */} +
+ +
+
+ ); +} diff --git a/src/components/editor/MonacoWrapper.tsx b/src/components/editor/MonacoWrapper.tsx index 2f1c86f..2178e82 100644 --- a/src/components/editor/MonacoWrapper.tsx +++ b/src/components/editor/MonacoWrapper.tsx @@ -1,107 +1,107 @@ - -import Editor, { type OnMount } from '@monaco-editor/react'; -import { AlertCircle, Trash2 } from 'lucide-react'; - -interface MonacoWrapperProps { - code: string; - onChange: (value: string | undefined) => void; - onMount: OnMount; - theme: 'light' | 'dark'; - syntaxErrors: { line: number; message: string }[]; - - setCode: (code: string) => void; -} - -export function MonacoWrapper({ - code, - onChange, - onMount, - theme, - syntaxErrors, - setCode -}: MonacoWrapperProps) { - // monacoRef removed as it was unused - - // Define themes once on mount or when theme prop changes appropriately - // For simplicity, we can do it inside a useEffect here if we have access to monaco instance, - // but usually it's better to do it once. - // However, since we need monaco instance, we'll assume the parent component or this component handles it via onMount/useEffect. - - // Actually, let's keep the theme definition inside the component for now or rely on the parent to pass the ref if needed. - // But better yet, let's expose specific theme logic or keep it self-contained if possible. - // The original code defined themes in useEffect when monacoRef was available. - - return ( -
- {/* Internal Badges */} -
- - Mermaid - -
-
- - Manual - -
- - - {/* Floating Action Buttons */} -
-
- -
-
- - {/* Syntax Errors */} - {syntaxErrors.length > 0 && ( -
-
- -
-

Syntax Error

-

{syntaxErrors[0].message}

-
-
-
- )} -
- ); -} + +import Editor, { type OnMount } from '@monaco-editor/react'; +import { AlertCircle, Trash2 } from 'lucide-react'; + +interface MonacoWrapperProps { + code: string; + onChange: (value: string | undefined) => void; + onMount: OnMount; + theme: 'light' | 'dark'; + syntaxErrors: { line: number; message: string }[]; + + setCode: (code: string) => void; +} + +export function MonacoWrapper({ + code, + onChange, + onMount, + theme, + syntaxErrors, + setCode +}: MonacoWrapperProps) { + // monacoRef removed as it was unused + + // Define themes once on mount or when theme prop changes appropriately + // For simplicity, we can do it inside a useEffect here if we have access to monaco instance, + // but usually it's better to do it once. + // However, since we need monaco instance, we'll assume the parent component or this component handles it via onMount/useEffect. + + // Actually, let's keep the theme definition inside the component for now or rely on the parent to pass the ref if needed. + // But better yet, let's expose specific theme logic or keep it self-contained if possible. + // The original code defined themes in useEffect when monacoRef was available. + + return ( +
+ {/* Internal Badges */} +
+ + Mermaid + +
+
+ + Manual + +
+ + + {/* Floating Action Buttons */} +
+
+ +
+
+ + {/* Syntax Errors */} + {syntaxErrors.length > 0 && ( +
+
+ +
+

Syntax Error

+

{syntaxErrors[0].message}

+
+
+
+ )} +
+ ); +} diff --git a/src/components/nodes/ShapeNode.tsx b/src/components/nodes/ShapeNode.tsx index b87c842..8cec1e9 100644 --- a/src/components/nodes/ShapeNode.tsx +++ b/src/components/nodes/ShapeNode.tsx @@ -1,69 +1,69 @@ -import { memo } from 'react'; -import { Handle, Position } from '@xyflow/react'; -import { NODE_STYLES } from './CustomNodes'; -import { SHAPE_PATHS } from '../../lib/shapes'; - -// Map mermaid types to shape names if needed, or use raw type -// Default fallback -const DEFAULT_SHAPE = 'rect'; - -export const ShapeNode = memo(({ data, selected, type }: any) => { - const rawShape = (data.shape || 'rect').toLowerCase(); - - // Normalize shape names - let shapeKey = rawShape; - if (rawShape === 'database') shapeKey = 'cyl'; - if (rawShape === 'decision') shapeKey = 'diamond'; - if (rawShape === 'process') shapeKey = 'rect'; - if (rawShape === 'start') shapeKey = 'stadium'; - if (rawShape === 'end') shapeKey = 'stadium'; - - const pathData = SHAPE_PATHS[shapeKey] || SHAPE_PATHS[DEFAULT_SHAPE]; - - // Style logic similar to StandardNode - // We can infer style from category or just use default - const category = data.category || 'default'; - // Mapping categories to styles in CustomNodes is logic we might want to reuse or replicate - // For now let's use a default blue or check if we can import logic. - // Since we are in a separate file, we might not have access to helper 'getNodeStyle'. - // Let's rely on data.category which mermaidParser sets. - - // Map parser categories to NODE_STYLES keys - let styleKey = 'default'; - if (category === 'filter-db') styleKey = 'data'; - else if (category === 'filter-client') styleKey = 'team'; // or platform - else if (category === 'filter-server') styleKey = 'tech'; - - // Override based on shape - if (shapeKey === 'cyl') styleKey = 'data'; - if (shapeKey === 'diamond') styleKey = 'decision'; - if (shapeKey === 'stadium' && (type === 'start' || type === 'end')) styleKey = type; - - // @ts-ignore - const themeStyle = NODE_STYLES[styleKey] || NODE_STYLES.default; - - return ( -
- - - - - {/* Label Overlay */} -
- - {data.label} - -
- - {/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */} - - - - -
- ); -}); +import { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { NODE_STYLES } from './CustomNodes'; +import { SHAPE_PATHS } from '../../lib/shapes'; + +// Map mermaid types to shape names if needed, or use raw type +// Default fallback +const DEFAULT_SHAPE = 'rect'; + +export const ShapeNode = memo(({ data, selected, type }: any) => { + const rawShape = (data.shape || 'rect').toLowerCase(); + + // Normalize shape names + let shapeKey = rawShape; + if (rawShape === 'database') shapeKey = 'cyl'; + if (rawShape === 'decision') shapeKey = 'diamond'; + if (rawShape === 'process') shapeKey = 'rect'; + if (rawShape === 'start') shapeKey = 'stadium'; + if (rawShape === 'end') shapeKey = 'stadium'; + + const pathData = SHAPE_PATHS[shapeKey] || SHAPE_PATHS[DEFAULT_SHAPE]; + + // Style logic similar to StandardNode + // We can infer style from category or just use default + const category = data.category || 'default'; + // Mapping categories to styles in CustomNodes is logic we might want to reuse or replicate + // For now let's use a default blue or check if we can import logic. + // Since we are in a separate file, we might not have access to helper 'getNodeStyle'. + // Let's rely on data.category which mermaidParser sets. + + // Map parser categories to NODE_STYLES keys + let styleKey = 'default'; + if (category === 'filter-db') styleKey = 'data'; + else if (category === 'filter-client') styleKey = 'team'; // or platform + else if (category === 'filter-server') styleKey = 'tech'; + + // Override based on shape + if (shapeKey === 'cyl') styleKey = 'data'; + if (shapeKey === 'diamond') styleKey = 'decision'; + if (shapeKey === 'stadium' && (type === 'start' || type === 'end')) styleKey = type; + + // @ts-ignore + const themeStyle = NODE_STYLES[styleKey] || NODE_STYLES.default; + + return ( +
+ + + + + {/* Label Overlay */} +
+ + {data.label} + +
+ + {/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */} + + + + +
+ ); +}); diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 468e673..765d485 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,38 +1,38 @@ -import React from 'react'; - -interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; - size?: 'sm' | 'md' | 'lg'; -} - -export const Button: React.FC = ({ - children, - variant = 'primary', - size = 'md', - className = '', - ...props -}) => { - const baseStyles = "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none"; - - const variants = { - primary: "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500", - secondary: "bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 focus:ring-indigo-500", - danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", - ghost: "bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 focus:ring-gray-500" - }; - - const sizes = { - sm: "px-3 py-1.5 text-xs", - md: "px-4 py-2 text-sm", - lg: "px-6 py-3 text-base" - }; - - return ( - - ); -}; +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; +} + +export const Button: React.FC = ({ + children, + variant = 'primary', + size = 'md', + className = '', + ...props +}) => { + const baseStyles = "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none"; + + const variants = { + primary: "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500", + secondary: "bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 focus:ring-indigo-500", + danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", + ghost: "bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 focus:ring-gray-500" + }; + + const sizes = { + sm: "px-3 py-1.5 text-xs", + md: "px-4 py-2 text-sm", + lg: "px-6 py-3 text-base" + }; + + return ( + + ); +}; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index e530bb1..9a8820a 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -1,28 +1,28 @@ -import React from 'react'; - -interface CardProps extends React.HTMLAttributes { - padding?: 'none' | 'sm' | 'md' | 'lg'; -} - -export const Card: React.FC = ({ - children, - className = '', - padding = 'md', - ...props -}) => { - const paddings = { - none: '', - sm: 'p-3', - md: 'p-4', - lg: 'p-6' - }; - - return ( -
- {children} -
- ); -}; +import React from 'react'; + +interface CardProps extends React.HTMLAttributes { + padding?: 'none' | 'sm' | 'md' | 'lg'; +} + +export const Card: React.FC = ({ + children, + className = '', + padding = 'md', + ...props +}) => { + const paddings = { + none: '', + sm: 'p-3', + md: 'p-4', + lg: 'p-6' + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/ui/OrchestratorLoader.tsx b/src/components/ui/OrchestratorLoader.tsx index f45e322..affae05 100644 --- a/src/components/ui/OrchestratorLoader.tsx +++ b/src/components/ui/OrchestratorLoader.tsx @@ -1,23 +1,23 @@ - - - -export function OrchestratorLoader() { - return ( -
-
- {/* Glow effect */} -
- - {/* Rotating Diamond */} -
- - {/* Inner accent (optional, based on image "feel") */} -
-
- -

- Orchestrating logic -

-
- ); -} + + + +export function OrchestratorLoader() { + return ( +
+
+ {/* Glow effect */} +
+ + {/* Rotating Diamond */} +
+ + {/* Inner accent (optional, based on image "feel") */} +
+
+ +

+ Orchestrating logic +

+
+ ); +} diff --git a/src/hooks/useDiagramAPI.ts b/src/hooks/useDiagramAPI.ts index 825fa96..a476a1a 100644 --- a/src/hooks/useDiagramAPI.ts +++ b/src/hooks/useDiagramAPI.ts @@ -1,70 +1,70 @@ -import { useEffect, useRef } from 'react'; -import { useFlowStore } from '../store'; -import { useSearchParams } from 'react-router-dom'; - -/** - * useDiagramAPI - * - * Enables programmatic control of the diagram editor via: - * 1. URL Parameters: ?code= - * 2. Window Messages: PostMessage for embedding scenarios - */ -export function useDiagramAPI() { - const [searchParams] = useSearchParams(); - const { setSourceCode, sourceCode } = useFlowStore(); - - // Create a ref for sourceCode to access current value in event listener without re-binding - const sourceCodeRef = useRef(sourceCode); - useEffect(() => { - sourceCodeRef.current = sourceCode; - }, [sourceCode]); - - // 1. Handle URL Parameters - useEffect(() => { - const codeParam = searchParams.get('code'); - if (codeParam) { - try { - // Decode Base64 - const decodedCode = atob(codeParam); - setSourceCode(decodedCode); - - // Clear param after consumption to avoid re-triggering or cluttering URL - // causing a reload? No, using router API. - // But better to keep it if user wants to share URL. - // Let's decided to keep it for "stateful URL" behavior for now. - // If we want to support "cleaning", we can uncomment below: - // setSearchParams(prev => { - // const next = new URLSearchParams(prev); - // next.delete('code'); - // return next; - // }); - } catch (e) { - console.error('Failed to decode diagram code from URL:', e); - } - } - }, [searchParams, setSourceCode]); - - // 2. Handle Window Messages (for generic iframe control) - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - // Security check: You might want to check event.origin here in production - - const { type, payload } = event.data; - - if (type === 'KVGRAPH_LOAD' && payload?.code) { - setSourceCode(payload.code); - } - - if (type === 'KVGRAPH_GET_CODE') { - // Reply with current code - event.source?.postMessage({ - type: 'KVGRAPH_CODE_RESPONSE', - payload: { code: sourceCodeRef.current } - }, { targetOrigin: event.origin }); - } - }; - - window.addEventListener('message', handleMessage); - return () => window.removeEventListener('message', handleMessage); - }, [setSourceCode]); -} +import { useEffect, useRef } from 'react'; +import { useFlowStore } from '../store'; +import { useSearchParams } from 'react-router-dom'; + +/** + * useDiagramAPI + * + * Enables programmatic control of the diagram editor via: + * 1. URL Parameters: ?code= + * 2. Window Messages: PostMessage for embedding scenarios + */ +export function useDiagramAPI() { + const [searchParams] = useSearchParams(); + const { setSourceCode, sourceCode } = useFlowStore(); + + // Create a ref for sourceCode to access current value in event listener without re-binding + const sourceCodeRef = useRef(sourceCode); + useEffect(() => { + sourceCodeRef.current = sourceCode; + }, [sourceCode]); + + // 1. Handle URL Parameters + useEffect(() => { + const codeParam = searchParams.get('code'); + if (codeParam) { + try { + // Decode Base64 + const decodedCode = atob(codeParam); + setSourceCode(decodedCode); + + // Clear param after consumption to avoid re-triggering or cluttering URL + // causing a reload? No, using router API. + // But better to keep it if user wants to share URL. + // Let's decided to keep it for "stateful URL" behavior for now. + // If we want to support "cleaning", we can uncomment below: + // setSearchParams(prev => { + // const next = new URLSearchParams(prev); + // next.delete('code'); + // return next; + // }); + } catch (e) { + console.error('Failed to decode diagram code from URL:', e); + } + } + }, [searchParams, setSourceCode]); + + // 2. Handle Window Messages (for generic iframe control) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + // Security check: You might want to check event.origin here in production + + const { type, payload } = event.data; + + if (type === 'KVGRAPH_LOAD' && payload?.code) { + setSourceCode(payload.code); + } + + if (type === 'KVGRAPH_GET_CODE') { + // Reply with current code + event.source?.postMessage({ + type: 'KVGRAPH_CODE_RESPONSE', + payload: { code: sourceCodeRef.current } + }, { targetOrigin: event.origin }); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [setSourceCode]); +} diff --git a/src/hooks/useMobileDetect.ts b/src/hooks/useMobileDetect.ts index da7531d..ba563a0 100644 --- a/src/hooks/useMobileDetect.ts +++ b/src/hooks/useMobileDetect.ts @@ -1,88 +1,88 @@ -import { useState, useEffect, useCallback } from 'react'; - -const MOBILE_BREAKPOINT = 768; - -interface MobileDetectResult { - isMobile: boolean; - isTouchDevice: boolean; - prefersReducedMotion: boolean; - viewportWidth: number; - viewportHeight: number; -} - -/** - * Hook for detecting mobile devices and touch capabilities. - * Provides reactive updates on viewport changes. - */ -export function useMobileDetect(): MobileDetectResult { - const [state, setState] = useState(() => ({ - isMobile: typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false, - isTouchDevice: typeof window !== 'undefined' ? 'ontouchstart' in window || navigator.maxTouchPoints > 0 : false, - prefersReducedMotion: typeof window !== 'undefined' - ? window.matchMedia('(prefers-reduced-motion: reduce)').matches - : false, - viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1024, - viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 768, - })); - - const handleResize = useCallback(() => { - setState(prev => { - const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT; - // Only update if values changed to prevent unnecessary re-renders - if ( - prev.isMobile === newIsMobile && - prev.viewportWidth === window.innerWidth && - prev.viewportHeight === window.innerHeight - ) { - return prev; - } - return { - ...prev, - isMobile: newIsMobile, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - }; - }); - }, []); - - useEffect(() => { - // Use visualViewport for better mobile keyboard handling - const viewport = window.visualViewport; - - const handleViewportResize = () => { - setState(prev => ({ - ...prev, - viewportHeight: viewport?.height || window.innerHeight, - })); - }; - - window.addEventListener('resize', handleResize); - viewport?.addEventListener('resize', handleViewportResize); - - // Check reduced motion preference changes - const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); - const handleMotionChange = (e: MediaQueryListEvent) => { - setState(prev => ({ ...prev, prefersReducedMotion: e.matches })); - }; - motionQuery.addEventListener('change', handleMotionChange); - - return () => { - window.removeEventListener('resize', handleResize); - viewport?.removeEventListener('resize', handleViewportResize); - motionQuery.removeEventListener('change', handleMotionChange); - }; - }, [handleResize]); - - return state; -} - -/** - * Utility function for one-time mobile check (non-reactive). - * Use in store initialization or outside React components. - */ -export function checkIsMobile(): boolean { - if (typeof window === 'undefined') return false; - return window.innerWidth < MOBILE_BREAKPOINT || 'ontouchstart' in window; -} - -export { MOBILE_BREAKPOINT }; +import { useState, useEffect, useCallback } from 'react'; + +const MOBILE_BREAKPOINT = 768; + +interface MobileDetectResult { + isMobile: boolean; + isTouchDevice: boolean; + prefersReducedMotion: boolean; + viewportWidth: number; + viewportHeight: number; +} + +/** + * Hook for detecting mobile devices and touch capabilities. + * Provides reactive updates on viewport changes. + */ +export function useMobileDetect(): MobileDetectResult { + const [state, setState] = useState(() => ({ + isMobile: typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false, + isTouchDevice: typeof window !== 'undefined' ? 'ontouchstart' in window || navigator.maxTouchPoints > 0 : false, + prefersReducedMotion: typeof window !== 'undefined' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false, + viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1024, + viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 768, + })); + + const handleResize = useCallback(() => { + setState(prev => { + const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT; + // Only update if values changed to prevent unnecessary re-renders + if ( + prev.isMobile === newIsMobile && + prev.viewportWidth === window.innerWidth && + prev.viewportHeight === window.innerHeight + ) { + return prev; + } + return { + ...prev, + isMobile: newIsMobile, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; + }); + }, []); + + useEffect(() => { + // Use visualViewport for better mobile keyboard handling + const viewport = window.visualViewport; + + const handleViewportResize = () => { + setState(prev => ({ + ...prev, + viewportHeight: viewport?.height || window.innerHeight, + })); + }; + + window.addEventListener('resize', handleResize); + viewport?.addEventListener('resize', handleViewportResize); + + // Check reduced motion preference changes + const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const handleMotionChange = (e: MediaQueryListEvent) => { + setState(prev => ({ ...prev, prefersReducedMotion: e.matches })); + }; + motionQuery.addEventListener('change', handleMotionChange); + + return () => { + window.removeEventListener('resize', handleResize); + viewport?.removeEventListener('resize', handleViewportResize); + motionQuery.removeEventListener('change', handleMotionChange); + }; + }, [handleResize]); + + return state; +} + +/** + * Utility function for one-time mobile check (non-reactive). + * Use in store initialization or outside React components. + */ +export function checkIsMobile(): boolean { + if (typeof window === 'undefined') return false; + return window.innerWidth < MOBILE_BREAKPOINT || 'ontouchstart' in window; +} + +export { MOBILE_BREAKPOINT }; diff --git a/src/hooks/useVisualOrganizer.ts b/src/hooks/useVisualOrganizer.ts index 180cc68..4f356db 100644 --- a/src/hooks/useVisualOrganizer.ts +++ b/src/hooks/useVisualOrganizer.ts @@ -1,87 +1,87 @@ -import { useCallback, useMemo } from 'react'; -import { useDiagramStore } from '../store'; -import { useSettingsStore } from '../store/settingsStore'; -import { createVisualOrganizer } from '../lib/visualOrganizer'; -import { analyzeVisualLayout } from '../lib/aiService'; -import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization'; - -export const useVisualOrganizer = () => { - const { nodes, edges, setNodes, setEdges } = useDiagramStore(); - const { aiMode, onlineProvider, apiKey, ollamaUrl, modelName } = useSettingsStore(); - - const visualOrganizer = useMemo(() => { - return createVisualOrganizer(nodes, edges); - }, [nodes, edges]); - - const analyzeLayout = useCallback(() => { - return visualOrganizer.analyzeLayout(); - }, [visualOrganizer]); - - const generateSuggestions = useCallback(async () => { - // 1. Get algorithmic suggestions - const algorithmicSuggestions = visualOrganizer.generateSuggestions(); - - // 2. Get AI suggestions if enabled - let aiSuggestions: LayoutSuggestion[] = []; - try { - const analysisResult = visualOrganizer.analyzeLayout(); - const aiResult = await analyzeVisualLayout( - nodes, - edges, - analysisResult.metrics, - ollamaUrl, - modelName, - aiMode, - onlineProvider, - apiKey - ); - - if (aiResult.success && aiResult.analysis?.suggestions) { - aiSuggestions = aiResult.analysis.suggestions.map((s: any) => ({ - id: s.id || `ai-${Math.random().toString(36).substr(2, 9)}`, - title: s.title, - description: s.description, - type: s.type || 'style', - impact: s.impact || 'low', - estimatedImprovement: 0, - beforeState: { metrics: analysisResult.metrics, issues: [] }, // AI doesn't calculate this yet - afterState: { metrics: analysisResult.metrics, estimatedIssues: [] }, - implementation: { - nodePositions: {}, // AI suggestions might not have positions yet - description: s.fix_strategy // Store strategy for possible future implementation - } - })); - } - } catch (error) { - console.warn('AI visual analysis failed:', error); - } - - return [...algorithmicSuggestions, ...aiSuggestions]; - }, [visualOrganizer, nodes, edges, aiMode, onlineProvider, apiKey, ollamaUrl, modelName]); - - const applySuggestion = useCallback((suggestion: LayoutSuggestion) => { - const { nodes: newNodes, edges: newEdges } = visualOrganizer.applySuggestion(suggestion); - setNodes(newNodes); - setEdges(newEdges); - }, [visualOrganizer, setNodes, setEdges]); - - const getPresets = useCallback(() => { - return visualOrganizer.getPresets(); - }, [visualOrganizer]); - - return { - analyzeLayout, - generateSuggestions, - applySuggestion, - getPresets, - visualOrganizer - }; -}; - -export type LayoutAnalysis = { - metrics: LayoutMetrics; - issues: VisualIssue[]; - strengths: string[]; -}; - -export default useVisualOrganizer; +import { useCallback, useMemo } from 'react'; +import { useDiagramStore } from '../store'; +import { useSettingsStore } from '../store/settingsStore'; +import { createVisualOrganizer } from '../lib/visualOrganizer'; +import { analyzeVisualLayout } from '../lib/aiService'; +import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization'; + +export const useVisualOrganizer = () => { + const { nodes, edges, setNodes, setEdges } = useDiagramStore(); + const { aiMode, onlineProvider, apiKey, ollamaUrl, modelName } = useSettingsStore(); + + const visualOrganizer = useMemo(() => { + return createVisualOrganizer(nodes, edges); + }, [nodes, edges]); + + const analyzeLayout = useCallback(() => { + return visualOrganizer.analyzeLayout(); + }, [visualOrganizer]); + + const generateSuggestions = useCallback(async () => { + // 1. Get algorithmic suggestions + const algorithmicSuggestions = visualOrganizer.generateSuggestions(); + + // 2. Get AI suggestions if enabled + let aiSuggestions: LayoutSuggestion[] = []; + try { + const analysisResult = visualOrganizer.analyzeLayout(); + const aiResult = await analyzeVisualLayout( + nodes, + edges, + analysisResult.metrics, + ollamaUrl, + modelName, + aiMode, + onlineProvider, + apiKey + ); + + if (aiResult.success && aiResult.analysis?.suggestions) { + aiSuggestions = aiResult.analysis.suggestions.map((s: any) => ({ + id: s.id || `ai-${Math.random().toString(36).substr(2, 9)}`, + title: s.title, + description: s.description, + type: s.type || 'style', + impact: s.impact || 'low', + estimatedImprovement: 0, + beforeState: { metrics: analysisResult.metrics, issues: [] }, // AI doesn't calculate this yet + afterState: { metrics: analysisResult.metrics, estimatedIssues: [] }, + implementation: { + nodePositions: {}, // AI suggestions might not have positions yet + description: s.fix_strategy // Store strategy for possible future implementation + } + })); + } + } catch (error) { + console.warn('AI visual analysis failed:', error); + } + + return [...algorithmicSuggestions, ...aiSuggestions]; + }, [visualOrganizer, nodes, edges, aiMode, onlineProvider, apiKey, ollamaUrl, modelName]); + + const applySuggestion = useCallback((suggestion: LayoutSuggestion) => { + const { nodes: newNodes, edges: newEdges } = visualOrganizer.applySuggestion(suggestion); + setNodes(newNodes); + setEdges(newEdges); + }, [visualOrganizer, setNodes, setEdges]); + + const getPresets = useCallback(() => { + return visualOrganizer.getPresets(); + }, [visualOrganizer]); + + return { + analyzeLayout, + generateSuggestions, + applySuggestion, + getPresets, + visualOrganizer + }; +}; + +export type LayoutAnalysis = { + metrics: LayoutMetrics; + issues: VisualIssue[]; + strengths: string[]; +}; + +export default useVisualOrganizer; diff --git a/src/lib/__tests__/aiService.test.ts b/src/lib/__tests__/aiService.test.ts index 11bb5b7..ead100b 100644 --- a/src/lib/__tests__/aiService.test.ts +++ b/src/lib/__tests__/aiService.test.ts @@ -1,154 +1,154 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { analyzeImage, interpretText } from '../aiService'; - -// Mock fetch global -const fetchMock = vi.fn(); -vi.stubGlobal('fetch', fetchMock); - -describe('aiService', () => { - beforeEach(() => { - fetchMock.mockReset(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('interpretText', () => { - it('should call online AI when provider is not local', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - choices: [{ - message: { - content: JSON.stringify({ - mermaidCode: 'flowchart TD\nA-->B', - metadata: {} - }) - } - }] - }), - text: async () => '' - }); - - const result = await interpretText('test prompt', '', 'gpt-4', 'online', 'openai', 'test-key'); - - expect(result.success).toBe(true); - expect(result.mermaidCode).toContain('flowchart TD'); - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('api.openai.com'), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Authorization': 'Bearer test-key' - }) - }) - ); - }); - - it('should call local Ollama when provider is local', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - message: { - content: JSON.stringify({ - mermaidCode: 'flowchart TD\nA-->B', - metadata: {} - }) - } - }), - text: async () => '' - }); - - // Using 'offline' mode correctly calls local AI - const result = await interpretText('test prompt', 'http://localhost:11434', 'llama3', 'offline', undefined, ''); - - expect(result.success).toBe(true); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:11434/api/chat', - expect.any(Object) - ); - }); - - it('should handle API errors gracefully', async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: 'Unauthorized', - text: async () => 'Unauthorized error text' - }); - - const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid API Key'); - }); - - it('should fetchWithRetry on transient errors', async () => { - // first call fails with 429 - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 429, - statusText: 'Too Many Requests', - text: async () => 'Rate limit exceeded' - }); - // second call succeeds - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - choices: [{ - message: { - content: JSON.stringify({ mermaidCode: 'flowchart TD', metadata: {} }) - } - }] - }), - text: async () => '' - }); - - // We expect it to retry, so use a short backoff or mock timers if possible. - // Here we rely on the mocked response sequence. - const result = await interpretText('retry test', '', 'gpt-4', 'online', 'openai', 'key'); - - expect(result.success).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('should fallback to error message on non-retryable errors', async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: 'Unauthorized', - text: async () => 'Unauthorized error text' - }); - - const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid API Key'); - }); - }); - - describe('analyzeImage', () => { - it('should successfully parse mermaid code from image analysis', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - choices: [{ - message: { - content: JSON.stringify({ - mermaidCode: 'flowchart LR\nX-->Y', - metadata: {} - }) - } - }] - }), - text: async () => '' - }); - - const result = await analyzeImage('base64data', '', 'gpt-4-vision', 'online', 'openai', 'key'); - - expect(result.success).toBe(true); - expect(result.mermaidCode).toContain('flowchart LR'); - }); - }); -}); +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { analyzeImage, interpretText } from '../aiService'; + +// Mock fetch global +const fetchMock = vi.fn(); +vi.stubGlobal('fetch', fetchMock); + +describe('aiService', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('interpretText', () => { + it('should call online AI when provider is not local', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ + message: { + content: JSON.stringify({ + mermaidCode: 'flowchart TD\nA-->B', + metadata: {} + }) + } + }] + }), + text: async () => '' + }); + + const result = await interpretText('test prompt', '', 'gpt-4', 'online', 'openai', 'test-key'); + + expect(result.success).toBe(true); + expect(result.mermaidCode).toContain('flowchart TD'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('api.openai.com'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': 'Bearer test-key' + }) + }) + ); + }); + + it('should call local Ollama when provider is local', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + message: { + content: JSON.stringify({ + mermaidCode: 'flowchart TD\nA-->B', + metadata: {} + }) + } + }), + text: async () => '' + }); + + // Using 'offline' mode correctly calls local AI + const result = await interpretText('test prompt', 'http://localhost:11434', 'llama3', 'offline', undefined, ''); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:11434/api/chat', + expect.any(Object) + ); + }); + + it('should handle API errors gracefully', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'Unauthorized error text' + }); + + const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid API Key'); + }); + + it('should fetchWithRetry on transient errors', async () => { + // first call fails with 429 + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: async () => 'Rate limit exceeded' + }); + // second call succeeds + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ + message: { + content: JSON.stringify({ mermaidCode: 'flowchart TD', metadata: {} }) + } + }] + }), + text: async () => '' + }); + + // We expect it to retry, so use a short backoff or mock timers if possible. + // Here we rely on the mocked response sequence. + const result = await interpretText('retry test', '', 'gpt-4', 'online', 'openai', 'key'); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should fallback to error message on non-retryable errors', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'Unauthorized error text' + }); + + const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid API Key'); + }); + }); + + describe('analyzeImage', () => { + it('should successfully parse mermaid code from image analysis', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ + message: { + content: JSON.stringify({ + mermaidCode: 'flowchart LR\nX-->Y', + metadata: {} + }) + } + }] + }), + text: async () => '' + }); + + const result = await analyzeImage('base64data', '', 'gpt-4-vision', 'online', 'openai', 'key'); + + expect(result.success).toBe(true); + expect(result.mermaidCode).toContain('flowchart LR'); + }); + }); +}); diff --git a/src/lib/__tests__/mermaidParser.test.ts b/src/lib/__tests__/mermaidParser.test.ts index 5eeee11..8d6f723 100644 --- a/src/lib/__tests__/mermaidParser.test.ts +++ b/src/lib/__tests__/mermaidParser.test.ts @@ -1,105 +1,105 @@ -import { describe, it, expect, vi } from 'vitest'; -import { parseMermaid } from '../mermaidParser'; - -// Mock mermaid library -vi.mock('mermaid', () => { - return { - default: { - initialize: vi.fn(), - parse: vi.fn().mockResolvedValue(true), - mermaidAPI: { - getDiagramFromText: vi.fn().mockImplementation(async (text) => { - // Mock DB response based on text content - const vertices: any = {}; - const edges: any[] = []; - const subgraphs: any[] = []; - - if (text.includes('Start')) { - vertices['A'] = { id: 'A', text: 'Start', type: 'round' }; - vertices['B'] = { id: 'B', text: 'End', type: 'round' }; - edges.push({ start: 'A', end: 'B', text: undefined, stroke: 'normal' }); - } - if (text.includes('Group1')) { - subgraphs.push({ id: 'Group1', title: 'Group1', nodes: ['A'] }); - vertices['A'] = { id: 'A', text: 'Node A', type: 'round' }; - vertices['B'] = { id: 'B', text: 'Node B', type: 'round' }; - // Edge from A outside to B outside - edges.push({ start: 'A', end: 'B', text: undefined }); - } - if (text.includes('Database')) { - vertices['DB'] = { id: 'DB', text: 'Database', type: 'cylinder' }; - vertices['S'] = { id: 'S', text: 'Server', type: 'round' }; - vertices['C'] = { id: 'C', text: 'Client App', type: 'round' }; - } - - return { - db: { - getVertices: () => vertices, - getEdges: () => edges, - getSubGraphs: () => subgraphs, - } - }; - }) - } - } - }; -}); - -describe('mermaidParser', () => { - it('should parse a simple flowchart', async () => { - const code = ` - flowchart TD - A[Start] --> B[End] - `; - const { nodes, edges } = await parseMermaid(code); - - expect(nodes).toHaveLength(2); - expect(edges).toHaveLength(1); - expect(nodes[0].data.label).toBe('Start'); - expect(nodes[1].data.label).toBe('End'); - }); - - it('should handle subgraphs correctly', async () => { - const code = ` - flowchart TD - subgraph Group1 - A[Node A] - end - A --> B[Node B] - `; - const { nodes } = await parseMermaid(code); - - // Should have 3 nodes: Group1, A, B - const groupNode = nodes.find(n => n.type === 'group'); - const childNode = nodes.find(n => n.id === 'A'); - - expect(groupNode).toBeDefined(); - // The mock implementation ensures correct parentId association logic is tested - if (groupNode && childNode) { - expect(childNode.parentId).toBe(groupNode.id); - } - }); - - it('should infer node types from labels', async () => { - const code = ` - flowchart TD - DB[(Database)] --> S[Server] - S --> C([Client App]) - `; - const { nodes } = await parseMermaid(code); - - const dbNode = nodes.find(n => n.data.label === 'Database'); - const clientNode = nodes.find(n => n.data.label === 'Client App'); - const serverNode = nodes.find(n => n.data.label === 'Server'); - - expect(dbNode?.type).toBe('database'); - expect(clientNode?.type).toBe('client'); - expect(serverNode?.type).toBe('server'); - }); - - it('should handle empty or invalid input securely', async () => { - const { nodes, edges } = await parseMermaid(''); - expect(nodes).toEqual([]); - expect(edges).toEqual([]); - }); -}); +import { describe, it, expect, vi } from 'vitest'; +import { parseMermaid } from '../mermaidParser'; + +// Mock mermaid library +vi.mock('mermaid', () => { + return { + default: { + initialize: vi.fn(), + parse: vi.fn().mockResolvedValue(true), + mermaidAPI: { + getDiagramFromText: vi.fn().mockImplementation(async (text) => { + // Mock DB response based on text content + const vertices: any = {}; + const edges: any[] = []; + const subgraphs: any[] = []; + + if (text.includes('Start')) { + vertices['A'] = { id: 'A', text: 'Start', type: 'round' }; + vertices['B'] = { id: 'B', text: 'End', type: 'round' }; + edges.push({ start: 'A', end: 'B', text: undefined, stroke: 'normal' }); + } + if (text.includes('Group1')) { + subgraphs.push({ id: 'Group1', title: 'Group1', nodes: ['A'] }); + vertices['A'] = { id: 'A', text: 'Node A', type: 'round' }; + vertices['B'] = { id: 'B', text: 'Node B', type: 'round' }; + // Edge from A outside to B outside + edges.push({ start: 'A', end: 'B', text: undefined }); + } + if (text.includes('Database')) { + vertices['DB'] = { id: 'DB', text: 'Database', type: 'cylinder' }; + vertices['S'] = { id: 'S', text: 'Server', type: 'round' }; + vertices['C'] = { id: 'C', text: 'Client App', type: 'round' }; + } + + return { + db: { + getVertices: () => vertices, + getEdges: () => edges, + getSubGraphs: () => subgraphs, + } + }; + }) + } + } + }; +}); + +describe('mermaidParser', () => { + it('should parse a simple flowchart', async () => { + const code = ` + flowchart TD + A[Start] --> B[End] + `; + const { nodes, edges } = await parseMermaid(code); + + expect(nodes).toHaveLength(2); + expect(edges).toHaveLength(1); + expect(nodes[0].data.label).toBe('Start'); + expect(nodes[1].data.label).toBe('End'); + }); + + it('should handle subgraphs correctly', async () => { + const code = ` + flowchart TD + subgraph Group1 + A[Node A] + end + A --> B[Node B] + `; + const { nodes } = await parseMermaid(code); + + // Should have 3 nodes: Group1, A, B + const groupNode = nodes.find(n => n.type === 'group'); + const childNode = nodes.find(n => n.id === 'A'); + + expect(groupNode).toBeDefined(); + // The mock implementation ensures correct parentId association logic is tested + if (groupNode && childNode) { + expect(childNode.parentId).toBe(groupNode.id); + } + }); + + it('should infer node types from labels', async () => { + const code = ` + flowchart TD + DB[(Database)] --> S[Server] + S --> C([Client App]) + `; + const { nodes } = await parseMermaid(code); + + const dbNode = nodes.find(n => n.data.label === 'Database'); + const clientNode = nodes.find(n => n.data.label === 'Client App'); + const serverNode = nodes.find(n => n.data.label === 'Server'); + + expect(dbNode?.type).toBe('database'); + expect(clientNode?.type).toBe('client'); + expect(serverNode?.type).toBe('server'); + }); + + it('should handle empty or invalid input securely', async () => { + const { nodes, edges } = await parseMermaid(''); + expect(nodes).toEqual([]); + expect(edges).toEqual([]); + }); +}); diff --git a/src/lib/exportUtils.ts b/src/lib/exportUtils.ts index d295a63..9cf50d0 100644 --- a/src/lib/exportUtils.ts +++ b/src/lib/exportUtils.ts @@ -1,10 +1,54 @@ import { toPng, toJpeg, toSvg } from 'html-to-image'; import { type Node, type Edge } from '../store'; +// Robust cross-browser file download function using data URLs for better Chrome compatibility +function saveFile(blob: Blob, filename: string): void { + console.log(`[Export] Starting download: ${filename}`); + + // Convert blob to data URL for better download attribute support + const reader = new FileReader(); + reader.onloadend = () => { + const dataUrl = reader.result as string; + + // Create an anchor element + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = dataUrl; + a.download = filename; + + // Append to body (required for Firefox) + document.body.appendChild(a); + + // Trigger download + a.click(); + + // Cleanup after a delay + setTimeout(() => { + document.body.removeChild(a); + }, 100); + + console.log(`[Export] Download triggered: ${filename}`); + }; + + reader.onerror = () => { + console.error(`[Export] Failed to read blob for: ${filename}`); + }; + + reader.readAsDataURL(blob); +} + +// Get the current theme's background color +function getThemeBackgroundColor(): string { + // Check if dark mode is active by looking at the document class + const isDark = document.documentElement.classList.contains('dark'); + return isDark ? '#020617' : '#ffffff'; +} + export async function exportToPng(element: HTMLElement): Promise { try { + const backgroundColor = getThemeBackgroundColor(); const dataUrl = await toPng(element, { - backgroundColor: '#020617', + backgroundColor, quality: 1, pixelRatio: 3, filter: (node) => { @@ -15,7 +59,8 @@ export async function exportToPng(element: HTMLElement): Promise { } }); - downloadFile(dataUrl, `diagram-${getTimestamp()}.png`); + const blob = dataURLtoBlob(dataUrl); + saveFile(blob, `diagram-${getTimestamp()}.png`); } catch (error) { console.error('Failed to export PNG:', error); throw error; @@ -24,9 +69,9 @@ export async function exportToPng(element: HTMLElement): Promise { export async function exportToJpg(element: HTMLElement): Promise { try { - + const backgroundColor = getThemeBackgroundColor(); const dataUrl = await toJpeg(element, { - backgroundColor: '#020617', + backgroundColor, quality: 0.95, pixelRatio: 2, filter: (node) => { @@ -37,7 +82,8 @@ export async function exportToJpg(element: HTMLElement): Promise { } }); - downloadFile(dataUrl, `diagram-${getTimestamp()}.jpg`); + const blob = dataURLtoBlob(dataUrl); + saveFile(blob, `diagram-${getTimestamp()}.jpg`); } catch (error) { console.error('Failed to export JPG:', error); throw error; @@ -46,8 +92,9 @@ export async function exportToJpg(element: HTMLElement): Promise { export async function exportToSvg(element: HTMLElement): Promise { try { + const backgroundColor = getThemeBackgroundColor(); const dataUrl = await toSvg(element, { - backgroundColor: '#020617', + backgroundColor, filter: (node) => { const className = node.className?.toString() || ''; return !className.includes('react-flow__controls') && @@ -56,7 +103,8 @@ export async function exportToSvg(element: HTMLElement): Promise { } }); - downloadFile(dataUrl, `diagram-${getTimestamp()}.svg`); + const blob = dataURLtoBlob(dataUrl); + saveFile(blob, `diagram-${getTimestamp()}.svg`); } catch (error) { console.error('Failed to export SVG:', error); throw error; @@ -80,10 +128,8 @@ export function exportToTxt(nodes: Node[], edges: Edge[]): void { txt += `- ${sourceLabel} -> ${targetLabel} ${e.label ? `(${e.label})` : ''}\n`; }); - const blob = new Blob([txt], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - downloadFile(url, `summary-${getTimestamp()}.txt`); - URL.revokeObjectURL(url); + const blob = new Blob([txt], { type: 'text/plain;charset=utf-8' }); + saveFile(blob, `summary-${getTimestamp()}.txt`); } export function exportToJson(nodes: Node[], edges: Edge[]): void { @@ -106,11 +152,8 @@ export function exportToJson(nodes: Node[], edges: Edge[]): void { }; const jsonString = JSON.stringify(data, null, 2); - const blob = new Blob([jsonString], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - downloadFile(url, `diagram-${getTimestamp()}.json`); - URL.revokeObjectURL(url); + const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8' }); + saveFile(blob, `diagram-${getTimestamp()}.json`); } export function exportToMermaid(nodes: Node[], edges: Edge[]): string { @@ -178,22 +221,24 @@ export function exportToMermaid(nodes: Node[], edges: Edge[]): string { export function downloadMermaid(nodes: Node[], edges: Edge[]): void { const mermaid = exportToMermaid(nodes, edges); - const blob = new Blob([mermaid], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - - downloadFile(url, `diagram-${getTimestamp()}.mmd`); - URL.revokeObjectURL(url); + const blob = new Blob([mermaid], { type: 'text/plain;charset=utf-8' }); + saveFile(blob, `diagram-${getTimestamp()}.mmd`); } function getTimestamp(): string { return new Date().toISOString().slice(0, 19).replace(/[:-]/g, ''); } -function downloadFile(url: string, filename: string): void { - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); +// Convert data URL to Blob for proper file download +function dataURLtoBlob(dataUrl: string): Blob { + const arr = dataUrl.split(','); + const mimeMatch = arr[0].match(/:(.*?);/); + const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream'; + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + return new Blob([u8arr], { type: mime }); } diff --git a/src/lib/layoutEngine.ts b/src/lib/layoutEngine.ts index bd6649b..af8a30f 100644 --- a/src/lib/layoutEngine.ts +++ b/src/lib/layoutEngine.ts @@ -1,424 +1,424 @@ -import dagre from 'dagre'; -import { type Node, type Edge } from '../store'; - -// Enhanced constants for better spacing -const NODE_WIDTH = 180; -const NODE_HEIGHT = 60; -const GROUP_PADDING = 60; // Increased padding -const GROUP_TITLE_HEIGHT = 50; -const GROUP_GAP = 120; // Increased gap between groups -const MIN_NODE_SPACING = 80; // Minimum space between nodes - -export interface LayoutOptions { - direction: 'TB' | 'LR' | 'BT' | 'RL'; - nodeSpacing: number; - rankSpacing: number; - smartOverlapResolution?: boolean; // Enable collision detection - optimizeForReadability?: boolean; // Prioritize clear flow -} - -const defaultOptions: LayoutOptions = { - direction: 'TB', - nodeSpacing: 100, // Increased from 60 to prevent overlap - rankSpacing: 150, // Increased from 80 for edge labels - smartOverlapResolution: true, - optimizeForReadability: true, -}; - -export function getLayoutedElements( - nodes: Node[], - edges: Edge[], - options: Partial = {} -): { nodes: Node[]; edges: Edge[] } { - const opts = { ...defaultOptions, ...options }; - const isHorizontal = opts.direction === 'LR' || opts.direction === 'RL'; - - // Separate group nodes from regular nodes - const groupNodes = nodes.filter(n => n.type === 'group'); - const regularNodes = nodes.filter(n => n.type !== 'group'); - - // If no groups, just layout all nodes flat - if (groupNodes.length === 0) { - return layoutFlatNodes(regularNodes, edges, opts, isHorizontal); - } - - // Separate nodes by their parent group - const nodesWithoutParent = regularNodes.filter(n => !n.parentId); - const nodesByGroup = new Map(); - - groupNodes.forEach(g => nodesByGroup.set(g.id, [])); - regularNodes.forEach(n => { - if (n.parentId && nodesByGroup.has(n.parentId)) { - nodesByGroup.get(n.parentId)!.push(n); - } - }); - - // Layout each group internally and calculate their sizes - const groupLayouts = new Map(); - - groupNodes.forEach(group => { - const childNodes = nodesByGroup.get(group.id) || []; - const layout = layoutGroupInternal(group, childNodes, edges, opts, isHorizontal); - groupLayouts.set(group.id, layout); - }); - - // Stack groups vertically (for TB direction) - const finalNodes: Node[] = []; - let currentY = 60; // Starting Y position - const groupX = 60; // Left margin for groups - - // Sort groups by their original order (first defined = first in list) - const sortedGroups = Array.from(groupLayouts.values()); - - sortedGroups.forEach(({ group, width, height, nodes: childNodes }) => { - // Position the group - finalNodes.push({ - ...group, - position: { x: groupX, y: currentY }, - style: { - ...group.style, - width, - height, - }, - } as Node); - - // Add positioned child nodes - childNodes.forEach(child => finalNodes.push(child)); - - // Move Y down for next group - currentY += height + GROUP_GAP; - }); - - // Layout orphan nodes (nodes without parent) to the right of groups - if (nodesWithoutParent.length > 0) { - const maxGroupWidth = Math.max(...sortedGroups.map(g => g.width), 300); - const orphanStartX = groupX + maxGroupWidth + 100; - - const orphanLayout = layoutOrphanNodes(nodesWithoutParent, edges, opts, isHorizontal, orphanStartX); - orphanLayout.forEach(node => finalNodes.push(node)); - } - - return { nodes: finalNodes, edges }; -} - -// Layout nodes within a single group -function layoutGroupInternal( - group: Node, - childNodes: Node[], - edges: Edge[], - opts: LayoutOptions, - isHorizontal: boolean -): { width: number; height: number; nodes: Node[]; group: Node } { - if (childNodes.length === 0) { - return { - width: 300, - height: 200, - nodes: [], - group - }; - } - - // Create dagre sub-graph for this group - const subGraph = new dagre.graphlib.Graph(); - subGraph.setDefaultEdgeLabel(() => ({})); - subGraph.setGraph({ - rankdir: opts.direction, - nodesep: opts.nodeSpacing, - ranksep: opts.rankSpacing, - marginx: 30, - marginy: 30, - }); - - // Add nodes - childNodes.forEach(node => { - const w = node.type === 'decision' ? 140 : NODE_WIDTH; - const h = node.type === 'decision' ? 90 : NODE_HEIGHT; - subGraph.setNode(node.id, { width: w, height: h }); - }); - - // Add edges within this group - edges.forEach(edge => { - const sourceInGroup = childNodes.some(n => n.id === edge.source); - const targetInGroup = childNodes.some(n => n.id === edge.target); - if (sourceInGroup && targetInGroup) { - subGraph.setEdge(edge.source, edge.target); - } - }); - - dagre.layout(subGraph); - - // Calculate bounds - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - const positionedChildren: Node[] = []; - - childNodes.forEach(node => { - const pos = subGraph.node(node.id); - if (!pos) return; - - const w = node.type === 'decision' ? 140 : NODE_WIDTH; - const h = node.type === 'decision' ? 90 : NODE_HEIGHT; - const x = pos.x - w / 2; - const y = pos.y - h / 2; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x + w); - maxY = Math.max(maxY, y + h); - - positionedChildren.push({ - ...node, - position: { x, y }, - targetPosition: isHorizontal ? 'left' : 'top', - sourcePosition: isHorizontal ? 'right' : 'bottom', - extent: 'parent', - } as Node); - }); - - // Normalize positions to start at padding - positionedChildren.forEach(child => { - child.position.x = child.position.x - minX + GROUP_PADDING; - child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT; - }); - - const contentWidth = maxX - minX; - const contentHeight = maxY - minY; - const groupWidth = contentWidth + GROUP_PADDING * 2; - const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT; - - return { - width: Math.max(groupWidth, 300), - height: Math.max(groupHeight, 200), - nodes: positionedChildren, - group - }; -} - -// Layout orphan nodes that don't belong to any group -function layoutOrphanNodes( - nodes: Node[], - edges: Edge[], - opts: LayoutOptions, - isHorizontal: boolean, - startX: number -): Node[] { - const orphanGraph = new dagre.graphlib.Graph(); - orphanGraph.setDefaultEdgeLabel(() => ({})); - orphanGraph.setGraph({ - rankdir: opts.direction, - nodesep: opts.nodeSpacing, - ranksep: opts.rankSpacing, - marginx: 0, - marginy: 60, - }); - - nodes.forEach(node => { - const w = node.type === 'decision' ? 140 : NODE_WIDTH; - const h = node.type === 'decision' ? 90 : NODE_HEIGHT; - orphanGraph.setNode(node.id, { width: w, height: h }); - }); - - // Add edges between orphan nodes - edges.forEach(edge => { - const sourceOrphan = nodes.some(n => n.id === edge.source); - const targetOrphan = nodes.some(n => n.id === edge.target); - if (sourceOrphan && targetOrphan) { - orphanGraph.setEdge(edge.source, edge.target); - } - }); - - dagre.layout(orphanGraph); - - return nodes.map(node => { - const pos = orphanGraph.node(node.id); - if (!pos) return node; - - const w = node.type === 'decision' ? 140 : NODE_WIDTH; - const h = node.type === 'decision' ? 90 : NODE_HEIGHT; - - return { - ...node, - position: { x: startX + pos.x - w / 2, y: pos.y - h / 2 }, - targetPosition: isHorizontal ? 'left' : 'top', - sourcePosition: isHorizontal ? 'right' : 'bottom', - } as Node; - }); -} - -// Flat layout when there are no groups -function layoutFlatNodes( - nodes: Node[], - edges: Edge[], - opts: LayoutOptions, - isHorizontal: boolean -): { nodes: Node[]; edges: Edge[] } { - const flatGraph = new dagre.graphlib.Graph(); - flatGraph.setDefaultEdgeLabel(() => ({})); - flatGraph.setGraph({ - rankdir: opts.direction, - nodesep: opts.nodeSpacing, - ranksep: opts.rankSpacing, - marginx: 60, - marginy: 60, - }); - - nodes.forEach(node => { - const w = node.type === 'decision' ? 140 : NODE_WIDTH; - const h = node.type === 'decision' ? 90 : NODE_HEIGHT; - flatGraph.setNode(node.id, { width: w, height: h }); - }); - - edges.forEach(edge => { - if (nodes.some(n => n.id === edge.source) && nodes.some(n => n.id === edge.target)) { - flatGraph.setEdge(edge.source, edge.target); - } - }); - - dagre.layout(flatGraph); - - const layoutedNodes = nodes.map(node => { - const pos = flatGraph.node(node.id); - if (!pos) return node; - - const w = node.type === 'decision' ? 140 : NODE_WIDTH; - const h = node.type === 'decision' ? 90 : NODE_HEIGHT; - - return { - ...node, - position: { x: pos.x - w / 2, y: pos.y - h / 2 }, - targetPosition: isHorizontal ? 'left' : 'top', - sourcePosition: isHorizontal ? 'right' : 'bottom', - } as Node; - }); - - // Apply smart overlap resolution if enabled - const resolvedNodes = opts.smartOverlapResolution - ? resolveOverlaps(layoutedNodes) - : layoutedNodes; - - return { nodes: resolvedNodes, edges }; -} - -/** - * Smart collision resolution - iteratively pushes overlapping nodes apart - * Uses a force-directed approach with multiple passes for stability - */ -function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] { - const mutableNodes = nodes.map(n => ({ - ...n, - position: { ...n.position }, - width: getNodeWidth(n), - height: getNodeHeight(n) - })); - - for (let iteration = 0; iteration < maxIterations; iteration++) { - let hasOverlap = false; - - for (let i = 0; i < mutableNodes.length; i++) { - for (let j = i + 1; j < mutableNodes.length; j++) { - const nodeA = mutableNodes[i]; - const nodeB = mutableNodes[j]; - - // Skip group nodes - if (nodeA.type === 'group' || nodeB.type === 'group') continue; - - // Check for overlap with padding - const overlapX = getOverlap( - nodeA.position.x, nodeA.width, - nodeB.position.x, nodeB.width, - MIN_NODE_SPACING - ); - - const overlapY = getOverlap( - nodeA.position.y, nodeA.height, - nodeB.position.y, nodeB.height, - MIN_NODE_SPACING - ); - - // If nodes overlap in both axes, push them apart - if (overlapX > 0 && overlapY > 0) { - hasOverlap = true; - - // Determine which axis needs less push (more efficient separation) - if (overlapX < overlapY) { - // Push horizontally - const pushX = overlapX / 2 + 5; - if (nodeA.position.x < nodeB.position.x) { - nodeA.position.x -= pushX; - nodeB.position.x += pushX; - } else { - nodeA.position.x += pushX; - nodeB.position.x -= pushX; - } - } else { - // Push vertically - const pushY = overlapY / 2 + 5; - if (nodeA.position.y < nodeB.position.y) { - nodeA.position.y -= pushY; - nodeB.position.y += pushY; - } else { - nodeA.position.y += pushY; - nodeB.position.y -= pushY; - } - } - } - } - } - - // If no overlaps detected, we're done - if (!hasOverlap) break; - } - - // Ensure no negative positions (shift everything if needed) - let minX = Infinity, minY = Infinity; - mutableNodes.forEach(n => { - if (n.type !== 'group') { - minX = Math.min(minX, n.position.x); - minY = Math.min(minY, n.position.y); - } - }); - - const offsetX = minX < 60 ? 60 - minX : 0; - const offsetY = minY < 60 ? 60 - minY : 0; - - return mutableNodes.map(n => ({ - ...n, - position: { - x: n.position.x + offsetX, - y: n.position.y + offsetY - } - })); -} - -/** - * Calculate overlap between two rectangles with padding - */ -function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number { - const end1 = pos1 + size1 + padding; - const end2 = pos2 + size2 + padding; - - return Math.min(end1 - pos2, end2 - pos1); -} - -/** - * Get node width based on type - */ -function getNodeWidth(node: Node): number { - if (node.type === 'decision') return 140; - if (node.style?.width && typeof node.style.width === 'number') return node.style.width; - return NODE_WIDTH; -} - -/** - * Get node height based on type - */ -function getNodeHeight(node: Node): number { - if (node.type === 'decision') return 90; - if (node.style?.height && typeof node.style.height === 'number') return node.style.height; - return NODE_HEIGHT; -} - +import dagre from 'dagre'; +import { type Node, type Edge } from '../store'; + +// Enhanced constants for better spacing +const NODE_WIDTH = 180; +const NODE_HEIGHT = 60; +const GROUP_PADDING = 60; // Increased padding +const GROUP_TITLE_HEIGHT = 50; +const GROUP_GAP = 120; // Increased gap between groups +const MIN_NODE_SPACING = 80; // Minimum space between nodes + +export interface LayoutOptions { + direction: 'TB' | 'LR' | 'BT' | 'RL'; + nodeSpacing: number; + rankSpacing: number; + smartOverlapResolution?: boolean; // Enable collision detection + optimizeForReadability?: boolean; // Prioritize clear flow +} + +const defaultOptions: LayoutOptions = { + direction: 'TB', + nodeSpacing: 100, // Increased from 60 to prevent overlap + rankSpacing: 150, // Increased from 80 for edge labels + smartOverlapResolution: true, + optimizeForReadability: true, +}; + +export function getLayoutedElements( + nodes: Node[], + edges: Edge[], + options: Partial = {} +): { nodes: Node[]; edges: Edge[] } { + const opts = { ...defaultOptions, ...options }; + const isHorizontal = opts.direction === 'LR' || opts.direction === 'RL'; + + // Separate group nodes from regular nodes + const groupNodes = nodes.filter(n => n.type === 'group'); + const regularNodes = nodes.filter(n => n.type !== 'group'); + + // If no groups, just layout all nodes flat + if (groupNodes.length === 0) { + return layoutFlatNodes(regularNodes, edges, opts, isHorizontal); + } + + // Separate nodes by their parent group + const nodesWithoutParent = regularNodes.filter(n => !n.parentId); + const nodesByGroup = new Map(); + + groupNodes.forEach(g => nodesByGroup.set(g.id, [])); + regularNodes.forEach(n => { + if (n.parentId && nodesByGroup.has(n.parentId)) { + nodesByGroup.get(n.parentId)!.push(n); + } + }); + + // Layout each group internally and calculate their sizes + const groupLayouts = new Map(); + + groupNodes.forEach(group => { + const childNodes = nodesByGroup.get(group.id) || []; + const layout = layoutGroupInternal(group, childNodes, edges, opts, isHorizontal); + groupLayouts.set(group.id, layout); + }); + + // Stack groups vertically (for TB direction) + const finalNodes: Node[] = []; + let currentY = 60; // Starting Y position + const groupX = 60; // Left margin for groups + + // Sort groups by their original order (first defined = first in list) + const sortedGroups = Array.from(groupLayouts.values()); + + sortedGroups.forEach(({ group, width, height, nodes: childNodes }) => { + // Position the group + finalNodes.push({ + ...group, + position: { x: groupX, y: currentY }, + style: { + ...group.style, + width, + height, + }, + } as Node); + + // Add positioned child nodes + childNodes.forEach(child => finalNodes.push(child)); + + // Move Y down for next group + currentY += height + GROUP_GAP; + }); + + // Layout orphan nodes (nodes without parent) to the right of groups + if (nodesWithoutParent.length > 0) { + const maxGroupWidth = Math.max(...sortedGroups.map(g => g.width), 300); + const orphanStartX = groupX + maxGroupWidth + 100; + + const orphanLayout = layoutOrphanNodes(nodesWithoutParent, edges, opts, isHorizontal, orphanStartX); + orphanLayout.forEach(node => finalNodes.push(node)); + } + + return { nodes: finalNodes, edges }; +} + +// Layout nodes within a single group +function layoutGroupInternal( + group: Node, + childNodes: Node[], + edges: Edge[], + opts: LayoutOptions, + isHorizontal: boolean +): { width: number; height: number; nodes: Node[]; group: Node } { + if (childNodes.length === 0) { + return { + width: 300, + height: 200, + nodes: [], + group + }; + } + + // Create dagre sub-graph for this group + const subGraph = new dagre.graphlib.Graph(); + subGraph.setDefaultEdgeLabel(() => ({})); + subGraph.setGraph({ + rankdir: opts.direction, + nodesep: opts.nodeSpacing, + ranksep: opts.rankSpacing, + marginx: 30, + marginy: 30, + }); + + // Add nodes + childNodes.forEach(node => { + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; + subGraph.setNode(node.id, { width: w, height: h }); + }); + + // Add edges within this group + edges.forEach(edge => { + const sourceInGroup = childNodes.some(n => n.id === edge.source); + const targetInGroup = childNodes.some(n => n.id === edge.target); + if (sourceInGroup && targetInGroup) { + subGraph.setEdge(edge.source, edge.target); + } + }); + + dagre.layout(subGraph); + + // Calculate bounds + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const positionedChildren: Node[] = []; + + childNodes.forEach(node => { + const pos = subGraph.node(node.id); + if (!pos) return; + + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; + const x = pos.x - w / 2; + const y = pos.y - h / 2; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + + positionedChildren.push({ + ...node, + position: { x, y }, + targetPosition: isHorizontal ? 'left' : 'top', + sourcePosition: isHorizontal ? 'right' : 'bottom', + extent: 'parent', + } as Node); + }); + + // Normalize positions to start at padding + positionedChildren.forEach(child => { + child.position.x = child.position.x - minX + GROUP_PADDING; + child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT; + }); + + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + const groupWidth = contentWidth + GROUP_PADDING * 2; + const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT; + + return { + width: Math.max(groupWidth, 300), + height: Math.max(groupHeight, 200), + nodes: positionedChildren, + group + }; +} + +// Layout orphan nodes that don't belong to any group +function layoutOrphanNodes( + nodes: Node[], + edges: Edge[], + opts: LayoutOptions, + isHorizontal: boolean, + startX: number +): Node[] { + const orphanGraph = new dagre.graphlib.Graph(); + orphanGraph.setDefaultEdgeLabel(() => ({})); + orphanGraph.setGraph({ + rankdir: opts.direction, + nodesep: opts.nodeSpacing, + ranksep: opts.rankSpacing, + marginx: 0, + marginy: 60, + }); + + nodes.forEach(node => { + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; + orphanGraph.setNode(node.id, { width: w, height: h }); + }); + + // Add edges between orphan nodes + edges.forEach(edge => { + const sourceOrphan = nodes.some(n => n.id === edge.source); + const targetOrphan = nodes.some(n => n.id === edge.target); + if (sourceOrphan && targetOrphan) { + orphanGraph.setEdge(edge.source, edge.target); + } + }); + + dagre.layout(orphanGraph); + + return nodes.map(node => { + const pos = orphanGraph.node(node.id); + if (!pos) return node; + + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; + + return { + ...node, + position: { x: startX + pos.x - w / 2, y: pos.y - h / 2 }, + targetPosition: isHorizontal ? 'left' : 'top', + sourcePosition: isHorizontal ? 'right' : 'bottom', + } as Node; + }); +} + +// Flat layout when there are no groups +function layoutFlatNodes( + nodes: Node[], + edges: Edge[], + opts: LayoutOptions, + isHorizontal: boolean +): { nodes: Node[]; edges: Edge[] } { + const flatGraph = new dagre.graphlib.Graph(); + flatGraph.setDefaultEdgeLabel(() => ({})); + flatGraph.setGraph({ + rankdir: opts.direction, + nodesep: opts.nodeSpacing, + ranksep: opts.rankSpacing, + marginx: 60, + marginy: 60, + }); + + nodes.forEach(node => { + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; + flatGraph.setNode(node.id, { width: w, height: h }); + }); + + edges.forEach(edge => { + if (nodes.some(n => n.id === edge.source) && nodes.some(n => n.id === edge.target)) { + flatGraph.setEdge(edge.source, edge.target); + } + }); + + dagre.layout(flatGraph); + + const layoutedNodes = nodes.map(node => { + const pos = flatGraph.node(node.id); + if (!pos) return node; + + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; + + return { + ...node, + position: { x: pos.x - w / 2, y: pos.y - h / 2 }, + targetPosition: isHorizontal ? 'left' : 'top', + sourcePosition: isHorizontal ? 'right' : 'bottom', + } as Node; + }); + + // Apply smart overlap resolution if enabled + const resolvedNodes = opts.smartOverlapResolution + ? resolveOverlaps(layoutedNodes) + : layoutedNodes; + + return { nodes: resolvedNodes, edges }; +} + +/** + * Smart collision resolution - iteratively pushes overlapping nodes apart + * Uses a force-directed approach with multiple passes for stability + */ +function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] { + const mutableNodes = nodes.map(n => ({ + ...n, + position: { ...n.position }, + width: getNodeWidth(n), + height: getNodeHeight(n) + })); + + for (let iteration = 0; iteration < maxIterations; iteration++) { + let hasOverlap = false; + + for (let i = 0; i < mutableNodes.length; i++) { + for (let j = i + 1; j < mutableNodes.length; j++) { + const nodeA = mutableNodes[i]; + const nodeB = mutableNodes[j]; + + // Skip group nodes + if (nodeA.type === 'group' || nodeB.type === 'group') continue; + + // Check for overlap with padding + const overlapX = getOverlap( + nodeA.position.x, nodeA.width, + nodeB.position.x, nodeB.width, + MIN_NODE_SPACING + ); + + const overlapY = getOverlap( + nodeA.position.y, nodeA.height, + nodeB.position.y, nodeB.height, + MIN_NODE_SPACING + ); + + // If nodes overlap in both axes, push them apart + if (overlapX > 0 && overlapY > 0) { + hasOverlap = true; + + // Determine which axis needs less push (more efficient separation) + if (overlapX < overlapY) { + // Push horizontally + const pushX = overlapX / 2 + 5; + if (nodeA.position.x < nodeB.position.x) { + nodeA.position.x -= pushX; + nodeB.position.x += pushX; + } else { + nodeA.position.x += pushX; + nodeB.position.x -= pushX; + } + } else { + // Push vertically + const pushY = overlapY / 2 + 5; + if (nodeA.position.y < nodeB.position.y) { + nodeA.position.y -= pushY; + nodeB.position.y += pushY; + } else { + nodeA.position.y += pushY; + nodeB.position.y -= pushY; + } + } + } + } + } + + // If no overlaps detected, we're done + if (!hasOverlap) break; + } + + // Ensure no negative positions (shift everything if needed) + let minX = Infinity, minY = Infinity; + mutableNodes.forEach(n => { + if (n.type !== 'group') { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + } + }); + + const offsetX = minX < 60 ? 60 - minX : 0; + const offsetY = minY < 60 ? 60 - minY : 0; + + return mutableNodes.map(n => ({ + ...n, + position: { + x: n.position.x + offsetX, + y: n.position.y + offsetY + } + })); +} + +/** + * Calculate overlap between two rectangles with padding + */ +function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number { + const end1 = pos1 + size1 + padding; + const end2 = pos2 + size2 + padding; + + return Math.min(end1 - pos2, end2 - pos1); +} + +/** + * Get node width based on type + */ +function getNodeWidth(node: Node): number { + if (node.type === 'decision') return 140; + if (node.style?.width && typeof node.style.width === 'number') return node.style.width; + return NODE_WIDTH; +} + +/** + * Get node height based on type + */ +function getNodeHeight(node: Node): number { + if (node.type === 'decision') return 90; + if (node.style?.height && typeof node.style.height === 'number') return node.style.height; + return NODE_HEIGHT; +} + diff --git a/src/lib/mermaidTest.ts b/src/lib/mermaidTest.ts index 92f2f20..3123475 100644 --- a/src/lib/mermaidTest.ts +++ b/src/lib/mermaidTest.ts @@ -1,30 +1,30 @@ -import mermaid from 'mermaid'; - -// Initialize mermaid -mermaid.initialize({ startOnLoad: false }); - -const graphDefinition = ` -flowchart TD - A[Start] --> B{Is it working?} - B -- Yes --> C[Great!] - B -- No --> D[Debug] -`; - -async function testParsing() { - try { - // Validate syntax - const valid = await mermaid.parse(graphDefinition); - console.log("Parse result:", valid); - - // Attempt to get data - // Note: mermaid.mermaidAPI.getDiagramFromText is deprecated/internal but often used. - // In v10+, we might need to use other methods. - const type = mermaid.detectType(graphDefinition); - console.log("Detected type:", type); - - } catch (error) { - console.error("Parsing failed:", error); - } -} - -testParsing(); +import mermaid from 'mermaid'; + +// Initialize mermaid +mermaid.initialize({ startOnLoad: false }); + +const graphDefinition = ` +flowchart TD + A[Start] --> B{Is it working?} + B -- Yes --> C[Great!] + B -- No --> D[Debug] +`; + +async function testParsing() { + try { + // Validate syntax + const valid = await mermaid.parse(graphDefinition); + console.log("Parse result:", valid); + + // Attempt to get data + // Note: mermaid.mermaidAPI.getDiagramFromText is deprecated/internal but often used. + // In v10+, we might need to use other methods. + const type = mermaid.detectType(graphDefinition); + console.log("Detected type:", type); + + } catch (error) { + console.error("Parsing failed:", error); + } +} + +testParsing(); diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index d9c606e..a270eb0 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -1,118 +1,118 @@ -export const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata. - -Return ONLY a strictly valid JSON object. No Markdown headers, no preamble. - -EXPECTED JSON FORMAT: -{ - "mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...", - "metadata": { - "NodeLabel": { - "techStack": ["Technologies used"], - "role": "Specific role (e.g., Load Balancer, Cache, Database)", - "description": "Concise technical responsibility" - } - } -} - -ARCHITECTURAL RULES: -1. Use 'flowchart TD' or 'flowchart LR'. -2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD'). -3. Labels must match the keys in the 'metadata' object EXACTLY. -4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata. -5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels. -6. Escape double quotes inside the mermaid string correctly. - -DIAGRAM QUALITY RULES: -7. NEVER use HTML tags (like
, , etc.) in node labels. Use short, clean text only. -8. NEVER use pipe characters (|) inside node labels - they break parsing. Use commas or spaces instead. -9. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}). -10. Use CYLINDER shapes [(Database)] for data stores. -11. Use ROUNDED shapes (Process) for human/manual tasks. -12. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C. -13. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes). -14. Use subgraphs/swimlanes to group related components by team, role, or domain. -15. Ensure consistent node shapes within each swimlane/subgraph.`; - -export const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram. -- Focus on the main data flow (max 5-7 nodes). -- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like
. -- Use diamond {Decision?} for approval/review steps. -- Minimal metadata (role only).`; - -export const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram. -- Include all subsystems, queues, workers, and external services. -- Use swimlanes (subgraphs) to group components by role/domain. -- NEVER use HTML tags like
in labels. Keep labels clean and concise. -- Use diamond {Decision?} for approval/review steps. -- Use cylinder [(DB)] for databases, rounded (Task) for human tasks. -- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C. -- Include feedback loops connecting outputs back to inputs where logical. -- Detailed metadata (techStack, role, description).`; - -export const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert. -Your task is to analyze the provided Mermaid flowchart code and either: -1. Fix any syntax errors that prevent it from rendering. -2. Improve the logical flow or visual clarity if the syntax is already correct. - -Return ONLY a strictly valid JSON object. No Markdown headers, no preamble. - -EXPECTED JSON FORMAT: -{ - "mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]", - "explanation": "Briefly explain what was fixed or improved." -} - -RULES: -- Maintain the original intent of the diagram. -- Use best practices for Mermaid layout and labeling. -- If the code is already perfect, return it as is but provide a positive explanation.`; - -export const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams. -Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity. - -Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json. - -EXPECTED JSON FORMAT: -{ - "analysis": { - "suggestions": [ - { - "id": "unique-id", - "title": "Short title", - "description": "Detailed explanation", - "type": "spacing" | "grouping" | "routing" | "hierarchy" | "style", - "impact": "high" | "medium" | "low", - "fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown" - } - ], - "summary": { - "critique": "Overall analysis", - "score": 0-100 - } - } -} - -EXAMPLE RESPONSE: -{ - "analysis": { - "suggestions": [ - { - "id": "sug-1", - "title": "Group Database Nodes", - "description": "Several database nodes are scattered. Group them for better logical separation.", - "type": "grouping", - "impact": "high", - "fix_strategy": "group_nodes" - } - ], - "summary": { - "critique": "The flow is generally good but lacks logical grouping for backend services.", - "score": 75 - } - } -} - -RULES: -- Focus on readability, flow, and logical grouping. -- Identify if nodes are too cluttered or if the flow is confusing. -- Suggest grouping for nodes that appear related based on their labels.`; +export const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata. + +Return ONLY a strictly valid JSON object. No Markdown headers, no preamble. + +EXPECTED JSON FORMAT: +{ + "mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...", + "metadata": { + "NodeLabel": { + "techStack": ["Technologies used"], + "role": "Specific role (e.g., Load Balancer, Cache, Database)", + "description": "Concise technical responsibility" + } + } +} + +ARCHITECTURAL RULES: +1. Use 'flowchart TD' or 'flowchart LR'. +2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD'). +3. Labels must match the keys in the 'metadata' object EXACTLY. +4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata. +5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels. +6. Escape double quotes inside the mermaid string correctly. + +DIAGRAM QUALITY RULES: +7. NEVER use HTML tags (like
, , etc.) in node labels. Use short, clean text only. +8. NEVER use pipe characters (|) inside node labels - they break parsing. Use commas or spaces instead. +9. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}). +10. Use CYLINDER shapes [(Database)] for data stores. +11. Use ROUNDED shapes (Process) for human/manual tasks. +12. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C. +13. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes). +14. Use subgraphs/swimlanes to group related components by team, role, or domain. +15. Ensure consistent node shapes within each swimlane/subgraph.`; + +export const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram. +- Focus on the main data flow (max 5-7 nodes). +- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like
. +- Use diamond {Decision?} for approval/review steps. +- Minimal metadata (role only).`; + +export const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram. +- Include all subsystems, queues, workers, and external services. +- Use swimlanes (subgraphs) to group components by role/domain. +- NEVER use HTML tags like
in labels. Keep labels clean and concise. +- Use diamond {Decision?} for approval/review steps. +- Use cylinder [(DB)] for databases, rounded (Task) for human tasks. +- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C. +- Include feedback loops connecting outputs back to inputs where logical. +- Detailed metadata (techStack, role, description).`; + +export const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert. +Your task is to analyze the provided Mermaid flowchart code and either: +1. Fix any syntax errors that prevent it from rendering. +2. Improve the logical flow or visual clarity if the syntax is already correct. + +Return ONLY a strictly valid JSON object. No Markdown headers, no preamble. + +EXPECTED JSON FORMAT: +{ + "mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]", + "explanation": "Briefly explain what was fixed or improved." +} + +RULES: +- Maintain the original intent of the diagram. +- Use best practices for Mermaid layout and labeling. +- If the code is already perfect, return it as is but provide a positive explanation.`; + +export const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams. +Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity. + +Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json. + +EXPECTED JSON FORMAT: +{ + "analysis": { + "suggestions": [ + { + "id": "unique-id", + "title": "Short title", + "description": "Detailed explanation", + "type": "spacing" | "grouping" | "routing" | "hierarchy" | "style", + "impact": "high" | "medium" | "low", + "fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown" + } + ], + "summary": { + "critique": "Overall analysis", + "score": 0-100 + } + } +} + +EXAMPLE RESPONSE: +{ + "analysis": { + "suggestions": [ + { + "id": "sug-1", + "title": "Group Database Nodes", + "description": "Several database nodes are scattered. Group them for better logical separation.", + "type": "grouping", + "impact": "high", + "fix_strategy": "group_nodes" + } + ], + "summary": { + "critique": "The flow is generally good but lacks logical grouping for backend services.", + "score": 75 + } + } +} + +RULES: +- Focus on readability, flow, and logical grouping. +- Identify if nodes are too cluttered or if the flow is confusing. +- Suggest grouping for nodes that appear related based on their labels.`; diff --git a/src/lib/shapes.ts b/src/lib/shapes.ts index cf8069a..0c7fe12 100644 --- a/src/lib/shapes.ts +++ b/src/lib/shapes.ts @@ -1,61 +1,61 @@ -// SVG Paths for Mermaid Shapes -// Based on Mermaid v11.3.0+ syntax -// Syntax: node@{ shape: shapeName } - -export interface ShapeDefinition { - id: string; - label: string; - path: string; - category: 'basic' | 'logic' | 'data' | 'other'; -} - -export const SHAPE_PATHS: Record = { - // Basic - rect: 'M0 4 h100 v60 h-100 z', - rounded: 'M10 4 h80 a10 10 0 0 1 10 10 v40 a10 10 0 0 1 -10 10 h-80 a10 10 0 0 1 -10 -10 v-40 a10 10 0 0 1 10 -10 z', - stadium: 'M20 4 h60 a20 20 0 0 1 20 20 v20 a20 20 0 0 1 -20 20 h-60 a20 20 0 0 1 -20 -20 v-20 a20 20 0 0 1 20 -20 z', - subroutine: 'M10 4 h80 v60 h-80 z M20 4 v60 M80 4 v60', - cyl: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0', - cylinder: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0', - - // Logic - diamond: 'M50 0 L100 50 L50 100 L0 50 Z', - rhombus: 'M50 0 L100 50 L50 100 L0 50 Z', - decision: 'M50 0 L100 50 L50 100 L0 50 Z', - hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 Z', - parallelogram: 'M15 4 h85 l-15 60 h-85 z', - trapezoid: 'M15 4 h70 l15 60 h-100 z', - - // Documents/Data - doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20', - document: 'M10 4 h80 v45 q0 15 -15 15 h-60 q-15 0 -15 -15 v-45 z M10 4 h80 v45 q-10 10 -40 10 q -30 0 -40 -10', - - // Cloud (Approximation) - cloud: 'M20 50 a20 20 0 0 1 20 -30 a25 25 0 0 1 40 10 a20 20 0 0 1 15 35 h-70 z', - - // Circles - circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60', - doublecircle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M50 9 a25 25 0 0 1 0 50 a25 25 0 0 1 0 -50', - - // New additions based on research - note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note - summary: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M30 24 l40 40 M30 64 l40 -40', // Crossed circle -}; - -export const SHAPE_DEFINITIONS: ShapeDefinition[] = [ - { id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' }, - { id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' }, - { id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' }, - { id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' }, - { id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' }, - - { id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' }, - { id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' }, - { id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' }, - - { id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' }, - { id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' }, - { id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' }, - - { id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' }, -]; +// SVG Paths for Mermaid Shapes +// Based on Mermaid v11.3.0+ syntax +// Syntax: node@{ shape: shapeName } + +export interface ShapeDefinition { + id: string; + label: string; + path: string; + category: 'basic' | 'logic' | 'data' | 'other'; +} + +export const SHAPE_PATHS: Record = { + // Basic + rect: 'M0 4 h100 v60 h-100 z', + rounded: 'M10 4 h80 a10 10 0 0 1 10 10 v40 a10 10 0 0 1 -10 10 h-80 a10 10 0 0 1 -10 -10 v-40 a10 10 0 0 1 10 -10 z', + stadium: 'M20 4 h60 a20 20 0 0 1 20 20 v20 a20 20 0 0 1 -20 20 h-60 a20 20 0 0 1 -20 -20 v-20 a20 20 0 0 1 20 -20 z', + subroutine: 'M10 4 h80 v60 h-80 z M20 4 v60 M80 4 v60', + cyl: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0', + cylinder: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0', + + // Logic + diamond: 'M50 0 L100 50 L50 100 L0 50 Z', + rhombus: 'M50 0 L100 50 L50 100 L0 50 Z', + decision: 'M50 0 L100 50 L50 100 L0 50 Z', + hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 Z', + parallelogram: 'M15 4 h85 l-15 60 h-85 z', + trapezoid: 'M15 4 h70 l15 60 h-100 z', + + // Documents/Data + doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20', + document: 'M10 4 h80 v45 q0 15 -15 15 h-60 q-15 0 -15 -15 v-45 z M10 4 h80 v45 q-10 10 -40 10 q -30 0 -40 -10', + + // Cloud (Approximation) + cloud: 'M20 50 a20 20 0 0 1 20 -30 a25 25 0 0 1 40 10 a20 20 0 0 1 15 35 h-70 z', + + // Circles + circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60', + doublecircle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M50 9 a25 25 0 0 1 0 50 a25 25 0 0 1 0 -50', + + // New additions based on research + note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note + summary: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M30 24 l40 40 M30 64 l40 -40', // Crossed circle +}; + +export const SHAPE_DEFINITIONS: ShapeDefinition[] = [ + { id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' }, + { id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' }, + { id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' }, + { id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' }, + { id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' }, + + { id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' }, + { id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' }, + { id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' }, + + { id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' }, + { id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' }, + { id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' }, + + { id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' }, +]; diff --git a/src/lib/visionService.ts b/src/lib/visionService.ts index e668701..420b6c3 100644 --- a/src/lib/visionService.ts +++ b/src/lib/visionService.ts @@ -1,135 +1,124 @@ - -import { env, pipeline, RawImage } from '@huggingface/transformers'; - -// Configure transformers.js -env.allowLocalModels = false; -env.useBrowserCache = true; - -export type VisionProgress = { - status: string; - progress?: number; - file?: string; -}; - -// ViT-GPT2 is the ONLY working model for browser-based image captioning -// Other models (BLIP, Florence-2, LLaVA) are not supported by transformers.js -const MODEL_ID = 'Xenova/vit-gpt2-image-captioning'; - -export class VisionService { - private captioner: any = null; - private isLoading = false; - private isReady = false; - - // Singleton instance - private static instance: VisionService; - - public static getInstance(): VisionService { - if (!VisionService.instance) { - VisionService.instance = new VisionService(); - } - return VisionService.instance; - } - - getStatus() { - return { - isReady: this.isReady, - isLoading: this.isLoading, - model: MODEL_ID - }; - } - - async initialize(onProgress?: (progress: VisionProgress) => void): Promise { - if (this.isReady || this.isLoading) return; - - this.isLoading = true; - - try { - console.log('Loading Vision Model...'); - if (onProgress) onProgress({ status: 'Loading Vision Model...' }); - - // Use the pipeline API - much simpler and faster - this.captioner = await pipeline('image-to-text', MODEL_ID, { - progress_callback: (progress: any) => { - if (onProgress && progress.status === 'progress') { - onProgress({ - status: `Downloading ${progress.file}`, - progress: progress.progress, - file: progress.file - }); - } - } - }); - - this.isReady = true; - console.log('Vision Model Ready'); - } catch (error) { - console.error('Failed to load Vision Model:', error); - throw error; - } finally { - this.isLoading = false; - } - } - - /** - * Analyzes an image (Base64 or URL) and returns a description. - * Uses vit-gpt2 for fast captioning. - */ - async analyzeImage(imageBase64: string): Promise { - if (!this.isReady) { - throw new Error('Vision model not loaded. Please initialize it first.'); - } - - try { - // Handle data URL prefix if present - const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`; - - let image = await RawImage.fromURL(cleanBase64); - - // Keep higher resolution for better detail detection - if (image.width > 512 || image.height > 512) { - image = await image.resize(512, 512); - } - - console.log('Starting enhanced image analysis...'); - const startTime = performance.now(); - - // Run multiple passes for more comprehensive description - const results = await Promise.all([ - // Pass 1: Detailed description - this.captioner(image, { - max_new_tokens: 150, - num_beams: 4, // Beam search for better quality - }), - // Pass 2: Alternative perspective - this.captioner(image, { - max_new_tokens: 100, - do_sample: true, - temperature: 0.7, - }), - ]); - - const endTime = performance.now(); - console.log(`Vision analysis completed in ${((endTime - startTime) / 1000).toFixed(1)}s`); - - // Combine descriptions for richer output - const caption1 = results[0]?.[0]?.generated_text || ''; - const caption2 = results[1]?.[0]?.generated_text || ''; - - // If both are similar, use just one; otherwise combine - if (caption1.toLowerCase().includes(caption2.toLowerCase().substring(0, 20)) || - caption2.toLowerCase().includes(caption1.toLowerCase().substring(0, 20))) { - return caption1.length > caption2.length ? caption1 : caption2; - } - - const combined = `${caption1}. Additionally: ${caption2}`; - console.log('Enhanced description:', combined); - return combined; - - } catch (error) { - console.error('Vision analysis failed:', error); - throw new Error('Failed to analyze image with local vision model'); - } - } -} - -export const visionService = VisionService.getInstance(); + +import { env, AutoProcessor, AutoModel, RawImage } from '@huggingface/transformers'; + +// Configure transformers.js +env.allowLocalModels = false; +env.useBrowserCache = true; + +export type VisionProgress = { + status: string; + progress?: number; + file?: string; +}; + +// We use Florence-2-base for a good balance of speed and accuracy (~200MB - 400MB) +// 'onnx-community/Florence-2-base-ft' is the modern standard for Transformers.js v3. +const MODEL_ID = 'onnx-community/Florence-2-base-ft'; + +export class VisionService { + private model: any = null; + private processor: any = null; + private isLoading = false; + private isReady = false; + + // Singleton instance + private static instance: VisionService; + + public static getInstance(): VisionService { + if (!VisionService.instance) { + VisionService.instance = new VisionService(); + } + return VisionService.instance; + } + + getStatus() { + return { + isReady: this.isReady, + isLoading: this.isLoading, + model: MODEL_ID + }; + } + + async initialize(onProgress?: (progress: VisionProgress) => void): Promise { + if (this.isReady || this.isLoading) return; + + this.isLoading = true; + + try { + console.log('Loading Vision Model...'); + if (onProgress) onProgress({ status: 'Loading Processor...' }); + + this.processor = await AutoProcessor.from_pretrained(MODEL_ID); + + if (onProgress) onProgress({ status: 'Loading Model (this may take a while)...' }); + + this.model = await AutoModel.from_pretrained(MODEL_ID, { + progress_callback: (progress: any) => { + if (onProgress && progress.status === 'progress') { + onProgress({ + status: `Downloading ${progress.file}`, + progress: progress.progress, + file: progress.file + }); + } + } + }); + + this.isReady = true; + console.log('Vision Model Ready'); + } catch (error) { + console.error('Failed to load Vision Model:', error); + throw error; + } finally { + this.isLoading = false; + } + } + + /** + * Analyzes an image (Base64 or URL) and returns a detailed description. + * We use the '' task for Florence-2. + */ + async analyzeImage(imageBase64: string): Promise { + if (!this.isReady) { + throw new Error('Vision model not loaded. Please initialize it first.'); + } + + try { + // Handle data URL prefix if present + const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`; + + const image = await RawImage.fromURL(cleanBase64); + + // Task: Detailed Captioning is best for understanding diagrams + const text = ''; + const inputs = await this.processor(image, text); + + const generatedIds = await this.model.generate({ + ...inputs, + max_new_tokens: 512, // Sufficient for a description + }); + + const generatedText = this.processor.batch_decode(generatedIds, { + skip_special_tokens: false, + })[0]; + + // Post-process to extract the caption + // Florence-2 output format usually includes the task token + const parsedAnswer = this.processor.post_process_generation( + generatedText, + text, + image.size + ); + + // Access the dictionary result. For CAPTION tasks, it's usually under '' or similar key + // Ideally post_process_generation returns { '': "Description..." } + return parsedAnswer[''] || typeof parsedAnswer === 'string' ? parsedAnswer : JSON.stringify(parsedAnswer); + + } catch (error) { + console.error('Vision analysis failed:', error); + throw new Error('Failed to analyze image with local vision model'); + } + } +} + +export const visionService = VisionService.getInstance(); diff --git a/src/lib/visualOrganizer.ts b/src/lib/visualOrganizer.ts index 343499e..46ffce2 100644 --- a/src/lib/visualOrganizer.ts +++ b/src/lib/visualOrganizer.ts @@ -1,793 +1,793 @@ -import type { Node, Edge } from '../store'; -import type { - LayoutMetrics, - VisualIssue, - LayoutSuggestion, - NodePosition -} from '../types/visualOrganization'; -import { getLayoutedElements } from './layoutEngine'; - -export class VisualOrganizer { - private nodes: Node[]; - private edges: Edge[]; - - constructor(nodes: Node[], edges: Edge[]) { - this.nodes = nodes; - this.edges = edges; - } - - /** - * Analyze the current layout and identify issues - */ - analyzeLayout(): { metrics: LayoutMetrics; issues: VisualIssue[]; strengths: string[] } { - const metrics = this.calculateMetrics(); - const issues = [...this.identifyIssues(), ...this.identifyStyleIssues()]; - const strengths = this.identifyStrengths(); - - return { metrics, issues, strengths }; - } - - /** - * Calculate layout metrics - */ - private calculateMetrics(): LayoutMetrics { - const nodeCount = this.nodes.filter(n => n.type !== 'group').length; - const edgeCount = this.edges.length; - const edgeCrossings = this.detectEdgeCrossings(); - const nodeDensity = this.calculateNodeDensity(); - const averageNodeSpacing = this.calculateAverageSpacing(); - const visualComplexity = this.calculateVisualComplexity(); - const aspectRatio = this.calculateAspectRatio(); - - return { - nodeCount, - edgeCount, - edgeCrossings, - nodeDensity, - averageNodeSpacing, - visualComplexity, - aspectRatio - }; - } - - /** - * Detect edge crossings using geometric analysis - */ - private detectEdgeCrossings(): number { - let crossings = 0; - const edges = this.edges; - - for (let i = 0; i < edges.length; i++) { - for (let j = i + 1; j < edges.length; j++) { - if (this.edgesCross(edges[i], edges[j])) { - crossings++; - } - } - } - - return crossings; - } - - /** - * Check if two edges cross geometrically - */ - private edgesCross(edge1: Edge, edge2: Edge): boolean { - const source1 = this.nodes.find(n => n.id === edge1.source); - const target1 = this.nodes.find(n => n.id === edge1.target); - const source2 = this.nodes.find(n => n.id === edge2.source); - const target2 = this.nodes.find(n => n.id === edge2.target); - - if (!source1 || !target1 || !source2 || !target2) return false; - - const p1 = { x: source1.position.x, y: source1.position.y }; - const p2 = { x: target1.position.x, y: target1.position.y }; - const p3 = { x: source2.position.x, y: source2.position.y }; - const p4 = { x: target2.position.x, y: target2.position.y }; - - return this.lineSegmentsIntersect(p1, p2, p3, p4); - } - - /** - * Check if two line segments intersect - */ - private lineSegmentsIntersect(p1: any, p2: any, p3: any, p4: any): boolean { - const ccw = (A: any, B: any, C: any) => (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x); - return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4); - } - - /** - * Calculate node density (nodes per area) - */ - private calculateNodeDensity(): number { - const visibleNodes = this.nodes.filter(n => n.type !== 'group'); - if (visibleNodes.length === 0) return 0; - - const bounds = this.getNodeBounds(); - const area = bounds.width * bounds.height; - - return visibleNodes.length / (area / 10000); // Normalize to reasonable scale - } - - /** - * Calculate average spacing between nodes - */ - private calculateAverageSpacing(): number { - const visibleNodes = this.nodes.filter(n => n.type !== 'group'); - if (visibleNodes.length < 2) return 0; - - let totalDistance = 0; - let pairCount = 0; - - for (let i = 0; i < visibleNodes.length; i++) { - for (let j = i + 1; j < visibleNodes.length; j++) { - const node1 = visibleNodes[i]; - const node2 = visibleNodes[j]; - const distance = Math.sqrt( - Math.pow(node1.position.x - node2.position.x, 2) + - Math.pow(node1.position.y - node2.position.y, 2) - ); - totalDistance += distance; - pairCount++; - } - } - - return pairCount > 0 ? totalDistance / pairCount : 0; - } - - /** - * Calculate visual complexity score (0-100) - */ - private calculateVisualComplexity(): number { - let complexity = 0; - - // Edge crossings contribute heavily to complexity - complexity += this.detectEdgeCrossings() * 10; - - // High node density increases complexity - complexity += this.calculateNodeDensity() * 20; - - // Multiple edge types increase complexity - const uniqueEdgeTypes = new Set(this.edges.map(e => e.type || 'default')).size; - complexity += uniqueEdgeTypes * 5; - - // Large number of nodes increases complexity - const nodeCount = this.nodes.filter(n => n.type !== 'group').length; - complexity += Math.max(0, nodeCount - 10) * 2; - - return Math.min(100, complexity); - } - - /** - * Calculate aspect ratio of the diagram - */ - private calculateAspectRatio(): number { - const bounds = this.getNodeBounds(); - if (bounds.height === 0) return 1; - return bounds.width / bounds.height; - } - - /** - * Get bounding box of all nodes - */ - private getNodeBounds() { - const visibleNodes = this.nodes.filter(n => n.type !== 'group'); - - if (visibleNodes.length === 0) { - return { width: 1000, height: 800, minX: 0, minY: 0, maxX: 1000, maxY: 800 }; - } - - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - - visibleNodes.forEach(node => { - minX = Math.min(minX, node.position.x); - minY = Math.min(minY, node.position.y); - maxX = Math.max(maxX, node.position.x); - maxY = Math.max(maxY, node.position.y); - }); - - return { - width: maxX - minX, - height: maxY - minY, - minX, minY, maxX, maxY - }; - } - - /** - * Identify visual issues in the current layout - */ - private identifyIssues(): VisualIssue[] { - const issues: VisualIssue[] = []; - const metrics = this.calculateMetrics(); - - // Edge crossing issues - if (metrics.edgeCrossings > 5) { - issues.push({ - type: 'edge-crossing', - severity: metrics.edgeCrossings > 10 ? 'high' : 'medium', - description: `High number of edge crossings (${metrics.edgeCrossings}) makes diagram difficult to follow`, - suggestedFix: 'Apply routing optimization to minimize edge intersections' - }); - } - - // Node overlap issues - const overlappingNodes = this.detectOverlappingNodes(); - if (overlappingNodes.length > 0) { - issues.push({ - type: 'overlap', - severity: overlappingNodes.length > 3 ? 'high' : 'medium', - description: `${overlappingNodes.length} nodes are overlapping or too close`, - affectedNodes: overlappingNodes, - suggestedFix: 'Increase spacing between nodes' - }); - } - - // Poor spacing issues - if (metrics.averageNodeSpacing < 80) { - issues.push({ - type: 'poor-spacing', - severity: metrics.averageNodeSpacing < 50 ? 'high' : 'medium', - description: 'Nodes are too close together, reducing readability', - suggestedFix: 'Increase overall node spacing' - }); - } - - // Unclear flow issues - if (metrics.visualComplexity > 70) { - issues.push({ - type: 'unclear-flow', - severity: metrics.visualComplexity > 85 ? 'high' : 'medium', - description: 'High visual complexity makes the diagram hard to understand', - suggestedFix: 'Simplify layout by grouping related nodes and reducing crossings' - }); - } - - // Inefficient layout issues - if (metrics.aspectRatio > 3 || metrics.aspectRatio < 0.3) { - issues.push({ - type: 'inefficient-layout', - severity: 'medium', - description: 'Diagram has poor aspect ratio, consider reorienting layout', - suggestedFix: 'Switch to horizontal layout or reorganize node positions' - }); - } - - return issues; - } - - /** - * Detect overlapping or too-close nodes - */ - private detectOverlappingNodes(): string[] { - const visibleNodes = this.nodes.filter(n => n.type !== 'group'); - const overlapping: string[] = []; - const threshold = 100; // Minimum distance between nodes - - for (let i = 0; i < visibleNodes.length; i++) { - for (let j = i + 1; j < visibleNodes.length; j++) { - const node1 = visibleNodes[i]; - const node2 = visibleNodes[j]; - const distance = Math.sqrt( - Math.pow(node1.position.x - node2.position.x, 2) + - Math.pow(node1.position.y - node2.position.y, 2) - ); - - if (distance < threshold) { - overlapping.push(node1.id, node2.id); - } - } - } - - return [...new Set(overlapping)]; - } - - /** - * Identify layout strengths - */ - private identifyStrengths(): string[] { - const strengths: string[] = []; - const metrics = this.calculateMetrics(); - - if (metrics.edgeCrossings === 0) { - strengths.push('Clean layout with no edge crossings'); - } - - if (metrics.averageNodeSpacing > 120) { - strengths.push('Good spacing between nodes for readability'); - } - - if (metrics.visualComplexity < 30) { - strengths.push('Low visual complexity makes diagram easy to understand'); - } - - if (metrics.aspectRatio >= 0.8 && metrics.aspectRatio <= 1.5) { - strengths.push('Well-proportioned diagram layout'); - } - - const nodeCount = this.nodes.filter(n => n.type !== 'group').length; - if (nodeCount <= 8) { - strengths.push('Appropriate number of nodes for clear communication'); - } - - return strengths; - } - - /** - * Generate layout optimization suggestions - */ - generateSuggestions(): LayoutSuggestion[] { - const suggestions: LayoutSuggestion[] = []; - const { metrics, issues } = this.analyzeLayout(); - - // Suggest spacing improvements - if (metrics.averageNodeSpacing < 100) { - suggestions.push({ - id: 'spacing-improvement', - title: 'Improve Node Spacing', - description: 'Increase spacing between nodes to improve readability and reduce visual clutter', - type: 'spacing', - impact: 'medium', - estimatedImprovement: 25, - beforeState: { metrics, issues }, - afterState: { - metrics: { ...metrics, averageNodeSpacing: 150, visualComplexity: Math.max(0, metrics.visualComplexity - 15) }, - estimatedIssues: issues.filter(i => i.type !== 'poor-spacing') - }, - implementation: { - nodePositions: this.calculateOptimalSpacing() - } - }); - } - - // Suggest routing optimization - if (metrics.edgeCrossings > 3) { - suggestions.push({ - id: 'routing-optimization', - title: 'Optimize Edge Routing', - description: 'Reduce edge crossings by applying smart routing algorithms', - type: 'routing', - impact: 'high', - estimatedImprovement: 40, - beforeState: { metrics, issues }, - afterState: { - metrics: { ...metrics, edgeCrossings: Math.max(0, metrics.edgeCrossings - 5), visualComplexity: Math.max(0, metrics.visualComplexity - 20) }, - estimatedIssues: issues.filter(i => i.type !== 'edge-crossing') - }, - implementation: { - nodePositions: this.calculateRoutingOptimizedPositions(), - edgeRouting: { style: 'curved', offsetStrategy: 'intelligent' } - } - }); - } - - // Suggest grouping improvements - const similarNodes = this.findSimilarNodes(); - if (similarNodes.length > 0) { - suggestions.push({ - id: 'grouping-improvement', - title: 'Group Related Nodes', - description: 'Group similar nodes together to improve visual hierarchy and organization', - type: 'grouping', - impact: 'medium', - estimatedImprovement: 30, - beforeState: { metrics, issues }, - afterState: { - metrics: { ...metrics, visualComplexity: Math.max(0, metrics.visualComplexity - 10) }, - estimatedIssues: issues.filter(i => i.type !== 'unclear-flow') - }, - implementation: { - nodePositions: this.calculateGroupedPositions(similarNodes), - styleChanges: { groupNodes: true } - } - }); - } - - // Suggest Visual Hierarchy improvements - const centralNode = this.findCentralNode(); - if (centralNode && (!centralNode.style?.width || (typeof centralNode.style.width === 'number' && centralNode.style.width < 100))) { - suggestions.push({ - id: 'hierarchy-emphasis', - title: 'Emphasize Central Node', - description: `Node "${centralNode.data.label}" appears central to the graph. Consider increasing its size or prominence.`, - type: 'hierarchy', - impact: 'medium', - estimatedImprovement: 20, - beforeState: { metrics, issues }, - afterState: { metrics, estimatedIssues: [] }, - implementation: { - nodePositions: {}, - styleChanges: { - [centralNode.id]: { - width: 250, - height: 120, - style: { ...centralNode.style, width: 250, height: 120, fontSize: '1.2em', fontWeight: 'bold' } - } - }, - description: `Make node ${centralNode.id} larger and more distinct.` - } - }); - } - - // Suggest Style Consistency - const styleIssues = this.identifyStyleIssues(); - if (styleIssues.length > 0) { - suggestions.push({ - id: 'style-consistency', - title: 'Fix Style Inconsistencies', - description: 'Detected nodes of the same type with different colors. Standardize them for consistency.', - type: 'style', - impact: 'low', - estimatedImprovement: 15, - beforeState: { metrics, issues: styleIssues }, - afterState: { metrics, estimatedIssues: [] }, - implementation: { - nodePositions: {}, - styleChanges: this.generateStyleConsistencyChanges(styleIssues), - description: 'Apply consistent colors to node types.' - } - }); - } - - // Suggest Node View Optimization - const nodeOptimization = this.generateNodeViewSuggestions(); - if (nodeOptimization) { - suggestions.push(nodeOptimization); - } - - return suggestions; - } - - /** - * Identify style consistency issues - */ - private identifyStyleIssues(): VisualIssue[] { - const issues: VisualIssue[] = []; - const visibleNodes = this.nodes.filter(n => n.type !== 'group'); - const nodesByType = new Map, ids: string[] }>(); - - visibleNodes.forEach(node => { - const type = node.type || 'default'; - const color = node.data?.color || node.style?.backgroundColor || 'default'; - - if (!nodesByType.has(type)) { - nodesByType.set(type, { colors: new Set(), ids: [] }); - } - const entry = nodesByType.get(type)!; - entry.colors.add(color as string); - entry.ids.push(node.id); - }); - - nodesByType.forEach((entry, type) => { - if (entry.colors.size > 1) { - issues.push({ - type: 'style-consistency', - severity: 'low', - description: `Nodes of type '${type}' have inconsistent colors`, - affectedNodes: entry.ids, - suggestedFix: 'Standardize color for this node type' - }); - } - }); - - return issues; - } - - /** - * Generate style changes to fix consistency issues - */ - private generateStyleConsistencyChanges(issues: VisualIssue[]): Record { - const changes: Record = {}; - const typeColors: Record = { - database: '#0ea5e9', // Sky 500 - service: '#8b5cf6', // Violet 500 - client: '#f59e0b', // Amber 500 - default: '#64748b' // Slate 500 - }; - - issues.filter(i => i.type === 'style-consistency').forEach(issue => { - if (!issue.affectedNodes) return; - - // Extract type from description or logic - let targetColor = typeColors.default; - if (issue.description.includes("'database'")) targetColor = typeColors.database; - if (issue.description.includes("'client'")) targetColor = typeColors.client; - if (issue.description.includes("'service'")) targetColor = typeColors.service; - - issue.affectedNodes.forEach(nodeId => { - changes[nodeId] = { - color: targetColor, - style: { backgroundColor: targetColor } - }; - }); - }); - - return changes; - } - - /** - * Find the most central node (highest degree centrality) - */ - private findCentralNode(): Node | null { - if (this.nodes.length === 0) return null; - - const degrees = new Map(); - this.edges.forEach(edge => { - degrees.set(edge.source, (degrees.get(edge.source) || 0) + 1); - degrees.set(edge.target, (degrees.get(edge.target) || 0) + 1); - }); - - let maxDegree = -1; - let centralNodeId: string | null = null; - - degrees.forEach((degree, id) => { - if (degree > maxDegree) { - maxDegree = degree; - centralNodeId = id; - } - }); - - if (centralNodeId && maxDegree > 2) { // Threshold for "central" - return this.nodes.find(n => n.id === centralNodeId) || null; - } - - return null; - } - - /** - * Calculate optimal spacing for nodes - */ - private calculateOptimalSpacing(): Record { - const positions: Record = {}; - const visibleNodes = this.nodes.filter(n => n.type !== 'group'); - const gridSize = 200; // Optimal spacing - - visibleNodes.forEach((node, index) => { - const row = Math.floor(index / Math.ceil(Math.sqrt(visibleNodes.length))); - const col = index % Math.ceil(Math.sqrt(visibleNodes.length)); - - positions[node.id] = { - x: col * gridSize + 100, - y: row * gridSize + 100 - }; - }); - - return positions; - } - - /** - * Calculate routing-optimized positions - */ - private calculateRoutingOptimizedPositions(): Record { - // Use the existing layout engine with optimized settings - const { nodes: layoutedNodes } = getLayoutedElements( - this.nodes, - this.edges, - { direction: 'TB', nodeSpacing: 80, rankSpacing: 100 } - ); - - const positions: Record = {}; - layoutedNodes.forEach(node => { - positions[node.id] = { x: node.position.x, y: node.position.y }; - }); - - return positions; - } - - /** - * Find similar nodes that could be grouped - */ - private findSimilarNodes(): Array<{ nodeIds: string[]; similarity: string }> { - const similar: Array<{ nodeIds: string[]; similarity: string }> = []; - const visibleNodes = this.nodes.filter(n => n.type !== 'group'); - - // Group by node type - const nodesByType = new Map(); - visibleNodes.forEach(node => { - const type = node.type || 'default'; - if (!nodesByType.has(type)) { - nodesByType.set(type, []); - } - nodesByType.get(type)!.push(node); - }); - - nodesByType.forEach((nodes, type) => { - if (nodes.length > 1) { - similar.push({ - nodeIds: nodes.map(n => n.id), - similarity: `Same type: ${type}` - }); - } - }); - - return similar; - } - - /** - * Calculate grouped positions for similar nodes - */ - private calculateGroupedPositions(similarNodes: Array<{ nodeIds: string[]; similarity: string }>): Record { - const positions: Record = {}; - const groupSize = 150; - let groupIndex = 0; - - similarNodes.forEach(group => { - group.nodeIds.forEach((nodeId, index) => { - positions[nodeId] = { - x: groupIndex * 300 + (index % 2) * groupSize, - y: groupIndex * 200 + Math.floor(index / 2) * groupSize - }; - }); - groupIndex++; - }); - - return positions; - } - - /** - * Apply a layout suggestion - */ - applySuggestion(suggestion: LayoutSuggestion): { nodes: Node[]; edges: Edge[] } { - const newNodes = this.nodes.map(node => { - let updatedNode = { ...node }; - - // Apply position changes - if (suggestion.implementation.nodePositions[node.id]) { - const newPos = suggestion.implementation.nodePositions[node.id]; - updatedNode.position = { x: newPos.x, y: newPos.y }; - } - - // Apply style changes - if (suggestion.implementation.styleChanges && suggestion.implementation.styleChanges[node.id]) { - const styleUpdates = suggestion.implementation.styleChanges[node.id]; - updatedNode.style = { ...updatedNode.style, ...styleUpdates }; - updatedNode.data = { ...updatedNode.data, ...styleUpdates }; // Also update data for some properties if needed - } - - return updatedNode; - }); - - // Apply edge changes if any - let newEdges = this.edges; - if (suggestion.implementation.edgeRouting) { - // Logic to update edge styles if needed - newEdges = this.edges.map(edge => ({ - ...edge, - type: suggestion.implementation.edgeRouting?.style === 'curved' ? 'curved' : 'straight', - data: { ...edge.data, offset: suggestion.implementation.edgeRouting?.offsetStrategy === 'intelligent' ? 20 : 0 } - })); - } - - return { nodes: newNodes, edges: newEdges }; - } - - /** - * Generate suggestions for optimizing node views based on semantics - */ - private generateNodeViewSuggestions(): LayoutSuggestion | null { - const styleChanges: Record = {}; - const { metrics, issues } = this.analyzeLayout(); - let improvementCount = 0; - - this.nodes.forEach(node => { - if (node.type === 'group') return; - - const semantics = this.analyzeNodeSemantics(node); - if (!semantics) return; - - // Define semantic styles - const styles = { - decision: { shape: 'diamond', backgroundColor: '#fcd34d', borderColor: '#d97706', borderRadius: '4px' }, // Amber - action: { shape: 'rect', backgroundColor: '#a78bfa', borderColor: '#7c3aed', borderRadius: '8px' }, // Violet - data: { shape: 'cylinder', backgroundColor: '#38bdf8', borderColor: '#0284c7', borderRadius: '12px' }, // Sky - startEnd: { shape: 'pill', backgroundColor: '#4ade80', borderColor: '#16a34a', borderRadius: '999px' }, // Green - concept: { shape: 'rect', backgroundColor: '#e2e8f0', borderColor: '#64748b', borderRadius: '6px' } // Slate - }; - - // Check if current style matches semantics (simplified check) - // In a real app we would compare actual style properties - // Here we assume if it's "default" or distinct enough we suggest it - - if (semantics === 'decision' && !node.data.label?.includes('?')) return; // Extra check for decisions - - const suggestedStyle = styles[semantics]; - styleChanges[node.id] = { - style: { - backgroundColor: suggestedStyle.backgroundColor, - border: `1px solid ${suggestedStyle.borderColor}`, - borderRadius: suggestedStyle.borderRadius - } - }; - improvementCount++; - }); - - if (improvementCount > 0) { - return { - id: 'node-optimization', - title: 'Optimize Node Views', - description: `Found ${improvementCount} nodes where visual style can better match their semantic meaning (Decisions, Actions, Data).`, - type: 'node-color-semantic', - impact: 'medium', - estimatedImprovement: 25, - beforeState: { metrics, issues }, - afterState: { metrics, estimatedIssues: [] }, - implementation: { - nodePositions: {}, - styleChanges, - description: 'Apply semantic styling to nodes based on their content.' - } - }; - } - - return null; - } - - /** - * Analyze node label to guess its semantic role - */ - private analyzeNodeSemantics(node: Node): 'decision' | 'action' | 'data' | 'startEnd' | 'concept' | null { - const label = node.data.label?.toLowerCase() || ''; - if (!label) return null; - - // Decision patterns - if (label.includes('?') || label.startsWith('is ') || label.startsWith('check ') || label.includes('approve') || label.includes('review')) { - return 'decision'; - } - - // Data patterns - if (label.includes('data') || label.includes('database') || label.includes('store') || label.includes('json') || label.includes('record')) { - return 'data'; - } - - // Start/End patterns - if (label === 'start' || label === 'end' || label === 'begin' || label === 'stop' || label === 'finish') { - return 'startEnd'; - } - - // Action patterns (verbs suitable for processes) - const actionVerbs = ['create', 'update', 'delete', 'process', 'calculate', 'send', 'receive', 'generate', 'publish', 'edit']; - if (actionVerbs.some(v => label.includes(v))) { - return 'action'; - } - - return 'concept'; // Default fallback for content-heavy nodes - } - getPresets(): LayoutSuggestion[] { - const { metrics, issues } = this.analyzeLayout(); - - return [ - { - id: 'preset-compact', - title: 'Compact Grid', - description: 'Arranges nodes in a tight grid to save space', - type: 'grouping', - impact: 'high', - estimatedImprovement: 50, - beforeState: { metrics, issues }, - afterState: { metrics, estimatedIssues: [] }, - implementation: { - nodePositions: this.calculateOptimalSpacing() - } - }, - { - id: 'preset-flow', - title: 'Clear Flow', - description: 'Optimizes for top-to-bottom flow with minimal crossings', - type: 'routing', - impact: 'high', - estimatedImprovement: 40, - beforeState: { metrics, issues }, - afterState: { metrics, estimatedIssues: [] }, - implementation: { - nodePositions: this.calculateRoutingOptimizedPositions(), - edgeRouting: { style: 'curved', offsetStrategy: 'intelligent' } - } - } - ]; - } -} - -/** - * Utility function to create visual organizer instance - */ -export function createVisualOrganizer(nodes: Node[], edges: Edge[]): VisualOrganizer { - return new VisualOrganizer(nodes, edges); -} +import type { Node, Edge } from '../store'; +import type { + LayoutMetrics, + VisualIssue, + LayoutSuggestion, + NodePosition +} from '../types/visualOrganization'; +import { getLayoutedElements } from './layoutEngine'; + +export class VisualOrganizer { + private nodes: Node[]; + private edges: Edge[]; + + constructor(nodes: Node[], edges: Edge[]) { + this.nodes = nodes; + this.edges = edges; + } + + /** + * Analyze the current layout and identify issues + */ + analyzeLayout(): { metrics: LayoutMetrics; issues: VisualIssue[]; strengths: string[] } { + const metrics = this.calculateMetrics(); + const issues = [...this.identifyIssues(), ...this.identifyStyleIssues()]; + const strengths = this.identifyStrengths(); + + return { metrics, issues, strengths }; + } + + /** + * Calculate layout metrics + */ + private calculateMetrics(): LayoutMetrics { + const nodeCount = this.nodes.filter(n => n.type !== 'group').length; + const edgeCount = this.edges.length; + const edgeCrossings = this.detectEdgeCrossings(); + const nodeDensity = this.calculateNodeDensity(); + const averageNodeSpacing = this.calculateAverageSpacing(); + const visualComplexity = this.calculateVisualComplexity(); + const aspectRatio = this.calculateAspectRatio(); + + return { + nodeCount, + edgeCount, + edgeCrossings, + nodeDensity, + averageNodeSpacing, + visualComplexity, + aspectRatio + }; + } + + /** + * Detect edge crossings using geometric analysis + */ + private detectEdgeCrossings(): number { + let crossings = 0; + const edges = this.edges; + + for (let i = 0; i < edges.length; i++) { + for (let j = i + 1; j < edges.length; j++) { + if (this.edgesCross(edges[i], edges[j])) { + crossings++; + } + } + } + + return crossings; + } + + /** + * Check if two edges cross geometrically + */ + private edgesCross(edge1: Edge, edge2: Edge): boolean { + const source1 = this.nodes.find(n => n.id === edge1.source); + const target1 = this.nodes.find(n => n.id === edge1.target); + const source2 = this.nodes.find(n => n.id === edge2.source); + const target2 = this.nodes.find(n => n.id === edge2.target); + + if (!source1 || !target1 || !source2 || !target2) return false; + + const p1 = { x: source1.position.x, y: source1.position.y }; + const p2 = { x: target1.position.x, y: target1.position.y }; + const p3 = { x: source2.position.x, y: source2.position.y }; + const p4 = { x: target2.position.x, y: target2.position.y }; + + return this.lineSegmentsIntersect(p1, p2, p3, p4); + } + + /** + * Check if two line segments intersect + */ + private lineSegmentsIntersect(p1: any, p2: any, p3: any, p4: any): boolean { + const ccw = (A: any, B: any, C: any) => (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x); + return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4); + } + + /** + * Calculate node density (nodes per area) + */ + private calculateNodeDensity(): number { + const visibleNodes = this.nodes.filter(n => n.type !== 'group'); + if (visibleNodes.length === 0) return 0; + + const bounds = this.getNodeBounds(); + const area = bounds.width * bounds.height; + + return visibleNodes.length / (area / 10000); // Normalize to reasonable scale + } + + /** + * Calculate average spacing between nodes + */ + private calculateAverageSpacing(): number { + const visibleNodes = this.nodes.filter(n => n.type !== 'group'); + if (visibleNodes.length < 2) return 0; + + let totalDistance = 0; + let pairCount = 0; + + for (let i = 0; i < visibleNodes.length; i++) { + for (let j = i + 1; j < visibleNodes.length; j++) { + const node1 = visibleNodes[i]; + const node2 = visibleNodes[j]; + const distance = Math.sqrt( + Math.pow(node1.position.x - node2.position.x, 2) + + Math.pow(node1.position.y - node2.position.y, 2) + ); + totalDistance += distance; + pairCount++; + } + } + + return pairCount > 0 ? totalDistance / pairCount : 0; + } + + /** + * Calculate visual complexity score (0-100) + */ + private calculateVisualComplexity(): number { + let complexity = 0; + + // Edge crossings contribute heavily to complexity + complexity += this.detectEdgeCrossings() * 10; + + // High node density increases complexity + complexity += this.calculateNodeDensity() * 20; + + // Multiple edge types increase complexity + const uniqueEdgeTypes = new Set(this.edges.map(e => e.type || 'default')).size; + complexity += uniqueEdgeTypes * 5; + + // Large number of nodes increases complexity + const nodeCount = this.nodes.filter(n => n.type !== 'group').length; + complexity += Math.max(0, nodeCount - 10) * 2; + + return Math.min(100, complexity); + } + + /** + * Calculate aspect ratio of the diagram + */ + private calculateAspectRatio(): number { + const bounds = this.getNodeBounds(); + if (bounds.height === 0) return 1; + return bounds.width / bounds.height; + } + + /** + * Get bounding box of all nodes + */ + private getNodeBounds() { + const visibleNodes = this.nodes.filter(n => n.type !== 'group'); + + if (visibleNodes.length === 0) { + return { width: 1000, height: 800, minX: 0, minY: 0, maxX: 1000, maxY: 800 }; + } + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + visibleNodes.forEach(node => { + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x); + maxY = Math.max(maxY, node.position.y); + }); + + return { + width: maxX - minX, + height: maxY - minY, + minX, minY, maxX, maxY + }; + } + + /** + * Identify visual issues in the current layout + */ + private identifyIssues(): VisualIssue[] { + const issues: VisualIssue[] = []; + const metrics = this.calculateMetrics(); + + // Edge crossing issues + if (metrics.edgeCrossings > 5) { + issues.push({ + type: 'edge-crossing', + severity: metrics.edgeCrossings > 10 ? 'high' : 'medium', + description: `High number of edge crossings (${metrics.edgeCrossings}) makes diagram difficult to follow`, + suggestedFix: 'Apply routing optimization to minimize edge intersections' + }); + } + + // Node overlap issues + const overlappingNodes = this.detectOverlappingNodes(); + if (overlappingNodes.length > 0) { + issues.push({ + type: 'overlap', + severity: overlappingNodes.length > 3 ? 'high' : 'medium', + description: `${overlappingNodes.length} nodes are overlapping or too close`, + affectedNodes: overlappingNodes, + suggestedFix: 'Increase spacing between nodes' + }); + } + + // Poor spacing issues + if (metrics.averageNodeSpacing < 80) { + issues.push({ + type: 'poor-spacing', + severity: metrics.averageNodeSpacing < 50 ? 'high' : 'medium', + description: 'Nodes are too close together, reducing readability', + suggestedFix: 'Increase overall node spacing' + }); + } + + // Unclear flow issues + if (metrics.visualComplexity > 70) { + issues.push({ + type: 'unclear-flow', + severity: metrics.visualComplexity > 85 ? 'high' : 'medium', + description: 'High visual complexity makes the diagram hard to understand', + suggestedFix: 'Simplify layout by grouping related nodes and reducing crossings' + }); + } + + // Inefficient layout issues + if (metrics.aspectRatio > 3 || metrics.aspectRatio < 0.3) { + issues.push({ + type: 'inefficient-layout', + severity: 'medium', + description: 'Diagram has poor aspect ratio, consider reorienting layout', + suggestedFix: 'Switch to horizontal layout or reorganize node positions' + }); + } + + return issues; + } + + /** + * Detect overlapping or too-close nodes + */ + private detectOverlappingNodes(): string[] { + const visibleNodes = this.nodes.filter(n => n.type !== 'group'); + const overlapping: string[] = []; + const threshold = 100; // Minimum distance between nodes + + for (let i = 0; i < visibleNodes.length; i++) { + for (let j = i + 1; j < visibleNodes.length; j++) { + const node1 = visibleNodes[i]; + const node2 = visibleNodes[j]; + const distance = Math.sqrt( + Math.pow(node1.position.x - node2.position.x, 2) + + Math.pow(node1.position.y - node2.position.y, 2) + ); + + if (distance < threshold) { + overlapping.push(node1.id, node2.id); + } + } + } + + return [...new Set(overlapping)]; + } + + /** + * Identify layout strengths + */ + private identifyStrengths(): string[] { + const strengths: string[] = []; + const metrics = this.calculateMetrics(); + + if (metrics.edgeCrossings === 0) { + strengths.push('Clean layout with no edge crossings'); + } + + if (metrics.averageNodeSpacing > 120) { + strengths.push('Good spacing between nodes for readability'); + } + + if (metrics.visualComplexity < 30) { + strengths.push('Low visual complexity makes diagram easy to understand'); + } + + if (metrics.aspectRatio >= 0.8 && metrics.aspectRatio <= 1.5) { + strengths.push('Well-proportioned diagram layout'); + } + + const nodeCount = this.nodes.filter(n => n.type !== 'group').length; + if (nodeCount <= 8) { + strengths.push('Appropriate number of nodes for clear communication'); + } + + return strengths; + } + + /** + * Generate layout optimization suggestions + */ + generateSuggestions(): LayoutSuggestion[] { + const suggestions: LayoutSuggestion[] = []; + const { metrics, issues } = this.analyzeLayout(); + + // Suggest spacing improvements + if (metrics.averageNodeSpacing < 100) { + suggestions.push({ + id: 'spacing-improvement', + title: 'Improve Node Spacing', + description: 'Increase spacing between nodes to improve readability and reduce visual clutter', + type: 'spacing', + impact: 'medium', + estimatedImprovement: 25, + beforeState: { metrics, issues }, + afterState: { + metrics: { ...metrics, averageNodeSpacing: 150, visualComplexity: Math.max(0, metrics.visualComplexity - 15) }, + estimatedIssues: issues.filter(i => i.type !== 'poor-spacing') + }, + implementation: { + nodePositions: this.calculateOptimalSpacing() + } + }); + } + + // Suggest routing optimization + if (metrics.edgeCrossings > 3) { + suggestions.push({ + id: 'routing-optimization', + title: 'Optimize Edge Routing', + description: 'Reduce edge crossings by applying smart routing algorithms', + type: 'routing', + impact: 'high', + estimatedImprovement: 40, + beforeState: { metrics, issues }, + afterState: { + metrics: { ...metrics, edgeCrossings: Math.max(0, metrics.edgeCrossings - 5), visualComplexity: Math.max(0, metrics.visualComplexity - 20) }, + estimatedIssues: issues.filter(i => i.type !== 'edge-crossing') + }, + implementation: { + nodePositions: this.calculateRoutingOptimizedPositions(), + edgeRouting: { style: 'curved', offsetStrategy: 'intelligent' } + } + }); + } + + // Suggest grouping improvements + const similarNodes = this.findSimilarNodes(); + if (similarNodes.length > 0) { + suggestions.push({ + id: 'grouping-improvement', + title: 'Group Related Nodes', + description: 'Group similar nodes together to improve visual hierarchy and organization', + type: 'grouping', + impact: 'medium', + estimatedImprovement: 30, + beforeState: { metrics, issues }, + afterState: { + metrics: { ...metrics, visualComplexity: Math.max(0, metrics.visualComplexity - 10) }, + estimatedIssues: issues.filter(i => i.type !== 'unclear-flow') + }, + implementation: { + nodePositions: this.calculateGroupedPositions(similarNodes), + styleChanges: { groupNodes: true } + } + }); + } + + // Suggest Visual Hierarchy improvements + const centralNode = this.findCentralNode(); + if (centralNode && (!centralNode.style?.width || (typeof centralNode.style.width === 'number' && centralNode.style.width < 100))) { + suggestions.push({ + id: 'hierarchy-emphasis', + title: 'Emphasize Central Node', + description: `Node "${centralNode.data.label}" appears central to the graph. Consider increasing its size or prominence.`, + type: 'hierarchy', + impact: 'medium', + estimatedImprovement: 20, + beforeState: { metrics, issues }, + afterState: { metrics, estimatedIssues: [] }, + implementation: { + nodePositions: {}, + styleChanges: { + [centralNode.id]: { + width: 250, + height: 120, + style: { ...centralNode.style, width: 250, height: 120, fontSize: '1.2em', fontWeight: 'bold' } + } + }, + description: `Make node ${centralNode.id} larger and more distinct.` + } + }); + } + + // Suggest Style Consistency + const styleIssues = this.identifyStyleIssues(); + if (styleIssues.length > 0) { + suggestions.push({ + id: 'style-consistency', + title: 'Fix Style Inconsistencies', + description: 'Detected nodes of the same type with different colors. Standardize them for consistency.', + type: 'style', + impact: 'low', + estimatedImprovement: 15, + beforeState: { metrics, issues: styleIssues }, + afterState: { metrics, estimatedIssues: [] }, + implementation: { + nodePositions: {}, + styleChanges: this.generateStyleConsistencyChanges(styleIssues), + description: 'Apply consistent colors to node types.' + } + }); + } + + // Suggest Node View Optimization + const nodeOptimization = this.generateNodeViewSuggestions(); + if (nodeOptimization) { + suggestions.push(nodeOptimization); + } + + return suggestions; + } + + /** + * Identify style consistency issues + */ + private identifyStyleIssues(): VisualIssue[] { + const issues: VisualIssue[] = []; + const visibleNodes = this.nodes.filter(n => n.type !== 'group'); + const nodesByType = new Map, ids: string[] }>(); + + visibleNodes.forEach(node => { + const type = node.type || 'default'; + const color = node.data?.color || node.style?.backgroundColor || 'default'; + + if (!nodesByType.has(type)) { + nodesByType.set(type, { colors: new Set(), ids: [] }); + } + const entry = nodesByType.get(type)!; + entry.colors.add(color as string); + entry.ids.push(node.id); + }); + + nodesByType.forEach((entry, type) => { + if (entry.colors.size > 1) { + issues.push({ + type: 'style-consistency', + severity: 'low', + description: `Nodes of type '${type}' have inconsistent colors`, + affectedNodes: entry.ids, + suggestedFix: 'Standardize color for this node type' + }); + } + }); + + return issues; + } + + /** + * Generate style changes to fix consistency issues + */ + private generateStyleConsistencyChanges(issues: VisualIssue[]): Record { + const changes: Record = {}; + const typeColors: Record = { + database: '#0ea5e9', // Sky 500 + service: '#8b5cf6', // Violet 500 + client: '#f59e0b', // Amber 500 + default: '#64748b' // Slate 500 + }; + + issues.filter(i => i.type === 'style-consistency').forEach(issue => { + if (!issue.affectedNodes) return; + + // Extract type from description or logic + let targetColor = typeColors.default; + if (issue.description.includes("'database'")) targetColor = typeColors.database; + if (issue.description.includes("'client'")) targetColor = typeColors.client; + if (issue.description.includes("'service'")) targetColor = typeColors.service; + + issue.affectedNodes.forEach(nodeId => { + changes[nodeId] = { + color: targetColor, + style: { backgroundColor: targetColor } + }; + }); + }); + + return changes; + } + + /** + * Find the most central node (highest degree centrality) + */ + private findCentralNode(): Node | null { + if (this.nodes.length === 0) return null; + + const degrees = new Map(); + this.edges.forEach(edge => { + degrees.set(edge.source, (degrees.get(edge.source) || 0) + 1); + degrees.set(edge.target, (degrees.get(edge.target) || 0) + 1); + }); + + let maxDegree = -1; + let centralNodeId: string | null = null; + + degrees.forEach((degree, id) => { + if (degree > maxDegree) { + maxDegree = degree; + centralNodeId = id; + } + }); + + if (centralNodeId && maxDegree > 2) { // Threshold for "central" + return this.nodes.find(n => n.id === centralNodeId) || null; + } + + return null; + } + + /** + * Calculate optimal spacing for nodes + */ + private calculateOptimalSpacing(): Record { + const positions: Record = {}; + const visibleNodes = this.nodes.filter(n => n.type !== 'group'); + const gridSize = 200; // Optimal spacing + + visibleNodes.forEach((node, index) => { + const row = Math.floor(index / Math.ceil(Math.sqrt(visibleNodes.length))); + const col = index % Math.ceil(Math.sqrt(visibleNodes.length)); + + positions[node.id] = { + x: col * gridSize + 100, + y: row * gridSize + 100 + }; + }); + + return positions; + } + + /** + * Calculate routing-optimized positions + */ + private calculateRoutingOptimizedPositions(): Record { + // Use the existing layout engine with optimized settings + const { nodes: layoutedNodes } = getLayoutedElements( + this.nodes, + this.edges, + { direction: 'TB', nodeSpacing: 80, rankSpacing: 100 } + ); + + const positions: Record = {}; + layoutedNodes.forEach(node => { + positions[node.id] = { x: node.position.x, y: node.position.y }; + }); + + return positions; + } + + /** + * Find similar nodes that could be grouped + */ + private findSimilarNodes(): Array<{ nodeIds: string[]; similarity: string }> { + const similar: Array<{ nodeIds: string[]; similarity: string }> = []; + const visibleNodes = this.nodes.filter(n => n.type !== 'group'); + + // Group by node type + const nodesByType = new Map(); + visibleNodes.forEach(node => { + const type = node.type || 'default'; + if (!nodesByType.has(type)) { + nodesByType.set(type, []); + } + nodesByType.get(type)!.push(node); + }); + + nodesByType.forEach((nodes, type) => { + if (nodes.length > 1) { + similar.push({ + nodeIds: nodes.map(n => n.id), + similarity: `Same type: ${type}` + }); + } + }); + + return similar; + } + + /** + * Calculate grouped positions for similar nodes + */ + private calculateGroupedPositions(similarNodes: Array<{ nodeIds: string[]; similarity: string }>): Record { + const positions: Record = {}; + const groupSize = 150; + let groupIndex = 0; + + similarNodes.forEach(group => { + group.nodeIds.forEach((nodeId, index) => { + positions[nodeId] = { + x: groupIndex * 300 + (index % 2) * groupSize, + y: groupIndex * 200 + Math.floor(index / 2) * groupSize + }; + }); + groupIndex++; + }); + + return positions; + } + + /** + * Apply a layout suggestion + */ + applySuggestion(suggestion: LayoutSuggestion): { nodes: Node[]; edges: Edge[] } { + const newNodes = this.nodes.map(node => { + let updatedNode = { ...node }; + + // Apply position changes + if (suggestion.implementation.nodePositions[node.id]) { + const newPos = suggestion.implementation.nodePositions[node.id]; + updatedNode.position = { x: newPos.x, y: newPos.y }; + } + + // Apply style changes + if (suggestion.implementation.styleChanges && suggestion.implementation.styleChanges[node.id]) { + const styleUpdates = suggestion.implementation.styleChanges[node.id]; + updatedNode.style = { ...updatedNode.style, ...styleUpdates }; + updatedNode.data = { ...updatedNode.data, ...styleUpdates }; // Also update data for some properties if needed + } + + return updatedNode; + }); + + // Apply edge changes if any + let newEdges = this.edges; + if (suggestion.implementation.edgeRouting) { + // Logic to update edge styles if needed + newEdges = this.edges.map(edge => ({ + ...edge, + type: suggestion.implementation.edgeRouting?.style === 'curved' ? 'curved' : 'straight', + data: { ...edge.data, offset: suggestion.implementation.edgeRouting?.offsetStrategy === 'intelligent' ? 20 : 0 } + })); + } + + return { nodes: newNodes, edges: newEdges }; + } + + /** + * Generate suggestions for optimizing node views based on semantics + */ + private generateNodeViewSuggestions(): LayoutSuggestion | null { + const styleChanges: Record = {}; + const { metrics, issues } = this.analyzeLayout(); + let improvementCount = 0; + + this.nodes.forEach(node => { + if (node.type === 'group') return; + + const semantics = this.analyzeNodeSemantics(node); + if (!semantics) return; + + // Define semantic styles + const styles = { + decision: { shape: 'diamond', backgroundColor: '#fcd34d', borderColor: '#d97706', borderRadius: '4px' }, // Amber + action: { shape: 'rect', backgroundColor: '#a78bfa', borderColor: '#7c3aed', borderRadius: '8px' }, // Violet + data: { shape: 'cylinder', backgroundColor: '#38bdf8', borderColor: '#0284c7', borderRadius: '12px' }, // Sky + startEnd: { shape: 'pill', backgroundColor: '#4ade80', borderColor: '#16a34a', borderRadius: '999px' }, // Green + concept: { shape: 'rect', backgroundColor: '#e2e8f0', borderColor: '#64748b', borderRadius: '6px' } // Slate + }; + + // Check if current style matches semantics (simplified check) + // In a real app we would compare actual style properties + // Here we assume if it's "default" or distinct enough we suggest it + + if (semantics === 'decision' && !node.data.label?.includes('?')) return; // Extra check for decisions + + const suggestedStyle = styles[semantics]; + styleChanges[node.id] = { + style: { + backgroundColor: suggestedStyle.backgroundColor, + border: `1px solid ${suggestedStyle.borderColor}`, + borderRadius: suggestedStyle.borderRadius + } + }; + improvementCount++; + }); + + if (improvementCount > 0) { + return { + id: 'node-optimization', + title: 'Optimize Node Views', + description: `Found ${improvementCount} nodes where visual style can better match their semantic meaning (Decisions, Actions, Data).`, + type: 'node-color-semantic', + impact: 'medium', + estimatedImprovement: 25, + beforeState: { metrics, issues }, + afterState: { metrics, estimatedIssues: [] }, + implementation: { + nodePositions: {}, + styleChanges, + description: 'Apply semantic styling to nodes based on their content.' + } + }; + } + + return null; + } + + /** + * Analyze node label to guess its semantic role + */ + private analyzeNodeSemantics(node: Node): 'decision' | 'action' | 'data' | 'startEnd' | 'concept' | null { + const label = node.data.label?.toLowerCase() || ''; + if (!label) return null; + + // Decision patterns + if (label.includes('?') || label.startsWith('is ') || label.startsWith('check ') || label.includes('approve') || label.includes('review')) { + return 'decision'; + } + + // Data patterns + if (label.includes('data') || label.includes('database') || label.includes('store') || label.includes('json') || label.includes('record')) { + return 'data'; + } + + // Start/End patterns + if (label === 'start' || label === 'end' || label === 'begin' || label === 'stop' || label === 'finish') { + return 'startEnd'; + } + + // Action patterns (verbs suitable for processes) + const actionVerbs = ['create', 'update', 'delete', 'process', 'calculate', 'send', 'receive', 'generate', 'publish', 'edit']; + if (actionVerbs.some(v => label.includes(v))) { + return 'action'; + } + + return 'concept'; // Default fallback for content-heavy nodes + } + getPresets(): LayoutSuggestion[] { + const { metrics, issues } = this.analyzeLayout(); + + return [ + { + id: 'preset-compact', + title: 'Compact Grid', + description: 'Arranges nodes in a tight grid to save space', + type: 'grouping', + impact: 'high', + estimatedImprovement: 50, + beforeState: { metrics, issues }, + afterState: { metrics, estimatedIssues: [] }, + implementation: { + nodePositions: this.calculateOptimalSpacing() + } + }, + { + id: 'preset-flow', + title: 'Clear Flow', + description: 'Optimizes for top-to-bottom flow with minimal crossings', + type: 'routing', + impact: 'high', + estimatedImprovement: 40, + beforeState: { metrics, issues }, + afterState: { metrics, estimatedIssues: [] }, + implementation: { + nodePositions: this.calculateRoutingOptimizedPositions(), + edgeRouting: { style: 'curved', offsetStrategy: 'intelligent' } + } + } + ]; + } +} + +/** + * Utility function to create visual organizer instance + */ +export function createVisualOrganizer(nodes: Node[], edges: Edge[]): VisualOrganizer { + return new VisualOrganizer(nodes, edges); +} diff --git a/src/lib/webLlmService.ts b/src/lib/webLlmService.ts index 5735b6d..bece909 100644 --- a/src/lib/webLlmService.ts +++ b/src/lib/webLlmService.ts @@ -1,116 +1,106 @@ -import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm"; -import type { InitProgressCallback } from "@mlc-ai/web-llm"; - -export type WebLlmProgress = { - progress: number; - text: string; - timeElapsed: number; -}; - -// Qwen3-0.6B is fast and works well with simple Mermaid generation prompts -const DEFAULT_MODEL = "Qwen3-0.6B-q4f32_1-MLC"; - -export class WebLlmService { - private engine: MLCEngine | null = null; - private isLoading = false; - private isReady = false; - - // Track GPU Availability - public static async isSystemSupported(): Promise { - // @ts-ignore - if (!navigator.gpu) { - console.warn('WebGPU not supported in this environment'); - return null; - } - try { - // @ts-ignore - const adapter = await navigator.gpu.requestAdapter(); - return !!adapter; - } catch (e) { - return false; - } - } - - async initialize(onProgress?: (progress: WebLlmProgress) => void): Promise { - if (this.engine || this.isLoading) return; - - this.isLoading = true; - - const initProgressCallback: InitProgressCallback = (report) => { - if (onProgress) { - // Parse the native report which is basically just text and percentage - // Example: "Loading model 10% [ cached ]" or "Fetching param shard 1/4" - const progressMatch = report.text.match(/(\d+)%/); - const progress = progressMatch ? parseInt(progressMatch[1], 10) : 0; - - onProgress({ - progress, - text: report.text, - timeElapsed: report.timeElapsed - }); - } - }; - - try { - console.log('Initializing WebLLM Engine...'); - this.engine = await CreateMLCEngine( - DEFAULT_MODEL, - { initProgressCallback } - ); - this.isReady = true; - console.log('WebLLM Engine Ready'); - } catch (error) { - console.error('Failed to initialize WebLLM:', error); - this.engine = null; - throw error; - } finally { - this.isLoading = false; - } - } - - async chat(messages: { role: 'system' | 'user' | 'assistant', content: string }[]): Promise> { - if (!this.engine || !this.isReady) { - throw new Error("WebLLM Engine not initialized. Please load the model first."); - } - - console.log('WebLLM: Creating completion...'); - const startTime = performance.now(); - const completion = await this.engine.chat.completions.create({ - messages, - stream: true, - temperature: 0, // Deterministic output for code - max_tokens: 512, // Mermaid code is compact - top_p: 0.9, // Faster sampling - repetition_penalty: 1.1, // Avoid repetitive output - }); - console.log('WebLLM: Completion created, streaming...'); - - // Create a generator to stream chunks easily - async function* streamGenerator() { - let tokenCount = 0; - for await (const chunk of completion) { - const content = chunk.choices[0]?.delta?.content || ""; - if (content) { - tokenCount++; - if (tokenCount === 1) console.log('WebLLM: First token received'); - yield content; - } - } - const endTime = performance.now(); - console.log(`WebLLM: Generation complete (${tokenCount} tokens, ${((endTime - startTime) / 1000).toFixed(1)}s)`); - } - - return streamGenerator(); - } - - getStatus(): { isReady: boolean; isLoading: boolean; model: string } { - return { - isReady: this.isReady, - isLoading: this.isLoading, - model: DEFAULT_MODEL - }; - } -} - -// Singleton instance -export const webLlmService = new WebLlmService(); +import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm"; +import type { InitProgressCallback } from "@mlc-ai/web-llm"; + +export type WebLlmProgress = { + progress: number; + text: string; + timeElapsed: number; +}; + +// Latest "Tiny" model with high instruction adherence +const DEFAULT_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC"; + +export class WebLlmService { + private engine: MLCEngine | null = null; + private isLoading = false; + private isReady = false; + + // Track GPU Availability + public static async isSystemSupported(): Promise { + // @ts-ignore + if (!navigator.gpu) { + console.warn('WebGPU not supported in this environment'); + return null; + } + try { + // @ts-ignore + const adapter = await navigator.gpu.requestAdapter(); + return !!adapter; + } catch (e) { + return false; + } + } + + async initialize(onProgress?: (progress: WebLlmProgress) => void): Promise { + if (this.engine || this.isLoading) return; + + this.isLoading = true; + + const initProgressCallback: InitProgressCallback = (report) => { + if (onProgress) { + // Parse the native report which is basically just text and percentage + // Example: "Loading model 10% [ cached ]" or "Fetching param shard 1/4" + const progressMatch = report.text.match(/(\d+)%/); + const progress = progressMatch ? parseInt(progressMatch[1], 10) : 0; + + onProgress({ + progress, + text: report.text, + timeElapsed: report.timeElapsed + }); + } + }; + + try { + console.log('Initializing WebLLM Engine...'); + this.engine = await CreateMLCEngine( + DEFAULT_MODEL, + { initProgressCallback } + ); + this.isReady = true; + console.log('WebLLM Engine Ready'); + } catch (error) { + console.error('Failed to initialize WebLLM:', error); + this.engine = null; + throw error; + } finally { + this.isLoading = false; + } + } + + async chat(messages: { role: 'system' | 'user' | 'assistant', content: string }[]): Promise> { + if (!this.engine || !this.isReady) { + throw new Error("WebLLM Engine not initialized. Please load the model first."); + } + + const completion = await this.engine.chat.completions.create({ + messages, + stream: true, + temperature: 0.1, // Low temp for code/logic generation + max_tokens: 4096, // Sufficient for diagrams + }); + + // Create a generator to stream chunks easily + async function* streamGenerator() { + for await (const chunk of completion) { + const content = chunk.choices[0]?.delta?.content || ""; + if (content) { + yield content; + } + } + } + + return streamGenerator(); + } + + getStatus(): { isReady: boolean; isLoading: boolean; model: string } { + return { + isReady: this.isReady, + isLoading: this.isLoading, + model: DEFAULT_MODEL + }; + } +} + +// Singleton instance +export const webLlmService = new WebLlmService(); diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index e352119..227d140 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -165,6 +165,42 @@ export function Editor() {
)} + + {/* Mobile Empty State - Big Get Started Prompt */} + {nodes.length === 0 && !isLoading && isMobile && !mobileEditorOpen && ( +
+
+ {/* Icon */} +
+ +
+ + {/* Title */} +

+ Create Your Diagram +

+ + {/* Description */} +

+ Describe your system in plain English, upload an image, or write Mermaid code. +

+ + {/* Get Started Button */} + + + {/* Hint */} +

+ Or tap the button anytime +

+
+
+ )} {/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */} diff --git a/src/pages/History.tsx b/src/pages/History.tsx index 8b09232..a8cbfb9 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -1,132 +1,132 @@ -import { useState, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - ArrowLeft, Search, Trash2, Activity, Clock, - ArrowRight, Zap, Filter -} from 'lucide-react'; -import { useFlowStore } from '../store'; - -export function History() { - const navigate = useNavigate(); - const { savedDiagrams, deleteDiagram } = useFlowStore(); - const [searchQuery, setSearchQuery] = useState(''); - - const filteredDiagrams = useMemo(() => { - return [...savedDiagrams] - .filter(d => d.name.toLowerCase().includes(searchQuery.toLowerCase())) - .reverse(); - }, [savedDiagrams, searchQuery]); - - const handleDelete = (e: React.MouseEvent, id: string) => { - e.stopPropagation(); - if (confirm('Are you sure you want to delete this intelligence draft?')) { - deleteDiagram(id); - } - }; - - return ( -
- {/* Ambient Background */} -
-
-
-
- - {/* Header */} -
-
- -
-
- -
-

Intelligence Archive

-
-
- -
-
- - setSearchQuery(e.target.value)} - placeholder="Find specific logic draft..." - className="w-full bg-void/30 border titanium-border rounded-2xl py-2.5 pl-12 pr-4 text-sm outline-none focus:border-blue-500/50 transition-all text-primary" - /> -
-
- - {filteredDiagrams.length} Results -
-
-
- - {/* Content List */} -
- {filteredDiagrams.length > 0 ? ( -
- {filteredDiagrams.map((diagram) => ( -
navigate(`/diagram?id=${diagram.id}`)} - className="group relative glass-panel rounded-3xl p-6 border titanium-border hover:border-blue-500/30 transition-all duration-300 cursor-pointer shadow-sm hover:shadow-2xl hover:-translate-y-1" - > -
-
- -
- -
- -
-

{diagram.name}

-
- - {new Date(diagram.createdAt).toLocaleDateString()} -
-
- -
- - {diagram.nodes.filter(n => n.type !== 'group').length} Entities - -
- Restore - -
-
-
- ))} -
- ) : ( -
-
- -
-

- {searchQuery ? 'No intelligence matching your query' : 'Your intelligence archive is empty'} -

-
- )} -
- - {/* Footer Statistics */} -
-

- Active Persistence Engine • v3.1.2 • Total Capacity: {savedDiagrams.length}/100 -

-
-
- ); -} +import { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ArrowLeft, Search, Trash2, Activity, Clock, + ArrowRight, Zap, Filter +} from 'lucide-react'; +import { useFlowStore } from '../store'; + +export function History() { + const navigate = useNavigate(); + const { savedDiagrams, deleteDiagram } = useFlowStore(); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredDiagrams = useMemo(() => { + return [...savedDiagrams] + .filter(d => d.name.toLowerCase().includes(searchQuery.toLowerCase())) + .reverse(); + }, [savedDiagrams, searchQuery]); + + const handleDelete = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + if (confirm('Are you sure you want to delete this intelligence draft?')) { + deleteDiagram(id); + } + }; + + return ( +
+ {/* Ambient Background */} +
+
+
+
+ + {/* Header */} +
+
+ +
+
+ +
+

Intelligence Archive

+
+
+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="Find specific logic draft..." + className="w-full bg-void/30 border titanium-border rounded-2xl py-2.5 pl-12 pr-4 text-sm outline-none focus:border-blue-500/50 transition-all text-primary" + /> +
+
+ + {filteredDiagrams.length} Results +
+
+
+ + {/* Content List */} +
+ {filteredDiagrams.length > 0 ? ( +
+ {filteredDiagrams.map((diagram) => ( +
navigate(`/diagram?id=${diagram.id}`)} + className="group relative glass-panel rounded-3xl p-6 border titanium-border hover:border-blue-500/30 transition-all duration-300 cursor-pointer shadow-sm hover:shadow-2xl hover:-translate-y-1" + > +
+
+ +
+ +
+ +
+

{diagram.name}

+
+ + {new Date(diagram.createdAt).toLocaleDateString()} +
+
+ +
+ + {diagram.nodes.filter(n => n.type !== 'group').length} Entities + +
+ Restore + +
+
+
+ ))} +
+ ) : ( +
+
+ +
+

+ {searchQuery ? 'No intelligence matching your query' : 'Your intelligence archive is empty'} +

+
+ )} +
+ + {/* Footer Statistics */} +
+

+ Active Persistence Engine • v3.1.2 • Total Capacity: {savedDiagrams.length}/100 +

+
+
+ ); +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 774bdc6..86783cb 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1,26 +1,26 @@ -// Basic types needed for plugins - - -export interface ToolbarItem { - id: string; - label: string; - icon: any; // Lucide icon component - onClick: () => void; - position?: 'left' | 'center' | 'right'; // Default 'right' (actions) - tooltip?: string; -} - -export interface PluginContext { - registerToolbarItem: (item: ToolbarItem) => void; - // Future extensions: - // registerSidebarTab: (tab: SidebarTab) => void; - // registerNodeAction: (action: NodeAction) => void; -} - -export interface Plugin { - id: string; - name: string; - version: string; - init: (context: PluginContext) => void; - cleanup?: () => void; -} +// Basic types needed for plugins + + +export interface ToolbarItem { + id: string; + label: string; + icon: any; // Lucide icon component + onClick: () => void; + position?: 'left' | 'center' | 'right'; // Default 'right' (actions) + tooltip?: string; +} + +export interface PluginContext { + registerToolbarItem: (item: ToolbarItem) => void; + // Future extensions: + // registerSidebarTab: (tab: SidebarTab) => void; + // registerNodeAction: (action: NodeAction) => void; +} + +export interface Plugin { + id: string; + name: string; + version: string; + init: (context: PluginContext) => void; + cleanup?: () => void; +} diff --git a/src/store/pluginStore.ts b/src/store/pluginStore.ts index 2a9cb50..348fe66 100644 --- a/src/store/pluginStore.ts +++ b/src/store/pluginStore.ts @@ -1,63 +1,63 @@ -import { create } from 'zustand'; -import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types'; - -interface PluginState { - plugins: Record; - toolbarItems: ToolbarItem[]; - - // Actions - registerPlugin: (plugin: Plugin) => void; - unregisterPlugin: (pluginId: string) => void; - - // Internal API for Plugins - _registerToolbarItem: (item: ToolbarItem) => void; -} - -export const usePluginStore = create((set, get) => ({ - plugins: {}, - toolbarItems: [], - - registerPlugin: (plugin) => { - if (get().plugins[plugin.id]) { - console.warn(`Plugin ${plugin.id} is already registered.`); - return; - } - - // Create Context - const context: PluginContext = { - registerToolbarItem: (item) => get()._registerToolbarItem(item), - }; - - // Init Plugin - try { - plugin.init(context); - set((state) => ({ - plugins: { ...state.plugins, [plugin.id]: plugin } - })); - console.log(`Plugin ${plugin.name} loaded.`); - } catch (e) { - console.error(`Failed to initialize plugin ${plugin.id}:`, e); - } - }, - - unregisterPlugin: (pluginId) => { - const plugin = get().plugins[pluginId]; - if (!plugin) return; - - if (plugin.cleanup) plugin.cleanup(); - - set((state) => { - const { [pluginId]: removed, ...rest } = state.plugins; - // Note: complex cleanup of UI items registered by this plugin would ideally happen here. - // For MVP, we might just clear all or need items to be tagged with pluginId. - // Improvement: ToolbarItem should have pluginId. - return { plugins: rest }; - }); - }, - - _registerToolbarItem: (item) => { - set((state) => ({ - toolbarItems: [...state.toolbarItems, item] - })); - } -})); +import { create } from 'zustand'; +import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types'; + +interface PluginState { + plugins: Record; + toolbarItems: ToolbarItem[]; + + // Actions + registerPlugin: (plugin: Plugin) => void; + unregisterPlugin: (pluginId: string) => void; + + // Internal API for Plugins + _registerToolbarItem: (item: ToolbarItem) => void; +} + +export const usePluginStore = create((set, get) => ({ + plugins: {}, + toolbarItems: [], + + registerPlugin: (plugin) => { + if (get().plugins[plugin.id]) { + console.warn(`Plugin ${plugin.id} is already registered.`); + return; + } + + // Create Context + const context: PluginContext = { + registerToolbarItem: (item) => get()._registerToolbarItem(item), + }; + + // Init Plugin + try { + plugin.init(context); + set((state) => ({ + plugins: { ...state.plugins, [plugin.id]: plugin } + })); + console.log(`Plugin ${plugin.name} loaded.`); + } catch (e) { + console.error(`Failed to initialize plugin ${plugin.id}:`, e); + } + }, + + unregisterPlugin: (pluginId) => { + const plugin = get().plugins[pluginId]; + if (!plugin) return; + + if (plugin.cleanup) plugin.cleanup(); + + set((state) => { + const { [pluginId]: removed, ...rest } = state.plugins; + // Note: complex cleanup of UI items registered by this plugin would ideally happen here. + // For MVP, we might just clear all or need items to be tagged with pluginId. + // Improvement: ToolbarItem should have pluginId. + return { plugins: rest }; + }); + }, + + _registerToolbarItem: (item) => { + set((state) => ({ + toolbarItems: [...state.toolbarItems, item] + })); + } +})); diff --git a/src/styles/ui.css b/src/styles/ui.css index 46f0533..2a477c1 100644 --- a/src/styles/ui.css +++ b/src/styles/ui.css @@ -416,4 +416,36 @@ .animate-fade-in { animation: fade-in 0.2s ease-out forwards; +} + +/* Slide in from right animation - for mobile menu */ +@keyframes slide-in-right { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-slide-in-right { + animation: slide-in-right 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* Slide out to right animation */ +@keyframes slide-out-right { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} + +.animate-slide-out-right { + animation: slide-out-right 0.25s ease-out forwards; } \ No newline at end of file diff --git a/src/test/setup.ts b/src/test/setup.ts index 6cb59dd..f139a48 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,42 +1,42 @@ -import '@testing-library/jest-dom'; -import * as matchers from '@testing-library/jest-dom/matchers'; -import { expect } from 'vitest'; - -expect.extend(matchers); - -// Mock localStorage -const localStorageMock = (function () { - let store: Record = {}; - return { - getItem: function (key: string) { - return store[key] || null; - }, - setItem: function (key: string, value: string) { - store[key] = value.toString(); - }, - clear: function () { - store = {}; - }, - removeItem: function (key: string) { - delete store[key]; - } - }; -})(); - -Object.defineProperty(window, 'localStorage', { - value: localStorageMock -}); - -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => { }, - removeListener: () => { }, - addEventListener: () => { }, - removeEventListener: () => { }, - dispatchEvent: () => false, - }), -}); +import '@testing-library/jest-dom'; +import * as matchers from '@testing-library/jest-dom/matchers'; +import { expect } from 'vitest'; + +expect.extend(matchers); + +// Mock localStorage +const localStorageMock = (function () { + let store: Record = {}; + return { + getItem: function (key: string) { + return store[key] || null; + }, + setItem: function (key: string, value: string) { + store[key] = value.toString(); + }, + clear: function () { + store = {}; + }, + removeItem: function (key: string) { + delete store[key]; + } + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => { }, + removeListener: () => { }, + addEventListener: () => { }, + removeEventListener: () => { }, + dispatchEvent: () => false, + }), +}); diff --git a/src/types/visualOrganization.ts b/src/types/visualOrganization.ts index 525b14a..402bdc5 100644 --- a/src/types/visualOrganization.ts +++ b/src/types/visualOrganization.ts @@ -1,105 +1,105 @@ -export interface NodePosition { - x: number; - y: number; - width?: number; - height?: number; -} - -export interface EdgeConnection { - source: string; - target: string; - type?: string; -} - -export interface LayoutMetrics { - nodeCount: number; - edgeCount: number; - edgeCrossings: number; - nodeDensity: number; - averageNodeSpacing: number; - visualComplexity: number; // 0-100 scale - aspectRatio: number; -} - -export interface VisualIssue { - type: 'edge-crossing' | 'overlap' | 'poor-spacing' | 'unclear-flow' | 'inefficient-layout' | 'style-consistency'; - severity: 'low' | 'medium' | 'high'; - description: string; - affectedNodes?: string[]; - affectedEdges?: string[]; - suggestedFix?: string; -} - -export interface LayoutSuggestion { - id: string; - title: string; - description: string; - type: 'spacing' | 'grouping' | 'routing' | 'hierarchy' | 'style' | 'node-shape' | 'node-color-semantic'; - impact: 'low' | 'medium' | 'high'; - estimatedImprovement: number; // percentage - beforeState: { - metrics: LayoutMetrics; - issues: VisualIssue[]; - }; - afterState: { - metrics: LayoutMetrics; - estimatedIssues: VisualIssue[]; - }; - implementation: { - nodePositions: Record; - edgeRouting?: Record; - styleChanges?: Record; - description?: string; - }; -} - -export interface VisualOrganizationRequest { - nodes: Array<{ - id: string; - label: string; - type: string; - position: NodePosition; - metadata?: any; - }>; - edges: EdgeConnection[]; - currentLayout?: { - direction: 'TB' | 'LR' | 'BT' | 'RL'; - spacing: number; - }; - preferences?: { - priority: 'readability' | 'compactness' | 'flow-clarity'; - groupSimilarNodes: boolean; - minimizeCrossings: boolean; - preserveUserLayout: boolean; - }; -} - -export interface VisualOrganizationResponse { - success: boolean; - suggestions: LayoutSuggestion[]; - summary: { - totalIssues: number; - criticalIssues: number; - potentialImprovement: number; - recommendedAction: string; - }; - error?: string; -} - -export interface VisualAnalysisResult { - metrics: LayoutMetrics; - issues: VisualIssue[]; - strengths: string[]; - recommendations: string[]; -} - -export interface AISuggestionPrompt { - systemPrompt: string; - userPrompt: string; - context: { - currentMetrics: LayoutMetrics; - identifiedIssues: VisualIssue[]; - nodeTypes: string[]; - edgeTypes: string[]; - }; -} +export interface NodePosition { + x: number; + y: number; + width?: number; + height?: number; +} + +export interface EdgeConnection { + source: string; + target: string; + type?: string; +} + +export interface LayoutMetrics { + nodeCount: number; + edgeCount: number; + edgeCrossings: number; + nodeDensity: number; + averageNodeSpacing: number; + visualComplexity: number; // 0-100 scale + aspectRatio: number; +} + +export interface VisualIssue { + type: 'edge-crossing' | 'overlap' | 'poor-spacing' | 'unclear-flow' | 'inefficient-layout' | 'style-consistency'; + severity: 'low' | 'medium' | 'high'; + description: string; + affectedNodes?: string[]; + affectedEdges?: string[]; + suggestedFix?: string; +} + +export interface LayoutSuggestion { + id: string; + title: string; + description: string; + type: 'spacing' | 'grouping' | 'routing' | 'hierarchy' | 'style' | 'node-shape' | 'node-color-semantic'; + impact: 'low' | 'medium' | 'high'; + estimatedImprovement: number; // percentage + beforeState: { + metrics: LayoutMetrics; + issues: VisualIssue[]; + }; + afterState: { + metrics: LayoutMetrics; + estimatedIssues: VisualIssue[]; + }; + implementation: { + nodePositions: Record; + edgeRouting?: Record; + styleChanges?: Record; + description?: string; + }; +} + +export interface VisualOrganizationRequest { + nodes: Array<{ + id: string; + label: string; + type: string; + position: NodePosition; + metadata?: any; + }>; + edges: EdgeConnection[]; + currentLayout?: { + direction: 'TB' | 'LR' | 'BT' | 'RL'; + spacing: number; + }; + preferences?: { + priority: 'readability' | 'compactness' | 'flow-clarity'; + groupSimilarNodes: boolean; + minimizeCrossings: boolean; + preserveUserLayout: boolean; + }; +} + +export interface VisualOrganizationResponse { + success: boolean; + suggestions: LayoutSuggestion[]; + summary: { + totalIssues: number; + criticalIssues: number; + potentialImprovement: number; + recommendedAction: string; + }; + error?: string; +} + +export interface VisualAnalysisResult { + metrics: LayoutMetrics; + issues: VisualIssue[]; + strengths: string[]; + recommendations: string[]; +} + +export interface AISuggestionPrompt { + systemPrompt: string; + userPrompt: string; + context: { + currentMetrics: LayoutMetrics; + identifiedIssues: VisualIssue[]; + nodeTypes: string[]; + edgeTypes: string[]; + }; +} diff --git a/todo.md b/todo.md deleted file mode 100644 index 15f794f..0000000 --- a/todo.md +++ /dev/null @@ -1,50 +0,0 @@ -# AI Visual Organization Agent Implementation - -## Phase 1: Enhanced AI Service Extension -- [x] Create visual organization types and interfaces -- [x] Add visual analysis AI methods to aiService.ts -- [x] Create specialized prompts for layout suggestions -- [x] Implement visual complexity scoring - -## Phase 2: Visual Organization Engine -- [x] Create visualOrganizer.ts module -- [x] Implement layout analysis algorithms -- [x] Add edge crossing detection and optimization -- [x] Create node grouping and clustering logic -- [x] Integrate with existing layout engine - -## Phase 3: UI Integration -- [x] Add Smart Layout panel to FlowCanvas (via NodeDetailsPanel) -- [x] Create suggestion cards component -- [x] Implement before/after preview functionality -- [x] Add AI organization control button -- [x] Create suggestion acceptance workflow - -## Phase 4: Advanced Features -- [x] Multi-layout comparison system -- [x] Auto-organization presets by diagram type -- [x] Visual hierarchy optimization -- [x] Style consistency recommendations - -## Phase 5: Smart Node Optimization (New) -- [x] AI Node Content Analysis (Shape/Icon/Label) -- [x] Intelligent Color Coding -- [x] Design System Refinement - -## Phase 6: Critical Functionality Fixes -- [x] Enable 'Save Draft' button -- [x] Enable 'Export' button - -## Phase 7: UI Redesign -- [x] Redesign Left Sidebar (Tabs, Editor, Bottom Actions) -- [x] Redesign Canvas Controls (Vertical Segmented Toolbar) -- [x] Implement Multi-Node Selection Mode (Pan vs Select toggle) -- [x] Redesign Sidebars (Mood & Tone Unification) -- [x] Refine Light Theme (Contrast and Visibility Fixes) -- [x] Theme Audit (Unify EditorHeader, Buttons, Cards) - -## Testing & Polish -- [x] Test visual organization suggestions -- [x] Validate AI integration -- [x] Performance optimization -- [x] UI/UX refinements diff --git a/vitest.config.ts b/vitest.config.ts index 9a2771e..8fafeb8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,11 @@ -/// -import { defineConfig } from 'vite'; - -export default defineConfig({ - test: { - globals: true, - environment: 'jsdom', - setupFiles: './src/test/setup.ts', - css: true, - }, -}); +/// +import { defineConfig } from 'vite'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + css: true, + }, +});