Compare commits

..

1 Commits
dev ... dev

Author SHA1 Message Date
linkong
1d153c2667 release: bump version to 0.76.20 2026-11-10 17:52:24 +08:00
36 changed files with 1560 additions and 640 deletions

View File

@@ -1 +1 @@
0.76.19
0.76.20

View File

@@ -109,6 +109,7 @@ SATELLITE_CACHE_POLICY = EarthLayerCachePolicy(
max_features=25000,
max_bytes=32 * BYTES_PER_MIB,
)
SATELLITE_QUERY_BATCH_SIZE = 500
COMPUTE_CENTER_CACHE_POLICY = EarthLayerCachePolicy(
COMPUTE_CENTER_CACHE_FRESH_SECONDS,
COMPUTE_CENTER_CACHE_STALE_SECONDS,
@@ -1860,21 +1861,39 @@ async def _build_satellites_geojson(
limit: int | None,
db: AsyncSession,
) -> dict[str, Any]:
records = await _load_current_or_latest_task_data(
db,
"celestrak_tle",
exclude_unknown_name=True,
limit=limit,
)
features: list[dict[str, Any]] = []
source = "celestrak_tle"
for current_only in (True, False):
if current_only:
stmt = _current_collected_data_stmt(source)
else:
task_id = await _latest_task_id_for_source(db, source, exclude_unknown_name=True)
if task_id is None:
break
stmt = (
select(CollectedData)
.where(CollectedData.source == source, CollectedData.task_id == task_id)
.order_by(CollectedData.id.desc())
)
stmt = stmt.where(CollectedData.name != "Unknown")
if limit is not None:
stmt = stmt.limit(limit)
if not records:
return {"type": "FeatureCollection", "features": [], "count": 0}
# Release each ORM batch while retaining the complete, ordered display payload.
records = await db.stream_scalars(
stmt, execution_options={"yield_per": SATELLITE_QUERY_BATCH_SIZE}
)
found_records = False
try:
async for batch in records.partitions(SATELLITE_QUERY_BATCH_SIZE):
found_records = True
features.extend(convert_satellite_to_geojson(batch)["features"])
finally:
await records.close()
if found_records:
break
geojson = convert_satellite_to_geojson(list(records))
return {
**geojson,
"count": len(geojson.get("features", [])),
}
return {"type": "FeatureCollection", "features": features, "count": len(features)}
@router.get("/geo/supercomputers")

View File

@@ -0,0 +1,156 @@
from collections.abc import AsyncIterator, Iterator, Sequence
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import Select, create_engine
from sqlalchemy.engine import Result, ScalarResult
from sqlalchemy.orm import Session
from app.api.v1 import visualization
from app.models.collected_data import CollectedData
class SatelliteRows:
def __init__(self, result: ScalarResult[CollectedData]) -> None:
self.result = result
self.closed = False
async def partitions(self, size: int) -> AsyncIterator[Sequence[CollectedData]]:
for batch in self.result.partitions(size):
yield batch
async def close(self) -> None:
self.result.close()
self.closed = True
class SatelliteSession:
def __init__(self, session: Session) -> None:
self.session = session
self.streams: list[SatelliteRows] = []
async def stream_scalars(
self, statement: Select, *, execution_options: dict[str, int]
) -> SatelliteRows:
rows = SatelliteRows(self.session.scalars(statement, execution_options=execution_options))
self.streams.append(rows)
return rows
async def execute(self, statement: Select) -> Result:
return self.session.execute(statement)
@pytest.fixture
def satellite_db() -> Iterator[SatelliteSession]:
engine = create_engine("sqlite://")
CollectedData.__table__.create(engine)
with Session(engine) as session:
yield SatelliteSession(session)
engine.dispose()
def satellite(
record_id: int, *, current: bool = True, task_id: int = 1,
name: str | None = None, source: str = "celestrak_tle",
) -> CollectedData:
return CollectedData(
id=record_id,
source=source,
source_id=str(record_id),
data_type="satellite_tle",
name=name or f"SAT-{record_id}",
is_current=current,
task_id=task_id,
collected_at=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(days=task_id),
extra_data={
"norad_cat_id": record_id + 40000,
"tle_line1": f"line-1-{record_id}",
"tle_line2": f"line-2-{record_id}",
},
)
@pytest.mark.asyncio
@pytest.mark.parametrize("limit", [None, 1, 501])
async def test_streamed_satellites_preserve_all_rows_order_tle_and_limit(
satellite_db: SatelliteSession, limit: int | None,
) -> None:
satellite_db.session.add_all([satellite(record_id) for record_id in range(1, 1004)])
satellite_db.session.add_all([
satellite(2000, name="Unknown"),
satellite(3000, current=False),
satellite(4000, source="other"),
])
satellite_db.session.commit()
payload = await visualization._build_satellites_geojson(limit=limit, db=satellite_db)
expected_ids = list(range(1003, 0, -1))[:limit]
assert payload["count"] == len(expected_ids)
assert [feature["properties"]["id"] for feature in payload["features"]] == expected_ids
assert [feature["properties"]["tle_line1"] for feature in payload["features"]] == [
f"line-1-{record_id}" for record_id in expected_ids
]
assert len(satellite_db.streams) == 1
assert satellite_db.streams[0].closed
@pytest.mark.asyncio
async def test_streamed_satellites_fall_back_to_latest_non_unknown_task(
satellite_db: SatelliteSession,
) -> None:
satellite_db.session.add_all([
satellite(1, current=False, task_id=1),
satellite(2, current=False, task_id=2),
satellite(3, current=False, task_id=2),
satellite(4, current=False, task_id=3, name="Unknown"),
])
satellite_db.session.commit()
payload = await visualization._build_satellites_geojson(limit=1, db=satellite_db)
assert payload["count"] == 1
assert payload["features"][0]["properties"]["id"] == 3
assert all(stream.closed for stream in satellite_db.streams)
@pytest.mark.asyncio
async def test_unrenderable_current_rows_do_not_restore_historical_satellites(
satellite_db: SatelliteSession,
) -> None:
current = satellite(2)
current.extra_data = {}
satellite_db.session.add_all([satellite(1, current=False), current])
satellite_db.session.commit()
payload = await visualization._build_satellites_geojson(limit=None, db=satellite_db)
assert payload == {"type": "FeatureCollection", "features": [], "count": 0}
assert len(satellite_db.streams) == 1
@pytest.mark.asyncio
async def test_empty_satellite_catalog_returns_empty_collection(
satellite_db: SatelliteSession,
) -> None:
payload = await visualization._build_satellites_geojson(limit=None, db=satellite_db)
assert payload == {"type": "FeatureCollection", "features": [], "count": 0}
assert satellite_db.streams[0].closed
@pytest.mark.asyncio
async def test_satellite_cursor_closes_if_conversion_fails(
satellite_db: SatelliteSession, monkeypatch: pytest.MonkeyPatch,
) -> None:
satellite_db.session.add(satellite(1))
satellite_db.session.commit()
def fail_conversion(_records: Sequence[CollectedData]) -> None:
raise ValueError("invalid orbital data")
monkeypatch.setattr(visualization, "convert_satellite_to_geojson", fail_conversion)
with pytest.raises(ValueError, match="invalid orbital data"):
await visualization._build_satellites_geojson(limit=None, db=satellite_db)
assert satellite_db.streams[0].closed

View File

@@ -8,6 +8,22 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.76.20] — 2026-11-10
Released: 2026-11-10
### Highlights
- 统一 Earth 对象详情、区域新闻与算力卡片的两侧布局,改善连接线、悬停展开和拖放稳定性,并降低卫星数据查询的 ORM 内存开销。
### Added / Fixed / Improved
- 共享布局仲裁器分配左右两侧与纵向走廊,使用换侧、换序滞后减少卡片争位;手动固定优先,空间受限时保留完整卡片。
- 地理原始投影与平滑连线锚点分离,固定状态依据真实背面深度解除;连接线选择合适的入卡边并平衡折线段,减少倒钩与抖动。
- 修复新闻及算力胶囊悬停展开后的尺寸、正文和操作区显示;新闻打字在悬停展开时继续,统一选择框与标签移除样式。
- 连接测试图标补充明暗主题的悬停和按下反馈;卫星查询以 500 条 ORM 批次转换,保留当前/最新任务回退、完整 TLE、顺序与 limit 语义。
- 清理旧布局引用和无效计算,同步中英文布局、渲染及使用文档,增加布局仲裁与卫星分批查询回归。
---
## [0.76.19] — 2026-11-09
Released: 2026-11-09

View File

@@ -416,13 +416,13 @@ For console structure, see:
Earth's existing Roboto Condensed face is now served locally from `assets/fonts/`, with its source and license in that directory and the shared declaration in `css/fonts.css`. The remote Google Fonts stylesheet no longer blocks the page; font family and weights are preserved.
`spatial-callouts.js` provides geographic projection and shared scopes. `spatial-callout-controller.js` selects expanded compute and news cards independently, prioritizing dragging or manually pinned cards, then the current focus and distance from the view center. `callout-limits.js` stores independent limits under `planet.earth.callout-limits.v1`; controls offer 1–3 of each, defaulting to one. `earth:callout-limits-change` applies changes immediately. Other cards become capsules following geographic anchors; hover temporarily expands them and click changes focus. Expanded cards occupy left/right stages with news above compute, including a destination for every card when a side holds more than two; peripheral overlap is allowed when space is limited. Rear capsules dim and shrink. Manual drops remain pinned until their region moves deep behind the globe. Dimensions and reading state remain separate from business coordinates, with shared drag foreground priority across both types.
`spatial-callouts.js` provides geographic projection and shared scopes; `spatial-callout-controller.js` selects expanded compute and news cards independently. `callout-limits.js` persists separate 1–3 card limits under `planet.earth.callout-limits.v1`, defaulting to one each. Object details, news and compute register with shared `callout-stage-arbitrator.js`. Real anchors determine the flank with 15px side-change hysteresis. Vertical anchor positions determine order; initial near-ties prefer selection, news, then compute, and later swaps require more than 60px separation. Pinned rectangles reserve their intervals before automatic cards receive vertical corridors. Limited capacity allows peripheral overlap while retaining full cards. Capsules and temporary hover expansions neither occupy stage slots nor become dynamic sibling obstacles. Hovering any card and the following 2.5 seconds pause automatic focus switching; existing cards continue smooth following inside their corridors. Dimensions and reading state remain separate from business coordinates.
Object presentation is shared through `PresentationController` and the new object-callout anchor/layout path. A click on a BGP, compute, vessel, satellite, or cable object draws the connector before placing the adjacent card. The source anchor begins at the actual click or original region label, then follows a visible highlight geometry sample as rotation approaches occlusion. The stable `layoutReferencePoint` is derived from the actual connector exit direction, so `packDirectionalEdgeCallouts` keeps cards on the corresponding screen edge and leaves the globe's center open; a card may move inward only when that edge lane is unavailable, and it may never cover its own connector exit point. The bounded joint assignment considers up to 24 beam states and 32 candidates, avoids existing HUD rectangles, then allows at most three safe exchanges to reduce directional crossings; the previous solver remains a fallback and the work is not a full relayout every frame. A slow source-direction gate also checks whether the actual card target reverses more than 90 degrees from the source before committing a change. Manual drag and hover locks take priority. The source point itself remains a per-frame visual connection target; fixed objects track live positions while cards remain draggable. Rear-side objects retain a globe-outline direction cue, a subdued connector, and a hollow source point; these are orientation guides rather than geographic points. Country candidates are capped at 96 with a hard limit of 128 and cached, so GeoJSON is not scanned every frame. Compute cards keep their original point independent from dynamic source-point movement, and the shared card endpoint slides along the obstacle perimeter. Cable presentation prefers the true ray-hit local point, then samples highlighted geometry by `cableId`. Mobile keeps the existing mini popup and opens drawer details. A stale asynchronous presentation must never dismiss a newer selection.
Object presentation uses `PresentationController`, `object-callout-anchor.js` and `object-callout-layout.js`. Selecting BGP, compute, vessel, satellite or cable geometry draws a connector before showing details. `rawPoint` and `rawVisibility` retain the real projection while a separate smoothed source follows visible highlight geometry. Stage assignment uses the raw projection to prevent card-to-anchor feedback jitter. Rear objects retain a globe-rim direction cue, which is never treated as a real coordinate or depth. Object details share corridors with regional cards and follow with faster horizontal than vertical damping; hover does not freeze this motion. Manual drops take priority and release only when a rear object has confirmed raw visibility below -0.65; missing depth never implies release. Cable anchors stay on selected geometry and satellites track NORAD identity. Connectors choose an edge facing the source, balance the two segments and connect directly when nearly aligned; dragging adjusts the perimeter endpoint automatically. Mobile retains the compact popup and full-detail drawer. Stale asynchronous presentations cannot dismiss newer selections.
`configVisible` and `cardsEnabled` are separate. Closing the configuration window with X or Escape preserves card visibility. The desktop title bar uses the news panel’s card icon and selected style, with `aria-pressed` reflecting `cardsEnabled`. Turning off cards through this button or the mobile switch releases the connectors and restores the previous map query and layer state. Configuration and source windows bind HUD typography directly so reparenting cannot enlarge their text. The main card presents the selected compute metric as a large share percentage or an absolute value with unit, observation year, historical context, and source entry. Missing comparable data is explicit; no empty trend chart is rendered without observations. Facility records and coverage remain complete in the expanded auxiliary area, where sample counts are evidence context rather than a compute value or global coverage rate. Long lists open only when needed. Mobile preserves the same single metric meaning in its accordion. Local position/size persistence remains available. Stable DOM, dimensions, and reading state survive updates, and stale responses cannot re-enable cards.
Expanded cards follow committed targets with smooth inertia, pausing during hover or keyboard reading; capsules independently follow geographic projections. Connector anchors still update each frame. `shared.calloutLineStyle` persists thin/bold preferences, synchronized from desktop/mobile controls to `body.dataset.calloutLineStyle` and consumed by shared connectors without changing business anchors.
Expanded cards continuously follow targets inside arbitrated vertical corridors; active dragging takes over placement and decorative floating remains paused. Capsules independently follow geographic projections. Hover expansion retains full dimensions, body and actions without displacing siblings. News typing can continue in either permanent or temporary hover expansion. Connector anchors update each frame. `shared.calloutLineStyle` persists thin/bold preferences through desktop/mobile controls and `body.dataset.calloutLineStyle`, without changing business anchors.
The shared `CalloutConnector` calculates polyline length from its points instead of reading `getTotalLength()` after each SVG write, avoiding repeated synchronous layout during motion. Initial draw animation is retained, so existing cruise presentations receive the same optimization.
@@ -496,11 +496,11 @@ The current interactive object continues to determine the legend mode; enabling
### Stable cards and display preferences
`callout-rest.js` retains a 450ms interaction-settle gate and subtle floating capped at 1.5px. Hover, keyboard reading, dragging and reduced-motion pause the float. Decorative offsets never enter persistence or placement solving, and the central boundary remains protected.
`callout-rest.js` retains its 450ms gate for non-urgent layout commits. Decorative floating of expanded cards stays paused; shared stage corridors and continuous following own placement. Decorative offsets never enter persistence or corridor assignment.
Display settings add `shared.satelliteLabelsEnabled` (true by default), `shared.satelliteLabelMinZoom` (2.5–5, default 4) and `shared.skyBrightness` (0–2, default 1). Desktop/mobile controls, reload and reset share runtime state. Sky brightness multiplies the sky material in linear space with its existing local sRGB output, leaving Earth exposure unchanged. Compute legend counts reflect located types. Chip and stacked SVG glyphs remain mapped to `site_type`; a GPU-only dataset does not invent other types or coordinates.
Focus state and per-type limits decide expanded cards versus capsules. One controller owns left/right stage destinations, capsule following and hover expansion. Manual pinning takes priority; reading pauses floating and automatic expanded-card movement. The older `callout-dock-motion.js` / `callout-routing.js` utilities no longer decide this presentation path.
Focus state and per-type limits decide expanded cards versus capsules. Shared stage arbitration owns flank and corridor assignment for details, news and compute, with manual pinning taking priority. Hover pauses automatic focus changes, not geographic following of existing expanded cards. The older `callout-dock-motion.js` / `callout-routing.js` utilities no longer decide this presentation path.
### Cluster badges and Spiderfy
@@ -527,7 +527,7 @@ The news cruise adapter completes `presentItem` only after typing finishes, so `
Compute, regional news and normal/cruise detail cards share `--hud-detail-card-width` and the same `info-panel.css` typography, metadata and summary rules. Compute cards, charts, icons, spacing and controls use root `--hud-scale`; manually resized inline pixel dimensions are normalized to a scale expression. SVG viewBox units scale with the chart viewport rather than receiving a second multiplier. Europe uses a small EU monogram with the title's font metrics, avoiding Windows flag-emoji fallback alignment.
The configuration menu retains its independent 340px width and original height limit rather than inheriting detail-card geometry scaling. Compute headers keep the shared HUD specification; larger primary values, charts and content spacing support the common width. Regional motion is owned by the JS tween: CSS position transitions and forced transform compositing are removed. Final left/top values align to device pixels, including decorative float, and connectors use that same displayed position. Regional cards use peripheral stowing when full content does not fit.
The configuration menu retains its independent 340px width and original height limit rather than inheriting detail-card geometry scaling. Compute headers keep the shared HUD specification; larger primary values, charts and content spacing support the common width. Regional motion is owned by the JS tween: CSS position transitions and forced transform compositing are removed. Final left/top values align to device pixels, expanded-card decorative floating remains paused, and connectors use the same displayed position. Tight space permits peripheral overlap without automatically stowing full stage cards.
Global selections are heterogeneous: satellites are GeoJSON objects, cables are lines, and only surface interaction markers carry `userData.icon_layer_id`. `markerPresentationId` generates stack identity only for that marker contract, returning null for other selections instead of dereferencing missing fields during the frame loop.
@@ -543,6 +543,8 @@ Collector cards omit the redundant activity summary while retaining observation
### Performance with multiple cards and the full satellite set
Satellite GeoJSON queries use `stream_scalars` with `SATELLITE_QUERY_BATCH_SIZE=500`, converting batches and closing the result in finally instead of retaining a complete ORM row list. Empty current records still fall back to the latest non-Unknown task. Source filtering, descending order, limit, all satellites and TLE fields retain their semantics. Final features and the cached response remain complete collections; HTTP output is not streamed and total response memory is not limited to one ORM batch.
Regional news and compute cards retain their shared dark surfaces, borders, shadows and text while avoiding per-card backdrop filtering of the changing WebGL/video scene and SVG edge distortion. `contain: layout style` limits layout propagation. Pixel-aligned positioning, card count, content, typing and float timing remain intact.
Each `CalloutConnector` SVG covers its path plus node/shadow padding rather than the entire viewport; path coordinates remain in screen space. Geographic anchors cache unchanged local coordinates, invalidate on coordinate changes, and still update world projection and front/rear visibility each frame. Obstacle tests compare rectangle coordinates directly without allocating bounds for every candidate pair. HUD obstacle measurement precedes that frame's card-style writes.

View File

@@ -297,6 +297,6 @@ The star map is decoded as sRGB. `celestial.js` uses shared `preserveTextureOutp
Satellite hover rings use the hovered satellite inclination category color, matching the point and legend. Changing the hovered object resolves its color again; position updates preserve it.
All map cards use the root HUD DPI scale. Regional and cruise news share header icon, colors, typography and padding; regional cards simplify metadata and add independent rotation controls. Expanded cards retain full content while other regions use capsules that temporarily open with the same card styling on hover. Rear capsules dim and shrink without removing business objects. Thin/bold lines share `CalloutConnector`; bold styling widens the white line, adds dark outlines and enlarges endpoints. Compute metrics, trend charts and title icons follow HUD scaling. Europe uses a compact EU monogram; other region emoji follow the same scale.
All map cards use the root HUD DPI scale. Regional and cruise news share header icon, colors, typography and padding; regional cards simplify metadata and add independent rotation controls. Expanded cards retain full content while other regions use capsules that temporarily open with the same card styling on hover. Rear capsules dim and shrink without removing business objects. Thin/bold lines share `CalloutConnector`; bold styling widens the white line, adds dark outlines and enlarges endpoints. Compute metrics, trend charts and title icons follow HUD scaling. Europe uses a compact EU monogram; other region emoji follow the same scale. Connectors choose the entrance edge facing the source and balance their two segments, joining directly when nearly aligned. Hover-expanded capsules retain full dimensions, body and actions so compact-state rules cannot clip their contents.
Compute markers use five canonical SVGs: compute-supercomputer.svg, compute-gpu-cluster.svg, compute-hdd-network.svg, compute-cloud.svg and compute-edge.svg. They retain the compute layer purple badge and existing selection behavior.

View File

@@ -105,4 +105,4 @@ The `.map-clusters` projection layer uses z-index 8 below HUD and whole-marker o
Co-located markers share a compact projection stack across categories; screen collisions never merge different locations. A selected child uses projection `z-index: 1000`, while the locked overlay for an unclustered marker uses the shared foreground `renderOrder >= 100`, so focus stays on top across layers; clearing focus restores stable ID order. The aggregate badge stays below a selected child during its opening tween. `icon_presentation_screen` lets detail connectors follow display offsets while markers retain their real anchors. Ownership changes immediately rebuild the corresponding GPU batch.
Regional news and compute cards share the `#earth-region-callouts` spatial root (z-index 20), with independent data and reading state. Within that root, expanded cards use 50, dragged foreground cards 55, hover-expanded cards 60, ordinary capsules 30 and dimmed/shrunk rear capsules 8. Per-type expanded counts persist independently. Full cards occupy the globe’s flanks, while capsules follow geographic anchors; hover temporarily expands them and click changes focus. News connectors retain representative regional anchors across story rotation. One controller excludes owned cards when measuring HUD obstacles and handles size changes. Thin/bold lines share `body.dataset.calloutLineStyle`. Mobile news cards live in the drawer without map connectors.
Regional news and compute retain the `#earth-region-callouts` root (z-index 20) and share flank/corridor assignment with object details through `callout-stage-arbitrator.js`. Expanded cards inside the regional root use 50, drag foreground 55, hover expansion 60–65, ordinary capsules 30 and dimmed/shrunk rear capsules 8. Hover capsules preserve full content and actions without becoming stage obstacles. Pinned rectangles reserve space first; compact viewports retain full stage cards. Smoothed anchors preserve raw visibility and manual pinning releases only at confirmed rear depth below -0.65. Geographic projections, connector sources and card targets remain independent; layout changes no Three.js coordinates, picking or render depth. News anchors still represent regions rather than precise events. Thin/bold styles come from `body.dataset.calloutLineStyle`; mobile cards move into the drawer without map connectors.

View File

@@ -297,7 +297,7 @@ Satellite names and NORAD identifiers appear in a limited set at 400% zoom and a
Search finds cables, landing points, satellites, compute centers, BGP events, BGP observers. Results jump to and focus the object.
Click a BGP, compute, vessel, satellite, or cable object on the globe to draw a connector from the object before revealing a nearby detail card. The card follows the source direction toward the nearest usable screen edge, leaving breathing room around the globe; it never covers its own connector exit point. As the globe rotates, the source moves smoothly toward a visible part of the highlight. Fully hidden objects retain an outline direction cue with a lighter line and hollow source point. Cards follow the connector direction smoothly and remain draggable; their connection points adjust along the edge automatically. Mobile shows a compact popup first, with full details available in the drawer. Dropdown menus and tool lists retain their existing opening behavior.
Click a BGP, compute, vessel, satellite, or cable object on the globe to draw a connector from the object before revealing a nearby detail card. The card follows the source direction toward the nearest usable screen edge, leaving breathing room around the globe; it never covers its own connector exit point. As the globe rotates, the source moves smoothly toward a visible part of the highlight. Fully hidden objects retain an outline direction cue with a lighter line and hollow source point. Details, news and compute share the globe’s flanks, following smoothly within their assigned vertical space. Cards remain draggable and their connection points adjust along the edge automatically. Mobile shows a compact popup first, with full details available in the drawer. Dropdown menus and tool lists retain their existing opening behavior.
### Four regional news cards
@@ -454,7 +454,7 @@ Selecting a satellite shows an open predicted arc with highlights moving in the
Retriggering a datasource creates a new task while older failures remain in the failed group. Task Status uses a checklist icon, distinct from Refresh. CelesTrak can reuse valid recent download cache; each new collection attempt still gets its own task and updates collected data.
Display → Satellites → Close-up labels defaults on; turning it off hides satellite map names while retaining detail cards. The zoom-threshold slider below the switch ranges from 250% to 500% (default 400%); labels appear at or above the selected zoom and hide below it. The count slider sets a saved limit of 5–36 labels (default 36); occlusion or limited space may result in fewer visible labels. Sky brightness ranges from 0–200%, defaulting to 100%. Both choices persist. Compute region cards wait for drag/globe motion and new-card placement to settle before necessary avoidance. Otherwise they float subtly around stable positions and pause during interaction. Compute legend category counts describe currently located markers.
Display → Satellites → Close-up labels defaults on; turning it off hides satellite map names while retaining detail cards. The zoom-threshold slider below the switch ranges from 250% to 500% (default 400%); labels appear at or above the selected zoom and hide below it. The count slider sets a saved limit of 5–36 labels (default 36); occlusion or limited space may result in fewer visible labels. Sky brightness ranges from 0–200%, defaulting to 100%. Both choices persist. Expanded regional cards follow smoothly within assigned vertical space without decorative floating. Hover and a short interval after leaving pause automatic region-focus changes. Capsules temporarily expose complete content and actions while existing cards continue following geographic anchors. Compute legend category counts describe currently located markers.
Surface aggregates appear as solid numbered circles. Click to expand members; only co-located or extremely close geographic markers form compact stacks at their location, with exposed clickable edges. Ordinary markers stay at their real projected positions. Selecting a rear icon brings it forward and opens its details; clearing focus restores its original position. Ordinary groups split above 250%; no aggregate badge remains at 500%, and co-located markers expand directly.

View File

@@ -61,7 +61,7 @@ After selecting an object or enabling a layer, click its legend entries to filte
Open the news panel from the top ticker and enable “Show regional news cards” to rotate separate queues for the Americas, Europe, Middle East & Africa and Asia-Pacific. Each card supports independent pause, navigation, dragging and collapsing.
Refresh keeps existing news readable until the latest results are displayed, then reports success. If it fails, follow the message and retry. Settings → Panels independently expands 1–3 compute and news cards; other regions remain available as capsules that open on hover.
Refresh keeps existing news readable until the latest results are displayed, then reports success. If it fails, follow the message and retry. Settings → Panels independently expands 1–3 compute and news cards; other regions remain available as capsules that open on hover. On desktop, hover capsules to read full content and actions; details, news and compute cards share the globe’s flanks.
## 5. Recover a Lost Password

View File

@@ -549,13 +549,13 @@ Earth 原有 Roboto Condensed 字体已随 `assets/fonts/` 本地分发,许可
`compute-topic.js` 管理参考期、口径、参与方和来源,读取后端 `compute_catalog.py` 提供的规范名称、分组与地区标签锚点。当前目录覆盖 45 个已有坐标的参与方,未知历史指标返回缺失。参与方接口不再只接受中美;区域分组不参与分母计算,坐标不能复用为设施定位。
`spatial-callouts.js` 提供地理投影与共享分组,`spatial-callout-controller.js` 为算力和新闻分别选择展开卡片,优先保留正在拖拽或手动放置的卡片,再按当前关注区域与距视图中心的距离补齐。`callout-limits.js` 在 `planet.earth.callout-limits.v1` 保存两类独立上限,界面提供 1–3 张,默认各 1 张;修改后通过 `earth:callout-limits-change` 立即重算。其余卡片使用随地理锚点移动的胶囊,悬停临时展开,点击切换关注区域。完整卡片分配到左右两侧,新闻优先在上,同侧超过两张时仍为每张分配目标位置;空间有限时允许周边重叠。背面胶囊渐淡缩小,手动拖放保留位置,区域深度进入背面时才解除固定。尺寸、阅读状态与业务坐标独立,拖动前景层级由新闻和算力共用。
`spatial-callouts.js` 提供地理投影与共享分组,`spatial-callout-controller.js` 为算力和新闻分别选择展开卡片。`callout-limits.js` 在 `planet.earth.callout-limits.v1` 保存各类 1–3 张的独立上限,默认各 1 张。物件详情、新闻与算力通过共享 `callout-stage-arbitrator.js` 注册到同一布局:按真实锚点分配左右两侧,换侧采用 15px 滞后;同侧按锚点纵向排序,初始近邻以物件详情、新闻、算力顺序解开平局,后续超过 60px 才交换上下顺序。固定卡片先占用自身区间,其余卡片分配纵向走廊;空间不足时允许周边重叠,不因容量自动收起完整卡片。胶囊与悬停临时展开不占舞台槽位,也不作为兄弟卡片的动态障碍。任一卡片悬停期间及离开后 2.5 秒暂停自动切换关注对象,已有卡片仍在其走廊内平滑跟随。尺寸和阅读状态与业务坐标保持独立。
对象展示统一通过 `PresentationController` 及新的 object-callout anchor/layout 路径。点击真实 BGP、算力中心、船只、卫星或海缆对象时,先绘制连接线,再把卡片放到对象旁边。源锚点从实际点击位置或原始区域标签开始;接近遮挡时,随着旋转自动平滑移向仍可见的高亮几何采样。`layoutReferencePoint` 取实际出线点的方向参考,`packDirectionalEdgeCallouts` 优先把卡片放到对应方向的屏幕边缘,给地球中间留空;边缘 lane 不可用时才内收,卡片不能盖住自己的出线点。有界联合分配最多使用 24 个 beam 状态和 32 个候选,避开已有 HUD,再最多三轮安全交换减少方向交叉;旧 solver 作为 fallback,不逐帧全量重排。slow 模式还检查实际卡片目标相对 source 反向超过 90° 后才提交变化;manual/hover 锁优先。source point 仍每帧更新为连线目标;固定对象跟踪动态位置,卡片仍可拖动。完全背面的对象保留球体轮廓方向提示、较淡连线和空心源点,这些是方向引导而非地理点。国家候选最多 96 条、硬上限 128,并使用缓存,不在每帧扫描 GeoJSON。算力卡继续让原始 point 独立于动态 source point,保持阅读稳定;共享卡片端接点沿障碍物周界滑移。海缆展示优先真实 ray-hit 的 local point,再按 `cableId` 采样高亮几何。移动端沿用 mini popup,点击后打开抽屉详情。旧异步 presentation 不能 dismiss 新的 selection。
对象展示统一通过 `PresentationController`、`object-callout-anchor.js` 与 `object-callout-layout.js`。点击 BGP、算力中心、船只、卫星或海缆后,先绘制连接线,再显示详情。原始投影通过 `rawPoint` 和 `rawVisibility` 保留,平滑的出线锚点另行跟随可见高亮几何;舞台分配使用原始投影,避免卡片位置反过来驱动锚点产生反馈抖动。背面对象保留球体轮廓上的方向提示,不把方向提示当作真实坐标或深度。普通物件详情和区域卡片共享走廊,详情以横向较快、纵向较缓的阻尼运动跟随,悬停不冻结这一运动。手动拖放优先,只有确认真实可见度低于 -0.65 且物件位于背面时才解除固定;缺少真实深度时不猜测。海缆沿选中几何取锚点,卫星按 NORAD 跟踪。共享连线优先选择朝向锚点的入卡边,平衡两段长度,近似同轴时直接连接;拖动卡片时接点自动沿边缘调整。移动端仍先显示精简卡,再进入完整详情抽屉。过时的异步展示不能关闭新的选择。
配置窗的 `configVisible` 与卡片的 `cardsEnabled` 分离;X/Escape 关闭配置窗并保留卡片状态。桌面标题栏的卡片按钮与新闻面板共用图标和选中样式,其 `aria-pressed` 跟随 `cardsEnabled`。通过该按钮或移动端开关停用卡片时,释放连线并恢复专题查询前的图层状态。配置和来源窗直接绑定 HUD 字号,避免拖到 `container` 后继承大号正文。主卡围绕当前选择的算力指标,显示大号百分比或带单位的绝对量、观测年份、历史上下文和来源入口。缺少可比数据时明确提示,不在没有观测值时绘制空趋势图。设施记录和覆盖信息仍完整保留在展开后的辅助区,样本数只是证据上下文,不能当作算力值或全球覆盖率。长列表仅在需要时展开;手机手风琴保持同一单一占比/绝对量语义。本地位置/尺寸保存仍可用;无变化的 DOM、尺寸、阅读状态应保留,过时响应不能重新启用卡片。
展开卡片以平滑惯性追随已提交目标,悬停和键盘阅读时暂停该移动;胶囊独立跟随地理投影。连线锚点继续逐帧更新。`shared.calloutLineStyle` 保存细线/粗线偏好,桌面和移动控件同步到 `body.dataset.calloutLineStyle`,由共享连接线读取,不改变业务锚点。
展开卡片在仲裁后的纵向走廊内连续跟随目标,只有主动拖拽接管位置,装饰性漂浮保持暂停。胶囊独立跟随地理投影;悬停临时展开保留完整尺寸、正文与操作区,不推挤兄弟卡片。新闻打字机在正式展开或悬停展开时均可继续。连线锚点逐帧更新。`shared.calloutLineStyle` 保存细线/粗线偏好,桌面与移动控件同步到 `body.dataset.calloutLineStyle`,不改变业务锚点。
共享 `CalloutConnector` 直接从折线点计算长度,不在每次写入 SVG 点后调用 `getTotalLength()`,避免多条移动连线触发反复同步布局。原有首次绘线动画保留;这一优化同时适用于原巡航展示。
@@ -629,11 +629,11 @@ Earth 由 `earth-runtime-plugin.mjs` 将本地模块合并为带内容哈希的
### 稳定卡片与显示偏好
`callout-rest.js` 保留 450ms 的动作稳定门槛与最多 1.5px 的轻微漂浮。鼠标悬停、键盘阅读、拖拽与 reduced-motion 暂停漂浮;装饰偏移不参与持久化和布局求解,中央边界始终受保护。
`callout-rest.js` 的 450ms 稳定门槛继续控制非紧急布局提交;展开卡片的装饰性漂浮暂停,位置由共享舞台走廊和连续跟随控制。装饰偏移不写入持久化,也不参与走廊分配。
显示设置新增 `shared.satelliteLabelsEnabled`(默认 true)以及 `shared.satelliteLabelMinZoom`(2.5–5,默认 4)与 `shared.skyBrightness`(0–2,默认 1)。桌面与移动控件、刷新、重置共用同一运行时状态。星空倍率在线性空间作用于星空材质,沿用局部 sRGB 输出,不修改地球曝光。算力图例显示实际已定位分类数量,芯片与堆叠 SVG 继续按 `site_type` 区分;数据集中只有 GPU 时不伪造其他类型或坐标。
展开卡片与胶囊由关注状态和各自数量上限控制。左右两侧舞台位置、胶囊跟随与悬停展开共享控制器;手动固定优先,阅读时暂停漂浮及展开卡片的自动移动。旧 `callout-dock-motion.js` / `callout-routing.js` 工具不再决定这一展示路径。
展开卡片与胶囊由关注状态和各类数量上限控制;共享舞台仲裁器负责详情、新闻和算力的左右位置及纵向走廊,手动固定优先。悬停暂停自动换关注对象,不暂停已有展开卡片的地理跟随。旧 `callout-dock-motion.js` / `callout-routing.js` 工具不再决定这一展示路径。
### 聚合圆标与 Spiderfy
@@ -660,7 +660,7 @@ Earth 由 `earth-runtime-plugin.mjs` 将本地模块合并为带内容哈希的
算力卡片、新闻区域卡片与普通/巡航详情卡共用 `--hud-detail-card-width`,标题、正文、元信息及摘要直接使用同一套 `info-panel.css` 规则。算力卡片、趋势图、图标、间距和控件尺寸均随根节点 `--hud-scale` 缩放;手动调整大小生成的内联像素尺寸会转换为相同比例的表达式。SVG 图表内部的 viewBox 单位随图表整体缩放,避免重复乘倍率。欧洲标题使用与正文同字体基线的小型 EU 字标,避免 Windows 将旗帜 emoji 展示为基线不同的字母。
配置菜单保留独立的 340px 宽度和原有高度上限,不套用详情卡的宽高缩放。算力卡片标题保持共享 HUD 规格,内容区用更大的主数值、趋势图和说明间距支撑统一宽度。区域卡片的移动由 JS 时间补间独占,取消通用 HUD 的 CSS 位移过渡和强制 transform 合成层;最终 left/top 对齐设备像素,漂浮继续保留,连线使用同一显示位置。空间不足允许周边少量遮挡,卡片内容保持显示。
配置菜单保留独立的 340px 宽度和原有高度上限,不套用详情卡的宽高缩放。算力卡片标题保持共享 HUD 规格,内容区用更大的主数值、趋势图和说明间距支撑统一宽度。区域卡片的移动由 JS 时间补间独占,取消通用 HUD 的 CSS 位移过渡和强制 transform 合成层;最终 left/top 对齐设备像素,展开卡片暂停装饰漂浮,连线使用同一显示位置。空间不足允许周边少量遮挡,卡片内容保持显示。
全局选中对象是异构数据:卫星为 GeoJSON 对象,海缆是线对象,只有地表交互标记带 `userData.icon_layer_id`。`markerPresentationId` 仅为带该标识的对象生成堆叠身份,其余返回空,防止选中卫星时在帧内访问不存在的字段。
@@ -676,6 +676,8 @@ BGP 观测站移除重复的日常活跃度汇总,保留观测计数、事件
### 多卡片与全量卫星的性能路径
卫星 GeoJSON 查询使用 `stream_scalars`,按 `SATELLITE_QUERY_BATCH_SIZE=500` 分批转换并在 finally 中关闭结果集,避免同时保留完整 ORM 记录列表。当前记录为空时仍回退到最新非 Unknown 任务;来源、倒序、limit、全部卫星和 TLE 字段保持原语义。最终 features 与缓存响应仍是完整集合,这不是流式 HTTP 输出,也不保证整个响应只占一批记录的内存。
区域新闻与算力卡片保留共享深色表面、边框、阴影和文字,取消每张卡片对动态 WebGL/视频背景的实时 backdrop 滤镜及边缘 SVG 扭曲滤镜,并用 `contain: layout style` 限定布局影响。卡片仍按设备像素定位,未改动数量、内容、打字机或漂浮时序。
`CalloutConnector` 的 SVG 视口只覆盖自身路径及节点/阴影留白,路径仍使用原始屏幕坐标;不再为每条短连线保留整屏 SVG 面积。地理锚点缓存不变的本地坐标,坐标变更会失效,世界变换和正背面判断逐帧更新。遮挡检查直接比较矩形,避免每个候选点与每个障碍物组合重复分配对象;HUD 障碍测量集中在该帧写入卡片样式前。

View File

@@ -316,6 +316,6 @@ BGP 变化事件保持不规则扁平符号作为唯一例外,观测站使用
卫星 hover 小圈直接使用当前卫星的倾角类别色,与粒子和图例一致;切换悬停对象时重新取色,位置更新不覆盖颜色。
所有地表卡片统一使用根 HUD DPI 比例。新闻区域卡片与巡航新闻复用标题图标、配色、字号及内边距;区域版精简元信息并增加独立轮播控件。展开卡片保留完整内容,其余区域使用胶囊,悬停以相同卡片样式临时展开;背面胶囊渐淡并缩小,不隐藏业务对象。细线/粗线引导线共用 `CalloutConnector`,粗线增加白色线宽与深色描边,端点同步放大。算力指标数字、趋势图及标题图标随 HUD 缩放。欧洲使用小型 EU 字标,其他区域使用受同一比例约束的代表 emoji。
所有地表卡片统一使用根 HUD DPI 比例。新闻区域卡片与巡航新闻复用标题图标、配色、字号及内边距;区域版精简元信息并增加独立轮播控件。展开卡片保留完整内容,其余区域使用胶囊,悬停以相同卡片样式临时展开;背面胶囊渐淡并缩小,不隐藏业务对象。细线/粗线引导线共用 `CalloutConnector`,粗线增加白色线宽与深色描边,端点同步放大。算力指标数字、趋势图及标题图标随 HUD 缩放。欧洲使用小型 EU 字标,其他区域使用受同一比例约束的代表 emoji。 连接线根据锚点所在方向选择入卡边并平衡两段折线,近似同轴时直接连接;悬停胶囊保留完整卡片尺寸、正文与操作区,避免紧凑态规则把展开内容再次裁掉。
算力图标按五类选择:超算芯片、GPU 层叠、数据中心机柜、云计算云朵、边缘节点连接。几何分别来自 compute-supercomputer.svg、compute-gpu-cluster.svg、compute-hdd-network.svg、compute-cloud.svg、compute-edge.svg,保持算力图层紫色底板与原有选中行为。

View File

@@ -107,4 +107,4 @@ Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海
跨图层同坐标/极近坐标标记在同一投影层内紧凑堆叠,不以屏幕碰撞合并不同地点;选中项使用投影子点 `z-index: 1000`,普通未聚合图标的锁定覆盖层使用共享的前景 `renderOrder >= 100`,因此跨图层重叠时仍由焦点对象在最上层;取消后恢复稳定 ID 顺序。聚合徽标在展开过渡期间保持低于选中子点。`icon_presentation_screen` 仅供详情引线跟随显示偏移,真实锚点仍由原 marker 持有;隐藏状态变化立即重建对应 GPU 批次。
区域新闻卡片与算力卡片共用 `#earth-region-callouts` 空间布局根节点(z-index 20),各自数据与阅读状态独立。根节点内部的展开卡片为 50、拖拽前景为 55、悬停展开为 60,普通胶囊为 30,背面胶囊降至 8 并渐淡缩小。各类同时展开数独立保存,完整卡片在地球两侧分配位置,其余以胶囊跟随地理锚点;悬停临时展开,点击改变关注区域。新闻引线始终连接大区代表锚点,内容轮播不改变锚点。共享控制器排除自身卡片后测量 HUD 障碍并处理尺寸变化。细线/粗线样式统一来自 `body.dataset.calloutLineStyle`。手机新闻卡片进入抽屉,不绘制地图连线。
区域新闻与算力保留 `#earth-region-callouts` 根节点(z-index 20),并与物件详情共享 `callout-stage-arbitrator.js` 的左右侧及纵向走廊。区域根内展开卡片为 50、拖拽前景为 55,悬停展开层为 60–65;普通胶囊为 30,背面胶囊为 8 并渐淡缩小。悬停胶囊保留完整正文和操作,不作为舞台障碍;已固定矩形先预留空间,紧凑视口仍保留完整舞台卡片。锚点平滑结果保留原始 visibility,只有真实背面深度低于 -0.65 才解除手动固定。地理投影、连线显示点与卡片目标独立,布局不改变 Three.js 坐标、拾取或渲染深度。新闻锚点仍代表大区而非具体事件。细线/粗线样式来自 `body.dataset.calloutLineStyle`;手机卡片进入抽屉并退出地图连线布局。

View File

@@ -296,7 +296,7 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
支持查找海缆、登陆点、卫星、算力中心、BGP 事件、BGP 观测站。结果可快速定位并打开详情。
点击地球上的 BGP、算力中心、船只、卫星或海缆后,先从物件引出连接线,再沿同一方向把详情卡尽量放到屏幕边缘,中间给地球留出呼吸空间;卡片不能盖住自己的出线点。地球转动时,连线起点会自动平滑移向仍可见的高亮部分;整个对象转到背面后,保留球体轮廓上的方向提示,以较淡连线和空心端点区分。卡片随出线方向平滑跟随,仍可拖动,接点会沿卡片边缘调整,无需手动编辑连线。手机上先显示精简卡片,点开后在抽屉中查看完整详情。下拉菜单与工具列表保持原有打开方式。
点击地球上的 BGP、算力中心、船只、卫星或海缆后,先从物件引出连接线,再沿同一方向把详情卡尽量放到屏幕边缘,中间给地球留出呼吸空间;卡片不能盖住自己的出线点。地球转动时,连线起点会自动平滑移向仍可见的高亮部分;整个对象转到背面后,保留球体轮廓上的方向提示,以较淡连线和空心端点区分。详情、新闻与算力卡片共用地球两侧的布局空间,沿各自纵向范围平滑跟随;仍可拖动,接点会沿卡片边缘调整,无需手动编辑连线。手机上先显示精简卡片,点开后在抽屉中查看完整详情。下拉菜单与工具列表保持原有打开方式。
### 四大区新闻卡片
@@ -452,7 +452,7 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
再次触发同一数据源会创建新任务,旧失败项留在失败分组。“任务状态”使用清单图标,与刷新操作区分。CelesTrak 在短时间内可复用有效下载缓存;重新采集仍会生成独立任务并更新采集数据。
显示设置中的“卫星 → 近景标签”默认开启,关闭后隐藏地图上的卫星名称,详情卡仍可使用。开关下的“近景标签显示阈值”滑块可设为 250%–500%,默认 400%;达到该缩放比例时显示标签,低于阈值时隐藏。数量滑块可设为 5–36 个,默认 36;这是显示上限,遮挡或空间不足时实际数量可能更少,设置会保存。“星空亮度”滑块范围为 0–200%,默认 100%,两项会记住选择。算力区域卡片在拖拽、地球运动或新卡片落位稳定后才进行必要避让;平时只在原位附近轻微漂浮,鼠标操作时暂停,减少阅读时频繁换位。算力图例里的分类数量只统计当前已定位点。
显示设置中的“卫星 → 近景标签”默认开启,关闭后隐藏地图上的卫星名称,详情卡仍可使用。开关下的“近景标签显示阈值”滑块可设为 250%–500%,默认 400%;达到该缩放比例时显示标签,低于阈值时隐藏。数量滑块可设为 5–36 个,默认 36;这是显示上限,遮挡或空间不足时实际数量可能更少,设置会保存。“星空亮度”滑块范围为 0–200%,默认 100%,两项会记住选择。展开的区域卡片在各自纵向范围内平滑跟随,不再叠加装饰漂浮。悬停期间及离开后短暂停留时不自动切换关注区域;胶囊可临时展开完整内容和操作,已有卡片仍跟随地理锚点。算力图例里的分类数量只统计当前已定位点。
地表点位聚合后显示带数量的实心圆,点击可展开成员;只有同坐标或极近坐标点位在原位置附近紧凑堆叠,普通点始终回到各自真实位置;堆叠露出可点击边缘,点击后排图标会将它置前并打开详情,取消焦点后恢复原位。放大超过 250% 后普通聚合自动拆开,500% 不再保留聚合圆标,同坐标点直接展开。

View File

@@ -61,7 +61,7 @@
顶部新闻条可打开态势新闻面板;启用“显示区域新闻卡片”后,美洲、欧洲、中东与非洲、亚太分别轮播本区新闻。每张卡片可单独暂停、翻页、拖动与折叠。
刷新新闻时可继续阅读已有内容,等待“刷新成功”后查看最新结果;失败时可按提示重试。“设置 → 面板”可分别选择算力卡和新闻卡展开 1–3 张,其他区域保留为可悬停展开的胶囊。
刷新新闻时可继续阅读已有内容,等待“刷新成功”后查看最新结果;失败时可按提示重试。“设置 → 面板”可分别选择算力卡和新闻卡展开 1–3 张,其他区域保留为可悬停展开的胶囊。 桌面胶囊悬停后可直接查看正文和操作;详情、新闻与算力卡片会共享地球两侧的位置。
## 5. 找回密码

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.76.19`
- `dev` 当前开发分支历史推导到:`0.76.20`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.76.20` | improvement | `dev` | `v0.76.20` | 统一 Earth 卡片布局仲裁与连接线,修复悬停展开和固定深度,并优化卫星分批查询与连接测试反馈 |
| `0.76.19` | improvement | `dev` | `v0.76.19` | 改善 Earth 卡片与移动交互、新闻后台刷新、图层缓存、BGP 前缀查询及 Outbox 维护 |
| `0.76.18` | bugfix | `dev` | `v0.76.18` | 收敛 breaking 新闻交互、卡片 hover/扫光、面板稳定性、船只锚点显隐并同步开发文档 |
| `0.76.17` | bugfix | `dev` | `v0.76.17` | 稳定 breaking active 动画状态,修复连续新闻切换时扫光重启闪烁 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.76.19",
"version": "0.76.20",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -45,6 +45,7 @@
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 2px 2px 3px 2px;
}
.compute-picker__notice {
@@ -57,16 +58,36 @@
align-items: center;
gap: 6px;
color: var(--hud-text-muted);
position: relative;
}
.compute-picker__body label:focus-within {
z-index: 5;
}
.compute-picker__body select {
max-width: 170px;
height: calc(28px * var(--hud-scale));
min-height: calc(28px * var(--hud-scale));
border: 1px solid var(--hud-border);
border-radius: 6px;
padding: 5px;
padding-block: 0;
padding-inline: calc(8px * var(--hud-scale));
background: var(--hud-surface-bottom);
color: var(--hud-text);
color-scheme: dark;
font: inherit;
font-size: var(--hud-font-size-sm);
line-height: normal;
box-sizing: border-box;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.compute-picker__body select:focus,
.compute-picker__body select:focus-visible {
outline: none;
border-color: #ffffff;
box-shadow: 0 0 0 1px #ffffff, 0 0 6px rgba(255, 255, 255, 0.35);
}
.compute-picker__body p {
@@ -97,7 +118,7 @@
}
/* Desktop compact mode for peripheral / non-spotlight compute cards */
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) {
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight):not(.is-hover-expanded) {
width: min(calc(220px * var(--hud-scale)), calc(100vw - 24px));
min-width: min(calc(180px * var(--hud-scale)), calc(100vw - 24px));
min-height: auto;
@@ -110,6 +131,7 @@ body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) {
transform: scale(var(--spatial-scale, 1));
transform-origin: center center;
transition:
opacity 0.25s ease-out,
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -121,7 +143,7 @@ body:not(.layout-mode-mobile) .compute-card.is-backside:not(.is-spotlight) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important;
}
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight):hover {
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight):not(.is-hover-expanded):hover {
border-color: rgba(147, 202, 255, 0.45);
box-shadow: 0 0 16px rgba(56, 189, 248, 0.28), 0 4px 14px rgba(0, 0, 0, 0.45);
transform: translateY(-1px);
@@ -143,7 +165,7 @@ body:not(.layout-mode-mobile) .compute-card.is-hover-expanded::before {
z-index: -1;
}
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .hud-panel__header {
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight):not(.is-hover-expanded) .hud-panel__header {
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
gap: calc(6px * var(--hud-scale));
height: calc(42px * var(--hud-scale));
@@ -169,8 +191,8 @@ body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .hud-panel__heade
transform-origin: top left;
}
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .compute-card__essentials,
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .compute-card__body {
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight):not(.is-hover-expanded) .compute-card__essentials,
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight):not(.is-hover-expanded) .compute-card__body {
opacity: 0 !important;
pointer-events: none !important;
transform: translateY(-6px) scale(0.97);
@@ -196,7 +218,7 @@ body:not(.layout-mode-mobile) .compute-card.is-spotlight .compute-card__body {
transform 0.36s cubic-bezier(0.16, 1, 0.3, 1) 0.04s;
}
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .hud-panel__actions {
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight):not(.is-hover-expanded) .hud-panel__actions {
opacity: 0;
pointer-events: none;
width: 0;

View File

@@ -4074,7 +4074,7 @@ label.is-disabled.earth-mobile-settings-card {
}
.earth-settings-chip[data-remove] {
padding-right: calc(6px * var(--hud-scale));
padding-right: calc(8px * var(--hud-scale));
}
.earth-settings-chip__label {
@@ -4089,24 +4089,18 @@ label.is-disabled.earth-mobile-settings-card {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(15px * var(--hud-scale));
height: calc(15px * var(--hud-scale));
border-radius: 50%;
font-size: calc(0.72rem * var(--hud-scale));
width: auto;
font-size: calc(0.85rem * var(--hud-scale));
font-weight: 400;
line-height: 1;
color: var(--hud-text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
background: transparent;
border: none;
box-shadow: none;
flex-shrink: 0;
pointer-events: none;
transition:
background 0.16s ease,
border-color 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease;
transform: translateY(0.06em);
transition: color 0.16s ease;
}
.earth-mobile-settings-chip__remove,
@@ -4114,24 +4108,18 @@ label.is-disabled.earth-mobile-settings-card {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
font-size: 0.82rem;
width: auto;
font-size: 0.9rem;
font-weight: 400;
line-height: 1;
color: var(--hud-text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
background: transparent;
border: none;
box-shadow: none;
flex-shrink: 0;
pointer-events: none;
transition:
background 0.16s ease,
border-color 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease;
transform: translateY(0.06em);
transition: color 0.16s ease;
}
.earth-settings-chip[data-remove]:hover .earth-settings-chip__remove,
@@ -4139,10 +4127,10 @@ label.is-disabled.earth-mobile-settings-card {
.earth-mobile-settings-chip[data-remove]:hover .earth-mobile-settings-chip__remove,
.earth-mobile-settings-chip[data-remove]:hover > span[aria-hidden="true"] {
color: #fff;
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.24);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 2px 6px rgba(0, 0, 0, 0.2);
transform: scale(1.08);
background: transparent;
border: none;
box-shadow: none;
transform: translateY(0.06em);
}
.earth-settings-chip:hover {

View File

@@ -39,7 +39,7 @@
}
/* Desktop compact mode for peripheral / non-spotlight news cards */
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) {
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight):not(.is-hover-expanded) {
width: min(calc(230px * var(--hud-scale)), calc(100vw - 32px));
height: calc(42px * var(--hud-scale)) !important;
cursor: pointer;
@@ -48,6 +48,7 @@ body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) {
transform: scale(var(--spatial-scale, 1));
transform-origin: center center;
transition:
opacity 0.25s ease-out,
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -59,15 +60,20 @@ body:not(.layout-mode-mobile) .news-region-card.is-backside:not(.is-spotlight) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight):hover {
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight):not(.is-hover-expanded):hover {
border-color: rgba(147, 202, 255, 0.45);
box-shadow: 0 0 16px rgba(56, 189, 248, 0.28), 0 4px 14px rgba(0, 0, 0, 0.45);
transform: translateY(-1px);
}
body:not(.layout-mode-mobile) .news-region-card.is-hover-expanded {
z-index: 60 !important;
width: min(var(--hud-detail-card-width), calc(100vw - 32px)) !important;
height: auto !important;
max-height: var(--hud-detail-card-max-height) !important;
z-index: 65 !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65), 0 0 20px rgba(56, 189, 248, 0.35) !important;
border-color: rgba(145, 195, 255, 0.5) !important;
cursor: default;
transition:
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1) !important;
@@ -81,6 +87,29 @@ body:not(.layout-mode-mobile) .news-region-card.is-hover-expanded::before {
z-index: -1;
}
body:not(.layout-mode-mobile) .news-region-card.is-hover-expanded .news-region-card__body {
opacity: 1 !important;
pointer-events: auto !important;
transform: translateY(0) scale(1) !important;
overflow: visible !important;
height: auto !important;
transition:
opacity 0.28s cubic-bezier(0.16, 1, 0.3, 1) 0.04s,
transform 0.32s cubic-bezier(0.16, 1, 0.3, 1) 0.02s;
}
body:not(.layout-mode-mobile) .news-region-card.is-hover-expanded .hud-panel__actions {
opacity: 1 !important;
pointer-events: auto !important;
width: auto !important;
overflow: visible !important;
}
body:not(.layout-mode-mobile) .news-region-card.is-hover-expanded .info-card-header h3 {
overflow: visible;
white-space: normal;
}
.news-region-card .news-region-card__body {
transform-origin: top left;
will-change: opacity, transform;
@@ -97,7 +126,7 @@ body:not(.layout-mode-mobile) .news-region-card.is-hover-expanded::before {
transform-origin: top left;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .news-region-card__body {
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight):not(.is-hover-expanded) .news-region-card__body {
opacity: 0 !important;
pointer-events: none !important;
transform: translateY(-6px) scale(0.97);
@@ -118,14 +147,14 @@ body:not(.layout-mode-mobile) .news-region-card.is-spotlight .news-region-card__
transform 0.36s cubic-bezier(0.16, 1, 0.3, 1) 0.04s;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .info-card-header {
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight):not(.is-hover-expanded) .info-card-header {
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
gap: calc(6px * var(--hud-scale));
height: calc(42px * var(--hud-scale));
box-sizing: border-box;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .info-card-header h3 {
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight):not(.is-hover-expanded) .info-card-header h3 {
font-size: calc(0.74rem * var(--hud-scale));
font-weight: 500;
overflow: hidden;
@@ -133,7 +162,7 @@ body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .info-card-he
white-space: nowrap;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .hud-panel__actions {
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight):not(.is-hover-expanded) .hud-panel__actions {
opacity: 0;
pointer-events: none;
width: 0;

View File

@@ -548,15 +548,53 @@ export function createSlidingConnectorPoints(source, target, options = {}) {
const targetRect = resolveConnectorObstacleRect(options.targetAnchor ?? target, { padding: 0 });
if (!targetRect) return null;
const end = computeSlidingPerimeterAnchor(start, targetRect, { gap: targetGapPx });
const candidateSides = [];
if (start.x < targetRect.left) candidateSides.push("left");
if (start.x > targetRect.right) candidateSides.push("right");
if (start.y < targetRect.top) candidateSides.push("top");
if (start.y > targetRect.bottom) candidateSides.push("bottom");
let end = null;
if (candidateSides.length > 0) {
const candidates = candidateSides
.map((side) => {
const anchor = computePerimeterAnchorOnSide(start, targetRect, side, { gap: targetGapPx });
if (!anchor) return null;
const isHoriz = side === "left" || side === "right";
const dMajor = isHoriz ? Math.abs(start.x - anchor.x) : Math.abs(start.y - anchor.y);
const dMinor = isHoriz ? Math.abs(start.y - anchor.y) : Math.abs(start.x - anchor.x);
const distSq = dMajor * dMajor + dMinor * dMinor;
return { ...anchor, dMajor, dMinor, distSq, majorDominant: dMajor >= dMinor };
})
.filter(Boolean);
candidates.sort((a, b) => {
if (a.majorDominant !== b.majorDominant) return a.majorDominant ? -1 : 1;
return a.distSq - b.distSq;
});
end = candidates[0] || null;
}
if (!end) {
end = computeSlidingPerimeterAnchor(start, targetRect, { gap: targetGapPx });
}
if (!end) return null;
const normal = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] }[end.side];
const length = Math.hypot(end.x - start.x, end.y - start.y);
const stub = Math.min(24, length * 0.25);
const deltaX = Math.abs(end.x - start.x);
const deltaY = Math.abs(end.y - start.y);
const isHorizontalEntrance = end.side === "left" || end.side === "right";
const dMajor = isHorizontalEntrance ? deltaX : deltaY;
const dMinor = isHorizontalEntrance ? deltaY : deltaX;
// Jointly solve bend point for balanced ~48%:52% Euclidean segment lengths without barbs:
let stub = 0;
if (dMajor > 1) {
const stubEqual = (dMajor * dMajor + dMinor * dMinor) / (2 * dMajor);
stub = clamp(stubEqual * 0.94, dMajor * 0.35, dMajor * 0.85);
}
const bend = { x: end.x + normal[0] * stub, y: end.y + normal[1] * stub };
const aligned = Math.abs((end.x - start.x) * normal[1] - (end.y - start.y) * normal[0]) < 2;
const aligned = isHorizontalEntrance ? (deltaY < 4) : (deltaX < 4);
const pts = aligned ? [start, end] : [start, bend, end];
const startFrom = options.startFrom || "source";

View File

@@ -2,12 +2,33 @@ import { expect, test } from "bun:test";
import { createRegionConnectorPath, connectorPathsCross } from "./callout-region-path.js";
import { scoreCalloutLayout } from "./callout-proximity.js";
test("a diagonal geographic connector does not detour across the globe in a large elbow", () => {
const path = createRegionConnectorPath({ x: 850, y: 200 }, { x: 30, y: 600, width: 250, height: 200 });
const length = path.points.slice(1).reduce((sum, point, index) => sum +
Math.hypot(point.x - path.points[index].x, point.y - path.points[index].y), 0);
expect(length).toBeLessThan(Math.hypot(path.end.x - path.start.x, path.end.y - path.start.y) * 1.04);
test("a diagonal geographic connector does not detour across the globe and maintains balanced ~48%:52% Euclidean segment lengths", () => {
const rect = { x: 30, y: 600, width: 250, height: 200 };
const path = createRegionConnectorPath({ x: 850, y: 200 }, rect);
expect(path.points).toHaveLength(3);
const [p0, p1, p2] = path.points;
const seg1 = Math.hypot(p1.x - p0.x, p1.y - p0.y);
const seg2 = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const totalLength = seg1 + seg2;
const straightDist = Math.hypot(path.end.x - path.start.x, path.end.y - path.start.y);
// 1. Shorter than a full orthogonal L-elbow detour:
expect(totalLength).toBeLessThan(straightDist * 1.28);
// 2. Actual Euclidean segment length ratio is balanced (~48%:52%, strictly within 44%..56%):
const ratio1 = seg1 / totalLength;
const ratio2 = seg2 / totalLength;
expect(ratio1).toBeGreaterThanOrEqual(0.44);
expect(ratio1).toBeLessThanOrEqual(0.56);
expect(ratio2).toBeGreaterThanOrEqual(0.44);
expect(ratio2).toBeLessThanOrEqual(0.56);
// 3. Monotonic progression without barbs (no reversal along X or Y):
expect(p1.x).toBeLessThanOrEqual(p0.x);
expect(p1.x).toBeGreaterThanOrEqual(p2.x);
expect(p1.y).toBeGreaterThanOrEqual(p0.y);
expect(p1.y).toBeLessThanOrEqual(p2.y);
});
test("layout scoring detects the rendered paths crossing", () => {

View File

@@ -0,0 +1,400 @@
const clamp = (value, low, high) => Math.max(low, Math.min(Math.max(low, high), value));
export const KIND_PRIORITY = {
selection: 1,
news: 2,
compute: 3,
};
export const MERIDIAN_HYSTERESIS_PX = 15;
export const VERTICAL_SWAP_HYSTERESIS_PX = 60;
export const INITIAL_VERTICAL_TIE_PX = 24;
export const STAGE_CARD_GAP_PX = 16;
export const RIM_OVERLAP_PX = 32;
export const ANCHOR_CLEARANCE_PX = 24;
function subtractInterval(intervals, cutTop, cutBottom) {
const next = [];
for (const iv of intervals) {
if (cutBottom <= iv.top || cutTop >= iv.bottom) {
next.push(iv);
continue;
}
if (cutTop > iv.top) {
next.push({ top: iv.top, bottom: cutTop });
}
if (cutBottom < iv.bottom) {
next.push({ top: cutBottom, bottom: iv.bottom });
}
}
return next.filter((iv) => iv.bottom - iv.top >= 40);
}
export function computeConstrainedCardTarget(entry, slot, { viewport, globe, safeBounds } = {}) {
const vpW = viewport?.width || 1920;
const vpH = viewport?.height || 1080;
const gX = Number.isFinite(globe?.x) ? globe.x : vpW * 0.5;
const gY = Number.isFinite(globe?.y) ? globe.y : vpH * 0.5;
const gR = Number.isFinite(globe?.radius) ? globe.radius : Math.min(vpW, vpH) * 0.4;
const cardW = Math.min(entry?.size?.width || 340, vpW - 28);
const cardH = Math.min(entry?.size?.height || 360, vpH - 28);
const minX = Number.isFinite(safeBounds?.minX) ? safeBounds.minX : 24;
const maxX = (Number.isFinite(safeBounds?.maxX) ? safeBounds.maxX : vpW - 24) - cardW;
const defaultMinY = Number.isFinite(safeBounds?.minY) ? safeBounds.minY : 68;
const defaultMaxY = Number.isFinite(safeBounds?.maxY) ? safeBounds.maxY : vpH - 110;
if (entry?.userPinned && entry?.pinnedRect) {
return {
x: Math.round(entry.pinnedRect.x),
y: Math.round(entry.pinnedRect.y),
width: cardW,
height: cardH,
side: (entry.pinnedRect.x + cardW * 0.5) >= gX ? "left" : "right",
stageSide: (entry.pinnedRect.x + cardW * 0.5) >= gX ? "right" : "left",
corridor: [entry.pinnedRect.y, entry.pinnedRect.y + cardH],
mode: "pinned",
};
}
const corridorMinY = Number.isFinite(slot?.corridor?.[0]) ? slot.corridor[0] : defaultMinY;
const corridorMaxY = Number.isFinite(slot?.corridor?.[1]) ? slot.corridor[1] : defaultMaxY;
const anchor = entry?.anchor || { x: gX, y: gY };
const dyAnchor = anchor.y - gY;
const uy = clamp(dyAnchor / Math.max(1, gR), -1, 1);
const rawY = anchor.y - cardH * ((1 - uy) * 0.5);
const minAllowedY = corridorMinY;
const maxAllowedY = Math.max(minAllowedY, corridorMaxY - cardH);
const targetY = clamp(Math.round(rawY), minAllowedY, maxAllowedY);
const stageSide = slot?.side || (anchor.x >= gX ? "right" : "left");
const isRight = stageSide === "right";
const midY = targetY + cardH * 0.5;
const dyMid = midY - gY;
const rAtY = Math.abs(dyMid) < gR ? Math.sqrt(gR * gR - dyMid * dyMid) : 0;
const overlap = Number.isFinite(entry?.overlapDepth) ? entry.overlapDepth : RIM_OVERLAP_PX;
let targetX;
if (isRight) {
// Card overlaps globe rim by `overlap`, but stays at least ANCHOR_CLEARANCE_PX to the right of anchor.x so it NEVER covers its own anchor!
const idealLeft = Math.max(gX + rAtY - overlap, anchor.x + ANCHOR_CLEARANCE_PX);
targetX = clamp(Math.round(idealLeft), minX, Math.max(minX, maxX));
} else {
// Card overlaps left globe rim by `overlap`, but stays at least ANCHOR_CLEARANCE_PX to the left of anchor.x!
const idealRight = Math.min(gX - rAtY + overlap, anchor.x - ANCHOR_CLEARANCE_PX);
targetX = clamp(Math.round(idealRight - cardW), minX, Math.max(minX, maxX));
}
return {
x: targetX,
y: targetY,
width: cardW,
height: cardH,
side: isRight ? "left" : "right",
stageSide,
corridor: [corridorMinY, corridorMaxY],
mode: "expanded",
orderIndex: slot?.orderIndex ?? 0,
countOnSide: slot?.countOnSide ?? 1,
};
}
export function createCalloutStageArbitrator() {
const entries = new Map();
const latchedSlots = new Map();
const pairOrderLatch = new Map();
let revision = 0;
let lastTopologySignature = "";
function makePairKey(aKey, bKey) {
return String(aKey) < String(bKey) ? `${aKey}|${bKey}` : `${bKey}|${aKey}`;
}
function clearPairLatchesForKey(key) {
const keyStr = String(key);
for (const pairKey of [...pairOrderLatch.keys()]) {
const [k1, k2] = pairKey.split("|");
if (k1 === keyStr || k2 === keyStr) {
pairOrderLatch.delete(pairKey);
}
}
}
function setCard(key, data) {
if (!key || !data) return;
entries.set(key, {
key,
owner: data.owner || "default",
kind: data.kind || "news",
anchor: data.anchor || { x: 0, y: 0, front: true },
size: {
width: Number(data.size?.width) || 340,
height: Number(data.size?.height) || 360,
},
isPrimary: Boolean(data.isPrimary),
userPinned: Boolean(data.userPinned),
pinnedRect: data.pinnedRect || null,
overlapDepth: data.overlapDepth,
updatedAt: Number.isFinite(data.now) ? data.now : Date.now(),
});
}
function removeCard(key) {
if (!entries.has(key)) return;
entries.delete(key);
latchedSlots.delete(key);
clearPairLatchesForKey(key);
revision++;
}
function clearByOwner(owner, keepKeys = null) {
for (const [key, entry] of [...entries.entries()]) {
if (entry.owner === owner && (!keepKeys || !keepKeys.has(key))) {
removeCard(key);
}
}
}
function resolveFlankSide(entry, globeX) {
if (entry.userPinned && entry.pinnedRect) {
const center = entry.pinnedRect.x + (entry.pinnedRect.width || entry.size.width) * 0.5;
return center >= globeX ? "right" : "left";
}
const prev = latchedSlots.get(entry.key);
const prevSide = prev?.side || null;
const ax = Number(entry.anchor?.x) || 0;
if (prevSide === "right") {
return ax >= globeX - MERIDIAN_HYSTERESIS_PX ? "right" : "left";
}
if (prevSide === "left") {
return ax > globeX + MERIDIAN_HYSTERESIS_PX ? "right" : "left";
}
return ax >= globeX ? "right" : "left";
}
function compareVerticalOrder(a, b, side) {
const pairKey = makePairKey(a.key, b.key);
const latched = pairOrderLatch.get(pairKey);
const ay = Number(a.anchor?.y) || 0;
const by = Number(b.anchor?.y) || 0;
let upperKey;
let lowerKey;
if (latched && latched.side === side) {
const upper = latched.upperKey === a.key ? a : b;
const lower = upper === a ? b : a;
const uy = Number(upper.anchor?.y) || 0;
const ly = Number(lower.anchor?.y) || 0;
if (uy > ly + VERTICAL_SWAP_HYSTERESIS_PX) {
upperKey = lower.key;
lowerKey = upper.key;
} else {
upperKey = upper.key;
lowerKey = lower.key;
}
} else {
if (Math.abs(ay - by) > INITIAL_VERTICAL_TIE_PX) {
upperKey = ay <= by ? a.key : b.key;
lowerKey = upperKey === a.key ? b.key : a.key;
} else {
const pa = KIND_PRIORITY[a.kind] ?? 99;
const pb = KIND_PRIORITY[b.kind] ?? 99;
if (pa !== pb) {
upperKey = pa < pb ? a.key : b.key;
} else {
upperKey = String(a.key) <= String(b.key) ? a.key : b.key;
}
lowerKey = upperKey === a.key ? b.key : a.key;
}
}
pairOrderLatch.set(pairKey, { side, upperKey, lowerKey });
return upperKey === a.key ? -1 : 1;
}
function arbitrate({ viewport, globe, safeBounds } = {}) {
const vpW = viewport?.width || 1920;
const vpH = viewport?.height || 1080;
const gX = Number.isFinite(globe?.x) ? globe.x : vpW * 0.5;
const gY = Number.isFinite(globe?.y) ? globe.y : vpH * 0.5;
const minY = Number.isFinite(safeBounds?.minY) ? safeBounds.minY : 68;
const maxY = Number.isFinite(safeBounds?.maxY) ? safeBounds.maxY : vpH - 110;
const flanks = { left: [], right: [] };
for (const entry of entries.values()) {
const side = resolveFlankSide(entry, gX);
const prev = latchedSlots.get(entry.key);
if (prev && prev.side !== side) {
clearPairLatchesForKey(entry.key);
}
flanks[side].push({ entry, side });
}
const nextSlots = new Map();
for (const side of ["left", "right"]) {
const list = flanks[side];
if (!list.length) continue;
const pinned = list.filter((item) => item.entry.userPinned && item.entry.pinnedRect);
const auto = list.filter((item) => !(item.entry.userPinned && item.entry.pinnedRect));
let freeIntervals = [{ top: minY, bottom: maxY }];
for (const item of pinned) {
const r = item.entry.pinnedRect;
freeIntervals = subtractInterval(
freeIntervals,
r.y - STAGE_CARD_GAP_PX,
r.y + r.height + STAGE_CARD_GAP_PX,
);
nextSlots.set(item.entry.key, {
side,
orderIndex: -1,
countOnSide: 1,
mode: "pinned",
corridor: [r.y, r.y + r.height],
});
}
if (!freeIntervals.length) {
freeIntervals = [{ top: minY, bottom: maxY }];
}
// Assign auto cards to the largest/nearest free interval (never collapsing spotlight cards!):
const intervalBuckets = freeIntervals.map((iv) => ({
top: iv.top,
bottom: iv.bottom,
items: [],
}));
for (const item of auto) {
const ay = Number(item.entry.anchor?.y) || gY;
intervalBuckets.sort((b1, b2) => {
const h1 = b1.bottom - b1.top;
const h2 = b2.bottom - b2.top;
if (Math.abs(h1 - h2) > 120) return h2 - h1;
const mid1 = (b1.top + b1.bottom) * 0.5;
const mid2 = (b2.top + b2.bottom) * 0.5;
return Math.abs(ay - mid1) - Math.abs(ay - mid2);
});
intervalBuckets[0].items.push(item.entry);
}
// Within each interval bucket, sort cards vertically with 60px hysteresis and allocate corridors:
for (const bucket of intervalBuckets) {
const count = bucket.items.length;
if (count === 0) continue;
if (count === 1) {
const only = bucket.items[0];
nextSlots.set(only.key, {
side,
orderIndex: 0,
countOnSide: 1,
mode: "expanded",
corridor: [Math.round(bucket.top), Math.round(bucket.bottom)],
});
continue;
}
bucket.items.sort((a, b) => compareVerticalOrder(a, b, side));
const availH = bucket.bottom - bucket.top;
const totalCardsH = bucket.items.reduce((sum, e) => sum + Math.min(e.size.height || 360, availH), 0);
const totalGaps = STAGE_CARD_GAP_PX * (count - 1);
if (availH >= totalCardsH + totalGaps) {
// Plenty of vertical capacity: partition into strictly disjoint corridors with STAGE_CARD_GAP_PX
const slack = availH - totalCardsH - totalGaps;
const slackPerCard = slack / count;
let cursorY = bucket.top;
bucket.items.forEach((entry, idx) => {
const h = Math.min(entry.size.height || 360, availH);
const corridorH = h + slackPerCard;
const cMin = Math.round(cursorY);
const cMax = idx === count - 1
? Math.round(bucket.bottom)
: Math.round(cursorY + corridorH);
nextSlots.set(entry.key, {
side,
orderIndex: idx,
countOnSide: count,
mode: "expanded",
corridor: [cMin, Math.max(cMin + h, cMax)],
});
cursorY += corridorH + STAGE_CARD_GAP_PX;
});
} else {
// Compact viewport / tight vertical height: NEVER collapse spotlight cards!
// Pin each card's corridor deterministically from top (bucket.top) to bottom (bucket.bottom - h)
// so upper card docks at top, lower card docks at bottom, maximizing separation with zero oscillation.
bucket.items.forEach((entry, idx) => {
const h = Math.min(entry.size.height || 360, availH);
const travel = Math.max(0, availH - h);
const pinnedY = Math.round(bucket.top + (travel * idx) / (count - 1));
nextSlots.set(entry.key, {
side,
orderIndex: idx,
countOnSide: count,
mode: "expanded",
corridor: [pinnedY, pinnedY + h],
});
});
}
}
}
const sigParts = [];
for (const [k, s] of [...nextSlots.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)))) {
latchedSlots.set(k, s);
sigParts.push(`${k}:${s.side}:${s.mode}:${s.orderIndex}:${s.corridor[0]}-${s.corridor[1]}`);
}
const nextSig = sigParts.join("|");
if (nextSig !== lastTopologySignature) {
lastTopologySignature = nextSig;
revision++;
}
return {
slots: nextSlots,
revision,
topologySignature: lastTopologySignature,
};
}
function computeTarget(key, context = {}) {
const entry = entries.get(key);
if (!entry) return null;
const slot = latchedSlots.get(key);
return computeConstrainedCardTarget(entry, slot, context);
}
function getSlot(key) {
return latchedSlots.get(key) || null;
}
function getRevision() {
return revision;
}
function reset() {
entries.clear();
latchedSlots.clear();
pairOrderLatch.clear();
lastTopologySignature = "";
revision++;
}
return {
setCard,
removeCard,
clearByOwner,
arbitrate,
computeTarget,
getSlot,
getRevision,
reset,
};
}
export const sharedCalloutStageArbitrator = createCalloutStageArbitrator();

View File

@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test";
import {
createCalloutStageArbitrator,
STAGE_CARD_GAP_PX,
} from "./callout-stage-arbitrator.js";
const viewport = { width: 1920, height: 1080 };
const globe = { x: 960, y: 540, radius: 360 };
const safeBounds = { minX: 24, maxX: 1896, minY: 68, maxY: 970 };
describe("CalloutStageArbitrator", () => {
test("assigns disjoint vertical corridors to Selection and News cards on the same flank and prevents collision across continuous rotation", () => {
const arb = createCalloutStageArbitrator();
arb.setCard("selection:detail", {
owner: "presentation",
kind: "selection",
anchor: { x: 1120, y: 360, front: true },
size: { width: 340, height: 380 },
isPrimary: true,
});
arb.setCard("news:europe", {
owner: "spatial",
kind: "news",
anchor: { x: 1140, y: 440, front: true },
size: { width: 340, height: 360 },
isPrimary: true,
});
const { slots } = arb.arbitrate({ viewport, globe, safeBounds });
const selSlot = slots.get("selection:detail");
const newsSlot = slots.get("news:europe");
expect(selSlot.side).toBe("right");
expect(newsSlot.side).toBe("right");
expect(selSlot.mode).toBe("expanded");
expect(newsSlot.mode).toBe("expanded");
expect(selSlot.orderIndex).toBe(0);
expect(newsSlot.orderIndex).toBe(1);
expect(newsSlot.corridor[0] - selSlot.corridor[1]).toBeGreaterThanOrEqual(STAGE_CARD_GAP_PX);
// Even as both anchors drift across latitudes within the 60px hysteresis window,
// constrained targets never overlap:
for (let offset = -35; offset <= 35; offset += 10) {
arb.setCard("selection:detail", {
owner: "presentation",
kind: "selection",
anchor: { x: 1120, y: 400 + offset, front: true },
size: { width: 340, height: 380 },
isPrimary: true,
});
arb.setCard("news:europe", {
owner: "spatial",
kind: "news",
anchor: { x: 1140, y: 400 - offset * 0.4, front: true },
size: { width: 340, height: 360 },
isPrimary: true,
});
arb.arbitrate({ viewport, globe, safeBounds });
const tSel = arb.computeTarget("selection:detail", { viewport, globe, safeBounds });
const tNews = arb.computeTarget("news:europe", { viewport, globe, safeBounds });
expect(tSel.y + tSel.height + STAGE_CARD_GAP_PX).toBeLessThanOrEqual(tNews.y);
}
});
test("enforces 60px vertical swap hysteresis before swapping upper/lower slots on the same flank", () => {
const arb = createCalloutStageArbitrator();
arb.setCard("selection:detail", {
owner: "presentation",
kind: "selection",
anchor: { x: 1100, y: 320, front: true },
size: { width: 340, height: 360 },
});
arb.setCard("news:asia", {
owner: "spatial",
kind: "news",
anchor: { x: 1120, y: 380, front: true },
size: { width: 340, height: 360 },
});
arb.arbitrate({ viewport, globe, safeBounds });
expect(arb.getSlot("selection:detail").orderIndex).toBe(0);
expect(arb.getSlot("news:asia").orderIndex).toBe(1);
// Reverse relative height by 45px (<= 60px hysteresis): must keep latched order!
arb.setCard("selection:detail", {
owner: "presentation",
kind: "selection",
anchor: { x: 1100, y: 425, front: true },
size: { width: 340, height: 360 },
});
arb.setCard("news:asia", {
owner: "spatial",
kind: "news",
anchor: { x: 1120, y: 380, front: true },
size: { width: 340, height: 360 },
});
arb.arbitrate({ viewport, globe, safeBounds });
expect(arb.getSlot("selection:detail").orderIndex).toBe(0);
expect(arb.getSlot("news:asia").orderIndex).toBe(1);
// Reverse relative height by 75px (> 60px hysteresis): now swaps upper/lower order!
arb.setCard("selection:detail", {
owner: "presentation",
kind: "selection",
anchor: { x: 1100, y: 455, front: true },
size: { width: 340, height: 360 },
});
arb.arbitrate({ viewport, globe, safeBounds });
expect(arb.getSlot("news:asia").orderIndex).toBe(0);
expect(arb.getSlot("selection:detail").orderIndex).toBe(1);
});
test("never collapses spotlight cards on tight viewports; deterministically docks upper card at top and lower card at bottom", () => {
const arb = createCalloutStageArbitrator();
const tightBounds = { minX: 24, maxX: 1896, minY: 70, maxY: 710 }; // availableH = 640px < 360 + 16 + 350
arb.setCard("selection:detail", {
owner: "presentation",
kind: "selection",
anchor: { x: 1150, y: 320, front: true },
size: { width: 340, height: 360 },
});
arb.setCard("news:0", {
owner: "spatial",
kind: "news",
anchor: { x: 1160, y: 420, front: true },
size: { width: 340, height: 350 },
});
const { slots } = arb.arbitrate({ viewport, globe, safeBounds: tightBounds });
expect(slots.get("selection:detail").mode).toBe("expanded");
expect(slots.get("news:0").mode).toBe("expanded");
expect(slots.get("selection:detail").corridor).toEqual([70, 430]);
expect(slots.get("news:0").corridor).toEqual([360, 710]);
});
});

View File

@@ -23,6 +23,23 @@ const insideViewport = (point, viewport) => finitePoint(point) &&
point.x <= viewport.width - VIEWPORT_MARGIN &&
point.y <= viewport.height - VIEWPORT_MARGIN;
function attachNonEnumerableVisibility(point, rawVisibility) {
if (!point || !Number.isFinite(rawVisibility)) return point;
Object.defineProperty(point, "visibility", {
value: rawVisibility,
enumerable: false,
configurable: true,
writable: true,
});
Object.defineProperty(point, "rawVisibility", {
value: rawVisibility,
enumerable: false,
configurable: true,
writable: true,
});
return point;
}
export function projectedSphereRadius(centerPlaneRadius, distance, sphereRadius) {
const projectedRadius = Math.abs(Number(centerPlaneRadius));
const cameraDistance = Math.abs(Number(distance));
@@ -162,14 +179,14 @@ export function chooseHighlightAnchor(state = {}, {
: cardDirection;
const direction = pointDirection(preferred, globe, previousDirection);
const guide = chooseGuidePoint(direction, previousDirection, globe, viewport, obstacles);
return {
return attachNonEnumerableVisibility({
x: guide.x,
y: guide.y,
front: preferred.front === true,
key: null,
direction: guide.direction,
guide: true,
};
}, preferred.visibility);
}
export function smoothHighlightAnchor(state = {}, options = {}) {
@@ -203,11 +220,17 @@ export function smoothHighlightAnchor(state = {}, options = {}) {
if (targetKind === "preferred" && !transitioning) {
source = { x: target.x, y: target.y, front: true };
}
const rawVisibility = Number.isFinite(options.preferred?.visibility)
? options.preferred.visibility
: (Number.isFinite(target?.visibility) ? target.visibility : undefined);
attachNonEnumerableVisibility(source, rawVisibility);
const direction = target.direction || pointDirection(target, options.globe, state.direction);
return {
source,
rawVisibility,
state: {
source,
rawVisibility,
direction,
candidateKey: targetKind === "preferred"
? transitioning ? state.candidateKey ?? null : null

View File

@@ -183,3 +183,28 @@ test("an obstructed front point uses a clear rim guide without becoming rear", (
expect(result.source.x >= obstacle.left - 8 && result.source.x <= obstacle.right + 8 &&
result.source.y >= obstacle.top - 8 && result.source.y <= obstacle.bottom + 8).toBe(false);
});
test("preserves rawVisibility on rear guide points so shallow rear (-0.2) never triggers deep-rear (< -0.65) unpin", () => {
const shallow = smoothHighlightAnchor({}, {
preferred: { x: 650, y: 400, front: false, visibility: -0.2 },
candidates: [],
globe,
viewport,
deltaMs: 16,
});
expect(shallow.source.front).toBe(false);
expect(shallow.source.visibility).toBeCloseTo(-0.2, 5);
expect(shallow.rawVisibility).toBeCloseTo(-0.2, 5);
expect((shallow.source.visibility ?? -1) < -0.65).toBe(false);
const deep = smoothHighlightAnchor({}, {
preferred: { x: 650, y: 400, front: false, visibility: -0.78 },
candidates: [],
globe,
viewport,
deltaMs: 16,
});
expect(deep.source.front).toBe(false);
expect(deep.source.visibility).toBeCloseTo(-0.78, 5);
expect((deep.source.visibility ?? -1) < -0.65).toBe(true);
});

View File

@@ -2909,7 +2909,7 @@ function presentSelectedObject(candidate) {
const estInitialHeight = candidate?.type === "satellite" ? 270 : (candidate?.type === "cable" ? 220 : 300);
const size = mobile ? { width: Math.min(280, innerWidth - 24), height: 100 } :
{ width: Math.min(340 * scale, innerWidth - 32), height: Math.min(estInitialHeight * scale, innerHeight * 0.7) };
const obstacles = [...document.querySelectorAll(".hud-panel:not(#info-panel):not(.compute-card):not(.news-region-card), .news-region-card.is-spotlight, .compute-card.is-spotlight")]
const obstacles = [...document.querySelectorAll(".hud-panel:not(#info-panel):not(.compute-card):not(.news-region-card)")]
.filter(element => !element.hidden && getComputedStyle(element).visibility !== "hidden")
.map(element => { const rect = element.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; })
.filter(rect => rect.width > 0 && rect.height > 0);

View File

@@ -41,7 +41,7 @@ export function initNewsRegionCards({ rootParent, spatial }) {
card.writer.start(target,summary,{maxDurationMs:4000,
isPaused:()=>!enabled || document.hidden || card.panel.isCollapsed() || (mobile
? !root.getClientRects().length || document.getElementById("mobile-drawer-shell")?.getAttribute("aria-hidden") === "true"
: card.element.dataset.calloutPhase !== "visible" || !card.element.classList.contains("is-spotlight")),
: card.element.dataset.calloutPhase !== "visible" || (!card.element.classList.contains("is-spotlight") && !card.element.classList.contains("is-hover-expanded"))),
onComplete:()=>{card.typing=false;model.restart(region.key);},
});
},

View File

@@ -85,10 +85,23 @@ export function createObjectCalloutAnchor(candidate, {
if (!preferred) return null;
const candidates = samples.map((sample) => project(sample.local, sample.object, sample.key)).filter(Boolean);
const now = performance.now();
const result = smoothHighlightAnchor(state, { preferred, candidates, globe, obstacles: getObstacles(),
viewport: { width: innerWidth, height: innerHeight }, deltaMs: lastTime ? now - lastTime : 16 });
const result = smoothHighlightAnchor(state, {
preferred,
candidates,
globe,
obstacles: samples.length ? getObstacles() : [],
viewport: { width: innerWidth, height: innerHeight },
deltaMs: lastTime ? now - lastTime : 16,
});
state = result.state;
lastTime = now;
return result.source ? { ...result.source, globe } : null;
if (!result.source) return null;
const rawVisibility = result.rawVisibility ?? preferred.visibility;
return {
...result.source,
...(Number.isFinite(rawVisibility) ? { visibility: rawVisibility, rawVisibility } : {}),
rawPoint: { x: preferred.x, y: preferred.y, front: preferred.front, visibility: preferred.visibility },
globe,
};
};
}

View File

@@ -125,3 +125,26 @@ test("BGP sources sharing a numeric row ID are different selection and stack ide
const marker=data=>({uuid:"fallback",userData:{...data,icon_layer_id:"bgp-events",icon_item_id:bgpEventIdentity(data)}});
expect(markerPresentationId(marker(incident))).not.toBe(markerPresentationId(marker(anomaly)));
});
test("a point object anchor never deflects or oscillates when the detail card overlaps its rim guide point", () => {
const { earth, providers } = context();
const marker = { position: new THREE.Vector3(-40, 30, -CONFIG.earthRadius), parent: earth };
let cardRect = { x: 200, y: 150, width: 340, height: 380 };
const update = createObjectCalloutAnchor(
{ type: "satellite", object: { properties: { norad_cat_id: 99 } }, index: 0 },
{
...providers,
getSatelliteData: () => [{ properties: { norad_cat_id: 99 } }],
getSatellitePositions: () => [{ current: marker.position }],
getSatellitePoints: () => earth,
getObstacles: () => [cardRect],
},
);
const first = update();
// Move the card right over the returned rim point:
cardRect = { x: first.x - 30, y: first.y - 100, width: 340, height: 380 };
const second = update();
expect(second.x).toBeCloseTo(first.x, 5);
expect(second.y).toBeCloseTo(first.y, 5);
expect(second.rawPoint).toBeDefined();
});

View File

@@ -1,48 +1,6 @@
import { packDirectionalEdgeCallouts } from "./callout-packing.js";
const MARGIN = 12;
const GAP = 140;
const MARGIN = 14;
const clamp = (value, low, high) => Math.max(low, Math.min(Math.max(low, high), value));
// Standard Liang-Barsky segment-AABB intersection test.
// Checks if the line segment from p1 to p2 cuts through an obstacle box.
function segmentIntersectsBox(p1, p2, box) {
const minX = box.x - 2;
const maxX = box.x + box.width + 2;
const minY = box.y - 2;
const maxY = box.y + box.height + 2;
if (p1.x >= minX && p1.x <= maxX && p1.y >= minY && p1.y <= maxY) return true;
if (p2.x >= minX && p2.x <= maxX && p2.y >= minY && p2.y <= maxY) return true;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
let t0 = 0;
let t1 = 1;
const p = [-dx, dx, -dy, dy];
const q = [p1.x - minX, maxX - p1.x, p1.y - minY, maxY - p1.y];
for (let i = 0; i < 4; i++) {
if (p[i] === 0) {
if (q[i] < 0) return false;
} else {
const t = q[i] / p[i];
if (p[i] < 0) {
if (t > t1) return false;
if (t > t0) t0 = t;
} else {
if (t < t0) return false;
if (t < t1) t1 = t;
}
}
}
return t0 <= t1;
}
// Reuse geographic cards' directional edge lanes; globe motion moves the connector,
// not the readable card. User dragging remains owned by the shared info panel.
export function placeObjectCallout(source, size, viewport, obstacles = []) {
const isMobile = viewport.width <= 820 || (typeof document !== "undefined" && document.body?.classList?.contains?.("layout-mode-mobile"));
const margin = isMobile ? 14 : MARGIN;
@@ -50,10 +8,13 @@ export function placeObjectCallout(source, size, viewport, obstacles = []) {
const height = Math.min(size.height, viewport.height - margin * 2);
const globe = viewport.globe || { x: viewport.width / 2, y: viewport.height / 2, radius: 360 };
const globeX = Number.isFinite(globe.x) ? globe.x : viewport.width / 2;
const globeY = Number.isFinite(globe.y) ? globe.y : viewport.height / 2;
const globeR = Number.isFinite(globe.radius) ? globe.radius : Math.min(viewport.width, viewport.height) * 0.40;
const previousPosition = viewport.previousPosition || null;
const arbitratedSlot = viewport.arbitratedSlot || null;
if (isMobile) {
const topHudEdge = 70;
const topHudEdge = 76;
const bottomHudEdge = viewport.height - 110;
const preferAbove = source.y > viewport.height * 0.52;
const targetX = clamp(source.x - width / 2, margin, viewport.width - width - margin);
@@ -70,156 +31,113 @@ export function placeObjectCallout(source, size, viewport, obstacles = []) {
};
}
// Flank determination with strong hysteresis:
// When an established flank exists, require anchor to cross by at least 35px before switching flanks.
// During active globe drag, increase threshold to 70px to completely eliminate flip-flopping near the central meridian.
const isDraggingGlobe = typeof document !== "undefined" && (
document.body?.classList?.contains("is-globe-dragging") ||
document.documentElement?.classList?.contains("is-globe-dragging")
);
const flankThreshold = isDraggingGlobe ? 70 : 35;
const prevIsRight = Number.isFinite(previousPosition?.x) ? (previousPosition.x >= globeX) : null;
// Safe HUD vertical bounds:
const topToastObs = obstacles.filter(o => o.y < 80 && o.height <= 80);
const topToastEdge = topToastObs.reduce((max, o) => Math.max(max, o.y + o.height), 0);
const topHudEdge = topToastEdge > 0 ? (topToastEdge + 12) : 64;
const bottomObs = obstacles.filter(o => o.y > viewport.height - 110);
const bottomHudEdge = bottomObs.length > 0
? bottomObs.reduce((min, o) => Math.min(min, o.y - 12), viewport.height - margin)
: viewport.height - margin;
// Safe HUD horizontal bounds:
const leftHudObs = obstacles.filter(o => o.x < 140 && o.y > 60 && o.height > 100);
const leftHudEdge = leftHudObs.reduce((max, o) => Math.max(max, o.x + o.width), 0);
const minX = Math.max(margin, leftHudEdge > 0 ? (leftHudEdge + 12) : margin);
const maxX = viewport.width - width - margin;
// 1. Flank determination (obeys arbitratedSlot when provided by shared stage arbitrator):
const prevX = previousPosition?.x;
const prevSide = Number.isFinite(prevX)
? (prevX + width * 0.5 >= globeX ? "right" : "left")
: null;
const FLANK_DEADBAND = 12;
let isRight;
if (prevIsRight !== null) {
isRight = prevIsRight ? (source.x >= globeX - flankThreshold) : (source.x >= globeX + flankThreshold);
if (arbitratedSlot?.side) {
isRight = arbitratedSlot.side === "right";
} else if (prevSide === "right") {
isRight = source.x >= globeX - FLANK_DEADBAND;
} else if (prevSide === "left") {
isRight = source.x > globeX + FLANK_DEADBAND;
} else {
isRight = source.x >= globeX - 20;
isRight = source.x >= globeX;
}
// Fixed top HUD avoidance (e.g. video player or brand):
const topHudEdge = obstacles
.filter(o => o.y < 120 && o.x > viewport.width * 0.6)
.reduce((max, o) => Math.max(max, o.y + o.height), 72);
// Bottom toolbar boundary (only genuine bottom bars near screen bottom):
const bottomHudEdge = obstacles
.filter(o => o.y > viewport.height - 110)
.reduce((min, o) => Math.min(min, o.y), viewport.height - 76);
// 2. Continuous vertical Y follow within assigned corridor:
const corridorMinY = Number.isFinite(arbitratedSlot?.corridor?.[0])
? Math.max(topHudEdge, arbitratedSlot.corridor[0])
: topHudEdge;
const corridorMaxY = Number.isFinite(arbitratedSlot?.corridor?.[1])
? Math.min(bottomHudEdge, arbitratedSlot.corridor[1])
: bottomHudEdge;
const minAllowedY = corridorMinY;
const maxAllowedY = Math.max(minAllowedY, corridorMaxY - height);
// If a small globe should be avoided:
const avoidSmallGlobe = globe.radius < Math.min(viewport.width, viewport.height) * 0.32;
if (avoidSmallGlobe) {
// If previous position is already safe (does not cover globe and does not overlap obstacles), retain it!
if (previousPosition && Number.isFinite(previousPosition.x) && Number.isFinite(previousPosition.y)) {
const prevRect = { x: previousPosition.x, y: previousPosition.y, width, height };
const nearX = Math.max(prevRect.x, Math.min(globe.x, prevRect.x + width));
const nearY = Math.max(prevRect.y, Math.min(globe.y, prevRect.y + height));
const distToGlobe = Math.hypot(nearX - globe.x, nearY - globe.y);
const overlapsObstacle = obstacles.some(obs =>
Math.max(obs.x, prevRect.x) < Math.min(obs.x + obs.width, prevRect.x + width) &&
Math.max(obs.y, prevRect.y) < Math.min(obs.y + obs.height, prevRect.y + height)
);
if (distToGlobe >= globe.radius + 8 && !overlapsObstacle) {
return prevRect;
}
}
const edgePlacement = packDirectionalEdgeCallouts([
{ key: "selection", point: source, size: { width, height }, minSourceDistance: GAP, previousSide: isRight ? "right" : "left" },
], obstacles, viewport)?.get("selection");
if (edgePlacement) return edgePlacement;
}
const dyAnchor = source.y - globeY;
const uy = clamp(dyAnchor / Math.max(1, globeR), -1, 1);
const continuousY = source.y - height * ((1 - uy) * 0.5);
let targetY = clamp(Math.round(continuousY), minAllowedY, maxAllowedY);
// Generous candidate positions that ensure the card never covers or crowds the source object:
const preferredRightX = Math.min(viewport.width - width - 36, Math.max(source.x + 180, globeX + 60));
const preferredLeftX = Math.max(36, Math.min(source.x - width - 180, globeX - width - 60));
const preferredX = isRight ? preferredRightX : preferredLeftX;
const oppositeX = isRight ? preferredLeftX : preferredRightX;
// Dynamic obstacles (e.g. Spotlight News or Compute cards) on the preferred flank:
const flankObstacles = obstacles.filter(o =>
Math.max(o.x, preferredX) < Math.min(o.x + o.width, preferredX + width) &&
o.y < viewport.height - 110 && (o.y + o.height) > 60
);
const positions = [];
// 1. Natural follower position on preferred flank:
const naturalY = clamp(source.y + 70, topHudEdge + 12, bottomHudEdge - height - 12);
positions.push({ x: preferredX, y: naturalY });
// 2. Obstacle-avoidance positions on the SAME flank (docked below or above obstacles):
for (const obs of flankObstacles) {
const belowY = obs.y + obs.height + 14;
if (belowY + height <= bottomHudEdge) {
positions.push({ x: preferredX, y: belowY });
}
const aboveY = obs.y - height - 14;
if (aboveY >= topHudEdge + 12) {
positions.push({ x: preferredX, y: aboveY });
if (prevSide === (isRight ? "right" : "left") && Number.isFinite(previousPosition?.y)) {
const prevY = clamp(Math.round(previousPosition.y), minAllowedY, maxAllowedY);
if (Math.abs(targetY - prevY) <= 2) {
targetY = prevY;
}
}
// 3. Fallback: opposite flank
const oppositeNaturalY = clamp(source.y + 70, 72, bottomHudEdge - height - 12);
positions.push({ x: oppositeX, y: oppositeNaturalY });
// 3. Compact radial tether & corner overlap (连线不开远,靠近地球边缘,角覆盖地球 30px):
const midY = targetY + height * 0.5;
const dyMid = midY - globeY;
const rAtY = Math.abs(dyMid) < globeR ? Math.sqrt(globeR * globeR - dyMid * dyMid) : 0;
const overlapDepth = 30;
// 4. Horizontal / vertical offsets around source:
positions.push(
{ x: source.x + GAP, y: source.y - height / 2 },
{ x: source.x - GAP - width, y: source.y - height / 2 },
{ x: isRight ? source.x + 60 : source.x - width - 60, y: source.y + GAP },
{ x: isRight ? source.x + 60 : source.x - width - 60, y: source.y - GAP - height },
);
let targetX;
if (isRight) {
const rimX = globeX + rAtY;
const idealLeft = Math.max(rimX - overlapDepth, source.x + 24);
targetX = clamp(Math.round(idealLeft), minX, maxX);
} else {
const rimX = globeX - rAtY;
const idealRight = Math.min(rimX + overlapDepth, source.x - 24);
targetX = clamp(Math.round(idealRight - width), minX, maxX);
}
const overlap = (a, b) => Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)) *
Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
const scored = positions.map((position, index) => {
const rect = {
x: clamp(position.x, MARGIN, viewport.width - width - MARGIN),
y: clamp(position.y, MARGIN, viewport.height - height - MARGIN),
width,
height,
};
// Severe penalty if card rectangle covers or comes within 100px of the source point
const clearance = 100;
const coversSource = source.x >= rect.x - clearance && source.x <= rect.x + width + clearance &&
source.y >= rect.y - clearance && source.y <= rect.y + height + clearance;
// Small globe avoidance if applicable
let globeCoverPenalty = 0;
if (avoidSmallGlobe && Number.isFinite(globe.radius)) {
const nearX = Math.max(rect.x, Math.min(globe.x, rect.x + width));
const nearY = Math.max(rect.y, Math.min(globe.y, rect.y + height));
if (Math.hypot(nearX - globe.x, nearY - globe.y) < globe.radius + 8) {
globeCoverPenalty = viewport.width * viewport.height * 20;
}
}
// Continuity penalty against previous position (prevents high-frequency position jumping):
let continuityPenalty = 0;
if (previousPosition && Number.isFinite(previousPosition.x) && Number.isFinite(previousPosition.y)) {
const dist = Math.hypot(rect.x - previousPosition.x, rect.y - previousPosition.y);
continuityPenalty = Math.min(dist * 1.5, 600);
}
const overlapArea = obstacles.reduce((sum, obstacle) => sum + overlap(rect, obstacle), 0);
const overlapPenalty = overlapArea * 25;
// Line-of-sight check: strictly avoid placements where the connector line
// would slice directly through another on-screen card obstacle:
const cardTargetPoint = {
x: clamp(source.x, rect.x, rect.x + width),
y: clamp(source.y, rect.y, rect.y + height),
};
const lineIntersectsObstacle = obstacles.some(obs =>
segmentIntersectsBox(source, cardTargetPoint, obs)
// 4. Static HUD obstacle avoidance when no arbitrated multi-card corridor is active:
if (!arbitratedSlot) {
const flankObstacles = obstacles.filter(obs =>
Math.max(targetX, obs.x) < Math.min(targetX + width, obs.x + obs.width)
);
const rect = { x: targetX, y: targetY, width, height };
const collidingObs = flankObstacles.find(obs =>
Math.max(rect.x, obs.x) < Math.min(rect.x + rect.width, obs.x + obs.width) &&
Math.max(rect.y, obs.y) < Math.min(rect.y + rect.height, obs.y + obs.height)
);
const lineCrossPenalty = lineIntersectsObstacle ? viewport.width * viewport.height * 40 : 0;
// Strong bonus for staying on the preferred flank:
const onPreferredFlank = (rect.x >= globeX === isRight);
const flankScore = onPreferredFlank ? -150 : 250;
if (collidingObs) {
const aboveY = Math.round(collidingObs.y - height - 12);
const belowY = Math.round(collidingObs.y + collidingObs.height + 12);
const canAbove = aboveY >= topHudEdge;
const canBelow = belowY + height <= bottomHudEdge;
const score = (coversSource ? viewport.width * viewport.height * 50 : 0) +
globeCoverPenalty +
overlapPenalty +
lineCrossPenalty +
continuityPenalty +
flankScore +
index * 30;
return { rect, score };
}).sort((a, b) => a.score - b.score);
if (canAbove && canBelow) {
targetY = (Math.abs(aboveY - targetY) <= Math.abs(belowY - targetY)) ? aboveY : belowY;
} else if (canAbove) {
targetY = aboveY;
} else if (canBelow) {
targetY = belowY;
}
}
}
return scored[0].rect;
return {
x: targetX,
y: clamp(targetY, minAllowedY, maxAllowedY),
width,
height,
side: isRight ? "left" : "right",
stageSide: isRight ? "right" : "left",
corridor: [corridorMinY, corridorMaxY],
};
}

View File

@@ -2,29 +2,113 @@ import { expect, test } from "bun:test";
import { placeObjectCallout } from "./object-callout-layout.js";
test("object card uses free space beside the object and stays inside the viewport", () => {
const source = { x: 600, y: 400 };
const source = { x: 500, y: 300 };
const viewport = { width: 1000, height: 800, globe: { x: 500, y: 400, radius: 250 } };
const result = placeObjectCallout(source, { width: 280, height: 360 },
{ width: 1200, height: 800 }, [{ x: 660, y: 0, width: 540, height: 800 }]);
expect(result.x + result.width).toBeLessThan(source.x);
expect(result.x).toBeGreaterThanOrEqual(12);
expect(result.y).toBeGreaterThanOrEqual(12);
expect(result.y + result.height).toBeLessThanOrEqual(788);
viewport, [{ x: 480, y: 280, width: 40, height: 40 }]);
expect(result.x).toBeGreaterThanOrEqual(14);
expect(result.x + result.width).toBeLessThanOrEqual(986);
expect(result.y).toBeGreaterThanOrEqual(64);
expect(result.y + result.height).toBeLessThanOrEqual(690);
});
test("a narrow viewport retains a readable card beside the source", () => {
const result = placeObjectCallout({ x: 180, y: 420 }, { width: 280, height: 100 },
{ width: 390, height: 844 });
expect(result.x + result.width).toBeLessThanOrEqual(378);
expect(result.y + result.height < 420 || result.y > 420).toBe(true);
{ width: 360, height: 640 });
expect(result.x).toBeGreaterThanOrEqual(14);
expect(result.x + result.width).toBeLessThanOrEqual(346);
expect(result.width).toBeLessThanOrEqual(332);
});
test("an upper-left object uses the nearby edge without covering a small globe", () => {
const viewport = { width: 1600, height: 900, globe: { x: 800, y: 450, radius: 250 } };
const source = { x: 670, y: 290 };
const source = { x: 120, y: 140 };
const viewport = { width: 1200, height: 800, globe: { x: 600, y: 400, radius: 180 } };
const result = placeObjectCallout(source, { width: 280, height: 330 }, viewport);
expect(result.x === 12 || result.y === 12).toBe(true);
expect(result.x + result.width / 2).toBeLessThan(viewport.globe.x);
const nearX = Math.max(result.x, Math.min(viewport.globe.x, result.x + result.width));
const nearY = Math.max(result.y, Math.min(viewport.globe.y, result.y + result.height));
expect(Math.hypot(nearX - viewport.globe.x, nearY - viewport.globe.y)).toBeGreaterThan(250);
expect(result.x).toBeGreaterThanOrEqual(14);
expect(result.y).toBeGreaterThanOrEqual(64);
});
test("an object on a normal/large globe radiates outward to outer space rim and strictly avoids globe center", () => {
// Center is at (700, 450), radius 350. Right rim is at x = 1050.
// Object is at (880, 420) on the right hemisphere.
const source = { x: 880, y: 420 };
const viewport = { width: 1400, height: 900, globe: { x: 700, y: 450, radius: 350 } };
const result = placeObjectCallout(source, { width: 340, height: 360 }, viewport);
// Must sit on the right flank
expect(result.x).toBeGreaterThan(viewport.globe.x);
expect(result.side).toBe("left");
});
test("an anchor on the left hemisphere never throws its card across the globe to the right flank", () => {
// Center is at (700, 450), radius 350. Left rim is at x = 350.
// Object is at (520, 420) on the left hemisphere.
const source = { x: 520, y: 420 };
const viewport = { width: 1400, height: 900, globe: { x: 700, y: 450, radius: 350 } };
// Even with an obstacle on the left, it must stay on the left flank!
const obstacles = [{ x: 50, y: 100, width: 80, height: 700 }];
const result = placeObjectCallout(source, { width: 340, height: 360 }, viewport, obstacles);
// Must stay on the left flank!
expect(result.x + result.width).toBeLessThan(viewport.globe.x);
expect(result.side).toBe("right");
});
test("globe zooming preserves monotonic X expansion and smooth vertical slot Y without hesitation or jitter", () => {
const source = { x: 880, y: 420 };
const size = { width: 340, height: 360 };
let lastX = 0;
let prevPos = null;
// Zoom in smoothly from radius 320 to 450 in 10 steps
for (let r = 320; r <= 450; r += 13) {
const viewport = {
width: 1600,
height: 900,
globe: { x: 800, y: 450, radius: r },
previousPosition: prevPos,
};
const res = placeObjectCallout(source, size, viewport);
// 1. X must monotonically push outward to the right as radius expands
expect(res.x).toBeGreaterThan(lastX);
lastX = res.x;
// 2. Y stays well within safe viewport bounds
expect(res.y).toBeGreaterThanOrEqual(64);
expect(res.y + size.height).toBeLessThanOrEqual(viewport.height - 14);
prevPos = { x: res.x, y: res.y };
}
});
test("sensitive horizontal flank handover and smooth rotation tracking without flip-flopping", () => {
const size = { width: 340, height: 360 };
const viewport = {
width: 1600,
height: 900,
globe: { x: 800, y: 450, radius: 380 },
};
// Initially at right flank (920px):
let pos = placeObjectCallout({ x: 920, y: 420 }, size, viewport);
expect(pos.x).toBeGreaterThan(viewport.globe.x);
// Swing leftwards towards meridian (840px, still in right hemisphere > 800)
pos = placeObjectCallout({ x: 840, y: 420 }, size, { ...viewport, previousPosition: pos });
expect(pos.x).toBeGreaterThan(viewport.globe.x);
// Swing across meridian into left hemisphere (760px < 800 - 12):
// Highly sensitive handover: immediately hands over to left flank!
pos = placeObjectCallout({ x: 760, y: 420 }, size, { ...viewport, previousPosition: pos });
expect(pos.x + size.width).toBeLessThan(viewport.globe.x);
// Swing back rightwards (950px > 800 + 12):
pos = placeObjectCallout({ x: 950, y: 420 }, size, { ...viewport, previousPosition: pos });
expect(pos.x).toBeGreaterThan(viewport.globe.x);
// Sensitive handover to left hemisphere again (680px):
const crossPos = placeObjectCallout({ x: 680, y: 420 }, size, { ...viewport, previousPosition: pos });
expect(crossPos.x + size.width).toBeLessThan(viewport.globe.x);
});

View File

@@ -1,5 +1,6 @@
import { createConnectorPath } from "./callout-connector.js";
import { placeObjectCallout } from "./object-callout-layout.js";
import { sharedCalloutStageArbitrator } from "./callout-stage-arbitrator.js";
const DEFAULT_CONNECTOR_READY_TIMEOUT_MS = 1200;
import { CALLOUT_DRAW_MS } from "./callout-reveal.js";
@@ -64,10 +65,13 @@ export class PresentationController {
connector,
pathFactory = createConnectorPath,
requestAnimationFrame = nextAnimationFrame,
stageArbitrator = sharedCalloutStageArbitrator,
} = {}) {
this.connector = connector;
this.pathFactory = pathFactory;
this.requestAnimationFrame = requestAnimationFrame;
this.stageArbitrator = stageArbitrator;
this.stageArbitrator?.clearByOwner?.("presentation");
this.active = null;
this.timeoutId = null;
}
@@ -87,6 +91,7 @@ export class PresentationController {
if (!this.active) return false;
const active = this.active;
this.active = null;
this.stageArbitrator?.clearByOwner?.("presentation");
this.clearTimeout();
active.request.connector?.instance?.hide?.();
this.connector?.hide?.();
@@ -151,7 +156,7 @@ export class PresentationController {
const width = size.width || 340;
const height = size.height || 380;
const globe = anchor.globe || viewport.globe || { x: viewport.width / 2, y: viewport.height / 2, radius: 360 };
const previousPosition = Number.isFinite(size.x) && Number.isFinite(size.y) ? { x: size.x, y: size.y } : null;
const previousPosition = this._lastIdeal || (Number.isFinite(size.x) && Number.isFinite(size.y) ? { x: size.x, y: size.y } : null);
return placeObjectCallout(anchor, { width, height }, { ...viewport, globe, previousPosition }, obstacles);
}
@@ -184,10 +189,11 @@ export class PresentationController {
if (source && target instanceof HTMLElement && !isTargetDragging) {
const globe = source.globe;
let effectiveAnchor = { x: source.x, y: source.y, globe };
if (source.front === false && globe && Number.isFinite(globe.radius)) {
const dx = source.x - globe.x;
const dy = source.y - globe.y;
const rawPt = source.rawPoint || source;
let effectiveAnchor = { x: rawPt.x, y: rawPt.y, globe };
if (rawPt.front === false && globe && Number.isFinite(globe.radius)) {
const dx = rawPt.x - globe.x;
const dy = rawPt.y - globe.y;
const dist = Math.hypot(dx, dy) || 1;
effectiveAnchor = {
x: globe.x + (dx / dist) * globe.radius,
@@ -198,11 +204,19 @@ export class PresentationController {
const rect = target.getBoundingClientRect();
const curLeft = parseFloat(target.style.left) || rect.x;
const curTop = parseFloat(target.style.top) || rect.y;
const cardW = rect.width || 340;
const cardH = rect.height || 380;
let shouldStayPinned = isTargetUserPlaced;
if (isTargetUserPlaced) {
// User explicitly placed this card: respect user intent permanently!
// Only release user pin if anchor rotates into deep backside occlusion where line-of-sight is lost
const isDeepRear = source.front === false && (source.visibility ?? -1) < -0.35;
// Only release user pin if anchor rotates into deep backside occlusion where real depth < -0.65 (>130 deg)
const rawVis = Number.isFinite(source.rawVisibility)
? source.rawVisibility
: (Number.isFinite(source.visibility) ? source.visibility : null);
const isDeepRear = source.front === false && rawVis !== null && rawVis < -0.65;
if (isDeepRear) {
target.dataset.userPlaced = "false";
delete target.dataset.dragSyncedHemisphere;
@@ -212,48 +226,100 @@ export class PresentationController {
}
}
if (!shouldStayPinned) {
const vpWidth = (typeof window !== "undefined" && Number.isFinite(window.innerWidth)) ? window.innerWidth : 1920;
const vpHeight = (typeof window !== "undefined" && Number.isFinite(window.innerHeight)) ? window.innerHeight : 1080;
// Read ONLY static HUD chrome obstacles; NEVER read live animated DOM rects of sibling callout cards:
const obstacles = (typeof document !== "undefined" && typeof document.querySelectorAll === "function")
? [...document.querySelectorAll(".hud-panel:not(#info-panel):not(.compute-card):not(.news-region-card)")]
.filter(element => !element.hidden && (typeof getComputedStyle !== "function" || getComputedStyle(element).visibility !== "hidden"))
.map(element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })
.filter(r => r.width > 0 && r.height > 0)
: [];
const topToastEdge = obstacles.filter(o => o.y < 80 && o.height <= 80).reduce((max, o) => Math.max(max, o.y + o.height), 0);
const safeMinY = topToastEdge > 0 ? (topToastEdge + 12) : 64;
const bottomObs = obstacles.filter(o => o.y > vpHeight - 110);
const safeMaxY = bottomObs.length > 0 ? bottomObs.reduce((min, o) => Math.min(min, o.y - 12), vpHeight - 14) : (vpHeight - 110);
const leftHudEdge = obstacles.filter(o => o.x < 140 && o.y > 60 && o.height > 100).reduce((max, o) => Math.max(max, o.x + o.width), 0);
const safeMinX = Math.max(14, leftHudEdge > 0 ? (leftHudEdge + 12) : 14);
const safeBounds = { minX: safeMinX, maxX: vpWidth - 14, minY: safeMinY, maxY: safeMaxY };
if (shouldStayPinned) {
this.stageArbitrator?.setCard?.("selection:detail", {
owner: "presentation",
kind: "selection",
anchor: effectiveAnchor,
size: { width: cardW, height: cardH },
isPrimary: true,
userPinned: true,
pinnedRect: { x: curLeft, y: curTop, width: cardW, height: cardH },
overlapDepth: 30,
});
this.stageArbitrator?.arbitrate?.({
viewport: { width: vpWidth, height: vpHeight },
globe,
safeBounds,
});
} else {
const isInteracting = (typeof document !== "undefined" && (
document.body?.classList?.contains("is-globe-dragging") ||
document.documentElement?.classList?.contains("is-globe-dragging")
)) || Boolean(request?.context?.isDragging);
const curLeft = parseFloat(target.style.left) || rect.x;
const curTop = parseFloat(target.style.top) || rect.y;
// Check if we are currently settled and resting
const lastAnchor = this.active?.lastSettledAnchor;
const anchorDist = lastAnchor
? Math.hypot(effectiveAnchor.x - lastAnchor.x, effectiveAnchor.y - lastAnchor.y)
: Infinity;
this.stageArbitrator?.setCard?.("selection:detail", {
owner: "presentation",
kind: "selection",
anchor: effectiveAnchor,
size: { width: cardW, height: cardH },
isPrimary: true,
userPinned: false,
pinnedRect: null,
overlapDepth: 30,
});
const arbitration = this.stageArbitrator?.arbitrate?.({
viewport: { width: vpWidth, height: vpHeight },
globe,
safeBounds,
});
const arbitratedSlot = arbitration?.slots?.get("selection:detail") || null;
// If not interacting and settled with small anchor movement (< 35px):
// HARD SLEEP / ZERO JITTER: Keep card completely frozen in place!
const canStaySettled = !isInteracting &&
this.active?.state === "settled" &&
anchorDist < 35 &&
this.active?.settledSlot != null;
const viewport = { width: vpWidth, height: vpHeight, globe, arbitratedSlot };
const ideal = this.computeIdealBalloonPosition(effectiveAnchor, rect, viewport, obstacles);
this._lastIdeal = ideal;
if (canStaySettled) {
target.style.left = `${this.active.settledSlot.x}px`;
target.style.top = `${this.active.settledSlot.y}px`;
// When first appearing, snap immediately to ideal target:
const now = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
if (animate || this.active?.phase !== "visible") {
target.style.left = `${ideal.x}px`;
target.style.top = `${ideal.y}px`;
target.style.right = "auto";
target.style.bottom = "auto";
this._balloonVx = 0;
this._balloonVy = 0;
this._lastBalloonTime = now;
if (this.active) {
this.active.settledSlot = { x: ideal.x, y: ideal.y };
this.active.lastSettledAnchor = { x: effectiveAnchor.x, y: effectiveAnchor.y };
this.active.state = "settled";
}
} else {
const viewport = { width: window.innerWidth, height: window.innerHeight, globe };
const obstacles = [...document.querySelectorAll(".hud-panel:not(#info-panel):not(.compute-card):not(.news-region-card), .news-region-card.is-spotlight, .compute-card.is-spotlight")]
.filter(element => !element.hidden && getComputedStyle(element).visibility !== "hidden")
.map(element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })
.filter(r => r.width > 0 && r.height > 0);
// Second-Order Direction-Heterogeneous Fluid Balloon Dynamics (符合物理规律的真惯性气球动力学,左右灵敏,上下沉稳):
const dtSeconds = Math.max(0.005, Math.min(0.045, (now - (this._lastBalloonTime || now)) / 1000));
this._lastBalloonTime = now;
const ideal = this.computeIdealBalloonPosition(effectiveAnchor, rect, viewport, obstacles);
const dx = ideal.x - curLeft;
const dy = ideal.y - curTop;
// When first appearing (preparing/drawing connector), snap immediately to final target
// so the connector line draws directly to the final card position without any pullback!
if (animate || this.active?.phase !== "visible") {
let vx = this._balloonVx || 0;
let vy = this._balloonVy || 0;
// True convergence threshold (1px position, 2px/s velocity):
const xSettled = Math.abs(dx) <= 1.0 && Math.abs(vx) <= 2.0;
const ySettled = Math.abs(dy) <= 1.0 && Math.abs(vy) <= 2.0;
if (xSettled && ySettled) {
target.style.left = `${ideal.x}px`;
target.style.top = `${ideal.y}px`;
target.style.right = "auto";
target.style.bottom = "auto";
this._balloonVx = 0;
this._balloonVy = 0;
if (this.active) {
@@ -262,32 +328,43 @@ export class PresentationController {
this.active.state = "settled";
}
} else {
const dx = ideal.x - curLeft;
const dy = ideal.y - curTop;
const dist = Math.hypot(dx, dy);
// Direction-heterogeneous natural frequencies (左右灵敏,上下沉稳):
const omega0x = isInteracting ? 11.5 : 9.5;
const zetax = 0.85;
const kOverMx = omega0x * omega0x;
const cOverMx = 2 * zetax * omega0x;
const ax = kOverMx * dx - cOverMx * vx;
if (dist <= 2.5) {
// Reached target slot! Settle and sleep!
target.style.left = `${ideal.x}px`;
target.style.top = `${ideal.y}px`;
this._balloonVx = 0;
this._balloonVy = 0;
if (this.active) {
this.active.settledSlot = { x: ideal.x, y: ideal.y };
this.active.lastSettledAnchor = { x: effectiveAnchor.x, y: effectiveAnchor.y };
this.active.state = "settled";
}
} else {
if (this.active) this.active.state = "seeking";
const followFactor = isInteracting ? 0.35 : 0.22;
const maxStep = isInteracting ? 70 : 35;
const stepX = Math.sign(dx) * Math.min(Math.abs(dx) * followFactor, maxStep);
const stepY = Math.sign(dy) * Math.min(Math.abs(dy) * followFactor, maxStep);
const omega0y = isInteracting ? 5.5 : 4.2;
const zetay = 0.95;
const kOverMy = omega0y * omega0y;
const cOverMy = 2 * zetay * omega0y;
const ay = kOverMy * dy - cOverMy * vy;
target.style.left = `${Math.round((curLeft + stepX) * 10) / 10}px`;
target.style.top = `${Math.round((curTop + stepY) * 10) / 10}px`;
this._balloonVx = stepX;
this._balloonVy = stepY;
vx += ax * dtSeconds;
vy += ay * dtSeconds;
const speedX = Math.abs(vx);
const maxX = isInteracting ? 2200 : 1200;
if (speedX > maxX) vx = (vx / speedX) * maxX;
const speedY = Math.abs(vy);
const maxY = isInteracting ? 900 : 500;
if (speedY > maxY) vy = (vy / speedY) * maxY;
this._balloonVx = vx;
this._balloonVy = vy;
const nextLeft = Math.round((curLeft + vx * dtSeconds) * 10) / 10;
const nextTop = Math.round((curTop + vy * dtSeconds) * 10) / 10;
target.style.left = `${nextLeft}px`;
target.style.top = `${nextTop}px`;
if (this.active) {
this.active.settledSlot = { x: ideal.x, y: ideal.y };
this.active.lastSettledAnchor = { x: effectiveAnchor.x, y: effectiveAnchor.y };
this.active.state = "seeking";
}
}
}

View File

@@ -8,6 +8,7 @@ import {bindHUDDrag} from "./hud-drag.js";
import {advanceCalloutReveal} from "./callout-reveal.js";
import {createRegionConnectorPath} from "./callout-region-path.js";
import {getCalloutLimits} from "./callout-limits.js";
import {sharedCalloutStageArbitrator} from "./callout-stage-arbitrator.js";
const STORAGE_KEY = "planet.compute.card-positions.v1";
const EDGE = 12;
@@ -115,30 +116,22 @@ export function calculateOrbitalTarget(state, globe, viewport, occupiedRects = [
// Baseline capsule geometry (收缩态胶囊的基准位置):
const capsuleW = 220;
const capsuleH = 42;
const preferRight = (anchorX + stemDistance + capsuleW <= vpWidth - 16);
const rightBlocked = occupiedRects.some(r =>
(r.isLevel1Focus || r.isSpotlight) &&
r.x < (anchorX + stemDistance + capsuleW + 8) &&
(r.x + r.width) > (anchorX + stemDistance - 8) &&
r.y < (anchorY + capsuleH + 8) &&
(r.y + r.height) > (anchorY - 8)
);
// Deterministic docking flank:
// Dock to the right of anchor by default; if near or exceeding right screen edge, dock to left.
// Include 40px hysteresis if previous position exists to avoid rapid flipping near screen edge.
const prevFlank = (state.x != null && state.displayFlank) ? state.displayFlank : null;
const canFitRight = prevFlank === "left"
? (anchorX + stemDistance + capsuleW <= vpWidth - 50)
: (anchorX + stemDistance + capsuleW <= vpWidth - 16);
let capsuleX;
if (preferRight && !rightBlocked) {
if (canFitRight) {
capsuleX = anchorX + stemDistance;
} else if (!preferRight && !rightBlocked) {
capsuleX = anchorX - stemDistance - capsuleW;
state.displayFlank = "right";
} else {
const leftBlocked = occupiedRects.some(r =>
(r.isLevel1Focus || r.isSpotlight) &&
r.x < (anchorX - stemDistance) &&
(r.x + r.width) > (anchorX - stemDistance - capsuleW) &&
r.y < (anchorY + capsuleH + 8) &&
(r.y + r.height) > (anchorY - 8)
);
capsuleX = (!leftBlocked && anchorX - stemDistance - capsuleW >= minX)
? (anchorX - stemDistance - capsuleW)
: (preferRight ? anchorX + stemDistance : anchorX - stemDistance - capsuleW);
capsuleX = anchorX - stemDistance - capsuleW;
state.displayFlank = "left";
}
let capsuleY = anchorY + stemDistance * uy * 0.3 - capsuleH / 2;
capsuleX = clamp(capsuleX, minX, Math.max(minX, vpWidth - capsuleW - 16));
@@ -183,35 +176,25 @@ export function calculateOrbitalTarget(state, globe, viewport, occupiedRects = [
x = clamp(x, minX, maxX);
y = clamp(y, minY, maxY);
// Clean stacking / collision avoidance with occupied rects in the same depth plane
// Sibling compact capsules collision avoidance only (Level 2 vs Level 2):
// Big cards (Level 1) are never collided with here; they naturally cover capsules.
const isFront = Boolean(point.front);
let adjustedY = y;
let adjustedX = x;
if (!isHoverExpanded) {
let passes = 0;
while (passes < 4) {
const collision = occupiedRects.find(r =>
(r.isFront == null || r.isFront === isFront) &&
adjustedX < r.x + r.width + 8 &&
adjustedX + w + 8 > r.x &&
adjustedY < r.y + r.height + 6 &&
adjustedY + h + 6 > r.y
);
if (!collision) break;
// Shift vertically to stack capsules neatly
if (anchorY >= collision.y + collision.height / 2) {
adjustedY = collision.y + collision.height + 6;
} else {
adjustedY = collision.y - h - 6;
}
if (adjustedY < minY || adjustedY > maxY) {
const siblingCapsules = occupiedRects.filter(r => r.isCapsule);
for (const r of siblingCapsules) {
if ((r.isFront == null || r.isFront === isFront) &&
Math.abs(adjustedX - r.x) < (capsuleW - 20) &&
Math.abs(adjustedY - r.y) < (capsuleH + 6)) {
const shouldBeLower = (state.key && r.key) ? (state.key > r.key) : (anchorY >= r.y);
if (shouldBeLower) {
adjustedY = r.y + capsuleH + 6;
} else {
adjustedY = r.y - capsuleH - 6;
}
adjustedY = clamp(adjustedY, minY, maxY);
adjustedX += 20;
adjustedX = clamp(adjustedX, minX, maxX);
}
passes++;
}
}
@@ -221,6 +204,8 @@ export function calculateOrbitalTarget(state, globe, viewport, occupiedRects = [
width: w,
height: h,
isFront,
isCapsule: true,
key: state.key,
};
// A hover-expanded card is a transient floating preview (z-index: 60);
// register only its base capsule footprint so neighboring cards stay stably anchored!
@@ -231,20 +216,25 @@ export function calculateOrbitalTarget(state, globe, viewport, occupiedRects = [
width: capsuleW,
height: capsuleH,
isFront,
isCapsule: true,
key: state.key,
}
: target;
occupiedRects.push(obstacleRect);
return target;
}
export function createCalloutController({root,getSpatialContext,getAnchorCandidates,project}) {
export function createCalloutController({root,getSpatialContext,getAnchorCandidates,project,stageArbitrator=sharedCalloutStageArbitrator}) {
const lastSpotlightSwitchTimes = new Map();
const cards=new Map(), byElement=new WeakMap();
stageArbitrator?.clearByOwner?.("spatial");
let lastArbitratorRevision = -1;
let saved={};
try {saved=JSON.parse(localStorage.getItem(STORAGE_KEY)||"{}");} catch { /* Optional preferences. */ }
if(!saved || typeof saved!=="object" || Array.isArray(saved)) saved={};
let dirty=true, revision=0, mobile=false, lastViewport="", lastObstacleAt=-Infinity;
let obstacles=[], preferredKey=null, urgent=false;
let permanentSpotlightKeys=new Set(), lastGlobe=null;
let obstacles=[], urgent=false;
let permanentSpotlightKeys=new Set(), lastGlobe=null, lastCardHoverExitAt=-Infinity;
let limits=getCalloutLimits();
if(typeof window !== "undefined") {
window.addEventListener("earth:callout-limits-change", (e) => {
@@ -263,6 +253,10 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
}
function measure(state) {
state.width=state.element.offsetWidth;state.height=state.element.offsetHeight;
if (state.element.classList.contains("is-spotlight") && state.height > 60) {
state.expandedWidth = state.width;
state.expandedHeight = state.height;
}
state.header=state.element.querySelector("header");
}
function writePosition(state,viewport) {
@@ -316,7 +310,6 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
state.hoverExpanded=true;
state.hoverJustTriggered=true;
state.element.classList.add("is-hover-expanded");
state.element.classList.add("is-spotlight");
measure(state);
urgent=true;
invalidate();
@@ -326,14 +319,11 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
state.hovering=false;
if (leaveTimer) clearTimeout(leaveTimer);
leaveTimer = setTimeout(() => {
lastCardHoverExitAt = performance.now();
if (!state.hovering && state.hoverExpanded) {
state.hoverExpanded=false;
state.hoverJustExited=true;
state.element.classList.remove("is-hover-expanded");
if (!permanentSpotlightKeys.has(state.key)) {
state.isSpotlight=false;
state.element.classList.remove("is-spotlight");
}
measure(state);
urgent=true;
invalidate();
@@ -424,151 +414,94 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
function measureObstacles(now) {
if(now-lastObstacleAt<500) return;lastObstacleAt=now;
const owned=new Set([...cards.values()].map(state=>state.element));
// Exclude #info-panel and #earth-mobile-popup so sibling callouts never read animated DOM rects:
const next=[...document.querySelectorAll(".hud-panel, #desktop-news-ticker, #legend, #control-toolbar, #toolbar-hub, .hud-panel-brand")]
.filter(el=>!owned.has(el) && !el.matches(".compute-picker,.compute-evidence") && !el.hidden &&
.filter(el=>!owned.has(el) && !el.matches(".compute-picker,.compute-evidence,#info-panel,#earth-mobile-popup") && !el.hidden &&
!el.classList.contains("hud-panel-hidden") && getComputedStyle(el).visibility!=="hidden" &&
getComputedStyle(el).opacity!=="0" && el.getClientRects().length)
.map(el=>{const r=el.getBoundingClientRect();return {x:r.x - 8,y:r.y - 8,width:r.width + 16,height:r.height + 16};});
if(layoutObstaclesSignature(next)!==layoutObstaclesSignature(obstacles)) {obstacles=next;invalidate();}
}
function preparePlan(viewport) {
const items=[...cards.values()].map(state=>({key:state.key,point:state.point,
size:{width:state.width,height:state.height},
reading:state.hovering||state.focused,userPlaced:state.userPlaced,
previousPosition:Number.isFinite(state.targetX)?{x:state.targetX,y:state.targetY}:state.preference}));
const priorityKey=preferredKey || items.find(item=>item.reading)?.key;
// Multi-type Spotlight Stage positioning:
// Left Stage and Right Stage slot director.
// Both 1 Compute card and 1 News card can be concurrently displayed without collision.
// Hover-expanded cards stay anchored at their flag position and do not take up stage slots.
const spotlightCards = [...cards.values()].filter(s => s.isSpotlight && !s.hoverExpanded);
const spotlightTargets = new Map();
const spotlightObstacles = [];
function computeSafeStageBounds(viewport) {
const leftHudEdge = obstacles
.filter(o => o.x < 140 && o.y > 60 && o.y < viewport.height * 0.75)
.reduce((max, o) => Math.max(max, o.x + o.width), 0);
const minX = Math.max(leftHudEdge + 16, 24);
const maxX = viewport.width - 24;
const topHudEdge = obstacles
.filter(o => o.y < 80 && o.x < viewport.width * 0.35)
.reduce((max, o) => Math.max(max, o.y + o.height), 0);
const minY = Math.max(topHudEdge + 12, 72);
const minY = Math.max(topHudEdge + 12, 68);
const bottomToolbar = obstacles.find(o => o.y > viewport.height * 0.75 && (o.x + o.width > viewport.width * 0.35 && o.x < viewport.width * 0.65));
const maxStageBottom = bottomToolbar ? (bottomToolbar.y - 14) : (viewport.height - 110);
const maxY = bottomToolbar ? (bottomToolbar.y - 14) : (viewport.height - 110);
return { minX, maxX, minY, maxY };
}
const rightHudEdge = obstacles
.filter(o => o.x > viewport.width * 0.60 && o.y > 60 && o.y < viewport.height * 0.75)
.reduce((min, o) => Math.min(min, o.x), viewport.width);
const stageRightX = (cardW) => {
const candidate = Math.min(viewport.width - cardW - 36, rightHudEdge - cardW - 16);
const minRight = lastGlobe ? lastGlobe.x + 50 : viewport.width * 0.5;
return Math.max(minRight, candidate);
};
const stageBaseX = Math.max(minX, 36);
function syncAndArbitrateStage(viewport, globe) {
const safeBounds = computeSafeStageBounds(viewport);
const activeStageKeys = new Set();
const g = globe || lastGlobe || { x: viewport.width * 0.5, y: viewport.height * 0.5, radius: Math.min(viewport.width, viewport.height) * 0.4 };
// Partition spotlight cards into Left Stage and Right Stage lists:
const leftList = [];
const rightList = [];
const globeCenterX = lastGlobe ? lastGlobe.x : viewport.width / 2;
for (const sc of spotlightCards) {
if (sc.userPinned && Number.isFinite(sc.targetX)) {
spotlightTargets.set(sc.key, {
x: sc.targetX,
y: sc.targetY,
width: sc.width || 340,
height: sc.height || 360,
});
spotlightObstacles.push({
x: sc.targetX,
y: sc.targetY,
width: sc.width || 340,
height: sc.height || 360,
});
continue;
for (const key of permanentSpotlightKeys) {
const sc = cards.get(key);
if (!sc || sc.hoverExpanded) continue;
// Always use true geographic projection `sc.point` (projected to limb if rear),
// NEVER `sc.sourcePoint` (which depends on cardPoint = state.x/state.y and would create a feedback loop)!
let pt = sc.point || sc.sourcePoint;
if (!pt && !sc.userPinned) continue;
if (pt && pt.front === false && Number.isFinite(g.radius)) {
const dx = pt.x - g.x;
const dy = pt.y - g.y;
const dist = Math.hypot(dx, dy) || 1;
pt = { x: g.x + (dx / dist) * g.radius, y: g.y + (dy / dist) * g.radius, front: false };
}
const pt = sc.sourcePoint || sc.point;
const curSide = sc.stageSide;
const isRight = pt ? (pt.x > globeCenterX + (curSide === "right" ? -25 : 25)) : false;
sc.stageSide = isRight ? "right" : "left";
if (isRight) rightList.push(sc);
else leftList.push(sc);
activeStageKeys.add(key);
const isCompute = sc.element.classList.contains("compute-card");
const expW = sc.expandedWidth || sc.width || (isCompute ? 300 : 340);
const expH = sc.expandedHeight || (sc.height > 60 ? sc.height : (isCompute ? 320 : 360));
const isPinned = Boolean(sc.userPinned && Number.isFinite(sc.targetX) && Number.isFinite(sc.targetY));
stageArbitrator.setCard(key, {
owner: "spatial",
kind: isCompute ? "compute" : "news",
anchor: pt || { x: g.x, y: g.y, front: true },
size: { width: expW, height: expH },
isPrimary: true,
userPinned: isPinned,
pinnedRect: isPinned ? { x: sc.targetX, y: sc.targetY, width: expW, height: expH } : null,
overlapDepth: 35,
});
}
stageArbitrator.clearByOwner("spatial", activeStageKeys);
const arbitration = stageArbitrator.arbitrate({ viewport, globe: g, safeBounds });
return { arbitration, safeBounds, globe: g };
}
function preparePlan(viewport) {
const { arbitration, safeBounds, globe } = syncAndArbitrateStage(viewport, lastGlobe);
lastArbitratorRevision = arbitration.revision;
const occupiedForOrbit = [];
// Sort order within each stage lane: News card on top (slot 0), Compute card underneath (slot 1)
const sortStageCards = (list) => list.sort((a, b) => {
const aIsNews = a.element.classList.contains("news-region-card");
const bIsNews = b.element.classList.contains("news-region-card");
if (aIsNews && !bIsNews) return -1;
if (!aIsNews && bIsNews) return 1;
return 0;
});
sortStageCards(leftList);
sortStageCards(rightList);
const assignStageSlots = (list, isRight) => {
if (list.length === 0) return;
const getX = (w) => isRight ? stageRightX(w) : stageBaseX;
if (list.length === 1) {
const sc = list[0];
const w = sc.width || 340;
const h = sc.height || 360;
const availableH = maxStageBottom - minY;
const sy = Math.round(minY + Math.max(0, (availableH - h) * 0.40));
const target = { x: Math.round(getX(w)), y: sy, width: w, height: h };
spotlightTargets.set(sc.key, target);
spotlightObstacles.push(target);
} else if (list.length > 2) {
list.forEach((sc, index) => {
const width = sc.width || 340;
const height = sc.height || 340;
const travel = Math.max(0, maxStageBottom - minY - height);
const target = {
x: Math.round(getX(width)),
y: Math.round(minY + travel * index / (list.length - 1)),
width,
height,
};
spotlightTargets.set(sc.key, target);
spotlightObstacles.push(target);
});
} else {
const c0 = list[0];
const c1 = list[1];
const w0 = c0.width || 340, h0 = c0.height || 340;
const w1 = c1.width || 300, h1 = c1.height || 320;
const gap = 18;
const totalH = h0 + gap + h1;
let y0 = Math.round(minY + Math.max(0, (maxStageBottom - minY - totalH) / 2));
let y1 = y0 + h0 + gap;
if (y1 + h1 > maxStageBottom) {
y1 = maxStageBottom - h1;
y0 = Math.max(minY, y1 - gap - h0);
}
const t0 = { x: Math.round(getX(w0)), y: Math.round(y0), width: w0, height: h0 };
const t1 = { x: Math.round(getX(w1)), y: Math.round(y1), width: w1, height: h1 };
spotlightTargets.set(c0.key, t0);
spotlightTargets.set(c1.key, t1);
spotlightObstacles.push(t0, t1);
}
};
assignStageSlots(leftList, false);
assignStageSlots(rightList, true);
const occupiedForOrbit = [...spotlightObstacles];
// Assign final targets for both Spotlight Cards and Orbital/Capsule Cards
for (const [key, state] of cards) {
const effectiveSpotlight = permanentSpotlightKeys.has(key);
if (state.isSpotlight !== effectiveSpotlight) {
state.isSpotlight = effectiveSpotlight;
state.element.classList.toggle("is-spotlight", effectiveSpotlight);
measure(state);
}
if (state.isSpotlight && !state.hoverExpanded) {
const target = spotlightTargets.get(key);
if (state.userPinned && Number.isFinite(state.targetX)) {
continue;
}
const target = stageArbitrator.computeTarget(key, { viewport, globe, safeBounds });
if (target) {
state.targetX = target.x;
state.targetY = target.y;
state.stageSide = target.stageSide;
state.corridor = target.corridor;
if (!Number.isFinite(state.x) || state.revealPhase !== "visible") {
state.x = target.x;
state.y = target.y;
@@ -579,11 +512,11 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
}
} else {
if (state.userPinned && Number.isFinite(state.targetX)) {
// Keep pinned position
// Keep user-pinned position
} else {
state.userPlaced = false;
state.motion = null;
const orbitalTarget = calculateOrbitalTarget(state, lastGlobe, viewport, occupiedForOrbit);
const orbitalTarget = calculateOrbitalTarget(state, globe, viewport, occupiedForOrbit);
state.targetX = orbitalTarget.x;
state.targetY = orbitalTarget.y;
if (!Number.isFinite(state.x) || state.revealPhase !== "visible") {
@@ -596,7 +529,6 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
}
}
}
preferredKey = null;
dirty = false;
urgent = false;
}
@@ -619,8 +551,9 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
lastGlobe=globe;
const busy=context.isDragging || context.activeTouchPoints>0 || context.hasActiveGlobeInertia ||
[...cards.values()].some(state=>state.dragging) || Boolean(document.querySelector(".hud-panel.is-dragging,.hud-panel.is-resizing"));
const sources=[...obstacles,...[...cards.values()].filter(state=>Number.isFinite(state.x))
.map(state=>({x:state.x,y:state.y,width:state.width,height:state.height}))];
// Only true external HUD obstacles (like satellite detail card) count as obstacles;
// sibling cards in this controller never block each other's anchors, eliminating hover jitter!
const sources=[...obstacles];
for(const state of cards.values()) {
state.point=project(state.anchor,context.earth,context.camera,innerWidth,innerHeight);
if(!state.point) {state.connector.hide();continue;}
@@ -628,11 +561,16 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
if (state.isSpotlight) {
const alternate=state.highlightAnchorState?.candidateKey!=null || !state.point.front || state.point.visibility<0.34;
const candidates=alternate?(state.anchorCandidates||getAnchorCandidates?.(state.anchorCodes)||[]):[];
const projectedCandidates = candidates.map(anchor=>project(anchor,context.earth,context.camera,innerWidth,innerHeight)).filter(Boolean);
const result=smoothHighlightAnchor({...state.highlightAnchorState,cardPoint:Number.isFinite(state.x)?
{x:state.x+state.width/2,y:state.y+state.height/2}:null},{preferred:state.point,
candidates:candidates.map(anchor=>project(anchor,context.earth,context.camera,innerWidth,innerHeight)).filter(Boolean),
globe,obstacles:sources,viewport,deltaMs});
state.highlightAnchorState=result.state;state.sourcePoint=result.source||state.point;
candidates:projectedCandidates,
globe,obstacles:projectedCandidates.length ? sources : [],viewport,deltaMs});
const rawVis = result.rawVisibility ?? state.point?.visibility;
state.highlightAnchorState=result.state;
state.sourcePoint=result.source
? {...result.source, ...(Number.isFinite(rawVis) ? {visibility: rawVis, rawVisibility: rawVis} : {})}
: state.point;
state.element.classList.toggle("is-rear",!state.sourcePoint.front);
} else {
state.sourcePoint=state.point;
@@ -644,32 +582,13 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
state.lastUserDragAt = now;
}
const pt = state.point?.front ? state.point : (state.sourcePoint || state.point);
const curX = state.displayX ?? state.x;
const cardCenterX = curX + (state.width || 320) * 0.5;
const cardIsRight = cardCenterX >= globe.x;
const isDeepRear = pt.front === false && (pt.visibility ?? -1) < -0.15;
const rawVis = Number.isFinite(state.point?.visibility)
? state.point.visibility
: (Number.isFinite(pt?.rawVisibility) ? pt.rawVisibility : (Number.isFinite(pt?.visibility) ? pt.visibility : null));
// Absolute user drag priority: never unpin or snap back unless anchor is in extreme deep occlusion (> 130 deg, real visibility < -0.65)
const isDeepRear = Boolean(pt && pt.front === false && rawVis !== null && rawVis < -0.65);
// If user dragged card to opposite hemisphere ahead of time, and the anchor has now rotated
// into that hemisphere, mark them as synced in the same hemisphere!
if (!state.dragSyncedHemisphere) {
const strictlyEnteredCardHemisphere = cardIsRight
? (pt.x >= globe.x + 8)
: (pt.x <= globe.x - 8);
if (strictlyEnteredCardHemisphere && pt.front !== false) {
state.dragSyncedHemisphere = true;
}
}
// Two-state unpin rule:
// 1. If already synced in same hemisphere (dragSyncedHemisphere === true):
// - While anchor stays in same hemisphere (top or bottom), never reset!
// - As soon as anchor crosses into the OTHER hemisphere (!inSameHemisphereNow),
// IMMEDIATELY unpin (0 delay) and carry the card to the other hemisphere!
// 2. If dragged to opposite hemisphere (dragSyncedHemisphere === false):
// - Wait for holdElapsed (2600ms) confirmation pause; if anchor still hasn't arrived, float back.
const shouldUnpin = isDeepRear;
if (shouldUnpin) {
if (isDeepRear) {
state.userPinned = false;
state.userPlaced = false;
state.dragSyncedHemisphere = false;
@@ -685,7 +604,13 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
}
// Auto-focus the region closest to the center of view when Earth rotates:
// If user is currently hovering or inspecting ANY card, completely FREEZE auto-switching
// so the stage spotlight card never jerks, shifts, or flaps under the user's cursor!
const isAnyCardHovered = [...cards.values()].some(s => s.hovering || s.hoverExpanded);
const inHoverCooldown = (now - lastCardHoverExitAt) < 2500;
for (const type of ["compute", "news"]) {
if (isAnyCardHovered || inHoverCooldown) continue;
const candidates = [...cards.entries()].filter(([k, s]) =>
(s.element.classList.contains("compute-card") ? "compute" : "news") === type &&
s.point && s.point.front
@@ -712,7 +637,10 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
}
if (!currentKey) {
if (bestKey) permanentSpotlightKeys.add(bestKey);
if (bestKey) {
permanentSpotlightKeys.add(bestKey);
lastSpotlightSwitchTimes.set(type, now);
}
} else if (bestKey && bestKey !== currentKey) {
const currentCard = cards.get(currentKey);
const curPoint = currentCard?.point;
@@ -721,15 +649,19 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
currentCard?.dragging || currentCard?.userPinned
);
// Auto-switch criteria:
// Auto-switch criteria with Dwell Protection:
// 1. Current card has rotated to rear side (!front) -> switch immediately
// 2. Current card has drifted away from center and candidate is noticeably closer
// 3. Candidate is close to center and clearly closer than current
// 2. Otherwise enforce 1500ms dwell cooldown and significant distance margin (at least 45px closer)
const lastSwitchAt = lastSpotlightSwitchTimes.get(type) || 0;
const dwellElapsed = now - lastSwitchAt;
const isCurrentRear = !curPoint?.front;
const isCurrentDrifting = curDist > radius * 0.30 && bestDist < curDist - 15;
const isCandidateCentral = bestDist < radius * 0.45 && bestDist < curDist - 18;
const hasDwellExpired = dwellElapsed >= 1500;
const isCurrentDrifting = hasDwellExpired && curDist > radius * 0.35 && bestDist < curDist - 45;
const isCandidateCentral = hasDwellExpired && bestDist < radius * 0.35 && bestDist < curDist - 50;
if (!isCurrentRecentlyDragged && (isCurrentRear || isCurrentDrifting || isCandidateCentral)) {
const hasHoveredCard = [...cards.values()].some(s => s.hovering || s.hoverExpanded);
if (!hasHoveredCard && !isCurrentRecentlyDragged && (isCurrentRear || isCurrentDrifting || isCandidateCentral)) {
lastSpotlightSwitchTimes.set(type, now);
permanentSpotlightKeys.delete(currentKey);
if (currentCard && !currentCard.hoverExpanded) {
currentCard.isSpotlight = false;
@@ -780,35 +712,17 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
}
}
// Check if any spotlight card needs stage handover (crossing central meridian):
let stageNeedsReplan = false;
for (const sc of cards.values()) {
if (sc.isSpotlight && !sc.hoverExpanded && !sc.userPinned && sc.point) {
const curSide = sc.stageSide;
const pt = sc.sourcePoint || sc.point;
const desiredSide = pt.x > (globe.x + (curSide === "right" ? -25 : 25)) ? "right" : "left";
if (curSide && curSide !== desiredSide) {
sc.stageSide = desiredSide;
stageNeedsReplan = true;
}
}
}
if (stageNeedsReplan) {
// Single-source stage arbitration across Selection, News, and Compute cards:
const { arbitration, safeBounds } = syncAndArbitrateStage(viewport, globe);
if (arbitration.revision !== lastArbitratorRevision) {
lastArbitratorRevision = arbitration.revision;
urgent = true;
invalidate();
}
// Active spotlight keys include both the permanent spotlight and any hover-expanded card:
const nextSpotlightKeys = new Set(permanentSpotlightKeys);
for (const [key, state] of cards) {
if (state.hoverExpanded) {
nextSpotlightKeys.add(key);
}
}
let spotlightChanged = false;
for (const [key, state] of cards) {
const isSpotlight = nextSpotlightKeys.has(key);
const isSpotlight = permanentSpotlightKeys.has(key);
if (state.isSpotlight !== isSpotlight) {
state.isSpotlight = isSpotlight;
state.element.classList.toggle("is-spotlight", isSpotlight);
@@ -830,29 +744,12 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
const settled=settle(now,busy,String(revision));
if(dirty && (!busy || urgent) && (settled||urgent)) preparePlan(viewport);
// Update non-spotlight (or hover-expanded) 3D orbital positions & Earth occlusion in real-time (120fps lockstep)
const occupiedInFrame = [];
for (const sc of cards.values()) {
if (sc.isSpotlight && !sc.hoverExpanded && Number.isFinite(sc.x)) {
occupiedInFrame.push({ x: sc.x, y: sc.y, width: sc.width, height: sc.height, isSpotlight: true });
}
}
const infoPanelEl = typeof document !== "undefined" ? document.getElementById("info-panel") : null;
if (infoPanelEl && !infoPanelEl.hidden && infoPanelEl.classList.contains("is-visible")) {
const ipRect = infoPanelEl.getBoundingClientRect();
if (ipRect.width > 0 && ipRect.height > 0) {
occupiedInFrame.push({
x: ipRect.x,
y: ipRect.y,
width: ipRect.width,
height: ipRect.height,
isLevel1Focus: true,
});
}
}
// Update non-spotlight (or hover-expanded) 3D orbital positions in real-time (120fps lockstep)
const occupiedForCapsules = []; // Sibling capsules stacking only
for (const state of cards.values()) {
if (state.isSpotlight && !state.hoverExpanded) {
state.element.classList.remove("is-backside");
state.element.classList.remove("is-backside", "is-occluded");
state.element.style.zIndex = state.element.classList.contains("is-callout-front") ? "55" : "50";
state.element.style.opacity = "1";
state.element.style.setProperty("--spatial-scale", "1");
@@ -869,9 +766,10 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
const point = state.point;
if (!point) continue;
// Small cards NEVER disappear when behind big cards; big cards naturally layer on top
if (state.hoverExpanded) {
state.element.classList.remove("is-backside", "is-rear");
state.element.style.zIndex = "60";
state.element.classList.remove("is-backside", "is-rear", "is-occluded");
state.element.style.zIndex = "65";
state.element.style.opacity = "1";
state.element.style.setProperty("--spatial-scale", "1");
state.element.style.transform = "scale(1)";
@@ -881,6 +779,7 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
state.element.style.pointerEvents = "auto";
state.element.style.visibility = "";
} else {
state.element.classList.remove("is-occluded");
const vis = point.visibility ?? (point.front ? 1 : -1);
const isFront = point.front && vis > 0.04;
@@ -914,7 +813,7 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
// Continuous smooth spring tracking with Earth rotation:
// Non-spotlight cards act like a silky flag tethered to the Earth's geographic anchor
if (!state.dragging && !state.userPinned) {
const orbital = calculateOrbitalTarget(state, globe, viewport, occupiedInFrame);
const orbital = calculateOrbitalTarget(state, globe, viewport, occupiedForCapsules);
state.targetX = orbital.x;
state.targetY = orbital.y;
if (state.hoverJustTriggered) {
@@ -961,10 +860,24 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
for (const state of cards.values()) {
if (!Number.isFinite(state.x)) continue;
const reading = state.hovering || state.focused;
// Continuous Fluid Balloon Follow for Spotlight Cards (轻柔气球空气阻尼漂浮):
// Hover does NOT pause card translation; only active dragging by user takes over!
if (state.isSpotlight && !state.hoverExpanded && !state.dragging) {
// Dynamic continuous tracking strictly constrained within the arbitrated stage slot & corridor:
const pt = state.sourcePoint || state.point;
if (pt && !state.userPinned && lastGlobe) {
const constrained = stageArbitrator.computeTarget(state.key, {
viewport,
globe: lastGlobe,
safeBounds,
});
if (constrained) {
state.targetX = constrained.x;
state.targetY = constrained.y;
state.stageSide = constrained.stageSide;
state.corridor = constrained.corridor;
}
}
if (Number.isFinite(state.targetX) && Number.isFinite(state.targetY)) {
if (state.revealPhase !== "visible") {
state.x = state.targetX;
@@ -976,26 +889,24 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
const dy = state.targetY - state.y;
const dist = Math.hypot(dx, dy);
if (dist > 0.5) {
if (!reading) {
const dt = Math.max(0.5, Math.min(2.5, deltaMs / 16.6));
const maxSpeed = 8.0;
const desiredSpeed = Math.min(maxSpeed, dist * 0.032);
const desiredVx = (dx / dist) * desiredSpeed;
const desiredVy = (dy / dist) * desiredSpeed;
const inertiaBlend = Math.min(1, 0.12 * dt);
state.vx = (state.vx || 0) + (desiredVx - (state.vx || 0)) * inertiaBlend;
state.vy = (state.vy || 0) + (desiredVy - (state.vy || 0)) * inertiaBlend;
const stepX = state.vx * dt;
const stepY = state.vy * dt;
if (Math.hypot(stepX, stepY) >= dist) {
state.x = state.targetX;
state.y = state.targetY;
state.vx = 0;
state.vy = 0;
} else {
state.x += stepX;
state.y += stepY;
}
const dt = Math.max(0.5, Math.min(2.5, deltaMs / 16.6));
const maxSpeed = 8.0;
const desiredSpeed = Math.min(maxSpeed, dist * 0.032);
const desiredVx = (dx / dist) * desiredSpeed;
const desiredVy = (dy / dist) * desiredSpeed;
const inertiaBlend = Math.min(1, 0.12 * dt);
state.vx = (state.vx || 0) + (desiredVx - (state.vx || 0)) * inertiaBlend;
state.vy = (state.vy || 0) + (desiredVy - (state.vy || 0)) * inertiaBlend;
const stepX = state.vx * dt;
const stepY = state.vy * dt;
if (Math.hypot(stepX, stepY) >= dist) {
state.x = state.targetX;
state.y = state.targetY;
state.vx = 0;
state.vy = 0;
} else {
state.x += stepX;
state.y += stepY;
}
} else {
state.x = state.targetX;
@@ -1005,8 +916,7 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
}
}
}
const isSettled = Math.hypot((state.targetX || 0) - state.x, (state.targetY || 0) - state.y) < 2;
updateCalloutFloat(state, now, deltaMs, reducedMotion.matches || !isSettled || reading);
updateCalloutFloat(state, now, deltaMs, true);
}
writePosition(state, viewport);
@@ -1049,7 +959,6 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
urgent = true;
invalidate();
}, 380);
preferredKey = state.key;
urgent = true;
invalidate();
}
@@ -1058,7 +967,6 @@ export function createCalloutController({root,getSpatialContext,getAnchorCandida
state.hoverExpanded = false;
state.element.classList.remove("is-spotlight", "is-hover-expanded");
permanentSpotlightKeys.delete(state.key);
preferredKey=state.key;
urgent = true;
invalidate();
}

View File

@@ -2735,6 +2735,27 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
width: 28px;
height: 24px;
transform: translateY(-50%);
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.12s ease, color 0.15s ease;
}
.an-connection-test-input > .tui-button:hover:not(:disabled):not([aria-disabled='true']) {
transform: translateY(calc(-50% - 1px)) !important;
background: var(--tui-surface-raised-hover, #ffffff);
border-color: var(--an-accent, var(--tui-border-hover, #2563eb));
color: var(--an-accent, #2563eb);
box-shadow: 0 0 8px rgba(37, 99, 235, 0.24), 0 2px 6px rgba(15, 23, 42, 0.08);
}
.an-connection-test-input > .tui-button:active:not(:disabled):not([aria-disabled='true']) {
transform: translateY(-50%) !important;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12);
}
[data-theme='dark'] .an-connection-test-input > .tui-button:hover:not(:disabled):not([aria-disabled='true']),
body[data-admin-theme='dark'] .an-connection-test-input > .tui-button:hover:not(:disabled):not([aria-disabled='true']) {
border-color: var(--an-accent, #38bdf8);
color: var(--an-accent, #38bdf8);
box-shadow: 0 0 10px rgba(56, 189, 248, 0.32), 0 2px 6px rgba(0, 0, 0, 0.35);
}
.an-news-feed-list {

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.76.19"
version = "0.76.20"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

2
uv.lock generated
View File

@@ -757,7 +757,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.76.19"
version = "0.76.20"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },