/** * Main application script for FFXI Trust Characters web app */ document.addEventListener('DOMContentLoaded', () => { // DOM elements const rolesList = document.getElementById('roles-list'); const charactersGrid = document.getElementById('characters-grid'); const characterInfo = document.getElementById('character-info'); const currentRoleHeading = document.getElementById('current-role'); const searchInput = document.getElementById('search-input'); // Current state let currentRole = 'All'; let currentCharacter = null; let trustData = null; let currentView = 'all'; // 'all', 'acquired', or 'unacquired' // Role-specific CSS class mapping const roleClasses = { 'Tank': 'Tank', 'Melee Fighter': 'Melee', 'Ranged Fighter': 'Ranged', 'Offensive Caster': 'Offensive', 'Healer': 'Healer', 'Support': 'Support', 'Special': 'Special', 'Unity Concord': 'Unity' }; /** * Initialize the application */ function init() { // Load trust data trustParser.loadData(data => { trustData = data; // Populate roles list populateRolesList(); // Show all characters initially showAllCharacters(); // Show synergy sets showSynergySets(); // Set up event listeners setupEventListeners(); }); } /** * Identify and group synergy sets * @returns {Array} - Array of synergy sets */ function identifySynergySets() { // Create a map to track processed characters const processedChars = new Set(); const synergySets = []; // Process each character Object.values(trustData.characters).forEach(character => { // Skip if already processed or no synergy names if (processedChars.has(character.name) || !character.trustSynergyNames || character.trustSynergyNames.length === 0) { return; } // Create a new set with this character and its synergy characters const setMembers = new Set([character.name]); // Add all synergy characters character.trustSynergyNames.forEach(synergyName => { setMembers.add(synergyName); // Also add any characters that have synergy with these synergy characters const synergyChar = trustData.characters[synergyName]; if (synergyChar && synergyChar.trustSynergyNames) { synergyChar.trustSynergyNames.forEach(nestedName => { if (setMembers.has(nestedName)) { setMembers.add(nestedName); } }); } }); // Convert set to array and sort const setArray = Array.from(setMembers).sort(); // Skip sets with only one character if (setArray.length <= 1) { return; } // Create a unique key for this set to avoid duplicates const setKey = setArray.join('|'); // Check if this set is already included const existingSetIndex = synergySets.findIndex(set => { const existingKey = set.characters.join('|'); return existingKey === setKey; }); if (existingSetIndex === -1) { // Add new set synergySets.push({ name: `${setArray.join(' / ')} Synergy`, characters: setArray }); // Mark all characters in this set as processed setArray.forEach(name => processedChars.add(name)); } }); return synergySets; } /** * Show synergy sets in the synergy sets container */ function showSynergySets() { const synergySetsContainer = document.getElementById('synergy-sets'); // Clear the container synergySetsContainer.innerHTML = ''; // Get synergy sets const synergySets = identifySynergySets(); // If no synergy sets, show a message if (synergySets.length === 0) { synergySetsContainer.innerHTML = `

No synergy sets found

`; return; } // Add each synergy set synergySets.forEach(set => { const setElement = document.createElement('div'); setElement.className = 'synergy-set'; // Add title const titleElement = document.createElement('div'); titleElement.className = 'synergy-set-title'; titleElement.textContent = set.name; setElement.appendChild(titleElement); // Add characters const charactersElement = document.createElement('div'); charactersElement.className = 'synergy-set-characters'; set.characters.forEach(name => { const character = trustData.characters[name]; if (!character) return; const charElement = document.createElement('span'); charElement.className = 'synergy-set-character'; charElement.dataset.name = name; charElement.textContent = name; // Add role class if (character.role && roleClasses[character.role]) { charElement.classList.add(roleClasses[character.role]); } // Add acquired class if the character is acquired if (character.acquired) { charElement.classList.add('acquired'); } charactersElement.appendChild(charElement); }); setElement.appendChild(charactersElement); synergySetsContainer.appendChild(setElement); }); } /** * Populate the roles list with data from the parser */ function populateRolesList() { // Add "All" option const allItem = document.createElement('li'); allItem.textContent = 'All'; allItem.classList.add('active'); allItem.dataset.role = 'All'; rolesList.appendChild(allItem); // Add "Synergy Sets" option const synergyItem = document.createElement('li'); synergyItem.textContent = 'Synergy Sets'; synergyItem.dataset.role = 'Synergy'; synergyItem.classList.add('Synergy'); rolesList.appendChild(synergyItem); // Define the order of roles const roleOrder = [ 'Tank', 'Melee Fighter', 'Ranged Fighter', 'Offensive Caster', 'Healer', 'Support', 'Unity Concord', 'Special' ]; // Add roles in the specified order roleOrder.forEach(role => { // Skip if the role doesn't exist in the data if (!trustData.roles.includes(role)) return; const li = document.createElement('li'); li.textContent = role; li.dataset.role = role; if (roleClasses[role]) { li.classList.add(roleClasses[role]); } rolesList.appendChild(li); }); // Add any remaining roles that weren't in the specified order trustData.roles.forEach(role => { if (!roleOrder.includes(role)) { const li = document.createElement('li'); li.textContent = role; li.dataset.role = role; if (roleClasses[role]) { li.classList.add(roleClasses[role]); } rolesList.appendChild(li); } }); } /** * Show all characters in the grid, grouped by role */ function showAllCharacters() { // Clear the grid charactersGrid.innerHTML = ''; // Show the synergy sets container document.getElementById('synergy-sets-container').style.display = 'none'; // Define the order of roles const roleOrder = [ 'Tank', 'Melee Fighter', 'Ranged Fighter', 'Offensive Caster', 'Healer', 'Support', 'Unity Concord', 'Special' ]; // Filter roles that exist in the data const orderedRoles = roleOrder.filter(role => trustData.roles.includes(role)); // Add any remaining roles that weren't in the specified order const remainingRoles = trustData.roles.filter(role => !roleOrder.includes(role)).sort(); const allRoles = [...orderedRoles, ...remainingRoles]; // For each role, create a section allRoles.forEach(role => { // Create section header const sectionHeader = document.createElement('div'); sectionHeader.className = 'role-section-header'; if (roleClasses[role]) { sectionHeader.classList.add(roleClasses[role]); } // Add expand/collapse icon const expandIcon = document.createElement('span'); expandIcon.className = 'expand-icon'; expandIcon.textContent = '−'; // Unicode minus sign (expanded by default) sectionHeader.appendChild(expandIcon); // Add role name const roleName = document.createElement('span'); roleName.textContent = role; sectionHeader.appendChild(roleName); // Add character count const charCount = trustData.charactersByRole[role] ? trustData.charactersByRole[role].length : 0; const countSpan = document.createElement('span'); countSpan.className = 'character-count'; countSpan.textContent = `(${charCount})`; sectionHeader.appendChild(countSpan); // Add section header to grid charactersGrid.appendChild(sectionHeader); // Create container for characters in this role const charactersContainer = document.createElement('div'); charactersContainer.className = 'role-characters-container'; charactersGrid.appendChild(charactersContainer); // Get characters for this role and filter by view let characters = trustData.charactersByRole[role] || []; characters = filterCharactersByView(characters); characters.sort(); // Sort character names alphabetically // Skip this role if no characters match the current view if (characters.length === 0) { // Remove the section header and container charactersGrid.removeChild(sectionHeader); charactersGrid.removeChild(charactersContainer); return; } // Update the character count to reflect filtered results countSpan.textContent = `(${characters.length})`; characters.forEach(name => { const fullCharacter = trustData.characters[name]; const card = document.createElement('div'); card.className = 'character-card'; // Add role class if (roleClasses[role]) { card.classList.add(roleClasses[role]); } // Add acquired class if the character is acquired if (fullCharacter && fullCharacter.acquired) { card.classList.add('acquired'); } card.dataset.name = name; card.textContent = name; // If this is the current character, mark it as active if (currentCharacter && currentCharacter.name === name) { card.classList.add('active'); } charactersContainer.appendChild(card); }); }); currentRoleHeading.textContent = 'All Characters'; } /** * Show characters for a specific role * @param {string} role - Role name */ function showCharactersByRole(role) { // Clear the grid charactersGrid.innerHTML = ''; // Show the synergy sets container document.getElementById('synergy-sets-container').style.display = 'block'; // Create section header const sectionHeader = document.createElement('div'); sectionHeader.className = 'role-section-header'; if (roleClasses[role]) { sectionHeader.classList.add(roleClasses[role]); } // Add expand/collapse icon const expandIcon = document.createElement('span'); expandIcon.className = 'expand-icon'; expandIcon.textContent = '−'; // Unicode minus sign (expanded by default) sectionHeader.appendChild(expandIcon); // Add role name const roleName = document.createElement('span'); roleName.textContent = role; sectionHeader.appendChild(roleName); // Add character count const charCount = trustData.charactersByRole[role] ? trustData.charactersByRole[role].length : 0; const countSpan = document.createElement('span'); countSpan.className = 'character-count'; countSpan.textContent = `(${charCount})`; sectionHeader.appendChild(countSpan); // Add section header to grid charactersGrid.appendChild(sectionHeader); // Create container for characters in this role const charactersContainer = document.createElement('div'); charactersContainer.className = 'role-characters-container'; charactersGrid.appendChild(charactersContainer); // Get characters for this role and filter by view let characters = trustData.charactersByRole[role] || []; characters = filterCharactersByView(characters); characters.sort(); // Sort character names alphabetically // If no characters match the current view, show a message if (characters.length === 0) { const noResults = document.createElement('div'); noResults.className = 'placeholder-message'; noResults.innerHTML = `

No ${currentView === 'acquired' ? 'acquired' : 'unacquired'} characters found in this role

`; charactersContainer.appendChild(noResults); // Update the character count to reflect filtered results countSpan.textContent = `(0)`; return; } // Update the character count to reflect filtered results countSpan.textContent = `(${characters.length})`; characters.forEach(name => { const fullCharacter = trustData.characters[name]; const card = document.createElement('div'); card.className = 'character-card'; // Add role class if (roleClasses[role]) { card.classList.add(roleClasses[role]); } // Add acquired class if the character is acquired if (fullCharacter && fullCharacter.acquired) { card.classList.add('acquired'); } card.dataset.name = name; card.textContent = name; // If this is the current character, mark it as active if (currentCharacter && currentCharacter.name === name) { card.classList.add('active'); } charactersContainer.appendChild(card); }); currentRoleHeading.textContent = `${role} Characters`; } /** * Show a list of characters in the grid, grouped by role * @param {Array} characters - Array of character objects */ function showCharacters(characters) { // Clear the grid charactersGrid.innerHTML = ''; // Show the synergy sets container document.getElementById('synergy-sets-container').style.display = 'block'; // Group characters by role const charactersByRole = {}; characters.forEach(char => { const fullCharacter = trustData.characters[char.name]; if (fullCharacter) { const role = fullCharacter.role || 'Unknown'; if (!charactersByRole[role]) { charactersByRole[role] = []; } charactersByRole[role].push(fullCharacter); } }); // Define the order of roles const roleOrder = [ 'Tank', 'Melee Fighter', 'Ranged Fighter', 'Offensive Caster', 'Healer', 'Support', 'Unity Concord', 'Special' ]; // Filter roles that exist in the search results const orderedRoles = roleOrder.filter(role => charactersByRole[role] && charactersByRole[role].length > 0); // Add any remaining roles that weren't in the specified order const remainingRoles = Object.keys(charactersByRole) .filter(role => !roleOrder.includes(role)) .sort(); const allRoles = [...orderedRoles, ...remainingRoles]; // For each role with characters, create a section allRoles.forEach(role => { const charactersInRole = charactersByRole[role]; // Skip if no characters in this role if (!charactersInRole || charactersInRole.length === 0) return; // Create section header const sectionHeader = document.createElement('div'); sectionHeader.className = 'role-section-header'; if (roleClasses[role]) { sectionHeader.classList.add(roleClasses[role]); } // Add expand/collapse icon const expandIcon = document.createElement('span'); expandIcon.className = 'expand-icon'; expandIcon.textContent = '−'; // Unicode minus sign (expanded by default) sectionHeader.appendChild(expandIcon); // Add role name const roleName = document.createElement('span'); roleName.textContent = role; sectionHeader.appendChild(roleName); // Add character count const countSpan = document.createElement('span'); countSpan.className = 'character-count'; countSpan.textContent = `(${charactersInRole.length})`; sectionHeader.appendChild(countSpan); // Add section header to grid charactersGrid.appendChild(sectionHeader); // Create container for characters in this role const charactersContainer = document.createElement('div'); charactersContainer.className = 'role-characters-container'; charactersGrid.appendChild(charactersContainer); // Sort characters by name charactersInRole.sort((a, b) => a.name.localeCompare(b.name)); // Add characters for this role charactersInRole.forEach(character => { const card = document.createElement('div'); card.className = 'character-card'; // Add role class if (roleClasses[role]) { card.classList.add(roleClasses[role]); } // Add acquired class if the character is acquired if (character.acquired) { card.classList.add('acquired'); } card.dataset.name = character.name; card.textContent = character.name; // If this is the current character, mark it as active if (currentCharacter && currentCharacter.name === character.name) { card.classList.add('active'); } charactersContainer.appendChild(card); }); }); // If no characters were found, show a message if (allRoles.length === 0) { const noResults = document.createElement('div'); noResults.className = 'placeholder-message'; noResults.innerHTML = '

No characters found

'; charactersGrid.appendChild(noResults); } } /** * Show detailed information for a character * @param {string} name - Character name */ function showCharacterDetails(name) { const character = trustData.characters[name]; if (!character) { characterInfo.innerHTML = '

Character not found

'; return; } // Update current character currentCharacter = character; // Mark the character card as active const cards = document.querySelectorAll('.character-card'); cards.forEach(card => { if (card.dataset.name === name) { card.classList.add('active'); } else { card.classList.remove('active'); } }); // Helper function to safely format text content const formatText = (text) => { if (!text) return ''; // Replace HTML entities to prevent XSS return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; // Helper function to format grid items (spells, abilities, weapon skills) const formatGridItems = (text) => { if (!text) return ''; // Split by newlines or commas const items = text.split(/[\n,]+/).map(item => item.trim()).filter(item => item); if (items.length === 0) return ''; return `
${ items.map(item => `
${formatText(item)}
`).join('') }
`; }; // Helper function to format bullet lists (acquisition, special features, trust synergy) const formatBulletList = (text) => { if (!text) return ''; // Split by newlines or periods followed by a space or newline const items = text.split(/\.\s+|\n+/).map(item => item.trim()).filter(item => item); if (items.length === 0) return ''; return ``; }; // Build the HTML for the character details let html = `

${formatText(character.name)}

${character.altName ? `

Also known as: ${formatText(character.altName)}

` : ''}

Role

${formatText(character.role)}

`; // Add job information if available if (character.job) { html += `

Job

${formatText(character.job)}

`; } // Add spells if available if (character.spells) { html += `

Spells

${formatGridItems(character.spells)}
`; } // Add abilities if available if (character.abilities) { html += `

Abilities

${formatGridItems(character.abilities)}
`; } // Add weapon skills if available if (character.weaponSkills) { html += `

Weapon Skills

${formatGridItems(character.weaponSkills)}
`; } // Add acquisition information if available if (character.acquisition) { html += `

Acquisition

${formatBulletList(character.acquisition)}
`; } // Add special features if available if (character.specialFeatures) { html += `

Special Features

${formatBulletList(character.specialFeatures)}
`; } // Add trust synergy if available if (character.trustSynergy) { html += `

Trust Synergy

${formatBulletList(character.trustSynergy)}
`; // Add trust synergy names if available if (character.trustSynergyNames && character.trustSynergyNames.length > 0) { html += `

Synergy Characters

${character.trustSynergyNames.map(name => { const synergyChar = trustData.characters[name]; const roleClass = synergyChar && roleClasses[synergyChar.role] ? roleClasses[synergyChar.role] : ''; return `${name}`; }).join('')}
`; } } // Update the character info container characterInfo.innerHTML = html; } /** * Show only synergy sets in the characters grid */ function showOnlySynergySets() { // Clear the grid charactersGrid.innerHTML = ''; // Hide the synergy sets container since we're showing them in the main grid document.getElementById('synergy-sets-container').style.display = 'none'; // Get synergy sets const synergySets = identifySynergySets(); // If no synergy sets, show a message if (synergySets.length === 0) { const noResults = document.createElement('div'); noResults.className = 'placeholder-message'; noResults.innerHTML = '

No synergy sets found

'; charactersGrid.appendChild(noResults); return; } // Add each synergy set synergySets.forEach(set => { // Create section header const sectionHeader = document.createElement('div'); sectionHeader.className = 'role-section-header Synergy'; // Add expand/collapse icon const expandIcon = document.createElement('span'); expandIcon.className = 'expand-icon'; expandIcon.textContent = '−'; // Unicode minus sign (expanded by default) sectionHeader.appendChild(expandIcon); // Add set name const setName = document.createElement('span'); setName.textContent = set.name; sectionHeader.appendChild(setName); // Add character count const countSpan = document.createElement('span'); countSpan.className = 'character-count'; countSpan.textContent = `(${set.characters.length})`; sectionHeader.appendChild(countSpan); // Add section header to grid charactersGrid.appendChild(sectionHeader); // Create container for characters in this set const charactersContainer = document.createElement('div'); charactersContainer.className = 'role-characters-container'; charactersGrid.appendChild(charactersContainer); // Filter characters by view let characters = set.characters; if (currentView !== 'all') { characters = filterCharactersByView(characters); } // Skip this set if no characters match the current view if (characters.length === 0) { // Remove the section header and container charactersGrid.removeChild(sectionHeader); charactersGrid.removeChild(charactersContainer); return; } // Update the character count to reflect filtered results countSpan.textContent = `(${characters.length})`; // Add characters for this set characters.forEach(name => { const fullCharacter = trustData.characters[name]; if (!fullCharacter) return; const card = document.createElement('div'); card.className = 'character-card'; // Add role class if (fullCharacter.role && roleClasses[fullCharacter.role]) { card.classList.add(roleClasses[fullCharacter.role]); } // Add acquired class if the character is acquired if (fullCharacter.acquired) { card.classList.add('acquired'); } card.dataset.name = name; card.textContent = name; // If this is the current character, mark it as active if (currentCharacter && currentCharacter.name === name) { card.classList.add('active'); } charactersContainer.appendChild(card); }); }); // Hide the synergy sets container since we're showing them in the main grid document.getElementById('synergy-sets-container').style.display = 'none'; currentRoleHeading.textContent = 'Synergy Sets'; } /** * Filter characters based on the current view (all, acquired, unacquired) * @param {Array} characters - Array of character objects or names * @returns {Array} - Filtered array of character objects */ function filterCharactersByView(characters) { if (currentView === 'all') { return characters; } return characters.filter(char => { const fullCharacter = typeof char === 'string' ? trustData.characters[char] : (char.name ? trustData.characters[char.name] : char); if (!fullCharacter) return false; return currentView === 'acquired' ? fullCharacter.acquired : !fullCharacter.acquired; }); } /** * Set up event listeners for user interactions */ function setupEventListeners() { // View toggle const viewToggleButtons = document.querySelectorAll('.view-toggle-btn'); viewToggleButtons.forEach(button => { button.addEventListener('click', () => { // Update active class viewToggleButtons.forEach(btn => btn.classList.remove('active')); button.classList.add('active'); // Update current view currentView = button.dataset.view; // Refresh the display if (currentRole === 'All') { showAllCharacters(); } else { showCharactersByRole(currentRole); } // Update synergy sets showSynergySets(); // Update the heading to reflect the current view let viewText = ''; switch (currentView) { case 'acquired': viewText = ' (Acquired)'; break; case 'unacquired': viewText = ' (Unacquired)'; break; } if (currentRoleHeading.textContent.includes('Search Results')) { // Don't modify search results heading } else if (currentRole === 'All') { currentRoleHeading.textContent = `All Characters${viewText}`; } else { currentRoleHeading.textContent = `${currentRole} Characters${viewText}`; } }); }); // Role selection rolesList.addEventListener('click', event => { const li = event.target.closest('li'); if (!li) return; // Update active class document.querySelectorAll('#roles-list li').forEach(item => { item.classList.remove('active'); }); li.classList.add('active'); // Show characters for the selected role const role = li.dataset.role; currentRole = role; if (role === 'All') { showAllCharacters(); } else if (role === 'Synergy') { showOnlySynergySets(); } else { showCharactersByRole(role); } // Update synergy sets if not showing only synergy sets if (role !== 'Synergy') { showSynergySets(); } }); // Character selection charactersGrid.addEventListener('click', event => { const card = event.target.closest('.character-card'); if (!card) return; const name = card.dataset.name; showCharacterDetails(name); }); // Search searchInput.addEventListener('input', event => { const query = event.target.value.trim(); if (query === '') { // If search is cleared, show characters for the current role if (currentRole === 'All') { showAllCharacters(); } else { showCharactersByRole(currentRole); } } else { // Search for characters and filter by view let results = trustParser.searchCharacters(query); // Only filter by view if not showing all characters if (currentView !== 'all') { results = filterCharactersByView(results); currentRoleHeading.textContent = `Search Results: "${query}" (${currentView === 'acquired' ? 'Acquired' : 'Unacquired'})`; } else { currentRoleHeading.textContent = `Search Results: "${query}"`; } showCharacters(results); // Update synergy sets showSynergySets(); } }); // Synergy character click in character details characterInfo.addEventListener('click', event => { const synergyChar = event.target.closest('.synergy-character'); if (synergyChar) { const name = synergyChar.dataset.name; if (name) { showCharacterDetails(name); // Scroll to the top of the character details characterInfo.scrollTop = 0; } } }); // Synergy set character click document.getElementById('synergy-sets').addEventListener('click', event => { const synergyChar = event.target.closest('.synergy-set-character'); if (synergyChar) { const name = synergyChar.dataset.name; if (name) { showCharacterDetails(name); // Scroll to the top of the character details characterInfo.scrollTop = 0; } } }); // Acquired checkbox toggle characterInfo.addEventListener('change', async event => { if (event.target.id === 'acquired-checkbox') { const id = parseInt(event.target.dataset.id); try { // Show loading state event.target.disabled = true; // Toggle the acquired status const updatedChar = await trustParser.toggleAcquired(id); // Update the checkbox event.target.checked = updatedChar.acquired; // Update the character card if it has an acquired class const cards = document.querySelectorAll('.character-card'); cards.forEach(card => { if (card.dataset.name === updatedChar.name) { if (updatedChar.acquired) { card.classList.add('acquired'); } else { card.classList.remove('acquired'); } } }); // Update synergy set characters const synergySetChars = document.querySelectorAll('.synergy-set-character'); synergySetChars.forEach(char => { if (char.dataset.name === updatedChar.name) { if (updatedChar.acquired) { char.classList.add('acquired'); } else { char.classList.remove('acquired'); } } }); // If we're in a filtered view, refresh the display if (currentView !== 'all') { if (currentRole === 'All') { showAllCharacters(); } else { showCharactersByRole(currentRole); } // Update synergy sets showSynergySets(); } } catch (error) { console.error('Error toggling acquired status:', error); // Revert the checkbox state event.target.checked = !event.target.checked; alert('Failed to update acquired status. Please try again.'); } finally { // Re-enable the checkbox event.target.disabled = false; } } }); // Collapsible section headers charactersGrid.addEventListener('click', event => { const header = event.target.closest('.role-section-header'); if (!header) return; // Toggle the collapsed state const container = header.nextElementSibling; if (container && container.classList.contains('role-characters-container')) { container.classList.toggle('collapsed'); // Update the expand/collapse icon const icon = header.querySelector('.expand-icon'); if (icon) { if (container.classList.contains('collapsed')) { icon.textContent = '+'; // Plus sign for collapsed } else { icon.textContent = '−'; // Minus sign for expanded } } } }); } // Initialize the application init(); });