Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> | |
| <title>pplx-embed web</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| overflow: hidden; | |
| background-color: #121212; | |
| font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| color: #ffffff; | |
| } | |
| #canvas-container { | |
| width: 100vw; | |
| height: 100vh; | |
| touch-action: none; | |
| cursor: grab; | |
| } | |
| #canvas-container:active { | |
| cursor: grabbing; | |
| } | |
| #ui-layer { | |
| position: absolute; | |
| bottom: 40px; | |
| left: 0; | |
| width: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 15px; | |
| pointer-events: none; | |
| } | |
| .header { | |
| text-align: center; | |
| pointer-events: auto; | |
| } | |
| .title { | |
| font-size: 1.2rem; | |
| font-weight: 600; | |
| letter-spacing: 1px; | |
| opacity: 0.9; | |
| } | |
| .subtitle { | |
| font-size: 0.85rem; | |
| opacity: 0.5; | |
| margin-top: 4px; | |
| } | |
| .search-container { | |
| pointer-events: auto; | |
| background: rgba(40, 42, 44, 0.8); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| padding: 8px 16px; | |
| border-radius: 30px; | |
| display: flex; | |
| align-items: center; | |
| backdrop-filter: blur(10px); | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); | |
| transition: all 0.3s ease; | |
| } | |
| .search-container:focus-within { | |
| border-color: rgba(255, 255, 255, 0.4); | |
| box-shadow: 0 10px 40px rgba(255, 255, 255, 0.05); | |
| } | |
| .search-container svg { | |
| width: 18px; | |
| height: 18px; | |
| fill: #888; | |
| margin-right: 10px; | |
| } | |
| input[type="text"] { | |
| background: transparent; | |
| border: none; | |
| color: white; | |
| font-size: 1.1rem; | |
| font-family: inherit; | |
| outline: none; | |
| width: 320px; | |
| } | |
| input[type="text"]::placeholder { | |
| color: #888; | |
| } | |
| input[type="text"]:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| } | |
| /* Loading overlay */ | |
| #loading-overlay { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 9999; | |
| background: #121212; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 24px; | |
| transition: opacity 0.6s ease; | |
| } | |
| #loading-overlay.fade-out { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| #loading-overlay .spinner { | |
| width: 48px; | |
| height: 48px; | |
| border: 4px solid rgba(255, 255, 255, 0.15); | |
| border-top-color: #ffffff; | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| #loading-overlay .loading-title { | |
| font-size: 3rem; | |
| font-weight: 700; | |
| letter-spacing: 1.5px; | |
| opacity: 0.95; | |
| } | |
| #loading-overlay .loading-status { | |
| font-size: 0.95rem; | |
| opacity: 0.5; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Fullscreen loading overlay --> | |
| <div id="loading-overlay"> | |
| <div class="loading-title">pplx-embed web</div> | |
| <div class="spinner"></div> | |
| <div class="loading-status" id="loadingStatus">Loading model...</div> | |
| </div> | |
| <div id="canvas-container"></div> | |
| <div id="ui-layer"> | |
| <div class="search-container"> | |
| <svg viewBox="0 0 24 24"> | |
| <path | |
| d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" | |
| /> | |
| </svg> | |
| <input type="text" id="searchInput" placeholder="Loading semantic search model..." disabled /> | |
| </div> | |
| <div class="header"> | |
| <div class="title">pplx-embed web</div> | |
| <div class="subtitle">Drag to spin, semantic search is initializing...</div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.module.js"; | |
| import { pipeline } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0-next.4"; | |
| const DEFAULT_SENTENCES = [ | |
| // Weather | |
| "The sun peeked through the clouds after a drizzly morning.", | |
| "A gentle breeze rustled the leaves as we walked along the shoreline.", | |
| "Heavy rains caused flooding in several low-lying neighborhoods.", | |
| "It was so hot that even the birds sought shade under the palm trees.", | |
| "By midnight, the temperature had dropped below freezing.", | |
| "Thunderstorms lit up the sky with flashes of lightning.", | |
| "A thick fog settled over the city streets at dawn.", | |
| "The air smelled of ozone after the sudden hailstorm.", | |
| "I watched the snowflakes drift silently onto the ground.", | |
| "A double rainbow appeared after the rain shower.", | |
| "The humidity soared to uncomfortable levels by midday.", | |
| "Dust devils formed in the dry desert plains.", | |
| "The barometer readings indicated an approaching front.", | |
| "A sudden gust of wind knocked over the garden chairs.", | |
| "Light drizzle turned into a torrential downpour within minutes.", | |
| // Technology | |
| "The new smartphone features a foldable display and 5G connectivity.", | |
| "In the world of AI, transformers have revolutionized natural language processing.", | |
| "Quantum computing promises to solve problems beyond classical computers' reach.", | |
| "Blockchain technology is being explored for secure voting systems.", | |
| "Virtual reality headsets are becoming more affordable and accessible.", | |
| "The rise of electric vehicles is reshaping the automotive industry.", | |
| "Cloud computing allows businesses to scale resources dynamically.", | |
| "Machine learning algorithms can now predict stock market trends with surprising accuracy.", | |
| "Augmented reality applications are transforming retail experiences.", | |
| "The Internet of Things connects everyday devices to the web for smarter living.", | |
| "Cybersecurity threats are evolving, requiring constant vigilance.", | |
| "3D printing is enabling rapid prototyping and custom manufacturing.", | |
| "Edge computing reduces latency by processing data closer to the source.", | |
| "Biometric authentication methods are enhancing security in devices.", | |
| "Wearable technology is tracking health metrics in real-time.", | |
| "Artificial intelligence is being used to create realistic deepfakes.", | |
| // Cooking | |
| "Preheat the oven to 375°F before you start mixing the batter.", | |
| "She finely chopped the garlic and sautéed it in two tablespoons of olive oil.", | |
| "A pinch of saffron adds a beautiful color and aroma to traditional paella.", | |
| "If the soup is too salty, add a peeled potato to absorb excess sodium.", | |
| "Let the bread dough rise for at least an hour in a warm, draft-free spot.", | |
| "Marinate the chicken overnight in a blend of citrus and spices.", | |
| "Use a cast-iron skillet to sear the steak on high heat.", | |
| "Whisk the egg whites until they form stiff peaks.", | |
| "Fold in the chocolate chips gently to keep the batter airy.", | |
| "Brush the pastry with an egg wash for a golden finish.", | |
| "Slow-roast the pork shoulder until it falls off the bone.", | |
| "Garnish the salad with toasted nuts and fresh herbs.", | |
| "Deglaze the pan with white wine for a rich sauce.", | |
| "Simmer the curry paste until the aroma intensifies.", | |
| "Let the risotto rest before serving to thicken slightly.", | |
| // Sports | |
| "He dribbled past two defenders and sank a three-pointer at the buzzer.", | |
| "The marathon runner kept a steady pace despite the sweltering heat.", | |
| "Their home team clinched the championship with a last-minute goal.", | |
| "NASCAR fans cheered as the cars roared around the oval track.", | |
| "She landed a perfect triple axel at the figure skating championship.", | |
| "The cyclist pedaled up the steep hill in record time.", | |
| "He pitched a no-hitter during the high school baseball game.", | |
| "The quarterback threw a touchdown pass under heavy pressure.", | |
| "They scored a hat-trick in the hockey final.", | |
| "The boxer delivered a swift uppercut in the final round.", | |
| "Surfers caught massive waves at dawn on the Pacific coast.", | |
| "Fans erupted when the underdog scored the winning goal.", | |
| "The swimmer broke the national record in the 200m freestyle.", | |
| "The gymnast executed a flawless routine on the balance beam.", | |
| "The rugby team celebrated their victory with a traditional haka.", | |
| // Finance | |
| "The stock market rallied after positive earnings reports.", | |
| "Investors are closely watching interest rate changes by the Federal Reserve.", | |
| "Cryptocurrency prices have been extremely volatile this year.", | |
| "Diversification is key to managing investment risk effectively.", | |
| "Inflation rates have reached a 40-year high, impacting consumer spending.", | |
| "Many companies are adopting ESG criteria to attract socially conscious investors.", | |
| "The bond market is reacting to geopolitical tensions and supply chain disruptions.", | |
| "Venture capital funding for startups has surged in the tech sector.", | |
| "Exchange-traded funds (ETFs) offer a way to invest in diversified portfolios.", | |
| "The global economy is recovering from the pandemic, but challenges remain.", | |
| "Central banks are exploring digital currencies to modernize payment systems.", | |
| "Retail investors are increasingly participating in the stock market through apps.", | |
| "Hedge funds are using complex algorithms to gain an edge in trading.", | |
| "Real estate prices have skyrocketed in urban areas due to low inventory.", | |
| "The startup raised $10 million in its Series A funding round.", | |
| // Music | |
| "The symphony orchestra played a hauntingly beautiful melody.", | |
| "She strummed her guitar softly, filling the room with a warm sound.", | |
| "The DJ mixed tracks seamlessly, keeping the crowd dancing all night.", | |
| "His voice soared during the high notes of the ballad.", | |
| "The band played an acoustic set in the intimate coffee shop.", | |
| "Jazz musicians often improvise solos based on the chord changes.", | |
| "The opera singer hit the high C with perfect pitch.", | |
| "The choir harmonized beautifully, filling the church with sound.", | |
| "He composed a symphony that was performed at the concert hall.", | |
| "The singer-songwriter wrote heartfelt lyrics about love and loss.", | |
| "The rock band headlined the festival, drawing a massive crowd.", | |
| "Hip-hop artists use rhythm and rhyme to tell powerful stories.", | |
| "The violinist played a virtuosic solo that left the audience in awe.", | |
| "Folk music often reflects the culture and traditions of a community.", | |
| "The gospel choir lifted spirits with their uplifting performance.", | |
| // History | |
| "The fall of the Berlin Wall in 1989 marked the end of the Cold War.", | |
| "Ancient Egypt's pyramids are a testament to their architectural prowess.", | |
| "Europe's Renaissance period sparked a revival in art and science.", | |
| "The signing of the Declaration of Independence in 1776 established the United States.", | |
| "The Industrial Revolution transformed economies and societies worldwide.", | |
| "Rome was the center of a vast empire that influenced law and governance.", | |
| "The discovery of the New World by Christopher Columbus in 1492 changed global trade.", | |
| "The French Revolution in 1789 led to significant political and social change.", | |
| "World War II was a global conflict that reshaped international relations.", | |
| "The fall of the Roman Empire in 476 AD marked the beginning of the Middle Ages.", | |
| "The invention of the printing press revolutionized the spread of knowledge.", | |
| "The Cold War was characterized by political tension between the U.S. and the Soviet Union.", | |
| "The ancient Silk Road connected East and West through trade routes.", | |
| "The signing of the Magna Carta in 1215 established principles of due process.", | |
| "Exploration during the Age of Discovery expanded European empires across the globe.", | |
| ]; | |
| const TOTAL_DOCS = DEFAULT_SENTENCES.length; | |
| const TOTAL_SPREADS = Math.ceil(TOTAL_DOCS / 2); | |
| const MODEL_ID = "perplexity-ai/pplx-embed-v1-0.6b"; | |
| const similarityScores = Array(TOTAL_DOCS + 1).fill(null); | |
| // slotToDoc[slot] gives the 1-indexed doc ID to display at carousel slot (0-indexed). | |
| // Default is identity: slot i shows doc i+1. | |
| const slotToDoc = Array.from({ length: TOTAL_DOCS }, (_, i) => i + 1); | |
| let extractor = null; | |
| let docEmbeddings = null; | |
| let latestSearchToken = 0; | |
| let searchDebounceId = null; | |
| const searchInput = document.getElementById("searchInput"); | |
| const subtitle = document.querySelector(".subtitle"); | |
| const loadingOverlay = document.getElementById("loading-overlay"); | |
| const loadingStatus = document.getElementById("loadingStatus"); | |
| function dot(a, b) { | |
| let sum = 0; | |
| for (let i = 0; i < a.length; i++) { | |
| sum += a[i] * b[i]; | |
| } | |
| return sum; | |
| } | |
| function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines) { | |
| const words = text.split(" "); | |
| const lines = []; | |
| let line = ""; | |
| for (let i = 0; i < words.length; i++) { | |
| const testLine = line ? `${line} ${words[i]}` : words[i]; | |
| const metrics = ctx.measureText(testLine); | |
| if (metrics.width > maxWidth && line) { | |
| lines.push(line); | |
| line = words[i]; | |
| if (lines.length === maxLines - 1) { | |
| break; | |
| } | |
| } else { | |
| line = testLine; | |
| } | |
| } | |
| if (line) { | |
| lines.push(line); | |
| } | |
| if (lines.length > maxLines) { | |
| lines.length = maxLines; | |
| } | |
| if (lines.length === maxLines && words.join(" ") !== lines.join(" ")) { | |
| const last = lines[maxLines - 1]; | |
| lines[maxLines - 1] = `${last.replace(/[.,;:!?]?$/, "")}…`; | |
| } | |
| const startY = y - ((lines.length - 1) * lineHeight) / 2; | |
| lines.forEach((lineText, idx) => { | |
| ctx.fillText(lineText, x, startY + idx * lineHeight); | |
| }); | |
| } | |
| function formatScore(score) { | |
| return score == null ? "--" : score.toFixed(3); | |
| } | |
| function jumpToDocument(docId) { | |
| const targetSpread = Math.floor((docId - 1) / 2); | |
| const targetAngleDeg = -targetSpread * 60; | |
| const currentDeg = (targetBaseY * 180) / Math.PI; | |
| const fullCylinderDeg = TOTAL_SPREADS * 60; | |
| let diff = (targetAngleDeg - currentDeg) % fullCylinderDeg; | |
| if (diff > fullCylinderDeg / 2) diff -= fullCylinderDeg; | |
| if (diff < -fullCylinderDeg / 2) diff += fullCylinderDeg; | |
| targetBaseY = ((currentDeg + diff) * Math.PI) / 180; | |
| } | |
| function dismissLoadingOverlay() { | |
| loadingOverlay.classList.add("fade-out"); | |
| loadingOverlay.addEventListener( | |
| "transitionend", | |
| () => { | |
| loadingOverlay.remove(); | |
| }, | |
| { once: true }, | |
| ); | |
| } | |
| async function initializeSemanticSearch() { | |
| loadingStatus.textContent = "Loading model..."; | |
| subtitle.textContent = "Loading embedding model on WebGPU..."; | |
| try { | |
| extractor = await pipeline("feature-extraction", MODEL_ID, { | |
| dtype: "q4", | |
| device: "webgpu", | |
| revision: "refs/pr/10", | |
| }); | |
| loadingStatus.textContent = "Embedding documents..."; | |
| subtitle.textContent = "Embedding all documents..."; | |
| const embeddingTensor = await extractor(DEFAULT_SENTENCES, { | |
| pooling: "mean", | |
| normalize: true, | |
| }); | |
| docEmbeddings = embeddingTensor.tolist(); | |
| searchInput.disabled = false; | |
| searchInput.placeholder = `Semantic search across ${TOTAL_DOCS} docs...`; | |
| subtitle.textContent = "Drag to spin, or type to search semantically"; | |
| dismissLoadingOverlay(); | |
| } catch (err) { | |
| console.error("Semantic search initialization failed:", err); | |
| subtitle.textContent = "Semantic search failed to initialize on this device"; | |
| searchInput.placeholder = "Semantic search unavailable"; | |
| loadingStatus.textContent = "Failed to load model"; | |
| setTimeout(dismissLoadingOverlay, 2000); | |
| } | |
| } | |
| async function runSemanticSearch(query) { | |
| if (!extractor || !docEmbeddings) return; | |
| const token = ++latestSearchToken; | |
| idleAutoSpin = false; | |
| subtitle.textContent = "Searching..."; | |
| const queryEmbeddingTensor = await extractor([query], { | |
| pooling: "mean", | |
| normalize: true, | |
| }); | |
| if (token !== latestSearchToken) return; | |
| const queryEmbedding = queryEmbeddingTensor.tolist()[0]; | |
| // Compute similarity for every document | |
| const scored = []; | |
| for (let i = 1; i <= TOTAL_DOCS; i++) { | |
| const score = dot(queryEmbedding, docEmbeddings[i - 1]); | |
| similarityScores[i] = score; | |
| scored.push({ docId: i, score }); | |
| } | |
| // Sort descending by score | |
| scored.sort((a, b) => b.score - a.score); | |
| const bestDocId = scored[0].docId; | |
| const bestScore = scored[0].score; | |
| const bestSlot = bestDocId - 1; // 0-indexed carousel slot | |
| // Place best match at its original slot, then alternate left/right | |
| // rank 0 → bestSlot, rank 1 → bestSlot-1, rank 2 → bestSlot+1, | |
| // rank 3 → bestSlot-2, rank 4 → bestSlot+2, … | |
| slotToDoc[bestSlot] = scored[0].docId; | |
| for (let r = 1; r < TOTAL_DOCS; r++) { | |
| const offset = Math.ceil(r / 2); | |
| const dir = r % 2 === 1 ? -1 : 1; | |
| const slot = (((bestSlot + dir * offset) % TOTAL_DOCS) + TOTAL_DOCS) % TOTAL_DOCS; | |
| slotToDoc[slot] = scored[r].docId; | |
| } | |
| rebuildAllDocTextures(); | |
| jumpToDocument(bestDocId); | |
| subtitle.textContent = `Top match: Document #${bestDocId} (${bestScore.toFixed(3)})`; | |
| } | |
| function clearSearchResults() { | |
| latestSearchToken++; | |
| for (let i = 0; i < TOTAL_DOCS; i++) { | |
| slotToDoc[i] = i + 1; // Reset to identity mapping | |
| similarityScores[i + 1] = null; | |
| } | |
| rebuildAllDocTextures(); | |
| idleAutoSpin = true; | |
| subtitle.textContent = "Drag to spin, or type to search semantically"; | |
| } | |
| // --- Setup Scene, Camera, and Renderer --- | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(30, window.innerWidth / window.innerHeight, 0.1, 100); | |
| camera.position.z = 14; | |
| const renderer = new THREE.WebGLRenderer({ | |
| antialias: true, | |
| alpha: true, | |
| }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| document.getElementById("canvas-container").appendChild(renderer.domElement); | |
| const logoGroup = new THREE.Group(); | |
| logoGroup.position.y = 0.6; // Nudge up to account for bottom UI | |
| scene.add(logoGroup); | |
| // --- Dimensions --- | |
| const panelWidth = 3.6; | |
| const panelHeight = 3.6; | |
| const panelDepth = 0.08; | |
| const axleHeight = 4.8; | |
| const panelColor = "#1a1c1d"; | |
| const lineColor = 0xffffff; | |
| // --- 1. Central Axle --- | |
| const axleGeom = new THREE.BoxGeometry(panelDepth, axleHeight, panelDepth); | |
| const axleMat = new THREE.MeshBasicMaterial({ color: panelColor }); | |
| const axle = new THREE.Mesh(axleGeom, axleMat); | |
| const axleEdges = new THREE.LineSegments(new THREE.EdgesGeometry(axleGeom), new THREE.LineBasicMaterial({ color: lineColor })); | |
| axle.add(axleEdges); | |
| logoGroup.add(axle); | |
| // --- 2. Generate document textures --- | |
| const docTextures = []; | |
| function createDocumentTexture(num, score) { | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = 512; | |
| canvas.height = Math.round(512 * (panelHeight / panelWidth)); | |
| const ctx = canvas.getContext("2d"); | |
| const sentence = DEFAULT_SENTENCES[num - 1]; | |
| // Background | |
| ctx.fillStyle = panelColor; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Subtle border to make it look like a document card | |
| ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; | |
| ctx.lineWidth = 12; | |
| ctx.strokeRect(10, 10, canvas.width - 20, canvas.height - 20); | |
| // Header metadata | |
| ctx.fillStyle = "#9aa0a6"; | |
| ctx.font = '600 24px "Segoe UI", sans-serif'; | |
| ctx.textAlign = "left"; | |
| ctx.textBaseline = "top"; | |
| ctx.fillText(`Document #${num}`, 30, 24); | |
| ctx.textAlign = "right"; | |
| ctx.fillText(`Score ${formatScore(score)}`, canvas.width - 30, 24); | |
| // Body sentence | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.font = '500 28px "Segoe UI", sans-serif'; | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| wrapText(ctx, sentence, canvas.width / 2, canvas.height / 2 + 12, canvas.width - 80, 38, 5); | |
| const texture = new THREE.CanvasTexture(canvas); | |
| texture.minFilter = THREE.LinearFilter; | |
| texture.magFilter = THREE.LinearFilter; | |
| return texture; | |
| } | |
| function rebuildAllDocTextures() { | |
| for (let i = 1; i <= TOTAL_DOCS; i++) { | |
| if (docTextures[i]) { | |
| docTextures[i].dispose(); | |
| } | |
| docTextures[i] = createDocumentTexture(i, similarityScores[i]); | |
| } | |
| } | |
| // Pre-generate all textures (1-indexed for convenience) | |
| for (let i = 1; i <= TOTAL_DOCS; i++) { | |
| docTextures[i] = createDocumentTexture(i, similarityScores[i]); | |
| } | |
| // --- 3. Create Panels & Dynamic Materials --- | |
| const panelGeom = new THREE.BoxGeometry(panelWidth, panelHeight, panelDepth); | |
| panelGeom.translate(panelWidth / 2, 0, 0); // Pivot at the left edge | |
| const edgeMat = new THREE.MeshBasicMaterial({ color: panelColor }); | |
| // We have 6 fins, each with a front and back face. Total 12 faces. | |
| // We give each face its own distinct material so we can dynamically swap them. | |
| const faceMaterials = []; | |
| for (let i = 0; i < 12; i++) { | |
| faceMaterials.push(new THREE.MeshBasicMaterial({ map: docTextures[1] })); | |
| } | |
| for (let i = 0; i < 6; i++) { | |
| const frontMat = faceMaterials[i * 2]; // Front face index | |
| const backMat = faceMaterials[i * 2 + 1]; // Back face index | |
| const materials = [ | |
| edgeMat, // Right edge | |
| edgeMat, // Left edge (inside axle) | |
| edgeMat, // Top edge | |
| edgeMat, // Bottom edge | |
| frontMat, // Front face (+Z local) | |
| backMat, // Back face (-Z local) | |
| ]; | |
| const panel = new THREE.Mesh(panelGeom, materials); | |
| panel.rotation.y = i * (Math.PI / 3); // 60 degrees | |
| const edges = new THREE.LineSegments(new THREE.EdgesGeometry(panelGeom), new THREE.LineBasicMaterial({ color: lineColor })); | |
| panel.add(edges); | |
| logoGroup.add(panel); | |
| } | |
| // --- 4. State & Interaction --- | |
| let targetBaseY = 0; // The logical rotation of the carousel | |
| let currentBaseY = 0; | |
| let mouseTiltX = 0; | |
| let mouseTiltY = 0; | |
| let isDragging = false; | |
| let previousMouseX = 0; | |
| let idleAutoSpin = true; | |
| // Search logic | |
| searchInput.addEventListener("input", (e) => { | |
| const query = e.target.value.trim(); | |
| if (searchDebounceId) { | |
| clearTimeout(searchDebounceId); | |
| } | |
| if (!query) { | |
| clearSearchResults(); | |
| return; | |
| } | |
| searchDebounceId = setTimeout(() => { | |
| runSemanticSearch(query).catch((err) => { | |
| console.error("Semantic search failed:", err); | |
| subtitle.textContent = "Search failed. Please try again."; | |
| }); | |
| }, 10); | |
| }); | |
| // Mouse Dragging Logic | |
| const container = document.getElementById("canvas-container"); | |
| container.addEventListener("mousedown", (e) => { | |
| isDragging = true; | |
| idleAutoSpin = false; | |
| previousMouseX = e.clientX; | |
| }); | |
| window.addEventListener("mouseup", () => (isDragging = false)); | |
| window.addEventListener("mousemove", (e) => { | |
| // Mouse parallax tilt | |
| mouseTiltX = (e.clientX / window.innerWidth) * 2 - 1; | |
| mouseTiltY = -(e.clientY / window.innerHeight) * 2 + 1; | |
| if (isDragging) { | |
| const deltaX = e.clientX - previousMouseX; | |
| targetBaseY += deltaX * 0.01; // Drag sensitivity | |
| previousMouseX = e.clientX; | |
| } | |
| }); | |
| // Touch support | |
| container.addEventListener("touchstart", (e) => { | |
| isDragging = true; | |
| idleAutoSpin = false; | |
| previousMouseX = e.touches[0].clientX; | |
| }); | |
| window.addEventListener("touchend", () => (isDragging = false)); | |
| window.addEventListener("touchmove", (e) => { | |
| if (isDragging) { | |
| const deltaX = e.touches[0].clientX - previousMouseX; | |
| targetBaseY += deltaX * 0.01; | |
| previousMouseX = e.touches[0].clientX; | |
| } | |
| }); | |
| window.addEventListener("resize", () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // --- 5. Smart Swap Animation Loop --- | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| if (idleAutoSpin && !isDragging) { | |
| targetBaseY -= 0.002; // Slow continuous spin | |
| } | |
| // Smooth interpolation to target rotation | |
| currentBaseY += (targetBaseY - currentBaseY) * 0.05; | |
| // Apply main rotation + slight parallax tilt from mouse | |
| logoGroup.rotation.y = currentBaseY; | |
| logoGroup.rotation.x = mouseTiltY * 0.2 + 0.1; | |
| // We evaluate the 6 "physical spreads". A spread is the V-shape pocket formed by two adjacent fins. | |
| // Spread k is formed by the Back face of Fin k (Left) and the Front face of Fin k+1 (Right). | |
| // When a spread rotates perfectly to the back (+90 deg from center), it is completely hidden. | |
| // At that exact moment, we update its textures to the next required documents. | |
| let currentDeg = (currentBaseY * 180) / Math.PI; | |
| for (let k = 0; k < 6; k++) { | |
| // Offset angle: Spread k is centered at k*60 + 30 degrees locally. | |
| // It faces the back when its world angle crosses +90 degrees. | |
| // We offset by 90 to make the swap boundary exactly at 0. | |
| let A_offset = currentDeg + k * 60 - 60; | |
| // Calculate how many full rotations this spread has made | |
| let n_wrap = Math.floor(A_offset / 360); | |
| // Determine which "Virtual Spread" this physical spread is currently representing. | |
| // The -4 offset ensures that at 0 degrees, the front-facing spread (k=4) represents V=0 (Docs 1 & 2). | |
| let k_virt = k - 6 * n_wrap - 4; | |
| // Map virtual spread to carousel slots, then look up via slotToDoc | |
| let slotLeft = (((2 * k_virt) % TOTAL_DOCS) + TOTAL_DOCS) % TOTAL_DOCS; | |
| let slotRight = (((2 * k_virt + 1) % TOTAL_DOCS) + TOTAL_DOCS) % TOTAL_DOCS; | |
| let docLeftId = slotToDoc[slotLeft]; | |
| let docRightId = slotToDoc[slotRight]; | |
| // Map to the specific left and right faces on the 3D model: | |
| // Left page is the BACK face of Fin k | |
| let leftFaceIdx = k * 2 + 1; | |
| // Right page is the FRONT face of the next fin, Fin (k+1)%6 | |
| let rightFaceIdx = ((k + 1) % 6) * 2; | |
| // Apply the textures if they aren't already set | |
| if (faceMaterials[leftFaceIdx].map !== docTextures[docLeftId]) { | |
| faceMaterials[leftFaceIdx].map = docTextures[docLeftId]; | |
| } | |
| if (faceMaterials[rightFaceIdx].map !== docTextures[docRightId]) { | |
| faceMaterials[rightFaceIdx].map = docTextures[docRightId]; | |
| } | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| initializeSemanticSearch(); | |
| animate(); | |
| </script> | |
| </body> | |
| </html> | |