diff options
Diffstat (limited to 'js')
51 files changed, 4020 insertions, 20 deletions
diff --git a/js/leibovitz/ChicagoFLF.ttf b/js/leibovitz/ChicagoFLF.ttf new file mode 100644 index 0000000..60691e1 --- /dev/null +++ b/js/leibovitz/ChicagoFLF.ttf Binary files differdiff --git a/js/leibovitz/android-icon-144x144.png b/js/leibovitz/android-icon-144x144.png new file mode 100644 index 0000000..ae37a7e --- /dev/null +++ b/js/leibovitz/android-icon-144x144.png Binary files differdiff --git a/js/leibovitz/android-icon-192x192.png b/js/leibovitz/android-icon-192x192.png new file mode 100644 index 0000000..4fd03e4 --- /dev/null +++ b/js/leibovitz/android-icon-192x192.png Binary files differdiff --git a/js/leibovitz/android-icon-36x36.png b/js/leibovitz/android-icon-36x36.png new file mode 100644 index 0000000..d5fbc51 --- /dev/null +++ b/js/leibovitz/android-icon-36x36.png Binary files differdiff --git a/js/leibovitz/android-icon-48x48.png b/js/leibovitz/android-icon-48x48.png new file mode 100644 index 0000000..ae93a97 --- /dev/null +++ b/js/leibovitz/android-icon-48x48.png Binary files differdiff --git a/js/leibovitz/android-icon-72x72.png b/js/leibovitz/android-icon-72x72.png new file mode 100644 index 0000000..89d3e98 --- /dev/null +++ b/js/leibovitz/android-icon-72x72.png Binary files differdiff --git a/js/leibovitz/android-icon-96x96.png b/js/leibovitz/android-icon-96x96.png new file mode 100644 index 0000000..a4fcd87 --- /dev/null +++ b/js/leibovitz/android-icon-96x96.png Binary files differdiff --git a/js/leibovitz/apple-icon-114x114.png b/js/leibovitz/apple-icon-114x114.png new file mode 100644 index 0000000..2a2af04 --- /dev/null +++ b/js/leibovitz/apple-icon-114x114.png Binary files differdiff --git a/js/leibovitz/apple-icon-120x120.png b/js/leibovitz/apple-icon-120x120.png new file mode 100644 index 0000000..dd9823f --- /dev/null +++ b/js/leibovitz/apple-icon-120x120.png Binary files differdiff --git a/js/leibovitz/apple-icon-144x144.png b/js/leibovitz/apple-icon-144x144.png new file mode 100644 index 0000000..ae37a7e --- /dev/null +++ b/js/leibovitz/apple-icon-144x144.png Binary files differdiff --git a/js/leibovitz/apple-icon-152x152.png b/js/leibovitz/apple-icon-152x152.png new file mode 100644 index 0000000..c43bf96 --- /dev/null +++ b/js/leibovitz/apple-icon-152x152.png Binary files differdiff --git a/js/leibovitz/apple-icon-180x180.png b/js/leibovitz/apple-icon-180x180.png new file mode 100644 index 0000000..f7435e7 --- /dev/null +++ b/js/leibovitz/apple-icon-180x180.png Binary files differdiff --git a/js/leibovitz/apple-icon-57x57.png b/js/leibovitz/apple-icon-57x57.png new file mode 100644 index 0000000..7f5dfa5 --- /dev/null +++ b/js/leibovitz/apple-icon-57x57.png Binary files differdiff --git a/js/leibovitz/apple-icon-60x60.png b/js/leibovitz/apple-icon-60x60.png new file mode 100644 index 0000000..3a6a826 --- /dev/null +++ b/js/leibovitz/apple-icon-60x60.png Binary files differdiff --git a/js/leibovitz/apple-icon-72x72.png b/js/leibovitz/apple-icon-72x72.png new file mode 100644 index 0000000..89d3e98 --- /dev/null +++ b/js/leibovitz/apple-icon-72x72.png Binary files differdiff --git a/js/leibovitz/apple-icon-76x76.png b/js/leibovitz/apple-icon-76x76.png new file mode 100644 index 0000000..9dc77b1 --- /dev/null +++ b/js/leibovitz/apple-icon-76x76.png Binary files differdiff --git a/js/leibovitz/apple-icon-precomposed.png b/js/leibovitz/apple-icon-precomposed.png new file mode 100644 index 0000000..8e17e9c --- /dev/null +++ b/js/leibovitz/apple-icon-precomposed.png Binary files differdiff --git a/js/leibovitz/apple-icon.png b/js/leibovitz/apple-icon.png new file mode 100644 index 0000000..8e17e9c --- /dev/null +++ b/js/leibovitz/apple-icon.png Binary files differdiff --git a/js/leibovitz/balance.js b/js/leibovitz/balance.js new file mode 100644 index 0000000..aeff62e --- /dev/null +++ b/js/leibovitz/balance.js @@ -0,0 +1,103 @@ +/** + * White balance management module implementing temperature-based color adjustment. + * + * Implements white balance adjustment using temperature-based RGB channel scaling. + * Provides non-linear temperature adjustment for natural color correction. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: temperature adjustment algorithm + * - Command Pattern: state reset operations + * + * White balance adjustment process: + * 1. Convert temperature to ratio relative to neutral (6500K) + * 2. Apply non-linear scaling (0.2 factor) to red and blue channels + * 3. Warmer temps (<6500K) increase red, decrease blue + * 4. Cooler temps (>6500K) increase blue, decrease red + * + * Features: + * - Temperature-based color adjustment + * - Non-linear response curve + * - Preserves green channel + * - Real-time updates + */ + +const BalanceManager = { + // Private state + _observers: new Set(), + _slider: null, + _value: null, + + /** + * Initializes the balance manager and sets up UI controls + */ + init() { + this._slider = document.getElementById('balance-slider'); + this._value = document.getElementById('balance-value'); + this._setupEventListeners(); + }, + + _setupEventListeners() { + this._slider.addEventListener('input', () => { + const value = this._slider.value; + this._value.textContent = `${value}K`; + this._notifyObservers(); + }); + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this.getCurrentBalance())); + }, + + /** + * Subscribes to balance state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + /** + * Gets the current white balance temperature + * @returns {number} Current temperature in Kelvin (2000K-10000K) + */ + getCurrentBalance() { + return parseInt(this._slider.value); + }, + + /** + * Applies white balance adjustment to an image + * And implements temperature-based RGB channel scaling with non-linear response + * @param {ImageData} imageData - Source image data + * @returns {ImageData} White balanced image data + */ + applyBalance(imageData) { + const balance = this.getCurrentBalance(); + if (!balance || balance === 6500) return imageData; // 6500K is neutral + + const data = imageData.data; + const temperature = balance / 6500; // Convert to temperature ratio + + for (let i = 0; i < data.length; i += 4) { + // Adjust red and blue channels based on temperature + // Warmer (lower K) increases red, decreases blue + // Cooler (higher K) increases blue, decreases red + data[i] = Math.min(255, data[i] * (1 + (temperature - 1) * 0.2)); // Red + data[i + 2] = Math.min(255, data[i + 2] * (1 + (1 - temperature) * 0.2)); // Blue + } + + return imageData; + }, + + /** + * Resets balance effect to default state + */ + reset() { + this._slider.value = 6500; + this._value.textContent = '6500K'; + this._notifyObservers(); + } +}; \ No newline at end of file diff --git a/js/leibovitz/blur.js b/js/leibovitz/blur.js new file mode 100644 index 0000000..bc6cddf --- /dev/null +++ b/js/leibovitz/blur.js @@ -0,0 +1,167 @@ +/** + * Blur management module implementing optimized box blur algorithm. + * + * Implements a two-pass box blur algorithm with boundary optimization. + * Uses block-based processing for improved performance on large images. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: blur algorithm implementation + * - Command Pattern: state reset operations + * + * The blur implementation uses a two-pass approach: + * 1. Horizontal pass: Applies blur along rows + * 2. Vertical pass: Applies blur along columns + * + * Features: + * - Boundary optimization for performance + * - Block-based processing + * - Two-pass implementation for better performance + * - Edge clamping to prevent artifacts + */ + +const BlurManager = { + // Private state + _currentBlur: 0, // Default blur (no blur) + _observers: new Set(), + _slider: null, + _value: null, + + init() { + this._slider = document.getElementById('blur-slider'); + this._value = document.getElementById('blur-value'); + this._setupEventListeners(); + }, + + _setupEventListeners() { + this._slider.addEventListener('input', () => { + const value = this._slider.value; + this._value.textContent = `${value}%`; + this._currentBlur = parseInt(value); + this._notifyObservers(); + }); + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this._currentBlur)); + }, + + /** + * Subscribes to blur state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + getCurrentBlur() { + return this._currentBlur; + }, + + /** + * Applies optimized box blur to an image + * And implements two-pass blur with content-aware boundary detection + * Uses separate horizontal and vertical passes, which is more performant + * @param {ImageData} imageData - Source image data + * @param {number} radius - Blur radius + * @returns {ImageData} Blurred image data + */ + applyBlur(imageData, radius) { + if (!radius) return imageData; + + const { data, width, height } = imageData; + const tempData = new Uint8ClampedArray(data); + + // Calculate the actual image boundaries + let minX = width, minY = height, maxX = 0, maxY = 0; + let hasContent = false; + + // Find the actual image boundaries + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + if (data[i + 3] > 0) { // Check alpha channel + hasContent = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (!hasContent) return imageData; + + // Add padding to boundaries to prevent edge artifacts + minX = Math.max(0, minX - radius); + minY = Math.max(0, minY - radius); + maxX = Math.min(width - 1, maxX + radius); + maxY = Math.min(height - 1, maxY + radius); + + // First pass: horizontal blur + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + let r = 0, g = 0, b = 0, a = 0; + let count = 0; + + for (let dx = -radius; dx <= radius; dx++) { + const nx = x + dx; + if (nx >= 0 && nx < width) { + const i = (y * width + nx) * 4; + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + a += data[i + 3]; + count++; + } + } + + // Store horizontal blur result + const i = (y * width + x) * 4; + tempData[i] = r / count; + tempData[i + 1] = g / count; + tempData[i + 2] = b / count; + tempData[i + 3] = a / count; + } + } + + // Second pass: vertical blur + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + let r = 0, g = 0, b = 0, a = 0; + let count = 0; + + for (let dy = -radius; dy <= radius; dy++) { + const ny = y + dy; + if (ny >= 0 && ny < height) { + const i = (ny * width + x) * 4; + r += tempData[i]; + g += tempData[i + 1]; + b += tempData[i + 2]; + a += tempData[i + 3]; + count++; + } + } + + // Store final blur result + const i = (y * width + x) * 4; + data[i] = r / count; + data[i + 1] = g / count; + data[i + 2] = b / count; + data[i + 3] = a / count; + } + } + + return imageData; + }, + + reset() { + this._currentBlur = 0; + this._slider.value = 0; + this._value.textContent = '0%'; + this._notifyObservers(); + } +}; \ No newline at end of file diff --git a/js/leibovitz/browserconfig.xml b/js/leibovitz/browserconfig.xml new file mode 100644 index 0000000..c554148 --- /dev/null +++ b/js/leibovitz/browserconfig.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig> \ No newline at end of file diff --git a/js/leibovitz/color.js b/js/leibovitz/color.js new file mode 100644 index 0000000..78f4ebc --- /dev/null +++ b/js/leibovitz/color.js @@ -0,0 +1,245 @@ +/** + * Color tint management module implementing HSL-based color manipulation. + * + * Implements color tinting using HSL color space transformation with circular interpolation. + * Features noise reduction and smooth blending for high-quality results. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: color manipulation algorithms + * - Command Pattern: state reset operations + * + * Color manipulation process: + * 1. Convert RGB to HSL color space + * 2. Apply circular interpolation for hue blending + * 3. Smooth blending for saturation and lightness + * 4. Noise reduction through value rounding + * 5. Convert back to RGB color space + * + * Features: + * - Circular interpolation for natural hue transitions + * - Noise reduction through value rounding + * - Smooth blending with quadratic easing + * - HSL color space for better color manipulation + */ + +const ColorManager = { + // Private state + _currentColor: null, + _observers: new Set(), + _colorInput: null, + + init() { + this._setupEventListeners(); + }, + + _setupEventListeners() { + this._colorInput = document.getElementById('color-tint'); + this._colorInput.addEventListener('input', (e) => { + this._currentColor = e.target.value; + this._notifyObservers(); + }); + + const resetButton = document.getElementById('reset-color'); + resetButton.addEventListener('click', () => { + // Reset color tint + this._currentColor = null; + this._colorInput.value = '#ffffff'; + this._notifyObservers(); + + // Reset contrast + ContrastManager.reset(); + + // Reset blur + BlurManager.reset(); + + // Reset white balance to default (6500K) + const balanceSlider = document.getElementById('balance-slider'); + const balanceValue = document.getElementById('balance-value'); + if (balanceSlider && balanceValue) { + balanceSlider.value = 6500; + balanceValue.textContent = '6500K'; + } + }); + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this._currentColor)); + }, + + /** + * Subscribes to color state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + getCurrentColor() { + return this._currentColor; + }, + + /** + * Applies color tint to an image using HSL color space + * Uses noise reduction and smooth blending for quality + * @param {ImageData} imageData - Source image data + * @param {string} color - Hex color value + * @returns {ImageData} Tinted image data + */ + applyTint(imageData, color) { + if (!color) return imageData; + + const { data } = imageData; + const [tintR, tintG, tintB] = this._hexToRgb(color); + + // Convert tint color to HSL for better color manipulation + const [tintH, tintS, tintL] = this._rgbToHsl(tintR, tintG, tintB); + + // Apply tint to each pixel with reduced noise + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // Convert pixel to HSL + const [h, s, l] = this._rgbToHsl(r, g, b); + + // Blend the tint color with the original color + // This tries to create a more natural LUT effect + const blendFactor = 0.15; // Reduced from 0.3 to 0.15 for smoother effect + + // Smooth blending for hue (circular interpolation) + const newH = this._blendHue(h, tintH, blendFactor); + + // Smooth blending for saturation and lightness with noise reduction + const newS = this._smoothBlend(s, tintS, blendFactor); + const newL = this._smoothBlend(l, tintL, blendFactor); + + // Convert back to RGB with noise reduction + const [newR, newG, newB] = this._hslToRgb(newH, newS, newL); + + // Apply noise reduction by rounding to nearest multiple of 4 + data[i] = Math.round(newR / 4) * 4; + data[i + 1] = Math.round(newG / 4) * 4; + data[i + 2] = Math.round(newB / 4) * 4; + } + + return imageData; + }, + + /** + * Converts hex color to RGB values + * @param {string} hex - Hex color string + * @returns {Array} RGB values [r, g, b] + */ + _hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16) + ] : null; + }, + + /** + * Converts RGB to HSL color space + * @param {number} r - Red component + * @param {number} g - Green component + * @param {number} b - Blue component + * @returns {Array} HSL values [h, s, l] + */ + _rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + + return [h, s, l]; + }, + + /** + * Converts HSL to RGB color space + * @param {number} h - Hue component + * @param {number} s - Saturation component + * @param {number} l - Lightness component + * @returns {Array} RGB values [r, g, b] + */ + _hslToRgb(h, s, l) { + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; + }, + + /** + * Blends two hue values with circular interpolation + * @param {number} h1 - First hue value + * @param {number} h2 - Second hue value + * @param {number} factor - Blend factor + * @returns {number} Blended hue value + */ + _blendHue(h1, h2, factor) { + const diff = h2 - h1; + if (Math.abs(diff) > 0.5) { + if (h1 > h2) { + return h1 + (h2 + 1 - h1) * factor; + } else { + return h1 + (h2 - (h1 + 1)) * factor; + } + } + return h1 + diff * factor; + }, + + /** + * Smooth blending with noise reduction + * Uses cubic easing function for smooth transitions + * @param {number} v1 - First value + * @param {number} v2 - Second value + * @param {number} factor - Blend factor + * @returns {number} Blended value + */ + _smoothBlend(v1, v2, factor) { + // Apply a smooth easing function + const t = factor * factor * (3 - 2 * factor); + return v1 + (v2 - v1) * t; + } +}; \ No newline at end of file diff --git a/js/leibovitz/contrast.js b/js/leibovitz/contrast.js new file mode 100644 index 0000000..c2b1a28 --- /dev/null +++ b/js/leibovitz/contrast.js @@ -0,0 +1,100 @@ +/** + * Contrast management module implementing linear contrast adjustment. + * + * Implements contrast adjustment using a linear scaling algorithm. + * Provides real-time contrast control with immediate visual feedback. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: contrast adjustment algorithm + * - Command Pattern: state reset operations + * + * Contrast adjustment process: + * 1. Calculate contrast factor using formula: (259 * (contrast + 255)) / (255 * (259 - contrast)) + * 2. Apply linear scaling to each color channel + * 3. Maintain color balance while adjusting contrast + * + * Features: + * - Linear contrast adjustment + * - Per-channel processing + * - Real-time updates + * - Preserves color relationships + */ + +const ContrastManager = { + // Private state + _currentContrast: 1.0, // Default contrast (no change) + _observers: new Set(), + _slider: null, + + /** + * Initializes the contrast manager and sets up UI controls + */ + init() { + this._setupEventListeners(); + }, + + /** + * Sets up event listeners for UI controls + */ + _setupEventListeners() { + this._slider = document.getElementById('contrast-slider'); + this._slider.addEventListener('input', (e) => { + this._currentContrast = parseFloat(e.target.value); + document.getElementById('contrast-value').textContent = this._currentContrast; + this._notifyObservers(); + }); + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this._currentContrast)); + }, + + /** + * Subscribes to contrast state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + getCurrentContrast() { + return this._currentContrast; + }, + + /** + * Applies contrast adjustment to an image + * Implements linear contrast adjustment algorithm + * @param {ImageData} imageData - Source image data + * @param {number} contrast - Contrast value + * @returns {ImageData} Contrasted image data + */ + applyContrast(imageData, contrast) { + if (!contrast || contrast === 1.0) return imageData; + + const { data } = imageData; + const factor = (259 * (contrast + 255)) / (255 * (259 - contrast)); + + for (let i = 0; i < data.length; i += 4) { + // Apply contrast to each color channel + for (let c = 0; c < 3; c++) { + const pixel = data[i + c]; + data[i + c] = factor * (pixel - 128) + 128; + } + } + + return imageData; + }, + + /** + * Resets contrast effect to default state + */ + reset() { + this._currentContrast = 1.0; + this._slider.value = 0; // Reset slider to middle position + this._notifyObservers(); + } +}; \ No newline at end of file diff --git a/js/leibovitz/dither.js b/js/leibovitz/dither.js new file mode 100644 index 0000000..e74f1be --- /dev/null +++ b/js/leibovitz/dither.js @@ -0,0 +1,613 @@ +/** + * Dithering management module implementing multiple dithering algorithms. + * + * Implements a couple dithering algorithms with block-based processing. + * Block-based processing is faster, and has better performance. + * Supports multiple dithering patterns with configurable block sizes. + * + * Implements the following design patterns: + * - Observer Pattern: state management and effect application + * - Factory Pattern: UI initialization + * - Strategy Pattern: dithering algorithm selection + * - Command Pattern: state reset operations + * + * Supported dithering algorithms: + * - Floyd-Steinberg: Error diffusion with standard distribution pattern + * - Ordered: Matrix-based threshold dithering + * - Atkinson: Error diffusion with 1/8 error distribution + * - Bayer: Pattern-based threshold dithering + * + * Each color channel (Red, Green, Blue) has 4 possible values: + * - 0 -> Black/None + * - 85 -> Low + * - 170 -> Medium + * - 255 -> Full + * + * Features: + * - Block-based processing for performance + * - Multiple dithering algorithms + * - Configurable block sizes + * - Error diffusion patterns + */ + +const DitherManager = { + // Private state + _currentMode: 'none', + _observers: new Set(), + _modeSelect: null, + _pixelSizeControl: null, + currentBlockSize: 4, + + /** + * Initializes the dither manager and sets up UI controls + */ + init() { + this._setupEventListeners(); + this._pixelSizeControl = document.getElementById('pixel-size-control'); + }, + + _setupEventListeners() { + this._modeSelect = document.getElementById('dither-select'); + this._modeSelect.addEventListener('change', (e) => { + this._currentMode = e.target.value; + // Show/hide pixel size control based on dithering mode + this._pixelSizeControl.style.display = + this._currentMode === 'none' ? 'none' : 'flex'; + this._notifyObservers(); + }); + + // Only add the event listener if the element actually exists + const blockSizeSlider = document.getElementById('block-size-slider'); + if (blockSizeSlider) { + blockSizeSlider.addEventListener('input', (e) => { + this.currentBlockSize = parseInt(e.target.value); + document.getElementById('block-size-value').textContent = + `${this.currentBlockSize}px`; + // Notify observers instead of directly calling processFrame + this._notifyObservers(); + }); + } + }, + + _notifyObservers() { + this._observers.forEach(observer => observer(this._currentMode)); + }, + + /** + * Subscribes to dithering state changes + * @param {Function} observer - Callback function for state changes + * @returns {Function} Unsubscribe function + */ + subscribe(observer) { + this._observers.add(observer); + return () => this._observers.delete(observer); + }, + + getCurrentMode() { + return this._currentMode; + }, + + /** + * Applies selected dithering algorithm to image data + * @param {ImageData} imageData - Source image data + * @param {string} mode - Selected dithering algorithm + * @returns {ImageData} Processed image data + */ + applyDither(imageData, mode) { + if (!mode || mode === 'none') return imageData; + + const { data } = imageData; + const width = imageData.width; + const height = imageData.height; + + switch (mode) { + case 'floyd-steinberg': + return this._floydSteinbergDither(data, width, height); + case 'ordered': + return this._orderedDither(data, width, height); + case 'atkinson': + return this._atkinsonDither(data, width, height); + case 'bayer': + return this._bayerDither(data, width, height); + default: + return imageData; + } + }, + + /** + * Quantizes a value to create chunkier output + * @param {number} value - Input value + * @param {number} levels - Number of quantization levels + * @returns {number} Quantized value + */ + _quantize(value, levels = 4) { + const step = 255 / (levels - 1); + return Math.round(value / step) * step; + }, + + /** + * Applies Floyd-Steinberg dithering algorithm + * Uses a 4x4 error distribution pattern for smoother results + * @param {Uint8ClampedArray} data - Image data + * @param {number} width - Image width + * @param {number} height - Image height + * @returns {ImageData} Dithered image data + */ + _floydSteinbergDither(data, width, height) { + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; + const blockSize = this.currentBlockSize; + + // Process in blocks, block by block + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Apply dithering to the block average + for (let c = 0; c < 3; c++) { + const oldPixel = blockAvg[c]; + const quantizedPixel = this._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } + } + + // Distribute error to neighboring blocks + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y, c, error * 7/16, width, blockSize, height); + } + if (y + blockSize < height) { + if (x - blockSize >= 0) { + this._distributeBlockError(newData, x - blockSize, y + blockSize, c, error * 3/16, width, blockSize, height); + } + this._distributeBlockError(newData, x, y + blockSize, c, error * 5/16, width, blockSize, height); + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y + blockSize, c, error * 1/16, width, blockSize, height); + } + } + } + } + } + + return new ImageData(newData, width, height); + }, + + /** + * Distributes error to neighboring blocks + * @param {Uint8ClampedArray} data - Image data + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {number} channel - Color channel + * @param {number} error - Error value to distribute + * @param {number} width - Image width + * @param {number} blockSize - Size of processing blocks + * @param {number} height - Image height + */ + _distributeBlockError(data, x, y, channel, error, width, blockSize, height) { + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + data[idx + channel] = Math.max(0, Math.min(255, data[idx + channel] + error)); + } + } + }, + + /** + * Applies ordered dithering using a Bayer matrix + * And implements threshold-based dithering with block processing + * Also uses a 4x4 Bayer matrix pattern for structured dithering + * @param {Uint8ClampedArray} data - Image data + * @param {number} width - Image width + * @param {number} height - Image height + * @returns {ImageData} Dithered image data + */ + _orderedDither(data, width, height) { + const newData = new Uint8ClampedArray(data); + const matrix = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5] + ]; + const matrixSize = 4; + const threshold = 128; + const levels = 4; + const blockSize = this.currentBlockSize; + + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Get matrix value for this block + const matrixX = Math.floor(x / blockSize) % matrixSize; + const matrixY = Math.floor(y / blockSize) % matrixSize; + const matrixValue = matrix[matrixY][matrixX] * 16; + + // Apply dithering to the block + for (let c = 0; c < 3; c++) { + const quantizedPixel = this._quantize(blockAvg[c], levels); + const newPixel = quantizedPixel + matrixValue > threshold ? 255 : 0; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } + } + } + } + } + + return new ImageData(newData, width, height); + }, + + /** + * Applies Atkinson dithering algorithm + * @param {Uint8ClampedArray} data - Image data + * @param {number} width - Image width + * @param {number} height - Image height + * @returns {ImageData} Dithered image data + */ + _atkinsonDither(data, width, height) { + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; + const blockSize = this.currentBlockSize; + + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Apply dithering to the block average + for (let c = 0; c < 3; c++) { + const oldPixel = blockAvg[c]; + const quantizedPixel = this._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } + } + + // Distribute error to neighboring blocks (Atkinson pattern) + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y, c, error / 8, width, blockSize, height); + if (x + blockSize * 2 < width) { + this._distributeBlockError(newData, x + blockSize * 2, y, c, error / 8, width, blockSize, height); + } + } + if (y + blockSize < height) { + if (x - blockSize >= 0) { + this._distributeBlockError(newData, x - blockSize, y + blockSize, c, error / 8, width, blockSize, height); + } + this._distributeBlockError(newData, x, y + blockSize, c, error / 8, width, blockSize, height); + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y + blockSize, c, error / 8, width, blockSize, height); + } + } + if (y + blockSize * 2 < height && x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y + blockSize * 2, c, error / 8, width, blockSize, height); + } + } + } + } + + return new ImageData(newData, width, height); + }, + + /** + * Applies Bayer dithering algorithm + * @param {Uint8ClampedArray} data - Image data + * @param {number} width - Image width + * @param {number} height - Image height + * @returns {ImageData} Dithered image data + */ + _bayerDither(data, width, height) { + const newData = new Uint8ClampedArray(data); + const blockSize = this.currentBlockSize; + + const bayerMatrix = [ + [ 0, 8, 2, 10], + [12, 4, 14, 6 ], + [ 3, 11, 1, 9 ], + [15, 7, 13, 5 ] + ]; + + const scaleFactor = 16; + + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Get matrix value for this block position + const matrixX = Math.floor(x / blockSize) % 4; + const matrixY = Math.floor(y / blockSize) % 4; + const threshold = (bayerMatrix[matrixY][matrixX] * scaleFactor); + + // Apply dithering to the block + for (let c = 0; c < 3; c++) { + const normalizedPixel = blockAvg[c]; + const newPixel = normalizedPixel > threshold ? 255 : 0; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } + } + } + } + } + + // Preserve alpha channel + for (let i = 3; i < newData.length; i += 4) { + newData[i] = data[i]; + } + + return new ImageData(newData, width, height); + } +}; + +// Legacy functions for backward compatibility +function bayerDither(imageData, width) { + const data = new Uint8ClampedArray(imageData.data); + const height = imageData.data.length / 4 / width; + const blockSize = DitherManager.currentBlockSize; + + // 8x8 Bayer matrix normalized to 0-1 range + const bayerMatrix = [ + [ 0, 48, 12, 60, 3, 51, 15, 63], + [32, 16, 44, 28, 35, 19, 47, 31], + [ 8, 56, 4, 52, 11, 59, 7, 55], + [40, 24, 36, 20, 43, 27, 39, 23], + [ 2, 50, 14, 62, 1, 49, 13, 61], + [34, 18, 46, 30, 33, 17, 45, 29], + [10, 58, 6, 54, 9, 57, 5, 53], + [42, 26, 38, 22, 41, 25, 37, 21] + ].map(row => row.map(x => x / 64)); + + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; + + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += data[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Get threshold from Bayer matrix for this block + const matrixX = Math.floor(x / blockSize) % 8; + const matrixY = Math.floor(y / blockSize) % 8; + const threshold = bayerMatrix[matrixY][matrixX]; + + // Apply threshold to the block + for (let c = 0; c < 3; c++) { + const normalizedPixel = blockAvg[c] / 255; + const newPixel = normalizedPixel > threshold ? 255 : 0; + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + data[idx + c] = newPixel; + } + } + } + } + } + + // Preserve alpha channel + for (let i = 3; i < data.length; i += 4) { + data[i] = imageData.data[i]; + } + + return data; +} + +function applyDithering(imageData, width) { + const method = document.getElementById('dither-select').value; + return DitherManager.applyDither(imageData, method); +} + +function floydSteinbergDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + + for (let c = 0; c < 3; c++) { + const oldPixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + newData[idx + c] = newPixel; + + if (x + 1 < width) { + newData[idx + 4 + c] += error * 7 / 16; + } + if (y + 1 === height) continue; + if (x > 0) { + newData[idx + width * 4 - 4 + c] += error * 3 / 16; + } + newData[idx + width * 4 + c] += error * 5 / 16; + if (x + 1 < width) { + newData[idx + width * 4 + 4 + c] += error * 1 / 16; + } + } + } + } + + return new ImageData(newData, width, height); +} + +function atkinsonDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + + for (let c = 0; c < 3; c++) { + const oldPixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + newData[idx + c] = newPixel; + + if (x + 1 < width) { + newData[idx + 4 + c] += error / 8; + } + if (x + 2 < width) { + newData[idx + 8 + c] += error / 8; + } + if (y + 1 === height) continue; + if (x > 0) { + newData[idx + width * 4 - 4 + c] += error / 8; + } + newData[idx + width * 4 + c] += error / 8; + if (x + 1 < width) { + newData[idx + width * 4 + 4 + c] += error / 8; + } + if (y + 2 === height) continue; + if (x + 1 < width) { + newData[idx + width * 8 + 4 + c] += error / 8; + } + } + } + } + + return new ImageData(newData, width, height); +} + +function orderedDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const matrix = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5] + ]; + const matrixSize = 4; + const threshold = 128; + const levels = 4; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const matrixX = x % matrixSize; + const matrixY = y % matrixSize; + const matrixValue = matrix[matrixY][matrixX] * 16; + + for (let c = 0; c < 3; c++) { + const pixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(pixel, levels); + newData[idx + c] = quantizedPixel + matrixValue > threshold ? 255 : 0; + } + } + } + + return new ImageData(newData, width, height); +} \ No newline at end of file diff --git a/js/leibovitz/favicon-16x16.png b/js/leibovitz/favicon-16x16.png new file mode 100644 index 0000000..9293108 --- /dev/null +++ b/js/leibovitz/favicon-16x16.png Binary files differdiff --git a/js/leibovitz/favicon-32x32.png b/js/leibovitz/favicon-32x32.png new file mode 100644 index 0000000..b6b0694 --- /dev/null +++ b/js/leibovitz/favicon-32x32.png Binary files differdiff --git a/js/leibovitz/favicon-96x96.png b/js/leibovitz/favicon-96x96.png new file mode 100644 index 0000000..a4fcd87 --- /dev/null +++ b/js/leibovitz/favicon-96x96.png Binary files differdiff --git a/js/leibovitz/favicon.ico b/js/leibovitz/favicon.ico new file mode 100644 index 0000000..8ce4e6e --- /dev/null +++ b/js/leibovitz/favicon.ico Binary files differdiff --git a/js/leibovitz/index.html b/js/leibovitz/index.html new file mode 100644 index 0000000..6207f07 --- /dev/null +++ b/js/leibovitz/index.html @@ -0,0 +1,548 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Leibovitz</title> + <meta name="description" content="Leibovitz is a web-based camera that lets you make fun photos."> + <link rel="apple-touch-icon" sizes="57x57" href="apple-icon-57x57.png"> + <link rel="apple-touch-icon" sizes="60x60" href="apple-icon-60x60.png"> + <link rel="apple-touch-icon" sizes="72x72" href="apple-icon-72x72.png"> + <link rel="apple-touch-icon" sizes="76x76" href="apple-icon-76x76.png"> + <link rel="apple-touch-icon" sizes="114x114" href="apple-icon-114x114.png"> + <link rel="apple-touch-icon" sizes="120x120" href="apple-icon-120x120.png"> + <link rel="apple-touch-icon" sizes="144x144" href="apple-icon-144x144.png"> + <link rel="apple-touch-icon" sizes="152x152" href="apple-icon-152x152.png"> + <link rel="apple-touch-icon" sizes="180x180" href="apple-icon-180x180.png"> + <link rel="icon" type="image/png" sizes="192x192" href="android-icon-192x192.png"> + <link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="96x96" href="favicon-96x96.png"> + <link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png"> + <link rel="manifest" href="manifest.json"> + <meta name="msapplication-TileColor" content="#ffffff"> + <meta name="msapplication-TileImage" content="ms-icon-144x144.png"> + <meta name="theme-color" content="#ffffff"> + <style> + @font-face { + font-family: 'ChicagoFLF'; + src: url('./ChicagoFLF.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + } + body, html { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background-color: beige; + overflow: hidden; + font-family: 'ChicagoFLF', sans-serif; + font-size: 16px; + } + .preview-container { + flex: 1; + position: relative; + margin: 0; + min-height: 0; + padding-top: 28px; + } + #canvas { + width: 100%; + height: 100%; + object-fit: contain; + display: none; + background-color: transparent; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } + .slide-controls { + position: absolute; + bottom: 20px; + left: 0; + right: 0; + display: none; + justify-content: space-around; + align-items: center; + background-color: rgba(255, 255, 255, 0.8); + padding: 10px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 10; + margin: 0 20px; + } + .slide-controls.visible { + display: flex; + } + .slider-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + flex: 1; + max-width: 200px; + } + .slider-group label { + font-size: 12px; + color: #666; + font-family: 'ChicagoFLF', sans-serif; + } + .slider-group input[type="range"] { + width: 100%; + height: 4px; + -webkit-appearance: none; + background: rgba(0, 0, 0, 0.2); + border-radius: 2px; + outline: none; + } + .slider-group input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: teal; + border-radius: 0; + cursor: pointer; + } + .slider-group input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + background: teal; + border-radius: 0; + cursor: pointer; + border: none; + } + .slider-group .value { + font-size: 12px; + color: #666; + font-family: 'ChicagoFLF', sans-serif; + } + .side-control { + position: absolute; + top: 50%; + height: 100%; + width: auto; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 1em; + padding: 1em 0; + background-color: rgba(255, 255, 255, 0.8); + z-index: 10; + } + .side-control.left { + left: 0; + } + .side-control.right { + right: 0; + } + + .vertical-slider { + transform: rotate(-90deg); + width: 200px; + margin: 90px -80px; + cursor: pointer; + } + input[type="range"]::-webkit-slider-thumb, .vertical-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + background: teal; + border-radius: 0; + cursor: pointer; + } + input[type="range"]::-webkit-slider-runnable-track, .vertical-slider::-webkit-slider-runnable-track { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.8); + border-radius: 2px; + } + input[type="range"]::-moz-range-thumb, .vertical-slider::-moz-range-thumb { + width: 20px; + height: 20px; + background: teal; + border-radius: 0; + cursor: pointer; + border: none; + } + input[type="range"]::-moz-range-track, .vertical-slider::-moz-range-track { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.8); + border-radius: 2px; + } + .vertical-label { + transform: rotate(90deg); + font-size: 12px; + color: #666; + user-select: none; + white-space: nowrap; + margin: 20px 0; + font-family: 'ChicagoFLF', sans-serif; + padding: 0 5px; + } + #blur-value { + font-size: 12px; + color: #666; + user-select: none; + white-space: nowrap; + font-family: 'ChicagoFLF', sans-serif; + padding: 0 5px; + } + #contrast-value { + font-size: 12px; + color: #666; + user-select: none; + white-space: nowrap; + font-family: 'ChicagoFLF', sans-serif; + padding: 0 5px; + } + #controls { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0; + padding: 0; + background-color: rgba(255, 255, 255, 0.8); + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); + flex-shrink: 0; + } + #settings-container { + width: 100%; + display: none; + flex-direction: column; + gap: 10px; + align-items: center; + background-color: rgba(255, 255, 255, 0.8); + padding: 10px; + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); + flex-shrink: 0; + position: relative; + } + #settings-container.visible { + display: flex; + } + #offline-status { + position: fixed; + top: 0; + left: 0; + right: 0; + width: 100%; + font-size: 12px; + color: #666; + display: none; + font-family: 'ChicagoFLF', sans-serif; + background-color: rgba(255, 255, 255, 0.8); + text-align: center; + padding: 4px; + z-index: 1000; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + #offline-status.visible { + display: block; + } + .top-controls { + display: flex; + gap: 10px; + align-items: center; + width: 100%; + justify-content: center; + flex-wrap: nowrap; + } + .color-controls { + display: flex; + gap: 5px; + align-items: center; + flex-shrink: 0; + } + #reset-color { + padding: 8px; + font-size: 18px; + background: none; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + font-family: 'ChicagoFLF', sans-serif; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + } + input[type="color"] { + width: 40px; + height: 40px; + padding: 0; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + } + select { + padding: 10px 15px; + font-family: 'ChicagoFLF', sans-serif; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #f0f0f0; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + line-height: 1.2; + color: #333; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%23333'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 16px; + padding-right: 35px; + } + select:focus { + outline: none; + border-color: teal; + box-shadow: 0 0 0 2px rgba(0, 128, 128, 0.2); + } + #dither-select { + min-width: 200px; + max-width: none; + flex-shrink: 0; + } + button.capture { + background-color: teal; + color: #FFFFFF; + padding: 10px 20px; + font-family: 'ChicagoFLF', sans-serif; + } + #toggle-camera { + padding: 10px 20px; + font-family: 'ChicagoFLF', sans-serif; + border-right: 1px solid rgba(0, 0, 0, 0.1); + } + #toggle-camera.hidden { + display: none; + } + button, select, input[type="color"] { + padding: 10px; + font-size: 18px; + cursor: pointer; + font-family: 'ChicagoFLF', sans-serif; + } + + button:hover, button:focus { + outline: none; + } + + button.capture:disabled { + background-color: #ccc; + color: #5c5c5c; + } + + .contrast-control { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 100%; + max-width: 300px; + margin: 0 auto; + padding: 0 10px; + } + .contrast-control label { + font-size: 12px; + color: #666; + width: 100%; + text-align: center; + font-family: 'ChicagoFLF', sans-serif; + } + .contrast-control input[type="range"] { + width: 100%; + } + .contrast-control .slider-container { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + } + .contrast-control .slider-container input[type="range"] { + flex: 1; + } + #block-size-value { + font-size: 12px; + color: #666; + min-width: 40px; + text-align: right; + font-family: 'ChicagoFLF', sans-serif; + } + #focus-container { + display: none; + } + #toggle-camera, button.capture, #edit-image { + font-size: 18px; + padding: 20px; + font-family: 'ChicagoFLF', sans-serif; + border-radius: 0; + text-align: center; + flex: 1; + } + button.capture:disabled { + background-color: #ccc; + color: #5c5c5c; + } + + @media (max-width: 600px) { + #controls { + flex-direction: column; + } + + #toggle-camera, button.capture, #edit-image { + width: 100%; + border-right: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + + #toggle-camera:last-child, button.capture:last-child, #edit-image:last-child { + border-bottom: none; + } + + .slide-controls { + flex-direction: column; + gap: 12px; + padding: 15px; + } + .slider-group { + width: 100%; + max-width: none; + flex-direction: row; + align-items: center; + gap: 12px; + } + .slider-group label { + min-width: 80px; + text-align: left; + font-size: 12px; + color: #666; + } + .slider-group input[type="range"] { + flex: 1; + } + .slider-group .value { + min-width: 40px; + text-align: right; + font-size: 12px; + color: #666; + } + select { + width: 100%; + max-width: 100%; + } + .top-controls { + flex-wrap: nowrap; + width: auto; + gap: 5px; + } + + #dither-select { + min-width: 150px; + max-width: none; + } + + .color-controls { + flex-shrink: 0; + } + } + #edit-image { + display: block; + padding: 20px; + font-family: 'ChicagoFLF', sans-serif; + border-radius: 0; + text-align: center; + background-color: teal; + color: #FFFFFF; + } + #edit-image.hidden { + display: none; + } + </style> +</head> +<body> + +<div class="preview-container"> + <canvas id="canvas"></canvas> + <div class="slide-controls"> + <div class="slider-group"> + <label for="blur-slider">Blur</label> + <input type="range" id="blur-slider" min="0" max="20" value="0" step="1"> + <span class="value" id="blur-value">0%</span> + </div> + <div class="slider-group"> + <label for="contrast-slider">Contrast</label> + <input type="range" id="contrast-slider" min="-255" max="255" value="0" step="1"> + <span class="value" id="contrast-value">0</span> + </div> + <div class="slider-group"> + <label for="balance-slider">Balance</label> + <input type="range" id="balance-slider" min="2000" max="10000" value="6500" step="100"> + <span class="value" id="balance-value">6500K</span> + </div> + <div class="slider-group" id="focus-control" style="display: none;"> + <label for="focus-slider">Focus</label> + <input type="range" id="focus-slider" min="0" max="100" step="1" value="50" disabled> + <span class="value" id="focus-value">50%</span> + </div> + <div class="slider-group" id="pixel-size-control" style="display: none;"> + <label for="block-size-slider">Pixel Size</label> + <input type="range" id="block-size-slider" min="1" max="12" value="4" step="1"> + <span class="value" id="block-size-value">4px</span> + </div> + </div> +</div> + +<div id="settings-container"> + <div id="offline-status">Offline Mode</div> + <div class="top-controls"> + <select id="dither-select"> + <option value="none">No Dithering</option> + <option value="floyd-steinberg">Floyd-Steinberg</option> + <option value="ordered">Ordered</option> + <option value="atkinson">Atkinson</option> + <option value="bayer">Bayer</option> + </select> + <div class="color-controls"> + <input type="color" id="color-tint" title="Color Tint"> + <button id="reset-color" title="Reset Color Tint">↺</button> + </div> + </div> +</div> + +<div id="controls"> + <button id="toggle-camera">Camera On</button> + <button id="edit-image" class="edit-image">Upload Image</button> + <button id="capture" disabled class="capture">Capture Image</button> + <input type="file" id="image-input" accept="image/*" style="display: none;"> +</div> + +<script src="dither.js"></script> +<script src="contrast.js"></script> +<script src="color.js"></script> +<script src="blur.js"></script> +<script src="balance.js"></script> +<script src="leibovitz.js"></script> +<script> +// Add offline status handling +window.addEventListener('online', () => { + document.getElementById('offline-status').classList.remove('visible'); +}); + +window.addEventListener('offline', () => { + document.getElementById('offline-status').classList.add('visible'); +}); + +// Check initial online status +if (!navigator.onLine) { + document.getElementById('offline-status').classList.add('visible'); +} +</script> +</body> +</html> diff --git a/js/leibovitz/leibovitz.js b/js/leibovitz/leibovitz.js new file mode 100644 index 0000000..5cd6f2d --- /dev/null +++ b/js/leibovitz/leibovitz.js @@ -0,0 +1,446 @@ +/** + * Start here. + * + * Susan Sontag: + * > The camera makes everyone a tourist in other people's reality, + * > and eventually in one's own. + * + * Uses multiple design patterns for state management and applying effects: + * - Observer Pattern: state management and effect application across modules + * - State Pattern: mode management (camera/edit) + * - Factory Pattern: UI initialization and media device creation + * - Strategy Pattern: algorithm selection when applying an effect + * - Command Pattern: canvas operations and state reset + * - Chain of Responsibility: sequential effect application + * + * + * Separate manager modules for each effect: + * - ColorManager: HSL-based color manipulation + * - DitherManager: multiple dithering algorithms + * - ContrastManager: linear contrast adjustment + * - BlurManager: optimized box blur + * - BalanceManager: temperature-based color adjustment + * + * + */ + +const canvas = document.getElementById('canvas'); +const ctx = canvas.getContext('2d'); +const video = document.createElement('video'); +const toggleCameraButton = document.getElementById('toggle-camera'); +const captureButton = document.getElementById('capture'); +const editImageButton = document.getElementById('edit-image'); +const imageInput = document.getElementById('image-input'); +const focusControl = document.getElementById('focus-control'); +const focusSlider = document.getElementById('focus-slider'); +const focusValue = document.getElementById('focus-value'); +const slideControls = document.querySelector('.slide-controls'); + +let cameraOn = false; +let stream = null; +let track = null; +let isEditMode = false; +let originalImage = null; // Store the original image for edit mode + +// Initialize managers +ColorManager.init(); +DitherManager.init(); +ContrastManager.init(); +BlurManager.init(); +BalanceManager.init(); + +/** + * Updates visibility of controls based on camera/edit mode state + */ +function updateSliderControlsVisibility() { + const settingsContainer = document.getElementById('settings-container'); + if (cameraOn || isEditMode) { + slideControls.classList.add('visible'); + settingsContainer.classList.add('visible'); + } else { + slideControls.classList.remove('visible'); + settingsContainer.classList.remove('visible'); + } +} + +/** + * Updates canvas dimensions while maintaining aspect ratio + */ +function updateCanvasSize() { + const container = document.querySelector('.preview-container'); + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + if (video.videoWidth && video.videoHeight) { + const videoAspect = video.videoWidth / video.videoHeight; + const containerAspect = containerWidth / containerHeight; + + if (containerAspect > videoAspect) { + canvas.height = containerHeight; + canvas.width = containerHeight * videoAspect; + } else { + canvas.width = containerWidth; + canvas.height = containerWidth / videoAspect; + } + } else { + canvas.width = containerWidth; + canvas.height = containerHeight; + } +} + +window.addEventListener('resize', () => { + if (cameraOn || isEditMode) { + updateCanvasSize(); + if (isEditMode && originalImage) { + applyEffects(); + } + } +}); + +updateCanvasSize(); + +function clearCanvas() { + const container = document.querySelector('.preview-container'); + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + canvas.width = containerWidth; + canvas.height = containerHeight; + ctx.clearRect(0, 0, containerWidth, containerHeight); + ctx.setTransform(1, 0, 0, 1, 0, 0); + canvas.style.display = 'none'; +} + +/** + * Initializes camera access and sets up video stream + * Implements the Factory pattern for media device creation + * Uses the State pattern for mode management and UI state + */ +function startCamera() { + if (isEditMode) { + if (!confirm('Switching to camera mode will discard your current image and edits. Continue?')) { + return; + } + isEditMode = false; + originalImage = null; + clearCanvas(); + } + + navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } }) + .then(s => { + stream = s; + video.srcObject = stream; + video.play(); + canvas.style.display = 'block'; + captureButton.disabled = false; + captureButton.active = true; + editImageButton.classList.add('hidden'); + toggleCameraButton.classList.add('hidden'); + isEditMode = false; + originalImage = null; + updateSliderControlsVisibility(); + + track = stream.getVideoTracks()[0]; + const settings = track.getSettings(); + + // Feature detection for focus control + // Relatively untested because I don't have a device with focus control + if ('focusDistance' in settings) { + focusControl.style.display = 'flex'; + focusSlider.disabled = false; + focusSlider.value = settings.focusDistance || focusSlider.min; + focusValue.textContent = `${focusSlider.value}%`; + + focusSlider.addEventListener('input', () => { + const value = focusSlider.value; + focusValue.textContent = `${value}%`; + track.applyConstraints({ + advanced: [{ focusDistance: value }] + }); + }); + } else { + console.warn('Focus control is not supported on this device.'); + focusControl.style.display = 'none'; + } + + // Animation loop using requestAnimationFrame + video.addEventListener('play', function() { + function step() { + if (!cameraOn) return; + drawVideoProportional(); + applyContrast(); + applyColorTint(); + applyBlur(); + applyBalance(); + applyDither(); + requestAnimationFrame(step); + } + requestAnimationFrame(step); + }); + }) + .catch(err => { + console.error('Error accessing camera: ', err); + toggleCameraButton.classList.remove('hidden'); + }); +} + +/** + * Stops camera stream and resets UI state + */ +function stopCamera() { + if (stream) { + stream.getTracks().forEach(track => track.stop()); + video.pause(); + clearCanvas(); + captureButton.disabled = true; + captureButton.active = false; + focusSlider.disabled = true; + focusControl.style.display = 'none'; + stream = null; + editImageButton.classList.remove('hidden'); + toggleCameraButton.classList.remove('hidden'); + updateSliderControlsVisibility(); + } +} + +/** + * Loads and displays an image file + * Uses aspect ratio preservation strategy for responsive display + */ +function loadImage(file) { + const reader = new FileReader(); + reader.onload = function(e) { + const img = new Image(); + img.onload = function() { + clearCanvas(); + + const container = document.querySelector('.preview-container'); + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + const imgAspect = img.width / img.height; + const containerAspect = containerWidth / containerHeight; + + let canvasWidth, canvasHeight; + + if (containerAspect > imgAspect) { + canvasHeight = containerHeight; + canvasWidth = containerHeight * imgAspect; + } else { + canvasWidth = containerWidth; + canvasHeight = containerWidth / imgAspect; + } + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + originalImage = img; + + ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight); + canvas.style.display = 'block'; + captureButton.disabled = false; + captureButton.active = true; + updateSliderControlsVisibility(); + + function step() { + if (!isEditMode) return; + applyEffects(); + requestAnimationFrame(step); + } + requestAnimationFrame(step); + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); +} + +/** + * Sequentially applies all effects to the original image + */ +function applyEffects() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height); + applyContrast(); + applyColorTint(); + applyBlur(); + applyBalance(); + applyDither(); +} + +/** + * Draws video feed maintaining aspect ratio + */ +function drawVideoProportional() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const videoAspectRatio = video.videoWidth / video.videoHeight; + const canvasAspectRatio = canvas.width / canvas.height; + + let drawWidth, drawHeight; + + if (canvasAspectRatio > videoAspectRatio) { + drawHeight = canvas.height; + drawWidth = videoAspectRatio * drawHeight; + } else { + drawWidth = canvas.width; + drawHeight = drawWidth / videoAspectRatio; + } + + const offsetX = (canvas.width - drawWidth) / 2; + const offsetY = (canvas.height - drawHeight) / 2; + + ctx.drawImage(video, offsetX, offsetY, drawWidth, drawHeight); +} + +function applyColorTint() { + const currentColor = ColorManager.getCurrentColor(); + if (!currentColor) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const tintedImageData = ColorManager.applyTint(imageData, currentColor); + ctx.putImageData(tintedImageData, 0, 0); +} + +function applyBalance() { + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const balancedImageData = BalanceManager.applyBalance(imageData); + ctx.putImageData(balancedImageData, 0, 0); +} + +function applyContrast() { + const currentContrast = ContrastManager.getCurrentContrast(); + if (!currentContrast || currentContrast === 1.0) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const contrastedImageData = ContrastManager.applyContrast(imageData, currentContrast); + ctx.putImageData(contrastedImageData, 0, 0); +} + +function applyDither() { + const currentMode = DitherManager.getCurrentMode(); + if (!currentMode || currentMode === 'none') return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const ditheredImageData = DitherManager.applyDither(imageData, currentMode); + ctx.putImageData(ditheredImageData, 0, 0); +} + + +function applyBlur() { + const currentBlur = BlurManager.getCurrentBlur(); + if (!currentBlur) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const blurredImageData = BlurManager.applyBlur(imageData, currentBlur); + ctx.putImageData(blurredImageData, 0, 0); +} + +/** + * Captures the current canvas state with effects + */ +captureButton.addEventListener('click', () => { + const currentColor = ColorManager.getCurrentColor(); + const borderWidth = 4; + + const captureCanvas = document.createElement('canvas'); + const captureCtx = captureCanvas.getContext('2d'); + + captureCanvas.width = canvas.width + (borderWidth * 2); + captureCanvas.height = canvas.height + (borderWidth * 2); + + if (currentColor) { + captureCtx.fillStyle = currentColor; + captureCtx.fillRect(0, 0, captureCanvas.width, captureCanvas.height); + } + + captureCtx.drawImage(canvas, borderWidth, borderWidth); + + const link = document.createElement('a'); + link.download = 'captured-image.png'; + link.href = captureCanvas.toDataURL('image/png'); + link.click(); +}); + +toggleCameraButton.addEventListener('click', () => { + cameraOn = !cameraOn; + if (cameraOn) { + startCamera(); + toggleCameraButton.textContent = 'Camera Off'; + } else { + stopCamera(); + toggleCameraButton.textContent = 'Camera On'; + } +}); + +editImageButton.addEventListener('click', () => { + if (!cameraOn) { + imageInput.click(); + } +}); + +imageInput.addEventListener('change', (e) => { + if (e.target.files && e.target.files[0]) { + isEditMode = true; + loadImage(e.target.files[0]); + } +}); + +/** + * Service Worker registration for offline functionality + */ +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/service-worker.js') + .then(registration => { + console.log('ServiceWorker registration successful with scope: ', registration.scope); + }, err => { + console.log('ServiceWorker registration failed: ', err); + }); + }); +} + +ColorManager._setupEventListeners(); + +function resetEffects() { + if (isEditMode && originalImage) { + applyEffects(); + } +} + +/** + * Reset handlers for each effect manager + */ +BlurManager.reset = function() { + this._currentBlur = 0; + this._slider.value = 0; + this._value.textContent = '0%'; + this._notifyObservers(); + resetEffects(); +}; + +ContrastManager.reset = function() { + this._currentContrast = 1.0; + this._slider.value = 0; + document.getElementById('contrast-value').textContent = '0'; + this._notifyObservers(); + resetEffects(); +}; + +ColorManager.reset = function() { + this._currentColor = null; + this._colorInput.value = '#ffffff'; + this._notifyObservers(); + resetEffects(); +}; + +BalanceManager.reset = function() { + this.balanceSlider.value = 6500; + this.balanceValue.textContent = '6500K'; + resetEffects(); +}; + +DitherManager.reset = function() { + this._currentMode = 'none'; + this._modeSelect.value = 'none'; + this._notifyObservers(); + resetEffects(); +}; \ No newline at end of file diff --git a/js/leibovitz/manifest.json b/js/leibovitz/manifest.json new file mode 100644 index 0000000..1ddc0b2 --- /dev/null +++ b/js/leibovitz/manifest.json @@ -0,0 +1,71 @@ +{ + "name": "Leibovitz", + "short_name": "Leibovitz", + "description": "A web-based camera that lets you make fun photos", + "start_url": ".", + "display": "standalone", + "background_color": "#f5f5dc", + "theme_color": "#f5f5dc", + "orientation": "portrait", + "icons": [ + { + "src": "android-icon-36x36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75" + }, + { + "src": "android-icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0" + }, + { + "src": "android-icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5" + }, + { + "src": "android-icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0" + }, + { + "src": "android-icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0" + }, + { + "src": "android-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0" + }, + { + "src": "apple-icon-180x180.png", + "sizes": "180x180", + "type": "image/png" + } + ], + "categories": ["photo", "camera", "art"], + "prefer_related_applications": false, + "shortcuts": [ + { + "name": "Take Photo", + "short_name": "Camera", + "description": "Open camera to take a photo", + "url": "?action=camera", + "icons": [{ "src": "android-icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Edit Photo", + "short_name": "Edit", + "description": "Open photo editor", + "url": "?action=edit", + "icons": [{ "src": "android-icon-96x96.png", "sizes": "96x96" }] + } + ] +} \ No newline at end of file diff --git a/js/leibovitz/ms-icon-144x144.png b/js/leibovitz/ms-icon-144x144.png new file mode 100644 index 0000000..ae37a7e --- /dev/null +++ b/js/leibovitz/ms-icon-144x144.png Binary files differdiff --git a/js/leibovitz/ms-icon-150x150.png b/js/leibovitz/ms-icon-150x150.png new file mode 100644 index 0000000..d9edbdb --- /dev/null +++ b/js/leibovitz/ms-icon-150x150.png Binary files differdiff --git a/js/leibovitz/ms-icon-310x310.png b/js/leibovitz/ms-icon-310x310.png new file mode 100644 index 0000000..9512221 --- /dev/null +++ b/js/leibovitz/ms-icon-310x310.png Binary files differdiff --git a/js/leibovitz/ms-icon-70x70.png b/js/leibovitz/ms-icon-70x70.png new file mode 100644 index 0000000..45b1734 --- /dev/null +++ b/js/leibovitz/ms-icon-70x70.png Binary files differdiff --git a/js/leibovitz/service-worker.js b/js/leibovitz/service-worker.js new file mode 100644 index 0000000..9ac287e --- /dev/null +++ b/js/leibovitz/service-worker.js @@ -0,0 +1,89 @@ +const CACHE_NAME = 'leibovitz-cache-v1'; +const urlsToCache = [ + '.', + 'index.html', + 'leibovitz.js', + 'blur.js', + 'contrast.js', + 'color.js', + 'balance.js', + 'dither.js', + 'service-worker.js', + 'ChicagoFLF.ttf', + 'manifest.json', + // Icons + 'android-icon-192x192.png', + 'android-icon-512x512.png', + 'favicon.ico', + 'favicon-16x16.png', + 'favicon-32x32.png', + 'favicon-96x96.png', + 'apple-icon-57x57.png', + 'apple-icon-60x60.png', + 'apple-icon-72x72.png', + 'apple-icon-76x76.png', + 'apple-icon-114x114.png', + 'apple-icon-120x120.png', + 'apple-icon-144x144.png', + 'apple-icon-152x152.png' +]; + +// Install event - cache all necessary files +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + return cache.addAll(urlsToCache); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + if (cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + }) + ); + }) + ); +}); + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', event => { + event.respondWith( + caches.match(event.request) + .then(response => { + // Return cached response if found + if (response) { + return response; + } + + // Clone the request because it can only be used once + const fetchRequest = event.request.clone(); + + // Try to fetch from network + return fetch(fetchRequest).then(response => { + // Check if we received a valid response + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + // Clone the response because it can only be used once + const responseToCache = response.clone(); + + // Cache the fetched response + caches.open(CACHE_NAME) + .then(cache => { + cache.put(event.request, responseToCache); + }); + + return response; + }); + }) + ); +}); \ No newline at end of file diff --git a/js/pipe.js b/js/pipe.js index 69ccae3..ace5fb9 100644 --- a/js/pipe.js +++ b/js/pipe.js @@ -1,2 +1,6 @@ const pipe = (...args) => args.reduce((acc, el) => el(acc)); +/* alt implementation +const pipe = (...fns) => (initialValue) => + fns.reduce((acc, fn) => fn(acc), initialValue); +*/ \ No newline at end of file diff --git a/js/pixel-art/pixel/app.js b/js/pixel-art/pixel/app.js index 8a7aceb..2d83997 100644 --- a/js/pixel-art/pixel/app.js +++ b/js/pixel-art/pixel/app.js @@ -299,29 +299,44 @@ function exportToPNG() { const filename = prompt("Enter a name for your file(s)", "pixel-art"); if (!filename) return; // Cancelled - canvases.forEach((canvasData, index) => { - const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); - tempCanvas.width = gridWidth * cellSize; - tempCanvas.height = gridHeight * cellSize; - - for (let x = 0; x < gridWidth; x++) { - for (let y = 0; y < gridHeight; y++) { - tempCtx.fillStyle = canvasData.grid[x][y] || 'transparent'; - tempCtx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize); + // An array of promises for each canvas + const exportPromises = canvases.map((canvasData, index) => { + return new Promise(resolve => { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + tempCanvas.width = gridWidth * cellSize; + tempCanvas.height = gridHeight * cellSize; + + // Draw the canvas content + for (let x = 0; x < gridWidth; x++) { + for (let y = 0; y < gridHeight; y++) { + tempCtx.fillStyle = canvasData.grid[x][y] || 'transparent'; + tempCtx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize); + } } - } - const paddedNumber = String(index + 1).padStart(2, '0'); - const finalFilename = canvases.length > 1 - ? `${filename}-${paddedNumber}.png` - : `${filename}.png`; + // Convert to data URL (trying to work around a webkit bug where blobs don't work so well) + const dataURL = tempCanvas.toDataURL('image/png'); + const paddedNumber = String(index + 1).padStart(2, '0'); + const finalFilename = canvases.length > 1 + ? `${filename}-${paddedNumber}.png` + : `${filename}.png`; + + resolve({ dataURL, filename: finalFilename }); + }); + }); - tempCanvas.toBlob(blob => { - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = finalFilename; - link.click(); + // Process exports sequentially with delay + Promise.all(exportPromises).then(exports => { + exports.forEach((exportData, index) => { + setTimeout(() => { + const link = document.createElement('a'); + link.href = exportData.dataURL; + link.download = exportData.filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, index * 1000); // 1 second delay between each download }); }); } diff --git a/js/poke-chain.js b/js/poke-chain.js new file mode 100644 index 0000000..2e227b6 --- /dev/null +++ b/js/poke-chain.js @@ -0,0 +1,58 @@ +const API_BASE = "https://pokeapi.co/api/v2"; + +/** + * Fetches JSON data from a given URL. + * @param {string} url + * @returns {Promise<any>} + */ +const fetchJSON = (url) => + fetch(url).then((res) => res.ok ? res.json() : Promise.reject(`Error: ${res.status}`)); + +/** + * Fetches a Pokémon's species data to get the evolution chain URL. + * @param {string|number} identifier - Pokémon name or ID. + * @returns {Promise<string>} + */ +const getEvolutionChainUrl = (identifier) => + fetchJSON(`${API_BASE}/pokemon-species/${identifier}`) + .then((data) => data.evolution_chain.url); + +/** + * Fetches and extracts evolution chain details. + * @param {string} url - Evolution chain API URL. + * @returns {Promise<string[]>} - Evolution chain sequence. + */ +const getEvolutionChain = (url) => + fetchJSON(url).then((data) => extractEvolutionChain(data.chain)); + +/** + * Recursively extracts evolution names from chain data. + * @param {Object} chain + * @returns {string[]} + */ +const extractEvolutionChain = (chain) => { + const evolutions = [chain.species.name]; + let next = chain.evolves_to; + + while (next.length) { + evolutions.push(next[0].species.name); + next = next[0].evolves_to; + } + + return evolutions; +}; + +/** + * Gets the evolution chain of a given Pokémon by name or ID. + * @param {string|number} identifier + * @returns {Promise<void>} + */ +const getPokemonEvolutionChain = (identifier) => + getEvolutionChainUrl(identifier) + .then(getEvolutionChain) + .then((evolutions) => console.log(`Evolution chain: ${evolutions.join(" → ")}`)) + .catch((error) => console.error(`Failed to fetch evolution chain: ${error}`)); + +// Test cases +getPokemonEvolutionChain("pikachu"); // Pikachu's evolution chain +getPokemonEvolutionChain(40); // Pokémon with ID 40 diff --git a/js/sentiment/.gitignore b/js/sentiment/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/js/sentiment/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/js/sentiment/README.md b/js/sentiment/README.md new file mode 100644 index 0000000..141470d --- /dev/null +++ b/js/sentiment/README.md @@ -0,0 +1,108 @@ +# Sentiment Analyzer + +## Overview + +The Sentiment Analyzer is a JavaScript application designed to analyze the sentiment of web content. It utilizes a combination of dictionary-based sentiment analysis, emotion categorization, intensity analysis, web content extraction, and metadata parsing to provide a comprehensive emotional analysis of text from web pages. + +### Key Features + +- **Emotion Categorization**: Classifies emotions into various categories such as joy, sadness, anger, and more. +- **Intensity Analysis**: Measures the intensity of sentiments based on the context and usage of words. +- **Web Content Extraction**: Fetches and extracts meaningful content from web pages, ignoring irrelevant sections like headers and footers. +- **Metadata Parsing**: Extracts useful metadata such as titles, authors, and publication dates from web pages. + +## Installation + +To install dependencies, run: + +```bash +bun install +``` + +## Usage + +To run the sentiment analyzer, use the following command: + +```bash +bun run app.js <url> +``` + +You can also analyze multiple URLs at once: + +```bash +bun run app.js <url1> <url2> <url3> +``` + +### Example + +```bash +bun run app.js https://example.com/blog-post +``` + +### Help + +To display help information, use: + +```bash +bun run app.js --help +``` + +## Building a Static Binary + +Bun allows you to build your application as a static binary, which can be distributed and run without requiring a separate runtime environment. To build the Sentiment Analyzer as a binary, follow these steps: + +1. **Build the Binary**: Run the following command in your terminal: + + ```bash + bun build app.js --outdir ./bin --target node + ``` + + This command compiles your application into a single binary executable for Node.js and places it in the `./bin` directory. + +2. **Run the Binary**: After building, you can run the binary directly: + + ```bash + ./bin/app.js <url> + ``` + + Or for multiple URLs: + + ```bash + ./bin/app.js <url1> <url2> <url3> + ``` + +### Example + +```bash +./bin/app.js https://example.com/blog-post +``` + +## Extending the Program + +The Sentiment Analyzer is designed to be extensible. Here are some ways you can enhance its functionality: + +1. **Add More Dictionaries**: You can extend the positive and negative word dictionaries by adding more words or phrases relevant to specific contexts or industries. + +2. **Enhance Emotion Categories**: Modify the `emotionCategories` object in `app.js` to include additional emotions or synonyms that are relevant to your analysis needs. + +3. **Implement Machine Learning**: Consider integrating machine learning models for more advanced sentiment analysis that can learn from context and improve over time. + +4. **Support for Multiple Languages**: Extend the program to support sentiment analysis in different languages by adding language-specific dictionaries and rules. + +5. **Dynamic Content Handling**: Improve the content extraction logic to handle dynamic web pages (Single Page Applications) that load content asynchronously. + +6. **Batch Processing**: Implement functionality to read URLs from a file and process them in batches, which can be useful for analyzing large datasets. + +7. **Output Formatting Options**: Add options to format the output in different ways (e.g., JSON, CSV) for easier integration with other tools or systems. + +## Contributing + +Contributions are welcome! If you have suggestions for improvements or new features, feel free to open an issue or submit a pull request. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +This project was created using `bun init` in bun v1.1.29. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/js/sentiment/SCRATCH.md b/js/sentiment/SCRATCH.md new file mode 100644 index 0000000..d5d4ca3 --- /dev/null +++ b/js/sentiment/SCRATCH.md @@ -0,0 +1,8 @@ +<https://getthematic.com/sentiment-analysis#how-does-sentiment-analysis-work> + +# Rule-based sentiment analysis works like this: + +1. “Lexicons” or lists of positive and negative words are created. These are words that are used to describe sentiment. For example, positive lexicons might include “fast”, “affordable”, and “user-friendly“. Negative lexicons could include “slow”, “pricey”, and “complicated”. +2. Before text can be analyzed it needs to be prepared. Several processes are used to format the text in a way that a machine can understand. Tokenization breaks up text into small chunks called tokens. Sentence tokenization splits up text into sentences. Word tokenization separates words in a sentence. For example, “the best customer service” would be split into “the”, “best”, and “customer service”. Lemmatization can be used to transforms words back to their root form. A lemma is the root form of a word. For example, the root form of “is, are, am, were, and been” is “be”. We also want to exclude things which are known but are not useful for sentiment analysis. So another important process is stopword removal which takes out common words like “for, at, a, to”. These words have little or no semantic value in the sentence. Applying these processes makes it easier for computers to understand the text. +3. A computer counts the number of positive or negative words in a particular text. A special rule can make sure that negated words, e.g. “not easy”, are counted as opposites. +4. The final step is to calculate the overall sentiment score for the text. As mentioned previously, this could be based on a scale of -100 to 100. In this case a score of 100 would be the highest score possible for positive sentiment. A score of 0 would indicate neutral sentiment. The score can also be expressed as a percentage, ranging from 0% as negative and 100% as positive. \ No newline at end of file diff --git a/js/sentiment/app.js b/js/sentiment/app.js new file mode 100644 index 0000000..1f66d92 --- /dev/null +++ b/js/sentiment/app.js @@ -0,0 +1,765 @@ +/** + * Web Sentiment Analyzer + * + * This program analyzes the sentiment of web content using a combination of: + * - Dictionary-based sentiment analysis + * - Emotion categorization + * - Intensity analysis + * - Web content extraction + * - Metadata parsing + * + * Architecture Overview: + * - Factory Pattern: Uses createWebSentimentAnalyzer to create analyzer instances + * - Builder Pattern: Configurable through dictionary additions and modifications + * - Strategy Pattern: Separates content fetching, analysis, and display logic + * - Command Pattern: CLI interface for processing multiple URLs + * + * @module sentiment-analyzer + */ + +import { JSDOM } from 'jsdom'; + +/** + * Creates a web-enabled sentiment analyzer with extended capabilities + * + * @param {Object} config - Optional configuration to override default dictionaries + * @returns {Object} An analyzer instance with public methods for sentiment analysis + * + * Extensibility Points: + * - Add more dictionaries (e.g., industry-specific terms) + * - Enhance emotion categories + * - Add language support + * - Implement ML-based sentiment analysis + */ +const createWebSentimentAnalyzer = (config = {}) => { + /** + * Default configuration with extensive sentiment dictionaries + * + * @property {Set} positiveWords - Words indicating positive sentiment + * @property {Set} negativeWords - Words indicating negative sentiment + * @property {Map} intensifiers - Words that modify sentiment intensity + * @property {Set} negators - Words that negate sentiment + * + * Potential Enhancements: + * - Add multi-word phrases + * - Include context-dependent sentiments + * - Add domain-specific dictionaries + */ + const defaultConfig = { + positiveWords: new Set([ + // Emotional positives + 'love', 'joy', 'happy', 'excited', 'peaceful', 'wonderful', 'fantastic', + 'delighted', 'pleased', 'glad', 'cheerful', 'content', 'satisfied', + 'grateful', 'thankful', 'blessed', 'optimistic', 'hopeful', + + // Quality positives + 'excellent', 'outstanding', 'superb', 'magnificent', 'brilliant', + 'exceptional', 'perfect', 'remarkable', 'spectacular', 'impressive', + 'incredible', 'amazing', 'extraordinary', 'marvelous', 'wonderful', + + // Performance positives + 'efficient', 'effective', 'reliable', 'innovative', 'productive', + 'successful', 'accomplished', 'achieved', 'improved', 'enhanced', + 'optimized', 'streamlined', 'breakthrough', 'revolutionary', + + // Relationship positives + 'friendly', 'helpful', 'supportive', 'kind', 'generous', 'caring', + 'compassionate', 'thoughtful', 'considerate', 'engaging', 'collaborative', + + // Experience positives + 'enjoyable', 'fun', 'entertaining', 'engaging', 'interesting', 'fascinating', + 'captivating', 'inspiring', 'motivating', 'enriching', 'rewarding', + + // Growth positives + 'growing', 'improving', 'developing', 'advancing', 'progressing', + 'evolving', 'flourishing', 'thriving', 'prospering', 'succeeding' + ]), + + negativeWords: new Set([ + // Emotional negatives + 'hate', 'angry', 'sad', 'upset', 'frustrated', 'disappointed', 'anxious', + 'worried', 'stressed', 'depressed', 'miserable', 'unhappy', 'distressed', + 'irritated', 'annoyed', 'furious', 'outraged', 'bitter', + + // Quality negatives + 'poor', 'bad', 'terrible', 'horrible', 'awful', 'dreadful', 'inferior', + 'mediocre', 'subpar', 'unacceptable', 'disappointing', 'inadequate', + 'deficient', 'flawed', 'defective', + + // Performance negatives + 'inefficient', 'ineffective', 'unreliable', 'problematic', 'failing', + 'broken', 'malfunctioning', 'corrupted', 'crashed', 'buggy', 'error', + 'failed', 'unsuccessful', 'unproductive', + + // Relationship negatives + 'hostile', 'unfriendly', 'unhelpful', 'rude', 'mean', 'cruel', 'harsh', + 'inconsiderate', 'selfish', 'aggressive', 'confrontational', 'toxic', + + // Experience negatives + 'boring', 'dull', 'tedious', 'monotonous', 'uninteresting', 'tiresome', + 'exhausting', 'frustrating', 'confusing', 'complicated', 'difficult', + + // Decline negatives + 'declining', 'deteriorating', 'worsening', 'failing', 'regressing', + 'degrading', 'diminishing', 'decreasing', 'falling', 'shrinking' + ]), + + intensifiers: new Map([ + // Strong intensifiers + ['extremely', 2.0], + ['absolutely', 2.0], + ['completely', 2.0], + ['totally', 2.0], + ['entirely', 2.0], + ['utterly', 2.0], + + // Moderate intensifiers + ['very', 1.5], + ['really', 1.5], + ['particularly', 1.5], + ['especially', 1.5], + ['notably', 1.5], + ['significantly', 1.5], + + // Mild intensifiers + ['quite', 1.25], + ['rather', 1.25], + ['somewhat', 1.25], + ['fairly', 1.25], + ['pretty', 1.25], + ['relatively', 1.25], + + // Emphatic phrases + ['without a doubt', 2.0], + ['beyond question', 2.0], + ['by far', 1.75], + ['to a great extent', 1.75] + ]), + + negators: new Set([ + // Direct negators + 'not', 'no', 'never', 'none', 'neither', 'nor', 'nothing', + + // Contracted negators + "n't", 'cannot', "won't", "wouldn't", "shouldn't", "couldn't", "haven't", + "hasn't", "didn't", "isn't", "aren't", "weren't", + + // Complex negators + 'hardly', 'scarcely', 'barely', 'rarely', 'seldom', 'few', 'little', + 'nowhere', 'nobody', 'none', 'by no means', 'on no account', + + // Implicit negators + 'deny', 'reject', 'refuse', 'prevent', 'avoid', 'stop', 'exclude', + 'doubt', 'question', 'dispute' + ]) + }; + + // Merge with provided config + const finalConfig = { ...defaultConfig, ...config }; + + /** + * Core sentiment analyzer implementation + * + * @param {Object} config - Configuration object with word dictionaries + * @returns {Object} Methods for text analysis + * + * Implementation Notes: + * - Uses a sliding window approach for context analysis + * - Implements multiplier-based intensity scoring + * - Categorizes emotions using predefined taxonomies + */ + const createSentimentAnalyzer = (config) => { + /** + * Main text analysis function + * + * @param {string} text - Text to analyze + * @returns {Object} Comprehensive analysis results + * + * Potential Improvements: + * - Add sentence-level analysis + * - Implement paragraph breakdown + * - Add statistical confidence scores + * - Consider word positioning and emphasis + */ + const analyzeText = (text) => { + // Ensure text is a string and has content + if (!text || typeof text !== 'string') { + console.warn('Invalid input to analyzeText:', text); + return { + score: 0, + words: [], + summary: { positive: 0, negative: 0, neutral: 0 }, + sentiment: 'Neutral', + topEmotions: [], + intensity: 'None', + wordCount: 0 + }; + } + + const words = text.toLowerCase() + .replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, '') + .split(/\s+/); + + let score = 0; + let multiplier = 1; + const analyzedWords = []; + const emotionCounts = new Map(); + let positiveCount = 0; + let negativeCount = 0; + let intensifierCount = 0; + + // Emotion categories for classification + const emotionCategories = { + // Strong positive emotions (weight: 2.0) + joy: { + weight: 2.0, + words: ['happy', 'joy', 'delighted', 'pleased', 'excited', 'ecstatic'] + }, + love: { + weight: 2.0, + words: ['loving', 'adoring', 'fond', 'affectionate', 'caring'] + }, + + // Moderate positive emotions (weight: 1.5) + satisfaction: { + weight: 1.5, + words: ['content', 'satisfied', 'fulfilled', 'pleased', 'accomplished'] + }, + + // Strong negative emotions (weight: -2.0) + anger: { + weight: -2.0, + words: ['angry', 'furious', 'outraged', 'enraged', 'hostile'] + }, + hate: { + weight: -2.0, + words: ['hate', 'despise', 'loathe', 'detest', 'abhor'] + }, + + // Moderate negative emotions (weight: -1.5) + frustration: { + weight: -1.5, + words: ['frustrated', 'annoyed', 'irritated', 'agitated'] + } + // ... other categories with appropriate weights + }; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + let wordImpact = { + word, + score: 0, + multiplier, + category: null + }; + + // Check for intensifiers + if (config.intensifiers.has(word)) { + multiplier = config.intensifiers.get(word); + intensifierCount++; + continue; + } + + // Check for negators + if (config.negators.has(word)) { + multiplier *= -1; + continue; + } + + // Score the word and categorize emotions + if (config.positiveWords.has(word)) { + // Increase base score for positive words + const baseScore = 2; + const wordScore = baseScore * multiplier; + score += wordScore; + positiveCount++; + wordImpact.score = wordScore; + + // Categorize emotion with weights + for (const [category, {weight, words}] of Object.entries(emotionCategories)) { + if (words.includes(word)) { + wordImpact.category = category; + emotionCounts.set(category, (emotionCounts.get(category) || 0) + 1); + // Add weighted bonus score + score += weight * multiplier; + break; + } + } + + } else if (config.negativeWords.has(word)) { + // Increase base score for negative words + const baseScore = -2; + const wordScore = baseScore * multiplier; + score += wordScore; + negativeCount++; + wordImpact.score = wordScore; + + // Categorize emotion with weights + for (const [category, {weight, words}] of Object.entries(emotionCategories)) { + if (words.includes(word)) { + wordImpact.category = category; + emotionCounts.set(category, (emotionCounts.get(category) || 0) + 1); + // Add weighted bonus score + score -= weight * multiplier; + break; + } + } + } + + if (wordImpact.score !== 0 || wordImpact.category) { + analyzedWords.push(wordImpact); + } + + // Reset multiplier after scoring a word + multiplier = 1; + } + + // Calculate intensity based on score magnitude and intensifier usage + const getIntensity = (score, intensifierCount) => { + const magnitude = Math.abs(score); + if (magnitude > 10 || intensifierCount > 5) return 'Very Strong'; + if (magnitude > 7 || intensifierCount > 3) return 'Strong'; + if (magnitude > 4 || intensifierCount > 1) return 'Moderate'; + if (magnitude > 0) return 'Mild'; + return 'Neutral'; + }; + + // Get top emotions + const topEmotions = Array.from(emotionCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([emotion, count]) => ({ emotion, count })); + + // Calculate the final score and clamp it between -10 and 10 + const clampedScore = Math.max(-10, Math.min(10, score)); + + // Only count words that contribute to sentiment + const sentimentWords = positiveCount + negativeCount; + const averageSentiment = sentimentWords > 0 ? clampedScore / sentimentWords : 0; + + return { + score: clampedScore, + words: analyzedWords, + summary: { + positive: positiveCount, + negative: negativeCount, + sentiment_words: sentimentWords, + total: words.length + }, + sentiment: getEmotionalTone(clampedScore), + topEmotions, + intensity: getIntensity(clampedScore, intensifierCount), + wordCount: words.length, + averageSentiment + }; + }; + + return { + analyzeText, + calculateSentimentScore: (text) => analyzeText(text).score, + getEmotionalTone: (text) => analyzeText(text).sentiment, + getTopWords: (text) => analyzeText(text).words + }; + }; + + // Re-use previous sentiment analysis functions + const { analyzeText, calculateSentimentScore, getEmotionalTone, getTopWords } = createSentimentAnalyzer(finalConfig); + + /** + * Fetches and extracts content from web pages + * + * @param {string} url - URL to analyze + * @returns {Promise<string>} Extracted text content + * + * Implementation Notes: + * - Uses progressive enhancement for content selection + * - Implements fallback strategies for content extraction + * - Handles various DOM structures + * + * Potential Enhancements: + * - Add support for dynamic content (SPA) + * - Implement content cleaning rules + * - Add support for paywalled content + * - Handle rate limiting + */ + const fetchContent = async (url) => { + try { + const response = await fetch(url); + const html = await response.text(); + + const dom = new JSDOM(html); + const doc = dom.window.document; + + // Enhanced content selectors + const contentSelectors = [ + 'article', + 'main', + '.content', + '.post-content', + '.entry-content', + '.article-content', + '.blog-post', + '.post', + 'article p', + '.content p', + 'p' + ]; + + let content = ''; + for (const selector of contentSelectors) { + const elements = doc.querySelectorAll(selector); + if (elements.length) { + elements.forEach(el => { + // Skip if element contains mostly navigation/header/footer content + if (el.closest('nav') || el.closest('header') || el.closest('footer')) { + return; + } + content += el.textContent + '\n\n'; + }); + if (content.trim().length > 0) break; + } + } + + // If no content was found through selectors, get all text content + if (!content) { + content = doc.body.textContent || ''; + } + + // Clean up the content + content = content + .replace(/\s+/g, ' ') + .replace(/\n\s*\n/g, '\n\n') + .trim(); + + // Ensure we're returning a string + return content || ''; + + } catch (error) { + console.error('Fetch error:', error); + throw new Error(`Failed to fetch content: ${error.message}`); + } + }; + + /** + * Extracts metadata from web documents + * + * @param {Document} doc - DOM document + * @returns {Object} Extracted metadata + * + * Implementation Notes: + * - Supports multiple metadata formats (meta tags, OpenGraph, etc.) + * - Uses fallback strategies for missing data + * + * Potential Improvements: + * - Add schema.org parsing + * - Support more metadata formats + * - Add validation and cleaning + */ + const extractMetadata = (doc) => { + const metadata = { + title: '', + description: '', + author: '', + date: '', + keywords: [] + }; + + // Extract meta tags with enhanced selectors + const metaTags = doc.querySelectorAll('meta'); + metaTags.forEach(tag => { + const name = tag.getAttribute('name')?.toLowerCase(); + const property = tag.getAttribute('property')?.toLowerCase(); + const content = tag.getAttribute('content'); + + if (content) { + if (name === 'description' || property === 'og:description') { + metadata.description = content; + } + if (name === 'author' || property === 'article:author') { + metadata.author = content; + } + if (name === 'keywords') { + metadata.keywords = content.split(',').map(k => k.trim()); + } + if (name === 'date' || property === 'article:published_time' || + property === 'article:modified_time') { + metadata.date = content; + } + } + }); + + // Try different title sources + metadata.title = doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || + doc.querySelector('h1')?.textContent || + doc.title || ''; + + // Try to find author in structured data + const authorElement = doc.querySelector('[rel="author"], .author, .byline'); + if (authorElement && !metadata.author) { + metadata.author = authorElement.textContent.trim(); + } + + // Try to find date in structured data + if (!metadata.date) { + const dateElement = doc.querySelector('time, .date, .published'); + if (dateElement) { + metadata.date = dateElement.getAttribute('datetime') || dateElement.textContent.trim(); + } + } + + return metadata; + }; + + /** + * Analyzes sentiment of web page content + * + * @param {string} url - URL to analyze + * @returns {Promise<Object>} Complete analysis results + * + * Potential Enhancements: + * - Add caching + * - Implement batch processing + * - Add historical tracking + */ + const analyzeUrl = async (url) => { + try { + const content = await fetchContent(url); + + if (!content) { + console.warn(`No content found for URL: ${url}`); + return { + score: 0, + words: [], + summary: { positive: 0, negative: 0, neutral: 0 }, + sentiment: 'Neutral', + topEmotions: [], + intensity: 'None', + wordCount: 0, + url, + metadata: {}, + fetchDate: new Date().toISOString() + }; + } + + const analysis = analyzeText(content); + + // Create a new JSDOM instance for metadata extraction + const response = await fetch(url); + const html = await response.text(); + const dom = new JSDOM(html); + + // Additional URL-specific analysis + analysis.url = url; + analysis.metadata = extractMetadata(dom.window.document); + analysis.fetchDate = new Date().toISOString(); + + return analysis; + } catch (error) { + console.error('Analysis error:', error); + throw new Error(`Analysis failed: ${error.message}`); + } + }; + + // Enhanced API + return { + analyzeText, + analyzeUrl, + addPositiveWords: (words) => words.forEach(word => finalConfig.positiveWords.add(word)), + addNegativeWords: (words) => words.forEach(word => finalConfig.negativeWords.add(word)), + addIntensifier: (word, multiplier) => finalConfig.intensifiers.set(word, multiplier), + addNegator: (word) => finalConfig.negators.add(word), + getConfig: () => ({ ...finalConfig }), + getDictionaries: () => ({ + positiveCount: finalConfig.positiveWords.size, + negativeCount: finalConfig.negativeWords.size, + intensifierCount: finalConfig.intensifiers.size, + negatorCount: finalConfig.negators.size + }) + }; + }; + + // Example usage: + const analyzer = createWebSentimentAnalyzer(); + + /** + * Creates a visual representation of sentiment score + * + * @param {number} score - Sentiment score to visualize + * @returns {string} ASCII visualization of sentiment scale + * + * Design Notes: + * - Uses Unicode characters for better visualization + * - Implements fixed-width scale for consistent display + * + * Potential Enhancements: + * - Add color support + * - Implement alternative visualizations + * - Add interactive elements + */ + const createSentimentScale = (score) => { + const width = 40; // Width of the scale + const middle = Math.floor(width / 2); + // Clamp score between -10 and 10 for display purposes + const clampedScore = Math.max(-10, Math.min(10, score)); + const position = Math.round(middle + (clampedScore * middle / 10)); + + let scale = ''; + for (let i = 0; i < width; i++) { + if (i === middle) scale += '│'; // Using Unicode box drawing character + else if (i === position) scale += '●'; + else scale += '─'; + } + + // Simpler scale display without arrows and extra spacing + return ` +NEGATIVE ${' '.repeat(middle-5)}NEUTRAL${' '.repeat(middle-5)} POSITIVE +[-10] ${scale} [+10] +Score: ${score.toFixed(2)} +`; + }; + + /** + * Formats analysis results for human readability + * + * @param {Object} analysis - Analysis results to format + * @returns {string} Formatted analysis report + * + * Implementation Notes: + * - Uses structured format for consistency + * - Implements progressive disclosure of details + * + * Potential Improvements: + * - Add output format options (JSON, CSV, etc.) + * - Implement templating system + * - Add internationalization support + */ + const formatAnalysisResults = (analysis) => { + const { + score, + summary, + sentiment, + topEmotions, + intensity, + wordCount, + metadata, + url + } = analysis; + + return ` +=== Sentiment Analysis for ${metadata.title || url} === + +${createSentimentScale(score)} + +Overall Assessment: +• Sentiment: ${sentiment} (${intensity}) +• Total Words Analyzed: ${wordCount} + +Word Breakdown: +• Positive Words: ${summary.positive} +• Negative Words: ${summary.negative} +• Sentiment-Carrying Words: ${summary.sentiment_words} (of ${summary.total} total) + +${topEmotions.length ? `Dominant Emotions: +${topEmotions.map(e => `• ${e.emotion} (mentioned ${e.count} time${e.count > 1 ? 's' : ''})`).join('\n')}` : ''} + +Content Details: +• Author: ${metadata.author || 'Not specified'} +• Date: ${metadata.date || 'Not specified'} +${metadata.description ? `• Description: ${metadata.description}` : ''} + +Notable Words: +${analysis.words + .filter(w => w.score !== 0) + .slice(0, 5) + .map(w => `• "${w.word}" (${w.score > 0 ? 'positive' : 'negative'}, ${w.category || 'general'})`) + .join('\n')} + +${'-'.repeat(60)} +`; + }; + + // Update the analyzeWebPage function + const analyzeWebPage = async (url) => { + try { + const analysis = await analyzer.analyzeUrl(url); + console.log(formatAnalysisResults(analysis)); + } catch (error) { + console.error(`\n❌ Analysis failed for ${url}:`, error.message); + } + }; + + // Example: + // analyzeWebPage('https://example.com/blog-post'); + + // Add custom words +// analyzer.addPositiveWords(['groundbreaking', 'game-changing']); +// analyzer.addNegativeWords(['concerning', 'questionable']); +// analyzer.addIntensifier('incredibly', 1.8); +// analyzer.addNegator('lacks'); + +// // Get dictionary stats +// console.log(analyzer.getDictionaries()); + +// Remove the hard-coded URLs and add CLI handling +const helpText = ` +Sentiment Analyzer +================= + +Analyzes the sentiment of web pages and provides detailed emotional analysis. + +Usage: + bun run app.js <url> + bun run app.js <url1> <url2> <url3> ... + +Example: + bun run app.js https://example.com/blog-post + bun run app.js https://blog1.com https://blog2.com + +Options: + --help, -h Show this help message +`; + +/** + * CLI program entry point + * + * Implementation Notes: + * - Uses async/await for proper error handling + * - Implements command pattern for URL processing + * + * Potential Enhancements: + * - Add configuration file support + * - Implement batch processing from file + * - Add progress indicators + * - Add output formatting options + */ +const main = async () => { + // Get command line arguments (skip first two as they're node/bun and script path) + const args = process.argv.slice(2); + + // Show help if no arguments or help flag + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + console.log(helpText); + return; + } + + // Create analyzer instance + // const analyzer = createWebSentimentAnalyzer(); + + // Analyze each URL + for (const url of args) { + try { + // Skip any help flags that might have been passed + if (url.startsWith('-')) continue; + + await analyzeWebPage(url); + } catch (error) { + console.error(`\n❌ Failed to analyze ${url}:`, error.message); + } + } +}; + +// Run the program +main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/js/sentiment/bookmarklet-minified.js b/js/sentiment/bookmarklet-minified.js new file mode 100644 index 0000000..4671638 --- /dev/null +++ b/js/sentiment/bookmarklet-minified.js @@ -0,0 +1 @@ +javascript:void function(){let e=document.createElement("script");e.src="https://eli.li/_assets/bin/sentiment.browser.js",e.onload=function(){analyzePage()},document.head.appendChild(e)}(); \ No newline at end of file diff --git a/js/sentiment/bookmarklet.js b/js/sentiment/bookmarklet.js new file mode 100644 index 0000000..9209754 --- /dev/null +++ b/js/sentiment/bookmarklet.js @@ -0,0 +1,8 @@ +javascript:(function(){ + const script = document.createElement('script'); + script.src = 'https://eli.li/_assets/bin/sentiment.browser.js'; + script.onload = function() { + analyzePage(); + }; + document.head.appendChild(script); +})(); \ No newline at end of file diff --git a/js/sentiment/bun.lockb b/js/sentiment/bun.lockb new file mode 100755 index 0000000..00cbdac --- /dev/null +++ b/js/sentiment/bun.lockb Binary files differdiff --git a/js/sentiment/jsconfig.json b/js/sentiment/jsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/js/sentiment/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/js/sentiment/package.json b/js/sentiment/package.json new file mode 100644 index 0000000..c55b3ee --- /dev/null +++ b/js/sentiment/package.json @@ -0,0 +1,14 @@ +{ + "name": "sentiment", + "module": "app.js", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "jsdom": "^26.0.0" + } +} \ No newline at end of file diff --git a/js/sentiment/sentiment/PressStart2P-Regular.ttf b/js/sentiment/sentiment/PressStart2P-Regular.ttf new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/js/sentiment/sentiment/PressStart2P-Regular.ttf diff --git a/js/sentiment/sentiment/index.html b/js/sentiment/sentiment/index.html new file mode 100644 index 0000000..f84d42c --- /dev/null +++ b/js/sentiment/sentiment/index.html @@ -0,0 +1,224 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Naive Sentiment Analyzer</title> + <link href="https://smallandnearlysilent.com/sentiment/PressStart2P-Regular.ttf" rel="stylesheet"> + <style> + @font-face { + font-family: 'Press Start 2P'; + src: url('https://smallandnearlysilent.com/sentiment/PressStart2P-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + } + :root { + --gba-dark: #081820; + --gba-mid: #346856; + --gba-light: #88c070; + --gba-pale: #e0f8d0; + } + + body { + font-family: 'Press Start 2P', monospace; + line-height: 1.6; + max-width: 800px; + margin: 0 auto; + padding: 20px; + background-color: var(--gba-dark); + color: var(--gba-pale); + font-size: 12px; + } + + /* Retro window styling */ + .window { + border: 4px solid var(--gba-pale); + border-radius: 0; + padding: 20px; + margin: 20px 0; + position: relative; + background: var(--gba-dark); + } + + .window::before { + content: ''; + position: absolute; + top: -8px; + left: -8px; + right: -8px; + bottom: -8px; + border: 2px solid var(--gba-dark); + z-index: -1; + } + + h1 { + color: var(--gba-light); + text-align: center; + margin: 40px 0; + text-transform: uppercase; + letter-spacing: 2px; + text-shadow: + 2px 2px 0 var(--gba-dark), + 4px 4px 0 var(--gba-mid); + } + + h2 { + color: var(--gba-light); + font-size: 14px; + margin-top: 30px; + border-bottom: 4px solid var(--gba-mid); + padding-bottom: 10px; + } + + .bookmarklet { + display: inline-block; + padding: 15px 30px; + background: var(--gba-light); + color: var(--gba-dark); + text-decoration: none; + margin: 20px 0; + cursor: move; + border: 4px solid var(--gba-pale); + box-shadow: + 4px 4px 0 var(--gba-mid), + 8px 8px 0 var(--gba-dark); + transition: all 0.1s ease; + } + + .bookmarklet:hover { + transform: translate(2px, 2px); + box-shadow: + 2px 2px 0 var(--gba-mid), + 6px 6px 0 var(--gba-dark); + } + + .installation { + border: 4px solid var(--gba-light); + padding: 20px; + margin: 20px 0; + background: var(--gba-mid); + } + + code { + background: var(--gba-dark); + padding: 4px 8px; + border: 2px solid var(--gba-light); + color: var(--gba-pale); + } + + .warning { + border: 4px solid var(--gba-light); + border-style: dashed; + padding: 20px; + margin: 20px 0; + animation: blink 2s infinite; + } + + @keyframes blink { + 0% { border-color: var(--gba-light); } + 50% { border-color: var(--gba-mid); } + 100% { border-color: var(--gba-light); } + } + + ul, ol { + padding-left: 20px; + } + + li { + margin: 10px 0; + position: relative; + } + + li::before { + content: '►'; + position: absolute; + left: -20px; + color: var(--gba-light); + } + + footer { + margin-top: 40px; + padding-top: 20px; + border-top: 4px solid var(--gba-mid); + color: var(--gba-light); + text-align: center; + font-size: 10px; + } + + a { + color: var(--gba-light); + text-decoration: none; + } + + a:hover { + color: var(--gba-pale); + text-decoration: underline; + } + + /* Pixel art decorations */ + .pixel-corner { + position: fixed; + width: 32px; + height: 32px; + background: var(--gba-light); + clip-path: polygon(0 0, 100% 0, 0 100%); + } + + .top-left { top: 0; left: 0; } + .top-right { top: 0; right: 0; transform: rotate(90deg); } + .bottom-left { bottom: 0; left: 0; transform: rotate(-90deg); } + .bottom-right { bottom: 0; right: 0; transform: rotate(180deg); } + + </style> +</head> +<body> + <div class="pixel-corner top-left"></div> + <div class="pixel-corner top-right"></div> + <div class="pixel-corner bottom-left"></div> + <div class="pixel-corner bottom-right"></div> + + <h1>Sentiment Scanner</h1> + + <div class="window"> + <p> + This bookmarklet analyzes the emotional tone of any webpage...badly. + </p> + </div> + + <div class="installation"> + <h2>Installation guide</h2> + <p><strong>Drag this bookmarklet to your bookmarks bar:</strong></p> + <a class="bookmarklet" href="javascript:void function(){let e=document.createElement('script');e.src='https://smallandnearlysilent.com/sentiment/sentiment.browser.js',e.onload=function(){analyzePage()},document.head.appendChild(e)}();"> + Sentiment + </a> + </div> + + <h2>How to use it</h2> + <ol> + <li>Navigate to a web page</li> + <li>Click the bookmarklet in your bookmarks bar</li> + <li>It'll analyze the page and display the results in a popup</li> + </ol> + + <div class="warning"> + <h3>Considerations</h3> + <ul> + <li>Primarily works with articles and blog posts</li> + <li>English text only</li> + </ul> + </div> + + <footer> + <p> + <a href="sentiment.browser.js" target="_blank">VIEW THE SCRIPT</a> + </p> + </footer> + + <script> + document.querySelector('.bookmarklet').addEventListener('click', function(e) { + e.preventDefault(); + alert('COMMAND INVALID!\nDRAG TO BOOKMARKS INSTEAD OF CLICKING!'); + }); + </script> +</body> +</html> \ No newline at end of file diff --git a/js/sentiment/sentiment/sentiment.browser.js b/js/sentiment/sentiment/sentiment.browser.js new file mode 100644 index 0000000..e909594 --- /dev/null +++ b/js/sentiment/sentiment/sentiment.browser.js @@ -0,0 +1,209 @@ +const createWebSentimentAnalyzer = (config = {}) => { + const defaultConfig = { + positiveWords: new Set([ + 'love', 'joy', 'happy', 'excited', 'peaceful', 'wonderful', 'fantastic', + 'excellent', 'outstanding', 'superb', 'brilliant', 'helpful', 'great', + 'efficient', 'effective', 'reliable', 'innovative', 'productive', + 'friendly', 'supportive', 'kind', 'generous', 'caring' + ]), + + negativeWords: new Set([ + 'hate', 'angry', 'sad', 'upset', 'frustrated', 'disappointed', 'anxious', + 'poor', 'bad', 'terrible', 'horrible', 'awful', 'dreadful', 'inferior', + 'inefficient', 'ineffective', 'unreliable', 'problematic', 'failing', + 'hostile', 'unfriendly', 'unhelpful', 'rude', 'mean' + ]), + + intensifiers: new Map([ + ['extremely', 2.0], + ['very', 1.5], + ['really', 1.5], + ['quite', 1.25] + ]), + + negators: new Set([ + 'not', 'no', 'never', 'none', + "n't", 'cannot', "won't", "wouldn't" + ]) + }; + + const finalConfig = { ...defaultConfig, ...config }; + + const analyzeText = (text) => { + if (!text || typeof text !== 'string') { + return { + score: 0, + summary: { positive: 0, negative: 0, neutral: 0 }, + sentiment: 'Neutral', + intensity: 'None', + wordCount: 0 + }; + } + + const words = text.toLowerCase() + .replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, '') + .split(/\s+/); + + let score = 0; + let multiplier = 1; + let positiveCount = 0; + let negativeCount = 0; + let intensifierCount = 0; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + + if (finalConfig.intensifiers.has(word)) { + multiplier = finalConfig.intensifiers.get(word); + intensifierCount++; + continue; + } + + if (finalConfig.negators.has(word)) { + multiplier *= -1; + continue; + } + + if (finalConfig.positiveWords.has(word)) { + score += 1 * multiplier; + positiveCount++; + } else if (finalConfig.negativeWords.has(word)) { + score += -1 * multiplier; + negativeCount++; + } + + multiplier = 1; + } + + const getIntensity = (score, intensifierCount) => { + const magnitude = Math.abs(score); + if (magnitude > 10 || intensifierCount > 5) return 'Very Strong'; + if (magnitude > 7 || intensifierCount > 3) return 'Strong'; + if (magnitude > 4 || intensifierCount > 1) return 'Moderate'; + if (magnitude > 0) return 'Mild'; + return 'Neutral'; + }; + + const getSentiment = (score) => { + if (score > 5) return 'Very Positive'; + if (score > 0) return 'Positive'; + if (score < -5) return 'Very Negative'; + if (score < 0) return 'Negative'; + return 'Neutral'; + }; + + return { + score, + summary: { + positive: positiveCount, + negative: negativeCount, + neutral: words.length - positiveCount - negativeCount, + total: words.length + }, + sentiment: getSentiment(score), + intensity: getIntensity(score, intensifierCount), + wordCount: words.length + }; + }; + + const extractPageContent = () => { + // Priority content selectors + const contentSelectors = [ + 'article', + 'main', + '.content', + '.post-content', + 'article p', + '.content p', + 'p' + ]; + + let content = ''; + for (const selector of contentSelectors) { + const elements = document.querySelectorAll(selector); + if (elements.length) { + elements.forEach(el => { + if (!el.closest('nav') && !el.closest('header') && !el.closest('footer')) { + content += el.textContent + '\n\n'; + } + }); + if (content.trim().length > 0) break; + } + } + + if (!content) { + content = document.body.textContent || ''; + } + + return content + .replace(/\s+/g, ' ') + .replace(/\n\s*\n/g, '\n\n') + .trim(); + }; + + const createResultsOverlay = (analysis) => { + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + max-width: 400px; + background: white; + border: 2px solid #ccc; + border-radius: 8px; + padding: 20px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 999999; + font-family: Arial, sans-serif; + font-size: 14px; + line-height: 1.4; + `; + + const closeButton = document.createElement('button'); + closeButton.textContent = '×'; + closeButton.style.cssText = ` + position: absolute; + top: 10px; + right: 10px; + border: none; + background: none; + font-size: 20px; + cursor: pointer; + color: #666; + `; + closeButton.onclick = () => overlay.remove(); + + const content = document.createElement('div'); + content.innerHTML = ` + <h2 style="margin: 0 0 15px 0; color: #333;">Sentiment Analysis</h2> + <p style="margin: 0 0 10px 0;"><strong>Overall Sentiment:</strong> ${analysis.sentiment}</p> + <p style="margin: 0 0 10px 0;"><strong>Intensity:</strong> ${analysis.intensity}</p> + <p style="margin: 0 0 10px 0;"><strong>Score:</strong> ${analysis.score.toFixed(2)}</p> + <hr style="margin: 15px 0; border: none; border-top: 1px solid #eee;"> + <p style="margin: 0 0 10px 0;"><strong>Word Count:</strong> ${analysis.wordCount}</p> + <p style="margin: 0 0 5px 0;"><strong>Breakdown:</strong></p> + <ul style="margin: 0; padding-left: 20px;"> + <li>Positive Words: ${analysis.summary.positive}</li> + <li>Negative Words: ${analysis.summary.negative}</li> + <li>Neutral Words: ${analysis.summary.neutral}</li> + </ul> + `; + + overlay.appendChild(closeButton); + overlay.appendChild(content); + document.body.appendChild(overlay); + }; + + return { + analyzeText, + extractPageContent, + createResultsOverlay + }; +}; + +const analyzePage = () => { + const analyzer = createWebSentimentAnalyzer(); + const content = analyzer.extractPageContent(); + const analysis = analyzer.analyzeText(content); + analyzer.createResultsOverlay(analysis); +}; \ No newline at end of file |