| """World map visualization tab using pyvis + iframe for reliable rendering.""" |
|
|
| import html as html_module |
| import gradio as gr |
|
|
| from core.world import WorldGraph |
| from ui.i18n import t |
|
|
|
|
| def _generate_map_html() -> str: |
| """Generate the interactive world map as an iframe with srcdoc.""" |
| world = WorldGraph() |
| vis_data = world.to_vis_data() |
|
|
| if not vis_data["nodes"]: |
| return f""" |
| <div style="text-align:center; padding:80px 20px; color:#888; font-family:Georgia,serif;"> |
| <div style="margin-bottom:16px;"></div> |
| <h2 style="color:#b8a9ff; margin-bottom:8px;">{t('map_empty_title')}</h2> |
| <p style="color:#666; font-size:14px;">{t('map_empty_desc')}</p> |
| </div> |
| """ |
|
|
| try: |
| from pyvis.network import Network |
|
|
| net = Network( |
| height="600px", |
| width="100%", |
| bgcolor="#0f0f1a", |
| font_color="#e0e0e0", |
| directed=True, |
| ) |
|
|
| net.barnes_hut( |
| gravity=-5000, |
| central_gravity=0.3, |
| spring_length=200, |
| spring_strength=0.05, |
| damping=0.09, |
| ) |
|
|
| NODE_STYLES = { |
| "location": {"color": "#4a9eff", "shape": "dot", "size": 35, "border": "#2d7dd2"}, |
| "npc": {"color": "#50c878", "shape": "diamond", "size": 25, "border": "#3da35d"}, |
| "quest": {"color": "#ffd700", "shape": "star", "size": 25, "border": "#ccac00"}, |
| "item": {"color": "#ff6b6b", "shape": "triangle", "size": 20, "border": "#cc5555"}, |
| "creature": {"color": "#ff9f43", "shape": "triangleDown", "size": 20, "border": "#cc7700"}, |
| "dream": {"color": "#9b59b6", "shape": "square", "size": 15, "border": "#7d3c98"}, |
| } |
|
|
| for node in vis_data["nodes"]: |
| style = NODE_STYLES.get(node["type"], NODE_STYLES["location"]) |
| tooltip = f"<b>{node['label']}</b><br>{node.get('title', '')}" |
| net.add_node( |
| node["id"], |
| label=node["label"], |
| color={ |
| "background": style["color"], |
| "border": style["border"], |
| "highlight": {"background": "#ffffff", "border": style["color"]}, |
| }, |
| shape=style["shape"], |
| size=style["size"], |
| title=tooltip, |
| borderWidth=2, |
| shadow=True, |
| ) |
|
|
| EDGE_COLORS = { |
| "resides_at": "#50c878", |
| "located_at": "#4a9eff", |
| "given_by": "#ffd700", |
| "from_dream": "#9b59b6", |
| "connects_to": "#666666", |
| "related_to": "#888888", |
| } |
|
|
| for edge in vis_data["edges"]: |
| relation = edge.get("label", "") |
| color = EDGE_COLORS.get(relation, "#555555") |
| net.add_edge( |
| edge["from"], |
| edge["to"], |
| label=relation, |
| color={"color": color, "highlight": "#ffffff"}, |
| width=1.5, |
| arrows={"to": {"enabled": True, "scaleFactor": 0.5}}, |
| smooth={"type": "curvedCW", "roundness": 0.2}, |
| ) |
|
|
| |
| map_html = net.generate_html() |
|
|
| |
| dark_css = """ |
| <style> |
| body { background-color: #0f0f1a !important; margin: 0; overflow: hidden; } |
| #mynetwork { border: 1px solid #333366; border-radius: 12px; } |
| </style> |
| """ |
| map_html = map_html.replace("</head>", dark_css + "</head>") |
|
|
| |
| escaped = html_module.escape(map_html) |
|
|
| stats = world.stats() |
|
|
| return f""" |
| <div style="margin-bottom:8px;"> |
| <div style="display:flex; gap:16px; padding:10px; justify-content:center; |
| background:#16162a; border-radius:8px; font-size:13px; border:1px solid #2a2a4a;"> |
| <span style="color:#4a9eff;">{t('map_location')}</span> |
| <span style="color:#50c878;">{t('map_npc')}</span> |
| <span style="color:#ffd700;">{t('map_quest')}</span> |
| <span style="color:#ff6b6b;">{t('map_item')}</span> |
| <span style="color:#9b59b6;">{t('map_dream')}</span> |
| </div> |
| </div> |
| <iframe |
| srcdoc="{escaped}" |
| style="width:100%; height:600px; border:1px solid #333366; border-radius:12px; background:#0f0f1a;" |
| sandbox="allow-scripts allow-same-origin" |
| ></iframe> |
| <div style="text-align:center; padding:6px; color:#555; font-size:12px;"> |
| {stats['total_nodes']} {t('map_nodes')} · {stats['total_edges']} {t('map_connections')} |
| </div> |
| """ |
|
|
| except Exception as e: |
| stats = world.stats() |
| by_type = stats.get("by_type", {}) |
| return f""" |
| <div style="text-align:center; padding:40px; color:#888;"> |
| <div style="margin-bottom:12px;"></div> |
| <h2>{t('map_overview')}</h2> |
| <p>{t('map_location')}: {by_type.get('location', 0)} |
| | {t('map_npc')}: {by_type.get('npc', 0)} |
| | {t('map_quest')}: {by_type.get('quest', 0)}</p> |
| <p style="color:#ff6b6b;">{t('map_render_error')}: {e}</p> |
| </div> |
| """ |
|
|
|
|
| def create_map_tab() -> gr.HTML: |
| """Create the world map tab UI.""" |
| map_display = gr.HTML(value=_generate_map_html()) |
| refresh_btn = gr.Button(t("map_refresh"), variant="secondary") |
| refresh_btn.click(fn=_generate_map_html, outputs=[map_display]) |
| return map_display |
|
|