Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 66 additions & 13 deletions src/hooks/useThoughtBubbles.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,41 @@ export const useThoughtBubbles = ({ isActive, onBubbleExpired }) => {
onBubbleExpiredRef.current = onBubbleExpired;
}, [onBubbleExpired]);

/**
* Calculate distance between two bubble positions
* Uses Euclidean distance formula accounting for viewport percentages
*/
const calculateDistance = (pos1, pos2) => {
const dx = pos1.x - pos2.x;
const dy = pos1.y - pos2.y;
return Math.sqrt(dx * dx + dy * dy);
};

/**
* Check if a position overlaps with existing bubbles
* Minimum distance threshold is ~25% of viewport to ensure clear separation
* (accounts for bubble width of ~20rem max, which is roughly 15-20% of typical mobile viewport)
*/
const isPositionValid = (newPos, existingBubbles) => {
const MIN_DISTANCE = 25; // Minimum distance in percentage units

for (const bubble of existingBubbles) {
const distance = calculateDistance(newPos, bubble.position);
if (distance < MIN_DISTANCE) {
return false;
}
}
return true;
};

/**
* Spawn new thought bubble
*
* Position calculation accounts for:
* - Top HUD: ~15% of viewport height
* - Bottom controls: ~30% of viewport height
* - Horizontal margins: ~10% on each side
* - Collision detection: Ensures bubbles don't overlap
*
* Bubbles use dynamic max-width constraints (via CSS calc) to prevent
* overflow on the right edge, ensuring full visibility across all viewports.
Expand All @@ -40,20 +68,45 @@ export const useThoughtBubbles = ({ isActive, onBubbleExpired }) => {
const randomWord = WORDS_OF_THE_VOICE[Math.floor(Math.random() * WORDS_OF_THE_VOICE.length)];
const bubbleId = `bubble_${bubbleIdCounter.current++}`;

// Calculate safe spawn area accounting for bubble dimensions
// Horizontal: 10-80% (leaving ~10% margin on each side for bubble width)
// Vertical: 20-55% (avoiding HUD at top 15%, controls at bottom 30%, with margins)
const newBubble = {
id: bubbleId,
word: randomWord,
spawnTime: Date.now(),
position: {
x: Math.random() * 70 + 10, // 10-80% from left
y: Math.random() * 35 + 20, // 20-55% from top
},
};
setActiveBubbles(prev => {
// Try to find a non-overlapping position
let position = null;
const MAX_ATTEMPTS = 20; // Maximum attempts to find valid position

for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const candidatePosition = {
x: Math.random() * 70 + 10, // 10-80% from left
y: Math.random() * 35 + 20, // 20-55% from top
};

if (isPositionValid(candidatePosition, prev)) {
position = candidatePosition;
break;
}
}

// Fallback: If no valid position found after max attempts,
// use a position anyway but space it vertically from others to minimize overlap
if (!position) {
const VERTICAL_STAGGER_OFFSET = 12; // % offset between staggered bubbles
const VERTICAL_RANGE = 36; // Total vertical range (55% - 20% + 1)
const MIN_VERTICAL_POSITION = 20; // Min Y position (top margin)

position = {
x: Math.random() * 70 + 10, // Random horizontal position
y: (prev.length * VERTICAL_STAGGER_OFFSET) % VERTICAL_RANGE + MIN_VERTICAL_POSITION, // 20-55%
};
}

const newBubble = {
id: bubbleId,
word: randomWord,
spawnTime: Date.now(),
position,
};

setActiveBubbles(prev => [...prev, newBubble]);
return [...prev, newBubble];
});

// Auto-remove after duration
setTimeout(() => {
Expand Down