feat: interactive portal previews on hover (#136)
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:
Alexander Whitestone
2026-03-24 00:05:25 -04:00
parent 7eca0fba5d
commit b28490e8fe
3 changed files with 227 additions and 0 deletions

144
app.js
View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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;