#!/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 --- 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 # Canvas: 16:9, circle fills the height _max_r = RING_STEP * FOLD_HOP + RING_BASE + NORMAL_BAND + FADE_BAND + 60 height = int(_max_r * 2 + 120) width = int(height * 16 / 9) cx, cy = width / 2, height / 2 # 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 # Use userSpaceOnUse so the gradient is a true circle regardless of canvas aspect ratio svg.append('') fade_start = ring_radius[FOLD_HOP] + FOLD_BAND fade_end = fade_start + FADE_BAND # Gradient radius must cover corners of canvas grad_r = max(width, height) start_pct = fade_start / grad_r * 100 end_pct = fade_end / grad_r * 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)') # Also render PNG import shutil, subprocess png_path = output_path.replace('.svg', '.png') if shutil.which('magick'): cmd = ['magick', '-density', '150', '-background', '#0a0a1a', output_path, png_path] elif shutil.which('convert'): cmd = ['convert', '-density', '150', '-background', '#0a0a1a', output_path, png_path] elif shutil.which('rsvg-convert'): cmd = ['rsvg-convert', '-d', '150', '-p', '150', '-b', '#0a0a1a', '-o', png_path, output_path] else: cmd = None print('No SVG-to-PNG converter found (tried magick, convert, rsvg-convert). PNG not generated.') if cmd: result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: print(f'PNG written to {png_path}') else: print(f'PNG conversion failed: {result.stderr.strip()}')