heatmap.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. <template>
  2. <view class="contribution-heatmap pa-24">
  3. <view class="header">
  4. <view class="title">
  5. <view v-if="false" style="color: #7e7e7f">数据范围:</view>
  6. <view>{{ dataRangeYears }}</view>
  7. </view>
  8. <view class="controls">
  9. <tm-stepper v-model="currentYear" :width="220" :height="48" :min="1999" :max="2099" :shadow="0" :round="2" color="light-blue" @change="changeYear"></tm-stepper>
  10. </view>
  11. </view>
  12. <view class="heatmap-container">
  13. <view class="weeks">
  14. <view class="week-label" v-for="week in weeks" :key="week">{{ week }}</view>
  15. </view>
  16. <view class="heatmap-content">
  17. <view class="months" :style="[calcContentStyle]">
  18. <view class="month-label" v-for="month in monthLabels" :key="month.index">
  19. {{ month.name }}
  20. </view>
  21. </view>
  22. <view class="days-container" :style="[calcContentStyle]">
  23. <view
  24. v-for="(day, index) in displayDays"
  25. :key="index"
  26. class="day-cell"
  27. :style="{ backgroundColor: getDayColor(day) }"
  28. @click="handleDayClick(day, index)"
  29. ></view>
  30. </view>
  31. </view>
  32. </view>
  33. <view class="footer">
  34. <view class="releases-count">
  35. <text>累计 {{ calcAllYearCount }} 篇</text>
  36. <text>丨</text>
  37. <text>本年 {{ calcCurrentYearCount }} 篇</text>
  38. </view>
  39. <view class="legend">
  40. <text class="legend-text">少</text>
  41. <view v-for="(color, index) in intensityColors" :key="index" class="day-cell legend-day-cell" :style="{ backgroundColor: color }"></view>
  42. <text class="legend-text">多</text>
  43. </view>
  44. </view>
  45. </view>
  46. </template>
  47. <script>
  48. import tmStepper from '@/tm-vuetify/components/tm-stepper/tm-stepper.vue';
  49. export default {
  50. name: 'Heatmap',
  51. components: {
  52. tmStepper
  53. },
  54. props: {
  55. year: {
  56. type: Number,
  57. default: () => new Date().getFullYear()
  58. },
  59. chartData: {
  60. type: Array,
  61. default: () => []
  62. }
  63. },
  64. data() {
  65. return {
  66. weeks: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
  67. intensityColors: [
  68. '#ebedf0', // 无贡献
  69. '#dbeafe', // 1-2次
  70. '#93c5fd', // 3-4次
  71. '#3b82f6', // 5-6次
  72. '#1e40af' // 7次以上
  73. ],
  74. displayDays: [],
  75. showTooltip: null,
  76. currentYear: '1900',
  77. currentYearData: []
  78. };
  79. },
  80. computed: {
  81. dataRangeYears() {
  82. const arr = this.chartData;
  83. const dateField = 'name';
  84. if (!arr || !Array.isArray(arr) || arr.length === 0) {
  85. return { minDate: null, maxDate: null };
  86. }
  87. // 提取所有有效日期
  88. const validDates = arr
  89. .map((item) => {
  90. if (item && item[dateField]) {
  91. const date = new Date(item[dateField]);
  92. return isNaN(date.getTime()) ? null : date;
  93. }
  94. return null;
  95. })
  96. .filter((date) => date !== null);
  97. if (validDates.length === 0) {
  98. return { minDate: null, maxDate: null };
  99. }
  100. // 找到最小和最大日期
  101. const minDate = new Date(Math.min(...validDates.map((date) => date.getTime())));
  102. const maxDate = new Date(Math.max(...validDates.map((date) => date.getTime())));
  103. const result = {
  104. minDate: this.formatDate(minDate),
  105. maxDate: this.formatDate(maxDate)
  106. };
  107. return `${result.minDate} 至 ${result.maxDate}`;
  108. },
  109. // 计算内容宽度
  110. calcContentStyle() {
  111. const rowCount = Math.ceil(this.displayDays.length / 7);
  112. const singleWidth = 36;
  113. return {
  114. width: rowCount * singleWidth + 'rpx'
  115. };
  116. },
  117. //累计的发文次数
  118. calcAllYearCount() {
  119. return this.chartData.reduce((acc, cur) => {
  120. return acc + cur.total;
  121. }, 0);
  122. },
  123. // 统计当前年累计的发文次数
  124. calcCurrentYearCount() {
  125. return this.currentYearData.reduce((acc, cur) => {
  126. return acc + cur.total;
  127. }, 0);
  128. },
  129. monthLabels() {
  130. const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
  131. const labels = [];
  132. if (this.displayDays.length === 0) return labels;
  133. // 计算每个格子的总宽度(包括间距)
  134. const cellTotalWidth = 15 + 1.5 * 2; // width + margin * 2
  135. let currentMonth = -1;
  136. let monthStartWeek = 0;
  137. // 遍历所有周
  138. const totalWeeks = Math.ceil(this.displayDays.length / 7);
  139. for (let week = 0; week < totalWeeks; week++) {
  140. // 找到这一周的第一个有效日期
  141. let weekMonth = -1;
  142. for (let day = 0; day < 7; day++) {
  143. const dayIndex = week * 7 + day;
  144. if (dayIndex < this.displayDays.length && this.displayDays[dayIndex].date) {
  145. weekMonth = this.displayDays[dayIndex].date.getMonth();
  146. break;
  147. }
  148. }
  149. // 如果找到了有效月份
  150. if (weekMonth !== -1) {
  151. // 如果是新的月份
  152. if (weekMonth !== currentMonth) {
  153. // 如果不是第一个月份,先计算前一个月份的宽度
  154. if (currentMonth !== -1) {
  155. const weeksInMonth = week - monthStartWeek;
  156. const width = weeksInMonth * cellTotalWidth;
  157. labels.push({
  158. name: months[currentMonth],
  159. width: width,
  160. marginLeft: monthStartWeek === 0 ? 0 : 2,
  161. index: currentMonth
  162. });
  163. }
  164. currentMonth = weekMonth;
  165. monthStartWeek = week;
  166. }
  167. }
  168. }
  169. // 处理最后一个月份
  170. if (currentMonth !== -1) {
  171. const weeksInMonth = totalWeeks - monthStartWeek;
  172. const width = weeksInMonth * cellTotalWidth;
  173. labels.push({
  174. name: months[currentMonth],
  175. width: width,
  176. marginLeft: monthStartWeek === 0 ? 0 : 2,
  177. index: currentMonth
  178. });
  179. }
  180. return labels;
  181. }
  182. },
  183. watch: {
  184. year: {
  185. immediate: true,
  186. handler(newYear) {
  187. this.generateDisplayData(newYear);
  188. }
  189. },
  190. chartData: {
  191. deep: true,
  192. handler(newData) {
  193. this.mergeContributionData(newData);
  194. }
  195. }
  196. },
  197. created() {
  198. this.filterAndSetCurrentYearData(this.year);
  199. },
  200. methods: {
  201. filterAndSetCurrentYearData(year) {
  202. this.currentYear = year;
  203. this.currentYearData = this.chartData.filter((x) => x.name.includes(this.currentYear));
  204. this.generateDisplayData(year);
  205. },
  206. generateDisplayData(year) {
  207. const startDate = new Date(year, 0, 1);
  208. const endDate = new Date(year, 11, 31);
  209. const days = [];
  210. // 计算第一周的偏移量(确保周一开始)
  211. let firstDay = startDate.getDay();
  212. firstDay = firstDay === 0 ? 6 : firstDay - 1; // 将周日(0)转换为6,周一(1)转换为0
  213. // 添加空白填充,使第一周从周一开始
  214. for (let i = 0; i < firstDay; i++) {
  215. days.push({ date: null, count: 0 });
  216. }
  217. // 生成一年的日期
  218. let currentDate = new Date(startDate);
  219. while (currentDate <= endDate) {
  220. // 默认无贡献
  221. days.push({
  222. date: new Date(currentDate),
  223. count: 0
  224. });
  225. currentDate.setDate(currentDate.getDate() + 1);
  226. }
  227. this.displayDays = days;
  228. this.mergeContributionData(this.currentYearData);
  229. },
  230. mergeContributionData(contributionData) {
  231. if (!contributionData || contributionData.length === 0) return;
  232. // 将传入的数据合并到显示数据中
  233. const dataMap = new Map();
  234. contributionData.forEach((item) => {
  235. const dateStr = this.formatDate(item.date);
  236. dataMap.set(dateStr, item.total);
  237. });
  238. this.displayDays.forEach((day) => {
  239. if (day.date) {
  240. const dateStr = this.formatDate(day.date);
  241. if (dataMap.has(dateStr)) {
  242. day.count = dataMap.get(dateStr);
  243. }
  244. }
  245. });
  246. },
  247. formatDate(date) {
  248. date = new Date(date);
  249. // 将日期格式化为 YYYY-MM-DD
  250. const year = date.getFullYear();
  251. const month = String(date.getMonth() + 1).padStart(2, '0');
  252. const day = String(date.getDate()).padStart(2, '0');
  253. return `${year}-${month}-${day}`;
  254. },
  255. formatDisplayDate(date) {
  256. // 将日期格式化为中文显示格式
  257. const year = date.getFullYear();
  258. const month = date.getMonth() + 1;
  259. const day = date.getDate();
  260. return `${year}年${month}月${day}日`;
  261. },
  262. getDayColor(day) {
  263. if (day.count === 0) return this.intensityColors[0];
  264. if (day.count <= 2) return this.intensityColors[1];
  265. if (day.count <= 4) return this.intensityColors[2];
  266. if (day.count <= 6) return this.intensityColors[3];
  267. return this.intensityColors[4];
  268. },
  269. getTooltipText(day) {
  270. if (!day.date) return '无数据';
  271. const dateStr = this.formatDisplayDate(day.date);
  272. if (day.count === 0) {
  273. return `${dateStr}: 发布 0 篇文章`;
  274. } else {
  275. return `${dateStr}: 发布 ${day.count} 篇文章`;
  276. }
  277. },
  278. handleDayClick(day, index) {
  279. uni.showToast({
  280. icon: 'none',
  281. title: this.getTooltipText(day)
  282. });
  283. },
  284. changeYear(value) {
  285. this.filterAndSetCurrentYearData(value);
  286. this.$emit('year-change', value);
  287. }
  288. }
  289. };
  290. </script>
  291. <style scoped>
  292. .contribution-heatmap {
  293. box-sizing: border-box;
  294. width: 100%;
  295. background-color: #fff;
  296. border-radius: 12rpx;
  297. box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.12), 0 2rpx 4rpx rgba(0, 0, 0, 0.24);
  298. }
  299. .header {
  300. display: flex;
  301. justify-content: space-between;
  302. align-items: center;
  303. margin-bottom: 30rpx;
  304. }
  305. .title {
  306. font-size: 24rpx;
  307. font-weight: 500;
  308. color: #000;
  309. border: 2rpx solid #ebedf0;
  310. border-radius: 10rpx;
  311. padding: 8rpx 12rpx;
  312. }
  313. .controls {
  314. display: flex;
  315. align-items: center;
  316. gap: 30rpx;
  317. }
  318. .year-selector {
  319. display: flex;
  320. align-items: center;
  321. gap: 16rpx;
  322. }
  323. .year-btn {
  324. background: none;
  325. border: none;
  326. cursor: pointer;
  327. color: #0969da;
  328. font-size: 28rpx;
  329. padding: 8rpx 16rpx;
  330. border-radius: 6rpx;
  331. }
  332. .year-btn:hover {
  333. background-color: #f6f8fa;
  334. }
  335. .year-display {
  336. font-size: 28rpx;
  337. font-weight: 600;
  338. min-width: 120rpx;
  339. text-align: center;
  340. }
  341. .legend {
  342. display: flex;
  343. align-items: center;
  344. gap: 2rpx;
  345. font-size: 24rpx;
  346. border: 2rpx solid #ebedf0;
  347. border-radius: 10rpx;
  348. padding: 8rpx 3rpx;
  349. }
  350. .legend-text {
  351. margin: 0 10rpx;
  352. }
  353. .legend-day-cell {
  354. width: 24rpx!important;
  355. height: 24rpx!important;
  356. }
  357. .heatmap-container {
  358. display: flex;
  359. padding-bottom: 20rpx;
  360. }
  361. .weeks {
  362. margin-top: -2rpx;
  363. flex-shrink: 0;
  364. display: flex;
  365. flex-direction: column;
  366. margin-right: 10rpx;
  367. padding-top: 48rpx;
  368. }
  369. .week-label {
  370. font-size: 24rpx;
  371. color: #7e7e7f;
  372. height: 30rpx;
  373. line-height: 30rpx;
  374. margin-bottom: 6rpx;
  375. text-align: right;
  376. }
  377. .months {
  378. width: 100%;
  379. display: flex;
  380. margin-bottom: 10rpx;
  381. }
  382. .month-label {
  383. flex: 1;
  384. flex-shrink: 0;
  385. font-size: 24rpx;
  386. color: #7e7e7f;
  387. text-align: center;
  388. }
  389. .heatmap-content {
  390. display: flex;
  391. flex-direction: column;
  392. overflow-x: auto;
  393. }
  394. .days-container {
  395. display: flex;
  396. flex-direction: column;
  397. flex-wrap: wrap;
  398. height: 252rpx; /* 7行 * 30rpx + 7行 * 6rpx间距 */
  399. }
  400. .day-cell {
  401. width: 32rpx;
  402. height: 32rpx;
  403. border-radius: 4rpx;
  404. margin: 3rpx;
  405. background-color: #ebedf0;
  406. position: relative;
  407. }
  408. .footer {
  409. margin-top: 12rpx;
  410. font-size: 24rpx;
  411. color: #586069;
  412. display: flex;
  413. justify-content: space-between;
  414. align-items: center;
  415. }
  416. .releases-count{
  417. display: flex;
  418. gap: 0 6rpx;
  419. box-sizing: border-box;
  420. border: 2rpx solid #ebedf0;
  421. border-radius: 10rpx;
  422. padding: 8rpx 12rpx;
  423. font-size: 24rpx;
  424. }
  425. </style>