diff --git a/js/arena-calculations.js b/js/arena-calculations.js index d40c6c4..707e668 100644 --- a/js/arena-calculations.js +++ b/js/arena-calculations.js @@ -8,7 +8,7 @@ // Panel specifications (from MATLAB design_arena.m) const PANEL_SPECS = { - 'G3': { + G3: { panel_width_mm: 32, panel_depth_mm: 18, pixels_per_panel: 8, @@ -16,7 +16,7 @@ const PANEL_SPECS = { pin_dist_mm: 15.24, pin_config: 'single' }, - 'G4': { + G4: { panel_width_mm: 40.45, panel_depth_mm: 18, pixels_per_panel: 16, @@ -33,7 +33,7 @@ const PANEL_SPECS = { pin_config: 'single' }, // Note: G5 is deprecated and no longer supported - 'G6': { + G6: { panel_width_mm: 45.4, panel_depth_mm: 3.45, pixels_per_panel: 20, @@ -68,7 +68,7 @@ function calculateGeometry(panelType, numPanels, panelsInstalled = null) { // Calculate geometry const alpha = (2 * Math.PI) / numPanels; - const cRadius = panelWidth / (Math.tan(alpha / 2)) / 2; + const cRadius = panelWidth / Math.tan(alpha / 2) / 2; const backCRadius = cRadius + panelDepth; // Resolution @@ -112,7 +112,11 @@ function compareGeometry(computed, reference, tolerance = 0.0001) { const comparisons = [ { field: 'c_radius_inches', label: 'Inner Radius (in)' }, - { field: 'back_c_radius_inches', label: 'Outer Radius (in)', refField: 'back_c_radius_inches' }, + { + field: 'back_c_radius_inches', + label: 'Outer Radius (in)', + refField: 'back_c_radius_inches' + }, { field: 'degs_per_pixel', label: 'Deg/Pixel' }, { field: 'azimuthal_pixels', label: 'Azimuthal Pixels' } ]; diff --git a/js/arena-configs.js b/js/arena-configs.js index 26dd8f5..a96e95f 100644 --- a/js/arena-configs.js +++ b/js/arena-configs.js @@ -7,171 +7,137 @@ */ const STANDARD_CONFIGS = { - "G6_2x10": { - "label": "G6 (2×10) - 360°", - "description": "Full G6 arena, 2 rows x 10 columns, 360 degree coverage", - "arena": { - "generation": "G6", - "num_rows": 2, - "num_cols": 10, - "columns_installed": null, - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 0 - } - }, - "G6_2x8of10": { - "label": "G6 (2×10) - 288°", - "description": "G6 walking arena, 2 rows, 8 of 10 columns installed (288 degree coverage)", - "arena": { - "generation": "G6", - "num_rows": 2, - "num_cols": 10, - "columns_installed": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8 - ], - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 0 - } - }, - "G6_3x12of18": { - "label": "G6 (3×18) - 240°", - "description": "G6 arena, 3 rows, 12 of 18 columns installed (240 degree coverage)", - "arena": { - "generation": "G6", - "num_rows": 3, - "num_cols": 18, - "columns_installed": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11 - ], - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": -60 - } - }, - "G41_2x12_ccw": { - "label": "G4.1 CCW (2×12) - 360°", - "description": "Standard G4.1 arena, 2 rows x 12 columns, 360 degree coverage, CCW column order", - "arena": { - "generation": "G4.1", - "num_rows": 2, - "num_cols": 12, - "columns_installed": null, - "orientation": "normal", - "column_order": "ccw", - "angle_offset_deg": 0 - } - }, - "G41_2x12_cw": { - "label": "G4.1 (2×12) - 360°", - "description": "G4.1 arena, 2 rows x 12 columns, 360 degree coverage, CW column order", - "arena": { - "generation": "G4.1", - "num_rows": 2, - "num_cols": 12, - "columns_installed": null, - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 0 - } - }, - "G4_3x12": { - "label": "G4 (3×12) - 360°", - "description": "G4 arena, 3 rows x 12 columns, 360 degree coverage", - "arena": { - "generation": "G4", - "num_rows": 3, - "num_cols": 12, - "columns_installed": null, - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 0 - } - }, - "G4_3x12of18": { - "label": "G4 (3×18) - 240°", - "description": "G4 arena, 3 rows, 12 of 18 columns installed (240 degree coverage)", - "arena": { - "generation": "G4", - "num_rows": 3, - "num_cols": 18, - "columns_installed": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11 - ], - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": -60 - } - }, - "G4_4x12": { - "label": "G4 (4×12) - 360°", - "description": "G4 arena, 4 rows x 12 columns, 360 degree coverage", - "arena": { - "generation": "G4", - "num_rows": 4, - "num_cols": 12, - "columns_installed": null, - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 0 - } - }, - "G3_3x24": { - "label": "G3 (3×24) - 360°", - "description": "Full G3 arena, 3 rows x 24 columns, 360 degree coverage", - "arena": { - "generation": "G3", - "num_rows": 3, - "num_cols": 24, - "columns_installed": null, - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 0 - } - }, - "G3_4x12": { - "label": "G3 (4×12) - 360°", - "description": "Legacy G3 arena, 4 rows x 12 columns, 360 degree coverage", - "arena": { - "generation": "G3", - "num_rows": 4, - "num_cols": 12, - "columns_installed": null, - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 0 + G6_2x10: { + label: 'G6 (2×10) - 360°', + description: 'Full G6 arena, 2 rows x 10 columns, 360 degree coverage', + arena: { + generation: 'G6', + num_rows: 2, + num_cols: 10, + columns_installed: null, + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 0 + } + }, + G6_2x8of10: { + label: 'G6 (2×10) - 288°', + description: 'G6 walking arena, 2 rows, 8 of 10 columns installed (288 degree coverage)', + arena: { + generation: 'G6', + num_rows: 2, + num_cols: 10, + columns_installed: [1, 2, 3, 4, 5, 6, 7, 8], + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 0 + } + }, + G6_3x12of18: { + label: 'G6 (3×18) - 240°', + description: 'G6 arena, 3 rows, 12 of 18 columns installed (240 degree coverage)', + arena: { + generation: 'G6', + num_rows: 3, + num_cols: 18, + columns_installed: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: -60 + } + }, + G41_2x12_ccw: { + label: 'G4.1 CCW (2×12) - 360°', + description: + 'Standard G4.1 arena, 2 rows x 12 columns, 360 degree coverage, CCW column order', + arena: { + generation: 'G4.1', + num_rows: 2, + num_cols: 12, + columns_installed: null, + orientation: 'normal', + column_order: 'ccw', + angle_offset_deg: 0 + } + }, + G41_2x12_cw: { + label: 'G4.1 (2×12) - 360°', + description: 'G4.1 arena, 2 rows x 12 columns, 360 degree coverage, CW column order', + arena: { + generation: 'G4.1', + num_rows: 2, + num_cols: 12, + columns_installed: null, + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 0 + } + }, + G4_3x12: { + label: 'G4 (3×12) - 360°', + description: 'G4 arena, 3 rows x 12 columns, 360 degree coverage', + arena: { + generation: 'G4', + num_rows: 3, + num_cols: 12, + columns_installed: null, + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 0 + } + }, + G4_3x12of18: { + label: 'G4 (3×18) - 240°', + description: 'G4 arena, 3 rows, 12 of 18 columns installed (240 degree coverage)', + arena: { + generation: 'G4', + num_rows: 3, + num_cols: 18, + columns_installed: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: -60 + } + }, + G4_4x12: { + label: 'G4 (4×12) - 360°', + description: 'G4 arena, 4 rows x 12 columns, 360 degree coverage', + arena: { + generation: 'G4', + num_rows: 4, + num_cols: 12, + columns_installed: null, + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 0 + } + }, + G3_3x24: { + label: 'G3 (3×24) - 360°', + description: 'Full G3 arena, 3 rows x 24 columns, 360 degree coverage', + arena: { + generation: 'G3', + num_rows: 3, + num_cols: 24, + columns_installed: null, + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 0 + } + }, + G3_4x12: { + label: 'G3 (4×12) - 360°', + description: 'Legacy G3 arena, 4 rows x 12 columns, 360 degree coverage', + arena: { + generation: 'G3', + num_rows: 4, + num_cols: 12, + columns_installed: null, + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 0 + } } - } }; // Generation ID registry (from maDisplayTools/configs/arena_registry/generations.yaml) @@ -186,9 +152,9 @@ const GENERATIONS = { // Arena ID registry — per-generation namespaces (from maDisplayTools/configs/arena_registry/index.yaml) const ARENA_REGISTRY = { - 'G4': { 1: 'G4_4x12', 2: 'G4_3x12of18' }, + G4: { 1: 'G4_4x12', 2: 'G4_3x12of18' }, 'G4.1': { 1: 'G41_2x12_cw' }, - 'G6': { 1: 'G6_2x10', 2: 'G6_2x8of10', 3: 'G6_3x12of18' } + G6: { 1: 'G6_2x10', 2: 'G6_2x8of10', 3: 'G6_3x12of18' } }; /** @@ -241,37 +207,37 @@ function getArenaId(generation, arenaName) { // Panel specifications by generation const PANEL_SPECS = { - 'G3': { + G3: { panel_width_mm: 32, panel_height_mm: 32, panel_depth_mm: 18, pixels_per_panel: 8, led_type: 'round', - led_diameter_mm: 3.0 // 3mm diameter round (4mm pitch) + led_diameter_mm: 3.0 // 3mm diameter round (4mm pitch) }, - 'G4': { + G4: { panel_width_mm: 40.45, panel_height_mm: 40.45, panel_depth_mm: 18, pixels_per_panel: 16, led_type: 'round', - led_diameter_mm: 1.9 // 1.9mm diameter round + led_diameter_mm: 1.9 // 1.9mm diameter round }, 'G4.1': { panel_width_mm: 40, panel_height_mm: 40, panel_depth_mm: 6.35, pixels_per_panel: 16, - led_type: 'rect', // 0603 SMD at 45 degrees + led_type: 'rect', // 0603 SMD at 45 degrees led_width_mm: 1.6, led_height_mm: 0.8 }, - 'G6': { + G6: { panel_width_mm: 45.4, panel_height_mm: 45.4, panel_depth_mm: 3.45, pixels_per_panel: 20, - led_type: 'rect', // 0402 SMD at 45 degrees + led_type: 'rect', // 0402 SMD at 45 degrees led_width_mm: 1.0, led_height_mm: 0.5 } @@ -284,7 +250,7 @@ function getConfig(name) { // Helper to list all config names grouped by generation function getConfigsByGeneration() { - const groups = { 'G6': [], 'G4.1': [], 'G4': [], 'G3': [] }; + const groups = { G6: [], 'G4.1': [], G4: [], G3: [] }; for (const [name, config] of Object.entries(STANDARD_CONFIGS)) { const gen = config.arena?.generation; @@ -299,9 +265,16 @@ function getConfigsByGeneration() { // Export for both browser and Node.js if (typeof module !== 'undefined' && module.exports) { module.exports = { - STANDARD_CONFIGS, PANEL_SPECS, GENERATIONS, ARENA_REGISTRY, - getConfig, getConfigsByGeneration, - getGenerationName, getGenerationId, getArenaName, getArenaId + STANDARD_CONFIGS, + PANEL_SPECS, + GENERATIONS, + ARENA_REGISTRY, + getConfig, + getConfigsByGeneration, + getGenerationName, + getGenerationId, + getArenaName, + getArenaId }; } @@ -321,7 +294,14 @@ if (typeof window !== 'undefined') { // ES6 module export export { - STANDARD_CONFIGS, PANEL_SPECS, GENERATIONS, ARENA_REGISTRY, - getConfig, getConfigsByGeneration, - getGenerationName, getGenerationId, getArenaName, getArenaId + STANDARD_CONFIGS, + PANEL_SPECS, + GENERATIONS, + ARENA_REGISTRY, + getConfig, + getConfigsByGeneration, + getGenerationName, + getGenerationId, + getArenaName, + getArenaId }; diff --git a/js/arena-geometry.js b/js/arena-geometry.js index e81d8ec..3c06e87 100644 --- a/js/arena-geometry.js +++ b/js/arena-geometry.js @@ -35,8 +35,8 @@ function arenaCoordinates(config) { const cols = panelSize * numCols; // Angular spacing - const panRad = (2 * Math.PI) / numCircle; // Radians per panel - const pRad = panRad / panelSize; // Radians per pixel + const panRad = (2 * Math.PI) / numCircle; // Radians per panel + const pRad = panRad / panelSize; // Radians per pixel // Initialize coordinate arrays as 2D Float32Array matrices const x = new Array(rows); @@ -67,7 +67,7 @@ function arenaCoordinates(config) { // Panel center angle (centered around 0, matching MATLAB) // cphi = -Pan_rad*(Pcols-1)/2 + Pan_rad*panelIdx - const panelCenterAngle = -panRad * (numCols - 1) / 2 + panRad * panelIdx; + const panelCenterAngle = (-panRad * (numCols - 1)) / 2 + panRad * panelIdx; // Pixel offset within panel (centered) // points = (p_rad-Pan_rad)/2:p_rad:(Pan_rad-p_rad)/2 @@ -102,7 +102,7 @@ function arenaCoordinates(config) { const pixelInPanel = c % panelSize; // Panel center angle (centered around 0, matching MATLAB) - const cphi = -panRad * (numCols - 1) / 2 + panRad * panelIdx; + const cphi = (-panRad * (numCols - 1)) / 2 + panRad * panelIdx; // Pixel offset along the flat panel face (in radians, converted to linear) const pixelOffsetRad = pRad * (pixelInPanel - (panelSize - 1) / 2); @@ -265,7 +265,7 @@ function cart2sphere(x, y, z) { // Polar angle (theta) - from north pole // Handle edge case where rho = 0 (theta is undefined) if (rhoVal === 0) { - theta[i][j] = 0; // Convention: theta = 0 when at origin + theta[i][j] = 0; // Convention: theta = 0 when at origin } else { theta[i][j] = Math.acos(-zVal / rhoVal); } diff --git a/js/g6-encoding.js b/js/g6-encoding.js index 8454ab0..ac5c878 100644 --- a/js/g6-encoding.js +++ b/js/g6-encoding.js @@ -16,11 +16,11 @@ * - For display (top-to-bottom), use displayRow = 19 - panelRow */ -const G6Encoding = (function() { +const G6Encoding = (function () { 'use strict'; const PANEL_SIZE = 20; - const TOTAL_PIXELS = PANEL_SIZE * PANEL_SIZE; // 400 + const TOTAL_PIXELS = PANEL_SIZE * PANEL_SIZE; // 400 const GS2_BYTES = 50; const GS16_BYTES = 200; @@ -54,7 +54,7 @@ const G6Encoding = (function() { * @returns {number} Panel row (0 = bottom of panel) */ function displayRowToPanelRow(displayRow) { - return (PANEL_SIZE - 1) - displayRow; + return PANEL_SIZE - 1 - displayRow; } /** @@ -63,7 +63,7 @@ const G6Encoding = (function() { * @returns {number} Display row (0 = top of screen) */ function panelRowToDisplayRow(panelRow) { - return (PANEL_SIZE - 1) - panelRow; + return PANEL_SIZE - 1 - panelRow; } /** @@ -81,8 +81,8 @@ const G6Encoding = (function() { if (pixelArray[row][col] > 0) { const pixelNum = pixelToIndex(row, col); const byteIdx = Math.floor(pixelNum / 8); - const bitPos = 7 - (pixelNum % 8); // MSB-first - bytes[byteIdx] |= (1 << bitPos); + const bitPos = 7 - (pixelNum % 8); // MSB-first + bytes[byteIdx] |= 1 << bitPos; } } } @@ -102,13 +102,13 @@ const G6Encoding = (function() { for (let row = 0; row < PANEL_SIZE; row++) { for (let col = 0; col < PANEL_SIZE; col++) { - const val = Math.max(0, Math.min(15, pixelArray[row][col])); // Clamp to 0-15 + const val = Math.max(0, Math.min(15, pixelArray[row][col])); // Clamp to 0-15 const pixelNum = pixelToIndex(row, col); const byteIdx = Math.floor(pixelNum / 2); if (pixelNum % 2 === 0) { // Even pixel -> high nibble - bytes[byteIdx] |= (val << 4); + bytes[byteIdx] |= val << 4; } else { // Odd pixel -> low nibble bytes[byteIdx] |= val; @@ -126,11 +126,13 @@ const G6Encoding = (function() { * @returns {number[][]} 20x20 array where [row][col], row 0 = bottom */ function decodeGS2(bytes) { - const pixelArray = Array(PANEL_SIZE).fill(null).map(() => Array(PANEL_SIZE).fill(0)); + const pixelArray = Array(PANEL_SIZE) + .fill(null) + .map(() => Array(PANEL_SIZE).fill(0)); for (let pixelNum = 0; pixelNum < TOTAL_PIXELS; pixelNum++) { const byteIdx = Math.floor(pixelNum / 8); - const bitPos = 7 - (pixelNum % 8); // MSB-first + const bitPos = 7 - (pixelNum % 8); // MSB-first if ((bytes[byteIdx] & (1 << bitPos)) !== 0) { const { row, col } = indexToPixel(pixelNum); @@ -148,7 +150,9 @@ const G6Encoding = (function() { * @returns {number[][]} 20x20 array where [row][col], row 0 = bottom, values 0-15 */ function decodeGS16(bytes) { - const pixelArray = Array(PANEL_SIZE).fill(null).map(() => Array(PANEL_SIZE).fill(0)); + const pixelArray = Array(PANEL_SIZE) + .fill(null) + .map(() => Array(PANEL_SIZE).fill(0)); for (let pixelNum = 0; pixelNum < TOTAL_PIXELS; pixelNum++) { const byteIdx = Math.floor(pixelNum / 2); @@ -156,10 +160,10 @@ const G6Encoding = (function() { if (pixelNum % 2 === 0) { // Even pixel -> high nibble - val = (bytes[byteIdx] >> 4) & 0x0F; + val = (bytes[byteIdx] >> 4) & 0x0f; } else { // Odd pixel -> low nibble - val = bytes[byteIdx] & 0x0F; + val = bytes[byteIdx] & 0x0f; } const { row, col } = indexToPixel(pixelNum); @@ -180,7 +184,9 @@ const G6Encoding = (function() { */ function encodeFromDisplay(displayArray, mode) { // Convert display orientation to panel orientation - const panelArray = Array(PANEL_SIZE).fill(null).map(() => Array(PANEL_SIZE).fill(0)); + const panelArray = Array(PANEL_SIZE) + .fill(null) + .map(() => Array(PANEL_SIZE).fill(0)); for (let displayRow = 0; displayRow < PANEL_SIZE; displayRow++) { const panelRow = displayRowToPanelRow(displayRow); @@ -225,11 +231,13 @@ const G6Encoding = (function() { if (computed.length !== refBytes.length) { return { pass: false, - differences: [{ - type: 'length', - computed: computed.length, - reference: refBytes.length - }] + differences: [ + { + type: 'length', + computed: computed.length, + reference: refBytes.length + } + ] }; } @@ -256,7 +264,9 @@ const G6Encoding = (function() { * @returns {number[][]} Array filled with zeros */ function createEmptyArray() { - return Array(PANEL_SIZE).fill(null).map(() => Array(PANEL_SIZE).fill(0)); + return Array(PANEL_SIZE) + .fill(null) + .map(() => Array(PANEL_SIZE).fill(0)); } /** @@ -265,7 +275,9 @@ const G6Encoding = (function() { * @returns {number[][]} Array filled with value */ function createFilledArray(value = 1) { - return Array(PANEL_SIZE).fill(null).map(() => Array(PANEL_SIZE).fill(value)); + return Array(PANEL_SIZE) + .fill(null) + .map(() => Array(PANEL_SIZE).fill(value)); } // Public API diff --git a/js/gif.worker.js b/js/gif.worker.js index 269624e..882a797 100644 --- a/js/gif.worker.js +++ b/js/gif.worker.js @@ -1,3 +1,887 @@ // gif.worker.js 0.2.0 - https://github.com/jnordberg/gif.js -(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o=ByteArray.pageSize)this.newPage();this.pages[this.page][this.cursor++]=val};ByteArray.prototype.writeUTFBytes=function(string){for(var l=string.length,i=0;i=0)this.dispose=disposalCode};GIFEncoder.prototype.setRepeat=function(repeat){this.repeat=repeat};GIFEncoder.prototype.setTransparent=function(color){this.transparent=color};GIFEncoder.prototype.addFrame=function(imageData){this.image=imageData;this.colorTab=this.globalPalette&&this.globalPalette.slice?this.globalPalette:null;this.getImagePixels();this.analyzePixels();if(this.globalPalette===true)this.globalPalette=this.colorTab;if(this.firstFrame){this.writeLSD();this.writePalette();if(this.repeat>=0){this.writeNetscapeExt()}}this.writeGraphicCtrlExt();this.writeImageDesc();if(!this.firstFrame&&!this.globalPalette)this.writePalette();this.writePixels();this.firstFrame=false};GIFEncoder.prototype.finish=function(){this.out.writeByte(59)};GIFEncoder.prototype.setQuality=function(quality){if(quality<1)quality=1;this.sample=quality};GIFEncoder.prototype.setDither=function(dither){if(dither===true)dither="FloydSteinberg";this.dither=dither};GIFEncoder.prototype.setGlobalPalette=function(palette){this.globalPalette=palette};GIFEncoder.prototype.getGlobalPalette=function(){return this.globalPalette&&this.globalPalette.slice&&this.globalPalette.slice(0)||this.globalPalette};GIFEncoder.prototype.writeHeader=function(){this.out.writeUTFBytes("GIF89a")};GIFEncoder.prototype.analyzePixels=function(){if(!this.colorTab){this.neuQuant=new NeuQuant(this.pixels,this.sample);this.neuQuant.buildColormap();this.colorTab=this.neuQuant.getColormap()}if(this.dither){this.ditherPixels(this.dither.replace("-serpentine",""),this.dither.match(/-serpentine/)!==null)}else{this.indexPixels()}this.pixels=null;this.colorDepth=8;this.palSize=7;if(this.transparent!==null){this.transIndex=this.findClosest(this.transparent,true)}};GIFEncoder.prototype.indexPixels=function(imgq){var nPix=this.pixels.length/3;this.indexedPixels=new Uint8Array(nPix);var k=0;for(var j=0;j=0&&x1+x=0&&y1+y>16,(c&65280)>>8,c&255,used)};GIFEncoder.prototype.findClosestRGB=function(r,g,b,used){if(this.colorTab===null)return-1;if(this.neuQuant&&!used){return this.neuQuant.lookupRGB(r,g,b)}var c=b|g<<8|r<<16;var minpos=0;var dmin=256*256*256;var len=this.colorTab.length;for(var i=0,index=0;i=0){disp=dispose&7}disp<<=2;this.out.writeByte(0|disp|0|transp);this.writeShort(this.delay);this.out.writeByte(this.transIndex);this.out.writeByte(0)};GIFEncoder.prototype.writeImageDesc=function(){this.out.writeByte(44);this.writeShort(0);this.writeShort(0);this.writeShort(this.width);this.writeShort(this.height);if(this.firstFrame||this.globalPalette){this.out.writeByte(0)}else{this.out.writeByte(128|0|0|0|this.palSize)}};GIFEncoder.prototype.writeLSD=function(){this.writeShort(this.width);this.writeShort(this.height);this.out.writeByte(128|112|0|this.palSize);this.out.writeByte(0);this.out.writeByte(0)};GIFEncoder.prototype.writeNetscapeExt=function(){this.out.writeByte(33);this.out.writeByte(255);this.out.writeByte(11);this.out.writeUTFBytes("NETSCAPE2.0");this.out.writeByte(3);this.out.writeByte(1);this.writeShort(this.repeat);this.out.writeByte(0)};GIFEncoder.prototype.writePalette=function(){this.out.writeBytes(this.colorTab);var n=3*256-this.colorTab.length;for(var i=0;i>8&255)};GIFEncoder.prototype.writePixels=function(){var enc=new LZWEncoder(this.width,this.height,this.indexedPixels,this.colorDepth);enc.encode(this.out)};GIFEncoder.prototype.stream=function(){return this.out};module.exports=GIFEncoder},{"./LZWEncoder.js":2,"./TypedNeuQuant.js":3}],2:[function(require,module,exports){var EOF=-1;var BITS=12;var HSIZE=5003;var masks=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];function LZWEncoder(width,height,pixels,colorDepth){var initCodeSize=Math.max(2,colorDepth);var accum=new Uint8Array(256);var htab=new Int32Array(HSIZE);var codetab=new Int32Array(HSIZE);var cur_accum,cur_bits=0;var a_count;var free_ent=0;var maxcode;var clear_flg=false;var g_init_bits,ClearCode,EOFCode;function char_out(c,outs){accum[a_count++]=c;if(a_count>=254)flush_char(outs)}function cl_block(outs){cl_hash(HSIZE);free_ent=ClearCode+2;clear_flg=true;output(ClearCode,outs)}function cl_hash(hsize){for(var i=0;i=0){disp=hsize_reg-i;if(i===0)disp=1;do{if((i-=disp)<0)i+=hsize_reg;if(htab[i]===fcode){ent=codetab[i];continue outer_loop}}while(htab[i]>=0)}output(ent,outs);ent=c;if(free_ent<1<0){outs.writeByte(a_count);outs.writeBytes(accum,0,a_count);a_count=0}}function MAXCODE(n_bits){return(1<0)cur_accum|=code<=8){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}if(free_ent>maxcode||clear_flg){if(clear_flg){maxcode=MAXCODE(n_bits=g_init_bits);clear_flg=false}else{++n_bits;if(n_bits==BITS)maxcode=1<0){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}flush_char(outs)}}this.encode=encode}module.exports=LZWEncoder},{}],3:[function(require,module,exports){var ncycles=100;var netsize=256;var maxnetpos=netsize-1;var netbiasshift=4;var intbiasshift=16;var intbias=1<>betashift;var betagamma=intbias<>3;var radiusbiasshift=6;var radiusbias=1<>3);var i,v;for(i=0;i>=netbiasshift;network[i][1]>>=netbiasshift;network[i][2]>>=netbiasshift;network[i][3]=i}}function altersingle(alpha,i,b,g,r){network[i][0]-=alpha*(network[i][0]-b)/initalpha;network[i][1]-=alpha*(network[i][1]-g)/initalpha;network[i][2]-=alpha*(network[i][2]-r)/initalpha}function alterneigh(radius,i,b,g,r){var lo=Math.abs(i-radius);var hi=Math.min(i+radius,netsize);var j=i+1;var k=i-1;var m=1;var p,a;while(jlo){a=radpower[m++];if(jlo){p=network[k--];p[0]-=a*(p[0]-b)/alpharadbias;p[1]-=a*(p[1]-g)/alpharadbias;p[2]-=a*(p[2]-r)/alpharadbias}}}function contest(b,g,r){var bestd=~(1<<31);var bestbiasd=bestd;var bestpos=-1;var bestbiaspos=bestpos;var i,n,dist,biasdist,betafreq;for(i=0;i>intbiasshift-netbiasshift);if(biasdist>betashift;freq[i]-=betafreq;bias[i]+=betafreq<>1;for(j=previouscol+1;j>1;for(j=previouscol+1;j<256;j++)netindex[j]=maxnetpos}function inxsearch(b,g,r){var a,p,dist;var bestd=1e3;var best=-1;var i=netindex[g];var j=i-1;while(i=0){if(i=bestd)i=netsize;else{i++;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist=0){p=network[j];dist=g-p[1];if(dist>=bestd)j=-1;else{j--;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist>radiusbiasshift;if(rad<=1)rad=0;for(i=0;i=lengthcount)pix-=lengthcount;i++;if(delta===0)delta=1;if(i%delta===0){alpha-=alpha/alphadec;radius-=radius/radiusdec;rad=radius>>radiusbiasshift;if(rad<=1)rad=0;for(j=0;j= ByteArray.pageSize) this.newPage(); + this.pages[this.page][this.cursor++] = val; + }; + ByteArray.prototype.writeUTFBytes = function (string) { + for (var l = string.length, i = 0; i < l; i++) + this.writeByte(string.charCodeAt(i)); + }; + ByteArray.prototype.writeBytes = function (array, offset, length) { + for (var l = length || array.length, i = offset || 0; i < l; i++) + this.writeByte(array[i]); + }; + function GIFEncoder(width, height) { + this.width = ~~width; + this.height = ~~height; + this.transparent = null; + this.transIndex = 0; + this.repeat = -1; + this.delay = 0; + this.image = null; + this.pixels = null; + this.indexedPixels = null; + this.colorDepth = null; + this.colorTab = null; + this.neuQuant = null; + this.usedEntry = new Array(); + this.palSize = 7; + this.dispose = -1; + this.firstFrame = true; + this.sample = 10; + this.dither = false; + this.globalPalette = false; + this.out = new ByteArray(); + } + GIFEncoder.prototype.setDelay = function (milliseconds) { + this.delay = Math.round(milliseconds / 10); + }; + GIFEncoder.prototype.setFrameRate = function (fps) { + this.delay = Math.round(100 / fps); + }; + GIFEncoder.prototype.setDispose = function (disposalCode) { + if (disposalCode >= 0) this.dispose = disposalCode; + }; + GIFEncoder.prototype.setRepeat = function (repeat) { + this.repeat = repeat; + }; + GIFEncoder.prototype.setTransparent = function (color) { + this.transparent = color; + }; + GIFEncoder.prototype.addFrame = function (imageData) { + this.image = imageData; + this.colorTab = + this.globalPalette && this.globalPalette.slice ? this.globalPalette : null; + this.getImagePixels(); + this.analyzePixels(); + if (this.globalPalette === true) this.globalPalette = this.colorTab; + if (this.firstFrame) { + this.writeLSD(); + this.writePalette(); + if (this.repeat >= 0) { + this.writeNetscapeExt(); + } + } + this.writeGraphicCtrlExt(); + this.writeImageDesc(); + if (!this.firstFrame && !this.globalPalette) this.writePalette(); + this.writePixels(); + this.firstFrame = false; + }; + GIFEncoder.prototype.finish = function () { + this.out.writeByte(59); + }; + GIFEncoder.prototype.setQuality = function (quality) { + if (quality < 1) quality = 1; + this.sample = quality; + }; + GIFEncoder.prototype.setDither = function (dither) { + if (dither === true) dither = 'FloydSteinberg'; + this.dither = dither; + }; + GIFEncoder.prototype.setGlobalPalette = function (palette) { + this.globalPalette = palette; + }; + GIFEncoder.prototype.getGlobalPalette = function () { + return ( + (this.globalPalette && + this.globalPalette.slice && + this.globalPalette.slice(0)) || + this.globalPalette + ); + }; + GIFEncoder.prototype.writeHeader = function () { + this.out.writeUTFBytes('GIF89a'); + }; + GIFEncoder.prototype.analyzePixels = function () { + if (!this.colorTab) { + this.neuQuant = new NeuQuant(this.pixels, this.sample); + this.neuQuant.buildColormap(); + this.colorTab = this.neuQuant.getColormap(); + } + if (this.dither) { + this.ditherPixels( + this.dither.replace('-serpentine', ''), + this.dither.match(/-serpentine/) !== null + ); + } else { + this.indexPixels(); + } + this.pixels = null; + this.colorDepth = 8; + this.palSize = 7; + if (this.transparent !== null) { + this.transIndex = this.findClosest(this.transparent, true); + } + }; + GIFEncoder.prototype.indexPixels = function (imgq) { + var nPix = this.pixels.length / 3; + this.indexedPixels = new Uint8Array(nPix); + var k = 0; + for (var j = 0; j < nPix; j++) { + var index = this.findClosestRGB( + this.pixels[k++] & 255, + this.pixels[k++] & 255, + this.pixels[k++] & 255 + ); + this.usedEntry[index] = true; + this.indexedPixels[j] = index; + } + }; + GIFEncoder.prototype.ditherPixels = function (kernel, serpentine) { + var kernels = { + FalseFloydSteinberg: [ + [3 / 8, 1, 0], + [3 / 8, 0, 1], + [2 / 8, 1, 1] + ], + FloydSteinberg: [ + [7 / 16, 1, 0], + [3 / 16, -1, 1], + [5 / 16, 0, 1], + [1 / 16, 1, 1] + ], + Stucki: [ + [8 / 42, 1, 0], + [4 / 42, 2, 0], + [2 / 42, -2, 1], + [4 / 42, -1, 1], + [8 / 42, 0, 1], + [4 / 42, 1, 1], + [2 / 42, 2, 1], + [1 / 42, -2, 2], + [2 / 42, -1, 2], + [4 / 42, 0, 2], + [2 / 42, 1, 2], + [1 / 42, 2, 2] + ], + Atkinson: [ + [1 / 8, 1, 0], + [1 / 8, 2, 0], + [1 / 8, -1, 1], + [1 / 8, 0, 1], + [1 / 8, 1, 1], + [1 / 8, 0, 2] + ] + }; + if (!kernel || !kernels[kernel]) { + throw 'Unknown dithering kernel: ' + kernel; + } + var ds = kernels[kernel]; + var index = 0, + height = this.height, + width = this.width, + data = this.pixels; + var direction = serpentine ? -1 : 1; + this.indexedPixels = new Uint8Array(this.pixels.length / 3); + for (var y = 0; y < height; y++) { + if (serpentine) direction = direction * -1; + for ( + var x = direction == 1 ? 0 : width - 1, + xend = direction == 1 ? width : 0; + x !== xend; + x += direction + ) { + index = y * width + x; + var idx = index * 3; + var r1 = data[idx]; + var g1 = data[idx + 1]; + var b1 = data[idx + 2]; + idx = this.findClosestRGB(r1, g1, b1); + this.usedEntry[idx] = true; + this.indexedPixels[index] = idx; + idx *= 3; + var r2 = this.colorTab[idx]; + var g2 = this.colorTab[idx + 1]; + var b2 = this.colorTab[idx + 2]; + var er = r1 - r2; + var eg = g1 - g2; + var eb = b1 - b2; + for ( + var i = direction == 1 ? 0 : ds.length - 1, + end = direction == 1 ? ds.length : 0; + i !== end; + i += direction + ) { + var x1 = ds[i][1]; + var y1 = ds[i][2]; + if ( + x1 + x >= 0 && + x1 + x < width && + y1 + y >= 0 && + y1 + y < height + ) { + var d = ds[i][0]; + idx = index + x1 + y1 * width; + idx *= 3; + data[idx] = Math.max(0, Math.min(255, data[idx] + er * d)); + data[idx + 1] = Math.max( + 0, + Math.min(255, data[idx + 1] + eg * d) + ); + data[idx + 2] = Math.max( + 0, + Math.min(255, data[idx + 2] + eb * d) + ); + } + } + } + } + }; + GIFEncoder.prototype.findClosest = function (c, used) { + return this.findClosestRGB( + (c & 16711680) >> 16, + (c & 65280) >> 8, + c & 255, + used + ); + }; + GIFEncoder.prototype.findClosestRGB = function (r, g, b, used) { + if (this.colorTab === null) return -1; + if (this.neuQuant && !used) { + return this.neuQuant.lookupRGB(r, g, b); + } + var c = b | (g << 8) | (r << 16); + var minpos = 0; + var dmin = 256 * 256 * 256; + var len = this.colorTab.length; + for (var i = 0, index = 0; i < len; index++) { + var dr = r - (this.colorTab[i++] & 255); + var dg = g - (this.colorTab[i++] & 255); + var db = b - (this.colorTab[i++] & 255); + var d = dr * dr + dg * dg + db * db; + if ((!used || this.usedEntry[index]) && d < dmin) { + dmin = d; + minpos = index; + } + } + return minpos; + }; + GIFEncoder.prototype.getImagePixels = function () { + var w = this.width; + var h = this.height; + this.pixels = new Uint8Array(w * h * 3); + var data = this.image; + var srcPos = 0; + var count = 0; + for (var i = 0; i < h; i++) { + for (var j = 0; j < w; j++) { + this.pixels[count++] = data[srcPos++]; + this.pixels[count++] = data[srcPos++]; + this.pixels[count++] = data[srcPos++]; + srcPos++; + } + } + }; + GIFEncoder.prototype.writeGraphicCtrlExt = function () { + this.out.writeByte(33); + this.out.writeByte(249); + this.out.writeByte(4); + var transp, disp; + if (this.transparent === null) { + transp = 0; + disp = 0; + } else { + transp = 1; + disp = 2; + } + if (this.dispose >= 0) { + disp = dispose & 7; + } + disp <<= 2; + this.out.writeByte(0 | disp | 0 | transp); + this.writeShort(this.delay); + this.out.writeByte(this.transIndex); + this.out.writeByte(0); + }; + GIFEncoder.prototype.writeImageDesc = function () { + this.out.writeByte(44); + this.writeShort(0); + this.writeShort(0); + this.writeShort(this.width); + this.writeShort(this.height); + if (this.firstFrame || this.globalPalette) { + this.out.writeByte(0); + } else { + this.out.writeByte(128 | 0 | 0 | 0 | this.palSize); + } + }; + GIFEncoder.prototype.writeLSD = function () { + this.writeShort(this.width); + this.writeShort(this.height); + this.out.writeByte(128 | 112 | 0 | this.palSize); + this.out.writeByte(0); + this.out.writeByte(0); + }; + GIFEncoder.prototype.writeNetscapeExt = function () { + this.out.writeByte(33); + this.out.writeByte(255); + this.out.writeByte(11); + this.out.writeUTFBytes('NETSCAPE2.0'); + this.out.writeByte(3); + this.out.writeByte(1); + this.writeShort(this.repeat); + this.out.writeByte(0); + }; + GIFEncoder.prototype.writePalette = function () { + this.out.writeBytes(this.colorTab); + var n = 3 * 256 - this.colorTab.length; + for (var i = 0; i < n; i++) this.out.writeByte(0); + }; + GIFEncoder.prototype.writeShort = function (pValue) { + this.out.writeByte(pValue & 255); + this.out.writeByte((pValue >> 8) & 255); + }; + GIFEncoder.prototype.writePixels = function () { + var enc = new LZWEncoder( + this.width, + this.height, + this.indexedPixels, + this.colorDepth + ); + enc.encode(this.out); + }; + GIFEncoder.prototype.stream = function () { + return this.out; + }; + module.exports = GIFEncoder; + }, + { './LZWEncoder.js': 2, './TypedNeuQuant.js': 3 } + ], + 2: [ + function (require, module, exports) { + var EOF = -1; + var BITS = 12; + var HSIZE = 5003; + var masks = [ + 0, 1, 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383, 32767, + 65535 + ]; + function LZWEncoder(width, height, pixels, colorDepth) { + var initCodeSize = Math.max(2, colorDepth); + var accum = new Uint8Array(256); + var htab = new Int32Array(HSIZE); + var codetab = new Int32Array(HSIZE); + var cur_accum, + cur_bits = 0; + var a_count; + var free_ent = 0; + var maxcode; + var clear_flg = false; + var g_init_bits, ClearCode, EOFCode; + function char_out(c, outs) { + accum[a_count++] = c; + if (a_count >= 254) flush_char(outs); + } + function cl_block(outs) { + cl_hash(HSIZE); + free_ent = ClearCode + 2; + clear_flg = true; + output(ClearCode, outs); + } + function cl_hash(hsize) { + for (var i = 0; i < hsize; ++i) htab[i] = -1; + } + function compress(init_bits, outs) { + var fcode, c, i, ent, disp, hsize_reg, hshift; + g_init_bits = init_bits; + clear_flg = false; + n_bits = g_init_bits; + maxcode = MAXCODE(n_bits); + ClearCode = 1 << (init_bits - 1); + EOFCode = ClearCode + 1; + free_ent = ClearCode + 2; + a_count = 0; + ent = nextPixel(); + hshift = 0; + for (fcode = HSIZE; fcode < 65536; fcode *= 2) ++hshift; + hshift = 8 - hshift; + hsize_reg = HSIZE; + cl_hash(hsize_reg); + output(ClearCode, outs); + outer_loop: while ((c = nextPixel()) != EOF) { + fcode = (c << BITS) + ent; + i = (c << hshift) ^ ent; + if (htab[i] === fcode) { + ent = codetab[i]; + continue; + } else if (htab[i] >= 0) { + disp = hsize_reg - i; + if (i === 0) disp = 1; + do { + if ((i -= disp) < 0) i += hsize_reg; + if (htab[i] === fcode) { + ent = codetab[i]; + continue outer_loop; + } + } while (htab[i] >= 0); + } + output(ent, outs); + ent = c; + if (free_ent < 1 << BITS) { + codetab[i] = free_ent++; + htab[i] = fcode; + } else { + cl_block(outs); + } + } + output(ent, outs); + output(EOFCode, outs); + } + function encode(outs) { + outs.writeByte(initCodeSize); + remaining = width * height; + curPixel = 0; + compress(initCodeSize + 1, outs); + outs.writeByte(0); + } + function flush_char(outs) { + if (a_count > 0) { + outs.writeByte(a_count); + outs.writeBytes(accum, 0, a_count); + a_count = 0; + } + } + function MAXCODE(n_bits) { + return (1 << n_bits) - 1; + } + function nextPixel() { + if (remaining === 0) return EOF; + --remaining; + var pix = pixels[curPixel++]; + return pix & 255; + } + function output(code, outs) { + cur_accum &= masks[cur_bits]; + if (cur_bits > 0) cur_accum |= code << cur_bits; + else cur_accum = code; + cur_bits += n_bits; + while (cur_bits >= 8) { + char_out(cur_accum & 255, outs); + cur_accum >>= 8; + cur_bits -= 8; + } + if (free_ent > maxcode || clear_flg) { + if (clear_flg) { + maxcode = MAXCODE((n_bits = g_init_bits)); + clear_flg = false; + } else { + ++n_bits; + if (n_bits == BITS) maxcode = 1 << BITS; + else maxcode = MAXCODE(n_bits); + } + } + if (code == EOFCode) { + while (cur_bits > 0) { + char_out(cur_accum & 255, outs); + cur_accum >>= 8; + cur_bits -= 8; + } + flush_char(outs); + } + } + this.encode = encode; + } + module.exports = LZWEncoder; + }, + {} + ], + 3: [ + function (require, module, exports) { + var ncycles = 100; + var netsize = 256; + var maxnetpos = netsize - 1; + var netbiasshift = 4; + var intbiasshift = 16; + var intbias = 1 << intbiasshift; + var gammashift = 10; + var gamma = 1 << gammashift; + var betashift = 10; + var beta = intbias >> betashift; + var betagamma = intbias << (gammashift - betashift); + var initrad = netsize >> 3; + var radiusbiasshift = 6; + var radiusbias = 1 << radiusbiasshift; + var initradius = initrad * radiusbias; + var radiusdec = 30; + var alphabiasshift = 10; + var initalpha = 1 << alphabiasshift; + var alphadec; + var radbiasshift = 8; + var radbias = 1 << radbiasshift; + var alpharadbshift = alphabiasshift + radbiasshift; + var alpharadbias = 1 << alpharadbshift; + var prime1 = 499; + var prime2 = 491; + var prime3 = 487; + var prime4 = 503; + var minpicturebytes = 3 * prime4; + function NeuQuant(pixels, samplefac) { + var network; + var netindex; + var bias; + var freq; + var radpower; + function init() { + network = []; + netindex = new Int32Array(256); + bias = new Int32Array(netsize); + freq = new Int32Array(netsize); + radpower = new Int32Array(netsize >> 3); + var i, v; + for (i = 0; i < netsize; i++) { + v = (i << (netbiasshift + 8)) / netsize; + network[i] = new Float64Array([v, v, v, 0]); + freq[i] = intbias / netsize; + bias[i] = 0; + } + } + function unbiasnet() { + for (var i = 0; i < netsize; i++) { + network[i][0] >>= netbiasshift; + network[i][1] >>= netbiasshift; + network[i][2] >>= netbiasshift; + network[i][3] = i; + } + } + function altersingle(alpha, i, b, g, r) { + network[i][0] -= (alpha * (network[i][0] - b)) / initalpha; + network[i][1] -= (alpha * (network[i][1] - g)) / initalpha; + network[i][2] -= (alpha * (network[i][2] - r)) / initalpha; + } + function alterneigh(radius, i, b, g, r) { + var lo = Math.abs(i - radius); + var hi = Math.min(i + radius, netsize); + var j = i + 1; + var k = i - 1; + var m = 1; + var p, a; + while (j < hi || k > lo) { + a = radpower[m++]; + if (j < hi) { + p = network[j++]; + p[0] -= (a * (p[0] - b)) / alpharadbias; + p[1] -= (a * (p[1] - g)) / alpharadbias; + p[2] -= (a * (p[2] - r)) / alpharadbias; + } + if (k > lo) { + p = network[k--]; + p[0] -= (a * (p[0] - b)) / alpharadbias; + p[1] -= (a * (p[1] - g)) / alpharadbias; + p[2] -= (a * (p[2] - r)) / alpharadbias; + } + } + } + function contest(b, g, r) { + var bestd = ~(1 << 31); + var bestbiasd = bestd; + var bestpos = -1; + var bestbiaspos = bestpos; + var i, n, dist, biasdist, betafreq; + for (i = 0; i < netsize; i++) { + n = network[i]; + dist = Math.abs(n[0] - b) + Math.abs(n[1] - g) + Math.abs(n[2] - r); + if (dist < bestd) { + bestd = dist; + bestpos = i; + } + biasdist = dist - (bias[i] >> (intbiasshift - netbiasshift)); + if (biasdist < bestbiasd) { + bestbiasd = biasdist; + bestbiaspos = i; + } + betafreq = freq[i] >> betashift; + freq[i] -= betafreq; + bias[i] += betafreq << gammashift; + } + freq[bestpos] += beta; + bias[bestpos] -= betagamma; + return bestbiaspos; + } + function inxbuild() { + var i, + j, + p, + q, + smallpos, + smallval, + previouscol = 0, + startpos = 0; + for (i = 0; i < netsize; i++) { + p = network[i]; + smallpos = i; + smallval = p[1]; + for (j = i + 1; j < netsize; j++) { + q = network[j]; + if (q[1] < smallval) { + smallpos = j; + smallval = q[1]; + } + } + q = network[smallpos]; + if (i != smallpos) { + j = q[0]; + q[0] = p[0]; + p[0] = j; + j = q[1]; + q[1] = p[1]; + p[1] = j; + j = q[2]; + q[2] = p[2]; + p[2] = j; + j = q[3]; + q[3] = p[3]; + p[3] = j; + } + if (smallval != previouscol) { + netindex[previouscol] = (startpos + i) >> 1; + for (j = previouscol + 1; j < smallval; j++) netindex[j] = i; + previouscol = smallval; + startpos = i; + } + } + netindex[previouscol] = (startpos + maxnetpos) >> 1; + for (j = previouscol + 1; j < 256; j++) netindex[j] = maxnetpos; + } + function inxsearch(b, g, r) { + var a, p, dist; + var bestd = 1e3; + var best = -1; + var i = netindex[g]; + var j = i - 1; + while (i < netsize || j >= 0) { + if (i < netsize) { + p = network[i]; + dist = p[1] - g; + if (dist >= bestd) i = netsize; + else { + i++; + if (dist < 0) dist = -dist; + a = p[0] - b; + if (a < 0) a = -a; + dist += a; + if (dist < bestd) { + a = p[2] - r; + if (a < 0) a = -a; + dist += a; + if (dist < bestd) { + bestd = dist; + best = p[3]; + } + } + } + } + if (j >= 0) { + p = network[j]; + dist = g - p[1]; + if (dist >= bestd) j = -1; + else { + j--; + if (dist < 0) dist = -dist; + a = p[0] - b; + if (a < 0) a = -a; + dist += a; + if (dist < bestd) { + a = p[2] - r; + if (a < 0) a = -a; + dist += a; + if (dist < bestd) { + bestd = dist; + best = p[3]; + } + } + } + } + } + return best; + } + function learn() { + var i; + var lengthcount = pixels.length; + var alphadec = 30 + (samplefac - 1) / 3; + var samplepixels = lengthcount / (3 * samplefac); + var delta = ~~(samplepixels / ncycles); + var alpha = initalpha; + var radius = initradius; + var rad = radius >> radiusbiasshift; + if (rad <= 1) rad = 0; + for (i = 0; i < rad; i++) + radpower[i] = alpha * (((rad * rad - i * i) * radbias) / (rad * rad)); + var step; + if (lengthcount < minpicturebytes) { + samplefac = 1; + step = 3; + } else if (lengthcount % prime1 !== 0) { + step = 3 * prime1; + } else if (lengthcount % prime2 !== 0) { + step = 3 * prime2; + } else if (lengthcount % prime3 !== 0) { + step = 3 * prime3; + } else { + step = 3 * prime4; + } + var b, g, r, j; + var pix = 0; + i = 0; + while (i < samplepixels) { + b = (pixels[pix] & 255) << netbiasshift; + g = (pixels[pix + 1] & 255) << netbiasshift; + r = (pixels[pix + 2] & 255) << netbiasshift; + j = contest(b, g, r); + altersingle(alpha, j, b, g, r); + if (rad !== 0) alterneigh(rad, j, b, g, r); + pix += step; + if (pix >= lengthcount) pix -= lengthcount; + i++; + if (delta === 0) delta = 1; + if (i % delta === 0) { + alpha -= alpha / alphadec; + radius -= radius / radiusdec; + rad = radius >> radiusbiasshift; + if (rad <= 1) rad = 0; + for (j = 0; j < rad; j++) + radpower[j] = + alpha * (((rad * rad - j * j) * radbias) / (rad * rad)); + } + } + } + function buildColormap() { + init(); + learn(); + unbiasnet(); + inxbuild(); + } + this.buildColormap = buildColormap; + function getColormap() { + var map = []; + var index = []; + for (var i = 0; i < netsize; i++) index[network[i][3]] = i; + var k = 0; + for (var l = 0; l < netsize; l++) { + var j = index[l]; + map[k++] = network[j][0]; + map[k++] = network[j][1]; + map[k++] = network[j][2]; + } + return map; + } + this.getColormap = getColormap; + this.lookupRGB = inxsearch; + } + module.exports = NeuQuant; + }, + {} + ], + 4: [ + function (require, module, exports) { + var GIFEncoder, renderFrame; + GIFEncoder = require('./GIFEncoder.js'); + renderFrame = function (frame) { + var encoder, page, stream, transfer; + encoder = new GIFEncoder(frame.width, frame.height); + if (frame.index === 0) { + encoder.writeHeader(); + } else { + encoder.firstFrame = false; + } + encoder.setTransparent(frame.transparent); + encoder.setRepeat(frame.repeat); + encoder.setDelay(frame.delay); + encoder.setQuality(frame.quality); + encoder.setDither(frame.dither); + encoder.setGlobalPalette(frame.globalPalette); + encoder.addFrame(frame.data); + if (frame.last) { + encoder.finish(); + } + if (frame.globalPalette === true) { + frame.globalPalette = encoder.getGlobalPalette(); + } + stream = encoder.stream(); + frame.data = stream.pages; + frame.cursor = stream.cursor; + frame.pageSize = stream.constructor.pageSize; + if (frame.canTransfer) { + transfer = (function () { + var i, len, ref, results; + ref = frame.data; + results = []; + for (i = 0, len = ref.length; i < len; i++) { + page = ref[i]; + results.push(page.buffer); + } + return results; + })(); + return self.postMessage(frame, transfer); + } else { + return self.postMessage(frame); + } + }; + self.onmessage = function (event) { + return renderFrame(event.data); + }; + }, + { './GIFEncoder.js': 1 } + ] + }, + {}, + [4] +); //# sourceMappingURL=gif.worker.js.map diff --git a/js/icon-generator.js b/js/icon-generator.js index 3bb1dcd..237eb96 100644 --- a/js/icon-generator.js +++ b/js/icon-generator.js @@ -20,8 +20,7 @@ function normalizePatternData(patternData) { rows: patternData.pixelRows || patternData.rows, cols: patternData.pixelCols || patternData.cols, // Map gs_val to grayscaleMode - grayscaleMode: patternData.grayscaleMode || - (patternData.gs_val === 16 ? 'GS16' : 'GS2') + grayscaleMode: patternData.grayscaleMode || (patternData.gs_val === 16 ? 'GS16' : 'GS2') }; } @@ -36,21 +35,20 @@ function generatePatternIcon(patternData, arenaConfig, options = {}) { // Normalize pattern data field names patternData = normalizePatternData(patternData); const opts = { - frameIndex: null, // null = middle frame + frameIndex: null, // null = middle frame width: 256, height: 256, - innerRadiusRatio: 0.2, // inner/outer radius (smaller = more perspective) - backgroundColor: 'dark', // 'dark', 'white', or 'transparent' - showGaps: true, // render missing panels as gaps - showOutlines: true, // show arena outlines for depth - padding: 2, // minimal padding - ring fills ~95% of canvas + innerRadiusRatio: 0.2, // inner/outer radius (smaller = more perspective) + backgroundColor: 'dark', // 'dark', 'white', or 'transparent' + showGaps: true, // render missing panels as gaps + showOutlines: true, // show arena outlines for depth + padding: 2, // minimal padding - ring fills ~95% of canvas ...options }; // Select frame - const frameIndex = opts.frameIndex !== null - ? opts.frameIndex - : Math.floor(patternData.frames.length / 2); + const frameIndex = + opts.frameIndex !== null ? opts.frameIndex : Math.floor(patternData.frames.length / 2); if (frameIndex < 0 || frameIndex >= patternData.frames.length) { throw new Error(`Frame index ${frameIndex} out of range`); @@ -74,13 +72,13 @@ function generateMotionIcon(patternData, arenaConfig, options = {}) { patternData = normalizePatternData(patternData); const opts = { - frameRange: [0, patternData.frames.length - 1], // [start, end] inclusive - maxFrames: 10, // max frames to sample - weightingFunction: 'exponential', // 'exponential' or 'linear' + frameRange: [0, patternData.frames.length - 1], // [start, end] inclusive + maxFrames: 10, // max frames to sample + weightingFunction: 'exponential', // 'exponential' or 'linear' width: 256, height: 256, innerRadiusRatio: 0.2, - backgroundColor: 'dark', // 'dark', 'white', or 'transparent' + backgroundColor: 'dark', // 'dark', 'white', or 'transparent' showGaps: true, showOutlines: true, ...options @@ -97,9 +95,10 @@ function generateMotionIcon(patternData, arenaConfig, options = {}) { const frameIndices = selectFrames(startIdx, endIdx, opts.maxFrames); // Calculate weights (newest = highest weight) - const weights = opts.weightingFunction === 'exponential' - ? calculateExponentialWeights(frameIndices.length) - : calculateLinearWeights(frameIndices.length); + const weights = + opts.weightingFunction === 'exponential' + ? calculateExponentialWeights(frameIndices.length) + : calculateLinearWeights(frameIndices.length); // Compute weighted average frame const averagedFrame = computeWeightedAverage( @@ -147,7 +146,7 @@ function calculateExponentialWeights(numFrames) { } // Normalize const sum = weights.reduce((a, b) => a + b, 0); - return weights.map(w => w / sum); + return weights.map((w) => w / sum); } /** @@ -160,7 +159,7 @@ function calculateLinearWeights(numFrames) { } // Normalize const sum = weights.reduce((a, b) => a + b, 0); - return weights.map(w => w / sum); + return weights.map((w) => w / sum); } /** @@ -204,7 +203,7 @@ function renderCylindricalIconToCanvas(frameData, patternData, arenaConfig, opts } else if (opts.backgroundColor === 'white') { bgColor = '#ffffff'; } else { - bgColor = '#0f1419'; // dark + bgColor = '#0f1419'; // dark } // Fill background (unless transparent) @@ -230,8 +229,8 @@ function renderCylindricalIconToCanvas(frameData, patternData, arenaConfig, opts const pixelsPerPanel = specs.pixels_per_panel; const numCols = arenaConfig.num_cols; const numRows = arenaConfig.num_rows; - const columnsInstalled = arenaConfig.columns_installed || - Array.from({ length: numCols }, (_, i) => i); + const columnsInstalled = + arenaConfig.columns_installed || Array.from({ length: numCols }, (_, i) => i); const columnOrder = arenaConfig.column_order || 'cw'; // Pattern data dimensions (from loaded file) @@ -243,14 +242,14 @@ function renderCylindricalIconToCanvas(frameData, patternData, arenaConfig, opts // Base offset: -90° to start at south (-PI/2) // In canvas: 0° = right (East), -90° = down (South), angles go counter-clockwise const BASE_OFFSET_RAD = -Math.PI / 2; - const alpha = (2 * Math.PI) / numCols; // angle per column (full arena) + const alpha = (2 * Math.PI) / numCols; // angle per column (full arena) // Arena-specific angular offset (e.g., for aligning gap position) - const angleOffsetRad = (arenaConfig.angle_offset_deg || 0) * Math.PI / 180; + const angleOffsetRad = ((arenaConfig.angle_offset_deg || 0) * Math.PI) / 180; // Render each installed column for (let installedIdx = 0; installedIdx < installedColumnCount; installedIdx++) { - const colIdx = columnsInstalled[installedIdx]; // Physical column position + const colIdx = columnsInstalled[installedIdx]; // Physical column position // Calculate angular position for this column based on column_order // CW: c0 spans from South boundary leftward (counter-clockwise), columns continue CCW @@ -280,8 +279,9 @@ function renderCylindricalIconToCanvas(frameData, patternData, arenaConfig, opts // Row 0 (top of arena) → inner edge of ring (center) // Row N (bottom of arena) → outer edge of ring const verticalFraction = verticalPixelIdx / totalVerticalPixels; - const pixelInnerRadius = innerRadius + (verticalFraction * radiusRange); - const pixelOuterRadius = innerRadius + ((verticalPixelIdx + 1) / totalVerticalPixels * radiusRange); + const pixelInnerRadius = innerRadius + verticalFraction * radiusRange; + const pixelOuterRadius = + innerRadius + ((verticalPixelIdx + 1) / totalVerticalPixels) * radiusRange; for (let px = 0; px < pixelsPerPanel; px++) { // Calculate position in pattern data (sequential columns) @@ -297,10 +297,12 @@ function renderCylindricalIconToCanvas(frameData, patternData, arenaConfig, opts const color = brightnessToRGB(brightness, patternData.grayscaleMode); // Calculate angular position for this pixel - const pixelAngle = colStartAngle + (px / pixelsPerPanel) * (colEndAngle - colStartAngle); + const pixelAngle = + colStartAngle + (px / pixelsPerPanel) * (colEndAngle - colStartAngle); // Calculate next pixel angle for width - const nextPixelAngle = colStartAngle + ((px + 1) / pixelsPerPanel) * (colEndAngle - colStartAngle); + const nextPixelAngle = + colStartAngle + ((px + 1) / pixelsPerPanel) * (colEndAngle - colStartAngle); // Ensure we always draw the shorter arc by using min/max const minAngle = Math.min(pixelAngle, nextPixelAngle); @@ -329,7 +331,7 @@ function renderCylindricalIconToCanvas(frameData, patternData, arenaConfig, opts // Draw radial lines for gaps in partial arenas if (opts.showGaps && columnsInstalled.length < numCols) { const installedSet = new Set(columnsInstalled); - ctx.strokeStyle = '#2d3640'; // border color + ctx.strokeStyle = '#2d3640'; // border color ctx.lineWidth = 1; // Find gap boundaries (transitions between installed and missing columns) @@ -349,10 +351,14 @@ function renderCylindricalIconToCanvas(frameData, patternData, arenaConfig, opts // Draw radial line at boundary ctx.beginPath(); - ctx.moveTo(centerX + innerRadius * Math.cos(angle), - centerY + innerRadius * Math.sin(angle)); - ctx.lineTo(centerX + outerRadius * Math.cos(angle), - centerY + outerRadius * Math.sin(angle)); + ctx.moveTo( + centerX + innerRadius * Math.cos(angle), + centerY + innerRadius * Math.sin(angle) + ); + ctx.lineTo( + centerX + outerRadius * Math.cos(angle), + centerY + outerRadius * Math.sin(angle) + ); ctx.stroke(); } } @@ -408,14 +414,18 @@ function generatePatternGIF(patternData, arenaConfig, options = {}, onProgress = showGaps: true, showOutlines: true, padding: 2, - quality: 10, // GIF quality (1-30, lower = better) - workers: 2, // Web workers for encoding + quality: 10, // GIF quality (1-30, lower = better) + workers: 2, // Web workers for encoding ...options }; // Check if gif.js is available if (typeof GIF === 'undefined') { - return Promise.reject(new Error('gif.js library not loaded. Add: ')); + return Promise.reject( + new Error( + 'gif.js library not loaded. Add: ' + ) + ); } return new Promise((resolve, reject) => { @@ -435,7 +445,12 @@ function generatePatternGIF(patternData, arenaConfig, options = {}, onProgress = // Add each frame for (let i = 0; i < patternData.frames.length; i++) { const frameData = patternData.frames[i]; - const canvas = renderCylindricalIconToCanvas(frameData, patternData, arenaConfig, opts); + const canvas = renderCylindricalIconToCanvas( + frameData, + patternData, + arenaConfig, + opts + ); gif.addFrame(canvas, { delay: frameDelay, copy: true }); } @@ -451,7 +466,6 @@ function generatePatternGIF(patternData, arenaConfig, options = {}, onProgress = // Start encoding gif.render(); - } catch (err) { reject(err); } @@ -505,7 +519,7 @@ function generateTestIcon(width, height, generation, numCols, numRows, pattern = frameData[idx] = Math.floor(col / 20) % 2; } else if (pattern === 'sine') { // Sine wave - frameData[idx] = (Math.sin(col * Math.PI / 30) + 1) / 2; + frameData[idx] = (Math.sin((col * Math.PI) / 30) + 1) / 2; } else { // All on frameData[idx] = 1; @@ -558,4 +572,11 @@ if (typeof module !== 'undefined' && module.exports) { } // ES6 module export -export { generatePatternIcon, generateMotionIcon, generatePatternGIF, generateTestIcon, renderCylindricalIconToCanvas, normalizePatternData }; +export { + generatePatternIcon, + generateMotionIcon, + generatePatternGIF, + generateTestIcon, + renderCylindricalIconToCanvas, + normalizePatternData +}; diff --git a/js/pat-encoder.js b/js/pat-encoder.js index df9512b..b8f73bd 100644 --- a/js/pat-encoder.js +++ b/js/pat-encoder.js @@ -15,15 +15,15 @@ * - Column 0 = leftmost (south in CW mode) */ -const PatEncoder = (function() { +const PatEncoder = (function () { 'use strict'; // Constants - must match pat-parser.js const G6_MAGIC = 'G6PT'; - const G6_HEADER_SIZE = 18; // V2: 18 bytes (always write V2) - const G6_FRAME_HEADER_SIZE = 4; // "FR" + 2 reserved bytes + const G6_HEADER_SIZE = 18; // V2: 18 bytes (always write V2) + const G6_FRAME_HEADER_SIZE = 4; // "FR" + 2 reserved bytes const G6_PANEL_SIZE = 20; - const G6_GS2_PANEL_BYTES = 53; // header(1) + cmd(1) + data(50) + stretch(1) + const G6_GS2_PANEL_BYTES = 53; // header(1) + cmd(1) + data(50) + stretch(1) const G6_GS16_PANEL_BYTES = 203; // header(1) + cmd(1) + data(200) + stretch(1) const G4_HEADER_SIZE = 7; @@ -31,7 +31,10 @@ const PatEncoder = (function() { // Generation ID mapping (same as pat-parser.js) const GENERATION_IDS = { - 'G3': 1, 'G4': 2, 'G4.1': 3, 'G6': 4 + G3: 1, + G4: 2, + 'G4.1': 3, + G6: 4 }; /** @@ -82,10 +85,14 @@ const PatEncoder = (function() { // Validate dimensions if (pixelRows !== rowCount * G6_PANEL_SIZE) { - throw new Error(`pixelRows (${pixelRows}) must equal rowCount (${rowCount}) * ${G6_PANEL_SIZE}`); + throw new Error( + `pixelRows (${pixelRows}) must equal rowCount (${rowCount}) * ${G6_PANEL_SIZE}` + ); } if (pixelCols !== colCount * G6_PANEL_SIZE) { - throw new Error(`pixelCols (${pixelCols}) must equal colCount (${colCount}) * ${G6_PANEL_SIZE}`); + throw new Error( + `pixelCols (${pixelCols}) must equal colCount (${colCount}) * ${G6_PANEL_SIZE}` + ); } const isGrayscale = gs_val === 16; @@ -93,8 +100,8 @@ const PatEncoder = (function() { const numPanels = rowCount * colCount; // Calculate total file size - const frameDataSize = G6_FRAME_HEADER_SIZE + (numPanels * panelBytes); - const totalSize = G6_HEADER_SIZE + (numFrames * frameDataSize); + const frameDataSize = G6_FRAME_HEADER_SIZE + numPanels * panelBytes; + const totalSize = G6_HEADER_SIZE + numFrames * frameDataSize; // Create buffer const buffer = new ArrayBuffer(totalSize); @@ -103,21 +110,21 @@ const PatEncoder = (function() { // Write V2 header (18 bytes) // Magic bytes "G6PT" - bytes[0] = 0x47; // 'G' - bytes[1] = 0x36; // '6' - bytes[2] = 0x50; // 'P' - bytes[3] = 0x54; // 'T' + bytes[0] = 0x47; // 'G' + bytes[1] = 0x36; // '6' + bytes[2] = 0x50; // 'P' + bytes[3] = 0x54; // 'T' // Byte 4: [VVVV][AAAA] - Version (4 bits = 2) + Arena ID upper 4 bits const version = 2; const clampedArenaId = Math.min(63, Math.max(0, arena_id)); const clampedObserverId = Math.min(63, Math.max(0, observer_id)); - const arenaUpper = (clampedArenaId >> 2) & 0x0F; // Upper 4 bits of 6-bit arena_id + const arenaUpper = (clampedArenaId >> 2) & 0x0f; // Upper 4 bits of 6-bit arena_id bytes[4] = (version << 4) | arenaUpper; // Byte 5: [AA][OOOOOO] - Arena ID lower 2 bits + Observer ID (6 bits) - const arenaLower = clampedArenaId & 0x03; // Lower 2 bits of arena_id - bytes[5] = (arenaLower << 6) | (clampedObserverId & 0x3F); + const arenaLower = clampedArenaId & 0x03; // Lower 2 bits of arena_id + bytes[5] = (arenaLower << 6) | (clampedObserverId & 0x3f); // Frame count (little-endian) view.setUint16(6, numFrames, true); @@ -134,7 +141,7 @@ const PatEncoder = (function() { for (let i = 0; i < numPanelsTotal && i < 48; i++) { const byteIdx = Math.floor(i / 8); const bitIdx = i % 8; - bytes[11 + byteIdx] |= (1 << bitIdx); + bytes[11 + byteIdx] |= 1 << bitIdx; } // Byte 17: checksum placeholder (will be computed after frame data) @@ -148,11 +155,11 @@ const PatEncoder = (function() { const stretch = stretchValues[f] !== undefined ? stretchValues[f] : 1; // Frame header "FR" + frame_idx (uint16 LE) - bytes[offset] = 0x46; // 'F' - bytes[offset + 1] = 0x52; // 'R' + bytes[offset] = 0x46; // 'F' + bytes[offset + 1] = 0x52; // 'R' // Frame index as little-endian uint16 - bytes[offset + 2] = f & 0xFF; // Low byte - bytes[offset + 3] = (f >> 8) & 0xFF; // High byte + bytes[offset + 2] = f & 0xff; // Low byte + bytes[offset + 3] = (f >> 8) & 0xff; // High byte offset += G6_FRAME_HEADER_SIZE; // Encode panels in row-major order @@ -160,8 +167,12 @@ const PatEncoder = (function() { for (let panelCol = 0; panelCol < colCount; panelCol++) { // Extract panel pixels from frame const panelPixels = extractPanelPixels( - frame, pixelCols, pixelRows, - panelRow, panelCol, G6_PANEL_SIZE + frame, + pixelCols, + pixelRows, + panelRow, + panelCol, + G6_PANEL_SIZE ); // Encode panel block @@ -238,15 +249,15 @@ const PatEncoder = (function() { for (let panelRow = 0; panelRow < G6_PANEL_SIZE; panelRow++) { for (let panelCol = 0; panelCol < G6_PANEL_SIZE; panelCol++) { // Flip rows to match encoder convention - const inputRow = (G6_PANEL_SIZE - 1) - panelRow; + const inputRow = G6_PANEL_SIZE - 1 - panelRow; const pixelVal = panelPixels[inputRow * G6_PANEL_SIZE + panelCol] > 0 ? 1 : 0; const pixelNum = panelRow * G6_PANEL_SIZE + panelCol; const byteIdx = Math.floor(pixelNum / 8); - const bitPos = 7 - (pixelNum % 8); // MSB first + const bitPos = 7 - (pixelNum % 8); // MSB first if (pixelVal) { - dataBytes[byteIdx] |= (1 << bitPos); + dataBytes[byteIdx] |= 1 << bitPos; } } } @@ -284,15 +295,18 @@ const PatEncoder = (function() { for (let panelRow = 0; panelRow < G6_PANEL_SIZE; panelRow++) { for (let panelCol = 0; panelCol < G6_PANEL_SIZE; panelCol++) { // Flip rows to match encoder convention - const inputRow = (G6_PANEL_SIZE - 1) - panelRow; - const pixelVal = Math.min(15, Math.max(0, panelPixels[inputRow * G6_PANEL_SIZE + panelCol])); + const inputRow = G6_PANEL_SIZE - 1 - panelRow; + const pixelVal = Math.min( + 15, + Math.max(0, panelPixels[inputRow * G6_PANEL_SIZE + panelCol]) + ); const pixelNum = panelRow * G6_PANEL_SIZE + panelCol; const byteIdx = Math.floor(pixelNum / 2); if (pixelNum % 2 === 0) { // Even pixel -> high nibble - dataBytes[byteIdx] |= (pixelVal << 4); + dataBytes[byteIdx] |= pixelVal << 4; } else { // Odd pixel -> low nibble dataBytes[byteIdx] |= pixelVal; @@ -339,10 +353,14 @@ const PatEncoder = (function() { // Validate dimensions if (pixelRows !== rowCount * G4_PANEL_SIZE) { - throw new Error(`pixelRows (${pixelRows}) must equal rowCount (${rowCount}) * ${G4_PANEL_SIZE}`); + throw new Error( + `pixelRows (${pixelRows}) must equal rowCount (${rowCount}) * ${G4_PANEL_SIZE}` + ); } if (pixelCols !== colCount * G4_PANEL_SIZE) { - throw new Error(`pixelCols (${pixelCols}) must equal colCount (${colCount}) * ${G4_PANEL_SIZE}`); + throw new Error( + `pixelCols (${pixelCols}) must equal colCount (${colCount}) * ${G4_PANEL_SIZE}` + ); } const isGrayscale = gs_val === 16; @@ -351,7 +369,7 @@ const PatEncoder = (function() { const frameBytes = (colCount * subpanelMsgLength + 1) * rowCount * numSubpanel; // Calculate total file size - const totalSize = G4_HEADER_SIZE + (numFrames * frameBytes); + const totalSize = G4_HEADER_SIZE + numFrames * frameBytes; // Create buffer const buffer = new ArrayBuffer(totalSize); @@ -359,20 +377,19 @@ const PatEncoder = (function() { const view = new DataView(buffer); // Write V2 header - view.setUint16(0, numPatsX, true); // little-endian + view.setUint16(0, numPatsX, true); // little-endian // Byte 2: [V][GGG][RRRR] - V2 flag + generation ID // Resolve generation_id: use explicit value, or look up from generation name - const genId = generation_id !== undefined ? generation_id - : (GENERATION_IDS[generation] || 0); - const v2Flag = 0x80; // Set MSB (bit 7) - const genBits = (genId & 0x07) << 4; // Bits 6-4 + const genId = generation_id !== undefined ? generation_id : GENERATION_IDS[generation] || 0; + const v2Flag = 0x80; // Set MSB (bit 7) + const genBits = (genId & 0x07) << 4; // Bits 6-4 bytes[2] = v2Flag | genBits; // Byte 3: Arena config ID bytes[3] = Math.min(255, Math.max(0, arena_id)); - bytes[4] = isGrayscale ? 16 : 2; // gs_val (use normalized values: 2 or 16) + bytes[4] = isGrayscale ? 16 : 2; // gs_val (use normalized values: 2 or 16) bytes[5] = rowCount; bytes[6] = colCount; @@ -383,7 +400,14 @@ const PatEncoder = (function() { const frame = frames[f]; const stretch = stretchValues[f] !== undefined ? stretchValues[f] : 1; - const frameData = encodeG4Frame(frame, rowCount, colCount, pixelCols, isGrayscale, stretch); + const frameData = encodeG4Frame( + frame, + rowCount, + colCount, + pixelCols, + isGrayscale, + stretch + ); bytes.set(frameData, offset); offset += frameBytes; } @@ -411,31 +435,55 @@ const PatEncoder = (function() { for (let m = 0; m < panelCols; m++) { if (k === 1) { // Command byte with stretch - frameData[n++] = (stretch << 1); + frameData[n++] = stretch << 1; } else { if (isGrayscale) { // GS16: 2 pixels per byte - const panelStartRowBeforeInvert = i * 16 + ((j - 1) % 2) * 8 + Math.floor((k - 2) / 4); - const panelStartRow = Math.floor(panelStartRowBeforeInvert / 16) * 16 + 15 - (panelStartRowBeforeInvert % 16); - const panelStartCol = m * 16 + Math.floor(j / 3) * 8 + ((k - 2) % 4) * 2; - - const px1 = getPixelSafe(frame, panelStartRow, panelStartCol, frameCols); - const px2 = getPixelSafe(frame, panelStartRow, panelStartCol + 1, frameCols); + const panelStartRowBeforeInvert = + i * 16 + ((j - 1) % 2) * 8 + Math.floor((k - 2) / 4); + const panelStartRow = + Math.floor(panelStartRowBeforeInvert / 16) * 16 + + 15 - + (panelStartRowBeforeInvert % 16); + const panelStartCol = + m * 16 + Math.floor(j / 3) * 8 + ((k - 2) % 4) * 2; + + const px1 = getPixelSafe( + frame, + panelStartRow, + panelStartCol, + frameCols + ); + const px2 = getPixelSafe( + frame, + panelStartRow, + panelStartCol + 1, + frameCols + ); // Low nibble = left pixel, high nibble = right pixel - frameData[n++] = (px1 & 0x0F) | ((px2 & 0x0F) << 4); + frameData[n++] = (px1 & 0x0f) | ((px2 & 0x0f) << 4); } else { // Binary: 8 pixels per byte const rowOffset = k - 2; - const panelStartRowBeforeInvert = i * 16 + ((j - 1) % 2) * 8 + rowOffset; - const panelStartRow = Math.floor(panelStartRowBeforeInvert / 16) * 16 + 15 - (panelStartRowBeforeInvert % 16); + const panelStartRowBeforeInvert = + i * 16 + ((j - 1) % 2) * 8 + rowOffset; + const panelStartRow = + Math.floor(panelStartRowBeforeInvert / 16) * 16 + + 15 - + (panelStartRowBeforeInvert % 16); const panelStartCol = m * 16 + Math.floor(j / 3) * 8; let byteVal = 0; for (let p = 0; p < 8; p++) { - const pixelVal = getPixelSafe(frame, panelStartRow, panelStartCol + p, frameCols); + const pixelVal = getPixelSafe( + frame, + panelStartRow, + panelStartCol + p, + frameCols + ); if (pixelVal > 0) { - byteVal |= (1 << p); + byteVal |= 1 << p; } } frameData[n++] = byteVal; @@ -493,11 +541,13 @@ const PatEncoder = (function() { if (bytesA.length !== bytesB.length) { return { equal: false, - differences: [{ - type: 'length', - a: bytesA.length, - b: bytesB.length - }] + differences: [ + { + type: 'length', + a: bytesA.length, + b: bytesB.length + } + ] }; } diff --git a/js/pat-parser.js b/js/pat-parser.js index ae1ad8d..317da84 100644 --- a/js/pat-parser.js +++ b/js/pat-parser.js @@ -1,677 +1,703 @@ -/** - * .pat File Parser Module - * - * Parses G4/G4.1 and G6 binary pattern files (.pat) used by Reiser Lab LED displays. - * Works in both Node.js and browser environments. - * - * Supported formats: - * - G6 V1: 20x20 pixel panels, 17-byte header with "G6PT" magic - * - G6 V2: 20x20 pixel panels, 18-byte header with arena_id + observer_id - * - G4/G4.1 V1: 16x16 pixel panels, 7-byte header (legacy) - * - G4/G4.1 V2: 16x16 pixel panels, 7-byte header with generation_id + arena_id - * - * Coordinate Convention: - * - Origin (0,0) at bottom-left of arena - * - Row 0 = bottom, increases upward - * - Column 0 = leftmost (south in CW mode) - */ - -const PatParser = (function() { - 'use strict'; - - // Constants - const G6_MAGIC = 'G6PT'; - const G6_V1_HEADER_SIZE = 17; - const G6_V2_HEADER_SIZE = 18; - const G6_FRAME_HEADER_SIZE = 4; - const G6_PANEL_SIZE = 20; - const G6_GS2_PANEL_BYTES = 53; - const G6_GS16_PANEL_BYTES = 203; - - const G4_HEADER_SIZE = 7; - const G4_PANEL_SIZE = 16; - - // Generation ID mapping (mirrors maDisplayTools/configs/arena_registry/generations.yaml) - const GENERATION_NAMES = { - 0: 'unspecified', 1: 'G3', 2: 'G4', 3: 'G4.1', 4: 'G6' - }; - - /** - * Detect pattern generation from file header - * @param {ArrayBuffer} buffer - Raw file data - * @returns {'G6'|'G4'} Generation type - */ - function detectGeneration(buffer) { - const view = new DataView(buffer); - - // Check for G6 magic bytes "G6PT" - if (buffer.byteLength >= 4) { - const magic = String.fromCharCode( - view.getUint8(0), - view.getUint8(1), - view.getUint8(2), - view.getUint8(3) - ); - if (magic === G6_MAGIC) { - return 'G6'; - } - } - - // Assume G4 format (no magic bytes) - return 'G4'; - } - - /** - * Parse a .pat file (auto-detects G4 vs G6) - * @param {ArrayBuffer} buffer - Raw file data - * @returns {PatternData} Parsed pattern data - */ - function parsePatFile(buffer) { - const generation = detectGeneration(buffer); - - if (generation === 'G6') { - return parseG6Pattern(buffer); - } else { - return parseG4Pattern(buffer); - } - } - - /** - * Parse G6 format pattern file - * - * V1 Header (17 bytes): - * Bytes 0-3: "G6PT" magic - * Byte 4: Version (1) - * Byte 5: gs_val (1=GS2 binary, 2=GS16 grayscale) - * Bytes 6-7: num_frames (uint16 LE) - * Byte 8: row_count (panel rows) - * Byte 9: col_count (installed columns) - * Byte 10: checksum - * Bytes 11-16: panel_mask (6 bytes, 48-bit bitmask) - * - * V2 Header (18 bytes): - * Bytes 0-3: "G6PT" magic - * Byte 4: [VVVV][AAAA] - Version (4 bits = 2) + Arena ID upper 4 bits - * Byte 5: [AA][OOOOOO] - Arena ID lower 2 bits + Observer ID (6 bits) - * Bytes 6-7: num_frames (uint16 LE) - * Byte 8: row_count (panel rows) - * Byte 9: col_count (installed columns) - * Byte 10: gs_val (1=GS2, 2=GS16) - * Bytes 11-16: panel_mask (6 bytes, 48-bit bitmask) - * Byte 17: checksum (XOR of frame data) - * - * @param {ArrayBuffer} buffer - Raw file data - * @returns {PatternData} Parsed pattern data - */ - function parseG6Pattern(buffer) { - const view = new DataView(buffer); - const bytes = new Uint8Array(buffer); - - // Verify magic - const magic = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]); - if (magic !== G6_MAGIC) { - throw new Error(`Invalid G6 file: expected G6PT magic, got ${magic}`); - } - - // Detect header version from byte 4 - // V1: byte 4 < 16 (version stored as full byte, value = 1) - // V2: byte 4 upper nibble >= 2 (version in upper 4 bits) - const versionByte = bytes[4]; - let headerVersion, arena_id, observer_id, gs_val_raw, checksum, panelMask, headerSize; - - if (versionByte < 16) { - // V1 format - headerVersion = versionByte; - arena_id = 0; - observer_id = 0; - gs_val_raw = bytes[5]; - checksum = bytes[10]; - panelMask = bytes.slice(11, 17); - headerSize = G6_V1_HEADER_SIZE; - } else { - // V2 format - headerVersion = (versionByte >> 4) & 0x0F; - const arenaUpper = versionByte & 0x0F; // Lower 4 bits of byte 4 - const byte5 = bytes[5]; - const arenaLower = (byte5 >> 6) & 0x03; // Upper 2 bits of byte 5 - arena_id = (arenaUpper << 2) | arenaLower; // Combined 6-bit arena ID - observer_id = byte5 & 0x3F; // Lower 6 bits of byte 5 - gs_val_raw = bytes[10]; - checksum = bytes[17]; - panelMask = bytes.slice(11, 17); - headerSize = G6_V2_HEADER_SIZE; - } - - const numFrames = view.getUint16(6, true); // little-endian - const rowCount = bytes[8]; - const colCount = bytes[9]; // Installed columns - - // Convert G6 gs_val to standard (2=binary, 16=grayscale) - const gs_val = gs_val_raw === 1 ? 2 : 16; - const isGrayscale = gs_val === 16; - const panelBytes = isGrayscale ? G6_GS16_PANEL_BYTES : G6_GS2_PANEL_BYTES; - - // Count panels from mask - let numPanels = 0; - for (let i = 0; i < 6; i++) { - for (let bit = 0; bit < 8; bit++) { - if (panelMask[i] & (1 << bit)) numPanels++; - } - } - - // Pattern dimensions - const pixelRows = rowCount * G6_PANEL_SIZE; - const pixelCols = colCount * G6_PANEL_SIZE; - const maxValue = isGrayscale ? 15 : 1; - - // Parse frames - const frames = []; - const stretchValues = []; - let offset = headerSize; - - for (let f = 0; f < numFrames; f++) { - // Verify frame header "FR" - if (bytes[offset] !== 0x46 || bytes[offset + 1] !== 0x52) { // 'F', 'R' - console.warn(`Frame ${f}: expected FR header at offset ${offset}`); - } - offset += G6_FRAME_HEADER_SIZE; - - // Decode panels for this frame - const frame = new Uint8Array(pixelRows * pixelCols); - let frameStretch = 0; - - for (let panelRow = 0; panelRow < rowCount; panelRow++) { - for (let panelCol = 0; panelCol < colCount; panelCol++) { - const panelBlock = bytes.slice(offset, offset + panelBytes); - offset += panelBytes; - - // Get stretch from last byte of panel block - frameStretch = panelBlock[panelBytes - 1]; - - // Decode panel pixels - const panelPixels = isGrayscale - ? decodeG6PanelGS16(panelBlock) - : decodeG6PanelGS2(panelBlock); - - // Copy to frame at correct position - // Panel row 0 = bottom of arena, displayed at bottom - for (let py = 0; py < G6_PANEL_SIZE; py++) { - for (let px = 0; px < G6_PANEL_SIZE; px++) { - const globalRow = panelRow * G6_PANEL_SIZE + py; - const globalCol = panelCol * G6_PANEL_SIZE + px; - const frameIdx = globalRow * pixelCols + globalCol; - frame[frameIdx] = panelPixels[py * G6_PANEL_SIZE + px]; - } - } - } - } - - frames.push(frame); - stretchValues.push(frameStretch); - } - - // Console diagnostics - console.group('Pattern loaded (G6)'); - console.log(`Generation: G6 (${G6_PANEL_SIZE}×${G6_PANEL_SIZE} panels)`); - console.log(`Header: V${headerVersion}${headerVersion >= 2 ? ` arena_id=${arena_id} observer_id=${observer_id}` : ''}`); - console.log(`Dimensions: ${rowCount} rows × ${colCount} cols = ${pixelRows}×${pixelCols} pixels`); - console.log(`Frames: ${numFrames}`); - console.log(`Grayscale: ${isGrayscale ? 'GS16 (4-bit, 0-15)' : 'GS2 (1-bit, 0-1)'}`); - console.log(`Pixel (0,0) value: ${frames[0][0]} (frame 0)`); - console.log(`Pixel (${pixelRows-1},${pixelCols-1}) value: ${frames[0][(pixelRows-1) * pixelCols + (pixelCols-1)]} (frame 0)`); - console.groupEnd(); - - return { - generation: 'G6', - gs_val, - numFrames, - rowCount, - colCount, - pixelRows, - pixelCols, - maxValue, - frames, - stretchValues, - panelSize: G6_PANEL_SIZE, - headerVersion, - arena_id, - observer_id, - checksum - }; - } - - /** - * Decode G6 GS2 (binary) panel block to pixels - * - * Panel block: 53 bytes [header, cmd, 50 data bytes, stretch] - * Data encoding: row-major, 1 bit per pixel, MSB first - * - * Row flip: Encoder flips rows (row_from_bottom = 19 - row), decoder flips back - * - * @param {Uint8Array} panelBlock - 53-byte panel block - * @returns {Uint8Array} 400 pixels (20x20), row-major, row 0 = bottom - */ - function decodeG6PanelGS2(panelBlock) { - const pixels = new Uint8Array(G6_PANEL_SIZE * G6_PANEL_SIZE); - const dataBytes = panelBlock.slice(2, 52); // Skip header (1) and cmd (1), take 50 bytes - - for (let panelRow = 0; panelRow < G6_PANEL_SIZE; panelRow++) { - for (let panelCol = 0; panelCol < G6_PANEL_SIZE; panelCol++) { - const pixelNum = panelRow * G6_PANEL_SIZE + panelCol; - const byteIdx = Math.floor(pixelNum / 8); - const bitPos = 7 - (pixelNum % 8); // MSB first - const pixelVal = (dataBytes[byteIdx] >> bitPos) & 1; - - // Compensate for encoder's row flip - // Panel row 0 (encoded) was originally row 19, map to output row 19 - const outputRow = (G6_PANEL_SIZE - 1) - panelRow; - const outputIdx = outputRow * G6_PANEL_SIZE + panelCol; - pixels[outputIdx] = pixelVal; - } - } - - return pixels; - } - - /** - * Decode G6 GS16 (grayscale) panel block to pixels - * - * Panel block: 203 bytes [header, cmd, 200 data bytes, stretch] - * Data encoding: row-major, 4 bits per pixel (2 pixels per byte) - * - Even pixel: high nibble - * - Odd pixel: low nibble - * - * @param {Uint8Array} panelBlock - 203-byte panel block - * @returns {Uint8Array} 400 pixels (20x20), row-major, row 0 = bottom - */ - function decodeG6PanelGS16(panelBlock) { - const pixels = new Uint8Array(G6_PANEL_SIZE * G6_PANEL_SIZE); - const dataBytes = panelBlock.slice(2, 202); // Skip header (1) and cmd (1), take 200 bytes - - for (let panelRow = 0; panelRow < G6_PANEL_SIZE; panelRow++) { - for (let panelCol = 0; panelCol < G6_PANEL_SIZE; panelCol++) { - const pixelNum = panelRow * G6_PANEL_SIZE + panelCol; - const byteIdx = Math.floor(pixelNum / 2); - let pixelVal; - - if (pixelNum % 2 === 0) { - // Even pixel: high nibble - pixelVal = (dataBytes[byteIdx] >> 4) & 0x0F; - } else { - // Odd pixel: low nibble - pixelVal = dataBytes[byteIdx] & 0x0F; - } - - // Compensate for encoder's row flip - const outputRow = (G6_PANEL_SIZE - 1) - panelRow; - const outputIdx = outputRow * G6_PANEL_SIZE + panelCol; - pixels[outputIdx] = pixelVal; - } - } - - return pixels; - } - - /** - * Parse G4/G4.1 format pattern file - * - * V1 Header (7 bytes): - * Bytes 0-1: NumPatsX (uint16 LE) - * Bytes 2-3: NumPatsY (uint16 LE) - * Byte 4: gs_val (1 or 2 = binary, 4 or 16 = grayscale) - * Byte 5: RowN (panel rows) - * Byte 6: ColN (panel cols) - * - * V2 Header (7 bytes): - * Bytes 0-1: NumPatsX (uint16 LE) - * Byte 2: [V][GGG][RRRR] - Version flag (bit 7) + Generation ID (bits 6-4) + Reserved - * Byte 3: Arena ID (8 bits, 0-255) - * Byte 4: gs_val (2 or 16) - * Byte 5: RowN (panel rows) - * Byte 6: ColN (panel cols) - * - * @param {ArrayBuffer} buffer - Raw file data - * @returns {PatternData} Parsed pattern data - */ - function parseG4Pattern(buffer) { - const view = new DataView(buffer); - const bytes = new Uint8Array(buffer); - - // Parse header - const numPatsX = view.getUint16(0, true); // little-endian - - // Detect V1 vs V2: V2 has MSB set in byte 2 (>= 0x80) - const configHigh = bytes[2]; - const isV2 = configHigh >= 0x80; - - let headerVersion, numPatsY, generation_id, generationName, arena_id; - - if (isV2) { - headerVersion = 2; - // Byte 2: [V][GGG][RRRR] — extract generation from bits 6-4 - generation_id = (configHigh >> 4) & 0x07; - generationName = GENERATION_NAMES[generation_id] || 'unknown'; - // Byte 3: Arena config ID - arena_id = bytes[3]; - // NumPatsY not stored in V2, assume 1 - numPatsY = 1; - } else { - headerVersion = 1; - numPatsY = view.getUint16(2, true); - generation_id = 0; - generationName = 'unspecified'; - arena_id = 0; - } - - const gs_val_raw = bytes[4]; - const rowN = bytes[5]; // Panel rows - const colN = bytes[6]; // Panel cols - - // Normalize gs_val (legacy 1→2, legacy 4→16) - let gs_val; - if (gs_val_raw === 1 || gs_val_raw === 2) { - gs_val = 2; // Binary - } else { - gs_val = 16; // Grayscale - } - - const isGrayscale = gs_val === 16; - const numFrames = numPatsX * numPatsY; - const pixelRows = rowN * G4_PANEL_SIZE; - const pixelCols = colN * G4_PANEL_SIZE; - const maxValue = isGrayscale ? 15 : 1; - - // Calculate frame size - const numSubpanel = 4; - const subpanelMsgLength = isGrayscale ? 33 : 9; - const frameBytes = (colN * subpanelMsgLength + 1) * rowN * numSubpanel; - - // Parse frames - const frames = []; - const stretchValues = []; - let offset = G4_HEADER_SIZE; - - for (let frameY = 0; frameY < numPatsY; frameY++) { - for (let frameX = 0; frameX < numPatsX; frameX++) { - const frameData = bytes.slice(offset, offset + frameBytes); - offset += frameBytes; - - const { pixels, stretch } = decodeG4Frame(frameData, rowN, colN, isGrayscale); - frames.push(pixels); - stretchValues.push(stretch); - } - } - - // Determine generation label for display - const genLabel = isV2 ? generationName : 'G4'; - - // Console diagnostics - console.group(`Pattern loaded (${genLabel})`); - console.log(`Generation: ${genLabel} (${G4_PANEL_SIZE}×${G4_PANEL_SIZE} panels)`); - console.log(`Header: V${headerVersion}${isV2 ? ` gen=${generationName} arena_id=${arena_id}` : ''}`); - console.log(`Dimensions: ${rowN} rows × ${colN} cols = ${pixelRows}×${pixelCols} pixels`); - console.log(`Frames: ${numFrames} (${numPatsX}×${numPatsY})`); - console.log(`Grayscale: ${isGrayscale ? 'GS16 (4-bit, 0-15)' : 'GS2 (1-bit, 0-1)'}`); - console.log(`Pixel (0,0) value: ${frames[0][0]} (frame 0)`); - console.log(`Pixel (${pixelRows-1},${pixelCols-1}) value: ${frames[0][(pixelRows-1) * pixelCols + (pixelCols-1)]} (frame 0)`); - console.groupEnd(); - - return { - generation: genLabel === 'unspecified' ? 'G4' : genLabel, - gs_val, - numFrames, - numPatsX, - numPatsY, - rowCount: rowN, - colCount: colN, - pixelRows, - pixelCols, - maxValue, - frames, - stretchValues, - panelSize: G4_PANEL_SIZE, - headerVersion, - generation_id, - arena_id - }; - } - - /** - * Decode a G4 frame from binary data - * - * G4 uses subpanel addressing with 4 quadrants per panel column. - * The encoding includes row inversions that must be compensated. - * - * @param {Uint8Array} frameData - Raw frame bytes - * @param {number} panelRow - Number of panel rows - * @param {number} panelCol - Number of panel columns - * @param {boolean} isGrayscale - True for GS16, false for binary - * @returns {{pixels: Uint8Array, stretch: number}} - */ - function decodeG4Frame(frameData, panelRow, panelCol, isGrayscale) { - const pixelRows = panelRow * G4_PANEL_SIZE; - const pixelCols = panelCol * G4_PANEL_SIZE; - const pixels = new Uint8Array(pixelRows * pixelCols); - - const numSubpanel = 4; - const subpanelMsgLength = isGrayscale ? 33 : 9; - let stretch = 0; - - let n = 0; - - for (let i = 0; i < panelRow; i++) { - for (let j = 1; j <= numSubpanel; j++) { - // Skip row header - n++; - - for (let k = 1; k <= subpanelMsgLength; k++) { - for (let m = 0; m < panelCol; m++) { - if (k === 1) { - // Command byte contains stretch - stretch = frameData[n] >> 1; - n++; - } else { - if (isGrayscale) { - // GS16: each byte has 2 pixels (4 bits each) - const panelStartRowBeforeInvert = i * 16 + ((j - 1) % 2) * 8 + Math.floor((k - 2) / 4); - const panelStartRow = Math.floor(panelStartRowBeforeInvert / 16) * 16 + 15 - (panelStartRowBeforeInvert % 16); - const panelStartCol = m * 16 + Math.floor(j / 3) * 8 + ((k - 2) % 4) * 2; - - const byte = frameData[n]; - const px1 = byte & 0x0F; // Low nibble = left pixel - const px2 = (byte >> 4) & 0x0F; // High nibble = right pixel - - if (panelStartRow < pixelRows && panelStartCol < pixelCols) { - pixels[panelStartRow * pixelCols + panelStartCol] = px1; - } - if (panelStartRow < pixelRows && panelStartCol + 1 < pixelCols) { - pixels[panelStartRow * pixelCols + panelStartCol + 1] = px2; - } - } else { - // Binary: each byte has 8 pixels (1 bit each) - const rowOffset = k - 2; // 0-7 - const panelStartRowBeforeInvert = i * 16 + ((j - 1) % 2) * 8 + rowOffset; - const panelStartRow = Math.floor(panelStartRowBeforeInvert / 16) * 16 + 15 - (panelStartRowBeforeInvert % 16); - const panelStartCol = m * 16 + Math.floor(j / 3) * 8; - - const byte = frameData[n]; - for (let p = 0; p < 8; p++) { - const pixelVal = (byte >> p) & 1; - const col = panelStartCol + p; - if (panelStartRow < pixelRows && col < pixelCols) { - pixels[panelStartRow * pixelCols + col] = pixelVal; - } - } - } - n++; - } - } - } - } - } - - return { pixels, stretch }; - } - - /** - * Verify pattern orientation by checking known pixel positions - * @param {PatternData} patternData - Parsed pattern - * @returns {Object[]} Array of check results - */ - function verifyPatternOrientation(patternData) { - const checks = []; - const { frames, pixelRows, pixelCols, generation, panelSize } = patternData; - - // Check 1: Bottom-left pixel accessible - const bottomLeftValue = frames[0][0]; - checks.push({ - name: 'Bottom-left pixel (0,0) accessible', - pass: bottomLeftValue !== undefined, - value: bottomLeftValue - }); - - // Check 2: Dimensions match expected - const expectedPixels = pixelRows * pixelCols; - checks.push({ - name: 'Frame size matches dimensions', - pass: frames[0].length === expectedPixels, - expected: expectedPixels, - actual: frames[0].length - }); - - // Check 3: Panel size correct for generation - const expectedPanelSize = generation === 'G6' ? 20 : 16; - checks.push({ - name: `Panel size is ${expectedPanelSize}x${expectedPanelSize}`, - pass: panelSize === expectedPanelSize, - expected: expectedPanelSize, - actual: panelSize - }); - - // Check 4: Pixel rows divisible by panel size - checks.push({ - name: 'Pixel rows divisible by panel size', - pass: pixelRows % panelSize === 0, - pixelRows, - panelSize - }); - - // Check 5: Pixel cols divisible by panel size - checks.push({ - name: 'Pixel cols divisible by panel size', - pass: pixelCols % panelSize === 0, - pixelCols, - panelSize - }); - - // Log results - console.group('Pattern Orientation Verification'); - checks.forEach(c => { - console.log(c.pass ? '✓' : '✗', c.name, c.pass ? '' : c); - }); - console.groupEnd(); - - return checks; - } - - /** - * Get pixel value at (row, col) from frame - * @param {PatternData} patternData - Parsed pattern - * @param {number} frameIdx - Frame index (0-based) - * @param {number} row - Pixel row (0 = bottom) - * @param {number} col - Pixel column (0 = left) - * @returns {number} Pixel value (0-1 for binary, 0-15 for grayscale) - */ - function getPixel(patternData, frameIdx, row, col) { - const { frames, pixelCols } = patternData; - if (frameIdx < 0 || frameIdx >= frames.length) return 0; - if (row < 0 || row >= patternData.pixelRows) return 0; - if (col < 0 || col >= pixelCols) return 0; - - return frames[frameIdx][row * pixelCols + col]; - } - - /** - * Find matching arena config based on pattern dimensions - * @param {PatternData} patternData - Parsed pattern - * @param {Object} STANDARD_CONFIGS - Arena configs from arena-configs.js - * @returns {string|null} Config name or null if no match - */ - function findMatchingConfig(patternData, STANDARD_CONFIGS) { - const { generation, rowCount, colCount } = patternData; - - for (const [name, config] of Object.entries(STANDARD_CONFIGS)) { - const arena = config.arena; - - // Match generation - if (arena.generation !== generation && - !(generation === 'G4' && arena.generation === 'G4.1')) { - continue; - } - - // Match dimensions - if (arena.num_rows !== rowCount) continue; - - // For partial arenas, check installed columns - const installedCols = arena.columns_installed - ? arena.columns_installed.length - : arena.num_cols; - - if (installedCols === colCount) { - return name; - } - } - - return null; - } - - // Public API - const api = { - // Core parsing - detectGeneration, - parsePatFile, - parseG6Pattern, - parseG4Pattern, - - // Panel decoding (for testing) - decodeG6PanelGS2, - decodeG6PanelGS16, - decodeG4Frame, - - // Utilities - verifyPatternOrientation, - getPixel, - findMatchingConfig, - - // Constants - G6_PANEL_SIZE, - G4_PANEL_SIZE, - G6_GS2_PANEL_BYTES, - G6_GS16_PANEL_BYTES, - G6_V1_HEADER_SIZE, - G6_V2_HEADER_SIZE, - GENERATION_NAMES - }; - - return api; -})(); - -// Export for Node.js (CommonJS) -if (typeof module !== 'undefined' && module.exports) { - module.exports = PatParser; -} - -// Export for browser (global) - for