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.
Files changed (35) hide show
  1. package/index.js +235 -0
  2. package/package.json +20 -0
  3. package/template/.claude/settings.json +5 -0
  4. package/template/.prettierrc +11 -0
  5. package/template/AGENTS.md +96 -0
  6. package/template/LICENSE +201 -0
  7. package/template/PROTOCOL_DIAGRAMS.md +195 -0
  8. package/template/README.md +51 -0
  9. package/template/astro.config.mjs +20 -0
  10. package/template/package.json +38 -0
  11. package/template/public/favicon.ico +0 -0
  12. package/template/public/map.js +329 -0
  13. package/template/public/planet.css +873 -0
  14. package/template/public/three.min.js +29395 -0
  15. package/template/schema.sql +22 -0
  16. package/template/scripts/inject-do-exports.js +61 -0
  17. package/template/scripts/simulate-universe.js +158 -0
  18. package/template/src/env.d.ts +21 -0
  19. package/template/src/lib/config.ts +52 -0
  20. package/template/src/lib/consensus.ts +127 -0
  21. package/template/src/lib/crypto.ts +89 -0
  22. package/template/src/lib/identity.ts +35 -0
  23. package/template/src/lib/travel.ts +75 -0
  24. package/template/src/pages/api/v1/control-ws.ts +22 -0
  25. package/template/src/pages/api/v1/port.ts +673 -0
  26. package/template/src/pages/control.astro +232 -0
  27. package/template/src/pages/index.astro +1009 -0
  28. package/template/src/pages/manifest.json.ts +37 -0
  29. package/template/src/tests/protocol.test.ts +158 -0
  30. package/template/src/tests/warp-links.test.ts +117 -0
  31. package/template/src/traffic-control.ts +52 -0
  32. package/template/tsconfig.json +9 -0
  33. package/template/vitest.config.ts +12 -0
  34. package/template/wrangler.build.jsonc +36 -0
  35. 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
+ }