refactor(tooling): T-1293 — atlas map, and the generator that must not run
The star-map family was the last unported part of the tree, and it never had a ticket. Two of its scripts become `reach atlas map` verbs, nested under atlas like planet (D-243: the Reach map is the ladder's top rung): - `reach atlas map data [--check]` regenerates client/data/star_map_data.json. The regenerated file differs by one line: `_meta.note`, which named the old script's path. - `reach atlas map svg` renders the concentric SVG (+ PNG), byte-identical to the old script's output on the same data. make check-star-map and star-map-data stay as one-line delegates, because pre-pr-client and pre-pr-validate depend on check-star-map. generate-star-map.py, its seed, sculpt-star-map.py and tune-star-map-topology.py are archived, not ported. The generator rewrites docs/design/star-map.json unconditionally from an S-NNN-keyed seed, so re-running it would erase the GJ migration and every hand edit since; sculpt and tune only understand S-NNN edges. .claude/rules/diagrams.md was telling agents to "edit the generator and re-run it". It now distinguishes the live concentric render, the seven frozen S-keyed sector .d2 files (T-1294), and the two SVGs that never had a generator in the repo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""`atlas map` — the top rung of the spatial ladder: the Reach map (D-191).
|
||||
|
||||
Renders of docs/design/star-map.json, which is canonical and GJ-keyed. The
|
||||
tools that generated and sculpted it are provenance in
|
||||
tooling/archive/map-bootstrap/ — see router.py for why they must not run.
|
||||
"""
|
||||
Executable
+345
@@ -0,0 +1,345 @@
|
||||
"""Generate client/data/star_map_data.json from star-map.json + systems.db + wiki.
|
||||
|
||||
reach atlas map data # regenerate
|
||||
reach atlas map data --check # exit 1 if the committed JSON is stale
|
||||
|
||||
Formerly tooling/generate-star-map-data.py (T-1293). The output is unchanged
|
||||
except `_meta.note`, which named the old script path and now names the verb.
|
||||
|
||||
Sources:
|
||||
docs/design/star-map.json — graph topology (nodes + edges)
|
||||
server/data/systems.db — proper names, geographic sectors, bodies, GDP tier
|
||||
wiki/star-systems/ — star type, GTTR excerpt (bodies/population from systems.db)
|
||||
|
||||
Output:
|
||||
client/data/star_map_data.json — self-contained client data for the star map UI
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
# The repo root, asked of git. The original derived it from this file's own
|
||||
# location, which is right only while the file sits one level under the root.
|
||||
_PROJECT_ROOT = str(config.repo_root())
|
||||
|
||||
STAR_MAP_PATH = os.path.join(_PROJECT_ROOT, "docs", "design", "star-map.json")
|
||||
SYSTEMS_DB_PATH = os.path.join(_PROJECT_ROOT, "server", "data", "systems.db")
|
||||
WIKI_PATH = os.path.join(_PROJECT_ROOT, "wiki", "star-systems")
|
||||
OUTPUT_PATH = os.path.join(_PROJECT_ROOT, "client", "data", "star_map_data.json")
|
||||
|
||||
|
||||
def system_id_to_wiki_slug(system_id: str) -> str:
|
||||
"""Convert system_id to wiki folder slug. 'GJ 71' → 'GJ-71'."""
|
||||
return system_id.replace(" ", "-")
|
||||
|
||||
|
||||
def parse_wiki_index(system_id: str) -> dict:
|
||||
"""Extract star type from index.md.
|
||||
|
||||
Bodies and population are authoritative from systems.db — not read from wiki.
|
||||
Returns dict with key: star_type (string, may be empty).
|
||||
"""
|
||||
slug = system_id_to_wiki_slug(system_id)
|
||||
path = os.path.join(WIKI_PATH, slug, "index.md")
|
||||
result = {"star_type": ""}
|
||||
if not os.path.exists(path):
|
||||
return result
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Star row: | **Star** | G8V · 11.9 ly |
|
||||
m = re.search(r"\|\s*\*\*Star\*\*\s*\|\s*([^|]+?)\s*\|", content)
|
||||
if m:
|
||||
raw = m.group(1).strip()
|
||||
# Extract spectral class — everything before " ·" or end of string
|
||||
result["star_type"] = raw.split("·")[0].strip()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_gttr_excerpt(system_id: str) -> str:
|
||||
"""Extract the first body paragraph from gttr.md as the GTTR excerpt."""
|
||||
slug = system_id_to_wiki_slug(system_id)
|
||||
path = os.path.join(WIKI_PATH, slug, "gttr.md")
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Skip the H1 heading line, then find the first non-empty paragraph
|
||||
in_content = False
|
||||
paragraph_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not in_content:
|
||||
# Skip heading and blank lines at start
|
||||
if stripped.startswith("# "):
|
||||
in_content = True
|
||||
continue
|
||||
if not stripped:
|
||||
# Blank line ends the paragraph if we've collected lines
|
||||
if paragraph_lines:
|
||||
break
|
||||
else:
|
||||
if not stripped.startswith("#"):
|
||||
paragraph_lines.append(stripped)
|
||||
|
||||
return " ".join(paragraph_lines)
|
||||
|
||||
|
||||
GDP_PER_CAPITA: dict = {
|
||||
5: 75_000,
|
||||
4: 40_000,
|
||||
3: 15_000,
|
||||
2: 5_000,
|
||||
1: 2_000,
|
||||
0: 500,
|
||||
}
|
||||
|
||||
|
||||
def infer_tier_from_population(pop: int) -> int:
|
||||
"""Infer an economic tier from total population when no explicit tier is set."""
|
||||
if pop >= 5_000_000_000:
|
||||
return 5
|
||||
if pop >= 1_000_000_000:
|
||||
return 4
|
||||
if pop >= 200_000_000:
|
||||
return 3
|
||||
if pop >= 10_000_000:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def compute_gdp(total_pop: int, economic_tier: int | None) -> str:
|
||||
"""Return a formatted GDP string in Tractus, or empty string if no population."""
|
||||
if total_pop == 0:
|
||||
return ""
|
||||
tier = economic_tier if economic_tier is not None else infer_tier_from_population(total_pop)
|
||||
per_cap = GDP_PER_CAPITA.get(tier, GDP_PER_CAPITA[1])
|
||||
value = total_pop * per_cap
|
||||
if value < 1_000_000_000:
|
||||
return f"{value / 1_000_000:.1f} MTr"
|
||||
if value < 1_000_000_000_000:
|
||||
return f"{value / 1_000_000_000:.1f} BTr"
|
||||
if value < 1_000_000_000_000_000:
|
||||
return f"{value / 1_000_000_000_000:.1f} TTr"
|
||||
return f"{value / 1_000_000_000_000_000:.1f} QTr"
|
||||
|
||||
|
||||
def build_adjacency(edges: list) -> dict:
|
||||
"""Build a map from system_id to list of adjacent system_ids from edges."""
|
||||
adj: dict = {}
|
||||
for edge in edges:
|
||||
if len(edge) < 2:
|
||||
continue
|
||||
a, b = edge[0], edge[1]
|
||||
adj.setdefault(a, [])
|
||||
adj.setdefault(b, [])
|
||||
if b not in adj[a]:
|
||||
adj[a].append(b)
|
||||
if a not in adj[b]:
|
||||
adj[b].append(a)
|
||||
return adj
|
||||
|
||||
|
||||
def generate() -> dict:
|
||||
"""Generate the enriched star map data dict."""
|
||||
if not os.path.exists(STAR_MAP_PATH):
|
||||
raise ReachError(f"star-map.json not found at {STAR_MAP_PATH}", fix="git restore docs/design/star-map.json")
|
||||
if not os.path.exists(SYSTEMS_DB_PATH):
|
||||
raise ReachError(f"systems.db not found at {SYSTEMS_DB_PATH}", fix="make regen-db")
|
||||
|
||||
with open(STAR_MAP_PATH) as f:
|
||||
star_map = json.load(f)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(SYSTEMS_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT system_id, proper_name, geographic_sector, geographic_band, currency_zone "
|
||||
"FROM star_systems"
|
||||
)
|
||||
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
|
||||
# Aggregate body data per system from the bodies table
|
||||
cur.execute("""
|
||||
SELECT system_id,
|
||||
SUM(CASE WHEN atmosphere IN ('breathable','standard') AND body_type IN ('planet','moon') THEN 1 ELSE 0 END) AS habitable,
|
||||
SUM(CASE WHEN inhabited = 1 THEN 1 ELSE 0 END) AS inhabited,
|
||||
SUM(CASE WHEN inhabited = 1 THEN COALESCE(population, 0) ELSE 0 END) AS total_pop
|
||||
FROM bodies
|
||||
GROUP BY system_id
|
||||
""")
|
||||
body_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
|
||||
# Also sum station populations
|
||||
cur.execute("""
|
||||
SELECT system_id,
|
||||
SUM(COALESCE(population, 0)) AS station_pop
|
||||
FROM stations
|
||||
GROUP BY system_id
|
||||
""")
|
||||
station_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
|
||||
# Economic tier for GDP calculation
|
||||
cur.execute("SELECT system_id, economic_tier FROM system_economy")
|
||||
econ_tiers = {row["system_id"]: row["economic_tier"] for row in cur.fetchall()}
|
||||
|
||||
# Per-system body list for atlas orbital diagram (D-191 §6)
|
||||
cur.execute("""
|
||||
SELECT system_id, body_id, parent_body_id, orbit_index, body_type,
|
||||
proper_name, atmosphere, inhabited, population,
|
||||
terrain_reference, mass_class, body_radius_km
|
||||
FROM bodies
|
||||
ORDER BY system_id, orbit_index
|
||||
""")
|
||||
orbit_bodies_by_system: dict = {}
|
||||
for row in cur.fetchall():
|
||||
sid_ = row["system_id"]
|
||||
orbit_bodies_by_system.setdefault(sid_, []).append({
|
||||
"body_id": row["body_id"],
|
||||
"parent_body_id": row["parent_body_id"],
|
||||
"orbit_index": row["orbit_index"],
|
||||
"body_type": row["body_type"],
|
||||
"proper_name": row["proper_name"],
|
||||
"atmosphere": row["atmosphere"],
|
||||
"inhabited": bool(row["inhabited"]),
|
||||
"population": int(row["population"] or 0),
|
||||
"terrain_reference": row["terrain_reference"],
|
||||
"mass_class": row["mass_class"],
|
||||
"body_radius_km": float(row["body_radius_km"] or 0),
|
||||
})
|
||||
|
||||
# Per-system station list for atlas orbital diagram (D-191 §6)
|
||||
cur.execute("""
|
||||
SELECT system_id, station_id, orbits_body_id, station_type,
|
||||
proper_name, population, governance_type, economic_role
|
||||
FROM stations
|
||||
ORDER BY system_id
|
||||
""")
|
||||
stations_by_system: dict = {}
|
||||
for row in cur.fetchall():
|
||||
sid_ = row["system_id"]
|
||||
stations_by_system.setdefault(sid_, []).append({
|
||||
"station_id": row["station_id"],
|
||||
"orbits_body_id": row["orbits_body_id"],
|
||||
"station_type": row["station_type"],
|
||||
"proper_name": row["proper_name"],
|
||||
"population": int(row["population"] or 0),
|
||||
"governance_type": row["governance_type"],
|
||||
"economic_role": row["economic_role"],
|
||||
})
|
||||
except sqlite3.Error as e:
|
||||
raise ReachError(f"systems.db query failed: {e}", fix="make regen-db — the schema may be behind") from e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
adjacency = build_adjacency(star_map["edges"])
|
||||
|
||||
nodes = []
|
||||
wiki_hits = 0
|
||||
gttr_hits = 0
|
||||
for n in star_map["nodes"]:
|
||||
sid = n["system_id"]
|
||||
db = db_lookup.get(sid, {})
|
||||
wiki = parse_wiki_index(sid)
|
||||
gttr = parse_gttr_excerpt(sid)
|
||||
if wiki["star_type"]:
|
||||
wiki_hits += 1
|
||||
if gttr:
|
||||
gttr_hits += 1
|
||||
|
||||
entry = {
|
||||
"system_id": sid,
|
||||
"proper_name": db.get("proper_name", ""),
|
||||
"geographic_sector": db.get("geographic_sector", "unknown"),
|
||||
"geographic_band": db.get("geographic_band", ""),
|
||||
"gate_topology": n["gate_topology"],
|
||||
"aperture_count": n["aperture_count"],
|
||||
"gate_connections": n["gate_connections"],
|
||||
"hop_distance": n["hop_distance_from_gateway"],
|
||||
"adjacent_systems": adjacency.get(sid, []),
|
||||
}
|
||||
if wiki["star_type"]:
|
||||
entry["star_type"] = wiki["star_type"]
|
||||
|
||||
# Currency zone from star_systems
|
||||
cz = db.get("currency_zone", "")
|
||||
if cz:
|
||||
entry["currency_zone"] = cz
|
||||
|
||||
# Bodies + population from systems.db (authoritative, not wiki)
|
||||
bs = body_stats.get(sid, {})
|
||||
ss = station_stats.get(sid, {})
|
||||
hab = int(bs.get("habitable", 0))
|
||||
inh = int(bs.get("inhabited", 0))
|
||||
body_pop = int(bs.get("total_pop", 0))
|
||||
sta_pop = int(ss.get("station_pop", 0))
|
||||
total_pop = body_pop + sta_pop
|
||||
|
||||
entry["bodies"] = "%d habitable · %d inhabited" % (hab, inh)
|
||||
entry["population"] = "{:,}".format(total_pop)
|
||||
gdp_str = compute_gdp(total_pop, econ_tiers.get(sid))
|
||||
if gdp_str:
|
||||
entry["gdp"] = gdp_str
|
||||
if gttr:
|
||||
entry["gttr_excerpt"] = gttr
|
||||
if n.get("is_gateway"):
|
||||
entry["is_gateway"] = True
|
||||
|
||||
# Atlas orbital diagram data (D-191 §6) — per-body and per-station arrays
|
||||
entry["orbit_bodies"] = orbit_bodies_by_system.get(sid, [])
|
||||
entry["stations"] = stations_by_system.get(sid, [])
|
||||
|
||||
nodes.append(entry)
|
||||
|
||||
nodes.sort(key=lambda x: (x["hop_distance"], x["system_id"]))
|
||||
|
||||
console.event(f"Wiki index data: {wiki_hits}/{len(nodes)} systems")
|
||||
console.event(f"GTTR excerpts: {gttr_hits}/{len(nodes)} systems")
|
||||
|
||||
return {
|
||||
"_meta": {
|
||||
"generated_from": "star-map.json + systems.db + wiki/star-systems",
|
||||
"system_count": len(nodes),
|
||||
"edge_count": len(star_map["edges"]),
|
||||
"note": "Client-side star map + atlas orbital data. Regenerate with: reach atlas map data",
|
||||
},
|
||||
"nodes": nodes,
|
||||
"edges": star_map["edges"],
|
||||
}
|
||||
|
||||
|
||||
def render(data: dict) -> str:
|
||||
"""The exact bytes written — shared by write and check so they cannot disagree."""
|
||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def write_output(data: dict, path: str) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(render(data))
|
||||
|
||||
|
||||
def run(check: bool = False) -> dict:
|
||||
"""Regenerate the client star-map data, or (check) report whether it is stale."""
|
||||
data = generate()
|
||||
if check:
|
||||
if not os.path.exists(OUTPUT_PATH):
|
||||
raise ReachError(f"{OUTPUT_PATH} does not exist", fix="reach atlas map data")
|
||||
with open(OUTPUT_PATH) as f:
|
||||
if f.read() != render(data):
|
||||
raise ReachError(
|
||||
f"STALE: {OUTPUT_PATH} differs from generated output",
|
||||
fix="reach atlas map data, then stage client/data/star_map_data.json",
|
||||
)
|
||||
return {"path": OUTPUT_PATH, "stale": False}
|
||||
write_output(data, OUTPUT_PATH)
|
||||
return {"path": OUTPUT_PATH, "nodes": data["_meta"]["system_count"], "edges": data["_meta"]["edge_count"]}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Transport for `atlas map` — the Reach map, the ladder's top rung (D-191, D-243).
|
||||
|
||||
Two verbs, both RENDERS of the committed star-map.json. What does not appear
|
||||
here is deliberate: the generator that produced star-map.json in the first
|
||||
place, and the sculpt/tune passes that shaped its topology, are archived
|
||||
(tooling/archive/map-bootstrap/). They are keyed on the retired S-NNN ids, and
|
||||
the generator overwrites docs/design/star-map.json unconditionally from its
|
||||
seed — re-running it would replace the GJ-keyed, hand-maintained map (T-1293).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from tooling.core import cli, console
|
||||
from tooling.core.command import command
|
||||
|
||||
app = cli.domain("map", "The Reach map — client star-map data and the concentric SVG.")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _rung() -> None:
|
||||
"""Keeps `map` a group (Typer collapses a single-command app)."""
|
||||
|
||||
|
||||
@app.command("data")
|
||||
@command
|
||||
def data(
|
||||
check: bool = typer.Option(False, "--check", help="Fail if the committed JSON is stale; write nothing."),
|
||||
) -> None:
|
||||
"""Regenerate client/data/star_map_data.json from star-map.json + systems.db + wiki."""
|
||||
from tooling.domains.atlas.map import data as service
|
||||
|
||||
result = service.run(check=check)
|
||||
if check:
|
||||
console.verdict(f"star-map data: {result['path']} is up to date")
|
||||
else:
|
||||
console.out(json.dumps(result))
|
||||
console.verdict(f"star-map data: wrote {result['nodes']} nodes, {result['edges']} edges")
|
||||
|
||||
|
||||
@app.command("svg")
|
||||
@command
|
||||
def svg(
|
||||
output: Path = typer.Argument(None, help="SVG path (default: docs/diagrams/design/star-map-concentric.svg)."),
|
||||
) -> None:
|
||||
"""Render the concentric-ring Reach map SVG (and a PNG beside it)."""
|
||||
from tooling.domains.atlas.map import svg as service
|
||||
|
||||
result = service.render(str(output) if output else service.DEFAULT_OUTPUT)
|
||||
console.out(json.dumps(result))
|
||||
@@ -0,0 +1,381 @@
|
||||
"""Render the concentric-ring star map SVG from star-map.json and systems.db.
|
||||
|
||||
reach atlas map svg [output.svg]
|
||||
default output: docs/diagrams/design/star-map-concentric.svg (+ .png beside it)
|
||||
|
||||
Rings are hop distance from the Gateway; wedges are the four cardinal reaches.
|
||||
This never passes through d2 (.claude/rules/diagrams.md), and the PNG it writes
|
||||
beside the default SVG is the one PNG that rule keeps on purpose.
|
||||
|
||||
Formerly tooling/generate-star-map-svg.py, a module-level script (T-1293). The
|
||||
drawing code is unchanged — only wrapped in render(), with its prints routed
|
||||
through console and the PNG step's converter choice made a pure function.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
|
||||
from tooling.core import config, console, process
|
||||
|
||||
ROOT = str(config.repo_root())
|
||||
|
||||
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')
|
||||
|
||||
|
||||
def render(output_path: str = DEFAULT_OUTPUT) -> dict:
|
||||
"""Write the SVG (and a PNG beside it when a converter exists)."""
|
||||
# --- 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(' <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(' <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))
|
||||
console.event(f'Written to {output_path}')
|
||||
console.event(f'{len(positions)} nodes, {len(starmap["edges"])} edges')
|
||||
console.event(f'Hops 0-{FOLD_HOP-1} individual, {FOLD_HOP}+ folded ({len(hop_groups.get(FOLD_HOP, []))} nodes)')
|
||||
|
||||
png = render_png(output_path)
|
||||
return {"svg": output_path, "png": png, "nodes": len(positions), "edges": len(starmap["edges"])}
|
||||
|
||||
|
||||
def png_command(svg_path: str, png_path: str, which=shutil.which) -> list[str] | None:
|
||||
"""The SVG→PNG converter argv, in the original preference order — pure."""
|
||||
if which('magick'):
|
||||
return ['magick', '-density', '150', '-background', '#0a0a1a', svg_path, png_path]
|
||||
if which('convert'):
|
||||
return ['convert', '-density', '150', '-background', '#0a0a1a', svg_path, png_path]
|
||||
if which('rsvg-convert'):
|
||||
return ['rsvg-convert', '-d', '150', '-p', '150', '-b', '#0a0a1a', '-o', png_path, svg_path]
|
||||
return None
|
||||
|
||||
|
||||
def render_png(svg_path: str) -> str | None:
|
||||
"""Rasterise beside the SVG. A missing or failing converter warns, as before."""
|
||||
png_path = svg_path.replace('.svg', '.png')
|
||||
cmd = png_command(svg_path, png_path)
|
||||
if cmd is None:
|
||||
console.event('No SVG-to-PNG converter found (tried magick, convert, rsvg-convert). PNG not generated.',
|
||||
level='warn')
|
||||
return None
|
||||
result = process.run(cmd, check=False)
|
||||
if result.returncode != 0:
|
||||
console.event(f'PNG conversion failed: {result.stderr.strip()}', level='warn')
|
||||
return None
|
||||
console.event(f'PNG written to {png_path}')
|
||||
return png_path
|
||||
@@ -156,13 +156,16 @@ def flatness_report(
|
||||
)
|
||||
|
||||
|
||||
# --- the rungs below the body surface -------------------------------------
|
||||
# --- the rungs, as nested groups -----------------------------------------
|
||||
#
|
||||
# `planet` is a nested GROUP, not a domain of its own: the ladder is one
|
||||
# subject (D-243), and a body is a rung of it rather than a peer. It is added
|
||||
# at import time but its own module tree loads only when a `planet` verb runs
|
||||
# — same lazy contract as the domains themselves.
|
||||
# `map` (the Reach, the top rung) and `planet` (a body, the third) are nested
|
||||
# GROUPS, not domains of their own: the ladder is one subject (D-243), and each
|
||||
# rung is part of it rather than a peer. They are added at import time, but
|
||||
# their module trees load only when one of their verbs runs — the same lazy
|
||||
# contract as the domains themselves.
|
||||
|
||||
from tooling.domains.atlas.map.router import app as _map_app # noqa: E402
|
||||
from tooling.domains.atlas.planet.router import app as _planet_app # noqa: E402
|
||||
|
||||
app.add_typer(_map_app, name="map")
|
||||
app.add_typer(_planet_app, name="planet")
|
||||
|
||||
Reference in New Issue
Block a user