跳转到内容

Github 贡献图

贡献数据来自尤雨溪,2024/08/25 - 2025/08/25 的数据

通过 https://github-contributions-api.jogruber.de/v4/<name>?y=last 获取数据,这是仓库

@ep_src_vue_generic_component_github_contributions_Example(./Example.vue)
vue
<template>
  <GithubContributions :data="contributionsData" :dark="isDark" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useData } from 'vitepress'
import GithubContributions from './GithubContributions.vue'
import data from './data.json'

const { isDark } = useData()

// 模拟贡献数据来之:https://github.com/yyx990803 2024/8/25 - 2025/8/25
const contributionsData = ref(
  data as unknown as {
    date: string
    count: number
    level: number
  }[],
)
</script>

组件源码:

vue
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watchEffect } from 'vue'

// 定义贡献项接口
interface Contribution {
  date: string
  count: number
  level: number
}

interface ContributionRect extends Contribution {
  x: number
  y: number
  row: number
  col: number
  month: number
  fill: string
  stroke: string
}

interface MonthLabel {
  name: string
  x: number
  y: number
}

interface WeekLabel {
  name: string
  x: number
  y: number
}

interface LevelColor {
  light: string
  dark: string
  borderLight: string
  borderDark: string
}

// 定义组件属性
interface Props {
  data: Contribution[]
  dark?: boolean
  background?: boolean
  fixedSize?: boolean
  tooltip?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  tooltip: true,
})

const PADDING_TOP = 4 // 顶部内边距
const PADDING_LEFT = 2 // 左侧内边距
const PADDING_RIGHT = 2 // 右侧内边距
const PADDING_BOTTOM = 0 // 底部内边距
const BLOCK_SIZE = 10 // 每个贡献块的大小
const BLOCK_GAP = 3 // 每个贡献块的间隔
const BLOCK_RADIUS = 2 // 每个贡献块的圆角
const MONTH_HEIGHT = 13 // 月份标签高度
const WEEK_WIDTH = 22 // 周标签的宽度
const LABEL_FONT_SIZE = 9 // 标签字体大小
const LEGEND_HEIGHT = 34 // 图例高度
const LEGEND_BLOCK_GAP = 4 // 图例块间隔
const LEGEND_PADDING = 32 // 图例内边距
const LEGEND_FONT_SIZE = 9 // 图例字体大小

// 贡献块属性
const blockAttributes = {
  width: BLOCK_SIZE,
  height: BLOCK_SIZE,
  rx: BLOCK_RADIUS,
  ry: BLOCK_RADIUS,
}

// 月份简称
const monthAbbrNames = [
  'Jan',
  'Feb',
  'Mar',
  'Apr',
  'May',
  'Jun',
  'Jul',
  'Aug',
  'Sep',
  'Oct',
  'Nov',
  'Dec',
]

// 月份名称
const monthNames = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
]

// 等级颜色
const levelColors: LevelColor[] = [
  {
    light: '#ebedf0',
    dark: '#151b23',
    borderLight: '#1f23280d',
    borderDark: '#0104090d',
  },
  {
    light: '#9be9a8',
    dark: '#033a16',
    borderLight: '#1f23280d',
    borderDark: '#0104090d',
  },
  {
    light: '#40c463',
    dark: '#196c2e',
    borderLight: '#1f23280d',
    borderDark: '#0104090d',
  },
  {
    light: '#30a14e',
    dark: '#2ea043',
    borderLight: '#1f23280d',
    borderDark: '#0104090d',
  },
  {
    light: '#216e39',
    dark: '#56d364',
    borderLight: '#1f23280d',
    borderDark: '#0104090d',
  },
]

// 标签颜色
const labelColors = {
  light: '#767676',
  dark: '#f0f6fc',
}

// 图例文字颜色
const legendTextColors = {
  light: '#59636e',
  dark: '#9198a1',
}

const svgWidth = ref(0)
const svgHeight = ref(0)
const legendCentralAxis = ref(0) // 图例中轴线
const tooltipInfo = reactive({
  x: 0,
  y: 0,
  visible: false,
  text: '',
})

const labelAttributes = computed(() => {
  return {
    fill: props.dark ? labelColors.dark : labelColors.light,
    'font-size': LABEL_FONT_SIZE,
  }
})

const legendTextAttributes = computed(() => {
  return {
    fill: props.dark ? legendTextColors.dark : legendTextColors.light,
    'font-size': LEGEND_FONT_SIZE,
  }
})

const contributionRects = ref<ContributionRect[]>([])
const monthLabels = ref<MonthLabel[]>([])
const weekLabels = ref<WeekLabel[]>([])
const legendContributionRects = ref<ContributionRect[]>([])
const legendMoreTextX = ref(0)
const legendLessTextX = ref(0)

const getLevelColor = (level: number) => {
  const color = levelColors[level] || levelColors[0]
  return props.dark
    ? { fill: color.dark, stroke: color.borderDark }
    : { fill: color.light, stroke: color.borderLight }
}

watchEffect(() => {
  const rects: ContributionRect[] = []
  const months: MonthLabel[] = []

  const baseX = PADDING_LEFT + WEEK_WIDTH + BLOCK_GAP
  const baseY = PADDING_TOP + MONTH_HEIGHT + BLOCK_GAP

  let row = new Date(props.data[0].date).getDay()
  let col = 0

  for (let i = 0; i < props.data.length; i++) {
    const contribution = props.data[i]
    const x = baseX + col * (BLOCK_SIZE + BLOCK_GAP)
    const y = baseY + row * (BLOCK_SIZE + BLOCK_GAP)
    rects.push({
      ...contribution,
      ...getLevelColor(contribution.level),
      x,
      y,
      row,
      col,
      month: new Date(contribution.date).getMonth(),
    })
    row++
    if (row > 6) {
      row = 0
      col++
    }
  }

  let lastMonth = rects[rects.length - 1].month
  let lastCol = rects[rects.length - 1].col
  for (let i = rects.length - 2; i >= 0; i--) {
    const rect = rects[i]
    const next = rects[i + 1]
    const nextNext = rects[i + 2]

    if (rect.month !== lastMonth) {
      const month = rect.col === lastCol ? next.month : nextNext.month
      months.unshift({
        name: monthAbbrNames[month],
        x: next.x,
        y: PADDING_TOP + MONTH_HEIGHT - BLOCK_GAP,
      })
    }

    lastMonth = rect.month
    lastCol = rect.col
  }

  contributionRects.value = rects
  monthLabels.value = months
  weekLabels.value = [
    {
      name: 'Mon',
      x: PADDING_LEFT,
      y: baseY + 1 * (BLOCK_SIZE + BLOCK_GAP) + BLOCK_SIZE / 2,
    },
    {
      name: 'Wed',
      x: PADDING_LEFT,
      y: baseY + 3 * (BLOCK_SIZE + BLOCK_GAP) + BLOCK_SIZE / 2,
    },
    {
      name: 'Fri',
      x: PADDING_LEFT,
      y: baseY + 5 * (BLOCK_SIZE + BLOCK_GAP) + BLOCK_SIZE / 2,
    },
  ]

  const rectGroupWidth = (col + 1) * BLOCK_SIZE + col * BLOCK_GAP
  const rectGroupHeight = 7 * BLOCK_SIZE + 6 * BLOCK_GAP

  svgWidth.value = baseX + rectGroupWidth + PADDING_RIGHT
  svgHeight.value =
    baseY + rectGroupHeight + LEGEND_HEIGHT + PADDING_BOTTOM
  legendCentralAxis.value = baseY + rectGroupHeight + LEGEND_HEIGHT / 2

  legendMoreTextX.value = svgWidth.value - LEGEND_PADDING - 22
  legendLessTextX.value =
    legendMoreTextX.value -
    (BLOCK_SIZE + LEGEND_BLOCK_GAP) * 5 -
    20 -
    LEGEND_BLOCK_GAP

  legendContributionRects.value = Array(5)
    .fill(0)
    .map<ContributionRect>((_, i) => {
      return {
        date: `Level ${i}`,
        count: 0,
        level: i,
        x:
          legendMoreTextX.value -
          (BLOCK_SIZE + LEGEND_BLOCK_GAP) * (5 - i),
        y: legendCentralAxis.value - BLOCK_SIZE / 2,
        row: 0,
        col: 0,
        month: 0,
        ...getLevelColor(i),
      }
    })
})

const formatContributionInfo = (contribution: ContributionRect) => {
  const date = new Date(contribution.date)

  const month = monthNames[date.getMonth()]

  const day = date.getDate()
  const dayWithSuffix = getDayWithOrdinalSuffix(day)

  // 处理没有贡献的情况
  if (contribution.count === 0) {
    return `No contributions on ${month} ${dayWithSuffix}`
  }

  // 根据贡献数量决定单复数形式
  const contributionText =
    contribution.count === 1 ? 'contribution' : 'contributions'

  return `${contribution.count} ${contributionText} on ${month} ${dayWithSuffix}`
}

const getDayWithOrdinalSuffix = (day: number) => {
  if (day > 3 && day < 21) return day + 'th' // 11-20 特殊情况

  switch (day % 10) {
    case 1:
      return day + 'st'
    case 2:
      return day + 'nd'
    case 3:
      return day + 'rd'
    default:
      return day + 'th'
  }
}

const tooltipRef = ref<HTMLDivElement>()

const showTooltip = async (
  event: MouseEvent,
  item: ContributionRect,
  text?: string,
) => {
  if (!props.tooltip) return
  tooltipInfo.visible = true
  tooltipInfo.x = -1000
  tooltipInfo.y = -1000
  tooltipInfo.text = text || formatContributionInfo(item)
  await nextTick()
  if (!tooltipRef.value || !event.target) return
  const { width, height } = tooltipRef.value.getBoundingClientRect()
  const { x, y } = (event.target as SVGRectElement).getBoundingClientRect()
  const centerX = x + BLOCK_SIZE / 2
  tooltipInfo.x = centerX - width / 2
  tooltipInfo.y = y - height - 4
}

// 隐藏工具提示
const hideTooltip = () => {
  if (!props.tooltip) return
  tooltipInfo.visible = false
}

const handleTooltipEnter = () => {
  if (!props.tooltip) return
  tooltipInfo.visible = true
}
const handleTooltipLeave = () => {
  if (!props.tooltip) return
  tooltipInfo.visible = false
}
</script>

<template>
  <div class="contributions" :class="{ dark, background }">
    <svg
      :width="fixedSize ? svgWidth : '100%'"
      :height="fixedSize ? svgHeight : '100%'"
      :viewBox="`0 0 ${svgWidth} ${svgHeight}`"
      class="contribution-chart"
    >
      <g class="month-labels">
        <text
          v-for="month in monthLabels"
          :key="month.name"
          :x="month.x"
          :y="month.y"
          text-anchor="start"
          dominant-baseline="text-bottom"
          v-bind="labelAttributes"
        >
          {{ month.name }}
        </text>
      </g>

      <g class="week-labels">
        <text
          v-for="week in weekLabels"
          :key="week.name"
          :x="week.x"
          :y="week.y"
          text-anchor="start"
          dominant-baseline="middle"
          v-bind="labelAttributes"
        >
          {{ week.name }}
        </text>
      </g>

      <g class="contribution-rects">
        <rect
          v-for="item in contributionRects"
          :key="item.date"
          :x="item.x"
          :y="item.y"
          :fill="item.fill"
          :style="{ stroke: item.stroke }"
          :data-level="item.level"
          v-bind="blockAttributes"
          @mouseover="showTooltip($event, item)"
          @mouseleave="hideTooltip"
        />
      </g>

      <g class="legend">
        <text
          :x="legendLessTextX"
          :y="legendCentralAxis"
          text-anchor="start"
          dominant-baseline="middle"
          v-bind="legendTextAttributes"
        >
          Less
        </text>
        <rect
          v-for="item in legendContributionRects"
          :key="item.date"
          :x="item.x"
          :y="item.y"
          :fill="item.fill"
          :style="{ stroke: item.stroke }"
          :data-level="item.level"
          v-bind="blockAttributes"
          @mouseover="showTooltip($event, item, item.date)"
          @mouseleave="hideTooltip"
        />
        <text
          :x="legendMoreTextX"
          :y="legendCentralAxis"
          text-anchor="start"
          dominant-baseline="middle"
          v-bind="legendTextAttributes"
        >
          More
        </text>
      </g>
    </svg>
    <teleport to="body" v-if="tooltip">
      <Transition name="tooltip">
        <div
          class="github-contribution-tooltip"
          :class="{ dark }"
          :style="{
            left: tooltipInfo.x + 'px',
            top: tooltipInfo.y + 'px',
          }"
          v-show="tooltipInfo.visible"
          ref="tooltipRef"
          @mouseenter="handleTooltipEnter"
          @mouseleave="handleTooltipLeave"
        >
          {{ tooltipInfo.text }}
        </div>
      </Transition>
    </teleport>
  </div>
</template>

<style scoped lang="scss">
.contributions {
  &.background {
    padding: 8px;
    border-radius: 6px;
    background-color: #fff;
  }

  &.dark {
    &.background {
      background-color: #0d1117;
    }
  }
}

.github-contribution-tooltip {
  position: fixed;
  padding: 4px 8px;
  border-radius: 6px;
  font-size: 12px;
  line-height: 1.5;
  background-color: #25292e;
  color: #fff;
  &::after {
    content: '';
    position: absolute;
    top: 100%;
    left: 0;
    width: 100%;
    height: 4px;
  }
  &.dark {
    background-color: #3d444d;
  }
}

.tooltip-enter-active,
.tooltip-leave-active {
  transition: opacity 0.1s ease-in;
}

.tooltip-enter-from,
.tooltip-leave-to {
  opacity: 0;
}
</style>