Lavender's Blog

浩瀚众星,皆降为尘

好故事,值得慢一点

重新设计 Shoka 主题统计页面,加入多年份文章日历、发布趋势、创作时钟、文章体量散点图、标签排行、分类分布以及分类与标签旭日图。

#前言

早期的统计页面只包含几个基础 ECharts 图表,功能可以使用,但在移动端、PJAX 页面切换、日间与夜间模式以及大量数据展示方面不够完善。本次改造在保留 Shoka 配色的基础上,重新设计图表卡片和加载方式,并让图表只在接近可视区域时初始化。

最终页面包含以下内容:

  • 可从左侧切换年份的文章发布日历;
  • 支持最近 6、12、24 个月、全部月份和自定义范围的发布趋势;
  • 按星期和 24 小时展示发布习惯的创作时钟;
  • 可点击进入文章、并随可视时间范围调整纵轴的文章体量散点图;
  • TOP 10 标签、文章分类分布;
  • 展示 “分类 → 标签” 关系的旭日图;
  • PJAX 切换、窗口缩放、移动端和日间/夜间模式实时适配。

#开始前准备

如果是第一次修改 Hexo 主题,请先完成下面几项:

  1. 找到博客根目录。能看到 _config.ymlpackage.jsonsourcethemes 文件夹的目录就是根目录;
  2. 备份 themes/shokasource/statistics ,或者先提交一次 Git;
  3. 在博客根目录打开终端,执行 npm install ,确认依赖已经安装;
  4. 本文出现的路径都从博客根目录开始计算,不要把主题配置文件和站点根配置文件混淆;
  5. 每完成一个大步骤都可以执行一次 hexo generate --bail ,这样更容易找到出错位置。

本教程需要 cheeriomomentecharts 。如果项目还没有安装,请执行:

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.jsonnpm 根据依赖版本自动更新锁定结果;不要手工编辑。

本次没有删除任何项目文件。内容层面替换的是统计页旧卡片布局、旧图表初始化方式和零散的 PJAX 事件;不是把旧文件直接删除。如果你已做过自定义修改,应逐段合并,不要不经比较就覆盖整个主题。

#实现

#第 1 步:新建统计页面

执行:

hexo new page statistics

也可以直接创建 source/statistics/index.md ,写入:

---
title: 文章统计
date: 2026-08-19 10:00:00
comment: false
copyright: false
---

统计页不需要评论,所以这里关闭评论和版权信息。

具体操作如下:

  1. 打开博客根目录下的 source 文件夹;
  2. 新建名为 statistics 的文件夹;
  3. 在文件夹中创建 index.md
  4. 把上面的 Front Matter 粘贴到文件最开头;
  5. 保存为 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:

图表容器 IDECharts 类型
文章发布日历posts-calendarcalendar + heatmap
发布趋势posts-chartline
创作时钟creation-clock-chartpolar + bar
文章体量article-size-chartscatter
标签排行tags-chartbar
分类分布categories-chartpie
分类与标签category-tag-sunburstsunburst

多年份日历额外增加年份导航:

<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(/&amp;#/g, '&#') : html
}, 15)

每个生成函数都分成两部分:Node.js 负责整理 Hexo 数据,浏览器端脚本负责创建 ECharts 实例。

按照下面的顺序添加函数,函数名必须和过滤器中的名称完全一致:

  1. postsCalendar()
  2. postsChart()
  3. tagsChart()
  4. categoriesChart()
  5. creationClockChart()
  6. articleSizeChart()
  7. 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 ,完成两处连接:

  1. 在 PJAX 清空旧页面前调用 destroyStatisticsCharts()
  2. 在新页面执行完 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.jssiteConfig.js 中存在:

js: {
  echarts: theme.vendors.js.echarts
}

如果漏掉这一项,浏览器端的 CONFIG.js.echarts 会是空值,统计页面不会开始初始化。

#常见问题

#页面只有卡片,没有图表

依次检查:

  1. 浏览器控制台是否提示 echarts is not defined
  2. _config.yml 中是否配置了 vendors.js.echarts
  3. script.js 是否把 ECharts 地址写入 CONFIG.js
  4. pjax.js 是否调用了 mountStatisticsCharts()
  5. 图表容器 ID 与 charts.js 中的 ID 是否完全一致。

#第一次进入正常,第二次进入空白

通常是 PJAX 切换时没有销毁旧实例。确认 pjaxReload() 调用了 destroyStatisticsCharts() ,并且管理器的 destroy() 中执行了 chart.dispose()

#夜间模式文字仍然是深色

不要只给主题切换按钮绑定点击事件。确认已经使用 MutationObserver 监听 document.documentElementdata-theme 属性。

#手机端图表被截断

确认图表容器使用 width: 100% ,并且注册了统一的窗口 resize 处理。年份列表和工具栏还需要在移动端缩小宽度或换行。

#测试

修改完成后执行:

hexo clean
hexo generate --bail
hexo server

重点检查:

  1. 直接打开统计页和从其他页面 PJAX 进入统计页都能显示图表;
  2. 多次进入、离开统计页,控制台没有重复初始化错误;
  3. 切换日间与夜间模式后文字和边框立即更新;
  4. 手机端年份列表、筛选器和图表没有横向溢出;
  5. CDN 不可用时,本地 ECharts 后备包仍可加载。

#总结

这次改造没有把统计图表简单堆放在页面中,而是将数据生成、页面结构、图表加载和生命周期管理分开。后续增加新图表时,只需要:

  1. 在统计页添加一个容器;
  2. charts.js 新增数据生成函数;
  3. mountStatisticsCharts() 的映射中注册容器;
  4. 将新增的 ECharts 图表类型加入定制后备包。

#涉及改动的代码

代码卡片按属性名、函数名或带内容校验的具名片段定位,并显示当前源码行号。片段前后插行可自动重新定位;片段本身改变或位置不唯一时会停止生成,核对教程后再更新摘录,避免误引其他功能。

下面只展示本教程涉及的改动片段,不再展开整个文件。点击文件名后可以看到源码中的原始行号;“⋯ 未改动代码已省略 ⋯” 表示两处改动之间仍有项目原代码,合并时不要删除。

行号以当前项目为准,后续继续修改文件后可能发生偏移。定位时应优先搜索卡片中的函数名、选择器或配置项。 package-lock.jsonecharts-custom.min.js 都由命令自动生成,不需要复制,也不要手工编辑。

#依赖与主题配置

package.json改动位置:第 35 行 1 行 · 23 B
package.json
    "echarts": "6.1.0",

themes/shoka/_config.yml 含有站点个性化配置,下面使用独立的脱敏示例。将这些字段合并到已有配置中,不再依赖可能变化的行号读取实际站点配置:

source/code-examples/shoka-tutorial/statistics.yml完整文件 10 行 · 336 B
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 行 2 行 · 51 B
themes/shoka/languages/zh-CN.yml
  statistics: 统计
  poststatistics: 文章统计

#统计页面与数据生成

source/statistics/index.md改动位置:第 1–8、10–34、580–622、834–842、844–890、892–955 行 196 行 · 9.1 KB
source/statistics/index.md
---
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 行 354 行 · 16.8 KB
themes/shoka/scripts/helpers/charts.js
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 行 234 行 · 9.7 KB
themes/shoka/source/js/_app/page.js
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 行 2 行 · 53 B
themes/shoka/source/js/_app/pjax.js
  destroyStatisticsCharts()
⋯ 未改动代码已省略 ⋯
  mountStatisticsCharts()

themes/shoka/scripts/generaters/script.js改动位置:第 46、69 行 2 行 · 108 B
themes/shoka/scripts/generaters/script.js
      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 行 33 行 · 592 B
themes/shoka/build-assets/echarts-custom-entry.js
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 ,无需在教程中展示压缩源码。

完成!

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Lavender 微信支付

微信支付

Lavender 支付宝

支付宝