Forum Discussion
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>