<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixel Particle Image</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<canvas id="pixelCanvas"></canvas>
<script src="script.js"></script>
</body>
</html>
body {
margin: 0;
overflow: hidden;
background-color: #111; /* Dark background */
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
canvas {
display: block;
border: 1px solid #333; /* Optional: See canvas edges */
}
const canvas = document.getElementById('pixelCanvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
// --- CONFIGURATION ---
const PIXELATION_LEVEL = 3; // Detail level for final images (HIGH PERFORMANCE COST!)
const MOUSE_RADIUS = 50;
const MOUSE_REPEL_FORCE = 0.4;
const PARTICLE_FRICTION = 0.94;
const PARTICLE_RETURN_FORCE = 0.06;
const IMAGE_SCALE_FACTOR = 0.85;
const GRID_PADDING = 25;
const MAX_PARTICLES_PER_IMAGE_MOBILE = 1800; // Limit per image on mobile
// Loading Animation Config
const LOADING_PARTICLE_COUNT = 300; // Max number of loading particles
const LOADING_EMISSION_RATE = 3; // New loading particles per frame
const LOADING_PARTICLE_COLOR = `hsla(200, 80%, 70%, 0.8)`; // Light blueish
// --- IMAGE URLS ---
const imageUrls = [
"https://pbs.twimg.com/profile_images/1857142933432287232/6T5XP2t2_400x400.jpg",
"https://tinyurl.com/246zzrd9", "https://tinyurl.com/29ftotun", "https://tinyurl.com/29ueslk5",
"https://tinyurl.com/27lqh8oo", "https://tinyurl.com/273o4jcq", "https://tinyurl.com/2cv8tuvz",
"https://tinyurl.com/24mcf84u", "https://tinyurl.com/23bc7fc3", "https://tinyurl.com/23bc7fc3",
"https://tinyurl.com/23fn8y58", "https://tinyurl.com/25d6pa22"
];
// --- STATE VARIABLES ---
let imagesData = [];
const mouse = { x: undefined, y: undefined, radius: MOUSE_RADIUS };
let isMobile = window.innerWidth < 768;
let gridCols = 0, gridRows = 0, cellWidth = 0, cellHeight = 0;
let mainAnimationId = null; // ID for the main animation loop
let loadingAnimationId = null; // ID for the loading animation loop
let isLoading = true; // Start in loading state
let loadingParticles = []; // Array for loading animation particles
// --- Particle Class (for final images) ---
class Particle {
constructor(originX, originY, color) {
this.originX = originX; this.originY = originY;
this.color = color;
this.x = Math.random() * canvas.width; this.y = Math.random() * canvas.height;
this.vx = 0; this.vy = 0;
this.size = PIXELATION_LEVEL;
this.density = (Math.random() * 15) + 5;
}
update() {
let dxMouse = this.x - mouse.x, dyMouse = this.y - mouse.y;
let distanceMouseSq = dxMouse * dxMouse + dyMouse * dyMouse;
let mouseRadiusSq = mouse.radius * mouse.radius;
let force = 0, forceDirectionX = 0, forceDirectionY = 0;
if (mouse.x !== undefined && distanceMouseSq > 0 && distanceMouseSq < mouseRadiusSq) {
let distanceMouse = Math.sqrt(distanceMouseSq);
forceDirectionX = dxMouse / distanceMouse; forceDirectionY = dyMouse / distanceMouse;
force = (mouse.radius - distanceMouse) / mouse.radius * MOUSE_REPEL_FORCE * this.density;
}
let dxOrigin = this.originX - this.x, dyOrigin = this.originY - this.y;
this.vx += forceDirectionX * force + dxOrigin * PARTICLE_RETURN_FORCE;
this.vy += forceDirectionY * force + dyOrigin * PARTICLE_RETURN_FORCE;
this.vx *= PARTICLE_FRICTION; this.vy *= PARTICLE_FRICTION;
this.x += this.vx; this.y += this.vy;
}
draw() {
ctx.fillStyle = this.color;
ctx.fillRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size);
}
}
// --- Loading Particle Class ---
class LoadingParticle {
constructor(x, y) {
this.x = x;
this.y = y;
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 2 + 0.5;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.size = Math.random() * 2 + 1; // Smaller loading particles
this.maxLife = Math.random() * 80 + 40; // Lifetime in frames
this.life = this.maxLife;
this.opacity = 1;
this.gravity = 0.01; // Slight downward pull
}
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += this.gravity; // Apply gravity
this.life--;
this.opacity = Math.max(0, this.life / this.maxLife); // Fade out
this.vx *= 0.98; // Air friction
this.vy *= 0.98;
}
draw() {
if (this.opacity <= 0 || this.life <= 0) return;
ctx.fillStyle = `hsla(200, 80%, ${60 + this.opacity * 30}%, ${this.opacity * 0.7})`; // Fade and change lightness
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
// --- Emit Loading Particles ---
function emitLoadingParticles() {
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
for (let i = 0; i < LOADING_EMISSION_RATE; i++) {
if (loadingParticles.length >= LOADING_PARTICLE_COUNT) return;
// Emit from center with slight spread
const spawnX = centerX + (Math.random() - 0.5) * 20;
const spawnY = centerY + (Math.random() - 0.5) * 20;
loadingParticles.push(new LoadingParticle(spawnX, spawnY));
}
}
// --- Loading Animation Loop ---
function animateLoading() {
if (!isLoading) return; // Stop if loading is finished
ctx.fillStyle = 'rgba(17, 17, 17, 0.2)'; // Clear with trails
ctx.fillRect(0, 0, canvas.width, canvas.height);
emitLoadingParticles();
// Update and draw loading particles
for (let i = loadingParticles.length - 1; i >= 0; i--) {
const p = loadingParticles[i];
p.update();
if (p.life <= 0) {
loadingParticles.splice(i, 1); // Remove dead particles
} else {
p.draw();
}
}
// Optional: Draw "Loading..." text
ctx.fillStyle = "rgba(255, 255, 255, 0.5)";
ctx.font = "16px sans-serif";
ctx.textAlign = "center";
ctx.fillText("Loading Images...", canvas.width / 2, canvas.height / 2 + 40);
loadingAnimationId = requestAnimationFrame(animateLoading); // Continue loop
}
// --- Image Loading --- (No changes needed from previous version)
function loadImages() {
console.log("Starting image loading...");
imagesData = [];
const promises = imageUrls.map(url => { /* ... same as before ... */
return new Promise((resolve, reject) => {
const img = new Image();
const data = { url: url, img: img, particles: [], scaledWidth: 0, scaledHeight: 0, offsetX: 0, offsetY: 0, isLoaded: false, error: null };
imagesData.push(data);
img.crossOrigin = "anonymous";
img.onload = () => { console.log("Loaded:", url.substring(0, 50) + "..."); data.isLoaded = true; resolve(data); };
img.onerror = (err) => { console.error("Error loading:", url, err); data.error = "Load Error"; reject(data); }; // Use reject here
img.src = url;
});
});
return Promise.allSettled(promises).then(results => {
console.log("Image loading attempts finished.");
results.forEach((result, index) => { // Log failures after settling
if (result.status === 'rejected') {
// Error is already set in imagesData[index].error by the onerror handler
console.warn(`Image at index ${index} (${imagesData[index].url.substring(0,30)}...) failed to load.`);
}
});
return imagesData;
});
}
// --- Layout Calculation --- (No changes needed)
function calculateLayout() { /* ... same as before ... */
const loadedImages = imagesData.filter(d => d.isLoaded && !d.error);
const numImages = loadedImages.length;
if (numImages === 0) { console.warn("No images loaded successfully for layout."); gridCols=0; gridRows=0; cellWidth=0; cellHeight=0; return; }
const aspectRatio = canvas.width / canvas.height;
gridCols = Math.ceil(Math.sqrt(numImages * aspectRatio)); gridRows = Math.ceil(numImages / gridCols);
cellWidth = Math.max(1, (canvas.width - GRID_PADDING * (gridCols + 1)) / gridCols);
cellHeight = Math.max(1, (canvas.height - GRID_PADDING * (gridRows + 1)) / gridRows);
console.log(`Layout: ${gridCols}x${gridRows} grid. Cell size: ${cellWidth.toFixed(1)}x${cellHeight.toFixed(1)}`);
let currentImageIndex = 0;
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
if (currentImageIndex >= loadedImages.length) break;
const imgData = loadedImages[currentImageIndex];
const img = imgData.img; const imgAspect = img.naturalWidth / img.naturalHeight;
let targetW = cellWidth * IMAGE_SCALE_FACTOR; let targetH = cellHeight * IMAGE_SCALE_FACTOR;
if (targetW / targetH > imgAspect) { imgData.scaledHeight = Math.min(img.naturalHeight, targetH); imgData.scaledWidth = imgData.scaledHeight * imgAspect; }
else { imgData.scaledWidth = Math.min(img.naturalWidth, targetW); imgData.scaledHeight = imgData.scaledWidth / imgAspect; }
imgData.scaledWidth = Math.max(1, Math.floor(imgData.scaledWidth)); imgData.scaledHeight = Math.max(1, Math.floor(imgData.scaledHeight));
const cellX = GRID_PADDING + c * (cellWidth + GRID_PADDING); const cellY = GRID_PADDING + r * (cellHeight + GRID_PADDING);
imgData.offsetX = cellX + (cellWidth - imgData.scaledWidth) / 2; imgData.offsetY = cellY + (cellHeight - imgData.scaledHeight) / 2;
currentImageIndex++;
}
if (currentImageIndex >= loadedImages.length) break;
}
}
// --- Particle Creation (for one image) --- (No changes needed)
function scanImageAndCreateParticles(imgData) { /* ... same as before ... */
if (!imgData || !imgData.isLoaded || imgData.error || !imgData.scaledWidth || imgData.scaledWidth <= 0) return;
imgData.particles = [];
const offscreenCanvas = document.createElement('canvas'); offscreenCanvas.width = imgData.scaledWidth; offscreenCanvas.height = imgData.scaledHeight;
const offscreenCtx = offscreenCanvas.getContext('2d', { willReadFrequently: true }); offscreenCtx.drawImage(imgData.img, 0, 0, imgData.scaledWidth, imgData.scaledHeight);
let imageDataResult;
try { imageDataResult = offscreenCtx.getImageData(0, 0, imgData.scaledWidth, imgData.scaledHeight); }
catch (e) { console.error(`CORS Error getting ImageData for: ${imgData.url}`, e); imgData.error = "CORS Error"; return; }
const data = imageDataResult.data; let particleCount = 0; const maxParticles = isMobile ? MAX_PARTICLES_PER_IMAGE_MOBILE : Infinity;
for (let y = 0; y < imgData.scaledHeight; y += PIXELATION_LEVEL) {
for (let x = 0; x < imgData.scaledWidth; x += PIXELATION_LEVEL) {
if (particleCount >= maxParticles) break;
const index = (Math.floor(y) * imgData.scaledWidth + Math.floor(x)) * 4; if (index >= data.length - 3) continue;
const r = data[index], g = data[index + 1], b = data[index + 2], a = data[index + 3];
if (a > 128) { const color = `rgb(${r},${g},${b})`; const originX = x + imgData.offsetX; const originY = y + imgData.offsetY; imgData.particles.push(new Particle(originX, originY, color)); particleCount++; }
} if (particleCount >= maxParticles) break;
}
}
// --- Initialization (Called after images loaded and on resize) ---
function initialize() {
console.log("Initializing layout and particles...");
// Stop any running animation loops
if (loadingAnimationId) cancelAnimationFrame(loadingAnimationId);
if (mainAnimationId) cancelAnimationFrame(mainAnimationId);
isLoading = false; // Ensure loading state is false now
loadingParticles = []; // Clear loading particles immediately
// Setup canvas and mouse state
canvas.width = window.innerWidth; canvas.height = window.innerHeight;
isMobile = window.innerWidth < 768;
mouse.radius = isMobile ? MOUSE_RADIUS * 0.7 : MOUSE_RADIUS;
calculateLayout(); // Determine positions based on loaded images
// Scan and create particles for successfully loaded images
imagesData.forEach(imgData => {
if (imgData.isLoaded && !imgData.error) {
scanImageAndCreateParticles(imgData);
}
});
const totalParticles = imagesData.reduce((sum, data) => sum + (data.particles ? data.particles.length : 0), 0);
console.log("Total particles created:", totalParticles);
if (totalParticles > 50000) { console.warn(`PERFORMANCE WARNING: ${totalParticles} particles created. This may cause lag.`); }
if (totalParticles > 0) {
mainAnimationId = requestAnimationFrame(animate); // Start the *main* animation loop
} else {
console.warn("Initialization complete, but no particles were created. Main animation not started.");
if (imagesData.every(d => !d.isLoaded || d.error)) {
drawErrorState("Failed to load or process any images. Check URLs and CORS permissions in console (F12).");
} else {
ctx.fillStyle = 'rgba(17, 17, 17, 1)'; // Clear canvas if starting blank after loading
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawIndividualImageErrors(); // Show specific errors immediately
}
}
}
// --- Drawing Error States --- (No changes needed)
function drawErrorState(message) { /* ... same as before ... */
ctx.fillStyle="rgba(0,0,0,0.8)"; ctx.fillRect(0,0,canvas.width,canvas.height); ctx.fillStyle="white"; ctx.font="18px sans-serif"; ctx.textAlign="center"; ctx.fillText(message,canvas.width/2,canvas.height/2);
}
function drawIndividualImageErrors() { /* ... same as before ... */
imagesData.forEach(imgData=>{if(imgData.error&&imgData.scaledWidth>0){const cx=imgData.offsetX+imgData.scaledWidth/2; const cy=imgData.offsetY+imgData.scaledHeight/2; ctx.fillStyle="rgba(120,0,0,0.75)"; ctx.fillRect(imgData.offsetX,imgData.offsetY,imgData.scaledWidth,imgData.scaledHeight); ctx.fillStyle="white"; ctx.font="bold 11px sans-serif"; ctx.textAlign="center"; ctx.fillText(imgData.error,cx,cy-5); ctx.font="9px sans-serif"; ctx.fillText("URL: "+imgData.url.substring(0,30)+"...",cx,cy+5,imgData.scaledWidth*0.9);}});
}
// --- Main Animation Loop (for final images) ---
function animate() {
if (isLoading) return; // Should not run if still loading, but safety check
ctx.fillStyle = 'rgba(17, 17, 17, 0.3)'; // Clear with trails
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Update and draw image particles
imagesData.forEach(imgData => {
if (imgData.particles && imgData.particles.length > 0) {
imgData.particles.forEach(particle => {
particle.update();
particle.draw();
});
}
});
// Draw error overlays for failed images
drawIndividualImageErrors();
mainAnimationId = requestAnimationFrame(animate); // Continue loop
}
// --- Initial Canvas Setup ---
function setupInitialCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
ctx.fillStyle = '#111'; // Initial background
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// --- Event Listeners --- (No changes needed)
window.addEventListener('resize', () => {
// If resizing happens during initial load, just resize canvas and let loading continue
// If resizing happens after load, re-initialize everything
if (isLoading) {
setupInitialCanvas(); // Just resize canvas
} else {
initialize(); // Re-layout, re-scan particles after load
}
});
window.addEventListener('mousemove', (event) => { mouse.x = event.clientX; mouse.y = event.clientY; });
window.addEventListener('mouseout', () => { mouse.x = undefined; mouse.y = undefined; });
window.addEventListener('touchstart', (event) => { if (event.touches.length > 0) { mouse.x = event.touches[0].clientX; mouse.y = event.touches[0].clientY; } }, { passive: true });
window.addEventListener('touchmove', (event) => { if (event.touches.length > 0) { mouse.x = event.touches[0].clientX; mouse.y = event.touches[0].clientY; } }, { passive: true });
window.addEventListener('touchend', () => { mouse.x = undefined; mouse.y = undefined; });
// --- Start ---
console.log("Page loaded, setting up initial canvas and starting loading sequence...");
setupInitialCanvas(); // Set initial canvas size immediately
loadingAnimationId = requestAnimationFrame(animateLoading); // Start the loading animation loop
// Begin loading images
loadImages().then(() => {
// This .then() block executes after all image load attempts are settled
isLoading = false; // Set loading state to false
if (loadingAnimationId) cancelAnimationFrame(loadingAnimationId); // Stop loading animation
loadingParticles = []; // Clear loading particles
// Note: We don't explicitly clear the canvas here, letting initialize handle it
// or letting the first frame of the main 'animate' loop draw over the last loading frame.
initialize(); // Setup layout, create image particles, start main animation
}).catch(err => {
// Catch should ideally not be needed with allSettled, but handles unexpected setup errors
console.error("Critical error during initial setup:", err);
isLoading = false; // Ensure loading stops even on error
if (loadingAnimationId) cancelAnimationFrame(loadingAnimationId);
drawErrorState("An unexpected error occurred during setup.");
});
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.