1734 lines
49 KiB
Svelte
1734 lines
49 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import {
|
|
Plus,
|
|
Upload,
|
|
Download,
|
|
Image as ImageIcon,
|
|
Undo2,
|
|
Redo2,
|
|
Maximize,
|
|
Trash2,
|
|
X,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Share2,
|
|
Search,
|
|
} from 'lucide-svelte';
|
|
import { iconMap, nodeTypes, typeColors, type NodeType } from '$lib/identityGraph';
|
|
|
|
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
$: if (nodes && links && (nodes.length > 0 || links.length > 0)) {
|
|
if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
|
|
autoSaveTimeout = setTimeout(() => {
|
|
saveToLocalStorage();
|
|
}, 2000);
|
|
}
|
|
|
|
type Node = {
|
|
id: string;
|
|
label: string;
|
|
type: NodeType;
|
|
x: number;
|
|
y: number;
|
|
notes?: string;
|
|
imageUrl?: string;
|
|
showLabel?: boolean;
|
|
showType?: boolean;
|
|
showNotes?: boolean;
|
|
};
|
|
|
|
type Link = {
|
|
id: string;
|
|
source: string;
|
|
target: string;
|
|
label: string;
|
|
};
|
|
|
|
type GraphState = {
|
|
nodes: Node[];
|
|
links: Link[];
|
|
};
|
|
|
|
let nodes: Node[] = [];
|
|
let links: Link[] = [];
|
|
|
|
const MAX_HISTORY = 50;
|
|
let undoStack: GraphState[] = [];
|
|
let redoStack: GraphState[] = [];
|
|
|
|
let transform = { x: 0, y: 0, k: 1 };
|
|
let isDragging = false;
|
|
let isPanning = false;
|
|
let isLinking = false;
|
|
|
|
let selectedNodeId: string | null = null;
|
|
let draggedNodeId: string | null = null;
|
|
let linkStartNodeId: string | null = null;
|
|
let hoverNodeId: string | null = null;
|
|
let editingLinkId: string | null = null;
|
|
let editingLinkLabel = '';
|
|
let selectedNode: Node | null = null;
|
|
type ConnectedEdge = {
|
|
id: string;
|
|
label: string;
|
|
otherNode: Node | null;
|
|
};
|
|
let connectedEdges: ConnectedEdge[] = [];
|
|
$: connectedEdges = selectedNode
|
|
? links
|
|
.filter(
|
|
(l) => selectedNode && (l.source === selectedNode.id || l.target === selectedNode.id)
|
|
)
|
|
.map((l) => {
|
|
if (!selectedNode) return null;
|
|
const otherId = l.source === selectedNode.id ? l.target : l.source;
|
|
return {
|
|
id: l.id,
|
|
label: l.label,
|
|
otherNode: nodes.find((n) => n.id === otherId) || null,
|
|
};
|
|
})
|
|
.filter((e): e is ConnectedEdge => e !== null)
|
|
: [];
|
|
|
|
let lastMouse = { x: 0, y: 0 };
|
|
let mouseWorld = { x: 0, y: 0 };
|
|
|
|
let svgElement: SVGSVGElement;
|
|
let containerElement: HTMLDivElement;
|
|
|
|
let showAddModal = false;
|
|
let newNodeLabel = '';
|
|
let newNodeType: NodeType = 'person';
|
|
let newNodeImage = '';
|
|
let newNodeNotes = '';
|
|
|
|
let editLabel = '';
|
|
let editType: NodeType = 'person';
|
|
let editImageUrl = '';
|
|
let editNotes = '';
|
|
let editShowLabel = true;
|
|
let editShowType = true;
|
|
let editShowNotes = true;
|
|
let inspectorNodeId: string | null = null;
|
|
let inspectorHistoryNodeId: string | null = null;
|
|
let inspectorHistoryCommitted = false;
|
|
let imageUploadInput: HTMLInputElement | null = null;
|
|
let modalImageUploadInput: HTMLInputElement | null = null;
|
|
let imageUploadError = '';
|
|
let newNodeImageError = '';
|
|
let controlsCollapsed = false;
|
|
let copiedNodes: Node[] = [];
|
|
let searchQuery = '';
|
|
let searchInput: HTMLInputElement | null = null;
|
|
let showSearch = false;
|
|
let linkEditInput: HTMLInputElement | null = null;
|
|
let addNodeInput: HTMLInputElement | null = null;
|
|
|
|
const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
|
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
|
|
$: filteredNodes = searchQuery.trim()
|
|
? nodes.filter(
|
|
(n) =>
|
|
n.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
n.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(n.notes && n.notes.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
)
|
|
: nodes;
|
|
|
|
$: filteredLinks = searchQuery.trim()
|
|
? links.filter((l) => {
|
|
const source = nodes.find((n) => n.id === l.source);
|
|
const target = nodes.find((n) => n.id === l.target);
|
|
return (
|
|
l.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(source && source.label.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
|
(target && target.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
);
|
|
})
|
|
: links;
|
|
|
|
$: selectedNode = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) || null : null;
|
|
$: if (selectedNode && selectedNode.id !== inspectorNodeId) {
|
|
hydrateInspectorFromNode(selectedNode);
|
|
} else if (!selectedNode && inspectorNodeId) {
|
|
clearInspector();
|
|
}
|
|
|
|
function hydrateInspectorFromNode(node: Node) {
|
|
editLabel = node.label;
|
|
editType = node.type;
|
|
editImageUrl = node.imageUrl || '';
|
|
editNotes = node.notes || '';
|
|
editShowLabel = node.showLabel !== false;
|
|
editShowType = node.showType !== false;
|
|
editShowNotes = node.showNotes !== false;
|
|
imageUploadError = '';
|
|
inspectorNodeId = node.id;
|
|
inspectorHistoryNodeId = node.id;
|
|
inspectorHistoryCommitted = false;
|
|
}
|
|
|
|
function clearInspector() {
|
|
editLabel = '';
|
|
editType = 'person';
|
|
editImageUrl = '';
|
|
editNotes = '';
|
|
editShowLabel = true;
|
|
editShowType = true;
|
|
editShowNotes = true;
|
|
imageUploadError = '';
|
|
inspectorNodeId = null;
|
|
inspectorHistoryNodeId = null;
|
|
inspectorHistoryCommitted = false;
|
|
}
|
|
|
|
function ensureInspectorHistory() {
|
|
if (!selectedNode) return;
|
|
if (inspectorHistoryNodeId !== selectedNode.id) {
|
|
inspectorHistoryNodeId = selectedNode.id;
|
|
inspectorHistoryCommitted = false;
|
|
}
|
|
if (!inspectorHistoryCommitted) {
|
|
pushState();
|
|
inspectorHistoryCommitted = true;
|
|
}
|
|
}
|
|
|
|
function updateSelectedNodeField(patch: Partial<Node>) {
|
|
if (!selectedNode) return;
|
|
ensureInspectorHistory();
|
|
nodes = nodes.map((n) =>
|
|
n.id === selectedNode.id
|
|
? {
|
|
...n,
|
|
...patch,
|
|
}
|
|
: n
|
|
);
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
function handleLabelInput(value: string) {
|
|
editLabel = value;
|
|
updateSelectedNodeField({ label: value });
|
|
}
|
|
|
|
function handleLabelInputEvent(event: Event) {
|
|
const target = event.currentTarget as HTMLInputElement | null;
|
|
handleLabelInput(target?.value ?? '');
|
|
}
|
|
|
|
function handleTypeSelect(type: NodeType) {
|
|
editType = type;
|
|
updateSelectedNodeField({ type });
|
|
}
|
|
|
|
function handleImageUrlInput(value: string) {
|
|
editImageUrl = value;
|
|
const trimmed = value.trim();
|
|
updateSelectedNodeField({ imageUrl: trimmed || undefined });
|
|
imageUploadError = '';
|
|
}
|
|
|
|
function handleImageUrlInputEvent(event: Event) {
|
|
const target = event.currentTarget as HTMLInputElement | null;
|
|
handleImageUrlInput(target?.value ?? '');
|
|
}
|
|
|
|
function handleNotesInput(value: string) {
|
|
editNotes = value;
|
|
const trimmed = value.trim();
|
|
updateSelectedNodeField({ notes: trimmed || undefined });
|
|
}
|
|
|
|
function handleNotesInputEvent(event: Event) {
|
|
const target = event.currentTarget as HTMLTextAreaElement | null;
|
|
handleNotesInput(target?.value ?? '');
|
|
}
|
|
|
|
function toggleShowLabel() {
|
|
editShowLabel = !editShowLabel;
|
|
updateSelectedNodeField({ showLabel: editShowLabel });
|
|
}
|
|
|
|
function toggleShowType() {
|
|
editShowType = !editShowType;
|
|
updateSelectedNodeField({ showType: editShowType });
|
|
}
|
|
|
|
function toggleShowNotes() {
|
|
editShowNotes = !editShowNotes;
|
|
updateSelectedNodeField({ showNotes: editShowNotes });
|
|
}
|
|
|
|
function triggerImageUpload() {
|
|
imageUploadInput?.click();
|
|
}
|
|
|
|
function handleImageFileSelected(event: Event) {
|
|
const input = event.currentTarget as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
|
imageUploadError = 'Only PNG, JPG, or WebP images are allowed.';
|
|
input.value = '';
|
|
return;
|
|
}
|
|
if (file.size > MAX_IMAGE_BYTES) {
|
|
imageUploadError = 'Image must be under 2MB.';
|
|
input.value = '';
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const dataUrl = reader.result as string;
|
|
editImageUrl = dataUrl;
|
|
updateSelectedNodeField({ imageUrl: dataUrl });
|
|
imageUploadError = '';
|
|
input.value = '';
|
|
};
|
|
reader.onerror = () => {
|
|
imageUploadError = 'Failed to read image.';
|
|
input.value = '';
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function clearImage() {
|
|
editImageUrl = '';
|
|
updateSelectedNodeField({ imageUrl: undefined });
|
|
imageUploadError = '';
|
|
}
|
|
|
|
function triggerNewNodeImageUpload() {
|
|
modalImageUploadInput?.click();
|
|
}
|
|
|
|
function handleNewNodeImageFileSelected(event: Event) {
|
|
const input = event.currentTarget as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
|
newNodeImageError = 'Only PNG, JPG, or WebP images are allowed.';
|
|
input.value = '';
|
|
return;
|
|
}
|
|
if (file.size > MAX_IMAGE_BYTES) {
|
|
newNodeImageError = 'Image must be under 2MB.';
|
|
input.value = '';
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
newNodeImage = reader.result as string;
|
|
newNodeImageError = '';
|
|
input.value = '';
|
|
};
|
|
reader.onerror = () => {
|
|
newNodeImageError = 'Failed to read image.';
|
|
input.value = '';
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function clearNewNodeImage() {
|
|
newNodeImage = '';
|
|
newNodeImageError = '';
|
|
}
|
|
|
|
function closeInspector() {
|
|
selectedNodeId = null;
|
|
}
|
|
|
|
function pushState() {
|
|
const currentState: GraphState = {
|
|
nodes: JSON.parse(JSON.stringify(nodes)),
|
|
links: JSON.parse(JSON.stringify(links)),
|
|
};
|
|
undoStack = [...undoStack, currentState];
|
|
if (undoStack.length > MAX_HISTORY) undoStack.shift();
|
|
redoStack = [];
|
|
}
|
|
|
|
function undo() {
|
|
if (undoStack.length === 0) return;
|
|
const currentState = {
|
|
nodes: JSON.parse(JSON.stringify(nodes)),
|
|
links: JSON.parse(JSON.stringify(links)),
|
|
};
|
|
redoStack = [currentState, ...redoStack];
|
|
|
|
const prevState = undoStack.pop();
|
|
// eslint-disable-next-line no-self-assign
|
|
undoStack = undoStack;
|
|
|
|
if (prevState) {
|
|
nodes = prevState.nodes;
|
|
links = prevState.links;
|
|
}
|
|
}
|
|
|
|
function redo() {
|
|
if (redoStack.length === 0) return;
|
|
const currentState = {
|
|
nodes: JSON.parse(JSON.stringify(nodes)),
|
|
links: JSON.parse(JSON.stringify(links)),
|
|
};
|
|
undoStack = [...undoStack, currentState];
|
|
|
|
const nextState = redoStack.shift();
|
|
// eslint-disable-next-line no-self-assign
|
|
redoStack = redoStack;
|
|
|
|
if (nextState) {
|
|
nodes = nextState.nodes;
|
|
links = nextState.links;
|
|
}
|
|
}
|
|
|
|
function saveToLocalStorage() {
|
|
const data = { nodes, links, transform };
|
|
localStorage.setItem('quad4-linking-graph-v1', JSON.stringify(data));
|
|
}
|
|
|
|
function loadFromUrl() {
|
|
if (typeof window === 'undefined') return false;
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
const encoded = params.get('graph');
|
|
if (!encoded) return false;
|
|
|
|
try {
|
|
const decoded = atob(encoded);
|
|
const data = JSON.parse(decoded);
|
|
if (data.nodes && data.links) {
|
|
pushState();
|
|
nodes = normalizeNodes(data.nodes);
|
|
links = data.links;
|
|
if (data.transform) {
|
|
transform = data.transform;
|
|
} else {
|
|
centerView();
|
|
}
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load graph from URL', e);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function loadFromLocalStorage() {
|
|
const raw = localStorage.getItem('quad4-linking-graph-v1');
|
|
if (raw) {
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
nodes = normalizeNodes(data.nodes || []);
|
|
links = data.links || [];
|
|
transform = data.transform || { x: 0, y: 0, k: 1 };
|
|
} catch (e) {
|
|
console.error('Failed to load graph', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function shareGraph() {
|
|
if (nodes.length === 0 && links.length === 0) {
|
|
alert('Nothing to share. Add some nodes first.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = {
|
|
version: 1,
|
|
timestamp: new Date().toISOString(),
|
|
nodes,
|
|
links,
|
|
transform,
|
|
};
|
|
|
|
const jsonData = JSON.stringify(data);
|
|
const encoded = btoa(jsonData);
|
|
const shareUrl = `${window.location.origin}${window.location.pathname}?graph=${encoded}`;
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
alert('Share link copied to clipboard!');
|
|
} else {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = shareUrl;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
alert('Share link copied to clipboard!');
|
|
} catch {
|
|
prompt('Copy this link:', shareUrl);
|
|
}
|
|
document.body.removeChild(textarea);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to share graph', err);
|
|
alert(`Failed to create share link: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
async function exportGraph() {
|
|
const data = {
|
|
version: 1,
|
|
timestamp: new Date().toISOString(),
|
|
nodes,
|
|
links,
|
|
};
|
|
|
|
try {
|
|
const jsonData = JSON.stringify(data, null, 2);
|
|
const blob = new Blob([jsonData], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `quad4-linking-graph-${new Date().toISOString().slice(0, 10)}.json`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error('Failed to export graph', err);
|
|
alert(`Failed to export graph: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
async function importGraph() {
|
|
try {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.json';
|
|
input.onchange = async (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
|
|
const text = await file.text();
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (parseErr) {
|
|
console.error('JSON parse error:', parseErr);
|
|
alert(
|
|
`Failed to parse JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (data.nodes && data.links) {
|
|
pushState();
|
|
nodes = normalizeNodes(data.nodes);
|
|
links = data.links;
|
|
centerView();
|
|
saveToLocalStorage();
|
|
} else {
|
|
alert('Invalid graph format: file must contain nodes and links arrays');
|
|
}
|
|
};
|
|
input.click();
|
|
} catch (err) {
|
|
console.error('Failed to import graph', err);
|
|
alert(`Failed to import graph: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
async function exportPNG() {
|
|
if (!svgElement) return;
|
|
|
|
try {
|
|
const serializer = new XMLSerializer();
|
|
const svgStr = serializer.serializeToString(svgElement);
|
|
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
|
const url = URL.createObjectURL(svgBlob);
|
|
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const bbox = svgElement.getBoundingClientRect();
|
|
canvas.width = bbox.width * 2;
|
|
canvas.height = bbox.height * 2;
|
|
|
|
ctx.fillStyle = '#0a0a0a';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
ctx.scale(2, 2);
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
canvas.toBlob((blob) => {
|
|
if (blob) {
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = blobUrl;
|
|
link.download = `quad4-linking-snapshot-${new Date().toISOString().slice(0, 10)}.png`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(blobUrl);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}, 'image/png');
|
|
};
|
|
img.onerror = () => {
|
|
console.error('Failed to load SVG image');
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
img.src = url;
|
|
} catch (err) {
|
|
console.error('Failed to export PNG', err);
|
|
}
|
|
}
|
|
|
|
function getScreenCoords(e: MouseEvent) {
|
|
const rect = containerElement.getBoundingClientRect();
|
|
return {
|
|
x: e.clientX - rect.left,
|
|
y: e.clientY - rect.top,
|
|
};
|
|
}
|
|
|
|
function screenToWorld(x: number, y: number) {
|
|
return {
|
|
x: (x - transform.x) / transform.k,
|
|
y: (y - transform.y) / transform.k,
|
|
};
|
|
}
|
|
|
|
function handleWheel(e: WheelEvent) {
|
|
e.preventDefault();
|
|
const zoomIntensity = 0.001;
|
|
const delta = -e.deltaY * zoomIntensity;
|
|
const newScale = Math.min(Math.max(0.1, transform.k + delta), 5);
|
|
|
|
const mouse = getScreenCoords(e);
|
|
const worldBefore = screenToWorld(mouse.x, mouse.y);
|
|
|
|
transform.k = newScale;
|
|
|
|
const worldAfter = screenToWorld(mouse.x, mouse.y);
|
|
transform.x += (worldAfter.x - worldBefore.x) * newScale;
|
|
transform.y += (worldAfter.y - worldBefore.y) * newScale;
|
|
}
|
|
|
|
function handleMouseDown(e: MouseEvent) {
|
|
const mouse = getScreenCoords(e);
|
|
lastMouse = mouse;
|
|
|
|
if (e.button === 1 || (e.button === 0 && e.altKey)) {
|
|
isPanning = true;
|
|
containerElement.style.cursor = 'grabbing';
|
|
} else if (e.button === 0 && !hoverNodeId) {
|
|
isPanning = true;
|
|
selectedNodeId = null;
|
|
containerElement.style.cursor = 'grabbing';
|
|
}
|
|
}
|
|
|
|
function handleNodeMouseDown(e: MouseEvent, nodeId: string) {
|
|
e.stopPropagation();
|
|
const mouse = getScreenCoords(e);
|
|
lastMouse = mouse;
|
|
|
|
if (e.shiftKey) {
|
|
isLinking = true;
|
|
linkStartNodeId = nodeId;
|
|
selectedNodeId = nodeId;
|
|
} else {
|
|
isDragging = true;
|
|
draggedNodeId = nodeId;
|
|
selectedNodeId = nodeId;
|
|
}
|
|
}
|
|
|
|
function handleMouseMove(e: MouseEvent) {
|
|
const mouse = getScreenCoords(e);
|
|
const dx = mouse.x - lastMouse.x;
|
|
const dy = mouse.y - lastMouse.y;
|
|
lastMouse = mouse;
|
|
|
|
mouseWorld = screenToWorld(mouse.x, mouse.y);
|
|
|
|
if (isPanning) {
|
|
transform.x += dx;
|
|
transform.y += dy;
|
|
} else if (isDragging && draggedNodeId) {
|
|
const node = nodes.find((n) => n.id === draggedNodeId);
|
|
if (node) {
|
|
node.x += dx / transform.k;
|
|
node.y += dy / transform.k;
|
|
// eslint-disable-next-line no-self-assign
|
|
nodes = nodes;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleMouseUp(_e: MouseEvent) {
|
|
if (isDragging && draggedNodeId) {
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
if (isLinking && linkStartNodeId && hoverNodeId && linkStartNodeId !== hoverNodeId) {
|
|
pushState();
|
|
addLink(linkStartNodeId, hoverNodeId);
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
isPanning = false;
|
|
isDragging = false;
|
|
isLinking = false;
|
|
draggedNodeId = null;
|
|
linkStartNodeId = null;
|
|
containerElement.style.cursor = 'default';
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
function addNode() {
|
|
if (!newNodeLabel) return;
|
|
pushState();
|
|
const id = 'n-' + Math.random().toString(36).slice(2, 11);
|
|
const center = screenToWorld(
|
|
containerElement.clientWidth / 2,
|
|
containerElement.clientHeight / 2
|
|
);
|
|
const trimmedNotes = newNodeNotes.trim();
|
|
const trimmedImage = newNodeImage.trim();
|
|
const lastNode = selectedNode || nodes[nodes.length - 1];
|
|
const inheritShowLabel = lastNode?.showLabel !== false;
|
|
const inheritShowType = lastNode?.showType !== false;
|
|
const inheritShowNotes = lastNode?.showNotes !== false;
|
|
|
|
nodes = [
|
|
...nodes,
|
|
{
|
|
id,
|
|
label: newNodeLabel,
|
|
type: newNodeType,
|
|
x: center.x + (Math.random() * 40 - 20),
|
|
y: center.y + (Math.random() * 40 - 20),
|
|
notes: trimmedNotes ? trimmedNotes : undefined,
|
|
imageUrl: trimmedImage ? trimmedImage : undefined,
|
|
showLabel: inheritShowLabel,
|
|
showType: inheritShowType,
|
|
showNotes: inheritShowNotes,
|
|
},
|
|
];
|
|
newNodeLabel = '';
|
|
newNodeImage = '';
|
|
newNodeNotes = '';
|
|
newNodeImageError = '';
|
|
showAddModal = false;
|
|
selectedNodeId = id;
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
function addLink(sourceId: string, targetId: string) {
|
|
const exists = links.find(
|
|
(l) =>
|
|
(l.source === sourceId && l.target === targetId) ||
|
|
(l.source === targetId && l.target === sourceId)
|
|
);
|
|
if (exists) return;
|
|
|
|
const id = 'l-' + Math.random().toString(36).slice(2, 11);
|
|
links = [
|
|
...links,
|
|
{
|
|
id,
|
|
source: sourceId,
|
|
target: targetId,
|
|
label: 'Linked',
|
|
},
|
|
];
|
|
}
|
|
|
|
function deleteSelected() {
|
|
if (!selectedNodeId) return;
|
|
pushState();
|
|
nodes = nodes.filter((n) => n.id !== selectedNodeId);
|
|
links = links.filter((l) => l.source !== selectedNodeId && l.target !== selectedNodeId);
|
|
selectedNodeId = null;
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
function startEditingLink(linkId: string, e: MouseEvent) {
|
|
e.stopPropagation();
|
|
const link = links.find((l) => l.id === linkId);
|
|
if (link) {
|
|
editingLinkId = linkId;
|
|
editingLinkLabel = link.label;
|
|
setTimeout(() => linkEditInput?.focus(), 10);
|
|
}
|
|
}
|
|
|
|
function saveLinkEdit() {
|
|
if (!editingLinkId) return;
|
|
pushState();
|
|
links = links.map((l) =>
|
|
l.id === editingLinkId ? { ...l, label: editingLinkLabel.trim() || 'Linked' } : l
|
|
);
|
|
editingLinkId = null;
|
|
editingLinkLabel = '';
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
function cancelLinkEdit() {
|
|
editingLinkId = null;
|
|
editingLinkLabel = '';
|
|
}
|
|
|
|
function handleLinkLabelInput(event: Event) {
|
|
const target = event.currentTarget as HTMLInputElement | null;
|
|
if (target) {
|
|
editingLinkLabel = target.value;
|
|
}
|
|
}
|
|
|
|
function centerView() {
|
|
if (nodes.length === 0) {
|
|
transform = { x: 0, y: 0, k: 1 };
|
|
return;
|
|
}
|
|
const xs = nodes.map((n) => n.x);
|
|
const ys = nodes.map((n) => n.y);
|
|
const minX = Math.min(...xs);
|
|
const maxX = Math.max(...xs);
|
|
const minY = Math.min(...ys);
|
|
const maxY = Math.max(...ys);
|
|
|
|
const width = maxX - minX;
|
|
const height = maxY - minY;
|
|
const cx = (minX + maxX) / 2;
|
|
const cy = (minY + maxY) / 2;
|
|
|
|
const padding = 100;
|
|
const availWidth = containerElement.clientWidth - padding * 2;
|
|
const availHeight = containerElement.clientHeight - padding * 2;
|
|
|
|
const scale = Math.min(
|
|
availWidth / Math.max(width, 100),
|
|
availHeight / Math.max(height, 100),
|
|
1
|
|
);
|
|
|
|
transform = {
|
|
k: scale,
|
|
x: containerElement.clientWidth / 2 - cx * scale,
|
|
y: containerElement.clientHeight / 2 - cy * scale,
|
|
};
|
|
}
|
|
|
|
function normalizeNodes(nodesToNormalize: Node[]): Node[] {
|
|
return nodesToNormalize.map((node) => ({
|
|
...node,
|
|
showLabel: node.showLabel !== undefined ? node.showLabel : true,
|
|
showType: node.showType !== undefined ? node.showType : true,
|
|
showNotes: node.showNotes !== undefined ? node.showNotes : true,
|
|
}));
|
|
}
|
|
|
|
function clearGraph() {
|
|
pushState();
|
|
nodes = [];
|
|
links = [];
|
|
transform = { x: 0, y: 0, k: 1 };
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
function copySelectedNode() {
|
|
if (!selectedNodeId) return;
|
|
const node = nodes.find((n) => n.id === selectedNodeId);
|
|
if (node) {
|
|
copiedNodes = [JSON.parse(JSON.stringify(node))];
|
|
}
|
|
}
|
|
|
|
function pasteNodes() {
|
|
if (copiedNodes.length === 0) return;
|
|
|
|
pushState();
|
|
const newNodes: Node[] = [];
|
|
const center = screenToWorld(
|
|
containerElement.clientWidth / 2,
|
|
containerElement.clientHeight / 2
|
|
);
|
|
|
|
copiedNodes.forEach((copiedNode, index) => {
|
|
const id = 'n-' + Math.random().toString(36).slice(2, 11);
|
|
const offsetX = (index % 3) * 60 - 60;
|
|
const offsetY = Math.floor(index / 3) * 60 - 60;
|
|
|
|
const newNode: Node = {
|
|
...copiedNode,
|
|
id,
|
|
x: center.x + offsetX,
|
|
y: center.y + offsetY,
|
|
};
|
|
newNodes.push(newNode);
|
|
});
|
|
|
|
nodes = [...nodes, ...newNodes];
|
|
selectedNodeId = newNodes[0]?.id || null;
|
|
saveToLocalStorage();
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
e.preventDefault();
|
|
showSearch = true;
|
|
setTimeout(() => searchInput?.focus(), 10);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
|
e.preventDefault();
|
|
copySelectedNode();
|
|
return;
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
|
e.preventDefault();
|
|
pasteNodes();
|
|
return;
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
e.preventDefault();
|
|
showSearch = true;
|
|
setTimeout(() => searchInput?.focus(), 10);
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Escape' && showSearch) {
|
|
showSearch = false;
|
|
searchQuery = '';
|
|
return;
|
|
}
|
|
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeId) {
|
|
deleteSelected();
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
|
e.preventDefault();
|
|
undo();
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
|
|
e.preventDefault();
|
|
redo();
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
if (!loadFromUrl()) {
|
|
loadFromLocalStorage();
|
|
}
|
|
});
|
|
|
|
$: if (showAddModal && addNodeInput) {
|
|
setTimeout(() => addNodeInput?.focus(), 10);
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="flex h-full flex-col bg-neutral-900/50 rounded-xl border border-neutral-800 overflow-hidden relative"
|
|
bind:this={containerElement}
|
|
>
|
|
<div class="absolute top-4 left-4 z-10 flex flex-col gap-2 pointer-events-none">
|
|
<div
|
|
class="bg-neutral-900/70 backdrop-blur border border-neutral-800 rounded-lg p-2 pointer-events-auto shadow-lg"
|
|
>
|
|
<div class="flex flex-col gap-1">
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Add Node"
|
|
on:click={() => (showAddModal = true)}
|
|
>
|
|
<Plus size={18} />
|
|
</button>
|
|
<div class="h-px bg-neutral-800 my-1"></div>
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Import Graph"
|
|
on:click={importGraph}
|
|
>
|
|
<Upload size={18} />
|
|
</button>
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Export JSON"
|
|
on:click={exportGraph}
|
|
>
|
|
<Download size={18} />
|
|
</button>
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Export PNG"
|
|
on:click={exportPNG}
|
|
>
|
|
<ImageIcon size={18} />
|
|
</button>
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Share Link"
|
|
on:click={shareGraph}
|
|
>
|
|
<Share2 size={18} />
|
|
</button>
|
|
<div class="h-px bg-neutral-800 my-1"></div>
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Undo (Ctrl+Z)"
|
|
on:click={undo}
|
|
disabled={undoStack.length === 0}
|
|
class:opacity-50={undoStack.length === 0}
|
|
>
|
|
<Undo2 size={18} />
|
|
</button>
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Redo (Ctrl+Y)"
|
|
on:click={redo}
|
|
disabled={redoStack.length === 0}
|
|
class:opacity-50={redoStack.length === 0}
|
|
>
|
|
<Redo2 size={18} />
|
|
</button>
|
|
<div class="h-px bg-neutral-800 my-1"></div>
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Fit to Screen"
|
|
on:click={centerView}
|
|
>
|
|
<Maximize size={18} />
|
|
</button>
|
|
<button
|
|
class="p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white"
|
|
title="Clear Graph"
|
|
on:click={clearGraph}
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if showSearch}
|
|
<div class="absolute top-4 left-1/2 -translate-x-1/2 z-20 pointer-events-none">
|
|
<div
|
|
class="bg-neutral-900/90 backdrop-blur border border-neutral-800 rounded-lg p-3 pointer-events-auto shadow-lg min-w-[300px]"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<Search size={16} class="text-neutral-400" />
|
|
<input
|
|
bind:this={searchInput}
|
|
type="text"
|
|
bind:value={searchQuery}
|
|
placeholder="Search nodes and links... (Ctrl+F)"
|
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm text-neutral-100 placeholder-neutral-500 focus:border-rose-500 focus:outline-none"
|
|
on:keydown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
showSearch = false;
|
|
searchQuery = '';
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
class="text-neutral-400 hover:text-white p-1"
|
|
on:click={() => {
|
|
showSearch = false;
|
|
searchQuery = '';
|
|
}}
|
|
title="Close (Esc)"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
{#if searchQuery.trim()}
|
|
<div class="mt-2 text-xs text-neutral-400">
|
|
Found {filteredNodes.length} node{filteredNodes.length !== 1 ? 's' : ''} and {filteredLinks.length}
|
|
link{filteredLinks.length !== 1 ? 's' : ''}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="absolute bottom-4 right-4 z-10 pointer-events-none">
|
|
<div
|
|
class="bg-neutral-900/70 backdrop-blur border border-neutral-800 rounded-lg pointer-events-auto shadow-lg"
|
|
>
|
|
<button
|
|
class="w-full flex items-center justify-between px-3 py-2 text-[10px] text-neutral-500 uppercase tracking-wider font-semibold hover:text-neutral-400 transition-colors"
|
|
on:click={() => (controlsCollapsed = !controlsCollapsed)}
|
|
>
|
|
<span>Controls</span>
|
|
{#if controlsCollapsed}
|
|
<ChevronUp size={14} />
|
|
{:else}
|
|
<ChevronDown size={14} />
|
|
{/if}
|
|
</button>
|
|
{#if !controlsCollapsed}
|
|
<div class="px-3 pb-3">
|
|
<div class="text-xs text-neutral-300 space-y-1">
|
|
<div class="flex justify-between gap-4">
|
|
<span>Pan</span> <span class="text-neutral-500">Left/Middle Drag</span>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<span>Zoom</span> <span class="text-neutral-500">Scroll Wheel</span>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<span>Select</span> <span class="text-neutral-500">Click Node</span>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<span>Link</span> <span class="text-neutral-500">Shift + Drag Node</span>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<span>Copy</span> <span class="text-neutral-500">Ctrl + C</span>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<span>Paste</span> <span class="text-neutral-500">Ctrl + V</span>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<span>Search</span> <span class="text-neutral-500">Ctrl + F</span>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<span>Delete</span> <span class="text-neutral-500">Del Key</span>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<span>Undo</span> <span class="text-neutral-500">Ctrl + Z</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
|
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
|
<div
|
|
class="flex-1 w-full h-full cursor-default bg-neutral-950 outline-none"
|
|
on:mousedown={handleMouseDown}
|
|
on:wheel={handleWheel}
|
|
on:mousemove={handleMouseMove}
|
|
on:mouseup={handleMouseUp}
|
|
on:mouseleave={handleMouseUp}
|
|
tabindex="0"
|
|
on:keydown={handleKeydown}
|
|
role="application"
|
|
aria-label="Identity Graph Canvas"
|
|
aria-roledescription="Interactive graph canvas"
|
|
>
|
|
<svg bind:this={svgElement} class="w-full h-full block">
|
|
<defs>
|
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#262626" stroke-width="1" />
|
|
</pattern>
|
|
</defs>
|
|
|
|
<g
|
|
transform="translate({transform.x % (40 * transform.k)}, {transform.y %
|
|
(40 * transform.k)}) scale({transform.k})"
|
|
>
|
|
<rect
|
|
x={-containerElement?.clientWidth / transform.k || -1000}
|
|
y={-containerElement?.clientHeight / transform.k || -1000}
|
|
width="400%"
|
|
height="400%"
|
|
fill="url(#grid)"
|
|
/>
|
|
</g>
|
|
|
|
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
|
|
{#each filteredLinks as link (link.id)}
|
|
{@const source = nodes.find((n) => n.id === link.source)}
|
|
{@const target = nodes.find((n) => n.id === link.target)}
|
|
{@const isMatch =
|
|
searchQuery.trim() &&
|
|
(link.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(source && source.label.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
|
(target && target.label.toLowerCase().includes(searchQuery.toLowerCase())))}
|
|
{#if source && target}
|
|
<g class="group">
|
|
<line
|
|
x1={source.x}
|
|
y1={source.y}
|
|
x2={target.x}
|
|
y2={target.y}
|
|
stroke={isMatch ? '#ef4444' : '#525252'}
|
|
stroke-width={isMatch ? '3' : '2'}
|
|
stroke-opacity={isMatch ? '0.8' : '1'}
|
|
class="transition-colors group-hover:stroke-neutral-300"
|
|
/>
|
|
{#if editingLinkId === link.id}
|
|
{@const labelWidth = Math.max(80, editingLinkLabel.length * 6 + 16)}
|
|
<rect
|
|
x={(source.x + target.x) / 2 - labelWidth / 2}
|
|
y={(source.y + target.y) / 2 - 10}
|
|
width={labelWidth}
|
|
height="20"
|
|
rx="4"
|
|
fill="#171717"
|
|
stroke="#ef4444"
|
|
stroke-width="1"
|
|
/>
|
|
<foreignObject
|
|
x={(source.x + target.x) / 2 - labelWidth / 2}
|
|
y={(source.y + target.y) / 2 - 10}
|
|
width={labelWidth}
|
|
height="20"
|
|
>
|
|
<!-- svelte-ignore a11y-autofocus -->
|
|
<input
|
|
bind:this={linkEditInput}
|
|
type="text"
|
|
value={editingLinkLabel}
|
|
on:input={handleLinkLabelInput}
|
|
on:keydown={(e) => {
|
|
if (e.key === 'Enter') saveLinkEdit();
|
|
if (e.key === 'Escape') cancelLinkEdit();
|
|
}}
|
|
on:blur={saveLinkEdit}
|
|
class="w-full h-full bg-transparent border-none outline-none text-[10px] text-neutral-200 font-mono px-2 text-center"
|
|
style="color: rgb(229 231 235);"
|
|
/>
|
|
</foreignObject>
|
|
{:else}
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<rect
|
|
x={(source.x + target.x) / 2 - (link.label.length * 3 + 4)}
|
|
y={(source.y + target.y) / 2 - 8}
|
|
width={link.label.length * 6 + 8}
|
|
height="16"
|
|
rx="4"
|
|
fill="#171717"
|
|
class="cursor-pointer"
|
|
role="button"
|
|
tabindex="0"
|
|
on:dblclick={(e) => startEditingLink(link.id, e)}
|
|
on:keydown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
startEditingLink(link.id, e as unknown as MouseEvent);
|
|
}
|
|
}}
|
|
/>
|
|
<text
|
|
x={(source.x + target.x) / 2}
|
|
y={(source.y + target.y) / 2}
|
|
dy="3"
|
|
text-anchor="middle"
|
|
class="text-[10px] fill-neutral-400 font-mono pointer-events-none select-none"
|
|
>
|
|
{link.label}
|
|
</text>
|
|
{/if}
|
|
</g>
|
|
{/if}
|
|
{/each}
|
|
|
|
{#if isLinking && linkStartNodeId}
|
|
{@const start = nodes.find((n) => n.id === linkStartNodeId)}
|
|
{#if start}
|
|
<line
|
|
x1={start.x}
|
|
y1={start.y}
|
|
x2={mouseWorld.x}
|
|
y2={mouseWorld.y}
|
|
stroke="#ef4444"
|
|
stroke-width="2"
|
|
stroke-dasharray="4"
|
|
class="pointer-events-none opacity-50"
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#each nodes as node (node.id)}
|
|
{@const isMatch =
|
|
searchQuery.trim() &&
|
|
(node.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
node.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(node.notes && node.notes.toLowerCase().includes(searchQuery.toLowerCase())))}
|
|
{@const isVisible = !searchQuery.trim() || isMatch}
|
|
{#if isVisible}
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<g
|
|
transform="translate({node.x}, {node.y})"
|
|
role="button"
|
|
tabindex="0"
|
|
on:mousedown={(e) => handleNodeMouseDown(e, node.id)}
|
|
on:mouseenter={() => (hoverNodeId = node.id)}
|
|
on:mouseleave={() => (hoverNodeId = null)}
|
|
on:keydown={(e) => e.key === 'Enter' && (selectedNodeId = node.id)}
|
|
class="cursor-pointer"
|
|
opacity={searchQuery.trim() && !isMatch ? '0.2' : '1'}
|
|
>
|
|
{#if selectedNodeId === node.id}
|
|
<circle
|
|
r="28"
|
|
fill="none"
|
|
stroke="#fff"
|
|
stroke-width="2"
|
|
stroke-opacity="0.2"
|
|
class="animate-pulse"
|
|
/>
|
|
{/if}
|
|
{#if isMatch && searchQuery.trim()}
|
|
<circle
|
|
r="30"
|
|
fill="none"
|
|
stroke="#ef4444"
|
|
stroke-width="3"
|
|
stroke-opacity="0.5"
|
|
class="animate-pulse"
|
|
/>
|
|
{/if}
|
|
|
|
{#if node.imageUrl}
|
|
<defs>
|
|
<clipPath id={`node-clip-${node.id}`}>
|
|
<circle r="24" cx="0" cy="0" />
|
|
</clipPath>
|
|
</defs>
|
|
<image
|
|
href={node.imageUrl}
|
|
x="-24"
|
|
y="-24"
|
|
width="48"
|
|
height="48"
|
|
preserveAspectRatio="xMidYMid slice"
|
|
clip-path={`url(#node-clip-${node.id})`}
|
|
/>
|
|
<circle
|
|
r="24"
|
|
fill="transparent"
|
|
stroke={typeColors[node.type]}
|
|
stroke-width={selectedNodeId === node.id ? 3 : 2}
|
|
class="transition-all shadow-lg"
|
|
/>
|
|
{:else}
|
|
<circle
|
|
r="24"
|
|
fill="#171717"
|
|
stroke={typeColors[node.type]}
|
|
stroke-width={selectedNodeId === node.id ? 3 : 2}
|
|
class={'transition-all shadow-lg ' +
|
|
(node.showType !== false ? 'hover:brightness-110' : '')}
|
|
/>
|
|
|
|
{#if node.showType !== false}
|
|
<g transform="translate(-12, -12)">
|
|
<svelte:component
|
|
this={iconMap[node.type]}
|
|
size={24}
|
|
color={typeColors[node.type]}
|
|
/>
|
|
</g>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if node.showType !== false && node.imageUrl}
|
|
<g transform="translate(16, 16)">
|
|
<circle r="10" fill="#0a0a0a" stroke={typeColors[node.type]} stroke-width="2" />
|
|
<g transform="translate(-6, -6)">
|
|
<svelte:component
|
|
this={iconMap[node.type]}
|
|
size={12}
|
|
color={typeColors[node.type]}
|
|
/>
|
|
</g>
|
|
</g>
|
|
{/if}
|
|
|
|
{#if node.showLabel !== false}
|
|
<g transform="translate(0, 36)">
|
|
<rect
|
|
x={-(node.label.length * 3.5 + 6)}
|
|
y="-10"
|
|
width={node.label.length * 7 + 12}
|
|
height="20"
|
|
rx="4"
|
|
fill="#000000"
|
|
fill-opacity="0.5"
|
|
/>
|
|
<text
|
|
text-anchor="middle"
|
|
dy="4"
|
|
class="text-[11px] fill-neutral-200 font-medium select-none pointer-events-none"
|
|
>
|
|
{node.label}
|
|
</text>
|
|
</g>
|
|
{/if}
|
|
|
|
{#if node.showNotes !== false && node.notes}
|
|
{@const noteSnippet =
|
|
node.notes.length > 42 ? `${node.notes.slice(0, 42)}…` : node.notes}
|
|
<g transform="translate(0, 62)">
|
|
<rect
|
|
x={-90}
|
|
y="-10"
|
|
width="180"
|
|
height="22"
|
|
rx="6"
|
|
fill="#1d1d1d"
|
|
stroke="rgba(251,191,36,0.3)"
|
|
/>
|
|
<text
|
|
text-anchor="middle"
|
|
dy="4"
|
|
class="text-[10px] fill-amber-200 font-mono select-none pointer-events-none"
|
|
>
|
|
{noteSnippet}
|
|
</text>
|
|
</g>
|
|
{/if}
|
|
</g>
|
|
{/if}
|
|
{/each}
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
{#if selectedNode}
|
|
<div
|
|
class="absolute top-0 right-0 h-full w-full max-w-sm border-l border-neutral-900 bg-neutral-950/85 backdrop-blur pointer-events-auto z-20"
|
|
>
|
|
<div class="flex items-center justify-between border-b border-neutral-900 px-4 py-3">
|
|
<div>
|
|
<div class="text-xs uppercase tracking-[0.3em] text-neutral-500">Entity</div>
|
|
<div class="text-lg font-semibold text-white">{selectedNode.label || 'Untitled'}</div>
|
|
</div>
|
|
<button
|
|
class="rounded-lg border border-neutral-800 p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-800"
|
|
on:click={closeInspector}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
<div class="flex h-[calc(100%-64px)] flex-col overflow-y-auto px-4 py-4 gap-4">
|
|
<div class="flex items-center gap-3">
|
|
{#if selectedNode.imageUrl}
|
|
<img
|
|
src={selectedNode.imageUrl}
|
|
alt={selectedNode.label}
|
|
class="h-16 w-16 rounded-2xl border border-neutral-800 object-cover shadow-lg"
|
|
/>
|
|
{:else}
|
|
<div
|
|
class="h-16 w-16 rounded-2xl border border-neutral-800 bg-neutral-900 flex items-center justify-center shadow-lg"
|
|
>
|
|
<svelte:component
|
|
this={iconMap[selectedNode.type]}
|
|
size={28}
|
|
color={typeColors[selectedNode.type]}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<div class="text-xs uppercase tracking-[0.3em] text-neutral-500">Type</div>
|
|
<div class="text-sm font-semibold text-neutral-200 capitalize">{selectedNode.type}</div>
|
|
{#if selectedNode.notes}
|
|
<div class="mt-1 text-xs text-amber-200 break-words">{selectedNode.notes}</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label
|
|
for="inspector-label-input"
|
|
class="block text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-1"
|
|
>Label</label
|
|
>
|
|
<input
|
|
id="inspector-label-input"
|
|
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm text-gray-100 placeholder-neutral-600 focus:border-rose-500 focus:outline-none"
|
|
value={editLabel}
|
|
on:input={handleLabelInputEvent}
|
|
placeholder="Entity Label"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-1">
|
|
Type
|
|
</div>
|
|
<div class="grid grid-cols-4 gap-2">
|
|
{#each nodeTypes as type}
|
|
<button
|
|
class={'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[11px] capitalize transition ' +
|
|
(editType === type
|
|
? 'border-rose-500 bg-rose-500/10 text-rose-200'
|
|
: 'border-neutral-800 bg-neutral-900 text-neutral-400 hover:border-neutral-700')}
|
|
type="button"
|
|
on:click={() => handleTypeSelect(type)}
|
|
>
|
|
<svelte:component this={iconMap[type]} size={18} color={typeColors[type]} />
|
|
{type}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label
|
|
for="inspector-image-input"
|
|
class="block text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-1"
|
|
>Custom Image URL</label
|
|
>
|
|
<input
|
|
id="inspector-image-input"
|
|
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm text-gray-100 placeholder-neutral-600 focus:border-rose-500 focus:outline-none"
|
|
value={editImageUrl}
|
|
on:input={handleImageUrlInputEvent}
|
|
placeholder="https://..."
|
|
/>
|
|
<div class="mt-2 flex gap-2 flex-wrap">
|
|
<button
|
|
class="rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800"
|
|
type="button"
|
|
on:click={triggerImageUpload}
|
|
>
|
|
Upload Image
|
|
</button>
|
|
{#if selectedNode?.imageUrl}
|
|
<button
|
|
class="rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800"
|
|
type="button"
|
|
on:click={clearImage}
|
|
>
|
|
Remove Image
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
<input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
class="hidden"
|
|
bind:this={imageUploadInput}
|
|
on:change={handleImageFileSelected}
|
|
/>
|
|
<p class="mt-1 text-[11px] text-neutral-500">
|
|
Paste a URL or upload a PNG/JPEG/WebP (2MB max).
|
|
</p>
|
|
{#if imageUploadError}
|
|
<p class="mt-1 text-[11px] text-rose-300">{imageUploadError}</p>
|
|
{/if}
|
|
</div>
|
|
<div>
|
|
<label
|
|
for="inspector-notes-input"
|
|
class="block text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-1"
|
|
>Notes</label
|
|
>
|
|
<textarea
|
|
id="inspector-notes-input"
|
|
rows="4"
|
|
class="w-full rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm text-gray-100 placeholder-neutral-600 focus:border-rose-500 focus:outline-none resize-none"
|
|
value={editNotes}
|
|
on:input={handleNotesInputEvent}
|
|
placeholder="Add analyst notes, identifiers, context..."
|
|
></textarea>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-2">
|
|
Display Options
|
|
</div>
|
|
<div class="space-y-2">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={editShowLabel}
|
|
on:change={toggleShowLabel}
|
|
class="rounded border-neutral-700 bg-neutral-800 text-rose-500 focus:ring-rose-500"
|
|
/>
|
|
<span class="text-sm text-neutral-300">Show Label</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={editShowType}
|
|
on:change={toggleShowType}
|
|
class="rounded border-neutral-700 bg-neutral-800 text-rose-500 focus:ring-rose-500"
|
|
/>
|
|
<span class="text-sm text-neutral-300">Show Type Icon</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={editShowNotes}
|
|
on:change={toggleShowNotes}
|
|
class="rounded border-neutral-700 bg-neutral-800 text-rose-500 focus:ring-rose-500"
|
|
/>
|
|
<span class="text-sm text-neutral-300">Show Notes</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border border-neutral-900 rounded-xl bg-neutral-900/60 p-3">
|
|
<div class="text-xs uppercase tracking-[0.3em] text-neutral-500 mb-2">Connections</div>
|
|
{#if connectedEdges.length > 0}
|
|
<div class="space-y-2">
|
|
{#each connectedEdges as edge}
|
|
<div class="rounded-lg border border-neutral-800 bg-neutral-900 px-3 py-2">
|
|
<div class="text-sm font-semibold text-neutral-100">
|
|
{edge.otherNode?.label || 'Unknown entity'}
|
|
</div>
|
|
<div class="text-xs text-neutral-500 capitalize">
|
|
{edge.otherNode?.type || 'entity'} • {edge.label}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="text-xs text-neutral-500">
|
|
No linked entities yet. Shift + drag from this node to create relationships.
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-auto pt-2 text-[11px] text-neutral-500 text-center">
|
|
Changes are applied and saved automatically.
|
|
</div>
|
|
<button
|
|
class="rounded-lg border border-rose-900/40 bg-rose-900/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-900/20 transition"
|
|
on:click={deleteSelected}
|
|
>
|
|
Delete Entity
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showAddModal}
|
|
<!-- svelte-ignore a11y-interactive-supports-focus -->
|
|
<div
|
|
class="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
|
on:click|self={() => (showAddModal = false)}
|
|
on:keydown={(e) => e.key === 'Escape' && (showAddModal = false)}
|
|
role="dialog"
|
|
tabindex="-1"
|
|
aria-modal="true"
|
|
aria-label="Add Entity"
|
|
>
|
|
<div
|
|
class="w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-900 p-6 shadow-2xl"
|
|
>
|
|
<h4 class="text-lg font-semibold text-gray-100 mb-4">Add Entity</h4>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label for="nodeLabel" class="block text-xs font-medium text-neutral-400 mb-1"
|
|
>Label / Name</label
|
|
>
|
|
<!-- svelte-ignore a11y-autofocus -->
|
|
<input
|
|
bind:this={addNodeInput}
|
|
id="nodeLabel"
|
|
class="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-gray-100 focus:border-rose-500 focus:outline-none placeholder-neutral-600"
|
|
placeholder="e.g. John Doe"
|
|
bind:value={newNodeLabel}
|
|
on:keydown={(e) => e.key === 'Enter' && addNode()}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label for="nodeType" class="block text-xs font-medium text-neutral-400 mb-1"
|
|
>Type</label
|
|
>
|
|
<div class="grid grid-cols-4 gap-2">
|
|
{#each nodeTypes as type}
|
|
<button
|
|
class={'flex flex-col items-center justify-center gap-1 rounded-lg border p-2 transition ' +
|
|
(newNodeType === type
|
|
? 'border-rose-500 bg-rose-500/10'
|
|
: 'border-neutral-800 bg-neutral-800 hover:border-neutral-700')}
|
|
on:click={() => (newNodeType = type)}
|
|
>
|
|
<svelte:component this={iconMap[type]} size={20} color={typeColors[type]} />
|
|
<span class="text-[10px] text-neutral-400 capitalize">{type}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label for="nodeImage" class="block text-xs font-medium text-neutral-400 mb-1"
|
|
>Custom Image URL</label
|
|
>
|
|
<input
|
|
id="nodeImage"
|
|
class="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-gray-100 focus:border-rose-500 focus:outline-none placeholder-neutral-600"
|
|
placeholder="https://..."
|
|
bind:value={newNodeImage}
|
|
/>
|
|
<div class="mt-2 flex gap-2 flex-wrap">
|
|
<button
|
|
class="rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-700"
|
|
type="button"
|
|
on:click={triggerNewNodeImageUpload}
|
|
>
|
|
Upload Image
|
|
</button>
|
|
{#if newNodeImage}
|
|
<button
|
|
class="rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-700"
|
|
type="button"
|
|
on:click={clearNewNodeImage}
|
|
>
|
|
Remove
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
<input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
class="hidden"
|
|
bind:this={modalImageUploadInput}
|
|
on:change={handleNewNodeImageFileSelected}
|
|
/>
|
|
<p class="mt-1 text-[11px] text-neutral-500">
|
|
Paste a URL or upload a PNG/JPEG/WebP (2MB max).
|
|
</p>
|
|
{#if newNodeImageError}
|
|
<p class="mt-1 text-[11px] text-rose-300">{newNodeImageError}</p>
|
|
{/if}
|
|
</div>
|
|
<div>
|
|
<label for="nodeNotes" class="block text-xs font-medium text-neutral-400 mb-1"
|
|
>Notes</label
|
|
>
|
|
<textarea
|
|
id="nodeNotes"
|
|
rows="3"
|
|
class="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-gray-100 focus:border-rose-500 focus:outline-none placeholder-neutral-600 resize-none"
|
|
placeholder="Short intel notes, context, identifiers..."
|
|
bind:value={newNodeNotes}
|
|
></textarea>
|
|
</div>
|
|
<div class="flex justify-end gap-2 mt-4">
|
|
<button
|
|
class="rounded-lg px-3 py-2 text-sm text-neutral-400 hover:text-white"
|
|
on:click={() => (showAddModal = false)}>Cancel</button
|
|
>
|
|
<button
|
|
class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 shadow-lg shadow-rose-900/20"
|
|
on:click={addNode}>Add Entity</button
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|