create-planet 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +235 -0
- package/package.json +20 -0
- package/template/.claude/settings.json +5 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +96 -0
- package/template/LICENSE +201 -0
- package/template/PROTOCOL_DIAGRAMS.md +195 -0
- package/template/README.md +51 -0
- package/template/astro.config.mjs +20 -0
- package/template/package.json +38 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/map.js +329 -0
- package/template/public/planet.css +873 -0
- package/template/public/three.min.js +29395 -0
- package/template/schema.sql +22 -0
- package/template/scripts/inject-do-exports.js +61 -0
- package/template/scripts/simulate-universe.js +158 -0
- package/template/src/env.d.ts +21 -0
- package/template/src/lib/config.ts +52 -0
- package/template/src/lib/consensus.ts +127 -0
- package/template/src/lib/crypto.ts +89 -0
- package/template/src/lib/identity.ts +35 -0
- package/template/src/lib/travel.ts +75 -0
- package/template/src/pages/api/v1/control-ws.ts +22 -0
- package/template/src/pages/api/v1/port.ts +673 -0
- package/template/src/pages/control.astro +232 -0
- package/template/src/pages/index.astro +1009 -0
- package/template/src/pages/manifest.json.ts +37 -0
- package/template/src/tests/protocol.test.ts +158 -0
- package/template/src/tests/warp-links.test.ts +117 -0
- package/template/src/traffic-control.ts +52 -0
- package/template/tsconfig.json +9 -0
- package/template/vitest.config.ts +12 -0
- package/template/wrangler.build.jsonc +36 -0
- package/template/wrangler.dev.jsonc +41 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Space Travel Protocol: Sequence Diagram
|
|
2
|
+
|
|
3
|
+
The following diagram illustrates the lifecycle of a travel transaction using the **Elected Traffic Controllers (ETC)** consensus protocol.
|
|
4
|
+
|
|
5
|
+
```mermaid
|
|
6
|
+
sequenceDiagram
|
|
7
|
+
participant Traveler as 🚀 Ship Browser
|
|
8
|
+
participant DO as TrafficControl DO
|
|
9
|
+
participant Origin as 🌍 Origin Space Port
|
|
10
|
+
participant Dest as 🌍 Destination Space Port
|
|
11
|
+
participant KV as KV Store
|
|
12
|
+
participant D1 as D1 Database
|
|
13
|
+
participant TCs as 🌐 Elected Traffic Controllers (3f+1)
|
|
14
|
+
|
|
15
|
+
Note over Traveler, Dest: PHASE 1: INITIATION & SORTITION
|
|
16
|
+
Traveler->>Origin: POST /initiate (Destination, ShipID)
|
|
17
|
+
par Parallel discovery
|
|
18
|
+
Origin->>D1: Lookup cached manifests for origin neighbors
|
|
19
|
+
D1-->>Origin: Origin neighbor manifests
|
|
20
|
+
opt Cache miss
|
|
21
|
+
Origin->>Dest: Fetch space-manifest link + manifest JSON
|
|
22
|
+
Dest-->>Origin: PlanetManifest (name, landing_site, space_port)
|
|
23
|
+
Origin->>D1: Store manifest in traffic_controllers cache
|
|
24
|
+
end
|
|
25
|
+
and
|
|
26
|
+
Origin->>Dest: GET ?action=neighbors
|
|
27
|
+
Dest-->>Origin: Dest neighbor manifests
|
|
28
|
+
end
|
|
29
|
+
Origin->>Origin: Calculate 3D Coordinates & Travel Time
|
|
30
|
+
Note over Origin: Mandatory TCs: Origin + Destination
|
|
31
|
+
Note over Origin: Elected TCs: half from origin neighbors, half from dest neighbors
|
|
32
|
+
Origin->>Origin: Elect TCs (seed-based sortition, dedup)
|
|
33
|
+
Origin->>KV: Read Ed25519 identity keys (identity_public/private)
|
|
34
|
+
KV-->>Origin: Key pair
|
|
35
|
+
Origin->>Origin: Sign Plan (Ed25519)
|
|
36
|
+
Origin->>KV: Save consensus plan state (consensus_plan_{id})
|
|
37
|
+
par Fire-and-forget
|
|
38
|
+
Origin-->>DO: INITIATE_TRAVEL event
|
|
39
|
+
and Parallel POST ×N
|
|
40
|
+
Origin->>TCs: PRE-PREPARE (Signed Plan)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Note over TCs, KV: PHASE 2: CONSENSUS (PBFT)
|
|
44
|
+
loop Each Traffic Controller
|
|
45
|
+
TCs->>TCs: Verify Plan integrity (coordinates & travel time)
|
|
46
|
+
TCs->>KV: Read own identity keys
|
|
47
|
+
TCs->>TCs: Sign Plan
|
|
48
|
+
TCs->>KV: Merge signatures into consensus plan state
|
|
49
|
+
par Fire-and-forget
|
|
50
|
+
TCs-->>DO: PREPARE_PLAN event
|
|
51
|
+
and Parallel POST ×N
|
|
52
|
+
TCs->>TCs: COMMIT (Signed Plan)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Note over Origin, D1: PHASE 3: RECORDING & TRANSIT
|
|
57
|
+
Origin->>KV: Read accumulated signatures (consensus_plan_{id})
|
|
58
|
+
Origin->>Origin: Check quorum (2f+1 signatures reached)
|
|
59
|
+
Origin->>Dest: POST ?action=register (Approved Plan)
|
|
60
|
+
Dest->>Dest: Verify destination URL
|
|
61
|
+
Dest->>Dest: Verify travel time math
|
|
62
|
+
Dest->>Dest: Verify end_timestamp not expired (anti-cheat)
|
|
63
|
+
Dest->>Dest: Verify quorum (2f+1 signatures)
|
|
64
|
+
Dest->>D1: Store plan (incoming traffic)
|
|
65
|
+
Dest-->>Origin: 200 OK
|
|
66
|
+
Origin->>D1: Persist Approved Plan (travel_plans, status=PLAN_ACCEPTED)
|
|
67
|
+
Origin->>DO: Broadcast QUORUM_REACHED event
|
|
68
|
+
DO-->>Traveler: WebSocket push (status updates to live UI)
|
|
69
|
+
Origin-->>Traveler: 200 OK (Travel Authorized)
|
|
70
|
+
|
|
71
|
+
Note over Origin, Traveler: Ship Status: PREPARING
|
|
72
|
+
|
|
73
|
+
rect rgb(20, 25, 35)
|
|
74
|
+
Note right of Origin: Wait for Start_Timestamp
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Note over Origin, Traveler: Ship Status: DEPARTING / IN TRANSIT (UI label)
|
|
78
|
+
Note over Traveler: Ship Status: INCOMING (ETA displayed)
|
|
79
|
+
|
|
80
|
+
rect rgb(20, 25, 35)
|
|
81
|
+
Note right of Origin: Wait for End_Timestamp
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Note over Traveler: Ship Status: ARRIVED (client-side only)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Protocol Summary
|
|
88
|
+
|
|
89
|
+
1. **Initiation:** The origin discovers its own neighbors and the destination's neighbors, then elects TCs: origin and destination are mandatory participants; remaining slots filled half from each neighbor pool.
|
|
90
|
+
2. **Consensus:** All elected TCs (including origin and destination) validate and sign the plan. Quorum requires $2f+1$ signatures; since origin and destination are both mandatory TCs, both must contribute.
|
|
91
|
+
3. **Recording:** Once quorum is reached, origin synchronously registers the plan with the destination (which verifies and stores it). Only after the destination confirms does origin persist the plan locally, broadcast the WebSocket event, and return 200 OK to the traveler.
|
|
92
|
+
4. **Transit:** Time is enforced by the federation; arrival status is tracked client-side via ETA timestamps.
|
|
93
|
+
|
|
94
|
+
## Data Storage
|
|
95
|
+
|
|
96
|
+
| Store | Purpose | Durability |
|
|
97
|
+
| ------------------ | --------------------------------------------------------- | --------------------------------- |
|
|
98
|
+
| **D1** | `travel_plans`, `traffic_controllers` cache | Persistent |
|
|
99
|
+
| **KV** | Ed25519 identity key pair, in-flight consensus plan state | Persistent (keys), TTL 1h (plans) |
|
|
100
|
+
| **Durable Object** | WebSocket sessions, last-50 event ring buffer | In-memory only (volatile) |
|
|
101
|
+
|
|
102
|
+
## Plan Data State Diagram
|
|
103
|
+
|
|
104
|
+
Server-side status stored in `travel_plans.status` (D1) and the in-flight KV plan:
|
|
105
|
+
|
|
106
|
+
```mermaid
|
|
107
|
+
stateDiagram-v2
|
|
108
|
+
[*] --> PREPARING : Origin creates plan (handleInitiate)
|
|
109
|
+
PREPARING --> PLAN_ACCEPTED : Quorum reached (2f+1 sigs) (handleCommit → D1 insert)
|
|
110
|
+
PLAN_ACCEPTED --> [*] : end_timestamp passes (no server transition — record kept forever)
|
|
111
|
+
|
|
112
|
+
note right of PREPARING
|
|
113
|
+
Stored in KV only
|
|
114
|
+
(consensus_plan_{id})
|
|
115
|
+
end note
|
|
116
|
+
|
|
117
|
+
note right of PLAN_ACCEPTED
|
|
118
|
+
Persisted to D1 travel_plans
|
|
119
|
+
Registered at destination (fire-and-forget)
|
|
120
|
+
KV entry expires after TTL
|
|
121
|
+
end note
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Plan UI Labels State Diagram
|
|
125
|
+
|
|
126
|
+
Client-side display labels derived from server status + timestamps:
|
|
127
|
+
|
|
128
|
+
```mermaid
|
|
129
|
+
stateDiagram-v2
|
|
130
|
+
[*] --> SCHEDULED : status=PLAN_ACCEPTED start_timestamp in future (outgoing row at origin)
|
|
131
|
+
[*] --> INCOMING : status=PLAN_ACCEPTED (incoming row at destination)
|
|
132
|
+
SCHEDULED --> IN_TRANSIT : start_timestamp reached
|
|
133
|
+
INCOMING --> IN_TRANSIT : start_timestamp reached
|
|
134
|
+
IN_TRANSIT --> ARRIVED : end_timestamp reached
|
|
135
|
+
ARRIVED --> [*] : 5 s linger then row moves to archive section
|
|
136
|
+
|
|
137
|
+
note right of SCHEDULED
|
|
138
|
+
CSS: status-scheduled
|
|
139
|
+
Label: "SCHEDULED"
|
|
140
|
+
end note
|
|
141
|
+
note right of INCOMING
|
|
142
|
+
CSS: status-transit
|
|
143
|
+
Label: "IN TRANSIT"
|
|
144
|
+
end note
|
|
145
|
+
note right of IN_TRANSIT
|
|
146
|
+
CSS: status-transit
|
|
147
|
+
Label: "IN TRANSIT"
|
|
148
|
+
end note
|
|
149
|
+
note right of ARRIVED
|
|
150
|
+
CSS: status-arrived
|
|
151
|
+
Label: "ARRIVED"
|
|
152
|
+
(client-side only — no DB write)
|
|
153
|
+
end note
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Entity-Relationship Diagram
|
|
157
|
+
|
|
158
|
+
```mermaid
|
|
159
|
+
erDiagram
|
|
160
|
+
TRAVEL_PLANS {
|
|
161
|
+
TEXT id PK
|
|
162
|
+
TEXT ship_id
|
|
163
|
+
TEXT origin_url
|
|
164
|
+
TEXT destination_url
|
|
165
|
+
INTEGER start_timestamp
|
|
166
|
+
INTEGER end_timestamp
|
|
167
|
+
TEXT status "PREPARING | PLAN_ACCEPTED"
|
|
168
|
+
TEXT signatures "JSON: planet_url→Ed25519 sig"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
TRAFFIC_CONTROLLERS {
|
|
172
|
+
TEXT planet_url PK
|
|
173
|
+
TEXT name
|
|
174
|
+
TEXT space_port_url
|
|
175
|
+
INTEGER last_manifest_fetch "Unix ms, TTL 1h"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
KV_IDENTITY {
|
|
179
|
+
TEXT identity_public "Ed25519 public key (Base64)"
|
|
180
|
+
TEXT identity_private "Ed25519 private key (Base64)"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
KV_CONSENSUS {
|
|
184
|
+
TEXT consensus_plan_id PK "consensus_plan_{uuid}"
|
|
185
|
+
TEXT plan_json "TravelPlan with accumulated sigs"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
DO_TRAFFIC_CONTROL {
|
|
189
|
+
SET sessions "Active WebSocket connections"
|
|
190
|
+
ARRAY events "Ring buffer: last 50 API events"
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
TRAVEL_PLANS ||--o{ KV_CONSENSUS : "built during PBFT"
|
|
194
|
+
TRAVEL_PLANS }o--o{ TRAFFIC_CONTROLLERS : "controllers elected from"
|
|
195
|
+
```
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Planet Advanced Reference Implementation
|
|
2
|
+
|
|
3
|
+
This repository serves as an **advanced reference implementation** for a planetary landing site in the [Federated Planets](https://github.com/Federated-Planets/federated-planets) universe, built with [Astro](https://astro.build) and optimized for running on [Cloudflare](https://workers.cloudflare.com/).
|
|
4
|
+
|
|
5
|
+
In addition to the standard 3D Star Map, this implementation includes a functional **Space Port UI** template for tracking live traffic and mission archives, following the [Space Travel Protocol](https://github.com/Federated-Planets/federated-planets/blob/main/TRAVEL.md).
|
|
6
|
+
|
|
7
|
+
For detailed information on how the Federated Planets world works, please refer to the [official specification](https://github.com/Federated-Planets/federated-planets).
|
|
8
|
+
|
|
9
|
+
## Project Structure
|
|
10
|
+
|
|
11
|
+
This project uses Astro to dynamically generate the landing site and calculate deterministic coordinates at build-time.
|
|
12
|
+
|
|
13
|
+
- **`src/pages/index.astro`**: The main **Landing Site** template. Coordinate calculations are performed in the Astro frontmatter.
|
|
14
|
+
- **`public/planet.css` & `public/map.js`**: Client-side styles and 3D ThreeJS interactivity.
|
|
15
|
+
- **`public/manifest.json`**: The metadata file for your planet.
|
|
16
|
+
- **`astro.config.mjs`**: Configuration for Astro and the `@astrojs/cloudflare` adapter.
|
|
17
|
+
|
|
18
|
+
## Space Port UI
|
|
19
|
+
|
|
20
|
+
The Space Port section in `index.astro` is designed to be updated via API or local state to show:
|
|
21
|
+
|
|
22
|
+
- **Live Traffic:** Ships preparing, departing, or arriving.
|
|
23
|
+
- **Mission Archive:** A historical log of recent arrivals and departures.
|
|
24
|
+
- **3D Coordinates:** All locations in the UI use the standard federation `XXX.XX:YYY.YY:ZZZ.ZZ` format, calculated automatically from domain names.
|
|
25
|
+
|
|
26
|
+
## Development and Build
|
|
27
|
+
|
|
28
|
+
1. **Install dependencies:**
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
```
|
|
32
|
+
2. **Local Development:**
|
|
33
|
+
```bash
|
|
34
|
+
npm run dev
|
|
35
|
+
```
|
|
36
|
+
3. **Build for Production:**
|
|
37
|
+
```bash
|
|
38
|
+
npm run build
|
|
39
|
+
```
|
|
40
|
+
4. **Local Preview (Wrangler):**
|
|
41
|
+
```bash
|
|
42
|
+
npm run preview
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Deployment
|
|
46
|
+
|
|
47
|
+
This project is configured for **Cloudflare Workers**.
|
|
48
|
+
|
|
49
|
+
- **Build Command:** `npm run build`
|
|
50
|
+
- **Output Directory:** `dist`
|
|
51
|
+
- **Environment Variables:** Ensure you use **Node.js 20+** in your build environment.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from "astro/config";
|
|
2
|
+
import cloudflare from "@astrojs/cloudflare";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
output: "server",
|
|
6
|
+
adapter: cloudflare({
|
|
7
|
+
imageService: "cloudflare",
|
|
8
|
+
platformProxy: {
|
|
9
|
+
enabled: true,
|
|
10
|
+
configPath: "wrangler.build.jsonc",
|
|
11
|
+
persist: true,
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
vite: {
|
|
15
|
+
ssr: {
|
|
16
|
+
external: ["cloudflare:workers"],
|
|
17
|
+
noExternal: true,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@federated-planets/planet",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A decentralized space exploration game where every planet is a sovereign website.",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "npm run build && wrangler dev -c wrangler.dev.jsonc",
|
|
7
|
+
"dev:db:init": "wrangler d1 execute planet_db --file=schema.sql --local -c wrangler.dev.jsonc",
|
|
8
|
+
"dev:clean": "rm -rf .wrangler/state",
|
|
9
|
+
"format": "prettier --write .",
|
|
10
|
+
"build": "astro build",
|
|
11
|
+
"postbuild": "node scripts/inject-do-exports.js",
|
|
12
|
+
"preview": "wrangler preview",
|
|
13
|
+
"simulate": "node scripts/simulate-universe.js",
|
|
14
|
+
"test": "npm run build && vitest run",
|
|
15
|
+
"test:protocol": "npm run build && vitest run src/tests/protocol.test.ts",
|
|
16
|
+
"test:warp-links": "npm run build && vitest run src/tests/warp-links.test.ts"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@astrojs/cloudflare": "^13.1.4",
|
|
20
|
+
"astro": "^6.1.1",
|
|
21
|
+
"cheerio": "^1.2.0",
|
|
22
|
+
"md5": "^2.3.0",
|
|
23
|
+
"zod": "^3.25.76"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"author": "Sergey Chernyshev",
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@astrojs/check": "^0.9.8",
|
|
30
|
+
"@types/md5": "^2.3.6",
|
|
31
|
+
"miniflare": "^4.20260317.3",
|
|
32
|
+
"prettier": "^3.8.1",
|
|
33
|
+
"prettier-plugin-astro": "^0.14.1",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vitest": "^4.1.2",
|
|
36
|
+
"wrangler": "^4.78.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// 3D Star Map using ThreeJS
|
|
2
|
+
let scene, camera, renderer, planetsGroup;
|
|
3
|
+
const planetPoints = [];
|
|
4
|
+
const shipPoints = [];
|
|
5
|
+
let selectedId = null;
|
|
6
|
+
|
|
7
|
+
const initThree = () => {
|
|
8
|
+
const container = document.getElementById("three-container");
|
|
9
|
+
if (!container) return;
|
|
10
|
+
|
|
11
|
+
const rootStyle = getComputedStyle(document.documentElement);
|
|
12
|
+
const colorOutbound =
|
|
13
|
+
rootStyle.getPropertyValue("--color-outbound").trim() || "#e67e22";
|
|
14
|
+
const colorInbound =
|
|
15
|
+
rootStyle.getPropertyValue("--color-inbound").trim() || "#3498db";
|
|
16
|
+
|
|
17
|
+
scene = new THREE.Scene();
|
|
18
|
+
scene.background = new THREE.Color(
|
|
19
|
+
rootStyle.getPropertyValue("--map-bg").trim() || "#0b0e14",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const width = container.clientWidth;
|
|
23
|
+
const height = container.clientHeight;
|
|
24
|
+
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 5000);
|
|
25
|
+
camera.position.set(1500, 1500, 1500);
|
|
26
|
+
camera.lookAt(0, 0, 0);
|
|
27
|
+
|
|
28
|
+
renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
29
|
+
renderer.setSize(width, height);
|
|
30
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
31
|
+
container.appendChild(renderer.domElement);
|
|
32
|
+
|
|
33
|
+
planetsGroup = new THREE.Group();
|
|
34
|
+
scene.add(planetsGroup);
|
|
35
|
+
|
|
36
|
+
const myX = parseFloat(document.body.dataset.myX) - 500;
|
|
37
|
+
const myY = parseFloat(document.body.dataset.myY) - 500;
|
|
38
|
+
const myZ = parseFloat(document.body.dataset.myZ) - 500;
|
|
39
|
+
|
|
40
|
+
const myPlanetGeo = new THREE.SphereGeometry(15, 32, 32);
|
|
41
|
+
const myPlanetMat = new THREE.MeshBasicMaterial({ color: 0x2ecc71 });
|
|
42
|
+
const myPlanet = new THREE.Mesh(myPlanetGeo, myPlanetMat);
|
|
43
|
+
myPlanet.position.set(myX, myY, myZ);
|
|
44
|
+
planetsGroup.add(myPlanet);
|
|
45
|
+
|
|
46
|
+
const pulseGeo = new THREE.SphereGeometry(20, 32, 32);
|
|
47
|
+
const pulseMat = new THREE.MeshBasicMaterial({
|
|
48
|
+
color: 0x2ecc71,
|
|
49
|
+
transparent: true,
|
|
50
|
+
opacity: 0.4,
|
|
51
|
+
});
|
|
52
|
+
const pulse = new THREE.Mesh(pulseGeo, pulseMat);
|
|
53
|
+
myPlanet.add(pulse);
|
|
54
|
+
myPlanet.userData.pulse = pulse;
|
|
55
|
+
|
|
56
|
+
document.querySelectorAll(".warp-links a").forEach((link) => {
|
|
57
|
+
const x = parseFloat(link.dataset.x) - 500;
|
|
58
|
+
const y = parseFloat(link.dataset.y) - 500;
|
|
59
|
+
const z = parseFloat(link.dataset.z) - 500;
|
|
60
|
+
const id = link.dataset.id;
|
|
61
|
+
|
|
62
|
+
const neighborGeo = new THREE.SphereGeometry(8, 16, 16);
|
|
63
|
+
const neighborMat = new THREE.MeshBasicMaterial({
|
|
64
|
+
color: 0x4a90e2,
|
|
65
|
+
transparent: true,
|
|
66
|
+
opacity: 0.6,
|
|
67
|
+
});
|
|
68
|
+
const neighbor = new THREE.Mesh(neighborGeo, neighborMat);
|
|
69
|
+
neighbor.position.set(x, y, z);
|
|
70
|
+
neighbor.userData = { id, originalColor: 0x4a90e2 };
|
|
71
|
+
planetsGroup.add(neighbor);
|
|
72
|
+
planetPoints.push(neighbor);
|
|
73
|
+
|
|
74
|
+
const points = [
|
|
75
|
+
new THREE.Vector3(myX, myY, myZ),
|
|
76
|
+
new THREE.Vector3(x, y, z),
|
|
77
|
+
];
|
|
78
|
+
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
|
79
|
+
const lineMat = new THREE.LineDashedMaterial({
|
|
80
|
+
color: 0x4a90e2,
|
|
81
|
+
dashSize: 20,
|
|
82
|
+
gapSize: 10,
|
|
83
|
+
transparent: true,
|
|
84
|
+
opacity: 0,
|
|
85
|
+
});
|
|
86
|
+
const line = new THREE.Line(lineGeo, lineMat);
|
|
87
|
+
line.computeLineDistances();
|
|
88
|
+
line.userData = { id };
|
|
89
|
+
planetsGroup.add(line);
|
|
90
|
+
planetPoints.push(line);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// planId → { mesh, line } for removal on arrival
|
|
94
|
+
const shipObjects = new Map();
|
|
95
|
+
|
|
96
|
+
// Add Active Ships from Traffic
|
|
97
|
+
const shipsData = JSON.parse(container.dataset.ships || "[]");
|
|
98
|
+
shipsData.forEach((ship) => {
|
|
99
|
+
const ox = ship.originCoords.x - 500;
|
|
100
|
+
const oy = ship.originCoords.y - 500;
|
|
101
|
+
const oz = ship.originCoords.z - 500;
|
|
102
|
+
const dx = ship.destCoords.x - 500;
|
|
103
|
+
const dy = ship.destCoords.y - 500;
|
|
104
|
+
const dz = ship.destCoords.z - 500;
|
|
105
|
+
|
|
106
|
+
// Travel line
|
|
107
|
+
const linePoints = [
|
|
108
|
+
new THREE.Vector3(ox, oy, oz),
|
|
109
|
+
new THREE.Vector3(dx, dy, dz),
|
|
110
|
+
];
|
|
111
|
+
const lineGeo = new THREE.BufferGeometry().setFromPoints(linePoints);
|
|
112
|
+
const shipLineColor =
|
|
113
|
+
ship.type === "incoming" ? colorInbound : colorOutbound;
|
|
114
|
+
const lineMat = new THREE.LineBasicMaterial({
|
|
115
|
+
color: new THREE.Color(shipLineColor),
|
|
116
|
+
transparent: true,
|
|
117
|
+
opacity: 0.7,
|
|
118
|
+
});
|
|
119
|
+
const travelLine = new THREE.Line(lineGeo, lineMat);
|
|
120
|
+
planetsGroup.add(travelLine);
|
|
121
|
+
|
|
122
|
+
// Ship mesh
|
|
123
|
+
const shipGeo = new THREE.TetrahedronGeometry(10);
|
|
124
|
+
const shipMat = new THREE.MeshBasicMaterial({ color: 0xf1c40f });
|
|
125
|
+
const shipMesh = new THREE.Mesh(shipGeo, shipMat);
|
|
126
|
+
|
|
127
|
+
shipMesh.userData = {
|
|
128
|
+
start: ship.rawPlan.start_timestamp,
|
|
129
|
+
end: ship.rawPlan.end_timestamp,
|
|
130
|
+
ox,
|
|
131
|
+
oy,
|
|
132
|
+
oz,
|
|
133
|
+
dx,
|
|
134
|
+
dy,
|
|
135
|
+
dz,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
planetsGroup.add(shipMesh);
|
|
139
|
+
shipPoints.push(shipMesh);
|
|
140
|
+
|
|
141
|
+
if (ship.rawPlan.id)
|
|
142
|
+
shipObjects.set(ship.rawPlan.id, { mesh: shipMesh, line: travelLine });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const animate = () => {
|
|
146
|
+
requestAnimationFrame(animate);
|
|
147
|
+
|
|
148
|
+
planetsGroup.rotation.y += 0.002;
|
|
149
|
+
planetsGroup.rotation.x += 0.001;
|
|
150
|
+
|
|
151
|
+
if (myPlanet.userData.pulse) {
|
|
152
|
+
const s = 1 + Math.sin(Date.now() * 0.005) * 0.5;
|
|
153
|
+
myPlanet.userData.pulse.scale.set(s, s, s);
|
|
154
|
+
myPlanet.userData.pulse.material.opacity = 0.4 * (1 - (s - 0.5) / 1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Dynamic Ship Positioning (Lerp between origin and destination)
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
shipPoints.forEach((s, i) => {
|
|
160
|
+
const { start, end, ox, oy, oz, dx, dy, dz } = s.userData;
|
|
161
|
+
|
|
162
|
+
let p = (now - start) / (end - start);
|
|
163
|
+
p = Math.max(0, Math.min(1, p));
|
|
164
|
+
|
|
165
|
+
s.position.set(
|
|
166
|
+
ox + (dx - ox) * p,
|
|
167
|
+
oy + (dy - oy) * p,
|
|
168
|
+
oz + (dz - oz) * p,
|
|
169
|
+
);
|
|
170
|
+
s.rotation.y += 0.05;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
renderer.render(scene, camera);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
animate();
|
|
177
|
+
|
|
178
|
+
window.addShipToScene = (ship) => {
|
|
179
|
+
const ox = ship.originCoords.x - 500;
|
|
180
|
+
const oy = ship.originCoords.y - 500;
|
|
181
|
+
const oz = ship.originCoords.z - 500;
|
|
182
|
+
const dx = ship.destCoords.x - 500;
|
|
183
|
+
const dy = ship.destCoords.y - 500;
|
|
184
|
+
const dz = ship.destCoords.z - 500;
|
|
185
|
+
|
|
186
|
+
const linePoints = [
|
|
187
|
+
new THREE.Vector3(ox, oy, oz),
|
|
188
|
+
new THREE.Vector3(dx, dy, dz),
|
|
189
|
+
];
|
|
190
|
+
const lineGeo = new THREE.BufferGeometry().setFromPoints(linePoints);
|
|
191
|
+
const shipLineColor =
|
|
192
|
+
ship.type === "incoming" ? colorInbound : colorOutbound;
|
|
193
|
+
const lineMat = new THREE.LineBasicMaterial({
|
|
194
|
+
color: new THREE.Color(shipLineColor),
|
|
195
|
+
transparent: true,
|
|
196
|
+
opacity: 0.7,
|
|
197
|
+
});
|
|
198
|
+
const travelLine = new THREE.Line(lineGeo, lineMat);
|
|
199
|
+
planetsGroup.add(travelLine);
|
|
200
|
+
|
|
201
|
+
const shipGeo = new THREE.TetrahedronGeometry(10);
|
|
202
|
+
const shipMat = new THREE.MeshBasicMaterial({ color: 0xf1c40f });
|
|
203
|
+
const shipMesh = new THREE.Mesh(shipGeo, shipMat);
|
|
204
|
+
shipMesh.userData = {
|
|
205
|
+
start: ship.rawPlan.start_timestamp,
|
|
206
|
+
end: ship.rawPlan.end_timestamp,
|
|
207
|
+
ox,
|
|
208
|
+
oy,
|
|
209
|
+
oz,
|
|
210
|
+
dx,
|
|
211
|
+
dy,
|
|
212
|
+
dz,
|
|
213
|
+
};
|
|
214
|
+
planetsGroup.add(shipMesh);
|
|
215
|
+
shipPoints.push(shipMesh);
|
|
216
|
+
|
|
217
|
+
if (ship.rawPlan.id)
|
|
218
|
+
shipObjects.set(ship.rawPlan.id, { mesh: shipMesh, line: travelLine });
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
window.removeShipFromScene = (planId) => {
|
|
222
|
+
const objs = shipObjects.get(planId);
|
|
223
|
+
if (!objs) return;
|
|
224
|
+
planetsGroup.remove(objs.mesh);
|
|
225
|
+
planetsGroup.remove(objs.line);
|
|
226
|
+
const idx = shipPoints.indexOf(objs.mesh);
|
|
227
|
+
if (idx !== -1) shipPoints.splice(idx, 1);
|
|
228
|
+
shipObjects.delete(planId);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
window.addEventListener("resize", () => {
|
|
232
|
+
const width = container.clientWidth;
|
|
233
|
+
const height = container.clientHeight;
|
|
234
|
+
renderer.setSize(width, height);
|
|
235
|
+
camera.aspect = width / height;
|
|
236
|
+
camera.updateProjectionMatrix();
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const updateHighlight = (id, isActive) => {
|
|
241
|
+
const elements = document.querySelectorAll(`[data-id="${id}"]`);
|
|
242
|
+
elements.forEach((el) => {
|
|
243
|
+
if (isActive) el.classList.add("active");
|
|
244
|
+
else el.classList.remove("active");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const link = document.querySelector(`.warp-links a[data-id="${id}"]`);
|
|
248
|
+
if (link) {
|
|
249
|
+
const li = link.closest("li");
|
|
250
|
+
if (isActive) li.classList.add("active");
|
|
251
|
+
else li.classList.remove("active");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
planetPoints.forEach((obj) => {
|
|
255
|
+
if (obj.userData.id === id) {
|
|
256
|
+
if (obj.isMesh) {
|
|
257
|
+
obj.material.opacity = isActive ? 1 : 0.6;
|
|
258
|
+
obj.scale.set(isActive ? 2 : 1, isActive ? 2 : 1, isActive ? 2 : 1);
|
|
259
|
+
obj.material.color.setHex(isActive ? 0xffffff : 0x4a90e2);
|
|
260
|
+
} else if (obj.isLine) {
|
|
261
|
+
obj.material.opacity = isActive ? 0.6 : 0;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const handleHover = (e) => {
|
|
268
|
+
const item = e.target.closest(".warp-links li");
|
|
269
|
+
if (!item) return;
|
|
270
|
+
const link = item.querySelector("a");
|
|
271
|
+
if (!link) return;
|
|
272
|
+
const id = link.dataset.id;
|
|
273
|
+
const isEnter = e.type === "mouseover" || e.type === "mouseenter";
|
|
274
|
+
if (isEnter) {
|
|
275
|
+
if (selectedId && selectedId !== id) updateHighlight(selectedId, false);
|
|
276
|
+
updateHighlight(id, true);
|
|
277
|
+
} else {
|
|
278
|
+
updateHighlight(id, false);
|
|
279
|
+
if (selectedId) updateHighlight(selectedId, true);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const handleClick = (e) => {
|
|
284
|
+
const item = e.target.closest(".warp-links li");
|
|
285
|
+
if (!item) return;
|
|
286
|
+
const link = item.querySelector("a");
|
|
287
|
+
if (!link) return;
|
|
288
|
+
const id = link.dataset.id;
|
|
289
|
+
if (e.target === link) return;
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
if (selectedId && selectedId !== id) updateHighlight(selectedId, false);
|
|
292
|
+
selectedId = id;
|
|
293
|
+
updateHighlight(selectedId, true);
|
|
294
|
+
window.dispatchEvent(
|
|
295
|
+
new CustomEvent("planetSelected", {
|
|
296
|
+
detail: {
|
|
297
|
+
id: id,
|
|
298
|
+
name: link.dataset.name,
|
|
299
|
+
url: link.href,
|
|
300
|
+
x: parseFloat(link.dataset.x),
|
|
301
|
+
y: parseFloat(link.dataset.y),
|
|
302
|
+
z: parseFloat(link.dataset.z),
|
|
303
|
+
formatted: link.dataset.formatted,
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const initMap = () => {
|
|
310
|
+
initThree();
|
|
311
|
+
const warpRing = document.querySelector(".warp-links");
|
|
312
|
+
if (warpRing) {
|
|
313
|
+
warpRing.addEventListener("mouseover", handleHover);
|
|
314
|
+
warpRing.addEventListener("mouseout", handleHover);
|
|
315
|
+
warpRing.addEventListener("click", handleClick);
|
|
316
|
+
}
|
|
317
|
+
window.addEventListener("clearSelection", () => {
|
|
318
|
+
if (selectedId) {
|
|
319
|
+
updateHighlight(selectedId, false);
|
|
320
|
+
selectedId = null;
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (document.readyState === "loading") {
|
|
326
|
+
document.addEventListener("DOMContentLoaded", initMap);
|
|
327
|
+
} else {
|
|
328
|
+
initMap();
|
|
329
|
+
}
|