作为一个爱好骑行的博主,总觉得博客里少了点什么,骑行骑行的,怎么能没有一个专门的骑行数据展示页呢
在设计这个页面的时候,参考了许多骑行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,有好大哥懂吗?它们现在限制了每小时的请求次数,我本来就是半吊子水平,现在是雪上加霜
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模块化继承了一些基础样式,二次开发会轻松很多
// 总次数和总距离字体
$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、懒加载上线