feat: add simulation settings and location board redesign

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:02:34 +08:00
co-authored by Claude
parent 65e77cc227
commit cf1d26dba9
13 changed files with 8289 additions and 141 deletions
+10 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, Uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, Uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db
@@ -22,6 +22,13 @@ class TimestampMixin:
)
class SimulationSetting(TimestampMixin, db.Model):
__tablename__ = "simulation_settings"
key: Mapped[str] = mapped_column(String(255), primary_key=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
class EngineFamily(TimestampMixin, db.Model):
__tablename__ = "engine_families"
@@ -130,9 +137,10 @@ class VehicleCost(TimestampMixin, db.Model):
class Asset(TimestampMixin, db.Model):
__tablename__ = "assets"
__table_args__ = (Index("ix_assets_name", "name"),)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
asset_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
is_retired: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
program: Mapped[str | None] = mapped_column(String(255))
+137 -48
View File
@@ -10,13 +10,13 @@ import re
from typing import Any
import uuid
from flask import Blueprint, abort, flash, has_request_context, redirect, render_template, request, session, url_for
from sqlalchemy import select, text, distinct
from sqlalchemy.exc import IntegrityError
from flask import Blueprint, abort, current_app, flash, has_request_context, redirect, render_template, request, session, url_for
from sqlalchemy import select, distinct
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import selectinload
from app.extensions import db
from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, DockingEvent, EngineFamily, EngineVariant, TankSpec, VehicleCost
from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, DockingEvent, EngineFamily, EngineVariant, SimulationSetting, TankSpec, VehicleCost
from app.services.fuel_conversion import list_fuel_factors
from app.services.log_book_importer import import_log_book_data
@@ -669,30 +669,29 @@ HARDCODED_SIMULATION_TIME = datetime(2060, 3, 12, 9, 0, tzinfo=timezone.utc)
def _db_read_simulation_time() -> datetime | None:
try:
row = db.session.execute(
text("SELECT value FROM simulation_settings WHERE key = 'current_time'")
).fetchone()
if row is None:
setting = db.session.get(SimulationSetting, "current_time")
if setting is None:
return None
return _parse_datetime(row[0], "DB Simulation Time")
except Exception:
return _parse_datetime(setting.value, "DB Simulation Time")
except (SQLAlchemyError, ValueError) as exc:
db.session.rollback()
current_app.logger.warning("Unable to read simulation time: %s", exc)
return None
def _db_write_simulation_time(value: datetime) -> None:
formatted = _format_datetime_input(value)
try:
db.session.execute(
text(
"INSERT INTO simulation_settings (key, value, updated_at) "
"VALUES ('current_time', :val, NOW()) "
"ON CONFLICT (key) DO UPDATE SET value = :val2, updated_at = NOW()"
),
{"val": formatted, "val2": formatted},
)
setting = db.session.get(SimulationSetting, "current_time")
if setting is None:
setting = SimulationSetting(key="current_time", value=formatted)
db.session.add(setting)
else:
setting.value = formatted
db.session.commit()
except Exception:
except SQLAlchemyError:
db.session.rollback()
raise
def _default_simulation_time() -> datetime:
@@ -3113,6 +3112,7 @@ def dashboard() -> str:
"location": snapshot.get("current_location", ""),
"mission": snapshot.get("current_mission", ""),
"state": snapshot.get("current_state", ""),
"future_events": snapshot.get("future_events", []),
})
# Metrics
@@ -3139,22 +3139,22 @@ def dashboard() -> str:
# Upcoming events (30 days)
now = simulation_time
upcoming_cutoff = now + timedelta(days=30)
upcoming_events = []
for a in active:
snapshot = _build_asset_snapshot(a if hasattr(a, "log_entries") else assets[0], simulation_time)
for ev in snapshot.get("future_events", []):
ev_date = ev.get("date", "")
if ev_date and ev_date >= now.strftime("%Y-%m-%d"):
for ev in a["future_events"]:
event_at = _as_utc(ev.get("sort_at"))
if event_at is not None and now < event_at <= upcoming_cutoff:
upcoming_events.append({
"date": ev_date,
"date": event_at.strftime("%Y-%m-%d"),
"title": f"{a['name']}: {ev.get('title', '')}",
"within_30d": True,
})
upcoming_events.sort(key=lambda e: e["date"])
upcoming_count = len(upcoming_events)
upcoming_events = upcoming_events[:5]
active_missions = len([a for a in active if a["state"] in ("Transfer", "Exploration")])
upcoming_count = len(upcoming_events)
upcoming_summary = f"{upcoming_count} events" if upcoming_count else "None imminent"
return render_template(
@@ -4677,43 +4677,132 @@ def mission_status_board_preview() -> str:
def mission_location_board_preview() -> str:
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
# Load all assets with logs
assets = _load_assets_with_logs()
# Build asset position data for Canvas
# Map location strings to approximate orbital bodies and distances
LOCATION_BODY_MAP = {
"LEO": "earth", "GEO": "earth", "MEO": "earth", "Earth System": "earth", "Earth Surface": "earth",
"Lunar Orbit": "moon", "Lunar Surface": "moon",
"Mars Orbit": "mars", "Mars Surface": "mars", "Phobos Orbit": "mars", "Deimos Orbit": "mars",
"Jupiter Orbit": "jupiter", "Europa Orbit": "jupiter", "Europa Surface": "jupiter",
"Ganymede Orbit": "jupiter", "Callisto Orbit": "jupiter",
"Saturn Orbit": "saturn", "Titan Orbit": "saturn", "Titan Surface": "saturn",
"Uranus Orbit": "uranus", "Neptune Orbit": "neptune", "Triton Orbit": "neptune",
"Mercury Orbit": "mercury", "Mercury Surface": "mercury",
"Venus Orbit": "venus", "Venus Surface": "venus",
"Solar Orbit": "sun", "Transfer": "transfer",
"Ceres Orbit": "ceres", "Ceres Surface": "ceres",
"Vesta Orbit": "vesta", "Vesta Surface": "vesta",
"Pluto Orbit": "pluto", "Pluto Surface": "pluto",
body_aliases = (
("mercury", ("mercury",)),
("venus", ("venus",)),
("moon", ("lunar", "moon")),
("earth", ("earth", "leo", "meo", "geo")),
("phobos", ("phobos",)),
("deimos", ("deimos",)),
("mars", ("mars",)),
("ceres", ("ceres",)),
("vesta", ("vesta",)),
("io", ("io orbit", "io surface", "io system")),
("europa", ("europa",)),
("ganymede", ("ganymede",)),
("callisto", ("callisto",)),
("jupiter", ("jupiter",)),
("mimas", ("mimas",)),
("enceladus", ("enceladus",)),
("tethys", ("tethys",)),
("dione", ("dione",)),
("rhea", ("rhea",)),
("titan", ("titan",)),
("iapetus", ("iapetus",)),
("saturn", ("saturn",)),
("miranda", ("miranda",)),
("ariel", ("ariel",)),
("umbriel", ("umbriel",)),
("titania", ("titania",)),
("oberon", ("oberon",)),
("uranus", ("uranus",)),
("triton", ("triton",)),
("neptune", ("neptune",)),
("charon", ("charon",)),
("pluto", ("pluto",)),
("sun", ("solar", "heliocentric", "sun")),
)
moon_systems = {
"moon": "earth", "phobos": "mars", "deimos": "mars",
"io": "jupiter", "europa": "jupiter", "ganymede": "jupiter", "callisto": "jupiter",
"mimas": "saturn", "enceladus": "saturn", "tethys": "saturn", "dione": "saturn",
"rhea": "saturn", "titan": "saturn", "iapetus": "saturn",
"miranda": "uranus", "ariel": "uranus", "umbriel": "uranus", "titania": "uranus", "oberon": "uranus",
"triton": "neptune", "charon": "pluto",
}
planetary_systems = {
"mercury", "venus", "earth", "mars", "ceres", "vesta", "jupiter",
"saturn", "uranus", "neptune", "pluto",
}
def location_body(value: object) -> str:
normalized = str(value or "").strip().casefold()
for body_key, aliases in body_aliases:
if any(alias in normalized for alias in aliases):
return body_key
return "unknown"
def body_system(body_key: str) -> str:
if body_key in moon_systems:
return moon_systems[body_key]
if body_key in planetary_systems or body_key == "sun":
return body_key
return "unknown"
def active_transfer(snapshot: dict[str, object]) -> dict[str, object] | None:
active_state = snapshot.get("active_state")
if active_state is None or not active_state.state_nodes:
return None
nodes = sorted(active_state.state_nodes, key=lambda item: _as_utc(item.at_time) or simulation_time)
current_index = -1
for index, node in enumerate(nodes):
node_at = _as_utc(node.at_time)
if node_at is not None and node_at <= simulation_time:
current_index = index
if current_index < 0:
return None
node = nodes[current_index]
if not node.transit_location or not node.previous_location or not node.target_location:
return None
from_body = location_body(node.previous_location)
to_body = location_body(node.target_location)
if "unknown" in (from_body, to_body) or from_body == to_body:
return None
start_at = _as_utc(node.at_time) or simulation_time
end_at = None
if current_index + 1 < len(nodes):
end_at = _as_utc(nodes[current_index + 1].at_time)
if end_at is None:
end_at = _as_utc(active_state.end_at)
progress = 50
if end_at is not None and end_at > start_at:
progress = round(100 * (simulation_time - start_at).total_seconds() / (end_at - start_at).total_seconds())
progress = max(2, min(98, progress))
from_system = body_system(from_body)
to_system = body_system(to_body)
scope = from_system if from_system == to_system and from_system not in {"unknown", "sun"} else "sun"
return {
"from": from_body,
"to": to_body,
"progress": progress,
"scope": scope,
"body": f"{from_body}-{to_body}-transfer",
"location": str(node.transit_location),
}
asset_positions = []
for asset in assets:
if asset.is_retired:
continue
snapshot = _build_asset_snapshot(asset, simulation_time)
location = snapshot.get("current_location", "Transfer")
body = LOCATION_BODY_MAP.get(location, "transfer")
location = str(snapshot.get("current_location") or "-")
transfer = active_transfer(snapshot)
body = str(transfer["body"]) if transfer else location_body(location)
scope = str(transfer["scope"]) if transfer else body_system(body)
asset_positions.append({
"id": str(asset.id),
"name": asset.name,
"asset_type": asset.asset_type,
"location": location,
"location": str(transfer["location"]) if transfer else location,
"body": body,
"scope": scope,
"mission": snapshot.get("current_mission", ""),
"state": snapshot.get("current_state", ""),
"next_event": snapshot.get("next_event", ""),
"detail_url": url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input),
"transfer": transfer,
})
# Count ongoing missions and upcoming events
@@ -4726,7 +4815,7 @@ def mission_location_board_preview() -> str:
active_page="location",
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=simulation_time_input,
asset_positions_json=__import__("json").dumps(asset_positions),
asset_positions=asset_positions,
asset_count=len(asset_positions),
ongoing_count=len(active_assets),
upcoming_events=[],
@@ -4885,7 +4974,7 @@ def mission_timeline_preview() -> str:
preview_tab="timeline",
timeline_scale=timeline_scale,
timeline_rows=timeline_rows,
timeline_json=timeline_json,
timeline_data=timeline_json_data,
timeline_scale_mode=timeline_scale_mode,
timeline_scale_options=TIMELINE_SCALE_OPTIONS,
timeline_column_width=timeline_column_width,
+46 -23
View File
@@ -9,7 +9,7 @@ from openpyxl import load_workbook
from sqlalchemy import delete, select
from app.extensions import db
from app.models import Asset, AssetLogEntry
from app.models import Asset, AssetLogEntry, AssetStateNode
EVENT_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2})\s+(.+)$")
@@ -99,6 +99,8 @@ def import_log_book_data(workbook_path: str | Path) -> LogBookImportSummary:
except Exception:
db.session.rollback()
raise
finally:
workbook.close()
return summary
@@ -156,22 +158,39 @@ def _import_sheet(worksheet: object, sheet_name: str, asset_spec: dict[str, str]
if log_name is None or start_at is None:
continue
db.session.add(
AssetLogEntry(
asset_id=asset.id,
entry_kind="state",
entry = AssetLogEntry(
asset_id=asset.id,
entry_kind="state",
title=log_name,
mission_label=log_name,
start_at=start_at,
end_at=end_at,
summary=mission_detail,
note=sub_log,
)
entry.state_nodes.append(
AssetStateNode(
title=log_name,
detail=mission_detail,
at_time=start_at,
target_location=inferred_location,
state_label=log_name,
mission_label=log_name,
location=inferred_location,
start_at=start_at,
end_at=end_at,
summary=mission_detail,
note=sub_log,
)
)
db.session.add(entry)
summary.state_entries += 1
events = _parse_sub_log_events(
asset.id,
log_name,
start_at,
end_at,
inferred_location,
sub_log,
)
db.session.add_all(events)
summary.event_entries += len(events)
return summary
@@ -260,19 +279,23 @@ def _parse_sub_log_events(
continue
description = match.group(2).strip()
events.append(
AssetLogEntry(
asset_id=asset_id,
entry_kind="event",
event = AssetLogEntry(
asset_id=asset_id,
entry_kind="event",
title=description,
mission_label=parent_title,
start_at=event_at,
end_at=None,
summary=parent_title,
note=line,
)
event.state_nodes.append(
AssetStateNode(
title=description,
state_label=None,
mission_label=parent_title,
location=_infer_event_location(description, default_location),
start_at=event_at,
end_at=None,
summary=parent_title,
note=line,
at_time=event_at,
target_location=_infer_event_location(description, default_location),
)
)
events.append(event)
return events
return events
+2
View File
@@ -54,6 +54,8 @@ def import_workbook_data(
except Exception:
db.session.rollback()
raise
finally:
workbook.close()
summary.imported_rows = {
"engine_families": family_count,
+274 -67
View File
@@ -1,79 +1,286 @@
{% extends "base.html" %}
{% block content %}
<div style="display:flex;flex:1;overflow:hidden;position:absolute;inset:0;top:var(--topbar-h);left:var(--sidebar-w)">
<!-- Canvas Map -->
<div id="map-ct" style="flex:1;position:relative;display:flex;align-items:center;justify-content:center;background:#0B0B10;cursor:grab;overflow:hidden">
<canvas id="map-cv"></canvas>
<button id="bk-btn" onclick="mapGoBack()">← Back</button>
<span id="scope-ind">Solar System</span>
</div>
<section id="ksp-location-board" aria-label="太阳系地点板">
<header class="location-toolbar">
<nav class="location-breadcrumbs" id="location-breadcrumbs" aria-label="地图层级"></nav>
<div class="location-summary">
<label class="transfer-toggle" for="transfer-toggle">
<input id="transfer-toggle" type="checkbox">
<span>全部转移轨迹</span>
</label>
<span id="scope-count">0 assets</span>
<span class="live-status"><i></i>实时星历</span>
<button class="location-reset" id="location-reset" type="button">重置</button>
</div>
</header>
<!-- Info Panel -->
<div id="map-info" style="width:280px;background:var(--surface);border-left:1px solid var(--border);padding:16px;overflow-y:auto;display:flex;flex-direction:column;gap:14px;flex-shrink:0">
<h2 id="mi-title" style="font-family:var(--font-data);font-size:.85rem;font-weight:600;padding-bottom:5px;border-bottom:1px solid var(--border)">Solar System</h2>
<div><h3 style="font-family:var(--font-data);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-bottom:3px">Scope</h3><div id="mi-scope" style="font-size:.88rem;font-weight:500">Solar System</div></div>
<div><h3 style="font-family:var(--font-data);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-bottom:3px">Assets in Scope</h3><div id="mi-assets"></div></div>
<div><h3 style="font-family:var(--font-data);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-bottom:3px">Ongoing Missions</h3><div style="font-family:var(--font-data);font-size:1.3rem;font-weight:600;color:var(--accent)" id="mi-miss">{{ ongoing_count }}</div></div>
<div><h3 style="font-family:var(--font-data);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-bottom:3px">Upcoming Events</h3><div id="mi-events"></div></div>
</div>
</div>
<div class="location-layout">
<main class="location-map" id="location-map">
<svg id="space-map" viewBox="0 0 1000 620" preserveAspectRatio="xMidYMid meet" role="img" aria-labelledby="map-title map-desc">
<title id="map-title">太阳系资产位置地图</title>
<desc id="map-desc">从太阳系全局钻取到行星系统、行星和卫星,并查看实际转移航路。</desc>
<g id="orbit-layer"></g>
<g id="route-layer"></g>
<g id="asset-layer"></g>
</svg>
<div class="body-layer" id="body-layer"></div>
<div class="map-caption"><span id="view-mode">SOLAR SYSTEM</span><b id="map-caption">选择天体或转移资产</b></div>
<div class="map-legend" aria-label="图例">
<span><i class="asset-dot"></i>资产位置</span>
<span><i class="progress-line"></i>已完成航程</span>
<span><i class="remaining-line"></i>剩余航程</span>
</div>
</main>
<script>
// Pass asset data from backend to JS
const LOC_ASSETS = {{ asset_positions_json|safe }};
const LOC_EVENTS = {{ upcoming_events|tojson|safe }};
</script>
<aside class="asset-panel">
<div class="asset-panel-head">
<div><span>ASSETS IN SCOPE</span><h2 id="scope-title">Solar System</h2></div>
<b id="panel-count">0</b>
</div>
<div class="asset-list" id="asset-list"></div>
</aside>
<aside class="asset-drawer" id="asset-drawer" aria-hidden="true">
<button class="drawer-close" id="drawer-close" type="button" aria-label="关闭详情">×</button>
<span class="drawer-eyebrow">ASSET DETAIL</span>
<h2 id="drawer-name"></h2>
<div class="drawer-fields" id="drawer-fields"></div>
<a class="drawer-link" id="drawer-link" href="#">打开资产详情页</a>
</aside>
</div>
</section>
<script id="location-assets-data" type="application/json">{{ asset_positions|tojson }}</script>
<style>
#ksp-location-board{position:absolute;inset:0;display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden;background:var(--bg);color:var(--text);font-size:clamp(12px,.28vw + 8px,22px)}
#ksp-location-board button,#ksp-location-board a{font:inherit}
.location-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:48px;padding:8px clamp(14px,1vw,30px);border-bottom:1px solid var(--border);background:var(--surface)}
.location-breadcrumbs,.location-summary{display:flex;align-items:center;gap:9px;min-width:0}
.location-breadcrumbs button{padding:0;border:0;background:transparent;color:var(--muted);cursor:pointer}
.location-breadcrumbs button.current{color:var(--text);font-weight:600}.location-breadcrumbs .sep{color:var(--muted)}
.location-summary{color:var(--muted);white-space:nowrap}.live-status{display:flex;align-items:center;gap:5px}.live-status i{width:7px;height:7px;border-radius:50%;background:var(--amber)}
.transfer-toggle{display:flex;align-items:center;gap:7px;cursor:pointer;color:var(--text)}.transfer-toggle input{accent-color:var(--accent)}
.location-reset{padding:4px 9px;border:1px solid var(--border);border-radius:4px;background:transparent;color:var(--muted);cursor:pointer}.location-reset:hover{color:var(--text);background:var(--surface2)}
.location-layout{position:relative;display:grid;grid-template-columns:minmax(0,1fr) clamp(270px,20vw,540px);min-width:0;min-height:0}
.location-map{position:relative;min-width:0;min-height:0;overflow:hidden;background:radial-gradient(circle at 50% 50%,rgba(59,130,246,.09),transparent 44%),var(--bg)}
#space-map{display:block;width:100%;height:100%}.body-layer{position:absolute;inset:0;pointer-events:none}
.asset-panel{display:grid;grid-template-rows:auto minmax(0,1fr);min-height:0;padding:clamp(14px,1vw,28px);border-left:1px solid var(--border);background:var(--surface)}
.asset-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--border)}
.asset-panel-head span,.drawer-eyebrow{color:var(--muted);font-family:var(--font-data);font-size:.7em;letter-spacing:.08em}.asset-panel-head h2{margin-top:4px;font-size:1.2em}.asset-panel-head b{padding:4px 8px;background:rgba(59,130,246,.12);color:var(--accent);font-family:var(--font-data)}
.asset-list{min-height:0;overflow-y:auto}.asset-button{display:flex;align-items:center;gap:9px;width:100%;padding:clamp(9px,.55vw,18px) 3px;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--text);text-align:left;cursor:pointer}.asset-button::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--amber);flex:0 0 auto}.asset-button.planned::before{background:var(--purple)}.asset-button:hover,.asset-button.selected{background:rgba(59,130,246,.08)}
.asset-empty{padding:18px 2px;color:var(--muted)}
.body-button{--planet-size:16px;position:absolute;transform:translate(-50%,-50%);display:block;width:var(--planet-size);height:var(--planet-size);padding:0;border:0;background:transparent;color:var(--text);pointer-events:auto;cursor:pointer;overflow:visible}
.planet-disc{display:block;width:100%;height:100%;border:1px solid var(--muted);border-radius:50%;background:var(--surface2)}.body-button.sun .planet-disc{border-color:var(--text);background:#74b4ec;box-shadow:0 0 26px rgba(116,180,236,.42)}.body-button.minor .planet-disc{border-style:dashed;background:rgba(148,163,184,.12)}.body-button:hover .planet-disc,.body-button:focus-visible .planet-disc{outline:2px solid var(--accent);outline-offset:3px}
.body-label{position:absolute;top:calc(100% + 5px);left:50%;transform:translateX(-50%);color:var(--muted);white-space:nowrap;pointer-events:none}
.map-caption{position:absolute;top:16px;left:18px;display:flex;align-items:center;gap:9px;max-width:70%;pointer-events:none}.map-caption span{padding:5px 8px;background:rgba(59,130,246,.12);color:#7ab8f6;font-family:var(--font-data)}.map-caption b{font-weight:500}
.map-legend{position:absolute;bottom:16px;left:18px;display:flex;gap:14px;padding:8px 10px;border:1px solid var(--border);background:rgba(19,19,24,.88);color:var(--text)}.map-legend span{display:flex;align-items:center;gap:6px}.asset-dot{width:7px;height:7px;border-radius:50%;background:var(--amber)}.progress-line,.remaining-line{display:inline-block;width:24px;border-top:2px solid #72b4f4}.remaining-line{border-top:1px dashed var(--muted)}
.orbit{fill:none;stroke:rgba(148,163,184,.18);stroke-width:1.1}.orbit.strong{stroke:rgba(248,250,252,.48);stroke-width:1.35}.orbit.minor{stroke-dasharray:3 7;opacity:.65}
.transfer-track{fill:none;stroke:rgba(148,163,184,.38);stroke-width:1.25;stroke-dasharray:4 8}.transfer-progress{fill:none;stroke:#72b4f4;stroke-width:2.25}.route-endpoint{fill:var(--bg);stroke:#72b4f4;stroke-width:1.6}.route-vessel-dot{fill:var(--bg);stroke:#72b4f4;stroke-width:2.5}.route-vessel-heading{stroke:#72b4f4;stroke-width:2;stroke-linecap:round}.route-label-leader{stroke:var(--muted);stroke-width:1;opacity:.7}.route-label-title,.route-label-meta{font-family:var(--font-ui);paint-order:stroke;stroke:var(--bg);stroke-linejoin:round}.route-label-title{fill:var(--text);font-size:12px;font-weight:600;stroke-width:3}.route-label-meta{fill:var(--muted);font-size:10px;stroke-width:3}.route-group.hidden{display:none}
.map-asset{fill:var(--amber);stroke:var(--bg);stroke-width:2}.map-asset-label{fill:var(--text);font-size:11px;font-family:var(--font-ui);paint-order:stroke;stroke:var(--bg);stroke-width:3;stroke-linejoin:round}
.asset-drawer{--panel-width:clamp(270px,20vw,540px);position:absolute;z-index:8;top:0;right:var(--panel-width);bottom:0;width:clamp(320px,23vw,620px);display:flex;flex-direction:column;gap:clamp(13px,.8vw,24px);padding:clamp(18px,1.2vw,36px);border-left:1px solid var(--border);background:var(--surface2);transform:translateX(calc(100% + var(--panel-width)));transition:transform .18s ease;pointer-events:none}.asset-drawer.open{transform:translateX(0);pointer-events:auto}.drawer-close{align-self:flex-end;width:30px;height:30px;border:1px solid var(--border);border-radius:4px;background:transparent;color:var(--muted);font-size:20px;cursor:pointer}.asset-drawer h2{font-size:1.4em}.drawer-fields{display:grid;gap:10px}.drawer-field{padding-top:9px;border-top:1px solid var(--border)}.drawer-field span,.drawer-field strong{display:block}.drawer-field span{margin-bottom:3px;color:var(--muted);font-size:.78em}.drawer-field strong{font-weight:500;overflow-wrap:anywhere}.drawer-link{margin-top:auto;padding:9px 12px;border-radius:4px;background:var(--accent);color:#fff;text-align:center}.drawer-link:hover{text-decoration:none;filter:brightness(1.08)}
@media(max-width:900px){.location-layout{grid-template-columns:minmax(0,1fr) 230px}.asset-drawer{--panel-width:230px;right:230px;width:300px}.live-status{display:none}.map-legend{display:none}}
@media(max-width:680px){.location-toolbar{align-items:flex-start;flex-direction:column}.location-summary{width:100%;overflow-x:auto}.location-layout{grid-template-columns:minmax(0,1fr) 180px}.asset-drawer{--panel-width:180px;right:180px;width:280px}.map-caption{max-width:90%}}
</style>
{% endblock %}
{% block scripts %}
<script>
// ═══ Keplerian Orbital Mechanics ═══
const J2000_JD=2451545.0,DAY_MS=86400000;
const ORB={
mercury:{a:.387098,e:.205630,i:7.005,O:48.331,p:77.456,L:252.251,P:87.969},
venus:{a:.723332,e:.006772,i:3.395,O:76.680,p:131.563,L:181.980,P:224.701},
earth:{a:1.000003,e:.016709,i:.000,O:.000,p:102.938,L:100.464,P:365.256},
mars:{a:1.523679,e:.093401,i:1.850,O:49.558,p:336.060,L:355.453,P:686.980},
ceres:{a:2.767500,e:.075823,i:10.593,O:80.306,p:72.716,L:73.598,P:1680},
vesta:{a:2.361000,e:.088740,i:7.141,O:103.852,p:150.047,L:18.900,P:1325},
jupiter:{a:5.203363,e:.048393,i:1.305,O:100.556,p:14.754,L:34.404,P:4332.589},
saturn:{a:9.537070,e:.054151,i:2.485,O:113.715,p:92.432,L:49.944,P:10759.22},
uranus:{a:19.19126,e:.047168,i:.770,O:74.230,p:170.964,L:313.232,P:30685.4},
neptune:{a:30.06904,e:.008606,i:1.770,O:131.723,p:44.971,L:304.880,P:60189},
pluto:{a:39.48212,e:.248828,i:17.140,O:110.304,p:224.069,L:238.929,P:90560}
};
function jd(d){return d.getTime()/DAY_MS+2440587.5}
function kepl(M,e,eps=1e-8){let E=M+e*Math.sin(M)*(1+e*Math.cos(M));for(let i=0;i<20;i++){let d=(M-E+e*Math.sin(E))/(1-e*Math.cos(E));E+=d;if(Math.abs(d)<eps)break}return E}
function helio(k,jv){const o=ORB[k];if(!o)return{x:0,y:0};const D=Math.PI/180,n=2*Math.PI/o.P,ds=jv-J2000_JD;let M=(o.L-o.p)*D+n*ds;M=((M%(2*Math.PI))+2*Math.PI)%(2*Math.PI);const E=kepl(M,o.e),s=Math.sin(E),c=Math.cos(E),r=o.a*(1-o.e*c),sv=Math.sqrt(1-o.e*o.e)*s/(1-o.e*c),cv=(c-o.e)/(1-o.e*c);const w=(o.p-o.O)*D,O=o.O*D,I=o.i*D,cO=Math.cos(O),sO=Math.sin(O),cI=Math.cos(I),sI=Math.sin(I);const xo=r*cv,yo=r*sv,cw=Math.cos(w),sw=Math.sin(w);const xe=xo*(cO*cw-sO*sw*cI)-yo*(cO*sw+sO*cw*cI),ye=xo*(sO*cw+cO*sw*cI)+yo*(cO*cw*cI-sO*sw);const dist=Math.sqrt(xe*xe+ye*ye),dd=Math.pow(dist,.4)*100,sc=dist>.001?dd/dist:0;return{x:xe*sc,y:ye*sc}}
(() => {
const root = document.getElementById('ksp-location-board');
const assets = JSON.parse(document.getElementById('location-assets-data').textContent);
const ns = 'http://www.w3.org/2000/svg';
const layers = {
orbit: root.querySelector('#orbit-layer'),
route: root.querySelector('#route-layer'),
asset: root.querySelector('#asset-layer')
};
const systems = {
mercury:{name:'Mercury',moons:[]}, venus:{name:'Venus',moons:[]}, earth:{name:'Earth',moons:['moon']},
mars:{name:'Mars',moons:['phobos','deimos']}, ceres:{name:'Ceres',moons:[]}, vesta:{name:'Vesta',moons:[]},
jupiter:{name:'Jupiter',moons:['io','europa','ganymede','callisto']},
saturn:{name:'Saturn',moons:['mimas','enceladus','tethys','dione','rhea','titan','iapetus']},
uranus:{name:'Uranus',moons:['miranda','ariel','umbriel','titania','oberon']},
neptune:{name:'Neptune',moons:['triton']}, pluto:{name:'Pluto',moons:['charon']}
};
const bodyNames = {sun:'Sun',mercury:'Mercury',venus:'Venus',earth:'Earth',moon:'Moon',mars:'Mars',phobos:'Phobos',deimos:'Deimos',ceres:'Ceres',vesta:'Vesta',jupiter:'Jupiter',io:'Io',europa:'Europa',ganymede:'Ganymede',callisto:'Callisto',saturn:'Saturn',mimas:'Mimas',enceladus:'Enceladus',tethys:'Tethys',dione:'Dione',rhea:'Rhea',titan:'Titan',iapetus:'Iapetus',uranus:'Uranus',miranda:'Miranda',ariel:'Ariel',umbriel:'Umbriel',titania:'Titania',oberon:'Oberon',neptune:'Neptune',triton:'Triton',pluto:'Pluto',charon:'Charon',unknown:'Unknown'};
const moonSystems = {moon:'earth',phobos:'mars',deimos:'mars',io:'jupiter',europa:'jupiter',ganymede:'jupiter',callisto:'jupiter',mimas:'saturn',enceladus:'saturn',tethys:'saturn',dione:'saturn',rhea:'saturn',titan:'saturn',iapetus:'saturn',miranda:'uranus',ariel:'uranus',umbriel:'uranus',titania:'uranus',oberon:'uranus',triton:'neptune',charon:'pluto'};
const planets = [
{id:'mercury',angle:-35,size:9,orbit:50},{id:'venus',angle:60,size:12,orbit:78},{id:'earth',angle:-22,size:13,orbit:110},{id:'mars',angle:125,size:11,orbit:140},
{id:'vesta',angle:-135,size:7,orbit:160,kind:'minor'},{id:'ceres',angle:150,size:8,orbit:176,kind:'minor'},{id:'jupiter',angle:32,size:25,orbit:215},
{id:'saturn',angle:210,size:22,orbit:260},{id:'uranus',angle:-28,size:17,orbit:310},{id:'neptune',angle:145,size:17,orbit:360},{id:'pluto',angle:210,size:10,orbit:420,kind:'minor'}
];
let view = {level:'solar',system:null,body:null};
let selectedRoute = null;
let showAllRoutes = false;
let bodyPoints = {};
// ═══ Star Map Data ═══
const BODIES={sun:{n:'Sun',t:'star',r:20,fr:52,c:'#FDB813',c2:'#ff6b00'},mercury:{n:'Mercury',t:'p',r:5.5,fr:38,c:'#b5b5b5',c2:'#8a8a8a'},venus:{n:'Venus',t:'p',r:8,fr:44,c:'#e6c229',c2:'#c4952a'},earth:{n:'Earth',t:'p',r:9,fr:46,c:'#4da6ff',c2:'#1a56db',mo:{moon:{n:'Moon',r:2.8,c:'#ccc',c2:'#999',orb:40,P:27.3}}},mars:{n:'Mars',t:'p',r:7,fr:40,c:'#c1440e',c2:'#7a1f0a',mo:{phobos:{n:'Phobos',r:1.6,c:'#998',c2:'#665',orb:26,P:.32},deimos:{n:'Deimos',r:1.4,c:'#887',c2:'#554',orb:35,P:1.26}}},ceres:{n:'Ceres',t:'d',r:4,fr:32,c:'#8b8b8b',c2:'#6b6b6b'},vesta:{n:'Vesta',t:'d',r:3,fr:28,c:'#a0a0a0',c2:'#808080'},jupiter:{n:'Jupiter',t:'p',r:16,fr:60,c:'#c9b07c',c2:'#8b6914',mo:{io:{n:'Io',r:2.8,c:'#e8d44d',c2:'#b8a420',orb:50,P:1.77},europa:{n:'Europa',r:2.5,c:'#d4c9a8',c2:'#a09060',orb:62,P:3.55},ganymede:{n:'Ganymede',r:3.2,c:'#aaa',c2:'#777',orb:76,P:7.15},callisto:{n:'Callisto',r:2.8,c:'#666',c2:'#444',orb:90,P:16.7}}},saturn:{n:'Saturn',t:'p',r:13.5,fr:54,c:'#e0c97f',c2:'#b8952a',mo:{mimas:{n:'Mimas',r:1.8,c:'#bbb',c2:'#999',orb:44,P:.94},titan:{n:'Titan',r:3.5,c:'#d4a85c',c2:'#a07830',orb:90,P:15.95}}},uranus:{n:'Uranus',t:'p',r:11,fr:46,c:'#7ec8e3',c2:'#4a90b0',mo:{titania:{n:'Titania',r:3,c:'#bbb',c2:'#999',orb:64,P:8.71}}},neptune:{n:'Neptune',t:'p',r:10,fr:44,c:'#3f54ba',c2:'#1a2a80',mo:{triton:{n:'Triton',r:2.8,c:'#b8d4e3',c2:'#80a0b0',orb:44,P:5.88}}},pluto:{n:'Pluto',t:'d',r:3,fr:28,c:'#d4c9a8',c2:'#a09070',mo:{charon:{n:'Charon',r:2.2,c:'#bbb',c2:'#999',orb:28,P:6.39}}}};
const SO=['mercury','venus','earth','mars','ceres','vesta','jupiter','saturn','uranus','neptune','pluto'];
for(const bk of Object.keys(BODIES)){const b=BODIES[bk];if(b.mo)for(const[mk,mb]of Object.entries(b.mo))BODIES[mk]=mb}
// ═══ State ═══
let mapVS=[{tp:'solar',ck:'sun'}],mapHv=null,mapDg=false,mapDS=null,mapDL=null,mapDD=false,mapVO={x:0,y:0},mapZm=1;
let mapCtx,mapCv,mapStars=null;
function initMap(){
mapCv=document.getElementById('map-cv');mapCtx=mapCv.getContext('2d');
function rsz(){const r=document.getElementById('map-ct').getBoundingClientRect(),d=devicePixelRatio||1;if(r.width===0||r.height===0)return;mapCv.width=r.width*d;mapCv.height=r.height*d;mapCv.style.width=r.width+'px';mapCv.style.height=r.height+'px';mapStars=null;renderMap()}
window.addEventListener('resize',rsz);setTimeout(rsz,100);
mapCv.addEventListener('mousedown',e=>{mapDg=true;mapDS={x:e.clientX,y:e.clientY};mapDL={x:e.clientX,y:e.clientY};mapDD=false});
window.addEventListener('mousemove',e=>{if(!mapDg||!mapDL)return;if(Math.abs(e.clientX-mapDS.x)>6||Math.abs(e.clientY-mapDS.y)>6)mapDD=true;if(mapDD){mapVO.x+=e.clientX-mapDL.x;mapVO.y+=e.clientY-mapDL.y;mapDL={x:e.clientX,y:e.clientY};renderMap()}});
window.addEventListener('mouseup',()=>{mapDg=false;mapDS=null;mapDL=null});
mapCv.addEventListener('wheel',e=>{e.preventDefault();mapZm=Math.max(.3,Math.min(4,mapZm*(e.deltaY>0?.9:1.1)));renderMap()},{passive:false});
mapCv.addEventListener('click',e=>{if(mapDD)return;const r=mapCv.getBoundingClientRect(),t=mapHit(e.clientX-r.left,e.clientY-r.top);if(!t)return;const v=mapVS[mapVS.length-1],b=BODIES[t];if(v.tp==='solar')mapVS.push({tp:b&&b.mo?'planet_system':'single',ck:t});else if(v.tp==='planet_system')mapVS.push({tp:'single',ck:t});mapVO={x:0,y:0};mapZm=1;updateMapBack();renderMap();rMapInfo()});
mapCv.addEventListener('mousemove',e=>{if(mapDD)return;const r=mapCv.getBoundingClientRect(),t=mapHit(e.clientX-r.left,e.clientY-r.top);if(t!==mapHv){mapHv=t;mapCv.style.cursor=t?'pointer':'grab';renderMap()}});
}
function mapFV(k){if(BODIES[k])return BODIES[k];for(const bk of Object.keys(BODIES)){const b=BODIES[bk];if(b.mo&&b.mo[k])return b.mo[k]}return null}
function mapHit(mx,my){const v=mapVS[mapVS.length-1],cs=[];if(v.tp==='solar'){cs.push({k:'sun'});for(const k of SO)cs.push({k})}else if(v.tp==='planet_system'){const c=BODIES[v.ck];if(c){cs.push({k:v.ck});if(c.mo)for(const mk of Object.keys(c.mo))cs.push({k:mk})}}else{const b=BODIES[v.ck];if(b)cs.push({k:v.ck})}for(const{k}of cs){const b=mapFV(k);if(!b||b._x==null)continue;const dx=mx-b._x,dy=my-b._y,hr=(b._r||4)+6;if(dx*dx+dy*dy<=hr*hr)return k}return null}
function mapDraw(ox,oy,rad,key,lb){const b=BODIES[key]||mapFV(key);if(!b)return;const isStar=b.t==='star',bc=b.c||'#888';mapCtx.save();mapCtx.beginPath();mapCtx.arc(ox,oy,rad,0,Math.PI*2);mapCtx.clip();const g=mapCtx.createRadialGradient(ox-rad*.25,oy-rad*.3,0,ox,oy,rad);if(isStar){g.addColorStop(0,'#fffff8');g.addColorStop(.3,'#ffcc44');g.addColorStop(.7,'#ff6600');g.addColorStop(1,'#330800')}else{g.addColorStop(0,'#fff');g.addColorStop(.2,bc);g.addColorStop(.8,b.c2||'#333');g.addColorStop(1,'#111')}mapCtx.fillStyle=g;mapCtx.beginPath();mapCtx.arc(ox,oy,rad,0,Math.PI*2);mapCtx.fill();mapCtx.restore();if(!isStar){mapCtx.save();mapCtx.beginPath();mapCtx.arc(ox,oy,rad,0,Math.PI*2);mapCtx.clip();const sh=mapCtx.createRadialGradient(ox-rad*.3,oy-rad*.3,rad*.05,ox+rad*.15,oy+rad*.15,rad*1.1);sh.addColorStop(0,'rgba(255,255,255,.1)');sh.addColorStop(.4,'rgba(0,0,0,0)');sh.addColorStop(.8,'rgba(0,0,0,.5)');mapCtx.fillStyle=sh;mapCtx.fillRect(ox-rad,oy-rad,rad*2,rad*2);mapCtx.restore()}mapCtx.fillStyle='#e2e8f0';mapCtx.font=`${Math.max(8,rad*.45)}px system-ui`;mapCtx.textAlign='center';mapCtx.fillText(lb,ox,oy-rad-4);b._x=ox;b._y=oy;b._r=rad}
function renderMap(){if(!mapCv||mapCv.width===0||mapCv.height===0)return;const dpr=devicePixelRatio||1,W=mapCv.width/dpr,H=mapCv.height/dpr;mapCtx.save();mapCtx.setTransform(dpr,0,0,dpr,0,0);if(!mapStars||mapStars.width!==mapCv.width){const sc=document.createElement('canvas');sc.width=W;sc.height=H;const sx=sc.getContext('2d');sx.fillStyle='#0B0B10';sx.fillRect(0,0,W,H);for(let i=0;i<600;i++){sx.fillStyle=`rgba(255,255,255,${.2+Math.random()*.6})`;sx.beginPath();sx.arc(Math.random()*W,Math.random()*H,Math.random()*1.2+.1,0,Math.PI*2);sx.fill()}mapStars=sc}mapCtx.drawImage(mapStars,0,0,W,H);const v=mapVS[mapVS.length-1],cx=W/2+mapVO.x,cy=H/2+mapVO.y,jv=jd(new Date('{{ simulation_time_display }}Z'));if(v.tp==='solar'){for(const k of SO){mapCtx.beginPath();mapCtx.arc(cx,cy,Math.pow(ORB[k].a,.4)*100*mapZm,0,Math.PI*2);mapCtx.strokeStyle='rgba(148,163,184,.1)';mapCtx.lineWidth=.5;mapCtx.setLineDash([3,6]);mapCtx.stroke();mapCtx.setLineDash([])}mapDraw(cx,cy,BODIES.sun.r*mapZm,'sun','Sun');for(const k of SO){const b=BODIES[k],p=helio(k,jv);mapDraw(cx+p.x*mapZm,cy-p.y*mapZm,b.r*mapZm,k,b.n)}}else if(v.tp==='planet_system'){const c=BODIES[v.ck];if(!c)return;mapDraw(cx,cy,c.fr*mapZm,v.ck,c.n);if(c.mo)for(const[mk,mb]of Object.entries(c.mo)){const od=mb.orb*mapZm*1.8;mapCtx.beginPath();mapCtx.arc(cx,cy,od,0,Math.PI*2);mapCtx.strokeStyle='rgba(148,163,184,.1)';mapCtx.lineWidth=.5;mapCtx.setLineDash([3,6]);mapCtx.stroke();mapCtx.setLineDash([]);const an=(2*Math.PI*((jv-J2000_JD)%mb.P))/mb.P;mapDraw(cx+od*Math.cos(an),cy-od*Math.sin(an),mb.r*2.2*mapZm,mk,mb.n)}}else{const b=BODIES[v.ck];if(!b)return;const mx=Math.min(cx,cy)*.5,rr=Math.min((b.fr?b.fr*3:(b.r||4)*15)*mapZm,mx);mapDraw(cx,cy,rr,v.ck,b.n);if(v.ck==='saturn'&&rr>6){mapCtx.save();mapCtx.strokeStyle='rgba(200,180,140,.3)';mapCtx.lineWidth=Math.max(1,rr*.06);mapCtx.beginPath();mapCtx.ellipse(cx,cy,rr*1.5,rr*.18,0,0,Math.PI*2);mapCtx.stroke();mapCtx.strokeStyle='rgba(200,180,140,.15)';mapCtx.lineWidth=Math.max(1,rr*.04);mapCtx.beginPath();mapCtx.ellipse(cx,cy,rr*1.8,rr*.28,0,0,Math.PI*2);mapCtx.stroke();mapCtx.restore()}}if(mapHv){const b=mapFV(mapHv);if(b&&b._x!=null){mapCtx.beginPath();mapCtx.arc(mapHv?b._x:0,mapHv?b._y:0,(b._r||4)+5,0,Math.PI*2);mapCtx.strokeStyle='rgba(59,130,246,.7)';mapCtx.lineWidth=2;mapCtx.stroke()}}mapCtx.restore();rMapInfo()}
function rMapInfo(){const v=mapVS[mapVS.length-1];let sk,sn;if(v.tp==='solar'){sk='solar_system';sn='Solar System'}else{sk=v.ck;const b=BODIES[v.ck];sn=b?b.n+(v.tp==='planet_system'?' System':''):v.ck}document.getElementById('mi-title').textContent=sn;document.getElementById('mi-scope').textContent=sn;const as=LOC_ASSETS.filter(a=>{if(sk==='solar_system')return true;const loc=a.location.toLowerCase();if(sk==='earth')return loc.includes('leo')||loc.includes('earth')||loc.includes('lunar');if(sk==='mars')return loc.includes('mars');if(sk==='jupiter')return loc.includes('jupiter')||loc.includes('europa');if(sk==='saturn')return loc.includes('saturn');if(sk==='uranus')return loc.includes('uranus');if(sk==='neptune')return loc.includes('neptune');if(sk==='mercury')return loc.includes('mercury');if(sk==='venus')return loc.includes('venus');if(sk==='ceres')return loc.includes('ceres');if(sk==='vesta')return loc.includes('vesta');if(sk==='pluto')return loc.includes('pluto');return loc.includes(sk)});document.getElementById('mi-assets').innerHTML=as.length?as.map(a=>`<div style="display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px solid var(--border);font-size:.74rem"><span style="color:var(--accent);cursor:pointer">${a.name}</span><span class="mt" style="font-family:var(--font-data);font-size:.66rem">${a.location}</span></div>`).join(''):'<div style="color:var(--muted);font-size:.72rem">No assets</div>'}
function updateMapBack(){document.getElementById('bk-btn').classList.toggle('vis',mapVS.length>1)}
function mapGoBack(){if(mapVS.length>1)mapVS.pop();mapVO={x:0,y:0};mapZm=1;updateMapBack();renderMap();rMapInfo()}
initMap();
function svgEl(tag, attrs, text) {
const node = document.createElementNS(ns, tag);
Object.entries(attrs || {}).forEach(([key, value]) => node.setAttribute(key, String(value)));
if (text !== undefined) node.textContent = text;
return node;
}
function clearMap() {
Object.values(layers).forEach(layer => layer.replaceChildren());
root.querySelector('#body-layer').replaceChildren();
bodyPoints = {};
}
function orbit(cx, cy, rx, ry, strong=false, kind='') {
layers.orbit.append(svgEl('ellipse', {cx,cy,rx,ry,class:'orbit '+(strong?'strong ':'')+(kind==='minor'?'minor':'')}));
}
function planetPoint(planet) {
const angle = planet.angle * Math.PI / 180;
return {x:500 + Math.cos(angle) * planet.orbit, y:310 + Math.sin(angle) * planet.orbit * .6};
}
function mapPoint(x,y) {
const stage = root.querySelector('#location-map');
const width = stage.clientWidth || 1000;
const height = stage.clientHeight || 620;
const scale = Math.min(width/1000,height/620);
return {x:(width-1000*scale)/2+x*scale,y:(height-620*scale)/2+y*scale};
}
function bodyButton(id, name, x, y, size, sun=false, kind='') {
bodyPoints[id] = {x,y};
const point = mapPoint(x,y);
const button = document.createElement('button');
button.type = 'button';
button.className = 'body-button '+(sun?'sun ':'')+(kind==='minor'?'minor':'');
button.style.left = point.x+'px';
button.style.top = point.y+'px';
button.style.setProperty('--planet-size','clamp('+size+'px,'+(size/10).toFixed(2)+'vw,'+(size*2)+'px)');
button.setAttribute('aria-label','打开 '+name);
const disc = document.createElement('span'); disc.className = 'planet-disc';
const label = document.createElement('span'); label.className = 'body-label'; label.textContent = name;
button.append(disc,label);
button.addEventListener('click',()=>openBody(id));
root.querySelector('#body-layer').append(button);
}
function systemForBody(body) { return moonSystems[body] || body; }
function visibleTransfer(asset) {
if (!asset.transfer) return false;
if (view.level === 'system') return asset.scope === view.system;
return view.level === 'solar' || view.system === 'sun' ? asset.scope === 'sun' : false;
}
function transferPoint(body) {
const system = systemForBody(body);
return bodyPoints[body] || bodyPoints[system] || null;
}
function drawTransfer(asset, index) {
if (!visibleTransfer(asset)) return;
const from = transferPoint(asset.transfer.from);
const to = transferPoint(asset.transfer.to);
if (!from || !to) return;
const bend = index % 2 ? -1 : 1;
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
const dx = to.x-from.x, dy = to.y-from.y;
const length = Math.max(1,Math.hypot(dx,dy));
const offset = Math.min(85,length*.24)*bend;
const cx = mx-dy/length*offset, cy = my+dx/length*offset;
const d = `M${from.x} ${from.y} Q${cx} ${cy} ${to.x} ${to.y}`;
const group = svgEl('g',{class:'route-group'+((showAllRoutes||selectedRoute===asset.id)?'':' hidden')});
layers.route.append(group);
const track = svgEl('path',{d,class:'transfer-track',pathLength:100}); group.append(track);
group.append(svgEl('path',{d,class:'transfer-progress',pathLength:100,'stroke-dasharray':asset.transfer.progress+' '+(100-asset.transfer.progress)}));
const total = track.getTotalLength();
const start = track.getPointAtLength(0), end = track.getPointAtLength(total);
const point = track.getPointAtLength(total*asset.transfer.progress/100);
const before = track.getPointAtLength(Math.max(0,total*asset.transfer.progress/100-2));
const angle = Math.atan2(point.y-before.y,point.x-before.x)*180/Math.PI;
group.append(svgEl('circle',{cx:start.x,cy:start.y,r:4,class:'route-endpoint'}));
group.append(svgEl('circle',{cx:end.x,cy:end.y,r:4,class:'route-endpoint'}));
const vessel = svgEl('g',{transform:`translate(${point.x} ${point.y}) rotate(${angle})`});
vessel.append(svgEl('circle',{cx:0,cy:0,r:6,class:'route-vessel-dot'}));
vessel.append(svgEl('line',{x1:5,y1:0,x2:15,y2:0,class:'route-vessel-heading'}));
group.append(vessel);
const side = point.x > 760 ? -1 : 1;
const labelX = point.x+side*24, labelY = point.y>340?point.y-20:point.y+27;
const anchor = side<0?'end':'start';
group.append(svgEl('line',{x1:point.x+side*8,y1:point.y-2,x2:labelX-side*4,y2:labelY-5,class:'route-label-leader'}));
group.append(svgEl('text',{x:labelX,y:labelY,'text-anchor':anchor,class:'route-label-title'},asset.name));
group.append(svgEl('text',{x:labelX,y:labelY+14,'text-anchor':anchor,class:'route-label-meta'},`${bodyNames[asset.transfer.from]||asset.transfer.from}${bodyNames[asset.transfer.to]||asset.transfer.to} · ${asset.transfer.progress}%`));
}
function drawAsset(x,y,label) {
layers.asset.append(svgEl('circle',{cx:x,cy:y,r:5,class:'map-asset'}));
layers.asset.append(svgEl('text',{x:x+10,y:y+4,class:'map-asset-label'},label));
}
function drawSolar() {
planets.forEach(p=>orbit(500,310,p.orbit,p.orbit*.6,p.id==='earth'||p.id==='mars',p.kind));
bodyButton('sun','Sun',500,310,34,true);
planets.forEach(p=>{const point=planetPoint(p);bodyButton(p.id,systems[p.id].name,point.x,point.y,p.size,false,p.kind)});
currentAssets().filter(a=>!a.transfer&&a.scope==='sun').slice(0,12).forEach((a,i)=>{const angle=.55+i*1.15;const radius=48+(i%4)*28;drawAsset(500+Math.cos(angle)*radius,310+Math.sin(angle)*radius*.6,a.name)});
assets.filter(a=>a.transfer).forEach(drawTransfer);
}
function drawSystem(systemId) {
const system = systems[systemId]; if (!system) return;
bodyButton(systemId,system.name,500,310,48);
const step = system.moons.length>5?42:system.moons.length>3?48:58;
system.moons.forEach((moon,index)=>{const radius=105+index*step;orbit(500,310,radius,radius*.7,index===0);const angle=.7+index*1.45;bodyButton(moon,bodyNames[moon],500+Math.cos(angle)*radius,310+Math.sin(angle)*radius*.7,12+(index%3)*2)});
assets.filter(a=>a.transfer).forEach(drawTransfer);
}
function drawLocal(bodyId) {
bodyButton(bodyId,systems[bodyId]?.name||bodyNames[bodyId]||bodyId,500,310,68,bodyId==='sun');
orbit(500,310,115,72,true); orbit(500,310,180,112); orbit(500,310,255,158);
currentAssets().filter(a=>!a.transfer).slice(0,20).forEach((a,index)=>{const angle=.4+index*.78;const radius=115+(index%3)*65;drawAsset(500+Math.cos(angle)*radius,310+Math.sin(angle)*radius*.62,a.name)});
}
function openBody(id) {
selectedRoute = null;
if (view.level==='solar') view = id==='sun'?{level:'local',system:'sun',body:'sun'}:{level:'system',system:id,body:null};
else if (view.level==='system') view = {level:'local',system:view.system,body:id};
else if (view.system==='sun'&&id!=='sun') view = {level:'system',system:id,body:null};
render();
}
function currentAssets() {
if (view.level==='solar') return assets;
if (view.system==='sun') return assets.filter(a=>a.scope==='sun');
if (view.level==='system') return assets.filter(a=>a.scope===view.system);
return assets.filter(a=>a.body===view.body);
}
function scopeName() {
if (view.level==='solar') return 'Solar System';
if (view.system==='sun') return 'Heliocentric Assets';
if (view.level==='system') return systems[view.system].name+' System';
return systems[view.body]?.name||bodyNames[view.body]||view.body;
}
function renderBreadcrumbs() {
const wrap = root.querySelector('#location-breadcrumbs'); wrap.replaceChildren();
const items = [{label:'Solar System',action:()=>{view={level:'solar',system:null,body:null};selectedRoute=null;render()}}];
if (view.level!=='solar'&&view.system!=='sun') items.push({label:systems[view.system].name+' System',action:()=>{view={level:'system',system:view.system,body:null};selectedRoute=null;render()}});
if (view.system==='sun') items.push({label:'Heliocentric Assets'}); else if (view.level==='local') items.push({label:scopeName()});
items.forEach((item,index)=>{if(index){const sep=document.createElement('span');sep.className='sep';sep.textContent='';wrap.append(sep)}const button=document.createElement('button');button.type='button';button.textContent=item.label;button.className=index===items.length-1?'current':'';if(item.action)button.addEventListener('click',item.action);wrap.append(button)});
}
function renderList() {
const rows = currentAssets();
root.querySelector('#scope-title').textContent=scopeName(); root.querySelector('#scope-count').textContent=rows.length+' assets'; root.querySelector('#panel-count').textContent=rows.length;
const list=root.querySelector('#asset-list'); list.replaceChildren();
if(!rows.length){const empty=document.createElement('div');empty.className='asset-empty';empty.textContent='当前范围内没有资产';list.append(empty);return}
rows.forEach(asset=>{const button=document.createElement('button');button.type='button';button.className='asset-button '+(String(asset.state).toLowerCase().includes('planned')?'planned ':'')+(selectedRoute===asset.id?'selected':'');button.textContent=asset.name;button.addEventListener('click',()=>{selectedRoute=visibleTransfer(asset)?asset.id:null;render();openDrawer(asset)});list.append(button)});
}
function openDrawer(asset) {
root.querySelector('#drawer-name').textContent=asset.name;
const fields=root.querySelector('#drawer-fields');fields.replaceChildren();
[['类型',asset.asset_type],['状态',asset.state],['位置',asset.location],['任务',asset.mission],['下一事件',asset.next_event]].forEach(([label,value])=>{const row=document.createElement('div');row.className='drawer-field';const key=document.createElement('span');key.textContent=label;const val=document.createElement('strong');val.textContent=value||'—';row.append(key,val);fields.append(row)});
root.querySelector('#drawer-link').href=asset.detail_url;
root.querySelector('#asset-drawer').classList.add('open'); root.querySelector('#asset-drawer').setAttribute('aria-hidden','false');
}
function closeDrawer(){root.querySelector('#asset-drawer').classList.remove('open');root.querySelector('#asset-drawer').setAttribute('aria-hidden','true')}
function render() {
closeDrawer(); clearMap();
if(view.level==='solar'||view.system==='sun')drawSolar();else if(view.level==='system')drawSystem(view.system);else drawLocal(view.body);
const hasRoutes=currentAssets().some(visibleTransfer);
const selected=assets.find(a=>a.id===selectedRoute);
root.querySelector('#view-mode').textContent=view.level==='solar'?'SOLAR SYSTEM':view.system==='sun'?'HELIOCENTRIC':view.level==='system'?'PLANETARY SYSTEM':'LOCAL ORBITS';
root.querySelector('#map-caption').textContent=selected?'已选择转移轨迹 · '+selected.name:showAllRoutes&&hasRoutes?'正在显示全部转移轨迹':view.level==='system'?'选择行星、卫星或转移资产':'选择天体或转移资产';
const toggle=root.querySelector('#transfer-toggle');toggle.checked=showAllRoutes&&hasRoutes;toggle.disabled=!hasRoutes;
renderBreadcrumbs();renderList();
}
root.querySelector('#drawer-close').addEventListener('click',closeDrawer);
root.querySelector('#transfer-toggle').addEventListener('change',event=>{showAllRoutes=event.target.checked;render()});
root.querySelector('#location-reset').addEventListener('click',()=>{view={level:'solar',system:null,body:null};selectedRoute=null;showAllRoutes=false;render()});
let resizeFrame=0;window.addEventListener('resize',()=>{cancelAnimationFrame(resizeFrame);resizeFrame=requestAnimationFrame(render)});
render();
})();
</script>
{% endblock %}
+1 -1
View File
@@ -194,7 +194,7 @@
{% endblock %}
{% block scripts %}
<script id="tl-json" type="application/json">{{ timeline_json|safe }}</script>
<script id="tl-json" type="application/json">{{ timeline_data|tojson }}</script>
<script>
(function(){
const viewport = document.getElementById('tl-viewport');
@@ -0,0 +1,37 @@
"""add simulation settings
Revision ID: c7e91a4b2d30
Revises: 2a4c9d8e7f10
Create Date: 2026-07-17 18:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "c7e91a4b2d30"
down_revision = "2a4c9d8e7f10"
branch_labels = None
depends_on = None
def upgrade():
# Some development databases received this table manually before it was
# brought under Alembic. Keep the migration safe for both those databases
# and clean installations.
if "simulation_settings" in sa.inspect(op.get_bind()).get_table_names():
return
op.create_table(
"simulation_settings",
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("key"),
)
def downgrade():
if "simulation_settings" in sa.inspect(op.get_bind()).get_table_names():
op.drop_table("simulation_settings")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff