Spaces:
Running
Running
File size: 29,143 Bytes
73d484f daa5270 e007996 daa5270 23ecb7c daa5270 73d484f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 | <!doctype html>
<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>
|