作为一个爱好骑行的博主,总觉得博客里少了点什么,骑行骑行的,怎么能没有一个专门的骑行数据展示页呢
在设计这个页面的时候,参考了许多骑行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 │ └─sasscycling.js
目前所有的逻辑都在这一个文件里完成,现在的功能还是个雏,因为没有打通Strava api,JSON数据是我手搓的..最近一直在搞Strava api,有好大哥懂吗?它们现在限制了每小时的请求次数,我本来就是半吊子水平,现在是雪上加霜
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);}
// 手搓JSONasync 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模块化继承了一些基础样式,二次开发会轻松很多
// 总次数和总距离字体$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没啥好看的,配置一下收工
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、懒加载上线