From 22759635ef8efac9a47a509dd132913d4397612a Mon Sep 17 00:00:00 2001 From: Akira Saito Date: Thu, 17 Jul 2025 05:43:24 +0900 Subject: [PATCH] update --- js/course-manager.js | 95 ++++++--- js/course-ui.js | 89 +++++++- js/main.js | 13 ++ js/performance-monitor.js | 424 +++++++++++++++++++++++++++++++++++++ js/progress-manager.js | 52 ++++- js/progress-ui.js | 82 +++++++- js/ui-optimizer.js | 425 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 1136 insertions(+), 44 deletions(-) create mode 100644 js/performance-monitor.js create mode 100644 js/ui-optimizer.js diff --git a/js/course-manager.js b/js/course-manager.js index d35656a..4dc38ba 100644 --- a/js/course-manager.js +++ b/js/course-manager.js @@ -69,45 +69,85 @@ class CourseManager { } /** - * 各コースのチャレンジデータを読み込み + * 各コースのチャレンジデータを読み込み(遅延読み込み対応) */ async loadChallengeData() { + // 遅延読み込み: 現在選択されているコースのみ読み込み + const selectedCourseId = this.progressManager?.getSelectedCourse(); + const challengeFiles = { 'sql-basics': 'slides/challenges.json', 'db-fundamentals': 'slides/db-fundamentals-challenges.json', 'big-data-basics': 'slides/big-data-basics-challenges.json' }; - for (const [courseId, filePath] of Object.entries(challengeFiles)) { - try { - const response = await fetch(filePath); - if (response.ok) { - this.challengeData[courseId] = await response.json(); - } else { - console.warn(`チャレンジファイルが見つかりません: ${filePath}`); - this.challengeData[courseId] = []; - } - } catch (error) { - console.error(`チャレンジデータ読み込みエラー (${courseId}):`, error); + // 選択されたコースがある場合は、そのコースのみ読み込み + if (selectedCourseId && challengeFiles[selectedCourseId]) { + await this.loadCourseChallenge(selectedCourseId, challengeFiles[selectedCourseId]); + } else { + // 初回アクセス時は基本コースのみ読み込み + await this.loadCourseChallenge('sql-basics', challengeFiles['sql-basics']); + } + } + + /** + * 特定コースのチャレンジデータを読み込み + */ + async loadCourseChallenge(courseId, filePath) { + // 既に読み込み済みの場合はスキップ + if (this.challengeData[courseId]) { + return this.challengeData[courseId]; + } + + try { + const response = await fetch(filePath); + if (response.ok) { + this.challengeData[courseId] = await response.json(); + console.log(`チャレンジデータを読み込みました: ${courseId}`); + } else { + console.warn(`チャレンジファイルが見つかりません: ${filePath}`); + this.challengeData[courseId] = []; + } + } catch (error) { + console.error(`チャレンジデータ読み込みエラー (${courseId}):`, error); + + // ErrorHandlerを使用してチャレンジデータエラーを処理 + if (window.errorHandler) { + const result = await window.errorHandler.handleError('CHALLENGE_LOAD_ERROR', error, { + courseId: courseId, + challengeFile: filePath, + operation: 'loadCourseChallenge' + }); - // ErrorHandlerを使用してチャレンジデータエラーを処理 - if (window.errorHandler) { - const result = await window.errorHandler.handleError('CHALLENGE_LOAD_ERROR', error, { - courseId: courseId, - challengeFile: filePath, - operation: 'loadChallengeData' - }); - - if (result.success) { - this.challengeData[courseId] = result.data; - } else { - this.challengeData[courseId] = []; - } + if (result.success) { + this.challengeData[courseId] = result.data; } else { this.challengeData[courseId] = []; } + } else { + this.challengeData[courseId] = []; } } + + return this.challengeData[courseId]; + } + + /** + * 必要に応じてコースのチャレンジデータを遅延読み込み + */ + async ensureCourseDataLoaded(courseId) { + if (!this.challengeData[courseId]) { + const challengeFiles = { + 'sql-basics': 'slides/challenges.json', + 'db-fundamentals': 'slides/db-fundamentals-challenges.json', + 'big-data-basics': 'slides/big-data-basics-challenges.json' + }; + + if (challengeFiles[courseId]) { + await this.loadCourseChallenge(courseId, challengeFiles[courseId]); + } + } + return this.challengeData[courseId] || []; } /** @@ -169,12 +209,15 @@ class CourseManager { /** * コースを選択 */ - selectCourse(courseId) { + async selectCourse(courseId) { const course = this.getCourse(courseId); if (!course) { throw new Error(`コースが見つかりません: ${courseId}`); } + // コースのチャレンジデータを遅延読み込み + await this.ensureCourseDataLoaded(courseId); + this.currentCourse = course; this.progressManager.saveSelectedCourse(courseId); diff --git a/js/course-ui.js b/js/course-ui.js index 74cfa48..f94d162 100644 --- a/js/course-ui.js +++ b/js/course-ui.js @@ -11,9 +11,28 @@ export class CourseUI { courseList: document.getElementById('course-list'), appLayout: document.querySelector('.app-layout') }; + + // UIOptimizerを初期化 + this.uiOptimizer = null; + this.initializeUIOptimizer(); + this.bindEvents(); } + /** + * UIOptimizerを初期化 + */ + async initializeUIOptimizer() { + try { + const { UIOptimizer } = await import('./ui-optimizer.js'); + this.uiOptimizer = new UIOptimizer(); + this.uiOptimizer.initialize(); + console.log('CourseUI: UIOptimizerが初期化されました'); + } catch (error) { + console.warn('UIOptimizerの読み込みに失敗しました:', error); + } + } + /** * イベントリスナーを設定 */ @@ -47,7 +66,7 @@ export class CourseUI { } /** - * コース一覧を描画 + * コース一覧を描画(最適化版) */ renderCourseList(courses) { if (!courses || courses.length === 0) { @@ -55,11 +74,20 @@ export class CourseUI { return; } - const courseCards = courses.map(course => this.createCourseCard(course)).join(''); - this.elements.courseList.innerHTML = courseCards; - - // コース選択ボタンのイベントリスナーを設定 - this.bindCourseSelectionEvents(); + // UIOptimizerが利用可能な場合は最適化された更新を使用 + if (this.uiOptimizer) { + this.uiOptimizer.updateCourseSelection(courses, 'high'); + + // イベントリスナーを遅延設定 + setTimeout(() => { + this.bindCourseSelectionEvents(); + }, 50); + } else { + // フォールバック: 従来の方法 + const courseCards = courses.map(course => this.createCourseCard(course)).join(''); + this.elements.courseList.innerHTML = courseCards; + this.bindCourseSelectionEvents(); + } } /** @@ -185,21 +213,25 @@ export class CourseUI { } /** - * コースを選択して学習を開始 + * コースを選択して学習を開始(最適化版) */ async selectCourse(courseId) { try { + // ローディング状態を表示 + this.showCourseLoadingState(courseId); + // コースを取得 const course = this.courseManager.getCourse(courseId); // 準備中のコースかチェック if (course.status === 'coming_soon') { alert('このコースは現在準備中です。近日公開予定です。'); + this.hideCourseLoadingState(); return; } - // コースを選択 - const selectedCourse = this.courseManager.selectCourse(courseId); + // コースを選択(遅延読み込み対応) + const selectedCourse = await this.courseManager.selectCourse(courseId); console.log(`コースを選択しました: ${selectedCourse.title}`); // GameEngineにコースを設定 @@ -220,17 +252,52 @@ export class CourseUI { window.progressUI.onCourseSelected(course); } - // チャレンジを更新 + // チャレンジを更新(非同期で実行) if (window.uiController && typeof window.uiController.updateChallenge === 'function') { - window.uiController.updateChallenge(); + // UI更新を次のフレームで実行 + requestAnimationFrame(() => { + window.uiController.updateChallenge(); + }); } + this.hideCourseLoadingState(); + } catch (error) { console.error('コース選択エラー:', error); + this.hideCourseLoadingState(); alert(`コース選択に失敗しました: ${error.message}`); } } + /** + * コース読み込み状態を表示 + */ + showCourseLoadingState(courseId) { + const courseCard = document.querySelector(`[data-course-id="${courseId}"]`); + if (courseCard) { + const selectBtn = courseCard.querySelector('.course-select-btn'); + if (selectBtn) { + selectBtn.disabled = true; + selectBtn.textContent = '読み込み中...'; + } + } + } + + /** + * コース読み込み状態を非表示 + */ + hideCourseLoadingState() { + const selectBtns = document.querySelectorAll('.course-select-btn'); + selectBtns.forEach(btn => { + btn.disabled = false; + const courseId = btn.dataset.courseId; + const course = this.courseManager.getCourse(courseId); + const progress = this.courseManager.getCourseProgress(courseId); + const isStarted = progress && progress.completedLessons.length > 0; + btn.textContent = isStarted ? '続きから学習' : 'コースを開始'; + }); + } + /** * コース詳細情報を表示 */ diff --git a/js/main.js b/js/main.js index a6c66c2..dcfe9ad 100644 --- a/js/main.js +++ b/js/main.js @@ -10,6 +10,8 @@ import { ErrorHandler } from './error-handler.js'; import { NotificationSystem } from './notification-system.js'; import { AdaptiveLearning } from './adaptive-learning.js'; import { AdaptiveLearningUI } from './adaptive-learning-ui.js'; +import { UIOptimizer } from './ui-optimizer.js'; +import { PerformanceMonitor } from './performance-monitor.js'; // グローバル変数 @@ -21,6 +23,8 @@ let courseUI; let progressUI; let adaptiveLearning; let adaptiveLearningUI; +let uiOptimizer; +let performanceMonitor; // アプリケーション初期化 async function initializeApp() { @@ -66,11 +70,20 @@ async function initializeApp() { // 既存のチャレンジ読み込み(フォールバック用) await gameEngine.loadChallenges(); + // UIOptimizer初期化 + uiOptimizer = new UIOptimizer(); + uiOptimizer.initialize(); + + // PerformanceMonitor初期化 + performanceMonitor = new PerformanceMonitor(); + // グローバルアクセス用 window.dbManager = dbManager; window.gameEngine = gameEngine; window.courseManager = courseManager; window.adaptiveLearning = adaptiveLearning; + window.uiOptimizer = uiOptimizer; + window.performanceMonitor = performanceMonitor; // UIコントローラー初期化 const autoComplete = new SQLAutoComplete(); diff --git a/js/performance-monitor.js b/js/performance-monitor.js new file mode 100644 index 0000000..3ca352b --- /dev/null +++ b/js/performance-monitor.js @@ -0,0 +1,424 @@ +/** + * PerformanceMonitor - パフォーマンス監視クラス + * アプリケーションのパフォーマンス指標を監視し、最適化の効果を測定 + */ +export class PerformanceMonitor { + constructor() { + this.metrics = new Map(); + this.observers = []; + this.isEnabled = true; + this.reportInterval = 30000; // 30秒間隔でレポート + this.startTime = performance.now(); + + this.initializeObservers(); + this.startPeriodicReporting(); + } + + /** + * パフォーマンス監視を初期化 + */ + initializeObservers() { + // Long Task Observer (長時間実行タスクの監視) + if ('PerformanceObserver' in window) { + try { + const longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + this.recordMetric('longTask', { + duration: entry.duration, + startTime: entry.startTime, + name: entry.name + }); + } + }); + longTaskObserver.observe({ entryTypes: ['longtask'] }); + this.observers.push(longTaskObserver); + } catch (error) { + console.warn('Long Task Observer not supported:', error); + } + } + + // Navigation Timing (ページ読み込み時間の監視) + if (performance.navigation) { + this.recordNavigationTiming(); + } + + // Memory Usage (メモリ使用量の監視) + if (performance.memory) { + this.startMemoryMonitoring(); + } + } + + /** + * ナビゲーションタイミングを記録 + */ + recordNavigationTiming() { + const navigation = performance.getEntriesByType('navigation')[0]; + if (navigation) { + this.recordMetric('navigation', { + domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart, + loadComplete: navigation.loadEventEnd - navigation.loadEventStart, + domInteractive: navigation.domInteractive - navigation.navigationStart, + firstPaint: this.getFirstPaintTime(), + firstContentfulPaint: this.getFirstContentfulPaintTime() + }); + } + } + + /** + * First Paint時間を取得 + */ + getFirstPaintTime() { + const paintEntries = performance.getEntriesByType('paint'); + const firstPaint = paintEntries.find(entry => entry.name === 'first-paint'); + return firstPaint ? firstPaint.startTime : null; + } + + /** + * First Contentful Paint時間を取得 + */ + getFirstContentfulPaintTime() { + const paintEntries = performance.getEntriesByType('paint'); + const firstContentfulPaint = paintEntries.find(entry => entry.name === 'first-contentful-paint'); + return firstContentfulPaint ? firstContentfulPaint.startTime : null; + } + + /** + * メモリ監視を開始 + */ + startMemoryMonitoring() { + setInterval(() => { + if (performance.memory) { + this.recordMetric('memory', { + usedJSHeapSize: performance.memory.usedJSHeapSize, + totalJSHeapSize: performance.memory.totalJSHeapSize, + jsHeapSizeLimit: performance.memory.jsHeapSizeLimit, + timestamp: Date.now() + }); + } + }, 10000); // 10秒間隔 + } + + /** + * メトリクスを記録 + */ + recordMetric(category, data) { + if (!this.isEnabled) return; + + if (!this.metrics.has(category)) { + this.metrics.set(category, []); + } + + const entry = { + ...data, + timestamp: Date.now(), + relativeTime: performance.now() - this.startTime + }; + + this.metrics.get(category).push(entry); + + // 古いデータを削除(最新100件を保持) + const categoryMetrics = this.metrics.get(category); + if (categoryMetrics.length > 100) { + categoryMetrics.splice(0, categoryMetrics.length - 100); + } + } + + /** + * 操作のパフォーマンスを測定 + */ + measureOperation(name, operation) { + const startTime = performance.now(); + const startMark = `${name}-start`; + const endMark = `${name}-end`; + const measureName = `${name}-duration`; + + performance.mark(startMark); + + const result = operation(); + + if (result && typeof result.then === 'function') { + // 非同期操作の場合 + return result.then( + (value) => { + performance.mark(endMark); + performance.measure(measureName, startMark, endMark); + + const duration = performance.now() - startTime; + this.recordMetric('operations', { + name, + duration, + success: true, + async: true + }); + + return value; + }, + (error) => { + performance.mark(endMark); + performance.measure(measureName, startMark, endMark); + + const duration = performance.now() - startTime; + this.recordMetric('operations', { + name, + duration, + success: false, + error: error.message, + async: true + }); + + throw error; + } + ); + } else { + // 同期操作の場合 + performance.mark(endMark); + performance.measure(measureName, startMark, endMark); + + const duration = performance.now() - startTime; + this.recordMetric('operations', { + name, + duration, + success: true, + async: false + }); + + return result; + } + } + + /** + * UI更新のパフォーマンスを測定 + */ + measureUIUpdate(elementKey, updateFunction) { + return this.measureOperation(`ui-update-${elementKey}`, updateFunction); + } + + /** + * データ読み込みのパフォーマンスを測定 + */ + measureDataLoad(dataType, loadFunction) { + return this.measureOperation(`data-load-${dataType}`, loadFunction); + } + + /** + * 定期的なパフォーマンスレポートを開始 + */ + startPeriodicReporting() { + setInterval(() => { + this.generatePerformanceReport(); + }, this.reportInterval); + } + + /** + * パフォーマンスレポートを生成 + */ + generatePerformanceReport() { + const report = { + timestamp: new Date().toISOString(), + uptime: performance.now() - this.startTime, + metrics: this.getMetricsSummary(), + recommendations: this.generateRecommendations() + }; + + console.log('📊 Performance Report:', report); + + // パフォーマンス問題があれば警告 + if (report.recommendations.length > 0) { + console.warn('⚠️ Performance Issues Detected:', report.recommendations); + } + + return report; + } + + /** + * メトリクスサマリーを取得 + */ + getMetricsSummary() { + const summary = {}; + + for (const [category, entries] of this.metrics.entries()) { + if (entries.length === 0) continue; + + switch (category) { + case 'operations': + summary[category] = this.summarizeOperations(entries); + break; + case 'memory': + summary[category] = this.summarizeMemory(entries); + break; + case 'longTask': + summary[category] = this.summarizeLongTasks(entries); + break; + default: + summary[category] = { + count: entries.length, + latest: entries[entries.length - 1] + }; + } + } + + return summary; + } + + /** + * 操作メトリクスをサマリー + */ + summarizeOperations(entries) { + const durations = entries.map(e => e.duration); + const successCount = entries.filter(e => e.success).length; + + return { + total: entries.length, + successRate: (successCount / entries.length * 100).toFixed(1) + '%', + averageDuration: (durations.reduce((a, b) => a + b, 0) / durations.length).toFixed(2) + 'ms', + maxDuration: Math.max(...durations).toFixed(2) + 'ms', + minDuration: Math.min(...durations).toFixed(2) + 'ms', + slowOperations: entries.filter(e => e.duration > 100).length + }; + } + + /** + * メモリメトリクスをサマリー + */ + summarizeMemory(entries) { + const latest = entries[entries.length - 1]; + const first = entries[0]; + + return { + current: { + used: (latest.usedJSHeapSize / 1024 / 1024).toFixed(2) + 'MB', + total: (latest.totalJSHeapSize / 1024 / 1024).toFixed(2) + 'MB', + limit: (latest.jsHeapSizeLimit / 1024 / 1024).toFixed(2) + 'MB' + }, + growth: { + used: ((latest.usedJSHeapSize - first.usedJSHeapSize) / 1024 / 1024).toFixed(2) + 'MB', + total: ((latest.totalJSHeapSize - first.totalJSHeapSize) / 1024 / 1024).toFixed(2) + 'MB' + } + }; + } + + /** + * 長時間タスクをサマリー + */ + summarizeLongTasks(entries) { + const durations = entries.map(e => e.duration); + + return { + count: entries.length, + totalDuration: durations.reduce((a, b) => a + b, 0).toFixed(2) + 'ms', + averageDuration: (durations.reduce((a, b) => a + b, 0) / durations.length).toFixed(2) + 'ms', + maxDuration: Math.max(...durations).toFixed(2) + 'ms' + }; + } + + /** + * パフォーマンス改善の推奨事項を生成 + */ + generateRecommendations() { + const recommendations = []; + + // 長時間タスクのチェック + const longTasks = this.metrics.get('longTask') || []; + if (longTasks.length > 0) { + const avgDuration = longTasks.reduce((sum, task) => sum + task.duration, 0) / longTasks.length; + if (avgDuration > 100) { + recommendations.push({ + type: 'performance', + severity: 'high', + message: `長時間実行タスクが検出されました (平均: ${avgDuration.toFixed(2)}ms)`, + suggestion: 'タスクを小さく分割するか、Web Workerの使用を検討してください' + }); + } + } + + // メモリ使用量のチェック + const memoryEntries = this.metrics.get('memory') || []; + if (memoryEntries.length > 1) { + const latest = memoryEntries[memoryEntries.length - 1]; + const usagePercent = (latest.usedJSHeapSize / latest.jsHeapSizeLimit) * 100; + + if (usagePercent > 80) { + recommendations.push({ + type: 'memory', + severity: 'high', + message: `メモリ使用量が高くなっています (${usagePercent.toFixed(1)}%)`, + suggestion: 'メモリリークの確認とキャッシュのクリーンアップを実行してください' + }); + } + } + + // 操作パフォーマンスのチェック + const operations = this.metrics.get('operations') || []; + const slowOperations = operations.filter(op => op.duration > 200); + if (slowOperations.length > operations.length * 0.1) { + recommendations.push({ + type: 'performance', + severity: 'medium', + message: `遅い操作が多く検出されています (${slowOperations.length}/${operations.length})`, + suggestion: '操作の最適化または非同期処理の導入を検討してください' + }); + } + + return recommendations; + } + + /** + * 特定のメトリクスを取得 + */ + getMetrics(category) { + return this.metrics.get(category) || []; + } + + /** + * 全メトリクスを取得 + */ + getAllMetrics() { + const result = {}; + for (const [category, entries] of this.metrics.entries()) { + result[category] = entries; + } + return result; + } + + /** + * メトリクスをクリア + */ + clearMetrics(category = null) { + if (category) { + this.metrics.delete(category); + } else { + this.metrics.clear(); + } + } + + /** + * 監視を停止 + */ + stop() { + this.isEnabled = false; + this.observers.forEach(observer => observer.disconnect()); + this.observers = []; + } + + /** + * 監視を再開 + */ + start() { + this.isEnabled = true; + this.initializeObservers(); + } + + /** + * パフォーマンス統計をエクスポート + */ + exportStats() { + return { + startTime: this.startTime, + uptime: performance.now() - this.startTime, + metrics: this.getAllMetrics(), + summary: this.getMetricsSummary(), + recommendations: this.generateRecommendations(), + exportedAt: new Date().toISOString() + }; + } +} \ No newline at end of file diff --git a/js/progress-manager.js b/js/progress-manager.js index 9f2ff71..de7245d 100644 --- a/js/progress-manager.js +++ b/js/progress-manager.js @@ -301,22 +301,72 @@ class ProgressManager { } /** - * 進捗データをlocalStorageに保存 + * 進捗データをlocalStorageに保存(効率的な更新処理) */ saveProgressData() { + // デバウンス処理: 短時間での連続保存を防ぐ + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + + this.saveTimeout = setTimeout(() => { + this.performSave(); + }, 100); // 100ms後に保存実行 + } + + /** + * 実際の保存処理を実行 + */ + performSave() { try { const dataToSave = { courseProgress: this.progressData, lastSaved: new Date().toISOString(), version: '1.0' }; + + // データサイズをチェック + const dataString = JSON.stringify(dataToSave); + const dataSize = new Blob([dataString]).size; + + // 大きすぎる場合は古いデータをクリーンアップ + if (dataSize > 500000) { // 500KB以上の場合 + this.cleanupProgressData(); + dataToSave.courseProgress = this.progressData; + } + localStorage.setItem(this.storageKey, JSON.stringify(dataToSave)); + console.log(`進捗データを保存しました (${Math.round(dataSize / 1024)}KB)`); } catch (error) { console.error('進捗データ保存エラー:', error); this.handleSaveError(error); } } + /** + * 進捗データのクリーンアップ(古いデータを削除) + */ + cleanupProgressData() { + const cutoffDate = new Date(); + cutoffDate.setMonth(cutoffDate.getMonth() - 6); // 6ヶ月前 + + let cleanedCount = 0; + for (const [courseId, progress] of Object.entries(this.progressData)) { + if (progress.lastAccessed) { + const lastAccessed = new Date(progress.lastAccessed); + if (lastAccessed < cutoffDate && !progress.isCompleted) { + // 6ヶ月以上アクセスがなく、未完了のコースデータを削除 + delete this.progressData[courseId]; + cleanedCount++; + } + } + } + + if (cleanedCount > 0) { + console.log(`古い進捗データを${cleanedCount}件クリーンアップしました`); + } + } + /** * 選択されたコースを保存 */ diff --git a/js/progress-ui.js b/js/progress-ui.js index 98e69dc..fb38095 100644 --- a/js/progress-ui.js +++ b/js/progress-ui.js @@ -10,6 +10,11 @@ export class ProgressUI { this.currentCourse = null; this.currentProgress = null; + // UIOptimizer初期化 + this.uiOptimizer = null; + this.lastUpdateData = null; // 前回の更新データをキャッシュ + this.initializeUIOptimizer(); + this.elements = { progressPanel: document.getElementById('progress-panel'), toggleButton: document.getElementById('toggle-progress-panel'), @@ -40,6 +45,29 @@ export class ProgressUI { this.bindEvents(); } + /** + * UIOptimizerを初期化 + */ + async initializeUIOptimizer() { + try { + const { UIOptimizer } = await import('./ui-optimizer.js'); + this.uiOptimizer = new UIOptimizer(); + this.uiOptimizer.initialize(); + + // 進捗UI用の要素をキャッシュ + this.uiOptimizer.cacheElement('progress-panel', '#progress-panel'); + this.uiOptimizer.cacheElement('course-progress-fill', '#course-progress-fill'); + this.uiOptimizer.cacheElement('course-progress-percentage', '#course-progress-percentage'); + this.uiOptimizer.cacheElement('modules-list', '#modules-list'); + this.uiOptimizer.cacheElement('completed-lessons-count', '#completed-lessons-count'); + this.uiOptimizer.cacheElement('total-lessons-count', '#total-lessons-count'); + + console.log('ProgressUI: UIOptimizerが初期化されました'); + } catch (error) { + console.warn('ProgressUI: UIOptimizerの読み込みに失敗しました:', error); + } + } + /** * イベントリスナーを設定 */ @@ -140,17 +168,12 @@ export class ProgressUI { } /** - * コース全体の進捗を更新 + * コース全体の進捗を更新(最適化版) */ updateCourseProgress() { const course = this.currentCourse; const progress = this.currentProgress; - // コース名を設定 - if (this.elements.currentCourseName) { - this.elements.currentCourseName.textContent = course.title; - } - // 総レッスン数を計算 const totalLessons = course.modules.reduce((total, module) => total + module.lessons.length, 0 @@ -159,6 +182,53 @@ export class ProgressUI { const completedLessons = progress.completedLessons.length; const progressPercentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; + + // 前回の更新データと比較して変更があるかチェック + const currentData = { + courseTitle: course.title, + progressPercentage, + completedLessons, + totalLessons + }; + + if (this.lastUpdateData && + JSON.stringify(this.lastUpdateData) === JSON.stringify(currentData)) { + return; // 変更がない場合はスキップ + } + + this.lastUpdateData = currentData; + + // UIOptimizerが利用可能な場合は最適化された更新を使用 + if (this.uiOptimizer) { + // 進捗データを構造化してUIOptimizerに渡す + const progressData = { + percentage: progressPercentage, + completedLessons, + totalLessons, + moduleUpdated: false // モジュール更新は別途チェック + }; + + this.uiOptimizer.updateProgressDisplay(course.id, progressData, 'high'); + + // コース名の更新 + this.uiOptimizer.updateElementContent('current-course-name', course.title, { + textContent: true, + priority: 'normal' + }); + } else { + // フォールバック: 従来の方法 + this.updateCourseProgressFallback(course, progress, progressPercentage, completedLessons, totalLessons); + } + } + + /** + * コース進捗更新のフォールバック処理 + */ + updateCourseProgressFallback(course, progress, progressPercentage, completedLessons, totalLessons) { + // コース名を設定 + if (this.elements.currentCourseName) { + this.elements.currentCourseName.textContent = course.title; + } // 進捗パーセンテージを更新 if (this.elements.courseProgressPercentage) { diff --git a/js/ui-optimizer.js b/js/ui-optimizer.js new file mode 100644 index 0000000..dff8f91 --- /dev/null +++ b/js/ui-optimizer.js @@ -0,0 +1,425 @@ +/** + * UIOptimizer - UI更新の最適化クラス + * 必要な部分のみの再描画、バッチ更新、仮想DOM的な差分更新を提供 + */ +export class UIOptimizer { + constructor() { + this.updateQueue = new Map(); + this.isUpdating = false; + this.observers = new Map(); + this.lastUpdateTime = 0; + this.updateThrottle = 16; // 60fps相当 + this.elementCache = new Map(); + } + + /** + * 要素をキャッシュに追加 + */ + cacheElement(key, selector) { + if (!this.elementCache.has(key)) { + const element = document.querySelector(selector); + if (element) { + this.elementCache.set(key, element); + } + } + return this.elementCache.get(key); + } + + /** + * キャッシュされた要素を取得 + */ + getCachedElement(key) { + return this.elementCache.get(key); + } + + /** + * UI更新をキューに追加 + */ + queueUpdate(elementKey, updateFunction, priority = 'normal') { + const updateId = `${elementKey}_${Date.now()}`; + + this.updateQueue.set(updateId, { + elementKey, + updateFunction, + priority, + timestamp: Date.now() + }); + + this.scheduleUpdate(); + return updateId; + } + + /** + * 更新処理をスケジュール + */ + scheduleUpdate() { + if (this.isUpdating) return; + + const now = Date.now(); + const timeSinceLastUpdate = now - this.lastUpdateTime; + + if (timeSinceLastUpdate >= this.updateThrottle) { + this.processUpdates(); + } else { + setTimeout(() => { + this.processUpdates(); + }, this.updateThrottle - timeSinceLastUpdate); + } + } + + /** + * キューに溜まった更新を処理 + */ + processUpdates() { + if (this.isUpdating || this.updateQueue.size === 0) return; + + this.isUpdating = true; + this.lastUpdateTime = Date.now(); + + try { + // 優先度順にソート + const updates = Array.from(this.updateQueue.values()).sort((a, b) => { + const priorityOrder = { high: 3, normal: 2, low: 1 }; + return priorityOrder[b.priority] - priorityOrder[a.priority]; + }); + + // バッチ処理で更新実行 + this.executeBatchUpdates(updates); + + // キューをクリア + this.updateQueue.clear(); + + } catch (error) { + console.error('UI更新処理エラー:', error); + } finally { + this.isUpdating = false; + } + } + + /** + * バッチ更新を実行 + */ + executeBatchUpdates(updates) { + // DOM読み取りフェーズ + const readOperations = []; + const writeOperations = []; + + updates.forEach(update => { + try { + const result = update.updateFunction(); + if (result && typeof result === 'object') { + if (result.read) readOperations.push(result.read); + if (result.write) writeOperations.push(result.write); + } else if (typeof result === 'function') { + writeOperations.push(result); + } + } catch (error) { + console.error(`UI更新エラー (${update.elementKey}):`, error); + } + }); + + // 読み取り操作を先に実行 + readOperations.forEach(readOp => { + try { + readOp(); + } catch (error) { + console.error('DOM読み取りエラー:', error); + } + }); + + // 書き込み操作を実行 + writeOperations.forEach(writeOp => { + try { + writeOp(); + } catch (error) { + console.error('DOM書き込みエラー:', error); + } + }); + } + + /** + * 要素の差分更新 + */ + updateElementContent(elementKey, newContent, options = {}) { + const element = this.getCachedElement(elementKey); + if (!element) { + console.warn(`要素が見つかりません: ${elementKey}`); + return; + } + + return this.queueUpdate(elementKey, () => { + return { + read: () => { + // 現在の内容を読み取り + const currentContent = options.attribute ? + element.getAttribute(options.attribute) : + element.innerHTML; + + // 差分チェック + if (currentContent === newContent) { + return; // 変更なしの場合はスキップ + } + }, + write: () => { + // 内容を更新 + if (options.attribute) { + element.setAttribute(options.attribute, newContent); + } else if (options.textContent) { + element.textContent = newContent; + } else { + element.innerHTML = newContent; + } + } + }; + }, options.priority || 'normal'); + } + + /** + * 要素のスタイル更新 + */ + updateElementStyle(elementKey, styles, priority = 'normal') { + const element = this.getCachedElement(elementKey); + if (!element) { + console.warn(`要素が見つかりません: ${elementKey}`); + return; + } + + return this.queueUpdate(elementKey, () => { + return { + write: () => { + Object.assign(element.style, styles); + } + }; + }, priority); + } + + /** + * 要素のクラス更新 + */ + updateElementClasses(elementKey, classUpdates, priority = 'normal') { + const element = this.getCachedElement(elementKey); + if (!element) { + console.warn(`要素が見つかりません: ${elementKey}`); + return; + } + + return this.queueUpdate(elementKey, () => { + return { + write: () => { + if (classUpdates.add) { + classUpdates.add.forEach(cls => element.classList.add(cls)); + } + if (classUpdates.remove) { + classUpdates.remove.forEach(cls => element.classList.remove(cls)); + } + if (classUpdates.toggle) { + classUpdates.toggle.forEach(cls => element.classList.toggle(cls)); + } + } + }; + }, priority); + } + + /** + * 進捗表示の最適化更新 + */ + updateProgressDisplay(courseId, progress, priority = 'high') { + const progressBarKey = 'progress-bar'; + const progressTextKey = 'progress-text'; + const moduleListKey = 'module-list'; + + // 進捗バーの更新 + this.updateElementStyle(progressBarKey, { + width: `${progress.percentage}%` + }, priority); + + // 進捗テキストの更新 + this.updateElementContent(progressTextKey, + `${progress.completedLessons}/${progress.totalLessons} レッスン完了`, + { textContent: true, priority } + ); + + // モジュールリストの更新(必要な場合のみ) + if (progress.moduleUpdated) { + this.updateModuleList(moduleListKey, progress.modules, priority); + } + } + + /** + * モジュールリストの効率的更新 + */ + updateModuleList(elementKey, modules, priority = 'normal') { + return this.queueUpdate(elementKey, () => { + const element = this.getCachedElement(elementKey); + if (!element) return; + + return { + read: () => { + // 現在のモジュール状態を読み取り + const currentModules = Array.from(element.children); + return currentModules; + }, + write: () => { + // 差分更新でモジュール状態を更新 + modules.forEach((module, index) => { + const moduleElement = element.children[index]; + if (moduleElement) { + const isCompleted = module.isCompleted; + const wasCompleted = moduleElement.classList.contains('completed'); + + if (isCompleted !== wasCompleted) { + moduleElement.classList.toggle('completed', isCompleted); + } + } + }); + } + }; + }, priority); + } + + /** + * コース選択画面の最適化更新 + */ + updateCourseSelection(courses, priority = 'normal') { + const courseListKey = 'course-list'; + + return this.queueUpdate(courseListKey, () => { + const element = this.getCachedElement(courseListKey); + if (!element) return; + + return { + write: () => { + // 仮想DOM的な差分更新 + const fragment = document.createDocumentFragment(); + + courses.forEach(course => { + const courseCard = this.createCourseCardElement(course); + fragment.appendChild(courseCard); + }); + + // 一括更新 + element.innerHTML = ''; + element.appendChild(fragment); + } + }; + }, priority); + } + + /** + * コースカード要素を作成(最適化版) + */ + createCourseCardElement(course) { + const template = document.createElement('template'); + template.innerHTML = ` +
+
+
${this.getCourseIcon(course.id)}
+
+

${course.title}

+ ${course.difficulty} +
+
+
${course.description}
+
+ +
+
+ `; + return template.content.firstElementChild; + } + + /** + * コースアイコンを取得 + */ + getCourseIcon(courseId) { + const icons = { + 'sql-basics': '📊', + 'db-fundamentals': '🗄️', + 'big-data-basics': '🚀' + }; + return icons[courseId] || '📚'; + } + + /** + * 要素の可視性チェック(Intersection Observer使用) + */ + observeElementVisibility(elementKey, callback) { + const element = this.getCachedElement(elementKey); + if (!element) return; + + if (!this.observers.has(elementKey)) { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + callback(entry.isIntersecting, entry); + }); + }, { + threshold: 0.1, + rootMargin: '50px' + }); + + observer.observe(element); + this.observers.set(elementKey, observer); + } + } + + /** + * 遅延読み込み要素の管理 + */ + setupLazyLoading(elementKey, loadCallback) { + this.observeElementVisibility(elementKey, (isVisible) => { + if (isVisible) { + loadCallback(); + // 一度読み込んだら監視を停止 + const observer = this.observers.get(elementKey); + if (observer) { + observer.disconnect(); + this.observers.delete(elementKey); + } + } + }); + } + + /** + * パフォーマンス統計を取得 + */ + getPerformanceStats() { + return { + queueSize: this.updateQueue.size, + cachedElements: this.elementCache.size, + activeObservers: this.observers.size, + lastUpdateTime: this.lastUpdateTime, + isUpdating: this.isUpdating + }; + } + + /** + * キャッシュをクリア + */ + clearCache() { + this.elementCache.clear(); + this.updateQueue.clear(); + + // Observerを停止 + this.observers.forEach(observer => observer.disconnect()); + this.observers.clear(); + + console.log('UIOptimizerのキャッシュをクリアしました'); + } + + /** + * 初期化処理 + */ + initialize() { + // 主要な要素をキャッシュ + this.cacheElement('course-list', '#course-list'); + this.cacheElement('progress-bar', '.course-progress-fill'); + this.cacheElement('progress-text', '.progress-text'); + this.cacheElement('module-list', '.module-list'); + this.cacheElement('course-selection-screen', '#course-selection-screen'); + this.cacheElement('app-layout', '.app-layout'); + + console.log('UIOptimizerが初期化されました'); + } +} \ No newline at end of file