| <!DOCTYPE html>
|
| <html lang="en">
|
|
|
| <head>
|
| <meta charset="UTF-8">
|
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| <title>NurseLex Semantic Search</title>
|
| <style>
|
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
|
|
|
| * {
|
| margin: 0;
|
| padding: 0;
|
| box-sizing: border-box;
|
| }
|
|
|
| body {
|
| font-family: 'Inter', sans-serif;
|
| background: #111111;
|
| color: #fff;
|
| overflow: hidden;
|
| height: 100vh;
|
| width: 100vw;
|
| }
|
|
|
| #canvas-container {
|
| position: fixed;
|
| top: 0;
|
| left: 0;
|
| width: 100%;
|
| height: 100%;
|
| z-index: 0;
|
| }
|
|
|
|
|
| #search-container {
|
| position: fixed;
|
| bottom: 60px;
|
| left: 50%;
|
| transform: translateX(-50%);
|
| z-index: 100;
|
| width: 100%;
|
| display: flex;
|
| flex-direction: column;
|
| align-items: center;
|
| gap: 12px;
|
| }
|
|
|
| #search-wrapper {
|
| position: relative;
|
| width: 400px;
|
| }
|
|
|
| .search-icon {
|
| position: absolute;
|
| left: 16px;
|
| top: 50%;
|
| transform: translateY(-50%);
|
| color: rgba(255, 255, 255, 0.4);
|
| pointer-events: none;
|
| }
|
|
|
| #search-input {
|
| width: 100%;
|
| background: #1f1f1f;
|
| border: 1px solid rgba(255, 255, 255, 0.1);
|
| border-radius: 24px;
|
| padding: 12px 20px 12px 44px;
|
| font-family: 'Inter', sans-serif;
|
| font-size: 14px;
|
| color: white;
|
| outline: none;
|
| transition: all 0.2s ease;
|
| }
|
|
|
| #search-input:focus {
|
| background: #2a2a2a;
|
| border-color: rgba(255, 255, 255, 0.3);
|
| }
|
|
|
| #search-input::placeholder {
|
| color: rgba(255, 255, 255, 0.4);
|
| }
|
|
|
| #meta-info {
|
| text-align: center;
|
| font-size: 12px;
|
| color: rgba(255, 255, 255, 0.4);
|
| }
|
|
|
| #meta-info .brand {
|
| font-weight: 600;
|
| color: rgba(255, 255, 255, 0.8);
|
| margin-bottom: 2px;
|
| }
|
|
|
|
|
| #loader {
|
| position: fixed;
|
| top: 50%;
|
| left: 50%;
|
| transform: translate(-50%, -50%);
|
| z-index: 200;
|
| background: rgba(17, 17, 17, 0.9);
|
| padding: 24px 40px;
|
| border-radius: 12px;
|
| border: 1px solid rgba(255, 255, 255, 0.1);
|
| text-align: center;
|
| transition: opacity 0.4s;
|
| }
|
|
|
| #loader.hidden {
|
| opacity: 0;
|
| pointer-events: none;
|
| }
|
|
|
| .spinner {
|
| width: 24px;
|
| height: 24px;
|
| border: 3px solid rgba(255, 255, 255, 0.1);
|
| border-top-color: white;
|
| border-radius: 50%;
|
| animation: spin 1s linear infinite;
|
| margin: 0 auto 16px;
|
| }
|
|
|
| @keyframes spin {
|
| 100% {
|
| transform: rotate(360deg);
|
| }
|
| }
|
| </style>
|
| </head>
|
|
|
| <body>
|
|
|
| <div id="loader">
|
| <div class="spinner"></div>
|
| <div id="status-text">Downloading embedding model...</div>
|
| </div>
|
|
|
| <div id="canvas-container"></div>
|
|
|
| <div id="search-container">
|
| <div id="search-wrapper">
|
| <svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| stroke-width="2">
|
| <circle cx="11" cy="11" r="8"></circle>
|
| <path d="m21 21-4.35-4.35"></path>
|
| </svg>
|
| <input id="search-input" type="text" placeholder="weather" disabled />
|
| </div>
|
| <div id="meta-info">
|
| <div class="brand">pplx-embed web</div>
|
| <div id="top-match">Loading clinical documents...</div>
|
| </div>
|
| </div>
|
|
|
|
|
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
|
|
|
| <script type="module">
|
| import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3/dist/transformers.min.js';
|
|
|
| env.allowLocalModels = false;
|
|
|
|
|
|
|
|
|
| const DOCUMENTS = [
|
| { id: 1, text: "A 67-year-old male presents with crushing central chest pain radiating to the left arm. Diaphoresis, nausea, and shortness of breath." },
|
| { id: 2, text: "Sudden onset pleuritic chest pain with haemoptysis in post-operative patient day 3. Tachycardia, low-grade fever." },
|
| { id: 3, text: "Patient febrile at 39.8Β°C, hypotensive BP 80/40, tachycardic HR 128. Source: urinary tract infection." },
|
| { id: 4, text: "Sudden facial drooping, arm weakness, and speech slurring in a 72-year-old female. FAST positive." },
|
| { id: 5, text: "Type 1 diabetic presenting with vomiting, polyuria, polydipsia, Kussmaul breathing. Blood glucose 28 mmol/L." },
|
| { id: 6, text: "Elderly patient with previous fall, gait instability, polypharmacy >4 drugs, impaired vision. Morse Fall Scale high risk." },
|
| { id: 7, text: "Wrong dose of methotrexate administered weekly instead of daily. Nurse failed to check original prescription." },
|
| { id: 8, text: "Immobile patient grade 2 sacral pressure injury. Waterlow score 18. Reposition every 2 hours." },
|
| { id: 9, text: "Patient expressing suicidal ideation with a plan. Previous attempt documented. Maintain observation." },
|
| { id: 10, text: "Patient develops urticaria, angioedema, bronchospasm following penicillin administration. Hypotensive BP 70/40." },
|
| { id: 11, text: "Patient post-laparotomy day 1, NRS pain score 8/10. Morphine PCA inadequate. Assess respiratory rate." },
|
| { id: 12, text: "Surgical wound red, warm, swollen with purulent discharge on day 5. Temperature 38.2Β°C, WBC elevated." },
|
| { id: 13, text: "Sudden-onset confusion, agitation, and disorientation in 81-year-old post-hip replacement." },
|
| { id: 14, text: "Health and Care (Staffing) Act 2019 mandates registered nurse staffing levels." },
|
| { id: 15, text: "Section 5(2) Mental Health Act 1983 allows a doctor to detain an informal inpatient for up to 72 hours." }
|
| ];
|
|
|
|
|
|
|
|
|
| const container = document.getElementById('canvas-container');
|
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
| renderer.setSize(window.innerWidth, window.innerHeight);
|
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
| container.appendChild(renderer.domElement);
|
|
|
| const scene = new THREE.Scene();
|
| const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
|
| camera.position.set(0, 0, 45);
|
|
|
|
|
| scene.add(new THREE.AmbientLight(0xffffff, 0.2));
|
|
|
|
|
| const carousel = new THREE.Group();
|
| scene.add(carousel);
|
|
|
|
|
| const poleGeo = new THREE.CylinderGeometry(0.05, 0.05, 30, 8);
|
| const poleMat = new THREE.MeshBasicMaterial({ color: 0x555555 });
|
| const pole = new THREE.Mesh(poleGeo, poleMat);
|
| carousel.add(pole);
|
|
|
|
|
|
|
|
|
| function createCardTexture(id, text, scoreStr) {
|
| const canvas = document.createElement('canvas');
|
|
|
| canvas.width = 1024;
|
| canvas.height = 1024 * (16 / 12);
|
| const ctx = canvas.getContext('2d');
|
|
|
|
|
| ctx.fillStyle = '#161616';
|
| ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
| ctx.fillStyle = '#ffffff';
|
|
|
|
|
| ctx.font = '500 48px Inter, sans-serif';
|
| ctx.textAlign = 'left';
|
| ctx.fillText(`Document #${id}`, 64, 80);
|
|
|
|
|
| if (scoreStr) {
|
| ctx.textAlign = 'right';
|
| ctx.fillText(`Score ${scoreStr}`, canvas.width - 64, 80);
|
| }
|
|
|
|
|
| ctx.font = '500 52px Inter, sans-serif';
|
| ctx.textAlign = 'left';
|
| ctx.textBaseline = 'middle';
|
|
|
|
|
| const words = text.split(' ');
|
| let line = '';
|
| let y = canvas.height / 2;
|
| const lines = [];
|
| const marginX = 120;
|
| const maxWidth = canvas.width - (marginX * 2);
|
|
|
| for (let n = 0; n < words.length; n++) {
|
| const testLine = line + words[n] + ' ';
|
| const metrics = ctx.measureText(testLine);
|
| if (metrics.width > maxWidth && n > 0) {
|
| lines.push(line);
|
| line = words[n] + ' ';
|
| } else {
|
| line = testLine;
|
| }
|
| }
|
| lines.push(line);
|
|
|
| const lineHeight = 76;
|
| const startY = y - ((lines.length - 1) * lineHeight) / 2;
|
|
|
| lines.forEach((l, i) => {
|
| ctx.fillText(l.trim(), marginX, startY + (i * lineHeight));
|
| });
|
|
|
| const texture = new THREE.CanvasTexture(canvas);
|
| texture.minFilter = THREE.LinearMipMapLinearFilter;
|
| texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
| return texture;
|
| }
|
|
|
|
|
|
|
|
|
| const cards = [];
|
| const cardWidth = 14;
|
| const cardHeight = 18;
|
|
|
| function updateAllCardTextures(scoresMap) {
|
| cards.forEach(c => {
|
| const score = scoresMap ? scoresMap[c.doc.id] : null;
|
| const scoreStr = score ? score.toFixed(3) : '';
|
| const newTex = createCardTexture(c.doc.id, c.doc.text, scoreStr);
|
| c.frontMat.map.dispose();
|
| c.frontMat.map = newTex;
|
| c.frontMat.needsUpdate = true;
|
| });
|
| }
|
|
|
| DOCUMENTS.forEach((doc, i) => {
|
| const group = new THREE.Group();
|
|
|
|
|
| const frontGeo = new THREE.PlaneGeometry(cardWidth, cardHeight);
|
| frontGeo.translate(cardWidth / 2, 0, 0);
|
|
|
| const tex = createCardTexture(doc.id, doc.text, '');
|
| const frontMat = new THREE.MeshBasicMaterial({
|
| map: tex,
|
| side: THREE.FrontSide
|
| });
|
| const frontMesh = new THREE.Mesh(frontGeo, frontMat);
|
| group.add(frontMesh);
|
|
|
|
|
| const backGeo = new THREE.PlaneGeometry(cardWidth, cardHeight);
|
| backGeo.translate(cardWidth / 2, 0, 0);
|
| const backMat = new THREE.MeshBasicMaterial({
|
| color: 0x161616,
|
| side: THREE.BackSide
|
| });
|
| const backMesh = new THREE.Mesh(backGeo, backMat);
|
| group.add(backMesh);
|
|
|
|
|
| const edgesGeo = new THREE.EdgesGeometry(frontGeo);
|
| const edgesMat = new THREE.LineBasicMaterial({ color: 0x888888, linewidth: 2 });
|
| const edges = new THREE.LineSegments(edgesGeo, edgesMat);
|
| group.add(edges);
|
|
|
|
|
| const angle = (i / DOCUMENTS.length) * Math.PI * 2;
|
| group.position.set(0, 0, 0);
|
| group.rotation.y = angle;
|
|
|
| carousel.add(group);
|
|
|
| cards.push({
|
| group,
|
| doc,
|
| cardMesh: frontMesh,
|
| frontMat,
|
| edgesMat,
|
| angle
|
| });
|
| });
|
|
|
|
|
|
|
|
|
| let targetRotation = 0;
|
| let isDragging = false;
|
| let dragDistance = 0;
|
| let previousMouseX = 0;
|
| let selectedCard = null;
|
|
|
| const raycaster = new THREE.Raycaster();
|
| const mouse = new THREE.Vector2();
|
|
|
|
|
| const detailPanel = document.createElement('div');
|
| detailPanel.id = 'detail-panel';
|
| detailPanel.innerHTML = `
|
| <button id="detail-close">β</button>
|
| <div id="detail-id"></div>
|
| <div id="detail-score"></div>
|
| <div id="detail-text"></div>
|
| `;
|
| detailPanel.style.cssText = `
|
| position: fixed; top: 50%; right: 40px; transform: translateY(-50%);
|
| width: 380px; max-height: 70vh; overflow-y: auto;
|
| background: rgba(22,22,22,0.92); backdrop-filter: blur(20px);
|
| border: 1px solid rgba(255,255,255,0.15); border-radius: 16px;
|
| padding: 28px; z-index: 300; color: white;
|
| font-family: Inter, sans-serif; opacity: 0;
|
| pointer-events: none; transition: opacity 0.3s ease;
|
| `;
|
| document.body.appendChild(detailPanel);
|
|
|
| const detailClose = document.getElementById('detail-close');
|
| detailClose.style.cssText = `
|
| position: absolute; top: 12px; right: 16px;
|
| background: none; border: none; color: rgba(255,255,255,0.5);
|
| font-size: 18px; cursor: pointer;
|
| `;
|
| detailClose.addEventListener('click', () => closeDetail());
|
|
|
| function showDetail(card) {
|
| selectedCard = card;
|
|
|
| cards.forEach(c => c.edgesMat.color.setHex(0x888888));
|
| card.edgesMat.color.setHex(0x00e5ff);
|
|
|
| document.getElementById('detail-id').textContent = `Document #${card.doc.id}`;
|
| document.getElementById('detail-id').style.cssText = 'font-size:14px; font-weight:600; color:#00e5ff; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;';
|
|
|
| const scoreEl = document.getElementById('detail-score');
|
| if (card.doc._lastScore !== undefined) {
|
| scoreEl.textContent = `Similarity: ${card.doc._lastScore.toFixed(3)}`;
|
| scoreEl.style.cssText = 'font-size:13px; color:rgba(255,255,255,0.5); margin-bottom:16px;';
|
| } else {
|
| scoreEl.textContent = '';
|
| }
|
|
|
| document.getElementById('detail-text').textContent = card.doc.text;
|
| document.getElementById('detail-text').style.cssText = 'font-size:15px; line-height:1.7; color:rgba(255,255,255,0.85);';
|
|
|
| detailPanel.style.opacity = '1';
|
| detailPanel.style.pointerEvents = 'auto';
|
|
|
|
|
| targetRotation = -card.angle;
|
| }
|
|
|
| function closeDetail() {
|
| detailPanel.style.opacity = '0';
|
| detailPanel.style.pointerEvents = 'none';
|
| if (selectedCard) {
|
| selectedCard.edgesMat.color.setHex(0x888888);
|
| selectedCard = null;
|
| }
|
| }
|
|
|
| document.addEventListener('mousedown', (e) => {
|
| isDragging = true;
|
| dragDistance = 0;
|
| previousMouseX = e.clientX;
|
| mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
| mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
| });
|
|
|
| document.addEventListener('mousemove', (e) => {
|
| if (isDragging) {
|
| const delta = e.clientX - previousMouseX;
|
| dragDistance += Math.abs(delta);
|
| targetRotation += delta * 0.005;
|
| previousMouseX = e.clientX;
|
| } else {
|
|
|
| targetRotation += 0.001;
|
| }
|
| });
|
|
|
| document.addEventListener('mouseup', (e) => {
|
|
|
| if (dragDistance < 5) {
|
| mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
| mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
| raycaster.setFromCamera(mouse, camera);
|
| const meshes = cards.map(c => c.cardMesh);
|
| const hits = raycaster.intersectObjects(meshes);
|
| if (hits.length > 0) {
|
| const hitCard = cards.find(c => c.cardMesh === hits[0].object);
|
| if (hitCard) showDetail(hitCard);
|
| } else {
|
| closeDetail();
|
| }
|
| }
|
| isDragging = false;
|
| });
|
|
|
| document.addEventListener('wheel', (e) => {
|
| targetRotation += e.deltaY * 0.002;
|
| });
|
|
|
| function animate() {
|
| requestAnimationFrame(animate);
|
|
|
|
|
|
|
| carousel.rotation.y += (targetRotation - carousel.rotation.y) * 0.1;
|
|
|
| renderer.render(scene, camera);
|
| }
|
| animate();
|
|
|
| window.addEventListener('resize', () => {
|
| camera.aspect = window.innerWidth / window.innerHeight;
|
| camera.updateProjectionMatrix();
|
| renderer.setSize(window.innerWidth, window.innerHeight);
|
| });
|
|
|
|
|
|
|
|
|
| let embedder = null;
|
| let docEmbeddings = null;
|
|
|
| async function cosineSimilarity(a, b) {
|
| let dot = 0, na = 0, nb = 0;
|
| for (let i = 0; i < a.length; i++) {
|
| dot += a[i] * b[i];
|
| na += a[i] * a[i];
|
| nb += b[i] * b[i];
|
| }
|
| return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
| }
|
|
|
| async function loadModel() {
|
| const statusText = document.getElementById('status-text');
|
| const loader = document.getElementById('loader');
|
|
|
| try {
|
| embedder = await pipeline(
|
| 'feature-extraction',
|
| 'Xenova/all-MiniLM-L6-v2',
|
| {
|
| progress_callback: (p) => {
|
| if (p.status === 'progress') {
|
| statusText.textContent = `Downloading model... ${Math.round(p.progress || 0)}%`;
|
| }
|
| }
|
| }
|
| );
|
|
|
| statusText.textContent = 'Formatting documents...';
|
|
|
| const allTexts = DOCUMENTS.map(d => d.text);
|
| const output = await embedder(allTexts, { pooling: 'mean', normalize: true });
|
| docEmbeddings = output.tolist();
|
|
|
|
|
| document.getElementById('search-input').disabled = false;
|
| document.getElementById('top-match').textContent = `${DOCUMENTS.length} ready`;
|
| loader.classList.add('hidden');
|
|
|
| } catch (err) {
|
| statusText.textContent = 'Error: ' + err.message;
|
| }
|
| }
|
|
|
| async function doSearch() {
|
| if (!embedder || !docEmbeddings) return;
|
|
|
| const query = document.getElementById('search-input').value.trim();
|
| if (!query) {
|
| updateAllCardTextures(null);
|
| document.getElementById('top-match').innerHTML = `${DOCUMENTS.length} ready`;
|
| return;
|
| }
|
|
|
| const qOutput = await embedder([query], { pooling: 'mean', normalize: true });
|
| const qEmb = qOutput.tolist()[0];
|
|
|
| const scoresMap = {};
|
| let maxScore = -1;
|
| let bestDoc = null;
|
|
|
| for (let i = 0; i < DOCUMENTS.length; i++) {
|
| const score = await cosineSimilarity(qEmb, docEmbeddings[i]);
|
| scoresMap[DOCUMENTS[i].id] = score;
|
|
|
| if (score > maxScore) {
|
| maxScore = score;
|
| bestDoc = DOCUMENTS[i];
|
| }
|
| }
|
|
|
|
|
| updateAllCardTextures(scoresMap);
|
|
|
|
|
| if (bestDoc) {
|
| document.getElementById('top-match').innerHTML = `Top match: Document #${bestDoc.id} (${maxScore.toFixed(3)})`;
|
|
|
|
|
| const targetCard = cards.find(c => c.doc.id === bestDoc.id);
|
| if (targetCard) {
|
|
|
|
|
| targetRotation = -targetCard.angle;
|
| }
|
| }
|
| }
|
|
|
| const searchInput = document.getElementById('search-input');
|
| let timeout;
|
| searchInput.addEventListener('input', () => {
|
| clearTimeout(timeout);
|
| timeout = setTimeout(doSearch, 300);
|
| });
|
|
|
| loadModel();
|
| </script>
|
| </body>
|
|
|
| </html> |