"""
Plotly-based map visualization component for the MUD Mapper.
This module provides the core visualization functions for rendering MUD zone
maps using Plotly. It creates interactive 2D map views that display rooms
as nodes and exits as connecting lines, with support for:
- **Flattened multi-level display**: All Z-levels shown on a single 2D plane
- **Visual Z-level differentiation**: Size and color distinguish floor levels
- **Room selection highlighting**: Selected room shown in red
- **Exit visualization**: Lines for cardinal directions, labels for up/down,
and triangle markers for cross-zone exits
- **Interactive pan, zoom, and hover tooltips**
Design Principles
-----------------
1. **Flattened View**: All rooms rendered on one plane regardless of Z
2. **Z-Ordered Rendering**: Rooms drawn back-to-front so ground level is clickable
3. **Separation of Concerns**: Base figure creation separate from room rendering
4. **Graceful Degradation**: Handles missing data, empty rooms, invalid exits
5. **Deterministic Output**: Same inputs produce identical figures
Coordinate System
-----------------
The map follows standard cartographic conventions:
- **X-axis**: East (+) / West (-), displayed left-to-right
- **Y-axis**: North (+) / South (-), displayed bottom-to-top
- **Z-axis**: Represented by visual styling (size/color), not position
The coordinate range is -20 to +20 on both axes, with gridlines every 5 units.
Visual Design
-------------
Rooms are styled by Z-level for easy identification:
- **z=-1 (Down)**: Black filled, 14px (smallest) - cellars, basements
- **z=+1 (Up)**: White with black border, 18px (medium) - towers, attics
- **z=0 (Ground)**: Blue filled, 20px (largest) - main floor
- **Selected**: Red, regardless of Z-level
Exit connections:
- **Cardinal exits (N/E/S/W)**: Gray lines between rooms on same Z-level
- **Vertical exits (U/D)**: Dashed lines between stacked rooms plus U/D labels
- **Zone exits**: Triangle markers pointing in the exit direction
- **Crosshair**: Dashed gray lines at origin for orientation
Constants
---------
Z_LEVEL_STYLES : dict[int, dict]
Visual styling for each Z-level (size, color, border)
Z_RENDER_ORDER : list[int]
Order in which Z-levels are rendered (back to front)
SELECTED_ROOM_COLOR : str
Color for highlighted/selected rooms
Functions
---------
create_map_figure(title) -> go.Figure
Create empty map canvas with grid and crosshair
create_map_figure_with_rooms(rooms, visible_z_levels, selected_room) -> go.Figure
Create map with rooms and exits rendered across multiple Z-levels
Internal Functions
------------------
_group_rooms_by_z_level(rooms, visible_z_levels) -> dict
Group rooms into buckets by their Z coordinate
_find_stacked_positions(rooms, visible_z_levels) -> dict
Find X,Y positions with rooms at multiple Z-levels
_draw_all_exit_lines(fig, rooms, visible_z_levels) -> None
Draw connecting lines for cardinal exits
_draw_vertical_exit_lines(fig, rooms, visible_z_levels) -> None
Draw dashed lines for up/down exits
_draw_zone_exit_markers(fig, rooms, visible_z_levels) -> None
Draw directional markers for cross-zone exits
_draw_rooms_at_z_level(fig, rooms_at_level, z_level, selected_room) -> None
Render rooms for a single Z-level as a Scatter trace
_add_vertical_exit_labels(fig, stacked_positions, rooms) -> None
Add "U/D" labels near stacked rooms with vertical exits
Usage
-----
Create an empty map canvas::
>>> from pipeworks_mud_mapper.components.map_view import create_map_figure
>>> fig = create_map_figure()
>>> fig.show() # Opens in browser
Render rooms from all Z-levels::
>>> from pipeworks_mud_mapper.components.map_view import (
... create_map_figure_with_rooms
... )
>>> rooms = {
... "ground": {"name": "Ground", "coords": [0, 0, 0], "exits": {"down": "cellar"}},
... "cellar": {"name": "Cellar", "coords": [0, 0, -1], "exits": {"up": "ground"}},
... }
>>> fig = create_map_figure_with_rooms(rooms, selected_room="ground")
>>> fig.show()
Filter to specific Z-levels::
>>> fig = create_map_figure_with_rooms(rooms, visible_z_levels=[0]) # Ground only
Integration with Dash::
>>> import dash
>>> from dash import dcc
>>> fig = create_map_figure_with_rooms(rooms)
>>> graph = dcc.Graph(figure=fig, id="map-graph")
Architecture
------------
The module uses a layered rendering approach:
1. **Base Figure** (create_map_figure)
- Creates the canvas with grid, crosshair, and axis labels
- Fixed dimensions (700x650) to prevent layout thrashing
2. **Exit Lines** (_draw_all_exit_lines)
- Drawn first so rooms appear on top
- Only cardinal directions on same Z-level
3. **Vertical Exit Lines** (_draw_vertical_exit_lines)
- Dashed connectors between stacked rooms with U/D exits
4. **Zone Exit Markers** (_draw_zone_exit_markers)
- Directional triangles for cross-zone exits
5. **Room Nodes** (_draw_rooms_at_z_level)
- Rendered in Z-order: z=-1 first, z=+1 second, z=0 last
- Ground level on top ensures it receives clicks
6. **Vertical Labels** (_add_vertical_exit_labels)
- Added last as annotations
- Show U/D for stacked rooms with vertical exits
Performance Considerations
--------------------------
- Rooms batched into single Scatter trace per Z-level
- Exit lines deduplicated (bidirectional exits draw once)
- Zone exit markers render as a single Scatter trace
- For zones with 100+ rooms, consider filter controls
- Figure creation is synchronous and typically < 50ms
"""
import plotly.graph_objects as go
from pipeworks_mud_mapper.models.room import DIRECTION_SHORT
# =============================================================================
# Z-Level Visual Configuration
# =============================================================================
#
# These constants define how rooms at different Z-levels are displayed.
# The design philosophy is:
#
# 1. Ground level (z=0) is most common and should be most prominent/clickable
# 2. Below ground (z=-1) uses dark colors suggesting depth/underground
# 3. Above ground (z=+1) uses light/hollow style suggesting elevation
# 4. Render order ensures ground is on top for click selection
Z_LEVEL_STYLES: dict[int, dict] = {
-1: {
# Below ground level (cellars, basements, underground)
# Smallest size, black fill - suggests depth and darkness
"size": 14,
"color": "rgba(40, 40, 40, 1)", # Black/dark gray filled
"border_color": "rgba(40, 40, 40, 1)", # Same as fill (no visible border)
"border_width": 1,
"label": "Down (z=-1)",
},
0: {
# Ground level (main floor, streets, ground-level rooms)
# Largest size, blue fill - the default and most prominent
"size": 20,
"color": "rgba(70, 130, 180, 1)", # Steel blue (familiar from original)
"border_color": "white", # White border for contrast
"border_width": 2,
"label": "Ground (z=0)",
},
1: {
# Above ground level (towers, attics, upper floors)
# Medium size, white with black border - suggests elevation/openness
"size": 18,
"color": "white", # White/hollow fill
"border_color": "rgba(40, 40, 40, 1)", # Black border for visibility
"border_width": 2,
"label": "Up (z=+1)",
},
}
"""
Visual styling configuration for each Z-level.
Each Z-level maps to a dictionary containing:
- **size**: Marker diameter in pixels
- **color**: Fill color (rgba string)
- **border_color**: Marker outline color
- **border_width**: Marker outline width in pixels
- **label**: Human-readable label for legends/tooltips
The size hierarchy (14 < 18 < 20) ensures that when rooms overlap at the
same X,Y position, the ground-level room (largest) is most visible and
receives clicks first due to render order.
"""
Z_RENDER_ORDER: list[int] = [-1, 1, 0]
"""
Order in which Z-levels are rendered (back to front).
Rooms are drawn in this order, meaning:
1. z=-1 (Down) is drawn first, appearing at the back
2. z=+1 (Up) is drawn second
3. z=0 (Ground) is drawn last, appearing on top
This ensures that when rooms overlap at the same X,Y position (stacked
vertically), the ground-level room is on top and receives mouse clicks.
Users can use the Z-level filter to hide ground level when they need to
select a room below or above.
"""
SELECTED_ROOM_COLOR: str = "rgba(255, 100, 100, 1)"
"""
Color used for the currently selected room.
This red color overrides the Z-level-based color when a room is selected,
providing clear visual feedback regardless of which floor the selected
room is on. The color is intentionally bright and distinct from all
Z-level colors.
"""
Z_LEVEL_VISUAL_OFFSET: dict[int, tuple[float, float]] = {
-1: (-0.4, -0.4), # Down: offset left and down (appears "behind")
0: (0.0, 0.0), # Ground: no offset (reference level)
1: (0.4, 0.4), # Up: offset right and up (appears "in front")
}
"""
Visual X,Y offset applied to rooms based on Z-level.
When rooms are stacked at the same logical X,Y position but different Z-levels,
this offset separates them visually so they don't overlap. The diagonal offset
creates an intuitive "stacking" effect:
- z=-1 (Down): Shifted left and down, appearing "behind"
- z=0 (Ground): No offset, the reference position
- z=+1 (Up): Shifted right and up, appearing "in front"
The offset value (0.4) is small enough that stacked rooms are clearly at the
"same position" but large enough to be visually distinct and separately clickable.
Note: This is purely visual. The logical coordinates used for U/D connections
remain unchanged.
"""
# =============================================================================
# Zone Exit Marker Configuration
# =============================================================================
ZONE_EXIT_MARKER_COLOR: str = "rgba(255, 165, 0, 0.85)"
"""
Color used for cross-zone exit markers.
Zone exits are visually distinct from local exits so authors can quickly
spot which directions lead out of the current zone. The orange hue reads
as a "handoff" indicator without overpowering room nodes.
"""
ZONE_EXIT_MARKER_OFFSETS: dict[str, tuple[float, float]] = {
"north": (0.0, 0.7),
"south": (0.0, -0.7),
"east": (0.7, 0.0),
"west": (-0.7, 0.0),
# Vertical exits are offset diagonally to distinguish from cardinal markers.
"up": (0.6, 0.6),
"down": (-0.6, -0.6),
}
"""
Offsets for positioning zone exit markers relative to the room node.
Each offset is expressed in map units and is applied after the Z-level
visual offset. This keeps markers near their originating room but prevents
overlap with the room node itself.
"""
ZONE_EXIT_MARKER_SYMBOLS: dict[str, str] = {
"north": "triangle-up",
"south": "triangle-down",
"east": "triangle-right",
"west": "triangle-left",
"up": "triangle-up-open",
"down": "triangle-down-open",
}
"""Plotly marker symbols for zone exits by direction."""
# =============================================================================
# Public API Functions
# =============================================================================
# =============================================================================
# Internal Helper Functions
# =============================================================================
def _get_visual_coords(
x: float, y: float, z: int, offset_scale_x: float = 1.0, offset_scale_y: float = 1.0
) -> tuple[float, float]:
"""
Apply visual offset to coordinates based on Z-level.
Stacked rooms (same X,Y, different Z) are offset visually so they don't
overlap on the 2D display. This keeps them clickable and distinguishable.
Parameters
----------
x : float
Logical X coordinate.
y : float
Logical Y coordinate.
z : int
Z-level (determines the offset to apply).
offset_scale_x : float, optional
X-axis multiplier for the offset (default: 1.0).
offset_scale_y : float, optional
Y-axis multiplier for the offset (default: 1.0).
Returns
-------
tuple[float, float]
Visual (x, y) coordinates with offset applied.
Examples
--------
Ground level has no offset::
>>> _get_visual_coords(5, 10, 0)
(5.0, 10.0)
Below ground shifts left and down::
>>> _get_visual_coords(5, 10, -1)
(4.6, 9.6)
Above ground shifts right and up::
>>> _get_visual_coords(5, 10, 1)
(5.4, 10.4)
Custom offset scale::
>>> _get_visual_coords(5, 10, -1, offset_scale_x=2.5, offset_scale_y=1.5)
(4.0, 9.4)
"""
base_offset = Z_LEVEL_VISUAL_OFFSET.get(z, (0.0, 0.0))
return (
x + base_offset[0] * offset_scale_x,
y + base_offset[1] * offset_scale_y,
)
def _group_rooms_by_z_level(
rooms: dict[str, dict],
visible_z_levels: list[int],
) -> dict[int, dict[str, dict]]:
"""
Group rooms by their Z-level coordinate.
Partitions rooms into buckets based on Z coordinate, filtering to
only include rooms at visible Z-levels.
Parameters
----------
rooms : dict[str, dict]
All rooms in the zone, keyed by room ID.
visible_z_levels : list[int]
Z-levels to include in the result.
Returns
-------
dict[int, dict[str, dict]]
Mapping of z_level -> {room_id: room_data} for visible levels only.
Z-levels with no rooms are not included in the result.
Examples
--------
Group rooms from a multi-level zone::
>>> rooms = {
... "cellar": {"coords": [0, 0, -1]},
... "hall": {"coords": [0, 0, 0]},
... "tower": {"coords": [0, 0, 1]},
... }
>>> result = _group_rooms_by_z_level(rooms, [-1, 0, 1])
>>> list(result.keys())
[-1, 0, 1]
>>> result[0]
{'hall': {'coords': [0, 0, 0]}}
Filter to specific levels::
>>> result = _group_rooms_by_z_level(rooms, [0])
>>> list(result.keys())
[0]
Notes
-----
- Rooms without "coords" key are treated as z=0
- The result only contains Z-levels that have at least one room
- This function is O(n) where n = number of rooms
"""
result: dict[int, dict[str, dict]] = {}
for room_id, room in rooms.items():
# Extract Z coordinate, defaulting to 0 if coords missing
coords = room.get("coords", [0, 0, 0])
z = coords[2] if len(coords) > 2 else 0
# Skip rooms not on a visible Z-level
if z not in visible_z_levels:
continue
# Initialize bucket for this Z-level if needed
if z not in result:
result[z] = {}
# Add room to its Z-level bucket
result[z][room_id] = room
return result
def _find_stacked_positions(
rooms: dict[str, dict],
visible_z_levels: list[int],
) -> dict[tuple[int, int], list[tuple[str, int]]]:
"""
Find X,Y positions that have rooms at multiple Z-levels.
Identifies positions where rooms are "stacked" vertically, meaning
they share the same X,Y coordinates but have different Z coordinates.
These positions are candidates for up/down exit labels.
Parameters
----------
rooms : dict[str, dict]
All rooms in the zone, keyed by room ID.
visible_z_levels : list[int]
Z-levels being displayed (rooms outside these are ignored).
Returns
-------
dict[tuple[int, int], list[tuple[str, int]]]
Mapping of (x, y) position to list of (room_id, z_level) tuples.
Only includes positions with 2 or more rooms (actual stacks).
Examples
--------
Find stacked positions in a vertical tower::
>>> rooms = {
... "base": {"coords": [5, 5, -1]},
... "ground": {"coords": [5, 5, 0]},
... "top": {"coords": [5, 5, 1]},
... "other": {"coords": [10, 10, 0]},
... }
>>> stacked = _find_stacked_positions(rooms, [-1, 0, 1])
>>> (5, 5) in stacked
True
>>> len(stacked[(5, 5)])
3
>>> (10, 10) in stacked # Only one room, not stacked
False
Notes
-----
- Positions with only 1 room are not considered "stacked"
- The room list for each position includes all visible Z-levels
- This is used to determine where to place U/D labels
"""
# Collect all rooms at each X,Y position
positions: dict[tuple[int, int], list[tuple[str, int]]] = {}
for room_id, room in rooms.items():
coords = room.get("coords", [0, 0, 0])
z = coords[2] if len(coords) > 2 else 0
# Skip rooms not on a visible Z-level
if z not in visible_z_levels:
continue
# Use (x, y) tuple as position key
xy = (coords[0], coords[1])
# Initialize list for this position if needed
if xy not in positions:
positions[xy] = []
# Record room and its Z-level
positions[xy].append((room_id, z))
# Filter to only positions with multiple rooms (actual stacks)
return {xy: room_list for xy, room_list in positions.items() if len(room_list) > 1}
def _draw_all_exit_lines(
fig: go.Figure,
rooms: dict[str, dict],
visible_z_levels: list[int],
visual_offset_x: float = 1.0,
visual_offset_y: float = 1.0,
) -> None:
"""
Draw exit lines for cardinal directions (N/E/S/W).
Adds Scatter traces to the figure for each exit connection. Only
draws lines for cardinal directions between rooms on the same Z-level.
Vertical exits (up/down) are handled separately via labels.
Parameters
----------
fig : go.Figure
Plotly figure to add traces to (modified in place).
rooms : dict[str, dict]
All rooms in the zone, keyed by room ID.
visible_z_levels : list[int]
Z-levels being displayed.
visual_offset_x : float, optional
X-axis scale factor for Z-level visual offset (default: 1.0).
visual_offset_y : float, optional
Y-axis scale factor for Z-level visual offset (default: 1.0).
Returns
-------
None
Modifies the figure in place.
Notes
-----
- Cross-zone exits (containing ':') are skipped
- Vertical exits (up/down) are skipped (handled by labels)
- Bidirectional exits only draw one line (deduplication)
- Lines are gray with slight transparency for visual clarity
- Exit lines to rooms outside visible_z_levels are not drawn
"""
# Track drawn connections to avoid duplicates
# (bidirectional exits would otherwise draw twice)
drawn_pairs: set[tuple[str, str]] = set()
for room_id, room in rooms.items():
coords = room.get("coords", [0, 0, 0])
z = coords[2] if len(coords) > 2 else 0
# Skip rooms not on a visible Z-level
if z not in visible_z_levels:
continue
# Apply visual offset for stacked room separation
x1, y1 = _get_visual_coords(coords[0], coords[1], z, visual_offset_x, visual_offset_y)
# Process each exit from this room
for direction, target in room.get("exits", {}).items():
# Skip cross-zone exits (format: "zone_id:room_id")
if ":" in str(target):
continue
# Skip vertical exits (handled by labels)
if direction in ("up", "down"):
continue
# Skip exits to non-existent rooms
if target not in rooms:
continue
# Skip if we've already drawn this connection
# (handles bidirectional exits)
pair = tuple(sorted([room_id, target]))
if pair in drawn_pairs:
continue
drawn_pairs.add(pair)
# Get target room coordinates
target_room = rooms[target]
target_coords = target_room.get("coords", [0, 0, 0])
target_z = target_coords[2] if len(target_coords) > 2 else 0
# Only draw line if target is on same Z-level and visible
if target_z != z or target_z not in visible_z_levels:
continue
# Apply visual offset for stacked room separation
x2, y2 = _get_visual_coords(
target_coords[0],
target_coords[1],
target_z,
visual_offset_x,
visual_offset_y,
)
# Add line trace connecting the rooms
fig.add_trace(
go.Scatter(
x=[x1, x2],
y=[y1, y2],
mode="lines",
line={"color": "rgba(100, 100, 100, 0.6)", "width": 2},
hoverinfo="skip", # Don't show hover on lines
showlegend=False,
)
)
def _draw_vertical_exit_lines(
fig: go.Figure,
rooms: dict[str, dict],
visible_z_levels: list[int],
visual_offset_x: float = 1.0,
visual_offset_y: float = 1.0,
) -> None:
"""Draw dashed lines for vertical (up/down) exits.
Vertical exits connect rooms on different Z-levels that often share
the same X,Y coordinates. We render a dashed connector between the
visually offset positions so the relationship remains visible in the
2D map without overwhelming the cardinal exit lines.
"""
drawn_pairs: set[tuple[str, str]] = set()
for room_id, room in rooms.items():
coords = room.get("coords", [0, 0, 0])
z = coords[2] if len(coords) > 2 else 0
if z not in visible_z_levels:
continue
x1, y1 = _get_visual_coords(coords[0], coords[1], z, visual_offset_x, visual_offset_y)
for direction, target in room.get("exits", {}).items():
if ":" in str(target):
continue
if direction not in ("up", "down"):
continue
if target not in rooms:
continue
pair = tuple(sorted([room_id, target]))
if pair in drawn_pairs:
continue
drawn_pairs.add(pair)
target_room = rooms[target]
target_coords = target_room.get("coords", [0, 0, 0])
target_z = target_coords[2] if len(target_coords) > 2 else 0
if target_z == z or target_z not in visible_z_levels:
continue
x2, y2 = _get_visual_coords(
target_coords[0],
target_coords[1],
target_z,
visual_offset_x,
visual_offset_y,
)
fig.add_trace(
go.Scatter(
x=[x1, x2],
y=[y1, y2],
mode="lines",
line={"color": "rgba(120, 120, 120, 0.45)", "width": 1, "dash": "dot"},
hoverinfo="skip",
showlegend=False,
)
)
def _draw_zone_exit_markers(
fig: go.Figure,
rooms: dict[str, dict],
visible_z_levels: list[int],
visual_offset_x: float = 1.0,
visual_offset_y: float = 1.0,
) -> None:
"""Draw triangle markers for cross-zone exits.
Parameters
----------
fig : go.Figure
Plotly figure to add traces to (modified in place).
rooms : dict[str, dict]
All rooms in the zone, keyed by room ID.
visible_z_levels : list[int]
Z-levels being displayed.
visual_offset_x : float, optional
X-axis scale factor for Z-level visual offset (default: 1.0).
visual_offset_y : float, optional
Y-axis scale factor for Z-level visual offset (default: 1.0).
Notes
-----
- Cross-zone exits are detected by a ":" in the target value.
- Markers are offset from the room node to reduce overlap.
- Marker traces are lightweight and do not include text labels, so
room selection is unaffected (clicks on markers are ignored).
"""
x_coords: list[float] = []
y_coords: list[float] = []
symbols: list[str] = []
hover_texts: list[str] = []
for room_id, room in rooms.items():
coords = room.get("coords", [0, 0, 0])
z = coords[2] if len(coords) > 2 else 0
if z not in visible_z_levels:
continue
base_x, base_y = _get_visual_coords(
coords[0],
coords[1],
z,
visual_offset_x,
visual_offset_y,
)
for direction, target in room.get("exits", {}).items():
if ":" not in str(target):
continue
offset = ZONE_EXIT_MARKER_OFFSETS.get(direction)
if offset is None:
continue
x_coords.append(base_x + offset[0])
y_coords.append(base_y + offset[1])
symbols.append(ZONE_EXIT_MARKER_SYMBOLS.get(direction, "triangle-up"))
short_dir = DIRECTION_SHORT.get(direction, direction)
hover_texts.append(f"Zone exit {short_dir}→{target}")
if not x_coords:
return
fig.add_trace(
go.Scatter(
x=x_coords,
y=y_coords,
mode="markers",
marker={
"size": 10,
"color": ZONE_EXIT_MARKER_COLOR,
"symbol": symbols,
"line": {"width": 1, "color": "rgba(120, 90, 20, 0.9)"},
},
hovertext=hover_texts,
hoverinfo="text",
showlegend=False,
name="Zone exits",
)
)
def _draw_rooms_at_z_level(
fig: go.Figure,
rooms_at_level: dict[str, dict],
z_level: int,
selected_room: str | None,
visual_offset_x: float = 1.0,
visual_offset_y: float = 1.0,
) -> None:
"""
Draw all rooms at a specific Z-level as a single Scatter trace.
Renders rooms with Z-level-specific styling (size, color, border).
The selected room (if any) is colored red regardless of Z-level.
Parameters
----------
fig : go.Figure
Plotly figure to add the trace to (modified in place).
rooms_at_level : dict[str, dict]
Rooms to draw, already filtered to this Z-level.
z_level : int
The Z-level being drawn (used for styling lookup).
selected_room : str | None
ID of the selected room (will be colored red).
visual_offset_x : float, optional
X-axis scale factor for Z-level visual offset (default: 1.0).
visual_offset_y : float, optional
Y-axis scale factor for Z-level visual offset (default: 1.0).
Returns
-------
None
Modifies the figure in place.
Notes
-----
- Rooms are batched into a single Scatter trace for performance
- Styling is determined by Z_LEVEL_STYLES constant
- Selected room color overrides Z-level color
- Hover text includes Z-level information
- Room ID is stored in text field for click handling
"""
# Early exit if no rooms at this level
if not rooms_at_level:
return
# Get visual styling for this Z-level (default to ground if unknown)
style = Z_LEVEL_STYLES.get(z_level, Z_LEVEL_STYLES[0])
# Prepare arrays for batch rendering
x_coords: list[float] = []
y_coords: list[float] = []
labels: list[str] = []
colors: list[str] = []
hover_texts: list[str] = []
for room_id, room in rooms_at_level.items():
coords = room.get("coords", [0, 0, 0])
# Apply visual offset based on Z-level so stacked rooms don't overlap
visual_x, visual_y = _get_visual_coords(
coords[0],
coords[1],
z_level,
visual_offset_x,
visual_offset_y,
)
x_coords.append(visual_x)
y_coords.append(visual_y)
# Room ID is used as the label (displayed above node)
# and stored for click handling
labels.append(room_id)
# Color: red for selected, otherwise use Z-level style
if room_id == selected_room:
colors.append(SELECTED_ROOM_COLOR)
else:
colors.append(style["color"])
# Build hover text with room details including Z-level
name = room.get("name", room_id)
exits = ", ".join(room.get("exits", {}).keys()) or "none"
hover_texts.append(
f"<b>{name}</b><br>" f"ID: {room_id}<br>" f"Z-level: {z_level}<br>" f"Exits: {exits}"
)
# Add all rooms at this Z-level as a single Scatter trace
fig.add_trace(
go.Scatter(
x=x_coords,
y=y_coords,
mode="markers+text",
marker={
"size": style["size"],
"color": colors,
"line": {
"width": style["border_width"],
"color": style["border_color"],
},
},
text=labels, # Room IDs for display and click handling
textposition="top center",
textfont={"size": 10},
hovertext=hover_texts,
hoverinfo="text",
showlegend=False,
name=style["label"], # For potential legend use
)
)
def _add_vertical_exit_labels(
fig: go.Figure,
stacked_positions: dict[tuple[int, int], list[tuple[str, int]]],
rooms: dict[str, dict],
) -> None:
"""
Add "U", "D", or "U/D" labels near stacked rooms with vertical exits.
For positions where rooms are stacked (same X,Y, different Z), checks
if any room has up or down exits. If so, adds a text annotation to
indicate the vertical connection.
Parameters
----------
fig : go.Figure
Plotly figure to add annotations to (modified in place).
stacked_positions : dict[tuple[int, int], list[tuple[str, int]]]
Mapping of (x, y) to list of (room_id, z_level) at that position.
Only includes positions with 2+ rooms.
rooms : dict[str, dict]
All rooms in the zone (for checking exit data).
Returns
-------
None
Modifies the figure in place by adding annotations.
Label Logic
-----------
- **"U/D"**: At least one room has "up" AND at least one has "down"
- **"U"**: At least one room has "up", none have "down"
- **"D"**: At least one room has "down", none have "up"
- **No label**: No rooms at this position have vertical exits
Notes
-----
- Labels are offset to the lower-right of the room center
- Small semi-transparent background for readability
- Only shown when actual up/down exits exist (not just stacking)
"""
for (x, y), room_list in stacked_positions.items():
# Check which vertical exits exist at this position
has_up = False
has_down = False
for room_id, _z_level in room_list:
room = rooms.get(room_id, {})
exits = room.get("exits", {})
if "up" in exits:
has_up = True
if "down" in exits:
has_down = True
# Skip if no vertical exits at this position
if not has_up and not has_down:
continue
# Determine label text based on which exits exist
if has_up and has_down:
label = "U/D"
elif has_up:
label = "U"
else:
label = "D"
# Position label offset from room center to avoid overlap
# Offset to lower-right where it's less likely to conflict
fig.add_annotation(
x=x + 0.8,
y=y - 0.8,
text=label,
showarrow=False,
font={
"size": 9,
"color": "rgba(80, 80, 80, 1)",
},
bgcolor="rgba(255, 255, 255, 0.8)",
bordercolor="rgba(100, 100, 100, 0.5)",
borderwidth=1,
borderpad=2,
)