feat(ui): star map click-through with wiki/GTTR popup and edge visibility

Show system profile popup on select: name, star type, hop distance,
corridor, GTTR excerpt, body count, population, adjacent systems.
Edge rendering changed to show no edges by default, only selected
system's gate lines on click. Star map data extended with wiki fields
for all 301 systems.

Ticket: #780

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 09:59:18 +02:00
co-authored by Claude Opus 4.6
parent 4dd866c9ad
commit 8eb9e0a383
3 changed files with 2443 additions and 366 deletions
File diff suppressed because it is too large Load Diff
+137 -63
View File
@@ -5,11 +5,12 @@ extends Control
## Renders 301 systems as dots on concentric rings (hop distance from Gateway).
## Sector-colored: core (white-gold), north (blue), south (orange), east (green), west (tan).
##
## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db).
## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db + wiki).
## Regenerate with: tooling/generate-star-map-data.py
##
## D-013: Diegetic neural insert overlay. Accessible from the insert UI.
## Parent epic: #51 (Diegetic Insert/Minimap), ticket #674.
## Ticket #780: Click-through popup — wiki/GTTR content on system select.
const DATA_PATH := "res://data/star_map_data.json"
@@ -30,10 +31,17 @@ const GATEWAY_RADIUS: float = 6.0
const SELECTION_RING_RADIUS: float = 8.0
const HIT_RADIUS: float = 10.0 # click tolerance
# Edge rendering
const EDGE_WIDTH: float = 0.4
const EDGE_ALPHA: float = 0.12
const EDGE_SELECTED_ALPHA: float = 0.5
# Edge rendering — only shown for selected system (ticket #780 UX rule)
const EDGE_WIDTH: float = 0.8
const EDGE_SELECTED_ALPHA: float = 0.55
# Info popup — expanded with wiki/GTTR content (#780)
const POPUP_WIDTH: float = 300.0
const POPUP_MARGIN: float = 16.0
const POPUP_PADDING: float = 12.0
const POPUP_LINE_H: float = 17.0
const POPUP_GTTR_FONT_SIZE: int = 10
const POPUP_GTTR_MAX_LINES: int = 7
# Colors — sector palette from wireframe
const COLOR_BG: Color = Color("#0d1117")
@@ -95,7 +103,6 @@ var _pan_start_offset: Vector2 = Vector2.ZERO
var _data_loaded: bool = false
var _insert_active: bool = true
var _show_edges: bool = false # toggle edge display
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
@@ -260,7 +267,7 @@ func _sector_sort_key(node: Dictionary) -> float:
"south_reach": return 3.0
"west_reach": return 4.0
"deep_frontier": return 5.0
_: return 6.0
_: return 6.0 # gdlint:ignore = max-returns
## Deterministic float in [-1, 1] from a string key.
@@ -289,8 +296,8 @@ func _draw() -> void:
# Sector labels
_draw_sector_labels(center)
# Edges (gate connections)
if _show_edges or _selected_system != "":
# Edges — only draw from selected system (UX rule: full edge web is too dense)
if _selected_system != "":
_draw_edges(center)
# System dots
@@ -358,23 +365,18 @@ func _draw_systems(center: Vector2) -> void:
func _draw_edges(center: Vector2) -> void:
for edge: Array in _edges:
if edge.size() < 2:
# Only draw edges connected to the selected system (full web is unreadable at 301 systems)
var node: Dictionary = _node_lookup.get(_selected_system, {})
var adj: Array = node.get("adjacent_systems", [])
if adj.is_empty():
return
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, EDGE_SELECTED_ALPHA)
var sel_pos: Vector2 = center + _node_positions.get(_selected_system, Vector2.ZERO) * _zoom
for neighbor_id: String in adj:
if not _node_positions.has(neighbor_id):
continue
var sid_a: String = edge[0]
var sid_b: String = edge[1]
if not _node_positions.has(sid_a) or not _node_positions.has(sid_b):
continue
var pos_a: Vector2 = center + _node_positions[sid_a] * _zoom
var pos_b: Vector2 = center + _node_positions[sid_b] * _zoom
var alpha: float = EDGE_ALPHA
# Highlight edges connected to selected system
if _selected_system == sid_a or _selected_system == sid_b:
alpha = EDGE_SELECTED_ALPHA
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, alpha)
draw_line(pos_a, pos_b, color, EDGE_WIDTH, true)
var neighbor_pos: Vector2 = center + _node_positions[neighbor_id] * _zoom
draw_line(sel_pos, neighbor_pos, color, EDGE_WIDTH, true)
func _draw_selection(center: Vector2) -> void:
@@ -389,7 +391,8 @@ func _draw_selection(center: Vector2) -> void:
if label.is_empty():
label = _selected_system
var font := get_theme_default_font()
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label,
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
func _draw_info_panel(sz: Vector2) -> void:
@@ -397,49 +400,125 @@ func _draw_info_panel(sz: Vector2) -> void:
if node.is_empty():
return
var panel_w: float = 220.0
var panel_h: float = 130.0
var margin: float = 16.0
var panel_pos := Vector2(sz.x - panel_w - margin, margin)
# Background
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
var font := get_theme_default_font()
var y: float = panel_pos.y + 20.0
var x: float = panel_pos.x + 12.0
var line_h: float = 18.0
var panel_w: float = POPUP_WIDTH
var pad: float = POPUP_PADDING
var inner_w: float = panel_w - pad * 2.0
# System name
var name: String = node.get("proper_name", "")
if name.is_empty():
name = node.get("system_id", "Unknown")
draw_string(font, Vector2(x, y), name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, COLOR_TEXT)
y += line_h
# Measure GTTR text height first so we can size the panel
var gttr: String = node.get("gttr_excerpt", "")
# Strip markdown bold markers for display
gttr = gttr.replace("**", "")
var gttr_block_h: float = 0.0
if not gttr.is_empty():
var gttr_size: Vector2 = font.get_multiline_string_size(
gttr, HORIZONTAL_ALIGNMENT_LEFT, inner_w, POPUP_GTTR_FONT_SIZE,
POPUP_GTTR_MAX_LINES)
gttr_block_h = gttr_size.y + 6.0
# System ID
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
y += line_h
# Fixed rows: name, id, star+hop, corridor, bodies+pop, adjacent, separator lines
var fixed_h: float = (
POPUP_LINE_H * 2.0 # name + id
+ 4.0 # separator
+ POPUP_LINE_H * 3.0 # star+hop, corridor, bodies+pop
+ 4.0 # separator
+ gttr_block_h
+ 4.0 # separator (before adjacent)
+ POPUP_LINE_H # adjacent systems label
+ pad * 2.0
)
var panel_h: float = fixed_h
# Sector
var sector: String = node.get("geographic_sector", "").replace("_", " ").capitalize()
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
var x: float = panel_pos.x + pad
var y: float = panel_pos.y + pad + POPUP_LINE_H # baseline offset
# Background + border
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)),
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
# ── System name ──────────────────────────────────────────────────────────
var sys_name: String = node.get("proper_name", "")
if sys_name.is_empty():
sys_name = node.get("system_id", "Unknown")
draw_string(font, Vector2(x, y), sys_name, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT)
y += POPUP_LINE_H
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
y += POPUP_LINE_H
# Separator
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
y += 6.0
# ── Stats block ──────────────────────────────────────────────────────────
var star_type: String = node.get("star_type", "")
var hop: int = int(node.get("hop_distance", 0))
var stat_line_1: String
if star_type.is_empty():
stat_line_1 = "Hop %d from Gateway" % hop
else:
stat_line_1 = "%s star · hop %d" % [star_type, hop]
draw_string(font, Vector2(x, y), stat_line_1, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
y += POPUP_LINE_H
var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper()
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
draw_string(font, Vector2(x, y), "Sector: " + sector, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, sector_color)
y += line_h
draw_string(font, Vector2(x, y), sector_str + " corridor", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
y += POPUP_LINE_H
# Hop distance
draw_string(font, Vector2(x, y), "Hop distance: " + str(node.get("hop_distance", "?")), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
y += line_h
var bodies: String = node.get("bodies", "")
var population: String = node.get("population", "")
var stat_line_3: String
if not bodies.is_empty() and not population.is_empty():
stat_line_3 = "%s · pop %s" % [bodies, population]
elif not bodies.is_empty():
stat_line_3 = bodies
elif not population.is_empty():
stat_line_3 = "Population: " + population
else:
stat_line_3 = "%d aperture%s · %s" % [
int(node.get("aperture_count", 0)),
"s" if int(node.get("aperture_count", 0)) != 1 else "",
node.get("gate_topology", "")]
draw_string(font, Vector2(x, y), stat_line_3, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
y += POPUP_LINE_H
# Gate connections
draw_string(font, Vector2(x, y), "Gates: " + str(int(node.get("aperture_count", 0))) + " apertures", HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
# ── GTTR excerpt ─────────────────────────────────────────────────────────
if not gttr.is_empty():
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
y += 6.0
draw_multiline_string(font, Vector2(x, y), gttr, HORIZONTAL_ALIGNMENT_LEFT,
inner_w, POPUP_GTTR_FONT_SIZE, POPUP_GTTR_MAX_LINES,
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.75))
y += gttr_block_h
# ── Adjacent systems ──────────────────────────────────────────────────────
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
y += 6.0
var adj: Array = node.get("adjacent_systems", [])
if adj.is_empty():
draw_string(font, Vector2(x, y), "No gate connections", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
else:
var adj_names: Array = []
for neighbor_id: String in adj:
var neighbor: Dictionary = _node_lookup.get(neighbor_id, {})
var n_name: String = neighbor.get("proper_name", "")
adj_names.append(n_name if not n_name.is_empty() else neighbor_id)
var adj_line: String = " · ".join(adj_names)
draw_multiline_string(font, Vector2(x, y), adj_line, HORIZONTAL_ALIGNMENT_LEFT,
inner_w, 10, 2, COLOR_TEXT_DIM)
func _draw_title() -> void:
var font := get_theme_default_font()
draw_string(font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT)
draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(),
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
func _dot_radius(topology: String) -> float:
@@ -508,12 +587,7 @@ func _find_nearest_system(pos: Vector2) -> String:
func _handle_click(pos: Vector2) -> void:
var nearest := _find_nearest_system(pos)
if nearest != "":
_selected_system = nearest
_show_edges = true
else:
_selected_system = ""
_show_edges = false
_selected_system = nearest
_dirty = true