From 9b359acf846ad6437f5a0210fe0cb971a80943ad Mon Sep 17 00:00:00 2001 From: Nicholas Mackowski Date: Wed, 19 Mar 2025 07:03:26 -0400 Subject: [PATCH] stacked bar chart --- components/ClimbingDashboard.vue | 232 +++++++++++++++++++++---------- server/api/tick-export.js | 88 ++---------- 2 files changed, 167 insertions(+), 153 deletions(-) diff --git a/components/ClimbingDashboard.vue b/components/ClimbingDashboard.vue index 0851ba2..fac8060 100644 --- a/components/ClimbingDashboard.vue +++ b/components/ClimbingDashboard.vue @@ -69,7 +69,7 @@
-

Total Sport Climbs

+

Total Climbs

{{ totalClimbs }}
@@ -77,31 +77,14 @@
{{ totalSends }}
(Onsight, Flash, Redpoint, Pinkpoint)
-
-

Most Common Grade

-
{{ commonGrade }}
-
-
-
-

Climbs by Grade

-
- -
-
+

Climbs by Grade

-

Progress Over Time

+

Climbs Per Year

@@ -134,7 +117,7 @@
-

Recent Climbs

+

Climbs

@@ -143,26 +126,20 @@ + - + +
Rating Style AreaNotes
{{ climb.Date }} {{ climb.Route }} {{ climb.Rating }} {{ climb["Lead Style"] }} {{ climb.Area }}{{ climb.Notes }}
-
- -
@@ -289,11 +266,9 @@ export default { // Climbing data totalClimbs: "--", totalSends: "--", - commonGrade: "--", orderedClimbs: [], gradeData: [], sendGradeData: [], - showSendsOnly: true, timeData: {}, popularAreas: [], popularCrags: [], @@ -328,17 +303,24 @@ export default { }, uniqueGrades() { const grades = new Set(); + this.orderedClimbs.forEach((climb) => { if (climb.Rating) grades.add(climb.Rating); }); + return [...grades].sort((a, b) => { - // Try to sort climbing grades in a logical order - const aNum = parseFloat(a.replace(/[^\d.]/g, "")); - const bNum = parseFloat(b.replace(/[^\d.]/g, "")); + const aStr = String(a); + const bStr = String(b); + + const aNum = parseFloat(aStr.replace(/[^\d.]/g, "")); + const bNum = parseFloat(bStr.replace(/[^\d.]/g, "")); + if (!isNaN(aNum) && !isNaN(bNum)) { if (aNum !== bNum) return aNum - bNum; } - return a.localeCompare(b); + + // Fall back to string comparison + return aStr.localeCompare(bStr); }); }, uniqueStyles() { @@ -450,12 +432,6 @@ export default { // Set dashboard data this.totalClimbs = data.total_climbs; this.totalSends = data.total_sends; - this.commonGrade = - data.send_grades && data.send_grades.length > 0 - ? data.send_grades[0][0] - : data.grades && data.grades.length > 0 - ? data.grades[0][0] - : ""; // Process climb data this.orderedClimbs = data.all_climbs || data.ordered_climbs || []; @@ -498,10 +474,21 @@ export default { }, createBarChart() { + // Exit early if the chart element doesn't exist if (!this.$refs.gradeChart) { + console.log("Chart reference not found"); return; } + const ctx = this.$refs.gradeChart.getContext("2d"); + + // Clear any existing chart + if (this.gradeChart) { + this.gradeChart.destroy(); + this.gradeChart = null; + } + + // Define the order of climbing grades we want to display const GRADE_ORDER = [ "5.6", "5.7", @@ -525,48 +512,117 @@ export default { "5.13d", ]; - const ctx = this.$refs.gradeChart.getContext("2d"); - - if (this.gradeChart) { - this.gradeChart.destroy(); - } + // Prepare data for sends and attempts separately + let sendCounts = {}; + let attemptCounts = {}; - const dataToUse = this.showSendsOnly - ? this.sendGradeData - : this.gradeData; + // Initialize with zero counts for all grades + GRADE_ORDER.forEach((grade) => { + sendCounts[grade] = 0; + attemptCounts[grade] = 0; + }); - const gradeCounts = {}; - dataToUse.forEach(([grade, count]) => { - gradeCounts[grade] = count; + // Count sends (climbs where Lead Style is one of the send styles) + this.orderedClimbs.forEach((climb) => { + if (!climb.Rating) return; + + const grade = climb.Rating.toString(); + if (!GRADE_ORDER.includes(grade)) return; + + const style = climb["Lead Style"] || ""; + const sendStyles = [ + "Onsight", + "Flash", + "Redpoint", + "Pinkpoint", + "Send", + ]; + + // If it's a send, add to sends count, otherwise to attempts count + if (sendStyles.includes(style)) { + sendCounts[grade] += 1; + } else { + attemptCounts[grade] += 1; + } }); - const orderedGradeData = GRADE_ORDER.map((grade) => [ - grade, - gradeCounts[grade] || 0, - ]); + // Prepare the datasets for the chart + const labels = GRADE_ORDER; + const sendsData = GRADE_ORDER.map((grade) => sendCounts[grade] || 0); + const attemptsData = GRADE_ORDER.map( + (grade) => attemptCounts[grade] || 0 + ); + // Create the stacked bar chart this.gradeChart = new Chart(ctx, { type: "bar", data: { - labels: orderedGradeData.map((item) => item[0]), + labels: labels, datasets: [ { - label: "Climbs by Grade", - data: orderedGradeData.map((item) => item[1]), - backgroundColor: this.showSendsOnly ? "#2ECC71" : "#3498DB", + label: "Sends", + data: sendsData, + backgroundColor: "#2ECC71", // Green for sends + borderColor: "#27AE60", + borderWidth: 1, + }, + { + label: "Attempts", + data: attemptsData, + backgroundColor: "#3498DB", // Blue for attempts + borderColor: "#2980B9", + borderWidth: 1, }, ], }, options: { responsive: true, - plugins: { - legend: { - display: false, - }, - }, scales: { + x: { + stacked: true, + title: { + display: true, + text: "Climbing Grade", + }, + }, y: { + stacked: true, beginAtZero: true, + title: { + display: true, + text: "Number of Climbs", + }, + }, + }, + plugins: { + legend: { + display: true, + position: "top", + }, + tooltip: { + callbacks: { + // Custom tooltip to show both the count and percentage + afterLabel: function (context) { + const datasetIndex = context.datasetIndex; + const index = context.dataIndex; + const grade = labels[index]; + const sends = sendCounts[grade] || 0; + const attempts = attemptCounts[grade] || 0; + const total = sends + attempts; + + if (datasetIndex === 0) { + // Sends dataset + return `${Math.round( + (sends / total) * 100 + )}% of ${grade} climbs`; + } else { + // Attempts dataset + return `${Math.round( + (attempts / total) * 100 + )}% of ${grade} climbs`; + } + }, + }, }, }, }, @@ -830,29 +886,51 @@ export default { margin-bottom: 15px; } +.chart-legend { + display: flex; + gap: 15px; +} + +.legend-item { + display: flex; + align-items: center; + font-size: 14px; +} + +.color-box { + display: inline-block; + width: 15px; + height: 15px; + margin-right: 5px; + border-radius: 3px; +} + +.sends-color { + background-color: #2ecc71; +} + +.attempts-color { + background-color: #3498db; +} + .chart-card h3 { margin-top: 0; color: #555; } -.toggle-container { +.radio-group { display: flex; - align-items: center; + gap: 15px; } -.toggle { - display: inline-flex; +.radio-label { + display: flex; align-items: center; cursor: pointer; } -.toggle input { - margin-right: 8px; -} - -.toggle-label { - font-size: 14px; - color: #555; +.radio-text { + margin-left: 5px; } .locations-container { diff --git a/server/api/tick-export.js b/server/api/tick-export.js index 2637d93..8eeedb4 100644 --- a/server/api/tick-export.js +++ b/server/api/tick-export.js @@ -1,10 +1,5 @@ import Papa from "papaparse"; -/** - * Parses a Mountain Project location string to extract Area, Crag, and Wall components - * @param {string} location - The full location string from Mountain Project - * @returns {Object} An object with area, crag and wall properties - */ function parseLocation(location) { const result = { area: "", @@ -14,27 +9,10 @@ function parseLocation(location) { if (!location) return result; - // Split the location by ">" and trim each part - const parts = location.split(">").map((part) => part.trim()); + // Split the location by ">" + const parts = location.split(">"); - // Need at least 2 parts to have any meaningful location data - if (parts.length < 2) return result; - - // For Red River Gorge locations - if (parts.length >= 2 && parts[1] === "Red River Gorge") { - result.area = "Red River Gorge"; - - if (parts.length >= 3) { - // Example: Kentucky > Red River Gorge > Bald Rock Recreational Preserve (BRRP) - result.crag = parts[2]; - - if (parts.length >= 4) { - // Example: Kentucky > Red River Gorge > Bald Rock Recreational Preserve (BRRP) > Chocolate Factory - result.wall = parts[3]; - } - } - } else if (parts.length >= 2) { - // For locations outside Red River Gorge + if (parts.length >= 2) { result.area = parts[1]; if (parts.length >= 3) { @@ -49,11 +27,7 @@ function parseLocation(location) { return result; } -/** - * Normalizes a grade by handling slashes like "5.11a/b" -> "5.11b" - * @param {string} grade - The climbing grade to normalize - * @returns {string} The normalized grade - */ +// Normalizes a grade by handling slashes like "5.11a/b" -> "5.11b" function normalizeGrade(grade) { if (!grade || typeof grade !== "string") return grade; @@ -66,17 +40,9 @@ function normalizeGrade(grade) { return grade; } -/** - * Gets the lead style from a row, handling different possible field names - * @param {Object} row - A data row from Mountain Project CSV - * @returns {string|null} The lead style or null if not found - */ +// Gets the lead style from a row, handling different possible field names function getLedStyle(row) { - const possibleFields = ["Lead Style", "LeadStyle", "leadstyle", "Lead_Style"]; - for (const field of possibleFields) { - if (row[field] !== undefined) return row[field]; - } - return null; + return row["Lead Style"] !== undefined ? row["Lead Style"] : null; } export default defineEventHandler(async (event) => { @@ -89,17 +55,13 @@ export default defineEventHandler(async (event) => { let url = "https://www.mountainproject.com/user/201253016/nick-mackowski/tick-export"; - // If userId and userName are provided, use them instead (self-service mode) + // If userId and userName are provided, use them instead if (userId && userName) { - console.log( - `Received request for userId: ${userId}, userName: ${userName}` - ); url = `https://www.mountainproject.com/user/${userId}/${userName}/tick-export`; - console.log(`Fetching data from: ${url}`); } try { - // Add timeout and retry logic for more reliable fetching + // Add timeout for more reliable fetching const fetchWithTimeout = async (url, options = {}, timeout = 10000) => { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); @@ -117,23 +79,7 @@ export default defineEventHandler(async (event) => { } }; - // Try up to 3 times with increasing timeouts - let csvText; - let attempts = 0; - const maxAttempts = 3; - - while (attempts < maxAttempts) { - try { - csvText = await fetchWithTimeout(url, {}, 10000 * (attempts + 1)); - break; - } catch (error) { - attempts++; - console.log(`Attempt ${attempts} failed: ${error.message}`); - if (attempts >= maxAttempts) throw error; - // Wait before retry - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } + const csvText = await fetchWithTimeout(url); // Check if we got data if (!csvText || csvText.trim() === "") { @@ -164,21 +110,11 @@ export default defineEventHandler(async (event) => { row.Rating ); - console.log(`Parsed ${rows.length} valid rows`); - - if (!rows.length) { - return { - error: "No climbing data", - details: - "No valid climbing data found. Make sure you've logged climbs on Mountain Project.", - }; - } - - // Process the data + // Output total number of climbs const total_climbs = rows.length; - // Define send styles - include boulder problems with V grades - const sendStyles = ["Onsight", "Flash", "Redpoint", "Pinkpoint", "Send"]; + // Define send styles + const sendStyles = ["Onsight", "Flash", "Redpoint", "Pinkpoint"]; // Count total sends properly, including boulder problems const total_sends = rows.filter((row) => {