Ohhnews

分类导航

$ cd ..
DZone Java原文

用Java和Quarkus把GitHub贡献日历变成城市天际线

#github#java#quarkus#数据可视化#svg

GitHub 的贡献日历提供了一种已经极具标志性的方式,让你概览自己一年中的活动。每一天对应一个方块,其颜色会随贡献等级变化。当然,也可以把这些日期和贡献排列成建筑:一周变成城市的一小部分,而更繁忙的周则让天际线更高。

我抽出一些空闲时间,围绕这个想法构建了 Quarkus Contribution City,使用 Java 和 Quarkus 从 GitHub 的贡献数据生成 SVG。视觉灵感来自 Pink Pixel 的 skyline,你可以在我的 GitHub 个人资料上看到使用我真实数据的版本。一个定时的 GitHub Action 会在夜间更新图片。

对 Java 开发者来说,这是一个小项目,你可以从 API 请求一路跟踪到最终图像。你可以先在自己的个人资料中使用已发布的 action,然后在了解城市是如何组合起来之后,在本地更改绘制代码。本文中,我创建了一个特殊的 DZone 示例,在建筑上方添加了一小段字符画。

将城市添加到你的个人资料

你的 GitHub 个人资料 README 位于一个与你账户同名的公开仓库中。对于我的账户,该仓库是 myfear/myfear。打开你自己的个人资料仓库;如果你之前没有使用过这个 GitHub 功能,也可以创建它并添加一个 README。

将项目完整的发布工作流复制到默认分支上的 .github/workflows/contribution-city.yml。该示例会检出你的个人资料仓库以及该 action 的 v1 发布线。然后它会生成图片并将其发布到 output 分支。该工作流包含最后一步所需的仓库写入权限。

生成步骤使用仓库所有者的用户名,因此同一配置可以在个人资料仓库之间复制。你可以将布局选项添加到该步骤中:

$ config
- name: Generate contribution city
  id: city
  uses: ./.city-action
  with:
    github-token: ${{ secrets.CONTRIBUTION_TOKEN || secrets.GITHUB_TOKEN }}
    username: ${{ github.repository_owner }}
    weeks: '53'
    height: '14'
    theme: github-dark

该本地路径指的是示例工作流中前面执行的动作检出目录。内置的 GITHUB_TOKEN 是读取你的可见贡献数据的起点。当需要额外访问权限时,可选的 CONTRIBUTION_TOKEN secret 可提供个人令牌。GitHub 返回的日历取决于令牌权限以及你账户的贡献可见性设置。

提交工作流后,在你的个人资料仓库中打开 Actions,选择 publish-contribution-city,然后选择 Run workflow。成功运行会在 output 分支上创建 contribution-city.svg。将以下图片引用添加到你的个人资料 README,并替换两处 USERNAME:

![我的 GitHub 贡献城市](https://raw.githubusercontent.com/USERNAME/USERNAME/output/contribution-city.svg)

该示例还设置了每天 03:17 UTC 的定时计划。后续运行时,如果生成的图片发生变化,就会创建一次提交。在这种设置中,工作流及其运行历史位于你的个人资料仓库中,而 Java 实现则维护在该 action 的仓库中。

为每一周建一栋楼

城市按时间顺序排列,最近的一周在右侧。每栋建筑宽两个字符单元,因此所有周都占据相同的水平空间。每周贡献总数决定高度,而每日贡献等级决定窗户颜色。街道下方的月份标签可帮助你在一年中定位。

高度需要多考虑一点。非常繁忙的一周,其贡献数可能是安静一周的很多倍。如果使用线性比例,那些较安静的周会被压缩到接近街道的位置。城市使用对数比例,这能给它们更多空间,同时保留每周总数的顺序。对于每一周,计算可以表示为:

$ java
int height = weeklyTotal == 0 ? 1 : 1 + (int) Math.round(
    Math.log1p(weeklyTotal) / Math.log1p(maximumWeeklyTotal) * (maxHeight - 1));

在最大贡献数为 20、高度上限为 14 的情况下,贡献数为 3 的一周会有七行窗户。最繁忙的一周高度为 14,而零贡献的一周保持一行。因此,安静时期在日历中仍能保留自己的位置。

天线用于标记显示范围内最繁忙的一周;如果总数相同,则由最近的一周获得天线。两列窗户通过循环遍历一周七天来填充。这样会在更高的建筑中重复每日模式,因此窗户数量只是一种视觉纹理。每周总数控制高度,而窗户颜色承载每日贡献等级。二者结合,让你看到整个日历中的模式。

你可以将 weeks 改为 12,获得更短的视图。此时最高建筑会根据这些最近的周计算,从而让所选时段利用可用高度。比较图片时请记住:同一周在较短视图中可能显得更高。显示的贡献总数仍覆盖 GitHub 返回的完整日历。

用 Java 跟踪数据流程

已发布的项目使用 Quarkus 以及 Quarkus GitHub Action 扩展。该扩展将 @Action 方法连接到工作流输入、输出和作业摘要。在该方法内部,绘制过程可归结为三个步骤:

$ java
ContributionCalendar calendar = contributionClient.fetch(
        configuration.username(),
        inputs.getGitHubToken().orElseThrow(),
        context.getGithubGraphQLUrl());
Scene scene = cityBuilder.build(calendar, configuration.options());
String svg = svgRenderer.render(scene, configuration.options().theme());

客户端通过一次 GraphQL 请求读取贡献日历,并将响应转换为用于日历、周和日的 Java record。CityBuilder 使用这些 record 在网格中放置屋顶、窗户和街道。SvgRenderer 将该网格转换为最终 SVG。因此,可以在普通 Java 类中更改天际线,而 GitHub 连接仍保留在客户端中。

SVG 包含显示城市所需的形状和文本。渲染时,颜色相同的相邻字符会被分组,并且会显式设置它们的水平位置以保持列对齐。相同的数据和选项会生成相同的文件内容,这使发布器可以在创建提交前比较图片。该 action 会配置 Java 和 JBang,构建选定的源代码修订版本,并运行打包后的应用。

在本地更改布局

要尝试更改,请安装 JDK 21 和 JBang,并确保 Git 和 Bash 可用。仓库包含一个 Maven Wrapper。集成测试也使用 JBang,通过 GitHub Actions 中使用的同一启动器运行打包后的 action。

$ bash
git clone --branch v1.0.0 https://github.com/myfear/quarkus-contribution-city.git
cd quarkus-contribution-city
./mvnw verify

测试使用本地测试数据和本地 GraphQL 服务器,因此你无需 GitHub 凭据即可运行它们。测试还会在 target/previews 下生成 SVG 预览。在浏览器中打开 github-dark.svg、recent-12.svg 和 empty.svg,比较完整日历、较短时段和零贡献日历。

先从 CityBuilder 中的高度计算开始修改,然后再次运行同一命令并比较预览。保存的数据在多次运行之间保持不变,因此更容易看出更改的效果。该项目采用 Apache License 2.0,v1.0.0 发布版为你提供了一个固定的起点。

为城市添加一个 DZone 地标

1.0.0 版通过工作流输入暴露 weeks、height 和两个内置主题。若要自定义形状,请在 fork 中扩展 Java 代码。Scene record 提供了一个可在渲染 SVG 之前修改的单元格网格,我们将在这里添加 DZone 字样。

每个单元格都有一个字符、一个角色(例如屋顶或窗户),以及渲染器使用的颜色信息。字符掩码足以描述一个新形状:# 表示填充块,空格则让背景保持可见。通过排列这些块,你可以绘制字母、拱门或阶梯塔。

在本示例中,字样放置在各周建筑的上方。它具有自己固定的形状,而下方的建筑继续显示贡献日历。该图片使用我真实账户数据的快照:2026 年 10 月 5 日我撰写本文时,GitHub 返回了 534 次贡献。

[LOADING...]

在我的贡献城市中加入受 DZone 启发的配色和装饰字样。各周建筑高度和每日窗户模式来自常规视图使用的同一日历。

将此类添加到 fork 中现有的 city 包,路径为 src/main/java/com/themainthread/contributioncity/city/DZoneLandmark.java:

$ java
package com.themainthread.contributioncity.city;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import com.themainthread.contributioncity.render.Theme;

public final class DZoneLandmark {

    public static final Theme THEME = new Theme("dzone-example", "#161b22", "#242c36", "#303947", "#f58a42", "#9ba9ba", "#edf2f7", List.of("#536070", "#86431f", "#b85b25", "#e57d36", "#ffb365"));

    private static final List<String> MASK = List.of(
            "#### ##### ### # # #####",
            "# # # # # ## # # ",
            "# # # # # ## # # ",
            "# # # # # # # # #### ",
            "# # # # # # ## # ",
            "# # # # # # ## # ",
            "#### ##### ### # # #####");

    private DZoneLandmark() {
    }

    public static Scene addTo(Scene city) {
        int landmarkWidth = MASK.getFirst().length() * 2;
        int width = Math.max(city.width(), landmarkWidth);
        int left = (width - landmarkWidth) / 2;

        List<List<Cell>> rows = new ArrayList<>();
        Cell block = new Cell('█', CellKind.ROOF, 0, -1);

        for (String line : MASK) {
            List<Cell> row = new ArrayList<>(Collections.nCopies(width, Cell.SKY));
            for (int column = 0; column < line.length(); column++) {
                if (line.charAt(column) == '#') {
                    row.set(left + column * 2, block);
                    row.set(left + column * 2 + 1, block);
                }
            }
            rows.add(row);
        }

        rows.add(Collections.nCopies(width, Cell.SKY));
        for (List<Cell> sourceRow : city.rows()) {
            List<Cell> row = new ArrayList<>(sourceRow);
            row.addAll(Collections.nCopies(width - city.width(), Cell.SKY));
            rows.add(row);
        }

        return new Scene(city.username(), city.totalContributions(), rows);
    }
}

该主题定义了背景、两种建筑阴影、屋顶、街道和标签颜色,随后是五种窗户颜色。这些窗户颜色对应 GitHub 从 0 到 4 的贡献等级。在这里,屋顶颜色也用于字样,因此装饰在全部七行中保持一致的橙色。

在 addTo 中,每个标记位置都会扩展为两个单元格,因为渲染器的单元格高度大于宽度。字样居中,其下添加一个空白行,然后将原始城市行复制到下方。较短的日历会在右侧获得额外的背景空间,以便为字样留出位置。各周建筑的日期、计数和顺序均保持不变。

要在你的 fork 中使用该装饰,请在 ContributionCityAction 中从 city 包导入 DZoneLandmark。将现有的场景构建和渲染代码行替换为:

$ java
Scene scene = cityBuilder.build(calendar, configuration.options());
Scene decorated = DZoneLandmark.addTo(scene);
String svg = svgRenderer.render(decorated, DZoneLandmark.THEME);

这会以 Java 方式选择示例的配色。已发布的 @v1 action 继续接受 github-dark 和 mono;你的 fork 包含额外的绘制代码。

如需本地预览,请在 PreviewTest.write 中现有的 Files.writeString 调用之后添加以下内容,并使用相同的 DZoneLandmark 导入:

$ java
Scene decorated = DZoneLandmark.addTo(builder.build(calendar, options));
Files.writeString(directory.resolve(name.replace(".svg", "-dzone.svg")), renderer.render(decorated, DZoneLandmark.THEME));

再次运行 ./mvnw verify 并打开 target/previews/github-dark-dzone.svg。构建还会写入带有 -dzone.svg 后缀的较短版本和空版本,因此你可以检查该装饰在多种日历中的适配情况。

当你准备在 GitHub 上使用 fork 时,请将发布工作流中的第二个检出步骤更新为你的 fork 仓库以及包含你更改的提交。随后,生成步骤将从该检出目录运行自定义 action。

用你自己的贡献试一试

用你自己的 GitHub 账户试用 Quarkus Contribution City,并将生成的 SVG 添加到个人资料中。先从已发布的 action 开始,然后 fork 项目并更改字符掩码,为你的城市添加一个专属地标。你可以以 DZone 版本为起点,绘制一座来自你家乡的阶梯塔,或在街道上方添加你团队的缩写。可在 GitHub issue 中分享你的版本;如果你想贡献新形状,也可以提交 pull request。