feat(electron): added opat parsing

This commit is contained in:
2025-08-10 10:47:15 -04:00
parent f06f597207
commit a1752aaf37
4 changed files with 1766 additions and 22 deletions

View File

@@ -18,7 +18,11 @@
</div>
</div>
<nav class="category-nav">
<div class="category-item active" data-category="libplugin" title="libplugin">
<div class="category-item" data-category="home" title="Home">
<div class="category-icon" style="background-color: #6366f1;">🏠</div>
<span class="category-label">Home</span>
</div>
<div class="category-item" data-category="libplugin" title="libplugin">
<div class="category-icon" style="background-color: #3b82f6;">LP</div>
<span class="category-label">libplugin</span>
</div>
@@ -67,14 +71,15 @@
</div>
</div>
<!-- OPAT Core content (empty for now) -->
<!-- OPAT Core content -->
<div class="sidebar-content hidden" data-category="opat">
<div class="sidebar-header">
<h3>OPAT Core</h3>
</div>
<div class="empty-state">
<p>OPAT tools coming soon...</p>
</div>
<nav class="sidebar-nav">
<input type="file" id="opat-file-input" accept=".opat" class="opat-file-input" style="display: none;"/>
<button id="opat-browse-btn" class="nav-button active">Open OPAT File</button>
</nav>
</div>
<!-- SERiF Libraries content (empty for now) -->
@@ -89,9 +94,254 @@
</aside>
<main class="content-area">
<div id="welcome-screen">
<h1>Welcome to 4DSTAR Bundle Manager</h1>
<p>Open or create a bundle to get started.</p>
<!-- Main Home Screen -->
<div id="welcome-screen" class="home-screen">
<div class="welcome-hero">
<div class="welcome-logo">
<div class="star-icon-large"></div>
<h1 class="welcome-title">4DSTAR</h1>
<p class="welcome-subtitle">Stellar Evolution Analysis Suite</p>
</div>
</div>
<div class="welcome-content">
<div class="welcome-section">
<h2>Getting Started</h2>
<p>Choose a tool from the sidebar to begin your analysis:</p>
<div class="feature-grid">
<div class="feature-card">
<div class="feature-icon" style="background-color: #3b82f6;">LP</div>
<div class="feature-info">
<h3>libplugin Bundle Manager</h3>
<p>Create, open, and manage stellar evolution bundles. Build models and analyze results.</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background-color: #f59e0b;">OC</div>
<div class="feature-info">
<h3>OPAT File Inspector</h3>
<p>Inspect and analyze OPAT (Opacity Table) files. View table data and explore index vectors.</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background-color: #10b981;">LC</div>
<div class="feature-info">
<h3>libconstants</h3>
<p>Access and manage physical constants and parameters for stellar evolution calculations.</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background-color: #ef4444;">SL</div>
<div class="feature-info">
<h3>SERiF Libraries</h3>
<p>Stellar Evolution Rapid interpolation Framework tools and utilities.</p>
</div>
</div>
</div>
</div>
<div class="welcome-tips">
<h3>Quick Tips</h3>
<ul>
<li>Use the sidebar navigation to switch between different tools</li>
<li>Each tool has its own dedicated workspace and features</li>
<li>File operations and settings are accessible from the top menu bar</li>
<li>Hover over sidebar icons for quick tool descriptions</li>
</ul>
</div>
</div>
</div>
<!-- libplugin Home Screen -->
<div id="libplugin-home" class="home-screen hidden">
<div class="category-hero">
<div class="category-hero-content">
<div class="category-icon-large" style="background-color: #3b82f6;">LP</div>
<h1 class="category-title">libplugin Bundle Manager</h1>
<p class="category-subtitle">Create, manage, and analyze stellar evolution bundles</p>
</div>
</div>
<div class="category-content">
<div class="feature-section">
<h2>Bundle Management</h2>
<div class="action-cards">
<div class="action-card">
<h3>📁 Open Bundle</h3>
<p>Load an existing stellar evolution bundle for analysis and modification.</p>
</div>
<div class="action-card">
<h3> Create Bundle</h3>
<p>Start a new stellar evolution project with custom parameters and configurations.</p>
</div>
<div class="action-card">
<h3>🔧 Build Models</h3>
<p>Compile and execute stellar evolution calculations with your bundle settings.</p>
</div>
</div>
</div>
<div class="info-section">
<h3>About libplugin</h3>
<p>The libplugin Bundle Manager provides a comprehensive interface for managing stellar evolution projects. Create bundles with specific initial conditions, track evolution parameters, and analyze results through integrated tools.</p>
</div>
</div>
</div>
<!-- OPAT Home Screen -->
<div id="opat-home" class="home-screen hidden">
<div class="category-hero">
<div class="category-hero-content">
<div class="category-icon-large" style="background-color: #f59e0b;">OC</div>
<h1 class="category-title">OPAT File Inspector</h1>
<p class="category-subtitle">Analyze opacity tables and stellar atmosphere data</p>
</div>
</div>
<div class="category-content">
<div class="feature-section">
<h2>OPAT Analysis Tools</h2>
<div class="action-cards">
<div class="action-card">
<h3>📊 Load OPAT File</h3>
<p>Import and parse OPAT (Opacity Table) files for detailed inspection.</p>
</div>
<div class="action-card">
<h3>🔍 Explore Data</h3>
<p>Navigate through index vectors and examine table structures and metadata.</p>
</div>
<div class="action-card">
<h3>📈 View Tables</h3>
<p>Display opacity data in interactive tables with full or filtered views.</p>
</div>
</div>
</div>
<div class="info-section">
<h3>About OPAT Files</h3>
<p>OPAT (Opacity Table) files contain crucial stellar atmosphere data including opacity coefficients, temperature and density grids, and related thermodynamic properties used in stellar evolution calculations.</p>
</div>
</div>
</div>
<!-- libconstants Home Screen -->
<div id="libconstants-home" class="home-screen hidden">
<div class="category-hero">
<div class="category-hero-content">
<div class="category-icon-large" style="background-color: #10b981;">LC</div>
<h1 class="category-title">libconstants</h1>
<p class="category-subtitle">Physical constants and stellar parameters</p>
</div>
</div>
<div class="category-content">
<div class="feature-section">
<h2>Constants Management</h2>
<div class="action-cards">
<div class="action-card">
<h3>🔢 Physical Constants</h3>
<p>Access fundamental physical constants used in stellar evolution calculations.</p>
</div>
<div class="action-card">
<h3>⭐ Stellar Parameters</h3>
<p>Manage stellar-specific constants and conversion factors.</p>
</div>
<div class="action-card">
<h3>⚙️ Configuration</h3>
<p>Customize constant values and units for specific research needs.</p>
</div>
</div>
</div>
<div class="info-section">
<h3>About libconstants</h3>
<p>The libconstants module provides a centralized repository of physical constants, conversion factors, and stellar parameters essential for accurate stellar evolution modeling and analysis.</p>
</div>
</div>
</div>
<!-- SERiF Home Screen -->
<div id="serif-home" class="home-screen hidden">
<div class="category-hero">
<div class="category-hero-content">
<div class="category-icon-large" style="background-color: #ef4444;">SL</div>
<h1 class="category-title">SERiF Libraries</h1>
<p class="category-subtitle">Stellar Evolution Rapid interpolation Framework</p>
</div>
</div>
<div class="category-content">
<div class="feature-section">
<h2>SERiF Tools</h2>
<div class="action-cards">
<div class="action-card">
<h3>🚀 Rapid Interpolation</h3>
<p>High-performance interpolation algorithms for stellar evolution tracks.</p>
</div>
<div class="action-card">
<h3>📚 Model Libraries</h3>
<p>Access pre-computed stellar evolution models and isochrones.</p>
</div>
<div class="action-card">
<h3>🔬 Analysis Framework</h3>
<p>Advanced tools for stellar population synthesis and comparison studies.</p>
</div>
</div>
</div>
<div class="info-section">
<h3>About SERiF</h3>
<p>The Stellar Evolution Rapid interpolation Framework (SERiF) provides optimized tools for working with large stellar evolution datasets, enabling fast interpolation and analysis of stellar models.</p>
</div>
</div>
</div>
<div id="opat-view" class="hidden">
<header class="content-header">
<h2 id="opat-title">OPAT File Inspector</h2>
<div class="action-buttons">
<button id="opat-close-btn">Close File</button>
</div>
</header>
<div class="tab-nav">
<button class="tab-link active" data-tab="opat-overview-tab">File Information</button>
<button class="tab-link" data-tab="opat-explorer-tab">Data Explorer</button>
</div>
<div id="opat-tab-content">
<div id="opat-overview-tab" class="tab-pane active">
<div id="opat-header-info" class="opat-info-content"></div>
<div class="opat-info-section">
<h4 class="opat-section-title">All Table Tags</h4>
<ul id="opat-all-tags-list" class="opat-tags-list"></ul>
</div>
</div>
<div id="opat-explorer-tab" class="tab-pane hidden">
<div class="opat-info-section">
<h4 class="opat-section-title">Data Card Explorer</h4>
<div class="opat-selector-group">
<label for="opat-index-selector" class="opat-label">Select Index Vector:</label>
<select id="opat-index-selector" class="opat-select">
<option value="">-- Select an Index Vector --</option>
</select>
</div>
<div id="opat-tables-display" class="opat-tables-display"></div>
</div>
<div class="opat-info-section">
<h4 class="opat-section-title">Table Viewer</h4>
<div id="opat-table-data-content" class="opat-table-viewer">
<p class="opat-placeholder">Click on a table from the 'Data Card Explorer' above to view its data here.</p>
</div>
</div>
</div>
</div>
</div>
<div id="bundle-view" class="hidden">
@@ -309,6 +559,7 @@
<div id="spinner" class="spinner hidden"></div>
<script src="opatParser.js"></script>
<script src="renderer.js"></script>
</body>

375
electron/opatParser.js Normal file
View File

@@ -0,0 +1,375 @@
/**
* A helper class to read binary data from an ArrayBuffer.
* It keeps track of the current position and handles reading different data types.
*/
class DataReader {
/**
* @param {ArrayBuffer} arrayBuffer The file content.
*/
constructor(arrayBuffer) {
this.dataView = new DataView(arrayBuffer);
this.decoder = new TextDecoder();
this.offset = 0;
}
seek(newOffset) {
this.offset = newOffset;
}
readString(length) {
const strBytes = new Uint8Array(this.dataView.buffer, this.offset, length);
this.offset += length;
// Trim null characters from the end
const firstNull = strBytes.indexOf(0);
const nonNullSlice = firstNull === -1 ? strBytes : strBytes.slice(0, firstNull);
return this.decoder.decode(nonNullSlice);
}
readBytes(length) {
const bytes = new Uint8Array(this.dataView.buffer, this.offset, length);
this.offset += length;
return bytes;
}
readUint8() {
const value = this.dataView.getUint8(this.offset, true);
this.offset += 1;
return value;
}
readUint16() {
const value = this.dataView.getUint16(this.offset, true);
this.offset += 2;
return value;
}
readUint32() {
const value = this.dataView.getUint32(this.offset, true);
this.offset += 4;
return value;
}
readUint64() {
const value = this.dataView.getBigUint64(this.offset, true);
this.offset += 8;
return value;
}
readFloat64() {
const value = this.dataView.getFloat64(this.offset, true);
this.offset += 8;
return value;
}
readFloat64Array(length) {
const byteLength = length * 8;
const values = new Float64Array(this.dataView.buffer, this.offset, length);
this.offset += byteLength;
return values;
}
}
/**
* Corresponds to the Header struct in opatIO.h
*/
class Header {
constructor() {
this.magic = "";
this.version = 0;
this.numTables = 0;
this.headerSize = 0;
this.indexOffset = 0n; // BigInt
this.creationDate = "";
this.sourceInfo = "";
this.comment = "";
this.numIndex = 0;
this.hashPrecision = 0;
this.reserved = null;
}
static fromReader(reader) {
const h = new Header();
h.magic = reader.readString(4);
h.version = reader.readUint16();
h.numTables = reader.readUint32();
h.headerSize = reader.readUint32();
h.indexOffset = reader.readUint64();
h.creationDate = reader.readString(16);
h.sourceInfo = reader.readString(64);
h.comment = reader.readString(128);
h.numIndex = reader.readUint16();
h.hashPrecision = reader.readUint8();
h.reserved = reader.readBytes(23);
return h;
}
}
/**
* Corresponds to the CardHeader struct in opatIO.h
*/
class CardHeader {
constructor() {
this.magic = "";
this.numTables = 0;
this.headerSize = 0;
this.indexOffset = 0n;
this.cardSize = 0n;
this.comment = "";
this.reserved = null;
}
static fromReader(reader) {
const h = new CardHeader();
h.magic = reader.readString(4);
h.numTables = reader.readUint32();
h.headerSize = reader.readUint32();
h.indexOffset = reader.readUint64();
h.cardSize = reader.readUint64();
h.comment = reader.readString(128);
h.reserved = reader.readBytes(100);
return h;
}
}
/**
* Corresponds to the CardCatalogEntry struct in opatIO.h
*/
class CardCatalogEntry {
constructor() {
this.index = []; // This will be an array of numbers (FloatIndexVector)
this.byteStart = 0n;
this.byteEnd = 0n;
this.sha256 = null; // Uint8Array
}
/**
* In JS, we can use a stringified version of the index vector as a key for Maps.
*/
getKey() {
return JSON.stringify(this.index);
}
static fromReader(reader, numIndex) {
const entry = new CardCatalogEntry();
entry.index = Array.from(reader.readFloat64Array(numIndex));
entry.byteStart = reader.readUint64();
entry.byteEnd = reader.readUint64();
entry.sha256 = reader.readBytes(32);
return entry;
}
}
/**
* Corresponds to the TableIndexEntry struct in opatIO.h
*/
class TableIndexEntry {
constructor() {
this.tag = "";
this.byteStart = 0n;
this.byteEnd = 0n;
this.numColumns = 0;
this.numRows = 0;
this.columnName = "";
this.rowName = "";
this.size = 0n; // Vector size of each cell
this.reserved = null;
}
static fromReader(reader) {
const entry = new TableIndexEntry();
entry.tag = reader.readString(8);
entry.byteStart = reader.readUint64();
entry.byteEnd = reader.readUint64();
entry.numColumns = reader.readUint16();
entry.numRows = reader.readUint16();
entry.columnName = reader.readString(8);
entry.rowName = reader.readString(8);
entry.size = reader.readUint64();
entry.reserved = reader.readBytes(12);
return entry;
}
}
/**
* Corresponds to the OPATTable struct in opatIO.h
*/
class OPATTable {
constructor() {
this.rowValues = new Float64Array(0);
this.columnValues = new Float64Array(0);
this.data = new Float64Array(0);
this.N_R = 0;
this.N_C = 0;
this.m_vsize = 0;
}
/**
* Gets the vector at a specific cell.
* @param {number} row The row index.
* @param {number} column The column index.
* @returns {Float64Array} The vector data in the cell.
*/
getData(row, column) {
if (row >= this.N_R || column >= this.N_C) {
throw new Error("Index out of range");
}
const startIndex = (row * this.N_C + column) * this.m_vsize;
return this.data.subarray(startIndex, startIndex + this.m_vsize);
}
/**
* Gets a single primitive value from a cell.
* @param {number} row
* @param {number} column
* @param {number} zdepth The index within the cell's vector.
*/
getValue(row, column, zdepth = 0) {
if (row >= this.N_R || column >= this.N_C || zdepth >= this.m_vsize) {
throw new Error("Index out of range");
}
const index = (row * this.N_C + column) * this.m_vsize + zdepth;
return this.data[index];
}
static fromReader(reader, cardEntry, tableEntry) {
const table = new OPATTable();
table.N_R = tableEntry.numRows;
table.N_C = tableEntry.numColumns;
table.m_vsize = Number(tableEntry.size); // Convert BigInt to Number
reader.seek(Number(cardEntry.byteStart) + Number(tableEntry.byteStart));
table.rowValues = reader.readFloat64Array(table.N_R);
table.columnValues = reader.readFloat64Array(table.N_C);
const dataSize = table.N_R * table.N_C * table.m_vsize;
table.data = reader.readFloat64Array(dataSize);
return table;
}
}
/**
* Corresponds to the DataCard struct in opatIO.h
*/
class DataCard {
constructor() {
/** @type {CardHeader} */
this.header = null;
/** @type {Map<string, TableIndexEntry>} */
this.tableIndex = new Map();
/** @type {Map<string, OPATTable>} */
this.tableData = new Map();
}
/**
* Get a table by its tag name.
* @param {string} tag The tag of the table to retrieve.
* @returns {OPATTable | undefined}
*/
get(tag) {
return this.tableData.get(tag);
}
static fromReader(reader, cardCatalogEntry) {
const card = new DataCard();
reader.seek(Number(cardCatalogEntry.byteStart));
// 1. Read Card Header
card.header = CardHeader.fromReader(reader);
// 2. Read Table Index
reader.seek(Number(cardCatalogEntry.byteStart) + Number(card.header.indexOffset));
for (let i = 0; i < card.header.numTables; i++) {
const tableIndexEntry = TableIndexEntry.fromReader(reader);
card.tableIndex.set(tableIndexEntry.tag, tableIndexEntry);
}
// 3. Read all Table Data within this card
for (const [tag, tableEntry] of card.tableIndex.entries()) {
const table = OPATTable.fromReader(reader, cardCatalogEntry, tableEntry);
card.tableData.set(tag, table);
}
return card;
}
}
/**
* The main class representing an entire OPAT file.
*/
class OPAT {
constructor() {
/** @type {Header} */
this.header = null;
/** @type {Map<string, CardCatalogEntry>} */
this.cardCatalog = new Map();
/** @type {Map<string, DataCard>} */
this.cards = new Map();
}
/**
* Get a DataCard by its index vector.
* @param {number[]} index The index vector (e.g., [1.0, 2.5]).
* @returns {DataCard | undefined}
*/
get(index) {
const key = JSON.stringify(index);
return this.cards.get(key);
}
/**
* Calculates and returns the bounds for each dimension.
* @returns {Array<{min: number, max: number}>}
*/
getBounds() {
const bounds = Array(this.header.numIndex).fill(null).map(() => ({
min: Infinity,
max: -Infinity
}));
for (const entry of this.cardCatalog.values()) {
for (let i = 0; i < this.header.numIndex; i++) {
bounds[i].min = Math.min(bounds[i].min, entry.index[i]);
bounds[i].max = Math.max(bounds[i].max, entry.index[i]);
}
}
return bounds;
}
}
/**
* Main parsing function. Reads an ArrayBuffer and returns an OPAT object.
* @param {ArrayBuffer} arrayBuffer The content of the .opat file.
* @returns {OPAT} The parsed OPAT object.
*/
function parseOPAT(arrayBuffer) {
const reader = new DataReader(arrayBuffer);
const opat = new OPAT();
// 1. Read Header
opat.header = Header.fromReader(reader);
if (opat.header.magic !== "OPAT") {
throw new Error("File is not a valid OPAT file.");
}
// 2. Read Card Catalog
reader.seek(Number(opat.header.indexOffset)); // Seek to where the catalog starts
for (let i = 0; i < opat.header.numTables; i++) {
const entry = CardCatalogEntry.fromReader(reader, opat.header.numIndex);
opat.cardCatalog.set(entry.getKey(), entry);
}
// 3. Read all DataCards
for (const entry of opat.cardCatalog.values()) {
const dataCard = DataCard.fromReader(reader, entry);
opat.cards.set(entry.getKey(), dataCard);
}
return opat;
}

View File

@@ -51,6 +51,26 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize DOM elements
initializeDOMElements();
// Initialize OPAT tab navigation
initializeOPATTabs();
// Initialize OPAT UI elements
initializeOPATElements();
// Initialize home screen - set home as default active category
const homeCategory = document.querySelector('.category-item[data-category="home"]');
const secondarySidebar = document.getElementById('secondary-sidebar');
if (homeCategory) {
homeCategory.classList.add('active');
showCategoryHomeScreen('home');
// Hide secondary sidebar on initial load since we start with home
if (secondarySidebar) {
secondarySidebar.style.display = 'none';
}
}
// Set initial view
showView('welcome-screen');
@@ -221,6 +241,8 @@ function setupEventListeners() {
fillTargetsContent.classList.add('hidden');
}
// Check if bundle is signed and show warning before bundle-modifying operations
function checkSignatureAndWarn(operation, operationName = 'operation') {
const isSigned = currentBundle &&
@@ -287,6 +309,7 @@ function setupEventListeners() {
categoryItems.forEach(categoryItem => {
categoryItem.addEventListener('click', () => {
const selectedCategory = categoryItem.getAttribute('data-category');
const secondarySidebar = document.getElementById('secondary-sidebar');
// Remove active class from all category items
categoryItems.forEach(item => item.classList.remove('active'));
@@ -297,17 +320,30 @@ function setupEventListeners() {
// Hide all sidebar contents
sidebarContents.forEach(content => content.classList.add('hidden'));
// Show selected category content
const selectedContent = document.querySelector(`.sidebar-content[data-category="${selectedCategory}"]`);
if (selectedContent) {
selectedContent.classList.remove('hidden');
// Show/hide secondary sidebar based on category
if (selectedCategory === 'home') {
// Hide the entire secondary sidebar on home screen
if (secondarySidebar) {
secondarySidebar.style.display = 'none';
}
} else {
// Show secondary sidebar and its content for other categories
if (secondarySidebar) {
secondarySidebar.style.display = 'flex';
}
const selectedContent = document.querySelector(`.sidebar-content[data-category="${selectedCategory}"]`);
if (selectedContent) {
selectedContent.classList.remove('hidden');
}
}
// Update welcome screen title based on selected category
updateWelcomeScreen(selectedCategory);
// Show appropriate home screen
showCategoryHomeScreen(selectedCategory);
});
});
// Update welcome screen based on selected category
function updateWelcomeScreen(category) {
const welcomeTitle = document.querySelector('#welcome-screen h1');
@@ -995,3 +1031,360 @@ async function reloadCurrentBundle() {
displayBundleInfo(result.report);
}
}
// ===== OPAT FILE INSPECTOR LOGIC =====
// OPAT File Inspector variables
let currentOPATFile = null;
let opatFileInput, opatBrowseBtn, opatView, opatCloseBtn;
let opatHeaderInfo, opatAllTagsList, opatIndexSelector, opatTablesDisplay, opatTableDataContent;
// Helper function to hide all views
function hideAllViews() {
const welcomeScreen = document.getElementById('welcome-screen');
const libpluginHome = document.getElementById('libplugin-home');
const opatHome = document.getElementById('opat-home');
const libconstantsHome = document.getElementById('libconstants-home');
const serifHome = document.getElementById('serif-home');
const opatView = document.getElementById('opat-view');
const libpluginView = document.getElementById('libplugin-view');
if (welcomeScreen) welcomeScreen.classList.add('hidden');
if (libpluginHome) libpluginHome.classList.add('hidden');
if (opatHome) opatHome.classList.add('hidden');
if (libconstantsHome) libconstantsHome.classList.add('hidden');
if (serifHome) serifHome.classList.add('hidden');
if (opatView) opatView.classList.add('hidden');
if (libpluginView) libpluginView.classList.add('hidden');
}
// Show appropriate home screen based on selected category
function showCategoryHomeScreen(category) {
hideAllViews();
switch (category) {
case 'home':
const welcomeScreen = document.getElementById('welcome-screen');
if (welcomeScreen) welcomeScreen.classList.remove('hidden');
break;
case 'libplugin':
const libpluginHome = document.getElementById('libplugin-home');
if (libpluginHome) libpluginHome.classList.remove('hidden');
break;
case 'opat':
const opatHome = document.getElementById('opat-home');
if (opatHome) opatHome.classList.remove('hidden');
break;
case 'libconstants':
const libconstantsHome = document.getElementById('libconstants-home');
if (libconstantsHome) libconstantsHome.classList.remove('hidden');
break;
case 'serif':
const serifHome = document.getElementById('serif-home');
if (serifHome) serifHome.classList.remove('hidden');
break;
default:
const defaultWelcome = document.getElementById('welcome-screen');
if (defaultWelcome) defaultWelcome.classList.remove('hidden');
}
}
// Initialize OPAT UI elements in DOMContentLoaded
function initializeOPATElements() {
opatFileInput = document.getElementById('opat-file-input');
opatBrowseBtn = document.getElementById('opat-browse-btn');
opatView = document.getElementById('opat-view');
opatCloseBtn = document.getElementById('opat-close-btn');
opatHeaderInfo = document.getElementById('opat-header-info');
opatAllTagsList = document.getElementById('opat-all-tags-list');
opatIndexSelector = document.getElementById('opat-index-selector');
opatTablesDisplay = document.getElementById('opat-tables-display');
opatTableDataContent = document.getElementById('opat-table-data-content');
// Event listeners
opatBrowseBtn.addEventListener('click', () => opatFileInput.click());
opatFileInput.addEventListener('change', handleOPATFileSelection);
opatIndexSelector.addEventListener('change', handleIndexVectorChange);
opatCloseBtn.addEventListener('click', closeOPATFile);
// Initialize OPAT tab navigation
initializeOPATTabs();
// Add window resize listener to update table heights
window.updateTableHeights = function() {
const newHeight = Math.max(300, window.innerHeight - 450);
// Target the main table containers
const containers = document.querySelectorAll('.opat-table-container');
containers.forEach((container, index) => {
container.style.setProperty('height', newHeight + 'px', 'important');
});
// Also target the scroll wrappers
const scrollWrappers = document.querySelectorAll('.table-scroll-wrapper');
scrollWrappers.forEach((wrapper, index) => {
wrapper.style.setProperty('height', '100%', 'important');
});
};
window.addEventListener('resize', () => {
console.log('Window resize event fired');
window.updateTableHeights();
});
// Also update on initial load and when tables are displayed
setTimeout(window.updateTableHeights, 100);
}
function closeOPATFile() {
currentOPATFile = null;
opatFileInput.value = '';
resetOPATViewerState();
hideAllViews();
document.getElementById('welcome-screen').classList.remove('hidden');
}
function resetOPATViewerState() {
// Clear all OPAT content areas
if (opatHeaderInfo) opatHeaderInfo.innerHTML = '';
if (opatAllTagsList) opatAllTagsList.innerHTML = '';
if (opatIndexSelector) {
opatIndexSelector.innerHTML = '<option value="">-- Select an Index Vector --</option>';
}
if (opatTablesDisplay) opatTablesDisplay.innerHTML = '';
if (opatTableDataContent) {
opatTableDataContent.innerHTML = '<p class="opat-placeholder">Click on a table from the \'Data Card Explorer\' above to view its data here.</p>';
}
// Reset to File Information tab
const opatTabLinks = document.querySelectorAll('#opat-view .tab-link');
const opatTabPanes = document.querySelectorAll('#opat-tab-content .tab-pane');
opatTabLinks.forEach(link => link.classList.remove('active'));
opatTabPanes.forEach(pane => pane.classList.add('hidden'));
// Activate File Information tab
const fileInfoTab = document.querySelector('[data-tab="opat-overview-tab"]');
const fileInfoPane = document.getElementById('opat-overview-tab');
if (fileInfoTab) fileInfoTab.classList.add('active');
if (fileInfoPane) fileInfoPane.classList.remove('hidden');
}
function initializeOPATTabs() {
const opatTabLinks = document.querySelectorAll('#opat-view .tab-link');
const opatTabPanes = document.querySelectorAll('#opat-tab-content .tab-pane');
opatTabLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetTab = link.getAttribute('data-tab');
// Remove active class from all tabs and panes
opatTabLinks.forEach(l => l.classList.remove('active'));
opatTabPanes.forEach(p => p.classList.add('hidden'));
// Add active class to clicked tab and show corresponding pane
link.classList.add('active');
document.getElementById(targetTab).classList.remove('hidden');
});
});
}
async function handleOPATFileSelection(event) {
const file = event.target.files[0];
if (!file) return;
try {
// Reset viewer state before loading new file
resetOPATViewerState();
const arrayBuffer = await file.arrayBuffer();
currentOPATFile = parseOPAT(arrayBuffer);
displayOPATFileInfo();
populateIndexSelector();
// Show OPAT view and hide other views
hideAllViews();
opatView.classList.remove('hidden');
// Update title with filename
document.getElementById('opat-title').textContent = `OPAT File Inspector - ${file.name}`;
} catch (error) {
console.error('Error parsing OPAT file:', error);
showModal('Error', `Failed to parse OPAT file: ${error.message}`);
}
}
function displayOPATFileInfo() {
if (!currentOPATFile) return;
const header = currentOPATFile.header;
opatHeaderInfo.innerHTML = `
<div class="opat-info-section">
<h4 class="opat-section-title">Header Information</h4>
<div class="info-grid">
<p><strong>Magic:</strong> ${header.magic}</p>
<p><strong>Version:</strong> ${header.version}</p>
<p><strong>Number of Tables:</strong> ${header.numTables}</p>
<p><strong>Header Size:</strong> ${header.headerSize} bytes</p>
<p><strong>Index Offset:</strong> ${header.indexOffset}</p>
<p><strong>Creation Date:</strong> ${header.creationDate}</p>
<p><strong>Source Info:</strong> ${header.sourceInfo}</p>
<p><strong>Comment:</strong> ${header.comment || 'None'}</p>
<p><strong>Number of Indices:</strong> ${header.numIndex}</p>
<p><strong>Hash Precision:</strong> ${header.hashPrecision}</p>
</div>
</div>
`;
// Display all unique table tags
displayAllTableTags();
}
function displayAllTableTags() {
if (!currentOPATFile) return;
const allTags = new Set();
for (const card of currentOPATFile.cards.values()) {
for (const tag of card.tableIndex.keys()) {
allTags.add(tag);
}
}
opatAllTagsList.innerHTML = '';
Array.from(allTags).sort().forEach(tag => {
const li = document.createElement('li');
li.textContent = tag;
opatAllTagsList.appendChild(li);
});
}
function populateIndexSelector() {
if (!currentOPATFile) return;
opatIndexSelector.innerHTML = '<option value="">-- Select an index vector --</option>';
for (const [key, entry] of currentOPATFile.cardCatalog.entries()) {
const option = document.createElement('option');
option.value = key;
option.textContent = `[${entry.index.join(', ')}]`;
opatIndexSelector.appendChild(option);
}
}
function handleIndexVectorChange() {
const selectedKey = opatIndexSelector.value;
if (!selectedKey || !currentOPATFile) {
opatTablesDisplay.innerHTML = '';
return;
}
const card = currentOPATFile.cards.get(selectedKey);
if (!card) return;
opatTablesDisplay.innerHTML = '';
for (const [tag, tableEntry] of card.tableIndex.entries()) {
const tableInfo = document.createElement('div');
tableInfo.className = 'opat-table-info';
tableInfo.innerHTML = `
<div class="opat-table-tag">${tag}</div>
<div class="opat-table-details">
Rows: ${tableEntry.numRows}, Columns: ${tableEntry.numColumns}<br>
Row Name: ${tableEntry.rowName}, Column Name: ${tableEntry.columnName}
</div>
`;
tableInfo.addEventListener('click', () => {
const table = card.tableData.get(tag);
displayTableData(table, tag);
});
opatTablesDisplay.appendChild(tableInfo);
}
}
function displayTableData(table, tag, showAll = false) {
if (!table) {
opatTableDataContent.innerHTML = '<p class="opat-placeholder">Table not found.</p>';
return;
}
let html = `<div class="opat-table-title"><span class="opat-table-tag-highlight">${tag}</span> Table Data</div>`;
html += `<p><strong>Dimensions:</strong> ${table.N_R} rows × ${table.N_C} columns × ${table.m_vsize} values per cell</p>`;
if (table.N_R > 0 && table.N_C > 0) {
if (table.m_vsize === 0 || table.data.length === 0) {
html += '<p><strong>Note:</strong> This table has no data values (m_vsize = 0 or empty data array).</p>';
html += '<p>The table structure exists but contains no numerical data to display.</p>';
} else {
// Add show all/show less toggle buttons
if (table.N_R > 50) {
html += '<div class="table-controls">';
if (!showAll) {
html += `<button class="show-all-btn" onclick="displayTableData(currentOPATFile.cards.get('${opatIndexSelector.value}').tableData.get('${tag}'), '${tag}', true)">Show All ${table.N_R} Rows</button>`;
} else {
html += `<button class="show-less-btn" onclick="displayTableData(currentOPATFile.cards.get('${opatIndexSelector.value}').tableData.get('${tag}'), '${tag}', false)">Show First 50 Rows</button>`;
}
html += '</div>';
}
html += '<div class="opat-table-container">';
html += '<div class="table-scroll-wrapper">';
html += '<table class="opat-data-table">';
// Header row
html += '<thead><tr><th class="corner-cell"></th>';
for (let c = 0; c < table.N_C; c++) {
html += `<th>${table.columnValues[c].toFixed(3)}</th>`;
}
html += '</tr></thead>';
// Data rows
html += '<tbody>';
const rowsToShow = showAll ? table.N_R : Math.min(table.N_R, 50);
for (let r = 0; r < rowsToShow; r++) {
html += '<tr>';
html += `<th class="row-header">${table.rowValues[r].toFixed(3)}</th>`;
for (let c = 0; c < table.N_C; c++) {
try {
const value = table.getValue(r, c, 0); // Get first value in cell
html += `<td>${value.toFixed(6)}</td>`;
} catch (error) {
html += `<td>N/A</td>`;
}
}
html += '</tr>';
}
html += '</tbody>';
html += '</table>';
html += '</div></div>';
if (table.N_R > 50 && !showAll) {
html += `<p><em>Showing first 50 rows of ${table.N_R} total rows.</em></p>`;
} else if (showAll && table.N_R > 50) {
html += `<p><em>Showing all ${table.N_R} rows.</em></p>`;
}
}
} else {
html += '<p>No data to display.</p>';
}
opatTableDataContent.innerHTML = html;
// Auto-switch to Data Explorer tab when displaying data
const explorerTab = document.querySelector('[data-tab="opat-explorer-tab"]');
if (explorerTab) {
explorerTab.click();
}
// Update table heights after table is rendered
setTimeout(() => {
if (window.updateTableHeights) {
window.updateTableHeights();
}
}, 50);
}

View File

@@ -504,18 +504,403 @@ body.dark-mode .info-button:hover {
flex-direction: column;
overflow: hidden;
min-width: 0;
padding: 0;
padding: 20px;
}
#welcome-screen {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.welcome-hero {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 300px;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
color: white;
margin-bottom: 2rem;
}
.welcome-logo {
text-align: center;
color: var(--text-light);
padding: 20px;
}
.star-icon-large {
font-size: 4rem;
color: #fbbf24;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
}
.welcome-title {
font-size: 3.5rem;
font-weight: 700;
margin: 0;
letter-spacing: 0.1em;
background: linear-gradient(45deg, #fbbf24, #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-subtitle {
font-size: 1.2rem;
margin: 0.5rem 0 0 0;
color: #cbd5e1;
font-weight: 300;
}
.welcome-content {
flex: 1;
padding: 0 3rem 3rem 3rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.welcome-section h2 {
color: #1e293b;
font-size: 2rem;
margin-bottom: 1rem;
text-align: center;
}
.welcome-section p {
color: #64748b;
font-size: 1.1rem;
text-align: center;
margin-bottom: 2rem;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.feature-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
border: 1px solid #e2e8f0;
}
.feature-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.15);
}
.feature-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1.2rem;
flex-shrink: 0;
}
.feature-info h3 {
color: #1e293b;
font-size: 1.2rem;
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.feature-info p {
color: #64748b;
font-size: 0.95rem;
margin: 0;
line-height: 1.5;
text-align: left;
}
.welcome-tips {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
}
.welcome-tips h3 {
color: #1e293b;
font-size: 1.4rem;
margin: 0 0 1rem 0;
font-weight: 600;
}
.welcome-tips ul {
color: #64748b;
font-size: 1rem;
line-height: 1.6;
margin: 0;
padding-left: 1.5rem;
}
.welcome-tips li {
margin-bottom: 0.5rem;
}
/* Category-specific home screens */
.home-screen {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.category-hero {
display: flex;
justify-content: center;
align-items: center;
min-height: 250px;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
color: white;
margin-bottom: 2rem;
}
.category-hero-content {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.category-icon-large {
width: 80px;
height: 80px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 2.5rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.category-title {
font-size: 2.5rem;
font-weight: 700;
margin: 0;
color: white;
}
.category-subtitle {
font-size: 1.1rem;
margin: 0;
color: #cbd5e1;
font-weight: 300;
}
.category-content {
flex: 1;
padding: 0 3rem 3rem 3rem;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
.feature-section h2 {
color: #1e293b;
font-size: 1.8rem;
margin-bottom: 1.5rem;
text-align: center;
}
.action-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.action-card {
padding: 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
border: 1px solid #e2e8f0;
text-align: center;
}
.action-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.15);
}
.action-card h3 {
color: #1e293b;
font-size: 1.3rem;
margin: 0 0 1rem 0;
font-weight: 600;
}
.action-card p {
color: #64748b;
font-size: 1rem;
margin: 0;
line-height: 1.6;
}
.info-section {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
}
.info-section h3 {
color: #1e293b;
font-size: 1.4rem;
margin: 0 0 1rem 0;
font-weight: 600;
}
.info-section p {
color: #64748b;
font-size: 1rem;
line-height: 1.6;
margin: 0;
}
/* Dark mode support for home screens */
body.dark-mode .home-screen {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
body.dark-mode .category-hero {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
body.dark-mode .feature-section h2,
body.dark-mode .category-title {
color: #f1f5f9;
}
body.dark-mode .category-subtitle {
color: #94a3b8;
}
body.dark-mode .action-card,
body.dark-mode .info-section,
body.dark-mode .feature-card,
body.dark-mode .welcome-tips {
background: #1e293b;
border-color: #334155;
}
body.dark-mode .action-card h3,
body.dark-mode .info-section h3,
body.dark-mode .feature-info h3,
body.dark-mode .welcome-tips h3 {
color: #f1f5f9;
}
body.dark-mode .action-card p,
body.dark-mode .info-section p,
body.dark-mode .feature-info p,
body.dark-mode .welcome-tips ul {
color: #94a3b8;
}
body.dark-mode .welcome-section h2 {
color: #f1f5f9;
}
body.dark-mode .welcome-section p {
color: #94a3b8;
}
/* Custom scrollbar styling for all views */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
::-webkit-scrollbar-corner {
background: #f1f5f9;
}
/* Dark mode scrollbar styling */
body.dark-mode ::-webkit-scrollbar-track {
background: #1e293b;
}
body.dark-mode ::-webkit-scrollbar-thumb {
background: #475569;
}
body.dark-mode ::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
body.dark-mode ::-webkit-scrollbar-corner {
background: #1e293b;
}
/* Specific scrollbar styling for table containers */
.opat-table-container ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.opat-table-container ::-webkit-scrollbar-track {
background: #f8fafc;
border-radius: 5px;
}
.opat-table-container ::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 5px;
}
.opat-table-container ::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
body.dark-mode .opat-table-container ::-webkit-scrollbar-track {
background: #0f172a;
}
body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb {
background: #334155;
}
body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
background: #475569;
}
#welcome-screen h1 {
@@ -1178,11 +1563,11 @@ body.dark-mode .fill-header p {
}
#fill-progress-container {
margin-top: 20px;
padding: 16px;
background-color: var(--sidebar-bg);
margin-bottom: 20px;
padding: 20px 30px;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid var(--border-color);
border: 1px solid #e9ecef;
}
#fill-progress-content {
@@ -1530,6 +1915,346 @@ body.dark-mode .info-tab-pane a {
line-height: 1.6;
}
/* OPAT File Inspector Styling */
#opat-tab-content {
padding: 20px;
height: calc(100vh - 200px);
overflow-y: auto;
display: flex;
flex-direction: column;
}
#opat-tab-content .tab-pane {
flex: 1;
display: flex;
flex-direction: column;
}
.opat-table-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: calc(100vh - 450px);
}
.table-scroll-wrapper {
width: 100%;
height: 100%;
min-height: 300px;
overflow: auto;
flex: 1;
}
#opat-tab-content .tab-pane {
padding: 0;
}
.opat-section {
margin-bottom: 20px;
padding: 16px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
body.dark-mode .opat-section {
background: #4b5563;
border: 1px solid #6b7280;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.opat-label {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
body.dark-mode .opat-label {
color: #f3f4f6;
}
.opat-file-input {
display: none;
}
.opat-content-wrapper {
margin-top: 20px;
}
.opat-info-section {
margin: 0 20px 24px 20px;
padding: 20px 30px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
body.dark-mode .opat-info-section {
background: #4b5563;
border: 1px solid #6b7280;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.opat-section-title {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
body.dark-mode .opat-section-title {
color: #f3f4f6;
border-bottom: 1px solid #6b7280;
}
.opat-info-content {
font-size: 0.85rem;
color: #6b7280;
line-height: 1.5;
}
body.dark-mode .opat-info-content {
color: #d1d5db;
}
.opat-tags-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 200px;
overflow-y: auto;
}
.opat-tags-list li {
padding: 6px 12px;
margin: 4px 0;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 0.85rem;
font-family: monospace;
color: #059669;
}
body.dark-mode .opat-tags-list li {
background: #374151;
border: 1px solid #4b5563;
color: #10b981;
}
.opat-selector-group {
margin-bottom: 16px;
}
.opat-select {
width: 100%;
padding: 8px 12px;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
color: #374151;
cursor: pointer;
}
body.dark-mode .opat-select {
background: #374151;
border: 1px solid #4b5563;
color: #f3f4f6;
}
.opat-tables-display {
max-height: 300px;
overflow-y: auto;
}
.opat-table-info {
padding: 12px;
margin: 8px 0;
background: #f8f9fa;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.opat-table-info:hover {
background: #e5e7eb;
border-color: #3b82f6;
}
body.dark-mode .opat-table-info {
background: #374151;
border: 1px solid #4b5563;
}
body.dark-mode .opat-table-info:hover {
background: #4b5563;
border-color: #3b82f6;
}
.opat-table-tag {
font-weight: 600;
color: #059669;
font-family: monospace;
margin-bottom: 4px;
}
body.dark-mode .opat-table-tag {
color: #10b981;
}
.opat-table-details {
font-size: 0.8rem;
color: #6b7280;
line-height: 1.4;
}
body.dark-mode .opat-table-details {
color: #9ca3af;
}
.opat-table-viewer {
max-height: 400px;
overflow: auto;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
}
body.dark-mode .opat-table-viewer {
border: 1px solid #4b5563;
background: #374151;
}
.opat-placeholder {
padding: 20px;
text-align: center;
color: #9ca3af;
font-style: italic;
margin: 0;
}
body.dark-mode .opat-placeholder {
color: #6b7280;
}
.opat-data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
font-family: monospace;
}
.opat-data-table th,
.opat-data-table td {
padding: 6px 8px;
border: 1px solid #e5e7eb;
text-align: center;
}
body.dark-mode .opat-data-table th,
body.dark-mode .opat-data-table td {
border: 1px solid #4b5563;
}
.opat-data-table th {
background: #f8f9fa;
font-weight: 600;
color: #374151;
position: sticky;
top: 0;
z-index: 1;
}
body.dark-mode .opat-data-table th {
background: #4b5563;
color: #f3f4f6;
}
.opat-data-table th.row-header {
background-color: #f8fafc;
font-weight: 600;
text-align: right;
padding-right: 12px;
}
body.dark-mode .opat-data-table th.row-header {
background: #4b5563;
}
.table-controls {
margin: 1rem 0;
text-align: center;
}
.show-all-btn, .show-less-btn {
background-color: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s ease;
}
.show-all-btn:hover, .show-less-btn:hover {
background-color: #2563eb;
}
.show-less-btn {
background-color: #6b7280;
}
.show-less-btn:hover {
background-color: #4b5563;
}
.opat-data-table .corner-cell {
position: sticky;
top: 0;
left: 0;
z-index: 2;
background: #e5e7eb;
}
body.dark-mode .opat-data-table .corner-cell {
background: #6b7280;
}
.opat-table-title {
padding: 12px 16px;
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #1f2937;
background: #f8f9fa;
border-bottom: 1px solid #e5e7eb;
}
body.dark-mode .opat-table-title {
color: #f3f4f6;
background: #4b5563;
border-bottom: 1px solid #6b7280;
}
.opat-table-tag-highlight {
color: #059669;
font-family: monospace;
}
body.dark-mode .opat-table-tag-highlight {
color: #10b981;
}
.info-tab-pane ul {
margin: 12px 0;
padding-left: 20px;