bowling-analysis-system
Version:
A comprehensive system for analyzing bowling techniques using video processing and metrics calculation
447 lines (403 loc) • 12.5 kB
JavaScript
/**
* @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
};