From c33ac8921f2a7645a672cbb6010c4ddfbfe8e077 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 15 Apr 2026 08:22:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20atlas=20heightmap=20viewer=20?= =?UTF-8?q?=E2=80=94=20pan/zoom,=20marker=20overlay,=20city=20data=20panel?= =?UTF-8?q?=20(#835)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/scripts/main.gd | 15 + client/ui/implant/atlas_marker_overlay.gd | 275 +++++++++++ client/ui/implant/atlas_panel.gd | 56 ++- client/ui/implant/atlas_viewer.gd | 532 ++++++++++++++++++++++ 4 files changed, 870 insertions(+), 8 deletions(-) create mode 100644 client/ui/implant/atlas_marker_overlay.gd create mode 100644 client/ui/implant/atlas_viewer.gd diff --git a/client/scripts/main.gd b/client/scripts/main.gd index a9f57ba76..ab818403a 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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() diff --git a/client/ui/implant/atlas_marker_overlay.gd b/client/ui/implant/atlas_marker_overlay.gd new file mode 100644 index 000000000..8ef9134b4 --- /dev/null +++ b/client/ui/implant/atlas_marker_overlay.gd @@ -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 diff --git a/client/ui/implant/atlas_panel.gd b/client/ui/implant/atlas_panel.gd index 3948e3628..2ca1da09f 100644 --- a/client/ui/implant/atlas_panel.gd +++ b/client/ui/implant/atlas_panel.gd @@ -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 diff --git a/client/ui/implant/atlas_viewer.gd b/client/ui/implant/atlas_viewer.gd new file mode 100644 index 000000000..b68b39b77 --- /dev/null +++ b/client/ui/implant/atlas_viewer.gd @@ -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()