feat(ui): atlas heightmap viewer — pan/zoom, marker overlay, city data panel (#835)

Adds Level.HEIGHTMAP_VIEWER to AtlasPanel. The viewer loads a body's
terrain_reference heightmap PNG, pairs it with markers.json (roads, rail, POIs,
cities, rivers/oceans/mountains), and renders markers in texture-space via an
AtlasMarkerOverlay Node2D child of a transformed canvas — pan = offset,
zoom = scale.

Pan/zoom is cursor-centred (wheel zooms under the mouse, drag pans), with a
fit-to-view reset on R. Empty markers.json state renders a bare heightmap;
missing terrain_reference shows a themed "terrain data pending (#839)" notice
instead of crashing.

City data sidebar rebuilds from the selected city: name, pop tier, function,
currency zone, Commission presence, shadow zone, gate distance. Pressing N on
a selected city emits economics_link_requested(system_id) — main.gd bridges
this to EconomicsPanel.select_system() + HudGroups.open_app("implant/economics")
as an insert overlay, satisfying the D-191 Phase 2/3 cross-panel integration.

The overlay renders all nine D-191 overlay layers off of per-overlay visibility
flags in AtlasViewer. Overlay toggling for the regional view (#836) plugs into
set_overlay_visible(); the five always-on layers (terrain, infrastructure,
named features, gate markers, political zones) draw by default, the four
toggleable layers draw from placeholder data, and the two locked layers
(stockpile_weeks, production_vs_baseline) remain off until unlocked.

Per D-191 criteria 1, 4, 5.
This commit is contained in:
2026-04-15 08:22:01 +02:00
parent 760953aeba
commit c33ac8921f
4 changed files with 870 additions and 8 deletions
+15
View File
@@ -168,6 +168,12 @@ func _ready() -> void:
debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested)
debug_console.unpause_requested.connect(_dialogue.on_dialogue_unpause_requested)
# #835 D-191: Atlas → Economics Monitor cross-link. The atlas city data panel
# emits economics_link_requested(system_id); we pre-filter the monitor and
# open it as an insert panel on top.
if atlas_panel and economics_panel:
atlas_panel.economics_link_requested.connect(_on_atlas_economics_link)
func _unhandled_key_input(event: InputEvent) -> void:
if event.is_pressed() and not event.is_echo():
@@ -192,6 +198,15 @@ func _unhandled_key_input(event: InputEvent) -> void:
economics_panel.navigate(1)
func _on_atlas_economics_link(system_id: String) -> void:
# #835 D-191: Pre-filter the economics monitor to the city's system and pop
# the panel open. AtlasPanel closes itself before emitting this signal.
if economics_panel == null:
return
economics_panel.select_system(system_id)
HudGroups.open_app("implant/economics", HudGroups.Mode.INSERT)
func _process(delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot: Variant = SimBridge.poll_snapshot()
+275
View File
@@ -0,0 +1,275 @@
class_name AtlasMarkerOverlay
extends Node2D
## Draws heightmap + markers for #835 AtlasViewer. Child of AtlasViewer._canvas
## so it inherits the pan/zoom transform. Draws in texture-native coordinates.
##
## Overlay layers (#836 toggles via viewer._overlay_visibility):
## terrain heightmap texture itself
## political_zones currency-zone color bands (coarse)
## infrastructure roads + railroads
## named_features labels for rivers, oceans, mountain ranges
## gate_markers gate-terminal POIs
## population_density (toggleable) density heatmap placeholder
## production_zones (toggleable) production zone outlines placeholder
## shadow_economy (toggleable) shadow-zone broad bands placeholder
## corp_presence (toggleable) Tier 1 corp dots placeholder
## stockpile_weeks (locked) gated by corporate contact
## production_vs_baseline (locked) gated by insider access
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
const COLOR_POLITICAL: Color = Color(0.25, 0.50, 0.75, 0.14)
const COLOR_ROAD: Color = Color(0.85, 0.60, 0.30, 0.85)
const COLOR_RAIL: Color = Color(0.45, 0.55, 0.70, 0.85)
const COLOR_CITY: Color = Color(0.94, 0.82, 0.38, 1.0)
const COLOR_CITY_HOVER: Color = Color(1.0, 1.0, 1.0, 1.0)
const COLOR_CITY_SELECTED: Color = Color(1.0, 0.95, 0.60, 1.0)
const COLOR_GATE: Color = Color(0.70, 0.88, 1.0, 1.0)
const COLOR_POI: Color = Color(0.45, 0.75, 0.85, 0.90)
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
const COLOR_POP_HEAT: Color = Color(0.95, 0.35, 0.25, 0.22)
const COLOR_PRODUCTION: Color = Color(0.40, 0.80, 0.55, 0.18)
const COLOR_SHADOW: Color = Color(0.35, 0.20, 0.50, 0.22)
const COLOR_CORP: Color = Color(0.85, 0.65, 0.20, 0.75)
const RAIL_DASH_ON: float = 6.0
const RAIL_DASH_OFF: float = 4.0
var viewer = null # AtlasViewer (untyped to avoid cyclic ref)
func _draw() -> void:
if viewer == null:
return
var tex: Texture2D = viewer.get_heightmap_texture()
if tex == null:
return
var tex_w: float = float(tex.get_width())
var tex_h: float = float(tex.get_height())
var markers: Dictionary = viewer.get_markers()
# Terrain (heightmap) — always first
if viewer.is_overlay_visible("terrain"):
draw_texture_rect(tex, Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), false, COLOR_HEIGHTMAP_TINT)
else:
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), Color(0.05, 0.07, 0.10, 1.0))
# Political zone tint (currency zone band — single tint over whole body for MVP)
if viewer.is_overlay_visible("political_zones"):
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), COLOR_POLITICAL)
# Infrastructure (roads + rail)
if viewer.is_overlay_visible("infrastructure"):
_draw_roads(markers)
_draw_rails(markers)
# Named features (rivers, oceans, mountain ranges)
if viewer.is_overlay_visible("named_features"):
_draw_named_features(markers)
# Toggleable placeholders — drawn only when populated
if viewer.is_overlay_visible("population_density"):
_draw_population_density(markers)
if viewer.is_overlay_visible("production_zones"):
_draw_production_zones(markers)
if viewer.is_overlay_visible("shadow_economy"):
_draw_shadow_economy(markers)
if viewer.is_overlay_visible("corp_presence"):
_draw_corp_presence(markers)
# POIs (non-gate first, then gates on top if enabled)
_draw_pois(markers)
# Gate markers
if viewer.is_overlay_visible("gate_markers"):
_draw_gate_markers(markers)
# Cities last so labels sit on top
_draw_cities(markers)
# =============================================================================
# Feature drawing
# =============================================================================
func _draw_roads(markers: Dictionary) -> void:
var roads: Array = markers.get("roads", [])
for r: Dictionary in roads:
var path: Array = r.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = _path_to_canvas(path)
draw_polyline(points, COLOR_ROAD, 1.5, true)
func _draw_rails(markers: Dictionary) -> void:
var rails: Array = markers.get("railroads", [])
for r: Dictionary in rails:
var path: Array = r.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = _path_to_canvas(path)
_draw_dashed_polyline(points, COLOR_RAIL, 1.0)
func _draw_named_features(markers: Dictionary) -> void:
var font := ThemeDB.fallback_font
var fs: int = 8
for ocean: Dictionary in markers.get("oceans", []):
var label: String = ocean.get("name", "") if ocean.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(ocean.get("center"))
draw_string(font, p, label.to_upper(), HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
for mtn: Dictionary in markers.get("mountain_ranges", []):
var label: String = mtn.get("name", "") if mtn.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(mtn.get("center"))
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
for river: Dictionary in markers.get("rivers", []):
var label: String = river.get("name", "") if river.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(river.get("center"))
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
func _draw_pois(markers: Dictionary) -> void:
var pois: Array = markers.get("pois", [])
for p: Dictionary in pois:
if bool(p.get("gate_terminal", false)):
continue # drawn by gate_markers layer
var pos: Vector2 = _poi_pos(p)
_draw_diamond(pos, 4.0, COLOR_POI)
func _draw_gate_markers(markers: Dictionary) -> void:
# Gates show up in both pois[] (kind="gate") and cities[].gate_terminal
for p: Dictionary in markers.get("pois", []):
if not bool(p.get("gate_terminal", false)):
continue
_draw_diamond(_poi_pos(p), 6.0, COLOR_GATE)
for c: Dictionary in markers.get("cities", []):
if not bool(c.get("gate_terminal", false)):
continue
var pos: Vector2 = viewer._city_canvas_pos(c)
draw_arc(pos, 9.0, 0.0, TAU, 18, COLOR_GATE, 1.2, true)
func _draw_cities(markers: Dictionary) -> void:
var cities: Array = markers.get("cities", [])
if cities.is_empty():
return
var font := ThemeDB.fallback_font
var hovered: Dictionary = viewer._hovered_city
var selected: Dictionary = viewer._selected_city
for c: Dictionary in cities:
var pos: Vector2 = viewer._city_canvas_pos(c)
var tier: int = int(c.get("population_tier", 1))
var r: float = 2.5 + float(tier) * 0.8
var col: Color = COLOR_CITY
if c == selected:
col = COLOR_CITY_SELECTED
elif c == hovered:
col = COLOR_CITY_HOVER
draw_circle(pos, r + 1.0, Color(0.0, 0.0, 0.0, 0.55))
draw_circle(pos, r, col)
var name_str: String = c.get("name", "") if c.get("name") else ""
if not name_str.is_empty() and (tier >= 3 or c == hovered or c == selected):
var lcolor: Color = COLOR_CITY_HOVER if c == hovered or c == selected else Color(0.88, 0.90, 0.96, 0.85)
draw_string(font, pos + Vector2(r + 2.0, r * 0.4), name_str, HORIZONTAL_ALIGNMENT_LEFT, -1, 8, lcolor)
# =============================================================================
# Overlay placeholders (populated by server side signals eventually)
# =============================================================================
func _draw_population_density(markers: Dictionary) -> void:
# Placeholder: soft blobs at city positions scaled by population_tier
for c: Dictionary in markers.get("cities", []):
var pos: Vector2 = viewer._city_canvas_pos(c)
var tier: int = int(c.get("population_tier", 1))
var r: float = 12.0 + float(tier) * 6.0
draw_circle(pos, r, COLOR_POP_HEAT)
func _draw_production_zones(markers: Dictionary) -> void:
for zone: Dictionary in markers.get("production_zones", []):
var p: Vector2 = _center_to_canvas(zone.get("center"))
var radius: float = float(zone.get("radius", 12.0))
draw_circle(p, radius, COLOR_PRODUCTION)
func _draw_shadow_economy(markers: Dictionary) -> void:
for zone: Dictionary in markers.get("shadow_zones", []):
var p: Vector2 = _center_to_canvas(zone.get("center"))
var radius: float = float(zone.get("radius", 16.0))
draw_circle(p, radius, COLOR_SHADOW)
func _draw_corp_presence(markers: Dictionary) -> void:
for corp: Dictionary in markers.get("corp_presence", []):
var p: Vector2 = _center_to_canvas(corp.get("pos"))
draw_rect(Rect2(p - Vector2(3, 3), Vector2(6, 6)), COLOR_CORP)
# =============================================================================
# Helpers
# =============================================================================
func _path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
out.append(viewer.grid_to_canvas(Vector2(float(pt[0]), float(pt[1]))))
return out
func _center_to_canvas(center: Variant) -> Vector2:
# Legacy markers.json encodes center as [row, col]
if center is Array and center.size() >= 2:
return viewer.grid_to_canvas(Vector2(float(center[1]), float(center[0])))
return Vector2.ZERO
func _poi_pos(poi: Dictionary) -> Vector2:
if poi.has("pos") and poi["pos"] is Array and poi["pos"].size() >= 2:
return viewer.grid_to_canvas(Vector2(float(poi["pos"][0]), float(poi["pos"][1])))
return _center_to_canvas(poi.get("center"))
func _draw_diamond(pos: Vector2, size: float, color: Color) -> void:
var pts: PackedVector2Array = PackedVector2Array([
pos + Vector2(0, -size),
pos + Vector2(size, 0),
pos + Vector2(0, size),
pos + Vector2(-size, 0),
])
draw_colored_polygon(pts, color)
func _draw_dashed_polyline(points: PackedVector2Array, color: Color, width: float) -> void:
if points.size() < 2:
return
# Simple per-segment dashing — good enough for the MVP.
for i: int in range(points.size() - 1):
var a: Vector2 = points[i]
var b: Vector2 = points[i + 1]
var seg: Vector2 = b - a
var seg_len: float = seg.length()
if seg_len <= 0.001:
continue
var dir: Vector2 = seg / seg_len
var t: float = 0.0
while t < seg_len:
var t_end: float = minf(t + RAIL_DASH_ON, seg_len)
draw_line(a + dir * t, a + dir * t_end, color, width, true)
t = t_end + RAIL_DASH_OFF
+48 -8
View File
@@ -1,16 +1,22 @@
class_name AtlasPanel
extends Control
## Atlas implant panel — 3-level navigation: system picker → orbital diagram → body entry (#834).
## Atlas implant panel — 4-level navigation: system picker → orbital diagram → body entry → regional viewer (#834, #835).
## FULLSCREEN implant app (z=20) at implant/map/atlas per D-170.
## Uses ImplantPanel component library (D-169). Data from star_map_data.json.
##
## Navigation:
## Level 0 SYSTEM_PICKER — ◄ ► cycle systems, Enter to open orbital view
## Level 1 ORBITAL_DIAGRAM — rendered via _draw(), click body → level 2, click station → mini panel
## Level 2 BODY_ENTRY — body info panel, Enter to open heightmap (#835), Esc back
## Level 0 SYSTEM_PICKER — ◄ ► cycle systems, Enter to open orbital view
## Level 1 ORBITAL_DIAGRAM — rendered via _draw(), click body → level 2, click station → mini panel
## Level 2 BODY_ENTRY — body info panel, Enter to open heightmap viewer, Esc back
## Level 3 HEIGHTMAP_VIEWER — AtlasViewer with pan/zoom + markers + city data (#835), Esc back
enum Level { SYSTEM_PICKER = 0, ORBITAL_DIAGRAM = 1, BODY_ENTRY = 2 }
## Emitted when the viewer's city-data panel requests the economics monitor for
## the current system. main.gd bridges this to EconomicsPanel.select_system()
## + HudGroups.open_app("implant/economics") — D-191 cross-panel integration.
signal economics_link_requested(system_id: String)
enum Level { SYSTEM_PICKER = 0, ORBITAL_DIAGRAM = 1, BODY_ENTRY = 2, HEIGHTMAP_VIEWER = 3 }
const APP_PATH := "implant/map/atlas"
const STAR_MAP_DATA := "res://data/star_map_data.json"
@@ -62,6 +68,7 @@ var _picker_panel = null # ImplantPanel — level 0 system selector
var _picker_nav_row = null # ImplantDataRow — nav hint text, updated on navigate
var _body_panel = null # ImplantPanel — level 2 body entry
var _station_panel = null # ImplantPanel — station mini, shown in level 1 on click
var _viewer = null # AtlasViewer — level 3 heightmap viewer (#835)
func _ready() -> void:
@@ -83,6 +90,7 @@ func _ready() -> void:
_build_picker_panel()
_build_body_panel()
_build_station_panel()
_build_heightmap_viewer()
_show_level(Level.SYSTEM_PICKER)
@@ -169,9 +177,10 @@ func _enter_body_entry(body: Dictionary) -> void:
func _open_heightmap_viewer() -> void:
# Placeholder — #835 implements the heightmap viewer scene.
# This will open it via HudGroups or a signal when #835 lands.
push_warning("AtlasPanel: heightmap viewer not yet implemented (#835)")
if _viewer == null:
return
_viewer.show_body(_selected_body, _current_system())
_show_level(Level.HEIGHTMAP_VIEWER)
# =============================================================================
@@ -257,6 +266,8 @@ func _draw() -> void:
_draw_orbital()
Level.BODY_ENTRY:
_draw_body_bg()
Level.HEIGHTMAP_VIEWER:
pass # AtlasViewer draws its own background
func _draw_picker_bg() -> void:
@@ -416,6 +427,9 @@ func _gui_input(event: InputEvent) -> void:
func _handle_key(event: InputEventKey) -> void:
if _level == Level.HEIGHTMAP_VIEWER:
# Viewer handles its own input via _gui_input; don't double-process.
return
match event.keycode:
KEY_ESCAPE:
_navigate_back()
@@ -442,6 +456,8 @@ func _navigate_back() -> void:
_show_level(Level.SYSTEM_PICKER)
Level.BODY_ENTRY:
_show_level(Level.ORBITAL_DIAGRAM)
Level.HEIGHTMAP_VIEWER:
_show_level(Level.BODY_ENTRY)
func _navigate_system(delta: int) -> void:
@@ -525,6 +541,8 @@ func _show_level(level: Level) -> void:
# Station panel managed separately — remains hidden until a station click
if _station_panel and level != Level.ORBITAL_DIAGRAM:
_station_panel.visible = false
if _viewer:
_viewer.visible = (level == Level.HEIGHTMAP_VIEWER)
queue_redraw()
@@ -642,6 +660,28 @@ func _build_station_panel() -> void:
add_child(_station_panel)
func _build_heightmap_viewer() -> void:
# #835: Regional viewer instanced at runtime (parse-order rule — AtlasMarkerOverlay
# class_name is resolved by the time this _ready() runs since AtlasPanel is not an autoload).
var ViewerScript := load("res://ui/implant/atlas_viewer.gd")
_viewer = ViewerScript.new()
_viewer.name = "AtlasViewer"
_viewer.visible = false
add_child(_viewer)
_viewer.back_pressed.connect(_on_viewer_back)
_viewer.economics_link_requested.connect(_on_viewer_economics_link)
func _on_viewer_back() -> void:
_show_level(Level.BODY_ENTRY)
func _on_viewer_economics_link(system_id: String) -> void:
economics_link_requested.emit(system_id)
# Close the atlas; main.gd will open economics monitor in its place.
HudGroups.close_app()
func _rebuild_station_panel() -> void:
if not _station_panel:
return
+532
View File
@@ -0,0 +1,532 @@
class_name AtlasViewer
extends Control
## Atlas regional viewer — heightmap PNG with pan/zoom + marker overlay (#835, D-191).
##
## Lives as a child of AtlasPanel, shown at Level.HEIGHTMAP_VIEWER. Receives
## body/system context from AtlasPanel via show_body(). Emits back_pressed and
## economics_link_requested signals so AtlasPanel can route them.
##
## Design notes:
## - Heightmap texture is drawn on a Node2D _canvas child. Pan = _canvas.position,
## zoom = _canvas.scale. MarkerOverlay is a child of _canvas so markers auto-
## follow the same transform.
## - markers.json schema (D-191 §8): cities, roads, railroads, pois, plus rivers,
## oceans, mountain_ranges with `center: [row, col]` and optional names.
## - Empty markers case (server #832/#833 not yet shipped): bare heightmap renders
## fine, no sidebar opens, overlays draw nothing.
## - Overlays (#836) plug into _overlay_visibility dict and _draw_overlays().
##
## Navigation:
## Mouse drag pan the map
## Mouse wheel zoom in / out (centered on cursor)
## Click city open city data panel
## R reset view
## Esc back to body entry
signal back_pressed
signal economics_link_requested(system_id: String)
const MIN_ZOOM: float = 0.5
const MAX_ZOOM: float = 8.0
const ZOOM_STEP: float = 1.15
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
# ── Colors ────────────────────────────────────────────────────────────────────
const COLOR_BG: Color = Color("#0d1117")
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
const COLOR_CITY: Color = Color("#f0d060")
const COLOR_CITY_HOVER: Color = Color("#ffffff")
const COLOR_CITY_LABEL: Color = Color("#c8d0e0")
const COLOR_ROAD: Color = Color("#d89040")
const COLOR_RAIL: Color = Color("#7888a0")
const COLOR_POI: Color = Color("#70c0d8")
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
const COLOR_GATE_MARKER: Color = Color("#b0e0ff")
const COLOR_POLITICAL_ZONE: Color = Color(0.25, 0.45, 0.65, 0.12)
const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788")
const COLOR_EMPTY_NOTICE: Color = Color("#445566")
# ── Context (set by AtlasPanel.show_body) ─────────────────────────────────────
var _body: Dictionary = {}
var _system: Dictionary = {}
var _implant_theme = null
# ── Heightmap + markers ───────────────────────────────────────────────────────
var _heightmap_texture: Texture2D = null
var _markers: Dictionary = {}
var _grid_w: float = 512.0
var _grid_h: float = 256.0
var _tex_w: float = 1024.0
var _tex_h: float = 512.0
# ── Pan/zoom state ────────────────────────────────────────────────────────────
var _view_offset: Vector2 = Vector2.ZERO
var _view_zoom: float = 1.0
var _dragging: bool = false
var _drag_start_mouse: Vector2
var _drag_start_offset: Vector2
# ── Selection ─────────────────────────────────────────────────────────────────
var _selected_city: Dictionary = {}
var _hovered_city: Dictionary = {}
# ── Overlay visibility (#836 plugs in here) ───────────────────────────────────
## Map of overlay id → bool. Always-on overlays are true by default; deferred
## overlays are locked (see _overlay_locked). AtlasOverlayBar in #836 writes
## into this dict via set_overlay_visible().
var _overlay_visibility: Dictionary = {
"terrain": true,
"infrastructure": true,
"named_features": true,
"gate_markers": true,
"political_zones": true,
"population_density": false,
"production_zones": false,
"shadow_economy": false,
"corp_presence": false,
"stockpile_weeks": false,
"production_vs_baseline": false,
}
var _overlay_locked: Dictionary = {
"stockpile_weeks": true,
"production_vs_baseline": true,
}
# ── Child nodes ───────────────────────────────────────────────────────────────
var _canvas: Node2D = null # transformed node holding heightmap + markers
var _overlay_node: AtlasMarkerOverlay = null # draws on top of heightmap
var _city_panel = null # ImplantPanel sidebar (city data)
var _empty_notice = null # ImplantPanel shown when heightmap missing
func _ready() -> void:
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = Control.GROW_DIRECTION_BOTH
grow_vertical = Control.GROW_DIRECTION_BOTH
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
_implant_theme = load("res://ui/implant/default_implant.tres")
# Build transformed canvas that holds heightmap + marker overlay
_canvas = Node2D.new()
_canvas.name = "AtlasCanvas"
add_child(_canvas)
_overlay_node = AtlasMarkerOverlay.new()
_overlay_node.name = "MarkerOverlay"
_overlay_node.viewer = self
_canvas.add_child(_overlay_node)
_build_city_panel()
_build_empty_notice()
## Called by AtlasPanel when entering the viewer for a specific body.
func show_body(body: Dictionary, system: Dictionary) -> void:
_body = body
_system = system
_selected_city = {}
_hovered_city = {}
_load_heightmap()
_load_markers()
_fit_to_view()
_city_panel.visible = false
_empty_notice.visible = (_heightmap_texture == null)
grab_focus()
queue_redraw()
_overlay_node.queue_redraw()
## #836 hook — toggle an overlay. Locked overlays are no-ops.
func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
if _overlay_locked.get(overlay_id, false):
return
if not _overlay_visibility.has(overlay_id):
return
_overlay_visibility[overlay_id] = visible_state
_overlay_node.queue_redraw()
func is_overlay_visible(overlay_id: String) -> bool:
return bool(_overlay_visibility.get(overlay_id, false))
func is_overlay_locked(overlay_id: String) -> bool:
return bool(_overlay_locked.get(overlay_id, false))
func get_heightmap_texture() -> Texture2D:
return _heightmap_texture
func get_markers() -> Dictionary:
return _markers
# =============================================================================
# Data loading
# =============================================================================
func _load_heightmap() -> void:
_heightmap_texture = null
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
var path: String = str(ref)
if not path.begins_with("res://"):
path = "res://" + path.lstrip("/")
if not ResourceLoader.exists(path):
push_warning("AtlasViewer: terrain_reference not found at %s" % path)
return
var tex: Variant = load(path)
if tex is Texture2D:
_heightmap_texture = tex
_tex_w = float(_heightmap_texture.get_width())
_tex_h = float(_heightmap_texture.get_height())
func _load_markers() -> void:
_markers = {}
_grid_w = _tex_w
_grid_h = _tex_h
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
var hm_path: String = str(ref)
if not hm_path.begins_with("res://"):
hm_path = "res://" + hm_path.lstrip("/")
var dir: String = hm_path.get_base_dir()
var markers_path: String = dir + "/markers.json"
if not FileAccess.file_exists(markers_path):
return
var f := FileAccess.open(markers_path, FileAccess.READ)
if f == null:
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
f.close()
if not (parsed is Dictionary):
return
_markers = parsed
var grid: Dictionary = _markers.get("grid", {})
_grid_w = float(grid.get("w", _tex_w))
_grid_h = float(grid.get("h", _tex_h))
# =============================================================================
# View transform
# =============================================================================
func _fit_to_view() -> void:
if _heightmap_texture == null:
_view_zoom = 1.0
_view_offset = Vector2.ZERO
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
return
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var avail: Vector2 = sz - Vector2(0, 60) # leave space for header
var fit_x: float = avail.x / _tex_w
var fit_y: float = avail.y / _tex_h
_view_zoom = clampf(minf(fit_x, fit_y) * 0.92, MIN_ZOOM, MAX_ZOOM)
var scaled: Vector2 = Vector2(_tex_w, _tex_h) * _view_zoom
_view_offset = (sz - scaled) * 0.5 + Vector2(0, 20)
_apply_transform()
func _apply_transform() -> void:
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
queue_redraw()
_overlay_node.queue_redraw()
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
if is_equal_approx(new_zoom, _view_zoom):
return
# Keep the texture point under cursor fixed while zooming
var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom
_view_zoom = new_zoom
_view_offset = mouse_pos - local_before * _view_zoom
_apply_transform()
## Convert grid coordinates (from markers.json) to canvas-space (texture pixels).
func grid_to_canvas(grid_point: Vector2) -> Vector2:
if _grid_w <= 0.0 or _grid_h <= 0.0:
return Vector2.ZERO
return Vector2(
grid_point.x / _grid_w * _tex_w,
grid_point.y / _grid_h * _tex_h
)
func canvas_to_screen(canvas_point: Vector2) -> Vector2:
return canvas_point * _view_zoom + _view_offset
func screen_to_canvas(screen_point: Vector2) -> Vector2:
if _view_zoom == 0.0:
return Vector2.ZERO
return (screen_point - _view_offset) / _view_zoom
# =============================================================================
# Drawing (background + header)
# =============================================================================
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
_draw_header()
if _heightmap_texture != null:
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
func _draw_header() -> void:
var font := get_theme_default_font()
var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", ""))
var sys_name: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", ""))
var title: String = "ATLAS — %s · %s" % [body_name.to_upper(), sys_name.to_upper()]
draw_string(font, Vector2(16, 28), title, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, COLOR_TEXT)
var hint: String = "drag pan · wheel zoom · r reset · click city data · esc back"
draw_string(font, Vector2(16, 44), hint, HORIZONTAL_ALIGNMENT_LEFT, -1, 9, COLOR_TEXT_DIM)
## Safely extract a string field from a dict, falling back when missing or null.
static func _dict_str(d: Dictionary, key: String, fallback: String) -> String:
var v: Variant = d.get(key)
if v == null:
return fallback
var s: String = str(v)
if s.is_empty():
return fallback
return s
# =============================================================================
# Input
# =============================================================================
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
return
if _heightmap_texture == null:
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_zoom_at(mb.position, ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_zoom_at(mb.position, 1.0 / ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
if not _try_click_city(mb.position):
_dragging = true
_drag_start_mouse = mb.position
_drag_start_offset = _view_offset
else:
_dragging = false
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _dragging:
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
_apply_transform()
else:
_update_hover(mm.position)
func _handle_key(event: InputEventKey) -> void:
match event.keycode:
KEY_ESCAPE:
if _selected_city.size() > 0:
_selected_city = {}
_city_panel.visible = false
queue_redraw()
else:
back_pressed.emit()
KEY_R:
_fit_to_view()
func _try_click_city(screen_pos: Vector2) -> bool:
var city: Dictionary = _find_city_at(screen_pos)
if city.is_empty():
return false
_selected_city = city
_rebuild_city_panel()
_city_panel.visible = true
queue_redraw()
return true
func _update_hover(screen_pos: Vector2) -> void:
var new_hover: Dictionary = _find_city_at(screen_pos)
if new_hover != _hovered_city:
_hovered_city = new_hover
_overlay_node.queue_redraw()
func _find_city_at(screen_pos: Vector2) -> Dictionary:
var cities: Array = _markers.get("cities", [])
if cities.is_empty():
return {}
var hit_radius: float = 12.0
for c: Dictionary in cities:
var canvas_pt: Vector2 = _city_canvas_pos(c)
var screen_pt: Vector2 = canvas_to_screen(canvas_pt)
if screen_pos.distance_to(screen_pt) <= hit_radius:
return c
return {}
## Extract canvas-space position for a city marker. Supports `pos: [x,y]`,
## `lat`/`lon` (equirectangular), and legacy `center: [row,col]`.
func _city_canvas_pos(city: Dictionary) -> Vector2:
if city.has("pos") and city["pos"] is Array and city["pos"].size() >= 2:
return grid_to_canvas(Vector2(float(city["pos"][0]), float(city["pos"][1])))
if city.has("lat") and city.has("lon"):
var lon: float = float(city["lon"])
var lat: float = float(city["lat"])
return Vector2(
(lon + 180.0) / 360.0 * _tex_w,
(90.0 - lat) / 180.0 * _tex_h
)
if city.has("center") and city["center"] is Array and city["center"].size() >= 2:
# center is [row, col] in grid space
return grid_to_canvas(Vector2(float(city["center"][1]), float(city["center"][0])))
return Vector2.ZERO
# =============================================================================
# City data panel (sidebar)
# =============================================================================
func _build_city_panel() -> void:
_city_panel = ImplantPanel.new()
_city_panel.name = "CityPanel"
_city_panel.theme_resource = _implant_theme
_city_panel.custom_minimum_size.x = PANEL_WIDTH
_city_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_city_panel.visible = false
add_child(_city_panel)
_position_city_panel()
func _position_city_panel() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
_city_panel.position = Vector2(sz.x - PANEL_WIDTH - PANEL_MARGIN, 60.0)
func _rebuild_city_panel() -> void:
if not _city_panel:
return
_city_panel.clear()
_position_city_panel()
if _selected_city.is_empty():
return
var c: Dictionary = _selected_city
var name_str: String = _dict_str(c, "name", "(unnamed)")
var tier: int = int(c.get("population_tier", 0))
var func_label: String = _dict_str(c, "primary_function", "").replace("_", " ")
var is_gate: bool = bool(c.get("gate_terminal", false))
var sys_label: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", ""))
var body_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", ""))
_city_panel.add_component(ImplantHeader.new(name_str, sys_label + " · " + body_label))
_city_panel.add_component(ImplantSeparator.new())
if tier > 0:
_city_panel.add_component(ImplantDataRow.new("pop tier %d" % tier))
if not func_label.is_empty():
_city_panel.add_component(ImplantDataRow.new("function " + func_label))
if is_gate:
_city_panel.add_component(ImplantDataRow.new("gate terminal"))
# Cross-system facts
var czone: String = str(_system.get("currency_zone", "")).replace("_", " ")
if not czone.is_empty():
_city_panel.add_component(ImplantDataRow.new("currency " + czone))
var commission: bool = bool(c.get("commission_presence", false))
_city_panel.add_component(ImplantDataRow.new("commission " + ("yes" if commission else "no")))
var shadow: Variant = c.get("shadow_economy_zone")
if shadow != null and not str(shadow).is_empty():
_city_panel.add_component(ImplantDataRow.new("shadow zone " + str(shadow)))
var gate_dist: Variant = c.get("gate_distance_hops")
if gate_dist != null:
_city_panel.add_component(ImplantDataRow.new("gate distance %d hops" % int(gate_dist)))
_city_panel.add_component(ImplantSeparator.new())
_city_panel.add_component(ImplantTextBlock.new("n open economics monitor for this system"))
_city_panel.add_component(ImplantTextBlock.new("esc close"))
func _unhandled_key_input(event: InputEvent) -> void:
if not visible:
return
if not (event is InputEventKey) or not event.pressed or event.is_echo():
return
var ek := event as InputEventKey
if ek.keycode == KEY_N and _selected_city.size() > 0:
var sys_id: String = _system.get("system_id", "")
if not sys_id.is_empty():
economics_link_requested.emit(sys_id)
get_viewport().set_input_as_handled()
# =============================================================================
# Empty-state notice
# =============================================================================
func _build_empty_notice() -> void:
_empty_notice = ImplantPanel.new()
_empty_notice.name = "EmptyNotice"
_empty_notice.theme_resource = _implant_theme
_empty_notice.custom_minimum_size.x = 400.0
_empty_notice.mouse_filter = Control.MOUSE_FILTER_IGNORE
_empty_notice.visible = false
add_child(_empty_notice)
_empty_notice.add_component(ImplantHeader.new("TERRAIN DATA PENDING", "regional atlas unavailable"))
_empty_notice.add_component(ImplantSeparator.new())
_empty_notice.add_component(ImplantTextBlock.new("The server atlas pipeline has not yet populated terrain_reference for this body (#839). Once regenerated, the heightmap and markers will render here."))
_empty_notice.add_component(ImplantSeparator.new())
_empty_notice.add_component(ImplantTextBlock.new("esc back to orbital view"))
_position_empty_notice()
func _position_empty_notice() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
_empty_notice.position = Vector2((sz.x - 400.0) * 0.5, sz.y * 0.35)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
if _city_panel:
_position_city_panel()
if _empty_notice:
_position_empty_notice()