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