headreel 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -19
- package/dist/core/data/contributions.js +12 -9
- package/dist/core/data/highlights.js +141 -0
- package/dist/core/encode/gif.js +50 -7
- package/dist/core/pipeline.js +5 -1
- package/dist/core/text.js +72 -0
- package/dist/styles/contribution-city/city.js +4 -2
- package/dist/styles/contribution-city/index.js +1 -1
- package/dist/styles/contribution-city/options.js +3 -2
- package/dist/styles/contribution-city/sketch.js +62 -32
- package/dist/styles/highlights-reel/index.js +16 -0
- package/dist/styles/highlights-reel/options.js +11 -0
- package/dist/styles/highlights-reel/palette.js +68 -0
- package/dist/styles/highlights-reel/reel.js +346 -0
- package/dist/styles/highlights-reel/sketch.js +555 -0
- package/dist/styles/index.js +2 -0
- package/dist/styles/repo-galaxy/sketch.js +16 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,32 +78,47 @@ You need:
|
|
|
78
78
|
|
|
79
79
|
The Action and the command use the same settings.
|
|
80
80
|
|
|
81
|
-
| Action input | Command flag | Default | Description
|
|
82
|
-
| ---------------- | ---------------------- | ------------------------------- |
|
|
83
|
-
| `style` | `--style` | (required) | The banner style. See [Styles](#styles).
|
|
84
|
-
| `username` | `--user` | repository owner | The GitHub user to show. The command requires it.
|
|
85
|
-
| `tagline` | `--tagline` | empty | One line under your name.
|
|
86
|
-
| `website` | `--website` | empty | Your website.
|
|
87
|
-
| `handle` | `--handle` | empty | A handle, for styles that show one.
|
|
88
|
-
| `options` | `--option <key=value>` | empty | Style options. Action: one `key: value` on each line. Command: repeat the flag, for example `--option
|
|
89
|
-
| `output` | `--out` | `headreel.gif` | The path of the banner.
|
|
90
|
-
| `token` | `--token` | `github.token` | The token that reads your GitHub data.
|
|
91
|
-
| `publish_mode` | - | `commit` | `commit` adds a commit on every update. `branch` keeps one commit on its own branch, replaced on every update.
|
|
92
|
-
| `commit_to` | - | checked-out branch / `headreel` | The branch that gets the banner. The default is `headreel` in `branch` mode.
|
|
93
|
-
| `commit_message` | - | `chore: update headreel banner` | The commit message.
|
|
94
|
-
| - | `--config <file>` | - | A JSON file with these settings. Flags replace its values.
|
|
81
|
+
| Action input | Command flag | Default | Description |
|
|
82
|
+
| ---------------- | ---------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `style` | `--style` | (required) | The banner style. See [Styles](#styles). |
|
|
84
|
+
| `username` | `--user` | repository owner | The GitHub user to show. The command requires it. |
|
|
85
|
+
| `tagline` | `--tagline` | empty | One line under your name. |
|
|
86
|
+
| `website` | `--website` | empty | Your website. |
|
|
87
|
+
| `handle` | `--handle` | empty | A handle, for styles that show one. |
|
|
88
|
+
| `options` | `--option <key=value>` | empty | Style options. Action: one `key: value` on each line. Command: repeat the flag, for example `--option accent=green`. |
|
|
89
|
+
| `output` | `--out` | `headreel.gif` | The path of the banner. |
|
|
90
|
+
| `token` | `--token` | `github.token` | The token that reads your GitHub data. |
|
|
91
|
+
| `publish_mode` | - | `commit` | `commit` adds a commit on every update. `branch` keeps one commit on its own branch, replaced on every update. |
|
|
92
|
+
| `commit_to` | - | checked-out branch / `headreel` | The branch that gets the banner. The default is `headreel` in `branch` mode. |
|
|
93
|
+
| `commit_message` | - | `chore: update headreel banner` | The commit message. |
|
|
94
|
+
| - | `--config <file>` | - | A JSON file with these settings. Flags replace its values. |
|
|
95
95
|
|
|
96
96
|
Empty settings do not show on the banner. Your name comes from your GitHub profile. If your profile has no name, the banner shows your username.
|
|
97
97
|
|
|
98
98
|
## Styles
|
|
99
99
|
|
|
100
|
+
Set the style with `style: <name>` in the Action, or `--style <name>` in the command. Set an option with `options: 'accent: green'` in the Action, or `--option accent=green` in the command. For more than one option in the Action, put each on its own line.
|
|
101
|
+
|
|
100
102
|
### Contribution City
|
|
101
103
|
|
|
102
104
|
`contribution-city`: each day of the last 12 months is one building. A taller building means more contributions. Beacons glow on your busiest days.
|
|
103
105
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
|
106
|
+

|
|
107
|
+
|
|
108
|
+
| Option | Default | Description |
|
|
109
|
+
| -------- | ------- | --------------------------------------------------------------------------------------------- |
|
|
110
|
+
| `accent` | `cyan` | Color of the roofs, windows, lights and links: `cyan`, `cobalt`, `green`, `violet` or `pink`. |
|
|
111
|
+
|
|
112
|
+
### Highlights Reel
|
|
113
|
+
|
|
114
|
+
`highlights-reel`: a few profile facts, each on its own card, filmed by one camera. The camera travels between the cards, pushes into your busiest week, and ends on a wide shot of the whole reel.
|
|
115
|
+
|
|
116
|
+

|
|
117
|
+
|
|
118
|
+
| Option | Default | Description |
|
|
119
|
+
| -------- | -------- | -------------------------------------------------------------------------------------- |
|
|
120
|
+
| `theme` | `light` | `light` or `dark`. Use `dark` if your profile is mostly viewed in GitHub dark mode. |
|
|
121
|
+
| `accent` | `cobalt` | Color of the bars, numbers and links: `cobalt`, `green`, `violet`, `orange` or `pink`. |
|
|
107
122
|
|
|
108
123
|
### Repo Galaxy
|
|
109
124
|
|
|
@@ -117,8 +132,6 @@ Empty settings do not show on the banner. Your name comes from your GitHub profi
|
|
|
117
132
|
| `include_forks` | `false` | Show forked repositories too. |
|
|
118
133
|
| `labels` | `top3` | `top3` names the three most starred repositories. `none` hides names. |
|
|
119
134
|
|
|
120
|
-
Set an option with `options: 'beacons: 5'` in the Action, or `--option beacons=5` in the command. For more than one option in the Action, put each on its own line.
|
|
121
|
-
|
|
122
135
|
## Troubleshooting
|
|
123
136
|
|
|
124
137
|
| Problem | Fix |
|
|
@@ -126,3 +139,11 @@ Set an option with `options: 'beacons: 5'` in the Action, or `--option beacons=5
|
|
|
126
139
|
| "Permission denied" or 403 error | Add `permissions: contents: write` to the workflow file. |
|
|
127
140
|
| "Could not resolve to a User" | Set `username` to a valid GitHub username. |
|
|
128
141
|
| "No GitHub token found" | Run `gh auth login`, or set `GITHUB_TOKEN`. |
|
|
142
|
+
|
|
143
|
+
## Support
|
|
144
|
+
|
|
145
|
+
If headreel is useful to you, please [star the repository](https://github.com/arifszn/headreel). It helps other people find it.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
[MIT License](./LICENSE).
|
|
@@ -32,6 +32,17 @@ export const contributionsSchema = z.object({
|
|
|
32
32
|
/** Calendar columns, oldest first. First and last weeks may be partial. */
|
|
33
33
|
weeks: z.array(z.array(daySchema)),
|
|
34
34
|
});
|
|
35
|
+
/** Normalizes the GraphQL contribution calendar into the stored shape. */
|
|
36
|
+
export function normalizeCalendar(calendar, from, to) {
|
|
37
|
+
return {
|
|
38
|
+
from,
|
|
39
|
+
to,
|
|
40
|
+
total: calendar.totalContributions,
|
|
41
|
+
weeks: calendar.weeks.map((week) => week.contributionDays
|
|
42
|
+
.map((d) => ({ date: d.date, weekday: d.weekday, count: d.contributionCount }))
|
|
43
|
+
.sort((a, b) => a.date.localeCompare(b.date))),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
35
46
|
const DAY_MS = 86_400_000;
|
|
36
47
|
/**
|
|
37
48
|
* The 365 days ending on `now` (UTC), matching GitHub's profile calendar.
|
|
@@ -51,13 +62,5 @@ export async function fetchContributions(client, login, now) {
|
|
|
51
62
|
from: `${from}T00:00:00Z`,
|
|
52
63
|
to: `${to}T23:59:59Z`,
|
|
53
64
|
});
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
from,
|
|
57
|
-
to,
|
|
58
|
-
total: calendar.totalContributions,
|
|
59
|
-
weeks: calendar.weeks.map((week) => week.contributionDays
|
|
60
|
-
.map((d) => ({ date: d.date, weekday: d.weekday, count: d.contributionCount }))
|
|
61
|
-
.sort((a, b) => a.date.localeCompare(b.date))),
|
|
62
|
-
};
|
|
65
|
+
return normalizeCalendar(user.contributionsCollection.contributionCalendar, from, to);
|
|
63
66
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { contributionWindow, contributionsSchema, normalizeCalendar } from './contributions.js';
|
|
3
|
+
import { MAX_REPOS } from './repos.js';
|
|
4
|
+
// One query for the whole reel: the same 365-day window as Contribution City,
|
|
5
|
+
// the most starred owned public repos (the first is the top repo, the set
|
|
6
|
+
// counts the top languages and the total stars), and all-time merged pull
|
|
7
|
+
// requests.
|
|
8
|
+
const QUERY = /* GraphQL */ `
|
|
9
|
+
query Highlights($login: String!, $first: Int!, $from: DateTime!, $to: DateTime!) {
|
|
10
|
+
user(login: $login) {
|
|
11
|
+
contributionsCollection(from: $from, to: $to) {
|
|
12
|
+
contributionCalendar {
|
|
13
|
+
totalContributions
|
|
14
|
+
weeks {
|
|
15
|
+
contributionDays {
|
|
16
|
+
date
|
|
17
|
+
weekday
|
|
18
|
+
contributionCount
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
repos: repositories(
|
|
24
|
+
first: $first
|
|
25
|
+
ownerAffiliations: OWNER
|
|
26
|
+
privacy: PUBLIC
|
|
27
|
+
isFork: false
|
|
28
|
+
orderBy: { field: STARGAZERS, direction: DESC }
|
|
29
|
+
) {
|
|
30
|
+
nodes {
|
|
31
|
+
name
|
|
32
|
+
stargazerCount
|
|
33
|
+
forkCount
|
|
34
|
+
primaryLanguage {
|
|
35
|
+
name
|
|
36
|
+
color
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
pullRequests(states: MERGED) {
|
|
41
|
+
totalCount
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
const topRepoSchema = z.object({
|
|
47
|
+
name: z.string().min(1),
|
|
48
|
+
stars: z.number().int().min(0),
|
|
49
|
+
forks: z.number().int().min(0),
|
|
50
|
+
language: z
|
|
51
|
+
.object({
|
|
52
|
+
name: z.string().min(1),
|
|
53
|
+
/** Linguist color; null when the repo has no language or color. */
|
|
54
|
+
color: z
|
|
55
|
+
.string()
|
|
56
|
+
.regex(/^#[0-9a-fA-F]{6}$/)
|
|
57
|
+
.nullable(),
|
|
58
|
+
})
|
|
59
|
+
.nullable(),
|
|
60
|
+
});
|
|
61
|
+
/** Repos shown as pseudo cards around the top repo, most starred first. */
|
|
62
|
+
export const STARRED_REPOS = 6;
|
|
63
|
+
export const highlightsSchema = z.object({
|
|
64
|
+
contributions: contributionsSchema,
|
|
65
|
+
topRepo: topRepoSchema.nullable(),
|
|
66
|
+
/** Stars summed over the fetched repos. */
|
|
67
|
+
totalStars: z.number().int().min(0),
|
|
68
|
+
/** The most starred repos, top repo first, for the pseudo cards. */
|
|
69
|
+
starredRepos: z
|
|
70
|
+
.array(z.object({
|
|
71
|
+
name: z.string().min(1),
|
|
72
|
+
stars: z.number().int().min(0),
|
|
73
|
+
/** Linguist color of the primary language; null when none. */
|
|
74
|
+
color: z
|
|
75
|
+
.string()
|
|
76
|
+
.regex(/^#[0-9a-fA-F]{6}$/)
|
|
77
|
+
.nullable(),
|
|
78
|
+
}))
|
|
79
|
+
.max(STARRED_REPOS),
|
|
80
|
+
mergedPullRequests: z.number().int().min(0),
|
|
81
|
+
/** Top three languages by repo count over the fetched repos, most first. */
|
|
82
|
+
languages: z
|
|
83
|
+
.array(z.object({
|
|
84
|
+
name: z.string().min(1),
|
|
85
|
+
/** Linguist color; null when no repo sets one. */
|
|
86
|
+
color: z
|
|
87
|
+
.string()
|
|
88
|
+
.regex(/^#[0-9a-fA-F]{6}$/)
|
|
89
|
+
.nullable(),
|
|
90
|
+
count: z.number().int().min(1),
|
|
91
|
+
}))
|
|
92
|
+
.max(3),
|
|
93
|
+
});
|
|
94
|
+
export async function fetchHighlights(client, login, now) {
|
|
95
|
+
const { from, to } = contributionWindow(now);
|
|
96
|
+
const { user } = await client(QUERY, {
|
|
97
|
+
login,
|
|
98
|
+
first: MAX_REPOS,
|
|
99
|
+
from: `${from}T00:00:00Z`,
|
|
100
|
+
to: `${to}T23:59:59Z`,
|
|
101
|
+
});
|
|
102
|
+
const nodes = user.repos.nodes;
|
|
103
|
+
// Languages by repo count, ties by name; repos without a language are skipped.
|
|
104
|
+
const counts = new Map();
|
|
105
|
+
for (const n of nodes) {
|
|
106
|
+
if (!n.primaryLanguage)
|
|
107
|
+
continue;
|
|
108
|
+
const entry = counts.get(n.primaryLanguage.name) ?? {
|
|
109
|
+
color: n.primaryLanguage.color,
|
|
110
|
+
count: 0,
|
|
111
|
+
};
|
|
112
|
+
entry.count++;
|
|
113
|
+
counts.set(n.primaryLanguage.name, entry);
|
|
114
|
+
}
|
|
115
|
+
const languages = [...counts.entries()]
|
|
116
|
+
.sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]))
|
|
117
|
+
.slice(0, 3)
|
|
118
|
+
.map(([name, { color, count }]) => ({ name, color, count }));
|
|
119
|
+
const top = nodes[0];
|
|
120
|
+
return {
|
|
121
|
+
contributions: normalizeCalendar(user.contributionsCollection.contributionCalendar, from, to),
|
|
122
|
+
topRepo: top
|
|
123
|
+
? {
|
|
124
|
+
name: top.name,
|
|
125
|
+
stars: top.stargazerCount,
|
|
126
|
+
forks: top.forkCount,
|
|
127
|
+
language: top.primaryLanguage
|
|
128
|
+
? { name: top.primaryLanguage.name, color: top.primaryLanguage.color }
|
|
129
|
+
: null,
|
|
130
|
+
}
|
|
131
|
+
: null,
|
|
132
|
+
totalStars: nodes.reduce((sum, n) => sum + n.stargazerCount, 0),
|
|
133
|
+
starredRepos: nodes.slice(0, STARRED_REPOS).map((n) => ({
|
|
134
|
+
name: n.name,
|
|
135
|
+
stars: n.stargazerCount,
|
|
136
|
+
color: n.primaryLanguage?.color ?? null,
|
|
137
|
+
})),
|
|
138
|
+
mergedPullRequests: user.pullRequests.totalCount,
|
|
139
|
+
languages,
|
|
140
|
+
};
|
|
141
|
+
}
|
package/dist/core/encode/gif.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as gifencModule from 'gifenc';
|
|
|
4
4
|
const gifenc = typeof gifencModule.quantize === 'function'
|
|
5
5
|
? gifencModule
|
|
6
6
|
: gifencModule.default;
|
|
7
|
-
const { GIFEncoder, quantize
|
|
7
|
+
const { GIFEncoder, quantize } = gifenc;
|
|
8
8
|
/** Number of frames sampled to build the shared palette. */
|
|
9
9
|
const PALETTE_SAMPLES = 6;
|
|
10
10
|
/** 255 real colors; the last global palette slot is the transparent index. */
|
|
@@ -33,12 +33,13 @@ const BAYER = (() => {
|
|
|
33
33
|
* that does not change between frames dithers identically, which keeps the
|
|
34
34
|
* frame deltas small and avoids shimmer.
|
|
35
35
|
*/
|
|
36
|
-
function dither(rgba, width) {
|
|
36
|
+
function dither(rgba, width, pinned) {
|
|
37
37
|
const out = new Uint8ClampedArray(rgba.length);
|
|
38
38
|
for (let i = 0, p = 0; i < rgba.length; i += 4, p++) {
|
|
39
39
|
const x = p % width;
|
|
40
40
|
const y = (p - x) / width;
|
|
41
|
-
const
|
|
41
|
+
const exact = pinned.size > 0 && pinned.has((rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2]);
|
|
42
|
+
const offset = exact ? 0 : BAYER[(y & 7) * 8 + (x & 7)] * DITHER_SPREAD;
|
|
42
43
|
out[i] = rgba[i] + offset;
|
|
43
44
|
out[i + 1] = rgba[i + 1] + offset;
|
|
44
45
|
out[i + 2] = rgba[i + 2] + offset;
|
|
@@ -46,12 +47,48 @@ function dither(rgba, width) {
|
|
|
46
47
|
}
|
|
47
48
|
return out;
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Maps pixels to their nearest palette index, memoized per exact color.
|
|
52
|
+
* gifenc's `applyPalette` memoizes per rgb565 bucket instead, so the first pixel
|
|
53
|
+
* of a bucket in scan order picks the index for the whole bucket. Near-white
|
|
54
|
+
* pixels then land on white in one frame and on a pale grey in the next, and a
|
|
55
|
+
* flat card flickers. An exact memo is a pure function of the color.
|
|
56
|
+
*/
|
|
57
|
+
function createMapper(palette) {
|
|
58
|
+
const memo = new Int16Array(1 << 24).fill(-1);
|
|
59
|
+
const nearest = (r, g, b) => {
|
|
60
|
+
let best = 0;
|
|
61
|
+
let bestDist = Infinity;
|
|
62
|
+
for (let k = 0; k < palette.length; k++) {
|
|
63
|
+
const c = palette[k];
|
|
64
|
+
const d = (c[0] - r) ** 2 + (c[1] - g) ** 2 + (c[2] - b) ** 2;
|
|
65
|
+
if (d < bestDist) {
|
|
66
|
+
bestDist = d;
|
|
67
|
+
best = k;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return best;
|
|
71
|
+
};
|
|
72
|
+
return (rgba) => {
|
|
73
|
+
const out = new Uint8Array(rgba.length / 4);
|
|
74
|
+
for (let i = 0, p = 0; i < rgba.length; i += 4, p++) {
|
|
75
|
+
const key = (rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2];
|
|
76
|
+
let index = memo[key];
|
|
77
|
+
if (index < 0) {
|
|
78
|
+
index = nearest(rgba[i], rgba[i + 1], rgba[i + 2]);
|
|
79
|
+
memo[key] = index;
|
|
80
|
+
}
|
|
81
|
+
out[p] = index;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function buildPalette(frames, frameBytes, colors) {
|
|
50
87
|
const step = Math.max(1, Math.floor(frames.length / PALETTE_SAMPLES));
|
|
51
88
|
const picks = frames.filter((_, i) => i % step === 0).slice(0, PALETTE_SAMPLES);
|
|
52
89
|
const sample = new Uint8ClampedArray(frameBytes * picks.length);
|
|
53
90
|
picks.forEach((data, i) => sample.set(data, i * frameBytes));
|
|
54
|
-
return quantize(sample,
|
|
91
|
+
return quantize(sample, colors);
|
|
55
92
|
}
|
|
56
93
|
/**
|
|
57
94
|
* Encodes RGBA frames into a looping GIF: one global palette, ordered
|
|
@@ -59,16 +96,22 @@ function buildPalette(frames, frameBytes) {
|
|
|
59
96
|
*/
|
|
60
97
|
export function encodeGif(frames, spec) {
|
|
61
98
|
const { width, height } = spec;
|
|
62
|
-
const
|
|
99
|
+
const pinnedColors = spec.pinned ?? [];
|
|
100
|
+
const palette = [
|
|
101
|
+
...buildPalette(frames, width * height * 4, COLORS - pinnedColors.length),
|
|
102
|
+
...pinnedColors.map((c) => [...c]),
|
|
103
|
+
];
|
|
104
|
+
const pinned = new Set(pinnedColors.map(([r, g, b]) => (r << 16) | (g << 8) | b));
|
|
63
105
|
const globalPalette = [...palette];
|
|
64
106
|
while (globalPalette.length < TRANSPARENT)
|
|
65
107
|
globalPalette.push([0, 0, 0]);
|
|
66
108
|
globalPalette.push([0, 0, 0]);
|
|
109
|
+
const toIndex = createMapper(palette);
|
|
67
110
|
const gif = GIFEncoder();
|
|
68
111
|
const delay = Math.round(1000 / spec.fps);
|
|
69
112
|
let previous;
|
|
70
113
|
for (const [i, data] of frames.entries()) {
|
|
71
|
-
const index =
|
|
114
|
+
const index = toIndex(dither(data, width, pinned));
|
|
72
115
|
let pixels = index;
|
|
73
116
|
if (previous) {
|
|
74
117
|
pixels = new Uint8Array(index);
|
package/dist/core/pipeline.js
CHANGED
|
@@ -24,5 +24,9 @@ export async function renderBanner(style, input) {
|
|
|
24
24
|
rng,
|
|
25
25
|
});
|
|
26
26
|
const frames = await renderFrames(sketch, { ...CANVAS, frames: style.frames });
|
|
27
|
-
return encodeGif(frames, {
|
|
27
|
+
return encodeGif(frames, {
|
|
28
|
+
...CANVAS,
|
|
29
|
+
fps: style.fps,
|
|
30
|
+
...(style.pinned ? { pinned: style.pinned(options) } : {}),
|
|
31
|
+
});
|
|
28
32
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createCanvas } from '@napi-rs/canvas';
|
|
2
|
+
import { registerFonts } from './fonts.js';
|
|
3
|
+
const ELLIPSIS = '…';
|
|
4
|
+
/**
|
|
5
|
+
* Fits one line of text into `maxWidth`. It first shrinks from `size` toward
|
|
6
|
+
* `minSize` (pass `minSize === size` to keep the size), then cuts the text
|
|
7
|
+
* with an ellipsis. The result is never wider than `maxWidth`, so no line can
|
|
8
|
+
* leave the canvas or run into the scene.
|
|
9
|
+
*/
|
|
10
|
+
export function fitLine(text, maxWidth, size, minSize, measure) {
|
|
11
|
+
if (measure(text, size) <= maxWidth)
|
|
12
|
+
return { text, size };
|
|
13
|
+
// Width scales about linearly with size; step down until it fits.
|
|
14
|
+
let fitted = Math.max(minSize, Math.floor((size * maxWidth) / measure(text, size)));
|
|
15
|
+
while (fitted > minSize && measure(text, fitted) > maxWidth)
|
|
16
|
+
fitted--;
|
|
17
|
+
if (measure(text, fitted) <= maxWidth)
|
|
18
|
+
return { text, size: fitted };
|
|
19
|
+
// Longest prefix that fits with the ellipsis.
|
|
20
|
+
const chars = [...text];
|
|
21
|
+
let lo = 0;
|
|
22
|
+
let hi = chars.length;
|
|
23
|
+
while (lo < hi) {
|
|
24
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
25
|
+
const candidate = `${chars.slice(0, mid).join('').trimEnd()}${ELLIPSIS}`;
|
|
26
|
+
if (measure(candidate, minSize) <= maxWidth)
|
|
27
|
+
lo = mid;
|
|
28
|
+
else
|
|
29
|
+
hi = mid - 1;
|
|
30
|
+
}
|
|
31
|
+
const cut = chars.slice(0, lo).join('').trimEnd();
|
|
32
|
+
return { text: cut ? `${cut}${ELLIPSIS}` : '', size: minSize };
|
|
33
|
+
}
|
|
34
|
+
let scratch;
|
|
35
|
+
/**
|
|
36
|
+
* Measures with the bundled fonts on a private canvas, so fitting never
|
|
37
|
+
* touches a sketch's own drawing state (p5 caches font and fill state).
|
|
38
|
+
* `tracking` is letter spacing in px, as the sketch draws it.
|
|
39
|
+
*/
|
|
40
|
+
export function canvasMeasure(family, weight, tracking = 0) {
|
|
41
|
+
registerFonts();
|
|
42
|
+
scratch ??= createCanvas(1, 1).getContext('2d');
|
|
43
|
+
const ctx = scratch;
|
|
44
|
+
return (text, size) => {
|
|
45
|
+
ctx.font = `${weight} ${size}px "${family}"`;
|
|
46
|
+
ctx.letterSpacing = `${tracking}px`;
|
|
47
|
+
return ctx.measureText(text).width;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** The identity column's lines, shared by every style (same fonts and sizes). */
|
|
51
|
+
export const IDENTITY_TYPE = {
|
|
52
|
+
name: { family: 'Space Grotesk', weight: 700, size: 50, minSize: 30, tracking: 3 },
|
|
53
|
+
tagline: { family: 'Space Grotesk', weight: 400, size: 17 },
|
|
54
|
+
website: { family: 'JetBrains Mono', weight: 400, size: 12 },
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Fits the identity column: `name` shrinks then cuts, `tagline` and `website`
|
|
58
|
+
* cut. `column` bounds name and tagline; `websiteColumn` bounds the website,
|
|
59
|
+
* which some styles draw beside the scene rather than above it.
|
|
60
|
+
*/
|
|
61
|
+
export function fitIdentity(lines, column, websiteColumn = column) {
|
|
62
|
+
const { name, tagline, website } = IDENTITY_TYPE;
|
|
63
|
+
return {
|
|
64
|
+
name: fitLine(lines.name, column, name.size, name.minSize, canvasMeasure(name.family, name.weight, name.tracking)),
|
|
65
|
+
tagline: lines.tagline
|
|
66
|
+
? fitLine(lines.tagline, column, tagline.size, tagline.size, canvasMeasure(tagline.family, tagline.weight))
|
|
67
|
+
: null,
|
|
68
|
+
website: lines.website
|
|
69
|
+
? fitLine(lines.website, websiteColumn, website.size, website.size, canvasMeasure(website.family, website.weight))
|
|
70
|
+
: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -31,7 +31,9 @@ export function tileAt(weeks, w, d) {
|
|
|
31
31
|
const depth = 6 - d;
|
|
32
32
|
return { x: o.x + w * LAYOUT.cell + depth * LAYOUT.depthX, y: o.y - depth * LAYOUT.depthY };
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/** Busiest days marked with a beacon. */
|
|
35
|
+
export const BEACONS = 8;
|
|
36
|
+
export function buildCity(data, rng) {
|
|
35
37
|
const between = (min, max) => min + rng() * (max - min);
|
|
36
38
|
const weeks = data.weeks.length;
|
|
37
39
|
const maxCount = Math.max(0, ...data.weeks.flat().map((d) => d.count));
|
|
@@ -73,7 +75,7 @@ export function buildCity(data, rng, beacons) {
|
|
|
73
75
|
const ranked = buildings
|
|
74
76
|
.filter((b) => b.count > 0)
|
|
75
77
|
.sort((a, b) => b.count - a.count || a.w - b.w || a.d - b.d);
|
|
76
|
-
for (const b of ranked.slice(0,
|
|
78
|
+
for (const b of ranked.slice(0, BEACONS))
|
|
77
79
|
b.beacon = true;
|
|
78
80
|
// Back rows first, then left to right, so front towers occlude correctly.
|
|
79
81
|
buildings.sort((a, b) => a.d - b.d || a.w - b.w);
|
|
@@ -9,6 +9,6 @@ export const contributionCity = {
|
|
|
9
9
|
data: { name: 'contributions', schema: contributionsSchema, fetch: fetchContributions },
|
|
10
10
|
options,
|
|
11
11
|
createSketch({ data, options, identity, rng }) {
|
|
12
|
-
return createCitySketch(buildCity(data, rng
|
|
12
|
+
return createCitySketch(buildCity(data, rng), identity, rng, options.accent);
|
|
13
13
|
},
|
|
14
14
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
export const ACCENTS = ['cyan', 'cobalt', 'green', 'violet', 'pink'];
|
|
2
3
|
export const options = z
|
|
3
4
|
.object({
|
|
4
|
-
/**
|
|
5
|
-
|
|
5
|
+
/** Color of the roofs, windows, glow, lines, scan beam and links. */
|
|
6
|
+
accent: z.enum(ACCENTS).default('cyan'),
|
|
6
7
|
})
|
|
7
8
|
.strict();
|