Forum Discussion

ArthaLearning01's avatar
ArthaLearning01
Community Member
27 days ago

Word Cloud Interaction

For the this build-a-thon, another interaction I built is the Word Cloud Interaction—an open-ended way for learners to explore related concepts without forcing them into a matching or sorting exercise.

Words slowly float around the page. When a learner clicks one, related words pull in and cluster nearby while unrelated ones drift to the edges and fade back. Supporting text appears alongside the interaction, showing how the selected concept connects to the others. Letting go resets everything and the words begin floating again.

The idea came from my love of word association—and frustration with how static or “forced” it usually feels in eLearning. I wanted something more natural and calm, inspired by watching clouds drift around the sky and letting your attention land where it wants.

I used ChatGPT for early brainstorming and to create a build spec, then moved into Antigravity with Gemini to build and refine the interaction using HTML/CSS/JS. A lot of tinkering went into the motion—early versions felt fast and jerky, so I ended up slowing everything way down and building the feel back up from there. GitHub helped keep track of versions as it evolved.

A few takeaways:

  • Smooth motion takes more refinement than expected—starting slow helped a lot.
  • The connecting lines made a bigger instructional difference than I anticipated.
  • Next time, I’d spend more time defining the feeling of the animation before jumping into development.

Course Link: 

https://rise.articulate.com/authoring/Kml0wZZQOMOkpD55ZEKhxROyM_iB0Azg 

I’m sharing the code so others can explore or remix it :)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>Concept Gravity Field</title>
    <style>
:root {
  --bg-color: #0f172a; /* Darker Slate-900 for premium feel and contrast */
  --text-primary: #f1f5f9; /* Lighter text for dark backgrounds */
  --text-secondary: #94a3b8;
  --node-bg: #1e293b; /* Dark node background */
  --node-border: #334155;
  --node-shadow:
    0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
  --node-shadow-hover:
    0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  --node-selected-glow: 0 0 0 4px rgba(59, 130, 246, 0.3);

  --accent-color: #3b82f6; /* Blue-500 */
  --related-color: #10b981; /* Emerald-500 */
  --unrelated-opacity: 0.4;

  --panel-bg: rgba(255, 255, 255, 0.95);
  --font-family: "Inter", sans-serif;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  -webkit-tap-highlight-color: transparent;
}

body {
  font-family: var(--font-family);
  background-color: var(--bg-color);
  color: var(--text-primary);
  overflow: hidden;
  height: 100vh;
  min-height: 550px;
  width: 100vw;
}

#main-wrapper {
  display: flex;
  width: 100%;
  height: 100%;
}

#sidebar {
  width: 240px;
  height: 100%;
  background: #1e293b; /* Distinct from main field but still dark */
  border-right: 1px solid #334155;
  padding: 24px;
  display: flex;
  flex-direction: column;
  z-index: 100;
  overflow-y: auto;
  box-shadow: 10px 0 25px -5px rgba(0, 0, 0, 0.4);
}

#sidebar h2 {
    font-size: 20px;
    font-weight: 600;
    margin-bottom: 24px;
    color: #ffffff;
    border-bottom: 2px solid var(--accent-color);
    padding-bottom: 12px;
}

#instructions li {
    margin-bottom: 16px;
    font-size: 15px;
    line-height: 1.6;
    color: var(--text-secondary);
}

#instructions strong {
    color: #ffffff;
    font-weight: 600;
}

#concept-details.hidden, #instructions.hidden {
    display: none;
}

#panel-desc {
    font-size: 16px;
    line-height: 1.6;
    color: #cbd5e1; /* Light gray for readability */
    margin-bottom: 24px;
}

#panel-stats {
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--accent-color);
    margin-bottom: 16px;
}

#panel-related-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 12px;
}

#panel-related-list li {
    font-size: 14px;
    background: #0f172a; /* Darker than sidebar */
    padding: 12px 16px;
    border-radius: 8px;
    color: #f1f5f9;
    border-left: 3px solid #000000; /* Matching the related concept node look */
}

#gravity-field {
  flex: 1;
  height: 100%;
  position: relative;
  background-color: var(--bg-color);
  overflow: hidden;
  touch-action: none;
  user-select: none;
  cursor: grab;
}

#gravity-field:active {
  cursor: grabbing;
}

/* SVG Layer */
#connections-layer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none; /* Let clicks pass through */
  z-index: 1;
}

.connection-line {
  fill: none;
  stroke: var(--accent-color);
  stroke-width: 2;
  opacity: 0.4; /* Slightly more visible on dark */
  transition: opacity 0.3s ease;
}

/* Nodes */
.node {
  position: absolute;
  background: var(--node-bg);
  border: 1px solid var(--node-border);
  border-radius: 9999px; /* Pill shape */
  padding: 8px 16px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text-primary);
  box-shadow: var(--node-shadow);
  cursor: pointer;
  white-space: nowrap;
  z-index: 10;
  transform-origin: center center;
  will-change: transform, left, top; /* Performance optimization */
  transition:
    box-shadow 0.2s ease,
    background-color 0.2s ease,
    opacity 0.3s ease,
    border-color 0.2s ease;
}

.node:hover {
  box-shadow: var(--node-shadow-hover);
  transform: scale(1.05); /* Handled by JS engine usually, but CSS backup */
  z-index: 20;
  border-color: var(--accent-color);
}

/* Node States */
.node.is-anchor {
  background-color: var(--accent-color);
  color: white;
  box-shadow: var(--node-selected-glow);
  z-index: 50;
  border-color: var(--accent-color);
}

.node.is-related {
  border-color: #4b5563;
  color: #ffffff;
  background-color: #000000;
  z-index: 30;
}

.node.is-unrelated {
  opacity: var(--unrelated-opacity);
  z-index: 5;
  filter: grayscale(0.8);
}

/* Info Panel */
#info-panel {
  position: absolute;
  bottom: 20px;
  left: 20px;
  right: 20px; /* Full width minus margins on mobile */
  max-width: 340px;
  background: var(--panel-bg);
  padding: 16px;
  border-radius: 12px;
  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
  backdrop-filter: blur(8px);
  z-index: 100;
  pointer-events: none; /* Let clicks pass through unless interactive elements added */
  transition:
    opacity 0.3s ease,
    transform 0.3s ease;
  opacity: 1;
  transform: translateY(0);
}

#info-panel.hidden {
  opacity: 0;
  transform: translateY(20px);
  pointer-events: none;
}

#panel-title {
  font-size: 16px;
  font-weight: 600;
  margin-bottom: 4px;
  color: var(--text-primary);
}

#panel-desc {
  font-size: 13px;
  color: var(--text-secondary);
  line-height: 1.4;
  margin-bottom: 8px;
}

#panel-stats {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  color: var(--accent-color);
  margin-bottom: 4px;
}

#panel-related-list {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

#panel-related-list li {
  font-size: 11px;
  background: #e2e8f0;
  padding: 2px 6px;
  border-radius: 4px;
  color: var(--bg-color);
}

/* Interaction Hint */
#interaction-hint {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(0, 0, 0, 0.6);
  color: white;
  padding: 8px 16px;
  border-radius: 20px;
  font-size: 12px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.5s;
  z-index: 0;
}

/* Show hint initially if needed, logic in JS to fade out */

/* Responsive Tweaks */
@media (max-width: 800px) {
  #main-wrapper {
    flex-direction: column-reverse;
  }

  #sidebar {
    width: 100%;
    height: 35%;
    border-right: none;
    border-top: 1px solid var(--node-border);
    padding: 24px;
  }

  #gravity-field {
    height: 65%;
  }
}

@media (prefers-reduced-motion: reduce) {
  * {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

.btn-recenter {
  margin-top: auto;
  background: var(--accent-color);
  color: white;
  border: none;
  padding: 12px;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.btn-recenter:hover {
  background: #2563eb;
}

.btn-recenter svg {
  width: 18px;
  height: 18px;
}
    </style>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>

    <div id="main-wrapper">
        <aside id="sidebar">
            <div id="sidebar-content">
                <div id="instructions">
                    <h2>How to Interact</h2>
                    <ul>
                        <li><strong>Click and Hold</strong> any concept to see its connections.</li>
                        <li><strong>Release</strong> to return to the calm drift state.</li>
                        <li>Notice how related concepts cluster while unrelated ones move away.</li>
                    </ul>
                </div>
                <div id="concept-details" class="hidden">
                    <h2 id="panel-title">Concept Title</h2>
                    <p id="panel-desc">Short description of the concept goes here.</p>
                    <div id="panel-stats">
                        <span id="panel-related-count">0</span> related concepts
                    </div>
                    <ul id="panel-related-list"></ul>
                </div>
                <button id="recenter-btn" class="btn-recenter">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
                    Recenter Bubbles
                </button>
            </div>
        </aside>

        <div id="gravity-field" touch-action="none">
            <!-- SVG Layer for connection lines -->
            <svg id="connections-layer"></svg>
            
            <!-- Nodes will be injected here by JS -->
            <div id="nodes-container"></div>
            
            <!-- Interaction Hint (Optional Overlay) -->
            <div id="interaction-hint">Touch & Hold a Concept</div>
        </div>
    </div>

    <script>
/**
 * Concept Gravity Field
 * A visual, non-linear concept map where concepts float freely and orbit when selected.
 */

// --- Configuration ---
const CONFIG = {
    nodeCount: 16,
    repelStrength: 5, // Minimal separation force
    centeringForce: 0.0001, // Almost imperceptible pull to center
    damping: 0.995, // High floatiness, very low friction
    noiseStrength: 0.0001, // Minimal autonomous drift
    
    // Focus State
    orbitRadiusDesktop: 220, // More room for large bubbles
    orbitRadiusMobile: 150,
    orbitSpeed: 0.0005, // Ultra-slow rotation
    orbitSpring: 0.00005, // Ultra-slow pull to orbit
    
    featureEdgeRepel: 0.15, 
    unrelatedRepel: 3, // Even slower push
    maxSpeed: 0.5, // Hard cap on drift speed
    maxFocusSpeed: 2.5, // Hard cap on transition speed
    deadzone: 5, // Pixels to ignore for dragging to prevent wiggle
};

// --- State ---
const state = {
    mode: 'DRIFT', // 'DRIFT' | 'FOCUS'
    anchorNodeId: null,
    dragging: false,
    nodes: [],
    width: window.innerWidth,
    height: window.innerHeight,
    pointer: { x: 0, y: 0 },
    lastTime: 0,
    focusStartTime: 0
};

// --- Data Generation ---
function generateNodes(count) {
    // Example Data: Instructional Design / Learning Theory
    const rawNodes = [
        { id: 'n1', label: 'Cognitivism', group: 'Theory', related: ['n2', 'n3', 'n4'] },
        { id: 'n2', label: 'Constructivism', group: 'Theory', related: ['n1', 'n5'] },
        { id: 'n3', label: 'Scaffolding', group: 'Method', related: ['n5', 'n1', 'n6'] },
        { id: 'n4', label: 'Mental Models', group: 'Concept', related: ['n1'] },
        { id: 'n5', label: 'ZPD', group: 'Concept', related: ['n3', 'n2'] },
        { id: 'n6', label: 'Active Learning', group: 'Practice', related: ['n3', 'n2'] },
        { id: 'n7', label: 'Behaviorism', group: 'Theory', related: ['n8'] },
        { id: 'n8', label: 'Reinforcement', group: 'Concept', related: ['n7'] },
        { id: 'n9', label: 'Connectivism', group: 'Theory', related: ['n10'] },
        { id: 'n10', label: 'Networked Knowledge', group: 'Concept', related: ['n9'] },
        { id: 'n11', label: 'Andragogy', group: 'Theory', related: ['n12'] },
        { id: 'n12', label: 'Self-Directed', group: 'Concept', related: ['n11'] },
        { id: 'n13', label: 'Gamification', group: 'Practice', related: ['n8', 'n6'] },
        { id: 'n14', label: 'Microlearning', group: 'Practice', related: ['n4'] },
        { id: 'n15', label: 'Bloom’s Taxonomy', group: 'Framework', related: ['n1'] }
    ];

    const nodes = [];
    
    // Create node objects with physics properties
    rawNodes.forEach((data, i) => {
        nodes.push({
            id: data.id,
            label: data.label,
            description: `Description for ${data.label}.`,
            group: data.group,
            related: data.related, // Pre-defined
            x: Math.random() * state.width,
            y: Math.random() * state.height,
            vx: (Math.random() - 0.5) * 0.1, // Near-zero init velocity
            vy: (Math.random() - 0.5) * 0.1,
            radius: 50, // Reduced from 80 to minimize deadzone
            noiseOffset: Math.random() * 1000
        });
    });
    
    // Ensure symmetric relationships
    nodes.forEach(node => {
        node.related.forEach(relId => {
            const relNode = nodes.find(n => n.id === relId);
            if (relNode && !relNode.related.includes(node.id)) {
                relNode.related.push(node.id);
            }
        });
    });

    return nodes;
}

// --- DOM Elements ---
const container = document.getElementById('gravity-field');
const nodesContainer = document.getElementById('nodes-container');
const svgLayer = document.getElementById('connections-layer');
const instructions = document.getElementById('instructions');
const conceptDetails = document.getElementById('concept-details');
const panelTitle = document.getElementById('panel-title');
const panelDesc = document.getElementById('panel-desc');
const panelStats = document.getElementById('panel-related-count');
const panelList = document.getElementById('panel-related-list');

// --- Initialization ---
function init() {
    state.width = container.clientWidth;
    state.height = container.clientHeight;
    state.nodes = generateNodes(CONFIG.nodeCount);
    
    // Create DOM elements for nodes
    state.nodes.forEach(node => {
        const el = document.createElement('div');
        el.className = 'node';
        el.id = node.id;
        el.textContent = node.label;
        el.style.left = '0px';
        el.style.top = '0px';
        
        // Pointer events handled on container for delegation and dragging
        el.addEventListener('pointerdown', (e) => handleNodeDown(e, node));
        
        nodesContainer.appendChild(el);
        node.el = el;
    });

    // Event Listeners
    window.addEventListener('resize', handleResize);
    container.addEventListener('pointermove', handlePointerMove);
    container.addEventListener('pointerup', handlePointerUp);
    container.addEventListener('pointerleave', handlePointerUp);
    document.getElementById('recenter-btn').addEventListener('click', recenterNodes);

    // Start Loop
    requestAnimationFrame(tick);
}

// --- Interaction Handlers ---
function handleNodeDown(e, node) {
    e.preventDefault(); // Prevent text selection/scroll
    e.stopPropagation();
    
    state.mode = 'FOCUS';
    state.anchorNodeId = node.id;
    state.dragging = true;
    state.pointer.x = e.clientX;
    state.pointer.y = e.clientY;
    state.focusStartTime = performance.now();
    
    // Predetermine targets for related nodes on a ring
    const related = state.nodes.filter(n => node.related.includes(n.id));
    const orbitRadius = 140; // Brought closer (was 240)
    
    related.forEach((rel, i) => {
        // Split arc layout: Top-Right (-60 to -15 deg) and Bottom-Right (15 to 60 deg)
        const half = Math.ceil(related.length / 2);
        let angle;
        
        if (i < half) {
            // Top Right Segment
            const fraction = half === 1 ? 0.5 : i / (half - 1 || 1);
            angle = (-60 + fraction * 45) * (Math.PI / 180);
        } else {
            // Bottom Right Segment
            const localI = i - half;
            const remaining = related.length - half;
            const fraction = remaining <= 0 ? 0.5 : localI / (remaining - 1 || 1);
            angle = (15 + fraction * 45) * (Math.PI / 180);
        }

        rel.startX = rel.x;
        rel.startY = rel.y;
        rel.targetAngle = angle; // Store for consistent use in tick
        rel.targetX = node.x + Math.cos(angle) * orbitRadius;
        rel.targetY = node.y + Math.sin(angle) * orbitRadius;
        
        // Ensure within bounds (tightened margins)
        const margin = 20;
        rel.targetX = Math.max(margin, Math.min(state.width - margin, rel.targetX));
        rel.targetY = Math.max(margin, Math.min(state.height - margin, rel.targetY));
    });

    updatePanel(node);
    updateNodeVisuals();
}

function handlePointerMove(e) {
    if (state.dragging) {
        state.pointer.x = e.clientX;
        state.pointer.y = e.clientY;
    }
}

function handlePointerUp() {
    if (state.mode === 'FOCUS') {
        state.mode = 'DRIFT';
        state.anchorNodeId = null;
        state.dragging = false;
        
        hidePanel();
        resetNodeVisuals();
    }
}

function handleResize() {
    state.width = container.clientWidth;
    state.height = container.clientHeight;
}

// --- Visual Updates ---

function updatePanel(anchorNode) {
    instructions.classList.add('hidden');
    conceptDetails.classList.remove('hidden');
    
    panelTitle.textContent = anchorNode.label;
    panelDesc.textContent = anchorNode.description;
    
    const relatedNodes = state.nodes.filter(n => anchorNode.related.includes(n.id));
    panelStats.textContent = relatedNodes.length;
    
    panelList.innerHTML = '';
    relatedNodes.forEach(n => {
        const li = document.createElement('li');
        li.textContent = n.label;
        panelList.appendChild(li);
    });
}

function hidePanel() {
    instructions.classList.remove('hidden');
    conceptDetails.classList.add('hidden');
}

function recenterNodes() {
    state.nodes.forEach(node => {
        const dx = (state.width / 2) - node.x;
        const dy = (state.height / 2) - node.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        
        if (dist > 0) {
            // Apply a strong impulse towards center
            const force = 5; 
            node.vx += (dx / dist) * force;
            node.vy += (dy / dist) * force;
        }
    });
}

function updateNodeVisuals() {
    const anchor = state.nodes.find(n => n.id === state.anchorNodeId);
    
    state.nodes.forEach(node => {
        node.el.className = 'node'; // Reset
        
        if (node.id === state.anchorNodeId) {
            node.el.classList.add('is-anchor');
        } else if (anchor.related.includes(node.id)) {
            node.el.classList.add('is-related');
        } else {
            node.el.classList.add('is-unrelated');
        }
    });

    // Draw lines
    drawLines(anchor);
}

function resetNodeVisuals() {
    state.nodes.forEach(node => {
        node.el.className = 'node';
    });
    // Clear lines
    svgLayer.innerHTML = '';
}

function drawLines(anchor) {
    svgLayer.innerHTML = ''; // Clear prev lines
    
    state.nodes.forEach(node => {
        if (anchor.related.includes(node.id)) {
            const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
            line.setAttribute('class', 'connection-line');
            line.setAttribute('x1', anchor.x);
            line.setAttribute('y1', anchor.y);
            line.setAttribute('x2', node.x);
            line.setAttribute('y2', node.y);
            svgLayer.appendChild(line);
            
            // Store ref to update position in tick
            node.lineEl = line; 
        }
    });
}

// --- Physics Engine ---

function tick(timestamp) {
    if (!state.lastTime) state.lastTime = timestamp;
    const dt = Math.min((timestamp - state.lastTime) / 16, 2); // Cap at 2x speed for lag
    state.lastTime = timestamp;

    const anchor = state.nodes.find(n => n.id === state.anchorNodeId);
    const orbitRadius = state.width < 640 ? CONFIG.orbitRadiusMobile : CONFIG.orbitRadiusDesktop;

    state.nodes.forEach(node => {
        // 1. Base Forces (Drift & Noise) - Skip for active anchor
        if (node !== anchor || state.mode !== 'FOCUS') {
            const time = timestamp * 0.001;
            const noiseX = Math.sin(time + node.noiseOffset) * CONFIG.noiseStrength;
            const noiseY = Math.cos(time + node.noiseOffset * 0.9) * CONFIG.noiseStrength;
            
            node.vx += noiseX;
            node.vy += noiseY;
        }

        // 2. Separation (Anti-overlap) - Skip for active anchor in FOCUS
        if (node !== anchor || state.mode !== 'FOCUS') {
            state.nodes.forEach(other => {
                if (node === other) return;
                
                // Skip collision if one is unrelated and the other is anchor/related in FOCUS mode
                if (state.mode === 'FOCUS' && anchor) {
                    const isNodeUnrelated = node !== anchor && !anchor.related.includes(node.id);
                    const isOtherUnrelated = other !== anchor && !anchor.related.includes(other.id);
                    
                    // If one is unrelated and the other is NOT, they pass through each other
                    if (isNodeUnrelated !== isOtherUnrelated) return;
                }

                const dx = node.x - other.x;
                const dy = node.y - other.y;
                const dist = Math.sqrt(dx*dx + dy*dy);
                const minDist = node.radius + other.radius + 20; // 10px buffer
                
                if (dist < minDist && dist > 0) {
                    const force = (minDist - dist) / dist * 0.5; // Spring push
                    const fx = dx * force;
                    const fy = dy * force;
                    node.vx += fx;
                    node.vy += fy;
                }
            });
        }

        // 3. State-Specific Forces
        if (state.mode === 'DRIFT') {
            // Gentle containment to center
            const dx = (state.width / 2) - node.x;
            const dy = (state.height / 2) - node.y;
            node.vx += dx * CONFIG.centeringForce * 0.1;
            node.vy += dy * CONFIG.centeringForce * 0.1;

        } else if (state.mode === 'FOCUS' && anchor) {
            
            if (node === anchor) {
                // Dragging Logic: Pull anchor to pointer
                const rect = container.getBoundingClientRect();
                const targetX = state.pointer.x - rect.left;
                const targetY = state.pointer.y - rect.top;
                
                const dx = targetX - node.x;
                const dy = targetY - node.y;
                const dist = Math.sqrt(dx*dx + dy*dy);
                
                if (dist > CONFIG.deadzone) {
                    // smoothing (increased for quicker response)
                    node.vx += dx * 0.4;
                    node.vy += dy * 0.4;
                } else {
                    // Inside deadzone - absolute freeze
                    node.vx = 0;
                    node.vy = 0;
                }
                
            } else if (anchor.related.includes(node.id)) {
                // 1-SECOND ARRIVAL & FOLLOW ANIMATION
                const elapsed = timestamp - state.focusStartTime;
                const duration = 1000;
                const orbitRadius = 140;

                // Target is relative to ANCHOR'S CURRENT POSITION using stored angle
                const currentTargetX = anchor.x + Math.cos(node.targetAngle) * orbitRadius;
                const currentTargetY = anchor.y + Math.sin(node.targetAngle) * orbitRadius;

                if (elapsed < duration) {
                    const t = elapsed / duration;
                    const ease = 1 - Math.pow(1 - t, 4);
                    
                    node.x = node.startX + (currentTargetX - node.startX) * ease;
                    node.y = node.startY + (currentTargetY - node.startY) * ease;
                } else {
                    // Following - Pull towards the moving target (quicker follow)
                    node.x += (currentTargetX - node.x) * 0.4;
                    node.y += (currentTargetY - node.y) * 0.4;
                }
                
                node.vx = 0;
                node.vy = 0;
                node._animating = true;
            } else {
                // UNRELATED - EXILE BEHAVIOR
                const dx = node.x - anchor.x;
                const dy = node.y - anchor.y;
                const dist = Math.sqrt(dx*dx + dy*dy);
                
                // Repel from anchor
                if (dist > 0) {
                    const repulsion = CONFIG.unrelatedRepel / (dist * 0.05 + 1);
                    node.vx += (dx / dist) * repulsion;
                    node.vy += (dy / dist) * repulsion;
                }
                
                // Aggressive damping for unrelated nodes to stop them at the edge
                node.vx *= 0.9;
                node.vy *= 0.9;

            }
        }

        // 4. Boundary Constraints (Bounce) - Tightened
        const margin = 10;
        if (node.x < margin) { node.x = margin; node.vx *= -0.5; }
        if (node.x > state.width - margin) { node.x = state.width - margin; node.vx *= -0.5; }
        if (node.y < margin) { node.y = margin; node.vy *= -0.5; }
        if (node.y > state.height - margin) { node.y = state.height - margin; node.vy *= -0.5; }

        // 5. Apply Velocity & Damping
        // Skip if directly setting position during animation
        if (node._animating) {
            delete node._animating;
        } else {
            node.vx *= CONFIG.damping;
            node.vy *= CONFIG.damping;
            
            // Speed Cap
            const speed = Math.sqrt(node.vx*node.vx + node.vy*node.vy);
            const currentMax = state.mode === 'FOCUS' ? CONFIG.maxFocusSpeed : CONFIG.maxSpeed;
            if (speed > currentMax) {
                node.vx = (node.vx / speed) * currentMax;
                node.vy = (node.vy / speed) * currentMax;
            }

            node.x += node.vx * dt;
            node.y += node.vy * dt;
        }

        // 6. Hard Edge Stop for Unrelated in Focus - Tightened
        if (state.mode === 'FOCUS' && anchor && node !== anchor && !anchor.related.includes(node.id)) {
            const margin = 15;
            if (node.x <= margin || node.x >= state.width - margin || 
                node.y <= margin || node.y >= state.height - margin) {
                node.vx = 0;
                node.vy = 0;
            }
        }

        // 6. Update DOM
        // Center the element
        const w = node.el.offsetWidth;
        const h = node.el.offsetHeight;
        node.el.style.transform = `translate(${node.x - w/2}px, ${node.y - h/2}px)`;

        // Update Line if related
        if (node.lineEl && anchor) {
            node.lineEl.setAttribute('x1', anchor.x);
            node.lineEl.setAttribute('y1', anchor.y);
            node.lineEl.setAttribute('x2', node.x);
            node.lineEl.setAttribute('y2', node.y);
        }
    });

    requestAnimationFrame(tick);
}

// Start
init();
    </script>
</body>
</html>

 

No RepliesBe the first to reply