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 @@
+
\ 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'')
+
+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)')