From 5dd1685be804caca4cb66411a583ec2dff256513 Mon Sep 17 00:00:00 2001 From: Cytrogen Date: Sun, 8 Mar 2026 22:40:25 -0400 Subject: [PATCH] =?UTF-8?q?=E5=8F=8B=E9=82=BB=E6=9D=91=E8=90=BD=EF=BC=9A?= =?UTF-8?q?=E7=BA=AF=20CSS=20=E7=BC=A9=E6=94=BE=20+=20=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E5=B1=B1=E4=B8=98=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-village.py | 481 ++++++++++++++++++++ friends.html | 1099 +++++++++++++++++++++++++++++++++++++++++++++- friends.opml | 96 ++++ realm.css | 87 ++++ 4 files changed, 1752 insertions(+), 11 deletions(-) create mode 100644 build-village.py create mode 100644 friends.opml diff --git a/build-village.py b/build-village.py new file mode 100644 index 0000000000000000000000000000000000000000..d37f0d26e9822cf28a59dca58a79f54747b9391e --- /dev/null +++ b/build-village.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +""" +build-village.py — Generate friends.html from a FreshRSS OPML export. + +Usage: + python build-village.py friends.opml + +Reads 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 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 . +# 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'''\ + + + + +''' + + +def house_flat(name: str) -> str: + """Type B: Flat-roof modernist block.""" + return f'''\ + + + + +''' + + +def house_squat(name: str) -> str: + """Type C: Wide squat bungalow.""" + return f'''\ + + + + +''' + + +def house_ell(name: str) -> str: + """Type D: L-shaped house with extension.""" + return f'''\ + + + + + + +''' + + +def house_tower(name: str) -> str: + """Type E: Tall narrow tower.""" + return f'''\ + + + + +''' + + +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 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + safe_url = friend.url.replace("&", "&").replace('"', """) + + return f'''\t\t\t\t +\t\t\t\t\t{safe_name} +\t\t\t\t\t +{_indent(house_svg, 6)} +\t\t\t\t\t +\t\t\t\t\t{safe_name} +\t\t\t\t''' + + +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 +\t\t\t\t + +\t\t\t\t +\t\t\t\t + +\t\t\t\t +\t\t\t\t