Canvas now 16:9 with circle filling height. Radial fade gradient uses userSpaceOnUse for true circular shape. Hop 15+ folded into final ring. Margins trimmed to fit content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
359 lines
13 KiB
Python
359 lines
13 KiB
Python
#!/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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="{width}" height="{height}">')
|
|
|
|
# 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('<defs>')
|
|
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'<radialGradient id="fadeEdge" cx="{cx}" cy="{cy}" r="{grad_r}" gradientUnits="userSpaceOnUse">')
|
|
svg.append(f' <stop offset="0%" stop-color="#0a0a1a" stop-opacity="0"/>')
|
|
svg.append(f' <stop offset="{start_pct:.1f}%" stop-color="#0a0a1a" stop-opacity="0"/>')
|
|
svg.append(f' <stop offset="{end_pct:.1f}%" stop-color="#0a0a1a" stop-opacity="1"/>')
|
|
svg.append(f' <stop offset="100%" stop-color="#0a0a1a" stop-opacity="1"/>')
|
|
svg.append('</radialGradient>')
|
|
svg.append('</defs>')
|
|
|
|
svg.append(f'<rect width="{width}" height="{height}" fill="#0a0a1a"/>')
|
|
|
|
# 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'<path d="M {x1_out:.0f} {y1_out:.0f} A {r_outer} {r_outer} 0 0 1 {x2_out:.0f} {y2_out:.0f} '
|
|
f'L {x1_in:.0f} {y1_in:.0f} A {r_inner} {r_inner} 0 0 0 {x2_in:.0f} {y2_in:.0f} Z" '
|
|
f'fill="{color}" opacity="{opacity}"/>')
|
|
|
|
# Fade overlay on outer edge
|
|
svg.append(f'<rect width="{width}" height="{height}" fill="url(#fadeEdge)"/>')
|
|
|
|
# Cardinal labels — placed in the fade zone
|
|
label_r = ring_radius[FOLD_HOP] + FOLD_BAND + FADE_BAND * 0.4
|
|
svg.append(f'<text x="{cx}" y="{cy - label_r}" fill="#4488FF" font-size="18" font-family="monospace" text-anchor="middle" opacity="0.4">NORTH REACH</text>')
|
|
svg.append(f'<text x="{cx}" y="{cy + label_r + 18}" fill="#FF4444" font-size="18" font-family="monospace" text-anchor="middle" opacity="0.4">SOUTH REACH</text>')
|
|
svg.append(f'<text x="{cx + label_r + 10}" y="{cy + 6}" fill="#44DD44" font-size="18" font-family="monospace" opacity="0.4">EAST REACH</text>')
|
|
svg.append(f'<text x="{cx - label_r - 10}" y="{cy + 6}" fill="#FF8800" font-size="18" font-family="monospace" text-anchor="end" opacity="0.4">WEST REACH</text>')
|
|
|
|
# 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'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="#222240" stroke-width="0.5"/>')
|
|
|
|
# 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'<circle cx="{x:.1f}" cy="{y:.1f}" r="{nr}" fill="{color}" opacity="{opacity}"/>')
|
|
|
|
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'<text x="{x+nr+2:.1f}" y="{y+3:.1f}" fill="{color}" font-size="{fs}" '
|
|
f'font-family="monospace" opacity="{min(opacity+0.2,1):.2f}">{name}</text>')
|
|
|
|
# Legend
|
|
lx, ly = 30, 40
|
|
svg.append(f'<text x="{lx}" y="{ly-12}" fill="#999" font-size="16" font-family="monospace" font-weight="bold">THE SETTLED REACH</text>')
|
|
ly += 8
|
|
for sector, color in node_colors.items():
|
|
svg.append(f'<circle cx="{lx+5}" cy="{ly}" r="5" fill="{color}"/>')
|
|
svg.append(f'<text x="{lx+16}" y="{ly+4}" fill="{color}" font-size="10" font-family="monospace">{sector}</text>')
|
|
ly += 20
|
|
ly += 10
|
|
svg.append(f'<text x="{lx}" y="{ly}" fill="#555" font-size="9" font-family="monospace">rings = hop distance from Gateway (center)</text>')
|
|
ly += 14
|
|
svg.append(f'<text x="{lx}" y="{ly}" fill="#444" font-size="8" font-family="monospace">outer ring = hop 15+ (deep frontier fade)</text>')
|
|
ly += 14
|
|
svg.append(f'<text x="{lx}" y="{ly}" fill="#444" font-size="8" font-family="monospace">opacity = settlement wave (bright=ancient, dim=recent)</text>')
|
|
|
|
svg.append('</svg>')
|
|
|
|
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()}')
|