Render world map overview as a screen-space starfield and rename it to 世界地图.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 11:02:23 +08:00
parent 8444d85005
commit fdab188cc1
11 changed files with 470 additions and 67 deletions

View file

@ -34,7 +34,9 @@ def _is_world_flat(view: TopoView) -> bool:
filt = dict(view.filter or {})
if filt.get("world_flat"):
return True
return str(view.name or "").strip() == "完整世界地图"
from .ume_topology_world import is_world_flat_view_name
return is_world_flat_view_name(str(view.name or "").strip())
def primary_canvas_view(db: Session, folder_id: str) -> TopoView | None:

View file

@ -302,6 +302,13 @@ class ViewEdgeOut(BaseModel):
discovered_at: datetime | None = None
class WorldScatterPointOut(BaseModel):
"""Lightweight display-space point for world-map starfield (no labels/edges)."""
x: float = 0.0
y: float = 0.0
class WorldTransformOut(BaseModel):
"""Flat world map: display = (world - origin) * scale. scale is 1.0 (1:1)."""
@ -314,6 +321,8 @@ class WorldTransformOut(BaseModel):
full_max_y: float = 0.0
total: int = 0
lod: str = "overview"
# Dock ME count when fabric world coords are empty — UI heal hint.
dock_me_count: int = 0
class TopologyViewGraphOut(BaseModel):
@ -324,6 +333,8 @@ class TopologyViewGraphOut(BaseModel):
truncate_reason: str = ""
outside_peers: list[dict[str, str]] = Field(default_factory=list)
world_transform: WorldTransformOut | None = None
# Overview starfield: all (or capped) display coords; RF nodes stay empty far out.
scatter: list[WorldScatterPointOut] = Field(default_factory=list)
class ViewPopulateOut(BaseModel):

View file

@ -20,12 +20,19 @@ WORLD_DRILL_FOLDER_NAME = "World"
WORLD_FOLDER_REF = "ume:world"
WORLD_DRILL_REF = "ume:world:drill"
WORLD_VIEW_NAME = "World"
WORLD_FLAT_VIEW_NAME = "完整世界地图"
WORLD_FLAT_VIEW_NAME = "世界地图"
# Pre-rename label kept so existing TopoView rows still match.
WORLD_FLAT_VIEW_NAME_LEGACY = "完整世界地图"
# Drill-down root: children of MD (root SBNs + rare direct MEs).
WORLD_LEVEL_FILTER = {"ume_level": True, "parent": "md"}
WORLD_FLAT_FILTER = {"world_flat": True}
def is_world_flat_view_name(name: str | None) -> bool:
n = str(name or "").strip()
return n == WORLD_FLAT_VIEW_NAME or n == WORLD_FLAT_VIEW_NAME_LEGACY
def _filt(view: TopoView | None) -> dict[str, Any]:
if view is None:
return {}
@ -40,7 +47,7 @@ def is_ume_level_view(view: TopoView | None) -> bool:
def is_world_flat_view(view: TopoView | None) -> bool:
filt = _filt(view)
return bool(filt.get("world_flat")) or str(getattr(view, "name", "") or "") == WORLD_FLAT_VIEW_NAME
return bool(filt.get("world_flat")) or is_world_flat_view_name(str(getattr(view, "name", "") or ""))
def is_ume_world_container(folder: TopoFolder | None) -> bool:
@ -326,7 +333,7 @@ def ensure_ume_world_and_sbn_folders(db: Session) -> dict[str, Any]:
UME World (nav only — hex browse)
├─ World (unique L2 folder ume:world:drill — SBNs hang here)
│ └─ <root SBNs…>
└─ 完整世界地图 (flat view — only when rule 2A holds)
└─ 世界地图 (flat view — only when rule 2A holds)
"""
now = _utcnow()
stats: dict[str, Any] = {

View file

@ -21,6 +21,7 @@ from .topology_schemas import (
TopologyViewGraphOut,
ViewEdgeOut,
ViewNodeOut,
WorldScatterPointOut,
WorldTransformOut,
)
from .topology_views_tree import _get_view_or_404, _view_out
@ -34,10 +35,12 @@ from .ume_topology_world import (
# Browser-safe caps. Flat world used to ship 2000 RF nodes (~1MB JSON) and freeze
# the UI (fitView never settles; workbench feels stuck). Keep membership graphs at
# the shared hard cap; flat overview/detail use LOD-specific caps.
# the shared hard cap; flat overview uses a lightweight scatter starfield (all
# coords) while detail uses viewport RF nodes.
WORLD_NODE_SOFT_CAP = VIEW_GRAPH_NODE_HARD_CAP
WORLD_FLAT_NODE_SOFT_CAP = 400 # legacy alias → detail
WORLD_FLAT_OVERVIEW_CAP = 1500
WORLD_FLAT_OVERVIEW_CAP = 1500 # legacy RF sample; overview now prefers scatter
WORLD_FLAT_SCATTER_CAP = 40000
# Close-up must be able to show a whole large SBN like the region canvas (≤ hard cap).
WORLD_FLAT_DETAIL_CAP = VIEW_GRAPH_NODE_HARD_CAP
# Viewport span (display/world units) at/under which we prefer a full region layout
@ -481,7 +484,7 @@ def get_flat_view_graph(
lod: str = "auto",
status: str = "active",
) -> TopologyViewGraphOut:
"""Flat world map with LOD: overview (global dots) or detail (viewport sample).
"""Flat world map with LOD: overview (scatter starfield) or detail (viewport RF).
Client bbox is in **display** coordinates (same as returned node x/y). Folder
auto-bbox (when only folder_id is set) stays in packed world coordinates.
@ -524,8 +527,27 @@ def get_flat_view_graph(
.one()
)
total_all = int(extent_row[4] or 0)
dock_me_count = 0
if not total_all:
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
try:
dock_me_count = int(
db.query(func.count(UmeTopoNode.node_id))
.filter(UmeTopoNode.node_type == "TOPO_NODE_ME")
.scalar()
or 0
)
except Exception:
dock_me_count = 0
return TopologyViewGraphOut(
view=_view_out(view),
nodes=[],
edges=[],
world_transform=WorldTransformOut(
total=0,
lod=str(lod or "overview").strip().lower() or "overview",
dock_me_count=dock_me_count,
),
)
full_min_x = float(extent_row[0])
full_max_x = float(extent_row[1])
@ -549,6 +571,54 @@ def get_flat_view_graph(
if lod_norm == "auto":
lod_norm = "detail" if client_bbox else "overview"
def _world_transform(*, total: int | None = None) -> WorldTransformOut:
return WorldTransformOut(
origin_x=full_min_x,
origin_y=full_min_y,
scale=scale,
full_min_x=full_min_x,
full_max_x=full_max_x,
full_min_y=full_min_y,
full_max_y=full_max_y,
total=int(total if total is not None else total_all),
lod=lod_norm,
dock_me_count=0,
)
# Overview: lightweight starfield only (no RF nodes / edges). Screen-space
# canvas draws these so fitView zoom no longer shrinks dots to sub-pixels.
if lod_norm == "overview" and not sbn_id.strip():
scatter_q = db.query(TopoFabricNode.world_x, TopoFabricNode.world_y).filter(
TopoFabricNode.world_x.isnot(None),
TopoFabricNode.world_y.isnot(None),
)
if region_folder_ids is not None:
scatter_q = scatter_q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids)))
rows = (
scatter_q.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc())
.limit(WORLD_FLAT_SCATTER_CAP + 1)
.all()
)
truncated = len(rows) > WORLD_FLAT_SCATTER_CAP
if truncated:
rows = rows[:WORLD_FLAT_SCATTER_CAP]
scatter = [
WorldScatterPointOut(
x=(float(wx or 0) - full_min_x) * scale,
y=(float(wy or 0) - full_min_y) * scale,
)
for wx, wy in rows
]
return TopologyViewGraphOut(
view=_view_out(view),
nodes=[],
edges=[],
truncated=truncated,
truncate_reason="too_many_scatter_points" if truncated else "",
world_transform=_world_transform(total=total_all),
scatter=scatter,
)
if client_bbox:
# Display == world - origin; pad detail viewport so pan feels continuous.
pad = 0.2 if lod_norm == "detail" else 0.0
@ -760,15 +830,6 @@ def get_flat_view_graph(
edges=edges_out,
truncated=truncated,
truncate_reason=reason,
world_transform=WorldTransformOut(
origin_x=full_min_x,
origin_y=full_min_y,
scale=scale,
full_min_x=full_min_x,
full_max_x=full_max_x,
full_min_y=full_min_y,
full_max_y=full_max_y,
total=total_all,
lod=lod_norm,
),
world_transform=_world_transform(),
scatter=[],
)