diff --git a/js/arena-configs.js b/js/arena-configs.js index 26dd8f5..1784c33 100644 --- a/js/arena-configs.js +++ b/js/arena-configs.js @@ -1,7 +1,7 @@ /** * Arena Configurations * Auto-generated from maDisplayTools/configs/arenas/ - * Last updated: 2026-01-30T15:23:37.594Z + * Last updated: 2026-02-11T13:35:39.006Z * * DO NOT EDIT MANUALLY - regenerate with: node scripts/generate-arena-configs.js */ @@ -68,6 +68,19 @@ const STANDARD_CONFIGS = { "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_ccw": { "label": "G4.1 CCW (2×12) - 360°", "description": "Standard G4.1 arena, 2 rows x 12 columns, 360 degree coverage, CCW column order", @@ -186,9 +199,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': { 1: 'G41_2x12_cw' }, - 'G6': { 1: 'G6_2x10', 2: 'G6_2x8of10', 3: 'G6_3x12of18' } + 'G4': { 1: 'G4_4x12', 2: 'G4_3x12of18' }, + 'G4.1': { 1: 'G41_2x12_cw' }, + 'G6': { 1: 'G6_2x10', 2: 'G6_2x8of10', 3: 'G6_3x12of18' } }; /** diff --git a/package-lock.json b/package-lock.json index 03ed1b6..1a09f7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,38 @@ { "name": "web-display-tools", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web-display-tools", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "devDependencies": { + "js-yaml": "^4.1.1", "prettier": "^3.8.1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", diff --git a/package.json b/package.json index d0d0711..d8443c9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "test": "node tests/validate-arena-calculations.js", "validate": "node tests/validate-arena-calculations.js", "format": "prettier --write \"**/*.js\"", - "format:check": "prettier --check \"**/*.js\"" + "format:check": "prettier --check \"**/*.js\"", + "generate:configs": "node scripts/generate-arena-configs.js" }, "repository": { "type": "git", @@ -15,6 +16,7 @@ "author": "Reiser Lab", "license": "MIT", "devDependencies": { + "js-yaml": "^4.1.1", "prettier": "^3.8.1" } } diff --git a/scripts/generate-arena-configs.js b/scripts/generate-arena-configs.js index 3a685a2..5e46627 100644 --- a/scripts/generate-arena-configs.js +++ b/scripts/generate-arena-configs.js @@ -7,83 +7,23 @@ * Usage: * node scripts/generate-arena-configs.js [config-dir] * - * If config-dir is not specified, looks for: - * 1. temp_configs/ (CI/CD fetched configs) - * 2. ../maDisplayTools/configs/arenas/ (local development) + * Config sources (in order of priority): + * 1. Command-line argument path + * 2. temp_configs/ (CI/CD fetched configs) + * 3. ../maDisplayTools/configs/arenas/ (local development) + * 4. GitHub: reiserlab/maDisplayTools feature/g6-tools branch (fallback download) */ const fs = require('fs'); const path = require('path'); +const https = require('https'); +const yaml = require('js-yaml'); -// Simple YAML parser for our arena config format -function parseYAML(yamlText) { - const config = {}; - let currentSection = config; - - const lines = yamlText.split('\n'); - for (const line of lines) { - // Skip comments and empty lines - if (line.trim().startsWith('#') || line.trim() === '') continue; - - // Check for section (arena:) - if (line.match(/^(\w+):$/)) { - const sectionName = line.match(/^(\w+):$/)[1]; - config[sectionName] = {}; - currentSection = config[sectionName]; - continue; - } - - // Check for key: value pairs (indented) - const kvMatch = line.match(/^\s+(\w+):\s*(.+?)(?:\s*#.*)?$/); - if (kvMatch) { - const key = kvMatch[1]; - let value = kvMatch[2].trim(); - - // Handle different value types - if (value === 'null') { - currentSection[key] = null; - } else if (value.startsWith('[') && value.endsWith(']')) { - // Array - const arrayContent = value.slice(1, -1); - if (arrayContent.trim() === '') { - currentSection[key] = []; - } else { - currentSection[key] = arrayContent.split(',').map(v => { - v = v.trim(); - const num = parseFloat(v); - return isNaN(num) ? v.replace(/^"|"$/g, '') : num; - }); - } - } else if (value.startsWith('"') && value.endsWith('"')) { - // Quoted string - currentSection[key] = value.slice(1, -1); - } else if (!isNaN(parseFloat(value))) { - // Number - currentSection[key] = parseFloat(value); - } else { - // Unquoted string - currentSection[key] = value; - } - continue; - } - - // Check for top-level key: value - const topKvMatch = line.match(/^(\w+):\s*(.+?)(?:\s*#.*)?$/); - if (topKvMatch) { - const key = topKvMatch[1]; - let value = topKvMatch[2].trim(); - - if (value.startsWith('"') && value.endsWith('"')) { - config[key] = value.slice(1, -1); - } else { - config[key] = value; - } - currentSection = config; - } - } - - return config; -} +// GitHub configuration for fallback download +const GITHUB_REPO = 'reiserlab/maDisplayTools'; +const GITHUB_BRANCH = 'feature/g6-tools'; +const GITHUB_CONFIG_PATH = 'configs/arenas'; +const GITHUB_REGISTRY_PATH = 'configs/arena_registry'; // Generate human-readable label from config function generateLabel(parsed) { @@ -101,7 +41,7 @@ function generateLabel(parsed) { if (columnsInstalled && Array.isArray(columnsInstalled)) { // columns_installed is always column indices (0-indexed) const installedCols = columnsInstalled.length; - const coverageDeg = Math.round(360 * installedCols / cols); + const coverageDeg = Math.round((360 * installedCols) / cols); coverage = `${coverageDeg}°`; } @@ -111,78 +51,284 @@ function generateLabel(parsed) { return `${gen}${orderSuffix} (${rows}×${cols}) - ${coverage}`; } -// Find config directory -function findConfigDir() { +/** + * Make an HTTPS GET request and return the response body + * @param {string} url - URL to fetch + * @returns {Promise} Response body + */ +function httpsGet(url) { + return new Promise((resolve, reject) => { + const options = { + headers: { + 'User-Agent': 'webDisplayTools-config-generator' + } + }; + + https + .get(url, options, (res) => { + // Handle redirects + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + httpsGet(res.headers.location).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + return; + } + + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve(data)); + res.on('error', reject); + }) + .on('error', reject); + }); +} + +/** + * Fetch the list of YAML files from GitHub API + * @returns {Promise>} + */ +async function fetchGitHubFileList() { + const apiUrl = `https://api.github.com/repos/${GITHUB_REPO}/contents/${GITHUB_CONFIG_PATH}?ref=${GITHUB_BRANCH}`; + console.log(` Fetching file list from GitHub API...`); + + const response = await httpsGet(apiUrl); + const files = JSON.parse(response); + + // Filter for YAML files only + return files + .filter((f) => f.type === 'file' && (f.name.endsWith('.yaml') || f.name.endsWith('.yml'))) + .map((f) => ({ + name: f.name, + download_url: f.download_url + })); +} + +/** + * Download YAML files from GitHub to a temporary directory + * @returns {Promise} Path to temp directory with downloaded files + */ +async function downloadFromGitHub() { + console.log(`Downloading configs from GitHub: ${GITHUB_REPO}@${GITHUB_BRANCH}`); + + // Create temp directory + const tempDir = path.join(process.cwd(), 'temp_configs'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + // Get list of YAML files + const files = await fetchGitHubFileList(); + + if (files.length === 0) { + throw new Error('No YAML files found in GitHub repository'); + } + + console.log(` Found ${files.length} YAML files`); + + // Download each file + for (const file of files) { + console.log(` Downloading: ${file.name}`); + const content = await httpsGet(file.download_url); + fs.writeFileSync(path.join(tempDir, file.name), content); + } + + console.log(` Downloaded to: ${tempDir}`); + return tempDir; +} + +/** + * Find config directory, downloading from GitHub if necessary + * @returns {Promise} Path to config directory + */ +async function findConfigDir() { // Check command line argument if (process.argv[2]) { - return process.argv[2]; + const argDir = process.argv[2]; + if (fs.existsSync(argDir)) { + return argDir; + } + console.error(`Warning: Specified directory not found: ${argDir}`); } // Check for CI/CD fetched configs const ciDir = path.join(process.cwd(), 'temp_configs'); if (fs.existsSync(ciDir)) { - return ciDir; + const yamlFiles = fs + .readdirSync(ciDir) + .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); + if (yamlFiles.length > 0) { + console.log('Using existing temp_configs/ directory'); + return ciDir; + } } // Check for local maDisplayTools const localDir = path.join(process.cwd(), '..', 'maDisplayTools', 'configs', 'arenas'); if (fs.existsSync(localDir)) { - return localDir; + const yamlFiles = fs + .readdirSync(localDir) + .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); + if (yamlFiles.length > 0) { + console.log('Using local maDisplayTools configs'); + return localDir; + } } - console.error('Error: Could not find config directory.'); - console.error('Provide path as argument or ensure temp_configs/ or ../maDisplayTools/configs/arenas/ exists.'); - process.exit(1); + // Fallback: download from GitHub + console.log('Local configs not found, downloading from GitHub...'); + try { + return await downloadFromGitHub(); + } catch (err) { + console.error(`Error downloading from GitHub: ${err.message}`); + console.error('\nCould not find config directory. Options:'); + console.error( + ' 1. Provide path as argument: node scripts/generate-arena-configs.js ' + ); + console.error(' 2. Ensure ../maDisplayTools/configs/arenas/ exists locally'); + console.error(' 3. Check network connection for GitHub download'); + process.exit(1); + } } -// Main -function main() { - const configDir = findConfigDir(); - const outputFile = path.join(process.cwd(), 'js', 'arena-configs.js'); +/** + * Download registry files (generations.yaml, index.yaml) from GitHub + * @param {string} targetDir - Parent directory to create arena_registry/ in + * @returns {Promise} Path to registry directory + */ +async function downloadRegistryFromGitHub(targetDir) { + const registryDir = path.join(targetDir, 'arena_registry'); + if (!fs.existsSync(registryDir)) { + fs.mkdirSync(registryDir, { recursive: true }); + } - console.log(`Reading configs from: ${configDir}`); + const baseUrl = `https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/${GITHUB_REGISTRY_PATH}`; + const files = ['generations.yaml', 'index.yaml']; - const configs = {}; - const files = fs.readdirSync(configDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml')); + for (const file of files) { + console.log(` Downloading: arena_registry/${file}`); + const content = await httpsGet(`${baseUrl}/${file}`); + fs.writeFileSync(path.join(registryDir, file), content); + } - if (files.length === 0) { - console.error('Error: No YAML files found in config directory.'); - process.exit(1); + return registryDir; +} + +/** + * Parse registry files from a directory + * @param {string} dir - Directory containing generations.yaml and index.yaml + * @returns {{generations: Object, arenaRegistry: Object}} + */ +function parseRegistryFiles(dir) { + const genData = yaml.load(fs.readFileSync(path.join(dir, 'generations.yaml'), 'utf8')); + const idxData = yaml.load(fs.readFileSync(path.join(dir, 'index.yaml'), 'utf8')); + + // Extract generations: keep only numeric IDs, pick name/panel_size/deprecated + const generations = {}; + for (const [key, value] of Object.entries(genData.generations || {})) { + if (!/^\d+$/.test(key)) continue; // skip range keys like "6-7" + const id = parseInt(key); + generations[id] = { name: value.name, panel_size: value.panel_size ?? null }; + if (value.deprecated) generations[id].deprecated = true; } - for (const file of files) { - const content = fs.readFileSync(path.join(configDir, file), 'utf8'); - const parsed = parseYAML(content); - const name = file.replace(/\.ya?ml$/, ''); + // Build YAML key → canonical name mapping (e.g., G41 → G4.1) + const yamlKeyToName = {}; + for (const gen of Object.values(generations)) { + if (gen.name) { + yamlKeyToName[gen.name] = gen.name; + yamlKeyToName[gen.name.replace('.', '')] = gen.name; + } + } - configs[name] = { - label: generateLabel(parsed), - description: parsed.description || '', - arena: parsed.arena - }; + // Extract arena registry: map YAML keys to canonical generation names + const arenaRegistry = {}; + for (const [key, value] of Object.entries(idxData)) { + const canonicalName = yamlKeyToName[key]; + if (!canonicalName || typeof value !== 'object') continue; + arenaRegistry[canonicalName] = {}; + for (const [arenaId, arenaName] of Object.entries(value)) { + arenaRegistry[canonicalName][parseInt(arenaId)] = arenaName; + } + } - console.log(` Parsed: ${name} -> ${configs[name].label}`); + return { generations, arenaRegistry }; +} + +/** + * Load registry data (generations + arena index) from local files or GitHub + * @param {string} configDir - The arena configs directory (used to find sibling registry dir) + * @returns {Promise<{generations: Object, arenaRegistry: Object}>} + */ +async function loadRegistryData(configDir) { + // Candidate directories for registry files + const candidates = [ + // Sibling to configDir (local maDisplayTools: configs/arenas/ → configs/arena_registry/) + path.join(configDir, '..', 'arena_registry'), + // Subdirectory of configDir (temp_configs/ → temp_configs/arena_registry/) + path.join(configDir, 'arena_registry'), + // Absolute local path + path.join(process.cwd(), '..', 'maDisplayTools', 'configs', 'arena_registry') + ]; + + for (const dir of candidates) { + const genFile = path.join(dir, 'generations.yaml'); + const idxFile = path.join(dir, 'index.yaml'); + if (fs.existsSync(genFile) && fs.existsSync(idxFile)) { + console.log(`Reading registry from: ${dir}`); + return parseRegistryFiles(dir); + } } - // Sort configs by generation then by name - const sortedConfigs = {}; - const sortOrder = ['G6', 'G4.1', 'G4', 'G3']; + // Fallback: download from GitHub + console.log('Registry files not found locally, downloading from GitHub...'); + try { + const registryDir = await downloadRegistryFromGitHub(configDir); + return parseRegistryFiles(registryDir); + } catch (err) { + console.error(`Error downloading registry from GitHub: ${err.message}`); + console.error('\nCould not find registry files (generations.yaml, index.yaml). Options:'); + console.error(' 1. Ensure ../maDisplayTools/configs/arena_registry/ exists locally'); + console.error(' 2. Check network connection for GitHub download'); + process.exit(1); + } +} - Object.keys(configs) - .sort((a, b) => { - const genA = configs[a].arena?.generation || ''; - const genB = configs[b].arena?.generation || ''; - const orderA = sortOrder.indexOf(genA); - const orderB = sortOrder.indexOf(genB); - if (orderA !== orderB) return orderA - orderB; - return a.localeCompare(b); - }) - .forEach(key => { - sortedConfigs[key] = configs[key]; - }); +/** + * Format GENERATIONS object as JavaScript source + */ +function formatGenerationsJS(generations) { + const ids = Object.keys(generations).map(Number).sort((a, b) => a - b); + const lines = ids.map((id) => { + const gen = generations[id]; + const props = [`name: '${gen.name}'`, `panel_size: ${gen.panel_size}`]; + if (gen.deprecated) props.push('deprecated: true'); + return ` ${id}: { ${props.join(', ')} }`; + }); + return `{\n${lines.join(',\n')}\n}`; +} - // Generate output - const output = `/** +/** + * Format ARENA_REGISTRY object as JavaScript source + */ +function formatArenaRegistryJS(registry) { + const lines = Object.entries(registry).map(([gen, arenas]) => { + const arenaEntries = Object.entries(arenas) + .sort(([a], [b]) => parseInt(a) - parseInt(b)) + .map(([id, name]) => `${id}: '${name}'`); + const padding = ' '.repeat(Math.max(1, 6 - gen.length)); + return ` '${gen}':${padding}{ ${arenaEntries.join(', ')} }`; + }); + return `{\n${lines.join(',\n')}\n}`; +} + +// Generate the output JavaScript file +function generateOutput(sortedConfigs, registryData) { + const { generations, arenaRegistry } = registryData; + + return `/** * Arena Configurations * Auto-generated from maDisplayTools/configs/arenas/ * Last updated: ${new Date().toISOString()} @@ -192,6 +338,60 @@ function main() { const STANDARD_CONFIGS = ${JSON.stringify(sortedConfigs, null, 2)}; +// Generation ID registry (from maDisplayTools/configs/arena_registry/generations.yaml) +const GENERATIONS = ${formatGenerationsJS(generations)}; + +// Arena ID registry — per-generation namespaces (from maDisplayTools/configs/arena_registry/index.yaml) +const ARENA_REGISTRY = ${formatArenaRegistryJS(arenaRegistry)}; + +/** + * Get generation name from ID + * @param {number} id - Generation ID (0-7) + * @returns {string} Generation name or 'unknown' + */ +function getGenerationName(id) { + return GENERATIONS[id] ? GENERATIONS[id].name : 'unknown'; +} + +/** + * Get generation ID from name + * @param {string} name - Generation name (e.g., 'G6', 'G4.1') + * @returns {number} Generation ID or 0 + */ +function getGenerationId(name) { + for (const [id, gen] of Object.entries(GENERATIONS)) { + if (gen.name === name) return parseInt(id); + } + return 0; +} + +/** + * Get arena config name from generation and arena ID + * @param {string} generation - Generation name (e.g., 'G6', 'G4') + * @param {number} arenaId - Arena ID + * @returns {string|null} Arena config name or null + */ +function getArenaName(generation, arenaId) { + const genRegistry = ARENA_REGISTRY[generation]; + if (!genRegistry) return null; + return genRegistry[arenaId] || null; +} + +/** + * Get arena ID from generation and config name + * @param {string} generation - Generation name (e.g., 'G6', 'G4') + * @param {string} arenaName - Arena config name (e.g., 'G6_2x10') + * @returns {number} Arena ID or 0 + */ +function getArenaId(generation, arenaName) { + const genRegistry = ARENA_REGISTRY[generation]; + if (!genRegistry) return 0; + for (const [id, name] of Object.entries(genRegistry)) { + if (name === arenaName) return parseInt(id); + } + return 0; +} + // Panel specifications by generation const PANEL_SPECS = { 'G3': { @@ -251,9 +451,90 @@ function getConfigsByGeneration() { // Export for both browser and Node.js if (typeof module !== 'undefined' && module.exports) { - module.exports = { STANDARD_CONFIGS, PANEL_SPECS, getConfig, getConfigsByGeneration }; + module.exports = { + STANDARD_CONFIGS, PANEL_SPECS, GENERATIONS, ARENA_REGISTRY, + getConfig, getConfigsByGeneration, + getGenerationName, getGenerationId, getArenaName, getArenaId + }; } + +// Browser global export (for non-module scripts) +if (typeof window !== 'undefined') { + window.STANDARD_CONFIGS = STANDARD_CONFIGS; + window.PANEL_SPECS = PANEL_SPECS; + window.GENERATIONS = GENERATIONS; + window.ARENA_REGISTRY = ARENA_REGISTRY; + window.getConfig = getConfig; + window.getConfigsByGeneration = getConfigsByGeneration; + window.getGenerationName = getGenerationName; + window.getGenerationId = getGenerationId; + window.getArenaName = getArenaName; + window.getArenaId = getArenaId; +} + +// ES6 module export +export { + STANDARD_CONFIGS, PANEL_SPECS, GENERATIONS, ARENA_REGISTRY, + getConfig, getConfigsByGeneration, + getGenerationName, getGenerationId, getArenaName, getArenaId +}; `; +} + +// Main +async function main() { + const configDir = await findConfigDir(); + const outputFile = path.join(process.cwd(), 'js', 'arena-configs.js'); + + console.log(`Reading configs from: ${configDir}`); + + const configs = {}; + const files = fs + .readdirSync(configDir) + .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); + + if (files.length === 0) { + console.error('Error: No YAML files found in config directory.'); + process.exit(1); + } + + for (const file of files) { + const content = fs.readFileSync(path.join(configDir, file), 'utf8'); + const parsed = yaml.load(content); + const name = file.replace(/\.ya?ml$/, ''); + + configs[name] = { + label: generateLabel(parsed), + description: parsed.description || '', + arena: parsed.arena + }; + + console.log(` Parsed: ${name} -> ${configs[name].label}`); + } + + // Sort configs by generation then by name + const sortedConfigs = {}; + const sortOrder = ['G6', 'G4.1', 'G4', 'G3']; + + Object.keys(configs) + .sort((a, b) => { + const genA = configs[a].arena?.generation || ''; + const genB = configs[b].arena?.generation || ''; + const orderA = sortOrder.indexOf(genA); + const orderB = sortOrder.indexOf(genB); + if (orderA !== orderB) return orderA - orderB; + return a.localeCompare(b); + }) + .forEach((key) => { + sortedConfigs[key] = configs[key]; + }); + + // Load registry data (generations + arena index) + const registryData = await loadRegistryData(configDir); + console.log(` Loaded ${Object.keys(registryData.generations).length} generations, ${Object.values(registryData.arenaRegistry).reduce((n, g) => n + Object.keys(g).length, 0)} arena registry entries`); + + // Generate output + const output = generateOutput(sortedConfigs, registryData); // Ensure js/ directory exists const jsDir = path.dirname(outputFile); @@ -265,4 +546,7 @@ if (typeof module !== 'undefined' && module.exports) { console.log(`\nGenerated ${outputFile} with ${Object.keys(sortedConfigs).length} configs`); } -main(); +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +});