BeatSense: unified FastAPI + Next.js deployment
Browse filesMulti-stage Dockerfile:
- Build Next.js 16 with standalone output
- Runtime serves FastAPI on :8000 + Next.js on :7860
- /api/* rewrites to internal FastAPI
Includes full clinical workstation UI with i18n (EN/ES),
dark mode, and printable thermal-style ticket result.
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +46 -0
- .gitattributes +1 -0
- Dockerfile +82 -0
- README.md +467 -3
- api/main.py +269 -0
- data/heart.csv +919 -0
- docker/entrypoint.sh +42 -0
- frontend/.gitignore +41 -0
- frontend/README.md +36 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next.config.ts +16 -0
- frontend/package.json +44 -0
- frontend/pnpm-lock.yaml +0 -0
- frontend/pnpm-workspace.yaml +3 -0
- frontend/postcss.config.mjs +7 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/logu1.png +3 -0
- frontend/public/next.svg +1 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/src/app/[lang]/eda/page.tsx +21 -0
- frontend/src/app/[lang]/layout.tsx +34 -0
- frontend/src/app/[lang]/model/page.tsx +21 -0
- frontend/src/app/[lang]/new-evaluation/page.tsx +21 -0
- frontend/src/app/[lang]/page.tsx +21 -0
- frontend/src/app/[lang]/patients/page.tsx +21 -0
- frontend/src/app/[lang]/settings/page.tsx +21 -0
- frontend/src/app/favicon.ico +0 -0
- frontend/src/app/globals.css +146 -0
- frontend/src/app/layout.tsx +37 -0
- frontend/src/components/charts/eda-charts.tsx +150 -0
- frontend/src/components/dashboard/dashboard-client.tsx +216 -0
- frontend/src/components/forms/evaluation-form.tsx +392 -0
- frontend/src/components/layout/header.tsx +52 -0
- frontend/src/components/layout/language-toggle.tsx +43 -0
- frontend/src/components/layout/page-header.tsx +21 -0
- frontend/src/components/layout/sidebar.tsx +84 -0
- frontend/src/components/layout/theme-toggle.tsx +30 -0
- frontend/src/components/model/model-insights.tsx +130 -0
- frontend/src/components/patients/patients-table.tsx +205 -0
- frontend/src/components/providers.tsx +45 -0
- frontend/src/components/settings/settings-panel.tsx +61 -0
- frontend/src/components/ticket/ticket.tsx +254 -0
- frontend/src/components/ui/badge.tsx +31 -0
- frontend/src/components/ui/button.tsx +53 -0
- frontend/src/components/ui/card.tsx +73 -0
- frontend/src/components/ui/input.tsx +18 -0
- frontend/src/components/ui/label.tsx +20 -0
- frontend/src/components/ui/select.tsx +81 -0
.dockerignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
**/.git
|
| 2 |
+
**/.gitignore
|
| 3 |
+
**/.gitattributes
|
| 4 |
+
**/.DS_Store
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
.venv
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.pyc
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
.mypy_cache/
|
| 12 |
+
.ruff_cache/
|
| 13 |
+
|
| 14 |
+
# Node
|
| 15 |
+
frontend/node_modules
|
| 16 |
+
frontend/.next
|
| 17 |
+
frontend/out
|
| 18 |
+
frontend/.turbo
|
| 19 |
+
|
| 20 |
+
# Notebooks / reports / huge artefacts not needed at runtime
|
| 21 |
+
notebook/
|
| 22 |
+
report/
|
| 23 |
+
assets/
|
| 24 |
+
|
| 25 |
+
# Local dev junk
|
| 26 |
+
*.log
|
| 27 |
+
.env
|
| 28 |
+
.env.*
|
| 29 |
+
!.env.example
|
| 30 |
+
.streamlit/
|
| 31 |
+
.python-version
|
| 32 |
+
|
| 33 |
+
# Docs
|
| 34 |
+
docs/article-medium.md
|
| 35 |
+
docs/inform.pdf
|
| 36 |
+
docs/historial_pacientes.csv
|
| 37 |
+
|
| 38 |
+
# Legacy
|
| 39 |
+
Dockerfile.streamlit
|
| 40 |
+
docker-compose.yml
|
| 41 |
+
app.py
|
| 42 |
+
.github/
|
| 43 |
+
|
| 44 |
+
# Macros
|
| 45 |
+
.idea/
|
| 46 |
+
.vscode/
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
# ---------------------------------------------------------------------------
|
| 3 |
+
# BeatSense — single-container Hugging Face Space image
|
| 4 |
+
# Runs FastAPI on :8000 (internal) and Next.js on :7860 (public, HF Spaces port)
|
| 5 |
+
# Next.js rewrites /api/* -> http://127.0.0.1:8000/*
|
| 6 |
+
# ---------------------------------------------------------------------------
|
| 7 |
+
|
| 8 |
+
# ============================================================================
|
| 9 |
+
# Stage 1: build the Next.js frontend (standalone output)
|
| 10 |
+
# ============================================================================
|
| 11 |
+
FROM node:20-bookworm-slim AS frontend-builder
|
| 12 |
+
|
| 13 |
+
ENV PNPM_HOME=/pnpm
|
| 14 |
+
ENV PATH=$PNPM_HOME:$PATH
|
| 15 |
+
RUN corepack enable && corepack prepare pnpm@10 --activate
|
| 16 |
+
|
| 17 |
+
WORKDIR /app/frontend
|
| 18 |
+
|
| 19 |
+
# Cache deps
|
| 20 |
+
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
| 21 |
+
RUN pnpm install --frozen-lockfile
|
| 22 |
+
|
| 23 |
+
# Build
|
| 24 |
+
COPY frontend ./
|
| 25 |
+
RUN pnpm build
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ============================================================================
|
| 29 |
+
# Stage 2: runtime — Python + FastAPI + Node runtime + Next.js standalone
|
| 30 |
+
# ============================================================================
|
| 31 |
+
FROM python:3.11-slim-bookworm AS runtime
|
| 32 |
+
|
| 33 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 34 |
+
PYTHONUNBUFFERED=1 \
|
| 35 |
+
PIP_NO_CACHE_DIR=1 \
|
| 36 |
+
NODE_ENV=production \
|
| 37 |
+
PORT=7860 \
|
| 38 |
+
HOSTNAME=0.0.0.0 \
|
| 39 |
+
API_INTERNAL_URL=http://127.0.0.1:8000
|
| 40 |
+
|
| 41 |
+
# System deps: Node.js (for Next.js standalone), curl (for healthchecks)
|
| 42 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 43 |
+
curl \
|
| 44 |
+
ca-certificates \
|
| 45 |
+
gnupg \
|
| 46 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 47 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 48 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 49 |
+
|
| 50 |
+
# uv for fast Python installs
|
| 51 |
+
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv
|
| 52 |
+
|
| 53 |
+
# HF Spaces runs containers as a non-root user with UID 1000
|
| 54 |
+
RUN useradd -m -u 1000 app
|
| 55 |
+
WORKDIR /home/app/code
|
| 56 |
+
RUN chown -R app:app /home/app
|
| 57 |
+
|
| 58 |
+
USER app
|
| 59 |
+
ENV HOME=/home/app
|
| 60 |
+
ENV PATH="/home/app/.local/bin:$PATH"
|
| 61 |
+
|
| 62 |
+
# ---- Python deps ----
|
| 63 |
+
COPY --chown=app:app pyproject.toml uv.lock ./
|
| 64 |
+
RUN uv sync --frozen --no-dev --compile-bytecode
|
| 65 |
+
|
| 66 |
+
# ---- App source ----
|
| 67 |
+
COPY --chown=app:app api ./api
|
| 68 |
+
COPY --chown=app:app models ./models
|
| 69 |
+
COPY --chown=app:app data ./data
|
| 70 |
+
RUN mkdir -p docs
|
| 71 |
+
|
| 72 |
+
# ---- Frontend: Next.js standalone bundle ----
|
| 73 |
+
COPY --from=frontend-builder --chown=app:app /app/frontend/.next/standalone ./frontend
|
| 74 |
+
COPY --from=frontend-builder --chown=app:app /app/frontend/.next/static ./frontend/.next/static
|
| 75 |
+
COPY --from=frontend-builder --chown=app:app /app/frontend/public ./frontend/public
|
| 76 |
+
|
| 77 |
+
# ---- Entrypoint that runs both processes ----
|
| 78 |
+
COPY --chown=app:app docker/entrypoint.sh /home/app/entrypoint.sh
|
| 79 |
+
RUN chmod +x /home/app/entrypoint.sh
|
| 80 |
+
|
| 81 |
+
EXPOSE 7860
|
| 82 |
+
ENTRYPOINT ["/home/app/entrypoint.sh"]
|
README.md
CHANGED
|
@@ -1,10 +1,474 @@
|
|
| 1 |
---
|
| 2 |
title: BeatSense
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
title: BeatSense
|
| 3 |
+
emoji: 🩺
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Cardiovascular risk assessment workstation (FastAPI + Next.js)
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<img src="src/img/logo.png" alt="BeatSense" width="260" />
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
<h1 align="center">BeatSense — Cardiovascular Risk Intelligence</h1>
|
| 18 |
+
|
| 19 |
+
<p align="center">
|
| 20 |
+
<a href="https://huggingface.co/spaces/devrup404/BeatSense">
|
| 21 |
+
<img src="src/img/qr_hf.png" alt="QR — Hugging Face Space" width="160"/>
|
| 22 |
+
</a>
|
| 23 |
+
</p>
|
| 24 |
+
|
| 25 |
+
<p align="center">
|
| 26 |
+
<sub><b>Scan to open the Hugging Face Space</b></sub>
|
| 27 |
+
</p>
|
| 28 |
+
|
| 29 |
+
<p align="center">
|
| 30 |
+
<a href="README.md"><img src="https://img.shields.io/badge/English-0d1a1f?style=flat&logoColor=7fd1c6&labelColor=0a1014" alt="English" /></a>
|
| 31 |
+
·
|
| 32 |
+
<a href="README.es.md"><img src="https://img.shields.io/badge/Espa%C3%B1ol-0d1a1f?style=flat&logoColor=7fd1c6&labelColor=0a1014" alt="Español" /></a>
|
| 33 |
+
</p>
|
| 34 |
+
|
| 35 |
+
<p align="center">
|
| 36 |
+
<img src="https://img.shields.io/badge/Python-3.11-0a1014?logo=python&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 37 |
+
<img src="https://img.shields.io/badge/FastAPI-0.115-0a1014?logo=fastapi&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 38 |
+
<img src="https://img.shields.io/badge/scikit--learn-1.8-0a1014?logo=scikitlearn&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 39 |
+
<img src="https://img.shields.io/badge/XGBoost-3.x-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 40 |
+
<img src="https://img.shields.io/badge/Optuna-4.x-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 41 |
+
<img src="https://img.shields.io/badge/uv-package%20mgr-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 42 |
+
<img src="https://img.shields.io/badge/Next.js-16-0a1014?logo=nextdotjs&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 43 |
+
<img src="https://img.shields.io/badge/React-19-0a1014?logo=react&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 44 |
+
<img src="https://img.shields.io/badge/TypeScript-5-0a1014?logo=typescript&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 45 |
+
<img src="https://img.shields.io/badge/Tailwind-v4-0a1014?logo=tailwindcss&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 46 |
+
<img src="https://img.shields.io/badge/TanStack%20Query-5-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 47 |
+
<img src="https://img.shields.io/badge/Recharts-2-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 48 |
+
<img src="https://img.shields.io/badge/pnpm-10-0a1014?logo=pnpm&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 49 |
+
<img src="https://img.shields.io/badge/Streamlit-1.x-0a1014?logo=streamlit&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 50 |
+
<img src="https://img.shields.io/badge/Docker-compose-0a1014?logo=docker&logoColor=7fd1c6&labelColor=0d1a1f" />
|
| 51 |
+
</p>
|
| 52 |
+
|
| 53 |
+
<p align="center">
|
| 54 |
+
<a href="https://medium.com/@devrup404/most-clinical-machine-learning-prototypes-share-the-same-fate-2c357e91e112"><img src="https://img.shields.io/badge/Medium-Read%20the%20article-0a1014?logo=medium&logoColor=7fd1c6&labelColor=0d1a1f" alt="Read on Medium" /></a>
|
| 55 |
+
</p>
|
| 56 |
+
|
| 57 |
+
> **BeatSense** is a cardiovascular risk assessment platform born from a question: what would a coronary risk tool actually feel like if it were built for the nurses station of a real hospital, instead of for a notebook demo?
|
| 58 |
+
>
|
| 59 |
+
> Around a trained scikit-learn model (918 patients, twelve clinical features), it ships three coordinated surfaces: a FastAPI service that exposes the model and persists every assessment, a Next.js 16 clinical workstation with sidebar navigation, dark mode, English and Spanish, and a result rendered as a printable thermal-style ticket ready to clip to the patient record, plus a legacy Streamlit dashboard kept alive for quick demos. The model itself is a Logistic Regression with a custom 0.4 decision threshold, picked because in cardiology a false negative is far more dangerous than a false positive, an interpretable choice that recovers 94% of true positives.
|
| 60 |
+
>
|
| 61 |
+
> The repo is end-to-end: notebooks for EDA and training, a typed REST API, a production-grade clinical UI, internationalization with zero hardcoded strings, and theme tokens out of the box.
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## Table of Contents
|
| 66 |
+
|
| 67 |
+
| | |
|
| 68 |
+
|---|---|
|
| 69 |
+
| Overview | Live Demo |
|
| 70 |
+
| Features | Architecture |
|
| 71 |
+
| Project Structure | Tech Stack |
|
| 72 |
+
| Dataset | Model Selection |
|
| 73 |
+
| Getting Started | Make Targets |
|
| 74 |
+
| API Reference | Frontend Highlights |
|
| 75 |
+
| Internationalization | Theming |
|
| 76 |
+
| Testing | Docker |
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Overview
|
| 81 |
+
|
| 82 |
+
**BeatSense** predicts the probability of coronary heart disease from clinical patient data. It ships three independent surfaces sharing the same trained model:
|
| 83 |
+
|
| 84 |
+
- A **FastAPI** REST backend that wraps the production model (models/modelo_coronario.pkl).
|
| 85 |
+
- A **Next.js 16** clinical workstation UI — sidebar, ticket-style report, dark mode, EN/ES — for nurses and clinicians.
|
| 86 |
+
- A legacy **Streamlit** dashboard (still operational) for quick demos.
|
| 87 |
+
|
| 88 |
+
The trained model is a **Logistic Regression** with a custom decision threshold of 0.4, chosen because in cardiology the cost of a false negative (missing a sick patient) far exceeds that of a false positive.
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## Live Demo
|
| 93 |
+
|
| 94 |
+
<p>
|
| 95 |
+
<a href="https://huggingface.co/spaces/devrup404/BeatSense"><img src="https://img.shields.io/badge/Hugging%20Face-Open%20Space-0a1014?style=flat&logo=huggingface&logoColor=7fd1c6&labelColor=0d1a1f" alt="Hugging Face Space" /></a>
|
| 96 |
+
</p>
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## Features
|
| 101 |
+
|
| 102 |
+
| Surface | Highlights |
|
| 103 |
+
|---|---|
|
| 104 |
+
| 🤖 **ML pipeline** | EDA + survival analysis (Kaplan–Meier, Cox), preprocessing, 4 models compared, Optuna tuning, full notebooks. |
|
| 105 |
+
| 🚀 **REST API** | FastAPI service with /predict, /history, /stats, /eda, /model-info, persistent CSV history, CORS enabled. |
|
| 106 |
+
| 🩺 **Clinical UI** | Next.js 16 + Tailwind v4 + shadcn-style components. Sidebar navigation, nurse-station header, KPI dashboard, filtered patient table, EDA charts, model insights. |
|
| 107 |
+
| 🖨️ **Printable ticket** | Risk result rendered as a thermal-printer ticket: monospace font, dashed dividers, barcode, window.print(). |
|
| 108 |
+
| 🌍 **i18n** | English (default) + Spanish, zero hardcoded strings, JSON dictionaries, cookie + Accept-Language detection. |
|
| 109 |
+
| 🌓 **Dark mode** | First-class next-themes integration, dark by default, no flash on first paint. |
|
| 110 |
+
| 📦 **Containerized** | Docker / docker-compose for the Streamlit surface. |
|
| 111 |
+
| 🛠️ **Dual toolchain** | uv for Python, pnpm for the Next.js workspace. One make dev boots both servers. |
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## Architecture
|
| 116 |
+
|
| 117 |
+
```
|
| 118 |
+
┌─────────────────────────┐ HTTPS/REST ┌────────────────────────┐
|
| 119 |
+
│ Next.js 16 frontend │ ────────────────▶ │ FastAPI service │
|
| 120 |
+
│ • App Router │ │ • /predict │
|
| 121 |
+
│ • Server + Client │ │ • /history /stats │
|
| 122 |
+
│ Components │ │ • /eda /model-info │
|
| 123 |
+
│ • TanStack Query │ ◀──────────────── │ • Loads .pkl + scaler │
|
| 124 |
+
└─────────────┬───────────┘ JSON └───────────┬────────────┘
|
| 125 |
+
│ │
|
| 126 |
+
│ static assets │ joblib.load
|
| 127 |
+
▼ ▼
|
| 128 |
+
┌──────────┐ ┌─────────────────────┐
|
| 129 |
+
│ /public │ │ models/ │
|
| 130 |
+
│ logu1.png│ │ • modelo_coronario │
|
| 131 |
+
└──────────┘ │ • scaler_coronario │
|
| 132 |
+
└─────────────────────┘
|
| 133 |
+
▲
|
| 134 |
+
│ persists
|
| 135 |
+
│
|
| 136 |
+
┌─────────┴───────────┐
|
| 137 |
+
│ docs/ │
|
| 138 |
+
│ historial_pacientes │
|
| 139 |
+
└──────────────────────┘
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
## Project Structure
|
| 145 |
+
|
| 146 |
+
```
|
| 147 |
+
BeatSense/
|
| 148 |
+
├── api/ # FastAPI backend
|
| 149 |
+
│ └── main.py # /predict, /history, /stats, /eda, /model-info
|
| 150 |
+
│
|
| 151 |
+
├── app.py # Legacy Streamlit app (still works)
|
| 152 |
+
│
|
| 153 |
+
├── frontend/ # Next.js 16 clinical workstation
|
| 154 |
+
│ ├── public/
|
| 155 |
+
│ │ └── logu1.png # Logo (auto-cropped for tight framing)
|
| 156 |
+
│ ├── src/
|
| 157 |
+
│ │ ├── app/
|
| 158 |
+
│ │ │ ├── layout.tsx # Root layout — ThemeProvider lives here
|
| 159 |
+
│ │ │ ├── globals.css # Tailwind v4 + design tokens + print CSS
|
| 160 |
+
│ │ │ └── [lang]/ # Locale-scoped routes
|
| 161 |
+
│ │ │ ├── layout.tsx # CRM shell (Sidebar + Header) + Providers
|
| 162 |
+
│ │ │ ├── page.tsx # Dashboard (KPIs + recent + pie chart)
|
| 163 |
+
│ │ │ ├── new-evaluation/ # Clinical form → ticket result
|
| 164 |
+
│ │ │ ├── patients/ # Filterable history table + CSV export
|
| 165 |
+
│ │ │ ├── eda/ # Dataset analytics (Recharts)
|
| 166 |
+
│ │ │ ├── model/ # Model metrics + comparison
|
| 167 |
+
│ │ │ └── settings/ # Language + theme switcher
|
| 168 |
+
│ │ ├── components/
|
| 169 |
+
│ │ │ ├── ui/ # Button, Card, Input, Label, Select, Badge
|
| 170 |
+
│ │ │ ├── layout/ # Sidebar, Header, PageHeader, toggles
|
| 171 |
+
│ │ │ ├── forms/ # EvaluationForm (RHF + Zod)
|
| 172 |
+
│ │ │ ├── ticket/ # Printable result ticket
|
| 173 |
+
│ │ │ ├── charts/ # EDA charts
|
| 174 |
+
│ │ │ ├── dashboard/ # Dashboard client widgets
|
| 175 |
+
│ │ │ ├── patients/ # Patient table
|
| 176 |
+
│ │ │ ├── model/ # Model insights
|
| 177 |
+
│ │ │ ├── settings/ # Settings panel
|
| 178 |
+
│ │ │ └── providers.tsx # ThemeRoot + I18nProvider + QueryClient
|
| 179 |
+
│ │ ├── i18n/
|
| 180 |
+
│ │ │ ├── config.ts # Locale list + defaultLocale
|
| 181 |
+
│ │ │ ├── context.tsx # Client-side useT() hook
|
| 182 |
+
│ │ │ ├── dictionaries.ts # Server-only loader
|
| 183 |
+
│ │ │ └── dictionaries/
|
| 184 |
+
│ │ │ ├── en.json
|
| 185 |
+
│ │ │ └── es.json
|
| 186 |
+
│ │ ├── lib/
|
| 187 |
+
│ │ │ ├── api.ts # Typed REST client (TanStack-friendly)
|
| 188 |
+
│ │ │ └── utils.ts # cn() helper
|
| 189 |
+
│ │ └── proxy.ts # Next 16 middleware: locale routing
|
| 190 |
+
│ ├── package.json
|
| 191 |
+
│ └── tsconfig.json
|
| 192 |
+
│
|
| 193 |
+
├── data/ # Heart Disease dataset (918 records)
|
| 194 |
+
│ ├── heart.csv # Raw
|
| 195 |
+
│ ├── heart_procesado_g.csv # Processed by Gema
|
| 196 |
+
│ ├── heart_procesado_i.csv # Processed by Isrodam
|
| 197 |
+
│ └── heart_processed_R.csv # Processed by Roberto
|
| 198 |
+
│
|
| 199 |
+
├── models/ # Persisted scikit-learn artifacts
|
| 200 |
+
│ ├── modelo_coronario.pkl
|
| 201 |
+
│ ├── scaler_coronario.pkl
|
| 202 |
+
│ └── model_R.pkl
|
| 203 |
+
│
|
| 204 |
+
├── notebook/ # EDA + training notebooks
|
| 205 |
+
│ ├── eda_heart_disease_p.ipynb # EDA + survival analysis (Paloma)
|
| 206 |
+
│ ├── eda_heart_failure_g.ipynb # EDA (Gema)
|
| 207 |
+
│ ├── eda_heart_failure_i.ipynb # EDA (Isrodam)
|
| 208 |
+
│ ├── eda_heart_failure_R.ipynb # EDA (Roberto)
|
| 209 |
+
│ ├── entrenamiento_modelo_final.ipynb
|
| 210 |
+
│ ├── entrenamiento_modelo_g.ipynb
|
| 211 |
+
│ └── entrenamiento_modelo_i.ipynb
|
| 212 |
+
│
|
| 213 |
+
├── report/ # Evaluation reports (PDF)
|
| 214 |
+
├── docs/historial_pacientes.csv # Patient history (written by /predict)
|
| 215 |
+
├── src/img/qr_app.png # QR code to live demo
|
| 216 |
+
├── assets/ # Static assets (lottie, etc.)
|
| 217 |
+
├── .streamlit/ # Streamlit theming
|
| 218 |
+
├── Makefile # uv + pnpm orchestration
|
| 219 |
+
├── dockerfile
|
| 220 |
+
├── docker-compose.yml
|
| 221 |
+
├── pyproject.toml # uv-managed Python deps
|
| 222 |
+
└── uv.lock
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
## Tech Stack
|
| 228 |
+
|
| 229 |
+
### Backend / ML
|
| 230 |
+
|
| 231 |
+
- **Python 3.11** managed with **uv**
|
| 232 |
+
- **FastAPI** + **Uvicorn** — REST API
|
| 233 |
+
- **scikit-learn** — Logistic Regression, RF, KNN; StandardScaler
|
| 234 |
+
- **XGBoost** — alternative model evaluated
|
| 235 |
+
- **Optuna** — hyperparameter search
|
| 236 |
+
- **pandas / numpy** — data wrangling
|
| 237 |
+
- **joblib** — model persistence
|
| 238 |
+
- **Streamlit** — legacy interactive dashboard
|
| 239 |
+
- **pytest** — tests
|
| 240 |
+
|
| 241 |
+
### Frontend
|
| 242 |
+
|
| 243 |
+
- **Next.js 16** (App Router, Turbopack, Server Components)
|
| 244 |
+
- **React 19** + **TypeScript 5**
|
| 245 |
+
- **Tailwind CSS v4** with custom design tokens
|
| 246 |
+
- **shadcn-style UI** built on **Radix UI** primitives
|
| 247 |
+
- **TanStack Query 5** — server-state caching
|
| 248 |
+
- **React Hook Form 7** + **Zod 4** — typed forms with runtime validation
|
| 249 |
+
- **Recharts** — analytics charts
|
| 250 |
+
- **next-themes** — dark/light theme
|
| 251 |
+
- **next-intl-style i18n** using Next.js native dictionaries
|
| 252 |
+
- **lucide-react** — icon set
|
| 253 |
+
- **pnpm 10** — workspace package manager
|
| 254 |
+
|
| 255 |
+
### DevOps
|
| 256 |
+
|
| 257 |
+
- **Make** — single command interface
|
| 258 |
+
- **Docker / docker-compose** — containerised Streamlit deployment
|
| 259 |
+
- **GitHub Actions** — CI/CD
|
| 260 |
+
- **Render** — production hosting (Streamlit demo)
|
| 261 |
+
|
| 262 |
+
---
|
| 263 |
+
|
| 264 |
+
## Dataset
|
| 265 |
+
|
| 266 |
+
918 records, 12 features:
|
| 267 |
+
|
| 268 |
+
| Variable | Description | Type |
|
| 269 |
+
|---|---|---|
|
| 270 |
+
| Age | Patient age | Numeric |
|
| 271 |
+
| Sex | Sex (M/F) | Categorical |
|
| 272 |
+
| ChestPainType | Type (ATA, NAP, ASY, TA) | Categorical |
|
| 273 |
+
| RestingBP | Resting blood pressure (mm Hg) | Numeric |
|
| 274 |
+
| Cholesterol | Serum cholesterol (mg/dl) | Numeric |
|
| 275 |
+
| FastingBS | Fasting blood sugar > 120 mg/dl (0/1) | Binary |
|
| 276 |
+
| RestingECG | Resting ECG result | Categorical |
|
| 277 |
+
| MaxHR | Max heart rate achieved | Numeric |
|
| 278 |
+
| ExerciseAngina | Exercise-induced angina (Y/N) | Binary |
|
| 279 |
+
| Oldpeak | ST depression | Numeric |
|
| 280 |
+
| ST_Slope | ST segment slope | Categorical |
|
| 281 |
+
| HeartDisease | **Target** — heart disease (0/1) | Binary |
|
| 282 |
+
|
| 283 |
+
The production model uses **12 engineered features** (Age, Sex, MaxHR, FastingBS, ExerciseAngina, one-hot of ChestPainType, one-hot of ST_Slope). Only Age and MaxHR are scaled.
|
| 284 |
+
|
| 285 |
+
---
|
| 286 |
+
|
| 287 |
+
## Model Selection
|
| 288 |
+
|
| 289 |
+
| Model | Accuracy | Recall (class 1) | ROC-AUC | Notes |
|
| 290 |
+
|---|---|---|---|---|
|
| 291 |
+
| **Logistic Regression** *(threshold 0.4)* | **88 %** | **0.94** | **0.926** | ✅ Selected |
|
| 292 |
+
| Random Forest | ~87 % | ~0.88 | ~0.91 | Higher raw accuracy, lower recall |
|
| 293 |
+
| K-NN | ~82 % | ~0.83 | ~0.87 | Scale-sensitive |
|
| 294 |
+
| XGBoost | ~86 % | ~0.87 | ~0.90 | Strong, less interpretable |
|
| 295 |
+
|
| 296 |
+
### Why Logistic Regression?
|
| 297 |
+
|
| 298 |
+
1. **Highest recall (0.94)** — at threshold 0.4 it catches 94% of truly sick patients. A false negative in cardiology is far worse than a false positive.
|
| 299 |
+
2. **No overfitting** — train/test gap of only 0.007. 5-fold CV: 84.74% ± 2.91%.
|
| 300 |
+
3. **Clinical transparency** — linear coefficients let clinicians see exactly which features drive each prediction.
|
| 301 |
+
|
| 302 |
+
> Full analysis:
|
| 303 |
+
>
|
| 304 |
+
> <a href="notebook/entrenamiento_modelo_final.ipynb"><img src="https://img.shields.io/badge/notebook-entrenamiento__modelo__final.ipynb-0a1014?style=flat&logo=jupyter&logoColor=7fd1c6&labelColor=0d1a1f" alt="Notebook" /></a>
|
| 305 |
+
> <a href="report/entrenamiento_modelo_final.pdf"><img src="https://img.shields.io/badge/report-entrenamiento__modelo__final.pdf-0a1014?style=flat&logo=adobeacrobatreader&logoColor=7fd1c6&labelColor=0d1a1f" alt="Report" /></a>
|
| 306 |
+
|
| 307 |
+
---
|
| 308 |
+
|
| 309 |
+
## Getting Started
|
| 310 |
+
|
| 311 |
+
### Prerequisites
|
| 312 |
+
|
| 313 |
+
- Python >=3.11, <3.13
|
| 314 |
+
- <a href="https://docs.astral.sh/uv/"><img src="https://img.shields.io/badge/uv-Python%20toolchain-0a1014?style=flat&logoColor=7fd1c6&labelColor=0d1a1f" alt="uv" /></a>
|
| 315 |
+
- <a href="https://pnpm.io/"><img src="https://img.shields.io/badge/pnpm-%E2%89%A510%20Node.js%20toolchain-0a1014?style=flat&logo=pnpm&logoColor=7fd1c6&labelColor=0d1a1f" alt="pnpm" /></a>
|
| 316 |
+
- Node.js >=20
|
| 317 |
+
|
| 318 |
+
### Install
|
| 319 |
+
|
| 320 |
+
```bash
|
| 321 |
+
# Python deps
|
| 322 |
+
make install
|
| 323 |
+
|
| 324 |
+
# Frontend deps
|
| 325 |
+
make frontend-install
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
### Run everything (recommended)
|
| 329 |
+
|
| 330 |
+
```bash
|
| 331 |
+
make dev
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
This spawns:
|
| 335 |
+
|
| 336 |
+
<p>
|
| 337 |
+
<a href="http://localhost:8000/docs"><img src="https://img.shields.io/badge/FastAPI-localhost:8000/docs-0a1014?style=flat&logo=fastapi&logoColor=7fd1c6&labelColor=0d1a1f" alt="FastAPI" /></a>
|
| 338 |
+
<a href="http://localhost:3000"><img src="https://img.shields.io/badge/Next.js-localhost:3000-0a1014?style=flat&logo=nextdotjs&logoColor=7fd1c6&labelColor=0d1a1f" alt="Next.js" /></a>
|
| 339 |
+
</p>
|
| 340 |
+
|
| 341 |
+
### Run individually
|
| 342 |
+
|
| 343 |
+
```bash
|
| 344 |
+
make api # FastAPI only
|
| 345 |
+
make frontend # Next.js only
|
| 346 |
+
make streamlit # Legacy Streamlit dashboard
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
---
|
| 350 |
+
|
| 351 |
+
## Make Targets
|
| 352 |
+
|
| 353 |
+
| Target | Purpose |
|
| 354 |
+
|---|---|
|
| 355 |
+
| make install | uv sync — install Python deps |
|
| 356 |
+
| make frontend-install | pnpm install — install frontend deps |
|
| 357 |
+
| make dev | Run **both** FastAPI + Next.js in parallel |
|
| 358 |
+
| make api | Run FastAPI only (uvicorn :8000) |
|
| 359 |
+
| make frontend | Run Next.js only (pnpm dev :3000) |
|
| 360 |
+
| make frontend-build | Production build of Next.js |
|
| 361 |
+
| make streamlit | Run legacy Streamlit app |
|
| 362 |
+
| make docker-up / docker-down / docker-build / docker-logs | Docker Compose lifecycle |
|
| 363 |
+
| make jupyter | Launch JupyterLab |
|
| 364 |
+
| make test | Run pytest |
|
| 365 |
+
| make clean | Remove caches and .next/ |
|
| 366 |
+
|
| 367 |
+
---
|
| 368 |
+
|
| 369 |
+
## API Reference
|
| 370 |
+
|
| 371 |
+
<p>
|
| 372 |
+
<b>Base URL</b>
|
| 373 |
+
<a href="http://localhost:8000"><img src="https://img.shields.io/badge/localhost:8000-0a1014?style=flat&logoColor=7fd1c6&labelColor=0d1a1f" alt="localhost:8000" /></a>
|
| 374 |
+
</p>
|
| 375 |
+
|
| 376 |
+
| Method | Path | Description |
|
| 377 |
+
|---|---|---|
|
| 378 |
+
| GET | /health | Liveness + model load status |
|
| 379 |
+
| POST | /predict | Run cardiovascular risk prediction |
|
| 380 |
+
| GET | /history | Return all saved patient assessments |
|
| 381 |
+
| GET | /stats | Aggregate KPIs (totals, high-risk %, today) |
|
| 382 |
+
| GET | /eda | Aggregated EDA payload for charts |
|
| 383 |
+
| GET | /model-info | Metrics of the selected model + alternatives |
|
| 384 |
+
|
| 385 |
+
### POST /predict payload
|
| 386 |
+
|
| 387 |
+
```json
|
| 388 |
+
{
|
| 389 |
+
"patient_id": "P-001",
|
| 390 |
+
"age": 55,
|
| 391 |
+
"sex": "M",
|
| 392 |
+
"chest_pain_type": "ASY",
|
| 393 |
+
"max_hr": 130,
|
| 394 |
+
"fasting_bs": 0,
|
| 395 |
+
"exercise_angina": "Y",
|
| 396 |
+
"st_slope": "Flat",
|
| 397 |
+
"resting_bp": 140,
|
| 398 |
+
"cholesterol": 240,
|
| 399 |
+
"resting_ecg": "Normal",
|
| 400 |
+
"oldpeak": 1.2
|
| 401 |
+
}
|
| 402 |
+
```
|
| 403 |
+
|
| 404 |
+
Response:
|
| 405 |
+
|
| 406 |
+
```json
|
| 407 |
+
{
|
| 408 |
+
"patient_id": "P-001",
|
| 409 |
+
"timestamp": "2026-06-08T10:32:41",
|
| 410 |
+
"probability": 94.28,
|
| 411 |
+
"classification": "HIGH",
|
| 412 |
+
"threshold": 40.0,
|
| 413 |
+
"probabilities": [0.057, 0.943]
|
| 414 |
+
}
|
| 415 |
+
```
|
| 416 |
+
|
| 417 |
+
---
|
| 418 |
+
|
| 419 |
+
## Frontend Highlights
|
| 420 |
+
|
| 421 |
+
- **/[lang]** Clinical dashboard — KPI cards, recent assessments, risk distribution pie.
|
| 422 |
+
- **/[lang]/new-evaluation** Clinical form in three sections (Identification, Vitals, Symptoms/ECG) with RHF + Zod validation. On submit, the answer renders as a printable **ticket** (window.print() switches the print stylesheet so only the ticket prints, like a thermal receipt).
|
| 423 |
+
- **/[lang]/patients** Searchable, filterable history table (high / low / all). CSV export client-side.
|
| 424 |
+
- **/[lang]/eda** Charts: age histogram, sex distribution, chest pain distribution, scatter Max HR vs Age coloured by outcome.
|
| 425 |
+
- **/[lang]/model** Selected model card, comparison bar chart, rationale paragraph.
|
| 426 |
+
- **/[lang]/settings** Language + theme switcher.
|
| 427 |
+
|
| 428 |
+
The layout is a **CRM/EHR shell** with a sticky dark sidebar (logo + navigation + institution badge), a header bar with patient search, shift indicator, language switch, theme toggle and a faux nurse-station avatar.
|
| 429 |
+
|
| 430 |
+
---
|
| 431 |
+
|
| 432 |
+
## Internationalization
|
| 433 |
+
|
| 434 |
+
- Built on **Next.js 16 native dictionaries** (app/[lang]/...).
|
| 435 |
+
- All UI strings live in frontend/src/i18n/dictionaries/{en,es}.json.
|
| 436 |
+
- Locale is resolved in this order by frontend/src/proxy.ts:
|
| 437 |
+
1. locale cookie (set by the language switcher),
|
| 438 |
+
2. Accept-Language header,
|
| 439 |
+
3. fallback to **English**.
|
| 440 |
+
- useT() exposes the dictionary to client components.
|
| 441 |
+
|
| 442 |
+
Add a new locale by creating {xx}.json, adding xx to locales in src/i18n/config.ts, and registering it in src/i18n/dictionaries.ts.
|
| 443 |
+
|
| 444 |
+
---
|
| 445 |
+
|
| 446 |
+
## Theming
|
| 447 |
+
|
| 448 |
+
- Dark mode is the **default**.
|
| 449 |
+
- Theme tokens are defined as CSS variables in globals.css (:root for light, .dark for dark) and bridged to Tailwind v4 via @theme inline.
|
| 450 |
+
- ThemeProvider is mounted at the **root layout** to avoid hydration issues with next-themes.
|
| 451 |
+
|
| 452 |
+
---
|
| 453 |
+
|
| 454 |
+
## Testing
|
| 455 |
+
|
| 456 |
+
```bash
|
| 457 |
+
make test # pytest
|
| 458 |
+
cd frontend && pnpm build # type-check + production build
|
| 459 |
+
```
|
| 460 |
+
|
| 461 |
+
---
|
| 462 |
+
|
| 463 |
+
## Docker
|
| 464 |
+
|
| 465 |
+
```bash
|
| 466 |
+
make docker-up # Build and run Streamlit container
|
| 467 |
+
make docker-logs # Stream logs
|
| 468 |
+
make docker-down # Stop
|
| 469 |
+
```
|
| 470 |
+
|
| 471 |
+
> Containerising the Next.js + FastAPI duo is on the roadmap.
|
| 472 |
+
|
| 473 |
---
|
| 474 |
|
|
|
api/main.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
import joblib
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from fastapi import FastAPI, HTTPException
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 16 |
+
MODEL_PATH = ROOT / "models" / "modelo_coronario.pkl"
|
| 17 |
+
SCALER_PATH = ROOT / "models" / "scaler_coronario.pkl"
|
| 18 |
+
HISTORY_PATH = ROOT / "docs" / "historial_pacientes.csv"
|
| 19 |
+
HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
|
| 21 |
+
HISTORY_COLUMNS = [
|
| 22 |
+
"Fecha", "Hora", "ID_Paciente", "Edad", "Sexo",
|
| 23 |
+
"FC_Maxima", "Glucosa_Ayunas", "Angina_Ejercicio",
|
| 24 |
+
"Tipo_Dolor", "Pendiente_ST",
|
| 25 |
+
"Probabilidad_%", "Clasificacion", "Riesgo",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
THRESHOLD = 40.0
|
| 29 |
+
|
| 30 |
+
model = joblib.load(MODEL_PATH)
|
| 31 |
+
scaler = joblib.load(SCALER_PATH)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class PredictRequest(BaseModel):
|
| 35 |
+
patient_id: str | None = Field(default=None, max_length=64)
|
| 36 |
+
age: int = Field(ge=1, le=120)
|
| 37 |
+
sex: Literal["M", "F"]
|
| 38 |
+
chest_pain_type: Literal["ASY", "ATA", "NAP", "TA"]
|
| 39 |
+
max_hr: int = Field(ge=40, le=240)
|
| 40 |
+
fasting_bs: Literal[0, 1]
|
| 41 |
+
exercise_angina: Literal["Y", "N"]
|
| 42 |
+
st_slope: Literal["Up", "Flat", "Down"]
|
| 43 |
+
resting_bp: int | None = Field(default=None, ge=60, le=250)
|
| 44 |
+
cholesterol: int | None = Field(default=None, ge=0, le=700)
|
| 45 |
+
resting_ecg: Literal["Normal", "ST", "LVH"] | None = None
|
| 46 |
+
oldpeak: float | None = Field(default=None, ge=-5, le=10)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class PredictResponse(BaseModel):
|
| 50 |
+
patient_id: str
|
| 51 |
+
timestamp: str
|
| 52 |
+
probability: float
|
| 53 |
+
classification: Literal["HIGH", "LOW"]
|
| 54 |
+
threshold: float
|
| 55 |
+
probabilities: list[float]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class HistoryEntry(BaseModel):
|
| 59 |
+
date: str
|
| 60 |
+
time: str
|
| 61 |
+
patient_id: str
|
| 62 |
+
age: int
|
| 63 |
+
sex: str
|
| 64 |
+
max_hr: int
|
| 65 |
+
fasting_bs: str
|
| 66 |
+
exercise_angina: str
|
| 67 |
+
chest_pain_type: str
|
| 68 |
+
st_slope: str
|
| 69 |
+
probability: float
|
| 70 |
+
classification: str
|
| 71 |
+
risk: str
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_features(req: PredictRequest) -> pd.DataFrame:
|
| 75 |
+
sex_num = 1 if req.sex == "M" else 0
|
| 76 |
+
exercise_bin = 1 if req.exercise_angina == "Y" else 0
|
| 77 |
+
|
| 78 |
+
cp_asy = int(req.chest_pain_type == "ASY")
|
| 79 |
+
cp_ata = int(req.chest_pain_type == "ATA")
|
| 80 |
+
cp_nap = int(req.chest_pain_type == "NAP")
|
| 81 |
+
cp_ta = int(req.chest_pain_type == "TA")
|
| 82 |
+
|
| 83 |
+
st_flat = int(req.st_slope == "Flat")
|
| 84 |
+
st_up = int(req.st_slope == "Up")
|
| 85 |
+
st_down = int(req.st_slope == "Down")
|
| 86 |
+
|
| 87 |
+
X = pd.DataFrame(
|
| 88 |
+
[[
|
| 89 |
+
req.age, sex_num, req.max_hr, req.fasting_bs,
|
| 90 |
+
exercise_bin, cp_asy, cp_ata, cp_nap, cp_ta,
|
| 91 |
+
st_flat, st_up, st_down,
|
| 92 |
+
]],
|
| 93 |
+
columns=[
|
| 94 |
+
"Age", "Sex", "MaxHR", "FastingBS", "ExerciseAngina",
|
| 95 |
+
"CP_ASY", "CP_ATA", "CP_NAP", "CP_TA",
|
| 96 |
+
"ST_Slope_Flat", "ST_Slope_Up", "ST_Slope_Down",
|
| 97 |
+
],
|
| 98 |
+
)
|
| 99 |
+
X[["Age", "MaxHR"]] = scaler.transform(X[["Age", "MaxHR"]])
|
| 100 |
+
return X
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def append_history(req: PredictRequest, probability: float, classification: str, patient_id: str) -> None:
|
| 104 |
+
now = datetime.now()
|
| 105 |
+
row = {
|
| 106 |
+
"Fecha": now.strftime("%Y-%m-%d"),
|
| 107 |
+
"Hora": now.strftime("%H:%M:%S"),
|
| 108 |
+
"ID_Paciente": patient_id,
|
| 109 |
+
"Edad": req.age,
|
| 110 |
+
"Sexo": req.sex,
|
| 111 |
+
"FC_Maxima": req.max_hr,
|
| 112 |
+
"Glucosa_Ayunas": "Sí" if req.fasting_bs == 1 else "No",
|
| 113 |
+
"Angina_Ejercicio": "Sí" if req.exercise_angina == "Y" else "No",
|
| 114 |
+
"Tipo_Dolor": req.chest_pain_type,
|
| 115 |
+
"Pendiente_ST": req.st_slope,
|
| 116 |
+
"Probabilidad_%": round(probability, 2),
|
| 117 |
+
"Clasificacion": "Alto Riesgo" if classification == "HIGH" else "Bajo Riesgo",
|
| 118 |
+
"Riesgo": "ALTO" if classification == "HIGH" else "BAJO",
|
| 119 |
+
}
|
| 120 |
+
file_exists = HISTORY_PATH.exists()
|
| 121 |
+
with HISTORY_PATH.open("a", newline="", encoding="utf-8") as f:
|
| 122 |
+
writer = csv.DictWriter(f, fieldnames=HISTORY_COLUMNS)
|
| 123 |
+
if not file_exists or HISTORY_PATH.stat().st_size == 0:
|
| 124 |
+
writer.writeheader()
|
| 125 |
+
writer.writerow(row)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
app = FastAPI(title="BeatSense API", version="1.0.0")
|
| 129 |
+
app.add_middleware(
|
| 130 |
+
CORSMiddleware,
|
| 131 |
+
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
| 132 |
+
allow_credentials=True,
|
| 133 |
+
allow_methods=["*"],
|
| 134 |
+
allow_headers=["*"],
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.get("/health")
|
| 139 |
+
def health() -> dict:
|
| 140 |
+
return {"status": "ok", "model_loaded": True, "threshold": THRESHOLD}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@app.post("/predict", response_model=PredictResponse)
|
| 144 |
+
def predict(req: PredictRequest) -> PredictResponse:
|
| 145 |
+
try:
|
| 146 |
+
X = build_features(req)
|
| 147 |
+
probs = model.predict_proba(X)[0]
|
| 148 |
+
probability = float(probs[1] * 100)
|
| 149 |
+
classification = "HIGH" if probability >= THRESHOLD else "LOW"
|
| 150 |
+
patient_id = req.patient_id or f"P-{uuid.uuid4().hex[:8].upper()}"
|
| 151 |
+
timestamp = datetime.now().isoformat()
|
| 152 |
+
append_history(req, probability, classification, patient_id)
|
| 153 |
+
return PredictResponse(
|
| 154 |
+
patient_id=patient_id,
|
| 155 |
+
timestamp=timestamp,
|
| 156 |
+
probability=probability,
|
| 157 |
+
classification=classification,
|
| 158 |
+
threshold=THRESHOLD,
|
| 159 |
+
probabilities=[float(probs[0]), float(probs[1])],
|
| 160 |
+
)
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@app.get("/history", response_model=list[HistoryEntry])
|
| 166 |
+
def history() -> list[HistoryEntry]:
|
| 167 |
+
if not HISTORY_PATH.exists():
|
| 168 |
+
return []
|
| 169 |
+
df = pd.read_csv(HISTORY_PATH)
|
| 170 |
+
out: list[HistoryEntry] = []
|
| 171 |
+
for _, row in df.iterrows():
|
| 172 |
+
out.append(HistoryEntry(
|
| 173 |
+
date=str(row["Fecha"]),
|
| 174 |
+
time=str(row["Hora"]),
|
| 175 |
+
patient_id=str(row["ID_Paciente"]),
|
| 176 |
+
age=int(row["Edad"]),
|
| 177 |
+
sex=str(row["Sexo"]),
|
| 178 |
+
max_hr=int(row["FC_Maxima"]),
|
| 179 |
+
fasting_bs=str(row["Glucosa_Ayunas"]),
|
| 180 |
+
exercise_angina=str(row["Angina_Ejercicio"]),
|
| 181 |
+
chest_pain_type=str(row["Tipo_Dolor"]),
|
| 182 |
+
st_slope=str(row["Pendiente_ST"]),
|
| 183 |
+
probability=float(row["Probabilidad_%"]),
|
| 184 |
+
classification=str(row["Clasificacion"]),
|
| 185 |
+
risk=str(row["Riesgo"]),
|
| 186 |
+
))
|
| 187 |
+
return out
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@app.get("/stats")
|
| 191 |
+
def stats() -> dict:
|
| 192 |
+
if not HISTORY_PATH.exists():
|
| 193 |
+
return {"total": 0, "high_risk": 0, "low_risk": 0, "high_risk_pct": 0, "today": 0}
|
| 194 |
+
df = pd.read_csv(HISTORY_PATH)
|
| 195 |
+
total = len(df)
|
| 196 |
+
high = int((df["Riesgo"] == "ALTO").sum())
|
| 197 |
+
low = total - high
|
| 198 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 199 |
+
today_count = int((df["Fecha"] == today).sum())
|
| 200 |
+
return {
|
| 201 |
+
"total": total,
|
| 202 |
+
"high_risk": high,
|
| 203 |
+
"low_risk": low,
|
| 204 |
+
"high_risk_pct": round(100 * high / total, 1) if total else 0,
|
| 205 |
+
"today": today_count,
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@app.get("/eda")
|
| 210 |
+
def eda() -> dict:
|
| 211 |
+
csv_path = ROOT / "data" / "heart.csv"
|
| 212 |
+
if not csv_path.exists():
|
| 213 |
+
return {}
|
| 214 |
+
df = pd.read_csv(csv_path)
|
| 215 |
+
# Age histogram (bins of 5)
|
| 216 |
+
age_bins = list(range(20, 90, 5))
|
| 217 |
+
df["age_bin"] = pd.cut(df["Age"], bins=age_bins, right=False)
|
| 218 |
+
age_hist = (
|
| 219 |
+
df.groupby("age_bin", observed=True)["HeartDisease"]
|
| 220 |
+
.agg(["count", "mean"])
|
| 221 |
+
.reset_index()
|
| 222 |
+
)
|
| 223 |
+
age_dist = [
|
| 224 |
+
{"bin": f"{int(row['age_bin'].left)}-{int(row['age_bin'].right)}", "count": int(row["count"]), "risk_rate": round(float(row["mean"]) * 100, 1)}
|
| 225 |
+
for _, row in age_hist.iterrows()
|
| 226 |
+
]
|
| 227 |
+
sex_dist = [
|
| 228 |
+
{"sex": s, "count": int((df["Sex"] == s).sum()), "risk_rate": round(float(df[df["Sex"] == s]["HeartDisease"].mean()) * 100, 1)}
|
| 229 |
+
for s in ["M", "F"]
|
| 230 |
+
]
|
| 231 |
+
cp_dist = [
|
| 232 |
+
{"type": c, "count": int((df["ChestPainType"] == c).sum()), "risk_rate": round(float(df[df["ChestPainType"] == c]["HeartDisease"].mean()) * 100, 1)}
|
| 233 |
+
for c in ["ASY", "ATA", "NAP", "TA"]
|
| 234 |
+
]
|
| 235 |
+
scatter = (
|
| 236 |
+
df.sample(min(300, len(df)), random_state=42)[["Age", "MaxHR", "HeartDisease"]]
|
| 237 |
+
.to_dict(orient="records")
|
| 238 |
+
)
|
| 239 |
+
return {
|
| 240 |
+
"age_dist": age_dist,
|
| 241 |
+
"sex_dist": sex_dist,
|
| 242 |
+
"chest_pain_dist": cp_dist,
|
| 243 |
+
"scatter": scatter,
|
| 244 |
+
"total": int(len(df)),
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@app.get("/model-info")
|
| 249 |
+
def model_info() -> dict:
|
| 250 |
+
return {
|
| 251 |
+
"name": "Logistic Regression",
|
| 252 |
+
"threshold": THRESHOLD,
|
| 253 |
+
"metrics": {
|
| 254 |
+
"accuracy": 0.88,
|
| 255 |
+
"recall": 0.94,
|
| 256 |
+
"roc_auc": 0.926,
|
| 257 |
+
"cv_mean": 0.8474,
|
| 258 |
+
"cv_std": 0.0291,
|
| 259 |
+
},
|
| 260 |
+
"alternatives": [
|
| 261 |
+
{"name": "Random Forest", "accuracy": 0.87, "recall": 0.88, "roc_auc": 0.91},
|
| 262 |
+
{"name": "K-NN", "accuracy": 0.82, "recall": 0.83, "roc_auc": 0.87},
|
| 263 |
+
{"name": "XGBoost", "accuracy": 0.86, "recall": 0.87, "roc_auc": 0.90},
|
| 264 |
+
],
|
| 265 |
+
"features": [
|
| 266 |
+
"Age", "Sex", "MaxHR", "FastingBS", "ExerciseAngina",
|
| 267 |
+
"ChestPainType (ASY/ATA/NAP/TA)", "ST_Slope (Up/Flat/Down)",
|
| 268 |
+
],
|
| 269 |
+
}
|
data/heart.csv
ADDED
|
@@ -0,0 +1,919 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Age,Sex,ChestPainType,RestingBP,Cholesterol,FastingBS,RestingECG,MaxHR,ExerciseAngina,Oldpeak,ST_Slope,HeartDisease
|
| 2 |
+
40,M,ATA,140,289,0,Normal,172,N,0,Up,0
|
| 3 |
+
49,F,NAP,160,180,0,Normal,156,N,1,Flat,1
|
| 4 |
+
37,M,ATA,130,283,0,ST,98,N,0,Up,0
|
| 5 |
+
48,F,ASY,138,214,0,Normal,108,Y,1.5,Flat,1
|
| 6 |
+
54,M,NAP,150,195,0,Normal,122,N,0,Up,0
|
| 7 |
+
39,M,NAP,120,339,0,Normal,170,N,0,Up,0
|
| 8 |
+
45,F,ATA,130,237,0,Normal,170,N,0,Up,0
|
| 9 |
+
54,M,ATA,110,208,0,Normal,142,N,0,Up,0
|
| 10 |
+
37,M,ASY,140,207,0,Normal,130,Y,1.5,Flat,1
|
| 11 |
+
48,F,ATA,120,284,0,Normal,120,N,0,Up,0
|
| 12 |
+
37,F,NAP,130,211,0,Normal,142,N,0,Up,0
|
| 13 |
+
58,M,ATA,136,164,0,ST,99,Y,2,Flat,1
|
| 14 |
+
39,M,ATA,120,204,0,Normal,145,N,0,Up,0
|
| 15 |
+
49,M,ASY,140,234,0,Normal,140,Y,1,Flat,1
|
| 16 |
+
42,F,NAP,115,211,0,ST,137,N,0,Up,0
|
| 17 |
+
54,F,ATA,120,273,0,Normal,150,N,1.5,Flat,0
|
| 18 |
+
38,M,ASY,110,196,0,Normal,166,N,0,Flat,1
|
| 19 |
+
43,F,ATA,120,201,0,Normal,165,N,0,Up,0
|
| 20 |
+
60,M,ASY,100,248,0,Normal,125,N,1,Flat,1
|
| 21 |
+
36,M,ATA,120,267,0,Normal,160,N,3,Flat,1
|
| 22 |
+
43,F,TA,100,223,0,Normal,142,N,0,Up,0
|
| 23 |
+
44,M,ATA,120,184,0,Normal,142,N,1,Flat,0
|
| 24 |
+
49,F,ATA,124,201,0,Normal,164,N,0,Up,0
|
| 25 |
+
44,M,ATA,150,288,0,Normal,150,Y,3,Flat,1
|
| 26 |
+
40,M,NAP,130,215,0,Normal,138,N,0,Up,0
|
| 27 |
+
36,M,NAP,130,209,0,Normal,178,N,0,Up,0
|
| 28 |
+
53,M,ASY,124,260,0,ST,112,Y,3,Flat,0
|
| 29 |
+
52,M,ATA,120,284,0,Normal,118,N,0,Up,0
|
| 30 |
+
53,F,ATA,113,468,0,Normal,127,N,0,Up,0
|
| 31 |
+
51,M,ATA,125,188,0,Normal,145,N,0,Up,0
|
| 32 |
+
53,M,NAP,145,518,0,Normal,130,N,0,Flat,1
|
| 33 |
+
56,M,NAP,130,167,0,Normal,114,N,0,Up,0
|
| 34 |
+
54,M,ASY,125,224,0,Normal,122,N,2,Flat,1
|
| 35 |
+
41,M,ASY,130,172,0,ST,130,N,2,Flat,1
|
| 36 |
+
43,F,ATA,150,186,0,Normal,154,N,0,Up,0
|
| 37 |
+
32,M,ATA,125,254,0,Normal,155,N,0,Up,0
|
| 38 |
+
65,M,ASY,140,306,1,Normal,87,Y,1.5,Flat,1
|
| 39 |
+
41,F,ATA,110,250,0,ST,142,N,0,Up,0
|
| 40 |
+
48,F,ATA,120,177,1,ST,148,N,0,Up,0
|
| 41 |
+
48,F,ASY,150,227,0,Normal,130,Y,1,Flat,0
|
| 42 |
+
54,F,ATA,150,230,0,Normal,130,N,0,Up,0
|
| 43 |
+
54,F,NAP,130,294,0,ST,100,Y,0,Flat,1
|
| 44 |
+
35,M,ATA,150,264,0,Normal,168,N,0,Up,0
|
| 45 |
+
52,M,NAP,140,259,0,ST,170,N,0,Up,0
|
| 46 |
+
43,M,ASY,120,175,0,Normal,120,Y,1,Flat,1
|
| 47 |
+
59,M,NAP,130,318,0,Normal,120,Y,1,Flat,0
|
| 48 |
+
37,M,ASY,120,223,0,Normal,168,N,0,Up,0
|
| 49 |
+
50,M,ATA,140,216,0,Normal,170,N,0,Up,0
|
| 50 |
+
36,M,NAP,112,340,0,Normal,184,N,1,Flat,0
|
| 51 |
+
41,M,ASY,110,289,0,Normal,170,N,0,Flat,1
|
| 52 |
+
50,M,ASY,130,233,0,Normal,121,Y,2,Flat,1
|
| 53 |
+
47,F,ASY,120,205,0,Normal,98,Y,2,Flat,1
|
| 54 |
+
45,M,ATA,140,224,1,Normal,122,N,0,Up,0
|
| 55 |
+
41,F,ATA,130,245,0,Normal,150,N,0,Up,0
|
| 56 |
+
52,F,ASY,130,180,0,Normal,140,Y,1.5,Flat,0
|
| 57 |
+
51,F,ATA,160,194,0,Normal,170,N,0,Up,0
|
| 58 |
+
31,M,ASY,120,270,0,Normal,153,Y,1.5,Flat,1
|
| 59 |
+
58,M,NAP,130,213,0,ST,140,N,0,Flat,1
|
| 60 |
+
54,M,ASY,150,365,0,ST,134,N,1,Up,0
|
| 61 |
+
52,M,ASY,112,342,0,ST,96,Y,1,Flat,1
|
| 62 |
+
49,M,ATA,100,253,0,Normal,174,N,0,Up,0
|
| 63 |
+
43,F,NAP,150,254,0,Normal,175,N,0,Up,0
|
| 64 |
+
45,M,ASY,140,224,0,Normal,144,N,0,Up,0
|
| 65 |
+
46,M,ASY,120,277,0,Normal,125,Y,1,Flat,1
|
| 66 |
+
50,F,ATA,110,202,0,Normal,145,N,0,Up,0
|
| 67 |
+
37,F,ATA,120,260,0,Normal,130,N,0,Up,0
|
| 68 |
+
45,F,ASY,132,297,0,Normal,144,N,0,Up,0
|
| 69 |
+
32,M,ATA,110,225,0,Normal,184,N,0,Up,0
|
| 70 |
+
52,M,ASY,160,246,0,ST,82,Y,4,Flat,1
|
| 71 |
+
44,M,ASY,150,412,0,Normal,170,N,0,Up,0
|
| 72 |
+
57,M,ATA,140,265,0,ST,145,Y,1,Flat,1
|
| 73 |
+
44,M,ATA,130,215,0,Normal,135,N,0,Up,0
|
| 74 |
+
52,M,ASY,120,182,0,Normal,150,N,0,Flat,1
|
| 75 |
+
44,F,ASY,120,218,0,ST,115,N,0,Up,0
|
| 76 |
+
55,M,ASY,140,268,0,Normal,128,Y,1.5,Flat,1
|
| 77 |
+
46,M,NAP,150,163,0,Normal,116,N,0,Up,0
|
| 78 |
+
32,M,ASY,118,529,0,Normal,130,N,0,Flat,1
|
| 79 |
+
35,F,ASY,140,167,0,Normal,150,N,0,Up,0
|
| 80 |
+
52,M,ATA,140,100,0,Normal,138,Y,0,Up,0
|
| 81 |
+
49,M,ASY,130,206,0,Normal,170,N,0,Flat,1
|
| 82 |
+
55,M,NAP,110,277,0,Normal,160,N,0,Up,0
|
| 83 |
+
54,M,ATA,120,238,0,Normal,154,N,0,Up,0
|
| 84 |
+
63,M,ASY,150,223,0,Normal,115,N,0,Flat,1
|
| 85 |
+
52,M,ATA,160,196,0,Normal,165,N,0,Up,0
|
| 86 |
+
56,M,ASY,150,213,1,Normal,125,Y,1,Flat,1
|
| 87 |
+
66,M,ASY,140,139,0,Normal,94,Y,1,Flat,1
|
| 88 |
+
65,M,ASY,170,263,1,Normal,112,Y,2,Flat,1
|
| 89 |
+
53,F,ATA,140,216,0,Normal,142,Y,2,Flat,0
|
| 90 |
+
43,M,TA,120,291,0,ST,155,N,0,Flat,1
|
| 91 |
+
55,M,ASY,140,229,0,Normal,110,Y,0.5,Flat,0
|
| 92 |
+
49,F,ATA,110,208,0,Normal,160,N,0,Up,0
|
| 93 |
+
39,M,ASY,130,307,0,Normal,140,N,0,Up,0
|
| 94 |
+
52,F,ATA,120,210,0,Normal,148,N,0,Up,0
|
| 95 |
+
48,M,ASY,160,329,0,Normal,92,Y,1.5,Flat,1
|
| 96 |
+
39,F,NAP,110,182,0,ST,180,N,0,Up,0
|
| 97 |
+
58,M,ASY,130,263,0,Normal,140,Y,2,Flat,1
|
| 98 |
+
43,M,ATA,142,207,0,Normal,138,N,0,Up,0
|
| 99 |
+
39,M,NAP,160,147,1,Normal,160,N,0,Up,0
|
| 100 |
+
56,M,ASY,120,85,0,Normal,140,N,0,Up,0
|
| 101 |
+
41,M,ATA,125,269,0,Normal,144,N,0,Up,0
|
| 102 |
+
65,M,ASY,130,275,0,ST,115,Y,1,Flat,1
|
| 103 |
+
51,M,ASY,130,179,0,Normal,100,N,0,Up,0
|
| 104 |
+
40,F,ASY,150,392,0,Normal,130,N,2,Flat,1
|
| 105 |
+
40,M,ASY,120,466,1,Normal,152,Y,1,Flat,1
|
| 106 |
+
46,M,ASY,118,186,0,Normal,124,N,0,Flat,1
|
| 107 |
+
57,M,ATA,140,260,1,Normal,140,N,0,Up,0
|
| 108 |
+
48,F,ASY,120,254,0,ST,110,N,0,Up,0
|
| 109 |
+
34,M,ATA,150,214,0,ST,168,N,0,Up,0
|
| 110 |
+
50,M,ASY,140,129,0,Normal,135,N,0,Up,0
|
| 111 |
+
39,M,ATA,190,241,0,Normal,106,N,0,Up,0
|
| 112 |
+
59,F,ATA,130,188,0,Normal,124,N,1,Flat,0
|
| 113 |
+
57,M,ASY,150,255,0,Normal,92,Y,3,Flat,1
|
| 114 |
+
47,M,ASY,140,276,1,Normal,125,Y,0,Up,0
|
| 115 |
+
38,M,ATA,140,297,0,Normal,150,N,0,Up,0
|
| 116 |
+
49,F,NAP,130,207,0,ST,135,N,0,Up,0
|
| 117 |
+
33,F,ASY,100,246,0,Normal,150,Y,1,Flat,1
|
| 118 |
+
38,M,ASY,120,282,0,Normal,170,N,0,Flat,1
|
| 119 |
+
59,F,ASY,130,338,1,ST,130,Y,1.5,Flat,1
|
| 120 |
+
35,F,TA,120,160,0,ST,185,N,0,Up,0
|
| 121 |
+
34,M,TA,140,156,0,Normal,180,N,0,Flat,1
|
| 122 |
+
47,F,NAP,135,248,1,Normal,170,N,0,Flat,1
|
| 123 |
+
52,F,NAP,125,272,0,Normal,139,N,0,Up,0
|
| 124 |
+
46,M,ASY,110,240,0,ST,140,N,0,Up,0
|
| 125 |
+
58,F,ATA,180,393,0,Normal,110,Y,1,Flat,1
|
| 126 |
+
58,M,ATA,130,230,0,Normal,150,N,0,Up,0
|
| 127 |
+
54,M,ATA,120,246,0,Normal,110,N,0,Up,0
|
| 128 |
+
34,F,ATA,130,161,0,Normal,190,N,0,Up,0
|
| 129 |
+
48,F,ASY,108,163,0,Normal,175,N,2,Up,0
|
| 130 |
+
54,F,ATA,120,230,1,Normal,140,N,0,Up,0
|
| 131 |
+
42,M,NAP,120,228,0,Normal,152,Y,1.5,Flat,0
|
| 132 |
+
38,M,NAP,145,292,0,Normal,130,N,0,Up,0
|
| 133 |
+
46,M,ASY,110,202,0,Normal,150,Y,0,Flat,1
|
| 134 |
+
56,M,ASY,170,388,0,ST,122,Y,2,Flat,1
|
| 135 |
+
56,M,ASY,150,230,0,ST,124,Y,1.5,Flat,1
|
| 136 |
+
61,F,ASY,130,294,0,ST,120,Y,1,Flat,0
|
| 137 |
+
49,M,NAP,115,265,0,Normal,175,N,0,Flat,1
|
| 138 |
+
43,F,ATA,120,215,0,ST,175,N,0,Up,0
|
| 139 |
+
39,M,ATA,120,241,0,ST,146,N,2,Up,0
|
| 140 |
+
54,M,ASY,140,166,0,Normal,118,Y,0,Flat,1
|
| 141 |
+
43,M,ASY,150,247,0,Normal,130,Y,2,Flat,1
|
| 142 |
+
52,M,ASY,160,331,0,Normal,94,Y,2.5,Flat,1
|
| 143 |
+
50,M,ASY,140,341,0,ST,125,Y,2.5,Flat,1
|
| 144 |
+
47,M,ASY,160,291,0,ST,158,Y,3,Flat,1
|
| 145 |
+
53,M,ASY,140,243,0,Normal,155,N,0,Up,0
|
| 146 |
+
56,F,ATA,120,279,0,Normal,150,N,1,Flat,1
|
| 147 |
+
39,M,ASY,110,273,0,Normal,132,N,0,Up,0
|
| 148 |
+
42,M,ATA,120,198,0,Normal,155,N,0,Up,0
|
| 149 |
+
43,F,ATA,120,249,0,ST,176,N,0,Up,0
|
| 150 |
+
50,M,ATA,120,168,0,Normal,160,N,0,Up,0
|
| 151 |
+
54,M,ASY,130,603,1,Normal,125,Y,1,Flat,1
|
| 152 |
+
39,M,ATA,130,215,0,Normal,120,N,0,Up,0
|
| 153 |
+
48,M,ATA,100,159,0,Normal,100,N,0,Up,0
|
| 154 |
+
40,M,ATA,130,275,0,Normal,150,N,0,Up,0
|
| 155 |
+
55,M,ASY,120,270,0,Normal,140,N,0,Up,0
|
| 156 |
+
41,M,ATA,120,291,0,ST,160,N,0,Up,0
|
| 157 |
+
56,M,ASY,155,342,1,Normal,150,Y,3,Flat,1
|
| 158 |
+
38,M,ASY,110,190,0,Normal,150,Y,1,Flat,1
|
| 159 |
+
49,M,ASY,140,185,0,Normal,130,N,0,Up,0
|
| 160 |
+
44,M,ASY,130,290,0,Normal,100,Y,2,Flat,1
|
| 161 |
+
54,M,ATA,160,195,0,ST,130,N,1,Up,0
|
| 162 |
+
59,M,ASY,140,264,1,LVH,119,Y,0,Flat,1
|
| 163 |
+
49,M,ASY,128,212,0,Normal,96,Y,0,Flat,1
|
| 164 |
+
47,M,ATA,160,263,0,Normal,174,N,0,Up,0
|
| 165 |
+
42,M,ATA,120,196,0,Normal,150,N,0,Up,0
|
| 166 |
+
52,F,ATA,140,225,0,Normal,140,N,0,Up,0
|
| 167 |
+
46,M,TA,140,272,1,Normal,175,N,2,Flat,1
|
| 168 |
+
50,M,ASY,140,231,0,ST,140,Y,5,Flat,1
|
| 169 |
+
48,M,ATA,140,238,0,Normal,118,N,0,Up,0
|
| 170 |
+
58,M,ASY,135,222,0,Normal,100,N,0,Up,0
|
| 171 |
+
58,M,NAP,140,179,0,Normal,160,N,0,Up,0
|
| 172 |
+
29,M,ATA,120,243,0,Normal,160,N,0,Up,0
|
| 173 |
+
40,M,NAP,140,235,0,Normal,188,N,0,Up,0
|
| 174 |
+
53,M,ATA,140,320,0,Normal,162,N,0,Up,0
|
| 175 |
+
49,M,NAP,140,187,0,Normal,172,N,0,Up,0
|
| 176 |
+
52,M,ASY,140,266,0,Normal,134,Y,2,Flat,1
|
| 177 |
+
43,M,ASY,140,288,0,Normal,135,Y,2,Flat,1
|
| 178 |
+
54,M,ASY,140,216,0,Normal,105,N,1.5,Flat,1
|
| 179 |
+
59,M,ATA,140,287,0,Normal,150,N,0,Up,0
|
| 180 |
+
37,M,NAP,130,194,0,Normal,150,N,0,Up,0
|
| 181 |
+
46,F,ASY,130,238,0,Normal,90,N,0,Up,0
|
| 182 |
+
52,M,ASY,130,225,0,Normal,120,Y,2,Flat,1
|
| 183 |
+
51,M,ATA,130,224,0,Normal,150,N,0,Up,0
|
| 184 |
+
52,M,ASY,140,404,0,Normal,124,Y,2,Flat,1
|
| 185 |
+
46,M,ASY,110,238,0,ST,140,Y,1,Flat,0
|
| 186 |
+
54,F,ATA,160,312,0,Normal,130,N,0,Up,0
|
| 187 |
+
58,M,NAP,160,211,1,ST,92,N,0,Flat,1
|
| 188 |
+
58,M,ATA,130,251,0,Normal,110,N,0,Up,0
|
| 189 |
+
41,M,ASY,120,237,1,Normal,138,Y,1,Flat,1
|
| 190 |
+
50,F,ASY,120,328,0,Normal,110,Y,1,Flat,0
|
| 191 |
+
53,M,ASY,180,285,0,ST,120,Y,1.5,Flat,1
|
| 192 |
+
46,M,ASY,180,280,0,ST,120,N,0,Up,0
|
| 193 |
+
50,M,ATA,170,209,0,ST,116,N,0,Up,0
|
| 194 |
+
48,M,ATA,130,245,0,Normal,160,N,0,Up,0
|
| 195 |
+
45,M,NAP,135,192,0,Normal,110,N,0,Up,0
|
| 196 |
+
41,F,ATA,125,184,0,Normal,180,N,0,Up,0
|
| 197 |
+
62,F,TA,160,193,0,Normal,116,N,0,Up,0
|
| 198 |
+
49,M,ASY,120,297,0,Normal,132,N,1,Flat,0
|
| 199 |
+
42,M,ATA,150,268,0,Normal,136,N,0,Up,0
|
| 200 |
+
53,M,ASY,120,246,0,Normal,116,Y,0,Flat,1
|
| 201 |
+
57,F,TA,130,308,0,Normal,98,N,1,Flat,0
|
| 202 |
+
47,M,TA,110,249,0,Normal,150,N,0,Up,0
|
| 203 |
+
46,M,NAP,120,230,0,Normal,150,N,0,Up,0
|
| 204 |
+
42,M,NAP,160,147,0,Normal,146,N,0,Up,0
|
| 205 |
+
31,F,ATA,100,219,0,ST,150,N,0,Up,0
|
| 206 |
+
56,M,ATA,130,184,0,Normal,100,N,0,Up,0
|
| 207 |
+
50,M,ASY,150,215,0,Normal,140,Y,0,Up,0
|
| 208 |
+
35,M,ATA,120,308,0,LVH,180,N,0,Up,0
|
| 209 |
+
35,M,ATA,110,257,0,Normal,140,N,0,Flat,1
|
| 210 |
+
28,M,ATA,130,132,0,LVH,185,N,0,Up,0
|
| 211 |
+
54,M,ASY,125,216,0,Normal,140,N,0,Flat,1
|
| 212 |
+
48,M,ASY,106,263,1,Normal,110,N,0,Flat,1
|
| 213 |
+
50,F,NAP,140,288,0,Normal,140,Y,0,Flat,1
|
| 214 |
+
56,M,NAP,130,276,0,Normal,128,Y,1,Up,0
|
| 215 |
+
56,F,NAP,130,219,0,ST,164,N,0,Up,0
|
| 216 |
+
47,M,ASY,150,226,0,Normal,98,Y,1.5,Flat,1
|
| 217 |
+
30,F,TA,170,237,0,ST,170,N,0,Up,0
|
| 218 |
+
39,M,ASY,110,280,0,Normal,150,N,0,Flat,1
|
| 219 |
+
54,M,NAP,120,217,0,Normal,137,N,0,Up,0
|
| 220 |
+
55,M,ATA,140,196,0,Normal,150,N,0,Up,0
|
| 221 |
+
29,M,ATA,140,263,0,Normal,170,N,0,Up,0
|
| 222 |
+
46,M,ASY,130,222,0,Normal,112,N,0,Flat,1
|
| 223 |
+
51,F,ASY,160,303,0,Normal,150,Y,1,Flat,1
|
| 224 |
+
48,F,NAP,120,195,0,Normal,125,N,0,Up,0
|
| 225 |
+
33,M,NAP,120,298,0,Normal,185,N,0,Up,0
|
| 226 |
+
55,M,ATA,120,256,1,Normal,137,N,0,Up,0
|
| 227 |
+
50,M,ASY,145,264,0,Normal,150,N,0,Flat,1
|
| 228 |
+
53,M,NAP,120,195,0,Normal,140,N,0,Up,0
|
| 229 |
+
38,M,ASY,92,117,0,Normal,134,Y,2.5,Flat,1
|
| 230 |
+
41,M,ATA,120,295,0,Normal,170,N,0,Up,0
|
| 231 |
+
37,F,ASY,130,173,0,ST,184,N,0,Up,0
|
| 232 |
+
37,M,ASY,130,315,0,Normal,158,N,0,Up,0
|
| 233 |
+
40,M,NAP,130,281,0,Normal,167,N,0,Up,0
|
| 234 |
+
38,F,ATA,120,275,0,Normal,129,N,0,Up,0
|
| 235 |
+
41,M,ASY,112,250,0,Normal,142,N,0,Up,0
|
| 236 |
+
54,F,ATA,140,309,0,ST,140,N,0,Up,0
|
| 237 |
+
39,M,ATA,120,200,0,Normal,160,Y,1,Flat,0
|
| 238 |
+
41,M,ASY,120,336,0,Normal,118,Y,3,Flat,1
|
| 239 |
+
55,M,TA,140,295,0,Normal,136,N,0,Flat,1
|
| 240 |
+
48,M,ASY,160,355,0,Normal,99,Y,2,Flat,1
|
| 241 |
+
48,M,ASY,160,193,0,Normal,102,Y,3,Flat,1
|
| 242 |
+
55,M,ATA,145,326,0,Normal,155,N,0,Up,0
|
| 243 |
+
54,M,ASY,200,198,0,Normal,142,Y,2,Flat,1
|
| 244 |
+
55,M,ATA,160,292,1,Normal,143,Y,2,Flat,1
|
| 245 |
+
43,F,ATA,120,266,0,Normal,118,N,0,Up,0
|
| 246 |
+
48,M,ASY,160,268,0,Normal,103,Y,1,Flat,1
|
| 247 |
+
54,M,TA,120,171,0,Normal,137,N,2,Up,0
|
| 248 |
+
54,M,NAP,120,237,0,Normal,150,Y,1.5,Flat,1
|
| 249 |
+
48,M,ASY,122,275,1,ST,150,Y,2,Down,1
|
| 250 |
+
45,M,ASY,130,219,0,ST,130,Y,1,Flat,1
|
| 251 |
+
49,M,ASY,130,341,0,Normal,120,Y,1,Flat,1
|
| 252 |
+
44,M,ASY,135,491,0,Normal,135,N,0,Flat,1
|
| 253 |
+
48,M,ASY,120,260,0,Normal,115,N,2,Flat,1
|
| 254 |
+
61,M,ASY,125,292,0,ST,115,Y,0,Up,0
|
| 255 |
+
62,M,ATA,140,271,0,Normal,152,N,1,Up,0
|
| 256 |
+
55,M,ASY,145,248,0,Normal,96,Y,2,Flat,1
|
| 257 |
+
53,F,NAP,120,274,0,Normal,130,N,0,Up,0
|
| 258 |
+
55,F,ATA,130,394,0,LVH,150,N,0,Up,0
|
| 259 |
+
36,M,NAP,150,160,0,Normal,172,N,0,Up,0
|
| 260 |
+
51,F,NAP,150,200,0,Normal,120,N,0.5,Up,0
|
| 261 |
+
55,F,ATA,122,320,0,Normal,155,N,0,Up,0
|
| 262 |
+
46,M,ATA,140,275,0,Normal,165,Y,0,Up,0
|
| 263 |
+
54,F,ATA,120,221,0,Normal,138,N,1,Up,0
|
| 264 |
+
46,M,ASY,120,231,0,Normal,115,Y,0,Flat,1
|
| 265 |
+
59,M,ASY,130,126,0,Normal,125,N,0,Flat,1
|
| 266 |
+
47,M,NAP,140,193,0,Normal,145,Y,1,Flat,1
|
| 267 |
+
54,M,ATA,160,305,0,Normal,175,N,0,Up,0
|
| 268 |
+
52,M,ASY,130,298,0,Normal,110,Y,1,Flat,1
|
| 269 |
+
34,M,ATA,98,220,0,Normal,150,N,0,Up,0
|
| 270 |
+
54,M,ASY,130,242,0,Normal,91,Y,1,Flat,1
|
| 271 |
+
47,F,NAP,130,235,0,Normal,145,N,2,Flat,0
|
| 272 |
+
45,M,ASY,120,225,0,Normal,140,N,0,Up,0
|
| 273 |
+
32,F,ATA,105,198,0,Normal,165,N,0,Up,0
|
| 274 |
+
55,M,ASY,140,201,0,Normal,130,Y,3,Flat,1
|
| 275 |
+
55,M,NAP,120,220,0,LVH,134,N,0,Up,0
|
| 276 |
+
45,F,ATA,180,295,0,Normal,180,N,0,Up,0
|
| 277 |
+
59,M,NAP,180,213,0,Normal,100,N,0,Up,0
|
| 278 |
+
51,M,NAP,135,160,0,Normal,150,N,2,Flat,1
|
| 279 |
+
52,M,ASY,170,223,0,Normal,126,Y,1.5,Flat,1
|
| 280 |
+
57,F,ASY,180,347,0,ST,126,Y,0.8,Flat,0
|
| 281 |
+
54,F,ATA,130,253,0,ST,155,N,0,Up,0
|
| 282 |
+
60,M,NAP,120,246,0,LVH,135,N,0,Up,0
|
| 283 |
+
49,M,ASY,150,222,0,Normal,122,N,2,Flat,1
|
| 284 |
+
51,F,NAP,130,220,0,Normal,160,Y,2,Up,0
|
| 285 |
+
55,F,ATA,110,344,0,ST,160,N,0,Up,0
|
| 286 |
+
42,M,ASY,140,358,0,Normal,170,N,0,Up,0
|
| 287 |
+
51,F,NAP,110,190,0,Normal,120,N,0,Up,0
|
| 288 |
+
59,M,ASY,140,169,0,Normal,140,N,0,Up,0
|
| 289 |
+
53,M,ATA,120,181,0,Normal,132,N,0,Up,0
|
| 290 |
+
48,F,ATA,133,308,0,ST,156,N,2,Up,0
|
| 291 |
+
36,M,ATA,120,166,0,Normal,180,N,0,Up,0
|
| 292 |
+
48,M,NAP,110,211,0,Normal,138,N,0,Up,0
|
| 293 |
+
47,F,ATA,140,257,0,Normal,135,N,1,Up,0
|
| 294 |
+
53,M,ASY,130,182,0,Normal,148,N,0,Up,0
|
| 295 |
+
65,M,ASY,115,0,0,Normal,93,Y,0,Flat,1
|
| 296 |
+
32,M,TA,95,0,1,Normal,127,N,0.7,Up,1
|
| 297 |
+
61,M,ASY,105,0,1,Normal,110,Y,1.5,Up,1
|
| 298 |
+
50,M,ASY,145,0,1,Normal,139,Y,0.7,Flat,1
|
| 299 |
+
57,M,ASY,110,0,1,ST,131,Y,1.4,Up,1
|
| 300 |
+
51,M,ASY,110,0,1,Normal,92,N,0,Flat,1
|
| 301 |
+
47,M,ASY,110,0,1,ST,149,N,2.1,Up,1
|
| 302 |
+
60,M,ASY,160,0,1,Normal,149,N,0.4,Flat,1
|
| 303 |
+
55,M,ATA,140,0,0,ST,150,N,0.2,Up,0
|
| 304 |
+
53,M,ASY,125,0,1,Normal,120,N,1.5,Up,1
|
| 305 |
+
62,F,ASY,120,0,1,ST,123,Y,1.7,Down,1
|
| 306 |
+
51,M,ASY,95,0,1,Normal,126,N,2.2,Flat,1
|
| 307 |
+
51,F,ASY,120,0,1,Normal,127,Y,1.5,Up,1
|
| 308 |
+
55,M,ASY,115,0,1,Normal,155,N,0.1,Flat,1
|
| 309 |
+
53,M,ATA,130,0,0,ST,120,N,0.7,Down,0
|
| 310 |
+
58,M,ASY,115,0,1,Normal,138,N,0.5,Up,1
|
| 311 |
+
57,M,ASY,95,0,1,Normal,182,N,0.7,Down,1
|
| 312 |
+
65,M,ASY,155,0,0,Normal,154,N,1,Up,0
|
| 313 |
+
60,M,ASY,125,0,1,Normal,110,N,0.1,Up,1
|
| 314 |
+
41,M,ASY,125,0,1,Normal,176,N,1.6,Up,1
|
| 315 |
+
34,M,ASY,115,0,1,Normal,154,N,0.2,Up,1
|
| 316 |
+
53,M,ASY,80,0,0,Normal,141,Y,2,Down,0
|
| 317 |
+
74,M,ATA,145,0,1,ST,123,N,1.3,Up,1
|
| 318 |
+
57,M,NAP,105,0,1,Normal,148,N,0.3,Flat,1
|
| 319 |
+
56,M,ASY,140,0,1,Normal,121,Y,1.8,Up,1
|
| 320 |
+
61,M,ASY,130,0,1,Normal,77,N,2.5,Flat,1
|
| 321 |
+
68,M,ASY,145,0,1,Normal,136,N,1.8,Up,1
|
| 322 |
+
59,M,NAP,125,0,1,Normal,175,N,2.6,Flat,1
|
| 323 |
+
63,M,ASY,100,0,1,Normal,109,N,-0.9,Flat,1
|
| 324 |
+
38,F,ASY,105,0,1,Normal,166,N,2.8,Up,1
|
| 325 |
+
62,M,ASY,115,0,1,Normal,128,Y,2.5,Down,1
|
| 326 |
+
46,M,ASY,100,0,1,ST,133,N,-2.6,Flat,1
|
| 327 |
+
42,M,ASY,105,0,1,Normal,128,Y,-1.5,Down,1
|
| 328 |
+
45,M,NAP,110,0,0,Normal,138,N,-0.1,Up,0
|
| 329 |
+
59,M,ASY,125,0,1,Normal,119,Y,0.9,Up,1
|
| 330 |
+
52,M,ASY,95,0,1,Normal,82,Y,0.8,Flat,1
|
| 331 |
+
60,M,ASY,130,0,1,ST,130,Y,1.1,Down,1
|
| 332 |
+
60,M,NAP,115,0,1,Normal,143,N,2.4,Up,1
|
| 333 |
+
56,M,ASY,115,0,1,ST,82,N,-1,Up,1
|
| 334 |
+
38,M,NAP,100,0,0,Normal,179,N,-1.1,Up,0
|
| 335 |
+
40,M,ASY,95,0,1,ST,144,N,0,Up,1
|
| 336 |
+
51,M,ASY,130,0,1,Normal,170,N,-0.7,Up,1
|
| 337 |
+
62,M,TA,120,0,1,LVH,134,N,-0.8,Flat,1
|
| 338 |
+
72,M,NAP,160,0,0,LVH,114,N,1.6,Flat,0
|
| 339 |
+
63,M,ASY,150,0,1,ST,154,N,3.7,Up,1
|
| 340 |
+
63,M,ASY,140,0,1,LVH,149,N,2,Up,1
|
| 341 |
+
64,F,ASY,95,0,1,Normal,145,N,1.1,Down,1
|
| 342 |
+
43,M,ASY,100,0,1,Normal,122,N,1.5,Down,1
|
| 343 |
+
64,M,ASY,110,0,1,Normal,114,Y,1.3,Down,1
|
| 344 |
+
61,M,ASY,110,0,1,Normal,113,N,1.4,Flat,1
|
| 345 |
+
52,M,ASY,130,0,1,Normal,120,N,0,Flat,1
|
| 346 |
+
51,M,ASY,120,0,1,Normal,104,N,0,Flat,1
|
| 347 |
+
69,M,ASY,135,0,0,Normal,130,N,0,Flat,1
|
| 348 |
+
59,M,ASY,120,0,0,Normal,115,N,0,Flat,1
|
| 349 |
+
48,M,ASY,115,0,1,Normal,128,N,0,Flat,1
|
| 350 |
+
69,M,ASY,137,0,0,ST,104,Y,1.6,Flat,1
|
| 351 |
+
36,M,ASY,110,0,1,Normal,125,Y,1,Flat,1
|
| 352 |
+
53,M,ASY,120,0,1,Normal,120,N,0,Flat,1
|
| 353 |
+
43,M,ASY,140,0,0,ST,140,Y,0.5,Up,1
|
| 354 |
+
56,M,ASY,120,0,0,ST,100,Y,-1,Down,1
|
| 355 |
+
58,M,ASY,130,0,0,ST,100,Y,1,Flat,1
|
| 356 |
+
55,M,ASY,120,0,0,ST,92,N,0.3,Up,1
|
| 357 |
+
67,M,TA,145,0,0,LVH,125,N,0,Flat,1
|
| 358 |
+
46,M,ASY,115,0,0,Normal,113,Y,1.5,Flat,1
|
| 359 |
+
53,M,ATA,120,0,0,Normal,95,N,0,Flat,1
|
| 360 |
+
38,M,NAP,115,0,0,Normal,128,Y,0,Flat,1
|
| 361 |
+
53,M,NAP,105,0,0,Normal,115,N,0,Flat,1
|
| 362 |
+
62,M,NAP,160,0,0,Normal,72,Y,0,Flat,1
|
| 363 |
+
47,M,ASY,160,0,0,Normal,124,Y,0,Flat,1
|
| 364 |
+
56,M,NAP,155,0,0,ST,99,N,0,Flat,1
|
| 365 |
+
56,M,ASY,120,0,0,ST,148,N,0,Flat,1
|
| 366 |
+
56,M,NAP,120,0,0,Normal,97,N,0,Flat,0
|
| 367 |
+
64,F,ASY,200,0,0,Normal,140,Y,1,Flat,1
|
| 368 |
+
61,M,ASY,150,0,0,Normal,117,Y,2,Flat,1
|
| 369 |
+
68,M,ASY,135,0,0,ST,120,Y,0,Up,1
|
| 370 |
+
57,M,ASY,140,0,0,Normal,120,Y,2,Flat,1
|
| 371 |
+
63,M,ASY,150,0,0,Normal,86,Y,2,Flat,1
|
| 372 |
+
60,M,ASY,135,0,0,Normal,63,Y,0.5,Up,1
|
| 373 |
+
66,M,ASY,150,0,0,Normal,108,Y,2,Flat,1
|
| 374 |
+
63,M,ASY,185,0,0,Normal,98,Y,0,Up,1
|
| 375 |
+
59,M,ASY,135,0,0,Normal,115,Y,1,Flat,1
|
| 376 |
+
61,M,ASY,125,0,0,Normal,105,Y,0,Down,1
|
| 377 |
+
73,F,NAP,160,0,0,ST,121,N,0,Up,1
|
| 378 |
+
47,M,NAP,155,0,0,Normal,118,Y,1,Flat,1
|
| 379 |
+
65,M,ASY,160,0,1,ST,122,N,1.2,Flat,1
|
| 380 |
+
70,M,ASY,140,0,1,Normal,157,Y,2,Flat,1
|
| 381 |
+
50,M,ASY,120,0,0,ST,156,Y,0,Up,1
|
| 382 |
+
60,M,ASY,160,0,0,ST,99,Y,0.5,Flat,1
|
| 383 |
+
50,M,ASY,115,0,0,Normal,120,Y,0.5,Flat,1
|
| 384 |
+
43,M,ASY,115,0,0,Normal,145,Y,2,Flat,1
|
| 385 |
+
38,F,ASY,110,0,0,Normal,156,N,0,Flat,1
|
| 386 |
+
54,M,ASY,120,0,0,Normal,155,N,0,Flat,1
|
| 387 |
+
61,M,ASY,150,0,0,Normal,105,Y,0,Flat,1
|
| 388 |
+
42,M,ASY,145,0,0,Normal,99,Y,0,Flat,1
|
| 389 |
+
53,M,ASY,130,0,0,LVH,135,Y,1,Flat,1
|
| 390 |
+
55,M,ASY,140,0,0,Normal,83,N,0,Flat,1
|
| 391 |
+
61,M,ASY,160,0,1,ST,145,N,1,Flat,1
|
| 392 |
+
51,M,ASY,140,0,0,Normal,60,N,0,Flat,1
|
| 393 |
+
70,M,ASY,115,0,0,ST,92,Y,0,Flat,1
|
| 394 |
+
61,M,ASY,130,0,0,LVH,115,N,0,Flat,1
|
| 395 |
+
38,M,ASY,150,0,1,Normal,120,Y,0.7,Flat,1
|
| 396 |
+
57,M,ASY,160,0,1,Normal,98,Y,2,Flat,1
|
| 397 |
+
38,M,ASY,135,0,1,Normal,150,N,0,Flat,1
|
| 398 |
+
62,F,TA,140,0,1,Normal,143,N,0,Flat,1
|
| 399 |
+
58,M,ASY,170,0,1,ST,105,Y,0,Flat,1
|
| 400 |
+
52,M,ASY,165,0,1,Normal,122,Y,1,Up,1
|
| 401 |
+
61,M,NAP,200,0,1,ST,70,N,0,Flat,1
|
| 402 |
+
50,F,ASY,160,0,1,Normal,110,N,0,Flat,1
|
| 403 |
+
51,M,ASY,130,0,1,ST,163,N,0,Flat,1
|
| 404 |
+
65,M,ASY,145,0,1,ST,67,N,0.7,Flat,1
|
| 405 |
+
52,M,ASY,135,0,1,Normal,128,Y,2,Flat,1
|
| 406 |
+
47,M,NAP,110,0,1,Normal,120,Y,0,Flat,1
|
| 407 |
+
35,M,ASY,120,0,1,Normal,130,Y,1.2,Flat,1
|
| 408 |
+
57,M,ASY,140,0,1,Normal,100,Y,0,Flat,1
|
| 409 |
+
62,M,ASY,115,0,1,Normal,72,Y,-0.5,Flat,1
|
| 410 |
+
59,M,ASY,110,0,1,Normal,94,N,0,Flat,1
|
| 411 |
+
53,M,NAP,160,0,1,LVH,122,Y,0,Flat,1
|
| 412 |
+
62,M,ASY,150,0,1,ST,78,N,2,Flat,1
|
| 413 |
+
54,M,ASY,180,0,1,Normal,150,N,1.5,Flat,1
|
| 414 |
+
56,M,ASY,125,0,1,Normal,103,Y,1,Flat,1
|
| 415 |
+
56,M,NAP,125,0,1,Normal,98,N,-2,Flat,1
|
| 416 |
+
54,M,ASY,130,0,1,Normal,110,Y,3,Flat,1
|
| 417 |
+
66,F,ASY,155,0,1,Normal,90,N,0,Flat,1
|
| 418 |
+
63,M,ASY,140,260,0,ST,112,Y,3,Flat,1
|
| 419 |
+
44,M,ASY,130,209,0,ST,127,N,0,Up,0
|
| 420 |
+
60,M,ASY,132,218,0,ST,140,Y,1.5,Down,1
|
| 421 |
+
55,M,ASY,142,228,0,ST,149,Y,2.5,Up,1
|
| 422 |
+
66,M,NAP,110,213,1,LVH,99,Y,1.3,Flat,0
|
| 423 |
+
66,M,NAP,120,0,0,ST,120,N,-0.5,Up,0
|
| 424 |
+
65,M,ASY,150,236,1,ST,105,Y,0,Flat,1
|
| 425 |
+
60,M,NAP,180,0,0,ST,140,Y,1.5,Flat,0
|
| 426 |
+
60,M,NAP,120,0,1,Normal,141,Y,2,Up,1
|
| 427 |
+
60,M,ATA,160,267,1,ST,157,N,0.5,Flat,1
|
| 428 |
+
56,M,ATA,126,166,0,ST,140,N,0,Up,0
|
| 429 |
+
59,M,ASY,140,0,0,ST,117,Y,1,Flat,1
|
| 430 |
+
62,M,ASY,110,0,0,Normal,120,Y,0.5,Flat,1
|
| 431 |
+
63,M,NAP,133,0,0,LVH,120,Y,1,Flat,1
|
| 432 |
+
57,M,ASY,128,0,1,ST,148,Y,1,Flat,1
|
| 433 |
+
62,M,ASY,120,220,0,ST,86,N,0,Up,0
|
| 434 |
+
63,M,ASY,170,177,0,Normal,84,Y,2.5,Down,1
|
| 435 |
+
46,M,ASY,110,236,0,Normal,125,Y,2,Flat,1
|
| 436 |
+
63,M,ASY,126,0,0,ST,120,N,1.5,Down,0
|
| 437 |
+
60,M,ASY,152,0,0,ST,118,Y,0,Up,0
|
| 438 |
+
58,M,ASY,116,0,0,Normal,124,N,1,Up,1
|
| 439 |
+
64,M,ASY,120,0,1,ST,106,N,2,Flat,1
|
| 440 |
+
63,M,NAP,130,0,0,ST,111,Y,0,Flat,1
|
| 441 |
+
74,M,NAP,138,0,0,Normal,116,N,0.2,Up,0
|
| 442 |
+
52,M,NAP,128,0,0,ST,180,N,3,Up,1
|
| 443 |
+
69,M,ASY,130,0,1,ST,129,N,1,Flat,1
|
| 444 |
+
51,M,ASY,128,0,1,ST,125,Y,1.2,Flat,1
|
| 445 |
+
60,M,ASY,130,186,1,ST,140,Y,0.5,Flat,1
|
| 446 |
+
56,M,ASY,120,100,0,Normal,120,Y,1.5,Flat,1
|
| 447 |
+
55,M,NAP,136,228,0,ST,124,Y,1.6,Flat,1
|
| 448 |
+
54,M,ASY,130,0,0,ST,117,Y,1.4,Flat,1
|
| 449 |
+
77,M,ASY,124,171,0,ST,110,Y,2,Up,1
|
| 450 |
+
63,M,ASY,160,230,1,Normal,105,Y,1,Flat,1
|
| 451 |
+
55,M,NAP,0,0,0,Normal,155,N,1.5,Flat,1
|
| 452 |
+
52,M,NAP,122,0,0,Normal,110,Y,2,Down,1
|
| 453 |
+
64,M,ASY,144,0,0,ST,122,Y,1,Flat,1
|
| 454 |
+
60,M,ASY,140,281,0,ST,118,Y,1.5,Flat,1
|
| 455 |
+
60,M,ASY,120,0,0,Normal,133,Y,2,Up,0
|
| 456 |
+
58,M,ASY,136,203,1,Normal,123,Y,1.2,Flat,1
|
| 457 |
+
59,M,ASY,154,0,0,ST,131,Y,1.5,Up,0
|
| 458 |
+
61,M,NAP,120,0,0,Normal,80,Y,0,Flat,1
|
| 459 |
+
40,M,ASY,125,0,1,Normal,165,N,0,Flat,1
|
| 460 |
+
61,M,ASY,134,0,1,ST,86,N,1.5,Flat,1
|
| 461 |
+
41,M,ASY,104,0,0,ST,111,N,0,Up,0
|
| 462 |
+
57,M,ASY,139,277,1,ST,118,Y,1.9,Flat,1
|
| 463 |
+
63,M,ASY,136,0,0,Normal,84,Y,0,Flat,1
|
| 464 |
+
59,M,ASY,122,233,0,Normal,117,Y,1.3,Down,1
|
| 465 |
+
51,M,ASY,128,0,0,Normal,107,N,0,Up,0
|
| 466 |
+
59,M,NAP,131,0,0,Normal,128,Y,2,Down,1
|
| 467 |
+
42,M,NAP,134,240,0,Normal,160,N,0,Up,0
|
| 468 |
+
55,M,NAP,120,0,0,ST,125,Y,2.5,Flat,1
|
| 469 |
+
63,F,ATA,132,0,0,Normal,130,N,0.1,Up,0
|
| 470 |
+
62,M,ASY,152,153,0,ST,97,Y,1.6,Up,1
|
| 471 |
+
56,M,ATA,124,224,1,Normal,161,N,2,Flat,0
|
| 472 |
+
53,M,ASY,126,0,0,Normal,106,N,0,Flat,1
|
| 473 |
+
68,M,ASY,138,0,0,Normal,130,Y,3,Flat,1
|
| 474 |
+
53,M,ASY,154,0,1,ST,140,Y,1.5,Flat,1
|
| 475 |
+
60,M,NAP,141,316,1,ST,122,Y,1.7,Flat,1
|
| 476 |
+
62,M,ATA,131,0,0,Normal,130,N,0.1,Up,0
|
| 477 |
+
59,M,ASY,178,0,1,LVH,120,Y,0,Flat,1
|
| 478 |
+
51,M,ASY,132,218,1,LVH,139,N,0.1,Up,0
|
| 479 |
+
61,M,ASY,110,0,1,Normal,108,Y,2,Down,1
|
| 480 |
+
57,M,ASY,130,311,1,ST,148,Y,2,Flat,1
|
| 481 |
+
56,M,NAP,170,0,0,LVH,123,Y,2.5,Flat,1
|
| 482 |
+
58,M,ATA,126,0,1,Normal,110,Y,2,Flat,1
|
| 483 |
+
69,M,NAP,140,0,1,ST,118,N,2.5,Down,1
|
| 484 |
+
67,M,TA,142,270,1,Normal,125,N,2.5,Up,1
|
| 485 |
+
58,M,ASY,120,0,0,LVH,106,Y,1.5,Down,1
|
| 486 |
+
65,M,ASY,134,0,0,Normal,112,Y,1.1,Flat,1
|
| 487 |
+
63,M,ATA,139,217,1,ST,128,Y,1.2,Flat,1
|
| 488 |
+
55,M,ATA,110,214,1,ST,180,N,0.4,Up,0
|
| 489 |
+
57,M,ASY,140,214,0,ST,144,Y,2,Flat,1
|
| 490 |
+
65,M,TA,140,252,0,Normal,135,N,0.3,Up,0
|
| 491 |
+
54,M,ASY,136,220,0,Normal,140,Y,3,Flat,1
|
| 492 |
+
72,M,NAP,120,214,0,Normal,102,Y,1,Flat,1
|
| 493 |
+
75,M,ASY,170,203,1,ST,108,N,0,Flat,1
|
| 494 |
+
49,M,TA,130,0,0,ST,145,N,3,Flat,1
|
| 495 |
+
51,M,NAP,137,339,0,Normal,127,Y,1.7,Flat,1
|
| 496 |
+
60,M,ASY,142,216,0,Normal,110,Y,2.5,Flat,1
|
| 497 |
+
64,F,ASY,142,276,0,Normal,140,Y,1,Flat,1
|
| 498 |
+
58,M,ASY,132,458,1,Normal,69,N,1,Down,0
|
| 499 |
+
61,M,ASY,146,241,0,Normal,148,Y,3,Down,1
|
| 500 |
+
67,M,ASY,160,384,1,ST,130,Y,0,Flat,1
|
| 501 |
+
62,M,ASY,135,297,0,Normal,130,Y,1,Flat,1
|
| 502 |
+
65,M,ASY,136,248,0,Normal,140,Y,4,Down,1
|
| 503 |
+
63,M,ASY,130,308,0,Normal,138,Y,2,Flat,1
|
| 504 |
+
69,M,ASY,140,208,0,ST,140,Y,2,Flat,1
|
| 505 |
+
51,M,ASY,132,227,1,ST,138,N,0.2,Up,0
|
| 506 |
+
62,M,ASY,158,210,1,Normal,112,Y,3,Down,1
|
| 507 |
+
55,M,NAP,136,245,1,ST,131,Y,1.2,Flat,1
|
| 508 |
+
75,M,ASY,136,225,0,Normal,112,Y,3,Flat,1
|
| 509 |
+
40,M,NAP,106,240,0,Normal,80,Y,0,Up,0
|
| 510 |
+
67,M,ASY,120,0,1,Normal,150,N,1.5,Down,1
|
| 511 |
+
58,M,ASY,110,198,0,Normal,110,N,0,Flat,1
|
| 512 |
+
60,M,ASY,136,195,0,Normal,126,N,0.3,Up,0
|
| 513 |
+
63,M,ASY,160,267,1,ST,88,Y,2,Flat,1
|
| 514 |
+
35,M,NAP,123,161,0,ST,153,N,-0.1,Up,0
|
| 515 |
+
62,M,TA,112,258,0,ST,150,Y,1.3,Flat,1
|
| 516 |
+
43,M,ASY,122,0,0,Normal,120,N,0.5,Up,1
|
| 517 |
+
63,M,NAP,130,0,1,ST,160,N,3,Flat,0
|
| 518 |
+
68,M,NAP,150,195,1,Normal,132,N,0,Flat,1
|
| 519 |
+
65,M,ASY,150,235,0,Normal,120,Y,1.5,Flat,1
|
| 520 |
+
48,M,NAP,102,0,1,ST,110,Y,1,Down,1
|
| 521 |
+
63,M,ASY,96,305,0,ST,121,Y,1,Up,1
|
| 522 |
+
64,M,ASY,130,223,0,ST,128,N,0.5,Flat,0
|
| 523 |
+
61,M,ASY,120,282,0,ST,135,Y,4,Down,1
|
| 524 |
+
50,M,ASY,144,349,0,LVH,120,Y,1,Up,1
|
| 525 |
+
59,M,ASY,124,160,0,Normal,117,Y,1,Flat,1
|
| 526 |
+
55,M,ASY,150,160,0,ST,150,N,0,Up,0
|
| 527 |
+
45,M,NAP,130,236,0,Normal,144,N,0.1,Up,0
|
| 528 |
+
65,M,ASY,144,312,0,LVH,113,Y,1.7,Flat,1
|
| 529 |
+
61,M,ATA,139,283,0,Normal,135,N,0.3,Up,0
|
| 530 |
+
49,M,NAP,131,142,0,Normal,127,Y,1.5,Flat,1
|
| 531 |
+
72,M,ASY,143,211,0,Normal,109,Y,1.4,Flat,1
|
| 532 |
+
50,M,ASY,133,218,0,Normal,128,Y,1.1,Flat,1
|
| 533 |
+
64,M,ASY,143,306,1,ST,115,Y,1.8,Flat,1
|
| 534 |
+
55,M,ASY,116,186,1,ST,102,N,0,Flat,1
|
| 535 |
+
63,M,ASY,110,252,0,ST,140,Y,2,Flat,1
|
| 536 |
+
59,M,ASY,125,222,0,Normal,135,Y,2.5,Down,1
|
| 537 |
+
56,M,ASY,130,0,0,LVH,122,Y,1,Flat,1
|
| 538 |
+
62,M,NAP,133,0,1,ST,119,Y,1.2,Flat,1
|
| 539 |
+
74,M,ASY,150,258,1,ST,130,Y,4,Down,1
|
| 540 |
+
54,M,ASY,130,202,1,Normal,112,Y,2,Flat,1
|
| 541 |
+
57,M,ASY,110,197,0,LVH,100,N,0,Up,0
|
| 542 |
+
62,M,NAP,138,204,0,ST,122,Y,1.2,Flat,1
|
| 543 |
+
76,M,NAP,104,113,0,LVH,120,N,3.5,Down,1
|
| 544 |
+
54,F,ASY,138,274,0,Normal,105,Y,1.5,Flat,1
|
| 545 |
+
70,M,ASY,170,192,0,ST,129,Y,3,Down,1
|
| 546 |
+
61,F,ATA,140,298,1,Normal,120,Y,0,Up,0
|
| 547 |
+
48,M,ASY,132,272,0,ST,139,N,0.2,Up,0
|
| 548 |
+
48,M,NAP,132,220,1,ST,162,N,0,Flat,1
|
| 549 |
+
61,M,TA,142,200,1,ST,100,N,1.5,Down,1
|
| 550 |
+
66,M,ASY,112,261,0,Normal,140,N,1.5,Up,1
|
| 551 |
+
68,M,TA,139,181,1,ST,135,N,0.2,Up,0
|
| 552 |
+
55,M,ASY,172,260,0,Normal,73,N,2,Flat,1
|
| 553 |
+
62,M,NAP,120,220,0,LVH,86,N,0,Up,0
|
| 554 |
+
71,M,NAP,144,221,0,Normal,108,Y,1.8,Flat,1
|
| 555 |
+
74,M,TA,145,216,1,Normal,116,Y,1.8,Flat,1
|
| 556 |
+
53,M,NAP,155,175,1,ST,160,N,0.3,Up,0
|
| 557 |
+
58,M,NAP,150,219,0,ST,118,Y,0,Flat,1
|
| 558 |
+
75,M,ASY,160,310,1,Normal,112,Y,2,Down,0
|
| 559 |
+
56,M,NAP,137,208,1,ST,122,Y,1.8,Flat,1
|
| 560 |
+
58,M,NAP,137,232,0,ST,124,Y,1.4,Flat,1
|
| 561 |
+
64,M,ASY,134,273,0,Normal,102,Y,4,Down,1
|
| 562 |
+
54,M,NAP,133,203,0,ST,137,N,0.2,Up,0
|
| 563 |
+
54,M,ATA,132,182,0,ST,141,N,0.1,Up,0
|
| 564 |
+
59,M,ASY,140,274,0,Normal,154,Y,2,Flat,0
|
| 565 |
+
55,M,ASY,135,204,1,ST,126,Y,1.1,Flat,1
|
| 566 |
+
57,M,ASY,144,270,1,ST,160,Y,2,Flat,1
|
| 567 |
+
61,M,ASY,141,292,0,ST,115,Y,1.7,Flat,1
|
| 568 |
+
41,M,ASY,150,171,0,Normal,128,Y,1.5,Flat,0
|
| 569 |
+
71,M,ASY,130,221,0,ST,115,Y,0,Flat,1
|
| 570 |
+
38,M,ASY,110,289,0,Normal,105,Y,1.5,Down,1
|
| 571 |
+
55,M,ASY,158,217,0,Normal,110,Y,2.5,Flat,1
|
| 572 |
+
56,M,ASY,128,223,0,ST,119,Y,2,Down,1
|
| 573 |
+
69,M,ASY,140,110,1,Normal,109,Y,1.5,Flat,1
|
| 574 |
+
64,M,ASY,150,193,0,ST,135,Y,0.5,Flat,1
|
| 575 |
+
72,M,ASY,160,123,1,LVH,130,N,1.5,Flat,1
|
| 576 |
+
69,M,ASY,142,210,1,ST,112,Y,1.5,Flat,1
|
| 577 |
+
56,M,ASY,137,282,1,Normal,126,Y,1.2,Flat,1
|
| 578 |
+
62,M,ASY,139,170,0,ST,120,Y,3,Flat,1
|
| 579 |
+
67,M,ASY,146,369,0,Normal,110,Y,1.9,Flat,1
|
| 580 |
+
57,M,ASY,156,173,0,LVH,119,Y,3,Down,1
|
| 581 |
+
69,M,ASY,145,289,1,ST,110,Y,1.8,Flat,1
|
| 582 |
+
51,M,ASY,131,152,1,LVH,130,Y,1,Flat,1
|
| 583 |
+
48,M,ASY,140,208,0,Normal,159,Y,1.5,Up,1
|
| 584 |
+
69,M,ASY,122,216,1,LVH,84,Y,0,Flat,1
|
| 585 |
+
69,M,NAP,142,271,0,LVH,126,N,0.3,Up,0
|
| 586 |
+
64,M,ASY,141,244,1,ST,116,Y,1.5,Flat,1
|
| 587 |
+
57,M,ATA,180,285,1,ST,120,N,0.8,Flat,1
|
| 588 |
+
53,M,ASY,124,243,0,Normal,122,Y,2,Flat,1
|
| 589 |
+
37,M,NAP,118,240,0,LVH,165,N,1,Flat,0
|
| 590 |
+
67,M,ASY,140,219,0,ST,122,Y,2,Flat,1
|
| 591 |
+
74,M,NAP,140,237,1,Normal,94,N,0,Flat,1
|
| 592 |
+
63,M,ATA,136,165,0,ST,133,N,0.2,Up,0
|
| 593 |
+
58,M,ASY,100,213,0,ST,110,N,0,Up,0
|
| 594 |
+
61,M,ASY,190,287,1,LVH,150,Y,2,Down,1
|
| 595 |
+
64,M,ASY,130,258,1,LVH,130,N,0,Flat,1
|
| 596 |
+
58,M,ASY,160,256,1,LVH,113,Y,1,Up,1
|
| 597 |
+
60,M,ASY,130,186,1,LVH,140,Y,0.5,Flat,1
|
| 598 |
+
57,M,ASY,122,264,0,LVH,100,N,0,Flat,1
|
| 599 |
+
55,M,NAP,133,185,0,ST,136,N,0.2,Up,0
|
| 600 |
+
55,M,ASY,120,226,0,LVH,127,Y,1.7,Down,1
|
| 601 |
+
56,M,ASY,130,203,1,Normal,98,N,1.5,Flat,1
|
| 602 |
+
57,M,ASY,130,207,0,ST,96,Y,1,Flat,0
|
| 603 |
+
61,M,NAP,140,284,0,Normal,123,Y,1.3,Flat,1
|
| 604 |
+
61,M,NAP,120,337,0,Normal,98,Y,0,Flat,1
|
| 605 |
+
74,M,ASY,155,310,0,Normal,112,Y,1.5,Down,1
|
| 606 |
+
68,M,NAP,134,254,1,Normal,151,Y,0,Up,0
|
| 607 |
+
51,F,ASY,114,258,1,LVH,96,N,1,Up,0
|
| 608 |
+
62,M,ASY,160,254,1,ST,108,Y,3,Flat,1
|
| 609 |
+
53,M,ASY,144,300,1,ST,128,Y,1.5,Flat,1
|
| 610 |
+
62,M,ASY,158,170,0,ST,138,Y,0,Flat,1
|
| 611 |
+
46,M,ASY,134,310,0,Normal,126,N,0,Flat,1
|
| 612 |
+
54,F,ASY,127,333,1,ST,154,N,0,Flat,1
|
| 613 |
+
62,M,TA,135,139,0,ST,137,N,0.2,Up,0
|
| 614 |
+
55,M,ASY,122,223,1,ST,100,N,0,Flat,1
|
| 615 |
+
58,M,ASY,140,385,1,LVH,135,N,0.3,Up,0
|
| 616 |
+
62,M,ATA,120,254,0,LVH,93,Y,0,Flat,1
|
| 617 |
+
70,M,ASY,130,322,0,LVH,109,N,2.4,Flat,1
|
| 618 |
+
67,F,NAP,115,564,0,LVH,160,N,1.6,Flat,0
|
| 619 |
+
57,M,ATA,124,261,0,Normal,141,N,0.3,Up,1
|
| 620 |
+
64,M,ASY,128,263,0,Normal,105,Y,0.2,Flat,0
|
| 621 |
+
74,F,ATA,120,269,0,LVH,121,Y,0.2,Up,0
|
| 622 |
+
65,M,ASY,120,177,0,Normal,140,N,0.4,Up,0
|
| 623 |
+
56,M,NAP,130,256,1,LVH,142,Y,0.6,Flat,1
|
| 624 |
+
59,M,ASY,110,239,0,LVH,142,Y,1.2,Flat,1
|
| 625 |
+
60,M,ASY,140,293,0,LVH,170,N,1.2,Flat,1
|
| 626 |
+
63,F,ASY,150,407,0,LVH,154,N,4,Flat,1
|
| 627 |
+
59,M,ASY,135,234,0,Normal,161,N,0.5,Flat,0
|
| 628 |
+
53,M,ASY,142,226,0,LVH,111,Y,0,Up,0
|
| 629 |
+
44,M,NAP,140,235,0,LVH,180,N,0,Up,0
|
| 630 |
+
61,M,TA,134,234,0,Normal,145,N,2.6,Flat,1
|
| 631 |
+
57,F,ASY,128,303,0,LVH,159,N,0,Up,0
|
| 632 |
+
71,F,ASY,112,149,0,Normal,125,N,1.6,Flat,0
|
| 633 |
+
46,M,ASY,140,311,0,Normal,120,Y,1.8,Flat,1
|
| 634 |
+
53,M,ASY,140,203,1,LVH,155,Y,3.1,Down,1
|
| 635 |
+
64,M,TA,110,211,0,LVH,144,Y,1.8,Flat,0
|
| 636 |
+
40,M,TA,140,199,0,Normal,178,Y,1.4,Up,0
|
| 637 |
+
67,M,ASY,120,229,0,LVH,129,Y,2.6,Flat,1
|
| 638 |
+
48,M,ATA,130,245,0,LVH,180,N,0.2,Flat,0
|
| 639 |
+
43,M,ASY,115,303,0,Normal,181,N,1.2,Flat,0
|
| 640 |
+
47,M,ASY,112,204,0,Normal,143,N,0.1,Up,0
|
| 641 |
+
54,F,ATA,132,288,1,LVH,159,Y,0,Up,0
|
| 642 |
+
48,F,NAP,130,275,0,Normal,139,N,0.2,Up,0
|
| 643 |
+
46,F,ASY,138,243,0,LVH,152,Y,0,Flat,0
|
| 644 |
+
51,F,NAP,120,295,0,LVH,157,N,0.6,Up,0
|
| 645 |
+
58,M,NAP,112,230,0,LVH,165,N,2.5,Flat,1
|
| 646 |
+
71,F,NAP,110,265,1,LVH,130,N,0,Up,0
|
| 647 |
+
57,M,NAP,128,229,0,LVH,150,N,0.4,Flat,1
|
| 648 |
+
66,M,ASY,160,228,0,LVH,138,N,2.3,Up,0
|
| 649 |
+
37,F,NAP,120,215,0,Normal,170,N,0,Up,0
|
| 650 |
+
59,M,ASY,170,326,0,LVH,140,Y,3.4,Down,1
|
| 651 |
+
50,M,ASY,144,200,0,LVH,126,Y,0.9,Flat,1
|
| 652 |
+
48,M,ASY,130,256,1,LVH,150,Y,0,Up,1
|
| 653 |
+
61,M,ASY,140,207,0,LVH,138,Y,1.9,Up,1
|
| 654 |
+
59,M,TA,160,273,0,LVH,125,N,0,Up,1
|
| 655 |
+
42,M,NAP,130,180,0,Normal,150,N,0,Up,0
|
| 656 |
+
48,M,ASY,122,222,0,LVH,186,N,0,Up,0
|
| 657 |
+
40,M,ASY,152,223,0,Normal,181,N,0,Up,1
|
| 658 |
+
62,F,ASY,124,209,0,Normal,163,N,0,Up,0
|
| 659 |
+
44,M,NAP,130,233,0,Normal,179,Y,0.4,Up,0
|
| 660 |
+
46,M,ATA,101,197,1,Normal,156,N,0,Up,0
|
| 661 |
+
59,M,NAP,126,218,1,Normal,134,N,2.2,Flat,1
|
| 662 |
+
58,M,NAP,140,211,1,LVH,165,N,0,Up,0
|
| 663 |
+
49,M,NAP,118,149,0,LVH,126,N,0.8,Up,1
|
| 664 |
+
44,M,ASY,110,197,0,LVH,177,N,0,Up,1
|
| 665 |
+
66,M,ATA,160,246,0,Normal,120,Y,0,Flat,1
|
| 666 |
+
65,F,ASY,150,225,0,LVH,114,N,1,Flat,1
|
| 667 |
+
42,M,ASY,136,315,0,Normal,125,Y,1.8,Flat,1
|
| 668 |
+
52,M,ATA,128,205,1,Normal,184,N,0,Up,0
|
| 669 |
+
65,F,NAP,140,417,1,LVH,157,N,0.8,Up,0
|
| 670 |
+
63,F,ATA,140,195,0,Normal,179,N,0,Up,0
|
| 671 |
+
45,F,ATA,130,234,0,LVH,175,N,0.6,Flat,0
|
| 672 |
+
41,F,ATA,105,198,0,Normal,168,N,0,Up,0
|
| 673 |
+
61,M,ASY,138,166,0,LVH,125,Y,3.6,Flat,1
|
| 674 |
+
60,F,NAP,120,178,1,Normal,96,N,0,Up,0
|
| 675 |
+
59,F,ASY,174,249,0,Normal,143,Y,0,Flat,1
|
| 676 |
+
62,M,ATA,120,281,0,LVH,103,N,1.4,Flat,1
|
| 677 |
+
57,M,NAP,150,126,1,Normal,173,N,0.2,Up,0
|
| 678 |
+
51,F,ASY,130,305,0,Normal,142,Y,1.2,Flat,1
|
| 679 |
+
44,M,NAP,120,226,0,Normal,169,N,0,Up,0
|
| 680 |
+
60,F,TA,150,240,0,Normal,171,N,0.9,Up,0
|
| 681 |
+
63,M,TA,145,233,1,LVH,150,N,2.3,Down,0
|
| 682 |
+
57,M,ASY,150,276,0,LVH,112,Y,0.6,Flat,1
|
| 683 |
+
51,M,ASY,140,261,0,LVH,186,Y,0,Up,0
|
| 684 |
+
58,F,ATA,136,319,1,LVH,152,N,0,Up,1
|
| 685 |
+
44,F,NAP,118,242,0,Normal,149,N,0.3,Flat,0
|
| 686 |
+
47,M,NAP,108,243,0,Normal,152,N,0,Up,1
|
| 687 |
+
61,M,ASY,120,260,0,Normal,140,Y,3.6,Flat,1
|
| 688 |
+
57,F,ASY,120,354,0,Normal,163,Y,0.6,Up,0
|
| 689 |
+
70,M,ATA,156,245,0,LVH,143,N,0,Up,0
|
| 690 |
+
76,F,NAP,140,197,0,ST,116,N,1.1,Flat,0
|
| 691 |
+
67,F,ASY,106,223,0,Normal,142,N,0.3,Up,0
|
| 692 |
+
45,M,ASY,142,309,0,LVH,147,Y,0,Flat,1
|
| 693 |
+
45,M,ASY,104,208,0,LVH,148,Y,3,Flat,0
|
| 694 |
+
39,F,NAP,94,199,0,Normal,179,N,0,Up,0
|
| 695 |
+
42,F,NAP,120,209,0,Normal,173,N,0,Flat,0
|
| 696 |
+
56,M,ATA,120,236,0,Normal,178,N,0.8,Up,0
|
| 697 |
+
58,M,ASY,146,218,0,Normal,105,N,2,Flat,1
|
| 698 |
+
35,M,ASY,120,198,0,Normal,130,Y,1.6,Flat,1
|
| 699 |
+
58,M,ASY,150,270,0,LVH,111,Y,0.8,Up,1
|
| 700 |
+
41,M,NAP,130,214,0,LVH,168,N,2,Flat,0
|
| 701 |
+
57,M,ASY,110,201,0,Normal,126,Y,1.5,Flat,0
|
| 702 |
+
42,M,TA,148,244,0,LVH,178,N,0.8,Up,0
|
| 703 |
+
62,M,ATA,128,208,1,LVH,140,N,0,Up,0
|
| 704 |
+
59,M,TA,178,270,0,LVH,145,N,4.2,Down,0
|
| 705 |
+
41,F,ATA,126,306,0,Normal,163,N,0,Up,0
|
| 706 |
+
50,M,ASY,150,243,0,LVH,128,N,2.6,Flat,1
|
| 707 |
+
59,M,ATA,140,221,0,Normal,164,Y,0,Up,0
|
| 708 |
+
61,F,ASY,130,330,0,LVH,169,N,0,Up,1
|
| 709 |
+
54,M,ASY,124,266,0,LVH,109,Y,2.2,Flat,1
|
| 710 |
+
54,M,ASY,110,206,0,LVH,108,Y,0,Flat,1
|
| 711 |
+
52,M,ASY,125,212,0,Normal,168,N,1,Up,1
|
| 712 |
+
47,M,ASY,110,275,0,LVH,118,Y,1,Flat,1
|
| 713 |
+
66,M,ASY,120,302,0,LVH,151,N,0.4,Flat,0
|
| 714 |
+
58,M,ASY,100,234,0,Normal,156,N,0.1,Up,1
|
| 715 |
+
64,F,NAP,140,313,0,Normal,133,N,0.2,Up,0
|
| 716 |
+
50,F,ATA,120,244,0,Normal,162,N,1.1,Up,0
|
| 717 |
+
44,F,NAP,108,141,0,Normal,175,N,0.6,Flat,0
|
| 718 |
+
67,M,ASY,120,237,0,Normal,71,N,1,Flat,1
|
| 719 |
+
49,F,ASY,130,269,0,Normal,163,N,0,Up,0
|
| 720 |
+
57,M,ASY,165,289,1,LVH,124,N,1,Flat,1
|
| 721 |
+
63,M,ASY,130,254,0,LVH,147,N,1.4,Flat,1
|
| 722 |
+
48,M,ASY,124,274,0,LVH,166,N,0.5,Flat,1
|
| 723 |
+
51,M,NAP,100,222,0,Normal,143,Y,1.2,Flat,0
|
| 724 |
+
60,F,ASY,150,258,0,LVH,157,N,2.6,Flat,1
|
| 725 |
+
59,M,ASY,140,177,0,Normal,162,Y,0,Up,1
|
| 726 |
+
45,F,ATA,112,160,0,Normal,138,N,0,Flat,0
|
| 727 |
+
55,F,ASY,180,327,0,ST,117,Y,3.4,Flat,1
|
| 728 |
+
41,M,ATA,110,235,0,Normal,153,N,0,Up,0
|
| 729 |
+
60,F,ASY,158,305,0,LVH,161,N,0,Up,1
|
| 730 |
+
54,F,NAP,135,304,1,Normal,170,N,0,Up,0
|
| 731 |
+
42,M,ATA,120,295,0,Normal,162,N,0,Up,0
|
| 732 |
+
49,F,ATA,134,271,0,Normal,162,N,0,Flat,0
|
| 733 |
+
46,M,ASY,120,249,0,LVH,144,N,0.8,Up,1
|
| 734 |
+
56,F,ASY,200,288,1,LVH,133,Y,4,Down,1
|
| 735 |
+
66,F,TA,150,226,0,Normal,114,N,2.6,Down,0
|
| 736 |
+
56,M,ASY,130,283,1,LVH,103,Y,1.6,Down,1
|
| 737 |
+
49,M,NAP,120,188,0,Normal,139,N,2,Flat,1
|
| 738 |
+
54,M,ASY,122,286,0,LVH,116,Y,3.2,Flat,1
|
| 739 |
+
57,M,ASY,152,274,0,Normal,88,Y,1.2,Flat,1
|
| 740 |
+
65,F,NAP,160,360,0,LVH,151,N,0.8,Up,0
|
| 741 |
+
54,M,NAP,125,273,0,LVH,152,N,0.5,Down,0
|
| 742 |
+
54,F,NAP,160,201,0,Normal,163,N,0,Up,0
|
| 743 |
+
62,M,ASY,120,267,0,Normal,99,Y,1.8,Flat,1
|
| 744 |
+
52,F,NAP,136,196,0,LVH,169,N,0.1,Flat,0
|
| 745 |
+
52,M,ATA,134,201,0,Normal,158,N,0.8,Up,0
|
| 746 |
+
60,M,ASY,117,230,1,Normal,160,Y,1.4,Up,1
|
| 747 |
+
63,F,ASY,108,269,0,Normal,169,Y,1.8,Flat,1
|
| 748 |
+
66,M,ASY,112,212,0,LVH,132,Y,0.1,Up,1
|
| 749 |
+
42,M,ASY,140,226,0,Normal,178,N,0,Up,0
|
| 750 |
+
64,M,ASY,120,246,0,LVH,96,Y,2.2,Down,1
|
| 751 |
+
54,M,NAP,150,232,0,LVH,165,N,1.6,Up,0
|
| 752 |
+
46,F,NAP,142,177,0,LVH,160,Y,1.4,Down,0
|
| 753 |
+
67,F,NAP,152,277,0,Normal,172,N,0,Up,0
|
| 754 |
+
56,M,ASY,125,249,1,LVH,144,Y,1.2,Flat,1
|
| 755 |
+
34,F,ATA,118,210,0,Normal,192,N,0.7,Up,0
|
| 756 |
+
57,M,ASY,132,207,0,Normal,168,Y,0,Up,0
|
| 757 |
+
64,M,ASY,145,212,0,LVH,132,N,2,Flat,1
|
| 758 |
+
59,M,ASY,138,271,0,LVH,182,N,0,Up,0
|
| 759 |
+
50,M,NAP,140,233,0,Normal,163,N,0.6,Flat,1
|
| 760 |
+
51,M,TA,125,213,0,LVH,125,Y,1.4,Up,0
|
| 761 |
+
54,M,ATA,192,283,0,LVH,195,N,0,Up,1
|
| 762 |
+
53,M,ASY,123,282,0,Normal,95,Y,2,Flat,1
|
| 763 |
+
52,M,ASY,112,230,0,Normal,160,N,0,Up,1
|
| 764 |
+
40,M,ASY,110,167,0,LVH,114,Y,2,Flat,1
|
| 765 |
+
58,M,NAP,132,224,0,LVH,173,N,3.2,Up,1
|
| 766 |
+
41,F,NAP,112,268,0,LVH,172,Y,0,Up,0
|
| 767 |
+
41,M,NAP,112,250,0,Normal,179,N,0,Up,0
|
| 768 |
+
50,F,NAP,120,219,0,Normal,158,N,1.6,Flat,0
|
| 769 |
+
54,F,NAP,108,267,0,LVH,167,N,0,Up,0
|
| 770 |
+
64,F,ASY,130,303,0,Normal,122,N,2,Flat,0
|
| 771 |
+
51,F,NAP,130,256,0,LVH,149,N,0.5,Up,0
|
| 772 |
+
46,F,ATA,105,204,0,Normal,172,N,0,Up,0
|
| 773 |
+
55,M,ASY,140,217,0,Normal,111,Y,5.6,Down,1
|
| 774 |
+
45,M,ATA,128,308,0,LVH,170,N,0,Up,0
|
| 775 |
+
56,M,TA,120,193,0,LVH,162,N,1.9,Flat,0
|
| 776 |
+
66,F,ASY,178,228,1,Normal,165,Y,1,Flat,1
|
| 777 |
+
38,M,TA,120,231,0,Normal,182,Y,3.8,Flat,1
|
| 778 |
+
62,F,ASY,150,244,0,Normal,154,Y,1.4,Flat,1
|
| 779 |
+
55,M,ATA,130,262,0,Normal,155,N,0,Up,0
|
| 780 |
+
58,M,ASY,128,259,0,LVH,130,Y,3,Flat,1
|
| 781 |
+
43,M,ASY,110,211,0,Normal,161,N,0,Up,0
|
| 782 |
+
64,F,ASY,180,325,0,Normal,154,Y,0,Up,0
|
| 783 |
+
50,F,ASY,110,254,0,LVH,159,N,0,Up,0
|
| 784 |
+
53,M,NAP,130,197,1,LVH,152,N,1.2,Down,0
|
| 785 |
+
45,F,ASY,138,236,0,LVH,152,Y,0.2,Flat,0
|
| 786 |
+
65,M,TA,138,282,1,LVH,174,N,1.4,Flat,1
|
| 787 |
+
69,M,TA,160,234,1,LVH,131,N,0.1,Flat,0
|
| 788 |
+
69,M,NAP,140,254,0,LVH,146,N,2,Flat,1
|
| 789 |
+
67,M,ASY,100,299,0,LVH,125,Y,0.9,Flat,1
|
| 790 |
+
68,F,NAP,120,211,0,LVH,115,N,1.5,Flat,0
|
| 791 |
+
34,M,TA,118,182,0,LVH,174,N,0,Up,0
|
| 792 |
+
62,F,ASY,138,294,1,Normal,106,N,1.9,Flat,1
|
| 793 |
+
51,M,ASY,140,298,0,Normal,122,Y,4.2,Flat,1
|
| 794 |
+
46,M,NAP,150,231,0,Normal,147,N,3.6,Flat,1
|
| 795 |
+
67,M,ASY,125,254,1,Normal,163,N,0.2,Flat,1
|
| 796 |
+
50,M,NAP,129,196,0,Normal,163,N,0,Up,0
|
| 797 |
+
42,M,NAP,120,240,1,Normal,194,N,0.8,Down,0
|
| 798 |
+
56,F,ASY,134,409,0,LVH,150,Y,1.9,Flat,1
|
| 799 |
+
41,M,ASY,110,172,0,LVH,158,N,0,Up,1
|
| 800 |
+
42,F,ASY,102,265,0,LVH,122,N,0.6,Flat,0
|
| 801 |
+
53,M,NAP,130,246,1,LVH,173,N,0,Up,0
|
| 802 |
+
43,M,NAP,130,315,0,Normal,162,N,1.9,Up,0
|
| 803 |
+
56,M,ASY,132,184,0,LVH,105,Y,2.1,Flat,1
|
| 804 |
+
52,M,ASY,108,233,1,Normal,147,N,0.1,Up,0
|
| 805 |
+
62,F,ASY,140,394,0,LVH,157,N,1.2,Flat,0
|
| 806 |
+
70,M,NAP,160,269,0,Normal,112,Y,2.9,Flat,1
|
| 807 |
+
54,M,ASY,140,239,0,Normal,160,N,1.2,Up,0
|
| 808 |
+
70,M,ASY,145,174,0,Normal,125,Y,2.6,Down,1
|
| 809 |
+
54,M,ATA,108,309,0,Normal,156,N,0,Up,0
|
| 810 |
+
35,M,ASY,126,282,0,LVH,156,Y,0,Up,1
|
| 811 |
+
48,M,NAP,124,255,1,Normal,175,N,0,Up,0
|
| 812 |
+
55,F,ATA,135,250,0,LVH,161,N,1.4,Flat,0
|
| 813 |
+
58,F,ASY,100,248,0,LVH,122,N,1,Flat,0
|
| 814 |
+
54,F,NAP,110,214,0,Normal,158,N,1.6,Flat,0
|
| 815 |
+
69,F,TA,140,239,0,Normal,151,N,1.8,Up,0
|
| 816 |
+
77,M,ASY,125,304,0,LVH,162,Y,0,Up,1
|
| 817 |
+
68,M,NAP,118,277,0,Normal,151,N,1,Up,0
|
| 818 |
+
58,M,ASY,125,300,0,LVH,171,N,0,Up,1
|
| 819 |
+
60,M,ASY,125,258,0,LVH,141,Y,2.8,Flat,1
|
| 820 |
+
51,M,ASY,140,299,0,Normal,173,Y,1.6,Up,1
|
| 821 |
+
55,M,ASY,160,289,0,LVH,145,Y,0.8,Flat,1
|
| 822 |
+
52,M,TA,152,298,1,Normal,178,N,1.2,Flat,0
|
| 823 |
+
60,F,NAP,102,318,0,Normal,160,N,0,Up,0
|
| 824 |
+
58,M,NAP,105,240,0,LVH,154,Y,0.6,Flat,0
|
| 825 |
+
64,M,NAP,125,309,0,Normal,131,Y,1.8,Flat,1
|
| 826 |
+
37,M,NAP,130,250,0,Normal,187,N,3.5,Down,0
|
| 827 |
+
59,M,TA,170,288,0,LVH,159,N,0.2,Flat,1
|
| 828 |
+
51,M,NAP,125,245,1,LVH,166,N,2.4,Flat,0
|
| 829 |
+
43,F,NAP,122,213,0,Normal,165,N,0.2,Flat,0
|
| 830 |
+
58,M,ASY,128,216,0,LVH,131,Y,2.2,Flat,1
|
| 831 |
+
29,M,ATA,130,204,0,LVH,202,N,0,Up,0
|
| 832 |
+
41,F,ATA,130,204,0,LVH,172,N,1.4,Up,0
|
| 833 |
+
63,F,NAP,135,252,0,LVH,172,N,0,Up,0
|
| 834 |
+
51,M,NAP,94,227,0,Normal,154,Y,0,Up,0
|
| 835 |
+
54,M,NAP,120,258,0,LVH,147,N,0.4,Flat,0
|
| 836 |
+
44,M,ATA,120,220,0,Normal,170,N,0,Up,0
|
| 837 |
+
54,M,ASY,110,239,0,Normal,126,Y,2.8,Flat,1
|
| 838 |
+
65,M,ASY,135,254,0,LVH,127,N,2.8,Flat,1
|
| 839 |
+
57,M,NAP,150,168,0,Normal,174,N,1.6,Up,0
|
| 840 |
+
63,M,ASY,130,330,1,LVH,132,Y,1.8,Up,1
|
| 841 |
+
35,F,ASY,138,183,0,Normal,182,N,1.4,Up,0
|
| 842 |
+
41,M,ATA,135,203,0,Normal,132,N,0,Flat,0
|
| 843 |
+
62,F,NAP,130,263,0,Normal,97,N,1.2,Flat,1
|
| 844 |
+
43,F,ASY,132,341,1,LVH,136,Y,3,Flat,1
|
| 845 |
+
58,F,TA,150,283,1,LVH,162,N,1,Up,0
|
| 846 |
+
52,M,TA,118,186,0,LVH,190,N,0,Flat,0
|
| 847 |
+
61,F,ASY,145,307,0,LVH,146,Y,1,Flat,1
|
| 848 |
+
39,M,ASY,118,219,0,Normal,140,N,1.2,Flat,1
|
| 849 |
+
45,M,ASY,115,260,0,LVH,185,N,0,Up,0
|
| 850 |
+
52,M,ASY,128,255,0,Normal,161,Y,0,Up,1
|
| 851 |
+
62,M,NAP,130,231,0,Normal,146,N,1.8,Flat,0
|
| 852 |
+
62,F,ASY,160,164,0,LVH,145,N,6.2,Down,1
|
| 853 |
+
53,F,ASY,138,234,0,LVH,160,N,0,Up,0
|
| 854 |
+
43,M,ASY,120,177,0,LVH,120,Y,2.5,Flat,1
|
| 855 |
+
47,M,NAP,138,257,0,LVH,156,N,0,Up,0
|
| 856 |
+
52,M,ATA,120,325,0,Normal,172,N,0.2,Up,0
|
| 857 |
+
68,M,NAP,180,274,1,LVH,150,Y,1.6,Flat,1
|
| 858 |
+
39,M,NAP,140,321,0,LVH,182,N,0,Up,0
|
| 859 |
+
53,F,ASY,130,264,0,LVH,143,N,0.4,Flat,0
|
| 860 |
+
62,F,ASY,140,268,0,LVH,160,N,3.6,Down,1
|
| 861 |
+
51,F,NAP,140,308,0,LVH,142,N,1.5,Up,0
|
| 862 |
+
60,M,ASY,130,253,0,Normal,144,Y,1.4,Up,1
|
| 863 |
+
65,M,ASY,110,248,0,LVH,158,N,0.6,Up,1
|
| 864 |
+
65,F,NAP,155,269,0,Normal,148,N,0.8,Up,0
|
| 865 |
+
60,M,NAP,140,185,0,LVH,155,N,3,Flat,1
|
| 866 |
+
60,M,ASY,145,282,0,LVH,142,Y,2.8,Flat,1
|
| 867 |
+
54,M,ASY,120,188,0,Normal,113,N,1.4,Flat,1
|
| 868 |
+
44,M,ATA,130,219,0,LVH,188,N,0,Up,0
|
| 869 |
+
44,M,ASY,112,290,0,LVH,153,N,0,Up,1
|
| 870 |
+
51,M,NAP,110,175,0,Normal,123,N,0.6,Up,0
|
| 871 |
+
59,M,NAP,150,212,1,Normal,157,N,1.6,Up,0
|
| 872 |
+
71,F,ATA,160,302,0,Normal,162,N,0.4,Up,0
|
| 873 |
+
61,M,NAP,150,243,1,Normal,137,Y,1,Flat,0
|
| 874 |
+
55,M,ASY,132,353,0,Normal,132,Y,1.2,Flat,1
|
| 875 |
+
64,M,NAP,140,335,0,Normal,158,N,0,Up,1
|
| 876 |
+
43,M,ASY,150,247,0,Normal,171,N,1.5,Up,0
|
| 877 |
+
58,F,NAP,120,340,0,Normal,172,N,0,Up,0
|
| 878 |
+
60,M,ASY,130,206,0,LVH,132,Y,2.4,Flat,1
|
| 879 |
+
58,M,ATA,120,284,0,LVH,160,N,1.8,Flat,1
|
| 880 |
+
49,M,ATA,130,266,0,Normal,171,N,0.6,Up,0
|
| 881 |
+
48,M,ATA,110,229,0,Normal,168,N,1,Down,1
|
| 882 |
+
52,M,NAP,172,199,1,Normal,162,N,0.5,Up,0
|
| 883 |
+
44,M,ATA,120,263,0,Normal,173,N,0,Up,0
|
| 884 |
+
56,F,ATA,140,294,0,LVH,153,N,1.3,Flat,0
|
| 885 |
+
57,M,ASY,140,192,0,Normal,148,N,0.4,Flat,0
|
| 886 |
+
67,M,ASY,160,286,0,LVH,108,Y,1.5,Flat,1
|
| 887 |
+
53,F,NAP,128,216,0,LVH,115,N,0,Up,0
|
| 888 |
+
52,M,NAP,138,223,0,Normal,169,N,0,Up,0
|
| 889 |
+
43,M,ASY,132,247,1,LVH,143,Y,0.1,Flat,1
|
| 890 |
+
52,M,ASY,128,204,1,Normal,156,Y,1,Flat,1
|
| 891 |
+
59,M,TA,134,204,0,Normal,162,N,0.8,Up,1
|
| 892 |
+
64,M,TA,170,227,0,LVH,155,N,0.6,Flat,0
|
| 893 |
+
66,F,NAP,146,278,0,LVH,152,N,0,Flat,0
|
| 894 |
+
39,F,NAP,138,220,0,Normal,152,N,0,Flat,0
|
| 895 |
+
57,M,ATA,154,232,0,LVH,164,N,0,Up,1
|
| 896 |
+
58,F,ASY,130,197,0,Normal,131,N,0.6,Flat,0
|
| 897 |
+
57,M,ASY,110,335,0,Normal,143,Y,3,Flat,1
|
| 898 |
+
47,M,NAP,130,253,0,Normal,179,N,0,Up,0
|
| 899 |
+
55,F,ASY,128,205,0,ST,130,Y,2,Flat,1
|
| 900 |
+
35,M,ATA,122,192,0,Normal,174,N,0,Up,0
|
| 901 |
+
61,M,ASY,148,203,0,Normal,161,N,0,Up,1
|
| 902 |
+
58,M,ASY,114,318,0,ST,140,N,4.4,Down,1
|
| 903 |
+
58,F,ASY,170,225,1,LVH,146,Y,2.8,Flat,1
|
| 904 |
+
58,M,ATA,125,220,0,Normal,144,N,0.4,Flat,0
|
| 905 |
+
56,M,ATA,130,221,0,LVH,163,N,0,Up,0
|
| 906 |
+
56,M,ATA,120,240,0,Normal,169,N,0,Down,0
|
| 907 |
+
67,M,NAP,152,212,0,LVH,150,N,0.8,Flat,1
|
| 908 |
+
55,F,ATA,132,342,0,Normal,166,N,1.2,Up,0
|
| 909 |
+
44,M,ASY,120,169,0,Normal,144,Y,2.8,Down,1
|
| 910 |
+
63,M,ASY,140,187,0,LVH,144,Y,4,Up,1
|
| 911 |
+
63,F,ASY,124,197,0,Normal,136,Y,0,Flat,1
|
| 912 |
+
41,M,ATA,120,157,0,Normal,182,N,0,Up,0
|
| 913 |
+
59,M,ASY,164,176,1,LVH,90,N,1,Flat,1
|
| 914 |
+
57,F,ASY,140,241,0,Normal,123,Y,0.2,Flat,1
|
| 915 |
+
45,M,TA,110,264,0,Normal,132,N,1.2,Flat,1
|
| 916 |
+
68,M,ASY,144,193,1,Normal,141,N,3.4,Flat,1
|
| 917 |
+
57,M,ASY,130,131,0,Normal,115,Y,1.2,Flat,1
|
| 918 |
+
57,F,ATA,130,236,0,LVH,174,N,0,Flat,1
|
| 919 |
+
38,M,NAP,138,175,0,Normal,173,N,0,Up,0
|
docker/entrypoint.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Boots FastAPI (8000) + Next.js standalone (7860) in one container.
|
| 3 |
+
# Forwards signals so the container stops cleanly under HF Spaces / docker stop.
|
| 4 |
+
set -euo pipefail
|
| 5 |
+
|
| 6 |
+
cd /home/app/code
|
| 7 |
+
|
| 8 |
+
PORT_API="${PORT_API:-8000}"
|
| 9 |
+
PORT_WEB="${PORT:-7860}"
|
| 10 |
+
HOST="${HOSTNAME:-0.0.0.0}"
|
| 11 |
+
|
| 12 |
+
echo "[boot] starting FastAPI on :${PORT_API} ..."
|
| 13 |
+
uv run uvicorn api.main:app --host 127.0.0.1 --port "${PORT_API}" --workers 1 &
|
| 14 |
+
PID_API=$!
|
| 15 |
+
|
| 16 |
+
# Wait for FastAPI to become ready (max ~30s)
|
| 17 |
+
for i in $(seq 1 30); do
|
| 18 |
+
if curl -fsS "http://127.0.0.1:${PORT_API}/health" >/dev/null 2>&1; then
|
| 19 |
+
echo "[boot] FastAPI is up."
|
| 20 |
+
break
|
| 21 |
+
fi
|
| 22 |
+
sleep 1
|
| 23 |
+
done
|
| 24 |
+
|
| 25 |
+
echo "[boot] starting Next.js on :${PORT_WEB} ..."
|
| 26 |
+
cd /home/app/code/frontend
|
| 27 |
+
HOSTNAME="${HOST}" PORT="${PORT_WEB}" node server.js &
|
| 28 |
+
PID_WEB=$!
|
| 29 |
+
|
| 30 |
+
terminate() {
|
| 31 |
+
echo "[boot] stopping ..."
|
| 32 |
+
kill -TERM "${PID_API}" "${PID_WEB}" 2>/dev/null || true
|
| 33 |
+
wait || true
|
| 34 |
+
exit 0
|
| 35 |
+
}
|
| 36 |
+
trap terminate INT TERM
|
| 37 |
+
|
| 38 |
+
# Exit if either child dies
|
| 39 |
+
wait -n "${PID_API}" "${PID_WEB}"
|
| 40 |
+
EXIT_CODE=$?
|
| 41 |
+
echo "[boot] a child exited with code ${EXIT_CODE}, shutting down."
|
| 42 |
+
terminate
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
output: "standalone",
|
| 5 |
+
async rewrites() {
|
| 6 |
+
const apiTarget = process.env.API_INTERNAL_URL ?? "http://127.0.0.1:8000";
|
| 7 |
+
return [
|
| 8 |
+
{
|
| 9 |
+
source: "/api/:path*",
|
| 10 |
+
destination: `${apiTarget}/:path*`,
|
| 11 |
+
},
|
| 12 |
+
];
|
| 13 |
+
},
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export default nextConfig;
|
frontend/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@hookform/resolvers": "^5.4.0",
|
| 13 |
+
"@radix-ui/react-dialog": "^1.1.16",
|
| 14 |
+
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
| 15 |
+
"@radix-ui/react-label": "^2.1.9",
|
| 16 |
+
"@radix-ui/react-select": "^2.3.0",
|
| 17 |
+
"@radix-ui/react-slot": "^1.2.5",
|
| 18 |
+
"@radix-ui/react-toast": "^1.2.16",
|
| 19 |
+
"@tanstack/react-query": "^5.101.0",
|
| 20 |
+
"@tanstack/react-table": "^8.21.3",
|
| 21 |
+
"class-variance-authority": "^0.7.1",
|
| 22 |
+
"clsx": "^2.1.1",
|
| 23 |
+
"lucide-react": "^1.17.0",
|
| 24 |
+
"next": "16.2.7",
|
| 25 |
+
"next-intl": "^4.13.0",
|
| 26 |
+
"next-themes": "^0.4.6",
|
| 27 |
+
"react": "19.2.4",
|
| 28 |
+
"react-dom": "19.2.4",
|
| 29 |
+
"react-hook-form": "^7.78.0",
|
| 30 |
+
"recharts": "^3.8.1",
|
| 31 |
+
"tailwind-merge": "^3.6.0",
|
| 32 |
+
"zod": "^4.4.3"
|
| 33 |
+
},
|
| 34 |
+
"devDependencies": {
|
| 35 |
+
"@tailwindcss/postcss": "^4",
|
| 36 |
+
"@types/node": "^20",
|
| 37 |
+
"@types/react": "^19",
|
| 38 |
+
"@types/react-dom": "^19",
|
| 39 |
+
"eslint": "^9",
|
| 40 |
+
"eslint-config-next": "16.2.7",
|
| 41 |
+
"tailwindcss": "^4",
|
| 42 |
+
"typescript": "^5"
|
| 43 |
+
}
|
| 44 |
+
}
|
frontend/pnpm-lock.yaml
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/pnpm-workspace.yaml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ignoredBuiltDependencies:
|
| 2 |
+
- sharp
|
| 3 |
+
- unrs-resolver
|
frontend/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
frontend/public/file.svg
ADDED
|
|
frontend/public/globe.svg
ADDED
|
|
frontend/public/logu1.png
ADDED
|
Git LFS Details
|
frontend/public/next.svg
ADDED
|
|
frontend/public/vercel.svg
ADDED
|
|
frontend/public/window.svg
ADDED
|
|
frontend/src/app/[lang]/eda/page.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from "next/navigation";
|
| 2 |
+
import { isLocale } from "@/i18n/config";
|
| 3 |
+
import { getDictionary } from "@/i18n/dictionaries";
|
| 4 |
+
import { PageHeader } from "@/components/layout/page-header";
|
| 5 |
+
import { EdaCharts } from "@/components/charts/eda-charts";
|
| 6 |
+
|
| 7 |
+
export default async function Page({
|
| 8 |
+
params,
|
| 9 |
+
}: {
|
| 10 |
+
params: Promise<{ lang: string }>;
|
| 11 |
+
}) {
|
| 12 |
+
const { lang } = await params;
|
| 13 |
+
if (!isLocale(lang)) notFound();
|
| 14 |
+
const dict = await getDictionary(lang);
|
| 15 |
+
return (
|
| 16 |
+
<div className="space-y-6">
|
| 17 |
+
<PageHeader title={dict.eda.title} subtitle={dict.eda.subtitle} />
|
| 18 |
+
<EdaCharts />
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
frontend/src/app/[lang]/layout.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from "next/navigation";
|
| 2 |
+
import { isLocale, locales } from "@/i18n/config";
|
| 3 |
+
import { getDictionary } from "@/i18n/dictionaries";
|
| 4 |
+
import { Providers } from "@/components/providers";
|
| 5 |
+
import { Sidebar } from "@/components/layout/sidebar";
|
| 6 |
+
import { Header } from "@/components/layout/header";
|
| 7 |
+
|
| 8 |
+
export async function generateStaticParams() {
|
| 9 |
+
return locales.map((lang) => ({ lang }));
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default async function LangLayout({
|
| 13 |
+
children,
|
| 14 |
+
params,
|
| 15 |
+
}: {
|
| 16 |
+
children: React.ReactNode;
|
| 17 |
+
params: Promise<{ lang: string }>;
|
| 18 |
+
}) {
|
| 19 |
+
const { lang } = await params;
|
| 20 |
+
if (!isLocale(lang)) notFound();
|
| 21 |
+
const dict = await getDictionary(lang);
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<Providers dict={dict} locale={lang}>
|
| 25 |
+
<div className="flex min-h-screen bg-background text-foreground">
|
| 26 |
+
<Sidebar />
|
| 27 |
+
<div className="flex flex-1 flex-col">
|
| 28 |
+
<Header />
|
| 29 |
+
<main className="flex-1 overflow-x-hidden p-6 lg:p-8">{children}</main>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</Providers>
|
| 33 |
+
);
|
| 34 |
+
}
|
frontend/src/app/[lang]/model/page.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from "next/navigation";
|
| 2 |
+
import { isLocale } from "@/i18n/config";
|
| 3 |
+
import { getDictionary } from "@/i18n/dictionaries";
|
| 4 |
+
import { PageHeader } from "@/components/layout/page-header";
|
| 5 |
+
import { ModelInsights } from "@/components/model/model-insights";
|
| 6 |
+
|
| 7 |
+
export default async function Page({
|
| 8 |
+
params,
|
| 9 |
+
}: {
|
| 10 |
+
params: Promise<{ lang: string }>;
|
| 11 |
+
}) {
|
| 12 |
+
const { lang } = await params;
|
| 13 |
+
if (!isLocale(lang)) notFound();
|
| 14 |
+
const dict = await getDictionary(lang);
|
| 15 |
+
return (
|
| 16 |
+
<div className="space-y-6">
|
| 17 |
+
<PageHeader title={dict.model.title} subtitle={dict.model.subtitle} />
|
| 18 |
+
<ModelInsights />
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
frontend/src/app/[lang]/new-evaluation/page.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { EvaluationForm } from "@/components/forms/evaluation-form";
|
| 2 |
+
import { PageHeader } from "@/components/layout/page-header";
|
| 3 |
+
import { getDictionary } from "@/i18n/dictionaries";
|
| 4 |
+
import { isLocale } from "@/i18n/config";
|
| 5 |
+
import { notFound } from "next/navigation";
|
| 6 |
+
|
| 7 |
+
export default async function Page({
|
| 8 |
+
params,
|
| 9 |
+
}: {
|
| 10 |
+
params: Promise<{ lang: string }>;
|
| 11 |
+
}) {
|
| 12 |
+
const { lang } = await params;
|
| 13 |
+
if (!isLocale(lang)) notFound();
|
| 14 |
+
const dict = await getDictionary(lang);
|
| 15 |
+
return (
|
| 16 |
+
<div className="space-y-6">
|
| 17 |
+
<PageHeader title={dict.form.title} subtitle={dict.form.subtitle} />
|
| 18 |
+
<EvaluationForm />
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
frontend/src/app/[lang]/page.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from "next/navigation";
|
| 2 |
+
import { isLocale } from "@/i18n/config";
|
| 3 |
+
import { getDictionary } from "@/i18n/dictionaries";
|
| 4 |
+
import { PageHeader } from "@/components/layout/page-header";
|
| 5 |
+
import { DashboardClient } from "@/components/dashboard/dashboard-client";
|
| 6 |
+
|
| 7 |
+
export default async function Page({
|
| 8 |
+
params,
|
| 9 |
+
}: {
|
| 10 |
+
params: Promise<{ lang: string }>;
|
| 11 |
+
}) {
|
| 12 |
+
const { lang } = await params;
|
| 13 |
+
if (!isLocale(lang)) notFound();
|
| 14 |
+
const dict = await getDictionary(lang);
|
| 15 |
+
return (
|
| 16 |
+
<div className="space-y-6">
|
| 17 |
+
<PageHeader title={dict.dashboard.title} subtitle={dict.dashboard.subtitle} />
|
| 18 |
+
<DashboardClient />
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
frontend/src/app/[lang]/patients/page.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from "next/navigation";
|
| 2 |
+
import { isLocale } from "@/i18n/config";
|
| 3 |
+
import { getDictionary } from "@/i18n/dictionaries";
|
| 4 |
+
import { PageHeader } from "@/components/layout/page-header";
|
| 5 |
+
import { PatientsTable } from "@/components/patients/patients-table";
|
| 6 |
+
|
| 7 |
+
export default async function Page({
|
| 8 |
+
params,
|
| 9 |
+
}: {
|
| 10 |
+
params: Promise<{ lang: string }>;
|
| 11 |
+
}) {
|
| 12 |
+
const { lang } = await params;
|
| 13 |
+
if (!isLocale(lang)) notFound();
|
| 14 |
+
const dict = await getDictionary(lang);
|
| 15 |
+
return (
|
| 16 |
+
<div className="space-y-6">
|
| 17 |
+
<PageHeader title={dict.history.title} subtitle={dict.history.subtitle} />
|
| 18 |
+
<PatientsTable />
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
frontend/src/app/[lang]/settings/page.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from "next/navigation";
|
| 2 |
+
import { isLocale } from "@/i18n/config";
|
| 3 |
+
import { getDictionary } from "@/i18n/dictionaries";
|
| 4 |
+
import { PageHeader } from "@/components/layout/page-header";
|
| 5 |
+
import { SettingsPanel } from "@/components/settings/settings-panel";
|
| 6 |
+
|
| 7 |
+
export default async function Page({
|
| 8 |
+
params,
|
| 9 |
+
}: {
|
| 10 |
+
params: Promise<{ lang: string }>;
|
| 11 |
+
}) {
|
| 12 |
+
const { lang } = await params;
|
| 13 |
+
if (!isLocale(lang)) notFound();
|
| 14 |
+
const dict = await getDictionary(lang);
|
| 15 |
+
return (
|
| 16 |
+
<div className="space-y-6">
|
| 17 |
+
<PageHeader title={dict.settings.title} />
|
| 18 |
+
<SettingsPanel />
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
frontend/src/app/favicon.ico
ADDED
|
|
frontend/src/app/globals.css
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@custom-variant dark (&:where(.dark, .dark *));
|
| 4 |
+
|
| 5 |
+
@theme inline {
|
| 6 |
+
--color-background: var(--background);
|
| 7 |
+
--color-foreground: var(--foreground);
|
| 8 |
+
--color-card: var(--card);
|
| 9 |
+
--color-card-foreground: var(--card-foreground);
|
| 10 |
+
--color-popover: var(--popover);
|
| 11 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 12 |
+
--color-primary: var(--primary);
|
| 13 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 14 |
+
--color-secondary: var(--secondary);
|
| 15 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 16 |
+
--color-muted: var(--muted);
|
| 17 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 18 |
+
--color-accent: var(--accent);
|
| 19 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 20 |
+
--color-destructive: var(--destructive);
|
| 21 |
+
--color-destructive-foreground: var(--destructive-foreground);
|
| 22 |
+
--color-success: var(--success);
|
| 23 |
+
--color-success-foreground: var(--success-foreground);
|
| 24 |
+
--color-warning: var(--warning);
|
| 25 |
+
--color-warning-foreground: var(--warning-foreground);
|
| 26 |
+
--color-border: var(--border);
|
| 27 |
+
--color-input: var(--input);
|
| 28 |
+
--color-ring: var(--ring);
|
| 29 |
+
--color-sidebar: var(--sidebar);
|
| 30 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 31 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 32 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 33 |
+
--font-sans: var(--font-inter);
|
| 34 |
+
--font-mono: var(--font-jetbrains-mono);
|
| 35 |
+
--radius-sm: 0.25rem;
|
| 36 |
+
--radius-md: 0.375rem;
|
| 37 |
+
--radius-lg: 0.5rem;
|
| 38 |
+
--radius-xl: 0.75rem;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
:root {
|
| 42 |
+
--background: #f7f8fa;
|
| 43 |
+
--foreground: #0b1220;
|
| 44 |
+
--card: #ffffff;
|
| 45 |
+
--card-foreground: #0b1220;
|
| 46 |
+
--popover: #ffffff;
|
| 47 |
+
--popover-foreground: #0b1220;
|
| 48 |
+
--primary: #1d4ed8;
|
| 49 |
+
--primary-foreground: #ffffff;
|
| 50 |
+
--secondary: #eef2ff;
|
| 51 |
+
--secondary-foreground: #1e293b;
|
| 52 |
+
--muted: #f1f5f9;
|
| 53 |
+
--muted-foreground: #64748b;
|
| 54 |
+
--accent: #e0f2fe;
|
| 55 |
+
--accent-foreground: #075985;
|
| 56 |
+
--destructive: #dc2626;
|
| 57 |
+
--destructive-foreground: #ffffff;
|
| 58 |
+
--success: #15803d;
|
| 59 |
+
--success-foreground: #ffffff;
|
| 60 |
+
--warning: #b45309;
|
| 61 |
+
--warning-foreground: #ffffff;
|
| 62 |
+
--border: #e2e8f0;
|
| 63 |
+
--input: #e2e8f0;
|
| 64 |
+
--ring: #1d4ed8;
|
| 65 |
+
--sidebar: #0f172a;
|
| 66 |
+
--sidebar-foreground: #cbd5e1;
|
| 67 |
+
--sidebar-border: #1e293b;
|
| 68 |
+
--sidebar-accent: #1e293b;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.dark {
|
| 72 |
+
--background: #0a0f1a;
|
| 73 |
+
--foreground: #e6edf7;
|
| 74 |
+
--card: #0f172a;
|
| 75 |
+
--card-foreground: #e6edf7;
|
| 76 |
+
--popover: #0f172a;
|
| 77 |
+
--popover-foreground: #e6edf7;
|
| 78 |
+
--primary: #60a5fa;
|
| 79 |
+
--primary-foreground: #0a0f1a;
|
| 80 |
+
--secondary: #1e293b;
|
| 81 |
+
--secondary-foreground: #e6edf7;
|
| 82 |
+
--muted: #1e293b;
|
| 83 |
+
--muted-foreground: #94a3b8;
|
| 84 |
+
--accent: #1e3a8a;
|
| 85 |
+
--accent-foreground: #dbeafe;
|
| 86 |
+
--destructive: #f87171;
|
| 87 |
+
--destructive-foreground: #0a0f1a;
|
| 88 |
+
--success: #34d399;
|
| 89 |
+
--success-foreground: #0a0f1a;
|
| 90 |
+
--warning: #fbbf24;
|
| 91 |
+
--warning-foreground: #0a0f1a;
|
| 92 |
+
--border: #1e293b;
|
| 93 |
+
--input: #1e293b;
|
| 94 |
+
--ring: #60a5fa;
|
| 95 |
+
--sidebar: #060a13;
|
| 96 |
+
--sidebar-foreground: #cbd5e1;
|
| 97 |
+
--sidebar-border: #1e293b;
|
| 98 |
+
--sidebar-accent: #1e293b;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
* {
|
| 102 |
+
border-color: var(--border);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
html,
|
| 106 |
+
body {
|
| 107 |
+
background: var(--background);
|
| 108 |
+
color: var(--foreground);
|
| 109 |
+
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
| 110 |
+
-webkit-font-smoothing: antialiased;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
::-webkit-scrollbar {
|
| 114 |
+
width: 10px;
|
| 115 |
+
height: 10px;
|
| 116 |
+
}
|
| 117 |
+
::-webkit-scrollbar-track {
|
| 118 |
+
background: transparent;
|
| 119 |
+
}
|
| 120 |
+
::-webkit-scrollbar-thumb {
|
| 121 |
+
background: var(--border);
|
| 122 |
+
border-radius: 9999px;
|
| 123 |
+
}
|
| 124 |
+
::-webkit-scrollbar-thumb:hover {
|
| 125 |
+
background: var(--muted-foreground);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* Ticket print styles */
|
| 129 |
+
@media print {
|
| 130 |
+
body * {
|
| 131 |
+
visibility: hidden;
|
| 132 |
+
}
|
| 133 |
+
.print-area,
|
| 134 |
+
.print-area * {
|
| 135 |
+
visibility: visible;
|
| 136 |
+
}
|
| 137 |
+
.print-area {
|
| 138 |
+
position: absolute;
|
| 139 |
+
left: 0;
|
| 140 |
+
top: 0;
|
| 141 |
+
width: 100%;
|
| 142 |
+
padding: 1rem;
|
| 143 |
+
background: #fff !important;
|
| 144 |
+
color: #000 !important;
|
| 145 |
+
}
|
| 146 |
+
}
|
frontend/src/app/layout.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter, JetBrains_Mono } from "next/font/google";
|
| 3 |
+
import { ThemeRoot } from "@/components/providers";
|
| 4 |
+
import "./globals.css";
|
| 5 |
+
|
| 6 |
+
const inter = Inter({
|
| 7 |
+
variable: "--font-inter",
|
| 8 |
+
subsets: ["latin"],
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
const mono = JetBrains_Mono({
|
| 12 |
+
variable: "--font-jetbrains-mono",
|
| 13 |
+
subsets: ["latin"],
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
export const metadata: Metadata = {
|
| 17 |
+
title: "BeatSense — Cardiovascular Risk Intelligence",
|
| 18 |
+
description:
|
| 19 |
+
"Clinical workstation for cardiovascular risk assessment powered by ML.",
|
| 20 |
+
icons: { icon: "/logu1.png" },
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
export default function RootLayout({
|
| 24 |
+
children,
|
| 25 |
+
}: {
|
| 26 |
+
children: React.ReactNode;
|
| 27 |
+
}) {
|
| 28 |
+
return (
|
| 29 |
+
<html suppressHydrationWarning>
|
| 30 |
+
<body
|
| 31 |
+
className={`${inter.variable} ${mono.variable} min-h-screen antialiased`}
|
| 32 |
+
>
|
| 33 |
+
<ThemeRoot>{children}</ThemeRoot>
|
| 34 |
+
</body>
|
| 35 |
+
</html>
|
| 36 |
+
);
|
| 37 |
+
}
|
frontend/src/components/charts/eda-charts.tsx
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useQuery } from "@tanstack/react-query";
|
| 4 |
+
import {
|
| 5 |
+
BarChart,
|
| 6 |
+
Bar,
|
| 7 |
+
XAxis,
|
| 8 |
+
YAxis,
|
| 9 |
+
Tooltip,
|
| 10 |
+
ResponsiveContainer,
|
| 11 |
+
CartesianGrid,
|
| 12 |
+
ScatterChart,
|
| 13 |
+
Scatter,
|
| 14 |
+
Legend,
|
| 15 |
+
Cell,
|
| 16 |
+
PieChart,
|
| 17 |
+
Pie,
|
| 18 |
+
} from "recharts";
|
| 19 |
+
import { api } from "@/lib/api";
|
| 20 |
+
import { useT } from "@/i18n/context";
|
| 21 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 22 |
+
|
| 23 |
+
const TOOLTIP = {
|
| 24 |
+
contentStyle: {
|
| 25 |
+
background: "var(--popover)",
|
| 26 |
+
border: "1px solid var(--border)",
|
| 27 |
+
borderRadius: 8,
|
| 28 |
+
fontSize: 12,
|
| 29 |
+
color: "var(--popover-foreground)",
|
| 30 |
+
},
|
| 31 |
+
cursor: { fill: "var(--muted)", opacity: 0.3 },
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
export function EdaCharts() {
|
| 35 |
+
const { dict } = useT();
|
| 36 |
+
const { data, isLoading } = useQuery({ queryKey: ["eda"], queryFn: api.eda });
|
| 37 |
+
|
| 38 |
+
if (isLoading || !data) {
|
| 39 |
+
return (
|
| 40 |
+
<Card>
|
| 41 |
+
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
| 42 |
+
{dict.common.loading}
|
| 43 |
+
</CardContent>
|
| 44 |
+
</Card>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="grid gap-6 lg:grid-cols-2">
|
| 50 |
+
<ChartCard title={dict.eda.ageDist}>
|
| 51 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 52 |
+
<BarChart data={data.age_dist}>
|
| 53 |
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
| 54 |
+
<XAxis dataKey="bin" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
|
| 55 |
+
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
|
| 56 |
+
<Tooltip {...TOOLTIP} />
|
| 57 |
+
<Bar dataKey="count" fill="var(--primary)" radius={[4, 4, 0, 0]} />
|
| 58 |
+
</BarChart>
|
| 59 |
+
</ResponsiveContainer>
|
| 60 |
+
</ChartCard>
|
| 61 |
+
|
| 62 |
+
<ChartCard title={dict.eda.sexDist}>
|
| 63 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 64 |
+
<PieChart>
|
| 65 |
+
<Pie
|
| 66 |
+
data={data.sex_dist}
|
| 67 |
+
dataKey="count"
|
| 68 |
+
nameKey="sex"
|
| 69 |
+
innerRadius={50}
|
| 70 |
+
outerRadius={90}
|
| 71 |
+
label
|
| 72 |
+
>
|
| 73 |
+
{data.sex_dist.map((_, i) => (
|
| 74 |
+
<Cell key={i} fill={i === 0 ? "var(--primary)" : "var(--accent)"} />
|
| 75 |
+
))}
|
| 76 |
+
</Pie>
|
| 77 |
+
<Tooltip {...TOOLTIP} />
|
| 78 |
+
<Legend wrapperStyle={{ fontSize: 12 }} />
|
| 79 |
+
</PieChart>
|
| 80 |
+
</ResponsiveContainer>
|
| 81 |
+
</ChartCard>
|
| 82 |
+
|
| 83 |
+
<ChartCard title={dict.eda.chestPainDist}>
|
| 84 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 85 |
+
<BarChart data={data.chest_pain_dist}>
|
| 86 |
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
| 87 |
+
<XAxis dataKey="type" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
|
| 88 |
+
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
|
| 89 |
+
<Tooltip {...TOOLTIP} />
|
| 90 |
+
<Bar dataKey="count" fill="var(--primary)" radius={[4, 4, 0, 0]} />
|
| 91 |
+
<Bar dataKey="risk_rate" fill="var(--destructive)" radius={[4, 4, 0, 0]} />
|
| 92 |
+
</BarChart>
|
| 93 |
+
</ResponsiveContainer>
|
| 94 |
+
</ChartCard>
|
| 95 |
+
|
| 96 |
+
<ChartCard title={dict.eda.maxHrVsAge}>
|
| 97 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 98 |
+
<ScatterChart>
|
| 99 |
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
| 100 |
+
<XAxis
|
| 101 |
+
dataKey="Age"
|
| 102 |
+
type="number"
|
| 103 |
+
tick={{ fontSize: 11 }}
|
| 104 |
+
stroke="var(--muted-foreground)"
|
| 105 |
+
name="Age"
|
| 106 |
+
/>
|
| 107 |
+
<YAxis
|
| 108 |
+
dataKey="MaxHR"
|
| 109 |
+
type="number"
|
| 110 |
+
tick={{ fontSize: 11 }}
|
| 111 |
+
stroke="var(--muted-foreground)"
|
| 112 |
+
name="MaxHR"
|
| 113 |
+
/>
|
| 114 |
+
<Tooltip {...TOOLTIP} />
|
| 115 |
+
<Scatter
|
| 116 |
+
data={data.scatter.filter((d) => d.HeartDisease === 0)}
|
| 117 |
+
fill="var(--success)"
|
| 118 |
+
name={dict.common.low}
|
| 119 |
+
/>
|
| 120 |
+
<Scatter
|
| 121 |
+
data={data.scatter.filter((d) => d.HeartDisease === 1)}
|
| 122 |
+
fill="var(--destructive)"
|
| 123 |
+
name={dict.common.high}
|
| 124 |
+
/>
|
| 125 |
+
<Legend wrapperStyle={{ fontSize: 12 }} />
|
| 126 |
+
</ScatterChart>
|
| 127 |
+
</ResponsiveContainer>
|
| 128 |
+
</ChartCard>
|
| 129 |
+
</div>
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function ChartCard({
|
| 134 |
+
title,
|
| 135 |
+
children,
|
| 136 |
+
}: {
|
| 137 |
+
title: string;
|
| 138 |
+
children: React.ReactNode;
|
| 139 |
+
}) {
|
| 140 |
+
return (
|
| 141 |
+
<Card>
|
| 142 |
+
<CardContent className="p-6">
|
| 143 |
+
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
| 144 |
+
{title}
|
| 145 |
+
</h2>
|
| 146 |
+
{children}
|
| 147 |
+
</CardContent>
|
| 148 |
+
</Card>
|
| 149 |
+
);
|
| 150 |
+
}
|
frontend/src/components/dashboard/dashboard-client.tsx
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { useQuery } from "@tanstack/react-query";
|
| 5 |
+
import {
|
| 6 |
+
Activity,
|
| 7 |
+
AlertTriangle,
|
| 8 |
+
CalendarDays,
|
| 9 |
+
HeartPulse,
|
| 10 |
+
TrendingUp,
|
| 11 |
+
} from "lucide-react";
|
| 12 |
+
import {
|
| 13 |
+
PieChart,
|
| 14 |
+
Pie,
|
| 15 |
+
Cell,
|
| 16 |
+
ResponsiveContainer,
|
| 17 |
+
Legend,
|
| 18 |
+
Tooltip,
|
| 19 |
+
} from "recharts";
|
| 20 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 21 |
+
import { Badge } from "@/components/ui/badge";
|
| 22 |
+
import { Button } from "@/components/ui/button";
|
| 23 |
+
import { api } from "@/lib/api";
|
| 24 |
+
import { useT } from "@/i18n/context";
|
| 25 |
+
|
| 26 |
+
export function DashboardClient() {
|
| 27 |
+
const { dict, locale } = useT();
|
| 28 |
+
const stats = useQuery({ queryKey: ["stats"], queryFn: api.stats });
|
| 29 |
+
const history = useQuery({ queryKey: ["history"], queryFn: api.history });
|
| 30 |
+
|
| 31 |
+
const s = stats.data ?? {
|
| 32 |
+
total: 0,
|
| 33 |
+
high_risk: 0,
|
| 34 |
+
low_risk: 0,
|
| 35 |
+
high_risk_pct: 0,
|
| 36 |
+
today: 0,
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const recent = (history.data ?? []).slice(-8).reverse();
|
| 40 |
+
|
| 41 |
+
const pieData = [
|
| 42 |
+
{ name: dict.dashboard.kpi.lowRisk, value: s.low_risk, color: "var(--success)" },
|
| 43 |
+
{ name: dict.dashboard.kpi.highRisk, value: s.high_risk, color: "var(--destructive)" },
|
| 44 |
+
];
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div className="grid gap-6">
|
| 48 |
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
| 49 |
+
<KpiCard
|
| 50 |
+
icon={<Activity className="h-4 w-4" />}
|
| 51 |
+
label={dict.dashboard.kpi.totalAssessments}
|
| 52 |
+
value={s.total.toString()}
|
| 53 |
+
/>
|
| 54 |
+
<KpiCard
|
| 55 |
+
icon={<CalendarDays className="h-4 w-4" />}
|
| 56 |
+
label={dict.dashboard.kpi.todayAssessments}
|
| 57 |
+
value={s.today.toString()}
|
| 58 |
+
/>
|
| 59 |
+
<KpiCard
|
| 60 |
+
icon={<AlertTriangle className="h-4 w-4" />}
|
| 61 |
+
label={dict.dashboard.kpi.highRiskPct}
|
| 62 |
+
value={`${s.high_risk_pct}%`}
|
| 63 |
+
tone={s.high_risk_pct >= 40 ? "danger" : "default"}
|
| 64 |
+
/>
|
| 65 |
+
<KpiCard
|
| 66 |
+
icon={<TrendingUp className="h-4 w-4" />}
|
| 67 |
+
label={dict.dashboard.kpi.highRisk}
|
| 68 |
+
value={s.high_risk.toString()}
|
| 69 |
+
tone="danger"
|
| 70 |
+
/>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div className="grid gap-6 lg:grid-cols-3">
|
| 74 |
+
<Card className="lg:col-span-2">
|
| 75 |
+
<CardContent className="p-6">
|
| 76 |
+
<div className="mb-4 flex items-center justify-between">
|
| 77 |
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
| 78 |
+
{dict.dashboard.recentTitle}
|
| 79 |
+
</h2>
|
| 80 |
+
<Button asChild size="sm">
|
| 81 |
+
<Link href={`/${locale}/new-evaluation`}>
|
| 82 |
+
<HeartPulse className="h-4 w-4" />
|
| 83 |
+
{dict.dashboard.quickAction}
|
| 84 |
+
</Link>
|
| 85 |
+
</Button>
|
| 86 |
+
</div>
|
| 87 |
+
{recent.length === 0 ? (
|
| 88 |
+
<div className="rounded-md border border-dashed border-border py-10 text-center text-sm text-muted-foreground">
|
| 89 |
+
{dict.dashboard.recentEmpty}
|
| 90 |
+
</div>
|
| 91 |
+
) : (
|
| 92 |
+
<div className="overflow-hidden rounded-md border border-border">
|
| 93 |
+
<table className="w-full text-sm">
|
| 94 |
+
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
| 95 |
+
<tr>
|
| 96 |
+
<th className="px-3 py-2 text-left">{dict.history.columns.patientId}</th>
|
| 97 |
+
<th className="px-3 py-2 text-left">{dict.history.columns.date}</th>
|
| 98 |
+
<th className="px-3 py-2 text-left">{dict.history.columns.age}</th>
|
| 99 |
+
<th className="px-3 py-2 text-left">{dict.history.columns.sex}</th>
|
| 100 |
+
<th className="px-3 py-2 text-right">{dict.history.columns.probability}</th>
|
| 101 |
+
<th className="px-3 py-2 text-right">{dict.history.columns.risk}</th>
|
| 102 |
+
</tr>
|
| 103 |
+
</thead>
|
| 104 |
+
<tbody>
|
| 105 |
+
{recent.map((r, i) => (
|
| 106 |
+
<tr
|
| 107 |
+
key={`${r.patient_id}-${i}`}
|
| 108 |
+
className="border-t border-border"
|
| 109 |
+
>
|
| 110 |
+
<td className="px-3 py-2 font-mono text-xs">{r.patient_id}</td>
|
| 111 |
+
<td className="px-3 py-2">{r.date} · {r.time}</td>
|
| 112 |
+
<td className="px-3 py-2">{r.age}</td>
|
| 113 |
+
<td className="px-3 py-2">{r.sex}</td>
|
| 114 |
+
<td className="px-3 py-2 text-right font-semibold">
|
| 115 |
+
{r.probability.toFixed(1)}%
|
| 116 |
+
</td>
|
| 117 |
+
<td className="px-3 py-2 text-right">
|
| 118 |
+
<Badge variant={r.risk === "ALTO" ? "destructive" : "success"}>
|
| 119 |
+
{r.risk === "ALTO"
|
| 120 |
+
? dict.common.high
|
| 121 |
+
: dict.common.low}
|
| 122 |
+
</Badge>
|
| 123 |
+
</td>
|
| 124 |
+
</tr>
|
| 125 |
+
))}
|
| 126 |
+
</tbody>
|
| 127 |
+
</table>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
</CardContent>
|
| 131 |
+
</Card>
|
| 132 |
+
|
| 133 |
+
<Card>
|
| 134 |
+
<CardContent className="p-6">
|
| 135 |
+
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
| 136 |
+
{dict.dashboard.riskDistribution}
|
| 137 |
+
</h2>
|
| 138 |
+
<div className="h-64">
|
| 139 |
+
{s.total === 0 ? (
|
| 140 |
+
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
| 141 |
+
{dict.common.noData}
|
| 142 |
+
</div>
|
| 143 |
+
) : (
|
| 144 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 145 |
+
<PieChart>
|
| 146 |
+
<Pie
|
| 147 |
+
data={pieData}
|
| 148 |
+
dataKey="value"
|
| 149 |
+
nameKey="name"
|
| 150 |
+
innerRadius={50}
|
| 151 |
+
outerRadius={80}
|
| 152 |
+
paddingAngle={2}
|
| 153 |
+
>
|
| 154 |
+
{pieData.map((d, i) => (
|
| 155 |
+
<Cell key={i} fill={d.color} />
|
| 156 |
+
))}
|
| 157 |
+
</Pie>
|
| 158 |
+
<Tooltip
|
| 159 |
+
contentStyle={{
|
| 160 |
+
background: "var(--popover)",
|
| 161 |
+
border: "1px solid var(--border)",
|
| 162 |
+
borderRadius: 8,
|
| 163 |
+
fontSize: 12,
|
| 164 |
+
}}
|
| 165 |
+
/>
|
| 166 |
+
<Legend wrapperStyle={{ fontSize: 12 }} />
|
| 167 |
+
</PieChart>
|
| 168 |
+
</ResponsiveContainer>
|
| 169 |
+
)}
|
| 170 |
+
</div>
|
| 171 |
+
</CardContent>
|
| 172 |
+
</Card>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function KpiCard({
|
| 179 |
+
icon,
|
| 180 |
+
label,
|
| 181 |
+
value,
|
| 182 |
+
tone = "default",
|
| 183 |
+
}: {
|
| 184 |
+
icon: React.ReactNode;
|
| 185 |
+
label: string;
|
| 186 |
+
value: string;
|
| 187 |
+
tone?: "default" | "danger";
|
| 188 |
+
}) {
|
| 189 |
+
return (
|
| 190 |
+
<Card>
|
| 191 |
+
<CardContent className="flex items-center justify-between gap-4 p-5">
|
| 192 |
+
<div>
|
| 193 |
+
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
| 194 |
+
{label}
|
| 195 |
+
</div>
|
| 196 |
+
<div
|
| 197 |
+
className={`mt-1 text-3xl font-bold ${
|
| 198 |
+
tone === "danger" ? "text-destructive" : "text-foreground"
|
| 199 |
+
}`}
|
| 200 |
+
>
|
| 201 |
+
{value}
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
<div
|
| 205 |
+
className={`flex h-11 w-11 items-center justify-center rounded-lg ${
|
| 206 |
+
tone === "danger"
|
| 207 |
+
? "bg-destructive/10 text-destructive"
|
| 208 |
+
: "bg-primary/10 text-primary"
|
| 209 |
+
}`}
|
| 210 |
+
>
|
| 211 |
+
{icon}
|
| 212 |
+
</div>
|
| 213 |
+
</CardContent>
|
| 214 |
+
</Card>
|
| 215 |
+
);
|
| 216 |
+
}
|
frontend/src/components/forms/evaluation-form.tsx
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useForm } from "react-hook-form";
|
| 4 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 5 |
+
import { z } from "zod";
|
| 6 |
+
import { useMutation } from "@tanstack/react-query";
|
| 7 |
+
import { useState } from "react";
|
| 8 |
+
import {
|
| 9 |
+
Heart,
|
| 10 |
+
Stethoscope,
|
| 11 |
+
ClipboardList,
|
| 12 |
+
Loader2,
|
| 13 |
+
RotateCcw,
|
| 14 |
+
Activity,
|
| 15 |
+
} from "lucide-react";
|
| 16 |
+
import { useT } from "@/i18n/context";
|
| 17 |
+
import { api, type PredictRequest, type PredictResponse } from "@/lib/api";
|
| 18 |
+
import { Button } from "@/components/ui/button";
|
| 19 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 20 |
+
import { Input } from "@/components/ui/input";
|
| 21 |
+
import { Label } from "@/components/ui/label";
|
| 22 |
+
import {
|
| 23 |
+
Select,
|
| 24 |
+
SelectContent,
|
| 25 |
+
SelectItem,
|
| 26 |
+
SelectTrigger,
|
| 27 |
+
SelectValue,
|
| 28 |
+
} from "@/components/ui/select";
|
| 29 |
+
import { Ticket } from "@/components/ticket/ticket";
|
| 30 |
+
|
| 31 |
+
const numberOrEmpty = z
|
| 32 |
+
.union([z.string(), z.number()])
|
| 33 |
+
.transform((v) => {
|
| 34 |
+
if (typeof v === "number") return v;
|
| 35 |
+
if (v === "" || v == null) return undefined;
|
| 36 |
+
const n = Number(v);
|
| 37 |
+
return Number.isFinite(n) ? n : undefined;
|
| 38 |
+
})
|
| 39 |
+
.optional();
|
| 40 |
+
|
| 41 |
+
const requiredNumber = z
|
| 42 |
+
.union([z.string(), z.number()])
|
| 43 |
+
.transform((v) => Number(v))
|
| 44 |
+
.refine((n) => Number.isFinite(n), { message: "Invalid number" });
|
| 45 |
+
|
| 46 |
+
const schema = z.object({
|
| 47 |
+
patient_id: z.string().max(64).optional(),
|
| 48 |
+
age: requiredNumber.refine((n) => n >= 1 && n <= 120, "Age out of range"),
|
| 49 |
+
sex: z.enum(["M", "F"]),
|
| 50 |
+
resting_bp: numberOrEmpty,
|
| 51 |
+
cholesterol: numberOrEmpty,
|
| 52 |
+
max_hr: requiredNumber.refine((n) => n >= 40 && n <= 240, "HR out of range"),
|
| 53 |
+
fasting_bs: z.enum(["0", "1"]),
|
| 54 |
+
exercise_angina: z.enum(["Y", "N"]),
|
| 55 |
+
chest_pain_type: z.enum(["ASY", "ATA", "NAP", "TA"]),
|
| 56 |
+
resting_ecg: z.enum(["Normal", "ST", "LVH"]).optional(),
|
| 57 |
+
st_slope: z.enum(["Up", "Flat", "Down"]),
|
| 58 |
+
oldpeak: numberOrEmpty,
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
type FormValues = z.input<typeof schema>;
|
| 62 |
+
type FormOutput = z.output<typeof schema>;
|
| 63 |
+
|
| 64 |
+
const DEFAULTS: Partial<FormValues> = {
|
| 65 |
+
patient_id: "",
|
| 66 |
+
sex: "M",
|
| 67 |
+
fasting_bs: "0",
|
| 68 |
+
exercise_angina: "N",
|
| 69 |
+
chest_pain_type: "ASY",
|
| 70 |
+
resting_ecg: "Normal",
|
| 71 |
+
st_slope: "Flat",
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
export function EvaluationForm() {
|
| 75 |
+
const { dict } = useT();
|
| 76 |
+
const [result, setResult] = useState<{
|
| 77 |
+
response: PredictResponse;
|
| 78 |
+
snapshot: FormOutput;
|
| 79 |
+
} | null>(null);
|
| 80 |
+
|
| 81 |
+
const {
|
| 82 |
+
register,
|
| 83 |
+
handleSubmit,
|
| 84 |
+
reset,
|
| 85 |
+
setValue,
|
| 86 |
+
watch,
|
| 87 |
+
formState: { errors },
|
| 88 |
+
} = useForm<FormValues, unknown, FormOutput>({
|
| 89 |
+
resolver: zodResolver(schema),
|
| 90 |
+
defaultValues: DEFAULTS,
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
const mutation = useMutation({
|
| 94 |
+
mutationFn: (values: PredictRequest) => api.predict(values),
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
const onSubmit = handleSubmit(async (values) => {
|
| 98 |
+
const payload: PredictRequest = {
|
| 99 |
+
patient_id: values.patient_id || null,
|
| 100 |
+
age: values.age,
|
| 101 |
+
sex: values.sex,
|
| 102 |
+
chest_pain_type: values.chest_pain_type,
|
| 103 |
+
max_hr: values.max_hr,
|
| 104 |
+
fasting_bs: Number(values.fasting_bs) as 0 | 1,
|
| 105 |
+
exercise_angina: values.exercise_angina,
|
| 106 |
+
st_slope: values.st_slope,
|
| 107 |
+
resting_bp: values.resting_bp ?? null,
|
| 108 |
+
cholesterol: values.cholesterol ?? null,
|
| 109 |
+
resting_ecg: values.resting_ecg ?? null,
|
| 110 |
+
oldpeak: values.oldpeak ?? null,
|
| 111 |
+
};
|
| 112 |
+
const response = await mutation.mutateAsync(payload);
|
| 113 |
+
setResult({ response, snapshot: values });
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
if (result) {
|
| 117 |
+
return (
|
| 118 |
+
<Ticket
|
| 119 |
+
response={result.response}
|
| 120 |
+
snapshot={result.snapshot}
|
| 121 |
+
onNew={() => {
|
| 122 |
+
setResult(null);
|
| 123 |
+
reset(DEFAULTS);
|
| 124 |
+
}}
|
| 125 |
+
/>
|
| 126 |
+
);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<form onSubmit={onSubmit} className="space-y-6">
|
| 131 |
+
<Card>
|
| 132 |
+
<CardContent className="grid gap-6 p-6">
|
| 133 |
+
<SectionTitle
|
| 134 |
+
icon={<ClipboardList className="h-4 w-4" />}
|
| 135 |
+
label={dict.form.sections.demographics}
|
| 136 |
+
/>
|
| 137 |
+
<div className="grid gap-4 sm:grid-cols-3">
|
| 138 |
+
<Field
|
| 139 |
+
label={dict.form.fields.patientId}
|
| 140 |
+
error={errors.patient_id?.message}
|
| 141 |
+
>
|
| 142 |
+
<Input
|
| 143 |
+
placeholder={dict.form.fields.patientIdPlaceholder}
|
| 144 |
+
{...register("patient_id")}
|
| 145 |
+
/>
|
| 146 |
+
</Field>
|
| 147 |
+
<Field label={dict.form.fields.age} error={errors.age?.message}>
|
| 148 |
+
<Input
|
| 149 |
+
type="number"
|
| 150 |
+
min={1}
|
| 151 |
+
max={120}
|
| 152 |
+
{...register("age")}
|
| 153 |
+
/>
|
| 154 |
+
</Field>
|
| 155 |
+
<Field label={dict.form.fields.sex} error={errors.sex?.message}>
|
| 156 |
+
<Select
|
| 157 |
+
value={watch("sex")}
|
| 158 |
+
onValueChange={(v) => setValue("sex", v as "M" | "F")}
|
| 159 |
+
>
|
| 160 |
+
<SelectTrigger>
|
| 161 |
+
<SelectValue />
|
| 162 |
+
</SelectTrigger>
|
| 163 |
+
<SelectContent>
|
| 164 |
+
<SelectItem value="M">{dict.common.male}</SelectItem>
|
| 165 |
+
<SelectItem value="F">{dict.common.female}</SelectItem>
|
| 166 |
+
</SelectContent>
|
| 167 |
+
</Select>
|
| 168 |
+
</Field>
|
| 169 |
+
</div>
|
| 170 |
+
</CardContent>
|
| 171 |
+
</Card>
|
| 172 |
+
|
| 173 |
+
<Card>
|
| 174 |
+
<CardContent className="grid gap-6 p-6">
|
| 175 |
+
<SectionTitle
|
| 176 |
+
icon={<Activity className="h-4 w-4" />}
|
| 177 |
+
label={dict.form.sections.vitals}
|
| 178 |
+
/>
|
| 179 |
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
| 180 |
+
<Field
|
| 181 |
+
label={dict.form.fields.restingBp}
|
| 182 |
+
error={errors.resting_bp?.message}
|
| 183 |
+
>
|
| 184 |
+
<Input type="number" min={60} max={250} {...register("resting_bp")} />
|
| 185 |
+
</Field>
|
| 186 |
+
<Field
|
| 187 |
+
label={dict.form.fields.cholesterol}
|
| 188 |
+
error={errors.cholesterol?.message}
|
| 189 |
+
>
|
| 190 |
+
<Input type="number" min={0} max={700} {...register("cholesterol")} />
|
| 191 |
+
</Field>
|
| 192 |
+
<Field label={dict.form.fields.maxHr} error={errors.max_hr?.message}>
|
| 193 |
+
<Input type="number" min={40} max={240} {...register("max_hr")} />
|
| 194 |
+
</Field>
|
| 195 |
+
<Field
|
| 196 |
+
label={dict.form.fields.fastingBs}
|
| 197 |
+
error={errors.fasting_bs?.message}
|
| 198 |
+
>
|
| 199 |
+
<Select
|
| 200 |
+
value={watch("fasting_bs")}
|
| 201 |
+
onValueChange={(v) =>
|
| 202 |
+
setValue("fasting_bs", v as "0" | "1")
|
| 203 |
+
}
|
| 204 |
+
>
|
| 205 |
+
<SelectTrigger>
|
| 206 |
+
<SelectValue />
|
| 207 |
+
</SelectTrigger>
|
| 208 |
+
<SelectContent>
|
| 209 |
+
<SelectItem value="0">{dict.common.no}</SelectItem>
|
| 210 |
+
<SelectItem value="1">{dict.common.yes}</SelectItem>
|
| 211 |
+
</SelectContent>
|
| 212 |
+
</Select>
|
| 213 |
+
</Field>
|
| 214 |
+
</div>
|
| 215 |
+
</CardContent>
|
| 216 |
+
</Card>
|
| 217 |
+
|
| 218 |
+
<Card>
|
| 219 |
+
<CardContent className="grid gap-6 p-6">
|
| 220 |
+
<SectionTitle
|
| 221 |
+
icon={<Stethoscope className="h-4 w-4" />}
|
| 222 |
+
label={dict.form.sections.symptoms}
|
| 223 |
+
/>
|
| 224 |
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
| 225 |
+
<Field
|
| 226 |
+
label={dict.form.fields.chestPainType}
|
| 227 |
+
error={errors.chest_pain_type?.message}
|
| 228 |
+
>
|
| 229 |
+
<Select
|
| 230 |
+
value={watch("chest_pain_type")}
|
| 231 |
+
onValueChange={(v) =>
|
| 232 |
+
setValue("chest_pain_type", v as FormValues["chest_pain_type"])
|
| 233 |
+
}
|
| 234 |
+
>
|
| 235 |
+
<SelectTrigger>
|
| 236 |
+
<SelectValue />
|
| 237 |
+
</SelectTrigger>
|
| 238 |
+
<SelectContent>
|
| 239 |
+
{(["ASY", "ATA", "NAP", "TA"] as const).map((k) => (
|
| 240 |
+
<SelectItem key={k} value={k}>
|
| 241 |
+
{dict.form.options.chestPain[k]}
|
| 242 |
+
</SelectItem>
|
| 243 |
+
))}
|
| 244 |
+
</SelectContent>
|
| 245 |
+
</Select>
|
| 246 |
+
</Field>
|
| 247 |
+
|
| 248 |
+
<Field
|
| 249 |
+
label={dict.form.fields.exerciseAngina}
|
| 250 |
+
error={errors.exercise_angina?.message}
|
| 251 |
+
>
|
| 252 |
+
<Select
|
| 253 |
+
value={watch("exercise_angina")}
|
| 254 |
+
onValueChange={(v) =>
|
| 255 |
+
setValue("exercise_angina", v as "Y" | "N")
|
| 256 |
+
}
|
| 257 |
+
>
|
| 258 |
+
<SelectTrigger>
|
| 259 |
+
<SelectValue />
|
| 260 |
+
</SelectTrigger>
|
| 261 |
+
<SelectContent>
|
| 262 |
+
<SelectItem value="N">{dict.common.no}</SelectItem>
|
| 263 |
+
<SelectItem value="Y">{dict.common.yes}</SelectItem>
|
| 264 |
+
</SelectContent>
|
| 265 |
+
</Select>
|
| 266 |
+
</Field>
|
| 267 |
+
|
| 268 |
+
<Field
|
| 269 |
+
label={dict.form.fields.restingEcg}
|
| 270 |
+
error={errors.resting_ecg?.message}
|
| 271 |
+
>
|
| 272 |
+
<Select
|
| 273 |
+
value={watch("resting_ecg") ?? "Normal"}
|
| 274 |
+
onValueChange={(v) =>
|
| 275 |
+
setValue("resting_ecg", v as FormValues["resting_ecg"])
|
| 276 |
+
}
|
| 277 |
+
>
|
| 278 |
+
<SelectTrigger>
|
| 279 |
+
<SelectValue />
|
| 280 |
+
</SelectTrigger>
|
| 281 |
+
<SelectContent>
|
| 282 |
+
{(["Normal", "ST", "LVH"] as const).map((k) => (
|
| 283 |
+
<SelectItem key={k} value={k}>
|
| 284 |
+
{dict.form.options.restingEcg[k]}
|
| 285 |
+
</SelectItem>
|
| 286 |
+
))}
|
| 287 |
+
</SelectContent>
|
| 288 |
+
</Select>
|
| 289 |
+
</Field>
|
| 290 |
+
|
| 291 |
+
<Field
|
| 292 |
+
label={dict.form.fields.stSlope}
|
| 293 |
+
error={errors.st_slope?.message}
|
| 294 |
+
>
|
| 295 |
+
<Select
|
| 296 |
+
value={watch("st_slope")}
|
| 297 |
+
onValueChange={(v) =>
|
| 298 |
+
setValue("st_slope", v as FormValues["st_slope"])
|
| 299 |
+
}
|
| 300 |
+
>
|
| 301 |
+
<SelectTrigger>
|
| 302 |
+
<SelectValue />
|
| 303 |
+
</SelectTrigger>
|
| 304 |
+
<SelectContent>
|
| 305 |
+
{(["Up", "Flat", "Down"] as const).map((k) => (
|
| 306 |
+
<SelectItem key={k} value={k}>
|
| 307 |
+
{dict.form.options.stSlope[k]}
|
| 308 |
+
</SelectItem>
|
| 309 |
+
))}
|
| 310 |
+
</SelectContent>
|
| 311 |
+
</Select>
|
| 312 |
+
</Field>
|
| 313 |
+
|
| 314 |
+
<Field
|
| 315 |
+
label={dict.form.fields.oldpeak}
|
| 316 |
+
error={errors.oldpeak?.message}
|
| 317 |
+
>
|
| 318 |
+
<Input
|
| 319 |
+
type="number"
|
| 320 |
+
step="0.1"
|
| 321 |
+
min={-5}
|
| 322 |
+
max={10}
|
| 323 |
+
{...register("oldpeak")}
|
| 324 |
+
/>
|
| 325 |
+
</Field>
|
| 326 |
+
</div>
|
| 327 |
+
</CardContent>
|
| 328 |
+
</Card>
|
| 329 |
+
|
| 330 |
+
{mutation.error && (
|
| 331 |
+
<div className="rounded-md border border-destructive bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
| 332 |
+
{dict.result.error}
|
| 333 |
+
</div>
|
| 334 |
+
)}
|
| 335 |
+
|
| 336 |
+
<div className="flex flex-wrap items-center justify-end gap-3">
|
| 337 |
+
<Button
|
| 338 |
+
type="button"
|
| 339 |
+
variant="ghost"
|
| 340 |
+
onClick={() => reset(DEFAULTS)}
|
| 341 |
+
disabled={mutation.isPending}
|
| 342 |
+
>
|
| 343 |
+
<RotateCcw className="h-4 w-4" />
|
| 344 |
+
{dict.form.actions.reset}
|
| 345 |
+
</Button>
|
| 346 |
+
<Button type="submit" size="lg" disabled={mutation.isPending}>
|
| 347 |
+
{mutation.isPending ? (
|
| 348 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 349 |
+
) : (
|
| 350 |
+
<Heart className="h-4 w-4" />
|
| 351 |
+
)}
|
| 352 |
+
{mutation.isPending ? dict.result.predicting : dict.form.actions.submit}
|
| 353 |
+
</Button>
|
| 354 |
+
</div>
|
| 355 |
+
</form>
|
| 356 |
+
);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
function SectionTitle({
|
| 360 |
+
icon,
|
| 361 |
+
label,
|
| 362 |
+
}: {
|
| 363 |
+
icon: React.ReactNode;
|
| 364 |
+
label: string;
|
| 365 |
+
}) {
|
| 366 |
+
return (
|
| 367 |
+
<div className="flex items-center gap-2 border-b border-border pb-3">
|
| 368 |
+
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 text-primary">
|
| 369 |
+
{icon}
|
| 370 |
+
</div>
|
| 371 |
+
<span className="text-sm font-semibold tracking-wide">{label}</span>
|
| 372 |
+
</div>
|
| 373 |
+
);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
function Field({
|
| 377 |
+
label,
|
| 378 |
+
error,
|
| 379 |
+
children,
|
| 380 |
+
}: {
|
| 381 |
+
label: string;
|
| 382 |
+
error?: string;
|
| 383 |
+
children: React.ReactNode;
|
| 384 |
+
}) {
|
| 385 |
+
return (
|
| 386 |
+
<div className="space-y-1.5">
|
| 387 |
+
<Label>{label}</Label>
|
| 388 |
+
{children}
|
| 389 |
+
{error && <p className="text-xs text-destructive">{error}</p>}
|
| 390 |
+
</div>
|
| 391 |
+
);
|
| 392 |
+
}
|
frontend/src/components/layout/header.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Search, Activity } from "lucide-react";
|
| 4 |
+
import { useT } from "@/i18n/context";
|
| 5 |
+
import { ThemeToggle } from "./theme-toggle";
|
| 6 |
+
import { LanguageToggle } from "./language-toggle";
|
| 7 |
+
|
| 8 |
+
export function Header() {
|
| 9 |
+
const { dict } = useT();
|
| 10 |
+
const now = new Date();
|
| 11 |
+
const date = now.toLocaleDateString(undefined, {
|
| 12 |
+
weekday: "long",
|
| 13 |
+
day: "2-digit",
|
| 14 |
+
month: "short",
|
| 15 |
+
year: "numeric",
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<header className="sticky top-0 z-30 flex h-16 items-center justify-between gap-4 border-b border-border bg-card/80 px-6 backdrop-blur lg:px-8">
|
| 20 |
+
<div className="flex items-center gap-3">
|
| 21 |
+
<div className="relative hidden md:block">
|
| 22 |
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
| 23 |
+
<input
|
| 24 |
+
type="search"
|
| 25 |
+
placeholder={dict.header.search}
|
| 26 |
+
className="h-9 w-72 rounded-md border border-input bg-background pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
| 27 |
+
/>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div className="flex items-center gap-3">
|
| 32 |
+
<div className="hidden items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-xs text-muted-foreground md:flex">
|
| 33 |
+
<Activity className="h-3.5 w-3.5 text-success" />
|
| 34 |
+
<span className="font-medium">{dict.header.shift}</span>
|
| 35 |
+
<span className="text-muted-foreground/60">·</span>
|
| 36 |
+
<span>{date}</span>
|
| 37 |
+
</div>
|
| 38 |
+
<LanguageToggle />
|
| 39 |
+
<ThemeToggle />
|
| 40 |
+
<div className="flex items-center gap-2 border-l border-border pl-3">
|
| 41 |
+
<div className="hidden text-right text-xs leading-tight md:block">
|
| 42 |
+
<div className="font-semibold">{dict.header.user}</div>
|
| 43 |
+
<div className="text-muted-foreground">{dict.header.role}</div>
|
| 44 |
+
</div>
|
| 45 |
+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
| 46 |
+
NP
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</header>
|
| 51 |
+
);
|
| 52 |
+
}
|
frontend/src/components/layout/language-toggle.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Languages } from "lucide-react";
|
| 4 |
+
import { usePathname, useRouter } from "next/navigation";
|
| 5 |
+
import { locales, type Locale } from "@/i18n/config";
|
| 6 |
+
import { useT } from "@/i18n/context";
|
| 7 |
+
import {
|
| 8 |
+
Select,
|
| 9 |
+
SelectContent,
|
| 10 |
+
SelectItem,
|
| 11 |
+
SelectTrigger,
|
| 12 |
+
SelectValue,
|
| 13 |
+
} from "@/components/ui/select";
|
| 14 |
+
|
| 15 |
+
export function LanguageToggle() {
|
| 16 |
+
const { locale } = useT();
|
| 17 |
+
const router = useRouter();
|
| 18 |
+
const pathname = usePathname();
|
| 19 |
+
|
| 20 |
+
function handleChange(next: string) {
|
| 21 |
+
document.cookie = `locale=${next}; path=/; max-age=31536000`;
|
| 22 |
+
const segments = pathname.split("/");
|
| 23 |
+
segments[1] = next;
|
| 24 |
+
router.push(segments.join("/") || `/${next}`);
|
| 25 |
+
router.refresh();
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<Select value={locale} onValueChange={handleChange}>
|
| 30 |
+
<SelectTrigger className="h-9 w-[110px] gap-2">
|
| 31 |
+
<Languages className="h-4 w-4 opacity-70" />
|
| 32 |
+
<SelectValue />
|
| 33 |
+
</SelectTrigger>
|
| 34 |
+
<SelectContent>
|
| 35 |
+
{(locales as readonly Locale[]).map((l) => (
|
| 36 |
+
<SelectItem key={l} value={l}>
|
| 37 |
+
{l === "en" ? "English" : "Español"}
|
| 38 |
+
</SelectItem>
|
| 39 |
+
))}
|
| 40 |
+
</SelectContent>
|
| 41 |
+
</Select>
|
| 42 |
+
);
|
| 43 |
+
}
|
frontend/src/components/layout/page-header.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function PageHeader({
|
| 2 |
+
title,
|
| 3 |
+
subtitle,
|
| 4 |
+
action,
|
| 5 |
+
}: {
|
| 6 |
+
title: string;
|
| 7 |
+
subtitle?: string;
|
| 8 |
+
action?: React.ReactNode;
|
| 9 |
+
}) {
|
| 10 |
+
return (
|
| 11 |
+
<div className="flex flex-wrap items-end justify-between gap-4 border-b border-border pb-4">
|
| 12 |
+
<div>
|
| 13 |
+
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
| 14 |
+
{subtitle && (
|
| 15 |
+
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
| 16 |
+
)}
|
| 17 |
+
</div>
|
| 18 |
+
{action}
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
frontend/src/components/layout/sidebar.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import Image from "next/image";
|
| 5 |
+
import { usePathname } from "next/navigation";
|
| 6 |
+
import {
|
| 7 |
+
LayoutDashboard,
|
| 8 |
+
HeartPulse,
|
| 9 |
+
Users,
|
| 10 |
+
BarChart3,
|
| 11 |
+
Brain,
|
| 12 |
+
Settings,
|
| 13 |
+
} from "lucide-react";
|
| 14 |
+
import { useT } from "@/i18n/context";
|
| 15 |
+
import { cn } from "@/lib/utils";
|
| 16 |
+
|
| 17 |
+
export function Sidebar() {
|
| 18 |
+
const { dict, locale } = useT();
|
| 19 |
+
const pathname = usePathname();
|
| 20 |
+
|
| 21 |
+
const items = [
|
| 22 |
+
{ href: "", label: dict.nav.dashboard, icon: LayoutDashboard },
|
| 23 |
+
{ href: "new-evaluation", label: dict.nav.newEvaluation, icon: HeartPulse },
|
| 24 |
+
{ href: "patients", label: dict.nav.patients, icon: Users },
|
| 25 |
+
{ href: "eda", label: dict.nav.eda, icon: BarChart3 },
|
| 26 |
+
{ href: "model", label: dict.nav.model, icon: Brain },
|
| 27 |
+
{ href: "settings", label: dict.nav.settings, icon: Settings },
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<aside className="sticky top-0 hidden h-screen w-64 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground lg:flex">
|
| 32 |
+
<div className="flex items-center gap-3 border-b border-sidebar-border px-5 py-5">
|
| 33 |
+
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-lg bg-white/5 ring-1 ring-white/10">
|
| 34 |
+
<Image
|
| 35 |
+
src="/logu1.png"
|
| 36 |
+
alt="BeatSense"
|
| 37 |
+
width={40}
|
| 38 |
+
height={40}
|
| 39 |
+
className="h-9 w-9 object-contain"
|
| 40 |
+
priority
|
| 41 |
+
/>
|
| 42 |
+
</div>
|
| 43 |
+
<div className="leading-tight">
|
| 44 |
+
<div className="text-sm font-bold tracking-wide text-white">
|
| 45 |
+
{dict.app.name}
|
| 46 |
+
</div>
|
| 47 |
+
<div className="text-[10px] uppercase tracking-widest text-sidebar-foreground/60">
|
| 48 |
+
{dict.app.tagline}
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<nav className="flex-1 space-y-1 px-3 py-4">
|
| 54 |
+
{items.map(({ href, label, icon: Icon }) => {
|
| 55 |
+
const full = `/${locale}${href ? `/${href}` : ""}`;
|
| 56 |
+
const active =
|
| 57 |
+
href === ""
|
| 58 |
+
? pathname === `/${locale}` || pathname === `/${locale}/`
|
| 59 |
+
: pathname.startsWith(full);
|
| 60 |
+
return (
|
| 61 |
+
<Link
|
| 62 |
+
key={href}
|
| 63 |
+
href={full}
|
| 64 |
+
className={cn(
|
| 65 |
+
"group flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
|
| 66 |
+
active
|
| 67 |
+
? "bg-sidebar-accent text-white"
|
| 68 |
+
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-white"
|
| 69 |
+
)}
|
| 70 |
+
>
|
| 71 |
+
<Icon className="h-4 w-4" />
|
| 72 |
+
{label}
|
| 73 |
+
</Link>
|
| 74 |
+
);
|
| 75 |
+
})}
|
| 76 |
+
</nav>
|
| 77 |
+
|
| 78 |
+
<div className="border-t border-sidebar-border px-5 py-4 text-[11px] leading-relaxed text-sidebar-foreground/60">
|
| 79 |
+
<div className="font-mono">{dict.app.institution}</div>
|
| 80 |
+
<div className="mt-1">v1.0 · {new Date().getFullYear()}</div>
|
| 81 |
+
</div>
|
| 82 |
+
</aside>
|
| 83 |
+
);
|
| 84 |
+
}
|
frontend/src/components/layout/theme-toggle.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Moon, Sun } from "lucide-react";
|
| 4 |
+
import { useTheme } from "next-themes";
|
| 5 |
+
import { useEffect, useState } from "react";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
|
| 8 |
+
export function ThemeToggle() {
|
| 9 |
+
const { theme, setTheme, resolvedTheme } = useTheme();
|
| 10 |
+
const [mounted, setMounted] = useState(false);
|
| 11 |
+
useEffect(() => setMounted(true), []);
|
| 12 |
+
|
| 13 |
+
const current = mounted ? (resolvedTheme ?? theme) : "dark";
|
| 14 |
+
const next = current === "dark" ? "light" : "dark";
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<Button
|
| 18 |
+
variant="outline"
|
| 19 |
+
size="icon"
|
| 20 |
+
onClick={() => setTheme(next)}
|
| 21 |
+
aria-label="Toggle theme"
|
| 22 |
+
>
|
| 23 |
+
{current === "dark" ? (
|
| 24 |
+
<Sun className="h-4 w-4" />
|
| 25 |
+
) : (
|
| 26 |
+
<Moon className="h-4 w-4" />
|
| 27 |
+
)}
|
| 28 |
+
</Button>
|
| 29 |
+
);
|
| 30 |
+
}
|
frontend/src/components/model/model-insights.tsx
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useQuery } from "@tanstack/react-query";
|
| 4 |
+
import {
|
| 5 |
+
Bar,
|
| 6 |
+
BarChart,
|
| 7 |
+
CartesianGrid,
|
| 8 |
+
Legend,
|
| 9 |
+
ResponsiveContainer,
|
| 10 |
+
Tooltip,
|
| 11 |
+
XAxis,
|
| 12 |
+
YAxis,
|
| 13 |
+
} from "recharts";
|
| 14 |
+
import { CheckCircle2 } from "lucide-react";
|
| 15 |
+
import { api } from "@/lib/api";
|
| 16 |
+
import { useT } from "@/i18n/context";
|
| 17 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 18 |
+
import { Badge } from "@/components/ui/badge";
|
| 19 |
+
|
| 20 |
+
export function ModelInsights() {
|
| 21 |
+
const { dict } = useT();
|
| 22 |
+
const { data, isLoading } = useQuery({
|
| 23 |
+
queryKey: ["model-info"],
|
| 24 |
+
queryFn: api.modelInfo,
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
if (isLoading || !data) {
|
| 28 |
+
return (
|
| 29 |
+
<Card>
|
| 30 |
+
<CardContent className="p-10 text-center text-sm text-muted-foreground">
|
| 31 |
+
{dict.common.loading}
|
| 32 |
+
</CardContent>
|
| 33 |
+
</Card>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const all = [
|
| 38 |
+
{
|
| 39 |
+
name: data.name,
|
| 40 |
+
accuracy: data.metrics.accuracy,
|
| 41 |
+
recall: data.metrics.recall,
|
| 42 |
+
roc_auc: data.metrics.roc_auc,
|
| 43 |
+
selected: true,
|
| 44 |
+
},
|
| 45 |
+
...data.alternatives.map((a) => ({ ...a, selected: false })),
|
| 46 |
+
];
|
| 47 |
+
|
| 48 |
+
const chartData = all.map((m) => ({
|
| 49 |
+
name: m.name,
|
| 50 |
+
Accuracy: +(m.accuracy * 100).toFixed(1),
|
| 51 |
+
Recall: +(m.recall * 100).toFixed(1),
|
| 52 |
+
"ROC-AUC": +(m.roc_auc * 100).toFixed(1),
|
| 53 |
+
}));
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="grid gap-6 lg:grid-cols-3">
|
| 57 |
+
<Card className="lg:col-span-1">
|
| 58 |
+
<CardContent className="p-6">
|
| 59 |
+
<div className="flex items-center gap-2">
|
| 60 |
+
<CheckCircle2 className="h-5 w-5 text-success" />
|
| 61 |
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
| 62 |
+
{dict.model.selected}
|
| 63 |
+
</h2>
|
| 64 |
+
</div>
|
| 65 |
+
<div className="mt-3 text-2xl font-bold">{data.name}</div>
|
| 66 |
+
<Badge variant="success" className="mt-2">
|
| 67 |
+
threshold {data.threshold}%
|
| 68 |
+
</Badge>
|
| 69 |
+
|
| 70 |
+
<div className="mt-6 grid grid-cols-2 gap-3">
|
| 71 |
+
<Metric label={dict.model.metrics.accuracy} value={`${(data.metrics.accuracy * 100).toFixed(0)}%`} />
|
| 72 |
+
<Metric label={dict.model.metrics.recall} value={data.metrics.recall.toFixed(2)} />
|
| 73 |
+
<Metric label={dict.model.metrics.rocAuc} value={data.metrics.roc_auc.toFixed(3)} />
|
| 74 |
+
<Metric
|
| 75 |
+
label={dict.model.metrics.cv}
|
| 76 |
+
value={`${(data.metrics.cv_mean * 100).toFixed(1)}% ± ${(data.metrics.cv_std * 100).toFixed(1)}`}
|
| 77 |
+
/>
|
| 78 |
+
</div>
|
| 79 |
+
</CardContent>
|
| 80 |
+
</Card>
|
| 81 |
+
|
| 82 |
+
<Card className="lg:col-span-2">
|
| 83 |
+
<CardContent className="p-6">
|
| 84 |
+
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
| 85 |
+
{dict.model.comparison}
|
| 86 |
+
</h2>
|
| 87 |
+
<ResponsiveContainer width="100%" height={300}>
|
| 88 |
+
<BarChart data={chartData}>
|
| 89 |
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
| 90 |
+
<XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
|
| 91 |
+
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" unit="%" />
|
| 92 |
+
<Tooltip
|
| 93 |
+
contentStyle={{
|
| 94 |
+
background: "var(--popover)",
|
| 95 |
+
border: "1px solid var(--border)",
|
| 96 |
+
borderRadius: 8,
|
| 97 |
+
fontSize: 12,
|
| 98 |
+
}}
|
| 99 |
+
/>
|
| 100 |
+
<Legend wrapperStyle={{ fontSize: 12 }} />
|
| 101 |
+
<Bar dataKey="Accuracy" fill="var(--primary)" radius={[4, 4, 0, 0]} />
|
| 102 |
+
<Bar dataKey="Recall" fill="var(--success)" radius={[4, 4, 0, 0]} />
|
| 103 |
+
<Bar dataKey="ROC-AUC" fill="var(--accent-foreground)" radius={[4, 4, 0, 0]} />
|
| 104 |
+
</BarChart>
|
| 105 |
+
</ResponsiveContainer>
|
| 106 |
+
</CardContent>
|
| 107 |
+
</Card>
|
| 108 |
+
|
| 109 |
+
<Card className="lg:col-span-3">
|
| 110 |
+
<CardContent className="p-6">
|
| 111 |
+
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
| 112 |
+
{dict.model.rationale}
|
| 113 |
+
</h2>
|
| 114 |
+
<p className="text-sm leading-relaxed">{dict.model.rationaleText}</p>
|
| 115 |
+
</CardContent>
|
| 116 |
+
</Card>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function Metric({ label, value }: { label: string; value: string }) {
|
| 122 |
+
return (
|
| 123 |
+
<div className="rounded-md border border-border bg-muted/30 p-3">
|
| 124 |
+
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
| 125 |
+
{label}
|
| 126 |
+
</div>
|
| 127 |
+
<div className="mt-1 text-lg font-bold tabular-nums">{value}</div>
|
| 128 |
+
</div>
|
| 129 |
+
);
|
| 130 |
+
}
|
frontend/src/components/patients/patients-table.tsx
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useMemo, useState } from "react";
|
| 4 |
+
import { useQuery } from "@tanstack/react-query";
|
| 5 |
+
import { Download, Search } from "lucide-react";
|
| 6 |
+
import { api, type HistoryEntry } from "@/lib/api";
|
| 7 |
+
import { useT } from "@/i18n/context";
|
| 8 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 9 |
+
import { Badge } from "@/components/ui/badge";
|
| 10 |
+
import { Button } from "@/components/ui/button";
|
| 11 |
+
|
| 12 |
+
type Filter = "all" | "high" | "low";
|
| 13 |
+
|
| 14 |
+
export function PatientsTable() {
|
| 15 |
+
const { dict } = useT();
|
| 16 |
+
const [query, setQuery] = useState("");
|
| 17 |
+
const [filter, setFilter] = useState<Filter>("all");
|
| 18 |
+
|
| 19 |
+
const { data } = useQuery({ queryKey: ["history"], queryFn: api.history });
|
| 20 |
+
|
| 21 |
+
const rows = useMemo(() => {
|
| 22 |
+
let r = [...(data ?? [])].reverse();
|
| 23 |
+
if (filter === "high") r = r.filter((x) => x.risk === "ALTO");
|
| 24 |
+
if (filter === "low") r = r.filter((x) => x.risk !== "ALTO");
|
| 25 |
+
if (query.trim())
|
| 26 |
+
r = r.filter((x) =>
|
| 27 |
+
x.patient_id.toLowerCase().includes(query.toLowerCase())
|
| 28 |
+
);
|
| 29 |
+
return r;
|
| 30 |
+
}, [data, query, filter]);
|
| 31 |
+
|
| 32 |
+
function exportCsv() {
|
| 33 |
+
const headers = [
|
| 34 |
+
"Date","Time","Patient ID","Age","Sex","Max HR","Fasting BS","Exercise Angina","Chest Pain","ST Slope","Probability","Risk",
|
| 35 |
+
];
|
| 36 |
+
const lines = [
|
| 37 |
+
headers.join(","),
|
| 38 |
+
...rows.map((r: HistoryEntry) =>
|
| 39 |
+
[
|
| 40 |
+
r.date,
|
| 41 |
+
r.time,
|
| 42 |
+
r.patient_id,
|
| 43 |
+
r.age,
|
| 44 |
+
r.sex,
|
| 45 |
+
r.max_hr,
|
| 46 |
+
r.fasting_bs,
|
| 47 |
+
r.exercise_angina,
|
| 48 |
+
r.chest_pain_type,
|
| 49 |
+
r.st_slope,
|
| 50 |
+
r.probability,
|
| 51 |
+
r.risk,
|
| 52 |
+
].join(",")
|
| 53 |
+
),
|
| 54 |
+
];
|
| 55 |
+
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
| 56 |
+
const url = URL.createObjectURL(blob);
|
| 57 |
+
const a = document.createElement("a");
|
| 58 |
+
a.href = url;
|
| 59 |
+
a.download = `beatsense-patients-${new Date().toISOString().slice(0, 10)}.csv`;
|
| 60 |
+
a.click();
|
| 61 |
+
URL.revokeObjectURL(url);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<Card>
|
| 66 |
+
<CardContent className="space-y-4 p-6">
|
| 67 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 68 |
+
<div className="relative">
|
| 69 |
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
| 70 |
+
<input
|
| 71 |
+
value={query}
|
| 72 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 73 |
+
placeholder={dict.history.search}
|
| 74 |
+
className="h-9 w-72 rounded-md border border-input bg-background pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
| 75 |
+
/>
|
| 76 |
+
</div>
|
| 77 |
+
<div className="flex items-center gap-2">
|
| 78 |
+
<FilterButton
|
| 79 |
+
label={dict.history.filterAll}
|
| 80 |
+
active={filter === "all"}
|
| 81 |
+
onClick={() => setFilter("all")}
|
| 82 |
+
/>
|
| 83 |
+
<FilterButton
|
| 84 |
+
label={dict.history.filterHigh}
|
| 85 |
+
active={filter === "high"}
|
| 86 |
+
onClick={() => setFilter("high")}
|
| 87 |
+
/>
|
| 88 |
+
<FilterButton
|
| 89 |
+
label={dict.history.filterLow}
|
| 90 |
+
active={filter === "low"}
|
| 91 |
+
onClick={() => setFilter("low")}
|
| 92 |
+
/>
|
| 93 |
+
<Button variant="outline" size="sm" onClick={exportCsv}>
|
| 94 |
+
<Download className="h-4 w-4" />
|
| 95 |
+
{dict.common.export}
|
| 96 |
+
</Button>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div className="overflow-x-auto rounded-md border border-border">
|
| 101 |
+
<table className="w-full text-sm">
|
| 102 |
+
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
| 103 |
+
<tr>
|
| 104 |
+
<Th>{dict.history.columns.date}</Th>
|
| 105 |
+
<Th>{dict.history.columns.time}</Th>
|
| 106 |
+
<Th>{dict.history.columns.patientId}</Th>
|
| 107 |
+
<Th className="text-right">{dict.history.columns.age}</Th>
|
| 108 |
+
<Th>{dict.history.columns.sex}</Th>
|
| 109 |
+
<Th className="text-right">{dict.history.columns.maxHr}</Th>
|
| 110 |
+
<Th>{dict.history.columns.fastingBs}</Th>
|
| 111 |
+
<Th>{dict.history.columns.exerciseAngina}</Th>
|
| 112 |
+
<Th>{dict.history.columns.chestPain}</Th>
|
| 113 |
+
<Th>{dict.history.columns.stSlope}</Th>
|
| 114 |
+
<Th className="text-right">{dict.history.columns.probability}</Th>
|
| 115 |
+
<Th className="text-right">{dict.history.columns.risk}</Th>
|
| 116 |
+
</tr>
|
| 117 |
+
</thead>
|
| 118 |
+
<tbody>
|
| 119 |
+
{rows.length === 0 ? (
|
| 120 |
+
<tr>
|
| 121 |
+
<td
|
| 122 |
+
colSpan={12}
|
| 123 |
+
className="py-10 text-center text-muted-foreground"
|
| 124 |
+
>
|
| 125 |
+
{dict.common.noData}
|
| 126 |
+
</td>
|
| 127 |
+
</tr>
|
| 128 |
+
) : (
|
| 129 |
+
rows.map((r, i) => (
|
| 130 |
+
<tr key={`${r.patient_id}-${i}`} className="border-t border-border hover:bg-muted/20">
|
| 131 |
+
<Td>{r.date}</Td>
|
| 132 |
+
<Td>{r.time}</Td>
|
| 133 |
+
<Td className="font-mono text-xs">{r.patient_id}</Td>
|
| 134 |
+
<Td className="text-right">{r.age}</Td>
|
| 135 |
+
<Td>{r.sex}</Td>
|
| 136 |
+
<Td className="text-right">{r.max_hr}</Td>
|
| 137 |
+
<Td>{r.fasting_bs}</Td>
|
| 138 |
+
<Td>{r.exercise_angina}</Td>
|
| 139 |
+
<Td>{r.chest_pain_type}</Td>
|
| 140 |
+
<Td>{r.st_slope}</Td>
|
| 141 |
+
<Td className="text-right font-semibold">
|
| 142 |
+
{r.probability.toFixed(1)}%
|
| 143 |
+
</Td>
|
| 144 |
+
<Td className="text-right">
|
| 145 |
+
<Badge
|
| 146 |
+
variant={r.risk === "ALTO" ? "destructive" : "success"}
|
| 147 |
+
>
|
| 148 |
+
{r.risk === "ALTO" ? dict.common.high : dict.common.low}
|
| 149 |
+
</Badge>
|
| 150 |
+
</Td>
|
| 151 |
+
</tr>
|
| 152 |
+
))
|
| 153 |
+
)}
|
| 154 |
+
</tbody>
|
| 155 |
+
</table>
|
| 156 |
+
</div>
|
| 157 |
+
</CardContent>
|
| 158 |
+
</Card>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function FilterButton({
|
| 163 |
+
label,
|
| 164 |
+
active,
|
| 165 |
+
onClick,
|
| 166 |
+
}: {
|
| 167 |
+
label: string;
|
| 168 |
+
active: boolean;
|
| 169 |
+
onClick: () => void;
|
| 170 |
+
}) {
|
| 171 |
+
return (
|
| 172 |
+
<button
|
| 173 |
+
onClick={onClick}
|
| 174 |
+
className={`h-9 rounded-md border px-3 text-xs font-medium transition-colors ${
|
| 175 |
+
active
|
| 176 |
+
? "border-primary bg-primary/10 text-primary"
|
| 177 |
+
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
| 178 |
+
}`}
|
| 179 |
+
>
|
| 180 |
+
{label}
|
| 181 |
+
</button>
|
| 182 |
+
);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
function Th({
|
| 186 |
+
children,
|
| 187 |
+
className,
|
| 188 |
+
}: {
|
| 189 |
+
children: React.ReactNode;
|
| 190 |
+
className?: string;
|
| 191 |
+
}) {
|
| 192 |
+
return (
|
| 193 |
+
<th className={`px-3 py-2 text-left ${className ?? ""}`}>{children}</th>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function Td({
|
| 198 |
+
children,
|
| 199 |
+
className,
|
| 200 |
+
}: {
|
| 201 |
+
children: React.ReactNode;
|
| 202 |
+
className?: string;
|
| 203 |
+
}) {
|
| 204 |
+
return <td className={`px-3 py-2 ${className ?? ""}`}>{children}</td>;
|
| 205 |
+
}
|
frontend/src/components/providers.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { ThemeProvider } from "next-themes";
|
| 4 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 5 |
+
import { useState, type ReactNode } from "react";
|
| 6 |
+
import { I18nProvider } from "@/i18n/context";
|
| 7 |
+
import type { Dictionary } from "@/i18n/dictionaries";
|
| 8 |
+
import type { Locale } from "@/i18n/config";
|
| 9 |
+
|
| 10 |
+
export function ThemeRoot({ children }: { children: ReactNode }) {
|
| 11 |
+
return (
|
| 12 |
+
<ThemeProvider
|
| 13 |
+
attribute="class"
|
| 14 |
+
defaultTheme="dark"
|
| 15 |
+
enableSystem
|
| 16 |
+
disableTransitionOnChange
|
| 17 |
+
>
|
| 18 |
+
{children}
|
| 19 |
+
</ThemeProvider>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function Providers({
|
| 24 |
+
dict,
|
| 25 |
+
locale,
|
| 26 |
+
children,
|
| 27 |
+
}: {
|
| 28 |
+
dict: Dictionary;
|
| 29 |
+
locale: Locale;
|
| 30 |
+
children: ReactNode;
|
| 31 |
+
}) {
|
| 32 |
+
const [qc] = useState(
|
| 33 |
+
() =>
|
| 34 |
+
new QueryClient({
|
| 35 |
+
defaultOptions: {
|
| 36 |
+
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
|
| 37 |
+
},
|
| 38 |
+
})
|
| 39 |
+
);
|
| 40 |
+
return (
|
| 41 |
+
<I18nProvider dict={dict} locale={locale}>
|
| 42 |
+
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
| 43 |
+
</I18nProvider>
|
| 44 |
+
);
|
| 45 |
+
}
|
frontend/src/components/settings/settings-panel.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useTheme } from "next-themes";
|
| 4 |
+
import { useEffect, useState } from "react";
|
| 5 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 6 |
+
import { useT } from "@/i18n/context";
|
| 7 |
+
import { LanguageToggle } from "@/components/layout/language-toggle";
|
| 8 |
+
import { Sun, Moon, Monitor } from "lucide-react";
|
| 9 |
+
|
| 10 |
+
export function SettingsPanel() {
|
| 11 |
+
const { dict } = useT();
|
| 12 |
+
const { theme, setTheme } = useTheme();
|
| 13 |
+
const [mounted, setMounted] = useState(false);
|
| 14 |
+
useEffect(() => setMounted(true), []);
|
| 15 |
+
|
| 16 |
+
const options = [
|
| 17 |
+
{ v: "light", label: dict.settings.themeLight, icon: Sun },
|
| 18 |
+
{ v: "dark", label: dict.settings.themeDark, icon: Moon },
|
| 19 |
+
{ v: "system", label: dict.settings.themeSystem, icon: Monitor },
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="grid gap-6 lg:grid-cols-2">
|
| 24 |
+
<Card>
|
| 25 |
+
<CardContent className="space-y-3 p-6">
|
| 26 |
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
| 27 |
+
{dict.settings.language}
|
| 28 |
+
</h2>
|
| 29 |
+
<LanguageToggle />
|
| 30 |
+
</CardContent>
|
| 31 |
+
</Card>
|
| 32 |
+
|
| 33 |
+
<Card>
|
| 34 |
+
<CardContent className="space-y-3 p-6">
|
| 35 |
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
| 36 |
+
{dict.settings.theme}
|
| 37 |
+
</h2>
|
| 38 |
+
<div className="grid grid-cols-3 gap-2">
|
| 39 |
+
{options.map(({ v, label, icon: Icon }) => {
|
| 40 |
+
const active = mounted && theme === v;
|
| 41 |
+
return (
|
| 42 |
+
<button
|
| 43 |
+
key={v}
|
| 44 |
+
onClick={() => setTheme(v)}
|
| 45 |
+
className={`flex flex-col items-center gap-2 rounded-md border p-3 text-sm transition-colors ${
|
| 46 |
+
active
|
| 47 |
+
? "border-primary bg-primary/10 text-primary"
|
| 48 |
+
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
| 49 |
+
}`}
|
| 50 |
+
>
|
| 51 |
+
<Icon className="h-5 w-5" />
|
| 52 |
+
{label}
|
| 53 |
+
</button>
|
| 54 |
+
);
|
| 55 |
+
})}
|
| 56 |
+
</div>
|
| 57 |
+
</CardContent>
|
| 58 |
+
</Card>
|
| 59 |
+
</div>
|
| 60 |
+
);
|
| 61 |
+
}
|
frontend/src/components/ticket/ticket.tsx
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useRef } from "react";
|
| 4 |
+
import Image from "next/image";
|
| 5 |
+
import { Printer, Plus } from "lucide-react";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { useT } from "@/i18n/context";
|
| 8 |
+
import type { PredictResponse } from "@/lib/api";
|
| 9 |
+
|
| 10 |
+
export type FormSnapshot = {
|
| 11 |
+
patient_id?: string;
|
| 12 |
+
age: number;
|
| 13 |
+
sex: "M" | "F";
|
| 14 |
+
max_hr: number;
|
| 15 |
+
fasting_bs: "0" | "1";
|
| 16 |
+
exercise_angina: "Y" | "N";
|
| 17 |
+
chest_pain_type: "ASY" | "ATA" | "NAP" | "TA";
|
| 18 |
+
st_slope: "Up" | "Flat" | "Down";
|
| 19 |
+
resting_bp?: number;
|
| 20 |
+
cholesterol?: number;
|
| 21 |
+
resting_ecg?: "Normal" | "ST" | "LVH";
|
| 22 |
+
oldpeak?: number;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export function Ticket({
|
| 26 |
+
response,
|
| 27 |
+
snapshot,
|
| 28 |
+
onNew,
|
| 29 |
+
}: {
|
| 30 |
+
response: PredictResponse;
|
| 31 |
+
snapshot: FormSnapshot;
|
| 32 |
+
onNew: () => void;
|
| 33 |
+
}) {
|
| 34 |
+
const { dict } = useT();
|
| 35 |
+
const ticketRef = useRef<HTMLDivElement>(null);
|
| 36 |
+
|
| 37 |
+
const ts = new Date(response.timestamp);
|
| 38 |
+
const date = ts.toLocaleDateString();
|
| 39 |
+
const time = ts.toLocaleTimeString();
|
| 40 |
+
const isHigh = response.classification === "HIGH";
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="mx-auto max-w-2xl space-y-4">
|
| 44 |
+
<div className="flex justify-end gap-2 print:hidden">
|
| 45 |
+
<Button variant="outline" onClick={onNew}>
|
| 46 |
+
<Plus className="h-4 w-4" />
|
| 47 |
+
{dict.ticket.newAssessment}
|
| 48 |
+
</Button>
|
| 49 |
+
<Button onClick={() => window.print()}>
|
| 50 |
+
<Printer className="h-4 w-4" />
|
| 51 |
+
{dict.common.print}
|
| 52 |
+
</Button>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div
|
| 56 |
+
ref={ticketRef}
|
| 57 |
+
className="print-area mx-auto rounded-lg border border-dashed border-foreground/30 bg-card p-8 font-mono text-[13px] leading-relaxed text-foreground shadow-2xl print:shadow-none"
|
| 58 |
+
style={{ maxWidth: "420px" }}
|
| 59 |
+
>
|
| 60 |
+
<div className="flex flex-col items-center gap-2 pb-3 text-center">
|
| 61 |
+
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-md">
|
| 62 |
+
<Image
|
| 63 |
+
src="/logu1.png"
|
| 64 |
+
alt="BeatSense"
|
| 65 |
+
width={56}
|
| 66 |
+
height={56}
|
| 67 |
+
className="h-12 w-12 object-contain"
|
| 68 |
+
/>
|
| 69 |
+
</div>
|
| 70 |
+
<div className="text-base font-bold tracking-widest uppercase">
|
| 71 |
+
{dict.app.name}
|
| 72 |
+
</div>
|
| 73 |
+
<div className="text-[10px] uppercase tracking-wider opacity-70">
|
| 74 |
+
{dict.ticket.facility}
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<Divider />
|
| 79 |
+
|
| 80 |
+
<div className="grid grid-cols-2 gap-y-1 text-[12px]">
|
| 81 |
+
<span className="opacity-70">{dict.ticket.code}</span>
|
| 82 |
+
<span className="text-right font-bold">{response.patient_id}</span>
|
| 83 |
+
<span className="opacity-70">{dict.ticket.date}</span>
|
| 84 |
+
<span className="text-right">{date}</span>
|
| 85 |
+
<span className="opacity-70">{dict.ticket.time}</span>
|
| 86 |
+
<span className="text-right">{time}</span>
|
| 87 |
+
<span className="opacity-70">{dict.ticket.clinician}</span>
|
| 88 |
+
<span className="text-right">{dict.header.user}</span>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<Divider />
|
| 92 |
+
|
| 93 |
+
<div className="text-[10px] uppercase tracking-widest opacity-70">
|
| 94 |
+
{dict.ticket.vitals}
|
| 95 |
+
</div>
|
| 96 |
+
<div className="mt-2 space-y-0.5 text-[12px]">
|
| 97 |
+
<Row label={dict.form.fields.age} value={`${snapshot.age}`} />
|
| 98 |
+
<Row
|
| 99 |
+
label={dict.form.fields.sex}
|
| 100 |
+
value={snapshot.sex === "M" ? dict.common.male : dict.common.female}
|
| 101 |
+
/>
|
| 102 |
+
{snapshot.resting_bp ? (
|
| 103 |
+
<Row
|
| 104 |
+
label={dict.form.fields.restingBp}
|
| 105 |
+
value={`${snapshot.resting_bp} mmHg`}
|
| 106 |
+
/>
|
| 107 |
+
) : null}
|
| 108 |
+
{snapshot.cholesterol ? (
|
| 109 |
+
<Row
|
| 110 |
+
label={dict.form.fields.cholesterol}
|
| 111 |
+
value={`${snapshot.cholesterol} mg/dL`}
|
| 112 |
+
/>
|
| 113 |
+
) : null}
|
| 114 |
+
<Row
|
| 115 |
+
label={dict.form.fields.maxHr}
|
| 116 |
+
value={`${snapshot.max_hr} bpm`}
|
| 117 |
+
/>
|
| 118 |
+
<Row
|
| 119 |
+
label={dict.form.fields.fastingBs}
|
| 120 |
+
value={snapshot.fasting_bs === "1" ? dict.common.yes : dict.common.no}
|
| 121 |
+
/>
|
| 122 |
+
<Row
|
| 123 |
+
label={dict.form.fields.chestPainType}
|
| 124 |
+
value={snapshot.chest_pain_type}
|
| 125 |
+
/>
|
| 126 |
+
<Row
|
| 127 |
+
label={dict.form.fields.exerciseAngina}
|
| 128 |
+
value={
|
| 129 |
+
snapshot.exercise_angina === "Y" ? dict.common.yes : dict.common.no
|
| 130 |
+
}
|
| 131 |
+
/>
|
| 132 |
+
<Row label={dict.form.fields.stSlope} value={snapshot.st_slope} />
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<Divider />
|
| 136 |
+
|
| 137 |
+
<div className="text-center">
|
| 138 |
+
<div className="text-[10px] uppercase tracking-widest opacity-70">
|
| 139 |
+
{dict.ticket.result}
|
| 140 |
+
</div>
|
| 141 |
+
<div className="mt-2 text-4xl font-black">
|
| 142 |
+
{response.probability.toFixed(1)}%
|
| 143 |
+
</div>
|
| 144 |
+
<div className="mt-1 text-[10px] uppercase opacity-60">
|
| 145 |
+
{dict.ticket.riskScore} · {dict.ticket.threshold}: {response.threshold}%
|
| 146 |
+
</div>
|
| 147 |
+
<div
|
| 148 |
+
className={`mt-3 inline-block rounded-sm border-2 px-3 py-1 text-sm font-bold tracking-widest ${
|
| 149 |
+
isHigh
|
| 150 |
+
? "border-destructive text-destructive"
|
| 151 |
+
: "border-success text-success"
|
| 152 |
+
}`}
|
| 153 |
+
>
|
| 154 |
+
{isHigh ? dict.ticket.highRisk : dict.ticket.lowRisk}
|
| 155 |
+
</div>
|
| 156 |
+
<ProbabilityBar value={response.probability} threshold={response.threshold} />
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<Divider />
|
| 160 |
+
|
| 161 |
+
<div className="text-[10px] uppercase tracking-widest opacity-70">
|
| 162 |
+
{dict.ticket.recommendation}
|
| 163 |
+
</div>
|
| 164 |
+
<p className="mt-1 text-[12px]">
|
| 165 |
+
{isHigh ? dict.ticket.recHigh : dict.ticket.recLow}
|
| 166 |
+
</p>
|
| 167 |
+
|
| 168 |
+
<Divider />
|
| 169 |
+
|
| 170 |
+
<div className="mt-2 flex flex-col items-center gap-2 text-[10px] opacity-70">
|
| 171 |
+
<Barcode value={response.patient_id} />
|
| 172 |
+
<div className="text-center">{dict.ticket.footer}</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function Row({ label, value }: { label: string; value: string }) {
|
| 180 |
+
return (
|
| 181 |
+
<div className="flex justify-between gap-2">
|
| 182 |
+
<span className="opacity-70">{label}</span>
|
| 183 |
+
<span className="text-right font-medium">{value}</span>
|
| 184 |
+
</div>
|
| 185 |
+
);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function Divider() {
|
| 189 |
+
return (
|
| 190 |
+
<div
|
| 191 |
+
className="my-3 h-px w-full"
|
| 192 |
+
style={{
|
| 193 |
+
background:
|
| 194 |
+
"repeating-linear-gradient(90deg, currentColor 0 4px, transparent 4px 8px)",
|
| 195 |
+
opacity: 0.4,
|
| 196 |
+
}}
|
| 197 |
+
/>
|
| 198 |
+
);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function ProbabilityBar({
|
| 202 |
+
value,
|
| 203 |
+
threshold,
|
| 204 |
+
}: {
|
| 205 |
+
value: number;
|
| 206 |
+
threshold: number;
|
| 207 |
+
}) {
|
| 208 |
+
return (
|
| 209 |
+
<div className="mt-3">
|
| 210 |
+
<div className="relative h-2 w-full overflow-hidden rounded-sm border border-foreground/30">
|
| 211 |
+
<div
|
| 212 |
+
className="h-full"
|
| 213 |
+
style={{
|
| 214 |
+
width: `${Math.min(100, value)}%`,
|
| 215 |
+
background: value >= threshold ? "var(--destructive)" : "var(--success)",
|
| 216 |
+
}}
|
| 217 |
+
/>
|
| 218 |
+
<div
|
| 219 |
+
className="absolute top-0 h-full w-px bg-foreground/80"
|
| 220 |
+
style={{ left: `${threshold}%` }}
|
| 221 |
+
/>
|
| 222 |
+
</div>
|
| 223 |
+
<div className="mt-1 flex justify-between font-mono text-[9px] opacity-60">
|
| 224 |
+
<span>0%</span>
|
| 225 |
+
<span>{threshold}%</span>
|
| 226 |
+
<span>100%</span>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function Barcode({ value }: { value: string }) {
|
| 233 |
+
const bars = Array.from(value).flatMap((c, i) => {
|
| 234 |
+
const code = c.charCodeAt(0) + i;
|
| 235 |
+
return [
|
| 236 |
+
{ w: (code % 4) + 1, gap: 1 },
|
| 237 |
+
{ w: ((code >> 2) % 3) + 1, gap: 2 },
|
| 238 |
+
];
|
| 239 |
+
});
|
| 240 |
+
return (
|
| 241 |
+
<div className="flex flex-col items-center">
|
| 242 |
+
<div className="flex h-10 items-end gap-[1px]">
|
| 243 |
+
{bars.map((b, i) => (
|
| 244 |
+
<div
|
| 245 |
+
key={i}
|
| 246 |
+
className="bg-foreground"
|
| 247 |
+
style={{ width: `${b.w}px`, height: "100%" }}
|
| 248 |
+
/>
|
| 249 |
+
))}
|
| 250 |
+
</div>
|
| 251 |
+
<div className="mt-1 font-mono text-[10px] tracking-widest">{value}</div>
|
| 252 |
+
</div>
|
| 253 |
+
);
|
| 254 |
+
}
|
frontend/src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 3 |
+
import { cn } from "@/lib/utils";
|
| 4 |
+
|
| 5 |
+
const badgeVariants = cva(
|
| 6 |
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default: "border-transparent bg-primary text-primary-foreground",
|
| 11 |
+
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
| 12 |
+
destructive:
|
| 13 |
+
"border-transparent bg-destructive text-destructive-foreground",
|
| 14 |
+
success: "border-transparent bg-success text-success-foreground",
|
| 15 |
+
warning: "border-transparent bg-warning text-warning-foreground",
|
| 16 |
+
outline: "text-foreground border-border",
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
defaultVariants: { variant: "default" },
|
| 20 |
+
}
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
export interface BadgeProps
|
| 24 |
+
extends React.HTMLAttributes<HTMLDivElement>,
|
| 25 |
+
VariantProps<typeof badgeVariants> {}
|
| 26 |
+
|
| 27 |
+
export function Badge({ className, variant, ...props }: BadgeProps) {
|
| 28 |
+
return (
|
| 29 |
+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
| 30 |
+
);
|
| 31 |
+
}
|
frontend/src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot";
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
const buttonVariants = cva(
|
| 7 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default:
|
| 12 |
+
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm",
|
| 13 |
+
destructive:
|
| 14 |
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
| 15 |
+
outline:
|
| 16 |
+
"border border-border bg-card hover:bg-muted text-foreground",
|
| 17 |
+
secondary:
|
| 18 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 19 |
+
ghost: "hover:bg-muted text-foreground",
|
| 20 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 21 |
+
},
|
| 22 |
+
size: {
|
| 23 |
+
default: "h-10 px-4 py-2",
|
| 24 |
+
sm: "h-8 rounded-md px-3 text-xs",
|
| 25 |
+
lg: "h-11 rounded-md px-8 text-base",
|
| 26 |
+
icon: "h-9 w-9",
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
defaultVariants: { variant: "default", size: "default" },
|
| 30 |
+
}
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
export interface ButtonProps
|
| 34 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 35 |
+
VariantProps<typeof buttonVariants> {
|
| 36 |
+
asChild?: boolean;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 40 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 41 |
+
const Comp = asChild ? Slot : "button";
|
| 42 |
+
return (
|
| 43 |
+
<Comp
|
| 44 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 45 |
+
ref={ref}
|
| 46 |
+
{...props}
|
| 47 |
+
/>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
);
|
| 51 |
+
Button.displayName = "Button";
|
| 52 |
+
|
| 53 |
+
export { Button, buttonVariants };
|
frontend/src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
|
| 4 |
+
export const Card = React.forwardRef<
|
| 5 |
+
HTMLDivElement,
|
| 6 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 7 |
+
>(({ className, ...props }, ref) => (
|
| 8 |
+
<div
|
| 9 |
+
ref={ref}
|
| 10 |
+
className={cn(
|
| 11 |
+
"rounded-xl border border-border bg-card text-card-foreground shadow-sm",
|
| 12 |
+
className
|
| 13 |
+
)}
|
| 14 |
+
{...props}
|
| 15 |
+
/>
|
| 16 |
+
));
|
| 17 |
+
Card.displayName = "Card";
|
| 18 |
+
|
| 19 |
+
export const CardHeader = React.forwardRef<
|
| 20 |
+
HTMLDivElement,
|
| 21 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 22 |
+
>(({ className, ...props }, ref) => (
|
| 23 |
+
<div
|
| 24 |
+
ref={ref}
|
| 25 |
+
className={cn("flex flex-col gap-1.5 p-6 pb-4", className)}
|
| 26 |
+
{...props}
|
| 27 |
+
/>
|
| 28 |
+
));
|
| 29 |
+
CardHeader.displayName = "CardHeader";
|
| 30 |
+
|
| 31 |
+
export const CardTitle = React.forwardRef<
|
| 32 |
+
HTMLHeadingElement,
|
| 33 |
+
React.HTMLAttributes<HTMLHeadingElement>
|
| 34 |
+
>(({ className, ...props }, ref) => (
|
| 35 |
+
<h3
|
| 36 |
+
ref={ref}
|
| 37 |
+
className={cn("text-base font-semibold tracking-tight", className)}
|
| 38 |
+
{...props}
|
| 39 |
+
/>
|
| 40 |
+
));
|
| 41 |
+
CardTitle.displayName = "CardTitle";
|
| 42 |
+
|
| 43 |
+
export const CardDescription = React.forwardRef<
|
| 44 |
+
HTMLParagraphElement,
|
| 45 |
+
React.HTMLAttributes<HTMLParagraphElement>
|
| 46 |
+
>(({ className, ...props }, ref) => (
|
| 47 |
+
<p
|
| 48 |
+
ref={ref}
|
| 49 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 50 |
+
{...props}
|
| 51 |
+
/>
|
| 52 |
+
));
|
| 53 |
+
CardDescription.displayName = "CardDescription";
|
| 54 |
+
|
| 55 |
+
export const CardContent = React.forwardRef<
|
| 56 |
+
HTMLDivElement,
|
| 57 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 58 |
+
>(({ className, ...props }, ref) => (
|
| 59 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 60 |
+
));
|
| 61 |
+
CardContent.displayName = "CardContent";
|
| 62 |
+
|
| 63 |
+
export const CardFooter = React.forwardRef<
|
| 64 |
+
HTMLDivElement,
|
| 65 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 66 |
+
>(({ className, ...props }, ref) => (
|
| 67 |
+
<div
|
| 68 |
+
ref={ref}
|
| 69 |
+
className={cn("flex items-center p-6 pt-0", className)}
|
| 70 |
+
{...props}
|
| 71 |
+
/>
|
| 72 |
+
));
|
| 73 |
+
CardFooter.displayName = "CardFooter";
|
frontend/src/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
|
| 4 |
+
export const Input = React.forwardRef<
|
| 5 |
+
HTMLInputElement,
|
| 6 |
+
React.InputHTMLAttributes<HTMLInputElement>
|
| 7 |
+
>(({ className, type, ...props }, ref) => (
|
| 8 |
+
<input
|
| 9 |
+
type={type}
|
| 10 |
+
ref={ref}
|
| 11 |
+
className={cn(
|
| 12 |
+
"flex h-10 w-full rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
| 13 |
+
className
|
| 14 |
+
)}
|
| 15 |
+
{...props}
|
| 16 |
+
/>
|
| 17 |
+
));
|
| 18 |
+
Input.displayName = "Input";
|
frontend/src/components/ui/label.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
| 5 |
+
import { cn } from "@/lib/utils";
|
| 6 |
+
|
| 7 |
+
export const Label = React.forwardRef<
|
| 8 |
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
| 9 |
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
| 10 |
+
>(({ className, ...props }, ref) => (
|
| 11 |
+
<LabelPrimitive.Root
|
| 12 |
+
ref={ref}
|
| 13 |
+
className={cn(
|
| 14 |
+
"text-xs font-medium uppercase tracking-wide text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
| 15 |
+
className
|
| 16 |
+
)}
|
| 17 |
+
{...props}
|
| 18 |
+
/>
|
| 19 |
+
));
|
| 20 |
+
Label.displayName = "Label";
|
frontend/src/components/ui/select.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import * as React from "react";
|
| 4 |
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
| 5 |
+
import { Check, ChevronDown } from "lucide-react";
|
| 6 |
+
import { cn } from "@/lib/utils";
|
| 7 |
+
|
| 8 |
+
export const Select = SelectPrimitive.Root;
|
| 9 |
+
export const SelectGroup = SelectPrimitive.Group;
|
| 10 |
+
export const SelectValue = SelectPrimitive.Value;
|
| 11 |
+
|
| 12 |
+
export const SelectTrigger = React.forwardRef<
|
| 13 |
+
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
| 14 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
| 15 |
+
>(({ className, children, ...props }, ref) => (
|
| 16 |
+
<SelectPrimitive.Trigger
|
| 17 |
+
ref={ref}
|
| 18 |
+
className={cn(
|
| 19 |
+
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
| 20 |
+
className
|
| 21 |
+
)}
|
| 22 |
+
{...props}
|
| 23 |
+
>
|
| 24 |
+
{children}
|
| 25 |
+
<SelectPrimitive.Icon asChild>
|
| 26 |
+
<ChevronDown className="h-4 w-4 opacity-60" />
|
| 27 |
+
</SelectPrimitive.Icon>
|
| 28 |
+
</SelectPrimitive.Trigger>
|
| 29 |
+
));
|
| 30 |
+
SelectTrigger.displayName = "SelectTrigger";
|
| 31 |
+
|
| 32 |
+
export const SelectContent = React.forwardRef<
|
| 33 |
+
React.ElementRef<typeof SelectPrimitive.Content>,
|
| 34 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
| 35 |
+
>(({ className, children, position = "popper", ...props }, ref) => (
|
| 36 |
+
<SelectPrimitive.Portal>
|
| 37 |
+
<SelectPrimitive.Content
|
| 38 |
+
ref={ref}
|
| 39 |
+
className={cn(
|
| 40 |
+
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md",
|
| 41 |
+
position === "popper" && "translate-y-1",
|
| 42 |
+
className
|
| 43 |
+
)}
|
| 44 |
+
position={position}
|
| 45 |
+
{...props}
|
| 46 |
+
>
|
| 47 |
+
<SelectPrimitive.Viewport
|
| 48 |
+
className={cn(
|
| 49 |
+
"p-1",
|
| 50 |
+
position === "popper" &&
|
| 51 |
+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
| 52 |
+
)}
|
| 53 |
+
>
|
| 54 |
+
{children}
|
| 55 |
+
</SelectPrimitive.Viewport>
|
| 56 |
+
</SelectPrimitive.Content>
|
| 57 |
+
</SelectPrimitive.Portal>
|
| 58 |
+
));
|
| 59 |
+
SelectContent.displayName = "SelectContent";
|
| 60 |
+
|
| 61 |
+
export const SelectItem = React.forwardRef<
|
| 62 |
+
React.ElementRef<typeof SelectPrimitive.Item>,
|
| 63 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
| 64 |
+
>(({ className, children, ...props }, ref) => (
|
| 65 |
+
<SelectPrimitive.Item
|
| 66 |
+
ref={ref}
|
| 67 |
+
className={cn(
|
| 68 |
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
| 69 |
+
className
|
| 70 |
+
)}
|
| 71 |
+
{...props}
|
| 72 |
+
>
|
| 73 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 74 |
+
<SelectPrimitive.ItemIndicator>
|
| 75 |
+
<Check className="h-4 w-4" />
|
| 76 |
+
</SelectPrimitive.ItemIndicator>
|
| 77 |
+
</span>
|
| 78 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 79 |
+
</SelectPrimitive.Item>
|
| 80 |
+
));
|
| 81 |
+
SelectItem.displayName = "SelectItem";
|