Files
portfolio/4-vitals-monitor.html
T
admin b5a08bb393 Task 2: Build ECG flatline and first heartbeat
Add the ECG animation phase between boot sequence fade and CV reveal.
After boot text fades out, a green flatline draws left-to-right across
the viewport center over 1000ms, followed by a PQRST heartbeat waveform
(R peak 40px) animating over 600ms. Uses SVG with stroke-dasharray/
dashoffset for line-drawing effect with green glow filter. The
drawHeartbeat() function is reusable for Task 3's escalating beats.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:38:22 +00:00

405 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Andy Charlwood — MPharm | CV</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #FFFFFF;
--text: #334155;
--heading: #0F172A;
--teal: #00897B;
--teal-light: rgba(0, 137, 123, 0.08);
--teal-medium: rgba(0, 137, 123, 0.15);
--coral: #FF6B6B;
--coral-light: rgba(255, 107, 107, 0.08);
--muted: #94A3B8;
--border: #E2E8F0;
--card-bg: #FFFFFF;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.1);
--radius: 16px;
--font-primary: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-secondary: 'Inter Tight', system-ui, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-primary);
font-size: 15px;
line-height: 1.7;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* =========================================
BOOT SCREEN
========================================= */
#boot-screen {
position: fixed;
inset: 0;
background: #000;
z-index: 1000;
display: flex;
flex-direction: column;
justify-content: center;
padding: 40px;
font-family: 'Fira Code', monospace;
font-size: 14px;
overflow: hidden;
}
#boot-lines {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 640px;
}
.boot-line {
opacity: 0;
transform: translateY(8px);
transition: opacity 400ms ease-out, transform 400ms ease-out;
white-space: nowrap;
line-height: 1.6;
}
.boot-line.visible {
opacity: 1;
transform: translateY(0);
}
.boot-line.fading {
opacity: 0;
transition: opacity 800ms ease;
}
.c-green { color: #00ff41; }
.c-green-bold { color: #00ff41; font-weight: 700; }
.c-cyan { color: #00e5ff; }
.c-dim { color: #3a6b45; }
.c-grey { color: #666; }
/* Blinking cursor */
#boot-cursor {
display: inline-block;
width: 8px;
height: 16px;
background: #00ff41;
animation: blink 1s step-end infinite;
vertical-align: middle;
margin-left: 4px;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* =========================================
ECG OVERLAY
========================================= */
#ecg-overlay {
position: fixed;
inset: 0;
z-index: 1001;
pointer-events: none;
}
#ecg-overlay svg {
width: 100%;
height: 100%;
display: block;
}
.ecg-line {
fill: none;
stroke: #00ff41;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 4px rgba(0, 255, 65, 0.6));
}
.ecg-heartbeat {
fill: none;
stroke: #00ff41;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 6px rgba(0, 255, 65, 0.7));
}
/* =========================================
FINAL CV CONTENT (hidden behind boot)
========================================= */
#cv-content {
opacity: 0;
}
#cv-content.revealed {
opacity: 1;
transition: opacity 600ms ease;
}
</style>
</head>
<body>
<!-- Boot Screen Overlay -->
<div id="boot-screen">
<div id="boot-lines"></div>
</div>
<!-- ECG Overlay (created dynamically but container lives here) -->
<div id="ecg-overlay"></div>
<!-- Final CV Content (hidden behind boot screen until transition) -->
<div id="cv-content">
<!-- CV sections will be built in subsequent tasks -->
</div>
<script>
(function() {
'use strict';
/* =========================================
BOOT SEQUENCE
========================================= */
var bootLines = [
{ html: '<span class="c-green-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
{ html: '<span class="c-dim">Initialising pharmacist profile...</span>', delay: 220 },
{ html: '<span class="c-dim">---</span>', delay: 220 },
{ html: '<span class="c-cyan">SYSTEM </span><span class="c-green">NHS Norfolk &amp; Waveney ICB</span>', delay: 220 },
{ html: '<span class="c-cyan">USER </span><span class="c-green">Andy Charlwood</span>', delay: 220 },
{ html: '<span class="c-cyan">ROLE </span><span class="c-green">Deputy Head of Population Health &amp; Data Analysis</span>', delay: 220 },
{ html: '<span class="c-cyan">LOCATION </span><span class="c-green">Norwich, UK</span>', delay: 220 },
{ html: '<span class="c-dim">---</span>', delay: 220 },
{ html: '<span class="c-dim">Loading modules...</span>', delay: 220 },
{ html: '<span class="c-green-bold">[OK]</span> <span class="c-dim">pharmacist_core.sys</span>', delay: 220 },
{ html: '<span class="c-green-bold">[OK]</span> <span class="c-dim">population_health.mod</span>', delay: 220 },
{ html: '<span class="c-green-bold">[OK]</span> <span class="c-dim">data_analytics.eng</span>', delay: 220 },
{ html: '<span class="c-dim">---</span>', delay: 220 },
{ html: '<span class="c-green-bold">&gt; READY — Rendering CV...</span><span id="boot-cursor"></span>', delay: 220 }
];
var bootContainer = document.getElementById('boot-lines');
var totalBootTime = 0;
bootLines.forEach(function(line, index) {
totalBootTime += line.delay;
var el = document.createElement('div');
el.className = 'boot-line';
el.innerHTML = line.html;
bootContainer.appendChild(el);
setTimeout(function() {
el.classList.add('visible');
}, totalBootTime);
});
// After all lines are visible, wait 400ms then remove cursor and fade boot text
var bootEndTime = totalBootTime + 400;
setTimeout(function() {
// Remove cursor
var cursor = document.getElementById('boot-cursor');
if (cursor) {
cursor.remove();
}
// Fade out all boot lines
var allLines = bootContainer.querySelectorAll('.boot-line');
for (var i = 0; i < allLines.length; i++) {
allLines[i].classList.add('fading');
}
}, bootEndTime);
// After fade out (800ms), start the ECG phase
setTimeout(function() {
startECGPhase();
}, bootEndTime + 800);
/* =========================================
ECG PHASE: Flatline + First Heartbeat
========================================= */
function startECGPhase() {
var overlay = document.getElementById('ecg-overlay');
var vw = window.innerWidth;
var vh = window.innerHeight;
var cy = Math.round(vh / 2);
// Create SVG
var svgNS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + vw + ' ' + vh);
svg.setAttribute('preserveAspectRatio', 'none');
overlay.appendChild(svg);
// --- FLATLINE ---
var flatlinePath = document.createElementNS(svgNS, 'path');
var flatlineD = 'M 0 ' + cy + ' L ' + vw + ' ' + cy;
flatlinePath.setAttribute('d', flatlineD);
flatlinePath.setAttribute('class', 'ecg-line');
svg.appendChild(flatlinePath);
var flatlineLen = flatlinePath.getTotalLength();
flatlinePath.style.strokeDasharray = flatlineLen;
flatlinePath.style.strokeDashoffset = flatlineLen;
// Animate flatline drawing left-to-right over 1000ms
requestAnimationFrame(function() {
flatlinePath.style.transition = 'stroke-dashoffset 1000ms linear';
flatlinePath.style.strokeDashoffset = '0';
});
// --- FIRST HEARTBEAT (after flatline completes) ---
setTimeout(function() {
drawHeartbeat(svg, svgNS, vw, vh, cy, 40, '#00ff41', 600, function() {
// After first heartbeat, hold 300ms then signal done (Task 3 will continue)
setTimeout(function() {
finishECGPhase();
}, 300);
});
}, 1050);
}
/**
* Draws a PQRST waveform centered horizontally on the screen.
* @param {SVGElement} svg - The SVG container
* @param {string} svgNS - SVG namespace
* @param {number} vw - Viewport width
* @param {number} vh - Viewport height
* @param {number} cy - Vertical center (baseline Y)
* @param {number} rHeight - Height of the R peak above baseline
* @param {string} color - Stroke color
* @param {number} duration - Animation duration in ms
* @param {function} onComplete - Callback when animation finishes
*/
function drawHeartbeat(svg, svgNS, vw, vh, cy, rHeight, color, duration, onComplete) {
// PQRST waveform shape relative to center point
// Total width of the waveform: ~160px
var waveWidth = 160;
var startX = Math.round((vw - waveWidth) / 2);
// Build the PQRST path
// P wave: gentle upward bump, ~8px above baseline, ~30px wide
// Flat: ~10px
// Q dip: sharp dip ~10px below, ~8px wide
// R spike: sharp peak rHeight above baseline, ~12px wide
// S dip: sharp dip ~15px below, ~8px wide
// Flat: ~10px
// T wave: gentle upward bump, ~12px above, ~35px wide
// Return to baseline
var x = startX;
var d = 'M ' + x + ' ' + cy;
// Lead-in flat segment
d += ' L ' + (x + 10) + ' ' + cy;
x += 10;
// P wave (cubic bezier for smooth bump)
d += ' C ' + (x + 8) + ' ' + cy + ', ' + (x + 10) + ' ' + (cy - 8) + ', ' + (x + 15) + ' ' + (cy - 8);
d += ' C ' + (x + 20) + ' ' + (cy - 8) + ', ' + (x + 22) + ' ' + cy + ', ' + (x + 30) + ' ' + cy;
x += 30;
// Flat segment before QRS
d += ' L ' + (x + 10) + ' ' + cy;
x += 10;
// Q dip
d += ' L ' + (x + 4) + ' ' + (cy + 10);
x += 4;
// R spike (sharp peak)
d += ' L ' + (x + 6) + ' ' + (cy - rHeight);
x += 6;
// S dip
d += ' L ' + (x + 6) + ' ' + (cy + 15);
x += 6;
// Return to baseline
d += ' L ' + (x + 4) + ' ' + cy;
x += 4;
// Flat segment before T wave
d += ' L ' + (x + 10) + ' ' + cy;
x += 10;
// T wave (cubic bezier for smooth bump)
d += ' C ' + (x + 8) + ' ' + cy + ', ' + (x + 12) + ' ' + (cy - 12) + ', ' + (x + 17) + ' ' + (cy - 12);
d += ' C ' + (x + 22) + ' ' + (cy - 12) + ', ' + (x + 27) + ' ' + cy + ', ' + (x + 35) + ' ' + cy;
x += 35;
// Trail-out flat segment
d += ' L ' + (x + 10) + ' ' + cy;
var beatPath = document.createElementNS(svgNS, 'path');
beatPath.setAttribute('d', d);
beatPath.setAttribute('class', 'ecg-heartbeat');
beatPath.style.stroke = color;
svg.appendChild(beatPath);
var beatLen = beatPath.getTotalLength();
beatPath.style.strokeDasharray = beatLen;
beatPath.style.strokeDashoffset = beatLen;
// Animate the heartbeat drawing
requestAnimationFrame(function() {
beatPath.style.transition = 'stroke-dashoffset ' + duration + 'ms ease-in-out';
beatPath.style.strokeDashoffset = '0';
});
// Callback after animation
if (onComplete) {
setTimeout(onComplete, duration + 50);
}
}
/**
* Temporary: finish ECG phase by hiding overlays and revealing CV.
* Task 3 will expand this with second/third heartbeats and branching.
*/
function finishECGPhase() {
var overlay = document.getElementById('ecg-overlay');
var bootScreen = document.getElementById('boot-screen');
// Fade out the ECG lines
overlay.style.transition = 'opacity 500ms ease';
overlay.style.opacity = '0';
// Transition boot screen background from black to white
bootScreen.style.transition = 'background 500ms ease';
bootScreen.style.background = '#FFFFFF';
setTimeout(function() {
// Remove overlays
overlay.style.display = 'none';
bootScreen.style.display = 'none';
// Reveal CV content
var cvContent = document.getElementById('cv-content');
cvContent.classList.add('revealed');
}, 500);
}
})();
</script>
</body>
</html>