--- layout: post title: 写一个骑行页面 date: 2024-08-11 16:02:01 +0800 category: tech thumb: ARTICLEPICTURES_PATH/3138bbfa27873a47be243177e9865ac.png tags: [前端, 骑行, Strava, 统计] --- 作为一个爱好骑行的博主,总觉得博客里少了点什么,骑行骑行的,怎么能没有一个专门的骑行数据展示页呢 在设计这个页面的时候,参考了许多骑行APP,然而,国内的骑行数据页面设计真的是一言难尽... 我骑行看数据用Strava多一些,但是它的PC端交互体验,实在不敢苟同。除了用APP版本,我几乎不会去它的网页。不得不说,国外这些骑行数据端做的确实很到位,我个人觉得数据分析方面Strava比Garmin要好! ## 项目结构 老规矩,先放目录结构。由于网站的主样式文件main压缩后都超160kb了,为了避免堵塞加载,新开辟一条生产线 说起SCSS,还是受Fooleap的启发才接触到的,我非常喜欢这种方式,它允许嵌套CSS,让代码更加模块化、结构化,还支持变量、继承。比起传统CSS那真是有过之而无不及啊! ``` Blog ├─assets │ cycling.min.css │ cycling.min.js │ ├─pages │ cycling.html │ └─src │ cycling.js │ main.js │ ├─cycling │ cycling.scss │ _bar-chart.scss │ _base.scss │ _calendar.scss │ _message-box.scss │ _sports.scss │ └─sass ``` ## cycling.js 目前所有的逻辑都在这一个文件里完成,现在的功能还是个雏,因为没有打通Strava api,JSON数据是我手搓的..最近一直在搞Strava api,有好大哥懂吗?它们现在限制了每小时的请求次数,我本来就是半吊子水平,现在是雪上加霜 ```js import './cycling/cycling.scss'; // 为了数据的统一性,generateCalendar处理后赋值供全局使用 let processedActivities = []; // 日历 function generateCalendar(activities, startDate, numWeeks) { const calendarElement = document.getElementById('calendar'); calendarElement.innerHTML = ''; const daysOfWeek = ['一', '二', '三', '四', '五', '六', '日']; daysOfWeek.forEach(day => { const dayElement = document.createElement('div'); dayElement.className = 'calendar-week-header'; dayElement.innerText = day; calendarElement.appendChild(dayElement); }); const todayStr = getChinaTime().toISOString().split('T')[0]; // 起始日期 let currentDate = new Date(startDate); processedActivities = []; // 创建日历 function createDayContainer(date, activities) { const dayContainer = document.createElement('div'); dayContainer.className = 'day-container'; const dateNumber = document.createElement('span'); dateNumber.className = 'date-number'; dateNumber.innerText = date.getDate(); dayContainer.appendChild(dateNumber); const activity = activities.find(activity => activity.activity_time === date.toISOString().split('T')[0]); if (activity) processedActivities.push(activity); // 根据骑行距离设置球的大小 const ballSize = activity ? Math.min(parseFloat(activity.riding_distance) / 4, 24) : 2; const ball = document.createElement('div'); ball.className = 'activity-indicator'; ball.style.width = `${ballSize}px`; ball.style.height = `${ballSize}px`; if (!activity) ball.classList.add('no-activity'); ball.style.left = '50%'; ball.style.top = '50%'; dayContainer.appendChild(ball); dayContainer.addEventListener('mouseenter', () => { dateNumber.style.opacity = '1'; ball.style.opacity = '0'; }); dayContainer.addEventListener('mouseleave', () => { dateNumber.style.opacity = '0'; ball.style.opacity = '1'; }); // 今天日期和球的颜色 if (date.toDateString() === new Date().toDateString()) { dayContainer.classList.add('today'); ball.style.backgroundColor = '#2ea9df'; dateNumber.style.color = '#2ea9df'; } return dayContainer; } // 异步显示,模仿打字机效果 async function displayCalendar() { for (let week = 0; week < numWeeks; week++) { for (let day = 0; day < 7; day++) { const currentDateStr = currentDate.toISOString().split('T')[0]; // 不再计算超过今天的日期 if (currentDateStr > todayStr) return; const dayContainer = createDayContainer(currentDate, activities); calendarElement.appendChild(dayContainer); // 速度 await new Promise(resolve => setTimeout(resolve, 30)); currentDate.setDate(currentDate.getDate() + 1); } } } displayCalendar().then(() => { generateBarChart(); displayTotalActivities(); }); } // 柱形图 function generateBarChart() { const barChartElement = document.getElementById('barChart'); barChartElement.innerHTML = ''; const today = getChinaTime(); const startDate = getStartDate(today, 21); // 每周数据 const weeklyData = {}; // 每周总活动时间 processedActivities.forEach(activity => { const activityDate = new Date(activity.activity_time); const weekStart = getWeekStartDate(activityDate); const weekEnd = new Date(weekStart); weekEnd.setDate(weekStart.getDate() + 6); const weekKey = `${weekStart.toISOString().split('T')[0]} - ${weekEnd.toISOString().split('T')[0]}`; weeklyData[weekKey] = (weeklyData[weekKey] || 0) + convertToHours(activity.moving_time); }); // 最大时间 const maxTime = Math.max(...Object.values(weeklyData), 0); // 创建柱形图 Object.keys(weeklyData).forEach(week => { const barContainer = document.createElement('div'); barContainer.className = 'bar-container'; const bar = document.createElement('div'); bar.className = 'bar'; const width = (weeklyData[week] / maxTime) * 190; bar.style.setProperty('--bar-width', `${width}px`); const durationText = document.createElement('div'); durationText.className = 'bar-duration'; durationText.innerText = '0h'; const messageBox = createMessageBox(); const clickMessageBox = createMessageBox(); barContainer.style.position = 'relative'; bar.appendChild(durationText); barContainer.appendChild(bar); barContainer.appendChild(messageBox); barContainer.appendChild(clickMessageBox); barChartElement.appendChild(barContainer); bar.style.width = '0'; bar.offsetHeight; // 动画效果 bar.style.transition = 'width 1s ease-out'; bar.style.width = `${width}px`; durationText.style.opacity = '1'; // 动态文本 animateText(durationText, 0, weeklyData[week], 1000); setupBarInteractions(bar, messageBox, clickMessageBox, weeklyData[week]); }); } // 动态文本显示 function animateText(element, startValue, endValue, duration) { const startTime = performance.now(); function update() { const elapsed = performance.now() - startTime; const progress = Math.min(elapsed / duration, 1); const currentValue = Math.floor(progress * endValue); element.innerText = `${currentValue}h`; if (progress < 1) { requestAnimationFrame(update); } else { element.innerText = `${endValue.toFixed(1)}h`; } } update(); } // 计算总公里数 function calculateTotalKilometers(activities) { return activities.reduce((total, activity) => total + parseFloat(activity.riding_distance) || 0, 0); } // 显示总活动数和总公里数 function displayTotalActivities() { const totalCountElement = document.getElementById('totalCount'); const totalDistanceElement = document.getElementById('totalDistance'); if (!totalCountElement || !totalDistanceElement) return; const totalCountValue = totalCountElement.querySelector('#totalCountValue'); const totalDistanceValue = totalDistanceElement.querySelector('#totalDistanceValue'); const totalCountSpinner = totalCountElement.querySelector('.loading-spinner'); const totalDistanceSpinner = totalDistanceElement.querySelector('.loading-spinner'); totalCountSpinner.classList.add('active'); totalDistanceSpinner.classList.add('active'); const uniqueDays = new Set(processedActivities.map(activity => activity.activity_time)); const totalCount = uniqueDays.size; const totalKilometers = calculateTotalKilometers(processedActivities); animateCount(totalCountValue, totalCount, 1000, 50); animateCount(totalDistanceValue, totalKilometers, 1000, 50, true); setTimeout(() => { totalDistanceValue.textContent = `${totalKilometers.toFixed(2)} km`; totalCountSpinner.classList.remove('active'); totalDistanceSpinner.classList.remove('active'); }, 1000); } // 获取一周的开始日期 function getWeekStartDate(date) { const day = date.getDay(); const diff = (day === 0 ? -6 : 1) - day; const weekStart = new Date(date); weekStart.setDate(weekStart.getDate() + diff); return weekStart; } // 将JSON的时间数据转换为小时 function convertToHours(moving_time) { const [hours, minutes] = moving_time.split(':').map(Number); return hours + (minutes / 60); } // 博客托管Github Pages需要中国时间 function getChinaTime() { const now = new Date(); const offset = 8 * 60 * 60 * 1000; return new Date(now.getTime() + offset); } // 手搓JSON async function loadActivityData() { const response = await fetch('XXXXXX'); return response.json(); } (async function() { const today = getChinaTime(); const startDate = getStartDate(today, 21); const activities = await loadActivityData(); generateCalendar(activities, startDate, 4); })(); // 创建消息盒子 function createMessageBox() { const messageBox = document.createElement('div'); messageBox.className = 'message-box'; return messageBox; } // 获取起始时间 function getStartDate(today, daysOffset) { const currentDayOfWeek = today.getDay(); const daysToMonday = (currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1); const startDate = new Date(today); startDate.setDate(today.getDate() - daysToMonday - daysOffset); startDate.setDate(startDate.getDate() - (startDate.getDay() === 0 ? 6 : startDate.getDay() - 1)); return startDate; } // 动态更新计数器 function animateCount(element, totalValue, duration, intervalDuration, isDistance = false) { const step = totalValue / (duration / intervalDuration); let count = 0; const interval = setInterval(() => { count += step; if (count >= totalValue) { count = totalValue; clearInterval(interval); } element.textContent = isDistance ? count.toFixed(2) : Math.round(count); }, intervalDuration); } // 骚话集合 function setupBarInteractions(bar, messageBox, clickMessageBox, weeklyData) { let mouseLeaveTimeout; let autoHideTimeout; bar.addEventListener('mouseenter', () => { clearTimeout(mouseLeaveTimeout); clearTimeout(autoHideTimeout); const message = weeklyData > 14 ? '这周干的还不错' : '偷懒了啊'; messageBox.innerText = message; messageBox.classList.add('show'); autoHideTimeout = setTimeout(() => { messageBox.classList.remove('show'); }, 700); }); bar.addEventListener('mouseleave', () => { mouseLeaveTimeout = setTimeout(() => { messageBox.classList.remove('show'); }, 700); }); bar.addEventListener('click', () => { clickMessageBox.innerText = '一起来运动吧!'; clickMessageBox.classList.add('show'); setTimeout(() => { clickMessageBox.classList.remove('show'); }, 700); messageBox.classList.remove('show'); clearTimeout(mouseLeaveTimeout); clearTimeout(autoHideTimeout); }); } ``` ## cycling.scss 骑行统计页面不会止步于此,接下来还会有很大的延申改动,我提前把变量接口留好了,定义了一些主样式变量,SCSS模块化继承了一些基础样式,二次开发会轻松很多 ```scss // 总次数和总距离字体 $primary-color: #2ea9df; // 柱状图字体 $gray-color: #333; // 柱状图颜色 $light-gray-color: #EBE6F2; // 柱状图边框 $light-gray-border-color: #DFD7E9; // 未活动日历 $no-activity-color: gray; //------ 分类色 // 公路车 $cycling-color: #EBE6F2; $cycling-border-color: #DFD7E9; // 跑步 $running-color: #D5E5D3; $running-border-color: #BDD6BA; // 背景和文本颜色 $background-color: #333; $text-color: #fff; @import 'base'; @import 'calendar'; @import 'bar-chart'; @import 'sports'; @import 'message-box'; ``` ## webpack配置 html和scss没啥好看的,配置一下收工 ```js const path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); module.exports = { mode: 'production', entry: { main: path.resolve(__dirname, 'src/main.js'), cycling: path.resolve(__dirname, 'src/cycling.js'), }, output: { path: path.resolve(__dirname, 'assets'), filename: '[name].min.js', publicPath: '/' }, stats: { entrypoints: false, children: false }, module: { rules: [ { test: /\.(scss|css)$/, use: [ MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader' ] }, { test: /\.html$/, use: ['html-loader'] } ], }, resolve: { alias: { 'iDisqus.css': 'disqus-php-api/dist/iDisqus.css', } }, plugins: [ new MiniCssExtractPlugin({ filename: '[name].min.css' }) ], optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: true }), new CssMinimizerPlugin() ], } }; ``` ## 效果 Fooleap的博客真的是相当不错,我特别喜欢他写的Jekyll主题,还有很大的折腾空间,比如全站PJAX、懒加载等等 这一周,我也着手用JQuery重新了写整站,完事后感觉真傻逼了,属于画蛇添足,多此一举。毕竟小站点,拖着一个磨盘挺累的。不上国内服务器的话,原生这条路死磕到底了,不过PJAX是必须要上的,预计下星期全站PJAX、懒加载上线 ![初稿][p1] [p1]: {{ site.ARTICLEPICTURES_PATH }}/6ebf4b151477aacd7908d80dad937a91.png 骑行:[https://lhasa.icu/cycling.html](https://lhasa.icu/cycling.html)