Files

656 lines
23 KiB
JavaScript

// Project Friction - Main Application
// Museum-quality digital exhibit for Canada's Gulf War role
class ProjectFriction {
constructor() {
this.currentSection = null;
this.map = null;
this.mapLayers = {};
this.timeline = null;
this.data = {
sections: [],
mapData: {},
timelineEvents: [],
articles: {}
};
this.init();
}
async init() {
try {
// Show loading screen
this.showLoadingScreen();
// Load data
await this.loadApplicationData();
// Initialize components
this.initializeMap();
this.initializeNavigation();
this.initializeTimeline();
this.initializeInteractions();
// Setup event listeners
this.setupEventListeners();
// Load initial state
this.loadMainNavigation();
// Hide loading screen
this.hideLoadingScreen();
console.log('Project Friction initialized successfully');
} catch (error) {
console.error('Error initializing Project Friction:', error);
this.showErrorMessage('Failed to load exhibition. Please refresh the page.');
}
}
async loadApplicationData() {
// Load optional datasets without failing app startup if files are missing.
this.data.sections = await this.loadOptionalJSON(['data/sections.json'], []);
this.data.mapData = await this.loadOptionalJSON(['data/map-data.json', 'map-data.json'], {});
this.data.timelineEvents = await this.loadOptionalJSON(['data/timeline-events.json'], []);
this.data.articles = await this.loadOptionalJSON(['data/articles.json'], {});
// Load block-based section content (used by the block renderer)
this.data.sectionsContent = await this.loadOptionalJSON(['data/sections-content.json'], {});
if (!Array.isArray(this.data.sections) || this.data.sections.length === 0) {
this.loadDefaultConfiguration();
}
this.mergeBuilderSectionsFromStorage();
}
mergeBuilderSectionsFromStorage() {
if (typeof localStorage === 'undefined') return;
var raw = localStorage.getItem('pf-builder-sections');
if (!raw) return;
try {
var parsed = JSON.parse(raw);
var order = Array.isArray(parsed.order) ? parsed.order : Object.keys(parsed.sections || {});
var sectionMeta = parsed.sections || {};
if (!Array.isArray(this.data.sections)) this.data.sections = [];
var existingById = {};
this.data.sections.forEach(function(section) {
existingById[section.id] = section;
});
order.forEach(function(id) {
var meta = sectionMeta[id];
if (!meta) return;
var stats = {
articles: parseInt(meta.stats && meta.stats.articles, 10) || 0,
storyMaps: parseInt(meta.stats && meta.stats.storyMaps, 10) || 0,
images: parseInt(meta.stats && meta.stats.images, 10) || 0
};
if (existingById[id]) {
existingById[id].title = meta.title || existingById[id].title;
existingById[id].subtitle = meta.subtitle || existingById[id].subtitle;
existingById[id].description = meta.description || existingById[id].description;
existingById[id].icon = meta.icon || existingById[id].icon;
existingById[id].color = meta.color || existingById[id].color;
existingById[id].stats = stats;
} else {
var newSection = {
id: id,
title: meta.title || id,
subtitle: meta.subtitle || '',
icon: meta.icon || '📚',
color: meta.color || '#7f8c8d',
description: meta.description || '',
stats: stats
};
this.data.sections.push(newSection);
existingById[id] = newSection;
}
}, this);
// Keep display order aligned with builder tabs for kiosk consistency.
var reordered = [];
order.forEach(function(id) {
if (existingById[id]) reordered.push(existingById[id]);
});
this.data.sections.forEach(function(section) {
if (order.indexOf(section.id) === -1) reordered.push(section);
});
this.data.sections = reordered;
} catch (error) {
console.warn('Could not parse builder section metadata from localStorage.');
}
}
async loadJSON(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load ${url}: ${response.status}`);
}
return await response.json();
}
async loadOptionalJSON(paths, fallbackValue) {
for (const path of paths) {
try {
return await this.loadJSON(path);
} catch (error) {
console.warn(`Optional data file not available: ${path}`);
}
}
return fallbackValue;
}
loadDefaultConfiguration() {
// Fallback configuration if data files fail to load
this.data.sections = [
{
id: 'context',
title: 'Historical Context',
subtitle: 'Coalition Building & Diplomacy',
icon: '🌍',
color: '#d4a574',
description: 'Understand how Iraq\'s invasion of Kuwait triggered the largest military coalition since WWII and Canada\'s diplomatic role.',
stats: { articles: 7, storyMaps: 3, images: 45 }
},
{
id: 'rcaf',
title: 'RCAF Desert Cats',
subtitle: '409 Squadron Air Operations',
icon: '✈️',
color: '#3498db',
description: 'Follow the Desert Cats from defensive patrols to strike missions, including the TNC-45 engagement and bombing runs.',
stats: { articles: 8, storyMaps: 4, images: 60 }
},
{
id: 'rcn',
title: 'RCN Task Group',
subtitle: 'Naval Blockade Operations',
icon: '⚓',
color: '#2c3e50',
description: 'The transformation of HMCS Terra Nova into a guided-missile destroyer and enforcement of UN sanctions.',
stats: { articles: 7, storyMaps: 2, images: 35 }
},
{
id: 'army',
title: 'Canadian Army',
subtitle: 'Medical & Security Forces',
icon: '🏥',
color: '#27ae60',
description: '1 Canadian Field Hospital\'s life-saving work and security operations protecting coalition installations.',
stats: { articles: 7, storyMaps: 3, images: 40 }
},
{
id: 'legacy',
title: 'After the War',
subtitle: 'Veterans & Long-term Impact',
icon: '🍁',
color: '#c0392b',
description: 'Gulf War Syndrome, environmental cleanup, peacekeeping missions, and the ongoing support for veterans.',
stats: { articles: 7, storyMaps: 2, images: 30 }
}
];
// Default stats for main navigation
this.data.globalStats = {
personnel: '4,500+',
sorties: '2,700',
medals: '4,449',
duration: '7'
};
}
initializeMap() {
if (!window.L) {
console.warn('Leaflet is unavailable. Map features are disabled.');
return;
}
const mapElement = document.getElementById('map');
if (!mapElement) {
console.warn('Map container not found.');
return;
}
// Initialize Leaflet map
this.map = L.map('map', {
center: [29.3759, 47.9774], // Kuwait City
zoom: 6,
zoomControl: false
});
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 18
}).addTo(this.map);
// Initialize layer groups
this.mapLayers = {
coalition: L.layerGroup(),
iraqi: L.layerGroup(),
canadian: L.layerGroup(),
events: L.layerGroup(),
routes: L.layerGroup()
};
// Add initial overview markers
this.loadOverviewMarkers();
}
loadOverviewMarkers() {
if (!this.map || !window.L) return;
// Add basic overview markers
const overviewMarkers = [
{ lat: 29.3759, lng: 47.9774, title: 'Kuwait City', type: 'liberation' },
{ lat: 25.2731, lng: 51.6080, title: 'Doha Air Base', type: 'canadian' },
{ lat: 26.2285, lng: 50.5860, title: 'Naval Operations', type: 'canadian' }
];
overviewMarkers.forEach(marker => {
const mapMarker = L.marker([marker.lat, marker.lng])
.bindPopup(`<strong>${marker.title}</strong>`)
.addTo(this.map);
});
}
initializeNavigation() {
if (typeof NavigationController === 'undefined') {
console.warn('NavigationController is unavailable. Using built-in navigation only.');
return;
}
this.navigation = new NavigationController(this);
this.navigation.init();
}
initializeTimeline() {
if (typeof TimelineController === 'undefined') {
console.warn('TimelineController is unavailable.');
return;
}
this.timeline = new TimelineController(this);
this.timeline.init();
}
initializeInteractions() {
if (typeof InteractionController === 'undefined') {
console.warn('InteractionController is unavailable.');
return;
}
this.interactions = new InteractionController(this);
this.interactions.init();
}
setupEventListeners() {
// Sidebar toggle
const sidebarToggle = document.getElementById('sidebarToggle');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', () => this.toggleSidebar());
}
// Back button
const backButton = document.getElementById('backButton');
if (backButton) {
backButton.addEventListener('click', () => this.showMainNavigation());
}
// Window resize handler
window.addEventListener('resize', () => this.handleResize());
// Keyboard navigation
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
}
loadMainNavigation() {
// Show main navigation
const mainNav = document.getElementById('mainNavigation');
const sectionContainer = document.getElementById('sectionContainer');
const backButton = document.getElementById('backButton');
if (mainNav) mainNav.classList.remove('hidden');
if (sectionContainer) sectionContainer.classList.add('hidden');
if (backButton) backButton.classList.remove('show');
// Load navigation cards and stats
this.renderQuickStats();
this.renderNavigationCards();
// Reset map to overview
this.resetMapView();
this.currentSection = null;
}
renderQuickStats() {
const statsContainer = document.getElementById('quickStats');
if (!statsContainer) return;
const stats = this.data.globalStats || {
personnel: '4,500+',
sorties: '2,700',
medals: '4,449',
duration: '7'
};
statsContainer.innerHTML = `
<div class="stat-card">
<div class="stat-number">${stats.personnel}</div>
<div class="stat-label">Personnel</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.sorties}</div>
<div class="stat-label">Sorties</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.medals}</div>
<div class="stat-label">Medals</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.duration}</div>
<div class="stat-label">Months</div>
</div>
`;
}
renderNavigationCards() {
const cardsContainer = document.getElementById('navigationCards');
if (!cardsContainer) return;
const cardsHTML = this.data.sections.map(section => `
<div class="nav-card" data-section="${section.id}">
<div class="nav-card-header">
<span class="nav-card-icon">${section.icon}</span>
<div class="nav-card-title">
<h3>${section.title}</h3>
<span>${section.subtitle}</span>
</div>
</div>
<div class="nav-card-preview">
${section.description}
</div>
<div class="nav-card-links">
<div class="nav-card-link">📄 ${section.stats.articles} Articles</div>
<div class="nav-card-link">🗺️ ${section.stats.storyMaps} Story Maps</div>
<div class="nav-card-link">📷 ${section.stats.images} Images</div>
</div>
</div>
`).join('');
cardsContainer.innerHTML = cardsHTML;
// Add click handlers
cardsContainer.querySelectorAll('.nav-card').forEach(card => {
card.addEventListener('click', () => {
const sectionId = card.dataset.section;
this.showSection(sectionId);
});
});
}
async showSection(sectionId) {
try {
const section = this.data.sections.find(s => s.id === sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
// Hide main navigation
const mainNav = document.getElementById('mainNavigation');
if (mainNav) mainNav.classList.add('hidden');
// Show section container and back button
const sectionContainer = document.getElementById('sectionContainer');
const backButton = document.getElementById('backButton');
if (sectionContainer) sectionContainer.classList.remove('hidden');
if (backButton) backButton.classList.add('show');
// Load section content
await this.loadSectionContent(section);
// Update map for section
this.updateMapForSection(section);
this.currentSection = sectionId;
} catch (error) {
console.error(`Error loading section ${sectionId}:`, error);
this.showErrorMessage(`Failed to load ${sectionId} section. Please try again.`);
}
}
async loadSectionContent(section) {
const sectionContainer = document.getElementById('sectionContainer');
if (!sectionContainer) return;
// 1. Check localStorage for builder-saved blocks
const saved = localStorage.getItem('pf-section-' + section.id);
if (saved) {
try {
const blocks = JSON.parse(saved);
if (typeof BlockRenderer !== 'undefined') {
sectionContainer.innerHTML = this.wrapSectionContent(section, BlockRenderer.render(blocks));
this.initializeSectionInteractions(section.id);
return;
}
} catch (e) { /* fall through */ }
}
// 2. Use sections-content.json block data
const contentData = this.data.sectionsContent && this.data.sectionsContent[section.id];
if (contentData && contentData.blocks && typeof BlockRenderer !== 'undefined') {
sectionContainer.innerHTML = this.wrapSectionContent(section, BlockRenderer.render(contentData.blocks));
this.initializeSectionInteractions(section.id);
return;
}
// 3. Try legacy HTML file
try {
const response = await fetch('sections/' + section.id + '.html');
if (!response.ok) throw new Error('No legacy HTML');
sectionContainer.innerHTML = await response.text();
this.initializeSectionInteractions(section.id);
} catch (error) {
// 4. Fallback placeholder
this.loadFallbackSectionContent(section);
}
}
wrapSectionContent(section, blocksHtml) {
return '<div class="section-content-inner" style="--theme-color:' + section.color + '">'
+ '<div class="section-page-header" style="border-left:4px solid ' + section.color + '; padding: 1rem 1.25rem; background:#f8f9fb; margin-bottom:1rem;">'
+ '<div style="display:flex;align-items:center;gap:0.75rem;">'
+ '<span style="font-size:28px">' + section.icon + '</span>'
+ '<div><h2 style="margin:0;font-size:18px;color:#2c3e50">' + section.title + '</h2>'
+ '<p style="margin:0;font-size:13px;color:#6c757d">' + section.subtitle + '</p></div>'
+ '</div></div>'
+ blocksHtml
+ '</div>';
}
loadFallbackSectionContent(section) {
const sectionContainer = document.getElementById('sectionContainer');
if (!sectionContainer) return;
sectionContainer.innerHTML = `
<div class="section-header" style="--theme-color: ${section.color};">
<div class="section-title">
<span class="section-icon">${section.icon}</span>
<span class="section-name">${section.title}</span>
</div>
<div class="section-subtitle">${section.subtitle}</div>
</div>
<div class="content-area">
<div class="article-section">
<h3>Content Loading</h3>
<p>This section is being developed. Please check back later for complete content.</p>
</div>
</div>
`;
}
initializeSectionInteractions(sectionId) {
if (typeof BlockRenderer !== 'undefined' && typeof BlockRenderer.initGalleries === 'function') {
BlockRenderer.initGalleries(document.getElementById('sectionContainer'));
}
// Wire map-button blocks to the Leaflet map
const self = this;
document.querySelectorAll('.block-map-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const lat = parseFloat(btn.dataset.lat);
const lng = parseFloat(btn.dataset.lng);
const zoom = parseInt(btn.dataset.zoom) || 6;
if (self.map) {
self.map.setView([lat, lng], zoom);
btn.classList.add('active');
setTimeout(function() { btn.classList.remove('active'); }, 1500);
}
});
});
}
updateMapForSection(section) {
if (!this.map) return;
// Clear existing layers
Object.values(this.mapLayers).forEach(layer => {
this.map.removeLayer(layer);
layer.clearLayers();
});
// Add section-specific map data
const sectionMapData = this.data.mapData[section.id];
if (sectionMapData) {
this.loadMapData(sectionMapData);
}
// Update map view for section
this.setMapViewForSection(section.id);
}
setMapViewForSection(sectionId) {
if (!this.map) return;
const mapViews = {
context: { center: [29.3759, 47.9774], zoom: 6 },
rcaf: { center: [25.2731, 51.6080], zoom: 8 },
rcn: { center: [27.0, 50.0], zoom: 7 },
army: { center: [32.0, 45.0], zoom: 6 },
legacy: { center: [35.0, 45.0], zoom: 5 }
};
const view = mapViews[sectionId] || mapViews.context;
this.map.setView(view.center, view.zoom);
}
loadMapData(mapData) {
if (!this.map || !window.L) return;
// Load markers, routes, and other geographic data
if (mapData.markers) {
mapData.markers.forEach(marker => {
const mapMarker = L.marker([marker.lat, marker.lng])
.bindPopup(`<strong>${marker.title}</strong><br>${marker.description || ''}`);
const layerGroup = this.mapLayers[marker.type] || this.mapLayers.events;
layerGroup.addLayer(mapMarker);
});
}
// Add all layers to map
Object.values(this.mapLayers).forEach(layer => {
this.map.addLayer(layer);
});
}
resetMapView() {
if (!this.map) return;
// Reset to overview
this.map.setView([29.3759, 47.9774], 6);
// Clear section-specific layers
Object.values(this.mapLayers).forEach(layer => {
this.map.removeLayer(layer);
layer.clearLayers();
});
// Reload overview markers
this.loadOverviewMarkers();
}
toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const toggleIcon = document.getElementById('toggle-icon');
if (sidebar && toggleIcon) {
sidebar.classList.toggle('collapsed');
toggleIcon.textContent = sidebar.classList.contains('collapsed') ? '◀' : '▶';
}
}
showMainNavigation() {
this.loadMainNavigation();
}
handleResize() {
// Handle responsive behavior
if (this.map) {
this.map.invalidateSize();
}
}
handleKeyboard(event) {
// Handle keyboard navigation
if (event.key === 'Escape') {
if (this.currentSection) {
this.showMainNavigation();
}
}
}
showLoadingScreen() {
const loadingScreen = document.getElementById('loadingScreen');
if (loadingScreen) {
loadingScreen.style.display = 'flex';
}
}
hideLoadingScreen() {
const loadingScreen = document.getElementById('loadingScreen');
if (loadingScreen) {
setTimeout(() => {
loadingScreen.classList.add('fade-out');
setTimeout(() => {
loadingScreen.style.display = 'none';
}, 500);
}, 1000);
}
}
showErrorMessage(message) {
console.error(message);
// In production, this would show a user-friendly error dialog
alert(message);
}
}
// Initialize the application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.projectFriction = new ProjectFriction();
});
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = ProjectFriction;
}