Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Santo Domingo Port Management System</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.min.js"></script> | |
<style> | |
body { | |
margin: 0; | |
overflow: hidden; | |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
} | |
#viewport-3d { | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
} | |
.overlay { | |
position: absolute; | |
background: rgba(15, 23, 42, 0.85); | |
backdrop-filter: blur(10px); | |
border-radius: 12px; | |
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); | |
color: white; | |
z-index: 100; | |
border: 1px solid rgba(56, 189, 248, 0.3); | |
} | |
.side-right { | |
right: 20px; | |
top: 50%; | |
transform: translateY(-50%); | |
padding: 15px 10px; | |
} | |
.top-right { | |
right: 20px; | |
top: 20px; | |
padding: 12px 20px; | |
} | |
.bottom { | |
bottom: 20px; | |
left: 50%; | |
transform: translateX(-50%); | |
width: 40%; | |
min-width: 400px; | |
} | |
.avatar { | |
width: 40px; | |
height: 40px; | |
border-radius: 50%; | |
margin-right: 10px; | |
object-fit: cover; | |
border: 2px solid #38bdf8; | |
} | |
#chat-messages { | |
height: 200px; | |
overflow-y: auto; | |
padding: 10px; | |
margin-bottom: 10px; | |
border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
} | |
.chat-message { | |
margin-bottom: 10px; | |
padding: 8px 12px; | |
border-radius: 8px; | |
max-width: 80%; | |
} | |
.user-message { | |
background: rgba(56, 189, 248, 0.2); | |
margin-left: auto; | |
border-top-right-radius: 0; | |
} | |
.ai-message { | |
background: rgba(30, 41, 59, 0.8); | |
margin-right: auto; | |
border-top-left-radius: 0; | |
} | |
.menu-btn { | |
width: 50px; | |
height: 50px; | |
margin: 10px 0; | |
border-radius: 10px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
transition: all 0.3s ease; | |
position: relative; | |
} | |
.menu-btn:hover { | |
background: rgba(56, 189, 248, 0.3); | |
transform: translateY(-3px); | |
} | |
.menu-btn.active { | |
background: rgba(56, 189, 248, 0.5); | |
} | |
.menu-btn::after { | |
content: attr(title); | |
position: absolute; | |
left: -150px; | |
background: rgba(15, 23, 42, 0.9); | |
padding: 5px 10px; | |
border-radius: 5px; | |
opacity: 0; | |
transition: opacity 0.3s; | |
pointer-events: none; | |
width: 140px; | |
text-align: right; | |
} | |
.menu-btn:hover::after { | |
opacity: 1; | |
} | |
#minimap { | |
width: 220px; | |
height: 220px; | |
overflow: hidden; | |
border: 2px solid #38bdf8; | |
} | |
#minimap img { | |
width: 100%; | |
height: 100%; | |
object-fit: cover; | |
} | |
#context-menu { | |
display: none; | |
width: 300px; | |
padding: 15px; | |
} | |
.context-item { | |
padding: 8px 12px; | |
border-radius: 6px; | |
margin-bottom: 5px; | |
cursor: pointer; | |
transition: background 0.2s; | |
} | |
.context-item:hover { | |
background: rgba(56, 189, 248, 0.3); | |
} | |
.pulse { | |
animation: pulse 2s infinite; | |
} | |
@keyframes pulse { | |
0% { | |
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.7); | |
} | |
70% { | |
box-shadow: 0 0 0 10px rgba(56, 189, 248, 0); | |
} | |
100% { | |
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); | |
} | |
} | |
.status-indicator { | |
width: 10px; | |
height: 10px; | |
border-radius: 50%; | |
display: inline-block; | |
margin-right: 8px; | |
} | |
.status-open { | |
background-color: #10b981; | |
} | |
.status-closed { | |
background-color: #ef4444; | |
} | |
.status-warning { | |
background-color: #f59e0b; | |
} | |
.progress-bar { | |
height: 8px; | |
background-color: rgba(255, 255, 255, 0.1); | |
border-radius: 4px; | |
margin-top: 5px; | |
overflow: hidden; | |
} | |
.progress-fill { | |
height: 100%; | |
background-color: #38bdf8; | |
border-radius: 4px; | |
transition: width 0.3s ease; | |
} | |
.vessel-marker { | |
position: absolute; | |
width: 12px; | |
height: 12px; | |
background-color: #38bdf8; | |
border-radius: 50%; | |
border: 2px solid white; | |
transform: translate(-50%, -50%); | |
} | |
/* Custom scrollbar */ | |
::-webkit-scrollbar { | |
width: 8px; | |
} | |
::-webkit-scrollbar-track { | |
background: rgba(255, 255, 255, 0.05); | |
border-radius: 10px; | |
} | |
::-webkit-scrollbar-thumb { | |
background: rgba(255, 255, 255, 0.2); | |
border-radius: 10px; | |
} | |
::-webkit-scrollbar-thumb:hover { | |
background: rgba(255, 255, 255, 0.3); | |
} | |
/* Minimap position indicators */ | |
.minimap-indicator { | |
position: absolute; | |
width: 6px; | |
height: 6px; | |
background-color: #38bdf8; | |
border-radius: 50%; | |
transform: translate(-50%, -50%); | |
} | |
.camera-indicator { | |
position: absolute; | |
width: 10px; | |
height: 10px; | |
background-color: #ef4444; | |
border-radius: 50%; | |
transform: translate(-50%, -50%); | |
} | |
.camera-direction { | |
position: absolute; | |
width: 0; | |
height: 0; | |
border-left: 8px solid transparent; | |
border-right: 8px solid transparent; | |
border-top: 12px solid #ef4444; | |
transform: translate(-50%, -50%) rotate(var(--rotation)); | |
} | |
</style> | |
</head> | |
<body class="bg-slate-900 text-white overflow-hidden"> | |
<!-- 3D Viewport Container --> | |
<div id="viewport-3d"></div> | |
<!-- Minimap --> | |
<div id="minimap" class="overlay top-right"> | |
<img src="https://maps.googleapis.com/maps/api/staticmap?center=18.4719,-69.8923&zoom=15&size=400x400&maptype=satellite&key=YOUR_API_KEY" alt="Santo Domingo Port Map"> | |
<div class="absolute bottom-2 right-2 bg-black bg-opacity-70 px-2 py-1 rounded text-xs"> | |
<span class="text-blue-300">Live View</span> | |
</div> | |
<!-- Vessel markers will be added here by JS --> | |
</div> | |
<!-- Main Menu --> | |
<nav id="main-menu" class="overlay side-right"> | |
<ul> | |
<li> | |
<button id="btn-berth" class="menu-btn pulse" title="Gestion des postes à quai"> | |
<i class="fas fa-anchor text-2xl text-blue-300"></i> | |
</button> | |
</li> | |
<li> | |
<button id="btn-yard" class="menu-btn" title="Gestion du yard"> | |
<i class="fas fa-boxes text-2xl text-blue-300"></i> | |
</button> | |
</li> | |
<li> | |
<button id="btn-gate" class="menu-btn" title="Gestion des accès"> | |
<i class="fas fa-truck-moving text-2xl text-blue-300"></i> | |
</button> | |
</li> | |
<li> | |
<button id="btn-schedule" class="menu-btn" title="Planification équipements/personnel"> | |
<i class="fas fa-calendar-alt text-2xl text-blue-300"></i> | |
</button> | |
</li> | |
<li> | |
<button id="btn-billing" class="menu-btn" title="Facturation & rapports"> | |
<i class="fas fa-file-invoice-dollar text-2xl text-blue-300"></i> | |
</button> | |
</li> | |
</ul> | |
</nav> | |
<!-- AI Chat Panel --> | |
<div id="chat-panel" class="overlay bottom"> | |
<div id="chat-header" class="flex items-center justify-between px-4 py-2 border-b border-slate-700"> | |
<h3 class="font-semibold text-blue-300 flex items-center"> | |
<img src="https://via.placeholder.com/24/38bdf8/ffffff?text=M2H" alt="M2H Majestic" class="mr-2 rounded"> | |
M2H Majestic Assistant | |
</h3> | |
<button id="minimize-chat" class="text-slate-400 hover:text-white"> | |
<i class="fas fa-minus"></i> | |
</button> | |
</div> | |
<div id="chat-messages"> | |
<div class="chat-message ai-message"> | |
<p>Bonjour! Je suis votre assistant M2H Majestic pour la gestion du port de Santo Domingo. Comment puis-je vous aider aujourd'hui?</p> | |
<p class="text-xs text-slate-400 mt-1">Maintenant</p> | |
</div> | |
<div class="chat-message ai-message"> | |
<p>Je peux vous fournir des informations sur les navires en approche, l'état des quais, la capacité du yard et plus encore.</p> | |
<p class="text-xs text-slate-400 mt-1">Maintenant</p> | |
</div> | |
</div> | |
<div id="chat-input" class="flex p-3"> | |
<input type="text" placeholder="Demandez des informations sur les navires, les quais..." | |
class="flex-1 bg-slate-800 rounded-l-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
<button id="send-btn" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-r-lg transition"> | |
<i class="fas fa-paper-plane"></i> | |
</button> | |
</div> | |
</div> | |
<!-- User Info --> | |
<div id="user-info" class="overlay top-right flex items-center"> | |
<img src="https://randomuser.me/api/portraits/men/32.jpg" alt="Avatar" class="avatar"> | |
<div> | |
<p class="font-medium">Jean Dupont</p> | |
<p class="text-xs text-blue-300">Yield Manager</p> | |
</div> | |
<button class="ml-4 text-slate-400 hover:text-white"> | |
<i class="fas fa-cog"></i> | |
</button> | |
</div> | |
<!-- Context Menu (hidden by default) --> | |
<div id="context-menu" class="overlay"> | |
<h3 class="font-semibold mb-3 text-blue-300 border-b border-slate-700 pb-2 flex items-center"> | |
<i class="fas fa-info-circle mr-2"></i> | |
<span id="context-title">Quai 1 - Terminal Santo Domingo</span> | |
</h3> | |
<div class="mb-3"> | |
<div class="flex items-center"> | |
<span class="status-indicator status-open"></span> | |
<span>Statut: <strong>Occupé</strong></span> | |
</div> | |
<div class="mt-1"> | |
<span>Navire: <strong>MSC Caribe</strong> (Porte-conteneurs)</span> | |
</div> | |
<div class="mt-1"> | |
<span>ETA: <strong>14:30</strong> (dans 2h15)</span> | |
</div> | |
</div> | |
<div class="mb-3"> | |
<div class="flex justify-between text-sm"> | |
<span>Progression déchargement:</span> | |
<span>65%</span> | |
</div> | |
<div class="progress-bar"> | |
<div class="progress-fill" style="width: 65%"></div> | |
</div> | |
</div> | |
<div class="context-item flex items-center"> | |
<i class="fas fa-info-circle mr-3 text-blue-300"></i> | |
<span>Voir les détails complets</span> | |
</div> | |
<div class="context-item flex items-center"> | |
<i class="fas fa-calendar-check mr-3 text-blue-300"></i> | |
<span>Planifier l'accostage</span> | |
</div> | |
<div class="context-item flex items-center"> | |
<i class="fas fa-truck-loading mr-3 text-blue-300"></i> | |
<span>Assigner des grues</span> | |
</div> | |
<div class="context-item flex items-center"> | |
<i class="fas fa-file-invoice mr-3 text-blue-300"></i> | |
<span>Générer un rapport</span> | |
</div> | |
</div> | |
<!-- Notification Toast --> | |
<div id="notification" class="fixed top-5 left-1/2 transform -translate-x-1/2 bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg hidden flex items-center"> | |
<i class="fas fa-check-circle mr-2"></i> | |
<span>Berth allocation updated successfully!</span> | |
</div> | |
<script> | |
// Initialize Three.js scene | |
let scene, camera, renderer, controls; | |
let portObjects = {}; | |
let selectedObject = null; | |
function initThreeJS() { | |
// Create scene | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x1e3c72); | |
scene.fog = new THREE.FogExp2(0x1e3c72, 0.001); | |
// Create camera | |
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
camera.position.set(0, 150, 300); | |
// Create renderer | |
renderer = new THREE.WebGLRenderer({ antialias: true }); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
renderer.shadowMap.enabled = true; | |
document.getElementById('viewport-3d').appendChild(renderer.domElement); | |
// Add controls | |
controls = new THREE.OrbitControls(camera, renderer.domElement); | |
controls.enableDamping = true; | |
controls.dampingFactor = 0.05; | |
controls.minDistance = 50; | |
controls.maxDistance = 500; | |
controls.maxPolarAngle = Math.PI * 0.9; | |
// Add lights | |
const ambientLight = new THREE.AmbientLight(0x404040); | |
scene.add(ambientLight); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
directionalLight.position.set(100, 300, 100); | |
directionalLight.castShadow = true; | |
directionalLight.shadow.mapSize.width = 2048; | |
directionalLight.shadow.mapSize.height = 2048; | |
scene.add(directionalLight); | |
// Add water | |
const waterGeometry = new THREE.PlaneGeometry(1000, 1000); | |
const waterMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x1e88e5, | |
roughness: 0.1, | |
metalness: 0.5 | |
}); | |
const water = new THREE.Mesh(waterGeometry, waterMaterial); | |
water.rotation.x = -Math.PI / 2; | |
water.position.y = -5; | |
scene.add(water); | |
// Add ground | |
const groundGeometry = new THREE.PlaneGeometry(1000, 1000); | |
const groundMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x4b5563, | |
roughness: 0.8 | |
}); | |
const ground = new THREE.Mesh(groundGeometry, groundMaterial); | |
ground.rotation.x = -Math.PI / 2; | |
ground.position.y = -5; | |
ground.receiveShadow = true; | |
scene.add(ground); | |
// Create port infrastructure | |
createPortInfrastructure(); | |
// Handle window resize | |
window.addEventListener('resize', onWindowResize); | |
// Start animation loop | |
animate(); | |
} | |
function createPortInfrastructure() { | |
// Create terminals (Santo Domingo, Don Diego, Sans Souci) | |
createTerminal('Santo Domingo', -200, 0, 0, 300, 200, [ | |
{ id: 1, length: 150, width: 30, occupied: true }, | |
{ id: 2, length: 180, width: 30, occupied: false }, | |
{ id: 3, length: 160, width: 30, occupied: true }, | |
{ id: 4, length: 140, width: 30, occupied: false }, | |
{ id: 5, length: 170, width: 30, occupied: true } | |
]); | |
createTerminal('Don Diego', 200, 0, 0, 250, 150, [ | |
{ id: 6, length: 120, width: 25, occupied: false }, | |
{ id: 7, length: 130, width: 25, occupied: true }, | |
{ id: 8, length: 140, width: 25, occupied: false } | |
]); | |
createTerminal('Sans Souci', 0, 0, -200, 250, 250, [ | |
{ id: 9, length: 250, width: 35, occupied: true } | |
]); | |
// Create storage areas | |
createStorageArea('Vehicle Storage', -100, 0, 150, 200, 150, 7000); | |
createStorageArea('Warehouse A', -300, 0, 50, 100, 80); | |
createStorageArea('Warehouse B', -350, 0, -50, 100, 80); | |
// Create gates | |
createGate('Main Gate', -400, 0, 0); | |
createGate('Truck Gate', -450, 0, 100); | |
createGate('Secondary Gate', -450, 0, -100); | |
} | |
function createTerminal(name, x, y, z, width, depth, berths) { | |
// Create terminal base | |
const terminalGeometry = new THREE.BoxGeometry(width, 5, depth); | |
const terminalMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x6b7280, | |
roughness: 0.7 | |
}); | |
const terminal = new THREE.Mesh(terminalGeometry, terminalMaterial); | |
terminal.position.set(x, y - 2.5, z); | |
terminal.receiveShadow = true; | |
terminal.userData = { type: 'terminal', name: name }; | |
scene.add(terminal); | |
portObjects[`terminal_${name}`] = terminal; | |
// Create berths | |
berths.forEach((berth, index) => { | |
const berthX = x + (index - (berths.length - 1) / 2) * (width / berths.length); | |
const berthZ = z + depth / 2; | |
const berthGeometry = new THREE.BoxGeometry(berth.length, 10, berth.width); | |
const berthMaterial = new THREE.MeshStandardMaterial({ | |
color: berth.occupied ? 0xef4444 : 0x10b981, | |
roughness: 0.6 | |
}); | |
const berthMesh = new THREE.Mesh(berthGeometry, berthMaterial); | |
berthMesh.position.set(berthX, y, berthZ); | |
berthMesh.userData = { | |
type: 'berth', | |
id: berth.id, | |
terminal: name, | |
occupied: berth.occupied, | |
vessel: berth.occupied ? `Vessel ${Math.floor(Math.random() * 1000)}` : null | |
}; | |
scene.add(berthMesh); | |
portObjects[`berth_${name}_${berth.id}`] = berthMesh; | |
// Add vessel if occupied | |
if (berth.occupied) { | |
const vesselGeometry = new THREE.BoxGeometry(berth.length * 0.8, 20, berth.width * 2); | |
const vesselMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x3b82f6, | |
roughness: 0.5 | |
}); | |
const vessel = new THREE.Mesh(vesselGeometry, vesselMaterial); | |
vessel.position.set(berthX, y + 15, berthZ + berth.width); | |
vessel.userData = { type: 'vessel', berth: berth.id }; | |
scene.add(vessel); | |
portObjects[`vessel_${name}_${berth.id}`] = vessel; | |
} | |
}); | |
// Add terminal building | |
const buildingGeometry = new THREE.BoxGeometry(width * 0.6, 30, depth * 0.3); | |
const buildingMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x9ca3af, | |
roughness: 0.8 | |
}); | |
const building = new THREE.Mesh(buildingGeometry, buildingMaterial); | |
building.position.set(x, y + 15, z - depth * 0.35); | |
building.castShadow = true; | |
scene.add(building); | |
} | |
function createStorageArea(name, x, y, z, width, depth, capacity) { | |
const areaGeometry = new THREE.BoxGeometry(width, 5, depth); | |
const areaMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x4b5563, | |
roughness: 0.8 | |
}); | |
const area = new THREE.Mesh(areaGeometry, areaMaterial); | |
area.position.set(x, y - 2.5, z); | |
area.userData = { type: 'storage', name: name, capacity: capacity }; | |
scene.add(area); | |
portObjects[`storage_${name}`] = area; | |
// Add containers if it's a container yard | |
if (name.includes('Vehicle')) { | |
for (let i = 0; i < 20; i++) { | |
const containerX = x + (Math.random() - 0.5) * width * 0.8; | |
const containerZ = z + (Math.random() - 0.5) * depth * 0.8; | |
const containerGeometry = new THREE.BoxGeometry(5, 3, 2); | |
const containerMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x3b82f6, | |
roughness: 0.7 | |
}); | |
const container = new THREE.Mesh(containerGeometry, containerMaterial); | |
container.position.set(containerX, y + 1.5, containerZ); | |
container.rotation.y = Math.random() * Math.PI; | |
scene.add(container); | |
} | |
} | |
} | |
function createGate(name, x, y, z) { | |
const gateGeometry = new THREE.BoxGeometry(10, 20, 5); | |
const gateMaterial = new THREE.MeshStandardMaterial({ | |
color: 0xf59e0b, | |
roughness: 0.6 | |
}); | |
const gate = new THREE.Mesh(gateGeometry, gateMaterial); | |
gate.position.set(x, y + 10, z); | |
gate.userData = { type: 'gate', name: name, status: Math.random() > 0.5 ? 'open' : 'closed' }; | |
scene.add(gate); | |
portObjects[`gate_${name}`] = gate; | |
// Add road | |
const roadGeometry = new THREE.PlaneGeometry(30, 100); | |
const roadMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x6b7280, | |
roughness: 0.9 | |
}); | |
const road = new THREE.Mesh(roadGeometry, roadMaterial); | |
road.rotation.x = -Math.PI / 2; | |
road.position.set(x, y, z); | |
scene.add(road); | |
} | |
function onWindowResize() { | |
camera.aspect = window.innerWidth / window.innerHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
} | |
function animate() { | |
requestAnimationFrame(animate); | |
controls.update(); | |
renderer.render(scene, camera); | |
// Update minimap indicators | |
updateMinimap(); | |
} | |
function updateMinimap() { | |
// Clear existing indicators | |
document.querySelectorAll('.vessel-marker, .minimap-indicator, .camera-indicator, .camera-direction').forEach(el => el.remove()); | |
// Add vessel markers | |
Object.keys(portObjects).forEach(key => { | |
const obj = portObjects[key]; | |
if (obj.userData.type === 'vessel' || obj.userData.type === 'gate') { | |
const position = obj.position.clone().project(camera); | |
// Convert normalized device coordinates to minimap coordinates | |
const x = ((position.x * 0.5 + 0.5) * 220) - 110; | |
const y = ((-position.y * 0.5 + 0.5) * 220) - 110; | |
if (x >= 0 && x <= 220 && y >= 0 && y <= 220) { | |
const marker = document.createElement('div'); | |
marker.className = obj.userData.type === 'vessel' ? 'vessel-marker' : 'minimap-indicator'; | |
marker.style.left = `${x}px`; | |
marker.style.top = `${y}px`; | |
document.getElementById('minimap').appendChild(marker); | |
} | |
} | |
}); | |
// Add camera indicator | |
const cameraPosition = camera.position.clone().project(camera); | |
const camX = ((cameraPosition.x * 0.5 + 0.5) * 220) - 110; | |
const camY = ((-cameraPosition.y * 0.5 + 0.5) * 220) - 110; | |
if (camX >= 0 && camX <= 220 && camY >= 0 && camY <= 220) { | |
const camMarker = document.createElement('div'); | |
camMarker.className = 'camera-indicator'; | |
camMarker.style.left = `${camX}px`; | |
camMarker.style.top = `${camY}px`; | |
document.getElementById('minimap').appendChild(camMarker); | |
// Add camera direction indicator | |
const direction = document.createElement('div'); | |
direction.className = 'camera-direction'; | |
direction.style.left = `${camX}px`; | |
direction.style.top = `${camY}px`; | |
// Calculate rotation based on camera direction | |
const target = new THREE.Vector3(0, 0, -1); | |
target.applyQuaternion(camera.quaternion); | |
const angle = Math.atan2(target.x, target.z); | |
direction.style.setProperty('--rotation', `${angle}rad`); | |
document.getElementById('minimap').appendChild(direction); | |
} | |
} | |
// Initialize Three.js when DOM is loaded | |
document.addEventListener('DOMContentLoaded', () => { | |
initThreeJS(); | |
// Set up raycasting for object selection | |
const raycaster = new THREE.Raycaster(); | |
const mouse = new THREE.Vector2(); | |
function onMouseClick(event) { | |
// Calculate mouse position in normalized device coordinates | |
mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
// Update the raycaster | |
raycaster.setFromCamera(mouse, camera); | |
// Calculate objects intersecting the ray | |
const intersects = raycaster.intersectObjects(scene.children); | |
if (intersects.length > 0) { | |
const object = intersects[0].object; | |
// Check if it's a port object we care about | |
if (object.userData && (object.userData.type === 'berth' || object.userData.type === 'gate')) { | |
selectedObject = object; | |
showContextMenu(object, event.clientX, event.clientY); | |
// Move camera to focus on the object | |
moveCameraToObject(object); | |
} | |
} else { | |
// Clicked on empty space, hide context menu | |
document.getElementById('context-menu').style.display = 'none'; | |
} | |
} | |
function moveCameraToObject(object) { | |
// Calculate target position for camera | |
const targetPosition = object.position.clone(); | |
targetPosition.y += 50; // Elevate camera | |
targetPosition.z -= 100; // Move camera back | |
// Animate camera movement | |
const duration = 1000; // ms | |
const startPosition = camera.position.clone(); | |
const startTime = Date.now(); | |
function animateCamera() { | |
const elapsed = Date.now() - startTime; | |
const progress = Math.min(elapsed / duration, 1); | |
camera.position.lerpVectors(startPosition, targetPosition, progress); | |
if (progress < 1) { | |
requestAnimationFrame(animateCamera); | |
} else { | |
// Camera arrived, update controls target | |
controls.target.copy(object.position); | |
} | |
} | |
animateCamera(); | |
} | |
function showContextMenu(object, x, y) { | |
const contextMenu = document.getElementById('context-menu'); | |
const title = document.getElementById('context-title'); | |
if (object.userData.type === 'berth') { | |
title.innerHTML = `<i class="fas fa-anchor mr-2"></i>Quai ${object.userData.id} - Terminal ${object.userData.terminal}`; | |
// Update status indicator | |
const statusIndicator = contextMenu.querySelector('.status-indicator'); | |
statusIndicator.className = 'status-indicator ' + | |
(object.userData.occupied ? 'status-open' : 'status-closed'); | |
// Update status text | |
contextMenu.querySelector('.status-indicator').nextElementSibling.innerHTML = | |
`Statut: <strong>${object.userData.occupied ? 'Occupé' : 'Libre'}</strong>`; | |
// Update vessel info if occupied | |
if (object.userData.occupied) { | |
contextMenu.querySelector('.mt-1 > span').innerHTML = | |
`Navire: <strong>${object.userData.vessel}</strong> (Porte-conteneurs)`; | |
} else { | |
contextMenu.querySelector('.mt-1 > span').innerHTML = | |
`Navire: <strong>Aucun</strong>`; | |
} | |
// Random ETA for demo | |
const hours = Math.floor(Math.random() * 24); | |
const minutes = Math.floor(Math.random() * 60); | |
const etaTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; | |
contextMenu.querySelectorAll('.mt-1')[1].innerHTML = | |
`ETA: <strong>${etaTime}</strong> (dans ${Math.floor(Math.random() * 6) + 1}h${Math.floor(Math.random() * 60)})`; | |
// Random progress for demo | |
const progress = object.userData.occupied ? Math.floor(Math.random() * 100) : 0; | |
contextMenu.querySelector('.progress-fill').style.width = `${progress}%`; | |
contextMenu.querySelectorAll('.text-sm span')[1].textContent = `${progress}%`; | |
} else if (object.userData.type === 'gate') { | |
title.innerHTML = `<i class="fas fa-truck-moving mr-2"></i>${object.userData.name}`; | |
// Update status indicator | |
const statusIndicator = contextMenu.querySelector('.status-indicator'); | |
statusIndicator.className = 'status-indicator ' + | |
(object.userData.status === 'open' ? 'status-open' : 'status-closed'); | |
// Update status text | |
contextMenu.querySelector('.status-indicator').nextElementSibling.innerHTML = | |
`Statut: <strong>${object.userData.status === 'open' ? 'Ouvert' : 'Fermé'}</strong>`; | |
// Update other info | |
contextMenu.querySelector('.mt-1 > span').innerHTML = | |
`Trafic: <strong>${Math.floor(Math.random() * 100)} camions/h</strong>`; | |
contextMenu.querySelectorAll('.mt-1')[1].innerHTML = | |
`Prochain: <strong>Camion ${Math.floor(Math.random() * 1000)}</strong>`; | |
// For gates, we'll show queue progress instead of loading | |
const queue = Math.floor(Math.random() * 100); | |
contextMenu.querySelector('.progress-fill').style.width = `${queue}%`; | |
contextMenu.querySelectorAll('.text-sm span')[1].textContent = `${queue}%`; | |
contextMenu.querySelectorAll('.text-sm span')[0].textContent = 'File d\'attente:'; | |
} | |
// Position and show the context menu | |
contextMenu.style.left = `${x}px`; | |
contextMenu.style.top = `${y}px`; | |
contextMenu.style.display = 'block'; | |
} | |
// Add click event listener | |
window.addEventListener('click', onMouseClick); | |
// Menu button interactions | |
const menuButtons = document.querySelectorAll('#main-menu .menu-btn'); | |
menuButtons.forEach(btn => { | |
btn.addEventListener('click', function() { | |
menuButtons.forEach(b => b.classList.remove('active')); | |
this.classList.add('active'); | |
// Show notification | |
const notification = document.getElementById('notification'); | |
notification.textContent = `${this.getAttribute('title')} panel opened`; | |
notification.classList.remove('hidden'); | |
notification.classList.remove('bg-green-600', 'bg-red-600', 'bg-blue-600'); | |
// Change color based on which button was clicked | |
if (this.id === 'btn-billing' || this.id === 'btn-schedule') { | |
notification.classList.add('bg-blue-600'); | |
} else if (this.id === 'btn-gate') { | |
notification.classList.add('bg-yellow-600'); | |
} else { | |
notification.classList.add('bg-green-600'); | |
} | |
setTimeout(() => { | |
notification.classList.add('hidden'); | |
}, 3000); | |
}); | |
}); | |
// Simulate chat interaction | |
document.getElementById('send-btn').addEventListener('click', sendMessage); | |
document.querySelector('#chat-input input').addEventListener('keypress', function(e) { | |
if (e.key === 'Enter') sendMessage(); | |
}); | |
function sendMessage() { | |
const input = document.querySelector('#chat-input input'); | |
const message = input.value.trim(); | |
if (message) { | |
// Add user message | |
const chatContainer = document.getElementById('chat-messages'); | |
const userMsg = document.createElement('div'); | |
userMsg.className = 'chat-message user-message'; | |
userMsg.innerHTML = ` | |
<p>${message}</p> | |
<p class="text-xs text-slate-400 mt-1">Maintenant</p> | |
`; | |
chatContainer.appendChild(userMsg); | |
// Clear input | |
input.value = ''; | |
// Scroll to bottom | |
chatContainer.scrollTop = chatContainer.scrollHeight; | |
// Simulate AI response after a delay | |
setTimeout(() => { | |
const responses = [ | |
"J'ai vérifié le système. Il y a 3 postes à quai disponibles qui peuvent accueillir ce navire.", | |
"Le yard a actuellement une capacité restante de 45% dans la Zone C.", | |
"La porte 2 connaît des retards en raison d'un trafic accru. Envisagez de rediriger vers la porte 3.", | |
"J'ai programmé 2 grues pour le navire MSC Oscar, comme demandé.", | |
"Le rapport de facturation pour le Q3 a été généré et envoyé à votre email." | |
]; | |
const aiMsg = document.createElement('div'); | |
aiMsg.className = 'chat-message ai-message'; | |
aiMsg.innerHTML = ` | |
<p>${responses[Math.floor(Math.random() * responses.length)]}</p> | |
<p class="text-xs text-slate-400 mt-1">Maintenant</p> | |
`; | |
chatContainer.appendChild(aiMsg); | |
chatContainer.scrollTop = chatContainer.scrollHeight; | |
}, 1000 + Math.random() * 2000); | |
} | |
} | |
// Minimize chat | |
document.getElementById('minimize-chat').addEventListener('click', function() { | |
const chatPanel = document.getElementById('chat-panel'); | |
const messages = document.getElementById('chat-messages'); | |
const input = document.getElementById('chat-input'); | |
if (messages.style.display !== 'none') { | |
messages.style.display = 'none'; | |
input.style.display = 'none'; | |
this.innerHTML = '<i class="fas fa-plus"></i>'; | |
chatPanel.style.transform = 'translateX(-50%) translateY(calc(100% - 40px))'; | |
} else { | |
messages.style.display = 'block'; | |
input.style.display = 'flex'; | |
this.innerHTML = '<i class="fas fa-minus"></i>'; | |
chatPanel.style.transform = 'translateX(-50%)'; | |
} | |
}); | |
// Simulate initial notification | |
setTimeout(() => { | |
const notification = document.getElementById('notification'); | |
notification.textContent = 'Navire MSC Lucy ETA mis à jour à 14:30'; | |
notification.classList.remove('hidden'); | |
setTimeout(() => { | |
notification.classList.add('hidden'); | |
}, 3000); | |
}, 1500); | |
}); | |
</script> | |
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=arkleinberg/tos" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
</html> |