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 { FloatingNav } from './components/FloatingNav'
|
||||
import { Hero } from './components/Hero'
|
||||
import { Skills } from './components/Skills'
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>('boot')
|
||||
@@ -24,12 +25,7 @@ function App() {
|
||||
<main className="max-w-[1000px] mx-auto px-8">
|
||||
<Hero />
|
||||
|
||||
<section id="skills" className="py-20">
|
||||
<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>
|
||||
<Skills />
|
||||
|
||||
<section id="experience" className="py-20">
|
||||
<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