From f9373be3bab89299b48fbba13220b41b8d19827c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 15 Mar 2026 11:05:34 +0100 Subject: [PATCH] feat(tooling): add star map SVG generator Reproducible concentric-ring star map: Gateway at center, hop rings radiating outward, sectors in cardinal wedges (N/E/S/W), alternating band colors per sector, hop 15+ folded into final ring with fade-out zone. Sol pinned due west. Run: python3 tooling/generate-star-map-svg.py Output: docs/diagrams/design/star-map-concentric.svg Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/diagrams/design/star-map-concentric.svg | 778 +++++++++++++++++++ tooling/generate-star-map-svg.py | 333 ++++++++ 2 files changed, 1111 insertions(+) create mode 100644 docs/diagrams/design/star-map-concentric.svg create mode 100644 tooling/generate-star-map-svg.py diff --git a/docs/diagrams/design/star-map-concentric.svg b/docs/diagrams/design/star-map-concentric.svg new file mode 100644 index 000000000..e4581601c --- /dev/null +++ b/docs/diagrams/design/star-map-concentric.svg @@ -0,0 +1,778 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +NORTH REACH +SOUTH REACH +EAST REACH +WEST REACH + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Sol + +Tau Ceti + + + + + + + +GJ 1156 + +Wolf 359 + +ACB + + + +GJ 393 + +Ran + +Guniibuu + + + + + + + + + + + + +Tessera + + + + + + + + + + +Keid + + + + + + + + + + +Parallax + + + + + +GJ 357 + + +GJ 34B + + + + + + + +GJ 1111 + + + + + + +Sirius + + + + + + + +Lacaille + + + +EZ Aqr + + + + + + + + + + + + + +Altair + +Struve + + +Proxima Centauri + + + + + + + + + + + + + +Achird + + + + +Lalande 21185 + +Nova Roma + + +GJ 388 + +Meridian + + + + +Ross 128 + +Barnard's Star + +Cygni B + + + + + + + + + +GJ 892 + + + + + + +GJ 54.1 + + + + +Renaissance + + + + + + + + + + + + + + + + + + + + + + +Ross 154 + + + +Cairn + + + + +Vargr + + + + + + + + + + + + + +Arbour + + + + + +GJ 664 + + + + + + + + + + + + + + + + + + + + + + + + + + +GJ 566B + +Groombridge + + + + + + + + + + + + +GJ 832 + + + + + + + + + + +Bastion + +GJ 566A + + + +GJ 661A + + + + + + + + + +GJ 4274 + + + + + +Kensho + + + + + + + + + + +Aurelius + + +1221-DXP9 + +82 G. Eri + + + + + + + +GJ 780 + + + + + + +Lu Ban + + + + + + + + + + + + + + + + + + +Prometheus + + + +THE SETTLED REACH + +core + +north_reach + +south_reach + +east_reach + +west_reach + +deep_frontier +rings = hop distance from Gateway (center) +outer ring = hop 15+ (deep frontier fade) +opacity = settlement wave (bright=ancient, dim=recent) + \ No newline at end of file diff --git a/tooling/generate-star-map-svg.py b/tooling/generate-star-map-svg.py new file mode 100644 index 000000000..5d731ee12 --- /dev/null +++ b/tooling/generate-star-map-svg.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Generate concentric-ring star map SVG from star-map.json and systems.db. + +Usage: python3 tooling/generate-star-map-svg.py [output.svg] +Default output: docs/diagrams/design/star-map-concentric.svg +""" + +import json, sqlite3, math, sys, os + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(SCRIPT_DIR) + +STARMAP_PATH = os.path.join(ROOT, 'docs/design/star-map.json') +DB_PATH = os.path.join(ROOT, 'server/data/systems.db') +DEFAULT_OUTPUT = os.path.join(ROOT, 'docs/diagrams/design/star-map-concentric.svg') + +output_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_OUTPUT + +# --- Load data --- +with open(STARMAP_PATH) as f: + starmap = json.load(f) + +db = sqlite3.connect(DB_PATH) +sectors = dict(db.execute('SELECT system_id, geographic_sector FROM star_systems').fetchall()) +names_raw = dict(db.execute('SELECT system_id, system_name FROM star_systems').fetchall()) +waves = dict(db.execute('SELECT s.system_id, h.settlement_wave FROM star_systems s JOIN system_history h ON s.system_id = h.system_id').fetchall()) +hops_db = dict(db.execute('SELECT system_id, hop_distance_from_gateway FROM system_gates').fetchall()) + +names = {} +for sid, n in names_raw.items(): + names[sid] = n if n and 'PLACEHOLDER' not in n else sid + +adj = {} +for n in starmap['nodes']: + adj[n['system_id']] = [] +for e in starmap['edges']: + adj.setdefault(e[0], []).append(e[1]) + adj.setdefault(e[1], []).append(e[0]) + +hop_groups = {} +for n in starmap['nodes']: + sid = n['system_id'] + h = hops_db.get(sid, 99) + hop_groups.setdefault(h, []).append(sid) + +apertures = {} +for n in starmap['nodes']: + apertures[n['system_id']] = n.get('aperture_count', 1) + +max_hop = max(hop_groups.keys()) + +# Collapse hops 15+ into a single ring +FOLD_HOP = 15 +folded_nodes = [] +for h in list(hop_groups.keys()): + if h >= FOLD_HOP: + folded_nodes.extend(hop_groups.pop(h)) +if folded_nodes: + hop_groups[FOLD_HOP] = list(dict.fromkeys( + hop_groups.get(FOLD_HOP, []) + folded_nodes + )) +max_display_hop = FOLD_HOP + +# --- Layout parameters --- +width, height = 3200, 3200 +cx, cy = width / 2, height / 2 + +# Ring radii — hop 15+ gets a wider band +RING_STEP = 55 +RING_BASE = 50 +ring_radius = {} +for h in range(max_display_hop + 1): + if h == 0: + ring_radius[h] = 0 + else: + ring_radius[h] = RING_STEP * h + RING_BASE + +# Band half-widths (how thick each ring band is) +NORMAL_BAND = 27 +FOLD_BAND = NORMAL_BAND # same width as normal rings +FADE_BAND = NORMAL_BAND * 4 # empty fade zone beyond the last ring + +# 90-degree wedges, no gaps, centered on cardinals +sector_wedge_center = { + 'north_reach': -math.pi / 2, + 'east_reach': 0, + 'south_reach': math.pi / 2, + 'west_reach': math.pi, +} +wedge_half = math.pi / 4 # 45 degrees each side = 90 total + +# Sol override: force to due west (angle = pi) +SOL_ID = 'GJ 0' + +# --- Sector colors --- +node_colors = { + 'core': '#FFD700', + 'north_reach': '#4488FF', + 'south_reach': '#FF4444', + 'east_reach': '#44DD44', + 'west_reach': '#FF8800', + 'deep_frontier': '#AA44FF' +} + +# Ring band colors per sector (two alternating shades) +band_colors = { + 'north_reach': ('#1a2244', '#1a2a55'), + 'east_reach': ('#1a2a1a', '#1a331a'), + 'south_reach': ('#2a1a1a', '#331a1a'), + 'west_reach': ('#2a2010', '#332810'), +} + +wave_opacity = { + 'origin': 1.0, 'wave_1': 1.0, 'wave_2': 0.85, 'wave_3': 0.7, + 'wave_4': 0.55, 'wave_5': 0.4, 'unsettled': 0.25 +} + +# --- Placement --- +positions = {} +for sid in hop_groups.get(0, []): + positions[sid] = (cx, cy) + +def parent_angle(sid, h): + # For folded nodes, parents can be at any hop < FOLD_HOP + threshold = min(h, FOLD_HOP) + parents = [nb for nb in adj.get(sid, []) if hops_db.get(nb, 99) < threshold and nb in positions] + if parents: + avg_x = sum(positions[p][0] for p in parents) / len(parents) + avg_y = sum(positions[p][1] for p in parents) / len(parents) + return math.atan2(avg_y - cy, avg_x - cx) + return 0 + +for h in range(1, max_display_hop + 1): + nodes = hop_groups.get(h, []) + if not nodes: + continue + r = ring_radius[h] + + # For the fold ring, spiral nodes counter-clockwise by their actual hop + # Nodes at higher real hops get placed slightly further out + is_fold = (h == FOLD_HOP) + + # Bucket by sector + buckets = {} + for sid in nodes: + s = sectors.get(sid, 'deep_frontier') + buckets.setdefault(s, []).append(sid) + + placed = [] + + # Cardinal sectors: strict wedge placement + for sector_name, center_angle in sector_wedge_center.items(): + bucket = buckets.get(sector_name, []) + if not bucket: + continue + if is_fold: + # Sort by actual hop distance (higher = further in spiral), then parent angle + bucket.sort(key=lambda s: (hops_db.get(s, 99), parent_angle(s, h))) + else: + bucket.sort(key=lambda s: parent_angle(s, h)) + n = len(bucket) + lo = center_angle - wedge_half + hi = center_angle + wedge_half + for i, sid in enumerate(bucket): + angle = lo + (hi - lo) * (i + 0.5) / n + placed.append((angle, sid)) + + # Core: place near parents + for sid in buckets.get('core', []): + pa = parent_angle(sid, h) + placed.append((pa, sid)) + + # Deep frontier: assign to nearest cardinal wedge + for sid in buckets.get('deep_frontier', []): + pa = parent_angle(sid, h) + best_sector = min(sector_wedge_center.items(), + key=lambda sc: min(abs(pa - sc[1]), + abs(pa - sc[1] + 2*math.pi), + abs(pa - sc[1] - 2*math.pi))) + center = best_sector[1] + lo = center - wedge_half + hi = center + wedge_half + clamped = max(lo + 0.02, min(hi - 0.02, pa)) + placed.append((clamped, sid)) + + # Sol override + for i, (angle, sid) in enumerate(placed): + if sid == SOL_ID: + placed[i] = (math.pi, sid) + break + + # Sort and resolve overlaps + placed.sort() + min_gap = 2 * math.pi / (len(placed) + 1) * 0.7 + for _ in range(30): + moved = False + for i in range(len(placed)): + j = (i + 1) % len(placed) + a1 = placed[i][0] + a2 = placed[j][0] + diff = a2 - a1 + if j == 0: + diff += 2 * math.pi + if diff < min_gap: + nudge = (min_gap - diff) / 2 + placed[i] = (a1 - nudge, placed[i][1]) + placed[j] = (a2 + nudge, placed[j][1]) + moved = True + if not moved: + break + + for angle, sid in placed: + if is_fold: + # Subtle counter-clockwise spiral by real hop distance + real_hop = hops_db.get(sid, FOLD_HOP) + hop_frac = (real_hop - FOLD_HOP) / max(max_hop - FOLD_HOP, 1) + spiral_offset = -hop_frac * 0.12 + x = cx + r * math.cos(angle + spiral_offset) + y = cy + r * math.sin(angle + spiral_offset) + else: + x = cx + r * math.cos(angle) + y = cy + r * math.sin(angle) + positions[sid] = (x, y) + +# --- SVG rendering --- +svg = [] +svg.append(f'') + +# Defs for the fade gradient beyond the last ring +svg.append('') +fade_start = ring_radius[FOLD_HOP] + FOLD_BAND # outer edge of last ring +fade_end = fade_start + FADE_BAND # end of fade zone +start_pct = fade_start / (width / 2) * 100 +end_pct = min(fade_end / (width / 2) * 100, 100) +svg.append(f'') +svg.append(f' ') +svg.append(f' ') +svg.append(f' ') +svg.append(f' ') +svg.append('') +svg.append('') + +svg.append(f'') + +# Alternating sector band rings +for sector_name, center_angle in sector_wedge_center.items(): + lo = center_angle - wedge_half + hi = center_angle + wedge_half + c1, c2 = band_colors[sector_name] + + for h in range(1, max_display_hop + 1): + band = NORMAL_BAND + r_inner = ring_radius[h] - band + r_outer = ring_radius[h] + band + if r_inner < 0: + r_inner = 0 + color = c1 if h % 2 == 0 else c2 + opacity = 0.35 + + x1_out = cx + r_outer * math.cos(lo) + y1_out = cy + r_outer * math.sin(lo) + x2_out = cx + r_outer * math.cos(hi) + y2_out = cy + r_outer * math.sin(hi) + x1_in = cx + r_inner * math.cos(hi) + y1_in = cy + r_inner * math.sin(hi) + x2_in = cx + r_inner * math.cos(lo) + y2_in = cy + r_inner * math.sin(lo) + + svg.append(f'') + +# Fade overlay on outer edge +svg.append(f'') + +# Cardinal labels — placed in the fade zone +label_r = ring_radius[FOLD_HOP] + FOLD_BAND + FADE_BAND * 0.4 +svg.append(f'NORTH REACH') +svg.append(f'SOUTH REACH') +svg.append(f'EAST REACH') +svg.append(f'WEST REACH') + +# Edges +for e in starmap['edges']: + if e[0] in positions and e[1] in positions: + x1, y1 = positions[e[0]] + x2, y2 = positions[e[1]] + svg.append(f'') + +# Nodes +for n in starmap['nodes']: + sid = n['system_id'] + if sid not in positions: + continue + x, y = positions[sid] + sector = sectors.get(sid, 'deep_frontier') + color = node_colors.get(sector, '#AA44FF') + wave = waves.get(sid, 'wave_3') + hop = hops_db.get(sid, 99) + opacity = wave_opacity.get(wave, 0.5) + + nr = 8 if sid == 'GJ 71' else (5 if sector == 'core' else (3 if hop <= 5 else 2)) + svg.append(f'') + + name = names.get(sid, sid) + ap = apertures.get(sid, 1) + if sector == 'core' or ap >= 5 or hop <= 3: + fs = 9 if sector == 'core' else 7 + svg.append(f'{name}') + +# Legend +lx, ly = 30, 40 +svg.append(f'THE SETTLED REACH') +ly += 8 +for sector, color in node_colors.items(): + svg.append(f'') + svg.append(f'{sector}') + ly += 20 +ly += 10 +svg.append(f'rings = hop distance from Gateway (center)') +ly += 14 +svg.append(f'outer ring = hop 15+ (deep frontier fade)') +ly += 14 +svg.append(f'opacity = settlement wave (bright=ancient, dim=recent)') + +svg.append('') + +with open(output_path, 'w') as f: + f.write('\n'.join(svg)) +print(f'Written to {output_path}') +print(f'{len(positions)} nodes, {len(starmap["edges"])} edges') +print(f'Hops 0-{FOLD_HOP-1} individual, {FOLD_HOP}+ folded ({len(hop_groups.get(FOLD_HOP, []))} nodes)')