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