feat: add simulation settings and location board redesign
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+10
-2
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.extensions import db
|
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):
|
class EngineFamily(TimestampMixin, db.Model):
|
||||||
__tablename__ = "engine_families"
|
__tablename__ = "engine_families"
|
||||||
|
|
||||||
@@ -130,9 +137,10 @@ class VehicleCost(TimestampMixin, db.Model):
|
|||||||
|
|
||||||
class Asset(TimestampMixin, db.Model):
|
class Asset(TimestampMixin, db.Model):
|
||||||
__tablename__ = "assets"
|
__tablename__ = "assets"
|
||||||
|
__table_args__ = (Index("ix_assets_name", "name"),)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
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)
|
asset_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
is_retired: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_retired: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
program: Mapped[str | None] = mapped_column(String(255))
|
program: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
|||||||
+137
-48
@@ -10,13 +10,13 @@ import re
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, has_request_context, redirect, render_template, request, session, url_for
|
from flask import Blueprint, abort, current_app, flash, has_request_context, redirect, render_template, request, session, url_for
|
||||||
from sqlalchemy import select, text, distinct
|
from sqlalchemy import select, distinct
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.extensions import db
|
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.fuel_conversion import list_fuel_factors
|
||||||
from app.services.log_book_importer import import_log_book_data
|
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:
|
def _db_read_simulation_time() -> datetime | None:
|
||||||
try:
|
try:
|
||||||
row = db.session.execute(
|
setting = db.session.get(SimulationSetting, "current_time")
|
||||||
text("SELECT value FROM simulation_settings WHERE key = 'current_time'")
|
if setting is None:
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
return None
|
||||||
return _parse_datetime(row[0], "DB Simulation Time")
|
return _parse_datetime(setting.value, "DB Simulation Time")
|
||||||
except Exception:
|
except (SQLAlchemyError, ValueError) as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.warning("Unable to read simulation time: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _db_write_simulation_time(value: datetime) -> None:
|
def _db_write_simulation_time(value: datetime) -> None:
|
||||||
formatted = _format_datetime_input(value)
|
formatted = _format_datetime_input(value)
|
||||||
try:
|
try:
|
||||||
db.session.execute(
|
setting = db.session.get(SimulationSetting, "current_time")
|
||||||
text(
|
if setting is None:
|
||||||
"INSERT INTO simulation_settings (key, value, updated_at) "
|
setting = SimulationSetting(key="current_time", value=formatted)
|
||||||
"VALUES ('current_time', :val, NOW()) "
|
db.session.add(setting)
|
||||||
"ON CONFLICT (key) DO UPDATE SET value = :val2, updated_at = NOW()"
|
else:
|
||||||
),
|
setting.value = formatted
|
||||||
{"val": formatted, "val2": formatted},
|
|
||||||
)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception:
|
except SQLAlchemyError:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _default_simulation_time() -> datetime:
|
def _default_simulation_time() -> datetime:
|
||||||
@@ -3113,6 +3112,7 @@ def dashboard() -> str:
|
|||||||
"location": snapshot.get("current_location", "—"),
|
"location": snapshot.get("current_location", "—"),
|
||||||
"mission": snapshot.get("current_mission", "—"),
|
"mission": snapshot.get("current_mission", "—"),
|
||||||
"state": snapshot.get("current_state", "—"),
|
"state": snapshot.get("current_state", "—"),
|
||||||
|
"future_events": snapshot.get("future_events", []),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Metrics
|
# Metrics
|
||||||
@@ -3139,22 +3139,22 @@ def dashboard() -> str:
|
|||||||
|
|
||||||
# Upcoming events (30 days)
|
# Upcoming events (30 days)
|
||||||
now = simulation_time
|
now = simulation_time
|
||||||
|
upcoming_cutoff = now + timedelta(days=30)
|
||||||
upcoming_events = []
|
upcoming_events = []
|
||||||
for a in active:
|
for a in active:
|
||||||
snapshot = _build_asset_snapshot(a if hasattr(a, "log_entries") else assets[0], simulation_time)
|
for ev in a["future_events"]:
|
||||||
for ev in snapshot.get("future_events", []):
|
event_at = _as_utc(ev.get("sort_at"))
|
||||||
ev_date = ev.get("date", "")
|
if event_at is not None and now < event_at <= upcoming_cutoff:
|
||||||
if ev_date and ev_date >= now.strftime("%Y-%m-%d"):
|
|
||||||
upcoming_events.append({
|
upcoming_events.append({
|
||||||
"date": ev_date,
|
"date": event_at.strftime("%Y-%m-%d"),
|
||||||
"title": f"{a['name']}: {ev.get('title', '')}",
|
"title": f"{a['name']}: {ev.get('title', '')}",
|
||||||
"within_30d": True,
|
"within_30d": True,
|
||||||
})
|
})
|
||||||
upcoming_events.sort(key=lambda e: e["date"])
|
upcoming_events.sort(key=lambda e: e["date"])
|
||||||
|
upcoming_count = len(upcoming_events)
|
||||||
upcoming_events = upcoming_events[:5]
|
upcoming_events = upcoming_events[:5]
|
||||||
|
|
||||||
active_missions = len([a for a in active if a["state"] in ("Transfer", "Exploration")])
|
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"
|
upcoming_summary = f"{upcoming_count} events" if upcoming_count else "None imminent"
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -4677,26 +4677,109 @@ def mission_status_board_preview() -> str:
|
|||||||
def mission_location_board_preview() -> str:
|
def mission_location_board_preview() -> str:
|
||||||
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
|
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
|
||||||
simulation_time_input = _format_datetime_input(simulation_time)
|
simulation_time_input = _format_datetime_input(simulation_time)
|
||||||
|
|
||||||
# Load all assets with logs
|
|
||||||
assets = _load_assets_with_logs()
|
assets = _load_assets_with_logs()
|
||||||
|
|
||||||
# Build asset position data for Canvas
|
body_aliases = (
|
||||||
# Map location strings to approximate orbital bodies and distances
|
("mercury", ("mercury",)),
|
||||||
LOCATION_BODY_MAP = {
|
("venus", ("venus",)),
|
||||||
"LEO": "earth", "GEO": "earth", "MEO": "earth", "Earth System": "earth", "Earth Surface": "earth",
|
("moon", ("lunar", "moon")),
|
||||||
"Lunar Orbit": "moon", "Lunar Surface": "moon",
|
("earth", ("earth", "leo", "meo", "geo")),
|
||||||
"Mars Orbit": "mars", "Mars Surface": "mars", "Phobos Orbit": "mars", "Deimos Orbit": "mars",
|
("phobos", ("phobos",)),
|
||||||
"Jupiter Orbit": "jupiter", "Europa Orbit": "jupiter", "Europa Surface": "jupiter",
|
("deimos", ("deimos",)),
|
||||||
"Ganymede Orbit": "jupiter", "Callisto Orbit": "jupiter",
|
("mars", ("mars",)),
|
||||||
"Saturn Orbit": "saturn", "Titan Orbit": "saturn", "Titan Surface": "saturn",
|
("ceres", ("ceres",)),
|
||||||
"Uranus Orbit": "uranus", "Neptune Orbit": "neptune", "Triton Orbit": "neptune",
|
("vesta", ("vesta",)),
|
||||||
"Mercury Orbit": "mercury", "Mercury Surface": "mercury",
|
("io", ("io orbit", "io surface", "io system")),
|
||||||
"Venus Orbit": "venus", "Venus Surface": "venus",
|
("europa", ("europa",)),
|
||||||
"Solar Orbit": "sun", "Transfer": "transfer",
|
("ganymede", ("ganymede",)),
|
||||||
"Ceres Orbit": "ceres", "Ceres Surface": "ceres",
|
("callisto", ("callisto",)),
|
||||||
"Vesta Orbit": "vesta", "Vesta Surface": "vesta",
|
("jupiter", ("jupiter",)),
|
||||||
"Pluto Orbit": "pluto", "Pluto Surface": "pluto",
|
("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 = []
|
asset_positions = []
|
||||||
@@ -4704,16 +4787,22 @@ def mission_location_board_preview() -> str:
|
|||||||
if asset.is_retired:
|
if asset.is_retired:
|
||||||
continue
|
continue
|
||||||
snapshot = _build_asset_snapshot(asset, simulation_time)
|
snapshot = _build_asset_snapshot(asset, simulation_time)
|
||||||
location = snapshot.get("current_location", "Transfer")
|
location = str(snapshot.get("current_location") or "-")
|
||||||
body = LOCATION_BODY_MAP.get(location, "transfer")
|
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({
|
asset_positions.append({
|
||||||
"id": str(asset.id),
|
"id": str(asset.id),
|
||||||
"name": asset.name,
|
"name": asset.name,
|
||||||
"asset_type": asset.asset_type,
|
"asset_type": asset.asset_type,
|
||||||
"location": location,
|
"location": str(transfer["location"]) if transfer else location,
|
||||||
"body": body,
|
"body": body,
|
||||||
|
"scope": scope,
|
||||||
"mission": snapshot.get("current_mission", ""),
|
"mission": snapshot.get("current_mission", ""),
|
||||||
"state": snapshot.get("current_state", ""),
|
"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
|
# Count ongoing missions and upcoming events
|
||||||
@@ -4726,7 +4815,7 @@ def mission_location_board_preview() -> str:
|
|||||||
active_page="location",
|
active_page="location",
|
||||||
simulation_time_display=_format_datetime_display(simulation_time),
|
simulation_time_display=_format_datetime_display(simulation_time),
|
||||||
simulation_time_input=simulation_time_input,
|
simulation_time_input=simulation_time_input,
|
||||||
asset_positions_json=__import__("json").dumps(asset_positions),
|
asset_positions=asset_positions,
|
||||||
asset_count=len(asset_positions),
|
asset_count=len(asset_positions),
|
||||||
ongoing_count=len(active_assets),
|
ongoing_count=len(active_assets),
|
||||||
upcoming_events=[],
|
upcoming_events=[],
|
||||||
@@ -4885,7 +4974,7 @@ def mission_timeline_preview() -> str:
|
|||||||
preview_tab="timeline",
|
preview_tab="timeline",
|
||||||
timeline_scale=timeline_scale,
|
timeline_scale=timeline_scale,
|
||||||
timeline_rows=timeline_rows,
|
timeline_rows=timeline_rows,
|
||||||
timeline_json=timeline_json,
|
timeline_data=timeline_json_data,
|
||||||
timeline_scale_mode=timeline_scale_mode,
|
timeline_scale_mode=timeline_scale_mode,
|
||||||
timeline_scale_options=TIMELINE_SCALE_OPTIONS,
|
timeline_scale_options=TIMELINE_SCALE_OPTIONS,
|
||||||
timeline_column_width=timeline_column_width,
|
timeline_column_width=timeline_column_width,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from openpyxl import load_workbook
|
|||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
from app.extensions import db
|
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+(.+)$")
|
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:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
return summary
|
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:
|
if log_name is None or start_at is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
db.session.add(
|
entry = AssetLogEntry(
|
||||||
AssetLogEntry(
|
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
entry_kind="state",
|
entry_kind="state",
|
||||||
title=log_name,
|
title=log_name,
|
||||||
state_label=log_name,
|
|
||||||
mission_label=log_name,
|
mission_label=log_name,
|
||||||
location=inferred_location,
|
|
||||||
start_at=start_at,
|
start_at=start_at,
|
||||||
end_at=end_at,
|
end_at=end_at,
|
||||||
summary=mission_detail,
|
summary=mission_detail,
|
||||||
note=sub_log,
|
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,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
summary.state_entries += 1
|
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
|
return summary
|
||||||
|
|
||||||
|
|
||||||
@@ -260,19 +279,23 @@ def _parse_sub_log_events(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
description = match.group(2).strip()
|
description = match.group(2).strip()
|
||||||
events.append(
|
event = AssetLogEntry(
|
||||||
AssetLogEntry(
|
|
||||||
asset_id=asset_id,
|
asset_id=asset_id,
|
||||||
entry_kind="event",
|
entry_kind="event",
|
||||||
title=description,
|
title=description,
|
||||||
state_label=None,
|
|
||||||
mission_label=parent_title,
|
mission_label=parent_title,
|
||||||
location=_infer_event_location(description, default_location),
|
|
||||||
start_at=event_at,
|
start_at=event_at,
|
||||||
end_at=None,
|
end_at=None,
|
||||||
summary=parent_title,
|
summary=parent_title,
|
||||||
note=line,
|
note=line,
|
||||||
)
|
)
|
||||||
|
event.state_nodes.append(
|
||||||
|
AssetStateNode(
|
||||||
|
title=description,
|
||||||
|
at_time=event_at,
|
||||||
|
target_location=_infer_event_location(description, default_location),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
return events
|
return events
|
||||||
@@ -54,6 +54,8 @@ def import_workbook_data(
|
|||||||
except Exception:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
summary.imported_rows = {
|
summary.imported_rows = {
|
||||||
"engine_families": family_count,
|
"engine_families": family_count,
|
||||||
|
|||||||
@@ -1,79 +1,286 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;flex:1;overflow:hidden;position:absolute;inset:0;top:var(--topbar-h);left:var(--sidebar-w)">
|
<section id="ksp-location-board" aria-label="太阳系地点板">
|
||||||
<!-- Canvas Map -->
|
<header class="location-toolbar">
|
||||||
<div id="map-ct" style="flex:1;position:relative;display:flex;align-items:center;justify-content:center;background:#0B0B10;cursor:grab;overflow:hidden">
|
<nav class="location-breadcrumbs" id="location-breadcrumbs" aria-label="地图层级"></nav>
|
||||||
<canvas id="map-cv"></canvas>
|
<div class="location-summary">
|
||||||
<button id="bk-btn" onclick="mapGoBack()">← Back</button>
|
<label class="transfer-toggle" for="transfer-toggle">
|
||||||
<span id="scope-ind">Solar System</span>
|
<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>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Info Panel -->
|
<div class="location-layout">
|
||||||
<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">
|
<main class="location-map" id="location-map">
|
||||||
<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>
|
<svg id="space-map" viewBox="0 0 1000 620" preserveAspectRatio="xMidYMid meet" role="img" aria-labelledby="map-title map-desc">
|
||||||
<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>
|
<title id="map-title">太阳系资产位置地图</title>
|
||||||
<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>
|
<desc id="map-desc">从太阳系全局钻取到行星系统、行星和卫星,并查看实际转移航路。</desc>
|
||||||
<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>
|
<g id="orbit-layer"></g>
|
||||||
<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>
|
<g id="route-layer"></g>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<script>
|
<aside class="asset-panel">
|
||||||
// Pass asset data from backend to JS
|
<div class="asset-panel-head">
|
||||||
const LOC_ASSETS = {{ asset_positions_json|safe }};
|
<div><span>ASSETS IN SCOPE</span><h2 id="scope-title">Solar System</h2></div>
|
||||||
const LOC_EVENTS = {{ upcoming_events|tojson|safe }};
|
<b id="panel-count">0</b>
|
||||||
</script>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
// ═══ Keplerian Orbital Mechanics ═══
|
(() => {
|
||||||
const J2000_JD=2451545.0,DAY_MS=86400000;
|
const root = document.getElementById('ksp-location-board');
|
||||||
const ORB={
|
const assets = JSON.parse(document.getElementById('location-assets-data').textContent);
|
||||||
mercury:{a:.387098,e:.205630,i:7.005,O:48.331,p:77.456,L:252.251,P:87.969},
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
venus:{a:.723332,e:.006772,i:3.395,O:76.680,p:131.563,L:181.980,P:224.701},
|
const layers = {
|
||||||
earth:{a:1.000003,e:.016709,i:.000,O:.000,p:102.938,L:100.464,P:365.256},
|
orbit: root.querySelector('#orbit-layer'),
|
||||||
mars:{a:1.523679,e:.093401,i:1.850,O:49.558,p:336.060,L:355.453,P:686.980},
|
route: root.querySelector('#route-layer'),
|
||||||
ceres:{a:2.767500,e:.075823,i:10.593,O:80.306,p:72.716,L:73.598,P:1680},
|
asset: root.querySelector('#asset-layer')
|
||||||
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}
|
const systems = {
|
||||||
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}
|
mercury:{name:'Mercury',moons:[]}, venus:{name:'Venus',moons:[]}, earth:{name:'Earth',moons:['moon']},
|
||||||
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}}
|
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 ═══
|
function svgEl(tag, attrs, text) {
|
||||||
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 node = document.createElementNS(ns, tag);
|
||||||
const SO=['mercury','venus','earth','mars','ceres','vesta','jupiter','saturn','uranus','neptune','pluto'];
|
Object.entries(attrs || {}).forEach(([key, value]) => node.setAttribute(key, String(value)));
|
||||||
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}
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
// ═══ 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 clearMap() {
|
||||||
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}
|
Object.values(layers).forEach(layer => layer.replaceChildren());
|
||||||
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}
|
root.querySelector('#body-layer').replaceChildren();
|
||||||
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()}
|
bodyPoints = {};
|
||||||
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 orbit(cx, cy, rx, ry, strong=false, kind='') {
|
||||||
function mapGoBack(){if(mapVS.length>1)mapVS.pop();mapVO={x:0,y:0};mapZm=1;updateMapBack();renderMap();rMapInfo()}
|
layers.orbit.append(svgEl('ellipse', {cx,cy,rx,ry,class:'orbit '+(strong?'strong ':'')+(kind==='minor'?'minor':'')}));
|
||||||
initMap();
|
}
|
||||||
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
const viewport = document.getElementById('tl-viewport');
|
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
Reference in New Issue
Block a user