Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Interactive Diagram Tool with AI</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<link rel="preconnect" href="https://fonts.googleapis.com"> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
<style> | |
body { | |
font-family: 'Inter', sans-serif; | |
overscroll-behavior: none; | |
} | |
.shape { | |
cursor: move; | |
user-select: none; | |
transition: transform 0.5s cubic-bezier(0.25, 1, 0.5, 1); /* Smooth animation for position changes */ | |
} | |
.shape-text { | |
pointer-events: none; | |
user-select: none; | |
font-size: 14px; | |
font-weight: 500; | |
} | |
#canvas-svg { | |
touch-action: none; | |
} | |
.connector-line { | |
stroke-width: 2.5; | |
stroke-linecap: round; | |
} | |
.highlight-connector { | |
stroke: #fbbf24; /* amber-400 */ | |
stroke-width: 4; | |
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); | |
} | |
/* Modal styles */ | |
#ai-modal { | |
transition: opacity 0.3s ease-in-out; | |
} | |
#ai-modal.hidden { | |
opacity: 0; | |
pointer-events: none; | |
} | |
.spinner { | |
border: 4px solid rgba(0, 0, 0, 0.1); | |
width: 36px; | |
height: 36px; | |
border-radius: 50%; | |
border-left-color: #4f46e5; /* indigo-600 */ | |
animation: spin 1s ease-in-out infinite; | |
} | |
@keyframes spin { | |
0% { transform: rotate(0deg); } | |
100% { transform: rotate(360deg); } | |
} | |
</style> | |
</head> | |
<body class="bg-gray-100 flex flex-col h-screen antialiased"> | |
<!-- Header --> | |
<header class="bg-white shadow-md p-4 z-20"> | |
<div class="container mx-auto flex justify-between items-center"> | |
<h1 class="text-xl font-bold text-gray-800">Diagram Tool with AI</h1> | |
</div> | |
</header> | |
<!-- Main Content --> | |
<div class="flex flex-1 overflow-hidden"> | |
<!-- Toolbar --> | |
<aside class="w-64 bg-white p-4 space-y-4 shadow-lg overflow-y-auto z-10"> | |
<h2 class="text-lg font-semibold text-gray-700 border-b pb-2">Tools</h2> | |
<div class="space-y-2"> | |
<button id="add-rect" class="w-full bg-sky-500 hover:bg-sky-600 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center space-x-2"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-square" viewBox="0 0 16 16"><path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/></svg> | |
<span>Rectangle</span> | |
</button> | |
<button id="add-circle" class="w-full bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center space-x-2"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-circle" viewBox="0 0 16 16"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/></svg> | |
<span>Circle</span> | |
</button> | |
<button id="add-diamond" class="w-full bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center space-x-2"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-diamond-fill" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M6.95.435c.58-.58 1.52-.58 2.1 0l6.515 6.516c.58.58.58 1.519 0 2.098L9.05 15.565c-.58.58-1.519.58-2.1 0L.435 9.05c-.58-.58-.58-1.519 0-2.098L6.95.435z"/></svg> | |
<span>Diamond</span> | |
</button> | |
</div> | |
<div class="border-t pt-4 space-y-2"> | |
<button id="connect-btn" class="w-full bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200">Connect Shapes</button> | |
<button id="clear-btn" class="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200">Clear Canvas</button> | |
</div> | |
<div class="border-t pt-4 space-y-2"> | |
<button id="ai-magic-btn" class="w-full bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-bold py-2 px-4 rounded-lg transition-all duration-200 flex items-center justify-center space-x-2"> | |
<span>✨</span> | |
<span>Auto-Arrange & Explain</span> | |
</button> | |
</div> | |
<div id="status-box" class="mt-4 p-3 bg-gray-100 rounded-lg text-sm text-gray-700 text-center"> | |
Add a shape to get started. | |
</div> | |
</aside> | |
<!-- Canvas Area --> | |
<main class="flex-1 bg-gray-200 p-4"> | |
<div id="canvas-container" class="w-full h-full bg-white rounded-lg shadow-inner overflow-hidden relative"> | |
<svg id="canvas-svg" class="w-full h-full"></svg> | |
</div> | |
</main> | |
</div> | |
<!-- AI Modal --> | |
<div id="ai-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex items-center justify-center p-4 z-50 hidden"> | |
<div id="ai-modal-content" class="bg-white rounded-xl shadow-2xl p-6 w-full max-w-2xl transform transition-all"> | |
<!-- Loading State --> | |
<div id="ai-loading" class="text-center"> | |
<div class="spinner mx-auto mb-4"></div> | |
<h3 class="text-lg font-semibold text-gray-800">AI is thinking...</h3> | |
<p class="text-gray-600">Analyzing your diagram to find the best layout.</p> | |
</div> | |
<!-- Result State --> | |
<div id="ai-result" class="hidden"> | |
<h3 class="text-2xl font-bold text-gray-800 mb-4">✨ Diagram Explanation</h3> | |
<div id="ai-explanation" class="prose prose-sm max-w-none text-gray-700 bg-gray-50 p-4 rounded-lg"></div> | |
<button id="close-modal-btn" class="mt-6 w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-200">Close</button> | |
</div> | |
</div> | |
</div> | |
<script> | |
// --- DOM Element References --- | |
const canvasContainer = document.getElementById('canvas-container'); | |
const svg = document.getElementById('canvas-svg'); | |
const addRectBtn = document.getElementById('add-rect'); | |
const addCircleBtn = document.getElementById('add-circle'); | |
const addDiamondBtn = document.getElementById('add-diamond'); | |
const connectBtn = document.getElementById('connect-btn'); | |
const clearBtn = document.getElementById('clear-btn'); | |
const statusBox = document.getElementById('status-box'); | |
const aiMagicBtn = document.getElementById('ai-magic-btn'); | |
const aiModal = document.getElementById('ai-modal'); | |
const aiModalContent = document.getElementById('ai-modal-content'); | |
const aiLoading = document.getElementById('ai-loading'); | |
const aiResult = document.getElementById('ai-result'); | |
const aiExplanation = document.getElementById('ai-explanation'); | |
const closeModalBtn = document.getElementById('close-modal-btn'); | |
// --- State Management --- | |
let shapes = []; | |
let connectors = []; | |
let isDragging = false; | |
let isConnecting = false; | |
let draggedShape = null; | |
let connectionStartShape = null; | |
let offset = { x: 0, y: 0 }; | |
// --- Core Functions --- | |
function render() { | |
svg.innerHTML = ''; | |
const connectorsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); | |
connectors.forEach(conn => { | |
const fromShape = shapes.find(s => s.id === conn.from); | |
const toShape = shapes.find(s => s.id === conn.to); | |
if (fromShape && toShape) { | |
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); | |
line.setAttribute('x1', fromShape.x); | |
line.setAttribute('y1', fromShape.y); | |
line.setAttribute('x2', toShape.x); | |
line.setAttribute('y2', toShape.y); | |
line.setAttribute('class', 'connector-line stroke-gray-500'); | |
connectorsGroup.appendChild(line); | |
} | |
}); | |
svg.appendChild(connectorsGroup); | |
const shapesGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); | |
shapes.forEach(shape => { | |
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); | |
group.setAttribute('class', 'shape'); | |
group.setAttribute('data-id', shape.id); | |
group.setAttribute('transform', `translate(${shape.x}, ${shape.y})`); | |
let element; | |
let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | |
textElement.setAttribute('class', 'shape-text fill-white'); | |
textElement.setAttribute('text-anchor', 'middle'); | |
textElement.setAttribute('dominant-baseline', 'central'); | |
switch (shape.type) { | |
case 'rect': | |
element = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); | |
element.setAttribute('x', -shape.width / 2); | |
element.setAttribute('y', -shape.height / 2); | |
element.setAttribute('width', shape.width); | |
element.setAttribute('height', shape.height); | |
element.setAttribute('class', 'fill-sky-500'); | |
element.setAttribute('rx', 8); | |
textElement.textContent = 'Process'; | |
break; | |
case 'circle': | |
element = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); | |
element.setAttribute('r', shape.radius); | |
element.setAttribute('class', 'fill-emerald-500'); | |
textElement.textContent = 'Start/End'; | |
break; | |
case 'diamond': | |
element = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); | |
const points = `0,${-shape.height / 2} ${shape.width / 2},0 0,${shape.height / 2} ${-shape.width / 2},0`; | |
element.setAttribute('points', points); | |
element.setAttribute('class', 'fill-indigo-500'); | |
textElement.textContent = 'Decision'; | |
break; | |
} | |
if (connectionStartShape && connectionStartShape.id === shape.id) { | |
element.classList.add('highlight-connector'); | |
} | |
group.appendChild(element); | |
group.appendChild(textElement); | |
shapesGroup.appendChild(group); | |
}); | |
svg.appendChild(shapesGroup); | |
} | |
function addShape(type) { | |
const canvasRect = canvasContainer.getBoundingClientRect(); | |
const newShape = { | |
id: `shape-${crypto.randomUUID().substring(0, 8)}`, | |
type: type, | |
x: canvasRect.width / 2 + (Math.random() - 0.5) * 100, | |
y: canvasRect.height / 2 + (Math.random() - 0.5) * 100, | |
width: 120, | |
height: 60, | |
radius: 40, | |
}; | |
shapes.push(newShape); | |
updateStatus('Shape added. Drag to position.'); | |
render(); | |
} | |
function getMousePosition(evt) { | |
const CTM = svg.getScreenCTM(); | |
const clientX = evt.touches ? evt.touches[0].clientX : evt.clientX; | |
const clientY = evt.touches ? evt.touches[0].clientY : evt.clientY; | |
return { | |
x: (clientX - CTM.e) / CTM.a, | |
y: (clientY - CTM.f) / CTM.d | |
}; | |
} | |
function updateStatus(message) { | |
statusBox.textContent = message; | |
} | |
// --- Event Handlers --- | |
function handlePointerDown(evt) { | |
evt.preventDefault(); | |
const target = evt.target.closest('.shape'); | |
if (!target) return; | |
const shapeId = target.getAttribute('data-id'); | |
const shape = shapes.find(s => s.id === shapeId); | |
if (isConnecting) { | |
if (!connectionStartShape) { | |
connectionStartShape = shape; | |
updateStatus('Selected start shape. Click another to connect.'); | |
} else { | |
if (connectionStartShape.id !== shape.id) { | |
connectors.push({ from: connectionStartShape.id, to: shape.id }); | |
updateStatus('Shapes connected!'); | |
} else { | |
updateStatus('Cannot connect a shape to itself.'); | |
} | |
connectionStartShape = null; | |
isConnecting = false; | |
connectBtn.classList.remove('bg-amber-500'); | |
connectBtn.classList.add('bg-gray-600'); | |
} | |
} else { | |
isDragging = true; | |
draggedShape = shape; | |
const mousePos = getMousePosition(evt); | |
offset.x = mousePos.x - draggedShape.x; | |
offset.y = mousePos.y - draggedShape.y; | |
updateStatus('Dragging shape...'); | |
} | |
render(); | |
} | |
function handlePointerMove(evt) { | |
if (!isDragging || !draggedShape) return; | |
evt.preventDefault(); | |
const mousePos = getMousePosition(evt); | |
draggedShape.x = mousePos.x - offset.x; | |
draggedShape.y = mousePos.y - offset.y; | |
render(); | |
} | |
function handlePointerUp(evt) { | |
if (isDragging) { | |
updateStatus('Drag shape or add another.'); | |
} | |
isDragging = false; | |
draggedShape = null; | |
} | |
// --- Gemini API Integration --- | |
async function callGeminiForLayout() { | |
if (shapes.length < 2) { | |
updateStatus("Add at least two shapes to use the AI feature."); | |
return; | |
} | |
// Show loading modal | |
aiModal.classList.remove('hidden'); | |
aiLoading.classList.remove('hidden'); | |
aiResult.classList.add('hidden'); | |
const canvasRect = canvasContainer.getBoundingClientRect(); | |
// 1. Serialize the diagram data | |
const diagramData = { | |
nodes: shapes.map(s => ({ id: s.id, type: s.type, label: s.type === 'rect' ? 'Process' : (s.type === 'circle' ? 'Start/End' : 'Decision') })), | |
edges: connectors.map(c => ({ from: c.from, to: c.to })), | |
canvas: { width: canvasRect.width, height: canvasRect.height } | |
}; | |
// 2. Create the prompt for the Gemini API | |
const prompt = ` | |
You are an expert diagramming assistant. Your task is to analyze a given diagram structure and provide an optimal layout and a clear explanation. | |
The diagram is represented by a JSON object with nodes and edges. | |
- "nodes" contains a list of shapes with their ID and type. | |
- "edges" defines the connections between nodes using their IDs. | |
- "canvas" provides the dimensions of the drawing area. | |
Based on the following diagram data, please return a JSON object that contains: | |
1. A "layout" object where keys are the node IDs and values are objects with optimal 'x' and 'y' coordinates for a clean, top-to-bottom flowchart layout. The coordinates should be within the canvas dimensions. | |
2. A "explanation" string that describes the process flow of the diagram in a clear, step-by-step manner. | |
Diagram Data: | |
${JSON.stringify(diagramData, null, 2)} | |
`; | |
// 3. Define the expected JSON schema for the response | |
const schema = { | |
type: "OBJECT", | |
properties: { | |
"layout": { | |
type: "OBJECT", | |
"description": "An object where keys are node IDs and values are objects with x and y coordinates.", | |
}, | |
"explanation": { | |
type: "STRING", | |
"description": "A step-by-step explanation of the diagram's flow." | |
} | |
}, | |
required: ["layout", "explanation"] | |
}; | |
try { | |
// 4. Call the Gemini API | |
let chatHistory = [{ role: "user", parts: [{ text: prompt }] }]; | |
const payload = { | |
contents: chatHistory, | |
generationConfig: { | |
responseMimeType: "application/json", | |
responseSchema: schema, | |
} | |
}; | |
const apiKey = ""; // Leave empty, will be handled by the environment | |
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`; | |
const response = await fetch(apiUrl, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(payload) | |
}); | |
if (!response.ok) { | |
throw new Error(`API call failed with status: ${response.status}`); | |
} | |
const result = await response.json(); | |
if (result.candidates && result.candidates.length > 0 && | |
result.candidates[0].content && result.candidates[0].content.parts && | |
result.candidates[0].content.parts.length > 0) { | |
const jsonText = result.candidates[0].content.parts[0].text; | |
const parsedJson = JSON.parse(jsonText); | |
// 5. Apply the AI suggestions | |
applyAILayout(parsedJson.layout); | |
displayAIExplanation(parsedJson.explanation); | |
} else { | |
throw new Error("Invalid response structure from API."); | |
} | |
} catch (error) { | |
console.error("Error calling Gemini API:", error); | |
updateStatus("Error: Could not get AI suggestions."); | |
aiModal.classList.add('hidden'); // Hide modal on error | |
} | |
} | |
function applyAILayout(layout) { | |
shapes.forEach(shape => { | |
if (layout[shape.id]) { | |
shape.x = layout[shape.id].x; | |
shape.y = layout[shape.id].y; | |
} | |
}); | |
render(); | |
} | |
function displayAIExplanation(explanation) { | |
aiExplanation.innerHTML = explanation.replace(/\n/g, '<br>'); // Simple formatting | |
aiLoading.classList.add('hidden'); | |
aiResult.classList.remove('hidden'); | |
} | |
// --- Event Listeners --- | |
addRectBtn.addEventListener('click', () => addShape('rect')); | |
addCircleBtn.addEventListener('click', () => addShape('circle')); | |
addDiamondBtn.addEventListener('click', () => addShape('diamond')); | |
connectBtn.addEventListener('click', () => { | |
isConnecting = !isConnecting; | |
if (isConnecting) { | |
connectionStartShape = null; | |
connectBtn.classList.remove('bg-gray-600'); | |
connectBtn.classList.add('bg-amber-500'); | |
updateStatus('CONNECT MODE: Click a shape to start.'); | |
} else { | |
connectionStartShape = null; | |
connectBtn.classList.remove('bg-amber-500'); | |
connectBtn.classList.add('bg-gray-600'); | |
updateStatus('Connect mode disabled.'); | |
} | |
render(); | |
}); | |
clearBtn.addEventListener('click', () => { | |
shapes = []; | |
connectors = []; | |
isConnecting = false; | |
connectionStartShape = null; | |
connectBtn.classList.remove('bg-amber-500'); | |
connectBtn.classList.add('bg-gray-600'); | |
updateStatus('Canvas cleared. Add a new shape.'); | |
render(); | |
}); | |
aiMagicBtn.addEventListener('click', callGeminiForLayout); | |
closeModalBtn.addEventListener('click', () => { | |
aiModal.classList.add('hidden'); | |
}); | |
// Close modal if clicking outside the content | |
aiModal.addEventListener('click', (e) => { | |
if (e.target === aiModal) { | |
aiModal.classList.add('hidden'); | |
} | |
}); | |
svg.addEventListener('mousedown', handlePointerDown); | |
svg.addEventListener('mousemove', handlePointerMove); | |
svg.addEventListener('mouseup', handlePointerUp); | |
svg.addEventListener('mouseleave', handlePointerUp); | |
svg.addEventListener('touchstart', handlePointerDown, { passive: false }); | |
svg.addEventListener('touchmove', handlePointerMove, { passive: false }); | |
svg.addEventListener('touchend', handlePointerUp); | |
svg.addEventListener('touchcancel', handlePointerUp); | |
// Initial render | |
render(); | |
</script> | |
</body> | |
</html> | |