mirror of
https://github.com/mossy1022/Smart-Connections-Visualizer.git
synced 2026-07-22 10:20:27 +00:00
The code changes include the implementation of selection state on click, adding highlighted and selected properties to nodes, and implementing multiselect selection box functionality by holding down control and dragging down and to the right. Can multi-drag - dragging a single selected node will drag all the selected
1578 lines
56 KiB
TypeScript
1578 lines
56 KiB
TypeScript
import { App, Plugin, PluginSettingTab, Setting, ItemView, WorkspaceLeaf, TFile } from 'obsidian';
|
|
import * as d3 from "d3";
|
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
|
|
|
interface MyPluginSettings {
|
|
mySetting: string;
|
|
}
|
|
|
|
const DEFAULT_SETTINGS: MyPluginSettings = {
|
|
mySetting: 'default'
|
|
}
|
|
|
|
const DEFAULT_NETWORK_SETTINGS : any = {
|
|
scoreThreshold: 0.8,
|
|
nodeSize: 3,
|
|
linkThickness: 0.3,
|
|
repelForce: 400,
|
|
linkForce: 0.4,
|
|
linkDistance: 70,
|
|
centerForce: 0.40
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
SmartSearch: any;
|
|
}
|
|
}
|
|
|
|
class MyItemView extends ItemView {
|
|
|
|
currentNoteKey: string; // Define the currentNoteKey property
|
|
centralNote: any;
|
|
centralNode: any;
|
|
connectionType = 'block';
|
|
isHovering: boolean; // Add a flag to track hover state
|
|
significanceScoreThreshold = 0.8;
|
|
settingsInstantiated = false;
|
|
nodeSize = 3;
|
|
linkThickness = 0.3;
|
|
repelForce = 400;
|
|
linkForce = 0.4;
|
|
linkDistance = 70;
|
|
centerForce = 0.40;
|
|
textFadeThreshold = 1.1;
|
|
minScore = 1;
|
|
maxScore = 0;
|
|
minNodeSize = 3;
|
|
maxNodeSize = 6;
|
|
minLinkThickness = 0.3;
|
|
maxLinkThickness = 4;
|
|
nodeSelection : any;
|
|
linkSelection : any;
|
|
labelSelection : any;
|
|
updatingVisualization: boolean; // Add this flag
|
|
isCtrlPressed = false;
|
|
isAltPressed = false;
|
|
isDragging = false;
|
|
isChangingConnectionType = true;
|
|
selectionBox: any;
|
|
validatedLinks : any;
|
|
|
|
// selection box origin point
|
|
startX = 0;
|
|
startY = 0;
|
|
|
|
constructor(leaf: WorkspaceLeaf) {
|
|
super(leaf);
|
|
this.currentNoteKey = '';
|
|
this.isHovering = false;
|
|
|
|
}
|
|
|
|
getViewType(): string {
|
|
return "My Item View";
|
|
}
|
|
|
|
getDisplayText(): string {
|
|
return "My Item View";
|
|
}
|
|
|
|
getIcon(): string {
|
|
return "dice";
|
|
}
|
|
|
|
// Function to update node appearance based on selection and highlight state
|
|
updateNodeAppearance() {
|
|
this.nodeSelection.transition().duration(500)
|
|
.attr('fill', (d: any) => {
|
|
if (d.id === this.centralNode.id) {
|
|
return '#7c8594';
|
|
} else if (d.selected) {
|
|
console.log('selected appearance');
|
|
return '#f3ee5d';
|
|
} else if (d.highlighted) {
|
|
return '#d46ebe';
|
|
} else {
|
|
return d.group === 'note' ? '#7c8594' : '#926ec9';
|
|
}
|
|
})
|
|
.attr('stroke', (d: any) => d.selected ? '#ff0' : 'transparent')
|
|
.attr('stroke-width', (d: any) => d.selected ? 2 : 0.3)
|
|
.attr('opacity', (d: any) => {
|
|
if (d.id === this.centralNode.id) {
|
|
return 1;
|
|
} else if (d.selected || d.highlighted) {
|
|
return 1;
|
|
} else if (this.isHovering) {
|
|
console.log('opacity low');
|
|
return 0.1;
|
|
} else {
|
|
console.log('opacity up');
|
|
return 1;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Function to toggle node selection
|
|
toggleNodeSelection(nodeId: string) {
|
|
const node = this.nodeSelection.data().find((d: any) => d.id === nodeId);
|
|
if (node) {
|
|
node.selected = !node.selected;
|
|
this.updateNodeAppearance();
|
|
}
|
|
}
|
|
|
|
// Clear all selections
|
|
clearSelections() {
|
|
this.nodeSelection.each((d: any) => d.selected = false);
|
|
this.updateNodeAppearance();
|
|
}
|
|
|
|
highlightNode(node: any) {
|
|
this.nodeSelection.each((d: any) => {
|
|
if (d.id !== this.centralNode.id) {
|
|
d.highlighted = (d.id === node.id || this.validatedLinks.some((link: any) =>
|
|
(link.source.id === node.id && link.target.id === d.id) ||
|
|
(link.target.id === node.id && link.source.id === d.id)));
|
|
}
|
|
});
|
|
this.updateNodeAppearance();
|
|
|
|
this.linkSelection.transition().duration(500)
|
|
.attr('opacity', (d: any) => (d.source.id === node.id || d.target.id === node.id) ? 1 : 0.1);
|
|
|
|
this.labelSelection.transition().duration(500)
|
|
.attr('opacity', (d: any) =>
|
|
(d.id === node.id || this.validatedLinks.some((link: any) =>
|
|
(link.source.id === node.id && link.target.id === d.id) ||
|
|
(link.target.id === node.id && link.source.id === d.id))) ? 1 : 0.1);
|
|
}
|
|
|
|
|
|
unhighlightNode() {
|
|
this.nodeSelection.each((d: any) => {
|
|
if (d.id !== this.centralNode.id && !d.selected) {
|
|
d.highlighted = false;
|
|
}
|
|
});
|
|
this.updateNodeAppearance();
|
|
|
|
this.linkSelection.transition().duration(500).attr('opacity', 1);
|
|
this.labelSelection.transition().duration(500).attr('opacity', 1);
|
|
}
|
|
|
|
|
|
|
|
async onOpen() {
|
|
|
|
setTimeout(() => {
|
|
|
|
// To be used for functions that dont have this in the scope
|
|
let that = this;
|
|
|
|
const smartNotes = window.SmartSearch.main.env.smart_notes.items;
|
|
|
|
if (Object.keys(smartNotes).length === 0) {
|
|
console.log("No smart notes found.");
|
|
return;
|
|
}
|
|
|
|
console.log('SmartNotes:', smartNotes);
|
|
|
|
let nodes: any[] = [];
|
|
let links: any[] = [];
|
|
let connections: any[] = [];
|
|
|
|
|
|
function validateGraphData(nodes: any[], links: any[]): boolean {
|
|
const nodeIds = new Set(nodes.map(node => node.id));
|
|
let isValid = true;
|
|
|
|
links.forEach((link, index) => {
|
|
if (!nodeIds.has(link.source)) {
|
|
console.error(`Link at index ${index} has an invalid source: ${link.source}`);
|
|
isValid = false;
|
|
}
|
|
if (!nodeIds.has(link.target)) {
|
|
console.error(`Link at index ${index} has an invalid target: ${link.target}`);
|
|
isValid = false;
|
|
}
|
|
});
|
|
|
|
nodes.forEach((node, index) => {
|
|
if (!node.hasOwnProperty('id') || !node.hasOwnProperty('name') || !node.hasOwnProperty('group')) {
|
|
console.error(`Node at index ${index} is missing required properties: ${JSON.stringify(node)}`);
|
|
isValid = false;
|
|
}
|
|
});
|
|
|
|
return isValid;
|
|
}
|
|
|
|
const updateConnections = () => {
|
|
|
|
// Reset Variables
|
|
nodes = [];
|
|
links = [];
|
|
connections = [];
|
|
this.minScore = 1;
|
|
this.maxScore = 0;
|
|
|
|
// Dont bother executing if we don't have a note to find connections for
|
|
if (!this.currentNoteKey) return;
|
|
|
|
this.centralNote = smartNotes[this.currentNoteKey];
|
|
const noteConnections = this.centralNote.find_connections().filter(
|
|
(connection: any) => connection.score >= this.significanceScoreThreshold);
|
|
|
|
console.log('central note: ', this.centralNote);
|
|
console.log('note connections: ', this.centralNote.find_connections());
|
|
|
|
// Add the note node itself
|
|
if (this.centralNote.key && this.centralNote.key.trim() !== '' && !nodes.some(node => node.id === this.centralNote.key)) {
|
|
nodes.push({
|
|
id: this.centralNote.key,
|
|
name: this.centralNote.key,
|
|
group: 'note',
|
|
x: Math.random() * 1000,
|
|
y: Math.random() * 1000,
|
|
fx: null,
|
|
fy: null,
|
|
selected: false, // Add selected property
|
|
highlighted : false
|
|
|
|
});
|
|
|
|
// Assign central node - 1st element in the array so far
|
|
this.centralNode = nodes[0];
|
|
|
|
}
|
|
|
|
// Filter connections based on the selected connection type
|
|
const filteredConnections = noteConnections.filter((connection: any) => connection.__proto__.constructor.name === (this.connectionType === 'block' ? 'SmartBlock' : 'SmartNote'));
|
|
// Adding connections from blockConnections
|
|
|
|
filteredConnections.forEach((connection: any, index: any) => {
|
|
if (connection && connection.data && connection.data.key && connection.data.key.trim() !== '') {
|
|
const connectionId = connection.data.key;
|
|
|
|
if (!nodes.some(node => node.id === connectionId)) {
|
|
nodes.push({
|
|
id: connectionId,
|
|
name: connectionId,
|
|
group: 'block',
|
|
x: Math.random() * 1000,
|
|
y: Math.random() * 1000,
|
|
fx: null,
|
|
fy: null,
|
|
selected: false, // Add selected property
|
|
highlighted : false
|
|
});
|
|
}
|
|
|
|
links.push({
|
|
source: this.centralNote.key,
|
|
target: connectionId,
|
|
value: connection.score || 0 // Ensure score is defined
|
|
});
|
|
|
|
connections.push({
|
|
source: this.centralNote.key,
|
|
target: connectionId,
|
|
score: connection.score || 0 // Ensure score is defined
|
|
});
|
|
|
|
if((connection && connection.score) > this.maxScore) {
|
|
this.maxScore = connection.score;
|
|
}
|
|
|
|
if((connection && connection.score) < this.minScore) {
|
|
this.minScore = connection.score;
|
|
}
|
|
|
|
} else {
|
|
console.warn(`Skipping invalid connection at index ${index}:`, connection);
|
|
}
|
|
});
|
|
|
|
// TODO:: Remove eventually, but keep for now just in case we want this for some reason
|
|
// Adding Smart Blocks from the note itself
|
|
// const smartBlocks = note.blocks;
|
|
// smartBlocks.forEach((block: any) => {
|
|
// if (block.key && block.key.trim() !== '' && !nodes.some(node => node.id === block.key)) {
|
|
// nodes.push({
|
|
// id: block.key,
|
|
// name: block.key,
|
|
// group: 'block',
|
|
// x: Math.random() * 1000,
|
|
// y: Math.random() * 1000,
|
|
// fx: null,
|
|
// fy: null
|
|
// });
|
|
// }
|
|
|
|
// links.push({
|
|
// source: note.key,
|
|
// target: block.key,
|
|
// value: 0.95 // Ensure score is defined
|
|
// });
|
|
|
|
// connections.push({
|
|
// source: note.key,
|
|
// target: block.key,
|
|
// score: 0.95 // Ensure score is defined
|
|
// });
|
|
|
|
// if((connection && connection.score) > this.maxScore) {
|
|
// this.maxScore = connection.score;
|
|
// }
|
|
|
|
// });
|
|
|
|
console.log('Nodes after updateConnections:', nodes);
|
|
console.log('Links after updateConnections:', links);
|
|
console.log('Connections after updateConnections:', connections);
|
|
|
|
const isValid = validateGraphData(nodes, links);
|
|
if (!isValid) {
|
|
console.error('Graph data validation failed.');
|
|
} else {
|
|
console.log('Graph data validation passed.');
|
|
}
|
|
};
|
|
|
|
// Define the dimensions for the SVG
|
|
const width = this.contentEl.clientWidth;
|
|
const height = this.contentEl.clientHeight;
|
|
|
|
const svg = d3.select(this.contentEl)
|
|
.append('svg')
|
|
.attr('width', '100%')
|
|
.attr('height', '98%')
|
|
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
.call(d3.zoom()
|
|
.scaleExtent([0.1, 10])
|
|
.on('zoom', (event) => {
|
|
svgGroup.attr('transform', event.transform);
|
|
updateLabelOpacity(event.transform.k);
|
|
}));
|
|
|
|
const svgGroup = svg.append('g');
|
|
|
|
const startBoxSelection = (event: any) => {
|
|
if (!this.isCtrlPressed) return;
|
|
|
|
this.isDragging = true;
|
|
const [x, y] = d3.pointer(event);
|
|
|
|
const transform = d3.zoomTransform(svg.node() as Element);
|
|
|
|
this.selectionBox = svg.append('rect')
|
|
.attr('class', 'selection-box')
|
|
.attr('x', x)
|
|
.attr('y', y)
|
|
.attr('width', 0)
|
|
.attr('height', 0)
|
|
.attr('stroke', '#00f')
|
|
.attr('stroke-width', 1)
|
|
.attr('fill', 'rgba(0, 0, 255, 0.3)'); // Semi-transparent fill
|
|
|
|
// Store the initial click point adjusted by zoom and pan
|
|
this.startX = x;
|
|
this.startY = y;
|
|
};
|
|
|
|
const updateBoxSelection = (event: any) => {
|
|
if (!this.isDragging) return;
|
|
|
|
const [x, y] = d3.pointer(event);
|
|
|
|
const transform = d3.zoomTransform(svg.node() as Element);
|
|
|
|
const newWidth = x - this.startX;
|
|
const newHeight = y - this.startY;
|
|
|
|
this.selectionBox
|
|
.attr('width', Math.abs(newWidth))
|
|
.attr('height', Math.abs(newHeight))
|
|
.attr('x', Math.min(x, this.startX))
|
|
.attr('y', Math.min(y, this.startY));
|
|
|
|
const endX = this.startX + newWidth;
|
|
const endY = this.startY + newHeight;
|
|
|
|
// Ensure the coordinates account for drag direction
|
|
const transformedStartX = Math.min(this.startX, endX);
|
|
const transformedStartY = Math.min(this.startY, endY);
|
|
const transformedEndX = Math.max(this.startX, endX);
|
|
const transformedEndY = Math.max(this.startY, endY);
|
|
|
|
// Adjust the coordinates for zoom and pan
|
|
const zoomedStartX = (transformedStartX - transform.x) / transform.k;
|
|
const zoomedStartY = (transformedStartY - transform.y) / transform.k;
|
|
const zoomedEndX = (transformedEndX - transform.x) / transform.k;
|
|
const zoomedEndY = (transformedEndY - transform.y) / transform.k;
|
|
|
|
this.nodeSelection.each((d: any) => {
|
|
const nodeX = d.x;
|
|
const nodeY = d.y;
|
|
if (nodeX >= zoomedStartX && nodeX <= zoomedEndX && nodeY >= zoomedStartY && nodeY <= zoomedEndY) {
|
|
d.selected = true;
|
|
} else {
|
|
d.selected = false;
|
|
}
|
|
});
|
|
|
|
this.updateNodeAppearance();
|
|
};
|
|
|
|
const endBoxSelection = () => {
|
|
if (!this.isDragging) return;
|
|
this.isDragging = false;
|
|
|
|
this.selectionBox.remove();
|
|
};
|
|
|
|
|
|
// Attach mouse event listeners to the SVG
|
|
svg.on('mousedown', (event: any) => {
|
|
if (!event.ctrlKey) {
|
|
this.clearSelections();
|
|
}
|
|
startBoxSelection(event);
|
|
}).on('mousemove', updateBoxSelection)
|
|
.on('mouseup', endBoxSelection)
|
|
.on('click', function(event) {
|
|
if (!event.defaultPrevented && !event.ctrlKey) {
|
|
that.clearSelections();
|
|
}
|
|
});
|
|
|
|
|
|
// Listen for Alt/Option key press for individual node selection
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Alt' || event.key === 'AltGraph') {
|
|
this.isAltPressed = true;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keyup', (event) => {
|
|
if (event.key === 'Alt' || event.key === 'AltGraph') {
|
|
this.isAltPressed = false;
|
|
}
|
|
});
|
|
|
|
|
|
// Listen for control key press
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Control') {
|
|
this.isCtrlPressed = true;
|
|
svg.style('cursor', 'crosshair');
|
|
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keyup', (event) => {
|
|
if (event.key === 'Control') {
|
|
this.isCtrlPressed = false;
|
|
svg.style('cursor', 'default');
|
|
|
|
}
|
|
});
|
|
|
|
|
|
const updateLabelOpacity = (zoomLevel: number) => {
|
|
const maxOpacity = 1; // Maximum opacity
|
|
const minOpacity = 0; // Minimum opacity
|
|
const minZoom = 0.1; // Minimum zoom level where opacity is 0
|
|
const maxZoom = this.textFadeThreshold; // Use the text fade threshold value
|
|
|
|
// Calculate the new opacity based on the zoom level
|
|
let newOpacity = (zoomLevel - minZoom) / (maxZoom - minZoom);
|
|
|
|
// Ensure labels become invisible at lower zoom levels
|
|
if (zoomLevel <= minZoom) {
|
|
newOpacity = minOpacity;
|
|
} else if (zoomLevel >= maxZoom) {
|
|
newOpacity = maxOpacity;
|
|
} else {
|
|
// Calculate the new opacity based on the zoom level
|
|
newOpacity = (zoomLevel - minZoom) / (maxZoom - minZoom);
|
|
}
|
|
|
|
// Clamp the opacity between 0 and 1
|
|
newOpacity = Math.max(minOpacity, Math.min(maxOpacity, newOpacity));
|
|
|
|
// Debug statements
|
|
// console.log(`Zoom Level: ${zoomLevel}, New Opacity: ${newOpacity}, Fade Threshold: ${this.textFadeThreshold}`);
|
|
|
|
|
|
svgGroup.selectAll('.label')
|
|
.transition()
|
|
.duration(300)
|
|
.attr('opacity', newOpacity);
|
|
};
|
|
|
|
|
|
//TODO:: Refactor this
|
|
// Define a custom center force to center the main node
|
|
const customCenterForce = (alpha: number) => {
|
|
if (that.centralNode) {
|
|
that.centralNode.x += (width / 2 - that.centralNode.x) * this.centerForce * alpha;
|
|
that.centralNode.y += (height / 2 - that.centralNode.y) * this.centerForce * alpha;
|
|
}
|
|
};
|
|
|
|
// Function to create a custom center force for the main node
|
|
const customCenterForce2 = (centerX: number, centerY: number) => {
|
|
return () => {
|
|
nodes.forEach((node: any) => {
|
|
if (node.id === this.centralNode.id) {
|
|
node.x += (centerX - node.x) * 0.1;
|
|
node.y += (centerY - node.y) * 0.1;
|
|
}
|
|
});
|
|
};
|
|
};
|
|
|
|
// Function to normalize significance score and inverse it to have links with higher scores closer to the central node
|
|
function inverseNormalize(value : number) {
|
|
return 1 - ((value - that.minScore) / (that.maxScore - that.minScore));
|
|
}
|
|
|
|
const simulation = d3.forceSimulation()
|
|
.force('center', null) // Remove the default center force
|
|
.force('charge', d3.forceManyBody().strength(-DEFAULT_NETWORK_SETTINGS.repelForce)) // Note the negative value for repulsion
|
|
.force('link', d3.forceLink().id((d: any) => d.id).distance((d: any) => 50 + (inverseNormalize(d.score) * 100)).strength(1))
|
|
.force('x', d3.forceX(width / 2).strength(this.centerForce))
|
|
.force('y', d3.forceY(height / 2).strength(this.centerForce))
|
|
.force('collide', d3.forceCollide().radius(DEFAULT_NETWORK_SETTINGS.nodeSize + 3).strength(0.7)) // Adjust radius based on node size
|
|
.force('customCenter', customCenterForce2(width / 2, height / 2)) // Add custom centering force
|
|
// TODO:: Keep here for a while just in case
|
|
// .velocityDecay(0.4) // Increased damping factor for smoother transitions
|
|
// .alphaDecay(0.15) // Increased alpha decay for faster cooling down
|
|
// .alphaMin(0.02) // Lower minimum alpha value before stopping
|
|
// .alpha(0.3) // Initial alpha value
|
|
// .alphaTarget(0); // Target alpha value to reach zero and stabilize
|
|
|
|
updateConnections();
|
|
|
|
let link = svgGroup.append('g')
|
|
.attr('class', 'links')
|
|
.selectAll('line')
|
|
.data(links)
|
|
.enter().append('line')
|
|
.attr('stroke', 'blue')
|
|
.attr('stroke-width', 2)
|
|
.attr('stroke-opacity', 1);
|
|
|
|
let node = svgGroup.append('g')
|
|
.attr('class', 'nodes')
|
|
.selectAll('circle')
|
|
.data(nodes)
|
|
.enter().append('circle')
|
|
.attr('r', 20)
|
|
.attr('fill', 'blue')
|
|
.call(d3.drag()
|
|
.on('start', (event, d: any) => {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
})
|
|
.on('drag', (event, d: any) => {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
})
|
|
.on('end', (event, d: any) => {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
}));
|
|
|
|
let nodeLabels = svgGroup.append('g')
|
|
.attr('class', 'labels')
|
|
.selectAll('text')
|
|
.data(nodes)
|
|
.enter().append('text')
|
|
.attr('dx', 12)
|
|
.attr('dy', '.35em')
|
|
.text((d: any) => d.name);
|
|
|
|
const tooltip = d3.select(this.contentEl)
|
|
.append('div')
|
|
.attr('class', 'tooltip')
|
|
.style('position', 'absolute')
|
|
.style('visibility', 'hidden')
|
|
.style('background', '#333') // Updated background color
|
|
.style('color', '#fff') // Updated text color
|
|
.style('padding', '5px')
|
|
.style('border', '1px solid #444') // Updated border color
|
|
.style('border-radius', '5px');
|
|
|
|
// Add tooltip event listeners to links
|
|
link.on('mouseover', function(event, d) {
|
|
|
|
tooltip.text(`Significance: ${d.scoretoFixed(3)}`)
|
|
.style('visibility', 'visible');
|
|
|
|
}).on('mousemove', function(event) {
|
|
|
|
const [x, y] = d3.pointer(event);
|
|
tooltip.style('top', `${y + 10}px`)
|
|
.style('left', `${x + 10}px`);
|
|
|
|
}).on('mouseout', function() {
|
|
|
|
tooltip.style('visibility', 'hidden');
|
|
|
|
});
|
|
|
|
// TODO:: Uncomment back in when want to start on tooltop func
|
|
// node.on('mouseover', function(event, d: any) {
|
|
// tooltip.text(d.name)
|
|
// .style('visibility', 'visible');
|
|
// }).on('mousemove', function(event) {
|
|
// const [x, y] = d3.pointer(event);
|
|
// tooltip.style('top', `${y + 10}px`)
|
|
// .style('left', `${x + 10}px`);
|
|
// }).on('mouseout', function() {
|
|
// tooltip.style('visibility', 'hidden');
|
|
// });
|
|
|
|
// Funcition to center the network on update
|
|
const centerNetwork = () => {
|
|
simulation
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.alpha(0.3)
|
|
.restart();
|
|
};
|
|
|
|
// Helper function to truncate labels, extract filenames or parts after hashtags, and remove brackets
|
|
const formatLabel = (path: string, maxLength: number = 18) => {
|
|
if (path.includes('#')) {
|
|
const parts = path.split('#');
|
|
path = parts[parts.length - 1]; // Take the last part after splitting by '#'
|
|
const cleanedPath = path.replace(/[\[\]]/g, ''); // Remove brackets
|
|
return cleanedPath.length > maxLength ? cleanedPath.slice(0, maxLength) + '...' : cleanedPath;
|
|
} else {
|
|
const filename = path.split('/').pop();
|
|
if (filename) {
|
|
const cleanedFilename = filename.replace(/[\[\]]/g, ''); // Remove brackets
|
|
return cleanedFilename.length > maxLength ? cleanedFilename.slice(0, maxLength) + '...' : cleanedFilename;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
// Function to rerender visualization with most up to date settings
|
|
const updateVisualization = (newScoreThreshold?: number) => {
|
|
|
|
// We dont want rapid calls if its already updating unless we are changing the network via connection type
|
|
if (this.updatingVisualization && !this.isChangingConnectionType) {
|
|
console.log('Update already in progress. Skipping...');
|
|
return;
|
|
} else {
|
|
|
|
console.log('Update not in progress . Skipping...');
|
|
|
|
}
|
|
|
|
this.isChangingConnectionType = false;
|
|
|
|
if (newScoreThreshold !== undefined) {
|
|
this.significanceScoreThreshold = newScoreThreshold;
|
|
}
|
|
|
|
updateConnections();
|
|
|
|
// console.log('Connections before filtering:', connections);
|
|
const filteredConnections = connections.filter((connection: any) => connection.score >= this.significanceScoreThreshold);
|
|
// console.log('Filtered Connections:', filteredConnections);
|
|
|
|
let visibleNodes = new Set<string>();
|
|
filteredConnections.forEach((connection) => {
|
|
visibleNodes.add(connection.source);
|
|
visibleNodes.add(connection.target);
|
|
});
|
|
|
|
const nodesData = Array.from(visibleNodes).map(id => {
|
|
const node = nodes.find(node => node.id === id);
|
|
if (node) {
|
|
// console.log(`Node data for id ${id}:`, node);
|
|
return node;
|
|
} else {
|
|
console.warn(`Node data missing for id ${id}`);
|
|
return null;
|
|
}
|
|
}).filter(Boolean);
|
|
|
|
console.log('Visible Nodes:', nodesData);
|
|
|
|
this.validatedLinks = filteredConnections.filter((link) => {
|
|
const sourceNode = nodesData.find(node => node.id === link.source);
|
|
const targetNode = nodesData.find(node => node.id === link.target);
|
|
|
|
if (!sourceNode || !targetNode) {
|
|
console.error(`Invalid link found: ${link.source} -> ${link.target}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
console.log('Validated Links:', this.validatedLinks);
|
|
|
|
// Safeguard to prevent infinite loop
|
|
if (nodesData.length === 0 || this.validatedLinks.length === 0) {
|
|
console.warn('No nodes or links to display after filtering. Aborting update.');
|
|
return;
|
|
}
|
|
|
|
//TODO:: For now, separate central node and other node sizes until we do a node size by feature
|
|
const mainNodeSize = this.maxNodeSize;
|
|
const otherNodeSize = this.minNodeSize;
|
|
|
|
// Move forces towards central node
|
|
if (this.centralNode) {
|
|
this.centralNode.fx = width / 2;
|
|
this.centralNode.fy = height / 2;
|
|
}
|
|
|
|
// Ensure lasting jittering of the nodes doesn't happen, so stop after 2 seconds
|
|
const updateForces = () => {
|
|
simulation.alpha(0.3).restart(); // Restart the simulation with initial alpha
|
|
setTimeout(() => {
|
|
this.updatingVisualization = false; // Reset the flag after stopping simulation
|
|
simulation.alphaTarget(0).stop(); // Stop the simulation after 2 seconds
|
|
}, 2000);
|
|
};
|
|
|
|
this.nodeSelection = svgGroup.select('g.nodes').selectAll('circle')
|
|
.data(nodesData, (d: any) => d.id)
|
|
.join(
|
|
enter => enter.append('circle')
|
|
.attr('class', 'node')
|
|
.attr('r', d => d.id === this.centralNode.id ? this.nodeSize + 2 : this.nodeSize)
|
|
.attr('fill', d => d.selected ? '#f3ee5d' : (d.group === 'note' ? '#7c8594' : '#926ec9'))
|
|
.attr('stroke', d => d.selected ? '#ff0' : (d.group === 'note' ? '#7c8594' : '#926ec9'))
|
|
.attr('stroke-width', d => d.selected ? 2 : 0.3)
|
|
.attr('opacity', 1)
|
|
.attr('cursor', 'grab')
|
|
.call(d3.drag()
|
|
.on('start', function(event, d: any) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
// Store initial positions of selected nodes
|
|
d.initialX = d.x;
|
|
d.initialY = d.y;
|
|
if (d.selected) {
|
|
that.nodeSelection.each((node: any) => {
|
|
if (node.selected) {
|
|
node.initialX = node.x;
|
|
node.initialY = node.y;
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.on('drag', function(event, d: any) {
|
|
const dx = event.x - d.initialX;
|
|
const dy = event.y - d.initialY;
|
|
if (d.selected) {
|
|
that.nodeSelection.each((node: any) => {
|
|
if (node.selected) {
|
|
node.fx = node.initialX + dx;
|
|
node.fy = node.initialY + dy;
|
|
}
|
|
});
|
|
} else {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
}
|
|
})
|
|
.on('end', function(event, d: any) {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
if (d.selected) {
|
|
that.nodeSelection.each((node: any) => {
|
|
if (node.selected) {
|
|
node.fx = null;
|
|
node.fy = null;
|
|
}
|
|
});
|
|
} else {
|
|
d.fx = null;
|
|
d.fy = null;
|
|
}
|
|
})
|
|
)
|
|
.on('click', function(event, d: any) {
|
|
event.stopPropagation();
|
|
if (!that.isAltPressed) {
|
|
that.clearSelections();
|
|
}
|
|
d.selected = !d.selected;
|
|
console.log('selected: ', d.selected)
|
|
that.updateNodeAppearance();
|
|
})
|
|
.on('mouseover', function(event, d: any) {
|
|
that.isHovering = true;
|
|
if (!d.selected) {
|
|
if (d.id !== that.centralNode.id && !d.highlighted) {
|
|
d3.select(this).transition().duration(500).attr('fill', '#d46ebe'); // Animate fill color to #d46ebe
|
|
}
|
|
that.highlightNode(d);
|
|
}
|
|
|
|
d3.select(this).attr('stroke', '#fff'); // Change stroke color to white on hover
|
|
svgGroup.select(`text[data-id='${d.id}']`).transition().duration(250).attr('y', d.y + 4); // Animate label down 10 pixels
|
|
|
|
event.stopPropagation();
|
|
|
|
that.app.workspace.trigger("hover-link", {
|
|
event,
|
|
source: 'D3',
|
|
hoverParent: this.parentElement,
|
|
targetEl: this,
|
|
linktext: d.id,
|
|
});
|
|
}).on('mouseout', function(event, d: any) {
|
|
that.isHovering = false;
|
|
if (!d.selected) {
|
|
if (d.id !== that.centralNode.id) {
|
|
d3.select(this).transition().duration(500).attr('fill', d.group === 'note' ? '#7c8594' : '#926ec9'); // Animate fill color back to original
|
|
}
|
|
}
|
|
that.unhighlightNode();
|
|
d3.select(this).attr('stroke', d.selected ? '#ff0' : 'transparent'); // Change stroke color back to less visible on mouse out
|
|
svgGroup.select(`text[data-id='${d.id}']`).transition().duration(250).attr('y', d.y); // Animate label back up 10 pixels
|
|
}),
|
|
update => update
|
|
.attr('r', d => d.id === this.centralNode.id ? this.nodeSize + 2 : this.nodeSize)
|
|
.attr('fill', d => d.selected ? '#f3ee5d' : (d.group === 'note' ? '#7c8594' : '#926ec9'))
|
|
.attr('stroke', d => d.selected ? '#ff0' : (d.group === 'note' ? '#7c8594' : '#926ec9'))
|
|
.attr('stroke-width', d => d.selected ? 2 : 0.3),
|
|
exit => exit.remove()
|
|
);
|
|
|
|
|
|
|
|
this.linkSelection = svgGroup.select('g.links').selectAll('line')
|
|
.data(this.validatedLinks, (d: any) => `${d.source}-${d.target}`)
|
|
.join(
|
|
enter => enter.append('line')
|
|
.attr('class', 'link')
|
|
.attr('stroke', '#4c7787') // Adjusted color to match the blue hue
|
|
.attr('stroke-width',(d: any) => d3.scaleLinear()
|
|
.domain([this.minScore, this.maxScore]) // Ensure this domain matches your score range
|
|
.range([this.minLinkThickness, this.maxLinkThickness])(d.score)) // Thinner stroke width
|
|
.attr('stroke-opacity', 1)
|
|
.attr('opacity', 1)
|
|
.on('mouseover', function(event, d : any) {
|
|
const [x, y] = d3.pointer(event);
|
|
tooltip.text(`Signficance: ${d.score.toFixed(3)}`)
|
|
.style('visibility', 'visible');
|
|
}).on('mousemove', function(event) {
|
|
const [x, y] = d3.pointer(event);
|
|
tooltip.style('top', `${y + 10}px`)
|
|
.style('left', `${x}px`);
|
|
}).on('mouseout', function() {
|
|
tooltip.style('visibility', 'hidden');
|
|
}),
|
|
update => update
|
|
.attr('stroke', '#4c7787')
|
|
.attr('stroke-width',(d: any) => d3.scaleLinear()
|
|
.domain([this.minScore, this.maxScore]) // Ensure this domain matches your score range
|
|
.range([this.minLinkThickness, this.maxLinkThickness])(d.score))
|
|
.on('mouseover', function(event, d : any) {
|
|
}).on('mousemove', function(event, d :any) {
|
|
// Calculate the position of the tooltip relative to the SVG coordinate system
|
|
}).on('mouseout', function() {
|
|
tooltip.style('visibility', 'hidden');
|
|
}), // Thinner stroke width
|
|
exit => exit.remove()
|
|
);
|
|
|
|
this.labelSelection = svgGroup.select('g.labels').selectAll('text')
|
|
.data(nodesData, (d: any) => d.id)
|
|
.join(
|
|
enter => enter.append('text')
|
|
.attr('class', 'label')
|
|
.attr('dx', 0) // Centered horizontally
|
|
.attr('font-size', 4)
|
|
.attr('dy', 12) // Positioned right underneath
|
|
.attr('text-anchor', 'middle') // Center align text
|
|
.attr('fill', '#bbb')
|
|
.attr('opacity', 1) // Set initial opacity
|
|
.text((d: any) => formatLabel(d.name)),
|
|
update => update
|
|
.attr('dx', 0) // Centered horizontally
|
|
.attr('dy', 12) // Positioned right underneath
|
|
.attr('text-anchor', 'middle') // Center align text
|
|
.text((d: any) => formatLabel(d.name))
|
|
.attr('fill', '#bbb')
|
|
.attr('font-size', 4)
|
|
.attr('opacity', 1), // Ensure updated opacity is 1
|
|
exit => exit.remove()
|
|
)
|
|
.attr('x', (d: any) => d.x)
|
|
.attr('y', (d: any) => d.y);
|
|
|
|
simulation.nodes(nodesData);
|
|
(simulation.force('link') as any).links(this.validatedLinks);
|
|
|
|
simulation.alpha(0.3).alphaTarget(0.05).restart();
|
|
|
|
|
|
centerNetwork();
|
|
|
|
updateForces();
|
|
|
|
simulation.on('tick', () => {
|
|
|
|
customCenterForce(0.1)
|
|
|
|
this.nodeSelection
|
|
.attr('cx', (d: any) => d.x)
|
|
.attr('cy', (d: any) => d.y);
|
|
|
|
this.linkSelection
|
|
.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);
|
|
|
|
svgGroup.select('g.labels').selectAll('text')
|
|
.data(nodesData, (d: any) => d.id)
|
|
.join(
|
|
enter => enter.append('text')
|
|
.attr('class', 'label')
|
|
.attr('dx', 12)
|
|
.attr('dy', '.35em')
|
|
.attr('fill', '#bbb')
|
|
.attr('data-id', d => d.id) // Add data-id attribute for easier selection
|
|
.text((d: any) => formatLabel(d.name)),
|
|
update => update
|
|
.text((d: any) => formatLabel(d.name))
|
|
.attr('fill', '#bbb')
|
|
.attr('data-id', d => d.id), // Add data-id attribute for easier selection
|
|
exit => exit.remove()
|
|
)
|
|
.attr('x', (d: any) => d.x)
|
|
.attr('y', (d: any) => d.y); // Position label below the node
|
|
|
|
// Update label opacity based on initial zoom level
|
|
updateLabelOpacity(d3.zoomTransform(svg.node() as Element).k);
|
|
});
|
|
|
|
this.updatingVisualization = true; // Set the flag to true
|
|
|
|
|
|
};
|
|
|
|
|
|
if (!this.settingsInstantiated) {
|
|
|
|
// Ensure that the settings menu and its elements are only created once
|
|
if (document.querySelector('.settings-icon')) {
|
|
console.log("score settings exist");
|
|
// If settings menu is already set up, skip setup
|
|
return;
|
|
} else {
|
|
console.log("score doesnt")
|
|
}
|
|
|
|
// Create the settings icon
|
|
const settingsIcon = document.createElement('div');
|
|
settingsIcon.innerHTML = '⚙'; // Unicode for gear icon
|
|
settingsIcon.classList.add('settings-icon');
|
|
this.contentEl.appendChild(settingsIcon);
|
|
|
|
// SVG icon for dropdown arrow
|
|
const dropdownArrow = `
|
|
<svg class="dropdown-indicator" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
|
</svg>`;
|
|
|
|
const rightArrow = `
|
|
<svg class="dropdown-indicator" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
|
</svg>`;
|
|
|
|
// Create the dropdown menu
|
|
const dropdownMenu = document.createElement('div');
|
|
dropdownMenu.classList.add('dropdown-menu');
|
|
dropdownMenu.innerHTML = `
|
|
<div class="menu-header">
|
|
<div class="icon" id="refresh-icon">↻</div> <!-- Unicode for refresh icon -->
|
|
<div class="icon" id="close-icon">✕</div> <!-- Unicode for X (close) icon -->
|
|
</div>
|
|
<div class="accordion-item">
|
|
<div class="accordion-header">
|
|
<span class="arrow-icon">${rightArrow}</span>Filters
|
|
</div>
|
|
<div class="accordion-content">
|
|
<div class="slider-container">
|
|
<label id="scoreThresholdLabel" for="scoreThreshold">Signficance Threshold: ${this.significanceScoreThreshold}</label>
|
|
<input type="range" id="scoreThreshold" class="slider" name="scoreThreshold" min="0" max="0.99" value="${this.significanceScoreThreshold}" step="0.01">
|
|
</div>
|
|
<label class="settings-item-content-label">Connection Type:</label>
|
|
<div class="radio-container">
|
|
<label>
|
|
<input type="radio" name="connectionType" value="block" ${this.connectionType === 'block' ? 'checked' : ''}> Block
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="connectionType" value="note" ${this.connectionType === 'note' ? 'checked' : ''}> Note
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!--
|
|
<div class="accordion-item">
|
|
<div class="accordion-header">
|
|
<span class="arrow-icon">${rightArrow}</span>Groups
|
|
</div>
|
|
<div class="accordion-content">
|
|
<input type="checkbox" id="group1" name="group1" checked>
|
|
<label for="group1">Group 1</label><br>
|
|
<input type="checkbox" id="group2" name="group2" checked>
|
|
<label for="group2">Group 2</label>
|
|
</div>
|
|
</div>
|
|
--!>
|
|
<div class="accordion-item">
|
|
<div class="accordion-header">
|
|
<span class="arrow-icon">${rightArrow}</span>Display
|
|
</div>
|
|
<div class="accordion-content">
|
|
<div class="slider-container">
|
|
<label id="fadeThresholdLabel" for="fadeThreshold"> Text Fade Threshold: ${this.textFadeThreshold}</label>
|
|
<input type="range" id="fadeThreshold" class="slider" name="fadeThreshold" min="0.1" max="10" value="1.2" step="0.01">
|
|
</div>
|
|
<div class="slider-container">
|
|
<label id="nodeSizeLabel" for="nodeSize">Node Size: ${this.nodeSize}</label>
|
|
<input type="range" id="nodeSize" class="slider" name="nodeSize" min="1" max="15" value="${this.nodeSize}" step="0.01">
|
|
</div>
|
|
<div class="slider-container">
|
|
<label id="minLinkThicknessLabel" for="minLinkThickness">Min Link Thickness: ${this.minLinkThickness}</label>
|
|
<input type="range" id="minLinkThickness" class="slider" name="minLinkThickness" min="0.1" max="10" value="${this.minLinkThickness}" step="0.01">
|
|
</div>
|
|
<div class="slider-container">
|
|
<label id="maxLinkThicknessLabel" for="maxLinkThickness">Max Link Thickness: ${this.maxLinkThickness}</label>
|
|
<input type="range" id="maxLinkThickness" class="slider" name="maxLinkThickness" min="0.1" max="10" value="${this.maxLinkThickness}" step="0.01">
|
|
</div>
|
|
<!-- <div class="slider-container">
|
|
<label id="lineThicknessLabel" for="lineThickness">Line Thickness: ${this.linkThickness}</label>
|
|
<input type="range" id="lineThickness" class="slider" name="lineThickness" min="0.1" max="5" value="${this.linkThickness}" step="0.01">
|
|
</div> --!>
|
|
</div>
|
|
</div>
|
|
<div class="accordion-item">
|
|
<div class="accordion-header">
|
|
<span class="arrow-icon">${rightArrow}</span>Forces
|
|
</div>
|
|
<div class="accordion-content">
|
|
<div class="slider-container">
|
|
<label for="centerForce" id="centerForceLabel">Center Force: ${this.centerForce}</label>
|
|
<input type="range" id="centerForce" name="centerForce" class="slider" min="0" max="1" value="${this.centerForce}" step="0.01">
|
|
</div>
|
|
<div class="slider-container">
|
|
<label for="repelForce" id="repelForceLabel">Repel Force: ${this.repelForce}</label>
|
|
<input type="range" id="repelForce" name="repelForce" class="slider" min="0" max="1500" value="${this.repelForce}" step="1">
|
|
</div>
|
|
<div class="slider-container">
|
|
<label for="linkForce" id="linkForceLabel">Link Force: ${this.linkForce}</label>
|
|
<input type="range" id="linkForce" name="linkForce" class="slider" min="0" max="1" value=" ${this.linkForce}" step="0.01">
|
|
</div>
|
|
<label for="linkDistance" id="linkDistanceLabel">Link Distance: ${this.linkDistance}</label>
|
|
<input type="range" id="linkDistance" name="linkDistance" class="slider" min="10" max="200" value="${this.linkDistance}" step="1">
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.contentEl.appendChild(dropdownMenu);
|
|
|
|
// Toggle dropdown menu visibility on settings icon click
|
|
settingsIcon.addEventListener('click', () => {
|
|
dropdownMenu.classList.toggle('open');
|
|
});
|
|
|
|
// Toggle accordion content visibility and arrow icon on header click
|
|
const accordionHeaders = dropdownMenu.querySelectorAll('.accordion-header');
|
|
accordionHeaders.forEach(header => {
|
|
header.addEventListener('click', () => {
|
|
const content = header.nextElementSibling;
|
|
const arrowIcon = header.querySelector('.arrow-icon');
|
|
|
|
if (content && arrowIcon) { // Add null check for arrowIcon
|
|
content.classList.toggle('show');
|
|
if (content.classList.contains('show')) {
|
|
arrowIcon.innerHTML = dropdownArrow;
|
|
} else {
|
|
arrowIcon.innerHTML = rightArrow;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Now query the DOM for the slider and label
|
|
const scoreThresholdSlider = document.getElementById('scoreThreshold') as HTMLInputElement;
|
|
const scoreThresholdLabel = document.getElementById('scoreThresholdLabel');
|
|
const nodeSizeSlider = document.getElementById('nodeSize') as HTMLInputElement;
|
|
const nodeSizeLabel = document.getElementById('nodeSizeLabel');
|
|
const lineThicknessSlider = document.getElementById('lineThickness') as HTMLInputElement;
|
|
const lineThicknessLabel = document.getElementById('lineThicknessLabel');
|
|
const centerForceSlider = document.getElementById('centerForce') as HTMLInputElement;
|
|
const centerForceLabel = document.getElementById('centerForceLabel');
|
|
const repelForceSlider = document.getElementById('repelForce') as HTMLInputElement;
|
|
const repelForceLabel = document.getElementById('repelForceLabel');
|
|
const linkForceSlider = document.getElementById('linkForce') as HTMLInputElement;
|
|
const linkForceLabel = document.getElementById('linkForceLabel');
|
|
const linkDistanceSlider = document.getElementById('linkDistance') as HTMLInputElement;
|
|
const linkDistanceLabel = document.getElementById('linkDistanceLabel');
|
|
const fadeThresholdSlider = document.getElementById('fadeThreshold') as HTMLInputElement;
|
|
const fadeThresholdLabel = document.getElementById('fadeThresholdLabel');
|
|
const minLinkThicknessSlider = document.getElementById('minLinkThickness') as HTMLInputElement;
|
|
const minLinkThicknessLabel = document.getElementById('minLinkThicknessLabel');
|
|
const maxLinkThicknessSlider = document.getElementById('maxLinkThickness') as HTMLInputElement;
|
|
const maxLinkThicknessLabel = document.getElementById('maxLinkThicknessLabel');
|
|
const connectionTypeRadios = document.querySelectorAll('input[name="connectionType"]');
|
|
|
|
// Event listener for Connection Type Radios slider
|
|
if (connectionTypeRadios) {
|
|
connectionTypeRadios.forEach(radio => {
|
|
radio.addEventListener('change', (event) => {
|
|
this.connectionType = (event.target as HTMLInputElement).value;
|
|
this.isChangingConnectionType = true;
|
|
updateVisualization();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Event listener for Min Link Thickness slider
|
|
if (minLinkThicknessSlider) {
|
|
minLinkThicknessSlider.addEventListener('input', (event) => {
|
|
const newMinLinkThickness = parseFloat((event.target as HTMLInputElement).value);
|
|
if (minLinkThicknessLabel) {
|
|
minLinkThicknessLabel.textContent = `Min Link Thickness: ${newMinLinkThickness}`;
|
|
}
|
|
this.minLinkThickness = newMinLinkThickness;
|
|
updateLinkThickness();
|
|
});
|
|
}
|
|
|
|
// Event listener for Max Link Thickness slider
|
|
if (maxLinkThicknessSlider) {
|
|
maxLinkThicknessSlider.addEventListener('input', (event) => {
|
|
const newMaxLinkThickness = parseFloat((event.target as HTMLInputElement).value);
|
|
if (maxLinkThicknessLabel) {
|
|
maxLinkThicknessLabel.textContent = `Max Link Thickness: ${newMaxLinkThickness}`;
|
|
}
|
|
this.maxLinkThickness = newMaxLinkThickness;
|
|
updateLinkThickness();
|
|
});
|
|
}
|
|
|
|
// Event listener for Text Fade Threshold slider
|
|
if (fadeThresholdSlider) {
|
|
fadeThresholdSlider.addEventListener('input', (event) => {
|
|
const newFadeThreshold = parseFloat((event.target as HTMLInputElement).value);
|
|
if (fadeThresholdLabel) {
|
|
fadeThresholdLabel.textContent = `Fade Threshold: ${newFadeThreshold}`;
|
|
}
|
|
this.textFadeThreshold = newFadeThreshold;
|
|
// Update the label opacity immediately to reflect the new threshold
|
|
updateLabelOpacity(d3.zoomTransform(svg.node() as Element).k);
|
|
});
|
|
}
|
|
|
|
// Event listener for Center Force slider
|
|
if (centerForceSlider) {
|
|
centerForceSlider.addEventListener('input', (event) => {
|
|
const newCenterForce = parseFloat((event.target as HTMLInputElement).value);
|
|
if (centerForceLabel) {
|
|
centerForceLabel.textContent = `Center Force: ${newCenterForce}`;
|
|
}
|
|
updateCenterForce(newCenterForce);
|
|
|
|
});
|
|
}
|
|
|
|
// Event listener for Repel Force slider
|
|
if (repelForceSlider) {
|
|
repelForceSlider.addEventListener('input', (event) => {
|
|
const newRepelForce = parseFloat((event.target as HTMLInputElement).value);
|
|
if (repelForceLabel) {
|
|
repelForceLabel.textContent = `Repel Force: ${newRepelForce}`;
|
|
}
|
|
updateRepelForce(newRepelForce);
|
|
});
|
|
}
|
|
|
|
// Event listener for Link Force slider
|
|
if (linkForceSlider) {
|
|
linkForceSlider.addEventListener('input', (event) => {
|
|
const newLinkForce = parseFloat((event.target as HTMLInputElement).value);
|
|
if (linkForceLabel) {
|
|
linkForceLabel.textContent = `Link Force: ${newLinkForce}`;
|
|
}
|
|
updateLinkForce(newLinkForce);
|
|
});
|
|
}
|
|
|
|
// Event listener for Link Distance slider
|
|
if (linkDistanceSlider) {
|
|
linkDistanceSlider.addEventListener('input', (event) => {
|
|
const newLinkDistance = parseFloat((event.target as HTMLInputElement).value);
|
|
if (linkDistanceLabel) {
|
|
linkDistanceLabel.textContent = `Link Distance: ${newLinkDistance}`;
|
|
}
|
|
updateLinkDistance(newLinkDistance);
|
|
|
|
});
|
|
}
|
|
|
|
// Function to update center force based on the slider value
|
|
const updateCenterForce = (newCenterForce: number) => {
|
|
simulation.force('center', null); // Remove the existing center force
|
|
simulation.force('center', d3.forceCenter(width / 2, height / 2).strength(newCenterForce)); // Add the new center force
|
|
simulation.alphaTarget(0.3).restart();
|
|
updateForces();
|
|
|
|
};
|
|
|
|
// Function to update repel force based on the slider value
|
|
const updateRepelForce = (newRepelForce: number) => {
|
|
(simulation.force('charge') as any).strength(-newRepelForce);
|
|
simulation.alphaTarget(0.3).restart();
|
|
updateForces();
|
|
|
|
};
|
|
|
|
const updateLinkThickness = () => {
|
|
const linkStrokeScale = d3.scaleLinear()
|
|
.domain([this.minScore, this.maxScore]) // Update this to match your score range
|
|
.range([this.minLinkThickness, this.maxLinkThickness]);
|
|
|
|
this.linkSelection.attr('stroke-width', (d: any) => linkStrokeScale(d.score));
|
|
};
|
|
|
|
|
|
// Function to update link force based on the slider value
|
|
const updateLinkForce = (newLinkForce: number) => {
|
|
simulation.force('link', null); // Remove the existing link force
|
|
simulation.force('link', d3.forceLink(this.validatedLinks).id((d: any) => d.id).strength(newLinkForce).distance(this.linkDistance)); // Add the new link force
|
|
simulation.alphaTarget(0.3).restart(); // Smoothly adjust without a full restart
|
|
updateForces();
|
|
|
|
};
|
|
|
|
// Function to update link distance based on the slider value
|
|
const updateLinkDistance = (newLinkDistance: number) => {
|
|
simulation.force('link', null); // Remove the existing link force
|
|
simulation.force('link', d3.forceLink(this.validatedLinks).id((d: any) => d.id).distance(newLinkDistance).strength(this.linkForce)); // Add the new link force with the current distance
|
|
simulation.alphaTarget(0.3).restart(); // Smoothly adjust without a full restart
|
|
updateForces();
|
|
|
|
};
|
|
|
|
// Function to ensure network stops in case nodes get jittery
|
|
const updateForces = () => {
|
|
simulation.alpha(0.3).restart(); // Restart the simulation with initial alpha
|
|
setTimeout(() => {
|
|
simulation.alphaTarget(0).stop(); // Stop the simulation after 5 seconds
|
|
}, 2000);
|
|
};
|
|
|
|
// Function to update node sizes based on the slider value
|
|
const updateNodeSizes = () => {
|
|
this.nodeSelection.attr('r', (d: any) => d.id === this.centralNode.id ? this.nodeSize + 3 : this.nodeSize);
|
|
};
|
|
|
|
// Function to debounce calls to updateVisualization
|
|
function debounce(func: Function, wait: number) {
|
|
let timeout: number | undefined;
|
|
return function(...args: any[]) {
|
|
clearTimeout(timeout);
|
|
timeout = window.setTimeout(() => func.apply(this, args), wait);
|
|
};
|
|
}
|
|
|
|
|
|
// Event listener for Score Threshold slider
|
|
if (scoreThresholdSlider && !this.settingsInstantiated) {
|
|
// Immediate update of label text
|
|
scoreThresholdSlider.addEventListener('input', (event) => {
|
|
const newScoreThreshold = parseFloat((event.target as HTMLInputElement).value);
|
|
if (scoreThresholdLabel) {
|
|
scoreThresholdLabel.textContent = `Significance Threshold: ${newScoreThreshold}`;
|
|
}
|
|
});
|
|
|
|
// Debounced updateVisualization function
|
|
const debouncedUpdateVisualization = debounce((event: Event) => {
|
|
const newScoreThreshold = parseFloat((event.target as HTMLInputElement).value);
|
|
updateVisualization(newScoreThreshold);
|
|
}, 500); // Adjust the delay (500ms) as needed
|
|
|
|
scoreThresholdSlider.addEventListener('input', debouncedUpdateVisualization);
|
|
|
|
this.settingsInstantiated = true;
|
|
}
|
|
|
|
// Event listener for Node Size slider
|
|
if (nodeSizeSlider) {
|
|
nodeSizeSlider.addEventListener('input', (event) => {
|
|
const newNodeSize = parseFloat((event.target as HTMLInputElement).value);
|
|
if (nodeSizeLabel) {
|
|
nodeSizeLabel.textContent = `Node Size: ${newNodeSize}`;
|
|
}
|
|
this.nodeSize = newNodeSize;
|
|
updateNodeSizes();
|
|
});
|
|
}
|
|
|
|
// Event listener for Link Thickness slider
|
|
if (lineThicknessSlider) {
|
|
lineThicknessSlider.addEventListener('input', (event) => {
|
|
console.log('line thicking');
|
|
const newLineThickness = parseFloat((event.target as HTMLInputElement).value);
|
|
this.linkThickness = newLineThickness;
|
|
if (lineThicknessLabel) {
|
|
lineThicknessLabel.textContent = `Line Thickness: ${newLineThickness}`;
|
|
}
|
|
updateLinkThickness();
|
|
});
|
|
}
|
|
|
|
// Close dropdown menu on X icon click
|
|
const closeIcon = document.getElementById('close-icon');
|
|
if (closeIcon) {
|
|
closeIcon.addEventListener('click', () => {
|
|
console.log('Close icon CLICKED');
|
|
|
|
dropdownMenu.classList.remove('open');
|
|
});
|
|
}
|
|
|
|
// Refresh functionality for refresh icon click (optional, implement as needed)
|
|
const refreshIcon = document.getElementById('refresh-icon');
|
|
if (refreshIcon) {
|
|
refreshIcon.addEventListener('click', () => {
|
|
console.log('Refresh icon clicked');
|
|
resetToDefault();
|
|
});
|
|
}
|
|
|
|
|
|
// Function to reset all variables to default using the global default constant
|
|
const resetToDefault = () => {
|
|
this.significanceScoreThreshold = DEFAULT_NETWORK_SETTINGS.scoreThreshold;
|
|
this.nodeSize = DEFAULT_NETWORK_SETTINGS.nodeSize;
|
|
this.linkThickness = DEFAULT_NETWORK_SETTINGS.linkThickness;
|
|
this.repelForce = DEFAULT_NETWORK_SETTINGS.repelForce;
|
|
this.linkForce = DEFAULT_NETWORK_SETTINGS.linkForce;
|
|
this.linkDistance = DEFAULT_NETWORK_SETTINGS.linkDistance;
|
|
this.centerForce = DEFAULT_NETWORK_SETTINGS.centerForce;
|
|
|
|
// Update slider values
|
|
if (scoreThresholdSlider) scoreThresholdSlider.value = `${this.significanceScoreThreshold}`;
|
|
if (scoreThresholdLabel) scoreThresholdLabel.textContent = `Significance Threshold: ${this.significanceScoreThreshold}`;
|
|
if (nodeSizeSlider) nodeSizeSlider.value = `${this.nodeSize}`;
|
|
if (nodeSizeLabel) nodeSizeLabel.textContent = `Node Size: ${this.nodeSize}`;
|
|
if (lineThicknessSlider) lineThicknessSlider.value = `${this.linkThickness}`;
|
|
if (lineThicknessLabel) lineThicknessLabel.textContent = `Line Thickness: ${this.linkThickness}`;
|
|
if (centerForceSlider) centerForceSlider.value = `${this.centerForce}`;
|
|
if (centerForceLabel) centerForceLabel.textContent = `Center Force: ${this.centerForce}`;
|
|
if (repelForceSlider) repelForceSlider.value = `${this.repelForce}`;
|
|
if (repelForceLabel) repelForceLabel.textContent = `Repel Force: ${this.repelForce}`;
|
|
if (linkForceSlider) linkForceSlider.value = `${this.linkForce}`;
|
|
if (linkForceLabel) linkForceLabel.textContent = `Link Force: ${this.linkForce}`;
|
|
if (linkDistanceSlider) linkDistanceSlider.value = `${this.linkDistance}`;
|
|
if (linkDistanceLabel) linkDistanceLabel.textContent = `Link Distance: ${this.linkDistance}`;
|
|
if (fadeThresholdSlider) fadeThresholdSlider.value = `${this.textFadeThreshold}`;
|
|
if (fadeThresholdLabel) fadeThresholdLabel.textContent = `Fade Threshold: ${this.textFadeThreshold}`;
|
|
if (minLinkThicknessSlider) minLinkThicknessSlider.value = `${this.minLinkThickness}`;
|
|
if (minLinkThicknessLabel) minLinkThicknessLabel.textContent = `Min Link Thickness: ${this.minLinkThickness}`;
|
|
if (maxLinkThicknessSlider) maxLinkThicknessSlider.value = `${this.maxLinkThickness}`;
|
|
if (maxLinkThicknessLabel) maxLinkThicknessLabel.textContent = `Max Link Thickness: ${this.maxLinkThickness}`;
|
|
|
|
// Reapply settings
|
|
updateNodeSizes();
|
|
updateLinkThickness();
|
|
|
|
// Update the simulation forces
|
|
simulation
|
|
.force('center', d3.forceCenter(width / 2, height / 2).strength(this.centerForce))
|
|
.force('charge', d3.forceManyBody().strength(-this.repelForce))
|
|
.force('link', d3.forceLink().id((d: any) => d.id).strength(this.linkForce).distance(this.linkDistance));
|
|
|
|
// Restart the simulation with the new settings
|
|
simulation.alpha(0.3).alphaTarget(0).restart();
|
|
updateVisualization(this.significanceScoreThreshold);
|
|
};
|
|
|
|
|
|
this.settingsInstantiated = true;
|
|
|
|
}
|
|
|
|
// Watch for changes in the currently viewed note
|
|
this.app.workspace.on('file-open', (file) => {
|
|
if (file && (this.currentNoteKey !== file.path) && !this.isHovering) {
|
|
// if (!this.updatingVisualization) {
|
|
this.currentNoteKey = file.path;
|
|
console.log('Current Note Key:', this.currentNoteKey);
|
|
updateVisualization();
|
|
// }
|
|
}
|
|
});
|
|
|
|
// Initial call to create the visualization
|
|
updateVisualization();
|
|
|
|
}, 1000);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/*
|
|
Main Colors
|
|
Menu text: #a3aecb
|
|
HoveredOverNode: #d46ebe
|
|
NormalNode: #926ec9
|
|
centralNode: #7c8594
|
|
Link: #4c7787
|
|
SliderKnob: #f3ee5d
|
|
*/
|
|
|
|
|
|
export default class MyPlugin extends Plugin {
|
|
settings: MyPluginSettings;
|
|
|
|
async onload() {
|
|
await this.loadSettings();
|
|
|
|
// Register the new view
|
|
this.registerView("My Item View", (leaf: WorkspaceLeaf) => new MyItemView(leaf));
|
|
|
|
// Register hover link source
|
|
this.registerHoverLinkSource('D3', {
|
|
display: 'D3 Hover Link Source',
|
|
defaultMod: true
|
|
});
|
|
|
|
// This creates an icon in the left ribbon.
|
|
const ribbonIconEl = this.addRibbonIcon('dice', 'Smart Graph', (evt: MouseEvent) => {
|
|
// Called when the user clicks the icon.
|
|
// Create a new leaf in the current workspace
|
|
let leaf = this.app.workspace.getLeaf(true);
|
|
|
|
// Set the new leaf's view to your custom view
|
|
leaf.setViewState({
|
|
type: "My Item View",
|
|
active: true,
|
|
});
|
|
})
|
|
// Perform additional things with the ribbon
|
|
ribbonIconEl.addClass('my-plugin-ribbon-class');
|
|
|
|
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
|
const statusBarItemEl = this.addStatusBarItem();
|
|
statusBarItemEl.setText('Status Bar Text');
|
|
|
|
// This adds a simple command that can be triggered anywhere
|
|
this.addCommand({
|
|
id: 'open-sample-modal-simple',
|
|
name: 'Open sample modal (simple)',
|
|
callback: () => {
|
|
// Create a new leaf in the current workspace
|
|
let leaf = this.app.workspace.getLeaf(true);
|
|
|
|
// Set the new leaf's view to your custom view
|
|
leaf.setViewState({
|
|
type: "My Item View",
|
|
active: true,
|
|
});
|
|
}
|
|
});
|
|
// This adds an editor command that can perform some operation on the current editor instance
|
|
this.addCommand({
|
|
id: 'sample-editor-command',
|
|
name: 'Sample editor command',
|
|
callback: () => {
|
|
// Create a new leaf in the current workspace
|
|
let leaf = this.app.workspace.getLeaf(true);
|
|
|
|
// Set the new leaf's view to your custom view
|
|
leaf.setViewState({
|
|
type: "My Item View",
|
|
active: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
// This adds a complex command that can check whether the current state of the app allows execution of the command
|
|
this.addCommand({
|
|
id: 'open-sample-modal-complex',
|
|
name: 'Open sample modal (complex)',
|
|
callback: () => {
|
|
// Create a new leaf in the current workspace
|
|
let leaf = this.app.workspace.getLeaf(true);
|
|
|
|
// Set the new leaf's view to your custom view
|
|
leaf.setViewState({
|
|
type: "My Item View",
|
|
active: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
// This adds a settings tab so the user can configure various aspects of the plugin
|
|
this.addSettingTab(new SampleSettingTab(this.app, this));
|
|
|
|
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
|
// Using this function will automatically remove the event listener when this plugin is disabled.
|
|
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
|
console.log('click', evt);
|
|
});
|
|
|
|
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
|
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
|
}
|
|
|
|
onunload() {}
|
|
|
|
async loadSettings() {
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
}
|
|
|
|
class SampleSettingTab extends PluginSettingTab {
|
|
plugin: MyPlugin;
|
|
|
|
constructor(app: App, plugin: MyPlugin) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
display(): void {
|
|
const {containerEl} = this;
|
|
|
|
containerEl.empty();
|
|
|
|
new Setting(containerEl)
|
|
.setName('Setting #1')
|
|
.setDesc('It\'s a secret')
|
|
.addText(text => text
|
|
.setPlaceholder('Enter your secret... This will change everything')
|
|
.setValue(this.plugin.settings.mySetting)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.mySetting = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
}
|
|
}
|