357 lines
16 KiB
HTML
357 lines
16 KiB
HTML
{% extends 'base.html' %}
|
|
{% block content %}
|
|
<div class="container-fluid d-flex flex-column pt-4" style="height: 100vh; max-height: 100vh; overflow: hidden;">
|
|
<div class="row mb-3 flex-shrink-0">
|
|
<div class="col-md-8">
|
|
<h1 class="mb-0">{{ project.title }}</h1>
|
|
<p class="text-muted mb-0">{{ project.industry }} | <strong>Goal:</strong> {{ project.goal }}</p>
|
|
</div>
|
|
<div class="col-md-4 text-end">
|
|
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary">Back to Projects</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row flex-grow-1 pb-3" style="min-height: 0;">
|
|
<div class="col-12 h-100">
|
|
<div class="card shadow-sm h-100 w-100 d-flex flex-column" style="border-radius: 12px; overflow: hidden; border: 1px solid rgba(0,0,0,0.08);">
|
|
<div class="card-header bg-white d-flex justify-content-between align-items-center flex-shrink-0" style="border-bottom: 1px solid rgba(0,0,0,0.05);">
|
|
<h5 class="mb-0 fw-bold">Interactive Mind Map</h5>
|
|
<div>
|
|
<button class="btn btn-sm btn-outline-success me-2" onclick="exportMap()">Export to Image</button>
|
|
<form method="POST" action="{% url 'regenerate_mindmap' project.pk %}" class="d-inline" onsubmit="return confirm('Are you sure you want to regenerate the entire map? This will delete all current nodes.');">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-sm btn-outline-danger me-2">Regenerate</button>
|
|
</form>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="fitNetwork()">Reset View</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-body p-0 position-relative flex-grow-1" style="min-height: 0; overflow: hidden; background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);">
|
|
{% if not nodes %}
|
|
<div class="position-absolute w-100 h-100 d-flex flex-column justify-content-center align-items-center" style="z-index: 10; background: rgba(255,255,255,0.9);">
|
|
<h4 class="text-muted mb-3">The mind map is empty</h4>
|
|
<p class="text-muted mb-4">Something went wrong during generation, or the map was cleared.</p>
|
|
<form method="POST" action="{% url 'regenerate_mindmap' project.pk %}">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-primary px-4 py-2 shadow-sm rounded-pill">
|
|
Generate Map Now
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- The vis-network container -->
|
|
<div id="mynetwork" style="width: 100%; height: 100%;"></div>
|
|
|
|
<!-- Floating Panels in Top Right -->
|
|
<div class="position-absolute d-flex flex-column" style="top: 15px; right: 15px; width: 350px; z-index: 1000; bottom: 15px; pointer-events: none;">
|
|
|
|
<!-- Node Details Panel -->
|
|
<div class="card shadow mb-3" style="pointer-events: auto; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.4); border-radius: 12px; background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);">
|
|
<div class="card-header py-2" style="background: transparent; border-bottom: 1px solid rgba(0,0,0,0.05); border-radius: 12px 12px 0 0;">
|
|
<h6 class="mb-0 fw-bold text-primary">Node Details</h6>
|
|
</div>
|
|
<div class="card-body py-2" id="node-details" style="max-height: 200px; overflow-y: auto;">
|
|
<p class="text-muted small mb-0">Click on a node or edge in the map to see its details here.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AI Chat Panel -->
|
|
<div class="card shadow d-flex flex-column" style="pointer-events: auto; flex: 1; min-height: 0; border: 1px solid rgba(255,255,255,0.4); border-radius: 12px; background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);">
|
|
<div class="card-header py-2" style="background: transparent; border-bottom: 1px solid rgba(0,0,0,0.05); border-radius: 12px 12px 0 0;">
|
|
<h6 class="mb-0 fw-bold text-success">Ask AI Assistant</h6>
|
|
</div>
|
|
<div class="card-body d-flex flex-column p-2" style="min-height: 0;">
|
|
<div id="ai-response" class="p-2 rounded mb-2 flex-grow-1" style="overflow-y: auto; font-size: 0.9rem; background: rgba(255,255,255,0.6); box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);">
|
|
<span class="text-muted">Hello! I'm your AI business strategist. Ask me to expand on a node, brainstorm new ideas, or analyze the map. If you ask me to add nodes to the map, I will!</span>
|
|
</div>
|
|
<div class="mt-auto flex-shrink-0">
|
|
<textarea id="ai-input" class="form-control form-control-sm mb-2" rows="2" style="resize: none; border-radius: 8px; background: rgba(255,255,255,0.9);" placeholder="e.g. Add 3 more nodes related to Marketing..."></textarea>
|
|
<button id="ai-submit" class="btn btn-primary btn-sm w-100 rounded-pill shadow-sm">Send to AI</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load Vis Network library -->
|
|
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
|
|
|
|
<!-- Add custom styles for vis-network tooltip -->
|
|
<style type="text/css">
|
|
.vis-tooltip {
|
|
position: absolute;
|
|
background-color: rgba(255, 255, 255, 0.95);
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
max-width: 320px;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
}
|
|
|
|
/* Optional: custom scrollbar for details and chat */
|
|
::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: rgba(0,0,0,0.15);
|
|
border-radius: 3px;
|
|
}
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(0,0,0,0.25);
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// Colors for different categories
|
|
const categoryColors = {
|
|
'Strategy': '#e1bee7', // Purple
|
|
'Product': '#bbdefb', // Blue
|
|
'Market': '#c8e6c9', // Green
|
|
'Operations': '#ffcc80', // Orange
|
|
'Finance': '#ffcdd2', // Red
|
|
'General': '#f5f5f5' // Grey
|
|
};
|
|
|
|
function getNodeColor(category) {
|
|
let cat = category || 'General';
|
|
let color = '#e0e0e0'; // Default light grey
|
|
for (let key in categoryColors) {
|
|
if (cat.toLowerCase().includes(key.toLowerCase())) {
|
|
color = categoryColors[key];
|
|
break;
|
|
}
|
|
}
|
|
return {
|
|
background: color,
|
|
border: '#9e9e9e',
|
|
highlight: { background: '#ffffff', border: '#42a5f5' }
|
|
};
|
|
}
|
|
|
|
// Prepare data from Django context
|
|
const nodesArray = [
|
|
{% for node in nodes %}
|
|
{
|
|
id: {{ node.pk }},
|
|
label: "{{ node.title|escapejs }}",
|
|
title: "<strong>{{ node.title|escapejs }}</strong><br>{{ node.summary|escapejs }}<br><em>Category: {{ node.category|escapejs }}</em>",
|
|
summary: "{{ node.summary|escapejs }}",
|
|
category: "{{ node.category|escapejs }}",
|
|
color: getNodeColor("{{ node.category|escapejs }}"),
|
|
shape: 'box',
|
|
font: { size: 16, face: 'Arial', color: '#333' },
|
|
margin: 10
|
|
},
|
|
{% endfor %}
|
|
];
|
|
|
|
const edgesArray = [
|
|
{% for conn in connections %}
|
|
{
|
|
from: {{ conn.source.pk }},
|
|
to: {{ conn.target.pk }},
|
|
label: "{{ conn.how|escapejs }}",
|
|
title: "<strong>Why:</strong> {{ conn.why|escapejs }}",
|
|
arrows: "to",
|
|
why: "{{ conn.why|escapejs }}",
|
|
how: "{{ conn.how|escapejs }}"
|
|
},
|
|
{% endfor %}
|
|
];
|
|
|
|
const container = document.getElementById("mynetwork");
|
|
const data = {
|
|
nodes: new vis.DataSet(nodesArray),
|
|
edges: new vis.DataSet(edgesArray)
|
|
};
|
|
|
|
const options = {
|
|
layout: {
|
|
improvedLayout: true
|
|
},
|
|
physics: {
|
|
solver: 'forceAtlas2Based',
|
|
forceAtlas2Based: {
|
|
gravitationalConstant: -100,
|
|
centralGravity: 0.01,
|
|
springLength: 150,
|
|
springConstant: 0.08
|
|
},
|
|
maxVelocity: 50,
|
|
minVelocity: 0.1,
|
|
timestep: 0.5,
|
|
stabilization: { iterations: 150 }
|
|
},
|
|
interaction: {
|
|
tooltipDelay: 200,
|
|
hover: true
|
|
},
|
|
edges: {
|
|
font: { size: 12, align: 'top' },
|
|
color: { color: '#848484', highlight: '#42a5f5' },
|
|
smooth: { type: 'continuous' }
|
|
}
|
|
};
|
|
|
|
const network = new vis.Network(container, data, options);
|
|
|
|
function fitNetwork() {
|
|
network.fit({ animation: { duration: 1000, easingFunction: 'easeInOutQuad' } });
|
|
}
|
|
|
|
// Export Map function
|
|
function exportMap() {
|
|
const canvas = document.querySelector('canvas');
|
|
if (!canvas) return;
|
|
|
|
const image = canvas.toDataURL("image/png");
|
|
const link = document.createElement('a');
|
|
link.download = 'mindmap.png';
|
|
link.href = image;
|
|
link.click();
|
|
}
|
|
|
|
// Handle clicks to show details in the sidebar
|
|
network.on("click", function (params) {
|
|
const detailsPanel = document.getElementById('node-details');
|
|
|
|
if (params.nodes.length > 0) {
|
|
// Node clicked
|
|
const nodeId = params.nodes[0];
|
|
const node = data.nodes.get(nodeId);
|
|
detailsPanel.innerHTML = `
|
|
<h6 class="mb-2 fw-bold text-dark">${node.label}</h6>
|
|
<span class="badge bg-secondary mb-2 bg-opacity-75">${node.category}</span>
|
|
<p class="small mb-0 text-dark"><strong>Summary:</strong><br>${node.summary}</p>
|
|
`;
|
|
} else if (params.edges.length > 0) {
|
|
// Edge clicked
|
|
const edgeId = params.edges[0];
|
|
const edge = data.edges.get(edgeId);
|
|
const fromNode = data.nodes.get(edge.from);
|
|
const toNode = data.nodes.get(edge.to);
|
|
detailsPanel.innerHTML = `
|
|
<h6 class="mb-2 fw-bold text-dark">Connection Details</h6>
|
|
<p class="small mb-1 text-dark"><strong>From:</strong> ${fromNode.label}</p>
|
|
<p class="small mb-2 text-dark"><strong>To:</strong> ${toNode.label}</p>
|
|
<div class="small text-dark border-top pt-2">
|
|
<p class="mb-1"><strong>How:</strong> ${edge.how || 'N/A'}</p>
|
|
<p class="mb-0"><strong>Why:</strong> ${edge.why || 'N/A'}</p>
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Clicked empty space
|
|
detailsPanel.innerHTML = '<p class="text-muted small mb-0">Click on a node or edge in the map to see its details here.</p>';
|
|
}
|
|
});
|
|
|
|
// AI Chat Handler
|
|
document.getElementById('ai-submit').addEventListener('click', function() {
|
|
const inputField = document.getElementById('ai-input');
|
|
const message = inputField.value.trim();
|
|
if (!message) return;
|
|
|
|
const responseDiv = document.getElementById('ai-response');
|
|
|
|
// Append user message
|
|
responseDiv.innerHTML += `<div class="mb-2 text-end"><span class="badge bg-primary rounded-pill px-3 py-2 text-wrap text-start d-inline-block shadow-sm" style="max-width: 85%; font-weight: normal; font-size: 0.9rem;">${message}</span></div>`;
|
|
inputField.value = '';
|
|
|
|
// Show loading state
|
|
const loadingId = 'loading-' + Date.now();
|
|
responseDiv.innerHTML += `<div id="${loadingId}" class="mb-2 text-start"><span class="badge bg-white text-primary border border-primary border-opacity-25 rounded-pill px-3 py-2 shadow-sm d-inline-block" style="font-weight: normal; font-size: 0.9rem;"><em><span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Thinking...</em></span></div>`;
|
|
responseDiv.scrollTop = responseDiv.scrollHeight;
|
|
|
|
fetch("{% url 'ai_chat' project.pk %}", {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': '{{ csrf_token }}'
|
|
},
|
|
body: JSON.stringify({ message: message })
|
|
})
|
|
.then(response => response.json())
|
|
.then(apiData => {
|
|
document.getElementById(loadingId).remove();
|
|
|
|
// Handle chat response
|
|
if (apiData.response) {
|
|
responseDiv.innerHTML += `<div class="mb-2 text-start"><span class="badge bg-white text-dark rounded-4 px-3 py-2 text-wrap text-start d-inline-block shadow-sm" style="max-width: 90%; font-weight: normal; font-size: 0.9rem; border: 1px solid rgba(0,0,0,0.05);">${apiData.response}</span></div>`;
|
|
}
|
|
|
|
// Check for new nodes and add them to the map
|
|
let mapUpdated = false;
|
|
|
|
if (apiData.added_nodes && apiData.added_nodes.length > 0) {
|
|
apiData.added_nodes.forEach(node => {
|
|
data.nodes.add({
|
|
id: node.id,
|
|
label: node.title,
|
|
title: "<strong>" + node.title + "</strong><br>" + node.summary + "<br><em>Category: " + node.category + "</em>",
|
|
summary: node.summary,
|
|
category: node.category,
|
|
color: getNodeColor(node.category),
|
|
shape: 'box',
|
|
font: { size: 16, face: 'Arial', color: '#333' },
|
|
margin: 10
|
|
});
|
|
});
|
|
mapUpdated = true;
|
|
}
|
|
|
|
if (apiData.added_connections && apiData.added_connections.length > 0) {
|
|
apiData.added_connections.forEach(conn => {
|
|
data.edges.add({
|
|
id: conn.id,
|
|
from: conn.source_id,
|
|
to: conn.target_id,
|
|
label: conn.how,
|
|
title: "<strong>Why:</strong> " + conn.why,
|
|
arrows: "to",
|
|
why: conn.why,
|
|
how: conn.how
|
|
});
|
|
});
|
|
mapUpdated = true;
|
|
}
|
|
|
|
if (mapUpdated) {
|
|
// Stabilize and adjust view
|
|
network.stabilize();
|
|
setTimeout(fitNetwork, 500); // Fit network smoothly after a slight delay
|
|
responseDiv.innerHTML += `<div class="mb-2 text-center"><small class="text-success"><i class="bi bi-check-circle-fill"></i> New items added to map!</small></div>`;
|
|
}
|
|
|
|
responseDiv.scrollTop = responseDiv.scrollHeight;
|
|
})
|
|
.catch(err => {
|
|
document.getElementById(loadingId).remove();
|
|
responseDiv.innerHTML += `<div class="mb-2 text-start"><span class="badge bg-danger text-white rounded-pill px-3 py-2 shadow-sm d-inline-block" style="font-weight: normal;">Error: Failed to get response.</span></div>`;
|
|
console.error(err);
|
|
});
|
|
});
|
|
|
|
// Allow submitting AI input with Enter key
|
|
document.getElementById('ai-input').addEventListener('keypress', function (e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
document.getElementById('ai-submit').click();
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %} |