selfhostedbg-web/src/components/GraphModal.astro

725 lines
No EOL
26 KiB
Text

---
import { siteConfig } from '@/config';
import Icon from './Icon.astro';
---
<!-- Graph Modal Overlay -->
<div
id="graph-modal-overlay"
class="fixed inset-0 z-50 hidden bg-black/50 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative w-full max-w-6xl h-[80vh] bg-white dark:bg-primary-900 rounded-xl shadow-2xl border border-primary-200 dark:border-primary-700 animate-scale-in">
<!-- Close Button -->
<button
id="graph-modal-close"
class="absolute top-4 right-4 z-10 p-2 text-primary-500 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-200 transition-colors rounded-lg hover:bg-primary-100 dark:hover:bg-primary-800"
aria-label="Close graph modal"
>
<Icon name="x" class="w-5 h-5" />
</button>
<!-- Graph Container -->
<div id="graph-modal-container" class="w-full h-full bg-primary-50 dark:bg-primary-800 rounded-xl overflow-hidden" style="touch-action: none;">
<!-- Graph will be rendered here -->
</div>
</div>
</div>
</div>
<script>
import * as d3 from "d3";
import { getGraphThemeColors } from '@/utils/graph-theme-colors';
// Graph Modal Functionality
let graphModalInitialized = false;
let svg: d3.Selection<SVGSVGElement, undefined, null, undefined> | null = null;
let simulation: d3.Simulation<any, any> | null = null;
let cleanup: (() => void) | null = null;
let hasFittedToGraph = false; // Prevent multiple fitToGraph calls
interface GraphNode {
id: string;
name: string;
slug?: string;
radius: number;
group?: string;
x?: number;
y?: number;
fx?: number | null;
fy?: number | null;
}
interface GraphLink {
source: string | GraphNode;
target: string | GraphNode;
value: number;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
function createGraphModalHTML() {
// Check if modal already exists
if (document.getElementById('graph-modal-overlay')) {
return;
}
const modal = document.createElement('div');
modal.id = 'graph-modal-overlay';
modal.className = 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm animate-fade-in hidden';
modal.innerHTML = `
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative w-full max-w-7xl h-[90vh] bg-primary-50 dark:bg-primary-800 rounded-xl shadow-2xl border border-primary-200 dark:border-primary-700 animate-scale-in">
<button
id="graph-modal-close"
class="absolute top-4 right-4 z-10 p-2 text-primary-500 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-200 transition-colors rounded-lg hover:bg-primary-100 dark:hover:bg-primary-700"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18"/>
<path d="M6 6l12 12"/>
</svg>
</button>
<div id="graph-modal-container" class="w-full h-full bg-primary-50 dark:bg-primary-800 rounded-xl overflow-hidden" style="touch-action: none;">
<div class="flex items-center justify-center h-full text-primary-500 dark:text-primary-400">
<div class="text-center">
<div class="animate-spin w-8 h-8 mx-auto mb-4 opacity-50">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 11-6.219-8.56"/>
</svg>
</div>
<p class="text-sm">Loading graph...</p>
</div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
}
// Global modal functions that can be called from anywhere
function openModal() {
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
if (!overlay) {
createGraphModalHTML();
// Try again after creating the HTML
setTimeout(() => {
openModal();
}, 100);
return;
}
overlay.classList.remove('hidden');
overlay.style.display = 'block';
overlay.style.visibility = 'visible';
overlay.style.opacity = '1';
overlay.style.zIndex = '9999';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
document.body.style.overflow = 'hidden';
// Reset the fitToGraph flag when opening
hasFittedToGraph = false;
loadGraphData();
}
function closeModal() {
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
if (!overlay) return;
overlay.classList.add('hidden');
// Reset all inline styles
overlay.style.display = '';
overlay.style.visibility = '';
overlay.style.opacity = '';
overlay.style.zIndex = '';
overlay.style.position = '';
overlay.style.top = '';
overlay.style.left = '';
overlay.style.width = '';
overlay.style.height = '';
overlay.style.backgroundColor = '';
document.body.style.overflow = '';
// Reset fitToGraph flag
hasFittedToGraph = false;
// Clean up D3 graph
if (cleanup) {
cleanup();
cleanup = null;
}
}
function handleGraphContainerClick(e: Event) {
// Prevent clicks on the graph container from bubbling up to the overlay
e.stopPropagation();
}
function attachEventListeners() {
// Remove existing listeners to prevent duplicates
const closeButton = document.getElementById('graph-modal-close') as HTMLButtonElement;
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
const graphContainer = document.getElementById('graph-modal-container') as HTMLElement;
if (closeButton) {
// Remove existing listeners by removing and re-adding the event listener
closeButton.removeEventListener('click', closeModal);
closeButton.addEventListener('click', closeModal);
}
if (overlay) {
// Remove existing listeners by removing and re-adding the event listener
overlay.removeEventListener('click', handleOverlayClick);
overlay.addEventListener('click', handleOverlayClick);
}
if (graphContainer) {
// Prevent clicks on the graph container from bubbling up to the overlay
graphContainer.removeEventListener('click', handleGraphContainerClick);
graphContainer.addEventListener('click', handleGraphContainerClick);
}
}
function handleOverlayClick(e: Event) {
const overlay = e.target as HTMLElement;
const modalContent = overlay.querySelector('.relative');
const graphContainer = overlay.querySelector('#graph-modal-container');
// Only close if clicking on the overlay itself, not on the modal content or graph
if (e.target === overlay && !modalContent?.contains(e.target as Node) && !graphContainer?.contains(e.target as Node)) {
closeModal();
}
}
function loadGraphData() {
const container = document.getElementById('graph-modal-container') as HTMLElement;
if (!container) {
return;
}
// Show loading state immediately
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;height:100%;text-align:center;" class="text-primary-500 dark:text-primary-400">
<div>
<div class="animate-spin" style="width:2rem;height:2rem;margin:0 auto 1rem;opacity:0.5;">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 11-6.219-8.56"/>
</svg>
</div>
<p style="font-size:0.875rem;">Loading graph...</p>
</div>
</div>
`;
fetch('/graph/graph-data.json')
.then(response => response.json())
.then((data: any) => {
// Transform data to match D3 format
const graphData: GraphData = {
nodes: data.nodes.map((node: any) => ({
id: node.id,
name: node.title || node.name,
slug: node.slug,
radius: Math.max(5, Math.min(15, (node.connections || 0) + 5)),
group: node.type
})),
links: data.connections.map((conn: any) => ({
source: conn.source,
target: conn.target,
value: 1
}))
};
renderGraph(container, graphData);
})
.catch(error => {
console.warn('Could not load graph data:', error);
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;height:100%;text-align:center;" class="text-primary-500 dark:text-primary-400">
<div>
<svg style="width:3rem;height:3rem;margin:0 auto 1rem;opacity:0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
<p style="font-size:1.125rem;font-weight:500;">Graph data not available</p>
<p style="font-size:0.875rem;margin-top:0.5rem;">Make sure to run the build process to generate graph data</p>
</div>
</div>
`;
});
}
function renderGraph(container: HTMLElement, data: GraphData) {
const colors = getGraphThemeColors();
const width = container.offsetWidth;
const height = container.offsetHeight;
const radiusForce = 300;
let dragStartX = 0;
let dragStartY = 0;
const CLICK_DISTANCE = 5; // px threshold to distinguish click from drag
let isDraggingNode = false;
// Clear container
container.innerHTML = '';
const links = data.links.map((d) => ({ ...d }));
const nodes = data.nodes.map((d) => ({ ...d }));
// Create force simulation
simulation = d3
.forceSimulation<GraphNode>(nodes)
.force(
"link",
d3.forceLink(links).id((d: any) => d.id).distance(100)
)
.force("charge", d3.forceManyBody().strength(-radiusForce))
.force("x", d3.forceX(width / 2).strength(0.05))
.force("y", d3.forceY(height / 2).strength(0.05))
.force("collision", d3.forceCollide().radius((d: any) => d.radius + 5));
// Create SVG
let svg = d3
.select(container)
.append("svg")
.attr("width", width)
.attr("height", height)
.style("background", colors.background);
const containerGroup = svg.append("g");
// Create zoom behavior
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 8]) // Extended zoom range for more flexibility
.wheelDelta((event) => {
// Conservative wheel delta
return -event.deltaY * 0.001;
})
.filter((event) => {
if (isDraggingNode) return false;
if (event.type === 'wheel') return true;
if (event.type === 'mousedown') {
return !event.target.closest('g[data-id]');
}
if (event.type === 'touchstart' || event.type === 'touchmove') {
if (event.touches && event.touches.length >= 2) return true;
return !event.target.closest('g[data-id]');
}
return false;
})
.on("start", function(event) {
// Show grabbing cursor only when starting to pan on empty space
if (event.sourceEvent && event.sourceEvent.type === 'mousedown') {
const isOnNode = !!(event.sourceEvent.target as Element)?.closest('g[data-id]');
if (!isOnNode && svg) svg.style('cursor', 'grabbing');
}
})
.on("end", function(event) {
// Always restore default cursor when zoom behavior ends
if (svg) svg.style('cursor', 'default');
})
.on("zoom", (event) => {
// Direct transform - no throttling or delays
containerGroup.attr("transform", event.transform);
// Hide/show labels based on zoom level
const scale = event.transform.k;
const labelThreshold = 0.6; // Hide labels when zoomed out beyond this threshold
if (scale < labelThreshold) {
// Hide all labels when zoomed out
labels.style("opacity", 0);
} else {
// Show all labels when zoomed in
labels.style("opacity", 1);
}
});
svg.call(zoom as any);
if (svg) svg.style('cursor', 'default');
// Create links
const link = containerGroup
.selectAll("line")
.data(links)
.enter()
.append("line")
.attr("stroke", colors.linkStroke)
.attr("stroke-width", 2)
.attr("stroke-opacity", 0.6);
// Create node groups
const nodeGroup = containerGroup
.selectAll("g")
.data(nodes)
.enter()
.append("g")
.attr("data-id", (d) => d.id)
.call(
d3
.drag<SVGGElement, GraphNode>()
.on("start", function (event) {
dragStartX = event.x;
dragStartY = event.y;
isDraggingNode = true;
if (svg) svg.style('cursor', 'default');
if (!event.active) simulation!.alphaTarget(0.1).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
})
.on("drag", function (event, d) {
d.fx = event.x;
d.fy = event.y;
})
.on("end", function (event, d) {
isDraggingNode = false;
if (svg) svg.style('cursor', 'default');
if (!event.active) simulation!.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
// Navigate on click (no drag movement). Uses distance threshold
// because D3 drag fires for even 1px of movement. Handled here
// instead of a "click" listener because D3 drag calls
// stopImmediatePropagation on mouseup in Chrome.
const dx = event.x - dragStartX;
const dy = event.y - dragStartY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CLICK_DISTANCE && d.slug) {
closeModal();
if ((window as any).swup) {
if ((window as any).resetLocalGraph) {
(window as any).resetLocalGraph();
}
(window as any).swup.navigate(`/posts/${d.slug}`);
} else {
window.location.href = `/posts/${d.slug}`;
}
}
}) as any,
);
// Build neighbor map for hover effects
const neighbors = new Map<string, Set<string>>();
for (const link of links) {
const src = typeof link.source === "object" ? link.source.id : link.source;
const tgt = typeof link.target === "object" ? link.target.id : link.target;
if (!neighbors.has(src)) neighbors.set(src, new Set());
if (!neighbors.has(tgt)) neighbors.set(tgt, new Set());
neighbors.get(src)!.add(tgt);
neighbors.get(tgt)!.add(src);
}
// Add hover effects
nodeGroup
.on("mouseover", function (_event, d) {
const connected = neighbors.get(d.id) ?? new Set();
connected.add(d.id);
link.transition().duration(150)
.attr("stroke", (l: any) =>
(typeof l.source === 'object' ? l.source.id : l.source) === d.id ||
(typeof l.target === 'object' ? l.target.id : l.target) === d.id
? colors.tagFill : colors.linkStroke)
.attr("stroke-opacity", (l: any) =>
(typeof l.source === 'object' ? l.source.id : l.source) === d.id ||
(typeof l.target === 'object' ? l.target.id : l.target) === d.id ? 0.8 : 0.1);
nodeGroup.selectAll<SVGCircleElement, GraphNode>("circle")
.attr("fill-opacity", (n) => connected.has(n.id) ? 1 : 0.3)
.attr("fill", (n) => n.id === d.id ? colors.tagFill : colors.linkStroke);
labels.transition().duration(150)
.style("opacity", (n) => connected.has(n.id) ? 1 : 0.1);
})
.on("mouseout", function () {
link.transition().duration(150)
.attr("stroke", colors.linkStroke)
.attr("stroke-opacity", 0.6);
nodeGroup.selectAll<SVGCircleElement, GraphNode>("circle")
.attr("fill", colors.linkStroke)
.attr("fill-opacity", 1);
const currentTransform = d3.zoomTransform(svg.node() as Element);
labels.transition().duration(150)
.style("opacity", currentTransform.k < 0.6 ? 0 : 0.6);
});
// Create circles
const node = nodeGroup
.append("circle")
.attr("r", (d) => d.radius)
.attr("fill", colors.linkStroke)
.attr("cursor", "pointer");
// Create labels
const labels = nodeGroup
.append("text")
.text((d) => d.name)
.attr("text-anchor", "middle")
.attr("dy", (d) => d.radius + 8) // Position below the node
.attr("font-size", "12px")
.attr("font-weight", "500")
.attr("fill", colors.postText)
.attr("pointer-events", "none")
.style("user-select", "none")
.style("opacity", 0.6); // Start with slightly faded labels
// Update positions on simulation tick
simulation.on("tick", () => {
link
.attr("x1", (d: any) => d.source.x)
.attr("y1", (d: any) => d.source.y)
.attr("x2", (d: any) => d.target.x)
.attr("y2", (d: any) => d.target.y);
nodeGroup.attr("transform", (d: any) => `translate(${d.x},${d.y})`);
});
simulation.alpha(1).restart();
// Don't auto-fit to graph - let user control the view
// Only fit if explicitly requested via keyboard shortcut
// Set up cleanup function
cleanup = () => {
if (simulation) {
simulation.stop();
simulation = null;
}
if (svg) {
svg.remove();
}
};
// Add keyboard shortcuts
const handleKeyDown = (e: KeyboardEvent) => {
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
if (overlay && !overlay.classList.contains('hidden')) {
switch (e.key) {
case 'r':
if (svg) {
svg.transition().duration(300).call(
zoom.transform as any,
d3.zoomIdentity.scale(1).translate(0, 0)
);
}
break;
case 'c':
if (svg) {
// Get the SVG dimensions
const svgNode = svg.node();
if (!svgNode) return;
const svgRect = svgNode.getBoundingClientRect();
const width = svgRect.width;
const height = svgRect.height;
// Get all nodes to calculate bounds
const nodes = svg.selectAll('g[data-id]').nodes();
if (nodes.length === 0) return;
// Calculate bounding box of all nodes
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
nodes.forEach((node) => {
if (node && node instanceof Element) {
const transform = node.getAttribute('transform');
if (transform) {
const match = transform.match(/translate\(([^,]+),\s*([^)]+)\)/);
if (match) {
const x = parseFloat(match[1]);
const y = parseFloat(match[2]);
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
}
});
// Add padding
const padding = 50;
const boundsWidth = maxX - minX + padding * 2;
const boundsHeight = maxY - minY + padding * 2;
// Calculate scale to fit viewport
const scaleX = width / boundsWidth;
const scaleY = height / boundsHeight;
const scale = Math.min(scaleX, scaleY); // Allow zoom out (scale < 1)
// Constrain scale to reasonable bounds (0.1 to 3)
const constrainedScale = Math.max(0.1, Math.min(3, scale));
// Calculate translation to center
const graphCenterX = (minX + maxX) / 2;
const graphCenterY = (minY + maxY) / 2;
const centerX = (width / 2) / constrainedScale - graphCenterX;
const centerY = (height / 2) / constrainedScale - graphCenterY;
svg.transition().duration(300).call(
zoom.transform as any,
d3.zoomIdentity.scale(constrainedScale).translate(centerX, centerY)
);
}
break;
}
}
};
document.addEventListener('keydown', handleKeyDown);
// Update cleanup function
const originalCleanup = cleanup;
cleanup = () => {
document.removeEventListener('keydown', handleKeyDown);
if (originalCleanup) originalCleanup();
};
}
function fitToGraph(svgElement: any, nodes: GraphNode[], width: number, height: number, zoomBehavior: any) {
if (!svgElement) {
return;
}
// Instead of fitting nodes, zoom OUT to show the entire graph area
// Use a small scale to zoom OUT and show everything
const scale = 0.3; // Zoom OUT to 30% (much smaller)
// Center the graph in the viewport
const x = (width / 2) - (width / 2 * scale);
const y = (height / 2) - (height / 2 * scale);
// Use the existing zoom behavior instead of creating a new one
svgElement
.transition()
.duration(750)
.call(
zoomBehavior.transform as any,
d3.zoomIdentity.scale(scale).translate(x, y),
);
}
function getGraphBounds(nodes: GraphNode[]) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of nodes) {
if (n.x !== undefined && n.y !== undefined) {
x0 = Math.min(x0, n.x);
y0 = Math.min(y0, n.y);
x1 = Math.max(x1, n.x);
y1 = Math.max(y1, n.y);
}
}
return { x0, y0, x1, y1 };
}
function initializeGraphModal() {
const overlay = document.getElementById('graph-modal-overlay') as HTMLElement;
const closeButton = document.getElementById('graph-modal-close') as HTMLButtonElement;
const container = document.getElementById('graph-modal-container') as HTMLElement;
if (!overlay || !closeButton || !container) {
// Create the modal HTML if it doesn't exist
createGraphModalHTML();
// Try again after creating the HTML
setTimeout(() => {
initializeGraphModal();
}, 100);
return;
}
// Always ensure the modal functions are available, even if already initialized
if (graphModalInitialized) {
// Make sure the functions are still available
(window as any).openGraphModal = openModal;
(window as any).closeGraphModal = closeModal;
// Re-attach event listeners to ensure they work
attachEventListeners();
return;
}
graphModalInitialized = true;
// Attach event listeners
attachEventListeners();
function getGraphBounds(nodes: GraphNode[]) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of nodes) {
if (!isNaN(n.x!) && !isNaN(n.y!)) {
x0 = Math.min(x0, n.x!);
y0 = Math.min(y0, n.y!);
x1 = Math.max(x1, n.x!);
y1 = Math.max(y1, n.y!);
}
}
return { x0, y0, x1, y1 };
}
// Escape key to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !overlay.classList.contains('hidden')) {
closeModal();
}
});
// Make functions globally accessible
(window as any).openGraphModal = openModal;
(window as any).closeGraphModal = closeModal;
(window as any).initializeGraphModal = initializeGraphModal;
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeGraphModal);
} else {
initializeGraphModal();
}
// Swup integration - reinitialize after page transitions
if ((window as any).swup) {
(window as any).swup.hooks.on('page:view', () => {
// Always try to reinitialize to ensure it's available
initializeGraphModal();
});
(window as any).swup.hooks.on('visit:end', () => {
// Ensure the global function is available after page transitions
if (!(window as any).openGraphModal) {
initializeGraphModal();
}
});
}
// Listen for theme changes to update graph colors
window.addEventListener('themechange', () => {
// Re-render the graph with new colors if it's currently open
const overlay = document.getElementById('graph-modal-overlay');
if (overlay && !overlay.classList.contains('hidden') && svg) {
// Get fresh data and re-render
fetch('/graph/graph-data.json')
.then(response => response.json())
.then(data => {
const container = document.getElementById('graph-modal-content');
if (container) {
renderGraph(container, data);
}
})
.catch(error => {
console.error('GraphModal: Error fetching graph data for theme update:', error);
});
}
});
</script>