Skip to content

Commit 9fe60a8

Browse files
committed
skill cloud
1 parent c489e6f commit 9fe60a8

5 files changed

Lines changed: 324 additions & 4 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { useEffect, useMemo, useRef } from 'react';
2+
3+
export interface SkillsCloudSkill {
4+
slug: string;
5+
name: string;
6+
iconSvg: string;
7+
}
8+
9+
interface Props {
10+
skills: SkillsCloudSkill[];
11+
}
12+
13+
function hash01(s: string): number {
14+
let h = 2166136261 >>> 0;
15+
for (let i = 0; i < s.length; i++) {
16+
h ^= s.charCodeAt(i);
17+
h = Math.imul(h, 16777619);
18+
}
19+
return (h >>> 0) / 0xffffffff;
20+
}
21+
22+
const MOUSE_SHIFT_PX = 22;
23+
const SCROLL_SHIFT_PX = 28;
24+
25+
export default function SkillsCloud({ skills }: Props) {
26+
const ref = useRef<HTMLDivElement>(null);
27+
28+
const positioned = useMemo(() => {
29+
const n = skills.length;
30+
return skills.map((s, i) => {
31+
const phi = (i + 0.5) * Math.PI * (3 - Math.sqrt(5));
32+
const r = Math.sqrt((i + 0.5) / n);
33+
const jitter = hash01(s.slug + ':j');
34+
const x = 50 + Math.cos(phi) * r * 40 + (jitter - 0.5) * 6;
35+
const y = 50 + Math.sin(phi) * r * 38 + (hash01(s.slug + ':k') - 0.5) * 6;
36+
const zRaw = hash01(s.slug);
37+
const z = (zRaw - 0.5) * 360;
38+
const depth = 0.4 + zRaw * 1.1;
39+
return { ...s, x, y, z, depth, delayIndex: i };
40+
});
41+
}, [skills]);
42+
43+
useEffect(() => {
44+
const el = ref.current;
45+
if (!el) return;
46+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
47+
48+
const host =
49+
(el.closest('[data-skills-cloud-root]') as HTMLElement | null) ?? el;
50+
51+
let mx = 0;
52+
let my = 0;
53+
let sy = 0;
54+
let pending = false;
55+
let enteringTimer: number | null = null;
56+
57+
const flush = () => {
58+
pending = false;
59+
el.style.setProperty('--mx', `${mx}px`);
60+
el.style.setProperty('--my', `${my}px`);
61+
el.style.setProperty('--sy', `${sy}px`);
62+
};
63+
const schedule = () => {
64+
if (!pending) {
65+
pending = true;
66+
requestAnimationFrame(flush);
67+
}
68+
};
69+
70+
const onMouse = (e: MouseEvent) => {
71+
const rect = host.getBoundingClientRect();
72+
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
73+
const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1;
74+
mx = -nx * MOUSE_SHIFT_PX;
75+
my = -ny * MOUSE_SHIFT_PX;
76+
if (el.dataset.returning !== 'entering') {
77+
el.dataset.returning = 'false';
78+
}
79+
schedule();
80+
};
81+
82+
const onEnter = (e: MouseEvent) => {
83+
const rect = host.getBoundingClientRect();
84+
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
85+
const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1;
86+
mx = -nx * MOUSE_SHIFT_PX;
87+
my = -ny * MOUSE_SHIFT_PX;
88+
el.dataset.returning = 'entering';
89+
schedule();
90+
if (enteringTimer !== null) window.clearTimeout(enteringTimer);
91+
enteringTimer = window.setTimeout(() => {
92+
if (el.dataset.returning === 'entering') {
93+
el.dataset.returning = 'false';
94+
}
95+
enteringTimer = null;
96+
}, 900);
97+
};
98+
99+
const onLeave = () => {
100+
mx = 0;
101+
my = 0;
102+
el.dataset.returning = 'true';
103+
if (enteringTimer !== null) {
104+
window.clearTimeout(enteringTimer);
105+
enteringTimer = null;
106+
}
107+
schedule();
108+
};
109+
110+
const onScroll = () => {
111+
const rect = host.getBoundingClientRect();
112+
const vh = window.innerHeight || 1;
113+
const centered = (rect.top + rect.height / 2 - vh / 2) / vh;
114+
sy = -centered * SCROLL_SHIFT_PX;
115+
schedule();
116+
};
117+
118+
host.addEventListener('mousemove', onMouse);
119+
host.addEventListener('mouseenter', onEnter);
120+
host.addEventListener('mouseleave', onLeave);
121+
window.addEventListener('scroll', onScroll, { passive: true });
122+
123+
return () => {
124+
host.removeEventListener('mousemove', onMouse);
125+
host.removeEventListener('mouseenter', onEnter);
126+
host.removeEventListener('mouseleave', onLeave);
127+
window.removeEventListener('scroll', onScroll);
128+
if (enteringTimer !== null) window.clearTimeout(enteringTimer);
129+
};
130+
}, []);
131+
132+
return (
133+
<div ref={ref} aria-hidden="true" className="skills-cloud">
134+
{positioned.map((s) => (
135+
<span
136+
key={s.slug}
137+
className="skills-cloud__icon"
138+
style={{
139+
left: `${s.x}%`,
140+
top: `${s.y}%`,
141+
['--depth' as string]: String(s.depth),
142+
['--z' as string]: `${s.z}px`,
143+
['--delay' as string]: `${(s.delayIndex % 7) * 0.6}s`,
144+
['--duration' as string]: `${6 + (s.delayIndex % 5)}s`,
145+
['--size' as string]: `${Math.round(26 + s.depth * 22)}px`,
146+
opacity: 0.45 + s.depth * 0.4,
147+
}}
148+
dangerouslySetInnerHTML={{ __html: s.iconSvg }}
149+
/>
150+
))}
151+
</div>
152+
);
153+
}

src/components/sections/Sections.astro

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import PhasesOverviewSection from '@components/sections/PhasesOverviewSection.as
1111
import ProjectsFilterGridSection from '@components/sections/ProjectsFilterGridSection.astro';
1212
import ProjectsRollSection from '@components/sections/ProjectsRollSection.astro';
1313
import RichTextSection from '@components/sections/RichTextSection.astro';
14+
import SkillsCloudSection from '@components/sections/SkillsCloudSection.astro';
1415
import SkillsSection from '@components/sections/SkillsSection.astro';
1516
import SectionLayout from '@layouts/SectionLayout.astro';
1617
@@ -116,6 +117,14 @@ const { sectionItems } = Astro.props;
116117
description={section.description}
117118
/>
118119
);
120+
case 'skillsCloud':
121+
return (
122+
<SkillsCloudSection
123+
eyebrow={section.eyebrow}
124+
heading={section.heading}
125+
body={section.body}
126+
/>
127+
);
119128
case 'organisationsRoll':
120129
return (
121130
<OrganisationsRollSection
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
---
2+
import { getCollection } from 'astro:content';
3+
import Heading from '@components/atoms/Heading.astro';
4+
import Text from '@components/atoms/Text.astro';
5+
import SkillsCloud, {
6+
type SkillsCloudSkill,
7+
} from '@components/organisms/SkillsCloud';
8+
import hugeiconsPack from '@iconify/json/json/hugeicons.json';
9+
import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils';
10+
import { isVisible } from '@utils/content';
11+
12+
export interface Props {
13+
eyebrow?: string;
14+
heading?: string;
15+
body?: string;
16+
}
17+
18+
const {
19+
eyebrow,
20+
heading = 'Creative project design for social good champions.',
21+
body,
22+
} = Astro.props;
23+
24+
const skillsRaw = await getCollection('skills');
25+
const skills = skillsRaw
26+
.filter((e) => isVisible(e))
27+
.map((e) => e.data)
28+
.sort((a, b) => a.name.localeCompare(b.name));
29+
30+
const renderIconSvg = (iconId: string, size = 48): string => {
31+
const [, name] = iconId.split(':');
32+
const data = getIconData(hugeiconsPack, name);
33+
if (!data) return '';
34+
const rendered = iconToSVG(data, { height: size, width: size });
35+
return iconToHTML(rendered.body, {
36+
...rendered.attributes,
37+
width: String(size),
38+
height: String(size),
39+
});
40+
};
41+
42+
const cloudSkills: SkillsCloudSkill[] = skills.map((s) => ({
43+
slug: s.slug,
44+
name: s.name,
45+
iconSvg: renderIconSvg(s.icon, 48),
46+
}));
47+
---
48+
49+
<div class="py-8 md:py-12" data-skills-cloud-root>
50+
<div class="grid items-center gap-10 md:grid-cols-2 md:gap-16">
51+
<div class="skills-cloud-wrap">
52+
<SkillsCloud client:visible skills={cloudSkills} />
53+
</div>
54+
55+
<div class="max-w-xl">
56+
{
57+
eyebrow && (
58+
<p class="mb-3 text-xs font-medium tracking-wide text-ink-muted uppercase">
59+
{eyebrow}
60+
</p>
61+
)
62+
}
63+
<Heading
64+
level={2}
65+
class="gradient-phase bg-clip-text pb-2 text-balance text-transparent"
66+
>
67+
{heading}
68+
</Heading>
69+
{
70+
body && (
71+
<Text size="lg" class="mt-4 text-ink-soft">
72+
{body}
73+
</Text>
74+
)
75+
}
76+
</div>
77+
</div>
78+
79+
<ul class="sr-only">
80+
{skills.map((s) => <li>{s.name}</li>)}
81+
</ul>
82+
</div>
83+
84+
<style is:global>
85+
.skills-cloud-wrap {
86+
position: relative;
87+
width: 100%;
88+
aspect-ratio: 1 / 1;
89+
max-height: 32rem;
90+
}
91+
92+
.skills-cloud {
93+
position: absolute;
94+
inset: 0;
95+
perspective: 1000px;
96+
transform-style: preserve-3d;
97+
--mx: 0px;
98+
--my: 0px;
99+
--sy: 0px;
100+
}
101+
102+
.skills-cloud__icon {
103+
position: absolute;
104+
width: var(--size, 40px);
105+
height: var(--size, 40px);
106+
margin-left: calc(var(--size, 40px) * -0.5);
107+
margin-top: calc(var(--size, 40px) * -0.5);
108+
color: var(--color-ink-soft, currentColor);
109+
transform: translate3d(
110+
calc((var(--mx) + var(--sy)) * var(--depth, 1)),
111+
calc((var(--my) + var(--sy)) * var(--depth, 1)),
112+
var(--z, 0px)
113+
);
114+
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
115+
animation: skills-cloud-float var(--duration, 8s) ease-in-out
116+
calc(var(--delay, 0s) * -1) infinite;
117+
will-change: transform;
118+
}
119+
120+
.skills-cloud[data-returning='true'] .skills-cloud__icon {
121+
transition: transform 3s cubic-bezier(0.22, 1, 0.36, 1) 400ms;
122+
}
123+
124+
.skills-cloud[data-returning='entering'] .skills-cloud__icon {
125+
transition: transform 900ms cubic-bezier(0.22, 1, 0.36, 1);
126+
}
127+
128+
.skills-cloud__icon > svg {
129+
width: 100%;
130+
height: 100%;
131+
display: block;
132+
}
133+
134+
@keyframes skills-cloud-float {
135+
0%,
136+
100% {
137+
translate: 0 -3px;
138+
}
139+
50% {
140+
translate: 0 3px;
141+
}
142+
}
143+
144+
@media (prefers-reduced-motion: reduce) {
145+
.skills-cloud__icon {
146+
animation: none;
147+
transition: none;
148+
transform: translate3d(0, 0, var(--z, 0px));
149+
}
150+
}
151+
</style>

src/content/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ const pagesCollection = defineCollection({
155155
title: z.string().optional(),
156156
description: z.string().optional(),
157157
}),
158+
SectionCommonSchema.extend({
159+
type: z.literal('skillsCloud'),
160+
eyebrow: z.string().optional(),
161+
heading: z.string().optional(),
162+
body: z.string().optional(),
163+
}),
158164
SectionCommonSchema.extend({
159165
type: z.literal('organisationsRoll'),
160166
title: z.string().optional(),

src/content/pages/home.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ title: Draftlab
22
status: published
33
description: Creative project design for social good
44
sections:
5-
- type: framework
6-
title: How we work
7-
description: "The framework below maps what we do across two dimensions: the phase of your project, and the way we show up."
8-
showProjects: true
5+
- type: skillsCloud
6+
# - type: framework
7+
# title: How we work
8+
# description: "The framework below maps what we do across two dimensions: the phase of your project, and the way we show up."
9+
# showProjects: true
910
- type: richText
1011
content: >-
1112
Projects rarely move in a straight line, and neither do we. You might engage us at any phase, for any modality, or across several at once. This framework describes the shape of our work.

0 commit comments

Comments
 (0)