diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..acd4af9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Vendor and 3rd party are ignored for reformatting +js/vendor/ 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 65e299e..608b5e0 100644 --- a/js/arena-configs.js +++ b/js/arena-configs.js @@ -7,171 +7,136 @@ */ 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 - } - }, - "G6_3x16_full": { - "label": "G6 (3×16) - 360°", - "description": "G6 arena, 3 rows × 16 columns", - "arena": { - "generation": "G6", - "num_rows": 3, - "num_cols": 16, - "columns_installed": null, - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 0 - } - }, - "G41_2x12_cw": { - "label": "G4.1 (2×12) - 360°", - "description": "G4.1 arena, 2 rows x 12 columns, 360 degree, CW, c0 at south", - "arena": { - "generation": "G4.1", - "num_rows": 2, - "num_cols": 12, - "columns_installed": null, - "orientation": "normal", - "column_order": "cw", - "angle_offset_deg": 15 - } - }, - "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 + } + }, + G6_3x16_full: { + label: 'G6 (3×16) - 360°', + description: 'G6 arena, 3 rows × 16 columns', + arena: { + generation: 'G6', + num_rows: 3, + num_cols: 16, + columns_installed: null, + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 0 + } + }, + G41_2x12_cw: { + label: 'G4.1 (2×12) - 360°', + description: 'G4.1 arena, 2 rows x 12 columns, 360 degree, CW, c0 at south', + arena: { + generation: 'G4.1', + num_rows: 2, + num_cols: 12, + columns_installed: null, + orientation: 'normal', + column_order: 'cw', + angle_offset_deg: 15 + } + }, + 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 +151,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', 4: 'G6_3x16_full' } + G6: { 1: 'G6_2x10', 2: 'G6_2x8of10', 3: 'G6_3x12of18', 4: 'G6_3x16_full' } }; /** @@ -241,37 +206,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 +249,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 +264,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 +293,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/crc.js b/js/crc.js index 0bc27ce..7fab249 100644 --- a/js/crc.js +++ b/js/crc.js @@ -53,9 +53,7 @@ const G6CRC = (function () { for (let b = 0; b < 256; b++) { let crc = b << 8; for (let i = 0; i < 8; i++) { - crc = crc & 0x8000 - ? ((crc << 1) ^ poly) & 0xffff - : (crc << 1) & 0xffff; + crc = crc & 0x8000 ? ((crc << 1) ^ poly) & 0xffff : (crc << 1) & 0xffff; } lut[b] = crc; } @@ -81,9 +79,7 @@ const G6CRC = (function () { return c ^ CRC16_CCITT_FALSE_XOROUT; } - const UNIVERSAL_INPUT = new Uint8Array([ - 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39 - ]); // "123456789" + const UNIVERSAL_INPUT = new Uint8Array([0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39]); // "123456789" const crc8Check = crc8Autosar(UNIVERSAL_INPUT); if (crc8Check !== 0xdf) { 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/icon-generator.js b/js/icon-generator.js index 3bb1dcd..7ff0a4d 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) => { @@ -427,7 +437,7 @@ function generatePatternGIF(patternData, arenaConfig, options = {}, onProgress = quality: opts.quality, width: opts.width, height: opts.height, - workerScript: './js/gif.worker.js' + workerScript: './js/vendor/gif.worker.js' }); const frameDelay = Math.round(1000 / opts.fps); @@ -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-parser.js b/js/pat-parser.js index 8efcef8..ca63332 100644 --- a/js/pat-parser.js +++ b/js/pat-parser.js @@ -1,763 +1,792 @@ -/** - * .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'; - - // ---- CRC helpers (inlined; canonical source: js/crc.js) ----------------- - // Inlined here (vs require()) to avoid Node ESM/CJS interop headaches when - // this file is loaded via `import PatParser from './js/pat-parser.js'`. - // Tests + the CLI verifier use js/crc.js directly; LUT-builder + universal - // check below match that file byte-for-byte. - - function _buildCrc8Lut(poly) { - const lut = new Uint8Array(256); - for (let b = 0; b < 256; b++) { - let c = b; - for (let i = 0; i < 8; i++) c = c & 0x80 ? ((c << 1) ^ poly) & 0xff : (c << 1) & 0xff; - lut[b] = c; - } - return lut; - } - function _buildCrc16Lut(poly) { - const lut = new Uint16Array(256); - for (let b = 0; b < 256; b++) { - let c = b << 8; - for (let i = 0; i < 8; i++) c = c & 0x8000 ? ((c << 1) ^ poly) & 0xffff : (c << 1) & 0xffff; - lut[b] = c; - } - return lut; - } - const _CRC8_LUT = _buildCrc8Lut(0x2f); - const _CRC16_LUT = _buildCrc16Lut(0x1021); - function crc8Autosar(bytes) { - let c = 0xff; - for (let i = 0; i < bytes.length; i++) c = _CRC8_LUT[(c ^ bytes[i]) & 0xff]; - return c ^ 0xff; - } - function crc16CcittFalse(bytes) { - let c = 0xffff; - for (let i = 0; i < bytes.length; i++) - c = ((c << 8) ^ _CRC16_LUT[((c >> 8) ^ bytes[i]) & 0xff]) & 0xffff; - return c; - } - const _U = new Uint8Array([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39]); - if (crc8Autosar(_U) !== 0xdf) throw new Error('pat-parser.js CRC-8/AUTOSAR universal check failed'); - if (crc16CcittFalse(_U) !== 0x29b1) throw new Error('pat-parser.js CRC-16/CCITT-FALSE universal check failed'); - // ------------------------------------------------------------------------- - - // 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_FRAME_TRAILER_SIZE = 2; // V2 only: CRC-16/CCITT-FALSE (little-endian) - 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). - * - * Options: - * strict (default false) — CRC failures throw instead of warning. Used by - * CI / regen / export-validation tooling. Interactive UI callers leave - * it `false` so the file still loads and the user can see what's there. - * - * @param {ArrayBuffer} buffer - Raw file data - * @param {Object} [opts] - Parser options - * @returns {PatternData} Parsed pattern data - */ - function parsePatFile(buffer, opts) { - const generation = detectGeneration(buffer); - const options = opts || {}; - - if (generation === 'G6') { - return parseG6Pattern(buffer, options); - } 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: CRC-8/AUTOSAR over header bytes 0-16 (per g6_01-panel-protocol.md § CRC-8) - * - * V2 frames carry a 2-byte CRC-16/CCITT-FALSE trailer (little-endian) over - * {FR_magic, frame_index, panel_blocks} (per g6_04-pattern-file-format.md - * § Frame Format). V1 frames have no trailer. - * - * @param {ArrayBuffer} buffer - Raw file data - * @param {Object} [options] - Parser options ({strict: bool}) - * @returns {PatternData} Parsed pattern data - */ - function parseG6Pattern(buffer, options) { - const opts = options || {}; - const strict = !!opts.strict; - 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; - - // Verify header CRC-8/AUTOSAR over bytes 0-16 - const expectedHeaderCrc = crc8Autosar(bytes.subarray(0, 17)); - if (expectedHeaderCrc !== checksum) { - const msg = `G6 header CRC-8 mismatch: file=0x${checksum.toString(16).padStart(2, '0')}, computed=0x${expectedHeaderCrc.toString(16).padStart(2, '0')}`; - if (strict) throw new Error(msg); - console.warn(msg); - } - } - - 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. V2 carries a per-frame CRC-16/CCITT-FALSE trailer; V1 does not. - const hasFrameTrailer = headerVersion >= 2; - const frames = []; - const stretchValues = []; - let offset = headerSize; - - for (let f = 0; f < numFrames; f++) { - const frameStart = offset; - - // Verify frame header "FR" - if (bytes[offset] !== 0x46 || bytes[offset + 1] !== 0x52) { // 'F', 'R' - const msg = `Frame ${f}: expected FR header at offset ${offset}, got 0x${bytes[offset].toString(16)} 0x${bytes[offset + 1].toString(16)}`; - if (strict) throw new Error(msg); - console.warn(msg); - } - 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]; - } - } - } - } - - if (hasFrameTrailer) { - // CRC-16 trailer over {FR_magic, frame_index, panel_blocks} of THIS frame, - // 2 bytes little-endian. - const trailerLo = bytes[offset]; - const trailerHi = bytes[offset + 1]; - const fileCrc = trailerLo | (trailerHi << 8); - const expectedCrc = crc16CcittFalse(bytes.subarray(frameStart, offset)); - if (expectedCrc !== fileCrc) { - const msg = `Frame ${f}: CRC-16 mismatch — file=0x${fileCrc.toString(16).padStart(4, '0')}, computed=0x${expectedCrc.toString(16).padStart(4, '0')}`; - if (strict) throw new Error(msg); - console.warn(msg); - } - offset += G6_FRAME_TRAILER_SIZE; - } - - 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