NurseLex-Match / browser_demo.html
NurseCitizenDeveloper's picture
Upload browser_demo.html with huggingface_hub
4665a8b verified
<!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 UI ── */
#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;
}
/* ── Loading state ── */
#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>
<!-- Three.js CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- Transformers.js API -->
<script type="module">
import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3/dist/transformers.min.js';
env.allowLocalModels = false;
// ─────────────────────────────────────────────────────────────────
// NURSING DOCUMENT CORPUS
// ─────────────────────────────────────────────────────────────────
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." }
];
// ─────────────────────────────────────────────────────────────────
// Three.js Setup
// ─────────────────────────────────────────────────────────────────
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); // Pulled back for carousel
// Lighting is minimal - the cards are mostly emissive self-lit
scene.add(new THREE.AmbientLight(0xffffff, 0.2));
// Group to hold all cards for spinning
const carousel = new THREE.Group();
scene.add(carousel);
// Center pole
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);
// ─────────────────────────────────────────────────────────────────
// Textures via Canvas
// ─────────────────────────────────────────────────────────────────
function createCardTexture(id, text, scoreStr) {
const canvas = document.createElement('canvas');
// Physical aspect ratio ~ 0.7 (width/height roughly 1:1.4 in reference)
canvas.width = 1024;
canvas.height = 1024 * (16 / 12);
const ctx = canvas.getContext('2d');
// Dark background (not pure black to match reference)
ctx.fillStyle = '#161616';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Text Setup
ctx.fillStyle = '#ffffff';
// Top Left: Document #X
ctx.font = '500 48px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`Document #${id}`, 64, 80);
// Top Right: Score O.XXX
if (scoreStr) {
ctx.textAlign = 'right';
ctx.fillText(`Score ${scoreStr}`, canvas.width - 64, 80);
}
// Center Text
ctx.font = '500 52px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
// Text wrapping
const words = text.split(' ');
let line = '';
let y = canvas.height / 2;
const lines = [];
const marginX = 120; // Indent text like in reference
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;
}
// ─────────────────────────────────────────────────────────────────
// Layout Cards
// ─────────────────────────────────────────────────────────────────
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(); // clean up old
c.frontMat.map = newTex;
c.frontMat.needsUpdate = true;
});
}
DOCUMENTS.forEach((doc, i) => {
const group = new THREE.Group();
// Front face β€” textured with document content
const frontGeo = new THREE.PlaneGeometry(cardWidth, cardHeight);
frontGeo.translate(cardWidth / 2, 0, 0); // hinge at left edge
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);
// Back face β€” solid dark (prevents seeing through + no mirrored text)
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);
// The white wireframe outline
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);
// Position all groups at center and fan out via Y rotation
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
});
});
// ─────────────────────────────────────────────────────────────────
// Interaction & Animation
// ─────────────────────────────────────────────────────────────────
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();
// Detail panel (injected into DOM)
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;
// Highlight selected card wireframe
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';
// Spin carousel to face this card
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 {
// Auto-rotate slowly
targetRotation += 0.001;
}
});
document.addEventListener('mouseup', (e) => {
// Only treat as click if mouse didn't drag far
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);
// Smooth damping for rotation
// To spin the whole star shape, rotate the carousel group
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);
});
// ─────────────────────────────────────────────────────────────────
// Transformers.js β€” Search Logic
// ─────────────────────────────────────────────────────────────────
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();
// Enable UI
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];
}
}
// Redraw textures with scores
updateAllCardTextures(scoresMap);
// Update UI
if (bestDoc) {
document.getElementById('top-match').innerHTML = `Top match: Document #${bestDoc.id} (${maxScore.toFixed(3)})`;
// Spin carousel to show the best match front-and-center
const targetCard = cards.find(c => c.doc.id === bestDoc.id);
if (targetCard) {
// Face camera (angle 0 is +Z)
// We need to rotate the carousel so this card is at the front (rotation.y = -cardAngle)
targetRotation = -targetCard.angle;
}
}
}
const searchInput = document.getElementById('search-input');
let timeout;
searchInput.addEventListener('input', () => {
clearTimeout(timeout);
timeout = setTimeout(doSearch, 300); // debounce
});
loadModel();
</script>
</body>
</html>