/* ring.jsx — cinematic 3D project carousel + grid view + filter overlay Redesigned: section header sits ABOVE the ring (no text over cards); a live caption bar tracks the front-most card; category quick-filters replace the scattered labels; arrows + drag + hover + keyboard all drive it. */ const { useState: uS, useEffect: uE, useRef: uR, useMemo: uM, useCallback: uC } = React; const CAT_ICON = { villa: "building", hospital: "hospital", commercial: "layers", infrastructure: "ruler", interior: "sofa", farmhouse: "trees", landscape: "leaf", residential: "building" }; function statusTone(s) { return s === "delivered" ? "success" : s === "ongoing" ? "warning" : "gold"; } /* ============================ the 3D carousel =========================== */ /* Robust index-based 3D coverflow — deterministic front card, momentum drag, snap, keyboard, trackpad, subtle mouse-parallax tilt, gentle auto-advance. */ function Ring({ items, lang, activeId, setActive, onFront, onCardOpen }) { const stageRef = uR(null), innerRef = uR(null), cardRefs = uR([]); const S = uR({ pos: 0, target: 0, dragging: false, hover: false, lastX: 0, moved: 0, tpx: 0, tpy: 0, px: 0, py: 0, front: -1, reduce: false, lastStep: 0 }); const onFrontRef = uR(onFront); onFrontRef.current = onFront; const openRef = uR(onCardOpen); openRef.current = onCardOpen; const [dims, setDims] = uS(null); cardRefs.current = []; const N = items.length; const nearestDelta = (i) => { let d = ((i - Math.round(S.current.pos)) % N + N) % N; if (d > N / 2) d -= N; return d; }; const goTo = (i) => { S.current.target = Math.round(S.current.pos) + nearestDelta(i); }; const step = (dir) => { S.current.target = Math.round(S.current.pos) + dir; S.current.lastStep = performance.now(); }; /* dimensions */ uE(() => { S.current.reduce = window.matchMedia("(prefers-reduced-motion:reduce)").matches; const stage = stageRef.current; if (!stage) return; const measure = () => { const w = stage.clientWidth; const cardW = Math.max(300, Math.min(w * 0.38, 520)); setDims({ w, cardW, cardH: cardW * 0.66, spacing: cardW * (w < 720 ? 0.46 : 0.52), depth: Math.min(w * 0.24, 280), angle: w < 720 ? 40 : 46, persp: 1700 }); }; measure(); const ro = new ResizeObserver(measure); ro.observe(stage); return () => ro.disconnect(); }, []); /* external activeId (audience picks) -> center that card */ uE(() => { if (activeId == null) return; const idx = items.findIndex((p) => p.id === activeId); if (idx >= 0) goTo(idx); }, [activeId, items]); /* animation loop */ uE(() => { if (!dims || !N) return; let raf; const s = S.current; const frame = (t) => { if (!s.dragging) { s.pos += (s.target - s.pos) * 0.14; if (Math.abs(s.target - s.pos) < 0.0008) s.pos = s.target; } s.px += (s.tpx - s.px) * 0.05; s.py += (s.tpy - s.py) * 0.05; const inner = innerRef.current; if (inner) inner.style.transform = `rotateX(${(-s.py * 5).toFixed(2)}deg) rotateY(${(s.px * 6).toFixed(2)}deg)`; for (let i = 0; i < N; i++) { const el = cardRefs.current[i]; if (!el) continue; let off = ((i - s.pos) % N + N) % N; if (off > N / 2) off -= N; const a = Math.abs(off); const x = off * dims.spacing; const z = -a * dims.depth; const ry = Math.max(-60, Math.min(60, -off * dims.angle)); const scale = Math.max(0.6, 1 - a * 0.14); const op = a > 3.1 ? 0 : Math.max(0, 1 - a * 0.30); const blur = Math.min(5, a * 1.5); el.style.transform = `translate(-50%,-50%) translate3d(${x.toFixed(1)}px,0,${z.toFixed(1)}px) rotateY(${ry.toFixed(1)}deg) scale(${scale.toFixed(3)})`; el.style.opacity = op.toFixed(3); el.style.filter = blur > 0.2 ? `blur(${blur.toFixed(1)}px)` : "none"; el.style.zIndex = String(1000 - Math.round(a * 100)); el.style.pointerEvents = op > 0.35 ? "auto" : "none"; } const front = ((Math.round(s.pos) % N) + N) % N; if (front !== s.front) { const p = cardRefs.current[s.front]; if (p) p.classList.remove("front"); const c = cardRefs.current[front]; if (c) c.classList.add("front"); s.front = front; if (items[front] && onFrontRef.current) onFrontRef.current(items[front].id); } const idle = s.pos === s.target && !s.hover && !s.dragging; if (!s.reduce && idle && t - s.lastStep > 4600) { s.target += 1; s.lastStep = t; } else if (!idle) { s.lastStep = t; } raf = requestAnimationFrame(frame); }; raf = requestAnimationFrame(frame); return () => cancelAnimationFrame(raf); }, [dims, items]); /* drag (pointer events, direction-locked) + trackpad + mouse-parallax */ uE(() => { if (!dims) return; const stage = stageRef.current, s = S.current; let startX = 0, startY = 0, decided = null, pid = null; const down = (e) => { if (e.pointerType === "mouse" && e.button !== 0) return; pid = e.pointerId; startX = e.clientX; startY = e.clientY; decided = null; s.lastX = e.clientX; s.moved = 0; s.dragging = false; s.target = s.pos; s.lastStep = performance.now(); // freeze auto-advance/settle at current spot }; const move = (e) => { if (pid !== null && e.pointerId !== pid) return; if (decided === null) { const adx = Math.abs(e.clientX - startX), ady = Math.abs(e.clientY - startY); if (adx < 6 && ady < 6) return; decided = adx > ady ? "h" : "v"; if (decided === "h") { s.dragging = true; stage.classList.add("grabbing"); try { stage.setPointerCapture(pid); } catch (_) {} } } if (decided !== "h") return; // vertical gesture -> let the page scroll const dx = e.clientX - s.lastX; s.lastX = e.clientX; s.moved += Math.abs(dx); s.pos -= dx / dims.spacing; if (e.cancelable) e.preventDefault(); }; const up = (e) => { if (pid !== null && e.pointerId !== pid) return; pid = null; if (s.dragging) { s.dragging = false; stage.classList.remove("grabbing"); s.target = Math.round(s.pos); } decided = null; }; const enter = () => { s.hover = true; }; const leave = () => { s.hover = false; s.tpx = 0; s.tpy = 0; }; const par = (e) => { if (e.pointerType && e.pointerType !== "mouse") return; const r = stage.getBoundingClientRect(); s.tpx = ((e.clientX - r.left) / r.width - 0.5) * 2; s.tpy = ((e.clientY - r.top) / r.height - 0.5) * 2; }; let wt = 0; const wheel = (e) => { if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; e.preventDefault(); const now = Date.now(); if (now - wt < 240) return; wt = now; step(e.deltaX > 0 ? 1 : -1); }; stage.addEventListener("pointerdown", down); stage.addEventListener("pointermove", move); stage.addEventListener("pointermove", par); stage.addEventListener("pointerup", up); stage.addEventListener("pointercancel", up); stage.addEventListener("pointerenter", enter); stage.addEventListener("pointerleave", leave); stage.addEventListener("wheel", wheel, { passive: false }); return () => { stage.removeEventListener("pointerdown", down); stage.removeEventListener("pointermove", move); stage.removeEventListener("pointermove", par); stage.removeEventListener("pointerup", up); stage.removeEventListener("pointercancel", up); stage.removeEventListener("pointerenter", enter); stage.removeEventListener("pointerleave", leave); stage.removeEventListener("wheel", wheel); }; }, [dims]); const onKey = (e) => { if (e.key === "ArrowRight") { e.preventDefault(); step(1); } else if (e.key === "ArrowLeft") { e.preventDefault(); step(-1); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); const f = items[S.current.front]; if (f) openRef.current(f.id); } }; return (
{T(project.summary)}
{project.gallery && project.gallery.length > 0 && ( )}{T(project.desc || project.summary)}
{T(P.sub)}