// Dither.js v2.3 - Fixed Image Sizing & Panel Integration // Cache-bust: 2025-02-02-v2.3 class DitherEffect { constructor(imageElement, options = {}) { console.log("๐Ÿ”ง DitherEffect constructor called for:", imageElement.src); this.imageElement = imageElement; this.originalSrc = imageElement.src; // Store original for re-rendering this.options = { colorMode: options.colorMode || "monochrome", // 'monochrome', 'color', 'duotone', 'tritone' ditherScale: options.ditherScale || 1.0, // 0.5 to 4.0 threshold: options.threshold || 0.5, // 0 to 1 colors: options.colors || 2, // 2 to 16 colorPalette: options.colorPalette || ["#000000", "#888888", "#ffffff"], // Mouse effect options mouseEnabled: options.mouseEnabled || false, // false by default mouseRadius: options.mouseRadius || 0.15, // 0.05 to 0.5 mouseSoftness: options.mouseSoftness !== undefined ? options.mouseSoftness : 0.0, // 0 to 1 mouseStrength: options.mouseStrength || 1.0, // 0 to 1 mouseSmoothing: options.mouseSmoothing || 0.15, // 0 to 1 (higher = more inertia/lag) trailEnabled: options.trailEnabled || false, // false by default trailLength: options.trailLength || 15, // number of trail points trailDecay: options.trailDecay || 0.92, // 0 to 1 (higher = longer lasting trail) // Animation options animateEnabled: options.animateEnabled !== false, // true by default animateSpeed: options.animateSpeed || 1.0, // 0.1 to 3.0 animateAmount: options.animateAmount || 0.1, // 0 to 0.5 (shimmer intensity) // Scope overlay options scopeEnabled: options.scopeEnabled || false, // false by default scopeColor: options.scopeColor || "#00ff00", // HUD color scopeOpacity: options.scopeOpacity || 0.8, // 0 to 1 scopeShowStats: options.scopeShowStats !== false, // show coordinates and stats scopeShowCrosshair: options.scopeShowCrosshair !== false, // show targeting crosshair scopeShowScanlines: options.scopeShowScanlines !== false, // show scan line effect }; // Mouse state this.mousePos = { x: -1, y: -1 }; // Normalized 0-1, -1 means not hovering this.smoothMousePos = { x: -1, y: -1 }; // Smoothed mouse position for inertia this.isHovering = false; this.animationFrame = null; this.startTime = Date.now(); // Trail/feedback system - store recent mouse positions this.mouseTrail = []; this.maxTrailLength = 30; // Store last 30 positions console.log("๐Ÿ“Š Initial options:", this.options); // Check if image is truly ready (has dimensions and data accessible) const isImageReady = () => { const hasDimensions = this.imageElement.naturalWidth > 0 && this.imageElement.naturalHeight > 0; return this.imageElement.complete && hasDimensions; }; if (isImageReady()) { console.log("โœ… Image ready with dimensions:", this.imageElement.naturalWidth, "x", this.imageElement.naturalHeight); this.init(); } else { console.log("โณ Waiting for image to load... (complete:", this.imageElement.complete, "naturalWidth:", this.imageElement.naturalWidth, ")"); // Use polling as backup for lazy-loaded images let attempts = 0; const maxAttempts = 100; // 10 seconds max const checkImage = () => { attempts++; const hasDimensions = this.imageElement.naturalWidth > 0 && this.imageElement.naturalHeight > 0; if (this.imageElement.complete && hasDimensions) { console.log("โœ… Image ready via polling (attempt", attempts, "):", this.imageElement.naturalWidth, "x", this.imageElement.naturalHeight); this.init(); } else if (attempts < maxAttempts) { if (attempts % 10 === 0) { console.log(`๐Ÿ”„ Still waiting... attempt ${attempts}, complete: ${this.imageElement.complete}, naturalWidth: ${this.imageElement.naturalWidth}`); } setTimeout(checkImage, 100); } else { console.error("โŒ Image failed to load after 10 seconds"); console.error(" complete:", this.imageElement.complete); console.error(" naturalWidth:", this.imageElement.naturalWidth); console.error(" src:", this.imageElement.src.substring(0, 100)); } }; // Also listen for load event const loadHandler = () => { console.log("โœ… Image load event fired"); // Small delay to ensure dimensions are set setTimeout(() => { if (!this.initialized && isImageReady()) { console.log("๐Ÿš€ Initializing from load event"); this.init(); } }, 50); }; this.imageElement.addEventListener("load", loadHandler, { once: true }); this.imageElement.addEventListener("error", (e) => { console.error("โŒ Failed to load image:", this.imageElement.src.substring(0, 100)); console.error(" Error:", e); }, { once: true }); // Start polling immediately setTimeout(checkImage, 50); } } init() { // Prevent double initialization if (this.initialized) { console.log("โš ๏ธ Already initialized, skipping"); return; } this.initialized = true; console.log("๐Ÿš€ DitherEffect.init() called"); console.log( "๐Ÿ“ Image dimensions:", this.imageElement.width, "x", this.imageElement.height ); console.log( "๐Ÿ“ Natural dimensions:", this.imageElement.naturalWidth, "x", this.imageElement.naturalHeight ); if (typeof THREE === "undefined") { console.error("โŒ THREE.js is not loaded!"); return; } console.log("โœ… THREE.js is available"); // Create renderer this.renderer = new THREE.WebGLRenderer({ alpha: true, preserveDrawingBuffer: true, }); this.renderer.setSize( this.imageElement.width || 512, this.imageElement.height || 512 ); // Create scene and camera this.scene = new THREE.Scene(); this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); // Dithering shader with mouse interaction and animations const ditherShader = { uniforms: { tDiffuse: { value: null }, resolution: { value: new THREE.Vector2() }, ditherScale: { value: this.options.ditherScale }, threshold: { value: this.options.threshold }, colorMode: { value: this.options.colorMode === "monochrome" ? 0 : this.options.colorMode === "color" ? 1 : this.options.colorMode === "duotone" ? 2 : 3, }, colors: { value: this.options.colors }, color1: { value: new THREE.Color(this.options.colorPalette[0]) }, color2: { value: new THREE.Color(this.options.colorPalette[1]) }, color3: { value: new THREE.Color(this.options.colorPalette[2]) }, // Mouse uniforms mousePos: { value: new THREE.Vector2(-1, -1) }, mouseRadius: { value: this.options.mouseRadius }, mouseSoftness: { value: this.options.mouseSoftness }, mouseStrength: { value: this.options.mouseStrength }, mouseEnabled: { value: this.options.mouseEnabled ? 1.0 : 0.0 }, // Trail uniforms - array of past positions with strengths trailPositions: { value: this.createTrailArray() }, trailEnabled: { value: this.options.trailEnabled ? 1.0 : 0.0 }, trailCount: { value: 0 }, // Animation uniforms time: { value: 0.0 }, animateEnabled: { value: this.options.animateEnabled ? 1.0 : 0.0 }, animateSpeed: { value: this.options.animateSpeed }, animateAmount: { value: this.options.animateAmount }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform sampler2D tDiffuse; uniform vec2 resolution; uniform float ditherScale; uniform float threshold; uniform int colorMode; uniform float colors; uniform vec3 color1; uniform vec3 color2; uniform vec3 color3; // Mouse uniforms uniform vec2 mousePos; uniform float mouseRadius; uniform float mouseSoftness; uniform float mouseStrength; uniform float mouseEnabled; // Trail uniforms uniform vec3 trailPositions[30]; // x, y, strength uniform float trailEnabled; uniform int trailCount; // Animation uniforms uniform float time; uniform float animateEnabled; uniform float animateSpeed; uniform float animateAmount; varying vec2 vUv; float bayerMatrix[16]; void initBayer() { bayerMatrix[0] = 0.0/16.0; bayerMatrix[1] = 8.0/16.0; bayerMatrix[2] = 2.0/16.0; bayerMatrix[3] = 10.0/16.0; bayerMatrix[4] = 12.0/16.0; bayerMatrix[5] = 4.0/16.0; bayerMatrix[6] = 14.0/16.0; bayerMatrix[7] = 6.0/16.0; bayerMatrix[8] = 3.0/16.0; bayerMatrix[9] = 11.0/16.0; bayerMatrix[10] = 1.0/16.0; bayerMatrix[11] = 9.0/16.0; bayerMatrix[12] = 15.0/16.0; bayerMatrix[13] = 7.0/16.0; bayerMatrix[14] = 13.0/16.0; bayerMatrix[15] = 5.0/16.0; } // Simple noise function for animation float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); f = f * f * (3.0 - 2.0 * f); float a = hash(i); float b = hash(i + vec2(1.0, 0.0)); float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0)); return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); } // Calculate mouse influence including trail - blocky with steps matching dither size float getMouseInfluence() { if (mouseEnabled < 0.5) return 0.0; vec2 aspect = vec2(resolution.x / resolution.y, 1.0); float totalInfluence = 0.0; // One block = one dither cell (4x4 Bayer matrix = ditherScale * 4 pixels) float blockSize = (ditherScale * 4.0) / min(resolution.x, resolution.y); // Current mouse position influence if (mousePos.x >= 0.0) { float dist = length((vUv - mousePos) * aspect); // How many blocks fit in radius float blocksInRadius = mouseRadius / blockSize; // Which block ring are we in (0 = center, 1 = first ring, etc) float blockRing = floor(dist / blockSize); // Total rings that should be visible float totalRings = floor(blocksInRadius); // Hard blocky: if we're within the number of rings, show it if (blockRing < totalRings) { // Optional: stepped fade based on ring number (inner rings = stronger) if (mouseSoftness > 0.1) { // Each ring fades slightly - creates stepped gradient float ringFade = 1.0 - (blockRing / totalRings) * mouseSoftness; totalInfluence = max(totalInfluence, ringFade * mouseStrength); } else { // No softness = all rings same strength (hard edge) totalInfluence = max(totalInfluence, mouseStrength); } } } // Trail influence (if enabled) if (trailEnabled > 0.5) { for (int i = 0; i < 30; i++) { if (i >= trailCount) break; vec2 trailPos = trailPositions[i].xy; float trailStrength = trailPositions[i].z; if (trailPos.x >= 0.0 && trailStrength > 0.01) { float dist = length((vUv - trailPos) * aspect); float blocksInRadius = mouseRadius / blockSize; float blockRing = floor(dist / blockSize); float totalRings = floor(blocksInRadius); if (blockRing < totalRings) { float influence; if (mouseSoftness > 0.1) { float ringFade = 1.0 - (blockRing / totalRings) * mouseSoftness; influence = ringFade * mouseStrength * trailStrength; } else { influence = mouseStrength * trailStrength; } totalInfluence = max(totalInfluence, influence); } } } } return totalInfluence; } // Get dithered color with optional animation vec4 getDitheredColor(vec4 color) { vec2 pixelPos = vUv * resolution / ditherScale; int x = int(mod(pixelPos.x, 4.0)); int y = int(mod(pixelPos.y, 4.0)); int index = x + y * 4; float bayerValue = bayerMatrix[index]; // Add animated noise/shimmer to the threshold float animatedThreshold = threshold; if (animateEnabled > 0.5) { float noiseVal = noise(vUv * 50.0 + time * animateSpeed); animatedThreshold = threshold + (noiseVal - 0.5) * animateAmount * 0.3; } if (colorMode == 0) { // Monochrome float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); float adjustedThreshold = bayerValue - 0.5 + animatedThreshold; float dithered = gray > adjustedThreshold ? 1.0 : 0.0; return vec4(vec3(dithered), color.a); } else if (colorMode == 1) { // Color mode with quantization vec3 quantized; float animOffset = animateEnabled > 0.5 ? noise(vUv * 30.0 + time * animateSpeed) * animateAmount * 0.2 : 0.0; quantized.r = floor(color.r * colors + bayerValue + animOffset) / colors; quantized.g = floor(color.g * colors + bayerValue + animOffset) / colors; quantized.b = floor(color.b * colors + bayerValue + animOffset) / colors; return vec4(quantized, color.a); } else if (colorMode == 2) { // Duotone (2 colors) float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); float adjustedThreshold = bayerValue - 0.5 + animatedThreshold; vec3 finalColor = gray > adjustedThreshold ? color2 : color1; return vec4(finalColor, color.a); } else { // Tritone (3 colors) float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); float adjustedGray = gray + (bayerValue - 0.5) * 0.25; float animOffset = animateEnabled > 0.5 ? (noise(vUv * 50.0 + time * animateSpeed) - 0.5) * animateAmount * 0.2 : 0.0; adjustedGray += animOffset; vec3 finalColor; if (adjustedGray < 0.33) { finalColor = color1; // Dark } else if (adjustedGray < 0.66) { finalColor = color2; // Mid } else { finalColor = color3; // Light } return vec4(finalColor, color.a); } } void main() { initBayer(); vec4 originalColor = texture2D(tDiffuse, vUv); // Get the dithered version vec4 ditheredColor = getDitheredColor(originalColor); // Get mouse influence (0 = show dithered, 1 = show original) float mouseInfluence = getMouseInfluence(); // Blend between dithered and original gl_FragColor = mix(ditheredColor, originalColor, mouseInfluence); } `, }; // Create material and mesh this.material = new THREE.ShaderMaterial(ditherShader); const geometry = new THREE.PlaneGeometry(2, 2); this.mesh = new THREE.Mesh(geometry, this.material); this.scene.add(this.mesh); this.loadAndApply(); } loadAndApply() { console.log("๐ŸŽจ loadAndApply() called"); const srcToLoad = this.originalSrc || this.imageElement.src; console.log("๐Ÿ”— Loading texture from:", srcToLoad); // Reload image with crossorigin attribute to avoid tainted canvas this.loadWithCORS(); } loadWithCORS() { console.log("๐Ÿ”„ Loading image with CORS..."); const newImg = new Image(); newImg.crossOrigin = "anonymous"; newImg.onload = () => { console.log("โœ… CORS image loaded successfully"); console.log("๐Ÿ“ Image size:", newImg.width, "x", newImg.height); // Create texture directly from the CORS-enabled image const texture = new THREE.Texture(newImg); texture.needsUpdate = true; this.applyDither(texture, newImg.width, newImg.height); }; newImg.onerror = (e) => { console.error("โŒ CORS image load failed:", e); console.log("๐Ÿ’ก Trying canvas fallback..."); this.loadViaCanvas(); }; // Add cache-busting to force reload with CORS headers const src = this.originalSrc || this.imageElement.src; newImg.src = src + (src.includes("?") ? "&" : "?") + "_cors=" + Date.now(); } loadViaCanvas() { console.log("๐Ÿ–ผ๏ธ Loading via canvas method..."); const img = this.imageElement; const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); // Use natural dimensions for better quality const width = img.naturalWidth || img.width || 512; const height = img.naturalHeight || img.height || 512; canvas.width = width; canvas.height = height; console.log("๐Ÿ“ Canvas size:", width, "x", height); try { ctx.drawImage(img, 0, 0, width, height); console.log("โœ… Drew image to canvas"); // Create texture from canvas const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; this.applyDither(texture, width, height); } catch (e) { console.error("โŒ Canvas method failed (likely CORS):", e); console.log("๐Ÿ’ก Trying THREE.TextureLoader as fallback..."); this.loadViaTextureLoader(); } } loadViaTextureLoader() { const loader = new THREE.TextureLoader(); loader.setCrossOrigin("anonymous"); loader.load( this.originalSrc || this.imageElement.src, (texture) => { console.log("โœ… Texture loaded via TextureLoader"); const width = texture.image.width; const height = texture.image.height; this.applyDither(texture, width, height); }, undefined, (error) => { console.error("โŒ Error loading texture:", error); console.error("๐Ÿ’ก The image might have CORS restrictions."); console.error("๐Ÿ’ก Try adding crossorigin='anonymous' to the img tag"); } ); } applyDither(texture, width, height) { console.log("๐ŸŽจ Applying dither effect..."); console.log("๐Ÿ“ Target dimensions:", width, "x", height); this.material.uniforms.tDiffuse.value = texture; this.material.uniforms.resolution.value.set(width, height); this.renderer.setSize(width, height); // Update stored dimensions this.width = width; this.height = height; this.renderer.render(this.scene, this.camera); console.log("๐Ÿ–ผ๏ธ Render complete, applying to image"); // Get the dithered image data const dataURL = this.renderer.domElement.toDataURL("image/png"); console.log("๐Ÿ“Š DataURL length:", dataURL.length); console.log("๐Ÿ“Š DataURL preview:", dataURL.substring(0, 50) + "..."); // Store texture for updates this.currentTexture = texture; // Clear srcset to prevent Webflow responsive image override if (this.imageElement.srcset) { console.log("๐Ÿ”„ Clearing srcset attribute"); this.imageElement.srcset = ""; } // Remove sizes attribute too if (this.imageElement.sizes) { this.imageElement.removeAttribute("sizes"); } // Replace image src console.log("๐Ÿ”„ Setting image src..."); this.imageElement.src = dataURL; // Force style update to ensure visibility this.imageElement.style.opacity = "1"; console.log("โœ… Dither effect applied!"); console.log("๐Ÿ“Š New image src length:", this.imageElement.src.length); // Set up mouse interaction this.setupMouseInteraction(); } createTrailArray() { // Create array of vec3 for trail positions (x, y, strength) const arr = []; for (let i = 0; i < 30; i++) { arr.push(new THREE.Vector3(-1, -1, 0)); } return arr; } updateTrail() { if (!this.options.trailEnabled) return; // Decay existing trail points for (let i = 0; i < this.mouseTrail.length; i++) { this.mouseTrail[i].strength *= this.options.trailDecay; } // Remove very weak points this.mouseTrail = this.mouseTrail.filter((p) => p.strength > 0.01); // Add current position if hovering if (this.isHovering && this.mousePos.x >= 0) { this.mouseTrail.unshift({ x: this.smoothMousePos.x, y: this.smoothMousePos.y, strength: 1.0, }); } // Limit trail length const maxLen = Math.min(this.options.trailLength, 30); if (this.mouseTrail.length > maxLen) { this.mouseTrail = this.mouseTrail.slice(0, maxLen); } // Update shader uniforms const trailArr = this.material.uniforms.trailPositions.value; for (let i = 0; i < 30; i++) { if (i < this.mouseTrail.length) { trailArr[i].set( this.mouseTrail[i].x, this.mouseTrail[i].y, this.mouseTrail[i].strength ); } else { trailArr[i].set(-1, -1, 0); } } this.material.uniforms.trailCount.value = this.mouseTrail.length; } setupMouseInteraction() { console.log("๐Ÿ–ฑ๏ธ Setting up mouse interaction..."); // Create a canvas overlay for real-time rendering this.canvas = document.createElement("canvas"); this.canvas.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; opacity: 0; transition: opacity 0.2s ease; `; // Wrap image if not already wrapped let wrapper = this.imageElement.parentElement; if (!wrapper.classList.contains("dither-wrapper")) { wrapper = document.createElement("div"); wrapper.className = "dither-wrapper"; wrapper.style.cssText = ` position: relative; display: inline-block; width: ${this.imageElement.width}px; height: ${this.imageElement.height}px; `; this.imageElement.parentElement.insertBefore(wrapper, this.imageElement); wrapper.appendChild(this.imageElement); } else { // Update existing wrapper dimensions wrapper.style.width = this.imageElement.width + 'px'; wrapper.style.height = this.imageElement.height + 'px'; } wrapper.appendChild(this.canvas); this.wrapper = wrapper; // Set canvas size to match current texture if (this.currentTexture && this.currentTexture.image) { this.canvas.width = this.currentTexture.image.width; this.canvas.height = this.currentTexture.image.height; } else if (this.width && this.height) { // Fallback to stored dimensions this.canvas.width = this.width; this.canvas.height = this.height; } else { // Final fallback to image element dimensions this.canvas.width = this.imageElement.naturalWidth || this.imageElement.width || 512; this.canvas.height = this.imageElement.naturalHeight || this.imageElement.height || 512; } console.log("๐Ÿ“ Canvas sized to:", this.canvas.width, "x", this.canvas.height); // Mouse events wrapper.style.cursor = "crosshair"; wrapper.addEventListener("mouseenter", (e) => { if (!this.options.mouseEnabled) return; console.log("๐Ÿ–ฑ๏ธ Mouse entered"); this.isHovering = true; this.canvas.style.opacity = "1"; this.imageElement.style.opacity = "0"; this.startRenderLoop(); }); wrapper.addEventListener("mouseleave", (e) => { console.log("๐Ÿ–ฑ๏ธ Mouse left"); this.isHovering = false; this.mousePos = { x: -1, y: -1 }; // Keep canvas visible if animation is enabled if (!this.options.animateEnabled) { this.canvas.style.opacity = "0"; this.imageElement.style.opacity = "1"; } this.stopRenderLoop(); }); wrapper.addEventListener("mousemove", (e) => { if (!this.options.mouseEnabled || !this.isHovering) return; const rect = wrapper.getBoundingClientRect(); this.mousePos.x = (e.clientX - rect.left) / rect.width; this.mousePos.y = 1.0 - (e.clientY - rect.top) / rect.height; // Flip Y for WebGL }); // Enable pointer events on canvas for interaction this.canvas.style.pointerEvents = "auto"; this.canvas.addEventListener("mouseenter", (e) => { if (!this.options.mouseEnabled) return; this.isHovering = true; this.canvas.style.opacity = "1"; this.imageElement.style.opacity = "0"; this.startRenderLoop(); }); this.canvas.addEventListener("mouseleave", (e) => { this.isHovering = false; this.mousePos = { x: -1, y: -1 }; // Keep canvas visible if animation is enabled if (!this.options.animateEnabled) { this.canvas.style.opacity = "0"; this.imageElement.style.opacity = "1"; } this.stopRenderLoop(); }); this.canvas.addEventListener("mousemove", (e) => { if (!this.options.mouseEnabled || !this.isHovering) return; const rect = this.canvas.getBoundingClientRect(); this.mousePos.x = (e.clientX - rect.left) / rect.width; this.mousePos.y = 1.0 - (e.clientY - rect.top) / rect.height; }); console.log("โœ… Mouse interaction ready"); // Create sci-fi scope overlay if (this.options.scopeEnabled) { this.createScopeOverlay(); } // Do an initial render to canvas so it's ready (even if not animating) // This ensures the canvas has content for mouse interactions const ctx = this.canvas.getContext("2d"); ctx.drawImage( this.renderer.domElement, 0, 0, this.canvas.width, this.canvas.height ); console.log("๐ŸŽจ Initial canvas render complete"); // If animation is enabled, start continuous rendering if (this.options.animateEnabled) { console.log("โœจ Animation enabled, starting continuous render..."); // Render one frame immediately before showing canvas to prevent flash this.material.uniforms.animateEnabled.value = 1.0; this.renderer.render(this.scene, this.camera); // Copy to canvas immediately const ctx = this.canvas.getContext("2d"); ctx.drawImage( this.renderer.domElement, 0, 0, this.canvas.width, this.canvas.height ); // Now make canvas visible and hide original image this.canvas.style.opacity = "1"; this.imageElement.style.opacity = "0"; // Start the render loop this.startRenderLoop(); } } createScopeOverlay() { console.log("๐ŸŽฏ Creating sci-fi scope overlay..."); // Create overlay container this.scopeOverlay = document.createElement("div"); this.scopeOverlay.className = "dither-scope-overlay"; this.scopeOverlay.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; opacity: 0; transition: opacity 0.3s ease; z-index: 10; `; // Create scope circle indicator this.scopeCircle = document.createElement("div"); this.scopeCircle.className = "scope-circle"; // Calculate actual pixel size based on the smaller dimension for perfect circle const rect = this.wrapper.getBoundingClientRect(); const minDimension = Math.min(rect.width, rect.height); const radiusPixels = minDimension * this.options.mouseRadius; const diameterPixels = radiusPixels * 2; this.scopeCircle.style.cssText = ` position: absolute; width: ${diameterPixels}px; height: ${diameterPixels}px; border: 2px solid ${this.options.scopeColor}; border-radius: 50%; box-shadow: 0 0 20px ${this.options.scopeColor}40, inset 0 0 20px ${this.options.scopeColor}20; transform: translate(-50%, -50%); pointer-events: none; opacity: ${this.options.scopeOpacity}; `; // Create crosshair if (this.options.scopeShowCrosshair) { const crosshair = document.createElement("div"); crosshair.className = "scope-crosshair"; crosshair.innerHTML = `
`; this.scopeCircle.appendChild(crosshair); } // Create HUD stats display if (this.options.scopeShowStats) { this.scopeStats = document.createElement("div"); this.scopeStats.className = "scope-stats"; this.scopeStats.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; bottom: 0; font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold; color: ${this.options.scopeColor}; text-shadow: 0 0 8px ${this.options.scopeColor}, 0 0 15px ${this.options.scopeColor}aa, 1px 1px 2px rgba(0,0,0,0.9), -1px -1px 2px rgba(0,0,0,0.9), 1px -1px 2px rgba(0,0,0,0.9), -1px 1px 2px rgba(0,0,0,0.9); line-height: 1.6; pointer-events: none; opacity: ${this.options.scopeOpacity}; `; this.scopeCircle.appendChild(this.scopeStats); } // Create scanlines effect if (this.options.scopeShowScanlines) { const scanlines = document.createElement("div"); scanlines.className = "scope-scanlines"; scanlines.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: repeating-linear-gradient( 0deg, transparent 0px, transparent 2px, ${this.options.scopeColor}10 2px, ${this.options.scopeColor}10 4px ); pointer-events: none; opacity: 0.3; border-radius: 50%; overflow: hidden; `; this.scopeCircle.appendChild(scanlines); } // Add corner markers const corners = ["top-left", "top-right", "bottom-left", "bottom-right"]; corners.forEach((corner) => { const marker = document.createElement("div"); const [vPos, hPos] = corner.split("-"); marker.style.cssText = ` position: absolute; ${vPos}: 5%; ${hPos}: 5%; width: 15px; height: 15px; border-${vPos}: 2px solid ${this.options.scopeColor}; border-${hPos}: 2px solid ${this.options.scopeColor}; opacity: ${this.options.scopeOpacity * 0.6}; `; this.scopeCircle.appendChild(marker); }); this.scopeOverlay.appendChild(this.scopeCircle); this.wrapper.appendChild(this.scopeOverlay); // Initialize tracking variables for stats this.scopeData = { targetLock: false, distance: 0, energy: 100, signal: 0, }; console.log("โœ… Scope overlay created"); } updateScopeOverlay() { if (!this.scopeOverlay || !this.options.scopeEnabled) return; // Update circle position if (this.mousePos.x >= 0 && this.isHovering) { const rect = this.wrapper.getBoundingClientRect(); const x = this.mousePos.x * rect.width; const y = (1 - this.mousePos.y) * rect.height; this.scopeCircle.style.left = `${x}px`; this.scopeCircle.style.top = `${y}px`; this.scopeOverlay.style.opacity = "1"; // Update stats with some dynamic values if (this.scopeStats && this.options.scopeShowStats) { // Calculate some fake but realistic-looking values const coordX = Math.round(this.mousePos.x * 1000); const coordY = Math.round(this.mousePos.y * 1000); // Simulate signal strength based on position this.scopeData.signal = Math.round( 50 + Math.sin(Date.now() / 1000) * 30 + Math.random() * 20 ); // Simulate distance with some variation this.scopeData.distance = (15.2 + Math.random() * 2).toFixed(1); // Energy slowly depletes if (this.scopeData.energy > 0) { this.scopeData.energy = Math.max(0, this.scopeData.energy - 0.05); } // Target lock status const centerDist = Math.sqrt( Math.pow(this.mousePos.x - 0.5, 2) + Math.pow(this.mousePos.y - 0.5, 2) ); this.scopeData.targetLock = centerDist < 0.3; this.scopeStats.innerHTML = `
โ—‰ TACTICAL SCOPE
X:${coordX} Y:${coordY}
RNG: ${this.scopeData.distance}m
SIG: ${this.scopeData.signal}%
PWR: ${Math.round(this.scopeData.energy)}%
${this.scopeData.targetLock ? "โ— LOCK" : "โ—‹ SCAN"}
`; } } else { this.scopeOverlay.style.opacity = "0"; // Reset energy when not active this.scopeData.energy = 100; } } startRenderLoop() { if (this.animationFrame) return; const render = () => { // Continue if hovering OR if animation is enabled const shouldAnimate = this.isHovering || this.options.animateEnabled; if (!shouldAnimate) { this.animationFrame = null; return; } // Update time uniform for animations const elapsed = (Date.now() - this.startTime) / 1000.0; this.material.uniforms.time.value = elapsed; // Smooth mouse position (lerp toward actual mouse) if (this.mousePos.x >= 0) { this.smoothMousePos.x += (this.mousePos.x - this.smoothMousePos.x) * this.options.mouseSmoothing; this.smoothMousePos.y += (this.mousePos.y - this.smoothMousePos.y) * this.options.mouseSmoothing; } // Update trail this.updateTrail(); // Update mouse uniform this.material.uniforms.mousePos.value.set( this.smoothMousePos.x, this.smoothMousePos.y ); this.material.uniforms.mouseRadius.value = this.options.mouseRadius; this.material.uniforms.mouseSoftness.value = this.options.mouseSoftness; this.material.uniforms.mouseStrength.value = this.options.mouseStrength; this.material.uniforms.trailEnabled.value = this.options.trailEnabled ? 1.0 : 0.0; // Update animation uniforms this.material.uniforms.animateEnabled.value = this.options.animateEnabled ? 1.0 : 0.0; this.material.uniforms.animateSpeed.value = this.options.animateSpeed; this.material.uniforms.animateAmount.value = this.options.animateAmount; // Render this.renderer.render(this.scene, this.camera); // Copy to visible canvas const ctx = this.canvas.getContext("2d"); ctx.drawImage( this.renderer.domElement, 0, 0, this.canvas.width, this.canvas.height ); // Update scope overlay position and stats this.updateScopeOverlay(); this.animationFrame = requestAnimationFrame(render); }; render(); } stopRenderLoop() { // Don't stop if animation is enabled if (this.options.animateEnabled) { // Just reset mouse position but keep animating this.material.uniforms.mousePos.value.set(-1, -1); return; } if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } // Reset mouse position and re-render static image this.material.uniforms.mousePos.value.set(-1, -1); this.renderer.render(this.scene, this.camera); } updateOptions(newOptions) { console.log("๐Ÿ”„ updateOptions called with:", newOptions); const wasAnimating = this.options.animateEnabled; Object.assign(this.options, newOptions); if (this.material && this.currentTexture) { this.material.uniforms.ditherScale.value = this.options.ditherScale; this.material.uniforms.threshold.value = this.options.threshold; this.material.uniforms.colorMode.value = this.options.colorMode === "monochrome" ? 0 : this.options.colorMode === "color" ? 1 : this.options.colorMode === "duotone" ? 2 : 3; this.material.uniforms.colors.value = this.options.colors; this.material.uniforms.color1.value.set(this.options.colorPalette[0]); this.material.uniforms.color2.value.set(this.options.colorPalette[1]); this.material.uniforms.color3.value.set( this.options.colorPalette[2] || "#ffffff" ); // Update mouse options this.material.uniforms.mouseRadius.value = this.options.mouseRadius; this.material.uniforms.mouseSoftness.value = this.options.mouseSoftness; this.material.uniforms.mouseStrength.value = this.options.mouseStrength; this.material.uniforms.mouseEnabled.value = this.options.mouseEnabled ? 1.0 : 0.0; // Update trail options this.material.uniforms.trailEnabled.value = this.options.trailEnabled ? 1.0 : 0.0; // Update animation options this.material.uniforms.animateEnabled.value = this.options.animateEnabled ? 1.0 : 0.0; this.material.uniforms.animateSpeed.value = this.options.animateSpeed; this.material.uniforms.animateAmount.value = this.options.animateAmount; // Handle animation toggle if (this.options.animateEnabled && !wasAnimating && this.canvas) { // Animation was just enabled console.log("โœจ Animation enabled, starting continuous render..."); this.canvas.style.opacity = "1"; this.imageElement.style.opacity = "0"; this.startRenderLoop(); } else if ( !this.options.animateEnabled && wasAnimating && this.canvas && !this.isHovering ) { // Animation was just disabled and not hovering console.log("โœจ Animation disabled, stopping render..."); if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } this.canvas.style.opacity = "0"; this.imageElement.style.opacity = "1"; } // Re-render with updated options (reset mouse for static render) this.material.uniforms.mousePos.value.set(-1, -1); this.renderer.render(this.scene, this.camera); // Update scope overlay if it exists if (this.scopeOverlay) { // Recreate scope overlay with new settings if scope was toggled or significantly changed const scopeNeedsRecreate = ("scopeEnabled" in newOptions && newOptions.scopeEnabled !== this.options.scopeEnabled) || "scopeColor" in newOptions || "scopeShowCrosshair" in newOptions || "scopeShowScanlines" in newOptions || "scopeShowStats" in newOptions || "mouseRadius" in newOptions; // Also recreate if radius changes if (scopeNeedsRecreate) { this.scopeOverlay.remove(); this.scopeOverlay = null; this.scopeCircle = null; this.scopeStats = null; if (this.options.scopeEnabled) { this.createScopeOverlay(); } } else if (this.scopeCircle) { // Just update existing scope styles this.scopeCircle.style.borderColor = this.options.scopeColor; this.scopeCircle.style.boxShadow = ` 0 0 20px ${this.options.scopeColor}40, inset 0 0 20px ${this.options.scopeColor}20 `; this.scopeCircle.style.opacity = this.options.scopeOpacity; if (this.scopeStats) { this.scopeStats.style.color = this.options.scopeColor; this.scopeStats.style.textShadow = ` 0 0 10px ${this.options.scopeColor}, 0 0 20px ${this.options.scopeColor}aa, 1px 1px 2px rgba(0,0,0,0.8), -1px -1px 2px rgba(0,0,0,0.8), 1px -1px 2px rgba(0,0,0,0.8), -1px 1px 2px rgba(0,0,0,0.8) `; this.scopeStats.style.opacity = this.options.scopeOpacity; this.scopeStats.style.borderColor = `${this.options.scopeColor}60`; } } } // Only update static image if not animating if (!this.options.animateEnabled && !this.isHovering) { const dataURL = this.renderer.domElement.toDataURL("image/png"); console.log("๐Ÿ“Š Updated DataURL length:", dataURL.length); // Clear srcset again in case it was restored if (this.imageElement.srcset) { this.imageElement.srcset = ""; } this.imageElement.src = dataURL; } console.log("โœ… Options updated and applied"); } else { console.warn("โš ๏ธ Material or texture not ready for update"); } } } // Store instances for external control window.ditherInstances = new Map(); // Helper function to read current control values from DOM function getControlValues() { const controls = { colorMode: document.getElementById("ditherColorMode"), ditherScale: document.getElementById("ditherScaleSlider"), threshold: document.getElementById("ditherThresholdSlider"), colors: document.getElementById("ditherColorsSlider"), color1: document.getElementById("ditherColor1"), color2: document.getElementById("ditherColor2"), color3: document.getElementById("ditherColor3"), mouseEnabled: document.getElementById("ditherMouseEnabled"), mouseRadius: document.getElementById("ditherMouseRadiusSlider"), mouseSoftness: document.getElementById("ditherMouseSoftnessSlider"), mouseStrength: document.getElementById("ditherMouseStrengthSlider"), trailEnabled: document.getElementById("ditherTrailEnabled"), trailLength: document.getElementById("ditherTrailLengthSlider"), trailDecay: document.getElementById("ditherTrailDecaySlider"), mouseSmoothing: document.getElementById("ditherMouseSmoothingSlider"), animateEnabled: document.getElementById("ditherAnimateEnabled"), animateSpeed: document.getElementById("ditherAnimateSpeedSlider"), animateAmount: document.getElementById("ditherAnimateAmountSlider"), scopeEnabled: document.getElementById("ditherScopeEnabled"), scopeColor: document.getElementById("ditherScopeColor"), scopeOpacity: document.getElementById("ditherScopeOpacitySlider"), scopeShowStats: document.getElementById("ditherScopeShowStats"), scopeShowCrosshair: document.getElementById("ditherScopeShowCrosshair"), scopeShowScanlines: document.getElementById("ditherScopeShowScanlines"), }; // If controls don't exist, return null (will use defaults) if (!controls.colorMode || !controls.ditherScale) { console.log("โš ๏ธ Controls not found in DOM, using default values"); return null; } // Read values from controls const values = { colorMode: controls.colorMode.value, ditherScale: parseFloat(controls.ditherScale.value), threshold: parseFloat(controls.threshold.value), colors: parseInt(controls.colors.value), colorPalette: [ controls.color1.value, controls.color2.value, controls.color3.value, ], mouseEnabled: controls.mouseEnabled.checked, mouseRadius: parseFloat(controls.mouseRadius.value), mouseSoftness: parseFloat(controls.mouseSoftness.value), mouseStrength: parseFloat(controls.mouseStrength.value), mouseSmoothing: parseFloat(controls.mouseSmoothing.value), trailEnabled: controls.trailEnabled.checked, trailLength: parseInt(controls.trailLength.value), trailDecay: parseFloat(controls.trailDecay.value), animateEnabled: controls.animateEnabled.checked, animateSpeed: parseFloat(controls.animateSpeed.value), animateAmount: parseFloat(controls.animateAmount.value), scopeEnabled: controls.scopeEnabled?.checked ?? true, scopeColor: controls.scopeColor?.value ?? "#00ff00", scopeOpacity: parseFloat(controls.scopeOpacity?.value ?? 0.8), scopeShowStats: controls.scopeShowStats?.checked ?? true, scopeShowCrosshair: controls.scopeShowCrosshair?.checked ?? true, scopeShowScanlines: controls.scopeShowScanlines?.checked ?? true, }; console.log("โœ… Read control values:", values); return values; } // Initialize dither on all images with data-dither attribute function initDitherImages() { console.log("๐Ÿ” initDitherImages() called"); const ditherImages = document.querySelectorAll("img[data-dither]"); console.log( "๐Ÿ–ผ๏ธ Found", ditherImages.length, "images with data-dither attribute" ); // Get current control values (or null if controls not available) const controlValues = getControlValues(); if (!controlValues) { console.log("โš ๏ธ No control values found, using defaults"); } ditherImages.forEach((img, index) => { console.log(`๐Ÿ“ท Image ${index}:`, img.src.substring(0, 50) + "..."); console.log(` - complete: ${img.complete}`); console.log(` - naturalWidth: ${img.naturalWidth}`); console.log(` - crossOrigin: ${img.crossOrigin || 'not set'}`); console.log(` - Already processed: ${window.ditherInstances.has(img)}`); if (!window.ditherInstances.has(img)) { // Check if image is actually ready if (img.complete && img.naturalWidth > 0) { const instance = new DitherEffect(img, controlValues || {}); window.ditherInstances.set(img, instance); console.log(` โœ… Created dither instance for image ${index}`); } else { console.log(` โณ Image not ready yet, waiting...`); // Wait for image to be ready const waitForImage = () => { if (img.complete && img.naturalWidth > 0) { console.log(` โœ… Image ${index} now ready`); const instance = new DitherEffect(img, controlValues || {}); window.ditherInstances.set(img, instance); } else { setTimeout(waitForImage, 100); } }; setTimeout(waitForImage, 100); } } else { console.log(` โญ๏ธ Already has instance, skipping`); } }); console.log("๐Ÿ“Š Total dither instances:", window.ditherInstances.size); } // Auto-initialize - handle both DOMContentLoaded and already-loaded cases if (document.readyState === "loading") { console.log("โณ DOM still loading, waiting for DOMContentLoaded..."); document.addEventListener("DOMContentLoaded", initDitherImages); } else { console.log("โœ… DOM already loaded, initializing immediately"); initDitherImages(); } // Also watch for lazy-loaded images const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === "attributes" && mutation.attributeName === "src") { const img = mutation.target; if (img.hasAttribute("data-dither") && !window.ditherInstances.has(img)) { console.log("๐Ÿ”„ Lazy image detected, initializing dither:", img.src); const instance = new DitherEffect(img); window.ditherInstances.set(img, instance); } } }); }); observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ["src"], }); console.log("๐Ÿ‘€ MutationObserver set up for lazy-loaded images"); // Expose init function globally for manual triggering window.initDitherImages = initDitherImages; // ============================================ // DITHER CONTROLS - Initialize UI Controls // ============================================ function initDitherControls() { console.log("๐ŸŽ›๏ธ Initializing Dither Controls..."); const controls = { colorMode: document.getElementById("ditherColorMode"), ditherScale: document.getElementById("ditherScaleSlider"), threshold: document.getElementById("ditherThresholdSlider"), colors: document.getElementById("ditherColorsSlider"), color1: document.getElementById("ditherColor1"), color2: document.getElementById("ditherColor2"), color3: document.getElementById("ditherColor3"), // Mouse controls mouseEnabled: document.getElementById("ditherMouseEnabled"), mouseRadius: document.getElementById("ditherMouseRadiusSlider"), mouseSoftness: document.getElementById("ditherMouseSoftnessSlider"), mouseStrength: document.getElementById("ditherMouseStrengthSlider"), // Trail controls trailEnabled: document.getElementById("ditherTrailEnabled"), trailLength: document.getElementById("ditherTrailLengthSlider"), trailDecay: document.getElementById("ditherTrailDecaySlider"), mouseSmoothing: document.getElementById("ditherMouseSmoothingSlider"), // Animation controls animateEnabled: document.getElementById("ditherAnimateEnabled"), animateSpeed: document.getElementById("ditherAnimateSpeedSlider"), animateAmount: document.getElementById("ditherAnimateAmountSlider"), // Scope controls scopeEnabled: document.getElementById("ditherScopeEnabled"), scopeColor: document.getElementById("ditherScopeColor"), scopeOpacity: document.getElementById("ditherScopeOpacitySlider"), scopeShowStats: document.getElementById("ditherScopeShowStats"), scopeShowCrosshair: document.getElementById("ditherScopeShowCrosshair"), scopeShowScanlines: document.getElementById("ditherScopeShowScanlines"), }; // Check if controls exist in the DOM if (!controls.colorMode || !controls.ditherScale) { console.log("โณ Controls not found yet, will retry..."); return false; } console.log("โœ… All controls found in DOM"); function updateDitherEffect() { console.log("๐Ÿ”„ updateDitherEffect called"); const ditherImages = document.querySelectorAll("img[data-dither]"); console.log("๐Ÿ–ผ๏ธ Found dither images:", ditherImages.length); console.log("๐Ÿ–ผ๏ธ DitherInstances size:", window.ditherInstances?.size || 0); ditherImages.forEach((img, index) => { const instance = window.ditherInstances?.get(img); console.log(` Image ${index}: Instance found = ${!!instance}`); if (instance) { const options = { colorMode: controls.colorMode.value, ditherScale: parseFloat(controls.ditherScale.value), threshold: parseFloat(controls.threshold.value), colors: parseFloat(controls.colors.value), colorPalette: [ controls.color1.value, controls.color2.value, controls.color3?.value ?? "#ffffff", ], // Mouse options - OFF by default mouseEnabled: controls.mouseEnabled?.checked ?? false, mouseRadius: parseFloat(controls.mouseRadius?.value ?? 0.15), mouseSoftness: parseFloat(controls.mouseSoftness?.value ?? 0.5), mouseStrength: parseFloat(controls.mouseStrength?.value ?? 1.0), // Trail options - OFF by default trailEnabled: controls.trailEnabled?.checked ?? false, trailLength: parseInt(controls.trailLength?.value ?? 15), trailDecay: parseFloat(controls.trailDecay?.value ?? 0.92), mouseSmoothing: parseFloat(controls.mouseSmoothing?.value ?? 0.15), // Animation options - ON by default animateEnabled: controls.animateEnabled?.checked ?? true, animateSpeed: parseFloat(controls.animateSpeed?.value ?? 1.0), animateAmount: parseFloat(controls.animateAmount?.value ?? 0.1), // Scope options - OFF by default scopeEnabled: controls.scopeEnabled?.checked ?? false, scopeColor: controls.scopeColor?.value ?? "#00ff00", scopeOpacity: parseFloat(controls.scopeOpacity?.value ?? 0.8), scopeShowStats: controls.scopeShowStats?.checked ?? true, scopeShowCrosshair: controls.scopeShowCrosshair?.checked ?? true, scopeShowScanlines: controls.scopeShowScanlines?.checked ?? true, }; console.log("๐Ÿ“Š Updating with options:", options); instance.updateOptions(options); } else { console.warn("โš ๏ธ No instance for image, creating one..."); const newInstance = new DitherEffect(img); window.ditherInstances.set(img, newInstance); } }); } function updateDitherUI() { document.getElementById("ditherScaleDisplay").textContent = controls.ditherScale.value; document.getElementById("ditherThresholdDisplay").textContent = controls.threshold.value; document.getElementById("ditherColorsDisplay").textContent = controls.colors.value; const mode = controls.colorMode.value; document.getElementById("ditherColorsGroup").className = mode === "color" ? "dither-control-group" : "dither-control-group dither-hide"; const showDuotone = mode === "duotone" || mode === "tritone"; document.getElementById("ditherColor1Group").className = showDuotone ? "dither-control-group" : "dither-control-group dither-hide"; document.getElementById("ditherColor2Group").className = showDuotone ? "dither-control-group" : "dither-control-group dither-hide"; const color3Group = document.getElementById("ditherColor3Group"); if (color3Group) { color3Group.className = mode === "tritone" ? "dither-control-group" : "dither-control-group dither-hide"; } // Update mouse control displays if (controls.mouseRadius) { document.getElementById("ditherMouseRadiusDisplay").textContent = controls.mouseRadius.value; } if (controls.mouseSoftness) { document.getElementById("ditherMouseSoftnessDisplay").textContent = controls.mouseSoftness.value; } if (controls.mouseStrength) { document.getElementById("ditherMouseStrengthDisplay").textContent = controls.mouseStrength.value; } // Show/hide mouse controls based on enabled state const mouseEnabled = controls.mouseEnabled?.checked ?? true; const mouseControlGroups = [ "mouseRadiusGroup", "mouseSoftnessGroup", "mouseStrengthGroup", ]; mouseControlGroups.forEach((id) => { const el = document.getElementById(id); if (el) { el.style.opacity = mouseEnabled ? "1" : "0.5"; el.style.pointerEvents = mouseEnabled ? "auto" : "none"; } }); // Update trail control displays if (controls.trailLength) { const trailLengthDisplay = document.getElementById( "ditherTrailLengthDisplay" ); if (trailLengthDisplay) trailLengthDisplay.textContent = controls.trailLength.value; } if (controls.trailDecay) { const trailDecayDisplay = document.getElementById( "ditherTrailDecayDisplay" ); if (trailDecayDisplay) trailDecayDisplay.textContent = controls.trailDecay.value; } if (controls.mouseSmoothing) { const mouseSmoothingDisplay = document.getElementById( "ditherMouseSmoothingDisplay" ); if (mouseSmoothingDisplay) mouseSmoothingDisplay.textContent = controls.mouseSmoothing.value; } // Show/hide trail controls based on enabled state const trailEnabled = controls.trailEnabled?.checked ?? true; const trailControlGroups = [ "trailLengthGroup", "trailDecayGroup", "mouseSmoothingGroup", ]; trailControlGroups.forEach((id) => { const el = document.getElementById(id); if (el) { el.style.opacity = trailEnabled ? "1" : "0.5"; el.style.pointerEvents = trailEnabled ? "auto" : "none"; } }); // Update animation control displays if (controls.animateSpeed) { const animateSpeedDisplay = document.getElementById( "ditherAnimateSpeedDisplay" ); if (animateSpeedDisplay) animateSpeedDisplay.textContent = controls.animateSpeed.value; } if (controls.animateAmount) { const animateAmountDisplay = document.getElementById( "ditherAnimateAmountDisplay" ); if (animateAmountDisplay) animateAmountDisplay.textContent = controls.animateAmount.value; } if (controls.pulseSpeed) { const pulseSpeedDisplay = document.getElementById( "ditherPulseSpeedDisplay" ); if (pulseSpeedDisplay) pulseSpeedDisplay.textContent = controls.pulseSpeed.value; } if (controls.pulseAmount) { const pulseAmountDisplay = document.getElementById( "ditherPulseAmountDisplay" ); if (pulseAmountDisplay) pulseAmountDisplay.textContent = controls.pulseAmount.value; } // Show/hide animation controls based on enabled states const animateEnabled = controls.animateEnabled?.checked ?? false; const animateControlGroups = ["animateSpeedGroup", "animateAmountGroup"]; animateControlGroups.forEach((id) => { const el = document.getElementById(id); if (el) { el.style.opacity = animateEnabled ? "1" : "0.5"; el.style.pointerEvents = animateEnabled ? "auto" : "none"; } }); // Update scope control displays if (controls.scopeOpacity) { const scopeOpacityDisplay = document.getElementById( "ditherScopeOpacityDisplay" ); if (scopeOpacityDisplay) scopeOpacityDisplay.textContent = controls.scopeOpacity.value; } // Show/hide scope controls based on enabled state const scopeEnabled = controls.scopeEnabled?.checked ?? true; const scopeControlGroups = [ "scopeColorGroup", "scopeOpacityGroup", "scopeStatsGroup", "scopeCrosshairGroup", "scopeScanlinesGroup", ]; scopeControlGroups.forEach((id) => { const el = document.getElementById(id); if (el) { el.style.opacity = scopeEnabled ? "1" : "0.5"; el.style.pointerEvents = scopeEnabled ? "auto" : "none"; } }); } // Attach event listeners controls.colorMode.addEventListener("change", () => { console.log("๐ŸŽจ Color mode changed to:", controls.colorMode.value); updateDitherUI(); updateDitherEffect(); }); controls.ditherScale.addEventListener("input", () => { console.log("๐Ÿ“ Scale changed to:", controls.ditherScale.value); updateDitherUI(); updateDitherEffect(); }); controls.threshold.addEventListener("input", () => { console.log("๐ŸŽฏ Threshold changed to:", controls.threshold.value); updateDitherUI(); updateDitherEffect(); }); controls.colors.addEventListener("input", () => { console.log("๐ŸŽจ Colors changed to:", controls.colors.value); updateDitherUI(); updateDitherEffect(); }); controls.color1.addEventListener("input", () => { console.log("๐ŸŽจ Color 1 changed to:", controls.color1.value); updateDitherEffect(); }); controls.color2.addEventListener("input", () => { console.log("๐ŸŽจ Color 2 changed to:", controls.color2.value); updateDitherEffect(); }); if (controls.color3) { controls.color3.addEventListener("input", () => { console.log("๐ŸŽจ Color 3 changed to:", controls.color3.value); updateDitherEffect(); }); } // Mouse control event listeners if (controls.mouseEnabled) { controls.mouseEnabled.addEventListener("change", () => { console.log("๐Ÿ–ฑ๏ธ Mouse enabled:", controls.mouseEnabled.checked); updateDitherUI(); updateDitherEffect(); }); } if (controls.mouseRadius) { controls.mouseRadius.addEventListener("input", () => { console.log("๐Ÿ–ฑ๏ธ Mouse radius:", controls.mouseRadius.value); updateDitherUI(); updateDitherEffect(); }); } if (controls.mouseSoftness) { controls.mouseSoftness.addEventListener("input", () => { console.log("๐Ÿ–ฑ๏ธ Mouse softness:", controls.mouseSoftness.value); updateDitherUI(); updateDitherEffect(); }); } if (controls.mouseStrength) { controls.mouseStrength.addEventListener("input", () => { console.log("๐Ÿ–ฑ๏ธ Mouse strength:", controls.mouseStrength.value); updateDitherUI(); updateDitherEffect(); }); } // Trail control event listeners if (controls.trailEnabled) { controls.trailEnabled.addEventListener("change", () => { console.log("๐ŸŒŠ Trail enabled:", controls.trailEnabled.checked); updateDitherUI(); updateDitherEffect(); }); } if (controls.trailLength) { controls.trailLength.addEventListener("input", () => { console.log("๐ŸŒŠ Trail length:", controls.trailLength.value); updateDitherUI(); updateDitherEffect(); }); } if (controls.trailDecay) { controls.trailDecay.addEventListener("input", () => { console.log("๐ŸŒŠ Trail decay:", controls.trailDecay.value); updateDitherUI(); updateDitherEffect(); }); } if (controls.mouseSmoothing) { controls.mouseSmoothing.addEventListener("input", () => { console.log("๐ŸŒŠ Mouse smoothing:", controls.mouseSmoothing.value); updateDitherUI(); updateDitherEffect(); }); } // Animation control event listeners if (controls.animateEnabled) { controls.animateEnabled.addEventListener("change", () => { console.log("โœจ Animate enabled:", controls.animateEnabled.checked); updateDitherUI(); updateDitherEffect(); }); } if (controls.animateSpeed) { controls.animateSpeed.addEventListener("input", () => { console.log("โœจ Animate speed:", controls.animateSpeed.value); updateDitherUI(); updateDitherEffect(); }); } if (controls.animateAmount) { controls.animateAmount.addEventListener("input", () => { console.log("โœจ Animate amount:", controls.animateAmount.value); updateDitherUI(); updateDitherEffect(); }); } // Scope control event listeners if (controls.scopeEnabled) { controls.scopeEnabled.addEventListener("change", () => { console.log("๐ŸŽฏ Scope enabled:", controls.scopeEnabled.checked); updateDitherUI(); updateDitherEffect(); }); } if (controls.scopeColor) { controls.scopeColor.addEventListener("input", () => { console.log("๐ŸŽฏ Scope color:", controls.scopeColor.value); updateDitherEffect(); }); } if (controls.scopeOpacity) { controls.scopeOpacity.addEventListener("input", () => { console.log("๐ŸŽฏ Scope opacity:", controls.scopeOpacity.value); updateDitherUI(); updateDitherEffect(); }); } if (controls.scopeShowStats) { controls.scopeShowStats.addEventListener("change", () => { console.log("๐ŸŽฏ Scope show stats:", controls.scopeShowStats.checked); updateDitherEffect(); }); } if (controls.scopeShowCrosshair) { controls.scopeShowCrosshair.addEventListener("change", () => { console.log( "๐ŸŽฏ Scope show crosshair:", controls.scopeShowCrosshair.checked ); updateDitherEffect(); }); } if (controls.scopeShowScanlines) { controls.scopeShowScanlines.addEventListener("change", () => { console.log( "๐ŸŽฏ Scope show scanlines:", controls.scopeShowScanlines.checked ); updateDitherEffect(); }); } console.log("โœ… Control event listeners attached"); updateDitherUI(); // Expose updateDitherEffect globally so floating panel can call it window.updateDitherEffect = updateDitherEffect; console.log("๐ŸŒ updateDitherEffect exposed globally"); return true; } // Initialize controls after DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initDitherControls); } else { // DOM already loaded, but controls HTML might load later (Webflow embed) // Try immediately, then retry if not found if (!initDitherControls()) { // Retry after a short delay for Webflow setTimeout(initDitherControls, 100); setTimeout(initDitherControls, 500); setTimeout(initDitherControls, 1000); } } // Expose for manual init window.initDitherControls = initDitherControls; // ============================================ // IMAGE UPLOAD & EXPORT FEATURES // ============================================ // Upload new image to dither DitherEffect.prototype.uploadImage = function(file) { console.log("๐Ÿ“ค Uploading new image..."); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { console.log("โœ… Uploaded image loaded:", img.width, "x", img.height); // Create texture from uploaded image const texture = new THREE.Texture(img); texture.needsUpdate = true; // Update the image element src and dimensions this.imageElement.src = img.src; this.originalSrc = img.src; // IMPORTANT: Update image element dimensions to match new image this.imageElement.width = img.width; this.imageElement.height = img.height; // Update the wrapper size if it exists if (this.wrapper) { this.wrapper.style.width = img.width + 'px'; this.wrapper.style.height = img.height + 'px'; // Remove any max-width constraints this.wrapper.style.maxWidth = 'none'; } // Update canvas size if it exists if (this.canvas) { this.canvas.width = img.width; this.canvas.height = img.height; this.canvas.style.width = img.width + 'px'; this.canvas.style.height = img.height + 'px'; } // Calculate max dimensions (fit within viewport) const maxWidth = window.innerWidth * 0.9; // 90% of viewport width const maxHeight = window.innerHeight * 0.8; // 80% of viewport height let newWidth = img.width; let newHeight = img.height; // Scale down if too large if (newWidth > maxWidth || newHeight > maxHeight) { const scaleX = maxWidth / newWidth; const scaleY = maxHeight / newHeight; const scale = Math.min(scaleX, scaleY); newWidth = Math.floor(newWidth * scale); newHeight = Math.floor(newHeight * scale); console.log("๐Ÿ“ Image scaled to fit viewport:", newWidth, "x", newHeight); } // Apply dimensions with max-width constraint this.imageElement.style.width = newWidth + 'px'; this.imageElement.style.height = newHeight + 'px'; this.imageElement.style.maxWidth = '100%'; this.imageElement.style.maxHeight = '80vh'; // Remove old scope overlay if it exists if (this.scopeOverlay && this.scopeOverlay.parentNode) { this.scopeOverlay.parentNode.removeChild(this.scopeOverlay); this.scopeOverlay = null; this.scopeCircle = null; this.scopeStats = null; console.log("๐Ÿงน Removed old scope overlay"); } console.log("๐Ÿ“ Updated dimensions to:", img.width, "x", img.height); // Apply dither with new texture using scaled dimensions this.applyDither(texture, newWidth, newHeight) resolve(img); }; img.onerror = (err) => { console.error("โŒ Failed to load uploaded image"); reject(err); }; img.src = e.target.result; }; reader.onerror = (err) => { console.error("โŒ Failed to read file"); reject(err); }; reader.readAsDataURL(file); }); }; // Export dithered image DitherEffect.prototype.exportImage = function(filename = "dithered-image.png", format = "png") { console.log("๐Ÿ’พ Exporting image..."); try { // Render current state this.renderer.render(this.scene, this.camera); // Get data URL from renderer let mimeType = format === "jpg" || format === "jpeg" ? "image/jpeg" : "image/png"; const dataURL = this.renderer.domElement.toDataURL(mimeType, 0.9); // Create download link const link = document.createElement("a"); link.download = filename; link.href = dataURL; // Trigger download document.body.appendChild(link); link.click(); document.body.removeChild(link); console.log("โœ… Image exported:", filename); return true; } catch (e) { console.error("โŒ Export failed:", e); return false; } }; // Export with specific options DitherEffect.prototype.exportWithOptions = function(options = {}) { const { filename = "dithered-image.png", format = "png", scale = 1, width = null, height = null } = options; console.log("๐Ÿ’พ Exporting with options:", options); try { let exportWidth, exportHeight; if (width && height) { exportWidth = width; exportHeight = height; } else { exportWidth = this.currentTexture.image.width * scale; exportHeight = this.currentTexture.image.height * scale; } // Temporarily resize renderer for export const originalWidth = this.renderer.domElement.width; const originalHeight = this.renderer.domElement.height; this.renderer.setSize(exportWidth, exportHeight); this.renderer.render(this.scene, this.camera); // Get data URL let mimeType = format === "jpg" || format === "jpeg" ? "image/jpeg" : "image/png"; const dataURL = this.renderer.domElement.toDataURL(mimeType, 0.9); // Restore original size this.renderer.setSize(originalWidth, originalHeight); // Download const link = document.createElement("a"); link.download = filename; link.href = dataURL; document.body.appendChild(link); link.click(); document.body.removeChild(link); console.log("โœ… Image exported:", filename, `${exportWidth}x${exportHeight}`); return true; } catch (e) { console.error("โŒ Export failed:", e); return false; } }; // Global upload handler function handleImageUpload(fileInput) { const files = fileInput.files; if (!files || files.length === 0) { console.log("โš ๏ธ No files selected"); return; } const file = files[0]; console.log("๐Ÿ“ File selected:", file.name, file.type, file.size); console.log("๐Ÿ” Dither instances found:", window.ditherInstances?.size || 0); // Validate file type if (!file.type.startsWith("image/")) { alert("Please select an image file"); return; } // Check if we have instances if (!window.ditherInstances || window.ditherInstances.size === 0) { console.error("โŒ No dither instances found. Make sure images have data-dither attribute."); alert("No dithered images found to upload to"); return; } // Apply to all dither instances window.ditherInstances.forEach((instance, img) => { console.log("๐Ÿ”„ Processing upload for instance..."); instance.uploadImage(file).then(() => { console.log("โœ… Upload applied to instance"); }).catch(err => { console.error("โŒ Upload failed:", err); }); }); // Clear the input so the same file can be selected again fileInput.value = ''; } // Global export handler function exportDitheredImage(options = {}) { const instances = Array.from(window.ditherInstances.values()); if (instances.length === 0) { alert("No dithered images to export"); return; } // Export first instance by default const instance = instances[0]; instance.exportWithOptions(options); } // Track if upload/export is already initialized let uploadExportInitialized = false; // Initialize upload and export controls function initUploadExportControls() { // Prevent re-initialization if (uploadExportInitialized) { return true; } console.log("๐ŸŽ›๏ธ Initializing Upload & Export Controls..."); const uploadInput = document.getElementById("ditherUploadInput"); const uploadBtn = document.getElementById("ditherUploadBtn"); const exportBtn = document.getElementById("ditherExportBtn"); const exportFormat = document.getElementById("ditherExportFormat"); const exportScale = document.getElementById("ditherExportScale"); const hasElements = uploadInput || uploadBtn || exportBtn; if (!hasElements) { console.log("โณ Upload/Export controls not found yet, will retry..."); return false; } // File upload via input if (uploadInput) { uploadInput.addEventListener("change", (e) => { console.log("๐Ÿ“‚ File input change event triggered"); handleImageUpload(e.target); }); console.log("โœ… Upload input listener attached"); } // Note: Upload button click handler is managed by the panel HTML // dither.js only handles the file input change event // Export button if (exportBtn) { exportBtn.addEventListener("click", () => { console.log("๐Ÿ’พ Export button clicked"); const format = exportFormat?.value || "png"; const scale = parseFloat(exportScale?.value || "1"); const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, "-"); exportDitheredImage({ filename: `dithered-${timestamp}.${format}`, format: format, scale: scale }); }); console.log("โœ… Export button listener attached"); } uploadExportInitialized = true; console.log("โœ… Upload & Export controls initialized"); return true; } // Add to initDitherControls or call separately const originalInitDitherControls = initDitherControls; initDitherControls = function() { const result = originalInitDitherControls(); initUploadExportControls(); return result; }; // Auto-initialize upload/export controls with retry logic function autoInitUploadExport() { if (!initUploadExportControls()) { // Retry after delays for Webflow setTimeout(initUploadExportControls, 200); setTimeout(initUploadExportControls, 500); setTimeout(initUploadExportControls, 1000); setTimeout(initUploadExportControls, 2000); } } // Run immediately if DOM is ready, otherwise wait if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", autoInitUploadExport); } else { autoInitUploadExport(); } // Watch for dynamically added upload/export controls (for Webflow embeds) let uploadObserver = null; function startUploadObserver() { if (uploadObserver) return; // Already watching uploadObserver = new MutationObserver((mutations) => { // Only check if not already initialized if (!uploadExportInitialized) { const uploadBtn = document.getElementById("ditherUploadBtn"); const exportBtn = document.getElementById("ditherExportBtn"); if (uploadBtn || exportBtn) { console.log("๐Ÿ”„ Upload/Export elements detected, initializing..."); const success = initUploadExportControls(); if (success && uploadObserver) { uploadObserver.disconnect(); uploadObserver = null; console.log("๐Ÿ‘€ Stopped watching - controls initialized"); } } } }); uploadObserver.observe(document.body, { childList: true, subtree: true, attributes: false }); console.log("๐Ÿ‘€ Watching for upload/export controls..."); } // Start observing after a short delay to let initial render complete setTimeout(startUploadObserver, 500); // Expose functions globally window.handleImageUpload = handleImageUpload; window.exportDitheredImage = exportDitheredImage; window.initUploadExportControls = initUploadExportControls; window.autoInitUploadExport = autoInitUploadExport; console.log("๐ŸŽจ Dither.js v2.0 loaded - Upload/Export features active"); console.log("๐Ÿ’ก Manual init: Run autoInitUploadExport() in console if buttons don't work");