UNPKG

bowling-analysis-system

Version:

A comprehensive system for analyzing bowling techniques using video processing and metrics calculation

447 lines (403 loc) 12.5 kB
/** * @fileoverview Utility functions for visualizing bowling metrics * @module utils/metricsVisualizer */ const { convertToChartFormat } = require('./timeSeriesConverter'); const { extractTimeSeriesData, extractEvents } = require('./metricsExtractor'); /** * Creates a visualization configuration for a time series metric * @param {Object} metricsData - Complete metrics data object * @param {string} metricPath - Dot-notation path to the metric * @param {Object} [options] - Visualization options * @param {boolean} [options.includeBoth=true] - Whether to include both left and right in output * @param {boolean} [options.includeAverage=true] - Whether to include average in output * @param {boolean} [options.includeEvents=true] - Whether to include event markers * @param {string} [options.title] - Chart title * @param {string} [options.xAxisLabel] - X-axis label * @param {string} [options.yAxisLabel] - Y-axis label * @returns {Object} Chart configuration object */ function createTimeSeriesVisualization(metricsData, metricPath, options = {}) { const { includeBoth = true, includeAverage = true, includeEvents = true, title = formatMetricLabel(metricPath), xAxisLabel = 'Frame', yAxisLabel = getYAxisLabel(metricPath) } = options; // Extract time series data const timeSeriesData = extractTimeSeriesData(metricsData, metricPath); if (!timeSeriesData || !Array.isArray(timeSeriesData)) { return { noData: true, metricPath, message: `No time series data found for ${metricPath}` }; } // Convert to chart format const chartData = convertToChartFormat(timeSeriesData, { includeBoth, includeAverage }); // Add event annotations if requested const annotations = includeEvents ? createEventAnnotations(metricsData) : []; // Create chart configuration return { type: 'line', data: chartData, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: !!title, text: title }, tooltip: { mode: 'index', intersect: false }, annotation: { annotations: annotations } }, scales: { x: { title: { display: !!xAxisLabel, text: xAxisLabel } }, y: { title: { display: !!yAxisLabel, text: yAxisLabel } } } } }; } /** * Creates event annotations for chart * @param {Object} metricsData - Complete metrics data object * @returns {Array} Array of annotation configurations * @private */ function createEventAnnotations(metricsData) { const events = extractEvents(metricsData); const annotations = []; // Event colors const eventColors = { releasePoint: 'rgba(255, 99, 132, 0.8)', frontFootLanding: 'rgba(54, 162, 235, 0.8)', backFootLanding: 'rgba(75, 192, 192, 0.8)', loadPosition: 'rgba(255, 206, 86, 0.8)', followThrough: 'rgba(153, 102, 255, 0.8)' }; // Create annotation for each event Object.entries(events).forEach(([eventName, eventData]) => { if (eventData && eventData.frameIndex !== undefined) { annotations.push({ type: 'line', mode: 'vertical', scaleID: 'x', value: eventData.frameIndex, borderColor: eventColors[eventName] || 'rgba(128, 128, 128, 0.8)', borderWidth: 2, label: { content: formatEventLabel(eventName), enabled: true, position: 'top' } }); } }); return annotations; } /** * Creates a heatmap visualization for a time series metric * @param {Object} metricsData - Complete metrics data object * @param {string} metricPath - Dot-notation path to the metric * @param {Object} [options] - Visualization options * @returns {Object} Heatmap configuration object */ function createHeatmapVisualization(metricsData, metricPath, options = {}) { const { title = formatMetricLabel(metricPath) + ' Heatmap', colorScale = 'viridis' } = options; // Extract time series data const timeSeriesData = extractTimeSeriesData(metricsData, metricPath); if (!timeSeriesData || !Array.isArray(timeSeriesData)) { return { noData: true, metricPath, message: `No time series data found for ${metricPath}` }; } // Check if it's left/right format const isLeftRightFormat = timeSeriesData.some(item => item !== null && typeof item === 'object' && ('left' in item || 'right' in item) ); let dataValues; if (isLeftRightFormat) { // Use average values for heatmap dataValues = timeSeriesData.map((item, index) => ({ x: index, y: 0, value: item ? item.average : null })); } else { dataValues = timeSeriesData.map((value, index) => ({ x: index, y: 0, value })); } // Filter out null values const validDataValues = dataValues.filter(item => item.value !== null); return { type: 'heatmap', data: { datasets: [{ label: formatMetricLabel(metricPath), data: validDataValues }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: !!title, text: title }, tooltip: { callbacks: { label: (context) => { return `Frame ${context.raw.x}: ${context.raw.value.toFixed(2)}`; } } } }, scales: { x: { type: 'linear', title: { display: true, text: 'Frame' } } } } }; } /** * Creates a comparison visualization for two time series metrics * @param {Object} metricsData - Complete metrics data object * @param {string} metricPath1 - Dot-notation path to the first metric * @param {string} metricPath2 - Dot-notation path to the second metric * @param {Object} [options] - Visualization options * @returns {Object} Comparison chart configuration */ function createComparisonVisualization(metricsData, metricPath1, metricPath2, options = {}) { const { title = `Comparison: ${formatMetricLabel(metricPath1)} vs ${formatMetricLabel(metricPath2)}`, includeEvents = true } = options; // Extract time series data for both metrics const timeSeriesData1 = extractTimeSeriesData(metricsData, metricPath1); const timeSeriesData2 = extractTimeSeriesData(metricsData, metricPath2); if (!timeSeriesData1 || !Array.isArray(timeSeriesData1)) { return { noData: true, metricPath: metricPath1, message: `No time series data found for ${metricPath1}` }; } if (!timeSeriesData2 || !Array.isArray(timeSeriesData2)) { return { noData: true, metricPath: metricPath2, message: `No time series data found for ${metricPath2}` }; } // Determine if they are left/right format const isLeftRightFormat1 = timeSeriesData1.some(item => item !== null && typeof item === 'object' && ('left' in item || 'right' in item) ); const isLeftRightFormat2 = timeSeriesData2.some(item => item !== null && typeof item === 'object' && ('left' in item || 'right' in item) ); // Create datasets const datasets = []; // Process first metric if (isLeftRightFormat1) { datasets.push({ label: `${formatMetricLabel(metricPath1)} Average`, data: timeSeriesData1.map(item => item ? item.average : null), borderColor: 'rgba(75, 192, 192, 1)', backgroundColor: 'rgba(75, 192, 192, 0.2)', fill: false }); } else { datasets.push({ label: formatMetricLabel(metricPath1), data: timeSeriesData1, borderColor: 'rgba(75, 192, 192, 1)', backgroundColor: 'rgba(75, 192, 192, 0.2)', fill: false }); } // Process second metric if (isLeftRightFormat2) { datasets.push({ label: `${formatMetricLabel(metricPath2)} Average`, data: timeSeriesData2.map(item => item ? item.average : null), borderColor: 'rgba(255, 99, 132, 1)', backgroundColor: 'rgba(255, 99, 132, 0.2)', fill: false }); } else { datasets.push({ label: formatMetricLabel(metricPath2), data: timeSeriesData2, borderColor: 'rgba(255, 99, 132, 1)', backgroundColor: 'rgba(255, 99, 132, 0.2)', fill: false }); } // Create chart data const chartData = { labels: Array.from({ length: Math.max(timeSeriesData1.length, timeSeriesData2.length) }, (_, i) => i), datasets }; // Add event annotations if requested const annotations = includeEvents ? createEventAnnotations(metricsData) : []; // Create chart configuration return { type: 'line', data: chartData, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: !!title, text: title }, tooltip: { mode: 'index', intersect: false }, annotation: { annotations: annotations } }, scales: { x: { title: { display: true, text: 'Frame' } }, y: { title: { display: true, text: 'Value' } } } } }; } /** * Formats a metric path into a human-readable label * @param {string} metricPath - Dot-notation path to the metric * @returns {string} Formatted label * @private */ function formatMetricLabel(metricPath) { if (!metricPath) { return 'Metric'; } // Get the last part of the path const parts = metricPath.split('.'); const lastPart = parts[parts.length - 1]; // Check if it's a left/right metric if (lastPart.startsWith('left') || lastPart.startsWith('right')) { const side = lastPart.startsWith('left') ? 'Left ' : 'Right '; const baseName = lastPart.substring(lastPart.startsWith('left') ? 4 : 5); return side + formatCamelCase(baseName); } return formatCamelCase(lastPart); } /** * Formats a camelCase string to Title Case with spaces * @param {string} camelCase - CamelCase string * @returns {string} Formatted string * @private */ function formatCamelCase(camelCase) { if (!camelCase) { return ''; } // Add space before capital letters and uppercase the first letter const withSpaces = camelCase.replace(/([A-Z])/g, ' $1') .replace(/^\w/, c => c.toUpperCase()); return withSpaces; } /** * Formats an event name into a human-readable label * @param {string} eventName - Name of the event * @returns {string} Formatted label * @private */ function formatEventLabel(eventName) { if (!eventName) { return 'Event'; } // Format camelCase event name return formatCamelCase(eventName); } /** * Gets an appropriate Y-axis label based on the metric path * @param {string} metricPath - Dot-notation path to the metric * @returns {string} Y-axis label * @private */ function getYAxisLabel(metricPath) { if (!metricPath) { return 'Value'; } // Extract category from path const parts = metricPath.split('.'); if (parts.length > 1) { const category = parts[1]; // The second part is usually the category after 'timeSeries' // Return appropriate unit based on category switch (category.toLowerCase()) { case 'angle': case 'angles': return 'Angle (degrees)'; case 'position': case 'positions': return 'Position (cm)'; case 'velocity': return 'Velocity (cm/s)'; case 'acceleration': return 'Acceleration (cm/s²)'; case 'power': return 'Power'; case 'balance': return 'Balance Score'; default: return 'Value'; } } return 'Value'; } module.exports = { createTimeSeriesVisualization, createHeatmapVisualization, createComparisonVisualization, formatMetricLabel };