~cytrogen/realm

ref: f6d518240097f488822a520c456a3592915b9725 realm/build-village.py -rw-r--r-- 19.0 KiB
f6d51824 — Cytrogen 更新联系方式 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
#!/usr/bin/env python3
"""
build-village.py — Generate friends.html from a FreshRSS OPML export.

Usage:
    python build-village.py friends.opml

Reads <outline> entries from the OPML, assigns each to one of 9 hillside
clusters, picks from 5 house templates, and writes a complete friends.html
with an interactive zoomable SVG village.
"""

import hashlib
import random
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass

# ────────────────────────────────────────────────────────────────
# Data structures
# ────────────────────────────────────────────────────────────────

@dataclass
class Friend:
    name: str
    url: str


# ────────────────────────────────────────────────────────────────
# OPML parser
# ────────────────────────────────────────────────────────────────

def _site_url_from_feed(xml_url: str) -> str:
    """Derive a site URL from a feed URL (best effort)."""
    from urllib.parse import urlparse
    parsed = urlparse(xml_url)
    return f"{parsed.scheme}://{parsed.netloc}/"


# Categories to exclude (your own sites)
EXCLUDE_CATEGORIES = {"第零区"}


def parse_opml(path: str) -> list[Friend]:
    """Extract friends from OPML <outline> elements."""
    tree = ET.parse(path)
    root = tree.getroot()
    friends = []

    for category in root.iter("outline"):
        cat_name = category.get("text", "").strip()

        # Skip excluded categories
        if cat_name in EXCLUDE_CATEGORIES:
            continue

        for outline in category.findall("outline"):
            name = outline.get("text", "").strip()
            xml_url = outline.get("xmlUrl", "").strip()
            html_url = outline.get("htmlUrl", "").strip()

            if not name or not xml_url:
                continue

            # Prefer htmlUrl; fall back to deriving from xmlUrl
            url = html_url or _site_url_from_feed(xml_url)
            friends.append(Friend(name=name, url=url))

    # Deterministic sort by name
    friends.sort(key=lambda f: f.name.lower())
    return friends


# ────────────────────────────────────────────────────────────────
# Coordinate layout
# ────────────────────────────────────────────────────────────────

def _name_hash(name: str) -> int:
    """Deterministic hash from name string (0–65535)."""
    return int(hashlib.md5(name.encode()).hexdigest()[:4], 16)


# ── Hill boundary calculation ──
# Upper hill: M100 480 Q350 120 600 130 Q850 120 1100 480
# Lower hill: M0 480 Q300 240 600 250 Q900 240 1200 480

_HILL_SEGMENTS = [
    # Upper hill
    ((100, 480), (350, 120), (600, 130)),
    ((600, 130), (850, 120), (1100, 480)),
    # Lower hill
    ((0, 480), (300, 240), (600, 250)),
    ((600, 250), (900, 240), (1200, 480)),
]

VALLEY_FLOOR = 480
HOUSE_W, HOUSE_H = 38, 34
MIN_GAP = 5


def _bezier_y_at_x(x: float, p0: tuple, p1: tuple, p2: tuple) -> float | None:
    """Find y on a quadratic bezier at given x. Returns None if outside."""
    x0, y0 = p0
    x1, y1 = p1
    x2, y2 = p2

    a = x0 - 2 * x1 + x2
    b = 2 * (x1 - x0)
    c = x0 - x

    if abs(a) < 1e-10:
        if abs(b) < 1e-10:
            return None
        t = -c / b
    else:
        disc = b * b - 4 * a * c
        if disc < 0:
            return None
        sqrt_d = disc ** 0.5
        t = None
        for tc in [(-b + sqrt_d) / (2 * a), (-b - sqrt_d) / (2 * a)]:
            if 0 <= tc <= 1:
                t = tc
                break
        if t is None:
            return None

    if not (0 <= t <= 1):
        return None
    return (1 - t) ** 2 * y0 + 2 * t * (1 - t) * y1 + t ** 2 * y2


def hill_boundary(x: float) -> float:
    """Return the highest ground y at given x (lowest value = highest on screen)."""
    min_y = VALLEY_FLOOR
    for seg in _HILL_SEGMENTS:
        y = _bezier_y_at_x(x, *seg)
        if y is not None and y < min_y:
            min_y = y
    return min_y


def place_houses(friends: list[Friend]) -> list[tuple[Friend, float, float]]:
    """Place houses randomly across the hill surface, no overlaps."""
    # Deterministic seed from all friend names
    seed_str = "|".join(f.name for f in friends)
    rng = random.Random(hashlib.md5(seed_str.encode()).hexdigest())

    placed: list[tuple[float, float]] = []
    result: list[tuple[Friend, float, float]] = []

    for friend in friends:
        ok = False

        for _ in range(2000):
            cx = rng.uniform(80, 1120)
            top = hill_boundary(cx) + 10  # margin below hill edge
            bottom = VALLEY_FLOOR - HOUSE_H
            if top >= bottom:
                continue
            cy = rng.uniform(top, bottom)

            # Check overlap with all placed houses
            overlap = False
            for px, py in placed:
                if abs(cx - px) < HOUSE_W + MIN_GAP and abs(cy - py) < HOUSE_H + MIN_GAP:
                    overlap = True
                    break

            if not overlap:
                placed.append((cx, cy))
                result.append((friend, round(cx, 1), round(cy, 1)))
                ok = True
                break

        if not ok:
            # Fallback: scan for first open spot along valley floor
            for fx in range(100, 1100, 20):
                fy = VALLEY_FLOOR - HOUSE_H - 5
                overlap = any(
                    abs(fx - px) < HOUSE_W + MIN_GAP and abs(fy - py) < HOUSE_H + MIN_GAP
                    for px, py in placed
                )
                if not overlap:
                    placed.append((fx, fy))
                    result.append((friend, float(fx), float(fy)))
                    break

    return result


# ────────────────────────────────────────────────────────────────
# House SVG templates
#
# Each function returns SVG elements (relative to 0,0) for a house.
# The caller wraps them in a <g transform="translate(x,y)">.
# All houses fit in roughly a 40×40 bounding box.
#
# ★ USER CONTRIBUTION POINT ★
# These 5 shapes define the village personality. Modify the paths
# below to change house silhouettes. Keep fill classes consistent:
#   .fill-wall / .fill-wall-dark  — walls
#   .fill-roof                    — roof
#   .fill-window                  — windows (glow on hover)
#   .fill-door                    — door
# ────────────────────────────────────────────────────────────────

def house_peaked(name: str) -> str:
    """Type A: Classic peaked roof cottage."""
    return f'''\
<rect class="fill-wall" x="2" y="16" width="28" height="20"/>
<polygon class="fill-roof" points="0,16 16,0 32,16"/>
<rect class="fill-window house-window" x="7" y="21" width="6" height="6"/>
<rect class="fill-window house-window" x="19" y="21" width="6" height="6"/>
<rect class="fill-door" x="12" y="28" width="8" height="8"/>'''


def house_flat(name: str) -> str:
    """Type B: Flat-roof modernist block."""
    return f'''\
<rect class="fill-wall" x="2" y="10" width="30" height="26"/>
<rect class="fill-roof" x="0" y="8" width="34" height="4"/>
<rect class="fill-window house-window" x="6" y="15" width="8" height="6"/>
<rect class="fill-window house-window" x="20" y="15" width="8" height="6"/>
<rect class="fill-door" x="13" y="28" width="8" height="8"/>'''


def house_squat(name: str) -> str:
    """Type C: Wide squat bungalow."""
    return f'''\
<rect class="fill-wall" x="0" y="14" width="38" height="22"/>
<polygon class="fill-roof" points="-2,14 19,4 40,14"/>
<rect class="fill-window house-window" x="4" y="19" width="7" height="5"/>
<rect class="fill-window house-window" x="27" y="19" width="7" height="5"/>
<rect class="fill-door" x="15" y="28" width="8" height="8"/>'''


def house_ell(name: str) -> str:
    """Type D: L-shaped house with extension."""
    return f'''\
<rect class="fill-wall" x="0" y="12" width="24" height="24"/>
<rect class="fill-wall-dark" x="24" y="20" width="14" height="16"/>
<polygon class="fill-roof" points="-2,12 12,2 26,12"/>
<rect class="fill-roof" x="22" y="17" width="18" height="4"/>
<rect class="fill-window house-window" x="5" y="18" width="5" height="5"/>
<rect class="fill-window house-window" x="28" y="24" width="5" height="5"/>
<rect class="fill-door" x="13" y="28" width="7" height="8"/>'''


def house_tower(name: str) -> str:
    """Type E: Tall narrow tower."""
    return f'''\
<rect class="fill-wall" x="6" y="8" width="20" height="28"/>
<polygon class="fill-roof" points="4,8 16,0 28,8"/>
<rect class="fill-window house-window" x="11" y="13" width="6" height="5"/>
<rect class="fill-window house-window" x="11" y="22" width="6" height="5"/>
<rect class="fill-door" x="10" y="30" width="8" height="6"/>'''


HOUSE_TEMPLATES = [house_peaked, house_flat, house_squat, house_ell, house_tower]


def render_house(friend: Friend, x: float, y: float, template_idx: int) -> str:
    """Render a single house as an <a> hotspot."""
    template_fn = HOUSE_TEMPLATES[template_idx % len(HOUSE_TEMPLATES)]
    house_svg = template_fn(friend.name)

    # Escape name for XML
    safe_name = friend.name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
    safe_url = friend.url.replace("&", "&amp;").replace('"', "&quot;")

    return f'''\t\t\t\t<a class="hotspot village-house" href="{safe_url}" target="_blank" rel="noopener noreferrer" aria-label="{safe_name}">
\t\t\t\t\t<title>{safe_name}</title>
\t\t\t\t\t<g transform="translate({x},{y})">
{_indent(house_svg, 6)}
\t\t\t\t\t</g>
\t\t\t\t\t<text class="hotspot-label village-label" x="{x + 16}" y="{y - 4}" text-anchor="middle">{safe_name}</text>
\t\t\t\t</a>'''


def _indent(text: str, tabs: int) -> str:
    prefix = "\t" * tabs
    return "\n".join(prefix + line for line in text.strip().split("\n"))


# ────────────────────────────────────────────────────────────────
# Background SVG
# ────────────────────────────────────────────────────────────────

BACKGROUND_SVG = '''\
\t\t\t\t<!-- Sky -->
\t\t\t\t<rect class="fill-sky" x="0" y="0" width="1200" height="800"/>

\t\t\t\t<!-- Stars -->
\t\t\t\t<g class="scene-detail" aria-hidden="true">
\t\t\t\t\t<rect class="fill-star" x="80" y="30" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="200" y="55" width="3" height="3"/>
\t\t\t\t\t<rect class="fill-star" x="350" y="20" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="480" y="45" width="3" height="3"/>
\t\t\t\t\t<rect class="fill-star" x="620" y="15" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="750" y="40" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="880" y="25" width="3" height="3"/>
\t\t\t\t\t<rect class="fill-star" x="1000" y="50" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="1100" y="18" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="150" y="70" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="550" y="60" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="950" y="38" width="3" height="3"/>
\t\t\t\t</g>

\t\t\t\t<!-- Distant mountains -->
\t\t\t\t<path class="fill-mountain-far" d="M0 200 L100 140 L220 170 L350 110 L500 150 L650 100 L800 130 L950 90 L1100 120 L1200 100 L1200 250 L0 250 Z" aria-hidden="true"/>
\t\t\t\t<path class="fill-mountain" d="M0 240 L150 180 L300 210 L450 160 L600 200 L750 150 L900 190 L1050 140 L1200 170 L1200 280 L0 280 Z" aria-hidden="true"/>

\t\t\t\t<!-- Hillside terrain (3 tiers) -->
\t\t\t\t<!-- Upper hill -->
\t\t\t\t<path class="fill-ground" d="M100 480 Q350 120 600 130 Q850 120 1100 480 Z" aria-hidden="true"/>
\t\t\t\t<!-- Lower hill -->
\t\t\t\t<path class="fill-ground" d="M0 480 Q300 240 600 250 Q900 240 1200 480 Z" aria-hidden="true"/>
\t\t\t\t<!-- Valley floor -->
\t\t\t\t<rect class="fill-ground" x="0" y="480" width="1200" height="320" aria-hidden="true"/>

\t\t\t\t<!-- Winding path -->
\t\t\t\t<path class="fill-path" d="M550 140 Q480 200 520 270 Q560 330 500 390 Q440 440 480 520 Q520 580 480 650 L500 650 Q540 580 500 520 Q460 440 520 390 Q580 330 540 270 Q500 200 570 140 Z" aria-hidden="true"/>

\t\t\t\t<!-- Decorative trees -->
\t\t\t\t<g class="scene-detail" aria-hidden="true">
\t\t\t\t\t<!-- Top tier trees -->
\t\t\t\t\t<polygon class="fill-tree" points="280,220 290,180 300,220"/>
\t\t\t\t\t<polygon class="fill-tree" points="680,210 690,170 700,210"/>
\t\t\t\t\t<polygon class="fill-tree" points="850,225 860,188 870,225"/>
\t\t\t\t\t<!-- Middle tier trees -->
\t\t\t\t\t<polygon class="fill-tree" points="150,340 162,300 174,340"/>
\t\t\t\t\t<polygon class="fill-tree" points="420,310 430,275 440,310"/>
\t\t\t\t\t<polygon class="fill-tree" points="680,320 690,285 700,320"/>
\t\t\t\t\t<polygon class="fill-tree" points="950,340 960,305 970,340"/>
\t\t\t\t\t<!-- Bottom tier trees -->
\t\t\t\t\t<polygon class="fill-tree" points="200,460 212,425 224,460"/>
\t\t\t\t\t<polygon class="fill-tree" points="480,445 490,412 500,445"/>
\t\t\t\t\t<polygon class="fill-tree" points="780,440 790,408 800,440"/>
\t\t\t\t\t<polygon class="fill-tree" points="1050,450 1060,418 1070,450"/>
\t\t\t\t</g>'''

# ────────────────────────────────────────────────────────────────
# HTML template
# ────────────────────────────────────────────────────────────────

def build_html(friends: list[Friend]) -> str:
    all_ordered = place_houses(friends)

    # Generate house SVG
    house_lines = []
    for i, (friend, x, y) in enumerate(all_ordered):
        house_svg = render_house(friend, x, y, i)
        house_lines.append(house_svg)

    houses_svg = "\n\n".join(house_lines)

    # Generate scene-nav list
    nav_items = []
    for friend, _, _ in all_ordered:
        safe_name = friend.name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        safe_url = friend.url.replace("&", "&amp;").replace('"', "&quot;")
        nav_items.append(
            f'\t\t\t\t<li><a class="scene-nav__link" href="{safe_url}" '
            f'target="_blank" rel="noopener noreferrer">{safe_name}</a></li>'
        )
    nav_list = "\n".join(nav_items)

    # Generate noscript link list
    noscript_items = []
    for friend, _, _ in all_ordered:
        safe_name = friend.name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        safe_url = friend.url.replace("&", "&amp;").replace('"', "&quot;")
        noscript_items.append(
            f'\t\t\t\t<li><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_name}</a></li>'
        )
    noscript_list = "\n".join(noscript_items)

    # Back-to-neighbors house SVG at bottom
    back_svg = '''\t\t\t\t<!-- Return path -->
\t\t\t\t<a class="hotspot" href="neighbors.html" aria-label="邻邦 — Return to neighbors">
\t\t\t\t\t<title>邻邦 — Return</title>
\t\t\t\t\t<path class="hotspot-shape fill-path" d="M475 680 L505 680 L500 620 L480 620 Z"/>
\t\t\t\t\t<text class="hotspot-label" x="490" y="710" text-anchor="middle">邻邦</text>
\t\t\t\t</a>'''

    return f'''\
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
\t<meta charset="utf-8">
\t<meta name="viewport" content="width=device-width, initial-scale=1">
\t<title>友邻 — Realm</title>
\t<meta name="description" content="友邻村落 — Friends village of the Realm">
\t<meta name="theme-color" content="#1a1a2e">
\t<link rel="stylesheet" href="realm.css">
</head>
<body>

\t<a class="skip-link" href="#scene-nav-friends">跳过场景</a>

\t<main class="realm-folder">
\t\t<h1 class="visually-hidden">友邻</h1>

\t\t<input type="radio" name="village-zoom" id="vz1" class="visually-hidden" checked>
\t\t<input type="radio" name="village-zoom" id="vz2" class="visually-hidden">
\t\t<input type="radio" name="village-zoom" id="vz3" class="visually-hidden">
\t\t<input type="radio" name="village-zoom" id="vz4" class="visually-hidden">

\t\t<div class="village-controls">
\t\t\t<label for="vz1" class="village-zoom-label">1×</label>
\t\t\t<label for="vz2" class="village-zoom-label">1.5×</label>
\t\t\t<label for="vz3" class="village-zoom-label">2×</label>
\t\t\t<label for="vz4" class="village-zoom-label">3×</label>
\t\t</div>

\t\t<div class="village-viewport">
\t\t\t<div class="village-canvas">
\t\t\t\t<svg class="village" role="group" aria-labelledby="scene-friends-title" viewBox="0 0 1200 800">
\t\t\t\t\t<title id="scene-friends-title">友邻村落 — A hillside village of {len(friends)} friends</title>

{BACKGROUND_SVG}

\t\t\t\t\t<!-- Houses -->
{houses_svg}

{back_svg}
\t\t\t\t</svg>
\t\t\t</div>
\t\t</div>

\t\t<nav id="scene-nav-friends" class="scene-nav" aria-label="友邻导航">
\t\t\t<ul class="scene-nav__list">
{nav_list}
\t\t\t\t<li><a class="scene-nav__link" href="neighbors.html">邻邦 — 返回邻邦</a></li>
\t\t\t</ul>
\t\t</nav>

\t\t<noscript>
\t\t\t<p style="padding:var(--space-md);color:var(--text-secondary);">
\t\t\t\t以下是所有友邻链接:
\t\t\t</p>
\t\t\t<ul style="padding:var(--space-md);list-style:disc;color:var(--text-secondary);">
{noscript_list}
\t\t\t</ul>
\t\t</noscript>
\t</main>

</body>
</html>
'''


# ────────────────────────────────────────────────────────────────
# Main
# ────────────────────────────────────────────────────────────────

def main():
    if len(sys.argv) < 2:
        print("Usage: python build-village.py <friends.opml>", file=sys.stderr)
        sys.exit(1)

    opml_path = sys.argv[1]
    friends = parse_opml(opml_path)

    if not friends:
        print("No friends found in OPML file.", file=sys.stderr)
        sys.exit(1)

    print(f"Found {len(friends)} friends, generating village...", file=sys.stderr)

    html = build_html(friends)

    with open("friends.html", "w", encoding="utf-8") as f:
        f.write(html)

    print("✓ friends.html generated.", file=sys.stderr)


if __name__ == "__main__":
    main()