@@ -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 , 1 fr ) ; overflow : hidden ; background : var ( - - bg ) ; color : var ( - - text ) ; font-size : clamp ( 12 px , .28 vw + 8 px , 22 px ) }
# ksp-location-board button , # ksp-location-board a { font : inherit }
. location-toolbar { display : flex ; align-items : center ; justify-content : space-between ; gap : 16 px ; min-height : 48 px ; padding : 8 px clamp ( 14 px , 1 vw , 30 px ) ; border-bottom : 1 px solid var ( - - border ) ; background : var ( - - surface ) }
. location-breadcrumbs , . location-summary { display : flex ; align-items : center ; gap : 9 px ; 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 : 5 px } . live-status i { width : 7 px ; height : 7 px ; border-radius : 50 % ; background : var ( - - amber ) }
. transfer-toggle { display : flex ; align-items : center ; gap : 7 px ; cursor : pointer ; color : var ( - - text ) } . transfer-toggle input { accent-color : var ( - - accent ) }
. location-reset { padding : 4 px 9 px ; border : 1 px solid var ( - - border ) ; border-radius : 4 px ; 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 , 1 fr ) clamp ( 270 px , 20 vw , 540 px ) ; 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 , 1 fr ) ; min-height : 0 ; padding : clamp ( 14 px , 1 vw , 28 px ) ; border-left : 1 px solid var ( - - border ) ; background : var ( - - surface ) }
. asset-panel-head { display : flex ; align-items : flex-start ; justify-content : space-between ; gap : 10 px ; padding-bottom : 10 px ; border-bottom : 1 px solid var ( - - border ) }
. asset-panel-head span , . drawer-eyebrow { color : var ( - - muted ) ; font-family : var ( - - font - data ) ; font-size : .7 em ; letter-spacing : .08 em } . asset-panel-head h2 { margin-top : 4 px ; font-size : 1.2 em } . asset-panel-head b { padding : 4 px 8 px ; 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 : 9 px ; width : 100 % ; padding : clamp ( 9 px , .55 vw , 18 px ) 3 px ; border : 0 ; border-bottom : 1 px solid var ( - - border ) ; background : transparent ; color : var ( - - text ) ; text-align : left ; cursor : pointer } . asset-button :: before { content : "" ; width : 7 px ; height : 7 px ; 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 : 18 px 2 px ; color : var ( - - muted ) }
. body-button { --planet-size : 16 px ; 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 : 1 px solid var ( - - muted ) ; border-radius : 50 % ; background : var ( - - surface2 ) } . body-button . sun . planet-disc { border-color : var ( - - text ) ; background : #74b4ec ; box-shadow : 0 0 26 px 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 : 2 px solid var ( - - accent ) ; outline-offset : 3 px }
. body-label { position : absolute ; top : calc ( 100 % + 5 px ) ; left : 50 % ; transform : translateX ( -50 % ) ; color : var ( - - muted ) ; white-space : nowrap ; pointer-events : none }
. map-caption { position : absolute ; top : 16 px ; left : 18 px ; display : flex ; align-items : center ; gap : 9 px ; max-width : 70 % ; pointer-events : none } . map-caption span { padding : 5 px 8 px ; background : rgba ( 59 , 130 , 246 , .12 ) ; color : #7ab8f6 ; font-family : var ( - - font - data ) } . map-caption b { font-weight : 500 }
. map-legend { position : absolute ; bottom : 16 px ; left : 18 px ; display : flex ; gap : 14 px ; padding : 8 px 10 px ; border : 1 px solid var ( - - border ) ; background : rgba ( 19 , 19 , 24 , .88 ) ; color : var ( - - text ) } . map-legend span { display : flex ; align-items : center ; gap : 6 px } . asset-dot { width : 7 px ; height : 7 px ; border-radius : 50 % ; background : var ( - - amber ) } . progress-line , . remaining-line { display : inline-block ; width : 24 px ; border-top : 2 px solid #72b4f4 } . remaining-line { border-top : 1 px 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 : 12 px ; font-weight : 600 ; stroke-width : 3 } . route-label-meta { fill : var ( - - muted ) ; font-size : 10 px ; 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 : 11 px ; font-family : var ( - - font - ui ) ; paint-order : stroke ; stroke : var ( - - bg ) ; stroke-width : 3 ; stroke-linejoin : round }
. asset-drawer { --panel-width : clamp ( 270 px , 20 vw , 540 px ) ; position : absolute ; z-index : 8 ; top : 0 ; right : var ( - - panel - width ) ; bottom : 0 ; width : clamp ( 320 px , 23 vw , 620 px ) ; display : flex ; flex-direction : column ; gap : clamp ( 13 px , .8 vw , 24 px ) ; padding : clamp ( 18 px , 1.2 vw , 36 px ) ; border-left : 1 px solid var ( - - border ) ; background : var ( - - surface2 ) ; transform : translateX ( calc ( 100 % + var ( - - panel - width ) ) ) ; transition : transform .18 s ease ; pointer-events : none } . asset-drawer . open { transform : translateX ( 0 ) ; pointer-events : auto } . drawer-close { align-self : flex-end ; width : 30 px ; height : 30 px ; border : 1 px solid var ( - - border ) ; border-radius : 4 px ; background : transparent ; color : var ( - - muted ) ; font-size : 20 px ; cursor : pointer } . asset-drawer h2 { font-size : 1.4 em } . drawer-fields { display : grid ; gap : 10 px } . drawer-field { padding-top : 9 px ; border-top : 1 px solid var ( - - border ) } . drawer-field span , . drawer-field strong { display : block } . drawer-field span { margin-bottom : 3 px ; color : var ( - - muted ) ; font-size : .78 em } . drawer-field strong { font-weight : 500 ; overflow-wrap : anywhere } . drawer-link { margin-top : auto ; padding : 9 px 12 px ; border-radius : 4 px ; 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 , 1 fr ) 230 px } . asset-drawer { --panel-width : 230 px ; right : 230 px ; width : 300 px } . 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 , 1 fr ) 180 px } . asset-drawer { --panel-width : 180 px ; right : 180 px ; width : 280 px } . 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 %}