feat: interactive portal previews on hover (#136)
Some checks failed
CI / validate (pull_request) Has been cancelled
Some checks failed
CI / validate (pull_request) Has been cancelled
- Load portals from portals.json and render as glowing 3D rings in scene - Raycast on mousemove to detect portal hover - Show tooltip card with portal name, description, destination URL, and screenshot - Screenshot falls back to a colored radial-gradient placeholder if image missing - Portals animate with slow Z-spin and opacity pulse each frame Fixes #136
This commit is contained in:
144
app.js
144
app.js
@@ -12,6 +12,61 @@ const NEXUS = {
|
||||
}
|
||||
};
|
||||
|
||||
// === PORTAL PREVIEW ===
|
||||
const portalPreview = document.getElementById('portal-preview');
|
||||
const portalPreviewImg = document.getElementById('portal-preview-img');
|
||||
const portalPreviewPlaceholder = document.getElementById('portal-preview-placeholder');
|
||||
const portalPreviewName = document.getElementById('portal-preview-name');
|
||||
const portalPreviewDesc = document.getElementById('portal-preview-desc');
|
||||
const portalPreviewUrl = document.getElementById('portal-preview-url');
|
||||
|
||||
let previewMouseX = 0;
|
||||
let previewMouseY = 0;
|
||||
|
||||
function showPortalPreview(portal, mx, my) {
|
||||
portalPreviewName.textContent = portal.name;
|
||||
portalPreviewDesc.textContent = portal.description;
|
||||
portalPreviewUrl.textContent = portal.destination.url.replace(/^https?:\/\//, '');
|
||||
|
||||
// Try screenshot image; fall back to colored placeholder
|
||||
const imgSrc = `screenshots/${portal.id}.png`;
|
||||
portalPreviewImg.onload = () => {
|
||||
portalPreviewImg.style.display = 'block';
|
||||
portalPreviewPlaceholder.style.display = 'none';
|
||||
};
|
||||
portalPreviewImg.onerror = () => {
|
||||
portalPreviewImg.style.display = 'none';
|
||||
portalPreviewPlaceholder.style.display = 'flex';
|
||||
portalPreviewPlaceholder.style.background =
|
||||
`radial-gradient(ellipse at center, ${portal.color}44 0%, ${portal.color}11 70%, transparent 100%)`;
|
||||
portalPreviewPlaceholder.style.borderColor = portal.color;
|
||||
portalPreviewPlaceholder.textContent = portal.name;
|
||||
portalPreviewPlaceholder.style.color = portal.color;
|
||||
};
|
||||
portalPreviewImg.src = imgSrc;
|
||||
|
||||
portalPreview.style.borderColor = portal.color;
|
||||
portalPreview.style.boxShadow = `0 0 20px ${portal.color}55`;
|
||||
portalPreview.classList.remove('hidden');
|
||||
positionPreview(mx, my);
|
||||
}
|
||||
|
||||
function hidePortalPreview() {
|
||||
portalPreview.classList.add('hidden');
|
||||
}
|
||||
|
||||
function positionPreview(mx, my) {
|
||||
const pad = 16;
|
||||
const w = portalPreview.offsetWidth || 220;
|
||||
const h = portalPreview.offsetHeight || 180;
|
||||
let left = mx + pad;
|
||||
let top = my + pad;
|
||||
if (left + w > window.innerWidth) left = mx - w - pad;
|
||||
if (top + h > window.innerHeight) top = my - h - pad;
|
||||
portalPreview.style.left = `${left}px`;
|
||||
portalPreview.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
// === SCENE SETUP ===
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(NEXUS.colors.bg);
|
||||
@@ -108,6 +163,58 @@ function buildConstellationLines() {
|
||||
const constellationLines = buildConstellationLines();
|
||||
scene.add(constellationLines);
|
||||
|
||||
// === PORTAL SYSTEM ===
|
||||
const portalObjects = []; // { group, ring, inner, portal }
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouseNDC = new THREE.Vector2();
|
||||
let activePortal = null;
|
||||
|
||||
async function initPortals() {
|
||||
let portals;
|
||||
try {
|
||||
const res = await fetch('./portals.json');
|
||||
portals = await res.json();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const portal of portals) {
|
||||
const color = new THREE.Color(portal.color);
|
||||
|
||||
// Outer glowing ring
|
||||
const ringGeo = new THREE.RingGeometry(1.2, 1.6, 48);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
|
||||
// Semi-transparent inner fill for hover hit area
|
||||
const innerGeo = new THREE.CircleGeometry(1.2, 48);
|
||||
const innerMat = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.12,
|
||||
});
|
||||
const inner = new THREE.Mesh(innerGeo, innerMat);
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.add(ring);
|
||||
group.add(inner);
|
||||
group.position.set(portal.position.x, portal.position.y, portal.position.z);
|
||||
if (portal.rotation?.y) group.rotation.y = portal.rotation.y;
|
||||
group.userData = { portal };
|
||||
|
||||
scene.add(group);
|
||||
portalObjects.push({ group, ring, inner, portal });
|
||||
}
|
||||
}
|
||||
|
||||
initPortals();
|
||||
|
||||
// === MOUSE-DRIVEN ROTATION ===
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
@@ -117,6 +224,35 @@ let targetRotY = 0;
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||
previewMouseX = e.clientX;
|
||||
previewMouseY = e.clientY;
|
||||
|
||||
// Raycast against portal meshes
|
||||
mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
raycaster.setFromCamera(mouseNDC, camera);
|
||||
|
||||
const hitMeshes = portalObjects.flatMap(p => [p.ring, p.inner]);
|
||||
const hits = raycaster.intersectObjects(hitMeshes);
|
||||
|
||||
if (hits.length > 0) {
|
||||
// Find which portal was hit
|
||||
const hitMesh = hits[0].object;
|
||||
const found = portalObjects.find(p => p.ring === hitMesh || p.inner === hitMesh);
|
||||
if (found) {
|
||||
if (activePortal !== found.portal) {
|
||||
activePortal = found.portal;
|
||||
showPortalPreview(found.portal, e.clientX, e.clientY);
|
||||
} else {
|
||||
positionPreview(e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (activePortal !== null) {
|
||||
activePortal = null;
|
||||
hidePortalPreview();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// === RESIZE HANDLER ===
|
||||
@@ -146,6 +282,14 @@ function animate() {
|
||||
// Subtle pulse on constellation opacity
|
||||
constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06;
|
||||
|
||||
// Animate portals — slow spin + opacity pulse
|
||||
for (const { group, ring, inner, portal } of portalObjects) {
|
||||
group.rotation.z = elapsed * 0.3 + (portal.rotation?.y ?? 0);
|
||||
const pulse = 0.7 + Math.sin(elapsed * 1.2 + group.position.x) * 0.15;
|
||||
ring.material.opacity = pulse;
|
||||
inner.material.opacity = 0.08 + Math.sin(elapsed * 0.8) * 0.05;
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
|
||||
13
index.html
13
index.html
@@ -36,6 +36,19 @@
|
||||
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
|
||||
</div>
|
||||
|
||||
<!-- Portal Preview Tooltip -->
|
||||
<div id="portal-preview" class="portal-preview hidden" aria-live="polite" aria-atomic="true">
|
||||
<div class="portal-preview-screenshot">
|
||||
<img id="portal-preview-img" src="" alt="Portal destination preview" style="display:none">
|
||||
<div id="portal-preview-placeholder" class="portal-preview-placeholder"></div>
|
||||
</div>
|
||||
<div class="portal-preview-info">
|
||||
<div id="portal-preview-name" class="portal-preview-name"></div>
|
||||
<div id="portal-preview-desc" class="portal-preview-desc"></div>
|
||||
<div id="portal-preview-url" class="portal-preview-url"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
70
style.css
70
style.css
@@ -56,6 +56,76 @@ canvas {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* === PORTAL PREVIEW === */
|
||||
.portal-preview {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 20, 0.93);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
width: 224px;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease;
|
||||
box-shadow: 0 0 20px rgba(68, 136, 255, 0.3);
|
||||
}
|
||||
|
||||
.portal-preview.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.portal-preview-screenshot {
|
||||
width: 204px;
|
||||
height: 120px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
.portal-preview-screenshot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.portal-preview-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.portal-preview-name {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.portal-preview-desc {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.portal-preview-url {
|
||||
font-size: 10px;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* === DEBUG MODE === */
|
||||
#debug-toggle {
|
||||
margin-left: 8px;
|
||||
|
||||
Reference in New Issue
Block a user