587 lines
20 KiB
JavaScript
587 lines
20 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();
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// 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;
|
|
} |