重新设计 Shoka 主题统计页面,加入多年份文章日历、发布趋势、创作时钟、文章体量散点图、标签排行、分类分布以及分类与标签旭日图。
#前言
早期的统计页面只包含几个基础 ECharts 图表,功能可以使用,但在移动端、PJAX 页面切换、日间与夜间模式以及大量数据展示方面不够完善。本次改造在保留 Shoka 配色的基础上,重新设计图表卡片和加载方式,并让图表只在接近可视区域时初始化。
最终页面包含以下内容:
- 可从左侧切换年份的文章发布日历;
- 支持最近 6、12、24 个月、全部月份和自定义范围的发布趋势;
- 按星期和 24 小时展示发布习惯的创作时钟;
- 可点击进入文章、并随可视时间范围调整纵轴的文章体量散点图;
- TOP 10 标签、文章分类分布;
- 展示 “分类 → 标签” 关系的旭日图;
- PJAX 切换、窗口缩放、移动端和日间/夜间模式实时适配。
#开始前准备
如果是第一次修改 Hexo 主题,请先完成下面几项:
- 找到博客根目录。能看到
_config.yml、package.json、source和themes文件夹的目录就是根目录; - 备份
themes/shoka和source/statistics,或者先提交一次 Git; - 在博客根目录打开终端,执行
npm install,确认依赖已经安装; - 本文出现的路径都从博客根目录开始计算,不要把主题配置文件和站点根配置文件混淆;
- 每完成一个大步骤都可以执行一次
hexo generate --bail,这样更容易找到出错位置。
本教程需要 cheerio 、 moment 和 echarts 。如果项目还没有安装,请执行:
npm install cheerio moment echarts --save |
请不要直接在 public 文件夹中修改代码。 public 是 Hexo 自动生成的目录,下一次执行 hexo clean 后其中的手动修改会全部消失。
#文件结构
本次主要涉及以下文件:
source/statistics/index.md | |
themes/shoka/scripts/helpers/charts.js | |
themes/shoka/source/js/_app/page.js | |
themes/shoka/scripts/generaters/script.js | |
themes/shoka/build-assets/echarts-custom-entry.js | |
themes/shoka/source/js/echarts-custom.min.js | |
themes/shoka/_config.yml |
#文件变更清单
下面按 “新增、修改、自动生成、删除” 说明每个文件发生了什么。这里描述的是完成本教程后的最终状态,实际操作时应按后文步骤依次进行。
| 操作 | 文件 | 具体变化 |
|---|---|---|
| 修改或重建 | source/statistics/index.md | 重做统计页结构和样式;加入年份选择、时间范围选择、7 个图表容器及主题同步脚本;关闭评论和版权区。若原来没有统计页,则把它视为新增文件。 |
| 修改 | themes/shoka/scripts/helpers/charts.js | 从基础统计扩展为 7 组数据生成函数;增加年份、月份、星期/小时、文章字数、分类和标签关系数据;为浏览器端注入独立脚本。 |
| 修改 | themes/shoka/source/js/_app/page.js | 新增统计图表统一管理器;按接近可视区域的顺序初始化图表;统一注册缩放、销毁和观察器。 |
| 修改 | themes/shoka/source/js/_app/pjax.js | 页面离开前销毁旧图表,新页面美化完成后重新挂载图表,解决 PJAX 往返后空白和重复监听。 |
| 修改 | themes/shoka/scripts/generaters/script.js | 把 ECharts CDN 地址和本地后备地址写入浏览器端 CONFIG 。 |
| 修改 | themes/shoka/_config.yml | 增加统计菜单与 ECharts 供应地址;其余主题配置保持不变。 |
| 修改 | themes/shoka/languages/zh-CN.yml | 为统计菜单增加中文名称。 |
| 新增 | themes/shoka/build-assets/echarts-custom-entry.js | 只注册本页使用的 ECharts 图表、组件和 Canvas 渲染器,作为定制构建入口。 |
| 自动生成 | themes/shoka/source/js/echarts-custom.min.js | 由上一项通过 esbuild 生成,作为 CDN 失败时的本地后备;不要手工修改。 |
| 修改 | package.json | 声明教程需要的直接依赖和现有项目依赖。执行安装命令后由 npm 更新。 |
| 自动生成 | package-lock.json | npm 根据依赖版本自动更新锁定结果;不要手工编辑。 |
本次没有删除任何项目文件。内容层面替换的是统计页旧卡片布局、旧图表初始化方式和零散的 PJAX 事件;不是把旧文件直接删除。如果你已做过自定义修改,应逐段合并,不要不经比较就覆盖整个主题。
#实现
#第 1 步:新建统计页面
执行:
hexo new page statistics |
也可以直接创建 source/statistics/index.md ,写入:
--- | |
title: 文章统计 | |
date: 2026-08-19 10:00:00 | |
comment: false | |
copyright: false | |
--- |
统计页不需要评论,所以这里关闭评论和版权信息。
具体操作如下:
- 打开博客根目录下的
source文件夹; - 新建名为
statistics的文件夹; - 在文件夹中创建
index.md; - 把上面的 Front Matter 粘贴到文件最开头;
- 保存为 UTF-8 编码。
#第 2 步:添加菜单和 ECharts
在 themes/shoka/_config.yml 的菜单中加入:
menu: | |
statistics: /statistics/ || clock |
打开文件后搜索 menu: ,把 statistics 放在该配置块中,并保持与其他菜单项相同的缩进。YAML 必须使用空格,不能使用 Tab。
在语言文件 themes/shoka/languages/zh-CN.yml 中加入:
menu: | |
statistics: 统计 |
然后配置 ECharts:
vendors: | |
js: | |
echarts: npm/echarts@6.1.0/dist/echarts.min.js |
这里配置的是首选 CDN 地址。后面还会增加本地后备文件,所以 CDN 访问失败时页面仍然可以显示图表。
#第 3 步:在统计页面加入图表容器
重新打开 source/statistics/index.md 。在 Front Matter 下面加入 raw 包裹区域,页面的样式、脚本和图表 HTML 都放在这两个标记之间:
{% raw %} | |
<style> | |
/* 统计页面样式放在这里 */ | |
</style> | |
<script type="text/x-shoka-statistics" id="statisticsThemeRuntime"> | |
/* 日间、夜间模式同步代码放在这里 */ | |
</script> | |
{% endraw %} |
上面的 raw 开始和结束标记必须成对出现。缺少结束标记会导致统计页面后面的 Markdown 内容无法正常渲染。
为了让所有图表保持统一结构,每个图表都使用 “图标 + 标题 + 说明 + 图表容器” 的卡片布局。以下是文章发布趋势的结构:
<section class="statistics-chart-card statistics-chart-card--trend" aria-labelledby="posts-chart-title"> | |
<header class="statistics-chart-card__header"> | |
<span class="statistics-chart-card__icon" aria-hidden="true"> | |
<i class="ic i-chart-area"></i> | |
</span> | |
<div class="statistics-chart-card__heading"> | |
<h3 id="posts-chart-title">文章发布趋势</h3> | |
<p>按月份观察创作节奏,可拖动底部滑块调整范围</p> | |
</div> | |
<div class="statistics-chart-toolbar"> | |
<label for="posts-chart-range">统计范围</label> | |
<select id="posts-chart-range"> | |
<option value="6" selected>最近 6 个月</option> | |
<option value="12">最近 12 个月</option> | |
<option value="24">最近 24 个月</option> | |
<option value="all">全部月份</option> | |
<option value="custom">自定义</option> | |
</select> | |
</div> | |
</header> | |
<div class="statistics-chart-card__body"> | |
<div id="posts-chart" class="statistics-chart-card__chart"></div> | |
</div> | |
</section> |
其他图表只需要替换容器 ID:
| 图表 | 容器 ID | ECharts 类型 |
|---|---|---|
| 文章发布日历 | posts-calendar | calendar + heatmap |
| 发布趋势 | posts-chart | line |
| 创作时钟 | creation-clock-chart | polar + bar |
| 文章体量 | article-size-chart | scatter |
| 标签排行 | tags-chart | bar |
| 分类分布 | categories-chart | pie |
| 分类与标签 | category-tag-sunburst | sunburst |
多年份日历额外增加年份导航:
<div class="statistics-calendar-card__body"> | |
<nav id="posts-calendar-years" | |
class="statistics-calendar-years" | |
aria-label="选择文章发布年份"></nav> | |
<div class="statistics-calendar-canvas"> | |
<div id="posts-calendar"></div> | |
</div> | |
</div> |
把所有容器添加完成后,页面暂时仍是空白,这是正常现象。下一步才会读取 Hexo 文章数据并生成 ECharts 配置。
#第 4 步:添加统计卡片样式
把下面的 CSS 放进刚才创建的 <style> 标签。 raw 标记可以避免页面中的模板符号被 Hexo 提前解析。
卡片基础样式如下:
.statistics-chart-card { | |
position: relative; | |
margin: 0 0 26px; | |
overflow: hidden; | |
color: var(--text-color); | |
border: 1px solid rgba(171, 102, 255, .14); | |
border-radius: 20px; | |
background: | |
radial-gradient(circle at 94% 0, rgba(116, 182, 247, .09), transparent 32%), | |
linear-gradient(145deg, var(--grey-0), var(--grey-1)); | |
box-shadow: 0 16px 42px rgba(80, 53, 118, .09); | |
} | |
.statistics-chart-card__header { | |
display: flex; | |
align-items: center; | |
gap: 12px; | |
min-height: 64px; | |
padding: 14px 20px; | |
border-bottom: 1px solid rgba(127, 127, 127, .11); | |
} | |
.statistics-chart-card__icon { | |
width: 40px; | |
height: 40px; | |
flex: 0 0 40px; | |
display: grid; | |
place-items: center; | |
color: #fff; | |
border-radius: 13px; | |
background: linear-gradient(135deg, #c48bff, #ab66ff 48%, #73b9f4); | |
box-shadow: 0 8px 20px rgba(171, 102, 255, .25); | |
} | |
.statistics-chart-card__heading h3 { | |
margin: 0; | |
color: var(--primary-color); | |
font-size: 17px; | |
} | |
.statistics-chart-card__heading p { | |
margin: 3px 0 0; | |
color: var(--grey-5); | |
font-size: 12px; | |
} | |
.statistics-chart-card__chart { | |
width: 100%; | |
height: 350px; | |
} | |
[data-theme='dark'] .statistics-chart-card { | |
border-color: rgba(190, 145, 255, .15); | |
background: | |
radial-gradient(circle at 94% 0, rgba(116, 182, 247, .1), transparent 32%), | |
linear-gradient(145deg, rgba(37, 39, 51, .96), rgba(28, 30, 40, .96)); | |
box-shadow: 0 18px 48px rgba(0, 0, 0, .17); | |
} |
移动端让工具栏换行,并适当降低图表高度:
@media (max-width: 767px) { | |
.statistics-chart-card__header { | |
align-items: flex-start; | |
flex-wrap: wrap; | |
padding: 14px; | |
} | |
.statistics-chart-toolbar { | |
width: 100%; | |
justify-content: flex-start; | |
margin-left: 52px; | |
} | |
.statistics-chart-card__chart { | |
height: 340px; | |
} | |
} |
#第 5 步:在构建阶段生成统计数据
打开 themes/shoka/scripts/helpers 。如果没有 charts.js 就新建该文件;如果已经存在,请先备份,再在其中注册 after_render:html 过滤器。它只在页面出现对应容器时注入图表脚本,因此普通文章不会携带统计数据。
'use strict' | |
const cheerio = require('cheerio') | |
const moment = require('moment') | |
hexo.extend.filter.register('after_render:html', function (html) { | |
const $ = cheerio.load(html) | |
const charts = [ | |
['#posts-calendar', '#postsCalendar', postsCalendar], | |
['#posts-chart', '#postsChart', postsChart], | |
['#tags-chart', '#tagsChart', function () { | |
return tagsChart($('#tags-chart').attr('data-length')) | |
}], | |
['#categories-chart', '#categoriesChart', categoriesChart], | |
['#creation-clock-chart', '#creationClockChart', creationClockChart], | |
['#article-size-chart', '#articleSizeChart', articleSizeChart], | |
['#category-tag-sunburst', '#categoryTagSunburstChart', categoryTagSunburstChart] | |
] | |
let changed = false | |
charts.forEach(function (item) { | |
const target = $(item[0]) | |
if (target.length && !$(item[1]).length) { | |
target.after(item[2]()) | |
changed = true | |
} | |
}) | |
return changed ? $.root().html().replace(/&#/g, '&#') : html | |
}, 15) |
每个生成函数都分成两部分:Node.js 负责整理 Hexo 数据,浏览器端脚本负责创建 ECharts 实例。
按照下面的顺序添加函数,函数名必须和过滤器中的名称完全一致:
postsCalendar();postsChart();tagsChart();categoriesChart();creationClockChart();articleSizeChart();categoryTagSunburstChart()。
以文章日历为例,先按日期和年份聚合文章数量:
function postsCalendar () { | |
const dateMap = new Map() | |
const yearMap = new Map() | |
hexo.locals.get('posts').forEach(function (post) { | |
const postDate = moment(post.date).startOf('day') | |
const date = postDate.format('YYYY-MM-DD') | |
const year = postDate.year() | |
dateMap.set(date, (dateMap.get(date) || 0) + 1) | |
yearMap.set(year, (yearMap.get(year) || 0) + 1) | |
}) | |
const availableYears = Array.from(yearMap.keys()).sort(function (a, b) { | |
return b - a | |
}) | |
const datePosts = [] | |
availableYears.forEach(function (year) { | |
const start = moment.utc([year, 0, 1]) | |
const end = moment.utc([year, 11, 31]) | |
for (let day = start.clone(); !day.isAfter(end); day.add(1, 'day')) { | |
const date = day.format('YYYY-MM-DD') | |
datePosts.push([date, dateMap.get(date) || 0]) | |
} | |
}) | |
return ` | |
<script type="text/x-shoka-statistics" id="postsCalendar"> | |
var postsCalendar = echarts.init(document.getElementById('posts-calendar'), 'light') | |
var postsCalendarData = ${JSON.stringify(datePosts)} | |
var postsCalendarYears = ${JSON.stringify(availableYears)} | |
// 根据选中年份过滤数据并调用 postsCalendar.setOption(...) | |
</script>` | |
} |
其他图表的数据来源:
postsChart():按YYYY-MM统计每月文章数量;tagsChart():读取hexo.locals.get('tags'),按文章数量降序排序;categoriesChart():读取分类名称和分类文章数量;creationClockChart():使用文章发布日期的星期和小时生成 7 × 24 数据;articleSizeChart():读取文章字数、日期、标题、分类和路径;拖动时间条时,只按当前可视范围重新计算纵轴上限,避免少数超长文章压缩其他散点;categoryTagSunburstChart():建立分类到标签的两层children数据。
旭日图的数据结构如下:
const data = [{ | |
name: 'SHOKA', | |
value: 10, | |
linkType: 'category', | |
children: [{ | |
name: 'Hexo', | |
value: 6, | |
linkType: 'tag' | |
}] | |
}] |
点击图表进入分类、标签或文章页面:
categoryTagSunburstChart.on('click', 'series', function (event) { | |
if (!event.data || !event.data.linkType) return | |
var base = event.data.linkType === 'category' ? '/categories/' : '/tags/' | |
window.location.href = base + encodeURIComponent(event.name) + '/' | |
}) |
#第 6 步:解决 PJAX 重复初始化
Shoka 使用 PJAX 切换页面。直接在页面内执行 ECharts 会产生以下问题:
- 第一次进入统计页可能没有加载 ECharts;
- 返回统计页时旧实例没有销毁;
- 每次进入都会重复注册
resize事件; - 页面很长时所有图表会同时初始化。
在 themes/shoka/source/js/_app/page.js 中建立统一管理器:
const destroyStatisticsCharts = function () { | |
var manager = window.ShokaStatisticsCharts | |
if (manager && typeof manager.destroy === 'function') manager.destroy() | |
if (window.statisticsThemeObserver) { | |
window.statisticsThemeObserver.disconnect() | |
window.statisticsThemeObserver = null | |
} | |
window.ShokaStatisticsCharts = null | |
} | |
const mountStatisticsCharts = function () { | |
var scripts = Array.prototype.slice.call( | |
document.querySelectorAll('script[type="text/x-shoka-statistics"]') | |
) | |
if (!scripts.length || !CONFIG.js.echarts) return | |
getScript(assetUrl('js', 'echarts'), function () { | |
if (!window.echarts) return | |
destroyStatisticsCharts() | |
var entries = [] | |
var observer = null | |
window.ShokaStatisticsCharts = { | |
register: function (chart, resize) { | |
entries.push({ chart: chart, resize: resize }) | |
}, | |
setObserver: function (value) { | |
observer = value | |
}, | |
destroy: function () { | |
if (observer) observer.disconnect() | |
entries.forEach(function (entry) { | |
if (entry.chart && !entry.chart.isDisposed()) entry.chart.dispose() | |
}) | |
entries = [] | |
} | |
} | |
var execute = function (script) { | |
if (!script || script.dataset.executed === 'true') return | |
script.dataset.executed = 'true' | |
var runtime = document.createElement('script') | |
runtime.textContent = script.textContent | |
document.body.appendChild(runtime) | |
document.body.removeChild(runtime) | |
} | |
var targets = { | |
postsCalendar: 'posts-calendar', | |
postsChart: 'posts-chart', | |
tagsChart: 'tags-chart', | |
categoriesChart: 'categories-chart', | |
creationClockChart: 'creation-clock-chart', | |
articleSizeChart: 'article-size-chart', | |
categoryTagSunburstChart: 'category-tag-sunburst' | |
} | |
var io = new IntersectionObserver(function (items) { | |
items.forEach(function (item) { | |
if (!item.isIntersecting) return | |
execute(document.getElementById(item.target.dataset.statisticsScript)) | |
io.unobserve(item.target) | |
}) | |
}, { rootMargin: '280px 0px' }) | |
scripts.forEach(function (script) { | |
var target = document.getElementById(targets[script.id]) | |
if (!target) return | |
target.dataset.statisticsScript = script.id | |
io.observe(target) | |
}) | |
window.ShokaStatisticsCharts.setObserver(io) | |
}, window.echarts, CONFIG.fallback.js.echarts) | |
} |
然后打开 themes/shoka/source/js/_app/pjax.js ,完成两处连接:
- 在 PJAX 清空旧页面前调用
destroyStatisticsCharts(); - 在新页面执行完
postBeauty()后调用mountStatisticsCharts()。
const pjaxReload = function () { | |
pagePosition() | |
destroyStatisticsCharts() | |
// 下面保留主题原有代码 | |
} | |
const siteRefresh = function (reload) { | |
// 上面保留主题原有代码 | |
postBeauty() | |
mountStatisticsCharts() | |
// 下面保留主题原有代码 | |
} |
只添加调用,不要删除 siteRefresh() 中原有的评论、侧边栏、播放器和加载动画代码。
这样 ECharts 只在统计页加载,并且每张图会在接近视口时才初始化。
#第 7 步:实时适配日间与夜间模式
不要只监听主题按钮,因为自动夜间模式和其他代码也可能修改主题。监听根节点的 data-theme 更可靠:
function switchPostChart () { | |
var root = document.documentElement | |
var dark = root.hasAttribute('data-theme') | |
var color = window.getComputedStyle(document.body).color || (dark ? '#f5f5f5' : '#333') | |
var background = window.getComputedStyle(root).getPropertyValue('--grey-0').trim() | |
if (typeof postsChart !== 'undefined') { | |
postsChart.setOption({ | |
textStyle: { color: color }, | |
xAxis: { axisLabel: { color: color } }, | |
yAxis: { axisLabel: { color: color } } | |
}) | |
} | |
if (typeof categoryTagSunburstChart !== 'undefined') { | |
categoryTagSunburstChart.setOption({ | |
series: [{ | |
itemStyle: { borderColor: background }, | |
levels: [{}, { label: { color: '#fff' } }, { label: { color: '#fff' } }] | |
}] | |
}) | |
} | |
} | |
if (window.statisticsThemeObserver) window.statisticsThemeObserver.disconnect() | |
window.statisticsThemeObserver = new MutationObserver(function () { | |
window.requestAnimationFrame(switchPostChart) | |
}) | |
window.statisticsThemeObserver.observe(document.documentElement, { | |
attributes: true, | |
attributeFilter: ['data-theme'] | |
}) |
#第 8 步:配置 ECharts 本地后备包
如果主 CDN 加载失败,可以准备本地后备包。为了避免发布完整的 ECharts,只导入当前使用的图表和组件:
import * as echarts from 'echarts/core' | |
import { | |
BarChart, HeatmapChart, LineChart, PieChart, ScatterChart, SunburstChart | |
} from 'echarts/charts' | |
import { | |
AxisPointerComponent, CalendarComponent, DataZoomComponent, | |
GraphicComponent, GridComponent, LegendComponent, MarkLineComponent, | |
PolarComponent, TitleComponent, TooltipComponent, VisualMapComponent | |
} from 'echarts/components' | |
import { CanvasRenderer } from 'echarts/renderers' | |
echarts.use([ | |
BarChart, HeatmapChart, LineChart, PieChart, ScatterChart, SunburstChart, | |
AxisPointerComponent, CalendarComponent, DataZoomComponent, | |
GraphicComponent, GridComponent, LegendComponent, MarkLineComponent, | |
PolarComponent, TitleComponent, TooltipComponent, VisualMapComponent, | |
CanvasRenderer | |
]) | |
window.echarts = echarts |
使用 esbuild 生成浏览器包:
npx esbuild themes/shoka/build-assets/echarts-custom-entry.js \ | |
--bundle --minify --format=iife --platform=browser --target=es2018 \ | |
--outfile=themes/shoka/source/js/echarts-custom.min.js |
最后在 script.js 中配置:
fallback: { | |
js: { | |
echarts: config.root + theme.js + '/echarts-custom.min.js' | |
} | |
} |
同时确认 themes/shoka/scripts/generaters/script.js 的 siteConfig.js 中存在:
js: { | |
echarts: theme.vendors.js.echarts | |
} |
如果漏掉这一项,浏览器端的 CONFIG.js.echarts 会是空值,统计页面不会开始初始化。
#常见问题
#页面只有卡片,没有图表
依次检查:
- 浏览器控制台是否提示
echarts is not defined; _config.yml中是否配置了vendors.js.echarts;script.js是否把 ECharts 地址写入CONFIG.js;pjax.js是否调用了mountStatisticsCharts();- 图表容器 ID 与
charts.js中的 ID 是否完全一致。
#第一次进入正常,第二次进入空白
通常是 PJAX 切换时没有销毁旧实例。确认 pjaxReload() 调用了 destroyStatisticsCharts() ,并且管理器的 destroy() 中执行了 chart.dispose() 。
#夜间模式文字仍然是深色
不要只给主题切换按钮绑定点击事件。确认已经使用 MutationObserver 监听 document.documentElement 的 data-theme 属性。
#手机端图表被截断
确认图表容器使用 width: 100% ,并且注册了统一的窗口 resize 处理。年份列表和工具栏还需要在移动端缩小宽度或换行。
#测试
修改完成后执行:
hexo clean | |
hexo generate --bail | |
hexo server |
重点检查:
- 直接打开统计页和从其他页面 PJAX 进入统计页都能显示图表;
- 多次进入、离开统计页,控制台没有重复初始化错误;
- 切换日间与夜间模式后文字和边框立即更新;
- 手机端年份列表、筛选器和图表没有横向溢出;
- CDN 不可用时,本地 ECharts 后备包仍可加载。
#总结
这次改造没有把统计图表简单堆放在页面中,而是将数据生成、页面结构、图表加载和生命周期管理分开。后续增加新图表时,只需要:
- 在统计页添加一个容器;
- 在
charts.js新增数据生成函数; - 在
mountStatisticsCharts()的映射中注册容器; - 将新增的 ECharts 图表类型加入定制后备包。
#涉及改动的代码
代码卡片按属性名、函数名或带内容校验的具名片段定位,并显示当前源码行号。片段前后插行可自动重新定位;片段本身改变或位置不唯一时会停止生成,核对教程后再更新摘录,避免误引其他功能。
下面只展示本教程涉及的改动片段,不再展开整个文件。点击文件名后可以看到源码中的原始行号;“⋯ 未改动代码已省略 ⋯” 表示两处改动之间仍有项目原代码,合并时不要删除。
行号以当前项目为准,后续继续修改文件后可能发生偏移。定位时应优先搜索卡片中的函数名、选择器或配置项。 package-lock.json 和 echarts-custom.min.js 都由命令自动生成,不需要复制,也不要手工编辑。
#依赖与主题配置
package.json改动位置:第 35 行
"echarts": "6.1.0", |
themes/shoka/_config.yml 含有站点个性化配置,下面使用独立的脱敏示例。将这些字段合并到已有配置中,不再依赖可能变化的行号读取实际站点配置:
source/code-examples/shoka-tutorial/statistics.yml完整文件
# 修改位置:themes/shoka/_config.yml,合并到已有 menu 和 vendors 字段 | |
# 仅为公开示例,不包含实际站点的域名、账号、邮箱或密钥。 | |
menu: | |
statistics: | |
default: /statistics/ || chart-area | |
poststatistics: /statistics/ || clock | |
vendors: | |
js: | |
echarts: npm/echarts@6.1.0/dist/echarts.min.js | |
themes/shoka/languages/zh-CN.yml改动位置:第 31–32 行
statistics: 统计 | |
poststatistics: 文章统计 |
#统计页面与数据生成
source/statistics/index.md改动位置:第 1–8、10–34、580–622、834–842、844–890、892–955 行
--- | |
title: 文章统计 | |
date: 2021-08-31 14:33:58 | |
comment: false | |
copyright: false | |
--- | |
{% raw %} | |
⋯ 未改动代码已省略 ⋯ | |
.statistics-chart-toolbar { | |
display: flex; | |
align-items: center; | |
justify-content: flex-end; | |
gap: 8px; | |
margin-left: auto; | |
font-size: 12px; | |
} | |
.statistics-calendar-card { | |
position: relative; | |
margin: 10px 0 26px; | |
overflow: hidden; | |
color: var(--text-color); | |
border: 1px solid rgba(var(--palette-primary-light-rgb, 171, 102, 255), .14); | |
border-radius: 20px; | |
background: | |
radial-gradient(circle at 92% 4%, rgba(var(--palette-primary-light-rgb, 171, 102, 255), .13), transparent 32%), | |
linear-gradient(145deg, var(--grey-0), var(--grey-1)); | |
box-shadow: 0 16px 42px rgba(var(--palette-primary-shade-rgb, 80, 53, 118), .09); | |
} | |
.statistics-calendar-card::before { | |
content: ''; | |
position: absolute; | |
⋯ 未改动代码已省略 ⋯ | |
<script type="text/x-shoka-statistics" id="statisticsThemeRuntime"> | |
function switchPostChart () { | |
var root = document.documentElement | |
var dark = root.hasAttribute('data-theme') | |
var color = window.getComputedStyle(document.body).color || (dark ? '#f5f5f5' : '#333') | |
var axisColor = window.getComputedStyle(root).getPropertyValue('--grey-4').trim() || color | |
var calendarBackground = window.getComputedStyle(root).getPropertyValue('--grey-0').trim() || (dark ? '#333' : '#fff') | |
var splitColor = dark ? 'rgba(255,255,255,.08)' : 'rgba(81,72,99,.09)' | |
var trackColor = dark ? 'rgba(255,255,255,.055)' : 'rgba(108,82,142,.055)' | |
var chartCenterColor = dark ? '#e4dff0' : '#554b66' | |
var calendarHeatColors = dark | |
? ['#343744', '#685279', '#9a5ca8', '#cb62bd', '#ff78ce'] | |
: ['#f0eef4', '#e8c6f1', '#d18be2', '#ab66ff', '#7140a5'] | |
var paletteStyle = window.getComputedStyle(root) | |
var accent = function (name, fallback) { | |
return paletteStyle.getPropertyValue('--palette-' + name).trim() || fallback | |
} | |
var tint = function (name, alpha, fallback) { | |
var channels = paletteStyle.getPropertyValue('--palette-' + name + '-rgb').trim() | |
return channels ? 'rgba(' + channels + ',' + alpha + ')' : fallback | |
} | |
var gradient = function (stops, vertical) { | |
return { type: 'linear', x: 0, y: 0, x2: vertical ? 0 : 1, y2: vertical ? 1 : 0, | |
colorStops: stops.map(function (value, index) { return { offset: index / (stops.length - 1), color: value } }) } | |
} | |
if (root.dataset.palette === 'custom') calendarHeatColors = dark | |
? ['primary-deep', 'primary-shade', 'primary', 'primary-light', 'primary-soft'].map(function (name) { return accent(name, color) }) | |
: ['primary-mist', 'primary-pale', 'primary-light', 'primary', 'primary-shade'].map(function (name) { return accent(name, color) }) | |
var zoomColors = { | |
fillerColor: tint('primary', .18, 'rgba(171, 102, 255, .18)'), | |
handleStyle: { color: accent('primary', '#ab66ff'), borderColor: accent('primary-pale', '#d8b9ff') }, | |
dataBackground: { lineStyle: { color: tint('primary', .42, 'rgba(171, 102, 255, .42)') }, areaStyle: { color: tint('primary', .12, 'rgba(171, 102, 255, .12)') } }, | |
selectedDataBackground: { lineStyle: { color: accent('primary', '#ab66ff') }, areaStyle: { color: tint('primary', .2, 'rgba(171, 102, 255, .2)') } }, | |
moveHandleStyle: { color: tint('primary', .4, 'rgba(171, 102, 255, .4)') } | |
} | |
var axis = function () { | |
return { | |
nameTextStyle: { color: color }, | |
axisLabel: { color: color }, | |
axisLine: { lineStyle: { color: axisColor } }, | |
splitLine: { lineStyle: { color: splitColor } } | |
} | |
} | |
⋯ 未改动代码已省略 ⋯ | |
if (window.statisticsThemeObserver) window.statisticsThemeObserver.disconnect() | |
window.statisticsThemeObserver = new MutationObserver(function () { | |
if (!document.getElementById('posts-calendar')) return | |
window.requestAnimationFrame(switchPostChart) | |
}) | |
window.statisticsThemeObserver.observe(document.documentElement, { | |
attributes: true, | |
attributeFilter: ['data-theme', 'data-palette'] | |
}) | |
⋯ 未改动代码已省略 ⋯ | |
<section class="statistics-calendar-card" aria-labelledby="posts-calendar-title"> | |
<header class="statistics-calendar-card__header"> | |
<span class="statistics-calendar-card__icon" aria-hidden="true"><i class="ic i-calendar"></i></span> | |
<div class="statistics-calendar-card__heading"> | |
<h3 id="posts-calendar-title">文章发布日历</h3> | |
<p>沿着时间轨迹,查看每一年的内容沉淀</p> | |
</div> | |
<div class="statistics-calendar-card__summary" aria-live="polite"> | |
<strong id="posts-calendar-year-label">—</strong> | |
<span id="posts-calendar-year-count">加载中</span> | |
</div> | |
</header> | |
<div class="statistics-calendar-card__body"> | |
<nav id="posts-calendar-years" class="statistics-calendar-years" aria-label="选择文章发布年份"></nav> | |
<div class="statistics-calendar-canvas"> | |
<div id="posts-calendar"></div> | |
</div> | |
</div> | |
</section> | |
<section class="statistics-chart-card statistics-chart-card--trend" aria-labelledby="posts-chart-title"> | |
<header class="statistics-chart-card__header"> | |
<span class="statistics-chart-card__icon" aria-hidden="true"><i class="ic i-chart-area"></i></span> | |
<div class="statistics-chart-card__heading"> | |
<h3 id="posts-chart-title">文章发布趋势</h3> | |
<p>按月份观察创作节奏,可拖动底部滑块调整范围</p> | |
</div> | |
<div class="statistics-chart-toolbar"> | |
<span class="statistics-chart-tip">支持拖动范围</span> | |
<label for="posts-chart-range">统计范围</label> | |
<select id="posts-chart-range" aria-label="选择文章发布统计时间范围"> | |
<option value="6" selected>最近 6 个月</option> | |
<option value="12">最近 12 个月</option> | |
<option value="24">最近 24 个月</option> | |
<option value="all">全部月份</option> | |
<option value="custom">自定义</option> | |
</select> | |
</div> | |
</header> | |
<div class="statistics-chart-card__body"> | |
<div id="posts-chart" class="statistics-chart-card__chart"></div> | |
<div id="posts-chart-empty" class="statistics-chart-empty" aria-live="polite"> | |
<i class="ic i-feather" aria-hidden="true"></i> | |
<strong>这段时间还没有新文章</strong> | |
<span>切换统计范围或拖动滑块,查看更早的更新记录</span> | |
</div> | |
</div> | |
</section> | |
⋯ 未改动代码已省略 ⋯ | |
<section class="statistics-chart-card statistics-chart-card--clock" aria-labelledby="creation-clock-title"> | |
<header class="statistics-chart-card__header"> | |
<span class="statistics-chart-card__icon" aria-hidden="true"><i class="ic i-clock"></i></span> | |
<div class="statistics-chart-card__heading"> | |
<h3 id="creation-clock-title">创作时钟</h3> | |
<p>以 24 小时为刻度,观察一周中最常发布内容的时间</p> | |
</div> | |
</header> | |
<div class="statistics-chart-card__body"> | |
<div id="creation-clock-chart" class="statistics-chart-card__chart"></div> | |
</div> | |
</section> | |
<section class="statistics-chart-card statistics-chart-card--scatter" aria-labelledby="article-size-title"> | |
<header class="statistics-chart-card__header"> | |
<span class="statistics-chart-card__icon" aria-hidden="true"><i class="ic i-pen"></i></span> | |
<div class="statistics-chart-card__heading"> | |
<h3 id="article-size-title">文章体量散点图</h3> | |
<p>气泡越大代表字数越多,纵轴会随时间范围自适应</p> | |
</div> | |
</header> | |
<div class="statistics-chart-card__body"> | |
<div id="article-size-chart" class="statistics-chart-card__chart"></div> | |
</div> | |
</section> | |
<section class="statistics-chart-card statistics-chart-card--tags" aria-labelledby="tags-chart-title"> | |
<header class="statistics-chart-card__header"> | |
<span class="statistics-chart-card__icon" aria-hidden="true"><i class="ic i-tags"></i></span> | |
<div class="statistics-chart-card__heading"> | |
<h3 id="tags-chart-title">TOP 10 标签</h3> | |
<p>高频内容主题排行,点击固定详情,再打开对应标签页</p> | |
</div> | |
</header> | |
<div class="statistics-chart-card__body"> | |
<div id="tags-chart" class="statistics-chart-card__chart" data-length="10"></div> | |
</div> | |
</section> | |
<section class="statistics-chart-card statistics-chart-card--categories" aria-labelledby="categories-chart-title"> | |
<header class="statistics-chart-card__header"> | |
<span class="statistics-chart-card__icon" aria-hidden="true"><i class="ic i-th"></i></span> | |
<div class="statistics-chart-card__heading"> | |
<h3 id="categories-chart-title">文章分类分布</h3> | |
<p>查看内容结构占比,点击固定详情,再打开对应分类页</p> | |
</div> | |
</header> | |
<div class="statistics-chart-card__body"> | |
<div id="categories-chart" class="statistics-chart-card__chart"></div> | |
</div> | |
</section> | |
<section class="statistics-chart-card statistics-chart-card--sunburst" aria-labelledby="category-tag-sunburst-title"> | |
<header class="statistics-chart-card__header"> | |
<span class="statistics-chart-card__icon" aria-hidden="true"><i class="ic i-sitemap"></i></span> | |
<div class="statistics-chart-card__heading"> | |
<h3 id="category-tag-sunburst-title">分类与标签旭日图</h3> | |
<p>内环展示分类,外环展开关联标签,点击固定详情与专题入口</p> | |
</div> | |
</header> | |
<div class="statistics-chart-card__body"> | |
<div id="category-tag-sunburst" class="statistics-chart-card__chart"></div> | |
</div> | |
</section> |
themes/shoka/scripts/helpers/charts.js改动位置:第 3–69、77–98、260–283、509–530、646–668、759–780、911–937、949–1026、1072–1103、1138–1151、1157–1179 行
const cheerio = require('cheerio') | |
const moment = require('moment') | |
const chartURL = path => require('hexo-util').url_for.call({ config: hexo.config }, path, { relative: false }) | |
const chartJson = value => JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029') | |
hexo.extend.filter.register('after_render:html', function (locals) { | |
const $ = cheerio.load(locals) | |
const calendar = $('#posts-calendar') | |
const post = $('#posts-chart') | |
const tag = $('#tags-chart') | |
const category = $('#categories-chart') | |
const creationClock = $('#creation-clock-chart') | |
const articleSize = $('#article-size-chart') | |
const categoryTagSunburst = $('#category-tag-sunburst') | |
const pageview = $('#pageview-chart') | |
let htmlEncode = false | |
if (calendar.length > 0 || post.length > 0 || tag.length > 0 || category.length > 0 || creationClock.length > 0 || articleSize.length > 0 || categoryTagSunburst.length > 0 || pageview.length > 0) { | |
if ($('#statisticsPinnedDetailStyle').length === 0) { | |
$('#posts-calendar, #posts-chart, #tags-chart, #categories-chart, #creation-clock-chart, #article-size-chart, #category-tag-sunburst, #pageview-chart').first().before(`<style id="statisticsPinnedDetailStyle"> | |
.statistics-detail-tip { color:var(--text-color); font-size:13px; line-height:1.8; opacity:.8; } | |
.statistics-pinned-detail { position:relative; margin:0 16px 16px; padding:16px 72px 16px 18px; border:1px solid #d8c4ed; border-radius:14px; background:#f5effb; color:#493757; font-size:13px; line-height:1.85; overflow-wrap:anywhere; } | |
.statistics-pinned-detail__title { display:block; margin-bottom:6px; font-size:15px; font-weight:650; color:#654382; } | |
.statistics-pinned-detail p { margin:3px 0; line-height:1.85; } | |
.statistics-pinned-detail__close { position:absolute; right:10px; top:10px; min-width:44px; min-height:44px; padding:6px 9px; border:1px solid #d8c4ed; border-radius:9px; background:#eee3f8; color:#654382; font:inherit; cursor:pointer; } | |
.statistics-pinned-detail__link { display:inline-flex; align-items:center; min-height:44px; box-sizing:border-box; margin-top:10px; margin-right:8px; padding:6px 14px; border:1px solid #c6a7e3; border-radius:9px; background:#e9dcf7; color:#583879; font-weight:600; text-decoration:none; } | |
.statistics-pinned-detail__close:focus-visible,.statistics-pinned-detail__link:focus-visible { outline:3px solid #a76dd9; outline-offset:3px; } | |
[data-theme='dark'] .statistics-pinned-detail { border-color:#645075; background:#342d40; color:#e5dcef; } | |
[data-theme='dark'] .statistics-pinned-detail__title { color:#dfc4f5; } | |
[data-theme='dark'] .statistics-pinned-detail__close,[data-theme='dark'] .statistics-pinned-detail__link { border-color:#79608f; background:#4a395c; color:#ead6fc; } | |
html[data-palette='custom'] .statistics-pinned-detail { border-color:var(--color-red-a3); background:var(--palette-primary-surface); color:var(--text-color); } | |
html[data-palette='custom'] .statistics-pinned-detail__title { color:var(--primary-color); } | |
html[data-palette='custom'] .statistics-pinned-detail__close,html[data-palette='custom'] .statistics-pinned-detail__link { border-color:var(--color-red-a3); background:var(--color-red-a1); color:var(--primary-color); } | |
html[data-palette='custom'] .statistics-pinned-detail__close:focus-visible,html[data-palette='custom'] .statistics-pinned-detail__link:focus-visible { outline-color:var(--primary-color); } | |
@media(max-width:767px) { .statistics-pinned-detail { margin:0 10px 12px; padding:14px 64px 14px 14px; font-size:13px; } } | |
</style>`) | |
} | |
if (calendar.length > 0 && $('#postsCalendar').length === 0) { | |
if (calendar.attr('data-encode') === 'true') htmlEncode = true | |
calendar.after(postsCalendar()) | |
} | |
if (post.length > 0 && $('#postsChart').length === 0) { | |
if (post.attr('data-encode') === 'true') htmlEncode = true | |
post.after(postsChart()) | |
} | |
if (tag.length > 0 && $('#tagsChart').length === 0) { | |
if (tag.attr('data-encode') === 'true') htmlEncode = true | |
tag.after(tagsChart(tag.attr('data-length'))) | |
} | |
if (category.length > 0 && $('#categoriesChart').length === 0) { | |
if (category.attr('data-encode') === 'true') htmlEncode = true | |
category.after(categoriesChart()) | |
} | |
if (creationClock.length > 0 && $('#creationClockChart').length === 0) { | |
if (creationClock.attr('data-encode') === 'true') htmlEncode = true | |
creationClock.after(creationClockChart()) | |
} | |
if (articleSize.length > 0 && $('#articleSizeChart').length === 0) { | |
if (articleSize.attr('data-encode') === 'true') htmlEncode = true | |
articleSize.after(articleSizeChart()) | |
} | |
if (categoryTagSunburst.length > 0 && $('#categoryTagSunburstChart').length === 0) { | |
if (categoryTagSunburst.attr('data-encode') === 'true') htmlEncode = true | |
categoryTagSunburst.after(categoryTagSunburstChart()) | |
} | |
if (pageview.length > 0 && $('#pageviewChart').length === 0) { | |
if (pageview.attr('data-encode') === 'true') htmlEncode = true | |
⋯ 未改动代码已省略 ⋯ | |
} | |
} else { | |
return locals | |
} | |
}, 15) | |
function postsCalendar () { | |
const dateMap = new Map() | |
const yearMap = new Map() | |
hexo.locals.get('posts').forEach(function (post) { | |
const postDate = moment(post.date).startOf('day') | |
const date = postDate.format('YYYY-MM-DD') | |
const year = postDate.year() | |
dateMap.set(date, (dateMap.get(date) || 0) + 1) | |
yearMap.set(year, (yearMap.get(year) || 0) + 1) | |
}) | |
const availableYears = Array.from(yearMap.keys()).sort(function (a, b) { | |
return b - a | |
}) | |
if (availableYears.length === 0) { | |
⋯ 未改动代码已省略 ⋯ | |
postsCalendar.resize() | |
renderPostsCalendar(postsCalendarSelectedYear) | |
}, function (event) { | |
var value = event.value || [] | |
return { title: value[0], lines: [value[1] + ' 篇文章', Number(value[1]) === 0 ? '当天没有文章发布记录。' : '按文章发布日期统计。'] } | |
}) | |
</script>` | |
} | |
function postsChart () { | |
const posts = hexo.locals.get('posts') | |
let startDate = moment().startOf('month') | |
const endDate = moment() | |
posts.forEach(function (post) { | |
const postDate = moment(post.date).startOf('month') | |
if (postDate.isBefore(startDate)) startDate = postDate | |
}) | |
const monthMap = new Map() | |
for (let cursor = startDate.clone(); !cursor.isAfter(endDate, 'month'); cursor.add(1, 'month')) { | |
monthMap.set(cursor.format('YYYY-MM'), 0) | |
} | |
posts.forEach(function (post) { | |
⋯ 未改动代码已省略 ⋯ | |
window.ShokaStatisticsCharts.register(postsChart, function () { | |
postsChart.resize() | |
}, function (event) { | |
return { title: event.name, lines: [event.value + ' 篇文章', Number(event.value) === 0 ? '该月没有文章发布记录。' : '按文章发布月份统计。'] } | |
}) | |
</script>` | |
} | |
function tagsChart (len) { | |
const tagArr = [] | |
hexo.locals.get('tags').map(function (tag) { | |
tagArr.push({ name: tag.name, value: tag.length, path: chartURL(tag.path) }) | |
}) | |
tagArr.sort((a, b) => { return b.value - a.value }) | |
let dataLength = Math.min(tagArr.length, len) || tagArr.length | |
const tagNameArr = [] | |
const tagCountArr = [] | |
for (let i = 0; i < dataLength; i++) { | |
tagNameArr.push(tagArr[i].name) | |
tagCountArr.push({ value: tagArr[i].value, path: tagArr[i].path }) | |
} | |
⋯ 未改动代码已省略 ⋯ | |
window.ShokaStatisticsCharts.register(tagsChart, function () { | |
tagsChart.resize() | |
}, function (event) { | |
return { title: event.name, lines: [event.value + ' 篇文章'], links: [{ href: event.data && event.data.path, label: '查看该标签文章' }] } | |
}) | |
</script>` | |
} | |
function categoriesChart () { | |
const categoryArr = [] | |
hexo.locals.get('categories').map(function (category) { | |
categoryArr.push({ name: category.name, value: category.length, path: chartURL(category.path) }) | |
}) | |
categoryArr.sort((a, b) => { return b.value - a.value }); | |
const categoryArrJson = chartJson(categoryArr) | |
const categoryTotal = categoryArr.reduce((total, category) => total + category.value, 0) | |
return ` | |
<script type="text/x-shoka-statistics" id="categoriesChart"> | |
var categoriesChart = echarts.init(document.getElementById('categories-chart'), 'light') | |
if (window.ShokaStatisticsCharts.bindMotion) window.ShokaStatisticsCharts.bindMotion(categoriesChart) | |
var categoriesChartRoot = document.documentElement | |
var categoriesChartDark = categoriesChartRoot.hasAttribute('data-theme') | |
⋯ 未改动代码已省略 ⋯ | |
window.ShokaStatisticsCharts.register(categoriesChart, function () { | |
categoriesChart.resize() | |
}, function (event) { | |
return { title: event.name, lines: [event.value + ' 篇文章', event.percent == null ? '' : '分类占比 ' + event.percent + '%'], links: [{ href: event.data && event.data.path, label: '查看该分类文章' }] } | |
}) | |
</script>` | |
} | |
function creationClockChart () { | |
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] | |
const hourLabels = Array.from({ length: 24 }, function (_, hour) { | |
return String(hour).padStart(2, '0') + ':00' | |
}) | |
const hourStats = Array.from({ length: 24 }, function () { | |
return { total: 0, weekdays: [0, 0, 0, 0, 0, 0, 0] } | |
}) | |
hexo.locals.get('posts').forEach(function (post) { | |
const postDate = moment(post.date) | |
const weekday = (postDate.day() + 6) % 7 | |
const hour = postDate.hour() | |
hourStats[hour].total += 1 | |
⋯ 未改动代码已省略 ⋯ | |
window.ShokaStatisticsCharts.register(creationClockChart, function () { | |
creationClockChart.resize() | |
}, function (event) { | |
var weekdays = ${chartJson(weekdays)} | |
var lines = [event.value + ' 篇文章'] | |
;((event.data && event.data.breakdown) || []).forEach(function (count, index) { lines.push(weekdays[index] + ':' + count + ' 篇') }) | |
if (Number(event.value) === 0) lines.push('该时段暂无发布记录。') | |
return { title: event.name, lines: lines } | |
}) | |
</script>` | |
} | |
function articleSizeChart () { | |
const palette = ['#ab66ff', '#668cf2', '#54b8d7', '#55c4a8', '#e7a85f', '#e476ad', '#9279dc', '#6d9ccf'] | |
const categoryColorMap = new Map() | |
const scatterData = [] | |
function getCollectionNames (collection) { | |
const names = [] | |
if (!collection || typeof collection.forEach !== 'function') return names | |
collection.forEach(function (item) { | |
if (item && item.name) names.push(String(item.name)) | |
}) | |
return names | |
} | |
hexo.locals.get('posts').sort('date').forEach(function (post) { | |
⋯ 未改动代码已省略 ⋯ | |
const scatterCategories = Array.from(categoryColorMap, function (entry) { | |
return { name: entry[0], color: entry[1] } | |
}) | |
return ` | |
<script type="text/x-shoka-statistics" id="articleSizeChart"> | |
var articleSizeChart = echarts.init(document.getElementById('article-size-chart'), 'light') | |
if (window.ShokaStatisticsCharts.bindMotion) window.ShokaStatisticsCharts.bindMotion(articleSizeChart) | |
var articleSizeRoot = document.documentElement | |
var articleSizeDark = articleSizeRoot.hasAttribute('data-theme') | |
var articleSizeTextColor = window.getComputedStyle(document.body).color || (articleSizeDark ? '#f5f5f5' : '#333') | |
var articleSizeAxisColor = window.getComputedStyle(articleSizeRoot).getPropertyValue('--grey-4').trim() || articleSizeTextColor | |
var articleSizeSplitColor = articleSizeDark ? 'rgba(255,255,255,.08)' : 'rgba(81,72,99,.09)' | |
var articleSizeData = ${chartJson(scatterData)} | |
var articleSizeCategories = ${chartJson(scatterCategories)} | |
var articleSizeSelectedCategories = null | |
var articleSizeYAxisFrame = null | |
var articleSizeTimeValues = articleSizeData.map(function (item) { | |
return new Date(item.value[0]).getTime() | |
}).filter(function (value) { | |
return Number.isFinite(value) | |
}) | |
var articleSizeTimeMin = articleSizeTimeValues.length ? Math.min.apply(null, articleSizeTimeValues) : 0 | |
var articleSizeTimeMax = articleSizeTimeValues.length ? Math.max.apply(null, articleSizeTimeValues) : articleSizeTimeMin | |
var articleSizeTimePadding = Math.max(24 * 60 * 60 * 1000, (articleSizeTimeMax - articleSizeTimeMin) * 0.018) | |
function articleSizeNiceAxisMax (value) { | |
var maximum = Math.max(0, Number(value) || 0) | |
if (!maximum) return 100 | |
var padded = maximum * 1.12 | |
var magnitude = Math.pow(10, Math.floor(Math.log(padded) / Math.LN10)) | |
var normalized = padded / magnitude | |
var steps = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] | |
var step = steps[steps.length - 1] | |
for (var index = 0; index < steps.length; index++) { | |
if (normalized <= steps[index]) { | |
step = steps[index] | |
break | |
} | |
} | |
return Math.ceil(step * magnitude) | |
} | |
function articleSizeZoomTime (value, fallbackPercent) { | |
if (value !== undefined && value !== null && value !== '') { | |
var timestamp = typeof value === 'number' ? value : new Date(value).getTime() | |
if (Number.isFinite(timestamp)) return timestamp | |
} | |
var extent = articleSizeTimeMax - articleSizeTimeMin | |
return articleSizeTimeMin + extent * fallbackPercent / 100 | |
} | |
function articleSizeVisibleAxisMax () { | |
var zoom = (articleSizeChart.getOption().dataZoom || [])[0] || {} | |
var startPercent = Number.isFinite(Number(zoom.start)) ? Number(zoom.start) : 0 | |
var endPercent = Number.isFinite(Number(zoom.end)) ? Number(zoom.end) : 100 | |
var startTime = articleSizeZoomTime(zoom.startValue, startPercent) | |
var endTime = articleSizeZoomTime(zoom.endValue, endPercent) | |
var lower = Math.min(startTime, endTime) | |
var upper = Math.max(startTime, endTime) | |
var visibleMaximum = 0 | |
articleSizeData.forEach(function (item) { | |
var timestamp = new Date(item.value[0]).getTime() | |
var category = item.value[3] | |
if (!Number.isFinite(timestamp) || timestamp < lower || timestamp > upper) return | |
if (articleSizeSelectedCategories && articleSizeSelectedCategories[category] === false) return | |
visibleMaximum = Math.max(visibleMaximum, Number(item.value[1]) || 0) | |
}) | |
return articleSizeNiceAxisMax(visibleMaximum) | |
} | |
function updateArticleSizeYAxis () { | |
articleSizeYAxisFrame = null | |
if (!articleSizeChart || articleSizeChart.isDisposed()) return | |
var nextMaximum = articleSizeVisibleAxisMax() | |
var currentAxis = (articleSizeChart.getOption().yAxis || [])[0] || {} | |
if (Number(currentAxis.max) === nextMaximum) return | |
⋯ 未改动代码已省略 ⋯ | |
xAxis: { | |
type: 'time', | |
min: articleSizeTimeMin - articleSizeTimePadding, | |
max: articleSizeTimeMax + articleSizeTimePadding, | |
axisTick: { show: false }, | |
axisLine: { lineStyle: { color: articleSizeAxisColor } }, | |
axisLabel: { color: articleSizeTextColor, margin: 12 }, | |
splitLine: { show: false } | |
}, | |
yAxis: { | |
type: 'value', | |
name: '文章字数', | |
nameGap: 16, | |
nameTextStyle: { color: articleSizeTextColor, fontSize: 10 }, | |
axisTick: { show: false }, | |
axisLine: { show: false }, | |
axisLabel: { | |
color: articleSizeTextColor, | |
formatter: function (value) { | |
return value >= 1000 ? (value / 1000).toFixed(value >= 10000 ? 0 : 1) + 'k' : value | |
} | |
}, | |
splitLine: { show: true, lineStyle: { color: articleSizeSplitColor, type: 'dashed' } } | |
}, | |
dataZoom: [{ | |
type: 'inside', | |
xAxisIndex: 0, | |
filterMode: 'none' | |
}, { | |
type: 'slider', | |
xAxisIndex: 0, | |
filterMode: 'none', | |
⋯ 未改动代码已省略 ⋯ | |
var title = params.value[2] | |
return title.length > 13 ? title.slice(0, 13) + '…' : title | |
} | |
}, | |
itemStyle: { opacity: 1, shadowBlur: 16 } | |
} | |
} | |
}) | |
} | |
articleSizeChart.setOption(articleSizeOption) | |
updateArticleSizeYAxis() | |
articleSizeChart.on('datazoom', scheduleArticleSizeYAxisUpdate) | |
articleSizeChart.on('legendselectchanged', function (event) { | |
articleSizeSelectedCategories = event.selected || null | |
⋯ 未改动代码已省略 ⋯ | |
window.ShokaStatisticsCharts.register(articleSizeChart, function () { | |
articleSizeChart.resize() | |
scheduleArticleSizeYAxisUpdate() | |
}, function (event) { | |
var value = event.value || [] | |
return { title: value[2], lines: ['发布于 ' + value[0] + ' · ' + value[3], '文章体量:' + Number(value[1]).toLocaleString() + ' 字'], links: [{ href: value[4] ? '/' + value[4] : '', label: '阅读全文' }] } | |
}) | |
</script>` | |
} | |
function categoryTagSunburstChart () { | |
const relationMap = new Map() | |
const tagPaths = new Map(), categoryPaths = new Map() | |
hexo.locals.get('tags').forEach(tag => tagPaths.set(String(tag.name), chartURL(tag.path))) | |
hexo.locals.get('categories').forEach(category => categoryPaths.set(String(category.name), chartURL(category.path))) | |
function getCollectionNames (collection) { | |
const names = [] | |
if (!collection || typeof collection.forEach !== 'function') return names | |
collection.forEach(function (item) { | |
if (item && item.name) names.push(String(item.name)) | |
}) | |
return names |
#浏览器加载与 PJAX 生命周期
themes/shoka/source/js/_app/page.js改动位置:第 591、734–764、766–967 行
var statisticsLoadId = 0 | |
⋯ 未改动代码已省略 ⋯ | |
const destroyStatisticsCharts = function () { | |
statisticsLoadId++ | |
if(statisticsAssetLoad) { | |
if(typeof statisticsAssetLoad.cancel === 'function') statisticsAssetLoad.cancel() | |
statisticsAssetLoad.clearNotices() | |
statisticsAssetLoad = null | |
} | |
var manager = window.ShokaStatisticsCharts | |
if(manager && typeof manager.destroy === 'function') | |
manager.destroy() | |
if(statisticsMountedScripts) { | |
statisticsMountedScripts.forEach(function(script) { delete script.dataset.executed }) | |
statisticsMountedScripts = null | |
} | |
if(window.statisticsThemeObserver) { | |
window.statisticsThemeObserver.disconnect() | |
window.statisticsThemeObserver = null | |
} | |
if(window.pageviewAbortController) { | |
window.pageviewAbortController.abort() | |
window.pageviewAbortController = null | |
} | |
;['postsCalendar', 'postsChart', 'tagsChart', 'categoriesChart', 'creationClockChart', 'articleSizeChart', 'categoryTagSunburstChart', 'pageviewChart', 'pageviewHeatChart', 'pageviewRegionChart', 'pageviewRegionCompareChart', 'pageviewCommentRegionChart', 'pageviewCalendarChart', 'pageviewGrowthChart'].forEach(function(name) { | |
window[name] = undefined | |
}) | |
window.updatePageviewChartTheme = undefined | |
window.ShokaStatisticsCharts = null | |
} | |
⋯ 未改动代码已省略 ⋯ | |
const mountStatisticsCharts = function () { | |
var scripts = Array.prototype.slice.call(document.querySelectorAll('script[type="text/x-shoka-statistics"]')) | |
if(!scripts.length || !CONFIG.js.echarts) | |
return | |
var sameScripts = function(previous) { | |
return previous && previous.length === scripts.length && scripts.every(function(script, index) { | |
return script.isConnected && script === previous[index] | |
}) | |
} | |
// Repeated mounts must not dispose already-running charts or restart a pending load. | |
if(statisticsAssetLoad && sameScripts(statisticsAssetLoad.scripts)) return | |
if(window.ShokaStatisticsCharts && sameScripts(statisticsMountedScripts)) return | |
destroyStatisticsCharts() | |
var currentLoadId = statisticsLoadId | |
var notices = [] | |
var state = { scripts: scripts, pending: false, attempt: 0, cancel: null, clearNotices: function() { | |
notices.forEach(function(entry) { | |
entry.target.setAttribute('aria-busy', 'false') | |
var parent = entry.notice.parentNode || entry.notice.parentElement | |
if(parent) parent.removeChild(entry.notice) | |
}) | |
notices = [] | |
} } | |
statisticsAssetLoad = state | |
var isCurrent = function() { | |
return statisticsAssetLoad === state && currentLoadId === statisticsLoadId && scripts[0].isConnected | |
} | |
var updateNotices = function(failed) { | |
if(failed) state.hasFailed = true | |
if(!notices.length) { | |
scripts.forEach(function(script) { | |
;[].concat(statisticsTargetByScript[script.id] || []).forEach(function(id) { | |
var target = document.getElementById(id) | |
if(!target) return | |
var notice = document.createElement('div') | |
notice.className = 'component-load-status statistics-load-status' | |
notice.setAttribute('role', 'status') | |
notice.setAttribute('aria-live', 'polite') | |
var label = document.createElement('span') | |
label.className = 'component-load-status__message' | |
var retry = document.createElement('button') | |
retry.type = 'button' | |
retry.textContent = '重新加载图表' | |
retry.addEventListener('click', function() { loadAssets() }) | |
notice.appendChild(label) | |
notice.appendChild(retry) | |
target.appendChild(notice) | |
notices.push({ target: target, notice: notice, label: label, retry: retry }) | |
}) | |
}) | |
} | |
notices.forEach(function(entry) { | |
entry.target.setAttribute('aria-busy', failed ? 'false' : 'true') | |
entry.notice.className = 'component-load-status statistics-load-status' + (failed ? ' is-error' : '') | |
entry.notice.setAttribute('aria-busy', failed ? 'false' : 'true') | |
entry.label.textContent = failed ? '图表资源暂时未能加载,请检查网络后重试。' : '正在加载图表资源…' | |
// Keep the focused retry control visible while a manual attempt is pending. | |
entry.retry.hidden = !failed && !state.hasFailed | |
entry.retry.disabled = !failed | |
}) | |
} | |
var loadAssets = function() { | |
if(!isCurrent() || state.pending) return | |
state.pending = true | |
var attempt = ++state.attempt | |
updateNotices(false) | |
var fail = function() { | |
if(!isCurrent() || !state.pending || attempt !== state.attempt) return | |
state.pending = false | |
state.cancel = null | |
updateNotices(true) | |
} | |
state.cancel = getScript(assetUrl('js', 'echarts'), function() { | |
if(!isCurrent() || !state.pending || attempt !== state.attempt) return | |
if(!window.echarts || typeof window.echarts.init !== 'function') { fail(); return } | |
state.pending = false | |
state.clearNotices() | |
statisticsAssetLoad = null | |
statisticsMountedScripts = scripts | |
installCharts() | |
}, window.echarts && typeof window.echarts.init === 'function', CONFIG.fallback && CONFIG.fallback.js.echarts, fail) | |
} | |
var installCharts = function() { | |
var chartEntries = [] | |
var cleanups = [] | |
var motionCharts = new Set() | |
var destroyed = false | |
var resizeTimer = null | |
var lazyObserver = null | |
var resizeHandler = function () { | |
window.clearTimeout(resizeTimer) | |
resizeTimer = window.setTimeout(function () { | |
chartEntries.forEach(function(entry) { | |
if(entry.chart && !entry.chart.isDisposed()) | |
(entry.resize || function () { entry.chart.resize() })() | |
}) | |
}, 120) | |
} | |
window.ShokaStatisticsCharts = { | |
bindMotion: function(chart) { | |
var motion = window.ShokaMotion | |
if(!chart || motionCharts.has(chart) || !motion || typeof motion.subscribe !== 'function' || typeof chart.setOption !== 'function') return | |
motionCharts.add(chart) | |
var setOption = chart.setOption | |
var initialOption = typeof chart.getOption === 'function' ? chart.getOption() : null | |
var preferred = !initialOption || initialOption.animation !== false | |
var applyMotion = function() { | |
if(!destroyed && !chart.isDisposed()) setOption.call(chart, { animation: preferred && !motion.reduced() }) | |
} | |
chart.setOption = function(option) { | |
if(destroyed || chart.isDisposed()) return | |
if(option && Object.prototype.hasOwnProperty.call(option, 'animation')) preferred = option.animation !== false | |
var args = Array.prototype.slice.call(arguments) | |
args[0] = Object.assign({}, option, { animation: preferred && !motion.reduced() }) | |
return setOption.apply(chart, args) | |
} | |
var unsubscribe = motion.subscribe(applyMotion) | |
applyMotion() | |
cleanups.push(function() { if(typeof unsubscribe === 'function') unsubscribe() }) | |
}, | |
register: function(chart, resize, describe) { | |
if(!chart || destroyed) return | |
chartEntries.push({ chart: chart, resize: resize }) | |
this.bindMotion(chart) | |
cleanups.push(bindStatisticsDetails(chart, describe, function() { | |
return !destroyed && currentLoadId === statisticsLoadId | |
})) | |
}, | |
addCleanup: function(cleanup) { | |
if(destroyed) cleanup() | |
else cleanups.push(cleanup) | |
}, | |
setObserver: function(observer) { | |
lazyObserver = observer | |
}, | |
destroy: function() { | |
if(destroyed) return | |
destroyed = true | |
cleanups.forEach(function(cleanup) { cleanup() }) | |
cleanups = [] | |
window.clearTimeout(resizeTimer) | |
window.removeEventListener('resize', resizeHandler) | |
if(lazyObserver) lazyObserver.disconnect() | |
chartEntries.forEach(function(entry) { | |
if(entry.chart && !entry.chart.isDisposed()) entry.chart.dispose() | |
}) | |
chartEntries = [] | |
} | |
} | |
window.addEventListener('resize', resizeHandler) | |
var execute = function(script) { | |
if(destroyed || !script || !script.isConnected || script.dataset.executed === 'true') return | |
script.dataset.executed = 'true' | |
var runtime = document.createElement('script') | |
runtime.dataset.statisticsRuntime = '' | |
runtime.textContent = script.textContent | |
document.body.appendChild(runtime) | |
document.body.removeChild(runtime) | |
// Charts are lazy: apply saved/custom colors as soon as each chart exists. | |
if(typeof window.switchPostChart === 'function' && document.documentElement.dataset.palette === 'custom' && document.getElementById('posts-calendar')) | |
window.switchPostChart() | |
} | |
var themeRuntime = document.getElementById('statisticsThemeRuntime') | |
execute(themeRuntime) | |
var targetByScript = statisticsTargetByScript | |
var chartScripts = scripts.filter(function(script) { return targetByScript[script.id] }) | |
if(!window.IntersectionObserver) { | |
chartScripts.forEach(execute) | |
return | |
} | |
lazyObserver = new IntersectionObserver(function(entries, observer) { | |
if(currentLoadId !== statisticsLoadId) return | |
entries.forEach(function(entry) { | |
if(!entry.isIntersecting && entry.intersectionRatio <= 0) return | |
var scriptId = entry.target.dataset.statisticsScript | |
execute(document.getElementById(scriptId)) | |
observer.unobserve(entry.target) | |
}) | |
}, { rootMargin: '280px 0px' }) | |
window.ShokaStatisticsCharts.setObserver(lazyObserver) | |
chartScripts.forEach(function(script) { | |
;[].concat(targetByScript[script.id]).forEach(function(id) { | |
var chart = document.getElementById(id) | |
if(!chart) return | |
var target = script.id === 'pageviewChart' ? chart.closest('section') || chart : chart | |
target.dataset.statisticsScript = script.id | |
lazyObserver.observe(target) | |
}) | |
}) | |
} | |
loadAssets() | |
} |
themes/shoka/source/js/_app/pjax.js改动位置:第 183、255 行
destroyStatisticsCharts() | |
⋯ 未改动代码已省略 ⋯ | |
mountStatisticsCharts() |
themes/shoka/scripts/generaters/script.js改动位置:第 46、69 行
echarts: theme.vendors.js.echarts, | |
⋯ 未改动代码已省略 ⋯ | |
echarts: config.root + theme.js + '/echarts-custom.min.js', |
#ECharts 定制构建入口
themes/shoka/build-assets/echarts-custom-entry.js改动位置:第 1–9、13–33、44–46 行
import * as echarts from 'echarts/core' | |
import { | |
BarChart, | |
HeatmapChart, | |
LineChart, | |
MapChart, | |
PieChart, | |
ScatterChart, | |
SunburstChart | |
⋯ 未改动代码已省略 ⋯ | |
CalendarComponent, | |
DataZoomComponent, | |
GraphicComponent, | |
GeoComponent, | |
GridComponent, | |
LegendComponent, | |
MarkLineComponent, | |
PolarComponent, | |
TitleComponent, | |
TooltipComponent, | |
VisualMapComponent | |
} from 'echarts/components' | |
import { CanvasRenderer } from 'echarts/renderers' | |
echarts.use([ | |
BarChart, | |
HeatmapChart, | |
LineChart, | |
MapChart, | |
PieChart, | |
ScatterChart, | |
⋯ 未改动代码已省略 ⋯ | |
TitleComponent, | |
TooltipComponent, | |
VisualMapComponent, |
生成本地后备文件时执行项目已有的 ECharts 构建命令即可。生成结果会写入 themes/shoka/source/js/echarts-custom.min.js ,无需在教程中展示压缩源码。
完成!