Skip to content

Commit c934cf1

Browse files
committed
Добавляет генератор графики
1 parent 6c891cc commit c934cf1

File tree

5 files changed

+315
-65
lines changed

5 files changed

+315
-65
lines changed

.github/workflows/hosts.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: hosts
2+
3+
on:
4+
schedule:
5+
- cron: '0 0 1 * *'
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
update:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version-file: package.json
19+
- run: npm ci
20+
- run: npm run hosts
21+
- run: |
22+
git config user.name "github-actions[bot]"
23+
git config user.email "github-actions[bot]@users.noreply.github.com"
24+
git add src/hosts.svg
25+
if ! git diff --staged --quiet; then
26+
git commit -m "Обновляет hosts.svg"
27+
git push
28+
fi

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"file-size": "node scripts/feed/file-size.js",
1717
"prepare": "husky || true",
1818
"wav": "node scripts/wav.js",
19-
"mp3": "node scripts/mp3.js"
19+
"mp3": "node scripts/mp3.js",
20+
"hosts": "node scripts/hosts.js"
2021
},
2122
"engines": {
2223
"node": ">=24"

scripts/hosts.js

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import yaml from 'js-yaml';
4+
5+
// Configuration
6+
const EPISODES_DIR = path.join('src', 'episodes');
7+
const SVG_PATH = path.join('src', 'hosts.svg');
8+
const START_YEAR = 2016;
9+
const PX_PER_MONTH = 6;
10+
const LEFT_MARGIN = 208;
11+
const ROW_HEIGHT = 64;
12+
const FIRST_ROW_Y = 80;
13+
14+
// Host name aliases → canonical name
15+
const HOST_ALIASES = {
16+
'Vadim Makeev': 'Вадим Макеев',
17+
'Vadim Machiavelli': 'Вадим Макеев',
18+
'Alessio Simonetti': 'Алексей Симоненко',
19+
'Olga Alessandrini': 'Ольга Алексашенко',
20+
};
21+
22+
// Fixed color mapping for known hosts (by name)
23+
const HOST_COLORS = {
24+
'Вадим Макеев': 'red',
25+
'Алексей Симоненко': 'orange',
26+
'Ольга Алексашенко': 'yellow',
27+
'Мария Просвирнина': 'green',
28+
'Никита Дубко': 'cyan',
29+
'Андрей Мелихов': 'blue',
30+
'Юля Миоцен': 'violet',
31+
'Полина Гуртовая': 'indigo',
32+
};
33+
34+
const FALLBACK_COLORS = [
35+
'red', 'orange', 'yellow', 'green',
36+
'cyan', 'blue', 'violet', 'indigo',
37+
];
38+
39+
function canonicalize(name) {
40+
return HOST_ALIASES[name] || name;
41+
}
42+
43+
function monthsFromEpoch(date) {
44+
return (date.getFullYear() - START_YEAR) * 12 + date.getMonth();
45+
}
46+
47+
function readEpisodes() {
48+
const episodes = [];
49+
const episodeDirs = fs.readdirSync(EPISODES_DIR, { withFileTypes: true });
50+
51+
for (const entry of episodeDirs) {
52+
if (!entry.isDirectory()) continue;
53+
54+
const ymlPath = path.join(EPISODES_DIR, entry.name, 'index.yml');
55+
if (!fs.existsSync(ymlPath)) continue;
56+
57+
const content = fs.readFileSync(ymlPath, 'utf-8');
58+
const data = yaml.load(content);
59+
60+
if (!data || !data.date || !data.hosts) continue;
61+
62+
const date = new Date(data.date);
63+
if (date.getFullYear() > 2900) continue;
64+
65+
episodes.push({
66+
number: entry.name,
67+
date,
68+
hosts: data.hosts.map(canonicalize),
69+
});
70+
}
71+
72+
episodes.sort((a, b) => a.date - b.date);
73+
return episodes;
74+
}
75+
76+
function collectHostAppearances(episodes) {
77+
const appearances = new Map();
78+
79+
for (const episode of episodes) {
80+
for (const host of episode.hosts) {
81+
if (!appearances.has(host)) {
82+
appearances.set(host, []);
83+
}
84+
appearances.get(host).push(episode.date);
85+
}
86+
}
87+
88+
return appearances;
89+
}
90+
91+
function computeHostData(episodes, now) {
92+
const appearances = collectHostAppearances(episodes);
93+
const latestEpisodeMonth = monthsFromEpoch(episodes[episodes.length - 1].date);
94+
const hosts = [];
95+
96+
for (const [name, dates] of appearances) {
97+
const firstDate = dates[0];
98+
const lastDate = dates[dates.length - 1];
99+
const isCurrent = monthsFromEpoch(lastDate) >= latestEpisodeMonth;
100+
101+
const startMonth = monthsFromEpoch(firstDate);
102+
const endMonth = isCurrent ? monthsFromEpoch(now) + 1 : monthsFromEpoch(lastDate);
103+
const durationMonths = endMonth - startMonth;
104+
105+
hosts.push({
106+
name,
107+
startMonth,
108+
durationMonths,
109+
isCurrent,
110+
totalEpisodes: dates.length,
111+
firstDate,
112+
lastDate,
113+
});
114+
}
115+
116+
hosts.sort((a, b) => {
117+
if (a.startMonth !== b.startMonth) return a.startMonth - b.startMonth;
118+
return b.totalEpisodes - a.totalEpisodes;
119+
});
120+
121+
return hosts;
122+
}
123+
124+
function patchSVG(svgContent, hosts, now) {
125+
const numHosts = hosts.length;
126+
const lastFullYear = now.getFullYear();
127+
const currentMonthX = LEFT_MARGIN + (monthsFromEpoch(now) + 1) * PX_PER_MONTH;
128+
const lastYearX = LEFT_MARGIN + (lastFullYear - START_YEAR) * 12 * PX_PER_MONTH;
129+
const width = Math.max(currentMonthX, lastYearX + 12 * PX_PER_MONTH);
130+
const height = FIRST_ROW_Y + (numHosts - 1) * ROW_HEIGHT + 112;
131+
const labelsY = height - 48;
132+
133+
let svg = svgContent;
134+
135+
// 1. Update viewBox
136+
svg = svg.replace(
137+
/viewBox="0 0 \d+ \d+"/,
138+
`viewBox="0 0 ${width} ${height}"`
139+
);
140+
141+
// 2. Update vertical line definition y2
142+
svg = svg.replace(
143+
/(id="vertical"[\s\S]*?y2=")(\d+)(")/,
144+
`$1${height}$3`
145+
);
146+
147+
// 3. Update horizontal line definition x2
148+
svg = svg.replace(
149+
/(id="horizontal"[\s\S]*?x2=")(\d+)(")/,
150+
`$1${currentMonthX}$3`
151+
);
152+
153+
// 4. Replace vertical <use> lines
154+
const verticalLines = [];
155+
for (let y = START_YEAR; y <= lastFullYear; y++) {
156+
const x = LEFT_MARGIN + (y - START_YEAR) * 12 * PX_PER_MONTH;
157+
verticalLines.push(`\t<use href="#vertical" x="${x}"/>`);
158+
}
159+
svg = svg.replace(
160+
/\t<use href="#vertical"[^]*?(?=\n\n)/,
161+
verticalLines.join('\n')
162+
);
163+
164+
// 5. Replace horizontal <use> lines
165+
const horizontalLines = [];
166+
for (let i = 0; i < numHosts; i++) {
167+
const y = FIRST_ROW_Y + i * ROW_HEIGHT;
168+
horizontalLines.push(`\t<use href="#horizontal" y="${y}"/>`);
169+
}
170+
svg = svg.replace(
171+
/\t<use href="#horizontal"[^]*?(?=\n\n)/,
172+
horizontalLines.join('\n')
173+
);
174+
175+
// 6. Replace year labels group
176+
const yearLabels = [];
177+
for (let y = START_YEAR; y <= lastFullYear; y++) {
178+
const x = LEFT_MARGIN + (y - START_YEAR) * 12 * PX_PER_MONTH;
179+
yearLabels.push(`\t\t<text x="${x}">${y}</text>`);
180+
}
181+
svg = svg.replace(
182+
/\t<g transform="translate\(16 \d+\)">[^]*?<\/g>/,
183+
`\t<g transform="translate(16 ${labelsY})">\n${yearLabels.join('\n')}\n\t</g>`
184+
);
185+
186+
// 7. Replace host groups
187+
const hostGroups = [];
188+
for (let i = 0; i < numHosts; i++) {
189+
const host = hosts[i];
190+
const y = FIRST_ROW_Y + i * ROW_HEIGHT;
191+
const color = HOST_COLORS[host.name]
192+
|| FALLBACK_COLORS[i % FALLBACK_COLORS.length];
193+
194+
const rectX = LEFT_MARGIN + host.startMonth * PX_PER_MONTH;
195+
const rectWidth = host.durationMonths * PX_PER_MONTH;
196+
const countX = rectX + rectWidth - 8;
197+
198+
hostGroups.push(
199+
`\t<g transform="translate(0 ${y})">\n` +
200+
`\t\t<text class="name" x="192">\n` +
201+
`\t\t\t${host.name}\n` +
202+
`\t\t</text>\n` +
203+
`\t\t<rect class="path" style="fill: var(--color-${color})" x="${rectX}" width="${rectWidth}"/>\n` +
204+
`\t\t<text x="${countX}" y="6" text-anchor="end">\n` +
205+
`\t\t\t${host.totalEpisodes}\n` +
206+
`\t\t</text>\n` +
207+
`\t</g>`
208+
);
209+
}
210+
211+
const startMarker = '<!-- hosts -->';
212+
const endMarker = '<!-- / hosts -->';
213+
const startIndex = svg.indexOf(startMarker);
214+
const endIndex = svg.indexOf(endMarker);
215+
svg = svg.slice(0, startIndex + startMarker.length) +
216+
'\n' + hostGroups.join('\n') + '\n\t' +
217+
svg.slice(endIndex);
218+
219+
return svg;
220+
}
221+
222+
// Main
223+
const now = new Date();
224+
const episodes = readEpisodes();
225+
const hosts = computeHostData(episodes, now);
226+
227+
const dateFormat = new Intl.DateTimeFormat('ru', { month: 'long', year: 'numeric' });
228+
229+
function formatDate(date) {
230+
const parts = dateFormat.formatToParts(date);
231+
const month = parts.find(p => p.type === 'month').value;
232+
const year = parts.find(p => p.type === 'year').value;
233+
return `${month} ${year}`;
234+
}
235+
236+
console.log('Hosting periods computed from episode data:\n');
237+
238+
for (const host of hosts) {
239+
const start = formatDate(host.firstDate);
240+
const end = host.isCurrent ? 'до сих пор' : formatDate(host.lastDate);
241+
console.log(` ${host.name}: ${start}${end} (${host.totalEpisodes})`);
242+
}
243+
244+
const svgContent = fs.readFileSync(SVG_PATH, 'utf-8');
245+
const patched = patchSVG(svgContent, hosts, now);
246+
fs.writeFileSync(SVG_PATH, patched);
247+
248+
console.log(`\n✓ Updated ${SVG_PATH}`);

src/episodes/1/index.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ title: Браузеры, PhantomJS, CSS-переменные, Sass с PostCSS,
33
date: 2016-02-01T09:00
44

55
hosts:
6-
- Ольга Алексашенко
76
- Вадим Макеев
87
- Алексей Симоненко
8+
- Ольга Алексашенко
99

1010
chapters:
1111
- time: 00:00:00

0 commit comments

Comments
 (0)