// 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");