feat: Build Skills section with SVG circular gauges
- Create Skills.tsx with three categories (Technical, Clinical, Strategic) - Add SkillGauge component with animated circular SVG progress - Create useScrollReveal.ts hook for IntersectionObserver-based animations - Staggered gauge fill animations (100ms delay per skill) - Exact 18 skills with percentages from concept.html - Hover effects matching concept.html styling
This commit is contained in:
+2
-6
@@ -4,6 +4,7 @@ import { BootSequence } from './components/BootSequence'
|
|||||||
import { ECGAnimation } from './components/ECGAnimation'
|
import { ECGAnimation } from './components/ECGAnimation'
|
||||||
import { FloatingNav } from './components/FloatingNav'
|
import { FloatingNav } from './components/FloatingNav'
|
||||||
import { Hero } from './components/Hero'
|
import { Hero } from './components/Hero'
|
||||||
|
import { Skills } from './components/Skills'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [phase, setPhase] = useState<Phase>('boot')
|
const [phase, setPhase] = useState<Phase>('boot')
|
||||||
@@ -24,12 +25,7 @@ function App() {
|
|||||||
<main className="max-w-[1000px] mx-auto px-8">
|
<main className="max-w-[1000px] mx-auto px-8">
|
||||||
<Hero />
|
<Hero />
|
||||||
|
|
||||||
<section id="skills" className="py-20">
|
<Skills />
|
||||||
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
|
|
||||||
Skills
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted text-center">Skills section will be built in Task 7</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="experience" className="py-20">
|
<section id="experience" className="py-20">
|
||||||
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
|
<h2 className="font-primary text-2xl font-bold text-heading text-center mb-8">
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useRef, useState, useEffect } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { Skill } from '../types'
|
||||||
|
import { calculateSkillOffset } from '../lib/utils'
|
||||||
|
|
||||||
|
const GAUGE_RADIUS = 34
|
||||||
|
const GAUGE_CIRCUMFERENCE = 2 * Math.PI * GAUGE_RADIUS
|
||||||
|
|
||||||
|
interface SkillGaugeProps {
|
||||||
|
skill: Skill
|
||||||
|
delay: number
|
||||||
|
isVisible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillGauge({ skill, delay, isVisible }: SkillGaugeProps) {
|
||||||
|
const [animated, setAnimated] = useState(false)
|
||||||
|
const strokeColor = skill.color === 'coral' ? '#FF6B6B' : '#00897B'
|
||||||
|
const hoverBg = skill.color === 'coral' ? 'hover:bg-coral-light' : 'hover:bg-teal-light'
|
||||||
|
|
||||||
|
const targetOffset = calculateSkillOffset(skill.level, GAUGE_RADIUS)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible && !animated) {
|
||||||
|
const timer = setTimeout(() => setAnimated(true), delay)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isVisible, animated, delay])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
|
||||||
|
transition={{ duration: 0.5, delay: delay / 1000, ease: 'easeOut' }}
|
||||||
|
className={`flex flex-col items-center p-4 rounded-2xl transition-colors duration-300 ${hoverBg}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="skill-gauge block"
|
||||||
|
width="80"
|
||||||
|
height="80"
|
||||||
|
viewBox="0 0 80 80"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r={GAUGE_RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke="#E2E8F0"
|
||||||
|
strokeWidth="5"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r={GAUGE_RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth="5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90, 40, 40)"
|
||||||
|
style={{
|
||||||
|
strokeDasharray: GAUGE_CIRCUMFERENCE,
|
||||||
|
strokeDashoffset: animated ? targetOffset : GAUGE_CIRCUMFERENCE,
|
||||||
|
transition: animated ? 'stroke-dashoffset 1.2s ease-out' : 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize="14"
|
||||||
|
fontWeight="600"
|
||||||
|
fill="#0F172A"
|
||||||
|
fontFamily="'Inter Tight', system-ui, sans-serif"
|
||||||
|
>
|
||||||
|
{skill.level}%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<span className="font-primary text-xs font-semibold text-heading mt-2 text-center leading-tight">
|
||||||
|
{skill.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-secondary text-[10px] text-muted uppercase tracking-wide mt-0.5">
|
||||||
|
{skill.category}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillCategoryProps {
|
||||||
|
label: string
|
||||||
|
skills: Skill[]
|
||||||
|
isVisible: boolean
|
||||||
|
baseDelay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillCategory({ label, skills, isVisible, baseDelay }: SkillCategoryProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-10 last:mb-0">
|
||||||
|
<h3 className="font-secondary text-xs font-semibold uppercase tracking-widest text-muted mb-5 pl-1">
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-6">
|
||||||
|
{skills.map((skill, index) => (
|
||||||
|
<SkillGauge
|
||||||
|
key={skill.name}
|
||||||
|
skill={skill}
|
||||||
|
delay={baseDelay + index * 100}
|
||||||
|
isVisible={isVisible}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillsData: Skill[] = [
|
||||||
|
{ name: 'Python', level: 90, category: 'Technical', color: 'teal' },
|
||||||
|
{ name: 'SQL', level: 88, category: 'Technical', color: 'teal' },
|
||||||
|
{ name: 'Power BI', level: 92, category: 'Technical', color: 'teal' },
|
||||||
|
{ name: 'JS / TS', level: 70, category: 'Technical', color: 'teal' },
|
||||||
|
{ name: 'Data Analysis', level: 95, category: 'Technical', color: 'teal' },
|
||||||
|
{ name: 'Dashboard Dev', level: 88, category: 'Technical', color: 'teal' },
|
||||||
|
{ name: 'Algorithm Design', level: 82, category: 'Technical', color: 'teal' },
|
||||||
|
{ name: 'Data Pipelines', level: 80, category: 'Technical', color: 'teal' },
|
||||||
|
|
||||||
|
{ name: 'Medicines Optimisation', level: 95, category: 'Clinical', color: 'coral' },
|
||||||
|
{ name: 'Pop. Health Analytics', level: 90, category: 'Clinical', color: 'coral' },
|
||||||
|
{ name: 'NICE TA', level: 85, category: 'Clinical', color: 'coral' },
|
||||||
|
{ name: 'Health Economics', level: 80, category: 'Clinical', color: 'coral' },
|
||||||
|
{ name: 'Clinical Pathways', level: 82, category: 'Clinical', color: 'coral' },
|
||||||
|
{ name: 'CD Assurance', level: 88, category: 'Clinical', color: 'coral' },
|
||||||
|
|
||||||
|
{ name: 'Budget Mgmt', level: 90, category: 'Strategic', color: 'teal' },
|
||||||
|
{ name: 'Stakeholder Engagement', level: 88, category: 'Strategic', color: 'teal' },
|
||||||
|
{ name: 'Pharma Negotiation', level: 85, category: 'Strategic', color: 'teal' },
|
||||||
|
{ name: 'Team Development', level: 82, category: 'Strategic', color: 'teal' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Skills() {
|
||||||
|
const sectionRef = useRef<HTMLElement>(null)
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = sectionRef.current
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true)
|
||||||
|
observer.unobserve(element)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.15, rootMargin: '0px' }
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(element)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const technicalSkills = skillsData.filter(s => s.category === 'Technical')
|
||||||
|
const clinicalSkills = skillsData.filter(s => s.category === 'Clinical')
|
||||||
|
const strategicSkills = skillsData.filter(s => s.category === 'Strategic')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="skills" ref={sectionRef} className="py-20">
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||||
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||||
|
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
||||||
|
>
|
||||||
|
Skills & Expertise
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<SkillCategory
|
||||||
|
label="Technical"
|
||||||
|
skills={technicalSkills}
|
||||||
|
isVisible={isVisible}
|
||||||
|
baseDelay={200}
|
||||||
|
/>
|
||||||
|
<SkillCategory
|
||||||
|
label="Clinical"
|
||||||
|
skills={clinicalSkills}
|
||||||
|
isVisible={isVisible}
|
||||||
|
baseDelay={200 + technicalSkills.length * 100 + 100}
|
||||||
|
/>
|
||||||
|
<SkillCategory
|
||||||
|
label="Strategic"
|
||||||
|
skills={strategicSkills}
|
||||||
|
isVisible={isVisible}
|
||||||
|
baseDelay={200 + technicalSkills.length * 100 + clinicalSkills.length * 100 + 200}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||||
|
|
||||||
|
interface UseScrollRevealOptions {
|
||||||
|
threshold?: number
|
||||||
|
rootMargin?: string
|
||||||
|
triggerOnce?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollReveal<T extends HTMLElement>(
|
||||||
|
options: UseScrollRevealOptions = {}
|
||||||
|
): [RefObject<T | null>, boolean] {
|
||||||
|
const { threshold = 0.15, rootMargin = '0px', triggerOnce = true } = options
|
||||||
|
const ref = useRef<T | null>(null)
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true)
|
||||||
|
if (triggerOnce) {
|
||||||
|
observer.unobserve(element)
|
||||||
|
}
|
||||||
|
} else if (!triggerOnce) {
|
||||||
|
setIsVisible(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold, rootMargin }
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(element)
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [threshold, rootMargin, triggerOnce])
|
||||||
|
|
||||||
|
return [ref, isVisible]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user